throughline 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,23 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * PreCompact hook — Claude Code の自動継続の第一段 (ADR 0032)
3
+ * PreCompact hook — Claude Code の自動継続の第一段 (ADR 0033)
4
4
  *
5
5
  * stdin: { session_id, transcript_path, cwd, hook_event_name, trigger, custom_instructions }
6
6
  *
7
- * 自動圧縮(trigger=auto)で、自動継続が有効な project の時だけ、その会話に印を残す。
8
- * 記憶の注入は圧縮の後の SessionStart(source=compact) が行う。
7
+ * 自動圧縮(trigger=auto)で、自動継続が有効な project の時だけ、/tl と同じ印と引き継ぎの記録を残し、
8
+ * 圧縮を止める。Claude Code は PreCompact の exit code 2 を「圧縮を止める」と解釈する。
9
+ * 止めた後の会話は圧縮されないまま続き、次の道具の hook(PreToolUse)が作業を止める。
9
10
  *
10
- * この hook は圧縮を止めない。Claude Code は PreCompact の exit code 2 と `decision: "block"` を
11
- * 「圧縮を止める」と解釈するので、stdout には何も書かず、失敗した時も exit code 1 で終わる。
11
+ * 手動の /compact、無効な project、subagent の中の圧縮では、何も止めずに exit code 0 で終わる。
12
+ * この hook 自身が失敗した時は exit code 1 で終わり、圧縮は止めない。
12
13
  */
13
14
 
14
15
  import { pathToFileURL } from 'node:url';
15
16
  import { parseHookPayload } from './hosts/index.mjs';
16
- import { recordClaudeCompactRequest } from './claude-auto-handoff.mjs';
17
+ import { requestClaudeAutoHandoff } from './claude-auto-handoff.mjs';
18
+ import { getDb } from './db.mjs';
17
19
  import { logDecision } from './decision-log.mjs';
18
20
  import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
19
21
  import { logHookFailure } from './hook-failure-log.mjs';
20
22
 
23
+ export const PRE_COMPACT_BLOCK_EXIT_CODE = 2;
24
+
21
25
  export async function run() {
22
26
  let raw = '';
23
27
  await new Promise((resolve) => {
@@ -30,7 +34,12 @@ export async function run() {
30
34
 
31
35
  const payload = parseHookPayload(raw, { env: process.env });
32
36
  const now = Date.now();
33
- const result = recordClaudeCompactRequest({ payload, env: process.env, now });
37
+ const result = requestClaudeAutoHandoff({
38
+ payload,
39
+ env: process.env,
40
+ now,
41
+ openDb: getDb,
42
+ });
34
43
 
35
44
  logDecision({
36
45
  ts: new Date(now).toISOString(),
@@ -39,9 +48,14 @@ export async function run() {
39
48
  project_path: result.projectPath ?? null,
40
49
  trigger: payload.trigger ?? null,
41
50
  auto_continuation: result.status,
51
+ handoff_id: result.handoffId ?? null,
42
52
  skip_reason: result.reason ?? null,
43
53
  });
44
54
 
55
+ if (result.block) {
56
+ process.stderr.write(`Throughlineが自動圧縮を止めました。新しい会話へ引き継ぎます(引き継ぎID: ${result.handoffId})。\n`);
57
+ process.exit(PRE_COMPACT_BLOCK_EXIT_CODE);
58
+ }
45
59
  process.exit(0);
46
60
  }
47
61
 
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreToolUse hook — Claude Code の自動継続の第二段 (ADR 0033)
4
+ *
5
+ * stdin: { session_id, transcript_path, cwd, permission_mode, effort, tool_name, tool_input, ... }
6
+ *
7
+ * `auto-handoff enable --host claude` が登録する。道具の呼び出しのたびに走るので、引き継ぎの記録が無い
8
+ * 会話では、記録のファイルの有無だけを見て終わる(DB も他の module も読み込まない)。
9
+ *
10
+ * 記録がある会話では、その道具を実行させずにターンを止め、後継を立てる worker を起動する。
11
+ * この hook 自身が失敗した時は exit code 1 で終わる。道具は止まらず、作業は続く。
12
+ */
13
+
14
+ import { existsSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { pathToFileURL } from 'node:url';
18
+
19
+ // claude-auto-handoff.mjs と同じ置き場と形。速い道を保つために、ここでは import しない。
20
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
21
+
22
+ export async function run() {
23
+ let raw = '';
24
+ await new Promise((resolve) => {
25
+ process.stdin.setEncoding('utf8');
26
+ process.stdin.on('data', (chunk) => {
27
+ raw += chunk;
28
+ });
29
+ process.stdin.on('end', resolve);
30
+ });
31
+
32
+ const payload = JSON.parse(raw.replace(/^/, ''));
33
+ 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);
37
+ }
38
+
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`);
51
+ process.exit(0);
52
+ }
53
+
54
+ async function reportFailure(err) {
55
+ const { recordRuntimeErrorBestEffort } = await import('./runtime-error-store.mjs');
56
+ const { logHookFailure } = await import('./hook-failure-log.mjs');
57
+ recordRuntimeErrorBestEffort('HOOK_PRE_TOOL_USE_FAILED');
58
+ logHookFailure('HOOK_PRE_TOOL_USE_FAILED', err);
59
+ }
60
+
61
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
62
+ run().catch(async (err) => {
63
+ await reportFailure(err);
64
+ process.stderr.write(`[pre-tool-use] error: ${err.message}\n`);
65
+ process.exit(1);
66
+ });
67
+ }
@@ -6,14 +6,15 @@ import { listAutoHandoffs, getAutoHandoff } from '../codex-auto-handoff-store.mj
6
6
  import { renderFrozenDetail } from '../codex-auto-handoff-memory.mjs';
7
7
  import { readAutoHandoffConfig, writeAutoHandoffConfig } from '../codex-auto-handoff-config.mjs';
8
8
  import { autoHandoffDeliveryProfile, runAutoHandoffWorker } from '../codex-auto-handoff.mjs';
9
- import { installCodexAutoHandoffHook, installClaudeAutoHandoffHook, claudeAutoHandoffHookRegistered } from './install.mjs';
9
+ import { installCodexAutoHandoffHook, installClaudeAutoHandoffHooks, removeClaudeAutoHandoffHooks,
10
+ claudeAutoHandoffHooksRegistered } from './install.mjs';
10
11
  import { sameProjectPath } from '../project-path.mjs';
11
12
  import { readClaudeAutoHandoffConfig, writeClaudeAutoHandoffConfig } from '../claude-auto-handoff-config.mjs';
12
- import { listClaudeCompactRequests } from '../claude-auto-handoff.mjs';
13
+ import { listClaudeAutoHandoffs, publicClaudeAutoHandoff, runClaudeAutoHandoffWorker } from '../claude-auto-handoff.mjs';
13
14
 
14
- // Claude の自動継続は圧縮後の同じ会話へ記憶を注入する (ADR 0032)。引き継ぎ操作の記録を持たないので、
15
- // 使える action は有効化・無効化・状態だけ。
16
- const CLAUDE_ACTIONS = ['enable', 'disable', 'status'];
15
+ // Claude の自動継続 (ADR 0033)。引き継ぎの記録は旧い会話の session id で持つ。結果が不明な配送は
16
+ // 再送しないので、resume は持たない。記憶の取得は通常の recall / detail を使うので、detail も持たない。
17
+ const CLAUDE_ACTIONS = ['enable', 'disable', 'status', 'worker'];
17
18
 
18
19
  export function parseAutoHandoffArgs(args) {
19
20
  const out = { action: args[0] ?? 'status', host: 'codex', operation: null, project: null, origin: null, turn: null, database: null, json: false };
@@ -30,7 +31,7 @@ export function parseAutoHandoffArgs(args) {
30
31
  out[key] = args[++i];
31
32
  }
32
33
  if (!['codex', 'claude'].includes(out.host)) throw Error('auto_handoff_host_invalid');
33
- if (out.host === 'claude' && (!CLAUDE_ACTIONS.includes(out.action) || out.operation)) throw Error('auto_handoff_action_unsupported');
34
+ if (out.host === 'claude' && !CLAUDE_ACTIONS.includes(out.action)) throw Error('auto_handoff_action_unsupported');
34
35
  if (out.project && !isAbsolute(out.project)) out.project = resolve(out.project);
35
36
  if (out.project && out.operation) throw Error('auto_handoff_argument_invalid');
36
37
  if (['worker', 'resume', 'detail'].includes(out.action) && !out.operation) throw Error('auto_handoff_operation_required');
@@ -67,24 +68,29 @@ export async function enableAutoHandoff({ project = null, codexHome = realCodexH
67
68
  return { status: 'enabled', config, hookKeys, host: 'desktop' };
68
69
  }
69
70
 
70
- export function enableClaudeAutoHandoff({ project = null, register = installClaudeAutoHandoffHook } = {}) {
71
+ export function enableClaudeAutoHandoff({ project = null, register = installClaudeAutoHandoffHooks } = {}) {
71
72
  const registration = register();
72
73
  const current = readClaudeAutoHandoffConfig();
73
74
  const projects = project ? [...new Set([...(current.enabled ? current.projects : []), project])] : [];
74
75
  const config = writeClaudeAutoHandoffConfig({ enabled: true, projects });
75
- return { status: 'enabled', config, hook: { ...registration, registered: true }, host: 'claude' };
76
+ return { status: 'enabled', config, hooks: { ...registration, registered: true }, host: 'claude' };
76
77
  }
77
78
 
78
- function runClaude(parsed) {
79
+ async function runClaude(parsed) {
79
80
  if (parsed.action === 'enable') return enableClaudeAutoHandoff({ project: parsed.project });
80
81
  if (parsed.action === 'disable') {
81
- return { status: 'disabled', host: 'claude',
82
- config: writeClaudeAutoHandoffConfig({ ...readClaudeAutoHandoffConfig(), enabled: false }) };
82
+ const config = writeClaudeAutoHandoffConfig({ ...readClaudeAutoHandoffConfig(), enabled: false });
83
+ // PreToolUse は道具の呼び出しのたびに走る。使わない間は置いたままにしない。
84
+ removeClaudeAutoHandoffHooks();
85
+ return { status: 'disabled', host: 'claude', config };
83
86
  }
84
- const pending = listClaudeCompactRequests()
85
- .filter(request => !parsed.project || sameProjectPath(request.project_path, parsed.project));
87
+ if (parsed.action === 'worker') return publicClaudeAutoHandoff(await runClaudeAutoHandoffWorker(parsed.operation));
88
+ const handoffs = listClaudeAutoHandoffs()
89
+ .filter(record => !parsed.project || sameProjectPath(record.project_path, parsed.project))
90
+ .filter(record => !parsed.operation || record.handoff_id === parsed.operation || record.source_session_id === parsed.operation)
91
+ .map(publicClaudeAutoHandoff);
86
92
  return { host: 'claude', config: readClaudeAutoHandoffConfig(),
87
- hook: { registered: claudeAutoHandoffHookRegistered() }, pending };
93
+ hooks: { registered: claudeAutoHandoffHooksRegistered() }, handoffs };
88
94
  }
89
95
 
90
96
  export function publicOperation(operation) {
@@ -102,14 +108,14 @@ export async function run(args = []) {
102
108
  const parsed = parseAutoHandoffArgs(args);
103
109
  let result;
104
110
  if (parsed.host === 'claude') {
105
- result = runClaude(parsed);
111
+ result = await runClaude(parsed);
106
112
  if (parsed.json) process.stdout.write(JSON.stringify(result) + '\n');
107
113
  else if (parsed.action === 'status') {
108
114
  process.stdout.write(`自動継続: ${result.config.enabled ? '有効' : '無効'}(Claude Code)\n`);
109
- process.stdout.write(`PreCompact hook: ${result.hook.registered ? '登録済み' : '未登録'}\n`);
110
- for (const item of result.pending) process.stdout.write(`${item.session_id} 圧縮待ち ${new Date(item.requested_at).toISOString()}\n`);
115
+ process.stdout.write(`hook(PreCompact・PreToolUse): ${result.hooks.registered ? '登録済み' : '未登録'}\n`);
116
+ for (const item of result.handoffs) process.stdout.write(`${item.handoff_id} ${item.state} ${item.error_code ?? ''}\n`);
111
117
  } else process.stdout.write(JSON.stringify(result, null, 2) + '\n');
112
- return 0;
118
+ return result.state === 'failed' || result.state === 'unknown' ? 1 : 0;
113
119
  }
114
120
  if (parsed.action === 'enable') result = await enableAutoHandoff({ project: parsed.project });
115
121
  else if (parsed.action === 'disable') result = { status: 'disabled', config: writeAutoHandoffConfig({ ...readAutoHandoffConfig(), enabled: false }) };
@@ -19,6 +19,7 @@ import { join, dirname, resolve, delimiter } from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { homedir } from 'node:os';
21
21
  import { ensureMonitorTaskFile, shouldRecommendGitignore } from '../vscode-task.mjs';
22
+ import { readClaudeAutoHandoffConfig } from '../claude-auto-handoff-config.mjs';
22
23
 
23
24
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
24
25
  const SLASH_COMMANDS_SRC = join(PACKAGE_ROOT, '.claude', 'commands');
@@ -37,6 +38,7 @@ const SC_COMMANDS = [
37
38
  'throughline session-start',
38
39
  'throughline prompt-submit',
39
40
  'throughline pre-compact',
41
+ 'throughline pre-tool-use',
40
42
  // 旧コマンド(アンインストール時に除去する)
41
43
  'throughline inject-context',
42
44
  'throughline capture-tool',
@@ -56,14 +58,22 @@ const SC_HOOKS = {
56
58
  UserPromptSubmit: {
57
59
  hooks: [{ type: 'command', command: 'throughline prompt-submit' }],
58
60
  },
59
- // 自動継続の第一段 (ADR 0032)。matcher は付けない: 手動の /compact でも呼ばれ、古い印を消す。
60
- // 自動継続が無効な時は何も残さずに抜ける。
61
+ };
62
+
63
+ // Claude の自動継続 (ADR 0033) の hook。install は登録しない。`auto-handoff enable --host claude` が登録し、
64
+ // `disable` が外す。PreToolUse は道具の呼び出しのたびに走るので、使わない端末には置かない。
65
+ // PreCompact に matcher は付けない: 手動の /compact でも呼ばれ、止める前の記録を取り下げる。
66
+ const CLAUDE_AUTO_HANDOFF_HOOKS = {
61
67
  PreCompact: {
62
68
  hooks: [{ type: 'command', command: 'throughline pre-compact' }],
63
69
  },
70
+ PreToolUse: {
71
+ hooks: [{ type: 'command', command: 'throughline pre-tool-use', timeout: 15 }],
72
+ },
64
73
  };
65
-
66
- const CLAUDE_PRE_COMPACT_COMMAND = SC_HOOKS.PreCompact.hooks[0].command;
74
+ const CLAUDE_AUTO_HANDOFF_COMMANDS = new Set(
75
+ Object.values(CLAUDE_AUTO_HANDOFF_HOOKS).map(entry => entry.hooks[0].command),
76
+ );
67
77
 
68
78
  const CODEX_COMMANDS = [
69
79
  'throughline codex-hook stop',
@@ -637,23 +647,57 @@ function installCodexHooks() {
637
647
  return { hooksPath, configPath };
638
648
  }
639
649
 
640
- export function claudeAutoHandoffHookRegistered(settingsPath = join(homedir(), '.claude', 'settings.json')) {
641
- const groups = readSettings(settingsPath).hooks?.PreCompact ?? [];
642
- // matcher 付きの登録は手動の /compact で呼ばれず、古い印が残る。matcher 無しだけを登録済みとする。
643
- return groups.some(group => !group.matcher && (group.hooks ?? []).some(hook => hook.command === CLAUDE_PRE_COMPACT_COMMAND));
650
+ const defaultClaudeSettingsPath = () => join(homedir(), '.claude', 'settings.json');
651
+
652
+ function isClaudeAutoHandoffEntry(group, event) {
653
+ const entry = CLAUDE_AUTO_HANDOFF_HOOKS[event];
654
+ return !group.matcher && (group.hooks ?? []).length === 1 &&
655
+ group.hooks[0].command === entry.hooks[0].command && group.hooks[0].timeout === entry.hooks[0].timeout;
656
+ }
657
+
658
+ /** 自動継続の hook が、今の形で2つとも登録されているか。 */
659
+ export function claudeAutoHandoffHooksRegistered(settingsPath = defaultClaudeSettingsPath()) {
660
+ const hooks = readSettings(settingsPath).hooks ?? {};
661
+ return Object.keys(CLAUDE_AUTO_HANDOFF_HOOKS).every(event =>
662
+ (hooks[event] ?? []).filter(group => isClaudeAutoHandoffEntry(group, event)).length === 1 &&
663
+ (hooks[event] ?? []).flatMap(group => group.hooks ?? [])
664
+ .filter(hook => CLAUDE_AUTO_HANDOFF_COMMANDS.has(hook.command)).length === 1);
644
665
  }
645
666
 
646
- /** `auto-handoff enable --host claude` 用。古い版で install した端末にも PreCompact hook を足す。 */
647
- export function installClaudeAutoHandoffHook(settingsPath = join(homedir(), '.claude', 'settings.json')) {
648
- if (!claudeAutoHandoffHookRegistered(settingsPath)) {
667
+ function withoutClaudeAutoHandoffHooks(hooks) {
668
+ const next = {};
669
+ for (const [event, groups] of Object.entries(hooks)) {
670
+ const kept = groups.map(group => ({ ...group,
671
+ hooks: (group.hooks ?? []).filter(hook => !CLAUDE_AUTO_HANDOFF_COMMANDS.has(hook.command)) }))
672
+ .filter(group => group.hooks.length);
673
+ if (kept.length) next[event] = kept;
674
+ }
675
+ return next;
676
+ }
677
+
678
+ /** `auto-handoff enable --host claude` 用。他製品の hook とその並びは保ち、自分の2つを今の形で置く。 */
679
+ export function installClaudeAutoHandoffHooks(settingsPath = defaultClaudeSettingsPath()) {
680
+ if (!claudeAutoHandoffHooksRegistered(settingsPath)) {
649
681
  const current = readSettings(settingsPath);
650
- current.hooks ??= {};
651
- const preserved = (current.hooks.PreCompact ?? []).map(group => ({ ...group,
652
- hooks: (group.hooks ?? []).filter(hook => hook.command !== CLAUDE_PRE_COMPACT_COMMAND) })).filter(group => group.hooks.length);
653
- current.hooks.PreCompact = [SC_HOOKS.PreCompact, ...preserved];
654
- writeSettings(settingsPath, current);
682
+ const hooks = withoutClaudeAutoHandoffHooks(current.hooks ?? {});
683
+ for (const [event, entry] of Object.entries(CLAUDE_AUTO_HANDOFF_HOOKS)) hooks[event] = [...(hooks[event] ?? []), entry];
684
+ writeSettings(settingsPath, { ...current, hooks });
655
685
  }
656
- return { settingsPath, command: CLAUDE_PRE_COMPACT_COMMAND };
686
+ return { settingsPath, commands: [...CLAUDE_AUTO_HANDOFF_COMMANDS] };
687
+ }
688
+
689
+ /** `auto-handoff disable --host claude` と、無効な端末の install 用。自分の2つだけを外す。 */
690
+ export function removeClaudeAutoHandoffHooks(settingsPath = defaultClaudeSettingsPath()) {
691
+ if (!existsSync(settingsPath)) return { settingsPath, removed: false };
692
+ const current = readSettings(settingsPath);
693
+ const before = current.hooks ?? {};
694
+ const hooks = withoutClaudeAutoHandoffHooks(before);
695
+ if (JSON.stringify(hooks) === JSON.stringify(before)) return { settingsPath, removed: false };
696
+ const next = { ...current };
697
+ if (Object.keys(hooks).length) next.hooks = hooks;
698
+ else delete next.hooks;
699
+ writeSettings(settingsPath, next);
700
+ return { settingsPath, removed: true };
657
701
  }
658
702
 
659
703
  export function installCodexAutoHandoffHook(codexHome = join(homedir(), '.codex')) {
@@ -773,6 +817,12 @@ export async function run(args = []) {
773
817
 
774
818
  current.hooks = existingHooks;
775
819
  writeSettings(settingsPath, current);
820
+ // Claude の自動継続の hook は、有効にした端末にだけ置く (ADR 0033)。0.13.0 の install が置いた
821
+ // PreCompact も、無効な端末からは外す。project の install では触らない。
822
+ if (!args.includes('--project')) {
823
+ if (readClaudeAutoHandoffConfig().enabled) installClaudeAutoHandoffHooks(settingsPath);
824
+ else removeClaudeAutoHandoffHooks(settingsPath);
825
+ }
776
826
  const { installed: installedCommands, skipped } = installSlashCommands(commandsDir);
777
827
  const codex = args.includes('--project') ? null : installCodexHooks();
778
828
  const grok = args.includes('--project') ? null : installGrokHooks();
@@ -804,7 +854,6 @@ export async function run(args = []) {
804
854
  console.log(' SessionStart → throughline session-start (セッション記録・バトン消費・引き継ぎ注入)');
805
855
  console.log(' Stop → throughline process-turn (L1 要約 + L2 本文保存 + L3 詳細保存)');
806
856
  console.log(' UserPromptSubmit → throughline prompt-submit (/tl & /clear バトン書き込み)');
807
- console.log(' PreCompact → throughline pre-compact (自動継続の印。既定は無効: auto-handoff enable --host claude)');
808
857
  if (codex) {
809
858
  console.log(` Codex UserPromptSubmit → ${buildCodexUserPromptSubmitHookCommand()} (capture / monitor state only; auto refresh disabled)`);
810
859
  console.log(` Codex PostToolUse → ${buildCodexPostToolUseHookCommand()} (capture / monitor state only; auto refresh disabled)`);
@@ -667,12 +667,30 @@ test('Claude Stop hook is registered with async:true so it does not block ター
667
667
  .flatMap(g => g.hooks ?? [])
668
668
  .find(h => h.command === 'throughline prompt-submit');
669
669
  assert.notEqual(promptSubmitHook.async, true, 'UserPromptSubmit stays synchronous (needs baton write committed before turn)');
670
- // 自動継続の印 (ADR 0032)。matcher 無し = 手動の /compact でも呼ばれて古い印を消す。
671
- assert.deepEqual(settings.hooks.PreCompact, [{ hooks: [{ type: 'command', command: 'throughline pre-compact' }] }]);
670
+ // Claude の自動継続の hook (ADR 0033) は install では置かない。有効にした端末にだけ置く。
671
+ assert.equal(settings.hooks.PreCompact, undefined);
672
+ assert.equal(settings.hooks.PreToolUse, undefined);
673
+
674
+ // 0.13.0 の install が置いた PreCompact は、無効な端末の install で外す。他製品の hook は残す。
675
+ const settingsPath = join(home.dir, '.claude', 'settings.json');
676
+ const other = { hooks: [{ type: 'command', command: 'other-product pre-compact' }] };
677
+ writeFileSync(settingsPath, JSON.stringify({ ...settings, hooks: { ...settings.hooks,
678
+ PreCompact: [{ hooks: [{ type: 'command', command: 'throughline pre-compact' }] }, other] } }));
679
+ await run([]);
680
+ assert.deepEqual(JSON.parse(readFileSync(settingsPath, 'utf8')).hooks.PreCompact, [other]);
681
+
682
+ // 有効な端末の install は、2つの hook を今の形で保つ
683
+ mkdirSync(join(home.dir, '.throughline'), { recursive: true });
684
+ writeFileSync(join(home.dir, '.throughline', 'claude-auto-handoff.json'),
685
+ JSON.stringify({ schema: 'throughline.claude-auto-handoff.v1', enabled: true, projects: [] }));
686
+ await run([]);
687
+ const enabled = JSON.parse(readFileSync(settingsPath, 'utf8')).hooks;
688
+ assert.deepEqual(enabled.PreCompact, [other, { hooks: [{ type: 'command', command: 'throughline pre-compact' }] }]);
689
+ assert.deepEqual(enabled.PreToolUse, [{ hooks: [{ type: 'command', command: 'throughline pre-tool-use', timeout: 15 }] }]);
672
690
 
673
691
  await run(['--uninstall']);
674
- const removed = JSON.parse(readFileSync(join(home.dir, '.claude', 'settings.json'), 'utf8'));
675
- assert.equal(removed.hooks, undefined, 'uninstall removes every Throughline Claude hook including PreCompact');
692
+ assert.deepEqual(JSON.parse(readFileSync(settingsPath, 'utf8')).hooks, { PreCompact: [other] },
693
+ 'uninstall removes every Throughline Claude hook and keeps the others');
676
694
  } finally {
677
695
  unsilence();
678
696
  home.restore();
@@ -15,12 +15,14 @@ import { consumeBaton } from './baton.mjs';
15
15
  import { consumePendingHandoff } from './pending-handoff.mjs';
16
16
  import { mergeSpecificPredecessor, resolveMergeTarget } from './session-merger.mjs';
17
17
  import { backfillBodies, deriveTranscriptPath, logBackfill } from './turn-backfill.mjs';
18
- import { buildBudgetedResumeContext } from './resume-context.mjs';
18
+ import { buildBudgetedResumeContext, buildAutoContinuationContext } from './resume-context.mjs';
19
+ import { acceptClaudeAutoContinuation } from './claude-auto-handoff.mjs';
19
20
  import { readAllSessionStates } from './state-file.mjs';
20
21
 
21
22
  /**
22
23
  * @param {import('node:sqlite').DatabaseSync} db
23
- * @param {{ sessionId: string, projectPath: string, now?: number }} params
24
+ * @param {{ sessionId: string, projectPath: string, now?: number, prompt?: string|null }} params
25
+ * prompt は届いた指示の本文。前任が自動継続で引き継いだ会話の時に、配送の受領を照合する (ADR 0033)。
24
26
  * @returns {{
25
27
  * attempted: boolean, // pending 行が存在した (= newborn の初回プロンプト)
26
28
  * triggeredPath: 'baton' | 'auto' | null,
@@ -31,7 +33,7 @@ import { readAllSessionStates } from './state-file.mjs';
31
33
  * injectionStats: object | null, // dropped counts 等 (ログ用)
32
34
  * }}
33
35
  */
34
- export function executeFirstPromptHandoff(db, { sessionId, projectPath, now = Date.now() }) {
36
+ export function executeFirstPromptHandoff(db, { sessionId, projectPath, now = Date.now(), prompt = null }) {
35
37
  const pending = consumePendingHandoff(db, { sessionId });
36
38
  if (!pending) {
37
39
  return {
@@ -82,11 +84,14 @@ export function executeFirstPromptHandoff(db, { sessionId, projectPath, now = Da
82
84
  const stateTranscriptPath = readAllSessionStates().find(
83
85
  (state) => state.sessionId === predecessorId,
84
86
  )?.transcriptPath;
85
- const predecessorTranscriptPath = existsSync(derivedTranscriptPath)
86
- ? derivedTranscriptPath
87
- : stateTranscriptPath && existsSync(stateTranscriptPath)
88
- ? stateTranscriptPath
89
- : null;
87
+ // 前任が自動継続でこの会話へ引き継いだなら、止めた時点の依頼を現在地に載せる (ADR 0033)。
88
+ // 止めたターンは Stop を通っていないので、state ファイルが無いことがある。hook が記録した
89
+ // transcript の場所を、回収元の候補に足す。
90
+ const autoContinuation = triggeredPath === 'baton'
91
+ ? acceptClaudeAutoContinuation({ predecessorId: baton.sessionId, successorSessionId: sessionId, prompt, now })
92
+ : null;
93
+ const predecessorTranscriptPath = [derivedTranscriptPath, stateTranscriptPath, autoContinuation?.transcriptPath]
94
+ .find((candidate) => candidate && existsSync(candidate)) ?? null;
90
95
 
91
96
  if (predecessorTranscriptPath) {
92
97
  try {
@@ -132,10 +137,16 @@ export function executeFirstPromptHandoff(db, { sessionId, projectPath, now = Da
132
137
  });
133
138
  }
134
139
 
135
- const budgeted = buildBudgetedResumeContext(db, {
136
- sessionId,
137
- isInheritance: true,
138
- });
140
+ const budgeted = autoContinuation
141
+ ? buildAutoContinuationContext(db, {
142
+ sessionId,
143
+ inFlight: autoContinuation.inFlight,
144
+ projectPath: autoContinuation.projectPath,
145
+ })
146
+ : buildBudgetedResumeContext(db, {
147
+ sessionId,
148
+ isInheritance: true,
149
+ });
139
150
  if (budgeted) {
140
151
  injectionText = budgeted.text;
141
152
  injectionStats = {
@@ -145,6 +156,7 @@ export function executeFirstPromptHandoff(db, { sessionId, projectPath, now = Da
145
156
  older_turns: budgeted.olderTurns,
146
157
  older_summarized: budgeted.olderSummarized,
147
158
  truncated_newest_l2: budgeted.truncatedNewestL2,
159
+ ...(autoContinuation ? { auto_handoff_id: autoContinuation.handoffId } : {}),
148
160
  };
149
161
  }
150
162
  }
@@ -26,6 +26,16 @@ export function compactClaudeTaskNotification(text) {
26
26
  return kept ? `${block}\n${kept}` : block;
27
27
  }
28
28
 
29
+ // 別の会話から届いた発言(cross-session messaging、Throughline の自動継続の指示)には、Claude Code が
30
+ // 毎回同じ注意書きを後ろに付ける。記憶には送り主の行と本文だけを残す。
31
+ const PEER_MESSAGE_PREFIX = 'Another Claude session sent a message:';
32
+ const PEER_MESSAGE_NOTE = '\n\nThis came from another Claude session';
33
+ export function compactClaudePeerMessage(text) {
34
+ if (typeof text !== 'string' || !text.startsWith(PEER_MESSAGE_PREFIX)) return text;
35
+ const note = text.lastIndexOf(PEER_MESSAGE_NOTE);
36
+ return note < 0 ? text : text.slice(0, note);
37
+ }
38
+
29
39
  export const claudeHostAdapter = Object.freeze({
30
40
  host: CLAUDE_HOST,
31
41
  matchesSessionId: (sessionId) => hostOfSessionId(sessionId) === CLAUDE_HOST,
@@ -180,6 +180,7 @@ export async function run() {
180
180
  sessionId: session_id,
181
181
  projectPath,
182
182
  now,
183
+ prompt: typeof prompt === 'string' ? prompt : null,
183
184
  });
184
185
  if (handoff.attempted) {
185
186
  if (handoff.injectionText) {
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 呼び出し元:
5
5
  * - handoff-executor.mjs(最初のUserPromptSubmitでauto path / baton path共通注入)
6
- * - claude-auto-handoff.mjs(自動圧縮の直後のSessionStartで同じ会話へ注入。ADR 0032)
6
+ * - handoff-executor.mjs(前任が自動継続で引き継いだ会話の、最初のUserPromptSubmit。ADR 0033)
7
7
  *
8
8
  * 設計 (docs/02_clear_auto_handoff_plan.md):
9
9
  * - 予算付き注入: ヘッダ + 現在地アンカー + pull案内を固定部とし、残りへ
@@ -58,23 +58,24 @@ const NORMAL_HEADER = '## Throughline: セッション記憶';
58
58
 
59
59
  const DEFAULT_ANCHOR_TITLE = '### 現在地 (直前のやりとり)';
60
60
 
61
- // 自動圧縮の直後に、同じ会話へ注入する時のヘッダ (ADR 0032)。/clear の引き継ぎと違い、
62
- // 作業の途中で、次のユーザー入力は来ない。宣言はさせず、そのまま続けさせる。
63
- const COMPACT_HEADER =
64
- `## Throughline: 自動圧縮後の継続用コンテキスト\n` +
61
+ // 自動継続で立てた新しい会話へ注入する時のヘッダ (ADR 0033)。/clear の引き継ぎと違い、
62
+ // 前の会話は作業の途中で止めてあり、次のユーザー入力は来ない。宣言はさせず、そのまま続けさせる。
63
+ const AUTO_CONTINUATION_HEADER = (projectPath) =>
64
+ `## Throughline: 自動継続の文脈\n` +
65
+ (projectPath ? `作業ディレクトリ: ${projectPath}\n` : '') +
65
66
  `\n` +
66
67
  `**読み方 (重要):**\n` +
67
- `- この会話は、たった今 Claude Code の自動圧縮で要約に置き換わりました。下記は Throughline が` +
68
- `圧縮の前に保存した **あなた自身とユーザーの会話の原文** です。\n` +
69
- `- 圧縮の要約と下記の記録が食い違う時は、下記の記録(元のユーザーの依頼・合意)を正としてください。\n` +
70
- `- 「現在地」のターンは **作業の途中** です。ユーザーの追加入力を待たず、未完了の作業をそのまま続けてください。\n` +
71
- `- 完了済みの操作を繰り返さないでください。直前の実行結果が分からない時は、作業ツリーや記録を確かめてから続けてください。\n` +
68
+ `- 前の会話は文脈の上限に近づいたため、Throughline が自動圧縮の前に止めて、この新しい会話へ引き継ぎました。` +
69
+ `下記は、前の会話で **あなた自身とユーザーが交わした会話の原文** です。\n` +
70
+ `- 「現在地」のターンは **作業の途中で止めたもの** です。ユーザーの追加入力を待たず、未完了の作業をそのまま続けてください。\n` +
71
+ `- 完了済みの操作を繰り返さないでください。止める直前に呼ぼうとした道具は実行されていません。` +
72
+ `直前の実行結果が分からない時は、作業ツリーや記録を確かめてから続けてください。\n` +
72
73
  '- **完了したターンの詳細**: **`Bash` ツールで `throughline detail HH:MM:SS` を実行** ' +
73
74
  `(該当ターンの本文+詳細を stdout に返します)`;
74
75
 
75
- const COMPACT_ANCHOR_TITLE = '### 現在地 (作業途中のターン)';
76
- // 作業途中のターンは Stop の前で、まだ bodies に無い。依頼の原文を残せる場所はここだけなので、
77
- // /clear のアンカー (600 字) より厚く取る。
76
+ const AUTO_CONTINUATION_ANCHOR_TITLE = '### 現在地 (作業の途中で止めたターン)';
77
+ // 途中で止めたターンは、本文の断片が無ければ bodies に入らない。依頼の原文を必ず渡せる場所は
78
+ // ここだけなので、/clear のアンカー (600 字) より厚く取る。
78
79
  const IN_FLIGHT_USER_MAX_CHARS = 4_000;
79
80
  const IN_FLIGHT_ASSISTANT_MAX_CHARS = 1_500;
80
81
 
@@ -549,28 +550,25 @@ function truncateInFlight(text, maxChars) {
549
550
  }
550
551
 
551
552
  /**
552
- * 自動圧縮の直後に、同じ会話へ注入するテキスト (ADR 0032)。
553
+ * 自動継続で立てた新しい会話へ注入するテキスト (ADR 0033)。
553
554
  *
554
555
  * 構成は /clear の引き継ぎと同じ予算付き(ヘッダ + 現在地 + 案内 + 入るだけの L2)。違いは現在地で、
555
- * DB の最新ターンではなく、transcript から読んだ作業途中のターンを載せる。作業途中のターンは
556
- * Stop の前で bodies に無いので、L2 セクションには出ない。
557
- *
558
- * 完了したターンが 1 つも無い会話(最初のターンの途中で圧縮)でも、現在地だけを返す。
556
+ * DB の最新ターンではなく、止めた時点に記録した作業途中のターンを載せる。
559
557
  *
560
558
  * @param {import('node:sqlite').DatabaseSync} db
561
559
  * @param {{
562
- * sessionId: string,
560
+ * sessionId: string, 合流を終えた後継の session id
563
561
  * inFlight: {
564
562
  * user: {content: string, timestamp: number|null},
565
- * fragments: Array<{content: string, timestamp: number|null}>,
563
+ * last_fragment: {content: string, timestamp: number|null} | null,
566
564
  * } | null,
567
- * inFlightUnreadable?: boolean, transcript に今の依頼がまだ書かれていない(hook の prompt_id と合わない)
565
+ * projectPath?: string | null, 前の会話の作業ディレクトリ。依頼の中の相対的な場所の基準として渡す
568
566
  * maxChars?: number,
569
567
  * }} params
570
568
  */
571
- export function buildCompactContinuationContext(
569
+ export function buildAutoContinuationContext(
572
570
  db,
573
- { sessionId, inFlight, inFlightUnreadable = false, maxChars = INJECTION_BUDGET_CHARS },
571
+ { sessionId, inFlight, projectPath = null, maxChars = INJECTION_BUDGET_CHARS },
574
572
  ) {
575
573
  const stored = buildResumeSections(db, { sessionId, isInheritance: true, handoffDisclosure: 'silent' });
576
574
  const stamp = (timestamp) => (timestamp == null ? '' : ` [${formatClock(timestamp)}]`);
@@ -580,26 +578,20 @@ export function buildCompactContinuationContext(
580
578
  `**作業中のユーザー依頼**${stamp(inFlight.user.timestamp)}: ` +
581
579
  truncateInFlight(inFlight.user.content, IN_FLIGHT_USER_MAX_CHARS),
582
580
  );
583
- const lastFragment = inFlight.fragments.at(-1);
584
- if (lastFragment) {
581
+ if (inFlight.last_fragment) {
585
582
  anchorLines.push(
586
- `**圧縮直前のあなたの発言**${stamp(lastFragment.timestamp)}: ` +
587
- truncateInFlight(lastFragment.content, IN_FLIGHT_ASSISTANT_MAX_CHARS),
583
+ `**止める直前のあなたの発言**${stamp(inFlight.last_fragment.timestamp)}: ` +
584
+ truncateInFlight(inFlight.last_fragment.content, IN_FLIGHT_ASSISTANT_MAX_CHARS),
588
585
  );
589
586
  }
590
587
  }
591
- if (!inFlight && inFlightUnreadable) {
592
- anchorLines.push(
593
- '**作業中のユーザー依頼**: (記録からまだ読めません。圧縮後の文脈にある最新のユーザー依頼を続けてください)',
594
- );
595
- }
596
588
  if (!stored && anchorLines.length === 0) return null;
597
589
 
598
590
  return renderBudgetedSections(
599
591
  db,
600
592
  {
601
- header: COMPACT_HEADER,
602
- anchorTitle: COMPACT_ANCHOR_TITLE,
593
+ header: AUTO_CONTINUATION_HEADER(projectPath),
594
+ anchorTitle: AUTO_CONTINUATION_ANCHOR_TITLE,
603
595
  anchorLines,
604
596
  l1Lines: [],
605
597
  l2Lines: stored?.l2Lines ?? [],