throughline 0.14.0 → 0.14.1

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 CHANGED
@@ -10,6 +10,26 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.14.1] — 2026-10-05
14
+
15
+ ### 修正
16
+
17
+ - Claude Codeの自動継続で、後継が完了済みの作業をやり直すことがあった不具合を直す。0.14.0は、止めたターンについて「止める直前の発言」1つしか後継へ渡していなかった。そのターンでそこまでにした発言と道具の呼び出しは、記憶に入っていなかった。macOSの実機(Haiku 4.5)で、後継2つが読み終えたファイルを最初から読み直した。
18
+ - Codexの自動継続と同じく、後継を立てる前に、止めたターンをDBへ取り込む。発言は全部をつないで本文(L2)に、道具の入出力は止めた道具の呼び出しまで詳細(L3)に入れる。後継は`throughline detail HH:MM:SS`で、止めたターンの入出力を取り出せる。0.14.0では、止めたターンの入出力はどこにも残らなかった。
19
+ - 後継へ渡す現在地に、次の2つを足す。
20
+ - **このターンでここまでにしたこと**: 発言と道具の呼び出しを古い順に並べる。作業フォルダの中のファイルは相対で書く。多い時は新しい側から2,400字まで載せ、残りは件数にする。
21
+ - **止める直前に呼ぼうとして、実行されなかった道具**: 圧縮を止めた後の応答が呼んだ道具。同じ応答に並んだ道具は全部ここに入る。
22
+ - 止めたターンは現在地にだけ載せ、直前の対話(L2)の一覧に重ねない。依頼の上限は4,000字から3,500字にする。
23
+
24
+ ### 追加
25
+
26
+ - `throughline auto-handoff status --host claude --json`の各引き継ぎに`in_flight_captured`を足す。止めたターンをDBへ取り込めた時に`true`。取り込めなくても引き継ぎは止めず、依頼とここまでにしたことは後継へ渡る。
27
+
28
+ ### 確認した範囲
29
+
30
+ - macOS 27.0のClaude Code。端末の会話(2.1.289)と、Claude Desktopが同梱する本体(2.1.286)を端末から起動した会話の両方から始めた。直した版で、Haiku 4.5は4回、Opus 5.5は7回、連続で引き継いで作業を完了した。読み直しも読み飛ばしも無く、どの会話でも自動圧縮は走っていない。
31
+ - Claude Desktopの画面から始めた会話、Windows、Fable、subagentが動いている最中の引き継ぎは確かめていない。
32
+
13
33
  ## [0.14.0] — 2026-10-05
14
34
 
15
35
  ### 変更
@@ -1676,7 +1696,8 @@ two attempts, instrument first instead of patching again.
1676
1696
 
1677
1697
  ---
1678
1698
 
1679
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.14.0...HEAD
1699
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.14.1...HEAD
1700
+ [0.14.1]: https://github.com/kitepon/Throughline/compare/v0.14.0...v0.14.1
1680
1701
  [0.14.0]: https://github.com/kitepon/Throughline/compare/v0.13.0...v0.14.0
1681
1702
  [0.13.0]: https://github.com/kitepon/Throughline/compare/v0.12.9...v0.13.0
1682
1703
  [0.12.9]: https://github.com/kitepon/Throughline/compare/v0.12.8...v0.12.9
package/README.ja.md CHANGED
@@ -59,7 +59,7 @@ throughline auto-handoff disable --host claude
59
59
  1. 自動圧縮の直前に、圧縮を止めます。`/tl`と同じ印を残します。
60
60
  2. 旧い会話の次の道具を、実行させずに止めます。旧い会話は止めるだけで、空にしません。
61
61
  3. 同じprojectに、新しいClaudeの会話を裏で立てます(`claude --bg`)。モデル・推論強度・権限は旧い会話から引き継ぎます。
62
- 4. 新しい会話へ継続の指示を1通送ります。届いた時に、止めた時点の依頼と直近の会話の原文を注入します(上限9,500字)。
62
+ 4. 新しい会話へ継続の指示を1通送ります。届いた時に、止めた時点の依頼、そのターンでここまでにしたこと(発言と道具の呼び出し)、実行されなかった道具、直近の会話の原文を注入します(上限9,500字)。止めたターンの道具の入出力は、後継が`throughline detail`で取り出せます。
63
63
 
64
64
  続きは裏の会話で動きます。`claude agents`の一覧と`claude attach <id>`で見られます。Claude Desktopの画面には出ず、後継のターンが終わって入力待ちになった後にDesktopで開けます。手動`/compact`、subagentの中の圧縮、`claude -p`などscriptから起動した会話は対象に含めません。
65
65
 
@@ -69,7 +69,7 @@ throughline auto-handoff status --host claude --operation <handoff-id> --json
69
69
 
70
70
  後継の立ち上げや配送に失敗した時は、固定の理由を記録して止まります。結果が不明な指示は再送しません。圧縮を止めた後にモデルが道具を呼ばずにターンを終えた時は、後継を立てません。印は残るので、1時間以内に開いた新しい会話が記憶を引き継ぎます。
71
71
 
72
- 有効にした端末では、Claudeの道具の呼び出しのたびに`PreToolUse`フックが1回走ります。LinuxのClaude Code 2.1.289で実機確認済みです。macOS・Windows、Claude Desktopから始まる会話は未検証です。[実測と検証範囲](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-05-claude-auto-handoff.md)を参照してください。
72
+ 有効にした端末では、Claudeの道具の呼び出しのたびに`PreToolUse`フックが1回走ります。LinuxとmacOSのClaude Code(2.1.289、Claude Desktop同梱の2.1.286)で実機確認済みです。Windowsと、Claude Desktopの画面から始めた会話は未検証です。[実測と検証範囲](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-05-claude-auto-handoff.md)を参照してください。
73
73
 
74
74
  ## 所有境界
75
75
 
package/README.md CHANGED
@@ -58,7 +58,7 @@ throughline auto-handoff disable --host claude
58
58
  1. 自動圧縮の直前に、圧縮を止めます。`/tl`と同じ印を残します。
59
59
  2. 旧い会話の次の道具を、実行させずに止めます。旧い会話は止めるだけで、空にしません。
60
60
  3. 同じprojectに、新しいClaudeの会話を裏で立てます(`claude --bg`)。モデル・推論強度・権限は旧い会話から引き継ぎます。
61
- 4. 新しい会話へ継続の指示を1通送ります。届いた時に、止めた時点の依頼と直近の会話の原文を注入します(上限9,500字)。
61
+ 4. 新しい会話へ継続の指示を1通送ります。届いた時に、止めた時点の依頼、そのターンでここまでにしたこと(発言と道具の呼び出し)、実行されなかった道具、直近の会話の原文を注入します(上限9,500字)。止めたターンの道具の入出力は、後継が`throughline detail`で取り出せます。
62
62
 
63
63
  続きは裏の会話で動きます。`claude agents`の一覧と`claude attach <id>`で見られます。Claude Desktopの画面には出ず、後継のターンが終わって入力待ちになった後にDesktopで開けます。手動`/compact`、subagentの中の圧縮、`claude -p`などscriptから起動した会話は対象に含めません。
64
64
 
@@ -68,7 +68,7 @@ throughline auto-handoff status --host claude --operation <handoff-id> --json
68
68
 
69
69
  後継の立ち上げや配送に失敗した時は、固定の理由を記録して止まります。結果が不明な指示は再送しません。圧縮を止めた後にモデルが道具を呼ばずにターンを終えた時は、後継を立てません。印は残るので、1時間以内に開いた新しい会話が記憶を引き継ぎます。
70
70
 
71
- 有効にした端末では、Claudeの道具の呼び出しのたびに`PreToolUse`フックが1回走ります。LinuxのClaude Code 2.1.289で実機確認済みです。macOS・Windows、Claude Desktopから始まる会話は未検証です。[実測と検証範囲](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-05-claude-auto-handoff.md)を参照してください。
71
+ 有効にした端末では、Claudeの道具の呼び出しのたびに`PreToolUse`フックが1回走ります。LinuxとmacOSのClaude Code(2.1.289、Claude Desktop同梱の2.1.286)で実機確認済みです。Windowsと、Claude Desktopの画面から始めた会話は未検証です。[実測と検証範囲](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-05-claude-auto-handoff.md)を参照してください。
72
72
 
73
73
  ## Ownership boundary
74
74
 
@@ -15,6 +15,7 @@ Codex 版の各段に当たるものを、Claude Code 2.1.289 の公式文書と
15
15
  |---|---|---|
16
16
  | 圧縮の前に旧タスクを止める | `PreCompact` の exit code 2 で圧縮を止める。`PreToolUse` が `permissionDecision: "deny"` と `continue: false` を返すと、その道具は実行されずにターンが止まる | 実機 |
17
17
  | 新しいタスクを作る | `claude --bg`。指示を付けずに起動すると、指示を待つ会話が立つ(`idle — send a prompt to start`) | 実機 |
18
+ | 止めたターンを取り込んでから記憶を作る | hook で止めたターンでは Stop hook が走らない。worker が、後継を立てる前に transcript から取り込む | 実機(0.14.1) |
18
19
  | 記憶を入れる | 最初の指示の `UserPromptSubmit` hook の出力。`/tl` の印(baton)の引き継ぎと同じ | 実機 |
19
20
  | 「続けて」を送る | 会話ごとの受け口(inbox socket)。配送ライブラリ `aiterm-steer-delivery` 0.1.13 の `sendClaudeInbox` | 実機 |
20
21
 
@@ -39,12 +40,23 @@ Claude Desktop の新しい会話は、画面を開いただけでは process
39
40
  `--settings '{"worktree":{"bgIsolation":"none"}}'` で同じ作業ツリーを編集させる。
40
41
  依頼と直前の発言は、止めた道具の呼び出しが transcript に書かれるのを待ってから読む(hook の時点では直前の発言が
41
42
  まだ書かれていないことがある)。
43
+ 後継を立てる前に、**止めたターンを DB へ取り込む**(0.14.1。Codex の自動継続と同じ)。発言は全部をつないで
44
+ bodies の本文にし、道具の入出力は transcript の末尾(止めた道具の呼び出し)まで details に入れる。
45
+ 完了したターンは最後の本文だけを残すが、止めたターンには結論の発言が無いので、全部を残す。
46
+ 取り込めなくても引き継ぎは止めない。理由は worker のログに残り、状態の `in_flight_captured` が `false` になる。
42
47
  4. **継続の指示を1通送る**: 後継の `SessionStart` hook が、同じ project に立ち上げ中の引き継ぎがある時だけ、
43
48
  自分の受け口(`CLAUDE_CODE_MESSAGING_SOCKET` と token)を控える。worker はそれを読んで消し、
44
49
  `sendClaudeInbox` で継続の指示を1通送る。配送は公開 API だけを使い、Throughline の中に送信の実装を持たない。
45
50
  届いた時の `UserPromptSubmit` が baton を消費して前任を合流させ、記憶を注入する。
46
- 注入は ADR 0016 と同じ予算(9,500 字)で、ヘッダに作業ディレクトリ、現在地に止めた時点の依頼(4,000 字まで)と
47
- 直前の発言(1,500 字まで)、続けて案内と直近の L2 を載せる。宣言文は求めない。
51
+ 注入は ADR 0016 と同じ予算(9,500 字)で、ヘッダに作業ディレクトリ、現在地、案内、直近の L2 の順に載せる。
52
+ 宣言文は求めない。現在地は止めたターンで、次の順に載せる(0.14.1)。
53
+ - 止めた時点の依頼(3,500 字まで)。
54
+ - このターンでここまでにしたこと。発言と道具の呼び出し(名前と対象。失敗は印を付ける)を古い順に並べる。
55
+ 新しい側から 2,400 字まで載せ、載せなかった古い側は件数にする。記録には直近 80 件までを持つ。
56
+ - 止める直前の発言(1,500 字まで)。一覧に同じ文がそのまま載っている時は重ねない。
57
+ - 止める直前に呼ぼうとして、実行されなかった道具。圧縮を止めた後の応答が呼んだ道具は、どれも実行されていない。
58
+ hook は並んで走るので、同じ応答(`message.id`)の道具をまとめて扱う。
59
+ 止めたターンは現在地にだけ載せ、L2 の一覧には重ねない。そのターンの道具の入出力は `throughline detail` で取り出せる。
48
60
  5. **受領**: 継続の指示は引き継ぎ ID を含む。後継の最初の指示がその ID を含む時だけ、受領を記録する。
49
61
  `sendClaudeInbox` の結果が `accepted` なら `sent`、`not_sent` なら `failed`、`unknown` なら `unknown` にする。
50
62
  結果が不明な時は再送しない。後継が作業を進めたことまでは確かめない(オーナーの判断: 不要)。
@@ -71,8 +83,11 @@ Claude Desktop の新しい会話は、画面を開いただけでは process
71
83
  - 続きは裏の会話(agent view)で動く。`claude agents` の一覧と `claude attach <id>` で見られる。Claude Desktop の画面には
72
84
  出ず、後継のターンが終わって入力待ちになった後に Desktop で開ける。
73
85
  - `PreToolUse` hook は、有効な端末の全ての Claude の会話で、道具の呼び出しのたびに1回走る。
74
- - 止めたターンは Stop hook を通らない。本文は後継の最初の指示の時に回収するが、返事の本文が1つも無いまま止めた
75
- ターンは bodies に入らず、依頼は現在地だけに載る。止めたターンの tool 入出力は L3 に入らない。
86
+ - 止めたターンは Stop hook を通らない。worker が取り込む(0.14.1)。発言が1つも無いまま止めたターンは、
87
+ user の行と道具の入出力だけが入る。完了の受領(Stop が出すもの)は、止めたターンには出さない。
88
+ - 0.14.0 は、止めたターンについて直前の発言1つだけを後継へ渡し、道具の入出力をどこにも残さなかった。
89
+ macOS の実機で、Haiku の後継が読み終えたファイルを最初から読み直した。Codex 版は止めたターンを取り込んでから
90
+ 記憶を作っており、その段を写していなかった。
76
91
  - 圧縮が「文脈上限の error から回復するため」に起きた時は、止めると元の error でその要求が失敗する(公式文書)。
77
92
  この場合の後継の立ち上げは、次の道具の呼び出しまで起きない。
78
93
  - 別の会話から届いた発言(`Another Claude session sent a message:`)は、Claude Code が後ろに付ける定型の注意書きを
@@ -81,6 +96,9 @@ Claude Desktop の新しい会話は、画面を開いただけでは process
81
96
 
82
97
  ## 確認した範囲
83
98
 
84
- - Linux の Claude Code 2.1.289。対話画面の会話から始めて、後継へ3回連続で引き継ぎ、最後の後継が作業を完了した。
85
- 4つの会話のどれにも圧縮の記録は無く、止めた道具は実行されていなかった。
86
- - macOS・Windows、Claude Desktop から始まる会話、subagent が動いている最中の引き継ぎは確かめていない。
99
+ - Linux の Claude Code 2.1.289。対話画面の会話から始めて、Haiku 4.5 で3回、Opus 5.5 で6回、連続で引き継ぎ、
100
+ 最後の後継が作業を完了した(0.14.0)。0.14.1 でも Haiku 4.5 で確認した。
101
+ - macOS 27.0。端末の会話(2.1.289)と、Claude Desktop が同梱する本体(2.1.286)を端末から起動した会話から始めた。
102
+ 0.14.1 で、Haiku 4.5 は4回、Opus 5.5 は7回、連続で引き継いで完了した。読み直しも読み飛ばしも無い。
103
+ - どの会話にも圧縮の記録は無く、止めた道具は実行されていなかった。
104
+ - Claude Desktop の画面から始めた会話、Windows、Fable、subagent が動いている最中の引き継ぎは確かめていない。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -7,7 +7,8 @@
7
7
  * 1. PreCompact (trigger=auto) : 圧縮を止め(exit code 2)、/tl と同じ印(baton)と引き継ぎの記録を残す。
8
8
  * 2. PreToolUse : 記録がある会話の次の道具を、実行させずに止める(deny + continue:false)。
9
9
  * 止めた時点の依頼・設定を記録へ写し、後継を立てる worker を起動する。
10
- * 3. worker : `claude --bg` で、指示を待つ新しい会話を同じ project に立てる。
10
+ * 3. worker : 止めたターンを DB へ取り込み(発言の全部と道具の入出力)、ここまでにしたことを
11
+ * 記録へ写す。`claude --bg` で、指示を待つ新しい会話を同じ project に立てる。
11
12
  * 4. worker : 後継の SessionStart が残した受け口へ、配送ライブラリで継続の指示を1通送る。
12
13
  * 届いた時の UserPromptSubmit が baton を消費し、記憶を注入する。
13
14
  *
@@ -25,7 +26,15 @@ import { sendClaudeInbox } from 'aiterm-steer-delivery';
25
26
  import { readClaudeAutoHandoffConfig, claudeAutoHandoffEnabledFor } from './claude-auto-handoff-config.mjs';
26
27
  import { CLAUDE_HOST, hostOfSessionId } from './hosts/identity.mjs';
27
28
  import { claudeHostAdapter } from './hosts/claude.mjs';
28
- import { normalizeTerminalText, readLatestUserGroup, readRawEntries } from './transcript-reader.mjs';
29
+ import {
30
+ isJunkAssistantText,
31
+ normalizeTerminalText,
32
+ readLatestUserGroup,
33
+ readRawEntries,
34
+ sliceInFlightTurnEntries,
35
+ } from './transcript-reader.mjs';
36
+ import { captureInFlightTurn } from './turn-backfill.mjs';
37
+ import { resolveMergeTarget } from './session-merger.mjs';
29
38
  import { writeBaton } from './baton.mjs';
30
39
  import { sameProjectPath } from './project-path.mjs';
31
40
  import { spawnPortable, spawnPortableSync } from './os/portable-spawn-sync.mjs';
@@ -113,11 +122,13 @@ export function listClaudeAutoHandoffs({ dir = claudeAutoHandoffDir() } = {}) {
113
122
  .sort((a, b) => a.requested_at - b.requested_at);
114
123
  }
115
124
 
116
- /** 外へ見せる項目だけ。依頼の本文と受け口は含めない。 */
125
+ /** 外へ見せる項目だけ。依頼の本文・発言・道具の対象と、受け口は含めない。 */
117
126
  export function publicClaudeAutoHandoff(record) {
118
127
  const { handoff_id, source_session_id, project_path, state, error_code, requested_at, updated_at, successor } = record;
119
128
  return { handoff_id, source_session_id, project_path, state, error_code: error_code ?? null,
120
129
  successor_session_id: successor?.session_id ?? null, successor_short_id: successor?.short_id ?? null,
130
+ // 止めたターンを DB へ取り込めたか。false の時、後継は `throughline detail` でそのターンの入出力を取れない。
131
+ in_flight_captured: Boolean(record.in_flight?.turn),
121
132
  requested_at, updated_at };
122
133
  }
123
134
 
@@ -227,24 +238,132 @@ function readLatestModel(transcriptPath) {
227
238
 
228
239
  const CONTINUATION_MARKER = /Throughline自動継続 ([0-9a-f-]{36})/;
229
240
 
241
+ // 後継へ渡す「このターンでここまでにしたこと」。1件の長さと件数を抑える。全文は取り込んだ details にある。
242
+ const STEP_TEXT_MAX_CHARS = 300;
243
+ const STEP_TARGET_MAX_CHARS = 160;
244
+ const STEPS_KEPT = 80;
245
+ const STOPPED_TOOLS_KEPT = 10;
246
+ // 道具の呼び出しを1行にする時に見せる入力。知らない道具は名前だけにする。
247
+ const TOOL_TARGET_KEYS = ['file_path', 'notebook_path', 'path', 'command', 'pattern', 'url', 'description', 'query', 'prompt', 'skill'];
248
+
249
+ function clipLine(text, maxChars) {
250
+ const line = String(text).replace(/\s+/g, ' ').trim();
251
+ return line.length <= maxChars ? line : `${line.slice(0, maxChars - 1)}…`;
252
+ }
253
+
254
+ const TOOL_PATH_KEYS = new Set(['file_path', 'notebook_path', 'path']);
255
+
256
+ /** 作業ディレクトリの中の場所は相対で書く(後継の記憶のヘッダに作業ディレクトリがある)。長い場所は末尾を残す。 */
257
+ function clipPath(value, projectPath) {
258
+ let line = value.trim();
259
+ if (projectPath && line.startsWith(projectPath) && /^[\\/]/.test(line.slice(projectPath.length))) {
260
+ line = line.slice(projectPath.length + 1);
261
+ }
262
+ return line.length <= STEP_TARGET_MAX_CHARS ? line : `…${line.slice(-(STEP_TARGET_MAX_CHARS - 1))}`;
263
+ }
264
+
265
+ function toolTarget(input, projectPath) {
266
+ if (!input || typeof input !== 'object') return '';
267
+ for (const key of TOOL_TARGET_KEYS) {
268
+ if (typeof input[key] !== 'string' || !input[key].trim()) continue;
269
+ return TOOL_PATH_KEYS.has(key) ? clipPath(input[key], projectPath) : clipLine(input[key], STEP_TARGET_MAX_CHARS);
270
+ }
271
+ return '';
272
+ }
273
+
274
+ /**
275
+ * 止めたターンで、止めるまでにした発言と道具の呼び出し(古い順)と、止めた道具。
276
+ *
277
+ * 圧縮を止めた後の応答が呼んだ道具は、どれも実行されていない(同じ応答に並んだ道具は全部止める)。
278
+ * hook が並んで走るので、記録に残る止めた道具の id は、応答の中の最初の道具とは限らない。
279
+ * 同じ応答(message.id)の道具を、まとめて「止めた道具」にする。
280
+ */
281
+ function readInFlightSteps(transcriptPath, stoppedToolUseId, projectPath) {
282
+ const steps = [];
283
+ const toolById = new Map();
284
+ for (const entry of sliceInFlightTurnEntries(readRawEntries(transcriptPath)).slice(1)) {
285
+ const blocks = entry?.message?.content;
286
+ if (!Array.isArray(blocks)) continue;
287
+ if (entry.type === 'assistant') {
288
+ const at = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
289
+ for (const block of blocks) {
290
+ if (block?.type === 'text' && typeof block.text === 'string' && block.text.trim() && !isJunkAssistantText(block.text)) {
291
+ steps.push({ kind: 'text', content: clipLine(block.text, STEP_TEXT_MAX_CHARS), timestamp: Number.isNaN(at) ? null : at });
292
+ } else if (block?.type === 'tool_use' && typeof block.id === 'string') {
293
+ const step = { kind: 'tool', id: block.id, response: entry.message.id ?? null, name: block.name ?? 'unknown',
294
+ target: toolTarget(block.input, projectPath), failed: false };
295
+ steps.push(step);
296
+ toolById.set(block.id, step);
297
+ }
298
+ }
299
+ } else if (entry.type === 'user') {
300
+ for (const block of blocks) {
301
+ if (block?.type === 'tool_result' && block.is_error === true && toolById.has(block.tool_use_id)) {
302
+ toolById.get(block.tool_use_id).failed = true;
303
+ }
304
+ }
305
+ }
306
+ }
307
+ const stopped = toolById.get(stoppedToolUseId);
308
+ const firstStopped = stopped
309
+ ? steps.findIndex(step => step.kind === 'tool' &&
310
+ (step.id === stopped.id || (stopped.response !== null && step.response === stopped.response)))
311
+ : -1;
312
+ const done = firstStopped < 0 ? steps : steps.slice(0, firstStopped);
313
+ const stoppedTools = firstStopped < 0 ? [] : steps.slice(firstStopped).filter(step => step.kind === 'tool');
314
+ const earlier = done.slice(0, Math.max(0, done.length - STEPS_KEPT));
315
+ const tools = {};
316
+ for (const step of earlier) if (step.kind === 'tool') tools[step.name] = (tools[step.name] ?? 0) + 1;
317
+ return {
318
+ steps: done.slice(earlier.length).map(step => step.kind === 'text'
319
+ ? { kind: 'text', content: step.content, timestamp: step.timestamp }
320
+ : { kind: 'tool', name: step.name, target: step.target, failed: step.failed }),
321
+ earlier_steps: earlier.length > 0 ? { texts: earlier.filter(step => step.kind === 'text').length, tools } : null,
322
+ stopped_tools: stoppedTools.slice(0, STOPPED_TOOLS_KEPT).map(step => ({ name: step.name, target: step.target })),
323
+ stopped_tools_total: stoppedTools.length,
324
+ };
325
+ }
326
+
230
327
  /**
231
328
  * 止めた時点の作業途中のターン。後継の会話が止められた時は、最後の user 発言が前の引き継ぎの
232
329
  * 継続の指示になっている。その時は、前の引き継ぎが運んだ元の依頼を引き続き運ぶ。
330
+ * turn は、このターンを DB へ取り込んだ時の場所(worker が取り込みの後に入れる)。
233
331
  */
234
- function snapshotInFlight(transcriptPath, dir) {
235
- const latest = readLatestUserGroup(transcriptPath);
332
+ function snapshotInFlight(record, dir) {
333
+ const latest = readLatestUserGroup(record.transcript_path);
236
334
  if (!latest) return null;
237
- const lastFragment = latest.fragments.at(-1) ?? null;
335
+ const lastFragment = latest.fragments.filter(fragment => !isJunkAssistantText(fragment.content)).at(-1) ?? null;
238
336
  const previousHandoffId = CONTINUATION_MARKER.exec(latest.user.content)?.[1];
239
337
  const carried = previousHandoffId
240
- ? listClaudeAutoHandoffs({ dir }).find(record => record.handoff_id === previousHandoffId)?.in_flight?.user
338
+ ? listClaudeAutoHandoffs({ dir }).find(other => other.handoff_id === previousHandoffId)?.in_flight?.user
241
339
  : null;
242
340
  return {
243
341
  user: carried ?? { content: latest.user.content, timestamp: latest.user.timestamp },
244
342
  last_fragment: lastFragment ? { content: lastFragment.content, timestamp: lastFragment.timestamp } : null,
343
+ ...readInFlightSteps(record.transcript_path, record.stopped_tool_use_id, record.project_path),
344
+ turn: null,
245
345
  };
246
346
  }
247
347
 
348
+ /**
349
+ * 止めたターンを DB へ取り込む (Codex の自動継続が、記憶を作る前に止めたターンを取り込むのと同じ)。
350
+ * 後継が前任を合流させる前に済ませる。取り込めなくても引き継ぎは止めない。依頼と、ここまでにしたことは
351
+ * 記録から後継へ渡る。取り込めなかった理由は worker のログに残り、記録の turn が null のままになる。
352
+ */
353
+ function captureStoppedTurn(record, openDb) {
354
+ try {
355
+ const db = openDb();
356
+ const { target, origin } = resolveMergeTarget(db, record.source_session_id);
357
+ const captured = captureInFlightTurn(db, { targetSessionId: target, originSessionId: origin,
358
+ transcriptPath: record.transcript_path, now: Date.now() });
359
+ return captured && { origin_session_id: origin, turn_number: captured.turnNumber, user_at: captured.userAt,
360
+ assistant_at: captured.assistantAt, details: captured.details };
361
+ } catch (error) {
362
+ process.stderr.write(`[auto-handoff] in-flight turn capture failed: ${error instanceof Error ? error.message : 'unknown'}\n`);
363
+ return null;
364
+ }
365
+ }
366
+
248
367
  /** 止めた道具の呼び出しが transcript に書かれるまで待つ。その前の発言が出そろった印になる。 */
249
368
  async function waitForStoppedToolUse(record, { timeoutMs, pollMs }) {
250
369
  if (!record.transcript_path || !record.stopped_tool_use_id) return;
@@ -426,6 +545,7 @@ export async function runClaudeAutoHandoffWorker(sessionId, {
426
545
  env = process.env,
427
546
  spawn = spawnPortableSync,
428
547
  send = sendClaudeInbox,
548
+ openDb = null,
429
549
  transcriptTimeoutMs = 5_000,
430
550
  targetTimeoutMs = 30_000,
431
551
  sendTimeoutMs = 30_000,
@@ -437,12 +557,15 @@ export async function runClaudeAutoHandoffWorker(sessionId, {
437
557
  const update = fields => { record = updateRecord(sessionId, fields, { dir }); return record; };
438
558
  const fail = code => update({ state: 'failed', error_code: code });
439
559
 
440
- // 止めた時点の依頼と、その時のモデルを写す。後継の最初の指示が、この依頼を現在地として受け取る。
560
+ // 止めた時点の依頼・ここまでにしたこと・その時のモデルを写し、止めたターンを DB へ取り込む。
561
+ // 後継の最初の指示が、これを現在地として受け取る。
441
562
  await waitForStoppedToolUse(record, { timeoutMs: transcriptTimeoutMs, pollMs });
563
+ const inFlight = record.transcript_path ? snapshotInFlight(record, dir) : null;
564
+ if (inFlight && openDb) inFlight.turn = captureStoppedTurn(record, openDb);
442
565
  update({
443
566
  state: 'launching',
444
567
  settings: { ...record.settings, model: record.transcript_path ? readLatestModel(record.transcript_path) : null },
445
- in_flight: record.transcript_path ? snapshotInFlight(record.transcript_path, dir) : null,
568
+ in_flight: inFlight,
446
569
  });
447
570
  const launched = spawn('claude', successorArgs(record), {
448
571
  cwd: record.project_path, env: successorEnv(env), encoding: 'utf8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'],
@@ -46,8 +46,8 @@ function makeBatonDb() {
46
46
  const user = (text, timestamp, extra = {}) => ({ type: 'user', timestamp, ...extra, message: { role: 'user', content: text } });
47
47
  const assistant = (text, timestamp, model = 'claude-fable-5-1') =>
48
48
  ({ type: 'assistant', timestamp, message: { role: 'assistant', model, content: [{ type: 'text', text }] } });
49
- const toolUse = (id, name) =>
50
- ({ type: 'assistant', message: { role: 'assistant', model: 'claude-fable-5-1', content: [{ type: 'tool_use', id, name, input: {} }] } });
49
+ const toolUse = (id, name, input = {}) =>
50
+ ({ type: 'assistant', message: { role: 'assistant', model: 'claude-fable-5-1', content: [{ type: 'tool_use', id, name, input }] } });
51
51
  const toolResult = (id, text) => ({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: text }] } });
52
52
  const jsonl = entries => entries.map(entry => JSON.stringify(entry)).join('\n') + '\n';
53
53
 
@@ -308,6 +308,14 @@ test('worker: 止めた時点の依頼とモデルを transcript から写し、
308
308
  assert.deepEqual(first.record.in_flight, {
309
309
  user: { content: '14個のファイルを順に読んで', timestamp: Date.parse('2026-10-04T03:00:10Z') },
310
310
  last_fragment: { content: 'part05 を読みました。次に part06 を読みます。', timestamp: Date.parse('2026-10-04T03:00:14Z') },
311
+ steps: [
312
+ { kind: 'text', content: 'part04 を読みました。', timestamp: Date.parse('2026-10-04T03:00:12Z') },
313
+ { kind: 'text', content: 'part05 を読みました。次に part06 を読みます。', timestamp: Date.parse('2026-10-04T03:00:14Z') },
314
+ ],
315
+ earlier_steps: null,
316
+ stopped_tools: [{ name: 'Read', target: '' }],
317
+ stopped_tools_total: 1,
318
+ turn: null,
311
319
  }, '止めた道具の直前の発言まで出そろってから写す');
312
320
 
313
321
  // 後継 B が止められた時、最後の user 発言は A からの継続の指示。現在地には元の依頼を運ぶ
@@ -323,9 +331,120 @@ test('worker: 止めた時点の依頼とモデルを transcript から写し、
323
331
  assert.deepEqual(second.record.in_flight, {
324
332
  user: { content: '14個のファイルを順に読んで', timestamp: Date.parse('2026-10-04T03:00:10Z') },
325
333
  last_fragment: { content: 'part09 を読みました。次に part10 を読みます。', timestamp: Date.parse('2026-10-04T03:01:20Z') },
334
+ steps: [{ kind: 'text', content: 'part09 を読みました。次に part10 を読みます。', timestamp: Date.parse('2026-10-04T03:01:20Z') }],
335
+ earlier_steps: null,
336
+ stopped_tools: [{ name: 'Read', target: '' }],
337
+ stopped_tools_total: 1,
338
+ turn: null,
326
339
  });
327
340
  }));
328
341
 
342
+ function makeMemoryDb() {
343
+ const db = makeBatonDb();
344
+ db.exec(`
345
+ CREATE TABLE sessions (session_id TEXT PRIMARY KEY, merged_into TEXT);
346
+ CREATE TABLE bodies (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, origin_session_id TEXT NOT NULL,
347
+ turn_number INTEGER NOT NULL, role TEXT NOT NULL, text TEXT NOT NULL, token_count INTEGER, created_at INTEGER NOT NULL,
348
+ turn_start TEXT, UNIQUE(session_id, origin_session_id, turn_number, role));
349
+ CREATE TABLE details (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, origin_session_id TEXT,
350
+ turn_number INTEGER, tool_name TEXT NOT NULL, input_text TEXT, output_text TEXT, token_count INTEGER NOT NULL DEFAULT 0,
351
+ created_at INTEGER NOT NULL, kind TEXT, source_id TEXT);
352
+ CREATE UNIQUE INDEX uq_details_source ON details(session_id, origin_session_id, source_id) WHERE source_id IS NOT NULL;
353
+ `);
354
+ return db;
355
+ }
356
+
357
+ test('worker: 止めたターンを DB へ取り込み、ここまでにしたことと、実行されなかった道具を記録へ写す', () => withDir(async dir => {
358
+ const block = (id, content, timestamp) =>
359
+ ({ type: 'assistant', timestamp, message: { id, role: 'assistant', model: 'claude-opus-5', content: [content] } });
360
+ const result = (id, text, isError = false) =>
361
+ ({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: text, ...(isError ? { is_error: true } : {}) }] } });
362
+ const transcript = join(dir, 'work.jsonl');
363
+ writeFileSync(transcript, jsonl([
364
+ user('前の依頼', '2026-10-04T02:00:00Z'),
365
+ assistant('前の回答', '2026-10-04T02:00:05Z', 'claude-opus-5'),
366
+ user('a.mjs を直して', '2026-10-04T03:00:00Z'),
367
+ block('m1', { type: 'text', text: '読みます。' }, '2026-10-04T03:00:01Z'),
368
+ block('m1', { type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/work/a.mjs' } }, '2026-10-04T03:00:01Z'),
369
+ result('t1', 'export const a = 1;'),
370
+ block('m2', { type: 'text', text: '直します。' }, '2026-10-04T03:00:03Z'),
371
+ block('m2', { type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: '/work/app/src/a.mjs', old_string: '1', new_string: '2' } }, '2026-10-04T03:00:03Z'),
372
+ result('t2', 'ok'),
373
+ block('m3', { type: 'tool_use', id: 't3', name: 'Bash', input: { command: 'npm test\n --silent' } }, '2026-10-04T03:00:05Z'),
374
+ result('t3', 'Exit code 1', true),
375
+ // ここで自動圧縮を止めた。次の応答が呼んだ道具は、2つとも実行されていない
376
+ block('m4', { type: 'text', text: '試験が落ちました。\n原因を見ます。' }, '2026-10-04T03:00:08Z'),
377
+ block('m4', { type: 'tool_use', id: 't4', name: 'Read', input: { file_path: `/work/app/test/${'d'.repeat(200)}/a.test.mjs` } }, '2026-10-04T03:00:08Z'),
378
+ block('m4', { type: 'tool_use', id: 't5', name: 'Grep', input: { pattern: 'expect\\(' } }, '2026-10-04T03:00:08Z'),
379
+ result('t5', 'PreToolUse:Grep hook error', true),
380
+ result('t4', 'PreToolUse:Read hook error', true),
381
+ ]));
382
+ const db = makeMemoryDb();
383
+ requestClaudeAutoHandoff({ payload: { session_id: SESSION, trigger: 'auto', cwd: '/work/app', transcript_path: transcript },
384
+ env: {}, config: ENABLED, openDb: () => db, dir });
385
+ // hook は並んで走る。記録に残る止めた道具は、応答の中の2つ目のこともある
386
+ await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app', transcript_path: transcript, tool_use_id: 't5' },
387
+ dir, launchWorker: async () => {} });
388
+ const record = await runClaudeAutoHandoffWorker(SESSION, { dir, env: {}, pollMs: 10, targetTimeoutMs: 300, transcriptTimeoutMs: 300,
389
+ openDb: () => db,
390
+ send: async () => ({ status: 'accepted', outcome_unknown: false, reason: 'receiver_confirmed' }),
391
+ spawn: () => {
392
+ recordClaudeSuccessorTarget({ payload: { session_id: SUCCESSOR, source: 'startup', cwd: '/work/app' },
393
+ env: { CLAUDE_CODE_MESSAGING_SOCKET: '/tmp/cc-socks/1.sock' }, dir });
394
+ return { status: 0, stderr: '', stdout: 'backgrounded · a41a97ae · tl-app (idle — send a prompt to start)\n' };
395
+ } });
396
+ assert.equal(record.state, 'sent');
397
+ assert.deepEqual(record.in_flight.steps, [
398
+ { kind: 'text', content: '読みます。', timestamp: Date.parse('2026-10-04T03:00:01Z') },
399
+ { kind: 'tool', name: 'Read', target: '/work/a.mjs', failed: false },
400
+ { kind: 'text', content: '直します。', timestamp: Date.parse('2026-10-04T03:00:03Z') },
401
+ { kind: 'tool', name: 'Edit', target: 'src/a.mjs', failed: false },
402
+ { kind: 'tool', name: 'Bash', target: 'npm test --silent', failed: true },
403
+ { kind: 'text', content: '試験が落ちました。 原因を見ます。', timestamp: Date.parse('2026-10-04T03:00:08Z') },
404
+ ], '前のターンは入れない。止めた道具は、ここまでにしたことに入れない。作業ディレクトリの中は相対で書く');
405
+ assert.deepEqual(record.in_flight.stopped_tools, [
406
+ { name: 'Read', target: `…${'d'.repeat(159 - '/a.test.mjs'.length)}/a.test.mjs` }, { name: 'Grep', target: 'expect\\(' }],
407
+ '長い場所は末尾を残す');
408
+ assert.equal(record.in_flight.stopped_tools_total, 2);
409
+ assert.deepEqual(record.in_flight.turn, { origin_session_id: SESSION, turn_number: 5,
410
+ user_at: Date.parse('2026-10-04T03:00:00Z'), assistant_at: Date.parse('2026-10-04T03:00:08Z'), details: 10 });
411
+ assert.equal(publicClaudeAutoHandoff(record).in_flight_captured, true);
412
+ assert.equal(JSON.stringify(publicClaudeAutoHandoff(record)).includes('a.mjs'), false, '道具の対象は外へ見せない');
413
+
414
+ // DB: 止めたターンは、発言を全部つないだ本文と、道具の入出力(止めた道具を含む)で入る
415
+ assert.deepEqual(db.prepare('SELECT session_id, turn_number, role, text, created_at FROM bodies ORDER BY id').all().map(row => ({ ...row })), [
416
+ { session_id: SESSION, turn_number: 5, role: 'user', text: 'a.mjs を直して', created_at: Date.parse('2026-10-04T03:00:00Z') },
417
+ { session_id: SESSION, turn_number: 5, role: 'assistant', text: '読みます。\n\n直します。\n\n試験が落ちました。\n原因を見ます。',
418
+ created_at: Date.parse('2026-10-04T03:00:08Z') },
419
+ ]);
420
+ assert.deepEqual(db.prepare(`SELECT kind, tool_name, turn_number FROM details WHERE origin_session_id = ? ORDER BY id`).all(SESSION)
421
+ .map(row => `${row.kind}:${row.tool_name}:${row.turn_number}`), [
422
+ 'tool_input:Read:5', 'tool_output:Read:5', 'tool_input:Edit:5', 'tool_output:Edit:5', 'tool_input:Bash:5', 'tool_output:Bash:5',
423
+ 'tool_input:Read:5', 'tool_input:Grep:5', 'tool_output:Grep:5', 'tool_output:Read:5']);
424
+ }));
425
+
426
+ test('worker: 止めたターンを取り込めなくても後継を立て、取り込めなかったことを記録に残す', () => withDir(async dir => {
427
+ const transcript = join(dir, 'work.jsonl');
428
+ writeFileSync(transcript, jsonl([user('依頼', '2026-10-04T03:00:00Z'), assistant('途中です。', '2026-10-04T03:00:02Z'), toolUse('toolu_1', 'Read')]));
429
+ const db = makeBatonDb(); // bodies も sessions も無い
430
+ requestClaudeAutoHandoff({ payload: { session_id: SESSION, trigger: 'auto', cwd: '/work/app', transcript_path: transcript },
431
+ env: {}, config: ENABLED, openDb: () => db, dir });
432
+ await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app', transcript_path: transcript, tool_use_id: 'toolu_1' },
433
+ dir, launchWorker: async () => {} });
434
+ const record = await runClaudeAutoHandoffWorker(SESSION, { dir, env: {}, pollMs: 10, targetTimeoutMs: 300, transcriptTimeoutMs: 300,
435
+ openDb: () => db,
436
+ send: async () => ({ status: 'accepted', outcome_unknown: false, reason: 'receiver_confirmed' }),
437
+ spawn: () => {
438
+ recordClaudeSuccessorTarget({ payload: { session_id: SUCCESSOR, source: 'startup', cwd: '/work/app' },
439
+ env: { CLAUDE_CODE_MESSAGING_SOCKET: '/tmp/cc-socks/1.sock' }, dir });
440
+ return { status: 0, stderr: '', stdout: 'backgrounded · a41a97ae · tl-app (idle — send a prompt to start)\n' };
441
+ } });
442
+ assert.equal(record.state, 'sent');
443
+ assert.equal(record.in_flight.turn, null);
444
+ assert.equal(record.in_flight.steps.length, 1);
445
+ assert.equal(publicClaudeAutoHandoff(record).in_flight_captured, false);
446
+ }));
447
+
329
448
  test('worker: 立ち上げ・受け口・配送の失敗は固定の理由を残し、結果が不明な配送は再送しない', () => withDir(async dir => {
330
449
  const sendOk = async () => ({ status: 'accepted', outcome_unknown: false, reason: 'receiver_confirmed' });
331
450
  const cases = [
@@ -501,10 +620,10 @@ test('hook: 自動圧縮を止め、次の道具を止めて worker を起動し
501
620
  user('成果物の1行目には必ず「約束: MANGO-4417」と書くこと', at(0)),
502
621
  assistant('了解しました。', at(1)),
503
622
  user('big1.txt と big2.txt を読んで result.txt を作って', at(10)),
504
- toolUse('toolu_1', 'Read'),
623
+ toolUse('toolu_1', 'Read', { file_path: '/p/big1.txt' }),
505
624
  toolResult('toolu_1', 'big1 contents'),
506
625
  assistant('big1.txt を読み終えました。次に big2.txt を読みます。', at(12)),
507
- toolUse('toolu_2', 'Read'),
626
+ toolUse('toolu_2', 'Read', { file_path: '/p/big2.txt' }),
508
627
  ]));
509
628
  const extra = fakeClaudePath(home);
510
629
  const hook = (command, payload, sessionId = SESSION) =>
@@ -529,7 +648,7 @@ test('hook: 自動圧縮を止め、次の道具を止めて worker を起動し
529
648
  assert.deepEqual(status().map(item => item.state), ['requested']);
530
649
 
531
650
  // 次の道具: 実行させずに止め、worker を起動する
532
- const stopped = hook('pre-tool-use', { hook_event_name: 'PreToolUse', tool_name: 'Read', permission_mode: 'acceptEdits' });
651
+ const stopped = hook('pre-tool-use', { hook_event_name: 'PreToolUse', tool_name: 'Read', tool_use_id: 'toolu_2', permission_mode: 'acceptEdits' });
533
652
  assert.equal(stopped.status, 0, stopped.stderr);
534
653
  const output = JSON.parse(stopped.stdout);
535
654
  assert.equal(output.continue, false);
@@ -544,6 +663,10 @@ test('hook: 自動圧縮を止め、次の道具を止めて worker を起動し
544
663
  const recordFile = join(handoffDir, `${SESSION}.json`);
545
664
  const record = JSON.parse(readFileSync(recordFile, 'utf8'));
546
665
  assert.equal(record.settings.permission_mode, 'acceptEdits');
666
+ // worker は後継を立てる前に、止めたターンを DB へ取り込んでいる
667
+ assert.equal(handoff.in_flight_captured, true);
668
+ assert.equal(record.in_flight.turn.origin_session_id, SESSION);
669
+ assert.equal(record.in_flight.turn.details, 3);
547
670
  writeFileSync(recordFile, JSON.stringify({ ...record, state: 'launching', error_code: null }));
548
671
  const started = hook('session-start', { hook_event_name: 'SessionStart', source: 'startup' }, SUCCESSOR);
549
672
  assert.equal(started.status, 0, started.stderr);
@@ -561,8 +684,11 @@ test('hook: 自動圧縮を止め、次の道具を止めて worker を起動し
561
684
  assert.equal(first.status, 0, first.stderr);
562
685
  assert.match(first.stdout, /^## Throughline: 自動継続の文脈\n/);
563
686
  assert.match(first.stdout, /### 現在地 \(作業の途中で止めたターン\)\n\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: big1\.txt と big2\.txt を読んで result\.txt を作って\n/);
564
- assert.match(first.stdout, /\*\*止める直前のあなたの発言\*\* \[\d\d:\d\d:\d\d\]: big1\.txt を読み終えました。次に big2\.txt を読みます。\n/);
687
+ assert.match(first.stdout, new RegExp('\\*\\*このターンでここまでにしたこと\\*\\*(古い順。どれも完了済み。道具の入出力の全文: `throughline detail \\d\\d:\\d\\d:\\d\\d`):\\n' +
688
+ '- 道具: Read /p/big1\\.txt\\n- 発言: big1\\.txt を読み終えました。次に big2\\.txt を読みます。\\n' +
689
+ '\\*\\*止める直前に呼ぼうとして、実行されなかった道具\\*\\*: Read /p/big2\\.txt\\n'));
565
690
  assert.match(first.stdout, /\[user\]: 成果物の1行目には必ず「約束: MANGO-4417」と書くこと\n/);
691
+ assert.equal(first.stdout.split('big1.txt と big2.txt を読んで').length, 2, '止めたターンの依頼は現在地に1回だけ');
566
692
  assert.doesNotMatch(first.stdout, /宣言|\/clear/);
567
693
  assert.ok(first.stdout.length <= 9_501);
568
694
  assert.ok(JSON.parse(readFileSync(recordFile, 'utf8')).accepted_at > 0, '届いた指示で受領を記録する');
@@ -571,6 +697,14 @@ test('hook: 自動圧縮を止め、次の道具を止めて worker を起動し
571
697
  const db = new DatabaseSync(join(home, '.throughline', 'throughline.db'));
572
698
  assert.equal(db.prepare('SELECT merged_into FROM sessions WHERE session_id = ?').get(SESSION).merged_into, SUCCESSOR);
573
699
  assert.deepEqual(db.prepare('SELECT DISTINCT session_id FROM bodies').all().map(row => row.session_id), [SUCCESSOR]);
700
+ // 止めたターンは、道具の入出力ごと後継の記憶に入っている(`throughline detail` で取り出せる)
701
+ assert.deepEqual(db.prepare(`SELECT b.role, b.text, (SELECT COUNT(*) FROM details d WHERE d.session_id = b.session_id
702
+ AND d.origin_session_id = b.origin_session_id AND d.turn_number = b.turn_number) AS details
703
+ FROM bodies b WHERE b.origin_session_id = ? AND b.turn_number = ? ORDER BY b.id`).all(SESSION, record.in_flight.turn.turn_number)
704
+ .map(row => ({ ...row })), [
705
+ { role: 'user', text: 'big1.txt と big2.txt を読んで result.txt を作って', details: 3 },
706
+ { role: 'assistant', text: 'big1.txt を読み終えました。次に big2.txt を読みます。', details: 3 },
707
+ ]);
574
708
  assert.equal(db.prepare('SELECT COUNT(*) AS c FROM handoff_batons').get().c, 0);
575
709
  db.close();
576
710
 
@@ -1,7 +1,7 @@
1
1
  import { isAbsolute, resolve } from 'node:path';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { withCodexReceiver, platformDesktopFinder, realCodexHome } from 'aiterm-steer-delivery';
4
- import { openReadOnlyDb, DB_PATH, CURRENT_VERSION } from '../db.mjs';
4
+ import { getDb, openReadOnlyDb, DB_PATH, CURRENT_VERSION } from '../db.mjs';
5
5
  import { listAutoHandoffs, getAutoHandoff } from '../codex-auto-handoff-store.mjs';
6
6
  import { renderFrozenDetail } from '../codex-auto-handoff-memory.mjs';
7
7
  import { readAutoHandoffConfig, writeAutoHandoffConfig } from '../codex-auto-handoff-config.mjs';
@@ -84,7 +84,7 @@ async function runClaude(parsed) {
84
84
  removeClaudeAutoHandoffHooks();
85
85
  return { status: 'disabled', host: 'claude', config };
86
86
  }
87
- if (parsed.action === 'worker') return publicClaudeAutoHandoff(await runClaudeAutoHandoffWorker(parsed.operation));
87
+ if (parsed.action === 'worker') return publicClaudeAutoHandoff(await runClaudeAutoHandoffWorker(parsed.operation, { openDb: getDb }));
88
88
  const handoffs = listClaudeAutoHandoffs()
89
89
  .filter(record => !parsed.project || sameProjectPath(record.project_path, parsed.project))
90
90
  .filter(record => !parsed.operation || record.handoff_id === parsed.operation || record.source_session_id === parsed.operation)
@@ -68,16 +68,19 @@ const AUTO_CONTINUATION_HEADER = (projectPath) =>
68
68
  `- 前の会話は文脈の上限に近づいたため、Throughline が自動圧縮の前に止めて、この新しい会話へ引き継ぎました。` +
69
69
  `下記は、前の会話で **あなた自身とユーザーが交わした会話の原文** です。\n` +
70
70
  `- 「現在地」のターンは **作業の途中で止めたもの** です。ユーザーの追加入力を待たず、未完了の作業をそのまま続けてください。\n` +
71
- `- 完了済みの操作を繰り返さないでください。止める直前に呼ぼうとした道具は実行されていません。` +
72
- `直前の実行結果が分からない時は、作業ツリーや記録を確かめてから続けてください。\n` +
73
- '- **完了したターンの詳細**: **`Bash` ツールで `throughline detail HH:MM:SS` を実行** ' +
74
- `(該当ターンの本文+詳細を stdout に返します)`;
71
+ `- 「ここまでにしたこと」と「直前の対話」に載っている操作は **完了しています**。繰り返さないでください。` +
72
+ `未実行なのは「実行されなかった道具」だけです。そこから続けてください。\n` +
73
+ `- 直前の実行結果が分からない時は、作業ツリーや詳細を確かめてから続けてください。\n` +
74
+ '- **各ターンの詳細**(止めたターンを含む): **`Bash` ツールで `throughline detail HH:MM:SS` を実行** ' +
75
+ `(該当ターンの本文+道具の入出力を stdout に返します)`;
75
76
 
76
77
  const AUTO_CONTINUATION_ANCHOR_TITLE = '### 現在地 (作業の途中で止めたターン)';
77
- // 途中で止めたターンは、本文の断片が無ければ bodies に入らない。依頼の原文を必ず渡せる場所は
78
- // ここだけなので、/clear のアンカー (600 字) より厚く取る。
79
- const IN_FLIGHT_USER_MAX_CHARS = 4_000;
78
+ // 止めたターンは現在地にだけ載せ、L2 の一覧からは外す(同じ依頼を二度載せない)。
79
+ // /clear のアンカー (600 字) より厚く取る。3つの上限を足しても、ヘッダと案内を合わせて予算に収まる。
80
+ const IN_FLIGHT_USER_MAX_CHARS = 3_500;
80
81
  const IN_FLIGHT_ASSISTANT_MAX_CHARS = 1_500;
82
+ // 止めたターンでここまでにしたこと(発言と道具の呼び出し)。新しい側から、入るだけ載せる。
83
+ const IN_FLIGHT_STEPS_MAX_CHARS = 2_400;
81
84
 
82
85
  // v0.10.12以前にThroughline自身がassistantへ付与させていた宣言行。
83
86
  // 製品由来のメタ情報だけを引き継ぎ材料から除き、会話本文への模倣連鎖を止める。
@@ -501,7 +504,8 @@ function renderBudgetedSections(db, sections, { sessionId, excludeOriginId, maxC
501
504
  const older = loadOlderTurnStats(db, {
502
505
  sessionId,
503
506
  excludeOriginId,
504
- windowKeys: new Set(turns.map((t) => t.turnKey)),
507
+ // 現在地にだけ載せたターン(自動継続の止めたターン)は、窓の外の古いターンに数えない。
508
+ windowKeys: new Set([...turns.map((t) => t.turnKey), ...(sections.anchorTurnKeys ?? [])]),
505
509
  });
506
510
 
507
511
  const guidanceLines = buildGuidanceLines({
@@ -549,11 +553,62 @@ function truncateInFlight(text, maxChars) {
549
553
  return `${trimmed.slice(0, head)} …(長いため中略 ${trimmed.length - maxChars} 字)… ${trimmed.slice(-tail)}`;
550
554
  }
551
555
 
556
+ /**
557
+ * 止めたターンでここまでにしたことを、現在地の行にする。
558
+ * 新しい側から予算に入るだけ載せ、載せなかった古い側は件数だけを1行にまとめる。
559
+ * @returns {{lines: string[], shownTexts: Set<string>}}
560
+ */
561
+ function renderInFlightSteps(inFlight, detailClock) {
562
+ const steps = [...(inFlight.steps ?? [])];
563
+ const earlier = inFlight.earlier_steps ?? null;
564
+ // 一覧の最後が止める直前の発言で、1行に詰めると元の文と変わる(改行がある・長い)時は、
565
+ // 一覧から外して、下の「止める直前のあなたの発言」に全文で載せる。
566
+ const last = steps.at(-1);
567
+ const lastFragment = inFlight.last_fragment;
568
+ if (last?.kind === 'text' && lastFragment && last.timestamp === lastFragment.timestamp &&
569
+ last.content !== lastFragment.content.trim()) {
570
+ steps.pop();
571
+ }
572
+ if (steps.length === 0 && !earlier) return { lines: [], shownTexts: new Set() };
573
+
574
+ const rendered = steps.map((step) =>
575
+ step.kind === 'text'
576
+ ? `- 発言: ${step.content}`
577
+ : `- 道具: ${step.name}${step.target ? ` ${step.target}` : ''}${step.failed ? ' → 失敗' : ''}`,
578
+ );
579
+ let budget = IN_FLIGHT_STEPS_MAX_CHARS;
580
+ let first = rendered.length;
581
+ while (first > 0 && budget - (rendered[first - 1].length + 1) >= 0) {
582
+ first -= 1;
583
+ budget -= rendered[first].length + 1;
584
+ }
585
+ const omitted = steps.slice(0, first);
586
+ const omittedTexts = (earlier?.texts ?? 0) + omitted.filter((step) => step.kind === 'text').length;
587
+ const omittedTools =
588
+ Object.values(earlier?.tools ?? {}).reduce((sum, count) => sum + count, 0) +
589
+ omitted.filter((step) => step.kind === 'tool').length;
590
+
591
+ const lines = [
592
+ `**このターンでここまでにしたこと**(古い順。どれも完了済み` +
593
+ (detailClock ? `。道具の入出力の全文: \`throughline detail ${detailClock}\`` : '') +
594
+ `):`,
595
+ ];
596
+ if (omittedTexts + omittedTools > 0) {
597
+ lines.push(`- (これより前に、発言 ${omittedTexts} 件・道具 ${omittedTools} 回)`);
598
+ }
599
+ lines.push(...rendered.slice(first));
600
+ return {
601
+ lines,
602
+ shownTexts: new Set(steps.slice(first).filter((step) => step.kind === 'text').map((step) => step.content)),
603
+ };
604
+ }
605
+
552
606
  /**
553
607
  * 自動継続で立てた新しい会話へ注入するテキスト (ADR 0033)。
554
608
  *
555
609
  * 構成は /clear の引き継ぎと同じ予算付き(ヘッダ + 現在地 + 案内 + 入るだけの L2)。違いは現在地で、
556
- * DB の最新ターンではなく、止めた時点に記録した作業途中のターンを載せる。
610
+ * DB の最新ターンではなく、止めた時点に記録した作業途中のターンを載せる。依頼、ここまでにしたこと
611
+ * (発言と道具の呼び出し)、止める直前の発言、実行されなかった道具の順。止めたターンは L2 の一覧から外す。
557
612
  *
558
613
  * @param {import('node:sqlite').DatabaseSync} db
559
614
  * @param {{
@@ -561,6 +616,11 @@ function truncateInFlight(text, maxChars) {
561
616
  * inFlight: {
562
617
  * user: {content: string, timestamp: number|null},
563
618
  * last_fragment: {content: string, timestamp: number|null} | null,
619
+ * steps?: Array<{kind: 'text', content: string, timestamp: number|null} | {kind: 'tool', name: string, target: string, failed: boolean}>,
620
+ * earlier_steps?: {texts: number, tools: Record<string, number>} | null,
621
+ * stopped_tools?: Array<{name: string, target: string}>,
622
+ * stopped_tools_total?: number,
623
+ * turn?: {origin_session_id: string, turn_number: number, user_at: number, assistant_at: number|null} | null,
564
624
  * } | null,
565
625
  * projectPath?: string | null, 前の会話の作業ディレクトリ。依頼の中の相対的な場所の基準として渡す
566
626
  * maxChars?: number,
@@ -573,17 +633,31 @@ export function buildAutoContinuationContext(
573
633
  const stored = buildResumeSections(db, { sessionId, isInheritance: true, handoffDisclosure: 'silent' });
574
634
  const stamp = (timestamp) => (timestamp == null ? '' : ` [${formatClock(timestamp)}]`);
575
635
  const anchorLines = [];
636
+ const turn = inFlight?.turn ?? null;
637
+ const turnKey = turn ? `${turn.origin_session_id}\x00${turn.turn_number}` : null;
576
638
  if (inFlight) {
577
639
  anchorLines.push(
578
640
  `**作業中のユーザー依頼**${stamp(inFlight.user.timestamp)}: ` +
579
641
  truncateInFlight(inFlight.user.content, IN_FLIGHT_USER_MAX_CHARS),
580
642
  );
581
- if (inFlight.last_fragment) {
643
+ const steps = renderInFlightSteps(inFlight, turn ? formatClock(turn.assistant_at ?? turn.user_at) : null);
644
+ anchorLines.push(...steps.lines);
645
+ // 一覧に同じ文がそのまま載っている時は、重ねて載せない。改行のある発言や長い発言は、ここに全文を載せる。
646
+ if (inFlight.last_fragment && !steps.shownTexts.has(inFlight.last_fragment.content.trim())) {
582
647
  anchorLines.push(
583
648
  `**止める直前のあなたの発言**${stamp(inFlight.last_fragment.timestamp)}: ` +
584
649
  truncateInFlight(inFlight.last_fragment.content, IN_FLIGHT_ASSISTANT_MAX_CHARS),
585
650
  );
586
651
  }
652
+ const stoppedTools = inFlight.stopped_tools ?? [];
653
+ if (stoppedTools.length > 0) {
654
+ const more = (inFlight.stopped_tools_total ?? stoppedTools.length) - stoppedTools.length;
655
+ anchorLines.push(
656
+ `**止める直前に呼ぼうとして、実行されなかった道具**: ` +
657
+ stoppedTools.map((tool) => `${tool.name}${tool.target ? ` ${tool.target}` : ''}`).join('、') +
658
+ (more > 0 ? `(ほか ${more} 件)` : ''),
659
+ );
660
+ }
587
661
  }
588
662
  if (!stored && anchorLines.length === 0) return null;
589
663
 
@@ -593,8 +667,10 @@ export function buildAutoContinuationContext(
593
667
  header: AUTO_CONTINUATION_HEADER(projectPath),
594
668
  anchorTitle: AUTO_CONTINUATION_ANCHOR_TITLE,
595
669
  anchorLines,
670
+ anchorTurnKeys: turnKey ? [turnKey] : [],
596
671
  l1Lines: [],
597
- l2Lines: stored?.l2Lines ?? [],
672
+ // 止めたターンは現在地に載せた。L2 の一覧には、それより前のターンだけを載せる。
673
+ l2Lines: (stored?.l2Lines ?? []).filter((line) => line.turnKey !== turnKey),
598
674
  },
599
675
  { sessionId, excludeOriginId: null, maxChars },
600
676
  );
@@ -939,7 +939,7 @@ test('buildAutoContinuationContext: 現在地は止めた時点のターンで
939
939
  assert.doesNotMatch(anchor, /前の依頼/);
940
940
  assert.match(result.text, /### 直前の対話 \(L2 \/ active work thread, 古い順\)\n\[\d\d:\d\d:\d\d\] \[user\]: 前の依頼\n\[\d\d:\d\d:\d\d\] \[assistant\]: 前の回答/);
941
941
  assert.doesNotMatch(result.text, /宣言|\/clear|圧縮の要約/);
942
- assert.match(result.text, /止める直前に呼ぼうとした道具は実行されていません/);
942
+ assert.match(result.text, /未実行なのは「実行されなかった道具」だけです/);
943
943
  assert.equal(result.injectedL2Turns, 1);
944
944
  });
945
945
 
@@ -957,8 +957,8 @@ test('buildAutoContinuationContext: 長い依頼は先頭と末尾を残し、
957
957
  },
958
958
  });
959
959
  assert.ok(result.totalChars <= INJECTION_BUDGET_CHARS, `${result.totalChars}`);
960
- assert.match(result.text, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: 先頭の指示ヰ+ …\(長いため中略 2010 字\)… ヰ+末尾の指示\n/);
961
- assert.equal((result.text.match(/ヰ/g) ?? []).length, 3_990);
960
+ assert.match(result.text, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: 先頭の指示ヰ+ …\(長いため中略 2510 字\)… ヰ+末尾の指示\n/);
961
+ assert.equal((result.text.match(/ヰ/g) ?? []).length, 3_490);
962
962
  assert.equal((result.text.match(/ヱ/g) ?? []).length, 1_500);
963
963
  assert.match(result.text, /ヱ …\(長いため中略 1500 字\)… ヱ/);
964
964
  assert.ok(result.injectedL2Turns >= 1, '最新の保存済みターンは入る');
@@ -966,3 +966,71 @@ test('buildAutoContinuationContext: 長い依頼は先頭と末尾を残し、
966
966
  assert.equal(result.injectedL2Turns + result.remainingL2Turns, 12);
967
967
  assert.match(result.text, /throughline recall --l2 --session S /);
968
968
  });
969
+
970
+ test('buildAutoContinuationContext: 止めたターンでここまでにしたことと、実行されなかった道具を現在地に載せ、そのターンをL2に重ねない', () => {
971
+ const db = makeDb();
972
+ insertBody(db, { session: 'S', origin: 'P0', turn: 1, role: 'user', text: '前の依頼', createdAt: 1_000 });
973
+ insertBody(db, { session: 'S', origin: 'P0', turn: 1, role: 'assistant', text: '前の回答', createdAt: 2_000 });
974
+ // worker が取り込んだ止めたターン(発言を全部つないだ本文)
975
+ insertBody(db, { session: 'S', origin: 'P', turn: 7, role: 'user', text: '今の依頼', createdAt: 3_000 });
976
+ insertBody(db, { session: 'S', origin: 'P', turn: 7, role: 'assistant', text: 'a を直しました。\n\nb を読みます。', createdAt: 5_000 });
977
+ const result = buildAutoContinuationContext(db, {
978
+ sessionId: 'S',
979
+ inFlight: {
980
+ user: { content: '今の依頼', timestamp: 3_000 },
981
+ last_fragment: { content: 'b を読みます。', timestamp: 5_000 },
982
+ steps: [
983
+ { kind: 'tool', name: 'Edit', target: '/work/a.mjs', failed: false },
984
+ { kind: 'tool', name: 'Bash', target: 'npm test', failed: true },
985
+ { kind: 'text', content: 'a を直しました。', timestamp: 4_000 },
986
+ { kind: 'text', content: 'b を読みます。', timestamp: 5_000 },
987
+ ],
988
+ earlier_steps: null,
989
+ stopped_tools: [{ name: 'Read', target: '/work/b.mjs' }, { name: 'Read', target: '/work/c.mjs' }],
990
+ stopped_tools_total: 3,
991
+ turn: { origin_session_id: 'P', turn_number: 7, user_at: 3_000, assistant_at: 5_000 },
992
+ },
993
+ });
994
+ const anchor = result.text.slice(result.text.indexOf('### 現在地'), result.text.indexOf('### さらに前の記憶'));
995
+ assert.match(anchor, new RegExp(
996
+ '\\*\\*作業中のユーザー依頼\\*\\* \\[\\d\\d:\\d\\d:\\d\\d\\]: 今の依頼\\n' +
997
+ '\\*\\*このターンでここまでにしたこと\\*\\*(古い順。どれも完了済み。道具の入出力の全文: `throughline detail \\d\\d:\\d\\d:\\d\\d`):\\n' +
998
+ '- 道具: Edit /work/a\\.mjs\\n- 道具: Bash npm test → 失敗\\n- 発言: a を直しました。\\n- 発言: b を読みます。\\n' +
999
+ '\\*\\*止める直前に呼ぼうとして、実行されなかった道具\\*\\*: Read /work/b\\.mjs、Read /work/c\\.mjs(ほか 1 件)\\n'));
1000
+ assert.doesNotMatch(anchor, /止める直前のあなたの発言/, '一覧に同じ文が載っている発言は重ねない');
1001
+ const l2 = result.text.slice(result.text.indexOf('### 直前の対話'));
1002
+ assert.match(l2, /\[user\]: 前の依頼\n\[\d\d:\d\d:\d\d\] \[assistant\]: 前の回答/);
1003
+ assert.doesNotMatch(l2, /今の依頼|a を直しました/, '止めたターンは現在地だけに載せる');
1004
+ assert.equal(result.injectedL2Turns, 1);
1005
+ assert.equal(result.olderTurns, 0, '止めたターンを、窓の外の古いターンに数えない');
1006
+ });
1007
+
1008
+ test('buildAutoContinuationContext: 改行のある直前の発言は全文を載せ、多い手順は新しい側から入るだけ載せて残りは件数にする', () => {
1009
+ const db = makeDb();
1010
+ const steps = [];
1011
+ for (let n = 1; n <= 80; n++) {
1012
+ steps.push({ kind: 'tool', name: 'Read', target: `/work/file-${String(n).padStart(3, '0')}-${'x'.repeat(40)}.mjs`, failed: false });
1013
+ }
1014
+ steps.push({ kind: 'text', content: '方針: 1. 型を直す 2. 試験を足す', timestamp: 9_000 });
1015
+ const result = buildAutoContinuationContext(db, {
1016
+ sessionId: 'S',
1017
+ inFlight: {
1018
+ user: { content: '大きな依頼', timestamp: 1_000 },
1019
+ last_fragment: { content: '方針:\n1. 型を直す\n2. 試験を足す', timestamp: 9_000 },
1020
+ steps,
1021
+ earlier_steps: { texts: 4, tools: { Read: 30, Edit: 6 } },
1022
+ stopped_tools: [{ name: 'Edit', target: '/work/types.mjs' }],
1023
+ stopped_tools_total: 1,
1024
+ turn: null,
1025
+ },
1026
+ });
1027
+ assert.ok(result.totalChars <= INJECTION_BUDGET_CHARS, `${result.totalChars}`);
1028
+ assert.match(result.text, /\*\*このターンでここまでにしたこと\*\*(古い順。どれも完了済み):\n- (これより前に、発言 4 件・道具 (\d+) 回)\n- 道具: Read /);
1029
+ const omittedTools = Number(/これより前に、発言 4 件・道具 (\d+) 回/.exec(result.text)[1]);
1030
+ const shownTools = (result.text.match(/^- 道具: Read /gm) ?? []).length;
1031
+ assert.equal(omittedTools + shownTools, 36 + 80, '載せた分と件数にした分で全部');
1032
+ assert.ok(shownTools >= 20 && shownTools < 80, `${shownTools}`);
1033
+ assert.doesNotMatch(result.text, /- 発言: 方針/, '下に全文を載せる発言は、一覧に重ねない');
1034
+ assert.match(result.text, /- 道具: Read \/work\/file-080-x+\.mjs\n\*\*止める直前のあなたの発言\*\* \[\d\d:\d\d:\d\d\]: 方針:\n1\. 型を直す\n2\. 試験を足す\n\*\*止める直前に呼ぼうとして、実行されなかった道具\*\*: Edit \/work\/types\.mjs\n/);
1035
+ });
1036
+
@@ -387,6 +387,28 @@ export function sliceCurrentTurnEntries(entries) {
387
387
  return entries.slice(userTextIdx, lastAssistantTextIdx + 1);
388
388
  }
389
389
 
390
+ /**
391
+ * 作業途中で止めたターンのエントリ範囲 (ADR 0033)。最後の user 本文の行から、transcript の末尾まで。
392
+ *
393
+ * sliceCurrentTurnEntries は最後の assistant 本文で切るので、その後に呼んだ道具が入らない。
394
+ * hook で止めたターンは、止めた道具の呼び出しと結果が末尾に並ぶ。それも同じターンとして返す。
395
+ *
396
+ * @param {Array<object>} entries readRawEntries の結果
397
+ * @returns {Array<object>}
398
+ */
399
+ export function sliceInFlightTurnEntries(entries) {
400
+ for (let i = entries.length - 1; i >= 0; i--) {
401
+ const e = entries[i];
402
+ if (entryKind(e) !== 'user' || isCompactSummaryEntry(e)) continue;
403
+ const blocks = e.message?.content;
404
+ const hasText = Array.isArray(blocks)
405
+ ? blocks.some((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.length > 0)
406
+ : typeof blocks === 'string' && blocks.length > 0;
407
+ if (hasText) return entries.slice(i);
408
+ }
409
+ return [];
410
+ }
411
+
390
412
  /**
391
413
  * 論理ターン内の全エントリから L3 (details) 用の生レコードを抽出する。
392
414
  *
@@ -10,6 +10,7 @@ import {
10
10
  getLogicalTurnGroups,
11
11
  readLatestUserGroup,
12
12
  sliceCurrentTurnEntries,
13
+ sliceInFlightTurnEntries,
13
14
  extractDetailBlocks,
14
15
  } from './transcript-reader.mjs';
15
16
  import { DETAIL_KIND } from './constants.mjs';
@@ -528,3 +529,26 @@ test('extractDetailBlocks: 複数ツール連続呼び出しを全て拾う', ()
528
529
  [DETAIL_KIND.TOOL_INPUT, DETAIL_KIND.TOOL_OUTPUT, DETAIL_KIND.TOOL_INPUT, DETAIL_KIND.TOOL_OUTPUT],
529
530
  );
530
531
  });
532
+
533
+ test('sliceInFlightTurnEntries: 最後の user 本文から末尾まで。最後の発言の後に呼んだ道具と、圧縮の要約行より前の本文も扱う', () => {
534
+ const entries = [
535
+ { type: 'user', message: { role: 'user', content: '前の依頼' } },
536
+ { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: '前の回答' }] } },
537
+ { type: 'user', message: { role: 'user', content: [{ type: 'text', text: '今の依頼' }] } },
538
+ { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: '途中です。' }] } },
539
+ { type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'Read', input: {} }] } },
540
+ { type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: 'hook error' }] } },
541
+ { type: 'attachment', attachment: { type: 'hook_stopped_continuation' } },
542
+ ];
543
+ const slice = sliceInFlightTurnEntries(entries);
544
+ assert.equal(slice.length, 5);
545
+ assert.equal(slice[0].message.content[0].text, '今の依頼');
546
+ assert.equal(sliceCurrentTurnEntries(entries).length, 2, '完了したターン用の切り出しは、最後の発言で終わる');
547
+ assert.deepEqual(extractDetailBlocks(slice).map((d) => d.source_id), ['t1', 't1:result', null]);
548
+
549
+ // 圧縮の要約行はターンの始まりにしない
550
+ const summary = { type: 'user', isCompactSummary: true, message: { role: 'user', content: '要約…' } };
551
+ assert.equal(sliceInFlightTurnEntries([entries[2], entries[3], summary, entries[4]]).length, 4);
552
+ assert.deepEqual(sliceInFlightTurnEntries([entries[1]]), []);
553
+ });
554
+
@@ -20,7 +20,14 @@
20
20
  import { appendFileSync, mkdirSync } from 'node:fs';
21
21
  import { homedir } from 'node:os';
22
22
  import { dirname, join } from 'node:path';
23
- import { getLogicalTurnGroups } from './transcript-reader.mjs';
23
+ import {
24
+ extractDetailBlocks,
25
+ getLogicalTurnGroups,
26
+ isJunkAssistantText,
27
+ readLatestUserGroup,
28
+ readRawEntries,
29
+ sliceInFlightTurnEntries,
30
+ } from './transcript-reader.mjs';
24
31
 
25
32
  /**
26
33
  * バックフィル回収実績を ~/.throughline/logs/backfill.log に 1 行 JSON で記録する。
@@ -132,3 +139,75 @@ export function backfillBodies(db, { targetSessionId, originSessionId, transcrip
132
139
  turnNumbers: groups.map((group) => group.representative.index),
133
140
  };
134
141
  }
142
+
143
+ /**
144
+ * 作業途中で止めたターンを bodies と details へ取り込む (ADR 0033)。
145
+ *
146
+ * hook で止めたターンでは Stop hook が走らない。止めた側(自動継続の worker)が、後継を立てる前に呼ぶ。
147
+ * Codex の自動継続が、止めたターンを取り込んでから記憶を作るのと同じ。
148
+ *
149
+ * - 完了したターンは最後の本文(代表断片)だけを残すが、止めたターンには結論の発言が無い。
150
+ * ここまでの発言を全部つないで assistant の本文にする。本文が1つも無ければ user の行だけを入れる。
151
+ * - turn_number は最後の本文断片の index(無ければ user 行の index)。backfillBodies は断片の index が
152
+ * bodies にある群を回収しないので、後継が前任を合流させる時に、代表断片の行が重ねて入らない。
153
+ * - details は最後の user 本文の行から transcript の末尾まで。止めた道具の呼び出しも入る。
154
+ * source_id の UNIQUE で、同じターンをもう一度取り込んでも増えない。
155
+ *
156
+ * @param {import('node:sqlite').DatabaseSync} db
157
+ * @param {object} opts
158
+ * @param {string} opts.targetSessionId 書き込み先 session_id(合流済みなら合流先)
159
+ * @param {string} opts.originSessionId transcript を持つ session_id
160
+ * @param {string|null|undefined} opts.transcriptPath
161
+ * @param {number} opts.now timestamp が無い時と details の created_at
162
+ * @returns {{turnNumber: number, userAt: number, assistantAt: number|null, insertedBodies: boolean, details: number}|null}
163
+ */
164
+ export function captureInFlightTurn(db, { targetSessionId, originSessionId, transcriptPath, now }) {
165
+ const latest = readLatestUserGroup(transcriptPath);
166
+ if (!latest) return null;
167
+ const fragments = latest.fragments.filter((f) => !isJunkAssistantText(f.content));
168
+ const candidates = [latest.user.turn_number, ...latest.fragments.map((f) => f.index)];
169
+ const captured = db
170
+ .prepare(
171
+ `SELECT turn_number FROM bodies
172
+ WHERE origin_session_id = ? AND turn_number IN (${candidates.map(() => '?').join(',')})
173
+ ORDER BY turn_number DESC LIMIT 1`,
174
+ )
175
+ .get(originSessionId, ...candidates);
176
+ const turnNumber = captured?.turn_number ?? fragments.at(-1)?.index ?? latest.user.turn_number;
177
+ const userAt = latest.user.timestamp ?? now;
178
+ const assistantAt = fragments.length > 0 ? (fragments.at(-1).timestamp ?? now) : null;
179
+ const assistantText = fragments.map((f) => f.content.trim()).join('\n\n');
180
+ const detailBlocks = extractDetailBlocks(sliceInFlightTurnEntries(readRawEntries(transcriptPath)));
181
+
182
+ const insertBody = db.prepare(
183
+ `INSERT OR IGNORE INTO bodies
184
+ (session_id, origin_session_id, turn_number, role, text, token_count, created_at, turn_start)
185
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
186
+ );
187
+ const insertDetail = db.prepare(
188
+ `INSERT OR IGNORE INTO details
189
+ (session_id, origin_session_id, turn_number, tool_name, input_text, output_text,
190
+ token_count, created_at, kind, source_id)
191
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
192
+ );
193
+ db.exec('BEGIN');
194
+ try {
195
+ if (!captured) {
196
+ insertBody.run(targetSessionId, originSessionId, turnNumber, 'user', latest.user.content,
197
+ Math.round(latest.user.content.length / 4), userAt, latest.user.start);
198
+ if (assistantText) {
199
+ insertBody.run(targetSessionId, originSessionId, turnNumber, 'assistant', assistantText,
200
+ Math.round(assistantText.length / 4), assistantAt, null);
201
+ }
202
+ }
203
+ for (const d of detailBlocks) {
204
+ insertDetail.run(targetSessionId, originSessionId, turnNumber, d.tool_name, d.input_text, d.output_text,
205
+ Math.round(((d.input_text?.length ?? 0) + (d.output_text?.length ?? 0)) / 4), now, d.kind, d.source_id);
206
+ }
207
+ db.exec('COMMIT');
208
+ } catch (err) {
209
+ db.exec('ROLLBACK');
210
+ throw err;
211
+ }
212
+ return { turnNumber, userAt, assistantAt, insertedBodies: !captured, details: detailBlocks.length };
213
+ }
@@ -4,7 +4,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir, homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { DatabaseSync } from 'node:sqlite';
7
- import { backfillBodies, deriveTranscriptPath } from './turn-backfill.mjs';
7
+ import { backfillBodies, captureInFlightTurn, deriveTranscriptPath } from './turn-backfill.mjs';
8
8
 
9
9
  function makeDb() {
10
10
  const db = new DatabaseSync(':memory:');
@@ -277,3 +277,77 @@ test('backfillBodies: 圧縮の要約行を数えていた頃に保存したタ
277
277
  },
278
278
  );
279
279
  });
280
+
281
+ // --- 作業途中で止めたターンの取り込み (ADR 0033) ---
282
+
283
+ function withDetails(db) {
284
+ db.exec(`
285
+ CREATE TABLE details (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, origin_session_id TEXT,
286
+ turn_number INTEGER, tool_name TEXT NOT NULL, input_text TEXT, output_text TEXT, token_count INTEGER NOT NULL DEFAULT 0,
287
+ created_at INTEGER NOT NULL, kind TEXT, source_id TEXT);
288
+ CREATE UNIQUE INDEX uq_details_source ON details(session_id, origin_session_id, source_id) WHERE source_id IS NOT NULL;
289
+ `);
290
+ return db;
291
+ }
292
+
293
+ const toolUse = (id, name, input) => ({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] } });
294
+ const toolResult = (id, content) => ({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content }] } });
295
+
296
+ test('captureInFlightTurn: 止めたターンの発言を全部つないで本文にし、末尾の道具まで details に入れる。合流時の回収は重ねない', () => {
297
+ withData(
298
+ [
299
+ entry('user', '前の依頼', '2026-10-04T02:00:00Z'),
300
+ entry('assistant', '前の回答', '2026-10-04T02:00:05Z'),
301
+ entry('user', '3つ読んで', '2026-10-04T03:00:00Z'),
302
+ toolUse('t1', 'Read', { file_path: '/w/1' }),
303
+ toolResult('t1', 'one'),
304
+ entry('assistant', '1 を読みました。', '2026-10-04T03:00:02Z'),
305
+ toolUse('t2', 'Read', { file_path: '/w/2' }),
306
+ toolResult('t2', 'two'),
307
+ entry('assistant', '2 を読みました。', '2026-10-04T03:00:04Z'),
308
+ toolUse('t3', 'Read', { file_path: '/w/3' }),
309
+ toolResult('t3', 'PreToolUse:Read hook error'),
310
+ ],
311
+ (path) => {
312
+ const db = withDetails(makeDb());
313
+ const captured = captureInFlightTurn(db, { targetSessionId: 'S', originSessionId: 'S', transcriptPath: path, now: 9_000 });
314
+ assert.deepEqual(captured, { turnNumber: 4, userAt: Date.parse('2026-10-04T03:00:00Z'),
315
+ assistantAt: Date.parse('2026-10-04T03:00:04Z'), insertedBodies: true, details: 6 });
316
+ assert.deepEqual(db.prepare('SELECT turn_number, role, text, turn_start FROM bodies ORDER BY id').all().map((row) => ({ ...row })), [
317
+ { turn_number: 4, role: 'user', text: '3つ読んで', turn_start: 'unknown' },
318
+ { turn_number: 4, role: 'assistant', text: '1 を読みました。\n\n2 を読みました。', turn_start: null },
319
+ ]);
320
+ assert.deepEqual(db.prepare('SELECT source_id, turn_number FROM details ORDER BY id').all().map((row) => `${row.source_id}@${row.turn_number}`),
321
+ ['t1@4', 't1:result@4', 't2@4', 't2:result@4', 't3@4', 't3:result@4'], '最後の発言の後に呼んだ、止めた道具も入る');
322
+
323
+ // もう一度取り込んでも増えない
324
+ const again = captureInFlightTurn(db, { targetSessionId: 'S', originSessionId: 'S', transcriptPath: path, now: 9_500 });
325
+ assert.equal(again.turnNumber, 4);
326
+ assert.equal(again.insertedBodies, false);
327
+ assert.equal(db.prepare('SELECT COUNT(*) AS n FROM details').get().n, 6);
328
+
329
+ // 後継が前任を合流させる時の回収: 完了した前のターンだけを入れ、止めたターンへ代表断片を重ねない
330
+ const backfill = backfillBodies(db, { targetSessionId: 'S', originSessionId: 'S', transcriptPath: path, now: 9_900 });
331
+ assert.equal(backfill.insertedTurns, 1);
332
+ assert.equal(backfill.skippedExisting, 1);
333
+ assert.equal(db.prepare(`SELECT text FROM bodies WHERE turn_number = 4 AND role = 'assistant'`).get().text,
334
+ '1 を読みました。\n\n2 を読みました。');
335
+ },
336
+ );
337
+ });
338
+
339
+ test('captureInFlightTurn: 発言が1つも無いターンは、user の行と道具の入出力だけを入れる', () => {
340
+ withData(
341
+ [entry('user', '黙って読んで', '2026-10-04T03:00:00Z'), toolUse('t1', 'Read', { file_path: '/w/1' }), toolResult('t1', 'one'),
342
+ toolUse('t2', 'Read', { file_path: '/w/2' })],
343
+ (path) => {
344
+ const db = withDetails(makeDb());
345
+ const captured = captureInFlightTurn(db, { targetSessionId: 'S', originSessionId: 'S', transcriptPath: path, now: 9_000 });
346
+ assert.deepEqual(captured, { turnNumber: 0, userAt: Date.parse('2026-10-04T03:00:00Z'), assistantAt: null, insertedBodies: true, details: 3 });
347
+ assert.deepEqual(db.prepare('SELECT turn_number, role, text FROM bodies').all().map((row) => ({ ...row })),
348
+ [{ turn_number: 0, role: 'user', text: '黙って読んで' }]);
349
+ assert.equal(captureInFlightTurn(db, { targetSessionId: 'S', originSessionId: 'S', transcriptPath: join(path, 'none'), now: 1 }), null);
350
+ },
351
+ );
352
+ });
353
+