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 +1 -1
- package/dist/cli/event-stream.js +7 -2
- package/dist/cli/guardian.js +22 -1
- package/dist/cli/room-commands.js +4 -2
- package/dist/cli/turn-commands.js +4 -2
- package/dist/commands.js +16 -4
- package/dist/config.js +1 -0
- package/dist/path-resolution.js +31 -0
- package/dist/service.js +229 -57
- package/docs/releases/0.4.9.md +17 -0
- package/docs/talking-stick-plan.md +1 -1
- package/package.json +1 -1
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
|
|
package/dist/cli/event-stream.js
CHANGED
|
@@ -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) {
|
package/dist/cli/guardian.js
CHANGED
|
@@ -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
|
-
...
|
|
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
package/dist/path-resolution.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1089
|
+
const normalized = normalizeProcessMetadata(options.processMetadata);
|
|
1090
|
+
const hasIdentity = hasExactProcessIdentity(normalized) ||
|
|
1091
|
+
hasHarnessProcessIdentity(normalized);
|
|
985
1092
|
if (existing) {
|
|
986
|
-
const
|
|
987
|
-
const
|
|
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
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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,
|
|
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
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
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:
|