talking-stick 0.4.3 → 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 CHANGED
@@ -230,7 +230,7 @@ Use `tt whoami --explain` to see which identity path the CLI chose.
230
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.
231
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.
232
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.
233
- - **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.
234
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.
235
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.
236
236
  - **Multi-process safe.** Shared SQLite with WAL mode, `BEGIN IMMEDIATE` writes, 250 ms polling for `wait_for_turn`. No daemon required.
@@ -258,6 +258,11 @@ npm run build
258
258
 
259
259
  See [`CHANGELOG.md`](CHANGELOG.md) for a per-version summary; full release notes live in [`docs/releases/`](docs/releases/).
260
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
+
261
266
  ## Read next
262
267
 
263
268
  - [`docs/talking-stick-plan.md`](docs/talking-stick-plan.md) — full protocol, state transitions, persistence model, design rationale, and open questions.
package/dist/service.js CHANGED
@@ -1207,7 +1207,7 @@ export class TalkingStickService {
1207
1207
  if (this.latestRoomActivityMs(room, members) > cutoffMs) {
1208
1208
  continue;
1209
1209
  }
1210
- if (members.some((member) => this.isMemberActive(member, now))) {
1210
+ if (members.some((member) => this.shouldRetainIdleRoom(member, now))) {
1211
1211
  continue;
1212
1212
  }
1213
1213
  this.deleteRoom(room.room_id);
@@ -1239,6 +1239,16 @@ export class TalkingStickService {
1239
1239
  }
1240
1240
  return this.hasRecentPresence(member, now);
1241
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
+ }
1242
1252
  hasRecentPresence(member, now) {
1243
1253
  return (now.getTime() - Date.parse(member.last_seen_at) <=
1244
1254
  this.policy.presenceTtlMs);
@@ -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",
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
+ }
@@ -71,13 +71,17 @@ tt instructions show --json
71
71
 
72
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.
73
73
 
74
- Right after joining, start a 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:
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:
75
75
 
76
76
  ```sh
77
77
  tt events --follow --json
78
78
  ```
79
79
 
80
- For `tt events --wait` and `tt events --follow`, the default target is `self`; add `--target any` only for audit/debug views. If your harness can stream a child process's stdout into the model's context (Claude Code's Monitor, Codex `attach`-style), this is enough — each line becomes an event you see mid-task. If your harness can only notice that a backgrounded command exits, use the polling fallback in §4.5. Without an ambient receiver, neither messages nor turn handoffs reach you between deliberate `tt wait` / `tt events` calls.
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.
81
85
 
82
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.
83
87