talking-stick 0.4.8 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -105,7 +105,7 @@ tt msg send/recv — out-of-band chat into the room event log
105
105
  tt instructions — editable collaboration prompt loaded by the skill
106
106
  ```
107
107
 
108
- A workspace maps to a room — usually the `git` root or nearest project marker — so two agents `cd`'d anywhere under the same repo join the same room automatically.
108
+ A workspace maps to a room — usually the `git` root or nearest project marker — so two agents `cd`'d anywhere under the same repo join the same room automatically. Marker files directly in your home directory are ignored for descendant paths, so scratch directories under `$HOME` do not collapse into one broad home-scoped room unless you explicitly join home itself.
109
109
 
110
110
  The global skill tells the model when to join, wait, take over, leave notes, send messages, and hand off.
111
111
 
@@ -20,7 +20,11 @@ export async function runEventStream(runtime, parsed, identity, roomId, options)
20
20
  event_type: options.event_type,
21
21
  target_agent_id: targetAgentId,
22
22
  from_agent_id: fromAgentId,
23
- max_wait_ms: follow || wait ? parseWaitTimeout(parsed) : 0
23
+ max_wait_ms: follow || wait ? parseWaitTimeout(parsed) : 0,
24
+ // Carry the caller's identity so a sustained self-receiver registers and
25
+ // refreshes presence (issue #29 Defect 1) — a `tt events --follow` /
26
+ // `--wait` watcher stays visible even if it never ran `tt join`.
27
+ process_metadata: identity.process_metadata
24
28
  };
25
29
  if (!follow) {
26
30
  const result = await runtime.commands.waitForEvents(waitInput);
@@ -38,7 +42,8 @@ export function resolveOptionalAgentSelector(runtime, identity, roomId, raw) {
38
42
  export function resolveAgentSelector(runtime, identity, roomId, raw) {
39
43
  const members = runtime.commands.getRoomState({
40
44
  room_id: roomId,
41
- agent_id: identity.agent_id
45
+ agent_id: identity.agent_id,
46
+ process_metadata: identity.process_metadata
42
47
  }).members;
43
48
  const exact = members.find((member) => member.agent_id === raw);
44
49
  if (exact) {
@@ -14,11 +14,12 @@ export async function runGuardCommand(parsed) {
14
14
  displayName: requireStringOption(parsed, "agent").replace(/^human:/, ""),
15
15
  sessionKind: "human_guardian"
16
16
  });
17
+ const harnessMetadata = parseHarnessMetadataOptions(parsed);
17
18
  const identity = {
18
19
  ...baseIdentity,
19
20
  process_metadata: {
20
21
  ...baseIdentity.process_metadata,
21
- ...parseHarnessMetadataOptions(parsed)
22
+ ...harnessMetadata
22
23
  }
23
24
  };
24
25
  const runtime = createRuntime();
@@ -32,8 +33,28 @@ export async function runGuardCommand(parsed) {
32
33
  expected_turn_id: parseRequiredInteger(parsed, "turn-id")
33
34
  };
34
35
  const intervalMs = joined.policy.heartbeatIntervalMs;
36
+ const harnessRef = {
37
+ pid: harnessMetadata.harness_pid,
38
+ process_started_at: harnessMetadata.harness_process_started_at
39
+ };
40
+ const inspector = createSystemProcessInspector();
35
41
  process.stdout.write(`${GUARD_READY}\n`);
36
42
  const timer = setInterval(() => {
43
+ // Tier-1 stale-guardian purge: if our own harness process is provably
44
+ // gone, surrender the turn instead of renewing the lease forever. This is
45
+ // the definitive case (no timeout): an orphaned guardian must not pin the
46
+ // stick once the harness it represents has exited. `unknown`/`alive` both
47
+ // fall through to the normal heartbeat; we only act on a definite `gone`.
48
+ if (checkGuardianLiveness(harnessRef, inspector) === "gone") {
49
+ try {
50
+ runtime.commands.relinquishOwnership(identity, heartbeatInput);
51
+ }
52
+ catch {
53
+ // Best effort: a takeover or graceful release may have already moved
54
+ // the turn on. Either way the harness is gone, so we exit.
55
+ }
56
+ process.exit(0);
57
+ }
37
58
  try {
38
59
  runtime.commands.heartbeat(identity, heartbeatInput);
39
60
  }
@@ -95,7 +95,8 @@ export function handleStateCommand(runtime, parsed) {
95
95
  const session = resolveSessionForReads(runtime, parsed, identity);
96
96
  const state = runtime.commands.getRoomState({
97
97
  room_id: session.room_id,
98
- agent_id: identity.agent_id
98
+ agent_id: identity.agent_id,
99
+ process_metadata: identity.process_metadata
99
100
  });
100
101
  printResult(parsed, { room: state.room, members: state.members }, () => {
101
102
  const lines = [
@@ -144,7 +145,8 @@ export async function handleEventsCommand(runtime, parsed) {
144
145
  room_id: session.room_id,
145
146
  agent_id: identity.agent_id,
146
147
  after_event_seq: parseOptionalInteger(parsed, "after"),
147
- limit: parseOptionalInteger(parsed, "limit")
148
+ limit: parseOptionalInteger(parsed, "limit"),
149
+ process_metadata: identity.process_metadata
148
150
  });
149
151
  printResult(parsed, events, () => {
150
152
  if (events.length === 0) {
@@ -245,7 +245,8 @@ function resolveAssignmentTarget(runtime, identity, session, selector) {
245
245
  }
246
246
  const state = runtime.commands.getRoomState({
247
247
  room_id: session.room_id,
248
- agent_id: identity.agent_id
248
+ agent_id: identity.agent_id,
249
+ process_metadata: identity.process_metadata
249
250
  });
250
251
  const normalizedSelector = selector.toLowerCase();
251
252
  const candidates = state.members.filter((member) => {
@@ -265,7 +266,8 @@ function resolveAssignmentTarget(runtime, identity, session, selector) {
265
266
  const events = runtime.commands.getRoomEvents({
266
267
  room_id: session.room_id,
267
268
  agent_id: identity.agent_id,
268
- limit: 500
269
+ limit: 500,
270
+ process_metadata: identity.process_metadata
269
271
  });
270
272
  return pickFairAssignmentCandidate(candidates, events).agent_id;
271
273
  }
package/dist/commands.js CHANGED
@@ -41,7 +41,8 @@ export class TalkingStickCommands {
41
41
  auto_claim: input.auto_claim,
42
42
  include_events: input.include_events,
43
43
  after_event_seq: input.after_event_seq,
44
- target_agent_id: input.target_agent_id
44
+ target_agent_id: input.target_agent_id,
45
+ process_metadata: identity.process_metadata
45
46
  });
46
47
  }
47
48
  heartbeat(identity, input) {
@@ -52,6 +53,14 @@ export class TalkingStickCommands {
52
53
  expected_turn_id: input.expected_turn_id
53
54
  });
54
55
  }
56
+ relinquishOwnership(identity, input) {
57
+ return this.service.relinquishOwnership({
58
+ agent_id: identity.agent_id,
59
+ room_id: input.room_id,
60
+ lease_id: input.lease_id,
61
+ expected_turn_id: input.expected_turn_id
62
+ });
63
+ }
55
64
  releaseStick(identity, input) {
56
65
  return this.service.releaseStick({
57
66
  agent_id: identity.agent_id,
@@ -92,7 +101,8 @@ export class TalkingStickCommands {
92
101
  room_id: input.room_id,
93
102
  body: input.body,
94
103
  to_agent_id: input.to_agent_id,
95
- delivery_hint: input.delivery_hint
104
+ delivery_hint: input.delivery_hint,
105
+ process_metadata: identity.process_metadata
96
106
  });
97
107
  }
98
108
  waitForEvents(input) {
@@ -106,7 +116,8 @@ export class TalkingStickCommands {
106
116
  agent_id: identity.agent_id,
107
117
  room_id: input.room_id,
108
118
  body: input.body,
109
- turn_id: input.turn_id
119
+ turn_id: input.turn_id,
120
+ process_metadata: identity.process_metadata
110
121
  });
111
122
  }
112
123
  listNotes(identity, input) {
@@ -115,7 +126,8 @@ export class TalkingStickCommands {
115
126
  agent_id: identity?.agent_id,
116
127
  after_note_id: input.after_note_id,
117
128
  include_resolved: input.include_resolved,
118
- limit: input.limit
129
+ limit: input.limit,
130
+ process_metadata: identity?.process_metadata
119
131
  });
120
132
  }
121
133
  }
package/dist/config.js CHANGED
@@ -2,6 +2,7 @@ import os from "node:os";
2
2
  import path from "node:path";
3
3
  export const defaultPolicy = {
4
4
  ownerLeaseTtlMs: 45 * 60 * 1000,
5
+ ownerActivityTtlMs: 45 * 60 * 1000,
5
6
  heartbeatIntervalMs: 5 * 60 * 1000,
6
7
  claimTtlMs: 20 * 60 * 1000,
7
8
  waitForTurnMaxWaitMs: 110 * 1000,
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
  const workspaceMarkers = [
5
6
  "CLAUDE.md",
@@ -78,8 +79,12 @@ function resolveGitRoot(canonicalContextPath) {
78
79
  }
79
80
  }
80
81
  function findNearestWorkspaceMarker(startPath) {
82
+ const homeMarkerBoundary = resolveHomeMarkerBoundary(startPath);
81
83
  let current = startPath;
82
84
  while (true) {
85
+ if (homeMarkerBoundary && samePath(current, homeMarkerBoundary)) {
86
+ return null;
87
+ }
83
88
  for (const marker of workspaceMarkers) {
84
89
  if (fs.existsSync(path.join(current, marker))) {
85
90
  return current;
@@ -92,6 +97,32 @@ function findNearestWorkspaceMarker(startPath) {
92
97
  current = parent;
93
98
  }
94
99
  }
100
+ function resolveHomeMarkerBoundary(startPath) {
101
+ const homeDir = os.homedir();
102
+ if (!homeDir) {
103
+ return null;
104
+ }
105
+ const resolvedHomeDir = path.resolve(homeDir);
106
+ const candidateHomes = [
107
+ canonicalizeDirectoryPath(resolvedHomeDir),
108
+ path.normalize(resolvedHomeDir)
109
+ ];
110
+ for (const candidateHome of candidateHomes) {
111
+ if (!samePath(startPath, candidateHome) &&
112
+ isWithinOrSame(startPath, candidateHome)) {
113
+ return candidateHome;
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+ function canonicalizeDirectoryPath(directoryPath) {
119
+ try {
120
+ return fs.realpathSync.native(directoryPath);
121
+ }
122
+ catch {
123
+ return path.normalize(directoryPath);
124
+ }
125
+ }
95
126
  function samePath(left, right) {
96
127
  return path.normalize(left) === path.normalize(right);
97
128
  }
package/dist/service.js CHANGED
@@ -306,7 +306,14 @@ export class TalkingStickService {
306
306
  return withImmediateTransaction(this.db, () => {
307
307
  const room = this.requireRoom(input.room_id);
308
308
  this.assertOwnerMutation(room, input, now);
309
- this.touchMember(input.room_id, input.agent_id, timestamp);
309
+ // Deliberately do NOT touch the member's last_seen_at here. Lease renewal
310
+ // is the guardian's job and must not be mistaken for harness presence:
311
+ // an abandoned-but-alive owner whose guardian keeps renewing would
312
+ // otherwise look permanently active and its turn could never be reclaimed
313
+ // (`owner_idle`). The lease itself (lease_expires_at, below) is the
314
+ // guardian's liveness signal; harness presence is recorded only by the
315
+ // harness's own `tt` commands. assertOwnerMutation already guarantees the
316
+ // owning member still exists.
310
317
  this.db
311
318
  .prepare(`
312
319
  UPDATE path_rooms
@@ -371,6 +378,56 @@ export class TalkingStickService {
371
378
  };
372
379
  });
373
380
  }
381
+ /**
382
+ * Tier-1 stale-guardian purge: the lease guardian discovered that its own
383
+ * harness process is provably gone, so it surrenders the turn before exiting.
384
+ * Unlike `releaseStick` this carries no handoff and is a no-op unless this
385
+ * agent still owns this exact turn/lease (the guardian may race a takeover or
386
+ * a graceful release). The room drops straight to `idle` so any waiter can
387
+ * claim it immediately, instead of waiting out the full lease TTL.
388
+ */
389
+ relinquishOwnership(input) {
390
+ const now = this.now();
391
+ const timestamp = now.toISOString();
392
+ this.purgeExpiredIdleRooms(now);
393
+ return withImmediateTransaction(this.db, () => {
394
+ const room = this.requireRoom(input.room_id);
395
+ if (room.owner !== input.agent_id ||
396
+ room.turn_id !== input.expected_turn_id ||
397
+ room.lease_id !== input.lease_id) {
398
+ return { status: "noop", room_id: input.room_id };
399
+ }
400
+ const eventSeq = this.appendEvent({
401
+ room_id: input.room_id,
402
+ turn_id: room.turn_id,
403
+ event_type: "release",
404
+ from_agent_id: input.agent_id,
405
+ to_agent_id: null,
406
+ handoff: null,
407
+ reason: "harness_gone",
408
+ created_at: timestamp
409
+ });
410
+ this.db
411
+ .prepare(`
412
+ UPDATE path_rooms
413
+ SET owner = NULL,
414
+ reserved_for = NULL,
415
+ pending_handoff_event_seq = NULL,
416
+ lease_id = NULL,
417
+ lease_expires_at = NULL,
418
+ claim_expires_at = NULL,
419
+ state = 'idle',
420
+ updated_at = ?
421
+ WHERE room_id = ?
422
+ `)
423
+ .run(timestamp, input.room_id);
424
+ return {
425
+ status: "relinquished",
426
+ room_id: input.room_id,
427
+ event_seq: eventSeq
428
+ };
429
+ });
430
+ }
374
431
  passStick(input) {
375
432
  validateHandoff(input.handoff);
376
433
  assertNonEmpty(input.to_agent_id, "to_agent_id");
@@ -486,7 +543,7 @@ export class TalkingStickService {
486
543
  const timestamp = now.toISOString();
487
544
  this.purgeExpiredIdleRooms(now);
488
545
  const room = this.requireRoom(input.room_id);
489
- this.touchKnownMember(input.room_id, input.agent_id, timestamp);
546
+ this.touchKnownMember(input.room_id, input.agent_id, timestamp, input.process_metadata);
490
547
  const inspection = this.inspectRoom(room, now);
491
548
  return {
492
549
  room: this.mapRoom(inspection, now),
@@ -495,7 +552,7 @@ export class TalkingStickService {
495
552
  }
496
553
  getRoomEvents(input) {
497
554
  this.purgeExpiredIdleRooms(this.now());
498
- this.touchKnownMember(input.room_id, input.agent_id, this.now().toISOString());
555
+ this.touchKnownMember(input.room_id, input.agent_id, this.now().toISOString(), input.process_metadata);
499
556
  const afterEventSeq = input.after_event_seq ?? 0;
500
557
  const limit = Math.min(input.limit ?? 100, 500);
501
558
  return this.db
@@ -532,7 +589,7 @@ export class TalkingStickService {
532
589
  if (room.state === "closed") {
533
590
  throw new ProtocolError("room_closed", "Messages cannot be sent to a closed room.", { room_id: input.room_id });
534
591
  }
535
- this.touchMember(input.room_id, input.agent_id, timestamp);
592
+ this.touchMember(input.room_id, input.agent_id, timestamp, input.process_metadata);
536
593
  if (input.to_agent_id) {
537
594
  const target = this.getMember(input.room_id, input.to_agent_id);
538
595
  if (!target) {
@@ -567,6 +624,17 @@ export class TalkingStickService {
567
624
  if (targetFilter === "self" && !input.agent_id) {
568
625
  throw new ProtocolError("agent_id_required", "agent_id is required when target_agent_id is 'self'.");
569
626
  }
627
+ // The default `target=self` event wait is the documented per-session
628
+ // presence primitive (the §2 ambient receiver): "watching" the room keeps
629
+ // the member visible. Refresh — and, for a receiver that never ran
630
+ // `tt join`, register — presence at entry (each long-poll cycle re-enters
631
+ // with a fresh cursor), re-stamping current harness metadata but never
632
+ // recording turn interest. An audit/debug `target=any` (or a third-party
633
+ // `target=<other>`) wait deliberately does NOT touch presence: it is not
634
+ // participation and must not resurrect a stale peer into an active waiter.
635
+ if (targetFilter === "self" && input.agent_id) {
636
+ this.recordReceiverPresence(input.room_id, input.agent_id, this.now().toISOString(), input.process_metadata);
637
+ }
570
638
  const eventTypes = normalizeEventTypeFilter(input.event_type);
571
639
  const afterEventSeq = input.after_event_seq ?? 0;
572
640
  const maxWaitMs = Math.min(Math.max(input.max_wait_ms ?? this.policy.waitForEventsMaxWaitMs, 0), this.policy.waitForEventsMaxWaitMs);
@@ -622,7 +690,7 @@ export class TalkingStickService {
622
690
  input.turn_id > room.turn_id)) {
623
691
  throw new ProtocolError("invalid_turn_id", "turn_id must be an integer between 0 and the current room turn_id.", { supplied: input.turn_id, current_turn_id: room.turn_id });
624
692
  }
625
- this.touchMember(input.room_id, input.agent_id, timestamp);
693
+ this.touchMember(input.room_id, input.agent_id, timestamp, input.process_metadata);
626
694
  const noteId = randomUUID();
627
695
  const turnId = input.turn_id ?? null;
628
696
  this.db
@@ -645,7 +713,7 @@ export class TalkingStickService {
645
713
  assertNonEmpty(input.room_id, "room_id");
646
714
  this.purgeExpiredIdleRooms(this.now());
647
715
  this.requireRoom(input.room_id);
648
- this.touchKnownMember(input.room_id, input.agent_id, this.now().toISOString());
716
+ this.touchKnownMember(input.room_id, input.agent_id, this.now().toISOString(), input.process_metadata);
649
717
  const limit = Math.min(Math.max(input.limit ?? 50, 1), 200);
650
718
  const includeResolved = input.include_resolved === true;
651
719
  let anchorCreatedAt = null;
@@ -693,7 +761,7 @@ export class TalkingStickService {
693
761
  const now = this.now();
694
762
  const timestamp = now.toISOString();
695
763
  const room = this.requireRoom(input.room_id);
696
- this.touchWaitingMember(input.room_id, input.agent_id, timestamp);
764
+ this.touchWaitingMember(input.room_id, input.agent_id, timestamp, input.process_metadata);
697
765
  const inspection = this.inspectRoomForMutation(room, now);
698
766
  if (room.state === "closed") {
699
767
  return { status: "closed", room_id: input.room_id };
@@ -807,6 +875,19 @@ export class TalkingStickService {
807
875
  current_owner: room.owner
808
876
  };
809
877
  }
878
+ if (room.owner &&
879
+ room.owner !== input.agent_id &&
880
+ inspection.state === "owned" &&
881
+ this.isOwnerHarnessIdle(inspection.ownerMember, now)) {
882
+ return {
883
+ status: "takeover_available",
884
+ room_id: input.room_id,
885
+ turn_id: room.turn_id,
886
+ room_state: "owner_idle",
887
+ reason: "owner_idle",
888
+ current_owner: room.owner
889
+ };
890
+ }
810
891
  return {
811
892
  status: "not_yet",
812
893
  room_state: inspection.state,
@@ -821,7 +902,7 @@ export class TalkingStickService {
821
902
  const now = this.now();
822
903
  const timestamp = now.toISOString();
823
904
  const room = this.requireRoom(input.room_id);
824
- this.touchWaitingMember(input.room_id, input.agent_id, timestamp);
905
+ this.touchWaitingMember(input.room_id, input.agent_id, timestamp, input.process_metadata);
825
906
  const inspection = this.inspectRoomForMutation(room, now);
826
907
  if (room.state === "closed") {
827
908
  return { status: "closed", room_id: input.room_id };
@@ -980,30 +1061,57 @@ export class TalkingStickService {
980
1061
  return this.requireRoom(roomId);
981
1062
  }
982
1063
  upsertMember(roomId, agentId, timestamp, processMetadata) {
1064
+ // `tt join`: create the member if absent, refresh presence, record wait
1065
+ // interest, and re-stamp process metadata.
1066
+ this.applyPresence(roomId, agentId, timestamp, {
1067
+ processMetadata,
1068
+ recordWait: true,
1069
+ allowCreate: true,
1070
+ requireMember: false
1071
+ });
1072
+ }
1073
+ /**
1074
+ * Unified presence write. Every non-guardian command routes through here so a
1075
+ * member's last_seen_at and current process metadata are refreshed by
1076
+ * ordinary `tt` use, not only by `tt join` (issue #29 Defect 1, and the
1077
+ * operator's "liveness updates on all tt tool use" rule).
1078
+ *
1079
+ * - `recordWait` also bumps last_wait_at (turn interest) — wait/try/join only;
1080
+ * reads and event receivers leave it untouched (watching is not a claim).
1081
+ * - Metadata is re-stamped only when the caller carries a usable process
1082
+ * identity, and the merge preserves a live owner/recipient's exact (guardian)
1083
+ * identity so a holder's own command never clobbers the guardian pid.
1084
+ * - `allowCreate` inserts a missing row (join, and sustained self-receivers
1085
+ * that never ran `tt join`); `requireMember` instead throws for write-RPCs.
1086
+ */
1087
+ applyPresence(roomId, agentId, timestamp, options) {
983
1088
  const existing = this.getMember(roomId, agentId);
984
- const normalizedMetadata = normalizeProcessMetadata(processMetadata);
1089
+ const normalized = normalizeProcessMetadata(options.processMetadata);
1090
+ const hasIdentity = hasExactProcessIdentity(normalized) ||
1091
+ hasHarnessProcessIdentity(normalized);
985
1092
  if (existing) {
986
- const room = this.requireRoom(roomId);
987
- const mergedMetadata = this.mergeMemberProcessMetadata(room, existing, normalizedMetadata);
1093
+ const sets = ["last_seen_at = ?", "status = 'active'"];
1094
+ const params = [timestamp];
1095
+ if (options.recordWait) {
1096
+ sets.push("last_wait_at = ?");
1097
+ params.push(timestamp);
1098
+ }
1099
+ if (hasIdentity) {
1100
+ const room = this.requireRoom(roomId);
1101
+ const merged = this.mergeMemberProcessMetadata(room, existing, normalized);
1102
+ sets.push("host_id = ?", "pid = ?", "process_started_at = ?", "session_kind = ?", "display_name = ?", "harness_name = ?", "harness_session_id = ?", "harness_host_id = ?", "harness_pid = ?", "harness_process_started_at = ?");
1103
+ params.push(merged.host_id, merged.pid, merged.process_started_at, merged.session_kind, merged.display_name, merged.harness_name, merged.harness_session_id, merged.harness_host_id, merged.harness_pid, merged.harness_process_started_at);
1104
+ }
1105
+ params.push(roomId, agentId);
988
1106
  this.db
989
- .prepare(`
990
- UPDATE room_members
991
- SET last_seen_at = ?,
992
- last_wait_at = ?,
993
- status = 'active',
994
- host_id = ?,
995
- pid = ?,
996
- process_started_at = ?,
997
- session_kind = ?,
998
- display_name = ?,
999
- harness_name = ?,
1000
- harness_session_id = ?,
1001
- harness_host_id = ?,
1002
- harness_pid = ?,
1003
- harness_process_started_at = ?
1004
- WHERE room_id = ? AND agent_id = ?
1005
- `)
1006
- .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);
1107
+ .prepare(`UPDATE room_members SET ${sets.join(", ")} WHERE room_id = ? AND agent_id = ?`)
1108
+ .run(...params);
1109
+ return;
1110
+ }
1111
+ if (options.requireMember) {
1112
+ throw new ProtocolError("unknown_member", "Agent must join the room before using this tool.", { to_agent_id: agentId });
1113
+ }
1114
+ if (!options.allowCreate) {
1007
1115
  return;
1008
1116
  }
1009
1117
  const nextOrdinal = this.db
@@ -1032,7 +1140,21 @@ export class TalkingStickService {
1032
1140
  )
1033
1141
  VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1034
1142
  `)
1035
- .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);
1143
+ .run(roomId, agentId, nextOrdinal, timestamp, timestamp, options.recordWait ? timestamp : null, normalized.host_id, normalized.pid, normalized.process_started_at, normalized.session_kind, normalized.display_name, normalized.harness_name, normalized.harness_session_id, normalized.harness_host_id, normalized.harness_pid, normalized.harness_process_started_at);
1144
+ }
1145
+ /**
1146
+ * Presence for a sustained self-targeted event receiver (the §2 ambient
1147
+ * receiver). Watching the room is the documented per-session presence
1148
+ * primitive, so it refreshes — and, for a receiver that never ran `tt join`,
1149
+ * registers — the member, but never records turn interest (last_wait_at).
1150
+ */
1151
+ recordReceiverPresence(roomId, agentId, timestamp, processMetadata) {
1152
+ this.applyPresence(roomId, agentId, timestamp, {
1153
+ processMetadata,
1154
+ recordWait: false,
1155
+ allowCreate: true,
1156
+ requireMember: false
1157
+ });
1036
1158
  }
1037
1159
  mergeMemberProcessMetadata(room, existing, incoming) {
1038
1160
  if (!this.shouldPreserveExactMemberProcessMetadata(room, existing, incoming)) {
@@ -1130,17 +1252,13 @@ export class TalkingStickService {
1130
1252
  return (sessionKindPriority(incoming.session_kind) <
1131
1253
  sessionKindPriority(existing.session_kind));
1132
1254
  }
1133
- touchMember(roomId, agentId, timestamp) {
1134
- const result = this.db
1135
- .prepare(`
1136
- UPDATE room_members
1137
- SET last_seen_at = ?, status = 'active'
1138
- WHERE room_id = ? AND agent_id = ?
1139
- `)
1140
- .run(timestamp, roomId, agentId);
1141
- if (result.changes === 0) {
1142
- throw new ProtocolError("unknown_member", "Agent must join the room before using this tool.", { to_agent_id: agentId });
1143
- }
1255
+ touchMember(roomId, agentId, timestamp, processMetadata) {
1256
+ this.applyPresence(roomId, agentId, timestamp, {
1257
+ processMetadata,
1258
+ recordWait: false,
1259
+ allowCreate: false,
1260
+ requireMember: true
1261
+ });
1144
1262
  }
1145
1263
  recordParkHint(roomId, agentId, pendingHandoffEventSeq) {
1146
1264
  this.db
@@ -1151,26 +1269,24 @@ export class TalkingStickService {
1151
1269
  `)
1152
1270
  .run(pendingHandoffEventSeq, roomId, agentId);
1153
1271
  }
1154
- touchWaitingMember(roomId, agentId, timestamp) {
1155
- const result = this.db
1156
- .prepare(`
1157
- UPDATE room_members
1158
- SET last_seen_at = ?, last_wait_at = ?, status = 'active'
1159
- WHERE room_id = ? AND agent_id = ?
1160
- `)
1161
- .run(timestamp, timestamp, roomId, agentId);
1162
- if (result.changes === 0) {
1163
- throw new ProtocolError("unknown_member", "Agent must join the room before using this tool.", { to_agent_id: agentId });
1164
- }
1272
+ touchWaitingMember(roomId, agentId, timestamp, processMetadata) {
1273
+ this.applyPresence(roomId, agentId, timestamp, {
1274
+ processMetadata,
1275
+ recordWait: true,
1276
+ allowCreate: false,
1277
+ requireMember: true
1278
+ });
1165
1279
  }
1166
- touchKnownMember(roomId, agentId, timestamp) {
1280
+ touchKnownMember(roomId, agentId, timestamp, processMetadata) {
1167
1281
  if (!agentId) {
1168
1282
  return;
1169
1283
  }
1170
- if (!this.getMember(roomId, agentId)) {
1171
- return;
1172
- }
1173
- this.touchMember(roomId, agentId, timestamp);
1284
+ this.applyPresence(roomId, agentId, timestamp, {
1285
+ processMetadata,
1286
+ recordWait: false,
1287
+ allowCreate: false,
1288
+ requireMember: false
1289
+ });
1174
1290
  }
1175
1291
  assertOwnerMutation(room, input, now) {
1176
1292
  const inspection = this.inspectRoomForMutation(room, now);
@@ -1252,6 +1368,15 @@ export class TalkingStickService {
1252
1368
  }
1253
1369
  return "owner_timeout";
1254
1370
  }
1371
+ if (room.owner &&
1372
+ room.owner !== agentId &&
1373
+ inspection.state === "owned" &&
1374
+ this.isOwnerHarnessIdle(inspection.ownerMember, now)) {
1375
+ // Tier-2: the owner's harness is alive but idle, and `agentId` is the
1376
+ // waiting peer exercising the takeover it was offered. This is the peer
1377
+ // gate — a takeover is only resolved here on behalf of an actual waiter.
1378
+ return "owner_idle";
1379
+ }
1255
1380
  throw new ProtocolError("takeover_not_available", "No takeover timeout is currently available for this room.", { room_state: inspection.state });
1256
1381
  }
1257
1382
  isClaimTakeoverEligible(room, agentId, now, inspection) {
@@ -1465,6 +1590,25 @@ export class TalkingStickService {
1465
1590
  }
1466
1591
  return this.hasRecentPresence(member, now);
1467
1592
  }
1593
+ /**
1594
+ * Tier-2 stale-guardian detection: the owner's harness process is alive (so
1595
+ * this is neither `owner_gone` nor a `stale_owner` lease timeout — the
1596
+ * guardian is faithfully renewing), yet the harness itself has run no `tt`
1597
+ * command for longer than `ownerActivityTtlMs`. Surfacing this as
1598
+ * `takeover_available` is always peer-gated: it is only ever evaluated for a
1599
+ * caller who is themselves waiting for the turn, so a long solo edit with no
1600
+ * peers waiting is never disturbed.
1601
+ */
1602
+ isOwnerHarnessIdle(ownerMember, now) {
1603
+ if (!ownerMember) {
1604
+ return false;
1605
+ }
1606
+ if (this.getMemberProcessLiveness(ownerMember) === "gone") {
1607
+ return false;
1608
+ }
1609
+ return (now.getTime() - Date.parse(ownerMember.last_seen_at) >
1610
+ this.policy.ownerActivityTtlMs);
1611
+ }
1468
1612
  shouldRetainIdleRoom(member, now) {
1469
1613
  const liveness = this.getMemberProcessLiveness(member);
1470
1614
  if (liveness === "alive") {
@@ -1660,6 +1804,27 @@ export class TalkingStickService {
1660
1804
  }
1661
1805
  }
1662
1806
  getMemberProcessLiveness(member) {
1807
+ // Prefer the durable harness identity over the bare pid. The bare pid is
1808
+ // frequently the per-turn guardian process (sessionKind `human_guardian`),
1809
+ // which the metadata merge pins onto the member row. Inspecting the bare
1810
+ // pid would let a dead guardian mark a live harness `inactive`, and let a
1811
+ // still-renewing guardian keep an abandoned harness looking `active`. The
1812
+ // harness pid is what actually represents the participant. Fall back to the
1813
+ // bare pid only when no harness identity was recorded (e.g. raw CLI use).
1814
+ if (hasHarnessProcessIdentity(member)) {
1815
+ return this.processLivenessChecker({
1816
+ host_id: member.harness_host_id ?? member.host_id,
1817
+ pid: member.harness_pid,
1818
+ process_started_at: member.harness_process_started_at,
1819
+ session_kind: member.session_kind,
1820
+ display_name: member.display_name,
1821
+ harness_name: member.harness_name,
1822
+ harness_session_id: member.harness_session_id,
1823
+ harness_host_id: member.harness_host_id,
1824
+ harness_pid: member.harness_pid,
1825
+ harness_process_started_at: member.harness_process_started_at
1826
+ });
1827
+ }
1663
1828
  return this.processLivenessChecker({
1664
1829
  host_id: member.host_id,
1665
1830
  pid: member.pid,
@@ -1824,6 +1989,13 @@ function hasExactProcessIdentity(metadata) {
1824
1989
  metadata.process_started_at !== undefined &&
1825
1990
  metadata.process_started_at.trim() !== "");
1826
1991
  }
1992
+ function hasHarnessProcessIdentity(metadata) {
1993
+ return (metadata.harness_pid !== null &&
1994
+ metadata.harness_pid !== undefined &&
1995
+ metadata.harness_process_started_at !== null &&
1996
+ metadata.harness_process_started_at !== undefined &&
1997
+ metadata.harness_process_started_at.trim() !== "");
1998
+ }
1827
1999
  function hasHarnessInstanceIdentity(metadata) {
1828
2000
  return (metadata.harness_name !== null &&
1829
2001
  metadata.harness_name !== undefined &&
@@ -0,0 +1,17 @@
1
+ # Talking Stick 0.4.9
2
+
3
+ Date: 2026-06-03
4
+
5
+ ## Fixed
6
+ - **Home-level workspace markers no longer capture scratch directories.** `resolveContextPath` now treats the user's home directory as a marker boundary for descendant paths, so an incidental `~/package.json`, `~/AGENTS.md`, or similar marker does not make unrelated markerless paths under `$HOME` join a home-scoped room. Explicitly joining `$HOME` still resolves to home, and real project markers below home still win.
7
+
8
+ ## Verification
9
+
10
+ ```bash
11
+ npm run typecheck
12
+ npm test
13
+ npm run build
14
+ node dist/cli.js --help
15
+ git diff --check
16
+ npm pack --dry-run
17
+ ```
@@ -54,7 +54,7 @@ This avoids the common monorepo failure mode where one agent starts in `/repo/pa
54
54
  Preferred workspace root resolution:
55
55
 
56
56
  1. If the request path is inside a git worktree, use the git top-level path.
57
- 2. Otherwise, use the nearest ancestor containing a recognized workspace marker such as `CLAUDE.md`, `AGENTS.md`, `package.json`, `pyproject.toml`, `Cargo.toml`, or `go.mod`.
57
+ 2. Otherwise, use the nearest ancestor containing a recognized workspace marker such as `CLAUDE.md`, `AGENTS.md`, `package.json`, `pyproject.toml`, `Cargo.toml`, or `go.mod`. When the request path is a child of the user's home directory, marker files directly in home are ignored so incidental home-level files do not capture unrelated scratch workspaces.
58
58
  3. Otherwise, use the canonical request path.
59
59
 
60
60
  Canonicalization applied before room lookup:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talking-stick",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
4
4
  "description": "CLI coordination tool for path-scoped agent handoffs.",
5
5
  "type": "module",
6
6
  "bin": {