throughline 0.12.3 → 0.12.4
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 +9 -1
- package/docs/adr/0022-cursor-host-capture.md +2 -0
- package/package.json +1 -1
- package/src/hook-entrypoints.test.mjs +64 -0
- package/src/hosts/cursor.mjs +2 -2
- package/src/hosts/cursor.test.mjs +22 -1
- package/src/hosts/index.mjs +6 -1
- package/src/prompt-submit.mjs +2 -2
- package/src/session-start.mjs +2 -2
- package/src/turn-processor.mjs +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,13 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.12.4] — 2026-10-04
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- WindowsのCursorがhookの標準入力へ付けるUTF-8 BOMをJSONとして読もうとして、SessionStart・UserPromptSubmit・Stopが失敗する不具合を直す。共有の読取処理で先頭のBOMを1つ取り除いてから解析する。JSONの文字列中のBOMは保ち、不正なJSONは引き続き失敗として扱う。Windowsの実hook入力でSessionStartの例外を再現し、3種類のhookで会話の保存と不正JSONの拒否を試験した。
|
|
18
|
+
- Cursorの`transcript_path`が未指定の場合、projectのフォルダ名をCursor自身と同じ規則で作る。以前はアンダースコアやドットを残していたため、foxの`C:\Users\kite_\.cache\…`などで実際の記録フォルダを見つけられなかった。英数字以外をハイフンに置き換え、連続するハイフンと両端を整える。指定された`transcript_path`はそのまま優先する。
|
|
19
|
+
|
|
13
20
|
## [0.12.3] — 2026-10-03
|
|
14
21
|
|
|
15
22
|
### 修正
|
|
@@ -1570,7 +1577,8 @@ two attempts, instrument first instead of patching again.
|
|
|
1570
1577
|
|
|
1571
1578
|
---
|
|
1572
1579
|
|
|
1573
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.12.
|
|
1580
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.12.4...HEAD
|
|
1581
|
+
[0.12.4]: https://github.com/kitepon/Throughline/compare/v0.12.3...v0.12.4
|
|
1574
1582
|
[0.12.3]: https://github.com/kitepon/Throughline/compare/v0.12.2...v0.12.3
|
|
1575
1583
|
[0.12.2]: https://github.com/kitepon/Throughline/compare/v0.12.1...v0.12.2
|
|
1576
1584
|
[0.12.1]: https://github.com/kitepon/Throughline/compare/v0.12.0...v0.12.1
|
|
@@ -19,6 +19,8 @@ L2 lives in `~/.cursor/projects/<slug>/agent-transcripts/<uuid>/<uuid>.jsonl` wi
|
|
|
19
19
|
## Decision
|
|
20
20
|
|
|
21
21
|
- Detect Cursor envelope (`cursor_version` or `hook_event_name` in the Cursor event set) as host=cursor.
|
|
22
|
+
- Shared hook stdin parsing accepts one leading UTF-8 BOM, as emitted by Windows Cursor. It preserves BOM characters inside JSON strings and still rejects malformed JSON.
|
|
23
|
+
- When `transcript_path` is absent, derive the project folder with Cursor's `utils/workspace-paths` rule: replace non-ASCII-alphanumeric characters with `-`, collapse consecutive hyphens, and remove leading/trailing hyphens. A supplied `transcript_path` remains authoritative.
|
|
22
24
|
- Normalize to the existing snake_case hook contract and prefix ids with `cursor:`. Strip optional `bc-` from cloud conversation ids when looking up transcripts.
|
|
23
25
|
- Prefer payload `transcript_path`. If absent, derive the agent-transcripts path. Do not invent a Claude-shaped transcript.
|
|
24
26
|
- `throughline install` upserts product hooks into `~/.cursor/hooks.json`. Keep factory / personal commands. Command is absolute `node` + `bin/throughline.mjs`. Do not write factory.json. Do not write bare `throughline`.
|
package/package.json
CHANGED
|
@@ -1278,3 +1278,67 @@ test('Phase 0-5: /tl and /clear prompts do NOT trigger spike', () => {
|
|
|
1278
1278
|
rmSync(home, { recursive: true, force: true });
|
|
1279
1279
|
}
|
|
1280
1280
|
});
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
test('Cursor hooks accept the UTF-8 BOM emitted by Windows and capture the turn', () => {
|
|
1284
|
+
const home = makeTempHome();
|
|
1285
|
+
const project = makeTempProject();
|
|
1286
|
+
const id = '23c59dba-f4b5-4ce7-a2de-27a2fbbb4193';
|
|
1287
|
+
const transcript = join(project, 'cursor.jsonl');
|
|
1288
|
+
writeFileSync(transcript, [
|
|
1289
|
+
{ role: 'user', message: { content: [{ type: 'text', text: 'BOM hook capture' }] } },
|
|
1290
|
+
{ role: 'assistant', message: { content: [{ type: 'text', text: 'Captured' }] } },
|
|
1291
|
+
].map((entry) => JSON.stringify(entry)).join('\n') + '\n');
|
|
1292
|
+
try {
|
|
1293
|
+
for (const [event, command] of [
|
|
1294
|
+
['sessionStart', 'session-start'],
|
|
1295
|
+
['beforeSubmitPrompt', 'prompt-submit'],
|
|
1296
|
+
['stop', 'process-turn'],
|
|
1297
|
+
]) {
|
|
1298
|
+
const result = runNode([join(REPO_ROOT, 'bin/throughline.mjs'), command], {
|
|
1299
|
+
home,
|
|
1300
|
+
cwd: project,
|
|
1301
|
+
input: '\uFEFF' + JSON.stringify({
|
|
1302
|
+
conversation_id: id,
|
|
1303
|
+
session_id: id,
|
|
1304
|
+
hook_event_name: event,
|
|
1305
|
+
cursor_version: '2026.10.01-e373342',
|
|
1306
|
+
workspace_roots: [project],
|
|
1307
|
+
transcript_path: transcript,
|
|
1308
|
+
prompt: '/tl',
|
|
1309
|
+
}),
|
|
1310
|
+
});
|
|
1311
|
+
assert.equal(result.status, 0, `${event}: ${result.stderr}`);
|
|
1312
|
+
}
|
|
1313
|
+
const db = openDb(home);
|
|
1314
|
+
try {
|
|
1315
|
+
assert.equal(db.prepare('SELECT session_id FROM sessions').get().session_id, `cursor:${id}`);
|
|
1316
|
+
assert.equal(db.prepare('SELECT session_id FROM handoff_batons').get().session_id, `cursor:${id}`);
|
|
1317
|
+
const bodies = db.prepare('SELECT role, text FROM bodies ORDER BY role').all();
|
|
1318
|
+
assert.deepEqual(bodies.map((row) => [row.role, row.text]), [
|
|
1319
|
+
['assistant', 'Captured'], ['user', 'BOM hook capture'],
|
|
1320
|
+
]);
|
|
1321
|
+
} finally {
|
|
1322
|
+
db.close();
|
|
1323
|
+
}
|
|
1324
|
+
} finally {
|
|
1325
|
+
rmSync(project, { recursive: true, force: true });
|
|
1326
|
+
rmSync(home, { recursive: true, force: true });
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
test('a leading BOM does not hide malformed hook JSON', () => {
|
|
1331
|
+
const home = makeTempHome();
|
|
1332
|
+
try {
|
|
1333
|
+
for (const command of ['session-start', 'prompt-submit', 'process-turn']) {
|
|
1334
|
+
const result = runNode([join(REPO_ROOT, 'bin/throughline.mjs'), command], {
|
|
1335
|
+
home, input: '\uFEFF{"conversation_id":',
|
|
1336
|
+
});
|
|
1337
|
+
assert.notEqual(result.status, 0, command);
|
|
1338
|
+
assert.match(result.stderr, /SyntaxError/, command);
|
|
1339
|
+
}
|
|
1340
|
+
assert.equal(existsSync(join(home, '.throughline', 'throughline.db')), false);
|
|
1341
|
+
} finally {
|
|
1342
|
+
rmSync(home, { recursive: true, force: true });
|
|
1343
|
+
}
|
|
1344
|
+
});
|
package/src/hosts/cursor.mjs
CHANGED
|
@@ -55,8 +55,8 @@ export function isCursorEnvelope(payload) {
|
|
|
55
55
|
|
|
56
56
|
export function encodeCursorProjectDir(projectPath) {
|
|
57
57
|
if (typeof projectPath !== 'string' || projectPath.length === 0) return null;
|
|
58
|
-
|
|
59
|
-
const encoded =
|
|
58
|
+
// Cursor's utils/workspace-paths normalizes every non-ASCII-alphanumeric run.
|
|
59
|
+
const encoded = projectPath.replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
|
60
60
|
return encoded.length > 0 ? encoded : null;
|
|
61
61
|
}
|
|
62
62
|
|
|
@@ -7,8 +7,24 @@ import {
|
|
|
7
7
|
encodeCursorProjectDir,
|
|
8
8
|
isCursorEnvelope,
|
|
9
9
|
normalizeHookPayload,
|
|
10
|
+
parseHookPayload,
|
|
10
11
|
} from './index.mjs';
|
|
11
12
|
|
|
13
|
+
test('parseHookPayload accepts one leading BOM and preserves BOM inside prompt text', () => {
|
|
14
|
+
const raw = JSON.stringify({
|
|
15
|
+
conversation_id: 'cursor-bom',
|
|
16
|
+
hook_event_name: 'beforeSubmitPrompt',
|
|
17
|
+
workspace_roots: ['/project'],
|
|
18
|
+
prompt: 'keep \uFEFF in text',
|
|
19
|
+
});
|
|
20
|
+
const expected = parseHookPayload(raw);
|
|
21
|
+
assert.deepEqual(parseHookPayload('\uFEFF' + raw), expected);
|
|
22
|
+
assert.equal(expected.session_id, 'cursor:cursor-bom');
|
|
23
|
+
assert.equal(expected.prompt, 'keep \uFEFF in text');
|
|
24
|
+
assert.throws(() => parseHookPayload('\uFEFF\uFEFF' + raw), SyntaxError);
|
|
25
|
+
assert.throws(() => parseHookPayload('\uFEFF{"conversation_id":'), SyntaxError);
|
|
26
|
+
});
|
|
27
|
+
|
|
12
28
|
test('isCursorEnvelope detects Cursor events and cursor_version', () => {
|
|
13
29
|
assert.equal(
|
|
14
30
|
isCursorEnvelope({
|
|
@@ -99,6 +115,11 @@ test('encodeCursorProjectDir matches ~/.cursor/projects slug', () => {
|
|
|
99
115
|
);
|
|
100
116
|
assert.equal(
|
|
101
117
|
encodeCursorProjectDir(String.raw`C:\Users\kite_\Developer\dotagents`),
|
|
102
|
-
'C-Users-
|
|
118
|
+
'C-Users-kite-Developer-dotagents',
|
|
119
|
+
);
|
|
120
|
+
assert.equal(
|
|
121
|
+
encodeCursorProjectDir(String.raw`C:\Users\kite_\.cache\aiterm-branch-test\steer-test`),
|
|
122
|
+
'C-Users-kite-cache-aiterm-branch-test-steer-test',
|
|
103
123
|
);
|
|
124
|
+
assert.equal(encodeCursorProjectDir('/work/part_with.dot space/'), 'work-part-with-dot-space');
|
|
104
125
|
});
|
package/src/hosts/index.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* hosts/index.mjs — host 境界の入口
|
|
3
3
|
*
|
|
4
4
|
* 共有 hook 入口 (session-start / prompt-submit / turn-processor) はここから
|
|
5
|
-
* `
|
|
5
|
+
* `parseHookPayload` と `hostAdapterForSessionId` だけを使い、
|
|
6
6
|
* ベンダー分岐を直接書かない。
|
|
7
7
|
*/
|
|
8
8
|
import { hostOfSessionId, CLAUDE_HOST, CODEX_HOST, GROK_HOST, CURSOR_HOST } from './identity.mjs';
|
|
@@ -27,6 +27,11 @@ const ADAPTERS = Object.freeze({
|
|
|
27
27
|
[CURSOR_HOST]: cursorHostAdapter,
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
/** Parse hook stdin, including the leading UTF-8 BOM emitted by Windows Cursor. */
|
|
31
|
+
export function parseHookPayload(raw, options = {}) {
|
|
32
|
+
return normalizeHookPayload(JSON.parse(raw.replace(/^\uFEFF/, '')), options);
|
|
33
|
+
}
|
|
34
|
+
|
|
30
35
|
/**
|
|
31
36
|
* hook stdin payload を Claude snake_case 契約へ正規化する。
|
|
32
37
|
* Grok は camelCase wire、Cursor は hook_event_name が sessionStart 等。
|
package/src/prompt-submit.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import { join, dirname } from 'node:path';
|
|
|
40
40
|
import { homedir } from 'node:os';
|
|
41
41
|
import { pathToFileURL } from 'node:url';
|
|
42
42
|
import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
43
|
-
import { hostAdapterForSessionId,
|
|
43
|
+
import { hostAdapterForSessionId, parseHookPayload } from './hosts/index.mjs';
|
|
44
44
|
|
|
45
45
|
// Phase 0-5 spike marker (SessionStart の spike-inject.flag とは別)
|
|
46
46
|
const PROMPT_SPIKE_MARKER_PATH = join(homedir(), '.throughline', 'spike-prompt.flag');
|
|
@@ -152,7 +152,7 @@ export async function run() {
|
|
|
152
152
|
process.stdin.on('end', resolve);
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
-
const payload =
|
|
155
|
+
const payload = parseHookPayload(raw, { env: process.env });
|
|
156
156
|
const { session_id, cwd, prompt } = payload;
|
|
157
157
|
const hostAdapter = hostAdapterForSessionId(session_id);
|
|
158
158
|
|
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 { hostAdapterForSessionId, NON_CLAUDE_SESSION_PREFIXES,
|
|
35
|
+
import { hostAdapterForSessionId, NON_CLAUDE_SESSION_PREFIXES, parseHookPayload } from './hosts/index.mjs';
|
|
36
36
|
import { executeFirstPromptHandoff } from './handoff-executor.mjs';
|
|
37
37
|
|
|
38
38
|
const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
|
|
@@ -101,7 +101,7 @@ export async function run() {
|
|
|
101
101
|
process.stdin.on('end', resolve);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
const payload =
|
|
104
|
+
const payload = parseHookPayload(raw, { env: process.env });
|
|
105
105
|
const { session_id, cwd, source, transcript_path } = payload;
|
|
106
106
|
|
|
107
107
|
if (!session_id) throw new Error('Missing session_id in SessionStart payload');
|
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 { hostAdapterForSessionId,
|
|
49
|
+
import { hostAdapterForSessionId, parseHookPayload } from './hosts/index.mjs';
|
|
50
50
|
|
|
51
51
|
/** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
|
|
52
52
|
export const L2_WINDOW = 20;
|
|
@@ -219,7 +219,7 @@ export async function run() {
|
|
|
219
219
|
process.stdin.on('end', resolve);
|
|
220
220
|
});
|
|
221
221
|
|
|
222
|
-
const payload =
|
|
222
|
+
const payload = parseHookPayload(raw || '{}', { env: process.env });
|
|
223
223
|
const { session_id, transcript_path, cwd, last_assistant_message } = payload;
|
|
224
224
|
if (!session_id) throw new Error('Missing session_id in Stop payload');
|
|
225
225
|
|