throughline 0.9.1 → 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 +52 -1
- package/README.ja.md +40 -2
- package/README.md +42 -3
- package/bin/throughline.mjs +12 -1
- package/docs/00_overview.md +2 -0
- package/docs/02_clear_auto_handoff_plan.md +6 -0
- package/docs/04_public_release_plan.md +2 -2
- package/docs/adr/0021-grok-host-capture.md +60 -0
- package/docs/plan_grok-successor-launch.md +99 -0
- package/package.json +4 -2
- 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 +184 -0
- package/src/cli/grok-continue.test.mjs +239 -0
- package/src/cli/handoff-context.mjs +19 -0
- package/src/cli/handoff-context.test.mjs +13 -0
- package/src/cli/install.mjs +62 -0
- package/src/cli/install.test.mjs +46 -0
- 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/grok-history-inject.mjs +71 -0
- package/src/grok-history-inject.test.mjs +69 -0
- package/src/haiku-summarizer.mjs +1 -1
- package/src/handoff-record.mjs +3 -1
- package/src/hook-entrypoints.test.mjs +257 -44
- package/src/hosts/claude.mjs +27 -0
- package/src/hosts/codex.mjs +28 -0
- package/src/hosts/grok.mjs +96 -0
- package/src/hosts/grok.test.mjs +79 -0
- 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 +53 -18
- package/src/prompt-submit.test.mjs +75 -1
- 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 +13 -5
- package/src/state-file.mjs +6 -5
- package/src/token-monitor.mjs +7 -6
- package/src/transcript-reader-grok.test.mjs +37 -0
- package/src/transcript-reader.mjs +7 -3
- package/src/turn-processor.mjs +8 -7
- package/src/hook-envelope.mjs +0 -12
- /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
package/src/prompt-submit.mjs
CHANGED
|
@@ -42,7 +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 {
|
|
45
|
+
import { hostAdapterForSessionId, normalizeHookPayload } from './hosts/index.mjs';
|
|
46
46
|
|
|
47
47
|
// Phase 0-5 spike marker (SessionStart の spike-inject.flag とは別)
|
|
48
48
|
const PROMPT_SPIKE_MARKER_PATH = join(homedir(), '.throughline', 'spike-prompt.flag');
|
|
@@ -109,28 +109,39 @@ function markSpiked(sessionId) {
|
|
|
109
109
|
writeFileSync(join(PROMPT_SPIKE_STATE_DIR, sessionId), '', 'utf8');
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
const USER_QUERY_RE = /<user_query>\s*([\s\S]*?)\s*<\/user_query>/i;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Grok は hook prompt と chat_history を
|
|
116
|
+
* `<user_query>/tl</user_query>` + skill 本文で包む。
|
|
117
|
+
* Claude の裸 `/tl` はそのまま返す。
|
|
118
|
+
*/
|
|
119
|
+
export function commandTextFromPrompt(prompt) {
|
|
120
|
+
if (typeof prompt !== 'string') return '';
|
|
121
|
+
const match = prompt.match(USER_QUERY_RE);
|
|
122
|
+
return (match ? match[1] : prompt).trim();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isNamedSlashCommand(prompt, name) {
|
|
126
|
+
const text = commandTextFromPrompt(prompt);
|
|
127
|
+
if (!text) return false;
|
|
128
|
+
return text === name || text.startsWith(`${name} `) || text.startsWith(`${name}\n`);
|
|
129
|
+
}
|
|
130
|
+
|
|
112
131
|
/**
|
|
113
132
|
* プロンプトが /tl バトン発動コマンドか判定する。
|
|
114
|
-
* 許容: "/tl", "/tl\n", "/tl 何か"
|
|
133
|
+
* 許容: "/tl", "/tl\n", "/tl 何か"。Grok の user_query 包装も見る。
|
|
115
134
|
*/
|
|
116
135
|
export function isBatonCommand(prompt) {
|
|
117
|
-
|
|
118
|
-
const trimmed = prompt.trim();
|
|
119
|
-
if (trimmed === '/tl') return true;
|
|
120
|
-
if (trimmed.startsWith('/tl ') || trimmed.startsWith('/tl\n')) return true;
|
|
121
|
-
return false;
|
|
136
|
+
return isNamedSlashCommand(prompt, '/tl');
|
|
122
137
|
}
|
|
123
138
|
|
|
124
139
|
/**
|
|
125
140
|
* プロンプトが /clear バトン発動コマンドか判定する。
|
|
126
|
-
* 許容: "/clear",
|
|
141
|
+
* 許容: "/clear", Grok の alias "/new"。Grok の user_query 包装も見る。
|
|
127
142
|
*/
|
|
128
143
|
export function isClearCommand(prompt) {
|
|
129
|
-
|
|
130
|
-
const trimmed = prompt.trim();
|
|
131
|
-
if (trimmed === '/clear') return true;
|
|
132
|
-
if (trimmed.startsWith('/clear ') || trimmed.startsWith('/clear\n')) return true;
|
|
133
|
-
return false;
|
|
144
|
+
return isNamedSlashCommand(prompt, '/clear') || isNamedSlashCommand(prompt, '/new');
|
|
134
145
|
}
|
|
135
146
|
|
|
136
147
|
export async function run() {
|
|
@@ -143,9 +154,9 @@ export async function run() {
|
|
|
143
154
|
process.stdin.on('end', resolve);
|
|
144
155
|
});
|
|
145
156
|
|
|
146
|
-
const payload = JSON.parse(raw);
|
|
147
|
-
if (isUnsupportedNonClaudeEnvelope(payload)) return;
|
|
157
|
+
const payload = normalizeHookPayload(JSON.parse(raw));
|
|
148
158
|
const { session_id, cwd, prompt } = payload;
|
|
159
|
+
const hostAdapter = hostAdapterForSessionId(session_id);
|
|
149
160
|
|
|
150
161
|
// VSCode 新規プロジェクトへの tasks.json 自動プロビジョニング。
|
|
151
162
|
// SessionStart/Stop に加えここでも呼ぶことで、どれか 1 つでも発火すれば初回メッセージ送信で
|
|
@@ -173,7 +184,14 @@ export async function run() {
|
|
|
173
184
|
});
|
|
174
185
|
if (handoff.attempted) {
|
|
175
186
|
if (handoff.injectionText) {
|
|
176
|
-
|
|
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`);
|
|
194
|
+
}
|
|
177
195
|
}
|
|
178
196
|
logDecision({
|
|
179
197
|
ts: new Date(now).toISOString(),
|
|
@@ -193,8 +211,15 @@ export async function run() {
|
|
|
193
211
|
}
|
|
194
212
|
}
|
|
195
213
|
|
|
196
|
-
|
|
197
|
-
|
|
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
|
+
});
|
|
221
|
+
const tlMatch = isBatonCommand(commandPrompt);
|
|
222
|
+
const clearMatch = !tlMatch && isClearCommand(commandPrompt);
|
|
198
223
|
|
|
199
224
|
// Phase 0-5 spike: real user prompt (not /tl, not /clear) で、marker file あり、
|
|
200
225
|
// session 未 spike なら chain (b) で JSONL に inject する。失敗しても prompt 自体は
|
|
@@ -227,6 +252,16 @@ export async function run() {
|
|
|
227
252
|
trigger: tlMatch ? 'tl' : 'clear',
|
|
228
253
|
});
|
|
229
254
|
|
|
255
|
+
// バトン書き込み後の副作用は host 依存 (Grok /tl だけ後継セッションを起動する)。
|
|
256
|
+
const afterBaton = hostAdapter.afterBatonWrite({
|
|
257
|
+
trigger: tlMatch ? 'tl' : 'clear',
|
|
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`);
|
|
263
|
+
}
|
|
264
|
+
|
|
230
265
|
process.exit(0);
|
|
231
266
|
}
|
|
232
267
|
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
commandTextFromPrompt,
|
|
5
|
+
isBatonCommand,
|
|
6
|
+
isClearCommand,
|
|
7
|
+
} from './prompt-submit.mjs';
|
|
8
|
+
import { hostAdapterForSessionId } from './hosts/index.mjs';
|
|
4
9
|
|
|
5
10
|
test('isBatonCommand: bare /tl', () => {
|
|
6
11
|
assert.equal(isBatonCommand('/tl'), true);
|
|
@@ -64,3 +69,72 @@ test('isClearCommand: rejects empty / non-string', () => {
|
|
|
64
69
|
assert.equal(isClearCommand(undefined), false);
|
|
65
70
|
assert.equal(isClearCommand(42), false);
|
|
66
71
|
});
|
|
72
|
+
|
|
73
|
+
test('commandTextFromPrompt unwraps Grok user_query and leaves bare Claude text', () => {
|
|
74
|
+
assert.equal(commandTextFromPrompt('/tl'), '/tl');
|
|
75
|
+
assert.equal(
|
|
76
|
+
commandTextFromPrompt('<user_query>\n/tl\n</user_query>\n<skill_information>saved</skill_information>'),
|
|
77
|
+
'/tl',
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
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
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('Grok afterBatonWrite calls grok-continue with the source session', () => {
|
|
106
|
+
const calls = [];
|
|
107
|
+
const result = hostAdapterForSessionId('grok:abc').afterBatonWrite({
|
|
108
|
+
trigger: 'tl',
|
|
109
|
+
sessionId: 'grok:abc',
|
|
110
|
+
cwd: '/work/Throughline',
|
|
111
|
+
continueRun: (argv, opts) => {
|
|
112
|
+
calls.push({ argv, opts });
|
|
113
|
+
return 0;
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
assert.deepEqual(result, { launched: true, exitCode: 0 });
|
|
117
|
+
assert.deepEqual(calls, [{
|
|
118
|
+
argv: ['--session', 'grok:abc'],
|
|
119
|
+
opts: { cwd: '/work/Throughline' },
|
|
120
|
+
}]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('isBatonCommand: Grok user_query wrap around /tl', () => {
|
|
124
|
+
const wrapped =
|
|
125
|
+
'<user_query>\n/tl\n</user_query>\n<skill_information>\nThroughline saved the baton.\n</skill_information>';
|
|
126
|
+
assert.equal(isBatonCommand(wrapped), true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('isBatonCommand: Grok wrap does not treat skill body /tl as the command', () => {
|
|
130
|
+
const wrapped =
|
|
131
|
+
'<user_query>\n続けてくれ\n</user_query>\n<skill_information>\nType /tl to hand off.\n</skill_information>';
|
|
132
|
+
assert.equal(isBatonCommand(wrapped), false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('isClearCommand: Grok user_query wrap around /clear and /new', () => {
|
|
136
|
+
assert.equal(isClearCommand('<user_query>\n/clear\n</user_query>'), true);
|
|
137
|
+
assert.equal(isClearCommand('<user_query>\n/new\n</user_query>'), true);
|
|
138
|
+
assert.equal(isClearCommand('/new'), true);
|
|
139
|
+
assert.equal(isClearCommand('/newest'), false);
|
|
140
|
+
});
|
|
@@ -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 {
|
|
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,17 +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
|
-
|
|
69
|
+
${nonClaudeExclusion}
|
|
65
70
|
ORDER BY updated_at DESC
|
|
66
71
|
LIMIT 5`,
|
|
67
72
|
)
|
|
68
|
-
.all(
|
|
73
|
+
.all(
|
|
74
|
+
projectPath,
|
|
75
|
+
currentSessionId,
|
|
76
|
+
...NON_CLAUDE_SESSION_PREFIXES.map((prefix) => `${prefix}%`),
|
|
77
|
+
);
|
|
69
78
|
|
|
70
79
|
if (candidates.length === 0) return null;
|
|
71
80
|
|
|
@@ -91,8 +100,7 @@ export async function run() {
|
|
|
91
100
|
process.stdin.on('end', resolve);
|
|
92
101
|
});
|
|
93
102
|
|
|
94
|
-
const payload = JSON.parse(raw);
|
|
95
|
-
if (isUnsupportedNonClaudeEnvelope(payload)) return;
|
|
103
|
+
const payload = normalizeHookPayload(JSON.parse(raw));
|
|
96
104
|
const { session_id, cwd, source, transcript_path } = payload;
|
|
97
105
|
|
|
98
106
|
if (!session_id) throw new Error('Missing session_id in SessionStart payload');
|
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
|
|
package/src/token-monitor.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { buildCodexMonitorUsage } from './codex-usage.mjs';
|
|
|
28
28
|
import { listCodexThreadCandidates } from './codex-thread-index.mjs';
|
|
29
29
|
import { readLatestUsage } from './transcript-usage.mjs';
|
|
30
30
|
import { startSizeQuery } from './terminal-size.mjs';
|
|
31
|
+
import { CODEX_HOST, CODEX_SESSION_PREFIX, codexSessionIdToThreadId } from './hosts/identity.mjs';
|
|
31
32
|
|
|
32
33
|
const REFRESH_MS = 1000;
|
|
33
34
|
// データ変化が無くても N ms ごとに再描画して「(24m ago)」表示を進める。
|
|
@@ -292,7 +293,7 @@ export function resolveColumns() {
|
|
|
292
293
|
function formatLine({ state, usage, isActive, now = Date.now() }) {
|
|
293
294
|
const project = basename(state.projectPath || '?');
|
|
294
295
|
const shortId = formatShortSessionId(state);
|
|
295
|
-
const host = state.host ===
|
|
296
|
+
const host = state.host === CODEX_HOST ? 'Codex' : state.host === 'unknown' ? 'Unknown' : 'Claude';
|
|
296
297
|
const tokens = usage?.tokens ?? 0;
|
|
297
298
|
const max = usage?.contextWindowSize ?? 200_000;
|
|
298
299
|
const ratio = max > 0 ? tokens / max : 0;
|
|
@@ -337,8 +338,8 @@ function formatLine({ state, usage, isActive, now = Date.now() }) {
|
|
|
337
338
|
|
|
338
339
|
function formatShortSessionId(state) {
|
|
339
340
|
const sessionId = String(state?.sessionId ?? '');
|
|
340
|
-
if (state?.host ===
|
|
341
|
-
return sessionId.slice(
|
|
341
|
+
if (state?.host === CODEX_HOST && sessionId.startsWith(CODEX_SESSION_PREFIX)) {
|
|
342
|
+
return (codexSessionIdToThreadId(sessionId) ?? '').slice(0, 8);
|
|
342
343
|
}
|
|
343
344
|
return sessionId.slice(0, 8);
|
|
344
345
|
}
|
|
@@ -372,7 +373,7 @@ function withLiveActivity(state, now = Date.now()) {
|
|
|
372
373
|
}
|
|
373
374
|
|
|
374
375
|
function resolveMonitorUsage(state) {
|
|
375
|
-
if (state.host ===
|
|
376
|
+
if (state.host === CODEX_HOST && state.rolloutPath) {
|
|
376
377
|
return buildCodexMonitorUsage(state.rolloutPath) ?? state.usage ?? null;
|
|
377
378
|
}
|
|
378
379
|
if (state.transcriptPath) {
|
|
@@ -410,8 +411,8 @@ function discoverCodexSessionStates(args = {}, cwd = process.cwd()) {
|
|
|
410
411
|
|
|
411
412
|
lastCodexDiscoveryError = null;
|
|
412
413
|
return candidates.map((candidate) => ({
|
|
413
|
-
sessionId:
|
|
414
|
-
host:
|
|
414
|
+
sessionId: `${CODEX_SESSION_PREFIX}${candidate.id}`,
|
|
415
|
+
host: CODEX_HOST,
|
|
415
416
|
projectPath: normalizeProjectPath(candidate.cwd ?? cwd),
|
|
416
417
|
transcriptPath: null,
|
|
417
418
|
rolloutPath: candidate.rolloutPath,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { getLogicalTurnGroups, readTranscript } from './transcript-reader.mjs';
|
|
8
|
+
|
|
9
|
+
test('readTranscript accepts Grok chat_history.jsonl user/assistant rows', () => {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-grok-transcript-'));
|
|
11
|
+
const path = join(dir, 'chat_history.jsonl');
|
|
12
|
+
writeFileSync(
|
|
13
|
+
path,
|
|
14
|
+
[
|
|
15
|
+
JSON.stringify({ type: 'system', content: 'ignore' }),
|
|
16
|
+
JSON.stringify({ type: 'user', content: [{ type: 'text', text: 'hello grok' }] }),
|
|
17
|
+
JSON.stringify({ type: 'assistant', content: 'captured reply' }),
|
|
18
|
+
JSON.stringify({ type: 'tool_result', content: 'not a turn' }),
|
|
19
|
+
].join('\n'),
|
|
20
|
+
);
|
|
21
|
+
try {
|
|
22
|
+
const turns = readTranscript(path);
|
|
23
|
+
assert.deepEqual(
|
|
24
|
+
turns.map((t) => ({ role: t.role, content: t.content })),
|
|
25
|
+
[
|
|
26
|
+
{ role: 'user', content: 'hello grok' },
|
|
27
|
+
{ role: 'assistant', content: 'captured reply' },
|
|
28
|
+
],
|
|
29
|
+
);
|
|
30
|
+
const groups = getLogicalTurnGroups(path);
|
|
31
|
+
assert.equal(groups.length, 1);
|
|
32
|
+
assert.equal(groups[0].user.content, 'hello grok');
|
|
33
|
+
assert.equal(groups[0].representative.content, 'captured reply');
|
|
34
|
+
} finally {
|
|
35
|
+
rmSync(dir, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
@@ -59,14 +59,18 @@ export function readTranscript(transcriptPath) {
|
|
|
59
59
|
if (entry.isSidechain === true) continue;
|
|
60
60
|
|
|
61
61
|
const msg = entry.message;
|
|
62
|
-
|
|
62
|
+
const grokContent = entry.content;
|
|
63
|
+
const role = msg?.role ?? entry.type;
|
|
64
|
+
const rawContent = msg?.content ?? grokContent;
|
|
65
|
+
if (!role || rawContent == null) continue;
|
|
63
66
|
|
|
64
|
-
const text = extractText(
|
|
67
|
+
const text = extractText(rawContent);
|
|
65
68
|
if (!text) continue;
|
|
66
69
|
|
|
67
70
|
const ts = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
|
|
71
|
+
// grok chat_history.jsonl は Claude の message 包みを持たず type/content 直置き。
|
|
68
72
|
turns.push({
|
|
69
|
-
role
|
|
73
|
+
role,
|
|
70
74
|
content: text,
|
|
71
75
|
turn_number: turns.length,
|
|
72
76
|
timestamp: Number.isNaN(ts) ? null : ts,
|
package/src/turn-processor.mjs
CHANGED
|
@@ -46,7 +46,7 @@ import { readLatestUsage } from './transcript-usage.mjs';
|
|
|
46
46
|
import { pathToFileURL } from 'node:url';
|
|
47
47
|
import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
48
48
|
import { writeCompletedTurnReceipt } from './completed-turn-receipts.mjs';
|
|
49
|
-
import {
|
|
49
|
+
import { hostAdapterForSessionId, normalizeHookPayload } from './hosts/index.mjs';
|
|
50
50
|
|
|
51
51
|
/** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
|
|
52
52
|
export const L2_WINDOW = 20;
|
|
@@ -191,8 +191,7 @@ export async function run() {
|
|
|
191
191
|
process.stdin.on('end', resolve);
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
-
const payload = JSON.parse(raw || '{}');
|
|
195
|
-
if (isUnsupportedNonClaudeEnvelope(payload)) return;
|
|
194
|
+
const payload = normalizeHookPayload(JSON.parse(raw || '{}'));
|
|
196
195
|
const { session_id, transcript_path, cwd, last_assistant_message } = payload;
|
|
197
196
|
if (!session_id) throw new Error('Missing session_id in Stop payload');
|
|
198
197
|
|
|
@@ -206,10 +205,12 @@ export async function run() {
|
|
|
206
205
|
process.stderr.write(`[vscode-task] ${msg}\n`);
|
|
207
206
|
}
|
|
208
207
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
208
|
+
if (hostAdapterForSessionId(session_id).waitsForStopTranscriptFlush) {
|
|
209
|
+
await waitForClaudeStopTranscriptFlush({
|
|
210
|
+
transcriptPath: transcript_path,
|
|
211
|
+
lastAssistantMessage: last_assistant_message,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
213
214
|
|
|
214
215
|
// Stop hook 時点で state ファイルを更新 → token-monitor の「アクティブ行」判定が
|
|
215
216
|
// アシスタント応答終了時刻まで追従する
|
package/src/hook-envelope.mjs
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
// Grok can invoke Claude-compatible hook commands with its camelCase wire.
|
|
2
|
-
// Throughline does not support Grok as a host, so this envelope is ignored
|
|
3
|
-
// before DB, state, VS Code task, transcript, or runtime-error side effects.
|
|
4
|
-
export function isUnsupportedNonClaudeEnvelope(payload) {
|
|
5
|
-
return payload !== null
|
|
6
|
-
&& typeof payload === 'object'
|
|
7
|
-
&& typeof payload.sessionId === 'string'
|
|
8
|
-
&& payload.sessionId.length > 0
|
|
9
|
-
&& typeof payload.hookEventName === 'string'
|
|
10
|
-
&& payload.hookEventName.length > 0
|
|
11
|
-
&& !Object.hasOwn(payload, 'session_id');
|
|
12
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|