throughline 0.9.1 → 0.10.1

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/README.ja.md +40 -2
  3. package/README.md +42 -3
  4. package/bin/throughline.mjs +12 -1
  5. package/docs/00_overview.md +2 -0
  6. package/docs/02_clear_auto_handoff_plan.md +6 -0
  7. package/docs/04_public_release_plan.md +2 -2
  8. package/docs/adr/0021-grok-host-capture.md +60 -0
  9. package/docs/plan_grok-successor-launch.md +99 -0
  10. package/package.json +4 -2
  11. package/src/auditor-context.mjs +2 -1
  12. package/src/cli/codex-handoff-start.mjs +5 -23
  13. package/src/cli/codex-summarize.mjs +2 -1
  14. package/src/cli/codex-visibility-smoke.mjs +5 -4
  15. package/src/cli/grok-continue.mjs +184 -0
  16. package/src/cli/grok-continue.test.mjs +239 -0
  17. package/src/cli/handoff-context.mjs +19 -0
  18. package/src/cli/handoff-context.test.mjs +13 -0
  19. package/src/cli/install.mjs +62 -0
  20. package/src/cli/install.test.mjs +46 -0
  21. package/src/cli/runtime-errors.test.mjs +1 -1
  22. package/src/codex-capture.mjs +14 -18
  23. package/src/codex-handoff-model-smoke.mjs +1 -1
  24. package/src/codex-sidecar.mjs +1 -1
  25. package/src/completed-turn-receipts.mjs +2 -29
  26. package/src/completed-turn-receipts.test.mjs +1 -1
  27. package/src/grok-history-inject.mjs +71 -0
  28. package/src/grok-history-inject.test.mjs +69 -0
  29. package/src/haiku-summarizer.mjs +1 -1
  30. package/src/handoff-record.mjs +3 -1
  31. package/src/hook-entrypoints.test.mjs +257 -44
  32. package/src/hosts/claude.mjs +27 -0
  33. package/src/hosts/codex.mjs +28 -0
  34. package/src/hosts/grok.mjs +96 -0
  35. package/src/hosts/grok.test.mjs +79 -0
  36. package/src/hosts/identity.mjs +71 -0
  37. package/src/hosts/identity.test.mjs +65 -0
  38. package/src/hosts/index.mjs +37 -0
  39. package/src/os/macos-terminal.mjs +44 -0
  40. package/src/os/open-url.mjs +16 -0
  41. package/src/os/paths.mjs +12 -0
  42. package/src/os/shell.mjs +16 -0
  43. package/src/os/windows-acl.mjs +57 -0
  44. package/src/project-path.mjs +4 -4
  45. package/src/prompt-submit.mjs +53 -18
  46. package/src/prompt-submit.test.mjs +75 -1
  47. package/src/runtime-error-hook.test.mjs +1 -1
  48. package/src/runtime-error-store.mjs +3 -40
  49. package/src/runtime-error-store.test.mjs +1 -1
  50. package/src/session-start.mjs +13 -5
  51. package/src/state-file.mjs +6 -5
  52. package/src/token-monitor.mjs +7 -6
  53. package/src/transcript-reader-grok.test.mjs +37 -0
  54. package/src/transcript-reader.mjs +7 -3
  55. package/src/turn-processor.mjs +8 -7
  56. package/src/hook-envelope.mjs +0 -12
  57. /package/src/{portable-spawn-sync.mjs → os/portable-spawn-sync.mjs} +0 -0
  58. /package/src/{portable-spawn-sync.test.mjs → os/portable-spawn-sync.test.mjs} +0 -0
  59. /package/src/{windows-acl-test-helper.mjs → os/windows-acl-test-helper.mjs} +0 -0
@@ -42,7 +42,7 @@ 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 { hostAdapterForSessionId, normalizeHookPayload } from './hosts/index.mjs';
46
46
 
47
47
  // Phase 0-5 spike marker (SessionStart の spike-inject.flag とは別)
48
48
  const PROMPT_SPIKE_MARKER_PATH = join(homedir(), '.throughline', 'spike-prompt.flag');
@@ -109,28 +109,39 @@ function markSpiked(sessionId) {
109
109
  writeFileSync(join(PROMPT_SPIKE_STATE_DIR, sessionId), '', 'utf8');
110
110
  }
111
111
 
112
+ const USER_QUERY_RE = /<user_query>\s*([\s\S]*?)\s*<\/user_query>/i;
113
+
114
+ /**
115
+ * Grok は hook prompt と chat_history を
116
+ * `<user_query>/tl</user_query>` + skill 本文で包む。
117
+ * Claude の裸 `/tl` はそのまま返す。
118
+ */
119
+ export function commandTextFromPrompt(prompt) {
120
+ if (typeof prompt !== 'string') return '';
121
+ const match = prompt.match(USER_QUERY_RE);
122
+ return (match ? match[1] : prompt).trim();
123
+ }
124
+
125
+ function isNamedSlashCommand(prompt, name) {
126
+ const text = commandTextFromPrompt(prompt);
127
+ if (!text) return false;
128
+ return text === name || text.startsWith(`${name} `) || text.startsWith(`${name}\n`);
129
+ }
130
+
112
131
  /**
113
132
  * プロンプトが /tl バトン発動コマンドか判定する。
114
- * 許容: "/tl", "/tl\n", "/tl 何か" (前後空白は trim 済み前提)
133
+ * 許容: "/tl", "/tl\n", "/tl 何か"。Grok user_query 包装も見る。
115
134
  */
116
135
  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;
136
+ return isNamedSlashCommand(prompt, '/tl');
122
137
  }
123
138
 
124
139
  /**
125
140
  * プロンプトが /clear バトン発動コマンドか判定する。
126
- * 許容: "/clear", "/clear\n", "/clear 何か" (前後空白は trim 済み前提)
141
+ * 許容: "/clear", Grok の alias "/new"。Grok user_query 包装も見る。
127
142
  */
128
143
  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;
144
+ return isNamedSlashCommand(prompt, '/clear') || isNamedSlashCommand(prompt, '/new');
134
145
  }
135
146
 
136
147
  export async function run() {
@@ -143,9 +154,9 @@ export async function run() {
143
154
  process.stdin.on('end', resolve);
144
155
  });
145
156
 
146
- const payload = JSON.parse(raw);
147
- if (isUnsupportedNonClaudeEnvelope(payload)) return;
157
+ const payload = normalizeHookPayload(JSON.parse(raw));
148
158
  const { session_id, cwd, prompt } = payload;
159
+ const hostAdapter = hostAdapterForSessionId(session_id);
149
160
 
150
161
  // VSCode 新規プロジェクトへの tasks.json 自動プロビジョニング。
151
162
  // SessionStart/Stop に加えここでも呼ぶことで、どれか 1 つでも発火すれば初回メッセージ送信で
@@ -173,7 +184,14 @@ export async function run() {
173
184
  });
174
185
  if (handoff.attempted) {
175
186
  if (handoff.injectionText) {
176
- process.stdout.write(handoff.injectionText + '\n');
187
+ // 注入の届け方は host 依存 (Claude: stdout / Grok: chat_history 直書き)
188
+ const delivery = hostAdapter.deliverHandoffInjection({
189
+ payload,
190
+ text: handoff.injectionText,
191
+ });
192
+ if (!delivery.delivered) {
193
+ process.stderr.write(`[prompt-submit] ${delivery.reason}\n`);
194
+ }
177
195
  }
178
196
  logDecision({
179
197
  ts: new Date(now).toISOString(),
@@ -193,8 +211,15 @@ export async function run() {
193
211
  }
194
212
  }
195
213
 
196
- const tlMatch = isBatonCommand(prompt);
197
- const clearMatch = !tlMatch && isClearCommand(prompt);
214
+ // slash command の判定材料は host 依存 (Grok は user_query 包装のため
215
+ // chat_history の最新 user 発話へ fallback する)
216
+ const commandPrompt = hostAdapter.resolveCommandPrompt({
217
+ prompt,
218
+ payload,
219
+ isCommandPrompt: (p) => isBatonCommand(p) || isClearCommand(p),
220
+ });
221
+ const tlMatch = isBatonCommand(commandPrompt);
222
+ const clearMatch = !tlMatch && isClearCommand(commandPrompt);
198
223
 
199
224
  // Phase 0-5 spike: real user prompt (not /tl, not /clear) で、marker file あり、
200
225
  // session 未 spike なら chain (b) で JSONL に inject する。失敗しても prompt 自体は
@@ -227,6 +252,16 @@ export async function run() {
227
252
  trigger: tlMatch ? 'tl' : 'clear',
228
253
  });
229
254
 
255
+ // バトン書き込み後の副作用は host 依存 (Grok /tl だけ後継セッションを起動する)。
256
+ const afterBaton = hostAdapter.afterBatonWrite({
257
+ trigger: tlMatch ? 'tl' : 'clear',
258
+ sessionId: session_id,
259
+ cwd: projectPath,
260
+ });
261
+ if (afterBaton.launched && afterBaton.exitCode !== 0) {
262
+ process.stderr.write(`[prompt-submit] grok-continue exited ${afterBaton.exitCode}\n`);
263
+ }
264
+
230
265
  process.exit(0);
231
266
  }
232
267
 
@@ -1,6 +1,11 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { isBatonCommand, isClearCommand } from './prompt-submit.mjs';
3
+ import {
4
+ commandTextFromPrompt,
5
+ isBatonCommand,
6
+ isClearCommand,
7
+ } from './prompt-submit.mjs';
8
+ import { hostAdapterForSessionId } from './hosts/index.mjs';
4
9
 
5
10
  test('isBatonCommand: bare /tl', () => {
6
11
  assert.equal(isBatonCommand('/tl'), true);
@@ -64,3 +69,72 @@ test('isClearCommand: rejects empty / non-string', () => {
64
69
  assert.equal(isClearCommand(undefined), false);
65
70
  assert.equal(isClearCommand(42), false);
66
71
  });
72
+
73
+ test('commandTextFromPrompt unwraps Grok user_query and leaves bare Claude text', () => {
74
+ assert.equal(commandTextFromPrompt('/tl'), '/tl');
75
+ assert.equal(
76
+ commandTextFromPrompt('<user_query>\n/tl\n</user_query>\n<skill_information>saved</skill_information>'),
77
+ '/tl',
78
+ );
79
+ });
80
+
81
+ test('afterBatonWrite launches grok-continue only for Grok /tl', () => {
82
+ const noLaunch = () => {
83
+ throw new Error('must not launch');
84
+ };
85
+ assert.deepEqual(
86
+ hostAdapterForSessionId('grok:abc').afterBatonWrite({
87
+ trigger: 'clear', sessionId: 'grok:abc', cwd: '/work', continueRun: noLaunch,
88
+ }),
89
+ { launched: false },
90
+ );
91
+ assert.deepEqual(
92
+ hostAdapterForSessionId('old-session').afterBatonWrite({
93
+ trigger: 'tl', sessionId: 'old-session', cwd: '/work', continueRun: noLaunch,
94
+ }),
95
+ { launched: false },
96
+ );
97
+ assert.deepEqual(
98
+ hostAdapterForSessionId('codex:thread').afterBatonWrite({
99
+ trigger: 'tl', sessionId: 'codex:thread', cwd: '/work', continueRun: noLaunch,
100
+ }),
101
+ { launched: false },
102
+ );
103
+ });
104
+
105
+ test('Grok afterBatonWrite calls grok-continue with the source session', () => {
106
+ const calls = [];
107
+ const result = hostAdapterForSessionId('grok:abc').afterBatonWrite({
108
+ trigger: 'tl',
109
+ sessionId: 'grok:abc',
110
+ cwd: '/work/Throughline',
111
+ continueRun: (argv, opts) => {
112
+ calls.push({ argv, opts });
113
+ return 0;
114
+ },
115
+ });
116
+ assert.deepEqual(result, { launched: true, exitCode: 0 });
117
+ assert.deepEqual(calls, [{
118
+ argv: ['--session', 'grok:abc'],
119
+ opts: { cwd: '/work/Throughline' },
120
+ }]);
121
+ });
122
+
123
+ test('isBatonCommand: Grok user_query wrap around /tl', () => {
124
+ const wrapped =
125
+ '<user_query>\n/tl\n</user_query>\n<skill_information>\nThroughline saved the baton.\n</skill_information>';
126
+ assert.equal(isBatonCommand(wrapped), true);
127
+ });
128
+
129
+ test('isBatonCommand: Grok wrap does not treat skill body /tl as the command', () => {
130
+ const wrapped =
131
+ '<user_query>\n続けてくれ\n</user_query>\n<skill_information>\nType /tl to hand off.\n</skill_information>';
132
+ assert.equal(isBatonCommand(wrapped), false);
133
+ });
134
+
135
+ test('isClearCommand: Grok user_query wrap around /clear and /new', () => {
136
+ assert.equal(isClearCommand('<user_query>\n/clear\n</user_query>'), true);
137
+ assert.equal(isClearCommand('<user_query>\n/new\n</user_query>'), true);
138
+ assert.equal(isClearCommand('/new'), true);
139
+ assert.equal(isClearCommand('/newest'), false);
140
+ });
@@ -6,7 +6,7 @@ import { tmpdir } from 'node:os';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { defaultFactoryReporterConfigPath, defaultRuntimeErrorStorePath } from './runtime-error-store.mjs';
9
- import { applyWindowsPrivateAcl } from './windows-acl-test-helper.mjs';
9
+ import { applyWindowsPrivateAcl } from './os/windows-acl-test-helper.mjs';
10
10
 
11
11
  const BIN = fileURLToPath(new URL('../bin/throughline.mjs', import.meta.url));
12
12
 
@@ -15,6 +15,7 @@ import { dirname, join } from 'node:path';
15
15
  import { createRequire } from 'node:module';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { DatabaseSync } from 'node:sqlite';
18
+ import { applyAndVerifyWindowsAcl, isWindows, verifyWindowsAcl } from './os/windows-acl.mjs';
18
19
 
19
20
  const require = createRequire(import.meta.url);
20
21
  const PACKAGE_VERSION = require('../package.json').version;
@@ -25,8 +26,6 @@ export const RUNTIME_ERROR_DIAGNOSTIC = '[throughline:runtime-errors] store_unav
25
26
  const DEFAULT_SNAPSHOT_LIMIT = 256;
26
27
  const BEST_EFFORT_TIMEOUT_MS = 750;
27
28
  const WINDOWS_BEST_EFFORT_TIMEOUT_MS = 5_000;
28
- // CI実測でPowerShellコールドスタートが3.0〜3.2秒に達しflakeしたため15秒 (run 29586852389 / 29628634501)
29
- const WINDOWS_ACL_TIMEOUT_MS = 15_000;
30
29
  const RESOLUTION_REASONS = new Set(['manual', 'recovered']);
31
30
  const PRIVATE_DIRECTORY_CAPABILITY = Symbol('throughline.private-directory');
32
31
 
@@ -55,7 +54,7 @@ const DEFINITIONS = Object.freeze({
55
54
 
56
55
  export function defaultFactoryReporterConfigPath(env = process.env) {
57
56
  const home = env.HOME || env.USERPROFILE || homedir();
58
- if ((env.OS === 'Windows_NT' || hostPlatform() === 'win32')) {
57
+ if (isWindows(env)) {
59
58
  return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'dotagents', 'factory-reporter', 'config.json');
60
59
  }
61
60
  return join(env.XDG_CONFIG_HOME || join(home, '.config'), 'dotagents', 'factory-reporter.json');
@@ -63,7 +62,7 @@ export function defaultFactoryReporterConfigPath(env = process.env) {
63
62
 
64
63
  export function defaultRuntimeErrorStorePath(env = process.env) {
65
64
  const home = env.HOME || env.USERPROFILE || homedir();
66
- if ((env.OS === 'Windows_NT' || hostPlatform() === 'win32')) {
65
+ if (isWindows(env)) {
67
66
  return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'throughline', 'runtime-errors.json');
68
67
  }
69
68
  return join(env.XDG_STATE_HOME || join(home, '.local', 'state'), 'throughline', 'runtime-errors.json');
@@ -412,10 +411,6 @@ function assertPrivateStoreFileShape(info) {
412
411
  }
413
412
  }
414
413
 
415
- function isWindows(env = process.env) {
416
- return env.OS === 'Windows_NT' || hostPlatform() === 'win32';
417
- }
418
-
419
414
  function isCanonicalFactoryReporterConfig(value) {
420
415
  if (!isPlainObject(value) || !exactKeys(value, ['schema_version', 'host', 'collection', 'reporting']) || value.schema_version !== '1.0') return false;
421
416
  if (!isPlainObject(value.host) || !exactKeys(value.host, ['id', 'profile']) ||
@@ -555,38 +550,6 @@ function assertPosixOwnerMode(info, expectedMode) {
555
550
  if (typeof process.getuid === 'function' && info.uid !== process.getuid()) throw new Error('runtime error store owner unsafe');
556
551
  }
557
552
 
558
- function applyAndVerifyWindowsAcl(path, directory) {
559
- runWindowsAclScript(path, directory, WINDOWS_ACL_APPLY_SCRIPT);
560
- }
561
-
562
- function verifyWindowsAcl(path, directory) {
563
- runWindowsAclScript(path, directory, WINDOWS_ACL_VERIFY_SCRIPT);
564
- }
565
-
566
- function runWindowsAclScript(path, directory, script) {
567
- const result = childProcess.spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
568
- env: { ...process.env, FACTORY_ACL_PATH: path, FACTORY_ACL_DIRECTORY: directory ? '1' : '0' },
569
- stdio: 'ignore', timeout: WINDOWS_ACL_TIMEOUT_MS, windowsHide: true,
570
- });
571
- if (result.status !== 0) throw new Error('Windows owner-only ACL verification failed');
572
- }
573
-
574
- const WINDOWS_ACL_VERIFY_SCRIPT = String.raw`
575
- $p=$env:FACTORY_ACL_PATH; $isDir=$env:FACTORY_ACL_DIRECTORY -eq '1'; $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
576
- $acl=if($isDir){[System.IO.Directory]::GetAccessControl($p)}else{[System.IO.File]::GetAccessControl($p)}
577
- $owner=$acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
578
- if($owner -ne $sid){exit 41}; $rules=@($acl.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier])); if($rules.Count -ne 1){exit 42}
579
- $r=$rules[0]; if($r.IdentityReference.Value -ne $sid -or $r.AccessControlType -ne 'Allow' -or $r.IsInherited -or ($r.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl){exit 43}
580
- `;
581
-
582
- const WINDOWS_ACL_APPLY_SCRIPT = String.raw`
583
- $p=$env:FACTORY_ACL_PATH; $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User
584
- $isDir=$env:FACTORY_ACL_DIRECTORY -eq '1'; $acl=if($isDir){New-Object System.Security.AccessControl.DirectorySecurity}else{New-Object System.Security.AccessControl.FileSecurity}; $acl.SetAccessRuleProtection($true,$false)
585
- $flags=if($isDir){[System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'}else{[System.Security.AccessControl.InheritanceFlags]::None}
586
- $rule=New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl',$flags,[System.Security.AccessControl.PropagationFlags]::None,[System.Security.AccessControl.AccessControlType]::Allow)
587
- $acl.SetOwner($sid); $acl.AddAccessRule($rule); if($isDir){[System.IO.Directory]::SetAccessControl($p,$acl)}else{[System.IO.File]::SetAccessControl($p,$acl)}
588
- ` + WINDOWS_ACL_VERIFY_SCRIPT;
589
-
590
553
  function assertExactInput(input, allowed) {
591
554
  if (!input || typeof input !== 'object' || Array.isArray(input) ||
592
555
  Object.keys(input).some((key) => !allowed.includes(key))) {
@@ -16,7 +16,7 @@ import {
16
16
  reopenRuntimeError,
17
17
  resolveRuntimeError,
18
18
  } from './runtime-error-store.mjs';
19
- import { applyWindowsPrivateAcl, verifyWindowsPrivateAcl } from './windows-acl-test-helper.mjs';
19
+ import { applyWindowsPrivateAcl, verifyWindowsPrivateAcl } from './os/windows-acl-test-helper.mjs';
20
20
 
21
21
  const TEST_PLATFORM = process.platform === 'win32' ? 'win32' : 'darwin';
22
22
 
@@ -32,7 +32,7 @@ import { logDecision } from './decision-log.mjs';
32
32
  import { existsSync } from 'node:fs';
33
33
  import { pathToFileURL } from 'node:url';
34
34
  import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
35
- import { isUnsupportedNonClaudeEnvelope } from './hook-envelope.mjs';
35
+ import { NON_CLAUDE_SESSION_PREFIXES, normalizeHookPayload } from './hosts/index.mjs';
36
36
 
37
37
  const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
38
38
 
@@ -55,17 +55,26 @@ function isAutoHandoffDisabled(env) {
55
55
  * @returns {{ session_id: string } | null}
56
56
  */
57
57
  function findLatestClaudePredecessor(db, projectPath, currentSessionId) {
58
+ // Claude 以外の host session (prefix 付き) を前任候補から除外する。
59
+ // prefix の正本は hosts/identity.mjs。
60
+ const nonClaudeExclusion = NON_CLAUDE_SESSION_PREFIXES
61
+ .map(() => 'AND session_id NOT LIKE ?')
62
+ .join('\n ');
58
63
  const candidates = db
59
64
  .prepare(
60
65
  `SELECT session_id FROM sessions
61
66
  WHERE lower(project_path) = lower(?)
62
67
  AND merged_into IS NULL
63
68
  AND session_id != ?
64
- AND session_id NOT LIKE 'codex:%'
69
+ ${nonClaudeExclusion}
65
70
  ORDER BY updated_at DESC
66
71
  LIMIT 5`,
67
72
  )
68
- .all(projectPath, currentSessionId);
73
+ .all(
74
+ projectPath,
75
+ currentSessionId,
76
+ ...NON_CLAUDE_SESSION_PREFIXES.map((prefix) => `${prefix}%`),
77
+ );
69
78
 
70
79
  if (candidates.length === 0) return null;
71
80
 
@@ -91,8 +100,7 @@ export async function run() {
91
100
  process.stdin.on('end', resolve);
92
101
  });
93
102
 
94
- const payload = JSON.parse(raw);
95
- if (isUnsupportedNonClaudeEnvelope(payload)) return;
103
+ const payload = normalizeHookPayload(JSON.parse(raw));
96
104
  const { session_id, cwd, source, transcript_path } = payload;
97
105
 
98
106
  if (!session_id) throw new Error('Missing session_id in SessionStart payload');
@@ -12,8 +12,10 @@
12
12
  */
13
13
 
14
14
  import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync, existsSync } from 'node:fs';
15
- import { homedir, platform } from 'node:os';
15
+ import { homedir } from 'node:os';
16
16
  import { join, resolve } from 'node:path';
17
+ import { CLAUDE_HOST, KNOWN_STATE_HOSTS } from './hosts/identity.mjs';
18
+ import { foldPathCaseForPlatform } from './os/paths.mjs';
17
19
 
18
20
  const STATE_DIR = join(homedir(), '.throughline', 'state');
19
21
 
@@ -31,8 +33,7 @@ export function normalizeProjectPath(p) {
31
33
  if (!p) return '';
32
34
  let result = resolve(p).replace(/\\/g, '/');
33
35
  if (result.length > 1 && result.endsWith('/')) result = result.slice(0, -1);
34
- if (platform() === 'win32') result = result.toLowerCase();
35
- return result;
36
+ return foldPathCaseForPlatform(result);
36
37
  }
37
38
 
38
39
  /**
@@ -155,8 +156,8 @@ function stateFilename(sessionId) {
155
156
  }
156
157
 
157
158
  function normalizeHost(host) {
158
- if (host === undefined || host === null || host === '') return 'claude';
159
- if (host === 'claude' || host === 'codex') return host;
159
+ if (host === undefined || host === null || host === '') return CLAUDE_HOST;
160
+ if (KNOWN_STATE_HOSTS.includes(host)) return host;
160
161
  return 'unknown';
161
162
  }
162
163
 
@@ -28,6 +28,7 @@ import { buildCodexMonitorUsage } from './codex-usage.mjs';
28
28
  import { listCodexThreadCandidates } from './codex-thread-index.mjs';
29
29
  import { readLatestUsage } from './transcript-usage.mjs';
30
30
  import { startSizeQuery } from './terminal-size.mjs';
31
+ import { CODEX_HOST, CODEX_SESSION_PREFIX, codexSessionIdToThreadId } from './hosts/identity.mjs';
31
32
 
32
33
  const REFRESH_MS = 1000;
33
34
  // データ変化が無くても N ms ごとに再描画して「(24m ago)」表示を進める。
@@ -292,7 +293,7 @@ export function resolveColumns() {
292
293
  function formatLine({ state, usage, isActive, now = Date.now() }) {
293
294
  const project = basename(state.projectPath || '?');
294
295
  const shortId = formatShortSessionId(state);
295
- const host = state.host === 'codex' ? 'Codex' : state.host === 'unknown' ? 'Unknown' : 'Claude';
296
+ const host = state.host === CODEX_HOST ? 'Codex' : state.host === 'unknown' ? 'Unknown' : 'Claude';
296
297
  const tokens = usage?.tokens ?? 0;
297
298
  const max = usage?.contextWindowSize ?? 200_000;
298
299
  const ratio = max > 0 ? tokens / max : 0;
@@ -337,8 +338,8 @@ function formatLine({ state, usage, isActive, now = Date.now() }) {
337
338
 
338
339
  function formatShortSessionId(state) {
339
340
  const sessionId = String(state?.sessionId ?? '');
340
- if (state?.host === 'codex' && sessionId.startsWith('codex:')) {
341
- return sessionId.slice('codex:'.length, 'codex:'.length + 8);
341
+ if (state?.host === CODEX_HOST && sessionId.startsWith(CODEX_SESSION_PREFIX)) {
342
+ return (codexSessionIdToThreadId(sessionId) ?? '').slice(0, 8);
342
343
  }
343
344
  return sessionId.slice(0, 8);
344
345
  }
@@ -372,7 +373,7 @@ function withLiveActivity(state, now = Date.now()) {
372
373
  }
373
374
 
374
375
  function resolveMonitorUsage(state) {
375
- if (state.host === 'codex' && state.rolloutPath) {
376
+ if (state.host === CODEX_HOST && state.rolloutPath) {
376
377
  return buildCodexMonitorUsage(state.rolloutPath) ?? state.usage ?? null;
377
378
  }
378
379
  if (state.transcriptPath) {
@@ -410,8 +411,8 @@ function discoverCodexSessionStates(args = {}, cwd = process.cwd()) {
410
411
 
411
412
  lastCodexDiscoveryError = null;
412
413
  return candidates.map((candidate) => ({
413
- sessionId: `codex:${candidate.id}`,
414
- host: 'codex',
414
+ sessionId: `${CODEX_SESSION_PREFIX}${candidate.id}`,
415
+ host: CODEX_HOST,
415
416
  projectPath: normalizeProjectPath(candidate.cwd ?? cwd),
416
417
  transcriptPath: null,
417
418
  rolloutPath: candidate.rolloutPath,
@@ -0,0 +1,37 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import { getLogicalTurnGroups, readTranscript } from './transcript-reader.mjs';
8
+
9
+ test('readTranscript accepts Grok chat_history.jsonl user/assistant rows', () => {
10
+ const dir = mkdtempSync(join(tmpdir(), 'tl-grok-transcript-'));
11
+ const path = join(dir, 'chat_history.jsonl');
12
+ writeFileSync(
13
+ path,
14
+ [
15
+ JSON.stringify({ type: 'system', content: 'ignore' }),
16
+ JSON.stringify({ type: 'user', content: [{ type: 'text', text: 'hello grok' }] }),
17
+ JSON.stringify({ type: 'assistant', content: 'captured reply' }),
18
+ JSON.stringify({ type: 'tool_result', content: 'not a turn' }),
19
+ ].join('\n'),
20
+ );
21
+ try {
22
+ const turns = readTranscript(path);
23
+ assert.deepEqual(
24
+ turns.map((t) => ({ role: t.role, content: t.content })),
25
+ [
26
+ { role: 'user', content: 'hello grok' },
27
+ { role: 'assistant', content: 'captured reply' },
28
+ ],
29
+ );
30
+ const groups = getLogicalTurnGroups(path);
31
+ assert.equal(groups.length, 1);
32
+ assert.equal(groups[0].user.content, 'hello grok');
33
+ assert.equal(groups[0].representative.content, 'captured reply');
34
+ } finally {
35
+ rmSync(dir, { recursive: true, force: true });
36
+ }
37
+ });
@@ -59,14 +59,18 @@ export function readTranscript(transcriptPath) {
59
59
  if (entry.isSidechain === true) continue;
60
60
 
61
61
  const msg = entry.message;
62
- if (!msg || !msg.role || msg.content == null) continue;
62
+ const grokContent = entry.content;
63
+ const role = msg?.role ?? entry.type;
64
+ const rawContent = msg?.content ?? grokContent;
65
+ if (!role || rawContent == null) continue;
63
66
 
64
- const text = extractText(msg.content);
67
+ const text = extractText(rawContent);
65
68
  if (!text) continue;
66
69
 
67
70
  const ts = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
71
+ // grok chat_history.jsonl は Claude の message 包みを持たず type/content 直置き。
68
72
  turns.push({
69
- role: msg.role,
73
+ role,
70
74
  content: text,
71
75
  turn_number: turns.length,
72
76
  timestamp: Number.isNaN(ts) ? null : ts,
@@ -46,7 +46,7 @@ import { readLatestUsage } from './transcript-usage.mjs';
46
46
  import { pathToFileURL } from 'node:url';
47
47
  import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
48
48
  import { writeCompletedTurnReceipt } from './completed-turn-receipts.mjs';
49
- import { isUnsupportedNonClaudeEnvelope } from './hook-envelope.mjs';
49
+ import { hostAdapterForSessionId, normalizeHookPayload } from './hosts/index.mjs';
50
50
 
51
51
  /** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
52
52
  export const L2_WINDOW = 20;
@@ -191,8 +191,7 @@ export async function run() {
191
191
  process.stdin.on('end', resolve);
192
192
  });
193
193
 
194
- const payload = JSON.parse(raw || '{}');
195
- if (isUnsupportedNonClaudeEnvelope(payload)) return;
194
+ const payload = normalizeHookPayload(JSON.parse(raw || '{}'));
196
195
  const { session_id, transcript_path, cwd, last_assistant_message } = payload;
197
196
  if (!session_id) throw new Error('Missing session_id in Stop payload');
198
197
 
@@ -206,10 +205,12 @@ export async function run() {
206
205
  process.stderr.write(`[vscode-task] ${msg}\n`);
207
206
  }
208
207
 
209
- await waitForClaudeStopTranscriptFlush({
210
- transcriptPath: transcript_path,
211
- lastAssistantMessage: last_assistant_message,
212
- });
208
+ if (hostAdapterForSessionId(session_id).waitsForStopTranscriptFlush) {
209
+ await waitForClaudeStopTranscriptFlush({
210
+ transcriptPath: transcript_path,
211
+ lastAssistantMessage: last_assistant_message,
212
+ });
213
+ }
213
214
 
214
215
  // Stop hook 時点で state ファイルを更新 → token-monitor の「アクティブ行」判定が
215
216
  // アシスタント応答終了時刻まで追従する
@@ -1,12 +0,0 @@
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) {
5
- return payload !== null
6
- && typeof payload === 'object'
7
- && typeof payload.sessionId === 'string'
8
- && payload.sessionId.length > 0
9
- && typeof payload.hookEventName === 'string'
10
- && payload.hookEventName.length > 0
11
- && !Object.hasOwn(payload, 'session_id');
12
- }