throughline 0.16.23 → 0.16.25
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 +63 -1
- package/README.md +9 -1
- package/docs/02_clear_auto_handoff_plan.md +3 -1
- package/docs/adr/0047-codex-internal-compaction-is-stopped-without-error.md +7 -0
- package/docs/adr/0051-claude-successor-desktop-move-is-opt-in.md +3 -0
- package/docs/adr/0053-claude-successor-finds-predecessor-by-handoff-id.md +53 -0
- package/docs/adr/0054-codex-compaction-of-thread-without-rollout-is-left-alone.md +62 -0
- package/package.json +1 -1
- package/src/baton.mjs +13 -0
- package/src/claude-auto-handoff.mjs +110 -13
- package/src/claude-auto-handoff.test.mjs +160 -15
- package/src/cli/auto-handoff.mjs +7 -2
- package/src/codex-auto-handoff.mjs +15 -1
- package/src/codex-auto-handoff.test.mjs +31 -3
- package/src/handoff-executor.mjs +20 -8
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,66 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.16.25] — 2026-10-12
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- **macOSのCodex Desktopで、引き継ぎのたびに`handoff_hook_identity_invalid`の失敗が記録されていた**([ADR 0054](docs/adr/0054-codex-compaction-of-thread-without-rollout-is-left-alone.md))。引き継ぎそのものは成功していた。0.16.18で失敗の行へ残すようにした合図の形を、2026-10-11の2回(0.16.23)で読めた。
|
|
18
|
+
- 合図は、引き継ぎ元でも引き継ぎ先でもない会話から来ていた。記録の場所(`transcript_path`)がnullで、会話のIDとターンのIDは正しい形。Codex Desktopのログでは、Desktop自身が立てた、画面に出ない会話(MCPが起動し、ターンが始まって終わる。画面の側は「unknown conversation」として受ける)。旧タスクのターンが止まった直後と、後継の画面を開いた直後に1つずつ出来て、最初のターンで自動圧縮を始めていた。
|
|
19
|
+
- Throughlineは、これを失敗として記録し、その会話のターンを止めていた。
|
|
20
|
+
- 直した後は、`PreCompact(auto)`の合図に記録の場所が無い時、引き継ぎの対象にしない。失敗にせず、圧縮を止める指示も返さない。通した事を`~/.throughline/codex-auto-handoff/unrecorded-compactions.jsonl`へ1行残す(時刻・会話のID・ターンのID・モデル名)。
|
|
21
|
+
- 記録の場所が相対pathの時と、記録を持つ会話の扱いは変えていない。
|
|
22
|
+
- 0.16.14([ADR 0047](docs/adr/0047-codex-internal-compaction-is-stopped-without-error.md))が想定した形(ターンのIDが`auto-compact-N`)は、実機ではまだ来ていない。
|
|
23
|
+
|
|
24
|
+
### 対応の範囲
|
|
25
|
+
|
|
26
|
+
| | macOS | Linux | Windows |
|
|
27
|
+
|---|---|---|---|
|
|
28
|
+
| Codex | 直した。起きた端末。単体試験と、実機で本物のhookの命令へ起きた形の合図を渡した(入っていた0.16.24は失敗を記録して`continue: false`。この版は何も出力せず、失敗の記録は増えない)。本物のCodex Desktopの引き継ぎでは、まだ通していない | 直した。同じ確認。実機でこの失敗が起きた事は無い | 直した。同じ確認。実機でこの失敗が起きた事は無い |
|
|
29
|
+
| Claude Code | 変更なし(この失敗はCodexの`PreCompact`だけが出す。Claude Codeの会話は必ず記録を持つ) | 同じ | 同じ |
|
|
30
|
+
| Grok | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
31
|
+
| Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
32
|
+
|
|
33
|
+
### 確かめていない事
|
|
34
|
+
|
|
35
|
+
- 本物のCodex Desktopが、記録なしの会話から合図を出す場面での、この版の動き。狙って起こせない。次のmacOSの引き継ぎで、失敗ログが増えず、`unrecorded-compactions.jsonl`に行が出る事で確かめる。
|
|
36
|
+
- Codex Desktopが、その会話を何に使っているか。Throughlineが止めていた間に、Desktopの側で欠けていた物があったかも分かっていない。
|
|
37
|
+
- 2026-10-10の5回(0.16.12と0.16.16)は、失敗の行に形が残っていない。同じ検査・同じ場面だが、同じ形だったとは確かめられない。
|
|
38
|
+
|
|
39
|
+
## [0.16.24] — 2026-10-11
|
|
40
|
+
|
|
41
|
+
### 修正
|
|
42
|
+
|
|
43
|
+
- **Claude Codeの自動継続で、後継へ前任の記憶が入らない事があった。同じprojectの別の会話へ、前任の記憶が入る事もあった**([ADR 0053](docs/adr/0053-claude-successor-finds-predecessor-by-handoff-id.md))。後継が前任を見つける所は、`/tl`と同じ「projectごとに1つの印」に頼っていた。印は引き継ぎを予約した時(`PreCompact`)に書き、期限は1時間。後継が立つのは、旧い会話が次に道具を使った時。その間に、同じprojectの別の会話が最初の指示を受けると、印を取るか、期限切れとして消していた。
|
|
44
|
+
- Windowsの実機で2026-10-11に起きた。同じフォルダに役の違うClaudeの会話が何本も動いていた。ある会話が17:21:55に引き継ぎを予約し、止まったのは19:43:09。18:43:06に別の会話の最初の指示が印を期限切れ(81分)として消し、19:43:14に後継が受けた最初の指示には記憶が1文字も入らなかった。引き継ぎは`unknown`(`handoff_delivery_unconfirmed`)で終わり、後継は別の役の仕事を始めた。
|
|
45
|
+
- 直した後は、後継の最初の指示の頭にある引き継ぎIDから記録を引いて、その記録の旧い会話を前任にする。projectごとの印は見ない。自動継続は、予約の時に印を書かない。`/tl`の印は変えていない。
|
|
46
|
+
- 0.16.23までの版が書いた印が残っている時は、後継が合流した時に消す。
|
|
47
|
+
- **受領を確かめられなかった引き継ぎ(`unknown`)の記録を、後継が残っていても24時間で消していた**。消えると、旧い会話がまた道具を使えるようになり、同じ会話から2つ目の後継が立つ。後継のtranscriptが残っている間は消さない。
|
|
48
|
+
|
|
49
|
+
- **Claude Codeの自動継続で、後継のeffortを、hookが値を渡さない時にも引き継ぐ**。後継は、止めた時点のモデルとeffortで立てる(`claude --bg --model … --effort …`)。モデルはtranscriptの最後の発言から、effortは`PreToolUse`のpayload(`effort.level`)から読んでいた。payloadにeffortが無い時は、transcriptの最後の発言の`effort`から読む(subagentの中の行は除く)。どちらにも無い時は、今までどおり端末の既定で立つ。
|
|
50
|
+
- 2026-10-11にWindowsの実機で、xhighの会話の続きがmediumになった。transcriptの`effort`の項目を読むと、落ちたのは引き継ぎの時ではなかった。後継はxhighで立ち(09:35〜10:39、発言365行がxhigh)、0.16.20の「後継をClaude Desktopへ移す処理」(`claude stop` → `claude --desktop --resume`)が10:39:59に開き直した後、mediumになっていた(10:43から)。その後の引き継ぎ2回は、止めた時点のmediumをそのまま渡した。移す処理は0.16.22から既定で無効([ADR 0051](docs/adr/0051-claude-successor-desktop-move-is-opt-in.md))。有効にした時は、開き直した会話のeffortがClaude Desktopの既定に戻る。これは直していない。
|
|
51
|
+
- モデルは落ちていない。同じ列の会話は全部`claude-opus-5-5`で、後継も文脈を90万token台まで使っている。
|
|
52
|
+
|
|
53
|
+
### 追加
|
|
54
|
+
|
|
55
|
+
- **Claudeの会話の「今の続き」を引く口**。`throughline auto-handoff successor --host claude --thread <session id> --json`(読むだけ)。`current_session_id`が今の続き(後継が無ければ渡したID自身)、`chain`がたどった引き継ぎ、`pending`が今の続きの会話にある、後継がまだ立っていない引き継ぎ(`requested`は予約だけで、会話は止まっていない)。後継のsession idが記録に無い時は、transcriptの名前から引く。ほかの道具が、旧い会話あての連絡を今の続きへ渡す時に使う。
|
|
56
|
+
|
|
57
|
+
### 対応の範囲
|
|
58
|
+
|
|
59
|
+
| | macOS | Linux | Windows |
|
|
60
|
+
|---|---|---|---|
|
|
61
|
+
| Claude Code | 直した(effortの控えは単体試験だけ)。単体試験と、実機で本物のhookの命令へ同じ順番(予約 → 別の会話の最初の指示 → 止める → 後継の最初の指示)を通した。入っていた0.16.23は前任の記憶が別の会話へ付き、後継は0文字。この版は後継だけに入る | 直した。同じ確認 | 直した。同じ確認。起きた端末 |
|
|
62
|
+
| Codex | 変更なし(後継はApp Serverで作り、前任は引き継ぎの記録で決めている。projectごとの印を使わない。モデルとeffortは後継の設定へ写し、合わない時は引き継ぎを失敗にする) | 同じ | 同じ |
|
|
63
|
+
| Grok | 変更なし(自動継続が無い。最初の指示の合流は、引き継ぎIDで始まる指示が無いので今までどおり) | 同じ | 同じ |
|
|
64
|
+
| Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
65
|
+
|
|
66
|
+
### 確かめていない事
|
|
67
|
+
|
|
68
|
+
- 本物のClaude Codeが、同じprojectに会話が何本もある状態で引き継ぐ所。確認は、仮のHOMEで、本物のhookの命令(`session-start`・`pre-compact`・`prompt-submit`・`pre-tool-use`)へ、実機で起きた順番を通す形で行った。後継を立てる`claude --bg`は偽物に置き換えている。
|
|
69
|
+
- 記憶が入らなかった後継へ、後から記憶を入れる処理は無い。`throughline recall --session <旧い会話のid>`で引ける。
|
|
70
|
+
- effortの控え(transcriptから読む所)は、単体試験で確かめた。payloadにeffortが無い本物のClaude Codeでは通していない。Windowsの実機の引き継ぎ(Claude Code 2.1.296)では、payloadの値とtranscriptの値は毎回同じだった。
|
|
71
|
+
- 旧い会話は、引き継ぎの後も文を受ければ文で答える(道具は断る)。この版では変えていない。
|
|
72
|
+
|
|
13
73
|
## [0.16.23] — 2026-10-11
|
|
14
74
|
|
|
15
75
|
### 修正
|
|
@@ -2401,7 +2461,9 @@ two attempts, instrument first instead of patching again.
|
|
|
2401
2461
|
|
|
2402
2462
|
---
|
|
2403
2463
|
|
|
2404
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.
|
|
2464
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.25...HEAD
|
|
2465
|
+
[0.16.25]: https://github.com/kitepon/Throughline/compare/v0.16.24...v0.16.25
|
|
2466
|
+
[0.16.24]: https://github.com/kitepon/Throughline/compare/v0.16.23...v0.16.24
|
|
2405
2467
|
[0.16.23]: https://github.com/kitepon/Throughline/compare/v0.16.22...v0.16.23
|
|
2406
2468
|
[0.16.22]: https://github.com/kitepon/Throughline/compare/v0.16.21...v0.16.22
|
|
2407
2469
|
[0.16.21]: https://github.com/kitepon/Throughline/compare/v0.16.20...v0.16.21
|
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ throughline auto-handoff disable --host claude
|
|
|
63
63
|
|
|
64
64
|
自動圧縮を置き換えます。圧縮が走る前に旧い会話を止め、記憶を持った新しい会話で作業を続けます([ADR 0033](docs/adr/0033-claude-auto-handoff-new-session.md))。
|
|
65
65
|
|
|
66
|
-
1.
|
|
66
|
+
1. 自動圧縮の直前に、圧縮を止めて、引き継ぎを予約します。
|
|
67
67
|
2. 旧い会話の次の道具を、実行させずに止めます。旧い会話は止めるだけで、空にしません。
|
|
68
68
|
3. 同じprojectに、継続の指示を最初の指示として付けた新しいClaudeの会話を裏で立てます(`claude --bg`)。会話の名前は、前任の会話の題から`<project名>|<作業の概要>(自動引き継ぎ)`にします。モデル・推論強度・権限は旧い会話から引き継ぎます。
|
|
69
69
|
4. 最初の指示を受け取った時に、止めた時点の依頼、そのターンでここまでにしたこと(発言と道具の呼び出し)、実行されなかった道具、直近の会話の原文を注入します(上限9,500字)。止めたターンの道具の入出力は、後継が`throughline detail`で取り出せます。
|
|
@@ -74,6 +74,14 @@ throughline auto-handoff disable --host claude
|
|
|
74
74
|
throughline auto-handoff status --host claude --operation <handoff-id> --json
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
+
ある会話の今の続きは、次の命令で引けます(読むだけ。ほかの道具が、旧い会話あての連絡を今の続きへ渡す時に使います)。
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
throughline auto-handoff successor --host claude --thread <claude-session-id> --json
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`current_session_id`が今の続きです(後継が無ければ渡したID自身)。`pending.state`が`requested`の間は、引き継ぎを予約しただけで、会話はまだ止まっていません。後継は、最初の指示が運ぶ引き継ぎIDで前任を決めます([ADR 0053](docs/adr/0053-claude-successor-finds-predecessor-by-handoff-id.md))。同じprojectに会話が何本あっても、前任の記憶は後継だけに入ります。
|
|
84
|
+
|
|
77
85
|
後継の立ち上げや配送に失敗した時は、固定の理由を記録して止まります。結果が不明な指示は再送しません。圧縮を止めた後にモデルが道具を呼ばずにターンを終えた時は、後継を立てません。印は残るので、1時間以内に開いた新しい会話が記憶を引き継ぎます。
|
|
78
86
|
|
|
79
87
|
有効にした端末では、Claudeの道具の呼び出しのたびに`PreToolUse`フックが1回走ります。Linux・macOS・Windowsの端末から始めた会話と、macOSのClaude Desktopの画面から始めた会話を実機確認済みです。Desktopでは、移動済みの会話の最初の後継への記憶注入と、最初からフォルダを選んだ会話の連続継承・作業完了を確認しました。[実測と検証範囲](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-05-claude-auto-handoff.md)を参照してください。
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
|
|
26
26
|
1. `SessionStart` は `pending_handoffs` に intent を登録するだけで、merge も注入もしない。
|
|
27
27
|
2. 次の最初の `UserPromptSubmit` が実セッションの存在証明となり、pending intent を一度だけ消費する。
|
|
28
|
-
3.
|
|
28
|
+
3. 届いた指示が Claude の自動継続の配送(文の頭が `Throughline自動継続 <引き継ぎID>`)なら、その引き継ぎの記録の旧い会話を
|
|
29
|
+
前任にする。baton は見ない([ADR 0053](adr/0053-claude-successor-finds-predecessor-by-handoff-id.md))。
|
|
30
|
+
それ以外で、適格な baton があればその session id を優先する。baton が無く、`source='clear'` の
|
|
29
31
|
auto predecessor があれば、その前任を使う。
|
|
30
32
|
4. 前任 transcript を backfill してから merge し、予算内の再開文脈を注入する。
|
|
31
33
|
|
|
@@ -53,3 +53,10 @@ Codex は、利用者のターンとは別に、自分で作る内部の文脈
|
|
|
53
53
|
- 0.16.18 で、この失敗の行へ合図の形(`identity`。ID・種類・絶対 path かどうか)を残す。次に起きた行を読んで、
|
|
54
54
|
扱いを決める。この ADR の Decision 1〜4 は、それまで変えない。
|
|
55
55
|
|
|
56
|
+
## 追記(2026-10-12)
|
|
57
|
+
|
|
58
|
+
0.16.23 の macOS の実機で、形の付いた失敗が2回出た(2026-10-11 16:22:00 と 16:23:01)。不正だったのは `transcript_path`
|
|
59
|
+
(null)で、ターンの ID は UUID だった。合図の会話は、引き継ぎ元でも引き継ぎ先でもない、Codex Desktop が自分で立てた
|
|
60
|
+
記録なしの会話だった。扱いは [ADR 0054](0054-codex-compaction-of-thread-without-rollout-is-left-alone.md) で決めた。
|
|
61
|
+
この ADR の Decision 1〜4(記録を持つ会話の、ターンの ID が `auto-compact-N` の合図)は変えていない。実機でその形が
|
|
62
|
+
来た事は、まだ無い(macOS の実機に `internal-compactions.jsonl` は出来ていない)。
|
|
@@ -45,3 +45,6 @@ Desktop へ開き直していた(ADR 0043)。
|
|
|
45
45
|
止めない。裏の命令が走っている会話を止めない。移した先でリモートコントロールが続く。作業ツリーの会話は作業ツリーで開く。
|
|
46
46
|
- ADR 0050 の「保留の文を持つ後継も移す」と、ADR 0049 の「受領を確かめられなかった後継も移す」は、移す処理を
|
|
47
47
|
有効にした時だけ働く。
|
|
48
|
+
- 移す処理を有効にした時、開き直した会話の effort は Claude Desktop の既定に戻る。後継を立てる時に渡した `--effort` は、
|
|
49
|
+
その process だけの指定で、`claude --desktop --resume` には引き継がれない(2026-10-11、Windows の実機。xhigh で立てた後継が、
|
|
50
|
+
移した後 medium になっていた。0.16.24 の調べ)。
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# ADR 0053: Claude の自動継続の後継は、引き継ぎ ID で前任を決める
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-10-11
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Claude Code の自動継続(ADR 0033)は、`PreCompact(auto)` で圧縮を止めて引き継ぎを予約し、次の `PreToolUse` で旧い会話を止めて
|
|
9
|
+
後継を立てる。後継が前任の記憶を受け取る所は、`/tl` と同じ仕組みを使っていた。予約の時に `handoff_batons`(project ごとに1行)へ
|
|
10
|
+
「この project の次の新しい会話は、この会話の続き」という印を書き、後継の最初の `UserPromptSubmit` がその印を取って合流する。
|
|
11
|
+
|
|
12
|
+
印には、自動継続に合わない所が3つあった。
|
|
13
|
+
|
|
14
|
+
- 印を書くのは予約の時で、後継が立つ時ではない。待ちに入っている会話は、次に道具を使うまで止まらない。印の期限は1時間。
|
|
15
|
+
- 印は project に1つ。同じ project の別の会話が予約すると、上書きされる。
|
|
16
|
+
- 印は、その project で次に最初の指示を受けた新しい会話が取る。後継より先に別の会話が指示を受けると、その会話が前任の記憶を合流させる。
|
|
17
|
+
|
|
18
|
+
2026-10-11、Windows の実機(0.16.23)で起きた。同じフォルダに、役の違う Claude の会話が何本も動いていた。ある会話が 17:21:55 に
|
|
19
|
+
引き継ぎを予約し、道具を使って止まったのは 19:43:09 だった。その間の 18:43:06 に、同じフォルダの別の会話が最初の指示を受け、
|
|
20
|
+
印は期限切れ(81分)として消された。19:43:14 に後継が最初の指示を受けた時、印は無く、記憶は1文字も入らなかった。引き継ぎは
|
|
21
|
+
`unknown`(`handoff_delivery_unconfirmed`)で終わった。後継は記憶なしで自分で現在地を拾い、別の役の仕事を始めた。同じ役の会話が
|
|
22
|
+
2つ同時に動き、止めた会話が受けていた依頼は、だれも始めなかった。
|
|
23
|
+
|
|
24
|
+
後継の最初の指示は、文の頭に `Throughline自動継続 <引き継ぎID>` を持っている。引き継ぎの記録(旧い会話の session id で持つファイル)には
|
|
25
|
+
同じ引き継ぎ ID と、後継の ID の頭8字が入っている。前任を決めるのに要る物は、印が無くてもそろっていた。
|
|
26
|
+
|
|
27
|
+
## Decision
|
|
28
|
+
|
|
29
|
+
1. 後継の最初の `UserPromptSubmit` は、届いた指示の頭の引き継ぎ ID から記録を引き、その記録の旧い会話を前任として合流させる。
|
|
30
|
+
project ごとの印は見ない。取りもしない。
|
|
31
|
+
2. 引き継ぎ ID で前任と認めるのは、記録が後継を立てている状態(`launching`・`launched`・`sent`・`unknown`)で、記録に後継の ID の頭が
|
|
32
|
+
入っている時はそれと合う会話だけ。文の途中に引用された引き継ぎ ID は、配送として扱わない。
|
|
33
|
+
3. 自動継続は、予約の時に project ごとの印を書かない。`/tl` の印は今までどおり(利用者が、次の新しい会話を前任の続きにすると
|
|
34
|
+
決めた時の物)。
|
|
35
|
+
4. 0.16.23 までの版が書いた印が残っている時は、後継が合流した時に、その前任を指す印を消す。
|
|
36
|
+
5. ある会話の今の続きを引く口を、Claude にも作る(`auto-handoff successor --host claude --thread <session id> --json`)。
|
|
37
|
+
記録を先までたどり、後継の session id が記録に無い時(受領を確かめられなかった引き継ぎ)は、transcript の名前から引く。
|
|
38
|
+
6. 受領を確かめられなかった引き継ぎ(`unknown`)の記録も、後継の transcript が残っている間は、期限(24時間)で消さない。
|
|
39
|
+
消すと、旧い会話がまた道具を使えるようになり、同じ会話から2つ目の後継が立つ。
|
|
40
|
+
|
|
41
|
+
## Consequences
|
|
42
|
+
|
|
43
|
+
- 同じ project に Claude の会話が何本あっても、後継は自分の前任の記憶だけを受け取る。予約から止まるまでが何時間あっても同じ。
|
|
44
|
+
- 同じ project で始まった別の新しい会話へ、引き継ぎ中の会話の記憶が入らない。
|
|
45
|
+
- 自動継続が、利用者の `/tl` の印を上書きしない。
|
|
46
|
+
- 引き継ぎの記録が消えた後(後継の transcript も無い時)に届いた継続の指示は、前任を引けず、記憶は入らない。
|
|
47
|
+
- 旧い会話は、記録がある間、文を受ければ文で答える。道具は断る(変えていない)。旧い会話あての連絡を今の続きへ渡すのは、
|
|
48
|
+
連絡を運ぶ側の仕事で、5 の口で今の続きを引ける。
|
|
49
|
+
- 記憶が入らなかった後継へ、後から記憶を入れる処理は無い。`throughline recall --session <旧い会話の id>` で引ける。
|
|
50
|
+
- ほかの製品が、次の2つの形に頼っている(通話MCP `grokbot-bridge` 0.3.6 の `UserPromptSubmit` の hook。後継へ、前任が持っていた
|
|
51
|
+
通話の引き取りを知らせる)。変える時は、先にその製品の担当へ知らせる。
|
|
52
|
+
- 後継の最初の指示の頭: `Throughline自動継続 ` に続けて、36字の引き継ぎ ID。
|
|
53
|
+
- `auto-handoff status --host claude --operation <引き継ぎID> --json` の `handoffs[0].source_session_id`。
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# ADR 0054: 記録(rollout)を持たない Codex の会話の自動圧縮には、手を出さない
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-10-12
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Codex の自動継続(ADR 0034)は、`PreCompact(auto)` の hook で圧縮を止めて、新しいタスクへ引き継ぐ。hook は、合図の
|
|
9
|
+
会話の ID・記録の場所(`transcript_path`)・ターンの ID を検査し、合わなければ `handoff_hook_identity_invalid` で失敗にして、
|
|
10
|
+
圧縮を止める指示(`continue: false`)を返していた。
|
|
11
|
+
|
|
12
|
+
macOS の実機で、この失敗が7回出た(2026-10-10 に5回、10-11 に2回)。どれも Codex の引き継ぎの直後で、引き継ぎそのものは
|
|
13
|
+
成功している。0.16.14(ADR 0047)は「ターンの ID が `auto-compact-N`」の形を想定して直したが、外れていた。0.16.18 で
|
|
14
|
+
失敗の行へ合図の形を残し、10-11 の2回で形が分かった。
|
|
15
|
+
|
|
16
|
+
| | 1回目 | 2回目 |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| 失敗の時刻(JST) | 16:22:00.396 | 16:23:01.422 |
|
|
19
|
+
| 会話の ID | `01a129d6-fe64…`(作られたのは 16:21:58.116) | `01a129d7-e996…`(16:22:58.326) |
|
|
20
|
+
| ターンの ID | UUID(16:21:58.225) | UUID(16:22:58.611) |
|
|
21
|
+
| `transcript_path` | null | null |
|
|
22
|
+
| `cwd`・モデル・`trigger` | 絶対 path・`gpt-6.1-sol`・`auto` | 同じ |
|
|
23
|
+
| agent の ID・種類 | 無し | 無し |
|
|
24
|
+
|
|
25
|
+
引き継ぎは 16:21:57.996 に予約(旧タスク `01a124ce-f984…`)、後継 `01a129d7-de33…` が 16:22:55.411 に出来て、
|
|
26
|
+
16:23:06 に済んだ。失敗した合図の会話は、旧タスクでも後継でもない。
|
|
27
|
+
|
|
28
|
+
Codex Desktop のログ(アプリの行だけを読んだ)では、この2つの会話は Desktop 自身の App Server の中にあり、MCP が
|
|
29
|
+
起動し、`turn/started` と `turn/completed` が出ている。画面の側は「unknown conversation」として受けている。1つ目は
|
|
30
|
+
旧タスクのターンが止まって通知を出した直後、2つ目は後継の画面を開いた直後に出来ている。Throughline の worker は、
|
|
31
|
+
後継のほかに会話を作らない(`thread/start` は1か所)。要約の `codex exec --ephemeral` は別のモデル(既定
|
|
32
|
+
`gpt-5.6-luna`)で動く。
|
|
33
|
+
|
|
34
|
+
つまり、Codex Desktop が自分で立てた、画面に出ない・記録を持たない会話が、最初のターンで自動圧縮を始め、その合図が
|
|
35
|
+
Throughline の hook へ来ていた。Throughline は失敗として記録し、その会話のターンを止めていた(2回とも、hook の失敗の
|
|
36
|
+
0.3〜0.5 秒後に `turn/completed`)。Desktop がその会話を何に使っているかは、分かっていない。
|
|
37
|
+
|
|
38
|
+
Throughline は、会話の記録(rollout)から発言を取り込む。記録を持たない会話は取り込んでいないので、引き継ぐ記憶が無く、
|
|
39
|
+
後継も作れない。
|
|
40
|
+
|
|
41
|
+
## Decision
|
|
42
|
+
|
|
43
|
+
1. `PreCompact(auto)` の合図の `transcript_path` が null か、項目が無い時は、引き継ぎの対象にしない。失敗にせず、
|
|
44
|
+
`continue: false` も返さない(hook は何も出力せず、終了 code 0)。圧縮は Codex がそのまま行う。
|
|
45
|
+
2. 通した事を `~/.throughline/codex-auto-handoff/unrecorded-compactions.jsonl` に1行残す(時刻・会話の ID・ターンの ID・
|
|
46
|
+
モデル名。記号の混じる値は長さだけ)。会話の本文と path は書かない。
|
|
47
|
+
3. `transcript_path` が文字列で、絶対 path でない時は、今までどおり失敗にする(記録を持つはずの会話で、場所だけが不正)。
|
|
48
|
+
4. 記録を持つ会話の扱い(ADR 0034・0047)は変えない。
|
|
49
|
+
|
|
50
|
+
## Consequences
|
|
51
|
+
|
|
52
|
+
- macOS の Codex Desktop の引き継ぎのたびに出ていた失敗の記録が、出なくなる見込み。次の実機の引き継ぎで、失敗ログが
|
|
53
|
+
増えず、`unrecorded-compactions.jsonl` に行が出る事で確かめる。
|
|
54
|
+
- Desktop が立てる画面に出ない会話は、Throughline に止められなくなり、Codex の圧縮が通る。自動継続を入れる前
|
|
55
|
+
(0.11.0 より前)と同じ動きに戻る。
|
|
56
|
+
- 「host の自動圧縮を通さない」という決め(ADR 0034)は、Throughline が記録している会話についての物として読む。
|
|
57
|
+
記録を持たない会話は、その外に置く。
|
|
58
|
+
- 利用者が自分で起こす記録なしの会話(`codex exec --ephemeral` など)も、同じ扱いになる。今までは失敗として止めていた。
|
|
59
|
+
どちらでも引き継ぎは出来ない。
|
|
60
|
+
- Codex が、記録を持つ会話の最初のターンで、記録を作る前に `transcript_path` を null で渡す事があれば、その圧縮も通る。
|
|
61
|
+
実機では見ていない。起きた時は `unrecorded-compactions.jsonl` の会話の ID が、後で記録を持つ会話と一致する事で気づける。
|
|
62
|
+
- 10-10 の5回は、失敗の行に形が残っていない。同じ検査・同じ場面(引き継ぎの直後)だが、同じ形だったとは確かめられない。
|
package/package.json
CHANGED
package/src/baton.mjs
CHANGED
|
@@ -113,3 +113,16 @@ export function consumeBaton(db, { projectPath, now = Date.now(), bornAt = now,
|
|
|
113
113
|
throw err;
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* ある会話を指している印を消す。自動継続の後継は、印ではなく引き継ぎ ID で前任を決める(ADR 0053)。
|
|
119
|
+
* 0.16.23 までの版が予約の時に書いた印が残っていると、同じ project の別の新しい会話がその前任を合流させるので、
|
|
120
|
+
* 後継が合流した時に消す。
|
|
121
|
+
*
|
|
122
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
123
|
+
* @param {{ sessionId: string }} params
|
|
124
|
+
* @returns {number} 消した行数
|
|
125
|
+
*/
|
|
126
|
+
export function discardBatonOf(db, { sessionId }) {
|
|
127
|
+
return Number(db.prepare('DELETE FROM handoff_batons WHERE session_id = ?').run(sessionId).changes);
|
|
128
|
+
}
|
|
@@ -39,7 +39,6 @@ import {
|
|
|
39
39
|
} from './transcript-reader.mjs';
|
|
40
40
|
import { captureInFlightTurn } from './turn-backfill.mjs';
|
|
41
41
|
import { resolveMergeTarget } from './session-merger.mjs';
|
|
42
|
-
import { writeBaton } from './baton.mjs';
|
|
43
42
|
import { sameProjectPath } from './project-path.mjs';
|
|
44
43
|
import { runWithPty } from './os/pty-run.mjs';
|
|
45
44
|
import { spawnPortable, spawnPortableSync } from './os/portable-spawn-sync.mjs';
|
|
@@ -69,6 +68,8 @@ const REMOTE_CONTROL_OVERRIDE_ENV = 'THROUGHLINE_AUTO_HANDOFF_REMOTE_CONTROL';
|
|
|
69
68
|
const RETRYABLE_STATES = new Set(['requested', 'failed']);
|
|
70
69
|
// 後継の最初の指示は、worker が後継の ID を記録へ写す前に届くことがある(launching)。
|
|
71
70
|
const ACCEPTABLE_STATES = new Set(['launching', 'launched', 'sent', 'unknown']);
|
|
71
|
+
/** 後継が立っている状態。 */
|
|
72
|
+
const SUCCESSOR_STATES = new Set(['launched', 'sent', 'unknown']);
|
|
72
73
|
|
|
73
74
|
export const claudeAutoHandoffDir = () => join(homedir(), '.throughline', 'claude-auto-handoff');
|
|
74
75
|
// 0.15.2 までが受け口の控えを置いた場所。期限の掃除だけを続ける。
|
|
@@ -159,16 +160,36 @@ export function publicClaudeAutoHandoff(record) {
|
|
|
159
160
|
* 後継の transcript は、Claude の projects の下の、後継を起動した project のフォルダに出来る。フォルダ名の付け方は
|
|
160
161
|
* OS で違うので、旧い会話の transcript の場所から projects をたどり、その下のフォルダを順に見る。
|
|
161
162
|
*/
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
163
|
+
/** Claude の projects の下から、後継の transcript を探して session id を返す。渡すのは session id か、その頭8字。 */
|
|
164
|
+
function findSuccessorSessionId(record) {
|
|
165
|
+
const known = record?.successor?.session_id;
|
|
166
|
+
const prefix = validSessionId(known) ? known : record?.successor?.short_id;
|
|
167
|
+
if (typeof prefix !== 'string' || !/^[0-9a-f-]{8,}$/.test(prefix) || typeof record.transcript_path !== 'string') return null;
|
|
166
168
|
const own = dirname(record.transcript_path);
|
|
167
|
-
|
|
169
|
+
const inFolder = folder => {
|
|
170
|
+
if (validSessionId(known)) return existsSync(join(folder, `${known}.jsonl`)) ? known : null;
|
|
171
|
+
let names;
|
|
172
|
+
try { names = readdirSync(folder); }
|
|
173
|
+
catch { return null; }
|
|
174
|
+
const file = names.find(name => name.startsWith(prefix) && name.endsWith('.jsonl'));
|
|
175
|
+
return file ? file.slice(0, -'.jsonl'.length) : null;
|
|
176
|
+
};
|
|
177
|
+
const found = inFolder(own);
|
|
178
|
+
if (found) return found;
|
|
168
179
|
let folders;
|
|
169
180
|
try { folders = readdirSync(dirname(own), { withFileTypes: true }); }
|
|
170
|
-
catch { return
|
|
171
|
-
|
|
181
|
+
catch { return null; }
|
|
182
|
+
for (const entry of folders) {
|
|
183
|
+
const id = entry.isDirectory() ? inFolder(join(dirname(own), entry.name)) : null;
|
|
184
|
+
if (id) return id;
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function successorRemains(record) {
|
|
190
|
+
// 受領を確かめられなかった引き継ぎ(unknown)も、後継は立っている。記録を消すと、旧い会話からもう1つ後継が立つ。
|
|
191
|
+
if (!SUCCESSOR_STATES.has(record?.state)) return false;
|
|
192
|
+
return findSuccessorSessionId(record) !== null;
|
|
172
193
|
}
|
|
173
194
|
|
|
174
195
|
function removeStale(dir, now) {
|
|
@@ -200,6 +221,40 @@ function removeStale(dir, now) {
|
|
|
200
221
|
}
|
|
201
222
|
}
|
|
202
223
|
|
|
224
|
+
/**
|
|
225
|
+
* ある会話の「今の続き」を引く(ほかの製品が、旧い会話あての連絡を今の続きへ渡すための入口)。読むだけ。
|
|
226
|
+
* 引き継ぎの記録を先までたどり、後継が立っている一番先の会話を返す。後継の session id が記録に無い時
|
|
227
|
+
* (受領を確かめられなかった引き継ぎ)は、transcript の名前から引く。引けなければ、そこで止まる。
|
|
228
|
+
* @returns {{
|
|
229
|
+
* session_id: string, handed_off: boolean, current_session_id: string,
|
|
230
|
+
* chain: Array<{handoff_id: string, source_session_id: string, successor_session_id: string|null, successor_short_id: string|null,
|
|
231
|
+
* state: string, requested_at: number, updated_at: number}>,
|
|
232
|
+
* pending: null | {handoff_id: string, source_session_id: string, state: string, error_code: string|null, requested_at: number, updated_at: number}
|
|
233
|
+
* }} current_session_id は、後継が無ければ session_id 自身。pending は、今の続きの会話にある、後継がまだ立っていない引き継ぎ
|
|
234
|
+
* (requested: 予約だけで会話は止まっていない、launching: 後継を立てている所、failed: 後継は立たなかった)。
|
|
235
|
+
*/
|
|
236
|
+
export function resolveClaudeAutoHandoffSuccessor(sessionId, { dir = claudeAutoHandoffDir() } = {}) {
|
|
237
|
+
if (!validSessionId(sessionId)) throw new ClaudeHandoffError('auto_handoff_session_id_invalid');
|
|
238
|
+
const chain = [];
|
|
239
|
+
let current = sessionId;
|
|
240
|
+
let pending = null;
|
|
241
|
+
for (let depth = 0; depth < 200; depth++) {
|
|
242
|
+
const record = readClaudeAutoHandoff(current, dir);
|
|
243
|
+
if (!record) break;
|
|
244
|
+
if (!SUCCESSOR_STATES.has(record.state)) {
|
|
245
|
+
pending = { handoff_id: record.handoff_id, source_session_id: record.source_session_id, state: record.state,
|
|
246
|
+
error_code: record.error_code ?? null, requested_at: record.requested_at, updated_at: record.updated_at };
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
const successorId = findSuccessorSessionId(record) ?? (validSessionId(record.successor?.session_id) ? record.successor.session_id : null);
|
|
250
|
+
chain.push({ handoff_id: record.handoff_id, source_session_id: record.source_session_id, successor_session_id: successorId,
|
|
251
|
+
successor_short_id: record.successor?.short_id ?? null, state: record.state, requested_at: record.requested_at, updated_at: record.updated_at });
|
|
252
|
+
if (!successorId || chain.some(step => step.source_session_id === successorId)) break;
|
|
253
|
+
current = successorId;
|
|
254
|
+
}
|
|
255
|
+
return { session_id: sessionId, handed_off: chain.length > 0, current_session_id: current, chain, pending };
|
|
256
|
+
}
|
|
257
|
+
|
|
203
258
|
/** script から起動した Claude(`claude -p`、Agent SDK)は、呼び出し元がその process の結果を待っている。止めない。 */
|
|
204
259
|
function unsupportedEntrypoint(env) {
|
|
205
260
|
return typeof env.CLAUDE_CODE_ENTRYPOINT === 'string' && env.CLAUDE_CODE_ENTRYPOINT.startsWith('sdk-');
|
|
@@ -333,7 +388,9 @@ export function requestClaudeAutoHandoff({
|
|
|
333
388
|
return { status: 'skipped', block: false, reason: 'handoff_host_unsupported', sessionId, projectPath };
|
|
334
389
|
}
|
|
335
390
|
|
|
336
|
-
//
|
|
391
|
+
// 後継は、最初の指示が運ぶ引き継ぎ ID からこの記録を引いて、この会話を前任として合流させる(ADR 0053)。
|
|
392
|
+
// project ごとに1つしか無い `/tl` の印は書かない。印を書いてから後継が立つまでの間に、同じ project の別の会話が
|
|
393
|
+
// 印を消す・上書きする・取ることがあり、後継へ記憶が入らなかった(0.16.23、Windows で実測)。
|
|
337
394
|
// 会話が別の project へ移っている時(Claude Desktop)は、sessions の project も移った先にそろえる。
|
|
338
395
|
// 合流は前任と後継の project が同じ時だけ行う。付け替えは Stop でも行うが、移った後に1回も Stop を
|
|
339
396
|
// 通らないまま引き継ぐ会話があり、その時は後継へ記憶が入らなかった(0.15.2・0.15.3、macOS で実測)。
|
|
@@ -341,7 +398,6 @@ export function requestClaudeAutoHandoff({
|
|
|
341
398
|
open().prepare('UPDATE sessions SET project_path = ? WHERE session_id = ? AND project_path <> ?')
|
|
342
399
|
.run(projectPath, sessionId, projectPath);
|
|
343
400
|
}
|
|
344
|
-
writeBaton(open(), { projectPath, sessionId, now });
|
|
345
401
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
346
402
|
removeStale(dir, now);
|
|
347
403
|
const handoffId = randomUUID();
|
|
@@ -376,6 +432,23 @@ function readLatestModel(transcriptPath) {
|
|
|
376
432
|
return null;
|
|
377
433
|
}
|
|
378
434
|
|
|
435
|
+
/** Claude Code の effort の値。 */
|
|
436
|
+
const EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* 止める時点の effort。transcript の最後の assistant 行(subagent の中の行は除く)から読む。
|
|
440
|
+
* hook の payload に effort が無い時の控え。読めなければ null(後継は、その端末の既定の effort で立つ)。
|
|
441
|
+
*/
|
|
442
|
+
function readLatestEffort(transcriptPath) {
|
|
443
|
+
const entries = readRawEntries(transcriptPath);
|
|
444
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
445
|
+
const entry = entries[index];
|
|
446
|
+
if (entry?.type !== 'assistant' || entry.isSidechain) continue;
|
|
447
|
+
if (EFFORT_LEVELS.has(entry.effort)) return entry.effort;
|
|
448
|
+
}
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
|
|
379
452
|
/**
|
|
380
453
|
* 止める会話の題。人が付けた題を先に読む(Claude Desktop の題と、`--name` で付いた後継の名前もこの行に入る)。
|
|
381
454
|
* 無ければ Claude Code が付けた題を読む。
|
|
@@ -388,6 +461,8 @@ function readSessionTitle(transcriptPath) {
|
|
|
388
461
|
}
|
|
389
462
|
|
|
390
463
|
const CONTINUATION_MARKER = /Throughline自動継続 ([0-9a-f-]{36})/;
|
|
464
|
+
/** 後継の最初の指示そのもの(文の頭にある時だけ)。会話の途中で引用された文は、配送として扱わない。 */
|
|
465
|
+
const CONTINUATION_PREFIX = /^\s*Throughline自動継続 ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?![0-9a-f-])/;
|
|
391
466
|
|
|
392
467
|
// 後継へ渡す「このターンでここまでにしたこと」。1件の長さと件数を抑える。全文は取り込んだ details にある。
|
|
393
468
|
const STEP_TEXT_MAX_CHARS = 300;
|
|
@@ -614,7 +689,10 @@ export function completeClaudeTurnWithoutHandoff({ sessionId, dir = claudeAutoHa
|
|
|
614
689
|
return true;
|
|
615
690
|
}
|
|
616
691
|
|
|
617
|
-
/**
|
|
692
|
+
/**
|
|
693
|
+
* 後継へ送る継続の指示。引き継ぎIDを含め、後継の最初の指示がこの配送のものかを照合できるようにする。
|
|
694
|
+
* 文の頭の形(`Throughline自動継続 ` + 36字の引き継ぎID)は、ほかの製品の hook も読んでいる(ADR 0053)。変える時は先に知らせる。
|
|
695
|
+
*/
|
|
618
696
|
export function claudeContinuationInput(record) {
|
|
619
697
|
// この文は後継の最初の指示(利用者の発言)として届く。止めた道具をやり直さずに「済んだ」と書いて
|
|
620
698
|
// 先へ進む後継が出たので(Haiku 4.5、macOS で実測)、実行されていない道具から始めることを明記する。
|
|
@@ -625,7 +703,24 @@ export function claudeContinuationInput(record) {
|
|
|
625
703
|
}
|
|
626
704
|
|
|
627
705
|
/**
|
|
628
|
-
* 最初の UserPromptSubmit
|
|
706
|
+
* 最初の UserPromptSubmit から呼ぶ。届いた指示が自動継続の配送(文の頭が `Throughline自動継続 <引き継ぎID>`)なら、
|
|
707
|
+
* その引き継ぎの前任の session id を返す(ADR 0053)。記録が無い・後継が別の会話だと分かっている時は null。
|
|
708
|
+
* project ごとの印を使わないので、同じ project に会話が何本あっても、後継は自分の前任だけを合流させる。
|
|
709
|
+
*
|
|
710
|
+
* @returns {string|null}
|
|
711
|
+
*/
|
|
712
|
+
export function findClaudeAutoContinuationSource({ prompt, successorSessionId, dir = claudeAutoHandoffDir() }) {
|
|
713
|
+
if (typeof prompt !== 'string' || typeof successorSessionId !== 'string') return null;
|
|
714
|
+
const handoffId = CONTINUATION_PREFIX.exec(prompt)?.[1];
|
|
715
|
+
if (!handoffId) return null;
|
|
716
|
+
const record = listClaudeAutoHandoffs({ dir }).find(item => item.handoff_id === handoffId);
|
|
717
|
+
if (!record || !ACCEPTABLE_STATES.has(record.state) || record.source_session_id === successorSessionId) return null;
|
|
718
|
+
if (record.successor?.short_id && !successorSessionId.startsWith(record.successor.short_id)) return null;
|
|
719
|
+
return record.source_session_id;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* 最初の UserPromptSubmit(前任の合流)から呼ぶ。前任がこの後継へ自動で引き継いだ会話なら、
|
|
629
724
|
* 止めた時点の依頼を返す。届いた指示がこの引き継ぎの配送なら、受領を記録する。
|
|
630
725
|
*
|
|
631
726
|
* @returns {{handoffId: string, projectPath: string, inFlight: object|null, transcriptPath: string|null}|null}
|
|
@@ -711,7 +806,9 @@ export async function runClaudeAutoHandoffWorker(sessionId, {
|
|
|
711
806
|
if (inFlight && openDb) inFlight.turn = captureStoppedTurn(record, openDb);
|
|
712
807
|
update({
|
|
713
808
|
state: 'launching',
|
|
714
|
-
|
|
809
|
+
// モデルと effort は、止めた時点の物を後継へ渡す。effort は hook の payload の値を使い、無い時だけ transcript から読む。
|
|
810
|
+
settings: { ...record.settings, model: record.transcript_path ? readLatestModel(record.transcript_path) : null,
|
|
811
|
+
effort: record.settings?.effort ?? (record.transcript_path ? readLatestEffort(record.transcript_path) : null) },
|
|
715
812
|
in_flight: inFlight,
|
|
716
813
|
});
|
|
717
814
|
const launch = remoteControl => spawn('claude', successorArgs(record, { remoteControl }), {
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
readFinishedTurnMark,
|
|
20
20
|
requestClaudeDesktopOpen,
|
|
21
21
|
runClaudeDesktopOpen,
|
|
22
|
+
findClaudeAutoContinuationSource,
|
|
23
|
+
resolveClaudeAutoHandoffSuccessor,
|
|
22
24
|
} from './claude-auto-handoff.mjs';
|
|
23
25
|
import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
|
|
24
26
|
|
|
@@ -85,7 +87,6 @@ test('PreCompact: 会話が別の project へ移っている時は、移った
|
|
|
85
87
|
assert.equal(result.block, true);
|
|
86
88
|
assert.equal(result.projectPath, moved);
|
|
87
89
|
assert.equal(readClaudeAutoHandoff(SESSION, dir).project_path, moved);
|
|
88
|
-
assert.equal(db.prepare('SELECT session_id FROM handoff_batons WHERE project_path = ?').get(moved).session_id, SESSION);
|
|
89
90
|
assert.equal(db.prepare('SELECT project_path FROM sessions WHERE session_id = ?').get(SESSION).project_path, moved,
|
|
90
91
|
'後継は同じ project の前任だけを合流させるので、sessions の project も移った先にそろえる');
|
|
91
92
|
}));
|
|
@@ -117,8 +118,7 @@ test('PreCompact: CLAUDE_PROJECT_DIR が元の repository を指していても
|
|
|
117
118
|
assert.equal(result.status, 'requested');
|
|
118
119
|
assert.equal(result.projectPath, worktree, '後継は、前任が動いていた作業ツリーで立てる');
|
|
119
120
|
assert.equal(readClaudeAutoHandoff(SESSION, dir).project_path, worktree);
|
|
120
|
-
assert.equal(db.prepare('SELECT
|
|
121
|
-
'印も作業ツリーに書く。後継は同じ project の前任だけを合流させる');
|
|
121
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM handoff_batons').get().c, 0);
|
|
122
122
|
|
|
123
123
|
// 有効かどうかも、登録した project で決める
|
|
124
124
|
rmSync(join(dir, `${SESSION}.json`));
|
|
@@ -131,13 +131,13 @@ test('PreCompact: CLAUDE_PROJECT_DIR が元の repository を指していても
|
|
|
131
131
|
assert.equal(request(dir, { db: registered(), ...adopted }).result.projectPath, repo);
|
|
132
132
|
}));
|
|
133
133
|
|
|
134
|
-
test('PreCompact:
|
|
134
|
+
test('PreCompact: 自動圧縮で有効な時だけ、記録を残して圧縮を止める。project ごとの印は書かない', () => withDir(dir => {
|
|
135
135
|
const { result, db } = request(dir);
|
|
136
136
|
assert.equal(result.status, 'requested');
|
|
137
137
|
assert.equal(result.block, true);
|
|
138
138
|
assert.equal(result.projectPath, '/work/app');
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
// 後継は引き継ぎ ID で前任を引く(ADR 0053)。同じ project の別の会話が取れる印は残さない
|
|
140
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM handoff_batons').get().c, 0);
|
|
141
141
|
const record = readClaudeAutoHandoff(SESSION, dir);
|
|
142
142
|
assert.equal(record.state, 'requested');
|
|
143
143
|
assert.equal(record.handoff_id, result.handoffId);
|
|
@@ -145,7 +145,6 @@ test('PreCompact: 自動圧縮で有効な時だけ、印と記録を残して
|
|
|
145
145
|
// 同じ圧縮で hook が2回届いても、同じ引き継ぎのまま止め続ける
|
|
146
146
|
const again = request(dir, { db, now: 6_000 }).result;
|
|
147
147
|
assert.deepEqual([again.status, again.block, again.handoffId], ['already_requested', true, result.handoffId]);
|
|
148
|
-
assert.equal(db.prepare('SELECT created_at FROM handoff_batons').get().created_at, 1_000);
|
|
149
148
|
|
|
150
149
|
// 有効判定は会話を起動した project で行う(cwd は Bash の cd に追従する)
|
|
151
150
|
rmSync(join(dir, `${SESSION}.json`));
|
|
@@ -164,7 +163,6 @@ test('PreCompact: Windows の `C:/…` の形の project は、後継が印を
|
|
|
164
163
|
});
|
|
165
164
|
assert.equal(result.status, 'requested');
|
|
166
165
|
assert.equal(result.projectPath, 'C:\\Users\\k\\proj');
|
|
167
|
-
assert.equal(db.prepare('SELECT project_path FROM handoff_batons').get().project_path, 'C:\\Users\\k\\proj');
|
|
168
166
|
assert.equal(readClaudeAutoHandoff(SESSION, dir).project_path, 'C:\\Users\\k\\proj');
|
|
169
167
|
}));
|
|
170
168
|
|
|
@@ -224,20 +222,77 @@ test('PreCompact: 後継の会話が残っている引き継ぎの記録は、
|
|
|
224
222
|
};
|
|
225
223
|
const kept = 'aaaaaaaa-0000-4000-8000-000000000011', moved = 'aaaaaaaa-0000-4000-8000-000000000012';
|
|
226
224
|
const gone = 'aaaaaaaa-0000-4000-8000-000000000013', unconfirmed = 'aaaaaaaa-0000-4000-8000-000000000014';
|
|
227
|
-
const failed = 'aaaaaaaa-0000-4000-8000-000000000015';
|
|
225
|
+
const failed = 'aaaaaaaa-0000-4000-8000-000000000015', unconfirmedAlive = 'aaaaaaaa-0000-4000-8000-000000000016';
|
|
228
226
|
// 後継の transcript が、旧い会話と同じフォルダにある。別の project のフォルダにある(会話を移していた時)。
|
|
229
227
|
writeFileSync(join(projects, '-work-app', `${record(kept)}.jsonl`), '');
|
|
230
228
|
writeFileSync(join(projects, '-moved-app', `${record(moved)}.jsonl`), '');
|
|
231
|
-
//
|
|
229
|
+
// 受領を確かめられなかった(後継の session id が無い)が、後継の transcript は残っている。後継は動いているので、記録を消さない
|
|
230
|
+
record(unconfirmedAlive, { state: 'unknown', successor: { short_id: 'dddddddd', session_id: null } });
|
|
231
|
+
writeFileSync(join(projects, '-moved-app', 'dddddddd-0000-4000-8000-000000000016.jsonl'), '');
|
|
232
|
+
// 後継が消されている。受領を確かめられず、後継の transcript も無い。立ち上げに失敗した。
|
|
232
233
|
record(gone);
|
|
233
|
-
record(unconfirmed, { state: 'unknown', successor: { short_id: '
|
|
234
|
+
record(unconfirmed, { state: 'unknown', successor: { short_id: 'cccccccc', session_id: null } });
|
|
234
235
|
writeFileSync(join(projects, '-work-app', `${record(failed, { state: 'failed' })}.jsonl`), '');
|
|
235
236
|
request(dir, { now });
|
|
236
|
-
assert.deepEqual(readdirSync(dir).filter(name => name.endsWith('.json')).sort(),
|
|
237
|
+
assert.deepEqual(readdirSync(dir).filter(name => name.endsWith('.json')).sort(),
|
|
238
|
+
[`${SESSION}.json`, `${kept}.json`, `${moved}.json`, `${unconfirmedAlive}.json`].sort());
|
|
237
239
|
// 残した記録の会話は、圧縮と道具を止め続ける。同じ会話から2つ目の後継は立たない。
|
|
238
240
|
assert.equal(request(dir, { now, payload: { session_id: kept } }).result.status, 'already_requested');
|
|
239
241
|
}));
|
|
240
242
|
|
|
243
|
+
test('後継の最初の指示: 文の頭の引き継ぎ ID から前任を引く。引用・別の会話・後継が立つ前の記録では引かない', () => withDir(dir => {
|
|
244
|
+
const handoffId = '11111111-2222-4333-8444-555555555555';
|
|
245
|
+
const write = fields => writeFileSync(join(dir, `${SESSION}.json`), JSON.stringify({ schema: 'throughline.claude-auto-handoff.v2',
|
|
246
|
+
handoff_id: handoffId, source_session_id: SESSION, project_path: '/work/app', state: 'launching', successor: null, ...fields }));
|
|
247
|
+
const prompt = claudeContinuationInput({ handoff_id: handoffId });
|
|
248
|
+
const find = (overrides = {}) => findClaudeAutoContinuationSource({ prompt, successorSessionId: SUCCESSOR, dir, ...overrides });
|
|
249
|
+
|
|
250
|
+
write({});
|
|
251
|
+
assert.equal(find(), SESSION, '後継の ID が記録へ入る前(launching)でも、引き継ぎ ID で引ける');
|
|
252
|
+
write({ state: 'unknown', successor: { short_id: SUCCESSOR.slice(0, 8), session_id: null } });
|
|
253
|
+
assert.equal(find(), SESSION, '受領を確かめられなかった後でも引ける');
|
|
254
|
+
assert.equal(find({ successorSessionId: 'ffffffff-0000-4000-8000-000000000001' }), null, '記録の後継と違う会話は、後継と認めない');
|
|
255
|
+
assert.equal(find({ successorSessionId: SESSION }), null, '前任自身は後継ではない');
|
|
256
|
+
assert.equal(find({ prompt: `この文は何?\n${prompt}` }), null, '会話の途中で引用された文は、配送として扱わない');
|
|
257
|
+
assert.equal(find({ prompt: prompt.replace(handoffId, '99999999-2222-4333-8444-555555555555') }), null, '記録に無い引き継ぎ ID');
|
|
258
|
+
assert.equal(find({ prompt: null }), null);
|
|
259
|
+
for (const state of ['requested', 'failed']) {
|
|
260
|
+
write({ state });
|
|
261
|
+
assert.equal(find(), null, `${state}: 後継が立っていない記録`);
|
|
262
|
+
}
|
|
263
|
+
}));
|
|
264
|
+
|
|
265
|
+
test('今の続き: 引き継ぎの記録を先までたどり、後継の ID が記録に無い時は transcript の名前から引く', () => withDir(dir => {
|
|
266
|
+
const projects = join(dir, 'claude', 'projects');
|
|
267
|
+
mkdirSync(join(projects, '-work-app'), { recursive: true }); mkdirSync(join(projects, '-work-app-wt'), { recursive: true });
|
|
268
|
+
const a = 'aaaaaaaa-0000-4000-8000-000000000021', b = 'bbbbbbbb-0000-4000-8000-000000000022', c = 'cccccccc-0000-4000-8000-000000000023';
|
|
269
|
+
const record = (id, fields) => writeFileSync(join(dir, `${id}.json`), JSON.stringify({ schema: 'throughline.claude-auto-handoff.v2',
|
|
270
|
+
handoff_id: `h-${id.slice(0, 8)}`, source_session_id: id, project_path: '/work/app', transcript_path: join(projects, '-work-app', `${id}.jsonl`),
|
|
271
|
+
requested_at: 1_000, updated_at: 2_000, error_code: null, ...fields }));
|
|
272
|
+
assert.deepEqual(resolveClaudeAutoHandoffSuccessor(a, { dir }),
|
|
273
|
+
{ session_id: a, handed_off: false, current_session_id: a, chain: [], pending: null }, '記録が無い会話は、自分自身が今の続き');
|
|
274
|
+
|
|
275
|
+
// a → b(受領済み)→ c(受領を確かめられなかった。後継の transcript は別の project のフォルダにある)。c は引き継ぎを予約しただけ
|
|
276
|
+
record(a, { state: 'sent', successor: { short_id: 'bbbbbbbb', session_id: b } });
|
|
277
|
+
writeFileSync(join(projects, '-work-app', `${b}.jsonl`), '');
|
|
278
|
+
record(b, { state: 'unknown', error_code: 'handoff_delivery_unconfirmed', successor: { short_id: 'cccccccc', session_id: null } });
|
|
279
|
+
writeFileSync(join(projects, '-work-app-wt', `${c}.jsonl`), '');
|
|
280
|
+
record(c, { state: 'requested', successor: null });
|
|
281
|
+
const resolved = resolveClaudeAutoHandoffSuccessor(a, { dir });
|
|
282
|
+
assert.deepEqual([resolved.handed_off, resolved.current_session_id], [true, c]);
|
|
283
|
+
assert.deepEqual(resolved.chain.map(step => [step.source_session_id, step.successor_session_id, step.successor_short_id, step.state]),
|
|
284
|
+
[[a, b, 'bbbbbbbb', 'sent'], [b, c, 'cccccccc', 'unknown']]);
|
|
285
|
+
assert.deepEqual(resolved.pending, { handoff_id: 'h-cccccccc', source_session_id: c, state: 'requested', error_code: null, requested_at: 1_000, updated_at: 2_000 },
|
|
286
|
+
'予約だけの会話は、まだ止まっていない。今の続きはその会話のまま');
|
|
287
|
+
assert.equal(resolveClaudeAutoHandoffSuccessor(b, { dir }).current_session_id, c, '途中の会話から引いても同じ');
|
|
288
|
+
|
|
289
|
+
// 後継の transcript が見つからない時は、そこで止まる(分からない ID を作らない)
|
|
290
|
+
rmSync(join(projects, '-work-app-wt', `${c}.jsonl`));
|
|
291
|
+
const lost = resolveClaudeAutoHandoffSuccessor(a, { dir });
|
|
292
|
+
assert.deepEqual([lost.current_session_id, lost.chain.at(-1).successor_session_id, lost.chain.at(-1).successor_short_id], [b, null, 'cccccccc']);
|
|
293
|
+
assert.throws(() => resolveClaudeAutoHandoffSuccessor('../escape', { dir }), /auto_handoff_session_id_invalid/);
|
|
294
|
+
}));
|
|
295
|
+
|
|
241
296
|
test('PreToolUse: 記録のある会話の道具を実行させずに止め、止めた時点の依頼と設定を写して worker を1回だけ起動する', () => withDir(async dir => {
|
|
242
297
|
const transcriptPath = join(dir, 'transcript.jsonl');
|
|
243
298
|
writeFileSync(transcriptPath, jsonl([
|
|
@@ -297,13 +352,12 @@ test('PreToolUse: worker を起動できなければ failed にし、次の道
|
|
|
297
352
|
assert.equal(readClaudeAutoHandoff(SESSION, dir).state, 'stopped');
|
|
298
353
|
}));
|
|
299
354
|
|
|
300
|
-
test('Stop:
|
|
355
|
+
test('Stop: 圧縮を止めた後、道具を呼ばずにターンが終わったら記録を取り下げる', () => withDir(async dir => {
|
|
301
356
|
const { db } = request(dir);
|
|
302
357
|
assert.equal(completeClaudeTurnWithoutHandoff({ sessionId: 'aaaaaaaa-0000-4000-8000-000000000009', dir }), false);
|
|
303
358
|
assert.equal(completeClaudeTurnWithoutHandoff({ sessionId: `grok:${SESSION}`, dir }), false);
|
|
304
359
|
assert.equal(completeClaudeTurnWithoutHandoff({ sessionId: SESSION, dir }), true);
|
|
305
360
|
assert.equal(readClaudeAutoHandoff(SESSION, dir), null);
|
|
306
|
-
assert.equal(db.prepare('SELECT session_id FROM handoff_batons').get().session_id, SESSION);
|
|
307
361
|
|
|
308
362
|
// 止めた後(worker が動いている)の記録は取り下げない
|
|
309
363
|
request(dir);
|
|
@@ -337,6 +391,36 @@ const launchedOk = () => ({ status: 0, stdout: 'Starting background service…\n
|
|
|
337
391
|
const successorStarts = (dir, predecessorId = SESSION) => args => acceptClaudeAutoContinuation({
|
|
338
392
|
predecessorId, successorSessionId: SUCCESSOR, prompt: args.at(-1), dir, now: 5_000 });
|
|
339
393
|
|
|
394
|
+
test('worker: hook の payload に effort が無い時は、止めた時点の effort を transcript から読んで後継へ渡す', () => withDir(async dir => {
|
|
395
|
+
const transcriptPath = join(dir, 'transcript.jsonl');
|
|
396
|
+
const turn = (text, timestamp, effort, extra = {}) => ({ ...assistant(text, timestamp, 'claude-opus-5-5'), effort, perTurnEffort: effort, ...extra });
|
|
397
|
+
writeFileSync(transcriptPath, jsonl([
|
|
398
|
+
user('設計を見直して', '2026-10-11T00:35:00Z'),
|
|
399
|
+
turn('見直します。', '2026-10-11T00:35:10Z', 'medium'),
|
|
400
|
+
// 利用者が途中で effort を上げた。subagent の中の行は、会話の effort ではない
|
|
401
|
+
turn('続けます。', '2026-10-11T00:40:00Z', 'xhigh'),
|
|
402
|
+
turn('調べます。', '2026-10-11T00:40:05Z', 'low', { isSidechain: true }),
|
|
403
|
+
{ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-5-5', content: [{ type: 'tool_use', id: 'toolu_3', name: 'Read', input: { file_path: '/work/app/a.txt' } }] } },
|
|
404
|
+
]));
|
|
405
|
+
const launch = async payload => {
|
|
406
|
+
rmSync(join(dir, `${SESSION}.json`), { force: true }); rmSync(join(dir, `${SESSION}.claim`), { force: true });
|
|
407
|
+
request(dir, { transcriptPath });
|
|
408
|
+
await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app', transcript_path: transcriptPath, tool_use_id: 'toolu_3', ...payload },
|
|
409
|
+
dir, launchWorker: async () => {} });
|
|
410
|
+
const calls = [];
|
|
411
|
+
const record = await runClaudeAutoHandoffWorker(SESSION, { dir, env: { PATH: '/bin' }, acceptTimeoutMs: 50, pollMs: 10, transcriptTimeoutMs: 50,
|
|
412
|
+
spawn: (command, args) => { calls.push(args); return launchedOk(); } });
|
|
413
|
+
return { record, args: calls[0] };
|
|
414
|
+
};
|
|
415
|
+
const pick = args => ['--model', '--effort'].map(flag => args[args.indexOf(flag) + 1]);
|
|
416
|
+
|
|
417
|
+
const missing = await launch({});
|
|
418
|
+
assert.deepEqual(pick(missing.args), ['claude-opus-5-5', 'xhigh'], '最後の発言の effort(subagent の行は除く)');
|
|
419
|
+
assert.deepEqual([missing.record.settings.model, missing.record.settings.effort], ['claude-opus-5-5', 'xhigh']);
|
|
420
|
+
// payload に effort がある時は、その値を使う
|
|
421
|
+
assert.deepEqual(pick((await launch({ effort: { level: 'max' } })).args), ['claude-opus-5-5', 'max']);
|
|
422
|
+
}));
|
|
423
|
+
|
|
340
424
|
test('worker: 継続の指示を最初の指示として付けて後継を同じprojectに立て、後継の受領を待つ', () => withDir(async dir => {
|
|
341
425
|
const { record, calls } = await runWorker(dir, { spawn: launchedOk, onLaunched: successorStarts(dir) });
|
|
342
426
|
assert.equal(calls.spawn.length, 1);
|
|
@@ -1195,7 +1279,68 @@ test('hook: 自動圧縮を止め、次の道具を止めて worker を起動し
|
|
|
1195
1279
|
[['manual', 'skipped', 'manual_compact'], ['auto', 'requested', null]]);
|
|
1196
1280
|
assert.equal(decisions.filter(entry => entry.phase === 'pre-tool-use-stop').length, 1);
|
|
1197
1281
|
const merged = decisions.find(entry => entry.phase === 'prompt-submit' && entry.merged);
|
|
1198
|
-
assert.deepEqual([merged.triggered_path, merged.injection.auto_handoff_id], ['
|
|
1282
|
+
assert.deepEqual([merged.triggered_path, merged.injection.auto_handoff_id], ['auto-handoff', record.handoff_id]);
|
|
1283
|
+
}));
|
|
1284
|
+
|
|
1285
|
+
test('hook: 引き継ぎを予約してから止まるまでの間に、同じ project の別の会話が最初の指示を受けても、記憶は後継だけに入る', () => withDir(async home => {
|
|
1286
|
+
// fox(Windows、0.16.23)で 2026-10-11 に起きた形。席の会話が 17:21 に引き継ぎを予約し、道具を使ったのは 19:43 だった。
|
|
1287
|
+
// その間に、同じフォルダの別の会話が最初の指示を受けて project ごとの印を消し、後継へ記憶が1文字も入らなかった。
|
|
1288
|
+
const project = join(home, 'project');
|
|
1289
|
+
mkdirSync(project, { recursive: true });
|
|
1290
|
+
const transcriptPath = join(home, 'transcript.jsonl');
|
|
1291
|
+
const OTHER = 'cccccccc-1111-4222-8333-444444444444';
|
|
1292
|
+
const extra = fakeClaudePath(home);
|
|
1293
|
+
const hook = (command, payload, sessionId = SESSION) =>
|
|
1294
|
+
cli(home, [command], JSON.stringify({ session_id: sessionId, cwd: project, transcript_path: transcriptPath, ...payload }), extra);
|
|
1295
|
+
|
|
1296
|
+
assert.equal(cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']).status, 0);
|
|
1297
|
+
assert.equal(hook('session-start', { hook_event_name: 'SessionStart', source: 'startup' }).status, 0);
|
|
1298
|
+
writeFileSync(transcriptPath, jsonl([
|
|
1299
|
+
user('Windows の席として、アプリを入れ直して', '2026-10-11T08:20:00Z'),
|
|
1300
|
+
assistant('入れ直します。', '2026-10-11T08:20:02Z'),
|
|
1301
|
+
toolUse('toolu_7', 'Bash', { command: 'echo reinstall' }),
|
|
1302
|
+
]));
|
|
1303
|
+
assert.equal(hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'auto' }).status, 2);
|
|
1304
|
+
|
|
1305
|
+
// 同じ project で、別の役の新しい会話が始まり、最初の指示を受ける
|
|
1306
|
+
const otherPayload = { session_id: OTHER, cwd: project, transcript_path: join(home, 'other.jsonl') };
|
|
1307
|
+
assert.equal(cli(home, ['session-start'], JSON.stringify({ ...otherPayload, hook_event_name: 'SessionStart', source: 'startup' }), extra).status, 0);
|
|
1308
|
+
const other = cli(home, ['prompt-submit'], JSON.stringify({ ...otherPayload, hook_event_name: 'UserPromptSubmit', prompt: 'Bot の仕事を続けて' }), extra);
|
|
1309
|
+
assert.equal(other.status, 0, other.stderr);
|
|
1310
|
+
assert.doesNotMatch(other.stdout, /Windows の席として/, '別の会話へ、席の会話の記憶を入れない');
|
|
1311
|
+
|
|
1312
|
+
// 席の会話が道具を使い、止まる。後継が立ち、最初の指示(継続の指示)を受ける
|
|
1313
|
+
assert.equal(JSON.parse(hook('pre-tool-use', { hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_use_id: 'toolu_7' }).stdout).continue, false);
|
|
1314
|
+
const status = () => JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout).handoffs;
|
|
1315
|
+
for (let i = 0; i < 100 && status()[0].state !== 'failed'; i++) await new Promise(r => setTimeout(r, 100));
|
|
1316
|
+
const recordFile = join(home, '.throughline', 'claude-auto-handoff', `${SESSION}.json`);
|
|
1317
|
+
const record = JSON.parse(readFileSync(recordFile, 'utf8'));
|
|
1318
|
+
writeFileSync(recordFile, JSON.stringify({ ...record, state: 'launched', error_code: null, successor: { short_id: SUCCESSOR.slice(0, 8), session_id: null } }));
|
|
1319
|
+
const successorPayload = { session_id: SUCCESSOR, cwd: project, transcript_path: join(home, 'successor.jsonl') };
|
|
1320
|
+
assert.equal(cli(home, ['session-start'], JSON.stringify({ ...successorPayload, hook_event_name: 'SessionStart', source: 'startup' }), extra).status, 0);
|
|
1321
|
+
const first = cli(home, ['prompt-submit'], JSON.stringify({ ...successorPayload, hook_event_name: 'UserPromptSubmit',
|
|
1322
|
+
prompt: claudeContinuationInput(record) }), extra);
|
|
1323
|
+
assert.equal(first.status, 0, first.stderr);
|
|
1324
|
+
assert.match(first.stdout, /^## Throughline: 自動継続の文脈\n/);
|
|
1325
|
+
assert.match(first.stdout, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: Windows の席として、アプリを入れ直して\n/);
|
|
1326
|
+
assert.equal(existsSync(join(home, '.throughline', 'claude-auto-handoff', `${SESSION}.accepted`)), true, '最初の指示が受領を残す');
|
|
1327
|
+
|
|
1328
|
+
const db = new DatabaseSync(join(home, '.throughline', 'throughline.db'));
|
|
1329
|
+
assert.equal(db.prepare('SELECT merged_into FROM sessions WHERE session_id = ?').get(SESSION).merged_into, SUCCESSOR);
|
|
1330
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM handoff_batons').get().c, 0);
|
|
1331
|
+
db.close();
|
|
1332
|
+
const decisions = readFileSync(join(home, '.throughline', 'logs', 'inheritance-decision.log'), 'utf8').trim().split('\n').map(JSON.parse)
|
|
1333
|
+
.filter(entry => entry.phase === 'prompt-submit');
|
|
1334
|
+
assert.deepEqual(decisions.map(entry => [entry.session_id, entry.triggered_path, entry.merged]),
|
|
1335
|
+
[[OTHER, null, false], [SUCCESSOR, 'auto-handoff', true]]);
|
|
1336
|
+
|
|
1337
|
+
// 今の続きを引く口(ほかの製品が、旧い会話あての連絡を付け替える時に使う)
|
|
1338
|
+
writeFileSync(join(home, 'successor.jsonl'), '');
|
|
1339
|
+
const resolved = JSON.parse(cli(home, ['auto-handoff', 'successor', '--host', 'claude', '--thread', SESSION, '--json']).stdout);
|
|
1340
|
+
assert.deepEqual([resolved.schema, resolved.host, resolved.session_id, resolved.handed_off], ['throughline.claude_auto_handoff_successor.v1', 'claude', SESSION, true]);
|
|
1341
|
+
assert.equal(resolved.chain[0].successor_short_id, SUCCESSOR.slice(0, 8));
|
|
1342
|
+
const missing = cli(home, ['auto-handoff', 'successor', '--host', 'claude', '--json']);
|
|
1343
|
+
assert.deepEqual([missing.status, JSON.parse(missing.stdout)], [1, { status: 'failed', code: 'auto_handoff_thread_required' }]);
|
|
1199
1344
|
}));
|
|
1200
1345
|
|
|
1201
1346
|
test('hook: 作業ツリーの後継を Claude Desktop が取り込んだ後(CLAUDE_PROJECT_DIR は元の repository)に引き継いでも、後継は作業ツリーで立ち、記憶が入る', () => withDir(async home => {
|
package/src/cli/auto-handoff.mjs
CHANGED
|
@@ -10,11 +10,12 @@ import { installCodexAutoHandoffHook, installClaudeAutoHandoffHooks, removeClaud
|
|
|
10
10
|
claudeAutoHandoffHooksRegistered } from './install.mjs';
|
|
11
11
|
import { sameProjectPath } from '../project-path.mjs';
|
|
12
12
|
import { readClaudeAutoHandoffConfig, writeClaudeAutoHandoffConfig } from '../claude-auto-handoff-config.mjs';
|
|
13
|
-
import { listClaudeAutoHandoffs, publicClaudeAutoHandoff, runClaudeAutoHandoffWorker,
|
|
13
|
+
import { listClaudeAutoHandoffs, publicClaudeAutoHandoff, resolveClaudeAutoHandoffSuccessor, runClaudeAutoHandoffWorker,
|
|
14
|
+
runClaudeDesktopOpen } from '../claude-auto-handoff.mjs';
|
|
14
15
|
|
|
15
16
|
// Claude の自動継続 (ADR 0033)。引き継ぎの記録は旧い会話の session id で持つ。結果が不明な配送は
|
|
16
17
|
// 再送しないので、resume は持たない。記憶の取得は通常の recall / detail を使うので、detail も持たない。
|
|
17
|
-
const CLAUDE_ACTIONS = ['enable', 'disable', 'status', 'worker', 'desktop-open'];
|
|
18
|
+
const CLAUDE_ACTIONS = ['enable', 'disable', 'status', 'worker', 'desktop-open', 'successor'];
|
|
18
19
|
|
|
19
20
|
export function parseAutoHandoffArgs(args) {
|
|
20
21
|
const out = { action: args[0] ?? 'status', host: 'codex', operation: null, project: null, origin: null, turn: null, database: null, thread: null, json: false, dryRun: false };
|
|
@@ -88,6 +89,10 @@ async function runClaude(parsed) {
|
|
|
88
89
|
}
|
|
89
90
|
if (parsed.action === 'worker') return publicClaudeAutoHandoff(await runClaudeAutoHandoffWorker(parsed.operation, { openDb: getDb }));
|
|
90
91
|
if (parsed.action === 'desktop-open') return publicClaudeAutoHandoff(await runClaudeDesktopOpen(parsed.operation));
|
|
92
|
+
// ある会話の今の続きを返す。読むだけ(`--thread` には Claude の session id を渡す)。
|
|
93
|
+
if (parsed.action === 'successor') {
|
|
94
|
+
return { schema: 'throughline.claude_auto_handoff_successor.v1', host: 'claude', ...resolveClaudeAutoHandoffSuccessor(parsed.thread) };
|
|
95
|
+
}
|
|
91
96
|
const handoffs = listClaudeAutoHandoffs()
|
|
92
97
|
.filter(record => !parsed.project || sameProjectPath(record.project_path, parsed.project))
|
|
93
98
|
.filter(record => !parsed.operation || record.handoff_id === parsed.operation || record.source_session_id === parsed.operation)
|
|
@@ -132,6 +132,12 @@ function recordInternalCompactionStop(entry) {
|
|
|
132
132
|
appendFileSync(join(stateRoot(), 'internal-compactions.jsonl'), JSON.stringify(entry) + '\n', { mode: 0o600 });
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/** 記録(rollout)を持たない会話の自動圧縮を通した記録。引き継ぎの行は増えないので、起きた事をここに残す。会話の本文は書かない。 */
|
|
136
|
+
function recordUnrecordedCompactionPass(entry) {
|
|
137
|
+
mkdirSync(stateRoot(), { recursive: true, mode: 0o700 });
|
|
138
|
+
appendFileSync(join(stateRoot(), 'unrecorded-compactions.jsonl'), JSON.stringify(entry) + '\n', { mode: 0o600 });
|
|
139
|
+
}
|
|
140
|
+
|
|
135
141
|
/** 引き継ぎ済みの旧タスクを止めた記録。引き継ぎの行は増えないので、起きた事をここに残す。会話の本文は書かない。 */
|
|
136
142
|
function recordAutoHandoffRedirect(entry) {
|
|
137
143
|
mkdirSync(stateRoot(), { recursive: true, mode: 0o700 });
|
|
@@ -186,10 +192,18 @@ function settleUndeliveredAutoHandoff(db, threadId, { rolloutPath, processes, re
|
|
|
186
192
|
export async function requestCodexAutoHandoff({ payload, db = null,
|
|
187
193
|
config = readAutoHandoffConfig(), launchWorker = launchAutoHandoffWorker,
|
|
188
194
|
findThread = findCodexThreadCandidate, openThread = openSuccessor, recordRedirect = recordAutoHandoffRedirect,
|
|
189
|
-
recordInternalCompaction = recordInternalCompactionStop,
|
|
195
|
+
recordInternalCompaction = recordInternalCompactionStop, recordUnrecordedCompaction = recordUnrecordedCompactionPass,
|
|
190
196
|
processes = readRuntimeProcesses, readTarget = targetState, now = Date.now() } = {}) {
|
|
191
197
|
if (payload.trigger !== 'auto' || !autoHandoffEnabledFor(config, payload.cwd)) return { status: 'skipped' };
|
|
192
198
|
const threadId = payload.session_id, turnId = payload.turn_id;
|
|
199
|
+
// 記録(rollout)を持たない会話からの合図。Codex Desktop は、ターンの終わりや会話を開いた時に、画面に出ない会話を
|
|
200
|
+
// 自分で立てる(2026-10-11、macOS。`transcript_path` が null、会話の ID は引き継ぎ元でも引き継ぎ先でもない)。
|
|
201
|
+
// Throughline はその会話を記録していないので、引き継ぐ記憶が無く、後継も作れない。失敗にせず、圧縮にも手を出さない (ADR 0054)。
|
|
202
|
+
if (payload.transcript_path === null || payload.transcript_path === undefined) {
|
|
203
|
+
recordUnrecordedCompaction({ at: new Date(now).toISOString(), thread_id: identityText(threadId), turn_id: identityText(turnId),
|
|
204
|
+
model: identityText(payload.model) });
|
|
205
|
+
return { status: 'skipped', unrecordedThread: true };
|
|
206
|
+
}
|
|
193
207
|
const located = CODEX_NATIVE_ID_PATTERN.test(threadId ?? '') &&
|
|
194
208
|
typeof payload.transcript_path === 'string' && isAbsolute(payload.transcript_path) && isAbsolute(payload.cwd ?? '');
|
|
195
209
|
// Codex が自分で作った内部の文脈(ターンの ID が `auto-compact-N`)からの圧縮。利用者のターンではないので、
|
|
@@ -339,6 +339,34 @@ test('タスクの今の続きを引く。先までたどり、消えた後継
|
|
|
339
339
|
void Z;
|
|
340
340
|
}));
|
|
341
341
|
|
|
342
|
+
test('記録(rollout)を持たない会話からの圧縮の合図は、失敗にせず、圧縮にも手を出さない。引き継ぎは作らない', async () => withDb(async (db, home) => {
|
|
343
|
+
// 2026-10-11 16:22 JST、macOS の Codex Desktop。会話の ID は引き継ぎ元でも引き継ぎ先でもなく、`transcript_path` が null だった
|
|
344
|
+
const payload = { trigger: 'auto', cwd: home, session_id: '01a129d6-fe64-7010-9c79-19fe3b82c64d', turn_id: '01a129d6-fed1-7662-8286-280519f8d9bf',
|
|
345
|
+
transcript_path: null, model: 'gpt-6.1-sol' };
|
|
346
|
+
const config = { enabled: true, projects: [home], openHost: 'desktop' };
|
|
347
|
+
let launched = 0; const passed = [], stopped = [];
|
|
348
|
+
const args = { db, config, payload, launchWorker: async () => { launched++; }, recordUnrecordedCompaction: entry => passed.push(entry),
|
|
349
|
+
recordInternalCompaction: entry => stopped.push(entry), now: Date.parse('2026-10-11T07:22:00.396Z') };
|
|
350
|
+
const result = await requestCodexAutoHandoff(args);
|
|
351
|
+
assert.deepEqual(result, { status: 'skipped', unrecordedThread: true }, '圧縮を止める指示(continue: false)を返さない');
|
|
352
|
+
assert.equal(launched, 0);
|
|
353
|
+
assert.equal(db.prepare('SELECT COUNT(*) n FROM codex_handoffs').get().n, 0, '引き継ぎの行は作らない');
|
|
354
|
+
assert.deepEqual(passed, [{ at: '2026-10-11T07:22:00.396Z', thread_id: payload.session_id, turn_id: payload.turn_id, model: 'gpt-6.1-sol' }]);
|
|
355
|
+
assert.deepEqual(stopped, []);
|
|
356
|
+
|
|
357
|
+
// 項目そのものが無い時と、ターンの ID が内部の形の時も同じ。記号の混じる値は、記録へ中身を残さない
|
|
358
|
+
await requestCodexAutoHandoff({ ...args, payload: { trigger: 'auto', cwd: home, session_id: payload.session_id, turn_id: 'auto-compact-1', model: 'a model/with spaces' } });
|
|
359
|
+
assert.deepEqual(passed[1], { at: '2026-10-11T07:22:00.396Z', thread_id: payload.session_id, turn_id: 'auto-compact-1', model: 'text(19)' });
|
|
360
|
+
assert.deepEqual(stopped, []);
|
|
361
|
+
|
|
362
|
+
// 記録の場所が相対の時は、今までどおり失敗(記録を持つはずの会話で、場所だけが不正)
|
|
363
|
+
await assert.rejects(requestCodexAutoHandoff({ ...args, payload: { ...payload, transcript_path: 'relative/rollout.jsonl' } }), /handoff_hook_identity_invalid/);
|
|
364
|
+
// 無効な project と手動の圧縮は、記録も残さない
|
|
365
|
+
assert.equal((await requestCodexAutoHandoff({ ...args, config: { ...config, projects: ['/other'] } })).status, 'skipped');
|
|
366
|
+
assert.equal((await requestCodexAutoHandoff({ ...args, payload: { ...payload, trigger: 'manual' } })).status, 'skipped');
|
|
367
|
+
assert.equal(passed.length, 2);
|
|
368
|
+
}));
|
|
369
|
+
|
|
342
370
|
test('Codexの内部の文脈(auto-compact-N)からの圧縮は、例外にせず止めて記録する。引き継ぎは作らない', async () => withDb(async (db, home) => {
|
|
343
371
|
const thread = '11111111-1111-1111-1111-111111111111';
|
|
344
372
|
const file = join(home, 'rollout.jsonl'); writeFileSync(file, '');
|
|
@@ -360,16 +388,16 @@ test('Codexの内部の文脈(auto-compact-N)からの圧縮は、例外に
|
|
|
360
388
|
|
|
361
389
|
// 形の決まっていない ID と、場所の分からない呼び出しは、今までどおり失敗として扱う
|
|
362
390
|
for (const broken of [{ turn_id: 'something-else' }, { turn_id: undefined }, { turn_id: 'auto-compact-' }, { session_id: 'not-a-uuid' },
|
|
363
|
-
{ transcript_path:
|
|
391
|
+
{ transcript_path: 'relative/rollout.jsonl' }]) {
|
|
364
392
|
await assert.rejects(requestCodexAutoHandoff({ ...args, payload: { ...payload, ...broken } }), /handoff_hook_identity_invalid/);
|
|
365
393
|
}
|
|
366
394
|
assert.equal(recorded.length, 1);
|
|
367
395
|
|
|
368
396
|
// 失敗には、どの値が不正だったかの形を付ける。path と記号の混じる値は、中身を残さない
|
|
369
|
-
const shaped = await requestCodexAutoHandoff({ ...args, payload: { ...payload, turn_id: 'something else', transcript_path:
|
|
397
|
+
const shaped = await requestCodexAutoHandoff({ ...args, payload: { ...payload, turn_id: 'something else', transcript_path: 'relative/rollout.jsonl',
|
|
370
398
|
agent_type: 'reviewer', model: 'gpt-5.5' } }).catch(error => error);
|
|
371
399
|
assert.equal(shaped.code, 'handoff_hook_identity_invalid');
|
|
372
|
-
assert.deepEqual(shaped.hookIdentity, { session_id: thread, turn_id: 'text(14)', transcript_path: '
|
|
400
|
+
assert.deepEqual(shaped.hookIdentity, { session_id: thread, turn_id: 'text(14)', transcript_path: 'relative(22)', cwd: 'absolute',
|
|
373
401
|
agent_id: 'undefined', agent_type: 'reviewer', model: 'gpt-5.5', trigger: 'auto' });
|
|
374
402
|
assert.deepEqual(hookIdentityShape({ session_id: thread, turn_id: 'auto-compact-', transcript_path: 'relative/rollout.jsonl', cwd: 7 }),
|
|
375
403
|
{ session_id: thread, turn_id: 'auto-compact-', transcript_path: 'relative(22)', cwd: 'number',
|
package/src/handoff-executor.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* handoff-executor.mjs — 最初の UserPromptSubmit で実行する引き継ぎ本体
|
|
3
3
|
*
|
|
4
4
|
* 二相ハンドオフ (ADR 0014) の第二相。SessionStart が登録した pending intent を
|
|
5
|
-
*
|
|
5
|
+
* 消費し、自動継続の配送 (ADR 0053) → baton path → auto path の順で前任を merge して、予算内の
|
|
6
6
|
* resume context を組み立てて返す。呼び出し元 (prompt-submit) が stdout へ書く。
|
|
7
7
|
*
|
|
8
8
|
* 幽霊セッション (transcript を生成しない SessionStart) はプロンプトを発火しない
|
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { existsSync } from 'node:fs';
|
|
14
|
-
import { consumeBaton } from './baton.mjs';
|
|
14
|
+
import { consumeBaton, discardBatonOf } from './baton.mjs';
|
|
15
15
|
import { consumePendingHandoff } from './pending-handoff.mjs';
|
|
16
16
|
import { mergeSpecificPredecessor, resolveMergeTarget } from './session-merger.mjs';
|
|
17
17
|
import { backfillBodies, deriveTranscriptPath, logBackfill } from './turn-backfill.mjs';
|
|
18
18
|
import { buildBudgetedResumeContext, buildAutoContinuationContext } from './resume-context.mjs';
|
|
19
|
-
import { acceptClaudeAutoContinuation } from './claude-auto-handoff.mjs';
|
|
19
|
+
import { acceptClaudeAutoContinuation, findClaudeAutoContinuationSource } from './claude-auto-handoff.mjs';
|
|
20
20
|
import { readAllSessionStates } from './state-file.mjs';
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -25,7 +25,7 @@ import { readAllSessionStates } from './state-file.mjs';
|
|
|
25
25
|
* prompt は届いた指示の本文。前任が自動継続で引き継いだ会話の時に、配送の受領を照合する (ADR 0033)。
|
|
26
26
|
* @returns {{
|
|
27
27
|
* attempted: boolean, // pending 行が存在した (= newborn の初回プロンプト)
|
|
28
|
-
* triggeredPath: 'baton' | 'auto' | null,
|
|
28
|
+
* triggeredPath: 'auto-handoff' | 'baton' | 'auto' | null, // auto-handoff は自動継続の後継 (ADR 0053)
|
|
29
29
|
* baton: object | null, // consumeBaton の生返り値 (ログ用)
|
|
30
30
|
* pendingCreatedAt: number | null,
|
|
31
31
|
* mergeResult: object, // mergeSpecificPredecessor の返り値 or skip
|
|
@@ -47,14 +47,26 @@ export function executeFirstPromptHandoff(db, { sessionId, projectPath, now = Da
|
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// 自動継続の後継は、最初の指示が運ぶ引き継ぎ ID で前任を決める (ADR 0053)。project ごとに1つの印は見ない
|
|
51
|
+
// (同じ project の別の会話のための印を、後継が取らない)。
|
|
52
|
+
const continuationSourceId = findClaudeAutoContinuationSource({ prompt, successorSessionId: sessionId });
|
|
50
53
|
// baton 適格性はセッション誕生時刻 (pending.createdAt) 基準。
|
|
51
54
|
// 誕生後に書かれたバトンは future_baton として残置される (本来の後継のもの)。
|
|
52
|
-
const baton = consumeBaton(db, { projectPath, now, bornAt: pending.createdAt });
|
|
55
|
+
const baton = continuationSourceId ? null : consumeBaton(db, { projectPath, now, bornAt: pending.createdAt });
|
|
53
56
|
|
|
54
57
|
let triggeredPath = null;
|
|
55
58
|
let mergeResult = { merged: false, skipReason: 'no_trigger' };
|
|
56
59
|
|
|
57
|
-
if (
|
|
60
|
+
if (continuationSourceId) {
|
|
61
|
+
triggeredPath = 'auto-handoff';
|
|
62
|
+
discardBatonOf(db, { sessionId: continuationSourceId });
|
|
63
|
+
const { target: predecessorId } = resolveMergeTarget(db, continuationSourceId);
|
|
64
|
+
mergeResult = mergeSpecificPredecessor(db, {
|
|
65
|
+
newSessionId: sessionId,
|
|
66
|
+
predecessorId,
|
|
67
|
+
now,
|
|
68
|
+
});
|
|
69
|
+
} else if (baton.sessionId) {
|
|
58
70
|
triggeredPath = 'baton';
|
|
59
71
|
const { target: predecessorId } = resolveMergeTarget(db, baton.sessionId);
|
|
60
72
|
mergeResult = mergeSpecificPredecessor(db, {
|
|
@@ -87,8 +99,8 @@ export function executeFirstPromptHandoff(db, { sessionId, projectPath, now = Da
|
|
|
87
99
|
// 前任が自動継続でこの会話へ引き継いだなら、止めた時点の依頼を現在地に載せる (ADR 0033)。
|
|
88
100
|
// 止めたターンは Stop を通っていないので、state ファイルが無いことがある。hook が記録した
|
|
89
101
|
// transcript の場所を、回収元の候補に足す。
|
|
90
|
-
const autoContinuation = triggeredPath === 'baton'
|
|
91
|
-
? acceptClaudeAutoContinuation({ predecessorId: baton.sessionId, successorSessionId: sessionId, prompt, now })
|
|
102
|
+
const autoContinuation = triggeredPath === 'auto-handoff' || triggeredPath === 'baton'
|
|
103
|
+
? acceptClaudeAutoContinuation({ predecessorId: continuationSourceId ?? baton.sessionId, successorSessionId: sessionId, prompt, now })
|
|
92
104
|
: null;
|
|
93
105
|
const predecessorTranscriptPath = [derivedTranscriptPath, stateTranscriptPath, autoContinuation?.transcriptPath]
|
|
94
106
|
.find((candidate) => candidate && existsSync(candidate)) ?? null;
|