throughline 0.16.32 → 0.16.33

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,27 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.16.33] — 2026-10-14
14
+
15
+ ### 追加
16
+
17
+ - **Claude Codeの自動継続で、後継の会話が止まっている時に旧い会話へ人が文を打ったら、後継を起こしてその文を最初の入力として渡す**([ADR 0064](docs/adr/0064-claude-stopped-successor-is-woken-by-human-input.md))。0.16.32は、止まっている後継(`claude stop`・Claude Codeの片付け)を起こさず、入力は後継を開いて何か入力した時に届いた。
18
+ - 起こすのは、人が打った文の時だけ。完了通知とほかの会話からの文では起こさない(今までどおり置き場で待つ)。
19
+ - 起こすのは、止まっていると確かめられた時だけ。後継で待つhookが生きていない、`claude agents --json --all`の後継の項目に`pid`・`status`を持つ物が無く裏の項目の`state`が`done`か`stopped`、後継のtranscriptが在って5秒より前に書かれた、Claude Desktopへ移した後継でない、後継に引き継ぎが進んでいない、文が12,000字以下。どれか1つでも確かめられなければ、0.16.32と同じに置き場で待つ。
20
+ - 起こす命令は`claude --bg --resume <後継の会話ID> -- <文>`。optionは付けない(裏の会話は立てた時のoptionを持っていて、optionなしの時だけ同じ会話のまま起きる。モデル・effort・権限のmode・名前はそのまま)。文は利用者の発言として後継へ入る。旧い会話の会話ごとの環境変数は渡さない。
21
+ - 起こす番は1つだけ(後継の置き場の印`waking`。起こせた後も60秒残す)。その間に届いた入力は、起きた後継のhookが受け取る。
22
+ - 写しが立った時(確かめた後に後継が動き出した)は、写しをすぐ`claude stop`・`claude rm`で止めて消し、文は置き場へ戻す。起こす命令が失敗した時も置き場へ戻す。起きたか分からない時は入れ直さない。
23
+ - 旧い会話へ返す文: 起きた時は「後継の会話が止まっていたので、Throughlineが起こして、この入力を後継の最初の入力として渡しました」。起こしている所・動いていた・確かめられなかった・分からない時は、それぞれの文と後継を開く命令。
24
+ - 運んだ記録(`forward.jsonl`)に`woken`・`wake-skipped`・`wake-copy`・`wake-failed`・`wake-unknown`、止めた記録(`redirects.jsonl`)と判定の記録に`wake`を足した。本文は書かない。
25
+ - `THROUGHLINE_AUTO_HANDOFF_WAKE=off`で起こさない(0.16.32の動き)。
26
+
27
+ ### 変わる動き
28
+
29
+ - 旧い会話へ人が打った文のhookが、後継が止まって見える時だけ`claude agents`と起こす命令を流す分、1〜2.5秒長くなる。
30
+ - 起きた後継は、Claude Codeが片付けるまで(手すきで61分)裏で動き、利用枠を使う。片付けられた後で旧い会話へ文を打つたびに、もう一度起きる。
31
+ - `claude rm`で一覧から消した後継と、12,000字を超える文では起こさない。
32
+ - 続けて打った2通めからは、起きた後継のhookの道(通知・文脈の形)で届く。その分は0.16.32と同じに、後継のモデルが従わない事がある。
33
+
13
34
  ## [0.16.32] — 2026-10-14
14
35
 
15
36
  ### 追加
@@ -2634,7 +2655,8 @@ two attempts, instrument first instead of patching again.
2634
2655
 
2635
2656
  ---
2636
2657
 
2637
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.32...HEAD
2658
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.33...HEAD
2659
+ [0.16.33]: https://github.com/kitepon/Throughline/compare/v0.16.32...v0.16.33
2638
2660
  [0.16.32]: https://github.com/kitepon/Throughline/compare/v0.16.31...v0.16.32
2639
2661
  [0.16.31]: https://github.com/kitepon/Throughline/compare/v0.16.30...v0.16.31
2640
2662
  [0.16.30]: https://github.com/kitepon/Throughline/compare/v0.16.29...v0.16.30
package/README.ja.md CHANGED
@@ -77,7 +77,7 @@ throughline auto-handoff status --host claude --operation <handoff-id> --json
77
77
 
78
78
  後継の立ち上げや配送に失敗した時は、固定の理由を記録して止まります。結果が不明な指示は再送しません。圧縮を止めた後にモデルが道具を呼ばずにターンを終えた時は、後継を立てません。印は残るので、1時間以内に開いた新しい会話が記憶を引き継ぎます。
79
79
 
80
- 引き継いだ後の旧い会話へ入力が届いた時は、モデルを動かさずに止めて、後継の会話へ届けます([ADR 0062](docs/adr/0062-claude-handed-off-conversation-blocks-input.md)、[ADR 0063](docs/adr/0063-claude-handed-off-input-is-forwarded-to-successor.md))。旧い会話へ打った文と、旧い会話が裏で起こした作業の完了通知が対象です。打った人には「届いた入力は、後継の会話へ渡しました」と後継を開く命令が返ります。手すきの後継はすぐ動き、作業中の後継は次の道具の前に読みます。後継が止まっている時は起こさず、後継を開いて何か入力した時に届きます。後継の画面では、運ばれた入力は利用者の発言の形ではなく、フックからの通知か文脈の形で出ます。届くまでの間、入力の本文は`~/.throughline/claude-auto-handoff/inbox/`に置かれます(本人だけが読める権限。届けたら消します)。
80
+ 引き継いだ後の旧い会話へ入力が届いた時は、モデルを動かさずに止めて、後継の会話へ届けます([ADR 0062](docs/adr/0062-claude-handed-off-conversation-blocks-input.md)、[ADR 0063](docs/adr/0063-claude-handed-off-input-is-forwarded-to-successor.md))。旧い会話へ打った文と、旧い会話が裏で起こした作業の完了通知が対象です。打った人には「届いた入力は、後継の会話へ渡しました」と後継を開く命令が返ります。手すきの後継はすぐ動き、作業中の後継は次の道具の前に読みます。後継が止まっている時(`claude stop`・Claude Codeの片付け)は、旧い会話へ人が打った文の時だけ、後継を起こしてその文を最初の入力として渡します([ADR 0064](docs/adr/0064-claude-stopped-successor-is-woken-by-human-input.md))。止まっていると確かめられない時と、完了通知の時は起こさず、後継を開いて何か入力した時に届きます。後継の画面では、運ばれた入力は利用者の発言の形ではなく、フックからの通知か文脈の形で出ます。届くまでの間、入力の本文は`~/.throughline/claude-auto-handoff/inbox/`に置かれます(本人だけが読める権限。届けたら消します)。
81
81
 
82
82
  有効にした端末では、Claudeの道具の呼び出しのたびに`PreToolUse`フックが1回、番の終わりのたびに`Stop`フックが1回多く走ります(後継でない会話では、すぐ終わります)。Linux・macOS・Windowsの端末から始めた会話と、macOSのClaude Desktopの画面から始めた会話を実機確認済みです。Desktopでは、移動済みの会話の最初の後継への記憶注入と、最初からフォルダを選んだ会話の連続継承・作業完了を確認しました。[実測と検証範囲](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-05-claude-auto-handoff.md)を参照してください。
83
83
 
package/README.md CHANGED
@@ -84,7 +84,7 @@ throughline auto-handoff successor --host claude --thread <claude-session-id> --
84
84
 
85
85
  後継の立ち上げや配送に失敗した時は、固定の理由を記録して止まります。結果が不明な指示は再送しません。圧縮を止めた後にモデルが道具を呼ばずにターンを終えた時は、後継を立てません。印は残るので、1時間以内に開いた新しい会話が記憶を引き継ぎます。
86
86
 
87
- 引き継いだ後の旧い会話へ入力が届いた時は、モデルを動かさずに止めて、後継の会話へ届けます([ADR 0062](docs/adr/0062-claude-handed-off-conversation-blocks-input.md)、[ADR 0063](docs/adr/0063-claude-handed-off-input-is-forwarded-to-successor.md))。旧い会話へ打った文と、旧い会話が裏で起こした作業の完了通知が対象です。打った人には「届いた入力は、後継の会話へ渡しました」と後継を開く命令が返ります。手すきの後継はすぐ動き、作業中の後継は次の道具の前に読みます。後継が止まっている時は起こさず、後継を開いて何か入力した時に届きます。後継の画面では、運ばれた入力は利用者の発言の形ではなく、フックからの通知か文脈の形で出ます。届くまでの間、入力の本文は`~/.throughline/claude-auto-handoff/inbox/`に置かれます(本人だけが読める権限。届けたら消します)。
87
+ 引き継いだ後の旧い会話へ入力が届いた時は、モデルを動かさずに止めて、後継の会話へ届けます([ADR 0062](docs/adr/0062-claude-handed-off-conversation-blocks-input.md)、[ADR 0063](docs/adr/0063-claude-handed-off-input-is-forwarded-to-successor.md))。旧い会話へ打った文と、旧い会話が裏で起こした作業の完了通知が対象です。打った人には「届いた入力は、後継の会話へ渡しました」と後継を開く命令が返ります。手すきの後継はすぐ動き、作業中の後継は次の道具の前に読みます。後継が止まっている時(`claude stop`・Claude Codeの片付け)は、旧い会話へ人が打った文の時だけ、後継を起こしてその文を最初の入力として渡します([ADR 0064](docs/adr/0064-claude-stopped-successor-is-woken-by-human-input.md))。止まっていると確かめられない時と、完了通知の時は起こさず、後継を開いて何か入力した時に届きます。後継の画面では、運ばれた入力は利用者の発言の形ではなく、フックからの通知か文脈の形で出ます。届くまでの間、入力の本文は`~/.throughline/claude-auto-handoff/inbox/`に置かれます(本人だけが読める権限。届けたら消します)。
88
88
 
89
89
  有効にした端末では、Claudeの道具の呼び出しのたびに`PreToolUse`フックが1回、番の終わりのたびに`Stop`フックが1回多く走ります(後継でない会話では、すぐ終わります)。Linux・macOS・Windowsの端末から始めた会話と、macOSのClaude Desktopの画面から始めた会話を実機確認済みです。Desktopでは、移動済みの会話の最初の後継への記憶注入と、最初からフォルダを選んだ会話の連続継承・作業完了を確認しました。[実測と検証範囲](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-05-claude-auto-handoff.md)を参照してください。
90
90
 
@@ -0,0 +1,113 @@
1
+ # ADR 0064: 止まっている後継の会話は、人が旧い会話へ打った文で起こして届ける
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-10-14
5
+
6
+ ## Context
7
+
8
+ ADR 0063 は、引き継ぎ済みの Claude の会話へ届いた入力を、後継の会話へ届ける形にした。後継が止まっている時
9
+ (`claude stop`、Claude Code が手すきの裏の会話を 61 分で片付けた時)は起こさず、入力は置き場で待ち、後継を開いて何か
10
+ 入力した時に届く。後継が片付けられた後で旧い会話へ打った文は、利用者が後継を開くまで届かない。
11
+
12
+ 利用者の決め(2026-10-14、「止まっている後継の会話へ届いた入力を、どう扱うか」)は「人が打った文の時だけ、止まっている
13
+ 後継を自動で起こして届ける。完了通知では起こさない」。その時に示した条件は「動いている会話へ起こす命令を流すと写しが
14
+ 立つので、止まっている事を確かめる道を 3 つの OS で確かめてから出す」。
15
+
16
+ Claude Code 2.1.296 で、次を実機で確かめた(Linux・macOS・Windows の3つとも同じ結果。自分で立てた裏の会話、Haiku。
17
+ 利用者の設定の hook を外し、試しの hook だけを付けた。試しの文は自分で決めた物)。
18
+
19
+ | 見た事 | 結果 |
20
+ |---|---|
21
+ | `claude agents --json --all` の項目(手すきで動いている裏の会話) | `state: done`・`status: idle`・`pid` がある |
22
+ | 同(作業中) | `state: working`・`status: busy`・`pid` がある |
23
+ | 同(手すきの時に `claude stop` で止めた) | `state: done`。`status` と `pid` の鍵が無い |
24
+ | 同(作業の途中で `claude stop` で止めた) | `state: stopped`。`status` と `pid` の鍵が無い |
25
+ | 同(Claude Code が 61 分で片付けた) | `state: done`。`pid` の鍵が無い(Linux。macOS・Windows は公開の後に読む) |
26
+ | 同(コンテナごと止められた) | `state: done`。`status` と `pid` の鍵が無い(Linux) |
27
+ | 同(`claude rm` で消した) | 一覧に出ない。transcript は残る。立てた時の option の控え(`~/.claude/jobs/<頭8字>/state.json`)は消える |
28
+ | 同(止めた裏の会話を、端末の `claude --resume` で開いている・`claude -p --resume` が走っている) | 裏の項目(`pid` なし)とは別に、同じ会話 ID の `kind: interactive` の項目が `pid`・`status` 付きで出る |
29
+ | 同(無い会話 ID へ `--bg --resume` を流した直後) | その ID で新しい会話が立ち、8 秒後も `state: working` で `pid` がまだ無い |
30
+ | 止めた会話へ `claude --bg --resume <ID> "<文>"`(option なし) | 同じ会話 ID のまま起き、文が `origin.kind: human` の利用者の行として入り、モデルが答えた。出力は `note: woke session <頭8字> with its saved options (--name, --permission-mode, --model, …)`。モデル・権限の mode・名前は、立てた時の物のまま。0.6〜1.1 秒で返る |
31
+ | 同(option を付けて流す) | 止めた会話でも写しが立つ。出力は `the flags you passed started a copy as <頭8字>. Without flags, the same command continues <頭8字> itself.` |
32
+ | 動いている会話(手すき・作業中・端末で開いている・`claude -p --resume` が走っている)へ option なしで流す | 写しが立ち、写しが文に答えた。出力は `started a copy as <頭8字>`(`is already running in the background` か `is open in another Claude Code process`)。元の会話は変わらない |
33
+ | 止めた会話へ、起こす命令を同時に2つ流す | 2つとも「起こした」と返ったのに、利用者の行は1つだけ入った(もう1つは消えた)。fox では2つめが写しになった。1.5 秒後の3つめは写しになった |
34
+ | 起きた後継の最初の番 | 置き場で待っていた完了通知は、入力の hook の文脈として届いた。番の終わりに待つ hook が立った |
35
+ | 頭が `-` の文・改行・`"`・`%PATH%`・`$HOME`・`&`・`|`・`<>`・`^`・日本語を含む文を `--` の後に置いて流す | そのまま同じ文が届いた(Windows も) |
36
+ | 別のフォルダから流す | 同じ会話 ID のまま起きた |
37
+ | `claude rm` で消した会話へ、切り離す option を付けて流す | 同じ会話 ID で起きたが、名前は新しく付き直した |
38
+
39
+ ## Decision
40
+
41
+ 1. **起こすのは、人が旧い会話へ打った文の時だけ。** ADR 0063 で運ぶ入力のうち、種類が `prompt`(利用者の入力)の時だけ
42
+ 起こす。完了通知(`task-notification`)とほかの会話からの文(`peer`)では起こさない(今までどおり置き場で待つ)。
43
+ slash command は今までどおり運ばない。
44
+ 2. **起こすのは、止まっていると確かめられた時だけ。** 次を全部満たす時に限る。どれか1つでも確かめられなければ起こさず、
45
+ 0.16.32 の動き(置き場で待つ)にする。
46
+ - 後継で待つ hook の process が生きていない(生きていれば、後継は動いている)。
47
+ - 引き継ぎの記録が、後継を Claude Desktop へ移した印(`desktop.state: opened`)を持っていない。
48
+ - 後継(今の続きの会話)に、Throughline の引き継ぎが進んでいない(予約・立ち上げ中・失敗の記録が無い)。
49
+ - 後継の transcript が在る(無い会話 ID へ流すと、その ID で新しい会話が立つ)。最後に書かれてから 5 秒より経っている。
50
+ - `claude agents --json --all` を読めて、後継の会話 ID の項目に、`pid` か `status` を持つ物が1つも無い。
51
+ `kind: background` の項目があり、その `state` が `done` か `stopped`。
52
+ - 文が 12,000 字以下で、NUL を含まない(Windows の命令行は全体で 32,767 字まで)。
53
+ 3. **起こす命令。** `claude --bg --resume <後継の会話 ID> -- <文>` を、後継の project のフォルダで流す。option は付けない
54
+ (裏の会話は立てた時の option を持っていて、option なしの時だけ同じ会話のまま起きる)。文は `--` の後に置く。
55
+ 旧い会話の会話ごとの環境変数(`CLAUDECODE`・`CLAUDE_CODE_ENTRYPOINT`・`CLAUDE_CODE_SESSION_ID`・`CLAUDE_PID`・
56
+ `CLAUDE_EFFORT` など)は渡さない。旧い会話の `UserPromptSubmit` の hook の中で、その場で流す(一覧 10 秒・起こす命令
57
+ 15 秒で打ち切る。ふだんは合わせて 1〜2.5 秒)。結果が分かってから、打った人へ返す文を決める。
58
+ 4. **起こす番は1つだけ。** 後継の置き場に印(`waking`)を排他で作れた hook だけが起こす。起こせた後も印は 60 秒残し、
59
+ その間に届いた入力は起こさずに置き場へ置く(起きた後継の hook が、次の道具の前か番の終わりに受け取る)。
60
+ 5. **渡す文を二重に届けない。** 起こす命令で渡す文は、置き場の受け取り済み(`claimed`)として書いてから命令を流す。
61
+ 起きた後継の入力の hook は、受け取り済みの文を文脈として渡さない。
62
+ - 起きた(出力の ID が後継の頭8字): 受け取り済みの文を消す。
63
+ - 写しが立った(出力に `started a copy as`、または出力の ID が後継と違う): 写しは渡した文で動き出すので、すぐ
64
+ `claude stop`・`claude rm` で止めて一覧から消す(写しの transcript は消さない)。文は置き場へ戻す。後継は動いているので、
65
+ 後継の hook が受け取る。
66
+ - 失敗した(命令が無い・立ち上がる前に失敗した・exit code が 0 でなく ID も無い): 文を置き場へ戻す。
67
+ - 分からない(時間切れ・exit code 0 で ID が読めない など): 入れ直さない(ADR 0063 の 6 と同じ)。受け取り済みに残り、
68
+ `auto-handoff status --host claude` の届いたか分からない数に出る。
69
+ 6. **打った人へ返す文。**
70
+ - 起きた: 「後継の会話が止まっていたので、Throughlineが起こして、この入力を後継の最初の入力として渡しました。後継は
71
+ 裏で作業を続けます」と、後継を開く命令。
72
+ - 起こしている所だった: 「届いた入力は、後継の会話へ渡しました。後継は、先に届いた入力で起きている所です」と、従わない
73
+ 事がある旨と、後継を開く命令。
74
+ - 動いていた(一覧で動いている・写しが立った・transcript が書かれたばかり): 0.16.32 の「後継は動いています」の文。
75
+ - 確かめられなかった・失敗した: 0.16.32 の「後継は今、入力を待ち受けていません」の文。
76
+ - 分からない: 「起こそうとしましたが、起きて入力を受け取ったか確かめられませんでした。二重に届かないよう、この入力は
77
+ 入れ直していません。後継を開いて確かめてください」と、後継を開く命令。
78
+ 7. **記録。** 運んだ記録(`forward.jsonl`)に `woken`・`wake-skipped`(理由)・`wake-copy`(写しの頭8字)・
79
+ `wake-failed`・`wake-unknown` を足す。止めた記録(`redirects.jsonl`)と判定の記録に、起こした結果(`wake`)を足す。
80
+ 本文は書かない。
81
+ 8. **止める上書き。** 環境変数 `THROUGHLINE_AUTO_HANDOFF_WAKE=off` の時は起こさない(0.16.32 の動き)。
82
+
83
+ ## Consequences
84
+
85
+ - 後継が止まっている時も、旧い会話へ打った文は、打ち直さなくても後継へ届く。利用者の発言の形(`origin.kind: human` の
86
+ 利用者の行)で届くので、ADR 0063 の「後継のモデルが、運ばれた入力を利用者の依頼として扱わない」が起きない。
87
+ - 起きた後継は、立てた時のモデル・effort・権限の mode・名前(・リモートコントロール)で動く。Claude Code が片付けるまで
88
+ (手すきで 61 分)裏で動き続け、利用枠を使う。片付けられた後で旧い会話へ文を打つたびに、もう一度起きる。
89
+ - 起きた後継の画面では、渡した文は利用者の発言として出る。旧い会話の画面には、止めた理由(「起こして渡しました」)と
90
+ 元の文が出る。
91
+ - 旧い会話へ打った文の hook が、後継が止まって見える時だけ、`claude agents`(と起こす命令)を流す分、1〜2.5 秒長くなる。
92
+ - 確かめた後から起こすまでの間に後継が動き出すと、写しが立つ。写しはすぐ止めて消すが、止めるまでの約 1 秒は写しが
93
+ 文で動く。
94
+ - `claude rm` で一覧から消した後継は、起こさない(立てた時の option の控えが消えていて、起こすと設定が変わる。端末で
95
+ 開いているかも確かめられない)。0.16.32 と同じに置き場で待つ。
96
+ - 12,000 字を超える文は起こさずに置き場で待つ。
97
+ - 起こす命令を同時に2つ流さないので、続けて打った2通めからは、起きた後継の hook の道(通知・文脈の形)で届く。
98
+ その分は ADR 0063 と同じに、後継のモデルが従わない事がある。
99
+
100
+ ## 確かめた事・確かめていない事
101
+
102
+ - 確かめた事は、Context の表と、この版の試験(CHANGELOG の 0.16.33)。
103
+ - 直した版を、本物の Claude Code 2.1.296 の裏の会話(Haiku)へ通した。Linux・macOS・Windows の3つ。利用者の設定の hook を
104
+ 外し、直した版を切り離した HOME で呼ぶ hook だけを付けた(直した版が流す `claude` は、HOME を本物へ戻す shim を通した)。
105
+ 結果は CHANGELOG と保守の記録に書く。
106
+ - 確かめていない:
107
+ - Claude Desktop の画面とリモートコントロール(スマホ)で、起きた後継がどう見えるか。リモートコントロール付きで立てた
108
+ 後継が、起きた後もリモートコントロール付きか(立てた時の option の控えには入る)。
109
+ - Windows で `claude` が単体の `claude.exe` の時の、文の渡り方(確かめた fox の `claude` は npm の shim で、Throughline は
110
+ `claude.ps1` を PowerShell から呼ぶ。記号と改行を含む文は、この道でそのまま届いた)。
111
+ - 61 分で片付けられた会話へ起こす命令を流した時の動き。一覧の形は Linux で `state: done`・`pid` なし(止めた会話と同じ形)と
112
+ 見たので、同じに起きる見込み。macOS・Windows の一覧の形も含めて、3つの OS で公開の後に読む。
113
+ - 本物の自動継続の後継での実地(入れた後の追跡で、`forward.jsonl` の `woken` を読む)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.16.32",
3
+ "version": "0.16.33",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -43,7 +43,7 @@ import { sameProjectPath } from './project-path.mjs';
43
43
  import { runWithPty } from './os/pty-run.mjs';
44
44
  import { spawnPortable, spawnPortableSync } from './os/portable-spawn-sync.mjs';
45
45
  import { composeAutoHandoffTitle } from './auto-handoff-title.mjs';
46
- import { claudeForwardKind, claudeForwardWaiterAlive, enqueueClaudeForward, ensureClaudeForwardInbox, readClaudeForwardStatus, removeStaleClaudeForwards } from './claude-forward.mjs';
46
+ import { acquireClaudeWakeClaim, claudeForwardKind, claudeForwardWaiterAlive, enqueueClaudeForward, ensureClaudeForwardInbox, holdClaudeForward, logClaudeForward, readClaudeForwardStatus, removeStaleClaudeForwards } from './claude-forward.mjs';
47
47
 
48
48
  export const CLAUDE_AUTO_HANDOFF_SCHEMA = 'throughline.claude-auto-handoff.v2';
49
49
  const CLI_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '../bin/throughline.mjs');
@@ -742,15 +742,197 @@ function handedOffReason(record, dir) {
742
742
  * 後継で待つ hook が生きていれば、後継は動いている。生きていなければ、後継は止まっているか、待つ hook が登録される前に
743
743
  * 立った会話で、入力は後継が次に動く時まで置き場で待つ。どちらかを、打った人へ返す。
744
744
  */
745
- function forwardedReason(record, dir, successorSessionId) {
745
+ function openSuccessorCommand(record, dir) {
746
746
  const { shortId, sessionId } = currentSuccessor(record, dir);
747
- const open = `claude attach ${shortId}` + (sessionId ? `(一覧に無い時は claude --resume ${sessionId})` : '');
747
+ return `claude attach ${shortId}` + (sessionId ? `(一覧に無い時は claude --resume ${sessionId})` : '');
748
+ }
749
+
750
+ function forwardedReason(record, dir, successorSessionId, { running = false, waking = false } = {}) {
751
+ const open = openSuccessorCommand(record, dir);
748
752
  return `この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: ${record.handoff_id})。` +
749
753
  '届いた入力は、後継の会話へ渡しました。' +
750
- (claudeForwardWaiterAlive(successorSessionId, dir)
751
- ? '後継は動いています。手すきならすぐ、作業中なら次の道具の前か番の終わりに届きます。' +
754
+ (waking
755
+ ? '後継は、先に届いた入力で起きている所です。起きた後継が、次の道具の前か番の終わりに読みます。' +
752
756
  `後継のモデルが、運ばれた入力を利用者の依頼として扱わない事があります。答えは後継で確かめてください: ${open}`
753
- : `後継は今、入力を待ち受けていません(止まっているか、この版より前に立った会話です)。後継を開いて何か入力した時に届きます: ${open}`);
757
+ : running || claudeForwardWaiterAlive(successorSessionId, dir)
758
+ ? '後継は動いています。手すきならすぐ、作業中なら次の道具の前か番の終わりに届きます。' +
759
+ `後継のモデルが、運ばれた入力を利用者の依頼として扱わない事があります。答えは後継で確かめてください: ${open}`
760
+ : `後継は今、入力を待ち受けていません(止まっているか、この版より前に立った会話です)。後継を開いて何か入力した時に届きます: ${open}`);
761
+ }
762
+
763
+ /** 止まっていた後継を起こして、入力を最初の入力として渡せた時に見せる文 (ADR 0064)。 */
764
+ function wokenReason(record, dir) {
765
+ return `この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: ${record.handoff_id})。` +
766
+ '後継の会話が止まっていたので、Throughlineが起こして、この入力を後継の最初の入力として渡しました。' +
767
+ `後継は裏で作業を続けます: ${openSuccessorCommand(record, dir)}`;
768
+ }
769
+
770
+ /** 起こす命令を流したが、起きたか確かめられなかった時に見せる文 (ADR 0064)。入力は入れ直さない。 */
771
+ function wakeUnknownReason(record, dir) {
772
+ return `この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: ${record.handoff_id})。` +
773
+ '後継の会話が止まっていたので起こそうとしましたが、起きて入力を受け取ったか確かめられませんでした。' +
774
+ `二重に届かないよう、この入力は入れ直していません。後継を開いて確かめてください: ${openSuccessorCommand(record, dir)}`;
775
+ }
776
+
777
+ // ---- 止まっている後継を、人が旧い会話へ打った文で起こす (ADR 0064) ----
778
+
779
+ // 起こす命令で渡す文の上限(UTF-16 の長さ)。Windows の命令行は全体で 32,767 字まで。引用で増える分を見込む。
780
+ export const CLAUDE_WAKE_TEXT_MAX_CHARS = 12_000;
781
+ // 起こしてよい、裏の会話の state(一覧の項目に `pid` も `status` も無い時だけ)。手すきで止めた・片付けられた会話は
782
+ // `done`、作業の途中で止めた会話は `stopped`。立ち上げ中の会話は `working` で、`pid` がまだ無い事がある。
783
+ const STOPPED_BACKGROUND_STATES = new Set(['done', 'stopped']);
784
+ // 後継の transcript がこれより新しく書かれていたら、動いていると見なして起こさない。
785
+ const RECENTLY_WRITTEN_MS = 5_000;
786
+ // 止まっていない会話へ起こす命令を流すと、Claude Code は写しを立てて、こう書く(2.1.296)。
787
+ const WAKE_COPY_PATTERN = /started a copy as ([0-9a-f]{8})\b/;
788
+ // 起こす仕組みを止める上書き(`off`)。
789
+ const WAKE_OVERRIDE_ENV = 'THROUGHLINE_AUTO_HANDOFF_WAKE';
790
+
791
+ // 起こす命令に渡さない、旧い会話の環境変数(hook は旧い会話の中で走る)。後継は、保存された自分の設定で起きる。
792
+ const WAKE_STRIPPED_ENV = ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_PID', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ATTENDED',
793
+ 'CLAUDE_EFFORT', 'CLAUDE_CODE_EXECPATH'];
794
+
795
+ function wakeCommandEnv(env) {
796
+ const next = successorEnv(env);
797
+ for (const key of WAKE_STRIPPED_ENV) delete next[key];
798
+ return next;
799
+ }
800
+
801
+ /** 起こす命令で渡せる文か。長すぎる文と、命令の引数に入らない文字(NUL)を含む文は渡さない。 */
802
+ export function claudeWakeTextAllowed(text) {
803
+ return typeof text === 'string' && text.trim() !== '' && text.length <= CLAUDE_WAKE_TEXT_MAX_CHARS && !text.includes('\0');
804
+ }
805
+
806
+ /**
807
+ * `claude agents --json --all` で、その会話が止まっていると確かめられるか。
808
+ * 同じ会話 ID の項目が1つでも `pid` か `status` を持てば動いている(裏の会話を端末で開いた時と、`claude -p --resume` が
809
+ * 走っている時は、裏の項目とは別に `interactive` の項目が出る)。裏の項目が無い・一覧を読めない時は、確かめられない。
810
+ *
811
+ * @returns {{stopped: boolean, reason: string}}
812
+ */
813
+ export function readClaudeSessionStopped(sessionId, { spawn = spawnPortableSync, env = process.env } = {}) {
814
+ const listed = spawn('claude', ['agents', '--json', '--all'], { env, encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] });
815
+ if (listed.error || listed.status !== 0) return { stopped: false, reason: 'list_failed' };
816
+ let entries;
817
+ try { entries = JSON.parse(`${listed.stdout ?? ''}`); }
818
+ catch { return { stopped: false, reason: 'list_failed' }; }
819
+ if (!Array.isArray(entries)) return { stopped: false, reason: 'list_failed' };
820
+ const same = entries.filter(entry => entry?.sessionId === sessionId);
821
+ if (!same.length) return { stopped: false, reason: 'not_listed' };
822
+ if (same.some(entry => entry.pid || Object.hasOwn(entry, 'status'))) return { stopped: false, reason: 'running' };
823
+ const background = same.find(entry => entry.kind === 'background');
824
+ if (!background) return { stopped: false, reason: 'not_background' };
825
+ if (!STOPPED_BACKGROUND_STATES.has(background.state)) return { stopped: false, reason: 'not_stopped' };
826
+ return { stopped: true, reason: 'stopped' };
827
+ }
828
+
829
+ /** 後継の transcript の場所(旧い会話の transcript と同じ projects の下を探す)。見つからなければ null。 */
830
+ function findSessionTranscript(record, sessionId) {
831
+ if (typeof record?.transcript_path !== 'string' || !validSessionId(sessionId)) return null;
832
+ const own = dirname(record.transcript_path);
833
+ const direct = join(own, `${sessionId}.jsonl`);
834
+ if (existsSync(direct)) return direct;
835
+ let folders;
836
+ try { folders = readdirSync(dirname(own), { withFileTypes: true }); }
837
+ catch { return null; }
838
+ for (const entry of folders) {
839
+ const path = entry.isDirectory() ? join(dirname(own), entry.name, `${sessionId}.jsonl`) : null;
840
+ if (path && existsSync(path)) return path;
841
+ }
842
+ return null;
843
+ }
844
+
845
+ /**
846
+ * 起こす命令を流す。option は付けない。裏の会話は、立てた時の option(モデル・effort・権限の mode・名前・リモート
847
+ * コントロール)を持っていて、option なしの時だけ、同じ会話のままそれで起きる。option を付けると、止まっている
848
+ * 会話でも写しが立つ。文は `--` の後に置く(頭が `-` の文を option として読ませない)。
849
+ *
850
+ * @returns {{outcome: 'woken'|'copy'|'failed'|'unknown', copyShortId?: string}}
851
+ */
852
+ function runClaudeWake({ sessionId, text, cwd, env, spawn }) {
853
+ const result = spawn('claude', ['--bg', '--resume', sessionId, '--', text], {
854
+ cwd, env, encoding: 'utf8', timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'],
855
+ });
856
+ if (result.error) {
857
+ // 立ち上がる前の失敗(命令が無い・実行できない)は届いていない。時間切れは、命令が途中まで進んだかもしれない。
858
+ return { outcome: result.error.code === 'ETIMEDOUT' ? 'unknown' : 'failed' };
859
+ }
860
+ const out = normalizeTerminalText(`${result.stdout ?? ''}${result.stderr ?? ''}`);
861
+ const copy = WAKE_COPY_PATTERN.exec(out)?.[1];
862
+ if (copy) return { outcome: 'copy', copyShortId: copy };
863
+ const shortId = BACKGROUNDED_PATTERN.exec(out)?.[1];
864
+ if (result.status === 0 && shortId === sessionId.slice(0, 8)) return { outcome: 'woken' };
865
+ if (result.status === 0 && shortId) return { outcome: 'copy', copyShortId: shortId };
866
+ if (result.status !== 0 && !shortId) return { outcome: 'failed' };
867
+ return { outcome: 'unknown' };
868
+ }
869
+
870
+ /**
871
+ * 後継が止まっていて、人が旧い会話へ文を打った時に、後継を起こしてその文を最初の入力として渡す (ADR 0064)。
872
+ * 利用者の決め(K-9UHCLQ、2026-10-14)は「人が打った文の時だけ起こす。完了通知では起こさない」。
873
+ *
874
+ * 止まっていると確かめられない時は何もしない(`skipped`。入力は呼んだ側が置き場へ置く)。写しが立った時は、写しを
875
+ * 止めて一覧から消し、入力を置き場へ戻す(`queued`)。起こす命令が失敗した時も置き場へ戻す。起きたか分からない時は
876
+ * 入れ直さない(`unknown`)。
877
+ *
878
+ * @returns {{outcome: 'woken'|'queued'|'unknown'|'skipped', reason: string, running?: boolean, waking?: boolean}}
879
+ */
880
+ export function wakeStoppedClaudeSuccessor({ record, successorSessionId, sourceSessionId, text, now = Date.now(),
881
+ dir = claudeAutoHandoffDir(), env = process.env, spawn = spawnPortableSync }) {
882
+ const skip = (reason, extra = {}) => {
883
+ logClaudeForward({ event: 'wake-skipped', reason, session_id: successorSessionId }, dir);
884
+ return { outcome: 'skipped', reason, ...extra };
885
+ };
886
+ if (`${env[WAKE_OVERRIDE_ENV] ?? ''}`.trim().toLowerCase() === 'off') return { outcome: 'skipped', reason: 'disabled' };
887
+ if (claudeForwardWaiterAlive(successorSessionId, dir)) return { outcome: 'skipped', reason: 'waiter_alive', running: true };
888
+ if (!claudeWakeTextAllowed(text)) return skip('text_not_allowed');
889
+ // Claude Desktop へ移した後継は、Desktop が開いている。裏で起こすと、同じ会話を2か所で動かす事になる。
890
+ if (record.desktop?.state === 'opened') return skip('desktop');
891
+ try {
892
+ if (resolveClaudeAutoHandoffSuccessor(successorSessionId, { dir }).pending) return skip('handoff_pending');
893
+ } catch { return skip('handoff_pending'); }
894
+ const transcript = findSessionTranscript(record, successorSessionId);
895
+ if (!transcript) return skip('transcript_missing'); // 無い会話 ID へ流すと、その ID で新しい会話が立つ
896
+ try {
897
+ if (Date.now() - statSync(transcript).mtimeMs < RECENTLY_WRITTEN_MS) return skip('recently_written', { running: true });
898
+ } catch { return skip('transcript_missing'); }
899
+ const release = acquireClaudeWakeClaim(successorSessionId, { dir, now });
900
+ if (!release) return skip('waking', { waking: true });
901
+ const wakeEnv = wakeCommandEnv(env);
902
+ const state = readClaudeSessionStopped(successorSessionId, { spawn, env: wakeEnv });
903
+ if (!state.stopped) {
904
+ release();
905
+ return skip(state.reason, { running: state.reason === 'running' });
906
+ }
907
+ const held = holdClaudeForward({ successorSessionId, sourceSessionId, handoffId: record.handoff_id, kind: 'prompt', text, now, dir });
908
+ if (!held) { release(); return skip('store_failed'); }
909
+ const cwd = typeof record.project_path === 'string' && existsSync(record.project_path) ? record.project_path : undefined;
910
+ const woke = runClaudeWake({ sessionId: successorSessionId, text, cwd, env: wakeEnv, spawn });
911
+ const base = { session_id: successorSessionId, source_session_id: sourceSessionId, chars: text.length };
912
+ if (woke.outcome === 'woken') {
913
+ held.settle();
914
+ logClaudeForward({ event: 'woken', ...base }, dir);
915
+ return { outcome: 'woken', reason: 'woken' }; // 番の印は残す(期限で切れる)
916
+ }
917
+ if (woke.outcome === 'copy') {
918
+ // 写しは、渡した文で動き出す。後継と同じ作業を2か所でしないよう、すぐ止めて一覧から消す。入力は置き場へ戻す
919
+ // (後継は動いているので、後継の hook が受け取る)。
920
+ for (const verb of ['stop', 'rm']) {
921
+ spawn('claude', [verb, woke.copyShortId], { env: wakeEnv, encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] });
922
+ }
923
+ const queued = held.release();
924
+ release();
925
+ logClaudeForward({ event: 'wake-copy', ...base, copy_short_id: woke.copyShortId, queued }, dir);
926
+ return queued ? { outcome: 'queued', reason: 'copy', running: true } : { outcome: 'unknown', reason: 'copy' };
927
+ }
928
+ if (woke.outcome === 'failed') {
929
+ const queued = held.release();
930
+ release();
931
+ logClaudeForward({ event: 'wake-failed', ...base, queued }, dir);
932
+ return queued ? { outcome: 'queued', reason: 'failed' } : { outcome: 'unknown', reason: 'failed' };
933
+ }
934
+ logClaudeForward({ event: 'wake-unknown', ...base }, dir);
935
+ return { outcome: 'unknown', reason: 'unknown' };
754
936
  }
755
937
 
756
938
  function stopOutput(record, dir) {
@@ -792,7 +974,7 @@ function recordClaudeRedirect(entry, dir) {
792
974
  *
793
975
  * @returns {{output: {decision: 'block', reason: string}, kind: 'prompt'|'task-notification', handoffId: string, successorShortId: string|null, forwarded: boolean}|null}
794
976
  */
795
- export function redirectClaudeHandedOffPrompt({ payload, now = Date.now(), dir = claudeAutoHandoffDir(), env = process.env }) {
977
+ export function redirectClaudeHandedOffPrompt({ payload, now = Date.now(), dir = claudeAutoHandoffDir(), env = process.env, spawn = spawnPortableSync }) {
796
978
  const sessionId = payload?.session_id;
797
979
  if (!validSessionId(sessionId) || payload.agent_id || isAitermSeatConversation(env, sessionId)) return null;
798
980
  const record = readClaudeAutoHandoff(sessionId, dir);
@@ -803,13 +985,23 @@ export function redirectClaudeHandedOffPrompt({ payload, now = Date.now(), dir =
803
985
  const successorSessionId = successor.sessionId ?? findSuccessorSessionId(record);
804
986
  // slash command は、その会話への命令なので運ばない。ほかの会話から届いた文は、利用者の入力と分けて運ぶ。
805
987
  const forwardKind = claudeForwardKind(payload.prompt);
806
- const forwarded = forwardKind !== null && validSessionId(successorSessionId) && successorSessionId !== sessionId &&
807
- enqueueClaudeForward({ successorSessionId, sourceSessionId: sessionId, handoffId: record.handoff_id, kind: forwardKind,
808
- text: payload.prompt, now, dir });
988
+ const canForward = forwardKind !== null && validSessionId(successorSessionId) && successorSessionId !== sessionId;
989
+ // 人が打った文で、後継が止まっていれば、後継を起こしてその文を渡す (ADR 0064)。完了通知とほかの会話からの文では起こさない。
990
+ const wake = canForward && forwardKind === 'prompt'
991
+ ? wakeStoppedClaudeSuccessor({ record, successorSessionId, sourceSessionId: sessionId, text: payload.prompt, now, dir, env, spawn })
992
+ : null;
993
+ const forwarded = canForward && (wake?.outcome === 'woken' || wake?.outcome === 'queued' ||
994
+ (wake?.outcome !== 'unknown' && enqueueClaudeForward({ successorSessionId, sourceSessionId: sessionId, handoffId: record.handoff_id,
995
+ kind: forwardKind, text: payload.prompt, now, dir })));
809
996
  recordClaudeRedirect({ at: new Date(now).toISOString(), source_session_id: sessionId, handoff_id: record.handoff_id,
810
- successor_short_id: successorShortId, kind, forwarded }, dir);
811
- return { output: { decision: 'block', reason: forwarded ? forwardedReason(record, dir, successorSessionId) : handedOffReason(record, dir) },
812
- kind, handoffId: record.handoff_id, successorShortId, forwarded };
997
+ successor_short_id: successorShortId, kind, forwarded,
998
+ ...(wake && wake.reason !== 'disabled' ? { wake: wake.outcome === 'skipped' ? `skipped:${wake.reason}` : wake.outcome } : {}) }, dir);
999
+ const reason = wake?.outcome === 'woken' ? wokenReason(record, dir)
1000
+ : wake?.outcome === 'unknown' ? wakeUnknownReason(record, dir)
1001
+ : forwarded ? forwardedReason(record, dir, successorSessionId, { running: Boolean(wake?.running), waking: Boolean(wake?.waking) })
1002
+ : handedOffReason(record, dir);
1003
+ return { output: { decision: 'block', reason }, kind, handoffId: record.handoff_id, successorShortId, forwarded,
1004
+ ...(wake ? { wake: wake.outcome } : {}) };
813
1005
  }
814
1006
 
815
1007
  /**
@@ -1611,7 +1611,9 @@ test('引き継ぎ済みの会話へ届いた入力: 後継が残っている時
1611
1611
  const record = (id, fields) => writeFileSync(join(dir, `${id}.json`), JSON.stringify({ schema: 'throughline.claude-auto-handoff.v2',
1612
1612
  handoff_id: `h-${id.slice(0, 8)}`, source_session_id: id, project_path: '/work/app', transcript_path: join(projects, `${id}.jsonl`),
1613
1613
  requested_at: 1_000, updated_at: 2_000, error_code: null, ...fields }));
1614
- const submit = (id, prompt, extra = {}, env = {}) => redirectClaudeHandedOffPrompt({ payload: { session_id: id, prompt, ...extra }, now: 5_000, dir, env });
1614
+ const noClaude = () => ({ error: Object.assign(new Error('spawn claude ENOENT'), { code: 'ENOENT' }) });
1615
+ const submit = (id, prompt, extra = {}, env = {}) => redirectClaudeHandedOffPrompt({ payload: { session_id: id, prompt, ...extra }, now: 5_000, dir, env, spawn: noClaude });
1616
+ const settled = path => { const past = new Date(Date.now() - 60_000); utimesSync(path, past, past); };
1615
1617
  const redirects = () => existsSync(join(dir, 'redirects.jsonl'))
1616
1618
  ? readFileSync(join(dir, 'redirects.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line)) : [];
1617
1619
 
@@ -1625,26 +1627,28 @@ test('引き継ぎ済みの会話へ届いた入力: 後継が残っている時
1625
1627
  assert.deepEqual(redirects(), []);
1626
1628
 
1627
1629
  writeFileSync(join(projects, `${b}.jsonl`), '');
1630
+ settled(join(projects, `${b}.jsonl`));
1628
1631
  const human = submit(a, '旧い会話へ打った文');
1629
1632
  assert.deepEqual(human, { output: { decision: 'block',
1630
1633
  reason: 'この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: h-aaaaaaaa)。届いた入力は、後継の会話へ渡しました。' +
1631
1634
  '後継は今、入力を待ち受けていません(止まっているか、この版より前に立った会話です)。後継を開いて何か入力した時に届きます: ' +
1632
1635
  'claude attach bbbbbbbb(一覧に無い時は claude --resume ' + b + ')' },
1633
- kind: 'prompt', handoffId: 'h-aaaaaaaa', successorShortId: 'bbbbbbbb', forwarded: true });
1636
+ kind: 'prompt', handoffId: 'h-aaaaaaaa', successorShortId: 'bbbbbbbb', forwarded: true, wake: 'skipped' });
1634
1637
  assert.equal(submit(a, '<task-notification>\n<task-id>b1</task-id>\n<status>completed</status>\n</task-notification>').kind, 'task-notification');
1635
1638
  assert.equal(submit(a, '依頼', { agent_id: 'agent-1' }), null, 'subagent の中の入力');
1636
1639
  assert.equal(submit('../escape', '依頼'), null);
1637
1640
  assert.equal(submit(a, '依頼', {}, { AITERM_AGENT_LAUNCH_ID: 'abc', AITERM_AGENT_VENDOR_SESSION_ID: a }), null, 'Aiterm の席の会話は止めない');
1638
1641
  assert.equal(submit(a, '依頼', {}, { AITERM_AGENT_LAUNCH_ID: 'abc', AITERM_AGENT_VENDOR_SESSION_ID: b }).kind, 'prompt', '席の印だけを継いだ会話は止める');
1639
1642
  assert.deepEqual(redirects(), [
1640
- { at: '1970-01-01T00:00:05.000Z', source_session_id: a, handoff_id: 'h-aaaaaaaa', successor_short_id: 'bbbbbbbb', kind: 'prompt', forwarded: true },
1643
+ { at: '1970-01-01T00:00:05.000Z', source_session_id: a, handoff_id: 'h-aaaaaaaa', successor_short_id: 'bbbbbbbb', kind: 'prompt', forwarded: true, wake: 'skipped:list_failed' },
1641
1644
  { at: '1970-01-01T00:00:05.000Z', source_session_id: a, handoff_id: 'h-aaaaaaaa', successor_short_id: 'bbbbbbbb', kind: 'task-notification', forwarded: true },
1642
- { at: '1970-01-01T00:00:05.000Z', source_session_id: a, handoff_id: 'h-aaaaaaaa', successor_short_id: 'bbbbbbbb', kind: 'prompt', forwarded: true },
1645
+ { at: '1970-01-01T00:00:05.000Z', source_session_id: a, handoff_id: 'h-aaaaaaaa', successor_short_id: 'bbbbbbbb', kind: 'prompt', forwarded: true, wake: 'skipped:list_failed' },
1643
1646
  ], '止めた記録に入力の本文は書かない');
1644
1647
 
1645
1648
  // 後継がさらに引き継がれている時は、その先を示す。道具を断る時の文も同じ
1646
1649
  record(b, { state: 'unknown', successor: { short_id: 'cccccccc', session_id: null } });
1647
1650
  writeFileSync(join(projects, `${c}.jsonl`), '');
1651
+ settled(join(projects, `${c}.jsonl`));
1648
1652
  assert.match(submit(a, '依頼').output.reason, /claude attach cccccccc(一覧に無い時は claude --resume cccccccc-0000-4000-8000-000000000033)$/);
1649
1653
  assert.match(submit(b, '依頼').output.reason, /引き継ぎID: h-bbbbbbbb.*claude attach cccccccc(一覧に無い時は claude --resume cccccccc-0000-4000-8000-000000000033)$/);
1650
1654
  const denied = await stopClaudeTurnForHandoff({ payload: { session_id: a, cwd: '/work/app' }, dir,
@@ -1659,6 +1663,7 @@ test('公開CLI: prompt-submit は、引き継ぎ済みの会話の入力を止
1659
1663
  const projects = join(home, '.claude', 'projects', '-work-app');
1660
1664
  mkdirSync(dir, { recursive: true }); mkdirSync(projects, { recursive: true });
1661
1665
  writeFileSync(join(projects, `${SUCCESSOR}.jsonl`), '');
1666
+ utimesSync(join(projects, `${SUCCESSOR}.jsonl`), new Date(Date.now() - 60_000), new Date(Date.now() - 60_000));
1662
1667
  writeFileSync(join(dir, `${SESSION}.json`), JSON.stringify({ schema: 'throughline.claude-auto-handoff.v2', handoff_id: 'h-1',
1663
1668
  source_session_id: SESSION, project_path: '/work/app', transcript_path: join(projects, `${SESSION}.jsonl`), state: 'sent',
1664
1669
  requested_at: 1_000, updated_at: 2_000, error_code: null, successor: { short_id: SUCCESSOR.slice(0, 8), session_id: SUCCESSOR } }));
@@ -8,6 +8,7 @@
8
8
  * ~/.throughline/claude-auto-handoff/inbox/<後継の会話 ID>/claimed/… 受け取った所が出力するまでの置き場
9
9
  * ~/.throughline/claude-auto-handoff/inbox/<後継の会話 ID>/full/… 長すぎる入力の全文
10
10
  * ~/.throughline/claude-auto-handoff/inbox/<後継の会話 ID>/waiter 今、待つ番の hook の印
11
+ * ~/.throughline/claude-auto-handoff/inbox/<後継の会話 ID>/waking 止まっている後継を起こしている所の印 (ADR 0064)
11
12
  * ~/.throughline/claude-auto-handoff/forward.jsonl 運んだ記録(本文は書かない)
12
13
  *
13
14
  * 届いたか分からない入力は入れ直さない。受け取る所は、ファイルを `claimed` へ改名してから出力し、出力した後で消す。
@@ -79,19 +80,75 @@ export function logClaudeForward(entry, dir = defaultDir()) {
79
80
  * 旧い会話が止めた入力を、後継の置き場へ置く。
80
81
  * @returns {boolean} 置けたか
81
82
  */
82
- export function enqueueClaudeForward({ successorSessionId, sourceSessionId, handoffId, kind, text, now = Date.now(), dir = defaultDir() }) {
83
+ export function enqueueClaudeForward(input) {
84
+ return writeClaudeForward(input) !== null;
85
+ }
86
+
87
+ /** 入力を1ファイルに書く。`claimed` が真なら、受け取り済みの置き場へ書く。書いたファイルの名前と場所を返す。 */
88
+ function writeClaudeForward({ successorSessionId, sourceSessionId, handoffId, kind, text, now = Date.now(), dir = defaultDir(), claimed = false }) {
83
89
  const inbox = claudeForwardInboxDir(successorSessionId, dir);
84
- if (!inbox || typeof text !== 'string' || !text.trim()) return false;
90
+ if (!inbox || typeof text !== 'string' || !text.trim()) return null;
85
91
  try {
86
- mkdirSync(inbox, { recursive: true, mode: 0o700 });
92
+ const folder = claimed ? join(inbox, 'claimed') : inbox;
93
+ mkdirSync(folder, { recursive: true, mode: 0o700 });
87
94
  const name = `${String(now).padStart(13, '0')}-${randomUUID().slice(0, 8)}.json`;
88
- const temporary = join(inbox, `${name}.tmp`);
95
+ const path = join(folder, claimed ? `${name}.${process.pid}` : name);
96
+ const temporary = join(folder, `${name}.tmp`);
89
97
  writeFileSync(temporary, JSON.stringify({ schema: 'throughline.claude-forward.v1', at: now,
90
98
  source_session_id: sourceSessionId ?? null, handoff_id: handoffId ?? null,
91
99
  kind: KINDS.includes(kind) ? kind : 'prompt', text }) + '\n', { mode: 0o600, flag: 'wx' });
92
- renameSync(temporary, join(inbox, name));
93
- return true;
94
- } catch { return false; }
100
+ renameSync(temporary, path);
101
+ return { inbox, name, path };
102
+ } catch { return null; }
103
+ }
104
+
105
+ /**
106
+ * 止まっている後継を起こす命令で直接渡す入力を、受け取り済み(`claimed`)として置く (ADR 0064)。置き場の入力として
107
+ * 待たせると、起きた後継の hook が同じ文を文脈としてもう1回渡すため。起こす命令が終わったら、届いた時は `settle()`、
108
+ * 届かなかったと確かめられた時は `release()`(置き場へ戻す)を呼ぶ。どちらも呼ばなければ、届いたか分からない入力として
109
+ * `claimed` に残る(入れ直さない)。
110
+ *
111
+ * @returns {{settle: () => void, release: () => boolean}|null} 書けなかった時は null
112
+ */
113
+ export function holdClaudeForward(input) {
114
+ const written = writeClaudeForward({ ...input, claimed: true });
115
+ if (!written) return null;
116
+ return {
117
+ settle: () => rmSync(written.path, { force: true }),
118
+ release: () => {
119
+ try { renameSync(written.path, join(written.inbox, written.name)); return true; }
120
+ catch { return false; }
121
+ },
122
+ };
123
+ }
124
+
125
+ const WAKE_CLAIM_TTL_MS = 60_000;
126
+
127
+ /**
128
+ * 止まっている後継を起こす番を取る (ADR 0064)。起こす命令を同時に2つ流すと、どちらも「起こした」と返るのに、
129
+ * 後継へ届くのは1通だけになる(Claude Code 2.1.296 の実測)。番を取れなかった入力は、置き場で待たせる。
130
+ * 起こせた後も印は消さず、60 秒の間は次の入力で起こさない(起きたばかりの後継は、一覧でまだ止まって見える事がある)。
131
+ *
132
+ * @returns {(() => void)|null} 番を返す関数。取れなかった時は null
133
+ */
134
+ export function acquireClaudeWakeClaim(sessionId, { dir = defaultDir(), now = Date.now() } = {}) {
135
+ const inbox = claudeForwardInboxDir(sessionId, dir);
136
+ if (!inbox) return null;
137
+ const path = join(inbox, 'waking');
138
+ for (let attempt = 0; attempt < 2; attempt++) {
139
+ try {
140
+ mkdirSync(inbox, { recursive: true, mode: 0o700 });
141
+ writeFileSync(path, `${process.pid}-${now}`, { mode: 0o600, flag: 'wx' });
142
+ return () => rmSync(path, { force: true });
143
+ } catch (error) {
144
+ if (error.code !== 'EEXIST') return null;
145
+ try {
146
+ if (now - statSync(path).mtimeMs <= WAKE_CLAIM_TTL_MS) return null;
147
+ rmSync(path, { force: true }); // 期限を過ぎた印
148
+ } catch { /* 別の hook が消した */ }
149
+ }
150
+ }
151
+ return null;
95
152
  }
96
153
 
97
154
  function pendingNames(inbox) {
@@ -287,7 +344,7 @@ export function removeStaleClaudeForwards({ dir = defaultDir(), now = Date.now()
287
344
  try { names = readdirSync(join(inbox, sub)); }
288
345
  catch { continue; }
289
346
  for (const name of names) {
290
- if (sub === '' && (name === 'claimed' || name === 'full' || name === 'waiter')) continue;
347
+ if (sub === '' && (name === 'claimed' || name === 'full' || name === 'waiter' || name === 'waking')) continue;
291
348
  const path = join(inbox, sub, name);
292
349
  try {
293
350
  if (now - statSync(path).mtimeMs > PENDING_TTL_MS) { rmSync(path, { force: true }); if (sub !== 'full') dropped++; }
@@ -13,8 +13,10 @@ import {
13
13
  claudeForwardWaiterAlive,
14
14
  registerClaudeForwardWaiter,
15
15
  claudeForwardInboxDir,
16
+ acquireClaudeWakeClaim,
16
17
  enqueueClaudeForward,
17
18
  ensureClaudeForwardInbox,
19
+ holdClaudeForward,
18
20
  hasPendingClaudeForwards,
19
21
  moveClaudeForwards,
20
22
  readClaudeForwardStatus,
@@ -24,7 +26,7 @@ import {
24
26
  unwrapClaudeForwardedDelivery,
25
27
  } from './claude-forward.mjs';
26
28
  import { waitForClaudeForwards } from './claude-forward-wait.mjs';
27
- import { acceptClaudeAutoContinuation, publicClaudeAutoHandoff, redirectClaudeHandedOffPrompt } from './claude-auto-handoff.mjs';
29
+ import { CLAUDE_WAKE_TEXT_MAX_CHARS, acceptClaudeAutoContinuation, publicClaudeAutoHandoff, readClaudeSessionStopped, redirectClaudeHandedOffPrompt } from './claude-auto-handoff.mjs';
28
30
  import { MID_TURN_INPUT_MARK, readTranscript } from './transcript-reader.mjs';
29
31
 
30
32
  const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -33,6 +35,11 @@ const OLD = 'aaaaaaaa-0000-4000-8000-000000000041';
33
35
  const NEXT = 'bbbbbbbb-0000-4000-8000-000000000042';
34
36
  const LAST = 'cccccccc-0000-4000-8000-000000000043';
35
37
 
38
+ // 一覧を読めない `claude`(試験では本物の claude を呼ばない)
39
+ const NO_CLAUDE = () => ({ error: Object.assign(new Error('spawn claude ENOENT'), { code: 'ENOENT' }) });
40
+ // 後継の transcript を、止まってしばらく経った物にする(書かれたばかりの transcript の後継は、動いていると見なす)
41
+ const settled = path => { const past = new Date(Date.now() - 60_000); utimesSync(path, past, past); };
42
+
36
43
  function withDir(fn) {
37
44
  const dir = mkdtempSync(join(tmpdir(), 'tl-claude-forward-'));
38
45
  const finish = () => rmSync(dir, { recursive: true, force: true });
@@ -201,9 +208,10 @@ test('旧い会話の側: 止めた入力を今の続きの会話の置き場へ
201
208
  const record = (id, fields) => writeFileSync(join(dir, `${id}.json`), JSON.stringify({ schema: 'throughline.claude-auto-handoff.v2',
202
209
  handoff_id: `h-${id.slice(0, 8)}`, source_session_id: id, project_path: '/work/app', transcript_path: join(projects, `${id}.jsonl`),
203
210
  requested_at: 1_000, updated_at: 2_000, error_code: null, state: 'sent', ...fields }));
204
- const submit = (id, prompt) => redirectClaudeHandedOffPrompt({ payload: { session_id: id, prompt }, now: 1_760_000_000_000, dir, env: {} });
211
+ const submit = (id, prompt) => redirectClaudeHandedOffPrompt({ payload: { session_id: id, prompt }, now: 1_760_000_000_000, dir, env: {}, spawn: NO_CLAUDE });
205
212
  record(OLD, { successor: { short_id: 'bbbbbbbb', session_id: NEXT } });
206
213
  writeFileSync(join(projects, `${NEXT}.jsonl`), '');
214
+ settled(join(projects, `${NEXT}.jsonl`));
207
215
 
208
216
  const human = submit(OLD, '旧い会話へ打った文');
209
217
  assert.equal(human.forwarded, true);
@@ -222,6 +230,7 @@ test('旧い会話の側: 止めた入力を今の続きの会話の置き場へ
222
230
  // 後継がさらに引き継がれている時は、その先の置き場へ置く
223
231
  record(NEXT, { state: 'unknown', successor: { short_id: 'cccccccc', session_id: null } });
224
232
  writeFileSync(join(projects, `${LAST}.jsonl`), '');
233
+ settled(join(projects, `${LAST}.jsonl`));
225
234
  assert.equal(submit(OLD, '後継の先へ届く文').forwarded, true);
226
235
  assert.deepEqual(readClaudeForwardStatus(NEXT, { dir }).pending, 2);
227
236
  assert.match(claimClaudeForwards({ sessionId: LAST, dir }).text, /引き継ぎ前の会話(aaaaaaaa).*\n後継の先へ届く文$/s);
@@ -351,3 +360,177 @@ test('公開CLI: pre-tool-use は、後継の置き場で待っている入力
351
360
  assert.equal(tool(NEXT).stdout, '', '同じ入力を2回は届けない');
352
361
  assert.equal(existsSync(join(home, '.throughline', 'throughline.db')), false);
353
362
  }));
363
+
364
+ // ---- 止まっている後継を、人の入力で起こして届ける (ADR 0064) ----
365
+
366
+ test('置き場: 起こす命令で渡す入力は受け取り済みとして置き、届いたら消し、届かなかったら置き場へ戻す', () => withDir(dir => {
367
+ const held = holdClaudeForward({ successorSessionId: NEXT, sourceSessionId: OLD, handoffId: 'h-1', kind: 'prompt', text: '起こして渡す文', now: 1_000, dir });
368
+ assert.deepEqual(readClaudeForwardStatus(NEXT, { dir, now: 2_000 }), { pending: 0, unknown: 0 });
369
+ assert.equal(hasPendingClaudeForwards(NEXT, dir), false, '起きた後継の hook が、同じ文を文脈としてもう1回渡さない');
370
+ assert.equal(held.release(), true);
371
+ assert.match(claimClaudeForwards({ sessionId: NEXT, dir }).text, /\n起こして渡す文$/);
372
+ const done = holdClaudeForward({ successorSessionId: NEXT, sourceSessionId: OLD, handoffId: 'h-1', kind: 'prompt', text: '届いた文', now: 1_000, dir });
373
+ done.settle();
374
+ assert.deepEqual(readdirSync(join(claudeForwardInboxDir(NEXT, dir), 'claimed')).filter(name => name.includes('-')).length, 1, '前の claim の残りだけ');
375
+ assert.equal(holdClaudeForward({ successorSessionId: '../x', text: 'x', dir }), null);
376
+ }));
377
+
378
+ test('置き場: 起こす番は1つだけ。60 秒を過ぎた印は取り直せる。片付けは印を入力として数えない', () => withDir(dir => {
379
+ const release = acquireClaudeWakeClaim(NEXT, { dir, now: Date.now() });
380
+ assert.equal(typeof release, 'function');
381
+ assert.equal(acquireClaudeWakeClaim(NEXT, { dir, now: Date.now() }), null, '起こしている間は、次の入力で起こさない');
382
+ release();
383
+ const again = acquireClaudeWakeClaim(NEXT, { dir, now: Date.now() });
384
+ assert.equal(typeof again, 'function');
385
+ const mark = join(claudeForwardInboxDir(NEXT, dir), 'waking');
386
+ const past = new Date(Date.now() - 61_000); utimesSync(mark, past, past);
387
+ assert.equal(typeof acquireClaudeWakeClaim(NEXT, { dir, now: Date.now() }), 'function', '期限を過ぎた印');
388
+ utimesSync(mark, new Date(0), new Date(0));
389
+ removeStaleClaudeForwards({ dir, now: Date.now(), keepSessionIds: new Set([NEXT]) });
390
+ assert.equal(existsSync(join(dir, 'forward.jsonl')), false, '印は期限切れの入力として数えない');
391
+ assert.equal(acquireClaudeWakeClaim('../x', { dir }), null);
392
+ }));
393
+
394
+ test('一覧の読み方: 裏の項目に pid も status も無く、state が done か stopped の時だけ止まっていると読む', () => {
395
+ const read = (entries, extra = {}) => readClaudeSessionStopped(NEXT, { spawn: () => ({ status: 0, stdout: JSON.stringify(entries), ...extra }) });
396
+ const bg = fields => ({ id: 'bbbbbbbb', kind: 'background', sessionId: NEXT, name: 'n', cwd: '/w', startedAt: 1, ...fields });
397
+ assert.deepEqual(read([bg({ state: 'done' })]), { stopped: true, reason: 'stopped' }, '手すきで止めた・片付けられた');
398
+ assert.deepEqual(read([bg({ state: 'stopped' })]), { stopped: true, reason: 'stopped' }, '作業の途中で止めた');
399
+ assert.equal(read([bg({ state: 'done', pid: 10, status: 'idle' })]).reason, 'running', '手すきで動いている');
400
+ assert.equal(read([bg({ state: 'working', pid: 10, status: 'busy' })]).reason, 'running', '作業中');
401
+ assert.equal(read([bg({ state: 'done' }), { kind: 'interactive', sessionId: NEXT, pid: 11, status: 'idle', name: 'n' }]).reason, 'running', '端末で開いている・claude -p --resume が走っている');
402
+ assert.equal(read([bg({ state: 'working' })]).reason, 'not_stopped', '立ち上げ中(pid がまだ無い)');
403
+ assert.equal(read([bg({ state: 'failed' })]).reason, 'not_stopped');
404
+ assert.equal(read([{ ...bg({ state: 'done' }), sessionId: LAST }]).reason, 'not_listed', 'claude rm で一覧から消した');
405
+ assert.equal(read([{ kind: 'interactive', sessionId: NEXT, name: 'n' }]).reason, 'not_background');
406
+ assert.equal(read([], { status: 1 }).reason, 'list_failed');
407
+ assert.equal(read(null, { stdout: 'not json' }).reason, 'list_failed');
408
+ assert.equal(readClaudeSessionStopped(NEXT, { spawn: NO_CLAUDE }).reason, 'list_failed');
409
+ });
410
+
411
+ test('旧い会話の側: 人が打った文で、後継が止まっていれば、後継を起こしてその文を最初の入力として渡す', () => withDir(dir => {
412
+ const projects = join(dir, 'claude', 'projects', '-work-app');
413
+ const project = join(dir, 'app');
414
+ mkdirSync(projects, { recursive: true }); mkdirSync(project);
415
+ writeFileSync(join(dir, `${OLD}.json`), JSON.stringify({ schema: 'throughline.claude-auto-handoff.v2', handoff_id: 'h-aaaaaaaa',
416
+ source_session_id: OLD, project_path: project, transcript_path: join(projects, `${OLD}.jsonl`), requested_at: 1_000, updated_at: 2_000,
417
+ error_code: null, state: 'sent', successor: { short_id: 'bbbbbbbb', session_id: NEXT } }));
418
+ const transcript = join(projects, `${NEXT}.jsonl`);
419
+ writeFileSync(transcript, ''); settled(transcript);
420
+ const bg = fields => ({ id: 'bbbbbbbb', kind: 'background', sessionId: NEXT, name: 'n', cwd: project, startedAt: 1, ...fields });
421
+ let listing = [bg({ state: 'done' })];
422
+ let woke = { status: 0, stdout: 'backgrounded · bbbbbbbb · n\n claude attach bbbbbbbb open in this terminal\nnote: woke session bbbbbbbb with its saved options (--name, --model).\n' };
423
+ const calls = [];
424
+ const spawn = (command, args, options) => {
425
+ calls.push({ args, cwd: options.cwd, env: options.env });
426
+ if (args[0] === 'agents') return { status: 0, stdout: JSON.stringify(listing) };
427
+ if (args[0] === '--bg') return typeof woke === 'function' ? woke() : woke;
428
+ return { status: 0, stdout: `${args[0]}ped` };
429
+ };
430
+ const env = { CLAUDECODE: '1', CLAUDE_CODE_ENTRYPOINT: 'cli', CLAUDE_CODE_SESSION_ID: OLD, PATH: '/bin' };
431
+ const submit = (prompt, extraEnv = {}) => redirectClaudeHandedOffPrompt({ payload: { session_id: OLD, prompt }, now: Date.now(), dir, env: { ...env, ...extraEnv }, spawn });
432
+ const inbox = claudeForwardInboxDir(NEXT, dir);
433
+ const pending = () => readClaudeForwardStatus(NEXT, { dir }).pending;
434
+ const claimed = () => { try { return readdirSync(join(inbox, 'claimed')).length; } catch { return 0; } };
435
+ const events = () => readFileSync(join(dir, 'forward.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line));
436
+ const freeClaim = () => rmSync(join(inbox, 'waking'), { force: true });
437
+ const open = `claude attach bbbbbbbb(一覧に無い時は claude --resume ${NEXT})`;
438
+
439
+ // 1) 止まっている後継: 起こして、文をそのまま渡す。option は付けず、文は -- の後。会話ごとの環境変数は渡さない
440
+ const text = '-v を付けて、もう一度ビルドして\n2行目';
441
+ const woken = submit(text);
442
+ assert.deepEqual([woken.wake, woken.forwarded], ['woken', true]);
443
+ assert.equal(woken.output.reason, 'この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: h-aaaaaaaa)。' +
444
+ `後継の会話が止まっていたので、Throughlineが起こして、この入力を後継の最初の入力として渡しました。後継は裏で作業を続けます: ${open}`);
445
+ assert.deepEqual(calls.map(call => call.args), [['agents', '--json', '--all'], ['--bg', '--resume', NEXT, '--', text]]);
446
+ assert.equal(calls[1].cwd, project);
447
+ assert.deepEqual(Object.keys(calls[1].env).filter(key => key.startsWith('CLAUDE')), [], '旧い会話の環境変数を後継へ渡さない');
448
+ assert.deepEqual([pending(), claimed()], [0, 0], '渡した文は置き場に残さない(起きた後継の hook が二重に渡さない)');
449
+ assert.deepEqual(events().at(-1), { at: events().at(-1).at, event: 'woken', session_id: NEXT, source_session_id: OLD, chars: text.length });
450
+ assert.equal(readFileSync(join(dir, 'forward.jsonl'), 'utf8').includes('ビルド'), false, '運んだ記録に本文は書かない');
451
+ assert.equal(JSON.parse(readFileSync(join(dir, 'redirects.jsonl'), 'utf8').trim().split('\n').at(-1)).wake, 'woken');
452
+
453
+ // 2) 起こした直後の次の入力: 番の印が残っているので起こさず、置き場へ置く
454
+ calls.length = 0;
455
+ const second = submit('続けてもう1つ');
456
+ assert.deepEqual([second.wake, second.forwarded, pending()], ['skipped', true, 1]);
457
+ assert.match(second.output.reason, /後継は、先に届いた入力で起きている所です。起きた後継が、次の道具の前か番の終わりに読みます。/);
458
+ assert.deepEqual(calls, [], '一覧も読まない');
459
+ freeClaim();
460
+
461
+ // 3) 動いている後継(手すき・作業中・端末で開いている): 起こさず、置き場へ置いて「動いています」と返す
462
+ for (const entries of [[bg({ state: 'done', pid: 5, status: 'idle' })], [bg({ state: 'working', pid: 5, status: 'busy' })],
463
+ [bg({ state: 'done' }), { kind: 'interactive', sessionId: NEXT, pid: 6, status: 'idle', name: 'n' }]]) {
464
+ listing = entries; calls.length = 0;
465
+ const result = submit('動いている後継へ');
466
+ assert.deepEqual([result.wake, result.forwarded], ['skipped', true]);
467
+ assert.match(result.output.reason, /後継は動いています。/);
468
+ assert.deepEqual(calls.map(call => call.args[0]), ['agents'], '起こす命令は流さない');
469
+ assert.equal(existsSync(join(inbox, 'waking')), false, '起こさなかった時は番を返す');
470
+ }
471
+
472
+ // 4) 止まっていると確かめられない(一覧に無い・立ち上げ中・一覧を読めない): 起こさず、0.16.32 と同じ文
473
+ for (const entries of [[], [bg({ state: 'working' })], 'unreadable']) {
474
+ listing = entries; calls.length = 0;
475
+ const result = submit('確かめられない後継へ');
476
+ assert.deepEqual([result.wake, result.forwarded], ['skipped', true]);
477
+ assert.match(result.output.reason, /後継は今、入力を待ち受けていません(止まっているか、この版より前に立った会話です)。/);
478
+ assert.equal(calls.some(call => call.args[0] === '--bg'), false);
479
+ }
480
+
481
+ // 5) 写しが立った(確かめた後に後継が動き出した): 写しを止めて一覧から消し、入力は置き場へ戻す
482
+ listing = [bg({ state: 'done' })]; calls.length = 0;
483
+ woke = { status: 0, stdout: 'backgrounded · 12345678\nnote: session bbbbbbbb is already running in the background, so this started a copy as 12345678.\n' };
484
+ const before = pending();
485
+ const copied = submit('写しが立つ入力');
486
+ assert.deepEqual([copied.wake, copied.forwarded, pending()], ['queued', true, before + 1]);
487
+ assert.deepEqual(calls.map(call => call.args), [['agents', '--json', '--all'], ['--bg', '--resume', NEXT, '--', '写しが立つ入力'], ['stop', '12345678'], ['rm', '12345678']]);
488
+ assert.match(copied.output.reason, /届いた入力は、後継の会話へ渡しました。後継は動いています。/);
489
+ assert.deepEqual([events().at(-1).event, events().at(-1).copy_short_id, events().at(-1).queued], ['wake-copy', '12345678', true]);
490
+
491
+ // 6) 起こす命令が失敗した: 置き場へ戻す
492
+ woke = { status: 1, stdout: '', stderr: 'Error: something went wrong\n' }; calls.length = 0;
493
+ const failed = submit('失敗する入力');
494
+ assert.deepEqual([failed.wake, failed.forwarded, pending()], ['queued', true, before + 2]);
495
+ assert.match(failed.output.reason, /後継は今、入力を待ち受けていません/);
496
+
497
+ // 7) 起きたか分からない(時間切れ): 入れ直さない
498
+ woke = () => ({ error: Object.assign(new Error('spawnSync claude ETIMEDOUT'), { code: 'ETIMEDOUT' }) });
499
+ const unknown = submit('分からない入力');
500
+ assert.deepEqual([unknown.wake, unknown.forwarded, pending(), claimed()], ['unknown', false, before + 2, 1]);
501
+ assert.equal(unknown.output.reason, 'この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: h-aaaaaaaa)。' +
502
+ '後継の会話が止まっていたので起こそうとしましたが、起きて入力を受け取ったか確かめられませんでした。' +
503
+ `二重に届かないよう、この入力は入れ直していません。後継を開いて確かめてください: ${open}`);
504
+ assert.equal(events().at(-1).event, 'wake-unknown');
505
+ freeClaim();
506
+
507
+ // 8) 起こさない入力: 完了通知・ほかの会話からの文・長すぎる文・NUL を含む文・上書きで止めた時・書かれたばかりの transcript
508
+ woke = { status: 0, stdout: 'backgrounded · bbbbbbbb · n\n' }; calls.length = 0;
509
+ assert.equal(submit('<task-notification>\n<task-id>b1</task-id>\n<status>completed</status>\n</task-notification>').wake, undefined);
510
+ assert.equal(submit('Another Claude session sent a message:\n確認をお願いします').wake, undefined);
511
+ assert.equal(submit('あ'.repeat(CLAUDE_WAKE_TEXT_MAX_CHARS + 1)).wake, 'skipped');
512
+ assert.equal(submit('NUL\0入り').wake, 'skipped');
513
+ assert.equal(submit('止めた時', { THROUGHLINE_AUTO_HANDOFF_WAKE: 'off' }).wake, 'skipped');
514
+ writeFileSync(transcript, '{}\n');
515
+ const fresh = submit('書かれたばかり');
516
+ assert.match(fresh.output.reason, /後継は動いています。/);
517
+ settled(transcript);
518
+ assert.deepEqual(calls, [], 'どれも一覧を読まず、起こす命令も流さない');
519
+ // Claude Desktop へ移した後継も起こさない
520
+ const record = JSON.parse(readFileSync(join(dir, `${OLD}.json`), 'utf8'));
521
+ writeFileSync(join(dir, `${OLD}.json`), JSON.stringify({ ...record, desktop: { wanted: true, state: 'opened' } }));
522
+ assert.equal(submit('Desktop へ移した後継').wake, 'skipped');
523
+ assert.deepEqual(calls, []);
524
+ writeFileSync(join(dir, `${OLD}.json`), JSON.stringify(record));
525
+
526
+ // 9) 後継がさらに引き継がれている時は、一番先の会話を起こす
527
+ writeFileSync(join(dir, `${NEXT}.json`), JSON.stringify({ schema: 'throughline.claude-auto-handoff.v2', handoff_id: 'h-bbbbbbbb',
528
+ source_session_id: NEXT, project_path: project, transcript_path: transcript, requested_at: 1_000, updated_at: 2_000,
529
+ error_code: null, state: 'sent', successor: { short_id: 'cccccccc', session_id: LAST } }));
530
+ writeFileSync(join(projects, `${LAST}.jsonl`), ''); settled(join(projects, `${LAST}.jsonl`));
531
+ listing = [{ id: 'cccccccc', kind: 'background', sessionId: LAST, state: 'done', name: 'n', cwd: project, startedAt: 1 }];
532
+ woke = { status: 0, stdout: 'backgrounded · cccccccc · n\nnote: woke session cccccccc with its saved options (--name).\n' };
533
+ const chained = submit('一番先へ');
534
+ assert.equal(chained.wake, 'woken');
535
+ assert.deepEqual(calls.at(-1).args, ['--bg', '--resume', LAST, '--', '一番先へ']);
536
+ }));
@@ -178,6 +178,7 @@ export async function run() {
178
178
  successor_short_id: redirect.successorShortId,
179
179
  input_kind: redirect.kind,
180
180
  forwarded: redirect.forwarded,
181
+ ...(redirect.wake ? { wake: redirect.wake } : {}),
181
182
  });
182
183
  process.stdout.write(`${JSON.stringify(redirect.output)}\n`);
183
184
  process.exit(0);