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.
- package/CHANGELOG.md +25 -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 +86 -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 +419 -137
- package/src/claude-auto-handoff.test.mjs +489 -193
- package/src/claude-pre-compact.mjs +21 -7
- package/src/claude-pre-tool-use.mjs +67 -0
- package/src/cli/auto-handoff.mjs +24 -18
- 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 +26 -34
- package/src/resume-context.test.mjs +26 -37
- package/src/runtime-error-store.mjs +6 -1
- package/src/session-start.mjs +8 -28
- package/src/transcript-reader.mjs +4 -4
- package/src/transcript-reader.test.mjs +19 -0
- package/src/turn-backfill.mjs +2 -7
- package/src/turn-backfill.test.mjs +0 -22
- 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
|
@@ -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,
|
|
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));
|
|
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,
|
package/src/prompt-submit.mjs
CHANGED
package/src/resume-context.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 呼び出し元:
|
|
5
5
|
* - handoff-executor.mjs(最初のUserPromptSubmitでauto path / baton path共通注入)
|
|
6
|
-
* -
|
|
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
|
-
//
|
|
62
|
-
//
|
|
63
|
-
const
|
|
64
|
-
`## Throughline:
|
|
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
|
-
`-
|
|
68
|
-
|
|
69
|
-
`-
|
|
70
|
-
`-
|
|
71
|
-
|
|
68
|
+
`- 前の会話は文脈の上限に近づいたため、Throughline が自動圧縮の前に止めて、この新しい会話へ引き継ぎました。` +
|
|
69
|
+
`下記は、前の会話で **あなた自身とユーザーが交わした会話の原文** です。\n` +
|
|
70
|
+
`- 「現在地」のターンは **作業の途中で止めたもの** です。ユーザーの追加入力を待たず、未完了の作業をそのまま続けてください。\n` +
|
|
71
|
+
`- 完了済みの操作を繰り返さないでください。止める直前に呼ぼうとした道具は実行されていません。` +
|
|
72
|
+
`直前の実行結果が分からない時は、作業ツリーや記録を確かめてから続けてください。\n` +
|
|
72
73
|
'- **完了したターンの詳細**: **`Bash` ツールで `throughline detail HH:MM:SS` を実行** ' +
|
|
73
74
|
`(該当ターンの本文+詳細を stdout に返します)`;
|
|
74
75
|
|
|
75
|
-
const
|
|
76
|
-
//
|
|
77
|
-
//
|
|
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
|
-
*
|
|
553
|
+
* 自動継続で立てた新しい会話へ注入するテキスト (ADR 0033)。
|
|
553
554
|
*
|
|
554
555
|
* 構成は /clear の引き継ぎと同じ予算付き(ヘッダ + 現在地 + 案内 + 入るだけの L2)。違いは現在地で、
|
|
555
|
-
* DB
|
|
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
|
-
*
|
|
563
|
+
* last_fragment: {content: string, timestamp: number|null} | null,
|
|
566
564
|
* } | null,
|
|
567
|
-
*
|
|
565
|
+
* projectPath?: string | null, 前の会話の作業ディレクトリ。依頼の中の相対的な場所の基準として渡す
|
|
568
566
|
* maxChars?: number,
|
|
569
567
|
* }} params
|
|
570
568
|
*/
|
|
571
|
-
export function
|
|
569
|
+
export function buildAutoContinuationContext(
|
|
572
570
|
db,
|
|
573
|
-
{ sessionId, inFlight,
|
|
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
|
-
|
|
584
|
-
if (lastFragment) {
|
|
581
|
+
if (inFlight.last_fragment) {
|
|
585
582
|
anchorLines.push(
|
|
586
|
-
|
|
587
|
-
truncateInFlight(
|
|
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:
|
|
602
|
-
anchorTitle:
|
|
593
|
+
header: AUTO_CONTINUATION_HEADER(projectPath),
|
|
594
|
+
anchorTitle: AUTO_CONTINUATION_ANCHOR_TITLE,
|
|
603
595
|
anchorLines,
|
|
604
596
|
l1Lines: [],
|
|
605
597
|
l2Lines: stored?.l2Lines ?? [],
|