throughline 0.16.22 → 0.16.24
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 +60 -1
- package/README.md +9 -1
- package/docs/02_clear_auto_handoff_plan.md +3 -1
- package/docs/adr/0051-claude-successor-desktop-move-is-opt-in.md +3 -0
- package/docs/adr/0052-install-keeps-registered-codex-hook-command.md +42 -0
- package/docs/adr/0053-claude-successor-finds-predecessor-by-handoff-id.md +49 -0
- package/package.json +1 -1
- package/src/baton.mjs +13 -0
- package/src/claude-auto-handoff.mjs +106 -12
- package/src/claude-auto-handoff.test.mjs +160 -15
- package/src/cli/auto-handoff.mjs +7 -2
- package/src/cli/install.mjs +17 -7
- package/src/cli/install.test.mjs +94 -1
- package/src/handoff-executor.mjs +20 -8
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,63 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.16.24] — 2026-10-11
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- **Claude Codeの自動継続で、後継へ前任の記憶が入らない事があった。同じprojectの別の会話へ、前任の記憶が入る事もあった**([ADR 0053](docs/adr/0053-claude-successor-finds-predecessor-by-handoff-id.md))。後継が前任を見つける所は、`/tl`と同じ「projectごとに1つの印」に頼っていた。印は引き継ぎを予約した時(`PreCompact`)に書き、期限は1時間。後継が立つのは、旧い会話が次に道具を使った時。その間に、同じprojectの別の会話が最初の指示を受けると、印を取るか、期限切れとして消していた。
|
|
18
|
+
- Windowsの実機で2026-10-11に起きた。同じフォルダに役の違うClaudeの会話が何本も動いていた。ある会話が17:21:55に引き継ぎを予約し、止まったのは19:43:09。18:43:06に別の会話の最初の指示が印を期限切れ(81分)として消し、19:43:14に後継が受けた最初の指示には記憶が1文字も入らなかった。引き継ぎは`unknown`(`handoff_delivery_unconfirmed`)で終わり、後継は別の役の仕事を始めた。
|
|
19
|
+
- 直した後は、後継の最初の指示の頭にある引き継ぎIDから記録を引いて、その記録の旧い会話を前任にする。projectごとの印は見ない。自動継続は、予約の時に印を書かない。`/tl`の印は変えていない。
|
|
20
|
+
- 0.16.23までの版が書いた印が残っている時は、後継が合流した時に消す。
|
|
21
|
+
- **受領を確かめられなかった引き継ぎ(`unknown`)の記録を、後継が残っていても24時間で消していた**。消えると、旧い会話がまた道具を使えるようになり、同じ会話から2つ目の後継が立つ。後継のtranscriptが残っている間は消さない。
|
|
22
|
+
|
|
23
|
+
- **Claude Codeの自動継続で、後継のeffortを、hookが値を渡さない時にも引き継ぐ**。後継は、止めた時点のモデルとeffortで立てる(`claude --bg --model … --effort …`)。モデルはtranscriptの最後の発言から、effortは`PreToolUse`のpayload(`effort.level`)から読んでいた。payloadにeffortが無い時は、transcriptの最後の発言の`effort`から読む(subagentの中の行は除く)。どちらにも無い時は、今までどおり端末の既定で立つ。
|
|
24
|
+
- 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の既定に戻る。これは直していない。
|
|
25
|
+
- モデルは落ちていない。同じ列の会話は全部`claude-opus-5-5`で、後継も文脈を90万token台まで使っている。
|
|
26
|
+
|
|
27
|
+
### 追加
|
|
28
|
+
|
|
29
|
+
- **Claudeの会話の「今の続き」を引く口**。`throughline auto-handoff successor --host claude --thread <session id> --json`(読むだけ)。`current_session_id`が今の続き(後継が無ければ渡したID自身)、`chain`がたどった引き継ぎ、`pending`が今の続きの会話にある、後継がまだ立っていない引き継ぎ(`requested`は予約だけで、会話は止まっていない)。後継のsession idが記録に無い時は、transcriptの名前から引く。ほかの道具が、旧い会話あての連絡を今の続きへ渡す時に使う。
|
|
30
|
+
|
|
31
|
+
### 対応の範囲
|
|
32
|
+
|
|
33
|
+
| | macOS | Linux | Windows |
|
|
34
|
+
|---|---|---|---|
|
|
35
|
+
| Claude Code | 直した(effortの控えは単体試験だけ)。単体試験と、実機で本物のhookの命令へ同じ順番(予約 → 別の会話の最初の指示 → 止める → 後継の最初の指示)を通した。入っていた0.16.23は前任の記憶が別の会話へ付き、後継は0文字。この版は後継だけに入る | 直した。同じ確認 | 直した。同じ確認。起きた端末 |
|
|
36
|
+
| Codex | 変更なし(後継はApp Serverで作り、前任は引き継ぎの記録で決めている。projectごとの印を使わない。モデルとeffortは後継の設定へ写し、合わない時は引き継ぎを失敗にする) | 同じ | 同じ |
|
|
37
|
+
| Grok | 変更なし(自動継続が無い。最初の指示の合流は、引き継ぎIDで始まる指示が無いので今までどおり) | 同じ | 同じ |
|
|
38
|
+
| Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
39
|
+
|
|
40
|
+
### 確かめていない事
|
|
41
|
+
|
|
42
|
+
- 本物のClaude Codeが、同じprojectに会話が何本もある状態で引き継ぐ所。確認は、仮のHOMEで、本物のhookの命令(`session-start`・`pre-compact`・`prompt-submit`・`pre-tool-use`)へ、実機で起きた順番を通す形で行った。後継を立てる`claude --bg`は偽物に置き換えている。
|
|
43
|
+
- 記憶が入らなかった後継へ、後から記憶を入れる処理は無い。`throughline recall --session <旧い会話のid>`で引ける。
|
|
44
|
+
- effortの控え(transcriptから読む所)は、単体試験で確かめた。payloadにeffortが無い本物のClaude Codeでは通していない。Windowsの実機の引き継ぎ(Claude Code 2.1.296)では、payloadの値とtranscriptの値は毎回同じだった。
|
|
45
|
+
- 旧い会話は、引き継ぎの後も文を受ければ文で答える(道具は断る)。この版では変えていない。
|
|
46
|
+
|
|
47
|
+
## [0.16.23] — 2026-10-11
|
|
48
|
+
|
|
49
|
+
### 修正
|
|
50
|
+
|
|
51
|
+
- **`install`と`self-update`が、Codexの信用済みのhookを「変わったhook」にしていた**([ADR 0052](docs/adr/0052-install-keeps-registered-codex-hook-command.md))。Codexのhookの命令は`<node> <throughline.mjs> codex-hook <event>`で、`<node>`の表記は入れる側のPATHで決まる(PATHの上に今のnodeと同じ実体があればその表記、無ければ`process.execPath`)。`install`はhookを毎回作り直すので、前と違うPATHで入れると、同じnodeを指したまま文字列だけが変わった。Codexはhookの信用を命令の文字列で決めるので、Throughlineのhookが4つとも`modified`になり、新しい会話が起動の所で「Hooks need review」を出して、人が選ぶまで止まった。
|
|
52
|
+
- Linuxの実機で2026-10-11に起きた。09:20に`~/.local/bin`をPATHの先頭に付けて`self-update`を流し、`~/.local/lib/node-v24/bin/node`が、同じ実体を指すsymlinkの`~/.local/bin/node`に変わった。15:05に、常駐のprocessが立てるCodexの会話がそこで止まっていると知らせを受けた。
|
|
53
|
+
- 直した後は、登録済みの命令が今のnodeと`throughline.mjs`の実体(realpath)を指している時、その文字列のまま書く。`auto-handoff enable`が置く`PreCompact`も同じ。登録が無い時、旧い形の時、nodeかscriptの実体が違う時は、今までどおり書き直す。
|
|
54
|
+
- nodeの実体が変わった時(別の版の場所へ入れ直した時など)は命令が変わり、Codexは確認の画面を出す。これは変えていない。
|
|
55
|
+
|
|
56
|
+
### 対応の範囲
|
|
57
|
+
|
|
58
|
+
| | macOS | Linux | Windows |
|
|
59
|
+
|---|---|---|---|
|
|
60
|
+
| Codex | 直した。単体試験と、実機(Codex Desktop同梱の0.162.0-alpha.17.2)。仮のHOMEとCODEX_HOMEでhookを信用し、PATHだけ変えて入れ直すと、入っていた0.16.22は4つとも`modified`、この版は4つとも`trusted`で`hooks.json`は1バイトも変わらない | 直した。単体試験と、実機(codex-cli 0.162.1)で同じ確認 | 直した。単体試験(表記の違いは大文字と小文字で作る)と、実機(Codex Desktop同梱の0.162.0-alpha.17.2)で同じ確認 |
|
|
61
|
+
| Claude Code | 変更なし(hookの命令は`throughline <subcommand>`で、nodeの場所が入らない) | 同じ | 同じ |
|
|
62
|
+
| Grok | 変更なし。命令には同じ決め方でnodeの表記が入り、入れる側のPATHで変わる。Grokがhookの変更で確認を出すかは確かめていない | 同じ | 同じ |
|
|
63
|
+
| Cursor | 変更なし。Grokと同じ | 同じ | 同じ |
|
|
64
|
+
|
|
65
|
+
### 確かめていない事
|
|
66
|
+
|
|
67
|
+
- 本物の`~/.codex`で、信用済みのhookを持つ端末へこの版を違うPATHから入れる所。確認は、仮のHOMEとCODEX_HOMEで、本物のCodexのApp Server(`hooks/list`)に信用の状態を聞く形で行った。
|
|
68
|
+
- `doctor --codex`の「trusted」は、`trusted_hash`が記録されているかだけを見ている。Codexが今の命令と比べた結果は見ていないので、`modified`でも「trusted」と出る。この版では変えていない。
|
|
69
|
+
|
|
13
70
|
## [0.16.22] — 2026-10-11
|
|
14
71
|
|
|
15
72
|
### 変更
|
|
@@ -2378,7 +2435,9 @@ two attempts, instrument first instead of patching again.
|
|
|
2378
2435
|
|
|
2379
2436
|
---
|
|
2380
2437
|
|
|
2381
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.
|
|
2438
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.24...HEAD
|
|
2439
|
+
[0.16.24]: https://github.com/kitepon/Throughline/compare/v0.16.23...v0.16.24
|
|
2440
|
+
[0.16.23]: https://github.com/kitepon/Throughline/compare/v0.16.22...v0.16.23
|
|
2382
2441
|
[0.16.22]: https://github.com/kitepon/Throughline/compare/v0.16.21...v0.16.22
|
|
2383
2442
|
[0.16.21]: https://github.com/kitepon/Throughline/compare/v0.16.20...v0.16.21
|
|
2384
2443
|
[0.16.20]: https://github.com/kitepon/Throughline/compare/v0.16.19...v0.16.20
|
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
|
|
|
@@ -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,42 @@
|
|
|
1
|
+
# ADR 0052: install は、今の node を指す登録済みの Codex hook の命令を書き換えない
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-10-11
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Codex は、hook を1つずつ信用の対象にする。信用は `config.toml` の `hooks.state."<hooks.json>:<event>:<group>:<hook>".trusted_hash`
|
|
9
|
+
に残り、hook の定義(命令の文字列を含む)から作った hash と比べる。hash が違う hook は「変わった hook」(`modified`)になり、
|
|
10
|
+
新しい会話は起動の所で「Hooks need review」の画面を出して、人が選ぶまで止まる。
|
|
11
|
+
|
|
12
|
+
Throughline の Codex hook の命令は `<node> <throughline.mjs> codex-hook <event>` で、`<node>` は
|
|
13
|
+
`resolveCodexHookNodePath` が決める。PATH の上に今の node と同じ実体があればその表記、無ければ `process.execPath`。
|
|
14
|
+
Homebrew の node を版ごとの場所(Cellar)で登録しないための決め方だが、同じ端末でも、入れる側の PATH で表記が変わる。
|
|
15
|
+
|
|
16
|
+
2026-10-11、Linux の実機で、`~/.local/bin` を PATH の先頭に付けて `self-update` を流した。それまで
|
|
17
|
+
`~/.local/lib/node-v24/bin/node` だった表記が、同じ実体を指す symlink の `~/.local/bin/node` に変わった。
|
|
18
|
+
Throughline の hook は4つとも `modified` になり、新しい Codex の会話が確認の画面で止まった。常駐の process が
|
|
19
|
+
立てる会話も、そこで止まった。
|
|
20
|
+
|
|
21
|
+
`install` は、Throughline の hook を毎回作り直して書いていた。`doctor` と `factory-diagnostics` は、登録済みの命令と
|
|
22
|
+
期待する命令を実体(realpath)で比べるので、表記が違っても「登録済み」と出す。`install` だけが、文字列で置き換えていた。
|
|
23
|
+
|
|
24
|
+
## Decision
|
|
25
|
+
|
|
26
|
+
1. `install` と `auto-handoff enable` は、登録済みの Throughline の Codex hook の命令が、期待する命令と同じ実体
|
|
27
|
+
(node と `throughline.mjs` の realpath、event)を指している時、その文字列のまま書く。
|
|
28
|
+
2. 登録が無い時、旧い形の時、node か script の実体が違う時(realpath を解決できない時を含む)は、今までどおり
|
|
29
|
+
期待する命令で書く。
|
|
30
|
+
3. 表記を「より安定した物」へ寄せるための書き換えはしない。
|
|
31
|
+
|
|
32
|
+
## Consequences
|
|
33
|
+
|
|
34
|
+
- 入れる側の PATH が変わっても、同じ node を使っている間は、Codex の信用が残る。
|
|
35
|
+
- node の実体が変わった時(別の版の場所へ入れ直した時など)は命令が変わり、Codex は確認の画面を出す。これは避けない。
|
|
36
|
+
命令が指す実行ファイルが変わった事を、利用者が確かめる場面だから。
|
|
37
|
+
- 版ごとの場所(Homebrew の Cellar など)で登録された命令は、その場所が残っている間はそのまま残る。場所が消えた後の
|
|
38
|
+
`install` で、期待する命令へ書き直す。その間 hook は失敗する。`doctor` と `factory-diagnostics` は、実体を
|
|
39
|
+
解決できない登録を「登録済み」と見ないので、そこで分かる。
|
|
40
|
+
- `doctor --codex` の「trusted」は、`trusted_hash` が記録されているかだけを見ている。Codex が今の命令と比べた結果
|
|
41
|
+
(`trusted`・`modified`・`untrusted`)は、Codex の App Server の `hooks/list` でしか分からない。この ADR では変えない。
|
|
42
|
+
- Grok と Cursor の hook の命令も同じ決め方で node の表記が入るが、この ADR では変えない。
|
|
@@ -0,0 +1,49 @@
|
|
|
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>` で引ける。
|
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;
|
|
@@ -625,7 +700,24 @@ export function claudeContinuationInput(record) {
|
|
|
625
700
|
}
|
|
626
701
|
|
|
627
702
|
/**
|
|
628
|
-
* 最初の UserPromptSubmit
|
|
703
|
+
* 最初の UserPromptSubmit から呼ぶ。届いた指示が自動継続の配送(文の頭が `Throughline自動継続 <引き継ぎID>`)なら、
|
|
704
|
+
* その引き継ぎの前任の session id を返す(ADR 0053)。記録が無い・後継が別の会話だと分かっている時は null。
|
|
705
|
+
* project ごとの印を使わないので、同じ project に会話が何本あっても、後継は自分の前任だけを合流させる。
|
|
706
|
+
*
|
|
707
|
+
* @returns {string|null}
|
|
708
|
+
*/
|
|
709
|
+
export function findClaudeAutoContinuationSource({ prompt, successorSessionId, dir = claudeAutoHandoffDir() }) {
|
|
710
|
+
if (typeof prompt !== 'string' || typeof successorSessionId !== 'string') return null;
|
|
711
|
+
const handoffId = CONTINUATION_PREFIX.exec(prompt)?.[1];
|
|
712
|
+
if (!handoffId) return null;
|
|
713
|
+
const record = listClaudeAutoHandoffs({ dir }).find(item => item.handoff_id === handoffId);
|
|
714
|
+
if (!record || !ACCEPTABLE_STATES.has(record.state) || record.source_session_id === successorSessionId) return null;
|
|
715
|
+
if (record.successor?.short_id && !successorSessionId.startsWith(record.successor.short_id)) return null;
|
|
716
|
+
return record.source_session_id;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* 最初の UserPromptSubmit(前任の合流)から呼ぶ。前任がこの後継へ自動で引き継いだ会話なら、
|
|
629
721
|
* 止めた時点の依頼を返す。届いた指示がこの引き継ぎの配送なら、受領を記録する。
|
|
630
722
|
*
|
|
631
723
|
* @returns {{handoffId: string, projectPath: string, inFlight: object|null, transcriptPath: string|null}|null}
|
|
@@ -711,7 +803,9 @@ export async function runClaudeAutoHandoffWorker(sessionId, {
|
|
|
711
803
|
if (inFlight && openDb) inFlight.turn = captureStoppedTurn(record, openDb);
|
|
712
804
|
update({
|
|
713
805
|
state: 'launching',
|
|
714
|
-
|
|
806
|
+
// モデルと effort は、止めた時点の物を後継へ渡す。effort は hook の payload の値を使い、無い時だけ transcript から読む。
|
|
807
|
+
settings: { ...record.settings, model: record.transcript_path ? readLatestModel(record.transcript_path) : null,
|
|
808
|
+
effort: record.settings?.effort ?? (record.transcript_path ? readLatestEffort(record.transcript_path) : null) },
|
|
715
809
|
in_flight: inFlight,
|
|
716
810
|
});
|
|
717
811
|
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)
|
package/src/cli/install.mjs
CHANGED
|
@@ -250,15 +250,25 @@ function isSameExecutablePath(a, b, realpath) {
|
|
|
250
250
|
return left !== null && right !== null && left === right;
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
|
|
253
|
+
// 登録済みの命令が今の node と script の実体を指している時は、その文字列のまま返す(ADR 0052)。
|
|
254
|
+
// node の表記は入れる側の PATH で変わる。Codex は hook の信用を命令の文字列で決めるので、表記だけを書き換えると
|
|
255
|
+
// 信用済みの hook が「変わった hook」になり、新しい会話が確認の画面で止まる。
|
|
256
|
+
function registeredCodexHookCommand(expected, groups) {
|
|
257
|
+
const registered = (Array.isArray(groups) ? groups : []).flatMap(group => group?.hooks ?? []).map(hook => hook?.command)
|
|
258
|
+
.find(command => isEquivalentCodexHookCommand(command, expected));
|
|
259
|
+
return registered ?? expected;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function createCodexHooks(registered = {}) {
|
|
263
|
+
const command = (event, expected) => registeredCodexHookCommand(expected, registered?.[event]);
|
|
254
264
|
return {
|
|
255
|
-
PreCompact: { matcher: 'auto', hooks: [{ type: 'command', command: buildCodexPreCompactHookCommand(),
|
|
265
|
+
PreCompact: { matcher: 'auto', hooks: [{ type: 'command', command: command('PreCompact', buildCodexPreCompactHookCommand()),
|
|
256
266
|
timeout: 30, async: false, statusMessage: 'Throughline: 自動継続を準備' }] },
|
|
257
267
|
UserPromptSubmit: {
|
|
258
268
|
hooks: [
|
|
259
269
|
{
|
|
260
270
|
type: 'command',
|
|
261
|
-
command: buildCodexUserPromptSubmitHookCommand(),
|
|
271
|
+
command: command('UserPromptSubmit', buildCodexUserPromptSubmitHookCommand()),
|
|
262
272
|
timeout: 30,
|
|
263
273
|
async: false,
|
|
264
274
|
statusMessage: null,
|
|
@@ -269,7 +279,7 @@ function createCodexHooks() {
|
|
|
269
279
|
hooks: [
|
|
270
280
|
{
|
|
271
281
|
type: 'command',
|
|
272
|
-
command: buildCodexPostToolUseHookCommand(),
|
|
282
|
+
command: command('PostToolUse', buildCodexPostToolUseHookCommand()),
|
|
273
283
|
timeout: 30,
|
|
274
284
|
async: false,
|
|
275
285
|
statusMessage: null,
|
|
@@ -280,7 +290,7 @@ function createCodexHooks() {
|
|
|
280
290
|
hooks: [
|
|
281
291
|
{
|
|
282
292
|
type: 'command',
|
|
283
|
-
command: buildCodexStopHookCommand(),
|
|
293
|
+
command: command('Stop', buildCodexStopHookCommand()),
|
|
284
294
|
timeout: 300,
|
|
285
295
|
async: false,
|
|
286
296
|
statusMessage: null,
|
|
@@ -628,7 +638,7 @@ function installCodexHooks() {
|
|
|
628
638
|
const configPath = resolveCodexConfigPath();
|
|
629
639
|
const current = readSettings(hooksPath);
|
|
630
640
|
const existingHooks = current.hooks ?? {};
|
|
631
|
-
const codexHooks = createCodexHooks();
|
|
641
|
+
const codexHooks = createCodexHooks(existingHooks);
|
|
632
642
|
|
|
633
643
|
for (const [key, entry] of Object.entries(codexHooks)) {
|
|
634
644
|
const list = existingHooks[key] ?? [];
|
|
@@ -705,7 +715,7 @@ export function installCodexAutoHandoffHook(codexHome = join(homedir(), '.codex'
|
|
|
705
715
|
const configPath = join(codexHome, 'config.toml');
|
|
706
716
|
const current = readSettings(hooksPath);
|
|
707
717
|
current.hooks ??= {};
|
|
708
|
-
const entry = createCodexHooks().PreCompact;
|
|
718
|
+
const entry = createCodexHooks(current.hooks).PreCompact;
|
|
709
719
|
const groups = current.hooks.PreCompact ?? [];
|
|
710
720
|
const existing = groups.flatMap(group => group.hooks ?? []).filter(hook => isThroughlineCodexHookCommand(hook.command));
|
|
711
721
|
if (existing.length !== 1 || existing[0].command !== entry.hooks[0].command || existing[0].timeout !== 30) {
|
package/src/cli/install.test.mjs
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync, symlinkSync, realpathSync } from 'node:fs';
|
|
4
4
|
import { tmpdir, homedir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
buildCodexPostToolUseHookCommand,
|
|
9
|
+
buildCodexPreCompactHookCommand,
|
|
9
10
|
buildCodexStopHookCommand,
|
|
10
11
|
buildCodexUserPromptSubmitHookCommand,
|
|
11
12
|
buildCursorHookCommand,
|
|
12
13
|
buildGrokHookCommand,
|
|
13
14
|
createGrokHooksFile,
|
|
14
15
|
createCursorHookEntries,
|
|
16
|
+
installCodexAutoHandoffHook,
|
|
15
17
|
isEquivalentCodexHookCommand,
|
|
16
18
|
isThroughlineCodexHookCommand,
|
|
17
19
|
isThroughlineCursorHookCommand,
|
|
@@ -855,3 +857,94 @@ test('install is idempotent: second run keeps exactly one tl.md and one hook ent
|
|
|
855
857
|
home.restore();
|
|
856
858
|
}
|
|
857
859
|
});
|
|
860
|
+
|
|
861
|
+
// 今の node を指す、別の表記。POSIX は symlink、Windows は大文字と小文字を入れ替えた path(どちらも realpath が同じになる)。
|
|
862
|
+
function alternateNodeSpelling(dir) {
|
|
863
|
+
if (process.platform === 'win32') {
|
|
864
|
+
const upper = process.execPath.toUpperCase();
|
|
865
|
+
return upper !== process.execPath ? upper : process.execPath.toLowerCase();
|
|
866
|
+
}
|
|
867
|
+
mkdirSync(join(dir, 'altbin'), { recursive: true });
|
|
868
|
+
const link = join(dir, 'altbin', 'node');
|
|
869
|
+
symlinkSync(realpathSync.native(process.execPath), link);
|
|
870
|
+
return link;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
test('install は、今の node を指す登録済みの Codex hook の命令を書き換えない(Codex の信用を保つ)', async () => {
|
|
874
|
+
const home = makeTempHome();
|
|
875
|
+
if (home.resolved !== home.dir) {
|
|
876
|
+
home.restore();
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
const unsilence = silence();
|
|
880
|
+
try {
|
|
881
|
+
const hooksPath = join(home.dir, '.codex', 'hooks.json');
|
|
882
|
+
await run([]);
|
|
883
|
+
const nodePath = alternateNodeSpelling(home.dir);
|
|
884
|
+
const registered = {
|
|
885
|
+
PreCompact: buildCodexPreCompactHookCommand({ nodePath }),
|
|
886
|
+
UserPromptSubmit: buildCodexUserPromptSubmitHookCommand({ nodePath }),
|
|
887
|
+
PostToolUse: buildCodexPostToolUseHookCommand({ nodePath }),
|
|
888
|
+
Stop: buildCodexStopHookCommand({ nodePath }),
|
|
889
|
+
};
|
|
890
|
+
assert.notEqual(registered.Stop, buildCodexStopHookCommand({ nodePath: process.execPath }));
|
|
891
|
+
// ほかの PATH から入れた時の登録を作る。node の表記だけが違い、指す実体は同じ。
|
|
892
|
+
const file = JSON.parse(readFileSync(hooksPath, 'utf8'));
|
|
893
|
+
for (const [event, command] of Object.entries(registered)) {
|
|
894
|
+
const own = file.hooks[event].flatMap(group => group.hooks).filter(hook => isThroughlineCodexHookCommand(hook.command));
|
|
895
|
+
assert.equal(own.length, 1);
|
|
896
|
+
own[0].command = command;
|
|
897
|
+
}
|
|
898
|
+
file.hooks.Stop.push({ hooks: [{ type: 'command', command: '/usr/bin/node /opt/other/bin/other codex-hook stop', timeout: 5 }] });
|
|
899
|
+
const before = JSON.stringify(file, null, 2) + '\n';
|
|
900
|
+
writeFileSync(hooksPath, before);
|
|
901
|
+
|
|
902
|
+
await run([]);
|
|
903
|
+
assert.equal(readFileSync(hooksPath, 'utf8'), before);
|
|
904
|
+
const autoHandoff = installCodexAutoHandoffHook(join(home.dir, '.codex'));
|
|
905
|
+
assert.equal(autoHandoff.command, registered.PreCompact);
|
|
906
|
+
assert.equal(readFileSync(hooksPath, 'utf8'), before);
|
|
907
|
+
|
|
908
|
+
// 登録済みの node が今の node でない時は、今までどおり書き直す。
|
|
909
|
+
const moved = JSON.parse(before);
|
|
910
|
+
moved.hooks.Stop[0].hooks[0].command = buildCodexStopHookCommand({ nodePath: join(home.dir, 'gone', 'node') });
|
|
911
|
+
writeFileSync(hooksPath, JSON.stringify(moved, null, 2) + '\n');
|
|
912
|
+
await run([]);
|
|
913
|
+
const after = JSON.parse(readFileSync(hooksPath, 'utf8'));
|
|
914
|
+
assert.equal(after.hooks.Stop[0].hooks[0].command, buildCodexStopHookCommand());
|
|
915
|
+
assert.equal(after.hooks.UserPromptSubmit[0].hooks[0].command, registered.UserPromptSubmit);
|
|
916
|
+
assert.equal(after.hooks.PostToolUse[0].hooks[0].command, registered.PostToolUse);
|
|
917
|
+
assert.equal(after.hooks.PreCompact.at(-1).hooks[0].command, registered.PreCompact);
|
|
918
|
+
} finally {
|
|
919
|
+
unsilence();
|
|
920
|
+
home.restore();
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
test('install は、入れる側の PATH が変わっても Codex hook の命令を変えない', { skip: process.platform === 'win32' }, async () => {
|
|
925
|
+
const home = makeTempHome();
|
|
926
|
+
if (home.resolved !== home.dir) {
|
|
927
|
+
home.restore();
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
const originalPath = process.env.PATH;
|
|
931
|
+
const unsilence = silence();
|
|
932
|
+
try {
|
|
933
|
+
const hooksPath = join(home.dir, '.codex', 'hooks.json');
|
|
934
|
+
const nodePath = alternateNodeSpelling(home.dir);
|
|
935
|
+
// node の symlink がある場所を PATH の先頭に置いて入れ、次は PATH に node が無い状態で入れる。
|
|
936
|
+
process.env.PATH = join(home.dir, 'altbin');
|
|
937
|
+
await run([]);
|
|
938
|
+
const first = readFileSync(hooksPath, 'utf8');
|
|
939
|
+
assert.equal(JSON.parse(first).hooks.Stop[0].hooks[0].command, buildCodexStopHookCommand({ nodePath }));
|
|
940
|
+
process.env.PATH = '';
|
|
941
|
+
assert.notEqual(buildCodexStopHookCommand(), buildCodexStopHookCommand({ nodePath }));
|
|
942
|
+
await run([]);
|
|
943
|
+
assert.equal(readFileSync(hooksPath, 'utf8'), first);
|
|
944
|
+
} finally {
|
|
945
|
+
if (originalPath === undefined) delete process.env.PATH;
|
|
946
|
+
else process.env.PATH = originalPath;
|
|
947
|
+
unsilence();
|
|
948
|
+
home.restore();
|
|
949
|
+
}
|
|
950
|
+
});
|
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;
|