talking-stick 0.4.8 → 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
 
@@ -105,7 +105,7 @@ tt msg send/recv — out-of-band chat into the room event log
105
105
  tt instructions — editable collaboration prompt loaded by the skill
106
106
  ```
107
107
 
108
- A workspace maps to a room — usually the `git` root or nearest project marker — so two agents `cd`'d anywhere under the same repo join the same room automatically.
108
+ A workspace maps to a room — usually the `git` root or nearest project marker — so two agents `cd`'d anywhere under the same repo join the same room automatically. Marker files directly in your home directory are ignored for descendant paths, so scratch directories under `$HOME` do not collapse into one broad home-scoped room unless you explicitly join home itself.
109
109
 
110
110
  The global skill tells the model when to join, wait, take over, leave notes, send messages, and hand off.
111
111
 
@@ -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
 
@@ -20,7 +20,11 @@ export async function runEventStream(runtime, parsed, identity, roomId, options)
20
20
  event_type: options.event_type,
21
21
  target_agent_id: targetAgentId,
22
22
  from_agent_id: fromAgentId,
23
- max_wait_ms: follow || wait ? parseWaitTimeout(parsed) : 0
23
+ max_wait_ms: follow || wait ? parseWaitTimeout(parsed) : 0,
24
+ // Carry the caller's identity so a sustained self-receiver registers and
25
+ // refreshes presence (issue #29 Defect 1) — a `tt events --follow` /
26
+ // `--wait` watcher stays visible even if it never ran `tt join`.
27
+ process_metadata: identity.process_metadata
24
28
  };
25
29
  if (!follow) {
26
30
  const result = await runtime.commands.waitForEvents(waitInput);
@@ -38,7 +42,8 @@ export function resolveOptionalAgentSelector(runtime, identity, roomId, raw) {
38
42
  export function resolveAgentSelector(runtime, identity, roomId, raw) {
39
43
  const members = runtime.commands.getRoomState({
40
44
  room_id: roomId,
41
- agent_id: identity.agent_id
45
+ agent_id: identity.agent_id,
46
+ process_metadata: identity.process_metadata
42
47
  }).members;
43
48
  const exact = members.find((member) => member.agent_id === raw);
44
49
  if (exact) {
@@ -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
+ }
@@ -14,11 +14,12 @@ export async function runGuardCommand(parsed) {
14
14
  displayName: requireStringOption(parsed, "agent").replace(/^human:/, ""),
15
15
  sessionKind: "human_guardian"
16
16
  });
17
+ const harnessMetadata = parseHarnessMetadataOptions(parsed);
17
18
  const identity = {
18
19
  ...baseIdentity,
19
20
  process_metadata: {
20
21
  ...baseIdentity.process_metadata,
21
- ...parseHarnessMetadataOptions(parsed)
22
+ ...harnessMetadata
22
23
  }
23
24
  };
24
25
  const runtime = createRuntime();
@@ -32,8 +33,28 @@ export async function runGuardCommand(parsed) {
32
33
  expected_turn_id: parseRequiredInteger(parsed, "turn-id")
33
34
  };
34
35
  const intervalMs = joined.policy.heartbeatIntervalMs;
36
+ const harnessRef = {
37
+ pid: harnessMetadata.harness_pid,
38
+ process_started_at: harnessMetadata.harness_process_started_at
39
+ };
40
+ const inspector = createSystemProcessInspector();
35
41
  process.stdout.write(`${GUARD_READY}\n`);
36
42
  const timer = setInterval(() => {
43
+ // Tier-1 stale-guardian purge: if our own harness process is provably
44
+ // gone, surrender the turn instead of renewing the lease forever. This is
45
+ // the definitive case (no timeout): an orphaned guardian must not pin the
46
+ // stick once the harness it represents has exited. `unknown`/`alive` both
47
+ // fall through to the normal heartbeat; we only act on a definite `gone`.
48
+ if (checkGuardianLiveness(harnessRef, inspector) === "gone") {
49
+ try {
50
+ runtime.commands.relinquishOwnership(identity, heartbeatInput);
51
+ }
52
+ catch {
53
+ // Best effort: a takeover or graceful release may have already moved
54
+ // the turn on. Either way the harness is gone, so we exit.
55
+ }
56
+ process.exit(0);
57
+ }
37
58
  try {
38
59
  runtime.commands.heartbeat(identity, heartbeatInput);
39
60
  }
@@ -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,
@@ -95,7 +95,8 @@ export function handleStateCommand(runtime, parsed) {
95
95
  const session = resolveSessionForReads(runtime, parsed, identity);
96
96
  const state = runtime.commands.getRoomState({
97
97
  room_id: session.room_id,
98
- agent_id: identity.agent_id
98
+ agent_id: identity.agent_id,
99
+ process_metadata: identity.process_metadata
99
100
  });
100
101
  printResult(parsed, { room: state.room, members: state.members }, () => {
101
102
  const lines = [
@@ -144,7 +145,8 @@ export async function handleEventsCommand(runtime, parsed) {
144
145
  room_id: session.room_id,
145
146
  agent_id: identity.agent_id,
146
147
  after_event_seq: parseOptionalInteger(parsed, "after"),
147
- limit: parseOptionalInteger(parsed, "limit")
148
+ limit: parseOptionalInteger(parsed, "limit"),
149
+ process_metadata: identity.process_metadata
148
150
  });
149
151
  printResult(parsed, events, () => {
150
152
  if (events.length === 0) {
@@ -245,7 +245,8 @@ function resolveAssignmentTarget(runtime, identity, session, selector) {
245
245
  }
246
246
  const state = runtime.commands.getRoomState({
247
247
  room_id: session.room_id,
248
- agent_id: identity.agent_id
248
+ agent_id: identity.agent_id,
249
+ process_metadata: identity.process_metadata
249
250
  });
250
251
  const normalizedSelector = selector.toLowerCase();
251
252
  const candidates = state.members.filter((member) => {
@@ -265,7 +266,8 @@ function resolveAssignmentTarget(runtime, identity, session, selector) {
265
266
  const events = runtime.commands.getRoomEvents({
266
267
  room_id: session.room_id,
267
268
  agent_id: identity.agent_id,
268
- limit: 500
269
+ limit: 500,
270
+ process_metadata: identity.process_metadata
269
271
  });
270
272
  return pickFairAssignmentCandidate(candidates, events).agent_id;
271
273
  }
package/dist/commands.js CHANGED
@@ -41,7 +41,8 @@ export class TalkingStickCommands {
41
41
  auto_claim: input.auto_claim,
42
42
  include_events: input.include_events,
43
43
  after_event_seq: input.after_event_seq,
44
- target_agent_id: input.target_agent_id
44
+ target_agent_id: input.target_agent_id,
45
+ process_metadata: identity.process_metadata
45
46
  });
46
47
  }
47
48
  heartbeat(identity, input) {
@@ -52,6 +53,14 @@ export class TalkingStickCommands {
52
53
  expected_turn_id: input.expected_turn_id
53
54
  });
54
55
  }
56
+ relinquishOwnership(identity, input) {
57
+ return this.service.relinquishOwnership({
58
+ agent_id: identity.agent_id,
59
+ room_id: input.room_id,
60
+ lease_id: input.lease_id,
61
+ expected_turn_id: input.expected_turn_id
62
+ });
63
+ }
55
64
  releaseStick(identity, input) {
56
65
  return this.service.releaseStick({
57
66
  agent_id: identity.agent_id,
@@ -92,7 +101,8 @@ export class TalkingStickCommands {
92
101
  room_id: input.room_id,
93
102
  body: input.body,
94
103
  to_agent_id: input.to_agent_id,
95
- delivery_hint: input.delivery_hint
104
+ delivery_hint: input.delivery_hint,
105
+ process_metadata: identity.process_metadata
96
106
  });
97
107
  }
98
108
  waitForEvents(input) {
@@ -106,7 +116,8 @@ export class TalkingStickCommands {
106
116
  agent_id: identity.agent_id,
107
117
  room_id: input.room_id,
108
118
  body: input.body,
109
- turn_id: input.turn_id
119
+ turn_id: input.turn_id,
120
+ process_metadata: identity.process_metadata
110
121
  });
111
122
  }
112
123
  listNotes(identity, input) {
@@ -115,7 +126,8 @@ export class TalkingStickCommands {
115
126
  agent_id: identity?.agent_id,
116
127
  after_note_id: input.after_note_id,
117
128
  include_resolved: input.include_resolved,
118
- limit: input.limit
129
+ limit: input.limit,
130
+ process_metadata: identity?.process_metadata
119
131
  });
120
132
  }
121
133
  }
package/dist/config.js CHANGED
@@ -2,6 +2,7 @@ import os from "node:os";
2
2
  import path from "node:path";
3
3
  export const defaultPolicy = {
4
4
  ownerLeaseTtlMs: 45 * 60 * 1000,
5
+ ownerActivityTtlMs: 45 * 60 * 1000,
5
6
  heartbeatIntervalMs: 5 * 60 * 1000,
6
7
  claimTtlMs: 20 * 60 * 1000,
7
8
  waitForTurnMaxWaitMs: 110 * 1000,
@@ -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
+ }