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.
- package/CHANGELOG.md +46 -1
- package/README.ja.md +14 -7
- package/README.md +14 -7
- package/bin/throughline.mjs +10 -4
- package/docs/02_clear_auto_handoff_plan.md +2 -2
- package/docs/05_codex_first_roadmap.md +3 -2
- package/docs/adr/0032-claude-compact-continuation.md +2 -0
- package/docs/adr/0033-claude-auto-handoff-new-session.md +104 -0
- package/package.json +2 -2
- package/rag/01-hooks/raw/claude-compact-hooks-extract.md +28 -3
- package/rag/INDEX.md +1 -1
- package/src/claude-auto-handoff.mjs +540 -135
- package/src/claude-auto-handoff.test.mjs +623 -193
- package/src/claude-pre-compact.mjs +21 -7
- package/src/claude-pre-tool-use.mjs +67 -0
- package/src/cli/auto-handoff.mjs +25 -19
- package/src/cli/install.mjs +67 -18
- package/src/cli/install.test.mjs +22 -4
- package/src/handoff-executor.mjs +24 -12
- package/src/hosts/claude.mjs +10 -0
- package/src/prompt-submit.mjs +1 -0
- package/src/resume-context.mjs +108 -40
- package/src/resume-context.test.mjs +88 -31
- package/src/runtime-error-store.mjs +6 -1
- package/src/session-start.mjs +8 -28
- package/src/transcript-reader.mjs +26 -4
- package/src/transcript-reader.test.mjs +43 -0
- package/src/turn-backfill.mjs +82 -8
- package/src/turn-backfill.test.mjs +75 -23
- package/src/turn-processor.mjs +4 -0
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* PreCompact hook — Claude Code の自動継続の第一段 (ADR
|
|
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
|
-
*
|
|
7
|
+
* 自動圧縮(trigger=auto)で、自動継続が有効な project の時だけ、/tl と同じ印と引き継ぎの記録を残し、
|
|
8
|
+
* 圧縮を止める。Claude Code は PreCompact の exit code 2 を「圧縮を止める」と解釈する。
|
|
9
|
+
* 止めた後の会話は圧縮されないまま続き、次の道具の hook(PreToolUse)が作業を止める。
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 {
|
|
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 =
|
|
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
|
+
}
|
package/src/cli/auto-handoff.mjs
CHANGED
|
@@ -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,
|
|
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 {
|
|
13
|
+
import { listClaudeAutoHandoffs, publicClaudeAutoHandoff, runClaudeAutoHandoffWorker } from '../claude-auto-handoff.mjs';
|
|
13
14
|
|
|
14
|
-
// Claude
|
|
15
|
-
//
|
|
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' &&
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
82
|
-
|
|
82
|
+
const config = writeClaudeAutoHandoffConfig({ ...readClaudeAutoHandoffConfig(), enabled: false });
|
|
83
|
+
// PreToolUse は道具の呼び出しのたびに走る。使わない間は置いたままにしない。
|
|
84
|
+
removeClaudeAutoHandoffHooks();
|
|
85
|
+
return { status: 'disabled', host: 'claude', config };
|
|
83
86
|
}
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
110
|
-
for (const item of result.
|
|
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 }) };
|
package/src/cli/install.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
|
652
|
-
|
|
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,
|
|
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)`);
|
package/src/cli/install.test.mjs
CHANGED
|
@@ -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
|
-
//
|
|
671
|
-
assert.
|
|
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
|
-
|
|
675
|
-
|
|
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();
|
package/src/handoff-executor.mjs
CHANGED
|
@@ -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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 =
|
|
136
|
-
|
|
137
|
-
|
|
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
|
}
|
package/src/hosts/claude.mjs
CHANGED
|
@@ -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,
|