talking-stick 0.1.0-alpha

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.
@@ -0,0 +1,980 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import os from "node:os";
3
+ import { setTimeout as sleep } from "node:timers/promises";
4
+ import { ancestorPaths, resolveContextPath } from "./path-resolution.js";
5
+ import { defaultPolicy } from "./config.js";
6
+ import { openDatabase, withImmediateTransaction } from "./db.js";
7
+ import { ProtocolError } from "./errors.js";
8
+ import { createSystemProcessInspector } from "./process-utils.js";
9
+ export class TalkingStickService {
10
+ db;
11
+ policy;
12
+ now;
13
+ ownsDatabase;
14
+ processLivenessChecker;
15
+ hostId;
16
+ constructor(options = {}) {
17
+ this.db = options.db ?? openDatabase(options);
18
+ this.ownsDatabase = !options.db;
19
+ this.now = options.now ?? (() => new Date());
20
+ this.policy = { ...defaultPolicy, ...options.policy };
21
+ this.hostId = options.hostId ?? os.hostname();
22
+ this.processLivenessChecker =
23
+ options.processLivenessChecker ??
24
+ createDefaultProcessLivenessChecker(this.hostId);
25
+ }
26
+ close() {
27
+ if (this.ownsDatabase && this.db.open) {
28
+ this.db.close();
29
+ }
30
+ }
31
+ listRooms(input = {}) {
32
+ const now = this.now();
33
+ if (!input.context_path) {
34
+ const rows = this.db
35
+ .prepare("SELECT * FROM path_rooms ORDER BY canonical_path")
36
+ .all();
37
+ return {
38
+ rooms: rows.map((row) => this.mapRoomForList(row, now))
39
+ };
40
+ }
41
+ const resolved = resolveContextPath(input.context_path);
42
+ const ancestors = ancestorPaths(resolved.canonical_context_path, resolved.workspace_root);
43
+ return {
44
+ rooms: this.findRoomsByCanonicalPaths(ancestors).map((row) => this.mapRoomForList(row, now))
45
+ };
46
+ }
47
+ joinPath(input) {
48
+ assertNonEmpty(input.agent_id, "agent_id");
49
+ assertNonEmpty(input.context_path, "context_path");
50
+ const resolved = resolveContextPath(input.context_path);
51
+ const now = this.now();
52
+ const timestamp = now.toISOString();
53
+ return withImmediateTransaction(this.db, () => {
54
+ const roomSelection = this.findOrCreateRoomForJoin(resolved, input.force_new === true, timestamp);
55
+ this.upsertMember(roomSelection.room.room_id, input.agent_id, timestamp, input.process_metadata);
56
+ const freshRoom = this.requireRoom(roomSelection.room.room_id);
57
+ return {
58
+ agent_id: input.agent_id,
59
+ room_id: freshRoom.room_id,
60
+ canonical_path: freshRoom.canonical_path,
61
+ requested_path: resolved.requested_path,
62
+ workspace_root: resolved.workspace_root,
63
+ joined_existing_room: roomSelection.joinedExistingRoom,
64
+ warning: roomSelection.warning,
65
+ policy: { ...this.policy },
66
+ room_state: this.mapRoom(this.inspectRoom(freshRoom, now), now),
67
+ handoff_template: handoffTemplate()
68
+ };
69
+ });
70
+ }
71
+ async waitForTurn(input) {
72
+ assertNonEmpty(input.agent_id, "agent_id");
73
+ assertNonEmpty(input.room_id, "room_id");
74
+ const maxWaitMs = input.max_wait_ms ?? this.policy.waitForTurnMaxWaitMs;
75
+ const deadline = Date.now() + Math.max(0, maxWaitMs);
76
+ while (true) {
77
+ this.warmRoomTurnLiveness(input.room_id);
78
+ const result = withImmediateTransaction(this.db, () => this.waitForTurnOnce(input));
79
+ if (result.status !== "not_yet" || Date.now() >= deadline) {
80
+ return result;
81
+ }
82
+ const remainingMs = deadline - Date.now();
83
+ await sleep(Math.min(this.policy.waitForTurnPollMs, remainingMs));
84
+ }
85
+ }
86
+ heartbeat(input) {
87
+ const now = this.now();
88
+ const timestamp = now.toISOString();
89
+ const nextLeaseExpiresAt = this.expiresAt(now, this.policy.ownerLeaseTtlMs);
90
+ this.warmRoomTurnLiveness(input.room_id);
91
+ return withImmediateTransaction(this.db, () => {
92
+ const room = this.requireRoom(input.room_id);
93
+ this.assertOwnerMutation(room, input, now);
94
+ this.touchMember(input.room_id, input.agent_id, timestamp);
95
+ this.db
96
+ .prepare(`
97
+ UPDATE path_rooms
98
+ SET lease_expires_at = ?, updated_at = ?, state = 'owned'
99
+ WHERE room_id = ?
100
+ `)
101
+ .run(nextLeaseExpiresAt, timestamp, input.room_id);
102
+ return {
103
+ status: "ok",
104
+ room_id: input.room_id,
105
+ turn_id: room.turn_id,
106
+ lease_id: input.lease_id,
107
+ lease_expires_at: nextLeaseExpiresAt
108
+ };
109
+ });
110
+ }
111
+ releaseStick(input) {
112
+ validateHandoff(input.handoff);
113
+ const now = this.now();
114
+ const timestamp = now.toISOString();
115
+ this.warmRoomTurnLiveness(input.room_id);
116
+ return withImmediateTransaction(this.db, () => {
117
+ const room = this.requireRoom(input.room_id);
118
+ this.assertOwnerMutation(room, input, now);
119
+ this.touchMember(input.room_id, input.agent_id, timestamp);
120
+ const eventSeq = this.appendEvent({
121
+ room_id: input.room_id,
122
+ turn_id: room.turn_id,
123
+ event_type: "release",
124
+ from_agent_id: input.agent_id,
125
+ to_agent_id: null,
126
+ handoff: input.handoff,
127
+ reason: null,
128
+ created_at: timestamp
129
+ });
130
+ const nextMember = this.findNextActiveMember(input.room_id, input.agent_id, now);
131
+ const reservedFor = nextMember?.agent_id ?? null;
132
+ const claimExpiresAt = reservedFor
133
+ ? this.expiresAt(now, this.policy.claimTtlMs)
134
+ : null;
135
+ this.db
136
+ .prepare(`
137
+ UPDATE path_rooms
138
+ SET sequence_index = ?,
139
+ owner = NULL,
140
+ reserved_for = ?,
141
+ pending_handoff_event_seq = ?,
142
+ lease_id = NULL,
143
+ lease_expires_at = NULL,
144
+ claim_expires_at = ?,
145
+ state = ?,
146
+ updated_at = ?
147
+ WHERE room_id = ?
148
+ `)
149
+ .run(nextMember?.ordinal ?? room.sequence_index, reservedFor, eventSeq, claimExpiresAt, reservedFor ? "reserved" : "idle", timestamp, input.room_id);
150
+ return {
151
+ status: "released",
152
+ room_id: input.room_id,
153
+ reserved_for: reservedFor,
154
+ event_seq: eventSeq
155
+ };
156
+ });
157
+ }
158
+ passStick(input) {
159
+ validateHandoff(input.handoff);
160
+ assertNonEmpty(input.to_agent_id, "to_agent_id");
161
+ const now = this.now();
162
+ const timestamp = now.toISOString();
163
+ this.warmRoomTurnLiveness(input.room_id);
164
+ return withImmediateTransaction(this.db, () => {
165
+ const room = this.requireRoom(input.room_id);
166
+ this.assertOwnerMutation(room, input, now);
167
+ this.touchMember(input.room_id, input.agent_id, timestamp);
168
+ const target = this.getMember(input.room_id, input.to_agent_id);
169
+ if (!target || !this.isMemberActive(target, now)) {
170
+ throw new ProtocolError("unknown_member", "pass_stick target must be an active room member in the MVP.", { to_agent_id: input.to_agent_id });
171
+ }
172
+ const eventSeq = this.appendEvent({
173
+ room_id: input.room_id,
174
+ turn_id: room.turn_id,
175
+ event_type: "pass",
176
+ from_agent_id: input.agent_id,
177
+ to_agent_id: input.to_agent_id,
178
+ handoff: input.handoff,
179
+ reason: null,
180
+ created_at: timestamp
181
+ });
182
+ this.db
183
+ .prepare(`
184
+ UPDATE path_rooms
185
+ SET sequence_index = ?,
186
+ owner = NULL,
187
+ reserved_for = ?,
188
+ pending_handoff_event_seq = ?,
189
+ lease_id = NULL,
190
+ lease_expires_at = NULL,
191
+ claim_expires_at = ?,
192
+ state = 'reserved',
193
+ updated_at = ?
194
+ WHERE room_id = ?
195
+ `)
196
+ .run(target.ordinal, input.to_agent_id, eventSeq, this.expiresAt(now, this.policy.claimTtlMs), timestamp, input.room_id);
197
+ return {
198
+ status: "passed",
199
+ room_id: input.room_id,
200
+ reserved_for: input.to_agent_id,
201
+ event_seq: eventSeq
202
+ };
203
+ });
204
+ }
205
+ takeoverStick(input) {
206
+ assertNonEmpty(input.agent_id, "agent_id");
207
+ assertNonEmpty(input.room_id, "room_id");
208
+ assertNonEmpty(input.reason, "reason");
209
+ const now = this.now();
210
+ const timestamp = now.toISOString();
211
+ this.warmRoomTurnLiveness(input.room_id);
212
+ return withImmediateTransaction(this.db, () => {
213
+ const room = this.requireRoom(input.room_id);
214
+ const inspection = this.inspectRoomForMutation(room, now);
215
+ if (room.turn_id !== input.expected_turn_id) {
216
+ throw new ProtocolError("turn_mismatch", "The supplied turn does not match the current room turn.", {
217
+ current_owner: room.owner,
218
+ current_turn_id: room.turn_id,
219
+ room_state: inspection.state
220
+ });
221
+ }
222
+ this.touchMember(input.room_id, input.agent_id, timestamp);
223
+ const takeoverKind = this.assertTakeoverEligible(room, input.agent_id, now, inspection);
224
+ const nextTurnId = room.turn_id + 1;
225
+ const leaseId = randomUUID();
226
+ const revokedAgentId = takeoverKind === "claim_timeout" || takeoverKind === "recipient_gone"
227
+ ? room.reserved_for
228
+ : room.owner;
229
+ this.db
230
+ .prepare(`
231
+ UPDATE path_rooms
232
+ SET owner = ?,
233
+ reserved_for = NULL,
234
+ pending_handoff_event_seq = NULL,
235
+ turn_id = ?,
236
+ lease_id = ?,
237
+ lease_expires_at = ?,
238
+ claim_expires_at = NULL,
239
+ state = 'owned',
240
+ updated_at = ?
241
+ WHERE room_id = ?
242
+ `)
243
+ .run(input.agent_id, nextTurnId, leaseId, this.expiresAt(now, this.policy.ownerLeaseTtlMs), timestamp, input.room_id);
244
+ this.appendEvent({
245
+ room_id: input.room_id,
246
+ turn_id: nextTurnId,
247
+ event_type: "takeover",
248
+ from_agent_id: revokedAgentId,
249
+ to_agent_id: input.agent_id,
250
+ handoff: null,
251
+ reason: input.reason,
252
+ created_at: timestamp
253
+ });
254
+ return {
255
+ status: "your_turn",
256
+ room_id: input.room_id,
257
+ turn_id: nextTurnId,
258
+ lease_id: leaseId,
259
+ revoked_agent_id: revokedAgentId,
260
+ reason: takeoverKind
261
+ };
262
+ });
263
+ }
264
+ getRoomState(input) {
265
+ const now = this.now();
266
+ const room = this.requireRoom(input.room_id);
267
+ const inspection = this.inspectRoom(room, now);
268
+ return {
269
+ room: this.mapRoom(inspection, now),
270
+ members: inspection.members.map((member) => this.mapMember(member, now))
271
+ };
272
+ }
273
+ getRoomEvents(input) {
274
+ const afterEventSeq = input.after_event_seq ?? 0;
275
+ const limit = Math.min(input.limit ?? 100, 500);
276
+ return this.db
277
+ .prepare(`
278
+ SELECT *
279
+ FROM room_events
280
+ WHERE room_id = ? AND event_seq > ?
281
+ ORDER BY event_seq
282
+ LIMIT ?
283
+ `)
284
+ .all(input.room_id, afterEventSeq, limit)
285
+ .map((row) => this.mapEvent(row));
286
+ }
287
+ waitForTurnOnce(input) {
288
+ const now = this.now();
289
+ const timestamp = now.toISOString();
290
+ const room = this.requireRoom(input.room_id);
291
+ this.touchMember(input.room_id, input.agent_id, timestamp);
292
+ const inspection = this.inspectRoomForMutation(room, now);
293
+ if (room.state === "closed") {
294
+ return { status: "closed", room_id: input.room_id };
295
+ }
296
+ if (!room.owner && !room.reserved_for) {
297
+ return this.grantTurn(room, input.agent_id, now);
298
+ }
299
+ if (inspection.state === "recipient_gone") {
300
+ if (room.reserved_for !== input.agent_id &&
301
+ this.isClaimTakeoverEligible(room, input.agent_id, now, inspection)) {
302
+ return {
303
+ status: "takeover_available",
304
+ room_id: input.room_id,
305
+ turn_id: room.turn_id,
306
+ room_state: "recipient_gone",
307
+ reason: "recipient_gone",
308
+ reserved_for: room.reserved_for ?? undefined
309
+ };
310
+ }
311
+ }
312
+ if (room.reserved_for) {
313
+ if (room.reserved_for === input.agent_id &&
314
+ inspection.state !== "recipient_gone") {
315
+ return this.grantTurn(room, input.agent_id, now);
316
+ }
317
+ if (inspection.state !== "recipient_gone" &&
318
+ this.hasExpired(room.claim_expires_at, now) &&
319
+ this.isClaimTakeoverEligible(room, input.agent_id, now, inspection)) {
320
+ return {
321
+ status: "takeover_available",
322
+ room_id: input.room_id,
323
+ turn_id: room.turn_id,
324
+ room_state: "reserved",
325
+ reason: "claim_timeout",
326
+ reserved_for: room.reserved_for
327
+ };
328
+ }
329
+ }
330
+ if (inspection.state === "owner_gone" && room.owner !== input.agent_id) {
331
+ return {
332
+ status: "takeover_available",
333
+ room_id: input.room_id,
334
+ turn_id: room.turn_id,
335
+ room_state: "owner_gone",
336
+ reason: "owner_gone",
337
+ current_owner: room.owner ?? undefined
338
+ };
339
+ }
340
+ if (room.owner && room.owner !== input.agent_id && inspection.state === "stale_owner") {
341
+ return {
342
+ status: "takeover_available",
343
+ room_id: input.room_id,
344
+ turn_id: room.turn_id,
345
+ room_state: "stale_owner",
346
+ reason: "owner_timeout",
347
+ current_owner: room.owner
348
+ };
349
+ }
350
+ return {
351
+ status: "not_yet",
352
+ cursor: String(this.latestEventSeq(input.room_id)),
353
+ room_state: inspection.state
354
+ };
355
+ }
356
+ grantTurn(room, agentId, now) {
357
+ const timestamp = now.toISOString();
358
+ const nextTurnId = room.turn_id + 1;
359
+ const leaseId = randomUUID();
360
+ const pendingEvent = room.pending_handoff_event_seq
361
+ ? this.getEventBySeq(room.pending_handoff_event_seq)
362
+ : null;
363
+ const reason = claimReasonForEvent(pendingEvent);
364
+ this.db
365
+ .prepare(`
366
+ UPDATE path_rooms
367
+ SET owner = ?,
368
+ reserved_for = NULL,
369
+ pending_handoff_event_seq = NULL,
370
+ turn_id = ?,
371
+ lease_id = ?,
372
+ lease_expires_at = ?,
373
+ claim_expires_at = NULL,
374
+ state = 'owned',
375
+ updated_at = ?
376
+ WHERE room_id = ?
377
+ `)
378
+ .run(agentId, nextTurnId, leaseId, this.expiresAt(now, this.policy.ownerLeaseTtlMs), timestamp, room.room_id);
379
+ this.appendEvent({
380
+ room_id: room.room_id,
381
+ turn_id: nextTurnId,
382
+ event_type: "claim",
383
+ from_agent_id: pendingEvent?.from_agent_id ?? null,
384
+ to_agent_id: agentId,
385
+ handoff: null,
386
+ reason: null,
387
+ created_at: timestamp
388
+ });
389
+ return {
390
+ status: "your_turn",
391
+ room_id: room.room_id,
392
+ turn_id: nextTurnId,
393
+ lease_id: leaseId,
394
+ handoff: pendingEvent ? this.mapEvent(pendingEvent).handoff : null,
395
+ from_agent_id: pendingEvent?.from_agent_id ?? null,
396
+ reason
397
+ };
398
+ }
399
+ findOrCreateRoomForJoin(resolved, forceNew, timestamp) {
400
+ const ancestors = ancestorPaths(resolved.canonical_context_path, resolved.workspace_root);
401
+ const existingAncestor = this.findDeepestRoom(ancestors);
402
+ if (forceNew) {
403
+ const exactRoom = this.findRoomByCanonicalPath(resolved.canonical_context_path);
404
+ if (exactRoom) {
405
+ return { room: exactRoom, joinedExistingRoom: true };
406
+ }
407
+ return {
408
+ room: this.createRoom(resolved.canonical_context_path, timestamp),
409
+ joinedExistingRoom: false,
410
+ warning: existingAncestor
411
+ ? `Created nested room inside ${existingAncestor.canonical_path}`
412
+ : undefined
413
+ };
414
+ }
415
+ if (existingAncestor) {
416
+ return { room: existingAncestor, joinedExistingRoom: true };
417
+ }
418
+ return {
419
+ room: this.createRoom(resolved.workspace_root, timestamp),
420
+ joinedExistingRoom: false
421
+ };
422
+ }
423
+ createRoom(canonicalPath, timestamp) {
424
+ const roomId = randomUUID();
425
+ this.db
426
+ .prepare(`
427
+ INSERT INTO path_rooms (
428
+ room_id,
429
+ canonical_path,
430
+ sequence_index,
431
+ owner,
432
+ reserved_for,
433
+ pending_handoff_event_seq,
434
+ turn_id,
435
+ lease_id,
436
+ lease_expires_at,
437
+ claim_expires_at,
438
+ state,
439
+ updated_at
440
+ )
441
+ VALUES (?, ?, 0, NULL, NULL, NULL, 0, NULL, NULL, NULL, 'idle', ?)
442
+ `)
443
+ .run(roomId, canonicalPath, timestamp);
444
+ return this.requireRoom(roomId);
445
+ }
446
+ upsertMember(roomId, agentId, timestamp, processMetadata) {
447
+ const existing = this.getMember(roomId, agentId);
448
+ const normalizedMetadata = normalizeProcessMetadata(processMetadata);
449
+ if (existing) {
450
+ const room = this.requireRoom(roomId);
451
+ const mergedMetadata = this.mergeMemberProcessMetadata(room, existing, normalizedMetadata);
452
+ this.db
453
+ .prepare(`
454
+ UPDATE room_members
455
+ SET last_seen_at = ?,
456
+ status = 'active',
457
+ host_id = ?,
458
+ pid = ?,
459
+ process_started_at = ?,
460
+ session_kind = ?,
461
+ display_name = ?
462
+ WHERE room_id = ? AND agent_id = ?
463
+ `)
464
+ .run(timestamp, mergedMetadata.host_id, mergedMetadata.pid, mergedMetadata.process_started_at, mergedMetadata.session_kind, mergedMetadata.display_name, roomId, agentId);
465
+ return;
466
+ }
467
+ const nextOrdinal = this.db
468
+ .prepare("SELECT MAX(ordinal) + 1 AS next_ordinal FROM room_members WHERE room_id = ?")
469
+ .get(roomId)?.next_ordinal ?? 0;
470
+ this.db
471
+ .prepare(`
472
+ INSERT INTO room_members (
473
+ room_id,
474
+ agent_id,
475
+ ordinal,
476
+ joined_at,
477
+ last_seen_at,
478
+ status,
479
+ host_id,
480
+ pid,
481
+ process_started_at,
482
+ session_kind,
483
+ display_name
484
+ )
485
+ VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)
486
+ `)
487
+ .run(roomId, agentId, nextOrdinal, timestamp, timestamp, normalizedMetadata.host_id, normalizedMetadata.pid, normalizedMetadata.process_started_at, normalizedMetadata.session_kind, normalizedMetadata.display_name);
488
+ }
489
+ mergeMemberProcessMetadata(room, existing, incoming) {
490
+ if (!this.shouldPreserveExactMemberProcessMetadata(room, existing, incoming)) {
491
+ return incoming;
492
+ }
493
+ return {
494
+ host_id: existing.host_id,
495
+ pid: existing.pid,
496
+ process_started_at: existing.process_started_at,
497
+ session_kind: existing.session_kind,
498
+ display_name: existing.display_name
499
+ };
500
+ }
501
+ shouldPreserveExactMemberProcessMetadata(room, existing, incoming) {
502
+ const isCurrentHolderOrRecipient = room.owner === existing.agent_id || room.reserved_for === existing.agent_id;
503
+ if (!isCurrentHolderOrRecipient) {
504
+ return false;
505
+ }
506
+ if (!hasExactProcessIdentity(existing)) {
507
+ return false;
508
+ }
509
+ if (!hasExactProcessIdentity(incoming)) {
510
+ return true;
511
+ }
512
+ return (sessionKindPriority(incoming.session_kind) <
513
+ sessionKindPriority(existing.session_kind));
514
+ }
515
+ touchMember(roomId, agentId, timestamp) {
516
+ const result = this.db
517
+ .prepare(`
518
+ UPDATE room_members
519
+ SET last_seen_at = ?, status = 'active'
520
+ WHERE room_id = ? AND agent_id = ?
521
+ `)
522
+ .run(timestamp, roomId, agentId);
523
+ if (result.changes === 0) {
524
+ throw new ProtocolError("unknown_member", "Agent must join the room before using this tool.", { to_agent_id: agentId });
525
+ }
526
+ }
527
+ assertOwnerMutation(room, input, now) {
528
+ const inspection = this.inspectRoomForMutation(room, now);
529
+ if (room.turn_id !== input.expected_turn_id) {
530
+ throw new ProtocolError("turn_mismatch", "The supplied turn does not match the current room turn.", {
531
+ current_owner: room.owner,
532
+ current_turn_id: room.turn_id,
533
+ room_state: inspection.state
534
+ });
535
+ }
536
+ if (room.owner !== input.agent_id ||
537
+ room.lease_id !== input.lease_id ||
538
+ room.state !== "owned" ||
539
+ (inspection.state !== "owned" && inspection.state !== "stale_owner")) {
540
+ throw new ProtocolError("stale_lease", "The supplied lease is no longer current for this room.", {
541
+ current_owner: room.owner,
542
+ current_turn_id: room.turn_id,
543
+ room_state: inspection.state
544
+ });
545
+ }
546
+ }
547
+ assertTakeoverEligible(room, agentId, now, inspection) {
548
+ if (inspection.state === "recipient_gone") {
549
+ if (!this.isClaimTakeoverEligible(room, agentId, now, inspection)) {
550
+ throw new ProtocolError("takeover_ineligible", "Agent is not eligible to take over this reserved turn.", {
551
+ reserved_for: room.reserved_for,
552
+ room_state: inspection.state
553
+ });
554
+ }
555
+ return "recipient_gone";
556
+ }
557
+ if (room.reserved_for && this.hasExpired(room.claim_expires_at, now)) {
558
+ if (!this.isClaimTakeoverEligible(room, agentId, now, inspection)) {
559
+ throw new ProtocolError("takeover_ineligible", "Agent is not eligible to take over this reserved turn.", {
560
+ reserved_for: room.reserved_for,
561
+ room_state: inspection.state
562
+ });
563
+ }
564
+ return "claim_timeout";
565
+ }
566
+ if (inspection.state === "owner_gone" && room.owner) {
567
+ if (room.owner === agentId) {
568
+ throw new ProtocolError("takeover_ineligible", "The current owner cannot take over its own dead lease.", {
569
+ current_owner: room.owner,
570
+ room_state: inspection.state
571
+ });
572
+ }
573
+ return "owner_gone";
574
+ }
575
+ if (room.owner && this.hasExpired(room.lease_expires_at, now)) {
576
+ if (room.owner === agentId) {
577
+ throw new ProtocolError("takeover_ineligible", "The current owner cannot take over its own stale lease.", {
578
+ current_owner: room.owner,
579
+ room_state: inspection.state
580
+ });
581
+ }
582
+ return "owner_timeout";
583
+ }
584
+ throw new ProtocolError("takeover_not_available", "No takeover timeout is currently available for this room.", { room_state: inspection.state });
585
+ }
586
+ isClaimTakeoverEligible(room, agentId, now, inspection) {
587
+ if (!room.reserved_for || room.reserved_for === agentId) {
588
+ return false;
589
+ }
590
+ const pendingEvent = room.pending_handoff_event_seq
591
+ ? this.getEventBySeq(room.pending_handoff_event_seq)
592
+ : null;
593
+ const priorOwner = pendingEvent?.from_agent_id ?? null;
594
+ if (priorOwner === agentId) {
595
+ return !this.hasOtherClaimTakeoverCandidate(inspection.members, room.reserved_for, agentId, now);
596
+ }
597
+ return true;
598
+ }
599
+ hasOtherClaimTakeoverCandidate(members, reservedFor, candidateAgentId, now) {
600
+ return members.some((member) => member.agent_id !== candidateAgentId &&
601
+ member.agent_id !== reservedFor &&
602
+ this.hasRecentPresence(member, now));
603
+ }
604
+ findNextActiveMember(roomId, afterAgentId, now) {
605
+ const members = this.getMembers(roomId);
606
+ if (members.length <= 1) {
607
+ return null;
608
+ }
609
+ const ownerIndex = members.findIndex((member) => member.agent_id === afterAgentId);
610
+ if (ownerIndex === -1) {
611
+ return null;
612
+ }
613
+ for (let offset = 1; offset < members.length; offset += 1) {
614
+ const candidate = members[(ownerIndex + offset) % members.length];
615
+ if (this.hasRecentPresence(candidate, now)) {
616
+ return candidate;
617
+ }
618
+ }
619
+ return null;
620
+ }
621
+ appendEvent(input) {
622
+ const result = this.db
623
+ .prepare(`
624
+ INSERT INTO room_events (
625
+ event_id,
626
+ room_id,
627
+ turn_id,
628
+ event_type,
629
+ from_agent_id,
630
+ to_agent_id,
631
+ handoff_json,
632
+ reason,
633
+ created_at
634
+ )
635
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
636
+ `)
637
+ .run(randomUUID(), input.room_id, input.turn_id, input.event_type, input.from_agent_id, input.to_agent_id, input.handoff ? JSON.stringify(input.handoff) : null, input.reason, input.created_at);
638
+ return Number(result.lastInsertRowid);
639
+ }
640
+ getRoomRow(roomId) {
641
+ return this.db
642
+ .prepare("SELECT * FROM path_rooms WHERE room_id = ?")
643
+ .get(roomId);
644
+ }
645
+ requireRoom(roomId) {
646
+ const room = this.getRoomRow(roomId);
647
+ if (!room) {
648
+ throw new ProtocolError("room_not_found", "Room was not found.");
649
+ }
650
+ return room;
651
+ }
652
+ findRoomByCanonicalPath(canonicalPath) {
653
+ return (this.db
654
+ .prepare("SELECT * FROM path_rooms WHERE canonical_path = ?")
655
+ .get(canonicalPath) ?? null);
656
+ }
657
+ findRoomsByCanonicalPaths(canonicalPaths) {
658
+ if (canonicalPaths.length === 0) {
659
+ return [];
660
+ }
661
+ const placeholders = canonicalPaths.map(() => "?").join(", ");
662
+ return this.db
663
+ .prepare(`SELECT * FROM path_rooms WHERE canonical_path IN (${placeholders})`)
664
+ .all(...canonicalPaths);
665
+ }
666
+ findDeepestRoom(canonicalPaths) {
667
+ const rows = this.findRoomsByCanonicalPaths(canonicalPaths);
668
+ const byPath = new Map(rows.map((row) => [row.canonical_path, row]));
669
+ for (const candidate of canonicalPaths) {
670
+ const row = byPath.get(candidate);
671
+ if (row) {
672
+ return row;
673
+ }
674
+ }
675
+ return null;
676
+ }
677
+ getMember(roomId, agentId) {
678
+ return (this.db
679
+ .prepare("SELECT * FROM room_members WHERE room_id = ? AND agent_id = ?")
680
+ .get(roomId, agentId) ?? null);
681
+ }
682
+ getMembers(roomId) {
683
+ return this.db
684
+ .prepare("SELECT * FROM room_members WHERE room_id = ? ORDER BY ordinal")
685
+ .all(roomId);
686
+ }
687
+ getEventBySeq(eventSeq) {
688
+ return (this.db
689
+ .prepare("SELECT * FROM room_events WHERE event_seq = ?")
690
+ .get(eventSeq) ?? null);
691
+ }
692
+ latestEventSeq(roomId) {
693
+ return (this.db
694
+ .prepare("SELECT MAX(event_seq) AS event_seq FROM room_events WHERE room_id = ?")
695
+ .get(roomId)?.event_seq ?? 0);
696
+ }
697
+ expiresAt(now, ttlMs) {
698
+ return new Date(now.getTime() + ttlMs).toISOString();
699
+ }
700
+ hasExpired(timestamp, now) {
701
+ return timestamp !== null && Date.parse(timestamp) <= now.getTime();
702
+ }
703
+ isMemberActive(member, now) {
704
+ if (this.getMemberProcessLiveness(member) === "gone") {
705
+ return false;
706
+ }
707
+ return this.hasRecentPresence(member, now);
708
+ }
709
+ hasRecentPresence(member, now) {
710
+ return (now.getTime() - Date.parse(member.last_seen_at) <=
711
+ this.policy.presenceTtlMs);
712
+ }
713
+ inspectRoom(room, now) {
714
+ const members = this.getMembers(room.room_id);
715
+ const ownerMember = room.owner
716
+ ? members.find((member) => member.agent_id === room.owner) ?? null
717
+ : null;
718
+ const reservedMember = room.reserved_for
719
+ ? members.find((member) => member.agent_id === room.reserved_for) ?? null
720
+ : null;
721
+ let state;
722
+ if (room.state === "closed") {
723
+ state = "closed";
724
+ }
725
+ else if (room.owner) {
726
+ const ownerLiveness = ownerMember
727
+ ? this.getMemberProcessLiveness(ownerMember)
728
+ : "gone";
729
+ if (ownerLiveness === "gone") {
730
+ state = "owner_gone";
731
+ }
732
+ else if (this.hasExpired(room.lease_expires_at, now)) {
733
+ state = "stale_owner";
734
+ }
735
+ else {
736
+ state = "owned";
737
+ }
738
+ }
739
+ else if (room.reserved_for) {
740
+ const reservedLiveness = reservedMember
741
+ ? this.getMemberProcessLiveness(reservedMember)
742
+ : "gone";
743
+ state = reservedLiveness === "gone" ? "recipient_gone" : "reserved";
744
+ }
745
+ else if (!members.some((member) => this.isMemberActive(member, now))) {
746
+ state = "dormant";
747
+ }
748
+ else {
749
+ state = "idle";
750
+ }
751
+ return {
752
+ room,
753
+ members,
754
+ ownerMember,
755
+ reservedMember,
756
+ state
757
+ };
758
+ }
759
+ inspectRoomForMutation(room, now) {
760
+ const members = this.getMembers(room.room_id);
761
+ const ownerMember = room.owner
762
+ ? members.find((member) => member.agent_id === room.owner) ?? null
763
+ : null;
764
+ const reservedMember = room.reserved_for
765
+ ? members.find((member) => member.agent_id === room.reserved_for) ?? null
766
+ : null;
767
+ let state;
768
+ if (room.state === "closed") {
769
+ state = "closed";
770
+ }
771
+ else if (room.owner) {
772
+ const ownerLiveness = ownerMember
773
+ ? this.getMemberProcessLiveness(ownerMember)
774
+ : "gone";
775
+ if (ownerLiveness === "gone") {
776
+ state = "owner_gone";
777
+ }
778
+ else if (this.hasExpired(room.lease_expires_at, now)) {
779
+ state = "stale_owner";
780
+ }
781
+ else {
782
+ state = "owned";
783
+ }
784
+ }
785
+ else if (room.reserved_for) {
786
+ const reservedLiveness = reservedMember
787
+ ? this.getMemberProcessLiveness(reservedMember)
788
+ : "gone";
789
+ state = reservedLiveness === "gone" ? "recipient_gone" : "reserved";
790
+ }
791
+ else if (!members.some((member) => this.hasRecentPresence(member, now))) {
792
+ state = "dormant";
793
+ }
794
+ else {
795
+ state = "idle";
796
+ }
797
+ return {
798
+ room,
799
+ members,
800
+ ownerMember,
801
+ reservedMember,
802
+ state
803
+ };
804
+ }
805
+ mapRoomForList(room, now) {
806
+ let state;
807
+ if (room.state === "closed") {
808
+ state = "closed";
809
+ }
810
+ else if (room.owner) {
811
+ state = this.hasExpired(room.lease_expires_at, now)
812
+ ? "stale_owner"
813
+ : "owned";
814
+ }
815
+ else if (room.reserved_for) {
816
+ state = "reserved";
817
+ }
818
+ else {
819
+ const members = this.getMembers(room.room_id);
820
+ state = members.some((member) => this.hasRecentPresence(member, now))
821
+ ? "idle"
822
+ : "dormant";
823
+ }
824
+ return {
825
+ ...room,
826
+ state
827
+ };
828
+ }
829
+ warmRoomTurnLiveness(roomId) {
830
+ const room = this.getRoomRow(roomId);
831
+ if (!room) {
832
+ return;
833
+ }
834
+ const members = this.getMembers(roomId);
835
+ if (room.owner) {
836
+ const owner = members.find((member) => member.agent_id === room.owner);
837
+ if (owner) {
838
+ this.getMemberProcessLiveness(owner);
839
+ }
840
+ return;
841
+ }
842
+ if (room.reserved_for) {
843
+ const reserved = members.find((member) => member.agent_id === room.reserved_for);
844
+ if (reserved) {
845
+ this.getMemberProcessLiveness(reserved);
846
+ }
847
+ }
848
+ }
849
+ getMemberProcessLiveness(member) {
850
+ return this.processLivenessChecker({
851
+ host_id: member.host_id,
852
+ pid: member.pid,
853
+ process_started_at: member.process_started_at,
854
+ session_kind: member.session_kind,
855
+ display_name: member.display_name
856
+ });
857
+ }
858
+ deriveRoomState(room, now) {
859
+ return this.inspectRoom(room, now).state;
860
+ }
861
+ mapRoom(inspection, now) {
862
+ const row = inspection.room;
863
+ return {
864
+ ...row,
865
+ state: inspection.state
866
+ };
867
+ }
868
+ mapMember(row, now) {
869
+ return {
870
+ ...row,
871
+ status: this.isMemberActive(row, now) ? "active" : "inactive"
872
+ };
873
+ }
874
+ mapEvent(row) {
875
+ return {
876
+ event_seq: row.event_seq,
877
+ event_id: row.event_id,
878
+ room_id: row.room_id,
879
+ turn_id: row.turn_id,
880
+ event_type: row.event_type,
881
+ from_agent_id: row.from_agent_id,
882
+ to_agent_id: row.to_agent_id,
883
+ handoff: row.handoff_json
884
+ ? JSON.parse(row.handoff_json)
885
+ : null,
886
+ reason: row.reason,
887
+ created_at: row.created_at
888
+ };
889
+ }
890
+ }
891
+ function normalizeProcessMetadata(processMetadata) {
892
+ return {
893
+ host_id: processMetadata?.host_id ?? null,
894
+ pid: processMetadata?.pid ?? null,
895
+ process_started_at: processMetadata?.process_started_at ?? null,
896
+ session_kind: processMetadata?.session_kind ?? "mcp_harness",
897
+ display_name: processMetadata?.display_name ?? null
898
+ };
899
+ }
900
+ function createDefaultProcessLivenessChecker(currentHostId) {
901
+ // This cache keeps normal polling from forking `ps` on every room inspection.
902
+ // A deeper move to out-of-transaction liveness refresh is possible later, but
903
+ // for the MVP we keep the lock boundary simple and the probe shared/cached.
904
+ const inspector = createSystemProcessInspector({ cacheTtlMs: 1_000 });
905
+ return (metadata) => {
906
+ if (metadata.pid === null ||
907
+ metadata.process_started_at === null ||
908
+ metadata.process_started_at.trim() === "") {
909
+ return "unknown";
910
+ }
911
+ if (metadata.host_id && metadata.host_id !== currentHostId) {
912
+ return "unknown";
913
+ }
914
+ if (process.platform === "win32") {
915
+ return "unknown";
916
+ }
917
+ const inspection = inspector.inspect(metadata.pid);
918
+ if (inspection === undefined) {
919
+ return "unknown";
920
+ }
921
+ if (inspection === null || !inspection.startTime) {
922
+ return "gone";
923
+ }
924
+ return inspection.startTime === metadata.process_started_at
925
+ ? "alive"
926
+ : "gone";
927
+ };
928
+ }
929
+ function hasExactProcessIdentity(metadata) {
930
+ return (metadata.pid !== null &&
931
+ metadata.pid !== undefined &&
932
+ metadata.process_started_at !== null &&
933
+ metadata.process_started_at !== undefined &&
934
+ metadata.process_started_at.trim() !== "");
935
+ }
936
+ function sessionKindPriority(sessionKind) {
937
+ switch (sessionKind) {
938
+ case "human_guardian":
939
+ return 3;
940
+ case "mcp_harness":
941
+ return 2;
942
+ case "human_cli":
943
+ return 1;
944
+ default:
945
+ return 2;
946
+ }
947
+ }
948
+ function claimReasonForEvent(pendingEvent) {
949
+ if (!pendingEvent) {
950
+ return "open_claim";
951
+ }
952
+ return pendingEvent.event_type === "pass" ? "direct_pass" : "sequence";
953
+ }
954
+ function validateHandoff(handoff) {
955
+ if (!handoff || typeof handoff !== "object") {
956
+ throw new ProtocolError("invalid_handoff", "handoff must be an object.", { field: "handoff" });
957
+ }
958
+ if (!handoff.status || !handoff.status.trim()) {
959
+ throw new ProtocolError("invalid_handoff", "handoff.status must be non-empty.", { field: "status" });
960
+ }
961
+ if (!handoff.next_action || !handoff.next_action.trim()) {
962
+ throw new ProtocolError("invalid_handoff", "handoff.next_action must be non-empty.", { field: "next_action" });
963
+ }
964
+ if (handoff.artifacts !== undefined && !Array.isArray(handoff.artifacts)) {
965
+ throw new ProtocolError("invalid_handoff", "handoff.artifacts must be an array when provided.", { field: "artifacts" });
966
+ }
967
+ }
968
+ function assertNonEmpty(value, field) {
969
+ if (!value || !value.trim()) {
970
+ throw new ProtocolError("invalid_input", `${field} must be non-empty.`, {
971
+ field
972
+ });
973
+ }
974
+ }
975
+ function handoffTemplate() {
976
+ return {
977
+ status: "What I did:\nWhat I learned:\nOpen risks:",
978
+ next_action: "What the next agent should do next."
979
+ };
980
+ }