talking-stick 0.1.0-alpha.3 → 0.1.0-alpha.5

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Talking Stick contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
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.3. Multi-process-safe (SQLite WAL), liveness-aware, no daemon. Supports Claude Code, Codex CLI, Gemini CLI, and OpenCode out of the box.
5
+ **Version:** 0.1.0-alpha.5. 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.3`. Requires Node ≥ 22. |
30
+ | **From npm** | `npm i -g talking-stick` | Published as `0.1.0-alpha.5`. 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
 
@@ -65,6 +65,7 @@ Once installed, each agent harness sees these tools:
65
65
  ```
66
66
  list_rooms — which rooms exist under a path
67
67
  join_path — join the room for this workspace
68
+ leave_room — explicitly leave a room; deletes it when no active members remain
68
69
  wait_for_turn — block until the stick is available, with takeover signals
69
70
  heartbeat — prove liveness while holding the stick
70
71
  release_stick — normal handoff to the next fair waiter, with structured Handoff
@@ -127,6 +128,7 @@ The same `tt` binary also works as a human CLI, useful for watching or participa
127
128
  tt whoami [--explain] # show the resolved CLI identity
128
129
  tt list [path] # list rooms
129
130
  tt join [path] [--force-new] # join the room for path
131
+ tt leave [path] # leave the room for path
130
132
  tt wait [path] [--timeout 30s] # block until your turn
131
133
  tt try [path] # non-blocking claim attempt
132
134
  tt state [path] # full room state
@@ -152,14 +154,15 @@ Human CLI commands use a stable identity like `human:<username>`. When `tt wait`
152
154
 
153
155
  ### CLI identity
154
156
 
155
- By default, `tt` behaves like a human CLI and resolves to `human:<username>`, even when you run it from a shell embedded inside Claude Code, Codex, Gemini, or OpenCode.
157
+ By default, `tt` behaves like a human CLI and resolves to `human:<username>` only when no harness environment is detected.
156
158
 
157
- Harness-aware CLI identity is now explicit:
159
+ Harness-aware CLI identity is resolved before the human fallback:
158
160
 
159
- - Set `TT_HARNESS_EXPORT=1` if you want `tt` to derive a harness-style identity from the current environment and process ancestry.
161
+ - Known harness environment markers such as `CLAUDECODE=1`, `CODEX_THREAD_ID`, `GEMINI_CLI=1`, or `OPENCODE=1` make `tt` derive a harness-style identity automatically.
160
162
  - Set `TT_HARNESS_AGENT_ID=<agent-id>` if the harness wants to export the exact agent id directly.
163
+ - Set `TT_HARNESS_EXPORT=1` only when you need ancestry-based harness detection without a known harness environment marker.
161
164
 
162
- If neither variable is set, `tt` stays on the human CLI path. That keeps ordinary shell usage predictable and avoids silently turning a human terminal into a harness participant.
165
+ If no harness signal is present, `tt` stays on the human CLI path. That keeps ordinary shell usage predictable while preventing harness-launched shells from silently joining rooms as `human:<username>`.
163
166
 
164
167
  Use `tt whoami --explain` to see which identity path the CLI chose.
165
168
 
@@ -168,6 +171,8 @@ Use `tt whoami --explain` to see which identity path the CLI chose.
168
171
  - **Workspace-root room resolution.** An agent at any depth under `/repo/` joins the `/repo/` room automatically. Nested rooms require explicit `force_new`.
169
172
  - **Structured handoffs.** `release_stick` and `pass_stick` carry a typed `Handoff` with required `status` / `next_action` and optional `artifacts[]` pointing at specific files and line ranges.
170
173
  - **Fair handoff selection.** Normal release prefers a recent waiter that is new or has gone longest without holding the stick; if the best-known candidate is between wait polls, a short grace window prevents immediate recycling to a less-fair claimant.
174
+ - **No immediate take-backs.** If release leaves a handoff idle, the prior owner waits through the short grace window before reclaiming while another member exists.
175
+ - **Ephemeral rooms.** `leave_room`/`tt leave` removes membership, rooms with no active members are physically deleted, and long-idle rooms are purged opportunistically on later invocations.
171
176
  - **Fencing tokens.** `lease_id` + `turn_id` make stale writes impossible — an agent who lost their turn cannot commit anything under the room's name.
172
177
  - **Liveness-aware recovery.** Dead or crashed holders are detected with OS-level process checks; claim-timeout takeover skips the prior owner when another active member is waiting.
173
178
  - **Multi-process safe.** Shared SQLite with WAL mode, `BEGIN IMMEDIATE` writes, 250 ms polling for `wait_for_turn`. No daemon required.
@@ -204,4 +209,4 @@ See [`CHANGELOG.md`](CHANGELOG.md) for a per-version summary; full release notes
204
209
 
205
210
  ## License
206
211
 
207
- Unlicensed WIP. To be decided before the first release.
212
+ MIT. See [LICENSE](LICENSE).
@@ -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,77 @@
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
+ if (env.TT_HARNESS_EXPORT === "1" || env.TT_HARNESS_EXPORT?.toLowerCase() === "true") {
29
+ return {
30
+ identity: harnessIdentity,
31
+ source: "harness_cli_exported_detection",
32
+ detail: "Resolved as harness CLI because TT_HARNESS_EXPORT enabled harness-aware detection."
33
+ };
34
+ }
35
+ return {
36
+ identity: harnessIdentity,
37
+ source: "harness_cli_env_detection",
38
+ detail: "Resolved as harness CLI from known harness environment variables."
39
+ };
40
+ }
41
+ if (env.TT_HARNESS_EXPORT?.trim()) {
42
+ return {
43
+ identity: deriveHumanCliIdentity(),
44
+ source: "human_cli_default",
45
+ detail: "TT_HARNESS_EXPORT was set, but no harness signal matched; defaulted to human CLI identity."
46
+ };
47
+ }
48
+ return {
49
+ identity: deriveHumanCliIdentity(),
50
+ source: "human_cli_default",
51
+ detail: "Defaulted to stable human CLI identity."
52
+ };
53
+ }
54
+ export function resolveTakeoverReason(parsed, env = process.env) {
55
+ const explicitReason = getStringOption(parsed, "reason");
56
+ if (explicitReason) {
57
+ return explicitReason;
58
+ }
59
+ if (hasOption(parsed, "operator-requested")) {
60
+ return "operator requested takeover";
61
+ }
62
+ if (isKnownHarnessCliEnv(env)) {
63
+ throw new Error("Missing required option --reason. Harness CLI takeovers must explain why, unless --operator-requested is set.");
64
+ }
65
+ return "operator takeover";
66
+ }
67
+ export function shouldUseOperatorOverride(parsed, env = process.env) {
68
+ return (!isKnownHarnessCliEnv(env) ||
69
+ hasOption(parsed, "operator-requested") ||
70
+ hasOption(parsed, "force"));
71
+ }
72
+ export function isKnownHarnessCliEnv(env = process.env) {
73
+ if (env.TT_HARNESS_AGENT_ID?.trim()) {
74
+ return true;
75
+ }
76
+ return deriveHarnessCliIdentity({ env }) !== null;
77
+ }
@@ -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
+ }