throughline 0.16.31 → 0.16.33

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,382 @@
1
+ /**
2
+ * claude-forward.mjs — 引き継ぎ済みの Claude の会話へ届いた入力を、後継の会話へ届ける置き場 (ADR 0063)
3
+ *
4
+ * 旧い会話の UserPromptSubmit が、止めた入力を後継ごとの置き場へ1通1ファイルで置く。後継の hook
5
+ * (Stop の待つ hook・UserPromptSubmit・PreToolUse)が受け取って、モデルへ渡す。
6
+ *
7
+ * ~/.throughline/claude-auto-handoff/inbox/<後継の会話 ID>/<時刻>-<乱数>.json 届けるのを待つ入力
8
+ * ~/.throughline/claude-auto-handoff/inbox/<後継の会話 ID>/claimed/… 受け取った所が出力するまでの置き場
9
+ * ~/.throughline/claude-auto-handoff/inbox/<後継の会話 ID>/full/… 長すぎる入力の全文
10
+ * ~/.throughline/claude-auto-handoff/inbox/<後継の会話 ID>/waiter 今、待つ番の hook の印
11
+ * ~/.throughline/claude-auto-handoff/inbox/<後継の会話 ID>/waking 止まっている後継を起こしている所の印 (ADR 0064)
12
+ * ~/.throughline/claude-auto-handoff/forward.jsonl 運んだ記録(本文は書かない)
13
+ *
14
+ * 届いたか分からない入力は入れ直さない。受け取る所は、ファイルを `claimed` へ改名してから出力し、出力した後で消す。
15
+ * 道具の呼び出しのたびに走る hook からも読むので、ここは node の標準 module だけを読み込む。
16
+ */
17
+
18
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { randomUUID } from 'node:crypto';
22
+
23
+ /** 運んだ文の頭に置く印。記憶を読む側(transcript-reader)が、この印で運んだ文を見分ける。 */
24
+ export const CLAUDE_FORWARD_MARK = '【Throughline 転送】';
25
+ const HUMAN_SECTION = '--- 利用者の入力';
26
+ const PEER_SECTION = '--- ほかの会話からの文';
27
+ const NOTIFICATION_SECTION = '--- 裏の作業の通知';
28
+ const KINDS = Object.freeze(['prompt', 'peer', 'task-notification']);
29
+ // Claude Code が、別の会話から届いた発言の頭に付ける文(hosts/claude.mjs の PEER_MESSAGE_PREFIX と同じ)。
30
+ const PEER_MESSAGE_PREFIX = 'Another Claude session sent a message:';
31
+
32
+ /**
33
+ * 旧い会話へ届いた入力の種類。slash command(`/tl` など)は、その会話への命令で、後継へ運んでも命令にならないので
34
+ * null を返す(運ばない。打った人には、後継で打ち直す文が返る)。
35
+ * @returns {'prompt'|'peer'|'task-notification'|null}
36
+ */
37
+ export function claudeForwardKind(prompt) {
38
+ if (typeof prompt !== 'string' || !prompt.trim()) return null;
39
+ if (/^\s*<task-notification>/.test(prompt)) return 'task-notification';
40
+ if (prompt.startsWith(PEER_MESSAGE_PREFIX)) return 'peer';
41
+ if (/^\s*\/[A-Za-z]/.test(prompt)) return null;
42
+ return 'prompt';
43
+ }
44
+ /** 文脈として足す道(UserPromptSubmit・PreToolUse)で運ぶ上限。Claude Code は 10,000 字を超える文脈を頭だけに切り詰める。 */
45
+ export const CLAUDE_FORWARD_CONTEXT_MAX_CHARS = 9000;
46
+ /** Stop の道で、1通をそのまま運ぶ上限。超えた分は頭と、全文の file の場所を運ぶ。 */
47
+ const STOP_ITEM_MAX_CHARS = 60_000;
48
+ const STOP_ITEM_HEAD_CHARS = 4000;
49
+ const PENDING_TTL_MS = 7 * 24 * 60 * 60 * 1000;
50
+ const EMPTY_INBOX_TTL_MS = 24 * 60 * 60 * 1000;
51
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
52
+ const ITEM_NAME = /^\d{13,}-[0-9a-f]{8}\.json$/;
53
+
54
+ const defaultDir = () => join(homedir(), '.throughline', 'claude-auto-handoff');
55
+
56
+ /** 後継の会話の置き場。会話 ID が置き場の名前に使えない形なら null。 */
57
+ export function claudeForwardInboxDir(sessionId, dir = defaultDir()) {
58
+ return typeof sessionId === 'string' && SESSION_ID_PATTERN.test(sessionId) ? join(dir, 'inbox', sessionId) : null;
59
+ }
60
+
61
+ /** 置き場を作る。待つ hook は、このフォルダが在る会話でだけ待つ。 */
62
+ export function ensureClaudeForwardInbox(sessionId, dir = defaultDir()) {
63
+ const inbox = claudeForwardInboxDir(sessionId, dir);
64
+ if (!inbox) return false;
65
+ try {
66
+ mkdirSync(inbox, { recursive: true, mode: 0o700 });
67
+ return true;
68
+ } catch { return false; }
69
+ }
70
+
71
+ /** 運んだ記録。入力の本文は書かない。 */
72
+ export function logClaudeForward(entry, dir = defaultDir()) {
73
+ try {
74
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
75
+ appendFileSync(join(dir, 'forward.jsonl'), JSON.stringify({ at: new Date().toISOString(), ...entry }) + '\n', { mode: 0o600 });
76
+ } catch { /* 記録を書けなくても、運ぶ */ }
77
+ }
78
+
79
+ /**
80
+ * 旧い会話が止めた入力を、後継の置き場へ置く。
81
+ * @returns {boolean} 置けたか
82
+ */
83
+ export function enqueueClaudeForward(input) {
84
+ return writeClaudeForward(input) !== null;
85
+ }
86
+
87
+ /** 入力を1ファイルに書く。`claimed` が真なら、受け取り済みの置き場へ書く。書いたファイルの名前と場所を返す。 */
88
+ function writeClaudeForward({ successorSessionId, sourceSessionId, handoffId, kind, text, now = Date.now(), dir = defaultDir(), claimed = false }) {
89
+ const inbox = claudeForwardInboxDir(successorSessionId, dir);
90
+ if (!inbox || typeof text !== 'string' || !text.trim()) return null;
91
+ try {
92
+ const folder = claimed ? join(inbox, 'claimed') : inbox;
93
+ mkdirSync(folder, { recursive: true, mode: 0o700 });
94
+ const name = `${String(now).padStart(13, '0')}-${randomUUID().slice(0, 8)}.json`;
95
+ const path = join(folder, claimed ? `${name}.${process.pid}` : name);
96
+ const temporary = join(folder, `${name}.tmp`);
97
+ writeFileSync(temporary, JSON.stringify({ schema: 'throughline.claude-forward.v1', at: now,
98
+ source_session_id: sourceSessionId ?? null, handoff_id: handoffId ?? null,
99
+ kind: KINDS.includes(kind) ? kind : 'prompt', text }) + '\n', { mode: 0o600, flag: 'wx' });
100
+ renameSync(temporary, path);
101
+ return { inbox, name, path };
102
+ } catch { return null; }
103
+ }
104
+
105
+ /**
106
+ * 止まっている後継を起こす命令で直接渡す入力を、受け取り済み(`claimed`)として置く (ADR 0064)。置き場の入力として
107
+ * 待たせると、起きた後継の hook が同じ文を文脈としてもう1回渡すため。起こす命令が終わったら、届いた時は `settle()`、
108
+ * 届かなかったと確かめられた時は `release()`(置き場へ戻す)を呼ぶ。どちらも呼ばなければ、届いたか分からない入力として
109
+ * `claimed` に残る(入れ直さない)。
110
+ *
111
+ * @returns {{settle: () => void, release: () => boolean}|null} 書けなかった時は null
112
+ */
113
+ export function holdClaudeForward(input) {
114
+ const written = writeClaudeForward({ ...input, claimed: true });
115
+ if (!written) return null;
116
+ return {
117
+ settle: () => rmSync(written.path, { force: true }),
118
+ release: () => {
119
+ try { renameSync(written.path, join(written.inbox, written.name)); return true; }
120
+ catch { return false; }
121
+ },
122
+ };
123
+ }
124
+
125
+ const WAKE_CLAIM_TTL_MS = 60_000;
126
+
127
+ /**
128
+ * 止まっている後継を起こす番を取る (ADR 0064)。起こす命令を同時に2つ流すと、どちらも「起こした」と返るのに、
129
+ * 後継へ届くのは1通だけになる(Claude Code 2.1.296 の実測)。番を取れなかった入力は、置き場で待たせる。
130
+ * 起こせた後も印は消さず、60 秒の間は次の入力で起こさない(起きたばかりの後継は、一覧でまだ止まって見える事がある)。
131
+ *
132
+ * @returns {(() => void)|null} 番を返す関数。取れなかった時は null
133
+ */
134
+ export function acquireClaudeWakeClaim(sessionId, { dir = defaultDir(), now = Date.now() } = {}) {
135
+ const inbox = claudeForwardInboxDir(sessionId, dir);
136
+ if (!inbox) return null;
137
+ const path = join(inbox, 'waking');
138
+ for (let attempt = 0; attempt < 2; attempt++) {
139
+ try {
140
+ mkdirSync(inbox, { recursive: true, mode: 0o700 });
141
+ writeFileSync(path, `${process.pid}-${now}`, { mode: 0o600, flag: 'wx' });
142
+ return () => rmSync(path, { force: true });
143
+ } catch (error) {
144
+ if (error.code !== 'EEXIST') return null;
145
+ try {
146
+ if (now - statSync(path).mtimeMs <= WAKE_CLAIM_TTL_MS) return null;
147
+ rmSync(path, { force: true }); // 期限を過ぎた印
148
+ } catch { /* 別の hook が消した */ }
149
+ }
150
+ }
151
+ return null;
152
+ }
153
+
154
+ function pendingNames(inbox) {
155
+ try { return readdirSync(inbox).filter(name => ITEM_NAME.test(name)).sort(); }
156
+ catch { return []; }
157
+ }
158
+
159
+ /**
160
+ * 待つ hook の名乗り。番の終わりのたびに新しい待つ hook が立つが、前の hook は Claude Code が止めない(会話が次の番へ
161
+ * 進んでも残る)。新しい hook が自分の印を書き、前の hook は印が替わったのを見て退く。1つの会話で待つのは1つだけにする。
162
+ */
163
+ export function registerClaudeForwardWaiter(sessionId, token, dir = defaultDir()) {
164
+ const inbox = claudeForwardInboxDir(sessionId, dir);
165
+ if (!inbox) return false;
166
+ try {
167
+ writeFileSync(join(inbox, 'waiter'), token, { mode: 0o600 });
168
+ return true;
169
+ } catch { return false; }
170
+ }
171
+
172
+ /** 今、その会話で待つ番の hook の印。読めなければ null。 */
173
+ export function currentClaudeForwardWaiter(sessionId, dir = defaultDir()) {
174
+ const inbox = claudeForwardInboxDir(sessionId, dir);
175
+ try { return inbox ? readFileSync(join(inbox, 'waiter'), 'utf8') : null; }
176
+ catch { return null; }
177
+ }
178
+
179
+ /**
180
+ * その会話で、待つ hook の process が生きているか。生きていれば、会話の process も生きている(待つ hook は、会話の
181
+ * process が居なくなると退く)。生きていない時は、会話が止まっているか、待つ hook が登録される前に立った会話。
182
+ */
183
+ export function claudeForwardWaiterAlive(sessionId, dir = defaultDir()) {
184
+ const pid = Number(/^(\d+)-/.exec(currentClaudeForwardWaiter(sessionId, dir) ?? '')?.[1]);
185
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
186
+ try { process.kill(pid, 0); return true; }
187
+ catch (error) { return error.code === 'EPERM'; }
188
+ }
189
+
190
+ /** 届けるのを待つ入力が在るか(フォルダを1回読むだけ)。 */
191
+ export function hasPendingClaudeForwards(sessionId, dir = defaultDir()) {
192
+ const inbox = claudeForwardInboxDir(sessionId, dir);
193
+ return Boolean(inbox) && pendingNames(inbox).length > 0;
194
+ }
195
+
196
+ function readItem(path) {
197
+ try {
198
+ const item = JSON.parse(readFileSync(path, 'utf8'));
199
+ return item?.schema === 'throughline.claude-forward.v1' && typeof item.text === 'string' ? item : null;
200
+ } catch { return null; }
201
+ }
202
+
203
+ const localStamp = at => {
204
+ const d = new Date(at);
205
+ const two = n => String(n).padStart(2, '0');
206
+ return Number.isNaN(d.getTime()) ? '時刻不明'
207
+ : `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())} ${two(d.getHours())}:${two(d.getMinutes())}`;
208
+ };
209
+
210
+ /** 後継へ渡す文。届いた順に、1つの文にまとめる。 */
211
+ export function formatClaudeForwards(items) {
212
+ const humans = items.filter(item => item.kind === 'prompt').length;
213
+ const peers = items.filter(item => item.kind === 'peer').length;
214
+ const notes = items.length - humans - peers;
215
+ const sources = [...new Set(items.map(item => String(item.source_session_id ?? '').slice(0, 8)).filter(Boolean))];
216
+ const counts = [humans ? `利用者の入力 ${humans} 通` : null, peers ? `ほかの会話からの文 ${peers} 通` : null,
217
+ notes ? `裏の作業の通知 ${notes} 通` : null].filter(Boolean).join('、');
218
+ const handoffs = [...new Set(items.map(item => item.handoff_id).filter(id => typeof id === 'string' && id))];
219
+ // 利用者の入力の見出しに「利用者がこの会話へ打った入力として扱ってください」と書く。出どころを述べるだけの形は、
220
+ // 実機で、後継のモデルが従わない回が増えた(ADR 0063 の実測)。
221
+ const lines = [`${CLAUDE_FORWARD_MARK}引き継ぎ前の会話${sources.length ? `(${sources.join('・')})` : ''}へ届いた入力を、Throughlineがこの会話へ運びました(${counts})。作業はこの会話が引き継いでいます。` +
222
+ (handoffs.length ? `引き継ぎID: ${handoffs.join('、')}` : '')];
223
+ for (const item of items) {
224
+ lines.push(item.kind === 'task-notification'
225
+ ? `${NOTIFICATION_SECTION}(${localStamp(item.at)} に届いた。引き継ぎ前の会話が裏で起こした作業の通知です。結果が要る時は、出力の file を読んで確かめてください)---`
226
+ : item.kind === 'peer'
227
+ ? `${PEER_SECTION}(${localStamp(item.at)} に届いた。ほかの Claude の会話が、引き継ぎ前の会話へ送った文です。利用者の入力ではありません)---`
228
+ : `${HUMAN_SECTION}(${localStamp(item.at)} に届いた。利用者がこの会話へ打った入力として扱ってください)---`);
229
+ lines.push(item.text);
230
+ }
231
+ return lines.join('\n');
232
+ }
233
+
234
+ /**
235
+ * 置き場の入力を受け取る(`claimed` へ改名する)。受け取った分は、出力した後で `settleClaudeForwards` で消す。
236
+ * maxChars を渡した時は、まとめた文がその字数に収まる分だけを、届いた順に受け取る。先頭の1通が収まらない時は
237
+ * 何も受け取らない(Stop の道に残す)。maxChars が無い時(Stop の道)は全部を受け取り、長すぎる1通は頭と全文の
238
+ * file の場所に替える。
239
+ *
240
+ * @returns {{text: string, count: number, humans: number, notifications: number, chars: number, paths: string[]}|null}
241
+ */
242
+ export function claimClaudeForwards({ sessionId, dir = defaultDir(), maxChars = null }) {
243
+ const inbox = claudeForwardInboxDir(sessionId, dir);
244
+ if (!inbox) return null;
245
+ const names = pendingNames(inbox);
246
+ if (!names.length) return null;
247
+ const claimedDir = join(inbox, 'claimed');
248
+ const items = [];
249
+ const paths = [];
250
+ for (const name of names) {
251
+ const item = readItem(join(inbox, name));
252
+ if (!item) { rmSync(join(inbox, name), { force: true }); continue; } // 読めないファイルは運べない
253
+ if (maxChars !== null && formatClaudeForwards([...items, item]).length > maxChars) break;
254
+ const claimed = join(claimedDir, `${name}.${process.pid}`);
255
+ try {
256
+ mkdirSync(claimedDir, { recursive: true, mode: 0o700 });
257
+ renameSync(join(inbox, name), claimed);
258
+ } catch { continue; } // 別の hook が先に受け取った
259
+ if (maxChars === null && item.text.length > STOP_ITEM_MAX_CHARS) {
260
+ const full = join(inbox, 'full', `${name.slice(0, -'.json'.length)}.txt`);
261
+ try {
262
+ mkdirSync(join(inbox, 'full'), { recursive: true, mode: 0o700 });
263
+ writeFileSync(full, item.text, { mode: 0o600 });
264
+ item.text = `${item.text.slice(0, STOP_ITEM_HEAD_CHARS)}\n…(長いので頭だけを運びました。全 ${item.text.length} 字の全文: ${full})`;
265
+ } catch { item.text = `${item.text.slice(0, STOP_ITEM_MAX_CHARS)}\n…(長いので ${STOP_ITEM_MAX_CHARS} 字で切りました)`; }
266
+ }
267
+ items.push(item);
268
+ paths.push(claimed);
269
+ }
270
+ if (!items.length) return null;
271
+ const text = formatClaudeForwards(items);
272
+ const notifications = items.filter(item => item.kind === 'task-notification').length;
273
+ // humans は、利用者の入力と、ほかの会話からの文(どちらも届いた入力。記憶へ残す側)の数。
274
+ return { text, count: items.length, humans: items.length - notifications, notifications, chars: text.length, paths };
275
+ }
276
+
277
+ /** 出力した入力を消す。 */
278
+ export function settleClaudeForwards(claim) {
279
+ for (const path of claim?.paths ?? []) rmSync(path, { force: true });
280
+ }
281
+
282
+ /**
283
+ * 文脈として足す道(UserPromptSubmit・PreToolUse)から呼ぶ。収まる分を受け取り、渡す文を返す。
284
+ * 呼んだ側は、文を出力した後で `done()` を呼ぶ。
285
+ *
286
+ * @returns {{text: string, done: () => void}|null}
287
+ */
288
+ export function takeClaudeForwardsAsContext({ sessionId, via, dir = defaultDir() }) {
289
+ const claim = claimClaudeForwards({ sessionId, dir, maxChars: CLAUDE_FORWARD_CONTEXT_MAX_CHARS });
290
+ if (!claim) return null;
291
+ return { text: claim.text, done: () => {
292
+ settleClaudeForwards(claim);
293
+ logClaudeForward({ event: 'delivered', via, session_id: sessionId, count: claim.count, humans: claim.humans,
294
+ notifications: claim.notifications, chars: claim.chars }, dir);
295
+ } };
296
+ }
297
+
298
+ /** 置き場に残った入力を、別の会話(今の続きの会話)の置き場へ移す。移した数を返す。 */
299
+ export function moveClaudeForwards({ fromSessionId, toSessionId, dir = defaultDir() }) {
300
+ const from = claudeForwardInboxDir(fromSessionId, dir);
301
+ const to = claudeForwardInboxDir(toSessionId, dir);
302
+ if (!from || !to || from === to) return 0;
303
+ let moved = 0;
304
+ for (const name of pendingNames(from)) {
305
+ try {
306
+ mkdirSync(to, { recursive: true, mode: 0o700 });
307
+ renameSync(join(from, name), join(to, name));
308
+ moved++;
309
+ } catch { /* 別の hook が先に受け取った */ }
310
+ }
311
+ if (moved) logClaudeForward({ event: 'moved', session_id: fromSessionId, to_session_id: toSessionId, count: moved }, dir);
312
+ return moved;
313
+ }
314
+
315
+ /** 置き場の数。pending は届けるのを待つ入力、unknown は受け取った後で届いたか分からなくなった入力(1分より前の物)。 */
316
+ export function readClaudeForwardStatus(sessionId, { dir = defaultDir(), now = Date.now() } = {}) {
317
+ const inbox = claudeForwardInboxDir(sessionId, dir);
318
+ if (!inbox || !existsSync(inbox)) return { pending: 0, unknown: 0 };
319
+ let unknown = 0;
320
+ try {
321
+ for (const name of readdirSync(join(inbox, 'claimed'))) {
322
+ try { if (now - statSync(join(inbox, 'claimed', name)).mtimeMs > 60_000) unknown++; } catch { /* 消えた */ }
323
+ }
324
+ } catch { /* claimed が無い */ }
325
+ return { pending: pendingNames(inbox).length, unknown };
326
+ }
327
+
328
+ /**
329
+ * 置き場の片付け。7日を過ぎた入力は届けずに消す。どの引き継ぎの記録からも後継として指されていない会話の置き場は、
330
+ * 空で 24 時間動きが無ければ消す。
331
+ * @param {Set<string>} keepSessionIds 後継として指されている会話の ID
332
+ */
333
+ export function removeStaleClaudeForwards({ dir = defaultDir(), now = Date.now(), keepSessionIds = new Set() } = {}) {
334
+ let sessions;
335
+ try { sessions = readdirSync(join(dir, 'inbox')); }
336
+ catch { return; }
337
+ for (const sessionId of sessions) {
338
+ const inbox = claudeForwardInboxDir(sessionId, dir);
339
+ if (!inbox) continue;
340
+ let dropped = 0;
341
+ let left = 0;
342
+ for (const sub of ['', 'claimed', 'full']) {
343
+ let names;
344
+ try { names = readdirSync(join(inbox, sub)); }
345
+ catch { continue; }
346
+ for (const name of names) {
347
+ if (sub === '' && (name === 'claimed' || name === 'full' || name === 'waiter' || name === 'waking')) continue;
348
+ const path = join(inbox, sub, name);
349
+ try {
350
+ if (now - statSync(path).mtimeMs > PENDING_TTL_MS) { rmSync(path, { force: true }); if (sub !== 'full') dropped++; }
351
+ else left++;
352
+ } catch { /* 別の hook が消した */ }
353
+ }
354
+ }
355
+ if (dropped) logClaudeForward({ event: 'expired', session_id: sessionId, count: dropped }, dir);
356
+ if (left || keepSessionIds.has(sessionId)) continue;
357
+ try {
358
+ if (now - statSync(inbox).mtimeMs > EMPTY_INBOX_TTL_MS) rmSync(inbox, { recursive: true, force: true });
359
+ } catch { /* 消えた */ }
360
+ }
361
+ }
362
+
363
+ /**
364
+ * 記憶を読む側から呼ぶ。Stop の道で届いた行(Claude Code が通知の包みを付ける)から、運んだ文だけを取り出す。
365
+ * 運んだ文でなければ null。
366
+ */
367
+ export function unwrapClaudeForwardedDelivery(text) {
368
+ if (typeof text !== 'string') return null;
369
+ const start = text.indexOf(CLAUDE_FORWARD_MARK);
370
+ if (start < 0 || !text.trimStart().startsWith('<task-notification>')) return null;
371
+ const close = text.lastIndexOf('</system-reminder>');
372
+ return (close > start ? text.slice(start, close) : text.slice(start)).trim();
373
+ }
374
+
375
+ /**
376
+ * 運んだ文に、届いた入力(利用者の入力か、ほかの会話からの文)が入っているか。完了通知だけを運んだ文は、
377
+ * 作業の途中の入力として記憶へ足さない(ADR 0060 と同じ分け方)。
378
+ */
379
+ export function claudeForwardCarriesHumanInput(text) {
380
+ return typeof text === 'string' && text.startsWith(CLAUDE_FORWARD_MARK) &&
381
+ (text.includes(`\n${HUMAN_SECTION}`) || text.includes(`\n${PEER_SECTION}`));
382
+ }