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.
- package/CHANGELOG.md +49 -1
- package/README.ja.md +4 -2
- package/README.md +4 -2
- package/bin/throughline.mjs +5 -0
- package/docs/adr/0063-claude-handed-off-input-is-forwarded-to-successor.md +156 -0
- package/docs/adr/0064-claude-stopped-successor-is-woken-by-human-input.md +113 -0
- package/package.json +1 -1
- package/src/claude-auto-handoff.mjs +237 -6
- package/src/claude-auto-handoff.test.mjs +41 -14
- package/src/claude-forward-wait.mjs +145 -0
- package/src/claude-forward.mjs +382 -0
- package/src/claude-forward.test.mjs +536 -0
- package/src/claude-pre-tool-use.mjs +33 -15
- package/src/cli/auto-handoff.mjs +2 -2
- package/src/cli/install.mjs +34 -16
- package/src/prompt-submit.mjs +17 -0
- package/src/resume-context.mjs +8 -2
- package/src/resume-context.test.mjs +3 -1
- package/src/runtime-error-store.mjs +6 -0
- package/src/transcript-reader.mjs +22 -2
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
775
|
-
|
|
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 は
|
|
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: [
|
|
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
|
|
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
|
|
1617
|
-
|
|
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:
|
|
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.
|
|
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
|
+
}
|