throughline 0.15.1 → 0.15.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -1
- package/docs/05_codex_first_roadmap.md +2 -2
- package/docs/adr/0033-claude-auto-handoff-new-session.md +24 -11
- package/package.json +1 -1
- package/src/claude-auto-handoff.mjs +52 -81
- package/src/claude-auto-handoff.test.mjs +102 -117
- package/src/hook-entrypoints.test.mjs +51 -0
- package/src/hosts/claude.mjs +40 -2
- package/src/hosts/claude.test.mjs +58 -1
- package/src/session-start.mjs +2 -9
- package/src/turn-processor.mjs +16 -4
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,48 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.15.3] — 2026-10-06
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- Claude Codeの自動継続(`auto-handoff enable --host claude`)で、旧い会話が権限のバイパス(`bypassPermissions`)の時、後継が継続の指示を受け取らずに止まる不具合を直す。後継は旧い会話の権限を引き継ぐ。Claude Codeは、バイパス中の会話へ別の会話やprocessから届いた文を、送り手が権限のmodeを名乗らない限り利用者の承認まで止める(画面には`Held peer message`と出る)。0.15.2までは、後継を立てた後で受け口へ指示を送っていたので、ここで止まり、記録は`unknown`・`handoff_delivery_timeout`になっていた。バイパス以外の権限では起きない。
|
|
18
|
+
- 継続の指示は、後継を立てる時の最初の指示として渡す(`claude --bg … "<指示>"`)。立てた後で外から送らない。後継の受信の設定(`crossSessionInbound`)は変えない([ADR 0033](docs/adr/0033-claude-auto-handoff-new-session.md)の4と5を書き換え)。
|
|
19
|
+
- 受領は、後継の最初の指示が`~/.throughline/claude-auto-handoff/<旧会話のsession_id>.accepted`に残し、workerがそれを待って`sent`にする。最初の指示は、workerが後継のIDを記録へ写す前に届くことがあるので、その間は指示が運ぶ引き継ぎIDで後継と認める。期限(60秒)までに残らなければ`unknown`・`handoff_delivery_unconfirmed`にし、後継は立て直さない。`handoff_successor_target_unavailable`と`handoff_delivery_timeout`は出なくなる。
|
|
20
|
+
- 継続の指示の文に、「実行されなかった道具」はまだ実行されていないこと、最初にそれを呼び出すことを書き足す。指示が利用者の発言として届くようになり、Haiku 4.5の後継が、止められた道具をやり直さずに「読みました」と書いて先へ進んだ回があった(macOS、1回)。
|
|
21
|
+
- 後継の`SessionStart`は受け口を控えなくなる。Codexの自動継続は変えない。
|
|
22
|
+
|
|
23
|
+
### 確認した範囲
|
|
24
|
+
|
|
25
|
+
- macOS、Claude Desktop(同梱の2.1.286、権限はバイパス、0.15.2)。作業フォルダを移した会話で、圧縮を止める・旧い会話を止める・後継を立てる、の3段がDesktopの画面から送った指示で通り、4段目が上の理由で止まることを確かめた。
|
|
26
|
+
- macOS、端末の会話(Claude Code 2.1.289、Haiku 4.5)。この版で、バイパスの会話は4回、編集の自動許可(`acceptEdits`)の会話も4回、連続で引き継いで14個を読み切り完了。どの引き継ぎも`sent`で、承認待ちは出ない。指示の文を書き足す前の版では、バイパスで2回動かし、1回は完了、1回は上の「読みました」で未完了。
|
|
27
|
+
- Windows 11、端末の会話(2.1.289、Haiku 4.5、`acceptEdits`)。この版で4回連続で引き継いで完了。最初の指示(改行と日本語を含む)は、そのまま後継のhookへ届く。
|
|
28
|
+
|
|
29
|
+
### 確認していない範囲
|
|
30
|
+
|
|
31
|
+
- Claude Desktopの画面で、この版が最後まで通ること(後継が指示を受け取って作業を終える所)。
|
|
32
|
+
- Linuxで実物のClaude Codeを動かした通し。単体と結合の試験だけ。
|
|
33
|
+
- Windowsのバイパスの会話、Opus・Fableの後継。
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
## [0.15.2] — 2026-10-06
|
|
37
|
+
|
|
38
|
+
### 修正
|
|
39
|
+
|
|
40
|
+
- Claude Desktopで「フォルダなし」で始めて、後から作業フォルダへ移した会話では、自動継続(`auto-handoff enable --host claude`)を有効にしていても引き継ぎが始まらない不具合を直す。Desktopは会話を移した後も、hookへ渡す`CLAUDE_PROJECT_DIR`を移る前の場所のままにする。Throughlineはその値で対象のprojectかどうかを決めていたので、自動圧縮の前のhookが`auto_handoff_disabled`で見送り、圧縮がそのまま走っていた。
|
|
41
|
+
- 会話が移った先は、Claude Codeがtranscriptへ書く`relocated`の行(`relocatedCwd`)から読む。移っている会話では、自動継続の有効判定と印(baton)のprojectを、移った先にする。
|
|
42
|
+
- 同じ会話のターン終了では、完了ターンの控えを移った先のprojectへ書き、`sessions.project_path`を移った先へ付け替える。これまでは移る前の場所のまま残り、移った先のprojectの過去の会話(`handoff-context`、`throughline detail`)にも`observer-read`にも、その会話が出なかった。
|
|
43
|
+
- 移っていない会話の扱いは変わらない。`relocated`の行が無ければ、これまでどおり`CLAUDE_PROJECT_DIR`、無ければhookの`cwd`を使う。
|
|
44
|
+
|
|
45
|
+
### 確認した範囲
|
|
46
|
+
|
|
47
|
+
- macOS(Claude Desktop同梱の2.1.286が書いた本物のtranscriptの写し、移った後に自動圧縮が2回走った会話)。切り離したHOMEでhookを手で呼び、0.14.2は`pre-compact`が終了コード0・`auto_handoff_disabled`、`sessions.project_path`と控えは移る前の場所。この版は`pre-compact`が終了コード2(圧縮を止める)・`requested`、`sessions.project_path`と控えは移った先、`observer-read`は移った先のprojectで`snapshot`を返す。
|
|
48
|
+
- macOS、端末から始めた移っていない会話(Claude Code 2.1.289、Haiku 4.5)。この版で4回連続で引き継ぎ、14個を番号順に読み切って完了。自動圧縮は0回。
|
|
49
|
+
|
|
50
|
+
### 確認していない範囲
|
|
51
|
+
|
|
52
|
+
- Claude Desktopの画面で、この版が通しで引き継ぐこと(旧い会話を止める所と、後継を立てる所)。Desktopの画面からの送信が要るため、hookを手で呼ぶ確認までにとどまる。
|
|
53
|
+
- Windows・Linuxで移した会話。`relocated`の行の読み取りは、Windowsの書き方の場所を単体の試験で確かめただけ。
|
|
54
|
+
|
|
13
55
|
## [0.15.1] — 2026-10-05
|
|
14
56
|
|
|
15
57
|
### 修正
|
|
@@ -1771,7 +1813,9 @@ two attempts, instrument first instead of patching again.
|
|
|
1771
1813
|
|
|
1772
1814
|
---
|
|
1773
1815
|
|
|
1774
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.15.
|
|
1816
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.15.3...HEAD
|
|
1817
|
+
[0.15.3]: https://github.com/kitepon/Throughline/compare/v0.15.2...v0.15.3
|
|
1818
|
+
[0.15.2]: https://github.com/kitepon/Throughline/compare/v0.15.1...v0.15.2
|
|
1775
1819
|
[0.15.1]: https://github.com/kitepon/Throughline/compare/v0.15.0...v0.15.1
|
|
1776
1820
|
[0.15.0]: https://github.com/kitepon/Throughline/compare/v0.14.3...v0.15.0
|
|
1777
1821
|
[0.14.3]: https://github.com/kitepon/Throughline/compare/v0.14.2...v0.14.3
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
最初の成立検証はCodex Desktopで行う。OSごとの仕組みと受入条件は共通にし、
|
|
29
29
|
差分は既存の `src/os/` と配送ライブラリの環境適合へ閉じ込める。
|
|
30
30
|
VS Code・CLIとClaude/Grok/Cursorの対応はhostごとに同じ条件で検証する。
|
|
31
|
-
Claude Codeは同じ並び(圧縮を止める、旧い会話を止める、新しい会話を立てる、継続の指示を1
|
|
32
|
-
Claude Codeの入口(hook
|
|
31
|
+
Claude Codeは同じ並び(圧縮を止める、旧い会話を止める、新しい会話を立てる、継続の指示を1通渡す)を、
|
|
32
|
+
Claude Codeの入口(hook、最初の指示つきの`claude --bg`)で行う([ADR 0033](adr/0033-claude-auto-handoff-new-session.md))。
|
|
33
33
|
この節の処理順と進行状態の細目はCodexだけに適用する。
|
|
34
34
|
一つのhostの成功で他のhostも対応済みとはしない。
|
|
35
35
|
|
|
@@ -14,10 +14,10 @@ Codex 版の各段に当たるものを、Claude Code 2.1.289 の公式文書と
|
|
|
14
14
|
| Codex 版の段 | Claude Code で当たるもの | 確認 |
|
|
15
15
|
|---|---|---|
|
|
16
16
|
| 圧縮の前に旧タスクを止める | `PreCompact` の exit code 2 で圧縮を止める。`PreToolUse` が `permissionDecision: "deny"` と `continue: false` を返すと、その道具は実行されずにターンが止まる | 実機 |
|
|
17
|
-
| 新しいタスクを作る | `claude --bg`。指示を付けずに起動すると、指示を待つ会話が立つ(`idle — send a prompt to start
|
|
17
|
+
| 新しいタスクを作る | `claude --bg`。指示を付けずに起動すると、指示を待つ会話が立つ(`idle — send a prompt to start`)。指示を付けて起動すると、その指示でターンが始まる | 実機 |
|
|
18
18
|
| 止めたターンを取り込んでから記憶を作る | hook で止めたターンでは Stop hook が走らない。worker が、後継を立てる前に transcript から取り込む | 実機(0.14.1) |
|
|
19
19
|
| 記憶を入れる | 最初の指示の `UserPromptSubmit` hook の出力。`/tl` の印(baton)の引き継ぎと同じ | 実機 |
|
|
20
|
-
| 「続けて」を送る |
|
|
20
|
+
| 「続けて」を送る | `claude --bg` の最初の指示(0.15.3 から)。0.15.2 までは会話ごとの受け口(inbox socket)へ `aiterm-steer-delivery` の `sendClaudeInbox` で送っていた | 実機 |
|
|
21
21
|
|
|
22
22
|
`PreCompact` の `continue` は捨てられるので、圧縮を止める hook と作業を止める hook は別になる。
|
|
23
23
|
`PreToolUse` が `continue: false` だけを返すと、その道具は実行されてから止まる。`deny` を一緒に返すと実行されない。
|
|
@@ -31,6 +31,8 @@ Claude Desktop の新しい会話は、画面を開いただけでは process
|
|
|
31
31
|
1. **圧縮を止める**: `PreCompact` hook(`throughline pre-compact`、matcher 無し)が、`trigger: "auto"` で自動継続が
|
|
32
32
|
有効な project の時だけ、`/tl` と同じ印(baton)と引き継ぎの記録を残し、exit code 2 で圧縮を止める。
|
|
33
33
|
印の project は、会話を起動した場所(`CLAUDE_PROJECT_DIR`)を、その OS の書き方にそろえたもの(0.14.2)。
|
|
34
|
+
Claude Desktop で会話を別の project へ移している時は、transcript の `relocated` の行(`relocatedCwd`)が示す移った先を使う。
|
|
35
|
+
`CLAUDE_PROJECT_DIR` は移る前の場所のまま届くため(0.15.2)。
|
|
34
36
|
hook の cwd は Bash の `cd` に追従するので使わない。後継は起動した場所で立ち、その cwd で印を探す。
|
|
35
37
|
手動の `/compact`、無効な project、subagent の中の圧縮(payload に `agent_id`)、script から起動した会話
|
|
36
38
|
(`CLAUDE_CODE_ENTRYPOINT` が `sdk-` で始まる)では止めない。
|
|
@@ -38,7 +40,8 @@ Claude Desktop の新しい会話は、画面を開いただけでは process
|
|
|
38
40
|
実行させずに止める。旧い会話は止めるだけで、空にしない。同じ応答に並んだ道具も全て止め、後継の立ち上げは1回だけ行う。
|
|
39
41
|
記録が無い会話では、記録のファイルの有無だけを見て終わる。subagent の中の道具は止めない。
|
|
40
42
|
3. **新しい会話を立てる**: 切り離した worker が、止めた時点の依頼・モデル・推論強度・権限を記録へ写し、
|
|
41
|
-
同じ project で `claude --bg`
|
|
43
|
+
同じ project で `claude --bg` を、継続の指示を最初の指示として付けて起動する(0.15.3。下の4)。
|
|
44
|
+
起動時に `--model`・`--effort`・`--permission-mode` を渡し、
|
|
42
45
|
`--settings '{"worktree":{"bgIsolation":"none"}}'` で同じ作業ツリーを編集させる。
|
|
43
46
|
依頼と直前の発言は、止めた道具の呼び出しが transcript に書かれるのを待ってから読む(hook の時点では直前の発言が
|
|
44
47
|
まだ書かれていないことがある)。
|
|
@@ -46,10 +49,14 @@ Claude Desktop の新しい会話は、画面を開いただけでは process
|
|
|
46
49
|
bodies の本文にし、道具の入出力は transcript の末尾(止めた道具の呼び出し)まで details に入れる。
|
|
47
50
|
完了したターンは最後の本文だけを残すが、止めたターンには結論の発言が無いので、全部を残す。
|
|
48
51
|
取り込めなくても引き継ぎは止めない。理由は worker のログに残り、状態の `in_flight_captured` が `false` になる。
|
|
49
|
-
4. **継続の指示を1
|
|
50
|
-
|
|
51
|
-
`
|
|
52
|
-
|
|
52
|
+
4. **継続の指示を1通渡す**: 継続の指示は、後継を起動する時の最初の指示として渡す(0.15.3、オーナーの判断 2026-10-06)。
|
|
53
|
+
立てた後で外から送らない。Claude Code は、権限のバイパス中の会話へ届いた文を、送り手が権限の mode を名乗らない限り
|
|
54
|
+
利用者の承認まで止める(cross-session messaging の決まり。画面には `Held peer message` と出る)。
|
|
55
|
+
後継は旧い会話の権限を引き継ぐので、旧い会話がバイパスだと、0.15.2 までの配送(後継の受け口へ `sendClaudeInbox` で送る)は
|
|
56
|
+
承認待ちで止まり、引き継ぎが進まなかった。後継の設定で受信を緩める方法(`crossSessionInbound: accept`)は採らない。
|
|
57
|
+
バイパス中の後継が、他の会話からの文も確認なしで受け取るようになるため。
|
|
58
|
+
`claude` は stdin も指示に足すので、起動時に stdin は渡さない。
|
|
59
|
+
その指示の `UserPromptSubmit` が baton を消費して前任を合流させ、記憶を注入する。
|
|
53
60
|
注入は ADR 0016 と同じ予算(9,500 字)で、ヘッダに作業ディレクトリ、現在地、案内、直近の L2 の順に載せる。
|
|
54
61
|
宣言文は求めない。現在地は止めたターンで、次の順に載せる(0.14.1)。
|
|
55
62
|
- 止めた時点の依頼(3,500 字まで)。
|
|
@@ -59,9 +66,11 @@ Claude Desktop の新しい会話は、画面を開いただけでは process
|
|
|
59
66
|
- 止める直前に呼ぼうとして、実行されなかった道具。圧縮を止めた後の応答が呼んだ道具は、どれも実行されていない。
|
|
60
67
|
hook は並んで走るので、同じ応答(`message.id`)の道具をまとめて扱う。
|
|
61
68
|
止めたターンは現在地にだけ載せ、L2 の一覧には重ねない。そのターンの道具の入出力は `throughline detail` で取り出せる。
|
|
62
|
-
5. **受領**: 継続の指示は引き継ぎ ID を含む。後継の最初の指示がその ID
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
5. **受領**: 継続の指示は引き継ぎ ID を含む。後継の最初の指示がその ID を含む時だけ、受領を残す
|
|
70
|
+
(`<旧会話の session_id>.accepted`。記録は worker だけが書き換える)。最初の指示は、worker が後継の ID を記録へ写す前に
|
|
71
|
+
届くことがあるので、その間は指示が運ぶ引き継ぎ ID で後継と認める。worker は受領を待ち、残れば `sent`、
|
|
72
|
+
期限(60秒)までに残らなければ `unknown`(`handoff_delivery_unconfirmed`)にする。
|
|
73
|
+
受領を確かめられない時も、後継を立て直さない。後継が作業を進めたことまでは確かめない(オーナーの判断: 不要)。
|
|
65
74
|
6. **連続の引き継ぎ**: 後継も同じ hook で止まり、次の後継へ引き継ぐ。後継の最後の user 発言は前の引き継ぎの
|
|
66
75
|
継続の指示なので、現在地には前の引き継ぎが運んだ元の依頼を引き続き載せる。
|
|
67
76
|
7. **道具を呼ばずに終わったターン**: 圧縮を止めた後、モデルが道具を呼ばずにターンを終えたら、Stop hook が記録を取り下げる。
|
|
@@ -106,4 +115,8 @@ Claude Desktop の新しい会話は、画面を開いただけでは process
|
|
|
106
115
|
- Windows 11。端末の会話(2.1.289)から始めた。0.14.2 で、Haiku 4.5 は4回、連続で引き継いで完了した。
|
|
107
116
|
0.14.1 までは、印(baton)に残す project の場所が `C:/…` の形で、後継が `C:\…` の形で探して見つけられなかった。
|
|
108
117
|
印と記録に残す場所は、その OS の書き方にそろえる(0.14.2)。
|
|
109
|
-
- Claude Desktop
|
|
118
|
+
- Claude Desktop の画面から送った指示(同梱の 2.1.286、権限はバイパス、作業フォルダを移した会話、0.15.2)。圧縮を止める・
|
|
119
|
+
旧い会話を止める・後継を立てる、の3段は通った。継続の指示は、バイパス中の後継が承認待ちで止めた(0.15.3 で渡し方を変えた理由)。
|
|
120
|
+
- 0.15.3(継続の指示を最初の指示として渡す)。macOS の端末の会話(2.1.289、Haiku 4.5)で、バイパスは4回、`acceptEdits` も4回、
|
|
121
|
+
連続で引き継いで完了。Windows 11 の端末の会話(`acceptEdits`)でも4回連続で引き継いで完了した。
|
|
122
|
+
- 0.15.3 が Claude Desktop の画面で最後まで通ること、Linux の実物での通し、Fable、subagent が動いている最中の引き継ぎは確かめていない。
|
package/package.json
CHANGED
|
@@ -8,21 +8,21 @@
|
|
|
8
8
|
* 2. PreToolUse : 記録がある会話の次の道具を、実行させずに止める(deny + continue:false)。
|
|
9
9
|
* 止めた時点の依頼・設定を記録へ写し、後継を立てる worker を起動する。
|
|
10
10
|
* 3. worker : 止めたターンを DB へ取り込み(発言の全部と道具の入出力)、ここまでにしたことを
|
|
11
|
-
* 記録へ写す。`claude --bg`
|
|
12
|
-
* 4.
|
|
13
|
-
*
|
|
11
|
+
* 記録へ写す。`claude --bg` を、継続の指示を最初の指示として付けて、同じ project に立てる。
|
|
12
|
+
* 4. 後継の UserPromptSubmit : その指示が baton を消費し、記憶を注入して、受領を残す。worker は受領を待つ。
|
|
13
|
+
* 外から後継へ文を送らない。権限のバイパス中の会話は、外から届いた文を
|
|
14
|
+
* 利用者の承認まで止めるため(0.15.3)。
|
|
14
15
|
*
|
|
15
16
|
* 旧い会話は止めるだけで、空にしない。手動の /compact と subagent の中の圧縮は対象にしない。
|
|
16
17
|
* 状態は ~/.throughline/claude-auto-handoff/ のファイルに持つ(schema は変えない)。
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
|
-
import { closeSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
20
21
|
import { homedir } from 'node:os';
|
|
21
22
|
import { basename, dirname, join, posix, resolve, win32 } from 'node:path';
|
|
22
23
|
import { randomUUID } from 'node:crypto';
|
|
23
24
|
import { fileURLToPath } from 'node:url';
|
|
24
25
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
25
|
-
import { sendClaudeInbox } from 'aiterm-steer-delivery';
|
|
26
26
|
import { readClaudeAutoHandoffConfig, claudeAutoHandoffEnabledFor } from './claude-auto-handoff-config.mjs';
|
|
27
27
|
import { CLAUDE_HOST, hostOfSessionId } from './hosts/identity.mjs';
|
|
28
28
|
import { claudeHostAdapter } from './hosts/claude.mjs';
|
|
@@ -45,7 +45,7 @@ const CLI_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '../bin/throug
|
|
|
45
45
|
const STALE_RECORD_MS = 24 * 60 * 60 * 1000;
|
|
46
46
|
// session id を記録のファイル名に使う。Claude の id は UUID で、path の区切りを含む値は受け付けない。
|
|
47
47
|
const CLAUDE_SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
48
|
-
// `claude --bg` の出力(例: "backgrounded · a41a97ae · name (idle — send a prompt to start)"
|
|
48
|
+
// `claude --bg` の出力(例: "backgrounded · a41a97ae · name"。指示なしで立てた時は "(idle — send a prompt to start)" が続く)。
|
|
49
49
|
// 後継の中から起動した時は ID に色の制御文字が付くので、外してから読む。
|
|
50
50
|
const BACKGROUNDED_PATTERN = /backgrounded\s+\S+\s+([0-9a-f]{8})\b/;
|
|
51
51
|
// 後継へ渡さない、会話ごとの環境変数。後継は自分の値を持つ。
|
|
@@ -54,11 +54,14 @@ const PER_SESSION_ENV = ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_ME
|
|
|
54
54
|
|
|
55
55
|
// requested と failed は、次の道具の hook が後継の立ち上げを始められる。
|
|
56
56
|
const RETRYABLE_STATES = new Set(['requested', 'failed']);
|
|
57
|
-
|
|
58
|
-
const ACCEPTABLE_STATES = new Set(['
|
|
57
|
+
// 後継の最初の指示は、worker が後継の ID を記録へ写す前に届くことがある(launching)。
|
|
58
|
+
const ACCEPTABLE_STATES = new Set(['launching', 'launched', 'sent', 'unknown']);
|
|
59
59
|
|
|
60
60
|
export const claudeAutoHandoffDir = () => join(homedir(), '.throughline', 'claude-auto-handoff');
|
|
61
|
+
// 0.15.2 までが受け口の控えを置いた場所。期限の掃除だけを続ける。
|
|
61
62
|
const targetsDir = (dir) => join(dir, 'targets');
|
|
63
|
+
// 後継の最初の指示が残す受領。記録(worker が書き換える)とは別のファイルにして、書き込みが重ならないようにする。
|
|
64
|
+
const acceptancePath = (sessionId, dir) => join(dir, `${sessionId}.accepted`);
|
|
62
65
|
|
|
63
66
|
class ClaudeHandoffError extends Error {
|
|
64
67
|
constructor(code) { super(code); this.code = code; }
|
|
@@ -141,7 +144,7 @@ function removeStale(dir, now) {
|
|
|
141
144
|
throw error;
|
|
142
145
|
}
|
|
143
146
|
for (const name of names) {
|
|
144
|
-
if (!/\.(json|claim|log)$/.test(name)) continue;
|
|
147
|
+
if (!/\.(json|claim|log|accepted)$/.test(name)) continue;
|
|
145
148
|
const path = join(folder, name);
|
|
146
149
|
let modifiedAt;
|
|
147
150
|
try { modifiedAt = statSync(path).mtimeMs; }
|
|
@@ -197,8 +200,10 @@ export function requestClaudeAutoHandoff({
|
|
|
197
200
|
const path = recordPath(sessionId, dir);
|
|
198
201
|
const existing = readClaudeAutoHandoff(sessionId, dir);
|
|
199
202
|
// 有効判定は会話を起動した project で行う。hook の cwd は Bash の cd に追従する。
|
|
203
|
+
// 会話が別の project へ移っている時(Claude Desktop)は、移った先で行う。
|
|
200
204
|
const projectPath = nativeProjectPath(
|
|
201
|
-
claudeHostAdapter.completionProjectPath({
|
|
205
|
+
claudeHostAdapter.completionProjectPath({
|
|
206
|
+
cwd: payload.cwd ?? process.cwd(), env, transcriptPath: payload.transcript_path }), platform);
|
|
202
207
|
|
|
203
208
|
if (payload.trigger !== 'auto') {
|
|
204
209
|
// 人が /compact を選んだ。まだ止めていない記録は取り下げる。
|
|
@@ -474,44 +479,12 @@ export function completeClaudeTurnWithoutHandoff({ sessionId, dir = claudeAutoHa
|
|
|
474
479
|
|
|
475
480
|
/** 後継へ送る継続の指示。引き継ぎIDを含め、後継の最初の指示がこの配送のものかを照合できるようにする。 */
|
|
476
481
|
export function claudeContinuationInput(record) {
|
|
482
|
+
// この文は後継の最初の指示(利用者の発言)として届く。止めた道具をやり直さずに「済んだ」と書いて
|
|
483
|
+
// 先へ進む後継が出たので(Haiku 4.5、macOS で実測)、実行されていない道具から始めることを明記する。
|
|
477
484
|
return `Throughline自動継続 ${record.handoff_id}\n` +
|
|
478
485
|
'注入された記憶と元のユーザー依頼に従い、未完了の作業をそのまま継続してください。' +
|
|
479
|
-
'
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
/**
|
|
483
|
-
* SessionStart から呼ぶ。同じ project で後継の立ち上げ中の引き継ぎがある時だけ、この会話の受け口を控える。
|
|
484
|
-
* worker が、`claude --bg` の返した ID と会話の ID を照合して読み、読んだら消す。
|
|
485
|
-
*/
|
|
486
|
-
export function recordClaudeSuccessorTarget({ payload, env = process.env, dir = claudeAutoHandoffDir(), now = Date.now() }) {
|
|
487
|
-
const sessionId = payload?.session_id;
|
|
488
|
-
const socketPath = env.CLAUDE_CODE_MESSAGING_SOCKET;
|
|
489
|
-
if (!validSessionId(sessionId) || payload.agent_id || payload.source !== 'startup' ||
|
|
490
|
-
typeof socketPath !== 'string' || !socketPath) return false;
|
|
491
|
-
const cwd = payload.cwd ?? process.cwd();
|
|
492
|
-
const waiting = listClaudeAutoHandoffs({ dir }).some(record =>
|
|
493
|
-
TARGET_WAIT_STATES.has(record.state) && sameProjectPath(record.project_path, cwd) &&
|
|
494
|
-
(!record.successor?.short_id || sessionId.startsWith(record.successor.short_id)));
|
|
495
|
-
if (!waiting) return false;
|
|
496
|
-
writeJsonPrivate(join(targetsDir(dir), `${sessionId}.json`), {
|
|
497
|
-
session_id: sessionId, socket_path: socketPath, token: env.CLAUDE_CODE_MESSAGING_TOKEN ?? null, recorded_at: now,
|
|
498
|
-
});
|
|
499
|
-
return true;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
function takeSuccessorTarget(shortId, dir) {
|
|
503
|
-
let names;
|
|
504
|
-
try { names = readdirSync(targetsDir(dir)); }
|
|
505
|
-
catch (error) {
|
|
506
|
-
if (error.code === 'ENOENT') return null;
|
|
507
|
-
throw error;
|
|
508
|
-
}
|
|
509
|
-
const name = names.find(entry => entry.startsWith(shortId) && entry.endsWith('.json'));
|
|
510
|
-
if (!name) return null;
|
|
511
|
-
const path = join(targetsDir(dir), name);
|
|
512
|
-
const target = readJson(path);
|
|
513
|
-
rmSync(path, { force: true });
|
|
514
|
-
return target;
|
|
486
|
+
'記憶の「実行されなかった道具」は、まだ実行されていません。最初にそれを実際に呼び出してから、先へ進んでください。' +
|
|
487
|
+
'「ここまでにしたこと」に載っている完了済みの操作は、重複実行しないでください。';
|
|
515
488
|
}
|
|
516
489
|
|
|
517
490
|
/**
|
|
@@ -524,15 +497,26 @@ export function acceptClaudeAutoContinuation({ predecessorId, successorSessionId
|
|
|
524
497
|
dir = claudeAutoHandoffDir(), now = Date.now() }) {
|
|
525
498
|
if (!validSessionId(predecessorId) || typeof successorSessionId !== 'string') return null;
|
|
526
499
|
const record = readClaudeAutoHandoff(predecessorId, dir);
|
|
527
|
-
if (!record || !ACCEPTABLE_STATES.has(record.state)
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
500
|
+
if (!record || !ACCEPTABLE_STATES.has(record.state)) return null;
|
|
501
|
+
const carriesInstruction = typeof prompt === 'string' && prompt.includes(`Throughline自動継続 ${record.handoff_id}`);
|
|
502
|
+
// 後継の ID が記録に入っていれば ID で、まだなら指示が運ぶ引き継ぎ ID で、後継と認める。
|
|
503
|
+
const isSuccessor = record.successor?.short_id
|
|
504
|
+
? successorSessionId.startsWith(record.successor.short_id)
|
|
505
|
+
: carriesInstruction;
|
|
506
|
+
if (!isSuccessor) return null;
|
|
507
|
+
if (carriesInstruction && !record.accepted_at && !existsSync(acceptancePath(predecessorId, dir))) {
|
|
508
|
+
writeJsonPrivate(acceptancePath(predecessorId, dir),
|
|
509
|
+
{ handoff_id: record.handoff_id, successor_session_id: successorSessionId, accepted_at: now });
|
|
531
510
|
}
|
|
532
511
|
return { handoffId: record.handoff_id, projectPath: record.project_path, inFlight: record.in_flight ?? null,
|
|
533
512
|
transcriptPath: record.transcript_path ?? null };
|
|
534
513
|
}
|
|
535
514
|
|
|
515
|
+
function readAcceptance(sessionId, handoffId, dir) {
|
|
516
|
+
const acceptance = readJson(acceptancePath(sessionId, dir));
|
|
517
|
+
return acceptance?.handoff_id === handoffId && typeof acceptance.accepted_at === 'number' ? acceptance : null;
|
|
518
|
+
}
|
|
519
|
+
|
|
536
520
|
function successorArgs(record) {
|
|
537
521
|
const args = ['--bg', '--name', `tl-${basename(record.project_path)}-${record.handoff_id.slice(0, 8)}`];
|
|
538
522
|
const { model, effort, permission_mode: permissionMode } = record.settings ?? {};
|
|
@@ -541,6 +525,9 @@ function successorArgs(record) {
|
|
|
541
525
|
if (permissionMode) args.push('--permission-mode', permissionMode);
|
|
542
526
|
// 後継は旧い会話の続きなので、同じ作業ツリーを編集する。別の worktree へ移さない。
|
|
543
527
|
args.push('--settings', JSON.stringify({ worktree: { bgIsolation: 'none' } }));
|
|
528
|
+
// 継続の指示は、起動時の最初の指示として渡す。立てた後で外から送ると、権限のバイパス中の後継は
|
|
529
|
+
// その文を利用者の承認まで止める(Claude Code の cross-session messaging の決まり)。
|
|
530
|
+
args.push(claudeContinuationInput(record));
|
|
544
531
|
return args;
|
|
545
532
|
}
|
|
546
533
|
|
|
@@ -551,17 +538,16 @@ function successorEnv(env) {
|
|
|
551
538
|
}
|
|
552
539
|
|
|
553
540
|
/**
|
|
554
|
-
*
|
|
541
|
+
* 継続の指示を付けて後継を立て、後継の最初の指示が受領を残すのを待つ。失敗は固定の理由を記録して止まる。
|
|
542
|
+
* 受領を確かめられない時も、後継を立て直さない(同じ指示を持った会話を2つ作らない)。
|
|
555
543
|
*/
|
|
556
544
|
export async function runClaudeAutoHandoffWorker(sessionId, {
|
|
557
545
|
dir = claudeAutoHandoffDir(),
|
|
558
546
|
env = process.env,
|
|
559
547
|
spawn = spawnPortableSync,
|
|
560
|
-
send = sendClaudeInbox,
|
|
561
548
|
openDb = null,
|
|
562
549
|
transcriptTimeoutMs = 5_000,
|
|
563
|
-
|
|
564
|
-
sendTimeoutMs = 30_000,
|
|
550
|
+
acceptTimeoutMs = 60_000,
|
|
565
551
|
pollMs = 200,
|
|
566
552
|
} = {}) {
|
|
567
553
|
let record = readClaudeAutoHandoff(sessionId, dir);
|
|
@@ -571,7 +557,8 @@ export async function runClaudeAutoHandoffWorker(sessionId, {
|
|
|
571
557
|
const fail = code => update({ state: 'failed', error_code: code });
|
|
572
558
|
|
|
573
559
|
// 止めた時点の依頼・ここまでにしたこと・その時のモデルを写し、止めたターンを DB へ取り込む。
|
|
574
|
-
//
|
|
560
|
+
// 後継の最初の指示が、これを現在地として受け取る。前の引き継ぎの受領が残っていれば消す。
|
|
561
|
+
rmSync(acceptancePath(sessionId, dir), { force: true });
|
|
575
562
|
await waitForStoppedToolUse(record, { timeoutMs: transcriptTimeoutMs, pollMs });
|
|
576
563
|
const inFlight = record.transcript_path ? snapshotInFlight(record, dir) : null;
|
|
577
564
|
if (inFlight && openDb) inFlight.turn = captureStoppedTurn(record, openDb);
|
|
@@ -595,31 +582,15 @@ export async function runClaudeAutoHandoffWorker(sessionId, {
|
|
|
595
582
|
}
|
|
596
583
|
update({ state: 'launched', successor: { short_id: shortId, session_id: null } });
|
|
597
584
|
|
|
598
|
-
|
|
599
|
-
for (const deadline = Date.now() +
|
|
600
|
-
|
|
601
|
-
if (
|
|
585
|
+
// 指示は起動時に渡してある。後継の最初の UserPromptSubmit が受領を残すのを待つ。
|
|
586
|
+
for (const deadline = Date.now() + acceptTimeoutMs; Date.now() < deadline;) {
|
|
587
|
+
const acceptance = readAcceptance(sessionId, record.handoff_id, dir);
|
|
588
|
+
if (acceptance) {
|
|
589
|
+
rmSync(acceptancePath(sessionId, dir), { force: true });
|
|
590
|
+
return update({ state: 'sent', error_code: null, accepted_at: acceptance.accepted_at,
|
|
591
|
+
successor: { short_id: shortId, session_id: acceptance.successor_session_id } });
|
|
592
|
+
}
|
|
593
|
+
await delay(pollMs);
|
|
602
594
|
}
|
|
603
|
-
|
|
604
|
-
update({ successor: { short_id: shortId, session_id: target.session_id } });
|
|
605
|
-
|
|
606
|
-
// ここから先は後継へ指示が届き得る。結果が不明でも、同じ指示をもう一度送らない。
|
|
607
|
-
update({ state: 'sending' });
|
|
608
|
-
const result = await send(
|
|
609
|
-
{ socket_path: target.socket_path, ...(target.token ? { token: target.token } : {}) },
|
|
610
|
-
claudeContinuationInput(record),
|
|
611
|
-
{
|
|
612
|
-
timeout_ms: sendTimeoutMs,
|
|
613
|
-
confirm_acceptance: async signal => {
|
|
614
|
-
while (!signal.aborted) {
|
|
615
|
-
if (readClaudeAutoHandoff(sessionId, dir)?.accepted_at) return true;
|
|
616
|
-
await delay(pollMs);
|
|
617
|
-
}
|
|
618
|
-
return false;
|
|
619
|
-
},
|
|
620
|
-
},
|
|
621
|
-
);
|
|
622
|
-
if (result.status === 'accepted') return update({ state: 'sent', error_code: null });
|
|
623
|
-
if (result.status === 'not_sent') return fail(`handoff_delivery_${result.reason}`);
|
|
624
|
-
return update({ state: 'unknown', error_code: `handoff_delivery_${result.reason}` });
|
|
595
|
+
return update({ state: 'unknown', error_code: 'handoff_delivery_unconfirmed' });
|
|
625
596
|
}
|
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
requestClaudeAutoHandoff,
|
|
11
11
|
stopClaudeTurnForHandoff,
|
|
12
12
|
completeClaudeTurnWithoutHandoff,
|
|
13
|
-
recordClaudeSuccessorTarget,
|
|
14
13
|
acceptClaudeAutoContinuation,
|
|
15
14
|
runClaudeAutoHandoffWorker,
|
|
16
15
|
readClaudeAutoHandoff,
|
|
@@ -63,6 +62,33 @@ function request(dir, overrides = {}) {
|
|
|
63
62
|
return { result, db };
|
|
64
63
|
}
|
|
65
64
|
|
|
65
|
+
test('PreCompact: 会話が別の project へ移っている時は、移った先で有効かどうかを決める(Claude Desktop)', () => withDir(dir => {
|
|
66
|
+
// Desktop は「フォルダなし」で始めた会話を後から project へ移す。CLAUDE_PROJECT_DIR は移る前のまま届く。
|
|
67
|
+
const scratch = '/Users/kite/Library/Application Support/Claude/scratch-workspaces/a/b/scratch-2026-10-05-ac3fa8';
|
|
68
|
+
const moved = '/Users/kite/tl-claude-probe';
|
|
69
|
+
const transcriptPath = join(dir, 'moved.jsonl');
|
|
70
|
+
writeFileSync(transcriptPath, jsonl([user('読んで', '2026-10-05T22:38:17.050Z'), assistant('読みました', '2026-10-05T22:39:18.926Z'),
|
|
71
|
+
{ type: 'relocated', sessionId: SESSION, relocatedCwd: moved }]));
|
|
72
|
+
const onlyMoved = { enabled: true, projects: [moved] };
|
|
73
|
+
|
|
74
|
+
const { result, db } = request(dir, { config: onlyMoved, transcriptPath, env: { CLAUDE_PROJECT_DIR: scratch }, payload: { cwd: moved } });
|
|
75
|
+
assert.equal(result.status, 'requested');
|
|
76
|
+
assert.equal(result.block, true);
|
|
77
|
+
assert.equal(result.projectPath, moved);
|
|
78
|
+
assert.equal(readClaudeAutoHandoff(SESSION, dir).project_path, moved);
|
|
79
|
+
assert.equal(db.prepare('SELECT session_id FROM handoff_batons WHERE project_path = ?').get(moved).session_id, SESSION);
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
test('PreCompact: 移っていない会話は、これまでどおり起動した project で決める', () => withDir(dir => {
|
|
83
|
+
const transcriptPath = join(dir, 'plain.jsonl');
|
|
84
|
+
writeFileSync(transcriptPath, jsonl([user('読んで', '2026-10-05T22:38:17.050Z'), assistant('読みました', '2026-10-05T22:39:18.926Z')]));
|
|
85
|
+
const { result } = request(dir, { config: { enabled: true, projects: ['/work/app/sub'] }, transcriptPath,
|
|
86
|
+
env: { CLAUDE_PROJECT_DIR: '/work/app' }, payload: { cwd: '/work/app/sub' } });
|
|
87
|
+
assert.equal(result.status, 'skipped');
|
|
88
|
+
assert.equal(result.reason, 'auto_handoff_disabled');
|
|
89
|
+
assert.equal(result.projectPath, '/work/app');
|
|
90
|
+
}));
|
|
91
|
+
|
|
66
92
|
test('PreCompact: 自動圧縮で有効な時だけ、印と記録を残して圧縮を止める', () => withDir(dir => {
|
|
67
93
|
const { result, db } = request(dir);
|
|
68
94
|
assert.equal(result.status, 'requested');
|
|
@@ -216,64 +242,48 @@ test('Stop: 圧縮を止めた後、道具を呼ばずにターンが終わっ
|
|
|
216
242
|
assert.equal(readClaudeAutoHandoff(SESSION, dir).state, 'stopped');
|
|
217
243
|
}));
|
|
218
244
|
|
|
219
|
-
/**
|
|
220
|
-
async function runWorker(dir, { spawn,
|
|
245
|
+
/** 止めた直後の記録を作り、後継の起動を差し替えて worker を走らせる。 */
|
|
246
|
+
async function runWorker(dir, { spawn, onLaunched }) {
|
|
221
247
|
request(dir);
|
|
222
248
|
await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app', permission_mode: 'acceptEdits',
|
|
223
249
|
effort: { level: 'high' } }, dir, launchWorker: async () => {} });
|
|
224
|
-
const calls = { spawn: []
|
|
250
|
+
const calls = { spawn: [] };
|
|
225
251
|
const record = await runClaudeAutoHandoffWorker(SESSION, {
|
|
226
252
|
dir, env: { PATH: '/bin', CLAUDECODE: '1', CLAUDE_CODE_ENTRYPOINT: 'claude-desktop', CLAUDE_CODE_MESSAGING_SOCKET: '/old.sock',
|
|
227
253
|
CLAUDE_CODE_MESSAGING_TOKEN: 'old-token', CLAUDE_PROJECT_DIR: '/work/app', CLAUDE_CODE_USE_BEDROCK: '1' },
|
|
228
|
-
|
|
254
|
+
acceptTimeoutMs: 300, pollMs: 10,
|
|
229
255
|
spawn: (command, args, options) => {
|
|
230
256
|
calls.spawn.push({ command, args, options });
|
|
231
257
|
const result = spawn(command, args, options);
|
|
232
|
-
if (result.status === 0) onLaunched?.();
|
|
258
|
+
if (result.status === 0) onLaunched?.(args);
|
|
233
259
|
return result;
|
|
234
260
|
},
|
|
235
|
-
send: async (target, text, options) => { calls.send.push({ target, text }); return send(target, text, options); },
|
|
236
261
|
});
|
|
237
262
|
return { record, calls };
|
|
238
263
|
}
|
|
239
264
|
|
|
240
|
-
const launchedOk = () => ({ status: 0, stdout: 'Starting background service…\nbackgrounded · a41a97ae · tl-app-1234
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
test('worker:
|
|
246
|
-
const { record, calls } = await runWorker(dir, {
|
|
247
|
-
spawn: launchedOk,
|
|
248
|
-
onLaunched: successorStarts(dir),
|
|
249
|
-
send: async (target, text, options) => {
|
|
250
|
-
// 届いた指示で、後継の最初の UserPromptSubmit が受領を記録する
|
|
251
|
-
const accepted = acceptClaudeAutoContinuation({ predecessorId: SESSION, successorSessionId: SUCCESSOR, prompt: text, dir });
|
|
252
|
-
assert.ok(accepted);
|
|
253
|
-
return await options.confirm_acceptance(new AbortController().signal)
|
|
254
|
-
? { status: 'accepted', outcome_unknown: false, reason: 'receiver_confirmed' }
|
|
255
|
-
: { status: 'unknown', outcome_unknown: true, reason: 'unconfirmed' };
|
|
256
|
-
},
|
|
257
|
-
});
|
|
265
|
+
const launchedOk = () => ({ status: 0, stdout: 'Starting background service…\nbackgrounded · a41a97ae · tl-app-1234\n', stderr: '' });
|
|
266
|
+
// 後継の最初の UserPromptSubmit。起動時に渡された指示で、受領を残す。worker が後継の ID を記録へ写す前に届く。
|
|
267
|
+
const successorStarts = (dir, predecessorId = SESSION) => args => acceptClaudeAutoContinuation({
|
|
268
|
+
predecessorId, successorSessionId: SUCCESSOR, prompt: args.at(-1), dir, now: 5_000 });
|
|
269
|
+
|
|
270
|
+
test('worker: 継続の指示を最初の指示として付けて後継を同じprojectに立て、後継の受領を待つ', () => withDir(async dir => {
|
|
271
|
+
const { record, calls } = await runWorker(dir, { spawn: launchedOk, onLaunched: successorStarts(dir) });
|
|
258
272
|
assert.equal(calls.spawn.length, 1);
|
|
259
273
|
const { command, args, options } = calls.spawn[0];
|
|
260
274
|
assert.equal(command, 'claude');
|
|
261
275
|
assert.deepEqual(args, ['--bg', '--name', `tl-app-${record.handoff_id.slice(0, 8)}`, '--effort', 'high',
|
|
262
|
-
'--permission-mode', 'acceptEdits', '--settings', '{"worktree":{"bgIsolation":"none"}}'],
|
|
263
|
-
'
|
|
276
|
+
'--permission-mode', 'acceptEdits', '--settings', '{"worktree":{"bgIsolation":"none"}}', claudeContinuationInput(record)],
|
|
277
|
+
'指示は起動時に渡す(外から送ると、権限のバイパス中の後継が承認まで止める)。モデルは transcript が無いので付かない');
|
|
278
|
+
assert.match(args.at(-1), new RegExp(`^Throughline自動継続 ${record.handoff_id}\\n注入された記憶と元のユーザー依頼に従い、未完了の作業をそのまま継続してください。`));
|
|
264
279
|
assert.equal(options.cwd, '/work/app');
|
|
265
280
|
assert.deepEqual(options.env, { PATH: '/bin', CLAUDE_CODE_USE_BEDROCK: '1' }, '会話ごとの環境変数は後継へ渡さない');
|
|
281
|
+
assert.deepEqual(options.stdio, ['ignore', 'pipe', 'pipe'], 'stdin を渡さない(claude は stdin も指示に足す)');
|
|
266
282
|
|
|
267
|
-
assert.
|
|
268
|
-
assert.deepEqual(calls.send[0].target, { socket_path: '/tmp/cc-socks/271650.sock', token: 'secret-token' });
|
|
269
|
-
assert.equal(calls.send[0].text, claudeContinuationInput(record));
|
|
270
|
-
assert.match(calls.send[0].text, new RegExp(`^Throughline自動継続 ${record.handoff_id}\\n注入された記憶と元のユーザー依頼に従い、未完了の作業をそのまま継続してください。`));
|
|
271
|
-
|
|
272
|
-
assert.deepEqual([record.state, record.error_code], ['sent', null]);
|
|
283
|
+
assert.deepEqual([record.state, record.error_code, record.accepted_at], ['sent', null, 5_000]);
|
|
273
284
|
assert.deepEqual(record.successor, { short_id: 'a41a97ae', session_id: SUCCESSOR });
|
|
274
|
-
assert.
|
|
275
|
-
assert.
|
|
276
|
-
assert.doesNotMatch(readFileSync(join(dir, `${SESSION}.json`), 'utf8'), /secret-token/, 'token は記録に残さない');
|
|
285
|
+
assert.equal(existsSync(join(dir, `${SESSION}.accepted`)), false, '受領の控えは読んだら消す');
|
|
286
|
+
assert.equal(existsSync(join(dir, 'targets')), false, '受け口は控えない');
|
|
277
287
|
|
|
278
288
|
// 引き継ぎ済みの会話の道具は、後継を案内して止める
|
|
279
289
|
const output = await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app' }, dir,
|
|
@@ -282,9 +292,8 @@ test('worker: 指示を待つ後継を同じprojectに立て、後継の受け
|
|
|
282
292
|
}));
|
|
283
293
|
|
|
284
294
|
test('worker: 止めた時点の依頼とモデルを transcript から写し、色付きの出力からも後継のIDを読む。後継が止められた時は元の依頼を運ぶ', () => withDir(async dir => {
|
|
285
|
-
const sendOk = async () => ({ status: 'accepted', outcome_unknown: false, reason: 'receiver_confirmed' });
|
|
286
295
|
const colored = () => ({ status: 0, stderr: '',
|
|
287
|
-
stdout: 'backgrounded · \u001b[36ma41a97ae\u001b[39m · tl-app-1234\u001b[2m
|
|
296
|
+
stdout: 'backgrounded · \u001b[36ma41a97ae\u001b[39m · tl-app-1234\u001b[2m\u001b[22m\n' });
|
|
288
297
|
const run = async (sessionId, transcriptPath, toolUseId) => {
|
|
289
298
|
const db = makeBatonDb();
|
|
290
299
|
requestClaudeAutoHandoff({ payload: { session_id: sessionId, trigger: 'auto', cwd: '/work/app', transcript_path: transcriptPath },
|
|
@@ -292,13 +301,12 @@ test('worker: 止めた時点の依頼とモデルを transcript から写し、
|
|
|
292
301
|
await stopClaudeTurnForHandoff({ payload: { session_id: sessionId, cwd: '/work/app', transcript_path: transcriptPath,
|
|
293
302
|
tool_use_id: toolUseId }, dir, launchWorker: async () => {} });
|
|
294
303
|
const calls = [];
|
|
295
|
-
const record = await runClaudeAutoHandoffWorker(sessionId, { dir, env: {},
|
|
304
|
+
const record = await runClaudeAutoHandoffWorker(sessionId, { dir, env: {}, pollMs: 10, acceptTimeoutMs: 300,
|
|
296
305
|
transcriptTimeoutMs: 300,
|
|
297
306
|
spawn: (command, args) => {
|
|
298
307
|
calls.push(args);
|
|
299
308
|
// 止めた道具の呼び出しは、hook の後で transcript に書かれる
|
|
300
|
-
|
|
301
|
-
env: { CLAUDE_CODE_MESSAGING_SOCKET: '/tmp/cc-socks/1.sock' }, dir });
|
|
309
|
+
successorStarts(dir, sessionId)(args);
|
|
302
310
|
return colored();
|
|
303
311
|
} });
|
|
304
312
|
return { record, calls };
|
|
@@ -337,12 +345,10 @@ test('worker: 止めた時点の依頼とモデルを transcript から写し、
|
|
|
337
345
|
// 後継 B が止められた時、最後の user 発言は A からの継続の指示。現在地には元の依頼を運ぶ
|
|
338
346
|
const transcriptB = join(dir, 'b.jsonl');
|
|
339
347
|
writeFileSync(transcriptB, jsonl([
|
|
340
|
-
user(
|
|
341
|
-
'2026-10-04T03:01:00Z'),
|
|
348
|
+
user(claudeContinuationInput(first.record), '2026-10-04T03:01:00Z'),
|
|
342
349
|
assistant('part09 を読みました。次に part10 を読みます。', '2026-10-04T03:01:20Z', 'claude-opus-5'),
|
|
343
350
|
{ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'tool_use', id: 'toolu_10', name: 'Read', input: {} }] } },
|
|
344
351
|
]));
|
|
345
|
-
rmSync(join(dir, 'targets'), { recursive: true, force: true });
|
|
346
352
|
const second = await run(SUCCESSOR, transcriptB, 'toolu_10');
|
|
347
353
|
assert.deepEqual(second.record.in_flight, {
|
|
348
354
|
user: { content: '14個のファイルを順に読んで', timestamp: Date.parse('2026-10-04T03:00:10Z') },
|
|
@@ -401,13 +407,11 @@ test('worker: 止めたターンを DB へ取り込み、ここまでにした
|
|
|
401
407
|
// hook は並んで走る。記録に残る止めた道具は、応答の中の2つ目のこともある
|
|
402
408
|
await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app', transcript_path: transcript, tool_use_id: 't5' },
|
|
403
409
|
dir, launchWorker: async () => {} });
|
|
404
|
-
const record = await runClaudeAutoHandoffWorker(SESSION, { dir, env: {}, pollMs: 10,
|
|
410
|
+
const record = await runClaudeAutoHandoffWorker(SESSION, { dir, env: {}, pollMs: 10, acceptTimeoutMs: 300, transcriptTimeoutMs: 300,
|
|
405
411
|
openDb: () => db,
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
env: { CLAUDE_CODE_MESSAGING_SOCKET: '/tmp/cc-socks/1.sock' }, dir });
|
|
410
|
-
return { status: 0, stderr: '', stdout: 'backgrounded · a41a97ae · tl-app (idle — send a prompt to start)\n' };
|
|
412
|
+
spawn: (command, args) => {
|
|
413
|
+
successorStarts(dir)(args);
|
|
414
|
+
return { status: 0, stderr: '', stdout: 'backgrounded · a41a97ae · tl-app\n' };
|
|
411
415
|
} });
|
|
412
416
|
assert.equal(record.state, 'sent');
|
|
413
417
|
assert.deepEqual(record.in_flight.steps, [
|
|
@@ -447,13 +451,11 @@ test('worker: 止めたターンを取り込めなくても後継を立て、取
|
|
|
447
451
|
env: {}, config: ENABLED, openDb: () => db, dir, platform: 'linux' });
|
|
448
452
|
await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app', transcript_path: transcript, tool_use_id: 'toolu_1' },
|
|
449
453
|
dir, launchWorker: async () => {} });
|
|
450
|
-
const record = await runClaudeAutoHandoffWorker(SESSION, { dir, env: {}, pollMs: 10,
|
|
454
|
+
const record = await runClaudeAutoHandoffWorker(SESSION, { dir, env: {}, pollMs: 10, acceptTimeoutMs: 300, transcriptTimeoutMs: 300,
|
|
451
455
|
openDb: () => db,
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
env: { CLAUDE_CODE_MESSAGING_SOCKET: '/tmp/cc-socks/1.sock' }, dir });
|
|
456
|
-
return { status: 0, stderr: '', stdout: 'backgrounded · a41a97ae · tl-app (idle — send a prompt to start)\n' };
|
|
456
|
+
spawn: (command, args) => {
|
|
457
|
+
successorStarts(dir)(args);
|
|
458
|
+
return { status: 0, stderr: '', stdout: 'backgrounded · a41a97ae · tl-app\n' };
|
|
457
459
|
} });
|
|
458
460
|
assert.equal(record.state, 'sent');
|
|
459
461
|
assert.equal(record.in_flight.turn, null);
|
|
@@ -461,23 +463,19 @@ test('worker: 止めたターンを取り込めなくても後継を立て、取
|
|
|
461
463
|
assert.equal(publicClaudeAutoHandoff(record).in_flight_captured, false);
|
|
462
464
|
}));
|
|
463
465
|
|
|
464
|
-
test('worker:
|
|
465
|
-
const sendOk = async () => ({ status: 'accepted', outcome_unknown: false, reason: 'receiver_confirmed' });
|
|
466
|
+
test('worker: 立ち上げの失敗は固定の理由を残し、受領を確かめられない時は後継を立て直さない', () => withDir(async dir => {
|
|
466
467
|
const cases = [
|
|
467
|
-
[{ spawn: () => ({ error: Object.assign(new Error('not found'), { code: 'ENOENT' }) })
|
|
468
|
-
[{ spawn: () => ({ status: 1, stdout: '', stderr: 'Workspace not trusted' })
|
|
469
|
-
[{ spawn: () => ({ status: 0, stdout: 'unexpected output', stderr: '' })
|
|
470
|
-
|
|
471
|
-
[{ spawn: launchedOk,
|
|
472
|
-
send: async () => ({ status: 'not_sent', outcome_unknown: false, reason: 'connect_failed' }) }, 'failed', 'handoff_delivery_connect_failed', 1],
|
|
473
|
-
[{ spawn: launchedOk, onLaunched: successorStarts(dir),
|
|
474
|
-
send: async () => ({ status: 'unknown', outcome_unknown: true, reason: 'unconfirmed' }) }, 'unknown', 'handoff_delivery_unconfirmed', 1],
|
|
468
|
+
[{ spawn: () => ({ error: Object.assign(new Error('not found'), { code: 'ENOENT' }) }) }, 'failed', 'handoff_claude_cli_unavailable'],
|
|
469
|
+
[{ spawn: () => ({ status: 1, stdout: '', stderr: 'Workspace not trusted' }) }, 'failed', 'handoff_successor_launch_failed'],
|
|
470
|
+
[{ spawn: () => ({ status: 0, stdout: 'unexpected output', stderr: '' }) }, 'failed', 'handoff_successor_launch_failed'],
|
|
471
|
+
// 後継は立ったが、最初の指示の受領が期限までに残らない
|
|
472
|
+
[{ spawn: launchedOk }, 'unknown', 'handoff_delivery_unconfirmed'],
|
|
475
473
|
];
|
|
476
|
-
for (const [deps, state, code
|
|
474
|
+
for (const [deps, state, code] of cases) {
|
|
477
475
|
rmSync(join(dir, `${SESSION}.json`), { force: true });
|
|
478
476
|
rmSync(join(dir, `${SESSION}.claim`), { force: true });
|
|
479
|
-
const { record
|
|
480
|
-
assert.deepEqual([record.state, record.error_code
|
|
477
|
+
const { record } = await runWorker(dir, deps);
|
|
478
|
+
assert.deepEqual([record.state, record.error_code], [state, code], code);
|
|
481
479
|
}
|
|
482
480
|
// 結果が不明な引き継ぎは、次の道具でも立ち上げ直さない
|
|
483
481
|
const launched = [];
|
|
@@ -486,52 +484,42 @@ test('worker: 立ち上げ・受け口・配送の失敗は固定の理由を残
|
|
|
486
484
|
assert.equal((await runClaudeAutoHandoffWorker(SESSION, { dir, spawn: () => { throw new Error('must not spawn'); } })).state, 'unknown');
|
|
487
485
|
}));
|
|
488
486
|
|
|
489
|
-
test('
|
|
490
|
-
const env = { CLAUDE_CODE_MESSAGING_SOCKET: '/tmp/cc-socks/1.sock', CLAUDE_CODE_MESSAGING_TOKEN: 't' };
|
|
491
|
-
const start = (overrides = {}, environment = env) => recordClaudeSuccessorTarget({
|
|
492
|
-
payload: { session_id: SUCCESSOR, source: 'startup', cwd: '/work/app', ...overrides }, env: environment, dir });
|
|
493
|
-
assert.equal(start(), false, '引き継ぎが無い');
|
|
494
|
-
request(dir);
|
|
495
|
-
assert.equal(start(), false, 'まだ旧い会話を止めていない');
|
|
496
|
-
|
|
497
|
-
const write = fields => writeFileSync(join(dir, `${SESSION}.json`),
|
|
498
|
-
JSON.stringify({ ...readClaudeAutoHandoff(SESSION, dir), ...fields }));
|
|
499
|
-
write({ state: 'launching' });
|
|
500
|
-
assert.equal(start({ cwd: '/work/other' }), false, '別の project');
|
|
501
|
-
assert.equal(start({ source: 'resume' }), false);
|
|
502
|
-
assert.equal(start({ source: 'compact' }), false);
|
|
503
|
-
assert.equal(start({ agent_id: 'agent-1' }), false);
|
|
504
|
-
assert.equal(start({}, {}), false, '受け口を持たない会話');
|
|
505
|
-
assert.equal(start({ session_id: `grok:${SUCCESSOR}` }), false);
|
|
506
|
-
assert.equal(start(), true);
|
|
507
|
-
rmSync(join(dir, 'targets'), { recursive: true });
|
|
508
|
-
|
|
509
|
-
write({ state: 'launched', successor: { short_id: 'a41a97ae', session_id: null } });
|
|
510
|
-
assert.equal(start({ session_id: 'bbbbbbbb-0000-4000-8000-000000000001' }), false, '後継のIDと合わない会話');
|
|
511
|
-
assert.equal(start(), true);
|
|
512
|
-
write({ state: 'sent' });
|
|
513
|
-
rmSync(join(dir, 'targets'), { recursive: true });
|
|
514
|
-
assert.equal(start(), false, '送り終えた引き継ぎ');
|
|
515
|
-
}));
|
|
516
|
-
|
|
517
|
-
test('最初の指示: 後継だけが止めた時点の依頼を受け取り、この引き継ぎの配送だけが受領になる', () => withDir(async dir => {
|
|
487
|
+
test('最初の指示: 後継だけが止めた時点の依頼を受け取り、この引き継ぎの指示だけが受領になる', () => withDir(async dir => {
|
|
518
488
|
request(dir);
|
|
519
489
|
await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app' }, dir, launchWorker: async () => {} });
|
|
520
490
|
const accept = (overrides = {}) => acceptClaudeAutoContinuation({ predecessorId: SESSION, successorSessionId: SUCCESSOR, dir, ...overrides });
|
|
521
|
-
|
|
491
|
+
const acceptance = () => existsSync(join(dir, `${SESSION}.accepted`))
|
|
492
|
+
? JSON.parse(readFileSync(join(dir, `${SESSION}.accepted`), 'utf8')) : null;
|
|
522
493
|
const record = readClaudeAutoHandoff(SESSION, dir);
|
|
494
|
+
const instruction = claudeContinuationInput(record);
|
|
495
|
+
assert.equal(accept({ prompt: instruction }), null, '後継をまだ立てていない(stopped)');
|
|
523
496
|
const inFlight = { user: { content: '依頼', timestamp: 1 }, last_fragment: null };
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
497
|
+
const expected = { handoffId: record.handoff_id, projectPath: '/work/app', inFlight, transcriptPath: null };
|
|
498
|
+
const write = fields => writeFileSync(join(dir, `${SESSION}.json`), JSON.stringify({ ...record, in_flight: inFlight, ...fields }));
|
|
499
|
+
|
|
500
|
+
// 立ち上げ中(後継の ID はまだ記録に無い): 指示が運ぶ引き継ぎ ID で後継と認める
|
|
501
|
+
write({ state: 'launching' });
|
|
502
|
+
assert.equal(accept(), null, '指示を持たない会話');
|
|
503
|
+
assert.equal(accept({ prompt: '別の指示' }), null, '同じ project で人が始めた別の会話');
|
|
504
|
+
assert.equal(accept({ prompt: 'Throughline自動継続 00000000-0000-4000-8000-000000000000\n…' }), null, '別の引き継ぎの指示');
|
|
505
|
+
assert.equal(acceptance(), null);
|
|
506
|
+
assert.deepEqual(accept({ prompt: instruction, now: 9_000 }), expected);
|
|
507
|
+
assert.deepEqual(acceptance(), { handoff_id: record.handoff_id, successor_session_id: SUCCESSOR, accepted_at: 9_000 });
|
|
508
|
+
assert.equal(readClaudeAutoHandoff(SESSION, dir).accepted_at, null, '記録は worker だけが書き換える');
|
|
509
|
+
accept({ prompt: instruction, now: 9_999 });
|
|
510
|
+
assert.equal(acceptance().accepted_at, 9_000, '受領は1回だけ残す');
|
|
511
|
+
rmSync(join(dir, `${SESSION}.accepted`));
|
|
512
|
+
|
|
513
|
+
// 後継の ID が記録に入った後: ID で後継と認める
|
|
514
|
+
write({ state: 'launched', successor: { short_id: 'a41a97ae', session_id: null } });
|
|
515
|
+
assert.equal(accept({ successorSessionId: 'bbbbbbbb-0000-4000-8000-000000000001', prompt: instruction }), null, '後継ではない会話');
|
|
527
516
|
assert.equal(accept({ predecessorId: 'aaaaaaaa-0000-4000-8000-000000000009' }), null);
|
|
528
517
|
assert.equal(accept({ predecessorId: `codex:${SESSION}` }), null);
|
|
529
|
-
|
|
530
518
|
// 人が先に別の指示を打った時は、依頼は渡すが受領にはしない
|
|
531
|
-
assert.deepEqual(accept({ prompt: '別の指示' }),
|
|
532
|
-
assert.equal(
|
|
533
|
-
accept({ prompt:
|
|
534
|
-
assert.equal(
|
|
519
|
+
assert.deepEqual(accept({ prompt: '別の指示' }), expected);
|
|
520
|
+
assert.equal(acceptance(), null);
|
|
521
|
+
accept({ prompt: instruction, now: 9_500 });
|
|
522
|
+
assert.equal(acceptance().accepted_at, 9_500);
|
|
535
523
|
}));
|
|
536
524
|
|
|
537
525
|
test('公開CLI: --host claude は enable・disable・status・worker を受け取る', () => {
|
|
@@ -683,19 +671,14 @@ test('hook: 自動圧縮を止め、次の道具を止めて worker を起動し
|
|
|
683
671
|
assert.equal(handoff.in_flight_captured, true);
|
|
684
672
|
assert.equal(record.in_flight.turn.origin_session_id, SESSION);
|
|
685
673
|
assert.equal(record.in_flight.turn.details, 3);
|
|
674
|
+
// 後継の最初の指示は、worker が後継の ID を記録へ写す前(launching)に届くことがある
|
|
686
675
|
writeFileSync(recordFile, JSON.stringify({ ...record, state: 'launching', error_code: null }));
|
|
687
676
|
const started = hook('session-start', { hook_event_name: 'SessionStart', source: 'startup' }, SUCCESSOR);
|
|
688
677
|
assert.equal(started.status, 0, started.stderr);
|
|
689
678
|
assert.equal(started.stdout, '');
|
|
690
|
-
assert.equal(existsSync(join(handoffDir, 'targets')), false, '
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
assert.equal(withSocket.status, 0, withSocket.stderr);
|
|
694
|
-
assert.deepEqual(readdirSync(join(handoffDir, 'targets')), [`${SUCCESSOR}.json`]);
|
|
695
|
-
writeFileSync(recordFile, JSON.stringify({ ...record, state: 'sending', error_code: null,
|
|
696
|
-
successor: { short_id: SUCCESSOR.slice(0, 8), session_id: SUCCESSOR } }));
|
|
697
|
-
|
|
698
|
-
const prompt = `Another Claude session sent a message:\n${claudeContinuationInput(record)}`;
|
|
679
|
+
assert.equal(existsSync(join(handoffDir, 'targets')), false, '受け口は控えない');
|
|
680
|
+
|
|
681
|
+
const prompt = claudeContinuationInput(record);
|
|
699
682
|
const first = cli(home, ['prompt-submit'], JSON.stringify({ session_id: SUCCESSOR, cwd: project, hook_event_name: 'UserPromptSubmit', prompt }), extra);
|
|
700
683
|
assert.equal(first.status, 0, first.stderr);
|
|
701
684
|
assert.match(first.stdout, /^## Throughline: 自動継続の文脈\n/);
|
|
@@ -707,7 +690,9 @@ test('hook: 自動圧縮を止め、次の道具を止めて worker を起動し
|
|
|
707
690
|
assert.equal(first.stdout.split('big1.txt と big2.txt を読んで').length, 2, '止めたターンの依頼は現在地に1回だけ');
|
|
708
691
|
assert.doesNotMatch(first.stdout, /宣言|\/clear/);
|
|
709
692
|
assert.ok(first.stdout.length <= 9_501);
|
|
710
|
-
|
|
693
|
+
const acceptance = JSON.parse(readFileSync(join(handoffDir, `${SESSION}.accepted`), 'utf8'));
|
|
694
|
+
assert.deepEqual([acceptance.handoff_id, acceptance.successor_session_id], [record.handoff_id, SUCCESSOR], '最初の指示が受領を残す');
|
|
695
|
+
assert.ok(acceptance.accepted_at > 0);
|
|
711
696
|
|
|
712
697
|
// 記憶は後継へ合流している(今の /tl の引き継ぎと同じ)
|
|
713
698
|
const db = new DatabaseSync(join(home, '.throughline', 'throughline.db'));
|
|
@@ -863,6 +863,57 @@ test('process-turn subprocess backfills all completed logical turns from a multi
|
|
|
863
863
|
}
|
|
864
864
|
});
|
|
865
865
|
|
|
866
|
+
test('process-turn subprocess files a relocated Claude Desktop conversation under the project it moved to', () => {
|
|
867
|
+
// Desktop は「フォルダなし」で始めた会話を後から project へ移す。hook の CLAUDE_PROJECT_DIR は移る前のまま届く。
|
|
868
|
+
const home = makeTempHome();
|
|
869
|
+
const scratch = makeTempProject();
|
|
870
|
+
const moved = makeTempProject();
|
|
871
|
+
const transcriptPath = join(moved, 'transcript.jsonl');
|
|
872
|
+
const sessionId = '04bf5ab3-4b29-4b35-9b85-02a48eec51a1';
|
|
873
|
+
const entries = [
|
|
874
|
+
{ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'first request' }] } },
|
|
875
|
+
{ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'first answer' }] } },
|
|
876
|
+
];
|
|
877
|
+
const stop = (lastAssistantMessage) => runNode([join(REPO_ROOT, 'src/turn-processor.mjs')], {
|
|
878
|
+
home,
|
|
879
|
+
cwd: moved,
|
|
880
|
+
env: { CLAUDE_PROJECT_DIR: scratch },
|
|
881
|
+
input: JSON.stringify({ session_id: sessionId, cwd: moved, transcript_path: transcriptPath, last_assistant_message: lastAssistantMessage }),
|
|
882
|
+
});
|
|
883
|
+
try {
|
|
884
|
+
// 移る前: SessionStart が移る前の場所で行を作り、最初の Stop も移る前の場所へ受領を書く。
|
|
885
|
+
const started = runNode([join(REPO_ROOT, 'src/session-start.mjs')], {
|
|
886
|
+
home, cwd: scratch, input: JSON.stringify({ session_id: sessionId, cwd: scratch, source: 'startup', transcript_path: transcriptPath }),
|
|
887
|
+
});
|
|
888
|
+
assert.equal(started.status, 0, started.stderr);
|
|
889
|
+
writeFileSync(transcriptPath, entries.map((entry) => JSON.stringify(entry)).join('\n') + '\n', 'utf8');
|
|
890
|
+
assert.equal(stop('first answer').status, 0);
|
|
891
|
+
let db = openDb(home);
|
|
892
|
+
assert.equal(db.prepare('SELECT project_path FROM sessions WHERE session_id = ?').get(sessionId).project_path, scratch);
|
|
893
|
+
db.close();
|
|
894
|
+
assert.equal(readCompletedTurnReceiptSnapshot({ projectPath: scratch, env: childEnv(home) }).receipts.length, 1);
|
|
895
|
+
|
|
896
|
+
// 移った後の Stop: sessions の project を付け替え、全ターンの受領を移った先へ出す。
|
|
897
|
+
appendFileSync(transcriptPath, [
|
|
898
|
+
{ type: 'relocated', sessionId, relocatedCwd: moved },
|
|
899
|
+
{ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'second request' }] } },
|
|
900
|
+
{ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'second answer' }] } },
|
|
901
|
+
].map((entry) => JSON.stringify(entry)).join('\n') + '\n', 'utf8');
|
|
902
|
+
const second = stop('second answer');
|
|
903
|
+
assert.equal(second.status, 0, second.stderr);
|
|
904
|
+
db = openDb(home);
|
|
905
|
+
assert.equal(db.prepare('SELECT project_path FROM sessions WHERE session_id = ?').get(sessionId).project_path, moved);
|
|
906
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM bodies').get().n, 4);
|
|
907
|
+
db.close();
|
|
908
|
+
const receipts = readCompletedTurnReceiptSnapshot({ projectPath: moved, env: childEnv(home) });
|
|
909
|
+
assert.deepEqual(receipts.receipts.map((entry) => entry.sequence), [1, 2]);
|
|
910
|
+
} finally {
|
|
911
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
912
|
+
rmSync(moved, { recursive: true, force: true });
|
|
913
|
+
rmSync(home, { recursive: true, force: true });
|
|
914
|
+
}
|
|
915
|
+
});
|
|
916
|
+
|
|
866
917
|
test('process-turn subprocess captures the completed turn when the next user entry lands right after Stop', () => {
|
|
867
918
|
const home = makeTempHome();
|
|
868
919
|
const project = makeTempProject();
|
package/src/hosts/claude.mjs
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Claude は Throughline の基準 host。hook payload は snake_case のまま届き、
|
|
5
5
|
* 引き継ぎ注入は UserPromptSubmit hook の stdout でモデルへ渡る。
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { isAbsolute, posix, win32 } from 'node:path';
|
|
8
9
|
import { CLAUDE_HOST, hostOfSessionId } from './identity.mjs';
|
|
9
10
|
|
|
10
11
|
// Claude Code の背景 task 通知は user 発言として transcript に入る。識別子・出力 path・
|
|
@@ -36,6 +37,40 @@ export function compactClaudePeerMessage(text) {
|
|
|
36
37
|
return note < 0 ? text : text.slice(0, note);
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
// Claude Desktop は「フォルダなし」で始めた会話を、後から実在の project へ移せる。移すと transcript に
|
|
41
|
+
// `{"type":"relocated","relocatedCwd":"<移った先>"}` の行が入り、transcript 自体も移った先の project の
|
|
42
|
+
// 置き場へ移る。hook へ渡る CLAUDE_PROJECT_DIR は、移る前の場所のまま残る(2.1.286 で実測)。
|
|
43
|
+
const RELOCATED_MARKER = '"relocated"';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 会話が最後に移った先。移っていない会話、transcript が読めない時は null。
|
|
47
|
+
* @param {string | null | undefined} transcriptPath
|
|
48
|
+
* @returns {string | null}
|
|
49
|
+
*/
|
|
50
|
+
export function readClaudeRelocatedCwd(transcriptPath) {
|
|
51
|
+
if (typeof transcriptPath !== 'string' || !transcriptPath) return null;
|
|
52
|
+
let raw;
|
|
53
|
+
try { raw = readFileSync(transcriptPath, 'utf8'); }
|
|
54
|
+
catch { return null; }
|
|
55
|
+
// 道具の出力が同じ文字列を含むことがある。行を JSON として読み、行そのものの type で確かめる。
|
|
56
|
+
for (let end = raw.length; end > 0;) {
|
|
57
|
+
const marker = raw.lastIndexOf(RELOCATED_MARKER, end - 1);
|
|
58
|
+
if (marker < 0) return null;
|
|
59
|
+
const lineStart = raw.lastIndexOf('\n', marker) + 1;
|
|
60
|
+
const newline = raw.indexOf('\n', marker);
|
|
61
|
+
const line = raw.slice(lineStart, newline < 0 ? raw.length : newline);
|
|
62
|
+
try {
|
|
63
|
+
const entry = JSON.parse(line);
|
|
64
|
+
const moved = entry?.type === 'relocated' ? entry.relocatedCwd : null;
|
|
65
|
+
if (typeof moved === 'string' && (posix.isAbsolute(moved) || win32.isAbsolute(moved))) return moved;
|
|
66
|
+
} catch {
|
|
67
|
+
// 書きかけの行。前の行を探す。
|
|
68
|
+
}
|
|
69
|
+
end = lineStart;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
39
74
|
export const claudeHostAdapter = Object.freeze({
|
|
40
75
|
host: CLAUDE_HOST,
|
|
41
76
|
matchesSessionId: (sessionId) => hostOfSessionId(sessionId) === CLAUDE_HOST,
|
|
@@ -45,7 +80,10 @@ export const claudeHostAdapter = Object.freeze({
|
|
|
45
80
|
// 完了受領はセッションを起動した project に書く。hook payload の cwd は Bash の cd に
|
|
46
81
|
// 追従するため、作業中に下位ディレクトリへ移ると起動 project の feed から漏れる。
|
|
47
82
|
// Claude Code は hook の環境変数 CLAUDE_PROJECT_DIR に起動 project を渡す。
|
|
48
|
-
|
|
83
|
+
// 会話が別の project へ移っている時は、移った先を使う(CLAUDE_PROJECT_DIR は移る前のまま)。
|
|
84
|
+
completionProjectPath({ cwd, env, transcriptPath }) {
|
|
85
|
+
const relocated = readClaudeRelocatedCwd(transcriptPath);
|
|
86
|
+
if (relocated) return relocated;
|
|
49
87
|
const projectDir = env?.CLAUDE_PROJECT_DIR;
|
|
50
88
|
return typeof projectDir === 'string' && isAbsolute(projectDir) ? projectDir : cwd;
|
|
51
89
|
},
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import {
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { claudeHostAdapter, readClaudeRelocatedCwd } from './claude.mjs';
|
|
4
7
|
import { codexHostAdapter } from './codex.mjs';
|
|
5
8
|
import { cursorHostAdapter } from './cursor.mjs';
|
|
6
9
|
import { grokHostAdapter } from './grok.mjs';
|
|
@@ -18,3 +21,57 @@ test('Codex, Grok, and Cursor completion receipts keep the hook cwd', () => {
|
|
|
18
21
|
assert.equal(adapter.completionProjectPath({ cwd: '/work/bot', env: { CLAUDE_PROJECT_DIR: '/elsewhere' } }), '/work/bot');
|
|
19
22
|
}
|
|
20
23
|
});
|
|
24
|
+
|
|
25
|
+
function withTranscript(lines, fn) {
|
|
26
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-claude-host-'));
|
|
27
|
+
const transcriptPath = join(dir, 'transcript.jsonl');
|
|
28
|
+
try {
|
|
29
|
+
writeFileSync(transcriptPath, lines.join('\n'), 'utf8');
|
|
30
|
+
return fn(transcriptPath);
|
|
31
|
+
} finally {
|
|
32
|
+
rmSync(dir, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Claude Desktop 2.1.286 が書く行の形(Mac で実測)。順序は2通りある。
|
|
37
|
+
const relocatedLine = (cwd) => JSON.stringify({ type: 'relocated', sessionId: '04bf5ab3-4b29-4b35-9b85-02a48eec51a1', relocatedCwd: cwd });
|
|
38
|
+
const userLine = (text) => JSON.stringify({ type: 'user', message: { role: 'user', content: text } });
|
|
39
|
+
|
|
40
|
+
test('readClaudeRelocatedCwd: 移っていない会話と読めない transcript は null', () => {
|
|
41
|
+
assert.equal(readClaudeRelocatedCwd(null), null);
|
|
42
|
+
assert.equal(readClaudeRelocatedCwd(join(tmpdir(), 'tl-claude-host-missing', 'none.jsonl')), null);
|
|
43
|
+
withTranscript([userLine('hello'), ''], (path) => assert.equal(readClaudeRelocatedCwd(path), null));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('readClaudeRelocatedCwd: 最後に移った先を返す', () => {
|
|
47
|
+
withTranscript([userLine('a'), relocatedLine('/Users/kite/first'), userLine('b'), relocatedLine('/Users/kite/tl-claude-probe'),
|
|
48
|
+
JSON.stringify({ type: 'agent-name', agentName: 'x' }), ''], (path) => {
|
|
49
|
+
assert.equal(readClaudeRelocatedCwd(path), '/Users/kite/tl-claude-probe');
|
|
50
|
+
});
|
|
51
|
+
withTranscript([relocatedLine('C:\\Users\\kite_\\proj')], (path) => {
|
|
52
|
+
assert.equal(readClaudeRelocatedCwd(path), 'C:\\Users\\kite_\\proj');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('readClaudeRelocatedCwd: 道具の出力に同じ文字列があっても、行そのものの type だけを見る', () => {
|
|
57
|
+
const quoted = JSON.stringify({ type: 'user', message: { role: 'user', content: [
|
|
58
|
+
{ type: 'tool_result', tool_use_id: 't1', content: relocatedLine('/quoted/in/output') }] } });
|
|
59
|
+
withTranscript([relocatedLine('/Users/kite/real'), quoted, '{"type":"relocated","relocatedCwd":"/partial'], (path) => {
|
|
60
|
+
assert.equal(readClaudeRelocatedCwd(path), '/Users/kite/real');
|
|
61
|
+
});
|
|
62
|
+
withTranscript([quoted, relocatedLine('relative/path'), JSON.stringify({ type: 'relocated' })], (path) => {
|
|
63
|
+
assert.equal(readClaudeRelocatedCwd(path), null);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('Claude completion receipts follow the project the conversation moved to (Claude Desktop)', () => {
|
|
68
|
+
const scratch = '/Users/kite/Library/Application Support/Claude/scratch-workspaces/a/b/scratch-2026-10-05-ac3fa8';
|
|
69
|
+
withTranscript([userLine('a'), relocatedLine('/Users/kite/tl-claude-probe'), ''], (transcriptPath) => {
|
|
70
|
+
assert.equal(claudeHostAdapter.completionProjectPath({
|
|
71
|
+
cwd: '/Users/kite/tl-claude-probe/sub', env: { CLAUDE_PROJECT_DIR: scratch }, transcriptPath,
|
|
72
|
+
}), '/Users/kite/tl-claude-probe');
|
|
73
|
+
});
|
|
74
|
+
withTranscript([userLine('a'), ''], (transcriptPath) => {
|
|
75
|
+
assert.equal(claudeHostAdapter.completionProjectPath({ cwd: '/work/bot/sub', env: { CLAUDE_PROJECT_DIR: '/work/bot' }, transcriptPath }), '/work/bot');
|
|
76
|
+
});
|
|
77
|
+
});
|
package/src/session-start.mjs
CHANGED
|
@@ -24,9 +24,8 @@
|
|
|
24
24
|
*
|
|
25
25
|
* 【自動継続の後継】 ADR 0033
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* UserPromptSubmit(上の二相ハンドオフの第二相)が行う。
|
|
27
|
+
* 後継は、継続の指示を最初の指示として付けて起動される。SessionStart では何もしない。
|
|
28
|
+
* 記憶の注入と受領は、その指示の UserPromptSubmit(上の二相ハンドオフの第二相)が行う。
|
|
30
29
|
*/
|
|
31
30
|
|
|
32
31
|
import { getDb } from './db.mjs';
|
|
@@ -41,7 +40,6 @@ import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
|
41
40
|
import { logHookFailure } from './hook-failure-log.mjs';
|
|
42
41
|
import { CLAUDE_HOST, hostAdapterForSessionId, hostOfSessionId, NON_CLAUDE_SESSION_PREFIXES, parseHookPayload } from './hosts/index.mjs';
|
|
43
42
|
import { executeFirstPromptHandoff } from './handoff-executor.mjs';
|
|
44
|
-
import { recordClaudeSuccessorTarget } from './claude-auto-handoff.mjs';
|
|
45
43
|
|
|
46
44
|
const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
|
|
47
45
|
|
|
@@ -170,11 +168,6 @@ export async function run() {
|
|
|
170
168
|
pending_registered: true,
|
|
171
169
|
});
|
|
172
170
|
|
|
173
|
-
// 自動継続の後継として立ち上がった会話なら、worker が指示を送れるように受け口を控える (ADR 0033)。
|
|
174
|
-
if (hostOfSessionId(session_id) === CLAUDE_HOST) {
|
|
175
|
-
recordClaudeSuccessorTarget({ payload, env: process.env, now });
|
|
176
|
-
}
|
|
177
|
-
|
|
178
171
|
// Cursor は beforeSubmitPrompt が additional_context を持たない。
|
|
179
172
|
// 新規 composer 会話の sessionStart が公式の注入口なので、ここで第二相を実行する。
|
|
180
173
|
// Claude の幽霊 SessionStart 問題はこの host では sessionStart = 会話作成のため適用しない。
|
package/src/turn-processor.mjs
CHANGED
|
@@ -49,6 +49,8 @@ import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
|
49
49
|
import { logHookFailure } from './hook-failure-log.mjs';
|
|
50
50
|
import { writeCompletedTurnReceipt } from './completed-turn-receipts.mjs';
|
|
51
51
|
import { hostAdapterForSessionId, parseHookPayload } from './hosts/index.mjs';
|
|
52
|
+
import { readClaudeRelocatedCwd } from './hosts/claude.mjs';
|
|
53
|
+
import { CLAUDE_HOST } from './hosts/identity.mjs';
|
|
52
54
|
import { completeClaudeTurnWithoutHandoff } from './claude-auto-handoff.mjs';
|
|
53
55
|
|
|
54
56
|
/** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
|
|
@@ -316,6 +318,20 @@ async function processStop() {
|
|
|
316
318
|
db.prepare('UPDATE sessions SET updated_at = ? WHERE session_id = ?').run(now, target);
|
|
317
319
|
}
|
|
318
320
|
|
|
321
|
+
// 完了受領と sessions の project は、会話を起動した project に合わせる。会話が別の project へ
|
|
322
|
+
// 移っている時(Claude Desktop)は、移った先へ付け替える。付け替えないと、移った先の project の
|
|
323
|
+
// 過去の会話にも Observer feed にも、この会話が出ない。
|
|
324
|
+
const hostAdapter = hostAdapterForSessionId(session_id);
|
|
325
|
+
const relocatedProjectPath = hostAdapter.host === CLAUDE_HOST ? readClaudeRelocatedCwd(transcript_path) : null;
|
|
326
|
+
const completionProjectPath = relocatedProjectPath ?? hostAdapter.completionProjectPath({
|
|
327
|
+
cwd: cwd ?? process.cwd(),
|
|
328
|
+
env: process.env,
|
|
329
|
+
});
|
|
330
|
+
if (relocatedProjectPath) {
|
|
331
|
+
db.prepare('UPDATE sessions SET project_path = ? WHERE session_id IN (?, ?) AND project_path <> ?')
|
|
332
|
+
.run(relocatedProjectPath, session_id, target, relocatedProjectPath);
|
|
333
|
+
}
|
|
334
|
+
|
|
319
335
|
// L2 = transcript 全体を論理ターン群で走査し、未捕捉の完了ターンを一括回収する。
|
|
320
336
|
// 従来の「最後の 1 ペアのみ保存」は Stop の空振り・不発が永久穴になった (docs/12 B-1)。
|
|
321
337
|
// user / assistant は「1 往復 = 1 ターン」として同じ turn_number(= 代表 assistant
|
|
@@ -349,10 +365,6 @@ async function processStop() {
|
|
|
349
365
|
// 過去のStopでDBだけ回収済みだったpairもreceipt storeの冪等性で穴埋めする。
|
|
350
366
|
// receipt failure は Stop hook の failure として上位へ伝播させる。L1/L3/usage は
|
|
351
367
|
// receipt 後の派生処理なので、そこで失敗しても completed pair を取り消さない。
|
|
352
|
-
const completionProjectPath = hostAdapterForSessionId(session_id).completionProjectPath({
|
|
353
|
-
cwd: cwd ?? process.cwd(),
|
|
354
|
-
env: process.env,
|
|
355
|
-
});
|
|
356
368
|
for (const completedTurnNumber of backfill.turnNumbers) {
|
|
357
369
|
publishCapturedClaudeCompletionReceipt(db, {
|
|
358
370
|
target,
|