throughline 0.13.0 → 0.14.1

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.
@@ -1,148 +1,587 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { spawnSync } from 'node:child_process';
4
- import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
4
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
- import { dirname, join } from 'node:path';
6
+ import { delimiter, dirname, join } from 'node:path';
7
7
  import { DatabaseSync } from 'node:sqlite';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import {
10
- recordClaudeCompactRequest,
11
- consumeClaudeCompactRequest,
12
- listClaudeCompactRequests,
10
+ requestClaudeAutoHandoff,
11
+ stopClaudeTurnForHandoff,
12
+ completeClaudeTurnWithoutHandoff,
13
+ recordClaudeSuccessorTarget,
14
+ acceptClaudeAutoContinuation,
15
+ runClaudeAutoHandoffWorker,
16
+ readClaudeAutoHandoff,
17
+ listClaudeAutoHandoffs,
18
+ publicClaudeAutoHandoff,
19
+ claudeContinuationInput,
13
20
  } from './claude-auto-handoff.mjs';
14
21
  import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
15
22
 
16
23
  const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
17
24
  const CLI = join(REPO_ROOT, 'bin', 'throughline.mjs');
18
25
  const SESSION = '0f3a6c1e-7b2d-4e5f-9a81-3c4d5e6f7a8b';
26
+ const SUCCESSOR = 'a41a97ae-6b2a-421b-b2d5-1a0b07988d06';
19
27
  const ENABLED = { enabled: true, projects: [] };
20
28
 
21
29
  function withDir(fn) {
22
30
  const dir = mkdtempSync(join(tmpdir(), 'tl-claude-auto-'));
23
- try { return fn(dir); }
24
- finally { rmSync(dir, { recursive: true, force: true }); }
31
+ const finish = () => rmSync(dir, { recursive: true, force: true });
32
+ let result;
33
+ try { result = fn(dir); }
34
+ catch (error) { finish(); throw error; }
35
+ if (result && typeof result.then === 'function') return result.finally(finish);
36
+ finish();
37
+ return result;
25
38
  }
26
39
 
27
- function childEnv(home) {
28
- // 設定とstateの置き場も一時HOMEへ向ける(本物のruntime error storeへ書かない)。
29
- return {
30
- ...process.env,
31
- HOME: home,
32
- USERPROFILE: home,
33
- XDG_CONFIG_HOME: join(home, '.config'),
34
- XDG_STATE_HOME: join(home, '.local', 'state'),
35
- LOCALAPPDATA: join(home, 'AppData', 'Local'),
36
- THROUGHLINE_NO_VSCODE: '1',
37
- CLAUDE_PROJECT_DIR: '',
38
- };
40
+ function makeBatonDb() {
41
+ const db = new DatabaseSync(':memory:');
42
+ db.exec('CREATE TABLE handoff_batons (project_path TEXT PRIMARY KEY, session_id TEXT NOT NULL, created_at INTEGER NOT NULL)');
43
+ return db;
39
44
  }
40
45
 
41
- function cli(home, args, input = '') {
42
- return spawnSync(process.execPath, [CLI, ...args], { cwd: REPO_ROOT, env: childEnv(home), input, encoding: 'utf8' });
43
- }
44
-
45
- const user = (text, timestamp) => ({ type: 'user', timestamp, message: { role: 'user', content: text } });
46
- const assistant = (text, timestamp) => ({ type: 'assistant', timestamp, message: { role: 'assistant', content: [{ type: 'text', text }] } });
47
- const toolUse = (id, name) => ({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input: {} }] } });
46
+ const user = (text, timestamp, extra = {}) => ({ type: 'user', timestamp, ...extra, message: { role: 'user', content: text } });
47
+ const assistant = (text, timestamp, model = 'claude-fable-5-1') =>
48
+ ({ type: 'assistant', timestamp, message: { role: 'assistant', model, content: [{ type: 'text', text }] } });
49
+ const toolUse = (id, name, input = {}) =>
50
+ ({ type: 'assistant', message: { role: 'assistant', model: 'claude-fable-5-1', content: [{ type: 'tool_use', id, name, input }] } });
48
51
  const toolResult = (id, text) => ({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: text }] } });
49
- const compactSummary = () => ({ type: 'user', isCompactSummary: true, isVisibleInTranscriptOnly: true,
50
- message: { role: 'user', content: 'This session is being continued from a previous conversation that ran out of context.' } });
51
52
  const jsonl = entries => entries.map(entry => JSON.stringify(entry)).join('\n') + '\n';
52
53
 
53
- test('PreCompact: 自動圧縮で有効な時だけ印を残し、手動・無効・対象外のprojectでは古い印を消す', () => withDir(dir => {
54
- const payload = { session_id: SESSION, trigger: 'auto', cwd: '/work/app', transcript_path: '/t.jsonl' };
55
- const args = { payload, env: {}, config: ENABLED, dir, now: 1_000 };
54
+ function request(dir, overrides = {}) {
55
+ const db = overrides.db ?? makeBatonDb();
56
+ const result = requestClaudeAutoHandoff({
57
+ payload: { session_id: SESSION, trigger: 'auto', cwd: '/work/app', transcript_path: overrides.transcriptPath ?? null,
58
+ ...overrides.payload },
59
+ env: overrides.env ?? {}, config: overrides.config ?? ENABLED, openDb: () => db, dir, now: overrides.now ?? 1_000,
60
+ });
61
+ return { result, db };
62
+ }
63
+
64
+ test('PreCompact: 自動圧縮で有効な時だけ、印と記録を残して圧縮を止める', () => withDir(dir => {
65
+ const { result, db } = request(dir);
66
+ assert.equal(result.status, 'requested');
67
+ assert.equal(result.block, true);
68
+ assert.equal(result.projectPath, '/work/app');
69
+ assert.deepEqual({ ...db.prepare('SELECT project_path, session_id, created_at FROM handoff_batons').get() },
70
+ { project_path: '/work/app', session_id: SESSION, created_at: 1_000 }, '/tl と同じ印');
71
+ const record = readClaudeAutoHandoff(SESSION, dir);
72
+ assert.equal(record.state, 'requested');
73
+ assert.equal(record.handoff_id, result.handoffId);
74
+
75
+ // 同じ圧縮で hook が2回届いても、同じ引き継ぎのまま止め続ける
76
+ const again = request(dir, { db, now: 6_000 }).result;
77
+ assert.deepEqual([again.status, again.block, again.handoffId], ['already_requested', true, result.handoffId]);
78
+ assert.equal(db.prepare('SELECT created_at FROM handoff_batons').get().created_at, 1_000);
56
79
 
57
- assert.deepEqual(recordClaudeCompactRequest(args), { status: 'requested', sessionId: SESSION, projectPath: '/work/app' });
58
- assert.deepEqual(listClaudeCompactRequests({ dir }), [{ session_id: SESSION, project_path: '/work/app', requested_at: 1_000 }]);
80
+ // 有効判定は会話を起動した project で行う(cwd は Bash の cd に追従する)
81
+ rmSync(join(dir, `${SESSION}.json`));
82
+ const started = request(dir, { payload: { cwd: '/work/app/sub' }, env: { CLAUDE_PROJECT_DIR: '/work/app' },
83
+ config: { enabled: true, projects: ['/work/app'] } }).result;
84
+ assert.deepEqual([started.status, started.projectPath], ['requested', '/work/app']);
85
+ }));
59
86
 
60
- for (const [override, reason] of [
61
- [{ payload: { ...payload, trigger: 'manual' } }, 'manual_compact'],
87
+ test('PreCompact: 手動・無効・対象外のproject・scriptから起動した会話・subagent では圧縮を止めない', () => withDir(dir => {
88
+ for (const [overrides, reason] of [
89
+ [{ payload: { trigger: 'manual' } }, 'manual_compact'],
62
90
  [{ config: { enabled: false, projects: [] } }, 'auto_handoff_disabled'],
63
91
  [{ config: { enabled: true, projects: ['/work/other'] } }, 'auto_handoff_disabled'],
92
+ [{ env: { CLAUDE_CODE_ENTRYPOINT: 'sdk-cli' } }, 'handoff_host_unsupported'],
93
+ [{ payload: { agent_id: 'agent-1' } }, 'subagent_compact'],
64
94
  ]) {
65
- recordClaudeCompactRequest(args);
66
- const result = recordClaudeCompactRequest({ ...args, ...override });
67
- assert.equal(result.status, 'skipped');
68
- assert.equal(result.reason, reason);
69
- assert.deepEqual(readdirSync(dir), [], reason);
95
+ const { result, db } = request(dir, overrides);
96
+ assert.deepEqual([result.status, result.block, result.reason], ['skipped', false, reason]);
97
+ assert.equal(db.prepare('SELECT COUNT(*) AS c FROM handoff_batons').get().c, 0, reason);
98
+ assert.deepEqual(listClaudeAutoHandoffs({ dir }), [], reason);
70
99
  }
71
-
72
- // 有効判定は会話を起動した project で行う(cwd は Bash の cd に追従する)
73
- const started = recordClaudeCompactRequest({ ...args, payload: { ...payload, cwd: '/work/app/sub' },
74
- env: { CLAUDE_PROJECT_DIR: '/work/app' }, config: { enabled: true, projects: ['/work/app'] } });
75
- assert.equal(started.status, 'requested');
76
- assert.equal(started.projectPath, '/work/app');
100
+ assert.equal(request(dir, { env: { CLAUDE_CODE_ENTRYPOINT: 'claude-desktop' } }).result.status, 'requested');
77
101
  }));
78
102
 
79
- test('PreCompact: subagent の中の圧縮は親の会話の印に触らない', () => withDir(dir => {
80
- const payload = { session_id: SESSION, trigger: 'auto', cwd: '/work/app' };
81
- recordClaudeCompactRequest({ payload, env: {}, config: ENABLED, dir, now: 1_000 });
82
- for (const trigger of ['auto', 'manual']) {
83
- const result = recordClaudeCompactRequest({ payload: { ...payload, trigger, agent_id: 'agent-1' }, env: {}, config: ENABLED, dir, now: 2_000 });
84
- assert.deepEqual(result, { status: 'skipped', reason: 'subagent_compact', sessionId: SESSION });
85
- }
86
- assert.equal(listClaudeCompactRequests({ dir })[0].requested_at, 1_000);
103
+ test('PreCompact: 手動の/compactは止める前の記録を取り下げ、subagentの圧縮は親の記録に触らない', () => withDir(dir => {
104
+ request(dir);
105
+ assert.equal(request(dir, { payload: { trigger: 'auto', agent_id: 'agent-1' } }).result.reason, 'subagent_compact');
106
+ assert.equal(readClaudeAutoHandoff(SESSION, dir).state, 'requested');
107
+ assert.equal(request(dir, { payload: { trigger: 'manual' } }).result.reason, 'manual_compact');
108
+ assert.equal(readClaudeAutoHandoff(SESSION, dir), null);
87
109
  }));
88
110
 
89
- test('PreCompact: session id が無い・pathを含む時は失敗し、期限を過ぎた他の会話の印は掃除する', () => withDir(dir => {
90
- assert.throws(() => recordClaudeCompactRequest({ payload: { trigger: 'auto' }, env: {}, config: ENABLED, dir }), /Missing session_id/);
91
- assert.throws(() => recordClaudeCompactRequest({ payload: { session_id: '../escape', trigger: 'auto', cwd: '/w' }, env: {}, config: ENABLED, dir }),
92
- /auto_handoff_session_id_invalid/);
111
+ test('PreCompact: session id が無い・pathを含む時は失敗し、期限を過ぎた記録は掃除する', () => withDir(dir => {
112
+ assert.throws(() => requestClaudeAutoHandoff({ payload: { trigger: 'auto' }, env: {}, config: ENABLED, openDb: makeBatonDb, dir }),
113
+ /Missing session_id/);
114
+ assert.throws(() => request(dir, { payload: { session_id: '../escape' } }), /auto_handoff_session_id_invalid/);
93
115
 
94
116
  const now = Date.parse('2026-10-04T12:00:00Z');
95
- mkdirSync(dir, { recursive: true });
96
- const stale = join(dir, 'aaaaaaaa-0000-4000-8000-000000000001.json');
117
+ mkdirSync(join(dir, 'targets'), { recursive: true });
118
+ const stale = [join(dir, 'aaaaaaaa-0000-4000-8000-000000000001.json'), join(dir, 'targets', 'aaaaaaaa-0000-4000-8000-000000000003.json')];
97
119
  const fresh = join(dir, 'aaaaaaaa-0000-4000-8000-000000000002.json');
98
- for (const path of [stale, fresh]) writeFileSync(path, '{}');
99
- utimesSync(stale, new Date(now - 25 * 3_600_000), new Date(now - 25 * 3_600_000));
120
+ for (const path of [...stale, fresh]) writeFileSync(path, '{}');
121
+ for (const path of stale) utimesSync(path, new Date(now - 25 * 3_600_000), new Date(now - 25 * 3_600_000));
100
122
  utimesSync(fresh, new Date(now - 3_600_000), new Date(now - 3_600_000));
101
- recordClaudeCompactRequest({ payload: { session_id: SESSION, trigger: 'auto', cwd: '/w' }, env: {}, config: ENABLED, dir, now });
102
- assert.deepEqual(readdirSync(dir).sort(), [`${SESSION}.json`, 'aaaaaaaa-0000-4000-8000-000000000002.json'].sort());
123
+ request(dir, { now });
124
+ assert.deepEqual(readdirSync(dir).filter(name => name.endsWith('.json')).sort(),
125
+ [`${SESSION}.json`, 'aaaaaaaa-0000-4000-8000-000000000002.json'].sort());
126
+ assert.deepEqual(readdirSync(join(dir, 'targets')), []);
127
+ }));
128
+
129
+ test('PreToolUse: 記録のある会話の道具を実行させずに止め、止めた時点の依頼と設定を写して worker を1回だけ起動する', () => withDir(async dir => {
130
+ const transcriptPath = join(dir, 'transcript.jsonl');
131
+ writeFileSync(transcriptPath, jsonl([
132
+ user('前の依頼', '2026-10-04T03:00:00Z'),
133
+ assistant('前の回答', '2026-10-04T03:00:01Z'),
134
+ user('big1 と big2 を読んで result.txt を作って', '2026-10-04T03:00:10Z'),
135
+ toolUse('toolu_1', 'Read'),
136
+ toolResult('toolu_1', 'contents'),
137
+ assistant('big1 を読み終えました。次に big2 を読みます。', '2026-10-04T03:00:12Z'),
138
+ { type: 'assistant', message: { role: 'assistant', model: '<synthetic>', content: [{ type: 'tool_use', id: 'toolu_2', name: 'Read', input: {} }] } },
139
+ ]));
140
+ const launched = [];
141
+ const launchWorker = async sessionId => { launched.push(sessionId); };
142
+ const payload = { session_id: SESSION, transcript_path: transcriptPath, cwd: '/work/app', tool_name: 'Read', tool_use_id: 'toolu_2',
143
+ permission_mode: 'acceptEdits', effort: { level: 'high' } };
144
+
145
+ assert.equal(await stopClaudeTurnForHandoff({ payload, dir, launchWorker }), null, '記録が無い会話には何もしない');
146
+ const { result } = request(dir, { transcriptPath });
147
+ assert.equal(await stopClaudeTurnForHandoff({ payload: { ...payload, agent_id: 'agent-1' }, dir, launchWorker }), null,
148
+ 'subagent の中の道具は止めない');
149
+
150
+ // 同じ応答に並んだ2つの道具
151
+ const [first, second] = await Promise.all([
152
+ stopClaudeTurnForHandoff({ payload, dir, launchWorker, now: 2_000 }),
153
+ stopClaudeTurnForHandoff({ payload: { ...payload, tool_name: 'Write' }, dir, launchWorker, now: 2_000 }),
154
+ ]);
155
+ for (const output of [first, second]) {
156
+ assert.equal(output.continue, false);
157
+ assert.match(output.stopReason, new RegExp(`自動圧縮の代わりに新しい会話へ引き継ぎます(引き継ぎID: ${result.handoffId})`));
158
+ assert.deepEqual(output.hookSpecificOutput, { hookEventName: 'PreToolUse', permissionDecision: 'deny',
159
+ permissionDecisionReason: output.stopReason });
160
+ }
161
+ assert.deepEqual(launched, [SESSION]);
162
+ const record = readClaudeAutoHandoff(SESSION, dir);
163
+ assert.equal(record.state, 'stopped');
164
+ assert.deepEqual(record.settings, { model: null, effort: 'high', permission_mode: 'acceptEdits' },
165
+ '権限と推論強度は hook の入力から写す。モデルと依頼は worker が transcript から読む');
166
+ assert.equal(record.stopped_tool_use_id, 'toolu_2');
167
+ assert.equal(record.in_flight, null);
168
+
169
+ // 止めた後に届いた道具も止める。worker はもう起動しない
170
+ assert.equal((await stopClaudeTurnForHandoff({ payload, dir, launchWorker })).continue, false);
171
+ assert.deepEqual(launched, [SESSION]);
172
+ }));
173
+
174
+ test('PreToolUse: worker を起動できなければ failed にし、次の道具でもう一度立ち上げる', () => withDir(async dir => {
175
+ request(dir);
176
+ const payload = { session_id: SESSION, cwd: '/work/app', tool_name: 'Read' };
177
+ const failing = async () => { throw new Error('spawn failed'); };
178
+ const output = await stopClaudeTurnForHandoff({ payload, dir, launchWorker: failing });
179
+ assert.equal(output.continue, false, '立ち上げに失敗しても、圧縮されていない会話の道具は止める');
180
+ assert.deepEqual([readClaudeAutoHandoff(SESSION, dir).state, readClaudeAutoHandoff(SESSION, dir).error_code],
181
+ ['failed', 'handoff_worker_start_failed']);
182
+ const launched = [];
183
+ await stopClaudeTurnForHandoff({ payload, dir, launchWorker: async sessionId => { launched.push(sessionId); } });
184
+ assert.deepEqual(launched, [SESSION]);
185
+ assert.equal(readClaudeAutoHandoff(SESSION, dir).state, 'stopped');
103
186
  }));
104
187
 
105
- test('SessionStart: 印は一度だけ取り出せる。別の会話・他hostの会話は取り出さない', () => withDir(dir => {
106
- recordClaudeCompactRequest({ payload: { session_id: SESSION, trigger: 'auto', cwd: '/work/app', transcript_path: '/t.jsonl' },
107
- env: {}, config: ENABLED, dir, now: 5_000 });
108
- assert.equal(consumeClaudeCompactRequest({ sessionId: 'aaaaaaaa-0000-4000-8000-000000000009', dir }), null);
109
- assert.equal(consumeClaudeCompactRequest({ sessionId: `grok:${SESSION}`, dir }), null);
110
- const request = consumeClaudeCompactRequest({ sessionId: SESSION, dir });
111
- assert.equal(request.trigger, 'auto');
112
- assert.equal(request.project_path, '/work/app');
113
- assert.equal(request.transcript_path, '/t.jsonl');
114
- assert.equal(request.requested_at, 5_000);
115
- assert.equal(consumeClaudeCompactRequest({ sessionId: SESSION, dir }), null);
116
-
117
- writeFileSync(join(dir, `${SESSION}.json`), JSON.stringify({ schema: 'other', session_id: SESSION, trigger: 'auto' }));
118
- assert.throws(() => consumeClaudeCompactRequest({ sessionId: SESSION, dir }), /auto_handoff_request_invalid/);
119
- assert.deepEqual(readdirSync(dir), [], '読めない印は残さない');
188
+ test('Stop: 圧縮を止めた後、道具を呼ばずにターンが終わったら記録を取り下げる(印は残す)', () => withDir(async dir => {
189
+ const { db } = request(dir);
190
+ assert.equal(completeClaudeTurnWithoutHandoff({ sessionId: 'aaaaaaaa-0000-4000-8000-000000000009', dir }), false);
191
+ assert.equal(completeClaudeTurnWithoutHandoff({ sessionId: `grok:${SESSION}`, dir }), false);
192
+ assert.equal(completeClaudeTurnWithoutHandoff({ sessionId: SESSION, dir }), true);
193
+ assert.equal(readClaudeAutoHandoff(SESSION, dir), null);
194
+ assert.equal(db.prepare('SELECT session_id FROM handoff_batons').get().session_id, SESSION);
195
+
196
+ // 止めた後(worker が動いている)の記録は取り下げない
197
+ request(dir);
198
+ await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app' }, dir, launchWorker: async () => {} });
199
+ assert.equal(completeClaudeTurnWithoutHandoff({ sessionId: SESSION, dir }), false);
200
+ assert.equal(readClaudeAutoHandoff(SESSION, dir).state, 'stopped');
120
201
  }));
121
202
 
122
- test('公開CLI: --host claude は enable・disable・status だけを受け取る', () => {
203
+ /** 止めた直後の記録を作り、後継の SessionStart と配送を差し替えて worker を走らせる。 */
204
+ async function runWorker(dir, { spawn, send, onLaunched }) {
205
+ request(dir);
206
+ await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app', permission_mode: 'acceptEdits',
207
+ effort: { level: 'high' } }, dir, launchWorker: async () => {} });
208
+ const calls = { spawn: [], send: [] };
209
+ const record = await runClaudeAutoHandoffWorker(SESSION, {
210
+ dir, env: { PATH: '/bin', CLAUDECODE: '1', CLAUDE_CODE_ENTRYPOINT: 'claude-desktop', CLAUDE_CODE_MESSAGING_SOCKET: '/old.sock',
211
+ CLAUDE_CODE_MESSAGING_TOKEN: 'old-token', CLAUDE_PROJECT_DIR: '/work/app', CLAUDE_CODE_USE_BEDROCK: '1' },
212
+ targetTimeoutMs: 300, sendTimeoutMs: 300, pollMs: 10,
213
+ spawn: (command, args, options) => {
214
+ calls.spawn.push({ command, args, options });
215
+ const result = spawn(command, args, options);
216
+ if (result.status === 0) onLaunched?.();
217
+ return result;
218
+ },
219
+ send: async (target, text, options) => { calls.send.push({ target, text }); return send(target, text, options); },
220
+ });
221
+ return { record, calls };
222
+ }
223
+
224
+ const launchedOk = () => ({ status: 0, stdout: 'Starting background service…\nbackgrounded · a41a97ae · tl-app-1234 (idle — send a prompt to start)\n', stderr: '' });
225
+ const successorStarts = dir => () => recordClaudeSuccessorTarget({
226
+ payload: { session_id: SUCCESSOR, source: 'startup', cwd: '/work/app' },
227
+ env: { CLAUDE_CODE_MESSAGING_SOCKET: '/tmp/cc-socks/271650.sock', CLAUDE_CODE_MESSAGING_TOKEN: 'secret-token' }, dir });
228
+
229
+ test('worker: 指示を待つ後継を同じprojectに立て、後継の受け口へ継続の指示を1通送る', () => withDir(async dir => {
230
+ const { record, calls } = await runWorker(dir, {
231
+ spawn: launchedOk,
232
+ onLaunched: successorStarts(dir),
233
+ send: async (target, text, options) => {
234
+ // 届いた指示で、後継の最初の UserPromptSubmit が受領を記録する
235
+ const accepted = acceptClaudeAutoContinuation({ predecessorId: SESSION, successorSessionId: SUCCESSOR, prompt: text, dir });
236
+ assert.ok(accepted);
237
+ return await options.confirm_acceptance(new AbortController().signal)
238
+ ? { status: 'accepted', outcome_unknown: false, reason: 'receiver_confirmed' }
239
+ : { status: 'unknown', outcome_unknown: true, reason: 'unconfirmed' };
240
+ },
241
+ });
242
+ assert.equal(calls.spawn.length, 1);
243
+ const { command, args, options } = calls.spawn[0];
244
+ assert.equal(command, 'claude');
245
+ assert.deepEqual(args, ['--bg', '--name', `tl-app-${record.handoff_id.slice(0, 8)}`, '--effort', 'high',
246
+ '--permission-mode', 'acceptEdits', '--settings', '{"worktree":{"bgIsolation":"none"}}'],
247
+ '指示は起動時に渡さない。モデルは transcript が無いので付かない');
248
+ assert.equal(options.cwd, '/work/app');
249
+ assert.deepEqual(options.env, { PATH: '/bin', CLAUDE_CODE_USE_BEDROCK: '1' }, '会話ごとの環境変数は後継へ渡さない');
250
+
251
+ assert.equal(calls.send.length, 1);
252
+ assert.deepEqual(calls.send[0].target, { socket_path: '/tmp/cc-socks/271650.sock', token: 'secret-token' });
253
+ assert.equal(calls.send[0].text, claudeContinuationInput(record));
254
+ assert.match(calls.send[0].text, new RegExp(`^Throughline自動継続 ${record.handoff_id}\\n注入された記憶と元のユーザー依頼に従い、未完了の作業をそのまま継続してください。`));
255
+
256
+ assert.deepEqual([record.state, record.error_code], ['sent', null]);
257
+ assert.deepEqual(record.successor, { short_id: 'a41a97ae', session_id: SUCCESSOR });
258
+ assert.deepEqual(readdirSync(join(dir, 'targets')), [], '受け口の控えは読んだら消す');
259
+ assert.doesNotMatch(JSON.stringify(publicClaudeAutoHandoff(record)), /secret-token|cc-socks/);
260
+ assert.doesNotMatch(readFileSync(join(dir, `${SESSION}.json`), 'utf8'), /secret-token/, 'token は記録に残さない');
261
+
262
+ // 引き継ぎ済みの会話の道具は、後継を案内して止める
263
+ const output = await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app' }, dir,
264
+ launchWorker: async () => { throw new Error('must not launch again'); } });
265
+ assert.match(output.stopReason, /引き継ぎ済みです.*claude attach a41a97ae/);
266
+ }));
267
+
268
+ test('worker: 止めた時点の依頼とモデルを transcript から写し、色付きの出力からも後継のIDを読む。後継が止められた時は元の依頼を運ぶ', () => withDir(async dir => {
269
+ const sendOk = async () => ({ status: 'accepted', outcome_unknown: false, reason: 'receiver_confirmed' });
270
+ const colored = () => ({ status: 0, stderr: '',
271
+ stdout: 'backgrounded · \u001b[36ma41a97ae\u001b[39m · tl-app-1234\u001b[2m (idle — send a prompt to start)\u001b[22m\n' });
272
+ const run = async (sessionId, transcriptPath, toolUseId) => {
273
+ const db = makeBatonDb();
274
+ requestClaudeAutoHandoff({ payload: { session_id: sessionId, trigger: 'auto', cwd: '/work/app', transcript_path: transcriptPath },
275
+ env: {}, config: ENABLED, openDb: () => db, dir });
276
+ await stopClaudeTurnForHandoff({ payload: { session_id: sessionId, cwd: '/work/app', transcript_path: transcriptPath,
277
+ tool_use_id: toolUseId }, dir, launchWorker: async () => {} });
278
+ const calls = [];
279
+ const record = await runClaudeAutoHandoffWorker(sessionId, { dir, env: {}, send: sendOk, pollMs: 10, targetTimeoutMs: 300,
280
+ transcriptTimeoutMs: 300,
281
+ spawn: (command, args) => {
282
+ calls.push(args);
283
+ // 止めた道具の呼び出しは、hook の後で transcript に書かれる
284
+ recordClaudeSuccessorTarget({ payload: { session_id: SUCCESSOR, source: 'startup', cwd: '/work/app' },
285
+ env: { CLAUDE_CODE_MESSAGING_SOCKET: '/tmp/cc-socks/1.sock' }, dir });
286
+ return colored();
287
+ } });
288
+ return { record, calls };
289
+ };
290
+
291
+ // 対話の会話 A。止めた道具の行は、worker が待っている間に書かれる
292
+ const transcriptA = join(dir, 'a.jsonl');
293
+ writeFileSync(transcriptA, jsonl([
294
+ user('14個のファイルを順に読んで', '2026-10-04T03:00:10Z'),
295
+ assistant('part04 を読みました。', '2026-10-04T03:00:12Z', 'claude-opus-5'),
296
+ ]));
297
+ setTimeout(() => writeFileSync(transcriptA, jsonl([
298
+ user('14個のファイルを順に読んで', '2026-10-04T03:00:10Z'),
299
+ assistant('part04 を読みました。', '2026-10-04T03:00:12Z', 'claude-opus-5'),
300
+ assistant('part05 を読みました。次に part06 を読みます。', '2026-10-04T03:00:14Z', 'claude-opus-5'),
301
+ { type: 'assistant', message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'tool_use', id: 'toolu_6', name: 'Read', input: {} }] } },
302
+ ])), 50);
303
+ const first = await run(SESSION, transcriptA, 'toolu_6');
304
+ assert.equal(first.record.state, 'sent');
305
+ assert.equal(first.record.successor.short_id, 'a41a97ae');
306
+ assert.equal(first.record.settings.model, 'claude-opus-5');
307
+ assert.deepEqual(first.calls[0].slice(3, 5), ['--model', 'claude-opus-5']);
308
+ assert.deepEqual(first.record.in_flight, {
309
+ user: { content: '14個のファイルを順に読んで', timestamp: Date.parse('2026-10-04T03:00:10Z') },
310
+ last_fragment: { content: 'part05 を読みました。次に part06 を読みます。', timestamp: Date.parse('2026-10-04T03:00:14Z') },
311
+ steps: [
312
+ { kind: 'text', content: 'part04 を読みました。', timestamp: Date.parse('2026-10-04T03:00:12Z') },
313
+ { kind: 'text', content: 'part05 を読みました。次に part06 を読みます。', timestamp: Date.parse('2026-10-04T03:00:14Z') },
314
+ ],
315
+ earlier_steps: null,
316
+ stopped_tools: [{ name: 'Read', target: '' }],
317
+ stopped_tools_total: 1,
318
+ turn: null,
319
+ }, '止めた道具の直前の発言まで出そろってから写す');
320
+
321
+ // 後継 B が止められた時、最後の user 発言は A からの継続の指示。現在地には元の依頼を運ぶ
322
+ const transcriptB = join(dir, 'b.jsonl');
323
+ writeFileSync(transcriptB, jsonl([
324
+ user(`Another Claude session sent a message:\n${claudeContinuationInput(first.record)}\n\nThis came from another Claude session — not typed by your user.`,
325
+ '2026-10-04T03:01:00Z'),
326
+ assistant('part09 を読みました。次に part10 を読みます。', '2026-10-04T03:01:20Z', 'claude-opus-5'),
327
+ { type: 'assistant', message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'tool_use', id: 'toolu_10', name: 'Read', input: {} }] } },
328
+ ]));
329
+ rmSync(join(dir, 'targets'), { recursive: true, force: true });
330
+ const second = await run(SUCCESSOR, transcriptB, 'toolu_10');
331
+ assert.deepEqual(second.record.in_flight, {
332
+ user: { content: '14個のファイルを順に読んで', timestamp: Date.parse('2026-10-04T03:00:10Z') },
333
+ last_fragment: { content: 'part09 を読みました。次に part10 を読みます。', timestamp: Date.parse('2026-10-04T03:01:20Z') },
334
+ steps: [{ kind: 'text', content: 'part09 を読みました。次に part10 を読みます。', timestamp: Date.parse('2026-10-04T03:01:20Z') }],
335
+ earlier_steps: null,
336
+ stopped_tools: [{ name: 'Read', target: '' }],
337
+ stopped_tools_total: 1,
338
+ turn: null,
339
+ });
340
+ }));
341
+
342
+ function makeMemoryDb() {
343
+ const db = makeBatonDb();
344
+ db.exec(`
345
+ CREATE TABLE sessions (session_id TEXT PRIMARY KEY, merged_into TEXT);
346
+ CREATE TABLE bodies (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, origin_session_id TEXT NOT NULL,
347
+ turn_number INTEGER NOT NULL, role TEXT NOT NULL, text TEXT NOT NULL, token_count INTEGER, created_at INTEGER NOT NULL,
348
+ turn_start TEXT, UNIQUE(session_id, origin_session_id, turn_number, role));
349
+ CREATE TABLE details (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, origin_session_id TEXT,
350
+ turn_number INTEGER, tool_name TEXT NOT NULL, input_text TEXT, output_text TEXT, token_count INTEGER NOT NULL DEFAULT 0,
351
+ created_at INTEGER NOT NULL, kind TEXT, source_id TEXT);
352
+ CREATE UNIQUE INDEX uq_details_source ON details(session_id, origin_session_id, source_id) WHERE source_id IS NOT NULL;
353
+ `);
354
+ return db;
355
+ }
356
+
357
+ test('worker: 止めたターンを DB へ取り込み、ここまでにしたことと、実行されなかった道具を記録へ写す', () => withDir(async dir => {
358
+ const block = (id, content, timestamp) =>
359
+ ({ type: 'assistant', timestamp, message: { id, role: 'assistant', model: 'claude-opus-5', content: [content] } });
360
+ const result = (id, text, isError = false) =>
361
+ ({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: text, ...(isError ? { is_error: true } : {}) }] } });
362
+ const transcript = join(dir, 'work.jsonl');
363
+ writeFileSync(transcript, jsonl([
364
+ user('前の依頼', '2026-10-04T02:00:00Z'),
365
+ assistant('前の回答', '2026-10-04T02:00:05Z', 'claude-opus-5'),
366
+ user('a.mjs を直して', '2026-10-04T03:00:00Z'),
367
+ block('m1', { type: 'text', text: '読みます。' }, '2026-10-04T03:00:01Z'),
368
+ block('m1', { type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/work/a.mjs' } }, '2026-10-04T03:00:01Z'),
369
+ result('t1', 'export const a = 1;'),
370
+ block('m2', { type: 'text', text: '直します。' }, '2026-10-04T03:00:03Z'),
371
+ block('m2', { type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: '/work/app/src/a.mjs', old_string: '1', new_string: '2' } }, '2026-10-04T03:00:03Z'),
372
+ result('t2', 'ok'),
373
+ block('m3', { type: 'tool_use', id: 't3', name: 'Bash', input: { command: 'npm test\n --silent' } }, '2026-10-04T03:00:05Z'),
374
+ result('t3', 'Exit code 1', true),
375
+ // ここで自動圧縮を止めた。次の応答が呼んだ道具は、2つとも実行されていない
376
+ block('m4', { type: 'text', text: '試験が落ちました。\n原因を見ます。' }, '2026-10-04T03:00:08Z'),
377
+ block('m4', { type: 'tool_use', id: 't4', name: 'Read', input: { file_path: `/work/app/test/${'d'.repeat(200)}/a.test.mjs` } }, '2026-10-04T03:00:08Z'),
378
+ block('m4', { type: 'tool_use', id: 't5', name: 'Grep', input: { pattern: 'expect\\(' } }, '2026-10-04T03:00:08Z'),
379
+ result('t5', 'PreToolUse:Grep hook error', true),
380
+ result('t4', 'PreToolUse:Read hook error', true),
381
+ ]));
382
+ const db = makeMemoryDb();
383
+ requestClaudeAutoHandoff({ payload: { session_id: SESSION, trigger: 'auto', cwd: '/work/app', transcript_path: transcript },
384
+ env: {}, config: ENABLED, openDb: () => db, dir });
385
+ // hook は並んで走る。記録に残る止めた道具は、応答の中の2つ目のこともある
386
+ await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app', transcript_path: transcript, tool_use_id: 't5' },
387
+ dir, launchWorker: async () => {} });
388
+ const record = await runClaudeAutoHandoffWorker(SESSION, { dir, env: {}, pollMs: 10, targetTimeoutMs: 300, transcriptTimeoutMs: 300,
389
+ openDb: () => db,
390
+ send: async () => ({ status: 'accepted', outcome_unknown: false, reason: 'receiver_confirmed' }),
391
+ spawn: () => {
392
+ recordClaudeSuccessorTarget({ payload: { session_id: SUCCESSOR, source: 'startup', cwd: '/work/app' },
393
+ env: { CLAUDE_CODE_MESSAGING_SOCKET: '/tmp/cc-socks/1.sock' }, dir });
394
+ return { status: 0, stderr: '', stdout: 'backgrounded · a41a97ae · tl-app (idle — send a prompt to start)\n' };
395
+ } });
396
+ assert.equal(record.state, 'sent');
397
+ assert.deepEqual(record.in_flight.steps, [
398
+ { kind: 'text', content: '読みます。', timestamp: Date.parse('2026-10-04T03:00:01Z') },
399
+ { kind: 'tool', name: 'Read', target: '/work/a.mjs', failed: false },
400
+ { kind: 'text', content: '直します。', timestamp: Date.parse('2026-10-04T03:00:03Z') },
401
+ { kind: 'tool', name: 'Edit', target: 'src/a.mjs', failed: false },
402
+ { kind: 'tool', name: 'Bash', target: 'npm test --silent', failed: true },
403
+ { kind: 'text', content: '試験が落ちました。 原因を見ます。', timestamp: Date.parse('2026-10-04T03:00:08Z') },
404
+ ], '前のターンは入れない。止めた道具は、ここまでにしたことに入れない。作業ディレクトリの中は相対で書く');
405
+ assert.deepEqual(record.in_flight.stopped_tools, [
406
+ { name: 'Read', target: `…${'d'.repeat(159 - '/a.test.mjs'.length)}/a.test.mjs` }, { name: 'Grep', target: 'expect\\(' }],
407
+ '長い場所は末尾を残す');
408
+ assert.equal(record.in_flight.stopped_tools_total, 2);
409
+ assert.deepEqual(record.in_flight.turn, { origin_session_id: SESSION, turn_number: 5,
410
+ user_at: Date.parse('2026-10-04T03:00:00Z'), assistant_at: Date.parse('2026-10-04T03:00:08Z'), details: 10 });
411
+ assert.equal(publicClaudeAutoHandoff(record).in_flight_captured, true);
412
+ assert.equal(JSON.stringify(publicClaudeAutoHandoff(record)).includes('a.mjs'), false, '道具の対象は外へ見せない');
413
+
414
+ // DB: 止めたターンは、発言を全部つないだ本文と、道具の入出力(止めた道具を含む)で入る
415
+ assert.deepEqual(db.prepare('SELECT session_id, turn_number, role, text, created_at FROM bodies ORDER BY id').all().map(row => ({ ...row })), [
416
+ { session_id: SESSION, turn_number: 5, role: 'user', text: 'a.mjs を直して', created_at: Date.parse('2026-10-04T03:00:00Z') },
417
+ { session_id: SESSION, turn_number: 5, role: 'assistant', text: '読みます。\n\n直します。\n\n試験が落ちました。\n原因を見ます。',
418
+ created_at: Date.parse('2026-10-04T03:00:08Z') },
419
+ ]);
420
+ assert.deepEqual(db.prepare(`SELECT kind, tool_name, turn_number FROM details WHERE origin_session_id = ? ORDER BY id`).all(SESSION)
421
+ .map(row => `${row.kind}:${row.tool_name}:${row.turn_number}`), [
422
+ 'tool_input:Read:5', 'tool_output:Read:5', 'tool_input:Edit:5', 'tool_output:Edit:5', 'tool_input:Bash:5', 'tool_output:Bash:5',
423
+ 'tool_input:Read:5', 'tool_input:Grep:5', 'tool_output:Grep:5', 'tool_output:Read:5']);
424
+ }));
425
+
426
+ test('worker: 止めたターンを取り込めなくても後継を立て、取り込めなかったことを記録に残す', () => withDir(async dir => {
427
+ const transcript = join(dir, 'work.jsonl');
428
+ writeFileSync(transcript, jsonl([user('依頼', '2026-10-04T03:00:00Z'), assistant('途中です。', '2026-10-04T03:00:02Z'), toolUse('toolu_1', 'Read')]));
429
+ const db = makeBatonDb(); // bodies も sessions も無い
430
+ requestClaudeAutoHandoff({ payload: { session_id: SESSION, trigger: 'auto', cwd: '/work/app', transcript_path: transcript },
431
+ env: {}, config: ENABLED, openDb: () => db, dir });
432
+ await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app', transcript_path: transcript, tool_use_id: 'toolu_1' },
433
+ dir, launchWorker: async () => {} });
434
+ const record = await runClaudeAutoHandoffWorker(SESSION, { dir, env: {}, pollMs: 10, targetTimeoutMs: 300, transcriptTimeoutMs: 300,
435
+ openDb: () => db,
436
+ send: async () => ({ status: 'accepted', outcome_unknown: false, reason: 'receiver_confirmed' }),
437
+ spawn: () => {
438
+ recordClaudeSuccessorTarget({ payload: { session_id: SUCCESSOR, source: 'startup', cwd: '/work/app' },
439
+ env: { CLAUDE_CODE_MESSAGING_SOCKET: '/tmp/cc-socks/1.sock' }, dir });
440
+ return { status: 0, stderr: '', stdout: 'backgrounded · a41a97ae · tl-app (idle — send a prompt to start)\n' };
441
+ } });
442
+ assert.equal(record.state, 'sent');
443
+ assert.equal(record.in_flight.turn, null);
444
+ assert.equal(record.in_flight.steps.length, 1);
445
+ assert.equal(publicClaudeAutoHandoff(record).in_flight_captured, false);
446
+ }));
447
+
448
+ test('worker: 立ち上げ・受け口・配送の失敗は固定の理由を残し、結果が不明な配送は再送しない', () => withDir(async dir => {
449
+ const sendOk = async () => ({ status: 'accepted', outcome_unknown: false, reason: 'receiver_confirmed' });
450
+ const cases = [
451
+ [{ spawn: () => ({ error: Object.assign(new Error('not found'), { code: 'ENOENT' }) }), send: sendOk }, 'failed', 'handoff_claude_cli_unavailable', 0],
452
+ [{ spawn: () => ({ status: 1, stdout: '', stderr: 'Workspace not trusted' }), send: sendOk }, 'failed', 'handoff_successor_launch_failed', 0],
453
+ [{ spawn: () => ({ status: 0, stdout: 'unexpected output', stderr: '' }), send: sendOk }, 'failed', 'handoff_successor_launch_failed', 0],
454
+ [{ spawn: launchedOk, send: sendOk }, 'failed', 'handoff_successor_target_unavailable', 0],
455
+ [{ spawn: launchedOk, onLaunched: successorStarts(dir),
456
+ send: async () => ({ status: 'not_sent', outcome_unknown: false, reason: 'connect_failed' }) }, 'failed', 'handoff_delivery_connect_failed', 1],
457
+ [{ spawn: launchedOk, onLaunched: successorStarts(dir),
458
+ send: async () => ({ status: 'unknown', outcome_unknown: true, reason: 'unconfirmed' }) }, 'unknown', 'handoff_delivery_unconfirmed', 1],
459
+ ];
460
+ for (const [deps, state, code, sends] of cases) {
461
+ rmSync(join(dir, `${SESSION}.json`), { force: true });
462
+ rmSync(join(dir, `${SESSION}.claim`), { force: true });
463
+ const { record, calls } = await runWorker(dir, deps);
464
+ assert.deepEqual([record.state, record.error_code, calls.send.length], [state, code, sends], code);
465
+ }
466
+ // 結果が不明な引き継ぎは、次の道具でも立ち上げ直さない
467
+ const launched = [];
468
+ await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app' }, dir, launchWorker: async id => { launched.push(id); } });
469
+ assert.deepEqual(launched, []);
470
+ assert.equal((await runClaudeAutoHandoffWorker(SESSION, { dir, spawn: () => { throw new Error('must not spawn'); } })).state, 'unknown');
471
+ }));
472
+
473
+ test('SessionStart: 後継の立ち上げ中の引き継ぎがある project の、新しく始まった会話だけが受け口を控える', () => withDir(async dir => {
474
+ const env = { CLAUDE_CODE_MESSAGING_SOCKET: '/tmp/cc-socks/1.sock', CLAUDE_CODE_MESSAGING_TOKEN: 't' };
475
+ const start = (overrides = {}, environment = env) => recordClaudeSuccessorTarget({
476
+ payload: { session_id: SUCCESSOR, source: 'startup', cwd: '/work/app', ...overrides }, env: environment, dir });
477
+ assert.equal(start(), false, '引き継ぎが無い');
478
+ request(dir);
479
+ assert.equal(start(), false, 'まだ旧い会話を止めていない');
480
+
481
+ const write = fields => writeFileSync(join(dir, `${SESSION}.json`),
482
+ JSON.stringify({ ...readClaudeAutoHandoff(SESSION, dir), ...fields }));
483
+ write({ state: 'launching' });
484
+ assert.equal(start({ cwd: '/work/other' }), false, '別の project');
485
+ assert.equal(start({ source: 'resume' }), false);
486
+ assert.equal(start({ source: 'compact' }), false);
487
+ assert.equal(start({ agent_id: 'agent-1' }), false);
488
+ assert.equal(start({}, {}), false, '受け口を持たない会話');
489
+ assert.equal(start({ session_id: `grok:${SUCCESSOR}` }), false);
490
+ assert.equal(start(), true);
491
+ rmSync(join(dir, 'targets'), { recursive: true });
492
+
493
+ write({ state: 'launched', successor: { short_id: 'a41a97ae', session_id: null } });
494
+ assert.equal(start({ session_id: 'bbbbbbbb-0000-4000-8000-000000000001' }), false, '後継のIDと合わない会話');
495
+ assert.equal(start(), true);
496
+ write({ state: 'sent' });
497
+ rmSync(join(dir, 'targets'), { recursive: true });
498
+ assert.equal(start(), false, '送り終えた引き継ぎ');
499
+ }));
500
+
501
+ test('最初の指示: 後継だけが止めた時点の依頼を受け取り、この引き継ぎの配送だけが受領になる', () => withDir(async dir => {
502
+ request(dir);
503
+ await stopClaudeTurnForHandoff({ payload: { session_id: SESSION, cwd: '/work/app' }, dir, launchWorker: async () => {} });
504
+ const accept = (overrides = {}) => acceptClaudeAutoContinuation({ predecessorId: SESSION, successorSessionId: SUCCESSOR, dir, ...overrides });
505
+ assert.equal(accept(), null, '後継がまだ立っていない');
506
+ const record = readClaudeAutoHandoff(SESSION, dir);
507
+ const inFlight = { user: { content: '依頼', timestamp: 1 }, last_fragment: null };
508
+ writeFileSync(join(dir, `${SESSION}.json`), JSON.stringify({ ...record, state: 'sending',
509
+ successor: { short_id: 'a41a97ae', session_id: SUCCESSOR }, in_flight: inFlight }));
510
+ assert.equal(accept({ successorSessionId: 'bbbbbbbb-0000-4000-8000-000000000001' }), null, '後継ではない会話');
511
+ assert.equal(accept({ predecessorId: 'aaaaaaaa-0000-4000-8000-000000000009' }), null);
512
+ assert.equal(accept({ predecessorId: `codex:${SESSION}` }), null);
513
+
514
+ // 人が先に別の指示を打った時は、依頼は渡すが受領にはしない
515
+ assert.deepEqual(accept({ prompt: '別の指示' }), { handoffId: record.handoff_id, projectPath: '/work/app', inFlight, transcriptPath: null });
516
+ assert.equal(readClaudeAutoHandoff(SESSION, dir).accepted_at, null);
517
+ accept({ prompt: `Another Claude session sent a message:\n${claudeContinuationInput(record)}`, now: 9_000 });
518
+ assert.equal(readClaudeAutoHandoff(SESSION, dir).accepted_at, 9_000);
519
+ }));
520
+
521
+ test('公開CLI: --host claude は enable・disable・status・worker を受け取る', () => {
123
522
  assert.equal(parseAutoHandoffArgs(['status', '--json']).host, 'codex');
124
523
  assert.deepEqual(
125
524
  (({ action, host, project, json }) => ({ action, host, project, json }))(parseAutoHandoffArgs(['enable', '--host', 'claude', '--project', REPO_ROOT, '--json'])),
126
525
  { action: 'enable', host: 'claude', project: REPO_ROOT, json: true },
127
526
  );
128
- for (const args of [['resume', '--host', 'claude', '--operation', 'id'], ['status', '--host', 'claude', '--operation', 'id'],
129
- ['worker', '--host', 'claude', '--operation', 'id'], ['status', '--host', 'grok'], ['status', '--host'], ['disable', '--host', 'claude', '--project', '/p']]) {
527
+ assert.equal(parseAutoHandoffArgs(['status', '--host', 'claude', '--operation', 'id']).operation, 'id');
528
+ assert.equal(parseAutoHandoffArgs(['worker', '--host', 'claude', '--operation', SESSION]).action, 'worker');
529
+ for (const args of [['resume', '--host', 'claude', '--operation', 'id'], ['detail', '--host', 'claude', '--operation', 'id', '--origin', 'A', '--turn', '1'],
530
+ ['worker', '--host', 'claude'], ['status', '--host', 'grok'], ['status', '--host'], ['disable', '--host', 'claude', '--project', REPO_ROOT]]) {
130
531
  assert.throws(() => parseAutoHandoffArgs(args), JSON.stringify(args));
131
532
  }
132
533
  });
133
534
 
134
- test('公開CLI: enable は PreCompact hook と設定を書き、Codex の設定には触らない', () => withDir(home => {
535
+ // ---- 実際の CLI を temp HOME で動かす ----
536
+
537
+ function childEnv(home, extra = {}) {
538
+ // 設定とstateの置き場も一時HOMEへ向ける(本物のruntime error storeへ書かない)。
539
+ return {
540
+ ...process.env,
541
+ HOME: home,
542
+ USERPROFILE: home,
543
+ XDG_CONFIG_HOME: join(home, '.config'),
544
+ XDG_STATE_HOME: join(home, '.local', 'state'),
545
+ LOCALAPPDATA: join(home, 'AppData', 'Local'),
546
+ THROUGHLINE_NO_VSCODE: '1',
547
+ CLAUDE_PROJECT_DIR: '',
548
+ CLAUDE_CODE_ENTRYPOINT: 'cli',
549
+ CLAUDE_CODE_MESSAGING_SOCKET: '',
550
+ CLAUDE_CODE_MESSAGING_TOKEN: '',
551
+ ...extra,
552
+ };
553
+ }
554
+
555
+ function cli(home, args, input = '', extra = {}) {
556
+ return spawnSync(process.execPath, [CLI, ...args], { cwd: REPO_ROOT, env: childEnv(home, extra), input, encoding: 'utf8' });
557
+ }
558
+
559
+ /** 本物の claude を起動させない。すぐ失敗する偽物を PATH の先頭に置く。 */
560
+ function fakeClaudePath(home) {
561
+ const bin = join(home, 'fake-bin');
562
+ mkdirSync(bin, { recursive: true });
563
+ if (process.platform === 'win32') writeFileSync(join(bin, 'claude.cmd'), '@echo off\r\nexit /b 1\r\n');
564
+ else {
565
+ writeFileSync(join(bin, 'claude'), '#!/bin/sh\nexit 1\n');
566
+ chmodSync(join(bin, 'claude'), 0o755);
567
+ }
568
+ const pathKey = Object.keys(process.env).find(key => key.toLowerCase() === 'path') ?? 'PATH';
569
+ return { [pathKey]: `${bin}${delimiter}${process.env[pathKey] ?? ''}` };
570
+ }
571
+
572
+ test('公開CLI: enable は2つの hook と設定を書き、disable は自分の hook だけを外す。Codex の設定には触らない', () => withDir(home => {
135
573
  const settingsPath = join(home, '.claude', 'settings.json');
136
574
  mkdirSync(dirname(settingsPath), { recursive: true });
137
- // 他製品のhookと、matcher付きの古い自分の登録がある状態から始める
138
- writeFileSync(settingsPath, JSON.stringify({ model: 'sonnet', hooks: { PreCompact: [
139
- { matcher: 'auto', hooks: [{ type: 'command', command: 'throughline pre-compact' }] },
140
- { hooks: [{ type: 'command', command: 'other-product pre-compact' }] },
141
- ] } }));
575
+ // 他製品の hook と、0.13.0 が置いた PreCompact がある状態から始める
576
+ const others = { PreCompact: [{ hooks: [{ type: 'command', command: 'other-product pre-compact' }] }],
577
+ PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'rtk hook claude' }] }] };
578
+ writeFileSync(settingsPath, JSON.stringify({ model: 'sonnet', hooks: {
579
+ PreCompact: [{ hooks: [{ type: 'command', command: 'throughline pre-compact' }] }, ...others.PreCompact],
580
+ PreToolUse: others.PreToolUse } }));
142
581
 
143
582
  const before = JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout);
144
583
  assert.deepEqual(before, { host: 'claude', config: { schema: 'throughline.claude-auto-handoff.v1', enabled: false, projects: [] },
145
- hook: { registered: false }, pending: [] });
584
+ hooks: { registered: false }, handoffs: [] });
146
585
  assert.equal(existsSync(join(home, '.throughline')), false, 'status は何も作らない');
147
586
 
148
587
  const enabled = cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']);
@@ -150,27 +589,28 @@ test('公開CLI: enable は PreCompact hook と設定を書き、Codex の設定
150
589
  assert.equal(JSON.parse(enabled.stdout).status, 'enabled');
151
590
  const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
152
591
  assert.equal(settings.model, 'sonnet');
153
- assert.deepEqual(settings.hooks.PreCompact, [
154
- { hooks: [{ type: 'command', command: 'throughline pre-compact' }] },
155
- { hooks: [{ type: 'command', command: 'other-product pre-compact' }] },
156
- ]);
592
+ assert.deepEqual(settings.hooks, {
593
+ PreCompact: [...others.PreCompact, { hooks: [{ type: 'command', command: 'throughline pre-compact' }] }],
594
+ PreToolUse: [...others.PreToolUse, { hooks: [{ type: 'command', command: 'throughline pre-tool-use', timeout: 15 }] }],
595
+ });
157
596
  const again = cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']);
158
597
  assert.equal(again.status, 0, again.stderr);
159
598
  assert.equal(readFileSync(settingsPath, 'utf8'), JSON.stringify(settings, null, 2) + '\n', '2回目は書き換えない');
160
599
 
161
- const text = cli(home, ['auto-handoff', 'status', '--host', 'claude']);
162
- assert.equal(text.stdout, '自動継続: 有効(Claude Code)\nPreCompact hook: 登録済み\n');
600
+ assert.equal(cli(home, ['auto-handoff', 'status', '--host', 'claude']).stdout,
601
+ '自動継続: 有効(Claude Code)\nhook(PreCompact・PreToolUse): 登録済み\n');
163
602
  assert.equal(existsSync(join(home, '.throughline', 'codex-auto-handoff.json')), false);
164
603
  assert.equal(JSON.parse(cli(home, ['auto-handoff', 'status', '--json']).stdout).config.enabled, false, 'Codex は無効のまま');
165
604
 
166
605
  const disabled = JSON.parse(cli(home, ['auto-handoff', 'disable', '--host', 'claude', '--json']).stdout);
167
606
  assert.equal(disabled.config.enabled, false);
607
+ assert.deepEqual(JSON.parse(readFileSync(settingsPath, 'utf8')).hooks, others);
168
608
  const unsupported = cli(home, ['auto-handoff', 'resume', '--host', 'claude', '--operation', 'x', '--json']);
169
609
  assert.equal(unsupported.status, 1);
170
610
  assert.deepEqual(JSON.parse(unsupported.stdout), { status: 'failed', code: 'auto_handoff_action_unsupported' });
171
611
  }));
172
612
 
173
- test('hook: 自動圧縮の直後に、作業途中のターンと直近の会話を同じ会話へ注入し、最終回答は1ターンとして保存する', () => withDir(home => {
613
+ test('hook: 自動圧縮を止め、次の道具を止めて worker を起動し、後継の最初の指示へ止めた時点の依頼と記憶を注入する', () => withDir(async home => {
174
614
  const project = join(home, 'project');
175
615
  mkdirSync(project, { recursive: true });
176
616
  const transcriptPath = join(home, 'transcript.jsonl');
@@ -180,129 +620,119 @@ test('hook: 自動圧縮の直後に、作業途中のターンと直近の会
180
620
  user('成果物の1行目には必ず「約束: MANGO-4417」と書くこと', at(0)),
181
621
  assistant('了解しました。', at(1)),
182
622
  user('big1.txt と big2.txt を読んで result.txt を作って', at(10)),
183
- toolUse('toolu_1', 'Read'),
623
+ toolUse('toolu_1', 'Read', { file_path: '/p/big1.txt' }),
184
624
  toolResult('toolu_1', 'big1 contents'),
185
625
  assistant('big1.txt を読み終えました。次に big2.txt を読みます。', at(12)),
626
+ toolUse('toolu_2', 'Read', { file_path: '/p/big2.txt' }),
186
627
  ]));
187
- const common = { session_id: SESSION, cwd: project, transcript_path: transcriptPath };
188
- const hook = (command, payload) => cli(home, [command], JSON.stringify({ ...common, ...payload }));
628
+ const extra = fakeClaudePath(home);
629
+ const hook = (command, payload, sessionId = SESSION) =>
630
+ cli(home, [command], JSON.stringify({ session_id: sessionId, cwd: project, transcript_path: transcriptPath, ...payload }), extra);
631
+ const status = () => JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout).handoffs;
189
632
 
190
633
  assert.equal(cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--project', project, '--json']).status, 0);
191
634
  assert.equal(hook('session-start', { hook_event_name: 'SessionStart', source: 'startup' }).status, 0);
192
635
 
193
- // 手動の /compact では注入しない
636
+ // 記録が無い間、道具の hook は何も出さずに終わる
637
+ const idle = hook('pre-tool-use', { hook_event_name: 'PreToolUse', tool_name: 'Read' });
638
+ assert.deepEqual([idle.status, idle.stdout, idle.stderr], [0, '', '']);
639
+ // 手動の /compact は止めない
194
640
  const manual = hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'manual' });
195
- assert.equal(manual.status, 0, manual.stderr);
196
- assert.equal(manual.stdout, '', 'PreCompact の stdout は空(JSON の decision を返さない)');
197
- assert.equal(hook('session-start', { hook_event_name: 'SessionStart', source: 'compact' }).stdout, '');
641
+ assert.deepEqual([manual.status, manual.stdout], [0, '']);
198
642
 
199
- // 自動圧縮
643
+ // 自動圧縮: 止める(exit code 2)
200
644
  const pre = hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'auto' });
201
- assert.equal(pre.status, 0, pre.stderr);
645
+ assert.equal(pre.status, 2, pre.stderr);
202
646
  assert.equal(pre.stdout, '');
203
- assert.equal(JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout).pending.length, 1);
204
- appendFileSync(transcriptPath, jsonl([compactSummary()]));
205
-
206
- const compacted = hook('session-start', { hook_event_name: 'SessionStart', source: 'compact' });
207
- assert.equal(compacted.status, 0, compacted.stderr);
208
- assert.match(compacted.stdout, /^## Throughline: 自動圧縮後の継続用コンテキスト\n/);
209
- assert.match(compacted.stdout, /### 現在地 \(作業途中のターン\)\n\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: big1\.txt と big2\.txt を読んで result\.txt を作って\n/);
210
- assert.match(compacted.stdout, /\*\*圧縮直前のあなたの発言\*\* \[\d\d:\d\d:\d\d\]: big1\.txt を読み終えました。次に big2\.txt を読みます。\n/);
211
- assert.match(compacted.stdout, /\[user\]: 成果物の1行目には必ず「約束: MANGO-4417」と書くこと\n/);
212
- assert.match(compacted.stdout, /\[assistant\]: 了解しました。/);
213
- assert.doesNotMatch(compacted.stdout, /This session is being continued/, '圧縮の要約は記録に入れない');
214
- assert.ok(compacted.stdout.length <= 9_501);
215
-
647
+ assert.match(pre.stderr, /Throughlineが自動圧縮を止めました。新しい会話へ引き継ぎます/);
648
+ assert.deepEqual(status().map(item => item.state), ['requested']);
649
+
650
+ // 次の道具: 実行させずに止め、worker を起動する
651
+ const stopped = hook('pre-tool-use', { hook_event_name: 'PreToolUse', tool_name: 'Read', tool_use_id: 'toolu_2', permission_mode: 'acceptEdits' });
652
+ assert.equal(stopped.status, 0, stopped.stderr);
653
+ const output = JSON.parse(stopped.stdout);
654
+ assert.equal(output.continue, false);
655
+ assert.equal(output.hookSpecificOutput.permissionDecision, 'deny');
656
+ // worker は偽の claude(すぐ失敗)で止まる。本物の claude は起動しない
657
+ let handoff;
658
+ for (let i = 0; i < 100 && (handoff = status()[0]).state !== 'failed'; i++) await new Promise(r => setTimeout(r, 100));
659
+ assert.deepEqual([handoff.state, handoff.error_code], ['failed', 'handoff_successor_launch_failed']);
660
+
661
+ // 後継が立って指示が届いた場面を、記録を進めて再現する
662
+ const handoffDir = join(home, '.throughline', 'claude-auto-handoff');
663
+ const recordFile = join(handoffDir, `${SESSION}.json`);
664
+ const record = JSON.parse(readFileSync(recordFile, 'utf8'));
665
+ assert.equal(record.settings.permission_mode, 'acceptEdits');
666
+ // worker は後継を立てる前に、止めたターンを DB へ取り込んでいる
667
+ assert.equal(handoff.in_flight_captured, true);
668
+ assert.equal(record.in_flight.turn.origin_session_id, SESSION);
669
+ assert.equal(record.in_flight.turn.details, 3);
670
+ writeFileSync(recordFile, JSON.stringify({ ...record, state: 'launching', error_code: null }));
671
+ const started = hook('session-start', { hook_event_name: 'SessionStart', source: 'startup' }, SUCCESSOR);
672
+ assert.equal(started.status, 0, started.stderr);
673
+ assert.equal(started.stdout, '');
674
+ assert.equal(existsSync(join(handoffDir, 'targets')), false, '受け口を持たない会話は控えない');
675
+ const withSocket = cli(home, ['session-start'], JSON.stringify({ session_id: SUCCESSOR, cwd: project, hook_event_name: 'SessionStart', source: 'startup' }),
676
+ { ...extra, CLAUDE_CODE_MESSAGING_SOCKET: join(home, 'in.sock'), CLAUDE_CODE_MESSAGING_TOKEN: 'secret-token' });
677
+ assert.equal(withSocket.status, 0, withSocket.stderr);
678
+ assert.deepEqual(readdirSync(join(handoffDir, 'targets')), [`${SUCCESSOR}.json`]);
679
+ writeFileSync(recordFile, JSON.stringify({ ...record, state: 'sending', error_code: null,
680
+ successor: { short_id: SUCCESSOR.slice(0, 8), session_id: SUCCESSOR } }));
681
+
682
+ const prompt = `Another Claude session sent a message:\n${claudeContinuationInput(record)}`;
683
+ const first = cli(home, ['prompt-submit'], JSON.stringify({ session_id: SUCCESSOR, cwd: project, hook_event_name: 'UserPromptSubmit', prompt }), extra);
684
+ assert.equal(first.status, 0, first.stderr);
685
+ assert.match(first.stdout, /^## Throughline: 自動継続の文脈\n/);
686
+ assert.match(first.stdout, /### 現在地 \(作業の途中で止めたターン\)\n\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: big1\.txt と big2\.txt を読んで result\.txt を作って\n/);
687
+ assert.match(first.stdout, new RegExp('\\*\\*このターンでここまでにしたこと\\*\\*(古い順。どれも完了済み。道具の入出力の全文: `throughline detail \\d\\d:\\d\\d:\\d\\d`):\\n' +
688
+ '- 道具: Read /p/big1\\.txt\\n- 発言: big1\\.txt を読み終えました。次に big2\\.txt を読みます。\\n' +
689
+ '\\*\\*止める直前に呼ぼうとして、実行されなかった道具\\*\\*: Read /p/big2\\.txt\\n'));
690
+ assert.match(first.stdout, /\[user\]: 成果物の1行目には必ず「約束: MANGO-4417」と書くこと\n/);
691
+ assert.equal(first.stdout.split('big1.txt と big2.txt を読んで').length, 2, '止めたターンの依頼は現在地に1回だけ');
692
+ assert.doesNotMatch(first.stdout, /宣言|\/clear/);
693
+ assert.ok(first.stdout.length <= 9_501);
694
+ assert.ok(JSON.parse(readFileSync(recordFile, 'utf8')).accepted_at > 0, '届いた指示で受領を記録する');
695
+
696
+ // 記憶は後継へ合流している(今の /tl の引き継ぎと同じ)
216
697
  const db = new DatabaseSync(join(home, '.throughline', 'throughline.db'));
217
- const bodies = () => db.prepare('SELECT turn_number, role, text FROM bodies ORDER BY turn_number, role DESC').all()
218
- .map(row => [row.turn_number, row.role, row.text]);
219
- assert.deepEqual(bodies(), [
220
- [1, 'user', '成果物の1行目には必ず「約束: MANGO-4417」と書くこと'],
221
- [1, 'assistant', '了解しました。'],
222
- ], '作業途中のターンは保存しない(途中の発言を回答として固定しない)');
223
-
224
- // 印は消費済み。次の圧縮が手動なら何も出さない
225
- assert.equal(JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout).pending.length, 0);
226
- assert.equal(hook('session-start', { hook_event_name: 'SessionStart', source: 'compact' }).stdout, '');
227
-
228
- // 圧縮の後、作業が終わる
229
- appendFileSync(transcriptPath, jsonl([
230
- toolUse('toolu_2', 'Read'),
231
- toolResult('toolu_2', 'big2 contents'),
232
- assistant('result.txt を作りました。', at(40)),
233
- ]));
234
- const stop = hook('process-turn', { hook_event_name: 'Stop', last_assistant_message: 'result.txt を作りました。' });
235
- assert.equal(stop.status, 0, stop.stderr);
236
- assert.deepEqual(bodies().slice(2), [
237
- [5, 'user', 'big1.txt と big2.txt を読んで result.txt を作って'],
238
- [5, 'assistant', 'result.txt を作りました。'],
698
+ assert.equal(db.prepare('SELECT merged_into FROM sessions WHERE session_id = ?').get(SESSION).merged_into, SUCCESSOR);
699
+ assert.deepEqual(db.prepare('SELECT DISTINCT session_id FROM bodies').all().map(row => row.session_id), [SUCCESSOR]);
700
+ // 止めたターンは、道具の入出力ごと後継の記憶に入っている(`throughline detail` で取り出せる)
701
+ assert.deepEqual(db.prepare(`SELECT b.role, b.text, (SELECT COUNT(*) FROM details d WHERE d.session_id = b.session_id
702
+ AND d.origin_session_id = b.origin_session_id AND d.turn_number = b.turn_number) AS details
703
+ FROM bodies b WHERE b.origin_session_id = ? AND b.turn_number = ? ORDER BY b.id`).all(SESSION, record.in_flight.turn.turn_number)
704
+ .map(row => ({ ...row })), [
705
+ { role: 'user', text: 'big1.txt と big2.txt を読んで result.txt を作って', details: 3 },
706
+ { role: 'assistant', text: 'big1.txt を読み終えました。次に big2.txt を読みます。', details: 3 },
239
707
  ]);
240
- const tools = db.prepare("SELECT tool_name FROM details WHERE kind = 'tool_input' AND turn_number = 5 ORDER BY id").all();
241
- assert.deepEqual(tools.map(row => row.tool_name), ['Read', 'Read'], '圧縮より前のtoolも同じターンのL3に入る');
708
+ assert.equal(db.prepare('SELECT COUNT(*) AS c FROM handoff_batons').get().c, 0);
242
709
  db.close();
243
710
 
244
711
  const decisions = readFileSync(join(home, '.throughline', 'logs', 'inheritance-decision.log'), 'utf8').trim().split('\n').map(JSON.parse);
245
712
  assert.deepEqual(decisions.filter(entry => entry.phase === 'pre-compact').map(entry => [entry.trigger, entry.auto_continuation, entry.skip_reason]),
246
713
  [['manual', 'skipped', 'manual_compact'], ['auto', 'requested', null]]);
247
- const continuation = decisions.filter(entry => entry.phase === 'compact-continuation');
248
- assert.equal(continuation.length, 1);
249
- assert.equal(continuation[0].injected, true);
250
- assert.equal(continuation[0].in_flight_fragments, 1);
251
- assert.equal(continuation[0].injected_l2_turns, 1);
252
- }));
253
-
254
- test('hook: transcript に今の依頼がまだ無い時(prompt_id が合わない)、前のターンを作業中として見せず、保存もしない', () => withDir(home => {
255
- const project = join(home, 'project');
256
- mkdirSync(project, { recursive: true });
257
- const transcriptPath = join(home, 'transcript.jsonl');
258
- writeFileSync(transcriptPath, jsonl([
259
- { ...user('最初の依頼', '2026-10-04T03:00:00Z'), promptId: 'prompt-1' },
260
- assistant('最初の回答', '2026-10-04T03:00:01Z'),
261
- { ...user('前の依頼', '2026-10-04T03:00:10Z'), promptId: 'prompt-2' },
262
- assistant('前の回答', '2026-10-04T03:00:11Z'),
263
- ]));
264
- const common = { session_id: SESSION, cwd: project, transcript_path: transcriptPath, prompt_id: 'prompt-3' };
265
- const hook = (command, payload) => cli(home, [command], JSON.stringify({ ...common, ...payload }));
266
- assert.equal(cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']).status, 0);
267
- assert.equal(hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'auto' }).status, 0);
268
-
269
- const compacted = hook('session-start', { hook_event_name: 'SessionStart', source: 'compact' });
270
- assert.equal(compacted.status, 0, compacted.stderr);
271
- assert.match(compacted.stdout, /\*\*作業中のユーザー依頼\*\*: (記録からまだ読めません。/);
272
- assert.doesNotMatch(compacted.stdout, /前の依頼|前の回答|圧縮直前のあなたの発言/);
273
- assert.match(compacted.stdout, /\[user\]: 最初の依頼\n/);
274
- const db = new DatabaseSync(join(home, '.throughline', 'throughline.db'));
275
- assert.deepEqual(db.prepare('SELECT text FROM bodies ORDER BY id').all().map(row => row.text), ['最初の依頼', '最初の回答']);
276
- db.close();
277
-
278
- // prompt_id が合う時は、その群が作業途中のターン
279
- assert.equal(hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'auto', prompt_id: 'prompt-2' }).status, 0);
280
- const matched = hook('session-start', { hook_event_name: 'SessionStart', source: 'compact', prompt_id: 'prompt-2' });
281
- assert.match(matched.stdout, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: 前の依頼\n\*\*圧縮直前のあなたの発言\*\* \[\d\d:\d\d:\d\d\]: 前の回答\n/);
714
+ assert.equal(decisions.filter(entry => entry.phase === 'pre-tool-use-stop').length, 1);
715
+ const merged = decisions.find(entry => entry.phase === 'prompt-submit' && entry.merged);
716
+ assert.deepEqual([merged.triggered_path, merged.injection.auto_handoff_id], ['baton', record.handoff_id]);
282
717
  }));
283
718
 
284
- test('hook: 自動継続が無効な時、自動圧縮の前後で何も残さず何も出さない', () => withDir(home => {
719
+ test('hook: 自動継続が無効な時、自動圧縮を止めず、何も残さない', () => withDir(home => {
285
720
  const project = join(home, 'project');
286
721
  mkdirSync(project, { recursive: true });
287
- const transcriptPath = join(home, 'transcript.jsonl');
288
- writeFileSync(transcriptPath, jsonl([user('依頼'), assistant('途中')]));
289
- const common = { session_id: SESSION, cwd: project, transcript_path: transcriptPath };
290
- const hook = (command, payload) => cli(home, [command], JSON.stringify({ ...common, ...payload }));
291
-
292
- const pre = hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'auto' });
293
- assert.equal(pre.status, 0, pre.stderr);
294
- assert.equal(pre.stdout, '');
722
+ const payload = { session_id: SESSION, cwd: project, hook_event_name: 'PreCompact', trigger: 'auto' };
723
+ const pre = cli(home, ['pre-compact'], JSON.stringify(payload));
724
+ assert.deepEqual([pre.status, pre.stdout, pre.stderr], [0, '', '']);
295
725
  assert.equal(existsSync(join(home, '.throughline', 'claude-auto-handoff')), false);
296
- const compacted = hook('session-start', { hook_event_name: 'SessionStart', source: 'compact' });
297
- assert.equal(compacted.status, 0, compacted.stderr);
298
- assert.equal(compacted.stdout, '');
726
+ assert.equal(existsSync(join(home, '.throughline', 'throughline.db')), false, '無効な時は DB も開かない');
299
727
  }));
300
728
 
301
- test('hook: PreCompact が失敗しても圧縮を止める終了コード(2)を返さず、理由を端末に残す', () => withDir(home => {
302
- const failed = cli(home, ['pre-compact'], '{"trigger":"auto"}');
303
- assert.equal(failed.status, 1);
304
- assert.equal(failed.stdout, '');
729
+ test('hook: 自分の失敗では圧縮も道具も止めず(exit code 1)、理由を端末に残す', () => withDir(home => {
730
+ const compact = cli(home, ['pre-compact'], '{"trigger":"auto"}');
731
+ assert.deepEqual([compact.status, compact.stdout], [1, '']);
732
+ const tool = cli(home, ['pre-tool-use'], '{not json');
733
+ assert.deepEqual([tool.status, tool.stdout], [1, '']);
305
734
  const log = readFileSync(join(home, '.throughline', 'logs', 'hook-failures.log'), 'utf8');
306
735
  assert.match(log, /HOOK_PRE_COMPACT_FAILED/);
307
736
  assert.match(log, /Missing session_id in PreCompact payload/);
737
+ assert.match(log, /HOOK_PRE_TOOL_USE_FAILED/);
308
738
  }));