talking-stick 0.4.5 → 0.4.8

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.
@@ -116,7 +116,7 @@ async function followEvents(runtime, parsed, input) {
116
116
  process.stderr.write(`cursor_event_seq=${cursor}\n`);
117
117
  }
118
118
  }
119
- function resolveTargetFilter(runtime, identity, roomId, raw) {
119
+ export function resolveTargetFilter(runtime, identity, roomId, raw) {
120
120
  if (raw === "self" || raw === "any") {
121
121
  return raw;
122
122
  }
@@ -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
  },
@@ -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") {
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,10 @@ 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
42
45
  });
43
46
  }
44
47
  heartbeat(identity, input) {
package/dist/db.js CHANGED
@@ -114,6 +114,13 @@ const migrations = [
114
114
  ALTER TABLE room_members ADD COLUMN harness_pid INTEGER;
115
115
  ALTER TABLE room_members ADD COLUMN harness_process_started_at TEXT;
116
116
  `
117
+ },
118
+ {
119
+ id: 7,
120
+ name: "room_member_last_park_hint_event_seq",
121
+ up: `
122
+ ALTER TABLE room_members ADD COLUMN last_park_hint_event_seq INTEGER;
123
+ `
117
124
  }
118
125
  ];
119
126
  export function resolveDatabasePath(options = {}) {
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();
@@ -665,6 +715,24 @@ export class TalkingStickService {
665
715
  if (!room.owner && !room.reserved_for) {
666
716
  const autoClaim = input.auto_claim ?? true;
667
717
  if (!autoClaim) {
718
+ if (room.pending_handoff_event_seq) {
719
+ const member = this.getMember(input.room_id, input.agent_id);
720
+ const alreadyHinted = member?.last_park_hint_event_seq === room.pending_handoff_event_seq;
721
+ if (!alreadyHinted) {
722
+ this.recordParkHint(input.room_id, input.agent_id, room.pending_handoff_event_seq);
723
+ return {
724
+ status: "not_yet",
725
+ room_state: inspection.state,
726
+ turn_id: room.turn_id,
727
+ current_owner: room.owner ?? undefined,
728
+ reserved_for: room.reserved_for ?? undefined,
729
+ lease_expires_at: room.lease_expires_at ?? undefined,
730
+ claim_expires_at: room.claim_expires_at ?? undefined,
731
+ reason: "auto_claim_disabled",
732
+ hint: "A pending handoff is waiting in this idle room, but park mode does not auto-claim. Run `tt wait --json` to pick it up, or ask for an explicit assignment."
733
+ };
734
+ }
735
+ }
668
736
  return {
669
737
  status: "not_yet",
670
738
  room_state: inspection.state,
@@ -672,9 +740,7 @@ export class TalkingStickService {
672
740
  current_owner: room.owner ?? undefined,
673
741
  reserved_for: room.reserved_for ?? undefined,
674
742
  lease_expires_at: room.lease_expires_at ?? undefined,
675
- claim_expires_at: room.claim_expires_at ?? undefined,
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."
743
+ claim_expires_at: room.claim_expires_at ?? undefined
678
744
  };
679
745
  }
680
746
  if (this.shouldDeferIdleClaim(room, input.agent_id, now)) {
@@ -751,6 +817,72 @@ export class TalkingStickService {
751
817
  claim_expires_at: room.claim_expires_at ?? undefined
752
818
  };
753
819
  }
820
+ waitForOwnerEventTurnOnce(input) {
821
+ const now = this.now();
822
+ const timestamp = now.toISOString();
823
+ const room = this.requireRoom(input.room_id);
824
+ this.touchWaitingMember(input.room_id, input.agent_id, timestamp);
825
+ const inspection = this.inspectRoomForMutation(room, now);
826
+ if (room.state === "closed") {
827
+ return { status: "closed", room_id: input.room_id };
828
+ }
829
+ if (room.owner === input.agent_id &&
830
+ room.lease_id &&
831
+ room.lease_expires_at &&
832
+ !this.hasExpired(room.lease_expires_at, now)) {
833
+ return {
834
+ status: "your_turn",
835
+ room_id: input.room_id,
836
+ turn_id: room.turn_id,
837
+ lease_id: room.lease_id,
838
+ handoff: null,
839
+ from_agent_id: null,
840
+ reason: "already_owner"
841
+ };
842
+ }
843
+ return {
844
+ status: "not_yet",
845
+ room_state: inspection.state,
846
+ turn_id: room.turn_id,
847
+ current_owner: room.owner ?? undefined,
848
+ reserved_for: room.reserved_for ?? undefined,
849
+ lease_expires_at: room.lease_expires_at ?? undefined,
850
+ claim_expires_at: room.claim_expires_at ?? undefined,
851
+ reason: "lost_turn"
852
+ };
853
+ }
854
+ isActiveOwner(roomId, agentId, now) {
855
+ const room = this.requireRoom(roomId);
856
+ return (room.owner === agentId &&
857
+ !!room.lease_id &&
858
+ !!room.lease_expires_at &&
859
+ !this.hasExpired(room.lease_expires_at, now));
860
+ }
861
+ isTurnWake(result, startedAsOwner = false) {
862
+ if (startedAsOwner &&
863
+ result.status === "your_turn" &&
864
+ result.reason === "already_owner") {
865
+ return false;
866
+ }
867
+ return (result.status === "your_turn" ||
868
+ result.status === "takeover_available" ||
869
+ // Park hints are turn wakes only because waitForTurnOnce throttles
870
+ // auto_claim_disabled per pending handoff; subsequent parked polls become
871
+ // plain not_yet and can timeout instead of tight-looping.
872
+ (result.status === "not_yet" &&
873
+ (result.reason === "auto_claim_disabled" ||
874
+ result.reason === "lost_turn")));
875
+ }
876
+ withWaitEvents(result, events, afterEventSeq, wakeReason) {
877
+ return {
878
+ ...result,
879
+ events,
880
+ cursor_event_seq: events.length > 0
881
+ ? events[events.length - 1].event_seq
882
+ : afterEventSeq,
883
+ wake_reason: wakeReason
884
+ };
885
+ }
754
886
  grantTurn(room, agentId, now) {
755
887
  const timestamp = now.toISOString();
756
888
  const nextTurnId = room.turn_id + 1;
@@ -1010,6 +1142,15 @@ export class TalkingStickService {
1010
1142
  throw new ProtocolError("unknown_member", "Agent must join the room before using this tool.", { to_agent_id: agentId });
1011
1143
  }
1012
1144
  }
1145
+ recordParkHint(roomId, agentId, pendingHandoffEventSeq) {
1146
+ this.db
1147
+ .prepare(`
1148
+ UPDATE room_members
1149
+ SET last_park_hint_event_seq = ?
1150
+ WHERE room_id = ? AND agent_id = ?
1151
+ `)
1152
+ .run(pendingHandoffEventSeq, roomId, agentId);
1153
+ }
1013
1154
  touchWaitingMember(roomId, agentId, timestamp) {
1014
1155
  const result = this.db
1015
1156
  .prepare(`
@@ -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,20 @@
1
+ # Talking Stick 0.4.6
2
+
3
+ Date: 2026-05-12
4
+
5
+ ## Added
6
+ - **`room_members.last_park_hint_event_seq`.** New nullable INTEGER column (migration 7) tracking which pending-handoff event sequence a member has already been hinted about via park mode. Used to give the `auto_claim_disabled` hint at most once per (member, pending handoff) pair.
7
+
8
+ ## Fixed
9
+ - **Park no longer spins on truly idle rooms.** `tt wait --park` short-returns with `reason: auto_claim_disabled` and a hint only the first time a member parks against a given pending handoff in an idle room. Subsequent parks by the same member against the same pending handoff long-poll quietly. Truly idle (no pending handoff) always long-polls. A fresh pending handoff (newer event sequence) hints again, and each member is hinted independently. Previously the short-return fired on every park call, which kept a naive re-park loop spinning even after the agent saw the hint.
10
+
11
+ ## Verification
12
+
13
+ ```bash
14
+ npm run typecheck
15
+ npm test
16
+ npm run build
17
+ node dist/cli.js --help
18
+ git diff --check
19
+ npm pack --dry-run
20
+ ```
@@ -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
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talking-stick",
3
- "version": "0.4.5",
3
+ "version": "0.4.8",
4
4
  "description": "CLI coordination tool for path-scoped agent handoffs.",
5
5
  "type": "module",
6
6
  "bin": {