talking-stick 0.4.9 → 0.4.11

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/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);
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { MissingHarnessError, resolveHarnessConfigDir, skipAction } from "./install.js";
6
6
  export const DEFAULT_SKILL_NAME = "talking-stick";
7
- const FILE_SKILL_HARNESSES = ["claude-code", "codex", "opencode"];
7
+ const FILE_SKILL_HARNESSES = ["claude-code", "codex", "grok", "opencode"];
8
8
  export function resolveBundledSkillPath(options = {}) {
9
9
  return options.sourcePath ?? path.resolve(currentPackageDir(), "skills", DEFAULT_SKILL_NAME);
10
10
  }
@@ -15,8 +15,10 @@ export function resolveSkillTargetPath(harness, options = {}) {
15
15
  return path.join(homeDir, ".claude", "skills", options.skillName ?? DEFAULT_SKILL_NAME);
16
16
  case "codex":
17
17
  return path.join(homeDir, ".codex", "skills", options.skillName ?? DEFAULT_SKILL_NAME);
18
+ case "grok":
19
+ return path.join(resolveHarnessConfigDir("grok", options), "skills", options.skillName ?? DEFAULT_SKILL_NAME);
18
20
  case "opencode":
19
- return path.join(homeDir, ".opencode", "skills", options.skillName ?? DEFAULT_SKILL_NAME);
21
+ return path.join(resolveHarnessConfigDir("opencode", options), "skills", options.skillName ?? DEFAULT_SKILL_NAME);
20
22
  default:
21
23
  throw new Error(`Unknown skill-install harness: ${harness}`);
22
24
  }
@@ -33,14 +35,16 @@ export function planSkillInstall(harness, options = {}) {
33
35
  harness,
34
36
  command: "gemini",
35
37
  args: ["skills", "link", sourcePath, "--scope", "user", "--consent"],
36
- description: `gemini skills link ${sourcePath} --scope user --consent`
38
+ description: `gemini skills link ${sourcePath} --scope user --consent`,
39
+ operation: "install"
37
40
  }
38
41
  : {
39
42
  kind: "exec",
40
43
  harness,
41
44
  command: "gemini",
42
45
  args: ["skills", "install", sourcePath, "--scope", "user", "--consent"],
43
- description: `gemini skills install ${sourcePath} --scope user --consent`
46
+ description: `gemini skills install ${sourcePath} --scope user --consent`,
47
+ operation: "install"
44
48
  };
45
49
  }
46
50
  const targetPath = resolveSkillTargetPath(harness, options);
@@ -56,6 +60,8 @@ export function planSkillInstall(harness, options = {}) {
56
60
  description: shouldLink
57
61
  ? `link ${sourcePath} -> ${targetPath}`
58
62
  : `copy ${sourcePath} -> ${targetPath}`,
63
+ operation: "install",
64
+ inspect: () => inspectInstalledSkill(sourcePath, targetPath, shouldLink),
59
65
  apply: () => installSkillDirectory(sourcePath, targetPath, harnessRootPath, shouldLink, options)
60
66
  };
61
67
  }
@@ -175,6 +181,31 @@ function syncInstalledFileSkill(harness, sourcePath, sourceDigest, options) {
175
181
  };
176
182
  }
177
183
  }
184
+ function inspectInstalledSkill(sourcePath, targetPath, link) {
185
+ try {
186
+ const stat = fs.lstatSync(targetPath);
187
+ if (link) {
188
+ if (!stat.isSymbolicLink()) {
189
+ return "different";
190
+ }
191
+ const currentTarget = fs.readlinkSync(targetPath);
192
+ const resolvedCurrentTarget = path.resolve(path.dirname(targetPath), currentTarget);
193
+ return sameRealPath(resolvedCurrentTarget, sourcePath)
194
+ ? "present"
195
+ : "different";
196
+ }
197
+ if (stat.isDirectory() && digestDirectory(targetPath) === digestDirectory(sourcePath)) {
198
+ return "present";
199
+ }
200
+ return "different";
201
+ }
202
+ catch (error) {
203
+ if (error.code === "ENOENT") {
204
+ return "absent";
205
+ }
206
+ throw error;
207
+ }
208
+ }
178
209
  function removeInstalledSkill(targetPath, harnessRootPath, options = {}) {
179
210
  const pathExists = options.pathExists ?? fs.existsSync;
180
211
  if (options.skipMissing && harnessRootPath && !pathExists(harnessRootPath)) {
@@ -0,0 +1,17 @@
1
+ # Talking Stick 0.4.10
2
+
3
+ Date: 2026-06-08
4
+
5
+ ## Added
6
+ - **Grok Build harness support.** `tt install grok` now installs the native `~/.grok/skills/talking-stick` skill and a trusted global `~/.grok/hooks/talking-stick-session.json` hook. Grok-launched `tt` calls work without cmux by detecting a `grok` root process in ancestry; `CMUX_AGENT_LAUNCH_KIND=grok` remains optional fast evidence when present. The hook records `GROK_SESSION_ID` context in `${TALKING_STICK_DATA_DIR}/grok-sessions.jsonl` so identity can upgrade from pid-root identity to the real Grok session id when the record matches the workspace and harness process.
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
+ ```
@@ -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
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talking-stick",
3
- "version": "0.4.9",
3
+ "version": "0.4.11",
4
4
  "description": "CLI coordination tool for path-scoped agent handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: talking-stick
3
- description: Use when working in a repo that coordinates multiple agent harnesses with Talking Stick (`tt` / `talking-stick`), or when the user asks you to avoid parallel work, wait your turn, pass structured handoffs, or coordinate with Claude, Codex, Gemini, or OpenCode in the same workspace. Also use when a workspace contains a `.talking-stick/` marker.
3
+ description: Use when working in a repo that coordinates multiple agent harnesses with Talking Stick (`tt` / `talking-stick`), or when the user asks you to avoid parallel work, wait your turn, pass structured handoffs, or coordinate with Claude, Codex, Gemini, Grok, or OpenCode in the same workspace. Also use when a workspace contains a `.talking-stick/` marker.
4
4
  ---
5
5
 
6
6
  This skill teaches a harness how to behave in a Talking Stick workspace.
@@ -49,7 +49,7 @@ Some workspaces may also have sibling receive processes running `tt events --fol
49
49
 
50
50
  If coordination is required and `tt` is unavailable, say so briefly and ask the user whether they want to install or enable Talking Stick first. Do not pretend coordination is active.
51
51
 
52
- Human CLI runs silently keep already-installed Claude Code, Codex, and OpenCode skill copies/symlinks aligned with the bundled Talking Stick skill. This is best effort and only updates existing installs; Gemini skills are registry-managed and should be refreshed with `tt install gemini` when needed.
52
+ Human CLI runs silently keep already-installed Claude Code, Codex, Grok, and OpenCode skill copies/symlinks aligned with the bundled Talking Stick skill. This is best effort and only updates existing installs; Gemini skills are registry-managed and should be refreshed with `tt install gemini` when needed.
53
53
 
54
54
  ### 2. Join The Workspace Room Once
55
55