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,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
+ }
@@ -1,19 +1,20 @@
1
1
  import { isAbsolute, resolve } from 'node:path';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { withCodexReceiver, platformDesktopFinder, realCodexHome } from 'aiterm-steer-delivery';
4
- import { openReadOnlyDb, DB_PATH, CURRENT_VERSION } from '../db.mjs';
4
+ import { getDb, openReadOnlyDb, DB_PATH, CURRENT_VERSION } from '../db.mjs';
5
5
  import { listAutoHandoffs, getAutoHandoff } from '../codex-auto-handoff-store.mjs';
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, { openDb: getDb }));
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) {