throughline 0.12.8 → 0.13.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 +35 -1
- package/README.ja.md +20 -0
- package/README.md +20 -0
- package/bin/throughline.mjs +10 -1
- package/docs/02_clear_auto_handoff_plan.md +2 -1
- package/docs/05_codex_first_roadmap.md +2 -0
- package/docs/adr/0031-serialized-schema-migration.md +43 -0
- package/docs/adr/0032-claude-compact-continuation.md +88 -0
- package/package.json +1 -1
- package/rag/01-hooks/raw/claude-compact-hooks-extract.md +56 -0
- package/rag/INDEX.md +4 -0
- package/src/claude-auto-handoff-config.mjs +38 -0
- package/src/claude-auto-handoff.mjs +207 -0
- package/src/claude-auto-handoff.test.mjs +308 -0
- package/src/claude-pre-compact.mjs +55 -0
- package/src/cli/auto-handoff.mjs +43 -5
- package/src/cli/install.mjs +28 -0
- package/src/cli/install.test.mjs +6 -0
- package/src/db-contention.test.mjs +27 -0
- package/src/db.mjs +62 -10
- package/src/hook-entrypoints.test.mjs +3 -1
- package/src/hook-failure-log.mjs +17 -1
- package/src/hook-failure-log.test.mjs +36 -2
- package/src/hosts/cursor.test.mjs +10 -0
- package/src/hosts/index.mjs +9 -1
- package/src/resume-context.mjs +101 -5
- package/src/resume-context.test.mjs +72 -0
- package/src/runtime-error-store.mjs +6 -0
- package/src/session-start.mjs +33 -1
- package/src/transcript-reader.mjs +58 -2
- package/src/transcript-reader.test.mjs +90 -0
- package/src/turn-backfill.mjs +7 -2
- package/src/turn-backfill.test.mjs +58 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude-auto-handoff.mjs — Claude Code の自動継続 (ADR 0032)
|
|
3
|
+
*
|
|
4
|
+
* Codex の自動継続は、自動圧縮の前に旧タスクを止めて新しいタスクへ記憶を渡す。Claude Code は
|
|
5
|
+
* hook から作業を止められず、新しい会話へ指示を送る公開 API も無い。自動圧縮の後は host が
|
|
6
|
+
* 同じ会話で作業を続け、SessionStart(source=compact) の hook 出力を圧縮後の文脈へ足す。
|
|
7
|
+
* そこで Claude では、会話を切り替えずに次の 2 段で記憶を渡す。
|
|
8
|
+
*
|
|
9
|
+
* 1. PreCompact : trigger=auto の時だけ、その会話に「自動圧縮が始まった」印を残す。
|
|
10
|
+
* 圧縮は止めない(止めると文脈上限の error になる)。
|
|
11
|
+
* 2. SessionStart : source=compact で印があれば、完了済みのターンを回収し、
|
|
12
|
+
* (source=compact) 作業途中のターンと直近の会話の原文を stdout へ書く。
|
|
13
|
+
*
|
|
14
|
+
* 手動の /compact は対象にしない(Codex と同じ)。印は PreCompact の payload だけから作り、
|
|
15
|
+
* SessionStart の時点の transcript から trigger を推測しない(圧縮境界の行は hook より後に書かれる)。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { randomUUID } from 'node:crypto';
|
|
22
|
+
import { readClaudeAutoHandoffConfig, claudeAutoHandoffEnabledFor } from './claude-auto-handoff-config.mjs';
|
|
23
|
+
import { CLAUDE_HOST, hostOfSessionId } from './hosts/identity.mjs';
|
|
24
|
+
import { claudeHostAdapter } from './hosts/claude.mjs';
|
|
25
|
+
import { readLatestUserGroup } from './transcript-reader.mjs';
|
|
26
|
+
import { backfillBodies, logBackfill } from './turn-backfill.mjs';
|
|
27
|
+
import { resolveMergeTarget } from './session-merger.mjs';
|
|
28
|
+
import { buildCompactContinuationContext } from './resume-context.mjs';
|
|
29
|
+
|
|
30
|
+
export const CLAUDE_COMPACT_REQUEST_SCHEMA = 'throughline.claude-auto-handoff.request.v1';
|
|
31
|
+
// 印は圧縮の完了で消える。圧縮が失敗して残った印は、次の PreCompact の時にこの期限で掃除する。
|
|
32
|
+
const STALE_REQUEST_MS = 24 * 60 * 60 * 1000;
|
|
33
|
+
// session id を印のファイル名に使う。Claude の id は UUID で、path の区切りを含む値は受け付けない。
|
|
34
|
+
const CLAUDE_SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
35
|
+
|
|
36
|
+
export const claudeCompactRequestDir = () => join(homedir(), '.throughline', 'claude-auto-handoff');
|
|
37
|
+
|
|
38
|
+
function requestPath(sessionId, dir) {
|
|
39
|
+
if (hostOfSessionId(sessionId) !== CLAUDE_HOST || !CLAUDE_SESSION_ID_PATTERN.test(sessionId)) {
|
|
40
|
+
throw new Error('auto_handoff_session_id_invalid');
|
|
41
|
+
}
|
|
42
|
+
return join(dir, `${sessionId}.json`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function removeStaleRequests(dir, now) {
|
|
46
|
+
let names;
|
|
47
|
+
try { names = readdirSync(dir); }
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error.code === 'ENOENT') return;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
for (const name of names) {
|
|
53
|
+
if (!name.endsWith('.json')) continue;
|
|
54
|
+
const path = join(dir, name);
|
|
55
|
+
let modifiedAt;
|
|
56
|
+
try { modifiedAt = statSync(path).mtimeMs; }
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error.code === 'ENOENT') continue; // 別の hook が同時に消した
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
if (now - modifiedAt > STALE_REQUEST_MS) rmSync(path, { force: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* PreCompact hook の本体。自動圧縮で、その project が有効な時だけ印を残す。
|
|
67
|
+
* 手動と無効の時は、同じ会話の古い印を消して何もしない。subagent の中の圧縮は、
|
|
68
|
+
* 親の会話の印に触らない(payload の session_id は親の会話を指す)。
|
|
69
|
+
*
|
|
70
|
+
* @param {{
|
|
71
|
+
* payload: {session_id?: string, trigger?: string, cwd?: string, transcript_path?: string},
|
|
72
|
+
* env?: NodeJS.ProcessEnv,
|
|
73
|
+
* config?: {enabled: boolean, projects: string[]},
|
|
74
|
+
* now?: number,
|
|
75
|
+
* dir?: string,
|
|
76
|
+
* }} params
|
|
77
|
+
* @returns {{status: 'requested'|'skipped', reason?: string, sessionId: string, projectPath?: string}}
|
|
78
|
+
*/
|
|
79
|
+
export function recordClaudeCompactRequest({
|
|
80
|
+
payload,
|
|
81
|
+
env = process.env,
|
|
82
|
+
config = readClaudeAutoHandoffConfig(),
|
|
83
|
+
now = Date.now(),
|
|
84
|
+
dir = claudeCompactRequestDir(),
|
|
85
|
+
}) {
|
|
86
|
+
const sessionId = payload?.session_id;
|
|
87
|
+
if (typeof sessionId !== 'string' || !sessionId) throw new Error('Missing session_id in PreCompact payload');
|
|
88
|
+
if (payload.agent_id) return { status: 'skipped', reason: 'subagent_compact', sessionId };
|
|
89
|
+
const path = requestPath(sessionId, dir);
|
|
90
|
+
// 有効判定は会話を起動した project で行う。hook の cwd は Bash の cd に追従する。
|
|
91
|
+
const projectPath = claudeHostAdapter.completionProjectPath({ cwd: payload.cwd ?? process.cwd(), env });
|
|
92
|
+
|
|
93
|
+
const reason = payload.trigger !== 'auto'
|
|
94
|
+
? 'manual_compact'
|
|
95
|
+
: claudeAutoHandoffEnabledFor(config, projectPath) ? null : 'auto_handoff_disabled';
|
|
96
|
+
if (reason) {
|
|
97
|
+
rmSync(path, { force: true });
|
|
98
|
+
return { status: 'skipped', reason, sessionId, projectPath };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
102
|
+
removeStaleRequests(dir, now);
|
|
103
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
104
|
+
writeFileSync(temporary, `${JSON.stringify({
|
|
105
|
+
schema: CLAUDE_COMPACT_REQUEST_SCHEMA,
|
|
106
|
+
session_id: sessionId,
|
|
107
|
+
trigger: 'auto',
|
|
108
|
+
project_path: projectPath,
|
|
109
|
+
transcript_path: payload.transcript_path ?? null,
|
|
110
|
+
requested_at: now,
|
|
111
|
+
})}\n`, { mode: 0o600, flag: 'wx' });
|
|
112
|
+
renameSync(temporary, path);
|
|
113
|
+
return { status: 'requested', sessionId, projectPath };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* SessionStart(source=compact) から呼ぶ。その会話の印を取り出して消す。無ければ null。
|
|
118
|
+
* @returns {{session_id: string, trigger: 'auto', project_path: string, transcript_path: string|null, requested_at: number}|null}
|
|
119
|
+
*/
|
|
120
|
+
export function consumeClaudeCompactRequest({ sessionId, dir = claudeCompactRequestDir() }) {
|
|
121
|
+
if (hostOfSessionId(sessionId) !== CLAUDE_HOST || !CLAUDE_SESSION_ID_PATTERN.test(sessionId)) return null;
|
|
122
|
+
const path = requestPath(sessionId, dir);
|
|
123
|
+
let text;
|
|
124
|
+
try { text = readFileSync(path, 'utf8'); }
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (error.code === 'ENOENT') return null;
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
rmSync(path, { force: true });
|
|
130
|
+
const request = JSON.parse(text);
|
|
131
|
+
if (request.schema !== CLAUDE_COMPACT_REQUEST_SCHEMA || request.session_id !== sessionId ||
|
|
132
|
+
request.trigger !== 'auto') throw new Error('auto_handoff_request_invalid');
|
|
133
|
+
return request;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function listClaudeCompactRequests({ dir = claudeCompactRequestDir() } = {}) {
|
|
137
|
+
let names;
|
|
138
|
+
try { names = readdirSync(dir); }
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (error.code === 'ENOENT') return [];
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
return names.filter(name => name.endsWith('.json')).map(name => {
|
|
144
|
+
const { session_id, project_path, requested_at } = JSON.parse(readFileSync(join(dir, name), 'utf8'));
|
|
145
|
+
return { session_id, project_path, requested_at };
|
|
146
|
+
}).sort((a, b) => a.requested_at - b.requested_at);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 自動圧縮の直後に注入するテキストを作る。
|
|
151
|
+
*
|
|
152
|
+
* 作業途中のターン(最後の user 発言の群)は Stop の前なので、回収の対象から外して transcript から読む。
|
|
153
|
+
* それより前の完了済みターンは、Stop が空振りした分もここで bodies へ回収してから並べる。
|
|
154
|
+
*
|
|
155
|
+
* transcript は hook より遅れて書かれることがある。hook payload の `prompt_id` と、最後の user 行の
|
|
156
|
+
* `promptId` が両方あって食い違う時は、今の依頼がまだ書かれていない。その時は最後の群を
|
|
157
|
+
* 作業途中として見せず、回収もしない(どちらのターンか確定できない群を、完了として保存しない)。
|
|
158
|
+
*
|
|
159
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
160
|
+
* @param {{ sessionId: string, transcriptPath: string|null|undefined, promptId?: string|null, now?: number }} params
|
|
161
|
+
* @returns {{
|
|
162
|
+
* text: string,
|
|
163
|
+
* stats: {total_chars: number, injected_l2_turns: number, remaining_l2_turns: number, older_turns: number,
|
|
164
|
+
* in_flight_user: boolean, in_flight_unreadable: boolean, in_flight_fragments: number, backfilled_turns: number},
|
|
165
|
+
* } | null}
|
|
166
|
+
*/
|
|
167
|
+
export function buildClaudeCompactContinuation(db, { sessionId, transcriptPath, promptId = null, now = Date.now() }) {
|
|
168
|
+
const { target, origin } = resolveMergeTarget(db, sessionId);
|
|
169
|
+
const latest = readLatestUserGroup(transcriptPath);
|
|
170
|
+
const inFlightUnreadable = Boolean(promptId && latest?.user.prompt_id && latest.user.prompt_id !== promptId);
|
|
171
|
+
const inFlight = inFlightUnreadable ? null : latest;
|
|
172
|
+
|
|
173
|
+
const backfill = backfillBodies(db, {
|
|
174
|
+
targetSessionId: target,
|
|
175
|
+
originSessionId: origin,
|
|
176
|
+
transcriptPath,
|
|
177
|
+
now,
|
|
178
|
+
beforeUserTurnNumber: latest ? latest.user.turn_number : null,
|
|
179
|
+
});
|
|
180
|
+
logBackfill({
|
|
181
|
+
ts: new Date(now).toISOString(),
|
|
182
|
+
hook: 'session-start-compact',
|
|
183
|
+
session_id: sessionId,
|
|
184
|
+
target,
|
|
185
|
+
origin,
|
|
186
|
+
transcript_path: transcriptPath ?? null,
|
|
187
|
+
groups: backfill.groups,
|
|
188
|
+
inserted_turns: backfill.insertedTurns,
|
|
189
|
+
skipped_existing: backfill.skippedExisting,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const context = buildCompactContinuationContext(db, { sessionId: target, inFlight, inFlightUnreadable });
|
|
193
|
+
if (!context) return null;
|
|
194
|
+
return {
|
|
195
|
+
text: context.text,
|
|
196
|
+
stats: {
|
|
197
|
+
total_chars: context.totalChars,
|
|
198
|
+
injected_l2_turns: context.injectedL2Turns,
|
|
199
|
+
remaining_l2_turns: context.remainingL2Turns,
|
|
200
|
+
older_turns: context.olderTurns,
|
|
201
|
+
in_flight_user: Boolean(inFlight),
|
|
202
|
+
in_flight_unreadable: inFlightUnreadable,
|
|
203
|
+
in_flight_fragments: inFlight?.fragments.length ?? 0,
|
|
204
|
+
backfilled_turns: backfill.insertedTurns,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import {
|
|
10
|
+
recordClaudeCompactRequest,
|
|
11
|
+
consumeClaudeCompactRequest,
|
|
12
|
+
listClaudeCompactRequests,
|
|
13
|
+
} from './claude-auto-handoff.mjs';
|
|
14
|
+
import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
|
|
15
|
+
|
|
16
|
+
const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
17
|
+
const CLI = join(REPO_ROOT, 'bin', 'throughline.mjs');
|
|
18
|
+
const SESSION = '0f3a6c1e-7b2d-4e5f-9a81-3c4d5e6f7a8b';
|
|
19
|
+
const ENABLED = { enabled: true, projects: [] };
|
|
20
|
+
|
|
21
|
+
function withDir(fn) {
|
|
22
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-claude-auto-'));
|
|
23
|
+
try { return fn(dir); }
|
|
24
|
+
finally { rmSync(dir, { recursive: true, force: true }); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function childEnv(home) {
|
|
28
|
+
// 設定とstateの置き場も一時HOMEへ向ける(本物のruntime error storeへ書かない)。
|
|
29
|
+
return {
|
|
30
|
+
...process.env,
|
|
31
|
+
HOME: home,
|
|
32
|
+
USERPROFILE: home,
|
|
33
|
+
XDG_CONFIG_HOME: join(home, '.config'),
|
|
34
|
+
XDG_STATE_HOME: join(home, '.local', 'state'),
|
|
35
|
+
LOCALAPPDATA: join(home, 'AppData', 'Local'),
|
|
36
|
+
THROUGHLINE_NO_VSCODE: '1',
|
|
37
|
+
CLAUDE_PROJECT_DIR: '',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function cli(home, args, input = '') {
|
|
42
|
+
return spawnSync(process.execPath, [CLI, ...args], { cwd: REPO_ROOT, env: childEnv(home), input, encoding: 'utf8' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const user = (text, timestamp) => ({ type: 'user', timestamp, message: { role: 'user', content: text } });
|
|
46
|
+
const assistant = (text, timestamp) => ({ type: 'assistant', timestamp, message: { role: 'assistant', content: [{ type: 'text', text }] } });
|
|
47
|
+
const toolUse = (id, name) => ({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input: {} }] } });
|
|
48
|
+
const toolResult = (id, text) => ({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: text }] } });
|
|
49
|
+
const compactSummary = () => ({ type: 'user', isCompactSummary: true, isVisibleInTranscriptOnly: true,
|
|
50
|
+
message: { role: 'user', content: 'This session is being continued from a previous conversation that ran out of context.' } });
|
|
51
|
+
const jsonl = entries => entries.map(entry => JSON.stringify(entry)).join('\n') + '\n';
|
|
52
|
+
|
|
53
|
+
test('PreCompact: 自動圧縮で有効な時だけ印を残し、手動・無効・対象外のprojectでは古い印を消す', () => withDir(dir => {
|
|
54
|
+
const payload = { session_id: SESSION, trigger: 'auto', cwd: '/work/app', transcript_path: '/t.jsonl' };
|
|
55
|
+
const args = { payload, env: {}, config: ENABLED, dir, now: 1_000 };
|
|
56
|
+
|
|
57
|
+
assert.deepEqual(recordClaudeCompactRequest(args), { status: 'requested', sessionId: SESSION, projectPath: '/work/app' });
|
|
58
|
+
assert.deepEqual(listClaudeCompactRequests({ dir }), [{ session_id: SESSION, project_path: '/work/app', requested_at: 1_000 }]);
|
|
59
|
+
|
|
60
|
+
for (const [override, reason] of [
|
|
61
|
+
[{ payload: { ...payload, trigger: 'manual' } }, 'manual_compact'],
|
|
62
|
+
[{ config: { enabled: false, projects: [] } }, 'auto_handoff_disabled'],
|
|
63
|
+
[{ config: { enabled: true, projects: ['/work/other'] } }, 'auto_handoff_disabled'],
|
|
64
|
+
]) {
|
|
65
|
+
recordClaudeCompactRequest(args);
|
|
66
|
+
const result = recordClaudeCompactRequest({ ...args, ...override });
|
|
67
|
+
assert.equal(result.status, 'skipped');
|
|
68
|
+
assert.equal(result.reason, reason);
|
|
69
|
+
assert.deepEqual(readdirSync(dir), [], reason);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 有効判定は会話を起動した project で行う(cwd は Bash の cd に追従する)
|
|
73
|
+
const started = recordClaudeCompactRequest({ ...args, payload: { ...payload, cwd: '/work/app/sub' },
|
|
74
|
+
env: { CLAUDE_PROJECT_DIR: '/work/app' }, config: { enabled: true, projects: ['/work/app'] } });
|
|
75
|
+
assert.equal(started.status, 'requested');
|
|
76
|
+
assert.equal(started.projectPath, '/work/app');
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
test('PreCompact: subagent の中の圧縮は親の会話の印に触らない', () => withDir(dir => {
|
|
80
|
+
const payload = { session_id: SESSION, trigger: 'auto', cwd: '/work/app' };
|
|
81
|
+
recordClaudeCompactRequest({ payload, env: {}, config: ENABLED, dir, now: 1_000 });
|
|
82
|
+
for (const trigger of ['auto', 'manual']) {
|
|
83
|
+
const result = recordClaudeCompactRequest({ payload: { ...payload, trigger, agent_id: 'agent-1' }, env: {}, config: ENABLED, dir, now: 2_000 });
|
|
84
|
+
assert.deepEqual(result, { status: 'skipped', reason: 'subagent_compact', sessionId: SESSION });
|
|
85
|
+
}
|
|
86
|
+
assert.equal(listClaudeCompactRequests({ dir })[0].requested_at, 1_000);
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
test('PreCompact: session id が無い・pathを含む時は失敗し、期限を過ぎた他の会話の印は掃除する', () => withDir(dir => {
|
|
90
|
+
assert.throws(() => recordClaudeCompactRequest({ payload: { trigger: 'auto' }, env: {}, config: ENABLED, dir }), /Missing session_id/);
|
|
91
|
+
assert.throws(() => recordClaudeCompactRequest({ payload: { session_id: '../escape', trigger: 'auto', cwd: '/w' }, env: {}, config: ENABLED, dir }),
|
|
92
|
+
/auto_handoff_session_id_invalid/);
|
|
93
|
+
|
|
94
|
+
const now = Date.parse('2026-10-04T12:00:00Z');
|
|
95
|
+
mkdirSync(dir, { recursive: true });
|
|
96
|
+
const stale = join(dir, 'aaaaaaaa-0000-4000-8000-000000000001.json');
|
|
97
|
+
const fresh = join(dir, 'aaaaaaaa-0000-4000-8000-000000000002.json');
|
|
98
|
+
for (const path of [stale, fresh]) writeFileSync(path, '{}');
|
|
99
|
+
utimesSync(stale, new Date(now - 25 * 3_600_000), new Date(now - 25 * 3_600_000));
|
|
100
|
+
utimesSync(fresh, new Date(now - 3_600_000), new Date(now - 3_600_000));
|
|
101
|
+
recordClaudeCompactRequest({ payload: { session_id: SESSION, trigger: 'auto', cwd: '/w' }, env: {}, config: ENABLED, dir, now });
|
|
102
|
+
assert.deepEqual(readdirSync(dir).sort(), [`${SESSION}.json`, 'aaaaaaaa-0000-4000-8000-000000000002.json'].sort());
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
test('SessionStart: 印は一度だけ取り出せる。別の会話・他hostの会話は取り出さない', () => withDir(dir => {
|
|
106
|
+
recordClaudeCompactRequest({ payload: { session_id: SESSION, trigger: 'auto', cwd: '/work/app', transcript_path: '/t.jsonl' },
|
|
107
|
+
env: {}, config: ENABLED, dir, now: 5_000 });
|
|
108
|
+
assert.equal(consumeClaudeCompactRequest({ sessionId: 'aaaaaaaa-0000-4000-8000-000000000009', dir }), null);
|
|
109
|
+
assert.equal(consumeClaudeCompactRequest({ sessionId: `grok:${SESSION}`, dir }), null);
|
|
110
|
+
const request = consumeClaudeCompactRequest({ sessionId: SESSION, dir });
|
|
111
|
+
assert.equal(request.trigger, 'auto');
|
|
112
|
+
assert.equal(request.project_path, '/work/app');
|
|
113
|
+
assert.equal(request.transcript_path, '/t.jsonl');
|
|
114
|
+
assert.equal(request.requested_at, 5_000);
|
|
115
|
+
assert.equal(consumeClaudeCompactRequest({ sessionId: SESSION, dir }), null);
|
|
116
|
+
|
|
117
|
+
writeFileSync(join(dir, `${SESSION}.json`), JSON.stringify({ schema: 'other', session_id: SESSION, trigger: 'auto' }));
|
|
118
|
+
assert.throws(() => consumeClaudeCompactRequest({ sessionId: SESSION, dir }), /auto_handoff_request_invalid/);
|
|
119
|
+
assert.deepEqual(readdirSync(dir), [], '読めない印は残さない');
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
test('公開CLI: --host claude は enable・disable・status だけを受け取る', () => {
|
|
123
|
+
assert.equal(parseAutoHandoffArgs(['status', '--json']).host, 'codex');
|
|
124
|
+
assert.deepEqual(
|
|
125
|
+
(({ action, host, project, json }) => ({ action, host, project, json }))(parseAutoHandoffArgs(['enable', '--host', 'claude', '--project', REPO_ROOT, '--json'])),
|
|
126
|
+
{ action: 'enable', host: 'claude', project: REPO_ROOT, json: true },
|
|
127
|
+
);
|
|
128
|
+
for (const args of [['resume', '--host', 'claude', '--operation', 'id'], ['status', '--host', 'claude', '--operation', 'id'],
|
|
129
|
+
['worker', '--host', 'claude', '--operation', 'id'], ['status', '--host', 'grok'], ['status', '--host'], ['disable', '--host', 'claude', '--project', '/p']]) {
|
|
130
|
+
assert.throws(() => parseAutoHandoffArgs(args), JSON.stringify(args));
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('公開CLI: enable は PreCompact hook と設定を書き、Codex の設定には触らない', () => withDir(home => {
|
|
135
|
+
const settingsPath = join(home, '.claude', 'settings.json');
|
|
136
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
137
|
+
// 他製品のhookと、matcher付きの古い自分の登録がある状態から始める
|
|
138
|
+
writeFileSync(settingsPath, JSON.stringify({ model: 'sonnet', hooks: { PreCompact: [
|
|
139
|
+
{ matcher: 'auto', hooks: [{ type: 'command', command: 'throughline pre-compact' }] },
|
|
140
|
+
{ hooks: [{ type: 'command', command: 'other-product pre-compact' }] },
|
|
141
|
+
] } }));
|
|
142
|
+
|
|
143
|
+
const before = JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout);
|
|
144
|
+
assert.deepEqual(before, { host: 'claude', config: { schema: 'throughline.claude-auto-handoff.v1', enabled: false, projects: [] },
|
|
145
|
+
hook: { registered: false }, pending: [] });
|
|
146
|
+
assert.equal(existsSync(join(home, '.throughline')), false, 'status は何も作らない');
|
|
147
|
+
|
|
148
|
+
const enabled = cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']);
|
|
149
|
+
assert.equal(enabled.status, 0, enabled.stderr);
|
|
150
|
+
assert.equal(JSON.parse(enabled.stdout).status, 'enabled');
|
|
151
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
152
|
+
assert.equal(settings.model, 'sonnet');
|
|
153
|
+
assert.deepEqual(settings.hooks.PreCompact, [
|
|
154
|
+
{ hooks: [{ type: 'command', command: 'throughline pre-compact' }] },
|
|
155
|
+
{ hooks: [{ type: 'command', command: 'other-product pre-compact' }] },
|
|
156
|
+
]);
|
|
157
|
+
const again = cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']);
|
|
158
|
+
assert.equal(again.status, 0, again.stderr);
|
|
159
|
+
assert.equal(readFileSync(settingsPath, 'utf8'), JSON.stringify(settings, null, 2) + '\n', '2回目は書き換えない');
|
|
160
|
+
|
|
161
|
+
const text = cli(home, ['auto-handoff', 'status', '--host', 'claude']);
|
|
162
|
+
assert.equal(text.stdout, '自動継続: 有効(Claude Code)\nPreCompact hook: 登録済み\n');
|
|
163
|
+
assert.equal(existsSync(join(home, '.throughline', 'codex-auto-handoff.json')), false);
|
|
164
|
+
assert.equal(JSON.parse(cli(home, ['auto-handoff', 'status', '--json']).stdout).config.enabled, false, 'Codex は無効のまま');
|
|
165
|
+
|
|
166
|
+
const disabled = JSON.parse(cli(home, ['auto-handoff', 'disable', '--host', 'claude', '--json']).stdout);
|
|
167
|
+
assert.equal(disabled.config.enabled, false);
|
|
168
|
+
const unsupported = cli(home, ['auto-handoff', 'resume', '--host', 'claude', '--operation', 'x', '--json']);
|
|
169
|
+
assert.equal(unsupported.status, 1);
|
|
170
|
+
assert.deepEqual(JSON.parse(unsupported.stdout), { status: 'failed', code: 'auto_handoff_action_unsupported' });
|
|
171
|
+
}));
|
|
172
|
+
|
|
173
|
+
test('hook: 自動圧縮の直後に、作業途中のターンと直近の会話を同じ会話へ注入し、最終回答は1ターンとして保存する', () => withDir(home => {
|
|
174
|
+
const project = join(home, 'project');
|
|
175
|
+
mkdirSync(project, { recursive: true });
|
|
176
|
+
const transcriptPath = join(home, 'transcript.jsonl');
|
|
177
|
+
const base = Date.parse('2026-10-04T03:00:00Z');
|
|
178
|
+
const at = seconds => new Date(base + seconds * 1000).toISOString();
|
|
179
|
+
writeFileSync(transcriptPath, jsonl([
|
|
180
|
+
user('成果物の1行目には必ず「約束: MANGO-4417」と書くこと', at(0)),
|
|
181
|
+
assistant('了解しました。', at(1)),
|
|
182
|
+
user('big1.txt と big2.txt を読んで result.txt を作って', at(10)),
|
|
183
|
+
toolUse('toolu_1', 'Read'),
|
|
184
|
+
toolResult('toolu_1', 'big1 contents'),
|
|
185
|
+
assistant('big1.txt を読み終えました。次に big2.txt を読みます。', at(12)),
|
|
186
|
+
]));
|
|
187
|
+
const common = { session_id: SESSION, cwd: project, transcript_path: transcriptPath };
|
|
188
|
+
const hook = (command, payload) => cli(home, [command], JSON.stringify({ ...common, ...payload }));
|
|
189
|
+
|
|
190
|
+
assert.equal(cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--project', project, '--json']).status, 0);
|
|
191
|
+
assert.equal(hook('session-start', { hook_event_name: 'SessionStart', source: 'startup' }).status, 0);
|
|
192
|
+
|
|
193
|
+
// 手動の /compact では注入しない
|
|
194
|
+
const manual = hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'manual' });
|
|
195
|
+
assert.equal(manual.status, 0, manual.stderr);
|
|
196
|
+
assert.equal(manual.stdout, '', 'PreCompact の stdout は空(JSON の decision を返さない)');
|
|
197
|
+
assert.equal(hook('session-start', { hook_event_name: 'SessionStart', source: 'compact' }).stdout, '');
|
|
198
|
+
|
|
199
|
+
// 自動圧縮
|
|
200
|
+
const pre = hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'auto' });
|
|
201
|
+
assert.equal(pre.status, 0, pre.stderr);
|
|
202
|
+
assert.equal(pre.stdout, '');
|
|
203
|
+
assert.equal(JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout).pending.length, 1);
|
|
204
|
+
appendFileSync(transcriptPath, jsonl([compactSummary()]));
|
|
205
|
+
|
|
206
|
+
const compacted = hook('session-start', { hook_event_name: 'SessionStart', source: 'compact' });
|
|
207
|
+
assert.equal(compacted.status, 0, compacted.stderr);
|
|
208
|
+
assert.match(compacted.stdout, /^## Throughline: 自動圧縮後の継続用コンテキスト\n/);
|
|
209
|
+
assert.match(compacted.stdout, /### 現在地 \(作業途中のターン\)\n\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: big1\.txt と big2\.txt を読んで result\.txt を作って\n/);
|
|
210
|
+
assert.match(compacted.stdout, /\*\*圧縮直前のあなたの発言\*\* \[\d\d:\d\d:\d\d\]: big1\.txt を読み終えました。次に big2\.txt を読みます。\n/);
|
|
211
|
+
assert.match(compacted.stdout, /\[user\]: 成果物の1行目には必ず「約束: MANGO-4417」と書くこと\n/);
|
|
212
|
+
assert.match(compacted.stdout, /\[assistant\]: 了解しました。/);
|
|
213
|
+
assert.doesNotMatch(compacted.stdout, /This session is being continued/, '圧縮の要約は記録に入れない');
|
|
214
|
+
assert.ok(compacted.stdout.length <= 9_501);
|
|
215
|
+
|
|
216
|
+
const db = new DatabaseSync(join(home, '.throughline', 'throughline.db'));
|
|
217
|
+
const bodies = () => db.prepare('SELECT turn_number, role, text FROM bodies ORDER BY turn_number, role DESC').all()
|
|
218
|
+
.map(row => [row.turn_number, row.role, row.text]);
|
|
219
|
+
assert.deepEqual(bodies(), [
|
|
220
|
+
[1, 'user', '成果物の1行目には必ず「約束: MANGO-4417」と書くこと'],
|
|
221
|
+
[1, 'assistant', '了解しました。'],
|
|
222
|
+
], '作業途中のターンは保存しない(途中の発言を回答として固定しない)');
|
|
223
|
+
|
|
224
|
+
// 印は消費済み。次の圧縮が手動なら何も出さない
|
|
225
|
+
assert.equal(JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout).pending.length, 0);
|
|
226
|
+
assert.equal(hook('session-start', { hook_event_name: 'SessionStart', source: 'compact' }).stdout, '');
|
|
227
|
+
|
|
228
|
+
// 圧縮の後、作業が終わる
|
|
229
|
+
appendFileSync(transcriptPath, jsonl([
|
|
230
|
+
toolUse('toolu_2', 'Read'),
|
|
231
|
+
toolResult('toolu_2', 'big2 contents'),
|
|
232
|
+
assistant('result.txt を作りました。', at(40)),
|
|
233
|
+
]));
|
|
234
|
+
const stop = hook('process-turn', { hook_event_name: 'Stop', last_assistant_message: 'result.txt を作りました。' });
|
|
235
|
+
assert.equal(stop.status, 0, stop.stderr);
|
|
236
|
+
assert.deepEqual(bodies().slice(2), [
|
|
237
|
+
[5, 'user', 'big1.txt と big2.txt を読んで result.txt を作って'],
|
|
238
|
+
[5, 'assistant', 'result.txt を作りました。'],
|
|
239
|
+
]);
|
|
240
|
+
const tools = db.prepare("SELECT tool_name FROM details WHERE kind = 'tool_input' AND turn_number = 5 ORDER BY id").all();
|
|
241
|
+
assert.deepEqual(tools.map(row => row.tool_name), ['Read', 'Read'], '圧縮より前のtoolも同じターンのL3に入る');
|
|
242
|
+
db.close();
|
|
243
|
+
|
|
244
|
+
const decisions = readFileSync(join(home, '.throughline', 'logs', 'inheritance-decision.log'), 'utf8').trim().split('\n').map(JSON.parse);
|
|
245
|
+
assert.deepEqual(decisions.filter(entry => entry.phase === 'pre-compact').map(entry => [entry.trigger, entry.auto_continuation, entry.skip_reason]),
|
|
246
|
+
[['manual', 'skipped', 'manual_compact'], ['auto', 'requested', null]]);
|
|
247
|
+
const continuation = decisions.filter(entry => entry.phase === 'compact-continuation');
|
|
248
|
+
assert.equal(continuation.length, 1);
|
|
249
|
+
assert.equal(continuation[0].injected, true);
|
|
250
|
+
assert.equal(continuation[0].in_flight_fragments, 1);
|
|
251
|
+
assert.equal(continuation[0].injected_l2_turns, 1);
|
|
252
|
+
}));
|
|
253
|
+
|
|
254
|
+
test('hook: transcript に今の依頼がまだ無い時(prompt_id が合わない)、前のターンを作業中として見せず、保存もしない', () => withDir(home => {
|
|
255
|
+
const project = join(home, 'project');
|
|
256
|
+
mkdirSync(project, { recursive: true });
|
|
257
|
+
const transcriptPath = join(home, 'transcript.jsonl');
|
|
258
|
+
writeFileSync(transcriptPath, jsonl([
|
|
259
|
+
{ ...user('最初の依頼', '2026-10-04T03:00:00Z'), promptId: 'prompt-1' },
|
|
260
|
+
assistant('最初の回答', '2026-10-04T03:00:01Z'),
|
|
261
|
+
{ ...user('前の依頼', '2026-10-04T03:00:10Z'), promptId: 'prompt-2' },
|
|
262
|
+
assistant('前の回答', '2026-10-04T03:00:11Z'),
|
|
263
|
+
]));
|
|
264
|
+
const common = { session_id: SESSION, cwd: project, transcript_path: transcriptPath, prompt_id: 'prompt-3' };
|
|
265
|
+
const hook = (command, payload) => cli(home, [command], JSON.stringify({ ...common, ...payload }));
|
|
266
|
+
assert.equal(cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']).status, 0);
|
|
267
|
+
assert.equal(hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'auto' }).status, 0);
|
|
268
|
+
|
|
269
|
+
const compacted = hook('session-start', { hook_event_name: 'SessionStart', source: 'compact' });
|
|
270
|
+
assert.equal(compacted.status, 0, compacted.stderr);
|
|
271
|
+
assert.match(compacted.stdout, /\*\*作業中のユーザー依頼\*\*: (記録からまだ読めません。/);
|
|
272
|
+
assert.doesNotMatch(compacted.stdout, /前の依頼|前の回答|圧縮直前のあなたの発言/);
|
|
273
|
+
assert.match(compacted.stdout, /\[user\]: 最初の依頼\n/);
|
|
274
|
+
const db = new DatabaseSync(join(home, '.throughline', 'throughline.db'));
|
|
275
|
+
assert.deepEqual(db.prepare('SELECT text FROM bodies ORDER BY id').all().map(row => row.text), ['最初の依頼', '最初の回答']);
|
|
276
|
+
db.close();
|
|
277
|
+
|
|
278
|
+
// prompt_id が合う時は、その群が作業途中のターン
|
|
279
|
+
assert.equal(hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'auto', prompt_id: 'prompt-2' }).status, 0);
|
|
280
|
+
const matched = hook('session-start', { hook_event_name: 'SessionStart', source: 'compact', prompt_id: 'prompt-2' });
|
|
281
|
+
assert.match(matched.stdout, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: 前の依頼\n\*\*圧縮直前のあなたの発言\*\* \[\d\d:\d\d:\d\d\]: 前の回答\n/);
|
|
282
|
+
}));
|
|
283
|
+
|
|
284
|
+
test('hook: 自動継続が無効な時、自動圧縮の前後で何も残さず何も出さない', () => withDir(home => {
|
|
285
|
+
const project = join(home, 'project');
|
|
286
|
+
mkdirSync(project, { recursive: true });
|
|
287
|
+
const transcriptPath = join(home, 'transcript.jsonl');
|
|
288
|
+
writeFileSync(transcriptPath, jsonl([user('依頼'), assistant('途中')]));
|
|
289
|
+
const common = { session_id: SESSION, cwd: project, transcript_path: transcriptPath };
|
|
290
|
+
const hook = (command, payload) => cli(home, [command], JSON.stringify({ ...common, ...payload }));
|
|
291
|
+
|
|
292
|
+
const pre = hook('pre-compact', { hook_event_name: 'PreCompact', trigger: 'auto' });
|
|
293
|
+
assert.equal(pre.status, 0, pre.stderr);
|
|
294
|
+
assert.equal(pre.stdout, '');
|
|
295
|
+
assert.equal(existsSync(join(home, '.throughline', 'claude-auto-handoff')), false);
|
|
296
|
+
const compacted = hook('session-start', { hook_event_name: 'SessionStart', source: 'compact' });
|
|
297
|
+
assert.equal(compacted.status, 0, compacted.stderr);
|
|
298
|
+
assert.equal(compacted.stdout, '');
|
|
299
|
+
}));
|
|
300
|
+
|
|
301
|
+
test('hook: PreCompact が失敗しても圧縮を止める終了コード(2)を返さず、理由を端末に残す', () => withDir(home => {
|
|
302
|
+
const failed = cli(home, ['pre-compact'], '{"trigger":"auto"}');
|
|
303
|
+
assert.equal(failed.status, 1);
|
|
304
|
+
assert.equal(failed.stdout, '');
|
|
305
|
+
const log = readFileSync(join(home, '.throughline', 'logs', 'hook-failures.log'), 'utf8');
|
|
306
|
+
assert.match(log, /HOOK_PRE_COMPACT_FAILED/);
|
|
307
|
+
assert.match(log, /Missing session_id in PreCompact payload/);
|
|
308
|
+
}));
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreCompact hook — Claude Code の自動継続の第一段 (ADR 0032)
|
|
4
|
+
*
|
|
5
|
+
* stdin: { session_id, transcript_path, cwd, hook_event_name, trigger, custom_instructions }
|
|
6
|
+
*
|
|
7
|
+
* 自動圧縮(trigger=auto)で、自動継続が有効な project の時だけ、その会話に印を残す。
|
|
8
|
+
* 記憶の注入は圧縮の後の SessionStart(source=compact) が行う。
|
|
9
|
+
*
|
|
10
|
+
* この hook は圧縮を止めない。Claude Code は PreCompact の exit code 2 と `decision: "block"` を
|
|
11
|
+
* 「圧縮を止める」と解釈するので、stdout には何も書かず、失敗した時も exit code 1 で終わる。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { pathToFileURL } from 'node:url';
|
|
15
|
+
import { parseHookPayload } from './hosts/index.mjs';
|
|
16
|
+
import { recordClaudeCompactRequest } from './claude-auto-handoff.mjs';
|
|
17
|
+
import { logDecision } from './decision-log.mjs';
|
|
18
|
+
import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
19
|
+
import { logHookFailure } from './hook-failure-log.mjs';
|
|
20
|
+
|
|
21
|
+
export async function run() {
|
|
22
|
+
let raw = '';
|
|
23
|
+
await new Promise((resolve) => {
|
|
24
|
+
process.stdin.setEncoding('utf8');
|
|
25
|
+
process.stdin.on('data', (chunk) => {
|
|
26
|
+
raw += chunk;
|
|
27
|
+
});
|
|
28
|
+
process.stdin.on('end', resolve);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const payload = parseHookPayload(raw, { env: process.env });
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
const result = recordClaudeCompactRequest({ payload, env: process.env, now });
|
|
34
|
+
|
|
35
|
+
logDecision({
|
|
36
|
+
ts: new Date(now).toISOString(),
|
|
37
|
+
phase: 'pre-compact',
|
|
38
|
+
session_id: result.sessionId,
|
|
39
|
+
project_path: result.projectPath ?? null,
|
|
40
|
+
trigger: payload.trigger ?? null,
|
|
41
|
+
auto_continuation: result.status,
|
|
42
|
+
skip_reason: result.reason ?? null,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
49
|
+
run().catch((err) => {
|
|
50
|
+
recordRuntimeErrorBestEffort('HOOK_PRE_COMPACT_FAILED');
|
|
51
|
+
logHookFailure('HOOK_PRE_COMPACT_FAILED', err);
|
|
52
|
+
process.stderr.write(`[pre-compact] error: ${err.message}\n`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
});
|
|
55
|
+
}
|
package/src/cli/auto-handoff.mjs
CHANGED
|
@@ -6,23 +6,31 @@ 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 } from './install.mjs';
|
|
9
|
+
import { installCodexAutoHandoffHook, installClaudeAutoHandoffHook, claudeAutoHandoffHookRegistered } from './install.mjs';
|
|
10
10
|
import { sameProjectPath } from '../project-path.mjs';
|
|
11
|
+
import { readClaudeAutoHandoffConfig, writeClaudeAutoHandoffConfig } from '../claude-auto-handoff-config.mjs';
|
|
12
|
+
import { listClaudeCompactRequests } from '../claude-auto-handoff.mjs';
|
|
13
|
+
|
|
14
|
+
// Claude の自動継続は圧縮後の同じ会話へ記憶を注入する (ADR 0032)。引き継ぎ操作の記録を持たないので、
|
|
15
|
+
// 使える action は有効化・無効化・状態だけ。
|
|
16
|
+
const CLAUDE_ACTIONS = ['enable', 'disable', 'status'];
|
|
11
17
|
|
|
12
18
|
export function parseAutoHandoffArgs(args) {
|
|
13
|
-
const out = { action: args[0] ?? 'status', operation: null, project: null, origin: null, turn: null, database: null, json: false };
|
|
19
|
+
const out = { action: args[0] ?? 'status', host: 'codex', operation: null, project: null, origin: null, turn: null, database: null, json: false };
|
|
14
20
|
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];
|
|
21
|
+
const allowed = ['host', ...{ enable: ['project'], disable: [], status: ['project', 'operation'],
|
|
22
|
+
resume: ['operation'], worker: ['operation'], detail: ['operation', 'origin', 'turn', 'database'] }[out.action]];
|
|
17
23
|
const seen = new Set();
|
|
18
24
|
for (let i = 1; i < args.length; i++) {
|
|
19
25
|
if (seen.has(args[i])) throw Error('auto_handoff_argument_invalid');
|
|
20
26
|
seen.add(args[i]);
|
|
21
27
|
if (args[i] === '--json') { out.json = true; continue; }
|
|
22
|
-
const key = { '--operation': 'operation', '--project': 'project', '--origin': 'origin', '--turn': 'turn', '--database': 'database' }[args[i]];
|
|
28
|
+
const key = { '--host': 'host', '--operation': 'operation', '--project': 'project', '--origin': 'origin', '--turn': 'turn', '--database': 'database' }[args[i]];
|
|
23
29
|
if (!key || !allowed.includes(key) || !args[i + 1] || args[i + 1].startsWith('--')) throw Error('auto_handoff_argument_invalid');
|
|
24
30
|
out[key] = args[++i];
|
|
25
31
|
}
|
|
32
|
+
if (!['codex', 'claude'].includes(out.host)) throw Error('auto_handoff_host_invalid');
|
|
33
|
+
if (out.host === 'claude' && (!CLAUDE_ACTIONS.includes(out.action) || out.operation)) throw Error('auto_handoff_action_unsupported');
|
|
26
34
|
if (out.project && !isAbsolute(out.project)) out.project = resolve(out.project);
|
|
27
35
|
if (out.project && out.operation) throw Error('auto_handoff_argument_invalid');
|
|
28
36
|
if (['worker', 'resume', 'detail'].includes(out.action) && !out.operation) throw Error('auto_handoff_operation_required');
|
|
@@ -59,6 +67,26 @@ export async function enableAutoHandoff({ project = null, codexHome = realCodexH
|
|
|
59
67
|
return { status: 'enabled', config, hookKeys, host: 'desktop' };
|
|
60
68
|
}
|
|
61
69
|
|
|
70
|
+
export function enableClaudeAutoHandoff({ project = null, register = installClaudeAutoHandoffHook } = {}) {
|
|
71
|
+
const registration = register();
|
|
72
|
+
const current = readClaudeAutoHandoffConfig();
|
|
73
|
+
const projects = project ? [...new Set([...(current.enabled ? current.projects : []), project])] : [];
|
|
74
|
+
const config = writeClaudeAutoHandoffConfig({ enabled: true, projects });
|
|
75
|
+
return { status: 'enabled', config, hook: { ...registration, registered: true }, host: 'claude' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function runClaude(parsed) {
|
|
79
|
+
if (parsed.action === 'enable') return enableClaudeAutoHandoff({ project: parsed.project });
|
|
80
|
+
if (parsed.action === 'disable') {
|
|
81
|
+
return { status: 'disabled', host: 'claude',
|
|
82
|
+
config: writeClaudeAutoHandoffConfig({ ...readClaudeAutoHandoffConfig(), enabled: false }) };
|
|
83
|
+
}
|
|
84
|
+
const pending = listClaudeCompactRequests()
|
|
85
|
+
.filter(request => !parsed.project || sameProjectPath(request.project_path, parsed.project));
|
|
86
|
+
return { host: 'claude', config: readClaudeAutoHandoffConfig(),
|
|
87
|
+
hook: { registered: claudeAutoHandoffHookRegistered() }, pending };
|
|
88
|
+
}
|
|
89
|
+
|
|
62
90
|
export function publicOperation(operation) {
|
|
63
91
|
if (!operation) return null;
|
|
64
92
|
const { handoff_id, source_thread_id, source_turn_id, target_thread_id, delivery_id,
|
|
@@ -73,6 +101,16 @@ export async function run(args = []) {
|
|
|
73
101
|
try {
|
|
74
102
|
const parsed = parseAutoHandoffArgs(args);
|
|
75
103
|
let result;
|
|
104
|
+
if (parsed.host === 'claude') {
|
|
105
|
+
result = runClaude(parsed);
|
|
106
|
+
if (parsed.json) process.stdout.write(JSON.stringify(result) + '\n');
|
|
107
|
+
else if (parsed.action === 'status') {
|
|
108
|
+
process.stdout.write(`自動継続: ${result.config.enabled ? '有効' : '無効'}(Claude Code)\n`);
|
|
109
|
+
process.stdout.write(`PreCompact hook: ${result.hook.registered ? '登録済み' : '未登録'}\n`);
|
|
110
|
+
for (const item of result.pending) process.stdout.write(`${item.session_id} 圧縮待ち ${new Date(item.requested_at).toISOString()}\n`);
|
|
111
|
+
} else process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
76
114
|
if (parsed.action === 'enable') result = await enableAutoHandoff({ project: parsed.project });
|
|
77
115
|
else if (parsed.action === 'disable') result = { status: 'disabled', config: writeAutoHandoffConfig({ ...readAutoHandoffConfig(), enabled: false }) };
|
|
78
116
|
else if (parsed.action === 'detail') {
|