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.
@@ -0,0 +1,143 @@
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
+ if (input.grokPid != null && input.grokProcessStartedAt != null) {
74
+ return null;
75
+ }
76
+ const uniqueSessionIds = new Set(workspaceCandidates.map((record) => record.grok_session_id));
77
+ if (uniqueSessionIds.size === 1) {
78
+ return workspaceCandidates[0] ?? null;
79
+ }
80
+ return null;
81
+ }
82
+ export function isGrokSessionEndEvent(event) {
83
+ return normalizeEventName(event) === "sessionend";
84
+ }
85
+ function parseGrokSessionRecord(value) {
86
+ if (!isObjectRecord(value))
87
+ return null;
88
+ if (value.source !== "grok_hook")
89
+ return null;
90
+ const grokSessionId = nonEmptyString(value.grok_session_id);
91
+ const workspaceRoot = nonEmptyString(value.workspace_root);
92
+ const event = nonEmptyString(value.event);
93
+ const observedAt = nonEmptyString(value.observed_at);
94
+ if (!grokSessionId || !workspaceRoot || !event || !observedAt) {
95
+ return null;
96
+ }
97
+ return {
98
+ source: "grok_hook",
99
+ grok_session_id: grokSessionId,
100
+ workspace_root: workspaceRoot,
101
+ cwd: nullableString(value.cwd),
102
+ event,
103
+ observed_at: observedAt,
104
+ grok_pid: nullableInteger(value.grok_pid),
105
+ grok_process_started_at: nullableString(value.grok_process_started_at)
106
+ };
107
+ }
108
+ function isStaleRecord(record, nowMs, maxAgeMs) {
109
+ const observedAtMs = Date.parse(record.observed_at);
110
+ if (Number.isNaN(observedAtMs))
111
+ return true;
112
+ return nowMs - observedAtMs > maxAgeMs;
113
+ }
114
+ function normalizeWorkspaceRoot(value) {
115
+ const trimmed = value?.trim();
116
+ if (!trimmed)
117
+ return null;
118
+ try {
119
+ return fs.realpathSync.native(trimmed);
120
+ }
121
+ catch {
122
+ return path.resolve(trimmed);
123
+ }
124
+ }
125
+ function normalizeEventName(event) {
126
+ return event.toLowerCase().replace(/[^a-z0-9]/g, "");
127
+ }
128
+ function nonEmptyString(value) {
129
+ return typeof value === "string" && value.trim().length > 0
130
+ ? value
131
+ : null;
132
+ }
133
+ function nullableString(value) {
134
+ return typeof value === "string" && value.trim().length > 0
135
+ ? value
136
+ : null;
137
+ }
138
+ function nullableInteger(value) {
139
+ return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
140
+ }
141
+ function isObjectRecord(value) {
142
+ return typeof value === "object" && value !== null && !Array.isArray(value);
143
+ }
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,19 @@ 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
+ import { writeFileAtomic } from "./atomic-write.js";
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"];
10
+ export const GROK_SESSION_HOOK_FILE = "talking-stick-session.json";
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";
12
+ export const GROK_SESSION_HOOK_EVENTS = [
13
+ "SessionStart",
14
+ "UserPromptSubmit",
15
+ "PreToolUse",
16
+ "SessionEnd"
17
+ ];
8
18
  export class MissingHarnessError extends Error {
9
19
  constructor(message) {
10
20
  super(message);
@@ -60,7 +70,7 @@ function defaultReadFile(filePath) {
60
70
  }
61
71
  }
62
72
  function defaultWriteFile(filePath, data) {
63
- fs.writeFileSync(filePath, data);
73
+ writeFileAtomic(filePath, data);
64
74
  }
65
75
  function defaultEnsureDir(dirPath) {
66
76
  fs.mkdirSync(dirPath, { recursive: true });
@@ -96,11 +106,21 @@ export function resolveHarnessConfigDir(harness, options = {}) {
96
106
  const resolved = resolveOptions(options);
97
107
  return resolveHarnessConfigDirFromResolved(harness, resolved);
98
108
  }
109
+ export function resolveGrokSessionHookPath(options = {}) {
110
+ const resolved = resolveOptions(options);
111
+ return path.join(resolveGrokConfigDirFromResolved(resolved), "hooks", GROK_SESSION_HOOK_FILE);
112
+ }
99
113
  function resolveOpencodeConfigDirFromResolved(resolved) {
100
114
  const xdg = resolved.env.XDG_CONFIG_HOME?.trim();
101
115
  const base = xdg && xdg.length > 0 ? xdg : path.join(resolved.homeDir, ".config");
102
116
  return path.join(base, "opencode");
103
117
  }
118
+ function resolveGrokConfigDirFromResolved(resolved) {
119
+ const grokHome = resolved.env.GROK_HOME?.trim();
120
+ return grokHome && grokHome.length > 0
121
+ ? grokHome
122
+ : path.join(resolved.homeDir, ".grok");
123
+ }
104
124
  function resolveHarnessConfigDirFromResolved(harness, resolved) {
105
125
  switch (harness) {
106
126
  case "claude-code":
@@ -109,6 +129,8 @@ function resolveHarnessConfigDirFromResolved(harness, resolved) {
109
129
  return path.join(resolved.homeDir, ".codex");
110
130
  case "gemini":
111
131
  return path.join(resolved.homeDir, ".gemini");
132
+ case "grok":
133
+ return resolveGrokConfigDirFromResolved(resolved);
112
134
  case "opencode":
113
135
  return resolveOpencodeConfigDirFromResolved(resolved);
114
136
  default:
@@ -157,6 +179,8 @@ export function planUninstall(harness, options = {}) {
157
179
  operation: "uninstall",
158
180
  serverName: resolved.serverName
159
181
  };
182
+ case "grok":
183
+ return skipAction(harness, "legacy Talking Stick cleanup is not applicable for grok");
160
184
  case "opencode": {
161
185
  const filePath = resolveOpencodeConfigPath(options);
162
186
  const configDir = path.dirname(filePath);
@@ -174,7 +198,7 @@ export function planUninstall(harness, options = {}) {
174
198
  operation: "uninstall",
175
199
  serverName: resolved.serverName,
176
200
  inspect: () => inspectOpencodeConfig(filePath, resolved),
177
- apply: () => patchOpencodeConfig(filePath, resolved, "uninstall")
201
+ apply: () => patchOpencodeConfig(filePath, resolved)
178
202
  };
179
203
  }
180
204
  default:
@@ -189,29 +213,90 @@ export function skipAction(harness, message) {
189
213
  message
190
214
  };
191
215
  }
192
- function patchOpencodeConfig(filePath, resolved, mode) {
216
+ export function planGrokSessionHookInstall(options = {}) {
217
+ const resolved = resolveOptions(options);
218
+ const grokConfigDir = resolveGrokConfigDirFromResolved(resolved);
219
+ const filePath = resolveGrokSessionHookPath(options);
220
+ if (resolved.skipMissing && !resolved.hooks.pathExists(grokConfigDir)) {
221
+ return skipAction("grok", `grok config directory not found: ${grokConfigDir}`);
222
+ }
223
+ return {
224
+ kind: "file-patch",
225
+ harness: "grok",
226
+ filePath,
227
+ description: `write Grok session hook ${filePath}`,
228
+ inspect: () => inspectGrokSessionHook(filePath, resolved),
229
+ apply: () => writeGrokSessionHook(filePath, resolved)
230
+ };
231
+ }
232
+ export function planGrokSessionHookUninstall(options = {}) {
233
+ const resolved = resolveOptions(options);
234
+ const grokConfigDir = resolveGrokConfigDirFromResolved(resolved);
235
+ const filePath = resolveGrokSessionHookPath(options);
236
+ if (resolved.skipMissing && !resolved.hooks.pathExists(grokConfigDir)) {
237
+ return skipAction("grok", `grok config directory not found: ${grokConfigDir}`);
238
+ }
239
+ return {
240
+ kind: "file-patch",
241
+ harness: "grok",
242
+ filePath,
243
+ description: `remove Grok session hook ${filePath}`,
244
+ inspect: () => resolved.hooks.readFile(filePath) === null ? "absent" : "present",
245
+ apply: () => removeGrokSessionHook(filePath, resolved)
246
+ };
247
+ }
248
+ export function buildGrokSessionHookConfig() {
249
+ const hook = {
250
+ type: "command",
251
+ command: DEFAULT_GROK_SESSION_HOOK_COMMAND,
252
+ timeout: 5
253
+ };
254
+ const hooks = Object.fromEntries(GROK_SESSION_HOOK_EVENTS.map((event) => [
255
+ event,
256
+ [
257
+ {
258
+ hooks: [hook]
259
+ }
260
+ ]
261
+ ]));
262
+ return JSON.stringify({ hooks }, null, 2) + "\n";
263
+ }
264
+ function inspectGrokSessionHook(filePath, resolved) {
265
+ const existing = resolved.hooks.readFile(filePath);
266
+ if (existing === null)
267
+ return "absent";
268
+ return existing === buildGrokSessionHookConfig() ? "present" : "different";
269
+ }
270
+ function writeGrokSessionHook(filePath, resolved) {
271
+ resolved.hooks.ensureDir(path.dirname(filePath));
272
+ resolved.hooks.writeFile(filePath, buildGrokSessionHookConfig());
273
+ }
274
+ function removeGrokSessionHook(filePath, resolved) {
275
+ void resolved;
276
+ try {
277
+ fs.rmSync(filePath, { force: true });
278
+ }
279
+ catch (error) {
280
+ if (error.code === "ENOENT") {
281
+ return;
282
+ }
283
+ throw error;
284
+ }
285
+ }
286
+ function patchOpencodeConfig(filePath, resolved) {
193
287
  const existing = resolved.hooks.readFile(filePath);
194
288
  if (resolved.skipMissing) {
195
289
  const configDir = path.dirname(filePath);
196
290
  if (!resolved.hooks.pathExists(configDir)) {
197
291
  throw new MissingHarnessError(`opencode config directory not found: ${configDir}`);
198
292
  }
199
- if (mode === "uninstall" && existing === null) {
293
+ if (existing === null) {
200
294
  throw new MissingHarnessError(`opencode config not found: ${filePath}`);
201
295
  }
202
296
  }
203
297
  const config = existing ? parseJsonOrThrow(existing, filePath) : {};
204
298
  const mcp = isPlainObject(config.mcp) ? { ...config.mcp } : {};
205
- if (mode === "install") {
206
- mcp[resolved.serverName] = {
207
- type: "local",
208
- command: [...resolved.serverCommand],
209
- enabled: true
210
- };
211
- }
212
- else {
213
- delete mcp[resolved.serverName];
214
- }
299
+ delete mcp[resolved.serverName];
215
300
  config.mcp = mcp;
216
301
  resolved.hooks.ensureDir(path.dirname(filePath));
217
302
  resolved.hooks.writeFile(filePath, JSON.stringify(config, null, 2) + "\n");
@@ -305,6 +390,15 @@ export function detectHarness(harness, options = {}) {
305
390
  return { harness, detected: true, evidence: configDir };
306
391
  return { harness, detected: false, evidence: "gemini not on PATH and no config directory" };
307
392
  }
393
+ case "grok": {
394
+ const bin = resolved.hooks.which("grok");
395
+ if (bin)
396
+ return { harness, detected: true, evidence: bin };
397
+ const configDir = resolveHarnessConfigDirFromResolved(harness, resolved);
398
+ if (resolved.hooks.pathExists(configDir))
399
+ return { harness, detected: true, evidence: configDir };
400
+ return { harness, detected: false, evidence: "grok not on PATH and no config directory" };
401
+ }
308
402
  case "opencode": {
309
403
  const bin = resolved.hooks.which("opencode");
310
404
  if (bin)
@@ -505,6 +599,22 @@ function successStatusForOperation(operation, beforeState) {
505
599
  }
506
600
  function formatMcpActionMessage(action, status, fallback) {
507
601
  if (!action.serverName || !action.operation) {
602
+ if (action.kind === "file-patch") {
603
+ switch (status) {
604
+ case "added":
605
+ return `Installed ${action.filePath}.`;
606
+ case "updated":
607
+ return `Updated ${action.filePath}.`;
608
+ case "already_present":
609
+ return `${action.filePath} is already installed.`;
610
+ case "removed":
611
+ return `Removed ${action.filePath}.`;
612
+ case "already_absent":
613
+ return `${action.filePath} is already absent.`;
614
+ default:
615
+ break;
616
+ }
617
+ }
508
618
  return fallback ?? "ok";
509
619
  }
510
620
  const target = `MCP server '${action.serverName}'`;
@@ -534,6 +644,8 @@ function mcpConfigLocation(action) {
534
644
  return "Codex global config";
535
645
  case "gemini":
536
646
  return "Gemini user config";
647
+ case "grok":
648
+ return "Grok config";
537
649
  case "opencode":
538
650
  return "OpenCode config";
539
651
  default:
@@ -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,16 +20,20 @@ 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
 
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
  }
@@ -145,6 +151,10 @@ export function extractHarnessInstructions(markdown, harness) {
145
151
  sections.get(current)?.push(line);
146
152
  continue;
147
153
  }
154
+ if (sawSection && isMarkdownH2Header(line)) {
155
+ current = null;
156
+ continue;
157
+ }
148
158
  if (!sawSection) {
149
159
  shared.push(line);
150
160
  continue;
@@ -196,6 +206,9 @@ function ensureInstructionFile(filePath) {
196
206
  fs.writeFileSync(filePath, DEFAULT_INSTRUCTIONS_MARKDOWN);
197
207
  return true;
198
208
  }
209
+ function isMarkdownH2Header(line) {
210
+ return /^##\s+.+?\s*$/.test(line);
211
+ }
199
212
  function parseHarnessHeader(line) {
200
213
  const match = line.match(/^##\s+(.+?)\s*$/);
201
214
  if (!match) {
@@ -208,6 +221,8 @@ function parseHarnessHeader(line) {
208
221
  return "codex";
209
222
  if (key.startsWith("gemini"))
210
223
  return "gemini";
224
+ if (key.startsWith("grok"))
225
+ return "grok";
211
226
  if (key.startsWith("opencode"))
212
227
  return "opencode";
213
228
  return null;
@@ -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;