throughline 0.16.26 → 0.16.28
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 +69 -1
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/docs/adr/0033-claude-auto-handoff-new-session.md +8 -0
- package/docs/adr/0057-claude-aiterm-seat-conversation-is-not-handed-off.md +88 -0
- package/docs/adr/0059-codex-handoff-carries-thread-goal.md +96 -0
- package/package.json +1 -1
- package/src/claude-auto-handoff.mjs +40 -2
- package/src/claude-auto-handoff.test.mjs +118 -0
- package/src/claude-pre-compact.mjs +2 -1
- package/src/cli/auto-handoff.mjs +9 -5
- package/src/cli/grok-continue.test.mjs +9 -4
- package/src/cli/runtime-errors.test.mjs +2 -3
- package/src/codex-auto-handoff.mjs +137 -5
- package/src/codex-auto-handoff.test.mjs +255 -3
- package/src/haiku-summarizer.test.mjs +8 -1
- package/src/hosts/codex-handoff-state.mjs +20 -0
- package/src/room-context.test.mjs +2 -1
- package/src/runtime-error-hook.test.mjs +3 -3
- package/src/runtime-error-report.test.mjs +3 -3
- package/src/runtime-error-store.test.mjs +3 -3
- package/src/spike-transcript-writer.test.mjs +3 -3
- package/src/test-temp-dir.mjs +24 -0
- package/src/transcript-reader.test.mjs +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,72 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.16.28] — 2026-10-14
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- **Codex Desktopで、ゴール(`/goal`)を持つタスクを引き継いだ後、旧タスクが止まらず、「タスク終了」の通知と後継への画面の切り替えが続いていた**([ADR 0059](docs/adr/0059-codex-handoff-carries-thread-goal.md))。Windowsの実機で2026-10-13に起きた。
|
|
18
|
+
- Codexは、タスクが手すきになるたびにゴールを読み、実行中なら自分で次のターンを起こす。旧タスクにゴールが実行中のまま残っていたので、止めるたびに次のターンが始まった。22:04から1〜2秒おきに2,067回。入力は全部Codex自身の続行の指示で、人の入力は無かった。Throughlineはそのたびにターンを止めて、後継を画面に開いていた。
|
|
19
|
+
- 後継にはゴールが運ばれていなかった。ゴールで動かしていた作業は、引き継ぎを境に、自分では続かなくなっていた。
|
|
20
|
+
- 直した後は、引き継ぎの最初に旧タスクのゴールを一時停止にし、後継が作業を続けたのを確かめた後で、ゴールを後継へ運ぶ。目標の文はそのまま、予算は使った分を引いた残りを渡す。動いていたゴールは後継でも動き、利用者が止めていたゴールは止めたまま移る。旧タスクのゴールは、一時停止のまま残す。
|
|
21
|
+
- 完了したゴールと、Codexが上限や行き詰まりで止めたゴールは運ばない。後継にもうゴールがあれば書き換えない。
|
|
22
|
+
- ゴールを読めない・書けない時も、引き継ぎは止めない。
|
|
23
|
+
- 引き継ぎが途中で止まった時は、旧タスクのゴールを実行中へ戻さない(戻すと、続行のターンと引き継ぎの失敗が繰り返される)。失敗の画面に、ゴールを止めてある事を書く。
|
|
24
|
+
- **引き継ぎ済みの旧タスクへ、人の入力ではないターンが来た時にも、後継を画面に開いていた**(同じADR)。ターンの入力がCodex自身の文脈だけの時は、止めて後継を示すが、画面は切り替えない。
|
|
25
|
+
- **0.16.27までの版が引き継いだ旧タスクに、ゴールが実行中のまま残っている時の片付け**(同じADR)。引き継ぎ済みの旧タスクへターンが来た時に、別のprocessで旧タスクのゴールを確かめる。実行中なら一時停止にし、今の後継にゴールが無ければ運ぶ。同じ旧タスクへは30秒に1回まで。片付けた事は`~/.throughline/codex-auto-handoff/goal-settles.jsonl`へ1行残す(時刻・ID・付けた状態。目標の文は書かない)。
|
|
26
|
+
|
|
27
|
+
### 変わる動き
|
|
28
|
+
|
|
29
|
+
- ゴールを持つタスクの引き継ぎで、旧タスクのゴールが「一時停止」になり、後継に同じゴールが付く。後継のゴールの使用量は0から数え直す。
|
|
30
|
+
- 後から片付けたゴール(上の3つめ)は、後継が手すきの時は、その場では動き出さない。後継の次のターンが終わった時からCodexが続ける。
|
|
31
|
+
- 旧タスクの画面のゴールの表示は、Codex Desktopがそのタスクを読み直すまで、古いまま見える事がある。
|
|
32
|
+
|
|
33
|
+
### 対応の範囲
|
|
34
|
+
|
|
35
|
+
| | macOS | Linux | Windows |
|
|
36
|
+
|---|---|---|---|
|
|
37
|
+
| Codex | 直した。単体試験と、実機で、直した版の関数(止める・運ぶ・片付ける)をCodex Desktop同梱のCodex(0.162.0-alpha.17.2)へ通した(25項目)。止めなければCodexが次のターンを起こし続ける事、止めた後は起きない事、後継がゴールを続けて完了する事まで | 直した。同じ確認を、codex-cli 0.162.1(コンテナ)で。本物のCodex Desktop(同梱の0.162.0-alpha.2)で、ゴールを持つタスクを自動圧縮の手前まで進める通しも流した(入っている0.16.27へ、直した3ファイルを一時だけ重ねた)。8回続けて引き継ぎ、毎回、旧タスクのゴールは一時停止・後継のゴールは実行中になり、最後の後継が作業を終えてゴールを完了した。引き継いだ後の旧タスクへ来たターンは0回(引き継ぎの最中に、1秒以内に4〜5回) | 直した。起きた端末。旧タスクのゴールを一時停止にして、連続が止まるのを見た。同じ確認を、Codex Desktop同梱のCodex(0.162.0-alpha.17.2)で(25項目)。起きた旧タスクの本物の記録へ、直した版のhookの入口を読むだけで当てた(止める・後継を開かない・片付けを起こす、の答え) |
|
|
38
|
+
| Claude Code | 変更なし(タスクごとのゴールが無い) | 同じ | 同じ |
|
|
39
|
+
| Grok | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
40
|
+
| Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
41
|
+
|
|
42
|
+
### 確かめていない事
|
|
43
|
+
|
|
44
|
+
- macOSとWindowsの本物のCodex Desktopの画面で、ゴールを持つタスクが自動圧縮の手前まで進んで引き継がれる通し。通したのはLinuxのCodex Desktopだけ。macOSとWindowsは、Desktop同梱のCodexへ関数を通す所までで、画面のタスクは使っていない。
|
|
45
|
+
- 0.16.27までの版が引き継いだ旧タスクの片付け(別のprocessを起こす所)を、本物のhookから通す事。関数と、起こす引数と、起きた旧タスクの記録に対するhookの答えは確かめた。Windowsの起きた旧タスクは、手で一時停止にしてあるので、もうこの道を通らない。
|
|
46
|
+
- Codex Desktopは、完了したゴールを自分で消す(Linuxの実機で、完了の直後に画面の側が`thread/goal/clear`を送った)。完了の前に画面の側がゴールを書き換える場面があるかは見ていない。
|
|
47
|
+
- ゴールの続行の指示(Codex自身の文脈)は、記憶では今までどおり利用者の発言として残る。この版では変えていない。
|
|
48
|
+
|
|
49
|
+
## [0.16.27] — 2026-10-13
|
|
50
|
+
|
|
51
|
+
### 修正
|
|
52
|
+
|
|
53
|
+
- **Aitermが起こしたClaude Codeの席の会話が、自動継続で裏の後継へ引き継がれ、呼び手の文が後継へ届かなくなっていた**([ADR 0057](docs/adr/0057-claude-aiterm-seat-conversation-is-not-handed-off.md))。Windowsの実機で2026-10-13に起きた(10-11にも1回)。
|
|
54
|
+
- Aitermの席は、起こした時の会話に結び付いている。後継は別の会話なので、呼び手が席へ送った文は旧い会話へ入り、後継の答えも呼び手へ戻らなかった。
|
|
55
|
+
- 直した後は、席の会話では自動圧縮を止めず、後継も立てない。席は同じ画面のまま続く。理由は判定のログに`aiterm_seat`で残る。
|
|
56
|
+
- 席と数えるのは、hookの環境に`AITERM_AGENT_LAUNCH_ID`があり、`AITERM_AGENT_VENDOR_SESSION_ID`がその会話のIDと同じ時だけ。起動の印だけでは外さない。裏の会話(`claude --bg`)はClaude Codeの常駐のprocessの環境を継ぐので、席の中から常駐が起きると、関係の無い裏の会話も席の印を持つ(Linux、Claude Code 2.1.296で実測)。
|
|
57
|
+
- **効くのは、`AITERM_AGENT_VENDOR_SESSION_ID`を出す版のAiterm(0.59.0の次の版)が起こした席から。** それまでの席は、今までどおり裏の後継へ引き継がれる。
|
|
58
|
+
- 席の会話に、後継を立てる前の引き継ぎの記録が残っている時は、記録を取り下げて圧縮と道具を通す。もう後継へ引き継いだ席の会話は、今までどおり後継を案内する。
|
|
59
|
+
|
|
60
|
+
### 変わる動き
|
|
61
|
+
|
|
62
|
+
- 効いた後の席では、文脈が一杯になるとClaude Codeの自動圧縮が走る。席の同じ画面で新しい会話へ替える形(圧縮なし)は、Aitermと合わせて次に作る。
|
|
63
|
+
- Aitermの席でない会話(端末・Claude Desktopで直接使う会話、その後継)の引き継ぎは変わらない。
|
|
64
|
+
|
|
65
|
+
### 対応の範囲
|
|
66
|
+
|
|
67
|
+
| | macOS | Linux | Windows |
|
|
68
|
+
|---|---|---|---|
|
|
69
|
+
| Claude Code(Aitermの席) | 直した。単体試験と、実機で候補の版の本物のhookの入口(`pre-compact`・`pre-tool-use`)へ、席・席の印を継いだだけの会話・印の無い会話の環境を通した。本物の席での通しは、会話IDを出す版のAitermが出てから | 直した。macOSと同じ確認(2台)。裏の会話が常駐の環境を継ぐ事は、ここで見た | 直した。起きた端末。macOSと同じ確認。本物の席での通しは、会話IDを出す版のAitermが出てから |
|
|
70
|
+
| Codex | 変更なし(自動継続はCodex Desktopだけ。Aitermの席のCLIは対象外) | 同じ | 同じ |
|
|
71
|
+
| Grok | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
72
|
+
| Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
73
|
+
|
|
74
|
+
### 確かめていない事
|
|
75
|
+
|
|
76
|
+
- 会話IDを出す版のAitermが起こした本物の席で、本物の自動圧縮が起きた時の動き。その版はまだ出ていない。
|
|
77
|
+
- macOSとWindowsで、裏の会話が常駐の環境を継ぐか。
|
|
78
|
+
|
|
13
79
|
## [0.16.26] — 2026-10-12
|
|
14
80
|
|
|
15
81
|
### 修正
|
|
@@ -2492,7 +2558,9 @@ two attempts, instrument first instead of patching again.
|
|
|
2492
2558
|
|
|
2493
2559
|
---
|
|
2494
2560
|
|
|
2495
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.
|
|
2561
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.28...HEAD
|
|
2562
|
+
[0.16.28]: https://github.com/kitepon/Throughline/compare/v0.16.27...v0.16.28
|
|
2563
|
+
[0.16.27]: https://github.com/kitepon/Throughline/compare/v0.16.26...v0.16.27
|
|
2496
2564
|
[0.16.26]: https://github.com/kitepon/Throughline/compare/v0.16.25...v0.16.26
|
|
2497
2565
|
[0.16.25]: https://github.com/kitepon/Throughline/compare/v0.16.24...v0.16.25
|
|
2498
2566
|
[0.16.24]: https://github.com/kitepon/Throughline/compare/v0.16.23...v0.16.24
|
package/README.ja.md
CHANGED
|
@@ -29,7 +29,7 @@ throughline auto-handoff disable
|
|
|
29
29
|
|
|
30
30
|
既定では無効です。`enable`は必要な`PreCompact(auto)`フックを登録し、そのフックの承認と有効状態を公式APIで確認します。`--project`を省略すると全projectを対象にします。macOS・Windows・LinuxのCodex Desktopで実機確認済みです(Windowsは0.16.6から、Linuxは0.16.7から)。Codex Desktopに同梱のCodexが0.162以上の時は、0.16.7以上が要ります。VS Code・CLIの自動継続は未検証です。
|
|
31
31
|
|
|
32
|
-
自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。後継が作業を続けたのを確かめた後、旧タスクの名前の頭へ`【引き継ぎ済み】`を付けます(0.16.10から。それより前の版が引き継いだ分は`throughline auto-handoff mark-sources`で付け直せます。名前の無い後継にも名前が付きます。`--dry-run`で、付ける名前だけを先に見られます)。一覧で、印の無い1本が今の続きです。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact
|
|
32
|
+
自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。後継が作業を続けたのを確かめた後、旧タスクの名前の頭へ`【引き継ぎ済み】`を付けます(0.16.10から。それより前の版が引き継いだ分は`throughline auto-handoff mark-sources`で付け直せます。名前の無い後継にも名前が付きます。`--dry-run`で、付ける名前だけを先に見られます)。一覧で、印の無い1本が今の続きです。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact`は発火対象に含めません。旧タスクにCodexのゴール(`/goal`)があれば、引き継ぎの最初に一時停止にし、後継が作業を続けた後で後継へ運びます(0.16.28から。動いていたゴールは後継でも動き、止めていたゴールは止めたまま移ります)。
|
|
33
33
|
|
|
34
34
|
記憶は引き継ぎ全体の直近20ターンをL2全文、それより古いターンをL1で渡します。元のユーザー依頼と中断地点を保持し、tool結果とThinkingは注入しません。本文内の`auto-handoff detail`コマンドで、凍結したL2/L3を引き継ぎID・origin・turnから取得できます。A→B→Cの連続引き継ぎでも祖先の詳細を取得できます。
|
|
35
35
|
|
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ throughline auto-handoff disable
|
|
|
28
28
|
|
|
29
29
|
既定では無効です。`enable`は必要な`PreCompact(auto)`フックを登録し、そのフックの承認と有効状態を公式APIで確認します。`--project`を省略すると全projectを対象にします。macOS・Windows・LinuxのCodex Desktopで実機確認済みです(Windowsは0.16.6から、Linuxは0.16.7から)。Codex Desktopに同梱のCodexが0.162以上の時は、0.16.7以上が要ります。VS Code・CLIの自動継続は未検証です。
|
|
30
30
|
|
|
31
|
-
自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。後継が作業を続けたのを確かめた後、旧タスクの名前の頭へ`【引き継ぎ済み】`を付けます(0.16.10から。それより前の版が引き継いだ分は`throughline auto-handoff mark-sources`で付け直せます。名前の無い後継にも名前が付きます。`--dry-run`で、付ける名前だけを先に見られます)。一覧で、印の無い1本が今の続きです。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact
|
|
31
|
+
自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。後継が作業を続けたのを確かめた後、旧タスクの名前の頭へ`【引き継ぎ済み】`を付けます(0.16.10から。それより前の版が引き継いだ分は`throughline auto-handoff mark-sources`で付け直せます。名前の無い後継にも名前が付きます。`--dry-run`で、付ける名前だけを先に見られます)。一覧で、印の無い1本が今の続きです。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact`は発火対象に含めません。旧タスクにCodexのゴール(`/goal`)があれば、引き継ぎの最初に一時停止にし、後継が作業を続けた後で後継へ運びます(0.16.28から。動いていたゴールは後継でも動き、止めていたゴールは止めたまま移ります)。
|
|
32
32
|
|
|
33
33
|
記憶は引き継ぎ全体の直近20ターンをL2全文、それより古いターンをL1で渡します。元のユーザー依頼と中断地点を保持し、tool結果とThinkingは注入しません。本文内の`auto-handoff detail`コマンドで、凍結したL2/L3を引き継ぎID・origin・turnから取得できます。A→B→Cの連続引き継ぎでも祖先の詳細を取得できます。
|
|
34
34
|
|
|
@@ -106,6 +106,14 @@ Claude Desktop の新しい会話は、画面を開いただけでは process
|
|
|
106
106
|
落として保存する。
|
|
107
107
|
- 依存の `aiterm-steer-delivery` を 0.1.9 から 0.1.13 に上げる。Codex の配送の入口は変わらない。
|
|
108
108
|
|
|
109
|
+
## 追記(2026-10-13、ADR 0057)
|
|
110
|
+
|
|
111
|
+
Decision 1 の「止めない会話」に、Aiterm が起こした席の会話を足した。席は起こした時の会話に結び付いていて、裏の後継へ
|
|
112
|
+
引き継ぐと、呼び手の文が後継へ届かない。席と数える条件と、常駐の process が環境を継がせる事は ADR 0057 に書いた。
|
|
113
|
+
|
|
114
|
+
hook で止めたターンで Stop hook が走らない事は、Claude Code 2.1.296(Windows)の実機の記録でも同じだった
|
|
115
|
+
(10:48:08 に止めた会話の次の Stop は、後から文が入った 11:02:52)。
|
|
116
|
+
|
|
109
117
|
## 確認した範囲
|
|
110
118
|
|
|
111
119
|
- Linux の Claude Code 2.1.289。対話画面の会話から始めて、Haiku 4.5 で3回、Opus 5.5 で6回、連続で引き継ぎ、
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# ADR 0057: Aiterm が起こした席の Claude の会話は、裏の後継へ引き継がない
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-10-13
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Claude Code の自動継続(ADR 0033)は、自動圧縮を止め、次の道具でターンを止めて、`claude --bg` の後継へ引き継ぐ。
|
|
9
|
+
引き継ぎを外すのは、手動の `/compact`、無効な project、subagent の中の圧縮、script から起動した会話
|
|
10
|
+
(`CLAUDE_CODE_ENTRYPOINT` が `sdk-` で始まる)だけだった。
|
|
11
|
+
|
|
12
|
+
Aiterm が起こした Claude の席(Bot がほかの端末に起こす子、通話の MCP が立てる席)は、入口が `cli` の対話の会話なので、
|
|
13
|
+
人の端末の会話と同じに引き継がれていた。席での振る舞いは、決めていなかった。
|
|
14
|
+
|
|
15
|
+
2026-10-13 10:48(JST)、Windows の実機で、Aiterm の remote で起こした席の会話が後継へ引き継がれた。Throughline の記録では
|
|
16
|
+
引き継ぎは成立している(`sent`)。席の側では次の事が起きた(Aiterm の担当の調べ)。
|
|
17
|
+
|
|
18
|
+
- Aiterm の席は、起こした時の会話 ID に結び付いている。別の会話の番の終わりは、席の物と数えない。
|
|
19
|
+
- 後継は Claude Code の常駐の process の下で動き、席の端末とは別の木にある。
|
|
20
|
+
- 呼び手が席へ送った文は旧い会話へ入り、後継へ届かない。後継の答えも呼び手へ戻らない。2通が落ち、呼び手が
|
|
21
|
+
`claude attach` で手でつないだ。
|
|
22
|
+
|
|
23
|
+
同じ形は 2026-10-11 にも1回起きている(通話の MCP が立てた席)。BellTeam のコンテナは、席の配送が終わらなくなる事が
|
|
24
|
+
分かっていたので、自動継続を無効にしてある。Windows・macOS・Linux の3台は全 project で有効にしていて、そこへ Aiterm が
|
|
25
|
+
起こした席が入っていた。
|
|
26
|
+
|
|
27
|
+
オーナーの決め(2026-10-13): 席では引き継がない形を先に入れ、続けて「席の同じ画面で新しい会話へ替える」形を Aiterm と
|
|
28
|
+
作る。後の形が出来れば、席でも圧縮は走らない。この ADR は先に入れる分を決める。
|
|
29
|
+
|
|
30
|
+
### 席を何で見分けるか
|
|
31
|
+
|
|
32
|
+
Aiterm は、席の process の環境へ起動の印(`AITERM_AGENT_LAUNCH_ID`、`AITERM_AGENT_KIND` など)を入れる。人が Aiterm の
|
|
33
|
+
端末の中で手で起こした claude には付かない(その端末にあるのは `AITERM_SESSION_ID` と `AITERM_STATE_BASE` だけ)。
|
|
34
|
+
|
|
35
|
+
印だけで席と決める形は、採れなかった。Linux の実機(Claude Code 2.1.296)で、裏の会話を2本、違う環境で続けて立てた。
|
|
36
|
+
1本目は `AITERM_AGENT_LAUNCH_ID` を付け、2本目は `AITERM_AGENT_*` を全部外した環境から立てた。2本目の会話の hook
|
|
37
|
+
(`SessionStart`・`UserPromptSubmit`・`Stop`)が受けた環境は、1本目と同じ印を持っていた。裏の会話は、起こした側ではなく、
|
|
38
|
+
Claude Code の常駐の process の環境を継ぐ。常駐は、最初に起こした process の環境を持ち続ける。
|
|
39
|
+
|
|
40
|
+
つまり、席の中(席の Bash、席の hook が起こす Throughline の worker)から常駐が起きると、その後に立つ裏の会話は、席と
|
|
41
|
+
関係が無くても席の印を持つ。印だけで外すと、利用者の会話の後継(裏の会話)まで引き継ぎから外れ、自動圧縮が走る。
|
|
42
|
+
Throughline は自動圧縮を置き換える物で、利用者の会話でそれが起きるのは、席の文が落ちる事より重い。
|
|
43
|
+
|
|
44
|
+
Aiterm は、席の Claude を `--session-id <ID>` で起こし、その ID だけを席の物と数える。Aiterm の担当と合わせて、この ID を
|
|
45
|
+
席の環境へ `AITERM_AGENT_VENDOR_SESSION_ID` として出してもらう(Aiterm 0.59.0 の次の版から。席の途中で変わらない)。
|
|
46
|
+
|
|
47
|
+
## Decision
|
|
48
|
+
|
|
49
|
+
1. **席の会話**は、hook の環境に `AITERM_AGENT_LAUNCH_ID` があり、かつ `AITERM_AGENT_VENDOR_SESSION_ID` が hook の
|
|
50
|
+
`session_id` と同じ会話とする(`isAitermSeatConversation`)。起動の印だけでは席と数えない。
|
|
51
|
+
2. `PreCompact(auto)` は、席の会話では圧縮を止めず、引き継ぎの記録も残さない。理由は `aiterm_seat`。判定の順は、
|
|
52
|
+
手動の `/compact` → 進んでいる引き継ぎ → 無効な project → script から起動した会話 → 席。自動継続が無効な端末の
|
|
53
|
+
席は、今までどおり `auto_handoff_disabled` で終わる。
|
|
54
|
+
3. 席の会話に、後継を立てる前の記録(`requested`・`failed`)が残っている時は、`PreCompact` と `PreToolUse` が記録と
|
|
55
|
+
立ち上げの札を取り下げ、圧縮と道具を通す。席を外す前の版が予約した会話と、会話 ID を出さない版の Aiterm の時に
|
|
56
|
+
予約した会話が、これに当たる。
|
|
57
|
+
4. もう後継へ引き継いだ席の会話(`stopped` から先)は、今までどおり圧縮と道具を止め、後継を案内する。続きは後継にある。
|
|
58
|
+
5. 席の印を継いだだけの会話(席が起こした子、席の中から起きた常駐が立てた裏の会話)は、会話 ID が違うので、
|
|
59
|
+
今までどおり引き継ぐ。
|
|
60
|
+
6. 会話 ID を出さない版の Aiterm が起こした席は、見分けられないので、今までどおり引き継ぐ。
|
|
61
|
+
7. `AITERM_AGENT_LAUNCH_ID` と `AITERM_AGENT_VENDOR_SESSION_ID` の名前と意味は、Aiterm の担当と合わせた物とする。
|
|
62
|
+
変える時は、先に知らせ合う。
|
|
63
|
+
|
|
64
|
+
## Consequences
|
|
65
|
+
|
|
66
|
+
- この版が効くのは、`AITERM_AGENT_VENDOR_SESSION_ID` を出す版の Aiterm が起こした席から。それまでの席は、今までどおり
|
|
67
|
+
裏の後継へ引き継がれ、呼び手の文は後継へ届かない。
|
|
68
|
+
- 効いた後の席は、同じ画面のまま続き、呼び手との行き来は切れない。文脈が一杯になると、Claude Code の自動圧縮が走る
|
|
69
|
+
(BellTeam のコンテナの席と同じ)。圧縮より前の細かい文脈は、Claude の要約に置き換わる。会話の記録は、今までどおり
|
|
70
|
+
Stop で Throughline に入る。
|
|
71
|
+
- 「host の自動圧縮を通さない」(ADR 0033)は、席の会話については、同じ画面で新しい会話へ替える形が出来るまで外す。
|
|
72
|
+
- 席の会話を起こし直す・`/clear` するなどで会話 ID が変わった時は、環境の ID と合わなくなり、今までどおり引き継ぐ。
|
|
73
|
+
- 席の環境へ足す印は、同じ道でほかの裏の会話へ継がれる。この先、席だけの動きを足す時も、会話 ID の一致で席と決めてから
|
|
74
|
+
印を読む。
|
|
75
|
+
- 後継を立てる worker は、席の `AITERM_AGENT_*` を外さずに `claude --bg` を呼ぶ(外すのは会話ごとの `CLAUDE_CODE_*` など)。
|
|
76
|
+
常駐が動いている時は、後継の環境は常駐の物になるので、外しても変わらない。ここは変えていない。
|
|
77
|
+
|
|
78
|
+
## 確認した範囲
|
|
79
|
+
|
|
80
|
+
- 単体試験と、本物の CLI の入口(`pre-compact`・`pre-tool-use`)を仮の HOME で動かす試験。
|
|
81
|
+
- 公開前に、候補の tarball を macOS(Node 26.11.1)・Linux(Node 24.20.0 と 26.11.1)・Windows 11(Node 24.20.0)へ置き、
|
|
82
|
+
仮の HOME で本物の hook の入口へ5つの場面を通した。席の環境は圧縮も道具も止めず記録を残さない。席の印を継いだだけの
|
|
83
|
+
環境・起動の印だけの環境・印の無い環境は、今までどおり圧縮を止めて引き継ぎを予約する。予約が残った席の会話は、次の
|
|
84
|
+
道具で記録を取り下げる。4台とも同じ結果だった。
|
|
85
|
+
- 裏の会話が常駐の環境を継ぐ事は、Linux(Claude Code 2.1.296)だけで見た。macOS と Windows では見ていない
|
|
86
|
+
(動いている常駐の下に試験の会話を立てる事になるので、流していない)。
|
|
87
|
+
- `AITERM_AGENT_VENDOR_SESSION_ID` を出す版の Aiterm は、まだ出ていない。本物の席での通し(本物の自動圧縮が起き、
|
|
88
|
+
席が同じ画面のまま答えを返す所)は、その版が入った後に確かめる。
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# ADR 0059: Codex の旧タスクのゴールは、引き継ぎの初めに止めて、後継へ運ぶ
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-13
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
Codex には、タスクごとのゴール(`/goal`)がある。目標の文を決めておくと、タスクが手すきになるたびに Codex が自分で
|
|
8
|
+
次のターンを起こし、目標が終わるまで続ける。
|
|
9
|
+
|
|
10
|
+
2026-10-13、Windows の Codex Desktop(同梱の Codex 0.162.0-alpha.17.2、Throughline 0.16.27)で、自動継続が
|
|
11
|
+
引き継いだ後の旧タスクが止まらなかった。
|
|
12
|
+
|
|
13
|
+
- 22:04:09 に旧タスクを止めて引き継ぎを始め、22:05:44 に後継が作業を続けた(`continued`)。引き継ぎ自体は成功している。
|
|
14
|
+
- 旧タスクには、20:10 に利用者が付けたゴールが「実行中」のまま残っていた。後継にはゴールが無かった。
|
|
15
|
+
- 旧タスクでは、22:04:10 から 1〜2 秒おきに新しいターンが始まり続けた。23:43:35 までに 2,067 回。
|
|
16
|
+
どのターンも入力は Codex 自身の文脈(`<codex_internal_context source="goal">` で始まる続行の指示)だけで、人の入力は 0。
|
|
17
|
+
- ターンが始まるたびに、文脈が上限を超えているので `PreCompact(auto)` が来る。Throughline は「引き継ぎ済みの旧タスクへ
|
|
18
|
+
新しい入力が来た」(ADR 0039)として、ターンを止め、後継を画面に開いた。
|
|
19
|
+
- 利用者には、「タスク終了」の通知が続き、画面が後継へ切り替わり続ける様に見えた。旧タスクの記録(rollout)は
|
|
20
|
+
約34MB まで増えた。モデルは呼ばれていない(止めるのはモデルを呼ぶ前)。
|
|
21
|
+
- 引き継ぎの最中(22:04:10〜22:05:44)にも同じターンが来ていた。こちらは「引き継ぎの最中の入力」(ADR 0040)として
|
|
22
|
+
止まっていた。
|
|
23
|
+
|
|
24
|
+
原因は、Throughline がゴールを扱っていなかった事にある。
|
|
25
|
+
|
|
26
|
+
- Codex は、タスクが手すきになるたびに DB のゴールを読み、`active` なら続行の指示でターンを始める
|
|
27
|
+
(`codex-rs/ext/goal/src/extension.rs` の `on_thread_idle` → `runtime.rs` の `continue_if_idle`)。
|
|
28
|
+
hook が止めたターン(`turn_aborted`、`interrupted`)の後も同じ。利用者が自分で中断した時だけの持ち越しの印は、hook の止めでは付かない。
|
|
29
|
+
- 後継は新しいタスクなので、ゴールを持たない。後継は継続の指示の1ターンを終えると、そこで止まる。
|
|
30
|
+
ゴールで動かしていた作業が、引き継ぎを境に自分では続かなくなっていた。
|
|
31
|
+
|
|
32
|
+
Codex の app-server には、ゴールを読む・変える・消す口がある(`thread/goal/get`・`thread/goal/set`・`thread/goal/clear`。
|
|
33
|
+
experimental)。別の process から使える。確かめた事(Linux の Codex 0.162.1 と、Windows の実機):
|
|
34
|
+
|
|
35
|
+
- 別の process が `status: 'paused'` を書くと、タスクを開いている process は次の手すきで続行を起こさない
|
|
36
|
+
(Windows の実機で、連続が1ターンの内に止まった)。
|
|
37
|
+
- ゴールの無いタスクへ、別の process が目標の文つきで `status: 'active'` を書ける。そのタスクがターンを動かしている間に
|
|
38
|
+
書くと、タスクを開いている process は、そのターンの後から続行を起こす。
|
|
39
|
+
- 手すきのタスクへ `active` を書いても、その場では続行が起きない。次にそのタスクのターンが終わった時から起きる。
|
|
40
|
+
- `status: 'paused'` と目標の文を一緒に渡して、止まった状態のゴールを新しく作れる。
|
|
41
|
+
|
|
42
|
+
## Decision
|
|
43
|
+
|
|
44
|
+
1. worker は、引き継ぎの最初に、旧タスクのゴールを読む。`active` なら `paused` にする(`pauseAutoHandoffSourceGoal`)。
|
|
45
|
+
ゴールは消さない。止めたかどうかを、引き継ぎの記録(`runtime_json.goalCarry.paused`)へ書く。目標の文は記録へ写さない。
|
|
46
|
+
2. 後継が作業を続けたのを確かめた後(`continued` の直後、順番待ちを運ぶより先)に、旧タスクのゴールを後継へ運ぶ
|
|
47
|
+
(`carryAutoHandoffGoal`)。後継の最初のターン(継続の指示)が動いている間に付けるので、そのターンの後から Codex が続ける。
|
|
48
|
+
- 目標の文はそのまま写す。予算(`tokenBudget`)があれば、旧タスクで使った分を引いた残りを渡す。
|
|
49
|
+
- この製品が止めたゴールは、後継では `active` にする。利用者が止めていたゴール(`paused`)は、`paused` のまま運ぶ。
|
|
50
|
+
- 完了したゴール(`complete`)と、Codex が上限や行き詰まりで止めたゴール(`budgetLimited`・`usageLimited`・`blocked`)は運ばない。
|
|
51
|
+
- 後継にもうゴールがあれば、書き換えない。
|
|
52
|
+
- 運ぶ時に旧タスクのゴールが `active` なら(最初の一時停止が効かなかった)、先に `paused` にしてから運ぶ。
|
|
53
|
+
3. 旧タスクのゴールは `paused` のまま残す。旧タスクは引き継ぎ済みなので、そのゴールは動かない。
|
|
54
|
+
4. ゴールを読めない・書けない時も、引き継ぎは止めない(ゴールの口が無い版の Codex を含む)。理由は worker のログに残す。
|
|
55
|
+
5. 途中で止まった引き継ぎ(`failed`・`unknown`)では、ゴールを後継へ運ばず、旧タスクのゴールも `active` へ戻さない。
|
|
56
|
+
戻すと、止まったままの旧タスクで、続行のターンと引き継ぎの失敗が繰り返される。失敗の画面に、ゴールを止めてある事と、
|
|
57
|
+
旧タスクで続ける時の戻し方を書く。やり直しで `continued` になった時に運ぶ。同じ旧タスクから新しい引き継ぎを作った時は、
|
|
58
|
+
前の引き継ぎが止めていた事を引き継ぐ(後継では `active` にする)。
|
|
59
|
+
6. 引き継ぎ済みの旧タスクへターンが来た時(ADR 0039 の道)に、次の2つを足す。
|
|
60
|
+
- そのターンの入力が Codex 自身の文脈だけなら(`<codex_internal_context` で始まる物だけ)、後継を画面に開かない。
|
|
61
|
+
人が入力していないのに画面を切り替えない。ターンを止めて後継を示す事は変えない。
|
|
62
|
+
- 旧タスクにゴールが動いたまま残っていないかを、別の process で確かめる(`auto-handoff settle-goal`。hook は待たない)。
|
|
63
|
+
動いていれば `paused` にし、今の一番先の後継にゴールが無ければ `active` で運ぶ。同じ旧タスクへは30秒に1回まで。
|
|
64
|
+
0.16.27 までの版が引き継いだ旧タスクと、引き継いだ後で利用者が旧タスクのゴールを再開した時が当たる。
|
|
65
|
+
片付けた事は `~/.throughline/codex-auto-handoff/goal-settles.jsonl` へ1行残す(時刻・ID・付けた状態。目標の文は書かない)。
|
|
66
|
+
|
|
67
|
+
## Consequences
|
|
68
|
+
|
|
69
|
+
- ゴールで動かしていたタスクは、引き継いだ後も後継がゴールで続ける。旧タスクは止まる。
|
|
70
|
+
- 引き継ぎの最中に来る続行のターンは、worker がゴールを止めるまでの数秒の分だけになる。
|
|
71
|
+
- 後継のゴールの使用量(`tokensUsed`・`timeUsedSeconds`)は 0 から数え直しになる。予算は残りを渡すので、合計の上限は変わらない。
|
|
72
|
+
- 6 の片付けで運んだゴールは、後継が手すきの時は、その場では動き出さない。後継の次のターンが終わった時から Codex が続ける。
|
|
73
|
+
- 旧タスクの画面のゴールの表示は、別の process が書いた変更なので、Codex Desktop がそのタスクを読み直すまで古いまま見える事がある。
|
|
74
|
+
- `continued` を記録した直後に worker が落ちると、ゴールは旧タスクで止まったまま、後継へは運ばれない。旧タスクは動かないので
|
|
75
|
+
連続は起きない。利用者が後継でゴールを付け直す必要がある。
|
|
76
|
+
- `blocked` などで止まっているゴールは運ばない。旧タスクに残る。
|
|
77
|
+
- 引き継ぎ1回につき、Codex の app-server への接続が2回増える(最初の読み書きと、運ぶ時)。
|
|
78
|
+
- Claude Code の自動継続は変えない。Claude Code にはタスクごとのゴールが無い。
|
|
79
|
+
|
|
80
|
+
## 確かめた範囲
|
|
81
|
+
|
|
82
|
+
| | macOS | Linux | Windows |
|
|
83
|
+
|---|---|---|---|
|
|
84
|
+
| Codex Desktop | 直した版の関数(止める・運ぶ・片付ける)を、Desktop 同梱の Codex(0.162.0-alpha.17.2)へ通した(25項目) | 同じ確認を codex-cli 0.162.1 で。本物の Codex Desktop(同梱の 0.162.0-alpha.2)で、ゴールを持つタスクの引き継ぎを8回続けて通した | 起きた端末。旧タスクのゴールを `paused` にして、連続が止まるのを見た。関数の確認(25項目)を Desktop 同梱の Codex(0.162.0-alpha.17.2)で。起きた旧タスクの本物の記録へ、直した版の hook の入口を読むだけで当てた |
|
|
85
|
+
| Claude Code | 変更なし(ゴールが無い) | 同じ | 同じ |
|
|
86
|
+
| Grok | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
87
|
+
| Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
|
|
88
|
+
|
|
89
|
+
Linux の通し(2026-10-14 00:05〜00:14 JST)で見た事:
|
|
90
|
+
|
|
91
|
+
- タスクを Codex Desktop で開くと、実行中のゴールを持つタスクは、入力を待たずに続行のターンを始めた。
|
|
92
|
+
- 引き継ぎのたびに、止めたターンの直後から続行のターンが 0.15 秒ほどの間隔で来た。worker がゴールを止めるまでの1秒以内に 4〜5 回で、
|
|
93
|
+
その後は来ていない。8回とも `goalCarry` は `{"paused":true,"carried":"active"}`。
|
|
94
|
+
- 最後の後継が作業を終えるとゴールは完了になり、Codex Desktop の画面の側が `thread/goal/clear` を送ってゴールを消した。
|
|
95
|
+
|
|
96
|
+
macOS と Windows の本物の Codex Desktop の画面で、ゴールを持つタスクが引き継がれる通しは、公開の時点では確かめていない。
|
package/package.json
CHANGED
|
@@ -260,6 +260,24 @@ function unsupportedEntrypoint(env) {
|
|
|
260
260
|
return typeof env.CLAUDE_CODE_ENTRYPOINT === 'string' && env.CLAUDE_CODE_ENTRYPOINT.startsWith('sdk-');
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
// Aiterm が席の Claude に付ける印(ADR 0057)。名前と意味は Aiterm の担当と合わせてある。変える時は先に知らせ合う。
|
|
264
|
+
// 起動の印と、席の Claude を起こした時の会話 ID(`--session-id` の値。席の途中で変わらない)。
|
|
265
|
+
const AITERM_LAUNCH_ENV = 'AITERM_AGENT_LAUNCH_ID';
|
|
266
|
+
const AITERM_SEAT_SESSION_ENV = 'AITERM_AGENT_VENDOR_SESSION_ID';
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Aiterm が起こした席の会話か(ADR 0057)。席は起こした時の会話に結び付いていて、呼び手はその会話の番の終わりを待っている。
|
|
270
|
+
* 裏の後継へ引き継ぐと、呼び手の文は後継へ届かず、後継の答えも呼び手へ戻らない。席の会話は引き継がない。
|
|
271
|
+
*
|
|
272
|
+
* 席と数えるのは、環境の会話 ID がこの会話の ID と同じ時だけ。起動の印だけでは決めない。印は席の Claude が起こした子へ
|
|
273
|
+
* 継がれ、裏の会話(`claude --bg`)は常駐の process の環境を継ぐ。席の中から常駐が起きると、その後に立つ裏の会話は、
|
|
274
|
+
* 席と関係が無くても席の印を持つ(Linux、Claude Code 2.1.296 で実測)。印だけで外すと、利用者の会話の後継まで外れる。
|
|
275
|
+
* 会話 ID を出さない版の Aiterm が起こした席は、見分けられないので、今までどおり引き継ぐ。
|
|
276
|
+
*/
|
|
277
|
+
export function isAitermSeatConversation(env, sessionId) {
|
|
278
|
+
return Boolean(env?.[AITERM_LAUNCH_ENV]) && typeof sessionId === 'string' && env[AITERM_SEAT_SESSION_ENV] === sessionId;
|
|
279
|
+
}
|
|
280
|
+
|
|
263
281
|
/** transcript の最後の行が持つ出どころ(`cli`、`claude-desktop` など)。読めなければ null。 */
|
|
264
282
|
function readLatestEntrypoint(transcriptPath) {
|
|
265
283
|
if (typeof transcriptPath !== 'string') return null;
|
|
@@ -377,8 +395,12 @@ export function requestClaudeAutoHandoff({
|
|
|
377
395
|
if (existing?.state === 'requested') rmSync(path, { force: true });
|
|
378
396
|
return { status: 'skipped', block: false, reason: 'manual_compact', sessionId, projectPath };
|
|
379
397
|
}
|
|
398
|
+
const seat = isAitermSeatConversation(env, sessionId);
|
|
399
|
+
// 席の会話に、後継を立てる前の記録が残っている(席を外す前の版が予約した)。取り下げて、圧縮を通す。
|
|
400
|
+
const withdrawn = seat && existing && RETRYABLE_STATES.has(existing.state);
|
|
401
|
+
if (withdrawn) withdrawRecord(sessionId, dir);
|
|
380
402
|
// 引き継ぎが進んでいる会話は、設定が後から変わっても圧縮させない。
|
|
381
|
-
if (existing) {
|
|
403
|
+
if (existing && !withdrawn) {
|
|
382
404
|
return { status: 'already_requested', block: true, sessionId, projectPath, handoffId: existing.handoff_id };
|
|
383
405
|
}
|
|
384
406
|
if (!claudeAutoHandoffEnabledFor(config, projectPath)) {
|
|
@@ -387,6 +409,9 @@ export function requestClaudeAutoHandoff({
|
|
|
387
409
|
if (unsupportedEntrypoint(env)) {
|
|
388
410
|
return { status: 'skipped', block: false, reason: 'handoff_host_unsupported', sessionId, projectPath };
|
|
389
411
|
}
|
|
412
|
+
if (seat) {
|
|
413
|
+
return { status: 'skipped', block: false, reason: 'aiterm_seat', sessionId, projectPath };
|
|
414
|
+
}
|
|
390
415
|
|
|
391
416
|
// 後継は、最初の指示が運ぶ引き継ぎ ID からこの記録を引いて、この会話を前任として合流させる(ADR 0053)。
|
|
392
417
|
// project ごとに1つしか無い `/tl` の印は書かない。印を書いてから後継が立つまでの間に、同じ project の別の会話が
|
|
@@ -613,6 +638,12 @@ export async function launchClaudeAutoHandoffWorker(sessionId, { dir = claudeAut
|
|
|
613
638
|
} finally { closeSync(log); }
|
|
614
639
|
}
|
|
615
640
|
|
|
641
|
+
/** 後継を立てる前の記録と、立ち上げの札を消す。その会話は、記録が無い会話と同じ扱いに戻る。 */
|
|
642
|
+
function withdrawRecord(sessionId, dir) {
|
|
643
|
+
rmSync(recordPath(sessionId, dir), { force: true });
|
|
644
|
+
rmSync(join(dir, `${sessionId}.claim`), { force: true });
|
|
645
|
+
}
|
|
646
|
+
|
|
616
647
|
function stopOutput(record) {
|
|
617
648
|
const reason = record.successor?.short_id
|
|
618
649
|
? `この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: ${record.handoff_id})。続きは後継の会話で行ってください: claude attach ${record.successor.short_id}`
|
|
@@ -631,7 +662,8 @@ function stopOutput(record) {
|
|
|
631
662
|
/**
|
|
632
663
|
* PreToolUse hook の本体。引き継ぎの記録がある会話の道具を、実行させずに止める。
|
|
633
664
|
* 最初に止めた hook だけが、止めた時点の依頼と設定を記録へ写し、後継を立てる worker を起動する。
|
|
634
|
-
* 記録が無い会話と、subagent の中の道具には何もしない(null)。
|
|
665
|
+
* 記録が無い会話と、subagent の中の道具には何もしない(null)。Aiterm が起こした席の会話に、後継を立てる前の記録が
|
|
666
|
+
* 残っている時は、記録を取り下げて何もしない(ADR 0057)。
|
|
635
667
|
*
|
|
636
668
|
* @returns {Promise<object|null>} Claude Code へ返す hook 出力
|
|
637
669
|
*/
|
|
@@ -640,12 +672,18 @@ export async function stopClaudeTurnForHandoff({
|
|
|
640
672
|
now = Date.now(),
|
|
641
673
|
dir = claudeAutoHandoffDir(),
|
|
642
674
|
launchWorker = launchClaudeAutoHandoffWorker,
|
|
675
|
+
env = process.env,
|
|
643
676
|
}) {
|
|
644
677
|
const sessionId = payload?.session_id;
|
|
645
678
|
if (!validSessionId(sessionId) || payload.agent_id) return null;
|
|
646
679
|
let record = readClaudeAutoHandoff(sessionId, dir);
|
|
647
680
|
if (!record) return null;
|
|
648
681
|
if (RETRYABLE_STATES.has(record.state)) {
|
|
682
|
+
// 席の会話は引き継がない(ADR 0057)。席を外す前の版が残した、後継を立てる前の記録は取り下げ、道具を通す。
|
|
683
|
+
if (isAitermSeatConversation(env, sessionId)) {
|
|
684
|
+
withdrawRecord(sessionId, dir);
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
649
687
|
// 同じ応答に並んだ道具の hook は同時に走る。立ち上げは1回だけ。
|
|
650
688
|
const claim = join(dir, `${sessionId}.claim`);
|
|
651
689
|
if (record.state === 'failed') rmSync(claim, { force: true });
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
runClaudeDesktopOpen,
|
|
22
22
|
findClaudeAutoContinuationSource,
|
|
23
23
|
resolveClaudeAutoHandoffSuccessor,
|
|
24
|
+
isAitermSeatConversation,
|
|
24
25
|
} from './claude-auto-handoff.mjs';
|
|
25
26
|
import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
|
|
26
27
|
|
|
@@ -182,6 +183,93 @@ test('PreCompact: 手動・無効・対象外のproject・scriptから起動し
|
|
|
182
183
|
assert.equal(request(dir, { env: { CLAUDE_CODE_ENTRYPOINT: 'claude-desktop' } }).result.status, 'requested');
|
|
183
184
|
}));
|
|
184
185
|
|
|
186
|
+
test('席の判定: 起動の印があり、環境の会話 ID がこの会話と同じ時だけ席と数える', () => {
|
|
187
|
+
const OTHER = 'cccccccc-1111-4222-8333-444444444444';
|
|
188
|
+
for (const [env, expected, label] of [
|
|
189
|
+
[{}, false, '印なし(人の端末、Desktop)'],
|
|
190
|
+
[{ AITERM_SESSION_ID: 't1', AITERM_STATE_BASE: '/tmp' }, false, 'Aiterm の素の端末で人が起こした claude'],
|
|
191
|
+
[{ AITERM_AGENT_LAUNCH_ID: 'abc', AITERM_AGENT_KIND: 'claude' }, false, '会話 ID を出さない版の Aiterm の席。印だけでは決めない'],
|
|
192
|
+
[{ AITERM_AGENT_LAUNCH_ID: 'abc', AITERM_AGENT_VENDOR_SESSION_ID: '' }, false, '空の会話 ID'],
|
|
193
|
+
[{ AITERM_AGENT_VENDOR_SESSION_ID: SESSION }, false, '起動の印が無い'],
|
|
194
|
+
[{ AITERM_AGENT_LAUNCH_ID: '', AITERM_AGENT_VENDOR_SESSION_ID: SESSION }, false, '空の印'],
|
|
195
|
+
[{ AITERM_AGENT_LAUNCH_ID: 'abc', AITERM_AGENT_KIND: 'claude', AITERM_AGENT_VENDOR_SESSION_ID: SESSION }, true, '席の会話'],
|
|
196
|
+
[{ AITERM_AGENT_LAUNCH_ID: 'abc', AITERM_AGENT_KIND: 'claude', AITERM_AGENT_VENDOR_SESSION_ID: OTHER }, false,
|
|
197
|
+
'席の印を継いだ別の会話(席が起こした子、席の中から起きた常駐が立てた裏の会話)'],
|
|
198
|
+
]) assert.equal(isAitermSeatConversation(env, SESSION), expected, label);
|
|
199
|
+
assert.equal(isAitermSeatConversation(undefined, SESSION), false);
|
|
200
|
+
assert.equal(isAitermSeatConversation({ AITERM_AGENT_LAUNCH_ID: 'abc' }, undefined), false);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const SEAT = { AITERM_AGENT_LAUNCH_ID: 'abc', AITERM_AGENT_KIND: 'claude', AITERM_AGENT_VENDOR_SESSION_ID: SESSION };
|
|
204
|
+
// 席の印を継いだだけの、別の会話の環境
|
|
205
|
+
const SEAT_DESCENDANT = { ...SEAT, AITERM_AGENT_VENDOR_SESSION_ID: SUCCESSOR };
|
|
206
|
+
|
|
207
|
+
test('PreCompact: Aiterm の席の会話は引き継がない。圧縮を止めず、記録も残さない', () => withDir(dir => {
|
|
208
|
+
for (const env of [SEAT, { ...SEAT, CLAUDE_CODE_ENTRYPOINT: 'cli' }]) {
|
|
209
|
+
const { result, db } = request(dir, { env });
|
|
210
|
+
assert.deepEqual([result.status, result.block, result.reason], ['skipped', false, 'aiterm_seat']);
|
|
211
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM handoff_batons').get().c, 0);
|
|
212
|
+
assert.deepEqual(listClaudeAutoHandoffs({ dir }), []);
|
|
213
|
+
}
|
|
214
|
+
// 自動継続が無効な端末の席は、今までどおり「無効」で終わる
|
|
215
|
+
assert.equal(request(dir, { env: SEAT, config: { enabled: false, projects: [] } }).result.reason, 'auto_handoff_disabled');
|
|
216
|
+
// 席の印を継いだ別の会話と、会話 ID を出さない版の Aiterm の席は、今までどおり引き継ぐ
|
|
217
|
+
for (const env of [SEAT_DESCENDANT, { AITERM_AGENT_LAUNCH_ID: 'abc', AITERM_AGENT_KIND: 'claude' }]) {
|
|
218
|
+
assert.equal(request(dir, { env }).result.status, 'requested');
|
|
219
|
+
rmSync(join(dir, `${SESSION}.json`));
|
|
220
|
+
}
|
|
221
|
+
}));
|
|
222
|
+
|
|
223
|
+
test('PreCompact: 席の会話に残っていた、後継を立てる前の記録は取り下げる。後継を立てた後の記録は保つ', () => withDir(dir => {
|
|
224
|
+
// 席を外す前の版(か、会話 ID を出さない版の Aiterm の時)に予約した記録
|
|
225
|
+
assert.equal(request(dir).result.status, 'requested');
|
|
226
|
+
writeFileSync(join(dir, `${SESSION}.claim`), '');
|
|
227
|
+
const withdrawn = request(dir, { env: SEAT }).result;
|
|
228
|
+
assert.deepEqual([withdrawn.status, withdrawn.block, withdrawn.reason], ['skipped', false, 'aiterm_seat']);
|
|
229
|
+
assert.equal(readClaudeAutoHandoff(SESSION, dir), null);
|
|
230
|
+
assert.equal(existsSync(join(dir, `${SESSION}.claim`)), false);
|
|
231
|
+
|
|
232
|
+
// もう後継へ引き継いだ席の会話は、今までどおり圧縮させない(続きは後継にある)
|
|
233
|
+
const { result } = request(dir);
|
|
234
|
+
const path = join(dir, `${SESSION}.json`);
|
|
235
|
+
writeFileSync(path, JSON.stringify({ ...JSON.parse(readFileSync(path, 'utf8')), state: 'sent',
|
|
236
|
+
successor: { short_id: SUCCESSOR.slice(0, 8), session_id: SUCCESSOR } }));
|
|
237
|
+
const kept = request(dir, { env: SEAT }).result;
|
|
238
|
+
assert.deepEqual([kept.status, kept.block, kept.handoffId], ['already_requested', true, result.handoffId]);
|
|
239
|
+
assert.equal(readClaudeAutoHandoff(SESSION, dir).state, 'sent');
|
|
240
|
+
}));
|
|
241
|
+
|
|
242
|
+
test('PreToolUse: 席の会話は止めない。後継を立てる前の記録は取り下げ、道具を通す', () => withDir(async dir => {
|
|
243
|
+
const payload = { session_id: SESSION, cwd: '/work/app', tool_name: 'Read', tool_use_id: 'toolu_2' };
|
|
244
|
+
const launched = [];
|
|
245
|
+
const launchWorker = async sessionId => { launched.push(sessionId); };
|
|
246
|
+
|
|
247
|
+
for (const state of ['requested', 'failed']) {
|
|
248
|
+
request(dir);
|
|
249
|
+
const path = join(dir, `${SESSION}.json`);
|
|
250
|
+
writeFileSync(path, JSON.stringify({ ...JSON.parse(readFileSync(path, 'utf8')), state }));
|
|
251
|
+
writeFileSync(join(dir, `${SESSION}.claim`), '');
|
|
252
|
+
assert.equal(await stopClaudeTurnForHandoff({ payload, dir, launchWorker, env: SEAT }), null, state);
|
|
253
|
+
assert.equal(readClaudeAutoHandoff(SESSION, dir), null, state);
|
|
254
|
+
assert.equal(existsSync(join(dir, `${SESSION}.claim`)), false, state);
|
|
255
|
+
}
|
|
256
|
+
assert.deepEqual(launched, [], '後継は立てない');
|
|
257
|
+
|
|
258
|
+
// 席の印を継いだ別の会話は、今までどおり止めて後継を立てる
|
|
259
|
+
request(dir);
|
|
260
|
+
const child = await stopClaudeTurnForHandoff({ payload, dir, launchWorker, env: SEAT_DESCENDANT });
|
|
261
|
+
assert.equal(child.continue, false);
|
|
262
|
+
assert.deepEqual(launched, [SESSION]);
|
|
263
|
+
|
|
264
|
+
// もう後継へ引き継いだ席の会話の道具は、今までどおり止めて後継を案内する
|
|
265
|
+
const path = join(dir, `${SESSION}.json`);
|
|
266
|
+
writeFileSync(path, JSON.stringify({ ...JSON.parse(readFileSync(path, 'utf8')), state: 'sent',
|
|
267
|
+
successor: { short_id: SUCCESSOR.slice(0, 8), session_id: SUCCESSOR } }));
|
|
268
|
+
const stopped = await stopClaudeTurnForHandoff({ payload, dir, launchWorker, env: SEAT });
|
|
269
|
+
assert.equal(stopped.continue, false);
|
|
270
|
+
assert.match(stopped.stopReason, new RegExp(`claude attach ${SUCCESSOR.slice(0, 8)}`));
|
|
271
|
+
}));
|
|
272
|
+
|
|
185
273
|
test('PreCompact: 手動の/compactは止める前の記録を取り下げ、subagentの圧縮は親の記録に触らない', () => withDir(dir => {
|
|
186
274
|
request(dir);
|
|
187
275
|
assert.equal(request(dir, { payload: { trigger: 'auto', agent_id: 'agent-1' } }).result.reason, 'subagent_compact');
|
|
@@ -1455,6 +1543,36 @@ test('hook: 自動継続が無効な時、自動圧縮を止めず、何も残
|
|
|
1455
1543
|
assert.equal(existsSync(join(home, '.throughline', 'throughline.db')), false, '無効な時は DB も開かない');
|
|
1456
1544
|
}));
|
|
1457
1545
|
|
|
1546
|
+
test('hook: Aiterm の席の会話は、自動継続が有効でも自動圧縮を止めず、道具も止めない', () => withDir(home => {
|
|
1547
|
+
const project = join(home, 'project');
|
|
1548
|
+
mkdirSync(project, { recursive: true });
|
|
1549
|
+
const seat = { AITERM_AGENT_LAUNCH_ID: '4c83fc1dd27459a9', AITERM_AGENT_KIND: 'claude', AITERM_SESSION_ID: 'seat-1',
|
|
1550
|
+
AITERM_AGENT_VENDOR_SESSION_ID: SESSION };
|
|
1551
|
+
const payload = { session_id: SESSION, cwd: project, transcript_path: join(home, 'transcript.jsonl') };
|
|
1552
|
+
assert.equal(cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']).status, 0);
|
|
1553
|
+
|
|
1554
|
+
const pre = cli(home, ['pre-compact'], JSON.stringify({ ...payload, hook_event_name: 'PreCompact', trigger: 'auto' }), seat);
|
|
1555
|
+
assert.deepEqual([pre.status, pre.stdout, pre.stderr], [0, '', '']);
|
|
1556
|
+
const tool = cli(home, ['pre-tool-use'], JSON.stringify({ ...payload, hook_event_name: 'PreToolUse', tool_name: 'Read', tool_use_id: 'toolu_1' }), seat);
|
|
1557
|
+
assert.deepEqual([tool.status, tool.stdout, tool.stderr], [0, '', '']);
|
|
1558
|
+
assert.deepEqual(JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout).handoffs, []);
|
|
1559
|
+
const decisions = readFileSync(join(home, '.throughline', 'logs', 'inheritance-decision.log'), 'utf8').trim().split('\n').map(line => JSON.parse(line));
|
|
1560
|
+
assert.deepEqual(decisions.filter(entry => entry.phase === 'pre-compact').map(entry => [entry.auto_continuation, entry.skip_reason]),
|
|
1561
|
+
[['skipped', 'aiterm_seat']]);
|
|
1562
|
+
|
|
1563
|
+
// 席の印を継いだだけの別の会話(会話 ID が違う)と、印の無い会話は、今までどおり止める
|
|
1564
|
+
const OTHER = 'cccccccc-1111-4222-8333-444444444444';
|
|
1565
|
+
const descendant = cli(home, ['pre-compact'], JSON.stringify({ ...payload, session_id: OTHER, hook_event_name: 'PreCompact', trigger: 'auto' }), seat);
|
|
1566
|
+
assert.equal(descendant.status, 2);
|
|
1567
|
+
assert.equal(cli(home, ['pre-compact'], JSON.stringify({ ...payload, hook_event_name: 'PreCompact', trigger: 'manual', session_id: OTHER }), seat).status, 0);
|
|
1568
|
+
const plain = cli(home, ['pre-compact'], JSON.stringify({ ...payload, hook_event_name: 'PreCompact', trigger: 'auto' }));
|
|
1569
|
+
assert.equal(plain.status, 2);
|
|
1570
|
+
// その記録が残った席の会話(席を外す前の版が予約した形)は、次の道具で取り下げる
|
|
1571
|
+
const next = cli(home, ['pre-tool-use'], JSON.stringify({ ...payload, hook_event_name: 'PreToolUse', tool_name: 'Read', tool_use_id: 'toolu_2' }), seat);
|
|
1572
|
+
assert.deepEqual([next.status, next.stdout], [0, '']);
|
|
1573
|
+
assert.deepEqual(JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout).handoffs, []);
|
|
1574
|
+
}));
|
|
1575
|
+
|
|
1458
1576
|
test('hook: 自分の失敗では圧縮も道具も止めず(exit code 1)、理由を端末に残す', () => withDir(home => {
|
|
1459
1577
|
const compact = cli(home, ['pre-compact'], '{"trigger":"auto"}');
|
|
1460
1578
|
assert.deepEqual([compact.status, compact.stdout], [1, '']);
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* 圧縮を止める。Claude Code は PreCompact の exit code 2 を「圧縮を止める」と解釈する。
|
|
9
9
|
* 止めた後の会話は圧縮されないまま続き、次の道具の hook(PreToolUse)が作業を止める。
|
|
10
10
|
*
|
|
11
|
-
* 手動の /compact、無効な project、subagent
|
|
11
|
+
* 手動の /compact、無効な project、subagent の中の圧縮、Aiterm が起こした席の会話(ADR 0057)では、何も止めずに
|
|
12
|
+
* exit code 0 で終わる。
|
|
12
13
|
* この hook 自身が失敗した時は exit code 1 で終わり、圧縮は止めない。
|
|
13
14
|
*/
|
|
14
15
|
|