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.
- package/CHANGELOG.md +24 -1
- package/package.json +1 -1
- package/src/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/grok-continue.mjs +8 -21
- package/src/cli/runtime-errors.test.mjs +1 -1
- package/src/codex-capture.mjs +14 -18
- package/src/codex-handoff-model-smoke.mjs +1 -1
- package/src/codex-sidecar.mjs +1 -1
- package/src/completed-turn-receipts.mjs +2 -29
- 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/macos-terminal.mjs +44 -0
- package/src/os/open-url.mjs +16 -0
- package/src/os/paths.mjs +12 -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 +3 -40
- 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/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,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
|
+
}
|
package/src/os/paths.mjs
ADDED
|
@@ -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
|
+
}
|
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);
|
|
@@ -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('
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
assert.
|
|
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('
|
|
105
|
+
test('Grok afterBatonWrite calls grok-continue with the source session', () => {
|
|
90
106
|
const calls = [];
|
|
91
|
-
const
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
package/src/session-start.mjs
CHANGED
|
@@ -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 './
|
|
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
|
-
|
|
65
|
-
AND session_id NOT LIKE 'grok:%'
|
|
69
|
+
${nonClaudeExclusion}
|
|
66
70
|
ORDER BY updated_at DESC
|
|
67
71
|
LIMIT 5`,
|
|
68
72
|
)
|
|
69
|
-
.all(
|
|
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
|
|
package/src/state-file.mjs
CHANGED
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync, existsSync } from 'node:fs';
|
|
15
|
-
import { homedir
|
|
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
|
-
|
|
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
|
|
159
|
-
if (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
|
|