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.
- package/README.md +166 -0
- package/dist/cli.js +701 -0
- package/dist/commands.js +70 -0
- package/dist/config.js +31 -0
- package/dist/db.js +177 -0
- package/dist/errors.js +20 -0
- package/dist/identity.js +184 -0
- package/dist/index.js +12 -0
- package/dist/install.js +272 -0
- package/dist/mcp-server.js +171 -0
- package/dist/path-resolution.js +101 -0
- package/dist/process-utils.js +93 -0
- package/dist/server.js +3 -0
- package/dist/service.js +980 -0
- package/dist/session-store.js +80 -0
- package/dist/skill-install.js +107 -0
- package/dist/types.js +1 -0
- package/docs/ambient-presence.md +191 -0
- package/docs/releases/0.1.0-alpha.md +32 -0
- package/docs/talking-stick-plan.md +1156 -0
- package/package.json +40 -0
- package/skills/talking-stick/SKILL.md +132 -0
- package/skills/talking-stick/agents/openai.yaml +4 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { clearCliSessionLease, createSystemProcessInspector, deriveHarnessCliIdentity, deriveHumanCliIdentity, findCliSessionForContextPath, isProtocolError, resolveCliSessionPath, runStdioServer, TalkingStickCommands, TalkingStickService, terminateKnownProcess, upsertCliSession } from "./index.js";
|
|
7
|
+
import { SUPPORTED_HARNESSES, detectHarness, parseHarnessList, planInstall, planUninstall, runAction } from "./install.js";
|
|
8
|
+
import { planSkillInstall, planSkillUninstall } from "./skill-install.js";
|
|
9
|
+
import { resolveContextPath } from "./path-resolution.js";
|
|
10
|
+
const GUARD_READY = "READY";
|
|
11
|
+
const STALE_GUARD_ERRORS = new Set(["stale_lease", "turn_mismatch", "room_not_found"]);
|
|
12
|
+
export async function runCli(argv = process.argv.slice(2)) {
|
|
13
|
+
const parsed = parseCommand(argv);
|
|
14
|
+
if (!parsed.name || parsed.name === "help" || parsed.name === "--help") {
|
|
15
|
+
printHelp();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (parsed.name === "mcp") {
|
|
19
|
+
await runStdioServer();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (parsed.name === "guard") {
|
|
23
|
+
await runGuardCommand(parsed);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (parsed.name === "install") {
|
|
27
|
+
await runInstallCommand(parsed);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (parsed.name === "uninstall") {
|
|
31
|
+
await runUninstallCommand(parsed);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (parsed.name === "install-skill") {
|
|
35
|
+
await runInstallSkillCommand(parsed);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (parsed.name === "uninstall-skill") {
|
|
39
|
+
await runUninstallSkillCommand(parsed);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const runtime = createRuntime();
|
|
43
|
+
try {
|
|
44
|
+
switch (parsed.name) {
|
|
45
|
+
case "list":
|
|
46
|
+
handleListCommand(runtime, parsed);
|
|
47
|
+
return;
|
|
48
|
+
case "join":
|
|
49
|
+
handleJoinCommand(runtime, parsed);
|
|
50
|
+
return;
|
|
51
|
+
case "state":
|
|
52
|
+
handleStateCommand(runtime, parsed);
|
|
53
|
+
return;
|
|
54
|
+
case "events":
|
|
55
|
+
handleEventsCommand(runtime, parsed);
|
|
56
|
+
return;
|
|
57
|
+
case "wait":
|
|
58
|
+
await handleWaitCommand(runtime, parsed, false);
|
|
59
|
+
return;
|
|
60
|
+
case "try":
|
|
61
|
+
await handleWaitCommand(runtime, parsed, true);
|
|
62
|
+
return;
|
|
63
|
+
case "takeover":
|
|
64
|
+
await handleTakeoverCommand(runtime, parsed);
|
|
65
|
+
return;
|
|
66
|
+
case "release":
|
|
67
|
+
handleReleaseCommand(runtime, parsed);
|
|
68
|
+
return;
|
|
69
|
+
case "pass":
|
|
70
|
+
handlePassCommand(runtime, parsed);
|
|
71
|
+
return;
|
|
72
|
+
default:
|
|
73
|
+
throw new Error(`Unknown command: ${parsed.name}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
runtime.close();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function createRuntime() {
|
|
81
|
+
const service = new TalkingStickService();
|
|
82
|
+
return {
|
|
83
|
+
commands: new TalkingStickCommands(service),
|
|
84
|
+
close: () => service.close()
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function handleListCommand(runtime, parsed) {
|
|
88
|
+
const contextPath = parsed.positionals[0] ?? process.cwd();
|
|
89
|
+
const result = runtime.commands.listRooms({ context_path: contextPath });
|
|
90
|
+
printResult(parsed, result, () => {
|
|
91
|
+
if (result.rooms.length === 0) {
|
|
92
|
+
return "No rooms found.";
|
|
93
|
+
}
|
|
94
|
+
return result.rooms
|
|
95
|
+
.map((room) => {
|
|
96
|
+
const owner = room.owner ? ` owner=${room.owner}` : "";
|
|
97
|
+
const reserved = room.reserved_for
|
|
98
|
+
? ` reserved_for=${room.reserved_for}`
|
|
99
|
+
: "";
|
|
100
|
+
return `${room.state} ${room.canonical_path}${owner}${reserved}`;
|
|
101
|
+
})
|
|
102
|
+
.join("\n");
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function handleJoinCommand(runtime, parsed) {
|
|
106
|
+
const contextPath = parsed.positionals[0] ?? process.cwd();
|
|
107
|
+
const identity = deriveCliIdentity(parsed);
|
|
108
|
+
const joined = runtime.commands.joinPath(identity, {
|
|
109
|
+
context_path: contextPath,
|
|
110
|
+
force_new: hasOption(parsed, "force-new")
|
|
111
|
+
});
|
|
112
|
+
upsertSessionFromJoin(identity, joined);
|
|
113
|
+
printResult(parsed, joined, () => {
|
|
114
|
+
return `Joined ${joined.canonical_path} as ${joined.agent_id}`;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function handleStateCommand(runtime, parsed) {
|
|
118
|
+
const identity = deriveCliIdentity(parsed);
|
|
119
|
+
const session = resolveSessionForReads(runtime, parsed, identity);
|
|
120
|
+
const state = runtime.commands.getRoomState({ room_id: session.room_id });
|
|
121
|
+
printResult(parsed, { room: state.room, members: state.members }, () => {
|
|
122
|
+
const owner = state.room.owner ? ` owner=${state.room.owner}` : "";
|
|
123
|
+
const reserved = state.room.reserved_for
|
|
124
|
+
? ` reserved_for=${state.room.reserved_for}`
|
|
125
|
+
: "";
|
|
126
|
+
return `${state.room.state} ${session.canonical_path}${owner}${reserved}`;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function handleEventsCommand(runtime, parsed) {
|
|
130
|
+
const identity = deriveCliIdentity(parsed);
|
|
131
|
+
const session = resolveSessionForReads(runtime, parsed, identity);
|
|
132
|
+
const events = runtime.commands.getRoomEvents({
|
|
133
|
+
room_id: session.room_id,
|
|
134
|
+
after_event_seq: parseOptionalInteger(parsed, "after"),
|
|
135
|
+
limit: parseOptionalInteger(parsed, "limit")
|
|
136
|
+
});
|
|
137
|
+
printResult(parsed, events, () => {
|
|
138
|
+
if (events.length === 0) {
|
|
139
|
+
return "No events.";
|
|
140
|
+
}
|
|
141
|
+
return events
|
|
142
|
+
.map((event) => `${event.event_seq} ${event.event_type} ${event.from_agent_id ?? "-"} -> ${event.to_agent_id ?? "-"}`)
|
|
143
|
+
.join("\n");
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
async function handleWaitCommand(runtime, parsed, isTry) {
|
|
147
|
+
const contextPath = parsed.positionals[0] ?? process.cwd();
|
|
148
|
+
const identity = deriveCliIdentity(parsed);
|
|
149
|
+
const joined = runtime.commands.joinPath(identity, { context_path: contextPath });
|
|
150
|
+
upsertSessionFromJoin(identity, joined);
|
|
151
|
+
const waitResult = await runtime.commands.waitForTurn(identity, {
|
|
152
|
+
room_id: joined.room_id,
|
|
153
|
+
max_wait_ms: isTry ? 0 : parseWaitTimeout(parsed)
|
|
154
|
+
});
|
|
155
|
+
if (waitResult.status === "your_turn") {
|
|
156
|
+
const guardianPid = await spawnGuardian({
|
|
157
|
+
agentId: identity.agent_id,
|
|
158
|
+
canonicalPath: joined.canonical_path,
|
|
159
|
+
roomId: joined.room_id,
|
|
160
|
+
leaseId: waitResult.lease_id,
|
|
161
|
+
turnId: waitResult.turn_id
|
|
162
|
+
});
|
|
163
|
+
upsertCliSession(resolveCliSessionPath(), {
|
|
164
|
+
agent_id: identity.agent_id,
|
|
165
|
+
room_id: joined.room_id,
|
|
166
|
+
canonical_path: joined.canonical_path,
|
|
167
|
+
workspace_root: joined.workspace_root,
|
|
168
|
+
lease_id: waitResult.lease_id,
|
|
169
|
+
turn_id: waitResult.turn_id,
|
|
170
|
+
guardian_pid: guardianPid.pid,
|
|
171
|
+
guardian_process_started_at: guardianPid.process_started_at,
|
|
172
|
+
updated_at: new Date().toISOString()
|
|
173
|
+
});
|
|
174
|
+
printResult(parsed, { ...waitResult, guardian_pid: guardianPid.pid }, () => `Your turn. Guardian ${guardianPid.pid} is holding the lease.`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
printResult(parsed, waitResult, () => formatWaitResult(waitResult));
|
|
178
|
+
}
|
|
179
|
+
async function handleTakeoverCommand(runtime, parsed) {
|
|
180
|
+
const contextPath = parsed.positionals[0] ?? process.cwd();
|
|
181
|
+
const identity = deriveCliIdentity(parsed);
|
|
182
|
+
const joined = runtime.commands.joinPath(identity, { context_path: contextPath });
|
|
183
|
+
upsertSessionFromJoin(identity, joined);
|
|
184
|
+
const availability = await runtime.commands.waitForTurn(identity, {
|
|
185
|
+
room_id: joined.room_id,
|
|
186
|
+
max_wait_ms: 0
|
|
187
|
+
});
|
|
188
|
+
if (availability.status !== "takeover_available") {
|
|
189
|
+
throw new Error(`Takeover is not available: ${formatWaitResult(availability)}`);
|
|
190
|
+
}
|
|
191
|
+
const result = runtime.commands.takeoverStick(identity, {
|
|
192
|
+
room_id: joined.room_id,
|
|
193
|
+
expected_turn_id: availability.turn_id,
|
|
194
|
+
reason: requireStringOption(parsed, "reason")
|
|
195
|
+
});
|
|
196
|
+
const guardianPid = await spawnGuardian({
|
|
197
|
+
agentId: identity.agent_id,
|
|
198
|
+
canonicalPath: joined.canonical_path,
|
|
199
|
+
roomId: joined.room_id,
|
|
200
|
+
leaseId: result.lease_id,
|
|
201
|
+
turnId: result.turn_id
|
|
202
|
+
});
|
|
203
|
+
upsertCliSession(resolveCliSessionPath(), {
|
|
204
|
+
agent_id: identity.agent_id,
|
|
205
|
+
room_id: joined.room_id,
|
|
206
|
+
canonical_path: joined.canonical_path,
|
|
207
|
+
workspace_root: joined.workspace_root,
|
|
208
|
+
lease_id: result.lease_id,
|
|
209
|
+
turn_id: result.turn_id,
|
|
210
|
+
guardian_pid: guardianPid.pid,
|
|
211
|
+
guardian_process_started_at: guardianPid.process_started_at,
|
|
212
|
+
updated_at: new Date().toISOString()
|
|
213
|
+
});
|
|
214
|
+
printResult(parsed, { ...result, guardian_pid: guardianPid.pid }, () => `Takeover succeeded. Guardian ${guardianPid.pid} is holding the lease.`);
|
|
215
|
+
}
|
|
216
|
+
function handleReleaseCommand(runtime, parsed) {
|
|
217
|
+
const identity = deriveCliIdentity(parsed);
|
|
218
|
+
const contextPath = parsed.positionals[0] ?? process.cwd();
|
|
219
|
+
const session = requireLeaseSession(identity, contextPath);
|
|
220
|
+
const handoff = requireHandoff(parsed);
|
|
221
|
+
const result = runtime.commands.releaseStick(identity, {
|
|
222
|
+
room_id: session.room_id,
|
|
223
|
+
lease_id: session.lease_id,
|
|
224
|
+
expected_turn_id: session.turn_id,
|
|
225
|
+
handoff
|
|
226
|
+
});
|
|
227
|
+
clearCliSessionLease(resolveCliSessionPath(), identity.agent_id, session.room_id);
|
|
228
|
+
stopGuardian(session.guardian_pid, session.guardian_process_started_at ?? null);
|
|
229
|
+
printResult(parsed, result, () => {
|
|
230
|
+
const target = result.reserved_for ? ` to ${result.reserved_for}` : "";
|
|
231
|
+
return `Released${target}.`;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
function handlePassCommand(runtime, parsed) {
|
|
235
|
+
const identity = deriveCliIdentity(parsed);
|
|
236
|
+
const contextPath = parsed.positionals[1] ?? process.cwd();
|
|
237
|
+
const session = requireLeaseSession(identity, contextPath);
|
|
238
|
+
const handoff = requireHandoff(parsed);
|
|
239
|
+
const target = parsed.positionals[0];
|
|
240
|
+
if (!target) {
|
|
241
|
+
const result = runtime.commands.releaseStick(identity, {
|
|
242
|
+
room_id: session.room_id,
|
|
243
|
+
lease_id: session.lease_id,
|
|
244
|
+
expected_turn_id: session.turn_id,
|
|
245
|
+
handoff
|
|
246
|
+
});
|
|
247
|
+
clearCliSessionLease(resolveCliSessionPath(), identity.agent_id, session.room_id);
|
|
248
|
+
stopGuardian(session.guardian_pid, session.guardian_process_started_at ?? null);
|
|
249
|
+
printResult(parsed, result, () => {
|
|
250
|
+
const reserved = result.reserved_for ? ` to ${result.reserved_for}` : "";
|
|
251
|
+
return `Passed${reserved}.`;
|
|
252
|
+
});
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const result = runtime.commands.passStick(identity, {
|
|
256
|
+
room_id: session.room_id,
|
|
257
|
+
lease_id: session.lease_id,
|
|
258
|
+
expected_turn_id: session.turn_id,
|
|
259
|
+
to_agent_id: target,
|
|
260
|
+
handoff
|
|
261
|
+
});
|
|
262
|
+
clearCliSessionLease(resolveCliSessionPath(), identity.agent_id, session.room_id);
|
|
263
|
+
stopGuardian(session.guardian_pid, session.guardian_process_started_at ?? null);
|
|
264
|
+
printResult(parsed, result, () => `Passed to ${result.reserved_for}.`);
|
|
265
|
+
}
|
|
266
|
+
async function runGuardCommand(parsed) {
|
|
267
|
+
const identity = deriveHumanCliIdentity({
|
|
268
|
+
agentId: requireStringOption(parsed, "agent"),
|
|
269
|
+
displayName: requireStringOption(parsed, "agent").replace(/^human:/, ""),
|
|
270
|
+
sessionKind: "human_guardian"
|
|
271
|
+
});
|
|
272
|
+
const runtime = createRuntime();
|
|
273
|
+
try {
|
|
274
|
+
const joined = runtime.commands.joinPath(identity, {
|
|
275
|
+
context_path: requireStringOption(parsed, "context-path")
|
|
276
|
+
});
|
|
277
|
+
const heartbeatInput = {
|
|
278
|
+
room_id: requireStringOption(parsed, "room-id"),
|
|
279
|
+
lease_id: requireStringOption(parsed, "lease-id"),
|
|
280
|
+
expected_turn_id: parseRequiredInteger(parsed, "turn-id")
|
|
281
|
+
};
|
|
282
|
+
const intervalMs = joined.policy.heartbeatIntervalMs;
|
|
283
|
+
process.stdout.write(`${GUARD_READY}\n`);
|
|
284
|
+
const timer = setInterval(() => {
|
|
285
|
+
try {
|
|
286
|
+
runtime.commands.heartbeat(identity, heartbeatInput);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
if (isProtocolError(error) && STALE_GUARD_ERRORS.has(error.code)) {
|
|
290
|
+
process.exit(0);
|
|
291
|
+
}
|
|
292
|
+
process.exit(1);
|
|
293
|
+
}
|
|
294
|
+
}, intervalMs);
|
|
295
|
+
const exit = () => {
|
|
296
|
+
clearInterval(timer);
|
|
297
|
+
process.exit(0);
|
|
298
|
+
};
|
|
299
|
+
process.on("SIGINT", exit);
|
|
300
|
+
process.on("SIGTERM", exit);
|
|
301
|
+
await new Promise(() => undefined);
|
|
302
|
+
}
|
|
303
|
+
finally {
|
|
304
|
+
runtime.close();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function deriveCliIdentity(parsed) {
|
|
308
|
+
const agentIdOption = getStringOption(parsed, "agent");
|
|
309
|
+
if (agentIdOption) {
|
|
310
|
+
const displayName = agentIdOption.replace(/^[^:]+:/, "");
|
|
311
|
+
return deriveHumanCliIdentity({
|
|
312
|
+
agentId: agentIdOption,
|
|
313
|
+
displayName
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
const harnessIdentity = deriveHarnessCliIdentity();
|
|
317
|
+
if (harnessIdentity) {
|
|
318
|
+
return harnessIdentity;
|
|
319
|
+
}
|
|
320
|
+
return deriveHumanCliIdentity();
|
|
321
|
+
}
|
|
322
|
+
function resolveSessionForReads(runtime, parsed, identity) {
|
|
323
|
+
const contextPath = parsed.positionals[0] ?? process.cwd();
|
|
324
|
+
const resolvedPath = resolveContextPath(contextPath);
|
|
325
|
+
const sessionPath = resolveCliSessionPath();
|
|
326
|
+
const existing = findCliSessionForContextPath(sessionPath, identity.agent_id, contextPath);
|
|
327
|
+
if (existing) {
|
|
328
|
+
return existing;
|
|
329
|
+
}
|
|
330
|
+
const rooms = runtime.commands.listRooms({ context_path: contextPath }).rooms;
|
|
331
|
+
const room = pickDeepestRoom(rooms);
|
|
332
|
+
if (!room) {
|
|
333
|
+
throw new Error("No room found for this path. Run `tt join` first.");
|
|
334
|
+
}
|
|
335
|
+
const session = {
|
|
336
|
+
agent_id: identity.agent_id,
|
|
337
|
+
room_id: room.room_id,
|
|
338
|
+
canonical_path: room.canonical_path,
|
|
339
|
+
workspace_root: resolvedPath.workspace_root,
|
|
340
|
+
updated_at: new Date().toISOString()
|
|
341
|
+
};
|
|
342
|
+
upsertCliSession(sessionPath, session);
|
|
343
|
+
return session;
|
|
344
|
+
}
|
|
345
|
+
function requireLeaseSession(identity, contextPath) {
|
|
346
|
+
const session = findCliSessionForContextPath(resolveCliSessionPath(), identity.agent_id, contextPath);
|
|
347
|
+
if (!session?.lease_id || session.turn_id === null || session.turn_id === undefined) {
|
|
348
|
+
throw new Error("No active lease for this path. Run `tt wait` or `tt takeover` first.");
|
|
349
|
+
}
|
|
350
|
+
return session;
|
|
351
|
+
}
|
|
352
|
+
function upsertSessionFromJoin(identity, joined) {
|
|
353
|
+
upsertCliSession(resolveCliSessionPath(), {
|
|
354
|
+
agent_id: identity.agent_id,
|
|
355
|
+
room_id: joined.room_id,
|
|
356
|
+
canonical_path: joined.canonical_path,
|
|
357
|
+
workspace_root: joined.workspace_root,
|
|
358
|
+
updated_at: new Date().toISOString()
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
function parseCommand(argv) {
|
|
362
|
+
const [name = "", ...rest] = argv;
|
|
363
|
+
const options = new Map();
|
|
364
|
+
const positionals = [];
|
|
365
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
366
|
+
const token = rest[index];
|
|
367
|
+
if (!token.startsWith("--")) {
|
|
368
|
+
positionals.push(token);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const key = token.slice(2);
|
|
372
|
+
const next = rest[index + 1];
|
|
373
|
+
if (!next || next.startsWith("--")) {
|
|
374
|
+
options.set(key, true);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
options.set(key, next);
|
|
378
|
+
index += 1;
|
|
379
|
+
}
|
|
380
|
+
return { name, positionals, options };
|
|
381
|
+
}
|
|
382
|
+
function hasOption(parsed, key) {
|
|
383
|
+
return parsed.options.has(key);
|
|
384
|
+
}
|
|
385
|
+
function getStringOption(parsed, key) {
|
|
386
|
+
const value = parsed.options.get(key);
|
|
387
|
+
return typeof value === "string" ? value : undefined;
|
|
388
|
+
}
|
|
389
|
+
function requireStringOption(parsed, key) {
|
|
390
|
+
const value = getStringOption(parsed, key);
|
|
391
|
+
if (!value) {
|
|
392
|
+
throw new Error(`Missing required option --${key}`);
|
|
393
|
+
}
|
|
394
|
+
return value;
|
|
395
|
+
}
|
|
396
|
+
function parseOptionalInteger(parsed, key) {
|
|
397
|
+
const value = getStringOption(parsed, key);
|
|
398
|
+
if (!value) {
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
const parsedValue = Number.parseInt(value, 10);
|
|
402
|
+
if (!Number.isInteger(parsedValue)) {
|
|
403
|
+
throw new Error(`--${key} must be an integer.`);
|
|
404
|
+
}
|
|
405
|
+
return parsedValue;
|
|
406
|
+
}
|
|
407
|
+
function parseRequiredInteger(parsed, key) {
|
|
408
|
+
const value = parseOptionalInteger(parsed, key);
|
|
409
|
+
if (value === undefined) {
|
|
410
|
+
throw new Error(`Missing required option --${key}`);
|
|
411
|
+
}
|
|
412
|
+
return value;
|
|
413
|
+
}
|
|
414
|
+
function parseWaitTimeout(parsed) {
|
|
415
|
+
const value = getStringOption(parsed, "timeout");
|
|
416
|
+
if (!value) {
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
return parseDurationMs(value);
|
|
420
|
+
}
|
|
421
|
+
const DEFAULT_CLI_HANDOFF_STATUS = "(human handoff — no structured status provided)";
|
|
422
|
+
const DEFAULT_CLI_HANDOFF_NEXT_ACTION = "(no explicit guidance — proceed as previously established)";
|
|
423
|
+
function requireHandoff(parsed) {
|
|
424
|
+
return {
|
|
425
|
+
status: getStringOption(parsed, "status") ?? DEFAULT_CLI_HANDOFF_STATUS,
|
|
426
|
+
next_action: getStringOption(parsed, "next-action") ?? DEFAULT_CLI_HANDOFF_NEXT_ACTION
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
function parseDurationMs(value) {
|
|
430
|
+
if (/^\d+$/.test(value)) {
|
|
431
|
+
return Number.parseInt(value, 10) * 1000;
|
|
432
|
+
}
|
|
433
|
+
const match = value.match(/^(\d+)(ms|s|m|h)$/);
|
|
434
|
+
if (!match) {
|
|
435
|
+
throw new Error("Timeout values must be bare seconds or use ms/s/m/h suffixes.");
|
|
436
|
+
}
|
|
437
|
+
const amount = Number.parseInt(match[1], 10);
|
|
438
|
+
const unit = match[2];
|
|
439
|
+
switch (unit) {
|
|
440
|
+
case "ms":
|
|
441
|
+
return amount;
|
|
442
|
+
case "s":
|
|
443
|
+
return amount * 1000;
|
|
444
|
+
case "m":
|
|
445
|
+
return amount * 60 * 1000;
|
|
446
|
+
case "h":
|
|
447
|
+
return amount * 60 * 60 * 1000;
|
|
448
|
+
default:
|
|
449
|
+
throw new Error(`Unsupported duration unit: ${unit}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
function pickDeepestRoom(rooms) {
|
|
453
|
+
if (rooms.length === 0) {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
return rooms
|
|
457
|
+
.slice()
|
|
458
|
+
.sort((left, right) => right.canonical_path.length - left.canonical_path.length)[0];
|
|
459
|
+
}
|
|
460
|
+
async function spawnGuardian(input) {
|
|
461
|
+
const self = resolveSelfSpawn();
|
|
462
|
+
const child = spawn(self.command, [
|
|
463
|
+
...self.args,
|
|
464
|
+
"guard",
|
|
465
|
+
"--agent",
|
|
466
|
+
input.agentId,
|
|
467
|
+
"--context-path",
|
|
468
|
+
input.canonicalPath,
|
|
469
|
+
"--room-id",
|
|
470
|
+
input.roomId,
|
|
471
|
+
"--lease-id",
|
|
472
|
+
input.leaseId,
|
|
473
|
+
"--turn-id",
|
|
474
|
+
String(input.turnId)
|
|
475
|
+
], {
|
|
476
|
+
detached: true,
|
|
477
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
478
|
+
env: process.env
|
|
479
|
+
});
|
|
480
|
+
return await new Promise((resolve, reject) => {
|
|
481
|
+
const inspector = createSystemProcessInspector();
|
|
482
|
+
let stdout = "";
|
|
483
|
+
let stderr = "";
|
|
484
|
+
const timeout = setTimeout(() => {
|
|
485
|
+
reject(new Error("Guardian did not signal readiness in time."));
|
|
486
|
+
}, 3_000);
|
|
487
|
+
child.stdout?.setEncoding("utf8");
|
|
488
|
+
child.stderr?.setEncoding("utf8");
|
|
489
|
+
child.stdout?.on("data", (chunk) => {
|
|
490
|
+
stdout += chunk;
|
|
491
|
+
if (!stdout.includes(GUARD_READY)) {
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
clearTimeout(timeout);
|
|
495
|
+
child.stdout?.destroy();
|
|
496
|
+
child.stderr?.destroy();
|
|
497
|
+
child.unref();
|
|
498
|
+
if (!child.pid) {
|
|
499
|
+
reject(new Error("Guardian started without a PID."));
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
resolve({
|
|
503
|
+
pid: child.pid,
|
|
504
|
+
process_started_at: inspector.inspect(child.pid)?.startTime ?? null
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
child.stderr?.on("data", (chunk) => {
|
|
508
|
+
stderr += chunk;
|
|
509
|
+
});
|
|
510
|
+
child.on("exit", (code) => {
|
|
511
|
+
clearTimeout(timeout);
|
|
512
|
+
reject(new Error(`Guardian exited before readiness (code ${code ?? "unknown"}): ${stderr.trim()}`));
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
function resolveSelfSpawn() {
|
|
517
|
+
const scriptPath = fileURLToPath(import.meta.url);
|
|
518
|
+
if (scriptPath.endsWith(".ts")) {
|
|
519
|
+
const tsxBin = path.join(process.cwd(), "node_modules", ".bin", "tsx");
|
|
520
|
+
if (fs.existsSync(tsxBin)) {
|
|
521
|
+
return { command: tsxBin, args: [scriptPath] };
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return { command: process.execPath, args: [scriptPath] };
|
|
525
|
+
}
|
|
526
|
+
function stopGuardian(guardianPid, guardianProcessStartedAt) {
|
|
527
|
+
if (!guardianPid) {
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
terminateKnownProcess({
|
|
531
|
+
pid: guardianPid,
|
|
532
|
+
process_started_at: guardianProcessStartedAt ?? null
|
|
533
|
+
}, {
|
|
534
|
+
inspector: createSystemProcessInspector()
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
function formatWaitResult(result) {
|
|
538
|
+
switch (result.status) {
|
|
539
|
+
case "not_yet":
|
|
540
|
+
return "Not your turn yet.";
|
|
541
|
+
case "closed":
|
|
542
|
+
return "The room is closed.";
|
|
543
|
+
case "takeover_available":
|
|
544
|
+
return `Takeover available: ${result.reason ?? "unknown"}.`;
|
|
545
|
+
case "your_turn":
|
|
546
|
+
return "Your turn.";
|
|
547
|
+
default:
|
|
548
|
+
return result.status;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
function printResult(parsed, result, renderText) {
|
|
552
|
+
if (hasOption(parsed, "json")) {
|
|
553
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
process.stdout.write(`${renderText()}\n`);
|
|
557
|
+
}
|
|
558
|
+
async function runInstallCommand(parsed) {
|
|
559
|
+
normalizeBooleanFlag(parsed, "print");
|
|
560
|
+
const harnesses = selectHarnesses(parsed);
|
|
561
|
+
const dryRun = hasOption(parsed, "print");
|
|
562
|
+
const actions = harnesses.map((harness) => planInstall(harness));
|
|
563
|
+
if (dryRun) {
|
|
564
|
+
for (const action of actions) {
|
|
565
|
+
printActionPlan(action);
|
|
566
|
+
}
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
const results = await Promise.all(actions.map((action) => runAction(action)));
|
|
570
|
+
reportInstallResults(results, "install");
|
|
571
|
+
}
|
|
572
|
+
async function runUninstallCommand(parsed) {
|
|
573
|
+
normalizeBooleanFlag(parsed, "print");
|
|
574
|
+
const harnesses = selectHarnesses(parsed);
|
|
575
|
+
const dryRun = hasOption(parsed, "print");
|
|
576
|
+
const actions = harnesses.map((harness) => planUninstall(harness));
|
|
577
|
+
if (dryRun) {
|
|
578
|
+
for (const action of actions) {
|
|
579
|
+
printActionPlan(action);
|
|
580
|
+
}
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const results = await Promise.all(actions.map((action) => runAction(action)));
|
|
584
|
+
reportInstallResults(results, "uninstall");
|
|
585
|
+
}
|
|
586
|
+
async function runInstallSkillCommand(parsed) {
|
|
587
|
+
normalizeBooleanFlag(parsed, "print");
|
|
588
|
+
normalizeBooleanFlag(parsed, "copy");
|
|
589
|
+
normalizeBooleanFlag(parsed, "link");
|
|
590
|
+
const harnesses = selectHarnesses(parsed);
|
|
591
|
+
const dryRun = hasOption(parsed, "print");
|
|
592
|
+
const link = resolveSkillInstallLinkMode(parsed);
|
|
593
|
+
const actions = harnesses.map((harness) => planSkillInstall(harness, { link }));
|
|
594
|
+
if (dryRun) {
|
|
595
|
+
for (const action of actions) {
|
|
596
|
+
printActionPlan(action);
|
|
597
|
+
}
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
const results = await Promise.all(actions.map((action) => runAction(action)));
|
|
601
|
+
reportInstallResults(results, "install");
|
|
602
|
+
}
|
|
603
|
+
async function runUninstallSkillCommand(parsed) {
|
|
604
|
+
normalizeBooleanFlag(parsed, "print");
|
|
605
|
+
const harnesses = selectHarnesses(parsed);
|
|
606
|
+
const dryRun = hasOption(parsed, "print");
|
|
607
|
+
const actions = harnesses.map((harness) => planSkillUninstall(harness));
|
|
608
|
+
if (dryRun) {
|
|
609
|
+
for (const action of actions) {
|
|
610
|
+
printActionPlan(action);
|
|
611
|
+
}
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const results = await Promise.all(actions.map((action) => runAction(action)));
|
|
615
|
+
reportInstallResults(results, "uninstall");
|
|
616
|
+
}
|
|
617
|
+
function normalizeBooleanFlag(parsed, key) {
|
|
618
|
+
const value = parsed.options.get(key);
|
|
619
|
+
if (typeof value === "string") {
|
|
620
|
+
parsed.positionals.unshift(value);
|
|
621
|
+
parsed.options.set(key, true);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
function resolveSkillInstallLinkMode(parsed) {
|
|
625
|
+
const wantsCopy = hasOption(parsed, "copy");
|
|
626
|
+
const wantsLink = hasOption(parsed, "link");
|
|
627
|
+
if (wantsCopy && wantsLink) {
|
|
628
|
+
throw new Error("Pass only one of --copy or --link.");
|
|
629
|
+
}
|
|
630
|
+
if (wantsCopy) {
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
return true;
|
|
634
|
+
}
|
|
635
|
+
function selectHarnesses(parsed) {
|
|
636
|
+
if (hasOption(parsed, "all")) {
|
|
637
|
+
const detected = SUPPORTED_HARNESSES.filter((harness) => detectHarness(harness).detected);
|
|
638
|
+
if (detected.length === 0) {
|
|
639
|
+
throw new Error(`No supported harnesses detected. Install one of: ${SUPPORTED_HARNESSES.join(", ")}, or pass harnesses explicitly.`);
|
|
640
|
+
}
|
|
641
|
+
return [...detected];
|
|
642
|
+
}
|
|
643
|
+
if (parsed.positionals.length === 0) {
|
|
644
|
+
throw new Error(`Specify at least one harness (${SUPPORTED_HARNESSES.join(", ")}) or pass --all to target every detected one.`);
|
|
645
|
+
}
|
|
646
|
+
return parseHarnessList(parsed.positionals);
|
|
647
|
+
}
|
|
648
|
+
function printActionPlan(action) {
|
|
649
|
+
if (action.kind === "exec") {
|
|
650
|
+
process.stdout.write(`[${action.harness}] ${action.description}\n`);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
process.stdout.write(`[${action.harness}] ${action.description}\n`);
|
|
654
|
+
}
|
|
655
|
+
function reportInstallResults(results, mode) {
|
|
656
|
+
let anyFailed = false;
|
|
657
|
+
for (const result of results) {
|
|
658
|
+
const status = result.ok ? "ok" : "FAIL";
|
|
659
|
+
process.stdout.write(`[${result.harness}] ${status}: ${result.message}\n`);
|
|
660
|
+
if (!result.ok)
|
|
661
|
+
anyFailed = true;
|
|
662
|
+
}
|
|
663
|
+
if (anyFailed) {
|
|
664
|
+
throw new Error(`${mode} completed with failures.`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
function printHelp() {
|
|
668
|
+
process.stdout.write(`Usage: tt <command> [options]
|
|
669
|
+
|
|
670
|
+
Commands:
|
|
671
|
+
tt list [path]
|
|
672
|
+
tt join [path] [--force-new]
|
|
673
|
+
tt wait [path] [--timeout 30s]
|
|
674
|
+
tt try [path]
|
|
675
|
+
tt state [path]
|
|
676
|
+
tt events [path] [--after N] [--limit N]
|
|
677
|
+
tt release [path] --status TEXT --next-action TEXT
|
|
678
|
+
tt pass [target] [path] --status TEXT --next-action TEXT
|
|
679
|
+
tt takeover [path] --reason TEXT
|
|
680
|
+
tt mcp
|
|
681
|
+
tt install <harness...> | --all [--print]
|
|
682
|
+
tt uninstall <harness...> | --all [--print]
|
|
683
|
+
tt install-skill <harness...> | --all [--print] [--copy] [--link]
|
|
684
|
+
tt uninstall-skill <harness...> | --all [--print]
|
|
685
|
+
|
|
686
|
+
Harnesses: ${SUPPORTED_HARNESSES.join(", ")}
|
|
687
|
+
|
|
688
|
+
Common options:
|
|
689
|
+
--agent ID Override the default human identity
|
|
690
|
+
--json Print JSON instead of text
|
|
691
|
+
`);
|
|
692
|
+
}
|
|
693
|
+
await runCli().catch((error) => {
|
|
694
|
+
const message = isProtocolError(error)
|
|
695
|
+
? JSON.stringify(error.toJSON(), null, 2)
|
|
696
|
+
: error instanceof Error
|
|
697
|
+
? error.message
|
|
698
|
+
: String(error);
|
|
699
|
+
process.stderr.write(`${message}\n`);
|
|
700
|
+
process.exit(1);
|
|
701
|
+
});
|