throughline 0.16.7 → 0.16.8
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 +30 -1
- package/README.ja.md +1 -1
- package/README.md +1 -1
- package/docs/adr/0043-claude-successor-opens-in-desktop.md +68 -0
- package/package.json +1 -1
- package/src/claude-auto-handoff.mjs +128 -1
- package/src/claude-auto-handoff.test.mjs +138 -2
- package/src/cli/auto-handoff.mjs +7 -6
- package/src/os/pty-run.mjs +13 -0
- package/src/turn-processor.mjs +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,34 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.16.8] — 2026-10-08
|
|
14
|
+
|
|
15
|
+
### 追加
|
|
16
|
+
|
|
17
|
+
- Claude Codeの自動継続で、Claude Desktopの画面から始まった会話の後継を、そのターンの作業を終えた時にClaude Desktopへ移して開く(macOS。[ADR 0043](docs/adr/0043-claude-successor-opens-in-desktop.md))。今までは、後継は裏の会話(`claude agents`の一覧)にだけ出て、Desktopの一覧には出なかった。Desktopで作業している時、旧い会話が止まり、続きが見えないまま裏で進んでいた。
|
|
18
|
+
- 後継がターンを終えると、手すきになるのを待って`claude stop`で止め、`claude --desktop --resume <session id>`で開く。Claude Codeは、裏で動いている会話(作業中も手すきも)をDesktopへ移さない。作業の間は、今までどおり裏で動く。
|
|
19
|
+
- 続けて引き継いだ時は、最後に作業を終えた後継だけを開く。
|
|
20
|
+
- 端末から始めた会話の後継は、今までどおり裏の会話のままにする。環境変数`THROUGHLINE_AUTO_HANDOFF_OPEN`で上書きできる(`desktop`は必ず開く、`off`は開かない)。
|
|
21
|
+
- `auto-handoff status --host claude --json`の各引き継ぎへ、`desktop_state`(`waiting`・`requested`・`opened`・`superseded`・`failed`。対象外は`null`)と`desktop_error_code`を足す。
|
|
22
|
+
|
|
23
|
+
### 変更
|
|
24
|
+
|
|
25
|
+
- Desktopで開く引き継ぎでは、旧い会話を止めた理由の文に、後継がターンを終えた時にClaude Desktopへ開く事を書く。
|
|
26
|
+
|
|
27
|
+
### 変わらない事
|
|
28
|
+
|
|
29
|
+
- 後継の立て方(`claude --bg`)、記憶の渡し方、モデル・推論強度・権限の引き継ぎ。Codexの自動継続。schema(v12)。
|
|
30
|
+
|
|
31
|
+
### 確認した範囲
|
|
32
|
+
|
|
33
|
+
- macOS(Claude Code 2.1.289、Claude Desktop 2.26454.2、Haiku 4.5)の試験用フォルダで、端末から始めた会話に`THROUGHLINE_AUTO_HANDOFF_OPEN=desktop`を付けて流した。1回の引き継ぎでは、後継が作業を終えた4秒後にDesktopがその会話を取り込んだ。続けて3回の引き継ぎでは、最後の後継だけが取り込まれた。
|
|
34
|
+
|
|
35
|
+
### 確認していない範囲
|
|
36
|
+
|
|
37
|
+
- Claude Desktopの画面から始めた本物の会話での通し。印の付き方は、実物の会話記録の値(`"entrypoint":"claude-desktop"`)と単体の試験で確かめた。
|
|
38
|
+
- WindowsとLinuxのClaude Desktop(この版は開かない)。
|
|
39
|
+
- 長いターンの後継と、後継が質問で止まった時の見え方。
|
|
40
|
+
|
|
13
41
|
## [0.16.7] — 2026-10-08
|
|
14
42
|
|
|
15
43
|
### 修正
|
|
@@ -2027,7 +2055,8 @@ two attempts, instrument first instead of patching again.
|
|
|
2027
2055
|
|
|
2028
2056
|
---
|
|
2029
2057
|
|
|
2030
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.
|
|
2058
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.8...HEAD
|
|
2059
|
+
[0.16.8]: https://github.com/kitepon/Throughline/compare/v0.16.7...v0.16.8
|
|
2031
2060
|
[0.16.7]: https://github.com/kitepon/Throughline/compare/v0.16.6...v0.16.7
|
|
2032
2061
|
[0.16.6]: https://github.com/kitepon/Throughline/compare/v0.16.5...v0.16.6
|
|
2033
2062
|
[0.16.5]: https://github.com/kitepon/Throughline/compare/v0.16.4...v0.16.5
|
package/README.ja.md
CHANGED
|
@@ -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>`で見られます。Desktop
|
|
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から起動した会話は対象に含めません。
|
|
65
65
|
|
|
66
66
|
```bash
|
|
67
67
|
throughline auto-handoff status --host claude --operation <handoff-id> --json
|
package/README.md
CHANGED
|
@@ -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>`で見られます。Desktop
|
|
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から起動した会話は対象に含めません。
|
|
64
64
|
|
|
65
65
|
```bash
|
|
66
66
|
throughline auto-handoff status --host claude --operation <handoff-id> --json
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# ADR 0043: Claude Desktop から始まった会話の後継を、作業を終えた時に Desktop へ開く
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-08
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
Claude Code の自動継続は、後継を `claude --bg`(裏の会話)で立てる(ADR 0033)。Claude Desktop と端末は会話の一覧が別で、
|
|
8
|
+
裏の会話は Desktop の一覧に出ない。Desktop で作業している利用者には、旧い会話が止まり、続きが見えないまま裏で進む
|
|
9
|
+
(2026-10-08、オーナーの指摘「デスクトップで新しいセッションが見えてるかは不明ということかな?」「直してくれ」)。
|
|
10
|
+
|
|
11
|
+
macOS(Claude Code 2.1.289、Claude Desktop 2.26454.2)で確かめた事:
|
|
12
|
+
|
|
13
|
+
- 0.16.7 の後継3つは、Desktop の会話の置き場(`claude-code-sessions/…/local_<id>.json`)に入らず、Desktop のログにも出なかった。
|
|
14
|
+
- 公式の入口は `claude --desktop --resume <session-id>`。公式の説明は「別の端末で開いている会話と、裏でまだ動いている会話は
|
|
15
|
+
移さない」と書いている([一次ソース抜粋](../../rag/01-hooks/raw/claude-desktop-session-open-extract.md))。
|
|
16
|
+
- 裏の会話を3つの状態で試した。作業中(`claude agents` の `status: busy`)と、ターンを終えた手すき(`status: idle`)は、
|
|
17
|
+
どちらも `That session is running in the background` で断られた。`claude stop <id>` の後は通り、Desktop のログに
|
|
18
|
+
`Resume deep link: importing CLI session …` → `Imported CLI session … as Desktop session local_…` が出た。
|
|
19
|
+
- `claude --desktop` は、出力が端末でないと動かない(`can't run non-interactively (… or redirected output)`)。
|
|
20
|
+
`script -q /dev/null claude --desktop --resume <id>` で擬似端末を挟むと、端末の無い process からも通る。
|
|
21
|
+
- Desktop へ「resume」を渡す URL は公開されていない。Desktop の公開の link は `claude://code/new`(新しい会話の下書き)だけ。
|
|
22
|
+
- Desktop の画面から始まった会話の transcript は、各行に `"entrypoint":"claude-desktop"` を持つ。端末と裏の会話は `cli`。
|
|
23
|
+
|
|
24
|
+
動いている最中の後継を Desktop に出す道は無い。出せるのは、後継を止めた後だけ。
|
|
25
|
+
|
|
26
|
+
## Decision
|
|
27
|
+
|
|
28
|
+
1. Claude Desktop から始まった会話の後継は、**そのターンの作業を終えた時に**、Desktop へ移して開く。
|
|
29
|
+
作業の間は今までどおり裏で動く(モデル・推論強度・権限は旧い会話のまま)。
|
|
30
|
+
2. Desktop から始まったかは、止める時点の transcript の最後の `entrypoint`(`claude-desktop`)と、hook の環境変数
|
|
31
|
+
`CLAUDE_CODE_ENTRYPOINT` で決め、引き継ぎの記録に `desktop.wanted` として残す。裏で作業している後継がさらに引き継ぐ時は、
|
|
32
|
+
元の会話の印を引き継ぐ。端末から始めた会話の後継は、今までどおり `claude agents` の一覧に出すだけにする。
|
|
33
|
+
3. きっかけは後継自身の Stop hook。対象の後継がターンを終えたら、切り離した process を起動する。その process は
|
|
34
|
+
`claude agents --json --all` でその後継が手すき(`status: idle`)になるのを待ち、`claude stop <短い ID>` で止め、
|
|
35
|
+
擬似端末の中で `claude --desktop --resume <session id>` を呼ぶ。出力の `Opening session <id>` と終了 code で成否を決める。
|
|
36
|
+
4. 後継が引き継ぎの途中(その後継自身の記録がある)なら移さない。作業は次の後継が続けるので、移すのは最後に作業を終えた
|
|
37
|
+
後継だけ。手すきにならないまま時間切れになった時は、止めずに戻り、次の Stop でやり直す。
|
|
38
|
+
5. 対応は macOS だけ(擬似端末に `/usr/bin/script` を使う)。ほかの OS では印を付けず、今までどおり裏の会話のままにする。
|
|
39
|
+
6. 環境変数 `THROUGHLINE_AUTO_HANDOFF_OPEN` で上書きできる。`desktop` は出どころに関係なく開き、`off` は開かない。
|
|
40
|
+
7. 旧い会話を止めた理由の文に、後継がターンを終えた時に Desktop へ開く事を書く。
|
|
41
|
+
8. 結果は記録の `desktop`(`state`: `requested`・`opened`・`superseded`・`failed`、`error_code`、`opened_at`)に残し、
|
|
42
|
+
`auto-handoff status --host claude --json` の `desktop_state`・`desktop_error_code` で読める。失敗しても引き継ぎの
|
|
43
|
+
`state` は変えない(作業は後継が済ませている)。
|
|
44
|
+
|
|
45
|
+
## Consequences
|
|
46
|
+
|
|
47
|
+
- Desktop で作業している利用者は、後継の作業が終わると、その会話が Desktop に開いて続きを打てる。
|
|
48
|
+
- 後継が作業している間は、Desktop には出ない。長いターンの間は、旧い会話の止めた理由の文だけが手がかりになる。
|
|
49
|
+
途中の様子は `claude agents`・`claude attach <id>` で見られる。
|
|
50
|
+
- Desktop へ移した会話の権限は、Desktop が決める(実測では `acceptEdits` で取り込まれた)。作業のターンは移す前に
|
|
51
|
+
終わっているので、作業中の権限は変わらない。
|
|
52
|
+
- `claude stop` の後で `--desktop --resume` が失敗すると、後継は止まったまま裏に残る。記録に理由が残り、
|
|
53
|
+
`claude --resume <session id>` か、手で `claude --desktop --resume <session id>` で開ける。
|
|
54
|
+
- Claude の Stop hook は、会話ごとに引き継ぎの記録の一覧を1回読む(記録は数個)。
|
|
55
|
+
|
|
56
|
+
## 確かめた範囲
|
|
57
|
+
|
|
58
|
+
macOS、Claude Code 2.1.289、Haiku 4.5、試験用フォルダ。端末から始めた会話に `THROUGHLINE_AUTO_HANDOFF_OPEN=desktop` を
|
|
59
|
+
付けて流した([実測](https://github.com/kitepon/Throughline/blob/main/evidence/2026-10-05-claude-auto-handoff.md))。
|
|
60
|
+
|
|
61
|
+
- 1回の引き継ぎ: 後継が 22:00:59 に作業を終え、22:01:03 に Desktop が取り込んだ。
|
|
62
|
+
- 続けて3回の引き継ぎ: 最後の後継だけが移り(22:03:13)、途中の2つは移らなかった。
|
|
63
|
+
|
|
64
|
+
## 確かめていない範囲
|
|
65
|
+
|
|
66
|
+
- Claude Desktop の画面から始めた本物の会話での通し(印の付き方は、実物の transcript の値と単体の試験で確かめた)。
|
|
67
|
+
- Windows と Linux の Claude Desktop。
|
|
68
|
+
- 長いターンの後継と、後継が質問で止まった時の見え方。
|
package/package.json
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* 4. 後継の UserPromptSubmit : その指示が baton を消費し、記憶を注入して、受領を残す。worker は受領を待つ。
|
|
13
13
|
* 外から後継へ文を送らない。権限のバイパス中の会話は、外から届いた文を
|
|
14
14
|
* 利用者の承認まで止めるため(0.15.3)。
|
|
15
|
+
* 5. 後継の Stop : Claude Desktop から始まった会話の後継は、そのターンの作業を終えた時に
|
|
16
|
+
* Desktop へ移して開く(`claude stop` → `claude --desktop --resume`)。
|
|
17
|
+
* Claude Code は、裏で動いている会話を Desktop へ移さない。
|
|
15
18
|
*
|
|
16
19
|
* 旧い会話は止めるだけで、空にしない。手動の /compact と subagent の中の圧縮は対象にしない。
|
|
17
20
|
* 状態は ~/.throughline/claude-auto-handoff/ のファイルに持つ(schema は変えない)。
|
|
@@ -37,6 +40,7 @@ import { captureInFlightTurn } from './turn-backfill.mjs';
|
|
|
37
40
|
import { resolveMergeTarget } from './session-merger.mjs';
|
|
38
41
|
import { writeBaton } from './baton.mjs';
|
|
39
42
|
import { sameProjectPath } from './project-path.mjs';
|
|
43
|
+
import { runWithPty } from './os/pty-run.mjs';
|
|
40
44
|
import { spawnPortable, spawnPortableSync } from './os/portable-spawn-sync.mjs';
|
|
41
45
|
import { composeAutoHandoffTitle } from './auto-handoff-title.mjs';
|
|
42
46
|
|
|
@@ -53,6 +57,11 @@ const BACKGROUNDED_PATTERN = /backgrounded\s+\S+\s+([0-9a-f]{8})\b/;
|
|
|
53
57
|
const PER_SESSION_ENV = ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_MESSAGING_SOCKET',
|
|
54
58
|
'CLAUDE_CODE_MESSAGING_TOKEN', 'CLAUDE_CODE_SSE_PORT', 'CLAUDE_PROJECT_DIR', 'CLAUDE_ENV_FILE'];
|
|
55
59
|
|
|
60
|
+
// Claude Desktop の画面から始まった会話が transcript に持つ印。
|
|
61
|
+
const DESKTOP_ENTRYPOINT = 'claude-desktop';
|
|
62
|
+
// 後継を Desktop で開くかを、会話の出どころに関係なく決める(`desktop` で必ず開く、`off` で開かない)。
|
|
63
|
+
const OPEN_OVERRIDE_ENV = 'THROUGHLINE_AUTO_HANDOFF_OPEN';
|
|
64
|
+
|
|
56
65
|
// requested と failed は、次の道具の hook が後継の立ち上げを始められる。
|
|
57
66
|
const RETRYABLE_STATES = new Set(['requested', 'failed']);
|
|
58
67
|
// 後継の最初の指示は、worker が後継の ID を記録へ写す前に届くことがある(launching)。
|
|
@@ -133,6 +142,9 @@ export function publicClaudeAutoHandoff(record) {
|
|
|
133
142
|
successor_session_id: successor?.session_id ?? null, successor_short_id: successor?.short_id ?? null,
|
|
134
143
|
// 止めたターンを DB へ取り込めたか。false の時、後継は `throughline detail` でそのターンの入出力を取れない。
|
|
135
144
|
in_flight_captured: Boolean(record.in_flight?.turn),
|
|
145
|
+
// 後継を Claude Desktop で開く引き継ぎか、開けたか(null は対象外)。
|
|
146
|
+
desktop_state: record.desktop?.wanted ? (record.desktop.state ?? 'waiting') : null,
|
|
147
|
+
desktop_error_code: record.desktop?.error_code ?? null,
|
|
136
148
|
requested_at, updated_at };
|
|
137
149
|
}
|
|
138
150
|
|
|
@@ -187,6 +199,26 @@ function unsupportedEntrypoint(env) {
|
|
|
187
199
|
return typeof env.CLAUDE_CODE_ENTRYPOINT === 'string' && env.CLAUDE_CODE_ENTRYPOINT.startsWith('sdk-');
|
|
188
200
|
}
|
|
189
201
|
|
|
202
|
+
/** transcript の最後の行が持つ出どころ(`cli`、`claude-desktop` など)。読めなければ null。 */
|
|
203
|
+
function readLatestEntrypoint(transcriptPath) {
|
|
204
|
+
if (typeof transcriptPath !== 'string') return null;
|
|
205
|
+
let entries;
|
|
206
|
+
try { entries = readRawEntries(transcriptPath); } catch { return null; }
|
|
207
|
+
return entries.findLast(entry => typeof entry?.entrypoint === 'string')?.entrypoint ?? null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* この会話の後継を、Claude Desktop で開くか。
|
|
212
|
+
* Desktop の画面から始まった会話と、その後継がさらに引き継ぐ時だけ開く。端末から始めた会話の後継は
|
|
213
|
+
* `claude agents` の一覧に出るので、Desktop へは移さない。開く命令を通せるのは macOS だけ。
|
|
214
|
+
*/
|
|
215
|
+
function openInDesktopWanted({ sessionId, transcriptPath, env, dir, platform }) {
|
|
216
|
+
if (platform !== 'darwin' || env[OPEN_OVERRIDE_ENV] === 'off') return false;
|
|
217
|
+
if (env[OPEN_OVERRIDE_ENV] === 'desktop') return true;
|
|
218
|
+
if (env.CLAUDE_CODE_ENTRYPOINT === DESKTOP_ENTRYPOINT || readLatestEntrypoint(transcriptPath) === DESKTOP_ENTRYPOINT) return true;
|
|
219
|
+
return listClaudeAutoHandoffs({ dir }).some(record => record.successor?.session_id === sessionId && record.desktop?.wanted);
|
|
220
|
+
}
|
|
221
|
+
|
|
190
222
|
/**
|
|
191
223
|
* 会話を起動した project の場所を、その OS の書き方にそろえる。
|
|
192
224
|
*
|
|
@@ -273,6 +305,8 @@ export function requestClaudeAutoHandoff({
|
|
|
273
305
|
successor: null,
|
|
274
306
|
error_code: null,
|
|
275
307
|
accepted_at: null,
|
|
308
|
+
desktop: { wanted: openInDesktopWanted({ sessionId, transcriptPath: payload.transcript_path, env, dir, platform }),
|
|
309
|
+
state: null, error_code: null, opened_at: null },
|
|
276
310
|
});
|
|
277
311
|
return { status: 'requested', block: true, sessionId, projectPath, handoffId };
|
|
278
312
|
}
|
|
@@ -452,7 +486,10 @@ export async function launchClaudeAutoHandoffWorker(sessionId, { dir = claudeAut
|
|
|
452
486
|
function stopOutput(record) {
|
|
453
487
|
const reason = record.successor?.short_id
|
|
454
488
|
? `この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: ${record.handoff_id})。続きは後継の会話で行ってください: claude attach ${record.successor.short_id}`
|
|
455
|
-
: `Throughlineが、自動圧縮の代わりに新しい会話へ引き継ぎます(引き継ぎID: ${record.handoff_id}
|
|
489
|
+
: `Throughlineが、自動圧縮の代わりに新しい会話へ引き継ぎます(引き継ぎID: ${record.handoff_id})。` +
|
|
490
|
+
(record.desktop?.wanted
|
|
491
|
+
? '後継の会話は裏で作業を続け、そのターンを終えた時にClaude Desktopへ開きます。途中の様子は `claude agents` の一覧で見られます。'
|
|
492
|
+
: '後継の会話は `claude agents` の一覧に出ます。');
|
|
456
493
|
return {
|
|
457
494
|
continue: false,
|
|
458
495
|
stopReason: reason,
|
|
@@ -645,3 +682,93 @@ export async function runClaudeAutoHandoffWorker(sessionId, {
|
|
|
645
682
|
}
|
|
646
683
|
return update({ state: 'unknown', error_code: 'handoff_delivery_unconfirmed' });
|
|
647
684
|
}
|
|
685
|
+
|
|
686
|
+
/** `claude agents --json --all` から、その後継(裏の会話)の行を読む。読めない時は null。 */
|
|
687
|
+
function readBackgroundAgent(shortId, { spawn, env }) {
|
|
688
|
+
const listed = spawn('claude', ['agents', '--json', '--all'], { env, encoding: 'utf8', timeout: 30_000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
689
|
+
if (listed.error || listed.status !== 0) return null;
|
|
690
|
+
try { return JSON.parse(listed.stdout).find(agent => agent?.id === shortId && agent.kind === 'background') ?? null; }
|
|
691
|
+
catch { return null; }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export async function launchClaudeDesktopOpen(sessionId, { dir = claudeAutoHandoffDir() } = {}) {
|
|
695
|
+
if (!validSessionId(sessionId)) throw new ClaudeHandoffError('auto_handoff_session_id_invalid');
|
|
696
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
697
|
+
const log = openSync(join(dir, `${sessionId}.desktop.log`), 'a', 0o600);
|
|
698
|
+
try {
|
|
699
|
+
const child = spawnPortable(process.execPath,
|
|
700
|
+
[CLI_PATH, 'auto-handoff', 'desktop-open', '--host', 'claude', '--operation', sessionId, '--json'],
|
|
701
|
+
{ detached: true, stdio: ['ignore', log, log], windowsHide: true });
|
|
702
|
+
await new Promise((resolveSpawn, reject) => { child.once('spawn', resolveSpawn); child.once('error', reject); });
|
|
703
|
+
child.unref();
|
|
704
|
+
return child.pid;
|
|
705
|
+
} finally { closeSync(log); }
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* 後継の Stop hook から呼ぶ。Claude Desktop で開く引き継ぎの後継が、ターンを終えた。
|
|
710
|
+
* その会話を Desktop へ移す process を起動する。対象でない会話には何もしない(null)。
|
|
711
|
+
*
|
|
712
|
+
* @returns {Promise<string|null>} 起動した時は、その引き継ぎ ID
|
|
713
|
+
*/
|
|
714
|
+
export async function requestClaudeDesktopOpen({ sessionId, dir = claudeAutoHandoffDir(), launch = launchClaudeDesktopOpen }) {
|
|
715
|
+
if (!validSessionId(sessionId)) return null;
|
|
716
|
+
// この会話自身が引き継ぎの途中なら、作業は次の後継が続ける。移すのは、最後に作業を終えた後継だけ。
|
|
717
|
+
if (readJson(recordPath(sessionId, dir))) return null;
|
|
718
|
+
const record = listClaudeAutoHandoffs({ dir }).find(item => item.successor?.session_id === sessionId &&
|
|
719
|
+
item.state === 'sent' && item.desktop?.wanted && !item.desktop.state);
|
|
720
|
+
if (!record) return null;
|
|
721
|
+
updateRecord(record.source_session_id, { desktop: { ...record.desktop, state: 'requested', error_code: null } }, { dir });
|
|
722
|
+
try { await launch(record.source_session_id, { dir }); }
|
|
723
|
+
catch {
|
|
724
|
+
updateRecord(record.source_session_id, { desktop: { ...record.desktop, state: 'failed', error_code: 'desktop_open_start_failed' } }, { dir });
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
return record.handoff_id;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* 作業を終えた後継を、Claude Desktop へ移して開く。
|
|
732
|
+
*
|
|
733
|
+
* Claude Code は、裏で動いている会話(作業中も、手すきも)を Desktop へ移さない。手すきになるのを待って
|
|
734
|
+
* `claude stop` で止め、`claude --desktop --resume <session id>` で開く。この命令は出力が端末でないと動かないので、
|
|
735
|
+
* 擬似端末の中で呼ぶ。後継がまた動き出した時は止めずに戻り、次の Stop でやり直す。
|
|
736
|
+
*/
|
|
737
|
+
export async function runClaudeDesktopOpen(sourceSessionId, {
|
|
738
|
+
dir = claudeAutoHandoffDir(),
|
|
739
|
+
env = process.env,
|
|
740
|
+
spawn = spawnPortableSync,
|
|
741
|
+
pty = runWithPty,
|
|
742
|
+
idleTimeoutMs = 120_000,
|
|
743
|
+
pollMs = 1_000,
|
|
744
|
+
} = {}) {
|
|
745
|
+
let record = readClaudeAutoHandoff(sourceSessionId, dir);
|
|
746
|
+
if (!record) throw new ClaudeHandoffError('handoff_not_found');
|
|
747
|
+
if (record.desktop?.state !== 'requested') return record;
|
|
748
|
+
const set = fields => { record = updateRecord(sourceSessionId, { desktop: { ...record.desktop, ...fields } }, { dir }); return record; };
|
|
749
|
+
const { short_id: shortId, session_id: successorId } = record.successor ?? {};
|
|
750
|
+
if (!shortId || !validSessionId(successorId)) return set({ state: 'failed', error_code: 'desktop_open_successor_unknown' });
|
|
751
|
+
const childEnv = successorEnv(env);
|
|
752
|
+
|
|
753
|
+
let idle = false;
|
|
754
|
+
for (const deadline = Date.now() + idleTimeoutMs; Date.now() < deadline;) {
|
|
755
|
+
// 後継がさらに引き継ぎを始めた。作業は次の後継が続けるので、この会話は移さない。
|
|
756
|
+
if (readJson(recordPath(successorId, dir))) return set({ state: 'superseded' });
|
|
757
|
+
const agent = readBackgroundAgent(shortId, { spawn, env: childEnv });
|
|
758
|
+
if (agent?.status === 'idle') { idle = true; break; }
|
|
759
|
+
await delay(pollMs);
|
|
760
|
+
}
|
|
761
|
+
// 手すきにならない(次のターンが始まった、一覧を読めない)。止めずに戻り、次の Stop でやり直す。
|
|
762
|
+
if (!idle) return set({ state: null });
|
|
763
|
+
|
|
764
|
+
const stopped = spawn('claude', ['stop', shortId], { env: childEnv, encoding: 'utf8', timeout: 30_000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
765
|
+
if (stopped.error || stopped.status !== 0) return set({ state: 'failed', error_code: 'desktop_open_stop_failed' });
|
|
766
|
+
const opened = pty('claude', ['--desktop', '--resume', successorId], { cwd: record.project_path, env: childEnv, timeout: 60_000 });
|
|
767
|
+
const output = normalizeTerminalText(`${opened?.stdout ?? ''}${opened?.stderr ?? ''}`);
|
|
768
|
+
if (!opened || opened.error || opened.status !== 0 || !output.includes(`Opening session ${successorId}`)) {
|
|
769
|
+
// 理由を端末内のログに残す(命令の出力だけ。会話の中身は含まれない)。
|
|
770
|
+
process.stderr.write(`[auto-handoff] claude --desktop --resume exit=${opened?.status ?? 'unsupported'} output=${JSON.stringify(output.slice(-400))}\n`);
|
|
771
|
+
return set({ state: 'failed', error_code: opened ? 'desktop_open_failed' : 'desktop_open_platform_unsupported' });
|
|
772
|
+
}
|
|
773
|
+
return set({ state: 'opened', error_code: null, opened_at: Date.now() });
|
|
774
|
+
}
|
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
listClaudeAutoHandoffs,
|
|
17
17
|
publicClaudeAutoHandoff,
|
|
18
18
|
claudeContinuationInput,
|
|
19
|
+
requestClaudeDesktopOpen,
|
|
20
|
+
runClaudeDesktopOpen,
|
|
19
21
|
} from './claude-auto-handoff.mjs';
|
|
20
22
|
import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
|
|
21
23
|
|
|
@@ -57,7 +59,7 @@ function request(dir, overrides = {}) {
|
|
|
57
59
|
...overrides.payload },
|
|
58
60
|
env: overrides.env ?? {}, config: overrides.config ?? ENABLED, openDb: () => db, dir, now: overrides.now ?? 1_000,
|
|
59
61
|
// 試験の project は POSIX の書き方(/work/app)。Windows の CI でも同じ値で比べる
|
|
60
|
-
platform: 'linux',
|
|
62
|
+
platform: overrides.platform ?? 'linux',
|
|
61
63
|
});
|
|
62
64
|
return { result, db };
|
|
63
65
|
}
|
|
@@ -593,6 +595,138 @@ test('最初の指示: 後継だけが止めた時点の依頼を受け取り、
|
|
|
593
595
|
assert.equal(acceptance().accepted_at, 9_500);
|
|
594
596
|
}));
|
|
595
597
|
|
|
598
|
+
// ---- 後継を Claude Desktop で開く ----
|
|
599
|
+
|
|
600
|
+
function writeRecord(dir, sourceId, fields) {
|
|
601
|
+
writeFileSync(join(dir, `${sourceId}.json`), JSON.stringify({ schema: 'throughline.claude-auto-handoff.v2', handoff_id: `h-${sourceId}`,
|
|
602
|
+
source_session_id: sourceId, project_path: '/work/app', transcript_path: null, state: 'sent', requested_at: 1_000, updated_at: 1_000,
|
|
603
|
+
settings: null, in_flight: null, successor: { short_id: SUCCESSOR.slice(0, 8), session_id: SUCCESSOR }, error_code: null, accepted_at: 2_000,
|
|
604
|
+
desktop: { wanted: true, state: null, error_code: null, opened_at: null }, ...fields }));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
test('PreCompact: Claude Desktop から始まった会話と、その後継の引き継ぎだけ、後継を Desktop で開く印を付ける(macOS)', () => withDir(dir => {
|
|
608
|
+
const wanted = overrides => {
|
|
609
|
+
rmSync(join(dir, `${SESSION}.json`), { force: true });
|
|
610
|
+
request(dir, overrides);
|
|
611
|
+
return readClaudeAutoHandoff(SESSION, dir).desktop;
|
|
612
|
+
};
|
|
613
|
+
const desktopTranscript = join(dir, 'desktop.jsonl');
|
|
614
|
+
writeFileSync(desktopTranscript, jsonl([{ ...user('読んで', '2026-10-08T12:00:00.000Z'), entrypoint: 'claude-desktop' },
|
|
615
|
+
{ ...assistant('読みました', '2026-10-08T12:00:05.000Z'), entrypoint: 'claude-desktop' }]));
|
|
616
|
+
const cliTranscript = join(dir, 'cli.jsonl');
|
|
617
|
+
writeFileSync(cliTranscript, jsonl([{ ...user('読んで', '2026-10-08T12:00:00.000Z'), entrypoint: 'cli' }]));
|
|
618
|
+
|
|
619
|
+
assert.deepEqual(wanted({ platform: 'darwin', transcriptPath: desktopTranscript }), { wanted: true, state: null, error_code: null, opened_at: null });
|
|
620
|
+
assert.equal(wanted({ platform: 'darwin', env: { CLAUDE_CODE_ENTRYPOINT: 'claude-desktop' } }).wanted, true);
|
|
621
|
+
assert.equal(wanted({ platform: 'darwin', transcriptPath: cliTranscript }).wanted, false, '端末から始めた会話の後継は、claude agents の一覧に出る');
|
|
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);
|
|
625
|
+
assert.equal(wanted({ platform: 'darwin', transcriptPath: cliTranscript, env: { THROUGHLINE_AUTO_HANDOFF_OPEN: 'desktop' } }).wanted, true);
|
|
626
|
+
assert.equal(wanted({ platform: 'darwin', transcriptPath: desktopTranscript, env: { THROUGHLINE_AUTO_HANDOFF_OPEN: 'off' } }).wanted, false);
|
|
627
|
+
|
|
628
|
+
// 裏で作業している後継がさらに引き継ぐ時は、元の会話の出どころを引き継ぐ(後継の transcript は cli)。
|
|
629
|
+
writeRecord(dir, 'origin-desktop', { successor: { short_id: SESSION.slice(0, 8), session_id: SESSION } });
|
|
630
|
+
assert.equal(wanted({ platform: 'darwin', transcriptPath: cliTranscript }).wanted, true);
|
|
631
|
+
writeRecord(dir, 'origin-desktop', { successor: { short_id: SESSION.slice(0, 8), session_id: SESSION }, desktop: { wanted: false, state: null } });
|
|
632
|
+
assert.equal(wanted({ platform: 'darwin', transcriptPath: cliTranscript }).wanted, false);
|
|
633
|
+
}));
|
|
634
|
+
|
|
635
|
+
test('PreToolUse: Desktop で開く引き継ぎは、止めた理由に、後継がターンを終えた時に Desktop へ開く事を書く', () => withDir(async dir => {
|
|
636
|
+
const desktopTranscript = join(dir, 'desktop.jsonl');
|
|
637
|
+
writeFileSync(desktopTranscript, jsonl([{ ...user('読んで', '2026-10-08T12:00:00.000Z'), entrypoint: 'claude-desktop' }]));
|
|
638
|
+
request(dir, { platform: 'darwin', transcriptPath: desktopTranscript });
|
|
639
|
+
const output = await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app', tool_use_id: 't1' }, dir, launchWorker: async () => 1 });
|
|
640
|
+
assert.match(output.stopReason, /そのターンを終えた時にClaude Desktopへ開きます。途中の様子は `claude agents` の一覧で見られます。$/);
|
|
641
|
+
}));
|
|
642
|
+
|
|
643
|
+
test('Stop: Desktop で開く引き継ぎの後継がターンを終えたら、移す process を1回だけ起動する', () => withDir(async dir => {
|
|
644
|
+
const launched = [];
|
|
645
|
+
const launch = async (id) => { launched.push(id); };
|
|
646
|
+
// 対象でない会話
|
|
647
|
+
assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null);
|
|
648
|
+
writeRecord(dir, SESSION, { desktop: { wanted: false, state: null } });
|
|
649
|
+
assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null, '端末から始めた会話の後継は移さない');
|
|
650
|
+
writeRecord(dir, SESSION, { state: 'unknown' });
|
|
651
|
+
assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null, '受領を確かめられていない後継は移さない');
|
|
652
|
+
// 対象の後継
|
|
653
|
+
writeRecord(dir, SESSION, {});
|
|
654
|
+
assert.equal(await requestClaudeDesktopOpen({ sessionId: SESSION, dir, launch }), null, '旧い会話自身の Stop では動かない');
|
|
655
|
+
assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), `h-${SESSION}`);
|
|
656
|
+
assert.deepEqual(launched, [SESSION]);
|
|
657
|
+
assert.equal(readClaudeAutoHandoff(SESSION, dir).desktop.state, 'requested');
|
|
658
|
+
assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null, '2回目の Stop では起動しない');
|
|
659
|
+
assert.deepEqual(launched, [SESSION]);
|
|
660
|
+
|
|
661
|
+
// 後継自身が引き継ぎの途中(作業は次の後継が続ける)なら、移さない
|
|
662
|
+
writeRecord(dir, SESSION, {});
|
|
663
|
+
writeRecord(dir, SUCCESSOR, { state: 'stopped', successor: null });
|
|
664
|
+
assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch }), null);
|
|
665
|
+
assert.deepEqual(launched, [SESSION]);
|
|
666
|
+
|
|
667
|
+
// 起動に失敗した時は理由を残す
|
|
668
|
+
rmSync(join(dir, `${SUCCESSOR}.json`));
|
|
669
|
+
assert.equal(await requestClaudeDesktopOpen({ sessionId: SUCCESSOR, dir, launch: async () => { throw new Error('spawn'); } }), null);
|
|
670
|
+
assert.deepEqual([readClaudeAutoHandoff(SESSION, dir).desktop.state, readClaudeAutoHandoff(SESSION, dir).desktop.error_code], ['failed', 'desktop_open_start_failed']);
|
|
671
|
+
}));
|
|
672
|
+
|
|
673
|
+
test('Desktop で開く: 後継が手すきになるのを待って止め、擬似端末の中で claude --desktop --resume を呼ぶ', () => withDir(async dir => {
|
|
674
|
+
const shortId = SUCCESSOR.slice(0, 8);
|
|
675
|
+
const run = async ({ statuses, stopStatus = 0, opened, beforePoll = () => {} }) => {
|
|
676
|
+
writeRecord(dir, SESSION, { desktop: { wanted: true, state: 'requested', error_code: null, opened_at: null } });
|
|
677
|
+
const calls = [];
|
|
678
|
+
const spawn = (command, args, options) => {
|
|
679
|
+
calls.push([command, ...args]);
|
|
680
|
+
if (args[0] === 'agents') {
|
|
681
|
+
beforePoll();
|
|
682
|
+
const status = statuses.length > 1 ? statuses.shift() : statuses[0];
|
|
683
|
+
return { status: 0, stdout: JSON.stringify([{ id: shortId, kind: 'interactive', status: 'idle' }, { id: 'ffffffff', kind: 'background', status: 'idle' },
|
|
684
|
+
...(status === null ? [] : [{ id: shortId, kind: 'background', status, sessionId: SUCCESSOR }])]) };
|
|
685
|
+
}
|
|
686
|
+
assert.deepEqual(options.env, { PATH: '/bin' }, '会話ごとの環境変数は渡さない');
|
|
687
|
+
return { status: stopStatus, stdout: `stopped ${shortId}\n` };
|
|
688
|
+
};
|
|
689
|
+
const pty = (command, args, options) => { calls.push(['pty', command, ...args, options.cwd]); return opened; };
|
|
690
|
+
const record = await runClaudeDesktopOpen(SESSION, { dir, env: { PATH: '/bin', CLAUDECODE: '1', CLAUDE_CODE_ENTRYPOINT: 'cli' }, spawn, pty, idleTimeoutMs: 60, pollMs: 5 });
|
|
691
|
+
return { record, calls };
|
|
692
|
+
};
|
|
693
|
+
const ok = { status: 0, stdout: `\u0004\b\bOpening session ${SUCCESSOR} in Claude Desktop\r\n`, stderr: '' };
|
|
694
|
+
|
|
695
|
+
// macOS の実測(2026-10-08): 作業中は busy、ターンを終えると idle。どちらも `--desktop --resume` は断られ、stop の後だけ通る。
|
|
696
|
+
const done = await run({ statuses: ['busy', 'busy', 'idle'], opened: ok });
|
|
697
|
+
assert.deepEqual(done.calls.slice(-2), [['claude', 'stop', shortId], ['pty', 'claude', '--desktop', '--resume', SUCCESSOR, '/work/app']]);
|
|
698
|
+
assert.equal(done.calls.filter(call => call[1] === 'agents').length, 3);
|
|
699
|
+
assert.deepEqual([done.record.desktop.state, done.record.desktop.error_code, typeof done.record.desktop.opened_at], ['opened', null, 'number']);
|
|
700
|
+
assert.equal(done.record.state, 'sent', '引き継ぎの状態は変えない');
|
|
701
|
+
assert.deepEqual([publicClaudeAutoHandoff(done.record).desktop_state, publicClaudeAutoHandoff(done.record).desktop_error_code], ['opened', null]);
|
|
702
|
+
|
|
703
|
+
// 手すきにならない(次のターンが始まった): 止めずに戻り、次の Stop でやり直せる状態にする
|
|
704
|
+
const busy = await run({ statuses: ['busy'], opened: ok });
|
|
705
|
+
assert.equal(busy.calls.some(call => call[1] === 'stop' || call[0] === 'pty'), false);
|
|
706
|
+
assert.equal(busy.record.desktop.state, null);
|
|
707
|
+
const missing = await run({ statuses: [null], opened: ok });
|
|
708
|
+
assert.equal(missing.calls.some(call => call[1] === 'stop'), false, '一覧に無い会話は止めない');
|
|
709
|
+
|
|
710
|
+
// 待つ間に、後継がさらに引き継ぎを始めた
|
|
711
|
+
const superseded = await run({ statuses: ['busy'], opened: ok,
|
|
712
|
+
beforePoll: () => writeRecord(dir, SUCCESSOR, { state: 'requested', successor: null }) });
|
|
713
|
+
assert.equal(superseded.record.desktop.state, 'superseded');
|
|
714
|
+
assert.equal(superseded.calls.some(call => call[1] === 'stop'), false);
|
|
715
|
+
rmSync(join(dir, `${SUCCESSOR}.json`));
|
|
716
|
+
|
|
717
|
+
// 止められない・開けない時は、固定の理由を残す
|
|
718
|
+
const stopFailed = await run({ statuses: ['idle'], stopStatus: 1, opened: ok });
|
|
719
|
+
assert.deepEqual([stopFailed.record.desktop.state, stopFailed.record.desktop.error_code], ['failed', 'desktop_open_stop_failed']);
|
|
720
|
+
assert.equal(stopFailed.calls.some(call => call[0] === 'pty'), false);
|
|
721
|
+
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
|
+
assert.deepEqual([refused.record.desktop.state, refused.record.desktop.error_code], ['failed', 'desktop_open_failed']);
|
|
723
|
+
|
|
724
|
+
// 対象でない記録には何もしない
|
|
725
|
+
writeRecord(dir, SESSION, {});
|
|
726
|
+
const untouched = await runClaudeDesktopOpen(SESSION, { dir, spawn: () => { throw new Error('must not run'); }, pty: () => { throw new Error('must not run'); } });
|
|
727
|
+
assert.equal(untouched.desktop.state, null);
|
|
728
|
+
}));
|
|
729
|
+
|
|
596
730
|
test('公開CLI: --host claude は enable・disable・status・worker を受け取る', () => {
|
|
597
731
|
assert.equal(parseAutoHandoffArgs(['status', '--json']).host, 'codex');
|
|
598
732
|
assert.deepEqual(
|
|
@@ -601,8 +735,10 @@ test('公開CLI: --host claude は enable・disable・status・worker を受け
|
|
|
601
735
|
);
|
|
602
736
|
assert.equal(parseAutoHandoffArgs(['status', '--host', 'claude', '--operation', 'id']).operation, 'id');
|
|
603
737
|
assert.equal(parseAutoHandoffArgs(['worker', '--host', 'claude', '--operation', SESSION]).action, 'worker');
|
|
738
|
+
assert.equal(parseAutoHandoffArgs(['desktop-open', '--host', 'claude', '--operation', SESSION]).action, 'desktop-open');
|
|
604
739
|
for (const args of [['resume', '--host', 'claude', '--operation', 'id'], ['detail', '--host', 'claude', '--operation', 'id', '--origin', 'A', '--turn', '1'],
|
|
605
|
-
['worker', '--host', 'claude'], ['status', '--host', 'grok'], ['status', '--host'], ['disable', '--host', 'claude', '--project', REPO_ROOT]
|
|
740
|
+
['worker', '--host', 'claude'], ['status', '--host', 'grok'], ['status', '--host'], ['disable', '--host', 'claude', '--project', REPO_ROOT],
|
|
741
|
+
['desktop-open', '--host', 'claude'], ['desktop-open', '--operation', SESSION]]) {
|
|
606
742
|
assert.throws(() => parseAutoHandoffArgs(args), JSON.stringify(args));
|
|
607
743
|
}
|
|
608
744
|
});
|
package/src/cli/auto-handoff.mjs
CHANGED
|
@@ -10,17 +10,17 @@ import { installCodexAutoHandoffHook, installClaudeAutoHandoffHooks, removeClaud
|
|
|
10
10
|
claudeAutoHandoffHooksRegistered } from './install.mjs';
|
|
11
11
|
import { sameProjectPath } from '../project-path.mjs';
|
|
12
12
|
import { readClaudeAutoHandoffConfig, writeClaudeAutoHandoffConfig } from '../claude-auto-handoff-config.mjs';
|
|
13
|
-
import { listClaudeAutoHandoffs, publicClaudeAutoHandoff, runClaudeAutoHandoffWorker } from '../claude-auto-handoff.mjs';
|
|
13
|
+
import { listClaudeAutoHandoffs, publicClaudeAutoHandoff, runClaudeAutoHandoffWorker, runClaudeDesktopOpen } from '../claude-auto-handoff.mjs';
|
|
14
14
|
|
|
15
15
|
// Claude の自動継続 (ADR 0033)。引き継ぎの記録は旧い会話の session id で持つ。結果が不明な配送は
|
|
16
16
|
// 再送しないので、resume は持たない。記憶の取得は通常の recall / detail を使うので、detail も持たない。
|
|
17
|
-
const CLAUDE_ACTIONS = ['enable', 'disable', 'status', 'worker'];
|
|
17
|
+
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'].includes(out.action)) throw Error('auto_handoff_action_invalid');
|
|
21
|
+
if (!['enable', 'disable', 'status', 'resume', 'detail', 'worker', 'desktop-open'].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'], detail: ['operation', 'origin', 'turn', 'database'] }[out.action]];
|
|
23
|
+
resume: ['operation'], worker: ['operation'], 'desktop-open': ['operation'], 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');
|
|
@@ -31,10 +31,10 @@ export function parseAutoHandoffArgs(args) {
|
|
|
31
31
|
out[key] = args[++i];
|
|
32
32
|
}
|
|
33
33
|
if (!['codex', 'claude'].includes(out.host)) throw Error('auto_handoff_host_invalid');
|
|
34
|
-
if (out.host === 'claude'
|
|
34
|
+
if (out.host === 'claude' ? !CLAUDE_ACTIONS.includes(out.action) : out.action === 'desktop-open') throw Error('auto_handoff_action_unsupported');
|
|
35
35
|
if (out.project && !isAbsolute(out.project)) out.project = resolve(out.project);
|
|
36
36
|
if (out.project && out.operation) throw Error('auto_handoff_argument_invalid');
|
|
37
|
-
if (['worker', 'resume', 'detail'].includes(out.action) && !out.operation) throw Error('auto_handoff_operation_required');
|
|
37
|
+
if (['worker', 'resume', 'detail', 'desktop-open'].includes(out.action) && !out.operation) throw Error('auto_handoff_operation_required');
|
|
38
38
|
if (out.action === 'detail') {
|
|
39
39
|
out.turn = Number(out.turn);
|
|
40
40
|
if (!out.origin || !Number.isSafeInteger(out.turn) || out.turn < 1) throw Error('auto_handoff_detail_identity_required');
|
|
@@ -85,6 +85,7 @@ async function runClaude(parsed) {
|
|
|
85
85
|
return { status: 'disabled', host: 'claude', config };
|
|
86
86
|
}
|
|
87
87
|
if (parsed.action === 'worker') return publicClaudeAutoHandoff(await runClaudeAutoHandoffWorker(parsed.operation, { openDb: getDb }));
|
|
88
|
+
if (parsed.action === 'desktop-open') return publicClaudeAutoHandoff(await runClaudeDesktopOpen(parsed.operation));
|
|
88
89
|
const handoffs = listClaudeAutoHandoffs()
|
|
89
90
|
.filter(record => !parsed.project || sameProjectPath(record.project_path, parsed.project))
|
|
90
91
|
.filter(record => !parsed.operation || record.handoff_id === parsed.operation || record.source_session_id === parsed.operation)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* os/pty-run.mjs — 端末を要求するコマンドを、端末の無い process から呼ぶ
|
|
3
|
+
*
|
|
4
|
+
* darwin: `script -q /dev/null <command> <args…>` が擬似端末を作る。
|
|
5
|
+
* 他の OS は未対応で、null を返す(呼び出し元が見送る)。
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
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 });
|
|
13
|
+
}
|
package/src/turn-processor.mjs
CHANGED
|
@@ -52,7 +52,7 @@ import { writeCompletedTurnReceipt } from './completed-turn-receipts.mjs';
|
|
|
52
52
|
import { hostAdapterForSessionId, parseHookPayload } from './hosts/index.mjs';
|
|
53
53
|
import { readClaudeRelocatedCwd } from './hosts/claude.mjs';
|
|
54
54
|
import { CLAUDE_HOST } from './hosts/identity.mjs';
|
|
55
|
-
import { completeClaudeTurnWithoutHandoff } from './claude-auto-handoff.mjs';
|
|
55
|
+
import { completeClaudeTurnWithoutHandoff, requestClaudeDesktopOpen } from './claude-auto-handoff.mjs';
|
|
56
56
|
|
|
57
57
|
/** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
|
|
58
58
|
export const L2_WINDOW = 20;
|
|
@@ -271,6 +271,10 @@ async function processStop() {
|
|
|
271
271
|
|
|
272
272
|
// 自動圧縮を止めた後、道具を呼ばずにターンが終わった。続ける作業が無いので、引き継ぎの記録を取り下げる (ADR 0033)。
|
|
273
273
|
completeClaudeTurnWithoutHandoff({ sessionId: session_id });
|
|
274
|
+
// Claude Desktop から始まった会話の後継が、ターンを終えた。Desktop へ移して開く process を起動する。
|
|
275
|
+
// 失敗しても、会話の保存は続ける。
|
|
276
|
+
try { await requestClaudeDesktopOpen({ sessionId: session_id }); }
|
|
277
|
+
catch (err) { process.stderr.write(`[auto-handoff] desktop open: ${err instanceof Error ? err.message : 'unknown'}\n`); }
|
|
274
278
|
|
|
275
279
|
// VSCode で開かれたプロジェクトに .vscode/tasks.json を自動プロビジョニングする。
|
|
276
280
|
// 2 回目以降は冪等性チェックで即 return するので毎ターン走っても安全。
|