throughline 0.12.9 → 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 +45 -1
- package/README.ja.md +27 -0
- package/README.md +27 -0
- package/bin/throughline.mjs +16 -1
- package/docs/02_clear_auto_handoff_plan.md +2 -1
- package/docs/05_codex_first_roadmap.md +3 -0
- package/docs/adr/0032-claude-compact-continuation.md +90 -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 +81 -0
- package/rag/INDEX.md +4 -0
- package/src/claude-auto-handoff-config.mjs +38 -0
- package/src/claude-auto-handoff.mjs +489 -0
- package/src/claude-auto-handoff.test.mjs +604 -0
- package/src/claude-pre-compact.mjs +69 -0
- package/src/claude-pre-tool-use.mjs +67 -0
- package/src/cli/auto-handoff.mjs +49 -5
- package/src/cli/install.mjs +77 -0
- package/src/cli/install.test.mjs +24 -0
- 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 +93 -5
- package/src/resume-context.test.mjs +61 -0
- package/src/runtime-error-store.mjs +11 -0
- package/src/session-start.mjs +13 -1
- package/src/transcript-reader.mjs +62 -6
- package/src/transcript-reader.test.mjs +109 -0
- package/src/turn-backfill.test.mjs +36 -0
- package/src/turn-processor.mjs +4 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreCompact hook — Claude Code の自動継続の第一段 (ADR 0033)
|
|
4
|
+
*
|
|
5
|
+
* stdin: { session_id, transcript_path, cwd, hook_event_name, trigger, custom_instructions }
|
|
6
|
+
*
|
|
7
|
+
* 自動圧縮(trigger=auto)で、自動継続が有効な project の時だけ、/tl と同じ印と引き継ぎの記録を残し、
|
|
8
|
+
* 圧縮を止める。Claude Code は PreCompact の exit code 2 を「圧縮を止める」と解釈する。
|
|
9
|
+
* 止めた後の会話は圧縮されないまま続き、次の道具の hook(PreToolUse)が作業を止める。
|
|
10
|
+
*
|
|
11
|
+
* 手動の /compact、無効な project、subagent の中の圧縮では、何も止めずに exit code 0 で終わる。
|
|
12
|
+
* この hook 自身が失敗した時は exit code 1 で終わり、圧縮は止めない。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { pathToFileURL } from 'node:url';
|
|
16
|
+
import { parseHookPayload } from './hosts/index.mjs';
|
|
17
|
+
import { requestClaudeAutoHandoff } from './claude-auto-handoff.mjs';
|
|
18
|
+
import { getDb } from './db.mjs';
|
|
19
|
+
import { logDecision } from './decision-log.mjs';
|
|
20
|
+
import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
21
|
+
import { logHookFailure } from './hook-failure-log.mjs';
|
|
22
|
+
|
|
23
|
+
export const PRE_COMPACT_BLOCK_EXIT_CODE = 2;
|
|
24
|
+
|
|
25
|
+
export async function run() {
|
|
26
|
+
let raw = '';
|
|
27
|
+
await new Promise((resolve) => {
|
|
28
|
+
process.stdin.setEncoding('utf8');
|
|
29
|
+
process.stdin.on('data', (chunk) => {
|
|
30
|
+
raw += chunk;
|
|
31
|
+
});
|
|
32
|
+
process.stdin.on('end', resolve);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const payload = parseHookPayload(raw, { env: process.env });
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
const result = requestClaudeAutoHandoff({
|
|
38
|
+
payload,
|
|
39
|
+
env: process.env,
|
|
40
|
+
now,
|
|
41
|
+
openDb: getDb,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
logDecision({
|
|
45
|
+
ts: new Date(now).toISOString(),
|
|
46
|
+
phase: 'pre-compact',
|
|
47
|
+
session_id: result.sessionId,
|
|
48
|
+
project_path: result.projectPath ?? null,
|
|
49
|
+
trigger: payload.trigger ?? null,
|
|
50
|
+
auto_continuation: result.status,
|
|
51
|
+
handoff_id: result.handoffId ?? null,
|
|
52
|
+
skip_reason: result.reason ?? null,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (result.block) {
|
|
56
|
+
process.stderr.write(`Throughlineが自動圧縮を止めました。新しい会話へ引き継ぎます(引き継ぎID: ${result.handoffId})。\n`);
|
|
57
|
+
process.exit(PRE_COMPACT_BLOCK_EXIT_CODE);
|
|
58
|
+
}
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
63
|
+
run().catch((err) => {
|
|
64
|
+
recordRuntimeErrorBestEffort('HOOK_PRE_COMPACT_FAILED');
|
|
65
|
+
logHookFailure('HOOK_PRE_COMPACT_FAILED', err);
|
|
66
|
+
process.stderr.write(`[pre-compact] error: ${err.message}\n`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -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,23 +6,32 @@ 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';
|
|
12
|
+
import { readClaudeAutoHandoffConfig, writeClaudeAutoHandoffConfig } from '../claude-auto-handoff-config.mjs';
|
|
13
|
+
import { listClaudeAutoHandoffs, publicClaudeAutoHandoff, runClaudeAutoHandoffWorker } from '../claude-auto-handoff.mjs';
|
|
14
|
+
|
|
15
|
+
// Claude の自動継続 (ADR 0033)。引き継ぎの記録は旧い会話の session id で持つ。結果が不明な配送は
|
|
16
|
+
// 再送しないので、resume は持たない。記憶の取得は通常の recall / detail を使うので、detail も持たない。
|
|
17
|
+
const CLAUDE_ACTIONS = ['enable', 'disable', 'status', 'worker'];
|
|
11
18
|
|
|
12
19
|
export function parseAutoHandoffArgs(args) {
|
|
13
|
-
const out = { action: args[0] ?? 'status', operation: null, project: null, origin: null, turn: null, database: null, json: false };
|
|
20
|
+
const out = { action: args[0] ?? 'status', host: 'codex', operation: null, project: null, origin: null, turn: null, database: null, json: false };
|
|
14
21
|
if (!['enable', 'disable', 'status', 'resume', 'detail', 'worker'].includes(out.action)) throw Error('auto_handoff_action_invalid');
|
|
15
|
-
const allowed = { enable: ['project'], disable: [], status: ['project', 'operation'],
|
|
16
|
-
resume: ['operation'], worker: ['operation'], detail: ['operation', 'origin', 'turn', 'database'] }[out.action];
|
|
22
|
+
const allowed = ['host', ...{ enable: ['project'], disable: [], status: ['project', 'operation'],
|
|
23
|
+
resume: ['operation'], worker: ['operation'], detail: ['operation', 'origin', 'turn', 'database'] }[out.action]];
|
|
17
24
|
const seen = new Set();
|
|
18
25
|
for (let i = 1; i < args.length; i++) {
|
|
19
26
|
if (seen.has(args[i])) throw Error('auto_handoff_argument_invalid');
|
|
20
27
|
seen.add(args[i]);
|
|
21
28
|
if (args[i] === '--json') { out.json = true; continue; }
|
|
22
|
-
const key = { '--operation': 'operation', '--project': 'project', '--origin': 'origin', '--turn': 'turn', '--database': 'database' }[args[i]];
|
|
29
|
+
const key = { '--host': 'host', '--operation': 'operation', '--project': 'project', '--origin': 'origin', '--turn': 'turn', '--database': 'database' }[args[i]];
|
|
23
30
|
if (!key || !allowed.includes(key) || !args[i + 1] || args[i + 1].startsWith('--')) throw Error('auto_handoff_argument_invalid');
|
|
24
31
|
out[key] = args[++i];
|
|
25
32
|
}
|
|
33
|
+
if (!['codex', 'claude'].includes(out.host)) throw Error('auto_handoff_host_invalid');
|
|
34
|
+
if (out.host === 'claude' && !CLAUDE_ACTIONS.includes(out.action)) throw Error('auto_handoff_action_unsupported');
|
|
26
35
|
if (out.project && !isAbsolute(out.project)) out.project = resolve(out.project);
|
|
27
36
|
if (out.project && out.operation) throw Error('auto_handoff_argument_invalid');
|
|
28
37
|
if (['worker', 'resume', 'detail'].includes(out.action) && !out.operation) throw Error('auto_handoff_operation_required');
|
|
@@ -59,6 +68,31 @@ export async function enableAutoHandoff({ project = null, codexHome = realCodexH
|
|
|
59
68
|
return { status: 'enabled', config, hookKeys, host: 'desktop' };
|
|
60
69
|
}
|
|
61
70
|
|
|
71
|
+
export function enableClaudeAutoHandoff({ project = null, register = installClaudeAutoHandoffHooks } = {}) {
|
|
72
|
+
const registration = register();
|
|
73
|
+
const current = readClaudeAutoHandoffConfig();
|
|
74
|
+
const projects = project ? [...new Set([...(current.enabled ? current.projects : []), project])] : [];
|
|
75
|
+
const config = writeClaudeAutoHandoffConfig({ enabled: true, projects });
|
|
76
|
+
return { status: 'enabled', config, hooks: { ...registration, registered: true }, host: 'claude' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function runClaude(parsed) {
|
|
80
|
+
if (parsed.action === 'enable') return enableClaudeAutoHandoff({ project: parsed.project });
|
|
81
|
+
if (parsed.action === 'disable') {
|
|
82
|
+
const config = writeClaudeAutoHandoffConfig({ ...readClaudeAutoHandoffConfig(), enabled: false });
|
|
83
|
+
// PreToolUse は道具の呼び出しのたびに走る。使わない間は置いたままにしない。
|
|
84
|
+
removeClaudeAutoHandoffHooks();
|
|
85
|
+
return { status: 'disabled', host: 'claude', config };
|
|
86
|
+
}
|
|
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);
|
|
92
|
+
return { host: 'claude', config: readClaudeAutoHandoffConfig(),
|
|
93
|
+
hooks: { registered: claudeAutoHandoffHooksRegistered() }, handoffs };
|
|
94
|
+
}
|
|
95
|
+
|
|
62
96
|
export function publicOperation(operation) {
|
|
63
97
|
if (!operation) return null;
|
|
64
98
|
const { handoff_id, source_thread_id, source_turn_id, target_thread_id, delivery_id,
|
|
@@ -73,6 +107,16 @@ export async function run(args = []) {
|
|
|
73
107
|
try {
|
|
74
108
|
const parsed = parseAutoHandoffArgs(args);
|
|
75
109
|
let result;
|
|
110
|
+
if (parsed.host === 'claude') {
|
|
111
|
+
result = await runClaude(parsed);
|
|
112
|
+
if (parsed.json) process.stdout.write(JSON.stringify(result) + '\n');
|
|
113
|
+
else if (parsed.action === 'status') {
|
|
114
|
+
process.stdout.write(`自動継続: ${result.config.enabled ? '有効' : '無効'}(Claude Code)\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`);
|
|
117
|
+
} else process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
118
|
+
return result.state === 'failed' || result.state === 'unknown' ? 1 : 0;
|
|
119
|
+
}
|
|
76
120
|
if (parsed.action === 'enable') result = await enableAutoHandoff({ project: parsed.project });
|
|
77
121
|
else if (parsed.action === 'disable') result = { status: 'disabled', config: writeAutoHandoffConfig({ ...readAutoHandoffConfig(), enabled: false }) };
|
|
78
122
|
else if (parsed.action === 'detail') {
|
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');
|
|
@@ -36,6 +37,8 @@ const SC_COMMANDS = [
|
|
|
36
37
|
'throughline process-turn',
|
|
37
38
|
'throughline session-start',
|
|
38
39
|
'throughline prompt-submit',
|
|
40
|
+
'throughline pre-compact',
|
|
41
|
+
'throughline pre-tool-use',
|
|
39
42
|
// 旧コマンド(アンインストール時に除去する)
|
|
40
43
|
'throughline inject-context',
|
|
41
44
|
'throughline capture-tool',
|
|
@@ -57,6 +60,21 @@ const SC_HOOKS = {
|
|
|
57
60
|
},
|
|
58
61
|
};
|
|
59
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 = {
|
|
67
|
+
PreCompact: {
|
|
68
|
+
hooks: [{ type: 'command', command: 'throughline pre-compact' }],
|
|
69
|
+
},
|
|
70
|
+
PreToolUse: {
|
|
71
|
+
hooks: [{ type: 'command', command: 'throughline pre-tool-use', timeout: 15 }],
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
const CLAUDE_AUTO_HANDOFF_COMMANDS = new Set(
|
|
75
|
+
Object.values(CLAUDE_AUTO_HANDOFF_HOOKS).map(entry => entry.hooks[0].command),
|
|
76
|
+
);
|
|
77
|
+
|
|
60
78
|
const CODEX_COMMANDS = [
|
|
61
79
|
'throughline codex-hook stop',
|
|
62
80
|
'throughline codex-hook user-prompt-submit',
|
|
@@ -629,6 +647,59 @@ function installCodexHooks() {
|
|
|
629
647
|
return { hooksPath, configPath };
|
|
630
648
|
}
|
|
631
649
|
|
|
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);
|
|
665
|
+
}
|
|
666
|
+
|
|
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)) {
|
|
681
|
+
const current = readSettings(settingsPath);
|
|
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 });
|
|
685
|
+
}
|
|
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 };
|
|
701
|
+
}
|
|
702
|
+
|
|
632
703
|
export function installCodexAutoHandoffHook(codexHome = join(homedir(), '.codex')) {
|
|
633
704
|
const hooksPath = join(codexHome, 'hooks.json');
|
|
634
705
|
const configPath = join(codexHome, 'config.toml');
|
|
@@ -746,6 +817,12 @@ export async function run(args = []) {
|
|
|
746
817
|
|
|
747
818
|
current.hooks = existingHooks;
|
|
748
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
|
+
}
|
|
749
826
|
const { installed: installedCommands, skipped } = installSlashCommands(commandsDir);
|
|
750
827
|
const codex = args.includes('--project') ? null : installCodexHooks();
|
|
751
828
|
const grok = args.includes('--project') ? null : installGrokHooks();
|
package/src/cli/install.test.mjs
CHANGED
|
@@ -667,6 +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
|
+
// 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 }] }]);
|
|
690
|
+
|
|
691
|
+
await run(['--uninstall']);
|
|
692
|
+
assert.deepEqual(JSON.parse(readFileSync(settingsPath, 'utf8')).hooks, { PreCompact: [other] },
|
|
693
|
+
'uninstall removes every Throughline Claude hook and keeps the others');
|
|
670
694
|
} finally {
|
|
671
695
|
unsilence();
|
|
672
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,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 呼び出し元:
|
|
5
5
|
* - handoff-executor.mjs(最初のUserPromptSubmitでauto path / baton path共通注入)
|
|
6
|
+
* - handoff-executor.mjs(前任が自動継続で引き継いだ会話の、最初のUserPromptSubmit。ADR 0033)
|
|
6
7
|
*
|
|
7
8
|
* 設計 (docs/02_clear_auto_handoff_plan.md):
|
|
8
9
|
* - 予算付き注入: ヘッダ + 現在地アンカー + pull案内を固定部とし、残りへ
|
|
@@ -55,6 +56,29 @@ const RESUME_HEADER_TEMPLATE = (turnCount, handoffDisclosure) =>
|
|
|
55
56
|
|
|
56
57
|
const NORMAL_HEADER = '## Throughline: セッション記憶';
|
|
57
58
|
|
|
59
|
+
const DEFAULT_ANCHOR_TITLE = '### 現在地 (直前のやりとり)';
|
|
60
|
+
|
|
61
|
+
// 自動継続で立てた新しい会話へ注入する時のヘッダ (ADR 0033)。/clear の引き継ぎと違い、
|
|
62
|
+
// 前の会話は作業の途中で止めてあり、次のユーザー入力は来ない。宣言はさせず、そのまま続けさせる。
|
|
63
|
+
const AUTO_CONTINUATION_HEADER = (projectPath) =>
|
|
64
|
+
`## Throughline: 自動継続の文脈\n` +
|
|
65
|
+
(projectPath ? `作業ディレクトリ: ${projectPath}\n` : '') +
|
|
66
|
+
`\n` +
|
|
67
|
+
`**読み方 (重要):**\n` +
|
|
68
|
+
`- 前の会話は文脈の上限に近づいたため、Throughline が自動圧縮の前に止めて、この新しい会話へ引き継ぎました。` +
|
|
69
|
+
`下記は、前の会話で **あなた自身とユーザーが交わした会話の原文** です。\n` +
|
|
70
|
+
`- 「現在地」のターンは **作業の途中で止めたもの** です。ユーザーの追加入力を待たず、未完了の作業をそのまま続けてください。\n` +
|
|
71
|
+
`- 完了済みの操作を繰り返さないでください。止める直前に呼ぼうとした道具は実行されていません。` +
|
|
72
|
+
`直前の実行結果が分からない時は、作業ツリーや記録を確かめてから続けてください。\n` +
|
|
73
|
+
'- **完了したターンの詳細**: **`Bash` ツールで `throughline detail HH:MM:SS` を実行** ' +
|
|
74
|
+
`(該当ターンの本文+詳細を stdout に返します)`;
|
|
75
|
+
|
|
76
|
+
const AUTO_CONTINUATION_ANCHOR_TITLE = '### 現在地 (作業の途中で止めたターン)';
|
|
77
|
+
// 途中で止めたターンは、本文の断片が無ければ bodies に入らない。依頼の原文を必ず渡せる場所は
|
|
78
|
+
// ここだけなので、/clear のアンカー (600 字) より厚く取る。
|
|
79
|
+
const IN_FLIGHT_USER_MAX_CHARS = 4_000;
|
|
80
|
+
const IN_FLIGHT_ASSISTANT_MAX_CHARS = 1_500;
|
|
81
|
+
|
|
58
82
|
// v0.10.12以前にThroughline自身がassistantへ付与させていた宣言行。
|
|
59
83
|
// 製品由来のメタ情報だけを引き継ぎ材料から除き、会話本文への模倣連鎖を止める。
|
|
60
84
|
// userが同じ文を引用した場合は会話内容なので保持する。
|
|
@@ -193,14 +217,14 @@ function buildResumeSections(
|
|
|
193
217
|
});
|
|
194
218
|
}
|
|
195
219
|
|
|
196
|
-
return { header, anchorLines, l1Lines, l2Lines };
|
|
220
|
+
return { header, anchorTitle: DEFAULT_ANCHOR_TITLE, anchorLines, l1Lines, l2Lines };
|
|
197
221
|
}
|
|
198
222
|
|
|
199
|
-
function joinSections({ header, anchorLines, l1Lines, l2Lines }, { l1Note = null, l2Note = null } = {}) {
|
|
223
|
+
function joinSections({ header, anchorTitle, anchorLines, l1Lines, l2Lines }, { l1Note = null, l2Note = null } = {}) {
|
|
200
224
|
const lines = [header];
|
|
201
225
|
if (anchorLines.length > 0) {
|
|
202
226
|
lines.push('');
|
|
203
|
-
lines.push(
|
|
227
|
+
lines.push(anchorTitle);
|
|
204
228
|
lines.push(...anchorLines);
|
|
205
229
|
}
|
|
206
230
|
if (l1Lines.length > 0 || l1Note) {
|
|
@@ -396,7 +420,11 @@ export function buildBudgetedResumeContext(
|
|
|
396
420
|
handoffDisclosure,
|
|
397
421
|
});
|
|
398
422
|
if (!sections) return null;
|
|
423
|
+
return renderBudgetedSections(db, sections, { sessionId, excludeOriginId, maxChars });
|
|
424
|
+
}
|
|
399
425
|
|
|
426
|
+
/** 組み立て済みのセクションを、予算内の注入テキストにする(/clear の引き継ぎと自動圧縮後の継続が共有)。 */
|
|
427
|
+
function renderBudgetedSections(db, sections, { sessionId, excludeOriginId, maxChars }) {
|
|
400
428
|
const lineCost = (s) => s.length + 1; // join('\n') 分
|
|
401
429
|
|
|
402
430
|
// L2 行をターン単位のグループにまとめる(元の古い順を保つ)
|
|
@@ -418,7 +446,7 @@ export function buildBudgetedResumeContext(
|
|
|
418
446
|
const fixedCost =
|
|
419
447
|
lineCost(sections.header) +
|
|
420
448
|
(sections.anchorLines.length > 0
|
|
421
|
-
? lineCost('') + lineCost(
|
|
449
|
+
? lineCost('') + lineCost(sections.anchorTitle) +
|
|
422
450
|
sections.anchorLines.reduce((a, l) => a + lineCost(l), 0)
|
|
423
451
|
: 0) +
|
|
424
452
|
lineCost('') + lineCost('### 直前の対話 (L2 / active work thread, 古い順)');
|
|
@@ -487,7 +515,7 @@ export function buildBudgetedResumeContext(
|
|
|
487
515
|
const lines = [sections.header];
|
|
488
516
|
if (sections.anchorLines.length > 0) {
|
|
489
517
|
lines.push('');
|
|
490
|
-
lines.push(
|
|
518
|
+
lines.push(sections.anchorTitle);
|
|
491
519
|
lines.push(...sections.anchorLines);
|
|
492
520
|
}
|
|
493
521
|
lines.push('');
|
|
@@ -511,3 +539,63 @@ export function buildBudgetedResumeContext(
|
|
|
511
539
|
truncatedNewestL2,
|
|
512
540
|
};
|
|
513
541
|
}
|
|
542
|
+
|
|
543
|
+
// 長い依頼は、指示が先頭にも末尾にも書かれる。先頭 3/4 と末尾 1/4 を残し、間を抜いたことを明示する。
|
|
544
|
+
function truncateInFlight(text, maxChars) {
|
|
545
|
+
const trimmed = text.trim();
|
|
546
|
+
if (trimmed.length <= maxChars) return trimmed;
|
|
547
|
+
const head = Math.ceil(maxChars * 0.75);
|
|
548
|
+
const tail = maxChars - head;
|
|
549
|
+
return `${trimmed.slice(0, head)} …(長いため中略 ${trimmed.length - maxChars} 字)… ${trimmed.slice(-tail)}`;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* 自動継続で立てた新しい会話へ注入するテキスト (ADR 0033)。
|
|
554
|
+
*
|
|
555
|
+
* 構成は /clear の引き継ぎと同じ予算付き(ヘッダ + 現在地 + 案内 + 入るだけの L2)。違いは現在地で、
|
|
556
|
+
* DB の最新ターンではなく、止めた時点に記録した作業途中のターンを載せる。
|
|
557
|
+
*
|
|
558
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
559
|
+
* @param {{
|
|
560
|
+
* sessionId: string, 合流を終えた後継の session id
|
|
561
|
+
* inFlight: {
|
|
562
|
+
* user: {content: string, timestamp: number|null},
|
|
563
|
+
* last_fragment: {content: string, timestamp: number|null} | null,
|
|
564
|
+
* } | null,
|
|
565
|
+
* projectPath?: string | null, 前の会話の作業ディレクトリ。依頼の中の相対的な場所の基準として渡す
|
|
566
|
+
* maxChars?: number,
|
|
567
|
+
* }} params
|
|
568
|
+
*/
|
|
569
|
+
export function buildAutoContinuationContext(
|
|
570
|
+
db,
|
|
571
|
+
{ sessionId, inFlight, projectPath = null, maxChars = INJECTION_BUDGET_CHARS },
|
|
572
|
+
) {
|
|
573
|
+
const stored = buildResumeSections(db, { sessionId, isInheritance: true, handoffDisclosure: 'silent' });
|
|
574
|
+
const stamp = (timestamp) => (timestamp == null ? '' : ` [${formatClock(timestamp)}]`);
|
|
575
|
+
const anchorLines = [];
|
|
576
|
+
if (inFlight) {
|
|
577
|
+
anchorLines.push(
|
|
578
|
+
`**作業中のユーザー依頼**${stamp(inFlight.user.timestamp)}: ` +
|
|
579
|
+
truncateInFlight(inFlight.user.content, IN_FLIGHT_USER_MAX_CHARS),
|
|
580
|
+
);
|
|
581
|
+
if (inFlight.last_fragment) {
|
|
582
|
+
anchorLines.push(
|
|
583
|
+
`**止める直前のあなたの発言**${stamp(inFlight.last_fragment.timestamp)}: ` +
|
|
584
|
+
truncateInFlight(inFlight.last_fragment.content, IN_FLIGHT_ASSISTANT_MAX_CHARS),
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (!stored && anchorLines.length === 0) return null;
|
|
589
|
+
|
|
590
|
+
return renderBudgetedSections(
|
|
591
|
+
db,
|
|
592
|
+
{
|
|
593
|
+
header: AUTO_CONTINUATION_HEADER(projectPath),
|
|
594
|
+
anchorTitle: AUTO_CONTINUATION_ANCHOR_TITLE,
|
|
595
|
+
anchorLines,
|
|
596
|
+
l1Lines: [],
|
|
597
|
+
l2Lines: stored?.l2Lines ?? [],
|
|
598
|
+
},
|
|
599
|
+
{ sessionId, excludeOriginId: null, maxChars },
|
|
600
|
+
);
|
|
601
|
+
}
|