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 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.3...HEAD
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.12.3",
3
+ "version": "0.12.4",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -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
+ });
@@ -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
- const posix = projectPath.replace(/\\/g, '/').replace(/^\/+/, '');
59
- const encoded = posix.replace(/:/g, '').replace(/\//g, '-');
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-kite_-Developer-dotagents',
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
  });
@@ -2,7 +2,7 @@
2
2
  * hosts/index.mjs — host 境界の入口
3
3
  *
4
4
  * 共有 hook 入口 (session-start / prompt-submit / turn-processor) はここから
5
- * `normalizeHookPayload` と `hostAdapterForSessionId` だけを使い、
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 等。
@@ -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, normalizeHookPayload } from './hosts/index.mjs';
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 = normalizeHookPayload(JSON.parse(raw), { env: process.env });
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
 
@@ -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, normalizeHookPayload } from './hosts/index.mjs';
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 = normalizeHookPayload(JSON.parse(raw), { env: process.env });
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');
@@ -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, normalizeHookPayload } from './hosts/index.mjs';
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 = normalizeHookPayload(JSON.parse(raw || '{}'), { env: process.env });
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