talking-stick 0.4.10 → 0.4.12

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
@@ -167,7 +167,7 @@ For harnesses that only notice completed subprocesses, run `tt events --wait --a
167
167
  - Codex: copied or linked into `~/.codex/skills/talking-stick`
168
168
  - Gemini: installed with `gemini skills install ... --scope user` or linked with `gemini skills link ... --scope user`
169
169
  - Grok Build: copied or linked into `~/.grok/skills/talking-stick`, plus a trusted global session hook at `~/.grok/hooks/talking-stick-session.json`
170
- - OpenCode: copied or linked into `~/.opencode/skills/talking-stick`
170
+ - OpenCode: copied or linked into the resolved OpenCode config directory, normally `~/.config/opencode/skills/talking-stick` and honoring `XDG_CONFIG_HOME`
171
171
 
172
172
  By default, `tt install` links the bundled skill into each harness so local updates are picked up immediately. Pass `--copy` if you want a standalone snapshot instead.
173
173
 
@@ -0,0 +1,21 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ export function writeFileAtomic(filePath, data) {
5
+ const directory = path.dirname(filePath);
6
+ fs.mkdirSync(directory, { recursive: true });
7
+ const tempPath = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`);
8
+ try {
9
+ fs.writeFileSync(tempPath, data);
10
+ fs.renameSync(tempPath, filePath);
11
+ }
12
+ catch (error) {
13
+ try {
14
+ fs.rmSync(tempPath, { force: true });
15
+ }
16
+ catch {
17
+ // Best effort cleanup for failed writes.
18
+ }
19
+ throw error;
20
+ }
21
+ }
@@ -103,8 +103,46 @@ export async function spawnGuardian(input) {
103
103
  const inspector = createSystemProcessInspector();
104
104
  let stdout = "";
105
105
  let stderr = "";
106
+ let settled = false;
107
+ const cleanup = () => {
108
+ clearTimeout(timeout);
109
+ child.stdout?.removeAllListeners();
110
+ child.stderr?.removeAllListeners();
111
+ child.removeAllListeners("exit");
112
+ child.removeAllListeners("error");
113
+ child.stdout?.destroy();
114
+ child.stderr?.destroy();
115
+ };
116
+ const killChild = () => {
117
+ try {
118
+ child.kill("SIGTERM");
119
+ }
120
+ catch {
121
+ // Best effort cleanup for a child that failed readiness.
122
+ }
123
+ };
124
+ const rejectOnce = (error, kill = false) => {
125
+ if (settled) {
126
+ return;
127
+ }
128
+ settled = true;
129
+ if (kill) {
130
+ killChild();
131
+ }
132
+ cleanup();
133
+ reject(error);
134
+ };
135
+ const resolveOnce = (value) => {
136
+ if (settled) {
137
+ return;
138
+ }
139
+ settled = true;
140
+ cleanup();
141
+ child.unref();
142
+ resolve(value);
143
+ };
106
144
  const timeout = setTimeout(() => {
107
- reject(new Error("Guardian did not signal readiness in time."));
145
+ rejectOnce(new Error("Guardian did not signal readiness in time."), true);
108
146
  }, GUARD_READY_TIMEOUT_MS);
109
147
  child.stdout?.setEncoding("utf8");
110
148
  child.stderr?.setEncoding("utf8");
@@ -113,15 +151,11 @@ export async function spawnGuardian(input) {
113
151
  if (!stdout.includes(GUARD_READY)) {
114
152
  return;
115
153
  }
116
- clearTimeout(timeout);
117
- child.stdout?.destroy();
118
- child.stderr?.destroy();
119
- child.unref();
120
154
  if (!child.pid) {
121
- reject(new Error("Guardian started without a PID."));
155
+ rejectOnce(new Error("Guardian started without a PID."), true);
122
156
  return;
123
157
  }
124
- resolve({
158
+ resolveOnce({
125
159
  pid: child.pid,
126
160
  process_started_at: inspector.inspect(child.pid)?.startTime ?? null
127
161
  });
@@ -130,8 +164,10 @@ export async function spawnGuardian(input) {
130
164
  stderr += chunk;
131
165
  });
132
166
  child.on("exit", (code) => {
133
- clearTimeout(timeout);
134
- reject(new Error(`Guardian exited before readiness (code ${code ?? "unknown"}): ${stderr.trim()}`));
167
+ rejectOnce(new Error(`Guardian exited before readiness (code ${code ?? "unknown"}): ${stderr.trim()}`));
168
+ });
169
+ child.on("error", (error) => {
170
+ rejectOnce(error instanceof Error ? error : new Error(String(error)));
135
171
  });
136
172
  });
137
173
  }
@@ -6,11 +6,8 @@ import { FileAuditLog, defaultAuditLogPath } from "../install-audit.js";
6
6
  import { removeStaleMcpRegistrations } from "../install-migration.js";
7
7
  import { detectInstallSource, isPackageManager, planSelfUpdate, resolveCurrentBinaryPath } from "../self-update.js";
8
8
  import { readPackageVersion, runStaleMcpCleanup } from "../update-migration.js";
9
- import { getStringOption, hasOption, normalizeBooleanFlag } from "./parser.js";
9
+ import { getStringOption, hasOption } from "./parser.js";
10
10
  export async function runInstallCommand(parsed) {
11
- normalizeBooleanFlag(parsed, "print");
12
- normalizeBooleanFlag(parsed, "copy");
13
- normalizeBooleanFlag(parsed, "link");
14
11
  const harnesses = selectHarnesses(parsed);
15
12
  const dryRun = hasOption(parsed, "print");
16
13
  const installOptions = {
@@ -32,7 +29,6 @@ export async function runInstallCommand(parsed) {
32
29
  printInstructionHint(results);
33
30
  }
34
31
  export async function runUninstallCommand(parsed) {
35
- normalizeBooleanFlag(parsed, "print");
36
32
  const harnesses = selectHarnesses(parsed);
37
33
  const dryRun = hasOption(parsed, "print");
38
34
  const installOptions = { skipMissing: true };
@@ -48,9 +44,6 @@ export async function runUninstallCommand(parsed) {
48
44
  reportCleanupResults(await runCleanup(harnesses, "uninstall", installOptions), "uninstall");
49
45
  }
50
46
  export async function runInstallSkillCommand(parsed) {
51
- normalizeBooleanFlag(parsed, "print");
52
- normalizeBooleanFlag(parsed, "copy");
53
- normalizeBooleanFlag(parsed, "link");
54
47
  const harnesses = selectHarnesses(parsed);
55
48
  const dryRun = hasOption(parsed, "print");
56
49
  const link = resolveSkillInstallLinkMode(parsed);
@@ -66,7 +59,6 @@ export async function runInstallSkillCommand(parsed) {
66
59
  reportInstallResults(results, "install");
67
60
  }
68
61
  export async function runUninstallSkillCommand(parsed) {
69
- normalizeBooleanFlag(parsed, "print");
70
62
  const harnesses = selectHarnesses(parsed);
71
63
  const dryRun = hasOption(parsed, "print");
72
64
  const installOptions = { skipMissing: true };
@@ -81,7 +73,6 @@ export async function runUninstallSkillCommand(parsed) {
81
73
  reportInstallResults(results, "uninstall");
82
74
  }
83
75
  export async function runSelfUpdateCommand(parsed, cliEntryUrl) {
84
- normalizeBooleanFlag(parsed, "print");
85
76
  const dryRun = hasOption(parsed, "print");
86
77
  const managerOverride = getStringOption(parsed, "manager");
87
78
  let source;
@@ -121,7 +112,6 @@ export async function runSelfUpdateCommand(parsed, cliEntryUrl) {
121
112
  process.stdout.write("Done. Restart any long-running harness sessions to pick up the new tt.\n");
122
113
  }
123
114
  export async function runMcpMigrationCommand(parsed) {
124
- normalizeBooleanFlag(parsed, "quiet");
125
115
  const reason = parseAuditReason(getStringOption(parsed, "reason") ?? "manual");
126
116
  const quiet = hasOption(parsed, "quiet");
127
117
  const cleanup = await runStaleMcpCleanup({
@@ -248,15 +238,19 @@ function reportInstallResults(results, mode) {
248
238
  throw new Error(`${mode} completed with failures.`);
249
239
  }
250
240
  }
251
- function printInstructionHint(results) {
252
- if (!results.some((result) => result.ok && !result.skipped)) {
241
+ export function printInstructionHint(results) {
242
+ const changed = new Set(["added", "updated", "ok"]);
243
+ if (!results.some((result) => result.ok && changed.has(result.status))) {
253
244
  return;
254
245
  }
255
246
  process.stdout.write("Customize collaboration instructions with: tt instructions edit\n");
256
247
  }
257
- function reportCleanupResults(results, mode) {
248
+ export function reportCleanupResults(results, mode) {
258
249
  let anyFailed = false;
259
250
  for (const result of results) {
251
+ if (result.action === "absent" || result.action === "skipped") {
252
+ continue;
253
+ }
260
254
  process.stdout.write(`[${result.harness}] mcp-cleanup ${result.action}: ${result.message}\n`);
261
255
  if (result.action === "failed")
262
256
  anyFailed = true;
@@ -24,8 +24,6 @@ export async function handleInstructionsCommand(parsed) {
24
24
  }
25
25
  }
26
26
  function handleInstructionsShowCommand(parsed) {
27
- repairBooleanFlag(parsed, "json", 0);
28
- repairBooleanFlag(parsed, "text", 0);
29
27
  const contextPath = resolveContextPathArg(parsed);
30
28
  const scope = parseInstructionScope(getStringOption(parsed, "scope"));
31
29
  const identity = deriveCliIdentity(parsed);
@@ -45,10 +43,6 @@ function handleInstructionsShowCommand(parsed) {
45
43
  });
46
44
  }
47
45
  async function handleInstructionsEditCommand(parsed) {
48
- repairBooleanFlag(parsed, "json", 0);
49
- repairBooleanFlag(parsed, "text", 0);
50
- repairBooleanFlag(parsed, "user", 0);
51
- repairBooleanFlag(parsed, "project", 0);
52
46
  const contextPath = resolveContextPathArg(parsed);
53
47
  const scope = resolveEditableScope(parsed, false);
54
48
  const result = await editInstructions({
@@ -66,10 +60,6 @@ async function handleInstructionsEditCommand(parsed) {
66
60
  });
67
61
  }
68
62
  function handleInstructionsResetCommand(parsed) {
69
- repairBooleanFlag(parsed, "json", 0);
70
- repairBooleanFlag(parsed, "text", 0);
71
- repairBooleanFlag(parsed, "user", 0);
72
- repairBooleanFlag(parsed, "project", 0);
73
63
  const contextPath = resolveContextPathArg(parsed);
74
64
  const scope = resolveEditableScope(parsed, true);
75
65
  const result = resetInstructions({
@@ -104,10 +94,3 @@ function resolveContextPathArg(parsed) {
104
94
  }
105
95
  return pathOption ?? parsed.positionals[0] ?? process.cwd();
106
96
  }
107
- function repairBooleanFlag(parsed, key, insertAt) {
108
- const value = parsed.options.get(key);
109
- if (typeof value === "string") {
110
- parsed.positionals.splice(insertAt, 0, value);
111
- parsed.options.set(key, true);
112
- }
113
- }
@@ -29,8 +29,6 @@ async function handleMsgSendCommand(runtime, parsed) {
29
29
  const identity = deriveCliIdentity(parsed);
30
30
  const session = resolveSessionForNotes(runtime, parsed, identity);
31
31
  const usesRoomFlag = hasOption(parsed, "room");
32
- repairBooleanFlag(parsed, "room", 0);
33
- repairBooleanFlag(parsed, "interrupt", usesRoomFlag ? 0 : 1);
34
32
  const recipientSelector = usesRoomFlag ? "room" : parsed.positionals[0];
35
33
  if (!recipientSelector) {
36
34
  throw new Error("Usage: tt msg send <recipient|room> <body...> [--interrupt] [--stdin].");
@@ -69,13 +67,6 @@ async function handleMsgRecvCommand(runtime, parsed) {
69
67
  force_tail_cursor: false
70
68
  });
71
69
  }
72
- function repairBooleanFlag(parsed, key, insertAt) {
73
- const value = parsed.options.get(key);
74
- if (typeof value === "string") {
75
- parsed.positionals.splice(insertAt, 0, value);
76
- parsed.options.set(key, true);
77
- }
78
- }
79
70
  function shortEventId(eventId) {
80
71
  return eventId.slice(0, 8);
81
72
  }
@@ -1,3 +1,26 @@
1
+ const BOOLEAN_FLAGS = new Set([
2
+ "all",
3
+ "copy",
4
+ "events",
5
+ "explain",
6
+ "follow",
7
+ "force",
8
+ "force-new",
9
+ "help",
10
+ "interrupt",
11
+ "json",
12
+ "link",
13
+ "operator-requested",
14
+ "park",
15
+ "print",
16
+ "project",
17
+ "quiet",
18
+ "room",
19
+ "stdin",
20
+ "text",
21
+ "user",
22
+ "wait"
23
+ ]);
1
24
  export function parseCommand(argv) {
2
25
  const [name = "", ...rest] = argv;
3
26
  const options = new Map();
@@ -9,6 +32,10 @@ export function parseCommand(argv) {
9
32
  continue;
10
33
  }
11
34
  const key = token.slice(2);
35
+ if (BOOLEAN_FLAGS.has(key)) {
36
+ options.set(key, true);
37
+ continue;
38
+ }
12
39
  const next = rest[index + 1];
13
40
  if (!next || next.startsWith("--")) {
14
41
  options.set(key, true);
@@ -33,18 +60,14 @@ export function requireStringOption(parsed, key) {
33
60
  }
34
61
  return value;
35
62
  }
36
- export function normalizeBooleanFlag(parsed, key) {
37
- const value = parsed.options.get(key);
38
- if (typeof value === "string") {
39
- parsed.positionals.unshift(value);
40
- parsed.options.set(key, true);
41
- }
42
- }
43
63
  export function parseOptionalInteger(parsed, key) {
44
64
  const value = getStringOption(parsed, key);
45
65
  if (!value) {
46
66
  return undefined;
47
67
  }
68
+ if (!/^\d+$/.test(value)) {
69
+ throw new Error(`--${key} must be an integer.`);
70
+ }
48
71
  const parsedValue = Number.parseInt(value, 10);
49
72
  if (!Number.isInteger(parsedValue)) {
50
73
  throw new Error(`--${key} must be an integer.`);
@@ -1,7 +1,7 @@
1
1
  import { deriveCliIdentity, resolveCliIdentity } from "./identity.js";
2
2
  import { removeCliSession, removeCliSessionsForRoom, resolveCliSessionPath } from "../index.js";
3
3
  import { stopGuardian } from "./guardian.js";
4
- import { getStringOption, hasOption, normalizeBooleanFlag, parseOptionalInteger } from "./parser.js";
4
+ import { getStringOption, hasOption, parseOptionalInteger } from "./parser.js";
5
5
  import { formatRelativeTime, printResult } from "./output.js";
6
6
  import { parseEventTypeFilter, runEventStream } from "./event-stream.js";
7
7
  import { resolveSessionForReads, upsertSessionFromJoin } from "./session.js";
@@ -129,8 +129,6 @@ export function handleStateCommand(runtime, parsed) {
129
129
  });
130
130
  }
131
131
  export async function handleEventsCommand(runtime, parsed) {
132
- normalizeBooleanFlag(parsed, "wait");
133
- normalizeBooleanFlag(parsed, "follow");
134
132
  const identity = deriveCliIdentity(parsed);
135
133
  const session = resolveSessionForReads(runtime, parsed, identity);
136
134
  if (hasOption(parsed, "wait") || hasOption(parsed, "follow")) {
@@ -2,13 +2,11 @@ import { clearCliSessionLease, createSystemProcessInspector, findCliSessionByRoo
2
2
  import { checkGuardianLiveness, spawnGuardian, stopGuardian } from "./guardian.js";
3
3
  import { resolveHandoff } from "./handoff.js";
4
4
  import { deriveCliIdentity, resolveTakeoverReason, shouldUseOperatorOverride } from "./identity.js";
5
- import { getStringOption, hasOption, normalizeBooleanFlag, parseRequiredInteger, parseWaitTimeout } from "./parser.js";
5
+ import { getStringOption, hasOption, parseRequiredInteger, parseWaitTimeout } from "./parser.js";
6
6
  import { resolveTargetFilter } from "./event-stream.js";
7
7
  import { formatWaitResult, printResult } from "./output.js";
8
8
  import { requireLeaseSession, upsertSessionFromJoin } from "./session.js";
9
9
  export async function handleWaitCommand(runtime, parsed, isTry, cliEntryUrl) {
10
- normalizeBooleanFlag(parsed, "park");
11
- normalizeBooleanFlag(parsed, "events");
12
10
  const park = hasOption(parsed, "park");
13
11
  const includeEvents = hasOption(parsed, "events");
14
12
  const afterEventSeq = includeEvents
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { isProtocolError } from "./index.js";
6
6
  import { parseCommand } from "./cli/parser.js";
7
- import { printHelp } from "./cli/output.js";
7
+ import { printHelp, shouldUseJson } from "./cli/output.js";
8
8
  import { getCommand } from "./cli/registry.js";
9
9
  import { createRuntime } from "./cli/runtime.js";
10
10
  import { runStartupMaintenance } from "./cli/startup-maintenance.js";
@@ -49,12 +49,24 @@ function isDirectExecution() {
49
49
  }
50
50
  if (isDirectExecution()) {
51
51
  await runCli().catch((error) => {
52
- const message = isProtocolError(error)
53
- ? JSON.stringify(error.toJSON(), null, 2)
54
- : error instanceof Error
52
+ const parsed = parseCommand(process.argv.slice(2));
53
+ if (shouldUseJson(parsed)) {
54
+ const payload = isProtocolError(error)
55
+ ? error.toJSON()
56
+ : {
57
+ error: "cli_error",
58
+ message: error instanceof Error ? error.message : String(error)
59
+ };
60
+ process.stderr.write(`${JSON.stringify(payload, null, 2)}\n`);
61
+ }
62
+ else {
63
+ const message = isProtocolError(error)
55
64
  ? error.message
56
- : String(error);
57
- process.stderr.write(`${message}\n`);
65
+ : error instanceof Error
66
+ ? error.message
67
+ : String(error);
68
+ process.stderr.write(`${message}\n`);
69
+ }
58
70
  process.exit(1);
59
71
  });
60
72
  }
@@ -70,6 +70,9 @@ export function findGrokSessionRecord(input) {
70
70
  return record;
71
71
  }
72
72
  }
73
+ if (input.grokPid != null && input.grokProcessStartedAt != null) {
74
+ return null;
75
+ }
73
76
  const uniqueSessionIds = new Set(workspaceCandidates.map((record) => record.grok_session_id));
74
77
  if (uniqueSessionIds.size === 1) {
75
78
  return workspaceCandidates[0] ?? null;
package/dist/install.js CHANGED
@@ -2,8 +2,10 @@ import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { writeFileAtomic } from "./atomic-write.js";
5
6
  export const SUPPORTED_HARNESSES = ["claude-code", "codex", "gemini", "grok", "opencode"];
6
7
  export const DEFAULT_SERVER_NAME = "talking-stick";
8
+ // Legacy MCP command retained only to identify stale config entries for removal.
7
9
  export const DEFAULT_SERVER_COMMAND = ["tt", "mcp"];
8
10
  export const GROK_SESSION_HOOK_FILE = "talking-stick-session.json";
9
11
  export const DEFAULT_GROK_SESSION_HOOK_COMMAND = ": talking-stick-grok-session-hook; if command -v tt >/dev/null 2>&1; then tt grok-session-hook >/dev/null 2>/dev/null || true; fi";
@@ -68,7 +70,7 @@ function defaultReadFile(filePath) {
68
70
  }
69
71
  }
70
72
  function defaultWriteFile(filePath, data) {
71
- fs.writeFileSync(filePath, data);
73
+ writeFileAtomic(filePath, data);
72
74
  }
73
75
  function defaultEnsureDir(dirPath) {
74
76
  fs.mkdirSync(dirPath, { recursive: true });
@@ -196,7 +198,7 @@ export function planUninstall(harness, options = {}) {
196
198
  operation: "uninstall",
197
199
  serverName: resolved.serverName,
198
200
  inspect: () => inspectOpencodeConfig(filePath, resolved),
199
- apply: () => patchOpencodeConfig(filePath, resolved, "uninstall")
201
+ apply: () => patchOpencodeConfig(filePath, resolved)
200
202
  };
201
203
  }
202
204
  default:
@@ -223,6 +225,7 @@ export function planGrokSessionHookInstall(options = {}) {
223
225
  harness: "grok",
224
226
  filePath,
225
227
  description: `write Grok session hook ${filePath}`,
228
+ operation: "install",
226
229
  inspect: () => inspectGrokSessionHook(filePath, resolved),
227
230
  apply: () => writeGrokSessionHook(filePath, resolved)
228
231
  };
@@ -281,29 +284,20 @@ function removeGrokSessionHook(filePath, resolved) {
281
284
  throw error;
282
285
  }
283
286
  }
284
- function patchOpencodeConfig(filePath, resolved, mode) {
287
+ function patchOpencodeConfig(filePath, resolved) {
285
288
  const existing = resolved.hooks.readFile(filePath);
286
289
  if (resolved.skipMissing) {
287
290
  const configDir = path.dirname(filePath);
288
291
  if (!resolved.hooks.pathExists(configDir)) {
289
292
  throw new MissingHarnessError(`opencode config directory not found: ${configDir}`);
290
293
  }
291
- if (mode === "uninstall" && existing === null) {
294
+ if (existing === null) {
292
295
  throw new MissingHarnessError(`opencode config not found: ${filePath}`);
293
296
  }
294
297
  }
295
298
  const config = existing ? parseJsonOrThrow(existing, filePath) : {};
296
299
  const mcp = isPlainObject(config.mcp) ? { ...config.mcp } : {};
297
- if (mode === "install") {
298
- mcp[resolved.serverName] = {
299
- type: "local",
300
- command: [...resolved.serverCommand],
301
- enabled: true
302
- };
303
- }
304
- else {
305
- delete mcp[resolved.serverName];
306
- }
300
+ delete mcp[resolved.serverName];
307
301
  config.mcp = mcp;
308
302
  resolved.hooks.ensureDir(path.dirname(filePath));
309
303
  resolved.hooks.writeFile(filePath, JSON.stringify(config, null, 2) + "\n");
@@ -576,6 +570,8 @@ export async function runAction(action, options = {}) {
576
570
  }
577
571
  }
578
572
  async function inspectExecAction(action, resolved) {
573
+ if (action.inspect)
574
+ return action.inspect();
579
575
  if (!action.operation || !action.serverName)
580
576
  return "unknown";
581
577
  if (action.harness === "gemini") {
@@ -606,6 +602,25 @@ function successStatusForOperation(operation, beforeState) {
606
602
  }
607
603
  function formatMcpActionMessage(action, status, fallback) {
608
604
  if (!action.serverName || !action.operation) {
605
+ if (action.kind === "file-patch") {
606
+ switch (status) {
607
+ case "added":
608
+ return `Installed ${action.filePath}.`;
609
+ case "updated":
610
+ return `Updated ${action.filePath}.`;
611
+ case "already_present":
612
+ return `${action.filePath} is already installed.`;
613
+ case "removed":
614
+ return `Removed ${action.filePath}.`;
615
+ case "already_absent":
616
+ return `${action.filePath} is already absent.`;
617
+ default:
618
+ break;
619
+ }
620
+ }
621
+ if (action.kind === "exec" && status === "already_present") {
622
+ return "skill is already installed.";
623
+ }
609
624
  return fallback ?? "ok";
610
625
  }
611
626
  const target = `MCP server '${action.serverName}'`;
@@ -12,7 +12,7 @@ On freshly invoked multi-agent tasks, give peers a short window to join before d
12
12
 
13
13
  Use phase names in handoffs when they clarify the work: draft, adversarial review, convergence, implementation, implementation review, test review, and release. These phases are vocabulary, not protocol state.
14
14
 
15
- Typical fits are advisory. Claude is usually strong at prose, first-pass synthesis, tool-running, implementation review, and test review. Codex is usually strong at adversarial review, convergence, implementation, edge cases, and release mechanics after operator approval. Gemini and OpenCode start with conservative local guidance until project dogfood says otherwise.
15
+ Claude and Codex are peers of comparable capability; neither outranks the other. Split work evenly between them rather than routing by stereotype, and have all models plan, implement, and evaluate together: any harness can draft, review, converge, implement, or release. Gemini and OpenCode start with conservative local guidance until project dogfood says otherwise.
16
16
 
17
17
  For multi-agent design work, prefer independent read-only drafts first, then adversarial review and convergence. Do not impose a draft file structure on the workspace by default. If scratch draft files are useful, delete superseded pre-convergence drafts after the converged plan exists unless the operator asks to keep them.
18
18
 
@@ -20,11 +20,11 @@ Default to normal release handoffs. Use named assignment only when a specific me
20
20
 
21
21
  ## Claude
22
22
 
23
- Lean into drafting, synthesis, tool-running, implementation review, and test review. Watch for scope creep and messy first-pass artifacts. When implementation belongs elsewhere, make the next phase explicit in the handoff.
23
+ Take a full, even share of planning, implementation, and evaluation. Watch for scope creep and messy first-pass artifacts. Make the next phase explicit in the handoff.
24
24
 
25
25
  ## Codex
26
26
 
27
- Lean into adversarial review, convergence, precise implementation, edge-case sweeps, and release mechanics after operator approval. Watch for over-indexing on mechanics when the operator still needs to decide direction.
27
+ Take a full, even share of planning, implementation, and evaluation. Watch for over-indexing on mechanics when the operator still needs to decide direction. Make the next phase explicit in the handoff.
28
28
 
29
29
  ## Gemini
30
30
 
@@ -151,6 +151,10 @@ export function extractHarnessInstructions(markdown, harness) {
151
151
  sections.get(current)?.push(line);
152
152
  continue;
153
153
  }
154
+ if (sawSection && isMarkdownH2Header(line)) {
155
+ current = null;
156
+ continue;
157
+ }
154
158
  if (!sawSection) {
155
159
  shared.push(line);
156
160
  continue;
@@ -202,6 +206,9 @@ function ensureInstructionFile(filePath) {
202
206
  fs.writeFileSync(filePath, DEFAULT_INSTRUCTIONS_MARKDOWN);
203
207
  return true;
204
208
  }
209
+ function isMarkdownH2Header(line) {
210
+ return /^##\s+.+?\s*$/.test(line);
211
+ }
205
212
  function parseHarnessHeader(line) {
206
213
  const match = line.match(/^##\s+(.+?)\s*$/);
207
214
  if (!match) {
@@ -56,7 +56,11 @@ function inspectSystemProcess(pid, options) {
56
56
  try {
57
57
  const output = (options.execFile ?? defaultExecFile)("ps", ["-o", "ppid=", "-o", "lstart=", "-o", "command=", "-p", String(pid)], {
58
58
  encoding: "utf8",
59
- stdio: ["ignore", "pipe", "ignore"]
59
+ stdio: ["ignore", "pipe", "ignore"],
60
+ env: {
61
+ ...process.env,
62
+ LC_ALL: "C"
63
+ }
60
64
  }).trimEnd();
61
65
  if (!output.trim()) {
62
66
  return null;
package/dist/service.js CHANGED
@@ -1416,10 +1416,13 @@ export class TalkingStickService {
1416
1416
  return null;
1417
1417
  }
1418
1418
  const lastOwnership = this.getLastOwnershipByAgent(roomId);
1419
- const referenceOrdinal = members.find((member) => member.agent_id === afterAgentId)?.ordinal ?? -1;
1419
+ const ordinalRanks = ordinalRankByAgent(members);
1420
+ const referenceRank = afterAgentId
1421
+ ? ordinalRanks.get(afterAgentId) ?? -1
1422
+ : -1;
1420
1423
  return candidates
1421
1424
  .slice()
1422
- .sort((left, right) => compareFairCandidates(left, right, lastOwnership, referenceOrdinal, members.length))[0];
1425
+ .sort((left, right) => compareFairCandidates(left, right, lastOwnership, ordinalRanks, referenceRank, members.length))[0];
1423
1426
  }
1424
1427
  getLastOwnershipByAgent(roomId) {
1425
1428
  const rows = this.db
@@ -1691,7 +1694,7 @@ export class TalkingStickService {
1691
1694
  : "gone";
1692
1695
  state =
1693
1696
  this.hasExpired(room.claim_expires_at, now) &&
1694
- reservedLiveness === "gone"
1697
+ this.isGonePersistent(reservedMember, reservedLiveness, now)
1695
1698
  ? "recipient_gone"
1696
1699
  : "reserved";
1697
1700
  }
@@ -1741,7 +1744,7 @@ export class TalkingStickService {
1741
1744
  : "gone";
1742
1745
  state =
1743
1746
  this.hasExpired(room.claim_expires_at, now) &&
1744
- reservedLiveness === "gone"
1747
+ this.isGonePersistent(reservedMember, reservedLiveness, now)
1745
1748
  ? "recipient_gone"
1746
1749
  : "reserved";
1747
1750
  }
@@ -1899,7 +1902,7 @@ function mapNoteRow(row) {
1899
1902
  resolved_by_agent_id: row.resolved_by_agent_id
1900
1903
  };
1901
1904
  }
1902
- function compareFairCandidates(left, right, lastOwnership, referenceOrdinal, memberCount) {
1905
+ function compareFairCandidates(left, right, lastOwnership, ordinalRanks, referenceRank, memberCount) {
1903
1906
  const leftLastOwned = lastOwnership.get(left.agent_id);
1904
1907
  const rightLastOwned = lastOwnership.get(right.agent_id);
1905
1908
  if (!leftLastOwned && rightLastOwned) {
@@ -1911,18 +1914,24 @@ function compareFairCandidates(left, right, lastOwnership, referenceOrdinal, mem
1911
1914
  if (leftLastOwned && rightLastOwned && leftLastOwned !== rightLastOwned) {
1912
1915
  return Date.parse(leftLastOwned) - Date.parse(rightLastOwned);
1913
1916
  }
1914
- const leftDistance = sequenceDistance(left.ordinal, referenceOrdinal, memberCount);
1915
- const rightDistance = sequenceDistance(right.ordinal, referenceOrdinal, memberCount);
1917
+ const leftDistance = sequenceDistance(ordinalRanks.get(left.agent_id) ?? left.ordinal, referenceRank, memberCount);
1918
+ const rightDistance = sequenceDistance(ordinalRanks.get(right.agent_id) ?? right.ordinal, referenceRank, memberCount);
1916
1919
  if (leftDistance !== rightDistance) {
1917
1920
  return leftDistance - rightDistance;
1918
1921
  }
1919
1922
  return left.ordinal - right.ordinal;
1920
1923
  }
1921
- function sequenceDistance(ordinal, referenceOrdinal, memberCount) {
1922
- if (memberCount <= 0 || referenceOrdinal < 0) {
1923
- return ordinal;
1924
+ function ordinalRankByAgent(members) {
1925
+ return new Map(members
1926
+ .slice()
1927
+ .sort((left, right) => left.ordinal - right.ordinal)
1928
+ .map((member, index) => [member.agent_id, index]));
1929
+ }
1930
+ function sequenceDistance(ordinalRank, referenceRank, memberCount) {
1931
+ if (memberCount <= 0 || referenceRank < 0) {
1932
+ return ordinalRank;
1924
1933
  }
1925
- const distance = (ordinal - referenceOrdinal + memberCount) % memberCount;
1934
+ const distance = (ordinalRank - referenceRank + memberCount) % memberCount;
1926
1935
  return distance === 0 ? memberCount : distance;
1927
1936
  }
1928
1937
  function parseTimestampMs(timestamp) {
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { writeFileAtomic } from "./atomic-write.js";
3
4
  import { resolveDataDir } from "./config.js";
4
5
  import { ancestorPaths, resolveContextPath } from "./path-resolution.js";
5
6
  export function resolveCliSessionPath(options = {}) {
@@ -22,8 +23,7 @@ export function readCliSessions(sessionPath) {
22
23
  }
23
24
  }
24
25
  export function writeCliSessions(sessionPath, sessions) {
25
- fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
26
- fs.writeFileSync(sessionPath, `${JSON.stringify(sessions, null, 2)}\n`);
26
+ writeFileAtomic(sessionPath, `${JSON.stringify(sessions, null, 2)}\n`);
27
27
  }
28
28
  export function upsertCliSession(sessionPath, session) {
29
29
  const sessions = readCliSessions(sessionPath);
@@ -18,7 +18,7 @@ export function resolveSkillTargetPath(harness, options = {}) {
18
18
  case "grok":
19
19
  return path.join(resolveHarnessConfigDir("grok", options), "skills", options.skillName ?? DEFAULT_SKILL_NAME);
20
20
  case "opencode":
21
- return path.join(homeDir, ".opencode", "skills", options.skillName ?? DEFAULT_SKILL_NAME);
21
+ return path.join(resolveHarnessConfigDir("opencode", options), "skills", options.skillName ?? DEFAULT_SKILL_NAME);
22
22
  default:
23
23
  throw new Error(`Unknown skill-install harness: ${harness}`);
24
24
  }
@@ -29,20 +29,25 @@ export function planSkillInstall(harness, options = {}) {
29
29
  const shouldLink = options.link ?? true;
30
30
  ensureSkillSourceExists(sourcePath);
31
31
  if (harness === "gemini") {
32
+ const geminiTargetPath = path.join(resolveHarnessConfigDir("gemini", options), "skills", skillName);
32
33
  return shouldLink
33
34
  ? {
34
35
  kind: "exec",
35
36
  harness,
36
37
  command: "gemini",
37
38
  args: ["skills", "link", sourcePath, "--scope", "user", "--consent"],
38
- description: `gemini skills link ${sourcePath} --scope user --consent`
39
+ description: `gemini skills link ${sourcePath} --scope user --consent`,
40
+ operation: "install",
41
+ inspect: () => inspectInstalledSkill(sourcePath, geminiTargetPath, true)
39
42
  }
40
43
  : {
41
44
  kind: "exec",
42
45
  harness,
43
46
  command: "gemini",
44
47
  args: ["skills", "install", sourcePath, "--scope", "user", "--consent"],
45
- description: `gemini skills install ${sourcePath} --scope user --consent`
48
+ description: `gemini skills install ${sourcePath} --scope user --consent`,
49
+ operation: "install",
50
+ inspect: () => inspectInstalledSkill(sourcePath, geminiTargetPath, false)
46
51
  };
47
52
  }
48
53
  const targetPath = resolveSkillTargetPath(harness, options);
@@ -58,6 +63,8 @@ export function planSkillInstall(harness, options = {}) {
58
63
  description: shouldLink
59
64
  ? `link ${sourcePath} -> ${targetPath}`
60
65
  : `copy ${sourcePath} -> ${targetPath}`,
66
+ operation: "install",
67
+ inspect: () => inspectInstalledSkill(sourcePath, targetPath, shouldLink),
61
68
  apply: () => installSkillDirectory(sourcePath, targetPath, harnessRootPath, shouldLink, options)
62
69
  };
63
70
  }
@@ -177,6 +184,31 @@ function syncInstalledFileSkill(harness, sourcePath, sourceDigest, options) {
177
184
  };
178
185
  }
179
186
  }
187
+ function inspectInstalledSkill(sourcePath, targetPath, link) {
188
+ try {
189
+ const stat = fs.lstatSync(targetPath);
190
+ if (link) {
191
+ if (!stat.isSymbolicLink()) {
192
+ return "different";
193
+ }
194
+ const currentTarget = fs.readlinkSync(targetPath);
195
+ const resolvedCurrentTarget = path.resolve(path.dirname(targetPath), currentTarget);
196
+ return sameRealPath(resolvedCurrentTarget, sourcePath)
197
+ ? "present"
198
+ : "different";
199
+ }
200
+ if (stat.isDirectory() && digestDirectory(targetPath) === digestDirectory(sourcePath)) {
201
+ return "present";
202
+ }
203
+ return "different";
204
+ }
205
+ catch (error) {
206
+ if (error.code === "ENOENT") {
207
+ return "absent";
208
+ }
209
+ throw error;
210
+ }
211
+ }
180
212
  function removeInstalledSkill(targetPath, harnessRootPath, options = {}) {
181
213
  const pathExists = options.pathExists ?? fs.existsSync;
182
214
  if (options.skipMissing && harnessRootPath && !pathExists(harnessRootPath)) {
@@ -0,0 +1,28 @@
1
+ # Talking Stick 0.4.11
2
+
3
+ Date: 2026-06-09
4
+
5
+ ## Fixed
6
+ - **Guardian no longer leaks when readiness times out.** `spawnGuardian`'s readiness timeout now kills the detached child and clears its listeners before rejecting. Previously the orphaned guardian survived, wrote `READY` to a stream nobody read, and held the lease indefinitely with no recorded PID for `stopGuardian` to reach. (#31)
7
+ - **Fair-turn ordering survives member churn.** Round-robin distance is now computed from each member's rank within the current member list instead of raw join ordinals modulo member count, which inverted ordering once departures left sparse ordinals (e.g. `[0, 5, 7]`). (#32)
8
+ - **Reserved-member liveness gets the same gone grace as owners.** Both room-inspection paths now run the reserved branch through `isGonePersistent`, so a transient process-check misread right after claim expiry can no longer deny the rightful recipient its grant. (#33)
9
+ - **`cli-sessions.json` and harness config patches are written atomically.** Both now write to a temp sibling and `rename`, so a crash or full disk mid-write can no longer truncate the session store or a user-owned config such as `opencode.json`. (#34)
10
+ - **`ps` lstart parsing is locale-stable.** Process inspection invokes `ps` with `LC_ALL=C`, so non-C locales no longer silently degrade identity resolution and liveness to the weakest fallback. (#35)
11
+ - **Errors are machine-readable in JSON mode.** When `--json` is requested, plain CLI errors serialize as `{"error": "cli_error", "message": ...}` on stderr (matching `ProtocolError`'s shape) instead of bare text, keeping the non-zero exit code. (#36)
12
+ - **Boolean flags never consume positionals.** The CLI parser has a boolean-flag registry, retiring the `--json`-eats-positional footgun and the per-command repair shims; `--after`-style integer options now reject trailing garbage like `100ms`. (#37)
13
+ - **Non-harness `##` headings no longer bleed into harness sections.** In instruction files, an unrecognized `##` heading after harness sections ends the current section; its content is excluded from harness extraction. (#38)
14
+ - **OpenCode skill installs follow the XDG-aware config dir.** The skill now lands next to `opencode.json` (normally `~/.config/opencode/skills/talking-stick`, honoring `XDG_CONFIG_HOME`) instead of the hardcoded `~/.opencode` tree; verified against OpenCode source, which scans both `skill/` and `skills/` under the config dir. (#39)
15
+ - **Restarted Grok sessions mint fresh identity.** When PID identity is available but matches no recorded session, the workspace-candidate fallback no longer hands the new process the previous session's identity while the old `session_end` hook is pending. (#40)
16
+ - **`tt install` is idempotent for skills.** Skill install actions carry `operation`/`inspect`, so a second run reports `already_present` instead of deleting and re-copying the skill directory every time. (#41)
17
+ - **Docs drift from the MCP-to-skill migration cleaned up.** AGENTS.md/README no longer reference the removed `src/mcp-server.ts` entry point or stale install paths; `patchOpencodeConfig`'s dead install branch is gone and the legacy `tt mcp` command constant is marked as match-only. (#42)
18
+
19
+ ## Verification
20
+
21
+ ```bash
22
+ npm run typecheck
23
+ npm test
24
+ npm run build
25
+ node dist/cli.js --help
26
+ git diff --check
27
+ npm pack --dry-run
28
+ ```
@@ -0,0 +1,17 @@
1
+ # Talking Stick 0.4.12
2
+
3
+ Date: 2026-06-09
4
+
5
+ ## Fixed
6
+ - **No-op `tt install` runs are quiet.** Legacy MCP cleanup lines are only printed when an entry was actually removed, preserved, or failed — `absent`/`skipped` no-ops stay silent. The Grok session hook and Gemini skill installs now inspect their targets and report `already_present` instead of rewriting (`ok: Updated ...`) or re-linking (`added: ok`) on every run, and the `tt instructions edit` hint only prints when an install actually changed something.
7
+
8
+ ## Verification
9
+
10
+ ```bash
11
+ npm run typecheck
12
+ npm test
13
+ npm run build
14
+ node dist/cli.js --help
15
+ git diff --check
16
+ npm pack --dry-run
17
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talking-stick",
3
- "version": "0.4.10",
3
+ "version": "0.4.12",
4
4
  "description": "CLI coordination tool for path-scoped agent handoffs.",
5
5
  "type": "module",
6
6
  "bin": {