talking-stick 0.4.3 → 0.4.5
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/cli/guardian.js +39 -3
- package/dist/cli/output.js +1 -1
- package/dist/cli/turn-commands.js +8 -4
- package/dist/db.js +11 -0
- package/dist/identity.js +24 -2
- package/dist/instructions.js +1 -1
- package/dist/service.js +147 -12
- package/docs/plans/2026-05-12-zombie-session-eviction.md +157 -0
- package/docs/releases/0.4.4.md +23 -0
- package/docs/releases/0.4.5.md +22 -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 +14 -3
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/cli/guardian.js
CHANGED
|
@@ -3,17 +3,24 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { createSystemProcessInspector, deriveHumanCliIdentity, isProtocolError, terminateKnownProcess } from "../index.js";
|
|
6
|
-
import { parseRequiredInteger, requireStringOption } from "./parser.js";
|
|
6
|
+
import { getStringOption, parseRequiredInteger, requireStringOption } from "./parser.js";
|
|
7
7
|
import { createRuntime } from "./runtime.js";
|
|
8
8
|
const GUARD_READY = "READY";
|
|
9
9
|
const GUARD_READY_TIMEOUT_MS = 10_000;
|
|
10
10
|
const STALE_GUARD_ERRORS = new Set(["stale_lease", "turn_mismatch", "room_not_found"]);
|
|
11
11
|
export async function runGuardCommand(parsed) {
|
|
12
|
-
const
|
|
12
|
+
const baseIdentity = deriveHumanCliIdentity({
|
|
13
13
|
agentId: requireStringOption(parsed, "agent"),
|
|
14
14
|
displayName: requireStringOption(parsed, "agent").replace(/^human:/, ""),
|
|
15
15
|
sessionKind: "human_guardian"
|
|
16
16
|
});
|
|
17
|
+
const identity = {
|
|
18
|
+
...baseIdentity,
|
|
19
|
+
process_metadata: {
|
|
20
|
+
...baseIdentity.process_metadata,
|
|
21
|
+
...parseHarnessMetadataOptions(parsed)
|
|
22
|
+
}
|
|
23
|
+
};
|
|
17
24
|
const runtime = createRuntime();
|
|
18
25
|
try {
|
|
19
26
|
const joined = runtime.commands.joinPath(identity, {
|
|
@@ -51,6 +58,7 @@ export async function runGuardCommand(parsed) {
|
|
|
51
58
|
}
|
|
52
59
|
export async function spawnGuardian(input) {
|
|
53
60
|
const self = resolveSelfSpawn(input.cliEntryUrl);
|
|
61
|
+
const harnessArgs = serializeHarnessMetadataOptions(input.processMetadata);
|
|
54
62
|
const child = spawn(self.command, [
|
|
55
63
|
...self.args,
|
|
56
64
|
"guard",
|
|
@@ -63,7 +71,8 @@ export async function spawnGuardian(input) {
|
|
|
63
71
|
"--lease-id",
|
|
64
72
|
input.leaseId,
|
|
65
73
|
"--turn-id",
|
|
66
|
-
String(input.turnId)
|
|
74
|
+
String(input.turnId),
|
|
75
|
+
...harnessArgs
|
|
67
76
|
], {
|
|
68
77
|
detached: true,
|
|
69
78
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -105,6 +114,33 @@ export async function spawnGuardian(input) {
|
|
|
105
114
|
});
|
|
106
115
|
});
|
|
107
116
|
}
|
|
117
|
+
function parseHarnessMetadataOptions(parsed) {
|
|
118
|
+
const harnessPid = getStringOption(parsed, "harness-pid");
|
|
119
|
+
return {
|
|
120
|
+
harness_name: getStringOption(parsed, "harness-name") ?? null,
|
|
121
|
+
harness_session_id: getStringOption(parsed, "harness-session-id") ?? null,
|
|
122
|
+
harness_host_id: getStringOption(parsed, "harness-host-id") ?? null,
|
|
123
|
+
harness_pid: harnessPid ? Number.parseInt(harnessPid, 10) : null,
|
|
124
|
+
harness_process_started_at: getStringOption(parsed, "harness-process-started-at") ?? null
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function serializeHarnessMetadataOptions(metadata) {
|
|
128
|
+
const args = [];
|
|
129
|
+
appendOption(args, "harness-name", metadata?.harness_name);
|
|
130
|
+
appendOption(args, "harness-session-id", metadata?.harness_session_id);
|
|
131
|
+
appendOption(args, "harness-host-id", metadata?.harness_host_id);
|
|
132
|
+
appendOption(args, "harness-pid", metadata?.harness_pid === null || metadata?.harness_pid === undefined
|
|
133
|
+
? null
|
|
134
|
+
: String(metadata.harness_pid));
|
|
135
|
+
appendOption(args, "harness-process-started-at", metadata?.harness_process_started_at);
|
|
136
|
+
return args;
|
|
137
|
+
}
|
|
138
|
+
function appendOption(args, key, value) {
|
|
139
|
+
if (!value) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
args.push(`--${key}`, value);
|
|
143
|
+
}
|
|
108
144
|
function resolveSelfSpawn(cliEntryUrl) {
|
|
109
145
|
const scriptPath = fileURLToPath(cliEntryUrl);
|
|
110
146
|
if (scriptPath.endsWith(".ts")) {
|
package/dist/cli/output.js
CHANGED
|
@@ -61,7 +61,7 @@ export function formatWaitResult(result) {
|
|
|
61
61
|
return `Not your turn — turn ${result.turn_id ?? "?"} is reserved for ${result.reserved_for}${deadline}.`;
|
|
62
62
|
}
|
|
63
63
|
if (result.reason === "auto_claim_disabled") {
|
|
64
|
-
return "Parked —
|
|
64
|
+
return result.hint ?? "Parked — idle room left unclaimed.";
|
|
65
65
|
}
|
|
66
66
|
return "Not your turn yet.";
|
|
67
67
|
}
|
|
@@ -34,7 +34,8 @@ export async function handleWaitCommand(runtime, parsed, isTry, cliEntryUrl) {
|
|
|
34
34
|
roomId: joined.room_id,
|
|
35
35
|
leaseId: waitResult.lease_id,
|
|
36
36
|
turnId: waitResult.turn_id,
|
|
37
|
-
cliEntryUrl
|
|
37
|
+
cliEntryUrl,
|
|
38
|
+
processMetadata: identity.process_metadata
|
|
38
39
|
});
|
|
39
40
|
upsertCliSession(sessionPath, {
|
|
40
41
|
agent_id: identity.agent_id,
|
|
@@ -71,7 +72,8 @@ export async function handleWaitCommand(runtime, parsed, isTry, cliEntryUrl) {
|
|
|
71
72
|
roomId: joined.room_id,
|
|
72
73
|
leaseId: waitResult.lease_id,
|
|
73
74
|
turnId: waitResult.turn_id,
|
|
74
|
-
cliEntryUrl
|
|
75
|
+
cliEntryUrl,
|
|
76
|
+
processMetadata: identity.process_metadata
|
|
75
77
|
});
|
|
76
78
|
upsertCliSession(resolveCliSessionPath(), {
|
|
77
79
|
agent_id: identity.agent_id,
|
|
@@ -110,7 +112,8 @@ export async function handleTakeCommand(runtime, parsed, cliEntryUrl) {
|
|
|
110
112
|
roomId: joined.room_id,
|
|
111
113
|
leaseId: availability.lease_id,
|
|
112
114
|
turnId: availability.turn_id,
|
|
113
|
-
cliEntryUrl
|
|
115
|
+
cliEntryUrl,
|
|
116
|
+
processMetadata: identity.process_metadata
|
|
114
117
|
});
|
|
115
118
|
upsertCliSession(resolveCliSessionPath(), {
|
|
116
119
|
agent_id: identity.agent_id,
|
|
@@ -144,7 +147,8 @@ export async function handleTakeCommand(runtime, parsed, cliEntryUrl) {
|
|
|
144
147
|
roomId: joined.room_id,
|
|
145
148
|
leaseId: result.lease_id,
|
|
146
149
|
turnId: result.turn_id,
|
|
147
|
-
cliEntryUrl
|
|
150
|
+
cliEntryUrl,
|
|
151
|
+
processMetadata: identity.process_metadata
|
|
148
152
|
});
|
|
149
153
|
upsertCliSession(resolveCliSessionPath(), {
|
|
150
154
|
agent_id: identity.agent_id,
|
package/dist/db.js
CHANGED
|
@@ -103,6 +103,17 @@ const migrations = [
|
|
|
103
103
|
up: `
|
|
104
104
|
ALTER TABLE room_events ADD COLUMN payload_json TEXT;
|
|
105
105
|
`
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: 6,
|
|
109
|
+
name: "room_member_harness_instance_metadata",
|
|
110
|
+
up: `
|
|
111
|
+
ALTER TABLE room_members ADD COLUMN harness_name TEXT;
|
|
112
|
+
ALTER TABLE room_members ADD COLUMN harness_session_id TEXT;
|
|
113
|
+
ALTER TABLE room_members ADD COLUMN harness_host_id TEXT;
|
|
114
|
+
ALTER TABLE room_members ADD COLUMN harness_pid INTEGER;
|
|
115
|
+
ALTER TABLE room_members ADD COLUMN harness_process_started_at TEXT;
|
|
116
|
+
`
|
|
106
117
|
}
|
|
107
118
|
];
|
|
108
119
|
export function resolveDatabasePath(options = {}) {
|
package/dist/identity.js
CHANGED
|
@@ -42,6 +42,7 @@ export function deriveMcpHarnessIdentity(options = {}) {
|
|
|
42
42
|
if (signal) {
|
|
43
43
|
const processRef = resolveSignalProcessRef(signal, parentPid, parentInspection, inspector);
|
|
44
44
|
const sessionId = resolveHarnessSessionId(signal, env, processRef.pid, processRef.inspection, username, hostId, inspector);
|
|
45
|
+
const harnessProcess = resolveHarnessProcessRef(signal, processRef, inspector);
|
|
45
46
|
const agentId = options.agentId ?? harnessAgentId(signal.harness, sessionId, hostId, username);
|
|
46
47
|
return {
|
|
47
48
|
agent_id: agentId,
|
|
@@ -50,7 +51,12 @@ export function deriveMcpHarnessIdentity(options = {}) {
|
|
|
50
51
|
pid: processRef.pid,
|
|
51
52
|
process_started_at: processRef.inspection?.startTime ?? null,
|
|
52
53
|
session_kind: "mcp_harness",
|
|
53
|
-
display_name: signal.harness
|
|
54
|
+
display_name: signal.harness,
|
|
55
|
+
harness_name: signal.harness,
|
|
56
|
+
harness_session_id: sessionId,
|
|
57
|
+
harness_host_id: hostId,
|
|
58
|
+
harness_pid: harnessProcess.pid,
|
|
59
|
+
harness_process_started_at: harnessProcess.startTime
|
|
54
60
|
}
|
|
55
61
|
};
|
|
56
62
|
}
|
|
@@ -105,6 +111,7 @@ export function deriveHarnessCliIdentity(options = {}) {
|
|
|
105
111
|
const username = options.username ?? safeUsername();
|
|
106
112
|
const sessionId = resolveHarnessSessionId(signal, env, processRef.pid, processRef.inspection, username, hostId, inspector);
|
|
107
113
|
const agentId = options.agentId ?? harnessAgentId(signal.harness, sessionId, hostId, username);
|
|
114
|
+
const harnessProcess = resolveHarnessProcessRef(signal, processRef, inspector);
|
|
108
115
|
return {
|
|
109
116
|
agent_id: agentId,
|
|
110
117
|
process_metadata: {
|
|
@@ -112,7 +119,12 @@ export function deriveHarnessCliIdentity(options = {}) {
|
|
|
112
119
|
pid: processRef.pid,
|
|
113
120
|
process_started_at: processRef.inspection?.startTime ?? null,
|
|
114
121
|
session_kind: "harness_cli",
|
|
115
|
-
display_name: options.displayName ?? signal.harness
|
|
122
|
+
display_name: options.displayName ?? signal.harness,
|
|
123
|
+
harness_name: signal.harness,
|
|
124
|
+
harness_session_id: sessionId,
|
|
125
|
+
harness_host_id: hostId,
|
|
126
|
+
harness_pid: harnessProcess.pid,
|
|
127
|
+
harness_process_started_at: harnessProcess.startTime
|
|
116
128
|
}
|
|
117
129
|
};
|
|
118
130
|
}
|
|
@@ -139,6 +151,16 @@ function resolveHarnessSessionId(signal, env, parentPid, parentInspection, usern
|
|
|
139
151
|
}
|
|
140
152
|
return `userhost:${sanitizeIdentityComponent(username)}@${hostId}`;
|
|
141
153
|
}
|
|
154
|
+
function resolveHarnessProcessRef(signal, processRef, inspector) {
|
|
155
|
+
const harnessRoot = findHarnessRootInAncestry(signal.harness, processRef.pid, processRef.inspection, inspector);
|
|
156
|
+
if (harnessRoot) {
|
|
157
|
+
return harnessRoot;
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
pid: processRef.pid,
|
|
161
|
+
startTime: processRef.inspection?.startTime ?? null
|
|
162
|
+
};
|
|
163
|
+
}
|
|
142
164
|
// Walks the process ancestry (inclusive of startPid) looking for the deepest
|
|
143
165
|
// process whose command matches the named harness. Anchoring session id to
|
|
144
166
|
// that root keeps `tt` invocations stable whether they're spawned directly
|
package/dist/instructions.js
CHANGED
|
@@ -6,7 +6,7 @@ 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
|
|
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 a handoff, message, or operator instruction leaves review, release, or other work pending, use normal \`tt wait --json\`; do not park. Use \`tt wait --park --json\` only for passive standby when no task is pending and you are blocked on operator input or an external signal.
|
|
10
10
|
|
|
11
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.
|
|
12
12
|
|
package/dist/service.js
CHANGED
|
@@ -15,6 +15,7 @@ const KNOWN_EVENT_TYPES = [
|
|
|
15
15
|
"takeover",
|
|
16
16
|
"close",
|
|
17
17
|
"kick",
|
|
18
|
+
"session_superseded",
|
|
18
19
|
"message_sent"
|
|
19
20
|
];
|
|
20
21
|
export class TalkingStickService {
|
|
@@ -66,7 +67,11 @@ export class TalkingStickService {
|
|
|
66
67
|
return withImmediateTransaction(this.db, () => {
|
|
67
68
|
const roomSelection = this.findOrCreateRoomForJoin(resolved, input.force_new === true, timestamp);
|
|
68
69
|
this.upsertMember(roomSelection.room.room_id, input.agent_id, timestamp, input.process_metadata);
|
|
70
|
+
const supersededAgentIds = this.retireSupersededHarnessSessions(roomSelection.room.room_id, input.agent_id, normalizeProcessMetadata(input.process_metadata), timestamp);
|
|
69
71
|
const freshRoom = this.requireRoom(roomSelection.room.room_id);
|
|
72
|
+
const warning = joinWarnings(roomSelection.warning, supersededAgentIds.length > 0
|
|
73
|
+
? `Superseded previous harness session(s): ${supersededAgentIds.join(", ")}.`
|
|
74
|
+
: undefined);
|
|
70
75
|
return {
|
|
71
76
|
agent_id: input.agent_id,
|
|
72
77
|
room_id: freshRoom.room_id,
|
|
@@ -74,7 +79,7 @@ export class TalkingStickService {
|
|
|
74
79
|
requested_path: resolved.requested_path,
|
|
75
80
|
workspace_root: resolved.workspace_root,
|
|
76
81
|
joined_existing_room: roomSelection.joinedExistingRoom,
|
|
77
|
-
warning
|
|
82
|
+
warning,
|
|
78
83
|
policy: { ...this.policy },
|
|
79
84
|
room_state: this.mapRoom(this.inspectRoom(freshRoom, now), now),
|
|
80
85
|
handoff_template: handoffTemplate()
|
|
@@ -233,7 +238,9 @@ export class TalkingStickService {
|
|
|
233
238
|
while (true) {
|
|
234
239
|
this.warmRoomTurnLiveness(input.room_id);
|
|
235
240
|
const result = withImmediateTransaction(this.db, () => this.waitForTurnOnce(input));
|
|
236
|
-
if (result.status !== "not_yet" ||
|
|
241
|
+
if (result.status !== "not_yet" ||
|
|
242
|
+
result.reason === "auto_claim_disabled" ||
|
|
243
|
+
Date.now() >= deadline) {
|
|
237
244
|
return result;
|
|
238
245
|
}
|
|
239
246
|
const remainingMs = deadline - Date.now();
|
|
@@ -666,7 +673,8 @@ export class TalkingStickService {
|
|
|
666
673
|
reserved_for: room.reserved_for ?? undefined,
|
|
667
674
|
lease_expires_at: room.lease_expires_at ?? undefined,
|
|
668
675
|
claim_expires_at: room.claim_expires_at ?? undefined,
|
|
669
|
-
reason: "auto_claim_disabled"
|
|
676
|
+
reason: "auto_claim_disabled",
|
|
677
|
+
hint: "Idle room left unclaimed because park mode disables auto-claim. If work is pending, run `tt wait --json` or ask for an explicit assignment."
|
|
670
678
|
};
|
|
671
679
|
}
|
|
672
680
|
if (this.shouldDeferIdleClaim(room, input.agent_id, now)) {
|
|
@@ -855,10 +863,15 @@ export class TalkingStickService {
|
|
|
855
863
|
pid = ?,
|
|
856
864
|
process_started_at = ?,
|
|
857
865
|
session_kind = ?,
|
|
858
|
-
display_name =
|
|
866
|
+
display_name = ?,
|
|
867
|
+
harness_name = ?,
|
|
868
|
+
harness_session_id = ?,
|
|
869
|
+
harness_host_id = ?,
|
|
870
|
+
harness_pid = ?,
|
|
871
|
+
harness_process_started_at = ?
|
|
859
872
|
WHERE room_id = ? AND agent_id = ?
|
|
860
873
|
`)
|
|
861
|
-
.run(timestamp, timestamp, mergedMetadata.host_id, mergedMetadata.pid, mergedMetadata.process_started_at, mergedMetadata.session_kind, mergedMetadata.display_name, roomId, agentId);
|
|
874
|
+
.run(timestamp, timestamp, mergedMetadata.host_id, mergedMetadata.pid, mergedMetadata.process_started_at, mergedMetadata.session_kind, mergedMetadata.display_name, mergedMetadata.harness_name, mergedMetadata.harness_session_id, mergedMetadata.harness_host_id, mergedMetadata.harness_pid, mergedMetadata.harness_process_started_at, roomId, agentId);
|
|
862
875
|
return;
|
|
863
876
|
}
|
|
864
877
|
const nextOrdinal = this.db
|
|
@@ -878,11 +891,16 @@ export class TalkingStickService {
|
|
|
878
891
|
pid,
|
|
879
892
|
process_started_at,
|
|
880
893
|
session_kind,
|
|
881
|
-
display_name
|
|
894
|
+
display_name,
|
|
895
|
+
harness_name,
|
|
896
|
+
harness_session_id,
|
|
897
|
+
harness_host_id,
|
|
898
|
+
harness_pid,
|
|
899
|
+
harness_process_started_at
|
|
882
900
|
)
|
|
883
|
-
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)
|
|
901
|
+
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
884
902
|
`)
|
|
885
|
-
.run(roomId, agentId, nextOrdinal, timestamp, timestamp, timestamp, normalizedMetadata.host_id, normalizedMetadata.pid, normalizedMetadata.process_started_at, normalizedMetadata.session_kind, normalizedMetadata.display_name);
|
|
903
|
+
.run(roomId, agentId, nextOrdinal, timestamp, timestamp, timestamp, normalizedMetadata.host_id, normalizedMetadata.pid, normalizedMetadata.process_started_at, normalizedMetadata.session_kind, normalizedMetadata.display_name, normalizedMetadata.harness_name, normalizedMetadata.harness_session_id, normalizedMetadata.harness_host_id, normalizedMetadata.harness_pid, normalizedMetadata.harness_process_started_at);
|
|
886
904
|
}
|
|
887
905
|
mergeMemberProcessMetadata(room, existing, incoming) {
|
|
888
906
|
if (!this.shouldPreserveExactMemberProcessMetadata(room, existing, incoming)) {
|
|
@@ -893,9 +911,76 @@ export class TalkingStickService {
|
|
|
893
911
|
pid: existing.pid,
|
|
894
912
|
process_started_at: existing.process_started_at,
|
|
895
913
|
session_kind: existing.session_kind,
|
|
896
|
-
display_name: existing.display_name
|
|
914
|
+
display_name: existing.display_name,
|
|
915
|
+
harness_name: existing.harness_name,
|
|
916
|
+
harness_session_id: existing.harness_session_id,
|
|
917
|
+
harness_host_id: existing.harness_host_id,
|
|
918
|
+
harness_pid: existing.harness_pid,
|
|
919
|
+
harness_process_started_at: existing.harness_process_started_at
|
|
897
920
|
};
|
|
898
921
|
}
|
|
922
|
+
retireSupersededHarnessSessions(roomId, incomingAgentId, incomingMetadata, timestamp) {
|
|
923
|
+
if (!hasHarnessInstanceIdentity(incomingMetadata)) {
|
|
924
|
+
return [];
|
|
925
|
+
}
|
|
926
|
+
const room = this.requireRoom(roomId);
|
|
927
|
+
const targetAgentIds = [...new Set([room.owner, room.reserved_for])].filter((agentId) => agentId !== null && agentId !== incomingAgentId);
|
|
928
|
+
const supersededAgentIds = [];
|
|
929
|
+
for (const targetAgentId of targetAgentIds) {
|
|
930
|
+
const target = this.getMember(roomId, targetAgentId);
|
|
931
|
+
if (!target ||
|
|
932
|
+
!isSupersededHarnessInstance(target, incomingMetadata)) {
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
supersededAgentIds.push(targetAgentId);
|
|
936
|
+
this.db
|
|
937
|
+
.prepare("DELETE FROM room_members WHERE room_id = ? AND agent_id = ?")
|
|
938
|
+
.run(roomId, targetAgentId);
|
|
939
|
+
this.appendEvent({
|
|
940
|
+
room_id: roomId,
|
|
941
|
+
turn_id: room.turn_id,
|
|
942
|
+
event_type: "session_superseded",
|
|
943
|
+
from_agent_id: incomingAgentId,
|
|
944
|
+
to_agent_id: targetAgentId,
|
|
945
|
+
handoff: null,
|
|
946
|
+
reason: `superseded by newer ${incomingMetadata.harness_name} session from the same harness process`,
|
|
947
|
+
created_at: timestamp
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
if (supersededAgentIds.length === 0) {
|
|
951
|
+
return [];
|
|
952
|
+
}
|
|
953
|
+
const ownerSuperseded = room.owner
|
|
954
|
+
? supersededAgentIds.includes(room.owner)
|
|
955
|
+
: false;
|
|
956
|
+
const recipientSuperseded = room.reserved_for
|
|
957
|
+
? supersededAgentIds.includes(room.reserved_for)
|
|
958
|
+
: false;
|
|
959
|
+
const nextOwner = ownerSuperseded ? null : room.owner;
|
|
960
|
+
const nextReservedFor = recipientSuperseded ? null : room.reserved_for;
|
|
961
|
+
const nextState = room.state === "closed"
|
|
962
|
+
? "closed"
|
|
963
|
+
: nextOwner
|
|
964
|
+
? "owned"
|
|
965
|
+
: nextReservedFor
|
|
966
|
+
? "reserved"
|
|
967
|
+
: "idle";
|
|
968
|
+
this.db
|
|
969
|
+
.prepare(`
|
|
970
|
+
UPDATE path_rooms
|
|
971
|
+
SET owner = ?,
|
|
972
|
+
reserved_for = ?,
|
|
973
|
+
pending_handoff_event_seq = ?,
|
|
974
|
+
lease_id = ?,
|
|
975
|
+
lease_expires_at = ?,
|
|
976
|
+
claim_expires_at = ?,
|
|
977
|
+
state = ?,
|
|
978
|
+
updated_at = ?
|
|
979
|
+
WHERE room_id = ?
|
|
980
|
+
`)
|
|
981
|
+
.run(nextOwner, nextReservedFor, ownerSuperseded ? null : room.pending_handoff_event_seq, ownerSuperseded ? null : room.lease_id, ownerSuperseded ? null : room.lease_expires_at, recipientSuperseded ? null : room.claim_expires_at, nextState, timestamp, roomId);
|
|
982
|
+
return supersededAgentIds;
|
|
983
|
+
}
|
|
899
984
|
shouldPreserveExactMemberProcessMetadata(room, existing, incoming) {
|
|
900
985
|
const isCurrentHolderOrRecipient = room.owner === existing.agent_id || room.reserved_for === existing.agent_id;
|
|
901
986
|
if (!isCurrentHolderOrRecipient) {
|
|
@@ -1207,7 +1292,7 @@ export class TalkingStickService {
|
|
|
1207
1292
|
if (this.latestRoomActivityMs(room, members) > cutoffMs) {
|
|
1208
1293
|
continue;
|
|
1209
1294
|
}
|
|
1210
|
-
if (members.some((member) => this.
|
|
1295
|
+
if (members.some((member) => this.shouldRetainIdleRoom(member, now))) {
|
|
1211
1296
|
continue;
|
|
1212
1297
|
}
|
|
1213
1298
|
this.deleteRoom(room.room_id);
|
|
@@ -1239,6 +1324,16 @@ export class TalkingStickService {
|
|
|
1239
1324
|
}
|
|
1240
1325
|
return this.hasRecentPresence(member, now);
|
|
1241
1326
|
}
|
|
1327
|
+
shouldRetainIdleRoom(member, now) {
|
|
1328
|
+
const liveness = this.getMemberProcessLiveness(member);
|
|
1329
|
+
if (liveness === "alive") {
|
|
1330
|
+
return true;
|
|
1331
|
+
}
|
|
1332
|
+
if (liveness === "gone") {
|
|
1333
|
+
return false;
|
|
1334
|
+
}
|
|
1335
|
+
return this.hasRecentPresence(member, now);
|
|
1336
|
+
}
|
|
1242
1337
|
hasRecentPresence(member, now) {
|
|
1243
1338
|
return (now.getTime() - Date.parse(member.last_seen_at) <=
|
|
1244
1339
|
this.policy.presenceTtlMs);
|
|
@@ -1429,7 +1524,12 @@ export class TalkingStickService {
|
|
|
1429
1524
|
pid: member.pid,
|
|
1430
1525
|
process_started_at: member.process_started_at,
|
|
1431
1526
|
session_kind: member.session_kind,
|
|
1432
|
-
display_name: member.display_name
|
|
1527
|
+
display_name: member.display_name,
|
|
1528
|
+
harness_name: member.harness_name,
|
|
1529
|
+
harness_session_id: member.harness_session_id,
|
|
1530
|
+
harness_host_id: member.harness_host_id,
|
|
1531
|
+
harness_pid: member.harness_pid,
|
|
1532
|
+
harness_process_started_at: member.harness_process_started_at
|
|
1433
1533
|
});
|
|
1434
1534
|
}
|
|
1435
1535
|
goneGraceMs() {
|
|
@@ -1532,7 +1632,12 @@ function normalizeProcessMetadata(processMetadata) {
|
|
|
1532
1632
|
pid: processMetadata?.pid ?? null,
|
|
1533
1633
|
process_started_at: processMetadata?.process_started_at ?? null,
|
|
1534
1634
|
session_kind: processMetadata?.session_kind ?? "mcp_harness",
|
|
1535
|
-
display_name: processMetadata?.display_name ?? null
|
|
1635
|
+
display_name: processMetadata?.display_name ?? null,
|
|
1636
|
+
harness_name: processMetadata?.harness_name ?? null,
|
|
1637
|
+
harness_session_id: processMetadata?.harness_session_id ?? null,
|
|
1638
|
+
harness_host_id: processMetadata?.harness_host_id ?? null,
|
|
1639
|
+
harness_pid: processMetadata?.harness_pid ?? null,
|
|
1640
|
+
harness_process_started_at: processMetadata?.harness_process_started_at ?? null
|
|
1536
1641
|
};
|
|
1537
1642
|
}
|
|
1538
1643
|
export function createDefaultProcessLivenessChecker(currentHostId, injectedInspector) {
|
|
@@ -1542,7 +1647,9 @@ export function createDefaultProcessLivenessChecker(currentHostId, injectedInspe
|
|
|
1542
1647
|
const inspector = injectedInspector ?? createSystemProcessInspector({ cacheTtlMs: 1_000 });
|
|
1543
1648
|
return (metadata) => {
|
|
1544
1649
|
if (metadata.pid === null ||
|
|
1650
|
+
metadata.pid === undefined ||
|
|
1545
1651
|
metadata.process_started_at === null ||
|
|
1652
|
+
metadata.process_started_at === undefined ||
|
|
1546
1653
|
metadata.process_started_at.trim() === "") {
|
|
1547
1654
|
return "unknown";
|
|
1548
1655
|
}
|
|
@@ -1576,6 +1683,34 @@ function hasExactProcessIdentity(metadata) {
|
|
|
1576
1683
|
metadata.process_started_at !== undefined &&
|
|
1577
1684
|
metadata.process_started_at.trim() !== "");
|
|
1578
1685
|
}
|
|
1686
|
+
function hasHarnessInstanceIdentity(metadata) {
|
|
1687
|
+
return (metadata.harness_name !== null &&
|
|
1688
|
+
metadata.harness_name !== undefined &&
|
|
1689
|
+
metadata.harness_name.trim() !== "" &&
|
|
1690
|
+
metadata.harness_session_id !== null &&
|
|
1691
|
+
metadata.harness_session_id !== undefined &&
|
|
1692
|
+
metadata.harness_session_id.trim() !== "" &&
|
|
1693
|
+
metadata.harness_pid !== null &&
|
|
1694
|
+
metadata.harness_pid !== undefined &&
|
|
1695
|
+
metadata.harness_process_started_at !== null &&
|
|
1696
|
+
metadata.harness_process_started_at !== undefined &&
|
|
1697
|
+
metadata.harness_process_started_at.trim() !== "");
|
|
1698
|
+
}
|
|
1699
|
+
function isSupersededHarnessInstance(existing, incoming) {
|
|
1700
|
+
if (!hasHarnessInstanceIdentity(existing) || !hasHarnessInstanceIdentity(incoming)) {
|
|
1701
|
+
return false;
|
|
1702
|
+
}
|
|
1703
|
+
return (existing.harness_name === incoming.harness_name &&
|
|
1704
|
+
existing.harness_host_id === incoming.harness_host_id &&
|
|
1705
|
+
existing.harness_pid === incoming.harness_pid &&
|
|
1706
|
+
existing.harness_process_started_at ===
|
|
1707
|
+
incoming.harness_process_started_at &&
|
|
1708
|
+
existing.harness_session_id !== incoming.harness_session_id);
|
|
1709
|
+
}
|
|
1710
|
+
function joinWarnings(...warnings) {
|
|
1711
|
+
const present = warnings.filter((warning) => Boolean(warning?.trim()));
|
|
1712
|
+
return present.length > 0 ? present.join(" ") : undefined;
|
|
1713
|
+
}
|
|
1579
1714
|
function sessionKindPriority(sessionKind) {
|
|
1580
1715
|
switch (sessionKind) {
|
|
1581
1716
|
case "human_guardian":
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Zombie Session Eviction (`/clear` Stale Stick Fix)
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
When a harness like Codex or Claude Code runs `/clear`, the in-process
|
|
6
|
+
session resets but the host harness process keeps running. The room sees:
|
|
7
|
+
|
|
8
|
+
- old `agent_id` (derived from the prior `CODEX_THREAD_ID` /
|
|
9
|
+
`CLAUDE_CODE_SESSION_ID`) still listed as owner or recipient
|
|
10
|
+
- liveness check via the stored `(pid, process_started_at)` still reports
|
|
11
|
+
"alive" — because the harness process is in fact alive
|
|
12
|
+
- new session joins with a different `agent_id`, sees the room owned, and
|
|
13
|
+
has no way to make progress
|
|
14
|
+
|
|
15
|
+
The old session can never reply (its thread is gone), so the room is
|
|
16
|
+
permanently stuck until an operator forces a takeover.
|
|
17
|
+
|
|
18
|
+
## Predicate
|
|
19
|
+
|
|
20
|
+
A member M in a room is **superseded** when, on `tt join` by a new member
|
|
21
|
+
M', all of the following hold:
|
|
22
|
+
|
|
23
|
+
- `M.harness_name == M'.harness_name`
|
|
24
|
+
- `M.harness_host_id == M'.harness_host_id`
|
|
25
|
+
- `M.harness_pid == M'.harness_pid`
|
|
26
|
+
- `M.harness_process_started_at == M'.harness_process_started_at`
|
|
27
|
+
- `M.harness_session_id != M'.harness_session_id`
|
|
28
|
+
- both members have non-null harness-instance metadata (legacy rows with
|
|
29
|
+
NULL fields are skipped — safe migration)
|
|
30
|
+
- M is currently the room owner or reserved recipient
|
|
31
|
+
|
|
32
|
+
Non-owner, non-reserved members are not evaluated. They are harmless
|
|
33
|
+
(they cannot block coordination) and age out through the existing
|
|
34
|
+
presence TTL.
|
|
35
|
+
|
|
36
|
+
## Identity Capture
|
|
37
|
+
|
|
38
|
+
`(harness_pid, harness_process_started_at)` is the **harness root**
|
|
39
|
+
process identity — the user-launched Codex/Claude process, not any
|
|
40
|
+
guardian subprocess or the current MCP child. Codex and Claude both keep
|
|
41
|
+
this process alive across `/clear`, so it is the durable fingerprint.
|
|
42
|
+
|
|
43
|
+
`identity.ts` walks the process ancestry from the `tt` invocation back to
|
|
44
|
+
the deepest ancestor whose command matches a known harness name and
|
|
45
|
+
records that process's PID + start time as the harness root. The current
|
|
46
|
+
in-process session id (`CODEX_THREAD_ID`, `CLAUDE_CODE_SESSION_ID`, or
|
|
47
|
+
the ancestry-derived fallback) is recorded as `harness_session_id`.
|
|
48
|
+
|
|
49
|
+
These fields are written to `room_members` on join. They are independent
|
|
50
|
+
from the existing `pid` / `process_started_at` columns, which continue
|
|
51
|
+
to track the *currently active* process (initially the harness, later
|
|
52
|
+
the guardian once `tt wait` succeeds).
|
|
53
|
+
|
|
54
|
+
## Schema
|
|
55
|
+
|
|
56
|
+
Migration 6 (`src/db.ts`) adds five nullable columns to `room_members`:
|
|
57
|
+
|
|
58
|
+
- `harness_name TEXT`
|
|
59
|
+
- `harness_session_id TEXT`
|
|
60
|
+
- `harness_host_id TEXT`
|
|
61
|
+
- `harness_pid INTEGER`
|
|
62
|
+
- `harness_process_started_at TEXT`
|
|
63
|
+
|
|
64
|
+
All columns are nullable so the migration is safe for existing rows.
|
|
65
|
+
Rows with NULL fields are excluded from the supersession predicate.
|
|
66
|
+
|
|
67
|
+
## Guardian Propagation
|
|
68
|
+
|
|
69
|
+
`tt wait` and `tt take` spawn a `tt guard` subprocess that holds the
|
|
70
|
+
lease and rejoins the room with `session_kind: human_guardian`. The
|
|
71
|
+
guardian must carry forward the original harness-instance fields, not
|
|
72
|
+
its own (it is not a harness). This is done by passing
|
|
73
|
+
`--harness-name`, `--harness-session-id`, `--harness-host-id`,
|
|
74
|
+
`--harness-pid`, and `--harness-process-started-at` to `tt guard` on
|
|
75
|
+
spawn. The guardian merges them into its derived identity before
|
|
76
|
+
rejoining.
|
|
77
|
+
|
|
78
|
+
The result: the member row's `pid` / `process_started_at` columns get
|
|
79
|
+
overwritten with the guardian's process (correct for liveness), but the
|
|
80
|
+
`harness_*` columns stay pinned to the original harness root.
|
|
81
|
+
|
|
82
|
+
## Eviction Action
|
|
83
|
+
|
|
84
|
+
On a qualifying join, in one transaction:
|
|
85
|
+
|
|
86
|
+
1. `DELETE FROM room_members WHERE room_id = ? AND agent_id = ?` for each
|
|
87
|
+
superseded member.
|
|
88
|
+
2. Append a `session_superseded` event with
|
|
89
|
+
`from_agent_id = incoming`, `to_agent_id = superseded`, and a reason
|
|
90
|
+
string identifying the harness.
|
|
91
|
+
3. If the superseded member was the room owner, clear
|
|
92
|
+
`owner`, `lease_id`, `lease_expires_at`, and
|
|
93
|
+
`pending_handoff_event_seq`.
|
|
94
|
+
4. If the superseded member was the reserved recipient, clear
|
|
95
|
+
`reserved_for` and `claim_expires_at`. **Preserve**
|
|
96
|
+
`pending_handoff_event_seq` so the next claimant still receives the
|
|
97
|
+
original handoff.
|
|
98
|
+
5. Recompute `state` to `owned`, `reserved`, `idle`, or `closed` as
|
|
99
|
+
appropriate.
|
|
100
|
+
|
|
101
|
+
The `joinPath` response includes a `warning` listing the superseded
|
|
102
|
+
agent ids.
|
|
103
|
+
|
|
104
|
+
## Event Type Choice: `session_superseded`
|
|
105
|
+
|
|
106
|
+
A new event type rather than reusing `kick`. Rationale:
|
|
107
|
+
|
|
108
|
+
- `kick` is operator-coded — humans reading `tt events` interpret a kick
|
|
109
|
+
as an explicit intervention.
|
|
110
|
+
- A supersession is automatic, not operator-driven, and never blocks the
|
|
111
|
+
new session.
|
|
112
|
+
- A dedicated type lets skill text instruct harnesses to treat it as
|
|
113
|
+
informational, distinct from `takeover_available`.
|
|
114
|
+
|
|
115
|
+
The CLI's default event formatter renders `session_superseded` the same
|
|
116
|
+
way as any other event (timestamp, type, from→to, reason) — no special
|
|
117
|
+
display logic needed.
|
|
118
|
+
|
|
119
|
+
## What Stays Out
|
|
120
|
+
|
|
121
|
+
- No notification to the old session before eviction. By construction
|
|
122
|
+
the old session cannot read or reply.
|
|
123
|
+
- No silent retention of a deactivated member row. The append-only event
|
|
124
|
+
log is the audit record; member rows are operational state.
|
|
125
|
+
- No change to `tt take` / `takeover_available` semantics. The
|
|
126
|
+
supersession path runs strictly on `tt join`, before any takeover
|
|
127
|
+
decision.
|
|
128
|
+
- No change to plain human sessions. A `human_guardian` row is only
|
|
129
|
+
subject to supersession when it carries harness-instance metadata from
|
|
130
|
+
a harness-launched guardian; standalone human CLI rows have NULL
|
|
131
|
+
harness fields and are skipped.
|
|
132
|
+
|
|
133
|
+
## Tests
|
|
134
|
+
|
|
135
|
+
`tests/talking-stick.test.ts` covers:
|
|
136
|
+
|
|
137
|
+
1. **Owner supersession.** Same-process new session joins, prior owner
|
|
138
|
+
is deleted, `session_superseded` event recorded, room returns to
|
|
139
|
+
`idle`, new session's `tt wait` immediately grants the turn with
|
|
140
|
+
`reason: open_claim`.
|
|
141
|
+
2. **Different-process non-supersession.** A different Codex process
|
|
142
|
+
joins; the existing owner is untouched; the new joiner gets
|
|
143
|
+
`not_yet` and sees the original owner.
|
|
144
|
+
3. **Recipient supersession preserves handoff.** Prior recipient is
|
|
145
|
+
evicted, but the handoff event remains pending; the new session's
|
|
146
|
+
`tt wait` returns `reason: sequence` with the original handoff
|
|
147
|
+
intact.
|
|
148
|
+
|
|
149
|
+
## Open Questions
|
|
150
|
+
|
|
151
|
+
None blocking. Possible follow-ups:
|
|
152
|
+
|
|
153
|
+
- Surface superseded events in `tt state --json` as a recent-history
|
|
154
|
+
field for operators inspecting why ownership changed.
|
|
155
|
+
- Consider supersession during `tt wait` as a defensive sweep (currently
|
|
156
|
+
join-only; depends on whether real-world failure modes show join
|
|
157
|
+
being skipped).
|
|
@@ -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
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Talking Stick 0.4.5
|
|
2
|
+
|
|
3
|
+
Date: 2026-05-12
|
|
4
|
+
|
|
5
|
+
## Added
|
|
6
|
+
- **Harness-instance member metadata.** New nullable columns on `room_members` (`harness_name`, `harness_session_id`, `harness_host_id`, `harness_pid`, `harness_process_started_at`) track the root harness process and the in-process session id independently of the row's current liveness fields. Identity resolution walks the process ancestry to populate them; `tt guard` carries them forward on spawn so guardian rejoins do not clobber them.
|
|
7
|
+
- **`session_superseded` event type.** Emitted when `tt join` detects that the room's owner or reserved recipient comes from the same harness process but a different in-process session (the `/clear` case). The superseded member row is deleted, owner/reservation/lease state is cleared as appropriate, and pending recipient handoffs are preserved for the next claimant. Legacy member rows with NULL harness-instance fields are skipped, so the migration is safe.
|
|
8
|
+
|
|
9
|
+
## Fixed
|
|
10
|
+
- **`/clear` no longer leaves a permanent stale stick holder.** When a harness like Codex or Claude Code resets its in-process session (e.g. `/clear`) while still holding or being reserved for the stick, the next `tt join` from the new in-process session evicts the prior agent and clears the lease so the new session can proceed. Previously the room stayed stuck until an operator forced a takeover.
|
|
11
|
+
- **Park mode no longer looks like active pending work.** `tt wait --park` now returns immediately when the room is idle and unreserved, with a JSON hint telling agents to use normal `tt wait --json` when a handoff or operator instruction leaves review/release work pending. The bundled instructions now reserve park mode for true passive standby.
|
|
12
|
+
|
|
13
|
+
## Verification
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm run typecheck
|
|
17
|
+
npm test
|
|
18
|
+
npm run build
|
|
19
|
+
node dist/cli.js --help
|
|
20
|
+
git diff --check
|
|
21
|
+
npm pack --dry-run
|
|
22
|
+
```
|
|
@@ -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.5",
|
|
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
|
+
}
|
|
@@ -63,6 +63,8 @@ Keep the returned room id and canonical path in mind. The current working direct
|
|
|
63
63
|
|
|
64
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
65
|
|
|
66
|
+
If `tt join` returns a `warning` containing `Superseded previous harness session(s): ...`, that is the normal path after a harness `/clear` (or equivalent in-process session reset). The prior session in this same harness process held or was reserved for the stick, can no longer reply, and has been removed from the room. A `session_superseded` event records who replaced whom. This is informational, not a takeover decision — proceed normally.
|
|
67
|
+
|
|
66
68
|
After joining, load editable collaboration instructions once:
|
|
67
69
|
|
|
68
70
|
```sh
|
|
@@ -71,13 +73,17 @@ tt instructions show --json
|
|
|
71
73
|
|
|
72
74
|
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
75
|
|
|
74
|
-
Right after joining, start
|
|
76
|
+
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
77
|
|
|
76
78
|
```sh
|
|
77
79
|
tt events --follow --json
|
|
78
80
|
```
|
|
79
81
|
|
|
80
|
-
For `tt events --wait` and `tt events --follow`, the default target is `self`; add `--target any` only for audit/debug views.
|
|
82
|
+
For `tt events --wait` and `tt events --follow`, the default target is `self`; add `--target any` only for audit/debug views.
|
|
83
|
+
|
|
84
|
+
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.
|
|
85
|
+
|
|
86
|
+
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
87
|
|
|
82
88
|
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
89
|
|
|
@@ -91,6 +97,8 @@ tt wait --json
|
|
|
91
97
|
|
|
92
98
|
The default wait timeout is `110s`, which is the normal active-coordination setting. If your harness has a shorter tool timeout, override with the longest safe value and immediately wait again when it returns without granting the turn. Do not busy-loop with short waits.
|
|
93
99
|
|
|
100
|
+
If a handoff, message, or operator instruction leaves review, release, or other task work pending, use normal `tt wait --json`; do not use park mode. `tt wait --park --json` is only for passive standby when no task is pending and you are waiting for operator input or another external signal.
|
|
101
|
+
|
|
94
102
|
Possible outcomes:
|
|
95
103
|
|
|
96
104
|
- `your_turn`: you may proceed
|
|
@@ -106,7 +114,7 @@ Prefer to run `tt wait` in the background if your harness supports background co
|
|
|
106
114
|
|
|
107
115
|
Prefer wait cycles over scheduled wakeups. A direct long-poll stays aligned with other agents and usually notices a released stick within the same cycle. Use scheduled wakeups only when your harness cannot keep a wait running in the background.
|
|
108
116
|
|
|
109
|
-
Do not replace `tt wait` with an event receiver. `tt events --wait` is only a wake channel for messages and handoff/reservation events. If it exits with a pass, release, assignment, or message, process the event, then run or continue `tt wait --json
|
|
117
|
+
Do not replace `tt wait` with an event receiver. `tt events --wait` is only a wake channel for messages and handoff/reservation events. If it exits with a pass, release, assignment, or message, process the event, then run or continue normal `tt wait --json` whenever work is pending; do not touch shared files unless that wait returns `your_turn`.
|
|
110
118
|
|
|
111
119
|
If you do not have the stick:
|
|
112
120
|
|
|
@@ -173,6 +181,8 @@ If `tt wait` reports `takeover_available`:
|
|
|
173
181
|
- if takeover is chosen, run `tt take --reason "..." --json`
|
|
174
182
|
- after takeover, run `tt events --target any --json` so you can reconstruct the last handoff before touching code
|
|
175
183
|
|
|
184
|
+
`session_superseded` is **not** a takeover reason — it is a separate informational event emitted on `tt join` when a new in-process harness session replaces a prior one (see §2). It never requires a takeover decision.
|
|
185
|
+
|
|
176
186
|
If the operator explicitly tells you to take over despite a reservation or live owner, use:
|
|
177
187
|
|
|
178
188
|
```sh
|
|
@@ -206,6 +216,7 @@ Use `tt assign <agent_id> . --stdin` only when a specific named member must go n
|
|
|
206
216
|
- they have unique context the next step requires
|
|
207
217
|
- they hold a credential or capability others lack
|
|
208
218
|
- the operator explicitly addressed the work to them
|
|
219
|
+
- the handoff asks that named peer for a concrete review or release action
|
|
209
220
|
|
|
210
221
|
Otherwise release. Pinning turns between two agents is an antipattern because it can lock humans out of their own room.
|
|
211
222
|
|