talking-stick 0.4.6 → 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) {
@@ -116,7 +121,7 @@ async function followEvents(runtime, parsed, input) {
116
121
  process.stderr.write(`cursor_event_seq=${cursor}\n`);
117
122
  }
118
123
  }
119
- function resolveTargetFilter(runtime, identity, roomId, raw) {
124
+ export function resolveTargetFilter(runtime, identity, roomId, raw) {
120
125
  if (raw === "self" || raw === "any") {
121
126
  return raw;
122
127
  }
@@ -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
  }
@@ -1,9 +1,23 @@
1
1
  import { SUPPORTED_HARNESSES } from "../install.js";
2
2
  import { isKnownHarnessCliEnv } from "./identity.js";
3
3
  import { hasOption } from "./parser.js";
4
+ export const COORDINATION_PROMPT = "Keep `tt wait` or `tt events` active until all goals are met; re-read the Talking Stick skill if context slips.";
5
+ const COORDINATION_PROMPT_COMMANDS = new Set([
6
+ "join",
7
+ "state",
8
+ "events",
9
+ "wait",
10
+ "try",
11
+ "take",
12
+ "takeover",
13
+ "release",
14
+ "pass",
15
+ "assign",
16
+ "msg send"
17
+ ]);
4
18
  export function printResult(parsed, result, renderText) {
5
19
  if (shouldUseJson(parsed)) {
6
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
20
+ process.stdout.write(`${JSON.stringify(withCoordinationPrompt(parsed, result), null, 2)}\n`);
7
21
  return;
8
22
  }
9
23
  process.stdout.write(`${renderText()}\n`);
@@ -23,6 +37,24 @@ export function shouldUseJson(parsed, env = process.env) {
23
37
  return true;
24
38
  return false;
25
39
  }
40
+ export function withCoordinationPrompt(parsed, result) {
41
+ if (!COORDINATION_PROMPT_COMMANDS.has(parsed.name)) {
42
+ return result;
43
+ }
44
+ if (!isObjectRecord(result) || Array.isArray(result)) {
45
+ return result;
46
+ }
47
+ if ("coordination_prompt" in result) {
48
+ return result;
49
+ }
50
+ return {
51
+ ...result,
52
+ coordination_prompt: COORDINATION_PROMPT
53
+ };
54
+ }
55
+ function isObjectRecord(value) {
56
+ return typeof value === "object" && value !== null;
57
+ }
26
58
  export function formatRelativeTime(iso, now = new Date()) {
27
59
  if (!iso)
28
60
  return "—";
@@ -126,8 +158,8 @@ Commands:
126
158
  tt join [path] [--force-new]
127
159
  tt leave [path]
128
160
  tt kick <agent_id> [path] [--reason TEXT] [--force]
129
- tt wait [path] [--timeout 110s] [--park]
130
- tt try [path] [--park]
161
+ tt wait [path] [--timeout 110s] [--park] [--events --after N]
162
+ tt try [path] [--park] [--events --after N]
131
163
  tt state [path]
132
164
  tt events [path] [--after N] [--limit N] [--wait|--follow] [--event TYPE[,TYPE]] [--target self|any|agent]
133
165
  tt msg send <recipient|room> <body...> [--interrupt] [--stdin] [--path DIR]
@@ -137,7 +137,7 @@ export const COMMAND_REGISTRY = [
137
137
  needsRuntime: true,
138
138
  startupMaintenance: true,
139
139
  internal: false,
140
- usage: "tt wait [path] [--timeout 110s] [--park]",
140
+ usage: "tt wait [path] [--timeout 110s] [--park] [--events --after N] [--target self|any|agent]",
141
141
  description: "Wait until this agent can claim the stick.",
142
142
  handler: ({ runtime, parsed, cliEntryUrl }) => handleWaitCommand(requireRuntime(runtime), parsed, false, cliEntryUrl)
143
143
  },
@@ -146,7 +146,7 @@ export const COMMAND_REGISTRY = [
146
146
  needsRuntime: true,
147
147
  startupMaintenance: true,
148
148
  internal: false,
149
- usage: "tt try [path] [--park]",
149
+ usage: "tt try [path] [--park] [--events --after N] [--target self|any|agent]",
150
150
  description: "Check turn availability without waiting.",
151
151
  handler: ({ runtime, parsed, cliEntryUrl }) => handleWaitCommand(requireRuntime(runtime), parsed, true, cliEntryUrl)
152
152
  },
@@ -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) {
@@ -2,20 +2,35 @@ import { clearCliSessionLease, createSystemProcessInspector, findCliSessionByRoo
2
2
  import { checkGuardianLiveness, spawnGuardian, stopGuardian } from "./guardian.js";
3
3
  import { resolveHandoff } from "./handoff.js";
4
4
  import { deriveCliIdentity, resolveTakeoverReason, shouldUseOperatorOverride } from "./identity.js";
5
- import { hasOption, normalizeBooleanFlag, parseWaitTimeout } from "./parser.js";
5
+ import { getStringOption, hasOption, normalizeBooleanFlag, parseRequiredInteger, parseWaitTimeout } from "./parser.js";
6
+ import { resolveTargetFilter } from "./event-stream.js";
6
7
  import { formatWaitResult, printResult } from "./output.js";
7
8
  import { requireLeaseSession, upsertSessionFromJoin } from "./session.js";
8
9
  export async function handleWaitCommand(runtime, parsed, isTry, cliEntryUrl) {
9
10
  normalizeBooleanFlag(parsed, "park");
11
+ normalizeBooleanFlag(parsed, "events");
10
12
  const park = hasOption(parsed, "park");
13
+ const includeEvents = hasOption(parsed, "events");
14
+ const afterEventSeq = includeEvents
15
+ ? parseRequiredInteger(parsed, "after")
16
+ : undefined;
17
+ if (!includeEvents && hasOption(parsed, "after")) {
18
+ throw new Error("Pass --after only with --events.");
19
+ }
11
20
  const contextPath = parsed.positionals[0] ?? process.cwd();
12
21
  const identity = deriveCliIdentity(parsed);
13
22
  const joined = runtime.commands.joinPath(identity, { context_path: contextPath });
14
23
  upsertSessionFromJoin(identity, joined);
24
+ const targetAgentId = includeEvents
25
+ ? resolveTargetFilter(runtime, identity, joined.room_id, getStringOption(parsed, "target") ?? "self")
26
+ : undefined;
15
27
  const waitResult = await runtime.commands.waitForTurn(identity, {
16
28
  room_id: joined.room_id,
17
29
  max_wait_ms: isTry ? 0 : parseWaitTimeout(parsed),
18
- auto_claim: park ? false : undefined
30
+ auto_claim: park ? false : undefined,
31
+ include_events: includeEvents,
32
+ after_event_seq: afterEventSeq,
33
+ target_agent_id: targetAgentId
19
34
  });
20
35
  if (waitResult.status === "your_turn") {
21
36
  if (waitResult.reason === "already_owner") {
@@ -230,7 +245,8 @@ function resolveAssignmentTarget(runtime, identity, session, selector) {
230
245
  }
231
246
  const state = runtime.commands.getRoomState({
232
247
  room_id: session.room_id,
233
- agent_id: identity.agent_id
248
+ agent_id: identity.agent_id,
249
+ process_metadata: identity.process_metadata
234
250
  });
235
251
  const normalizedSelector = selector.toLowerCase();
236
252
  const candidates = state.members.filter((member) => {
@@ -250,7 +266,8 @@ function resolveAssignmentTarget(runtime, identity, session, selector) {
250
266
  const events = runtime.commands.getRoomEvents({
251
267
  room_id: session.room_id,
252
268
  agent_id: identity.agent_id,
253
- limit: 500
269
+ limit: 500,
270
+ process_metadata: identity.process_metadata
254
271
  });
255
272
  return pickFairAssignmentCandidate(candidates, events).agent_id;
256
273
  }
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import { createRuntime } from "./cli/runtime.js";
10
10
  import { runStartupMaintenance } from "./cli/startup-maintenance.js";
11
11
  export { checkGuardianLiveness } from "./cli/guardian.js";
12
12
  export { parseHandoffJson } from "./cli/handoff.js";
13
- export { formatRelativeTime, shouldUseJson } from "./cli/output.js";
13
+ export { COORDINATION_PROMPT, formatRelativeTime, shouldUseJson, withCoordinationPrompt } from "./cli/output.js";
14
14
  export { shouldAutoSyncInstalledSkills, shouldRunFirstRunMcpMigration } from "./cli/startup-maintenance.js";
15
15
  export async function runCli(argv = process.argv.slice(2)) {
16
16
  const parsed = parseCommand(argv);
package/dist/commands.js CHANGED
@@ -38,7 +38,11 @@ export class TalkingStickCommands {
38
38
  agent_id: identity.agent_id,
39
39
  room_id: input.room_id,
40
40
  max_wait_ms: input.max_wait_ms,
41
- auto_claim: input.auto_claim
41
+ auto_claim: input.auto_claim,
42
+ include_events: input.include_events,
43
+ after_event_seq: input.after_event_seq,
44
+ target_agent_id: input.target_agent_id,
45
+ process_metadata: identity.process_metadata
42
46
  });
43
47
  }
44
48
  heartbeat(identity, input) {
@@ -49,6 +53,14 @@ export class TalkingStickCommands {
49
53
  expected_turn_id: input.expected_turn_id
50
54
  });
51
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
+ }
52
64
  releaseStick(identity, input) {
53
65
  return this.service.releaseStick({
54
66
  agent_id: identity.agent_id,
@@ -89,7 +101,8 @@ export class TalkingStickCommands {
89
101
  room_id: input.room_id,
90
102
  body: input.body,
91
103
  to_agent_id: input.to_agent_id,
92
- delivery_hint: input.delivery_hint
104
+ delivery_hint: input.delivery_hint,
105
+ process_metadata: identity.process_metadata
93
106
  });
94
107
  }
95
108
  waitForEvents(input) {
@@ -103,7 +116,8 @@ export class TalkingStickCommands {
103
116
  agent_id: identity.agent_id,
104
117
  room_id: input.room_id,
105
118
  body: input.body,
106
- turn_id: input.turn_id
119
+ turn_id: input.turn_id,
120
+ process_metadata: identity.process_metadata
107
121
  });
108
122
  }
109
123
  listNotes(identity, input) {
@@ -112,7 +126,8 @@ export class TalkingStickCommands {
112
126
  agent_id: identity?.agent_id,
113
127
  after_note_id: input.after_note_id,
114
128
  include_resolved: input.include_resolved,
115
- limit: input.limit
129
+ limit: input.limit,
130
+ process_metadata: identity?.process_metadata
116
131
  });
117
132
  }
118
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
@@ -233,6 +233,9 @@ export class TalkingStickService {
233
233
  assertNonEmpty(input.agent_id, "agent_id");
234
234
  assertNonEmpty(input.room_id, "room_id");
235
235
  this.purgeExpiredIdleRooms(this.now());
236
+ if (input.include_events) {
237
+ return this.waitForTurnWithEvents(input);
238
+ }
236
239
  const maxWaitMs = input.max_wait_ms ?? this.policy.waitForTurnMaxWaitMs;
237
240
  const deadline = Date.now() + Math.max(0, maxWaitMs);
238
241
  while (true) {
@@ -247,6 +250,53 @@ export class TalkingStickService {
247
250
  await sleep(Math.min(this.policy.waitForTurnPollMs, remainingMs));
248
251
  }
249
252
  }
253
+ async waitForTurnWithEvents(input) {
254
+ if (input.after_event_seq === undefined) {
255
+ throw new ProtocolError("invalid_cursor", "after_event_seq is required when include_events is true.");
256
+ }
257
+ if (!Number.isInteger(input.after_event_seq) || input.after_event_seq < 0) {
258
+ throw new ProtocolError("invalid_cursor", "after_event_seq must be a non-negative integer.");
259
+ }
260
+ const targetFilter = input.target_agent_id ?? "self";
261
+ if (targetFilter === "self" && !input.agent_id) {
262
+ throw new ProtocolError("agent_id_required", "agent_id is required when target_agent_id is 'self'.");
263
+ }
264
+ this.requireRoom(input.room_id);
265
+ this.warmRoomTurnLiveness(input.room_id);
266
+ const startedAsOwner = this.isActiveOwner(input.room_id, input.agent_id, this.now());
267
+ const afterEventSeq = input.after_event_seq;
268
+ const maxWaitMs = input.max_wait_ms ?? this.policy.waitForTurnMaxWaitMs;
269
+ const deadline = Date.now() + Math.max(0, maxWaitMs);
270
+ while (true) {
271
+ this.warmRoomTurnLiveness(input.room_id);
272
+ const waitResult = withImmediateTransaction(this.db, () => startedAsOwner
273
+ ? this.waitForOwnerEventTurnOnce(input)
274
+ : this.waitForTurnOnce(input));
275
+ const events = this.queryEvents({
276
+ room_id: input.room_id,
277
+ after_event_seq: afterEventSeq,
278
+ event_types: null,
279
+ target: targetFilter,
280
+ caller_agent_id: input.agent_id,
281
+ from_agent_id: null,
282
+ limit: this.policy.waitForEventsBatchLimit
283
+ });
284
+ if (waitResult.status === "closed") {
285
+ return this.withWaitEvents(waitResult, events, afterEventSeq, "closed");
286
+ }
287
+ if (this.isTurnWake(waitResult, startedAsOwner)) {
288
+ return this.withWaitEvents(waitResult, events, afterEventSeq, "turn");
289
+ }
290
+ if (events.length > 0) {
291
+ return this.withWaitEvents(waitResult, events, afterEventSeq, "event");
292
+ }
293
+ if (Date.now() >= deadline) {
294
+ return this.withWaitEvents(waitResult, events, afterEventSeq, "timeout");
295
+ }
296
+ const remainingMs = deadline - Date.now();
297
+ await sleep(Math.min(this.policy.waitForTurnPollMs, this.policy.waitForEventsPollMs, remainingMs));
298
+ }
299
+ }
250
300
  heartbeat(input) {
251
301
  const now = this.now();
252
302
  const timestamp = now.toISOString();
@@ -256,7 +306,14 @@ export class TalkingStickService {
256
306
  return withImmediateTransaction(this.db, () => {
257
307
  const room = this.requireRoom(input.room_id);
258
308
  this.assertOwnerMutation(room, input, now);
259
- 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.
260
317
  this.db
261
318
  .prepare(`
262
319
  UPDATE path_rooms
@@ -321,6 +378,56 @@ export class TalkingStickService {
321
378
  };
322
379
  });
323
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
+ }
324
431
  passStick(input) {
325
432
  validateHandoff(input.handoff);
326
433
  assertNonEmpty(input.to_agent_id, "to_agent_id");
@@ -436,7 +543,7 @@ export class TalkingStickService {
436
543
  const timestamp = now.toISOString();
437
544
  this.purgeExpiredIdleRooms(now);
438
545
  const room = this.requireRoom(input.room_id);
439
- this.touchKnownMember(input.room_id, input.agent_id, timestamp);
546
+ this.touchKnownMember(input.room_id, input.agent_id, timestamp, input.process_metadata);
440
547
  const inspection = this.inspectRoom(room, now);
441
548
  return {
442
549
  room: this.mapRoom(inspection, now),
@@ -445,7 +552,7 @@ export class TalkingStickService {
445
552
  }
446
553
  getRoomEvents(input) {
447
554
  this.purgeExpiredIdleRooms(this.now());
448
- 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);
449
556
  const afterEventSeq = input.after_event_seq ?? 0;
450
557
  const limit = Math.min(input.limit ?? 100, 500);
451
558
  return this.db
@@ -482,7 +589,7 @@ export class TalkingStickService {
482
589
  if (room.state === "closed") {
483
590
  throw new ProtocolError("room_closed", "Messages cannot be sent to a closed room.", { room_id: input.room_id });
484
591
  }
485
- this.touchMember(input.room_id, input.agent_id, timestamp);
592
+ this.touchMember(input.room_id, input.agent_id, timestamp, input.process_metadata);
486
593
  if (input.to_agent_id) {
487
594
  const target = this.getMember(input.room_id, input.to_agent_id);
488
595
  if (!target) {
@@ -517,6 +624,17 @@ export class TalkingStickService {
517
624
  if (targetFilter === "self" && !input.agent_id) {
518
625
  throw new ProtocolError("agent_id_required", "agent_id is required when target_agent_id is 'self'.");
519
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
+ }
520
638
  const eventTypes = normalizeEventTypeFilter(input.event_type);
521
639
  const afterEventSeq = input.after_event_seq ?? 0;
522
640
  const maxWaitMs = Math.min(Math.max(input.max_wait_ms ?? this.policy.waitForEventsMaxWaitMs, 0), this.policy.waitForEventsMaxWaitMs);
@@ -572,7 +690,7 @@ export class TalkingStickService {
572
690
  input.turn_id > room.turn_id)) {
573
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 });
574
692
  }
575
- this.touchMember(input.room_id, input.agent_id, timestamp);
693
+ this.touchMember(input.room_id, input.agent_id, timestamp, input.process_metadata);
576
694
  const noteId = randomUUID();
577
695
  const turnId = input.turn_id ?? null;
578
696
  this.db
@@ -595,7 +713,7 @@ export class TalkingStickService {
595
713
  assertNonEmpty(input.room_id, "room_id");
596
714
  this.purgeExpiredIdleRooms(this.now());
597
715
  this.requireRoom(input.room_id);
598
- 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);
599
717
  const limit = Math.min(Math.max(input.limit ?? 50, 1), 200);
600
718
  const includeResolved = input.include_resolved === true;
601
719
  let anchorCreatedAt = null;
@@ -643,7 +761,7 @@ export class TalkingStickService {
643
761
  const now = this.now();
644
762
  const timestamp = now.toISOString();
645
763
  const room = this.requireRoom(input.room_id);
646
- this.touchWaitingMember(input.room_id, input.agent_id, timestamp);
764
+ this.touchWaitingMember(input.room_id, input.agent_id, timestamp, input.process_metadata);
647
765
  const inspection = this.inspectRoomForMutation(room, now);
648
766
  if (room.state === "closed") {
649
767
  return { status: "closed", room_id: input.room_id };
@@ -757,6 +875,19 @@ export class TalkingStickService {
757
875
  current_owner: room.owner
758
876
  };
759
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
+ }
760
891
  return {
761
892
  status: "not_yet",
762
893
  room_state: inspection.state,
@@ -767,6 +898,72 @@ export class TalkingStickService {
767
898
  claim_expires_at: room.claim_expires_at ?? undefined
768
899
  };
769
900
  }
901
+ waitForOwnerEventTurnOnce(input) {
902
+ const now = this.now();
903
+ const timestamp = now.toISOString();
904
+ const room = this.requireRoom(input.room_id);
905
+ this.touchWaitingMember(input.room_id, input.agent_id, timestamp, input.process_metadata);
906
+ const inspection = this.inspectRoomForMutation(room, now);
907
+ if (room.state === "closed") {
908
+ return { status: "closed", room_id: input.room_id };
909
+ }
910
+ if (room.owner === input.agent_id &&
911
+ room.lease_id &&
912
+ room.lease_expires_at &&
913
+ !this.hasExpired(room.lease_expires_at, now)) {
914
+ return {
915
+ status: "your_turn",
916
+ room_id: input.room_id,
917
+ turn_id: room.turn_id,
918
+ lease_id: room.lease_id,
919
+ handoff: null,
920
+ from_agent_id: null,
921
+ reason: "already_owner"
922
+ };
923
+ }
924
+ return {
925
+ status: "not_yet",
926
+ room_state: inspection.state,
927
+ turn_id: room.turn_id,
928
+ current_owner: room.owner ?? undefined,
929
+ reserved_for: room.reserved_for ?? undefined,
930
+ lease_expires_at: room.lease_expires_at ?? undefined,
931
+ claim_expires_at: room.claim_expires_at ?? undefined,
932
+ reason: "lost_turn"
933
+ };
934
+ }
935
+ isActiveOwner(roomId, agentId, now) {
936
+ const room = this.requireRoom(roomId);
937
+ return (room.owner === agentId &&
938
+ !!room.lease_id &&
939
+ !!room.lease_expires_at &&
940
+ !this.hasExpired(room.lease_expires_at, now));
941
+ }
942
+ isTurnWake(result, startedAsOwner = false) {
943
+ if (startedAsOwner &&
944
+ result.status === "your_turn" &&
945
+ result.reason === "already_owner") {
946
+ return false;
947
+ }
948
+ return (result.status === "your_turn" ||
949
+ result.status === "takeover_available" ||
950
+ // Park hints are turn wakes only because waitForTurnOnce throttles
951
+ // auto_claim_disabled per pending handoff; subsequent parked polls become
952
+ // plain not_yet and can timeout instead of tight-looping.
953
+ (result.status === "not_yet" &&
954
+ (result.reason === "auto_claim_disabled" ||
955
+ result.reason === "lost_turn")));
956
+ }
957
+ withWaitEvents(result, events, afterEventSeq, wakeReason) {
958
+ return {
959
+ ...result,
960
+ events,
961
+ cursor_event_seq: events.length > 0
962
+ ? events[events.length - 1].event_seq
963
+ : afterEventSeq,
964
+ wake_reason: wakeReason
965
+ };
966
+ }
770
967
  grantTurn(room, agentId, now) {
771
968
  const timestamp = now.toISOString();
772
969
  const nextTurnId = room.turn_id + 1;
@@ -864,30 +1061,57 @@ export class TalkingStickService {
864
1061
  return this.requireRoom(roomId);
865
1062
  }
866
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) {
867
1088
  const existing = this.getMember(roomId, agentId);
868
- const normalizedMetadata = normalizeProcessMetadata(processMetadata);
1089
+ const normalized = normalizeProcessMetadata(options.processMetadata);
1090
+ const hasIdentity = hasExactProcessIdentity(normalized) ||
1091
+ hasHarnessProcessIdentity(normalized);
869
1092
  if (existing) {
870
- const room = this.requireRoom(roomId);
871
- 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);
872
1106
  this.db
873
- .prepare(`
874
- UPDATE room_members
875
- SET last_seen_at = ?,
876
- last_wait_at = ?,
877
- status = 'active',
878
- host_id = ?,
879
- pid = ?,
880
- process_started_at = ?,
881
- session_kind = ?,
882
- display_name = ?,
883
- harness_name = ?,
884
- harness_session_id = ?,
885
- harness_host_id = ?,
886
- harness_pid = ?,
887
- harness_process_started_at = ?
888
- WHERE room_id = ? AND agent_id = ?
889
- `)
890
- .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) {
891
1115
  return;
892
1116
  }
893
1117
  const nextOrdinal = this.db
@@ -916,7 +1140,21 @@ export class TalkingStickService {
916
1140
  )
917
1141
  VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
918
1142
  `)
919
- .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
+ });
920
1158
  }
921
1159
  mergeMemberProcessMetadata(room, existing, incoming) {
922
1160
  if (!this.shouldPreserveExactMemberProcessMetadata(room, existing, incoming)) {
@@ -1014,17 +1252,13 @@ export class TalkingStickService {
1014
1252
  return (sessionKindPriority(incoming.session_kind) <
1015
1253
  sessionKindPriority(existing.session_kind));
1016
1254
  }
1017
- touchMember(roomId, agentId, timestamp) {
1018
- const result = this.db
1019
- .prepare(`
1020
- UPDATE room_members
1021
- SET last_seen_at = ?, status = 'active'
1022
- WHERE room_id = ? AND agent_id = ?
1023
- `)
1024
- .run(timestamp, roomId, agentId);
1025
- if (result.changes === 0) {
1026
- throw new ProtocolError("unknown_member", "Agent must join the room before using this tool.", { to_agent_id: agentId });
1027
- }
1255
+ touchMember(roomId, agentId, timestamp, processMetadata) {
1256
+ this.applyPresence(roomId, agentId, timestamp, {
1257
+ processMetadata,
1258
+ recordWait: false,
1259
+ allowCreate: false,
1260
+ requireMember: true
1261
+ });
1028
1262
  }
1029
1263
  recordParkHint(roomId, agentId, pendingHandoffEventSeq) {
1030
1264
  this.db
@@ -1035,26 +1269,24 @@ export class TalkingStickService {
1035
1269
  `)
1036
1270
  .run(pendingHandoffEventSeq, roomId, agentId);
1037
1271
  }
1038
- touchWaitingMember(roomId, agentId, timestamp) {
1039
- const result = this.db
1040
- .prepare(`
1041
- UPDATE room_members
1042
- SET last_seen_at = ?, last_wait_at = ?, status = 'active'
1043
- WHERE room_id = ? AND agent_id = ?
1044
- `)
1045
- .run(timestamp, timestamp, roomId, agentId);
1046
- if (result.changes === 0) {
1047
- throw new ProtocolError("unknown_member", "Agent must join the room before using this tool.", { to_agent_id: agentId });
1048
- }
1272
+ touchWaitingMember(roomId, agentId, timestamp, processMetadata) {
1273
+ this.applyPresence(roomId, agentId, timestamp, {
1274
+ processMetadata,
1275
+ recordWait: true,
1276
+ allowCreate: false,
1277
+ requireMember: true
1278
+ });
1049
1279
  }
1050
- touchKnownMember(roomId, agentId, timestamp) {
1280
+ touchKnownMember(roomId, agentId, timestamp, processMetadata) {
1051
1281
  if (!agentId) {
1052
1282
  return;
1053
1283
  }
1054
- if (!this.getMember(roomId, agentId)) {
1055
- return;
1056
- }
1057
- this.touchMember(roomId, agentId, timestamp);
1284
+ this.applyPresence(roomId, agentId, timestamp, {
1285
+ processMetadata,
1286
+ recordWait: false,
1287
+ allowCreate: false,
1288
+ requireMember: false
1289
+ });
1058
1290
  }
1059
1291
  assertOwnerMutation(room, input, now) {
1060
1292
  const inspection = this.inspectRoomForMutation(room, now);
@@ -1136,6 +1368,15 @@ export class TalkingStickService {
1136
1368
  }
1137
1369
  return "owner_timeout";
1138
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
+ }
1139
1380
  throw new ProtocolError("takeover_not_available", "No takeover timeout is currently available for this room.", { room_state: inspection.state });
1140
1381
  }
1141
1382
  isClaimTakeoverEligible(room, agentId, now, inspection) {
@@ -1349,6 +1590,25 @@ export class TalkingStickService {
1349
1590
  }
1350
1591
  return this.hasRecentPresence(member, now);
1351
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
+ }
1352
1612
  shouldRetainIdleRoom(member, now) {
1353
1613
  const liveness = this.getMemberProcessLiveness(member);
1354
1614
  if (liveness === "alive") {
@@ -1544,6 +1804,27 @@ export class TalkingStickService {
1544
1804
  }
1545
1805
  }
1546
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
+ }
1547
1828
  return this.processLivenessChecker({
1548
1829
  host_id: member.host_id,
1549
1830
  pid: member.pid,
@@ -1708,6 +1989,13 @@ function hasExactProcessIdentity(metadata) {
1708
1989
  metadata.process_started_at !== undefined &&
1709
1990
  metadata.process_started_at.trim() !== "");
1710
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
+ }
1711
1999
  function hasHarnessInstanceIdentity(metadata) {
1712
2000
  return (metadata.harness_name !== null &&
1713
2001
  metadata.harness_name !== undefined &&
@@ -0,0 +1,211 @@
1
+ # wait-events ambient loop
2
+
3
+ > **Status:** converged Codex + Claude design after operator pushback.
4
+ >
5
+ > **Problem trigger:** a holder still needs live messages while holding the
6
+ > stick. A checkpoint-only wait loop lets agents fall out of receive exactly
7
+ > when coordination matters.
8
+
9
+ ## Problem
10
+
11
+ The current recommended workflow has two concepts:
12
+
13
+ - `tt wait --json` grants ownership and starts or repairs the guardian.
14
+ - `tt events --follow --json` is the ambient receiver for messages, passes,
15
+ releases, and assignments.
16
+
17
+ That split is correct semantically, but it is too fragile operationally. Some
18
+ harnesses cannot observe a long-running child process line-by-line and only see
19
+ process completion. Others can start an ambient receiver but then stop paying
20
+ attention after a release, timeout, or apparent task boundary. In practice, an
21
+ agent can miss a late message or stop receiving while it owns the turn.
22
+
23
+ The UX goal is one harness receive loop that remains active while waiting and
24
+ while holding.
25
+
26
+ ## Decision
27
+
28
+ Add an event-aware wait mode:
29
+
30
+ ```sh
31
+ tt wait --events --after <event_seq> --json
32
+ ```
33
+
34
+ This is the recommended ambient loop for harnesses. It runs in the background,
35
+ returns on each wake, and is restarted with the returned `cursor_event_seq`.
36
+
37
+ The normal ownership rule remains unchanged: only a `your_turn` wait result
38
+ with a live guardian grants authority to mutate shared workspace state.
39
+ Events returned by the command are observability, not permission.
40
+
41
+ `tt events --follow` stays available for audit, debugging, and lower-level
42
+ event inspection, but the bundled skill should stop teaching it as the primary
43
+ harness loop once `wait --events` is dogfooded.
44
+
45
+ ## Output Shape
46
+
47
+ `tt wait --events --after N --json` returns the existing wait result plus:
48
+
49
+ ```json
50
+ {
51
+ "events": [],
52
+ "cursor_event_seq": 1234,
53
+ "wake_reason": "turn"
54
+ }
55
+ ```
56
+
57
+ `wake_reason` is one of:
58
+
59
+ - `turn`: ownership state changed or an ownership-relevant branch is available.
60
+ - `event`: self-targeted events arrived.
61
+ - `timeout`: no relevant turn or event change arrived before timeout.
62
+ - `closed`: the room closed. `status: "closed"` remains the canonical signal;
63
+ this reason only explains why the long-poll woke.
64
+
65
+ If both a turn change and events are present, `wake_reason` is `turn`, and the
66
+ harness still drains `events`.
67
+
68
+ Events are returned on every result branch, including `your_turn`, `not_yet`,
69
+ `takeover_available`, and `closed`. Terminal branches must not silently drop
70
+ queued events.
71
+
72
+ Holder timeout result shape is explicit: when the caller already owns the turn
73
+ and no relevant events arrive before timeout, return `status: "your_turn"`,
74
+ `reason: "already_owner"`, `events: []`, and `wake_reason: "timeout"`. The
75
+ caller still owns the turn; the result is only a receive-loop checkpoint.
76
+
77
+ ## Wake Semantics
78
+
79
+ When the caller is not the owner, wait-events wakes on:
80
+
81
+ - a normal `your_turn` grant,
82
+ - a reservation/pass to the caller,
83
+ - room state entering a `takeover_available` branch the caller could exercise,
84
+ - self-targeted event batches,
85
+ - room closure,
86
+ - timeout.
87
+
88
+ When the caller already owns the turn, wait-events still long-polls. It wakes
89
+ on:
90
+
91
+ - self-targeted messages or broadcasts,
92
+ - ownership loss or takeover,
93
+ - room closure,
94
+ - timeout.
95
+
96
+ If the holder loses the turn, return `status: "not_yet"` with
97
+ `reason: "lost_turn"` and any queued events. The former holder should not get an
98
+ automatic re-grant path from that result.
99
+
100
+ Holder-side wait-events must not release the turn, renew the lease, or change
101
+ guardian state. The foreground work remains covered by the existing guardian
102
+ process.
103
+
104
+ ## Park Mode
105
+
106
+ `tt wait --park --events --after N --json` composes the same receive loop with
107
+ park semantics.
108
+
109
+ It wakes on self-targeted events, ownership-relevant state changes, closure, or
110
+ timeout, but it never claims an idle room. In an idle room with a pending
111
+ handoff, the result remains `status: "not_yet"` with
112
+ `reason: "auto_claim_disabled"` plus the event fields. `wake_reason` reflects
113
+ what woke the loop: usually `event`, `turn`, or `timeout`.
114
+
115
+ ## Targeting
116
+
117
+ The default event target is `self`, matching the existing event filter:
118
+
119
+ - direct messages to the caller,
120
+ - broadcasts from other agents,
121
+ - non-message events to or from the caller.
122
+
123
+ `--target any` may exist for diagnostics, but the skill should not recommend it
124
+ for normal harness loops.
125
+
126
+ ## Cursor Contract
127
+
128
+ `--after` is required when `--events` is present. Omitting it is an explicit
129
+ usage error. The harness owns the cursor and must pass the previous
130
+ `cursor_event_seq` into the next invocation.
131
+
132
+ `--after` without `--events` should also be an explicit usage error. Plain
133
+ `tt wait` should not gain cursor semantics accidentally.
134
+
135
+ Initial cursor choices:
136
+
137
+ - use the room sequence from `tt join` / current state if available,
138
+ - or use `--after 0` for a deliberate full replay.
139
+
140
+ No implicit historical replay in the default path.
141
+
142
+ ## Heartbeat Contract
143
+
144
+ Guardian remains the only owner lease heartbeat.
145
+
146
+ Wait-events may refresh ordinary member presence as a read/check-in, but it
147
+ must not update `lease_expires_at`, create a guardian, stop a guardian, or
148
+ otherwise mutate owner lease state except when the wait-events path legitimately
149
+ grants ownership. In that case, it is the normal wait path and must spawn or
150
+ repair the guardian before returning `your_turn`.
151
+
152
+ This is important for background use: if a backgrounded wait-events call grants
153
+ `your_turn`, that same process must return a live `guardian_pid`. Requiring a
154
+ follow-up foreground `tt wait` would create a race where the harness starts work
155
+ before a guardian exists.
156
+
157
+ ## Skill Shape
158
+
159
+ The harness guidance becomes:
160
+
161
+ 1. Join once.
162
+ 2. Start one background `tt wait --events --after <cursor> --json` loop.
163
+ 3. On each return, process `events`, update the cursor, and restart the loop.
164
+ Restart after `your_turn` too; ownership is not a reason to stop receiving.
165
+ 4. Treat `your_turn` plus guardian as write authority.
166
+ 5. Treat events on any non-owner result as messages/signals only.
167
+ 6. Release or park according to the existing active-work rules.
168
+
169
+ This removes the separate "ambient receiver vs wait fallback" decision from
170
+ normal harness behavior.
171
+
172
+ ## Implementation Order
173
+
174
+ 1. Extend wait CLI parsing with `--events`, `--after`, and optional `--target`.
175
+ 2. Add result fields for `events`, `cursor_event_seq`, and `wake_reason`.
176
+ 3. Implement service polling that checks both wait state and self-targeted
177
+ events without mutating owner lease state.
178
+ 4. Preserve existing `tt wait` behavior when `--events` is absent.
179
+ 5. Add tests for:
180
+ - waiting agent receives `your_turn` with events and a live guardian,
181
+ - holder receives a message while holding and gets `your_turn`,
182
+ `already_owner`, events, and no lease mutation,
183
+ - holder timeout returns `your_turn`, `already_owner`, empty events, and
184
+ `wake_reason: "timeout"`,
185
+ - holder is taken over and receives `not_yet`, `lost_turn`, and queued
186
+ events,
187
+ - event-only wake for a non-owner returns `not_yet` plus events,
188
+ - turn+event wake returns `wake_reason: "turn"` and still includes events,
189
+ - closed room still returns queued events,
190
+ - park plus events in an idle room returns `not_yet`,
191
+ `auto_claim_disabled`, and event fields,
192
+ - two concurrent wait-events calls on idle: one wins `your_turn`, the other
193
+ gets `not_yet` plus any queued events,
194
+ - `--events` without `--after` fails,
195
+ - `--after` without `--events` fails,
196
+ - wait-events alone does not preserve ownership if the guardian dies.
197
+ 6. Dogfood in a shared room with Codex and Claude before changing the bundled
198
+ skill recommendation.
199
+
200
+ ## Risks
201
+
202
+ - The name `wait` now covers both ownership and receive-loop behavior. The
203
+ output must keep permission boundaries explicit.
204
+ - A holder can still idle-hold if the harness misuses the loop as foreground
205
+ sleep. The skill must say the loop is background observability, not a reason
206
+ to keep the stick while inactive.
207
+ - Wait-events alone must not preserve ownership. If the guardian dies or the
208
+ harness idles without active work, the lease expires normally and peers can
209
+ reach takeover availability.
210
+ - Cursor misuse can replay or miss events. Requiring `--after` makes this
211
+ explicit instead of magical.
@@ -0,0 +1,17 @@
1
+ # Talking Stick 0.4.7
2
+
3
+ Date: 2026-05-20
4
+
5
+ ## Added
6
+ - **`tt wait --events --after N`.** New flag turns `tt wait` into a unified background receive loop that long-polls for ownership changes *and* messages without a separate `tt events --follow` consumer. Holders can run the same command to receive directed and broadcast messages without renewing their lease; non-holders wake on ownership-relevant transitions (grant, reservation, takeover-available, room closure) or on self-targeted events. Result shape gains `events`, `cursor_event_seq`, and `wake_reason` (`turn` | `event` | `timeout` | `closed`). The cursor is required and explicit so a harness keeps the receive loop precise across restarts. `--target self|any|<agent_id>` (default `self`) selects which events the loop surfaces; release-to-room broadcasts are excluded by `self` but still wake the loop via the ownership-check half. `tt try --events --after N` composes the same shape for one-shot checks, and `--park --events` composes with the existing park-hint throttle so a parked receiver wakes once on a fresh pending handoff and then times out cleanly.
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
+ ```
@@ -0,0 +1,17 @@
1
+ # Talking Stick 0.4.8
2
+
3
+ Date: 2026-05-21
4
+
5
+ ## Added
6
+ - **`coordination_prompt` in coordination command JSON.** Object-shaped JSON results from the common coordination commands (`join`, `state`, `events`, `wait`, `try`, `take`, `takeover`, `release`, `pass`, `assign`, `msg send`) now carry a short `coordination_prompt` reminder: keep `tt wait`/`tt events` active until all goals are met, and re-read the Talking Stick skill if context slips. The field is added only to plain JSON objects — event-stream arrays and instruction output are left untouched — and is never duplicated if a result already includes it. This keeps the stay-in-the-loop guidance in front of a harness even when the skill has scrolled out of context.
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
+ ```
@@ -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.6",
3
+ "version": "0.4.9",
4
4
  "description": "CLI coordination tool for path-scoped agent handoffs.",
5
5
  "type": "module",
6
6
  "bin": {