throughline 0.9.1 → 0.10.0

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.
@@ -74,65 +74,196 @@ test('hook modules can be imported without executing their hook body', () => {
74
74
  }
75
75
  });
76
76
 
77
- test('Grok camelCase envelopes are unsupported no-op before Throughline side effects [GF04T]', () => {
77
+ test('Grok camelCase envelopes register a grok: session instead of no-op', () => {
78
78
  const home = makeTempHome();
79
79
  const project = makeTempProject();
80
+ const sessionId = '01a00aa2-50f8-7791-86fd-df2d27cf003e';
80
81
  const common = {
81
- sessionId: 'grok-session',
82
+ sessionId,
82
83
  cwd: project,
83
84
  workspaceRoot: project,
84
- timestamp: '2026-08-14T00:00:00Z',
85
+ timestamp: '2026-08-17T00:00:00Z',
85
86
  permissionMode: 'default',
86
87
  };
87
- const cases = [
88
- {
89
- event: 'SessionStart',
90
- entrypoint: 'src/session-start.mjs',
91
- input: { ...common, hookEventName: 'session_start', source: 'startup' },
92
- },
93
- {
94
- event: 'UserPromptSubmit',
95
- entrypoint: 'src/prompt-submit.mjs',
96
- input: { ...common, hookEventName: 'user_prompt_submit', prompt: 'hello' },
97
- },
98
- {
99
- event: 'Stop',
100
- entrypoint: 'src/turn-processor.mjs',
101
- input: {
88
+
89
+ try {
90
+ const start = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
91
+ home,
92
+ cwd: project,
93
+ input: JSON.stringify({ ...common, hookEventName: 'session_start', source: 'startup' }),
94
+ });
95
+ assert.equal(start.status, 0, start.stderr);
96
+
97
+ const prompt = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
98
+ home,
99
+ cwd: project,
100
+ input: JSON.stringify({ ...common, hookEventName: 'user_prompt_submit', prompt: 'hello grok' }),
101
+ });
102
+ assert.equal(prompt.status, 0, prompt.stderr);
103
+
104
+ const historyDir = join(home, '.grok', 'sessions', encodeURIComponent(project), sessionId);
105
+ mkdirSync(historyDir, { recursive: true });
106
+ writeFileSync(
107
+ join(historyDir, 'chat_history.jsonl'),
108
+ [
109
+ JSON.stringify({ type: 'user', content: [{ type: 'text', text: 'hello grok' }] }),
110
+ JSON.stringify({ type: 'assistant', content: 'captured reply' }),
111
+ ].join('\n'),
112
+ );
113
+ const stop = runNode([join(REPO_ROOT, 'src/turn-processor.mjs')], {
114
+ home,
115
+ cwd: project,
116
+ input: JSON.stringify({
102
117
  ...common,
103
118
  hookEventName: 'stop',
104
- reason: 'end_turn',
105
- stopHookActive: false,
106
- lastAssistantMessage: 'done',
107
- backgroundTasks: [],
108
- sessionCrons: [],
109
- },
110
- },
111
- ];
119
+ lastAssistantMessage: 'captured reply',
120
+ }),
121
+ });
122
+ assert.equal(stop.status, 0, stop.stderr);
123
+
124
+ const db = openDb(home);
125
+ const row = db.prepare('SELECT session_id, project_path FROM sessions').get();
126
+ assert.equal(row.session_id, `grok:${sessionId}`);
127
+ assert.equal(row.project_path, project);
128
+ const bodies = db.prepare('SELECT role, text FROM bodies ORDER BY role').all();
129
+ assert.deepEqual(
130
+ bodies.map((b) => ({ role: b.role, text: b.text })),
131
+ [
132
+ { role: 'assistant', text: 'captured reply' },
133
+ { role: 'user', text: 'hello grok' },
134
+ ],
135
+ );
136
+ db.close();
137
+ } finally {
138
+ rmSync(project, { recursive: true, force: true });
139
+ rmSync(home, { recursive: true, force: true });
140
+ }
141
+ });
142
+
143
+ test('Grok Stop with updates.jsonl transcriptPath still captures L2 from chat_history', () => {
144
+ const home = makeTempHome();
145
+ const project = makeTempProject();
146
+ const sessionId = '01a00b38-87ea-7670-8f7d-a9fe937263c5';
147
+ const common = {
148
+ sessionId,
149
+ cwd: project,
150
+ workspaceRoot: project,
151
+ timestamp: '2026-08-17T00:00:00Z',
152
+ permissionMode: 'default',
153
+ };
112
154
 
113
155
  try {
114
- const results = cases.map(({ event, entrypoint, input }) => {
115
- const result = runNode([join(REPO_ROOT, entrypoint)], {
116
- home,
117
- cwd: project,
118
- input: JSON.stringify(input),
119
- env: { THROUGHLINE_NO_VSCODE: '0', TERM_PROGRAM: 'vscode' },
120
- });
121
- return { event, status: result.status, stdout: result.stdout, stderr: result.stderr };
156
+ const start = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
157
+ home,
158
+ cwd: project,
159
+ input: JSON.stringify({ ...common, hookEventName: 'session_start', source: 'startup' }),
160
+ });
161
+ assert.equal(start.status, 0, start.stderr);
162
+
163
+ const historyDir = join(home, '.grok', 'sessions', encodeURIComponent(project), sessionId);
164
+ mkdirSync(historyDir, { recursive: true });
165
+ writeFileSync(
166
+ join(historyDir, 'updates.jsonl'),
167
+ JSON.stringify({
168
+ method: '_x.ai/session/update',
169
+ params: { update: { sessionUpdate: 'hook_execution', event_name: 'stop' } },
170
+ }) + '\n',
171
+ );
172
+ writeFileSync(
173
+ join(historyDir, 'chat_history.jsonl'),
174
+ [
175
+ JSON.stringify({ type: 'user', content: [{ type: 'text', text: 'hello grok' }] }),
176
+ JSON.stringify({ type: 'assistant', content: 'captured reply' }),
177
+ ].join('\n'),
178
+ );
179
+ const stop = runNode([join(REPO_ROOT, 'src/turn-processor.mjs')], {
180
+ home,
181
+ cwd: project,
182
+ input: JSON.stringify({
183
+ ...common,
184
+ hookEventName: 'stop',
185
+ transcriptPath: join(historyDir, 'updates.jsonl'),
186
+ lastAssistantMessage: 'captured reply',
187
+ }),
122
188
  });
189
+ assert.equal(stop.status, 0, stop.stderr);
123
190
 
191
+ const db = openDb(home);
192
+ const bodies = db.prepare('SELECT role, text FROM bodies ORDER BY role').all();
124
193
  assert.deepEqual(
125
- {
126
- results,
127
- throughlineStateExists: existsSync(join(home, '.throughline')),
128
- vscodeTaskExists: existsSync(join(project, '.vscode', 'tasks.json')),
129
- },
130
- {
131
- results: cases.map(({ event }) => ({ event, status: 0, stdout: '', stderr: '' })),
132
- throughlineStateExists: false,
133
- vscodeTaskExists: false,
134
- },
194
+ bodies.map((b) => ({ role: b.role, text: b.text })),
195
+ [
196
+ { role: 'assistant', text: 'captured reply' },
197
+ { role: 'user', text: 'hello grok' },
198
+ ],
135
199
  );
200
+ db.close();
201
+ } finally {
202
+ rmSync(project, { recursive: true, force: true });
203
+ rmSync(home, { recursive: true, force: true });
204
+ }
205
+ });
206
+
207
+ test('Grok wrapped /tl prompt writes a grok: baton', () => {
208
+ const home = makeTempHome();
209
+ const project = makeTempProject();
210
+ const sessionId = '01a00b38-87ea-7670-8f7d-a9fe937263c5';
211
+ try {
212
+ const result = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
213
+ home,
214
+ cwd: project,
215
+ input: JSON.stringify({
216
+ sessionId,
217
+ cwd: project,
218
+ hookEventName: 'user_prompt_submit',
219
+ prompt:
220
+ '<user_query>\n/tl\n</user_query>\n<skill_information>\nThroughline saved the baton.\n</skill_information>',
221
+ }),
222
+ });
223
+ assert.equal(result.status, 0, result.stderr);
224
+ assert.match(result.stderr, /grok-continue exited/);
225
+
226
+ const db = openDb(home);
227
+ const row = db.prepare('SELECT project_path, session_id FROM handoff_batons').get();
228
+ assert.equal(row.project_path, project);
229
+ assert.equal(row.session_id, `grok:${sessionId}`);
230
+ db.close();
231
+ } finally {
232
+ rmSync(project, { recursive: true, force: true });
233
+ rmSync(home, { recursive: true, force: true });
234
+ }
235
+ });
236
+
237
+ test('Grok empty hook prompt still writes /tl baton from chat_history', () => {
238
+ const home = makeTempHome();
239
+ const project = makeTempProject();
240
+ const sessionId = '01a00b38-87ea-7670-8f7d-a9fe937263c5';
241
+ const historyDir = join(home, '.grok', 'sessions', encodeURIComponent(project), sessionId);
242
+ mkdirSync(historyDir, { recursive: true });
243
+ writeFileSync(
244
+ join(historyDir, 'chat_history.jsonl'),
245
+ JSON.stringify({
246
+ type: 'user',
247
+ content: [{ type: 'text', text: '<user_query>\n/tl\n</user_query>\n<skill_information>x</skill_information>' }],
248
+ }) + '\n',
249
+ );
250
+ try {
251
+ const result = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
252
+ home,
253
+ cwd: project,
254
+ input: JSON.stringify({
255
+ sessionId,
256
+ cwd: project,
257
+ hookEventName: 'user_prompt_submit',
258
+ }),
259
+ });
260
+ assert.equal(result.status, 0, result.stderr);
261
+ assert.match(result.stderr, /grok-continue exited/);
262
+
263
+ const db = openDb(home);
264
+ const row = db.prepare('SELECT session_id FROM handoff_batons').get();
265
+ assert.equal(row.session_id, `grok:${sessionId}`);
266
+ db.close();
136
267
  } finally {
137
268
  rmSync(project, { recursive: true, force: true });
138
269
  rmSync(home, { recursive: true, force: true });
@@ -154,6 +285,7 @@ test('prompt-submit subprocess writes a /tl baton into an isolated DB', () => {
154
285
  });
155
286
 
156
287
  assert.equal(result.status, 0, result.stderr);
288
+ assert.equal(result.stderr.includes('grok-continue'), false);
157
289
 
158
290
  const db = openDb(home);
159
291
  const row = db.prepare('SELECT project_path, session_id FROM handoff_batons').get();
@@ -249,6 +381,87 @@ test('prompt-submit: non-baton prompt does not write any baton', () => {
249
381
  }
250
382
  });
251
383
 
384
+ test('Grok first prompt injects handoff into chat_history instead of stdout', () => {
385
+ const home = makeTempHome();
386
+ const project = makeTempProject();
387
+ const oldId = '01a00aa2-50f8-7791-86fd-df2d27cf003e';
388
+ const newId = '01a00ce5-0169-7852-95c0-9e6b8cc50c06';
389
+ const historyDir = join(home, '.grok', 'sessions', encodeURIComponent(project), newId);
390
+ mkdirSync(historyDir, { recursive: true });
391
+ writeFileSync(
392
+ join(historyDir, 'chat_history.jsonl'),
393
+ [
394
+ JSON.stringify({ type: 'system', content: 'sys' }),
395
+ JSON.stringify({
396
+ type: 'user',
397
+ content: [{ type: 'text', text: '<user_query>\nこれかな?\n</user_query>' }],
398
+ }),
399
+ ].join('\n') + '\n',
400
+ );
401
+ try {
402
+ const baton = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
403
+ home,
404
+ cwd: project,
405
+ input: JSON.stringify({
406
+ sessionId: oldId,
407
+ cwd: project,
408
+ hookEventName: 'user_prompt_submit',
409
+ prompt: '/tl',
410
+ }),
411
+ });
412
+ assert.equal(baton.status, 0, baton.stderr);
413
+
414
+ const db = openDb(home);
415
+ db.prepare(
416
+ `INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
417
+ VALUES (?, ?, 'active', 1, 1)`,
418
+ ).run(`grok:${oldId}`, project);
419
+ db.prepare(
420
+ `INSERT INTO bodies
421
+ (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
422
+ VALUES (?, ?, 1, 'assistant', 'old assistant body', 4, 2)`,
423
+ ).run(`grok:${oldId}`, `grok:${oldId}`);
424
+ db.close();
425
+
426
+ const started = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
427
+ home,
428
+ cwd: project,
429
+ input: JSON.stringify({
430
+ sessionId: newId,
431
+ cwd: project,
432
+ hookEventName: 'session_start',
433
+ source: 'new',
434
+ }),
435
+ });
436
+ assert.equal(started.status, 0, started.stderr);
437
+
438
+ const firstPrompt = runNode([join(REPO_ROOT, 'src/prompt-submit.mjs')], {
439
+ home,
440
+ cwd: project,
441
+ input: JSON.stringify({
442
+ sessionId: newId,
443
+ cwd: project,
444
+ hookEventName: 'user_prompt_submit',
445
+ prompt: 'これかな?',
446
+ }),
447
+ });
448
+ assert.equal(firstPrompt.status, 0, firstPrompt.stderr);
449
+ assert.equal(firstPrompt.stdout, '', 'Grok must not rely on ignored hook stdout');
450
+
451
+ const rows = readFileSync(join(historyDir, 'chat_history.jsonl'), 'utf8')
452
+ .split('\n')
453
+ .filter(Boolean)
454
+ .map((line) => JSON.parse(line));
455
+ assert.equal(rows[1].synthetic_reason, 'system_reminder');
456
+ assert.match(rows[1].content[0].text, /data-throughline-handoff="1"/);
457
+ assert.match(rows[1].content[0].text, /old assistant body/);
458
+ assert.match(rows[2].content[0].text, /これかな?/);
459
+ } finally {
460
+ rmSync(project, { recursive: true, force: true });
461
+ rmSync(home, { recursive: true, force: true });
462
+ }
463
+ });
464
+
252
465
  test('two-phase: session-start registers intent only; first prompt consumes baton and injects', () => {
253
466
  const home = makeTempHome();
254
467
  const project = makeTempProject();
@@ -1,7 +1,13 @@
1
- // Grok can invoke Claude-compatible hook commands with its camelCase wire.
2
- // Throughline does not support Grok as a host, so this envelope is ignored
3
- // before DB, state, VS Code task, transcript, or runtime-error side effects.
4
- export function isUnsupportedNonClaudeEnvelope(payload) {
1
+ // Grok sends Claude-compatible hook commands with a camelCase wire
2
+ // (sessionId, hookEventName) and no session_id. Throughline treats that
3
+ // envelope as host=grok: normalize to the Claude snake_case contract and
4
+ // prefix session ids so they never mix with Claude predecessor search.
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ export const GROK_SESSION_PREFIX = 'grok:';
9
+
10
+ export function isGrokEnvelope(payload) {
5
11
  return payload !== null
6
12
  && typeof payload === 'object'
7
13
  && typeof payload.sessionId === 'string'
@@ -10,3 +16,36 @@ export function isUnsupportedNonClaudeEnvelope(payload) {
10
16
  && payload.hookEventName.length > 0
11
17
  && !Object.hasOwn(payload, 'session_id');
12
18
  }
19
+
20
+ export function grokBareSessionId(sessionId) {
21
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return null;
22
+ return sessionId.startsWith(GROK_SESSION_PREFIX)
23
+ ? sessionId.slice(GROK_SESSION_PREFIX.length)
24
+ : sessionId;
25
+ }
26
+
27
+ export function deriveGrokChatHistoryPath(projectPath, sessionId, { home = homedir() } = {}) {
28
+ const bare = grokBareSessionId(sessionId);
29
+ if (!projectPath || !bare) return null;
30
+ return join(home, '.grok', 'sessions', encodeURIComponent(projectPath), bare, 'chat_history.jsonl');
31
+ }
32
+
33
+ export function normalizeHookPayload(payload, { home = homedir() } = {}) {
34
+ if (!isGrokEnvelope(payload)) return payload;
35
+ const cwd = typeof payload.cwd === 'string' && payload.cwd.length > 0
36
+ ? payload.cwd
37
+ : (typeof payload.workspaceRoot === 'string' ? payload.workspaceRoot : undefined);
38
+ // Live Grok Stop sets transcriptPath to updates.jsonl (sessionUpdate frames,
39
+ // no user/assistant rows). L2 lives in chat_history.jsonl only.
40
+ const transcriptPath = deriveGrokChatHistoryPath(cwd, payload.sessionId, { home });
41
+ return {
42
+ ...payload,
43
+ session_id: `${GROK_SESSION_PREFIX}${payload.sessionId}`,
44
+ cwd,
45
+ source: payload.source,
46
+ prompt: payload.prompt,
47
+ hook_event_name: payload.hookEventName,
48
+ transcript_path: transcriptPath,
49
+ last_assistant_message: payload.lastAssistantMessage,
50
+ };
51
+ }
@@ -0,0 +1,79 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { join } from 'node:path';
4
+
5
+ import {
6
+ deriveGrokChatHistoryPath,
7
+ isGrokEnvelope,
8
+ normalizeHookPayload,
9
+ } from './hook-envelope.mjs';
10
+
11
+ test('isGrokEnvelope detects camelCase wire without session_id', () => {
12
+ assert.equal(
13
+ isGrokEnvelope({
14
+ sessionId: '01a00aa2-dead-beef',
15
+ hookEventName: 'session_start',
16
+ cwd: '/tmp/proj',
17
+ }),
18
+ true,
19
+ );
20
+ assert.equal(
21
+ isGrokEnvelope({
22
+ session_id: 'claude-session',
23
+ hook_event_name: 'SessionStart',
24
+ }),
25
+ false,
26
+ );
27
+ });
28
+
29
+ test('normalizeHookPayload prefixes grok: and derives chat_history path', () => {
30
+ const home = '/tmp/tl-home';
31
+ const cwd = '/Users/kite/Developer/dotagents';
32
+ const payload = normalizeHookPayload(
33
+ {
34
+ sessionId: '01a00aa2-dead-beef',
35
+ hookEventName: 'user_prompt_submit',
36
+ cwd,
37
+ prompt: 'hello',
38
+ lastAssistantMessage: 'hi',
39
+ },
40
+ { home },
41
+ );
42
+ assert.equal(payload.session_id, 'grok:01a00aa2-dead-beef');
43
+ assert.equal(payload.prompt, 'hello');
44
+ assert.equal(payload.last_assistant_message, 'hi');
45
+ assert.equal(
46
+ payload.transcript_path,
47
+ join(home, '.grok', 'sessions', encodeURIComponent(cwd), '01a00aa2-dead-beef', 'chat_history.jsonl'),
48
+ );
49
+ assert.equal(
50
+ deriveGrokChatHistoryPath(cwd, 'grok:01a00aa2-dead-beef', { home }),
51
+ payload.transcript_path,
52
+ );
53
+ });
54
+
55
+ test('normalizeHookPayload ignores Grok transcriptPath pointing at updates.jsonl', () => {
56
+ const home = '/tmp/tl-home';
57
+ const cwd = '/Users/kite/Developer/dotagents';
58
+ const sessionId = '01a00b38-87ea-7670-8f7d-a9fe937263c5';
59
+ const payload = normalizeHookPayload(
60
+ {
61
+ sessionId,
62
+ hookEventName: 'stop',
63
+ cwd,
64
+ transcriptPath: join(
65
+ home,
66
+ '.grok',
67
+ 'sessions',
68
+ encodeURIComponent(cwd),
69
+ sessionId,
70
+ 'updates.jsonl',
71
+ ),
72
+ },
73
+ { home },
74
+ );
75
+ assert.equal(
76
+ payload.transcript_path,
77
+ join(home, '.grok', 'sessions', encodeURIComponent(cwd), sessionId, 'chat_history.jsonl'),
78
+ );
79
+ });
@@ -42,7 +42,10 @@ import { join, dirname } from 'node:path';
42
42
  import { homedir } from 'node:os';
43
43
  import { pathToFileURL } from 'node:url';
44
44
  import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
45
- import { isUnsupportedNonClaudeEnvelope } from './hook-envelope.mjs';
45
+ import { GROK_SESSION_PREFIX, normalizeHookPayload } from './hook-envelope.mjs';
46
+ import { injectGrokHandoffContext } from './grok-history-inject.mjs';
47
+ import { readTranscript } from './transcript-reader.mjs';
48
+ import { run as runGrokContinue } from './cli/grok-continue.mjs';
46
49
 
47
50
  // Phase 0-5 spike marker (SessionStart の spike-inject.flag とは別)
48
51
  const PROMPT_SPIKE_MARKER_PATH = join(homedir(), '.throughline', 'spike-prompt.flag');
@@ -109,28 +112,61 @@ function markSpiked(sessionId) {
109
112
  writeFileSync(join(PROMPT_SPIKE_STATE_DIR, sessionId), '', 'utf8');
110
113
  }
111
114
 
115
+ const USER_QUERY_RE = /<user_query>\s*([\s\S]*?)\s*<\/user_query>/i;
116
+
117
+ /**
118
+ * Grok は hook prompt と chat_history を
119
+ * `<user_query>/tl</user_query>` + skill 本文で包む。
120
+ * Claude の裸 `/tl` はそのまま返す。
121
+ */
122
+ export function commandTextFromPrompt(prompt) {
123
+ if (typeof prompt !== 'string') return '';
124
+ const match = prompt.match(USER_QUERY_RE);
125
+ return (match ? match[1] : prompt).trim();
126
+ }
127
+
128
+ function isNamedSlashCommand(prompt, name) {
129
+ const text = commandTextFromPrompt(prompt);
130
+ if (!text) return false;
131
+ return text === name || text.startsWith(`${name} `) || text.startsWith(`${name}\n`);
132
+ }
133
+
134
+ function lastUserPromptText(transcriptPath) {
135
+ const turns = readTranscript(transcriptPath);
136
+ for (let i = turns.length - 1; i >= 0; i--) {
137
+ if (turns[i].role === 'user') return turns[i].content;
138
+ }
139
+ return '';
140
+ }
141
+
112
142
  /**
113
143
  * プロンプトが /tl バトン発動コマンドか判定する。
114
- * 許容: "/tl", "/tl\n", "/tl 何か" (前後空白は trim 済み前提)
144
+ * 許容: "/tl", "/tl\n", "/tl 何か"。Grok user_query 包装も見る。
115
145
  */
116
146
  export function isBatonCommand(prompt) {
117
- if (typeof prompt !== 'string') return false;
118
- const trimmed = prompt.trim();
119
- if (trimmed === '/tl') return true;
120
- if (trimmed.startsWith('/tl ') || trimmed.startsWith('/tl\n')) return true;
121
- return false;
147
+ return isNamedSlashCommand(prompt, '/tl');
122
148
  }
123
149
 
124
150
  /**
125
151
  * プロンプトが /clear バトン発動コマンドか判定する。
126
- * 許容: "/clear", "/clear\n", "/clear 何か" (前後空白は trim 済み前提)
152
+ * 許容: "/clear", Grok の alias "/new"。Grok user_query 包装も見る。
127
153
  */
128
154
  export function isClearCommand(prompt) {
129
- if (typeof prompt !== 'string') return false;
130
- const trimmed = prompt.trim();
131
- if (trimmed === '/clear') return true;
132
- if (trimmed.startsWith('/clear ') || trimmed.startsWith('/clear\n')) return true;
133
- return false;
155
+ return isNamedSlashCommand(prompt, '/clear') || isNamedSlashCommand(prompt, '/new');
156
+ }
157
+
158
+ export function shouldLaunchGrokContinue({ sessionId, trigger }) {
159
+ return trigger === 'tl'
160
+ && typeof sessionId === 'string'
161
+ && sessionId.startsWith(GROK_SESSION_PREFIX);
162
+ }
163
+
164
+ export function launchGrokContinueAfterTl({
165
+ sessionId,
166
+ cwd,
167
+ continueRun = runGrokContinue,
168
+ }) {
169
+ return continueRun(['--session', sessionId], { cwd });
134
170
  }
135
171
 
136
172
  export async function run() {
@@ -143,8 +179,7 @@ export async function run() {
143
179
  process.stdin.on('end', resolve);
144
180
  });
145
181
 
146
- const payload = JSON.parse(raw);
147
- if (isUnsupportedNonClaudeEnvelope(payload)) return;
182
+ const payload = normalizeHookPayload(JSON.parse(raw));
148
183
  const { session_id, cwd, prompt } = payload;
149
184
 
150
185
  // VSCode 新規プロジェクトへの tasks.json 自動プロビジョニング。
@@ -173,7 +208,17 @@ export async function run() {
173
208
  });
174
209
  if (handoff.attempted) {
175
210
  if (handoff.injectionText) {
176
- process.stdout.write(handoff.injectionText + '\n');
211
+ if (session_id.startsWith(GROK_SESSION_PREFIX)) {
212
+ const injected = injectGrokHandoffContext(
213
+ payload.transcript_path,
214
+ handoff.injectionText,
215
+ );
216
+ if (!injected.injected) {
217
+ process.stderr.write(`[prompt-submit] grok chat_history inject skipped: ${injected.reason}\n`);
218
+ }
219
+ } else {
220
+ process.stdout.write(handoff.injectionText + '\n');
221
+ }
177
222
  }
178
223
  logDecision({
179
224
  ts: new Date(now).toISOString(),
@@ -193,8 +238,17 @@ export async function run() {
193
238
  }
194
239
  }
195
240
 
196
- const tlMatch = isBatonCommand(prompt);
197
- const clearMatch = !tlMatch && isClearCommand(prompt);
241
+ let commandPrompt = prompt;
242
+ if (
243
+ typeof session_id === 'string'
244
+ && session_id.startsWith(GROK_SESSION_PREFIX)
245
+ && !isBatonCommand(commandPrompt)
246
+ && !isClearCommand(commandPrompt)
247
+ ) {
248
+ commandPrompt = lastUserPromptText(payload.transcript_path);
249
+ }
250
+ const tlMatch = isBatonCommand(commandPrompt);
251
+ const clearMatch = !tlMatch && isClearCommand(commandPrompt);
198
252
 
199
253
  // Phase 0-5 spike: real user prompt (not /tl, not /clear) で、marker file あり、
200
254
  // session 未 spike なら chain (b) で JSONL に inject する。失敗しても prompt 自体は
@@ -227,6 +281,19 @@ export async function run() {
227
281
  trigger: tlMatch ? 'tl' : 'clear',
228
282
  });
229
283
 
284
+ if (shouldLaunchGrokContinue({
285
+ sessionId: session_id,
286
+ trigger: tlMatch ? 'tl' : 'clear',
287
+ })) {
288
+ const code = launchGrokContinueAfterTl({
289
+ sessionId: session_id,
290
+ cwd: projectPath,
291
+ });
292
+ if (code !== 0) {
293
+ process.stderr.write(`[prompt-submit] grok-continue exited ${code}\n`);
294
+ }
295
+ }
296
+
230
297
  process.exit(0);
231
298
  }
232
299