talking-stick 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/dist/cli/output.js +5 -2
- package/dist/cli/registry.js +2 -2
- package/dist/cli/turn-commands.js +5 -2
- package/dist/commands.js +2 -1
- package/dist/instructions.js +3 -1
- package/dist/service.js +24 -1
- package/docs/plans/2026-05-10-wait-park-mode.md +137 -0
- package/docs/releases/0.4.3.md +48 -0
- package/docs/releases/0.4.4.md +23 -0
- package/docs/talking-stick-plan.md +2 -0
- package/package.json +2 -1
- package/scripts/prepare-release.mjs +261 -0
- package/skills/talking-stick/SKILL.md +11 -2
package/README.md
CHANGED
|
@@ -93,6 +93,7 @@ tt list — which rooms exist under a path
|
|
|
93
93
|
tt join — join the room for this workspace
|
|
94
94
|
tt leave — explicitly leave a room; deletes it when no active members remain
|
|
95
95
|
tt wait — block until the stick is available, with takeover signals
|
|
96
|
+
tt wait --park — stay coordinated without auto-claiming idle rooms
|
|
96
97
|
tt release — normal handoff to the next fair waiter, with structured Handoff
|
|
97
98
|
tt assign — explicit handoff to a named agent
|
|
98
99
|
tt take — deliberate claim when the prior holder is gone/stuck
|
|
@@ -182,8 +183,8 @@ tt whoami [--explain] # show the resolved C
|
|
|
182
183
|
tt list [path] # list rooms
|
|
183
184
|
tt join [path] [--force-new] # join the room for path
|
|
184
185
|
tt leave [path] # leave the room for path
|
|
185
|
-
tt wait [path] [--timeout 110s]
|
|
186
|
-
tt try [path]
|
|
186
|
+
tt wait [path] [--timeout 110s] [--park] # block until your turn; --park disables idle auto-claim
|
|
187
|
+
tt try [path] [--park] # non-blocking claim attempt
|
|
187
188
|
tt state [path] # full room state
|
|
188
189
|
tt events [path] [--after N] [--limit N] [--wait|--follow] [--event TYPE[,TYPE]] [--target self|any|agent] # room event log; --wait/--follow long-polls
|
|
189
190
|
tt msg send <recipient|room> <body...> [--interrupt] [--stdin] [--path DIR] # send an OOB message
|
|
@@ -229,7 +230,7 @@ Use `tt whoami --explain` to see which identity path the CLI chose.
|
|
|
229
230
|
- **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.
|
|
230
231
|
- **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.
|
|
231
232
|
- **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.
|
|
232
|
-
- **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.
|
|
233
|
+
- **Ephemeral rooms.** `leave_room`/`tt leave` removes membership, rooms with no active members are physically deleted, and long-idle rooms with no recent activity or provably live member process are purged opportunistically on later invocations. The default idle retention is seven days.
|
|
233
234
|
- **Fencing tokens.** `lease_id` + `turn_id` make stale writes impossible — an agent who lost their turn cannot commit anything under the room's name.
|
|
234
235
|
- **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.
|
|
235
236
|
- **Multi-process safe.** Shared SQLite with WAL mode, `BEGIN IMMEDIATE` writes, 250 ms polling for `wait_for_turn`. No daemon required.
|
|
@@ -257,6 +258,11 @@ npm run build
|
|
|
257
258
|
|
|
258
259
|
See [`CHANGELOG.md`](CHANGELOG.md) for a per-version summary; full release notes live in [`docs/releases/`](docs/releases/).
|
|
259
260
|
|
|
261
|
+
When cutting a release, add entries under `CHANGELOG.md`'s `Unreleased` section,
|
|
262
|
+
then run `npm version <new-version>`. The version lifecycle script moves those
|
|
263
|
+
entries into the new version section, writes `docs/releases/<version>.md`, and
|
|
264
|
+
adds the GitHub release link before npm commits and tags the version.
|
|
265
|
+
|
|
260
266
|
## Read next
|
|
261
267
|
|
|
262
268
|
- [`docs/talking-stick-plan.md`](docs/talking-stick-plan.md) — full protocol, state transitions, persistence model, design rationale, and open questions.
|
package/dist/cli/output.js
CHANGED
|
@@ -60,6 +60,9 @@ export function formatWaitResult(result) {
|
|
|
60
60
|
: "";
|
|
61
61
|
return `Not your turn — turn ${result.turn_id ?? "?"} is reserved for ${result.reserved_for}${deadline}.`;
|
|
62
62
|
}
|
|
63
|
+
if (result.reason === "auto_claim_disabled") {
|
|
64
|
+
return "Parked — auto-claim disabled; idle room left untouched.";
|
|
65
|
+
}
|
|
63
66
|
return "Not your turn yet.";
|
|
64
67
|
}
|
|
65
68
|
case "closed":
|
|
@@ -123,8 +126,8 @@ Commands:
|
|
|
123
126
|
tt join [path] [--force-new]
|
|
124
127
|
tt leave [path]
|
|
125
128
|
tt kick <agent_id> [path] [--reason TEXT] [--force]
|
|
126
|
-
tt wait [path] [--timeout 110s]
|
|
127
|
-
tt try [path]
|
|
129
|
+
tt wait [path] [--timeout 110s] [--park]
|
|
130
|
+
tt try [path] [--park]
|
|
128
131
|
tt state [path]
|
|
129
132
|
tt events [path] [--after N] [--limit N] [--wait|--follow] [--event TYPE[,TYPE]] [--target self|any|agent]
|
|
130
133
|
tt msg send <recipient|room> <body...> [--interrupt] [--stdin] [--path DIR]
|
package/dist/cli/registry.js
CHANGED
|
@@ -137,7 +137,7 @@ export const COMMAND_REGISTRY = [
|
|
|
137
137
|
needsRuntime: true,
|
|
138
138
|
startupMaintenance: true,
|
|
139
139
|
internal: false,
|
|
140
|
-
usage: "tt wait [path] [--timeout 110s]",
|
|
140
|
+
usage: "tt wait [path] [--timeout 110s] [--park]",
|
|
141
141
|
description: "Wait until this agent can claim the stick.",
|
|
142
142
|
handler: ({ runtime, parsed, cliEntryUrl }) => handleWaitCommand(requireRuntime(runtime), parsed, false, cliEntryUrl)
|
|
143
143
|
},
|
|
@@ -146,7 +146,7 @@ export const COMMAND_REGISTRY = [
|
|
|
146
146
|
needsRuntime: true,
|
|
147
147
|
startupMaintenance: true,
|
|
148
148
|
internal: false,
|
|
149
|
-
usage: "tt try [path]",
|
|
149
|
+
usage: "tt try [path] [--park]",
|
|
150
150
|
description: "Check turn availability without waiting.",
|
|
151
151
|
handler: ({ runtime, parsed, cliEntryUrl }) => handleWaitCommand(requireRuntime(runtime), parsed, true, cliEntryUrl)
|
|
152
152
|
},
|
|
@@ -2,17 +2,20 @@ import { clearCliSessionLease, createSystemProcessInspector, findCliSessionByRoo
|
|
|
2
2
|
import { checkGuardianLiveness, spawnGuardian, stopGuardian } from "./guardian.js";
|
|
3
3
|
import { resolveHandoff } from "./handoff.js";
|
|
4
4
|
import { deriveCliIdentity, resolveTakeoverReason, shouldUseOperatorOverride } from "./identity.js";
|
|
5
|
-
import { parseWaitTimeout } from "./parser.js";
|
|
5
|
+
import { hasOption, normalizeBooleanFlag, parseWaitTimeout } from "./parser.js";
|
|
6
6
|
import { formatWaitResult, printResult } from "./output.js";
|
|
7
7
|
import { requireLeaseSession, upsertSessionFromJoin } from "./session.js";
|
|
8
8
|
export async function handleWaitCommand(runtime, parsed, isTry, cliEntryUrl) {
|
|
9
|
+
normalizeBooleanFlag(parsed, "park");
|
|
10
|
+
const park = hasOption(parsed, "park");
|
|
9
11
|
const contextPath = parsed.positionals[0] ?? process.cwd();
|
|
10
12
|
const identity = deriveCliIdentity(parsed);
|
|
11
13
|
const joined = runtime.commands.joinPath(identity, { context_path: contextPath });
|
|
12
14
|
upsertSessionFromJoin(identity, joined);
|
|
13
15
|
const waitResult = await runtime.commands.waitForTurn(identity, {
|
|
14
16
|
room_id: joined.room_id,
|
|
15
|
-
max_wait_ms: isTry ? 0 : parseWaitTimeout(parsed)
|
|
17
|
+
max_wait_ms: isTry ? 0 : parseWaitTimeout(parsed),
|
|
18
|
+
auto_claim: park ? false : undefined
|
|
16
19
|
});
|
|
17
20
|
if (waitResult.status === "your_turn") {
|
|
18
21
|
if (waitResult.reason === "already_owner") {
|
package/dist/commands.js
CHANGED
|
@@ -37,7 +37,8 @@ export class TalkingStickCommands {
|
|
|
37
37
|
return this.service.waitForTurn({
|
|
38
38
|
agent_id: identity.agent_id,
|
|
39
39
|
room_id: input.room_id,
|
|
40
|
-
max_wait_ms: input.max_wait_ms
|
|
40
|
+
max_wait_ms: input.max_wait_ms,
|
|
41
|
+
auto_claim: input.auto_claim
|
|
41
42
|
});
|
|
42
43
|
}
|
|
43
44
|
heartbeat(identity, input) {
|
package/dist/instructions.js
CHANGED
|
@@ -6,7 +6,9 @@ import { resolveContextPath } from "./path-resolution.js";
|
|
|
6
6
|
export const DEFAULT_MAX_INSTRUCTION_FILE_BYTES = 256 * 1024;
|
|
7
7
|
export const DEFAULT_INSTRUCTIONS_MARKDOWN = `# Talking Stick collaboration instructions
|
|
8
8
|
|
|
9
|
-
Keep using Talking Stick until the shared task is done. After releasing or handing off, re-enter the wait loop by default. Prefer continued action unless the task is complete or the operator explicitly redirects or stops the room. If you are the only active member of the room, stop polling after a clear handoff rather than churning release/reclaim turns.
|
|
9
|
+
Keep using Talking Stick until the shared task is done. After releasing or handing off, re-enter the wait loop by default. Prefer continued action unless the task is complete or the operator explicitly redirects or stops the room. If you are the only active member of the room, stop polling after a clear handoff rather than churning release/reclaim turns. If you have no expected work and are blocked on operator input or an external signal, use \`tt wait --park --json\` so you stay coordinated without auto-claiming idle turns.
|
|
10
|
+
|
|
11
|
+
On freshly invoked multi-agent tasks, give peers a short window to join before deciding you are alone. Use a normal wait timeout or spend about a minute on read-only repo orientation while other harnesses appear.
|
|
10
12
|
|
|
11
13
|
Use phase names in handoffs when they clarify the work: draft, adversarial review, convergence, implementation, implementation review, test review, and release. These phases are vocabulary, not protocol state.
|
|
12
14
|
|
package/dist/service.js
CHANGED
|
@@ -656,6 +656,19 @@ export class TalkingStickService {
|
|
|
656
656
|
};
|
|
657
657
|
}
|
|
658
658
|
if (!room.owner && !room.reserved_for) {
|
|
659
|
+
const autoClaim = input.auto_claim ?? true;
|
|
660
|
+
if (!autoClaim) {
|
|
661
|
+
return {
|
|
662
|
+
status: "not_yet",
|
|
663
|
+
room_state: inspection.state,
|
|
664
|
+
turn_id: room.turn_id,
|
|
665
|
+
current_owner: room.owner ?? undefined,
|
|
666
|
+
reserved_for: room.reserved_for ?? undefined,
|
|
667
|
+
lease_expires_at: room.lease_expires_at ?? undefined,
|
|
668
|
+
claim_expires_at: room.claim_expires_at ?? undefined,
|
|
669
|
+
reason: "auto_claim_disabled"
|
|
670
|
+
};
|
|
671
|
+
}
|
|
659
672
|
if (this.shouldDeferIdleClaim(room, input.agent_id, now)) {
|
|
660
673
|
return {
|
|
661
674
|
status: "not_yet",
|
|
@@ -1194,7 +1207,7 @@ export class TalkingStickService {
|
|
|
1194
1207
|
if (this.latestRoomActivityMs(room, members) > cutoffMs) {
|
|
1195
1208
|
continue;
|
|
1196
1209
|
}
|
|
1197
|
-
if (members.some((member) => this.
|
|
1210
|
+
if (members.some((member) => this.shouldRetainIdleRoom(member, now))) {
|
|
1198
1211
|
continue;
|
|
1199
1212
|
}
|
|
1200
1213
|
this.deleteRoom(room.room_id);
|
|
@@ -1226,6 +1239,16 @@ export class TalkingStickService {
|
|
|
1226
1239
|
}
|
|
1227
1240
|
return this.hasRecentPresence(member, now);
|
|
1228
1241
|
}
|
|
1242
|
+
shouldRetainIdleRoom(member, now) {
|
|
1243
|
+
const liveness = this.getMemberProcessLiveness(member);
|
|
1244
|
+
if (liveness === "alive") {
|
|
1245
|
+
return true;
|
|
1246
|
+
}
|
|
1247
|
+
if (liveness === "gone") {
|
|
1248
|
+
return false;
|
|
1249
|
+
}
|
|
1250
|
+
return this.hasRecentPresence(member, now);
|
|
1251
|
+
}
|
|
1229
1252
|
hasRecentPresence(member, now) {
|
|
1230
1253
|
return (now.getTime() - Date.parse(member.last_seen_at) <=
|
|
1231
1254
|
this.policy.presenceTtlMs);
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Park mode for `tt wait`
|
|
2
|
+
|
|
3
|
+
**Status:** Implemented in working tree (claude:b8175be6 + codex:0c293df7). Original checklist retained as design context.
|
|
4
|
+
|
|
5
|
+
**Origin:** Coordination churn observed during the guardian-contract session on 2026-05-10. After a no-work release, the just-released agent's next `tt wait` auto-claimed the idle room because the existing `shouldDeferIdleClaim` cooldown is gated on `hasOtherActiveRoomMember`, which was false when the other agent went briefly inactive. Sequence: claim → release → claim → release with no work in between (turns 247–248 in this room; turns 1205–1291 earlier with claude:b2c853ee). Independently surfaced from both sides; design draft in room note `722adc99-1f1e-4e83-a950-5176dce3ae1c`.
|
|
6
|
+
|
|
7
|
+
## Problem
|
|
8
|
+
|
|
9
|
+
`tt wait` is overloaded as two operations:
|
|
10
|
+
|
|
11
|
+
1. Wait for a turn that will be handed to me (reserved_for me, pass/assign to me, takeover).
|
|
12
|
+
2. Claim the room if it is idle (auto-claim).
|
|
13
|
+
|
|
14
|
+
The second operation is correct when the caller has work. When the caller has no work but is staying coordinated (waiting on operator input or other external signal), it produces the churn pattern. The existing `priorOwnerReleaseCooldownMs` heuristic helps but can't encode operator-wait intent — only the caller knows whether they have work.
|
|
15
|
+
|
|
16
|
+
## Design
|
|
17
|
+
|
|
18
|
+
Add a protocol-level opt-out for the idle auto-claim. The two operations split cleanly:
|
|
19
|
+
|
|
20
|
+
- **`tt wait`** (current behavior, unchanged): caller is willing to take the stick now. Auto-claim is on.
|
|
21
|
+
- **`tt wait --park`**: caller wants to stay coordinated but only act on explicit signals. Auto-claim is off. Already-owner, reserved-to-me, pass-to-me, and takeover-available still return `your_turn` / `takeover_available` normally.
|
|
22
|
+
|
|
23
|
+
**Protocol field:** `WaitForTurnInput.auto_claim?: boolean` (default `true`). The CLI flag `--park` sets `auto_claim: false`. Naming separation is deliberate — `auto_claim` is the precise protocol invariant; `--park` is the UX vocabulary.
|
|
24
|
+
|
|
25
|
+
**Filtered branches under `auto_claim: false`:**
|
|
26
|
+
|
|
27
|
+
- `!room.owner && !room.reserved_for` → `grantTurn` (service.ts:1107) is the **only** auto-claim path. Park returns `not_yet` with reason `auto_claim_disabled` here.
|
|
28
|
+
|
|
29
|
+
**Preserved branches:**
|
|
30
|
+
|
|
31
|
+
- already_owner (service.ts:1090) — true signal, return your_turn.
|
|
32
|
+
- reserved_for === caller (service.ts:1138) — explicit pass, return your_turn.
|
|
33
|
+
- recipient_gone / owner_gone / stale_owner / claim_timeout (service.ts:1122, 1146, 1162, 1173) — return takeover_available. Park = no automatic ownership, not blind recovery.
|
|
34
|
+
- closed (service.ts:1086) — unchanged.
|
|
35
|
+
- default not_yet (service.ts:1184) — unchanged.
|
|
36
|
+
|
|
37
|
+
**Fair routing:** parked agents stay eligible for `tt assign next`. Park opts out of automatic claim, not explicit routing.
|
|
38
|
+
|
|
39
|
+
**Cooldown:** `shouldDeferIdleClaim` is unchanged. Park is the explicit-intent mechanism; the cooldown is a heuristic for the no-park case. Tightening the cooldown to fire when alone would be a silent semantic change for plain `tt wait` callers and is the wrong tool.
|
|
40
|
+
|
|
41
|
+
## Implementation
|
|
42
|
+
|
|
43
|
+
### `src/service.ts`
|
|
44
|
+
|
|
45
|
+
Add `auto_claim?: boolean` to `WaitForTurnInput`. In `waitForTurnOnce` (line 1078), gate the idle branch:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
if (!room.owner && !room.reserved_for) {
|
|
49
|
+
const autoClaim = input.auto_claim ?? true;
|
|
50
|
+
if (!autoClaim) {
|
|
51
|
+
return {
|
|
52
|
+
status: "not_yet",
|
|
53
|
+
room_state: inspection.state,
|
|
54
|
+
turn_id: room.turn_id,
|
|
55
|
+
reason: "auto_claim_disabled"
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (this.shouldDeferIdleClaim(room, input.agent_id, now)) {
|
|
59
|
+
return { status: "not_yet", /* existing fields */ };
|
|
60
|
+
}
|
|
61
|
+
return this.grantTurn(room, input.agent_id, now);
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Add `auto_claim_disabled` to the `not_yet` reason union.
|
|
66
|
+
|
|
67
|
+
### MCP surface
|
|
68
|
+
|
|
69
|
+
No MCP schema change is needed in the current CLI-only implementation. The older `src/mcp-server.ts` surface no longer exists; `src/commands.ts` carries the command-level `auto_claim` field through to the service.
|
|
70
|
+
|
|
71
|
+
### `src/cli/parser.ts`
|
|
72
|
+
|
|
73
|
+
Use `normalizeBooleanFlag(parsed, "park")` in the wait/try handler so `--park` can appear before or after the optional path. The generic parser still consumes the next non-`--` token by default; normalization restores that consumed token as a positional for this boolean flag.
|
|
74
|
+
|
|
75
|
+
### `src/cli/turn-commands.ts`
|
|
76
|
+
|
|
77
|
+
In `handleWaitCommand`:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
normalizeBooleanFlag(parsed, "park");
|
|
81
|
+
const park = hasOption(parsed, "park");
|
|
82
|
+
const waitResult = await runtime.commands.waitForTurn(identity, {
|
|
83
|
+
room_id: joined.room_id,
|
|
84
|
+
max_wait_ms: isTry ? 0 : parseWaitTimeout(parsed),
|
|
85
|
+
auto_claim: park ? false : undefined
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Update `formatWaitResult` to print `Parked — auto-claim disabled; idle room left untouched.` when `status: "not_yet"` and `reason: "auto_claim_disabled"`.
|
|
90
|
+
|
|
91
|
+
### `skills/talking-stick/SKILL.md`
|
|
92
|
+
|
|
93
|
+
In §8 "After Release, Stay In The Loop", add after the "stop polling if only active member" rule:
|
|
94
|
+
|
|
95
|
+
> If you have no expected work and are blocked on operator input or external signal, use `tt wait --park` instead of `tt wait` to stay coordinated without claiming idle turns. Park returns `your_turn` only for explicit signals (reserved-to-me, pass/assign to me, takeover-available); it never auto-claims an idle room. Switch back to plain `tt wait` once you have work to do.
|
|
96
|
+
|
|
97
|
+
Add `tt wait --park` to the CLI list in §1.
|
|
98
|
+
|
|
99
|
+
### `README.md`
|
|
100
|
+
|
|
101
|
+
Add `--park` to the `tt wait` line in the CLI cheat sheet (~line 100). One-line description under it: "Stay coordinated without auto-claiming idle turns."
|
|
102
|
+
|
|
103
|
+
### `CHANGELOG.md`
|
|
104
|
+
|
|
105
|
+
Unreleased Added entry:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
- **`tt wait --park`.** New flag opts out of idle-room auto-claim while keeping the agent coordinated for explicit passes, assignments, and takeover signals. Use when waiting on operator input without intent to take the next idle turn. Protocol-level field is `wait_for_turn.auto_claim` (default true).
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Tests
|
|
112
|
+
|
|
113
|
+
Add to `tests/talking-stick.test.ts` and `tests/cli.test.ts`:
|
|
114
|
+
|
|
115
|
+
- **service**: `waitForTurn with auto_claim=false on an idle room returns not_yet with reason auto_claim_disabled` (regression for this session's churn).
|
|
116
|
+
- **service**: `waitForTurn with auto_claim=false when reserved_for == caller returns your_turn`.
|
|
117
|
+
- **service**: `waitForTurn with auto_claim=false when caller is already owner returns your_turn`.
|
|
118
|
+
- **service**: `waitForTurn with auto_claim=false surfaces takeover_available for stale owner` (and for claim_timeout, recipient_gone, owner_gone).
|
|
119
|
+
- **service**: `waitForTurn with auto_claim=false returns not_yet when another agent owns the stick`.
|
|
120
|
+
- **service**: plain `waitForTurn` (auto_claim default true) still claims idle rooms — pin no-regression.
|
|
121
|
+
- **CLI**: `tt wait --park` against an idle room exits with not_yet, no guardian spawned, no claim event in the log.
|
|
122
|
+
- **CLI**: `tt wait --park` against a reservation-to-me returns your_turn with a live guardian.
|
|
123
|
+
|
|
124
|
+
## Out of scope
|
|
125
|
+
|
|
126
|
+
- No change to `shouldDeferIdleClaim` (line 2066) or `priorOwnerReleaseCooldownMs`.
|
|
127
|
+
- No new `tt park` verb. `--park` flag is the only surface in v1.
|
|
128
|
+
- No change to how parked agents are heartbeat-tracked; they remain active members.
|
|
129
|
+
- No change to fair-routing eligibility; parked agents stay in the `tt assign next` pool.
|
|
130
|
+
|
|
131
|
+
## Verification
|
|
132
|
+
|
|
133
|
+
`npm run typecheck && npm test && npm run build`. Manual smoke: in a two-agent room, agent A releases with no work, runs `tt wait --park --timeout 5s` — expect `not_yet, reason: auto_claim_disabled` and no claim event. Then agent B does `tt assign next` — agent A's next park wait should return `your_turn`. Then agent A releases and runs plain `tt wait --timeout 5s` — expect your_turn (auto-claim restored).
|
|
134
|
+
|
|
135
|
+
## Commit message
|
|
136
|
+
|
|
137
|
+
`Add tt wait --park for non-claiming coordination`
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Talking Stick 0.4.3
|
|
2
|
+
|
|
3
|
+
Date: 2026-05-11
|
|
4
|
+
|
|
5
|
+
This patch adds a parked wait mode for agents that need to remain coordinated
|
|
6
|
+
without taking an idle turn. It addresses release/reclaim churn during pauses
|
|
7
|
+
for operator input while preserving the existing `tt wait` behavior for agents
|
|
8
|
+
that are ready to work.
|
|
9
|
+
|
|
10
|
+
## Added
|
|
11
|
+
|
|
12
|
+
### `tt wait --park`
|
|
13
|
+
|
|
14
|
+
`tt wait --park` and `tt try --park` now opt out of idle-room auto-claim. The
|
|
15
|
+
underlying service/command field is `auto_claim`, which defaults to true for
|
|
16
|
+
the existing behavior. When false, an idle room returns `not_yet` with
|
|
17
|
+
`reason: "auto_claim_disabled"` and does not mint a claim event or start a
|
|
18
|
+
guardian.
|
|
19
|
+
|
|
20
|
+
Parked waits still return actionable signals:
|
|
21
|
+
|
|
22
|
+
- already-owned turns remain `your_turn`
|
|
23
|
+
- explicit passes or assignments to the caller still become `your_turn`
|
|
24
|
+
- takeover availability is still surfaced
|
|
25
|
+
|
|
26
|
+
Plain `tt wait` is unchanged and still auto-claims idle rooms.
|
|
27
|
+
|
|
28
|
+
## Changed
|
|
29
|
+
|
|
30
|
+
### Coordination guidance
|
|
31
|
+
|
|
32
|
+
The bundled skill and default editable instructions now tell agents to use
|
|
33
|
+
`tt wait --park --json` when they have no expected work and are blocked on
|
|
34
|
+
operator input or an external signal. They also remind freshly invoked agents
|
|
35
|
+
to give peers a short window to join before concluding they are alone, using
|
|
36
|
+
normal waits or read-only repo orientation while other harnesses appear.
|
|
37
|
+
|
|
38
|
+
## Verification
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm run typecheck
|
|
42
|
+
npx vitest run tests/talking-stick.test.ts tests/cli.test.ts -t "auto_claim=false|tt wait --park|auto_claim default"
|
|
43
|
+
npm test
|
|
44
|
+
npm run build
|
|
45
|
+
node dist/cli.js --help
|
|
46
|
+
git diff --check
|
|
47
|
+
npm pack --dry-run
|
|
48
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Talking Stick 0.4.4
|
|
2
|
+
|
|
3
|
+
Date: 2026-05-12
|
|
4
|
+
|
|
5
|
+
## Added
|
|
6
|
+
- **Automatic release prep.** `npm version <new-version>` now runs `scripts/prepare-release.mjs`, moving `CHANGELOG.md`'s `Unreleased` entries into the new version section, creating `docs/releases/<version>.md`, and adding the GitHub release link before npm creates the version commit/tag.
|
|
7
|
+
|
|
8
|
+
## Changed
|
|
9
|
+
- **Ambient receiver guidance.** The shipped skill now says to run exactly one streaming ambient receiver per session, and warns that exit-notify background commands silently swallow `tt events --follow` output instead of surfacing mid-task events.
|
|
10
|
+
|
|
11
|
+
## Fixed
|
|
12
|
+
- **Idle-room retention.** Opportunistic cleanup still deletes long-idle rooms after the seven-day default retention, but it now preserves a room when any recorded member process is provably still alive. Once no member is recently active or live, the same cleanup path removes the room and its member, event, and note rows.
|
|
13
|
+
|
|
14
|
+
## Verification
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm run typecheck
|
|
18
|
+
npm test
|
|
19
|
+
npm run build
|
|
20
|
+
node dist/cli.js --help
|
|
21
|
+
git diff --check
|
|
22
|
+
npm pack --dry-run
|
|
23
|
+
```
|
|
@@ -747,6 +747,7 @@ wait_for_turn_poll_ms = 250; // transport polling cadence
|
|
|
747
747
|
wait_for_events_max_wait_ms = 110 * 1000; // 110 seconds
|
|
748
748
|
presence_ttl_ms = 4 * 60 * 60 * 1000; // 4 hours
|
|
749
749
|
waiter_grace_ms = 10 * 1000; // 10 seconds
|
|
750
|
+
idle_room_ttl_ms = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
750
751
|
```
|
|
751
752
|
|
|
752
753
|
Timeout meanings:
|
|
@@ -757,6 +758,7 @@ Timeout meanings:
|
|
|
757
758
|
- `owner_lease_ttl` is how long an owner may remain silent before takeover becomes possible.
|
|
758
759
|
- `presence_ttl` determines whether a member is active for sequence selection and takeover eligibility.
|
|
759
760
|
- `waiter_grace_ms` is the short window used to identify recent waiters and to avoid immediately recycling the turn while a fairer known member is between wait polls.
|
|
761
|
+
- `idle_room_ttl` is the retention window for dormant coordination history. Opportunistic cleanup only purges a long-idle room when no member has recent presence and no recorded member process is provably still alive.
|
|
760
762
|
|
|
761
763
|
Rationale for these defaults: a real agent turn often runs 20-30 minutes (plan-and-edit, build-and-verify, review-and-respond), and a human collaborator walking through a few rooms may easily be idle for an hour without being "gone." Earlier drafts inherited chat-scale defaults (5-minute lease, 10-minute presence) which would silently open takeover windows mid-turn. The selected values accept a slower takeover response in exchange for not interrupting legitimate long work; operators who want faster response can shorten them via per-room policy once that ships.
|
|
762
764
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talking-stick",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "CLI coordination tool for path-scoped agent handoffs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"postinstall": "node scripts/postinstall-mcp-cleanup.cjs",
|
|
22
22
|
"prepare": "tsc -p tsconfig.build.json && chmod +x dist/cli.js",
|
|
23
23
|
"test": "vitest run",
|
|
24
|
+
"version": "node scripts/prepare-release.mjs --from-package",
|
|
24
25
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
25
26
|
},
|
|
26
27
|
"dependencies": {
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const CHANGELOG_PATH = "CHANGELOG.md";
|
|
9
|
+
const RELEASES_DIR = path.join("docs", "releases");
|
|
10
|
+
const PACKAGE_PATH = "package.json";
|
|
11
|
+
const RELEASE_URL_PREFIX =
|
|
12
|
+
"https://github.com/mostlydev/talking-stick/releases/tag/v";
|
|
13
|
+
|
|
14
|
+
function main() {
|
|
15
|
+
const options = parseArgs(process.argv.slice(2));
|
|
16
|
+
const version = options.fromPackage
|
|
17
|
+
? readPackageVersion()
|
|
18
|
+
: options.version;
|
|
19
|
+
if (!version) {
|
|
20
|
+
throw new Error("Usage: prepare-release --from-package | --version VERSION");
|
|
21
|
+
}
|
|
22
|
+
assertVersion(version);
|
|
23
|
+
|
|
24
|
+
const date = options.date ?? new Date().toISOString().slice(0, 10);
|
|
25
|
+
const changelog = readText(CHANGELOG_PATH);
|
|
26
|
+
const { nextChangelog, releaseBody } = prepareChangelog({
|
|
27
|
+
changelog,
|
|
28
|
+
version,
|
|
29
|
+
date
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const releasePath = path.join(RELEASES_DIR, `${version}.md`);
|
|
33
|
+
if (fs.existsSync(releasePath)) {
|
|
34
|
+
throw new Error(`${releasePath} already exists.`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fs.mkdirSync(RELEASES_DIR, { recursive: true });
|
|
38
|
+
writeText(CHANGELOG_PATH, nextChangelog);
|
|
39
|
+
writeText(releasePath, renderReleaseNotes(version, date, releaseBody));
|
|
40
|
+
stageGeneratedFiles([CHANGELOG_PATH, releasePath]);
|
|
41
|
+
|
|
42
|
+
console.log(`Prepared release notes for ${version}.`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseArgs(args) {
|
|
46
|
+
const options = {
|
|
47
|
+
fromPackage: false,
|
|
48
|
+
version: undefined,
|
|
49
|
+
date: undefined
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
53
|
+
const arg = args[index];
|
|
54
|
+
if (arg === "--from-package") {
|
|
55
|
+
options.fromPackage = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (arg === "--version") {
|
|
59
|
+
options.version = requireValue(args, (index += 1), "--version");
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (arg === "--date") {
|
|
63
|
+
options.date = requireValue(args, (index += 1), "--date");
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (options.fromPackage && options.version) {
|
|
70
|
+
throw new Error("Use either --from-package or --version, not both.");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return options;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function requireValue(args, index, name) {
|
|
77
|
+
const value = args[index];
|
|
78
|
+
if (!value || value.startsWith("--")) {
|
|
79
|
+
throw new Error(`${name} requires a value.`);
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readPackageVersion() {
|
|
85
|
+
const parsed = JSON.parse(readText(PACKAGE_PATH));
|
|
86
|
+
if (typeof parsed.version !== "string") {
|
|
87
|
+
throw new Error("package.json does not contain a string version.");
|
|
88
|
+
}
|
|
89
|
+
return parsed.version;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function assertVersion(version) {
|
|
93
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
|
|
94
|
+
throw new Error(`Invalid release version: ${version}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function prepareChangelog({ changelog, version, date }) {
|
|
99
|
+
const lines = changelog.replace(/\r\n/g, "\n").split("\n");
|
|
100
|
+
const unreleasedIndex = lines.findIndex((line) => line === "## Unreleased");
|
|
101
|
+
if (unreleasedIndex === -1) {
|
|
102
|
+
throw new Error("CHANGELOG.md must contain a '## Unreleased' section.");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const duplicateIndex = lines.findIndex(
|
|
106
|
+
(line) => line === `## [${version}] — ${date}` || line.startsWith(`## [${version}] `)
|
|
107
|
+
);
|
|
108
|
+
if (duplicateIndex !== -1) {
|
|
109
|
+
throw new Error(`CHANGELOG.md already contains a ${version} section.`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const nextSectionIndex = findNextVersionHeading(lines, unreleasedIndex + 1);
|
|
113
|
+
const unreleasedBody = trimBlankLines(
|
|
114
|
+
lines.slice(unreleasedIndex + 1, nextSectionIndex)
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
if (unreleasedBody.length === 0) {
|
|
118
|
+
throw new Error("CHANGELOG.md Unreleased section is empty.");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const releaseSection = [
|
|
122
|
+
"## Unreleased",
|
|
123
|
+
"",
|
|
124
|
+
`## [${version}] — ${date}`,
|
|
125
|
+
"",
|
|
126
|
+
`Full notes: [\`docs/releases/${version}.md\`](docs/releases/${version}.md).`,
|
|
127
|
+
"",
|
|
128
|
+
...unreleasedBody,
|
|
129
|
+
""
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const nextLines = [
|
|
133
|
+
...lines.slice(0, unreleasedIndex),
|
|
134
|
+
...releaseSection,
|
|
135
|
+
...lines.slice(nextSectionIndex)
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
const nextChangelog = ensureReleaseLink(
|
|
139
|
+
`${nextLines.join("\n").replace(/\n*$/, "")}\n`,
|
|
140
|
+
version
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
nextChangelog,
|
|
145
|
+
releaseBody: unreleasedBody.join("\n")
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function findNextVersionHeading(lines, startIndex) {
|
|
150
|
+
const nextIndex = lines.findIndex(
|
|
151
|
+
(line, index) => index >= startIndex && /^##\s+/.test(line)
|
|
152
|
+
);
|
|
153
|
+
return nextIndex === -1 ? lines.length : nextIndex;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function trimBlankLines(lines) {
|
|
157
|
+
let start = 0;
|
|
158
|
+
let end = lines.length;
|
|
159
|
+
while (start < end && lines[start].trim() === "") {
|
|
160
|
+
start += 1;
|
|
161
|
+
}
|
|
162
|
+
while (end > start && lines[end - 1].trim() === "") {
|
|
163
|
+
end -= 1;
|
|
164
|
+
}
|
|
165
|
+
return lines.slice(start, end);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function ensureReleaseLink(changelog, version) {
|
|
169
|
+
const reference = `[${version}]: ${RELEASE_URL_PREFIX}${version}`;
|
|
170
|
+
const lines = changelog.replace(/\r\n/g, "\n").split("\n");
|
|
171
|
+
|
|
172
|
+
if (lines.some((line) => line.startsWith(`[${version}]:`))) {
|
|
173
|
+
return changelog;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const firstReferenceIndex = lines.findIndex((line) =>
|
|
177
|
+
/^\[[^\]]+\]:\s+/.test(line)
|
|
178
|
+
);
|
|
179
|
+
if (firstReferenceIndex === -1) {
|
|
180
|
+
return `${changelog.replace(/\n*$/, "")}\n\n${reference}\n`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
lines.splice(firstReferenceIndex, 0, reference);
|
|
184
|
+
return `${lines.join("\n").replace(/\n*$/, "")}\n`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function renderReleaseNotes(version, date, changelogBody) {
|
|
188
|
+
return `# Talking Stick ${version}
|
|
189
|
+
|
|
190
|
+
Date: ${date}
|
|
191
|
+
|
|
192
|
+
${renderReleaseBody(changelogBody)}
|
|
193
|
+
|
|
194
|
+
## Verification
|
|
195
|
+
|
|
196
|
+
\`\`\`bash
|
|
197
|
+
npm run typecheck
|
|
198
|
+
npm test
|
|
199
|
+
npm run build
|
|
200
|
+
node dist/cli.js --help
|
|
201
|
+
git diff --check
|
|
202
|
+
npm pack --dry-run
|
|
203
|
+
\`\`\`
|
|
204
|
+
`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function renderReleaseBody(changelogBody) {
|
|
208
|
+
return changelogBody
|
|
209
|
+
.split("\n")
|
|
210
|
+
.map((line) => {
|
|
211
|
+
const heading = /^(#{3,})\s+(.+)$/.exec(line);
|
|
212
|
+
if (!heading) {
|
|
213
|
+
return line;
|
|
214
|
+
}
|
|
215
|
+
return `${heading[1].slice(1)} ${heading[2]}`;
|
|
216
|
+
})
|
|
217
|
+
.join("\n");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function readText(filePath) {
|
|
221
|
+
return fs.readFileSync(filePath, "utf8");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function writeText(filePath, content) {
|
|
225
|
+
fs.writeFileSync(filePath, content, "utf8");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function stageGeneratedFiles(filePaths) {
|
|
229
|
+
if (
|
|
230
|
+
process.env.npm_lifecycle_event !== "version" ||
|
|
231
|
+
process.env.npm_config_git_tag_version === "false"
|
|
232
|
+
) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const insideWorkTree = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
237
|
+
encoding: "utf8",
|
|
238
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
239
|
+
});
|
|
240
|
+
if (
|
|
241
|
+
insideWorkTree.status !== 0 ||
|
|
242
|
+
insideWorkTree.stdout.trim() !== "true"
|
|
243
|
+
) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const add = spawnSync("git", ["add", ...filePaths], {
|
|
248
|
+
encoding: "utf8",
|
|
249
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
250
|
+
});
|
|
251
|
+
if (add.status !== 0) {
|
|
252
|
+
throw new Error(add.stderr.trim() || "Failed to stage release files.");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (
|
|
257
|
+
process.argv[1] &&
|
|
258
|
+
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
259
|
+
) {
|
|
260
|
+
main();
|
|
261
|
+
}
|
|
@@ -32,6 +32,7 @@ Useful commands:
|
|
|
32
32
|
- `tt whoami --json`
|
|
33
33
|
- `tt join --json`
|
|
34
34
|
- `tt wait --json`
|
|
35
|
+
- `tt wait --park --json`
|
|
35
36
|
- `tt try --json`
|
|
36
37
|
- `tt state --json`
|
|
37
38
|
- `tt events --after N --target any --json`
|
|
@@ -60,6 +61,8 @@ tt join --json
|
|
|
60
61
|
|
|
61
62
|
Keep the returned room id and canonical path in mind. The current working directory is the implicit path for normal commands; pass an explicit path only when coordinating a different directory or intentionally selecting a nested room.
|
|
62
63
|
|
|
64
|
+
On freshly invoked multi-agent tasks, give peers a short window to join before deciding you are alone. Use a normal wait timeout or spend about a minute on read-only repo orientation while other harnesses appear.
|
|
65
|
+
|
|
63
66
|
After joining, load editable collaboration instructions once:
|
|
64
67
|
|
|
65
68
|
```sh
|
|
@@ -68,13 +71,17 @@ tt instructions show --json
|
|
|
68
71
|
|
|
69
72
|
If that command fails, continue with this bundled skill. Editable instructions can add local preferences, but they do not override the safety rules in this skill.
|
|
70
73
|
|
|
71
|
-
Right after joining, start
|
|
74
|
+
Right after joining, start exactly one background ambient receiver so direct messages and turn passes/reservations surface as soon as they happen instead of waiting for the next time you poll:
|
|
72
75
|
|
|
73
76
|
```sh
|
|
74
77
|
tt events --follow --json
|
|
75
78
|
```
|
|
76
79
|
|
|
77
|
-
For `tt events --wait` and `tt events --follow`, the default target is `self`; add `--target any` only for audit/debug views.
|
|
80
|
+
For `tt events --wait` and `tt events --follow`, the default target is `self`; add `--target any` only for audit/debug views.
|
|
81
|
+
|
|
82
|
+
The receiver must stream stdout line-by-line into your model context (Claude Code's Monitor, Codex `attach`-style) so each event becomes a notification you see mid-task. A backgrounded shell that only notifies when the process exits is **not** an ambient receiver — it silently swallows every event until termination, then fires a single useless notification at the end. If your harness can only observe process-exit, use the polling fallbacks in §4.5 instead; do not dress an exit-notify background command up as a stream consumer.
|
|
83
|
+
|
|
84
|
+
Run exactly one ambient receiver per session. A second `tt events --follow` does not add coverage — both instances compete for the same stream, and one of them is likely silently consuming events you will never see. If you need a different filter, stop the existing receiver first.
|
|
78
85
|
|
|
79
86
|
The ambient receiver is not a turn claimant. It never grants the stick and never starts the lease guardian. Keep using `tt wait --json` for ownership.
|
|
80
87
|
|
|
@@ -226,6 +233,8 @@ Exit the wait loop only when one of these is true:
|
|
|
226
233
|
|
|
227
234
|
In every other case, after `tt release` or `tt assign`, go straight back into `tt wait --json`. If you are the only active member of the room, stop polling after a clear handoff. Treat "only active" as no other member that `tt state --json` reports active or that has been seen in the last hour; if liveness is ambiguous, run one more normal wait cycle instead of churning. Other agents going briefly quiet is not enough to declare yourself alone.
|
|
228
235
|
|
|
236
|
+
If you have no expected work and are blocked on operator input or an external signal, use `tt wait --park --json` instead of `tt wait --json` to stay coordinated without claiming idle turns. Park still surfaces explicit passes, assignments, and takeover availability; it never auto-claims an idle room. Switch back to plain `tt wait --json` once you have work to do.
|
|
237
|
+
|
|
229
238
|
If the operator tells you to drop out of coordination, run `tt leave --json`. Rooms with no active members are deleted instead of kept as history, and long-idle rooms may be purged on later invocations.
|
|
230
239
|
|
|
231
240
|
If the room state shows ghost members from past sessions whose processes are gone, run `tt kick <agent_id> --json` to evict them. Use `--force` only when the operator explicitly tells you to remove a still-active member.
|