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 +6 -1
- package/dist/service.js +11 -1
- 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 +6 -2
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.
|
|
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
|
+
"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
|
|
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.
|
|
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
|
|