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.
@@ -1,207 +1,489 @@
1
1
  /**
2
- * claude-auto-handoff.mjs — Claude Code の自動継続 (ADR 0032)
2
+ * claude-auto-handoff.mjs — Claude Code の自動継続 (ADR 0033)
3
3
  *
4
- * Codex の自動継続は、自動圧縮の前に旧タスクを止めて新しいタスクへ記憶を渡す。Claude Code は
5
- * hook から作業を止められず、新しい会話へ指示を送る公開 API も無い。自動圧縮の後は host が
6
- * 同じ会話で作業を続け、SessionStart(source=compact) の hook 出力を圧縮後の文脈へ足す。
7
- * そこで Claude では、会話を切り替えずに次の 2 段で記憶を渡す。
4
+ * 自動圧縮を置き換える。圧縮が走る前に旧い会話を止め、記憶を持った新しい会話で作業を続ける。
5
+ * Codex の自動継続 (codex-auto-handoff.mjs) と同じ並びで、Claude Code の入口に合わせている。
8
6
  *
9
- * 1. PreCompact : trigger=auto の時だけ、その会話に「自動圧縮が始まった」印を残す。
10
- * 圧縮は止めない(止めると文脈上限の error になる)。
11
- * 2. SessionStart : source=compact で印があれば、完了済みのターンを回収し、
12
- * (source=compact) 作業途中のターンと直近の会話の原文を stdout へ書く。
7
+ * 1. PreCompact (trigger=auto) : 圧縮を止め(exit code 2)、/tl と同じ印(baton)と引き継ぎの記録を残す。
8
+ * 2. PreToolUse : 記録がある会話の次の道具を、実行させずに止める(deny + continue:false)。
9
+ * 止めた時点の依頼・設定を記録へ写し、後継を立てる worker を起動する。
10
+ * 3. worker : `claude --bg` で、指示を待つ新しい会話を同じ project に立てる。
11
+ * 4. worker : 後継の SessionStart が残した受け口へ、配送ライブラリで継続の指示を1通送る。
12
+ * 届いた時の UserPromptSubmit が baton を消費し、記憶を注入する。
13
13
  *
14
- * 手動の /compact は対象にしない(Codex と同じ)。印は PreCompact の payload だけから作り、
15
- * SessionStart の時点の transcript から trigger を推測しない(圧縮境界の行は hook より後に書かれる)。
14
+ * 旧い会話は止めるだけで、空にしない。手動の /compact と subagent の中の圧縮は対象にしない。
15
+ * 状態は ~/.throughline/claude-auto-handoff/ のファイルに持つ(schema は変えない)。
16
16
  */
17
17
 
18
- import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
18
+ import { closeSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
19
19
  import { homedir } from 'node:os';
20
- import { join } from 'node:path';
20
+ import { basename, dirname, join, resolve } from 'node:path';
21
21
  import { randomUUID } from 'node:crypto';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { setTimeout as delay } from 'node:timers/promises';
24
+ import { sendClaudeInbox } from 'aiterm-steer-delivery';
22
25
  import { readClaudeAutoHandoffConfig, claudeAutoHandoffEnabledFor } from './claude-auto-handoff-config.mjs';
23
26
  import { CLAUDE_HOST, hostOfSessionId } from './hosts/identity.mjs';
24
27
  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 の区切りを含む値は受け付けない。
28
+ import { normalizeTerminalText, readLatestUserGroup, readRawEntries } from './transcript-reader.mjs';
29
+ import { writeBaton } from './baton.mjs';
30
+ import { sameProjectPath } from './project-path.mjs';
31
+ import { spawnPortable, spawnPortableSync } from './os/portable-spawn-sync.mjs';
32
+
33
+ export const CLAUDE_AUTO_HANDOFF_SCHEMA = 'throughline.claude-auto-handoff.v2';
34
+ const CLI_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '../bin/throughline.mjs');
35
+ // 終わった引き継ぎの記録と、取り残された受け口の控えを掃除する期限。
36
+ const STALE_RECORD_MS = 24 * 60 * 60 * 1000;
37
+ // session id を記録のファイル名に使う。Claude の id は UUID で、path の区切りを含む値は受け付けない。
34
38
  const CLAUDE_SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
39
+ // `claude --bg` の出力(例: "backgrounded · a41a97ae · name (idle — send a prompt to start)")。
40
+ // 後継の中から起動した時は ID に色の制御文字が付くので、外してから読む。
41
+ const BACKGROUNDED_PATTERN = /backgrounded\s+\S+\s+([0-9a-f]{8})\b/;
42
+ // 後継へ渡さない、会話ごとの環境変数。後継は自分の値を持つ。
43
+ const PER_SESSION_ENV = ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_MESSAGING_SOCKET',
44
+ 'CLAUDE_CODE_MESSAGING_TOKEN', 'CLAUDE_CODE_SSE_PORT', 'CLAUDE_PROJECT_DIR', 'CLAUDE_ENV_FILE'];
35
45
 
36
- export const claudeCompactRequestDir = () => join(homedir(), '.throughline', 'claude-auto-handoff');
46
+ // requested と failed は、次の道具の hook が後継の立ち上げを始められる。
47
+ const RETRYABLE_STATES = new Set(['requested', 'failed']);
48
+ const TARGET_WAIT_STATES = new Set(['launching', 'launched']);
49
+ const ACCEPTABLE_STATES = new Set(['launched', 'sending', 'sent', 'unknown']);
37
50
 
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
- }
51
+ export const claudeAutoHandoffDir = () => join(homedir(), '.throughline', 'claude-auto-handoff');
52
+ const targetsDir = (dir) => join(dir, 'targets');
53
+
54
+ class ClaudeHandoffError extends Error {
55
+ constructor(code) { super(code); this.code = code; }
56
+ }
57
+
58
+ function validSessionId(sessionId) {
59
+ return typeof sessionId === 'string' && hostOfSessionId(sessionId) === CLAUDE_HOST &&
60
+ CLAUDE_SESSION_ID_PATTERN.test(sessionId);
61
+ }
62
+
63
+ function recordPath(sessionId, dir) {
64
+ if (!validSessionId(sessionId)) throw new ClaudeHandoffError('auto_handoff_session_id_invalid');
42
65
  return join(dir, `${sessionId}.json`);
43
66
  }
44
67
 
45
- function removeStaleRequests(dir, now) {
68
+ function writeJsonPrivate(path, value) {
69
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
70
+ const temporary = `${path}.${randomUUID()}.tmp`;
71
+ writeFileSync(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600, flag: 'wx' });
72
+ renameSync(temporary, path);
73
+ }
74
+
75
+ function readJson(path) {
76
+ let text;
77
+ try { text = readFileSync(path, 'utf8'); }
78
+ catch (error) {
79
+ if (error.code === 'ENOENT') return null;
80
+ throw error;
81
+ }
82
+ return JSON.parse(text);
83
+ }
84
+
85
+ /** 旧い会話の session id から、引き継ぎの記録を読む。無ければ null。 */
86
+ export function readClaudeAutoHandoff(sessionId, dir = claudeAutoHandoffDir()) {
87
+ const record = readJson(recordPath(sessionId, dir));
88
+ if (record === null) return null;
89
+ if (record.schema !== CLAUDE_AUTO_HANDOFF_SCHEMA || record.source_session_id !== sessionId) {
90
+ throw new ClaudeHandoffError('auto_handoff_record_invalid');
91
+ }
92
+ return record;
93
+ }
94
+
95
+ function updateRecord(sessionId, fields, { dir, now = Date.now() }) {
96
+ const record = readClaudeAutoHandoff(sessionId, dir);
97
+ if (!record) throw new ClaudeHandoffError('handoff_not_found');
98
+ const next = { ...record, ...fields, updated_at: now };
99
+ writeJsonPrivate(recordPath(sessionId, dir), next);
100
+ return next;
101
+ }
102
+
103
+ export function listClaudeAutoHandoffs({ dir = claudeAutoHandoffDir() } = {}) {
46
104
  let names;
47
105
  try { names = readdirSync(dir); }
48
106
  catch (error) {
49
- if (error.code === 'ENOENT') return;
107
+ if (error.code === 'ENOENT') return [];
50
108
  throw error;
51
109
  }
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; }
110
+ return names.filter(name => name.endsWith('.json'))
111
+ .map(name => readJson(join(dir, name)))
112
+ .filter(record => record?.schema === CLAUDE_AUTO_HANDOFF_SCHEMA)
113
+ .sort((a, b) => a.requested_at - b.requested_at);
114
+ }
115
+
116
+ /** 外へ見せる項目だけ。依頼の本文と受け口は含めない。 */
117
+ export function publicClaudeAutoHandoff(record) {
118
+ const { handoff_id, source_session_id, project_path, state, error_code, requested_at, updated_at, successor } = record;
119
+ return { handoff_id, source_session_id, project_path, state, error_code: error_code ?? null,
120
+ successor_session_id: successor?.session_id ?? null, successor_short_id: successor?.short_id ?? null,
121
+ requested_at, updated_at };
122
+ }
123
+
124
+ function removeStale(dir, now) {
125
+ for (const folder of [dir, targetsDir(dir)]) {
126
+ let names;
127
+ try { names = readdirSync(folder); }
57
128
  catch (error) {
58
- if (error.code === 'ENOENT') continue; // 別の hook が同時に消した
129
+ if (error.code === 'ENOENT') continue;
59
130
  throw error;
60
131
  }
61
- if (now - modifiedAt > STALE_REQUEST_MS) rmSync(path, { force: true });
132
+ for (const name of names) {
133
+ if (!/\.(json|claim|log)$/.test(name)) continue;
134
+ const path = join(folder, name);
135
+ let modifiedAt;
136
+ try { modifiedAt = statSync(path).mtimeMs; }
137
+ catch (error) {
138
+ if (error.code === 'ENOENT') continue; // 別の hook が同時に消した
139
+ throw error;
140
+ }
141
+ if (now - modifiedAt > STALE_RECORD_MS) rmSync(path, { force: true });
142
+ }
62
143
  }
63
144
  }
64
145
 
146
+ /** script から起動した Claude(`claude -p`、Agent SDK)は、呼び出し元がその process の結果を待っている。止めない。 */
147
+ function unsupportedEntrypoint(env) {
148
+ return typeof env.CLAUDE_CODE_ENTRYPOINT === 'string' && env.CLAUDE_CODE_ENTRYPOINT.startsWith('sdk-');
149
+ }
150
+
65
151
  /**
66
- * PreCompact hook の本体。自動圧縮で、その project が有効な時だけ印を残す。
67
- * 手動と無効の時は、同じ会話の古い印を消して何もしない。subagent の中の圧縮は、
68
- * 親の会話の印に触らない(payload の session_id は親の会話を指す)。
152
+ * PreCompact hook の本体。自動圧縮で、その project が有効な時だけ、印と記録を残して圧縮を止める。
69
153
  *
70
154
  * @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,
155
+ * payload: {session_id?: string, trigger?: string, cwd?: string, transcript_path?: string, agent_id?: string},
156
+ * openDb: () => import('node:sqlite').DatabaseSync, 印を書く時だけ開く
157
+ * env?: NodeJS.ProcessEnv, config?: {enabled: boolean, projects: string[]}, now?: number, dir?: string,
76
158
  * }} params
77
- * @returns {{status: 'requested'|'skipped', reason?: string, sessionId: string, projectPath?: string}}
159
+ * @returns {{status: 'requested'|'already_requested'|'skipped', block: boolean, reason?: string,
160
+ * sessionId: string, projectPath?: string, handoffId?: string}}
78
161
  */
79
- export function recordClaudeCompactRequest({
162
+ export function requestClaudeAutoHandoff({
80
163
  payload,
164
+ openDb,
81
165
  env = process.env,
82
166
  config = readClaudeAutoHandoffConfig(),
83
167
  now = Date.now(),
84
- dir = claudeCompactRequestDir(),
168
+ dir = claudeAutoHandoffDir(),
85
169
  }) {
86
170
  const sessionId = payload?.session_id;
87
171
  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);
172
+ // subagent の中の圧縮。payload の session_id は親の会話を指すので、親の記録には触らない。
173
+ if (payload.agent_id) return { status: 'skipped', block: false, reason: 'subagent_compact', sessionId };
174
+ const path = recordPath(sessionId, dir);
175
+ const existing = readClaudeAutoHandoff(sessionId, dir);
90
176
  // 有効判定は会話を起動した project で行う。hook の cwd は Bash の cd に追従する。
91
177
  const projectPath = claudeHostAdapter.completionProjectPath({ cwd: payload.cwd ?? process.cwd(), env });
92
178
 
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 };
179
+ if (payload.trigger !== 'auto') {
180
+ // 人が /compact を選んだ。まだ止めていない記録は取り下げる。
181
+ if (existing?.state === 'requested') rmSync(path, { force: true });
182
+ return { status: 'skipped', block: false, reason: 'manual_compact', sessionId, projectPath };
183
+ }
184
+ // 引き継ぎが進んでいる会話は、設定が後から変わっても圧縮させない。
185
+ if (existing) {
186
+ return { status: 'already_requested', block: true, sessionId, projectPath, handoffId: existing.handoff_id };
187
+ }
188
+ if (!claudeAutoHandoffEnabledFor(config, projectPath)) {
189
+ return { status: 'skipped', block: false, reason: 'auto_handoff_disabled', sessionId, projectPath };
190
+ }
191
+ if (unsupportedEntrypoint(env)) {
192
+ return { status: 'skipped', block: false, reason: 'handoff_host_unsupported', sessionId, projectPath };
99
193
  }
100
194
 
195
+ // /tl と同じ印。後継の最初の指示が、この会話を前任として合流させる。
196
+ writeBaton(openDb(), { projectPath, sessionId, now });
101
197
  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',
198
+ removeStale(dir, now);
199
+ const handoffId = randomUUID();
200
+ writeJsonPrivate(path, {
201
+ schema: CLAUDE_AUTO_HANDOFF_SCHEMA,
202
+ handoff_id: handoffId,
203
+ source_session_id: sessionId,
108
204
  project_path: projectPath,
109
205
  transcript_path: payload.transcript_path ?? null,
206
+ state: 'requested',
110
207
  requested_at: now,
111
- })}\n`, { mode: 0o600, flag: 'wx' });
112
- renameSync(temporary, path);
113
- return { status: 'requested', sessionId, projectPath };
208
+ updated_at: now,
209
+ settings: null,
210
+ in_flight: null,
211
+ successor: null,
212
+ error_code: null,
213
+ accepted_at: null,
214
+ });
215
+ return { status: 'requested', block: true, sessionId, projectPath, handoffId };
216
+ }
217
+
218
+ /** 止める時点で動いていたモデル。transcript の最後の assistant 行から読む(API error の合成行は除く)。 */
219
+ function readLatestModel(transcriptPath) {
220
+ const entries = readRawEntries(transcriptPath);
221
+ for (let index = entries.length - 1; index >= 0; index--) {
222
+ const model = entries[index]?.type === 'assistant' ? entries[index].message?.model : null;
223
+ if (typeof model === 'string' && model.startsWith('claude-')) return model;
224
+ }
225
+ return null;
114
226
  }
115
227
 
228
+ const CONTINUATION_MARKER = /Throughline自動継続 ([0-9a-f-]{36})/;
229
+
116
230
  /**
117
- * SessionStart(source=compact) から呼ぶ。その会話の印を取り出して消す。無ければ null。
118
- * @returns {{session_id: string, trigger: 'auto', project_path: string, transcript_path: string|null, requested_at: number}|null}
231
+ * 止めた時点の作業途中のターン。後継の会話が止められた時は、最後の user 発言が前の引き継ぎの
232
+ * 継続の指示になっている。その時は、前の引き継ぎが運んだ元の依頼を引き続き運ぶ。
119
233
  */
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;
234
+ function snapshotInFlight(transcriptPath, dir) {
235
+ const latest = readLatestUserGroup(transcriptPath);
236
+ if (!latest) return null;
237
+ const lastFragment = latest.fragments.at(-1) ?? null;
238
+ const previousHandoffId = CONTINUATION_MARKER.exec(latest.user.content)?.[1];
239
+ const carried = previousHandoffId
240
+ ? listClaudeAutoHandoffs({ dir }).find(record => record.handoff_id === previousHandoffId)?.in_flight?.user
241
+ : null;
242
+ return {
243
+ user: carried ?? { content: latest.user.content, timestamp: latest.user.timestamp },
244
+ last_fragment: lastFragment ? { content: lastFragment.content, timestamp: lastFragment.timestamp } : null,
245
+ };
246
+ }
247
+
248
+ /** 止めた道具の呼び出しが transcript に書かれるまで待つ。その前の発言が出そろった印になる。 */
249
+ async function waitForStoppedToolUse(record, { timeoutMs, pollMs }) {
250
+ if (!record.transcript_path || !record.stopped_tool_use_id) return;
251
+ const written = () => readRawEntries(record.transcript_path).some(entry => entry?.type === 'assistant' &&
252
+ Array.isArray(entry.message?.content) &&
253
+ entry.message.content.some(block => block?.type === 'tool_use' && block.id === record.stopped_tool_use_id));
254
+ for (const deadline = Date.now() + timeoutMs; !written() && Date.now() < deadline;) await delay(pollMs);
255
+ }
256
+
257
+ export async function launchClaudeAutoHandoffWorker(sessionId, { dir = claudeAutoHandoffDir() } = {}) {
258
+ if (!validSessionId(sessionId)) throw new ClaudeHandoffError('auto_handoff_session_id_invalid');
259
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
260
+ const log = openSync(join(dir, `${sessionId}.log`), 'a', 0o600);
261
+ try {
262
+ const child = spawnPortable(process.execPath,
263
+ [CLI_PATH, 'auto-handoff', 'worker', '--host', 'claude', '--operation', sessionId, '--json'],
264
+ { detached: true, stdio: ['ignore', log, log], windowsHide: true });
265
+ await new Promise((resolveSpawn, reject) => { child.once('spawn', resolveSpawn); child.once('error', reject); });
266
+ child.unref();
267
+ return child.pid;
268
+ } finally { closeSync(log); }
269
+ }
270
+
271
+ function stopOutput(record) {
272
+ const reason = record.successor?.short_id
273
+ ? `この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: ${record.handoff_id})。続きは後継の会話で行ってください: claude attach ${record.successor.short_id}`
274
+ : `Throughlineが、自動圧縮の代わりに新しい会話へ引き継ぎます(引き継ぎID: ${record.handoff_id})。後継の会話は \`claude agents\` の一覧に出ます。`;
275
+ return {
276
+ continue: false,
277
+ stopReason: reason,
278
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason },
279
+ };
280
+ }
281
+
282
+ /**
283
+ * PreToolUse hook の本体。引き継ぎの記録がある会話の道具を、実行させずに止める。
284
+ * 最初に止めた hook だけが、止めた時点の依頼と設定を記録へ写し、後継を立てる worker を起動する。
285
+ * 記録が無い会話と、subagent の中の道具には何もしない(null)。
286
+ *
287
+ * @returns {Promise<object|null>} Claude Code へ返す hook 出力
288
+ */
289
+ export async function stopClaudeTurnForHandoff({
290
+ payload,
291
+ now = Date.now(),
292
+ dir = claudeAutoHandoffDir(),
293
+ launchWorker = launchClaudeAutoHandoffWorker,
294
+ }) {
295
+ const sessionId = payload?.session_id;
296
+ if (!validSessionId(sessionId) || payload.agent_id) return null;
297
+ let record = readClaudeAutoHandoff(sessionId, dir);
298
+ if (!record) return null;
299
+ if (RETRYABLE_STATES.has(record.state)) {
300
+ // 同じ応答に並んだ道具の hook は同時に走る。立ち上げは1回だけ。
301
+ const claim = join(dir, `${sessionId}.claim`);
302
+ if (record.state === 'failed') rmSync(claim, { force: true });
303
+ let claimed = true;
304
+ try { closeSync(openSync(claim, 'wx', 0o600)); }
305
+ catch (error) {
306
+ if (error.code !== 'EEXIST') throw error;
307
+ claimed = false;
308
+ }
309
+ if (claimed) {
310
+ // 依頼とモデルは worker が transcript から読む。hook の時点では、直前の発言がまだ書かれていないことがある。
311
+ record = updateRecord(sessionId, {
312
+ state: 'stopped',
313
+ error_code: null,
314
+ stopped_tool_use_id: typeof payload.tool_use_id === 'string' ? payload.tool_use_id : null,
315
+ transcript_path: payload.transcript_path ?? record.transcript_path ?? null,
316
+ settings: {
317
+ model: null,
318
+ effort: typeof payload.effort?.level === 'string' ? payload.effort.level : null,
319
+ permission_mode: typeof payload.permission_mode === 'string' ? payload.permission_mode : null,
320
+ },
321
+ }, { dir, now });
322
+ try { await launchWorker(sessionId, { dir }); }
323
+ catch {
324
+ record = updateRecord(sessionId, { state: 'failed', error_code: 'handoff_worker_start_failed' }, { dir });
325
+ }
326
+ }
128
327
  }
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;
328
+ return stopOutput(record);
329
+ }
330
+
331
+ /**
332
+ * Stop hook から呼ぶ。圧縮を止めた後、道具を呼ばずにターンが終わった時は、続ける作業が無い。
333
+ * 記録を取り下げる。印(baton)は残るので、次に開いた新しい会話が記憶を引き継ぐ。
334
+ */
335
+ export function completeClaudeTurnWithoutHandoff({ sessionId, dir = claudeAutoHandoffDir() }) {
336
+ if (!validSessionId(sessionId)) return false;
337
+ const record = readClaudeAutoHandoff(sessionId, dir);
338
+ if (record?.state !== 'requested') return false;
339
+ rmSync(recordPath(sessionId, dir), { force: true });
340
+ return true;
341
+ }
342
+
343
+ /** 後継へ送る継続の指示。引き継ぎIDを含め、後継の最初の指示がこの配送のものかを照合できるようにする。 */
344
+ export function claudeContinuationInput(record) {
345
+ return `Throughline自動継続 ${record.handoff_id}\n` +
346
+ '注入された記憶と元のユーザー依頼に従い、未完了の作業をそのまま継続してください。' +
347
+ '直前の実行結果を確認し、完了済みの操作を重複実行しないでください。';
348
+ }
349
+
350
+ /**
351
+ * SessionStart から呼ぶ。同じ project で後継の立ち上げ中の引き継ぎがある時だけ、この会話の受け口を控える。
352
+ * worker が、`claude --bg` の返した ID と会話の ID を照合して読み、読んだら消す。
353
+ */
354
+ export function recordClaudeSuccessorTarget({ payload, env = process.env, dir = claudeAutoHandoffDir(), now = Date.now() }) {
355
+ const sessionId = payload?.session_id;
356
+ const socketPath = env.CLAUDE_CODE_MESSAGING_SOCKET;
357
+ if (!validSessionId(sessionId) || payload.agent_id || payload.source !== 'startup' ||
358
+ typeof socketPath !== 'string' || !socketPath) return false;
359
+ const cwd = payload.cwd ?? process.cwd();
360
+ const waiting = listClaudeAutoHandoffs({ dir }).some(record =>
361
+ TARGET_WAIT_STATES.has(record.state) && sameProjectPath(record.project_path, cwd) &&
362
+ (!record.successor?.short_id || sessionId.startsWith(record.successor.short_id)));
363
+ if (!waiting) return false;
364
+ writeJsonPrivate(join(targetsDir(dir), `${sessionId}.json`), {
365
+ session_id: sessionId, socket_path: socketPath, token: env.CLAUDE_CODE_MESSAGING_TOKEN ?? null, recorded_at: now,
366
+ });
367
+ return true;
134
368
  }
135
369
 
136
- export function listClaudeCompactRequests({ dir = claudeCompactRequestDir() } = {}) {
370
+ function takeSuccessorTarget(shortId, dir) {
137
371
  let names;
138
- try { names = readdirSync(dir); }
372
+ try { names = readdirSync(targetsDir(dir)); }
139
373
  catch (error) {
140
- if (error.code === 'ENOENT') return [];
374
+ if (error.code === 'ENOENT') return null;
141
375
  throw error;
142
376
  }
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);
377
+ const name = names.find(entry => entry.startsWith(shortId) && entry.endsWith('.json'));
378
+ if (!name) return null;
379
+ const path = join(targetsDir(dir), name);
380
+ const target = readJson(path);
381
+ rmSync(path, { force: true });
382
+ return target;
147
383
  }
148
384
 
149
385
  /**
150
- * 自動圧縮の直後に注入するテキストを作る。
386
+ * 最初の UserPromptSubmit(baton の合流)から呼ぶ。前任がこの後継へ自動で引き継いだ会話なら、
387
+ * 止めた時点の依頼を返す。届いた指示がこの引き継ぎの配送なら、受領を記録する。
151
388
  *
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}
389
+ * @returns {{handoffId: string, projectPath: string, inFlight: object|null, transcriptPath: string|null}|null}
166
390
  */
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,
391
+ export function acceptClaudeAutoContinuation({ predecessorId, successorSessionId, prompt = null,
392
+ dir = claudeAutoHandoffDir(), now = Date.now() }) {
393
+ if (!validSessionId(predecessorId) || typeof successorSessionId !== 'string') return null;
394
+ const record = readClaudeAutoHandoff(predecessorId, dir);
395
+ if (!record || !ACCEPTABLE_STATES.has(record.state) || !record.successor?.short_id ||
396
+ !successorSessionId.startsWith(record.successor.short_id)) return null;
397
+ if (typeof prompt === 'string' && prompt.includes(`Throughline自動継続 ${record.handoff_id}`) && !record.accepted_at) {
398
+ updateRecord(predecessorId, { accepted_at: now }, { dir, now });
399
+ }
400
+ return { handoffId: record.handoff_id, projectPath: record.project_path, inFlight: record.in_flight ?? null,
401
+ transcriptPath: record.transcript_path ?? null };
402
+ }
403
+
404
+ function successorArgs(record) {
405
+ const args = ['--bg', '--name', `tl-${basename(record.project_path)}-${record.handoff_id.slice(0, 8)}`];
406
+ const { model, effort, permission_mode: permissionMode } = record.settings ?? {};
407
+ if (model) args.push('--model', model);
408
+ if (effort) args.push('--effort', effort);
409
+ if (permissionMode) args.push('--permission-mode', permissionMode);
410
+ // 後継は旧い会話の続きなので、同じ作業ツリーを編集する。別の worktree へ移さない。
411
+ args.push('--settings', JSON.stringify({ worktree: { bgIsolation: 'none' } }));
412
+ return args;
413
+ }
414
+
415
+ function successorEnv(env) {
416
+ const next = { ...env };
417
+ for (const key of PER_SESSION_ENV) delete next[key];
418
+ return next;
419
+ }
420
+
421
+ /**
422
+ * 後継を立てて、継続の指示を1通送る。失敗は固定の理由を記録して止まる。送信の結果が不明な時は再送しない。
423
+ */
424
+ export async function runClaudeAutoHandoffWorker(sessionId, {
425
+ dir = claudeAutoHandoffDir(),
426
+ env = process.env,
427
+ spawn = spawnPortableSync,
428
+ send = sendClaudeInbox,
429
+ transcriptTimeoutMs = 5_000,
430
+ targetTimeoutMs = 30_000,
431
+ sendTimeoutMs = 30_000,
432
+ pollMs = 200,
433
+ } = {}) {
434
+ let record = readClaudeAutoHandoff(sessionId, dir);
435
+ if (!record) throw new ClaudeHandoffError('handoff_not_found');
436
+ if (record.state !== 'stopped') return record;
437
+ const update = fields => { record = updateRecord(sessionId, fields, { dir }); return record; };
438
+ const fail = code => update({ state: 'failed', error_code: code });
439
+
440
+ // 止めた時点の依頼と、その時のモデルを写す。後継の最初の指示が、この依頼を現在地として受け取る。
441
+ await waitForStoppedToolUse(record, { timeoutMs: transcriptTimeoutMs, pollMs });
442
+ update({
443
+ state: 'launching',
444
+ settings: { ...record.settings, model: record.transcript_path ? readLatestModel(record.transcript_path) : null },
445
+ in_flight: record.transcript_path ? snapshotInFlight(record.transcript_path, dir) : null,
179
446
  });
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,
447
+ const launched = spawn('claude', successorArgs(record), {
448
+ cwd: record.project_path, env: successorEnv(env), encoding: 'utf8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'],
190
449
  });
450
+ if (launched.error) {
451
+ return fail(launched.error.code === 'ENOENT' ? 'handoff_claude_cli_unavailable' : 'handoff_successor_launch_failed');
452
+ }
453
+ const shortId = BACKGROUNDED_PATTERN.exec(normalizeTerminalText(`${launched.stdout ?? ''}`))?.[1];
454
+ if (launched.status !== 0 || !shortId) {
455
+ // 理由を端末内の worker のログに残す(指示や記憶は含まれない、起動コマンドの出力だけ)。
456
+ process.stderr.write(`[auto-handoff] claude --bg exit=${launched.status} stdout=${JSON.stringify(`${launched.stdout ?? ''}`.slice(0, 500))} ` +
457
+ `stderr=${JSON.stringify(`${launched.stderr ?? ''}`.slice(0, 500))}\n`);
458
+ return fail('handoff_successor_launch_failed');
459
+ }
460
+ update({ state: 'launched', successor: { short_id: shortId, session_id: null } });
191
461
 
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,
462
+ let target = null;
463
+ for (const deadline = Date.now() + targetTimeoutMs; !target && Date.now() < deadline;) {
464
+ target = takeSuccessorTarget(shortId, dir);
465
+ if (!target) await delay(pollMs);
466
+ }
467
+ if (!target) return fail('handoff_successor_target_unavailable');
468
+ update({ successor: { short_id: shortId, session_id: target.session_id } });
469
+
470
+ // ここから先は後継へ指示が届き得る。結果が不明でも、同じ指示をもう一度送らない。
471
+ update({ state: 'sending' });
472
+ const result = await send(
473
+ { socket_path: target.socket_path, ...(target.token ? { token: target.token } : {}) },
474
+ claudeContinuationInput(record),
475
+ {
476
+ timeout_ms: sendTimeoutMs,
477
+ confirm_acceptance: async signal => {
478
+ while (!signal.aborted) {
479
+ if (readClaudeAutoHandoff(sessionId, dir)?.accepted_at) return true;
480
+ await delay(pollMs);
481
+ }
482
+ return false;
483
+ },
205
484
  },
206
- };
485
+ );
486
+ if (result.status === 'accepted') return update({ state: 'sent', error_code: null });
487
+ if (result.status === 'not_sent') return fail(`handoff_delivery_${result.reason}`);
488
+ return update({ state: 'unknown', error_code: `handoff_delivery_${result.reason}` });
207
489
  }