throughline 0.16.8 → 0.16.10

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,54 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.16.10] — 2026-10-09
14
+
15
+ ### 修正
16
+
17
+ - Codex Desktopの自動継続で、引き継ぎを重ねると後継のタスクが全部同じ名前になり、一覧でどれが今の続きかを見分けられなかった。元のタスクも、引き継いだ後に名前が変わらなかった。後継が作業を続けたのを確かめた後に、旧タスクの名前の頭へ`【引き継ぎ済み】`を付ける。同じ作業の系列で、印の無い1本が今の続きになる([ADR 0044](docs/adr/0044-codex-handed-off-source-is-marked.md))。
18
+ - 名前は、その時の旧タスクの名前を読んでから作る。利用者が付け直した名前も、印の後ろに残る。付けられなくても引き継ぎは止めない。
19
+ - 途中で止まった引き継ぎの旧タスクには付けない。
20
+
21
+ ### 追加
22
+
23
+ - `throughline auto-handoff mark-sources`。0.16.9までの版が引き継いだ旧タスクへ、印を付け直す。生きている後継がある旧タスクだけに付ける。何度流しても同じ結果になる。
24
+
25
+ ### 変わらない事
26
+
27
+ - 後継の名前(`<project名>|<作業の概要>(自動引き継ぎ)`)。引き継ぎ済みの旧タスクへ入力した時の扱い(止めて後継を開く)。Claude Codeの自動継続。
28
+
29
+ ### 確認した範囲
30
+
31
+ - Linux(Codex Desktop同梱のCodex 0.162.0-alpha.2)の試験用フォルダで、本物のCodex Desktopに自動継続を2回続けて流した。2回とも、後継が作業を続けた後に旧タスクが`【引き継ぎ済み】`の名前になり、印の無いタスクは最後の後継の1本だけになった。
32
+ - macOS(同梱のCodex 0.162.0-alpha.2)とWindowsの同梱のCodexで、試験タスクへ印を付けた。1回目は付き、2回目は付け直さなかった。`thread/list`の`updatedAt`は変わらなかった。この2つの端末では、引き継ぎの通しは流していない。
33
+ - Desktopが開いたままのタスクへ、別のprocessから名前を付けられる事(Linux。`thread/archive`は`already has an active writer`で断られ、`thread/name/set`は通った)。
34
+
35
+ ### 確認していない範囲
36
+
37
+ - 付け直した名前が、Codex Desktopの一覧の表示へいつ出るか(画面は見ていない。Codexの`thread/list`と`session_index.jsonl`では、付けた直後に新しい名前が返る)。
38
+ - Claude Codeの後継も同じ名前の付け方だが、旧い会話の名前を外から変える公式の入口を確かめていない。
39
+
40
+ ## [0.16.9] — 2026-10-09
41
+
42
+ ### 追加
43
+
44
+ - Claude Desktopから始まった会話の後継を、作業を終えた時にClaude Desktopへ開く動き(0.16.8、[ADR 0043](docs/adr/0043-claude-successor-opens-in-desktop.md))を、Windowsでも行う。`claude --desktop --resume`は出力が端末でないと動かないので、Windowsでは新しいconsole(最小化)の中のPowerShellに実行させ、その終了codeで成否を決める。
45
+ - Windowsの端末(Claude Code 2.1.293、Claude Desktop 2.16120)の実測。裏の会話は、作業中も手すきも`That session is running in the background`(終了code 1)で断られ、`claude stop`の後は`Opening session <id> in Claude Desktop`(終了code 0)で開いた。macOSと同じ。
46
+ - `cmd /c start /wait`は、中の命令が断られても0を返し、batchのshim(`claude.cmd`)を閉じないwindowで開くので使わない。
47
+
48
+ ### 変わらない事
49
+
50
+ - macOSの動き。LinuxのClaude Codeには`--desktop`が無い(`--desktop isn't available on this platform. It works on macOS and Windows (x64).`)ので、Linuxでは今までどおり裏の会話のままにする。
51
+
52
+ ### 確認した範囲
53
+
54
+ - Windowsで、端末から始めた会話に`THROUGHLINE_AUTO_HANDOFF_OPEN=desktop`を付けて流した。続けて4回引き継ぎ、最後の後継が作業を終えた16秒後に、Claude Desktopがその会話を取り込んだ。途中の後継は移っていない。
55
+ - Linux(Claude Code 2.1.293)で、Claude Codeの自動継続が続けて3回引き継いで完了する事(後継は裏の会話のまま)。
56
+
57
+ ### 確認していない範囲
58
+
59
+ - WindowsとmacOSの、Claude Desktopの画面から始めた本物の会話での通し。
60
+
13
61
  ## [0.16.8] — 2026-10-08
14
62
 
15
63
  ### 追加
@@ -2055,7 +2103,9 @@ two attempts, instrument first instead of patching again.
2055
2103
 
2056
2104
  ---
2057
2105
 
2058
- [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.8...HEAD
2106
+ [Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.10...HEAD
2107
+ [0.16.10]: https://github.com/kitepon/Throughline/compare/v0.16.9...v0.16.10
2108
+ [0.16.9]: https://github.com/kitepon/Throughline/compare/v0.16.8...v0.16.9
2059
2109
  [0.16.8]: https://github.com/kitepon/Throughline/compare/v0.16.7...v0.16.8
2060
2110
  [0.16.7]: https://github.com/kitepon/Throughline/compare/v0.16.6...v0.16.7
2061
2111
  [0.16.6]: https://github.com/kitepon/Throughline/compare/v0.16.5...v0.16.6
package/README.ja.md CHANGED
@@ -29,7 +29,7 @@ throughline auto-handoff disable
29
29
 
30
30
  既定では無効です。`enable`は必要な`PreCompact(auto)`フックを登録し、そのフックの承認と有効状態を公式APIで確認します。`--project`を省略すると全projectを対象にします。macOS・Windows・LinuxのCodex Desktopで実機確認済みです(Windowsは0.16.6から、Linuxは0.16.7から)。Codex Desktopに同梱のCodexが0.162以上の時は、0.16.7以上が要ります。VS Code・CLIの自動継続は未検証です。
31
31
 
32
- 自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact`は発火対象に含めません。
32
+ 自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。後継が作業を続けたのを確かめた後、旧タスクの名前の頭へ`【引き継ぎ済み】`を付けます(0.16.10から。それより前の版が引き継いだ分は`throughline auto-handoff mark-sources`で付け直せます)。一覧で、印の無い1本が今の続きです。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact`は発火対象に含めません。
33
33
 
34
34
  記憶は引き継ぎ全体の直近20ターンをL2全文、それより古いターンをL1で渡します。元のユーザー依頼と中断地点を保持し、tool結果とThinkingは注入しません。本文内の`auto-handoff detail`コマンドで、凍結したL2/L3を引き継ぎID・origin・turnから取得できます。A→B→Cの連続引き継ぎでも祖先の詳細を取得できます。
35
35
 
@@ -61,7 +61,7 @@ throughline auto-handoff disable --host claude
61
61
  3. 同じprojectに、継続の指示を最初の指示として付けた新しいClaudeの会話を裏で立てます(`claude --bg`)。会話の名前は、前任の会話の題から`<project名>|<作業の概要>(自動引き継ぎ)`にします。モデル・推論強度・権限は旧い会話から引き継ぎます。
62
62
  4. 最初の指示を受け取った時に、止めた時点の依頼、そのターンでここまでにしたこと(発言と道具の呼び出し)、実行されなかった道具、直近の会話の原文を注入します(上限9,500字)。止めたターンの道具の入出力は、後継が`throughline detail`で取り出せます。
63
63
 
64
- 続きは裏の会話で動きます。`claude agents`の一覧と`claude attach <id>`で見られます。Claude Desktopの画面から始まった会話の後継は、そのターンの作業を終えた時に、Claude Desktopへ移して開きます(macOS、0.16.8から。[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から起動した会話は対象に含めません。
64
+ 続きは裏の会話で動きます。`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から起動した会話は対象に含めません。
65
65
 
66
66
  ```bash
67
67
  throughline auto-handoff status --host claude --operation <handoff-id> --json
package/README.md CHANGED
@@ -28,7 +28,7 @@ throughline auto-handoff disable
28
28
 
29
29
  既定では無効です。`enable`は必要な`PreCompact(auto)`フックを登録し、そのフックの承認と有効状態を公式APIで確認します。`--project`を省略すると全projectを対象にします。macOS・Windows・LinuxのCodex Desktopで実機確認済みです(Windowsは0.16.6から、Linuxは0.16.7から)。Codex Desktopに同梱のCodexが0.162以上の時は、0.16.7以上が要ります。VS Code・CLIの自動継続は未検証です。
30
30
 
31
- 自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact`は発火対象に含めません。
31
+ 自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。後継が作業を続けたのを確かめた後、旧タスクの名前の頭へ`【引き継ぎ済み】`を付けます(0.16.10から。それより前の版が引き継いだ分は`throughline auto-handoff mark-sources`で付け直せます)。一覧で、印の無い1本が今の続きです。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact`は発火対象に含めません。
32
32
 
33
33
  記憶は引き継ぎ全体の直近20ターンをL2全文、それより古いターンをL1で渡します。元のユーザー依頼と中断地点を保持し、tool結果とThinkingは注入しません。本文内の`auto-handoff detail`コマンドで、凍結したL2/L3を引き継ぎID・origin・turnから取得できます。A→B→Cの連続引き継ぎでも祖先の詳細を取得できます。
34
34
 
@@ -60,7 +60,7 @@ throughline auto-handoff disable --host claude
60
60
  3. 同じprojectに、継続の指示を最初の指示として付けた新しいClaudeの会話を裏で立てます(`claude --bg`)。会話の名前は、前任の会話の題から`<project名>|<作業の概要>(自動引き継ぎ)`にします。モデル・推論強度・権限は旧い会話から引き継ぎます。
61
61
  4. 最初の指示を受け取った時に、止めた時点の依頼、そのターンでここまでにしたこと(発言と道具の呼び出し)、実行されなかった道具、直近の会話の原文を注入します(上限9,500字)。止めたターンの道具の入出力は、後継が`throughline detail`で取り出せます。
62
62
 
63
- 続きは裏の会話で動きます。`claude agents`の一覧と`claude attach <id>`で見られます。Claude Desktopの画面から始まった会話の後継は、そのターンの作業を終えた時に、Claude Desktopへ移して開きます(macOS、0.16.8から。[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から起動した会話は対象に含めません。
63
+ 続きは裏の会話で動きます。`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から起動した会話は対象に含めません。
64
64
 
65
65
  ```bash
66
66
  throughline auto-handoff status --host claude --operation <handoff-id> --json
@@ -412,6 +412,8 @@ Usage:
412
412
  引き継ぎの状態と固定理由を確認
413
413
  throughline auto-handoff resume --operation <id> --json
414
414
  同じ引き継ぎを再開(結果不明は再送しない)
415
+ throughline auto-handoff mark-sources [--json]
416
+ 引き継ぎ済みの旧タスクの名前へ印を付け直す
415
417
  throughline auto-handoff detail --operation <id> --origin <session> --turn <n>
416
418
  凍結したL2/L3を取得
417
419
  throughline auto-handoff enable|disable|status --host claude [--project <path>] [--json]
@@ -35,7 +35,9 @@ macOS(Claude Code 2.1.289、Claude Desktop 2.26454.2)で確かめた事:
35
35
  擬似端末の中で `claude --desktop --resume <session id>` を呼ぶ。出力の `Opening session <id>` と終了 code で成否を決める。
36
36
  4. 後継が引き継ぎの途中(その後継自身の記録がある)なら移さない。作業は次の後継が続けるので、移すのは最後に作業を終えた
37
37
  後継だけ。手すきにならないまま時間切れになった時は、止めずに戻り、次の Stop でやり直す。
38
- 5. 対応は macOS だけ(擬似端末に `/usr/bin/script` を使う)。ほかの OS では印を付けず、今までどおり裏の会話のままにする。
38
+ 5. 対応は macOS と Windows。macOS は擬似端末に `/usr/bin/script` を使う。Windows は PowerShell の
39
+ `Start-Process -Wait -PassThru` で新しい console(最小化)の中の pwsh に実行させ、その終了 code で成否を決める
40
+ (出た文は読めない)。Linux の Claude Code には `--desktop` が無いので、印を付けず、今までどおり裏の会話のままにする。
39
41
  6. 環境変数 `THROUGHLINE_AUTO_HANDOFF_OPEN` で上書きできる。`desktop` は出どころに関係なく開き、`off` は開かない。
40
42
  7. 旧い会話を止めた理由の文に、後継がターンを終えた時に Desktop へ開く事を書く。
41
43
  8. 結果は記録の `desktop`(`state`: `requested`・`opened`・`superseded`・`failed`、`error_code`、`opened_at`)に残し、
@@ -61,8 +63,21 @@ macOS、Claude Code 2.1.289、Haiku 4.5、試験用フォルダ。端末から
61
63
  - 1回の引き継ぎ: 後継が 22:00:59 に作業を終え、22:01:03 に Desktop が取り込んだ。
62
64
  - 続けて3回の引き継ぎ: 最後の後継だけが移り(22:03:13)、途中の2つは移らなかった。
63
65
 
66
+ ## Windows(2026-10-08 23:45〜23:57、0.16.9)
67
+
68
+ Claude Code 2.1.293、Claude Desktop 2.16120(MSIX)。画面のある session で確かめた(ssh は session 0 で、Desktop へ届かない)。
69
+
70
+ - 裏の会話の3つの状態は macOS と同じ。作業中と手すきは `That session is running in the background`(終了 code 1)、
71
+ `claude stop` の後は `Opening session <id> in Claude Desktop`(終了 code 0)で、数秒後に Desktop の会話の置き場
72
+ (`%LOCALAPPDATA%\Packages\Claude_…\LocalCache\Roaming\Claude\claude-code-sessions`)へ入った。
73
+ - `cmd /c start "" /min /wait claude …` は、batch の shim を `cmd /K` で開いて戻らない。内側に `cmd /c` を挟むと戻るが、
74
+ 断られた時も終了 code は 0 だった。`Start-Process pwsh … -Wait -PassThru` の `ExitCode` は、断られた時 1、開けた時 0。
75
+ - 通し: 端末から始めた会話に `THROUGHLINE_AUTO_HANDOFF_OPEN=desktop` を付けた。続けて4回引き継ぎ、最後の後継が
76
+ 23:56:26 に作業を終え、23:56:42 に Desktop が取り込んだ。途中の3つは移っていない。
77
+
78
+ Linux(Claude Code 2.1.293)は `--desktop isn't available on this platform. It works on macOS and Windows (x64).` で断る。
79
+
64
80
  ## 確かめていない範囲
65
81
 
66
82
  - Claude Desktop の画面から始めた本物の会話での通し(印の付き方は、実物の transcript の値と単体の試験で確かめた)。
67
- - Windows と Linux の Claude Desktop。
68
83
  - 長いターンの後継と、後継が質問で止まった時の見え方。
@@ -0,0 +1,50 @@
1
+ # ADR 0044: 引き継ぎが済んだ Codex の旧タスクの名前へ、引き継ぎ済みの印を付ける
2
+
3
+ 日付: 2026-10-09
4
+
5
+ ## Context
6
+
7
+ Codex Desktop の自動継続は、後継のタスクに `<project 名>|<作業の概要>(自動引き継ぎ)` の名前を付ける(ADR 0037)。
8
+ 概要は前任の題から取り、前任がこの形の名前を持つ後継なら中の概要だけを取り出す。project 名と印が積み重ならない
9
+ 代わりに、2回目からの後継も1回目と同じ名前になる。
10
+
11
+ 2026-10-09、オーナーから「スルーライン後に引き継ぎ先のcodexタスクが見つからない」と指摘があった。macOS の実機の
12
+ `session_index.jsonl` には、同じ名前の後継が4本(`BellTeam|ベルチーム(自動引き継ぎ)`)と3本
13
+ (`approval-box|ナユタとASCの状況を確認(自動引き継ぎ)`)あった。元のタスクも、引き継いだ後に名前が変わらない。
14
+ 一覧を見ても、どれが今の続きかを名前で決められない。
15
+
16
+ 確かめた事(Codex Desktop 同梱の Codex 0.162.0-alpha.2):
17
+
18
+ - 後継は一覧から消えていない。Desktop と同じ条件(`thread/list`、`archived: false`、`useStateDbOnly: true`)で
19
+ 問い合わせると、その日の後継は2番目に入っていた(1番目は旧タスク)。
20
+ - Desktop が開いたままのタスクは、別の process から `thread/archive` できない(`already has an active writer`)。
21
+ `thread/name/set` は通る。
22
+ - `thread/name/set` は、`thread/list` の `updatedAt` と `recencyAt` を変えない。一覧の並びは動かない。
23
+ - 引き継ぎ済みの旧タスクへ入力すると、止めた理由の文に後継の名前と link が出る(ADR 0039)。ここは足りている。
24
+
25
+ 直し方は2つ考えた。旧タスクへ印を付ける形と、後継の名前へ何回目かの番号を付ける形。番号の形は、元のタスクに印が
26
+ 付かず、番号を見比べる必要がある。オーナーは印の形を選んだ(Approval Box K-TFYVTM)。
27
+
28
+ ## Decision
29
+
30
+ 1. 後継が作業を続けたのを確かめた後(引き継ぎの `state` が `continued` になった直後)に、旧タスクの名前の頭へ
31
+ `【引き継ぎ済み】` を付ける。一覧は名前の後ろを切るので、印は頭に置く。
32
+ 2. 名前は、その時の旧タスクの名前を `thread/read` で読んでから作る。利用者が付け直した名前も残る。名前の無いタスクは、
33
+ 最初の依頼の1行目を使う(60 字で切る)。もう印がある名前、空の値、継続の指示が題として見えている後継には付けない。
34
+ 3. 付けられなくても、引き継ぎは止めない。名前は表示だけに使う。理由は worker のログに残す。
35
+ 4. 途中で止まった引き継ぎ(`failed`・`unknown`)の旧タスクには付けない。作業はまだ旧タスクにある。
36
+ 5. 印の付いた名前から後継の名前を作る時は、印を外す(やり直しで、印の付いた旧タスクの題を読む事がある)。
37
+ 6. 印を付けない版(0.16.9 まで)が引き継いだ分は、`throughline auto-handoff mark-sources` で付け直す。
38
+ `continued` の引き継ぎの旧タスクを古い順に見て、生きている後継がある物だけに付ける。後継が消されている旧タスクは、
39
+ 今の続きなので付けない。何度流しても同じ結果になる。
40
+ 7. 引き継ぎ済みの旧タスクへ入力した時の扱い(止めて後継を開く。ADR 0039)は変えない。
41
+
42
+ ## Consequences
43
+
44
+ - 同じ作業の系列で、印の無いタスクは1本になる。それが今の続き。
45
+ - 利用者が付けた題を含めて、旧タスクの名前が変わる。元の名前は印の後ろに残る。
46
+ - 後継を消して旧タスクで作業を続ける時は、旧タスクに印が残る。名前は手で直せる。
47
+ - 引き継ぎ1回につき、app-server への接続が1回増える(`thread/read` と `thread/name/set`)。後継が作業を始めた後なので、
48
+ 引き継ぎの時間は変わらない。
49
+ - Claude Code の後継も同じ名前の付け方(ADR 0037)だが、旧い会話の名前を外から変える公式の入口を確かめていない。
50
+ この ADR は Codex だけを扱う。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.16.8",
3
+ "version": "0.16.10",
4
4
  "type": "module",
5
5
  "description": "Persistent memory hooks for Claude Code, Codex, Grok, and Cursor (/clear-safe context compression)",
6
6
  "keywords": [
@@ -8,6 +8,8 @@
8
8
  export const AUTO_HANDOFF_TITLE_MARK = '自動引き継ぎ';
9
9
  const SEPARATOR = '|';
10
10
  const SUFFIX = `(${AUTO_HANDOFF_TITLE_MARK})`;
11
+ // 引き継ぎが済んだ旧タスクの名前の頭に付ける印。一覧は名前の後ろを切るので、頭に置く。
12
+ export const AUTO_HANDOFF_DONE_PREFIX = '【引き継ぎ済み】';
11
13
  // 一覧の幅で末尾の印が切れにくい長さに、概要を抑える。
12
14
  const SUMMARY_MAX_CHARS = 40;
13
15
  // 継続の指示(Claude・Codex とも、この語と引き継ぎ ID で始まる)。名前の付いていない後継は、これが題として見える。
@@ -35,7 +37,7 @@ function clip(text, maxChars) {
35
37
  */
36
38
  export function autoHandoffSummaryOf(title) {
37
39
  if (typeof title !== 'string') return null;
38
- const firstLine = title.split(/\r?\n/).map(oneLine).find(Boolean) ?? '';
40
+ const firstLine = withoutDonePrefix(title.split(/\r?\n/).map(oneLine).find(Boolean) ?? '');
39
41
  if (!firstLine || CONTINUATION_INPUT_PATTERN.test(firstLine)) return null;
40
42
  if (firstLine.endsWith(SUFFIX)) {
41
43
  const body = firstLine.slice(0, -SUFFIX.length);
@@ -46,6 +48,26 @@ export function autoHandoffSummaryOf(title) {
46
48
  return clip(firstLine, SUMMARY_MAX_CHARS);
47
49
  }
48
50
 
51
+ function withoutDonePrefix(text) {
52
+ let rest = text;
53
+ while (rest.startsWith(AUTO_HANDOFF_DONE_PREFIX)) rest = rest.slice(AUTO_HANDOFF_DONE_PREFIX.length).trimStart();
54
+ return rest;
55
+ }
56
+
57
+ /**
58
+ * 引き継ぎが済んだ旧タスクの名前を作る。元の名前の頭に印を付けるだけで、元の名前は残す。
59
+ * 一覧で、印の無い1本が今の続きだと読めるようにする。もう印が付いている名前と、名前にできない値は null(付け直さない)。
60
+ * @param {string|null|undefined} title 旧タスクの今の名前。名前が無いタスクでは、最初の依頼の文
61
+ */
62
+ export function markHandedOffTitle(title) {
63
+ if (typeof title !== 'string') return null;
64
+ const firstLine = title.split(/\r?\n/).map(oneLine).find(Boolean) ?? '';
65
+ if (!firstLine || firstLine.startsWith(AUTO_HANDOFF_DONE_PREFIX)) return null;
66
+ // 継続の指示が題として見えている後継(名前を付けない版が作った物)は、指示の文を名前にしない。
67
+ if (CONTINUATION_INPUT_PATTERN.test(firstLine)) return null;
68
+ return AUTO_HANDOFF_DONE_PREFIX + clip(firstLine, SUMMARY_MAX_CHARS + 20);
69
+ }
70
+
49
71
  /**
50
72
  * 後継の名前を作る。
51
73
  * @param {{projectPath: string, titles?: Array<string|null|undefined>}} input titles は概要の候補(先頭から使えるものを採る)
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { autoHandoffProjectName, autoHandoffSummaryOf, composeAutoHandoffTitle } from './auto-handoff-title.mjs';
3
+ import { autoHandoffProjectName, autoHandoffSummaryOf, composeAutoHandoffTitle, markHandedOffTitle } from './auto-handoff-title.mjs';
4
4
 
5
5
  test('名前は「project 名|作業の概要(自動引き継ぎ)」の形にする', () => {
6
6
  assert.equal(composeAutoHandoffTitle({ projectPath: '/Users/kite/Developer/BellTeam', titles: ['ASCからの連絡を確認'] }),
@@ -48,3 +48,21 @@ test('概要は最初の行を1行にして、長さを抑える', () => {
48
48
  assert.equal(Array.from(autoHandoffSummaryOf(`${'😀'.repeat(50)}`)).length, 40, '絵文字を途中で割らない');
49
49
  assert.equal(autoHandoffSummaryOf(undefined), null);
50
50
  });
51
+
52
+ test('引き継ぎ済みの印は名前の頭に付け、元の名前を残す。印を重ねない', () => {
53
+ assert.equal(markHandedOffTitle('kitepon.devのメールアドレス作成方法'), '【引き継ぎ済み】kitepon.devのメールアドレス作成方法');
54
+ assert.equal(markHandedOffTitle('BellTeam|ベルチーム(自動引き継ぎ)'), '【引き継ぎ済み】BellTeam|ベルチーム(自動引き継ぎ)');
55
+ assert.equal(markHandedOffTitle('【引き継ぎ済み】ベルチーム'), null, 'もう印がある名前は付け直さない');
56
+ assert.equal(markHandedOffTitle('1行目\n2行目'), '【引き継ぎ済み】1行目', '名前の無いタスクの依頼文は1行目だけ使う');
57
+ assert.equal(Array.from(markHandedOffTitle('あ'.repeat(200))).length, '【引き継ぎ済み】'.length + 60, '長い依頼文は切る');
58
+ for (const value of [null, undefined, '', ' ', 'Throughline自動継続 39cdb98e-9e68-4dff-8a53-38d1fd861a8b']) {
59
+ assert.equal(markHandedOffTitle(value), null);
60
+ }
61
+ });
62
+
63
+ test('印の付いた旧タスクの名前から概要を取る時は、印を外す', () => {
64
+ assert.equal(autoHandoffSummaryOf('【引き継ぎ済み】BellTeam|ベルチーム(自動引き継ぎ)'), 'ベルチーム');
65
+ assert.equal(autoHandoffSummaryOf('【引き継ぎ済み】ベルチーム'), 'ベルチーム');
66
+ assert.equal(composeAutoHandoffTitle({ projectPath: '/work/BellTeam', titles: ['【引き継ぎ済み】BellTeam|ベルチーム(自動引き継ぎ)'] }),
67
+ 'BellTeam|ベルチーム(自動引き継ぎ)');
68
+ });
@@ -13,7 +13,7 @@
13
13
  * 外から後継へ文を送らない。権限のバイパス中の会話は、外から届いた文を
14
14
  * 利用者の承認まで止めるため(0.15.3)。
15
15
  * 5. 後継の Stop : Claude Desktop から始まった会話の後継は、そのターンの作業を終えた時に
16
- * Desktop へ移して開く(`claude stop` → `claude --desktop --resume`)。
16
+ * Desktop へ移して開く(`claude stop` → `claude --desktop --resume`。macOS と Windows)。
17
17
  * Claude Code は、裏で動いている会話を Desktop へ移さない。
18
18
  *
19
19
  * 旧い会話は止めるだけで、空にしない。手動の /compact と subagent の中の圧縮は対象にしない。
@@ -210,10 +210,11 @@ function readLatestEntrypoint(transcriptPath) {
210
210
  /**
211
211
  * この会話の後継を、Claude Desktop で開くか。
212
212
  * Desktop の画面から始まった会話と、その後継がさらに引き継ぐ時だけ開く。端末から始めた会話の後継は
213
- * `claude agents` の一覧に出るので、Desktop へは移さない。開く命令を通せるのは macOS だけ。
213
+ * `claude agents` の一覧に出るので、Desktop へは移さない。Claude Code の `--desktop` があるのは macOS と Windows だけ
214
+ * (Linux は `--desktop isn't available on this platform` で断る)。
214
215
  */
215
216
  function openInDesktopWanted({ sessionId, transcriptPath, env, dir, platform }) {
216
- if (platform !== 'darwin' || env[OPEN_OVERRIDE_ENV] === 'off') return false;
217
+ if (!['darwin', 'win32'].includes(platform) || env[OPEN_OVERRIDE_ENV] === 'off') return false;
217
218
  if (env[OPEN_OVERRIDE_ENV] === 'desktop') return true;
218
219
  if (env.CLAUDE_CODE_ENTRYPOINT === DESKTOP_ENTRYPOINT || readLatestEntrypoint(transcriptPath) === DESKTOP_ENTRYPOINT) return true;
219
220
  return listClaudeAutoHandoffs({ dir }).some(record => record.successor?.session_id === sessionId && record.desktop?.wanted);
@@ -732,7 +733,7 @@ export async function requestClaudeDesktopOpen({ sessionId, dir = claudeAutoHand
732
733
  *
733
734
  * Claude Code は、裏で動いている会話(作業中も、手すきも)を Desktop へ移さない。手すきになるのを待って
734
735
  * `claude stop` で止め、`claude --desktop --resume <session id>` で開く。この命令は出力が端末でないと動かないので、
735
- * 擬似端末の中で呼ぶ。後継がまた動き出した時は止めずに戻り、次の Stop でやり直す。
736
+ * 擬似端末(macOS)か新しい console(Windows)の中で呼ぶ。後継がまた動き出した時は止めずに戻り、次の Stop でやり直す。
736
737
  */
737
738
  export async function runClaudeDesktopOpen(sourceSessionId, {
738
739
  dir = claudeAutoHandoffDir(),
@@ -765,7 +766,9 @@ export async function runClaudeDesktopOpen(sourceSessionId, {
765
766
  if (stopped.error || stopped.status !== 0) return set({ state: 'failed', error_code: 'desktop_open_stop_failed' });
766
767
  const opened = pty('claude', ['--desktop', '--resume', successorId], { cwd: record.project_path, env: childEnv, timeout: 60_000 });
767
768
  const output = normalizeTerminalText(`${opened?.stdout ?? ''}${opened?.stderr ?? ''}`);
768
- if (!opened || opened.error || opened.status !== 0 || !output.includes(`Opening session ${successorId}`)) {
769
+ // Windows は新しい console の中で動かすので、出た文を読めない。終了 code で成否を見る。
770
+ const announced = opened?.outputUnavailable || output.includes(`Opening session ${successorId}`);
771
+ if (!opened || opened.error || opened.status !== 0 || !announced) {
769
772
  // 理由を端末内のログに残す(命令の出力だけ。会話の中身は含まれない)。
770
773
  process.stderr.write(`[auto-handoff] claude --desktop --resume exit=${opened?.status ?? 'unsupported'} output=${JSON.stringify(output.slice(-400))}\n`);
771
774
  return set({ state: 'failed', error_code: opened ? 'desktop_open_failed' : 'desktop_open_platform_unsupported' });
@@ -620,8 +620,10 @@ test('PreCompact: Claude Desktop から始まった会話と、その後継の
620
620
  assert.equal(wanted({ platform: 'darwin', env: { CLAUDE_CODE_ENTRYPOINT: 'claude-desktop' } }).wanted, true);
621
621
  assert.equal(wanted({ platform: 'darwin', transcriptPath: cliTranscript }).wanted, false, '端末から始めた会話の後継は、claude agents の一覧に出る');
622
622
  assert.equal(wanted({ platform: 'darwin' }).wanted, false);
623
- assert.equal(wanted({ platform: 'linux', transcriptPath: desktopTranscript }).wanted, false, '開く命令を通せるのは macOS だけ');
624
- assert.equal(wanted({ platform: 'win32', payload: { cwd: 'C:\\work\\app' }, env: { CLAUDE_CODE_ENTRYPOINT: 'claude-desktop' } }).wanted, false);
623
+ assert.equal(wanted({ platform: 'linux', transcriptPath: desktopTranscript }).wanted, false, 'Linux の Claude Code に --desktop は無い');
624
+ assert.equal(wanted({ platform: 'linux', transcriptPath: desktopTranscript, env: { THROUGHLINE_AUTO_HANDOFF_OPEN: 'desktop' } }).wanted, false);
625
+ assert.equal(wanted({ platform: 'win32', payload: { cwd: 'C:\\work\\app' }, env: { CLAUDE_CODE_ENTRYPOINT: 'claude-desktop' } }).wanted, true);
626
+ assert.equal(wanted({ platform: 'win32', payload: { cwd: 'C:\\work\\app' } }).wanted, false);
625
627
  assert.equal(wanted({ platform: 'darwin', transcriptPath: cliTranscript, env: { THROUGHLINE_AUTO_HANDOFF_OPEN: 'desktop' } }).wanted, true);
626
628
  assert.equal(wanted({ platform: 'darwin', transcriptPath: desktopTranscript, env: { THROUGHLINE_AUTO_HANDOFF_OPEN: 'off' } }).wanted, false);
627
629
 
@@ -721,6 +723,12 @@ test('Desktop で開く: 後継が手すきになるのを待って止め、擬
721
723
  const refused = await run({ statuses: ['idle'], opened: { status: 1, stdout: `Session ${SUCCESSOR} is open in another terminal. Run /desktop there instead.\r\n`, stderr: '' } });
722
724
  assert.deepEqual([refused.record.desktop.state, refused.record.desktop.error_code], ['failed', 'desktop_open_failed']);
723
725
 
726
+ // Windows は新しい console の中で動かすので、出た文を読めない。終了 code だけで成否を決める
727
+ const windowsOk = await run({ statuses: ['idle'], opened: { status: 0, stdout: '', stderr: '', outputUnavailable: true } });
728
+ assert.equal(windowsOk.record.desktop.state, 'opened');
729
+ const windowsRefused = await run({ statuses: ['idle'], opened: { status: 1, stdout: '', stderr: '', outputUnavailable: true } });
730
+ assert.deepEqual([windowsRefused.record.desktop.state, windowsRefused.record.desktop.error_code], ['failed', 'desktop_open_failed']);
731
+
724
732
  // 対象でない記録には何もしない
725
733
  writeRecord(dir, SESSION, {});
726
734
  const untouched = await runClaudeDesktopOpen(SESSION, { dir, spawn: () => { throw new Error('must not run'); }, pty: () => { throw new Error('must not run'); } });
@@ -5,7 +5,7 @@ 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';
8
- import { autoHandoffDeliveryProfile, runAutoHandoffWorker } from '../codex-auto-handoff.mjs';
8
+ import { autoHandoffDeliveryProfile, runAutoHandoffWorker, markAutoHandoffSources } from '../codex-auto-handoff.mjs';
9
9
  import { installCodexAutoHandoffHook, installClaudeAutoHandoffHooks, removeClaudeAutoHandoffHooks,
10
10
  claudeAutoHandoffHooksRegistered } from './install.mjs';
11
11
  import { sameProjectPath } from '../project-path.mjs';
@@ -18,9 +18,9 @@ const CLAUDE_ACTIONS = ['enable', 'disable', 'status', 'worker', 'desktop-open']
18
18
 
19
19
  export function parseAutoHandoffArgs(args) {
20
20
  const out = { action: args[0] ?? 'status', host: 'codex', operation: null, project: null, origin: null, turn: null, database: null, json: false };
21
- if (!['enable', 'disable', 'status', 'resume', 'detail', 'worker', 'desktop-open'].includes(out.action)) throw Error('auto_handoff_action_invalid');
21
+ if (!['enable', 'disable', 'status', 'resume', 'detail', 'worker', 'desktop-open', 'mark-sources'].includes(out.action)) throw Error('auto_handoff_action_invalid');
22
22
  const allowed = ['host', ...{ enable: ['project'], disable: [], status: ['project', 'operation'],
23
- resume: ['operation'], worker: ['operation'], 'desktop-open': ['operation'], detail: ['operation', 'origin', 'turn', 'database'] }[out.action]];
23
+ resume: ['operation'], worker: ['operation'], 'desktop-open': ['operation'], 'mark-sources': [], detail: ['operation', 'origin', 'turn', 'database'] }[out.action]];
24
24
  const seen = new Set();
25
25
  for (let i = 1; i < args.length; i++) {
26
26
  if (seen.has(args[i])) throw Error('auto_handoff_argument_invalid');
@@ -127,6 +127,10 @@ export async function run(args = []) {
127
127
  finally { db.close(); }
128
128
  process.stdout.write(parsed.json ? JSON.stringify({ status: 'ok', text }) + '\n' : text + '\n');
129
129
  return 0;
130
+ } else if (parsed.action === 'mark-sources') {
131
+ // 印を付けない版(0.16.9 まで)が引き継いだ旧タスクへ、引き継ぎ済みの印を付け直す。何度流しても同じ結果になる。
132
+ const db = getDb();
133
+ result = { status: 'ok', ...await markAutoHandoffSources(db) };
130
134
  } else if (parsed.action === 'worker') {
131
135
  result = publicOperation(await runAutoHandoffWorker(parsed.operation));
132
136
  } else if (parsed.action === 'resume') {
@@ -47,6 +47,12 @@ export function listUndeliveredAutoHandoffsForSource(db, threadId) {
47
47
  ORDER BY created_at DESC, rowid DESC`).all(threadId).map(decode);
48
48
  }
49
49
 
50
+ /** 後継が作業を続けた引き継ぎの全部(古い順)。旧タスクへ引き継ぎ済みの印を付け直す時に使う。 */
51
+ export function listContinuedAutoHandoffs(db) {
52
+ return db.prepare(`SELECT * FROM codex_handoffs WHERE state = 'continued' AND target_thread_id IS NOT NULL
53
+ ORDER BY created_at ASC, rowid ASC`).all().map(decode);
54
+ }
55
+
50
56
  export function listAutoHandoffs(db, { projectPath = null, limit = 20 } = {}) {
51
57
  const rows = projectPath == null
52
58
  ? db.prepare('SELECT * FROM codex_handoffs ORDER BY created_at DESC LIMIT ?').all(limit)
@@ -12,7 +12,7 @@ import { getDb, DB_PATH } from './db.mjs';
12
12
  import { findCodexThreadCandidate } from './codex-thread-index.mjs';
13
13
  import { captureCodexRolloutToDb } from './codex-capture.mjs';
14
14
  import { readAutoHandoffConfig, autoHandoffEnabledFor } from './codex-auto-handoff-config.mjs';
15
- import { getAutoHandoff, requestAutoHandoff, updateAutoHandoff, failAutoHandoff,
15
+ import { getAutoHandoff, requestAutoHandoff, updateAutoHandoff, failAutoHandoff, listContinuedAutoHandoffs,
16
16
  claimAutoHandoff, releaseAutoHandoff, freezeCodexMemory, continuationInput,
17
17
  findAutoHandoffForTurn, listDeliveredAutoHandoffsForSource, listUndeliveredAutoHandoffsForSource,
18
18
  HANDOFF_TERMINAL_STATES } from './codex-auto-handoff-store.mjs';
@@ -20,7 +20,7 @@ import { ensureAutoHandoffSummaries, renderAutoHandoffMemory } from './codex-aut
20
20
  import { CODEX_NATIVE_ID_PATTERN, CodexHandoffError, readCodexHandoffState,
21
21
  settingsMatch, threadStartSettings } from './hosts/codex-handoff-state.mjs';
22
22
  import { resolvePreparedSettings } from './hosts/codex-handoff-state.mjs';
23
- import { composeAutoHandoffTitle } from './auto-handoff-title.mjs';
23
+ import { composeAutoHandoffTitle, markHandedOffTitle } from './auto-handoff-title.mjs';
24
24
 
25
25
  const CLI_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '../bin/throughline.mjs');
26
26
  const stateRoot = () => join(homedir(), '.throughline', 'codex-auto-handoff');
@@ -283,6 +283,46 @@ export async function nameAutoHandoffTarget(request, targetId, name) {
283
283
  }
284
284
  }
285
285
 
286
+ /**
287
+ * 引き継ぎが済んだ旧タスクの名前の頭へ、引き継ぎ済みの印を付ける。同じ名前の後継が一覧に並んでも、
288
+ * 印の無い1本が今の続きだと分かるようにする。今の名前を読んでから付けるので、利用者が付け直した名前も残る。
289
+ * 名前は表示だけに使うので、付けられなくても引き継ぎは止めない(理由は worker のログに残す)。
290
+ * @returns {Promise<'marked'|'kept'|'failed'>} kept はもう印がある・名前にできる値が無い
291
+ */
292
+ export async function markAutoHandoffSource(operation, runtime, { connect = withCodexReceiver } = {}) {
293
+ try {
294
+ return await connect(autoHandoffDeliveryProfile, { thread_id: operation.source_thread_id, codex_home: operation.codex_home },
295
+ async request => {
296
+ const read = await request('thread/read', { threadId: operation.source_thread_id, includeTurns: false });
297
+ const name = markHandedOffTitle(read.thread?.name ?? read.thread?.preview ?? null);
298
+ if (!name) return 'kept';
299
+ await request('thread/name/set', { threadId: operation.source_thread_id, name });
300
+ return 'marked';
301
+ }, runtime);
302
+ } catch (cause) {
303
+ process.stderr.write(`[auto-handoff] source mark failed: ${cause?.delivery_code ?? cause?.code ?? 'unknown'}\n`);
304
+ return 'failed';
305
+ }
306
+ }
307
+
308
+ /**
309
+ * 今までに済んだ引き継ぎの旧タスクへ、まとめて印を付ける(印を付けない版が作った分の付け直し)。
310
+ * 後継が消されている引き継ぎは、旧タスクが今の続きなので付けない。
311
+ */
312
+ export async function markAutoHandoffSources(db, { executable = platformDesktopFinder()(), mark = markAutoHandoffSource,
313
+ findThread = findCodexThreadCandidate } = {}) {
314
+ const runtime = { executable, timeout_ms: 30_000 };
315
+ const result = { marked: 0, kept: 0, failed: 0, skipped: 0 };
316
+ const seen = new Set();
317
+ for (const operation of listContinuedAutoHandoffs(db)) {
318
+ if (seen.has(operation.source_thread_id)) continue;
319
+ seen.add(operation.source_thread_id);
320
+ if (!findLiveAutoHandoffSuccessor(db, operation.source_thread_id, { findThread })) { result.skipped++; continue; }
321
+ result[await mark(operation, runtime)]++;
322
+ }
323
+ return result;
324
+ }
325
+
286
326
  async function createTarget(db, operation, runtime) {
287
327
  const targetId = await withCodexReceiver(autoHandoffDeliveryProfile, { thread_id: operation.source_thread_id, codex_home: operation.codex_home },
288
328
  async request => {
@@ -355,7 +395,7 @@ ${operation.error_code === 'handoff_summarizer_backend_failed' ? '<p>記憶の
355
395
  export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, dependencies = {} } = {}) {
356
396
  const deps = { processes: readRuntimeProcesses, readSource: sourceState,
357
397
  nativeState: sourceNativeState, capture: captureCodexRolloutToDb, summaries: ensureAutoHandoffSummaries,
358
- createTarget, readTarget: targetState, openTarget, verify: verifyCodexParent,
398
+ createTarget, readTarget: targetState, openTarget, verify: verifyCodexParent, markSource: markAutoHandoffSource,
359
399
  submit: submitCodexParentAnswer, notify: showAutoHandoffFailure, wait: waitForHandoff,
360
400
  executable: () => platformDesktopFinder()(), liveSuccessor: findLiveAutoHandoffSuccessor, ...dependencies };
361
401
  let operation = getAutoHandoff(db, id);
@@ -470,6 +510,8 @@ export async function runAutoHandoffWorker(id, { db = getDb(), resume = false, d
470
510
  return target.correlatedTurnId && target.progress ? target : null;
471
511
  }, { timeoutMs: 180_000, timeoutCode: 'handoff_target_progress_unconfirmed' });
472
512
  update({ state: 'continued', started_turn_id: target.correlatedTurnId, resume_state: null, error_code: null });
513
+ // 後継が作業を続けたのを確かめてから付ける。途中で止まった引き継ぎの旧タスクには付けない。
514
+ await deps.markSource(operation, runtime);
473
515
  }
474
516
  return operation;
475
517
  } catch (cause) {
@@ -13,7 +13,8 @@ import { collectAutoHandoffMemory, ensureAutoHandoffSummaries, renderAutoHandoff
13
13
  renderFrozenDetail } from './codex-auto-handoff-memory.mjs';
14
14
  import { readCodexHandoffState, settingsMatch, threadStartSettings, resolvePreparedSettings } from './hosts/codex-handoff-state.mjs';
15
15
  import { runAutoHandoffWorker, requestCodexAutoHandoff, sourceBoundary,
16
- autoHandoffTargetName, nameAutoHandoffTarget, findLiveAutoHandoffSuccessor } from './codex-auto-handoff.mjs';
16
+ autoHandoffTargetName, nameAutoHandoffTarget, findLiveAutoHandoffSuccessor,
17
+ markAutoHandoffSource, markAutoHandoffSources } from './codex-auto-handoff.mjs';
17
18
  import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
18
19
 
19
20
  async function withDb(fn) {
@@ -115,6 +116,7 @@ function workerDependencies(db, settings, submit, counters) {
115
116
  readTarget: operation => ({ settings, latestTurnId: ['submitted', 'unknown'].includes(operation.state) ? 'new-turn' : null,
116
117
  correlatedTurnId: ['submitted', 'unknown'].includes(operation.state) ? 'new-turn' : null, progress: true }),
117
118
  openTarget: () => {}, verify: async () => {}, submit,
119
+ markSource: async operation => { (counters.marked ??= []).push(`${operation.source_thread_id}:${operation.state}`); return 'marked'; },
118
120
  notify: () => { counters.notified++; },
119
121
  wait: async check => { const result = await check(); if (!result) throw Error('観測できません'); return result; },
120
122
  };
@@ -128,6 +130,7 @@ test('停止→記憶→作成→配送→進捗を確認し、重複workerは
128
130
  assert.equal(result.state, 'continued'); assert.equal(result.started_turn_id, 'new-turn');
129
131
  await runAutoHandoffWorker(operation.handoff_id, { db, dependencies: deps });
130
132
  assert.equal(counters.created, 1); assert.equal(counters.submitted, 1); assert.equal(counters.notified, 0);
133
+ assert.deepEqual(counters.marked, ['worker-source:continued'], '後継が作業を続けたのを確かめた後に、旧タスクへ印を1回だけ付ける');
131
134
  }));
132
135
 
133
136
  test('配送結果不明は再送せず、実際の入力・開始を観測して同じ後継から回復する', async () => withDb(async db => {
@@ -470,6 +473,61 @@ test('後継タスクの名前は project 名・前任の題・自動引き継
470
473
  assert.equal(await nameAutoHandoffTarget(rejected, 'T', 'x'), false, '名前を付けられなくても、引き継ぎは止めない');
471
474
  }));
472
475
 
476
+ test('引き継ぎが済んだ旧タスクの名前の頭へ印を付け、付けられなくても引き継ぎは止めない', async () => {
477
+ const operation = { source_thread_id: 'S', codex_home: '/home' };
478
+ const receiver = (thread, sent, fail = null) => async (profile, parent, action) => action(async (method, params) => {
479
+ sent.push([method, params]);
480
+ if (method === fail) throw Object.assign(new Error('rejected'), { delivery_code: 'CODEX_RECEIVER_REJECTED' });
481
+ return method === 'thread/read' ? { thread } : {};
482
+ });
483
+ let sent = [];
484
+ assert.equal(await markAutoHandoffSource(operation, {}, { connect: receiver({ name: 'ベルチーム' }, sent) }), 'marked');
485
+ assert.deepEqual(sent, [['thread/read', { threadId: 'S', includeTurns: false }],
486
+ ['thread/name/set', { threadId: 'S', name: '【引き継ぎ済み】ベルチーム' }]]);
487
+
488
+ // 後継がさらに引き継いだ時は、後継の名前をそのまま残して印だけ足す
489
+ sent = [];
490
+ await markAutoHandoffSource(operation, {}, { connect: receiver({ name: 'BellTeam|ベルチーム(自動引き継ぎ)' }, sent) });
491
+ assert.equal(sent[1][1].name, '【引き継ぎ済み】BellTeam|ベルチーム(自動引き継ぎ)');
492
+
493
+ // 名前の無いタスクは、最初の依頼の1行目を使う
494
+ sent = [];
495
+ await markAutoHandoffSource(operation, {}, { connect: receiver({ name: null, preview: 'ASCの状況を確認して\n2行目' }, sent) });
496
+ assert.equal(sent[1][1].name, '【引き継ぎ済み】ASCの状況を確認して');
497
+
498
+ // もう印がある・名前にできる値が無い時は、付け直さない
499
+ for (const thread of [{ name: '【引き継ぎ済み】ベルチーム' }, { name: null, preview: '' },
500
+ { name: null, preview: 'Throughline自動継続 39cdb98e-9e68-4dff-8a53-38d1fd861a8b' }]) {
501
+ sent = [];
502
+ assert.equal(await markAutoHandoffSource(operation, {}, { connect: receiver(thread, sent) }), 'kept');
503
+ assert.equal(sent.length, 1, '名前は書かない');
504
+ }
505
+
506
+ assert.equal(await markAutoHandoffSource(operation, {}, { connect: receiver({ name: 'x' }, [], 'thread/name/set') }), 'failed');
507
+ assert.equal(await markAutoHandoffSource(operation, {}, { connect: receiver({ name: 'x' }, [], 'thread/read') }), 'failed');
508
+ });
509
+
510
+ test('印を付けた旧タスクから作る後継の名前には、印を持ち込まない', async () => withDb(async db => {
511
+ const operation = updateAutoHandoff(db, request(db, 'A', { projectPath: '/Users/kite/Developer/BellTeam' }).operation.handoff_id,
512
+ { runtime_json: { projectId: null, title: '【引き継ぎ済み】BellTeam|ベルチーム(自動引き継ぎ)' } }).operation;
513
+ assert.equal(autoHandoffTargetName(db, operation), 'BellTeam|ベルチーム(自動引き継ぎ)');
514
+ }));
515
+
516
+ test('今までの引き継ぎの旧タスクへまとめて印を付け、後継が消えた引き継ぎと途中で止まった引き継ぎには付けない', async () => withDb(async db => {
517
+ const done = (source, target, fields = {}) => updateAutoHandoff(db, request(db, source).operation.handoff_id,
518
+ { state: 'continued', target_thread_id: target, queued_submission_id: `receipt-${target}`, ...fields }).operation;
519
+ done('A', 'B');
520
+ done('B', 'C');
521
+ done('X', 'gone');
522
+ updateAutoHandoff(db, request(db, 'F').operation.handoff_id, { state: 'failed', target_thread_id: 'F2', error_code: 'handoff_target_not_loaded' });
523
+ const marked = [];
524
+ const result = await markAutoHandoffSources(db, { executable: 'fixture',
525
+ findThread: ({ threadId }) => threadId === 'gone' ? null : { rolloutPath: `/r/${threadId}`, mtimeMs: 1 },
526
+ mark: async operation => { marked.push(operation.source_thread_id); return operation.source_thread_id === 'B' ? 'kept' : 'marked'; } });
527
+ assert.deepEqual(marked, ['A', 'B']);
528
+ assert.deepEqual(result, { marked: 1, kept: 1, failed: 0, skipped: 1 });
529
+ }));
530
+
473
531
  // 元turn(止めた)の後に、旧タスクへ来た入力のturnが続くrollout。laterは後続turnの行。
474
532
  function sourceRollout(home, thread, turn, later = [], stoppedAt = '2026-10-07T00:49:01.000Z') {
475
533
  const dir = join(home, 'codex', 'sessions'); mkdirSync(dir, { recursive: true });
@@ -669,5 +727,6 @@ test('要約のbackendが使えない時、workerは固定の理由で止まり
669
727
  const result = await runAutoHandoffWorker(operation.handoff_id, { db, dependencies: deps });
670
728
  assert.equal(result.state, 'failed'); assert.equal(result.error_code, expected); assert.equal(result.resume_state, 'source_stopped');
671
729
  assert.equal(counters.created, 0); assert.equal(counters.submitted, 0); assert.equal(counters.notified, 1);
730
+ assert.equal(counters.marked, undefined, '途中で止まった引き継ぎの旧タスクには印を付けない');
672
731
  }
673
732
  }));
@@ -1,13 +1,29 @@
1
1
  /**
2
2
  * os/pty-run.mjs — 端末を要求するコマンドを、端末の無い process から呼ぶ
3
3
  *
4
- * darwin: `script -q /dev/null <command> <args…>` が擬似端末を作る。
4
+ * darwin: `script -q /dev/null <command> <args…>` が擬似端末を作る。出力を読める。
5
+ * win32 : PowerShell の `Start-Process -Wait -PassThru` で、新しい console(最小化)の中の pwsh にコマンドを実行させ、
6
+ * その終了 code を受け取る。出力はその console に出るので読めない(`outputUnavailable: true`)。
7
+ * `cmd /c start /wait` は、中のコマンドが失敗しても 0 を返した(Windows で実測)ので使わない。
5
8
  * 他の OS は未対応で、null を返す(呼び出し元が見送る)。
6
9
  */
7
10
  import { spawnSync } from 'node:child_process';
8
11
 
12
+ const psQuote = value => `'${String(value).replaceAll("'", "''")}'`;
13
+ const psEncoded = script => Buffer.from(script, 'utf16le').toString('base64');
14
+
9
15
  export function runWithPty(command, args, { platform = process.platform, spawnImpl = spawnSync, ...options } = {}) {
10
- if (platform !== 'darwin') return null;
11
- return spawnImpl('/usr/bin/script', ['-q', '/dev/null', command, ...args],
12
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...options });
16
+ if (platform === 'darwin') {
17
+ return spawnImpl('/usr/bin/script', ['-q', '/dev/null', command, ...args],
18
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...options });
19
+ }
20
+ if (platform === 'win32') {
21
+ const inner = `& ${[command, ...args].map(psQuote).join(' ')}; exit $LASTEXITCODE`;
22
+ const outer = `$p = Start-Process -FilePath pwsh.exe -ArgumentList @('-NoProfile','-EncodedCommand',${psQuote(psEncoded(inner))}) ` +
23
+ '-WindowStyle Minimized -Wait -PassThru; exit $p.ExitCode';
24
+ const result = spawnImpl('pwsh.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', psEncoded(outer)],
25
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, ...options });
26
+ return { ...result, outputUnavailable: true };
27
+ }
28
+ return null;
13
29
  }
@@ -0,0 +1,27 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { runWithPty } from './pty-run.mjs';
4
+
5
+ const decode = encoded => Buffer.from(encoded, 'base64').toString('utf16le');
6
+
7
+ test('runWithPty: macOS は script の擬似端末、Windows は新しい console の pwsh、ほかは未対応', () => {
8
+ const calls = [];
9
+ const spawnImpl = (command, args, options) => { calls.push({ command, args, options }); return { status: 0, stdout: 'ok', stderr: '' }; };
10
+ const mac = runWithPty('claude', ['--desktop', '--resume', 'id'], { platform: 'darwin', spawnImpl, cwd: '/p' });
11
+ assert.deepEqual([calls[0].command, calls[0].args], ['/usr/bin/script', ['-q', '/dev/null', 'claude', '--desktop', '--resume', 'id']]);
12
+ assert.deepEqual([calls[0].options.cwd, calls[0].options.stdio], ['/p', ['ignore', 'pipe', 'pipe']]);
13
+ assert.equal(mac.outputUnavailable, undefined);
14
+
15
+ const win = runWithPty('claude', ['--desktop', '--resume', "it's"], { platform: 'win32', spawnImpl, cwd: 'C:\\p' });
16
+ assert.equal(calls[1].command, 'pwsh.exe');
17
+ assert.deepEqual(calls[1].args.slice(0, 4), ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand']);
18
+ const outer = decode(calls[1].args[4]);
19
+ assert.match(outer, /^\$p = Start-Process -FilePath pwsh\.exe -ArgumentList @\('-NoProfile','-EncodedCommand','[A-Za-z0-9+/=]+'\) -WindowStyle Minimized -Wait -PassThru; exit \$p\.ExitCode$/);
20
+ // fox の実測(2026-10-08): 新しい console の中の終了 code は、断られた時 1、開けた時 0。
21
+ assert.equal(decode(/'-EncodedCommand','([A-Za-z0-9+/=]+)'/.exec(outer)[1]), "& 'claude' '--desktop' '--resume' 'it''s'; exit $LASTEXITCODE");
22
+ assert.deepEqual([calls[1].options.cwd, calls[1].options.windowsHide], ['C:\\p', true]);
23
+ assert.deepEqual([win.status, win.outputUnavailable], [0, true], 'console へ出た文は読めないので、終了 code で成否を見る');
24
+
25
+ assert.equal(runWithPty('claude', ['--desktop'], { platform: 'linux', spawnImpl }), null);
26
+ assert.equal(calls.length, 2);
27
+ });