throughline 0.16.15 → 0.16.17

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,59 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.16.17] — 2026-10-10
14
+
15
+ ### 修正
16
+
17
+ - Windowsで、Claude Codeの自動継続の後、Windows Terminalの窓が約2秒おきに開いては消えた。後継をClaude Desktopへ移す処理は、後継が手すきになるまで`claude agents --json --all`を繰り返し呼ぶ。この処理はconsoleを持たない切り離したprocessで動き、子の`pwsh.exe`を窓を隠さずに起こしていたので、呼ぶたびに新しいconsole(既定のターミナルの窓)が出ていた。後継が手すきにならない間、最長2分、ターンが終わるたびに続いた。
18
+ - 子processを起こす共通の部品(`src/os/portable-spawn-sync.mjs`)が、窓を隠す指定(`windowsHide`)を既定で付ける。`claude agents`・`claude stop`・後継の起動・L1の要約(`claude -p`・`codex exec`)・更新の処理が対象。
19
+ - URLを開く`cmd.exe /c start`と、手動の引き継ぎのapp-serverの起動にも付けた。開く先のアプリの窓は隠れない。
20
+ - 端末が要る`claude --desktop --resume`は、今までどおり最小化した新しいconsoleの中で呼ぶ(1回だけ)。
21
+ - 手すきを待つ間隔を1秒から3秒へ広げた。
22
+
23
+ ### 対応の範囲
24
+
25
+ | | macOS | Linux | Windows |
26
+ |---|---|---|---|
27
+ | Claude Code | 窓の指定は効かないOS。手すきを待つ間隔だけ変わる(3秒)。単体試験とCI | 窓の指定は効かないOS。単体試験とCI。Desktopへ移す処理はLinuxでは動かない | 直した。実機の画面のあるsessionで、consoleを持たない切り離したprocessから`claude agents`を呼び、現れた窓を30msおきに数えた。0.16.16は3回の呼び出しでWindows Terminalの窓が3回、直した版は5回の呼び出しで0回。呼び出しの結果(一覧)は同じに読めた |
28
+ | Codex | 同じ(効かないOS) | 同じ | 直した。URLを開く所は、実機で窓を隠した`cmd.exe`からアプリが開く事を確かめた(電卓のURLで代用)。自動継続のworkerが起こすL1の要約(`codex exec`)は同じ部品を通るが、個別には確かめていない |
29
+ | Grok | 同じ(効かないOS) | 同じ | 直した。Stopのhookが起こすL1の要約が同じ部品を通る。個別には確かめていない |
30
+ | Cursor | 同じ(効かないOS) | 同じ | 直した。Grokと同じ |
31
+
32
+ ### 確認していない範囲
33
+
34
+ - 本物の引き継ぎの後に窓が出ない事(公開して端末へ入れた後に確かめる)。
35
+ - 後継が手すきにならない理由(裏で動き続ける処理を持つ会話など)。その間、後継はClaude Desktopへ移らない。
36
+
37
+ ## [0.16.16] — 2026-10-10
38
+
39
+ ### 追加
40
+
41
+ - Claude Desktopから始まった会話の後継を、リモートコントロール付き(`--remote-control=<後継の名前>`)で立てる。後継は裏の会話で、ターンを終えるまでClaude Desktopの一覧に出ない。作業の最中も、claude.ai/codeとClaudeのアプリから様子を見て操作できる([ADR 0048](docs/adr/0048-claude-desktop-origin-successor-has-remote-control.md))。
42
+ - 端末から始めた会話の後継には付けない。後継がさらに引き継ぐ時は、元の会話の出どころを引き継ぐ。
43
+ - 付けて立てるのに失敗した時は、付けずに1回だけ立て直す。引き継ぎは止めない。
44
+ - `auto-handoff status --host claude --json`の`remote_control_state`で読める(`requested`: 付けて立てた、`unavailable`: 付けずに立て直した、`null`: 対象外)。
45
+ - 環境変数`THROUGHLINE_AUTO_HANDOFF_REMOTE_CONTROL`で上書きできる(`on`・`off`)。
46
+
47
+ ### 対応の範囲
48
+
49
+ | | macOS | Linux | Windows |
50
+ |---|---|---|---|
51
+ | Claude Code | 追加した。試験用フォルダで、本物のworkerが後継をリモートコントロール付きで立て、transcriptにリモートのURLが入った(Claude Code 2.1.289、画面のあるsessionの端末)。sshから立てた時は、後継は立つがURLが入らなかった | 追加した。同じ確かめでURLが入った(2.1.296)。信頼していないフォルダでは、付けずに立て直す所まで動き、どちらも`Workspace not trusted`で立たなかった(今までと同じ) | 追加した。同じ確かめでURLが入った(2.1.296) |
52
+ | Codex | 対象外。リモートコントロールはClaude Codeの機能 | 同じ | 同じ |
53
+ | Grok | 対象外。同じ理由。自動継続の機能も無い | 同じ | 同じ |
54
+ | Cursor | 対象外。同じ理由。自動継続の機能も無い | 同じ | 同じ |
55
+
56
+ ### 変わらない事
57
+
58
+ - 後継がターンを終えた時にClaude Desktopへ移して開く動き。端末から始めた会話の引き継ぎ。
59
+
60
+ ### 確認していない範囲
61
+
62
+ - claude.ai/codeとClaudeのアプリの側で、後継が見えて操作できる所(確かめたのは、後継のtranscriptにリモートのURLが入った事まで)。
63
+ - Claude Desktopの会話の、本物の引き継ぎでの動き(試験は、端末から始めた会話にDesktopの印を付けて流した)。
64
+ - リモートコントロールが使えない状態(未ログインなど)での実物の動き(試験では作り物の失敗で、付けずに立て直す事を確かめた)。
65
+
13
66
  ## [0.16.15] — 2026-10-10
14
67
 
15
68
  ### 追加
@@ -2217,7 +2270,9 @@ two attempts, instrument first instead of patching again.
2217
2270
 
2218
2271
  ---
2219
2272
 
2220
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.15...HEAD
2273
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.17...HEAD
2274
+ [0.16.17]: https://github.com/kitepon/Throughline/compare/v0.16.16...v0.16.17
2275
+ [0.16.16]: https://github.com/kitepon/Throughline/compare/v0.16.15...v0.16.16
2221
2276
  [0.16.15]: https://github.com/kitepon/Throughline/compare/v0.16.14...v0.16.15
2222
2277
  [0.16.14]: https://github.com/kitepon/Throughline/compare/v0.16.13...v0.16.14
2223
2278
  [0.16.13]: https://github.com/kitepon/Throughline/compare/v0.16.12...v0.16.13
package/README.ja.md CHANGED
@@ -69,7 +69,7 @@ throughline auto-handoff disable --host claude
69
69
  3. 同じprojectに、継続の指示を最初の指示として付けた新しいClaudeの会話を裏で立てます(`claude --bg`)。会話の名前は、前任の会話の題から`<project名>|<作業の概要>(自動引き継ぎ)`にします。モデル・推論強度・権限は旧い会話から引き継ぎます。
70
70
  4. 最初の指示を受け取った時に、止めた時点の依頼、そのターンでここまでにしたこと(発言と道具の呼び出し)、実行されなかった道具、直近の会話の原文を注入します(上限9,500字)。止めたターンの道具の入出力は、後継が`throughline detail`で取り出せます。
71
71
 
72
- 続きは裏の会話で動きます。`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から起動した会話は対象に含めません。
72
+ 続きは裏の会話で動きます。`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))。
73
73
 
74
74
  ```bash
75
75
  throughline auto-handoff status --host claude --operation <handoff-id> --json
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から起動した会話は対象に含めません。
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))。
72
72
 
73
73
  ```bash
74
74
  throughline auto-handoff status --host claude --operation <handoff-id> --json
@@ -0,0 +1,47 @@
1
+ # ADR 0048: Claude Desktop から始まった会話の後継は、リモートコントロール付きで立てる
2
+
3
+ 日付: 2026-10-10
4
+
5
+ ## Context
6
+
7
+ Claude Code の自動継続は、後継を `claude --bg`(裏の会話)で立てる(ADR 0033)。Claude Desktop から始まった会話の後継は、
8
+ ターンを終えた時に Desktop へ移して開く(ADR 0043)。動いている最中の裏の会話は Desktop へ移せないので、後継が作業している間は、
9
+ Desktop の一覧に何も出ない。
10
+
11
+ 2026-10-10、Windows の実機で、本物の会話の最初の引き継ぎが起きた(Claude Desktop の会話、約 96.8 万トークン)。引き継ぎは成功したが、
12
+ 後継は裏で作業を続け、オーナーには状況が見えなかった(「スルーラインは発動したのか?状況がわからん」「リモートコントロールは有効に
13
+ なっているのかな」)。旧い会話は Desktop の会話で、リモートの接続の記録があった。後継には無かった。Throughline が後継を立てる時に、
14
+ リモートコントロールの指定を付けていなかった。
15
+
16
+ Claude Code には `--remote-control [name]` がある。付けて立てた会話は、claude.ai/code と Claude のアプリから見て操作できる。
17
+ 実機で確かめた事(試験用フォルダ、Haiku):
18
+
19
+ - `claude --bg --name=<名前> --remote-control=<名前> …` は、Windows・Linux(Claude Code 2.1.296)と macOS(2.1.289)で通る。
20
+ 立った会話は裏の会話(`kind: background`)で、transcript の `remote_session_change` にリモートの URL が入る。
21
+ - 名前は `=` の形で渡す。名前を省ける option なので、離して書くと次の引数を名前として読む。
22
+ - macOS で ssh から立てた時は、指定は通り会話も立つが、URL が入らなかった。画面のある session の端末からは入った。
23
+ 本物の引き継ぎの worker は Claude Desktop の下で動く。
24
+ - 信頼していないフォルダでは、リモートコントロールの有無に関係なく `Workspace not trusted` で立たない。
25
+
26
+ ## Decision
27
+
28
+ 1. Claude Desktop から始まった会話の後継と、その後継がさらに引き継ぐ時の後継を、`--remote-control=<後継の名前>` を付けて立てる。
29
+ 端末から始めた会話の後継には付けない(オーナーの裁定。Approval Box K-4DNZDK)。
30
+ 2. Desktop から始まったかは、ADR 0043 と同じく、止める時点の transcript の最後の `entrypoint`(`claude-desktop`)と、hook の環境変数
31
+ `CLAUDE_CODE_ENTRYPOINT` で決める。OS は問わない。引き継ぎの記録に `remote_control.wanted` として残す。
32
+ 3. 付けて立てるのに失敗した時(終了 code が 0 でない、裏の会話の ID が返らない)は、付けずに1回だけ立て直す。引き継ぎは止めない。
33
+ 後継が立っていない事が分かっている時だけ立て直すので、後継は2つにならない。
34
+ 4. 結果を記録の `remote_control.state`(`requested`: 付けて立てた、`unavailable`: 付けずに立て直した)に残し、
35
+ `auto-handoff status --host claude --json` の `remote_control_state` で読める。付けて立てた後に、Claude Code がリモートへ
36
+ つなげたかまでは見ない。
37
+ 5. 環境変数 `THROUGHLINE_AUTO_HANDOFF_REMOTE_CONTROL` で上書きできる。`on` は出どころに関係なく付け、`off` は付けない。
38
+ 6. 旧い会話を止めた理由の文に、後継がリモートコントロール付きで立つ事を書く。
39
+ 7. 後継がターンを終えた時に Desktop へ移して開く動き(ADR 0043)は変えない。
40
+
41
+ ## Consequences
42
+
43
+ - Desktop で作業している利用者は、後継が作業している最中も、claude.ai/code と Claude のアプリから様子を見て操作できる。
44
+ - リモートコントロールを付けた会話は、利用者のアカウントでログインした端末から操作できる状態になる。
45
+ - claude.ai/code とアプリの一覧に、Desktop から始まった会話の後継が並ぶ。
46
+ - すでに動いている後継には付かない。次の引き継ぎから付く。
47
+ - リモートコントロールは Claude Code の機能なので、Codex・Grok・Cursor には当てはまらない。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.16.15",
3
+ "version": "0.16.17",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -61,6 +61,8 @@ const PER_SESSION_ENV = ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_ME
61
61
  const DESKTOP_ENTRYPOINT = 'claude-desktop';
62
62
  // 後継を Desktop で開くかを、会話の出どころに関係なく決める(`desktop` で必ず開く、`off` で開かない)。
63
63
  const OPEN_OVERRIDE_ENV = 'THROUGHLINE_AUTO_HANDOFF_OPEN';
64
+ // 後継をリモートコントロール付きで立てるかの上書き。`on` は出どころに関係なく付け、`off` は付けない。
65
+ const REMOTE_CONTROL_OVERRIDE_ENV = 'THROUGHLINE_AUTO_HANDOFF_REMOTE_CONTROL';
64
66
 
65
67
  // requested と failed は、次の道具の hook が後継の立ち上げを始められる。
66
68
  const RETRYABLE_STATES = new Set(['requested', 'failed']);
@@ -145,6 +147,9 @@ export function publicClaudeAutoHandoff(record) {
145
147
  // 後継を Claude Desktop で開く引き継ぎか、開けたか(null は対象外)。
146
148
  desktop_state: record.desktop?.wanted ? (record.desktop.state ?? 'waiting') : null,
147
149
  desktop_error_code: record.desktop?.error_code ?? null,
150
+ // 後継をリモートコントロール付きで立てる引き継ぎか(null は対象外)。requested は指定を付けて立てた、unavailable は付けずに立て直した。
151
+ // 付けて立てた後に、Claude Code がリモートへつなげたかまでは見ていない。
152
+ remote_control_state: record.remote_control?.wanted ? (record.remote_control.state ?? 'waiting') : null,
148
153
  requested_at, updated_at };
149
154
  }
150
155
 
@@ -220,6 +225,19 @@ function openInDesktopWanted({ sessionId, transcriptPath, env, dir, platform })
220
225
  return listClaudeAutoHandoffs({ dir }).some(record => record.successor?.session_id === sessionId && record.desktop?.wanted);
221
226
  }
222
227
 
228
+ /**
229
+ * この会話の後継を、リモートコントロール付きで立てるか(ADR 0048)。
230
+ * Claude Desktop の画面から始まった会話と、その後継がさらに引き継ぐ時だけ付ける。後継は裏の会話で、ターンを終えるまで
231
+ * Desktop の一覧に出ない。リモートコントロールがあれば、作業の最中も claude.ai/code と Claude のアプリから見られる。
232
+ * 端末から始めた会話の後継は `claude agents`・`claude attach` で見られるので付けない。
233
+ */
234
+ function remoteControlWanted({ sessionId, transcriptPath, env, dir }) {
235
+ if (env[REMOTE_CONTROL_OVERRIDE_ENV] === 'off') return false;
236
+ if (env[REMOTE_CONTROL_OVERRIDE_ENV] === 'on') return true;
237
+ if (env.CLAUDE_CODE_ENTRYPOINT === DESKTOP_ENTRYPOINT || readLatestEntrypoint(transcriptPath) === DESKTOP_ENTRYPOINT) return true;
238
+ return listClaudeAutoHandoffs({ dir }).some(record => record.successor?.session_id === sessionId && record.remote_control?.wanted);
239
+ }
240
+
223
241
  /**
224
242
  * 会話を起動した project の場所を、その OS の書き方にそろえる。
225
243
  *
@@ -308,6 +326,7 @@ export function requestClaudeAutoHandoff({
308
326
  accepted_at: null,
309
327
  desktop: { wanted: openInDesktopWanted({ sessionId, transcriptPath: payload.transcript_path, env, dir, platform }),
310
328
  state: null, error_code: null, opened_at: null },
329
+ remote_control: { wanted: remoteControlWanted({ sessionId, transcriptPath: payload.transcript_path, env, dir }), state: null },
311
330
  });
312
331
  return { status: 'requested', block: true, sessionId, projectPath, handoffId };
313
332
  }
@@ -489,7 +508,8 @@ function stopOutput(record) {
489
508
  ? `この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: ${record.handoff_id})。続きは後継の会話で行ってください: claude attach ${record.successor.short_id}`
490
509
  : `Throughlineが、自動圧縮の代わりに新しい会話へ引き継ぎます(引き継ぎID: ${record.handoff_id})。` +
491
510
  (record.desktop?.wanted
492
- ? '後継の会話は裏で作業を続け、そのターンを終えた時にClaude Desktopへ開きます。途中の様子は `claude agents` の一覧で見られます。'
511
+ ? '後継の会話は裏で作業を続け、そのターンを終えた時にClaude Desktopへ開きます。途中の様子は `claude agents` の一覧で見られます。' +
512
+ (record.remote_control?.wanted ? '後継はリモートコントロール付きで立てるので、claude.ai/code とClaudeのアプリからも見られます。' : '')
493
513
  : '後継の会話は `claude agents` の一覧に出ます。');
494
514
  return {
495
515
  continue: false,
@@ -605,9 +625,12 @@ function successorName(record) {
605
625
  titles: [record.transcript_path ? readSessionTitle(record.transcript_path) : null, record.in_flight?.user?.content] });
606
626
  }
607
627
 
608
- function successorArgs(record) {
628
+ function successorArgs(record, { remoteControl = false } = {}) {
609
629
  // 名前は `--name=` の形で渡す。project のフォルダ名が `-` で始まっても、option として読まれない。
610
- const args = ['--bg', `--name=${successorName(record)}`];
630
+ const name = successorName(record);
631
+ const args = ['--bg', `--name=${name}`];
632
+ // リモートコントロールの名前も `=` の形で渡す(名前を省ける option なので、離して書くと次の引数を名前として読む)。
633
+ if (remoteControl) args.push(`--remote-control=${name}`);
611
634
  const { model, effort, permission_mode: permissionMode } = record.settings ?? {};
612
635
  if (model) args.push('--model', model);
613
636
  if (effort) args.push('--effort', effort);
@@ -656,13 +679,27 @@ export async function runClaudeAutoHandoffWorker(sessionId, {
656
679
  settings: { ...record.settings, model: record.transcript_path ? readLatestModel(record.transcript_path) : null },
657
680
  in_flight: inFlight,
658
681
  });
659
- const launched = spawn('claude', successorArgs(record), {
682
+ const launch = remoteControl => spawn('claude', successorArgs(record, { remoteControl }), {
660
683
  cwd: record.project_path, env: successorEnv(env), encoding: 'utf8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'],
661
684
  });
685
+ const backgroundedId = result => BACKGROUNDED_PATTERN.exec(normalizeTerminalText(`${result.stdout ?? ''}`))?.[1];
686
+ let launched = launch(Boolean(record.remote_control?.wanted));
687
+ if (record.remote_control?.wanted) {
688
+ if (!launched.error && launched.status === 0 && backgroundedId(launched)) {
689
+ update({ remote_control: { ...record.remote_control, state: 'requested' } });
690
+ } else if (!launched.error && !backgroundedId(launched)) {
691
+ // リモートコントロールを付けると立てられない(未ログイン、古い Claude Code など)。後継は立っていないので、
692
+ // 付けずに立て直す。引き継ぎは止めない。
693
+ process.stderr.write(`[auto-handoff] claude --bg --remote-control exit=${launched.status} ` +
694
+ `stderr=${JSON.stringify(`${launched.stderr ?? ''}`.slice(0, 300))}; retrying without remote control\n`);
695
+ update({ remote_control: { ...record.remote_control, state: 'unavailable' } });
696
+ launched = launch(false);
697
+ }
698
+ }
662
699
  if (launched.error) {
663
700
  return fail(launched.error.code === 'ENOENT' ? 'handoff_claude_cli_unavailable' : 'handoff_successor_launch_failed');
664
701
  }
665
- const shortId = BACKGROUNDED_PATTERN.exec(normalizeTerminalText(`${launched.stdout ?? ''}`))?.[1];
702
+ const shortId = backgroundedId(launched);
666
703
  if (launched.status !== 0 || !shortId) {
667
704
  // 理由を端末内の worker のログに残す(指示や記憶は含まれない、起動コマンドの出力だけ)。
668
705
  process.stderr.write(`[auto-handoff] claude --bg exit=${launched.status} stdout=${JSON.stringify(`${launched.stdout ?? ''}`.slice(0, 500))} ` +
@@ -741,7 +778,8 @@ export async function runClaudeDesktopOpen(sourceSessionId, {
741
778
  spawn = spawnPortableSync,
742
779
  pty = runWithPty,
743
780
  idleTimeoutMs = 120_000,
744
- pollMs = 1_000,
781
+ // 一覧を読むたびに `claude agents` を起こす(Windows では pwsh 経由)。手すきは急いで知る必要が無いので、間を空ける。
782
+ pollMs = 3_000,
745
783
  } = {}) {
746
784
  let record = readClaudeAutoHandoff(sourceSessionId, dir);
747
785
  if (!record) throw new ClaudeHandoffError('handoff_not_found');
@@ -639,7 +639,83 @@ test('PreToolUse: Desktop で開く引き継ぎは、止めた理由に、後継
639
639
  writeFileSync(desktopTranscript, jsonl([{ ...user('読んで', '2026-10-08T12:00:00.000Z'), entrypoint: 'claude-desktop' }]));
640
640
  request(dir, { platform: 'darwin', transcriptPath: desktopTranscript });
641
641
  const output = await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app', tool_use_id: 't1' }, dir, launchWorker: async () => 1 });
642
- assert.match(output.stopReason, /そのターンを終えた時にClaude Desktopへ開きます。途中の様子は `claude agents` の一覧で見られます。$/);
642
+ assert.match(output.stopReason, /そのターンを終えた時にClaude Desktopへ開きます。途中の様子は `claude agents` の一覧で見られます。後継はリモートコントロール付きで立てるので、claude\.ai\/code とClaudeのアプリからも見られます。$/);
643
+ }));
644
+
645
+ test('PreCompact: Claude Desktop から始まった会話と、その後継の引き継ぎだけ、後継をリモートコントロール付きで立てる印を付ける(OS に関係なく)', () => withDir(dir => {
646
+ const wanted = overrides => {
647
+ rmSync(join(dir, `${SESSION}.json`), { force: true });
648
+ request(dir, overrides);
649
+ return readClaudeAutoHandoff(SESSION, dir).remote_control;
650
+ };
651
+ const desktopTranscript = join(dir, 'desktop.jsonl');
652
+ writeFileSync(desktopTranscript, jsonl([{ ...user('読んで', '2026-10-10T05:00:00.000Z'), entrypoint: 'claude-desktop' }]));
653
+ const cliTranscript = join(dir, 'cli.jsonl');
654
+ writeFileSync(cliTranscript, jsonl([{ ...user('読んで', '2026-10-10T05:00:00.000Z'), entrypoint: 'cli' }]));
655
+ for (const platform of ['darwin', 'win32', 'linux']) {
656
+ const payload = platform === 'win32' ? { cwd: 'C:\\work\\app' } : undefined;
657
+ assert.deepEqual(wanted({ platform, payload, transcriptPath: desktopTranscript }), { wanted: true, state: null }, platform);
658
+ assert.equal(wanted({ platform, payload, env: { CLAUDE_CODE_ENTRYPOINT: 'claude-desktop' } }).wanted, true, platform);
659
+ assert.equal(wanted({ platform, payload, transcriptPath: cliTranscript }).wanted, false, `${platform}: 端末から始めた会話の後継には付けない`);
660
+ assert.equal(wanted({ platform, payload }).wanted, false, platform);
661
+ assert.equal(wanted({ platform, payload, transcriptPath: cliTranscript, env: { THROUGHLINE_AUTO_HANDOFF_REMOTE_CONTROL: 'on' } }).wanted, true, platform);
662
+ assert.equal(wanted({ platform, payload, transcriptPath: desktopTranscript, env: { THROUGHLINE_AUTO_HANDOFF_REMOTE_CONTROL: 'off' } }).wanted, false, platform);
663
+ }
664
+ // 裏で作業している後継がさらに引き継ぐ時は、元の会話の出どころを引き継ぐ(後継の transcript は cli)。
665
+ writeRecord(dir, 'origin-desktop', { successor: { short_id: SESSION.slice(0, 8), session_id: SESSION }, remote_control: { wanted: true, state: 'requested' } });
666
+ assert.equal(wanted({ platform: 'darwin', transcriptPath: cliTranscript }).wanted, true);
667
+ writeRecord(dir, 'origin-desktop', { successor: { short_id: SESSION.slice(0, 8), session_id: SESSION }, remote_control: { wanted: false, state: null } });
668
+ assert.equal(wanted({ platform: 'darwin', transcriptPath: cliTranscript }).wanted, false);
669
+ }));
670
+
671
+ test('worker: 印のある引き継ぎは、後継を --remote-control=<後継の名前> 付きで立てる。印の無い引き継ぎには付けない', () => withDir(async dir => {
672
+ const run = async (sessionId, env) => {
673
+ const transcriptPath = join(dir, `${sessionId}.jsonl`);
674
+ writeFileSync(transcriptPath, jsonl([{ ...user('14個のファイルを順に読んで', '2026-10-10T05:00:10Z'), entrypoint: env.CLAUDE_CODE_ENTRYPOINT ?? 'cli' },
675
+ { type: 'custom-title', customTitle: 'マビノギ BOT 開発' }, assistant('part04 を読みました。', '2026-10-10T05:00:12Z')]));
676
+ const db = makeBatonDb();
677
+ requestClaudeAutoHandoff({ payload: { session_id: sessionId, trigger: 'auto', cwd: '/work/OpenLogicool', transcript_path: transcriptPath },
678
+ env, config: ENABLED, openDb: () => db, dir, platform: 'linux' });
679
+ await stopClaudeTurnForHandoff({ payload: { session_id: sessionId, cwd: '/work/OpenLogicool', transcript_path: transcriptPath },
680
+ dir, launchWorker: async () => {} });
681
+ const launches = [];
682
+ const record = await runClaudeAutoHandoffWorker(sessionId, { dir, env: {}, pollMs: 10, acceptTimeoutMs: 300, transcriptTimeoutMs: 50,
683
+ spawn: (command, args) => { launches.push(args); successorStarts(dir, sessionId)(args); return launchedOk(); } });
684
+ return { record, launches };
685
+ };
686
+ const desktop = await run('bbbbbbbb-0000-4000-8000-000000000001', { CLAUDE_CODE_ENTRYPOINT: 'claude-desktop' });
687
+ assert.equal(desktop.launches.length, 1);
688
+ assert.deepEqual(desktop.launches[0].slice(0, 3), ['--bg', '--name=OpenLogicool|マビノギ BOT 開発(自動引き継ぎ)', '--remote-control=OpenLogicool|マビノギ BOT 開発(自動引き継ぎ)']);
689
+ assert.equal(desktop.record.state, 'sent');
690
+ assert.deepEqual(desktop.record.remote_control, { wanted: true, state: 'requested' });
691
+ assert.equal(publicClaudeAutoHandoff(desktop.record).remote_control_state, 'requested');
692
+
693
+ const terminal = await run('bbbbbbbb-0000-4000-8000-000000000002', {});
694
+ assert.ok(!terminal.launches[0].some(arg => arg.startsWith('--remote-control')));
695
+ assert.deepEqual(terminal.record.remote_control, { wanted: false, state: null });
696
+ assert.equal(publicClaudeAutoHandoff(terminal.record).remote_control_state, null);
697
+ }));
698
+
699
+ test('worker: リモートコントロールを付けると立てられない時は、付けずに立て直す。引き継ぎは止めない', () => withDir(async dir => {
700
+ const sessionId = 'cccccccc-0000-4000-8000-000000000001';
701
+ const transcriptPath = join(dir, `${sessionId}.jsonl`);
702
+ writeFileSync(transcriptPath, jsonl([{ ...user('読んで', '2026-10-10T05:00:10Z'), entrypoint: 'claude-desktop' }, assistant('読みました。', '2026-10-10T05:00:12Z')]));
703
+ const db = makeBatonDb();
704
+ requestClaudeAutoHandoff({ payload: { session_id: sessionId, trigger: 'auto', cwd: '/work/app', transcript_path: transcriptPath },
705
+ env: {}, config: ENABLED, openDb: () => db, dir, platform: 'darwin' });
706
+ await stopClaudeTurnForHandoff({ payload: { session_id: sessionId, cwd: '/work/app', transcript_path: transcriptPath }, dir, launchWorker: async () => {} });
707
+ const launches = [];
708
+ const record = await runClaudeAutoHandoffWorker(sessionId, { dir, env: {}, pollMs: 10, acceptTimeoutMs: 300, transcriptTimeoutMs: 50,
709
+ spawn: (command, args) => {
710
+ launches.push(args.some(arg => arg.startsWith('--remote-control')));
711
+ if (launches.length === 1) return { status: 1, stdout: '', stderr: 'Remote Control requires a claude.ai login' };
712
+ successorStarts(dir, sessionId)(args); return launchedOk();
713
+ } });
714
+ assert.deepEqual(launches, [true, false], '1回目は付けて、2回目は付けずに立てる');
715
+ assert.equal(record.state, 'sent'); assert.equal(record.error_code, null);
716
+ assert.deepEqual(record.remote_control, { wanted: true, state: 'unavailable' });
717
+ assert.equal(publicClaudeAutoHandoff(record).remote_control_state, 'unavailable');
718
+ assert.equal(publicClaudeAutoHandoff(record).desktop_state, 'waiting', 'Desktop へ開く印は変わらない');
643
719
  }));
644
720
 
645
721
  test('Stop: Desktop で開く引き継ぎの後継がターンを終えたら、移す process を1回だけ起動する', () => withDir(async dir => {
@@ -1014,7 +1014,7 @@ export async function run(argv = []) {
1014
1014
  await check('throughline コマンドが PATH で見つかるか', () => {
1015
1015
  try {
1016
1016
  const which = process.platform === 'win32' ? 'where throughline' : 'which throughline';
1017
- const result = execSync(which, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
1017
+ const result = execSync(which, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }).trim();
1018
1018
  return result.split(/\r?\n/)[0];
1019
1019
  } catch {
1020
1020
  throw new Error('見つからない — npm install -g throughline を実行してください');
@@ -1649,6 +1649,7 @@ function startAppServerClient({ command, args, cwd, timeoutMs, requestTimeoutMs
1649
1649
  const child = spawn(command, args, {
1650
1650
  cwd,
1651
1651
  stdio: ['pipe', 'pipe', 'pipe'],
1652
+ windowsHide: true,
1652
1653
  });
1653
1654
  let stdoutBuffer = '';
1654
1655
  let stderr = '';
@@ -11,6 +11,7 @@ export function openUrlWithOsHandler(url, {
11
11
  spawnImpl = spawnSync,
12
12
  } = {}) {
13
13
  if (platform === 'darwin') return spawnImpl('open', [url], { encoding: 'utf8' });
14
- if (platform === 'win32') return spawnImpl('cmd.exe', ['/c', 'start', '', url], { encoding: 'utf8' });
14
+ // cmd.exe 自身の窓は隠す(console を持たない worker から呼ぶと、窓が一瞬出る)。start が開く先の窓は、これでは隠れない。
15
+ if (platform === 'win32') return spawnImpl('cmd.exe', ['/c', 'start', '', url], { encoding: 'utf8', windowsHide: true });
15
16
  return spawnImpl('xdg-open', [url], { encoding: 'utf8' });
16
17
  }
@@ -0,0 +1,14 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { openUrlWithOsHandler } from './open-url.mjs';
4
+
5
+ test('openUrlWithOsHandler: OS ごとの命令で開き、Windows では cmd.exe の窓を隠す', () => {
6
+ const calls = [];
7
+ const spawnImpl = (command, args, options) => { calls.push({ command, args, options }); return { status: 0 }; };
8
+ openUrlWithOsHandler('codex://threads/abc', { platform: 'win32', spawnImpl });
9
+ openUrlWithOsHandler('codex://threads/abc', { platform: 'darwin', spawnImpl });
10
+ openUrlWithOsHandler('codex://threads/abc', { platform: 'linux', spawnImpl });
11
+ assert.deepEqual(calls[0], { command: 'cmd.exe', args: ['/c', 'start', '', 'codex://threads/abc'], options: { encoding: 'utf8', windowsHide: true } });
12
+ assert.deepEqual([calls[1].command, calls[1].args], ['open', ['codex://threads/abc']]);
13
+ assert.deepEqual([calls[2].command, calls[2].args], ['xdg-open', ['codex://threads/abc']]);
14
+ });
@@ -28,11 +28,15 @@ function resolveWindowsCommand(command, env) {
28
28
  return command;
29
29
  }
30
30
 
31
- function portableInvocation(command, args, options = {}) {
31
+ export function portableInvocation(command, args, options = {}) {
32
32
  const platform = options.platform ?? process.platform;
33
33
  const spawnOptions = { ...options };
34
34
  delete spawnOptions.platform;
35
35
  spawnOptions.shell = false;
36
+ // Windows では、console を持たない process(切り離した worker など)が console の子を起こすと、新しい console が作られ、
37
+ // 既定のターミナル(Windows Terminal)の窓が一瞬出る。出力は pipe で受けるので、窓は要らない。
38
+ // 窓が要る呼び出し(端末が要る命令)は os/pty-run.mjs が別に持つ。ほかの OS では無視される。
39
+ spawnOptions.windowsHide ??= true;
36
40
 
37
41
  if (platform !== 'win32') return [command, args, spawnOptions];
38
42
 
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
3
3
  import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import { spawnPortable, spawnPortableSync } from './portable-spawn-sync.mjs';
6
+ import { portableInvocation, spawnPortable, spawnPortableSync } from './portable-spawn-sync.mjs';
7
7
  import { createInterface } from 'node:readline';
8
8
  import { once } from 'node:events';
9
9
 
@@ -88,3 +88,24 @@ exit $LASTEXITCODE
88
88
  rmSync(dir, { recursive: true, force: true });
89
89
  }
90
90
  });
91
+
92
+ test('portableInvocation: 窓を隠す指定を既定で付ける。呼び出し側が false を渡した時だけ外す', () => {
93
+ for (const platform of ['win32', 'linux', 'darwin']) {
94
+ const [, , hidden] = portableInvocation('claude', ['agents', '--json', '--all'], { platform, env: { PATH: '' }, encoding: 'utf8' });
95
+ assert.equal(hidden.windowsHide, true, platform);
96
+ assert.equal(hidden.shell, false); assert.equal(hidden.encoding, 'utf8'); assert.equal('platform' in hidden, false);
97
+ const [, , shown] = portableInvocation('claude', ['agents'], { platform, env: { PATH: '' }, windowsHide: false });
98
+ assert.equal(shown.windowsHide, false, platform);
99
+ }
100
+ });
101
+
102
+ test('portableInvocation: Windows の .ps1 shim は pwsh.exe 経由で、窓を隠して起こす', () => {
103
+ const dir = mkdtempSync(join(tmpdir(), 'tl-portable-hide-'));
104
+ try {
105
+ writeFileSync(join(dir, 'claude.ps1'), 'exit 0\n');
106
+ const [command, args, options] = portableInvocation('claude', ['stop', 'abcd1234'], { platform: 'win32', env: { PATH: dir } });
107
+ assert.equal(command, 'pwsh.exe');
108
+ assert.ok(args.at(-1).includes(join(dir, 'claude.ps1')) && args.at(-1).includes("'stop' 'abcd1234'"));
109
+ assert.equal(options.windowsHide, true);
110
+ } finally { rmSync(dir, { recursive: true, force: true }); }
111
+ });