throughline 0.10.0 → 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 (41) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/package.json +1 -1
  3. package/src/auditor-context.mjs +2 -1
  4. package/src/cli/codex-handoff-start.mjs +5 -23
  5. package/src/cli/codex-summarize.mjs +2 -1
  6. package/src/cli/codex-visibility-smoke.mjs +5 -4
  7. package/src/cli/grok-continue.mjs +8 -21
  8. package/src/cli/runtime-errors.test.mjs +1 -1
  9. package/src/codex-capture.mjs +14 -18
  10. package/src/codex-handoff-model-smoke.mjs +1 -1
  11. package/src/codex-sidecar.mjs +1 -1
  12. package/src/completed-turn-receipts.mjs +2 -29
  13. package/src/completed-turn-receipts.test.mjs +1 -1
  14. package/src/haiku-summarizer.mjs +1 -1
  15. package/src/handoff-record.mjs +3 -1
  16. package/src/hosts/claude.mjs +27 -0
  17. package/src/hosts/codex.mjs +28 -0
  18. package/src/hosts/grok.mjs +96 -0
  19. package/src/{hook-envelope.test.mjs → hosts/grok.test.mjs} +1 -1
  20. package/src/hosts/identity.mjs +71 -0
  21. package/src/hosts/identity.test.mjs +65 -0
  22. package/src/hosts/index.mjs +37 -0
  23. package/src/os/macos-terminal.mjs +44 -0
  24. package/src/os/open-url.mjs +16 -0
  25. package/src/os/paths.mjs +12 -0
  26. package/src/os/shell.mjs +16 -0
  27. package/src/os/windows-acl.mjs +57 -0
  28. package/src/project-path.mjs +4 -4
  29. package/src/prompt-submit.mjs +23 -55
  30. package/src/prompt-submit.test.mjs +27 -10
  31. package/src/runtime-error-hook.test.mjs +1 -1
  32. package/src/runtime-error-store.mjs +3 -40
  33. package/src/runtime-error-store.test.mjs +1 -1
  34. package/src/session-start.mjs +12 -4
  35. package/src/state-file.mjs +6 -5
  36. package/src/token-monitor.mjs +7 -6
  37. package/src/turn-processor.mjs +2 -2
  38. package/src/hook-envelope.mjs +0 -51
  39. /package/src/{portable-spawn-sync.mjs → os/portable-spawn-sync.mjs} +0 -0
  40. /package/src/{portable-spawn-sync.test.mjs → os/portable-spawn-sync.test.mjs} +0 -0
  41. /package/src/{windows-acl-test-helper.mjs → os/windows-acl-test-helper.mjs} +0 -0
@@ -0,0 +1,65 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import {
5
+ CODEX_SESSION_PREFIX,
6
+ GROK_SESSION_PREFIX,
7
+ NON_CLAUDE_SESSION_PREFIXES,
8
+ buildCodexThroughlineSessionId,
9
+ codexSessionIdToThreadId,
10
+ grokBareSessionId,
11
+ hostOfSessionId,
12
+ isCodexSessionId,
13
+ isGrokSessionId,
14
+ } from './identity.mjs';
15
+ import { hostAdapterForSessionId } from './index.mjs';
16
+
17
+ test('session prefixes are the canonical wire values', () => {
18
+ assert.equal(CODEX_SESSION_PREFIX, 'codex:');
19
+ assert.equal(GROK_SESSION_PREFIX, 'grok:');
20
+ assert.deepEqual([...NON_CLAUDE_SESSION_PREFIXES], ['codex:', 'grok:']);
21
+ });
22
+
23
+ test('hostOfSessionId maps prefixes to hosts and defaults to claude', () => {
24
+ assert.equal(hostOfSessionId('codex:0199aa'), 'codex');
25
+ assert.equal(hostOfSessionId('grok:0199aa'), 'grok');
26
+ assert.equal(hostOfSessionId('0199aa-claude'), 'claude');
27
+ assert.equal(hostOfSessionId(undefined), 'claude');
28
+ });
29
+
30
+ test('codex session id round-trips thread id', () => {
31
+ assert.equal(buildCodexThroughlineSessionId(' t1 '), 'codex:t1');
32
+ assert.equal(codexSessionIdToThreadId('codex:t1'), 't1');
33
+ assert.equal(codexSessionIdToThreadId('t1'), null);
34
+ assert.equal(codexSessionIdToThreadId('codex:'), null);
35
+ assert.throws(() => buildCodexThroughlineSessionId(' '));
36
+ });
37
+
38
+ test('grokBareSessionId strips only the grok prefix', () => {
39
+ assert.equal(grokBareSessionId('grok:abc'), 'abc');
40
+ assert.equal(grokBareSessionId('abc'), 'abc');
41
+ assert.equal(grokBareSessionId(''), null);
42
+ });
43
+
44
+ test('isCodexSessionId / isGrokSessionId reject non-strings', () => {
45
+ assert.equal(isCodexSessionId(null), false);
46
+ assert.equal(isGrokSessionId(undefined), false);
47
+ });
48
+
49
+ test('every host adapter satisfies the shared hook contract', () => {
50
+ for (const sessionId of ['claude-session', 'codex:t1', 'grok:t1']) {
51
+ const adapter = hostAdapterForSessionId(sessionId);
52
+ assert.equal(typeof adapter.host, 'string');
53
+ assert.equal(adapter.matchesSessionId(sessionId), true);
54
+ assert.equal(typeof adapter.waitsForStopTranscriptFlush, 'boolean');
55
+ assert.equal(typeof adapter.deliverHandoffInjection, 'function');
56
+ assert.equal(typeof adapter.resolveCommandPrompt, 'function');
57
+ assert.equal(typeof adapter.afterBatonWrite, 'function');
58
+ }
59
+ });
60
+
61
+ test('flush barrier applies to claude and codex sessions, not grok', () => {
62
+ assert.equal(hostAdapterForSessionId('claude-session').waitsForStopTranscriptFlush, true);
63
+ assert.equal(hostAdapterForSessionId('codex:t1').waitsForStopTranscriptFlush, true);
64
+ assert.equal(hostAdapterForSessionId('grok:t1').waitsForStopTranscriptFlush, false);
65
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * hosts/index.mjs — host 境界の入口
3
+ *
4
+ * 共有 hook 入口 (session-start / prompt-submit / turn-processor) はここから
5
+ * `normalizeHookPayload` と `hostAdapterForSessionId` だけを使い、
6
+ * ベンダー分岐を直接書かない。
7
+ */
8
+ import { hostOfSessionId, CLAUDE_HOST, CODEX_HOST, GROK_HOST } from './identity.mjs';
9
+ import { claudeHostAdapter } from './claude.mjs';
10
+ import { codexHostAdapter } from './codex.mjs';
11
+ import { grokHostAdapter, isGrokEnvelope, normalizeGrokHookPayload } from './grok.mjs';
12
+
13
+ export * from './identity.mjs';
14
+ export { isGrokEnvelope, deriveGrokChatHistoryPath, normalizeGrokHookPayload } from './grok.mjs';
15
+
16
+ const ADAPTERS = Object.freeze({
17
+ [CLAUDE_HOST]: claudeHostAdapter,
18
+ [CODEX_HOST]: codexHostAdapter,
19
+ [GROK_HOST]: grokHostAdapter,
20
+ });
21
+
22
+ /**
23
+ * hook stdin payload を Claude snake_case 契約へ正規化する。
24
+ * 現状 camelCase envelope を送るのは Grok だけ (v0.10.0 / ADR 0021)。
25
+ */
26
+ export function normalizeHookPayload(payload, options = {}) {
27
+ if (isGrokEnvelope(payload)) return normalizeGrokHookPayload(payload, options);
28
+ return payload;
29
+ }
30
+
31
+ /**
32
+ * @param {string} sessionId
33
+ * @returns {typeof claudeHostAdapter}
34
+ */
35
+ export function hostAdapterForSessionId(sessionId) {
36
+ return ADAPTERS[hostOfSessionId(sessionId)];
37
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * os/macos-terminal.mjs — macOS Terminal.app で対話 process を立てる
3
+ *
4
+ * 2 つの起動形を提供する (どちらも osascript 経由):
5
+ * - runTerminalScriptDetached: launch script の path を quoted form で exec
6
+ * (grok-continue が使う detached 起動)
7
+ * - runTerminalDoScript: shell command 1 行を do script で実行 (codex resume 用)
8
+ *
9
+ * macOS 以外では呼ばないこと。platform gate は呼び出し元の責務
10
+ * (explicit error にするため、ここで silent no-op にしない)。
11
+ */
12
+ import { spawn, spawnSync } from 'node:child_process';
13
+
14
+ import { appleString } from './shell.mjs';
15
+
16
+ export function appleScriptForTerminalExec(launchScriptPath) {
17
+ return [
18
+ 'tell application "Terminal"',
19
+ ' activate',
20
+ ` do script "exec " & quoted form of ${JSON.stringify(launchScriptPath)}`,
21
+ 'end tell',
22
+ '',
23
+ ].join('\n');
24
+ }
25
+
26
+ export function runTerminalScriptDetached({ launchFile, spawnImpl = spawn }) {
27
+ const child = spawnImpl('osascript', ['-e', appleScriptForTerminalExec(launchFile)], {
28
+ detached: true,
29
+ stdio: 'ignore',
30
+ });
31
+ child.unref?.();
32
+ return child;
33
+ }
34
+
35
+ export function runTerminalDoScript(shellCommand, { spawnImpl = spawnSync } = {}) {
36
+ return spawnImpl('osascript', [], {
37
+ input: `tell application "Terminal"
38
+ activate
39
+ do script ${appleString(shellCommand)}
40
+ end tell
41
+ `,
42
+ encoding: 'utf8',
43
+ });
44
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * os/open-url.mjs — deep link / URL を OS 既定の handler で開く
3
+ *
4
+ * darwin: `open` / win32: `cmd /c start` / その他: `xdg-open`。
5
+ * 呼び出し元は result.status !== 0 を explicit failure として扱う。
6
+ */
7
+ import { spawnSync } from 'node:child_process';
8
+
9
+ export function openUrlWithOsHandler(url, {
10
+ platform = process.platform,
11
+ spawnImpl = spawnSync,
12
+ } = {}) {
13
+ if (platform === 'darwin') return spawnImpl('open', [url], { encoding: 'utf8' });
14
+ if (platform === 'win32') return spawnImpl('cmd.exe', ['/c', 'start', '', url], { encoding: 'utf8' });
15
+ return spawnImpl('xdg-open', [url], { encoding: 'utf8' });
16
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * os/paths.mjs — path 正規化の OS 依存部分
3
+ *
4
+ * Windows の filesystem は case-insensitive のため、比較・保存用の
5
+ * 正規化 path は win32 でだけ小文字へ畳む。この判断が state-file と
6
+ * project-path に別々に書かれていたため、ここへ集約する。
7
+ */
8
+ import { platform } from 'node:os';
9
+
10
+ export function foldPathCaseForPlatform(path, { hostPlatform = platform() } = {}) {
11
+ return hostPlatform === 'win32' ? path.toLowerCase() : path;
12
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * os/shell.mjs — shell / AppleScript 文字列 quoting の唯一の正本
3
+ *
4
+ * grok-continue と codex-handoff-start がそれぞれ同じ POSIX single-quote
5
+ * escape を別実装していたため集約する。
6
+ */
7
+
8
+ /** POSIX shell single-quote escape */
9
+ export function shQuote(value) {
10
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
11
+ }
12
+
13
+ /** AppleScript の string literal escape */
14
+ export function appleString(value) {
15
+ return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
16
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * os/windows-acl.mjs — Windows owner-only ACL の適用と検証 (唯一の正本)
3
+ *
4
+ * runtime-error-store と completed-turn-receipts が同一の PowerShell ACL
5
+ * 実装を別々に持っていたため (env 変数名だけ違う事故フォーク)、ここへ集約する。
6
+ *
7
+ * apply script は適用直後に同一 process 内で read-back 検証まで行う。
8
+ * 検証内容: owner = current SID、explicit rule が current SID の
9
+ * FullControl Allow 1 本だけ、継承 rule なし。
10
+ *
11
+ * CI 実測で PowerShell コールドスタートが 3.0〜3.2 秒に達し 3 秒 cap と衝突して
12
+ * flake したため timeout は 15 秒 (run 29586852389 / 29628634501)。
13
+ * explicit failure 契約は不変: 非 0 exit は例外にする。
14
+ *
15
+ * 注意: テストは `childProcess.spawnSync` を node:child_process の default
16
+ * export 経由で mock するため、named import に変えないこと。
17
+ */
18
+ import childProcess from 'node:child_process';
19
+ import { platform as hostPlatform } from 'node:os';
20
+
21
+ export const WINDOWS_ACL_TIMEOUT_MS = 15_000;
22
+
23
+ export function isWindows(env = process.env) {
24
+ return env.OS === 'Windows_NT' || hostPlatform() === 'win32';
25
+ }
26
+
27
+ export function applyAndVerifyWindowsAcl(path, directory) {
28
+ runWindowsAclScript(path, directory, WINDOWS_ACL_APPLY_SCRIPT);
29
+ }
30
+
31
+ export function verifyWindowsAcl(path, directory) {
32
+ runWindowsAclScript(path, directory, WINDOWS_ACL_VERIFY_SCRIPT);
33
+ }
34
+
35
+ function runWindowsAclScript(path, directory, script) {
36
+ const result = childProcess.spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
37
+ env: { ...process.env, THROUGHLINE_ACL_PATH: path, THROUGHLINE_ACL_DIRECTORY: directory ? '1' : '0' },
38
+ stdio: 'ignore', timeout: WINDOWS_ACL_TIMEOUT_MS, windowsHide: true,
39
+ });
40
+ if (result.status !== 0) throw new Error('Windows owner-only ACL verification failed');
41
+ }
42
+
43
+ const WINDOWS_ACL_VERIFY_SCRIPT = String.raw`
44
+ $p=$env:THROUGHLINE_ACL_PATH; $isDir=$env:THROUGHLINE_ACL_DIRECTORY -eq '1'; $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
45
+ $acl=if($isDir){[System.IO.Directory]::GetAccessControl($p)}else{[System.IO.File]::GetAccessControl($p)}
46
+ $owner=$acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
47
+ if($owner -ne $sid){exit 41}; $rules=@($acl.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier])); if($rules.Count -ne 1){exit 42}
48
+ $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}
49
+ `;
50
+
51
+ const WINDOWS_ACL_APPLY_SCRIPT = String.raw`
52
+ $p=$env:THROUGHLINE_ACL_PATH; $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User
53
+ $isDir=$env:THROUGHLINE_ACL_DIRECTORY -eq '1'; $acl=if($isDir){New-Object System.Security.AccessControl.DirectorySecurity}else{New-Object System.Security.AccessControl.FileSecurity}; $acl.SetAccessRuleProtection($true,$false)
54
+ $flags=if($isDir){[System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'}else{[System.Security.AccessControl.InheritanceFlags]::None}
55
+ $rule=New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl',$flags,[System.Security.AccessControl.PropagationFlags]::None,[System.Security.AccessControl.AccessControlType]::Allow)
56
+ $acl.SetOwner($sid); $acl.AddAccessRule($rule); if($isDir){[System.IO.Directory]::SetAccessControl($p,$acl)}else{[System.IO.File]::SetAccessControl($p,$acl)}
57
+ ` + WINDOWS_ACL_VERIFY_SCRIPT;
@@ -1,7 +1,8 @@
1
1
  import { existsSync, realpathSync } from 'node:fs';
2
- import { platform } from 'node:os';
3
2
  import { resolve, sep } from 'node:path';
4
3
 
4
+ import { foldPathCaseForPlatform } from './os/paths.mjs';
5
+
5
6
  export function normalizeProjectPathForCompare(value) {
6
7
  if (!value) return '';
7
8
  let resolved = resolve(String(value));
@@ -10,9 +11,8 @@ export function normalizeProjectPathForCompare(value) {
10
11
  } catch {
11
12
  // Fall back to the lexical path when the filesystem cannot resolve it.
12
13
  }
13
- let normalized = resolved.split(sep).join('/').replace(/\/+$/, '');
14
- if (platform() === 'win32') normalized = normalized.toLowerCase();
15
- return normalized;
14
+ const normalized = resolved.split(sep).join('/').replace(/\/+$/, '');
15
+ return foldPathCaseForPlatform(normalized);
16
16
  }
17
17
 
18
18
  export function sameProjectPath(a, b) {
@@ -42,10 +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 { 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';
45
+ import { hostAdapterForSessionId, normalizeHookPayload } from './hosts/index.mjs';
49
46
 
50
47
  // Phase 0-5 spike marker (SessionStart の spike-inject.flag とは別)
51
48
  const PROMPT_SPIKE_MARKER_PATH = join(homedir(), '.throughline', 'spike-prompt.flag');
@@ -131,14 +128,6 @@ function isNamedSlashCommand(prompt, name) {
131
128
  return text === name || text.startsWith(`${name} `) || text.startsWith(`${name}\n`);
132
129
  }
133
130
 
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
-
142
131
  /**
143
132
  * プロンプトが /tl バトン発動コマンドか判定する。
144
133
  * 許容: "/tl", "/tl\n", "/tl 何か"。Grok の user_query 包装も見る。
@@ -155,20 +144,6 @@ export function isClearCommand(prompt) {
155
144
  return isNamedSlashCommand(prompt, '/clear') || isNamedSlashCommand(prompt, '/new');
156
145
  }
157
146
 
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 });
170
- }
171
-
172
147
  export async function run() {
173
148
  let raw = '';
174
149
  await new Promise((resolve) => {
@@ -181,6 +156,7 @@ export async function run() {
181
156
 
182
157
  const payload = normalizeHookPayload(JSON.parse(raw));
183
158
  const { session_id, cwd, prompt } = payload;
159
+ const hostAdapter = hostAdapterForSessionId(session_id);
184
160
 
185
161
  // VSCode 新規プロジェクトへの tasks.json 自動プロビジョニング。
186
162
  // SessionStart/Stop に加えここでも呼ぶことで、どれか 1 つでも発火すれば初回メッセージ送信で
@@ -208,16 +184,13 @@ export async function run() {
208
184
  });
209
185
  if (handoff.attempted) {
210
186
  if (handoff.injectionText) {
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');
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`);
221
194
  }
222
195
  }
223
196
  logDecision({
@@ -238,15 +211,13 @@ export async function run() {
238
211
  }
239
212
  }
240
213
 
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
- }
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
+ });
250
221
  const tlMatch = isBatonCommand(commandPrompt);
251
222
  const clearMatch = !tlMatch && isClearCommand(commandPrompt);
252
223
 
@@ -281,17 +252,14 @@ export async function run() {
281
252
  trigger: tlMatch ? 'tl' : 'clear',
282
253
  });
283
254
 
284
- if (shouldLaunchGrokContinue({
285
- sessionId: session_id,
255
+ // バトン書き込み後の副作用は host 依存 (Grok /tl だけ後継セッションを起動する)。
256
+ const afterBaton = hostAdapter.afterBatonWrite({
286
257
  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
- }
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`);
295
263
  }
296
264
 
297
265
  process.exit(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
 
@@ -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 { 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