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.
- package/CHANGELOG.md +38 -1
- package/README.ja.md +1 -1
- package/README.md +2 -2
- package/docs/16_readonly_handoff_context_plan.md +2 -2
- package/package.json +1 -1
- package/src/auditor-context.mjs +4 -3
- package/src/cli/auditor-context.mjs +2 -1
- package/src/cli/codex-handoff-start.mjs +5 -23
- package/src/cli/codex-summarize.mjs +2 -1
- package/src/cli/codex-visibility-smoke.mjs +5 -4
- package/src/cli/doctor.mjs +7 -6
- package/src/cli/grok-continue.mjs +8 -21
- package/src/cli/runtime-errors.test.mjs +1 -1
- package/src/cli/trim.mjs +3 -2
- package/src/codex-auto-refresh.mjs +3 -3
- package/src/codex-capture.mjs +14 -18
- package/src/codex-handoff-model-smoke.mjs +1 -1
- package/src/codex-sidecar.mjs +3 -2
- package/src/completed-turn-receipts.mjs +6 -33
- package/src/completed-turn-receipts.test.mjs +1 -1
- package/src/haiku-summarizer.mjs +1 -1
- package/src/handoff-record.mjs +3 -1
- package/src/hosts/claude.mjs +27 -0
- package/src/hosts/codex.mjs +28 -0
- package/src/hosts/grok.mjs +96 -0
- package/src/{hook-envelope.test.mjs → hosts/grok.test.mjs} +1 -1
- package/src/hosts/identity.mjs +71 -0
- package/src/hosts/identity.test.mjs +65 -0
- package/src/hosts/index.mjs +37 -0
- package/src/os/app-dirs.mjs +25 -0
- package/src/os/macos-terminal.mjs +44 -0
- package/src/os/open-url.mjs +16 -0
- package/src/os/paths.mjs +16 -0
- package/src/os/shell.mjs +16 -0
- package/src/os/windows-acl.mjs +57 -0
- package/src/project-path.mjs +4 -4
- package/src/prompt-submit.mjs +23 -55
- package/src/prompt-submit.test.mjs +27 -10
- package/src/runtime-error-hook.test.mjs +1 -1
- package/src/runtime-error-store.mjs +9 -47
- package/src/runtime-error-store.test.mjs +1 -1
- package/src/session-start.mjs +12 -4
- package/src/state-file.mjs +6 -5
- package/src/token-monitor.mjs +7 -6
- package/src/trim-model.mjs +6 -5
- package/src/turn-processor.mjs +2 -2
- package/src/hook-envelope.mjs +0 -51
- /package/src/{portable-spawn-sync.mjs → os/portable-spawn-sync.mjs} +0 -0
- /package/src/{portable-spawn-sync.test.mjs → os/portable-spawn-sync.test.mjs} +0 -0
- /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
|
+
});
|
|
@@ -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
|
+
}
|
package/src/os/paths.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/os/shell.mjs
ADDED
|
@@ -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;
|
package/src/project-path.mjs
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
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) {
|
package/src/prompt-submit.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
285
|
-
|
|
255
|
+
// バトン書き込み後の副作用は host 依存 (Grok /tl だけ後継セッションを起動する)。
|
|
256
|
+
const afterBaton = hostAdapter.afterBatonWrite({
|
|
286
257
|
trigger: tlMatch ? 'tl' : 'clear',
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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);
|