@tyhld/conductor 0.3.0

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/dist/relay.js ADDED
@@ -0,0 +1,1315 @@
1
+ /**
2
+ * @tyhld/conductor — ADR-029 relay 段階3。
3
+ *
4
+ * heartbeat 常駐(src/cli.ts)に「指示の取得 → アイドル判定 → tmux送信 →
5
+ * 完了マーカー検出 → 状態書き戻し」を足すためのモジュール。
6
+ *
7
+ * 【最重要の安全方針】
8
+ * 最悪のケース = 作業中の送信先CC(以下 CC2)へ誤爆して混線・破壊すること。
9
+ * これを絶対に回避するため、判定はすべて保守的(少しでも作業中っぽければ送らない)。
10
+ * 送り損ねは次ポーリングで再挑戦できるので無害。「誤爆より送り遅れ」。
11
+ *
12
+ * 既存の heartbeat 機能・送信先API・認可(共有シークレット Bearer)は変更しない。足すだけ。
13
+ * 鍵はコードに書かず、必ず環境変数経由で受け取る。
14
+ */
15
+ import { execFileSync } from 'node:child_process';
16
+ // ───────────────────────────────────────────────────────────────────────────
17
+ // 調整可能な定数(後でチューニングできるよう、ここに集約する)
18
+ // ───────────────────────────────────────────────────────────────────────────
19
+ /** 静止確認・プロンプト判定に使う「画面末尾の行数」。 */
20
+ export const RELAY_TAIL_LINES = 40;
21
+ /** 静止確認: 1回目と2回目の capture の間隔(ms)。この間に画面が動けば「作業中」。 */
22
+ export const RELAY_SETTLE_MS = 1500;
23
+ /**
24
+ * pending(送信済みDONE待ち)を諦めるまでの上限(ms)。既定15分。
25
+ * CC2 が異常終了(例:529)等で DONE を返さないと pending に固着し後続を拾えなくなるため、
26
+ * この時間を超えたら relay 内部で pending を解除して次へ進む(サーバ status は戻さない)。
27
+ * 環境変数 RELAY_PENDING_TIMEOUT_MS で上書き可(正の数のみ採用、不正値は既定にフォールバック)。
28
+ * CC2 の正常作業も長くなり得るので、誤って諦めないよう十分大きめにする。
29
+ */
30
+ export const RELAY_PENDING_TIMEOUT_MS = (() => {
31
+ const def = 15 * 60 * 1000; // 900000ms = 15分
32
+ const raw = (process.env.RELAY_PENDING_TIMEOUT_MS ?? '').trim();
33
+ if (raw === '')
34
+ return def;
35
+ const parsed = Number(raw);
36
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : def;
37
+ })();
38
+ /** 環境変数を正の数として読む(空/不正は既定へフォールバック)。再送ポリシーの定数で使う。 */
39
+ function envPositiveNumber(name, def) {
40
+ const raw = (process.env[name] ?? '').trim();
41
+ if (raw === '')
42
+ return def;
43
+ const parsed = Number(raw);
44
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : def;
45
+ }
46
+ /**
47
+ * 自動再送(2-a)のポリシー。生存中の職人が動かないときに同一指示を再送する。
48
+ * - RELAY_RESEND_DELAY_MS: 初回送信(sentAt)から最初の再送までの待機(ms)。既定30秒。
49
+ * - RELAY_RESEND_INTERVAL_MS: 2回目以降(fast相)の再送間隔(ms)。直近送信(lastSentAt)基準。既定30秒。
50
+ * - RELAY_RESEND_MAX: fast再送の回数。これを超えたら fast を打ち切り、★以後は低頻度(slow相)で
51
+ * 再配信を継続する(従来のように完全には止めない)。既定3。
52
+ * - RELAY_RESEND_SLOW_INTERVAL_MS: fast上限到達後の低頻度再配信の間隔(ms)。既定2分。
53
+ * 起動順レース(職人が relay より後から起動)で fast を使い切った後でも、職人が起動して
54
+ * 未着信+アイドルになれば次の slow tick で必ず届くようにする根治。最終打ち切りは pending
55
+ * タイムアウト(RELAY_PENDING_TIMEOUT_MS)に委ねる。暴走防止は decideResend が
56
+ * 「未着信(anchor未可視)かつ idleStable かつ 間隔経過」のときだけ送ることで担保。
57
+ * いずれも環境変数で上書き可(正の数のみ採用)。
58
+ */
59
+ export const RELAY_RESEND_DELAY_MS = envPositiveNumber('RELAY_RESEND_DELAY_MS', 30 * 1000);
60
+ export const RELAY_RESEND_INTERVAL_MS = envPositiveNumber('RELAY_RESEND_INTERVAL_MS', 30 * 1000);
61
+ export const RELAY_RESEND_MAX = envPositiveNumber('RELAY_RESEND_MAX', 3);
62
+ export const RELAY_RESEND_SLOW_INTERVAL_MS = envPositiveNumber('RELAY_RESEND_SLOW_INTERVAL_MS', 2 * 60 * 1000);
63
+ /**
64
+ * 同一 command を relay が配達する累計回数の絶対上限(保険・無限ループの構造的封じ込め)。
65
+ * 初回送信+自動再送+sent孤児復元後の再送を、プロセス内で id 単位に通算する。
66
+ *
67
+ * 【なぜ必要か】RELAY_RESEND_MAX は fast相→slow相の“間隔”を切り替えるだけで、slow相は打ち切らず
68
+ * 低頻度に再配信を継続する設計(起動順レース根治)。そのため職人窓が永久に未着信/未DONEのとき、
69
+ * 「slow再送 → pending タイムアウト(15分)で解除 → fetchCommand が同じ queued を再取得 → 再び pending →
70
+ * slow再送 …」のサイクルが(resendCount がその都度0にリセットされるため)事実上無限に続く穴が残る。
71
+ * この上限を超えた command はプロセス内で「配達放棄(abandoned)」とし、二度と配達しない(WARNログを残す)。
72
+ *
73
+ * 【スコープ】プロセス再起動でカウンタ/放棄集合はリセットされる(サーバAPIは変更しない方針のため永続化しない)。
74
+ * これは許容範囲: DONE 到達済みの command はそもそもサーバが queued を返さない=再取得されない。
75
+ * 本上限は「完了通知が単線で切れて詰まった」異常時に無限ループを構造的に不可能化するための保険。
76
+ * 環境変数 RELAY_DELIVER_MAX で上書き可(正の数のみ)。既定20(初回1+再送19相当)。
77
+ */
78
+ export const RELAY_DELIVER_MAX = envPositiveNumber('RELAY_DELIVER_MAX', 20);
79
+ /**
80
+ * sent孤児の復元(2-b)を有効化するか。既定有効。CONDUCTOR_RESTORE_ORPHANS が
81
+ * 0/false/no/off(大小無視)のときだけ無効化する(安全に止められる逃げ道)。
82
+ */
83
+ export const RELAY_RESTORE_ORPHANS_ENABLED = !/^(0|false|no|off)$/i.test((process.env.CONDUCTOR_RESTORE_ORPHANS ?? '').trim());
84
+ /**
85
+ * 孤児取得APIのタイムアウト(ms)。devlog 到達不能でも relay 起動がハングしないよう短め。
86
+ * 環境変数 CONDUCTOR_RESTORE_ORPHANS_TIMEOUT_MS で上書き可(正の数のみ)。既定5秒。
87
+ */
88
+ export const RELAY_ORPHAN_FETCH_TIMEOUT_MS = envPositiveNumber('CONDUCTOR_RESTORE_ORPHANS_TIMEOUT_MS', 5 * 1000);
89
+ /**
90
+ * 外部完了検出の中央 status probe(fetchCommandStatus)のタイムアウト(ms)。既定5s。
91
+ * pending(着信確認済み)+職人アイドルのときだけ叩く最善努力プローブ。中央到達不能でも tick を
92
+ * ハングさせないため短めに切り、失敗は null(=従来の副シグナル/タイムアウトへ無損失縮退)。
93
+ */
94
+ export const RELAY_STATUS_FETCH_TIMEOUT_MS = envPositiveNumber('RELAY_STATUS_FETCH_TIMEOUT_MS', 5 * 1000);
95
+ /**
96
+ * 生存ハートビート(案①・調査 cmqbuch4l)の送信間隔(ms)。既定2.5分。
97
+ * pending(送信済みDONE待ち)で職人が busy のとき、この間隔で中央へ「まだ作業中」を通知し
98
+ * 中央の lastSeenAt を更新する。中央の auto-fail TTL(既定15分)より十分短くし、長時間作業中の
99
+ * sent が誤って failed 化されないようにする。毎tick送らずここでスロットルする。
100
+ * 環境変数 RELAY_HEARTBEAT_INTERVAL_MS で上書き可(正の数のみ)。
101
+ */
102
+ export const RELAY_HEARTBEAT_INTERVAL_MS = envPositiveNumber('RELAY_HEARTBEAT_INTERVAL_MS', 150 * 1000);
103
+ /**
104
+ * 「実際に処理中のときだけ出る、確実なシグナル」だけを持つ(小文字化して部分一致で判定)。
105
+ * 一つでも末尾付近にあれば送らない。
106
+ *
107
+ * 【なぜ絞るか】以前は running/working/thinking/tool use/press up to 等の一般語も入れていたが、
108
+ * これらは CC2 の通常画面や週替わりヒント(例: "Feature of the week: /loop ...")に紛れて
109
+ * 誤検出し、アイドルなのに永久に送れない不具合を招いた。判定は一般語の有無では行わない。
110
+ * - 'esc to interrupt': Claude Code が処理ループ中に必ず出す、最も確実な表示。主シグナル。
111
+ * - 'compacting': 自動コンパクト中の確実な処理表示(通常画面・ヒント文には出ない)。
112
+ * スピナーグリフ(BUSY_GLYPHS)は実処理中のみ点滅するので併用する。
113
+ */
114
+ export const BUSY_MARKERS = [
115
+ 'esc to interrupt',
116
+ 'compacting',
117
+ ];
118
+ /**
119
+ * 「作業中」を示すスピナーグリフ。末尾付近にあれば送らない。
120
+ * 実処理中だけ点滅する Braille スピナーのみを残す。
121
+ * (飾り記号 '✶ ✻ ✽ ✢ ✳ ✦ ✧' は Claude Code のアイドル時UI(ウェルカム表示・Tip行等)にも
122
+ * 出るため、アイドルなのに永久 busy 判定となる誤検出源だったので除外した。
123
+ * '·' のような普通の出力に頻出する記号も入れない。)
124
+ */
125
+ export const BUSY_GLYPHS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
126
+ /**
127
+ * CC2 の作業報告を囲ませる行マーカー(ADR-029 報告集約・送信側)。
128
+ * CC2 はこの2行で報告本文を挟んで出力し、relay が間の行群を抜き出して中央へ送る。
129
+ * BUSY_MARKERS / BUSY_GLYPHS / `DONE:` のいずれとも文字列が衝突しない(アイドル判定・完了判定を汚さない)。
130
+ */
131
+ export const REPORT_START = 'REPORT_START';
132
+ export const REPORT_END = 'REPORT_END';
133
+ /** 連続スキップ何回でアイドル誤判定の疑いをWARNするか(観測のみ・送信挙動は変えない)。 */
134
+ export const RELAY_SKIP_WARN_THRESHOLD = 6;
135
+ /** 初期状態を作る。 */
136
+ export function createRelayState() {
137
+ return {
138
+ pending: null,
139
+ running: false,
140
+ restored: false,
141
+ lastHalted: null,
142
+ lastSkippedId: null,
143
+ skipCount: 0,
144
+ skipWarned: false,
145
+ lastHeartbeatAt: null,
146
+ deliverCounts: new Map(),
147
+ abandoned: new Set(),
148
+ };
149
+ }
150
+ /**
151
+ * この command を(初回/再送問わず)まだ配達してよいか。純粋関数(I/Oなし=単体テスト可能)。
152
+ * - 既に放棄済み(abandoned) → false。
153
+ * - 累計配達回数が RELAY_DELIVER_MAX 以上 → false(もう配達しない=無限ループ防止の絶対上限)。
154
+ */
155
+ export function canDeliver(state, id) {
156
+ if (state.abandoned.has(id))
157
+ return false;
158
+ return (state.deliverCounts.get(id) ?? 0) < RELAY_DELIVER_MAX;
159
+ }
160
+ /** 配達成功を1件記録して累計を返す(初回送信・再送の両方の成功時に呼ぶ)。 */
161
+ export function markDelivered(state, id) {
162
+ const n = (state.deliverCounts.get(id) ?? 0) + 1;
163
+ state.deliverCounts.set(id, n);
164
+ return n;
165
+ }
166
+ // ───────────────────────────────────────────────────────────────────────────
167
+ // 純粋関数(外部I/Oなし=単体テスト/ドライランしやすい)
168
+ // ───────────────────────────────────────────────────────────────────────────
169
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
170
+ /** 末尾の空行を除いた「末尾 RELAY_TAIL_LINES 行」を文字列で返す。静止比較の単位。 */
171
+ export function tailOf(text) {
172
+ const lines = text.replace(/\r/g, '').split('\n');
173
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '')
174
+ lines.pop();
175
+ return lines.slice(-RELAY_TAIL_LINES).join('\n');
176
+ }
177
+ /**
178
+ * 画面末尾に「確実な処理中シグナル(esc to interrupt / compacting / スピナーグリフ)」が
179
+ * あるかを判定する純粋関数。末尾 RELAY_TAIL_LINES 行だけを見る(looksIdle と同じ範囲)。
180
+ * - busy 判定を looksIdle から切り出して再利用可能にしたもの(自動再送の可否判定でも使う)。
181
+ * - looksIdle はこれが true なら即アイドル不成立に倒す。
182
+ */
183
+ export function hasBusySignal(text) {
184
+ const lines = text.replace(/\r/g, '').split('\n');
185
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '')
186
+ lines.pop();
187
+ const tail = lines.slice(-RELAY_TAIL_LINES);
188
+ const tailText = tail.join('\n');
189
+ const lower = tailText.toLowerCase();
190
+ if (BUSY_MARKERS.some((m) => lower.includes(m)))
191
+ return true;
192
+ if (BUSY_GLYPHS.some((g) => tailText.includes(g)))
193
+ return true;
194
+ return false;
195
+ }
196
+ /**
197
+ * 画面テキストから「アイドルか」を判定する純粋関数(判定(a))。
198
+ * 方針:「確実な処理中シグナル(esc to interrupt / compacting / スピナーグリフ)が無く、
199
+ * かつ末尾付近に入力待ちプロンプト行がある」ときだけアイドルとみなす。一般語では判定しない。
200
+ * 週替わりヒントやバナーが上にあっても、末尾に入力待ちがあればアイドルと判定できる。
201
+ * 曖昧なら false(=送らない側)に倒す。
202
+ */
203
+ export function looksIdle(text) {
204
+ const lines = text.replace(/\r/g, '').split('\n');
205
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '')
206
+ lines.pop();
207
+ const tail = lines.slice(-RELAY_TAIL_LINES);
208
+ // 確実な処理中シグナルがあれば即アウト(作業中とみなす)。
209
+ if (hasBusySignal(text))
210
+ return false;
211
+ // 許可プロンプト表示中はアイドルとみなさない(落とし穴ガード)。
212
+ // Claude Code の許可プロンプト("❯ 1. Yes" 等)も `❯` で始まる行を含むため、下の hasPrompt が
213
+ // true になりアイドル誤判定し得る。許可プロンプト中に relay の pending ループが新規指示や再送を
214
+ // 流し込むと、選択メニューに指示文が打ち込まれて混線する。許可プロンプト中は必ず非アイドルに倒し、
215
+ // 応答は専用の許可ループ(permission.ts)にのみ委ねる(送信経路の唯一化=CC2書き込みの排他)。
216
+ if (hasPermissionPrompt(text).isPrompt)
217
+ return false;
218
+ // 入力待ちプロンプト行の検出。
219
+ // tmux のボックス装飾(│ | 空白)を行頭から剥がし、残りが入力待ちプロンプト記号で始まれば入力待ち。
220
+ // CC2 (Claude Code v2系) の実プロンプトは `❯`(U+276F) のみを採用する。
221
+ // 【後方互換 `>` 許容を撤去】`>` 始まりは bash の継続プロンプトや独自PS1など
222
+ // 非claude窓にも出るため、待機と誤認して非claude窓へ誤爆する穴の原因だった。
223
+ // claude の入力プロンプトは `❯` なので、`❯` 始まりだけをアイドルとみなす。
224
+ const hasPrompt = tail.some((line) => {
225
+ const stripped = line.replace(/^[│|\s]+/, '');
226
+ return stripped.startsWith('❯');
227
+ });
228
+ return hasPrompt;
229
+ }
230
+ /**
231
+ * 許可プロンプトに固有で、通常画面・週替わりヒント・報告/ENQUEUE出力のいずれにも出ない綴り。
232
+ * - 質問行: "Do you want to proceed?" / "Do you want to allow this connection?" 等。
233
+ * - 選択肢行: "❯ 1. Yes" / "1. Yes"(番号付き選択メニューの先頭)。
234
+ * 【誤検出を絶対に避ける】質問行と "1. Yes" 選択肢行の「両方」が末尾付近に在るときだけ許可プロンプトと
235
+ * みなす。どちらか一方だけ(例: 文章中の "yes")では成立させない。これにより looksIdle を不当に
236
+ * false へ倒して relay を永久に黙らせる事故(過去の busy 誤検出と同型の罠)を防ぐ。
237
+ */
238
+ const PERMISSION_QUESTION_RE = /do you want to (proceed|allow|create|run|make|continue|apply|delete|overwrite|trust|execute)/i;
239
+ /** 選択肢「1. Yes」行(行頭の `❯` カーソルは任意)。`1.` でも `1)` でも拾う。 */
240
+ const PERMISSION_YES_RE = /^❯?\s*1[.)]\s*yes\b/i;
241
+ /** 種別 network 判定に使う、接続許可特有の語。 */
242
+ const PERMISSION_NETWORK_RE = /connection|connect to|fetch|request to|url|domain|host\b|network/i;
243
+ /** tmux のボックス装飾(│ ╎ ┃ | と前後空白)を行から剥がす。 */
244
+ function stripBoxDecoration(line) {
245
+ return line
246
+ .replace(/^[│|╎┃\s]+/, '')
247
+ .replace(/[│|╎┃\s]+$/, '')
248
+ .trim();
249
+ }
250
+ /**
251
+ * 画面テキストに「許可プロンプト(Do you want to ... + 1. Yes 選択肢)」が出ているかを判定する純粋関数。
252
+ * - 末尾付近のみ見る(許可プロンプトは常に画面下部に描画される)。枠が高いことがあるので少し広めに取る。
253
+ * - 質問行と "1. Yes" 選択肢行の両方が在るときだけ isPrompt=true(誤検出をしない最優先)。
254
+ * - command は枠上辺(╭)〜質問行の内容行を best-effort で結合(抽出不能でも検知自体は成立)。
255
+ * - kind は質問文の語から network / command を推定(不明は 'command' に寄せる)。
256
+ */
257
+ export function hasPermissionPrompt(text) {
258
+ const NONE = { isPrompt: false, command: '', kind: 'unknown' };
259
+ const allLines = text.replace(/\r/g, '').split('\n');
260
+ // 末尾付近(枠の高さを考慮し RELAY_TAIL_LINES より広めの 50 行)だけを対象にする。
261
+ const span = Math.max(RELAY_TAIL_LINES, 50);
262
+ const lines = allLines.slice(-span);
263
+ const stripped = lines.map(stripBoxDecoration);
264
+ // 質問行("Do you want to ...")を末尾側から探す。
265
+ let questionIdx = -1;
266
+ for (let i = stripped.length - 1; i >= 0; i--) {
267
+ if (PERMISSION_QUESTION_RE.test(stripped[i])) {
268
+ questionIdx = i;
269
+ break;
270
+ }
271
+ }
272
+ if (questionIdx < 0)
273
+ return NONE;
274
+ // "1. Yes" 選択肢行が在るか(質問行とセットで初めて許可プロンプトと確定する)。
275
+ if (!stripped.some((s) => PERMISSION_YES_RE.test(s)))
276
+ return NONE;
277
+ // 種別: 質問文に接続許可特有の語があれば network、無ければ command(ツール実行許可)。
278
+ const kind = PERMISSION_NETWORK_RE.test(stripped[questionIdx])
279
+ ? 'network'
280
+ : 'command';
281
+ // command 抽出(best-effort): 質問行の直上にある枠上辺(╭)以降〜質問行までの内容行を結合する。
282
+ let topIdx = 0;
283
+ for (let i = questionIdx - 1; i >= 0; i--) {
284
+ if (lines[i].includes('╭')) {
285
+ topIdx = i + 1;
286
+ break;
287
+ }
288
+ }
289
+ const content = [];
290
+ for (let i = topIdx; i < questionIdx; i++) {
291
+ const s = stripped[i];
292
+ if (s !== '')
293
+ content.push(s);
294
+ }
295
+ const command = content.join(' ').replace(/\s+/g, ' ').trim();
296
+ return { isPrompt: true, command, kind };
297
+ }
298
+ /**
299
+ * セッション評価などの「想定外モーダル」(Yes/No の許可ではなく、閉じるだけでよい類)を検知する純粋関数。
300
+ * 例: 「How is Claude doing this session? 1:Bad 2:Fine 3:Good 0:Dismiss」。
301
+ *
302
+ * 【なぜ別関数か】これは許可プロンプト(hasPermissionPrompt: "Do you want to … 1.Yes")とは別物で、
303
+ * ALLOW/DENY の安全判断対象ではない。放置すると CC1B 自身がこれで固まり、全現場の許可判断が
304
+ * 止まる(指示 cmqc0ynkb の副因)。検知したら Esc で**閉じるだけ**にする(Yes/No は絶対に押さない)。
305
+ *
306
+ * 【誤爆を避ける】許可プロンプト(1.Yes)とは綴りが重ならない2パターンのいずれかが末尾付近に在るときだけ true:
307
+ * - 評価モーダル特有の質問文 /how is claude doing/i
308
+ * - 「0. Dismiss」/「0) Dismiss」選択肢行(行頭から単独。許可メニューは 1/2/3 で 0:Dismiss を持たない)
309
+ */
310
+ const DISMISS_RATING_RE = /how is claude doing/i;
311
+ const DISMISS_OPTION_RE = /^❯?\s*0\s*[.)]?\s*dismiss\b/i;
312
+ export function hasDismissablePrompt(text) {
313
+ const allLines = text.replace(/\r/g, '').split('\n');
314
+ const span = Math.max(RELAY_TAIL_LINES, 50);
315
+ const lines = allLines.slice(-span).map(stripBoxDecoration);
316
+ return lines.some((s) => DISMISS_RATING_RE.test(s) || DISMISS_OPTION_RE.test(s));
317
+ }
318
+ /**
319
+ * Claude Code 固有の表示マーカー(小文字化して部分一致で判定・内容ベースのガード用)。
320
+ * 名前一致+`❯`プロンプトだけでは、fish/starship 等 `❯` を入力記号に使う非claude窓へ
321
+ * 誤爆し得る。送信直前に capture へこのいずれかが含まれるときだけ送ることで、その穴を塞ぐ。
322
+ * - 'claude code': 起動バナー(ウェルカム画面)に出る固有文字列。
323
+ * - '? for shortcuts': 入力枠フッターのヒント。アイドルで入力欄が空のとき表示される。
324
+ * - 'accept edits on' / 'esc to interrupt' / 'for agents': FleetView系フッターの語。
325
+ * それぞれ「権限モード行(accept edits on)」「busy中の中断ヒント(esc to interrupt)」
326
+ * 「エージェント切替ヒント(← for agents)」として画面末尾に出る。入力欄に未送信の残テキストが
327
+ * あると '? for shortcuts' が隠れて偽陰性になるが、これらフッター語は残テキスト有無に関係なく
328
+ * 出るため取りこぼしを救える。いずれも素のシェルや一般TUIには出ない Claude Code 固有語に
329
+ * 限定し、非claude窓への誤検知を避ける。
330
+ * いずれも素のシェルや一般TUIには出ないため、非claude窓を確実に弾ける。
331
+ */
332
+ export const CLAUDE_MARKERS = [
333
+ 'claude code',
334
+ '? for shortcuts',
335
+ 'accept edits on',
336
+ 'esc to interrupt',
337
+ 'for agents',
338
+ ];
339
+ /**
340
+ * 入力枠の横罫線(全幅ルール)とみなす連続 `─`(U+2500) の最小本数。
341
+ * 短い装飾区切り線の誤検知を避けるため長め(全幅枠相当)に取る。
342
+ */
343
+ const BOX_RULE_MIN_DASHES = 12;
344
+ /**
345
+ * capture テキストが「Claude Code の箱」と言えるかを判定する純粋関数(内容ベースのガード)。
346
+ *
347
+ * 【判定: 恒久シグナル OR 既存マーカー(OR で後退させない)】
348
+ * (a) 恒久シグナル: Claude Code は idle 時、画面末尾へ「入力枠+`❯` プロンプト」を常に描画する。
349
+ * バナー('claude code')がスクロールアウトしても、フッターヒントがローテーションしても、
350
+ * この入力枠は出続けるため可視範囲依存に強い。枠の描画は2バリエーションを許容する:
351
+ * (a-1) 角丸ボックス: 上辺 `╭──…` / 下辺 `╰──…`(または右上 `…─╮` / 右下 `…─╯`)。
352
+ * (a-2) 直線フルワイド罫線: コーナー無しで `─` が BOX_RULE_MIN_DASHES 本以上連続する横線。
353
+ * FleetView 等で角丸でなく直線 ──── の枠を描く版を取りこぼさないため。
354
+ * いずれも `❯` プロンプト行との同時存在で枠と判定。素のシェルや fish/starship 等
355
+ * (`❯` は使うが全幅 ─ 罫線も角丸枠も描かない)には出ないので非claude窓を弾ける。
356
+ * (b) 既存マーカー: CLAUDE_MARKERS のいずれか(従来挙動を温存・取りこぼし防止)。
357
+ * どちらかが成立すれば true。両方不成立なら false(=送らない側)。
358
+ */
359
+ export function looksLikeClaude(text) {
360
+ const lower = text.toLowerCase();
361
+ if (CLAUDE_MARKERS.some((m) => lower.includes(m)))
362
+ return true;
363
+ // 恒久シグナル: 入力枠の罫線と `❯` プロンプト行の同時存在。
364
+ const lines = text.replace(/\r/g, '').split('\n');
365
+ const hasPrompt = lines.some((line) => line.replace(/^[│|\s]+/, '').startsWith('❯'));
366
+ // 枠線は2種類を許容(描画バリエーション差を吸収):
367
+ // (1) 角丸ボックス: 上辺 `╭──…` / 下辺 `╰──…`(または右上 `…─╮` / 右下 `…─╯`)。
368
+ // (2) 直線フルワイド罫線: `─` が BOX_RULE_MIN_DASHES 本以上連続する横線(角丸でない版)。
369
+ const straightRule = new RegExp(`─{${BOX_RULE_MIN_DASHES},}`);
370
+ const hasFrame = lines.some((line) => /[╭╰]─{2,}|─{2,}[╮╯]/.test(line) || straightRule.test(line));
371
+ return hasPrompt && hasFrame;
372
+ }
373
+ /**
374
+ * 画面テキストに「完了マーカー DONE:{site} が単独で出力されているか」を判定する純粋関数。
375
+ *
376
+ * 注意: 送信本文自体に "...必ず DONE:{site} とだけ出力してください" という指示文が含まれ、
377
+ * それが画面にエコーされる。これを完了と誤検出しないよう、
378
+ * 「marker 以外の文字(装飾を除く)がほぼ残らない=marker 単独の行」だけを完了とみなす。
379
+ * 最も新しい(末尾側の) marker 行で判定する。
380
+ */
381
+ /** 1行が「DONE:{site} 単独行」か。marker と装飾・空白を除いて何も残らなければ単独。 */
382
+ function isStandaloneDoneLine(line, marker) {
383
+ if (!line.includes(marker))
384
+ return false;
385
+ const rest = line.replace(marker, '').replace(/[│|>⏺●○•*·\s]/g, '');
386
+ return rest.length === 0;
387
+ }
388
+ export function hasDoneMarker(text, site) {
389
+ const marker = `DONE:${site}`;
390
+ const lines = text.replace(/\r/g, '').split('\n');
391
+ for (let i = lines.length - 1; i >= 0; i--) {
392
+ if (!lines[i].includes(marker))
393
+ continue;
394
+ // marker を含む最も新しい行が「単独行」かどうかで判定。
395
+ return isStandaloneDoneLine(lines[i], marker);
396
+ }
397
+ return false;
398
+ }
399
+ /**
400
+ * 送信ごとの一意アンカートークンの接頭辞。送信本文の末尾に `${SEND_ANCHOR_PREFIX}:{id}` を埋め込み、
401
+ * 画面にエコーされたこの行を「今回ジョブの境界」として使う。
402
+ * BUSY_MARKERS / `DONE:` / `REPORT_*` のいずれとも衝突しない綴りにする(他判定を汚さない)。
403
+ */
404
+ export const SEND_ANCHOR_PREFIX = 'CONDUCTOR_JOB';
405
+ /** 指示idから送信アンカー文字列を作る純粋関数。id は中央発番で globally unique=他出力と衝突しない。 */
406
+ export function buildAnchor(id) {
407
+ return `${SEND_ANCHOR_PREFIX}:${id}`;
408
+ }
409
+ /**
410
+ * capture テキストから「アンカーが最後に現れた行より下」だけを切り出す純粋関数。
411
+ * - アンカーは今回送信した指示文の末尾に在り、画面では『送信したユーザ入力のエコー』の最後に出る。
412
+ * その下に職人(CC2)の応答が描画されるので、下側だけ見れば今回ジョブの出力に限定できる。
413
+ * - 最後の出現を採るのは、職人が応答中にプロンプトを引用してアンカーを再掲しても、
414
+ * 最終的な DONE 行はさらにその下に出るため取りこぼさないようにするため。
415
+ * - アンカーが画面外(2000行窓から流出)なら null(=まだ確証なし。保守側で完了とみなさない)。
416
+ */
417
+ export function sliceBelowAnchor(text, anchor) {
418
+ const lines = text.replace(/\r/g, '').split('\n');
419
+ let anchorIdx = -1;
420
+ for (let i = lines.length - 1; i >= 0; i--) {
421
+ if (lines[i].includes(anchor)) {
422
+ anchorIdx = i;
423
+ break;
424
+ }
425
+ }
426
+ if (anchorIdx < 0)
427
+ return null;
428
+ return lines.slice(anchorIdx + 1).join('\n');
429
+ }
430
+ /** テキスト中に「DONE:{site} 単独行」が1つでもあるか(装飾を除いて marker 単独)。 */
431
+ export function hasStandaloneDone(text, site) {
432
+ const marker = `DONE:${site}`;
433
+ const lines = text.replace(/\r/g, '').split('\n');
434
+ return lines.some((line) => isStandaloneDoneLine(line, marker));
435
+ }
436
+ /**
437
+ * アンカー基準の完了判定(根治の中核)。
438
+ * 「アンカーより下の領域」に DONE:{site} 単独行があれば完了とみなす。
439
+ * - churn で古い DONE が窓上端から押し出されても、過去ジョブの DONE が窓に残っていても、
440
+ * 見るのはアンカーより下=今回ジョブの出力だけなので、誤検出も見逃しも起きない。
441
+ * - 送信本文エコー由来の非単独 DONE(指示文「…必ず DONE:{site} とだけ…」)は
442
+ * isStandaloneDoneLine が弾く&そもそもアンカー行と同一論理行=アンカーより上に来るので対象外。
443
+ * - アンカーが画面外なら false(保守側=完了とみなさず、次tick再確認 / 最終的に pending タイムアウト)。
444
+ */
445
+ export function hasDoneBelowAnchor(text, site, anchor) {
446
+ const below = sliceBelowAnchor(text, anchor);
447
+ if (below === null)
448
+ return false;
449
+ return hasStandaloneDone(below, site);
450
+ }
451
+ /**
452
+ * 生存中の職人が動かないときの自動再送の可否を判定する純粋関数(2-a の核心)。
453
+ *
454
+ * 【二重実行を防ぐ冪等性が最優先】判定の順序がそのまま安全の優先順位:
455
+ * 1) アンカー(CONDUCTOR_JOB:{id})が画面に出ている=tmuxへ着信済み。アイドルでも再送しない。
456
+ * (職人が読んで思考中の可能性。これが二重実行防止の核心。経過時間より着信判定を優先する)
457
+ * 2) busy(esc to interrupt / compacting / スピナー)=実処理中なら再送しない(混線回避)。
458
+ * 3) 二重チェックのアイドル(isIdleStable)が不成立なら再送しない(保守的)。
459
+ * 4) 送信間隔が lastSentAt から未経過なら待つ。間隔は「初回=delay / fast相(回数<max)=interval /
460
+ * ★slow相(回数≥max)=slowInterval」。
461
+ * 5) 上を全て満たす=未着信かつ静止かつ閾値経過 → 'resend'。
462
+ *
463
+ * ★起動順レース根治(不具合A): かつては「最大再送回数到達で 'stop'(打ち切り)」だったが、
464
+ * これだと職人が relay より後から起動したケースで二度と届かなかった。本実装は上限到達で
465
+ * 打ち切らず、間隔を slowInterval に広げて低頻度で再配信を継続する(職人が起動して未着信+
466
+ * アイドルになれば次の slow tick で必ず届く)。暴走しないのは、再送が常に「未着信(anchor未可視)
467
+ * かつ idleStable かつ 間隔経過」を満たすときだけ=着信済み/busy/非アイドルの間は一切送らないため。
468
+ * 最終的な打ち切りは外側の pending タイムアウト(RELAY_PENDING_TIMEOUT_MS)に委ねる。
469
+ *
470
+ * capture は単一フレームの画面テキスト(着信判定 anchor とbusy判定に使う)。
471
+ * idleStable は呼び出し側で isIdleStable() を実行した二重チェック済みアイドルの真偽。
472
+ */
473
+ export function decideResend(args) {
474
+ const { capture, anchor, idleStable, lastSentAt, resendCount, now, policy } = args;
475
+ // (1) 冪等性の核心: 着信済み(アンカーが画面に可視)ならアイドルでも再送しない。
476
+ if (capture.includes(anchor)) {
477
+ return { verdict: 'wait', reason: 'delivered (anchor visible on screen)' };
478
+ }
479
+ // (2) 実処理中シグナルがあれば再送しない(職人が動いている=送信は届いて処理中の可能性)。
480
+ if (hasBusySignal(capture)) {
481
+ return { verdict: 'wait', reason: 'busy (work-in-progress signal)' };
482
+ }
483
+ // (3) 二重チェックのアイドルが不成立なら再送しない。
484
+ if (!idleStable) {
485
+ return { verdict: 'wait', reason: 'not idle-stable' };
486
+ }
487
+ // (4) 送信間隔(初回 delay / fast相 interval / ★slow相 slowInterval)未経過なら待つ。lastSentAt 基準。
488
+ // fast相 = 回数 < maxResend、slow相 = 回数 ≥ maxResend(上限到達後も打ち切らず低頻度継続)。
489
+ const inSlowPhase = resendCount >= policy.maxResend;
490
+ const required = resendCount === 0
491
+ ? policy.resendDelayMs
492
+ : inSlowPhase
493
+ ? policy.resendSlowIntervalMs
494
+ : policy.resendIntervalMs;
495
+ if (now - lastSentAt < required) {
496
+ return { verdict: 'wait', reason: 'resend interval not elapsed' };
497
+ }
498
+ // (5) 未着信+アイドル+閾値経過 → 再送(上限後も slow相 で低頻度に継続)。
499
+ return {
500
+ verdict: 'resend',
501
+ reason: inSlowPhase
502
+ ? 'undelivered & idle-stable & slow-interval elapsed (post-max slow redelivery)'
503
+ : 'undelivered & idle-stable & interval elapsed',
504
+ };
505
+ }
506
+ /**
507
+ * 外部完了(中央で done/failed 化された案件)を検知して pending 追跡を解除すべきかを判定する純粋関数。
508
+ *
509
+ * 【背景・実地バグ 2026-07-04】relayTick の完了検出は「職人画面に DONE:{site} 単独行がエコーされる」
510
+ * 画面スクレイプが唯一の経路だった。ところが職人が devlog MCP の conductor_complete で中央を直接
511
+ * done 化すると画面に DONE 行が出ないため、この検出が永遠に成立せず pending が固着し、
512
+ * `return; // sent 中は新規取得しない` により次の queued へ二度と進めなくなる(relay 再起動でのみ復旧)。
513
+ * これは「完了の真実は中央にあるのに relay が自前の画面検出だけを待っている」という設計の穴。
514
+ *
515
+ * 【判定の根拠=根治方針「完了の真実は中央にある」への寄せ】
516
+ * 中央は「1現場同時1指示」で、ある指示が sent の間は新規 queued を返さない(fetchCommand=null)。
517
+ * したがって次の条件が揃えば pending.id は中央で既に終端した確証になる:
518
+ * - delivered(着信確認済み=relay が status を 'sent' に遷移済み)で、
519
+ * - 職人画面がアイドル(looksIdle=処理中でも許可プロンプトでもない・入力待ち)で、
520
+ * - fetchCommand が pending.id とは別の id の指示を返した(=中央がもう次を配れる状態)。
521
+ * このとき追跡を解除して次tickで新規指示を素直に取りに行けば、外部done化でも詰まらない。
522
+ *
523
+ * 【安全側(false=解除しない)に倒すケース】いずれも従来の画面DONE検出/pendingタイムアウトへ無損失縮退:
524
+ * - delivered 前(まだ queued 据置・着信未確認): 次のqueuedはpending自身なので誤検出しない。
525
+ * - 非アイドル(busy/許可プロンプト等): まだ職人が動作中の可能性 → 解除しない。
526
+ * - 中央不達 or 次指示なし(nextCommandId=null)。
527
+ * - 次指示が pending と同id(通常は起こらないが、念のため同一なら解除しない)。
528
+ *
529
+ * I/O を持たない純粋関数(fetchCommand の結果 id と画面アイドル判定を引数で受ける)=単体テスト可能。
530
+ */
531
+ export function detectExternalDone(args) {
532
+ if (!args.delivered)
533
+ return false; // 着信未確認=中央はまだ queued。次のqueuedはpending自身。
534
+ if (!args.idle)
535
+ return false; // 職人が動作中/許可待ちの可能性 → 解除しない(保守的)。
536
+ if (args.nextCommandId === null)
537
+ return false; // 中央不達 or 次指示なし=pendingは依然sent中。
538
+ return args.nextCommandId !== args.pendingId; // 別idが配信可能=pendingは中央で終端済みの確証。
539
+ }
540
+ /**
541
+ * 中央が返した指示 status 文字列が「終端(もう作業しない状態)」かを判定する純粋関数。
542
+ *
543
+ * 【根治の中核】detectExternalDone は「次に別の queued がある」ときしか外部done化を検知できず、
544
+ * キューが空(nextCommandId=null)だと conductor_complete 済みでも pending が 15分固着していた
545
+ * (実地: 24hで完了の約8割が pendingタイムアウト固着)。中央の真実は「その id 単体の status」に
546
+ * あるので、fetchCommandStatus で直接問い、終端ならキューの有無に関係なく追跡解除する。
547
+ *
548
+ * 終端とみなす綴り: done / failed / cancelled / canceled / abandoned(大小・前後空白は無視)。
549
+ * queued / sent / running 等の「まだ生きている」status、および null(中央不達/未知)は終端でない。
550
+ * 曖昧なら false(=解除しない安全側)に倒す(looksIdle と同じ保守方針)。
551
+ */
552
+ export function isTerminalCommandStatus(status) {
553
+ if (status === null)
554
+ return false;
555
+ const s = status.trim().toLowerCase();
556
+ return s === 'done' || s === 'failed' || s === 'cancelled' || s === 'canceled' || s === 'abandoned';
557
+ }
558
+ /**
559
+ * sent孤児を 2-a の pending 構造へ変換する純粋関数(★送信はしない)。
560
+ * - anchor は buildAnchor(id) で再生成(id 不変=アンカー不変)。送信本文末尾のアンカーと一致するため、
561
+ * 復元後に画面へアンカーが残っていれば decideResend が「着信済み」と見て再送しない(二重実行防止)。
562
+ * - resendCount=0 から再開。lastSentAt は取得した sentAt を流用(次の再送間隔判定の基準)。
563
+ * 孤児は sentAt が十分過去(TTL超)なので、未着信なら次tickの decideResend で即 resend 判定になり得る。
564
+ * - delivered=true: 孤児は元々サーバ status=sent=過去に着信確認を経て sent 化済みの段階。
565
+ * 復元後に再度 patchStatus('sent') する必要はなく、DONE 待ち/再送のみを担う。
566
+ */
567
+ export function orphanToPending(orphan) {
568
+ return {
569
+ id: orphan.id,
570
+ site: orphan.site,
571
+ sentAt: orphan.sentAt,
572
+ anchor: buildAnchor(orphan.id),
573
+ body: orphan.body,
574
+ resendCount: 0,
575
+ lastSentAt: orphan.sentAt,
576
+ resendExhaustedWarned: false,
577
+ delivered: true,
578
+ };
579
+ }
580
+ /**
581
+ * 単一 pending スロットへ復元する孤児を1件選ぶ純粋関数(二重復元・上書き防止の核心)。
582
+ * - 既に pending がある(同一プロセスで送信中/既に復元済み)→ null(復元しない)。
583
+ * これにより「同一idの二重復元」も「別idでの上書き」も起きない(1現場同時1指示の不変条件を守る)。
584
+ * - pending が無く孤児があれば、最も古い(sentAt最小)1件を選ぶ(決定的・最優先で処理すべき滞留)。
585
+ */
586
+ export function selectOrphanToRestore(orphans, currentPending) {
587
+ if (currentPending !== null)
588
+ return null;
589
+ if (orphans.length === 0)
590
+ return null;
591
+ return orphans.reduce((oldest, o) => (o.sentAt < oldest.sentAt ? o : oldest));
592
+ }
593
+ /**
594
+ * 実際に tmux へ流し込む1行テキストを組み立てる純粋関数。
595
+ * 送る本文 = 指示body + 報告マーカー指示 + 完了マーカー指示。
596
+ * CC2 の途中送信を防ぐため、改行はすべて空白に潰して「1行=1メッセージ」にする
597
+ * (本文内に生 Enter を含めない=送信は末尾の Enter 1回だけにするため)。
598
+ *
599
+ * 完了時に CC2 へ求める出力形式:
600
+ * 1) 報告を ${REPORT_START} と ${REPORT_END} の2行で挟んで出力(変更ファイル・要点・チェック結果を簡潔に)。
601
+ * 2) その後の最終行に必ず DONE:{site} とだけ出力(既存の完了運用=relay画面スクレイプの高速パスを維持)。
602
+ * 3) さらに devlog MCP の conductor_complete を commandId=<id> で1回呼ぶ(案D・relay非依存の確実な完了通知)。
603
+ * relay 再起動・画面churn で (1)(2) のスクレイプ検出が落ちても、中央が (3) で確実に done を立てる二重防御。
604
+ *
605
+ * anchor: この送信に固有のアンカートークン(buildAnchor(id)=`CONDUCTOR_JOB:<id>`)。本文の「最後」に置くことで、
606
+ * 画面では送信エコーの末尾に出る=完了検出(hasDoneBelowAnchor)の境界になる。
607
+ * 末尾に置く理由: アンカーより上に来る指示文中の "DONE:{site}" を完了検出から確実に除外するため。
608
+ */
609
+ export function buildSendText(body, site, anchor) {
610
+ const marker = `DONE:${site}`;
611
+ // anchor から素の commandId を取り出す(conductor_complete に渡す引数。接頭辞無しなので新たな
612
+ // アンカー一致を作らず、sliceBelowAnchor/decideResend の境界判定に干渉しない)。
613
+ const commandId = anchor.startsWith(`${SEND_ANCHOR_PREFIX}:`)
614
+ ? anchor.slice(SEND_ANCHOR_PREFIX.length + 1)
615
+ : anchor;
616
+ const full = `${body}\n\n` +
617
+ `作業がすべて完了したら、まず報告を ${REPORT_START} と ${REPORT_END} の2行で挟んで出力してください` +
618
+ `(変更ファイル・要点・チェック結果を簡潔に)。` +
619
+ `その後の最終行に必ず ${marker} とだけ出力してください。\n\n` +
620
+ // ★案D(relay非依存の確実な完了通知): 完了したら必ず devlog MCP の conductor_complete を呼ぶ。
621
+ `そして完了したら必ず、devlog の MCP ツール conductor_complete を commandId="${commandId}" で1回だけ呼んでください` +
622
+ `(relayに依存しない確実な完了通知です。report 引数に上記の報告本文を添えても構いません)。\n\n` +
623
+ // アンカー(管理用ジョブ印)は必ず本文の最後に置く。完了検出の境界として使う。
624
+ `(管理用ジョブ印・変更不要: ${anchor})`;
625
+ // 改行(と連続改行)を空白1個に潰し、前後を整える。
626
+ return full.replace(/\r?\n+/g, ' ').replace(/\s+/g, ' ').trim();
627
+ }
628
+ /**
629
+ * 画面テキストから CC2 の報告本文を抜き出す純粋関数(ADR-029 報告集約・送信側)。
630
+ *
631
+ * 方針:
632
+ * - 行単位で「装飾を除くと ${REPORT_START} / ${REPORT_END} だけが残る行」をマーカー行とみなす。
633
+ * 送信本文のエコー行は1行に両マーカー+指示文が同居するため、装飾除去後に空にならず
634
+ * マーカー行と判定されない(=誤抽出しない)。
635
+ * - マーカーが複数あれば「最後のペア」(最後の END と、その直前の最も近い START)を採用する。
636
+ * CC2 の実報告は送信本文エコーより後(画面下側)に出るため、最後のペア採用で実報告を拾える。
637
+ * - START/END が無い・対応が壊れている・中身が空のときは null(報告なし)。done化は妨げない。
638
+ *
639
+ * 限界(今回スコープ外): tmux capture-pane は画面に見えている範囲しか取れないため、
640
+ * 報告がスクロールで流れると全文は取れない(取れた範囲を report とする)。
641
+ */
642
+ export function extractReport(text) {
643
+ const lines = text.replace(/\r/g, '').split('\n');
644
+ // 装飾(ボックス罫線・行頭バレット・空白)を除いた中身を返す。
645
+ const stripped = (line) => line.replace(/[│|>⏺●○•*·]/g, '').trim();
646
+ // 最後の END マーカー行を探す。
647
+ let endIdx = -1;
648
+ for (let i = lines.length - 1; i >= 0; i--) {
649
+ if (stripped(lines[i]) === REPORT_END) {
650
+ endIdx = i;
651
+ break;
652
+ }
653
+ }
654
+ if (endIdx < 0)
655
+ return null;
656
+ // その直前にある最も近い START マーカー行を探す。
657
+ let startIdx = -1;
658
+ for (let i = endIdx - 1; i >= 0; i--) {
659
+ if (stripped(lines[i]) === REPORT_START) {
660
+ startIdx = i;
661
+ break;
662
+ }
663
+ }
664
+ if (startIdx < 0)
665
+ return null;
666
+ // 間の行群を取り出し、前後の空行を trim。
667
+ const inner = lines.slice(startIdx + 1, endIdx);
668
+ while (inner.length > 0 && inner[0].trim() === '')
669
+ inner.shift();
670
+ while (inner.length > 0 && inner[inner.length - 1].trim() === '')
671
+ inner.pop();
672
+ const report = inner.join('\n').trim();
673
+ return report.length > 0 ? report : null;
674
+ }
675
+ // ───────────────────────────────────────────────────────────────────────────
676
+ // tmux I/O(失敗は握りつぶして null/false。送らない側に倒すため)
677
+ // ───────────────────────────────────────────────────────────────────────────
678
+ /** tmux セッション {site} が存在するか。tmux 未インストール時も false。 */
679
+ export function tmuxHasSession(site) {
680
+ try {
681
+ execFileSync('tmux', ['has-session', '-t', site], { stdio: 'ignore' });
682
+ return true;
683
+ }
684
+ catch {
685
+ return false;
686
+ }
687
+ }
688
+ /** capture-pane で遡るスクロールバック行数。可視画面+この行数の履歴を取る。 */
689
+ export const RELAY_CAPTURE_SCROLLBACK_LINES = 2000;
690
+ /**
691
+ * tmux セッション {site} の画面を取得。失敗時 null。
692
+ *
693
+ * 【可視画面+履歴を取る理由(症状A/Bの根治)】
694
+ * 以前は `-p` のみ=可視画面1枚しか取らず、目印が画面外へ流れると失われた:
695
+ * A) claude箱マーカー(バナー)がスクロールアウト→送信スキップ。
696
+ * B) REPORT_START がスクロールアウト→報告抽出 null/DONE 取りこぼしで pending 固着。
697
+ * `-S -{N}` で直近 N 行の履歴も含めて取得し、流れた目印を拾えるようにする。
698
+ * 末尾基準の判定(looksIdle/isIdleStable)は取得テキストの末尾 RELAY_TAIL_LINES 行だけを
699
+ * slice するため、取得範囲を広げても idle 判定の挙動は不変(下端で従来どおり効く)。
700
+ */
701
+ export function tmuxCapture(site) {
702
+ try {
703
+ return execFileSync('tmux', ['capture-pane', '-t', site, '-p', '-S', `-${RELAY_CAPTURE_SCROLLBACK_LINES}`], {
704
+ stdio: ['ignore', 'pipe', 'ignore'],
705
+ encoding: 'utf8',
706
+ });
707
+ }
708
+ catch {
709
+ return null;
710
+ }
711
+ }
712
+ /**
713
+ * tmux へ本文を1メッセージとして送り、最後に Enter を1回だけ送る。
714
+ * - `--` で本文をオプション扱いさせない。
715
+ * - 引数配列で渡すのでシェルのエスケープは不要(日本語・記号もリテラルで渡る)。
716
+ * - 本文に生 Enter を含めない(buildSendText で改行を潰してある)。
717
+ * 成否を boolean で返す。
718
+ */
719
+ export function sendToTmux(site, text) {
720
+ try {
721
+ execFileSync('tmux', ['send-keys', '-t', site, '--', text], { stdio: 'ignore' });
722
+ execFileSync('tmux', ['send-keys', '-t', site, 'Enter'], { stdio: 'ignore' });
723
+ return true;
724
+ }
725
+ catch {
726
+ return false;
727
+ }
728
+ }
729
+ /**
730
+ * tmux セッションへ「単一キー」を送る(Enter は付けない)。
731
+ * 許可プロンプトの番号選択メニューは、数字キー単押しで該当肢を即選択して進む(Enter不要)。
732
+ * そのため ALLOW→"1" / DENY→"3" は send-keys でキーだけ送る。`--` も Enter も付けない。
733
+ * (sendToTmux は本文+Enter=1メッセージ送信用。選択肢キー送信とは用途が異なるので別関数にする。)
734
+ * 成否を boolean で返す(失敗は握って false=送らない側)。
735
+ */
736
+ export function sendKeyToTmux(site, key) {
737
+ try {
738
+ execFileSync('tmux', ['send-keys', '-t', site, key], { stdio: 'ignore' });
739
+ return true;
740
+ }
741
+ catch {
742
+ return false;
743
+ }
744
+ }
745
+ /**
746
+ * 二重チェックの「アイドル」判定。
747
+ * RELAY_SETTLE_MS あけて2回 capture し、両方で looksIdle が成立(❯入力待ち行あり+
748
+ * BUSY_MARKERS/スピナー不在)すれば idle とみなす。
749
+ * いずれかの capture が失敗、または looksIdle 不成立なら false(送らない側)。
750
+ *
751
+ * 【設計変更】以前は末尾40行の完全一致(tailOf(cap1) === tailOf(cap2))も要求していたが、
752
+ * アイドルでも入力枠のヒント文ローテーション等で末尾が微変動すると永久に静止と見なされず、
753
+ * 同一指示を毎tickスキップし続ける不具合の原因だった。
754
+ * 「2回連続でアイドルに見える」ことで十分保守的なので、末尾全文一致は要求しない。
755
+ */
756
+ export async function isIdleStable(site) {
757
+ const cap1 = tmuxCapture(site);
758
+ if (cap1 === null || !looksIdle(cap1))
759
+ return false;
760
+ await sleep(RELAY_SETTLE_MS);
761
+ const cap2 = tmuxCapture(site);
762
+ if (cap2 === null || !looksIdle(cap2))
763
+ return false;
764
+ return true;
765
+ }
766
+ // ───────────────────────────────────────────────────────────────────────────
767
+ // 中央API I/O
768
+ // ───────────────────────────────────────────────────────────────────────────
769
+ const now = () => new Date().toLocaleTimeString();
770
+ const log = (msg) => console.log(`[relay] ${now()} ${msg}`);
771
+ /**
772
+ * 共有シークレットを Bearer で付ける認可ヘッダを組み立てる(全API共通・コピペ増殖を避ける)。
773
+ * 鍵はコードに書かず cfg.secret(環境変数由来)からのみ受け取る。
774
+ */
775
+ function authHeaders(cfg) {
776
+ return { Authorization: `Bearer ${cfg.secret}` };
777
+ }
778
+ /**
779
+ * 中央の緊急停止フラグを取得。GET {url}/api/conductor/control(共有シークレット Bearer)。
780
+ *
781
+ * 【最重要・安全側のフェイルセーフ】
782
+ * 取得失敗(ネットワーク不通 / HTTP非2xx / 応答が不正)のときは relayHalted=true とみなす。
783
+ * 理由: 中央と通信できない時に勝手に送るのは危険。「通信できない=送らない」が安全。
784
+ * ok=false でその旨を呼び出し側へ伝え、ログを遷移時だけに絞れるようにする。
785
+ */
786
+ export async function fetchControl(cfg) {
787
+ try {
788
+ // ?site= を付けて per-site の relayEnabled も同時に取得(ADR-038)。site 無し応答とも後方互換。
789
+ const url = `${cfg.url}/api/conductor/control?site=${encodeURIComponent(cfg.site)}`;
790
+ const res = await fetch(url, { headers: authHeaders(cfg) });
791
+ if (!res.ok) {
792
+ return { relayHalted: true, ok: false, detail: `HTTP ${res.status}` };
793
+ }
794
+ const data = await res.json().catch(() => null);
795
+ const halted = data?.relayHalted;
796
+ if (typeof halted !== 'boolean') {
797
+ return { relayHalted: true, ok: false, detail: '応答が不正' };
798
+ }
799
+ // per-site 子トグル。boolean ならその値、欠落/不正は undefined=ON(稼働)フォールバック。
800
+ const enabledRaw = data?.relayEnabled;
801
+ const relayEnabled = typeof enabledRaw === 'boolean' ? enabledRaw : undefined;
802
+ return { relayHalted: halted, ok: true, detail: '', relayEnabled };
803
+ }
804
+ catch (err) {
805
+ return {
806
+ relayHalted: true,
807
+ ok: false,
808
+ detail: err instanceof Error ? err.message : String(err),
809
+ };
810
+ }
811
+ }
812
+ /**
813
+ * 自分宛(machine+site)の queued 指示を取得。先頭1件だけ返す(1現場同時1指示)。
814
+ * 空/エラー/不正は null。
815
+ */
816
+ export async function fetchCommand(cfg) {
817
+ const u = `${cfg.url}/api/conductor/commands` +
818
+ `?machine=${encodeURIComponent(cfg.machine)}&site=${encodeURIComponent(cfg.site)}`;
819
+ try {
820
+ const res = await fetch(u, { headers: authHeaders(cfg) });
821
+ if (!res.ok) {
822
+ log(`site=${cfg.site} commands取得 -> HTTP ${res.status} NG`);
823
+ return null;
824
+ }
825
+ const data = await res.json().catch(() => null);
826
+ const queued = Array.isArray(data)
827
+ ? data
828
+ : data?.queued ?? [];
829
+ if (!Array.isArray(queued) || queued.length === 0)
830
+ return null;
831
+ const first = queued[0];
832
+ if (!first || first.id == null || typeof first.body !== 'string') {
833
+ log(`site=${cfg.site} 指示の形式が不正のためスキップ`);
834
+ return null;
835
+ }
836
+ return { id: String(first.id), body: first.body };
837
+ }
838
+ catch (err) {
839
+ log(`site=${cfg.site} commands取得 -> 失敗: ${err instanceof Error ? err.message : String(err)}`);
840
+ return null;
841
+ }
842
+ }
843
+ /**
844
+ * 指示の状態を遷移させる。PATCH /api/conductor/commands/{id} body:{status, report?}。
845
+ * - report(任意): 抜き出せた CC2 の報告本文。done遷移と一緒に送る。中央が2万字で切り詰める。
846
+ * null/未指定なら report は送らず status のみ(従来どおり)。
847
+ * - 409(他が sent 中などの安全弁)→ false(送らない)。
848
+ * - 200 → true。
849
+ */
850
+ export async function patchStatus(cfg, id, status, report) {
851
+ try {
852
+ const payload = report != null ? { status, report } : { status };
853
+ const res = await fetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(id)}`, {
854
+ method: 'PATCH',
855
+ headers: {
856
+ ...authHeaders(cfg),
857
+ 'Content-Type': 'application/json',
858
+ },
859
+ body: JSON.stringify(payload),
860
+ });
861
+ if (res.status === 409) {
862
+ log(`site=${cfg.site} id=${id} ${status}遷移 -> 409 安全弁により中止`);
863
+ return false;
864
+ }
865
+ if (!res.ok) {
866
+ log(`site=${cfg.site} id=${id} ${status}遷移 -> HTTP ${res.status} NG`);
867
+ return false;
868
+ }
869
+ return true;
870
+ }
871
+ catch (err) {
872
+ log(`site=${cfg.site} id=${id} ${status}遷移 -> 失敗: ${err instanceof Error ? err.message : String(err)}`);
873
+ return false;
874
+ }
875
+ }
876
+ /**
877
+ * 指示 {id} 単体の現在 status を中央へ問う(外部完了検出の根治プローブ)。
878
+ * GET /api/conductor/commands/{id}(共有シークレット Bearer)。応答 JSON から status 文字列を拾う。
879
+ * - 応答形は `{ status }` / `{ command: { status } }` / 素の command オブジェクトのいずれも許容する。
880
+ * - status を確定的に読めた場合のみ小文字化して返す。それ以外(非2xx・404・タイムアウト・通信失敗・
881
+ * status 欠落/非文字列)はすべて null=「中央不達 or 未知」を意味し、呼び出し側は従来経路
882
+ * (detectExternalDone の副シグナル/pendingタイムアウト)へ無損失で縮退する。
883
+ * - 最善努力プローブなので失敗はログを出さず握りつぶす(delivered+アイドル毎tickで叩くため冗長ログ回避)。
884
+ *
885
+ * 【デプロイ順の安全性】このエンドポイントが中央(devlog-tracker)に未実装の間は 404/非2xx→null となり、
886
+ * relay は従来どおり動く(無害・無損失)。中央側 PR が入った時点で自動的に根治が効き始める。
887
+ */
888
+ export async function fetchCommandStatus(cfg, id) {
889
+ try {
890
+ const res = await fetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(id)}`, {
891
+ headers: authHeaders(cfg),
892
+ signal: AbortSignal.timeout(RELAY_STATUS_FETCH_TIMEOUT_MS),
893
+ });
894
+ if (!res.ok)
895
+ return null; // 404(未実装/purge済) 含め非2xx は未知=null で縮退。
896
+ const data = await res.json().catch(() => null);
897
+ if (data === null || typeof data !== 'object')
898
+ return null;
899
+ // `{status}` / `{command:{status}}` / 素の command のいずれからも status を拾う。
900
+ const top = data.status;
901
+ const nested = data.command?.status;
902
+ const raw = typeof top === 'string' ? top : typeof nested === 'string' ? nested : null;
903
+ return raw === null ? null : raw.trim().toLowerCase();
904
+ }
905
+ catch {
906
+ return null; // タイムアウト/通信失敗=未知。従来経路へ縮退(安全側)。
907
+ }
908
+ }
909
+ /**
910
+ * 生存ハートビートを中央へ送る(案①・調査 cmqbuch4l)。
911
+ * PATCH /api/conductor/commands/{id}/seen(共有シークレット Bearer)。
912
+ * 中央は where{id,status:'sent'} の指示にだけ lastSeenAt=now() を刻む(終端後は無視=冪等)。
913
+ *
914
+ * 【方針】これは「中央へ生存を知らせるだけ」の最善努力通知。失敗は致命ではないので patchStatus 同様
915
+ * 例外を外へ投げず、ここで握りつぶしてログのみ出す。中央不達時は lastSeenAt が更新されず、
916
+ * 中央側は従来どおり sentAt 基準(15分 TTL)へ無損失で縮退する(後方互換)。
917
+ */
918
+ export async function sendHeartbeat(cfg, id) {
919
+ try {
920
+ const res = await fetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(id)}/seen`, {
921
+ method: 'PATCH',
922
+ headers: authHeaders(cfg),
923
+ });
924
+ if (!res.ok) {
925
+ log(`site=${cfg.site} id=${id} heartbeat -> HTTP ${res.status} NG(次回再試行・縮退で安全)`);
926
+ }
927
+ }
928
+ catch (err) {
929
+ log(`site=${cfg.site} id=${id} heartbeat -> 失敗: ${err instanceof Error ? err.message : String(err)}(縮退で安全)`);
930
+ }
931
+ }
932
+ /**
933
+ * 自分宛(machine+site)の sent孤児を取得。GET /api/conductor/commands/orphans(共有シークレット Bearer)。
934
+ * - タイムアウト(RELAY_ORPHAN_FETCH_TIMEOUT_MS)を付け、devlog 到達不能でも起動がハングしない。
935
+ * - 空配列/エラー/未到達/不正応答はすべて [] を返す(起動を妨げないフェイルセーフ)。
936
+ * - sentAt は数値(ms)でも ISO 文字列でも受け、解釈不能なら現在時刻にフォールバック
937
+ * (= 次の再送まで delay 待ちになる安全側)。
938
+ */
939
+ export async function fetchOrphans(cfg) {
940
+ const u = `${cfg.url}/api/conductor/commands/orphans` +
941
+ `?machine=${encodeURIComponent(cfg.machine)}&site=${encodeURIComponent(cfg.site)}`;
942
+ try {
943
+ const res = await fetch(u, {
944
+ headers: authHeaders(cfg),
945
+ signal: AbortSignal.timeout(RELAY_ORPHAN_FETCH_TIMEOUT_MS),
946
+ });
947
+ if (!res.ok) {
948
+ log(`WARN site=${cfg.site} orphans取得 -> HTTP ${res.status}(起動は継続)`);
949
+ return [];
950
+ }
951
+ const data = await res.json().catch(() => null);
952
+ const arr = Array.isArray(data)
953
+ ? data
954
+ : data?.orphans ?? [];
955
+ if (!Array.isArray(arr))
956
+ return [];
957
+ const out = [];
958
+ for (const item of arr) {
959
+ const it = item;
960
+ if (!it || it.id == null || typeof it.body !== 'string')
961
+ continue;
962
+ const site = typeof it.site === 'string' ? it.site : cfg.site;
963
+ const sentAt = typeof it.sentAt === 'number'
964
+ ? it.sentAt
965
+ : typeof it.sentAt === 'string'
966
+ ? Date.parse(it.sentAt)
967
+ : NaN;
968
+ out.push({
969
+ id: String(it.id),
970
+ site,
971
+ body: it.body,
972
+ sentAt: Number.isFinite(sentAt) ? sentAt : Date.now(),
973
+ });
974
+ }
975
+ return out;
976
+ }
977
+ catch (err) {
978
+ // タイムアウト/ネットワーク不通も握りつぶす(起動を妨げない)。
979
+ log(`WARN site=${cfg.site} orphans取得 -> 失敗: ${err instanceof Error ? err.message : String(err)}(起動は継続)`);
980
+ return [];
981
+ }
982
+ }
983
+ /**
984
+ * sent孤児を pending へ復元する(2-b・起動後1回)。★ここでは送信しない=pendingに積むだけ。
985
+ * 実際の再送可否は次の relayTick の decideResend(着信済みなら再送しない等の冪等ガード)に委ねる。
986
+ * - 無効化(CONDUCTOR_RESTORE_ORPHANS=off)・取得空・pending占有中は何もしない。
987
+ * - フェイルセーフ: fetchOrphans が [] を返すケースは静かに通常起動を続ける。
988
+ */
989
+ export async function restoreOrphans(cfg, state) {
990
+ if (!RELAY_RESTORE_ORPHANS_ENABLED)
991
+ return;
992
+ const orphans = await fetchOrphans(cfg);
993
+ const pick = selectOrphanToRestore(orphans, state.pending);
994
+ if (pick === null) {
995
+ if (orphans.length > 0) {
996
+ log(`site=${cfg.site} orphan復元: pending占有中につきスキップ(孤児${orphans.length}件は次プロセスへ)`);
997
+ }
998
+ return;
999
+ }
1000
+ state.pending = orphanToPending(pick);
1001
+ log(`site=${cfg.site} id=${pick.id} を sent孤児から pending へ復元(★送信せず・次tickで2-a判定)`);
1002
+ }
1003
+ // ───────────────────────────────────────────────────────────────────────────
1004
+ // relay 本体(1ポーリング分)
1005
+ // ───────────────────────────────────────────────────────────────────────────
1006
+ /**
1007
+ * relay の1tick。CONDUCTOR_RELAY_INTERVAL_SEC ごとに呼ばれる想定。
1008
+ *
1009
+ * 流れ:
1010
+ * - pending(送信済みDONE待ち)があれば、完了マーカー検出だけ行う
1011
+ * (sent 中はサーバも新規指示を返さないので取得しない)。停止中でも継続する
1012
+ * (既に送った指示の後始末は進める)。
1013
+ * - 緊急停止フラグを確認。停止中なら新規の取得・送信は一切しない。
1014
+ * - pending が無く停止中でもなければ、取得 → アイドル判定 → sent遷移 → 送信。
1015
+ *
1016
+ * すべての判定は保守的(少しでも怪しければ送らない/次回再挑戦)。
1017
+ */
1018
+ export async function relayTick(cfg, state) {
1019
+ if (state.running)
1020
+ return; // 前回の tick が走行中ならスキップ
1021
+ state.running = true;
1022
+ try {
1023
+ // ── sent孤児の復元(2-b・起動後1回) ──
1024
+ // cli.ts を触らず「起動時1回」を実現するため、最初の tick でだけ復元する。
1025
+ // 失敗しても再試行しない(フラグを先に立てる=起動を妨げない/多重実行防止)。
1026
+ // ★復元は pending に積むだけ。再送可否は下の pending 分岐 = decideResend に一元化する。
1027
+ if (!state.restored) {
1028
+ state.restored = true;
1029
+ await restoreOrphans(cfg, state);
1030
+ }
1031
+ // ── 完了マーカー検出(pending があれば最優先・停止中でも継続) ──
1032
+ if (state.pending) {
1033
+ const cap = tmuxCapture(cfg.site);
1034
+ if (cap === null)
1035
+ return; // tmux未取得: 次回再確認
1036
+ // ── 着信確認 → 初めて sent 化(不具合B 根治・案: アンカー可視を確認してから sent) ──
1037
+ // 初回 send-keys 成功だけでは「送ったが職人画面に入っていない」(起動順レース/モーダル等)が
1038
+ // あり得るため、サーバ status は送信直後ではなく「アンカー(CONDUCTOR_JOB:{id})が画面に
1039
+ // エコーされた=確かに着信した」を確認してから 'sent' にする。それまでは queued 据置=
1040
+ // 管制で「送信済」と誤表示しない。delivered になるまでは下の自動再送が(未着信+アイドル時に)
1041
+ // 配信を担保する。DONE:{site} はアンカーより下に出る=着信確認はDONE検出より必ず先に成立する。
1042
+ if (!state.pending.delivered && cap.includes(state.pending.anchor)) {
1043
+ if (await patchStatus(cfg, state.pending.id, 'sent')) {
1044
+ state.pending.delivered = true;
1045
+ // DONE 待ちの上限時間は「着信=作業開始」時点から数え直す(送信〜着信の遅延で
1046
+ // 早期に pending タイムアウトしないように)。再送間隔基準の lastSentAt は触らない。
1047
+ state.pending.sentAt = Date.now();
1048
+ state.lastHeartbeatAt = null;
1049
+ log(`site=${cfg.site} id=${state.pending.id} 着信確認 → sent`);
1050
+ }
1051
+ // patchStatus 失敗時は delivered=false のまま(次tick再確認)。queued 据置で安全側。
1052
+ }
1053
+ // アンカー(今回送信の境界)より下だけを見て DONE:{site} 単独行を探す。
1054
+ // churn で古い DONE が押し出されても・過去ジョブの DONE が窓に残っても、誤検出/見逃しなし。
1055
+ const below = sliceBelowAnchor(cap, state.pending.anchor);
1056
+ if (below !== null && hasStandaloneDone(below, cfg.site)) {
1057
+ // 報告本文も「アンカーより下」から抜き出す(今回ジョブの報告に限定。過去報告の誤添付を防ぐ)。
1058
+ const report = extractReport(below);
1059
+ const ok = await patchStatus(cfg, state.pending.id, 'done', report);
1060
+ if (ok) {
1061
+ log(`site=${cfg.site} id=${state.pending.id} done${report != null ? '(報告添付)' : ''}`);
1062
+ state.pending = null;
1063
+ }
1064
+ return; // sent 中は新規取得しない
1065
+ }
1066
+ // ── 外部完了の検出(中央が真実・conductor_complete 等でdone化された案件の追跡解除・根治) ──
1067
+ // 職人が devlog MCP の conductor_complete で中央を直接done化すると、画面に DONE:{site} 行が
1068
+ // 出ないため上の画面スクレイプでは完了を永遠に検知できない(2026-07-04 実地バグ)。
1069
+ //
1070
+ // 【根治の核=完了の真実は中央にある】pending.id 単体の status を中央へ直接問い(fetchCommandStatus)、
1071
+ // done/failed 等の終端なら「次の queued の有無に関係なく」追跡解除する。これが真の根治:
1072
+ // 旧 detectExternalDone は「別の次指示が queued にある」ときしか救えず、キューが空だと
1073
+ // conductor_complete 済みでも 15分固着していた(実地: 完了の約8割が pendingタイムアウト固着)。
1074
+ // 【副シグナル温存=無損失縮退】中央 status が読めない(null=エンドポイント未実装/不達)ときだけ、
1075
+ // 従来の detectExternalDone(別idが配れる=終端の確証)へフォールバックする。中央側 status API が
1076
+ // 入るまではこの副シグナルが従来どおり効き、入った後は主シグナル(status)が確実に固着を断つ。
1077
+ // HTTP コストを抑えるため delivered+アイドルのときだけ probe する(busyな通常作業中は叩かない)。
1078
+ const idle = looksIdle(cap);
1079
+ if (state.pending.delivered && idle) {
1080
+ const centralStatus = await fetchCommandStatus(cfg, state.pending.id);
1081
+ if (isTerminalCommandStatus(centralStatus)) {
1082
+ log(`site=${cfg.site} id=${state.pending.id} は中央で終端(status=${centralStatus})を確認` +
1083
+ '(外部完了検出・キュー有無に依らず)→ 追跡解除し次tickで新規取得');
1084
+ state.pending = null;
1085
+ state.lastHeartbeatAt = null; // 次指示の生存HBは先頭から数え直す(前指示のスロットル引継ぎ防止)。
1086
+ return; // 次tickで halt確認→fetchCommand→アイドル判定→送信の通常経路を通す。
1087
+ }
1088
+ // 中央 status が未知(null)のときのみ、従来の副シグナル(別idが配信可能=終端の確証)で救う。
1089
+ // 中央が sent/queued 等の「生存中」を明示的に返した場合は解除しない(中央の真実を尊重)。
1090
+ if (centralStatus === null) {
1091
+ const next = await fetchCommand(cfg);
1092
+ const nextId = next?.id ?? null;
1093
+ if (detectExternalDone({
1094
+ delivered: state.pending.delivered,
1095
+ pendingId: state.pending.id,
1096
+ idle,
1097
+ nextCommandId: nextId,
1098
+ })) {
1099
+ log(`site=${cfg.site} id=${state.pending.id} は中央で終端済み(外部完了検出:` +
1100
+ `次指示 id=${nextId} が配信可能・status API不達時の副シグナル)→ 追跡解除し次tickで新規取得`);
1101
+ state.pending = null;
1102
+ state.lastHeartbeatAt = null;
1103
+ return; // 次tickで halt確認→fetchCommand→アイドル判定→送信の通常経路を通す。
1104
+ }
1105
+ }
1106
+ }
1107
+ // ── 生存ハートビート(案①・調査 cmqbuch4l) ──
1108
+ // 職人が確実に busy(esc to interrupt 等)なら「まだ作業中」を中央へ通知し lastSeenAt を更新する。
1109
+ // 中央はこれを生存基準(COALESCE(lastSeenAt,sentAt)>cutoff)に使い、長時間作業中の sent を
1110
+ // 15分 TTL で誤って failed 化しない。毎tick送らず RELAY_HEARTBEAT_INTERVAL_MS でスロットルする。
1111
+ // 送信失敗は握りつぶし(sendHeartbeat 内でログのみ)→中央不達時は従来 sentAt 基準へ無損失縮退。
1112
+ if (hasBusySignal(cap)) {
1113
+ const nowMs = Date.now();
1114
+ if (state.lastHeartbeatAt === null ||
1115
+ nowMs - state.lastHeartbeatAt >= RELAY_HEARTBEAT_INTERVAL_MS) {
1116
+ state.lastHeartbeatAt = nowMs;
1117
+ await sendHeartbeat(cfg, state.pending.id);
1118
+ }
1119
+ }
1120
+ // ── 生存中の自動再送(2-a)。DONE 未達のとき、未着信+アイドルなら同一指示を再送する ──
1121
+ // 冪等性最優先: 着信判定(anchor可視)は cap から即わかる。アイドルの二重チェック
1122
+ // (isIdleStable=1.5s sleep) は「未着信」のときだけ実行し、着信済みの通常経路では待たない。
1123
+ // 判定そのものは純粋関数 decideResend に委譲(着信済み/busy/非アイドル/間隔を一括で結論)。
1124
+ // 上限到達後は打ち切らず slow相 で低頻度に再配信を継続する(起動順レース根治)。
1125
+ //
1126
+ // ★delivered 永続ガード(不具合A・再送ループ根治): 一度「着信確認 → sent」で delivered=true に
1127
+ // なった指示は、以後ライブ画面で anchor が churn により窓(capture-pane の可視+scrollback)から
1128
+ // 押し出されて見えなくなっても、二度と自動再送しない。従来は decideResend(1) と呼出側の
1129
+ // delivered をいずれも「その場の cap.includes(anchor)」で毎tick再導出していたため、確定済みの
1130
+ // 着信が undelivered へ逆戻りして再送ループ化していた(職人へ同一 commandId が連続着信し、
1131
+ // 既に done でも送り続ける)。着信確認済みは永続フラグを唯一の真とし、完了検出は上の DONE
1132
+ // スクレイプ、固着回避は下の pending タイムアウトに委ねる。
1133
+ // delivered=false の間(初回着信前・起動順レース)は従来どおり未着信+アイドルで再送して配信を担保する。
1134
+ if (!state.pending.delivered) {
1135
+ const delivered = cap.includes(state.pending.anchor);
1136
+ const idleStable = delivered ? false : await isIdleStable(cfg.site);
1137
+ const decision = decideResend({
1138
+ capture: cap,
1139
+ anchor: state.pending.anchor,
1140
+ idleStable,
1141
+ sentAt: state.pending.sentAt,
1142
+ lastSentAt: state.pending.lastSentAt,
1143
+ resendCount: state.pending.resendCount,
1144
+ now: Date.now(),
1145
+ policy: {
1146
+ resendDelayMs: RELAY_RESEND_DELAY_MS,
1147
+ resendIntervalMs: RELAY_RESEND_INTERVAL_MS,
1148
+ maxResend: RELAY_RESEND_MAX,
1149
+ resendSlowIntervalMs: RELAY_RESEND_SLOW_INTERVAL_MS,
1150
+ },
1151
+ });
1152
+ if (decision.verdict === 'resend') {
1153
+ // ── 配達回数の絶対上限(保険・無限ループの構造的封じ込め) ──
1154
+ // slow相は打ち切らず継続する設計+pendingタイムアウト後の再取得で、職人窓が永久に
1155
+ // 未着信/未DONEだと事実上無限に再配達し得る。累計が上限を超えたらこの command を放棄し
1156
+ // 二度と配達しない(pendingを解除し abandoned へ。完了は職人の conductor_complete か
1157
+ // 画面DONE検出という別経路に委ねる)。
1158
+ if (!canDeliver(state, state.pending.id)) {
1159
+ if (!state.abandoned.has(state.pending.id)) {
1160
+ state.abandoned.add(state.pending.id);
1161
+ log(`WARN site=${cfg.site} id=${state.pending.id} 配達回数が上限(${RELAY_DELIVER_MAX})に到達。` +
1162
+ '再配達を放棄し pending を解除(無限ループの構造的封じ込め。' +
1163
+ '完了通知は職人の conductor_complete か画面DONE検出に委ねる)');
1164
+ }
1165
+ state.pending = null;
1166
+ return;
1167
+ }
1168
+ // 送信直前ガード(looksLikeClaude)も初回送信と同様に尊重し、非claude窓への誤爆を防ぐ。
1169
+ const guardCap = tmuxCapture(cfg.site);
1170
+ if (guardCap === null || !looksLikeClaude(guardCap)) {
1171
+ log(`WARN site=${cfg.site} id=${state.pending.id} 再送直前にClaude Codeの箱を` +
1172
+ '確認できず再送中止(次tick再判定)');
1173
+ return;
1174
+ }
1175
+ // fast相 / slow相 の別(このtickの送信が低頻度再配信フェーズか)。回数++前に判定する。
1176
+ const slow = state.pending.resendCount >= RELAY_RESEND_MAX;
1177
+ // id 不変=anchor 不変なので、初回と同一の送信テキストを再生成する。
1178
+ const text = buildSendText(state.pending.body, cfg.site, state.pending.anchor);
1179
+ if (!sendToTmux(cfg.site, text)) {
1180
+ log(`再送 send-keys failed, keep pending: ${cfg.site}/${state.pending.id}`);
1181
+ return;
1182
+ }
1183
+ state.pending.resendCount += 1;
1184
+ state.pending.lastSentAt = Date.now();
1185
+ // 累計配達回数を通算(pending 入れ替えをまたいで id 単位に数える=絶対上限の基準)。
1186
+ markDelivered(state, state.pending.id);
1187
+ // fast相→slow相への移行ログは一度だけ(多重ログ防止)。打ち切りはせず低頻度で継続する旨を記す。
1188
+ if (slow && !state.pending.resendExhaustedWarned) {
1189
+ state.pending.resendExhaustedWarned = true;
1190
+ log(`site=${cfg.site} id=${state.pending.id} fast再送上限(${RELAY_RESEND_MAX})到達。` +
1191
+ `以後は ${Math.round(RELAY_RESEND_SLOW_INTERVAL_MS / 1000)}s 間隔で低頻度に再配信を継続` +
1192
+ '(職人が後から起動しても届くようにする・打ち切りは pending タイムアウトに委ねる)');
1193
+ }
1194
+ log(`site=${cfg.site} id=${state.pending.id} 自動再送 #${state.pending.resendCount}` +
1195
+ `(${slow ? '低頻度(slow)' : '未着信+アイドル'}: ${decision.reason})`);
1196
+ return; // sent 中は新規取得しない
1197
+ }
1198
+ // 'wait' は再送せずタイムアウト判定へ進む(decideResend は 'stop' を返さない=低頻度継続)。
1199
+ }
1200
+ // DONE 未達のまま上限時間を超えたら、固着回避のため relay 内部で pending を解除して次へ進む。
1201
+ // (CC2 の異常終了等で DONE が永遠に来ないケースの根治。サーバ status は戻さない=relay内部のみ。)
1202
+ const elapsedMs = Date.now() - state.pending.sentAt;
1203
+ if (elapsedMs > RELAY_PENDING_TIMEOUT_MS) {
1204
+ log(`WARN site=${cfg.site} id=${state.pending.id} が pending タイムアウト` +
1205
+ `(${Math.round(elapsedMs / 1000)}s)。DONE未達のため解除し次へ進む`);
1206
+ state.pending = null;
1207
+ }
1208
+ return; // sent 中は新規取得しない
1209
+ }
1210
+ // ── 停止フラグ確認(新規の取得・送信のみ止める) ──
1211
+ // 停止条件は3つ: ①通信不能(安全側)②グローバル緊急停止 relayHalted(後方互換)
1212
+ // ③この現場の子トグル relayEnabled=false(ADR-038)。relayEnabled が undefined(未取得)は
1213
+ // ON(稼働)扱いでフォールバックする(停止しない)。ログは状態が変わった時だけ出す。
1214
+ const ctl = await fetchControl(cfg);
1215
+ const haltedBySite = ctl.relayEnabled === false;
1216
+ if (ctl.relayHalted || haltedBySite) {
1217
+ if (state.lastHalted !== true) {
1218
+ log(!ctl.ok
1219
+ ? `中央と通信できないため安全側で送信を停止します(${ctl.detail})`
1220
+ : haltedBySite
1221
+ ? `この現場(${cfg.site})の relay送信トグルがOFF(停止)のため送信しません`
1222
+ : 'relay停止中(緊急停止スイッチON)のため送信しません');
1223
+ }
1224
+ state.lastHalted = true;
1225
+ return;
1226
+ }
1227
+ if (state.lastHalted === true) {
1228
+ log('停止が解除されました。relayを再開します');
1229
+ }
1230
+ state.lastHalted = false;
1231
+ // ── 新規指示の取得 ──
1232
+ const cmd = await fetchCommand(cfg);
1233
+ if (!cmd)
1234
+ return; // 指示なし
1235
+ // ── 配達放棄ガード(無限ループの構造的封じ込め) ──
1236
+ // 配達回数上限を超えて放棄した command は、サーバがまだ queued を返しても(=完了通知が
1237
+ // 単線で切れて詰まった状態でも)二度と配達しない。放棄時に既にWARNを出しているので静かにスキップ。
1238
+ if (state.abandoned.has(cmd.id)) {
1239
+ return;
1240
+ }
1241
+ // ── アイドル判定(保守的・二重チェック) ──
1242
+ if (!tmuxHasSession(cfg.site)) {
1243
+ log(`site=${cfg.site} id=${cmd.id} tmux未起動のため送らずスキップ`);
1244
+ return;
1245
+ }
1246
+ if (!(await isIdleStable(cfg.site))) {
1247
+ // 連続スキップ回数を可観測化(観測のみ・送信挙動は変えない)。
1248
+ // 別idに変わっていたら連続をリセットしてから数え直す。
1249
+ if (state.lastSkippedId !== cmd.id) {
1250
+ state.lastSkippedId = cmd.id;
1251
+ state.skipCount = 0;
1252
+ state.skipWarned = false;
1253
+ }
1254
+ state.skipCount += 1;
1255
+ log(`site=${cfg.site} id=${cmd.id} 作業中の可能性ありスキップ(次回再判定)`);
1256
+ if (state.skipCount > RELAY_SKIP_WARN_THRESHOLD && !state.skipWarned) {
1257
+ state.skipWarned = true;
1258
+ log(`WARN site=${cfg.site} id=${cmd.id} が${state.skipCount}回連続スキップ。` +
1259
+ `アイドル誤判定の疑い`);
1260
+ }
1261
+ return;
1262
+ }
1263
+ // ここまで来たらアイドル判定が通った=この id の連続スキップは解消。
1264
+ state.lastSkippedId = null;
1265
+ state.skipCount = 0;
1266
+ state.skipWarned = false;
1267
+ // ── 内容ベースのガード(送信直前・最終確認) ──
1268
+ // 名前一致+❯プロンプトだけでは fish/starship 等 ❯ を使う非claude窓に誤爆し得るため、
1269
+ // 送信直前に capture し直し、Claude Code 固有マーカーが確認できる時だけ送る。
1270
+ // 確認できなければ sent 遷移もせずスキップ+WARN(サーバ status は queued のまま温存)。
1271
+ const guardCap = tmuxCapture(cfg.site);
1272
+ if (guardCap === null || !looksLikeClaude(guardCap)) {
1273
+ log(`WARN site=${cfg.site} id=${cmd.id} Claude Codeの箱と確認できないため送信せずスキップ` +
1274
+ '(名前一致でも非claude窓への誤爆を防止)');
1275
+ return;
1276
+ }
1277
+ // ── tmux 送信 ──
1278
+ // この送信に固有のアンカー(buildAnchor(id))を本文末尾に埋め込む。
1279
+ // 画面にエコーされたアンカー行を「今回ジョブの境界」として記録し、完了検出は
1280
+ // アンカーより下=今回ジョブの出力だけを見る(churn・過去ジョブ DONE に強い)。
1281
+ const anchor = buildAnchor(cmd.id);
1282
+ const text = buildSendText(cmd.body, cfg.site, anchor);
1283
+ if (!sendToTmux(cfg.site, text)) {
1284
+ // 送信失敗(sendToTmux は内部で例外も握って false を返す)。status は queued のまま据え置く。
1285
+ log(`send-keys failed, keep queued: ${cfg.site}/${cmd.id}`);
1286
+ return;
1287
+ }
1288
+ // 送信成功 → pending を立てて DONE を待つ。★ただし sent 化はここではしない(不具合B 根治)。
1289
+ // send-keys 成功=tmuxへキー送出に成功、だけでは「職人画面に本文が入った」保証にならない
1290
+ // (起動順レースで relay が職人より先に走り旧/未準備セッションへ送る等)。そのため status は
1291
+ // queued 据置のまま delivered=false で開始し、次tick以降にアンカーが画面へエコーされた=
1292
+ // 確かに着信した、を確認してから patchStatus('sent') する(上の delivered 分岐)。
1293
+ // 着信するまでは decideResend が(未着信+アイドル時に)fast→slow で再配信を継続する。
1294
+ const sentAt = Date.now();
1295
+ state.pending = {
1296
+ id: cmd.id,
1297
+ site: cfg.site,
1298
+ sentAt,
1299
+ anchor,
1300
+ body: cmd.body,
1301
+ resendCount: 0,
1302
+ lastSentAt: sentAt,
1303
+ resendExhaustedWarned: false,
1304
+ delivered: false,
1305
+ };
1306
+ // 累計配達回数を通算(初回送信も1件として数える=絶対上限 RELAY_DELIVER_MAX の基準)。
1307
+ markDelivered(state, cmd.id);
1308
+ // 新しい指示の生存ハートビートは先頭から数え直す(前指示のスロットルを引き継がない)。
1309
+ state.lastHeartbeatAt = null;
1310
+ log(`site=${cfg.site} id=${cmd.id} 送信(着信確認待ち・queued据置)`);
1311
+ }
1312
+ finally {
1313
+ state.running = false;
1314
+ }
1315
+ }