throughline 0.16.29 → 0.16.31
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 +36 -1
- package/docs/adr/0061-codex-internal-context-is-compacted-in-memory.md +63 -0
- package/docs/adr/0062-claude-handed-off-conversation-blocks-input.md +111 -0
- package/package.json +1 -1
- package/src/claude-auto-handoff.mjs +120 -4
- package/src/claude-auto-handoff.test.mjs +106 -0
- package/src/codex-rollout-memory.mjs +33 -3
- package/src/codex-rollout-memory.test.mjs +76 -0
- package/src/prompt-submit.mjs +27 -0
- package/src/resume-context.mjs +14 -0
- package/src/resume-context.test.mjs +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,39 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.16.31] — 2026-10-14
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- **Claude Codeの自動継続で引き継いだ後も、旧い会話へ届いた入力でモデルが動いていた**([ADR 0062](docs/adr/0062-claude-handed-off-conversation-blocks-input.md))。旧い会話が裏で起こした作業の完了通知や、旧い会話へ打った文が届くたびに、文脈がほぼ一杯の旧い会話でモデルが1回動き、文だけで答えていた。届いた入力は後継へ渡らなかった。Windowsの実機の1日で191行あった(完了通知171、人の入力の形19、ほかの会話からの文1)。
|
|
18
|
+
- 直した後は、引き継ぎ済みで後継が残っている会話へ届いた入力を、モデルを動かさずに止める。人が打った時は、理由(引き継ぎIDと後継を開く命令)と元の文が返る。
|
|
19
|
+
- 後継がまだ立っていない間の入力、後継の記録が無くなった会話、Aitermが起こした席の会話、subagentの中の入力は止めない。
|
|
20
|
+
- 止めた回は`~/.throughline/claude-auto-handoff/redirects.jsonl`に1行残す(入力の本文は書かない)。
|
|
21
|
+
- **引き継いだ後の会話で道具を断る時の文が、古い後継を指す事があった**(同じADR)。後継がさらに引き継がれている時は、今の続きの会話を示す。`claude rm`で一覧から消した後継は`claude attach`で開けないので、`claude --resume <会話ID>`も添える。
|
|
22
|
+
|
|
23
|
+
### 追加
|
|
24
|
+
|
|
25
|
+
- **引き継ぐ時に、前の会話が裏で動かしていた作業の一覧を、後継の最初の指示へ載せる**(同じADR)。終わった通知がまだ来ていない作業の、道具の名前・対象・ID・出力のファイルの場所。新しい順に10件まで。完了通知は旧い会話で止まり後継へは届かないので、後継は出力のファイルで結果を確かめる。
|
|
26
|
+
|
|
27
|
+
### 変わる動き
|
|
28
|
+
|
|
29
|
+
- 引き継ぎ済みのClaudeの会話へ文を打つと、モデルは答えず、「この会話はThroughlineが新しい会話へ引き継ぎ済みです…」と元の文が返る。後継で打ち直す。旧い会話へ届いた入力を後継へ自動で届ける形は、この版には入っていない。
|
|
30
|
+
- `claude -p --resume <旧い会話>`のようにscriptから送った入力も止まる(標準出力に理由と元の文、終了code 0)。
|
|
31
|
+
- Codex・Grok・Cursorは変えていない。
|
|
32
|
+
|
|
33
|
+
## [0.16.30] — 2026-10-14
|
|
34
|
+
|
|
35
|
+
### 修正
|
|
36
|
+
|
|
37
|
+
- **Codexのゴール(`/goal`)で動くタスクで、Codexが自分で入れる続行の指示が、利用者の発言として会話の記録(L2)に残っていた**([ADR 0061](docs/adr/0061-codex-internal-context-is-compacted-in-memory.md))。続行のターンの入力は、`<codex_internal_context source="goal">`で始まる約6,300字の定型文で、利用者の発言としてrolloutに入る。Windowsの実機のタスクでは、利用者の発言15行のうち7行がこれで、字数の87%を占めていた。引き継ぎの記憶に入る直近のターンが同じ定型文で埋まり、利用者が打った依頼が押し出される形だった。
|
|
38
|
+
- 直した後は、開く印(`source`つき)・利用者が決めたゴールの文(`<objective>`)・閉じる印だけを残し、定型文を落とす。ターンは落とさない。
|
|
39
|
+
- 閉じる印が無い物と、発言の途中に印がある物は、触らない。
|
|
40
|
+
|
|
41
|
+
### 変わる動き
|
|
42
|
+
|
|
43
|
+
- ゴールで動くCodexのタスクで、続行のターンの利用者の発言が短い行になる。前の版が保存した長い行は、そのタスクの次の取り込みで置き換わる。
|
|
44
|
+
- Claude Code・Grok・Cursorの記録の読み方は変えていない。
|
|
45
|
+
|
|
13
46
|
## [0.16.29] — 2026-10-14
|
|
14
47
|
|
|
15
48
|
### 修正
|
|
@@ -2576,7 +2609,9 @@ two attempts, instrument first instead of patching again.
|
|
|
2576
2609
|
|
|
2577
2610
|
---
|
|
2578
2611
|
|
|
2579
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.
|
|
2612
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.31...HEAD
|
|
2613
|
+
[0.16.31]: https://github.com/kitepon/Throughline/compare/v0.16.30...v0.16.31
|
|
2614
|
+
[0.16.30]: https://github.com/kitepon/Throughline/compare/v0.16.29...v0.16.30
|
|
2580
2615
|
[0.16.29]: https://github.com/kitepon/Throughline/compare/v0.16.28...v0.16.29
|
|
2581
2616
|
[0.16.28]: https://github.com/kitepon/Throughline/compare/v0.16.27...v0.16.28
|
|
2582
2617
|
[0.16.27]: https://github.com/kitepon/Throughline/compare/v0.16.26...v0.16.27
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# ADR 0061: Codex が自分で起こしたターンの入力は、印とゴールの文だけを記憶に残す
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-10-14
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Codex は、ゴール(`/goal`)が実行中のタスクで、手すきになるたびに自分で次のターンを起こす(ADR 0059)。そのターンの入力は、
|
|
9
|
+
rollout に user の発言(`response_item` の `message`、`role: "user"`)として入る。本文は `<codex_internal_context source="goal">` で
|
|
10
|
+
始まり `</codex_internal_context>` で終わる。中身は、続け方の決まりを書いた定型文で、利用者が決めたゴールの文を
|
|
11
|
+
`<objective>` … `</objective>` に持つ。Linux の実機の試験タスクで読んだ1通は 6,343 字で、ゴールの文はそのうち約 200 字だった。
|
|
12
|
+
|
|
13
|
+
この製品は、user の発言から `# AGENTS.md instructions` と `<hook_prompt` で始まる物だけを外していた。ゴールの続行の入力は、
|
|
14
|
+
利用者の発言としてそのまま L2 に入っていた。
|
|
15
|
+
|
|
16
|
+
2026-10-13 の Windows の実機のタスクでは、L2 の user 15 行のうち 7 行がこの入力で(各 6,293 字)、user の字数の 87% を占めていた
|
|
17
|
+
(行の数と長さだけを読んだ。本文は読んでいない)。ゴールで動かすタスクでは、引き継ぎの記憶に入る直近のターンの user の本文が、
|
|
18
|
+
同じ定型文の繰り返しになる。記憶の字数の枠を定型文が使い、利用者が実際に打った依頼が押し出される。
|
|
19
|
+
|
|
20
|
+
ゴールそのものは、ADR 0059 で、引き継ぎの時に後継へ運んでいる。記憶に定型文を残す理由は無い。
|
|
21
|
+
|
|
22
|
+
## Decision
|
|
23
|
+
|
|
24
|
+
1. user の発言が `<codex_internal_context` の開く印で始まり、閉じる印 `</codex_internal_context>` を持つ時、開く印(属性ごと)・
|
|
25
|
+
`<objective>` … `</objective>`(あれば)・閉じる印だけを残す(`compactCodexInternalContext`)。ほかの中身は落とす。
|
|
26
|
+
- 開く印の `source` は、何のターンかを表すので残す。
|
|
27
|
+
- ゴールの文は、利用者が決めた文なので残す。そのターンが何を目指して動いたかが、記憶から読める。
|
|
28
|
+
- 閉じる印の後ろに文があれば、残す。
|
|
29
|
+
- 閉じる印が無い時は、何も落とさない。形が分からない物を推測で切らない。
|
|
30
|
+
- 発言の途中に印がある物(頭に別の文がある物)は、触らない。
|
|
31
|
+
2. ターンは落とさない。user の行は短い行として残る。ターンの番号の数え方は変えない。
|
|
32
|
+
3. 同じ正規化を、user の発言を読む3か所(`event_msg` の `user_message`、`response_item` の `message`、圧縮で置き換わった履歴)に
|
|
33
|
+
通す。巻き戻しの照合は、3か所の本文を比べるので、揃えておく。
|
|
34
|
+
4. 判定は印だけで行う。定型文の言い回しは読まない。Codex が定型文を変えても、印と `<objective>` が同じなら動く。
|
|
35
|
+
|
|
36
|
+
## Consequences
|
|
37
|
+
|
|
38
|
+
- ゴールで動くタスクの L2 で、続行のターンの user の行が、約 6,300 字から「印 + ゴールの文」になる。
|
|
39
|
+
- Codex の取り込みは、タスクの bodies を毎回全部入れ直す。前の版が保存した長い行は、次の取り込みで短い行に置き換わる。
|
|
40
|
+
本文が変わったターンの L1 の要約は消えて、作り直される(今までの決まりのまま)。
|
|
41
|
+
- 引き継ぎ済みの旧タスクは、取り込みが走らないので、前の版が保存した行のまま残る。
|
|
42
|
+
- `<objective>` の中身は、Codex が書いた形のまま残る(`<` などの置き換えを戻さない)。
|
|
43
|
+
- Claude Code・Grok・Cursor には、この形の入力が無い。変えない。
|
|
44
|
+
|
|
45
|
+
## 確かめた事・確かめていない事
|
|
46
|
+
|
|
47
|
+
- 確かめた:
|
|
48
|
+
- 単体試験(定型文を落とす、後ろの文を残す、ゴールの文が無い形、閉じる印が無い・頭に無い・似た名前の印を触らない、
|
|
49
|
+
rollout を通した時の3つのターン)。
|
|
50
|
+
- 本物の rollout へ、前の版と直した版の読み手を当てて比べた(DB は開かない。数だけ)。3つの OS とも、ターンの数と行の数は同じで、
|
|
51
|
+
続行の入力でない user の行と assistant の行は1つも変わらない。
|
|
52
|
+
|
|
53
|
+
| | 続行の入力のある rollout | 続行の入力の行 | その字数 | 直した後の一番長い行 |
|
|
54
|
+
|---|---|---|---|---|
|
|
55
|
+
| Linux | 9 本(試験のタスク) | 17 → 17 | 107,635 → 5,491 | 323 字 |
|
|
56
|
+
| macOS | 9 本(本物 1 本と試験のタスク) | 21 → 21 | 132,486 → 6,248 | 536 字 |
|
|
57
|
+
| Windows | 7 本(本物 3 本と試験のタスク) | 2,069 → 2,069 | 13,020,355 → 579,482 | 691 字 |
|
|
58
|
+
|
|
59
|
+
- 取り込みの入口(`codex-capture`)へ、試験のタスクの rollout の写しを、使い捨ての HOME で、前の版 → 直した版 → 直した版の順に
|
|
60
|
+
通した(3つの OS)。前の版が入れた長い行(6,335〜6,567 字)が、直した版の取り込みで短い行(323〜557 字)に置き換わり、行の数と
|
|
61
|
+
details は変わらない。もう1回通しても同じ。
|
|
62
|
+
- 確かめていない: `source` が `goal` 以外の `<codex_internal_context>`。実物を見ていない。同じ形(開く印と閉じる印)なら、
|
|
63
|
+
印だけが残る。直した版を入れた端末で、本物の Codex がゴールの続行のターンを終えた時の L2 は、入れた後の追跡で読む。
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# ADR 0062: 引き継ぎ済みの Claude の会話へ届いた入力は、モデルを動かさずに止めて、後継の場所を返す
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-10-14
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Claude Code の自動継続(ADR 0033)は、文脈が一杯になった会話を止め、裏の新しい会話(後継)へ作業を引き継ぐ。
|
|
9
|
+
引き継いだ後も、旧い会話は消えない。旧い会話へは、入力が届き続ける。
|
|
10
|
+
|
|
11
|
+
- 旧い会話が裏で起こした作業(`run_in_background` の道具など)の完了通知。作業は旧い会話の process の下で動き続け、
|
|
12
|
+
終わると旧い会話へ通知が届く。
|
|
13
|
+
- 人が旧い会話へ打った文。端末や一覧で旧い会話を開いたまま打つと、そこへ届く。
|
|
14
|
+
- ほかの会話から届いた文。
|
|
15
|
+
|
|
16
|
+
この製品が旧い会話でしていたのは、道具を断る事だけだった(`PreToolUse`。理由に後継の場所を書く)。入力が届くたびに、
|
|
17
|
+
文脈がほぼ一杯の旧い会話でモデルが1回動き、文だけで答える。届いた入力は後継へ渡らない。自動圧縮の合図も来続ける。
|
|
18
|
+
|
|
19
|
+
2026-10-13 の Windows の実機の1日で、引き継いだ後の旧い会話へ届いた入力は 191 行だった(完了通知 171、人の入力の形 19、
|
|
20
|
+
ほかの会話からの文 1。行の種類と時刻だけを読んだ。本文は読んでいない)。人の入力の形のうち 17 行は、引き継ぎの後の3時間に
|
|
21
|
+
1つの旧い会話へ届いていて、後継へは届いていない。別の旧い会話では、引き継ぎの後の 24 分に完了通知が 114 通届いていた。
|
|
22
|
+
|
|
23
|
+
Claude Code 2.1.296 で、次を実機で確かめた(Linux。後に macOS と Windows でも同じ)。
|
|
24
|
+
|
|
25
|
+
- 裏の作業の完了通知でも `UserPromptSubmit` が走る。`prompt` は `<task-notification>` で始まる。
|
|
26
|
+
- `UserPromptSubmit` が `{"decision":"block","reason":…}` を返すと、完了通知でも人の入力でも、user 行は書かれず、モデルは動かず、
|
|
27
|
+
Stop も走らない。transcript には `system` の `informational` の行が1つ入る。人が打った時は、理由と元の文
|
|
28
|
+
(`Original prompt: …`)が打った人へ返る。
|
|
29
|
+
- 裏で起こした道具は、結果の行に `toolUseResult.backgroundTaskId` を持ち、結果の文に出力の file の場所
|
|
30
|
+
(`Output is being written to: …`)を持つ。完了通知は `<task-id>` と `<status>` を持つ。
|
|
31
|
+
|
|
32
|
+
届いた入力を後継へ自動で届ける形は、hook を1つ増やし、後継ごとに待つ process が要る。作り込む所が多い。利用者の決め
|
|
33
|
+
(2026-10-14)は「先に、旧い会話で止めて後継の場所を見せる形を入れ、続けて、後継へ届ける形を作る」。この ADR は先の分を決める。
|
|
34
|
+
届ける形は、別の ADR で決める。
|
|
35
|
+
|
|
36
|
+
## Decision
|
|
37
|
+
|
|
38
|
+
1. `UserPromptSubmit` の入口(`prompt-submit`)で、その会話に引き継ぎの記録があり、後継が残っている時、入力を止める
|
|
39
|
+
(`redirectClaudeHandedOffPrompt`)。返すのは `{"decision":"block","reason":…}`。
|
|
40
|
+
- 止めるのは、記録の状態が、後継が立った後(`launched`・`sent`・`unknown`)で、後継の transcript が在る時
|
|
41
|
+
(`successorRemains`。期限の過ぎた記録を消さずに残す時の条件と同じ)。
|
|
42
|
+
- 人の入力・完了通知・ほかの会話からの文を、同じに止める。
|
|
43
|
+
- 理由の文は、道具を断る時の文と同じにする。引き継ぎ ID と、今の続きの会話を開く命令を書く。
|
|
44
|
+
2. 止めない物:
|
|
45
|
+
- 引き継ぎの記録が無い会話(ほとんどの会話)。記録の file が無ければ、何も読み込まずに先へ進む。
|
|
46
|
+
- 後継がまだ立っていない会話(予約・止めた直後・立ち上げ中・失敗)。その間の入力は、今までどおり。
|
|
47
|
+
- 後継の transcript が無くなった会話。
|
|
48
|
+
- 後継自身(記録が無い)。継続の指示と、後継の最初の入力は止まらない。
|
|
49
|
+
- subagent の中の入力。
|
|
50
|
+
- Aiterm が起こした席の会話(ADR 0057)。席は今は引き継がない。席を外す前の版が引き継いだ席が残っていても、呼び手は
|
|
51
|
+
席の番の終わりを待っている。止めると番が始まらず、呼び手は答えを受け取れない。席は今までどおり、文で答える。
|
|
52
|
+
3. 理由の文に書く後継は、今の続きの会話にする。後継がさらに引き継がれていれば、その先を書く
|
|
53
|
+
(`resolveClaudeAutoHandoffSuccessor`)。道具を断る時の文も同じに直す。
|
|
54
|
+
- 書く命令は `claude attach <頭8字>` と、`claude --resume <会話 ID>`。`claude rm` で一覧から消した後継は、
|
|
55
|
+
transcript が残っていて `claude attach` では開けない(`No job matching`)。`claude --resume` なら開ける。
|
|
56
|
+
4. 止めた回は、`~/.throughline/claude-auto-handoff/redirects.jsonl` に1行残す(時刻・旧い会話の ID・引き継ぎ ID・後継の頭8字・
|
|
57
|
+
入力の種類 `prompt` か `task-notification`)。入力の本文は書かない。判定の記録(`inheritance-decision.log`)にも
|
|
58
|
+
`prompt-redirect` を1行残す。こちらも本文は書かない。
|
|
59
|
+
5. 完了通知は旧い会話で止まり、後継へは届かない。代わりに、引き継ぐ時に、前の会話が裏で動かしていて、終わった通知がまだ
|
|
60
|
+
来ていない作業の一覧を、後継の最初の指示(現在地)へ載せる(`readRunningBackgroundTasks`)。
|
|
61
|
+
- 一覧は transcript から作る。裏で起こした道具の結果の行(`backgroundTaskId`)から、`<status>` を持つ完了通知
|
|
62
|
+
(user 行か、ターンの途中に届いた `queued_command`)が届いた物を除く。途中の知らせ(`<status>` の無い通知)が来ただけの
|
|
63
|
+
作業は、まだ動いている物として残す。
|
|
64
|
+
- 載せるのは、道具の名前・対象(命令や説明の頭)・作業の ID・出力の file の場所。新しい順に 10 件まで。残りは件数だけ。
|
|
65
|
+
- 後継には「終わった通知は、この会話へは届かない。結果が要る時は、出力の file を読んで確かめる」と書く。
|
|
66
|
+
6. 止める判定は、DB を開かない。記録の file だけを読む。
|
|
67
|
+
|
|
68
|
+
## Consequences
|
|
69
|
+
|
|
70
|
+
- 引き継ぎ済みの会話へ入力が届いても、モデルは動かない。文脈がほぼ一杯の会話で、通知のたびに1回動く事が無くなる。
|
|
71
|
+
- 旧い会話へ打った文は、理由と元の文が返る。利用者は、後継で打ち直す(届ける形が入るまで)。
|
|
72
|
+
- 完了通知は後継へ届かない(今までも届いていない)。後継は、引き継ぎの時に受け取った一覧と出力の file で確かめる。引き継ぎの
|
|
73
|
+
後に旧い会話が新しく裏の作業を起こす事は無い(道具が断られる)。
|
|
74
|
+
- `claude -p --resume <旧い会話>` のように script から旧い会話へ入力を送った時も止まる。標準出力に理由と元の文が返り、
|
|
75
|
+
終了 code は 0。
|
|
76
|
+
- 後継を `claude rm` で消しても、transcript が残る間、旧い会話は止まったままになる(道具も今までどおり断られる)。
|
|
77
|
+
理由の文の `claude --resume <会話 ID>` で後継を開ける。後継の transcript まで消すと、旧い会話の入力は通る
|
|
78
|
+
(道具は、記録が期限で消えるまで断られる。今までどおり)。
|
|
79
|
+
- `redirects.jsonl` は増える一方で、この版では古い行を消さない(1行 約 220 バイト。1日 200 行で約 44KB)。
|
|
80
|
+
- Codex・Grok・Cursor は変えない。Codex の旧タスクへ届く入力は、ADR 0039・0040 が別に扱っている。
|
|
81
|
+
|
|
82
|
+
## 確かめた事・確かめていない事
|
|
83
|
+
|
|
84
|
+
- 確かめた:
|
|
85
|
+
- 単体試験(記録が無い・後継が立っていない・後継の transcript が無い時は止めない、人の入力と完了通知を止める、席の会話と
|
|
86
|
+
subagent は止めない、後継の先を示す、止めた記録に本文を書かない、公開 CLI の `prompt-submit` の出力、裏の作業の一覧の作り方、
|
|
87
|
+
後継の現在地の文)。
|
|
88
|
+
- 本物の Claude Code 2.1.296 の裏の会話(Haiku)へ、直した版の `prompt-submit` を通した。Linux・macOS・Windows の3つ。
|
|
89
|
+
利用者の設定の hook を外し(`--setting-sources project,local`)、直した版を切り離した HOME で呼ぶ hook だけを付けた。
|
|
90
|
+
旧い会話の役が裏で `sleep 25` を起こし、番を終えた所で、後継の役の会話への引き継ぎの記録を切り離した置き場へ置いた。
|
|
91
|
+
3つの OS とも同じ結果だった。
|
|
92
|
+
|
|
93
|
+
| 見た事 | 結果 |
|
|
94
|
+
|---|---|
|
|
95
|
+
| 番を終えた旧い会話の、裏で動いている作業の一覧 | 1 件。出力の file の場所を読めて、その file が在る |
|
|
96
|
+
| 裏の作業の完了通知 | 止まった。transcript に入ったのは `queue-operation` 2 行と `system` の `informational` 1 行。user 行も assistant 行も無い |
|
|
97
|
+
| 人の入力(`claude -p --resume <旧い会話>`) | 止まった。理由と元の文が返り、終了 code 0 |
|
|
98
|
+
| 後継を `claude rm` で消した後 | 後継の transcript は残る。`claude attach` は `No job matching` で終了 code 1。旧い会話への入力は止まる。`claude --resume <後継の会話 ID>` は通る |
|
|
99
|
+
| 後継の transcript も消した後 | 旧い会話への入力は通り、モデルが答える |
|
|
100
|
+
| 切り離した置き場の失敗の記録 | 無し |
|
|
101
|
+
| 本物の置き場の判定の記録にある、試しの会話の ID | 0 |
|
|
102
|
+
|
|
103
|
+
- 直した版の裏の作業の読み手を、本物の記録へ当てた(読むだけ。数だけ出す)。10/10 から動いた記録で、Linux 172 本のうち
|
|
104
|
+
7 本に 8 件、macOS 73 本のうち 2 本に 2 件、Windows 109 本のうち 13 本に 25 件。どれも出力の file の場所を読めた
|
|
105
|
+
(Windows は `PowerShell` の道具が 21 件、`Bash` が 4 件)。読み手が落ちた記録は無い。
|
|
106
|
+
- 確かめていない:
|
|
107
|
+
- 端末の画面・Claude Desktop・リモートコントロール(スマホ)で、止めた時にどう見えるか。確かめたのは、裏の会話への
|
|
108
|
+
完了通知と、`claude -p --resume` の入力。
|
|
109
|
+
- ほかの会話から届いた文(`SendMessage`)を止めた時に、送り手へ何が返るか。
|
|
110
|
+
- 本物の自動継続で、裏の作業を持つ会話が引き継がれた時の、後継の現在地の文。単体試験と、読み手の実機の確かめまで。
|
|
111
|
+
入れた後の追跡で読む。
|
package/package.json
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* 状態は ~/.throughline/claude-auto-handoff/ のファイルに持つ(schema は変えない)。
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
25
25
|
import { homedir } from 'node:os';
|
|
26
26
|
import { dirname, join, posix, resolve, win32 } from 'node:path';
|
|
27
27
|
import { randomUUID } from 'node:crypto';
|
|
@@ -575,6 +575,60 @@ function readInFlightSteps(transcriptPath, stoppedToolUseId, projectPath) {
|
|
|
575
575
|
};
|
|
576
576
|
}
|
|
577
577
|
|
|
578
|
+
const BACKGROUND_TASKS_KEPT = 10;
|
|
579
|
+
const TASK_NOTIFICATION_HEAD = /^\s*<task-notification>/;
|
|
580
|
+
const BACKGROUND_OUTPUT_FILE = /Output is being written to: (.+?)(?:\. |\.$|$)/m;
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* 会話が裏で起こして、終わった通知がまだ来ていない作業(新しい順)(ADR 0062)。
|
|
584
|
+
*
|
|
585
|
+
* 裏で起こした道具は、結果の行に host が `toolUseResult.backgroundTaskId` を書く。終わると、完了通知
|
|
586
|
+
* (`<task-notification>` に `<task-id>` と `<status>`)が、user 行か、ターンの途中なら `queued_command` で届く。
|
|
587
|
+
* 引き継いだ後は、その通知が旧い会話へ届く。後継は、出力のファイルを読めば結果を確かめられる。
|
|
588
|
+
*
|
|
589
|
+
* @returns {{tasks: Array<{task_id: string, name: string, target: string, output_file: string|null}>, total: number}}
|
|
590
|
+
*/
|
|
591
|
+
export function readRunningBackgroundTasks(transcriptPath, projectPath = null) {
|
|
592
|
+
const toolById = new Map();
|
|
593
|
+
const started = new Map();
|
|
594
|
+
const finished = new Set();
|
|
595
|
+
const noteFinished = (text) => {
|
|
596
|
+
if (typeof text !== 'string' || !TASK_NOTIFICATION_HEAD.test(text) || !/<status>[^<]+<\/status>/.test(text)) return;
|
|
597
|
+
const id = /<task-id>([^<]+)<\/task-id>/.exec(text)?.[1];
|
|
598
|
+
if (id) finished.add(id.trim());
|
|
599
|
+
};
|
|
600
|
+
for (const entry of readRawEntries(transcriptPath)) {
|
|
601
|
+
if (entry?.isSidechain === true) continue;
|
|
602
|
+
if (entry?.type === 'attachment') {
|
|
603
|
+
if (entry.attachment?.type === 'queued_command') noteFinished(entry.attachment.prompt);
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
const content = entry?.message?.content;
|
|
607
|
+
if (entry?.type === 'assistant' && Array.isArray(content)) {
|
|
608
|
+
for (const block of content) {
|
|
609
|
+
if (block?.type === 'tool_use' && typeof block.id === 'string') {
|
|
610
|
+
toolById.set(block.id, { name: block.name ?? 'unknown', target: toolTarget(block.input, projectPath) });
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
} else if (entry?.type === 'user') {
|
|
614
|
+
if (typeof content === 'string') { noteFinished(content); continue; }
|
|
615
|
+
if (!Array.isArray(content)) continue;
|
|
616
|
+
const taskId = entry.toolUseResult?.backgroundTaskId;
|
|
617
|
+
for (const block of content) {
|
|
618
|
+
if (block?.type === 'text') noteFinished(block.text);
|
|
619
|
+
if (block?.type !== 'tool_result' || typeof taskId !== 'string' || !taskId) continue;
|
|
620
|
+
const text = typeof block.content === 'string' ? block.content
|
|
621
|
+
: Array.isArray(block.content) ? block.content.map(part => (typeof part?.text === 'string' ? part.text : '')).join('') : '';
|
|
622
|
+
const tool = toolById.get(block.tool_use_id) ?? { name: 'unknown', target: '' };
|
|
623
|
+
started.set(taskId, { task_id: taskId, name: tool.name, target: tool.target,
|
|
624
|
+
output_file: BACKGROUND_OUTPUT_FILE.exec(text)?.[1]?.trim() || null });
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
const running = [...started.values()].filter(task => !finished.has(task.task_id)).reverse();
|
|
629
|
+
return { tasks: running.slice(0, BACKGROUND_TASKS_KEPT), total: running.length };
|
|
630
|
+
}
|
|
631
|
+
|
|
578
632
|
/**
|
|
579
633
|
* 止めた時点の作業途中のターン。後継の会話が止められた時は、最後の user 発言が前の引き継ぎの
|
|
580
634
|
* 継続の指示になっている。その時は、前の引き継ぎが運んだ元の依頼を引き続き運ぶ。
|
|
@@ -592,6 +646,7 @@ function snapshotInFlight(record, dir) {
|
|
|
592
646
|
user: carried ?? { content: latest.user.content, timestamp: latest.user.timestamp },
|
|
593
647
|
last_fragment: lastFragment ? { content: lastFragment.content, timestamp: lastFragment.timestamp } : null,
|
|
594
648
|
...readInFlightSteps(record.transcript_path, record.stopped_tool_use_id, record.project_path),
|
|
649
|
+
background: readRunningBackgroundTasks(record.transcript_path, record.project_path),
|
|
595
650
|
turn: null,
|
|
596
651
|
};
|
|
597
652
|
}
|
|
@@ -644,9 +699,37 @@ function withdrawRecord(sessionId, dir) {
|
|
|
644
699
|
rmSync(join(dir, `${sessionId}.claim`), { force: true });
|
|
645
700
|
}
|
|
646
701
|
|
|
647
|
-
|
|
702
|
+
/**
|
|
703
|
+
* 引き継ぎ済みの会話から見た、今の続きの会話。後継がさらに引き継がれていれば、その先を返す。
|
|
704
|
+
* たどれない時は、記録にある直接の後継。
|
|
705
|
+
*
|
|
706
|
+
* @returns {{shortId: string|null, sessionId: string|null}}
|
|
707
|
+
*/
|
|
708
|
+
function currentSuccessor(record, dir) {
|
|
709
|
+
try {
|
|
710
|
+
const resolved = resolveClaudeAutoHandoffSuccessor(record.source_session_id, { dir });
|
|
711
|
+
if (resolved.handed_off && resolved.current_session_id !== record.source_session_id) {
|
|
712
|
+
return { shortId: resolved.current_session_id.slice(0, 8), sessionId: resolved.current_session_id };
|
|
713
|
+
}
|
|
714
|
+
} catch { /* 記録を読めない時は、直接の後継を示す */ }
|
|
715
|
+
return { shortId: record.successor?.short_id ?? null,
|
|
716
|
+
sessionId: validSessionId(record.successor?.session_id) ? record.successor.session_id : null };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* 引き継ぎ済みの会話で、道具を断る時と入力を止める時に見せる文。
|
|
721
|
+
* `claude rm` で一覧から消した後継は `claude attach` では開けない(記録は残る)。その時に開ける命令も添える。
|
|
722
|
+
*/
|
|
723
|
+
function handedOffReason(record, dir) {
|
|
724
|
+
const { shortId, sessionId } = currentSuccessor(record, dir);
|
|
725
|
+
return `この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: ${record.handoff_id})。` +
|
|
726
|
+
`続きは後継の会話で行ってください: claude attach ${shortId}` +
|
|
727
|
+
(sessionId ? `(一覧に無い時は claude --resume ${sessionId})` : '');
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function stopOutput(record, dir) {
|
|
648
731
|
const reason = record.successor?.short_id
|
|
649
|
-
?
|
|
732
|
+
? handedOffReason(record, dir)
|
|
650
733
|
: `Throughlineが、自動圧縮の代わりに新しい会話へ引き継ぎます(引き継ぎID: ${record.handoff_id})。` +
|
|
651
734
|
(record.desktop?.wanted
|
|
652
735
|
? '後継の会話は裏で作業を続け、そのターンを終えた時にClaude Desktopへ開きます。途中の様子は `claude agents` の一覧で見られます。'
|
|
@@ -659,6 +742,39 @@ function stopOutput(record) {
|
|
|
659
742
|
};
|
|
660
743
|
}
|
|
661
744
|
|
|
745
|
+
/** 引き継ぎ済みの会話へ届いた入力を止めた記録。入力の本文は書かない。 */
|
|
746
|
+
function recordClaudeRedirect(entry, dir) {
|
|
747
|
+
try {
|
|
748
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
749
|
+
appendFileSync(join(dir, 'redirects.jsonl'), JSON.stringify(entry) + '\n', { mode: 0o600 });
|
|
750
|
+
} catch { /* 記録を書けなくても、入力は止める */ }
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* UserPromptSubmit hook から呼ぶ (ADR 0062)。引き継ぎ済みで、後継が残っている会話へ届いた入力を、モデルを動かさずに止める。
|
|
755
|
+
*
|
|
756
|
+
* 作業は後継が持っている。旧い会話は道具を使えないので、届いた入力に文で答える事しかできない。人が打った文は、
|
|
757
|
+
* 後継の場所と一緒に打った人へ返す(Claude Code が、止めた理由と元の文を見せる)。裏の作業の完了通知も、ここで止める。
|
|
758
|
+
* 止めないと、文脈がほぼ一杯の旧い会話で、通知のたびにモデルが1回動く。
|
|
759
|
+
*
|
|
760
|
+
* 記録が無い会話、後継がまだ立っていない会話(予約・立ち上げ中・失敗)、後継が無くなった会話、subagent の中の入力には
|
|
761
|
+
* 何もしない(null)。Aiterm が起こした席の会話(ADR 0057)も止めない。席を外す前の版が引き継いだ席が残っていても、
|
|
762
|
+
* 呼び手は席の番の終わりを待っている。止めると番が始まらず、呼び手は答えを受け取れない。
|
|
763
|
+
*
|
|
764
|
+
* @returns {{output: {decision: 'block', reason: string}, kind: 'prompt'|'task-notification', handoffId: string, successorShortId: string|null}|null}
|
|
765
|
+
*/
|
|
766
|
+
export function redirectClaudeHandedOffPrompt({ payload, now = Date.now(), dir = claudeAutoHandoffDir(), env = process.env }) {
|
|
767
|
+
const sessionId = payload?.session_id;
|
|
768
|
+
if (!validSessionId(sessionId) || payload.agent_id || isAitermSeatConversation(env, sessionId)) return null;
|
|
769
|
+
const record = readClaudeAutoHandoff(sessionId, dir);
|
|
770
|
+
if (!record || !successorRemains(record)) return null;
|
|
771
|
+
const successorShortId = currentSuccessor(record, dir).shortId;
|
|
772
|
+
const kind = typeof payload.prompt === 'string' && /^\s*<task-notification>/.test(payload.prompt) ? 'task-notification' : 'prompt';
|
|
773
|
+
recordClaudeRedirect({ at: new Date(now).toISOString(), source_session_id: sessionId, handoff_id: record.handoff_id,
|
|
774
|
+
successor_short_id: successorShortId, kind }, dir);
|
|
775
|
+
return { output: { decision: 'block', reason: handedOffReason(record, dir) }, kind, handoffId: record.handoff_id, successorShortId };
|
|
776
|
+
}
|
|
777
|
+
|
|
662
778
|
/**
|
|
663
779
|
* PreToolUse hook の本体。引き継ぎの記録がある会話の道具を、実行させずに止める。
|
|
664
780
|
* 最初に止めた hook だけが、止めた時点の依頼と設定を記録へ写し、後継を立てる worker を起動する。
|
|
@@ -712,7 +828,7 @@ export async function stopClaudeTurnForHandoff({
|
|
|
712
828
|
}
|
|
713
829
|
}
|
|
714
830
|
}
|
|
715
|
-
return stopOutput(record);
|
|
831
|
+
return stopOutput(record, dir);
|
|
716
832
|
}
|
|
717
833
|
|
|
718
834
|
/**
|
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
findClaudeAutoContinuationSource,
|
|
23
23
|
resolveClaudeAutoHandoffSuccessor,
|
|
24
24
|
isAitermSeatConversation,
|
|
25
|
+
redirectClaudeHandedOffPrompt,
|
|
26
|
+
readRunningBackgroundTasks,
|
|
25
27
|
} from './claude-auto-handoff.mjs';
|
|
26
28
|
import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
|
|
27
29
|
|
|
@@ -583,6 +585,7 @@ test('worker: 止めた時点の依頼とモデルを transcript から写し、
|
|
|
583
585
|
earlier_steps: null,
|
|
584
586
|
stopped_tools: [{ name: 'Read', target: '' }],
|
|
585
587
|
stopped_tools_total: 1,
|
|
588
|
+
background: { tasks: [], total: 0 },
|
|
586
589
|
turn: null,
|
|
587
590
|
}, '止めた道具の直前の発言まで出そろってから写す');
|
|
588
591
|
|
|
@@ -603,6 +606,7 @@ test('worker: 止めた時点の依頼とモデルを transcript から写し、
|
|
|
603
606
|
earlier_steps: null,
|
|
604
607
|
stopped_tools: [{ name: 'Read', target: '' }],
|
|
605
608
|
stopped_tools_total: 1,
|
|
609
|
+
background: { tasks: [], total: 0 },
|
|
606
610
|
turn: null,
|
|
607
611
|
});
|
|
608
612
|
assert.equal(second.calls[0][1], first.calls[0][1], '引き継ぎを重ねても、project 名と印は積み重ならない');
|
|
@@ -1583,3 +1587,105 @@ test('hook: 自分の失敗では圧縮も道具も止めず(exit code 1)、
|
|
|
1583
1587
|
assert.match(log, /Missing session_id in PreCompact payload/);
|
|
1584
1588
|
assert.match(log, /HOOK_PRE_TOOL_USE_FAILED/);
|
|
1585
1589
|
}));
|
|
1590
|
+
|
|
1591
|
+
// ---- 引き継ぎ済みの会話へ届いた入力 (ADR 0062) ----
|
|
1592
|
+
|
|
1593
|
+
test('引き継ぎ済みの会話へ届いた入力: 後継が残っている時だけ、モデルを動かさずに止めて、今の続きの場所を返す', () => withDir(async dir => {
|
|
1594
|
+
const projects = join(dir, 'claude', 'projects', '-work-app');
|
|
1595
|
+
mkdirSync(projects, { recursive: true });
|
|
1596
|
+
const a = 'aaaaaaaa-0000-4000-8000-000000000031', b = 'bbbbbbbb-0000-4000-8000-000000000032', c = 'cccccccc-0000-4000-8000-000000000033';
|
|
1597
|
+
const record = (id, fields) => writeFileSync(join(dir, `${id}.json`), JSON.stringify({ schema: 'throughline.claude-auto-handoff.v2',
|
|
1598
|
+
handoff_id: `h-${id.slice(0, 8)}`, source_session_id: id, project_path: '/work/app', transcript_path: join(projects, `${id}.jsonl`),
|
|
1599
|
+
requested_at: 1_000, updated_at: 2_000, error_code: null, ...fields }));
|
|
1600
|
+
const submit = (id, prompt, extra = {}, env = {}) => redirectClaudeHandedOffPrompt({ payload: { session_id: id, prompt, ...extra }, now: 5_000, dir, env });
|
|
1601
|
+
const redirects = () => existsSync(join(dir, 'redirects.jsonl'))
|
|
1602
|
+
? readFileSync(join(dir, 'redirects.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line)) : [];
|
|
1603
|
+
|
|
1604
|
+
assert.equal(submit(a, '依頼'), null, '記録が無い会話');
|
|
1605
|
+
for (const state of ['requested', 'stopped', 'launching', 'failed']) {
|
|
1606
|
+
record(a, { state, successor: null });
|
|
1607
|
+
assert.equal(submit(a, '依頼'), null, `後継がまだ立っていない(${state})`);
|
|
1608
|
+
}
|
|
1609
|
+
record(a, { state: 'sent', successor: { short_id: 'bbbbbbbb', session_id: b } });
|
|
1610
|
+
assert.equal(submit(a, '依頼'), null, '後継の transcript が無い(後継が消された)会話は止めない');
|
|
1611
|
+
assert.deepEqual(redirects(), []);
|
|
1612
|
+
|
|
1613
|
+
writeFileSync(join(projects, `${b}.jsonl`), '');
|
|
1614
|
+
const human = submit(a, '旧い会話へ打った文');
|
|
1615
|
+
assert.deepEqual(human, { output: { decision: 'block',
|
|
1616
|
+
reason: 'この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: h-aaaaaaaa)。続きは後継の会話で行ってください: claude attach bbbbbbbb(一覧に無い時は claude --resume ' + b + ')' },
|
|
1617
|
+
kind: 'prompt', handoffId: 'h-aaaaaaaa', successorShortId: 'bbbbbbbb' });
|
|
1618
|
+
assert.equal(submit(a, '<task-notification>\n<task-id>b1</task-id>\n<status>completed</status>\n</task-notification>').kind, 'task-notification');
|
|
1619
|
+
assert.equal(submit(a, '依頼', { agent_id: 'agent-1' }), null, 'subagent の中の入力');
|
|
1620
|
+
assert.equal(submit('../escape', '依頼'), null);
|
|
1621
|
+
assert.equal(submit(a, '依頼', {}, { AITERM_AGENT_LAUNCH_ID: 'abc', AITERM_AGENT_VENDOR_SESSION_ID: a }), null, 'Aiterm の席の会話は止めない');
|
|
1622
|
+
assert.equal(submit(a, '依頼', {}, { AITERM_AGENT_LAUNCH_ID: 'abc', AITERM_AGENT_VENDOR_SESSION_ID: b }).kind, 'prompt', '席の印だけを継いだ会話は止める');
|
|
1623
|
+
assert.deepEqual(redirects(), [
|
|
1624
|
+
{ at: '1970-01-01T00:00:05.000Z', source_session_id: a, handoff_id: 'h-aaaaaaaa', successor_short_id: 'bbbbbbbb', kind: 'prompt' },
|
|
1625
|
+
{ at: '1970-01-01T00:00:05.000Z', source_session_id: a, handoff_id: 'h-aaaaaaaa', successor_short_id: 'bbbbbbbb', kind: 'task-notification' },
|
|
1626
|
+
{ at: '1970-01-01T00:00:05.000Z', source_session_id: a, handoff_id: 'h-aaaaaaaa', successor_short_id: 'bbbbbbbb', kind: 'prompt' },
|
|
1627
|
+
], '止めた記録に入力の本文は書かない');
|
|
1628
|
+
|
|
1629
|
+
// 後継がさらに引き継がれている時は、その先を示す。道具を断る時の文も同じ
|
|
1630
|
+
record(b, { state: 'unknown', successor: { short_id: 'cccccccc', session_id: null } });
|
|
1631
|
+
writeFileSync(join(projects, `${c}.jsonl`), '');
|
|
1632
|
+
assert.match(submit(a, '依頼').output.reason, /claude attach cccccccc(一覧に無い時は claude --resume cccccccc-0000-4000-8000-000000000033)$/);
|
|
1633
|
+
assert.match(submit(b, '依頼').output.reason, /引き継ぎID: h-bbbbbbbb.*claude attach cccccccc(一覧に無い時は claude --resume cccccccc-0000-4000-8000-000000000033)$/);
|
|
1634
|
+
const denied = await stopClaudeTurnForHandoff({ payload: { session_id: a, cwd: '/work/app' }, dir,
|
|
1635
|
+
launchWorker: async () => { throw new Error('must not launch'); } });
|
|
1636
|
+
assert.match(denied.stopReason, /引き継ぎID: h-aaaaaaaa.*claude attach cccccccc(一覧に無い時は claude --resume cccccccc-0000-4000-8000-000000000033)$/);
|
|
1637
|
+
// 今の続きの会話(記録が無い)は止めない
|
|
1638
|
+
assert.equal(submit(c, '依頼'), null);
|
|
1639
|
+
}));
|
|
1640
|
+
|
|
1641
|
+
test('公開CLI: prompt-submit は、引き継ぎ済みの会話の入力を止める出力を返す。ほかの会話には何も返さない', () => withDir(home => {
|
|
1642
|
+
const dir = join(home, '.throughline', 'claude-auto-handoff');
|
|
1643
|
+
const projects = join(home, '.claude', 'projects', '-work-app');
|
|
1644
|
+
mkdirSync(dir, { recursive: true }); mkdirSync(projects, { recursive: true });
|
|
1645
|
+
writeFileSync(join(projects, `${SUCCESSOR}.jsonl`), '');
|
|
1646
|
+
writeFileSync(join(dir, `${SESSION}.json`), JSON.stringify({ schema: 'throughline.claude-auto-handoff.v2', handoff_id: 'h-1',
|
|
1647
|
+
source_session_id: SESSION, project_path: '/work/app', transcript_path: join(projects, `${SESSION}.jsonl`), state: 'sent',
|
|
1648
|
+
requested_at: 1_000, updated_at: 2_000, error_code: null, successor: { short_id: SUCCESSOR.slice(0, 8), session_id: SUCCESSOR } }));
|
|
1649
|
+
const submit = (sessionId, prompt) => cli(home, ['prompt-submit'], JSON.stringify({ session_id: sessionId, cwd: home,
|
|
1650
|
+
hook_event_name: 'UserPromptSubmit', prompt, transcript_path: join(projects, `${sessionId}.jsonl`) }), fakeClaudePath(home));
|
|
1651
|
+
|
|
1652
|
+
const blocked = submit(SESSION, '旧い会話へ打った文');
|
|
1653
|
+
assert.equal(blocked.status, 0, blocked.stderr);
|
|
1654
|
+
assert.deepEqual(JSON.parse(blocked.stdout), { decision: 'block',
|
|
1655
|
+
reason: `この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: h-1)。続きは後継の会話で行ってください: claude attach ${SUCCESSOR.slice(0, 8)}(一覧に無い時は claude --resume ${SUCCESSOR})` });
|
|
1656
|
+
assert.equal(existsSync(join(home, '.throughline', 'throughline.db')), false, '止める時は DB を開かない');
|
|
1657
|
+
const logged = readFileSync(join(home, '.throughline', 'logs', 'inheritance-decision.log'), 'utf8').trim().split('\n').map(line => JSON.parse(line));
|
|
1658
|
+
assert.deepEqual(logged.map(row => [row.phase, row.session_id, row.handoff_id, row.successor_short_id, row.input_kind]),
|
|
1659
|
+
[['prompt-redirect', SESSION, 'h-1', SUCCESSOR.slice(0, 8), 'prompt']]);
|
|
1660
|
+
assert.equal(JSON.stringify(logged).includes('旧い会話へ打った文'), false, '判定の記録に本文は書かない');
|
|
1661
|
+
|
|
1662
|
+
const passed = submit(SUCCESSOR, '後継へ打った文');
|
|
1663
|
+
assert.equal(passed.status, 0, passed.stderr);
|
|
1664
|
+
assert.equal(passed.stdout, '', '記録が無い会話の入力は、今までどおり通す');
|
|
1665
|
+
}));
|
|
1666
|
+
|
|
1667
|
+
test('裏で動いている作業: 起こした道具から、終わった通知が届いた物を除く(user 行でも、途中の入力でも)', () => withDir(dir => {
|
|
1668
|
+
const transcript = join(dir, 't.jsonl');
|
|
1669
|
+
const started = (toolUseId, taskId, output) => ({ type: 'user', toolUseResult: { stdout: '', stderr: '', backgroundTaskId: taskId },
|
|
1670
|
+
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolUseId,
|
|
1671
|
+
content: `Command running in background with ID: ${taskId}. Output is being written to: ${output}. You will be notified when it completes.` }] } });
|
|
1672
|
+
const notice = (taskId, status) => `<task-notification>\n<task-id>${taskId}</task-id>\n<tool-use-id>x</tool-use-id>\n<output-file>/o</output-file>\n` +
|
|
1673
|
+
(status ? `<status>${status}</status>\n` : '<event>tick</event>\n') + '<summary>s</summary>\n</task-notification>';
|
|
1674
|
+
writeFileSync(transcript, jsonl([
|
|
1675
|
+
user('依頼', '2026-10-14T00:00:00.000Z', { origin: { kind: 'human' } }),
|
|
1676
|
+
toolUse('t1', 'Bash', { command: 'npm run build', run_in_background: true }), started('t1', 'b1', '/tmp/tasks/b1.output'),
|
|
1677
|
+
toolUse('t2', 'Bash', { command: 'npm test', run_in_background: true }), started('t2', 'b2', 'C:\\Users\\k\\AppData\\Local\\Temp\\claude tasks\\b2.output'),
|
|
1678
|
+
toolUse('t3', 'Bash', { command: 'sleep 600', run_in_background: true }), started('t3', 'b3', '/tmp/tasks/b3.output'),
|
|
1679
|
+
toolUse('t4', 'Monitor', { description: 'CI を見る' }), started('t4', 'b4', '/tmp/tasks/b4.output'),
|
|
1680
|
+
toolUse('t5', 'Read', { file_path: '/work/app/a.mjs' }), toolResult('t5', 'ok'),
|
|
1681
|
+
user(notice('b1', 'completed'), '2026-10-14T00:01:00.000Z', { origin: { kind: 'task-notification' } }),
|
|
1682
|
+
{ type: 'attachment', attachment: { type: 'queued_command', prompt: notice('b3', 'failed'), origin: { kind: 'task-notification' } } },
|
|
1683
|
+
user(notice('b4', null), '2026-10-14T00:02:00.000Z', { origin: { kind: 'task-notification' } }),
|
|
1684
|
+
{ ...started('t9', 'b9', '/tmp/tasks/b9.output'), isSidechain: true },
|
|
1685
|
+
]));
|
|
1686
|
+
assert.deepEqual(readRunningBackgroundTasks(transcript, '/work/app'), { total: 2, tasks: [
|
|
1687
|
+
{ task_id: 'b4', name: 'Monitor', target: 'CI を見る', output_file: '/tmp/tasks/b4.output' },
|
|
1688
|
+
{ task_id: 'b2', name: 'Bash', target: 'npm test', output_file: 'C:\\Users\\k\\AppData\\Local\\Temp\\claude tasks\\b2.output' },
|
|
1689
|
+
] }, '途中の知らせ(status なし)が来ただけの作業は、まだ動いている');
|
|
1690
|
+
assert.deepEqual(readRunningBackgroundTasks(join(dir, 'none.jsonl')), { tasks: [], total: 0 });
|
|
1691
|
+
}));
|
|
@@ -383,7 +383,7 @@ function compactedPayloadToUserMessages(payload, timestamp) {
|
|
|
383
383
|
.map((item) => ({
|
|
384
384
|
time: timestamp ?? null,
|
|
385
385
|
role: 'user',
|
|
386
|
-
text:
|
|
386
|
+
text: normalizeUserMessageText(messageContentToText(item.content)),
|
|
387
387
|
}))
|
|
388
388
|
.filter((message) => message.text.length > 0);
|
|
389
389
|
}
|
|
@@ -485,7 +485,7 @@ function removeRolledBackTurns(activeTurns, count) {
|
|
|
485
485
|
|
|
486
486
|
function eventPayloadToMemoryMessage(payload, timestamp) {
|
|
487
487
|
if (payload.type === 'user_message') {
|
|
488
|
-
const text =
|
|
488
|
+
const text = normalizeUserMessageText(payload.message);
|
|
489
489
|
if (!text || shouldSkipUserMessage(text)) return null;
|
|
490
490
|
return {
|
|
491
491
|
time: timestamp ?? null,
|
|
@@ -509,7 +509,8 @@ function eventPayloadToMemoryMessage(payload, timestamp) {
|
|
|
509
509
|
|
|
510
510
|
function responseItemToConversationMessage(payload, timestamp) {
|
|
511
511
|
if (payload?.type !== 'message' || !['user', 'assistant', 'developer'].includes(payload.role)) return null;
|
|
512
|
-
const
|
|
512
|
+
const rawText = messageContentToText(payload.content);
|
|
513
|
+
const text = payload.role === 'user' ? normalizeUserMessageText(rawText) : normalizeMessageText(rawText);
|
|
513
514
|
if (!text) return null;
|
|
514
515
|
if (payload.role === 'developer' && !isThroughlineInjectedDeveloperMemory(text)) return null;
|
|
515
516
|
if (payload.role === 'user' && shouldSkipUserMessage(text)) return null;
|
|
@@ -551,6 +552,35 @@ function normalizeMessageText(value) {
|
|
|
551
552
|
return value.replace(/\s+/g, ' ').trim();
|
|
552
553
|
}
|
|
553
554
|
|
|
555
|
+
/** user の発言の正規化。空白をまとめた後、Codex が自分で書いた文脈の定型文を落とす (ADR 0061)。 */
|
|
556
|
+
function normalizeUserMessageText(value) {
|
|
557
|
+
return compactCodexInternalContext(normalizeMessageText(value));
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const CODEX_INTERNAL_CONTEXT_OPEN = /^<codex_internal_context(?:\s[^>]*)?>/;
|
|
561
|
+
const CODEX_INTERNAL_CONTEXT_CLOSE = '</codex_internal_context>';
|
|
562
|
+
const CODEX_INTERNAL_OBJECTIVE = /<objective>[\s\S]*?<\/objective>/;
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Codex が自分で起こしたターンの入力(ゴールの続行の指示など)は、`<codex_internal_context …>` で包まれて
|
|
566
|
+
* user の発言として rollout に入る。中身は毎回ほぼ同じ約 6,000 字の定型文で、人の発言ではない。記憶には、
|
|
567
|
+
* 開く印(`source` が何のターンかを表す)、利用者が決めたゴールの文(`<objective>`)、閉じる印だけを残す。
|
|
568
|
+
* 閉じる印の後ろに文があれば、それは残す。閉じる印が無い時は、形が分からないので何も落とさない。
|
|
569
|
+
* @param {string} text 空白をまとめた後の user の発言
|
|
570
|
+
* @returns {string}
|
|
571
|
+
*/
|
|
572
|
+
export function compactCodexInternalContext(text) {
|
|
573
|
+
if (typeof text !== 'string') return text;
|
|
574
|
+
const open = CODEX_INTERNAL_CONTEXT_OPEN.exec(text);
|
|
575
|
+
if (!open) return text;
|
|
576
|
+
const close = text.indexOf(CODEX_INTERNAL_CONTEXT_CLOSE, open[0].length);
|
|
577
|
+
if (close < 0) return text;
|
|
578
|
+
const objective = CODEX_INTERNAL_OBJECTIVE.exec(text.slice(open[0].length, close))?.[0] ?? null;
|
|
579
|
+
const tail = text.slice(close + CODEX_INTERNAL_CONTEXT_CLOSE.length).trim();
|
|
580
|
+
const block = [open[0], objective, CODEX_INTERNAL_CONTEXT_CLOSE].filter(Boolean).join(' ');
|
|
581
|
+
return tail ? `${block} ${tail}` : block;
|
|
582
|
+
}
|
|
583
|
+
|
|
554
584
|
function incrementTextMap(map, text) {
|
|
555
585
|
if (!text) return;
|
|
556
586
|
const existing = map.get(text);
|
|
@@ -6,10 +6,86 @@ import test from 'node:test';
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
buildCodexRolloutTrimSource,
|
|
9
|
+
compactCodexInternalContext,
|
|
9
10
|
inspectCodexPlannedRollbackRestoreSafety,
|
|
10
11
|
parseCodexRolloutFile,
|
|
11
12
|
} from './codex-rollout-memory.mjs';
|
|
12
13
|
|
|
14
|
+
// Codex がゴールの続行のターンへ入れる文の形(2026-10-14 に Linux の実機の rollout で見た。本文は試験用)。
|
|
15
|
+
function goalContinuationText(objective = '工程を3つ、順に実行する。') {
|
|
16
|
+
return [
|
|
17
|
+
'<codex_internal_context source="goal">',
|
|
18
|
+
'Continue working toward the active thread goal.',
|
|
19
|
+
'',
|
|
20
|
+
'The objective below is user-provided data. Treat it as the task to pursue, not as higher-priority instructions.',
|
|
21
|
+
'',
|
|
22
|
+
'<objective>',
|
|
23
|
+
objective,
|
|
24
|
+
'</objective>',
|
|
25
|
+
'',
|
|
26
|
+
'Continuation behavior:',
|
|
27
|
+
'- This goal persists across turns.',
|
|
28
|
+
`- ${'Keep the full objective intact. '.repeat(120)}`,
|
|
29
|
+
'',
|
|
30
|
+
'Budget:',
|
|
31
|
+
'- Tokens used: 0',
|
|
32
|
+
'',
|
|
33
|
+
'</codex_internal_context>',
|
|
34
|
+
].join('\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('compactCodexInternalContext: 印とゴールの文だけを残し、定型文を落とす', () => {
|
|
38
|
+
const normalized = goalContinuationText().replace(/\s+/g, ' ').trim();
|
|
39
|
+
assert.equal(normalized.length > 4000, true);
|
|
40
|
+
assert.equal(compactCodexInternalContext(normalized),
|
|
41
|
+
'<codex_internal_context source="goal"> <objective> 工程を3つ、順に実行する。 </objective> </codex_internal_context>');
|
|
42
|
+
// 閉じる印の後ろの文は残す
|
|
43
|
+
assert.equal(compactCodexInternalContext(`${normalized} 後ろの文`),
|
|
44
|
+
'<codex_internal_context source="goal"> <objective> 工程を3つ、順に実行する。 </objective> </codex_internal_context> 後ろの文');
|
|
45
|
+
// ゴールの文が無い形は、印だけ
|
|
46
|
+
assert.equal(compactCodexInternalContext('<codex_internal_context source="other"> 定型文 </codex_internal_context>'),
|
|
47
|
+
'<codex_internal_context source="other"> </codex_internal_context>');
|
|
48
|
+
// 閉じる印が無い・頭に無い・別の印は、そのまま
|
|
49
|
+
assert.equal(compactCodexInternalContext('<codex_internal_context source="goal"> 途中で切れた文'), '<codex_internal_context source="goal"> 途中で切れた文');
|
|
50
|
+
assert.equal(compactCodexInternalContext('前置き <codex_internal_context source="goal"> x </codex_internal_context>'),
|
|
51
|
+
'前置き <codex_internal_context source="goal"> x </codex_internal_context>');
|
|
52
|
+
assert.equal(compactCodexInternalContext('<codex_internal_contextual> x </codex_internal_context>'), '<codex_internal_contextual> x </codex_internal_context>');
|
|
53
|
+
assert.equal(compactCodexInternalContext('普通の依頼'), '普通の依頼');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('parseCodexRolloutFile: ゴールの続行のターンは、定型文を落とした短い行になる。人の依頼と道具はそのまま', () => {
|
|
57
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
|
|
58
|
+
const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
|
|
59
|
+
try {
|
|
60
|
+
const goalItem = () => responseItem({ type: 'message', role: 'user', content: [{ type: 'input_text', text: goalContinuationText() }] });
|
|
61
|
+
const rollout = writeRollout(home, {
|
|
62
|
+
id: '019dfaba-f87e-7f41-a144-d5ca7c6dd7fa',
|
|
63
|
+
cwd: project,
|
|
64
|
+
events: [
|
|
65
|
+
event('user_message', { message: '最初の依頼' }),
|
|
66
|
+
event('task_started'),
|
|
67
|
+
event('agent_message', { message: '最初の回答' }),
|
|
68
|
+
event('task_complete'),
|
|
69
|
+
event('task_started'),
|
|
70
|
+
goalItem(),
|
|
71
|
+
event('agent_message', { message: 'ゴールの続きの報告' }),
|
|
72
|
+
event('task_complete'),
|
|
73
|
+
event('task_started'),
|
|
74
|
+
goalItem(),
|
|
75
|
+
],
|
|
76
|
+
});
|
|
77
|
+
const parsed = parseCodexRolloutFile(rollout);
|
|
78
|
+
assert.deepEqual(parsed.activeTurns.map((turn) => turn.messages.map((message) => [message.role, message.text.length < 200 ? message.text : `long:${message.text.length}`])), [
|
|
79
|
+
[['user', '最初の依頼'], ['assistant', '最初の回答']],
|
|
80
|
+
[['user', '<codex_internal_context source="goal"> <objective> 工程を3つ、順に実行する。 </objective> </codex_internal_context>'], ['assistant', 'ゴールの続きの報告']],
|
|
81
|
+
[['user', '<codex_internal_context source="goal"> <objective> 工程を3つ、順に実行する。 </objective> </codex_internal_context>']],
|
|
82
|
+
]);
|
|
83
|
+
} finally {
|
|
84
|
+
rmSync(home, { recursive: true, force: true });
|
|
85
|
+
rmSync(project, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
13
89
|
test('parseCodexRolloutFile: applies rollback events before rendering active work', () => {
|
|
14
90
|
const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
|
|
15
91
|
const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
|
package/src/prompt-submit.mjs
CHANGED
|
@@ -42,6 +42,10 @@ import { pathToFileURL } from 'node:url';
|
|
|
42
42
|
import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
43
43
|
import { logHookFailure } from './hook-failure-log.mjs';
|
|
44
44
|
import { hostAdapterForSessionId, parseHookPayload } from './hosts/index.mjs';
|
|
45
|
+
import { CLAUDE_HOST } from './hosts/identity.mjs';
|
|
46
|
+
|
|
47
|
+
// 記録のファイル名に使える session id だけを見る(claude-auto-handoff.mjs と同じ形)。
|
|
48
|
+
const CLAUDE_SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
45
49
|
|
|
46
50
|
// Phase 0-5 spike marker (SessionStart の spike-inject.flag とは別)
|
|
47
51
|
const PROMPT_SPIKE_MARKER_PATH = join(homedir(), '.throughline', 'spike-prompt.flag');
|
|
@@ -157,6 +161,29 @@ export async function run() {
|
|
|
157
161
|
const { session_id, cwd, prompt } = payload;
|
|
158
162
|
const hostAdapter = hostAdapterForSessionId(session_id);
|
|
159
163
|
|
|
164
|
+
// 引き継ぎ済みの Claude の会話へ届いた入力は、モデルを動かさずに止めて、後継の場所を返す (ADR 0062)。
|
|
165
|
+
// 記録のファイルが無い会話(ほとんどの会話)では、何も読み込まずに先へ進む。
|
|
166
|
+
if (hostAdapter.host === CLAUDE_HOST && typeof session_id === 'string' && CLAUDE_SESSION_ID_PATTERN.test(session_id) &&
|
|
167
|
+
existsSync(join(homedir(), '.throughline', 'claude-auto-handoff', `${session_id}.json`))) {
|
|
168
|
+
const { redirectClaudeHandedOffPrompt } = await import('./claude-auto-handoff.mjs');
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
const redirect = redirectClaudeHandedOffPrompt({ payload, now });
|
|
171
|
+
if (redirect) {
|
|
172
|
+
logDecision({
|
|
173
|
+
ts: new Date(now).toISOString(),
|
|
174
|
+
phase: 'prompt-redirect',
|
|
175
|
+
session_id,
|
|
176
|
+
project_path: cwd ?? process.cwd(),
|
|
177
|
+
handoff_id: redirect.handoffId,
|
|
178
|
+
successor_short_id: redirect.successorShortId,
|
|
179
|
+
input_kind: redirect.kind,
|
|
180
|
+
});
|
|
181
|
+
process.stdout.write(`${JSON.stringify(redirect.output)}\n`);
|
|
182
|
+
process.exit(0);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
160
187
|
// VSCode 新規プロジェクトへの tasks.json 自動プロビジョニング。
|
|
161
188
|
// SessionStart/Stop に加えここでも呼ぶことで、どれか 1 つでも発火すれば初回メッセージ送信で
|
|
162
189
|
// tasks.json が生える。冪等性は ensureMonitorTaskFile 側で保証。/tl 判定より前に置く。
|
package/src/resume-context.mjs
CHANGED
|
@@ -886,6 +886,7 @@ function renderInFlightSteps(inFlight, detailClock) {
|
|
|
886
886
|
* earlier_steps?: {texts: number, tools: Record<string, number>} | null,
|
|
887
887
|
* stopped_tools?: Array<{name: string, target: string}>,
|
|
888
888
|
* stopped_tools_total?: number,
|
|
889
|
+
* background?: {tasks: Array<{task_id: string, name: string, target: string, output_file: string|null}>, total: number},
|
|
889
890
|
* turn?: {origin_session_id: string, turn_number: number, user_at: number, assistant_at: number|null} | null,
|
|
890
891
|
* } | null,
|
|
891
892
|
* projectPath?: string | null, 前の会話の作業ディレクトリ。依頼の中の相対的な場所の基準として渡す
|
|
@@ -924,6 +925,19 @@ export function buildAutoContinuationContext(
|
|
|
924
925
|
(more > 0 ? `(ほか ${more} 件)` : ''),
|
|
925
926
|
);
|
|
926
927
|
}
|
|
928
|
+
// 前の会話が裏で起こした作業は、前の会話の process の下で動き続ける。終わった通知は前の会話へ届くので、ここで知らせる (ADR 0062)。
|
|
929
|
+
const background = inFlight.background?.tasks ?? [];
|
|
930
|
+
if (background.length > 0) {
|
|
931
|
+
const more = (inFlight.background.total ?? background.length) - background.length;
|
|
932
|
+
anchorLines.push(
|
|
933
|
+
'**前の会話が裏で動かしていた作業(止めた時点で、終わった通知が来ていなかった物)**: ' +
|
|
934
|
+
'終わった通知は、この会話へは届きません。結果が要る時は、出力のファイルを読んで確かめてください。' +
|
|
935
|
+
(more > 0 ? `(ほか ${more} 件)` : ''),
|
|
936
|
+
...background.map((task) =>
|
|
937
|
+
`- ${task.name}${task.target ? ` ${task.target}` : ''}(ID ${task.task_id})` +
|
|
938
|
+
(task.output_file ? ` → 出力: ${task.output_file}` : '')),
|
|
939
|
+
);
|
|
940
|
+
}
|
|
927
941
|
}
|
|
928
942
|
if (!stored && anchorLines.length === 0) return null;
|
|
929
943
|
|
|
@@ -968,6 +968,23 @@ test('buildAutoContinuationContext: 長い依頼は先頭と末尾を残し、
|
|
|
968
968
|
assert.match(result.text, /throughline recall --l2 --session S /);
|
|
969
969
|
});
|
|
970
970
|
|
|
971
|
+
test('buildAutoContinuationContext: 前の会話が裏で動かしていた作業を、出力の場所つきで現在地に載せる', () => {
|
|
972
|
+
const db = makeDb();
|
|
973
|
+
const inFlight = { user: { content: '今の依頼', timestamp: 3_000 }, last_fragment: null, steps: [], earlier_steps: null,
|
|
974
|
+
stopped_tools: [], stopped_tools_total: 0, turn: null };
|
|
975
|
+
const withTasks = buildAutoContinuationContext(db, { sessionId: 'S', inFlight: { ...inFlight, background: { total: 3, tasks: [
|
|
976
|
+
{ task_id: 'b2', name: 'Bash', target: 'npm test', output_file: '/tmp/tasks/b2.output' },
|
|
977
|
+
{ task_id: 'b1', name: 'Monitor', target: '', output_file: null },
|
|
978
|
+
] } } });
|
|
979
|
+
assert.match(withTasks.text, new RegExp(
|
|
980
|
+
'\\*\\*前の会話が裏で動かしていた作業(止めた時点で、終わった通知が来ていなかった物)\\*\\*: ' +
|
|
981
|
+
'終わった通知は、この会話へは届きません。結果が要る時は、出力のファイルを読んで確かめてください。(ほか 1 件)\\n' +
|
|
982
|
+
'- Bash npm test(ID b2) → 出力: /tmp/tasks/b2\\.output\\n- Monitor(ID b1)\\n'));
|
|
983
|
+
for (const background of [undefined, { tasks: [], total: 0 }]) {
|
|
984
|
+
assert.doesNotMatch(buildAutoContinuationContext(db, { sessionId: 'S', inFlight: { ...inFlight, background } }).text, /裏で動かしていた作業/);
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
|
|
971
988
|
test('buildAutoContinuationContext: 止めたターンでここまでにしたことと、実行されなかった道具を現在地に載せ、そのターンをL2に重ねない', () => {
|
|
972
989
|
const db = makeDb();
|
|
973
990
|
insertBody(db, { session: 'S', origin: 'P0', turn: 1, role: 'user', text: '前の依頼', createdAt: 1_000 });
|