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
@@ -0,0 +1,28 @@
1
+ /**
2
+ * hosts/codex.mjs — Codex host adapter
3
+ *
4
+ * Codex session (`codex:<thread_id>`) は Claude-facing hook (SessionStart /
5
+ * UserPromptSubmit / Stop) を通らず、専用入口 `throughline codex-hook`
6
+ * ([src/cli/codex-hook.mjs](../cli/codex-hook.mjs)) で capture される。
7
+ * この adapter は共有コードが Codex session を Claude と取り違えないための
8
+ * 識別と、Claude-facing hook に Codex session が流入した場合の既存挙動
9
+ * (Claude と同じ経路で処理する) を明文化する。
10
+ */
11
+ import { CODEX_HOST, isCodexSessionId } from './identity.mjs';
12
+
13
+ export const codexHostAdapter = Object.freeze({
14
+ host: CODEX_HOST,
15
+ matchesSessionId: isCodexSessionId,
16
+ // 既存挙動: Claude Stop hook の flush barrier は grok 以外 (codex: 含む) に適用される。
17
+ waitsForStopTranscriptFlush: true,
18
+ deliverHandoffInjection({ text, stdout = process.stdout }) {
19
+ stdout.write(text + '\n');
20
+ return { delivered: true };
21
+ },
22
+ resolveCommandPrompt({ prompt }) {
23
+ return prompt;
24
+ },
25
+ afterBatonWrite() {
26
+ return { launched: false };
27
+ },
28
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * hosts/grok.mjs — Grok host 境界 (envelope 正規化 + hook adapter)
3
+ *
4
+ * Grok sends Claude-compatible hook commands with a camelCase wire
5
+ * (sessionId, hookEventName) and no session_id. Throughline treats that
6
+ * envelope as host=grok: normalize to the Claude snake_case contract and
7
+ * prefix session ids so they never mix with Claude predecessor search.
8
+ *
9
+ * Grok 固有の挙動 (v0.10.0 / ADR 0021):
10
+ * - UserPromptSubmit stdout はモデルへ渡らないため、引き継ぎ注入は
11
+ * chat_history.jsonl への直接書き込みで行う
12
+ * - hook prompt は `<user_query>` 包装のため、裸の slash command 判定には
13
+ * chat_history の最新 user 発話を使う
14
+ * - `/tl` 成功後だけ grok-continue で後継の対話 grok を立てる
15
+ * - Stop hook の transcript flush barrier は Claude transcript 専用のため使わない
16
+ */
17
+ import { homedir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ import { GROK_HOST, GROK_SESSION_PREFIX, grokBareSessionId, isGrokSessionId } from './identity.mjs';
21
+ import { injectGrokHandoffContext } from '../grok-history-inject.mjs';
22
+ import { readTranscript } from '../transcript-reader.mjs';
23
+ import { run as runGrokContinue } from '../cli/grok-continue.mjs';
24
+
25
+ export { GROK_SESSION_PREFIX, grokBareSessionId };
26
+
27
+ export function isGrokEnvelope(payload) {
28
+ return payload !== null
29
+ && typeof payload === 'object'
30
+ && typeof payload.sessionId === 'string'
31
+ && payload.sessionId.length > 0
32
+ && typeof payload.hookEventName === 'string'
33
+ && payload.hookEventName.length > 0
34
+ && !Object.hasOwn(payload, 'session_id');
35
+ }
36
+
37
+ export function deriveGrokChatHistoryPath(projectPath, sessionId, { home = homedir() } = {}) {
38
+ const bare = grokBareSessionId(sessionId);
39
+ if (!projectPath || !bare) return null;
40
+ return join(home, '.grok', 'sessions', encodeURIComponent(projectPath), bare, 'chat_history.jsonl');
41
+ }
42
+
43
+ export function normalizeGrokHookPayload(payload, { home = homedir() } = {}) {
44
+ if (!isGrokEnvelope(payload)) return payload;
45
+ const cwd = typeof payload.cwd === 'string' && payload.cwd.length > 0
46
+ ? payload.cwd
47
+ : (typeof payload.workspaceRoot === 'string' ? payload.workspaceRoot : undefined);
48
+ // Live Grok Stop sets transcriptPath to updates.jsonl (sessionUpdate frames,
49
+ // no user/assistant rows). L2 lives in chat_history.jsonl only.
50
+ const transcriptPath = deriveGrokChatHistoryPath(cwd, payload.sessionId, { home });
51
+ return {
52
+ ...payload,
53
+ session_id: `${GROK_SESSION_PREFIX}${payload.sessionId}`,
54
+ cwd,
55
+ source: payload.source,
56
+ prompt: payload.prompt,
57
+ hook_event_name: payload.hookEventName,
58
+ transcript_path: transcriptPath,
59
+ last_assistant_message: payload.lastAssistantMessage,
60
+ };
61
+ }
62
+
63
+ function lastUserPromptText(transcriptPath) {
64
+ const turns = readTranscript(transcriptPath);
65
+ for (let i = turns.length - 1; i >= 0; i--) {
66
+ if (turns[i].role === 'user') return turns[i].content;
67
+ }
68
+ return '';
69
+ }
70
+
71
+ export const grokHostAdapter = Object.freeze({
72
+ host: GROK_HOST,
73
+ matchesSessionId: isGrokSessionId,
74
+ // Claude Stop transcript flush barrier は Claude transcript の完了行を待つ機構。
75
+ // Grok の chat_history には適用しない (既存挙動)。
76
+ waitsForStopTranscriptFlush: false,
77
+ // Grok は UserPromptSubmit stdout をモデルへ渡さないため chat_history に直接注入する。
78
+ deliverHandoffInjection({ payload, text }) {
79
+ const injected = injectGrokHandoffContext(payload.transcript_path, text);
80
+ return injected.injected
81
+ ? { delivered: true }
82
+ : { delivered: false, reason: `grok chat_history inject skipped: ${injected.reason}` };
83
+ },
84
+ // Grok の hook prompt は `<user_query>` 包装 + skill 本文のため、裸の /tl 判定は
85
+ // chat_history の最新 user 発話へ fallback する。
86
+ resolveCommandPrompt({ prompt, payload, isCommandPrompt }) {
87
+ if (isCommandPrompt(prompt)) return prompt;
88
+ return lastUserPromptText(payload.transcript_path);
89
+ },
90
+ // Grok `/tl` 成功後だけ、源セッションの project_path で後継の対話 grok を立てる。
91
+ afterBatonWrite({ trigger, sessionId, cwd, continueRun = runGrokContinue }) {
92
+ if (trigger !== 'tl') return { launched: false };
93
+ const code = continueRun(['--session', sessionId], { cwd });
94
+ return { launched: true, exitCode: code };
95
+ },
96
+ });
@@ -6,7 +6,7 @@ import {
6
6
  deriveGrokChatHistoryPath,
7
7
  isGrokEnvelope,
8
8
  normalizeHookPayload,
9
- } from './hook-envelope.mjs';
9
+ } from './index.mjs';
10
10
 
11
11
  test('isGrokEnvelope detects camelCase wire without session_id', () => {
12
12
  assert.equal(
@@ -0,0 +1,71 @@
1
+ /**
2
+ * hosts/identity.mjs — harness (hook host) 識別の唯一の正本
3
+ *
4
+ * Throughline は Claude / Codex / Grok の 3 hook host を同じ SQLite に保存する。
5
+ * host の見分け方は session_id prefix だけであり、その prefix 定義と判定関数を
6
+ * このファイルに一元化する。共有コード (hook 入口・monitor・state-file・
7
+ * predecessor 検索) は文字列リテラルを直接持たず、必ずここを参照する。
8
+ *
9
+ * 新しい host を足す場合はここへ prefix / host 名を追加し、
10
+ * `src/hosts/<host>.mjs` に adapter を実装する。
11
+ */
12
+
13
+ export const CLAUDE_HOST = 'claude';
14
+ export const CODEX_HOST = 'codex';
15
+ export const GROK_HOST = 'grok';
16
+
17
+ export const CODEX_SESSION_PREFIX = 'codex:';
18
+ export const GROK_SESSION_PREFIX = 'grok:';
19
+
20
+ /**
21
+ * Claude session は prefix を持たない。auto handoff の前任検索 (Claude 専用) は
22
+ * この一覧の prefix を持つ session を除外する。
23
+ */
24
+ export const NON_CLAUDE_SESSION_PREFIXES = Object.freeze([
25
+ CODEX_SESSION_PREFIX,
26
+ GROK_SESSION_PREFIX,
27
+ ]);
28
+
29
+ /**
30
+ * state ファイルに保存できる host 値。Grok session は Claude 互換 hook 経路で
31
+ * 保存されるため state 上は 'claude' として扱う (既存挙動)。
32
+ */
33
+ export const KNOWN_STATE_HOSTS = Object.freeze([CLAUDE_HOST, CODEX_HOST]);
34
+
35
+ export function isCodexSessionId(sessionId) {
36
+ return typeof sessionId === 'string' && sessionId.startsWith(CODEX_SESSION_PREFIX);
37
+ }
38
+
39
+ export function isGrokSessionId(sessionId) {
40
+ return typeof sessionId === 'string' && sessionId.startsWith(GROK_SESSION_PREFIX);
41
+ }
42
+
43
+ /**
44
+ * session_id から hook host を返す。prefix なしは Claude。
45
+ * @param {string} sessionId
46
+ * @returns {'claude'|'codex'|'grok'}
47
+ */
48
+ export function hostOfSessionId(sessionId) {
49
+ if (isCodexSessionId(sessionId)) return CODEX_HOST;
50
+ if (isGrokSessionId(sessionId)) return GROK_HOST;
51
+ return CLAUDE_HOST;
52
+ }
53
+
54
+ export function buildCodexThroughlineSessionId(threadId) {
55
+ if (typeof threadId !== 'string' || threadId.trim().length === 0) {
56
+ throw new Error('threadId is required');
57
+ }
58
+ return `${CODEX_SESSION_PREFIX}${threadId.trim()}`;
59
+ }
60
+
61
+ export function codexSessionIdToThreadId(sessionId) {
62
+ if (!isCodexSessionId(sessionId)) return null;
63
+ return sessionId.slice(CODEX_SESSION_PREFIX.length) || null;
64
+ }
65
+
66
+ export function grokBareSessionId(sessionId) {
67
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return null;
68
+ return sessionId.startsWith(GROK_SESSION_PREFIX)
69
+ ? sessionId.slice(GROK_SESSION_PREFIX.length)
70
+ : sessionId;
71
+ }
@@ -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,25 @@
1
+ /**
2
+ * os/app-dirs.mjs — OS別のユーザー設定/状態ベースディレクトリ解決
3
+ *
4
+ * LOCALAPPDATA(Windows)と XDG_CONFIG_HOME / XDG_STATE_HOME(POSIX)の
5
+ * フォールバック組み立てが runtime-error-store と completed-turn-receipts に
6
+ * 別々に書かれていたため、ここへ集約する。最終的なアプリ別 join は呼び出し側が持つ。
7
+ */
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+
11
+ function homeOf(env) {
12
+ return env.HOME || env.USERPROFILE || homedir();
13
+ }
14
+
15
+ export function windowsLocalAppData(env = process.env) {
16
+ return env.LOCALAPPDATA || join(homeOf(env), 'AppData', 'Local');
17
+ }
18
+
19
+ export function xdgConfigHome(env = process.env) {
20
+ return env.XDG_CONFIG_HOME || join(homeOf(env), '.config');
21
+ }
22
+
23
+ export function xdgStateHome(env = process.env) {
24
+ return env.XDG_STATE_HOME || join(homeOf(env), '.local', 'state');
25
+ }
@@ -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,16 @@
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
+ }
13
+
14
+ export function isWin32Platform(hostPlatform = platform()) {
15
+ return hostPlatform === 'win32';
16
+ }
@@ -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);