throughline 0.13.0 → 0.14.1

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