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.
@@ -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 { acquireClaudeWakeClaim, claudeForwardKind, claudeForwardWaiterAlive, enqueueClaudeForward, ensureClaudeForwardInbox, holdClaudeForward, logClaudeForward, 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
  /**
@@ -727,6 +737,204 @@ function handedOffReason(record, dir) {
727
737
  (sessionId ? `(一覧に無い時は claude --resume ${sessionId})` : '');
728
738
  }
729
739
 
740
+ /**
741
+ * 引き継ぎ済みの会話へ届いた入力を、後継の置き場へ置けた時に見せる文 (ADR 0063)。
742
+ * 後継で待つ hook が生きていれば、後継は動いている。生きていなければ、後継は止まっているか、待つ hook が登録される前に
743
+ * 立った会話で、入力は後継が次に動く時まで置き場で待つ。どちらかを、打った人へ返す。
744
+ */
745
+ function openSuccessorCommand(record, dir) {
746
+ const { shortId, sessionId } = currentSuccessor(record, dir);
747
+ return `claude attach ${shortId}` + (sessionId ? `(一覧に無い時は claude --resume ${sessionId})` : '');
748
+ }
749
+
750
+ function forwardedReason(record, dir, successorSessionId, { running = false, waking = false } = {}) {
751
+ const open = openSuccessorCommand(record, dir);
752
+ return `この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: ${record.handoff_id})。` +
753
+ '届いた入力は、後継の会話へ渡しました。' +
754
+ (waking
755
+ ? '後継は、先に届いた入力で起きている所です。起きた後継が、次の道具の前か番の終わりに読みます。' +
756
+ `後継のモデルが、運ばれた入力を利用者の依頼として扱わない事があります。答えは後継で確かめてください: ${open}`
757
+ : running || claudeForwardWaiterAlive(successorSessionId, dir)
758
+ ? '後継は動いています。手すきならすぐ、作業中なら次の道具の前か番の終わりに届きます。' +
759
+ `後継のモデルが、運ばれた入力を利用者の依頼として扱わない事があります。答えは後継で確かめてください: ${open}`
760
+ : `後継は今、入力を待ち受けていません(止まっているか、この版より前に立った会話です)。後継を開いて何か入力した時に届きます: ${open}`);
761
+ }
762
+
763
+ /** 止まっていた後継を起こして、入力を最初の入力として渡せた時に見せる文 (ADR 0064)。 */
764
+ function wokenReason(record, dir) {
765
+ return `この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: ${record.handoff_id})。` +
766
+ '後継の会話が止まっていたので、Throughlineが起こして、この入力を後継の最初の入力として渡しました。' +
767
+ `後継は裏で作業を続けます: ${openSuccessorCommand(record, dir)}`;
768
+ }
769
+
770
+ /** 起こす命令を流したが、起きたか確かめられなかった時に見せる文 (ADR 0064)。入力は入れ直さない。 */
771
+ function wakeUnknownReason(record, dir) {
772
+ return `この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: ${record.handoff_id})。` +
773
+ '後継の会話が止まっていたので起こそうとしましたが、起きて入力を受け取ったか確かめられませんでした。' +
774
+ `二重に届かないよう、この入力は入れ直していません。後継を開いて確かめてください: ${openSuccessorCommand(record, dir)}`;
775
+ }
776
+
777
+ // ---- 止まっている後継を、人が旧い会話へ打った文で起こす (ADR 0064) ----
778
+
779
+ // 起こす命令で渡す文の上限(UTF-16 の長さ)。Windows の命令行は全体で 32,767 字まで。引用で増える分を見込む。
780
+ export const CLAUDE_WAKE_TEXT_MAX_CHARS = 12_000;
781
+ // 起こしてよい、裏の会話の state(一覧の項目に `pid` も `status` も無い時だけ)。手すきで止めた・片付けられた会話は
782
+ // `done`、作業の途中で止めた会話は `stopped`。立ち上げ中の会話は `working` で、`pid` がまだ無い事がある。
783
+ const STOPPED_BACKGROUND_STATES = new Set(['done', 'stopped']);
784
+ // 後継の transcript がこれより新しく書かれていたら、動いていると見なして起こさない。
785
+ const RECENTLY_WRITTEN_MS = 5_000;
786
+ // 止まっていない会話へ起こす命令を流すと、Claude Code は写しを立てて、こう書く(2.1.296)。
787
+ const WAKE_COPY_PATTERN = /started a copy as ([0-9a-f]{8})\b/;
788
+ // 起こす仕組みを止める上書き(`off`)。
789
+ const WAKE_OVERRIDE_ENV = 'THROUGHLINE_AUTO_HANDOFF_WAKE';
790
+
791
+ // 起こす命令に渡さない、旧い会話の環境変数(hook は旧い会話の中で走る)。後継は、保存された自分の設定で起きる。
792
+ const WAKE_STRIPPED_ENV = ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_PID', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ATTENDED',
793
+ 'CLAUDE_EFFORT', 'CLAUDE_CODE_EXECPATH'];
794
+
795
+ function wakeCommandEnv(env) {
796
+ const next = successorEnv(env);
797
+ for (const key of WAKE_STRIPPED_ENV) delete next[key];
798
+ return next;
799
+ }
800
+
801
+ /** 起こす命令で渡せる文か。長すぎる文と、命令の引数に入らない文字(NUL)を含む文は渡さない。 */
802
+ export function claudeWakeTextAllowed(text) {
803
+ return typeof text === 'string' && text.trim() !== '' && text.length <= CLAUDE_WAKE_TEXT_MAX_CHARS && !text.includes('\0');
804
+ }
805
+
806
+ /**
807
+ * `claude agents --json --all` で、その会話が止まっていると確かめられるか。
808
+ * 同じ会話 ID の項目が1つでも `pid` か `status` を持てば動いている(裏の会話を端末で開いた時と、`claude -p --resume` が
809
+ * 走っている時は、裏の項目とは別に `interactive` の項目が出る)。裏の項目が無い・一覧を読めない時は、確かめられない。
810
+ *
811
+ * @returns {{stopped: boolean, reason: string}}
812
+ */
813
+ export function readClaudeSessionStopped(sessionId, { spawn = spawnPortableSync, env = process.env } = {}) {
814
+ const listed = spawn('claude', ['agents', '--json', '--all'], { env, encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] });
815
+ if (listed.error || listed.status !== 0) return { stopped: false, reason: 'list_failed' };
816
+ let entries;
817
+ try { entries = JSON.parse(`${listed.stdout ?? ''}`); }
818
+ catch { return { stopped: false, reason: 'list_failed' }; }
819
+ if (!Array.isArray(entries)) return { stopped: false, reason: 'list_failed' };
820
+ const same = entries.filter(entry => entry?.sessionId === sessionId);
821
+ if (!same.length) return { stopped: false, reason: 'not_listed' };
822
+ if (same.some(entry => entry.pid || Object.hasOwn(entry, 'status'))) return { stopped: false, reason: 'running' };
823
+ const background = same.find(entry => entry.kind === 'background');
824
+ if (!background) return { stopped: false, reason: 'not_background' };
825
+ if (!STOPPED_BACKGROUND_STATES.has(background.state)) return { stopped: false, reason: 'not_stopped' };
826
+ return { stopped: true, reason: 'stopped' };
827
+ }
828
+
829
+ /** 後継の transcript の場所(旧い会話の transcript と同じ projects の下を探す)。見つからなければ null。 */
830
+ function findSessionTranscript(record, sessionId) {
831
+ if (typeof record?.transcript_path !== 'string' || !validSessionId(sessionId)) return null;
832
+ const own = dirname(record.transcript_path);
833
+ const direct = join(own, `${sessionId}.jsonl`);
834
+ if (existsSync(direct)) return direct;
835
+ let folders;
836
+ try { folders = readdirSync(dirname(own), { withFileTypes: true }); }
837
+ catch { return null; }
838
+ for (const entry of folders) {
839
+ const path = entry.isDirectory() ? join(dirname(own), entry.name, `${sessionId}.jsonl`) : null;
840
+ if (path && existsSync(path)) return path;
841
+ }
842
+ return null;
843
+ }
844
+
845
+ /**
846
+ * 起こす命令を流す。option は付けない。裏の会話は、立てた時の option(モデル・effort・権限の mode・名前・リモート
847
+ * コントロール)を持っていて、option なしの時だけ、同じ会話のままそれで起きる。option を付けると、止まっている
848
+ * 会話でも写しが立つ。文は `--` の後に置く(頭が `-` の文を option として読ませない)。
849
+ *
850
+ * @returns {{outcome: 'woken'|'copy'|'failed'|'unknown', copyShortId?: string}}
851
+ */
852
+ function runClaudeWake({ sessionId, text, cwd, env, spawn }) {
853
+ const result = spawn('claude', ['--bg', '--resume', sessionId, '--', text], {
854
+ cwd, env, encoding: 'utf8', timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'],
855
+ });
856
+ if (result.error) {
857
+ // 立ち上がる前の失敗(命令が無い・実行できない)は届いていない。時間切れは、命令が途中まで進んだかもしれない。
858
+ return { outcome: result.error.code === 'ETIMEDOUT' ? 'unknown' : 'failed' };
859
+ }
860
+ const out = normalizeTerminalText(`${result.stdout ?? ''}${result.stderr ?? ''}`);
861
+ const copy = WAKE_COPY_PATTERN.exec(out)?.[1];
862
+ if (copy) return { outcome: 'copy', copyShortId: copy };
863
+ const shortId = BACKGROUNDED_PATTERN.exec(out)?.[1];
864
+ if (result.status === 0 && shortId === sessionId.slice(0, 8)) return { outcome: 'woken' };
865
+ if (result.status === 0 && shortId) return { outcome: 'copy', copyShortId: shortId };
866
+ if (result.status !== 0 && !shortId) return { outcome: 'failed' };
867
+ return { outcome: 'unknown' };
868
+ }
869
+
870
+ /**
871
+ * 後継が止まっていて、人が旧い会話へ文を打った時に、後継を起こしてその文を最初の入力として渡す (ADR 0064)。
872
+ * 利用者の決め(K-9UHCLQ、2026-10-14)は「人が打った文の時だけ起こす。完了通知では起こさない」。
873
+ *
874
+ * 止まっていると確かめられない時は何もしない(`skipped`。入力は呼んだ側が置き場へ置く)。写しが立った時は、写しを
875
+ * 止めて一覧から消し、入力を置き場へ戻す(`queued`)。起こす命令が失敗した時も置き場へ戻す。起きたか分からない時は
876
+ * 入れ直さない(`unknown`)。
877
+ *
878
+ * @returns {{outcome: 'woken'|'queued'|'unknown'|'skipped', reason: string, running?: boolean, waking?: boolean}}
879
+ */
880
+ export function wakeStoppedClaudeSuccessor({ record, successorSessionId, sourceSessionId, text, now = Date.now(),
881
+ dir = claudeAutoHandoffDir(), env = process.env, spawn = spawnPortableSync }) {
882
+ const skip = (reason, extra = {}) => {
883
+ logClaudeForward({ event: 'wake-skipped', reason, session_id: successorSessionId }, dir);
884
+ return { outcome: 'skipped', reason, ...extra };
885
+ };
886
+ if (`${env[WAKE_OVERRIDE_ENV] ?? ''}`.trim().toLowerCase() === 'off') return { outcome: 'skipped', reason: 'disabled' };
887
+ if (claudeForwardWaiterAlive(successorSessionId, dir)) return { outcome: 'skipped', reason: 'waiter_alive', running: true };
888
+ if (!claudeWakeTextAllowed(text)) return skip('text_not_allowed');
889
+ // Claude Desktop へ移した後継は、Desktop が開いている。裏で起こすと、同じ会話を2か所で動かす事になる。
890
+ if (record.desktop?.state === 'opened') return skip('desktop');
891
+ try {
892
+ if (resolveClaudeAutoHandoffSuccessor(successorSessionId, { dir }).pending) return skip('handoff_pending');
893
+ } catch { return skip('handoff_pending'); }
894
+ const transcript = findSessionTranscript(record, successorSessionId);
895
+ if (!transcript) return skip('transcript_missing'); // 無い会話 ID へ流すと、その ID で新しい会話が立つ
896
+ try {
897
+ if (Date.now() - statSync(transcript).mtimeMs < RECENTLY_WRITTEN_MS) return skip('recently_written', { running: true });
898
+ } catch { return skip('transcript_missing'); }
899
+ const release = acquireClaudeWakeClaim(successorSessionId, { dir, now });
900
+ if (!release) return skip('waking', { waking: true });
901
+ const wakeEnv = wakeCommandEnv(env);
902
+ const state = readClaudeSessionStopped(successorSessionId, { spawn, env: wakeEnv });
903
+ if (!state.stopped) {
904
+ release();
905
+ return skip(state.reason, { running: state.reason === 'running' });
906
+ }
907
+ const held = holdClaudeForward({ successorSessionId, sourceSessionId, handoffId: record.handoff_id, kind: 'prompt', text, now, dir });
908
+ if (!held) { release(); return skip('store_failed'); }
909
+ const cwd = typeof record.project_path === 'string' && existsSync(record.project_path) ? record.project_path : undefined;
910
+ const woke = runClaudeWake({ sessionId: successorSessionId, text, cwd, env: wakeEnv, spawn });
911
+ const base = { session_id: successorSessionId, source_session_id: sourceSessionId, chars: text.length };
912
+ if (woke.outcome === 'woken') {
913
+ held.settle();
914
+ logClaudeForward({ event: 'woken', ...base }, dir);
915
+ return { outcome: 'woken', reason: 'woken' }; // 番の印は残す(期限で切れる)
916
+ }
917
+ if (woke.outcome === 'copy') {
918
+ // 写しは、渡した文で動き出す。後継と同じ作業を2か所でしないよう、すぐ止めて一覧から消す。入力は置き場へ戻す
919
+ // (後継は動いているので、後継の hook が受け取る)。
920
+ for (const verb of ['stop', 'rm']) {
921
+ spawn('claude', [verb, woke.copyShortId], { env: wakeEnv, encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] });
922
+ }
923
+ const queued = held.release();
924
+ release();
925
+ logClaudeForward({ event: 'wake-copy', ...base, copy_short_id: woke.copyShortId, queued }, dir);
926
+ return queued ? { outcome: 'queued', reason: 'copy', running: true } : { outcome: 'unknown', reason: 'copy' };
927
+ }
928
+ if (woke.outcome === 'failed') {
929
+ const queued = held.release();
930
+ release();
931
+ logClaudeForward({ event: 'wake-failed', ...base, queued }, dir);
932
+ return queued ? { outcome: 'queued', reason: 'failed' } : { outcome: 'unknown', reason: 'failed' };
933
+ }
934
+ logClaudeForward({ event: 'wake-unknown', ...base }, dir);
935
+ return { outcome: 'unknown', reason: 'unknown' };
936
+ }
937
+
730
938
  function stopOutput(record, dir) {
731
939
  const reason = record.successor?.short_id
732
940
  ? handedOffReason(record, dir)
@@ -761,18 +969,39 @@ function recordClaudeRedirect(entry, dir) {
761
969
  * 何もしない(null)。Aiterm が起こした席の会話(ADR 0057)も止めない。席を外す前の版が引き継いだ席が残っていても、
762
970
  * 呼び手は席の番の終わりを待っている。止めると番が始まらず、呼び手は答えを受け取れない。
763
971
  *
764
- * @returns {{output: {decision: 'block', reason: string}, kind: 'prompt'|'task-notification', handoffId: string, successorShortId: string|null}|null}
972
+ * 止めた入力は、今の続きの会話の置き場へ置く (ADR 0063)。後継の hook が受け取って、モデルへ渡す。後継の会話 ID を
973
+ * 引けない時、置き場へ書けなかった時、入力が slash command の時は、置かずに「後継で打ち直す」文を返す。
974
+ *
975
+ * @returns {{output: {decision: 'block', reason: string}, kind: 'prompt'|'task-notification', handoffId: string, successorShortId: string|null, forwarded: boolean}|null}
765
976
  */
766
- export function redirectClaudeHandedOffPrompt({ payload, now = Date.now(), dir = claudeAutoHandoffDir(), env = process.env }) {
977
+ export function redirectClaudeHandedOffPrompt({ payload, now = Date.now(), dir = claudeAutoHandoffDir(), env = process.env, spawn = spawnPortableSync }) {
767
978
  const sessionId = payload?.session_id;
768
979
  if (!validSessionId(sessionId) || payload.agent_id || isAitermSeatConversation(env, sessionId)) return null;
769
980
  const record = readClaudeAutoHandoff(sessionId, dir);
770
981
  if (!record || !successorRemains(record)) return null;
771
- const successorShortId = currentSuccessor(record, dir).shortId;
982
+ const successor = currentSuccessor(record, dir);
983
+ const successorShortId = successor.shortId;
772
984
  const kind = typeof payload.prompt === 'string' && /^\s*<task-notification>/.test(payload.prompt) ? 'task-notification' : 'prompt';
985
+ const successorSessionId = successor.sessionId ?? findSuccessorSessionId(record);
986
+ // slash command は、その会話への命令なので運ばない。ほかの会話から届いた文は、利用者の入力と分けて運ぶ。
987
+ const forwardKind = claudeForwardKind(payload.prompt);
988
+ const canForward = forwardKind !== null && validSessionId(successorSessionId) && successorSessionId !== sessionId;
989
+ // 人が打った文で、後継が止まっていれば、後継を起こしてその文を渡す (ADR 0064)。完了通知とほかの会話からの文では起こさない。
990
+ const wake = canForward && forwardKind === 'prompt'
991
+ ? wakeStoppedClaudeSuccessor({ record, successorSessionId, sourceSessionId: sessionId, text: payload.prompt, now, dir, env, spawn })
992
+ : null;
993
+ const forwarded = canForward && (wake?.outcome === 'woken' || wake?.outcome === 'queued' ||
994
+ (wake?.outcome !== 'unknown' && enqueueClaudeForward({ successorSessionId, sourceSessionId: sessionId, handoffId: record.handoff_id,
995
+ kind: forwardKind, text: payload.prompt, now, dir })));
773
996
  recordClaudeRedirect({ at: new Date(now).toISOString(), source_session_id: sessionId, handoff_id: record.handoff_id,
774
- successor_short_id: successorShortId, kind }, dir);
775
- return { output: { decision: 'block', reason: handedOffReason(record, dir) }, kind, handoffId: record.handoff_id, successorShortId };
997
+ successor_short_id: successorShortId, kind, forwarded,
998
+ ...(wake && wake.reason !== 'disabled' ? { wake: wake.outcome === 'skipped' ? `skipped:${wake.reason}` : wake.outcome } : {}) }, dir);
999
+ const reason = wake?.outcome === 'woken' ? wokenReason(record, dir)
1000
+ : wake?.outcome === 'unknown' ? wakeUnknownReason(record, dir)
1001
+ : forwarded ? forwardedReason(record, dir, successorSessionId, { running: Boolean(wake?.running), waking: Boolean(wake?.waking) })
1002
+ : handedOffReason(record, dir);
1003
+ return { output: { decision: 'block', reason }, kind, handoffId: record.handoff_id, successorShortId, forwarded,
1004
+ ...(wake ? { wake: wake.outcome } : {}) };
776
1005
  }
777
1006
 
778
1007
  /**
@@ -893,6 +1122,8 @@ export function acceptClaudeAutoContinuation({ predecessorId, successorSessionId
893
1122
  if (carriesInstruction && !record.accepted_at && !existsSync(acceptancePath(predecessorId, dir))) {
894
1123
  writeJsonPrivate(acceptancePath(predecessorId, dir),
895
1124
  { handoff_id: record.handoff_id, successor_session_id: successorSessionId, accepted_at: now });
1125
+ // 後継の置き場を作る(ADR 0063)。番の終わりの待つ hook は、このフォルダが在る会話でだけ待つ。
1126
+ if (validSessionId(successorSessionId)) ensureClaudeForwardInbox(successorSessionId, dir);
896
1127
  }
897
1128
  return { handoffId: record.handoff_id, projectPath: record.project_path, inFlight: record.in_flight ?? null,
898
1129
  transcriptPath: record.transcript_path ?? null };
@@ -1230,15 +1230,16 @@ function fakeClaudePath(home) {
1230
1230
  return { [pathKey]: `${bin}${delimiter}${process.env[pathKey] ?? ''}` };
1231
1231
  }
1232
1232
 
1233
- test('公開CLI: enable は2つの hook と設定を書き、disable は自分の hook だけを外す。Codex の設定には触らない', () => withDir(home => {
1233
+ test('公開CLI: enable は3つの hook と設定を書き、disable は自分の hook だけを外す。Codex の設定には触らない', () => withDir(home => {
1234
1234
  const settingsPath = join(home, '.claude', 'settings.json');
1235
1235
  mkdirSync(dirname(settingsPath), { recursive: true });
1236
1236
  // 他製品の hook と、0.13.0 が置いた PreCompact がある状態から始める
1237
1237
  const others = { PreCompact: [{ hooks: [{ type: 'command', command: 'other-product pre-compact' }] }],
1238
- 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 }] }] };
1239
1240
  writeFileSync(settingsPath, JSON.stringify({ model: 'sonnet', hooks: {
1240
1241
  PreCompact: [{ hooks: [{ type: 'command', command: 'throughline pre-compact' }] }, ...others.PreCompact],
1241
- PreToolUse: others.PreToolUse } }));
1242
+ PreToolUse: others.PreToolUse, Stop: others.Stop } }));
1242
1243
 
1243
1244
  const before = JSON.parse(cli(home, ['auto-handoff', 'status', '--host', 'claude', '--json']).stdout);
1244
1245
  assert.deepEqual(before, { host: 'claude', config: { schema: 'throughline.claude-auto-handoff.v1', enabled: false, projects: [] },
@@ -1250,22 +1251,35 @@ test('公開CLI: enable は2つの hook と設定を書き、disable は自分
1250
1251
  assert.equal(JSON.parse(enabled.stdout).status, 'enabled');
1251
1252
  const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
1252
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 }] };
1253
1256
  assert.deepEqual(settings.hooks, {
1254
- PreCompact: [...others.PreCompact, { hooks: [{ type: 'command', command: 'throughline pre-compact' }] }],
1257
+ PreCompact: [{ hooks: [{ type: 'command', command: 'throughline pre-compact' }] }, ...others.PreCompact],
1255
1258
  PreToolUse: [...others.PreToolUse, { hooks: [{ type: 'command', command: 'throughline pre-tool-use', timeout: 15 }] }],
1259
+ Stop: [...others.Stop, forwardWait],
1256
1260
  });
1257
1261
  const again = cli(home, ['auto-handoff', 'enable', '--host', 'claude', '--json']);
1258
1262
  assert.equal(again.status, 0, again.stderr);
1259
1263
  assert.equal(readFileSync(settingsPath, 'utf8'), JSON.stringify(settings, null, 2) + '\n', '2回目は書き換えない');
1260
1264
 
1261
1265
  assert.equal(cli(home, ['auto-handoff', 'status', '--host', 'claude']).stdout,
1262
- '自動継続: 有効(Claude Code)\nhook(PreCompact・PreToolUse): 登録済み\n');
1266
+ '自動継続: 有効(Claude Code)\nhook(PreCompact・PreToolUse・Stop): 登録済み\n');
1263
1267
  assert.equal(existsSync(join(home, '.throughline', 'codex-auto-handoff.json')), false);
1264
1268
  assert.equal(JSON.parse(cli(home, ['auto-handoff', 'status', '--json']).stdout).config.enabled, false, 'Codex は無効のまま');
1265
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
+
1266
1280
  const disabled = JSON.parse(cli(home, ['auto-handoff', 'disable', '--host', 'claude', '--json']).stdout);
1267
1281
  assert.equal(disabled.config.enabled, false);
1268
- assert.deepEqual(JSON.parse(readFileSync(settingsPath, 'utf8')).hooks, others);
1282
+ assert.deepEqual(JSON.parse(readFileSync(settingsPath, 'utf8')).hooks, { ...others, PreToolUse: [...others.PreToolUse, later] });
1269
1283
  const unsupported = cli(home, ['auto-handoff', 'resume', '--host', 'claude', '--operation', 'x', '--json']);
1270
1284
  assert.equal(unsupported.status, 1);
1271
1285
  assert.deepEqual(JSON.parse(unsupported.stdout), { status: 'failed', code: 'auto_handoff_action_unsupported' });
@@ -1597,7 +1611,9 @@ test('引き継ぎ済みの会話へ届いた入力: 後継が残っている時
1597
1611
  const record = (id, fields) => writeFileSync(join(dir, `${id}.json`), JSON.stringify({ schema: 'throughline.claude-auto-handoff.v2',
1598
1612
  handoff_id: `h-${id.slice(0, 8)}`, source_session_id: id, project_path: '/work/app', transcript_path: join(projects, `${id}.jsonl`),
1599
1613
  requested_at: 1_000, updated_at: 2_000, error_code: null, ...fields }));
1600
- const submit = (id, prompt, extra = {}, env = {}) => redirectClaudeHandedOffPrompt({ payload: { session_id: id, prompt, ...extra }, now: 5_000, dir, env });
1614
+ const noClaude = () => ({ error: Object.assign(new Error('spawn claude ENOENT'), { code: 'ENOENT' }) });
1615
+ const submit = (id, prompt, extra = {}, env = {}) => redirectClaudeHandedOffPrompt({ payload: { session_id: id, prompt, ...extra }, now: 5_000, dir, env, spawn: noClaude });
1616
+ const settled = path => { const past = new Date(Date.now() - 60_000); utimesSync(path, past, past); };
1601
1617
  const redirects = () => existsSync(join(dir, 'redirects.jsonl'))
1602
1618
  ? readFileSync(join(dir, 'redirects.jsonl'), 'utf8').trim().split('\n').map(line => JSON.parse(line)) : [];
1603
1619
 
@@ -1611,24 +1627,28 @@ test('引き継ぎ済みの会話へ届いた入力: 後継が残っている時
1611
1627
  assert.deepEqual(redirects(), []);
1612
1628
 
1613
1629
  writeFileSync(join(projects, `${b}.jsonl`), '');
1630
+ settled(join(projects, `${b}.jsonl`));
1614
1631
  const human = submit(a, '旧い会話へ打った文');
1615
1632
  assert.deepEqual(human, { output: { decision: 'block',
1616
- reason: 'この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: h-aaaaaaaa)。続きは後継の会話で行ってください: claude attach bbbbbbbb(一覧に無い時は claude --resume ' + b + ')' },
1617
- kind: 'prompt', handoffId: 'h-aaaaaaaa', successorShortId: 'bbbbbbbb' });
1633
+ reason: 'この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: h-aaaaaaaa)。届いた入力は、後継の会話へ渡しました。' +
1634
+ '後継は今、入力を待ち受けていません(止まっているか、この版より前に立った会話です)。後継を開いて何か入力した時に届きます: ' +
1635
+ 'claude attach bbbbbbbb(一覧に無い時は claude --resume ' + b + ')' },
1636
+ kind: 'prompt', handoffId: 'h-aaaaaaaa', successorShortId: 'bbbbbbbb', forwarded: true, wake: 'skipped' });
1618
1637
  assert.equal(submit(a, '<task-notification>\n<task-id>b1</task-id>\n<status>completed</status>\n</task-notification>').kind, 'task-notification');
1619
1638
  assert.equal(submit(a, '依頼', { agent_id: 'agent-1' }), null, 'subagent の中の入力');
1620
1639
  assert.equal(submit('../escape', '依頼'), null);
1621
1640
  assert.equal(submit(a, '依頼', {}, { AITERM_AGENT_LAUNCH_ID: 'abc', AITERM_AGENT_VENDOR_SESSION_ID: a }), null, 'Aiterm の席の会話は止めない');
1622
1641
  assert.equal(submit(a, '依頼', {}, { AITERM_AGENT_LAUNCH_ID: 'abc', AITERM_AGENT_VENDOR_SESSION_ID: b }).kind, 'prompt', '席の印だけを継いだ会話は止める');
1623
1642
  assert.deepEqual(redirects(), [
1624
- { at: '1970-01-01T00:00:05.000Z', source_session_id: a, handoff_id: 'h-aaaaaaaa', successor_short_id: 'bbbbbbbb', kind: 'prompt' },
1625
- { at: '1970-01-01T00:00:05.000Z', source_session_id: a, handoff_id: 'h-aaaaaaaa', successor_short_id: 'bbbbbbbb', kind: 'task-notification' },
1626
- { at: '1970-01-01T00:00:05.000Z', source_session_id: a, handoff_id: 'h-aaaaaaaa', successor_short_id: 'bbbbbbbb', kind: 'prompt' },
1643
+ { at: '1970-01-01T00:00:05.000Z', source_session_id: a, handoff_id: 'h-aaaaaaaa', successor_short_id: 'bbbbbbbb', kind: 'prompt', forwarded: true, wake: 'skipped:list_failed' },
1644
+ { at: '1970-01-01T00:00:05.000Z', source_session_id: a, handoff_id: 'h-aaaaaaaa', successor_short_id: 'bbbbbbbb', kind: 'task-notification', forwarded: true },
1645
+ { at: '1970-01-01T00:00:05.000Z', source_session_id: a, handoff_id: 'h-aaaaaaaa', successor_short_id: 'bbbbbbbb', kind: 'prompt', forwarded: true, wake: 'skipped:list_failed' },
1627
1646
  ], '止めた記録に入力の本文は書かない');
1628
1647
 
1629
1648
  // 後継がさらに引き継がれている時は、その先を示す。道具を断る時の文も同じ
1630
1649
  record(b, { state: 'unknown', successor: { short_id: 'cccccccc', session_id: null } });
1631
1650
  writeFileSync(join(projects, `${c}.jsonl`), '');
1651
+ settled(join(projects, `${c}.jsonl`));
1632
1652
  assert.match(submit(a, '依頼').output.reason, /claude attach cccccccc(一覧に無い時は claude --resume cccccccc-0000-4000-8000-000000000033)$/);
1633
1653
  assert.match(submit(b, '依頼').output.reason, /引き継ぎID: h-bbbbbbbb.*claude attach cccccccc(一覧に無い時は claude --resume cccccccc-0000-4000-8000-000000000033)$/);
1634
1654
  const denied = await stopClaudeTurnForHandoff({ payload: { session_id: a, cwd: '/work/app' }, dir,
@@ -1643,6 +1663,7 @@ test('公開CLI: prompt-submit は、引き継ぎ済みの会話の入力を止
1643
1663
  const projects = join(home, '.claude', 'projects', '-work-app');
1644
1664
  mkdirSync(dir, { recursive: true }); mkdirSync(projects, { recursive: true });
1645
1665
  writeFileSync(join(projects, `${SUCCESSOR}.jsonl`), '');
1666
+ utimesSync(join(projects, `${SUCCESSOR}.jsonl`), new Date(Date.now() - 60_000), new Date(Date.now() - 60_000));
1646
1667
  writeFileSync(join(dir, `${SESSION}.json`), JSON.stringify({ schema: 'throughline.claude-auto-handoff.v2', handoff_id: 'h-1',
1647
1668
  source_session_id: SESSION, project_path: '/work/app', transcript_path: join(projects, `${SESSION}.jsonl`), state: 'sent',
1648
1669
  requested_at: 1_000, updated_at: 2_000, error_code: null, successor: { short_id: SUCCESSOR.slice(0, 8), session_id: SUCCESSOR } }));
@@ -1652,16 +1673,22 @@ test('公開CLI: prompt-submit は、引き継ぎ済みの会話の入力を止
1652
1673
  const blocked = submit(SESSION, '旧い会話へ打った文');
1653
1674
  assert.equal(blocked.status, 0, blocked.stderr);
1654
1675
  assert.deepEqual(JSON.parse(blocked.stdout), { decision: 'block',
1655
- reason: `この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: h-1)。続きは後継の会話で行ってください: claude attach ${SUCCESSOR.slice(0, 8)}(一覧に無い時は claude --resume ${SUCCESSOR})` });
1676
+ reason: 'この会話はThroughlineが新しい会話へ引き継ぎ済みです(引き継ぎID: h-1)。届いた入力は、後継の会話へ渡しました。' +
1677
+ '後継は今、入力を待ち受けていません(止まっているか、この版より前に立った会話です)。後継を開いて何か入力した時に届きます: ' +
1678
+ `claude attach ${SUCCESSOR.slice(0, 8)}(一覧に無い時は claude --resume ${SUCCESSOR})` });
1656
1679
  assert.equal(existsSync(join(home, '.throughline', 'throughline.db')), false, '止める時は DB を開かない');
1657
1680
  const logged = readFileSync(join(home, '.throughline', 'logs', 'inheritance-decision.log'), 'utf8').trim().split('\n').map(line => JSON.parse(line));
1658
1681
  assert.deepEqual(logged.map(row => [row.phase, row.session_id, row.handoff_id, row.successor_short_id, row.input_kind]),
1659
1682
  [['prompt-redirect', SESSION, 'h-1', SUCCESSOR.slice(0, 8), 'prompt']]);
1660
1683
  assert.equal(JSON.stringify(logged).includes('旧い会話へ打った文'), false, '判定の記録に本文は書かない');
1661
1684
 
1685
+ // 止めた入力は後継の置き場で待ち、後継の次の入力で文脈として届く(ADR 0063)。届けた後は残らない
1662
1686
  const passed = submit(SUCCESSOR, '後継へ打った文');
1663
1687
  assert.equal(passed.status, 0, passed.stderr);
1664
- assert.equal(passed.stdout, '', '記録が無い会話の入力は、今までどおり通す');
1688
+ assert.match(passed.stdout, /^【Throughline 転送】引き継ぎ前の会話(0f3a6c1e)へ届いた入力を、Throughlineがこの会話へ運びました(利用者の入力 1 通)。/);
1689
+ assert.match(passed.stdout, /\n--- 利用者の入力([^)]*利用者がこの会話へ打った入力として扱ってください)---\n旧い会話へ打った文\n$/);
1690
+ assert.equal(submit(SUCCESSOR, '後継へ打った次の文').stdout, '', '同じ入力を2回は届けない');
1691
+ assert.equal(submit('eeeeeeee-0000-4000-8000-000000000099', '関係の無い会話').stdout, '', '置き場が無い会話の入力は、今までどおり通す');
1665
1692
  }));
1666
1693
 
1667
1694
  test('裏で動いている作業: 起こした道具から、終わった通知が届いた物を除く(user 行でも、途中の入力でも)', () => withDir(dir => {
@@ -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
+ }