talking-stick 0.4.4 → 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.
@@ -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 identity = deriveHumanCliIdentity({
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")) {
@@ -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 — auto-claim disabled; idle room left untouched.";
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
@@ -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 you are the only active member of the room, stop polling after a clear handoff rather than churning release/reclaim turns. If you have no expected work and are blocked on operator input or an external signal, use \`tt wait --park --json\` so you stay coordinated without auto-claiming idle turns.
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: roomSelection.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" || Date.now() >= deadline) {
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) {
@@ -1439,7 +1524,12 @@ export class TalkingStickService {
1439
1524
  pid: member.pid,
1440
1525
  process_started_at: member.process_started_at,
1441
1526
  session_kind: member.session_kind,
1442
- 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
1443
1533
  });
1444
1534
  }
1445
1535
  goneGraceMs() {
@@ -1542,7 +1632,12 @@ function normalizeProcessMetadata(processMetadata) {
1542
1632
  pid: processMetadata?.pid ?? null,
1543
1633
  process_started_at: processMetadata?.process_started_at ?? null,
1544
1634
  session_kind: processMetadata?.session_kind ?? "mcp_harness",
1545
- 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
1546
1641
  };
1547
1642
  }
1548
1643
  export function createDefaultProcessLivenessChecker(currentHostId, injectedInspector) {
@@ -1552,7 +1647,9 @@ export function createDefaultProcessLivenessChecker(currentHostId, injectedInspe
1552
1647
  const inspector = injectedInspector ?? createSystemProcessInspector({ cacheTtlMs: 1_000 });
1553
1648
  return (metadata) => {
1554
1649
  if (metadata.pid === null ||
1650
+ metadata.pid === undefined ||
1555
1651
  metadata.process_started_at === null ||
1652
+ metadata.process_started_at === undefined ||
1556
1653
  metadata.process_started_at.trim() === "") {
1557
1654
  return "unknown";
1558
1655
  }
@@ -1586,6 +1683,34 @@ function hasExactProcessIdentity(metadata) {
1586
1683
  metadata.process_started_at !== undefined &&
1587
1684
  metadata.process_started_at.trim() !== "");
1588
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
+ }
1589
1714
  function sessionKindPriority(sessionKind) {
1590
1715
  switch (sessionKind) {
1591
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,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
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talking-stick",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "CLI coordination tool for path-scoped agent handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
@@ -95,6 +97,8 @@ tt wait --json
95
97
 
96
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.
97
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
+
98
102
  Possible outcomes:
99
103
 
100
104
  - `your_turn`: you may proceed
@@ -110,7 +114,7 @@ Prefer to run `tt wait` in the background if your harness supports background co
110
114
 
111
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.
112
116
 
113
- 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`; do not touch shared files unless that wait returns `your_turn`.
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`.
114
118
 
115
119
  If you do not have the stick:
116
120
 
@@ -177,6 +181,8 @@ If `tt wait` reports `takeover_available`:
177
181
  - if takeover is chosen, run `tt take --reason "..." --json`
178
182
  - after takeover, run `tt events --target any --json` so you can reconstruct the last handoff before touching code
179
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
+
180
186
  If the operator explicitly tells you to take over despite a reservation or live owner, use:
181
187
 
182
188
  ```sh
@@ -210,6 +216,7 @@ Use `tt assign <agent_id> . --stdin` only when a specific named member must go n
210
216
  - they have unique context the next step requires
211
217
  - they hold a credential or capability others lack
212
218
  - the operator explicitly addressed the work to them
219
+ - the handoff asks that named peer for a concrete review or release action
213
220
 
214
221
  Otherwise release. Pinning turns between two agents is an antipattern because it can lock humans out of their own room.
215
222