throughline 0.16.17 → 0.16.19

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,42 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.16.19] — 2026-10-11
14
+
15
+ ### 修正
16
+
17
+ - Claude Codeの自動継続で、作業ツリー(`<repository>/.claude/worktrees/<名前>`)の後継をClaude Desktopが取り込んだ後にもう一度引き継ぐと、後継が元のrepositoryで立ち、前任の記憶が入らず、Desktopへも移らなかった([ADR 0049](docs/adr/0049-claude-handoff-uses-registered-project.md))。Windowsの実機(Claude Code 2.1.296)で2026-10-10 23:43に1回起きた。hookの`CLAUDE_PROJECT_DIR`が元のrepositoryを指し、会話を登録したproject(作業ツリー)と違っていた。
18
+ - 後継を立てるprojectを、会話が移った先(`relocated`)、会話を登録したproject(`sessions`。その場所が今もある時)、`CLAUDE_PROJECT_DIR`、hookのcwdの順で決める。
19
+ - 受領を確かめられなかった引き継ぎ(`unknown`・`handoff_delivery_unconfirmed`)でも、その後継がターンを終えたら、Claude Desktopへ移す。今までは裏の会話のまま残り、リモートコントロールからしか見えなかった。`state`と`error_code`は変えない。
20
+
21
+ ### 対応の範囲
22
+
23
+ | | macOS | Linux | Windows |
24
+ |---|---|---|---|
25
+ | Claude Code | 直した。単体試験と、切り離した置き場での本物のhookの命令(作業ツリーで登録した会話へ、`CLAUDE_PROJECT_DIR`を元のrepositoryにして`pre-compact`を渡し、後継が作業ツリーで立って記憶が合流する事。直す前の版は`project_mismatch`になる事)。**本物のClaude Desktopが作業ツリーの後継を取り込む場面は、macOSでは未確認** | 直した。単体試験と、切り離した置き場での本物のhookの命令。Claude Desktopへ移す処理はLinuxでは対象外(Claude Codeの`--desktop`が無い)なので、`unknown`の後継を移す直しは働かない | 直した。単体試験と、切り離した置き場での本物のhookの命令(`CLAUDE_PROJECT_DIR`は`C:/…`の形)。実機で起きたのはこのOS。**直した版での本物の引き継ぎは、次に起きた時に記録で確かめる** |
26
+ | Codex | 変更なし。projectはhookのcwdで決める。引き継ぎの部品も別 | 同じ | 同じ |
27
+ | Grok | 変更なし(自動継続が無い) | 同じ | 同じ |
28
+ | Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
29
+
30
+ ## [0.16.18] — 2026-10-11
31
+
32
+ ### 変更
33
+
34
+ - Codexの`PreCompact(auto)`のhookが`handoff_hook_identity_invalid`で失敗した時、失敗ログ(`~/.throughline/logs/hook-failures.log`)の行へ、渡された合図の形(`identity`)を残す。0.16.14で内部のID(`auto-compact-N`)を失敗から外したが、0.16.16のmacOSの実機で同じ失敗が2回出た(2026-10-10 16:55:08と16:55:15、引き継ぎ先のタスクを作った直後)。内部の圧縮の記録は0行で、0.16.14が想定した形ではなかった。失敗ログには文面と位置しか無く、どの値が不正だったかを決められなかった。
35
+ - 残すのは、会話のID・ターンのID・agentのID・agentの種類・モデル名・`trigger`と、`transcript_path`・`cwd`が絶対pathかどうか。pathの中身と、記号の混じる値は残さない(長さだけ)。会話の本文は入らない。
36
+ - 失敗の扱い(圧縮を止める・失敗として数える)は変えていない。原因が決まるまで、この失敗は今までどおり記録される。
37
+
38
+ ### 対応の範囲
39
+
40
+ | | macOS | Linux | Windows |
41
+ |---|---|---|---|
42
+ | Codex(Desktop) | 足した。単体試験と、切り離した置き場での本物のhookの命令(不正な合図を渡して、失敗ログの行に`identity`が出る事)。**本物のCodex Desktopがこの失敗を起こす場面は、狙って起こせないので未確認。次に起きた行で原因を決める** | 足した。単体試験と、切り離した置き場での本物のhookの命令。実機(同梱0.162.0-alpha.2)では、この失敗が起きていない | 足した。単体試験と、切り離した置き場での本物のhookの命令。実機では、この失敗の記録が無い |
43
+ | Codex(CLI・席) | 自動継続の対象外(Desktop以外は、この検査の前後で何もしない)。対象外の呼び出しがこの検査で失敗した時は、同じ形が残る | 同じ。BellTeamのコンテナとmain-serverは自動継続が無効で、この処理へ入らない | 同じ |
44
+ | Claude Code | 変更なし。この失敗はCodexの`PreCompact`だけが出す。失敗ログの部品は共通だが、`identity`を付けるのはCodexのこの失敗だけ | 同じ | 同じ |
45
+ | Grok | 変更なし(自動継続が無い) | 同じ | 同じ |
46
+ | Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
47
+
48
+
13
49
  ## [0.16.17] — 2026-10-10
14
50
 
15
51
  ### 修正
@@ -2270,7 +2306,9 @@ two attempts, instrument first instead of patching again.
2270
2306
 
2271
2307
  ---
2272
2308
 
2273
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.17...HEAD
2309
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.19...HEAD
2310
+ [0.16.19]: https://github.com/kitepon/Throughline/compare/v0.16.18...v0.16.19
2311
+ [0.16.18]: https://github.com/kitepon/Throughline/compare/v0.16.17...v0.16.18
2274
2312
  [0.16.17]: https://github.com/kitepon/Throughline/compare/v0.16.16...v0.16.17
2275
2313
  [0.16.16]: https://github.com/kitepon/Throughline/compare/v0.16.15...v0.16.16
2276
2314
  [0.16.15]: https://github.com/kitepon/Throughline/compare/v0.16.14...v0.16.15
@@ -38,3 +38,18 @@ Codex は、利用者のターンとは別に、自分で作る内部の文脈
38
38
  - 内部の文脈からの圧縮で、失敗の記録が増えなくなる。圧縮を止める動きは変わらない。
39
39
  - 内部の文脈からの圧縮が何度も起きるタスクでも、圧縮は通らない。利用者のターンの `PreCompact` が来るまで、文脈はそのまま残る。
40
40
  - Codex が内部の ID の形を変えると、また失敗として記録される。その時は記録の message で気づける。
41
+
42
+ ## 追記(2026-10-11)
43
+
44
+ 0.16.16 の macOS の実機で、同じ `handoff_hook_identity_invalid` が2回出た(2026-10-10 16:55:08 と 16:55:15)。どちらも、
45
+ 引き継ぎ先のタスクを作った直後(作成の 1.4 秒後と、最初のターンの開始の 1.5 秒後)。`internal-compactions.jsonl` は
46
+ 0行だった。この ADR が想定した形(ターンの ID が `auto-compact-N`)の呼び出しではなかった事になる。
47
+
48
+ - Context に書いた「内部の文脈から `PreCompact(auto)` を呼ぶ」は、Codex の source を読んだ推測で、実機では確かめていない。
49
+ `next_internal_sub_id` の ID は、`thread/inject_items` の文脈(`new_inject_items_context`)にも使われる。
50
+ - hook へ渡る `transcript_path` は null になり得る(`codex-rs/hooks/src/schema.rs` の `NullableString`)。ターンの中の
51
+ 圧縮は、ターンの ID(UUID)をそのまま渡す(`with_model` は `sub_id` を引き継ぐ)。どの値が不正だったかは、失敗ログに
52
+ 文面と位置しか無く、決められなかった。
53
+ - 0.16.18 で、この失敗の行へ合図の形(`identity`。ID・種類・絶対 path かどうか)を残す。次に起きた行を読んで、
54
+ 扱いを決める。この ADR の Decision 1〜4 は、それまで変えない。
55
+
@@ -0,0 +1,49 @@
1
+ # ADR 0049: Claude の引き継ぎは、会話を登録した project で後継を立てる。受領を確かめられなかった後継も Desktop へ移す
2
+
3
+ 日付: 2026-10-11
4
+
5
+ ## Context
6
+
7
+ Claude Code の自動継続は、PreCompact の hook で、後継を立てる project を決める(ADR 0033)。決め方は、会話が別の project へ
8
+ 移っていれば移った先、そうでなければ hook の環境変数 `CLAUDE_PROJECT_DIR`、無ければ hook の cwd だった。後継の最初の指示は、
9
+ 前任と後継の project(`sessions.project_path`)が同じ時だけ、前任の記憶を合流させる。`sessions` の行は SessionStart の cwd で作る。
10
+
11
+ 2026-10-11、オーナーから「WindowsのClaudeの自動スルーラインで起動する新セッションってRemoteがONで立ち上がってるの?
12
+ なんか意図した挙動と違う気がする」と指摘があった。Windows の実機(Claude Code 2.1.296)の記録を読んだ。
13
+
14
+ - 0.16.16 より後の Claude の引き継ぎは5回。5回とも、後継はリモートコントロール付きで立っていた(ADR 0048)。
15
+ 4回は、後継がターンを終えた後に Claude Desktop へ移っていた(ADR 0043)。
16
+ - 1回(10/10 23:43)は、受領を確かめられないまま(`unknown`・`handoff_delivery_unconfirmed`)だった。後継は立って作業を
17
+ 続けていたが、Desktop へ移らず、裏の会話のまま残った。見えるのはリモートコントロールからだけだった。
18
+ - この回の前任は、作業ツリー(`<repository>/.claude/worktrees/<名前>`)で動く後継を、Claude Desktop が取り込んだ会話だった。
19
+ SessionStart の cwd は作業ツリーで、`sessions` の project も作業ツリーだった。PreCompact の時、`CLAUDE_PROJECT_DIR` は
20
+ 元の repository を指していた。transcript に `relocated` の行は無い。
21
+ - その結果、後継は元の repository(branch は `main`)で立った。前任と project が違うので、記憶は合流しなかった
22
+ (`merge_skip_reason: project_mismatch`)。合流しないと受領も残らないので、引き継ぎは `unknown` で終わった。
23
+ - Desktop へ移す処理は、受領が残った引き継ぎ(`sent`)の後継だけを対象にしていた。`unknown` の記録には後継の session id が
24
+ 無く、後継の Stop では何も起きなかった。
25
+
26
+ ## Decision
27
+
28
+ 1. PreCompact で後継を立てる project は、次の順で決める。会話が移った先(`relocated`)、会話を登録した project
29
+ (`sessions.project_path`。その場所が今もある時)、`CLAUDE_PROJECT_DIR`、hook の cwd。
30
+ 印(baton)・有効かどうかの判定・後継を立てる場所は、同じ値を使う。
31
+ 2. `sessions` を読むのは、自動圧縮で、自動継続が有効で、まだ引き継ぎの記録が無い時だけ。読めない時と、登録した場所が
32
+ もう無い時(作業ツリーを消した後)は、今までと同じ値で進める。
33
+ 3. 受領を確かめられなかった引き継ぎ(`unknown`、後継の短い ID だけが記録にある)でも、その後継がターンを終えたら、
34
+ Desktop へ移す。継続の指示は起動時の引数で渡してあるので、ターンを終えた後継は指示を受けている。
35
+ 後継の session id を記録へ入れる。`state` と `error_code` は変えない(記憶が合流しなかった事は、記録に残す)。
36
+ 4. 後継が立たなかった引き継ぎ(`failed`)は、今までどおり何もしない。
37
+
38
+ ## Consequences
39
+
40
+ - 作業ツリーの会話は、Desktop が取り込んだ後に引き継いでも、同じ作業ツリーで続く。記憶も合流する。
41
+ - 何かの理由で受領を確かめられなかった時も、後継は裏の会話のまま残らない。ただし、その後継に前任の記憶が入ったかは
42
+ 別の話で、`unknown` の記録を見て確かめる必要がある。
43
+ - 会話を起動した後で `CLAUDE_PROJECT_DIR` だけが変わる場面では、登録した project が優先される。会話を別の project へ
44
+ 移す場面は、今までどおり `relocated` の行で分かる。
45
+ - Stop の完了受領の project(`turn-processor`)は変えていない。Desktop が取り込んだ作業ツリーの会話の完了受領は、
46
+ 今までどおり `CLAUDE_PROJECT_DIR`(元の repository)へ出る。
47
+ - リモートコントロール付きで立てた後継は、Desktop へ移す時に止める(`claude stop`)。起動時のリモートの接続はそこで切れ、
48
+ 移った後は Claude Desktop 自身のリモートの設定に従う。ここは変えていない。
49
+ - Codex・Grok・Cursor は、hook の cwd で project を決める。この ADR の対象は Claude Code だけ。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.16.17",
3
+ "version": "0.16.19",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -249,6 +249,23 @@ function nativeProjectPath(projectPath, platform) {
249
249
  return (platform === 'win32' ? win32 : posix).resolve(projectPath);
250
250
  }
251
251
 
252
+ /**
253
+ * その会話を sessions へ登録した project(SessionStart の cwd)。行が無い時、その場所がもう無い時は null。
254
+ *
255
+ * 作業ツリーで動く後継を Claude Desktop が取り込むと、hook の CLAUDE_PROJECT_DIR は元の repository を指す
256
+ * (Windows、Claude Code 2.1.296 で実測)。そこで後継を立てると、後継は別の作業ツリーで始まり、前任と project が
257
+ * 違うので記憶も合流しない(ADR 0049)。登録した project で立てれば、前任と後継の project がそろう。
258
+ */
259
+ function registeredProjectPath(db, sessionId, platform) {
260
+ let registered;
261
+ // sessions を読めない時は、hook の環境から読んだ project で進める(今までと同じ動き)。
262
+ try { registered = db.prepare('SELECT project_path FROM sessions WHERE session_id = ?').get(sessionId)?.project_path; }
263
+ catch { return null; }
264
+ if (typeof registered !== 'string' || !(posix.isAbsolute(registered) || win32.isAbsolute(registered))) return null;
265
+ const native = nativeProjectPath(registered, platform);
266
+ return existsSync(native) ? native : null;
267
+ }
268
+
252
269
  /**
253
270
  * PreCompact hook の本体。自動圧縮で、その project が有効な時だけ、印と記録を残して圧縮を止める。
254
271
  *
@@ -277,9 +294,15 @@ export function requestClaudeAutoHandoff({
277
294
  const existing = readClaudeAutoHandoff(sessionId, dir);
278
295
  // 有効判定は会話を起動した project で行う。hook の cwd は Bash の cd に追従する。
279
296
  // 会話が別の project へ移っている時(Claude Desktop)は、移った先で行う。
280
- const projectPath = nativeProjectPath(
281
- claudeHostAdapter.completionProjectPath({
282
- cwd: payload.cwd ?? process.cwd(), env, transcriptPath: payload.transcript_path }), platform);
297
+ const relocated = readClaudeRelocatedCwd(payload.transcript_path);
298
+ const hookProjectPath = nativeProjectPath(
299
+ relocated ?? claudeHostAdapter.completionProjectPath({ cwd: payload.cwd ?? process.cwd(), env }), platform);
300
+ let db = null;
301
+ const open = () => (db ??= openDb());
302
+ // 自動圧縮で、機能が有効な時だけ、会話を登録した project を読む(ADR 0049)。
303
+ const projectPath = payload.trigger === 'auto' && !existing && config.enabled && !relocated
304
+ ? registeredProjectPath(open(), sessionId, platform) ?? hookProjectPath
305
+ : hookProjectPath;
283
306
 
284
307
  if (payload.trigger !== 'auto') {
285
308
  // 人が /compact を選んだ。まだ止めていない記録は取り下げる。
@@ -298,15 +321,14 @@ export function requestClaudeAutoHandoff({
298
321
  }
299
322
 
300
323
  // /tl と同じ印。後継の最初の指示が、この会話を前任として合流させる。
301
- const db = openDb();
302
324
  // 会話が別の project へ移っている時(Claude Desktop)は、sessions の project も移った先にそろえる。
303
325
  // 合流は前任と後継の project が同じ時だけ行う。付け替えは Stop でも行うが、移った後に1回も Stop を
304
326
  // 通らないまま引き継ぐ会話があり、その時は後継へ記憶が入らなかった(0.15.2・0.15.3、macOS で実測)。
305
- if (readClaudeRelocatedCwd(payload.transcript_path)) {
306
- db.prepare('UPDATE sessions SET project_path = ? WHERE session_id = ? AND project_path <> ?')
327
+ if (relocated) {
328
+ open().prepare('UPDATE sessions SET project_path = ? WHERE session_id = ? AND project_path <> ?')
307
329
  .run(projectPath, sessionId, projectPath);
308
330
  }
309
- writeBaton(db, { projectPath, sessionId, now });
331
+ writeBaton(open(), { projectPath, sessionId, now });
310
332
  mkdirSync(dir, { recursive: true, mode: 0o700 });
311
333
  removeStale(dir, now);
312
334
  const handoffId = randomUUID();
@@ -753,10 +775,15 @@ export async function requestClaudeDesktopOpen({ sessionId, dir = claudeAutoHand
753
775
  if (!validSessionId(sessionId)) return null;
754
776
  // この会話自身が引き継ぎの途中なら、作業は次の後継が続ける。移すのは、最後に作業を終えた後継だけ。
755
777
  if (readJson(recordPath(sessionId, dir))) return null;
756
- const record = listClaudeAutoHandoffs({ dir }).find(item => item.successor?.session_id === sessionId &&
757
- item.state === 'sent' && item.desktop?.wanted && !item.desktop.state);
778
+ const waiting = listClaudeAutoHandoffs({ dir }).filter(item => item.desktop?.wanted && !item.desktop.state);
779
+ // 受領を確かめられなかった引き継ぎ(unknown)は、後継の session id が記録に無い。その後継がターンを終えたなら、
780
+ // 後継は動いている。裏の会話のまま残さず、同じように Desktop へ移す(ADR 0049)。
781
+ const record = waiting.find(item => item.state === 'sent' && item.successor?.session_id === sessionId) ??
782
+ waiting.find(item => item.state === 'unknown' && !item.successor?.session_id &&
783
+ typeof item.successor?.short_id === 'string' && sessionId.startsWith(item.successor.short_id));
758
784
  if (!record) return null;
759
- updateRecord(record.source_session_id, { desktop: { ...record.desktop, state: 'requested', error_code: null } }, { dir });
785
+ updateRecord(record.source_session_id, { successor: { ...record.successor, session_id: sessionId },
786
+ desktop: { ...record.desktop, state: 'requested', error_code: null } }, { dir });
760
787
  try { await launch(record.source_session_id, { dir }); }
761
788
  catch {
762
789
  updateRecord(record.source_session_id, { desktop: { ...record.desktop, state: 'failed', error_code: 'desktop_open_start_failed' } }, { dir });
@@ -97,6 +97,37 @@ test('PreCompact: 移っていない会話は、これまでどおり起動し
97
97
  assert.equal(result.projectPath, '/work/app');
98
98
  }));
99
99
 
100
+ test('PreCompact: CLAUDE_PROJECT_DIR が元の repository を指していても、会話を登録した作業ツリーで引き継ぐ(Claude Desktop が取り込んだ後継)', () => withDir(dir => {
101
+ // 作業ツリーで動く後継を Desktop が取り込むと、hook の cwd は作業ツリー、CLAUDE_PROJECT_DIR は元の repository になる
102
+ const repo = join(dir, 'repo');
103
+ const worktree = join(repo, '.claude', 'worktrees', 'core-ui');
104
+ mkdirSync(worktree, { recursive: true });
105
+ const registered = () => {
106
+ const db = makeBatonDb();
107
+ db.exec('CREATE TABLE sessions (session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL)');
108
+ db.prepare('INSERT INTO sessions VALUES (?, ?)').run(SESSION, worktree);
109
+ return db;
110
+ };
111
+ const adopted = { env: { CLAUDE_PROJECT_DIR: repo }, payload: { cwd: worktree }, platform: process.platform };
112
+
113
+ const { result, db } = request(dir, { db: registered(), ...adopted });
114
+ assert.equal(result.status, 'requested');
115
+ assert.equal(result.projectPath, worktree, '後継は、前任が動いていた作業ツリーで立てる');
116
+ assert.equal(readClaudeAutoHandoff(SESSION, dir).project_path, worktree);
117
+ assert.equal(db.prepare('SELECT session_id FROM handoff_batons WHERE project_path = ?').get(worktree).session_id, SESSION,
118
+ '印も作業ツリーに書く。後継は同じ project の前任だけを合流させる');
119
+
120
+ // 有効かどうかも、登録した project で決める
121
+ rmSync(join(dir, `${SESSION}.json`));
122
+ assert.equal(request(dir, { db: registered(), ...adopted, config: { enabled: true, projects: [repo] } }).result.reason, 'auto_handoff_disabled');
123
+ assert.equal(request(dir, { db: registered(), ...adopted, config: { enabled: true, projects: [worktree] } }).result.status, 'requested');
124
+
125
+ // 登録した場所がもう無い時(作業ツリーを消した後)は、hook の環境から読んだ project で進める
126
+ rmSync(join(dir, `${SESSION}.json`));
127
+ rmSync(worktree, { recursive: true });
128
+ assert.equal(request(dir, { db: registered(), ...adopted }).result.projectPath, repo);
129
+ }));
130
+
100
131
  test('PreCompact: 自動圧縮で有効な時だけ、印と記録を残して圧縮を止める', () => withDir(dir => {
101
132
  const { result, db } = request(dir);
102
133
  assert.equal(result.status, 'requested');
@@ -725,8 +756,8 @@ test('Stop: Desktop で開く引き継ぎの後継がターンを終えたら、
725
756
  assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null);
726
757
  writeRecord(dir, SESSION, { desktop: { wanted: false, state: null } });
727
758
  assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null, '端末から始めた会話の後継は移さない');
728
- writeRecord(dir, SESSION, { state: 'unknown' });
729
- assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null, '受領を確かめられていない後継は移さない');
759
+ writeRecord(dir, SESSION, { state: 'failed', error_code: 'handoff_successor_launch_failed', successor: null });
760
+ assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null, '後継が立たなかった引き継ぎでは動かない');
730
761
  // 対象の後継
731
762
  writeRecord(dir, SESSION, {});
732
763
  assert.equal(await requestClaudeDesktopOpen({ sessionId: SESSION, dir, launch }), null, '旧い会話自身の Stop では動かない');
@@ -748,6 +779,32 @@ test('Stop: Desktop で開く引き継ぎの後継がターンを終えたら、
748
779
  assert.deepEqual([readClaudeAutoHandoff(SESSION, dir).desktop.state, readClaudeAutoHandoff(SESSION, dir).desktop.error_code], ['failed', 'desktop_open_start_failed']);
749
780
  }));
750
781
 
782
+ test('Stop: 受領を確かめられなかった引き継ぎ(unknown)でも、その後継がターンを終えたら Desktop へ移す', () => withDir(async dir => {
783
+ const launched = [];
784
+ const launch = async (id) => { launched.push(id); };
785
+ // worker は、後継の最初の指示の受領を待ち切れなかった。記録にあるのは後継の短い ID だけ
786
+ const unconfirmed = { state: 'unknown', error_code: 'handoff_delivery_unconfirmed', accepted_at: null,
787
+ successor: { short_id: SUCCESSOR.slice(0, 8), session_id: null } };
788
+ writeRecord(dir, SESSION, unconfirmed);
789
+ assert.equal(await requestClaudeDesktopOpen({ sessionId: 'bbbbbbbb-0000-4000-8000-000000000002', dir, launch }), null, '別の会話の Stop では動かない');
790
+ assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), `h-${SESSION}`);
791
+ assert.deepEqual(launched, [SESSION]);
792
+ const record = readClaudeAutoHandoff(SESSION, dir);
793
+ assert.deepEqual(record.successor, { short_id: SUCCESSOR.slice(0, 8), session_id: SUCCESSOR }, '移す process が使う後継の ID を記録へ入れる');
794
+ assert.deepEqual([record.state, record.error_code, record.desktop.state], ['unknown', 'handoff_delivery_unconfirmed', 'requested'],
795
+ '受領を確かめられなかった事は、記録に残したままにする');
796
+ assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null, '2回目の Stop では起動しない');
797
+
798
+ // 移す process は、その記録で後継を止めて Desktop で開く
799
+ const calls = [];
800
+ const opened = await runClaudeDesktopOpen(SESSION, { dir, env: {}, pollMs: 10, idleTimeoutMs: 500,
801
+ spawn: (command, args) => { calls.push(args.slice(0, 2).join(' '));
802
+ return args[0] === 'agents' ? { status: 0, stdout: JSON.stringify([{ id: SUCCESSOR.slice(0, 8), kind: 'background', status: 'idle' }]) } : { status: 0, stdout: '' }; },
803
+ pty: (command, args) => { calls.push(args.join(' ')); return { status: 0, stdout: `Opening session ${SUCCESSOR} in Claude Desktop` }; } });
804
+ assert.deepEqual(calls, ['agents --json', `stop ${SUCCESSOR.slice(0, 8)}`, `--desktop --resume ${SUCCESSOR}`]);
805
+ assert.deepEqual([opened.state, opened.desktop.state], ['unknown', 'opened']);
806
+ }));
807
+
751
808
  test('Desktop で開く: 後継が手すきになるのを待って止め、擬似端末の中で claude --desktop --resume を呼ぶ', () => withDir(async dir => {
752
809
  const shortId = SUCCESSOR.slice(0, 8);
753
810
  const run = async ({ statuses, stopStatus = 0, opened, beforePoll = () => {} }) => {
@@ -1008,6 +1065,55 @@ test('hook: 自動圧縮を止め、次の道具を止めて worker を起動し
1008
1065
  assert.deepEqual([merged.triggered_path, merged.injection.auto_handoff_id], ['baton', record.handoff_id]);
1009
1066
  }));
1010
1067
 
1068
+ test('hook: 作業ツリーの後継を Claude Desktop が取り込んだ後(CLAUDE_PROJECT_DIR は元の repository)に引き継いでも、後継は作業ツリーで立ち、記憶が入る', () => withDir(async home => {
1069
+ // fox(Windows、Claude Code 2.1.296)で 2026-10-10 に起きた形。SessionStart の cwd は作業ツリー、
1070
+ // PreCompact の時の CLAUDE_PROJECT_DIR は元の repository だった。
1071
+ const repo = join(home, 'repo');
1072
+ const worktree = join(repo, '.claude', 'worktrees', 'core-ui');
1073
+ mkdirSync(worktree, { recursive: true });
1074
+ const transcriptPath = join(home, 'transcript.jsonl');
1075
+ const adopted = { ...fakeClaudePath(home), CLAUDE_PROJECT_DIR: repo, CLAUDE_CODE_ENTRYPOINT: 'claude-desktop' };
1076
+ const hook = (command, payload) =>
1077
+ cli(home, [command], JSON.stringify({ session_id: SESSION, cwd: worktree, transcript_path: transcriptPath, ...payload }), adopted);
1078
+
1079
+ assert.equal(cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']).status, 0);
1080
+ assert.equal(hook('session-start', { hook_event_name: 'SessionStart', source: 'resume' }).status, 0);
1081
+ writeFileSync(transcriptPath, jsonl([
1082
+ { ...user('画面の余白を直して', '2026-10-10T14:40:00Z'), entrypoint: 'claude-desktop', cwd: worktree },
1083
+ assistant('余白を直します。', '2026-10-10T14:40:02Z'),
1084
+ toolUse('toolu_9', 'Read', { file_path: join(worktree, 'ui.css') }),
1085
+ ]));
1086
+
1087
+ const pre = hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'auto' });
1088
+ assert.equal(pre.status, 2, pre.stderr);
1089
+ const stopped = hook('pre-tool-use', { hook_event_name: 'PreToolUse', tool_name: 'Read', tool_use_id: 'toolu_9', permission_mode: 'auto' });
1090
+ assert.equal(JSON.parse(stopped.stdout).continue, false);
1091
+ const status = () => JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout).handoffs;
1092
+ let handoff;
1093
+ for (let i = 0; i < 100 && (handoff = status()[0]).state !== 'failed'; i++) await new Promise(r => setTimeout(r, 100));
1094
+ assert.equal(handoff.error_code, 'handoff_successor_launch_failed', '偽の claude で止まる。本物は起動しない');
1095
+ assert.equal(handoff.project_path, worktree, '後継を立てる場所は、前任が動いていた作業ツリー');
1096
+
1097
+ // 後継は作業ツリーで立ち、最初の指示(起動時に渡された継続の指示)を受ける
1098
+ const recordFile = join(home, '.throughline', 'claude-auto-handoff', `${SESSION}.json`);
1099
+ const record = JSON.parse(readFileSync(recordFile, 'utf8'));
1100
+ writeFileSync(recordFile, JSON.stringify({ ...record, state: 'launching', error_code: null }));
1101
+ const successorPayload = { session_id: SUCCESSOR, cwd: record.project_path, hook_event_name: 'SessionStart', source: 'startup' };
1102
+ assert.equal(cli(home, ['session-start'], JSON.stringify(successorPayload), fakeClaudePath(home)).status, 0);
1103
+ const first = cli(home, ['prompt-submit'], JSON.stringify({ ...successorPayload, hook_event_name: 'UserPromptSubmit',
1104
+ prompt: claudeContinuationInput(record) }), fakeClaudePath(home));
1105
+ assert.equal(first.status, 0, first.stderr);
1106
+ assert.match(first.stdout, /^## Throughline: 自動継続の文脈\n/);
1107
+ assert.match(first.stdout, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: 画面の余白を直して\n/);
1108
+ assert.equal(existsSync(join(home, '.throughline', 'claude-auto-handoff', `${SESSION}.accepted`)), true, '最初の指示が受領を残す');
1109
+
1110
+ const db = new DatabaseSync(join(home, '.throughline', 'throughline.db'));
1111
+ assert.equal(db.prepare('SELECT merged_into FROM sessions WHERE session_id = ?').get(SESSION).merged_into, SUCCESSOR);
1112
+ db.close();
1113
+ const decisions = readFileSync(join(home, '.throughline', 'logs', 'inheritance-decision.log'), 'utf8').trim().split('\n').map(JSON.parse);
1114
+ assert.deepEqual(decisions.filter(entry => entry.phase === 'prompt-submit').map(entry => [entry.merged, entry.merge_skip_reason ?? null]), [[true, null]]);
1115
+ }));
1116
+
1011
1117
  test('hook: 別の project へ移した会話(Claude Desktop)を、移った後に1回も Stop を通らないまま引き継いでも、後継へ記憶が入る', () => withDir(async home => {
1012
1118
  // Desktop は「フォルダなし」で始めた会話を project へ移す。SessionStart は移る前の場所で呼ばれ、
1013
1119
  // CLAUDE_PROJECT_DIR は移った後も移る前の場所のまま届く。
@@ -106,6 +106,26 @@ function openSuccessor(operation) {
106
106
  // Codex が内部の文脈へ付けるターンの ID(`codex-rs/core/src/session/mod.rs` の `next_internal_sub_id`)。
107
107
  const INTERNAL_COMPACTION_TURN_ID_PATTERN = /^auto-compact-\d+$/;
108
108
 
109
+ const SAFE_IDENTITY_TEXT_PATTERN = /^[A-Za-z0-9_.:-]{1,64}$/;
110
+ const identityText = value => typeof value !== 'string' ? (value === null ? 'null' : typeof value)
111
+ : SAFE_IDENTITY_TEXT_PATTERN.test(value) ? value : `text(${value.length})`;
112
+ const identityPath = value => typeof value !== 'string' ? (value === null ? 'null' : typeof value)
113
+ : isAbsolute(value) ? 'absolute' : `relative(${value.length})`;
114
+
115
+ /**
116
+ * 引き継ぎの起点にできなかった合図の形。失敗ログへ残して、どの値が不正だったかを後から読めるようにする
117
+ * (2026-10-10、macOS で `handoff_hook_identity_invalid` が5回出たが、ログに形が無く原因を決められなかった)。
118
+ * ID・モデル名・種類だけを残す。path は絶対かどうかだけ。記号の混じる値は長さだけ。会話の本文は入らない。
119
+ */
120
+ export function hookIdentityShape(payload = {}) {
121
+ return {
122
+ session_id: identityText(payload.session_id), turn_id: identityText(payload.turn_id),
123
+ transcript_path: identityPath(payload.transcript_path), cwd: identityPath(payload.cwd),
124
+ agent_id: identityText(payload.agent_id), agent_type: identityText(payload.agent_type),
125
+ model: identityText(payload.model), trigger: identityText(payload.trigger),
126
+ };
127
+ }
128
+
109
129
  /** ターンの外の自動圧縮を止めた記録。引き継ぎの行は増えないので、起きた事をここに残す。会話の本文は書かない。 */
110
130
  function recordInternalCompactionStop(entry) {
111
131
  mkdirSync(stateRoot(), { recursive: true, mode: 0o700 });
@@ -179,7 +199,9 @@ export async function requestCodexAutoHandoff({ payload, db = null,
179
199
  return { status: 'ok', inserted: false, internalCompaction: true, continue: false,
180
200
  stopReason: 'Throughlineが、ターンの外の自動圧縮を止めました。文脈が上限に近づいたターンで、新しいタスクへ引き継ぎます。' };
181
201
  }
182
- if (!located || !CODEX_NATIVE_ID_PATTERN.test(turnId ?? '')) throw error('handoff_hook_identity_invalid');
202
+ if (!located || !CODEX_NATIVE_ID_PATTERN.test(turnId ?? '')) {
203
+ throw Object.assign(error('handoff_hook_identity_invalid'), { hookIdentity: hookIdentityShape(payload) });
204
+ }
183
205
  // JS実装のrealpathは、WindowsのCodex Desktopが起動したhookの中で `EISDIR: lstat 'C:'` で落ちる。OSのrealpathを使う。
184
206
  const rolloutPath = realpathSync.native(payload.transcript_path);
185
207
  const state = readCodexHandoffState(rolloutPath, { threadId, turnId });
@@ -15,7 +15,7 @@ import { readCodexHandoffState, settingsMatch, threadStartSettings, resolvePrepa
15
15
  import { runAutoHandoffWorker, requestCodexAutoHandoff, sourceBoundary,
16
16
  autoHandoffTargetName, nameAutoHandoffTarget, findLiveAutoHandoffSuccessor,
17
17
  markAutoHandoffSource, markAutoHandoffSources, autoHandoffSourceFallbackTitle, carryAutoHandoffQueue,
18
- untrackedInheritedMemory, resolveAutoHandoffSuccessor } from './codex-auto-handoff.mjs';
18
+ untrackedInheritedMemory, resolveAutoHandoffSuccessor, hookIdentityShape } from './codex-auto-handoff.mjs';
19
19
  import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
20
20
 
21
21
  async function withDb(fn) {
@@ -364,6 +364,16 @@ test('Codexの内部の文脈(auto-compact-N)からの圧縮は、例外に
364
364
  await assert.rejects(requestCodexAutoHandoff({ ...args, payload: { ...payload, ...broken } }), /handoff_hook_identity_invalid/);
365
365
  }
366
366
  assert.equal(recorded.length, 1);
367
+
368
+ // 失敗には、どの値が不正だったかの形を付ける。path と記号の混じる値は、中身を残さない
369
+ const shaped = await requestCodexAutoHandoff({ ...args, payload: { ...payload, turn_id: 'something else', transcript_path: null,
370
+ agent_type: 'reviewer', model: 'gpt-5.5' } }).catch(error => error);
371
+ assert.equal(shaped.code, 'handoff_hook_identity_invalid');
372
+ assert.deepEqual(shaped.hookIdentity, { session_id: thread, turn_id: 'text(14)', transcript_path: 'null', cwd: 'absolute',
373
+ agent_id: 'undefined', agent_type: 'reviewer', model: 'gpt-5.5', trigger: 'auto' });
374
+ assert.deepEqual(hookIdentityShape({ session_id: thread, turn_id: 'auto-compact-', transcript_path: 'relative/rollout.jsonl', cwd: 7 }),
375
+ { session_id: thread, turn_id: 'auto-compact-', transcript_path: 'relative(22)', cwd: 'number',
376
+ agent_id: 'undefined', agent_type: 'undefined', model: 'undefined', trigger: 'undefined' });
367
377
  }));
368
378
 
369
379
  test('PreCompactの入口は、JS実装のrealpathが使えない環境(WindowsのCodex Desktopのhook)でも引き継ぎを作る', async () => withDb(async (db, home) => {
@@ -62,6 +62,13 @@ export function logHookFailure(code, error, { home, now = Date.now(), stderr = p
62
62
  if (typeof context?.transcript_path === 'string' && context.transcript_path) {
63
63
  entry.transcript_path = context.transcript_path;
64
64
  }
65
+ // 不正な合図の形(errorが `hookIdentity` を持つ時)。文面が同じ失敗でも、どの値が不正だったかを残す。
66
+ // 値は呼び出し側が ID・種類・「絶対 path かどうか」へ直してある。ここでは短い文字列だけを通す。
67
+ const identity = error?.hookIdentity;
68
+ if (identity && typeof identity === 'object') {
69
+ const kept = Object.entries(identity).filter(([, value]) => typeof value === 'string' && value.length <= 80);
70
+ if (kept.length > 0) entry.identity = Object.fromEntries(kept);
71
+ }
65
72
  // 外部 CLI の失敗は、message が終了 code だけになる。理由(reason)と CLI の stderr の末尾も残す
66
73
  // (CLI は先頭に起動情報を出し、失敗の理由は末尾に出す)。
67
74
  if (typeof error?.reason === 'string' && error.reason) entry.reason = error.reason;
@@ -50,6 +50,22 @@ test('logHookFailureは、外部CLIの失敗の理由とstderrの末尾を残す
50
50
  }
51
51
  });
52
52
 
53
+ test('logHookFailureは、不正な合図の形を残す', () => {
54
+ const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
55
+ try {
56
+ const shaped = new Error('handoff_hook_identity_invalid');
57
+ shaped.hookIdentity = { turn_id: 'auto-compact-', transcript_path: 'null', cwd: 'absolute', long: 'x'.repeat(81), count: 3 };
58
+ logHookFailure('HOOK_CODEX_FAILED', shaped, { home });
59
+ logHookFailure('HOOK_CODEX_FAILED', new Error('plain'), { home });
60
+ const [first, second] = readFileSync(hookFailureLogPath({ home }), 'utf8').trim().split('\n').map((line) => JSON.parse(line));
61
+ // 短い文字列だけを通す(長い値と文字列でない値は落とす)。
62
+ assert.deepEqual(first.identity, { turn_id: 'auto-compact-', transcript_path: 'null', cwd: 'absolute' });
63
+ assert.equal('identity' in second, false);
64
+ } finally {
65
+ rmSync(home, { recursive: true, force: true });
66
+ }
67
+ });
68
+
53
69
  test('logHookFailureは、どの会話の失敗かを残す', () => {
54
70
  const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
55
71
  try {