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/README.md +8 -6
- package/dist/atomic-write.js +21 -0
- package/dist/cli/grok-session-hook.js +69 -0
- package/dist/cli/guardian.js +45 -9
- package/dist/cli/install-commands.js +34 -19
- package/dist/cli/instructions-commands.js +0 -17
- package/dist/cli/msg-commands.js +0 -9
- package/dist/cli/output.js +1 -1
- package/dist/cli/parser.js +30 -7
- package/dist/cli/registry.js +10 -0
- package/dist/cli/room-commands.js +1 -3
- package/dist/cli/turn-commands.js +1 -3
- package/dist/cli.js +18 -6
- package/dist/grok-session-store.js +143 -0
- package/dist/identity.js +77 -8
- package/dist/index.js +2 -1
- package/dist/install.js +127 -15
- package/dist/instructions.js +19 -4
- package/dist/process-utils.js +5 -1
- package/dist/service.js +20 -11
- package/dist/session-store.js +2 -2
- package/dist/skill-install.js +35 -4
- package/docs/releases/0.4.10.md +17 -0
- package/docs/releases/0.4.11.md +28 -0
- package/package.json +1 -1
- package/skills/talking-stick/SKILL.md +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
253
|
+
const harness = HARNESS_COMMAND_MAPPING[label];
|
|
254
|
+
if (harness) {
|
|
235
255
|
return {
|
|
236
|
-
harness
|
|
237
|
-
sessionId:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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:
|
package/dist/instructions.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/process-utils.js
CHANGED
|
@@ -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;
|