throughline 0.10.2 → 0.10.3
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 +19 -1
- package/README.ja.md +8 -2
- package/README.md +7 -0
- package/docs/00_overview.md +1 -0
- package/docs/adr/0022-cursor-host-capture.md +39 -0
- package/package.json +9 -8
- package/src/cli/install.mjs +91 -0
- package/src/cli/install.test.mjs +57 -0
- package/src/hosts/claude.mjs +1 -0
- package/src/hosts/codex.mjs +1 -0
- package/src/hosts/cursor.mjs +128 -0
- package/src/hosts/cursor.test.mjs +104 -0
- package/src/hosts/grok.mjs +1 -0
- package/src/hosts/identity.mjs +19 -2
- package/src/hosts/identity.test.mjs +27 -4
- package/src/hosts/index.mjs +11 -2
- package/src/session-start.mjs +24 -1
- package/src/transcript-reader-cursor.test.mjs +43 -0
- package/src/transcript-reader.mjs +14 -7
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,22 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.10.3] — 2026-08-24
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Cursor を first-class hook host にする(工場 Cursor harness campaign Wave 6)。
|
|
18
|
+
- envelope は `hook_event_name=sessionStart|beforeSubmitPrompt|stop` と
|
|
19
|
+
`conversation_id` / `cursor_version`。session id は `cursor:<uuid>`。
|
|
20
|
+
- L2 は payload の `transcript_path`、無ければ
|
|
21
|
+
`~/.cursor/projects/<slug>/agent-transcripts/<id>/<id>.jsonl`。
|
|
22
|
+
- `throughline install` は `~/.cursor/hooks.json` へ絶対 `node` +
|
|
23
|
+
`bin/throughline.mjs` の sessionStart / beforeSubmitPrompt / stop を upsert
|
|
24
|
+
する。工場 hook(`cursor-*-hook`)は残す。bare `throughline` は書かない。
|
|
25
|
+
- beforeSubmitPrompt は continue のみなので、引き継ぎ注入は sessionStart の
|
|
26
|
+
`additional_context`。`/tl` 後継の自動起動はしない。
|
|
27
|
+
- Claude / Codex / Grok 契約は変えない。
|
|
28
|
+
|
|
13
29
|
## [0.10.2] — 2026-08-24
|
|
14
30
|
|
|
15
31
|
### Changed
|
|
@@ -1304,7 +1320,9 @@ two attempts, instrument first instead of patching again.
|
|
|
1304
1320
|
|
|
1305
1321
|
---
|
|
1306
1322
|
|
|
1307
|
-
[Unreleased]: https://github.com/kitepon-rgb/Throughline/compare/v0.10.
|
|
1323
|
+
[Unreleased]: https://github.com/kitepon-rgb/Throughline/compare/v0.10.3...HEAD
|
|
1324
|
+
[0.10.3]: https://github.com/kitepon-rgb/Throughline/compare/v0.10.2...v0.10.3
|
|
1325
|
+
[0.10.2]: https://github.com/kitepon-rgb/Throughline/compare/v0.10.1...v0.10.2
|
|
1308
1326
|
[0.10.1]: https://github.com/kitepon-rgb/Throughline/compare/v0.10.0...v0.10.1
|
|
1309
1327
|
[0.10.0]: https://github.com/kitepon-rgb/Throughline/compare/v0.9.1...v0.10.0
|
|
1310
1328
|
[0.9.1]: https://github.com/kitepon-rgb/Throughline/compare/v0.9.0...v0.9.1
|
package/README.ja.md
CHANGED
|
@@ -39,8 +39,14 @@ throughline install # hook / Codex skill / VS Code monitor task を登録
|
|
|
39
39
|
`/clear` を経由しない新規 chat / VS Code 再起動では `/tl` で前任を指名できる。
|
|
40
40
|
|
|
41
41
|
Grok Desktop も first-class host である。`throughline install` は
|
|
42
|
-
`~/.grok/hooks/throughline.json` を書く。Grok
|
|
43
|
-
Terminal
|
|
42
|
+
`~/.grok/hooks/throughline.json` を書く。Grok では `/tl` は今の窓へ注入せず、
|
|
43
|
+
新しい Terminal 席を立てる。
|
|
44
|
+
|
|
45
|
+
Cursor も first-class host である。`throughline install` は `~/.cursor/hooks.json`
|
|
46
|
+
へ sessionStart / beforeSubmitPrompt / stop を upsert し、工場 hook は残す。
|
|
47
|
+
capture は Cursor の `agent-transcripts` jsonl。注入は sessionStart の
|
|
48
|
+
`additional_context`。`/tl` 後継の自動起動はしない。
|
|
49
|
+
[ADR 0022](docs/adr/0022-cursor-host-capture.md) を正とする。
|
|
44
50
|
|
|
45
51
|
<details>
|
|
46
52
|
<summary><b>Grok も併用する場合</b> Grok hooks も登録される — クリックで詳細</summary>
|
package/README.md
CHANGED
|
@@ -43,6 +43,13 @@ Grok Desktop is also a first-class host. `throughline install` writes
|
|
|
43
43
|
`~/.grok/hooks/throughline.json`. On Grok, `/tl` does not inject into the
|
|
44
44
|
current window — it starts a new Terminal seat. See below.
|
|
45
45
|
|
|
46
|
+
Cursor is also a first-class host. `throughline install` upserts
|
|
47
|
+
`sessionStart` / `beforeSubmitPrompt` / `stop` into `~/.cursor/hooks.json` and
|
|
48
|
+
leaves factory hooks in place. Capture reads Cursor `agent-transcripts` jsonl.
|
|
49
|
+
Handoff injection uses `sessionStart` `additional_context`. `/tl` does not
|
|
50
|
+
auto-launch a successor Cursor chat — the next new conversation drinks the
|
|
51
|
+
baton. See [ADR 0022](docs/adr/0022-cursor-host-capture.md).
|
|
52
|
+
|
|
46
53
|
<details>
|
|
47
54
|
<summary><b>Also using Codex?</b> Global install registers Codex hooks too — click for details.</summary>
|
|
48
55
|
|
package/docs/00_overview.md
CHANGED
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
- [ADR 0003](adr/0003-observer-completed-chain-cursor.md): Observer cursorをhost固有のcompleted pair chainとprefix検証へ束縛する。
|
|
41
41
|
- [ADR 0020](adr/0020-windows-ci-release-latency.md): Windows ACL契約を維持し、境界fixtureと実ACL検証を分離する。
|
|
42
42
|
- [ADR 0021](adr/0021-grok-host-capture.md): Grok を first-class hook host にし、`/tl` 後の記憶再開は `grok-continue` に固定する。
|
|
43
|
+
- [ADR 0022](adr/0022-cursor-host-capture.md): Cursor を first-class hook host にし、注入は sessionStart の `additional_context`、hook は `~/.cursor/hooks.json` への upsert。
|
|
43
44
|
|
|
44
45
|
Observerの公開境界は`throughline observer-read`/`throughline observer-wait`のJSON-only CLIである。
|
|
45
46
|
ThroughlineはClaude Stop receiptとCodex rolloutの`task_complete`だけからcompleted cursorを構築し、
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# ADR 0022: Treat Cursor as a first-class Throughline host
|
|
2
|
+
|
|
3
|
+
Date: 2026-08-24
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
Accepted
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
dotagents Wave 5 made Cursor a peer factory harness. Throughline still had only Claude / Codex / Grok adapters. Cursor parent sessions were not captured.
|
|
12
|
+
|
|
13
|
+
Cursor hook envelope is not Claude PascalCase and not Grok camelCase. Official events are `sessionStart`, `beforeSubmitPrompt` (Claude `UserPromptSubmit` 相当), and `stop`. Common fields include `conversation_id`, `workspace_roots`, `cursor_version`, and optional `transcript_path`.
|
|
14
|
+
|
|
15
|
+
L2 lives in `~/.cursor/projects/<slug>/agent-transcripts/<uuid>/<uuid>.jsonl` with role-first rows (`{role, message.content}`), not Claude `{type, message}`.
|
|
16
|
+
|
|
17
|
+
`beforeSubmitPrompt` stdout is `{continue}` only. `sessionStart` may return `additional_context`. Cursor has a single `~/.cursor/hooks.json`; factory hooks already occupy that file.
|
|
18
|
+
|
|
19
|
+
## Decision
|
|
20
|
+
|
|
21
|
+
- Detect Cursor envelope (`cursor_version` or `hook_event_name` in the Cursor event set) as host=cursor.
|
|
22
|
+
- 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
|
+
- Prefer payload `transcript_path`. If absent, derive the agent-transcripts path. Do not invent a Claude-shaped transcript.
|
|
24
|
+
- `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`.
|
|
25
|
+
- Cursor `sessionStart` of a new composer conversation is the handoff injection mouth (`additional_context`). Consume pending there. Skip `is_background_agent`. Claude ghost-SessionStart rules stay on Claude.
|
|
26
|
+
- `/tl` writes a baton. Do not auto-launch a successor Cursor chat. The next new conversation's `sessionStart` drinks the baton.
|
|
27
|
+
- Keep Claude, Codex, and Grok adapters unchanged. Do not mix `cursor:` rows into Claude predecessor search.
|
|
28
|
+
|
|
29
|
+
## Consequences
|
|
30
|
+
|
|
31
|
+
- Cursor capture / restore / `handoff-context` reuse the existing DB after hook fire exists.
|
|
32
|
+
- Live injection in the same conversation after `/tl` is not a Cursor contract (no additional_context on `beforeSubmitPrompt`).
|
|
33
|
+
- Factory apply-cursor-config must keep Throughline commands, matching the existing personal-hook upsert.
|
|
34
|
+
|
|
35
|
+
## 現在地
|
|
36
|
+
|
|
37
|
+
公式 Cursor hooks 文書(2026-08-24)とこのMacの
|
|
38
|
+
`~/.cursor/projects/Users-kite-Developer-dotagents/agent-transcripts/<uuid>/<uuid>.jsonl`
|
|
39
|
+
を実測した。公開後の新規 Cursor session 受入は別 H。
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "throughline",
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
"version": "0.10.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"claude-code",
|
|
8
|
+
"codex",
|
|
9
|
+
"grok",
|
|
10
|
+
"cursor",
|
|
10
11
|
"hooks",
|
|
11
12
|
"context-compression",
|
|
12
13
|
"llm"
|
|
@@ -40,7 +41,7 @@
|
|
|
40
41
|
"LICENSE"
|
|
41
42
|
],
|
|
42
43
|
"scripts": {
|
|
43
|
-
"test": "node --import ./src/test-env.mjs --test scripts/verify-release-commit.test.mjs src/*.test.mjs src/cli/*.test.mjs",
|
|
44
|
+
"test": "node --import ./src/test-env.mjs --test scripts/verify-release-commit.test.mjs src/*.test.mjs src/cli/*.test.mjs src/hosts/*.test.mjs",
|
|
44
45
|
"verify:release-commit": "node scripts/verify-release-commit.mjs",
|
|
45
46
|
"prepublishOnly": "npm run verify:release-commit && npm test"
|
|
46
47
|
},
|
package/src/cli/install.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* script path で登録する。
|
|
12
12
|
* Grok-facing hook も Desktop の GUI PATH に throughline が無いため、同じ絶対
|
|
13
13
|
* node + CLI script path で ~/.grok/hooks/throughline.json に書く。
|
|
14
|
+
* Cursor-facing hook は ~/.cursor/hooks.json へ upsert する(工場 hook は残す)。
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync, unlinkSync, rmSync, realpathSync } from 'node:fs';
|
|
@@ -27,6 +28,7 @@ const CODEX_SKILL_NAMES = ['throughline'];
|
|
|
27
28
|
const CODEX_HOOKS_RELATIVE_PATH = ['.codex', 'hooks.json'];
|
|
28
29
|
const CODEX_CONFIG_RELATIVE_PATH = ['.codex', 'config.toml'];
|
|
29
30
|
const GROK_HOOKS_RELATIVE_PATH = ['.grok', 'hooks', 'throughline.json'];
|
|
31
|
+
const CURSOR_HOOKS_RELATIVE_PATH = ['.cursor', 'hooks.json'];
|
|
30
32
|
|
|
31
33
|
// Throughline が管理する hook コマンド一覧
|
|
32
34
|
// schema v4 以降: PostToolUse (capture-tool) は廃止。Stop 内で L2/L3 を一括処理する。
|
|
@@ -297,6 +299,16 @@ export function buildGrokHookCommand(subcommand, {
|
|
|
297
299
|
return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} ${subcommand}`;
|
|
298
300
|
}
|
|
299
301
|
|
|
302
|
+
export function buildCursorHookCommand(subcommand, options = {}) {
|
|
303
|
+
return buildGrokHookCommand(subcommand, options);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function isThroughlineCursorHookCommand(command) {
|
|
307
|
+
return typeof command === 'string'
|
|
308
|
+
&& command.includes('throughline.mjs')
|
|
309
|
+
&& /\b(session-start|prompt-submit|process-turn)\b/.test(command);
|
|
310
|
+
}
|
|
311
|
+
|
|
300
312
|
export function createGrokHooksFile(options = {}) {
|
|
301
313
|
return {
|
|
302
314
|
hooks: {
|
|
@@ -334,6 +346,74 @@ function uninstallGrokHooks() {
|
|
|
334
346
|
return { hooksPath, removed: 1 };
|
|
335
347
|
}
|
|
336
348
|
|
|
349
|
+
function resolveCursorHooksPath() {
|
|
350
|
+
return join(homedir(), ...CURSOR_HOOKS_RELATIVE_PATH);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function createCursorHookEntries(options = {}) {
|
|
354
|
+
return {
|
|
355
|
+
sessionStart: [
|
|
356
|
+
{ command: buildCursorHookCommand('session-start', options), timeout: 10 },
|
|
357
|
+
],
|
|
358
|
+
beforeSubmitPrompt: [
|
|
359
|
+
{ command: buildCursorHookCommand('prompt-submit', options), timeout: 30 },
|
|
360
|
+
],
|
|
361
|
+
stop: [
|
|
362
|
+
{ command: buildCursorHookCommand('process-turn', options), timeout: 300 },
|
|
363
|
+
],
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function readCursorHooksFile(hooksPath) {
|
|
368
|
+
if (!existsSync(hooksPath)) return { version: 1, hooks: {} };
|
|
369
|
+
const parsed = JSON.parse(readFileSync(hooksPath, 'utf8'));
|
|
370
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
371
|
+
throw new Error(`${hooksPath} は object である必要があります`);
|
|
372
|
+
}
|
|
373
|
+
if (parsed.hooks == null) parsed.hooks = {};
|
|
374
|
+
if (typeof parsed.hooks !== 'object' || Array.isArray(parsed.hooks)) {
|
|
375
|
+
throw new Error(`${hooksPath} の hooks は object である必要があります`);
|
|
376
|
+
}
|
|
377
|
+
return parsed;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function installCursorHooks() {
|
|
381
|
+
const hooksPath = resolveCursorHooksPath();
|
|
382
|
+
mkdirSync(dirname(hooksPath), { recursive: true });
|
|
383
|
+
const current = readCursorHooksFile(hooksPath);
|
|
384
|
+
current.version = 1;
|
|
385
|
+
const wanted = createCursorHookEntries();
|
|
386
|
+
for (const [event, entries] of Object.entries(wanted)) {
|
|
387
|
+
const existing = Array.isArray(current.hooks[event]) ? current.hooks[event] : [];
|
|
388
|
+
const kept = existing.filter((entry) => !isThroughlineCursorHookCommand(entry?.command));
|
|
389
|
+
current.hooks[event] = [...kept, ...entries];
|
|
390
|
+
}
|
|
391
|
+
writeFileSync(hooksPath, `${JSON.stringify(current, null, 2)}\n`);
|
|
392
|
+
return { hooksPath };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function uninstallCursorHooks() {
|
|
396
|
+
const hooksPath = resolveCursorHooksPath();
|
|
397
|
+
if (!existsSync(hooksPath)) return { hooksPath, removed: 0 };
|
|
398
|
+
const current = readCursorHooksFile(hooksPath);
|
|
399
|
+
let removed = 0;
|
|
400
|
+
for (const [event, list] of Object.entries(current.hooks ?? {})) {
|
|
401
|
+
if (!Array.isArray(list)) continue;
|
|
402
|
+
const kept = list.filter((entry) => {
|
|
403
|
+
if (isThroughlineCursorHookCommand(entry?.command)) {
|
|
404
|
+
removed += 1;
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
return true;
|
|
408
|
+
});
|
|
409
|
+
if (kept.length > 0) current.hooks[event] = kept;
|
|
410
|
+
else delete current.hooks[event];
|
|
411
|
+
}
|
|
412
|
+
if (removed === 0) return { hooksPath, removed: 0 };
|
|
413
|
+
writeFileSync(hooksPath, `${JSON.stringify(current, null, 2)}\n`);
|
|
414
|
+
return { hooksPath, removed };
|
|
415
|
+
}
|
|
416
|
+
|
|
337
417
|
function installSlashCommands(commandsDir) {
|
|
338
418
|
if (!existsSync(SLASH_COMMANDS_SRC)) {
|
|
339
419
|
return { installed: [], skipped: 'source-missing' };
|
|
@@ -589,6 +669,7 @@ export async function run(args = []) {
|
|
|
589
669
|
const removedCommands = uninstallSlashCommands(commandsDir);
|
|
590
670
|
const codex = args.includes('--project') ? null : uninstallCodexHooks();
|
|
591
671
|
const grok = args.includes('--project') ? null : uninstallGrokHooks();
|
|
672
|
+
const cursor = args.includes('--project') ? null : uninstallCursorHooks();
|
|
592
673
|
const removedCodexSkills = args.includes('--project') ? [] : uninstallCodexSkills(codexSkillsDir);
|
|
593
674
|
console.log('Throughline hooks を削除しました。');
|
|
594
675
|
console.log(` ${settingsPath}`);
|
|
@@ -601,6 +682,9 @@ export async function run(args = []) {
|
|
|
601
682
|
if (grok?.removed > 0) {
|
|
602
683
|
console.log(` Grok hooks 削除: ${grok.removed} (${grok.hooksPath})`);
|
|
603
684
|
}
|
|
685
|
+
if (cursor?.removed > 0) {
|
|
686
|
+
console.log(` Cursor hooks 削除: ${cursor.removed} (${cursor.hooksPath})`);
|
|
687
|
+
}
|
|
604
688
|
if (removedCodexSkills.length > 0) {
|
|
605
689
|
console.log(` Codex skills 削除: ${removedCodexSkills.join(', ')} (${codexSkillsDir})`);
|
|
606
690
|
}
|
|
@@ -624,6 +708,7 @@ export async function run(args = []) {
|
|
|
624
708
|
const { installed: installedCommands, skipped } = installSlashCommands(commandsDir);
|
|
625
709
|
const codex = args.includes('--project') ? null : installCodexHooks();
|
|
626
710
|
const grok = args.includes('--project') ? null : installGrokHooks();
|
|
711
|
+
const cursor = args.includes('--project') ? null : installCursorHooks();
|
|
627
712
|
const codexSkills = args.includes('--project') ? { installed: [], skipped: null } : installCodexSkills(codexSkillsDir);
|
|
628
713
|
const monitorTask = ensureMonitorTaskFile({
|
|
629
714
|
cwd: process.cwd(),
|
|
@@ -643,6 +728,9 @@ export async function run(args = []) {
|
|
|
643
728
|
if (grok) {
|
|
644
729
|
console.log(` ${grok.hooksPath}`);
|
|
645
730
|
}
|
|
731
|
+
if (cursor) {
|
|
732
|
+
console.log(` ${cursor.hooksPath}`);
|
|
733
|
+
}
|
|
646
734
|
console.log('');
|
|
647
735
|
console.log('有効な hooks:');
|
|
648
736
|
console.log(' SessionStart → throughline session-start (セッション記録・バトン消費・引き継ぎ注入)');
|
|
@@ -656,6 +744,9 @@ export async function run(args = []) {
|
|
|
656
744
|
if (grok) {
|
|
657
745
|
console.log(' Grok SessionStart / UserPromptSubmit / Stop → ~/.grok/hooks/throughline.json');
|
|
658
746
|
}
|
|
747
|
+
if (cursor) {
|
|
748
|
+
console.log(' Cursor sessionStart / beforeSubmitPrompt / stop → ~/.cursor/hooks.json(工場hookは残す)');
|
|
749
|
+
}
|
|
659
750
|
console.log('');
|
|
660
751
|
if (installedCommands.length > 0) {
|
|
661
752
|
console.log(`slash commands を配置しました: ${installedCommands.map(n => '/' + n.replace(/\.md$/, '')).join(', ')}`);
|
package/src/cli/install.test.mjs
CHANGED
|
@@ -10,8 +10,10 @@ import {
|
|
|
10
10
|
buildCodexUserPromptSubmitHookCommand,
|
|
11
11
|
buildGrokHookCommand,
|
|
12
12
|
createGrokHooksFile,
|
|
13
|
+
createCursorHookEntries,
|
|
13
14
|
isEquivalentCodexHookCommand,
|
|
14
15
|
isThroughlineCodexHookCommand,
|
|
16
|
+
isThroughlineCursorHookCommand,
|
|
15
17
|
parseCodexHookCommand,
|
|
16
18
|
resolveCodexHookNodePath,
|
|
17
19
|
run,
|
|
@@ -92,6 +94,28 @@ test('global install copies Throughline slash commands to ~/.claude/commands/',
|
|
|
92
94
|
assert.match(command, /throughline\.mjs/);
|
|
93
95
|
assert.doesNotMatch(command, /^throughline /);
|
|
94
96
|
}
|
|
97
|
+
const cursorHooks = JSON.parse(readFileSync(join(home.dir, '.cursor', 'hooks.json'), 'utf8'));
|
|
98
|
+
assert.equal(cursorHooks.version, 1);
|
|
99
|
+
assert.equal(
|
|
100
|
+
cursorHooks.hooks.sessionStart[0].command,
|
|
101
|
+
createCursorHookEntries().sessionStart[0].command,
|
|
102
|
+
);
|
|
103
|
+
assert.equal(
|
|
104
|
+
cursorHooks.hooks.beforeSubmitPrompt[0].command,
|
|
105
|
+
createCursorHookEntries().beforeSubmitPrompt[0].command,
|
|
106
|
+
);
|
|
107
|
+
assert.equal(
|
|
108
|
+
cursorHooks.hooks.stop[0].command,
|
|
109
|
+
createCursorHookEntries().stop[0].command,
|
|
110
|
+
);
|
|
111
|
+
for (const command of [
|
|
112
|
+
cursorHooks.hooks.sessionStart[0].command,
|
|
113
|
+
cursorHooks.hooks.beforeSubmitPrompt[0].command,
|
|
114
|
+
cursorHooks.hooks.stop[0].command,
|
|
115
|
+
]) {
|
|
116
|
+
assert.equal(isThroughlineCursorHookCommand(command), true);
|
|
117
|
+
assert.doesNotMatch(command, /^throughline /);
|
|
118
|
+
}
|
|
95
119
|
} finally {
|
|
96
120
|
unsilence();
|
|
97
121
|
home.restore();
|
|
@@ -192,6 +216,39 @@ test('Grok hook commands are absolute node + throughline.mjs on every platform',
|
|
|
192
216
|
assert.equal(file.hooks.Stop[0].hooks[0].async, true);
|
|
193
217
|
});
|
|
194
218
|
|
|
219
|
+
test('Cursor install upserts product hooks and keeps factory commands', async () => {
|
|
220
|
+
const home = makeTempHome();
|
|
221
|
+
if (home.resolved !== home.dir) {
|
|
222
|
+
home.restore();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const unsilence = silence();
|
|
226
|
+
try {
|
|
227
|
+
mkdirSync(join(home.dir, '.cursor'), { recursive: true });
|
|
228
|
+
writeFileSync(join(home.dir, '.cursor', 'hooks.json'), `${JSON.stringify({
|
|
229
|
+
version: 1,
|
|
230
|
+
hooks: {
|
|
231
|
+
sessionStart: [{ command: '/tmp/cursor-todo-gate-hook session-start', timeout: 10 }],
|
|
232
|
+
},
|
|
233
|
+
}, null, 2)}\n`);
|
|
234
|
+
await run([]);
|
|
235
|
+
const installed = JSON.parse(readFileSync(join(home.dir, '.cursor', 'hooks.json'), 'utf8'));
|
|
236
|
+
assert.equal(installed.hooks.sessionStart[0].command, '/tmp/cursor-todo-gate-hook session-start');
|
|
237
|
+
assert.equal(isThroughlineCursorHookCommand(installed.hooks.sessionStart[1].command), true);
|
|
238
|
+
assert.equal(isThroughlineCursorHookCommand(installed.hooks.beforeSubmitPrompt[0].command), true);
|
|
239
|
+
assert.equal(isThroughlineCursorHookCommand(installed.hooks.stop[0].command), true);
|
|
240
|
+
await run(['--uninstall']);
|
|
241
|
+
const after = JSON.parse(readFileSync(join(home.dir, '.cursor', 'hooks.json'), 'utf8'));
|
|
242
|
+
assert.equal(after.hooks.sessionStart.length, 1);
|
|
243
|
+
assert.equal(after.hooks.sessionStart[0].command, '/tmp/cursor-todo-gate-hook session-start');
|
|
244
|
+
assert.equal(after.hooks.beforeSubmitPrompt, undefined);
|
|
245
|
+
assert.equal(after.hooks.stop, undefined);
|
|
246
|
+
} finally {
|
|
247
|
+
unsilence();
|
|
248
|
+
home.restore();
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
195
252
|
test('Codex hook builders use the PowerShell call operator on Windows only', () => {
|
|
196
253
|
const options = {
|
|
197
254
|
nodePath: String.raw`C:\Program Files\nodejs\node.exe`,
|
package/src/hosts/claude.mjs
CHANGED
package/src/hosts/codex.mjs
CHANGED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hosts/cursor.mjs — Cursor host 境界 (envelope 正規化 + hook adapter)
|
|
3
|
+
*
|
|
4
|
+
* Cursor は Claude PascalCase でも Grok camelCase wire でもない。
|
|
5
|
+
* hook_event_name は sessionStart / beforeSubmitPrompt / stop などの
|
|
6
|
+
* Cursor envelope。session 識別は conversation_id(sessionStart では
|
|
7
|
+
* session_id と同じ)。L2 は ~/.cursor/projects/<slug>/agent-transcripts/<id>/<id>.jsonl。
|
|
8
|
+
*
|
|
9
|
+
* beforeSubmitPrompt の stdout は continue だけ(additional_context なし)。
|
|
10
|
+
* 引き継ぎ注入は sessionStart の additional_context が公式口。
|
|
11
|
+
* /tl 後継の自動起動はしない(次の新規 Cursor 会話の sessionStart が飲む)。
|
|
12
|
+
*/
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
CURSOR_HOST,
|
|
18
|
+
CURSOR_SESSION_PREFIX,
|
|
19
|
+
cursorBareSessionId,
|
|
20
|
+
isCursorSessionId,
|
|
21
|
+
} from './identity.mjs';
|
|
22
|
+
|
|
23
|
+
const CURSOR_HOOK_EVENTS = new Set([
|
|
24
|
+
'sessionStart',
|
|
25
|
+
'sessionEnd',
|
|
26
|
+
'beforeSubmitPrompt',
|
|
27
|
+
'stop',
|
|
28
|
+
'preToolUse',
|
|
29
|
+
'postToolUse',
|
|
30
|
+
'postToolUseFailure',
|
|
31
|
+
'subagentStart',
|
|
32
|
+
'subagentStop',
|
|
33
|
+
'beforeShellExecution',
|
|
34
|
+
'afterShellExecution',
|
|
35
|
+
'beforeMCPExecution',
|
|
36
|
+
'afterMCPExecution',
|
|
37
|
+
'beforeReadFile',
|
|
38
|
+
'afterFileEdit',
|
|
39
|
+
'preCompact',
|
|
40
|
+
'afterAgentResponse',
|
|
41
|
+
'afterAgentThought',
|
|
42
|
+
'beforeTabFileRead',
|
|
43
|
+
'afterTabFileEdit',
|
|
44
|
+
'workspaceOpen',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
export function isCursorEnvelope(payload) {
|
|
48
|
+
if (payload === null || typeof payload !== 'object') return false;
|
|
49
|
+
if (typeof payload.cursor_version === 'string' && payload.cursor_version.length > 0) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return typeof payload.hook_event_name === 'string'
|
|
53
|
+
&& CURSOR_HOOK_EVENTS.has(payload.hook_event_name);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function encodeCursorProjectDir(projectPath) {
|
|
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, '-');
|
|
60
|
+
return encoded.length > 0 ? encoded : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function deriveCursorTranscriptPath(projectPath, sessionId, { home = homedir() } = {}) {
|
|
64
|
+
const bare = cursorBareSessionId(sessionId);
|
|
65
|
+
const slug = encodeCursorProjectDir(projectPath);
|
|
66
|
+
if (!bare || !slug) return null;
|
|
67
|
+
return join(home, '.cursor', 'projects', slug, 'agent-transcripts', bare, `${bare}.jsonl`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cursorCwd(payload) {
|
|
71
|
+
if (typeof payload.cwd === 'string' && payload.cwd.length > 0) return payload.cwd;
|
|
72
|
+
const roots = payload.workspace_roots;
|
|
73
|
+
if (Array.isArray(roots) && typeof roots[0] === 'string' && roots[0].length > 0) {
|
|
74
|
+
return roots[0];
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cursorRawSessionId(payload) {
|
|
80
|
+
if (typeof payload.conversation_id === 'string' && payload.conversation_id.length > 0) {
|
|
81
|
+
return payload.conversation_id;
|
|
82
|
+
}
|
|
83
|
+
if (typeof payload.session_id === 'string' && payload.session_id.length > 0) {
|
|
84
|
+
return payload.session_id;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function normalizeCursorHookPayload(payload, { home = homedir() } = {}) {
|
|
90
|
+
if (!isCursorEnvelope(payload)) return payload;
|
|
91
|
+
const rawId = cursorRawSessionId(payload);
|
|
92
|
+
const cwd = cursorCwd(payload);
|
|
93
|
+
const prefixed = rawId ? `${CURSOR_SESSION_PREFIX}${cursorBareSessionId(rawId)}` : undefined;
|
|
94
|
+
const givenTranscript = typeof payload.transcript_path === 'string' && payload.transcript_path.length > 0
|
|
95
|
+
? payload.transcript_path
|
|
96
|
+
: null;
|
|
97
|
+
const transcriptPath = givenTranscript ?? deriveCursorTranscriptPath(cwd, prefixed, { home });
|
|
98
|
+
return {
|
|
99
|
+
...payload,
|
|
100
|
+
session_id: prefixed,
|
|
101
|
+
cwd,
|
|
102
|
+
source: typeof payload.source === 'string' && payload.source.length > 0
|
|
103
|
+
? payload.source
|
|
104
|
+
: 'startup',
|
|
105
|
+
prompt: payload.prompt,
|
|
106
|
+
hook_event_name: payload.hook_event_name,
|
|
107
|
+
transcript_path: transcriptPath,
|
|
108
|
+
last_assistant_message: payload.last_assistant_message ?? payload.text,
|
|
109
|
+
is_background_agent: payload.is_background_agent === true,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const cursorHostAdapter = Object.freeze({
|
|
114
|
+
host: CURSOR_HOST,
|
|
115
|
+
matchesSessionId: isCursorSessionId,
|
|
116
|
+
waitsForStopTranscriptFlush: false,
|
|
117
|
+
consumesHandoffAtSessionStart: true,
|
|
118
|
+
deliverHandoffInjection({ text, stdout = process.stdout }) {
|
|
119
|
+
stdout.write(`${JSON.stringify({ additional_context: text })}\n`);
|
|
120
|
+
return { delivered: true };
|
|
121
|
+
},
|
|
122
|
+
resolveCommandPrompt({ prompt }) {
|
|
123
|
+
return prompt;
|
|
124
|
+
},
|
|
125
|
+
afterBatonWrite() {
|
|
126
|
+
return { launched: false };
|
|
127
|
+
},
|
|
128
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
deriveCursorTranscriptPath,
|
|
7
|
+
encodeCursorProjectDir,
|
|
8
|
+
isCursorEnvelope,
|
|
9
|
+
normalizeHookPayload,
|
|
10
|
+
} from './index.mjs';
|
|
11
|
+
|
|
12
|
+
test('isCursorEnvelope detects Cursor events and cursor_version', () => {
|
|
13
|
+
assert.equal(
|
|
14
|
+
isCursorEnvelope({
|
|
15
|
+
hook_event_name: 'sessionStart',
|
|
16
|
+
session_id: '7face712-ef58-40d7-b71e-b71091dfee5c',
|
|
17
|
+
workspace_roots: ['/Users/kite/Developer/dotagents'],
|
|
18
|
+
}),
|
|
19
|
+
true,
|
|
20
|
+
);
|
|
21
|
+
assert.equal(
|
|
22
|
+
isCursorEnvelope({
|
|
23
|
+
cursor_version: '1.7.2',
|
|
24
|
+
conversation_id: '7face712-ef58-40d7-b71e-b71091dfee5c',
|
|
25
|
+
}),
|
|
26
|
+
true,
|
|
27
|
+
);
|
|
28
|
+
assert.equal(
|
|
29
|
+
isCursorEnvelope({
|
|
30
|
+
session_id: 'claude-session',
|
|
31
|
+
hook_event_name: 'SessionStart',
|
|
32
|
+
}),
|
|
33
|
+
false,
|
|
34
|
+
);
|
|
35
|
+
assert.equal(
|
|
36
|
+
isCursorEnvelope({
|
|
37
|
+
sessionId: '01a00aa2-dead-beef',
|
|
38
|
+
hookEventName: 'session_start',
|
|
39
|
+
}),
|
|
40
|
+
false,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('normalizeHookPayload prefixes cursor: and derives agent-transcripts path', () => {
|
|
45
|
+
const home = '/tmp/tl-home';
|
|
46
|
+
const cwd = '/Users/kite/Developer/dotagents';
|
|
47
|
+
const payload = normalizeHookPayload(
|
|
48
|
+
{
|
|
49
|
+
conversation_id: '7face712-ef58-40d7-b71e-b71091dfee5c',
|
|
50
|
+
hook_event_name: 'beforeSubmitPrompt',
|
|
51
|
+
workspace_roots: [cwd],
|
|
52
|
+
prompt: '/tl',
|
|
53
|
+
cursor_version: '1.7.2',
|
|
54
|
+
},
|
|
55
|
+
{ home },
|
|
56
|
+
);
|
|
57
|
+
assert.equal(payload.session_id, 'cursor:7face712-ef58-40d7-b71e-b71091dfee5c');
|
|
58
|
+
assert.equal(payload.cwd, cwd);
|
|
59
|
+
assert.equal(payload.prompt, '/tl');
|
|
60
|
+
assert.equal(
|
|
61
|
+
payload.transcript_path,
|
|
62
|
+
join(
|
|
63
|
+
home,
|
|
64
|
+
'.cursor',
|
|
65
|
+
'projects',
|
|
66
|
+
'Users-kite-Developer-dotagents',
|
|
67
|
+
'agent-transcripts',
|
|
68
|
+
'7face712-ef58-40d7-b71e-b71091dfee5c',
|
|
69
|
+
'7face712-ef58-40d7-b71e-b71091dfee5c.jsonl',
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
assert.equal(
|
|
73
|
+
deriveCursorTranscriptPath(cwd, 'cursor:7face712-ef58-40d7-b71e-b71091dfee5c', { home }),
|
|
74
|
+
payload.transcript_path,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('normalizeHookPayload strips bc- prefix and keeps payload transcript_path', () => {
|
|
79
|
+
const home = '/tmp/tl-home';
|
|
80
|
+
const given = '/tmp/given.jsonl';
|
|
81
|
+
const payload = normalizeHookPayload(
|
|
82
|
+
{
|
|
83
|
+
conversation_id: 'bc-7face712-ef58-40d7-b71e-b71091dfee5c',
|
|
84
|
+
hook_event_name: 'stop',
|
|
85
|
+
cwd: '/Users/kite/Developer/dotagents',
|
|
86
|
+
transcript_path: given,
|
|
87
|
+
status: 'completed',
|
|
88
|
+
},
|
|
89
|
+
{ home },
|
|
90
|
+
);
|
|
91
|
+
assert.equal(payload.session_id, 'cursor:7face712-ef58-40d7-b71e-b71091dfee5c');
|
|
92
|
+
assert.equal(payload.transcript_path, given);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('encodeCursorProjectDir matches ~/.cursor/projects slug', () => {
|
|
96
|
+
assert.equal(
|
|
97
|
+
encodeCursorProjectDir('/Users/kite/Developer/dotagents'),
|
|
98
|
+
'Users-kite-Developer-dotagents',
|
|
99
|
+
);
|
|
100
|
+
assert.equal(
|
|
101
|
+
encodeCursorProjectDir(String.raw`C:\Users\kite_\Developer\dotagents`),
|
|
102
|
+
'C-Users-kite_-Developer-dotagents',
|
|
103
|
+
);
|
|
104
|
+
});
|
package/src/hosts/grok.mjs
CHANGED
|
@@ -88,6 +88,7 @@ export const grokHostAdapter = Object.freeze({
|
|
|
88
88
|
return lastUserPromptText(payload.transcript_path);
|
|
89
89
|
},
|
|
90
90
|
// Grok `/tl` 成功後だけ、源セッションの project_path で後継の対話 grok を立てる。
|
|
91
|
+
consumesHandoffAtSessionStart: false,
|
|
91
92
|
afterBatonWrite({ trigger, sessionId, cwd, continueRun = runGrokContinue }) {
|
|
92
93
|
if (trigger !== 'tl') return { launched: false };
|
|
93
94
|
const code = continueRun(['--session', sessionId], { cwd });
|
package/src/hosts/identity.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* hosts/identity.mjs — harness (hook host) 識別の唯一の正本
|
|
3
3
|
*
|
|
4
|
-
* Throughline は Claude / Codex / Grok の
|
|
4
|
+
* Throughline は Claude / Codex / Grok / Cursor の hook host を同じ SQLite に保存する。
|
|
5
5
|
* host の見分け方は session_id prefix だけであり、その prefix 定義と判定関数を
|
|
6
6
|
* このファイルに一元化する。共有コード (hook 入口・monitor・state-file・
|
|
7
7
|
* predecessor 検索) は文字列リテラルを直接持たず、必ずここを参照する。
|
|
@@ -13,9 +13,11 @@
|
|
|
13
13
|
export const CLAUDE_HOST = 'claude';
|
|
14
14
|
export const CODEX_HOST = 'codex';
|
|
15
15
|
export const GROK_HOST = 'grok';
|
|
16
|
+
export const CURSOR_HOST = 'cursor';
|
|
16
17
|
|
|
17
18
|
export const CODEX_SESSION_PREFIX = 'codex:';
|
|
18
19
|
export const GROK_SESSION_PREFIX = 'grok:';
|
|
20
|
+
export const CURSOR_SESSION_PREFIX = 'cursor:';
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* Claude session は prefix を持たない。auto handoff の前任検索 (Claude 専用) は
|
|
@@ -24,6 +26,7 @@ export const GROK_SESSION_PREFIX = 'grok:';
|
|
|
24
26
|
export const NON_CLAUDE_SESSION_PREFIXES = Object.freeze([
|
|
25
27
|
CODEX_SESSION_PREFIX,
|
|
26
28
|
GROK_SESSION_PREFIX,
|
|
29
|
+
CURSOR_SESSION_PREFIX,
|
|
27
30
|
]);
|
|
28
31
|
|
|
29
32
|
/**
|
|
@@ -40,14 +43,19 @@ export function isGrokSessionId(sessionId) {
|
|
|
40
43
|
return typeof sessionId === 'string' && sessionId.startsWith(GROK_SESSION_PREFIX);
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
export function isCursorSessionId(sessionId) {
|
|
47
|
+
return typeof sessionId === 'string' && sessionId.startsWith(CURSOR_SESSION_PREFIX);
|
|
48
|
+
}
|
|
49
|
+
|
|
43
50
|
/**
|
|
44
51
|
* session_id から hook host を返す。prefix なしは Claude。
|
|
45
52
|
* @param {string} sessionId
|
|
46
|
-
* @returns {'claude'|'codex'|'grok'}
|
|
53
|
+
* @returns {'claude'|'codex'|'grok'|'cursor'}
|
|
47
54
|
*/
|
|
48
55
|
export function hostOfSessionId(sessionId) {
|
|
49
56
|
if (isCodexSessionId(sessionId)) return CODEX_HOST;
|
|
50
57
|
if (isGrokSessionId(sessionId)) return GROK_HOST;
|
|
58
|
+
if (isCursorSessionId(sessionId)) return CURSOR_HOST;
|
|
51
59
|
return CLAUDE_HOST;
|
|
52
60
|
}
|
|
53
61
|
|
|
@@ -69,3 +77,12 @@ export function grokBareSessionId(sessionId) {
|
|
|
69
77
|
? sessionId.slice(GROK_SESSION_PREFIX.length)
|
|
70
78
|
: sessionId;
|
|
71
79
|
}
|
|
80
|
+
|
|
81
|
+
export function cursorBareSessionId(sessionId) {
|
|
82
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return null;
|
|
83
|
+
let bare = sessionId.startsWith(CURSOR_SESSION_PREFIX)
|
|
84
|
+
? sessionId.slice(CURSOR_SESSION_PREFIX.length)
|
|
85
|
+
: sessionId;
|
|
86
|
+
if (bare.startsWith('bc-')) bare = bare.slice(3);
|
|
87
|
+
return bare.length > 0 ? bare : null;
|
|
88
|
+
}
|
|
@@ -3,13 +3,16 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
CODEX_SESSION_PREFIX,
|
|
6
|
+
CURSOR_SESSION_PREFIX,
|
|
6
7
|
GROK_SESSION_PREFIX,
|
|
7
8
|
NON_CLAUDE_SESSION_PREFIXES,
|
|
8
9
|
buildCodexThroughlineSessionId,
|
|
9
10
|
codexSessionIdToThreadId,
|
|
11
|
+
cursorBareSessionId,
|
|
10
12
|
grokBareSessionId,
|
|
11
13
|
hostOfSessionId,
|
|
12
14
|
isCodexSessionId,
|
|
15
|
+
isCursorSessionId,
|
|
13
16
|
isGrokSessionId,
|
|
14
17
|
} from './identity.mjs';
|
|
15
18
|
import { hostAdapterForSessionId } from './index.mjs';
|
|
@@ -17,12 +20,14 @@ import { hostAdapterForSessionId } from './index.mjs';
|
|
|
17
20
|
test('session prefixes are the canonical wire values', () => {
|
|
18
21
|
assert.equal(CODEX_SESSION_PREFIX, 'codex:');
|
|
19
22
|
assert.equal(GROK_SESSION_PREFIX, 'grok:');
|
|
20
|
-
assert.
|
|
23
|
+
assert.equal(CURSOR_SESSION_PREFIX, 'cursor:');
|
|
24
|
+
assert.deepEqual([...NON_CLAUDE_SESSION_PREFIXES], ['codex:', 'grok:', 'cursor:']);
|
|
21
25
|
});
|
|
22
26
|
|
|
23
27
|
test('hostOfSessionId maps prefixes to hosts and defaults to claude', () => {
|
|
24
28
|
assert.equal(hostOfSessionId('codex:0199aa'), 'codex');
|
|
25
29
|
assert.equal(hostOfSessionId('grok:0199aa'), 'grok');
|
|
30
|
+
assert.equal(hostOfSessionId('cursor:0199aa'), 'cursor');
|
|
26
31
|
assert.equal(hostOfSessionId('0199aa-claude'), 'claude');
|
|
27
32
|
assert.equal(hostOfSessionId(undefined), 'claude');
|
|
28
33
|
});
|
|
@@ -41,25 +46,43 @@ test('grokBareSessionId strips only the grok prefix', () => {
|
|
|
41
46
|
assert.equal(grokBareSessionId(''), null);
|
|
42
47
|
});
|
|
43
48
|
|
|
44
|
-
test('
|
|
49
|
+
test('cursorBareSessionId strips cursor: and optional bc- prefix', () => {
|
|
50
|
+
assert.equal(cursorBareSessionId('cursor:abc'), 'abc');
|
|
51
|
+
assert.equal(cursorBareSessionId('bc-abc'), 'abc');
|
|
52
|
+
assert.equal(cursorBareSessionId('cursor:bc-abc'), 'abc');
|
|
53
|
+
assert.equal(cursorBareSessionId('abc'), 'abc');
|
|
54
|
+
assert.equal(cursorBareSessionId(''), null);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('isCodexSessionId / isGrokSessionId / isCursorSessionId reject non-strings', () => {
|
|
45
58
|
assert.equal(isCodexSessionId(null), false);
|
|
46
59
|
assert.equal(isGrokSessionId(undefined), false);
|
|
60
|
+
assert.equal(isCursorSessionId(undefined), false);
|
|
47
61
|
});
|
|
48
62
|
|
|
49
63
|
test('every host adapter satisfies the shared hook contract', () => {
|
|
50
|
-
for (const sessionId of ['claude-session', 'codex:t1', 'grok:t1']) {
|
|
64
|
+
for (const sessionId of ['claude-session', 'codex:t1', 'grok:t1', 'cursor:t1']) {
|
|
51
65
|
const adapter = hostAdapterForSessionId(sessionId);
|
|
52
66
|
assert.equal(typeof adapter.host, 'string');
|
|
53
67
|
assert.equal(adapter.matchesSessionId(sessionId), true);
|
|
54
68
|
assert.equal(typeof adapter.waitsForStopTranscriptFlush, 'boolean');
|
|
69
|
+
assert.equal(typeof adapter.consumesHandoffAtSessionStart, 'boolean');
|
|
55
70
|
assert.equal(typeof adapter.deliverHandoffInjection, 'function');
|
|
56
71
|
assert.equal(typeof adapter.resolveCommandPrompt, 'function');
|
|
57
72
|
assert.equal(typeof adapter.afterBatonWrite, 'function');
|
|
58
73
|
}
|
|
59
74
|
});
|
|
60
75
|
|
|
61
|
-
test('flush barrier applies to claude and codex sessions, not grok', () => {
|
|
76
|
+
test('flush barrier applies to claude and codex sessions, not grok or cursor', () => {
|
|
62
77
|
assert.equal(hostAdapterForSessionId('claude-session').waitsForStopTranscriptFlush, true);
|
|
63
78
|
assert.equal(hostAdapterForSessionId('codex:t1').waitsForStopTranscriptFlush, true);
|
|
64
79
|
assert.equal(hostAdapterForSessionId('grok:t1').waitsForStopTranscriptFlush, false);
|
|
80
|
+
assert.equal(hostAdapterForSessionId('cursor:t1').waitsForStopTranscriptFlush, false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('only Cursor consumes handoff at sessionStart', () => {
|
|
84
|
+
assert.equal(hostAdapterForSessionId('claude-session').consumesHandoffAtSessionStart, false);
|
|
85
|
+
assert.equal(hostAdapterForSessionId('codex:t1').consumesHandoffAtSessionStart, false);
|
|
86
|
+
assert.equal(hostAdapterForSessionId('grok:t1').consumesHandoffAtSessionStart, false);
|
|
87
|
+
assert.equal(hostAdapterForSessionId('cursor:t1').consumesHandoffAtSessionStart, true);
|
|
65
88
|
});
|
package/src/hosts/index.mjs
CHANGED
|
@@ -5,26 +5,35 @@
|
|
|
5
5
|
* `normalizeHookPayload` と `hostAdapterForSessionId` だけを使い、
|
|
6
6
|
* ベンダー分岐を直接書かない。
|
|
7
7
|
*/
|
|
8
|
-
import { hostOfSessionId, CLAUDE_HOST, CODEX_HOST, GROK_HOST } from './identity.mjs';
|
|
8
|
+
import { hostOfSessionId, CLAUDE_HOST, CODEX_HOST, GROK_HOST, CURSOR_HOST } from './identity.mjs';
|
|
9
9
|
import { claudeHostAdapter } from './claude.mjs';
|
|
10
10
|
import { codexHostAdapter } from './codex.mjs';
|
|
11
11
|
import { grokHostAdapter, isGrokEnvelope, normalizeGrokHookPayload } from './grok.mjs';
|
|
12
|
+
import { cursorHostAdapter, isCursorEnvelope, normalizeCursorHookPayload } from './cursor.mjs';
|
|
12
13
|
|
|
13
14
|
export * from './identity.mjs';
|
|
14
15
|
export { isGrokEnvelope, deriveGrokChatHistoryPath, normalizeGrokHookPayload } from './grok.mjs';
|
|
16
|
+
export {
|
|
17
|
+
isCursorEnvelope,
|
|
18
|
+
encodeCursorProjectDir,
|
|
19
|
+
deriveCursorTranscriptPath,
|
|
20
|
+
normalizeCursorHookPayload,
|
|
21
|
+
} from './cursor.mjs';
|
|
15
22
|
|
|
16
23
|
const ADAPTERS = Object.freeze({
|
|
17
24
|
[CLAUDE_HOST]: claudeHostAdapter,
|
|
18
25
|
[CODEX_HOST]: codexHostAdapter,
|
|
19
26
|
[GROK_HOST]: grokHostAdapter,
|
|
27
|
+
[CURSOR_HOST]: cursorHostAdapter,
|
|
20
28
|
});
|
|
21
29
|
|
|
22
30
|
/**
|
|
23
31
|
* hook stdin payload を Claude snake_case 契約へ正規化する。
|
|
24
|
-
*
|
|
32
|
+
* Grok は camelCase wire、Cursor は hook_event_name が sessionStart 等。
|
|
25
33
|
*/
|
|
26
34
|
export function normalizeHookPayload(payload, options = {}) {
|
|
27
35
|
if (isGrokEnvelope(payload)) return normalizeGrokHookPayload(payload, options);
|
|
36
|
+
if (isCursorEnvelope(payload)) return normalizeCursorHookPayload(payload, options);
|
|
28
37
|
return payload;
|
|
29
38
|
}
|
|
30
39
|
|
package/src/session-start.mjs
CHANGED
|
@@ -32,7 +32,8 @@ 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 { NON_CLAUDE_SESSION_PREFIXES, normalizeHookPayload } from './hosts/index.mjs';
|
|
35
|
+
import { hostAdapterForSessionId, NON_CLAUDE_SESSION_PREFIXES, normalizeHookPayload } from './hosts/index.mjs';
|
|
36
|
+
import { executeFirstPromptHandoff } from './handoff-executor.mjs';
|
|
36
37
|
|
|
37
38
|
const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
|
|
38
39
|
|
|
@@ -161,6 +162,28 @@ export async function run() {
|
|
|
161
162
|
pending_registered: true,
|
|
162
163
|
});
|
|
163
164
|
|
|
165
|
+
// Cursor は beforeSubmitPrompt が additional_context を持たない。
|
|
166
|
+
// 新規 composer 会話の sessionStart が公式の注入口なので、ここで第二相を実行する。
|
|
167
|
+
// Claude の幽霊 SessionStart 問題はこの host では sessionStart = 会話作成のため適用しない。
|
|
168
|
+
// クラウド / background agent は baton を奪わない。
|
|
169
|
+
const hostAdapter = hostAdapterForSessionId(session_id);
|
|
170
|
+
if (hostAdapter.consumesHandoffAtSessionStart === true && payload.is_background_agent !== true) {
|
|
171
|
+
const handoff = executeFirstPromptHandoff(db, {
|
|
172
|
+
sessionId: session_id,
|
|
173
|
+
projectPath,
|
|
174
|
+
now,
|
|
175
|
+
});
|
|
176
|
+
if (handoff.attempted && handoff.injectionText) {
|
|
177
|
+
const delivery = hostAdapter.deliverHandoffInjection({
|
|
178
|
+
payload,
|
|
179
|
+
text: handoff.injectionText,
|
|
180
|
+
});
|
|
181
|
+
if (!delivery.delivered) {
|
|
182
|
+
process.stderr.write(`[session-start] ${delivery.reason}\n`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
164
187
|
process.exit(0);
|
|
165
188
|
}
|
|
166
189
|
|
|
@@ -0,0 +1,43 @@
|
|
|
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, sliceCurrentTurnEntries, readRawEntries } from './transcript-reader.mjs';
|
|
8
|
+
|
|
9
|
+
test('readTranscript accepts Cursor agent-transcripts role-first jsonl', () => {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-cursor-transcript-'));
|
|
11
|
+
const path = join(dir, '7face712.jsonl');
|
|
12
|
+
writeFileSync(
|
|
13
|
+
path,
|
|
14
|
+
[
|
|
15
|
+
JSON.stringify({
|
|
16
|
+
role: 'user',
|
|
17
|
+
message: { content: [{ type: 'text', text: 'hello cursor' }] },
|
|
18
|
+
}),
|
|
19
|
+
JSON.stringify({
|
|
20
|
+
role: 'assistant',
|
|
21
|
+
message: { content: [{ type: 'text', text: 'captured reply' }] },
|
|
22
|
+
}),
|
|
23
|
+
].join('\n'),
|
|
24
|
+
);
|
|
25
|
+
try {
|
|
26
|
+
const turns = readTranscript(path);
|
|
27
|
+
assert.deepEqual(
|
|
28
|
+
turns.map((t) => ({ role: t.role, content: t.content })),
|
|
29
|
+
[
|
|
30
|
+
{ role: 'user', content: 'hello cursor' },
|
|
31
|
+
{ role: 'assistant', content: 'captured reply' },
|
|
32
|
+
],
|
|
33
|
+
);
|
|
34
|
+
const groups = getLogicalTurnGroups(path);
|
|
35
|
+
assert.equal(groups.length, 1);
|
|
36
|
+
assert.equal(groups[0].user.content, 'hello cursor');
|
|
37
|
+
assert.equal(groups[0].representative.content, 'captured reply');
|
|
38
|
+
const sliced = sliceCurrentTurnEntries(readRawEntries(path));
|
|
39
|
+
assert.equal(sliced.length, 2);
|
|
40
|
+
} finally {
|
|
41
|
+
rmSync(dir, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
import { readFileSync, existsSync } from 'fs';
|
|
12
12
|
import { DETAIL_KIND } from './constants.mjs';
|
|
13
13
|
|
|
14
|
+
function entryKind(entry) {
|
|
15
|
+
if (typeof entry?.type === 'string' && entry.type.length > 0) return entry.type;
|
|
16
|
+
if (typeof entry?.role === 'string' && entry.role.length > 0) return entry.role;
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
14
20
|
/**
|
|
15
21
|
* content 配列からテキスト部分だけを結合する。
|
|
16
22
|
* thinking ブロックは除外。
|
|
@@ -51,8 +57,9 @@ export function readTranscript(transcriptPath) {
|
|
|
51
57
|
continue;
|
|
52
58
|
}
|
|
53
59
|
|
|
54
|
-
// user / assistant
|
|
55
|
-
|
|
60
|
+
// user / assistant エントリのみ対象。Claude は type、Cursor jsonl は role。
|
|
61
|
+
const kind = entryKind(entry);
|
|
62
|
+
if (kind !== 'user' && kind !== 'assistant') continue;
|
|
56
63
|
|
|
57
64
|
// subagent の sidechain エントリは主会話ではないので除外。現行 CC は主 transcript に
|
|
58
65
|
// 書かない(400 transcript 実測ゼロ件)が、将来変更への安価な防御 (docs/12 B-1)
|
|
@@ -60,7 +67,7 @@ export function readTranscript(transcriptPath) {
|
|
|
60
67
|
|
|
61
68
|
const msg = entry.message;
|
|
62
69
|
const grokContent = entry.content;
|
|
63
|
-
const role = msg?.role ??
|
|
70
|
+
const role = msg?.role ?? kind;
|
|
64
71
|
const rawContent = msg?.content ?? grokContent;
|
|
65
72
|
if (!role || rawContent == null) continue;
|
|
66
73
|
|
|
@@ -258,7 +265,7 @@ export function sliceCurrentTurnEntries(entries) {
|
|
|
258
265
|
let lastAssistantTextIdx = -1;
|
|
259
266
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
260
267
|
const e = entries[i];
|
|
261
|
-
if (e
|
|
268
|
+
if (entryKind(e) !== 'assistant') continue;
|
|
262
269
|
const blocks = e.message?.content;
|
|
263
270
|
if (!Array.isArray(blocks)) continue;
|
|
264
271
|
if (blocks.some((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.length > 0)) {
|
|
@@ -272,7 +279,7 @@ export function sliceCurrentTurnEntries(entries) {
|
|
|
272
279
|
let userTextIdx = -1;
|
|
273
280
|
for (let i = lastAssistantTextIdx - 1; i >= 0; i--) {
|
|
274
281
|
const e = entries[i];
|
|
275
|
-
if (e
|
|
282
|
+
if (entryKind(e) !== 'user') continue;
|
|
276
283
|
const blocks = e.message?.content;
|
|
277
284
|
if (Array.isArray(blocks)) {
|
|
278
285
|
if (blocks.some((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.length > 0)) {
|
|
@@ -319,7 +326,7 @@ export function extractDetailBlocks(turnEntries) {
|
|
|
319
326
|
const toolNameById = new Map();
|
|
320
327
|
|
|
321
328
|
for (const e of turnEntries) {
|
|
322
|
-
if (e
|
|
329
|
+
if (entryKind(e) === 'assistant') {
|
|
323
330
|
const blocks = e.message?.content;
|
|
324
331
|
if (!Array.isArray(blocks)) continue;
|
|
325
332
|
for (let i = 0; i < blocks.length; i++) {
|
|
@@ -355,7 +362,7 @@ export function extractDetailBlocks(turnEntries) {
|
|
|
355
362
|
}
|
|
356
363
|
// text は扱わない
|
|
357
364
|
}
|
|
358
|
-
} else if (e
|
|
365
|
+
} else if (entryKind(e) === 'user') {
|
|
359
366
|
const blocks = e.message?.content;
|
|
360
367
|
if (!Array.isArray(blocks)) continue;
|
|
361
368
|
for (const b of blocks) {
|