talking-stick 0.1.0-alpha.3 → 0.1.0-alpha.4
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 +2 -2
- package/dist/cli/guardian.js +151 -0
- package/dist/cli/handoff.js +45 -0
- package/dist/cli/identity.js +70 -0
- package/dist/cli/install-commands.js +156 -0
- package/dist/cli/notes-commands.js +70 -0
- package/dist/cli/output.js +147 -0
- package/dist/cli/parser.js +90 -0
- package/dist/cli/registry.js +189 -0
- package/dist/cli/room-commands.js +124 -0
- package/dist/cli/runtime.js +8 -0
- package/dist/cli/session.js +73 -0
- package/dist/cli/startup-maintenance.js +24 -0
- package/dist/cli/turn-commands.js +269 -0
- package/dist/cli.js +17 -1271
- package/docs/releases/0.1.0-alpha.4.md +34 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
An MCP coordination server that lets multiple AI coding agents share a single workspace without stepping on each other. One agent holds the stick at a time; handoffs carry structured context so the next agent doesn't have to re-derive it.
|
|
4
4
|
|
|
5
|
-
**Version:** 0.1.0-alpha.
|
|
5
|
+
**Version:** 0.1.0-alpha.4. Multi-process-safe (SQLite WAL), liveness-aware, no daemon. Supports Claude Code, Codex CLI, Gemini CLI, and OpenCode out of the box.
|
|
6
6
|
|
|
7
7
|
## Quickstart
|
|
8
8
|
|
|
@@ -27,7 +27,7 @@ That's it. The next time two agents `cd` into the same repo, they see each other
|
|
|
27
27
|
|
|
28
28
|
| Method | Command | Notes |
|
|
29
29
|
|---|---|---|
|
|
30
|
-
| **From npm** | `npm i -g talking-stick` | Published as `0.1.0-alpha.
|
|
30
|
+
| **From npm** | `npm i -g talking-stick` | Published as `0.1.0-alpha.4`. Requires Node ≥ 22. |
|
|
31
31
|
| **From GitHub** | `npm i -g github:mostlydev/talking-stick` | Tracks the `master` branch; builds on install via the `prepare` hook. |
|
|
32
32
|
| **From source** | `git clone … && npm install && npm link` | For contributors. |
|
|
33
33
|
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { createSystemProcessInspector, deriveHumanCliIdentity, isProtocolError, terminateKnownProcess } from "../index.js";
|
|
6
|
+
import { parseRequiredInteger, requireStringOption } from "./parser.js";
|
|
7
|
+
import { createRuntime } from "./runtime.js";
|
|
8
|
+
const GUARD_READY = "READY";
|
|
9
|
+
const GUARD_READY_TIMEOUT_MS = 10_000;
|
|
10
|
+
const STALE_GUARD_ERRORS = new Set(["stale_lease", "turn_mismatch", "room_not_found"]);
|
|
11
|
+
export async function runGuardCommand(parsed) {
|
|
12
|
+
const identity = deriveHumanCliIdentity({
|
|
13
|
+
agentId: requireStringOption(parsed, "agent"),
|
|
14
|
+
displayName: requireStringOption(parsed, "agent").replace(/^human:/, ""),
|
|
15
|
+
sessionKind: "human_guardian"
|
|
16
|
+
});
|
|
17
|
+
const runtime = createRuntime();
|
|
18
|
+
try {
|
|
19
|
+
const joined = runtime.commands.joinPath(identity, {
|
|
20
|
+
context_path: requireStringOption(parsed, "context-path")
|
|
21
|
+
});
|
|
22
|
+
const heartbeatInput = {
|
|
23
|
+
room_id: requireStringOption(parsed, "room-id"),
|
|
24
|
+
lease_id: requireStringOption(parsed, "lease-id"),
|
|
25
|
+
expected_turn_id: parseRequiredInteger(parsed, "turn-id")
|
|
26
|
+
};
|
|
27
|
+
const intervalMs = joined.policy.heartbeatIntervalMs;
|
|
28
|
+
process.stdout.write(`${GUARD_READY}\n`);
|
|
29
|
+
const timer = setInterval(() => {
|
|
30
|
+
try {
|
|
31
|
+
runtime.commands.heartbeat(identity, heartbeatInput);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (isProtocolError(error) && STALE_GUARD_ERRORS.has(error.code)) {
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
}, intervalMs);
|
|
40
|
+
const exit = () => {
|
|
41
|
+
clearInterval(timer);
|
|
42
|
+
process.exit(0);
|
|
43
|
+
};
|
|
44
|
+
process.on("SIGINT", exit);
|
|
45
|
+
process.on("SIGTERM", exit);
|
|
46
|
+
await new Promise(() => undefined);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
runtime.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export async function spawnGuardian(input) {
|
|
53
|
+
const self = resolveSelfSpawn(input.cliEntryUrl);
|
|
54
|
+
const child = spawn(self.command, [
|
|
55
|
+
...self.args,
|
|
56
|
+
"guard",
|
|
57
|
+
"--agent",
|
|
58
|
+
input.agentId,
|
|
59
|
+
"--context-path",
|
|
60
|
+
input.canonicalPath,
|
|
61
|
+
"--room-id",
|
|
62
|
+
input.roomId,
|
|
63
|
+
"--lease-id",
|
|
64
|
+
input.leaseId,
|
|
65
|
+
"--turn-id",
|
|
66
|
+
String(input.turnId)
|
|
67
|
+
], {
|
|
68
|
+
detached: true,
|
|
69
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
70
|
+
env: process.env
|
|
71
|
+
});
|
|
72
|
+
return await new Promise((resolve, reject) => {
|
|
73
|
+
const inspector = createSystemProcessInspector();
|
|
74
|
+
let stdout = "";
|
|
75
|
+
let stderr = "";
|
|
76
|
+
const timeout = setTimeout(() => {
|
|
77
|
+
reject(new Error("Guardian did not signal readiness in time."));
|
|
78
|
+
}, GUARD_READY_TIMEOUT_MS);
|
|
79
|
+
child.stdout?.setEncoding("utf8");
|
|
80
|
+
child.stderr?.setEncoding("utf8");
|
|
81
|
+
child.stdout?.on("data", (chunk) => {
|
|
82
|
+
stdout += chunk;
|
|
83
|
+
if (!stdout.includes(GUARD_READY)) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
clearTimeout(timeout);
|
|
87
|
+
child.stdout?.destroy();
|
|
88
|
+
child.stderr?.destroy();
|
|
89
|
+
child.unref();
|
|
90
|
+
if (!child.pid) {
|
|
91
|
+
reject(new Error("Guardian started without a PID."));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
resolve({
|
|
95
|
+
pid: child.pid,
|
|
96
|
+
process_started_at: inspector.inspect(child.pid)?.startTime ?? null
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
child.stderr?.on("data", (chunk) => {
|
|
100
|
+
stderr += chunk;
|
|
101
|
+
});
|
|
102
|
+
child.on("exit", (code) => {
|
|
103
|
+
clearTimeout(timeout);
|
|
104
|
+
reject(new Error(`Guardian exited before readiness (code ${code ?? "unknown"}): ${stderr.trim()}`));
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
function resolveSelfSpawn(cliEntryUrl) {
|
|
109
|
+
const scriptPath = fileURLToPath(cliEntryUrl);
|
|
110
|
+
if (scriptPath.endsWith(".ts")) {
|
|
111
|
+
const tsxBin = path.join(process.cwd(), "node_modules", ".bin", "tsx");
|
|
112
|
+
if (fs.existsSync(tsxBin)) {
|
|
113
|
+
return { command: tsxBin, args: [scriptPath] };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { command: process.execPath, args: [scriptPath] };
|
|
117
|
+
}
|
|
118
|
+
export function stopGuardian(guardianPid, guardianProcessStartedAt) {
|
|
119
|
+
if (!guardianPid) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
terminateKnownProcess({
|
|
123
|
+
pid: guardianPid,
|
|
124
|
+
process_started_at: guardianProcessStartedAt ?? null
|
|
125
|
+
}, {
|
|
126
|
+
inspector: createSystemProcessInspector()
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
export function checkGuardianLiveness(ref, inspector, platform = process.platform) {
|
|
130
|
+
if (ref.pid === null ||
|
|
131
|
+
ref.pid === undefined ||
|
|
132
|
+
!ref.process_started_at ||
|
|
133
|
+
ref.process_started_at.trim() === "") {
|
|
134
|
+
return "unknown";
|
|
135
|
+
}
|
|
136
|
+
if (platform === "win32") {
|
|
137
|
+
return "unknown";
|
|
138
|
+
}
|
|
139
|
+
const inspection = inspector.inspect(ref.pid);
|
|
140
|
+
if (inspection === undefined) {
|
|
141
|
+
return "unknown";
|
|
142
|
+
}
|
|
143
|
+
if (inspection === null || !inspection.startTime) {
|
|
144
|
+
return "gone";
|
|
145
|
+
}
|
|
146
|
+
// Trim-normalized match mirrors the service-layer liveness checker: a live
|
|
147
|
+
// pid with startTime drift is more likely the original process than a reuse.
|
|
148
|
+
return inspection.startTime.trim() === ref.process_started_at.trim()
|
|
149
|
+
? "alive"
|
|
150
|
+
: "unknown";
|
|
151
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { getStringOption, hasOption } from "./parser.js";
|
|
2
|
+
const DEFAULT_CLI_HANDOFF_STATUS = "(human handoff — no structured status provided)";
|
|
3
|
+
const DEFAULT_CLI_HANDOFF_NEXT_ACTION = "(no explicit guidance — proceed as previously established)";
|
|
4
|
+
export async function resolveHandoff(parsed) {
|
|
5
|
+
if (hasOption(parsed, "stdin")) {
|
|
6
|
+
const raw = (await readAllStdin()).trim();
|
|
7
|
+
if (!raw) {
|
|
8
|
+
throw new Error("--stdin specified but no input received. Pipe a JSON Handoff or omit --stdin.");
|
|
9
|
+
}
|
|
10
|
+
let value;
|
|
11
|
+
try {
|
|
12
|
+
value = JSON.parse(raw);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
throw new Error(`Invalid JSON on stdin: ${error.message}`);
|
|
16
|
+
}
|
|
17
|
+
return parseHandoffJson(value);
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
status: getStringOption(parsed, "status") ?? DEFAULT_CLI_HANDOFF_STATUS,
|
|
21
|
+
next_action: getStringOption(parsed, "next-action") ?? DEFAULT_CLI_HANDOFF_NEXT_ACTION
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function parseHandoffJson(value) {
|
|
25
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
26
|
+
throw new Error("Handoff JSON must be an object.");
|
|
27
|
+
}
|
|
28
|
+
const obj = value;
|
|
29
|
+
if (typeof obj.status !== "string" || obj.status.trim() === "") {
|
|
30
|
+
throw new Error("Handoff JSON requires a non-empty `status` string.");
|
|
31
|
+
}
|
|
32
|
+
if (typeof obj.next_action !== "string" || obj.next_action.trim() === "") {
|
|
33
|
+
throw new Error("Handoff JSON requires a non-empty `next_action` string.");
|
|
34
|
+
}
|
|
35
|
+
// Pass optional fields through; the service layer's validateHandoff does
|
|
36
|
+
// final structural validation on artifacts/open_questions/do_not.
|
|
37
|
+
return obj;
|
|
38
|
+
}
|
|
39
|
+
export async function readAllStdin() {
|
|
40
|
+
const chunks = [];
|
|
41
|
+
for await (const chunk of process.stdin) {
|
|
42
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
43
|
+
}
|
|
44
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
45
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { deriveHarnessCliIdentity, deriveHumanCliIdentity } from "../index.js";
|
|
2
|
+
import { getStringOption, hasOption } from "./parser.js";
|
|
3
|
+
export function deriveCliIdentity(parsed) {
|
|
4
|
+
return resolveCliIdentity(parsed).identity;
|
|
5
|
+
}
|
|
6
|
+
export function resolveCliIdentity(parsed, env = process.env) {
|
|
7
|
+
const agentIdOption = getStringOption(parsed, "agent");
|
|
8
|
+
if (agentIdOption) {
|
|
9
|
+
const displayName = agentIdOption.replace(/^[^:]+:/, "");
|
|
10
|
+
return {
|
|
11
|
+
identity: deriveHumanCliIdentity({
|
|
12
|
+
agentId: agentIdOption,
|
|
13
|
+
displayName
|
|
14
|
+
}),
|
|
15
|
+
source: "agent_override",
|
|
16
|
+
detail: "Resolved from explicit --agent override."
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const harnessIdentity = deriveHarnessCliIdentity({ env });
|
|
20
|
+
if (harnessIdentity) {
|
|
21
|
+
if (env.TT_HARNESS_AGENT_ID?.trim()) {
|
|
22
|
+
return {
|
|
23
|
+
identity: harnessIdentity,
|
|
24
|
+
source: "harness_cli_exported_agent_id",
|
|
25
|
+
detail: "Resolved from explicit TT_HARNESS_AGENT_ID export."
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
identity: harnessIdentity,
|
|
30
|
+
source: "harness_cli_exported_detection",
|
|
31
|
+
detail: "Resolved as harness CLI because TT_HARNESS_EXPORT enabled harness-aware detection."
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (env.TT_HARNESS_EXPORT?.trim()) {
|
|
35
|
+
return {
|
|
36
|
+
identity: deriveHumanCliIdentity(),
|
|
37
|
+
source: "human_cli_default",
|
|
38
|
+
detail: "TT_HARNESS_EXPORT was set, but no harness signal matched; defaulted to human CLI identity."
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
identity: deriveHumanCliIdentity(),
|
|
43
|
+
source: "human_cli_default",
|
|
44
|
+
detail: "Defaulted to stable human CLI identity."
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function resolveTakeoverReason(parsed, env = process.env) {
|
|
48
|
+
const explicitReason = getStringOption(parsed, "reason");
|
|
49
|
+
if (explicitReason) {
|
|
50
|
+
return explicitReason;
|
|
51
|
+
}
|
|
52
|
+
if (hasOption(parsed, "operator-requested")) {
|
|
53
|
+
return "operator requested takeover";
|
|
54
|
+
}
|
|
55
|
+
if (isKnownHarnessCliEnv(env)) {
|
|
56
|
+
throw new Error("Missing required option --reason. Harness CLI takeovers must explain why, unless --operator-requested is set.");
|
|
57
|
+
}
|
|
58
|
+
return "operator takeover";
|
|
59
|
+
}
|
|
60
|
+
export function shouldUseOperatorOverride(parsed, env = process.env) {
|
|
61
|
+
return (!isKnownHarnessCliEnv(env) ||
|
|
62
|
+
hasOption(parsed, "operator-requested") ||
|
|
63
|
+
hasOption(parsed, "force"));
|
|
64
|
+
}
|
|
65
|
+
export function isKnownHarnessCliEnv(env = process.env) {
|
|
66
|
+
if (env.TT_HARNESS_AGENT_ID?.trim()) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
return deriveHarnessCliIdentity({ env }) !== null;
|
|
70
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { SUPPORTED_HARNESSES, detectHarness, parseHarnessList, planInstall, planUninstall, runAction } from "../install.js";
|
|
3
|
+
import { planSkillInstall, planSkillUninstall } from "../skill-install.js";
|
|
4
|
+
import { detectInstallSource, isPackageManager, planSelfUpdate, resolveCurrentBinaryPath } from "../self-update.js";
|
|
5
|
+
import { getStringOption, hasOption, normalizeBooleanFlag } from "./parser.js";
|
|
6
|
+
export async function runInstallCommand(parsed) {
|
|
7
|
+
normalizeBooleanFlag(parsed, "print");
|
|
8
|
+
const harnesses = selectHarnesses(parsed);
|
|
9
|
+
const dryRun = hasOption(parsed, "print");
|
|
10
|
+
const installOptions = { skipMissing: true };
|
|
11
|
+
const actions = harnesses.map((harness) => planInstall(harness, installOptions));
|
|
12
|
+
if (dryRun) {
|
|
13
|
+
for (const action of actions) {
|
|
14
|
+
printActionPlan(action);
|
|
15
|
+
}
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const results = await Promise.all(actions.map((action) => runAction(action, installOptions)));
|
|
19
|
+
reportInstallResults(results, "install");
|
|
20
|
+
}
|
|
21
|
+
export async function runUninstallCommand(parsed) {
|
|
22
|
+
normalizeBooleanFlag(parsed, "print");
|
|
23
|
+
const harnesses = selectHarnesses(parsed);
|
|
24
|
+
const dryRun = hasOption(parsed, "print");
|
|
25
|
+
const installOptions = { skipMissing: true };
|
|
26
|
+
const actions = harnesses.map((harness) => planUninstall(harness, installOptions));
|
|
27
|
+
if (dryRun) {
|
|
28
|
+
for (const action of actions) {
|
|
29
|
+
printActionPlan(action);
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const results = await Promise.all(actions.map((action) => runAction(action, installOptions)));
|
|
34
|
+
reportInstallResults(results, "uninstall");
|
|
35
|
+
}
|
|
36
|
+
export async function runInstallSkillCommand(parsed) {
|
|
37
|
+
normalizeBooleanFlag(parsed, "print");
|
|
38
|
+
normalizeBooleanFlag(parsed, "copy");
|
|
39
|
+
normalizeBooleanFlag(parsed, "link");
|
|
40
|
+
const harnesses = selectHarnesses(parsed);
|
|
41
|
+
const dryRun = hasOption(parsed, "print");
|
|
42
|
+
const link = resolveSkillInstallLinkMode(parsed);
|
|
43
|
+
const installOptions = { link, skipMissing: true };
|
|
44
|
+
const actions = harnesses.map((harness) => planSkillInstall(harness, installOptions));
|
|
45
|
+
if (dryRun) {
|
|
46
|
+
for (const action of actions) {
|
|
47
|
+
printActionPlan(action);
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const results = await Promise.all(actions.map((action) => runAction(action, installOptions)));
|
|
52
|
+
reportInstallResults(results, "install");
|
|
53
|
+
}
|
|
54
|
+
export async function runUninstallSkillCommand(parsed) {
|
|
55
|
+
normalizeBooleanFlag(parsed, "print");
|
|
56
|
+
const harnesses = selectHarnesses(parsed);
|
|
57
|
+
const dryRun = hasOption(parsed, "print");
|
|
58
|
+
const installOptions = { skipMissing: true };
|
|
59
|
+
const actions = harnesses.map((harness) => planSkillUninstall(harness, installOptions));
|
|
60
|
+
if (dryRun) {
|
|
61
|
+
for (const action of actions) {
|
|
62
|
+
printActionPlan(action);
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const results = await Promise.all(actions.map((action) => runAction(action, installOptions)));
|
|
67
|
+
reportInstallResults(results, "uninstall");
|
|
68
|
+
}
|
|
69
|
+
export async function runSelfUpdateCommand(parsed, cliEntryUrl) {
|
|
70
|
+
normalizeBooleanFlag(parsed, "print");
|
|
71
|
+
const dryRun = hasOption(parsed, "print");
|
|
72
|
+
const managerOverride = getStringOption(parsed, "manager");
|
|
73
|
+
let source;
|
|
74
|
+
if (managerOverride) {
|
|
75
|
+
if (!isPackageManager(managerOverride)) {
|
|
76
|
+
throw new Error(`--manager must be one of npm | pnpm | yarn | bun (got ${managerOverride}).`);
|
|
77
|
+
}
|
|
78
|
+
source = managerOverride;
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
const binaryPath = resolveCurrentBinaryPath(cliEntryUrl);
|
|
82
|
+
source = detectInstallSource({ binaryPath });
|
|
83
|
+
}
|
|
84
|
+
const plan = planSelfUpdate(source);
|
|
85
|
+
if (!plan) {
|
|
86
|
+
if (source === "dev") {
|
|
87
|
+
throw new Error("tt is running from a development checkout. Use `git pull && npm install && npm run build` instead of `tt self-update`, or pass `--manager npm|pnpm|yarn|bun` if this is wrong.");
|
|
88
|
+
}
|
|
89
|
+
throw new Error(`Could not determine how tt was installed. Pass --manager npm|pnpm|yarn|bun to override.`);
|
|
90
|
+
}
|
|
91
|
+
if (dryRun) {
|
|
92
|
+
process.stdout.write(`${plan.description}\n`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
process.stdout.write(`Updating via: ${plan.description}\n`);
|
|
96
|
+
await runInheritIo(plan.command, plan.args);
|
|
97
|
+
process.stdout.write("Done. Restart your harness MCP subprocess to pick up the new dist.\n");
|
|
98
|
+
}
|
|
99
|
+
function resolveSkillInstallLinkMode(parsed) {
|
|
100
|
+
const wantsCopy = hasOption(parsed, "copy");
|
|
101
|
+
const wantsLink = hasOption(parsed, "link");
|
|
102
|
+
if (wantsCopy && wantsLink) {
|
|
103
|
+
throw new Error("Pass only one of --copy or --link.");
|
|
104
|
+
}
|
|
105
|
+
if (wantsCopy) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
function selectHarnesses(parsed) {
|
|
111
|
+
if (hasOption(parsed, "all")) {
|
|
112
|
+
const detected = SUPPORTED_HARNESSES.filter((harness) => detectHarness(harness).detected);
|
|
113
|
+
return [...detected];
|
|
114
|
+
}
|
|
115
|
+
if (parsed.positionals.length === 0) {
|
|
116
|
+
throw new Error(`Specify at least one harness (${SUPPORTED_HARNESSES.join(", ")}) or pass --all to target every detected one.`);
|
|
117
|
+
}
|
|
118
|
+
return parseHarnessList(parsed.positionals);
|
|
119
|
+
}
|
|
120
|
+
function printActionPlan(action) {
|
|
121
|
+
if (action.kind === "skip") {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (action.kind === "exec") {
|
|
125
|
+
process.stdout.write(`[${action.harness}] ${action.description}\n`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
process.stdout.write(`[${action.harness}] ${action.description}\n`);
|
|
129
|
+
}
|
|
130
|
+
function runInheritIo(command, args) {
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const child = spawn(command, args, { stdio: "inherit", shell: false });
|
|
133
|
+
child.on("error", reject);
|
|
134
|
+
child.on("close", (code) => {
|
|
135
|
+
if (code === 0) {
|
|
136
|
+
resolve();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
reject(new Error(`${command} exited with code ${code}.`));
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
function reportInstallResults(results, mode) {
|
|
144
|
+
let anyFailed = false;
|
|
145
|
+
for (const result of results) {
|
|
146
|
+
if (result.skipped)
|
|
147
|
+
continue;
|
|
148
|
+
const status = result.ok ? "ok" : "FAIL";
|
|
149
|
+
process.stdout.write(`[${result.harness}] ${status}: ${result.message}\n`);
|
|
150
|
+
if (!result.ok)
|
|
151
|
+
anyFailed = true;
|
|
152
|
+
}
|
|
153
|
+
if (anyFailed) {
|
|
154
|
+
throw new Error(`${mode} completed with failures.`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { deriveCliIdentity } from "./identity.js";
|
|
2
|
+
import { readAllStdin } from "./handoff.js";
|
|
3
|
+
import { getStringOption, hasOption, parseOptionalInteger } from "./parser.js";
|
|
4
|
+
import { formatRelativeTime, printResult } from "./output.js";
|
|
5
|
+
import { resolveSessionForNotes } from "./session.js";
|
|
6
|
+
export async function handleNotesCommand(runtime, parsed) {
|
|
7
|
+
const [subcommand, ...rest] = parsed.positionals;
|
|
8
|
+
if (!subcommand) {
|
|
9
|
+
throw new Error("Usage: tt notes <add|list> [...]. See `tt --help` for details.");
|
|
10
|
+
}
|
|
11
|
+
const subParsed = {
|
|
12
|
+
name: `notes ${subcommand}`,
|
|
13
|
+
positionals: rest,
|
|
14
|
+
options: parsed.options
|
|
15
|
+
};
|
|
16
|
+
switch (subcommand) {
|
|
17
|
+
case "add":
|
|
18
|
+
await handleNotesAddCommand(runtime, subParsed);
|
|
19
|
+
return;
|
|
20
|
+
case "list":
|
|
21
|
+
handleNotesListCommand(runtime, subParsed);
|
|
22
|
+
return;
|
|
23
|
+
default:
|
|
24
|
+
throw new Error(`Unknown notes subcommand: ${subcommand}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function handleNotesAddCommand(runtime, parsed) {
|
|
28
|
+
const identity = deriveCliIdentity(parsed);
|
|
29
|
+
const session = resolveSessionForNotes(runtime, parsed, identity);
|
|
30
|
+
const positionalBody = parsed.positionals.join(" ").trim();
|
|
31
|
+
const body = positionalBody ||
|
|
32
|
+
(hasOption(parsed, "stdin") ? (await readAllStdin()).trim() : "");
|
|
33
|
+
if (!body) {
|
|
34
|
+
throw new Error("Note body is required (pass as a positional or use --stdin to read from stdin).");
|
|
35
|
+
}
|
|
36
|
+
const turnId = parseOptionalInteger(parsed, "turn");
|
|
37
|
+
const result = runtime.commands.addNote(identity, {
|
|
38
|
+
room_id: session.room_id,
|
|
39
|
+
body,
|
|
40
|
+
turn_id: turnId
|
|
41
|
+
});
|
|
42
|
+
printResult(parsed, result, () => `Added note ${shortNoteId(result.note_id)} (turn=${result.turn_id ?? "-"}).`);
|
|
43
|
+
}
|
|
44
|
+
function handleNotesListCommand(runtime, parsed) {
|
|
45
|
+
const identity = deriveCliIdentity(parsed);
|
|
46
|
+
const session = resolveSessionForNotes(runtime, parsed, identity);
|
|
47
|
+
const includeResolved = hasOption(parsed, "all");
|
|
48
|
+
const result = runtime.commands.listNotes(identity, {
|
|
49
|
+
room_id: session.room_id,
|
|
50
|
+
include_resolved: includeResolved,
|
|
51
|
+
after_note_id: getStringOption(parsed, "after"),
|
|
52
|
+
limit: parseOptionalInteger(parsed, "limit")
|
|
53
|
+
});
|
|
54
|
+
printResult(parsed, result, () => {
|
|
55
|
+
if (result.notes.length === 0) {
|
|
56
|
+
return "No notes.";
|
|
57
|
+
}
|
|
58
|
+
const header = `${result.notes.length} note${result.notes.length === 1 ? "" : "s"} in this room:`;
|
|
59
|
+
const lines = result.notes.map((note) => {
|
|
60
|
+
const scope = note.turn_id !== null ? `turn ${note.turn_id}` : "room-scoped";
|
|
61
|
+
const firstLine = note.body.split("\n")[0] ?? "";
|
|
62
|
+
const preview = firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
|
|
63
|
+
return `- ${shortNoteId(note.note_id)} ${note.author_agent_id} · ${formatRelativeTime(note.created_at)} · ${scope}\n ${preview}`;
|
|
64
|
+
});
|
|
65
|
+
return [header, ...lines].join("\n");
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function shortNoteId(noteId) {
|
|
69
|
+
return noteId.slice(0, 8);
|
|
70
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { SUPPORTED_HARNESSES } from "../install.js";
|
|
2
|
+
import { hasOption } from "./parser.js";
|
|
3
|
+
export function printResult(parsed, result, renderText) {
|
|
4
|
+
if (shouldUseJson(parsed)) {
|
|
5
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
process.stdout.write(`${renderText()}\n`);
|
|
9
|
+
}
|
|
10
|
+
export function shouldUseJson(parsed, env = process.env) {
|
|
11
|
+
if (hasOption(parsed, "json"))
|
|
12
|
+
return true;
|
|
13
|
+
if (hasOption(parsed, "text"))
|
|
14
|
+
return false;
|
|
15
|
+
// Auto-JSON when invoked from a harness, using the same opt-in gate as
|
|
16
|
+
// identity resolution.
|
|
17
|
+
const exportFlag = env.TT_HARNESS_EXPORT;
|
|
18
|
+
if (exportFlag === "1" || exportFlag?.toLowerCase() === "true")
|
|
19
|
+
return true;
|
|
20
|
+
if (env.TT_HARNESS_AGENT_ID?.trim())
|
|
21
|
+
return true;
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
export function formatRelativeTime(iso, now = new Date()) {
|
|
25
|
+
if (!iso)
|
|
26
|
+
return "—";
|
|
27
|
+
const target = Date.parse(iso);
|
|
28
|
+
if (Number.isNaN(target))
|
|
29
|
+
return iso;
|
|
30
|
+
const deltaMs = target - now.getTime();
|
|
31
|
+
const absMs = Math.abs(deltaMs);
|
|
32
|
+
const minute = 60_000;
|
|
33
|
+
const hour = 60 * minute;
|
|
34
|
+
const day = 24 * hour;
|
|
35
|
+
let value;
|
|
36
|
+
if (absMs < minute)
|
|
37
|
+
value = `${Math.max(1, Math.round(absMs / 1000))}s`;
|
|
38
|
+
else if (absMs < hour)
|
|
39
|
+
value = `${Math.round(absMs / minute)}m`;
|
|
40
|
+
else if (absMs < day)
|
|
41
|
+
value = `${Math.round(absMs / hour)}h`;
|
|
42
|
+
else
|
|
43
|
+
value = `${Math.round(absMs / day)}d`;
|
|
44
|
+
return deltaMs >= 0 ? `in ${value}` : `${value} ago`;
|
|
45
|
+
}
|
|
46
|
+
export function formatWaitResult(result) {
|
|
47
|
+
switch (result.status) {
|
|
48
|
+
case "not_yet": {
|
|
49
|
+
if (result.current_owner) {
|
|
50
|
+
const deadline = result.lease_expires_at
|
|
51
|
+
? ` (lease expires ${formatRelativeTime(result.lease_expires_at)})`
|
|
52
|
+
: "";
|
|
53
|
+
return `Not your turn — ${result.current_owner} holds turn ${result.turn_id ?? "?"}${deadline}.`;
|
|
54
|
+
}
|
|
55
|
+
if (result.reserved_for) {
|
|
56
|
+
const deadline = result.claim_expires_at
|
|
57
|
+
? ` (claim expires ${formatRelativeTime(result.claim_expires_at)})`
|
|
58
|
+
: "";
|
|
59
|
+
return `Not your turn — turn ${result.turn_id ?? "?"} is reserved for ${result.reserved_for}${deadline}.`;
|
|
60
|
+
}
|
|
61
|
+
return "Not your turn yet.";
|
|
62
|
+
}
|
|
63
|
+
case "closed":
|
|
64
|
+
return "The room is closed.";
|
|
65
|
+
case "takeover_available":
|
|
66
|
+
return `Takeover available: ${result.reason ?? "unknown"}.`;
|
|
67
|
+
case "your_turn": {
|
|
68
|
+
if (result.reason === "already_owner") {
|
|
69
|
+
return "Already holding the stick.";
|
|
70
|
+
}
|
|
71
|
+
const header = result.from_agent_id != null
|
|
72
|
+
? `Your turn (turn ${result.turn_id ?? "?"}, ${result.reason ?? "claim"} from ${result.from_agent_id}).`
|
|
73
|
+
: `Your turn (turn ${result.turn_id ?? "?"}, ${result.reason ?? "claim"}).`;
|
|
74
|
+
const handoffBlock = result.handoff ? formatHandoff(result.handoff) : "";
|
|
75
|
+
return handoffBlock ? `${header}\n\n${handoffBlock}` : header;
|
|
76
|
+
}
|
|
77
|
+
default:
|
|
78
|
+
return result.status;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function formatHandoff(handoff) {
|
|
82
|
+
const sections = [];
|
|
83
|
+
if (handoff.status?.trim()) {
|
|
84
|
+
sections.push(`Status:\n${indent(handoff.status.trim())}`);
|
|
85
|
+
}
|
|
86
|
+
if (handoff.next_action?.trim()) {
|
|
87
|
+
sections.push(`Next action:\n${indent(handoff.next_action.trim())}`);
|
|
88
|
+
}
|
|
89
|
+
if (handoff.artifacts && handoff.artifacts.length > 0) {
|
|
90
|
+
const lines = handoff.artifacts.map((artifact) => {
|
|
91
|
+
const range = artifact.lines
|
|
92
|
+
? `:${artifact.lines[0]}-${artifact.lines[1]}`
|
|
93
|
+
: "";
|
|
94
|
+
const note = artifact.note ? ` — ${artifact.note}` : "";
|
|
95
|
+
return `- ${artifact.path}${range} (${artifact.role})${note}`;
|
|
96
|
+
});
|
|
97
|
+
sections.push(`Artifacts:\n${lines.join("\n")}`);
|
|
98
|
+
}
|
|
99
|
+
if (handoff.open_questions && handoff.open_questions.length > 0) {
|
|
100
|
+
const lines = handoff.open_questions.map((q) => `- ${q}`);
|
|
101
|
+
sections.push(`Open questions:\n${lines.join("\n")}`);
|
|
102
|
+
}
|
|
103
|
+
if (handoff.do_not && handoff.do_not.length > 0) {
|
|
104
|
+
const lines = handoff.do_not.map((q) => `- ${q}`);
|
|
105
|
+
sections.push(`Do not:\n${lines.join("\n")}`);
|
|
106
|
+
}
|
|
107
|
+
return sections.join("\n\n");
|
|
108
|
+
}
|
|
109
|
+
export function indent(text, prefix = " ") {
|
|
110
|
+
return text
|
|
111
|
+
.split("\n")
|
|
112
|
+
.map((line) => (line.length > 0 ? `${prefix}${line}` : line))
|
|
113
|
+
.join("\n");
|
|
114
|
+
}
|
|
115
|
+
export function printHelp() {
|
|
116
|
+
process.stdout.write(`Usage: tt <command> [options]
|
|
117
|
+
|
|
118
|
+
Commands:
|
|
119
|
+
tt whoami [--explain]
|
|
120
|
+
tt list [path]
|
|
121
|
+
tt join [path] [--force-new]
|
|
122
|
+
tt wait [path] [--timeout 30s]
|
|
123
|
+
tt try [path]
|
|
124
|
+
tt state [path]
|
|
125
|
+
tt events [path] [--after N] [--limit N]
|
|
126
|
+
tt release [path] (--status TEXT --next-action TEXT | --stdin)
|
|
127
|
+
tt pass [path] (--status TEXT --next-action TEXT | --stdin)
|
|
128
|
+
tt assign <target|next> [path] (--status TEXT --next-action TEXT | --stdin)
|
|
129
|
+
tt take [path] [--reason TEXT] [--operator-requested]
|
|
130
|
+
tt takeover [path] [--reason TEXT] [--operator-requested]
|
|
131
|
+
tt notes add <body> [--turn N] [--path DIR] [--stdin]
|
|
132
|
+
tt notes list [--all] [--after NOTE_ID] [--limit N] [--path DIR]
|
|
133
|
+
tt mcp
|
|
134
|
+
tt install <harness...> | --all [--print]
|
|
135
|
+
tt uninstall <harness...> | --all [--print]
|
|
136
|
+
tt install-skill <harness...> | --all [--print] [--copy] [--link]
|
|
137
|
+
tt uninstall-skill <harness...> | --all [--print]
|
|
138
|
+
tt self-update [--print] [--manager npm|pnpm|yarn|bun]
|
|
139
|
+
|
|
140
|
+
Harnesses: ${SUPPORTED_HARNESSES.join(", ")}
|
|
141
|
+
|
|
142
|
+
Common options:
|
|
143
|
+
--agent ID Override the default human identity
|
|
144
|
+
--json Force JSON output (also default when invoked from a harness)
|
|
145
|
+
--text Force human-readable text even when invoked from a harness
|
|
146
|
+
`);
|
|
147
|
+
}
|