throughline 0.16.21 → 0.16.23

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,54 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.16.23] — 2026-10-11
14
+
15
+ ### 修正
16
+
17
+ - **`install`と`self-update`が、Codexの信用済みのhookを「変わったhook」にしていた**([ADR 0052](docs/adr/0052-install-keeps-registered-codex-hook-command.md))。Codexのhookの命令は`<node> <throughline.mjs> codex-hook <event>`で、`<node>`の表記は入れる側のPATHで決まる(PATHの上に今のnodeと同じ実体があればその表記、無ければ`process.execPath`)。`install`はhookを毎回作り直すので、前と違うPATHで入れると、同じnodeを指したまま文字列だけが変わった。Codexはhookの信用を命令の文字列で決めるので、Throughlineのhookが4つとも`modified`になり、新しい会話が起動の所で「Hooks need review」を出して、人が選ぶまで止まった。
18
+ - Linuxの実機で2026-10-11に起きた。09:20に`~/.local/bin`をPATHの先頭に付けて`self-update`を流し、`~/.local/lib/node-v24/bin/node`が、同じ実体を指すsymlinkの`~/.local/bin/node`に変わった。15:05に、常駐のprocessが立てるCodexの会話がそこで止まっていると知らせを受けた。
19
+ - 直した後は、登録済みの命令が今のnodeと`throughline.mjs`の実体(realpath)を指している時、その文字列のまま書く。`auto-handoff enable`が置く`PreCompact`も同じ。登録が無い時、旧い形の時、nodeかscriptの実体が違う時は、今までどおり書き直す。
20
+ - nodeの実体が変わった時(別の版の場所へ入れ直した時など)は命令が変わり、Codexは確認の画面を出す。これは変えていない。
21
+
22
+ ### 対応の範囲
23
+
24
+ | | macOS | Linux | Windows |
25
+ |---|---|---|---|
26
+ | Codex | 直した。単体試験と、実機(Codex Desktop同梱の0.162.0-alpha.17.2)。仮のHOMEとCODEX_HOMEでhookを信用し、PATHだけ変えて入れ直すと、入っていた0.16.22は4つとも`modified`、この版は4つとも`trusted`で`hooks.json`は1バイトも変わらない | 直した。単体試験と、実機(codex-cli 0.162.1)で同じ確認 | 直した。単体試験(表記の違いは大文字と小文字で作る)と、実機(Codex Desktop同梱の0.162.0-alpha.17.2)で同じ確認 |
27
+ | Claude Code | 変更なし(hookの命令は`throughline <subcommand>`で、nodeの場所が入らない) | 同じ | 同じ |
28
+ | Grok | 変更なし。命令には同じ決め方でnodeの表記が入り、入れる側のPATHで変わる。Grokがhookの変更で確認を出すかは確かめていない | 同じ | 同じ |
29
+ | Cursor | 変更なし。Grokと同じ | 同じ | 同じ |
30
+
31
+ ### 確かめていない事
32
+
33
+ - 本物の`~/.codex`で、信用済みのhookを持つ端末へこの版を違うPATHから入れる所。確認は、仮のHOMEとCODEX_HOMEで、本物のCodexのApp Server(`hooks/list`)に信用の状態を聞く形で行った。
34
+ - `doctor --codex`の「trusted」は、`trusted_hash`が記録されているかだけを見ている。Codexが今の命令と比べた結果は見ていないので、`modified`でも「trusted」と出る。この版では変えていない。
35
+
36
+ ## [0.16.22] — 2026-10-11
37
+
38
+ ### 変更
39
+
40
+ - **Claude Codeの自動継続で、後継をClaude Desktopへ移す処理を、既定で無効にした**([ADR 0051](docs/adr/0051-claude-successor-desktop-move-is-opt-in.md))。後継は裏の会話のまま残り、リモートコントロール(claude.ai/codeとClaudeのアプリ)、`claude agents`、`claude attach <id>`で見られる。Claude Desktopの一覧には出ない。
41
+ - 今までは、Claude Desktopから始まった会話の後継がターンを終えるたびに、`claude stop`で止めて`claude --desktop --resume`で開き直していた。Windowsの実機で2026-10-11に2回(10:31、10:39)、オーナーがスマホから話している最中の会話が消えた。止めると、起動時に付けたリモートコントロールの接続が切れ、移った先のDesktopの会話ではリモートコントロールが有効になっていなかった。1回目は、会話が裏で走らせていた命令(`run_in_background`)も一緒に止まった。作業ツリーで作業していた会話は、開き直した後のcwdが元のrepositoryになった。
42
+ - 0.16.20(保留の文を持つ後継も移す)で、それまで移らなかった会話が移るようになり、この2回が起きた。保留の文が無い会話では、0.16.8(macOS)と0.16.9(Windows)から同じ事が起きていた。
43
+ - 環境変数`THROUGHLINE_AUTO_HANDOFF_OPEN`を`origin`にすると、今までの既定(Desktopから始まった会話の後継だけ移す)で動く。`desktop`は出どころに関係なく移す。未設定とそれ以外の値は移さない。
44
+ - 0.16.21までの版が「移す」と記録した引き継ぎの後継も、有効にしていなければ移さない。
45
+ - 旧い会話を止めた理由の文を直した。移さない時は「後継の会話は裏で作業を続けます」と書き、リモートコントロール付きの時はその事も書く。
46
+
47
+ ### 対応の範囲
48
+
49
+ | | macOS | Linux | Windows |
50
+ |---|---|---|---|
51
+ | Claude Code | 止めた。単体試験と、実機(Claude Code 2.1.289)。試験用の場所の記録で、入っていた0.16.20は移すprocessを起こし、この版は未設定では起こさず(記録も変えない)、`origin`では起こす事 | 元から移す処理が動かない(Claude Codeの`--desktop`が無い)。単体試験と、実機(2.1.296)で、記録を探す処理が同じ結果になる事 | 止めた。単体試験と、実機(Claude Code 2.1.296)で同じ確認。今日消えた会話を移した本物の記録の写しでも、未設定では起こさない事 |
52
+ | Codex | 変更なし(Desktopへ移す処理が無い。後継は最初からCodex Desktopのタスク) | 同じ | 同じ |
53
+ | Grok | 変更なし(自動継続が無い) | 同じ | 同じ |
54
+ | Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
55
+
56
+ ### 確かめていない事
57
+
58
+ - この版での本物の引き継ぎ。後継がターンを終えても裏の会話のまま残り、スマホから見え続ける所。
59
+ - 0.16.21までに移した会話(Desktopの会話になっている)へ、リモートコントロールを付け直す方法。
60
+
13
61
  ## [0.16.21] — 2026-10-11
14
62
 
15
63
  ### 修正
@@ -2353,7 +2401,9 @@ two attempts, instrument first instead of patching again.
2353
2401
 
2354
2402
  ---
2355
2403
 
2356
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.21...HEAD
2404
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.23...HEAD
2405
+ [0.16.23]: https://github.com/kitepon/Throughline/compare/v0.16.22...v0.16.23
2406
+ [0.16.22]: https://github.com/kitepon/Throughline/compare/v0.16.21...v0.16.22
2357
2407
  [0.16.21]: https://github.com/kitepon/Throughline/compare/v0.16.20...v0.16.21
2358
2408
  [0.16.20]: https://github.com/kitepon/Throughline/compare/v0.16.19...v0.16.20
2359
2409
  [0.16.19]: https://github.com/kitepon/Throughline/compare/v0.16.18...v0.16.19
package/README.md CHANGED
@@ -68,7 +68,7 @@ throughline auto-handoff disable --host claude
68
68
  3. 同じprojectに、継続の指示を最初の指示として付けた新しいClaudeの会話を裏で立てます(`claude --bg`)。会話の名前は、前任の会話の題から`<project名>|<作業の概要>(自動引き継ぎ)`にします。モデル・推論強度・権限は旧い会話から引き継ぎます。
69
69
  4. 最初の指示を受け取った時に、止めた時点の依頼、そのターンでここまでにしたこと(発言と道具の呼び出し)、実行されなかった道具、直近の会話の原文を注入します(上限9,500字)。止めたターンの道具の入出力は、後継が`throughline detail`で取り出せます。
70
70
 
71
- 続きは裏の会話で動きます。`claude agents`の一覧と`claude attach <id>`で見られます。Claude Desktopの画面から始まった会話の後継は、そのターンの作業を終えた時に、Claude Desktopへ移して開きます(macOSは0.16.8から、Windowsは0.16.9から。LinuxのClaude Codeには`--desktop`が無いので開きません。[ADR 0043](docs/adr/0043-claude-successor-opens-in-desktop.md))。Claude Codeは裏で動いている会話をDesktopへ移さないので、作業の間はDesktopには出ません。続けて引き継いだ時は、最後に作業を終えた後継だけを開きます。ほかの会話から`SendMessage`で届いた文をClaude Codeが承認待ちで保留している後継も、ターンが終わっていれば開きます。その時に保留されている文は、会話へ届かずに消えます(0.16.20から。[ADR 0050](docs/adr/0050-claude-successor-with-held-peer-message-moves-to-desktop.md))。端末から始めた会話の後継は開きません。手で開く時は、`claude stop <id>`で裏の実行を停止し、`claude --desktop --resume <session-id>`を実行します。`session-id`は`claude agents --json --all`の`sessionId`です。環境変数`THROUGHLINE_AUTO_HANDOFF_OPEN`を`desktop`にすると出どころに関係なく開き、`off`にすると開きません。手動`/compact`、subagentの中の圧縮、`claude -p`などscriptから起動した会話は対象に含めません。 Claude Desktopから始まった会話の後継は、リモートコントロール付きで立てるので、作業の最中もclaude.ai/codeとClaudeのアプリから見られます(0.16.16から。[ADR 0048](docs/adr/0048-claude-desktop-origin-successor-has-remote-control.md))。
71
+ 続きは裏の会話で動きます。`claude agents`の一覧と`claude attach <id>`で見られます。後継をClaude Desktopへ移す処理は、0.16.22から既定で無効です([ADR 0051](docs/adr/0051-claude-successor-desktop-move-is-opt-in.md))。移す時は裏の会話を`claude stop`で止めて開き直すので、リモートコントロールの接続が切れ、裏で走らせていた命令も止まり、話している最中の会話が消えます。環境変数`THROUGHLINE_AUTO_HANDOFF_OPEN`を`origin`にすると、Claude Desktopの画面から始まった会話の後継だけ、そのターンの作業を終えた時にClaude Desktopへ移して開きます(macOSとWindows。LinuxのClaude Codeには`--desktop`が無いので開きません。[ADR 0043](docs/adr/0043-claude-successor-opens-in-desktop.md))。`desktop`にすると出どころに関係なく開きます。移す処理が有効な時は、ほかの会話から`SendMessage`で届いた文をClaude Codeが承認待ちで保留している後継も、ターンが終わっていれば開き、保留されている文は会話へ届かずに消えます([ADR 0050](docs/adr/0050-claude-successor-with-held-peer-message-moves-to-desktop.md))。手で開く時は、`claude stop <id>`で裏の実行を停止し、`claude --desktop --resume <session-id>`を実行します。`session-id`は`claude agents --json --all`の`sessionId`です。手動`/compact`、subagentの中の圧縮、`claude -p`などscriptから起動した会話は対象に含めません。 Claude Desktopから始まった会話の後継は、リモートコントロール付きで立てるので、作業の最中もclaude.ai/codeとClaudeのアプリから見られます(0.16.16から。[ADR 0048](docs/adr/0048-claude-desktop-origin-successor-has-remote-control.md))。
72
72
 
73
73
  ```bash
74
74
  throughline auto-handoff status --host claude --operation <handoff-id> --json
@@ -0,0 +1,47 @@
1
+ # ADR 0051: Claude Code の後継を Claude Desktop へ移す処理は、既定で無効にする
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-10-11
5
+ - Supersedes: [ADR 0043](0043-claude-successor-opens-in-desktop.md) の既定(Desktop から始まった会話の後継を、ターンの後に移す)
6
+ - Related: [ADR 0048](0048-claude-desktop-origin-successor-has-remote-control.md)、[ADR 0049](0049-claude-handoff-uses-registered-project.md)、[ADR 0050](0050-claude-successor-with-held-peer-message-moves-to-desktop.md)
7
+
8
+ ## Context
9
+
10
+ Claude Code の自動継続は、後継を裏の会話(`claude --bg`)で立てる。Claude Desktop から始まった会話の後継は、
11
+ リモートコントロール付きで立て(ADR 0048)、ターンを終えた時に `claude stop` で止めて `claude --desktop --resume` で
12
+ Desktop へ開き直していた(ADR 0043)。
13
+
14
+ 2026-10-11、Windows の実機で、オーナーがスマホ(リモートコントロール)から後継と話している最中に、会話が続けて消えた。
15
+
16
+ - 10:31:43 と 10:39:50 ごろの2回。どちらも、後継がターンを終えた(返事を返した)直後に、Throughline が止めて Desktop へ移した。
17
+ - 止めると、Throughline が起動時に付けたリモートコントロールの接続が切れる。移った先の Desktop の会話は、
18
+ リモートコントロールが有効になっていなかった。スマホと claude.ai/code からは、会話が消えたままになる。
19
+ - 1回目の会話は、裏で命令を走らせ(`run_in_background`)、その結果を待つ形でターンを終えていた。止めた時に、その命令も
20
+ 止まった(`task-notification` の `status` は `stopped`)。移った後の会話は、その知らせを受けたまま手すきで止まった。
21
+ - 1回目の会話は作業ツリーで作業していたが、Desktop で開き直した後の cwd は、会話を立てた元の repository だった。
22
+ - ADR 0050(0.16.20、同じ日の 09:16 公開)で、保留の文を持つ会話も移すようにした。それまで移らずに済んでいた会話が
23
+ 移るようになり、この2回が起きた。保留の文が無い会話では、0.16.8(macOS)と 0.16.9(Windows)から同じ事が起きていた。
24
+
25
+ 「ターンが終わった」は「作業が終わった」ではなかった。利用者が話している最中の会話も、裏の命令を待っている会話も、
26
+ ターンは終わる。その会話を止めて別の場所へ開き直すと、利用者は会話を見失う。
27
+
28
+ ## Decision
29
+
30
+ 1. 後継を Claude Desktop へ移す処理は、既定で無効にする。後継は、裏の会話のまま残す。リモートコントロール(ADR 0048)、
31
+ `claude agents`、`claude attach <id>` で見られる。
32
+ 2. 環境変数 `THROUGHLINE_AUTO_HANDOFF_OPEN` が `origin` の時だけ、今までの既定(Desktop から始まった会話の後継を、
33
+ ターンの後に移す)で動く。`desktop` は、出どころに関係なく移す(今までと同じ)。それ以外の値と未設定は、移さない。
34
+ 3. 0.16.21 までの版が「移す」と記録した引き継ぎ(`desktop.wanted: true`、`desktop.state` が空)の後継も、
35
+ 有効にしていなければ移さない。記録は書き換えない。
36
+ 4. リモートコントロール付きで立てる処理(ADR 0048)は変えない。
37
+
38
+ ## Consequences
39
+
40
+ - 利用者が話している会話を、Throughline が止める事は無くなる。裏の命令も、保留の文も、リモートコントロールの接続も残る。
41
+ - Claude Desktop の一覧には、後継が出ない。Desktop の画面だけを見ている利用者は、引き継ぎの後の会話を Desktop では
42
+ 見つけられない。旧い会話の止めた理由に、後継の短い ID(`claude attach <id>`)とリモートコントロールで見られる事を書く。
43
+ - Desktop へ移したい時は、今までどおり手で `claude stop <id>` と `claude --desktop --resume <session-id>` を使える。
44
+ - 移す処理をもう一度既定にするかは、オーナーの裁定を待つ。その時は、少なくとも次を満たす。話している最中の会話を
45
+ 止めない。裏の命令が走っている会話を止めない。移した先でリモートコントロールが続く。作業ツリーの会話は作業ツリーで開く。
46
+ - ADR 0050 の「保留の文を持つ後継も移す」と、ADR 0049 の「受領を確かめられなかった後継も移す」は、移す処理を
47
+ 有効にした時だけ働く。
@@ -0,0 +1,42 @@
1
+ # ADR 0052: install は、今の node を指す登録済みの Codex hook の命令を書き換えない
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-10-11
5
+
6
+ ## Context
7
+
8
+ Codex は、hook を1つずつ信用の対象にする。信用は `config.toml` の `hooks.state."<hooks.json>:<event>:<group>:<hook>".trusted_hash`
9
+ に残り、hook の定義(命令の文字列を含む)から作った hash と比べる。hash が違う hook は「変わった hook」(`modified`)になり、
10
+ 新しい会話は起動の所で「Hooks need review」の画面を出して、人が選ぶまで止まる。
11
+
12
+ Throughline の Codex hook の命令は `<node> <throughline.mjs> codex-hook <event>` で、`<node>` は
13
+ `resolveCodexHookNodePath` が決める。PATH の上に今の node と同じ実体があればその表記、無ければ `process.execPath`。
14
+ Homebrew の node を版ごとの場所(Cellar)で登録しないための決め方だが、同じ端末でも、入れる側の PATH で表記が変わる。
15
+
16
+ 2026-10-11、Linux の実機で、`~/.local/bin` を PATH の先頭に付けて `self-update` を流した。それまで
17
+ `~/.local/lib/node-v24/bin/node` だった表記が、同じ実体を指す symlink の `~/.local/bin/node` に変わった。
18
+ Throughline の hook は4つとも `modified` になり、新しい Codex の会話が確認の画面で止まった。常駐の process が
19
+ 立てる会話も、そこで止まった。
20
+
21
+ `install` は、Throughline の hook を毎回作り直して書いていた。`doctor` と `factory-diagnostics` は、登録済みの命令と
22
+ 期待する命令を実体(realpath)で比べるので、表記が違っても「登録済み」と出す。`install` だけが、文字列で置き換えていた。
23
+
24
+ ## Decision
25
+
26
+ 1. `install` と `auto-handoff enable` は、登録済みの Throughline の Codex hook の命令が、期待する命令と同じ実体
27
+ (node と `throughline.mjs` の realpath、event)を指している時、その文字列のまま書く。
28
+ 2. 登録が無い時、旧い形の時、node か script の実体が違う時(realpath を解決できない時を含む)は、今までどおり
29
+ 期待する命令で書く。
30
+ 3. 表記を「より安定した物」へ寄せるための書き換えはしない。
31
+
32
+ ## Consequences
33
+
34
+ - 入れる側の PATH が変わっても、同じ node を使っている間は、Codex の信用が残る。
35
+ - node の実体が変わった時(別の版の場所へ入れ直した時など)は命令が変わり、Codex は確認の画面を出す。これは避けない。
36
+ 命令が指す実行ファイルが変わった事を、利用者が確かめる場面だから。
37
+ - 版ごとの場所(Homebrew の Cellar など)で登録された命令は、その場所が残っている間はそのまま残る。場所が消えた後の
38
+ `install` で、期待する命令へ書き直す。その間 hook は失敗する。`doctor` と `factory-diagnostics` は、実体を
39
+ 解決できない登録を「登録済み」と見ないので、そこで分かる。
40
+ - `doctor --codex` の「trusted」は、`trusted_hash` が記録されているかだけを見ている。Codex が今の命令と比べた結果
41
+ (`trusted`・`modified`・`untrusted`)は、Codex の App Server の `hooks/list` でしか分からない。この ADR では変えない。
42
+ - Grok と Cursor の hook の命令も同じ決め方で node の表記が入るが、この ADR では変えない。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.16.21",
3
+ "version": "0.16.23",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -60,7 +60,7 @@ const PER_SESSION_ENV = ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_ME
60
60
 
61
61
  // Claude Desktop の画面から始まった会話が transcript に持つ印。
62
62
  const DESKTOP_ENTRYPOINT = 'claude-desktop';
63
- // 後継を Desktop で開くかを、会話の出どころに関係なく決める(`desktop` で必ず開く、`off` で開かない)。
63
+ // 後継を Desktop へ移すか。既定は移さない(ADR 0051)。`origin` は Desktop から始まった会話の後継だけ、`desktop` は出どころに関係なく移す。
64
64
  const OPEN_OVERRIDE_ENV = 'THROUGHLINE_AUTO_HANDOFF_OPEN';
65
65
  // 後継をリモートコントロール付きで立てるかの上書き。`on` は出どころに関係なく付け、`off` は付けない。
66
66
  const REMOTE_CONTROL_OVERRIDE_ENV = 'THROUGHLINE_AUTO_HANDOFF_REMOTE_CONTROL';
@@ -214,14 +214,26 @@ function readLatestEntrypoint(transcriptPath) {
214
214
  }
215
215
 
216
216
  /**
217
- * この会話の後継を、Claude Desktop で開くか。
218
- * Desktop の画面から始まった会話と、その後継がさらに引き継ぐ時だけ開く。端末から始めた会話の後継は
217
+ * 後継を Claude Desktop へ移す処理を、利用者が有効にしているか(ADR 0051)。既定は無効。
218
+ * 移す時は、裏の会話を `claude stop` で止めて Desktop で開き直す。止めると、その会話のリモートコントロールの接続が切れ
219
+ * (スマホと claude.ai/code から会話が消える)、裏で走らせていた命令も止まる。利用者が話している最中の会話でも起きる。
220
+ * `THROUGHLINE_AUTO_HANDOFF_OPEN` が `origin`(Desktop から始まった会話の後継だけ)か `desktop`(出どころに関係なく)の時だけ移す。
221
+ */
222
+ function desktopOpenMode(env) {
223
+ const mode = env[OPEN_OVERRIDE_ENV];
224
+ return mode === 'origin' || mode === 'desktop' ? mode : 'off';
225
+ }
226
+
227
+ /**
228
+ * この会話の後継を、Claude Desktop で開くか。既定では開かない(ADR 0051)。
229
+ * `origin` の時は、Desktop の画面から始まった会話と、その後継がさらに引き継ぐ時だけ開く。端末から始めた会話の後継は
219
230
  * `claude agents` の一覧に出るので、Desktop へは移さない。Claude Code の `--desktop` があるのは macOS と Windows だけ
220
231
  * (Linux は `--desktop isn't available on this platform` で断る)。
221
232
  */
222
233
  function openInDesktopWanted({ sessionId, transcriptPath, env, dir, platform }) {
223
- if (!['darwin', 'win32'].includes(platform) || env[OPEN_OVERRIDE_ENV] === 'off') return false;
224
- if (env[OPEN_OVERRIDE_ENV] === 'desktop') return true;
234
+ const mode = desktopOpenMode(env);
235
+ if (!['darwin', 'win32'].includes(platform) || mode === 'off') return false;
236
+ if (mode === 'desktop') return true;
225
237
  if (env.CLAUDE_CODE_ENTRYPOINT === DESKTOP_ENTRYPOINT || readLatestEntrypoint(transcriptPath) === DESKTOP_ENTRYPOINT) return true;
226
238
  return listClaudeAutoHandoffs({ dir }).some(record => record.successor?.session_id === sessionId && record.desktop?.wanted);
227
239
  }
@@ -531,9 +543,9 @@ function stopOutput(record) {
531
543
  ? `この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: ${record.handoff_id})。続きは後継の会話で行ってください: claude attach ${record.successor.short_id}`
532
544
  : `Throughlineが、自動圧縮の代わりに新しい会話へ引き継ぎます(引き継ぎID: ${record.handoff_id})。` +
533
545
  (record.desktop?.wanted
534
- ? '後継の会話は裏で作業を続け、そのターンを終えた時にClaude Desktopへ開きます。途中の様子は `claude agents` の一覧で見られます。' +
535
- (record.remote_control?.wanted ? '後継はリモートコントロール付きで立てるので、claude.ai/code とClaudeのアプリからも見られます。' : '')
536
- : '後継の会話は `claude agents` の一覧に出ます。');
546
+ ? '後継の会話は裏で作業を続け、そのターンを終えた時にClaude Desktopへ開きます。途中の様子は `claude agents` の一覧で見られます。'
547
+ : '後継の会話は裏で作業を続けます。`claude agents` の一覧に出ます。') +
548
+ (record.remote_control?.wanted ? '後継はリモートコントロール付きで立てるので、claude.ai/code とClaudeのアプリからも見られます。' : '');
537
549
  return {
538
550
  continue: false,
539
551
  stopReason: reason,
@@ -821,13 +833,15 @@ export async function launchClaudeDesktopOpen(sessionId, { dir = claudeAutoHando
821
833
 
822
834
  /**
823
835
  * 後継の Stop hook から呼ぶ。Claude Desktop で開く引き継ぎの後継が、ターンを終えた。
824
- * その会話を Desktop へ移す process を起動する。対象でない会話には何もしない(null)。
836
+ * その会話を Desktop へ移す process を起動する。対象でない会話と、移す処理が無効(既定)の時は何もしない(null)。
825
837
  * `transcriptPath` は、その後継の transcript(Stop の payload の値)。移す process が、ターンが終わっているかを読む(ADR 0050)。
826
838
  *
827
839
  * @returns {Promise<string|null>} 起動した時は、その引き継ぎ ID
828
840
  */
829
- export async function requestClaudeDesktopOpen({ sessionId, transcriptPath = null, dir = claudeAutoHandoffDir(), launch = launchClaudeDesktopOpen }) {
841
+ export async function requestClaudeDesktopOpen({ sessionId, transcriptPath = null, dir = claudeAutoHandoffDir(), launch = launchClaudeDesktopOpen, env = process.env }) {
830
842
  if (!validSessionId(sessionId)) return null;
843
+ // 既定では移さない。0.16.21 までの版が「移す」と記録した引き継ぎの後継も、利用者が有効にしていなければ移さない(ADR 0051)。
844
+ if (desktopOpenMode(env) === 'off') return null;
831
845
  // この会話自身が引き継ぎの途中なら、作業は次の後継が続ける。移すのは、最後に作業を終えた後継だけ。
832
846
  if (readJson(recordPath(sessionId, dir))) return null;
833
847
  const waiting = listClaudeAutoHandoffs({ dir }).filter(item => item.desktop?.wanted && !item.desktop.state);
@@ -26,6 +26,8 @@ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
26
26
  const CLI = join(REPO_ROOT, 'bin', 'throughline.mjs');
27
27
  const SESSION = '0f3a6c1e-7b2d-4e5f-9a81-3c4d5e6f7a8b';
28
28
  const SUCCESSOR = 'a41a97ae-6b2a-421b-b2d5-1a0b07988d06';
29
+ // Desktop へ移す処理は、利用者が有効にした時だけ動く(ADR 0051)。移す処理の試験は、Desktop から始まった会話の後継だけ移す設定で流す。
30
+ const OPEN = { THROUGHLINE_AUTO_HANDOFF_OPEN: 'origin' };
29
31
  const ENABLED = { enabled: true, projects: [] };
30
32
 
31
33
  function withDir(fn) {
@@ -638,6 +640,11 @@ function writeRecord(dir, sourceId, fields) {
638
640
 
639
641
  test('PreCompact: Claude Desktop から始まった会話と、その後継の引き継ぎだけ、後継を Desktop で開く印を付ける(macOS)', () => withDir(dir => {
640
642
  const wanted = overrides => {
643
+ rmSync(join(dir, `${SESSION}.json`), { force: true });
644
+ request(dir, { ...overrides, env: { ...OPEN, ...overrides.env } });
645
+ return readClaudeAutoHandoff(SESSION, dir).desktop;
646
+ };
647
+ const byDefault = overrides => {
641
648
  rmSync(join(dir, `${SESSION}.json`), { force: true });
642
649
  request(dir, overrides);
643
650
  return readClaudeAutoHandoff(SESSION, dir).desktop;
@@ -648,6 +655,17 @@ test('PreCompact: Claude Desktop から始まった会話と、その後継の
648
655
  const cliTranscript = join(dir, 'cli.jsonl');
649
656
  writeFileSync(cliTranscript, jsonl([{ ...user('読んで', '2026-10-08T12:00:00.000Z'), entrypoint: 'cli' }]));
650
657
 
658
+ // 既定では、Desktop から始まった会話の後継も移さない(移すと、話している最中の会話がスマホから消える)
659
+ for (const platform of ['darwin', 'win32']) {
660
+ const payload = platform === 'win32' ? { cwd: 'C:\\work\\app' } : undefined;
661
+ assert.deepEqual(byDefault({ platform, payload, transcriptPath: desktopTranscript }), { wanted: false, state: null, error_code: null, opened_at: null }, platform);
662
+ assert.equal(byDefault({ platform, payload, env: { CLAUDE_CODE_ENTRYPOINT: 'claude-desktop' } }).wanted, false, platform);
663
+ assert.equal(byDefault({ platform, payload, transcriptPath: desktopTranscript, env: { THROUGHLINE_AUTO_HANDOFF_OPEN: 'on' } }).wanted, false, '知らない値では移さない');
664
+ }
665
+ writeRecord(dir, 'origin-desktop', { successor: { short_id: SESSION.slice(0, 8), session_id: SESSION } });
666
+ assert.equal(byDefault({ platform: 'darwin', transcriptPath: cliTranscript }).wanted, false, '0.16.21 までの版が「移す」と記録した引き継ぎの後継が、さらに引き継ぐ時も移さない');
667
+ rmSync(join(dir, 'origin-desktop.json'), { force: true });
668
+
651
669
  assert.deepEqual(wanted({ platform: 'darwin', transcriptPath: desktopTranscript }), { wanted: true, state: null, error_code: null, opened_at: null });
652
670
  assert.equal(wanted({ platform: 'darwin', env: { CLAUDE_CODE_ENTRYPOINT: 'claude-desktop' } }).wanted, true);
653
671
  assert.equal(wanted({ platform: 'darwin', transcriptPath: cliTranscript }).wanted, false, '端末から始めた会話の後継は、claude agents の一覧に出る');
@@ -669,7 +687,13 @@ test('PreCompact: Claude Desktop から始まった会話と、その後継の
669
687
  test('PreToolUse: Desktop で開く引き継ぎは、止めた理由に、後継がターンを終えた時に Desktop へ開く事を書く', () => withDir(async dir => {
670
688
  const desktopTranscript = join(dir, 'desktop.jsonl');
671
689
  writeFileSync(desktopTranscript, jsonl([{ ...user('読んで', '2026-10-08T12:00:00.000Z'), entrypoint: 'claude-desktop' }]));
690
+ // 既定では移さない。止めた理由には、裏で続ける事と、リモートコントロールで見られる事だけを書く
672
691
  request(dir, { platform: 'darwin', transcriptPath: desktopTranscript });
692
+ const kept = await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app', tool_use_id: 't1' }, dir, launchWorker: async () => 1 });
693
+ assert.match(kept.stopReason, /後継の会話は裏で作業を続けます。`claude agents` の一覧に出ます。後継はリモートコントロール付きで立てるので、claude\.ai\/code とClaudeのアプリからも見られます。/);
694
+ assert.doesNotMatch(kept.stopReason, /Claude Desktopへ開きます/);
695
+ rmSync(join(dir, `${SESSION}.json`), { force: true });
696
+ request(dir, { platform: 'darwin', transcriptPath: desktopTranscript, env: OPEN });
673
697
  const output = await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app', tool_use_id: 't1' }, dir, launchWorker: async () => 1 });
674
698
  assert.match(output.stopReason, /そのターンを終えた時にClaude Desktopへ開きます。途中の様子は `claude agents` の一覧で見られます。後継はリモートコントロール付きで立てるので、claude\.ai\/code とClaudeのアプリからも見られます。$/);
675
699
  }));
@@ -734,7 +758,7 @@ test('worker: リモートコントロールを付けると立てられない時
734
758
  writeFileSync(transcriptPath, jsonl([{ ...user('読んで', '2026-10-10T05:00:10Z'), entrypoint: 'claude-desktop' }, assistant('読みました。', '2026-10-10T05:00:12Z')]));
735
759
  const db = makeBatonDb();
736
760
  requestClaudeAutoHandoff({ payload: { session_id: sessionId, trigger: 'auto', cwd: '/work/app', transcript_path: transcriptPath },
737
- env: {}, config: ENABLED, openDb: () => db, dir, platform: 'darwin' });
761
+ env: OPEN, config: ENABLED, openDb: () => db, dir, platform: 'darwin' });
738
762
  await stopClaudeTurnForHandoff({ payload: { session_id: sessionId, cwd: '/work/app', transcript_path: transcriptPath }, dir, launchWorker: async () => {} });
739
763
  const launches = [];
740
764
  const record = await runClaudeAutoHandoffWorker(sessionId, { dir, env: {}, pollMs: 10, acceptTimeoutMs: 300, transcriptTimeoutMs: 50,
@@ -754,29 +778,36 @@ test('Stop: Desktop で開く引き継ぎの後継がターンを終えたら、
754
778
  const launched = [];
755
779
  const launch = async (id) => { launched.push(id); };
756
780
  // 対象でない会話
757
- assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null);
781
+ assert.equal(await requestClaudeDesktopOpen({ env: OPEN, sessionId: SUCCESSOR, dir, launch }), null);
782
+ // 移す処理は既定で無効。0.16.21 までの版が「移す」と記録した引き継ぎの後継がターンを終えても、止めない・移さない(ADR 0051)
783
+ writeRecord(dir, SESSION, {});
784
+ for (const env of [{}, { THROUGHLINE_AUTO_HANDOFF_OPEN: 'off' }, { THROUGHLINE_AUTO_HANDOFF_OPEN: 'on' }]) {
785
+ assert.equal(await requestClaudeDesktopOpen({ env, sessionId: SUCCESSOR, dir, launch }), null, JSON.stringify(env));
786
+ }
787
+ assert.deepEqual(launched, []);
788
+ assert.equal(readClaudeAutoHandoff(SESSION, dir).desktop.state, null, '記録も変えない');
758
789
  writeRecord(dir, SESSION, { desktop: { wanted: false, state: null } });
759
- assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null, '端末から始めた会話の後継は移さない');
790
+ assert.equal(await requestClaudeDesktopOpen({ env: OPEN, sessionId: SUCCESSOR, dir, launch }), null, '端末から始めた会話の後継は移さない');
760
791
  writeRecord(dir, SESSION, { state: 'failed', error_code: 'handoff_successor_launch_failed', successor: null });
761
- assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null, '後継が立たなかった引き継ぎでは動かない');
792
+ assert.equal(await requestClaudeDesktopOpen({ env: OPEN, sessionId: SUCCESSOR, dir, launch }), null, '後継が立たなかった引き継ぎでは動かない');
762
793
  // 対象の後継
763
794
  writeRecord(dir, SESSION, {});
764
- assert.equal(await requestClaudeDesktopOpen({ sessionId: SESSION, dir, launch }), null, '旧い会話自身の Stop では動かない');
765
- assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), `h-${SESSION}`);
795
+ assert.equal(await requestClaudeDesktopOpen({ env: OPEN, sessionId: SESSION, dir, launch }), null, '旧い会話自身の Stop では動かない');
796
+ assert.equal(await requestClaudeDesktopOpen({ env: OPEN, sessionId: SUCCESSOR, dir, launch }), `h-${SESSION}`);
766
797
  assert.deepEqual(launched, [SESSION]);
767
798
  assert.equal(readClaudeAutoHandoff(SESSION, dir).desktop.state, 'requested');
768
- assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null, '2回目の Stop では起動しない');
799
+ assert.equal(await requestClaudeDesktopOpen({ env: OPEN, sessionId: SUCCESSOR, dir, launch }), null, '2回目の Stop では起動しない');
769
800
  assert.deepEqual(launched, [SESSION]);
770
801
 
771
802
  // 後継自身が引き継ぎの途中(作業は次の後継が続ける)なら、移さない
772
803
  writeRecord(dir, SESSION, {});
773
804
  writeRecord(dir, SUCCESSOR, { state: 'stopped', successor: null });
774
- assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null);
805
+ assert.equal(await requestClaudeDesktopOpen({ env: OPEN, sessionId: SUCCESSOR, dir, launch }), null);
775
806
  assert.deepEqual(launched, [SESSION]);
776
807
 
777
808
  // 起動に失敗した時は理由を残す
778
809
  rmSync(join(dir, `${SUCCESSOR}.json`));
779
- assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch: async () => { throw new Error('spawn'); } }), null);
810
+ assert.equal(await requestClaudeDesktopOpen({ env: OPEN, sessionId: SUCCESSOR, dir, launch: async () => { throw new Error('spawn'); } }), null);
780
811
  assert.deepEqual([readClaudeAutoHandoff(SESSION, dir).desktop.state, readClaudeAutoHandoff(SESSION, dir).desktop.error_code], ['failed', 'desktop_open_start_failed']);
781
812
  }));
782
813
 
@@ -787,22 +818,22 @@ test('Stop: 受領を確かめられなかった引き継ぎ(unknown)でも
787
818
  const unconfirmed = { state: 'unknown', error_code: 'handoff_delivery_unconfirmed', accepted_at: null,
788
819
  successor: { short_id: SUCCESSOR.slice(0, 8), session_id: null } };
789
820
  writeRecord(dir, SESSION, unconfirmed);
790
- assert.equal(await requestClaudeDesktopOpen({ sessionId: 'bbbbbbbb-0000-4000-8000-000000000002', dir, launch }), null, '別の会話の Stop では動かない');
791
- assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), `h-${SESSION}`);
821
+ assert.equal(await requestClaudeDesktopOpen({ env: OPEN, sessionId: 'bbbbbbbb-0000-4000-8000-000000000002', dir, launch }), null, '別の会話の Stop では動かない');
822
+ assert.equal(await requestClaudeDesktopOpen({ env: OPEN, sessionId: SUCCESSOR, dir, launch }), `h-${SESSION}`);
792
823
  assert.deepEqual(launched, [SESSION]);
793
824
  const record = readClaudeAutoHandoff(SESSION, dir);
794
825
  assert.deepEqual(record.successor, { short_id: SUCCESSOR.slice(0, 8), session_id: SUCCESSOR }, '移す process が使う後継の ID を記録へ入れる');
795
826
  assert.deepEqual([record.state, record.error_code, record.desktop.state], ['unknown', 'handoff_delivery_unconfirmed', 'requested'],
796
827
  '受領を確かめられなかった事は、記録に残したままにする');
797
- assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null, '2回目の Stop では起動しない');
828
+ assert.equal(await requestClaudeDesktopOpen({ env: OPEN, sessionId: SUCCESSOR, dir, launch }), null, '2回目の Stop では起動しない');
798
829
 
799
830
  // 移す process が、手すきを待ち切れずに戻った(fox の実物、2026-10-11 02:51)。記録には後継の session id が入っている。
800
831
  // 次の Stop で、同じ記録からやり直す
801
832
  const busy = await runClaudeDesktopOpen(SESSION, { dir, env: {}, pollMs: 10, idleTimeoutMs: 40, pty: () => { throw new Error('must not open'); },
802
833
  spawn: (command, args) => { assert.equal(args[0], 'agents'); return { status: 0, stdout: JSON.stringify([{ id: SUCCESSOR.slice(0, 8), kind: 'background', status: 'busy' }]) }; } });
803
834
  assert.deepEqual([busy.state, busy.desktop.state, busy.successor.session_id], ['unknown', null, SUCCESSOR]);
804
- assert.equal(await requestClaudeDesktopOpen({ sessionId: `${SUCCESSOR.slice(0, 8)}-0000-4000-8000-000000000009`, dir, launch }), null, '短い ID が同じでも、別の会話の Stop では動かない');
805
- assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), `h-${SESSION}`, '戻った後の Stop でやり直す');
835
+ assert.equal(await requestClaudeDesktopOpen({ env: OPEN, sessionId: `${SUCCESSOR.slice(0, 8)}-0000-4000-8000-000000000009`, dir, launch }), null, '短い ID が同じでも、別の会話の Stop では動かない');
836
+ assert.equal(await requestClaudeDesktopOpen({ env: OPEN, sessionId: SUCCESSOR, dir, launch }), `h-${SESSION}`, '戻った後の Stop でやり直す');
806
837
  assert.deepEqual(launched, [SESSION, SESSION]);
807
838
  assert.equal(readClaudeAutoHandoff(SESSION, dir).desktop.state, 'requested');
808
839
 
@@ -916,7 +947,7 @@ test('transcript: 終わったターンで止まっている時だけ、印(tr
916
947
  test('Stop: 後継の transcript の場所を、移す process のために記録へ残す', () => withDir(async dir => {
917
948
  writeRecord(dir, SESSION, {});
918
949
  const launch = async () => {};
919
- assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, transcriptPath: '/home/u/.claude/projects/-work-app/successor.jsonl', dir, launch }), `h-${SESSION}`);
950
+ assert.equal(await requestClaudeDesktopOpen({ env: OPEN, sessionId: SUCCESSOR, transcriptPath: '/home/u/.claude/projects/-work-app/successor.jsonl', dir, launch }), `h-${SESSION}`);
920
951
  assert.deepEqual(readClaudeAutoHandoff(SESSION, dir).successor,
921
952
  { short_id: SUCCESSOR.slice(0, 8), session_id: SUCCESSOR, transcript_path: '/home/u/.claude/projects/-work-app/successor.jsonl' });
922
953
  assert.equal(JSON.stringify(publicClaudeAutoHandoff(readClaudeAutoHandoff(SESSION, dir))).includes('successor.jsonl'), false, '公開の表示には場所を出さない');
@@ -250,15 +250,25 @@ function isSameExecutablePath(a, b, realpath) {
250
250
  return left !== null && right !== null && left === right;
251
251
  }
252
252
 
253
- function createCodexHooks() {
253
+ // 登録済みの命令が今の node と script の実体を指している時は、その文字列のまま返す(ADR 0052)。
254
+ // node の表記は入れる側の PATH で変わる。Codex は hook の信用を命令の文字列で決めるので、表記だけを書き換えると
255
+ // 信用済みの hook が「変わった hook」になり、新しい会話が確認の画面で止まる。
256
+ function registeredCodexHookCommand(expected, groups) {
257
+ const registered = (Array.isArray(groups) ? groups : []).flatMap(group => group?.hooks ?? []).map(hook => hook?.command)
258
+ .find(command => isEquivalentCodexHookCommand(command, expected));
259
+ return registered ?? expected;
260
+ }
261
+
262
+ function createCodexHooks(registered = {}) {
263
+ const command = (event, expected) => registeredCodexHookCommand(expected, registered?.[event]);
254
264
  return {
255
- PreCompact: { matcher: 'auto', hooks: [{ type: 'command', command: buildCodexPreCompactHookCommand(),
265
+ PreCompact: { matcher: 'auto', hooks: [{ type: 'command', command: command('PreCompact', buildCodexPreCompactHookCommand()),
256
266
  timeout: 30, async: false, statusMessage: 'Throughline: 自動継続を準備' }] },
257
267
  UserPromptSubmit: {
258
268
  hooks: [
259
269
  {
260
270
  type: 'command',
261
- command: buildCodexUserPromptSubmitHookCommand(),
271
+ command: command('UserPromptSubmit', buildCodexUserPromptSubmitHookCommand()),
262
272
  timeout: 30,
263
273
  async: false,
264
274
  statusMessage: null,
@@ -269,7 +279,7 @@ function createCodexHooks() {
269
279
  hooks: [
270
280
  {
271
281
  type: 'command',
272
- command: buildCodexPostToolUseHookCommand(),
282
+ command: command('PostToolUse', buildCodexPostToolUseHookCommand()),
273
283
  timeout: 30,
274
284
  async: false,
275
285
  statusMessage: null,
@@ -280,7 +290,7 @@ function createCodexHooks() {
280
290
  hooks: [
281
291
  {
282
292
  type: 'command',
283
- command: buildCodexStopHookCommand(),
293
+ command: command('Stop', buildCodexStopHookCommand()),
284
294
  timeout: 300,
285
295
  async: false,
286
296
  statusMessage: null,
@@ -628,7 +638,7 @@ function installCodexHooks() {
628
638
  const configPath = resolveCodexConfigPath();
629
639
  const current = readSettings(hooksPath);
630
640
  const existingHooks = current.hooks ?? {};
631
- const codexHooks = createCodexHooks();
641
+ const codexHooks = createCodexHooks(existingHooks);
632
642
 
633
643
  for (const [key, entry] of Object.entries(codexHooks)) {
634
644
  const list = existingHooks[key] ?? [];
@@ -705,7 +715,7 @@ export function installCodexAutoHandoffHook(codexHome = join(homedir(), '.codex'
705
715
  const configPath = join(codexHome, 'config.toml');
706
716
  const current = readSettings(hooksPath);
707
717
  current.hooks ??= {};
708
- const entry = createCodexHooks().PreCompact;
718
+ const entry = createCodexHooks(current.hooks).PreCompact;
709
719
  const groups = current.hooks.PreCompact ?? [];
710
720
  const existing = groups.flatMap(group => group.hooks ?? []).filter(hook => isThroughlineCodexHookCommand(hook.command));
711
721
  if (existing.length !== 1 || existing[0].command !== entry.hooks[0].command || existing[0].timeout !== 30) {
@@ -1,17 +1,19 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
3
+ import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync, symlinkSync, realpathSync } from 'node:fs';
4
4
  import { tmpdir, homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
 
7
7
  import {
8
8
  buildCodexPostToolUseHookCommand,
9
+ buildCodexPreCompactHookCommand,
9
10
  buildCodexStopHookCommand,
10
11
  buildCodexUserPromptSubmitHookCommand,
11
12
  buildCursorHookCommand,
12
13
  buildGrokHookCommand,
13
14
  createGrokHooksFile,
14
15
  createCursorHookEntries,
16
+ installCodexAutoHandoffHook,
15
17
  isEquivalentCodexHookCommand,
16
18
  isThroughlineCodexHookCommand,
17
19
  isThroughlineCursorHookCommand,
@@ -855,3 +857,94 @@ test('install is idempotent: second run keeps exactly one tl.md and one hook ent
855
857
  home.restore();
856
858
  }
857
859
  });
860
+
861
+ // 今の node を指す、別の表記。POSIX は symlink、Windows は大文字と小文字を入れ替えた path(どちらも realpath が同じになる)。
862
+ function alternateNodeSpelling(dir) {
863
+ if (process.platform === 'win32') {
864
+ const upper = process.execPath.toUpperCase();
865
+ return upper !== process.execPath ? upper : process.execPath.toLowerCase();
866
+ }
867
+ mkdirSync(join(dir, 'altbin'), { recursive: true });
868
+ const link = join(dir, 'altbin', 'node');
869
+ symlinkSync(realpathSync.native(process.execPath), link);
870
+ return link;
871
+ }
872
+
873
+ test('install は、今の node を指す登録済みの Codex hook の命令を書き換えない(Codex の信用を保つ)', async () => {
874
+ const home = makeTempHome();
875
+ if (home.resolved !== home.dir) {
876
+ home.restore();
877
+ return;
878
+ }
879
+ const unsilence = silence();
880
+ try {
881
+ const hooksPath = join(home.dir, '.codex', 'hooks.json');
882
+ await run([]);
883
+ const nodePath = alternateNodeSpelling(home.dir);
884
+ const registered = {
885
+ PreCompact: buildCodexPreCompactHookCommand({ nodePath }),
886
+ UserPromptSubmit: buildCodexUserPromptSubmitHookCommand({ nodePath }),
887
+ PostToolUse: buildCodexPostToolUseHookCommand({ nodePath }),
888
+ Stop: buildCodexStopHookCommand({ nodePath }),
889
+ };
890
+ assert.notEqual(registered.Stop, buildCodexStopHookCommand({ nodePath: process.execPath }));
891
+ // ほかの PATH から入れた時の登録を作る。node の表記だけが違い、指す実体は同じ。
892
+ const file = JSON.parse(readFileSync(hooksPath, 'utf8'));
893
+ for (const [event, command] of Object.entries(registered)) {
894
+ const own = file.hooks[event].flatMap(group => group.hooks).filter(hook => isThroughlineCodexHookCommand(hook.command));
895
+ assert.equal(own.length, 1);
896
+ own[0].command = command;
897
+ }
898
+ file.hooks.Stop.push({ hooks: [{ type: 'command', command: '/usr/bin/node /opt/other/bin/other codex-hook stop', timeout: 5 }] });
899
+ const before = JSON.stringify(file, null, 2) + '\n';
900
+ writeFileSync(hooksPath, before);
901
+
902
+ await run([]);
903
+ assert.equal(readFileSync(hooksPath, 'utf8'), before);
904
+ const autoHandoff = installCodexAutoHandoffHook(join(home.dir, '.codex'));
905
+ assert.equal(autoHandoff.command, registered.PreCompact);
906
+ assert.equal(readFileSync(hooksPath, 'utf8'), before);
907
+
908
+ // 登録済みの node が今の node でない時は、今までどおり書き直す。
909
+ const moved = JSON.parse(before);
910
+ moved.hooks.Stop[0].hooks[0].command = buildCodexStopHookCommand({ nodePath: join(home.dir, 'gone', 'node') });
911
+ writeFileSync(hooksPath, JSON.stringify(moved, null, 2) + '\n');
912
+ await run([]);
913
+ const after = JSON.parse(readFileSync(hooksPath, 'utf8'));
914
+ assert.equal(after.hooks.Stop[0].hooks[0].command, buildCodexStopHookCommand());
915
+ assert.equal(after.hooks.UserPromptSubmit[0].hooks[0].command, registered.UserPromptSubmit);
916
+ assert.equal(after.hooks.PostToolUse[0].hooks[0].command, registered.PostToolUse);
917
+ assert.equal(after.hooks.PreCompact.at(-1).hooks[0].command, registered.PreCompact);
918
+ } finally {
919
+ unsilence();
920
+ home.restore();
921
+ }
922
+ });
923
+
924
+ test('install は、入れる側の PATH が変わっても Codex hook の命令を変えない', { skip: process.platform === 'win32' }, async () => {
925
+ const home = makeTempHome();
926
+ if (home.resolved !== home.dir) {
927
+ home.restore();
928
+ return;
929
+ }
930
+ const originalPath = process.env.PATH;
931
+ const unsilence = silence();
932
+ try {
933
+ const hooksPath = join(home.dir, '.codex', 'hooks.json');
934
+ const nodePath = alternateNodeSpelling(home.dir);
935
+ // node の symlink がある場所を PATH の先頭に置いて入れ、次は PATH に node が無い状態で入れる。
936
+ process.env.PATH = join(home.dir, 'altbin');
937
+ await run([]);
938
+ const first = readFileSync(hooksPath, 'utf8');
939
+ assert.equal(JSON.parse(first).hooks.Stop[0].hooks[0].command, buildCodexStopHookCommand({ nodePath }));
940
+ process.env.PATH = '';
941
+ assert.notEqual(buildCodexStopHookCommand(), buildCodexStopHookCommand({ nodePath }));
942
+ await run([]);
943
+ assert.equal(readFileSync(hooksPath, 'utf8'), first);
944
+ } finally {
945
+ if (originalPath === undefined) delete process.env.PATH;
946
+ else process.env.PATH = originalPath;
947
+ unsilence();
948
+ home.restore();
949
+ }
950
+ });