throughline 0.16.31 → 0.16.33

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.
@@ -0,0 +1,536 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import {
9
+ CLAUDE_FORWARD_CONTEXT_MAX_CHARS,
10
+ CLAUDE_FORWARD_MARK,
11
+ claimClaudeForwards,
12
+ claudeForwardCarriesHumanInput,
13
+ claudeForwardWaiterAlive,
14
+ registerClaudeForwardWaiter,
15
+ claudeForwardInboxDir,
16
+ acquireClaudeWakeClaim,
17
+ enqueueClaudeForward,
18
+ ensureClaudeForwardInbox,
19
+ holdClaudeForward,
20
+ hasPendingClaudeForwards,
21
+ moveClaudeForwards,
22
+ readClaudeForwardStatus,
23
+ removeStaleClaudeForwards,
24
+ settleClaudeForwards,
25
+ takeClaudeForwardsAsContext,
26
+ unwrapClaudeForwardedDelivery,
27
+ } from './claude-forward.mjs';
28
+ import { waitForClaudeForwards } from './claude-forward-wait.mjs';
29
+ import { CLAUDE_WAKE_TEXT_MAX_CHARS, acceptClaudeAutoContinuation, publicClaudeAutoHandoff, readClaudeSessionStopped, redirectClaudeHandedOffPrompt } from './claude-auto-handoff.mjs';
30
+ import { MID_TURN_INPUT_MARK, readTranscript } from './transcript-reader.mjs';
31
+
32
+ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
33
+ const CLI = join(REPO_ROOT, 'bin', 'throughline.mjs');
34
+ const OLD = 'aaaaaaaa-0000-4000-8000-000000000041';
35
+ const NEXT = 'bbbbbbbb-0000-4000-8000-000000000042';
36
+ const LAST = 'cccccccc-0000-4000-8000-000000000043';
37
+
38
+ // 一覧を読めない `claude`(試験では本物の claude を呼ばない)
39
+ const NO_CLAUDE = () => ({ error: Object.assign(new Error('spawn claude ENOENT'), { code: 'ENOENT' }) });
40
+ // 後継の transcript を、止まってしばらく経った物にする(書かれたばかりの transcript の後継は、動いていると見なす)
41
+ const settled = path => { const past = new Date(Date.now() - 60_000); utimesSync(path, past, past); };
42
+
43
+ function withDir(fn) {
44
+ const dir = mkdtempSync(join(tmpdir(), 'tl-claude-forward-'));
45
+ const finish = () => rmSync(dir, { recursive: true, force: true });
46
+ let result;
47
+ try { result = fn(dir); }
48
+ catch (error) { finish(); throw error; }
49
+ if (result && typeof result.then === 'function') return result.finally(finish);
50
+ finish();
51
+ return result;
52
+ }
53
+
54
+ const put = (dir, to, text, { kind = 'prompt', now = 1_760_000_000_000, from = OLD } = {}) =>
55
+ enqueueClaudeForward({ successorSessionId: to, sourceSessionId: from, handoffId: 'h-1', kind, text, now, dir });
56
+ const forwardLog = dir => existsSync(join(dir, 'forward.jsonl'))
57
+ ? readFileSync(join(dir, 'forward.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line)) : [];
58
+ const jsonl = entries => entries.map(entry => JSON.stringify(entry)).join('\n') + '\n';
59
+
60
+ test('置き場: 届いた順に1つの文にまとめて受け取り、出力した後で消す。本文は記録へ書かない', () => withDir(dir => {
61
+ assert.equal(claimClaudeForwards({ sessionId: NEXT, dir }), null, '置き場が無い');
62
+ assert.equal(put(dir, NEXT, ' '), false, '空の入力は置かない');
63
+ assert.equal(put(dir, '../escape', '文'), false, '置き場の名前に使えない会話 ID');
64
+ assert.equal(put(dir, NEXT, '1通目の依頼', { now: 1_760_000_000_000 }), true);
65
+ assert.equal(put(dir, NEXT, '<task-notification>\n<task-id>b1</task-id>\n<status>completed</status>\n</task-notification>',
66
+ { kind: 'task-notification', now: 1_760_000_001_000 }), true);
67
+ assert.equal(put(dir, NEXT, '2通目の依頼', { now: 1_760_000_002_000 }), true);
68
+ assert.equal(hasPendingClaudeForwards(NEXT, dir), true);
69
+ assert.deepEqual(readClaudeForwardStatus(NEXT, { dir }), { pending: 3, unknown: 0 });
70
+
71
+ const claim = claimClaudeForwards({ sessionId: NEXT, dir });
72
+ assert.deepEqual([claim.count, claim.humans, claim.notifications], [3, 2, 1]);
73
+ const lines = claim.text.split('\n');
74
+ assert.equal(lines[0], `${CLAUDE_FORWARD_MARK}引き継ぎ前の会話(aaaaaaaa)へ届いた入力を、Throughlineがこの会話へ運びました(利用者の入力 2 通、裏の作業の通知 1 通)。作業はこの会話が引き継いでいます。引き継ぎID: h-1`);
75
+ assert.match(lines[1], /^--- 利用者の入力(\d{4}-\d\d-\d\d \d\d:\d\d に届いた。利用者がこの会話へ打った入力として扱ってください)---$/);
76
+ assert.equal(lines[2], '1通目の依頼');
77
+ assert.match(lines[3], /^--- 裏の作業の通知(.*引き継ぎ前の会話が裏で起こした作業の通知です。結果が要る時は、出力の file を読んで確かめてください)---$/);
78
+ assert.equal(lines.at(-1), '2通目の依頼');
79
+ assert.equal(claimClaudeForwards({ sessionId: NEXT, dir }), null, '受け取った入力は、もう受け取れない');
80
+ assert.deepEqual(readClaudeForwardStatus(NEXT, { dir }), { pending: 0, unknown: 0 }, '受け取った直後は、届いたか分からない数に入れない');
81
+ settleClaudeForwards(claim);
82
+ assert.deepEqual(readdirSync(join(claudeForwardInboxDir(NEXT, dir), 'claimed')), []);
83
+ }));
84
+
85
+ test('置き場: 受け取った後で出力する前に落ちた入力は、置き場へ戻らない(届いたか分からない数に出る)', () => withDir(dir => {
86
+ put(dir, NEXT, '落ちる前に受け取られた依頼');
87
+ const claim = claimClaudeForwards({ sessionId: NEXT, dir });
88
+ assert.equal(claim.count, 1);
89
+ // 出力も片付けもせずに落ちた事にする
90
+ const old = new Date(Date.now() - 120_000);
91
+ utimesSync(claim.paths[0], old, old);
92
+ assert.equal(claimClaudeForwards({ sessionId: NEXT, dir }), null, '入れ直さない');
93
+ assert.deepEqual(readClaudeForwardStatus(NEXT, { dir }), { pending: 0, unknown: 1 });
94
+ }));
95
+
96
+ test('置き場: 文脈として足す道は、9,000 字に収まる分だけを届いた順に運ぶ。収まらない入力は番の終わりの道に残す', () => withDir(dir => {
97
+ put(dir, NEXT, '短い依頼', { now: 1_760_000_000_000 });
98
+ put(dir, NEXT, 'あ'.repeat(CLAUDE_FORWARD_CONTEXT_MAX_CHARS), { now: 1_760_000_001_000 });
99
+ put(dir, NEXT, '長い依頼の後ろの短い依頼', { now: 1_760_000_002_000 });
100
+ const first = takeClaudeForwardsAsContext({ sessionId: NEXT, via: 'pre-tool-use', dir });
101
+ assert.match(first.text, /短い依頼$/);
102
+ assert.equal(first.text.length <= CLAUDE_FORWARD_CONTEXT_MAX_CHARS, true);
103
+ first.done();
104
+ assert.equal(takeClaudeForwardsAsContext({ sessionId: NEXT, via: 'pre-tool-use', dir }), null,
105
+ '先頭の1通が収まらない時は、後ろの短い入力も運ばない(順番を入れ替えない)');
106
+ assert.deepEqual(readClaudeForwardStatus(NEXT, { dir }), { pending: 2, unknown: 0 });
107
+ const rest = claimClaudeForwards({ sessionId: NEXT, dir });
108
+ assert.equal(rest.count, 2, '番の終わりの道は、長さに関わらず全部を運ぶ');
109
+ assert.equal(rest.text.includes('あ'.repeat(CLAUDE_FORWARD_CONTEXT_MAX_CHARS)), true);
110
+ assert.deepEqual(forwardLog(dir).map(row => [row.event, row.via, row.count, row.humans, row.notifications]), [['delivered', 'pre-tool-use', 1, 1, 0]]);
111
+ assert.equal(JSON.stringify(forwardLog(dir)).includes('短い依頼'), false, '運んだ記録に本文は書かない');
112
+ }));
113
+
114
+ test('置き場: 番の終わりの道で 60,000 字を超える1通は、頭と全文の file の場所を運ぶ', () => withDir(dir => {
115
+ const long = '長'.repeat(60_001);
116
+ put(dir, NEXT, long);
117
+ const claim = claimClaudeForwards({ sessionId: NEXT, dir });
118
+ const full = /全 60001 字の全文: (.+))$/.exec(claim.text)?.[1];
119
+ assert.equal(claim.text.length < 5000, true);
120
+ assert.equal(readFileSync(full, 'utf8'), long);
121
+ }));
122
+
123
+ test('置き場: さらに引き継がれた会話の置き場に残った入力は、今の続きの会話の置き場へ移せる', () => withDir(dir => {
124
+ put(dir, NEXT, '残っていた依頼');
125
+ assert.equal(moveClaudeForwards({ fromSessionId: NEXT, toSessionId: LAST, dir }), 1);
126
+ assert.equal(hasPendingClaudeForwards(NEXT, dir), false);
127
+ assert.match(claimClaudeForwards({ sessionId: LAST, dir }).text, /残っていた依頼$/);
128
+ assert.equal(moveClaudeForwards({ fromSessionId: NEXT, toSessionId: NEXT, dir }), 0);
129
+ }));
130
+
131
+ test('置き場の片付け: 7日を過ぎた入力は届けずに消す。後継として指されていない空の置き場は、24 時間動きが無ければ消す', () => withDir(dir => {
132
+ const now = Date.now();
133
+ const age = (path, ms) => { const at = new Date(now - ms); utimesSync(path, at, at); };
134
+ put(dir, NEXT, '古い依頼');
135
+ put(dir, LAST, '新しい依頼');
136
+ ensureClaudeForwardInbox(OLD, dir);
137
+ const day = 24 * 60 * 60 * 1000;
138
+ const nextInbox = claudeForwardInboxDir(NEXT, dir);
139
+ for (const name of readdirSync(nextInbox)) age(join(nextInbox, name), 8 * day);
140
+ age(nextInbox, 2 * day);
141
+ age(claudeForwardInboxDir(OLD, dir), 2 * day);
142
+ removeStaleClaudeForwards({ dir, now, keepSessionIds: new Set([OLD]) });
143
+ assert.deepEqual(readdirSync(nextInbox), [], '古い入力は届けずに消す');
144
+ // 入力を消した時に置き場のフォルダの時刻が進むので、空の置き場が消えるのは、その 24 時間後の片付け
145
+ age(nextInbox, 2 * day);
146
+ removeStaleClaudeForwards({ dir, now, keepSessionIds: new Set([OLD]) });
147
+ assert.equal(existsSync(nextInbox), false, '指されていない空の置き場は、24 時間動きが無ければ消す');
148
+ assert.equal(existsSync(claudeForwardInboxDir(OLD, dir)), true, '後継として指されている会話の置き場は、空でも残す');
149
+ assert.equal(hasPendingClaudeForwards(LAST, dir), true, '新しい入力は残す');
150
+ assert.deepEqual(forwardLog(dir).map(row => [row.event, row.session_id, row.count]), [['expired', NEXT, 1]]);
151
+ }));
152
+
153
+ test('待つ hook: 入力が届くまで待ち、届いたら受け取って終わる。期限・会話が居なくなった時は、受け取らずに終わる', () => withDir(async dir => {
154
+ ensureClaudeForwardInbox(NEXT, dir);
155
+ let ticks = 0;
156
+ const delivered = await waitForClaudeForwards({ sessionId: NEXT, dir, pollMs: 1,
157
+ sleep: async () => { if (++ticks === 3) put(dir, NEXT, '待っている間に届いた依頼'); } });
158
+ assert.deepEqual([delivered.code, delivered.reason, delivered.count], [2, 'delivered', 1]);
159
+ assert.match(delivered.text, /待っている間に届いた依頼$/);
160
+ assert.equal(ticks, 3);
161
+
162
+ let clock = 0;
163
+ const deadline = await waitForClaudeForwards({ sessionId: NEXT, dir, maxWaitMs: 5000, now: () => clock, sleep: async () => { clock += 1000; } });
164
+ assert.deepEqual([deadline.code, deadline.reason], [0, 'deadline']);
165
+ put(dir, NEXT, '会話が居なくなる前から在った依頼');
166
+ const first = await waitForClaudeForwards({ sessionId: NEXT, dir, isOrphaned: () => true, sleep: async () => {} });
167
+ assert.equal(first.reason, 'delivered', '在る入力は、先に受け取る');
168
+ const orphaned = await waitForClaudeForwards({ sessionId: NEXT, dir, isOrphaned: () => true, sleep: async () => {} });
169
+ assert.deepEqual([orphaned.code, orphaned.reason], [0, 'orphaned']);
170
+ assert.deepEqual(forwardLog(dir).map(row => [row.event, row.reason]),
171
+ [['wait-end', 'delivered'], ['wait-end', 'deadline'], ['wait-end', 'delivered'], ['wait-end', 'orphaned']]);
172
+ }));
173
+
174
+ test('待つ hook: 同じ会話で新しい待つ hook が立ったら、前の hook は受け取らずに退く(待つのは1つだけ)', () => withDir(async dir => {
175
+ ensureClaudeForwardInbox(NEXT, dir);
176
+ let release;
177
+ const gate = new Promise(resolve => { release = resolve; });
178
+ let firstTicks = 0;
179
+ const first = waitForClaudeForwards({ sessionId: NEXT, dir, token: 'first', sleep: async () => { if (++firstTicks === 1) await gate; } });
180
+ await new Promise(resolve => setImmediate(resolve));
181
+ // 次の番の終わりの hook が立つ。入力はその後に届く
182
+ let secondTicks = 0;
183
+ const second = waitForClaudeForwards({ sessionId: NEXT, dir, token: 'second',
184
+ sleep: async () => { if (++secondTicks === 1) { put(dir, NEXT, '新しい hook が受け取る依頼'); release(); await new Promise(resolve => setImmediate(resolve)); } } });
185
+ const [a, b] = await Promise.all([first, second]);
186
+ assert.deepEqual([a.code, a.reason], [0, 'superseded']);
187
+ assert.deepEqual([b.code, b.reason, b.count], [2, 'delivered', 1]);
188
+ assert.deepEqual(readClaudeForwardStatus(NEXT, { dir }).pending, 0);
189
+ }));
190
+
191
+ test('待つ hook: 自分の会話がさらに引き継がれたら、残った入力を今の続きの会話へ移して終わる(旧い会話を起こさない)', () => withDir(async dir => {
192
+ const projects = join(dir, 'claude', 'projects', '-work-app');
193
+ mkdirSync(projects, { recursive: true });
194
+ writeFileSync(join(projects, `${LAST}.jsonl`), '');
195
+ writeFileSync(join(dir, `${NEXT}.json`), JSON.stringify({ schema: 'throughline.claude-auto-handoff.v2', handoff_id: 'h-next',
196
+ source_session_id: NEXT, project_path: '/work/app', transcript_path: join(projects, `${NEXT}.jsonl`), state: 'sent',
197
+ requested_at: 1_000, updated_at: 2_000, error_code: null, successor: { short_id: 'cccccccc', session_id: LAST } }));
198
+ put(dir, NEXT, '引き継ぎの直前に届いた依頼');
199
+ const result = await waitForClaudeForwards({ sessionId: NEXT, dir, sleep: async () => { throw new Error('must not wait'); } });
200
+ assert.deepEqual([result.code, result.reason, result.count], [0, 'handed-off', 1]);
201
+ assert.equal(hasPendingClaudeForwards(NEXT, dir), false);
202
+ assert.match(claimClaudeForwards({ sessionId: LAST, dir }).text, /引き継ぎの直前に届いた依頼$/);
203
+ }));
204
+
205
+ test('旧い会話の側: 止めた入力を今の続きの会話の置き場へ置く。置けない時は、後継で打ち直す文を返す', () => withDir(dir => {
206
+ const projects = join(dir, 'claude', 'projects', '-work-app');
207
+ mkdirSync(projects, { recursive: true });
208
+ const record = (id, fields) => writeFileSync(join(dir, `${id}.json`), JSON.stringify({ schema: 'throughline.claude-auto-handoff.v2',
209
+ handoff_id: `h-${id.slice(0, 8)}`, source_session_id: id, project_path: '/work/app', transcript_path: join(projects, `${id}.jsonl`),
210
+ requested_at: 1_000, updated_at: 2_000, error_code: null, state: 'sent', ...fields }));
211
+ const submit = (id, prompt) => redirectClaudeHandedOffPrompt({ payload: { session_id: id, prompt }, now: 1_760_000_000_000, dir, env: {}, spawn: NO_CLAUDE });
212
+ record(OLD, { successor: { short_id: 'bbbbbbbb', session_id: NEXT } });
213
+ writeFileSync(join(projects, `${NEXT}.jsonl`), '');
214
+ settled(join(projects, `${NEXT}.jsonl`));
215
+
216
+ const human = submit(OLD, '旧い会話へ打った文');
217
+ assert.equal(human.forwarded, true);
218
+ assert.match(human.output.reason, /届いた入力は、後継の会話へ渡しました。後継は今、入力を待ち受けていません(止まっているか、この版より前に立った会話です)。後継を開いて何か入力した時に届きます: claude attach bbbbbbbb(一覧に無い時は claude --resume bbbbbbbb-0000-4000-8000-000000000042)$/);
219
+ // 後継で待つ hook が生きている時は、後継が動いていると返す。hook の process が居なくなった印は、生きていると読まない
220
+ registerClaudeForwardWaiter(NEXT, `${process.pid}-1-test`, dir);
221
+ assert.match(submit(OLD, '動いている後継へ打った文').output.reason, /届いた入力は、後継の会話へ渡しました。後継は動いています。手すきならすぐ、作業中なら次の道具の前か番の終わりに届きます。後継のモデルが、運ばれた入力を利用者の依頼として扱わない事があります。答えは後継で確かめてください: claude attach bbbbbbbb/);
222
+ registerClaudeForwardWaiter(NEXT, '999999999-1-gone', dir);
223
+ assert.equal(claudeForwardWaiterAlive(NEXT, dir), false);
224
+ // ここまでの2通のうち1通を受け取って、後ろの数え方を元に戻す
225
+ settleClaudeForwards(claimClaudeForwards({ sessionId: NEXT, dir }));
226
+ put(dir, NEXT, '数え直しの1通');
227
+ assert.equal(submit(OLD, '<task-notification>\n<task-id>b1</task-id>\n<status>completed</status>\n</task-notification>').forwarded, true);
228
+ assert.deepEqual(publicClaudeAutoHandoff(JSON.parse(readFileSync(join(dir, `${OLD}.json`), 'utf8')), { dir }).forward_pending, 2);
229
+
230
+ // 後継がさらに引き継がれている時は、その先の置き場へ置く
231
+ record(NEXT, { state: 'unknown', successor: { short_id: 'cccccccc', session_id: null } });
232
+ writeFileSync(join(projects, `${LAST}.jsonl`), '');
233
+ settled(join(projects, `${LAST}.jsonl`));
234
+ assert.equal(submit(OLD, '後継の先へ届く文').forwarded, true);
235
+ assert.deepEqual(readClaudeForwardStatus(NEXT, { dir }).pending, 2);
236
+ assert.match(claimClaudeForwards({ sessionId: LAST, dir }).text, /引き継ぎ前の会話(aaaaaaaa).*\n後継の先へ届く文$/s);
237
+
238
+ // ほかの会話から届いた文は、利用者の入力と分けて運ぶ
239
+ assert.equal(submit(OLD, 'Another Claude session sent a message:\n確認をお願いします\n\nThis came from another Claude session').forwarded, true);
240
+ const peer = claimClaudeForwards({ sessionId: LAST, dir });
241
+ assert.match(peer.text, /(ほかの会話からの文 1 通)。.*\n--- ほかの会話からの文([^)]*利用者の入力ではありません)---\nAnother Claude session sent a message:\n確認をお願いします/s);
242
+ assert.deepEqual([peer.humans, peer.notifications], [1, 0]);
243
+ assert.equal(claudeForwardCarriesHumanInput(peer.text), true, 'ほかの会話からの文も、届いた入力として記憶へ残す');
244
+
245
+ // 空の入力と slash command は運ばない。その時は 0.16.31 と同じ文
246
+ for (const prompt of ['', '/tl', ' /clear', '/compact 要点だけ']) {
247
+ const kept = submit(OLD, prompt);
248
+ assert.equal(kept.forwarded, false, prompt);
249
+ assert.match(kept.output.reason, /続きは後継の会話で行ってください: claude attach cccccccc/);
250
+ }
251
+ assert.equal(hasPendingClaudeForwards(LAST, dir), false);
252
+ assert.equal(submit(OLD, '/etc/hosts を見て').forwarded === false, true, '頭が / の文は、命令と見分けられないので運ばない');
253
+ const redirects = readFileSync(join(dir, 'redirects.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line));
254
+ assert.deepEqual(redirects.map(row => [row.kind, row.forwarded]), [['prompt', true], ['prompt', true], ['task-notification', true], ['prompt', true],
255
+ ['prompt', true], ['prompt', false], ['prompt', false], ['prompt', false], ['prompt', false], ['prompt', false]]);
256
+ assert.equal(readFileSync(join(dir, 'redirects.jsonl'), 'utf8').includes('旧い会話へ打った文'), false, '止めた記録に本文は書かない');
257
+ }));
258
+
259
+ test('後継の側: 継続の指示を受領した時に、置き場が出来る(番の終わりの待つ hook が待つ様になる)', () => withDir(dir => {
260
+ writeFileSync(join(dir, `${OLD}.json`), JSON.stringify({ schema: 'throughline.claude-auto-handoff.v2', handoff_id: 'h-accept',
261
+ source_session_id: OLD, project_path: '/work/app', transcript_path: null, state: 'launching', requested_at: 1_000, updated_at: 2_000,
262
+ error_code: null, successor: null }));
263
+ assert.equal(existsSync(claudeForwardInboxDir(NEXT, dir)), false);
264
+ const accepted = acceptClaudeAutoContinuation({ predecessorId: OLD, successorSessionId: NEXT, prompt: 'Throughline自動継続 h-accept\n続けてください', dir });
265
+ assert.equal(accepted.handoffId, 'h-accept');
266
+ assert.equal(existsSync(claudeForwardInboxDir(NEXT, dir)), true);
267
+ }));
268
+
269
+ test('記憶: 運んだ利用者の入力を、後継の記録の中で利用者の発言として読む。完了通知だけを文脈で運んだ分は足さない', () => withDir(dir => {
270
+ const human = `${CLAUDE_FORWARD_MARK}引き継ぎ前の会話(aaaaaaaa)へ届いた入力を、Throughlineがこの会話へ運びました(利用者の入力 1 通)。作業はこの会話が引き継いでいます。\n` +
271
+ '--- 利用者の入力(2026-10-14 08:00 に届いた。利用者がこの会話へ打った入力として扱ってください)---\n運ばれた依頼';
272
+ const note = `${CLAUDE_FORWARD_MARK}引き継ぎ前の会話(aaaaaaaa)へ届いた入力を、Throughlineがこの会話へ運びました(裏の作業の通知 1 通)。作業はこの会話が引き継いでいます。\n` +
273
+ '--- 裏の作業の通知(2026-10-14 08:01 に届いた。引き継ぎ前の会話が裏で起こした作業の通知です。結果が要る時は、出力の file を読んで確かめてください)---\n<task-notification>\n<status>completed</status>\n</task-notification>';
274
+ const wrapped = text => `<task-notification>\n<summary>Stop hook feedback</summary>\n</task-notification>\n<system-reminder>\nStop hook blocking error from command "throughline claude-forward-wait": ${text}\n</system-reminder>`;
275
+ assert.equal(unwrapClaudeForwardedDelivery(wrapped(human)), human);
276
+ assert.equal(unwrapClaudeForwardedDelivery('<task-notification>\n<summary>Stop hook feedback</summary>\n</task-notification>\n<system-reminder>\nほかの hook の文\n</system-reminder>'), null);
277
+ assert.equal(unwrapClaudeForwardedDelivery(human), null, '包みの無い文は、そのまま読む');
278
+ assert.equal(claudeForwardCarriesHumanInput(human), true);
279
+ assert.equal(claudeForwardCarriesHumanInput(note), false);
280
+
281
+ const transcript = join(dir, 't.jsonl');
282
+ const user = (text, extra = {}) => ({ type: 'user', timestamp: '2026-10-14T00:00:00.000Z', ...extra, message: { role: 'user', content: text } });
283
+ const assistant = text => ({ type: 'assistant', timestamp: '2026-10-14T00:00:01.000Z', message: { role: 'assistant', content: [{ type: 'text', text }] } });
284
+ const context = text => ({ type: 'attachment', attachment: { type: 'hook_additional_context', content: [text], hookName: 'PreToolUse', hookEvent: 'PreToolUse' } });
285
+ writeFileSync(transcript, jsonl([
286
+ user('最初の依頼', { origin: { kind: 'human' } }),
287
+ context(human),
288
+ context(note),
289
+ context('ほかの hook が足した文脈'),
290
+ // 前の番から待っていた hook が、作業中に運んだ入力。Claude Code が通知の形で順番待ちへ入れる
291
+ { type: 'attachment', attachment: { type: 'queued_command', prompt: wrapped(human.replace('運ばれた依頼', '作業中に運ばれた依頼')), origin: { kind: 'task-notification', producer: 'session-task' } } },
292
+ { type: 'attachment', attachment: { type: 'queued_command', prompt: wrapped(note), origin: { kind: 'task-notification', producer: 'session-task' } } },
293
+ // 道具の前の hook の出力そのもの(JSON)も hook_success で残る。運んだ文で始まらないので、足さない
294
+ { type: 'attachment', attachment: { type: 'hook_success', hookEvent: 'PreToolUse', content: JSON.stringify({ hookSpecificOutput: { additionalContext: human } }) } },
295
+ assistant('1つめの答え'),
296
+ user(wrapped(human), { origin: { kind: 'task-notification', producer: 'session-task' } }),
297
+ assistant('2つめの答え'),
298
+ // 止まっていた後継を開いて入力した時: 入力の hook が stdout へ出した文脈は、入力の行の後ろに hook_success で残る
299
+ user('後継を開いて打った依頼', { origin: { kind: 'human' } }),
300
+ { type: 'attachment', attachment: { type: 'hook_success', hookEvent: 'UserPromptSubmit', content: `${human}\n`, stdout: `${human}\n` } },
301
+ { type: 'attachment', attachment: { type: 'hook_success', hookEvent: 'UserPromptSubmit', content: `${note}\n` } },
302
+ assistant('3つめの答え'),
303
+ ]));
304
+ const turns = readTranscript(transcript);
305
+ assert.deepEqual(turns.map(turn => turn.role), ['user', 'assistant', 'user', 'assistant', 'user', 'assistant']);
306
+ assert.equal(turns[0].content, `最初の依頼\n\n${MID_TURN_INPUT_MARK}\n${human}` +
307
+ `\n\n${MID_TURN_INPUT_MARK}\n${human.replace('運ばれた依頼', '作業中に運ばれた依頼')}`);
308
+ assert.equal(turns[2].content, human, '番の終わりの道で届いた行は、包みを外して運んだ文だけを残す');
309
+ assert.equal(turns[4].content, `後継を開いて打った依頼\n\n${MID_TURN_INPUT_MARK}\n${human}`);
310
+ }));
311
+
312
+ // ---- 実際の CLI を temp HOME で動かす ----
313
+
314
+ function childEnv(home, extra = {}) {
315
+ return { ...process.env, HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: join(home, '.config'), XDG_STATE_HOME: join(home, '.local', 'state'),
316
+ LOCALAPPDATA: join(home, 'AppData', 'Local'), THROUGHLINE_NO_VSCODE: '1', CLAUDE_PROJECT_DIR: '', CLAUDE_CODE_ENTRYPOINT: 'cli',
317
+ CLAUDE_PID: '', ...extra };
318
+ }
319
+ const cli = (home, args, input, extra = {}) =>
320
+ spawnSync(process.execPath, [CLI, ...args], { cwd: REPO_ROOT, env: childEnv(home, extra), input, encoding: 'utf8', timeout: 60_000 });
321
+
322
+ test('公開CLI: claude-forward-wait は、置き場が無い会話と script から起動した会話ではすぐ終わり、入力が在れば stderr へ出して exit code 2 で終わる', () => withDir(home => {
323
+ const dir = join(home, '.throughline', 'claude-auto-handoff');
324
+ const stop = (sessionId, extra = {}, env = {}) => cli(home, ['claude-forward-wait'],
325
+ JSON.stringify({ session_id: sessionId, hook_event_name: 'Stop', stop_hook_active: false, ...extra }), env);
326
+
327
+ const none = stop(NEXT);
328
+ assert.deepEqual([none.status, none.stdout, none.stderr], [0, '', ''], '置き場が無い会話');
329
+ assert.equal(existsSync(join(home, '.throughline')), false, '置き場が無い会話では、何も作らない');
330
+
331
+ put(dir, NEXT, '手すきの後継へ届く依頼');
332
+ assert.deepEqual([stop(NEXT, {}, { CLAUDE_CODE_ENTRYPOINT: 'sdk-cli' }).status, hasPendingClaudeForwards(NEXT, dir)], [0, true],
333
+ 'script から起動した会話では待たない。入力は置き場に残る');
334
+ assert.deepEqual([stop(NEXT, { agent_id: 'agent-1' }).status, hasPendingClaudeForwards(NEXT, dir)], [0, true], 'subagent の中では待たない');
335
+
336
+ const delivered = stop(NEXT);
337
+ assert.equal(delivered.status, 2, delivered.stderr);
338
+ assert.equal(delivered.stdout, '');
339
+ assert.match(delivered.stderr, /^【Throughline 転送】引き継ぎ前の会話(aaaaaaaa).*\n手すきの後継へ届く依頼$/s);
340
+ assert.deepEqual(readClaudeForwardStatus(NEXT, { dir }), { pending: 0, unknown: 0 });
341
+ assert.deepEqual(forwardLog(dir).map(row => [row.event, row.via ?? row.reason ?? null]),
342
+ [['wait-start', null], ['wait-end', 'delivered'], ['delivered', 'stop']]);
343
+ assert.equal(existsSync(join(home, '.throughline', 'throughline.db')), false, '待つ hook は DB を開かない');
344
+ }));
345
+
346
+ test('公開CLI: pre-tool-use は、後継の置き場で待っている入力を、道具を止めずに文脈として返す', () => withDir(home => {
347
+ const dir = join(home, '.throughline', 'claude-auto-handoff');
348
+ const tool = sessionId => cli(home, ['pre-tool-use'], JSON.stringify({ session_id: sessionId, hook_event_name: 'PreToolUse',
349
+ tool_name: 'Bash', tool_input: { command: 'ls' }, cwd: home }));
350
+ ensureClaudeForwardInbox(NEXT, dir);
351
+ assert.deepEqual([tool(NEXT).status, tool(NEXT).stdout], [0, ''], '置き場が空の時は、何も返さない');
352
+ put(dir, NEXT, '作業中の後継へ届く依頼');
353
+ const first = tool(NEXT);
354
+ assert.equal(first.status, 0, first.stderr);
355
+ const output = JSON.parse(first.stdout);
356
+ assert.deepEqual(Object.keys(output), ['hookSpecificOutput']);
357
+ assert.equal(output.hookSpecificOutput.hookEventName, 'PreToolUse');
358
+ assert.equal(output.hookSpecificOutput.permissionDecision, undefined, '道具は止めない');
359
+ assert.match(output.hookSpecificOutput.additionalContext, /^【Throughline 転送】.*\n作業中の後継へ届く依頼$/s);
360
+ assert.equal(tool(NEXT).stdout, '', '同じ入力を2回は届けない');
361
+ assert.equal(existsSync(join(home, '.throughline', 'throughline.db')), false);
362
+ }));
363
+
364
+ // ---- 止まっている後継を、人の入力で起こして届ける (ADR 0064) ----
365
+
366
+ test('置き場: 起こす命令で渡す入力は受け取り済みとして置き、届いたら消し、届かなかったら置き場へ戻す', () => withDir(dir => {
367
+ const held = holdClaudeForward({ successorSessionId: NEXT, sourceSessionId: OLD, handoffId: 'h-1', kind: 'prompt', text: '起こして渡す文', now: 1_000, dir });
368
+ assert.deepEqual(readClaudeForwardStatus(NEXT, { dir, now: 2_000 }), { pending: 0, unknown: 0 });
369
+ assert.equal(hasPendingClaudeForwards(NEXT, dir), false, '起きた後継の hook が、同じ文を文脈としてもう1回渡さない');
370
+ assert.equal(held.release(), true);
371
+ assert.match(claimClaudeForwards({ sessionId: NEXT, dir }).text, /\n起こして渡す文$/);
372
+ const done = holdClaudeForward({ successorSessionId: NEXT, sourceSessionId: OLD, handoffId: 'h-1', kind: 'prompt', text: '届いた文', now: 1_000, dir });
373
+ done.settle();
374
+ assert.deepEqual(readdirSync(join(claudeForwardInboxDir(NEXT, dir), 'claimed')).filter(name => name.includes('-')).length, 1, '前の claim の残りだけ');
375
+ assert.equal(holdClaudeForward({ successorSessionId: '../x', text: 'x', dir }), null);
376
+ }));
377
+
378
+ test('置き場: 起こす番は1つだけ。60 秒を過ぎた印は取り直せる。片付けは印を入力として数えない', () => withDir(dir => {
379
+ const release = acquireClaudeWakeClaim(NEXT, { dir, now: Date.now() });
380
+ assert.equal(typeof release, 'function');
381
+ assert.equal(acquireClaudeWakeClaim(NEXT, { dir, now: Date.now() }), null, '起こしている間は、次の入力で起こさない');
382
+ release();
383
+ const again = acquireClaudeWakeClaim(NEXT, { dir, now: Date.now() });
384
+ assert.equal(typeof again, 'function');
385
+ const mark = join(claudeForwardInboxDir(NEXT, dir), 'waking');
386
+ const past = new Date(Date.now() - 61_000); utimesSync(mark, past, past);
387
+ assert.equal(typeof acquireClaudeWakeClaim(NEXT, { dir, now: Date.now() }), 'function', '期限を過ぎた印');
388
+ utimesSync(mark, new Date(0), new Date(0));
389
+ removeStaleClaudeForwards({ dir, now: Date.now(), keepSessionIds: new Set([NEXT]) });
390
+ assert.equal(existsSync(join(dir, 'forward.jsonl')), false, '印は期限切れの入力として数えない');
391
+ assert.equal(acquireClaudeWakeClaim('../x', { dir }), null);
392
+ }));
393
+
394
+ test('一覧の読み方: 裏の項目に pid も status も無く、state が done か stopped の時だけ止まっていると読む', () => {
395
+ const read = (entries, extra = {}) => readClaudeSessionStopped(NEXT, { spawn: () => ({ status: 0, stdout: JSON.stringify(entries), ...extra }) });
396
+ const bg = fields => ({ id: 'bbbbbbbb', kind: 'background', sessionId: NEXT, name: 'n', cwd: '/w', startedAt: 1, ...fields });
397
+ assert.deepEqual(read([bg({ state: 'done' })]), { stopped: true, reason: 'stopped' }, '手すきで止めた・片付けられた');
398
+ assert.deepEqual(read([bg({ state: 'stopped' })]), { stopped: true, reason: 'stopped' }, '作業の途中で止めた');
399
+ assert.equal(read([bg({ state: 'done', pid: 10, status: 'idle' })]).reason, 'running', '手すきで動いている');
400
+ assert.equal(read([bg({ state: 'working', pid: 10, status: 'busy' })]).reason, 'running', '作業中');
401
+ assert.equal(read([bg({ state: 'done' }), { kind: 'interactive', sessionId: NEXT, pid: 11, status: 'idle', name: 'n' }]).reason, 'running', '端末で開いている・claude -p --resume が走っている');
402
+ assert.equal(read([bg({ state: 'working' })]).reason, 'not_stopped', '立ち上げ中(pid がまだ無い)');
403
+ assert.equal(read([bg({ state: 'failed' })]).reason, 'not_stopped');
404
+ assert.equal(read([{ ...bg({ state: 'done' }), sessionId: LAST }]).reason, 'not_listed', 'claude rm で一覧から消した');
405
+ assert.equal(read([{ kind: 'interactive', sessionId: NEXT, name: 'n' }]).reason, 'not_background');
406
+ assert.equal(read([], { status: 1 }).reason, 'list_failed');
407
+ assert.equal(read(null, { stdout: 'not json' }).reason, 'list_failed');
408
+ assert.equal(readClaudeSessionStopped(NEXT, { spawn: NO_CLAUDE }).reason, 'list_failed');
409
+ });
410
+
411
+ test('旧い会話の側: 人が打った文で、後継が止まっていれば、後継を起こしてその文を最初の入力として渡す', () => withDir(dir => {
412
+ const projects = join(dir, 'claude', 'projects', '-work-app');
413
+ const project = join(dir, 'app');
414
+ mkdirSync(projects, { recursive: true }); mkdirSync(project);
415
+ writeFileSync(join(dir, `${OLD}.json`), JSON.stringify({ schema: 'throughline.claude-auto-handoff.v2', handoff_id: 'h-aaaaaaaa',
416
+ source_session_id: OLD, project_path: project, transcript_path: join(projects, `${OLD}.jsonl`), requested_at: 1_000, updated_at: 2_000,
417
+ error_code: null, state: 'sent', successor: { short_id: 'bbbbbbbb', session_id: NEXT } }));
418
+ const transcript = join(projects, `${NEXT}.jsonl`);
419
+ writeFileSync(transcript, ''); settled(transcript);
420
+ const bg = fields => ({ id: 'bbbbbbbb', kind: 'background', sessionId: NEXT, name: 'n', cwd: project, startedAt: 1, ...fields });
421
+ let listing = [bg({ state: 'done' })];
422
+ let woke = { status: 0, stdout: 'backgrounded · bbbbbbbb · n\n claude attach bbbbbbbb open in this terminal\nnote: woke session bbbbbbbb with its saved options (--name, --model).\n' };
423
+ const calls = [];
424
+ const spawn = (command, args, options) => {
425
+ calls.push({ args, cwd: options.cwd, env: options.env });
426
+ if (args[0] === 'agents') return { status: 0, stdout: JSON.stringify(listing) };
427
+ if (args[0] === '--bg') return typeof woke === 'function' ? woke() : woke;
428
+ return { status: 0, stdout: `${args[0]}ped` };
429
+ };
430
+ const env = { CLAUDECODE: '1', CLAUDE_CODE_ENTRYPOINT: 'cli', CLAUDE_CODE_SESSION_ID: OLD, PATH: '/bin' };
431
+ const submit = (prompt, extraEnv = {}) => redirectClaudeHandedOffPrompt({ payload: { session_id: OLD, prompt }, now: Date.now(), dir, env: { ...env, ...extraEnv }, spawn });
432
+ const inbox = claudeForwardInboxDir(NEXT, dir);
433
+ const pending = () => readClaudeForwardStatus(NEXT, { dir }).pending;
434
+ const claimed = () => { try { return readdirSync(join(inbox, 'claimed')).length; } catch { return 0; } };
435
+ const events = () => readFileSync(join(dir, 'forward.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line));
436
+ const freeClaim = () => rmSync(join(inbox, 'waking'), { force: true });
437
+ const open = `claude attach bbbbbbbb(一覧に無い時は claude --resume ${NEXT})`;
438
+
439
+ // 1) 止まっている後継: 起こして、文をそのまま渡す。option は付けず、文は -- の後。会話ごとの環境変数は渡さない
440
+ const text = '-v を付けて、もう一度ビルドして\n2行目';
441
+ const woken = submit(text);
442
+ assert.deepEqual([woken.wake, woken.forwarded], ['woken', true]);
443
+ assert.equal(woken.output.reason, 'この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: h-aaaaaaaa)。' +
444
+ `後継の会話が止まっていたので、Throughlineが起こして、この入力を後継の最初の入力として渡しました。後継は裏で作業を続けます: ${open}`);
445
+ assert.deepEqual(calls.map(call => call.args), [['agents', '--json', '--all'], ['--bg', '--resume', NEXT, '--', text]]);
446
+ assert.equal(calls[1].cwd, project);
447
+ assert.deepEqual(Object.keys(calls[1].env).filter(key => key.startsWith('CLAUDE')), [], '旧い会話の環境変数を後継へ渡さない');
448
+ assert.deepEqual([pending(), claimed()], [0, 0], '渡した文は置き場に残さない(起きた後継の hook が二重に渡さない)');
449
+ assert.deepEqual(events().at(-1), { at: events().at(-1).at, event: 'woken', session_id: NEXT, source_session_id: OLD, chars: text.length });
450
+ assert.equal(readFileSync(join(dir, 'forward.jsonl'), 'utf8').includes('ビルド'), false, '運んだ記録に本文は書かない');
451
+ assert.equal(JSON.parse(readFileSync(join(dir, 'redirects.jsonl'), 'utf8').trim().split('\n').at(-1)).wake, 'woken');
452
+
453
+ // 2) 起こした直後の次の入力: 番の印が残っているので起こさず、置き場へ置く
454
+ calls.length = 0;
455
+ const second = submit('続けてもう1つ');
456
+ assert.deepEqual([second.wake, second.forwarded, pending()], ['skipped', true, 1]);
457
+ assert.match(second.output.reason, /後継は、先に届いた入力で起きている所です。起きた後継が、次の道具の前か番の終わりに読みます。/);
458
+ assert.deepEqual(calls, [], '一覧も読まない');
459
+ freeClaim();
460
+
461
+ // 3) 動いている後継(手すき・作業中・端末で開いている): 起こさず、置き場へ置いて「動いています」と返す
462
+ for (const entries of [[bg({ state: 'done', pid: 5, status: 'idle' })], [bg({ state: 'working', pid: 5, status: 'busy' })],
463
+ [bg({ state: 'done' }), { kind: 'interactive', sessionId: NEXT, pid: 6, status: 'idle', name: 'n' }]]) {
464
+ listing = entries; calls.length = 0;
465
+ const result = submit('動いている後継へ');
466
+ assert.deepEqual([result.wake, result.forwarded], ['skipped', true]);
467
+ assert.match(result.output.reason, /後継は動いています。/);
468
+ assert.deepEqual(calls.map(call => call.args[0]), ['agents'], '起こす命令は流さない');
469
+ assert.equal(existsSync(join(inbox, 'waking')), false, '起こさなかった時は番を返す');
470
+ }
471
+
472
+ // 4) 止まっていると確かめられない(一覧に無い・立ち上げ中・一覧を読めない): 起こさず、0.16.32 と同じ文
473
+ for (const entries of [[], [bg({ state: 'working' })], 'unreadable']) {
474
+ listing = entries; calls.length = 0;
475
+ const result = submit('確かめられない後継へ');
476
+ assert.deepEqual([result.wake, result.forwarded], ['skipped', true]);
477
+ assert.match(result.output.reason, /後継は今、入力を待ち受けていません(止まっているか、この版より前に立った会話です)。/);
478
+ assert.equal(calls.some(call => call.args[0] === '--bg'), false);
479
+ }
480
+
481
+ // 5) 写しが立った(確かめた後に後継が動き出した): 写しを止めて一覧から消し、入力は置き場へ戻す
482
+ listing = [bg({ state: 'done' })]; calls.length = 0;
483
+ woke = { status: 0, stdout: 'backgrounded · 12345678\nnote: session bbbbbbbb is already running in the background, so this started a copy as 12345678.\n' };
484
+ const before = pending();
485
+ const copied = submit('写しが立つ入力');
486
+ assert.deepEqual([copied.wake, copied.forwarded, pending()], ['queued', true, before + 1]);
487
+ assert.deepEqual(calls.map(call => call.args), [['agents', '--json', '--all'], ['--bg', '--resume', NEXT, '--', '写しが立つ入力'], ['stop', '12345678'], ['rm', '12345678']]);
488
+ assert.match(copied.output.reason, /届いた入力は、後継の会話へ渡しました。後継は動いています。/);
489
+ assert.deepEqual([events().at(-1).event, events().at(-1).copy_short_id, events().at(-1).queued], ['wake-copy', '12345678', true]);
490
+
491
+ // 6) 起こす命令が失敗した: 置き場へ戻す
492
+ woke = { status: 1, stdout: '', stderr: 'Error: something went wrong\n' }; calls.length = 0;
493
+ const failed = submit('失敗する入力');
494
+ assert.deepEqual([failed.wake, failed.forwarded, pending()], ['queued', true, before + 2]);
495
+ assert.match(failed.output.reason, /後継は今、入力を待ち受けていません/);
496
+
497
+ // 7) 起きたか分からない(時間切れ): 入れ直さない
498
+ woke = () => ({ error: Object.assign(new Error('spawnSync claude ETIMEDOUT'), { code: 'ETIMEDOUT' }) });
499
+ const unknown = submit('分からない入力');
500
+ assert.deepEqual([unknown.wake, unknown.forwarded, pending(), claimed()], ['unknown', false, before + 2, 1]);
501
+ assert.equal(unknown.output.reason, 'この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: h-aaaaaaaa)。' +
502
+ '後継の会話が止まっていたので起こそうとしましたが、起きて入力を受け取ったか確かめられませんでした。' +
503
+ `二重に届かないよう、この入力は入れ直していません。後継を開いて確かめてください: ${open}`);
504
+ assert.equal(events().at(-1).event, 'wake-unknown');
505
+ freeClaim();
506
+
507
+ // 8) 起こさない入力: 完了通知・ほかの会話からの文・長すぎる文・NUL を含む文・上書きで止めた時・書かれたばかりの transcript
508
+ woke = { status: 0, stdout: 'backgrounded · bbbbbbbb · n\n' }; calls.length = 0;
509
+ assert.equal(submit('<task-notification>\n<task-id>b1</task-id>\n<status>completed</status>\n</task-notification>').wake, undefined);
510
+ assert.equal(submit('Another Claude session sent a message:\n確認をお願いします').wake, undefined);
511
+ assert.equal(submit('あ'.repeat(CLAUDE_WAKE_TEXT_MAX_CHARS + 1)).wake, 'skipped');
512
+ assert.equal(submit('NUL\0入り').wake, 'skipped');
513
+ assert.equal(submit('止めた時', { THROUGHLINE_AUTO_HANDOFF_WAKE: 'off' }).wake, 'skipped');
514
+ writeFileSync(transcript, '{}\n');
515
+ const fresh = submit('書かれたばかり');
516
+ assert.match(fresh.output.reason, /後継は動いています。/);
517
+ settled(transcript);
518
+ assert.deepEqual(calls, [], 'どれも一覧を読まず、起こす命令も流さない');
519
+ // Claude Desktop へ移した後継も起こさない
520
+ const record = JSON.parse(readFileSync(join(dir, `${OLD}.json`), 'utf8'));
521
+ writeFileSync(join(dir, `${OLD}.json`), JSON.stringify({ ...record, desktop: { wanted: true, state: 'opened' } }));
522
+ assert.equal(submit('Desktop へ移した後継').wake, 'skipped');
523
+ assert.deepEqual(calls, []);
524
+ writeFileSync(join(dir, `${OLD}.json`), JSON.stringify(record));
525
+
526
+ // 9) 後継がさらに引き継がれている時は、一番先の会話を起こす
527
+ writeFileSync(join(dir, `${NEXT}.json`), JSON.stringify({ schema: 'throughline.claude-auto-handoff.v2', handoff_id: 'h-bbbbbbbb',
528
+ source_session_id: NEXT, project_path: project, transcript_path: transcript, requested_at: 1_000, updated_at: 2_000,
529
+ error_code: null, state: 'sent', successor: { short_id: 'cccccccc', session_id: LAST } }));
530
+ writeFileSync(join(projects, `${LAST}.jsonl`), ''); settled(join(projects, `${LAST}.jsonl`));
531
+ listing = [{ id: 'cccccccc', kind: 'background', sessionId: LAST, state: 'done', name: 'n', cwd: project, startedAt: 1 }];
532
+ woke = { status: 0, stdout: 'backgrounded · cccccccc · n\nnote: woke session cccccccc with its saved options (--name).\n' };
533
+ const chained = submit('一番先へ');
534
+ assert.equal(chained.wake, 'woken');
535
+ assert.deepEqual(calls.at(-1).args, ['--bg', '--resume', LAST, '--', '一番先へ']);
536
+ }));
@@ -8,6 +8,7 @@
8
8
  * 会話では、記録のファイルの有無だけを見て終わる(DB も他の module も読み込まない)。
9
9
  *
10
10
  * 記録がある会話では、その道具を実行させずにターンを止め、後継を立てる worker を起動する。
11
+ * 後継の会話では、引き継ぎ前の会話へ届いた入力が置き場で待っていれば、文脈として渡す (ADR 0063)。道具は止めない。
11
12
  * この hook 自身が失敗した時は exit code 1 で終わる。道具は止まらず、作業は続く。
12
13
  */
13
14
 
@@ -31,23 +32,40 @@ export async function run() {
31
32
 
32
33
  const payload = JSON.parse(raw.replace(/^/, ''));
33
34
  const sessionId = payload?.session_id;
34
- if (typeof sessionId !== 'string' || !SESSION_ID_PATTERN.test(sessionId) || payload.agent_id ||
35
- !existsSync(join(homedir(), '.throughline', 'claude-auto-handoff', `${sessionId}.json`))) {
36
- process.exit(0);
35
+ if (typeof sessionId !== 'string' || !SESSION_ID_PATTERN.test(sessionId) || payload.agent_id) process.exit(0);
36
+ const dir = join(homedir(), '.throughline', 'claude-auto-handoff');
37
+ const hasRecord = existsSync(join(dir, `${sessionId}.json`));
38
+ // 引き継ぎ前の会話へ届いた入力の、この会話(後継)の置き場 (ADR 0063)。
39
+ const hasInbox = existsSync(join(dir, 'inbox', sessionId));
40
+ if (!hasRecord && !hasInbox) process.exit(0);
41
+
42
+ if (hasRecord) {
43
+ const { stopClaudeTurnForHandoff } = await import('./claude-auto-handoff.mjs');
44
+ const { logDecision } = await import('./decision-log.mjs');
45
+ const now = Date.now();
46
+ const output = await stopClaudeTurnForHandoff({ payload, now });
47
+ if (output) {
48
+ logDecision({
49
+ ts: new Date(now).toISOString(),
50
+ phase: 'pre-tool-use-stop',
51
+ session_id: sessionId,
52
+ tool_name: payload.tool_name ?? null,
53
+ });
54
+ process.stdout.write(`${JSON.stringify(output)}\n`);
55
+ process.exit(0);
56
+ }
37
57
  }
38
58
 
39
- const { stopClaudeTurnForHandoff } = await import('./claude-auto-handoff.mjs');
40
- const { logDecision } = await import('./decision-log.mjs');
41
- const now = Date.now();
42
- const output = await stopClaudeTurnForHandoff({ payload, now });
43
- if (!output) process.exit(0);
44
- logDecision({
45
- ts: new Date(now).toISOString(),
46
- phase: 'pre-tool-use-stop',
47
- session_id: sessionId,
48
- tool_name: payload.tool_name ?? null,
49
- });
50
- process.stdout.write(`${JSON.stringify(output)}\n`);
59
+ // 作業中の後継へ、置き場で待っている入力を、次の道具の前に文脈として渡す。道具は止めない。
60
+ if (hasInbox) {
61
+ const { takeClaudeForwardsAsContext } = await import('./claude-forward.mjs');
62
+ const taken = takeClaudeForwardsAsContext({ sessionId, via: 'pre-tool-use' });
63
+ if (taken) {
64
+ const output = { hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: taken.text } };
65
+ await new Promise(resolve => process.stdout.write(`${JSON.stringify(output)}\n`, resolve));
66
+ taken.done();
67
+ }
68
+ }
51
69
  process.exit(0);
52
70
  }
53
71
 
@@ -97,7 +97,7 @@ async function runClaude(parsed) {
97
97
  const handoffs = listClaudeAutoHandoffs()
98
98
  .filter(record => !parsed.project || sameProjectPath(record.project_path, parsed.project))
99
99
  .filter(record => !parsed.operation || record.handoff_id === parsed.operation || record.source_session_id === parsed.operation)
100
- .map(publicClaudeAutoHandoff);
100
+ .map(record => publicClaudeAutoHandoff(record));
101
101
  return { host: 'claude', config: readClaudeAutoHandoffConfig(),
102
102
  hooks: { registered: claudeAutoHandoffHooksRegistered() }, handoffs };
103
103
  }
@@ -121,7 +121,7 @@ export async function run(args = []) {
121
121
  if (parsed.json) process.stdout.write(JSON.stringify(result) + '\n');
122
122
  else if (parsed.action === 'status') {
123
123
  process.stdout.write(`自動継続: ${result.config.enabled ? '有効' : '無効'}(Claude Code)\n`);
124
- process.stdout.write(`hook(PreCompact・PreToolUse): ${result.hooks.registered ? '登録済み' : '未登録'}\n`);
124
+ process.stdout.write(`hook(PreCompact・PreToolUse・Stop): ${result.hooks.registered ? '登録済み' : '未登録'}\n`);
125
125
  for (const item of result.handoffs) process.stdout.write(`${item.handoff_id} ${item.state} ${item.error_code ?? ''}\n`);
126
126
  } else process.stdout.write(JSON.stringify(result, null, 2) + '\n');
127
127
  return result.state === 'failed' || result.state === 'unknown' ? 1 : 0;