throughline 0.16.19 → 0.16.20

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,31 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.16.20] — 2026-10-11
14
+
15
+ ### 修正
16
+
17
+ - Claude Codeの自動継続で、後継がターンを終えてもClaude Desktopへ移らず、裏の会話のまま残る場面があった([ADR 0050](docs/adr/0050-claude-successor-with-held-peer-message-moves-to-desktop.md))。Windowsの実機(Claude Code 2.1.296)で2026-10-10に2回起きた(1回は2時間42分後に移り、1回は残ったまま)。利用者の別の会話から`SendMessage`で届いた文を、Claude Codeが承認まで保留していた。保留がある間、`claude agents`はその会話を、ターンの後も`waiting`(`permission prompt`)と返す。Throughlineは手すき(`idle`)の後継だけを移していた。
18
+ - 後継が`waiting`を返す時は、後継のtranscriptの末尾を読む。終わったターン(`stop_reason`が`end_turn`のassistantの行)で止まっていて、3秒後も同じなら、手すきと同じに扱って移す。
19
+ - **移す時に保留されている文は、会話へ届かないまま消える**(オーナーの裁定)。
20
+ - 道具の許可を本当に待っている会話(ターンの途中)と、次のターンが動いている会話は、今までどおり移さない。
21
+ - transcriptから読むのは行の種類と`stop_reason`だけで、本文は読まない。
22
+
23
+ ### 対応の範囲
24
+
25
+ | | macOS | Linux | Windows |
26
+ |---|---|---|---|
27
+ | Claude Code | 直した。単体試験と、実機(Claude Code 2.1.289、Claude Desktop)の試験用の会話。保留を作り、0.16.19は止めず、直した版は`claude stop`→本物の`claude --desktop --resume`でDesktopの会話になる事。ターンの途中の許可待ちは止めない事 | 対象外。Claude Desktopへ移す処理が無い(Claude Codeの`--desktop`が無い)。単体試験とCIだけ | 直した。単体試験と、実機(Claude Code 2.1.296)の試験用の会話。保留を作り、0.16.19は止めず、直した版は`claude stop`まで進む事。ターンの途中の許可待ちは止めない事。**試験用の会話では`claude --desktop --resume`を呼んでいない**(止めた後の会話を開く所は0.16.9からの処理で、変えていない) |
28
+ | Codex | 変更なし(会話から会話への文の保留が無い。引き継ぎの部品も別) | 同じ | 同じ |
29
+ | Grok | 変更なし(自動継続が無い) | 同じ | 同じ |
30
+ | Cursor | 変更なし(自動継続が無い) | 同じ | 同じ |
31
+
32
+ ### 確かめていない事
33
+
34
+ - 止めた時、送り手の会話へ「届かなかった」と伝わるか。
35
+ - リモートコントロール(claude.ai/codeとClaudeのアプリ)の画面に、保留を承認する問いが出るか。
36
+ - 保留を持つ会話へ指示が届いた直後(その行がtranscriptに書かれる前)に、移す処理が重なる場面。
37
+
13
38
  ## [0.16.19] — 2026-10-11
14
39
 
15
40
  ### 修正
@@ -2306,7 +2331,8 @@ two attempts, instrument first instead of patching again.
2306
2331
 
2307
2332
  ---
2308
2333
 
2309
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.19...HEAD
2334
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.20...HEAD
2335
+ [0.16.20]: https://github.com/kitepon/Throughline/compare/v0.16.19...v0.16.20
2310
2336
  [0.16.19]: https://github.com/kitepon/Throughline/compare/v0.16.18...v0.16.19
2311
2337
  [0.16.18]: https://github.com/kitepon/Throughline/compare/v0.16.17...v0.16.18
2312
2338
  [0.16.17]: https://github.com/kitepon/Throughline/compare/v0.16.16...v0.16.17
package/README.md CHANGED
@@ -68,7 +68,7 @@ throughline auto-handoff disable --host claude
68
68
  3. 同じprojectに、継続の指示を最初の指示として付けた新しいClaudeの会話を裏で立てます(`claude --bg`)。会話の名前は、前任の会話の題から`<project名>|<作業の概要>(自動引き継ぎ)`にします。モデル・推論強度・権限は旧い会話から引き継ぎます。
69
69
  4. 最初の指示を受け取った時に、止めた時点の依頼、そのターンでここまでにしたこと(発言と道具の呼び出し)、実行されなかった道具、直近の会話の原文を注入します(上限9,500字)。止めたターンの道具の入出力は、後継が`throughline detail`で取り出せます。
70
70
 
71
- 続きは裏の会話で動きます。`claude agents`の一覧と`claude attach <id>`で見られます。Claude Desktopの画面から始まった会話の後継は、そのターンの作業を終えた時に、Claude Desktopへ移して開きます(macOSは0.16.8から、Windowsは0.16.9から。LinuxのClaude Codeには`--desktop`が無いので開きません。[ADR 0043](docs/adr/0043-claude-successor-opens-in-desktop.md))。Claude Codeは裏で動いている会話をDesktopへ移さないので、作業の間はDesktopには出ません。続けて引き継いだ時は、最後に作業を終えた後継だけを開きます。端末から始めた会話の後継は開きません。手で開く時は、`claude stop <id>`で裏の実行を停止し、`claude --desktop --resume <session-id>`を実行します。`session-id`は`claude agents --json --all`の`sessionId`です。環境変数`THROUGHLINE_AUTO_HANDOFF_OPEN`を`desktop`にすると出どころに関係なく開き、`off`にすると開きません。手動`/compact`、subagentの中の圧縮、`claude -p`などscriptから起動した会話は対象に含めません。 Claude Desktopから始まった会話の後継は、リモートコントロール付きで立てるので、作業の最中もclaude.ai/codeとClaudeのアプリから見られます(0.16.16から。[ADR 0048](docs/adr/0048-claude-desktop-origin-successor-has-remote-control.md))。
71
+ 続きは裏の会話で動きます。`claude agents`の一覧と`claude attach <id>`で見られます。Claude Desktopの画面から始まった会話の後継は、そのターンの作業を終えた時に、Claude Desktopへ移して開きます(macOSは0.16.8から、Windowsは0.16.9から。LinuxのClaude Codeには`--desktop`が無いので開きません。[ADR 0043](docs/adr/0043-claude-successor-opens-in-desktop.md))。Claude Codeは裏で動いている会話をDesktopへ移さないので、作業の間はDesktopには出ません。続けて引き継いだ時は、最後に作業を終えた後継だけを開きます。ほかの会話から`SendMessage`で届いた文をClaude Codeが承認待ちで保留している後継も、ターンが終わっていれば開きます。その時に保留されている文は、会話へ届かずに消えます(0.16.20から。[ADR 0050](docs/adr/0050-claude-successor-with-held-peer-message-moves-to-desktop.md))。端末から始めた会話の後継は開きません。手で開く時は、`claude stop <id>`で裏の実行を停止し、`claude --desktop --resume <session-id>`を実行します。`session-id`は`claude agents --json --all`の`sessionId`です。環境変数`THROUGHLINE_AUTO_HANDOFF_OPEN`を`desktop`にすると出どころに関係なく開き、`off`にすると開きません。手動`/compact`、subagentの中の圧縮、`claude -p`などscriptから起動した会話は対象に含めません。 Claude Desktopから始まった会話の後継は、リモートコントロール付きで立てるので、作業の最中もclaude.ai/codeとClaudeのアプリから見られます(0.16.16から。[ADR 0048](docs/adr/0048-claude-desktop-origin-successor-has-remote-control.md))。
72
72
 
73
73
  ```bash
74
74
  throughline auto-handoff status --host claude --operation <handoff-id> --json
@@ -0,0 +1,56 @@
1
+ # ADR 0050: ほかの会話から届いた文を保留している後継も、ターンが終わっていれば Claude Desktop へ移す
2
+
3
+ 日付: 2026-10-11
4
+
5
+ ## Context
6
+
7
+ Claude Desktop の画面から始まった会話の後継は、ターンを終えた時に Desktop へ移す(ADR 0043)。移す process は、
8
+ `claude agents --json --all` でその後継が手すき(`status: idle`)になるのを待ち、`claude stop` で止めて、
9
+ `claude --desktop --resume` で開く。手すきにならない時は止めずに戻り、次の Stop でやり直す。
10
+
11
+ 2026-10-11、Windows の実機(Claude Code 2.1.296)で、10/10 23:43 に立った後継が、ターンを終えた後も裏の会話のまま残った。
12
+ 記録を読んで分かった事は次のとおり。
13
+
14
+ - 後継の transcript は、`stop_reason: end_turn` の assistant の行と `stop_hook_summary` で終わっていた。結果の無い道具の呼び出しは無い。
15
+ - それでも `claude agents` は、その後継を `status: waiting`・`waitingFor: permission prompt` と返していた。
16
+ - 理由は、会話から会話への文(`SendMessage`)の保留だった。Claude Code は、送り手と受け手で権限の設定が違う時、届いた文を
17
+ 利用者が承認するまで保留する。transcript には `system` の行(`Held peer message … not delivered to Claude (1 held)`)が1行入る。
18
+ 保留が1通でもある間、その会話は、作業中もターンの後も `waiting / permission prompt` を返す。
19
+ - 送り手は利用者の別の会話で、Throughline が送った文ではない。
20
+ - 同じ作業の1つ前の後継も同じだった。保留が入ってから解けるまでの2時間42分、移せなかった(Desktop へ移ったのは、後継が立ってから3時間34分後)。
21
+
22
+ Windows と macOS の試験用の会話で、同じ状態を作って確かめた(Windows は権限の組み合わせ2通り、macOS は1通り)。
23
+
24
+ - 保留を持つ会話は、`claude stop` で止められる。止めた後、`claude -p --resume` と `claude --desktop --resume` で続けられる。
25
+ - 止めると、保留の文は会話へ届かないまま消える。transcript には、知らせの行の中に文の冒頭だけが残る。
26
+ - 道具の許可を本当に待っている会話(ターンの途中)も、同じ `waiting / permission prompt` を返す。この時、transcript の最後は
27
+ 道具を呼んだ assistant の行(`stop_reason: tool_use`)で、結果の行が無い。
28
+
29
+ 裏の会話の保留を承認する画面へ行く道は、端末の `claude attach` とリモートコントロールだけで、Claude Desktop には出ない。
30
+ オーナーの裁定(Approval Box K-3LPKZE、2026-10-11)は「保留があっても、ターンが終わっていれば Desktop へ移す。
31
+ 保留中の文は消える」。
32
+
33
+ ## Decision
34
+
35
+ 1. 移す process は、後継が `waiting` を返す時、その後継の transcript の末尾を読む。次の全部が当たる時、手すきと同じに扱う。
36
+ - `claude agents` の `state` が `working` でない。
37
+ - 最後の `user`・`assistant` の行が assistant で、`stop_reason` が `end_turn` か `stop_sequence`。道具の呼び出しを含まない。
38
+ - その行の後に、取り出されても消されてもいない順番待ちの指示(`queue-operation` の `enqueue`)が無い。
39
+ - 次に見た時(3秒後)も、transcript の大きさが同じ。
40
+ 2. transcript の場所は、後継の Stop の hook が受け取った `transcript_path` を、引き継ぎの記録の `successor.transcript_path` に残して使う。
41
+ 公開の表示(`auto-handoff status`)には出さない。
42
+ 3. transcript から読むのは、行の種類・`stop_reason`・発言の部品の種類・順番待ちの操作の種類だけ。本文は読まない。
43
+ 4. 当たらない時(ターンの途中の許可待ち、次のターンが動いている、transcript を読めない、記録に場所が無い)は、今までどおり止めずに戻る。
44
+ 5. `busy` の後継は、今までどおり待つ。
45
+
46
+ ## Consequences
47
+
48
+ - ほかの会話から文が届いて保留されている後継も、ターンを終えれば Desktop に出る。
49
+ - その時に保留されている文は、承認も拒否もされずに消える。受け手の会話には届かない。送り手の会話へ知らせが行くかは確かめていない。
50
+ - 保留を持つ会話は、作業中も `waiting` を返す。指示が届いてから、その行が transcript に書かれるまでの間に2回続けて読むと、
51
+ 始まったばかりのターンを止める恐れが残る。2回の間隔は3秒で、Claude Code は指示の行をすぐ書くので、起きる窓は狭い。
52
+ - ターンの後も裏で動く処理(裏の shell、裏の subagent)を持つ会話は、止める時にその処理も止まる。手すき(`idle`)の後継を
53
+ 止める時と同じで、ここは変えていない。
54
+ - `stop_reason` を transcript に書かない版の Claude Code では、この判定は当たらない。今までどおり、保留が解けるまで待つ。
55
+ - 0.16.19 までの版が残した記録には transcript の場所が無い。その後継は、次の Stop で場所が入ってから移る。
56
+ - Linux は Desktop へ移す処理が無いので、対象外。Codex・Grok・Cursor は、会話から会話への文の保留が無いので、変更なし。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.16.19",
3
+ "version": "0.16.20",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -14,13 +14,14 @@
14
14
  * 利用者の承認まで止めるため(0.15.3)。
15
15
  * 5. 後継の Stop : Claude Desktop から始まった会話の後継は、そのターンの作業を終えた時に
16
16
  * Desktop へ移して開く(`claude stop` → `claude --desktop --resume`。macOS と Windows)。
17
- * Claude Code は、裏で動いている会話を Desktop へ移さない。
17
+ * Claude Code は、裏で動いている会話を Desktop へ移さない。ほかの会話から届いた文を
18
+ * 保留している後継も、ターンが終わっていれば移す(ADR 0050)。
18
19
  *
19
20
  * 旧い会話は止めるだけで、空にしない。手動の /compact と subagent の中の圧縮は対象にしない。
20
21
  * 状態は ~/.throughline/claude-auto-handoff/ のファイルに持つ(schema は変えない)。
21
22
  */
22
23
 
23
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
24
+ import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
24
25
  import { homedir } from 'node:os';
25
26
  import { dirname, join, posix, resolve, win32 } from 'node:path';
26
27
  import { randomUUID } from 'node:crypto';
@@ -751,6 +752,59 @@ function readBackgroundAgent(shortId, { spawn, env }) {
751
752
  catch { return null; }
752
753
  }
753
754
 
755
+ // 終わったターンの最後の assistant の行が持つ `stop_reason`。道具を呼んだ行(`tool_use`)と、途中で切れた行は含めない。
756
+ const FINISHED_STOP_REASONS = new Set(['end_turn', 'stop_sequence']);
757
+ // 後継の transcript は、末尾だけ読む。ターンの終わりの行は、最後の発言とその後の数行(hook のまとめ、題、保留の知らせ)にある。
758
+ const TRANSCRIPT_TAIL_BYTES = 1024 * 1024;
759
+
760
+ /** transcript の末尾の行を読む。途中から始まる最初の行は捨てる。読めない時は null。 */
761
+ function readTranscriptTail(transcriptPath, bytes = TRANSCRIPT_TAIL_BYTES) {
762
+ let fd;
763
+ try {
764
+ fd = openSync(transcriptPath, 'r');
765
+ const { size } = fstatSync(fd);
766
+ const start = Math.max(0, size - bytes);
767
+ const buffer = Buffer.alloc(size - start);
768
+ const read = readSync(fd, buffer, 0, buffer.length, start);
769
+ const lines = buffer.toString('utf8', 0, read).split('\n');
770
+ if (start > 0) lines.shift();
771
+ const entries = [];
772
+ for (const line of lines) {
773
+ if (!line.trim()) continue;
774
+ try { entries.push(JSON.parse(line)); } catch { /* 書いている途中の最後の行 */ }
775
+ }
776
+ return { size, entries };
777
+ } catch { return null; }
778
+ finally { if (fd !== undefined) closeSync(fd); }
779
+ }
780
+
781
+ /**
782
+ * 後継の transcript が、終わったターンで止まっているか(ADR 0050)。
783
+ *
784
+ * 止まっている時は、その時の transcript の大きさを返す。呼ぶ側は、続けて2回同じ値が返った時だけ「動いていない」と見る。
785
+ * 止まっていない時(最後の発言が利用者の指示・道具の結果・道具を呼んだ assistant、または順番待ちの指示が残っている)と、
786
+ * 読めない時は null。会話の本文は読まない(行の種類と `stop_reason` だけを見る)。
787
+ */
788
+ export function readFinishedTurnMark(transcriptPath) {
789
+ if (typeof transcriptPath !== 'string' || !transcriptPath) return null;
790
+ const tail = readTranscriptTail(transcriptPath);
791
+ if (!tail) return null;
792
+ const entries = tail.entries.filter(entry => entry && typeof entry === 'object' && entry.isSidechain !== true);
793
+ const last = entries.findLastIndex(entry => entry.type === 'user' || entry.type === 'assistant');
794
+ if (last < 0) return null;
795
+ const { type, message } = entries[last];
796
+ if (type !== 'assistant' || !FINISHED_STOP_REASONS.has(message?.stop_reason)) return null;
797
+ if (Array.isArray(message.content) && message.content.some(block => block?.type === 'tool_use')) return null;
798
+ // ターンの終わりの後に積まれた指示(`enqueue`)が、取り出されても消されてもいない。次のターンがすぐ始まる。
799
+ let queued = 0;
800
+ for (const entry of entries.slice(last + 1)) {
801
+ if (entry.type !== 'queue-operation') continue;
802
+ if (entry.operation === 'enqueue') queued += 1;
803
+ else if (entry.operation === 'dequeue' || entry.operation === 'remove') queued = Math.max(0, queued - 1);
804
+ }
805
+ return queued > 0 ? null : tail.size;
806
+ }
807
+
754
808
  export async function launchClaudeDesktopOpen(sessionId, { dir = claudeAutoHandoffDir() } = {}) {
755
809
  if (!validSessionId(sessionId)) throw new ClaudeHandoffError('auto_handoff_session_id_invalid');
756
810
  mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -768,10 +822,11 @@ export async function launchClaudeDesktopOpen(sessionId, { dir = claudeAutoHando
768
822
  /**
769
823
  * 後継の Stop hook から呼ぶ。Claude Desktop で開く引き継ぎの後継が、ターンを終えた。
770
824
  * その会話を Desktop へ移す process を起動する。対象でない会話には何もしない(null)。
825
+ * `transcriptPath` は、その後継の transcript(Stop の payload の値)。移す process が、ターンが終わっているかを読む(ADR 0050)。
771
826
  *
772
827
  * @returns {Promise<string|null>} 起動した時は、その引き継ぎ ID
773
828
  */
774
- export async function requestClaudeDesktopOpen({ sessionId, dir = claudeAutoHandoffDir(), launch = launchClaudeDesktopOpen }) {
829
+ export async function requestClaudeDesktopOpen({ sessionId, transcriptPath = null, dir = claudeAutoHandoffDir(), launch = launchClaudeDesktopOpen }) {
775
830
  if (!validSessionId(sessionId)) return null;
776
831
  // この会話自身が引き継ぎの途中なら、作業は次の後継が続ける。移すのは、最後に作業を終えた後継だけ。
777
832
  if (readJson(recordPath(sessionId, dir))) return null;
@@ -782,8 +837,9 @@ export async function requestClaudeDesktopOpen({ sessionId, dir = claudeAutoHand
782
837
  waiting.find(item => item.state === 'unknown' && !item.successor?.session_id &&
783
838
  typeof item.successor?.short_id === 'string' && sessionId.startsWith(item.successor.short_id));
784
839
  if (!record) return null;
785
- updateRecord(record.source_session_id, { successor: { ...record.successor, session_id: sessionId },
786
- desktop: { ...record.desktop, state: 'requested', error_code: null } }, { dir });
840
+ const successor = { ...record.successor, session_id: sessionId };
841
+ if (typeof transcriptPath === 'string' && transcriptPath) successor.transcript_path = transcriptPath;
842
+ updateRecord(record.source_session_id, { successor, desktop: { ...record.desktop, state: 'requested', error_code: null } }, { dir });
787
843
  try { await launch(record.source_session_id, { dir }); }
788
844
  catch {
789
845
  updateRecord(record.source_session_id, { desktop: { ...record.desktop, state: 'failed', error_code: 'desktop_open_start_failed' } }, { dir });
@@ -798,6 +854,10 @@ export async function requestClaudeDesktopOpen({ sessionId, dir = claudeAutoHand
798
854
  * Claude Code は、裏で動いている会話(作業中も、手すきも)を Desktop へ移さない。手すきになるのを待って
799
855
  * `claude stop` で止め、`claude --desktop --resume <session id>` で開く。この命令は出力が端末でないと動かないので、
800
856
  * 擬似端末(macOS)か新しい console(Windows)の中で呼ぶ。後継がまた動き出した時は止めずに戻り、次の Stop でやり直す。
857
+ *
858
+ * ほかの会話から届いた文を Claude Code が保留している後継は、ターンを終えても手すき(`idle`)にならず、
859
+ * `waiting`(`permission prompt`)のままになる。この時は transcript を読み、終わったターンで止まっていれば同じように移す。
860
+ * 保留されている文は、止める時に消える(ADR 0050)。道具の許可を本当に待っている会話(ターンの途中)は移さない。
801
861
  */
802
862
  export async function runClaudeDesktopOpen(sourceSessionId, {
803
863
  dir = claudeAutoHandoffDir(),
@@ -812,16 +872,22 @@ export async function runClaudeDesktopOpen(sourceSessionId, {
812
872
  if (!record) throw new ClaudeHandoffError('handoff_not_found');
813
873
  if (record.desktop?.state !== 'requested') return record;
814
874
  const set = fields => { record = updateRecord(sourceSessionId, { desktop: { ...record.desktop, ...fields } }, { dir }); return record; };
815
- const { short_id: shortId, session_id: successorId } = record.successor ?? {};
875
+ const { short_id: shortId, session_id: successorId, transcript_path: successorTranscript } = record.successor ?? {};
816
876
  if (!shortId || !validSessionId(successorId)) return set({ state: 'failed', error_code: 'desktop_open_successor_unknown' });
817
877
  const childEnv = successorEnv(env);
818
878
 
819
879
  let idle = false;
880
+ let finishedMark = null;
820
881
  for (const deadline = Date.now() + idleTimeoutMs; Date.now() < deadline;) {
821
882
  // 後継がさらに引き継ぎを始めた。作業は次の後継が続けるので、この会話は移さない。
822
883
  if (readJson(recordPath(successorId, dir))) return set({ state: 'superseded' });
823
884
  const agent = readBackgroundAgent(shortId, { spawn, env: childEnv });
824
885
  if (agent?.status === 'idle') { idle = true; break; }
886
+ // 保留の文を持つ後継は、作業中もターンの後も `waiting` を返す。transcript が終わったターンで止まっていて、
887
+ // 次に見た時も同じ大きさなら、手すきと同じに扱う。1回だけでは決めない(指示が届いた直後は、その行がまだ無い)。
888
+ const mark = agent?.status === 'waiting' && agent.state !== 'working' ? readFinishedTurnMark(successorTranscript) : null;
889
+ if (mark !== null && mark === finishedMark) { idle = true; break; }
890
+ finishedMark = mark;
825
891
  await delay(pollMs);
826
892
  }
827
893
  // 手すきにならない(次のターンが始まった、一覧を読めない)。止めずに戻り、次の Stop でやり直す。
@@ -1,7 +1,7 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { spawnSync } from 'node:child_process';
4
- import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
4
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { delimiter, dirname, join } from 'node:path';
7
7
  import { DatabaseSync } from 'node:sqlite';
@@ -16,6 +16,7 @@ import {
16
16
  listClaudeAutoHandoffs,
17
17
  publicClaudeAutoHandoff,
18
18
  claudeContinuationInput,
19
+ readFinishedTurnMark,
19
20
  requestClaudeDesktopOpen,
20
21
  runClaudeDesktopOpen,
21
22
  } from './claude-auto-handoff.mjs';
@@ -868,6 +869,97 @@ test('Desktop で開く: 後継が手すきになるのを待って止め、擬
868
869
  assert.equal(untouched.desktop.state, null);
869
870
  }));
870
871
 
872
+ // ---- ほかの会話から届いた文を保留している後継(ADR 0050) ----
873
+
874
+ // Claude Code 2.1.296 の transcript の形。ターンの最後の assistant は `stop_reason: end_turn`、道具を呼ぶ行は `tool_use`。
875
+ const endTurn = text => ({ type: 'assistant', message: { role: 'assistant', stop_reason: 'end_turn', content: [{ type: 'text', text }] } });
876
+ const callsTool = id => ({ type: 'assistant', message: { role: 'assistant', stop_reason: 'tool_use', content: [{ type: 'tool_use', id, name: 'Bash', input: {} }] } });
877
+ const turnTail = [{ type: 'system', subtype: 'stop_hook_summary' }, { type: 'system', subtype: 'turn_duration' }, { type: 'last-prompt' }];
878
+ // 保留の知らせは system の行で、user の行にも順番待ちの行にもならない(2026-10-11 の Windows と macOS の実測)
879
+ const heldNotice = { type: 'system', subtype: 'informational', level: 'warning', content: 'Held peer message from another session' };
880
+
881
+ test('transcript: 終わったターンで止まっている時だけ、印(transcript の大きさ)を返す', () => withDir(dir => {
882
+ const path = join(dir, 'successor.jsonl');
883
+ const mark = entries => { writeFileSync(path, jsonl(entries)); return readFinishedTurnMark(path); };
884
+ const finished = [user('続けて'), callsTool('t1'), toolResult('t1', 'ok'), endTurn('終わりました'), ...turnTail];
885
+
886
+ assert.equal(mark(finished), Buffer.byteLength(jsonl(finished)));
887
+ assert.equal(typeof mark([...finished, heldNotice]), 'number', '保留の知らせが後ろにあっても、ターンは終わっている');
888
+ assert.equal(mark([user('続けて'), callsTool('t1')]), null, '道具の許可を待っている(結果の無い道具の呼び出し)');
889
+ assert.equal(mark([user('続けて'), callsTool('t1'), toolResult('t1', 'ok')]), null, '道具の結果の後、次の発言を待っている');
890
+ assert.equal(mark([...finished, user('次はこれ')]), null, '次の指示が届いている');
891
+ assert.equal(mark([...finished, { type: 'queue-operation', operation: 'enqueue' }]), null, '順番待ちの指示が残っている');
892
+ assert.equal(typeof mark([...finished, { type: 'queue-operation', operation: 'enqueue' }, { type: 'queue-operation', operation: 'remove' }]), 'number',
893
+ '消された順番待ちは数えない');
894
+ assert.equal(mark([user('続けて'), assistant('途中')]), null, '`stop_reason` の無い行では決めない');
895
+ assert.equal(mark([user('続けて'), { type: 'assistant', message: { role: 'assistant', stop_reason: 'max_tokens', content: [{ type: 'text', text: '切れた' }] } }]), null);
896
+ assert.equal(typeof mark([...finished, { ...user('subagent の中'), isSidechain: true }]), 'number', 'subagent の行は見ない');
897
+ assert.equal(mark([]), null);
898
+ assert.equal(readFinishedTurnMark(join(dir, 'missing.jsonl')), null, '読めない時は決めない');
899
+ assert.equal(readFinishedTurnMark(null), null);
900
+
901
+ // 末尾だけを読む。長い transcript でも、最後のターンの行で決まる
902
+ const long = [...Array.from({ length: 400 }, (_, index) => user(`${index}`.padEnd(4_000, 'x'))), ...finished];
903
+ assert.equal(mark(long), Buffer.byteLength(jsonl(long)));
904
+ }));
905
+
906
+ test('Stop: 後継の transcript の場所を、移す process のために記録へ残す', () => withDir(async dir => {
907
+ writeRecord(dir, SESSION, {});
908
+ const launch = async () => {};
909
+ assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, transcriptPath: '/home/u/.claude/projects/-work-app/successor.jsonl', dir, launch }), `h-${SESSION}`);
910
+ assert.deepEqual(readClaudeAutoHandoff(SESSION, dir).successor,
911
+ { short_id: SUCCESSOR.slice(0, 8), session_id: SUCCESSOR, transcript_path: '/home/u/.claude/projects/-work-app/successor.jsonl' });
912
+ assert.equal(JSON.stringify(publicClaudeAutoHandoff(readClaudeAutoHandoff(SESSION, dir))).includes('successor.jsonl'), false, '公開の表示には場所を出さない');
913
+ }));
914
+
915
+ test('Desktop で開く: 保留の文で waiting のままの後継も、ターンが終わっていれば止めて開く。ターンの途中の許可待ちは移さない', () => withDir(async dir => {
916
+ const shortId = SUCCESSOR.slice(0, 8);
917
+ const transcript = join(dir, 'successor.jsonl');
918
+ const finished = [user('続けて'), callsTool('t1'), toolResult('t1', 'ok'), endTurn('終わりました'), ...turnTail, heldNotice];
919
+ const run = async ({ entries, agent = {}, recordTranscript = transcript, onPoll = () => {} }) => {
920
+ writeFileSync(transcript, jsonl(entries));
921
+ writeRecord(dir, SESSION, { successor: { short_id: shortId, session_id: SUCCESSOR, ...(recordTranscript ? { transcript_path: recordTranscript } : {}) },
922
+ desktop: { wanted: true, state: 'requested', error_code: null, opened_at: null } });
923
+ const calls = [];
924
+ let polls = 0;
925
+ const spawn = (command, args) => {
926
+ calls.push(args[0]);
927
+ if (args[0] !== 'agents') return { status: 0, stdout: '' };
928
+ onPoll(polls += 1);
929
+ return { status: 0, stdout: JSON.stringify([{ id: shortId, kind: 'background', status: 'waiting', waitingFor: 'permission prompt', state: 'done', ...agent }]) };
930
+ };
931
+ const pty = (command, args) => { calls.push(args.join(' ')); return { status: 0, stdout: `Opening session ${SUCCESSOR} in Claude Desktop` }; };
932
+ const record = await runClaudeDesktopOpen(SESSION, { dir, env: {}, spawn, pty, idleTimeoutMs: 80, pollMs: 5 });
933
+ return { record, calls };
934
+ };
935
+ const untouched = result => {
936
+ assert.equal(result.calls.some(call => call === 'stop' || call.startsWith('--desktop')), false, '止めない・開かない');
937
+ assert.equal(result.record.desktop.state, null, '次の Stop でやり直せる');
938
+ };
939
+
940
+ // Windows と macOS の実測(2026-10-11): ほかの会話から届いた文を保留している間、ターンを終えても `waiting / permission prompt`
941
+ const held = await run({ entries: finished });
942
+ assert.deepEqual(held.calls, ['agents', 'agents', 'stop', `--desktop --resume ${SUCCESSOR}`], '続けて2回、同じ所で止まっているのを見てから止める');
943
+ assert.equal(held.record.desktop.state, 'opened');
944
+ // ターンの終わりに付く見立てが blocked の時(実機の後継)も同じ
945
+ assert.equal((await run({ entries: finished, agent: { state: 'blocked' } })).record.desktop.state, 'opened');
946
+
947
+ // 道具の許可を本当に待っている(ターンの途中)
948
+ untouched(await run({ entries: [user('続けて'), callsTool('t1')] }));
949
+ // 保留を持ったまま、次のターンが動いている
950
+ untouched(await run({ entries: [...finished, user('次はこれ'), callsTool('t2'), toolResult('t2', 'ok')] }));
951
+ untouched(await run({ entries: finished, agent: { state: 'working' } }));
952
+ // 見ている間に transcript が伸び続ける(指示が届いた)
953
+ untouched(await run({ entries: finished, onPoll: () => appendFileSync(transcript, jsonl([{ type: 'last-prompt' }])) }));
954
+ const arrived = await run({ entries: finished, onPoll: count => { if (count === 2) appendFileSync(transcript, jsonl([user('次はこれ')])); } });
955
+ untouched(arrived);
956
+ // transcript の場所が記録に無い(0.16.19 までの版が残した記録)・読めない時は、今までどおり待つ
957
+ untouched(await run({ entries: finished, recordTranscript: null }));
958
+ untouched(await run({ entries: finished, recordTranscript: join(dir, 'missing.jsonl') }));
959
+ // busy は、transcript が終わったターンに見えても移さない
960
+ untouched(await run({ entries: finished, agent: { status: 'busy' } }));
961
+ }));
962
+
871
963
  test('公開CLI: --host claude は enable・disable・status・worker を受け取る', () => {
872
964
  assert.equal(parseAutoHandoffArgs(['status', '--json']).host, 'codex');
873
965
  assert.deepEqual(
@@ -273,7 +273,7 @@ async function processStop() {
273
273
  completeClaudeTurnWithoutHandoff({ sessionId: session_id });
274
274
  // Claude Desktop から始まった会話の後継が、ターンを終えた。Desktop へ移して開く process を起動する。
275
275
  // 失敗しても、会話の保存は続ける。
276
- try { await requestClaudeDesktopOpen({ sessionId: session_id }); }
276
+ try { await requestClaudeDesktopOpen({ sessionId: session_id, transcriptPath: transcript_path }); }
277
277
  catch (err) { process.stderr.write(`[auto-handoff] desktop open: ${err instanceof Error ? err.message : 'unknown'}\n`); }
278
278
 
279
279
  // VSCode で開かれたプロジェクトに .vscode/tasks.json を自動プロビジョニングする。