talking-stick 0.1.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -43
- package/dist/cli/event-stream.js +124 -0
- package/dist/cli/install-commands.js +76 -36
- package/dist/cli/msg-commands.js +81 -0
- package/dist/cli/output.js +5 -3
- package/dist/cli/registry.js +24 -33
- package/dist/cli/room-commands.js +13 -2
- package/dist/cli/startup-maintenance.js +27 -1
- package/dist/cli.js +2 -2
- package/dist/commands.js +15 -0
- package/dist/config.js +4 -1
- package/dist/db.js +7 -0
- package/dist/identity.js +4 -4
- package/dist/index.js +2 -2
- package/dist/install-audit.js +21 -0
- package/dist/install-migration.js +84 -0
- package/dist/install.js +0 -69
- package/dist/service.js +161 -4
- package/dist/update-migration.js +135 -0
- package/docs/plans/2026-05-04-diff-walker-design.md +585 -0
- package/docs/plans/2026-05-05-cli-only-coordination.md +224 -0
- package/docs/plans/out-of-band-signaling-implementation.md +854 -0
- package/docs/plans/out-of-band-signaling.md +255 -176
- package/docs/receive-consumer-contract.md +32 -0
- package/docs/releases/0.2.0.md +85 -0
- package/docs/releases/0.3.0.md +77 -0
- package/docs/talking-stick-plan.md +3 -2
- package/package.json +4 -3
- package/scripts/postinstall-mcp-cleanup.cjs +25 -0
- package/skills/talking-stick/SKILL.md +131 -88
- package/dist/mcp-server.js +0 -212
- package/dist/server.js +0 -3
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Talking Stick
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A CLI coordination tool 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.
|
|
5
|
+
**Version:** 0.3.0. Multi-process-safe (SQLite WAL), liveness-aware, no daemon. Supports Claude Code, Codex CLI, Gemini CLI, and OpenCode out of the box. Two agents in the same room can also chat out-of-band — without passing the stick — via `tt msg send/recv`.
|
|
6
6
|
|
|
7
7
|
## Quickstart
|
|
8
8
|
|
|
@@ -14,13 +14,13 @@ Three steps, then you're coordinating two agents in the same repo.
|
|
|
14
14
|
npm i -g talking-stick
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
### 2.
|
|
17
|
+
### 2. Install the skill in every harness
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
20
|
tt install --all
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
Restart any harness that was already running so it loads the
|
|
23
|
+
Restart any harness that was already running so it loads the updated skill. The skill teaches agents to coordinate by running `tt` CLI commands from the workspace.
|
|
24
24
|
|
|
25
25
|
### 3. Try it: two agents, one repo
|
|
26
26
|
|
|
@@ -46,7 +46,7 @@ That's the whole workflow. They negotiate turns automatically, hand off structur
|
|
|
46
46
|
|
|
47
47
|
| Method | Command | Notes |
|
|
48
48
|
|---|---|---|
|
|
49
|
-
| **From npm** | `npm i -g talking-stick` | Published as `0.
|
|
49
|
+
| **From npm** | `npm i -g talking-stick` | Published as `0.3.0`. Requires Node ≥ 22. |
|
|
50
50
|
| **From GitHub** | `npm i -g github:mostlydev/talking-stick` | Tracks the `master` branch; builds on install via the `prepare` hook. |
|
|
51
51
|
| **From source** | `git clone … && npm install && npm link` | For contributors. |
|
|
52
52
|
|
|
@@ -76,7 +76,7 @@ Uses the right npm/pnpm/yarn by default:
|
|
|
76
76
|
tt self-update
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
`tt self-update` also removes stale Talking Stick MCP registrations left by older installs. The first normal `tt` invocation after a package-version change runs the same cleanup if the package manager skipped lifecycle scripts.
|
|
80
80
|
|
|
81
81
|
### Remove
|
|
82
82
|
|
|
@@ -86,29 +86,26 @@ tt uninstall --all
|
|
|
86
86
|
|
|
87
87
|
## What it gives your agent
|
|
88
88
|
|
|
89
|
-
Once installed, each agent harness
|
|
89
|
+
Once installed, each agent harness has a skill that tells it to coordinate through the `tt` CLI:
|
|
90
90
|
|
|
91
91
|
```
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
92
|
+
tt list — which rooms exist under a path
|
|
93
|
+
tt join — join the room for this workspace
|
|
94
|
+
tt leave — explicitly leave a room; deletes it when no active members remain
|
|
95
|
+
tt wait — block until the stick is available, with takeover signals
|
|
96
|
+
tt release — normal handoff to the next fair waiter, with structured Handoff
|
|
97
|
+
tt assign — explicit handoff to a named agent
|
|
98
|
+
tt take — deliberate claim when the prior holder is gone/stuck
|
|
99
|
+
tt kick — evict an idle member whose process is gone
|
|
100
|
+
tt state — authoritative state projection
|
|
101
|
+
tt events — audit log and long-poll stream of turn transitions/messages
|
|
102
|
+
tt notes add/list — durable async observations for the room
|
|
103
|
+
tt msg send/recv — out-of-band chat into the room event log
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
A workspace maps to a room — usually the `git` root or nearest project marker — so two agents `cd`'d anywhere under the same repo join the same room automatically.
|
|
107
107
|
|
|
108
|
-
The skill
|
|
109
|
-
|
|
110
|
-
- MCP gives the harness the coordination surface
|
|
111
|
-
- the global skill tells the model when to join, wait, heartbeat, take over, and hand off
|
|
108
|
+
The global skill tells the model when to join, wait, verify its guardian, take over, leave notes, send messages, and hand off.
|
|
112
109
|
|
|
113
110
|
## Non-owner notes
|
|
114
111
|
|
|
@@ -118,26 +115,37 @@ While you wait your turn you may still need to flag something to the current own
|
|
|
118
115
|
- `list_notes` returns notes for the room; readers can paginate with `after_note_id` and opt into resolved entries with `include_resolved`.
|
|
119
116
|
- Notes are for observations and pointers, not for coordinating shared edits. Shared workspace changes still require holding the stick.
|
|
120
117
|
|
|
121
|
-
##
|
|
118
|
+
## Out-of-band messaging
|
|
122
119
|
|
|
123
|
-
|
|
120
|
+
The stick guarantees single-writer authority over shared workspace state. It is **not** a chat protocol. When two agents need to talk — design questions, "are you about to break X?", live coordination — use messages instead of churning the stick.
|
|
124
121
|
|
|
125
|
-
|
|
122
|
+
```bash
|
|
123
|
+
tt msg send <recipient|room> "<body>" [--interrupt] [--stdin]
|
|
124
|
+
tt msg recv [--wait|--follow] [--from agent] [--after N] [--target self|any|agent]
|
|
125
|
+
tt events --wait|--follow [--event TYPE[,TYPE]] [--target self|any|agent]
|
|
126
|
+
```
|
|
126
127
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
128
|
+
- `<recipient>` is a full `agent_id`, an unambiguous active display name (`codex`, `claude`), or the literal `room` for broadcast.
|
|
129
|
+
- `--interrupt` marks the message time-sensitive; receivers decide whether to act on it now.
|
|
130
|
+
- `tt msg recv --follow` is a long-running tail (one JSON line per event) suited to harnesses that can monitor child stdout (Claude Code Monitor, terminals).
|
|
131
|
+
- `tt msg recv --wait` exits on the next matching batch — ideal for harnesses that can launch a background command and notice when it completes; restart with `--after <last_event_seq>` to resume.
|
|
132
|
+
- `tt events --wait` and `tt events --follow` default to `--target self`; pass `--target any` only for audit/debug views.
|
|
133
|
+
- `wait_for_events` is observer-safe: it never mutates room state, so non-holders can use it freely without disturbing turn-fairness bookkeeping.
|
|
134
|
+
- Event receive does not grant the stick. Agents must still use `tt wait` for ownership and verify the returned guardian before editing shared files.
|
|
133
135
|
|
|
134
|
-
|
|
136
|
+
**When to message vs note vs handoff.**
|
|
135
137
|
|
|
136
|
-
|
|
138
|
+
- **Message** — conversational, ephemeral, between live processes. Six round-trips of "what about line 84?" cost about as much as one structured handoff and zero stick churn.
|
|
139
|
+
- **Note** (`tt notes add`) — durable, resolvable artifacts. Leave a note when the next holder should consider something at handoff, or when the observation should outlive the conversation.
|
|
140
|
+
- **Handoff** (`release_stick` / `pass_stick`) — transfer of work. Messages do not replace handoffs; they live alongside them.
|
|
137
141
|
|
|
138
|
-
|
|
142
|
+
**`to_agent_id` is routing, not ACL.** Any room member can read any message via `get_room_events` or `tt events --follow --target any`. Messages are not private. They also do not grant the stick — a non-holder paging the holder gets attention, not write authority.
|
|
139
143
|
|
|
140
|
-
|
|
144
|
+
For harnesses that only notice completed subprocesses, run `tt events --wait --after <cursor> --json` as a wake process alongside the normal `tt wait --json` loop. A message, pass, release, or assignment event should make the agent read/reply/retry `tt wait`; it is not permission to mutate the workspace.
|
|
145
|
+
|
|
146
|
+
## How installation works per harness
|
|
147
|
+
|
|
148
|
+
`tt install` installs or refreshes the bundled `talking-stick` skill. It does not add MCP servers. During install, uninstall, package update, and first run after an installed package version changes, `tt` removes stale MCP registrations written by older Talking Stick releases.
|
|
141
149
|
|
|
142
150
|
- Claude Code: copied or linked into `~/.claude/skills/talking-stick`
|
|
143
151
|
- Codex: copied or linked into `~/.codex/skills/talking-stick`
|
|
@@ -146,6 +154,8 @@ Talking Stick also ships with a portable `talking-stick` skill:
|
|
|
146
154
|
|
|
147
155
|
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.
|
|
148
156
|
|
|
157
|
+
Stale MCP cleanup is strict for OpenCode JSON entries: it removes only the canonical `mcp.talking-stick` value with `["tt", "mcp"]` and leaves hand-edited entries alone. Claude Code, Codex, and Gemini cleanup uses their own `mcp remove` commands when the old server name exists. Every cleanup run appends JSONL audit entries to `${TALKING_STICK_DATA_DIR}/update-migrations.log`.
|
|
158
|
+
|
|
149
159
|
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.
|
|
150
160
|
|
|
151
161
|
## Human CLI
|
|
@@ -157,10 +167,12 @@ tt whoami [--explain] # show the resolved C
|
|
|
157
167
|
tt list [path] # list rooms
|
|
158
168
|
tt join [path] [--force-new] # join the room for path
|
|
159
169
|
tt leave [path] # leave the room for path
|
|
160
|
-
tt wait [path] [--timeout
|
|
170
|
+
tt wait [path] [--timeout 110s] # block until your turn
|
|
161
171
|
tt try [path] # non-blocking claim attempt
|
|
162
172
|
tt state [path] # full room state
|
|
163
|
-
tt events [path] [--after N] [--limit N]
|
|
173
|
+
tt events [path] [--after N] [--limit N] [--wait|--follow] [--event TYPE[,TYPE]] [--target self|any|agent] # room event log; --wait/--follow long-polls
|
|
174
|
+
tt msg send <recipient|room> <body...> [--interrupt] [--stdin] [--path DIR] # send an OOB message
|
|
175
|
+
tt msg recv [--wait|--follow] [--from agent] [--after N] [--target self|any|agent] [--path DIR] # receive OOB messages
|
|
164
176
|
tt release [path] --status TEXT --next-action TEXT # normal handoff
|
|
165
177
|
tt pass [path] --status TEXT --next-action TEXT # pass/end your turn
|
|
166
178
|
tt assign <target|next> [path] --status TEXT --next-action TEXT # explicit handoff
|
|
@@ -168,13 +180,14 @@ tt take [path] [--reason TEXT] # human-friendly take/
|
|
|
168
180
|
tt takeover [path] [--reason TEXT] # alias for take
|
|
169
181
|
tt notes add <body> [--turn N] [--path DIR] [--stdin] # leave an async note
|
|
170
182
|
tt notes list [--all] [--after ID] [--limit N] [--path DIR] # read notes
|
|
171
|
-
tt
|
|
172
|
-
tt
|
|
173
|
-
tt uninstall <harness...> | --all [--print] # remove MCP server and skill
|
|
183
|
+
tt install <harness...> | --all [--print] [--copy] [--link] # install skill and clean stale MCP entries
|
|
184
|
+
tt uninstall <harness...> | --all [--print] # remove skill and stale MCP entries
|
|
174
185
|
tt self-update [--print] [--manager npm|pnpm|yarn|bun] # update to the latest published tt
|
|
175
186
|
```
|
|
176
187
|
|
|
177
|
-
`
|
|
188
|
+
`[path]` defaults to the current working directory. Omit it for normal in-repo coordination; pass it only when you intentionally want a different or nested room.
|
|
189
|
+
|
|
190
|
+
`tt self-update` detects how `tt` was installed (npm / pnpm / yarn / bun, including npm-via-Homebrew/mise/asdf/nvm), runs the right global-update command, then removes stale MCP registrations from older Talking Stick installs. Pass `--print` to see the inferred command without running it; pass `--manager` to override detection. Running `tt self-update` from a development checkout (where `tt` resolves outside `node_modules/talking-stick`) refuses and tells you to `git pull && npm install && npm run build` instead.
|
|
178
191
|
|
|
179
192
|
Human CLI commands use a stable identity like `human:<username>`. When `tt wait`, `tt take`, or `tt takeover` wins the turn, a small background guardian keeps the lease alive on your behalf until you release, pass, or assign it. Human CLI `take` intentionally works without a required reason so an operator can step into a stuck room quickly; harness-aware CLI takeovers still require `--reason` unless the command includes `--operator-requested`.
|
|
180
193
|
|
|
@@ -202,7 +215,7 @@ Use `tt whoami --explain` to see which identity path the CLI chose.
|
|
|
202
215
|
- **Fencing tokens.** `lease_id` + `turn_id` make stale writes impossible — an agent who lost their turn cannot commit anything under the room's name.
|
|
203
216
|
- **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.
|
|
204
217
|
- **Multi-process safe.** Shared SQLite with WAL mode, `BEGIN IMMEDIATE` writes, 250 ms polling for `wait_for_turn`. No daemon required.
|
|
205
|
-
- **Per-call identity derivation.**
|
|
218
|
+
- **Per-call identity derivation.** Harness-launched CLI calls derive identity from harness environment or ancestry. Human CLI callers get a stable `human:<username>` identity.
|
|
206
219
|
|
|
207
220
|
## Storage
|
|
208
221
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { ProtocolError } from "../errors.js";
|
|
2
|
+
import { getStringOption, parseOptionalInteger, parseWaitTimeout } from "./parser.js";
|
|
3
|
+
import { shouldUseJson } from "./output.js";
|
|
4
|
+
export async function runEventStream(runtime, parsed, identity, roomId, options) {
|
|
5
|
+
const follow = parsed.options.has("follow");
|
|
6
|
+
const wait = parsed.options.has("wait");
|
|
7
|
+
if (follow && wait) {
|
|
8
|
+
throw new Error("Pass only one of --wait or --follow.");
|
|
9
|
+
}
|
|
10
|
+
const tailMode = follow || wait || options.force_tail_cursor;
|
|
11
|
+
const explicitAfter = parseOptionalInteger(parsed, "after");
|
|
12
|
+
const afterEventSeq = explicitAfter ??
|
|
13
|
+
(tailMode ? runtime.commands.getLatestEventSeq({ room_id: roomId }) : 0);
|
|
14
|
+
const targetAgentId = resolveTargetFilter(runtime, identity, roomId, getStringOption(parsed, "target") ?? options.default_target);
|
|
15
|
+
const fromAgentId = resolveOptionalAgentSelector(runtime, identity, roomId, getStringOption(parsed, "from"));
|
|
16
|
+
const waitInput = {
|
|
17
|
+
agent_id: identity.agent_id,
|
|
18
|
+
room_id: roomId,
|
|
19
|
+
after_event_seq: afterEventSeq,
|
|
20
|
+
event_type: options.event_type,
|
|
21
|
+
target_agent_id: targetAgentId,
|
|
22
|
+
from_agent_id: fromAgentId,
|
|
23
|
+
max_wait_ms: follow || wait ? parseWaitTimeout(parsed) : 0
|
|
24
|
+
};
|
|
25
|
+
if (!follow) {
|
|
26
|
+
const result = await runtime.commands.waitForEvents(waitInput);
|
|
27
|
+
writeEventLines(parsed, result.events);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
await followEvents(runtime, parsed, waitInput);
|
|
31
|
+
}
|
|
32
|
+
export function resolveOptionalAgentSelector(runtime, identity, roomId, raw) {
|
|
33
|
+
if (!raw) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return resolveAgentSelector(runtime, identity, roomId, raw);
|
|
37
|
+
}
|
|
38
|
+
export function resolveAgentSelector(runtime, identity, roomId, raw) {
|
|
39
|
+
const members = runtime.commands.getRoomState({
|
|
40
|
+
room_id: roomId,
|
|
41
|
+
agent_id: identity.agent_id
|
|
42
|
+
}).members;
|
|
43
|
+
const exact = members.find((member) => member.agent_id === raw);
|
|
44
|
+
if (exact) {
|
|
45
|
+
return exact.agent_id;
|
|
46
|
+
}
|
|
47
|
+
const candidates = members.filter((member) => member.status === "active" && member.display_name === raw);
|
|
48
|
+
if (candidates.length === 1) {
|
|
49
|
+
return candidates[0].agent_id;
|
|
50
|
+
}
|
|
51
|
+
if (candidates.length > 1) {
|
|
52
|
+
throw new ProtocolError("ambiguous_recipient", `Multiple active room members match '${raw}'.`, { candidates: candidates.map((member) => member.agent_id) });
|
|
53
|
+
}
|
|
54
|
+
throw new ProtocolError("unknown_recipient", `No active room member matches '${raw}'.`);
|
|
55
|
+
}
|
|
56
|
+
export function parseEventTypeFilter(value) {
|
|
57
|
+
if (!value) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const values = value
|
|
61
|
+
.split(",")
|
|
62
|
+
.map((item) => item.trim())
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
if (values.length === 0) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
return values.length === 1 ? values[0] : values;
|
|
68
|
+
}
|
|
69
|
+
export function writeEventLines(parsed, events) {
|
|
70
|
+
for (const event of events) {
|
|
71
|
+
const line = shouldUseJson(parsed)
|
|
72
|
+
? JSON.stringify(event)
|
|
73
|
+
: formatEventLine(event);
|
|
74
|
+
process.stdout.write(`${line}\n`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function formatEventLine(event) {
|
|
78
|
+
if (event.event_type === "message_sent") {
|
|
79
|
+
const target = event.to_agent_id ?? "room";
|
|
80
|
+
const hint = event.payload?.delivery_hint === "interrupt" ? " [interrupt]" : "";
|
|
81
|
+
return `[${event.created_at}] ${event.from_agent_id ?? "-"} -> ${target}${hint}: ${event.payload?.body ?? ""}`;
|
|
82
|
+
}
|
|
83
|
+
const reason = event.reason ? ` (${event.reason})` : "";
|
|
84
|
+
const target = event.from_agent_id && event.to_agent_id
|
|
85
|
+
? `${event.from_agent_id} -> ${event.to_agent_id}`
|
|
86
|
+
: event.to_agent_id
|
|
87
|
+
? `-> ${event.to_agent_id}`
|
|
88
|
+
: event.from_agent_id ?? "-";
|
|
89
|
+
return `[${event.created_at}] ${event.event_type} ${target}${reason}`;
|
|
90
|
+
}
|
|
91
|
+
async function followEvents(runtime, parsed, input) {
|
|
92
|
+
let cursor = input.after_event_seq ?? 0;
|
|
93
|
+
let shouldExit = false;
|
|
94
|
+
const markExit = () => {
|
|
95
|
+
shouldExit = true;
|
|
96
|
+
};
|
|
97
|
+
process.once("SIGTERM", markExit);
|
|
98
|
+
process.once("SIGHUP", markExit);
|
|
99
|
+
process.once("SIGINT", markExit);
|
|
100
|
+
try {
|
|
101
|
+
while (!shouldExit) {
|
|
102
|
+
const result = await runtime.commands.waitForEvents({
|
|
103
|
+
...input,
|
|
104
|
+
after_event_seq: cursor
|
|
105
|
+
});
|
|
106
|
+
if (result.events.length > 0) {
|
|
107
|
+
writeEventLines(parsed, result.events);
|
|
108
|
+
cursor = result.cursor_event_seq;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
process.off("SIGTERM", markExit);
|
|
114
|
+
process.off("SIGHUP", markExit);
|
|
115
|
+
process.off("SIGINT", markExit);
|
|
116
|
+
process.stderr.write(`cursor_event_seq=${cursor}\n`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function resolveTargetFilter(runtime, identity, roomId, raw) {
|
|
120
|
+
if (raw === "self" || raw === "any") {
|
|
121
|
+
return raw;
|
|
122
|
+
}
|
|
123
|
+
return resolveAgentSelector(runtime, identity, roomId, raw);
|
|
124
|
+
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { SUPPORTED_HARNESSES, detectHarness, parseHarnessList,
|
|
2
|
+
import { SUPPORTED_HARNESSES, detectHarness, parseHarnessList, planUninstall, runAction } from "../install.js";
|
|
3
3
|
import { planSkillInstall, planSkillUninstall } from "../skill-install.js";
|
|
4
|
+
import { resolveDataDir } from "../config.js";
|
|
5
|
+
import { FileAuditLog, defaultAuditLogPath } from "../install-audit.js";
|
|
6
|
+
import { removeStaleMcpRegistrations } from "../install-migration.js";
|
|
4
7
|
import { detectInstallSource, isPackageManager, planSelfUpdate, resolveCurrentBinaryPath } from "../self-update.js";
|
|
8
|
+
import { readPackageVersion, runStaleMcpCleanup } from "../update-migration.js";
|
|
5
9
|
import { getStringOption, hasOption, normalizeBooleanFlag } from "./parser.js";
|
|
6
10
|
export async function runInstallCommand(parsed) {
|
|
7
11
|
normalizeBooleanFlag(parsed, "print");
|
|
@@ -14,28 +18,33 @@ export async function runInstallCommand(parsed) {
|
|
|
14
18
|
skipMissing: true
|
|
15
19
|
};
|
|
16
20
|
if (dryRun) {
|
|
17
|
-
for (const action of
|
|
21
|
+
for (const action of planInstallActions(harnesses, installOptions)) {
|
|
22
|
+
printActionPlan(action);
|
|
23
|
+
}
|
|
24
|
+
for (const action of planCleanupActions(harnesses, installOptions)) {
|
|
18
25
|
printActionPlan(action);
|
|
19
26
|
}
|
|
20
27
|
return;
|
|
21
28
|
}
|
|
22
|
-
const results = (await Promise.all(harnesses.map((harness) =>
|
|
29
|
+
const results = (await Promise.all(harnesses.map((harness) => runSkillInstall(harness, installOptions)))).flat();
|
|
23
30
|
reportInstallResults(results, "install");
|
|
31
|
+
reportCleanupResults(await runCleanup(harnesses, "manual", installOptions), "install");
|
|
24
32
|
}
|
|
25
33
|
export async function runUninstallCommand(parsed) {
|
|
26
34
|
normalizeBooleanFlag(parsed, "print");
|
|
27
35
|
const harnesses = selectHarnesses(parsed);
|
|
28
36
|
const dryRun = hasOption(parsed, "print");
|
|
29
37
|
const installOptions = { skipMissing: true };
|
|
30
|
-
const actions =
|
|
38
|
+
const actions = planUninstallActions(harnesses, installOptions);
|
|
31
39
|
if (dryRun) {
|
|
32
40
|
for (const action of actions) {
|
|
33
41
|
printActionPlan(action);
|
|
34
42
|
}
|
|
35
43
|
return;
|
|
36
44
|
}
|
|
37
|
-
const results = (await Promise.all(harnesses.map((harness) =>
|
|
45
|
+
const results = (await Promise.all(harnesses.map((harness) => runSkillUninstall(harness, installOptions)))).flat();
|
|
38
46
|
reportInstallResults(results, "uninstall");
|
|
47
|
+
reportCleanupResults(await runCleanup(harnesses, "uninstall", installOptions), "uninstall");
|
|
39
48
|
}
|
|
40
49
|
export async function runInstallSkillCommand(parsed) {
|
|
41
50
|
normalizeBooleanFlag(parsed, "print");
|
|
@@ -85,6 +94,7 @@ export async function runSelfUpdateCommand(parsed, cliEntryUrl) {
|
|
|
85
94
|
const binaryPath = resolveCurrentBinaryPath(cliEntryUrl);
|
|
86
95
|
source = detectInstallSource({ binaryPath });
|
|
87
96
|
}
|
|
97
|
+
const packageVersionFrom = readPackageVersion(cliEntryUrl);
|
|
88
98
|
const plan = planSelfUpdate(source);
|
|
89
99
|
if (!plan) {
|
|
90
100
|
if (source === "dev") {
|
|
@@ -98,7 +108,29 @@ export async function runSelfUpdateCommand(parsed, cliEntryUrl) {
|
|
|
98
108
|
}
|
|
99
109
|
process.stdout.write(`Updating via: ${plan.description}\n`);
|
|
100
110
|
await runInheritIo(plan.command, plan.args);
|
|
101
|
-
|
|
111
|
+
const packageVersionTo = readPackageVersion(cliEntryUrl);
|
|
112
|
+
const cleanup = await runStaleMcpCleanup({
|
|
113
|
+
harnesses: "all",
|
|
114
|
+
reason: "update",
|
|
115
|
+
packageVersionFrom,
|
|
116
|
+
packageVersionTo,
|
|
117
|
+
installOptions: { skipMissing: true }
|
|
118
|
+
});
|
|
119
|
+
reportCleanupResults(cleanup.results, "self-update");
|
|
120
|
+
process.stdout.write("Done. Restart any long-running harness sessions to pick up the new tt.\n");
|
|
121
|
+
}
|
|
122
|
+
export async function runMcpMigrationCommand(parsed) {
|
|
123
|
+
normalizeBooleanFlag(parsed, "quiet");
|
|
124
|
+
const reason = parseAuditReason(getStringOption(parsed, "reason") ?? "manual");
|
|
125
|
+
const quiet = hasOption(parsed, "quiet");
|
|
126
|
+
const cleanup = await runStaleMcpCleanup({
|
|
127
|
+
harnesses: "all",
|
|
128
|
+
reason,
|
|
129
|
+
installOptions: { skipMissing: true }
|
|
130
|
+
});
|
|
131
|
+
if (!quiet) {
|
|
132
|
+
reportCleanupResults(cleanup.results, "self-update");
|
|
133
|
+
}
|
|
102
134
|
}
|
|
103
135
|
function resolveSkillInstallLinkMode(parsed) {
|
|
104
136
|
const wantsCopy = hasOption(parsed, "copy");
|
|
@@ -111,48 +143,39 @@ function resolveSkillInstallLinkMode(parsed) {
|
|
|
111
143
|
}
|
|
112
144
|
return true;
|
|
113
145
|
}
|
|
114
|
-
function
|
|
115
|
-
return harnesses.
|
|
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
|
-
});
|
|
146
|
+
function planInstallActions(harnesses, installOptions) {
|
|
147
|
+
return harnesses.map((harness) => planSkillInstall(harness, installOptions));
|
|
130
148
|
}
|
|
131
|
-
function
|
|
149
|
+
function planUninstallActions(harnesses, installOptions) {
|
|
132
150
|
return harnesses.flatMap((harness) => [
|
|
133
|
-
planUninstall(harness, installOptions),
|
|
134
151
|
planSkillUninstall(harness, {
|
|
135
152
|
...installOptions,
|
|
136
153
|
skipMissing: false
|
|
137
|
-
})
|
|
154
|
+
}),
|
|
155
|
+
planUninstall(harness, installOptions)
|
|
138
156
|
]);
|
|
139
157
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return [mcpResult];
|
|
145
|
-
}
|
|
158
|
+
function planCleanupActions(harnesses, installOptions) {
|
|
159
|
+
return harnesses.map((harness) => planUninstall(harness, installOptions));
|
|
160
|
+
}
|
|
161
|
+
async function runSkillInstall(harness, installOptions) {
|
|
146
162
|
const skillAction = planSkillInstall(harness, installOptions);
|
|
147
163
|
const skillResult = await runAction(skillAction, installOptions);
|
|
148
|
-
return [
|
|
164
|
+
return [skillResult];
|
|
149
165
|
}
|
|
150
|
-
async function
|
|
151
|
-
const mcpAction = planUninstall(harness, installOptions);
|
|
152
|
-
const mcpResult = await runAction(mcpAction, installOptions);
|
|
166
|
+
async function runSkillUninstall(harness, installOptions) {
|
|
153
167
|
const skillAction = planSkillUninstall(harness, installOptions);
|
|
154
168
|
const skillResult = await runAction(skillAction, installOptions);
|
|
155
|
-
return [
|
|
169
|
+
return [skillResult];
|
|
170
|
+
}
|
|
171
|
+
async function runCleanup(harnesses, reason, installOptions) {
|
|
172
|
+
const dataDir = resolveDataDir();
|
|
173
|
+
return removeStaleMcpRegistrations({
|
|
174
|
+
harnesses,
|
|
175
|
+
reason,
|
|
176
|
+
audit: new FileAuditLog(defaultAuditLogPath(dataDir)),
|
|
177
|
+
installOptions
|
|
178
|
+
});
|
|
156
179
|
}
|
|
157
180
|
function selectHarnesses(parsed) {
|
|
158
181
|
if (hasOption(parsed, "all")) {
|
|
@@ -199,6 +222,23 @@ function reportInstallResults(results, mode) {
|
|
|
199
222
|
throw new Error(`${mode} completed with failures.`);
|
|
200
223
|
}
|
|
201
224
|
}
|
|
225
|
+
function reportCleanupResults(results, mode) {
|
|
226
|
+
let anyFailed = false;
|
|
227
|
+
for (const result of results) {
|
|
228
|
+
process.stdout.write(`[${result.harness}] mcp-cleanup ${result.action}: ${result.message}\n`);
|
|
229
|
+
if (result.action === "failed")
|
|
230
|
+
anyFailed = true;
|
|
231
|
+
}
|
|
232
|
+
if (anyFailed) {
|
|
233
|
+
throw new Error(`${mode} completed with MCP cleanup failures.`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
202
236
|
function formatInstallStatus(status) {
|
|
203
237
|
return status.replaceAll("_", "-");
|
|
204
238
|
}
|
|
239
|
+
function parseAuditReason(value) {
|
|
240
|
+
if (value === "update" || value === "first-run" || value === "uninstall" || value === "manual") {
|
|
241
|
+
return value;
|
|
242
|
+
}
|
|
243
|
+
throw new Error(`--reason must be one of update | first-run | uninstall | manual (got ${value}).`);
|
|
244
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { deriveCliIdentity } from "./identity.js";
|
|
2
|
+
import { readAllStdin } from "./handoff.js";
|
|
3
|
+
import { hasOption } from "./parser.js";
|
|
4
|
+
import { printResult } from "./output.js";
|
|
5
|
+
import { resolveAgentSelector, runEventStream } from "./event-stream.js";
|
|
6
|
+
import { resolveSessionForNotes } from "./session.js";
|
|
7
|
+
export async function handleMsgCommand(runtime, parsed) {
|
|
8
|
+
const [subcommand, ...rest] = parsed.positionals;
|
|
9
|
+
if (!subcommand) {
|
|
10
|
+
throw new Error("Usage: tt msg <send|recv> [...]. See `tt --help` for details.");
|
|
11
|
+
}
|
|
12
|
+
const subParsed = {
|
|
13
|
+
name: `msg ${subcommand}`,
|
|
14
|
+
positionals: rest,
|
|
15
|
+
options: parsed.options
|
|
16
|
+
};
|
|
17
|
+
switch (subcommand) {
|
|
18
|
+
case "send":
|
|
19
|
+
await handleMsgSendCommand(runtime, subParsed);
|
|
20
|
+
return;
|
|
21
|
+
case "recv":
|
|
22
|
+
await handleMsgRecvCommand(runtime, subParsed);
|
|
23
|
+
return;
|
|
24
|
+
default:
|
|
25
|
+
throw new Error(`Unknown msg subcommand: ${subcommand}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function handleMsgSendCommand(runtime, parsed) {
|
|
29
|
+
const identity = deriveCliIdentity(parsed);
|
|
30
|
+
const session = resolveSessionForNotes(runtime, parsed, identity);
|
|
31
|
+
const usesRoomFlag = hasOption(parsed, "room");
|
|
32
|
+
repairBooleanFlag(parsed, "room", 0);
|
|
33
|
+
repairBooleanFlag(parsed, "interrupt", usesRoomFlag ? 0 : 1);
|
|
34
|
+
const recipientSelector = usesRoomFlag ? "room" : parsed.positionals[0];
|
|
35
|
+
if (!recipientSelector) {
|
|
36
|
+
throw new Error("Usage: tt msg send <recipient|room> <body...> [--interrupt] [--stdin].");
|
|
37
|
+
}
|
|
38
|
+
const bodyStart = usesRoomFlag ? 0 : 1;
|
|
39
|
+
const positionalBody = parsed.positionals.slice(bodyStart).join(" ");
|
|
40
|
+
const body = positionalBody.length > 0
|
|
41
|
+
? positionalBody
|
|
42
|
+
: hasOption(parsed, "stdin")
|
|
43
|
+
? await readAllStdin()
|
|
44
|
+
: "";
|
|
45
|
+
if (body.length === 0) {
|
|
46
|
+
throw new Error("Message body is required (pass as a positional or use --stdin to read from stdin).");
|
|
47
|
+
}
|
|
48
|
+
const toAgentId = recipientSelector === "room"
|
|
49
|
+
? null
|
|
50
|
+
: resolveAgentSelector(runtime, identity, session.room_id, recipientSelector);
|
|
51
|
+
const result = runtime.commands.sendMessage(identity, {
|
|
52
|
+
room_id: session.room_id,
|
|
53
|
+
body,
|
|
54
|
+
to_agent_id: toAgentId,
|
|
55
|
+
delivery_hint: hasOption(parsed, "interrupt") ? "interrupt" : "normal"
|
|
56
|
+
});
|
|
57
|
+
printResult(parsed, result, () => {
|
|
58
|
+
const target = toAgentId ?? "room";
|
|
59
|
+
const hint = hasOption(parsed, "interrupt") ? " interrupt" : "";
|
|
60
|
+
return `Sent${hint} message ${shortEventId(result.event_id)} to ${target}.`;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
async function handleMsgRecvCommand(runtime, parsed) {
|
|
64
|
+
const identity = deriveCliIdentity(parsed);
|
|
65
|
+
const session = resolveSessionForNotes(runtime, parsed, identity);
|
|
66
|
+
await runEventStream(runtime, parsed, identity, session.room_id, {
|
|
67
|
+
event_type: "message_sent",
|
|
68
|
+
default_target: "self",
|
|
69
|
+
force_tail_cursor: false
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function repairBooleanFlag(parsed, key, insertAt) {
|
|
73
|
+
const value = parsed.options.get(key);
|
|
74
|
+
if (typeof value === "string") {
|
|
75
|
+
parsed.positionals.splice(insertAt, 0, value);
|
|
76
|
+
parsed.options.set(key, true);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function shortEventId(eventId) {
|
|
80
|
+
return eventId.slice(0, 8);
|
|
81
|
+
}
|
package/dist/cli/output.js
CHANGED
|
@@ -123,10 +123,12 @@ Commands:
|
|
|
123
123
|
tt join [path] [--force-new]
|
|
124
124
|
tt leave [path]
|
|
125
125
|
tt kick <agent_id> [path] [--reason TEXT] [--force]
|
|
126
|
-
tt wait [path] [--timeout
|
|
126
|
+
tt wait [path] [--timeout 110s]
|
|
127
127
|
tt try [path]
|
|
128
128
|
tt state [path]
|
|
129
|
-
tt events [path] [--after N] [--limit N]
|
|
129
|
+
tt events [path] [--after N] [--limit N] [--wait|--follow] [--event TYPE[,TYPE]] [--target self|any|agent]
|
|
130
|
+
tt msg send <recipient|room> <body...> [--interrupt] [--stdin] [--path DIR]
|
|
131
|
+
tt msg recv [--wait|--follow] [--from agent] [--after N] [--target self|any|agent] [--path DIR]
|
|
130
132
|
tt release [path] (--status TEXT --next-action TEXT | --stdin)
|
|
131
133
|
tt pass [path] (--status TEXT --next-action TEXT | --stdin)
|
|
132
134
|
tt assign <target|next> [path] (--status TEXT --next-action TEXT | --stdin)
|
|
@@ -134,7 +136,6 @@ Commands:
|
|
|
134
136
|
tt takeover [path] [--reason TEXT] [--operator-requested]
|
|
135
137
|
tt notes add <body> [--turn N] [--path DIR] [--stdin]
|
|
136
138
|
tt notes list [--all] [--after NOTE_ID] [--limit N] [--path DIR]
|
|
137
|
-
tt mcp
|
|
138
139
|
tt install <harness...> | --all [--print] [--copy] [--link]
|
|
139
140
|
tt uninstall <harness...> | --all [--print]
|
|
140
141
|
tt self-update [--print] [--manager npm|pnpm|yarn|bun]
|
|
@@ -142,6 +143,7 @@ Commands:
|
|
|
142
143
|
Harnesses: ${SUPPORTED_HARNESSES.join(", ")}
|
|
143
144
|
|
|
144
145
|
Common options:
|
|
146
|
+
[path] Defaults to the current working directory when omitted
|
|
145
147
|
--agent ID Override the default human identity
|
|
146
148
|
--json Force JSON output (also default when invoked from a harness)
|
|
147
149
|
--text Force human-readable text even when invoked from a harness
|