throughline 0.16.20 → 0.16.22

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,53 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.16.22] — 2026-10-11
14
+
15
+ ### 変更
16
+
17
+ - **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の一覧には出ない。
18
+ - 今までは、Claude Desktopから始まった会話の後継がターンを終えるたびに、`claude stop`で止めて`claude --desktop --resume`で開き直していた。Windowsの実機で2026-10-11に2回(10:31、10:39)、オーナーがスマホから話している最中の会話が消えた。止めると、起動時に付けたリモートコントロールの接続が切れ、移った先のDesktopの会話ではリモートコントロールが有効になっていなかった。1回目は、会話が裏で走らせていた命令(`run_in_background`)も一緒に止まった。作業ツリーで作業していた会話は、開き直した後のcwdが元のrepositoryになった。
19
+ - 0.16.20(保留の文を持つ後継も移す)で、それまで移らなかった会話が移るようになり、この2回が起きた。保留の文が無い会話では、0.16.8(macOS)と0.16.9(Windows)から同じ事が起きていた。
20
+ - 環境変数`THROUGHLINE_AUTO_HANDOFF_OPEN`を`origin`にすると、今までの既定(Desktopから始まった会話の後継だけ移す)で動く。`desktop`は出どころに関係なく移す。未設定とそれ以外の値は移さない。
21
+ - 0.16.21までの版が「移す」と記録した引き継ぎの後継も、有効にしていなければ移さない。
22
+ - 旧い会話を止めた理由の文を直した。移さない時は「後継の会話は裏で作業を続けます」と書き、リモートコントロール付きの時はその事も書く。
23
+
24
+ ### 対応の範囲
25
+
26
+ | | macOS | Linux | Windows |
27
+ |---|---|---|---|
28
+ | Claude Code | 止めた。単体試験と、実機(Claude Code 2.1.289)。試験用の場所の記録で、入っていた0.16.20は移すprocessを起こし、この版は未設定では起こさず(記録も変えない)、`origin`では起こす事 | 元から移す処理が動かない(Claude Codeの`--desktop`が無い)。単体試験と、実機(2.1.296)で、記録を探す処理が同じ結果になる事 | 止めた。単体試験と、実機(Claude Code 2.1.296)で同じ確認。今日消えた会話を移した本物の記録の写しでも、未設定では起こさない事 |
29
+ | Codex | 変更なし(Desktopへ移す処理が無い。後継は最初からCodex Desktopのタスク) | 同じ | 同じ |
30
+ | Grok | 変更なし(自動継続が無い) | 同じ | 同じ |
31
+ | Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
32
+
33
+ ### 確かめていない事
34
+
35
+ - この版での本物の引き継ぎ。後継がターンを終えても裏の会話のまま残り、スマホから見え続ける所。
36
+ - 0.16.21までに移した会話(Desktopの会話になっている)へ、リモートコントロールを付け直す方法。
37
+
38
+ ## [0.16.21] — 2026-10-11
39
+
40
+ ### 修正
41
+
42
+ - Claude Codeの自動継続で、受領を確かめられなかった引き継ぎ(`unknown`)の後継をClaude Desktopへ移す処理が、1回しか起きなかった(0.16.19で入れた処理の漏れ。[ADR 0049](docs/adr/0049-claude-handoff-uses-registered-project.md))。1回目のStopで後継のsession idを記録へ入れるが、次のStopで記録を探す条件は「session idがまだ無い」だった。1回目に後継が手すきにならずに戻ると、その後のターンの終わりでは何も起きず、後継は裏の会話のまま残った。Windowsの実機で1回起きた(2026-10-10 23:43の引き継ぎ。1回目は10-11 02:51で、後継は保留の文で`waiting`だった。その後の09:10と10:03のターンの終わりでは、移す処理が起きなかった)。
43
+ - session idが記録にある時は、そのidが同じ会話のStopで、同じ記録からやり直す。短いIDの先頭一致で探すのは、session idがまだ無い時だけ。
44
+ - 受領が残った引き継ぎ(`sent`)の後継は、今までもやり直していた。変えていない。
45
+
46
+ ### 対応の範囲
47
+
48
+ | | macOS | Linux | Windows |
49
+ |---|---|---|---|
50
+ | Claude Code | 直した。単体試験と、実機(Claude Code 2.1.289)。試験用の場所の記録と本物の`claude agents`で、0.16.20は戻った後のStopで何もせず、直した版は移すprocessをもう1回起こす事 | 記録を探す処理は同じ物なので直っている。単体試験と、実機(Claude Code 2.1.296)で同じ確認。ただしClaude Desktopへ移す処理は、Linuxでは起動しない(Claude Codeの`--desktop`が無い) | 直した。単体試験と、実機(Claude Code 2.1.296)で同じ確認。この失敗が起きた本物の記録を試験用の場所へ写した物でも、0.16.20は何もせず、直した版は移すprocessを起こす事 |
51
+ | Codex | 変更なし(引き継ぎの部品が別。Desktopへ移す処理が無い) | 同じ | 同じ |
52
+ | Grok | 変更なし(自動継続が無い) | 同じ | 同じ |
53
+ | Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
54
+
55
+ ### 確かめていない事
56
+
57
+ - 本物の引き継ぎで、受領を確かめられなかった後継が、2回目より後のStopでClaude Desktopへ移る所。0.16.19より後、受領を確かめられない引き継ぎは起きていない(狙って起こせない)。
58
+ - この失敗が起きた会話(2026-10-10 23:43の後継)は、直す前に次の後継へ引き継いだ。作業は次の後継が続けていて、この会話はDesktopへ移さない。
59
+
13
60
  ## [0.16.20] — 2026-10-11
14
61
 
15
62
  ### 修正
@@ -2331,7 +2378,9 @@ two attempts, instrument first instead of patching again.
2331
2378
 
2332
2379
  ---
2333
2380
 
2334
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.20...HEAD
2381
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.22...HEAD
2382
+ [0.16.22]: https://github.com/kitepon/Throughline/compare/v0.16.21...v0.16.22
2383
+ [0.16.21]: https://github.com/kitepon/Throughline/compare/v0.16.20...v0.16.21
2335
2384
  [0.16.20]: https://github.com/kitepon/Throughline/compare/v0.16.19...v0.16.20
2336
2385
  [0.16.19]: https://github.com/kitepon/Throughline/compare/v0.16.18...v0.16.19
2337
2386
  [0.16.18]: https://github.com/kitepon/Throughline/compare/v0.16.17...v0.16.18
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
@@ -33,6 +33,9 @@ Claude Code の自動継続は、PreCompact の hook で、後継を立てる pr
33
33
  3. 受領を確かめられなかった引き継ぎ(`unknown`、後継の短い ID だけが記録にある)でも、その後継がターンを終えたら、
34
34
  Desktop へ移す。継続の指示は起動時の引数で渡してあるので、ターンを終えた後継は指示を受けている。
35
35
  後継の session id を記録へ入れる。`state` と `error_code` は変えない(記憶が合流しなかった事は、記録に残す)。
36
+ 移す process が後継の手すきを待ち切れずに戻った時は、次の Stop で同じ記録からやり直す。この時は、記録に入れた
37
+ session id が同じ会話だけを対象にする(0.16.21。0.16.19 と 0.16.20 は、session id が無い記録だけを探していて、
38
+ 2回目より後の Stop では何も起きなかった。Windows の実機で 2026-10-11 に1回起きた)。
36
39
  4. 後継が立たなかった引き継ぎ(`failed`)は、今までどおり何もしない。
37
40
 
38
41
  ## Consequences
@@ -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
+ 有効にした時だけ働く。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.16.20",
3
+ "version": "0.16.22",
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,21 +833,24 @@ 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);
834
848
  // 受領を確かめられなかった引き継ぎ(unknown)は、後継の session id が記録に無い。その後継がターンを終えたなら、
835
849
  // 後継は動いている。裏の会話のまま残さず、同じように Desktop へ移す(ADR 0049)。
850
+ // 1回目の Stop で session id を記録へ入れるので、移せずに戻った後の Stop は、その id で同じ記録を見つける。
836
851
  const record = waiting.find(item => item.state === 'sent' && item.successor?.session_id === sessionId) ??
837
- waiting.find(item => item.state === 'unknown' && !item.successor?.session_id &&
838
- typeof item.successor?.short_id === 'string' && sessionId.startsWith(item.successor.short_id));
852
+ waiting.find(item => item.state === 'unknown' && typeof item.successor?.short_id === 'string' &&
853
+ (item.successor.session_id ? item.successor.session_id === sessionId : sessionId.startsWith(item.successor.short_id)));
839
854
  if (!record) return null;
840
855
  const successor = { ...record.successor, session_id: sessionId };
841
856
  if (typeof transcriptPath === 'string' && transcriptPath) successor.transcript_path = transcriptPath;
@@ -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,14 +818,24 @@ 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 では起動しない');
829
+
830
+ // 移す process が、手すきを待ち切れずに戻った(fox の実物、2026-10-11 02:51)。記録には後継の session id が入っている。
831
+ // 次の Stop で、同じ記録からやり直す
832
+ const busy = await runClaudeDesktopOpen(SESSION, { dir, env: {}, pollMs: 10, idleTimeoutMs: 40, pty: () => { throw new Error('must not open'); },
833
+ spawn: (command, args) => { assert.equal(args[0], 'agents'); return { status: 0, stdout: JSON.stringify([{ id: SUCCESSOR.slice(0, 8), kind: 'background', status: 'busy' }]) }; } });
834
+ assert.deepEqual([busy.state, busy.desktop.state, busy.successor.session_id], ['unknown', null, SUCCESSOR]);
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 でやり直す');
837
+ assert.deepEqual(launched, [SESSION, SESSION]);
838
+ assert.equal(readClaudeAutoHandoff(SESSION, dir).desktop.state, 'requested');
798
839
 
799
840
  // 移す process は、その記録で後継を止めて Desktop で開く
800
841
  const calls = [];
@@ -906,7 +947,7 @@ test('transcript: 終わったターンで止まっている時だけ、印(tr
906
947
  test('Stop: 後継の transcript の場所を、移す process のために記録へ残す', () => withDir(async dir => {
907
948
  writeRecord(dir, SESSION, {});
908
949
  const launch = async () => {};
909
- 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}`);
910
951
  assert.deepEqual(readClaudeAutoHandoff(SESSION, dir).successor,
911
952
  { short_id: SUCCESSOR.slice(0, 8), session_id: SUCCESSOR, transcript_path: '/home/u/.claude/projects/-work-app/successor.jsonl' });
912
953
  assert.equal(JSON.stringify(publicClaudeAutoHandoff(readClaudeAutoHandoff(SESSION, dir))).includes('successor.jsonl'), false, '公開の表示には場所を出さない');