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.
@@ -0,0 +1,489 @@
1
+ /**
2
+ * claude-auto-handoff.mjs — Claude Code の自動継続 (ADR 0033)
3
+ *
4
+ * 自動圧縮を置き換える。圧縮が走る前に旧い会話を止め、記憶を持った新しい会話で作業を続ける。
5
+ * Codex の自動継続 (codex-auto-handoff.mjs) と同じ並びで、Claude Code の入口に合わせている。
6
+ *
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
+ *
14
+ * 旧い会話は止めるだけで、空にしない。手動の /compact と subagent の中の圧縮は対象にしない。
15
+ * 状態は ~/.throughline/claude-auto-handoff/ のファイルに持つ(schema は変えない)。
16
+ */
17
+
18
+ import { closeSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { basename, dirname, join, resolve } from 'node:path';
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';
25
+ import { readClaudeAutoHandoffConfig, claudeAutoHandoffEnabledFor } from './claude-auto-handoff-config.mjs';
26
+ import { CLAUDE_HOST, hostOfSessionId } from './hosts/identity.mjs';
27
+ import { claudeHostAdapter } from './hosts/claude.mjs';
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 の区切りを含む値は受け付けない。
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'];
45
+
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']);
50
+
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');
65
+ return join(dir, `${sessionId}.json`);
66
+ }
67
+
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() } = {}) {
104
+ let names;
105
+ try { names = readdirSync(dir); }
106
+ catch (error) {
107
+ if (error.code === 'ENOENT') return [];
108
+ throw error;
109
+ }
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); }
128
+ catch (error) {
129
+ if (error.code === 'ENOENT') continue;
130
+ throw error;
131
+ }
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
+ }
143
+ }
144
+ }
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
+
151
+ /**
152
+ * PreCompact hook の本体。自動圧縮で、その project が有効な時だけ、印と記録を残して圧縮を止める。
153
+ *
154
+ * @param {{
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,
158
+ * }} params
159
+ * @returns {{status: 'requested'|'already_requested'|'skipped', block: boolean, reason?: string,
160
+ * sessionId: string, projectPath?: string, handoffId?: string}}
161
+ */
162
+ export function requestClaudeAutoHandoff({
163
+ payload,
164
+ openDb,
165
+ env = process.env,
166
+ config = readClaudeAutoHandoffConfig(),
167
+ now = Date.now(),
168
+ dir = claudeAutoHandoffDir(),
169
+ }) {
170
+ const sessionId = payload?.session_id;
171
+ if (typeof sessionId !== 'string' || !sessionId) throw new Error('Missing session_id in PreCompact payload');
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);
176
+ // 有効判定は会話を起動した project で行う。hook の cwd は Bash の cd に追従する。
177
+ const projectPath = claudeHostAdapter.completionProjectPath({ cwd: payload.cwd ?? process.cwd(), env });
178
+
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 };
193
+ }
194
+
195
+ // /tl と同じ印。後継の最初の指示が、この会話を前任として合流させる。
196
+ writeBaton(openDb(), { projectPath, sessionId, now });
197
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
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,
204
+ project_path: projectPath,
205
+ transcript_path: payload.transcript_path ?? null,
206
+ state: 'requested',
207
+ requested_at: now,
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;
226
+ }
227
+
228
+ const CONTINUATION_MARKER = /Throughline自動継続 ([0-9a-f-]{36})/;
229
+
230
+ /**
231
+ * 止めた時点の作業途中のターン。後継の会話が止められた時は、最後の user 発言が前の引き継ぎの
232
+ * 継続の指示になっている。その時は、前の引き継ぎが運んだ元の依頼を引き続き運ぶ。
233
+ */
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
+ }
327
+ }
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;
368
+ }
369
+
370
+ function takeSuccessorTarget(shortId, dir) {
371
+ let names;
372
+ try { names = readdirSync(targetsDir(dir)); }
373
+ catch (error) {
374
+ if (error.code === 'ENOENT') return null;
375
+ throw error;
376
+ }
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;
383
+ }
384
+
385
+ /**
386
+ * 最初の UserPromptSubmit(baton の合流)から呼ぶ。前任がこの後継へ自動で引き継いだ会話なら、
387
+ * 止めた時点の依頼を返す。届いた指示がこの引き継ぎの配送なら、受領を記録する。
388
+ *
389
+ * @returns {{handoffId: string, projectPath: string, inFlight: object|null, transcriptPath: string|null}|null}
390
+ */
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,
446
+ });
447
+ const launched = spawn('claude', successorArgs(record), {
448
+ cwd: record.project_path, env: successorEnv(env), encoding: 'utf8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'],
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 } });
461
+
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
+ },
484
+ },
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}` });
489
+ }