throughline 0.16.30 → 0.16.32

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.
@@ -21,7 +21,7 @@
21
21
  * 状態は ~/.throughline/claude-auto-handoff/ のファイルに持つ(schema は変えない)。
22
22
  */
23
23
 
24
- import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
24
+ import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
25
25
  import { homedir } from 'node:os';
26
26
  import { dirname, join, posix, resolve, win32 } from 'node:path';
27
27
  import { randomUUID } from 'node:crypto';
@@ -43,6 +43,7 @@ import { sameProjectPath } from './project-path.mjs';
43
43
  import { runWithPty } from './os/pty-run.mjs';
44
44
  import { spawnPortable, spawnPortableSync } from './os/portable-spawn-sync.mjs';
45
45
  import { composeAutoHandoffTitle } from './auto-handoff-title.mjs';
46
+ import { claudeForwardKind, claudeForwardWaiterAlive, enqueueClaudeForward, ensureClaudeForwardInbox, readClaudeForwardStatus, removeStaleClaudeForwards } from './claude-forward.mjs';
46
47
 
47
48
  export const CLAUDE_AUTO_HANDOFF_SCHEMA = 'throughline.claude-auto-handoff.v2';
48
49
  const CLI_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '../bin/throughline.mjs');
@@ -140,10 +141,13 @@ export function listClaudeAutoHandoffs({ dir = claudeAutoHandoffDir() } = {}) {
140
141
  }
141
142
 
142
143
  /** 外へ見せる項目だけ。依頼の本文・発言・道具の対象と、受け口は含めない。 */
143
- export function publicClaudeAutoHandoff(record) {
144
+ export function publicClaudeAutoHandoff(record, { dir = claudeAutoHandoffDir() } = {}) {
144
145
  const { handoff_id, source_session_id, project_path, state, error_code, requested_at, updated_at, successor } = record;
146
+ // 後継の置き場の数(ADR 0063)。pending は届けるのを待つ入力、unknown は届いたか分からなくなった入力。
147
+ const forward = validSessionId(successor?.session_id) ? readClaudeForwardStatus(successor.session_id, { dir }) : null;
145
148
  return { handoff_id, source_session_id, project_path, state, error_code: error_code ?? null,
146
149
  successor_session_id: successor?.session_id ?? null, successor_short_id: successor?.short_id ?? null,
150
+ forward_pending: forward ? forward.pending : null, forward_unknown: forward ? forward.unknown : null,
147
151
  // 止めたターンを DB へ取り込めたか。false の時、後継は `throughline detail` でそのターンの入出力を取れない。
148
152
  in_flight_captured: Boolean(record.in_flight?.turn),
149
153
  // 後継を Claude Desktop で開く引き継ぎか、開けたか(null は対象外)。
@@ -219,6 +223,12 @@ function removeStale(dir, now) {
219
223
  rmSync(path, { force: true });
220
224
  }
221
225
  }
226
+ // 後継の置き場(ADR 0063)。後継として指されている会話の置き場は、空でも残す(待つ hook が、フォルダの有無で待つかを決める)。
227
+ try {
228
+ const keepSessionIds = new Set(listClaudeAutoHandoffs({ dir })
229
+ .map(record => record.successor?.session_id).filter(validSessionId));
230
+ removeStaleClaudeForwards({ dir, now, keepSessionIds });
231
+ } catch { /* 置き場の片付けの失敗で、引き継ぎを止めない */ }
222
232
  }
223
233
 
224
234
  /**
@@ -575,6 +585,60 @@ function readInFlightSteps(transcriptPath, stoppedToolUseId, projectPath) {
575
585
  };
576
586
  }
577
587
 
588
+ const BACKGROUND_TASKS_KEPT = 10;
589
+ const TASK_NOTIFICATION_HEAD = /^\s*<task-notification>/;
590
+ const BACKGROUND_OUTPUT_FILE = /Output is being written to: (.+?)(?:\. |\.$|$)/m;
591
+
592
+ /**
593
+ * 会話が裏で起こして、終わった通知がまだ来ていない作業(新しい順)(ADR 0062)。
594
+ *
595
+ * 裏で起こした道具は、結果の行に host が `toolUseResult.backgroundTaskId` を書く。終わると、完了通知
596
+ * (`<task-notification>` に `<task-id>` と `<status>`)が、user 行か、ターンの途中なら `queued_command` で届く。
597
+ * 引き継いだ後は、その通知が旧い会話へ届く。後継は、出力のファイルを読めば結果を確かめられる。
598
+ *
599
+ * @returns {{tasks: Array<{task_id: string, name: string, target: string, output_file: string|null}>, total: number}}
600
+ */
601
+ export function readRunningBackgroundTasks(transcriptPath, projectPath = null) {
602
+ const toolById = new Map();
603
+ const started = new Map();
604
+ const finished = new Set();
605
+ const noteFinished = (text) => {
606
+ if (typeof text !== 'string' || !TASK_NOTIFICATION_HEAD.test(text) || !/<status>[^<]+<\/status>/.test(text)) return;
607
+ const id = /<task-id>([^<]+)<\/task-id>/.exec(text)?.[1];
608
+ if (id) finished.add(id.trim());
609
+ };
610
+ for (const entry of readRawEntries(transcriptPath)) {
611
+ if (entry?.isSidechain === true) continue;
612
+ if (entry?.type === 'attachment') {
613
+ if (entry.attachment?.type === 'queued_command') noteFinished(entry.attachment.prompt);
614
+ continue;
615
+ }
616
+ const content = entry?.message?.content;
617
+ if (entry?.type === 'assistant' && Array.isArray(content)) {
618
+ for (const block of content) {
619
+ if (block?.type === 'tool_use' && typeof block.id === 'string') {
620
+ toolById.set(block.id, { name: block.name ?? 'unknown', target: toolTarget(block.input, projectPath) });
621
+ }
622
+ }
623
+ } else if (entry?.type === 'user') {
624
+ if (typeof content === 'string') { noteFinished(content); continue; }
625
+ if (!Array.isArray(content)) continue;
626
+ const taskId = entry.toolUseResult?.backgroundTaskId;
627
+ for (const block of content) {
628
+ if (block?.type === 'text') noteFinished(block.text);
629
+ if (block?.type !== 'tool_result' || typeof taskId !== 'string' || !taskId) continue;
630
+ const text = typeof block.content === 'string' ? block.content
631
+ : Array.isArray(block.content) ? block.content.map(part => (typeof part?.text === 'string' ? part.text : '')).join('') : '';
632
+ const tool = toolById.get(block.tool_use_id) ?? { name: 'unknown', target: '' };
633
+ started.set(taskId, { task_id: taskId, name: tool.name, target: tool.target,
634
+ output_file: BACKGROUND_OUTPUT_FILE.exec(text)?.[1]?.trim() || null });
635
+ }
636
+ }
637
+ }
638
+ const running = [...started.values()].filter(task => !finished.has(task.task_id)).reverse();
639
+ return { tasks: running.slice(0, BACKGROUND_TASKS_KEPT), total: running.length };
640
+ }
641
+
578
642
  /**
579
643
  * 止めた時点の作業途中のターン。後継の会話が止められた時は、最後の user 発言が前の引き継ぎの
580
644
  * 継続の指示になっている。その時は、前の引き継ぎが運んだ元の依頼を引き続き運ぶ。
@@ -592,6 +656,7 @@ function snapshotInFlight(record, dir) {
592
656
  user: carried ?? { content: latest.user.content, timestamp: latest.user.timestamp },
593
657
  last_fragment: lastFragment ? { content: lastFragment.content, timestamp: lastFragment.timestamp } : null,
594
658
  ...readInFlightSteps(record.transcript_path, record.stopped_tool_use_id, record.project_path),
659
+ background: readRunningBackgroundTasks(record.transcript_path, record.project_path),
595
660
  turn: null,
596
661
  };
597
662
  }
@@ -644,9 +709,53 @@ function withdrawRecord(sessionId, dir) {
644
709
  rmSync(join(dir, `${sessionId}.claim`), { force: true });
645
710
  }
646
711
 
647
- function stopOutput(record) {
712
+ /**
713
+ * 引き継ぎ済みの会話から見た、今の続きの会話。後継がさらに引き継がれていれば、その先を返す。
714
+ * たどれない時は、記録にある直接の後継。
715
+ *
716
+ * @returns {{shortId: string|null, sessionId: string|null}}
717
+ */
718
+ function currentSuccessor(record, dir) {
719
+ try {
720
+ const resolved = resolveClaudeAutoHandoffSuccessor(record.source_session_id, { dir });
721
+ if (resolved.handed_off && resolved.current_session_id !== record.source_session_id) {
722
+ return { shortId: resolved.current_session_id.slice(0, 8), sessionId: resolved.current_session_id };
723
+ }
724
+ } catch { /* 記録を読めない時は、直接の後継を示す */ }
725
+ return { shortId: record.successor?.short_id ?? null,
726
+ sessionId: validSessionId(record.successor?.session_id) ? record.successor.session_id : null };
727
+ }
728
+
729
+ /**
730
+ * 引き継ぎ済みの会話で、道具を断る時と入力を止める時に見せる文。
731
+ * `claude rm` で一覧から消した後継は `claude attach` では開けない(記録は残る)。その時に開ける命令も添える。
732
+ */
733
+ function handedOffReason(record, dir) {
734
+ const { shortId, sessionId } = currentSuccessor(record, dir);
735
+ return `この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: ${record.handoff_id})。` +
736
+ `続きは後継の会話で行ってください: claude attach ${shortId}` +
737
+ (sessionId ? `(一覧に無い時は claude --resume ${sessionId})` : '');
738
+ }
739
+
740
+ /**
741
+ * 引き継ぎ済みの会話へ届いた入力を、後継の置き場へ置けた時に見せる文 (ADR 0063)。
742
+ * 後継で待つ hook が生きていれば、後継は動いている。生きていなければ、後継は止まっているか、待つ hook が登録される前に
743
+ * 立った会話で、入力は後継が次に動く時まで置き場で待つ。どちらかを、打った人へ返す。
744
+ */
745
+ function forwardedReason(record, dir, successorSessionId) {
746
+ const { shortId, sessionId } = currentSuccessor(record, dir);
747
+ const open = `claude attach ${shortId}` + (sessionId ? `(一覧に無い時は claude --resume ${sessionId})` : '');
748
+ return `この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: ${record.handoff_id})。` +
749
+ '届いた入力は、後継の会話へ渡しました。' +
750
+ (claudeForwardWaiterAlive(successorSessionId, dir)
751
+ ? '後継は動いています。手すきならすぐ、作業中なら次の道具の前か番の終わりに届きます。' +
752
+ `後継のモデルが、運ばれた入力を利用者の依頼として扱わない事があります。答えは後継で確かめてください: ${open}`
753
+ : `後継は今、入力を待ち受けていません(止まっているか、この版より前に立った会話です)。後継を開いて何か入力した時に届きます: ${open}`);
754
+ }
755
+
756
+ function stopOutput(record, dir) {
648
757
  const reason = record.successor?.short_id
649
- ? `この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: ${record.handoff_id})。続きは後継の会話で行ってください: claude attach ${record.successor.short_id}`
758
+ ? handedOffReason(record, dir)
650
759
  : `Throughlineが、自動圧縮の代わりに新しい会話へ引き継ぎます(引き継ぎID: ${record.handoff_id})。` +
651
760
  (record.desktop?.wanted
652
761
  ? '後継の会話は裏で作業を続け、そのターンを終えた時にClaude Desktopへ開きます。途中の様子は `claude agents` の一覧で見られます。'
@@ -659,6 +768,50 @@ function stopOutput(record) {
659
768
  };
660
769
  }
661
770
 
771
+ /** 引き継ぎ済みの会話へ届いた入力を止めた記録。入力の本文は書かない。 */
772
+ function recordClaudeRedirect(entry, dir) {
773
+ try {
774
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
775
+ appendFileSync(join(dir, 'redirects.jsonl'), JSON.stringify(entry) + '\n', { mode: 0o600 });
776
+ } catch { /* 記録を書けなくても、入力は止める */ }
777
+ }
778
+
779
+ /**
780
+ * UserPromptSubmit hook から呼ぶ (ADR 0062)。引き継ぎ済みで、後継が残っている会話へ届いた入力を、モデルを動かさずに止める。
781
+ *
782
+ * 作業は後継が持っている。旧い会話は道具を使えないので、届いた入力に文で答える事しかできない。人が打った文は、
783
+ * 後継の場所と一緒に打った人へ返す(Claude Code が、止めた理由と元の文を見せる)。裏の作業の完了通知も、ここで止める。
784
+ * 止めないと、文脈がほぼ一杯の旧い会話で、通知のたびにモデルが1回動く。
785
+ *
786
+ * 記録が無い会話、後継がまだ立っていない会話(予約・立ち上げ中・失敗)、後継が無くなった会話、subagent の中の入力には
787
+ * 何もしない(null)。Aiterm が起こした席の会話(ADR 0057)も止めない。席を外す前の版が引き継いだ席が残っていても、
788
+ * 呼び手は席の番の終わりを待っている。止めると番が始まらず、呼び手は答えを受け取れない。
789
+ *
790
+ * 止めた入力は、今の続きの会話の置き場へ置く (ADR 0063)。後継の hook が受け取って、モデルへ渡す。後継の会話 ID を
791
+ * 引けない時、置き場へ書けなかった時、入力が slash command の時は、置かずに「後継で打ち直す」文を返す。
792
+ *
793
+ * @returns {{output: {decision: 'block', reason: string}, kind: 'prompt'|'task-notification', handoffId: string, successorShortId: string|null, forwarded: boolean}|null}
794
+ */
795
+ export function redirectClaudeHandedOffPrompt({ payload, now = Date.now(), dir = claudeAutoHandoffDir(), env = process.env }) {
796
+ const sessionId = payload?.session_id;
797
+ if (!validSessionId(sessionId) || payload.agent_id || isAitermSeatConversation(env, sessionId)) return null;
798
+ const record = readClaudeAutoHandoff(sessionId, dir);
799
+ if (!record || !successorRemains(record)) return null;
800
+ const successor = currentSuccessor(record, dir);
801
+ const successorShortId = successor.shortId;
802
+ const kind = typeof payload.prompt === 'string' && /^\s*<task-notification>/.test(payload.prompt) ? 'task-notification' : 'prompt';
803
+ const successorSessionId = successor.sessionId ?? findSuccessorSessionId(record);
804
+ // slash command は、その会話への命令なので運ばない。ほかの会話から届いた文は、利用者の入力と分けて運ぶ。
805
+ const forwardKind = claudeForwardKind(payload.prompt);
806
+ const forwarded = forwardKind !== null && validSessionId(successorSessionId) && successorSessionId !== sessionId &&
807
+ enqueueClaudeForward({ successorSessionId, sourceSessionId: sessionId, handoffId: record.handoff_id, kind: forwardKind,
808
+ text: payload.prompt, now, dir });
809
+ recordClaudeRedirect({ at: new Date(now).toISOString(), source_session_id: sessionId, handoff_id: record.handoff_id,
810
+ successor_short_id: successorShortId, kind, forwarded }, dir);
811
+ return { output: { decision: 'block', reason: forwarded ? forwardedReason(record, dir, successorSessionId) : handedOffReason(record, dir) },
812
+ kind, handoffId: record.handoff_id, successorShortId, forwarded };
813
+ }
814
+
662
815
  /**
663
816
  * PreToolUse hook の本体。引き継ぎの記録がある会話の道具を、実行させずに止める。
664
817
  * 最初に止めた hook だけが、止めた時点の依頼と設定を記録へ写し、後継を立てる worker を起動する。
@@ -712,7 +865,7 @@ export async function stopClaudeTurnForHandoff({
712
865
  }
713
866
  }
714
867
  }
715
- return stopOutput(record);
868
+ return stopOutput(record, dir);
716
869
  }
717
870
 
718
871
  /**
@@ -777,6 +930,8 @@ export function acceptClaudeAutoContinuation({ predecessorId, successorSessionId
777
930
  if (carriesInstruction && !record.accepted_at && !existsSync(acceptancePath(predecessorId, dir))) {
778
931
  writeJsonPrivate(acceptancePath(predecessorId, dir),
779
932
  { handoff_id: record.handoff_id, successor_session_id: successorSessionId, accepted_at: now });
933
+ // 後継の置き場を作る(ADR 0063)。番の終わりの待つ hook は、このフォルダが在る会話でだけ待つ。
934
+ if (validSessionId(successorSessionId)) ensureClaudeForwardInbox(successorSessionId, dir);
780
935
  }
781
936
  return { handoffId: record.handoff_id, projectPath: record.project_path, inFlight: record.in_flight ?? null,
782
937
  transcriptPath: record.transcript_path ?? null };
@@ -22,6 +22,8 @@ import {
22
22
  findClaudeAutoContinuationSource,
23
23
  resolveClaudeAutoHandoffSuccessor,
24
24
  isAitermSeatConversation,
25
+ redirectClaudeHandedOffPrompt,
26
+ readRunningBackgroundTasks,
25
27
  } from './claude-auto-handoff.mjs';
26
28
  import { parseAutoHandoffArgs } from './cli/auto-handoff.mjs';
27
29
 
@@ -583,6 +585,7 @@ test('worker: 止めた時点の依頼とモデルを transcript から写し、
583
585
  earlier_steps: null,
584
586
  stopped_tools: [{ name: 'Read', target: '' }],
585
587
  stopped_tools_total: 1,
588
+ background: { tasks: [], total: 0 },
586
589
  turn: null,
587
590
  }, '止めた道具の直前の発言まで出そろってから写す');
588
591
 
@@ -603,6 +606,7 @@ test('worker: 止めた時点の依頼とモデルを transcript から写し、
603
606
  earlier_steps: null,
604
607
  stopped_tools: [{ name: 'Read', target: '' }],
605
608
  stopped_tools_total: 1,
609
+ background: { tasks: [], total: 0 },
606
610
  turn: null,
607
611
  });
608
612
  assert.equal(second.calls[0][1], first.calls[0][1], '引き継ぎを重ねても、project 名と印は積み重ならない');
@@ -1226,15 +1230,16 @@ function fakeClaudePath(home) {
1226
1230
  return { [pathKey]: `${bin}${delimiter}${process.env[pathKey] ?? ''}` };
1227
1231
  }
1228
1232
 
1229
- test('公開CLI: enable は2つの hook と設定を書き、disable は自分の hook だけを外す。Codex の設定には触らない', () => withDir(home => {
1233
+ test('公開CLI: enable は3つの hook と設定を書き、disable は自分の hook だけを外す。Codex の設定には触らない', () => withDir(home => {
1230
1234
  const settingsPath = join(home, '.claude', 'settings.json');
1231
1235
  mkdirSync(dirname(settingsPath), { recursive: true });
1232
1236
  // 他製品の hook と、0.13.0 が置いた PreCompact がある状態から始める
1233
1237
  const others = { PreCompact: [{ hooks: [{ type: 'command', command: 'other-product pre-compact' }] }],
1234
- PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'rtk hook claude' }] }] };
1238
+ PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'rtk hook claude' }] }],
1239
+ Stop: [{ hooks: [{ type: 'command', command: 'throughline process-turn', async: true }] }] };
1235
1240
  writeFileSync(settingsPath, JSON.stringify({ model: 'sonnet', hooks: {
1236
1241
  PreCompact: [{ hooks: [{ type: 'command', command: 'throughline pre-compact' }] }, ...others.PreCompact],
1237
- PreToolUse: others.PreToolUse } }));
1242
+ PreToolUse: others.PreToolUse, Stop: others.Stop } }));
1238
1243
 
1239
1244
  const before = JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout);
1240
1245
  assert.deepEqual(before, { host: 'claude', config: { schema: 'throughline.claude-auto-handoff.v1', enabled: false, projects: [] },
@@ -1246,22 +1251,35 @@ test('公開CLI: enable は2つの hook と設定を書き、disable は自分
1246
1251
  assert.equal(JSON.parse(enabled.stdout).status, 'enabled');
1247
1252
  const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
1248
1253
  assert.equal(settings.model, 'sonnet');
1254
+ // 今の形で登録済みの PreCompact は、並びを動かさない。足りない hook だけを後ろへ足す(ADR 0063)
1255
+ const forwardWait = { hooks: [{ type: 'command', command: 'throughline claude-forward-wait', asyncRewake: true, timeout: 86400 }] };
1249
1256
  assert.deepEqual(settings.hooks, {
1250
- PreCompact: [...others.PreCompact, { hooks: [{ type: 'command', command: 'throughline pre-compact' }] }],
1257
+ PreCompact: [{ hooks: [{ type: 'command', command: 'throughline pre-compact' }] }, ...others.PreCompact],
1251
1258
  PreToolUse: [...others.PreToolUse, { hooks: [{ type: 'command', command: 'throughline pre-tool-use', timeout: 15 }] }],
1259
+ Stop: [...others.Stop, forwardWait],
1252
1260
  });
1253
1261
  const again = cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']);
1254
1262
  assert.equal(again.status, 0, again.stderr);
1255
1263
  assert.equal(readFileSync(settingsPath, 'utf8'), JSON.stringify(settings, null, 2) + '\n', '2回目は書き換えない');
1256
1264
 
1257
1265
  assert.equal(cli(home, ['auto-handoff', 'status', '--host', 'claude']).stdout,
1258
- '自動継続: 有効(Claude Code)\nhook(PreCompact・PreToolUse): 登録済み\n');
1266
+ '自動継続: 有効(Claude Code)\nhook(PreCompact・PreToolUse・Stop): 登録済み\n');
1259
1267
  assert.equal(existsSync(join(home, '.throughline', 'codex-auto-handoff.json')), false);
1260
1268
  assert.equal(JSON.parse(cli(home, ['auto-handoff', 'status', '--json']).stdout).config.enabled, false, 'Codex は無効のまま');
1261
1269
 
1270
+ // 前の版が登録した2つだけの端末(自分の hook の後ろに他製品の hook がある)で、版を上げた時の install の形:
1271
+ // 前からある2つの並びは動かさず、Stop の待つ hook だけを足す
1272
+ const later = { hooks: [{ type: 'command', command: 'later-product pre-tool-use' }] };
1273
+ writeFileSync(settingsPath, JSON.stringify({ ...settings, hooks: { ...settings.hooks,
1274
+ PreToolUse: [...settings.hooks.PreToolUse, later], Stop: others.Stop } }, null, 2) + '\n');
1275
+ assert.equal(JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout).hooks.registered, false);
1276
+ assert.equal(cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']).status, 0);
1277
+ assert.deepEqual(JSON.parse(readFileSync(settingsPath, 'utf8')).hooks, { ...settings.hooks,
1278
+ PreToolUse: [...settings.hooks.PreToolUse, later], Stop: [...others.Stop, forwardWait] });
1279
+
1262
1280
  const disabled = JSON.parse(cli(home, ['auto-handoff', 'disable', '--host', 'claude', '--json']).stdout);
1263
1281
  assert.equal(disabled.config.enabled, false);
1264
- assert.deepEqual(JSON.parse(readFileSync(settingsPath, 'utf8')).hooks, others);
1282
+ assert.deepEqual(JSON.parse(readFileSync(settingsPath, 'utf8')).hooks, { ...others, PreToolUse: [...others.PreToolUse, later] });
1265
1283
  const unsupported = cli(home, ['auto-handoff', 'resume', '--host', 'claude', '--operation', 'x', '--json']);
1266
1284
  assert.equal(unsupported.status, 1);
1267
1285
  assert.deepEqual(JSON.parse(unsupported.stdout), { status: 'failed', code: 'auto_handoff_action_unsupported' });
@@ -1583,3 +1601,113 @@ test('hook: 自分の失敗では圧縮も道具も止めず(exit code 1)、
1583
1601
  assert.match(log, /Missing session_id in PreCompact payload/);
1584
1602
  assert.match(log, /HOOK_PRE_TOOL_USE_FAILED/);
1585
1603
  }));
1604
+
1605
+ // ---- 引き継ぎ済みの会話へ届いた入力 (ADR 0062) ----
1606
+
1607
+ test('引き継ぎ済みの会話へ届いた入力: 後継が残っている時だけ、モデルを動かさずに止めて、今の続きの場所を返す', () => withDir(async dir => {
1608
+ const projects = join(dir, 'claude', 'projects', '-work-app');
1609
+ mkdirSync(projects, { recursive: true });
1610
+ const a = 'aaaaaaaa-0000-4000-8000-000000000031', b = 'bbbbbbbb-0000-4000-8000-000000000032', c = 'cccccccc-0000-4000-8000-000000000033';
1611
+ const record = (id, fields) => writeFileSync(join(dir, `${id}.json`), JSON.stringify({ schema: 'throughline.claude-auto-handoff.v2',
1612
+ handoff_id: `h-${id.slice(0, 8)}`, source_session_id: id, project_path: '/work/app', transcript_path: join(projects, `${id}.jsonl`),
1613
+ requested_at: 1_000, updated_at: 2_000, error_code: null, ...fields }));
1614
+ const submit = (id, prompt, extra = {}, env = {}) => redirectClaudeHandedOffPrompt({ payload: { session_id: id, prompt, ...extra }, now: 5_000, dir, env });
1615
+ const redirects = () => existsSync(join(dir, 'redirects.jsonl'))
1616
+ ? readFileSync(join(dir, 'redirects.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line)) : [];
1617
+
1618
+ assert.equal(submit(a, '依頼'), null, '記録が無い会話');
1619
+ for (const state of ['requested', 'stopped', 'launching', 'failed']) {
1620
+ record(a, { state, successor: null });
1621
+ assert.equal(submit(a, '依頼'), null, `後継がまだ立っていない(${state})`);
1622
+ }
1623
+ record(a, { state: 'sent', successor: { short_id: 'bbbbbbbb', session_id: b } });
1624
+ assert.equal(submit(a, '依頼'), null, '後継の transcript が無い(後継が消された)会話は止めない');
1625
+ assert.deepEqual(redirects(), []);
1626
+
1627
+ writeFileSync(join(projects, `${b}.jsonl`), '');
1628
+ const human = submit(a, '旧い会話へ打った文');
1629
+ assert.deepEqual(human, { output: { decision: 'block',
1630
+ reason: 'この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: h-aaaaaaaa)。届いた入力は、後継の会話へ渡しました。' +
1631
+ '後継は今、入力を待ち受けていません(止まっているか、この版より前に立った会話です)。後継を開いて何か入力した時に届きます: ' +
1632
+ 'claude attach bbbbbbbb(一覧に無い時は claude --resume ' + b + ')' },
1633
+ kind: 'prompt', handoffId: 'h-aaaaaaaa', successorShortId: 'bbbbbbbb', forwarded: true });
1634
+ assert.equal(submit(a, '<task-notification>\n<task-id>b1</task-id>\n<status>completed</status>\n</task-notification>').kind, 'task-notification');
1635
+ assert.equal(submit(a, '依頼', { agent_id: 'agent-1' }), null, 'subagent の中の入力');
1636
+ assert.equal(submit('../escape', '依頼'), null);
1637
+ assert.equal(submit(a, '依頼', {}, { AITERM_AGENT_LAUNCH_ID: 'abc', AITERM_AGENT_VENDOR_SESSION_ID: a }), null, 'Aiterm の席の会話は止めない');
1638
+ assert.equal(submit(a, '依頼', {}, { AITERM_AGENT_LAUNCH_ID: 'abc', AITERM_AGENT_VENDOR_SESSION_ID: b }).kind, 'prompt', '席の印だけを継いだ会話は止める');
1639
+ assert.deepEqual(redirects(), [
1640
+ { at: '1970-01-01T00:00:05.000Z', source_session_id: a, handoff_id: 'h-aaaaaaaa', successor_short_id: 'bbbbbbbb', kind: 'prompt', forwarded: true },
1641
+ { at: '1970-01-01T00:00:05.000Z', source_session_id: a, handoff_id: 'h-aaaaaaaa', successor_short_id: 'bbbbbbbb', kind: 'task-notification', forwarded: true },
1642
+ { at: '1970-01-01T00:00:05.000Z', source_session_id: a, handoff_id: 'h-aaaaaaaa', successor_short_id: 'bbbbbbbb', kind: 'prompt', forwarded: true },
1643
+ ], '止めた記録に入力の本文は書かない');
1644
+
1645
+ // 後継がさらに引き継がれている時は、その先を示す。道具を断る時の文も同じ
1646
+ record(b, { state: 'unknown', successor: { short_id: 'cccccccc', session_id: null } });
1647
+ writeFileSync(join(projects, `${c}.jsonl`), '');
1648
+ assert.match(submit(a, '依頼').output.reason, /claude attach cccccccc(一覧に無い時は claude --resume cccccccc-0000-4000-8000-000000000033)$/);
1649
+ assert.match(submit(b, '依頼').output.reason, /引き継ぎID: h-bbbbbbbb.*claude attach cccccccc(一覧に無い時は claude --resume cccccccc-0000-4000-8000-000000000033)$/);
1650
+ const denied = await stopClaudeTurnForHandoff({ payload: { session_id: a, cwd: '/work/app' }, dir,
1651
+ launchWorker: async () => { throw new Error('must not launch'); } });
1652
+ assert.match(denied.stopReason, /引き継ぎID: h-aaaaaaaa.*claude attach cccccccc(一覧に無い時は claude --resume cccccccc-0000-4000-8000-000000000033)$/);
1653
+ // 今の続きの会話(記録が無い)は止めない
1654
+ assert.equal(submit(c, '依頼'), null);
1655
+ }));
1656
+
1657
+ test('公開CLI: prompt-submit は、引き継ぎ済みの会話の入力を止める出力を返す。ほかの会話には何も返さない', () => withDir(home => {
1658
+ const dir = join(home, '.throughline', 'claude-auto-handoff');
1659
+ const projects = join(home, '.claude', 'projects', '-work-app');
1660
+ mkdirSync(dir, { recursive: true }); mkdirSync(projects, { recursive: true });
1661
+ writeFileSync(join(projects, `${SUCCESSOR}.jsonl`), '');
1662
+ writeFileSync(join(dir, `${SESSION}.json`), JSON.stringify({ schema: 'throughline.claude-auto-handoff.v2', handoff_id: 'h-1',
1663
+ source_session_id: SESSION, project_path: '/work/app', transcript_path: join(projects, `${SESSION}.jsonl`), state: 'sent',
1664
+ requested_at: 1_000, updated_at: 2_000, error_code: null, successor: { short_id: SUCCESSOR.slice(0, 8), session_id: SUCCESSOR } }));
1665
+ const submit = (sessionId, prompt) => cli(home, ['prompt-submit'], JSON.stringify({ session_id: sessionId, cwd: home,
1666
+ hook_event_name: 'UserPromptSubmit', prompt, transcript_path: join(projects, `${sessionId}.jsonl`) }), fakeClaudePath(home));
1667
+
1668
+ const blocked = submit(SESSION, '旧い会話へ打った文');
1669
+ assert.equal(blocked.status, 0, blocked.stderr);
1670
+ assert.deepEqual(JSON.parse(blocked.stdout), { decision: 'block',
1671
+ reason: 'この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: h-1)。届いた入力は、後継の会話へ渡しました。' +
1672
+ '後継は今、入力を待ち受けていません(止まっているか、この版より前に立った会話です)。後継を開いて何か入力した時に届きます: ' +
1673
+ `claude attach ${SUCCESSOR.slice(0, 8)}(一覧に無い時は claude --resume ${SUCCESSOR})` });
1674
+ assert.equal(existsSync(join(home, '.throughline', 'throughline.db')), false, '止める時は DB を開かない');
1675
+ const logged = readFileSync(join(home, '.throughline', 'logs', 'inheritance-decision.log'), 'utf8').trim().split('\n').map(line => JSON.parse(line));
1676
+ assert.deepEqual(logged.map(row => [row.phase, row.session_id, row.handoff_id, row.successor_short_id, row.input_kind]),
1677
+ [['prompt-redirect', SESSION, 'h-1', SUCCESSOR.slice(0, 8), 'prompt']]);
1678
+ assert.equal(JSON.stringify(logged).includes('旧い会話へ打った文'), false, '判定の記録に本文は書かない');
1679
+
1680
+ // 止めた入力は後継の置き場で待ち、後継の次の入力で文脈として届く(ADR 0063)。届けた後は残らない
1681
+ const passed = submit(SUCCESSOR, '後継へ打った文');
1682
+ assert.equal(passed.status, 0, passed.stderr);
1683
+ assert.match(passed.stdout, /^【Throughline 転送】引き継ぎ前の会話(0f3a6c1e)へ届いた入力を、Throughlineがこの会話へ運びました(利用者の入力 1 通)。/);
1684
+ assert.match(passed.stdout, /\n--- 利用者の入力([^)]*利用者がこの会話へ打った入力として扱ってください)---\n旧い会話へ打った文\n$/);
1685
+ assert.equal(submit(SUCCESSOR, '後継へ打った次の文').stdout, '', '同じ入力を2回は届けない');
1686
+ assert.equal(submit('eeeeeeee-0000-4000-8000-000000000099', '関係の無い会話').stdout, '', '置き場が無い会話の入力は、今までどおり通す');
1687
+ }));
1688
+
1689
+ test('裏で動いている作業: 起こした道具から、終わった通知が届いた物を除く(user 行でも、途中の入力でも)', () => withDir(dir => {
1690
+ const transcript = join(dir, 't.jsonl');
1691
+ const started = (toolUseId, taskId, output) => ({ type: 'user', toolUseResult: { stdout: '', stderr: '', backgroundTaskId: taskId },
1692
+ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolUseId,
1693
+ content: `Command running in background with ID: ${taskId}. Output is being written to: ${output}. You will be notified when it completes.` }] } });
1694
+ const notice = (taskId, status) => `<task-notification>\n<task-id>${taskId}</task-id>\n<tool-use-id>x</tool-use-id>\n<output-file>/o</output-file>\n` +
1695
+ (status ? `<status>${status}</status>\n` : '<event>tick</event>\n') + '<summary>s</summary>\n</task-notification>';
1696
+ writeFileSync(transcript, jsonl([
1697
+ user('依頼', '2026-10-14T00:00:00.000Z', { origin: { kind: 'human' } }),
1698
+ toolUse('t1', 'Bash', { command: 'npm run build', run_in_background: true }), started('t1', 'b1', '/tmp/tasks/b1.output'),
1699
+ toolUse('t2', 'Bash', { command: 'npm test', run_in_background: true }), started('t2', 'b2', 'C:\\Users\\k\\AppData\\Local\\Temp\\claude tasks\\b2.output'),
1700
+ toolUse('t3', 'Bash', { command: 'sleep 600', run_in_background: true }), started('t3', 'b3', '/tmp/tasks/b3.output'),
1701
+ toolUse('t4', 'Monitor', { description: 'CI を見る' }), started('t4', 'b4', '/tmp/tasks/b4.output'),
1702
+ toolUse('t5', 'Read', { file_path: '/work/app/a.mjs' }), toolResult('t5', 'ok'),
1703
+ user(notice('b1', 'completed'), '2026-10-14T00:01:00.000Z', { origin: { kind: 'task-notification' } }),
1704
+ { type: 'attachment', attachment: { type: 'queued_command', prompt: notice('b3', 'failed'), origin: { kind: 'task-notification' } } },
1705
+ user(notice('b4', null), '2026-10-14T00:02:00.000Z', { origin: { kind: 'task-notification' } }),
1706
+ { ...started('t9', 'b9', '/tmp/tasks/b9.output'), isSidechain: true },
1707
+ ]));
1708
+ assert.deepEqual(readRunningBackgroundTasks(transcript, '/work/app'), { total: 2, tasks: [
1709
+ { task_id: 'b4', name: 'Monitor', target: 'CI を見る', output_file: '/tmp/tasks/b4.output' },
1710
+ { task_id: 'b2', name: 'Bash', target: 'npm test', output_file: 'C:\\Users\\k\\AppData\\Local\\Temp\\claude tasks\\b2.output' },
1711
+ ] }, '途中の知らせ(status なし)が来ただけの作業は、まだ動いている');
1712
+ assert.deepEqual(readRunningBackgroundTasks(join(dir, 'none.jsonl')), { tasks: [], total: 0 });
1713
+ }));
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Stop hook(`asyncRewake: true`)— 引き継ぎ前の会話へ届いた入力を、手すきの後継へ届ける (ADR 0063)
4
+ *
5
+ * stdin: { session_id, hook_event_name: "Stop", stop_hook_active, ... }
6
+ *
7
+ * `auto-handoff enable --host claude` が登録する。番の終わりのたびに走るので、自分の置き場のフォルダが無い会話
8
+ * (後継でない会話。ほとんどの会話)では、フォルダの有無だけを見て終わる。
9
+ *
10
+ * 置き場のフォルダが在る会話では、入力が届くまで待つ。届いたら stderr へ出して exit code 2 で終わる。Claude Code は、
11
+ * `asyncRewake` の hook が exit code 2 で終わると、stderr の文を持たせて会話を起こす。
12
+ *
13
+ * script から起動した Claude(`claude -p`、Agent SDK)では待たない。待つと、呼び出し元の命令が返らない。
14
+ *
15
+ * 待っている hook は、会話が次の番へ進んでも Claude Code に止められない(実測)。次の番の終わりに立つ新しい hook が
16
+ * 置き場に印を書き、前の hook はそれを見て退く。待っている間に次の番が始まって入力が届いた時は、前の hook が出した文を
17
+ * Claude Code が順番待ちへ入れ、次の道具の合間に届ける(実測)。
18
+ */
19
+
20
+ import { existsSync } from 'node:fs';
21
+ import { homedir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { pathToFileURL } from 'node:url';
24
+ import { setTimeout as delay } from 'node:timers/promises';
25
+
26
+ // claude-forward.mjs と同じ置き場と形。速い道を保つために、ここでは import しない。
27
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
28
+ /** 登録する hook の timeout(秒)。install.mjs の値と合わせる。自分からは、その少し手前で終わる。 */
29
+ export const CLAUDE_FORWARD_WAIT_TIMEOUT_SECONDS = 86_400;
30
+ const POLL_MS = 1000;
31
+
32
+ /**
33
+ * 待つ本体。試験から呼べる様に、終わり方を値で返す(process は終わらせない)。
34
+ *
35
+ * @returns {Promise<{code: 0|2, reason: string, text?: string, count?: number}>}
36
+ */
37
+ export async function waitForClaudeForwards({
38
+ sessionId,
39
+ dir = join(homedir(), '.throughline', 'claude-auto-handoff'),
40
+ maxWaitMs = (CLAUDE_FORWARD_WAIT_TIMEOUT_SECONDS - 300) * 1000,
41
+ pollMs = POLL_MS,
42
+ isOrphaned = () => process.platform !== 'win32' && process.ppid === 1,
43
+ sleep = delay,
44
+ now = () => Date.now(),
45
+ token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
46
+ }) {
47
+ const forward = await import('./claude-forward.mjs');
48
+ const startedAt = now();
49
+ // 前の番の終わりから待っている hook は、Claude Code が止めない。自分の印を書いて、前の hook を退かせる。
50
+ const registered = forward.registerClaudeForwardWaiter(sessionId, token, dir);
51
+ const end = (code, reason, extra = {}) => {
52
+ forward.logClaudeForward({ event: 'wait-end', session_id: sessionId, reason,
53
+ waited_s: Math.round((now() - startedAt) / 1000), ...(extra.count ? { count: extra.count } : {}) }, dir);
54
+ return { code, reason, ...extra };
55
+ };
56
+ for (;;) {
57
+ // 同じ会話で新しい待つ hook が立ったら、退く(待つのは1つだけ)。
58
+ if (registered && forward.currentClaudeForwardWaiter(sessionId, dir) !== token) return end(0, 'superseded');
59
+ // 自分の会話がさらに引き継がれて後継が立ったら、残った入力を今の続きの会話へ移して終わる。旧い会話は起こさない。
60
+ if (existsSync(join(dir, `${sessionId}.json`))) {
61
+ const { resolveClaudeAutoHandoffSuccessor } = await import('./claude-auto-handoff.mjs');
62
+ let current = null;
63
+ try {
64
+ const resolved = resolveClaudeAutoHandoffSuccessor(sessionId, { dir });
65
+ if (resolved.handed_off && resolved.current_session_id !== sessionId) current = resolved.current_session_id;
66
+ } catch { /* 記録を読めない時は、自分の会話で受け取る */ }
67
+ if (current) {
68
+ const moved = forward.moveClaudeForwards({ fromSessionId: sessionId, toSessionId: current, dir });
69
+ return end(0, 'handed-off', { count: moved });
70
+ }
71
+ }
72
+ if (forward.hasPendingClaudeForwards(sessionId, dir)) {
73
+ const claim = forward.claimClaudeForwards({ sessionId, dir });
74
+ if (claim) {
75
+ return { ...end(2, 'delivered', { count: claim.count }), text: claim.text, claim };
76
+ }
77
+ }
78
+ if (isOrphaned()) return end(0, 'orphaned');
79
+ if (now() - startedAt >= maxWaitMs) return end(0, 'deadline');
80
+ await sleep(pollMs);
81
+ }
82
+ }
83
+
84
+ export async function run() {
85
+ let raw = '';
86
+ await new Promise((resolve) => {
87
+ process.stdin.setEncoding('utf8');
88
+ process.stdin.on('data', (chunk) => {
89
+ raw += chunk;
90
+ });
91
+ process.stdin.on('end', resolve);
92
+ });
93
+
94
+ const payload = JSON.parse(raw.replace(/^/, ''));
95
+ const sessionId = payload?.session_id;
96
+ const dir = join(homedir(), '.throughline', 'claude-auto-handoff');
97
+ if (typeof sessionId !== 'string' || !SESSION_ID_PATTERN.test(sessionId) || payload.agent_id ||
98
+ !existsSync(join(dir, 'inbox', sessionId))) {
99
+ process.exit(0);
100
+ }
101
+ const entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT;
102
+ if (typeof entrypoint === 'string' && entrypoint.startsWith('sdk-')) process.exit(0);
103
+
104
+ const forward = await import('./claude-forward.mjs');
105
+ const startedAt = Date.now();
106
+ // 期限や `claude stop` で止められた時の跡(POSIX だけ。Windows は跡を残せずに止まる)。
107
+ process.on('SIGTERM', () => {
108
+ forward.logClaudeForward({ event: 'wait-end', session_id: sessionId, reason: 'sigterm',
109
+ waited_s: Math.round((Date.now() - startedAt) / 1000) }, dir);
110
+ process.exit(143);
111
+ });
112
+ forward.logClaudeForward({ event: 'wait-start', session_id: sessionId }, dir);
113
+ // 会話の process が居なくなったら終わる。居ない会話へ出力すると、入力が届かないまま消える。
114
+ const claudePid = Number(process.env.CLAUDE_PID);
115
+ const claudeGone = () => {
116
+ if (!Number.isSafeInteger(claudePid) || claudePid <= 0) return false;
117
+ try { process.kill(claudePid, 0); return false; }
118
+ catch (error) { return error.code === 'ESRCH'; }
119
+ };
120
+ const result = await waitForClaudeForwards({ sessionId, dir,
121
+ isOrphaned: () => claudeGone() || (process.platform !== 'win32' && process.ppid === 1) });
122
+ if (result.code !== 2) process.exit(0);
123
+ // 出力を書き切ってから、受け取った入力を消して終わる。
124
+ process.stderr.write(result.text, () => {
125
+ forward.settleClaudeForwards(result.claim);
126
+ forward.logClaudeForward({ event: 'delivered', via: 'stop', session_id: sessionId, count: result.claim.count,
127
+ humans: result.claim.humans, notifications: result.claim.notifications, chars: result.claim.chars }, dir);
128
+ process.exit(2);
129
+ });
130
+ }
131
+
132
+ async function reportFailure(err) {
133
+ const { recordRuntimeErrorBestEffort } = await import('./runtime-error-store.mjs');
134
+ const { logHookFailure } = await import('./hook-failure-log.mjs');
135
+ recordRuntimeErrorBestEffort('HOOK_CLAUDE_FORWARD_WAIT_FAILED');
136
+ logHookFailure('HOOK_CLAUDE_FORWARD_WAIT_FAILED', err);
137
+ }
138
+
139
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
140
+ run().catch(async (err) => {
141
+ await reportFailure(err);
142
+ process.stderr.write(`[claude-forward-wait] error: ${err.message}\n`);
143
+ process.exit(1);
144
+ });
145
+ }