throughline 0.16.14 → 0.16.16

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,57 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.16.16] — 2026-10-10
14
+
15
+ ### 追加
16
+
17
+ - Claude Desktopから始まった会話の後継を、リモートコントロール付き(`--remote-control=<後継の名前>`)で立てる。後継は裏の会話で、ターンを終えるまでClaude Desktopの一覧に出ない。作業の最中も、claude.ai/codeとClaudeのアプリから様子を見て操作できる([ADR 0048](docs/adr/0048-claude-desktop-origin-successor-has-remote-control.md))。
18
+ - 端末から始めた会話の後継には付けない。後継がさらに引き継ぐ時は、元の会話の出どころを引き継ぐ。
19
+ - 付けて立てるのに失敗した時は、付けずに1回だけ立て直す。引き継ぎは止めない。
20
+ - `auto-handoff status --host claude --json`の`remote_control_state`で読める(`requested`: 付けて立てた、`unavailable`: 付けずに立て直した、`null`: 対象外)。
21
+ - 環境変数`THROUGHLINE_AUTO_HANDOFF_REMOTE_CONTROL`で上書きできる(`on`・`off`)。
22
+
23
+ ### 対応の範囲
24
+
25
+ | | macOS | Linux | Windows |
26
+ |---|---|---|---|
27
+ | Claude Code | 追加した。試験用フォルダで、本物のworkerが後継をリモートコントロール付きで立て、transcriptにリモートのURLが入った(Claude Code 2.1.289、画面のあるsessionの端末)。sshから立てた時は、後継は立つがURLが入らなかった | 追加した。同じ確かめでURLが入った(2.1.296)。信頼していないフォルダでは、付けずに立て直す所まで動き、どちらも`Workspace not trusted`で立たなかった(今までと同じ) | 追加した。同じ確かめでURLが入った(2.1.296) |
28
+ | Codex | 対象外。リモートコントロールはClaude Codeの機能 | 同じ | 同じ |
29
+ | Grok | 対象外。同じ理由。自動継続の機能も無い | 同じ | 同じ |
30
+ | Cursor | 対象外。同じ理由。自動継続の機能も無い | 同じ | 同じ |
31
+
32
+ ### 変わらない事
33
+
34
+ - 後継がターンを終えた時にClaude Desktopへ移して開く動き。端末から始めた会話の引き継ぎ。
35
+
36
+ ### 確認していない範囲
37
+
38
+ - claude.ai/codeとClaudeのアプリの側で、後継が見えて操作できる所(確かめたのは、後継のtranscriptにリモートのURLが入った事まで)。
39
+ - Claude Desktopの会話の、本物の引き継ぎでの動き(試験は、端末から始めた会話にDesktopの印を付けて流した)。
40
+ - リモートコントロールが使えない状態(未ログインなど)での実物の動き(試験では作り物の失敗で、付けずに立て直す事を確かめた)。
41
+
42
+ ## [0.16.15] — 2026-10-10
43
+
44
+ ### 追加
45
+
46
+ - `throughline auto-handoff successor --thread <Codexの会話のID> --json`。あるCodexのタスクの「今の続き」を返す、読むだけの入口。ほかの製品が、旧タスクあての連絡を今の続きへ渡す時に使う。`auto-handoff status --json`は新しい20件だけを返すので、古い引き継ぎの続きを引けなかった。
47
+ - 返す物: `handed_off`(残っている後継があるか)、`current_thread_id`(引き継ぎを先までたどった、残っている一番先の後継。無ければ渡したID自身)、`chain`(そこまでの引き継ぎ)、`pending`(今の続きのタスクで、後継へ指示を送る前の引き継ぎ。`in_flight`がtrueならworkerが動いている最中、falseなら止まっていて`error_code`と`resume_state`が入る)。
48
+ - 消された後継とアーカイブ済みの後継は、無い物として扱う(旧タスクへ入力が来た時に開く後継と同じ決め方)。
49
+ - 記録が無い端末では、引き継ぎが1つも無い時と同じ答えを返す。記録は読み取り専用で開く。
50
+
51
+ ### 対応の範囲
52
+
53
+ | | macOS | Linux | Windows |
54
+ |---|---|---|---|
55
+ | Codex(Desktop) | 追加した。実機の記録で確認(引き継ぎ済みのタスク2つから今の続きが返り、引き継ぎの無いタスクは自分自身が返った) | 追加した。実機の記録で確認(後継がアーカイブ済みの系列は、旧タスク自身が返った) | 追加した。実機の記録で確認(16本続いた系列の最初と途中から、同じ今の続きが返った。止まったタスクは`pending`に理由が出た) |
56
+ | Claude Code | 対象外。Claude Codeの自動継続の記録は別の形で、`auto-handoff status --host claude --json`が全件を返す | 同じ | 同じ |
57
+ | Grok | 対象外。自動継続の機能が無い | 同じ | 同じ |
58
+ | Cursor | 対象外。自動継続の機能が無い | 同じ | 同じ |
59
+
60
+ ### 変わらない事
61
+
62
+ - 引き継ぎの動き。`auto-handoff status --json`の出力。
63
+
13
64
  ## [0.16.14] — 2026-10-10
14
65
 
15
66
  ### 修正
@@ -2195,7 +2246,9 @@ two attempts, instrument first instead of patching again.
2195
2246
 
2196
2247
  ---
2197
2248
 
2198
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.14...HEAD
2249
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.16...HEAD
2250
+ [0.16.16]: https://github.com/kitepon/Throughline/compare/v0.16.15...v0.16.16
2251
+ [0.16.15]: https://github.com/kitepon/Throughline/compare/v0.16.14...v0.16.15
2199
2252
  [0.16.14]: https://github.com/kitepon/Throughline/compare/v0.16.13...v0.16.14
2200
2253
  [0.16.13]: https://github.com/kitepon/Throughline/compare/v0.16.12...v0.16.13
2201
2254
  [0.16.12]: https://github.com/kitepon/Throughline/compare/v0.16.11...v0.16.12
package/README.ja.md CHANGED
@@ -40,6 +40,14 @@ throughline auto-handoff status --operation <handoff-id> --json
40
40
  throughline auto-handoff resume --operation <handoff-id> --json
41
41
  ```
42
42
 
43
+ あるタスクの今の続きは、次の命令で引けます(読むだけ。ほかの道具が、旧タスクあての連絡を今の続きへ渡す時に使います)。
44
+
45
+ ```bash
46
+ throughline auto-handoff successor --thread <codex-thread-id> --json
47
+ ```
48
+
49
+ `current_thread_id`が今の続きです(後継が無ければ渡したID自身)。`pending.in_flight`がtrueの間は、引き継ぎの途中です。
50
+
43
51
  原因を解消した後の`resume`は同じ引き継ぎと既知の後継を再利用します。配送後の結果不明は実際の入力・開始を観測した場合だけ回復し、作成結果が不明な後継は再作成しません。従来の手動`$throughline`とcurrent-thread実験の設定は独立しています。
44
52
 
45
53
  [実測と検証範囲](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-03-codex-auto-continuation.md)、[Windowsの実測](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-08-windows-codex-auto-handoff.md)、[Linuxの実測](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-08-linux-codex-auto-handoff.md)を参照してください。
@@ -61,7 +69,7 @@ throughline auto-handoff disable --host claude
61
69
  3. 同じprojectに、継続の指示を最初の指示として付けた新しいClaudeの会話を裏で立てます(`claude --bg`)。会話の名前は、前任の会話の題から`<project名>|<作業の概要>(自動引き継ぎ)`にします。モデル・推論強度・権限は旧い会話から引き継ぎます。
62
70
  4. 最初の指示を受け取った時に、止めた時点の依頼、そのターンでここまでにしたこと(発言と道具の呼び出し)、実行されなかった道具、直近の会話の原文を注入します(上限9,500字)。止めたターンの道具の入出力は、後継が`throughline detail`で取り出せます。
63
71
 
64
- 続きは裏の会話で動きます。`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には出ません。続けて引き継いだ時は、最後に作業を終えた後継だけを開きます。端末から始めた会話の後継は開きません。手で開く時は、`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から起動した会話は対象に含めません。
72
+ 続きは裏の会話で動きます。`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には出ません。続けて引き継いだ時は、最後に作業を終えた後継だけを開きます。端末から始めた会話の後継は開きません。手で開く時は、`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))。
65
73
 
66
74
  ```bash
67
75
  throughline auto-handoff status --host claude --operation <handoff-id> --json
package/README.md CHANGED
@@ -39,6 +39,14 @@ throughline auto-handoff status --operation <handoff-id> --json
39
39
  throughline auto-handoff resume --operation <handoff-id> --json
40
40
  ```
41
41
 
42
+ あるタスクの今の続きは、次の命令で引けます(読むだけ。ほかの道具が、旧タスクあての連絡を今の続きへ渡す時に使います)。
43
+
44
+ ```bash
45
+ throughline auto-handoff successor --thread <codex-thread-id> --json
46
+ ```
47
+
48
+ `current_thread_id`が今の続きです(後継が無ければ渡したID自身)。`pending.in_flight`がtrueの間は、引き継ぎの途中です。
49
+
42
50
  原因を解消した後の`resume`は同じ引き継ぎと既知の後継を再利用します。配送後の結果不明は実際の入力・開始を観測した場合だけ回復し、作成結果が不明な後継は再作成しません。従来の手動`$throughline`とcurrent-thread実験の設定は独立しています。
43
51
 
44
52
  [実測と検証範囲](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-03-codex-auto-continuation.md)、[Windowsの実測](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-08-windows-codex-auto-handoff.md)、[Linuxの実測](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-08-linux-codex-auto-handoff.md)を参照してください。
@@ -60,7 +68,7 @@ throughline auto-handoff disable --host claude
60
68
  3. 同じprojectに、継続の指示を最初の指示として付けた新しいClaudeの会話を裏で立てます(`claude --bg`)。会話の名前は、前任の会話の題から`<project名>|<作業の概要>(自動引き継ぎ)`にします。モデル・推論強度・権限は旧い会話から引き継ぎます。
61
69
  4. 最初の指示を受け取った時に、止めた時点の依頼、そのターンでここまでにしたこと(発言と道具の呼び出し)、実行されなかった道具、直近の会話の原文を注入します(上限9,500字)。止めたターンの道具の入出力は、後継が`throughline detail`で取り出せます。
62
70
 
63
- 続きは裏の会話で動きます。`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には出ません。続けて引き継いだ時は、最後に作業を終えた後継だけを開きます。端末から始めた会話の後継は開きません。手で開く時は、`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から起動した会話は対象に含めません。
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には出ません。続けて引き継いだ時は、最後に作業を終えた後継だけを開きます。端末から始めた会話の後継は開きません。手で開く時は、`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))。
64
72
 
65
73
  ```bash
66
74
  throughline auto-handoff status --host claude --operation <handoff-id> --json
@@ -412,6 +412,8 @@ Usage:
412
412
  引き継ぎの状態と固定理由を確認
413
413
  throughline auto-handoff resume --operation <id> --json
414
414
  同じ引き継ぎを再開(結果不明は再送しない)
415
+ throughline auto-handoff successor --thread <codex-thread-id> --json
416
+ あるCodexのタスクの今の続きを返す(読むだけ)
415
417
  throughline auto-handoff mark-sources [--dry-run] [--json]
416
418
  今までの引き継ぎの名前を付け直す(旧タスクへ印、
417
419
  名前の無い後継へ名前)
@@ -0,0 +1,47 @@
1
+ # ADR 0048: Claude Desktop から始まった会話の後継は、リモートコントロール付きで立てる
2
+
3
+ 日付: 2026-10-10
4
+
5
+ ## Context
6
+
7
+ Claude Code の自動継続は、後継を `claude --bg`(裏の会話)で立てる(ADR 0033)。Claude Desktop から始まった会話の後継は、
8
+ ターンを終えた時に Desktop へ移して開く(ADR 0043)。動いている最中の裏の会話は Desktop へ移せないので、後継が作業している間は、
9
+ Desktop の一覧に何も出ない。
10
+
11
+ 2026-10-10、Windows の実機で、本物の会話の最初の引き継ぎが起きた(Claude Desktop の会話、約 96.8 万トークン)。引き継ぎは成功したが、
12
+ 後継は裏で作業を続け、オーナーには状況が見えなかった(「スルーラインは発動したのか?状況がわからん」「リモートコントロールは有効に
13
+ なっているのかな」)。旧い会話は Desktop の会話で、リモートの接続の記録があった。後継には無かった。Throughline が後継を立てる時に、
14
+ リモートコントロールの指定を付けていなかった。
15
+
16
+ Claude Code には `--remote-control [name]` がある。付けて立てた会話は、claude.ai/code と Claude のアプリから見て操作できる。
17
+ 実機で確かめた事(試験用フォルダ、Haiku):
18
+
19
+ - `claude --bg --name=<名前> --remote-control=<名前> …` は、Windows・Linux(Claude Code 2.1.296)と macOS(2.1.289)で通る。
20
+ 立った会話は裏の会話(`kind: background`)で、transcript の `remote_session_change` にリモートの URL が入る。
21
+ - 名前は `=` の形で渡す。名前を省ける option なので、離して書くと次の引数を名前として読む。
22
+ - macOS で ssh から立てた時は、指定は通り会話も立つが、URL が入らなかった。画面のある session の端末からは入った。
23
+ 本物の引き継ぎの worker は Claude Desktop の下で動く。
24
+ - 信頼していないフォルダでは、リモートコントロールの有無に関係なく `Workspace not trusted` で立たない。
25
+
26
+ ## Decision
27
+
28
+ 1. Claude Desktop から始まった会話の後継と、その後継がさらに引き継ぐ時の後継を、`--remote-control=<後継の名前>` を付けて立てる。
29
+ 端末から始めた会話の後継には付けない(オーナーの裁定。Approval Box K-4DNZDK)。
30
+ 2. Desktop から始まったかは、ADR 0043 と同じく、止める時点の transcript の最後の `entrypoint`(`claude-desktop`)と、hook の環境変数
31
+ `CLAUDE_CODE_ENTRYPOINT` で決める。OS は問わない。引き継ぎの記録に `remote_control.wanted` として残す。
32
+ 3. 付けて立てるのに失敗した時(終了 code が 0 でない、裏の会話の ID が返らない)は、付けずに1回だけ立て直す。引き継ぎは止めない。
33
+ 後継が立っていない事が分かっている時だけ立て直すので、後継は2つにならない。
34
+ 4. 結果を記録の `remote_control.state`(`requested`: 付けて立てた、`unavailable`: 付けずに立て直した)に残し、
35
+ `auto-handoff status --host claude --json` の `remote_control_state` で読める。付けて立てた後に、Claude Code がリモートへ
36
+ つなげたかまでは見ない。
37
+ 5. 環境変数 `THROUGHLINE_AUTO_HANDOFF_REMOTE_CONTROL` で上書きできる。`on` は出どころに関係なく付け、`off` は付けない。
38
+ 6. 旧い会話を止めた理由の文に、後継がリモートコントロール付きで立つ事を書く。
39
+ 7. 後継がターンを終えた時に Desktop へ移して開く動き(ADR 0043)は変えない。
40
+
41
+ ## Consequences
42
+
43
+ - Desktop で作業している利用者は、後継が作業している最中も、claude.ai/code と Claude のアプリから様子を見て操作できる。
44
+ - リモートコントロールを付けた会話は、利用者のアカウントでログインした端末から操作できる状態になる。
45
+ - claude.ai/code とアプリの一覧に、Desktop から始まった会話の後継が並ぶ。
46
+ - すでに動いている後継には付かない。次の引き継ぎから付く。
47
+ - リモートコントロールは Claude Code の機能なので、Codex・Grok・Cursor には当てはまらない。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.16.14",
3
+ "version": "0.16.16",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -61,6 +61,8 @@ const PER_SESSION_ENV = ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_ME
61
61
  const DESKTOP_ENTRYPOINT = 'claude-desktop';
62
62
  // 後継を Desktop で開くかを、会話の出どころに関係なく決める(`desktop` で必ず開く、`off` で開かない)。
63
63
  const OPEN_OVERRIDE_ENV = 'THROUGHLINE_AUTO_HANDOFF_OPEN';
64
+ // 後継をリモートコントロール付きで立てるかの上書き。`on` は出どころに関係なく付け、`off` は付けない。
65
+ const REMOTE_CONTROL_OVERRIDE_ENV = 'THROUGHLINE_AUTO_HANDOFF_REMOTE_CONTROL';
64
66
 
65
67
  // requested と failed は、次の道具の hook が後継の立ち上げを始められる。
66
68
  const RETRYABLE_STATES = new Set(['requested', 'failed']);
@@ -145,6 +147,9 @@ export function publicClaudeAutoHandoff(record) {
145
147
  // 後継を Claude Desktop で開く引き継ぎか、開けたか(null は対象外)。
146
148
  desktop_state: record.desktop?.wanted ? (record.desktop.state ?? 'waiting') : null,
147
149
  desktop_error_code: record.desktop?.error_code ?? null,
150
+ // 後継をリモートコントロール付きで立てる引き継ぎか(null は対象外)。requested は指定を付けて立てた、unavailable は付けずに立て直した。
151
+ // 付けて立てた後に、Claude Code がリモートへつなげたかまでは見ていない。
152
+ remote_control_state: record.remote_control?.wanted ? (record.remote_control.state ?? 'waiting') : null,
148
153
  requested_at, updated_at };
149
154
  }
150
155
 
@@ -220,6 +225,19 @@ function openInDesktopWanted({ sessionId, transcriptPath, env, dir, platform })
220
225
  return listClaudeAutoHandoffs({ dir }).some(record => record.successor?.session_id === sessionId && record.desktop?.wanted);
221
226
  }
222
227
 
228
+ /**
229
+ * この会話の後継を、リモートコントロール付きで立てるか(ADR 0048)。
230
+ * Claude Desktop の画面から始まった会話と、その後継がさらに引き継ぐ時だけ付ける。後継は裏の会話で、ターンを終えるまで
231
+ * Desktop の一覧に出ない。リモートコントロールがあれば、作業の最中も claude.ai/code と Claude のアプリから見られる。
232
+ * 端末から始めた会話の後継は `claude agents`・`claude attach` で見られるので付けない。
233
+ */
234
+ function remoteControlWanted({ sessionId, transcriptPath, env, dir }) {
235
+ if (env[REMOTE_CONTROL_OVERRIDE_ENV] === 'off') return false;
236
+ if (env[REMOTE_CONTROL_OVERRIDE_ENV] === 'on') return true;
237
+ if (env.CLAUDE_CODE_ENTRYPOINT === DESKTOP_ENTRYPOINT || readLatestEntrypoint(transcriptPath) === DESKTOP_ENTRYPOINT) return true;
238
+ return listClaudeAutoHandoffs({ dir }).some(record => record.successor?.session_id === sessionId && record.remote_control?.wanted);
239
+ }
240
+
223
241
  /**
224
242
  * 会話を起動した project の場所を、その OS の書き方にそろえる。
225
243
  *
@@ -308,6 +326,7 @@ export function requestClaudeAutoHandoff({
308
326
  accepted_at: null,
309
327
  desktop: { wanted: openInDesktopWanted({ sessionId, transcriptPath: payload.transcript_path, env, dir, platform }),
310
328
  state: null, error_code: null, opened_at: null },
329
+ remote_control: { wanted: remoteControlWanted({ sessionId, transcriptPath: payload.transcript_path, env, dir }), state: null },
311
330
  });
312
331
  return { status: 'requested', block: true, sessionId, projectPath, handoffId };
313
332
  }
@@ -489,7 +508,8 @@ function stopOutput(record) {
489
508
  ? `この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: ${record.handoff_id})。続きは後継の会話で行ってください: claude attach ${record.successor.short_id}`
490
509
  : `Throughlineが、自動圧縮の代わりに新しい会話へ引き継ぎます(引き継ぎID: ${record.handoff_id})。` +
491
510
  (record.desktop?.wanted
492
- ? '後継の会話は裏で作業を続け、そのターンを終えた時にClaude Desktopへ開きます。途中の様子は `claude agents` の一覧で見られます。'
511
+ ? '後継の会話は裏で作業を続け、そのターンを終えた時にClaude Desktopへ開きます。途中の様子は `claude agents` の一覧で見られます。' +
512
+ (record.remote_control?.wanted ? '後継はリモートコントロール付きで立てるので、claude.ai/code とClaudeのアプリからも見られます。' : '')
493
513
  : '後継の会話は `claude agents` の一覧に出ます。');
494
514
  return {
495
515
  continue: false,
@@ -605,9 +625,12 @@ function successorName(record) {
605
625
  titles: [record.transcript_path ? readSessionTitle(record.transcript_path) : null, record.in_flight?.user?.content] });
606
626
  }
607
627
 
608
- function successorArgs(record) {
628
+ function successorArgs(record, { remoteControl = false } = {}) {
609
629
  // 名前は `--name=` の形で渡す。project のフォルダ名が `-` で始まっても、option として読まれない。
610
- const args = ['--bg', `--name=${successorName(record)}`];
630
+ const name = successorName(record);
631
+ const args = ['--bg', `--name=${name}`];
632
+ // リモートコントロールの名前も `=` の形で渡す(名前を省ける option なので、離して書くと次の引数を名前として読む)。
633
+ if (remoteControl) args.push(`--remote-control=${name}`);
611
634
  const { model, effort, permission_mode: permissionMode } = record.settings ?? {};
612
635
  if (model) args.push('--model', model);
613
636
  if (effort) args.push('--effort', effort);
@@ -656,13 +679,27 @@ export async function runClaudeAutoHandoffWorker(sessionId, {
656
679
  settings: { ...record.settings, model: record.transcript_path ? readLatestModel(record.transcript_path) : null },
657
680
  in_flight: inFlight,
658
681
  });
659
- const launched = spawn('claude', successorArgs(record), {
682
+ const launch = remoteControl => spawn('claude', successorArgs(record, { remoteControl }), {
660
683
  cwd: record.project_path, env: successorEnv(env), encoding: 'utf8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'],
661
684
  });
685
+ const backgroundedId = result => BACKGROUNDED_PATTERN.exec(normalizeTerminalText(`${result.stdout ?? ''}`))?.[1];
686
+ let launched = launch(Boolean(record.remote_control?.wanted));
687
+ if (record.remote_control?.wanted) {
688
+ if (!launched.error && launched.status === 0 && backgroundedId(launched)) {
689
+ update({ remote_control: { ...record.remote_control, state: 'requested' } });
690
+ } else if (!launched.error && !backgroundedId(launched)) {
691
+ // リモートコントロールを付けると立てられない(未ログイン、古い Claude Code など)。後継は立っていないので、
692
+ // 付けずに立て直す。引き継ぎは止めない。
693
+ process.stderr.write(`[auto-handoff] claude --bg --remote-control exit=${launched.status} ` +
694
+ `stderr=${JSON.stringify(`${launched.stderr ?? ''}`.slice(0, 300))}; retrying without remote control\n`);
695
+ update({ remote_control: { ...record.remote_control, state: 'unavailable' } });
696
+ launched = launch(false);
697
+ }
698
+ }
662
699
  if (launched.error) {
663
700
  return fail(launched.error.code === 'ENOENT' ? 'handoff_claude_cli_unavailable' : 'handoff_successor_launch_failed');
664
701
  }
665
- const shortId = BACKGROUNDED_PATTERN.exec(normalizeTerminalText(`${launched.stdout ?? ''}`))?.[1];
702
+ const shortId = backgroundedId(launched);
666
703
  if (launched.status !== 0 || !shortId) {
667
704
  // 理由を端末内の worker のログに残す(指示や記憶は含まれない、起動コマンドの出力だけ)。
668
705
  process.stderr.write(`[auto-handoff] claude --bg exit=${launched.status} stdout=${JSON.stringify(`${launched.stdout ?? ''}`.slice(0, 500))} ` +
@@ -639,7 +639,83 @@ test('PreToolUse: Desktop で開く引き継ぎは、止めた理由に、後継
639
639
  writeFileSync(desktopTranscript, jsonl([{ ...user('読んで', '2026-10-08T12:00:00.000Z'), entrypoint: 'claude-desktop' }]));
640
640
  request(dir, { platform: 'darwin', transcriptPath: desktopTranscript });
641
641
  const output = await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app', tool_use_id: 't1' }, dir, launchWorker: async () => 1 });
642
- assert.match(output.stopReason, /そのターンを終えた時にClaude Desktopへ開きます。途中の様子は `claude agents` の一覧で見られます。$/);
642
+ assert.match(output.stopReason, /そのターンを終えた時にClaude Desktopへ開きます。途中の様子は `claude agents` の一覧で見られます。後継はリモートコントロール付きで立てるので、claude\.ai\/code とClaudeのアプリからも見られます。$/);
643
+ }));
644
+
645
+ test('PreCompact: Claude Desktop から始まった会話と、その後継の引き継ぎだけ、後継をリモートコントロール付きで立てる印を付ける(OS に関係なく)', () => withDir(dir => {
646
+ const wanted = overrides => {
647
+ rmSync(join(dir, `${SESSION}.json`), { force: true });
648
+ request(dir, overrides);
649
+ return readClaudeAutoHandoff(SESSION, dir).remote_control;
650
+ };
651
+ const desktopTranscript = join(dir, 'desktop.jsonl');
652
+ writeFileSync(desktopTranscript, jsonl([{ ...user('読んで', '2026-10-10T05:00:00.000Z'), entrypoint: 'claude-desktop' }]));
653
+ const cliTranscript = join(dir, 'cli.jsonl');
654
+ writeFileSync(cliTranscript, jsonl([{ ...user('読んで', '2026-10-10T05:00:00.000Z'), entrypoint: 'cli' }]));
655
+ for (const platform of ['darwin', 'win32', 'linux']) {
656
+ const payload = platform === 'win32' ? { cwd: 'C:\\work\\app' } : undefined;
657
+ assert.deepEqual(wanted({ platform, payload, transcriptPath: desktopTranscript }), { wanted: true, state: null }, platform);
658
+ assert.equal(wanted({ platform, payload, env: { CLAUDE_CODE_ENTRYPOINT: 'claude-desktop' } }).wanted, true, platform);
659
+ assert.equal(wanted({ platform, payload, transcriptPath: cliTranscript }).wanted, false, `${platform}: 端末から始めた会話の後継には付けない`);
660
+ assert.equal(wanted({ platform, payload }).wanted, false, platform);
661
+ assert.equal(wanted({ platform, payload, transcriptPath: cliTranscript, env: { THROUGHLINE_AUTO_HANDOFF_REMOTE_CONTROL: 'on' } }).wanted, true, platform);
662
+ assert.equal(wanted({ platform, payload, transcriptPath: desktopTranscript, env: { THROUGHLINE_AUTO_HANDOFF_REMOTE_CONTROL: 'off' } }).wanted, false, platform);
663
+ }
664
+ // 裏で作業している後継がさらに引き継ぐ時は、元の会話の出どころを引き継ぐ(後継の transcript は cli)。
665
+ writeRecord(dir, 'origin-desktop', { successor: { short_id: SESSION.slice(0, 8), session_id: SESSION }, remote_control: { wanted: true, state: 'requested' } });
666
+ assert.equal(wanted({ platform: 'darwin', transcriptPath: cliTranscript }).wanted, true);
667
+ writeRecord(dir, 'origin-desktop', { successor: { short_id: SESSION.slice(0, 8), session_id: SESSION }, remote_control: { wanted: false, state: null } });
668
+ assert.equal(wanted({ platform: 'darwin', transcriptPath: cliTranscript }).wanted, false);
669
+ }));
670
+
671
+ test('worker: 印のある引き継ぎは、後継を --remote-control=<後継の名前> 付きで立てる。印の無い引き継ぎには付けない', () => withDir(async dir => {
672
+ const run = async (sessionId, env) => {
673
+ const transcriptPath = join(dir, `${sessionId}.jsonl`);
674
+ writeFileSync(transcriptPath, jsonl([{ ...user('14個のファイルを順に読んで', '2026-10-10T05:00:10Z'), entrypoint: env.CLAUDE_CODE_ENTRYPOINT ?? 'cli' },
675
+ { type: 'custom-title', customTitle: 'マビノギ BOT 開発' }, assistant('part04 を読みました。', '2026-10-10T05:00:12Z')]));
676
+ const db = makeBatonDb();
677
+ requestClaudeAutoHandoff({ payload: { session_id: sessionId, trigger: 'auto', cwd: '/work/OpenLogicool', transcript_path: transcriptPath },
678
+ env, config: ENABLED, openDb: () => db, dir, platform: 'linux' });
679
+ await stopClaudeTurnForHandoff({ payload: { session_id: sessionId, cwd: '/work/OpenLogicool', transcript_path: transcriptPath },
680
+ dir, launchWorker: async () => {} });
681
+ const launches = [];
682
+ const record = await runClaudeAutoHandoffWorker(sessionId, { dir, env: {}, pollMs: 10, acceptTimeoutMs: 300, transcriptTimeoutMs: 50,
683
+ spawn: (command, args) => { launches.push(args); successorStarts(dir, sessionId)(args); return launchedOk(); } });
684
+ return { record, launches };
685
+ };
686
+ const desktop = await run('bbbbbbbb-0000-4000-8000-000000000001', { CLAUDE_CODE_ENTRYPOINT: 'claude-desktop' });
687
+ assert.equal(desktop.launches.length, 1);
688
+ assert.deepEqual(desktop.launches[0].slice(0, 3), ['--bg', '--name=OpenLogicool|マビノギ BOT 開発(自動引き継ぎ)', '--remote-control=OpenLogicool|マビノギ BOT 開発(自動引き継ぎ)']);
689
+ assert.equal(desktop.record.state, 'sent');
690
+ assert.deepEqual(desktop.record.remote_control, { wanted: true, state: 'requested' });
691
+ assert.equal(publicClaudeAutoHandoff(desktop.record).remote_control_state, 'requested');
692
+
693
+ const terminal = await run('bbbbbbbb-0000-4000-8000-000000000002', {});
694
+ assert.ok(!terminal.launches[0].some(arg => arg.startsWith('--remote-control')));
695
+ assert.deepEqual(terminal.record.remote_control, { wanted: false, state: null });
696
+ assert.equal(publicClaudeAutoHandoff(terminal.record).remote_control_state, null);
697
+ }));
698
+
699
+ test('worker: リモートコントロールを付けると立てられない時は、付けずに立て直す。引き継ぎは止めない', () => withDir(async dir => {
700
+ const sessionId = 'cccccccc-0000-4000-8000-000000000001';
701
+ const transcriptPath = join(dir, `${sessionId}.jsonl`);
702
+ writeFileSync(transcriptPath, jsonl([{ ...user('読んで', '2026-10-10T05:00:10Z'), entrypoint: 'claude-desktop' }, assistant('読みました。', '2026-10-10T05:00:12Z')]));
703
+ const db = makeBatonDb();
704
+ requestClaudeAutoHandoff({ payload: { session_id: sessionId, trigger: 'auto', cwd: '/work/app', transcript_path: transcriptPath },
705
+ env: {}, config: ENABLED, openDb: () => db, dir, platform: 'darwin' });
706
+ await stopClaudeTurnForHandoff({ payload: { session_id: sessionId, cwd: '/work/app', transcript_path: transcriptPath }, dir, launchWorker: async () => {} });
707
+ const launches = [];
708
+ const record = await runClaudeAutoHandoffWorker(sessionId, { dir, env: {}, pollMs: 10, acceptTimeoutMs: 300, transcriptTimeoutMs: 50,
709
+ spawn: (command, args) => {
710
+ launches.push(args.some(arg => arg.startsWith('--remote-control')));
711
+ if (launches.length === 1) return { status: 1, stdout: '', stderr: 'Remote Control requires a claude.ai login' };
712
+ successorStarts(dir, sessionId)(args); return launchedOk();
713
+ } });
714
+ assert.deepEqual(launches, [true, false], '1回目は付けて、2回目は付けずに立てる');
715
+ assert.equal(record.state, 'sent'); assert.equal(record.error_code, null);
716
+ assert.deepEqual(record.remote_control, { wanted: true, state: 'unavailable' });
717
+ assert.equal(publicClaudeAutoHandoff(record).remote_control_state, 'unavailable');
718
+ assert.equal(publicClaudeAutoHandoff(record).desktop_state, 'waiting', 'Desktop へ開く印は変わらない');
643
719
  }));
644
720
 
645
721
  test('Stop: Desktop で開く引き継ぎの後継がターンを終えたら、移す process を1回だけ起動する', () => withDir(async dir => {
@@ -5,7 +5,7 @@ import { getDb, openReadOnlyDb, DB_PATH, CURRENT_VERSION } from '../db.mjs';
5
5
  import { listAutoHandoffs, getAutoHandoff } from '../codex-auto-handoff-store.mjs';
6
6
  import { renderFrozenDetail } from '../codex-auto-handoff-memory.mjs';
7
7
  import { readAutoHandoffConfig, writeAutoHandoffConfig } from '../codex-auto-handoff-config.mjs';
8
- import { autoHandoffDeliveryProfile, runAutoHandoffWorker, markAutoHandoffSources } from '../codex-auto-handoff.mjs';
8
+ import { autoHandoffDeliveryProfile, runAutoHandoffWorker, markAutoHandoffSources, resolveAutoHandoffSuccessor } from '../codex-auto-handoff.mjs';
9
9
  import { installCodexAutoHandoffHook, installClaudeAutoHandoffHooks, removeClaudeAutoHandoffHooks,
10
10
  claudeAutoHandoffHooksRegistered } from './install.mjs';
11
11
  import { sameProjectPath } from '../project-path.mjs';
@@ -17,17 +17,17 @@ import { listClaudeAutoHandoffs, publicClaudeAutoHandoff, runClaudeAutoHandoffWo
17
17
  const CLAUDE_ACTIONS = ['enable', 'disable', 'status', 'worker', 'desktop-open'];
18
18
 
19
19
  export function parseAutoHandoffArgs(args) {
20
- const out = { action: args[0] ?? 'status', host: 'codex', operation: null, project: null, origin: null, turn: null, database: null, json: false, dryRun: false };
21
- if (!['enable', 'disable', 'status', 'resume', 'detail', 'worker', 'desktop-open', 'mark-sources'].includes(out.action)) throw Error('auto_handoff_action_invalid');
20
+ const out = { action: args[0] ?? 'status', host: 'codex', operation: null, project: null, origin: null, turn: null, database: null, thread: null, json: false, dryRun: false };
21
+ if (!['enable', 'disable', 'status', 'resume', 'detail', 'worker', 'desktop-open', 'mark-sources', 'successor'].includes(out.action)) throw Error('auto_handoff_action_invalid');
22
22
  const allowed = ['host', ...{ enable: ['project'], disable: [], status: ['project', 'operation'],
23
- resume: ['operation'], worker: ['operation'], 'desktop-open': ['operation'], 'mark-sources': [], detail: ['operation', 'origin', 'turn', 'database'] }[out.action]];
23
+ resume: ['operation'], worker: ['operation'], 'desktop-open': ['operation'], 'mark-sources': [], successor: ['thread'], detail: ['operation', 'origin', 'turn', 'database'] }[out.action]];
24
24
  const seen = new Set();
25
25
  for (let i = 1; i < args.length; i++) {
26
26
  if (seen.has(args[i])) throw Error('auto_handoff_argument_invalid');
27
27
  seen.add(args[i]);
28
28
  if (args[i] === '--json') { out.json = true; continue; }
29
29
  if (args[i] === '--dry-run' && out.action === 'mark-sources') { out.dryRun = true; continue; }
30
- const key = { '--host': 'host', '--operation': 'operation', '--project': 'project', '--origin': 'origin', '--turn': 'turn', '--database': 'database' }[args[i]];
30
+ const key = { '--host': 'host', '--operation': 'operation', '--project': 'project', '--origin': 'origin', '--turn': 'turn', '--database': 'database', '--thread': 'thread' }[args[i]];
31
31
  if (!key || !allowed.includes(key) || !args[i + 1] || args[i + 1].startsWith('--')) throw Error('auto_handoff_argument_invalid');
32
32
  out[key] = args[++i];
33
33
  }
@@ -36,6 +36,7 @@ export function parseAutoHandoffArgs(args) {
36
36
  if (out.project && !isAbsolute(out.project)) out.project = resolve(out.project);
37
37
  if (out.project && out.operation) throw Error('auto_handoff_argument_invalid');
38
38
  if (['worker', 'resume', 'detail', 'desktop-open'].includes(out.action) && !out.operation) throw Error('auto_handoff_operation_required');
39
+ if (out.action === 'successor' && !out.thread) throw Error('auto_handoff_thread_required');
39
40
  if (out.action === 'detail') {
40
41
  out.turn = Number(out.turn);
41
42
  if (!out.origin || !Number.isSafeInteger(out.turn) || out.turn < 1) throw Error('auto_handoff_detail_identity_required');
@@ -128,6 +129,14 @@ export async function run(args = []) {
128
129
  finally { db.close(); }
129
130
  process.stdout.write(parsed.json ? JSON.stringify({ status: 'ok', text }) + '\n' : text + '\n');
130
131
  return 0;
132
+ } else if (parsed.action === 'successor') {
133
+ // あるタスクの今の続きを返す。読むだけ。DB がまだ無い端末では、引き継ぎが1つも無いのと同じ答えにする。
134
+ const db = existsSync(DB_PATH) ? openReadOnlyDb() : null;
135
+ try {
136
+ if (db && db.prepare('PRAGMA user_version').get().user_version !== CURRENT_VERSION) throw Error('auto_handoff_schema_mismatch');
137
+ result = { schema: 'throughline.codex_auto_handoff_successor.v1', ...(db ? resolveAutoHandoffSuccessor(db, parsed.thread)
138
+ : { thread_id: parsed.thread, handed_off: false, current_thread_id: parsed.thread, chain: [], pending: null }) };
139
+ } finally { db?.close(); }
131
140
  } else if (parsed.action === 'mark-sources') {
132
141
  // 0.16.9 までの版が引き継いだ分の名前を付け直す(名前の無い後継へ名前、旧タスクへ引き継ぎ済みの印)。何度流しても同じ結果になる。
133
142
  const db = getDb();
@@ -301,6 +301,39 @@ export async function nameAutoHandoffTarget(request, targetId, name) {
301
301
  }
302
302
  }
303
303
 
304
+ /**
305
+ * あるタスクの「今の続き」を引く(ほかの製品が、旧タスクあての連絡を今の続きへ渡すための入口)。読むだけ。
306
+ * 引き継ぎを先までたどり、残っている一番先の後継を返す。消された後継は無い物として扱う。
307
+ * @returns {{
308
+ * thread_id: string, handed_off: boolean, current_thread_id: string,
309
+ * chain: Array<{handoff_id: string, source_thread_id: string, target_thread_id: string, state: string, created_at: number, updated_at: number}>,
310
+ * pending: null | {handoff_id: string, source_thread_id: string, state: string, error_code: string|null, resume_state: string|null,
311
+ * in_flight: boolean, created_at: number, updated_at: number}
312
+ * }} current_thread_id は、後継が無ければ thread_id 自身。pending は、今の続きのタスクで、後継へ指示を送る前の引き継ぎ
313
+ * (一番新しい1つ)。in_flight が true なら worker が動いている最中、false なら止まっている(error_code と resume_state を見る)。
314
+ */
315
+ export function resolveAutoHandoffSuccessor(db, threadId, { findThread = findCodexThreadCandidate,
316
+ processes = readRuntimeProcesses, now = Date.now() } = {}) {
317
+ if (!CODEX_NATIVE_ID_PATTERN.test(threadId ?? '')) throw error('handoff_thread_id_invalid');
318
+ // 残っている一番先の後継(旧タスクへ入力が来た時に開く物と同じ)。そこから threadId まで、作った引き継ぎをさかのぼる。
319
+ const leaf = findLiveAutoHandoffSuccessor(db, threadId, { findThread });
320
+ const path = [];
321
+ for (let operation = leaf, depth = 0; operation && depth < 200; depth++) {
322
+ path.unshift(operation);
323
+ if (operation.source_thread_id === threadId) break;
324
+ operation = findAutoHandoffForTarget(db, operation.source_thread_id);
325
+ }
326
+ const chain = path.map(operation => ({ handoff_id: operation.handoff_id, source_thread_id: operation.source_thread_id,
327
+ target_thread_id: operation.target_thread_id, state: operation.state, created_at: operation.created_at, updated_at: operation.updated_at }));
328
+ const current = leaf ? leaf.target_thread_id : threadId;
329
+ const undelivered = listUndeliveredAutoHandoffsForSource(db, current)[0] ?? null;
330
+ const pending = undelivered ? { handoff_id: undelivered.handoff_id, source_thread_id: undelivered.source_thread_id,
331
+ state: undelivered.state, error_code: undelivered.error_code ?? null, resume_state: undelivered.resume_state ?? null,
332
+ in_flight: !HANDOFF_TERMINAL_STATES.has(undelivered.state) && workerActive(undelivered, processes, now),
333
+ created_at: undelivered.created_at, updated_at: undelivered.updated_at } : null;
334
+ return { thread_id: threadId, handed_off: chain.length > 0, current_thread_id: current, chain, pending };
335
+ }
336
+
304
337
  /**
305
338
  * 引き継ぎが済んだ旧タスクの名前の頭へ、引き継ぎ済みの印を付ける。同じ名前の後継が一覧に並んでも、
306
339
  * 印の無い1本が今の続きだと分かるようにする。今の名前を読んでから付けるので、利用者が付け直した名前も残る。
@@ -15,7 +15,7 @@ import { readCodexHandoffState, settingsMatch, threadStartSettings, resolvePrepa
15
15
  import { runAutoHandoffWorker, requestCodexAutoHandoff, sourceBoundary,
16
16
  autoHandoffTargetName, nameAutoHandoffTarget, findLiveAutoHandoffSuccessor,
17
17
  markAutoHandoffSource, markAutoHandoffSources, autoHandoffSourceFallbackTitle, carryAutoHandoffQueue,
18
- untrackedInheritedMemory } from './codex-auto-handoff.mjs';
18
+ untrackedInheritedMemory, resolveAutoHandoffSuccessor } from './codex-auto-handoff.mjs';
19
19
  import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
20
20
 
21
21
  async function withDb(fn) {
@@ -287,6 +287,58 @@ test('PreCompactの重複ではworkerを増やさず、manualと別projectでは
287
287
  assert.equal((await requestCodexAutoHandoff({ ...args, config: { ...config, projects: ['/other'] } })).status, 'skipped');
288
288
  }));
289
289
 
290
+ test('タスクの今の続きを引く。先までたどり、消えた後継は無い物として扱い、途中の引き継ぎと止まった引き継ぎを見分ける', async () => withDb(async db => {
291
+ const id = n => `${String(n).repeat(8)}-${String(n).repeat(4)}-${String(n).repeat(4)}-${String(n).repeat(4)}-${String(n).repeat(12)}`;
292
+ const [A, B, C, D, X, Y, Z] = [1, 2, 3, 4, 5, 6, 7].map(id);
293
+ const done = (source, target) => updateAutoHandoff(db, request(db, source).operation.handoff_id,
294
+ { state: 'continued', target_thread_id: target, queued_submission_id: `receipt-${target}` }).operation;
295
+ const gone = new Set();
296
+ const options = { findThread: ({ threadId }) => gone.has(threadId) ? null : { rolloutPath: `/r/${threadId}`, mtimeMs: 1 }, processes: () => [], now: 1_000_000 };
297
+ // 引き継ぎの無いタスクは、自分が今の続き
298
+ assert.deepEqual(resolveAutoHandoffSuccessor(db, X, options), { thread_id: X, handed_off: false, current_thread_id: X, chain: [], pending: null });
299
+
300
+ const ab = done(A, B); const bc = done(B, C);
301
+ const fromA = resolveAutoHandoffSuccessor(db, A, options);
302
+ assert.equal(fromA.handed_off, true); assert.equal(fromA.current_thread_id, C); assert.equal(fromA.pending, null);
303
+ assert.deepEqual(fromA.chain.map(item => [item.handoff_id, item.source_thread_id, item.target_thread_id, item.state]),
304
+ [[ab.handoff_id, A, B, 'continued'], [bc.handoff_id, B, C, 'continued']]);
305
+ assert.equal(resolveAutoHandoffSuccessor(db, B, options).current_thread_id, C);
306
+ assert.deepEqual(resolveAutoHandoffSuccessor(db, B, options).chain.map(item => item.handoff_id), [bc.handoff_id]);
307
+ assert.equal(resolveAutoHandoffSuccessor(db, C, options).handed_off, false);
308
+
309
+ // 今の続き C が、さらに引き継いでいる最中(worker が動いている)
310
+ const cd = updateAutoHandoff(db, request(db, C).operation.handoff_id, { state: 'memory_ready' }).operation;
311
+ db.prepare('UPDATE codex_handoffs SET worker_identity_json = ?, updated_at = ? WHERE handoff_id = ?').run(JSON.stringify({ pid: 4242, started_identity: 'w' }), 999_000, cd.handoff_id);
312
+ const running = resolveAutoHandoffSuccessor(db, A, { ...options, processes: () => [{ pid: 4242, started_identity: 'w' }] });
313
+ assert.equal(running.current_thread_id, C); assert.equal(running.pending.handoff_id, cd.handoff_id);
314
+ assert.equal(running.pending.in_flight, true); assert.equal(running.pending.state, 'memory_ready');
315
+ // worker が居なくなっていれば、途中で止まった物として返す
316
+ assert.equal(resolveAutoHandoffSuccessor(db, A, options).pending.in_flight, false);
317
+
318
+ // C の引き継ぎが失敗して止まった
319
+ updateAutoHandoff(db, cd.handoff_id, { state: 'failed', error_code: 'handoff_summarizer_backend_failed', resume_state: 'source_stopped' });
320
+ const stopped = resolveAutoHandoffSuccessor(db, A, options);
321
+ assert.equal(stopped.current_thread_id, C);
322
+ assert.deepEqual([stopped.pending.state, stopped.pending.error_code, stopped.pending.resume_state, stopped.pending.in_flight],
323
+ ['failed', 'handoff_summarizer_backend_failed', 'source_stopped', false]);
324
+
325
+ // やり直して D へ続いた。C が消されても、D が残っていれば D が今の続き
326
+ updateAutoHandoff(db, cd.handoff_id, { state: 'continued', error_code: null, resume_state: null, target_thread_id: D, queued_submission_id: 'receipt-D' });
327
+ gone.add(C);
328
+ const toD = resolveAutoHandoffSuccessor(db, A, options);
329
+ assert.equal(toD.current_thread_id, D); assert.equal(toD.chain.length, 3); assert.equal(toD.pending, null);
330
+ // 後継が全部消されたら、旧タスクが今の続きに戻る
331
+ gone.add(D); gone.add(B);
332
+ assert.deepEqual(resolveAutoHandoffSuccessor(db, A, options), { thread_id: A, handed_off: false, current_thread_id: A, chain: [], pending: null });
333
+
334
+ // 後継が出来る前に失敗したタスク(後継なし)
335
+ updateAutoHandoff(db, request(db, Y).operation.handoff_id, { state: 'failed', error_code: 'handoff_target_not_loaded', resume_state: 'memory_ready' });
336
+ const failed = resolveAutoHandoffSuccessor(db, Y, options);
337
+ assert.equal(failed.handed_off, false); assert.equal(failed.current_thread_id, Y); assert.equal(failed.pending.error_code, 'handoff_target_not_loaded');
338
+ assert.throws(() => resolveAutoHandoffSuccessor(db, 'not-a-thread', options), /handoff_thread_id_invalid/);
339
+ void Z;
340
+ }));
341
+
290
342
  test('Codexの内部の文脈(auto-compact-N)からの圧縮は、例外にせず止めて記録する。引き継ぎは作らない', async () => withDb(async (db, home) => {
291
343
  const thread = '11111111-1111-1111-1111-111111111111';
292
344
  const file = join(home, 'rollout.jsonl'); writeFileSync(file, '');