throughline 0.16.18 → 0.16.20
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 +45 -1
- package/README.md +1 -1
- package/docs/adr/0049-claude-handoff-uses-registered-project.md +49 -0
- package/docs/adr/0050-claude-successor-with-held-peer-message-moves-to-desktop.md +56 -0
- package/package.json +1 -1
- package/src/claude-auto-handoff.mjs +107 -14
- package/src/claude-auto-handoff.test.mjs +201 -3
- package/src/turn-processor.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,48 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.16.20] — 2026-10-11
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- Claude Codeの自動継続で、後継がターンを終えてもClaude Desktopへ移らず、裏の会話のまま残る場面があった([ADR 0050](docs/adr/0050-claude-successor-with-held-peer-message-moves-to-desktop.md))。Windowsの実機(Claude Code 2.1.296)で2026-10-10に2回起きた(1回は2時間42分後に移り、1回は残ったまま)。利用者の別の会話から`SendMessage`で届いた文を、Claude Codeが承認まで保留していた。保留がある間、`claude agents`はその会話を、ターンの後も`waiting`(`permission prompt`)と返す。Throughlineは手すき(`idle`)の後継だけを移していた。
|
|
18
|
+
- 後継が`waiting`を返す時は、後継のtranscriptの末尾を読む。終わったターン(`stop_reason`が`end_turn`のassistantの行)で止まっていて、3秒後も同じなら、手すきと同じに扱って移す。
|
|
19
|
+
- **移す時に保留されている文は、会話へ届かないまま消える**(オーナーの裁定)。
|
|
20
|
+
- 道具の許可を本当に待っている会話(ターンの途中)と、次のターンが動いている会話は、今までどおり移さない。
|
|
21
|
+
- transcriptから読むのは行の種類と`stop_reason`だけで、本文は読まない。
|
|
22
|
+
|
|
23
|
+
### 対応の範囲
|
|
24
|
+
|
|
25
|
+
| | macOS | Linux | Windows |
|
|
26
|
+
|---|---|---|---|
|
|
27
|
+
| Claude Code | 直した。単体試験と、実機(Claude Code 2.1.289、Claude Desktop)の試験用の会話。保留を作り、0.16.19は止めず、直した版は`claude stop`→本物の`claude --desktop --resume`でDesktopの会話になる事。ターンの途中の許可待ちは止めない事 | 対象外。Claude Desktopへ移す処理が無い(Claude Codeの`--desktop`が無い)。単体試験とCIだけ | 直した。単体試験と、実機(Claude Code 2.1.296)の試験用の会話。保留を作り、0.16.19は止めず、直した版は`claude stop`まで進む事。ターンの途中の許可待ちは止めない事。**試験用の会話では`claude --desktop --resume`を呼んでいない**(止めた後の会話を開く所は0.16.9からの処理で、変えていない) |
|
|
28
|
+
| Codex | 変更なし(会話から会話への文の保留が無い。引き継ぎの部品も別) | 同じ | 同じ |
|
|
29
|
+
| Grok | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
30
|
+
| Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
31
|
+
|
|
32
|
+
### 確かめていない事
|
|
33
|
+
|
|
34
|
+
- 止めた時、送り手の会話へ「届かなかった」と伝わるか。
|
|
35
|
+
- リモートコントロール(claude.ai/codeとClaudeのアプリ)の画面に、保留を承認する問いが出るか。
|
|
36
|
+
- 保留を持つ会話へ指示が届いた直後(その行がtranscriptに書かれる前)に、移す処理が重なる場面。
|
|
37
|
+
|
|
38
|
+
## [0.16.19] — 2026-10-11
|
|
39
|
+
|
|
40
|
+
### 修正
|
|
41
|
+
|
|
42
|
+
- Claude Codeの自動継続で、作業ツリー(`<repository>/.claude/worktrees/<名前>`)の後継をClaude Desktopが取り込んだ後にもう一度引き継ぐと、後継が元のrepositoryで立ち、前任の記憶が入らず、Desktopへも移らなかった([ADR 0049](docs/adr/0049-claude-handoff-uses-registered-project.md))。Windowsの実機(Claude Code 2.1.296)で2026-10-10 23:43に1回起きた。hookの`CLAUDE_PROJECT_DIR`が元のrepositoryを指し、会話を登録したproject(作業ツリー)と違っていた。
|
|
43
|
+
- 後継を立てるprojectを、会話が移った先(`relocated`)、会話を登録したproject(`sessions`。その場所が今もある時)、`CLAUDE_PROJECT_DIR`、hookのcwdの順で決める。
|
|
44
|
+
- 受領を確かめられなかった引き継ぎ(`unknown`・`handoff_delivery_unconfirmed`)でも、その後継がターンを終えたら、Claude Desktopへ移す。今までは裏の会話のまま残り、リモートコントロールからしか見えなかった。`state`と`error_code`は変えない。
|
|
45
|
+
|
|
46
|
+
### 対応の範囲
|
|
47
|
+
|
|
48
|
+
| | macOS | Linux | Windows |
|
|
49
|
+
|---|---|---|---|
|
|
50
|
+
| Claude Code | 直した。単体試験と、切り離した置き場での本物のhookの命令(作業ツリーで登録した会話へ、`CLAUDE_PROJECT_DIR`を元のrepositoryにして`pre-compact`を渡し、後継が作業ツリーで立って記憶が合流する事。直す前の版は`project_mismatch`になる事)。**本物のClaude Desktopが作業ツリーの後継を取り込む場面は、macOSでは未確認** | 直した。単体試験と、切り離した置き場での本物のhookの命令。Claude Desktopへ移す処理はLinuxでは対象外(Claude Codeの`--desktop`が無い)なので、`unknown`の後継を移す直しは働かない | 直した。単体試験と、切り離した置き場での本物のhookの命令(`CLAUDE_PROJECT_DIR`は`C:/…`の形)。実機で起きたのはこのOS。**直した版での本物の引き継ぎは、次に起きた時に記録で確かめる** |
|
|
51
|
+
| Codex | 変更なし。projectはhookのcwdで決める。引き継ぎの部品も別 | 同じ | 同じ |
|
|
52
|
+
| Grok | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
53
|
+
| Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
54
|
+
|
|
13
55
|
## [0.16.18] — 2026-10-11
|
|
14
56
|
|
|
15
57
|
### 変更
|
|
@@ -2289,7 +2331,9 @@ two attempts, instrument first instead of patching again.
|
|
|
2289
2331
|
|
|
2290
2332
|
---
|
|
2291
2333
|
|
|
2292
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.
|
|
2334
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.20...HEAD
|
|
2335
|
+
[0.16.20]: https://github.com/kitepon/Throughline/compare/v0.16.19...v0.16.20
|
|
2336
|
+
[0.16.19]: https://github.com/kitepon/Throughline/compare/v0.16.18...v0.16.19
|
|
2293
2337
|
[0.16.18]: https://github.com/kitepon/Throughline/compare/v0.16.17...v0.16.18
|
|
2294
2338
|
[0.16.17]: https://github.com/kitepon/Throughline/compare/v0.16.16...v0.16.17
|
|
2295
2339
|
[0.16.16]: https://github.com/kitepon/Throughline/compare/v0.16.15...v0.16.16
|
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ throughline auto-handoff disable --host claude
|
|
|
68
68
|
3. 同じprojectに、継続の指示を最初の指示として付けた新しいClaudeの会話を裏で立てます(`claude --bg`)。会話の名前は、前任の会話の題から`<project名>|<作業の概要>(自動引き継ぎ)`にします。モデル・推論強度・権限は旧い会話から引き継ぎます。
|
|
69
69
|
4. 最初の指示を受け取った時に、止めた時点の依頼、そのターンでここまでにしたこと(発言と道具の呼び出し)、実行されなかった道具、直近の会話の原文を注入します(上限9,500字)。止めたターンの道具の入出力は、後継が`throughline detail`で取り出せます。
|
|
70
70
|
|
|
71
|
-
続きは裏の会話で動きます。`claude agents`の一覧と`claude attach <id>`で見られます。Claude Desktopの画面から始まった会話の後継は、そのターンの作業を終えた時に、Claude Desktopへ移して開きます(macOSは0.16.8から、Windowsは0.16.9から。LinuxのClaude Codeには`--desktop`が無いので開きません。[ADR 0043](docs/adr/0043-claude-successor-opens-in-desktop.md))。Claude Codeは裏で動いている会話をDesktopへ移さないので、作業の間はDesktop
|
|
71
|
+
続きは裏の会話で動きます。`claude agents`の一覧と`claude attach <id>`で見られます。Claude Desktopの画面から始まった会話の後継は、そのターンの作業を終えた時に、Claude Desktopへ移して開きます(macOSは0.16.8から、Windowsは0.16.9から。LinuxのClaude Codeには`--desktop`が無いので開きません。[ADR 0043](docs/adr/0043-claude-successor-opens-in-desktop.md))。Claude Codeは裏で動いている会話をDesktopへ移さないので、作業の間はDesktopには出ません。続けて引き継いだ時は、最後に作業を終えた後継だけを開きます。ほかの会話から`SendMessage`で届いた文をClaude Codeが承認待ちで保留している後継も、ターンが終わっていれば開きます。その時に保留されている文は、会話へ届かずに消えます(0.16.20から。[ADR 0050](docs/adr/0050-claude-successor-with-held-peer-message-moves-to-desktop.md))。端末から始めた会話の後継は開きません。手で開く時は、`claude stop <id>`で裏の実行を停止し、`claude --desktop --resume <session-id>`を実行します。`session-id`は`claude agents --json --all`の`sessionId`です。環境変数`THROUGHLINE_AUTO_HANDOFF_OPEN`を`desktop`にすると出どころに関係なく開き、`off`にすると開きません。手動`/compact`、subagentの中の圧縮、`claude -p`などscriptから起動した会話は対象に含めません。 Claude Desktopから始まった会話の後継は、リモートコントロール付きで立てるので、作業の最中もclaude.ai/codeとClaudeのアプリから見られます(0.16.16から。[ADR 0048](docs/adr/0048-claude-desktop-origin-successor-has-remote-control.md))。
|
|
72
72
|
|
|
73
73
|
```bash
|
|
74
74
|
throughline auto-handoff status --host claude --operation <handoff-id> --json
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# ADR 0049: Claude の引き継ぎは、会話を登録した project で後継を立てる。受領を確かめられなかった後継も Desktop へ移す
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-11
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
Claude Code の自動継続は、PreCompact の hook で、後継を立てる project を決める(ADR 0033)。決め方は、会話が別の project へ
|
|
8
|
+
移っていれば移った先、そうでなければ hook の環境変数 `CLAUDE_PROJECT_DIR`、無ければ hook の cwd だった。後継の最初の指示は、
|
|
9
|
+
前任と後継の project(`sessions.project_path`)が同じ時だけ、前任の記憶を合流させる。`sessions` の行は SessionStart の cwd で作る。
|
|
10
|
+
|
|
11
|
+
2026-10-11、オーナーから「WindowsのClaudeの自動スルーラインで起動する新セッションってRemoteがONで立ち上がってるの?
|
|
12
|
+
なんか意図した挙動と違う気がする」と指摘があった。Windows の実機(Claude Code 2.1.296)の記録を読んだ。
|
|
13
|
+
|
|
14
|
+
- 0.16.16 より後の Claude の引き継ぎは5回。5回とも、後継はリモートコントロール付きで立っていた(ADR 0048)。
|
|
15
|
+
4回は、後継がターンを終えた後に Claude Desktop へ移っていた(ADR 0043)。
|
|
16
|
+
- 1回(10/10 23:43)は、受領を確かめられないまま(`unknown`・`handoff_delivery_unconfirmed`)だった。後継は立って作業を
|
|
17
|
+
続けていたが、Desktop へ移らず、裏の会話のまま残った。見えるのはリモートコントロールからだけだった。
|
|
18
|
+
- この回の前任は、作業ツリー(`<repository>/.claude/worktrees/<名前>`)で動く後継を、Claude Desktop が取り込んだ会話だった。
|
|
19
|
+
SessionStart の cwd は作業ツリーで、`sessions` の project も作業ツリーだった。PreCompact の時、`CLAUDE_PROJECT_DIR` は
|
|
20
|
+
元の repository を指していた。transcript に `relocated` の行は無い。
|
|
21
|
+
- その結果、後継は元の repository(branch は `main`)で立った。前任と project が違うので、記憶は合流しなかった
|
|
22
|
+
(`merge_skip_reason: project_mismatch`)。合流しないと受領も残らないので、引き継ぎは `unknown` で終わった。
|
|
23
|
+
- Desktop へ移す処理は、受領が残った引き継ぎ(`sent`)の後継だけを対象にしていた。`unknown` の記録には後継の session id が
|
|
24
|
+
無く、後継の Stop では何も起きなかった。
|
|
25
|
+
|
|
26
|
+
## Decision
|
|
27
|
+
|
|
28
|
+
1. PreCompact で後継を立てる project は、次の順で決める。会話が移った先(`relocated`)、会話を登録した project
|
|
29
|
+
(`sessions.project_path`。その場所が今もある時)、`CLAUDE_PROJECT_DIR`、hook の cwd。
|
|
30
|
+
印(baton)・有効かどうかの判定・後継を立てる場所は、同じ値を使う。
|
|
31
|
+
2. `sessions` を読むのは、自動圧縮で、自動継続が有効で、まだ引き継ぎの記録が無い時だけ。読めない時と、登録した場所が
|
|
32
|
+
もう無い時(作業ツリーを消した後)は、今までと同じ値で進める。
|
|
33
|
+
3. 受領を確かめられなかった引き継ぎ(`unknown`、後継の短い ID だけが記録にある)でも、その後継がターンを終えたら、
|
|
34
|
+
Desktop へ移す。継続の指示は起動時の引数で渡してあるので、ターンを終えた後継は指示を受けている。
|
|
35
|
+
後継の session id を記録へ入れる。`state` と `error_code` は変えない(記憶が合流しなかった事は、記録に残す)。
|
|
36
|
+
4. 後継が立たなかった引き継ぎ(`failed`)は、今までどおり何もしない。
|
|
37
|
+
|
|
38
|
+
## Consequences
|
|
39
|
+
|
|
40
|
+
- 作業ツリーの会話は、Desktop が取り込んだ後に引き継いでも、同じ作業ツリーで続く。記憶も合流する。
|
|
41
|
+
- 何かの理由で受領を確かめられなかった時も、後継は裏の会話のまま残らない。ただし、その後継に前任の記憶が入ったかは
|
|
42
|
+
別の話で、`unknown` の記録を見て確かめる必要がある。
|
|
43
|
+
- 会話を起動した後で `CLAUDE_PROJECT_DIR` だけが変わる場面では、登録した project が優先される。会話を別の project へ
|
|
44
|
+
移す場面は、今までどおり `relocated` の行で分かる。
|
|
45
|
+
- Stop の完了受領の project(`turn-processor`)は変えていない。Desktop が取り込んだ作業ツリーの会話の完了受領は、
|
|
46
|
+
今までどおり `CLAUDE_PROJECT_DIR`(元の repository)へ出る。
|
|
47
|
+
- リモートコントロール付きで立てた後継は、Desktop へ移す時に止める(`claude stop`)。起動時のリモートの接続はそこで切れ、
|
|
48
|
+
移った後は Claude Desktop 自身のリモートの設定に従う。ここは変えていない。
|
|
49
|
+
- Codex・Grok・Cursor は、hook の cwd で project を決める。この ADR の対象は Claude Code だけ。
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# ADR 0050: ほかの会話から届いた文を保留している後継も、ターンが終わっていれば Claude Desktop へ移す
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-11
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
Claude Desktop の画面から始まった会話の後継は、ターンを終えた時に Desktop へ移す(ADR 0043)。移す process は、
|
|
8
|
+
`claude agents --json --all` でその後継が手すき(`status: idle`)になるのを待ち、`claude stop` で止めて、
|
|
9
|
+
`claude --desktop --resume` で開く。手すきにならない時は止めずに戻り、次の Stop でやり直す。
|
|
10
|
+
|
|
11
|
+
2026-10-11、Windows の実機(Claude Code 2.1.296)で、10/10 23:43 に立った後継が、ターンを終えた後も裏の会話のまま残った。
|
|
12
|
+
記録を読んで分かった事は次のとおり。
|
|
13
|
+
|
|
14
|
+
- 後継の transcript は、`stop_reason: end_turn` の assistant の行と `stop_hook_summary` で終わっていた。結果の無い道具の呼び出しは無い。
|
|
15
|
+
- それでも `claude agents` は、その後継を `status: waiting`・`waitingFor: permission prompt` と返していた。
|
|
16
|
+
- 理由は、会話から会話への文(`SendMessage`)の保留だった。Claude Code は、送り手と受け手で権限の設定が違う時、届いた文を
|
|
17
|
+
利用者が承認するまで保留する。transcript には `system` の行(`Held peer message … not delivered to Claude (1 held)`)が1行入る。
|
|
18
|
+
保留が1通でもある間、その会話は、作業中もターンの後も `waiting / permission prompt` を返す。
|
|
19
|
+
- 送り手は利用者の別の会話で、Throughline が送った文ではない。
|
|
20
|
+
- 同じ作業の1つ前の後継も同じだった。保留が入ってから解けるまでの2時間42分、移せなかった(Desktop へ移ったのは、後継が立ってから3時間34分後)。
|
|
21
|
+
|
|
22
|
+
Windows と macOS の試験用の会話で、同じ状態を作って確かめた(Windows は権限の組み合わせ2通り、macOS は1通り)。
|
|
23
|
+
|
|
24
|
+
- 保留を持つ会話は、`claude stop` で止められる。止めた後、`claude -p --resume` と `claude --desktop --resume` で続けられる。
|
|
25
|
+
- 止めると、保留の文は会話へ届かないまま消える。transcript には、知らせの行の中に文の冒頭だけが残る。
|
|
26
|
+
- 道具の許可を本当に待っている会話(ターンの途中)も、同じ `waiting / permission prompt` を返す。この時、transcript の最後は
|
|
27
|
+
道具を呼んだ assistant の行(`stop_reason: tool_use`)で、結果の行が無い。
|
|
28
|
+
|
|
29
|
+
裏の会話の保留を承認する画面へ行く道は、端末の `claude attach` とリモートコントロールだけで、Claude Desktop には出ない。
|
|
30
|
+
オーナーの裁定(Approval Box K-3LPKZE、2026-10-11)は「保留があっても、ターンが終わっていれば Desktop へ移す。
|
|
31
|
+
保留中の文は消える」。
|
|
32
|
+
|
|
33
|
+
## Decision
|
|
34
|
+
|
|
35
|
+
1. 移す process は、後継が `waiting` を返す時、その後継の transcript の末尾を読む。次の全部が当たる時、手すきと同じに扱う。
|
|
36
|
+
- `claude agents` の `state` が `working` でない。
|
|
37
|
+
- 最後の `user`・`assistant` の行が assistant で、`stop_reason` が `end_turn` か `stop_sequence`。道具の呼び出しを含まない。
|
|
38
|
+
- その行の後に、取り出されても消されてもいない順番待ちの指示(`queue-operation` の `enqueue`)が無い。
|
|
39
|
+
- 次に見た時(3秒後)も、transcript の大きさが同じ。
|
|
40
|
+
2. transcript の場所は、後継の Stop の hook が受け取った `transcript_path` を、引き継ぎの記録の `successor.transcript_path` に残して使う。
|
|
41
|
+
公開の表示(`auto-handoff status`)には出さない。
|
|
42
|
+
3. transcript から読むのは、行の種類・`stop_reason`・発言の部品の種類・順番待ちの操作の種類だけ。本文は読まない。
|
|
43
|
+
4. 当たらない時(ターンの途中の許可待ち、次のターンが動いている、transcript を読めない、記録に場所が無い)は、今までどおり止めずに戻る。
|
|
44
|
+
5. `busy` の後継は、今までどおり待つ。
|
|
45
|
+
|
|
46
|
+
## Consequences
|
|
47
|
+
|
|
48
|
+
- ほかの会話から文が届いて保留されている後継も、ターンを終えれば Desktop に出る。
|
|
49
|
+
- その時に保留されている文は、承認も拒否もされずに消える。受け手の会話には届かない。送り手の会話へ知らせが行くかは確かめていない。
|
|
50
|
+
- 保留を持つ会話は、作業中も `waiting` を返す。指示が届いてから、その行が transcript に書かれるまでの間に2回続けて読むと、
|
|
51
|
+
始まったばかりのターンを止める恐れが残る。2回の間隔は3秒で、Claude Code は指示の行をすぐ書くので、起きる窓は狭い。
|
|
52
|
+
- ターンの後も裏で動く処理(裏の shell、裏の subagent)を持つ会話は、止める時にその処理も止まる。手すき(`idle`)の後継を
|
|
53
|
+
止める時と同じで、ここは変えていない。
|
|
54
|
+
- `stop_reason` を transcript に書かない版の Claude Code では、この判定は当たらない。今までどおり、保留が解けるまで待つ。
|
|
55
|
+
- 0.16.19 までの版が残した記録には transcript の場所が無い。その後継は、次の Stop で場所が入ってから移る。
|
|
56
|
+
- Linux は Desktop へ移す処理が無いので、対象外。Codex・Grok・Cursor は、会話から会話への文の保留が無いので、変更なし。
|
package/package.json
CHANGED
|
@@ -14,13 +14,14 @@
|
|
|
14
14
|
* 利用者の承認まで止めるため(0.15.3)。
|
|
15
15
|
* 5. 後継の Stop : Claude Desktop から始まった会話の後継は、そのターンの作業を終えた時に
|
|
16
16
|
* Desktop へ移して開く(`claude stop` → `claude --desktop --resume`。macOS と Windows)。
|
|
17
|
-
* Claude Code は、裏で動いている会話を Desktop
|
|
17
|
+
* Claude Code は、裏で動いている会話を Desktop へ移さない。ほかの会話から届いた文を
|
|
18
|
+
* 保留している後継も、ターンが終わっていれば移す(ADR 0050)。
|
|
18
19
|
*
|
|
19
20
|
* 旧い会話は止めるだけで、空にしない。手動の /compact と subagent の中の圧縮は対象にしない。
|
|
20
21
|
* 状態は ~/.throughline/claude-auto-handoff/ のファイルに持つ(schema は変えない)。
|
|
21
22
|
*/
|
|
22
23
|
|
|
23
|
-
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
24
25
|
import { homedir } from 'node:os';
|
|
25
26
|
import { dirname, join, posix, resolve, win32 } from 'node:path';
|
|
26
27
|
import { randomUUID } from 'node:crypto';
|
|
@@ -249,6 +250,23 @@ function nativeProjectPath(projectPath, platform) {
|
|
|
249
250
|
return (platform === 'win32' ? win32 : posix).resolve(projectPath);
|
|
250
251
|
}
|
|
251
252
|
|
|
253
|
+
/**
|
|
254
|
+
* その会話を sessions へ登録した project(SessionStart の cwd)。行が無い時、その場所がもう無い時は null。
|
|
255
|
+
*
|
|
256
|
+
* 作業ツリーで動く後継を Claude Desktop が取り込むと、hook の CLAUDE_PROJECT_DIR は元の repository を指す
|
|
257
|
+
* (Windows、Claude Code 2.1.296 で実測)。そこで後継を立てると、後継は別の作業ツリーで始まり、前任と project が
|
|
258
|
+
* 違うので記憶も合流しない(ADR 0049)。登録した project で立てれば、前任と後継の project がそろう。
|
|
259
|
+
*/
|
|
260
|
+
function registeredProjectPath(db, sessionId, platform) {
|
|
261
|
+
let registered;
|
|
262
|
+
// sessions を読めない時は、hook の環境から読んだ project で進める(今までと同じ動き)。
|
|
263
|
+
try { registered = db.prepare('SELECT project_path FROM sessions WHERE session_id = ?').get(sessionId)?.project_path; }
|
|
264
|
+
catch { return null; }
|
|
265
|
+
if (typeof registered !== 'string' || !(posix.isAbsolute(registered) || win32.isAbsolute(registered))) return null;
|
|
266
|
+
const native = nativeProjectPath(registered, platform);
|
|
267
|
+
return existsSync(native) ? native : null;
|
|
268
|
+
}
|
|
269
|
+
|
|
252
270
|
/**
|
|
253
271
|
* PreCompact hook の本体。自動圧縮で、その project が有効な時だけ、印と記録を残して圧縮を止める。
|
|
254
272
|
*
|
|
@@ -277,9 +295,15 @@ export function requestClaudeAutoHandoff({
|
|
|
277
295
|
const existing = readClaudeAutoHandoff(sessionId, dir);
|
|
278
296
|
// 有効判定は会話を起動した project で行う。hook の cwd は Bash の cd に追従する。
|
|
279
297
|
// 会話が別の project へ移っている時(Claude Desktop)は、移った先で行う。
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
298
|
+
const relocated = readClaudeRelocatedCwd(payload.transcript_path);
|
|
299
|
+
const hookProjectPath = nativeProjectPath(
|
|
300
|
+
relocated ?? claudeHostAdapter.completionProjectPath({ cwd: payload.cwd ?? process.cwd(), env }), platform);
|
|
301
|
+
let db = null;
|
|
302
|
+
const open = () => (db ??= openDb());
|
|
303
|
+
// 自動圧縮で、機能が有効な時だけ、会話を登録した project を読む(ADR 0049)。
|
|
304
|
+
const projectPath = payload.trigger === 'auto' && !existing && config.enabled && !relocated
|
|
305
|
+
? registeredProjectPath(open(), sessionId, platform) ?? hookProjectPath
|
|
306
|
+
: hookProjectPath;
|
|
283
307
|
|
|
284
308
|
if (payload.trigger !== 'auto') {
|
|
285
309
|
// 人が /compact を選んだ。まだ止めていない記録は取り下げる。
|
|
@@ -298,15 +322,14 @@ export function requestClaudeAutoHandoff({
|
|
|
298
322
|
}
|
|
299
323
|
|
|
300
324
|
// /tl と同じ印。後継の最初の指示が、この会話を前任として合流させる。
|
|
301
|
-
const db = openDb();
|
|
302
325
|
// 会話が別の project へ移っている時(Claude Desktop)は、sessions の project も移った先にそろえる。
|
|
303
326
|
// 合流は前任と後継の project が同じ時だけ行う。付け替えは Stop でも行うが、移った後に1回も Stop を
|
|
304
327
|
// 通らないまま引き継ぐ会話があり、その時は後継へ記憶が入らなかった(0.15.2・0.15.3、macOS で実測)。
|
|
305
|
-
if (
|
|
306
|
-
|
|
328
|
+
if (relocated) {
|
|
329
|
+
open().prepare('UPDATE sessions SET project_path = ? WHERE session_id = ? AND project_path <> ?')
|
|
307
330
|
.run(projectPath, sessionId, projectPath);
|
|
308
331
|
}
|
|
309
|
-
writeBaton(
|
|
332
|
+
writeBaton(open(), { projectPath, sessionId, now });
|
|
310
333
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
311
334
|
removeStale(dir, now);
|
|
312
335
|
const handoffId = randomUUID();
|
|
@@ -729,6 +752,59 @@ function readBackgroundAgent(shortId, { spawn, env }) {
|
|
|
729
752
|
catch { return null; }
|
|
730
753
|
}
|
|
731
754
|
|
|
755
|
+
// 終わったターンの最後の assistant の行が持つ `stop_reason`。道具を呼んだ行(`tool_use`)と、途中で切れた行は含めない。
|
|
756
|
+
const FINISHED_STOP_REASONS = new Set(['end_turn', 'stop_sequence']);
|
|
757
|
+
// 後継の transcript は、末尾だけ読む。ターンの終わりの行は、最後の発言とその後の数行(hook のまとめ、題、保留の知らせ)にある。
|
|
758
|
+
const TRANSCRIPT_TAIL_BYTES = 1024 * 1024;
|
|
759
|
+
|
|
760
|
+
/** transcript の末尾の行を読む。途中から始まる最初の行は捨てる。読めない時は null。 */
|
|
761
|
+
function readTranscriptTail(transcriptPath, bytes = TRANSCRIPT_TAIL_BYTES) {
|
|
762
|
+
let fd;
|
|
763
|
+
try {
|
|
764
|
+
fd = openSync(transcriptPath, 'r');
|
|
765
|
+
const { size } = fstatSync(fd);
|
|
766
|
+
const start = Math.max(0, size - bytes);
|
|
767
|
+
const buffer = Buffer.alloc(size - start);
|
|
768
|
+
const read = readSync(fd, buffer, 0, buffer.length, start);
|
|
769
|
+
const lines = buffer.toString('utf8', 0, read).split('\n');
|
|
770
|
+
if (start > 0) lines.shift();
|
|
771
|
+
const entries = [];
|
|
772
|
+
for (const line of lines) {
|
|
773
|
+
if (!line.trim()) continue;
|
|
774
|
+
try { entries.push(JSON.parse(line)); } catch { /* 書いている途中の最後の行 */ }
|
|
775
|
+
}
|
|
776
|
+
return { size, entries };
|
|
777
|
+
} catch { return null; }
|
|
778
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* 後継の transcript が、終わったターンで止まっているか(ADR 0050)。
|
|
783
|
+
*
|
|
784
|
+
* 止まっている時は、その時の transcript の大きさを返す。呼ぶ側は、続けて2回同じ値が返った時だけ「動いていない」と見る。
|
|
785
|
+
* 止まっていない時(最後の発言が利用者の指示・道具の結果・道具を呼んだ assistant、または順番待ちの指示が残っている)と、
|
|
786
|
+
* 読めない時は null。会話の本文は読まない(行の種類と `stop_reason` だけを見る)。
|
|
787
|
+
*/
|
|
788
|
+
export function readFinishedTurnMark(transcriptPath) {
|
|
789
|
+
if (typeof transcriptPath !== 'string' || !transcriptPath) return null;
|
|
790
|
+
const tail = readTranscriptTail(transcriptPath);
|
|
791
|
+
if (!tail) return null;
|
|
792
|
+
const entries = tail.entries.filter(entry => entry && typeof entry === 'object' && entry.isSidechain !== true);
|
|
793
|
+
const last = entries.findLastIndex(entry => entry.type === 'user' || entry.type === 'assistant');
|
|
794
|
+
if (last < 0) return null;
|
|
795
|
+
const { type, message } = entries[last];
|
|
796
|
+
if (type !== 'assistant' || !FINISHED_STOP_REASONS.has(message?.stop_reason)) return null;
|
|
797
|
+
if (Array.isArray(message.content) && message.content.some(block => block?.type === 'tool_use')) return null;
|
|
798
|
+
// ターンの終わりの後に積まれた指示(`enqueue`)が、取り出されても消されてもいない。次のターンがすぐ始まる。
|
|
799
|
+
let queued = 0;
|
|
800
|
+
for (const entry of entries.slice(last + 1)) {
|
|
801
|
+
if (entry.type !== 'queue-operation') continue;
|
|
802
|
+
if (entry.operation === 'enqueue') queued += 1;
|
|
803
|
+
else if (entry.operation === 'dequeue' || entry.operation === 'remove') queued = Math.max(0, queued - 1);
|
|
804
|
+
}
|
|
805
|
+
return queued > 0 ? null : tail.size;
|
|
806
|
+
}
|
|
807
|
+
|
|
732
808
|
export async function launchClaudeDesktopOpen(sessionId, { dir = claudeAutoHandoffDir() } = {}) {
|
|
733
809
|
if (!validSessionId(sessionId)) throw new ClaudeHandoffError('auto_handoff_session_id_invalid');
|
|
734
810
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -746,17 +822,24 @@ export async function launchClaudeDesktopOpen(sessionId, { dir = claudeAutoHando
|
|
|
746
822
|
/**
|
|
747
823
|
* 後継の Stop hook から呼ぶ。Claude Desktop で開く引き継ぎの後継が、ターンを終えた。
|
|
748
824
|
* その会話を Desktop へ移す process を起動する。対象でない会話には何もしない(null)。
|
|
825
|
+
* `transcriptPath` は、その後継の transcript(Stop の payload の値)。移す process が、ターンが終わっているかを読む(ADR 0050)。
|
|
749
826
|
*
|
|
750
827
|
* @returns {Promise<string|null>} 起動した時は、その引き継ぎ ID
|
|
751
828
|
*/
|
|
752
|
-
export async function requestClaudeDesktopOpen({ sessionId, dir = claudeAutoHandoffDir(), launch = launchClaudeDesktopOpen }) {
|
|
829
|
+
export async function requestClaudeDesktopOpen({ sessionId, transcriptPath = null, dir = claudeAutoHandoffDir(), launch = launchClaudeDesktopOpen }) {
|
|
753
830
|
if (!validSessionId(sessionId)) return null;
|
|
754
831
|
// この会話自身が引き継ぎの途中なら、作業は次の後継が続ける。移すのは、最後に作業を終えた後継だけ。
|
|
755
832
|
if (readJson(recordPath(sessionId, dir))) return null;
|
|
756
|
-
const
|
|
757
|
-
|
|
833
|
+
const waiting = listClaudeAutoHandoffs({ dir }).filter(item => item.desktop?.wanted && !item.desktop.state);
|
|
834
|
+
// 受領を確かめられなかった引き継ぎ(unknown)は、後継の session id が記録に無い。その後継がターンを終えたなら、
|
|
835
|
+
// 後継は動いている。裏の会話のまま残さず、同じように Desktop へ移す(ADR 0049)。
|
|
836
|
+
const record = waiting.find(item => item.state === 'sent' && item.successor?.session_id === sessionId) ??
|
|
837
|
+
waiting.find(item => item.state === 'unknown' && !item.successor?.session_id &&
|
|
838
|
+
typeof item.successor?.short_id === 'string' && sessionId.startsWith(item.successor.short_id));
|
|
758
839
|
if (!record) return null;
|
|
759
|
-
|
|
840
|
+
const successor = { ...record.successor, session_id: sessionId };
|
|
841
|
+
if (typeof transcriptPath === 'string' && transcriptPath) successor.transcript_path = transcriptPath;
|
|
842
|
+
updateRecord(record.source_session_id, { successor, desktop: { ...record.desktop, state: 'requested', error_code: null } }, { dir });
|
|
760
843
|
try { await launch(record.source_session_id, { dir }); }
|
|
761
844
|
catch {
|
|
762
845
|
updateRecord(record.source_session_id, { desktop: { ...record.desktop, state: 'failed', error_code: 'desktop_open_start_failed' } }, { dir });
|
|
@@ -771,6 +854,10 @@ export async function requestClaudeDesktopOpen({ sessionId, dir = claudeAutoHand
|
|
|
771
854
|
* Claude Code は、裏で動いている会話(作業中も、手すきも)を Desktop へ移さない。手すきになるのを待って
|
|
772
855
|
* `claude stop` で止め、`claude --desktop --resume <session id>` で開く。この命令は出力が端末でないと動かないので、
|
|
773
856
|
* 擬似端末(macOS)か新しい console(Windows)の中で呼ぶ。後継がまた動き出した時は止めずに戻り、次の Stop でやり直す。
|
|
857
|
+
*
|
|
858
|
+
* ほかの会話から届いた文を Claude Code が保留している後継は、ターンを終えても手すき(`idle`)にならず、
|
|
859
|
+
* `waiting`(`permission prompt`)のままになる。この時は transcript を読み、終わったターンで止まっていれば同じように移す。
|
|
860
|
+
* 保留されている文は、止める時に消える(ADR 0050)。道具の許可を本当に待っている会話(ターンの途中)は移さない。
|
|
774
861
|
*/
|
|
775
862
|
export async function runClaudeDesktopOpen(sourceSessionId, {
|
|
776
863
|
dir = claudeAutoHandoffDir(),
|
|
@@ -785,16 +872,22 @@ export async function runClaudeDesktopOpen(sourceSessionId, {
|
|
|
785
872
|
if (!record) throw new ClaudeHandoffError('handoff_not_found');
|
|
786
873
|
if (record.desktop?.state !== 'requested') return record;
|
|
787
874
|
const set = fields => { record = updateRecord(sourceSessionId, { desktop: { ...record.desktop, ...fields } }, { dir }); return record; };
|
|
788
|
-
const { short_id: shortId, session_id: successorId } = record.successor ?? {};
|
|
875
|
+
const { short_id: shortId, session_id: successorId, transcript_path: successorTranscript } = record.successor ?? {};
|
|
789
876
|
if (!shortId || !validSessionId(successorId)) return set({ state: 'failed', error_code: 'desktop_open_successor_unknown' });
|
|
790
877
|
const childEnv = successorEnv(env);
|
|
791
878
|
|
|
792
879
|
let idle = false;
|
|
880
|
+
let finishedMark = null;
|
|
793
881
|
for (const deadline = Date.now() + idleTimeoutMs; Date.now() < deadline;) {
|
|
794
882
|
// 後継がさらに引き継ぎを始めた。作業は次の後継が続けるので、この会話は移さない。
|
|
795
883
|
if (readJson(recordPath(successorId, dir))) return set({ state: 'superseded' });
|
|
796
884
|
const agent = readBackgroundAgent(shortId, { spawn, env: childEnv });
|
|
797
885
|
if (agent?.status === 'idle') { idle = true; break; }
|
|
886
|
+
// 保留の文を持つ後継は、作業中もターンの後も `waiting` を返す。transcript が終わったターンで止まっていて、
|
|
887
|
+
// 次に見た時も同じ大きさなら、手すきと同じに扱う。1回だけでは決めない(指示が届いた直後は、その行がまだ無い)。
|
|
888
|
+
const mark = agent?.status === 'waiting' && agent.state !== 'working' ? readFinishedTurnMark(successorTranscript) : null;
|
|
889
|
+
if (mark !== null && mark === finishedMark) { idle = true; break; }
|
|
890
|
+
finishedMark = mark;
|
|
798
891
|
await delay(pollMs);
|
|
799
892
|
}
|
|
800
893
|
// 手すきにならない(次のターンが始まった、一覧を読めない)。止めずに戻り、次の Stop でやり直す。
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { delimiter, dirname, join } from 'node:path';
|
|
7
7
|
import { DatabaseSync } from 'node:sqlite';
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
listClaudeAutoHandoffs,
|
|
17
17
|
publicClaudeAutoHandoff,
|
|
18
18
|
claudeContinuationInput,
|
|
19
|
+
readFinishedTurnMark,
|
|
19
20
|
requestClaudeDesktopOpen,
|
|
20
21
|
runClaudeDesktopOpen,
|
|
21
22
|
} from './claude-auto-handoff.mjs';
|
|
@@ -97,6 +98,37 @@ test('PreCompact: 移っていない会話は、これまでどおり起動し
|
|
|
97
98
|
assert.equal(result.projectPath, '/work/app');
|
|
98
99
|
}));
|
|
99
100
|
|
|
101
|
+
test('PreCompact: CLAUDE_PROJECT_DIR が元の repository を指していても、会話を登録した作業ツリーで引き継ぐ(Claude Desktop が取り込んだ後継)', () => withDir(dir => {
|
|
102
|
+
// 作業ツリーで動く後継を Desktop が取り込むと、hook の cwd は作業ツリー、CLAUDE_PROJECT_DIR は元の repository になる
|
|
103
|
+
const repo = join(dir, 'repo');
|
|
104
|
+
const worktree = join(repo, '.claude', 'worktrees', 'core-ui');
|
|
105
|
+
mkdirSync(worktree, { recursive: true });
|
|
106
|
+
const registered = () => {
|
|
107
|
+
const db = makeBatonDb();
|
|
108
|
+
db.exec('CREATE TABLE sessions (session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL)');
|
|
109
|
+
db.prepare('INSERT INTO sessions VALUES (?, ?)').run(SESSION, worktree);
|
|
110
|
+
return db;
|
|
111
|
+
};
|
|
112
|
+
const adopted = { env: { CLAUDE_PROJECT_DIR: repo }, payload: { cwd: worktree }, platform: process.platform };
|
|
113
|
+
|
|
114
|
+
const { result, db } = request(dir, { db: registered(), ...adopted });
|
|
115
|
+
assert.equal(result.status, 'requested');
|
|
116
|
+
assert.equal(result.projectPath, worktree, '後継は、前任が動いていた作業ツリーで立てる');
|
|
117
|
+
assert.equal(readClaudeAutoHandoff(SESSION, dir).project_path, worktree);
|
|
118
|
+
assert.equal(db.prepare('SELECT session_id FROM handoff_batons WHERE project_path = ?').get(worktree).session_id, SESSION,
|
|
119
|
+
'印も作業ツリーに書く。後継は同じ project の前任だけを合流させる');
|
|
120
|
+
|
|
121
|
+
// 有効かどうかも、登録した project で決める
|
|
122
|
+
rmSync(join(dir, `${SESSION}.json`));
|
|
123
|
+
assert.equal(request(dir, { db: registered(), ...adopted, config: { enabled: true, projects: [repo] } }).result.reason, 'auto_handoff_disabled');
|
|
124
|
+
assert.equal(request(dir, { db: registered(), ...adopted, config: { enabled: true, projects: [worktree] } }).result.status, 'requested');
|
|
125
|
+
|
|
126
|
+
// 登録した場所がもう無い時(作業ツリーを消した後)は、hook の環境から読んだ project で進める
|
|
127
|
+
rmSync(join(dir, `${SESSION}.json`));
|
|
128
|
+
rmSync(worktree, { recursive: true });
|
|
129
|
+
assert.equal(request(dir, { db: registered(), ...adopted }).result.projectPath, repo);
|
|
130
|
+
}));
|
|
131
|
+
|
|
100
132
|
test('PreCompact: 自動圧縮で有効な時だけ、印と記録を残して圧縮を止める', () => withDir(dir => {
|
|
101
133
|
const { result, db } = request(dir);
|
|
102
134
|
assert.equal(result.status, 'requested');
|
|
@@ -725,8 +757,8 @@ test('Stop: Desktop で開く引き継ぎの後継がターンを終えたら、
|
|
|
725
757
|
assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null);
|
|
726
758
|
writeRecord(dir, SESSION, { desktop: { wanted: false, state: null } });
|
|
727
759
|
assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null, '端末から始めた会話の後継は移さない');
|
|
728
|
-
writeRecord(dir, SESSION, { state: '
|
|
729
|
-
assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null, '
|
|
760
|
+
writeRecord(dir, SESSION, { state: 'failed', error_code: 'handoff_successor_launch_failed', successor: null });
|
|
761
|
+
assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null, '後継が立たなかった引き継ぎでは動かない');
|
|
730
762
|
// 対象の後継
|
|
731
763
|
writeRecord(dir, SESSION, {});
|
|
732
764
|
assert.equal(await requestClaudeDesktopOpen({ sessionId: SESSION, dir, launch }), null, '旧い会話自身の Stop では動かない');
|
|
@@ -748,6 +780,32 @@ test('Stop: Desktop で開く引き継ぎの後継がターンを終えたら、
|
|
|
748
780
|
assert.deepEqual([readClaudeAutoHandoff(SESSION, dir).desktop.state, readClaudeAutoHandoff(SESSION, dir).desktop.error_code], ['failed', 'desktop_open_start_failed']);
|
|
749
781
|
}));
|
|
750
782
|
|
|
783
|
+
test('Stop: 受領を確かめられなかった引き継ぎ(unknown)でも、その後継がターンを終えたら Desktop へ移す', () => withDir(async dir => {
|
|
784
|
+
const launched = [];
|
|
785
|
+
const launch = async (id) => { launched.push(id); };
|
|
786
|
+
// worker は、後継の最初の指示の受領を待ち切れなかった。記録にあるのは後継の短い ID だけ
|
|
787
|
+
const unconfirmed = { state: 'unknown', error_code: 'handoff_delivery_unconfirmed', accepted_at: null,
|
|
788
|
+
successor: { short_id: SUCCESSOR.slice(0, 8), session_id: null } };
|
|
789
|
+
writeRecord(dir, SESSION, unconfirmed);
|
|
790
|
+
assert.equal(await requestClaudeDesktopOpen({ sessionId: 'bbbbbbbb-0000-4000-8000-000000000002', dir, launch }), null, '別の会話の Stop では動かない');
|
|
791
|
+
assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), `h-${SESSION}`);
|
|
792
|
+
assert.deepEqual(launched, [SESSION]);
|
|
793
|
+
const record = readClaudeAutoHandoff(SESSION, dir);
|
|
794
|
+
assert.deepEqual(record.successor, { short_id: SUCCESSOR.slice(0, 8), session_id: SUCCESSOR }, '移す process が使う後継の ID を記録へ入れる');
|
|
795
|
+
assert.deepEqual([record.state, record.error_code, record.desktop.state], ['unknown', 'handoff_delivery_unconfirmed', 'requested'],
|
|
796
|
+
'受領を確かめられなかった事は、記録に残したままにする');
|
|
797
|
+
assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null, '2回目の Stop では起動しない');
|
|
798
|
+
|
|
799
|
+
// 移す process は、その記録で後継を止めて Desktop で開く
|
|
800
|
+
const calls = [];
|
|
801
|
+
const opened = await runClaudeDesktopOpen(SESSION, { dir, env: {}, pollMs: 10, idleTimeoutMs: 500,
|
|
802
|
+
spawn: (command, args) => { calls.push(args.slice(0, 2).join(' '));
|
|
803
|
+
return args[0] === 'agents' ? { status: 0, stdout: JSON.stringify([{ id: SUCCESSOR.slice(0, 8), kind: 'background', status: 'idle' }]) } : { status: 0, stdout: '' }; },
|
|
804
|
+
pty: (command, args) => { calls.push(args.join(' ')); return { status: 0, stdout: `Opening session ${SUCCESSOR} in Claude Desktop` }; } });
|
|
805
|
+
assert.deepEqual(calls, ['agents --json', `stop ${SUCCESSOR.slice(0, 8)}`, `--desktop --resume ${SUCCESSOR}`]);
|
|
806
|
+
assert.deepEqual([opened.state, opened.desktop.state], ['unknown', 'opened']);
|
|
807
|
+
}));
|
|
808
|
+
|
|
751
809
|
test('Desktop で開く: 後継が手すきになるのを待って止め、擬似端末の中で claude --desktop --resume を呼ぶ', () => withDir(async dir => {
|
|
752
810
|
const shortId = SUCCESSOR.slice(0, 8);
|
|
753
811
|
const run = async ({ statuses, stopStatus = 0, opened, beforePoll = () => {} }) => {
|
|
@@ -811,6 +869,97 @@ test('Desktop で開く: 後継が手すきになるのを待って止め、擬
|
|
|
811
869
|
assert.equal(untouched.desktop.state, null);
|
|
812
870
|
}));
|
|
813
871
|
|
|
872
|
+
// ---- ほかの会話から届いた文を保留している後継(ADR 0050) ----
|
|
873
|
+
|
|
874
|
+
// Claude Code 2.1.296 の transcript の形。ターンの最後の assistant は `stop_reason: end_turn`、道具を呼ぶ行は `tool_use`。
|
|
875
|
+
const endTurn = text => ({ type: 'assistant', message: { role: 'assistant', stop_reason: 'end_turn', content: [{ type: 'text', text }] } });
|
|
876
|
+
const callsTool = id => ({ type: 'assistant', message: { role: 'assistant', stop_reason: 'tool_use', content: [{ type: 'tool_use', id, name: 'Bash', input: {} }] } });
|
|
877
|
+
const turnTail = [{ type: 'system', subtype: 'stop_hook_summary' }, { type: 'system', subtype: 'turn_duration' }, { type: 'last-prompt' }];
|
|
878
|
+
// 保留の知らせは system の行で、user の行にも順番待ちの行にもならない(2026-10-11 の Windows と macOS の実測)
|
|
879
|
+
const heldNotice = { type: 'system', subtype: 'informational', level: 'warning', content: 'Held peer message from another session' };
|
|
880
|
+
|
|
881
|
+
test('transcript: 終わったターンで止まっている時だけ、印(transcript の大きさ)を返す', () => withDir(dir => {
|
|
882
|
+
const path = join(dir, 'successor.jsonl');
|
|
883
|
+
const mark = entries => { writeFileSync(path, jsonl(entries)); return readFinishedTurnMark(path); };
|
|
884
|
+
const finished = [user('続けて'), callsTool('t1'), toolResult('t1', 'ok'), endTurn('終わりました'), ...turnTail];
|
|
885
|
+
|
|
886
|
+
assert.equal(mark(finished), Buffer.byteLength(jsonl(finished)));
|
|
887
|
+
assert.equal(typeof mark([...finished, heldNotice]), 'number', '保留の知らせが後ろにあっても、ターンは終わっている');
|
|
888
|
+
assert.equal(mark([user('続けて'), callsTool('t1')]), null, '道具の許可を待っている(結果の無い道具の呼び出し)');
|
|
889
|
+
assert.equal(mark([user('続けて'), callsTool('t1'), toolResult('t1', 'ok')]), null, '道具の結果の後、次の発言を待っている');
|
|
890
|
+
assert.equal(mark([...finished, user('次はこれ')]), null, '次の指示が届いている');
|
|
891
|
+
assert.equal(mark([...finished, { type: 'queue-operation', operation: 'enqueue' }]), null, '順番待ちの指示が残っている');
|
|
892
|
+
assert.equal(typeof mark([...finished, { type: 'queue-operation', operation: 'enqueue' }, { type: 'queue-operation', operation: 'remove' }]), 'number',
|
|
893
|
+
'消された順番待ちは数えない');
|
|
894
|
+
assert.equal(mark([user('続けて'), assistant('途中')]), null, '`stop_reason` の無い行では決めない');
|
|
895
|
+
assert.equal(mark([user('続けて'), { type: 'assistant', message: { role: 'assistant', stop_reason: 'max_tokens', content: [{ type: 'text', text: '切れた' }] } }]), null);
|
|
896
|
+
assert.equal(typeof mark([...finished, { ...user('subagent の中'), isSidechain: true }]), 'number', 'subagent の行は見ない');
|
|
897
|
+
assert.equal(mark([]), null);
|
|
898
|
+
assert.equal(readFinishedTurnMark(join(dir, 'missing.jsonl')), null, '読めない時は決めない');
|
|
899
|
+
assert.equal(readFinishedTurnMark(null), null);
|
|
900
|
+
|
|
901
|
+
// 末尾だけを読む。長い transcript でも、最後のターンの行で決まる
|
|
902
|
+
const long = [...Array.from({ length: 400 }, (_, index) => user(`${index}`.padEnd(4_000, 'x'))), ...finished];
|
|
903
|
+
assert.equal(mark(long), Buffer.byteLength(jsonl(long)));
|
|
904
|
+
}));
|
|
905
|
+
|
|
906
|
+
test('Stop: 後継の transcript の場所を、移す process のために記録へ残す', () => withDir(async dir => {
|
|
907
|
+
writeRecord(dir, SESSION, {});
|
|
908
|
+
const launch = async () => {};
|
|
909
|
+
assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, transcriptPath: '/home/u/.claude/projects/-work-app/successor.jsonl', dir, launch }), `h-${SESSION}`);
|
|
910
|
+
assert.deepEqual(readClaudeAutoHandoff(SESSION, dir).successor,
|
|
911
|
+
{ short_id: SUCCESSOR.slice(0, 8), session_id: SUCCESSOR, transcript_path: '/home/u/.claude/projects/-work-app/successor.jsonl' });
|
|
912
|
+
assert.equal(JSON.stringify(publicClaudeAutoHandoff(readClaudeAutoHandoff(SESSION, dir))).includes('successor.jsonl'), false, '公開の表示には場所を出さない');
|
|
913
|
+
}));
|
|
914
|
+
|
|
915
|
+
test('Desktop で開く: 保留の文で waiting のままの後継も、ターンが終わっていれば止めて開く。ターンの途中の許可待ちは移さない', () => withDir(async dir => {
|
|
916
|
+
const shortId = SUCCESSOR.slice(0, 8);
|
|
917
|
+
const transcript = join(dir, 'successor.jsonl');
|
|
918
|
+
const finished = [user('続けて'), callsTool('t1'), toolResult('t1', 'ok'), endTurn('終わりました'), ...turnTail, heldNotice];
|
|
919
|
+
const run = async ({ entries, agent = {}, recordTranscript = transcript, onPoll = () => {} }) => {
|
|
920
|
+
writeFileSync(transcript, jsonl(entries));
|
|
921
|
+
writeRecord(dir, SESSION, { successor: { short_id: shortId, session_id: SUCCESSOR, ...(recordTranscript ? { transcript_path: recordTranscript } : {}) },
|
|
922
|
+
desktop: { wanted: true, state: 'requested', error_code: null, opened_at: null } });
|
|
923
|
+
const calls = [];
|
|
924
|
+
let polls = 0;
|
|
925
|
+
const spawn = (command, args) => {
|
|
926
|
+
calls.push(args[0]);
|
|
927
|
+
if (args[0] !== 'agents') return { status: 0, stdout: '' };
|
|
928
|
+
onPoll(polls += 1);
|
|
929
|
+
return { status: 0, stdout: JSON.stringify([{ id: shortId, kind: 'background', status: 'waiting', waitingFor: 'permission prompt', state: 'done', ...agent }]) };
|
|
930
|
+
};
|
|
931
|
+
const pty = (command, args) => { calls.push(args.join(' ')); return { status: 0, stdout: `Opening session ${SUCCESSOR} in Claude Desktop` }; };
|
|
932
|
+
const record = await runClaudeDesktopOpen(SESSION, { dir, env: {}, spawn, pty, idleTimeoutMs: 80, pollMs: 5 });
|
|
933
|
+
return { record, calls };
|
|
934
|
+
};
|
|
935
|
+
const untouched = result => {
|
|
936
|
+
assert.equal(result.calls.some(call => call === 'stop' || call.startsWith('--desktop')), false, '止めない・開かない');
|
|
937
|
+
assert.equal(result.record.desktop.state, null, '次の Stop でやり直せる');
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
// Windows と macOS の実測(2026-10-11): ほかの会話から届いた文を保留している間、ターンを終えても `waiting / permission prompt`
|
|
941
|
+
const held = await run({ entries: finished });
|
|
942
|
+
assert.deepEqual(held.calls, ['agents', 'agents', 'stop', `--desktop --resume ${SUCCESSOR}`], '続けて2回、同じ所で止まっているのを見てから止める');
|
|
943
|
+
assert.equal(held.record.desktop.state, 'opened');
|
|
944
|
+
// ターンの終わりに付く見立てが blocked の時(実機の後継)も同じ
|
|
945
|
+
assert.equal((await run({ entries: finished, agent: { state: 'blocked' } })).record.desktop.state, 'opened');
|
|
946
|
+
|
|
947
|
+
// 道具の許可を本当に待っている(ターンの途中)
|
|
948
|
+
untouched(await run({ entries: [user('続けて'), callsTool('t1')] }));
|
|
949
|
+
// 保留を持ったまま、次のターンが動いている
|
|
950
|
+
untouched(await run({ entries: [...finished, user('次はこれ'), callsTool('t2'), toolResult('t2', 'ok')] }));
|
|
951
|
+
untouched(await run({ entries: finished, agent: { state: 'working' } }));
|
|
952
|
+
// 見ている間に transcript が伸び続ける(指示が届いた)
|
|
953
|
+
untouched(await run({ entries: finished, onPoll: () => appendFileSync(transcript, jsonl([{ type: 'last-prompt' }])) }));
|
|
954
|
+
const arrived = await run({ entries: finished, onPoll: count => { if (count === 2) appendFileSync(transcript, jsonl([user('次はこれ')])); } });
|
|
955
|
+
untouched(arrived);
|
|
956
|
+
// transcript の場所が記録に無い(0.16.19 までの版が残した記録)・読めない時は、今までどおり待つ
|
|
957
|
+
untouched(await run({ entries: finished, recordTranscript: null }));
|
|
958
|
+
untouched(await run({ entries: finished, recordTranscript: join(dir, 'missing.jsonl') }));
|
|
959
|
+
// busy は、transcript が終わったターンに見えても移さない
|
|
960
|
+
untouched(await run({ entries: finished, agent: { status: 'busy' } }));
|
|
961
|
+
}));
|
|
962
|
+
|
|
814
963
|
test('公開CLI: --host claude は enable・disable・status・worker を受け取る', () => {
|
|
815
964
|
assert.equal(parseAutoHandoffArgs(['status', '--json']).host, 'codex');
|
|
816
965
|
assert.deepEqual(
|
|
@@ -1008,6 +1157,55 @@ test('hook: 自動圧縮を止め、次の道具を止めて worker を起動し
|
|
|
1008
1157
|
assert.deepEqual([merged.triggered_path, merged.injection.auto_handoff_id], ['baton', record.handoff_id]);
|
|
1009
1158
|
}));
|
|
1010
1159
|
|
|
1160
|
+
test('hook: 作業ツリーの後継を Claude Desktop が取り込んだ後(CLAUDE_PROJECT_DIR は元の repository)に引き継いでも、後継は作業ツリーで立ち、記憶が入る', () => withDir(async home => {
|
|
1161
|
+
// fox(Windows、Claude Code 2.1.296)で 2026-10-10 に起きた形。SessionStart の cwd は作業ツリー、
|
|
1162
|
+
// PreCompact の時の CLAUDE_PROJECT_DIR は元の repository だった。
|
|
1163
|
+
const repo = join(home, 'repo');
|
|
1164
|
+
const worktree = join(repo, '.claude', 'worktrees', 'core-ui');
|
|
1165
|
+
mkdirSync(worktree, { recursive: true });
|
|
1166
|
+
const transcriptPath = join(home, 'transcript.jsonl');
|
|
1167
|
+
const adopted = { ...fakeClaudePath(home), CLAUDE_PROJECT_DIR: repo, CLAUDE_CODE_ENTRYPOINT: 'claude-desktop' };
|
|
1168
|
+
const hook = (command, payload) =>
|
|
1169
|
+
cli(home, [command], JSON.stringify({ session_id: SESSION, cwd: worktree, transcript_path: transcriptPath, ...payload }), adopted);
|
|
1170
|
+
|
|
1171
|
+
assert.equal(cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']).status, 0);
|
|
1172
|
+
assert.equal(hook('session-start', { hook_event_name: 'SessionStart', source: 'resume' }).status, 0);
|
|
1173
|
+
writeFileSync(transcriptPath, jsonl([
|
|
1174
|
+
{ ...user('画面の余白を直して', '2026-10-10T14:40:00Z'), entrypoint: 'claude-desktop', cwd: worktree },
|
|
1175
|
+
assistant('余白を直します。', '2026-10-10T14:40:02Z'),
|
|
1176
|
+
toolUse('toolu_9', 'Read', { file_path: join(worktree, 'ui.css') }),
|
|
1177
|
+
]));
|
|
1178
|
+
|
|
1179
|
+
const pre = hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'auto' });
|
|
1180
|
+
assert.equal(pre.status, 2, pre.stderr);
|
|
1181
|
+
const stopped = hook('pre-tool-use', { hook_event_name: 'PreToolUse', tool_name: 'Read', tool_use_id: 'toolu_9', permission_mode: 'auto' });
|
|
1182
|
+
assert.equal(JSON.parse(stopped.stdout).continue, false);
|
|
1183
|
+
const status = () => JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout).handoffs;
|
|
1184
|
+
let handoff;
|
|
1185
|
+
for (let i = 0; i < 100 && (handoff = status()[0]).state !== 'failed'; i++) await new Promise(r => setTimeout(r, 100));
|
|
1186
|
+
assert.equal(handoff.error_code, 'handoff_successor_launch_failed', '偽の claude で止まる。本物は起動しない');
|
|
1187
|
+
assert.equal(handoff.project_path, worktree, '後継を立てる場所は、前任が動いていた作業ツリー');
|
|
1188
|
+
|
|
1189
|
+
// 後継は作業ツリーで立ち、最初の指示(起動時に渡された継続の指示)を受ける
|
|
1190
|
+
const recordFile = join(home, '.throughline', 'claude-auto-handoff', `${SESSION}.json`);
|
|
1191
|
+
const record = JSON.parse(readFileSync(recordFile, 'utf8'));
|
|
1192
|
+
writeFileSync(recordFile, JSON.stringify({ ...record, state: 'launching', error_code: null }));
|
|
1193
|
+
const successorPayload = { session_id: SUCCESSOR, cwd: record.project_path, hook_event_name: 'SessionStart', source: 'startup' };
|
|
1194
|
+
assert.equal(cli(home, ['session-start'], JSON.stringify(successorPayload), fakeClaudePath(home)).status, 0);
|
|
1195
|
+
const first = cli(home, ['prompt-submit'], JSON.stringify({ ...successorPayload, hook_event_name: 'UserPromptSubmit',
|
|
1196
|
+
prompt: claudeContinuationInput(record) }), fakeClaudePath(home));
|
|
1197
|
+
assert.equal(first.status, 0, first.stderr);
|
|
1198
|
+
assert.match(first.stdout, /^## Throughline: 自動継続の文脈\n/);
|
|
1199
|
+
assert.match(first.stdout, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: 画面の余白を直して\n/);
|
|
1200
|
+
assert.equal(existsSync(join(home, '.throughline', 'claude-auto-handoff', `${SESSION}.accepted`)), true, '最初の指示が受領を残す');
|
|
1201
|
+
|
|
1202
|
+
const db = new DatabaseSync(join(home, '.throughline', 'throughline.db'));
|
|
1203
|
+
assert.equal(db.prepare('SELECT merged_into FROM sessions WHERE session_id = ?').get(SESSION).merged_into, SUCCESSOR);
|
|
1204
|
+
db.close();
|
|
1205
|
+
const decisions = readFileSync(join(home, '.throughline', 'logs', 'inheritance-decision.log'), 'utf8').trim().split('\n').map(JSON.parse);
|
|
1206
|
+
assert.deepEqual(decisions.filter(entry => entry.phase === 'prompt-submit').map(entry => [entry.merged, entry.merge_skip_reason ?? null]), [[true, null]]);
|
|
1207
|
+
}));
|
|
1208
|
+
|
|
1011
1209
|
test('hook: 別の project へ移した会話(Claude Desktop)を、移った後に1回も Stop を通らないまま引き継いでも、後継へ記憶が入る', () => withDir(async home => {
|
|
1012
1210
|
// Desktop は「フォルダなし」で始めた会話を project へ移す。SessionStart は移る前の場所で呼ばれ、
|
|
1013
1211
|
// CLAUDE_PROJECT_DIR は移った後も移る前の場所のまま届く。
|
package/src/turn-processor.mjs
CHANGED
|
@@ -273,7 +273,7 @@ async function processStop() {
|
|
|
273
273
|
completeClaudeTurnWithoutHandoff({ sessionId: session_id });
|
|
274
274
|
// Claude Desktop から始まった会話の後継が、ターンを終えた。Desktop へ移して開く process を起動する。
|
|
275
275
|
// 失敗しても、会話の保存は続ける。
|
|
276
|
-
try { await requestClaudeDesktopOpen({ sessionId: session_id }); }
|
|
276
|
+
try { await requestClaudeDesktopOpen({ sessionId: session_id, transcriptPath: transcript_path }); }
|
|
277
277
|
catch (err) { process.stderr.write(`[auto-handoff] desktop open: ${err instanceof Error ? err.message : 'unknown'}\n`); }
|
|
278
278
|
|
|
279
279
|
// VSCode で開かれたプロジェクトに .vscode/tasks.json を自動プロビジョニングする。
|