talking-stick 0.1.0-alpha.5 → 0.1.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 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. 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. 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.5`. Requires Node ≥ 22. |
30
+ | **From npm** | `npm i -g talking-stick` | Published as `0.1.0`. 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
 
@@ -143,9 +143,7 @@ function runInheritIo(command, args) {
143
143
  function reportInstallResults(results, mode) {
144
144
  let anyFailed = false;
145
145
  for (const result of results) {
146
- if (result.skipped)
147
- continue;
148
- const status = result.ok ? "ok" : "FAIL";
146
+ const status = formatInstallStatus(result.status);
149
147
  process.stdout.write(`[${result.harness}] ${status}: ${result.message}\n`);
150
148
  if (!result.ok)
151
149
  anyFailed = true;
@@ -154,3 +152,6 @@ function reportInstallResults(results, mode) {
154
152
  throw new Error(`${mode} completed with failures.`);
155
153
  }
156
154
  }
155
+ function formatInstallStatus(status) {
156
+ return status.replaceAll("_", "-");
157
+ }
package/dist/install.js CHANGED
@@ -130,7 +130,10 @@ export function planInstall(harness, options = {}) {
130
130
  harness,
131
131
  command: "claude",
132
132
  args: ["mcp", "add", "-s", "user", resolved.serverName, "--", serverBin, ...serverArgs],
133
- description: `claude mcp add -s user ${resolved.serverName} -- ${resolved.serverCommand.join(" ")}`
133
+ description: `claude mcp add -s user ${resolved.serverName} -- ${resolved.serverCommand.join(" ")}`,
134
+ operation: "install",
135
+ serverName: resolved.serverName,
136
+ serverCommand: resolved.serverCommand
134
137
  };
135
138
  case "codex":
136
139
  if (resolved.skipMissing && !resolved.hooks.which("codex")) {
@@ -141,7 +144,10 @@ export function planInstall(harness, options = {}) {
141
144
  harness,
142
145
  command: "codex",
143
146
  args: ["mcp", "add", resolved.serverName, "--", serverBin, ...serverArgs],
144
- description: `codex mcp add ${resolved.serverName} -- ${resolved.serverCommand.join(" ")}`
147
+ description: `codex mcp add ${resolved.serverName} -- ${resolved.serverCommand.join(" ")}`,
148
+ operation: "install",
149
+ serverName: resolved.serverName,
150
+ serverCommand: resolved.serverCommand
145
151
  };
146
152
  case "gemini":
147
153
  if (resolved.skipMissing && !resolved.hooks.which("gemini")) {
@@ -152,7 +158,10 @@ export function planInstall(harness, options = {}) {
152
158
  harness,
153
159
  command: "gemini",
154
160
  args: ["mcp", "add", "-s", "user", "-t", "stdio", resolved.serverName, serverBin, ...serverArgs],
155
- description: `gemini mcp add -s user -t stdio ${resolved.serverName} ${resolved.serverCommand.join(" ")}`
161
+ description: `gemini mcp add -s user -t stdio ${resolved.serverName} ${resolved.serverCommand.join(" ")}`,
162
+ operation: "install",
163
+ serverName: resolved.serverName,
164
+ serverCommand: resolved.serverCommand
156
165
  };
157
166
  case "opencode": {
158
167
  const filePath = resolveOpencodeConfigPath(options);
@@ -165,6 +174,9 @@ export function planInstall(harness, options = {}) {
165
174
  harness,
166
175
  filePath,
167
176
  description: `merge mcp.${resolved.serverName} into ${filePath}`,
177
+ operation: "install",
178
+ serverName: resolved.serverName,
179
+ inspect: () => inspectOpencodeConfig(filePath, resolved),
168
180
  apply: () => patchOpencodeConfig(filePath, resolved, "install")
169
181
  };
170
182
  }
@@ -184,7 +196,9 @@ export function planUninstall(harness, options = {}) {
184
196
  harness,
185
197
  command: "claude",
186
198
  args: ["mcp", "remove", "-s", "user", resolved.serverName],
187
- description: `claude mcp remove -s user ${resolved.serverName}`
199
+ description: `claude mcp remove -s user ${resolved.serverName}`,
200
+ operation: "uninstall",
201
+ serverName: resolved.serverName
188
202
  };
189
203
  case "codex":
190
204
  if (resolved.skipMissing && !resolved.hooks.which("codex")) {
@@ -195,7 +209,9 @@ export function planUninstall(harness, options = {}) {
195
209
  harness,
196
210
  command: "codex",
197
211
  args: ["mcp", "remove", resolved.serverName],
198
- description: `codex mcp remove ${resolved.serverName}`
212
+ description: `codex mcp remove ${resolved.serverName}`,
213
+ operation: "uninstall",
214
+ serverName: resolved.serverName
199
215
  };
200
216
  case "gemini":
201
217
  if (resolved.skipMissing && !resolved.hooks.which("gemini")) {
@@ -206,7 +222,9 @@ export function planUninstall(harness, options = {}) {
206
222
  harness,
207
223
  command: "gemini",
208
224
  args: ["mcp", "remove", "-s", "user", resolved.serverName],
209
- description: `gemini mcp remove -s user ${resolved.serverName}`
225
+ description: `gemini mcp remove -s user ${resolved.serverName}`,
226
+ operation: "uninstall",
227
+ serverName: resolved.serverName
210
228
  };
211
229
  case "opencode": {
212
230
  const filePath = resolveOpencodeConfigPath(options);
@@ -222,6 +240,9 @@ export function planUninstall(harness, options = {}) {
222
240
  harness,
223
241
  filePath,
224
242
  description: `remove mcp.${resolved.serverName} from ${filePath}`,
243
+ operation: "uninstall",
244
+ serverName: resolved.serverName,
245
+ inspect: () => inspectOpencodeConfig(filePath, resolved),
225
246
  apply: () => patchOpencodeConfig(filePath, resolved, "uninstall")
226
247
  };
227
248
  }
@@ -264,6 +285,47 @@ function patchOpencodeConfig(filePath, resolved, mode) {
264
285
  resolved.hooks.ensureDir(path.dirname(filePath));
265
286
  resolved.hooks.writeFile(filePath, JSON.stringify(config, null, 2) + "\n");
266
287
  }
288
+ function inspectOpencodeConfig(filePath, resolved) {
289
+ const existing = resolved.hooks.readFile(filePath);
290
+ if (existing === null)
291
+ return "absent";
292
+ let config;
293
+ try {
294
+ config = parseJsonOrThrow(existing, filePath);
295
+ }
296
+ catch {
297
+ return "unknown";
298
+ }
299
+ const mcp = isPlainObject(config.mcp) ? config.mcp : {};
300
+ if (!(resolved.serverName in mcp))
301
+ return "absent";
302
+ const expected = {
303
+ type: "local",
304
+ command: [...resolved.serverCommand],
305
+ enabled: true
306
+ };
307
+ return valuesEqual(mcp[resolved.serverName], expected) ? "present" : "different";
308
+ }
309
+ function inspectGeminiSettings(action, resolved) {
310
+ const filePath = path.join(resolveHarnessConfigDirFromResolved("gemini", resolved), "settings.json");
311
+ const existing = resolved.hooks.readFile(filePath);
312
+ if (existing === null)
313
+ return "absent";
314
+ let config;
315
+ try {
316
+ config = parseJsonOrThrow(existing, filePath);
317
+ }
318
+ catch {
319
+ return "unknown";
320
+ }
321
+ const servers = isPlainObject(config.mcpServers) ? config.mcpServers : {};
322
+ const serverName = action.serverName ?? resolved.serverName;
323
+ if (!(serverName in servers))
324
+ return "absent";
325
+ const [command, ...args] = action.serverCommand ?? resolved.serverCommand;
326
+ const expected = { command, args };
327
+ return valuesEqual(servers[serverName], expected) ? "present" : "different";
328
+ }
267
329
  function parseJsonOrThrow(raw, filePath) {
268
330
  try {
269
331
  const parsed = JSON.parse(raw);
@@ -279,6 +341,9 @@ function parseJsonOrThrow(raw, filePath) {
279
341
  function isPlainObject(value) {
280
342
  return typeof value === "object" && value !== null && !Array.isArray(value);
281
343
  }
344
+ function valuesEqual(left, right) {
345
+ return JSON.stringify(left) === JSON.stringify(right);
346
+ }
282
347
  export function detectHarness(harness, options = {}) {
283
348
  const resolved = resolveOptions(options);
284
349
  switch (harness) {
@@ -333,6 +398,7 @@ export async function runAction(action, options = {}) {
333
398
  harness: action.harness,
334
399
  ok: true,
335
400
  action,
401
+ status: "skipped",
336
402
  message: action.message,
337
403
  skipped: true
338
404
  };
@@ -344,6 +410,7 @@ export async function runAction(action, options = {}) {
344
410
  harness: action.harness,
345
411
  ok: resolved.skipMissing,
346
412
  action,
413
+ status: resolved.skipMissing ? "skipped" : "failed",
347
414
  message: `${action.command} not on PATH`,
348
415
  skipped: resolved.skipMissing
349
416
  };
@@ -353,9 +420,29 @@ export async function runAction(action, options = {}) {
353
420
  harness: action.harness,
354
421
  ok: false,
355
422
  action,
423
+ status: "failed",
356
424
  message: invocation.error
357
425
  };
358
426
  }
427
+ const beforeState = await inspectExecAction(action, resolved);
428
+ if (action.operation === "install" && beforeState === "present") {
429
+ return {
430
+ harness: action.harness,
431
+ ok: true,
432
+ action,
433
+ status: "already_present",
434
+ message: formatMcpActionMessage(action, "already_present")
435
+ };
436
+ }
437
+ if (action.operation === "uninstall" && beforeState === "absent") {
438
+ return {
439
+ harness: action.harness,
440
+ ok: true,
441
+ action,
442
+ status: "already_absent",
443
+ message: formatMcpActionMessage(action, "already_absent")
444
+ };
445
+ }
359
446
  let result;
360
447
  try {
361
448
  result = await resolved.hooks.run(invocation.command, invocation.args, invocation.options);
@@ -365,31 +452,75 @@ export async function runAction(action, options = {}) {
365
452
  harness: action.harness,
366
453
  ok: false,
367
454
  action,
455
+ status: "failed",
368
456
  message: formatExecError(error)
369
457
  };
370
458
  }
371
459
  if (result.exitCode === 0) {
460
+ const status = successStatusForOperation(action.operation, beforeState);
461
+ return {
462
+ harness: action.harness,
463
+ ok: true,
464
+ action,
465
+ status,
466
+ message: formatMcpActionMessage(action, status, result.stdout.trim() || undefined)
467
+ };
468
+ }
469
+ const errorMessage = result.stderr.trim() || result.stdout.trim() || `${action.command} exited with code ${result.exitCode}`;
470
+ if (action.operation === "install" && isAlreadyPresentMessage(errorMessage)) {
471
+ return {
472
+ harness: action.harness,
473
+ ok: true,
474
+ action,
475
+ status: "already_present",
476
+ message: formatMcpActionMessage(action, "already_present")
477
+ };
478
+ }
479
+ if (action.operation === "uninstall" && isAlreadyAbsentMessage(errorMessage)) {
372
480
  return {
373
481
  harness: action.harness,
374
482
  ok: true,
375
483
  action,
376
- message: result.stdout.trim() || `${action.command} succeeded`
484
+ status: "already_absent",
485
+ message: formatMcpActionMessage(action, "already_absent")
377
486
  };
378
487
  }
379
488
  return {
380
489
  harness: action.harness,
381
490
  ok: false,
382
491
  action,
383
- message: (result.stderr.trim() || result.stdout.trim() || `${action.command} exited with code ${result.exitCode}`)
492
+ status: "failed",
493
+ message: errorMessage
494
+ };
495
+ }
496
+ const beforeState = action.inspect?.() ?? "unknown";
497
+ if (action.operation === "install" && beforeState === "present") {
498
+ return {
499
+ harness: action.harness,
500
+ ok: true,
501
+ action,
502
+ status: "already_present",
503
+ message: formatMcpActionMessage(action, "already_present")
504
+ };
505
+ }
506
+ if (action.operation === "uninstall" && beforeState === "absent") {
507
+ return {
508
+ harness: action.harness,
509
+ ok: true,
510
+ action,
511
+ status: "already_absent",
512
+ message: formatMcpActionMessage(action, "already_absent")
384
513
  };
385
514
  }
386
515
  try {
387
516
  action.apply();
517
+ const status = successStatusForOperation(action.operation, beforeState);
388
518
  return {
389
519
  harness: action.harness,
390
520
  ok: true,
391
521
  action,
392
- message: `Updated ${action.filePath}`
522
+ status,
523
+ message: formatMcpActionMessage(action, status, `Updated ${action.filePath}`)
393
524
  };
394
525
  }
395
526
  catch (error) {
@@ -398,6 +529,7 @@ export async function runAction(action, options = {}) {
398
529
  harness: action.harness,
399
530
  ok: true,
400
531
  action,
532
+ status: "skipped",
401
533
  message: error.message,
402
534
  skipped: true
403
535
  };
@@ -406,10 +538,83 @@ export async function runAction(action, options = {}) {
406
538
  harness: action.harness,
407
539
  ok: false,
408
540
  action,
541
+ status: "failed",
409
542
  message: error.message
410
543
  };
411
544
  }
412
545
  }
546
+ async function inspectExecAction(action, resolved) {
547
+ if (!action.operation || !action.serverName)
548
+ return "unknown";
549
+ if (action.harness === "gemini") {
550
+ return inspectGeminiSettings(action, resolved);
551
+ }
552
+ if (action.harness !== "claude-code" && action.harness !== "codex") {
553
+ return "unknown";
554
+ }
555
+ const invocation = resolveCommandInvocation(action.command, ["mcp", "get", action.serverName], resolved);
556
+ if (!invocation || "error" in invocation)
557
+ return "unknown";
558
+ try {
559
+ const result = await resolved.hooks.run(invocation.command, invocation.args, invocation.options);
560
+ return result.exitCode === 0 ? "present" : "absent";
561
+ }
562
+ catch {
563
+ return "unknown";
564
+ }
565
+ }
566
+ function successStatusForOperation(operation, beforeState) {
567
+ if (operation === "install") {
568
+ return beforeState === "different" ? "updated" : "added";
569
+ }
570
+ if (operation === "uninstall") {
571
+ return "removed";
572
+ }
573
+ return "ok";
574
+ }
575
+ function formatMcpActionMessage(action, status, fallback) {
576
+ if (!action.serverName || !action.operation) {
577
+ return fallback ?? "ok";
578
+ }
579
+ const target = `MCP server '${action.serverName}'`;
580
+ const location = mcpConfigLocation(action);
581
+ switch (status) {
582
+ case "added":
583
+ return `${target} registered in ${location}.`;
584
+ case "updated":
585
+ return `${target} updated in ${location}.`;
586
+ case "already_present":
587
+ return `${target} already registered in ${location}.`;
588
+ case "removed":
589
+ return `${target} removed from ${location}.`;
590
+ case "already_absent":
591
+ return `${target} is not registered in ${location}.`;
592
+ default:
593
+ return fallback ?? "ok";
594
+ }
595
+ }
596
+ function mcpConfigLocation(action) {
597
+ if (action.kind === "file-patch")
598
+ return action.filePath;
599
+ switch (action.harness) {
600
+ case "claude-code":
601
+ return "Claude Code user config";
602
+ case "codex":
603
+ return "Codex global config";
604
+ case "gemini":
605
+ return "Gemini user config";
606
+ case "opencode":
607
+ return "OpenCode config";
608
+ default:
609
+ return "harness config";
610
+ }
611
+ }
612
+ function isAlreadyPresentMessage(message) {
613
+ return /\balready\b.*\b(exists|configured|present|registered|installed)\b/i.test(message);
614
+ }
615
+ function isAlreadyAbsentMessage(message) {
616
+ return /\b(not found|does not exist|not configured|not registered|no mcp server)\b/i.test(message);
617
+ }
413
618
  export function parseHarnessList(values) {
414
619
  const result = [];
415
620
  for (const value of values) {
@@ -422,15 +627,18 @@ export function parseHarnessList(values) {
422
627
  return result;
423
628
  }
424
629
  function resolveExecInvocation(action, resolved) {
425
- const executable = resolved.hooks.which(action.command);
630
+ return resolveCommandInvocation(action.command, action.args, resolved);
631
+ }
632
+ function resolveCommandInvocation(command, args, resolved) {
633
+ const executable = resolved.hooks.which(command);
426
634
  if (!executable) {
427
635
  return null;
428
636
  }
429
637
  if (resolved.platform === "win32" && /\.(cmd|bat)$/i.test(executable)) {
430
- const unsafeArg = action.args.find(containsWindowsCmdMetacharacter);
638
+ const unsafeArg = args.find(containsWindowsCmdMetacharacter);
431
639
  if (unsafeArg !== undefined) {
432
640
  return {
433
- error: `Cannot safely launch ${action.command} through cmd.exe because ` +
641
+ error: `Cannot safely launch ${command} through cmd.exe because ` +
434
642
  `an argument contains Windows command metacharacters (& | < > ^ % ").`
435
643
  };
436
644
  }
@@ -439,13 +647,13 @@ function resolveExecInvocation(action, resolved) {
439
647
  "cmd.exe";
440
648
  return {
441
649
  command: cmdExe,
442
- args: ["/d", "/s", "/c", executable, ...action.args],
650
+ args: ["/d", "/s", "/c", executable, ...args],
443
651
  options: { windowsHide: true }
444
652
  };
445
653
  }
446
654
  return {
447
655
  command: executable,
448
- args: action.args,
656
+ args,
449
657
  options: resolved.platform === "win32" ? { windowsHide: true } : undefined
450
658
  };
451
659
  }
@@ -27,7 +27,7 @@ export function createMcpServer(service = new TalkingStickService()) {
27
27
  const resolveConnectionIdentity = createConnectionIdentityResolver();
28
28
  const server = new McpServer({
29
29
  name: "talking-stick",
30
- version: "0.1.0-alpha"
30
+ version: "0.1.0"
31
31
  });
32
32
  server.registerTool("list_rooms", {
33
33
  title: "List Rooms",
@@ -0,0 +1,61 @@
1
+ # Talking Stick 0.1.0-alpha.6
2
+
3
+ Date: 2026-04-27
4
+
5
+ Installer idempotency alpha. This release makes repeated `tt install --all`
6
+ runs precise instead of noisy.
7
+
8
+ ## Changed
9
+
10
+ ### Precise install results
11
+
12
+ `tt install` and `tt uninstall` now return and print explicit result statuses:
13
+
14
+ - `added`
15
+ - `already-present`
16
+ - `updated`
17
+ - `removed`
18
+ - `already-absent`
19
+ - `skipped`
20
+ - `failed`
21
+
22
+ The CLI reports the state Talking Stick observed instead of forwarding each
23
+ harness's native wording directly. That keeps Claude Code, Codex, Gemini, and
24
+ OpenCode output consistent.
25
+
26
+ ### Idempotent MCP install preflight
27
+
28
+ Before running native MCP add/remove commands, Talking Stick now checks whether
29
+ the named MCP server is already in the expected state:
30
+
31
+ - Claude Code and Codex use their native `mcp get` command.
32
+ - Gemini is inspected through its user `settings.json`.
33
+ - OpenCode is inspected through its JSON config.
34
+
35
+ If `talking-stick` is already registered, `tt install` reports
36
+ `already-present` and does not call another native add command.
37
+
38
+ ## Fixed
39
+
40
+ ### Claude Code already-present installs
41
+
42
+ Claude Code reports an existing MCP server as a native command failure. Talking
43
+ Stick now recognizes that response as `already-present`, so a rerun of
44
+ `tt install --all` no longer ends with "install completed with failures" just
45
+ because Claude Code was already configured.
46
+
47
+ ### Codex duplicate-looking installs
48
+
49
+ Codex already stores MCP servers by name, but rerunning `tt install --all`
50
+ previously invoked `codex mcp add` again and printed Codex's native "Added"
51
+ message. Talking Stick now reports `already-present` without invoking another
52
+ add when the named server is already present.
53
+
54
+ ## Verification
55
+
56
+ - `npm test -- tests/install.test.ts`
57
+ - `npm run typecheck`
58
+ - `npm run build`
59
+ - `git diff --check`
60
+ - `node dist/cli.js install --all`
61
+ - `npx vitest run --reporter verbose` — 208 tests across 14 files
@@ -0,0 +1,37 @@
1
+ # Talking Stick 0.1.0
2
+
3
+ Date: 2026-04-27
4
+
5
+ First non-alpha release. This promotes the current single-host coordination
6
+ surface from alpha to the initial SemVer baseline.
7
+
8
+ ## What is in the baseline
9
+
10
+ - MCP room coordination: `join_path`, `wait_for_turn`, `heartbeat`,
11
+ `release_stick`, `pass_stick`, `takeover_stick`, state reads, event reads,
12
+ and non-owner notes.
13
+ - Human `tt` CLI flows for joining, waiting, releasing, passing, takeover,
14
+ notes, room inspection, installers, skill installers, and self-update.
15
+ - SQLite WAL-backed persistence with multi-process safety, lease fencing,
16
+ liveness-aware recovery, fair release selection, and ephemeral room cleanup.
17
+ - User-global MCP and skill install support for Claude Code, Codex CLI, Gemini
18
+ CLI, and OpenCode.
19
+
20
+ ## Changed
21
+
22
+ ### Non-alpha package version
23
+
24
+ Package metadata, README version text, and the MCP server handshake now advertise
25
+ `0.1.0`.
26
+
27
+ ### Long-poll skill guidance
28
+
29
+ The bundled skill now uses `110000` ms as the default client-safe
30
+ `wait_for_turn` long-poll budget.
31
+
32
+ ## Verification
33
+
34
+ - `npm run typecheck`
35
+ - `npm test` — 208 tests across 14 files
36
+ - `git diff --check`
37
+ - `npm pack --dry-run --ignore-scripts`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talking-stick",
3
- "version": "0.1.0-alpha.5",
3
+ "version": "0.1.0",
4
4
  "description": "MCP coordination server for path-scoped agent handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -53,11 +53,11 @@ Keep the wait input minimal:
53
53
  ```json
54
54
  {
55
55
  "room_id": "<room_id from join_path>",
56
- "max_wait_ms": 60000
56
+ "max_wait_ms": 110000
57
57
  }
58
58
  ```
59
59
 
60
- `max_wait_ms` is optional. Use the longest client-safe wait you can support: 60000 ms is a good MCP default when the harness can tolerate it; 120000 ms is fine only when the tool/client timeout is known to exceed that. If the call times out at the harness layer, fall back to a shorter value and call again. Do not send `cursor`, even if an old tool schema still exposes it; `wait_for_turn` is cursor-free, and resumable event replay belongs to `get_room_events`.
60
+ `max_wait_ms` is optional. Use the longest client-safe wait you can support: 110000 ms is a good MCP default when the harness can tolerate it; 180000 ms is fine only when the tool/client timeout is known to exceed that. If the call times out at the harness layer, fall back to a shorter value and call again. Do not send `cursor`, even if an old tool schema still exposes it; `wait_for_turn` is cursor-free, and resumable event replay belongs to `get_room_events`.
61
61
 
62
62
  Possible outcomes:
63
63