throughline 0.15.5 → 0.16.0
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 +24 -1
- package/README.ja.md +2 -2
- package/README.md +2 -2
- package/docs/adr/0037-auto-handoff-successor-title.md +47 -0
- package/package.json +1 -1
- package/src/auto-handoff-title.mjs +57 -0
- package/src/auto-handoff-title.test.mjs +50 -0
- package/src/claude-auto-handoff.mjs +21 -2
- package/src/claude-auto-handoff.test.mjs +40 -3
- package/src/codex-auto-handoff.mjs +27 -0
- package/src/codex-auto-handoff.test.mjs +29 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,28 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.16.0] — 2026-10-07
|
|
14
|
+
|
|
15
|
+
### 変更
|
|
16
|
+
|
|
17
|
+
- 自動継続が立てる後継に、`<project 名>|<作業の概要>(自動引き継ぎ)`の名前を付ける([ADR 0037](docs/adr/0037-auto-handoff-successor-title.md))。一覧を見た時に、どのprojectの何の作業の続きかを読めるようにする。
|
|
18
|
+
- Claude Code: 今までの名前は`tl-<フォルダ名>-<引き継ぎIDの先頭8桁>`で、Claude Desktopの一覧にもそのまま出ていた。作業の概要は前任の会話の題(Claude Desktopの題、人が付けた題、Claude Codeが付けた題の順)から取り、題が無ければ止めた時点の依頼の最初の行を使う。
|
|
19
|
+
- Codex: 今までは後継タスクに名前を付けておらず、継続の指示(`Throughline自動継続 <ID> / <ID>`)がそのまま題として見えていた。前任のタスクの題を概要にして、後継を作った直後に`thread/name/set`で付ける。前任がこの版より前に立てた名前の無い後継なら、引き継ぎの記録をさかのぼって元のタスクの題を使う。
|
|
20
|
+
- 概要は40字で切る。引き継ぎを重ねても、project名と印は積み重ならない。AIは呼ばない。
|
|
21
|
+
- Codexで名前を付けられなかった時も、引き継ぎは止めない(理由はworkerのログに残る)。
|
|
22
|
+
|
|
23
|
+
### 確認した範囲
|
|
24
|
+
|
|
25
|
+
- Linux(Claude Code 2.1.292、Haiku)の切り離した置き場で、本物の会話に自動圧縮と道具のhookを渡した。後継は`p1|read file fruit colors(自動引き継ぎ)`の名前で立ち、継続の指示を受け取った。その後継からもう一度引き継ぐと、3つ目も同じ名前になった。
|
|
26
|
+
- Linux(Codex 0.160.1)のapp-serverで、新しいスレッドに最初のターンの前に付けた名前が、ターンの後も残る。
|
|
27
|
+
- 公開するpackageを展開して、本物のworkerから後継を立て、`claude agents`に出る名前が渡した名前と一致することを3環境で見た(題は日本語・全角の記号・`&`・二重引用符を含む): Linux(Claude Code 2.1.292)、macOS(2.1.289)、Windows 11(2.1.292、PowerShellのshim経由、Node 24.20.0)。
|
|
28
|
+
- macOSのCodex Desktopに同梱のCodex(0.160.1)が`thread/name/set`を持つ。
|
|
29
|
+
|
|
30
|
+
### 確認していない範囲
|
|
31
|
+
|
|
32
|
+
- Codex Desktopの実機での自動継続の通し(後継タスクが一覧に新しい名前で出るところ)。確かめたのは、同じ版のCodexのapp-serverで名前が付いて残ることまで。
|
|
33
|
+
- Claude Desktopの一覧に新しい名前が出るところ。今までの名前(`tl-…`)がDesktopの題としてそのまま出ていたことは、macOSの記録で見ている。
|
|
34
|
+
|
|
13
35
|
## [0.15.5] — 2026-10-07
|
|
14
36
|
|
|
15
37
|
### 修正
|
|
@@ -1854,7 +1876,8 @@ two attempts, instrument first instead of patching again.
|
|
|
1854
1876
|
|
|
1855
1877
|
---
|
|
1856
1878
|
|
|
1857
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.
|
|
1879
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.0...HEAD
|
|
1880
|
+
[0.16.0]: https://github.com/kitepon/Throughline/compare/v0.15.5...v0.16.0
|
|
1858
1881
|
[0.15.5]: https://github.com/kitepon/Throughline/compare/v0.15.4...v0.15.5
|
|
1859
1882
|
[0.15.4]: https://github.com/kitepon/Throughline/compare/v0.15.3...v0.15.4
|
|
1860
1883
|
[0.15.3]: https://github.com/kitepon/Throughline/compare/v0.15.2...v0.15.3
|
package/README.ja.md
CHANGED
|
@@ -29,7 +29,7 @@ throughline auto-handoff disable
|
|
|
29
29
|
|
|
30
30
|
既定では無効です。`enable`は必要な`PreCompact(auto)`フックを登録し、そのフックの承認と有効状態を公式APIで確認します。`--project`を省略すると全projectを対象にします。macOSのCodex Desktopで実機確認済みです。VS Code・CLI・他OSの自動継続は未検証です。
|
|
31
31
|
|
|
32
|
-
自動圧縮の開始前に旧ターンを停止し、同じproject
|
|
32
|
+
自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact`は発火対象に含めません。
|
|
33
33
|
|
|
34
34
|
記憶は引き継ぎ全体の直近20ターンをL2全文、それより古いターンをL1で渡します。元のユーザー依頼と中断地点を保持し、tool結果とThinkingは注入しません。本文内の`auto-handoff detail`コマンドで、凍結したL2/L3を引き継ぎID・origin・turnから取得できます。A→B→Cの連続引き継ぎでも祖先の詳細を取得できます。
|
|
35
35
|
|
|
@@ -58,7 +58,7 @@ throughline auto-handoff disable --host claude
|
|
|
58
58
|
|
|
59
59
|
1. 自動圧縮の直前に、圧縮を止めます。`/tl`と同じ印を残します。
|
|
60
60
|
2. 旧い会話の次の道具を、実行させずに止めます。旧い会話は止めるだけで、空にしません。
|
|
61
|
-
3. 同じprojectに、継続の指示を最初の指示として付けた新しいClaudeの会話を裏で立てます(`claude --bg
|
|
61
|
+
3. 同じprojectに、継続の指示を最初の指示として付けた新しいClaudeの会話を裏で立てます(`claude --bg`)。会話の名前は、前任の会話の題から`<project名>|<作業の概要>(自動引き継ぎ)`にします。モデル・推論強度・権限は旧い会話から引き継ぎます。
|
|
62
62
|
4. 最初の指示を受け取った時に、止めた時点の依頼、そのターンでここまでにしたこと(発言と道具の呼び出し)、実行されなかった道具、直近の会話の原文を注入します(上限9,500字)。止めたターンの道具の入出力は、後継が`throughline detail`で取り出せます。
|
|
63
63
|
|
|
64
64
|
続きは裏の会話で動きます。`claude agents`の一覧と`claude attach <id>`で見られます。Desktopへ自動で表示はしません。Desktopで開く時は、作業完了を確認して`claude stop <id>`で裏の実行を停止し、`claude --desktop --resume <session-id>`を実行します。`session-id`は`claude agents --json --all`の`sessionId`です。手動`/compact`、subagentの中の圧縮、`claude -p`などscriptから起動した会話は対象に含めません。
|
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ throughline auto-handoff disable
|
|
|
28
28
|
|
|
29
29
|
既定では無効です。`enable`は必要な`PreCompact(auto)`フックを登録し、そのフックの承認と有効状態を公式APIで確認します。`--project`を省略すると全projectを対象にします。macOSのCodex Desktopで実機確認済みです。VS Code・CLI・他OSの自動継続は未検証です。
|
|
30
30
|
|
|
31
|
-
自動圧縮の開始前に旧ターンを停止し、同じproject
|
|
31
|
+
自動圧縮の開始前に旧ターンを停止し、同じprojectの新しいタスクへ記憶を注入して表示します。新しいタスクには、前任のタスクの題から`<project名>|<作業の概要>(自動引き継ぎ)`の名前を付けます。元のモデル・推論強度・権限・モードなどが表示後も一致することを確認してから、継続指示を一度だけ送ります。後継でその入力と作業の進捗を観測して完了とします。手動`/compact`は発火対象に含めません。
|
|
32
32
|
|
|
33
33
|
記憶は引き継ぎ全体の直近20ターンをL2全文、それより古いターンをL1で渡します。元のユーザー依頼と中断地点を保持し、tool結果とThinkingは注入しません。本文内の`auto-handoff detail`コマンドで、凍結したL2/L3を引き継ぎID・origin・turnから取得できます。A→B→Cの連続引き継ぎでも祖先の詳細を取得できます。
|
|
34
34
|
|
|
@@ -57,7 +57,7 @@ throughline auto-handoff disable --host claude
|
|
|
57
57
|
|
|
58
58
|
1. 自動圧縮の直前に、圧縮を止めます。`/tl`と同じ印を残します。
|
|
59
59
|
2. 旧い会話の次の道具を、実行させずに止めます。旧い会話は止めるだけで、空にしません。
|
|
60
|
-
3. 同じprojectに、継続の指示を最初の指示として付けた新しいClaudeの会話を裏で立てます(`claude --bg
|
|
60
|
+
3. 同じprojectに、継続の指示を最初の指示として付けた新しいClaudeの会話を裏で立てます(`claude --bg`)。会話の名前は、前任の会話の題から`<project名>|<作業の概要>(自動引き継ぎ)`にします。モデル・推論強度・権限は旧い会話から引き継ぎます。
|
|
61
61
|
4. 最初の指示を受け取った時に、止めた時点の依頼、そのターンでここまでにしたこと(発言と道具の呼び出し)、実行されなかった道具、直近の会話の原文を注入します(上限9,500字)。止めたターンの道具の入出力は、後継が`throughline detail`で取り出せます。
|
|
62
62
|
|
|
63
63
|
続きは裏の会話で動きます。`claude agents`の一覧と`claude attach <id>`で見られます。Desktopへ自動で表示はしません。Desktopで開く時は、作業完了を確認して`claude stop <id>`で裏の実行を停止し、`claude --desktop --resume <session-id>`を実行します。`session-id`は`claude agents --json --all`の`sessionId`です。手動`/compact`、subagentの中の圧縮、`claude -p`などscriptから起動した会話は対象に含めません。
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# ADR 0037: 自動継続の後継に「project 名|作業の概要(自動引き継ぎ)」の名前を付ける
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-07
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
自動継続が立てる後継は、一覧で何の project の何の続きかを読めなかった(2026-10-07、オーナーの指摘)。
|
|
8
|
+
|
|
9
|
+
- Claude Code: 後継を `claude --bg --name tl-<フォルダ名>-<引き継ぎ ID の先頭8桁>` で立てていた。Claude Desktop の一覧にも
|
|
10
|
+
この名前がそのまま題として出る(macOS で実測: `tl-tl-claude-probe-3eacc0af`)。作業の中身は名前に入らない。
|
|
11
|
+
- Codex: 後継タスクに名前を付けていなかった。Codex は名前の無いタスクを最初の user 発言で表示するので、継続の指示
|
|
12
|
+
(`Throughline自動継続 <引き継ぎ ID> / <配送 ID>`)がそのまま題として見える。macOS の実機で、自動継続が立てた
|
|
13
|
+
後継4つに名前が無いことを `session_index.jsonl` で確かめた。project 名も作業の中身も読めない。
|
|
14
|
+
|
|
15
|
+
前任の題は host が既に持っている。
|
|
16
|
+
|
|
17
|
+
- Claude Code は会話記録に題を書く。端末の会話は `ai-title`(Claude Code が付ける。変わるたびに行が増える)、
|
|
18
|
+
Claude Desktop の会話と人が名前を変えた会話は `custom-title`。`--name` で立てた会話も `custom-title` に入り、
|
|
19
|
+
`ai-title` は付かない(Linux 2.1.292、macOS の記録で確認)。
|
|
20
|
+
- Codex は `thread/read` が `name`(Desktop が付けた題、または人が付けた題)と `preview`(最初の user 発言)を返す。
|
|
21
|
+
自動継続は、前任の状態を読む時にこの値を既に取っている(`runtime.title`)。名前は `thread/name/set` で付けられ、
|
|
22
|
+
最初のターンの前に付けた名前はターンの後も残る(Linux 0.160.1 で確認)。書かれるのは `session_index.jsonl` だけで、
|
|
23
|
+
rollout には何も足されない。
|
|
24
|
+
|
|
25
|
+
## Decision
|
|
26
|
+
|
|
27
|
+
1. 後継の名前を `<project 名>|<作業の概要>(自動引き継ぎ)` にする。project 名は project の場所の最後のフォルダ名。
|
|
28
|
+
概要が取れない時は `<project 名>(自動引き継ぎ)`。形は `src/auto-handoff-title.mjs` に1か所だけ置く。
|
|
29
|
+
2. 作業の概要は前任の題から取る。AI は呼ばない(引き継ぎを遅らせず、後継を立てる前に失敗する所を増やさない)。
|
|
30
|
+
- Claude Code: 最後の `custom-title`、無ければ最後の `ai-title`、どちらも無ければ止めた時点の依頼の最初の行。
|
|
31
|
+
- Codex: 前任の `name`、無ければ `preview`。
|
|
32
|
+
3. 概要は最初の行だけを1行にして、40字で切る。制御文字と二重引用符は落とす(名前は起動の引数として渡す。
|
|
33
|
+
Windows は PowerShell の shim を通る)。
|
|
34
|
+
4. 前任がこの形の名前を持つ後継なら、中の概要だけを取り出して使う。引き継ぎを重ねても project 名と印は
|
|
35
|
+
積み重ならない。継続の指示は概要にしない。Codex で前任が名前の無い後継(この版より前に立てたもの)の時は、
|
|
36
|
+
引き継ぎの記録をさかのぼって元のタスクの題を使う。
|
|
37
|
+
5. Claude Code へは `--name=<名前>` の形で渡す。フォルダ名が `-` で始まっても option として読まれない。
|
|
38
|
+
6. Codex は、後継を作った直後、記憶を注入する前に `thread/name/set` を呼ぶ。名前は表示だけに使うので、
|
|
39
|
+
断られても引き継ぎは止めず、worker のログに理由の code を残す。
|
|
40
|
+
|
|
41
|
+
## Consequences
|
|
42
|
+
|
|
43
|
+
- 名前に引き継ぎ ID が入らなくなる。後継の ID は今までどおり記録(Claude は `successor.short_id`、Codex は
|
|
44
|
+
`target_thread_id`)と `auto-handoff status` にある。Throughline は名前で後継を探していない。
|
|
45
|
+
- 同じ会話から引き継ぎを重ねると、後継は同じ名前になる。一覧では新しい順で見分ける。
|
|
46
|
+
- 概要は前任の題を写すだけなので、題が作業の中身からずれていれば、名前もずれる。
|
|
47
|
+
- 記録の schema は変えない。
|
package/package.json
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 自動継続が立てる後継(Claude の会話、Codex のタスク)の名前。
|
|
3
|
+
*
|
|
4
|
+
* 形は「project 名|作業の概要(自動引き継ぎ)」。一覧を見た時に、どの project の、何の作業の続きかを読める
|
|
5
|
+
* ようにする。作業の概要は前任の題から取り、AI は呼ばない。前任の題が取れない時は project 名と印だけにする。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const AUTO_HANDOFF_TITLE_MARK = '自動引き継ぎ';
|
|
9
|
+
const SEPARATOR = '|';
|
|
10
|
+
const SUFFIX = `(${AUTO_HANDOFF_TITLE_MARK})`;
|
|
11
|
+
// 一覧の幅で末尾の印が切れにくい長さに、概要を抑える。
|
|
12
|
+
const SUMMARY_MAX_CHARS = 40;
|
|
13
|
+
// 継続の指示(Claude・Codex とも、この語と引き継ぎ ID で始まる)。名前の付いていない後継は、これが題として見える。
|
|
14
|
+
const CONTINUATION_INPUT_PATTERN = /^Throughline自動継続 [0-9a-f]{8}-/;
|
|
15
|
+
|
|
16
|
+
/** project の場所から、名前に使うフォルダ名を取る。Windows の場所を他の OS で読んでも同じ値にする。 */
|
|
17
|
+
export function autoHandoffProjectName(projectPath) {
|
|
18
|
+
if (typeof projectPath !== 'string') return '';
|
|
19
|
+
return oneLine(projectPath.split(/[\\/]+/).filter(Boolean).at(-1) ?? '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function oneLine(text) {
|
|
23
|
+
// 起動の引数として渡すので、制御文字と改行を落とす。二重引用符は Windows の引数の受け渡しで崩れるので落とす。
|
|
24
|
+
return text.replace(/[\u0000-\u001f\u007f"]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function clip(text, maxChars) {
|
|
28
|
+
const chars = Array.from(text);
|
|
29
|
+
return chars.length <= maxChars ? text : `${chars.slice(0, maxChars - 1).join('').trimEnd()}…`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 前任の題や依頼から、作業の概要を取る。前任がこの形の名前を持つ後継なら、中の概要だけを取り出す
|
|
34
|
+
* (引き継ぎを重ねても、project 名と印が積み重ならない)。概要にならない値は null。
|
|
35
|
+
*/
|
|
36
|
+
export function autoHandoffSummaryOf(title) {
|
|
37
|
+
if (typeof title !== 'string') return null;
|
|
38
|
+
const firstLine = title.split(/\r?\n/).map(oneLine).find(Boolean) ?? '';
|
|
39
|
+
if (!firstLine || CONTINUATION_INPUT_PATTERN.test(firstLine)) return null;
|
|
40
|
+
if (firstLine.endsWith(SUFFIX)) {
|
|
41
|
+
const body = firstLine.slice(0, -SUFFIX.length);
|
|
42
|
+
const at = body.indexOf(SEPARATOR);
|
|
43
|
+
const inner = at < 0 ? '' : body.slice(at + SEPARATOR.length).trim();
|
|
44
|
+
return inner ? clip(inner, SUMMARY_MAX_CHARS) : null;
|
|
45
|
+
}
|
|
46
|
+
return clip(firstLine, SUMMARY_MAX_CHARS);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 後継の名前を作る。
|
|
51
|
+
* @param {{projectPath: string, titles?: Array<string|null|undefined>}} input titles は概要の候補(先頭から使えるものを採る)
|
|
52
|
+
*/
|
|
53
|
+
export function composeAutoHandoffTitle({ projectPath, titles = [] }) {
|
|
54
|
+
const project = autoHandoffProjectName(projectPath);
|
|
55
|
+
const summary = titles.map(autoHandoffSummaryOf).find(Boolean) ?? null;
|
|
56
|
+
return [project, summary].filter(Boolean).join(SEPARATOR) + SUFFIX;
|
|
57
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { autoHandoffProjectName, autoHandoffSummaryOf, composeAutoHandoffTitle } from './auto-handoff-title.mjs';
|
|
4
|
+
|
|
5
|
+
test('名前は「project 名|作業の概要(自動引き継ぎ)」の形にする', () => {
|
|
6
|
+
assert.equal(composeAutoHandoffTitle({ projectPath: '/Users/kite/Developer/BellTeam', titles: ['ASCからの連絡を確認'] }),
|
|
7
|
+
'BellTeam|ASCからの連絡を確認(自動引き継ぎ)');
|
|
8
|
+
assert.equal(composeAutoHandoffTitle({ projectPath: '/work/app', titles: [null, '', ' ', '2つ目の候補'] }),
|
|
9
|
+
'app|2つ目の候補(自動引き継ぎ)', '使える最初の候補を採る');
|
|
10
|
+
assert.equal(composeAutoHandoffTitle({ projectPath: '/work/app' }), 'app(自動引き継ぎ)', '概要が取れない時は project 名と印だけ');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('project 名は場所の最後のフォルダ名。Windows の場所を他の OS で読んでも同じ', () => {
|
|
14
|
+
assert.equal(autoHandoffProjectName('C:\\Users\\kite_\\Developer\\Throughline'), 'Throughline');
|
|
15
|
+
assert.equal(autoHandoffProjectName('C:\\Users\\kite_\\tl-claude-probe\\'), 'tl-claude-probe');
|
|
16
|
+
assert.equal(autoHandoffProjectName('/srv/bellteam/bots/bot-220e0f1b/'), 'bot-220e0f1b');
|
|
17
|
+
assert.equal(autoHandoffProjectName(null), '');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('前任がこの形の名前を持つ後継なら、中の概要だけを取り出す', () => {
|
|
21
|
+
const first = composeAutoHandoffTitle({ projectPath: '/work/app', titles: ['hookの失敗を直す'] });
|
|
22
|
+
assert.equal(autoHandoffSummaryOf(first), 'hookの失敗を直す');
|
|
23
|
+
assert.equal(composeAutoHandoffTitle({ projectPath: '/work/app', titles: [first] }), first, '引き継ぎを重ねても同じ名前');
|
|
24
|
+
assert.equal(composeAutoHandoffTitle({ projectPath: '/work/moved', titles: [first] }), 'moved|hookの失敗を直す(自動引き継ぎ)',
|
|
25
|
+
'project 名は今の場所から付け直す');
|
|
26
|
+
assert.equal(autoHandoffSummaryOf('app(自動引き継ぎ)'), null, '概要の無い名前からは、概要を作らない');
|
|
27
|
+
assert.equal(composeAutoHandoffTitle({ projectPath: '/work/app', titles: ['app(自動引き継ぎ)', '元の依頼'] }),
|
|
28
|
+
'app|元の依頼(自動引き継ぎ)');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('継続の指示は概要にしない(名前の無い後継は、これが題として見える)', () => {
|
|
32
|
+
const claude = 'Throughline自動継続 89903aed-a0a8-486e-9932-a10ce526d2d5\n注入された記憶と元のユーザー依頼に従い、未完了の作業をそのまま継続してください。';
|
|
33
|
+
const codex = 'Throughline自動継続 89903aed-a0a8-486e-9932-a10ce526d2d5 / b90f43ab-7056-4216-9737-a7430a6768ca\n注入された記憶と…';
|
|
34
|
+
assert.equal(autoHandoffSummaryOf(claude), null);
|
|
35
|
+
assert.equal(autoHandoffSummaryOf(codex), null);
|
|
36
|
+
assert.equal(composeAutoHandoffTitle({ projectPath: '/work/app', titles: [codex, 'ASCからの連絡を確認'] }),
|
|
37
|
+
'app|ASCからの連絡を確認(自動引き継ぎ)');
|
|
38
|
+
assert.equal(autoHandoffSummaryOf('Throughline自動継続の試験'), 'Throughline自動継続の試験', '人が付けた題は残す');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('概要は最初の行を1行にして、長さを抑える', () => {
|
|
42
|
+
assert.equal(autoHandoffSummaryOf('\n 1行目の 依頼\t文 \n2行目'), '1行目の 依頼 文');
|
|
43
|
+
assert.equal(autoHandoffSummaryOf('say "hello" & exit'), 'say hello & exit', '二重引用符は落とす');
|
|
44
|
+
const long = autoHandoffSummaryOf('あ'.repeat(100));
|
|
45
|
+
assert.equal(Array.from(long).length, 40);
|
|
46
|
+
assert.ok(long.endsWith('…'));
|
|
47
|
+
assert.equal(autoHandoffSummaryOf(long), long, '切った概要は、もう一度通しても変わらない');
|
|
48
|
+
assert.equal(Array.from(autoHandoffSummaryOf(`${'😀'.repeat(50)}`)).length, 40, '絵文字を途中で割らない');
|
|
49
|
+
assert.equal(autoHandoffSummaryOf(undefined), null);
|
|
50
|
+
});
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
21
21
|
import { homedir } from 'node:os';
|
|
22
|
-
import {
|
|
22
|
+
import { dirname, join, posix, resolve, win32 } from 'node:path';
|
|
23
23
|
import { randomUUID } from 'node:crypto';
|
|
24
24
|
import { fileURLToPath } from 'node:url';
|
|
25
25
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
@@ -38,6 +38,7 @@ import { resolveMergeTarget } from './session-merger.mjs';
|
|
|
38
38
|
import { writeBaton } from './baton.mjs';
|
|
39
39
|
import { sameProjectPath } from './project-path.mjs';
|
|
40
40
|
import { spawnPortable, spawnPortableSync } from './os/portable-spawn-sync.mjs';
|
|
41
|
+
import { composeAutoHandoffTitle } from './auto-handoff-title.mjs';
|
|
41
42
|
|
|
42
43
|
export const CLAUDE_AUTO_HANDOFF_SCHEMA = 'throughline.claude-auto-handoff.v2';
|
|
43
44
|
const CLI_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '../bin/throughline.mjs');
|
|
@@ -262,6 +263,17 @@ function readLatestModel(transcriptPath) {
|
|
|
262
263
|
return null;
|
|
263
264
|
}
|
|
264
265
|
|
|
266
|
+
/**
|
|
267
|
+
* 止める会話の題。人が付けた題を先に読む(Claude Desktop の題と、`--name` で付いた後継の名前もこの行に入る)。
|
|
268
|
+
* 無ければ Claude Code が付けた題を読む。
|
|
269
|
+
*/
|
|
270
|
+
function readSessionTitle(transcriptPath) {
|
|
271
|
+
const entries = readRawEntries(transcriptPath);
|
|
272
|
+
const latest = (type, key) => entries.findLast(entry => entry?.type === type &&
|
|
273
|
+
typeof entry[key] === 'string' && entry[key].trim())?.[key] ?? null;
|
|
274
|
+
return latest('custom-title', 'customTitle') ?? latest('ai-title', 'aiTitle');
|
|
275
|
+
}
|
|
276
|
+
|
|
265
277
|
const CONTINUATION_MARKER = /Throughline自動継続 ([0-9a-f-]{36})/;
|
|
266
278
|
|
|
267
279
|
// 後継へ渡す「このターンでここまでにしたこと」。1件の長さと件数を抑える。全文は取り込んだ details にある。
|
|
@@ -525,8 +537,15 @@ function readAcceptance(sessionId, handoffId, dir) {
|
|
|
525
537
|
return acceptance?.handoff_id === handoffId && typeof acceptance.accepted_at === 'number' ? acceptance : null;
|
|
526
538
|
}
|
|
527
539
|
|
|
540
|
+
/** 後継の名前。一覧で、どの project の何の作業の続きかを読めるようにする。概要は前任の題、無ければ元の依頼から取る。 */
|
|
541
|
+
function successorName(record) {
|
|
542
|
+
return composeAutoHandoffTitle({ projectPath: record.project_path,
|
|
543
|
+
titles: [record.transcript_path ? readSessionTitle(record.transcript_path) : null, record.in_flight?.user?.content] });
|
|
544
|
+
}
|
|
545
|
+
|
|
528
546
|
function successorArgs(record) {
|
|
529
|
-
|
|
547
|
+
// 名前は `--name=` の形で渡す。project のフォルダ名が `-` で始まっても、option として読まれない。
|
|
548
|
+
const args = ['--bg', `--name=${successorName(record)}`];
|
|
530
549
|
const { model, effort, permission_mode: permissionMode } = record.settings ?? {};
|
|
531
550
|
if (model) args.push('--model', model);
|
|
532
551
|
if (effort) args.push('--effort', effort);
|
|
@@ -278,9 +278,10 @@ test('worker: 継続の指示を最初の指示として付けて後継を同じ
|
|
|
278
278
|
assert.equal(calls.spawn.length, 1);
|
|
279
279
|
const { command, args, options } = calls.spawn[0];
|
|
280
280
|
assert.equal(command, 'claude');
|
|
281
|
-
assert.deepEqual(args, ['--bg', '--name',
|
|
281
|
+
assert.deepEqual(args, ['--bg', '--name=app(自動引き継ぎ)', '--effort', 'high',
|
|
282
282
|
'--permission-mode', 'acceptEdits', '--settings', '{"worktree":{"bgIsolation":"none"}}', claudeContinuationInput(record)],
|
|
283
|
-
'指示は起動時に渡す(外から送ると、権限のバイパス中の後継が承認まで止める)。モデルは transcript
|
|
283
|
+
'指示は起動時に渡す(外から送ると、権限のバイパス中の後継が承認まで止める)。モデルは transcript が無いので付かない。' +
|
|
284
|
+
'名前は、前任の題も依頼も読めないので project 名と印だけ');
|
|
284
285
|
assert.match(args.at(-1), new RegExp(`^Throughline自動継続 ${record.handoff_id}\\n注入された記憶と元のユーザー依頼に従い、未完了の作業をそのまま継続してください。`));
|
|
285
286
|
assert.equal(options.cwd, '/work/app');
|
|
286
287
|
assert.deepEqual(options.env, { PATH: '/bin', CLAUDE_CODE_USE_BEDROCK: '1' }, '会話ごとの環境変数は後継へ渡さない');
|
|
@@ -334,7 +335,8 @@ test('worker: 止めた時点の依頼とモデルを transcript から写し、
|
|
|
334
335
|
assert.equal(first.record.state, 'sent');
|
|
335
336
|
assert.equal(first.record.successor.short_id, 'a41a97ae');
|
|
336
337
|
assert.equal(first.record.settings.model, 'claude-opus-5');
|
|
337
|
-
assert.deepEqual(first.calls[0].slice(
|
|
338
|
+
assert.deepEqual(first.calls[0].slice(1, 4), ['--name=app|14個のファイルを順に読んで(自動引き継ぎ)', '--model', 'claude-opus-5'],
|
|
339
|
+
'前任に題が無い時は、元の依頼を名前の概要にする');
|
|
338
340
|
assert.deepEqual(first.record.in_flight, {
|
|
339
341
|
user: { content: '14個のファイルを順に読んで', timestamp: Date.parse('2026-10-04T03:00:10Z') },
|
|
340
342
|
last_fragment: { content: 'part05 を読みました。次に part06 を読みます。', timestamp: Date.parse('2026-10-04T03:00:14Z') },
|
|
@@ -351,6 +353,8 @@ test('worker: 止めた時点の依頼とモデルを transcript から写し、
|
|
|
351
353
|
// 後継 B が止められた時、最後の user 発言は A からの継続の指示。現在地には元の依頼を運ぶ
|
|
352
354
|
const transcriptB = join(dir, 'b.jsonl');
|
|
353
355
|
writeFileSync(transcriptB, jsonl([
|
|
356
|
+
// `--name` で立てた会話には、Claude Code が名前を題として書く
|
|
357
|
+
{ type: 'custom-title', customTitle: first.calls[0][1].slice('--name='.length), sessionId: SUCCESSOR },
|
|
354
358
|
user(claudeContinuationInput(first.record), '2026-10-04T03:01:00Z'),
|
|
355
359
|
assistant('part09 を読みました。次に part10 を読みます。', '2026-10-04T03:01:20Z', 'claude-opus-5'),
|
|
356
360
|
{ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'tool_use', id: 'toolu_10', name: 'Read', input: {} }] } },
|
|
@@ -365,6 +369,39 @@ test('worker: 止めた時点の依頼とモデルを transcript から写し、
|
|
|
365
369
|
stopped_tools_total: 1,
|
|
366
370
|
turn: null,
|
|
367
371
|
});
|
|
372
|
+
assert.equal(second.calls[0][1], first.calls[0][1], '引き継ぎを重ねても、project 名と印は積み重ならない');
|
|
373
|
+
}));
|
|
374
|
+
|
|
375
|
+
test('worker: 後継の名前は project 名・前任の題・自動引き継ぎの印で作る。人が付けた題を Claude Code の題より先に使う', () => withDir(async dir => {
|
|
376
|
+
const nameFor = async (sessionId, titleLines) => {
|
|
377
|
+
const transcriptPath = join(dir, `${sessionId}.jsonl`);
|
|
378
|
+
writeFileSync(transcriptPath, jsonl([
|
|
379
|
+
user('14個のファイルを順に読んで', '2026-10-04T03:00:10Z'),
|
|
380
|
+
...titleLines,
|
|
381
|
+
assistant('part04 を読みました。', '2026-10-04T03:00:12Z'),
|
|
382
|
+
]));
|
|
383
|
+
const db = makeBatonDb();
|
|
384
|
+
requestClaudeAutoHandoff({ payload: { session_id: sessionId, trigger: 'auto', cwd: '/work/app', transcript_path: transcriptPath },
|
|
385
|
+
env: {}, config: ENABLED, openDb: () => db, dir, platform: 'linux' });
|
|
386
|
+
await stopClaudeTurnForHandoff({ payload: { session_id: sessionId, cwd: '/work/app', transcript_path: transcriptPath },
|
|
387
|
+
dir, launchWorker: async () => {} });
|
|
388
|
+
let name = null;
|
|
389
|
+
await runClaudeAutoHandoffWorker(sessionId, { dir, env: {}, pollMs: 10, acceptTimeoutMs: 300, transcriptTimeoutMs: 50,
|
|
390
|
+
spawn: (command, args) => { name = args[1]; successorStarts(dir, sessionId)(args); return launchedOk(); } });
|
|
391
|
+
return name;
|
|
392
|
+
};
|
|
393
|
+
// 端末の会話: Claude Code が付けた題(新しい方)
|
|
394
|
+
assert.equal(await nameFor('aaaaaaaa-0000-4000-8000-000000000001', [
|
|
395
|
+
{ type: 'ai-title', aiTitle: 'ファイルの読み込み' }, { type: 'ai-title', aiTitle: '14ファイル順序読み込みと結果記録' },
|
|
396
|
+
]), '--name=app|14ファイル順序読み込みと結果記録(自動引き継ぎ)');
|
|
397
|
+
// Claude Desktop の会話と、人が名前を変えた会話: 題は custom-title に入る
|
|
398
|
+
assert.equal(await nameFor('aaaaaaaa-0000-4000-8000-000000000002', [
|
|
399
|
+
{ type: 'custom-title', customTitle: 'MCP共有ラッパー設計' }, { type: 'ai-title', aiTitle: '別の題' },
|
|
400
|
+
]), '--name=app|MCP共有ラッパー設計(自動引き継ぎ)');
|
|
401
|
+
// 改行・二重引用符・長い題は、1行の引数として渡せる形にする
|
|
402
|
+
assert.equal(await nameFor('aaaaaaaa-0000-4000-8000-000000000003', [
|
|
403
|
+
{ type: 'custom-title', customTitle: `"結果" を\n${'長'.repeat(60)}` },
|
|
404
|
+
]), '--name=app|結果 を(自動引き継ぎ)');
|
|
368
405
|
}));
|
|
369
406
|
|
|
370
407
|
function makeMemoryDb() {
|
|
@@ -18,6 +18,7 @@ import { ensureAutoHandoffSummaries, renderAutoHandoffMemory } from './codex-aut
|
|
|
18
18
|
import { CODEX_NATIVE_ID_PATTERN, CodexHandoffError, readCodexHandoffState,
|
|
19
19
|
settingsMatch, threadStartSettings } from './hosts/codex-handoff-state.mjs';
|
|
20
20
|
import { resolvePreparedSettings } from './hosts/codex-handoff-state.mjs';
|
|
21
|
+
import { composeAutoHandoffTitle } from './auto-handoff-title.mjs';
|
|
21
22
|
|
|
22
23
|
const CLI_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '../bin/throughline.mjs');
|
|
23
24
|
const stateRoot = () => join(homedir(), '.throughline', 'codex-auto-handoff');
|
|
@@ -120,6 +121,30 @@ async function sourceNativeState(operation, runtime) {
|
|
|
120
121
|
}, runtime);
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
/**
|
|
125
|
+
* 後継タスクの名前。一覧で、どの project の何の作業の続きかを読めるようにする。概要は前任の題から取る。
|
|
126
|
+
* 前任が名前の無い後継(題が継続の指示になっている)なら、引き継ぎをさかのぼって元のタスクの題を使う。
|
|
127
|
+
*/
|
|
128
|
+
export function autoHandoffTargetName(db, operation) {
|
|
129
|
+
const titles = [];
|
|
130
|
+
for (let current = operation, depth = 0; current && depth < 20; depth++) {
|
|
131
|
+
titles.push(current.runtime?.title);
|
|
132
|
+
current = current.previous_handoff_id ? getAutoHandoff(db, current.previous_handoff_id) : null;
|
|
133
|
+
}
|
|
134
|
+
return composeAutoHandoffTitle({ projectPath: operation.project_path, titles });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 後継タスクに名前を付ける。名前は表示だけに使うので、付けられなくても引き継ぎは止めない(理由は worker のログに残す)。 */
|
|
138
|
+
export async function nameAutoHandoffTarget(request, targetId, name) {
|
|
139
|
+
try {
|
|
140
|
+
await request('thread/name/set', { threadId: targetId, name });
|
|
141
|
+
return true;
|
|
142
|
+
} catch (cause) {
|
|
143
|
+
process.stderr.write(`[auto-handoff] thread/name/set failed: ${cause?.delivery_code ?? cause?.code ?? 'unknown'}\n`);
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
123
148
|
async function createTarget(db, operation, runtime) {
|
|
124
149
|
const targetId = await withCodexReceiver(autoHandoffDeliveryProfile, { thread_id: operation.source_thread_id, codex_home: operation.codex_home },
|
|
125
150
|
async request => {
|
|
@@ -133,6 +158,8 @@ async function createTarget(db, operation, runtime) {
|
|
|
133
158
|
if (!CODEX_NATIVE_ID_PATTERN.test(targetId ?? '') || !sameProjectPath(start.cwd, operation.project_path)) throw error('handoff_target_response_invalid');
|
|
134
159
|
updateAutoHandoff(db, operation.handoff_id, { target_thread_id: targetId, mutation_stage: 'inject' });
|
|
135
160
|
}
|
|
161
|
+
// 最初のターンの前に付ける。付けないと、継続の指示がそのままタスクの題として見える。
|
|
162
|
+
await nameAutoHandoffTarget(request, targetId, autoHandoffTargetName(db, operation));
|
|
136
163
|
if (!operation.runtime.targetMemoryInjected) {
|
|
137
164
|
const text = renderAutoHandoffMemory(db, operation);
|
|
138
165
|
await request('thread/inject_items', { threadId: targetId, items: [{ type: 'message', role: 'developer',
|
|
@@ -10,7 +10,8 @@ import { requestAutoHandoff, updateAutoHandoff, getAutoHandoff, freezeCodexMemor
|
|
|
10
10
|
import { collectAutoHandoffMemory, ensureAutoHandoffSummaries, renderAutoHandoffMemory,
|
|
11
11
|
renderFrozenDetail } from './codex-auto-handoff-memory.mjs';
|
|
12
12
|
import { readCodexHandoffState, settingsMatch, threadStartSettings } from './hosts/codex-handoff-state.mjs';
|
|
13
|
-
import { runAutoHandoffWorker, requestCodexAutoHandoff, sourceBoundary
|
|
13
|
+
import { runAutoHandoffWorker, requestCodexAutoHandoff, sourceBoundary,
|
|
14
|
+
autoHandoffTargetName, nameAutoHandoffTarget } from './codex-auto-handoff.mjs';
|
|
14
15
|
import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
|
|
15
16
|
|
|
16
17
|
async function withDb(fn) {
|
|
@@ -291,3 +292,30 @@ test('forkした子の先頭metadataを保持し、継承された親metadataで
|
|
|
291
292
|
assert.equal(state.stoppedAt, state.turnStartAt);
|
|
292
293
|
assert.throws(() => readCodexHandoffState(file, { threadId: 'parent' }), /handoff_thread_mismatch/);
|
|
293
294
|
}));
|
|
295
|
+
|
|
296
|
+
test('後継タスクの名前は project 名・前任の題・自動引き継ぎの印で作り、名前の無い後継からは元のタスクの題をさかのぼる', async () => withDb(async db => {
|
|
297
|
+
const named = (operation, title) => updateAutoHandoff(db, operation.handoff_id, { runtime_json: { projectId: null, title } }).operation;
|
|
298
|
+
const a = named(request(db, 'A', { projectPath: '/Users/kite/Developer/BellTeam' }).operation, 'ASCからの連絡を確認');
|
|
299
|
+
assert.equal(autoHandoffTargetName(db, a), 'BellTeam|ASCからの連絡を確認(自動引き継ぎ)');
|
|
300
|
+
|
|
301
|
+
// A の後継 B は、名前を付けない版が作った。題は継続の指示として見える
|
|
302
|
+
updateAutoHandoff(db, a.handoff_id, { target_thread_id: 'B' });
|
|
303
|
+
const b = named(request(db, 'B', { projectPath: '/Users/kite/Developer/BellTeam' }).operation, continuationInput(a));
|
|
304
|
+
assert.equal(b.previous_handoff_id, a.handoff_id);
|
|
305
|
+
assert.equal(autoHandoffTargetName(db, b), 'BellTeam|ASCからの連絡を確認(自動引き継ぎ)');
|
|
306
|
+
|
|
307
|
+
// B の後継 C は、この形の名前を持つ。重ねても同じ名前になる
|
|
308
|
+
updateAutoHandoff(db, b.handoff_id, { target_thread_id: 'C' });
|
|
309
|
+
const c = named(request(db, 'C', { projectPath: '/Users/kite/Developer/BellTeam' }).operation, autoHandoffTargetName(db, b));
|
|
310
|
+
assert.equal(autoHandoffTargetName(db, c), 'BellTeam|ASCからの連絡を確認(自動引き継ぎ)');
|
|
311
|
+
|
|
312
|
+
// 題がどこにも無い時は project 名と印だけ
|
|
313
|
+
const lone = named(request(db, 'D', { projectPath: 'C:\\Users\\kite_\\Throughline' }).operation, '');
|
|
314
|
+
assert.equal(autoHandoffTargetName(db, lone), 'Throughline(自動引き継ぎ)');
|
|
315
|
+
|
|
316
|
+
const sent = [];
|
|
317
|
+
assert.equal(await nameAutoHandoffTarget(async (method, params) => { sent.push([method, params]); return {}; }, 'T', 'BellTeam(自動引き継ぎ)'), true);
|
|
318
|
+
assert.deepEqual(sent, [['thread/name/set', { threadId: 'T', name: 'BellTeam(自動引き継ぎ)' }]]);
|
|
319
|
+
const rejected = async () => { throw Object.assign(new Error('unknown method'), { delivery_code: 'CODEX_RECEIVER_REJECTED' }); };
|
|
320
|
+
assert.equal(await nameAutoHandoffTarget(rejected, 'T', 'x'), false, '名前を付けられなくても、引き継ぎは止めない');
|
|
321
|
+
}));
|