throughline 0.10.0 → 0.10.2

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 (50) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/README.ja.md +1 -1
  3. package/README.md +2 -2
  4. package/docs/16_readonly_handoff_context_plan.md +2 -2
  5. package/package.json +1 -1
  6. package/src/auditor-context.mjs +4 -3
  7. package/src/cli/auditor-context.mjs +2 -1
  8. package/src/cli/codex-handoff-start.mjs +5 -23
  9. package/src/cli/codex-summarize.mjs +2 -1
  10. package/src/cli/codex-visibility-smoke.mjs +5 -4
  11. package/src/cli/doctor.mjs +7 -6
  12. package/src/cli/grok-continue.mjs +8 -21
  13. package/src/cli/runtime-errors.test.mjs +1 -1
  14. package/src/cli/trim.mjs +3 -2
  15. package/src/codex-auto-refresh.mjs +3 -3
  16. package/src/codex-capture.mjs +14 -18
  17. package/src/codex-handoff-model-smoke.mjs +1 -1
  18. package/src/codex-sidecar.mjs +3 -2
  19. package/src/completed-turn-receipts.mjs +6 -33
  20. package/src/completed-turn-receipts.test.mjs +1 -1
  21. package/src/haiku-summarizer.mjs +1 -1
  22. package/src/handoff-record.mjs +3 -1
  23. package/src/hosts/claude.mjs +27 -0
  24. package/src/hosts/codex.mjs +28 -0
  25. package/src/hosts/grok.mjs +96 -0
  26. package/src/{hook-envelope.test.mjs → hosts/grok.test.mjs} +1 -1
  27. package/src/hosts/identity.mjs +71 -0
  28. package/src/hosts/identity.test.mjs +65 -0
  29. package/src/hosts/index.mjs +37 -0
  30. package/src/os/app-dirs.mjs +25 -0
  31. package/src/os/macos-terminal.mjs +44 -0
  32. package/src/os/open-url.mjs +16 -0
  33. package/src/os/paths.mjs +16 -0
  34. package/src/os/shell.mjs +16 -0
  35. package/src/os/windows-acl.mjs +57 -0
  36. package/src/project-path.mjs +4 -4
  37. package/src/prompt-submit.mjs +23 -55
  38. package/src/prompt-submit.test.mjs +27 -10
  39. package/src/runtime-error-hook.test.mjs +1 -1
  40. package/src/runtime-error-store.mjs +9 -47
  41. package/src/runtime-error-store.test.mjs +1 -1
  42. package/src/session-start.mjs +12 -4
  43. package/src/state-file.mjs +6 -5
  44. package/src/token-monitor.mjs +7 -6
  45. package/src/trim-model.mjs +6 -5
  46. package/src/turn-processor.mjs +2 -2
  47. package/src/hook-envelope.mjs +0 -51
  48. /package/src/{portable-spawn-sync.mjs → os/portable-spawn-sync.mjs} +0 -0
  49. /package/src/{portable-spawn-sync.test.mjs → os/portable-spawn-sync.test.mjs} +0 -0
  50. /package/src/{windows-acl-test-helper.mjs → os/windows-acl-test-helper.mjs} +0 -0
@@ -4,9 +4,8 @@ import {
4
4
  commandTextFromPrompt,
5
5
  isBatonCommand,
6
6
  isClearCommand,
7
- launchGrokContinueAfterTl,
8
- shouldLaunchGrokContinue,
9
7
  } from './prompt-submit.mjs';
8
+ import { hostAdapterForSessionId } from './hosts/index.mjs';
10
9
 
11
10
  test('isBatonCommand: bare /tl', () => {
12
11
  assert.equal(isBatonCommand('/tl'), true);
@@ -79,16 +78,34 @@ test('commandTextFromPrompt unwraps Grok user_query and leaves bare Claude text'
79
78
  );
80
79
  });
81
80
 
82
- test('shouldLaunchGrokContinue is only Grok /tl', () => {
83
- assert.equal(shouldLaunchGrokContinue({ sessionId: 'grok:abc', trigger: 'tl' }), true);
84
- assert.equal(shouldLaunchGrokContinue({ sessionId: 'grok:abc', trigger: 'clear' }), false);
85
- assert.equal(shouldLaunchGrokContinue({ sessionId: 'old-session', trigger: 'tl' }), false);
86
- assert.equal(shouldLaunchGrokContinue({ sessionId: 'codex:thread', trigger: 'tl' }), false);
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
+ );
87
103
  });
88
104
 
89
- test('launchGrokContinueAfterTl calls grok-continue with the source session', () => {
105
+ test('Grok afterBatonWrite calls grok-continue with the source session', () => {
90
106
  const calls = [];
91
- const code = launchGrokContinueAfterTl({
107
+ const result = hostAdapterForSessionId('grok:abc').afterBatonWrite({
108
+ trigger: 'tl',
92
109
  sessionId: 'grok:abc',
93
110
  cwd: '/work/Throughline',
94
111
  continueRun: (argv, opts) => {
@@ -96,7 +113,7 @@ test('launchGrokContinueAfterTl calls grok-continue with the source session', ()
96
113
  return 0;
97
114
  },
98
115
  });
99
- assert.equal(code, 0);
116
+ assert.deepEqual(result, { launched: true, exitCode: 0 });
100
117
  assert.deepEqual(calls, [{
101
118
  argv: ['--session', 'grok:abc'],
102
119
  opts: { cwd: '/work/Throughline' },
@@ -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
 
@@ -10,11 +10,13 @@ import {
10
10
  writeFileSync,
11
11
  } from 'node:fs';
12
12
  import childProcess from 'node:child_process';
13
- import { arch as hostArch, homedir, platform as hostPlatform } from 'node:os';
13
+ import { arch as hostArch, platform as hostPlatform } from 'node:os';
14
14
  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';
19
+ import { windowsLocalAppData, xdgConfigHome, xdgStateHome } from './os/app-dirs.mjs';
18
20
 
19
21
  const require = createRequire(import.meta.url);
20
22
  const PACKAGE_VERSION = require('../package.json').version;
@@ -25,8 +27,6 @@ export const RUNTIME_ERROR_DIAGNOSTIC = '[throughline:runtime-errors] store_unav
25
27
  const DEFAULT_SNAPSHOT_LIMIT = 256;
26
28
  const BEST_EFFORT_TIMEOUT_MS = 750;
27
29
  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
30
  const RESOLUTION_REASONS = new Set(['manual', 'recovered']);
31
31
  const PRIVATE_DIRECTORY_CAPABILITY = Symbol('throughline.private-directory');
32
32
 
@@ -54,19 +54,17 @@ const DEFINITIONS = Object.freeze({
54
54
  });
55
55
 
56
56
  export function defaultFactoryReporterConfigPath(env = process.env) {
57
- const home = env.HOME || env.USERPROFILE || homedir();
58
- if ((env.OS === 'Windows_NT' || hostPlatform() === 'win32')) {
59
- return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'dotagents', 'factory-reporter', 'config.json');
57
+ if (isWindows(env)) {
58
+ return join(windowsLocalAppData(env), 'dotagents', 'factory-reporter', 'config.json');
60
59
  }
61
- return join(env.XDG_CONFIG_HOME || join(home, '.config'), 'dotagents', 'factory-reporter.json');
60
+ return join(xdgConfigHome(env), 'dotagents', 'factory-reporter.json');
62
61
  }
63
62
 
64
63
  export function defaultRuntimeErrorStorePath(env = process.env) {
65
- const home = env.HOME || env.USERPROFILE || homedir();
66
- if ((env.OS === 'Windows_NT' || hostPlatform() === 'win32')) {
67
- return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'throughline', 'runtime-errors.json');
64
+ if (isWindows(env)) {
65
+ return join(windowsLocalAppData(env), 'throughline', 'runtime-errors.json');
68
66
  }
69
- return join(env.XDG_STATE_HOME || join(home, '.local', 'state'), 'throughline', 'runtime-errors.json');
67
+ return join(xdgStateHome(env), 'throughline', 'runtime-errors.json');
70
68
  }
71
69
 
72
70
  export function isRuntimeErrorCollectionEnabled({ env = process.env, configPath } = {}) {
@@ -412,10 +410,6 @@ function assertPrivateStoreFileShape(info) {
412
410
  }
413
411
  }
414
412
 
415
- function isWindows(env = process.env) {
416
- return env.OS === 'Windows_NT' || hostPlatform() === 'win32';
417
- }
418
-
419
413
  function isCanonicalFactoryReporterConfig(value) {
420
414
  if (!isPlainObject(value) || !exactKeys(value, ['schema_version', 'host', 'collection', 'reporting']) || value.schema_version !== '1.0') return false;
421
415
  if (!isPlainObject(value.host) || !exactKeys(value.host, ['id', 'profile']) ||
@@ -555,38 +549,6 @@ function assertPosixOwnerMode(info, expectedMode) {
555
549
  if (typeof process.getuid === 'function' && info.uid !== process.getuid()) throw new Error('runtime error store owner unsafe');
556
550
  }
557
551
 
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
552
  function assertExactInput(input, allowed) {
591
553
  if (!input || typeof input !== 'object' || Array.isArray(input) ||
592
554
  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 { normalizeHookPayload } 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,18 +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:%'
65
- AND session_id NOT LIKE 'grok:%'
69
+ ${nonClaudeExclusion}
66
70
  ORDER BY updated_at DESC
67
71
  LIMIT 5`,
68
72
  )
69
- .all(projectPath, currentSessionId);
73
+ .all(
74
+ projectPath,
75
+ currentSessionId,
76
+ ...NON_CLAUDE_SESSION_PREFIXES.map((prefix) => `${prefix}%`),
77
+ );
70
78
 
71
79
  if (candidates.length === 0) return null;
72
80
 
@@ -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,
@@ -1,4 +1,5 @@
1
1
  import { inspectCodexPlannedRollbackRestoreSafety } from './codex-rollout-memory.mjs';
2
+ import { CLAUDE_HOST, CODEX_HOST } from './hosts/identity.mjs';
2
3
  import { buildHandoffRecord, N_RECENT_L2 } from './handoff-record.mjs';
3
4
  import { sameProjectPath } from './project-path.mjs';
4
5
  import { estimateTokens } from './token-estimator.mjs';
@@ -48,7 +49,7 @@ export function findLatestSessionIdForProject(db, projectPath) {
48
49
 
49
50
  function resolveDefaultSessionId({ sessionId, host, codexThreadId, db, projectPath }) {
50
51
  if (sessionId) return sessionId;
51
- if (host === 'codex') {
52
+ if (host === CODEX_HOST) {
52
53
  return codexThreadId ? `codex:${codexThreadId}` : null;
53
54
  }
54
55
  return findLatestSessionIdForProject(db, projectPath);
@@ -79,7 +80,7 @@ function countDistinctCapturedTurns(db, sessionId) {
79
80
  }
80
81
 
81
82
  export function describeTrimHost(host) {
82
- if (host === 'claude') {
83
+ if (host === CLAUDE_HOST) {
83
84
  return {
84
85
  host,
85
86
  automaticRollback: false,
@@ -94,7 +95,7 @@ export function describeTrimHost(host) {
94
95
  };
95
96
  }
96
97
 
97
- if (host === 'codex') {
98
+ if (host === CODEX_HOST) {
98
99
  return {
99
100
  host,
100
101
  automaticRollback: true,
@@ -123,7 +124,7 @@ export function describeTrimHost(host) {
123
124
  }
124
125
 
125
126
  function buildSafeContinuation({ host, hostIdentity }) {
126
- if (host !== 'codex') return null;
127
+ if (host !== CODEX_HOST) return null;
127
128
 
128
129
  const threadId = hostIdentity?.codexThreadId ?? '<thread-id>';
129
130
  const sessionId = threadId === '<thread-id>' ? 'codex:<thread-id>' : `codex:${threadId}`;
@@ -561,7 +562,7 @@ function buildPlanSession({ resolvedSessionId, session, trimSource, projectPath
561
562
  }
562
563
 
563
564
  function buildHostIdentity({ host, codexThreadId, codexThreadIdSource = null }) {
564
- if (host !== 'codex') {
565
+ if (host !== CODEX_HOST) {
565
566
  return {
566
567
  host,
567
568
  codexThreadId: null,
@@ -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 { normalizeHookPayload } 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;
@@ -205,7 +205,7 @@ export async function run() {
205
205
  process.stderr.write(`[vscode-task] ${msg}\n`);
206
206
  }
207
207
 
208
- if (!session_id.startsWith('grok:')) {
208
+ if (hostAdapterForSessionId(session_id).waitsForStopTranscriptFlush) {
209
209
  await waitForClaudeStopTranscriptFlush({
210
210
  transcriptPath: transcript_path,
211
211
  lastAssistantMessage: last_assistant_message,
@@ -1,51 +0,0 @@
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) {
11
- return payload !== null
12
- && typeof payload === 'object'
13
- && typeof payload.sessionId === 'string'
14
- && payload.sessionId.length > 0
15
- && typeof payload.hookEventName === 'string'
16
- && payload.hookEventName.length > 0
17
- && !Object.hasOwn(payload, 'session_id');
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
- }