talking-stick 0.4.9 → 0.4.10

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
  A CLI coordination tool 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.4.1. Multi-process-safe (SQLite WAL), liveness-aware, no daemon. Supports Claude Code, Codex CLI, Gemini CLI, and OpenCode out of the box. Two agents in the same room can also chat out-of-band — without passing the stick — via `tt msg send/recv`.
5
+ **Version:** 0.4.1. Multi-process-safe (SQLite WAL), liveness-aware, no daemon. Supports Claude Code, Codex CLI, Gemini CLI, Grok Build, and OpenCode out of the box. Two agents in the same room can also chat out-of-band — without passing the stick — via `tt msg send/recv`.
6
6
 
7
7
  ## Quickstart
8
8
 
@@ -166,13 +166,14 @@ For harnesses that only notice completed subprocesses, run `tt events --wait --a
166
166
  - Claude Code: copied or linked into `~/.claude/skills/talking-stick`
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
+ - Grok Build: copied or linked into `~/.grok/skills/talking-stick`, plus a trusted global session hook at `~/.grok/hooks/talking-stick-session.json`
169
170
  - OpenCode: copied or linked into `~/.opencode/skills/talking-stick`
170
171
 
171
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.
172
173
 
173
- Stale MCP cleanup is strict for OpenCode JSON entries: it removes only the canonical `mcp.talking-stick` value with `["tt", "mcp"]` and leaves hand-edited entries alone. Claude Code, Codex, and Gemini cleanup uses their own `mcp remove` commands when the old server name exists. Every cleanup run appends JSONL audit entries to `${TALKING_STICK_DATA_DIR}/update-migrations.log`.
174
+ Stale MCP cleanup is strict for OpenCode JSON entries: it removes only the canonical `mcp.talking-stick` value with `["tt", "mcp"]` and leaves hand-edited entries alone. Claude Code, Codex, and Gemini cleanup uses their own `mcp remove` commands when the old server name exists. Grok Build has no Talking Stick MCP registration path; install is native skill plus hook only. Every cleanup run appends JSONL audit entries to `${TALKING_STICK_DATA_DIR}/update-migrations.log`.
174
175
 
175
- Human CLI invocations also perform a silent best-effort sync for already-installed file-based skills in Claude Code, Codex, and OpenCode. If the installed skill is a copy, it is refreshed from the bundled skill; if it is a stale symlink, it is relinked. Missing harness config directories and missing skill installs are skipped. Gemini skills are managed by Gemini's own registry, so use `tt install gemini` after updating when needed.
176
+ Human CLI invocations also perform a silent best-effort sync for already-installed file-based skills in Claude Code, Codex, Grok Build, and OpenCode. If the installed skill is a copy, it is refreshed from the bundled skill; if it is a stale symlink, it is relinked. Missing harness config directories and missing skill installs are skipped. Gemini skills are managed by Gemini's own registry, so use `tt install gemini` after updating when needed.
176
177
 
177
178
  ## Human CLI
178
179
 
@@ -189,7 +190,7 @@ tt state [path] # full room state
189
190
  tt events [path] [--after N] [--limit N] [--wait|--follow] [--event TYPE[,TYPE]] [--target self|any|agent] # room event log; --wait/--follow long-polls
190
191
  tt msg send <recipient|room> <body...> [--interrupt] [--stdin] [--path DIR] # send an OOB message
191
192
  tt msg recv [--wait|--follow] [--from agent] [--after N] [--target self|any|agent] [--path DIR] # receive OOB messages
192
- tt instructions show [path] [--harness claude|codex|gemini|opencode|all] [--scope effective|bundled|user|project] # show collaboration prompt
193
+ tt instructions show [path] [--harness claude|codex|gemini|grok|opencode|all] [--scope effective|bundled|user|project] # show collaboration prompt
193
194
  tt instructions edit [path] [--user|--project] # edit user or project prompt
194
195
  tt instructions reset [path] (--user|--project) # delete a user or project prompt
195
196
  tt release [path] --status TEXT --next-action TEXT # normal handoff
@@ -216,7 +217,8 @@ By default, `tt` behaves like a human CLI and resolves to `human:<username>` onl
216
217
 
217
218
  Harness-aware CLI identity is resolved before the human fallback:
218
219
 
219
- - 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.
220
+ - Known harness environment markers such as `CLAUDECODE=1`, `CODEX_THREAD_ID`, `GEMINI_CLI=1`, `CMUX_AGENT_LAUNCH_KIND=grok`, or `OPENCODE=1` make `tt` derive a harness-style identity automatically. The cmux Grok marker is optional; Grok Build also works without cmux by walking process ancestry for a `grok` root process.
221
+ - Grok Build's installed hook records hook-only `GROK_SESSION_ID` context into `${TALKING_STICK_DATA_DIR}/grok-sessions.jsonl`, letting later Grok-launched `tt` calls upgrade from process identity to the real Grok session id. `GROK_SESSION_ID` by itself is not treated as a normal shell marker, and the hook is not required for basic Grok detection.
220
222
  - Set `TT_HARNESS_AGENT_ID=<agent-id>` if the harness wants to export the exact agent id directly.
221
223
  - Set `TT_HARNESS_EXPORT=1` only when you need ancestry-based harness detection without a known harness environment marker.
222
224
 
@@ -0,0 +1,69 @@
1
+ import { createSystemProcessInspector } from "../process-utils.js";
2
+ import { appendGrokSessionRecord } from "../grok-session-store.js";
3
+ import { findHarnessRootInAncestry } from "../identity.js";
4
+ export async function runGrokSessionHookCommand(options = {}) {
5
+ try {
6
+ const env = options.env ?? process.env;
7
+ const input = parseHookInput(options.stdin ?? await readStdin());
8
+ const sessionId = firstNonEmptyString(env.GROK_SESSION_ID, input.sessionId);
9
+ const workspaceRoot = firstNonEmptyString(env.GROK_WORKSPACE_ROOT, env.CLAUDE_PROJECT_DIR, input.workspaceRoot);
10
+ if (!sessionId || !workspaceRoot) {
11
+ return;
12
+ }
13
+ const event = firstNonEmptyString(env.GROK_HOOK_EVENT, input.hookEventName) ?? "unknown";
14
+ const inspector = options.inspector ?? createSystemProcessInspector();
15
+ const parentPid = options.parentPid ?? process.ppid;
16
+ const parentInspection = inspector.inspect(parentPid);
17
+ const grokRoot = findHarnessRootInAncestry("grok", parentPid, parentInspection, inspector, 20);
18
+ const record = {
19
+ source: "grok_hook",
20
+ grok_session_id: sessionId,
21
+ workspace_root: workspaceRoot,
22
+ cwd: firstNonEmptyString(input.cwd),
23
+ event,
24
+ observed_at: firstNonEmptyString(input.timestamp) ??
25
+ (options.now ?? new Date()).toISOString(),
26
+ grok_pid: grokRoot?.pid ?? null,
27
+ grok_process_started_at: grokRoot?.startTime ?? null
28
+ };
29
+ appendGrokSessionRecord(record, { logPath: options.logPath });
30
+ }
31
+ catch {
32
+ // Grok hooks must fail open. Identity can fall back to pid-root detection
33
+ // when the hook cannot record a session row.
34
+ }
35
+ }
36
+ function parseHookInput(raw) {
37
+ const trimmed = raw.trim();
38
+ if (!trimmed)
39
+ return {};
40
+ try {
41
+ const parsed = JSON.parse(trimmed);
42
+ return isObjectRecord(parsed) ? parsed : {};
43
+ }
44
+ catch {
45
+ return {};
46
+ }
47
+ }
48
+ function readStdin() {
49
+ return new Promise((resolve, reject) => {
50
+ let raw = "";
51
+ process.stdin.setEncoding("utf8");
52
+ process.stdin.on("data", (chunk) => {
53
+ raw += chunk;
54
+ });
55
+ process.stdin.on("error", reject);
56
+ process.stdin.on("end", () => resolve(raw));
57
+ });
58
+ }
59
+ function firstNonEmptyString(...values) {
60
+ for (const value of values) {
61
+ if (typeof value === "string" && value.trim().length > 0) {
62
+ return value.trim();
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+ function isObjectRecord(value) {
68
+ return typeof value === "object" && value !== null && !Array.isArray(value);
69
+ }
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { SUPPORTED_HARNESSES, detectHarness, parseHarnessList, planUninstall, runAction } from "../install.js";
2
+ import { SUPPORTED_HARNESSES, detectHarness, parseHarnessList, planGrokSessionHookInstall, planGrokSessionHookUninstall, planUninstall, runAction } from "../install.js";
3
3
  import { planSkillInstall, planSkillUninstall } from "../skill-install.js";
4
4
  import { resolveDataDir } from "../config.js";
5
5
  import { FileAuditLog, defaultAuditLogPath } from "../install-audit.js";
@@ -145,7 +145,7 @@ function resolveSkillInstallLinkMode(parsed) {
145
145
  return true;
146
146
  }
147
147
  function planInstallActions(harnesses, installOptions) {
148
- return harnesses.map((harness) => planSkillInstall(harness, installOptions));
148
+ return harnesses.flatMap((harness) => planInstallActionsForHarness(harness, installOptions));
149
149
  }
150
150
  function planUninstallActions(harnesses, installOptions) {
151
151
  return harnesses.flatMap((harness) => [
@@ -153,6 +153,14 @@ function planUninstallActions(harnesses, installOptions) {
153
153
  ...installOptions,
154
154
  skipMissing: false
155
155
  }),
156
+ ...(harness === "grok"
157
+ ? [
158
+ planGrokSessionHookUninstall({
159
+ ...installOptions,
160
+ skipMissing: false
161
+ })
162
+ ]
163
+ : []),
156
164
  planUninstall(harness, installOptions)
157
165
  ]);
158
166
  }
@@ -160,14 +168,31 @@ function planCleanupActions(harnesses, installOptions) {
160
168
  return harnesses.map((harness) => planUninstall(harness, installOptions));
161
169
  }
162
170
  async function runSkillInstall(harness, installOptions) {
163
- const skillAction = planSkillInstall(harness, installOptions);
164
- const skillResult = await runAction(skillAction, installOptions);
165
- return [skillResult];
171
+ const actions = planInstallActionsForHarness(harness, installOptions);
172
+ return Promise.all(actions.map((action) => runAction(action, installOptions)));
166
173
  }
167
174
  async function runSkillUninstall(harness, installOptions) {
168
- const skillAction = planSkillUninstall(harness, installOptions);
169
- const skillResult = await runAction(skillAction, installOptions);
170
- return [skillResult];
175
+ const actions = [
176
+ planSkillUninstall(harness, {
177
+ ...installOptions,
178
+ skipMissing: false
179
+ }),
180
+ ...(harness === "grok"
181
+ ? [
182
+ planGrokSessionHookUninstall({
183
+ ...installOptions,
184
+ skipMissing: false
185
+ })
186
+ ]
187
+ : [])
188
+ ];
189
+ return Promise.all(actions.map((action) => runAction(action, installOptions)));
190
+ }
191
+ function planInstallActionsForHarness(harness, installOptions) {
192
+ return [
193
+ planSkillInstall(harness, installOptions),
194
+ ...(harness === "grok" ? [planGrokSessionHookInstall(installOptions)] : [])
195
+ ];
171
196
  }
172
197
  async function runCleanup(harnesses, reason, installOptions) {
173
198
  const dataDir = resolveDataDir();
@@ -164,7 +164,7 @@ Commands:
164
164
  tt events [path] [--after N] [--limit N] [--wait|--follow] [--event TYPE[,TYPE]] [--target self|any|agent]
165
165
  tt msg send <recipient|room> <body...> [--interrupt] [--stdin] [--path DIR]
166
166
  tt msg recv [--wait|--follow] [--from agent] [--after N] [--target self|any|agent] [--path DIR]
167
- tt instructions show [path] [--harness claude|codex|gemini|opencode|all] [--scope effective|bundled|user|project]
167
+ tt instructions show [path] [--harness claude|codex|gemini|grok|opencode|all] [--scope effective|bundled|user|project]
168
168
  tt instructions edit [path] [--user|--project]
169
169
  tt instructions reset [path] (--user|--project)
170
170
  tt release [path] (--status TEXT --next-action TEXT | --stdin)
@@ -1,4 +1,5 @@
1
1
  import { runGuardCommand } from "./guardian.js";
2
+ import { runGrokSessionHookCommand } from "./grok-session-hook.js";
2
3
  import { runInstallCommand, runMcpMigrationCommand, runSelfUpdateCommand, runUninstallCommand } from "./install-commands.js";
3
4
  import { handleInstructionsCommand } from "./instructions-commands.js";
4
5
  import { handleMsgCommand } from "./msg-commands.js";
@@ -15,6 +16,15 @@ export const COMMAND_REGISTRY = [
15
16
  description: "Run an internal lease heartbeat guardian.",
16
17
  handler: ({ parsed }) => runGuardCommand(parsed)
17
18
  },
19
+ {
20
+ name: "grok-session-hook",
21
+ needsRuntime: false,
22
+ startupMaintenance: false,
23
+ internal: true,
24
+ usage: "tt grok-session-hook",
25
+ description: "Record Grok hook session context for identity resolution.",
26
+ handler: () => runGrokSessionHookCommand()
27
+ },
18
28
  {
19
29
  name: "install",
20
30
  needsRuntime: false,
@@ -0,0 +1,140 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { resolveDataDir } from "./config.js";
4
+ export const DEFAULT_GROK_SESSION_RECORD_MAX_AGE_MS = 4 * 60 * 60 * 1000;
5
+ export function resolveGrokSessionLogPath(options = {}) {
6
+ return path.join(resolveDataDir(options), "grok-sessions.jsonl");
7
+ }
8
+ export function appendGrokSessionRecord(record, options = {}) {
9
+ const logPath = options.logPath ?? resolveGrokSessionLogPath(options.dataDirOptions ?? {});
10
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
11
+ fs.appendFileSync(logPath, `${JSON.stringify(record)}\n`, "utf8");
12
+ }
13
+ export function readGrokSessionRecords(logPath) {
14
+ let raw;
15
+ try {
16
+ raw = fs.readFileSync(logPath, "utf8");
17
+ }
18
+ catch (error) {
19
+ if (error.code === "ENOENT") {
20
+ return [];
21
+ }
22
+ throw error;
23
+ }
24
+ const records = [];
25
+ for (const line of raw.split("\n")) {
26
+ if (!line.trim())
27
+ continue;
28
+ try {
29
+ const parsed = JSON.parse(line);
30
+ const record = parseGrokSessionRecord(parsed);
31
+ if (record)
32
+ records.push(record);
33
+ }
34
+ catch {
35
+ // Hook logs are append-only and best-effort; one bad line should not
36
+ // break identity resolution for the whole session.
37
+ }
38
+ }
39
+ return records;
40
+ }
41
+ export function findGrokSessionRecord(input) {
42
+ const workspaceRoot = normalizeWorkspaceRoot(input.workspaceRoot);
43
+ if (!workspaceRoot)
44
+ return null;
45
+ const logPath = input.logPath ?? resolveGrokSessionLogPath();
46
+ const nowMs = input.now?.getTime() ?? Date.now();
47
+ const maxAgeMs = input.maxAgeMs ?? DEFAULT_GROK_SESSION_RECORD_MAX_AGE_MS;
48
+ const records = readGrokSessionRecords(logPath);
49
+ const endedSessionIds = new Set();
50
+ const workspaceCandidates = [];
51
+ for (const record of records.slice().reverse()) {
52
+ if (normalizeWorkspaceRoot(record.workspace_root) !== workspaceRoot) {
53
+ continue;
54
+ }
55
+ if (isStaleRecord(record, nowMs, maxAgeMs)) {
56
+ continue;
57
+ }
58
+ if (isGrokSessionEndEvent(record.event)) {
59
+ endedSessionIds.add(record.grok_session_id);
60
+ continue;
61
+ }
62
+ if (endedSessionIds.has(record.grok_session_id)) {
63
+ continue;
64
+ }
65
+ workspaceCandidates.push(record);
66
+ if (input.grokPid != null &&
67
+ input.grokProcessStartedAt != null &&
68
+ record.grok_pid === input.grokPid &&
69
+ record.grok_process_started_at === input.grokProcessStartedAt) {
70
+ return record;
71
+ }
72
+ }
73
+ const uniqueSessionIds = new Set(workspaceCandidates.map((record) => record.grok_session_id));
74
+ if (uniqueSessionIds.size === 1) {
75
+ return workspaceCandidates[0] ?? null;
76
+ }
77
+ return null;
78
+ }
79
+ export function isGrokSessionEndEvent(event) {
80
+ return normalizeEventName(event) === "sessionend";
81
+ }
82
+ function parseGrokSessionRecord(value) {
83
+ if (!isObjectRecord(value))
84
+ return null;
85
+ if (value.source !== "grok_hook")
86
+ return null;
87
+ const grokSessionId = nonEmptyString(value.grok_session_id);
88
+ const workspaceRoot = nonEmptyString(value.workspace_root);
89
+ const event = nonEmptyString(value.event);
90
+ const observedAt = nonEmptyString(value.observed_at);
91
+ if (!grokSessionId || !workspaceRoot || !event || !observedAt) {
92
+ return null;
93
+ }
94
+ return {
95
+ source: "grok_hook",
96
+ grok_session_id: grokSessionId,
97
+ workspace_root: workspaceRoot,
98
+ cwd: nullableString(value.cwd),
99
+ event,
100
+ observed_at: observedAt,
101
+ grok_pid: nullableInteger(value.grok_pid),
102
+ grok_process_started_at: nullableString(value.grok_process_started_at)
103
+ };
104
+ }
105
+ function isStaleRecord(record, nowMs, maxAgeMs) {
106
+ const observedAtMs = Date.parse(record.observed_at);
107
+ if (Number.isNaN(observedAtMs))
108
+ return true;
109
+ return nowMs - observedAtMs > maxAgeMs;
110
+ }
111
+ function normalizeWorkspaceRoot(value) {
112
+ const trimmed = value?.trim();
113
+ if (!trimmed)
114
+ return null;
115
+ try {
116
+ return fs.realpathSync.native(trimmed);
117
+ }
118
+ catch {
119
+ return path.resolve(trimmed);
120
+ }
121
+ }
122
+ function normalizeEventName(event) {
123
+ return event.toLowerCase().replace(/[^a-z0-9]/g, "");
124
+ }
125
+ function nonEmptyString(value) {
126
+ return typeof value === "string" && value.trim().length > 0
127
+ ? value
128
+ : null;
129
+ }
130
+ function nullableString(value) {
131
+ return typeof value === "string" && value.trim().length > 0
132
+ ? value
133
+ : null;
134
+ }
135
+ function nullableInteger(value) {
136
+ return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
137
+ }
138
+ function isObjectRecord(value) {
139
+ return typeof value === "object" && value !== null && !Array.isArray(value);
140
+ }
package/dist/identity.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import crypto from "node:crypto";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { findGrokSessionRecord, resolveGrokSessionLogPath } from "./grok-session-store.js";
5
+ import { resolveContextPath } from "./path-resolution.js";
4
6
  import { createSystemProcessInspector } from "./process-utils.js";
5
7
  const HARNESS_CLI_EXPORT_ENV = "TT_HARNESS_EXPORT";
6
8
  const HARNESS_CLI_AGENT_ID_ENV = "TT_HARNESS_AGENT_ID";
@@ -41,7 +43,11 @@ export function deriveMcpHarnessIdentity(options = {}) {
41
43
  const signal = detectHarnessSignal(env);
42
44
  if (signal) {
43
45
  const processRef = resolveSignalProcessRef(signal, parentPid, parentInspection, inspector);
44
- const sessionId = resolveHarnessSessionId(signal, env, processRef.pid, processRef.inspection, username, hostId, inspector);
46
+ const sessionId = resolveHarnessSessionId(signal, env, processRef.pid, processRef.inspection, username, hostId, inspector, {
47
+ contextPath: options.contextPath,
48
+ grokSessionLogPath: options.grokSessionLogPath,
49
+ now: options.now
50
+ });
45
51
  const harnessProcess = resolveHarnessProcessRef(signal, processRef, inspector);
46
52
  const agentId = options.agentId ?? harnessAgentId(signal.harness, sessionId, hostId, username);
47
53
  return {
@@ -100,7 +106,9 @@ export function deriveHarnessCliIdentity(options = {}) {
100
106
  }
101
107
  let signal = detectHarnessSignal(env);
102
108
  if (!signal && !isHarnessCliExportEnabled(env)) {
103
- return null;
109
+ signal = detectGrokViaAncestry(parentPid, parentInspection, inspector);
110
+ if (!signal)
111
+ return null;
104
112
  }
105
113
  if (!signal) {
106
114
  signal = detectHarnessViaAncestry(parentPid, inspector);
@@ -109,7 +117,11 @@ export function deriveHarnessCliIdentity(options = {}) {
109
117
  return null;
110
118
  const processRef = resolveSignalProcessRef(signal, parentPid, parentInspection, inspector);
111
119
  const username = options.username ?? safeUsername();
112
- const sessionId = resolveHarnessSessionId(signal, env, processRef.pid, processRef.inspection, username, hostId, inspector);
120
+ const sessionId = resolveHarnessSessionId(signal, env, processRef.pid, processRef.inspection, username, hostId, inspector, {
121
+ contextPath: options.contextPath,
122
+ grokSessionLogPath: options.grokSessionLogPath,
123
+ now: options.now
124
+ });
113
125
  const agentId = options.agentId ?? harnessAgentId(signal.harness, sessionId, hostId, username);
114
126
  const harnessProcess = resolveHarnessProcessRef(signal, processRef, inspector);
115
127
  return {
@@ -136,10 +148,16 @@ function harnessAgentId(harness, sessionId, hostId, username) {
136
148
  sanitizeIdentityComponent(username)
137
149
  ])}`;
138
150
  }
139
- function resolveHarnessSessionId(signal, env, parentPid, parentInspection, username, hostId, inspector) {
151
+ function resolveHarnessSessionId(signal, env, parentPid, parentInspection, username, hostId, inspector, options = {}) {
140
152
  if (signal.sessionId)
141
153
  return `harness:${signal.sessionId}`;
142
154
  const harnessRoot = findHarnessRootInAncestry(signal.harness, parentPid, parentInspection, inspector);
155
+ if (signal.harness === "grok") {
156
+ const grokSessionId = resolveGrokHookSessionId(env, harnessRoot, options);
157
+ if (grokSessionId) {
158
+ return `harness:${grokSessionId}`;
159
+ }
160
+ }
143
161
  if (harnessRoot) {
144
162
  return `pid:${harnessRoot.pid}@${harnessRoot.startTime}`;
145
163
  }
@@ -165,7 +183,7 @@ function resolveHarnessProcessRef(signal, processRef, inspector) {
165
183
  // process whose command matches the named harness. Anchoring session id to
166
184
  // that root keeps `tt` invocations stable whether they're spawned directly
167
185
  // by the harness (MCP subprocess) or through intermediate shells (CLI shell-out).
168
- function findHarnessRootInAncestry(harness, startPid, startInspection, inspector, maxDepth = 10) {
186
+ export function findHarnessRootInAncestry(harness, startPid, startInspection, inspector, maxDepth = 10) {
169
187
  let result = null;
170
188
  let currentPid = startPid;
171
189
  let currentInspection = startInspection;
@@ -220,6 +238,7 @@ const HARNESS_COMMAND_MAPPING = {
220
238
  "claude-code": "claude",
221
239
  codex: "codex",
222
240
  gemini: "gemini",
241
+ grok: "grok",
223
242
  opencode: "opencode"
224
243
  };
225
244
  function detectHarnessViaAncestry(pid, inspector, maxDepth = 10) {
@@ -231,10 +250,13 @@ function detectHarnessViaAncestry(pid, inspector, maxDepth = 10) {
231
250
  if (!inspection)
232
251
  break;
233
252
  const label = deriveCommandLabel(inspection.command);
234
- if (HARNESS_COMMAND_MAPPING[label]) {
253
+ const harness = HARNESS_COMMAND_MAPPING[label];
254
+ if (harness) {
235
255
  return {
236
- harness: HARNESS_COMMAND_MAPPING[label],
237
- sessionId: `pid:${inspection.pid}@${inspection.startTime}`,
256
+ harness,
257
+ sessionId: harness === "grok"
258
+ ? null
259
+ : `pid:${inspection.pid}@${inspection.startTime}`,
238
260
  pidHint: null
239
261
  };
240
262
  }
@@ -267,8 +289,51 @@ function detectHarnessSignal(env) {
267
289
  pidHint: null
268
290
  };
269
291
  }
292
+ const cmuxHarness = resolveCmuxLaunchHarness(env);
293
+ if (cmuxHarness) {
294
+ return {
295
+ harness: cmuxHarness,
296
+ sessionId: null,
297
+ pidHint: null
298
+ };
299
+ }
270
300
  return null;
271
301
  }
302
+ function detectGrokViaAncestry(parentPid, parentInspection, inspector) {
303
+ const grokRoot = findHarnessRootInAncestry("grok", parentPid, parentInspection, inspector, 20);
304
+ return grokRoot
305
+ ? { harness: "grok", sessionId: null, pidHint: null }
306
+ : null;
307
+ }
308
+ function resolveGrokHookSessionId(env, harnessRoot, options) {
309
+ const workspaceRoot = resolveGrokWorkspaceRoot(env, options.contextPath);
310
+ const record = findGrokSessionRecord({
311
+ logPath: options.grokSessionLogPath ??
312
+ resolveGrokSessionLogPath({ env }),
313
+ workspaceRoot,
314
+ grokPid: harnessRoot?.pid ?? null,
315
+ grokProcessStartedAt: harnessRoot?.startTime ?? null,
316
+ now: options.now
317
+ });
318
+ return record?.grok_session_id ?? null;
319
+ }
320
+ function resolveGrokWorkspaceRoot(env, contextPath) {
321
+ const explicit = nonEmpty(env.GROK_WORKSPACE_ROOT) ??
322
+ nonEmpty(env.CLAUDE_PROJECT_DIR);
323
+ if (explicit)
324
+ return path.resolve(explicit);
325
+ const candidate = contextPath ?? nonEmpty(env.PWD) ?? process.cwd();
326
+ try {
327
+ return resolveContextPath(candidate).workspace_root;
328
+ }
329
+ catch {
330
+ return path.resolve(candidate);
331
+ }
332
+ }
333
+ function resolveCmuxLaunchHarness(env) {
334
+ const launchKind = normalizeEnvValue(env.CMUX_AGENT_LAUNCH_KIND);
335
+ return launchKind ? HARNESS_COMMAND_MAPPING[launchKind] ?? null : null;
336
+ }
272
337
  function resolveSignalProcessRef(signal, fallbackPid, fallbackInspection, inspector) {
273
338
  if (signal.pidHint && signal.pidHint !== fallbackPid) {
274
339
  const hintedInspection = inspector.inspect(signal.pidHint);
@@ -294,6 +359,10 @@ function parsePositiveInteger(value) {
294
359
  function nonEmpty(value) {
295
360
  return value && value.trim().length > 0 ? value : null;
296
361
  }
362
+ function normalizeEnvValue(value) {
363
+ const nonBlank = nonEmpty(value);
364
+ return nonBlank ? nonBlank.toLowerCase() : null;
365
+ }
297
366
  function deriveCommandLabel(command) {
298
367
  if (!command) {
299
368
  return "harness";
package/dist/index.js CHANGED
@@ -5,7 +5,8 @@ export { ProtocolError, isProtocolError } from "./errors.js";
5
5
  export { deriveHarnessCliIdentity, deriveHumanCliIdentity, deriveMcpHarnessIdentity } from "./identity.js";
6
6
  export { ancestorPaths, canonicalizeContextPath, resolveContextPath, resolveWorkspaceRoot } from "./path-resolution.js";
7
7
  export { DEFAULT_MAX_INSTRUCTION_FILE_BYTES, DEFAULT_INSTRUCTIONS_MARKDOWN, editInstructions, extractHarnessInstructions, normalizeInstructionHarness, parseInstructionScope, resetInstructions, resolveInstructionHarness, resolveInstructionPaths, showInstructions } from "./instructions.js";
8
- export { SUPPORTED_HARNESSES, MissingHarnessError, detectHarness, parseHarnessList, planUninstall, resolveHarnessConfigDir, resolveOpencodeConfigDir, resolveOpencodeConfigPath, runAction, skipAction } from "./install.js";
8
+ export { SUPPORTED_HARNESSES, buildGrokSessionHookConfig, DEFAULT_GROK_SESSION_HOOK_COMMAND, GROK_SESSION_HOOK_EVENTS, GROK_SESSION_HOOK_FILE, MissingHarnessError, detectHarness, parseHarnessList, planGrokSessionHookInstall, planGrokSessionHookUninstall, planUninstall, resolveGrokSessionHookPath, resolveHarnessConfigDir, resolveOpencodeConfigDir, resolveOpencodeConfigPath, runAction, skipAction } from "./install.js";
9
+ export { DEFAULT_GROK_SESSION_RECORD_MAX_AGE_MS, appendGrokSessionRecord, findGrokSessionRecord, isGrokSessionEndEvent, readGrokSessionRecords, resolveGrokSessionLogPath } from "./grok-session-store.js";
9
10
  export { DEFAULT_SKILL_NAME, planSkillInstall, planSkillUninstall, resolveBundledSkillPath, resolveSkillTargetPath, syncInstalledSkills } from "./skill-install.js";
10
11
  export { readPackageVersion, readUpdateMigrationState, resolveUpdateMigrationStatePath, runFirstRunMcpMigration, runStaleMcpCleanup, writeUpdateMigrationState } from "./update-migration.js";
11
12
  export { createSystemProcessInspector, terminateKnownProcess } from "./process-utils.js";
package/dist/install.js CHANGED
@@ -2,9 +2,17 @@ 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
- export const SUPPORTED_HARNESSES = ["claude-code", "codex", "gemini", "opencode"];
5
+ export const SUPPORTED_HARNESSES = ["claude-code", "codex", "gemini", "grok", "opencode"];
6
6
  export const DEFAULT_SERVER_NAME = "talking-stick";
7
7
  export const DEFAULT_SERVER_COMMAND = ["tt", "mcp"];
8
+ export const GROK_SESSION_HOOK_FILE = "talking-stick-session.json";
9
+ 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";
10
+ export const GROK_SESSION_HOOK_EVENTS = [
11
+ "SessionStart",
12
+ "UserPromptSubmit",
13
+ "PreToolUse",
14
+ "SessionEnd"
15
+ ];
8
16
  export class MissingHarnessError extends Error {
9
17
  constructor(message) {
10
18
  super(message);
@@ -96,11 +104,21 @@ export function resolveHarnessConfigDir(harness, options = {}) {
96
104
  const resolved = resolveOptions(options);
97
105
  return resolveHarnessConfigDirFromResolved(harness, resolved);
98
106
  }
107
+ export function resolveGrokSessionHookPath(options = {}) {
108
+ const resolved = resolveOptions(options);
109
+ return path.join(resolveGrokConfigDirFromResolved(resolved), "hooks", GROK_SESSION_HOOK_FILE);
110
+ }
99
111
  function resolveOpencodeConfigDirFromResolved(resolved) {
100
112
  const xdg = resolved.env.XDG_CONFIG_HOME?.trim();
101
113
  const base = xdg && xdg.length > 0 ? xdg : path.join(resolved.homeDir, ".config");
102
114
  return path.join(base, "opencode");
103
115
  }
116
+ function resolveGrokConfigDirFromResolved(resolved) {
117
+ const grokHome = resolved.env.GROK_HOME?.trim();
118
+ return grokHome && grokHome.length > 0
119
+ ? grokHome
120
+ : path.join(resolved.homeDir, ".grok");
121
+ }
104
122
  function resolveHarnessConfigDirFromResolved(harness, resolved) {
105
123
  switch (harness) {
106
124
  case "claude-code":
@@ -109,6 +127,8 @@ function resolveHarnessConfigDirFromResolved(harness, resolved) {
109
127
  return path.join(resolved.homeDir, ".codex");
110
128
  case "gemini":
111
129
  return path.join(resolved.homeDir, ".gemini");
130
+ case "grok":
131
+ return resolveGrokConfigDirFromResolved(resolved);
112
132
  case "opencode":
113
133
  return resolveOpencodeConfigDirFromResolved(resolved);
114
134
  default:
@@ -157,6 +177,8 @@ export function planUninstall(harness, options = {}) {
157
177
  operation: "uninstall",
158
178
  serverName: resolved.serverName
159
179
  };
180
+ case "grok":
181
+ return skipAction(harness, "legacy Talking Stick cleanup is not applicable for grok");
160
182
  case "opencode": {
161
183
  const filePath = resolveOpencodeConfigPath(options);
162
184
  const configDir = path.dirname(filePath);
@@ -189,6 +211,76 @@ export function skipAction(harness, message) {
189
211
  message
190
212
  };
191
213
  }
214
+ export function planGrokSessionHookInstall(options = {}) {
215
+ const resolved = resolveOptions(options);
216
+ const grokConfigDir = resolveGrokConfigDirFromResolved(resolved);
217
+ const filePath = resolveGrokSessionHookPath(options);
218
+ if (resolved.skipMissing && !resolved.hooks.pathExists(grokConfigDir)) {
219
+ return skipAction("grok", `grok config directory not found: ${grokConfigDir}`);
220
+ }
221
+ return {
222
+ kind: "file-patch",
223
+ harness: "grok",
224
+ filePath,
225
+ description: `write Grok session hook ${filePath}`,
226
+ inspect: () => inspectGrokSessionHook(filePath, resolved),
227
+ apply: () => writeGrokSessionHook(filePath, resolved)
228
+ };
229
+ }
230
+ export function planGrokSessionHookUninstall(options = {}) {
231
+ const resolved = resolveOptions(options);
232
+ const grokConfigDir = resolveGrokConfigDirFromResolved(resolved);
233
+ const filePath = resolveGrokSessionHookPath(options);
234
+ if (resolved.skipMissing && !resolved.hooks.pathExists(grokConfigDir)) {
235
+ return skipAction("grok", `grok config directory not found: ${grokConfigDir}`);
236
+ }
237
+ return {
238
+ kind: "file-patch",
239
+ harness: "grok",
240
+ filePath,
241
+ description: `remove Grok session hook ${filePath}`,
242
+ inspect: () => resolved.hooks.readFile(filePath) === null ? "absent" : "present",
243
+ apply: () => removeGrokSessionHook(filePath, resolved)
244
+ };
245
+ }
246
+ export function buildGrokSessionHookConfig() {
247
+ const hook = {
248
+ type: "command",
249
+ command: DEFAULT_GROK_SESSION_HOOK_COMMAND,
250
+ timeout: 5
251
+ };
252
+ const hooks = Object.fromEntries(GROK_SESSION_HOOK_EVENTS.map((event) => [
253
+ event,
254
+ [
255
+ {
256
+ hooks: [hook]
257
+ }
258
+ ]
259
+ ]));
260
+ return JSON.stringify({ hooks }, null, 2) + "\n";
261
+ }
262
+ function inspectGrokSessionHook(filePath, resolved) {
263
+ const existing = resolved.hooks.readFile(filePath);
264
+ if (existing === null)
265
+ return "absent";
266
+ return existing === buildGrokSessionHookConfig() ? "present" : "different";
267
+ }
268
+ function writeGrokSessionHook(filePath, resolved) {
269
+ resolved.hooks.ensureDir(path.dirname(filePath));
270
+ resolved.hooks.writeFile(filePath, buildGrokSessionHookConfig());
271
+ }
272
+ function removeGrokSessionHook(filePath, resolved) {
273
+ void resolved;
274
+ try {
275
+ fs.rmSync(filePath, { force: true });
276
+ }
277
+ catch (error) {
278
+ if (error.code === "ENOENT") {
279
+ return;
280
+ }
281
+ throw error;
282
+ }
283
+ }
192
284
  function patchOpencodeConfig(filePath, resolved, mode) {
193
285
  const existing = resolved.hooks.readFile(filePath);
194
286
  if (resolved.skipMissing) {
@@ -305,6 +397,15 @@ export function detectHarness(harness, options = {}) {
305
397
  return { harness, detected: true, evidence: configDir };
306
398
  return { harness, detected: false, evidence: "gemini not on PATH and no config directory" };
307
399
  }
400
+ case "grok": {
401
+ const bin = resolved.hooks.which("grok");
402
+ if (bin)
403
+ return { harness, detected: true, evidence: bin };
404
+ const configDir = resolveHarnessConfigDirFromResolved(harness, resolved);
405
+ if (resolved.hooks.pathExists(configDir))
406
+ return { harness, detected: true, evidence: configDir };
407
+ return { harness, detected: false, evidence: "grok not on PATH and no config directory" };
408
+ }
308
409
  case "opencode": {
309
410
  const bin = resolved.hooks.which("opencode");
310
411
  if (bin)
@@ -534,6 +635,8 @@ function mcpConfigLocation(action) {
534
635
  return "Codex global config";
535
636
  case "gemini":
536
637
  return "Gemini user config";
638
+ case "grok":
639
+ return "Grok config";
537
640
  case "opencode":
538
641
  return "OpenCode config";
539
642
  default:
@@ -30,6 +30,10 @@ Lean into adversarial review, convergence, precise implementation, edge-case swe
30
30
 
31
31
  Use broad context review and exploration conservatively until the project has stronger Gemini-specific dogfood. Keep handoffs concrete and do not assume responsibility that the operator assigned to another harness.
32
32
 
33
+ ## Grok
34
+
35
+ Use Grok Build as a first-class local coding harness. Keep coordination safety ahead of speed, rely on the native Grok skill and session hook when installed, and keep handoffs concrete when another harness is better positioned to implement or review.
36
+
33
37
  ## OpenCode
34
38
 
35
39
  Use terminal-native local exploration and implementation conservatively until the project has stronger OpenCode-specific dogfood. Keep coordination safety ahead of speed.
@@ -41,6 +45,8 @@ const HARNESS_ALIASES = {
41
45
  "claude-code": "claude",
42
46
  codex: "codex",
43
47
  gemini: "gemini",
48
+ grok: "grok",
49
+ "grok-build": "grok",
44
50
  opencode: "opencode"
45
51
  };
46
52
  export function resolveInstructionPaths(options = {}) {
@@ -108,7 +114,7 @@ export function resolveInstructionHarness(explicitHarness, identity) {
108
114
  export function normalizeInstructionHarness(value) {
109
115
  const normalized = HARNESS_ALIASES[normalizeKey(value)];
110
116
  if (!normalized) {
111
- throw new Error(`--harness must be one of claude, codex, gemini, opencode, all (got ${value}).`);
117
+ throw new Error(`--harness must be one of claude, codex, gemini, grok, opencode, all (got ${value}).`);
112
118
  }
113
119
  return normalized;
114
120
  }
@@ -208,6 +214,8 @@ function parseHarnessHeader(line) {
208
214
  return "codex";
209
215
  if (key.startsWith("gemini"))
210
216
  return "gemini";
217
+ if (key.startsWith("grok"))
218
+ return "grok";
211
219
  if (key.startsWith("opencode"))
212
220
  return "opencode";
213
221
  return null;
@@ -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,6 +15,8 @@ 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
21
  return path.join(homeDir, ".opencode", "skills", options.skillName ?? DEFAULT_SKILL_NAME);
20
22
  default:
@@ -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
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talking-stick",
3
- "version": "0.4.9",
3
+ "version": "0.4.10",
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