throughline 0.10.9 → 0.10.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/CHANGELOG.md CHANGED
@@ -10,6 +10,23 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.10.11] — 2026-09-01
14
+
15
+ ### Fixed
16
+
17
+ - Codex 0.151以降が会話本文を`response_item`だけに記録するrolloutでも、userとassistantの
18
+ 発言をL2へ取り込む。旧`event_msg`が併記されるrolloutでは同じ発言を二重保存しない。
19
+
20
+ ## [0.10.10] — 2026-09-01
21
+
22
+ ### Fixed
23
+
24
+ - Grok 1.0.13がcamelCaseとsnake_caseのhook属性を同時に送る場合でも、Grokの
25
+ 予約環境変数をhost境界として`grok:` sessionへ正規化する。Stop hookをCursorと
26
+ 誤認して同じBotに`cursor:` sessionを作る問題を修正した。
27
+ - `latest-session`と`handoff-context`のread-only SQLite接続にも、書込接続と同じ
28
+ 5秒の競合待ちを適用した。hook書込と再起動時の記憶読取が重なっても、即時失敗しない。
29
+
13
30
  ## [0.10.9] — 2026-09-01
14
31
 
15
32
  ### Fixed
@@ -1397,7 +1414,9 @@ two attempts, instrument first instead of patching again.
1397
1414
 
1398
1415
  ---
1399
1416
 
1400
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.10.9...HEAD
1417
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.10.11...HEAD
1418
+ [0.10.11]: https://github.com/kitepon/Throughline/compare/v0.10.10...v0.10.11
1419
+ [0.10.10]: https://github.com/kitepon/Throughline/compare/v0.10.9...v0.10.10
1401
1420
  [0.10.9]: https://github.com/kitepon/Throughline/compare/v0.10.8...v0.10.9
1402
1421
  [0.10.8]: https://github.com/kitepon/Throughline/compare/v0.10.7...v0.10.8
1403
1422
  [0.10.7]: https://github.com/kitepon/Throughline/compare/v0.10.6...v0.10.7
package/README.md CHANGED
@@ -817,7 +817,7 @@ entry to the `tasks` array yourself:
817
817
 
818
818
  ## Commands
819
819
 
820
- **The current release is v0.10.9.** Throughline supports Claude Code, Codex,
820
+ **The current release is v0.10.11.** Throughline supports Claude Code, Codex,
821
821
  Grok, and Cursor as documented host adapters. The versioned, read-only
822
822
  `handoff-context` boundary opens only an existing database and leaves baton
823
823
  state, session ownership, and memory rows unchanged. Factory diagnostics,
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## 現行状態
8
8
 
9
- - release candidateのpackage版は `0.10.9`。tagとnpmの公開確認はpublish後に行う。
9
+ - release candidateのpackage版は `0.10.11`。tagとnpmの公開確認はpublish後に行う。
10
10
  - Claude Code、Codex、Grok、Cursorをfirst-class hostとして扱う。
11
11
  - 現行DB schemaはv9。schemaの正本は [src/db.mjs](../src/db.mjs) の
12
12
  `CURRENT_VERSION`、二相handoffの判断は
@@ -77,6 +77,7 @@ publish済みでも、上の確認が終わるまではrelease完了としない
77
77
  - Codex: UserPromptSubmit / PostToolUse / Stop hook、feature flags、`$throughline` skill。
78
78
  絶対Node + installed CLI pathを使う。
79
79
  - Grok: `~/.grok/hooks/throughline.json`と`grok-continue`。絶対pathを使う。
80
+ Grok予約環境変数でhostを確定し、camelCase/snake_caseの属性名を同じ境界で正規化する。
80
81
  - Cursor: `~/.cursor/hooks.json`へsessionStart / beforeSubmitPrompt / stopをupsertし、
81
82
  既存hookを保持する。注入は`additional_context`を使う。
82
83
 
@@ -14,8 +14,10 @@ Grok stores turns in `~/.grok/sessions/<encodeURIComponent(cwd)>/<sessionId>/cha
14
14
 
15
15
  ## Decision
16
16
 
17
- - Detect the camelCase envelope (`sessionId` + `hookEventName`, no `session_id`) as host=grok.
18
- - Normalize it to the existing snake_case hook contract and prefix ids with `grok:`.
17
+ - Grokの予約環境変数(`GROK_HOOK_EVENT` / `GROK_SESSION_ID`)をhost境界とする。
18
+ - camelCase (`sessionId` / `hookEventName`) snake_case (`session_id` /
19
+ `hook_event_name`) のどちらも既存hook契約へ正規化し、idへ`grok:`を付ける。
20
+ Grok 1.0.13のように両方が同居してもCursorとして分類しない。
19
21
  - Read L2 from Grok `chat_history.jsonl`. Do not invent a Claude-shaped transcript.
20
22
  - Grok host ignores payload `transcriptPath`. Live Stop sends `updates.jsonl`, which has no user/assistant rows.
21
23
  - Install product hooks at `~/.grok/hooks/throughline.json`. Do not write factory.json.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.10.9",
3
+ "version": "0.10.11",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -1,9 +1,9 @@
1
- import { DatabaseSync } from 'node:sqlite';
2
1
  import { existsSync, readFileSync } from 'node:fs';
3
2
  import { homedir } from 'node:os';
4
3
  import { isAbsolute, join } from 'node:path';
5
4
 
6
5
  import { sameProjectPath } from '../project-path.mjs';
6
+ import { openReadOnlyDb } from '../db.mjs';
7
7
  import {
8
8
  buildBudgetedResumeContext,
9
9
  INJECTION_BUDGET_CHARS,
@@ -62,7 +62,7 @@ export function readHandoffContext(sessionId, {
62
62
  } = {}) {
63
63
  if (!existsSync(dbPath)) return null;
64
64
 
65
- const db = new DatabaseSync(dbPath, { readOnly: true });
65
+ const db = openReadOnlyDb(dbPath);
66
66
  try {
67
67
  let supplementContext = null;
68
68
  if (supplementFile) {
@@ -101,7 +101,7 @@ export function readSessionProjectPath(sessionId, {
101
101
  } = {}) {
102
102
  if (!existsSync(dbPath)) return null;
103
103
 
104
- const db = new DatabaseSync(dbPath, { readOnly: true });
104
+ const db = openReadOnlyDb(dbPath);
105
105
  try {
106
106
  const row = db.prepare(
107
107
  'SELECT project_path FROM sessions WHERE session_id = ?',
@@ -1,8 +1,8 @@
1
- import { DatabaseSync } from 'node:sqlite';
2
1
  import { existsSync } from 'node:fs';
3
2
  import { homedir } from 'node:os';
4
3
  import { join, resolve } from 'node:path';
5
4
 
5
+ import { openReadOnlyDb } from '../db.mjs';
6
6
  import { sameProjectPath } from '../project-path.mjs';
7
7
 
8
8
  export const LATEST_SESSION_SCHEMA = 'throughline.latest_session.v1';
@@ -25,7 +25,7 @@ export function findLatestSession(projectPath, {
25
25
  } = {}) {
26
26
  if (!existsSync(dbPath)) return null;
27
27
 
28
- const db = new DatabaseSync(dbPath, { readOnly: true });
28
+ const db = openReadOnlyDb(dbPath);
29
29
  try {
30
30
  const rows = db.prepare(
31
31
  `SELECT session_id, project_path, updated_at
@@ -1,4 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
+ import { spawn } from 'node:child_process';
3
+ import { once } from 'node:events';
2
4
  import { mkdtempSync, rmSync } from 'node:fs';
3
5
  import { tmpdir } from 'node:os';
4
6
  import { join, resolve } from 'node:path';
@@ -38,6 +40,38 @@ test('同じprojectの最新sessionだけを返す', () => {
38
40
  }
39
41
  });
40
42
 
43
+ test('別hook processの短いwriter lockを待って同じprojectを読む', async () => {
44
+ const root = mkdtempSync(join(tmpdir(), 'throughline-latest-session-lock-'));
45
+ const dbPath = join(root, 'throughline.db');
46
+ const project = join(root, 'bot');
47
+ const db = new DatabaseSync(dbPath);
48
+ db.exec('PRAGMA journal_mode=DELETE; CREATE TABLE sessions (session_id TEXT, project_path TEXT, updated_at TEXT)');
49
+ db.prepare('INSERT INTO sessions VALUES (?, ?, ?)').run('same', project, '2026-09-01T02:00:00Z');
50
+ db.close();
51
+
52
+ const holder = spawn(process.execPath, ['--input-type=module', '-e', `
53
+ import { DatabaseSync } from 'node:sqlite';
54
+ const db = new DatabaseSync(process.argv[1]);
55
+ db.exec('BEGIN EXCLUSIVE');
56
+ process.stdout.write('locked\\n');
57
+ setTimeout(() => { db.exec('COMMIT'); db.close(); }, 200);
58
+ `, dbPath], { stdio: ['ignore', 'pipe', 'pipe'] });
59
+ const holderExit = once(holder, 'exit');
60
+ await new Promise((resolve, reject) => {
61
+ holder.stdout.once('data', resolve);
62
+ holder.once('error', reject);
63
+ });
64
+
65
+ try {
66
+ assert.equal(findLatestSession(project, { dbPath }).session_id, 'same');
67
+ const [code] = await holderExit;
68
+ assert.equal(code, 0);
69
+ } finally {
70
+ if (holder.exitCode === null) holder.kill();
71
+ rmSync(root, { recursive: true, force: true });
72
+ }
73
+ });
74
+
41
75
  test('readyとemptyをJSONで返す', () => {
42
76
  const stdout = output();
43
77
  const stderr = output();
@@ -248,6 +248,69 @@ test('captureCodexRolloutToDb: rebuilds namespaced Codex session from active rol
248
248
  }
249
249
  });
250
250
 
251
+ test('captureCodexRolloutToDb: Codex 0.151 response_item会話を取り込み旧event_msgとの重複を除く', () => {
252
+ const db = makeDb();
253
+ const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
254
+ const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
255
+ const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
256
+ try {
257
+ writeRollout(home, {
258
+ id: threadId,
259
+ cwd: project,
260
+ events: [
261
+ event('task_started'),
262
+ responseItem({
263
+ type: 'message',
264
+ role: 'user',
265
+ content: [{ type: 'input_text', text: 'current request' }],
266
+ }),
267
+ responseItem({
268
+ type: 'message',
269
+ role: 'assistant',
270
+ content: [{ type: 'output_text', text: 'current answer' }],
271
+ }),
272
+ event('task_complete'),
273
+ event('user_message', { message: 'dual request' }),
274
+ event('task_started'),
275
+ responseItem({
276
+ type: 'message',
277
+ role: 'user',
278
+ content: [{ type: 'input_text', text: 'dual request' }],
279
+ }),
280
+ event('agent_message', { message: 'dual answer' }),
281
+ responseItem({
282
+ type: 'message',
283
+ role: 'assistant',
284
+ content: [{ type: 'output_text', text: 'dual answer' }],
285
+ }),
286
+ event('task_complete'),
287
+ ],
288
+ });
289
+
290
+ const result = captureCodexRolloutToDb(db, {
291
+ threadId,
292
+ codexHome: home,
293
+ projectPath: project,
294
+ now: 1234,
295
+ });
296
+
297
+ assert.equal(result.capturedTurns, 2);
298
+ assert.equal(result.capturedRows, 4);
299
+ assert.deepEqual(
300
+ db.prepare('SELECT turn_number, role, text FROM bodies ORDER BY id').all().map((row) => ({ ...row })),
301
+ [
302
+ { turn_number: 1, role: 'user', text: 'current request' },
303
+ { turn_number: 1, role: 'assistant', text: 'current answer' },
304
+ { turn_number: 2, role: 'user', text: 'dual request' },
305
+ { turn_number: 2, role: 'assistant', text: 'dual answer' },
306
+ ],
307
+ );
308
+ } finally {
309
+ rmSync(home, { recursive: true, force: true });
310
+ rmSync(project, { recursive: true, force: true });
311
+ }
312
+ });
313
+
251
314
  test('captureCodexRolloutToDb: second capture removes stale rows from previous active tail', () => {
252
315
  const db = makeDb();
253
316
  const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
@@ -106,21 +106,20 @@ export function parseCodexRolloutFile(
106
106
  }
107
107
 
108
108
  if (row?.type === 'response_item') {
109
- const responseUserMessage = responseItemToUserMessage(payload, row.timestamp);
110
- if (responseUserMessage) {
109
+ const responseMessage = responseItemToConversationMessage(payload, row.timestamp);
110
+ if (responseMessage?.role === 'user') {
111
111
  if (stats.rollbackEvents > 0) {
112
112
  stats.userMessagesAfterRollback++;
113
113
  }
114
114
  notePotentialResurrectedUserMessage({
115
- message: responseUserMessage,
115
+ message: responseMessage,
116
116
  compactedReplacementUserTexts,
117
117
  rolledBackUserTexts,
118
118
  resurrectedUserTexts,
119
119
  stats,
120
120
  });
121
121
  }
122
- const injectedMessage = responseItemToMemoryMessage(payload, row.timestamp);
123
- if (injectedMessage) {
122
+ if (responseMessage?.role === 'developer') {
124
123
  stats.injectedDeveloperMessages++;
125
124
  }
126
125
  const detail = responseItemToDetail(payload, row.timestamp, toolNameByCallId);
@@ -144,6 +143,24 @@ export function parseCodexRolloutFile(
144
143
  pendingDetails.push(detail);
145
144
  }
146
145
  }
146
+ if (responseMessage && responseMessage.role !== 'developer') {
147
+ if (openTurn) {
148
+ appendUniqueMessage(openTurn.messages, responseMessage);
149
+ } else if (afterRollback && responseMessage.role === 'assistant') {
150
+ if (!postRollbackTurn) {
151
+ postRollbackTurn = {
152
+ number: `rollout-${stats.parsedRows}`,
153
+ messages: [],
154
+ details: [],
155
+ completedAt: null,
156
+ };
157
+ activeTurns.push(postRollbackTurn);
158
+ }
159
+ appendUniqueMessage(postRollbackTurn.messages, responseMessage);
160
+ } else {
161
+ appendUniqueMessage(pendingMessages, responseMessage);
162
+ }
163
+ }
147
164
  continue;
148
165
  }
149
166
 
@@ -226,7 +243,7 @@ export function parseCodexRolloutFile(
226
243
  }
227
244
 
228
245
  if (openTurn) {
229
- openTurn.messages.push(message);
246
+ appendUniqueMessage(openTurn.messages, message);
230
247
  } else if (afterRollback && message.role === 'assistant') {
231
248
  if (!postRollbackTurn) {
232
249
  postRollbackTurn = {
@@ -237,9 +254,9 @@ export function parseCodexRolloutFile(
237
254
  };
238
255
  activeTurns.push(postRollbackTurn);
239
256
  }
240
- postRollbackTurn.messages.push(message);
257
+ appendUniqueMessage(postRollbackTurn.messages, message);
241
258
  } else {
242
- pendingMessages.push(message);
259
+ appendUniqueMessage(pendingMessages, message);
243
260
  }
244
261
  }
245
262
 
@@ -490,27 +507,22 @@ function eventPayloadToMemoryMessage(payload, timestamp) {
490
507
  return null;
491
508
  }
492
509
 
493
- function responseItemToMemoryMessage(payload, timestamp) {
494
- if (payload?.type !== 'message' || payload.role !== 'developer') return null;
510
+ function responseItemToConversationMessage(payload, timestamp) {
511
+ if (payload?.type !== 'message' || !['user', 'assistant', 'developer'].includes(payload.role)) return null;
495
512
  const text = normalizeMessageText(messageContentToText(payload.content));
496
- if (!isThroughlineInjectedDeveloperMemory(text)) return null;
497
513
  if (!text) return null;
514
+ if (payload.role === 'developer' && !isThroughlineInjectedDeveloperMemory(text)) return null;
515
+ if (payload.role === 'user' && shouldSkipUserMessage(text)) return null;
498
516
  return {
499
517
  time: timestamp ?? null,
500
- role: 'developer',
518
+ role: payload.role,
501
519
  text,
502
520
  };
503
521
  }
504
522
 
505
- function responseItemToUserMessage(payload, timestamp) {
506
- if (payload?.type !== 'message' || payload.role !== 'user') return null;
507
- const text = normalizeMessageText(messageContentToText(payload.content));
508
- if (!text || shouldSkipUserMessage(text)) return null;
509
- return {
510
- time: timestamp ?? null,
511
- role: 'user',
512
- text,
513
- };
523
+ function appendUniqueMessage(messages, message) {
524
+ if (messages.some((item) => item.role === message.role && item.text === message.text)) return;
525
+ messages.push(message);
514
526
  }
515
527
 
516
528
  function isThroughlineInjectedDeveloperMemory(text) {
package/src/db.mjs CHANGED
@@ -9,11 +9,22 @@ import { join } from 'path';
9
9
 
10
10
  const DB_DIR = join(homedir(), '.throughline');
11
11
  const DB_PATH = join(DB_DIR, 'throughline.db');
12
- const DB_BUSY_TIMEOUT_MS = 5_000;
12
+ export const DB_BUSY_TIMEOUT_MS = 5_000;
13
13
  export const CURRENT_VERSION = 9;
14
14
 
15
15
  let _db = null;
16
16
 
17
+ export function openReadOnlyDb(path = DB_PATH) {
18
+ const db = new DatabaseSync(path, { readOnly: true });
19
+ try {
20
+ db.exec(`PRAGMA busy_timeout = ${DB_BUSY_TIMEOUT_MS}`);
21
+ return db;
22
+ } catch (error) {
23
+ db.close();
24
+ throw error;
25
+ }
26
+ }
27
+
17
28
  function initSchema(db) {
18
29
  const row = db.prepare('PRAGMA user_version').get();
19
30
  const version = row.user_version ?? 0;
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * hosts/grok.mjs — Grok host 境界 (envelope 正規化 + hook adapter)
3
3
  *
4
- * Grok sends Claude-compatible hook commands with a camelCase wire
5
- * (sessionId, hookEventName) and no session_id. Throughline treats that
6
- * envelope as host=grok: normalize to the Claude snake_case contract and
7
- * prefix session ids so they never mix with Claude predecessor search.
4
+ * Grok sends Claude-compatible hook commands and may expose both camelCase
5
+ * and snake_case aliases. The reserved GROK_* hook environment binds the
6
+ * host; Throughline normalizes either wire to the Claude snake_case contract
7
+ * and prefixes session ids so they never mix with other host identities.
8
8
  *
9
9
  * Grok 固有の挙動 (v0.10.0 / ADR 0021):
10
10
  * - UserPromptSubmit stdout はモデルへ渡らないため、引き継ぎ注入は
@@ -30,8 +30,7 @@ export function isGrokEnvelope(payload) {
30
30
  && typeof payload.sessionId === 'string'
31
31
  && payload.sessionId.length > 0
32
32
  && typeof payload.hookEventName === 'string'
33
- && payload.hookEventName.length > 0
34
- && !Object.hasOwn(payload, 'session_id');
33
+ && payload.hookEventName.length > 0;
35
34
  }
36
35
 
37
36
  export function deriveGrokChatHistoryPath(projectPath, sessionId, { home = homedir() } = {}) {
@@ -40,21 +39,23 @@ export function deriveGrokChatHistoryPath(projectPath, sessionId, { home = homed
40
39
  return join(home, '.grok', 'sessions', encodeURIComponent(projectPath), bare, 'chat_history.jsonl');
41
40
  }
42
41
 
43
- export function normalizeGrokHookPayload(payload, { home = homedir() } = {}) {
44
- if (!isGrokEnvelope(payload)) return payload;
42
+ export function normalizeGrokHookPayload(payload, { home = homedir(), force = false, env = {} } = {}) {
43
+ if (!force && !isGrokEnvelope(payload)) return payload;
44
+ const rawSessionId = payload.sessionId ?? payload.session_id ?? env.GROK_SESSION_ID;
45
+ const hookEventName = payload.hookEventName ?? payload.hook_event_name ?? env.GROK_HOOK_EVENT;
45
46
  const cwd = typeof payload.cwd === 'string' && payload.cwd.length > 0
46
47
  ? payload.cwd
47
- : (typeof payload.workspaceRoot === 'string' ? payload.workspaceRoot : undefined);
48
+ : (typeof payload.workspaceRoot === 'string' ? payload.workspaceRoot : env.GROK_WORKSPACE_ROOT);
48
49
  // Live Grok Stop sets transcriptPath to updates.jsonl (sessionUpdate frames,
49
50
  // no user/assistant rows). L2 lives in chat_history.jsonl only.
50
- const transcriptPath = deriveGrokChatHistoryPath(cwd, payload.sessionId, { home });
51
+ const transcriptPath = deriveGrokChatHistoryPath(cwd, rawSessionId, { home });
51
52
  return {
52
53
  ...payload,
53
- session_id: `${GROK_SESSION_PREFIX}${payload.sessionId}`,
54
+ session_id: `${GROK_SESSION_PREFIX}${rawSessionId}`,
54
55
  cwd,
55
56
  source: payload.source,
56
57
  prompt: payload.prompt,
57
- hook_event_name: payload.hookEventName,
58
+ hook_event_name: hookEventName,
58
59
  transcript_path: transcriptPath,
59
60
  last_assistant_message: payload.lastAssistantMessage,
60
61
  };
@@ -17,6 +17,15 @@ test('isGrokEnvelope detects camelCase wire without session_id', () => {
17
17
  }),
18
18
  true,
19
19
  );
20
+ assert.equal(
21
+ isGrokEnvelope({
22
+ sessionId: '01a00aa2-dead-beef',
23
+ session_id: '01a00aa2-dead-beef',
24
+ hookEventName: 'stop',
25
+ hook_event_name: 'stop',
26
+ }),
27
+ true,
28
+ );
20
29
  assert.equal(
21
30
  isGrokEnvelope({
22
31
  session_id: 'claude-session',
@@ -26,6 +35,34 @@ test('isGrokEnvelope detects camelCase wire without session_id', () => {
26
35
  );
27
36
  });
28
37
 
38
+ test('Grok予約環境がsnake_case payloadをCursorではなくGrokへ固定する', () => {
39
+ const home = '/tmp/tl-home';
40
+ const cwd = '/srv/bellteam/bots/bot-a6fbf921';
41
+ const sessionId = '15e10cc9-ba86-4056-874b-0d1724b1bed3';
42
+ const payload = normalizeHookPayload(
43
+ {
44
+ session_id: sessionId,
45
+ hook_event_name: 'stop',
46
+ cwd,
47
+ last_assistant_message: 'done',
48
+ },
49
+ {
50
+ home,
51
+ env: {
52
+ GROK_HOOK_EVENT: 'stop',
53
+ GROK_SESSION_ID: sessionId,
54
+ GROK_WORKSPACE_ROOT: cwd,
55
+ },
56
+ },
57
+ );
58
+ assert.equal(payload.session_id, `grok:${sessionId}`);
59
+ assert.equal(payload.hook_event_name, 'stop');
60
+ assert.equal(
61
+ payload.transcript_path,
62
+ join(home, '.grok', 'sessions', encodeURIComponent(cwd), sessionId, 'chat_history.jsonl'),
63
+ );
64
+ });
65
+
29
66
  test('normalizeHookPayload prefixes grok: and derives chat_history path', () => {
30
67
  const home = '/tmp/tl-home';
31
68
  const cwd = '/Users/kite/Developer/dotagents';
@@ -32,6 +32,9 @@ const ADAPTERS = Object.freeze({
32
32
  * Grok は camelCase wire、Cursor は hook_event_name が sessionStart 等。
33
33
  */
34
34
  export function normalizeHookPayload(payload, options = {}) {
35
+ if (options.env?.GROK_HOOK_EVENT || options.env?.GROK_SESSION_ID) {
36
+ return normalizeGrokHookPayload(payload, { ...options, force: true });
37
+ }
35
38
  if (isGrokEnvelope(payload)) return normalizeGrokHookPayload(payload, options);
36
39
  if (isCursorEnvelope(payload)) return normalizeCursorHookPayload(payload, options);
37
40
  return payload;
@@ -152,7 +152,7 @@ export async function run() {
152
152
  process.stdin.on('end', resolve);
153
153
  });
154
154
 
155
- const payload = normalizeHookPayload(JSON.parse(raw));
155
+ const payload = normalizeHookPayload(JSON.parse(raw), { env: process.env });
156
156
  const { session_id, cwd, prompt } = payload;
157
157
  const hostAdapter = hostAdapterForSessionId(session_id);
158
158
 
@@ -101,7 +101,7 @@ export async function run() {
101
101
  process.stdin.on('end', resolve);
102
102
  });
103
103
 
104
- const payload = normalizeHookPayload(JSON.parse(raw));
104
+ const payload = normalizeHookPayload(JSON.parse(raw), { env: process.env });
105
105
  const { session_id, cwd, source, transcript_path } = payload;
106
106
 
107
107
  if (!session_id) throw new Error('Missing session_id in SessionStart payload');
@@ -191,7 +191,7 @@ export async function run() {
191
191
  process.stdin.on('end', resolve);
192
192
  });
193
193
 
194
- const payload = normalizeHookPayload(JSON.parse(raw || '{}'));
194
+ const payload = normalizeHookPayload(JSON.parse(raw || '{}'), { env: process.env });
195
195
  const { session_id, transcript_path, cwd, last_assistant_message } = payload;
196
196
  if (!session_id) throw new Error('Missing session_id in Stop payload');
197
197