talking-stick 0.1.4 → 0.3.0
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 +56 -43
- package/dist/cli/event-stream.js +124 -0
- package/dist/cli/install-commands.js +76 -36
- package/dist/cli/msg-commands.js +81 -0
- package/dist/cli/output.js +5 -3
- package/dist/cli/registry.js +24 -33
- package/dist/cli/room-commands.js +13 -2
- package/dist/cli/startup-maintenance.js +27 -1
- package/dist/cli.js +2 -2
- package/dist/commands.js +15 -0
- package/dist/config.js +4 -1
- package/dist/db.js +7 -0
- package/dist/identity.js +4 -4
- package/dist/index.js +2 -2
- package/dist/install-audit.js +21 -0
- package/dist/install-migration.js +84 -0
- package/dist/install.js +0 -69
- package/dist/service.js +161 -4
- package/dist/update-migration.js +135 -0
- package/docs/plans/2026-05-04-diff-walker-design.md +585 -0
- package/docs/plans/2026-05-05-cli-only-coordination.md +224 -0
- package/docs/plans/out-of-band-signaling-implementation.md +854 -0
- package/docs/plans/out-of-band-signaling.md +255 -176
- package/docs/receive-consumer-contract.md +32 -0
- package/docs/releases/0.2.0.md +85 -0
- package/docs/releases/0.3.0.md +77 -0
- package/docs/talking-stick-plan.md +3 -2
- package/package.json +4 -3
- package/scripts/postinstall-mcp-cleanup.cjs +25 -0
- package/skills/talking-stick/SKILL.md +131 -88
- package/dist/mcp-server.js +0 -212
- package/dist/server.js +0 -3
package/dist/service.js
CHANGED
|
@@ -7,6 +7,16 @@ import { openDatabase, withImmediateTransaction } from "./db.js";
|
|
|
7
7
|
import { ProtocolError } from "./errors.js";
|
|
8
8
|
import { createSystemProcessInspector } from "./process-utils.js";
|
|
9
9
|
const MAX_NOTE_BODY_BYTES = 16 * 1024;
|
|
10
|
+
const MAX_MESSAGE_BODY_BYTES = 4096;
|
|
11
|
+
const KNOWN_EVENT_TYPES = [
|
|
12
|
+
"claim",
|
|
13
|
+
"release",
|
|
14
|
+
"pass",
|
|
15
|
+
"takeover",
|
|
16
|
+
"close",
|
|
17
|
+
"kick",
|
|
18
|
+
"message_sent"
|
|
19
|
+
];
|
|
10
20
|
export class TalkingStickService {
|
|
11
21
|
db;
|
|
12
22
|
policy;
|
|
@@ -442,6 +452,95 @@ export class TalkingStickService {
|
|
|
442
452
|
.all(input.room_id, afterEventSeq, limit)
|
|
443
453
|
.map((row) => this.mapEvent(row));
|
|
444
454
|
}
|
|
455
|
+
sendMessage(input) {
|
|
456
|
+
assertNonEmpty(input.agent_id, "agent_id");
|
|
457
|
+
assertNonEmpty(input.room_id, "room_id");
|
|
458
|
+
const body = input.body ?? "";
|
|
459
|
+
if (body.length === 0) {
|
|
460
|
+
throw new ProtocolError("invalid_body", "Message body must not be empty.");
|
|
461
|
+
}
|
|
462
|
+
const byteLength = Buffer.byteLength(body, "utf8");
|
|
463
|
+
if (byteLength > MAX_MESSAGE_BODY_BYTES) {
|
|
464
|
+
throw new ProtocolError("message_too_large", `Message body exceeds ${MAX_MESSAGE_BODY_BYTES} bytes.`, { supplied: byteLength });
|
|
465
|
+
}
|
|
466
|
+
const deliveryHint = input.delivery_hint ?? "normal";
|
|
467
|
+
if (deliveryHint !== "normal" && deliveryHint !== "interrupt") {
|
|
468
|
+
throw new ProtocolError("invalid_delivery_hint", "delivery_hint must be 'normal' or 'interrupt'.");
|
|
469
|
+
}
|
|
470
|
+
const now = this.now();
|
|
471
|
+
const timestamp = now.toISOString();
|
|
472
|
+
this.purgeExpiredIdleRooms(now);
|
|
473
|
+
return withImmediateTransaction(this.db, () => {
|
|
474
|
+
const room = this.requireRoom(input.room_id);
|
|
475
|
+
if (room.state === "closed") {
|
|
476
|
+
throw new ProtocolError("room_closed", "Messages cannot be sent to a closed room.", { room_id: input.room_id });
|
|
477
|
+
}
|
|
478
|
+
this.touchMember(input.room_id, input.agent_id, timestamp);
|
|
479
|
+
if (input.to_agent_id) {
|
|
480
|
+
const target = this.getMember(input.room_id, input.to_agent_id);
|
|
481
|
+
if (!target) {
|
|
482
|
+
throw new ProtocolError("unknown_recipient", "to_agent_id is not a member of this room.", { to_agent_id: input.to_agent_id });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const eventSeq = this.appendEvent({
|
|
486
|
+
room_id: input.room_id,
|
|
487
|
+
turn_id: room.turn_id,
|
|
488
|
+
event_type: "message_sent",
|
|
489
|
+
from_agent_id: input.agent_id,
|
|
490
|
+
to_agent_id: input.to_agent_id ?? null,
|
|
491
|
+
handoff: null,
|
|
492
|
+
reason: null,
|
|
493
|
+
created_at: timestamp,
|
|
494
|
+
payload: { body, delivery_hint: deliveryHint }
|
|
495
|
+
});
|
|
496
|
+
const row = this.db
|
|
497
|
+
.prepare("SELECT event_id FROM room_events WHERE event_seq = ?")
|
|
498
|
+
.get(eventSeq);
|
|
499
|
+
return {
|
|
500
|
+
event_seq: eventSeq,
|
|
501
|
+
event_id: row?.event_id ?? "",
|
|
502
|
+
created_at: timestamp
|
|
503
|
+
};
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
async waitForEvents(input) {
|
|
507
|
+
assertNonEmpty(input.room_id, "room_id");
|
|
508
|
+
this.requireRoom(input.room_id);
|
|
509
|
+
const targetFilter = input.target_agent_id ?? "self";
|
|
510
|
+
if (targetFilter === "self" && !input.agent_id) {
|
|
511
|
+
throw new ProtocolError("agent_id_required", "agent_id is required when target_agent_id is 'self'.");
|
|
512
|
+
}
|
|
513
|
+
const eventTypes = normalizeEventTypeFilter(input.event_type);
|
|
514
|
+
const afterEventSeq = input.after_event_seq ?? 0;
|
|
515
|
+
const maxWaitMs = Math.min(Math.max(input.max_wait_ms ?? this.policy.waitForEventsMaxWaitMs, 0), this.policy.waitForEventsMaxWaitMs);
|
|
516
|
+
const deadline = Date.now() + maxWaitMs;
|
|
517
|
+
while (true) {
|
|
518
|
+
const events = this.queryEvents({
|
|
519
|
+
room_id: input.room_id,
|
|
520
|
+
after_event_seq: afterEventSeq,
|
|
521
|
+
event_types: eventTypes,
|
|
522
|
+
target: targetFilter,
|
|
523
|
+
caller_agent_id: input.agent_id ?? null,
|
|
524
|
+
from_agent_id: input.from_agent_id ?? null,
|
|
525
|
+
limit: this.policy.waitForEventsBatchLimit
|
|
526
|
+
});
|
|
527
|
+
if (events.length > 0 || Date.now() >= deadline) {
|
|
528
|
+
const lastSeq = events.length > 0
|
|
529
|
+
? events[events.length - 1].event_seq
|
|
530
|
+
: afterEventSeq;
|
|
531
|
+
return { events, cursor_event_seq: lastSeq };
|
|
532
|
+
}
|
|
533
|
+
const remainingMs = deadline - Date.now();
|
|
534
|
+
await sleep(Math.min(this.policy.waitForEventsPollMs, remainingMs));
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
getLatestEventSeq(input) {
|
|
538
|
+
assertNonEmpty(input.room_id, "room_id");
|
|
539
|
+
this.requireRoom(input.room_id);
|
|
540
|
+
return (this.db
|
|
541
|
+
.prepare("SELECT MAX(event_seq) AS event_seq FROM room_events WHERE room_id = ?")
|
|
542
|
+
.get(input.room_id)?.event_seq ?? 0);
|
|
543
|
+
}
|
|
445
544
|
addNote(input) {
|
|
446
545
|
assertNonEmpty(input.agent_id, "agent_id");
|
|
447
546
|
assertNonEmpty(input.room_id, "room_id");
|
|
@@ -983,13 +1082,52 @@ export class TalkingStickService {
|
|
|
983
1082
|
to_agent_id,
|
|
984
1083
|
handoff_json,
|
|
985
1084
|
reason,
|
|
986
|
-
created_at
|
|
1085
|
+
created_at,
|
|
1086
|
+
payload_json
|
|
987
1087
|
)
|
|
988
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1088
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
989
1089
|
`)
|
|
990
|
-
.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);
|
|
1090
|
+
.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, input.payload ? JSON.stringify(input.payload) : null);
|
|
991
1091
|
return Number(result.lastInsertRowid);
|
|
992
1092
|
}
|
|
1093
|
+
queryEvents(input) {
|
|
1094
|
+
const clauses = ["room_id = ?", "event_seq > ?"];
|
|
1095
|
+
const params = [input.room_id, input.after_event_seq];
|
|
1096
|
+
if (input.event_types) {
|
|
1097
|
+
clauses.push(`event_type IN (${input.event_types.map(() => "?").join(", ")})`);
|
|
1098
|
+
params.push(...input.event_types);
|
|
1099
|
+
}
|
|
1100
|
+
if (input.target === "self") {
|
|
1101
|
+
if (!input.caller_agent_id) {
|
|
1102
|
+
throw new ProtocolError("agent_id_required", "agent_id is required when target_agent_id is 'self'.");
|
|
1103
|
+
}
|
|
1104
|
+
clauses.push(`(
|
|
1105
|
+
(event_type = 'message_sent' AND (to_agent_id = ? OR (to_agent_id IS NULL AND from_agent_id != ?)))
|
|
1106
|
+
OR
|
|
1107
|
+
(event_type != 'message_sent' AND (to_agent_id = ? OR from_agent_id = ?))
|
|
1108
|
+
)`);
|
|
1109
|
+
params.push(input.caller_agent_id, input.caller_agent_id, input.caller_agent_id, input.caller_agent_id);
|
|
1110
|
+
}
|
|
1111
|
+
else if (input.target !== "any") {
|
|
1112
|
+
clauses.push("to_agent_id = ?");
|
|
1113
|
+
params.push(input.target);
|
|
1114
|
+
}
|
|
1115
|
+
if (input.from_agent_id) {
|
|
1116
|
+
clauses.push("from_agent_id = ?");
|
|
1117
|
+
params.push(input.from_agent_id);
|
|
1118
|
+
}
|
|
1119
|
+
params.push(Math.min(Math.max(input.limit, 1), 500));
|
|
1120
|
+
return this.db
|
|
1121
|
+
.prepare(`
|
|
1122
|
+
SELECT *
|
|
1123
|
+
FROM room_events
|
|
1124
|
+
WHERE ${clauses.join(" AND ")}
|
|
1125
|
+
ORDER BY event_seq
|
|
1126
|
+
LIMIT ?
|
|
1127
|
+
`)
|
|
1128
|
+
.all(...params)
|
|
1129
|
+
.map((row) => this.mapEvent(row));
|
|
1130
|
+
}
|
|
993
1131
|
getRoomRow(roomId) {
|
|
994
1132
|
return this.db
|
|
995
1133
|
.prepare("SELECT * FROM path_rooms WHERE room_id = ?")
|
|
@@ -1306,6 +1444,9 @@ export class TalkingStickService {
|
|
|
1306
1444
|
};
|
|
1307
1445
|
}
|
|
1308
1446
|
mapEvent(row) {
|
|
1447
|
+
const payload = row.event_type === "message_sent" && row.payload_json
|
|
1448
|
+
? JSON.parse(row.payload_json)
|
|
1449
|
+
: null;
|
|
1309
1450
|
return {
|
|
1310
1451
|
event_seq: row.event_seq,
|
|
1311
1452
|
event_id: row.event_id,
|
|
@@ -1318,7 +1459,8 @@ export class TalkingStickService {
|
|
|
1318
1459
|
? JSON.parse(row.handoff_json)
|
|
1319
1460
|
: null,
|
|
1320
1461
|
reason: row.reason,
|
|
1321
|
-
created_at: row.created_at
|
|
1462
|
+
created_at: row.created_at,
|
|
1463
|
+
payload
|
|
1322
1464
|
};
|
|
1323
1465
|
}
|
|
1324
1466
|
}
|
|
@@ -1449,6 +1591,21 @@ function validateHandoff(handoff) {
|
|
|
1449
1591
|
throw new ProtocolError("invalid_handoff", "handoff.artifacts must be an array when provided.", { field: "artifacts" });
|
|
1450
1592
|
}
|
|
1451
1593
|
}
|
|
1594
|
+
function normalizeEventTypeFilter(filter) {
|
|
1595
|
+
if (filter === undefined) {
|
|
1596
|
+
return null;
|
|
1597
|
+
}
|
|
1598
|
+
const values = Array.isArray(filter) ? filter : [filter];
|
|
1599
|
+
if (values.length === 0) {
|
|
1600
|
+
throw new ProtocolError("invalid_event_type_filter", "event_type filter must not be empty.");
|
|
1601
|
+
}
|
|
1602
|
+
for (const value of values) {
|
|
1603
|
+
if (!KNOWN_EVENT_TYPES.includes(value)) {
|
|
1604
|
+
throw new ProtocolError("invalid_event_type_filter", `Unsupported event_type: ${value}.`);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
return values;
|
|
1608
|
+
}
|
|
1452
1609
|
function assertNonEmpty(value, field) {
|
|
1453
1610
|
if (!value || !value.trim()) {
|
|
1454
1611
|
throw new ProtocolError("invalid_input", `${field} must be non-empty.`, {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { resolveDataDir } from "./config.js";
|
|
5
|
+
import { FileAuditLog, defaultAuditLogPath } from "./install-audit.js";
|
|
6
|
+
import { removeStaleMcpRegistrations } from "./install-migration.js";
|
|
7
|
+
export const UPDATE_MIGRATION_STATE_FILE = "update-migrations-state.json";
|
|
8
|
+
export async function runStaleMcpCleanup(options) {
|
|
9
|
+
const packageVersionTo = options.packageVersionTo ?? options.packageVersion ?? readPackageVersion();
|
|
10
|
+
const dataDir = options.dataDir ?? resolveMigrationDataDir(options.installOptions);
|
|
11
|
+
const statePath = resolveUpdateMigrationStatePath(dataDir);
|
|
12
|
+
const auditPath = defaultAuditLogPath(dataDir);
|
|
13
|
+
const audit = options.audit ?? new FileAuditLog(auditPath);
|
|
14
|
+
const results = await removeStaleMcpRegistrations({
|
|
15
|
+
harnesses: options.harnesses ?? "all",
|
|
16
|
+
reason: options.reason,
|
|
17
|
+
packageVersionFrom: options.packageVersionFrom,
|
|
18
|
+
packageVersionTo,
|
|
19
|
+
audit,
|
|
20
|
+
installOptions: options.installOptions
|
|
21
|
+
});
|
|
22
|
+
if (options.updateState !== false && !results.some((result) => result.action === "failed")) {
|
|
23
|
+
writeUpdateMigrationState(statePath, {
|
|
24
|
+
mcp_cleanup_version: packageVersionTo,
|
|
25
|
+
updated_at: new Date().toISOString()
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
status: "ran",
|
|
30
|
+
packageVersionFrom: options.packageVersionFrom,
|
|
31
|
+
packageVersionTo,
|
|
32
|
+
statePath,
|
|
33
|
+
auditPath,
|
|
34
|
+
results
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export async function runFirstRunMcpMigration(options = {}) {
|
|
38
|
+
const packageVersion = options.packageVersion ?? readPackageVersion();
|
|
39
|
+
const dataDir = options.dataDir ?? resolveMigrationDataDir(options.installOptions);
|
|
40
|
+
const statePath = resolveUpdateMigrationStatePath(dataDir);
|
|
41
|
+
const auditPath = defaultAuditLogPath(dataDir);
|
|
42
|
+
const state = readUpdateMigrationState(statePath);
|
|
43
|
+
if (state.mcp_cleanup_version === packageVersion) {
|
|
44
|
+
return {
|
|
45
|
+
status: "current",
|
|
46
|
+
packageVersion,
|
|
47
|
+
statePath,
|
|
48
|
+
auditPath,
|
|
49
|
+
results: []
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return runStaleMcpCleanup({
|
|
53
|
+
harnesses: "all",
|
|
54
|
+
reason: "first-run",
|
|
55
|
+
packageVersionFrom: state.mcp_cleanup_version,
|
|
56
|
+
packageVersionTo: packageVersion,
|
|
57
|
+
dataDir,
|
|
58
|
+
audit: options.audit,
|
|
59
|
+
installOptions: options.installOptions
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
export function resolveUpdateMigrationStatePath(dataDir) {
|
|
63
|
+
return path.join(dataDir, UPDATE_MIGRATION_STATE_FILE);
|
|
64
|
+
}
|
|
65
|
+
export function readUpdateMigrationState(statePath) {
|
|
66
|
+
try {
|
|
67
|
+
const raw = fs.readFileSync(statePath, "utf8");
|
|
68
|
+
const parsed = JSON.parse(raw);
|
|
69
|
+
if (!isPlainObject(parsed))
|
|
70
|
+
return {};
|
|
71
|
+
return {
|
|
72
|
+
mcp_cleanup_version: typeof parsed.mcp_cleanup_version === "string"
|
|
73
|
+
? parsed.mcp_cleanup_version
|
|
74
|
+
: undefined,
|
|
75
|
+
updated_at: typeof parsed.updated_at === "string" ? parsed.updated_at : undefined
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error.code === "ENOENT")
|
|
80
|
+
return {};
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function writeUpdateMigrationState(statePath, state) {
|
|
85
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
86
|
+
const tmpPath = `${statePath}.${process.pid}.tmp`;
|
|
87
|
+
fs.writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
88
|
+
fs.renameSync(tmpPath, statePath);
|
|
89
|
+
}
|
|
90
|
+
export function readPackageVersion(startUrl = import.meta.url) {
|
|
91
|
+
const root = findPackageRoot(fileURLToPath(startUrl));
|
|
92
|
+
if (!root)
|
|
93
|
+
return "unknown";
|
|
94
|
+
try {
|
|
95
|
+
const raw = fs.readFileSync(path.join(root, "package.json"), "utf8");
|
|
96
|
+
const parsed = JSON.parse(raw);
|
|
97
|
+
return typeof parsed.version === "string" && parsed.version.trim()
|
|
98
|
+
? parsed.version
|
|
99
|
+
: "unknown";
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return "unknown";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function resolveMigrationDataDir(installOptions) {
|
|
106
|
+
const options = {
|
|
107
|
+
env: installOptions?.env,
|
|
108
|
+
platform: installOptions?.platform,
|
|
109
|
+
homeDir: installOptions?.homeDir
|
|
110
|
+
};
|
|
111
|
+
return resolveDataDir(options);
|
|
112
|
+
}
|
|
113
|
+
function findPackageRoot(startPath) {
|
|
114
|
+
let current;
|
|
115
|
+
try {
|
|
116
|
+
current = fs.statSync(startPath).isDirectory()
|
|
117
|
+
? startPath
|
|
118
|
+
: path.dirname(startPath);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
current = path.dirname(startPath);
|
|
122
|
+
}
|
|
123
|
+
while (true) {
|
|
124
|
+
const candidate = path.join(current, "package.json");
|
|
125
|
+
if (fs.existsSync(candidate))
|
|
126
|
+
return current;
|
|
127
|
+
const parent = path.dirname(current);
|
|
128
|
+
if (parent === current)
|
|
129
|
+
return null;
|
|
130
|
+
current = parent;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function isPlainObject(value) {
|
|
134
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
135
|
+
}
|