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.
- package/CHANGELOG.md +46 -1
- package/README.ja.md +14 -7
- package/README.md +14 -7
- package/bin/throughline.mjs +10 -4
- package/docs/02_clear_auto_handoff_plan.md +2 -2
- package/docs/05_codex_first_roadmap.md +3 -2
- package/docs/adr/0032-claude-compact-continuation.md +2 -0
- package/docs/adr/0033-claude-auto-handoff-new-session.md +104 -0
- package/package.json +2 -2
- package/rag/01-hooks/raw/claude-compact-hooks-extract.md +28 -3
- package/rag/INDEX.md +1 -1
- package/src/claude-auto-handoff.mjs +540 -135
- package/src/claude-auto-handoff.test.mjs +623 -193
- package/src/claude-pre-compact.mjs +21 -7
- package/src/claude-pre-tool-use.mjs +67 -0
- package/src/cli/auto-handoff.mjs +25 -19
- package/src/cli/install.mjs +67 -18
- package/src/cli/install.test.mjs +22 -4
- package/src/handoff-executor.mjs +24 -12
- package/src/hosts/claude.mjs +10 -0
- package/src/prompt-submit.mjs +1 -0
- package/src/resume-context.mjs +108 -40
- package/src/resume-context.test.mjs +88 -31
- package/src/runtime-error-store.mjs +6 -1
- package/src/session-start.mjs +8 -28
- package/src/transcript-reader.mjs +26 -4
- package/src/transcript-reader.test.mjs +43 -0
- package/src/turn-backfill.mjs +82 -8
- package/src/turn-backfill.test.mjs +75 -23
- package/src/turn-processor.mjs +4 -0
|
@@ -1,207 +1,612 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* claude-auto-handoff.mjs — Claude Code の自動継続 (ADR
|
|
2
|
+
* claude-auto-handoff.mjs — Claude Code の自動継続 (ADR 0033)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* 同じ会話で作業を続け、SessionStart(source=compact) の hook 出力を圧縮後の文脈へ足す。
|
|
7
|
-
* そこで Claude では、会話を切り替えずに次の 2 段で記憶を渡す。
|
|
4
|
+
* 自動圧縮を置き換える。圧縮が走る前に旧い会話を止め、記憶を持った新しい会話で作業を続ける。
|
|
5
|
+
* Codex の自動継続 (codex-auto-handoff.mjs) と同じ並びで、Claude Code の入口に合わせている。
|
|
8
6
|
*
|
|
9
|
-
* 1. PreCompact
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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 {
|
|
26
|
-
|
|
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 {
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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;
|
|
140
|
+
if (error.code === 'ENOENT') continue;
|
|
59
141
|
throw error;
|
|
60
142
|
}
|
|
61
|
-
|
|
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
|
-
*
|
|
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',
|
|
170
|
+
* @returns {{status: 'requested'|'already_requested'|'skipped', block: boolean, reason?: string,
|
|
171
|
+
* sessionId: string, projectPath?: string, handoffId?: string}}
|
|
78
172
|
*/
|
|
79
|
-
export function
|
|
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 =
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
schema:
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
*
|
|
118
|
-
*
|
|
275
|
+
* 止めたターンで、止めるまでにした発言と道具の呼び出し(古い順)と、止めた道具。
|
|
276
|
+
*
|
|
277
|
+
* 圧縮を止めた後の応答が呼んだ道具は、どれも実行されていない(同じ応答に並んだ道具は全部止める)。
|
|
278
|
+
* hook が並んで走るので、記録に残る止めた道具の id は、応答の中の最初の道具とは限らない。
|
|
279
|
+
* 同じ応答(message.id)の道具を、まとめて「止めた道具」にする。
|
|
119
280
|
*/
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
*
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
}
|