@tyhld/conductor 0.3.0 → 0.7.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.
Files changed (46) hide show
  1. package/README.md +301 -19
  2. package/dist/cli.js +143 -18
  3. package/dist/ear-routing.js +57 -0
  4. package/dist/ear.js +57 -0
  5. package/dist/env-file-perm.js +67 -0
  6. package/dist/nudge.js +172 -0
  7. package/dist/realtime-parse.js +116 -0
  8. package/dist/realtime.js +251 -0
  9. package/dist/relay-runner.js +65 -0
  10. package/dist/relay.js +911 -134
  11. package/dist/websocket-transport.js +66 -0
  12. package/launchd/ear-install.sh +96 -0
  13. package/package.json +30 -1
  14. package/sales-template/README.md +185 -25
  15. package/sales-template/install.sh +890 -0
  16. package/sales-template/launchd/install.sh +151 -0
  17. package/sales-template/settings.json +26 -97
  18. package/sales-template/setup.sh +754 -117
  19. package/sales-template/systemd/README.md +28 -4
  20. package/sales-template/systemd/install.sh +54 -5
  21. package/sales-template/systemd/paste-cache-prune-install.sh +75 -0
  22. package/sales-template/systemd/tyhld-paste-cache-prune.service +25 -0
  23. package/sales-template/systemd/tyhld-paste-cache-prune.timer +19 -0
  24. package/sales-template/uninstall.sh +245 -0
  25. package/scripts/hooks/README.md +246 -0
  26. package/scripts/hooks/cc2_guard.py +145 -0
  27. package/scripts/hooks/codex-hooks.sample.json +58 -0
  28. package/scripts/hooks/hook_datalink.py +440 -0
  29. package/scripts/hooks/install-codex-hooks.sh +127 -0
  30. package/scripts/hooks/notification_hook.py +167 -0
  31. package/scripts/hooks/permission_request_hook.py +207 -0
  32. package/scripts/hooks/policy.py +759 -0
  33. package/scripts/hooks/settings.sample.json +142 -0
  34. package/scripts/hooks/stop_hook.py +275 -0
  35. package/scripts/hooks/summary_ja.py +155 -0
  36. package/scripts/hooks/test_hook_datalink.py +282 -0
  37. package/scripts/hooks/test_policy.py +1241 -0
  38. package/skills/conductor-craftsman/SKILL.md +40 -0
  39. package/systemd/conductor-ear.service +63 -0
  40. package/systemd/conductor@.service +62 -0
  41. package/systemd/ear-install.sh +131 -0
  42. package/systemd/guard-sync-install.sh +94 -0
  43. package/systemd/tyhld-guard-sync.service +28 -0
  44. package/systemd/tyhld-guard-sync.timer +25 -0
  45. package/sales-template/cc2_guard.py +0 -395
  46. package/sales-template/systemd/conductor@.service +0 -47
package/dist/relay.js CHANGED
@@ -9,10 +9,23 @@
9
9
  * これを絶対に回避するため、判定はすべて保守的(少しでも作業中っぽければ送らない)。
10
10
  * 送り損ねは次ポーリングで再挑戦できるので無害。「誤爆より送り遅れ」。
11
11
  *
12
- * 既存の heartbeat 機能・送信先API・認可(共有シークレット Bearer)は変更しない。足すだけ。
12
+ * 既存の heartbeat 機能・送信先API・認可(テナントトークン Bearer)は変更しない。足すだけ。
13
13
  * 鍵はコードに書かず、必ず環境変数経由で受け取る。
14
+ *
15
+ * 【役割(段3で「配達係」に絞った)】
16
+ * relay がするのは「指示を職人へ届け、終わったことを中央へ書き戻す」ことだけ。
17
+ * ① 指示の取得 ② 本文の投入 ③ アイドル判定 ④ Claude箱の確認 ⑤ tmux在否
18
+ * ⑦⑧ 完了検出 ⑪ 生存報告 ⑰〜㉑ 再送・中央照合・孤児復元 ㉒ 緊急停止・現場トグル ㉓ 適応ポーリング
19
+ * ★職人の画面から「質問の中身」を読んで意味を解釈すること、職人の端末でキーを押すことはしない。
20
+ * 許可の確認と応答は PermissionRequest フック(scripts/hooks/permission_request_hook.py)が
21
+ * データ経路(POST /api/conductor/hook-requests)で担う。画面スクレイプに依存しない。
22
+ * 唯一の例外は「選択メニューが出ているか」の真偽(hasChoiceMenu)で、これは③アイドル判定が
23
+ * メニューへ指示を流し込まないための落とし穴ガード専用。中身は読まない。
14
24
  */
15
25
  import { execFileSync } from 'node:child_process';
26
+ import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
27
+ import os from 'node:os';
28
+ import path from 'node:path';
16
29
  // ───────────────────────────────────────────────────────────────────────────
17
30
  // 調整可能な定数(後でチューニングできるよう、ここに集約する)
18
31
  // ───────────────────────────────────────────────────────────────────────────
@@ -43,6 +56,25 @@ function envPositiveNumber(name, def) {
43
56
  const parsed = Number(raw);
44
57
  return Number.isFinite(parsed) && parsed > 0 ? parsed : def;
45
58
  }
59
+ /**
60
+ * 生存の見張り(keepalive)を打ち切るまでの上限(ms)。既定30分=中央のタイムアウトと同じ長さ。
61
+ *
62
+ * 【なぜ pending とは別の寿命が要るのか(調査 176aebcf・案①-a)】
63
+ * 上の RELAY_PENDING_TIMEOUT_MS は「配達の追跡」を諦める時計で、固着して次の指示を拾えなく
64
+ * なるのを防ぐ役目がある(=短くてよい)。ところが従来はこれを過ぎると生存通知(/seen)まで
65
+ * 一緒に止まっていた。生存通知を送るのは relay だけなので、以後その指示には二度と通知が
66
+ * 飛ばず、中央では sent →(5分)→ stalled →(30分)→ timeout と一方通行に落ちる。職人が実際には
67
+ * 作業中でも「返事がありません」のまま戻れない(2026-09-01 実地・指示 db46b24f)。
68
+ * ★中央側に戻り道はある(/seen の body 無し通知が stalled → sent へ戻す)。送り手が消えるのが穴。
69
+ *
70
+ * そこで「配達の追跡(pending)」と「生存の見張り(keepalive)」の寿命を分ける。pending は従来
71
+ * どおり15分で解除して次の指示を拾えるようにし、生存通知だけをこの時計で続ける。
72
+ *
73
+ * 【なぜ30分か】中央が sent/stalled を timeout(=諦めて現場を解放)に落とすのと同じ長さ。
74
+ * これより長く見張っても、中央は既に別の指示を配れる状態になっており意味がない。
75
+ * 環境変数 RELAY_KEEPALIVE_MAX_MS で上書き可(正の数のみ)。
76
+ */
77
+ export const RELAY_KEEPALIVE_MAX_MS = envPositiveNumber('RELAY_KEEPALIVE_MAX_MS', 30 * 60 * 1000);
46
78
  /**
47
79
  * 自動再送(2-a)のポリシー。生存中の職人が動かないときに同一指示を再送する。
48
80
  * - RELAY_RESEND_DELAY_MS: 初回送信(sentAt)から最初の再送までの待機(ms)。既定30秒。
@@ -130,12 +162,17 @@ export const BUSY_GLYPHS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '
130
162
  */
131
163
  export const REPORT_START = 'REPORT_START';
132
164
  export const REPORT_END = 'REPORT_END';
165
+ /** 設定から職人の種類を読む。未指定・知らない値は 'claude'(後退させない)。 */
166
+ export function kindOf(cfg) {
167
+ return cfg.agentKind ?? 'claude';
168
+ }
133
169
  /** 連続スキップ何回でアイドル誤判定の疑いをWARNするか(観測のみ・送信挙動は変えない)。 */
134
170
  export const RELAY_SKIP_WARN_THRESHOLD = 6;
135
171
  /** 初期状態を作る。 */
136
172
  export function createRelayState() {
137
173
  return {
138
174
  pending: null,
175
+ keepalive: null,
139
176
  running: false,
140
177
  restored: false,
141
178
  lastHalted: null,
@@ -145,6 +182,8 @@ export function createRelayState() {
145
182
  lastHeartbeatAt: null,
146
183
  deliverCounts: new Map(),
147
184
  abandoned: new Set(),
185
+ promptPending: false,
186
+ busyRaisedId: null,
148
187
  };
149
188
  }
150
189
  /**
@@ -175,21 +214,103 @@ export function tailOf(text) {
175
214
  return lines.slice(-RELAY_TAIL_LINES).join('\n');
176
215
  }
177
216
  /**
178
- * 画面末尾に「確実な処理中シグナル(esc to interrupt / compacting / スピナーグリフ)」が
179
- * あるかを判定する純粋関数。末尾 RELAY_TAIL_LINES 行だけを見る(looksIdle と同じ範囲)。
180
- * - busy 判定を looksIdle から切り出して再利用可能にしたもの(自動再送の可否判定でも使う)。
181
- * - looksIdle はこれが true なら即アイドル不成立に倒す。
217
+ * 入力枠の横罫線(全幅 ルール、または角丸コーナー ╭╰╮╯)とみなす行か。
218
+ * looksLikeClaude の枠検出(BOX_RULE_MIN_DASHES)と同じ定義。Claude Code v2.x は
219
+ * idle/busy とも入力枠の横罫線を最下部に常時描くので、これを「フッター境界」の基準に使う。
182
220
  */
183
- export function hasBusySignal(text) {
221
+ function isBoxRuleLine(line) {
222
+ return /[╭╰]─{2,}|─{2,}[╮╯]/.test(line) || new RegExp(`─{${BOX_RULE_MIN_DASHES},}`).test(line);
223
+ }
224
+ /**
225
+ * busy シグナルの走査対象=「入力枠フッター領域(=最後の横罫線より下)」だけを文字列で返す純粋関数。
226
+ *
227
+ * 【なぜフッターだけに絞るか(恒久修正の核心・販売ブロッカー根治)】
228
+ * 以前は末尾 RELAY_TAIL_LINES 行を"全文"走査していたため、Claude Code の会話ログ本文(トランス
229
+ * クリプト)に 'esc to interrupt' 等の busy 相当語(例: relay 自身の話題・ログ貼り付け・エラー文)や
230
+ * スピナー点字が含まれると、tmux が真にアイドルでも永久 busy 誤判定になり、認証200・ジョブ待機でも
231
+ * 指示が一生注入されなかった。顧客環境で必ず再発する販売ブロッカー。
232
+ * Claude Code v2.x は idle/busy とも入力枠の横罫線を最下部に常時描くので、その"最後の罫線より下"
233
+ * (=ライブのフッター行: 'esc to interrupt'(busy) / '? for shortcuts'(idle) / 権限モード行)
234
+ * だけを busy 走査対象にすれば、上のトランスクリプト本文は判定を汚さない(バージョン非依存の恒久要素)。
235
+ * 罫線が見つからない場合(Claude の枠でない/取得不全)は末尾全域を返す=従来の保守挙動(送らない側)を温存。
236
+ */
237
+ export function busyScanRegion(text) {
184
238
  const lines = text.replace(/\r/g, '').split('\n');
185
239
  while (lines.length > 0 && lines[lines.length - 1].trim() === '')
186
240
  lines.pop();
187
241
  const tail = lines.slice(-RELAY_TAIL_LINES);
188
- const tailText = tail.join('\n');
189
- const lower = tailText.toLowerCase();
242
+ let ruleIdx = -1;
243
+ for (let i = tail.length - 1; i >= 0; i--) {
244
+ if (isBoxRuleLine(tail[i])) {
245
+ ruleIdx = i;
246
+ break;
247
+ }
248
+ }
249
+ // 最後の罫線より下=フッター行だけを走査対象にする。罫線が無ければ末尾全域(従来の保守挙動を温存)。
250
+ const region = ruleIdx >= 0 ? tail.slice(ruleIdx + 1) : tail;
251
+ return region.join('\n');
252
+ }
253
+ /**
254
+ * 画面に「確実な処理中シグナル(esc to interrupt / compacting / スピナーグリフ)」が
255
+ * あるかを判定する純粋関数。
256
+ * - 走査対象は busyScanRegion=入力枠フッター領域だけ(最後の横罫線より下)。トランスクリプト
257
+ * 本文の busy 相当語で永久 busy 誤判定にならないための恒久修正。判別軸は v2.x 常時描画のフッター
258
+ * 行('esc to interrupt' を含む→busy / '? for shortcuts' を含む→idle)。
259
+ * - busy 判定を looksIdle から切り出して再利用可能にしたもの(自動再送の可否判定でも使う)。
260
+ * - looksIdle はこれが true なら即アイドル不成立に倒す。
261
+ */
262
+ export function hasBusySignal(text) {
263
+ const regionText = busyScanRegion(text);
264
+ const lower = regionText.toLowerCase();
190
265
  if (BUSY_MARKERS.some((m) => lower.includes(m)))
191
266
  return true;
192
- if (BUSY_GLYPHS.some((g) => tailText.includes(g)))
267
+ if (BUSY_GLYPHS.some((g) => regionText.includes(g)))
268
+ return true;
269
+ return false;
270
+ }
271
+ /** 環境変数などから種類を読む。知らない値・未設定は 'claude' へ倒す(後退させない)。 */
272
+ export function parseAgentKind(raw) {
273
+ return (raw ?? '').trim().toLowerCase() === 'codex' ? 'codex' : 'claude';
274
+ }
275
+ /** Codex 固有の画面印(実測)。この窓が Codex の職人かを見分ける。 */
276
+ export const CODEX_MARKERS = [
277
+ 'openai codex',
278
+ 'ask codex to do anything',
279
+ 'esc to interrupt',
280
+ ];
281
+ /** Codex の作業中シグナル(実測)。`esc to interrupt` は Claude と同じ綴り。 */
282
+ export const CODEX_BUSY_MARKERS = ['esc to interrupt', 'working ('];
283
+ /** Codex の選択メニューのカーソル(実測: U+203A)。★Claude の `❯`(U+276F) とは別物。 */
284
+ export const CODEX_MENU_CURSOR = '›';
285
+ /**
286
+ * 種類ごとの目印を返す。★関数にしてあるのは、定数の宣言順に縛られないようにするため
287
+ * (CLAUDE_MARKERS などはこの下で宣言される)。
288
+ */
289
+ export function agentProfile(kind) {
290
+ if (kind === 'codex') {
291
+ return {
292
+ markers: CODEX_MARKERS,
293
+ busyMarkers: CODEX_BUSY_MARKERS,
294
+ cursor: CODEX_MENU_CURSOR,
295
+ idleRule: 'not-busy',
296
+ };
297
+ }
298
+ return {
299
+ markers: CLAUDE_MARKERS,
300
+ busyMarkers: BUSY_MARKERS,
301
+ cursor: '❯',
302
+ idleRule: 'prompt',
303
+ };
304
+ }
305
+ /** 種類を見て「作業中か」を判定する。claude は従来の hasBusySignal と同一。 */
306
+ export function hasBusySignalFor(text, kind) {
307
+ if (kind === 'claude')
308
+ return hasBusySignal(text);
309
+ const regionText = busyScanRegion(text);
310
+ const lower = regionText.toLowerCase();
311
+ if (agentProfile(kind).busyMarkers.some((m) => lower.includes(m)))
312
+ return true;
313
+ if (BUSY_GLYPHS.some((g) => regionText.includes(g)))
193
314
  return true;
194
315
  return false;
195
316
  }
@@ -197,6 +318,8 @@ export function hasBusySignal(text) {
197
318
  * 画面テキストから「アイドルか」を判定する純粋関数(判定(a))。
198
319
  * 方針:「確実な処理中シグナル(esc to interrupt / compacting / スピナーグリフ)が無く、
199
320
  * かつ末尾付近に入力待ちプロンプト行がある」ときだけアイドルとみなす。一般語では判定しない。
321
+ * busy シグナルの走査は入力枠フッター領域だけ(hasBusySignal→busyScanRegion)なので、会話ログ
322
+ * 本文(トランスクリプト)に busy 相当語が混ざっても永久 busy 誤判定にならない。
200
323
  * 週替わりヒントやバナーが上にあっても、末尾に入力待ちがあればアイドルと判定できる。
201
324
  * 曖昧なら false(=送らない側)に倒す。
202
325
  */
@@ -208,12 +331,13 @@ export function looksIdle(text) {
208
331
  // 確実な処理中シグナルがあれば即アウト(作業中とみなす)。
209
332
  if (hasBusySignal(text))
210
333
  return false;
211
- // 許可プロンプト表示中はアイドルとみなさない(落とし穴ガード)。
212
- // Claude Code の許可プロンプト("❯ 1. Yes" 等)も `❯` で始まる行を含むため、下の hasPrompt が
213
- // true になりアイドル誤判定し得る。許可プロンプト中に relay の pending ループが新規指示や再送を
214
- // 流し込むと、選択メニューに指示文が打ち込まれて混線する。許可プロンプト中は必ず非アイドルに倒し、
215
- // 応答は専用の許可ループ(permission.ts)にのみ委ねる(送信経路の唯一化=CC2書き込みの排他)。
216
- if (hasPermissionPrompt(text).isPrompt)
334
+ // 選択メニュー表示中はアイドルとみなさない(落とし穴ガード・★撤去してはいけない要)。
335
+ // Claude Code の確認メニュー("❯ 1. Yes" 等)も `❯` で始まる行を含むため、下の hasPrompt が
336
+ // true になりアイドル誤判定し得る。メニュー表示中に relay の pending ループが新規指示や再送を
337
+ // 流し込むと、選択メニューに指示文が打ち込まれて混線する(=作業への割り込み事故)。
338
+ // ★段3で「質問の中身を読む処理」は全部撤去したが、この真偽ガードだけは残す(残す理由は
339
+ // hasChoiceMenu のコメント参照)。答えるのはフック経路の役目で、relay は一切押さない。
340
+ if (hasChoiceMenu(text))
217
341
  return false;
218
342
  // 入力待ちプロンプト行の検出。
219
343
  // tmux のボックス装飾(│ | 空白)を行頭から剥がし、残りが入力待ちプロンプト記号で始まれば入力待ち。
@@ -227,19 +351,48 @@ export function looksIdle(text) {
227
351
  });
228
352
  return hasPrompt;
229
353
  }
354
+ // ───────────────────────────────────────────────────────────────────────────
355
+ // 選択メニュー検知(★アイドル判定(③)の落とし穴ガード専用・純粋関数)
356
+ //
357
+ // 【段3で何を撤去したか】ここには以前「許可プロンプトの検知と中身の読み取り」一式があった:
358
+ // hasPermissionPrompt(isPrompt/command/kind/summary/noKey/choices)、extractPermSummary、
359
+ // hasDismissablePrompt、および質問文・選択肢・拒否肢番号を読むための正規表現群。
360
+ // 確認の受け渡しは PermissionRequest フック(scripts/hooks/permission_request_hook.py)が
361
+ // データ経路で担うようになったため、画面から文字を読む部分はすべて撤去した。
362
+ //
363
+ // 【なぜ「メニューが出ているか」の真偽だけは残すか】撤去できない安全上の理由がある。
364
+ // Claude Code の選択メニューは "❯ 1. Yes" のように `❯` で始まる行を含むため、これを
365
+ // 除外しないと looksIdle が「入力待ち」と誤判定する。アイドル誤判定は relay が選択メニューへ
366
+ // 指示文を流し込む=職人の作業に割り込んで混線させる事故に直結する(③アイドル判定の要)。
367
+ // よって「メニューが出ている」という【構造の真偽】だけを残し、中身(質問文・説明・選択肢・
368
+ // 拒否肢番号・種別)は一切読まない。
369
+ // ───────────────────────────────────────────────────────────────────────────
370
+ /**
371
+ * 確認プロンプトの「見出し(質問)行」に出る綴り。メニュー構造だけでは確定させない
372
+ * 2シグナル方式の片側(もう片側は ❯ カーソル)。★見出しの意味は解釈せず、有無だけを見る。
373
+ */
374
+ const PERMISSION_HEADER_RE = /do you want to \S+|do you trust|would you like to|ready to (code|submit)|requires approval|needs? your approval|approval for the|wants to (run|use|access|search|edit|create|make|execute|connect)|allow\b.{0,40}\baccess/i;
375
+ /**
376
+ * 番号選択メニューの「カーソル付き」選択肢行(例 "❯ 1. Yes" / "❯ 2. …")。
377
+ * ★U+276F の `❯` が番号選択肢の行頭に付くのは Claude Code の対話メニュー固有で、通常の
378
+ * markdown 箇条書き("1. …")や地の文には出ない。これを「これは選ばせる画面だ」の構造シグナル
379
+ * として使う=見出しの言い回しに依存せず、あらゆる種類の確認(proc/plan/trust/Notebook…)を拾える。
380
+ * ※アイドルの素の入力枠 "❯ "(番号なし)はここに一致しない(番号+区切りが必須)。
381
+ */
382
+ const PERMISSION_MENU_CURSOR_RE = /^❯\s*\d+[.)]/;
383
+ /** フッター/取り消し案内など、メニューの一部ではない行(メニュー収集時に読み飛ばす)。 */
384
+ const PERMISSION_MENU_FOOTER_RE = /esc to cancel|tab to amend|for shortcuts|for agents|↑\/↓|to navigate/i;
385
+ /**
386
+ * Codex のメニューに付く案内行(実測: `Press enter to continue`)。
387
+ * ★Claude 側の一覧(PERMISSION_MENU_FOOTER_RE)は1語も変えない。ここは Codex にだけ足す(ADR-021)。
388
+ */
389
+ const CODEX_MENU_FOOTER_RE = /press enter to continue/i;
230
390
  /**
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;
391
+ * 番号付き選択肢行(例 "❯ 1. Yes" / "2. Yes, and don’t ask again…" / "3. No, … (esc)")。
392
+ * 行頭・番号・区切り記号を必須にしているので、文章中の数字では成立しない。
393
+ * 1 群目=番号キー。★文言(2 群目)は使わない=画面から意味を読まない。
394
+ */
395
+ const PERMISSION_CHOICE_RE = /^❯?\s*(\d+)[.)]\s*(?:\S.*?)\s*$/;
243
396
  /** tmux のボックス装飾(│ ╎ ┃ | と前後空白)を行から剥がす。 */
244
397
  function stripBoxDecoration(line) {
245
398
  return line
@@ -248,72 +401,80 @@ function stripBoxDecoration(line) {
248
401
  .trim();
249
402
  }
250
403
  /**
251
- * 画面テキストに「許可プロンプト(Do you want to ... + 1. Yes 選択肢)」が出ているかを判定する純粋関数。
252
- * - 末尾付近のみ見る(許可プロンプトは常に画面下部に描画される)。枠が高いことがあるので少し広めに取る。
253
- * - 質問行と "1. Yes" 選択肢行の両方が在るときだけ isPrompt=true(誤検出をしない最優先)。
254
- * - command は枠上辺(╭)〜質問行の内容行を best-effort で結合(抽出不能でも検知自体は成立)。
255
- * - kind は質問文の語から network / command を推定(不明は 'command' に寄せる)。
404
+ * 画面末尾に「番号選択メニュー(=人の答え待ち)」が描画されているかだけを返す純粋関数。
405
+ * ★真偽のみ。質問文・説明・選択肢の文言・拒否肢の番号・種別は一切読まない(段3で撤去)。
406
+ *
407
+ * 判定は旧 hasPermissionPrompt isPrompt と同一条件を保つ(アイドル判定の非回帰の要):
408
+ * - 末尾付近(枠の高さを考慮し 50 行)だけを見る。
409
+ * - 末尾から連続する選択肢ブロックを集める。空行・枠罫線・フッターは挟んでよい。
410
+ * 番号でも空でも罫線でもフッターでもない実テキスト行に当たったらそこがメニューの上端。
411
+ * - 有効なメニュー=先頭肢「1」を含む(孤立した番号や地の文の "2)" では成立させない)。
412
+ * - 確定条件=メニューがある かつ(❯カーソルがある または メニューより上に見出し語がある)。
413
+ * 見出し語もカーソりも無い"ただの番号付き箇条書き"は確定させない(偽陽性を出さない)。
414
+ */
415
+ /**
416
+ * 種類を見て「選択メニュー(=人の答え待ち)が出ているか」を判定する(★ADR-021)。
417
+ * claude … 従来の hasChoiceMenu と同一(カーソルは `❯`)
418
+ * codex … カーソルが `›`(U+203A)(実測)。それ以外の作りは同じ。
419
+ * ★真偽だけを返すのは従来どおり。質問文・選択肢の文言は読まない(relay は答えない)。
420
+ */
421
+ export function hasChoiceMenuFor(text, kind) {
422
+ if (kind === 'claude')
423
+ return hasChoiceMenu(text);
424
+ return hasChoiceMenuWithCursor(text, agentProfile(kind).cursor, CODEX_MENU_FOOTER_RE);
425
+ }
426
+ export function hasChoiceMenu(text) {
427
+ return hasChoiceMenuWithCursor(text, '❯');
428
+ }
429
+ /**
430
+ * 選択メニュー判定の本体。カーソル記号だけを差し替えられる形にした(★ADR-021)。
431
+ * ★判定の中身は従来の hasChoiceMenu から1行も変えていない(記号を引数にしただけ)。
256
432
  */
257
- export function hasPermissionPrompt(text) {
258
- const NONE = { isPrompt: false, command: '', kind: 'unknown' };
433
+ function hasChoiceMenuWithCursor(text, cursor, extraFooterRe) {
259
434
  const allLines = text.replace(/\r/g, '').split('\n');
260
- // 末尾付近(枠の高さを考慮し RELAY_TAIL_LINES より広めの 50 行)だけを対象にする。
261
435
  const span = Math.max(RELAY_TAIL_LINES, 50);
262
436
  const lines = allLines.slice(-span);
263
437
  const stripped = lines.map(stripBoxDecoration);
264
- // 質問行("Do you want to ...")を末尾側から探す。
265
- let questionIdx = -1;
438
+ // 行頭カーソル(`❯ 1.` / `› 1.`)。記号は種類ごとに差し替える(ADR-021)。
439
+ const esc = cursor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
440
+ const menuCursorRe = new RegExp(`^${esc}\\s*\\d+[.)]`);
441
+ // ★選択肢行そのものも、行頭カーソルを種類ごとに許す(Claude は `❯`、Codex は `›`)。
442
+ // カーソル記号を差し替えるだけで、判定の中身は PERMISSION_CHOICE_RE と同じ形。
443
+ const choiceRe = new RegExp(`^${esc}?\\s*(\\d+)[.)]\\s*(?:\\S.*?)\\s*$`);
444
+ const keys = new Set();
445
+ let hasMenuCursor = false;
446
+ let menuTopIdx = -1; // メニュー最上段の選択肢行の位置
266
447
  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;
448
+ const s = stripped[i];
449
+ if (s === '')
450
+ continue; // 空行は挟んでよい
451
+ if (isBoxRuleLine(lines[i]))
452
+ continue; // 枠の横罫線は無視
453
+ if (PERMISSION_MENU_FOOTER_RE.test(s))
454
+ continue; // フッター/取り消し案内は無視
455
+ if (extraFooterRe !== undefined && extraFooterRe.test(s))
456
+ continue; // 種類ごとの案内行
457
+ const m = choiceRe.exec(s);
458
+ if (m) {
459
+ if (menuCursorRe.test(s))
460
+ hasMenuCursor = true;
461
+ keys.add(m[1]);
462
+ menuTopIdx = i;
463
+ continue;
287
464
  }
465
+ // 番号でも空でも罫線でもフッターでもない実テキスト行=メニューの上端。ここで打ち切る。
466
+ break;
288
467
  }
289
- const content = [];
290
- for (let i = topIdx; i < questionIdx; i++) {
291
- const s = stripped[i];
292
- if (s !== '')
293
- content.push(s);
468
+ if (!keys.has('1'))
469
+ return false;
470
+ if (hasMenuCursor)
471
+ return true;
472
+ // カーソルが無い版でも、メニューより上に確認の見出し語があれば確定させる。
473
+ for (let i = (menuTopIdx >= 0 ? menuTopIdx : stripped.length) - 1; i >= 0; i--) {
474
+ if (PERMISSION_HEADER_RE.test(stripped[i]))
475
+ return true;
294
476
  }
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));
477
+ return false;
317
478
  }
318
479
  /**
319
480
  * Claude Code 固有の表示マーカー(小文字化して部分一致で判定・内容ベースのガード用)。
@@ -370,6 +531,37 @@ export function looksLikeClaude(text) {
370
531
  const hasFrame = lines.some((line) => /[╭╰]─{2,}|─{2,}[╮╯]/.test(line) || straightRule.test(line));
371
532
  return hasPrompt && hasFrame;
372
533
  }
534
+ /**
535
+ * 種類を見て「この窓はその職人か」を判定する(★ADR-021)。
536
+ * claude … 従来の looksLikeClaude と同一(1文字も変えない)
537
+ * codex … 実測した画面印のどれかがあれば true
538
+ * ★どちらでもない窓へは送らない、という既存の安全策はそのまま効く。
539
+ */
540
+ export function looksLikeAgent(text, kind) {
541
+ if (kind === 'claude')
542
+ return looksLikeClaude(text);
543
+ const lower = text.toLowerCase();
544
+ return agentProfile(kind).markers.some((m) => lower.includes(m));
545
+ }
546
+ /**
547
+ * 種類を見て「待機中か」を判定する(★ADR-021)。
548
+ * claude … 従来の looksIdle と同一(1文字も変えない)
549
+ * codex … ★入力欄は作業中も出たままなので、入力欄の有無では判定できない(実測)。
550
+ * 「作業中シグナルが無い」かつ「選択メニューが出ていない」ときだけ待機とみなす。
551
+ *
552
+ * ★未実測の画面は「待機ではない」側へ倒す。知らない画面へ指示を流し込まないための安全側
553
+ * (承認待ちの他の見出し・`% context left` は未確認。便の指示どおり勝手に押さない)。
554
+ */
555
+ export function looksIdleFor(text, kind) {
556
+ if (kind === 'claude')
557
+ return looksIdle(text);
558
+ if (hasBusySignalFor(text, kind))
559
+ return false;
560
+ if (hasChoiceMenuFor(text, kind))
561
+ return false;
562
+ // ★その窓が Codex だと分かるときだけ「待機」と言い切る。分からない窓は待機にしない。
563
+ return looksLikeAgent(text, kind);
564
+ }
373
565
  /**
374
566
  * 画面テキストに「完了マーカー DONE:{site} が単独で出力されているか」を判定する純粋関数。
375
567
  *
@@ -414,6 +606,99 @@ export function buildAnchor(id) {
414
606
  * 最終的な DONE 行はさらにその下に出るため取りこぼさないようにするため。
415
607
  * - アンカーが画面外(2000行窓から流出)なら null(=まだ確証なし。保守側で完了とみなさない)。
416
608
  */
609
+ /**
610
+ * 完了検出(DONE行・報告本文)を探す範囲を決める。★ADR-020 の根治点。
611
+ *
612
+ * 【なぜ要るか(実測 2026-09-02)】
613
+ * 職人の画面は Claude Code の全画面表示(`tui: fullscreen`)で動くため、tmux から見ると
614
+ * 【代替画面(alternate screen)】になり、**さかのぼれる行が1行も無い**。
615
+ * 実測: `capture-pane -S -2000` を打っても返るのは見えている 80 行だけ
616
+ * (`alternate_on=1` / `pane_height=34`)。
617
+ * その結果、送信時に画面へ出たアンカー(`CONDUCTOR_JOB:{id}`)は数秒でスクロールアウトし、
618
+ * `sliceBelowAnchor` が常に null を返す → DONE 行を1度も見つけられない →
619
+ * **報告本文を添えた完了(patchStatus done+report)が一度も走らない**。
620
+ * 実測: 2026-09-02 の完了 88 件すべてが「中央の status を問い合わせて終わり」の経路で、
621
+ * 報告添付は **0 件**(`done遷移 -> HTTP` のログ自体が1行も無い)。
622
+ *
623
+ * 【なぜアンカーを求めていたのか】前の指示の DONE 行や報告を、今の指示のものと取り違えないため。
624
+ *
625
+ * 【根治】アンカーが見えないときも安全に全画面を見てよい条件がある:
626
+ * ★**アンカーが「一度見えた(delivered)」あとに「見えなくなった」なら、
627
+ * アンカーより上にあった前の指示の出力は、アンカーより先に画面から消えている。**
628
+ * 画面は上から順に流れて出ていくので、アンカーが消えた時点で、その上にあった
629
+ * 前の指示の DONE 行も必ず消えている。だから取り違えは構造的に起こらない。
630
+ *
631
+ * 返り:
632
+ * - アンカーが見える → アンカーより下(従来どおり・いちばん厳しい)
633
+ * - アンカーが見えない かつ delivered → 画面全体(上記の理由で安全)
634
+ * - アンカーを一度も見ていない → null(まだ届いていない=見に行かない)
635
+ */
636
+ // ───────────────────────────────────────────────────────────────────────────
637
+ // Codex の完了・報告を受け取る口(★ADR-021)
638
+ //
639
+ // 【なぜ画面を読まないのか】Codex には `notify` があり、ターンが終わると
640
+ // {"type":"agent-turn-complete","cwd":…,"last-assistant-message":…} が【データで】届く(実測)。
641
+ // 受け口 scripts/codex/codex-notify.py が、内部の「題名を付ける」ターンをふるい落として
642
+ // 現場ごとの受け皿へ1行書く。relay はそれを読み、**既存の完了経路**(patchStatus done+report)
643
+ // へそのまま橋渡しする。=完了を中央へ伝える道は今までどおり1本のまま。
644
+ // ───────────────────────────────────────────────────────────────────────────
645
+ /** Codex の受け皿の置き場。受け口スクリプトと同じ決め方にする。 */
646
+ export function codexNotifyDir(env = process.env) {
647
+ const explicit = (env.CONDUCTOR_CODEX_NOTIFY_DIR ?? '').trim();
648
+ if (explicit)
649
+ return explicit;
650
+ const home = (env.HOME ?? '').trim();
651
+ return home ? path.join(home, '.tyhld', 'codex-notify') : '';
652
+ }
653
+ /**
654
+ * 受け皿から「まだ渡していない完了」を1件取り出し、ファイルを空にする(=二重に使わない)。
655
+ * 返り: 最新の1件 / null(無い・読めない)。
656
+ * ★読めない・壊れている行は捨てる。relay を止めない(フックと同じ作法)。
657
+ */
658
+ export function takeCodexNotice(site, env = process.env) {
659
+ const dir = codexNotifyDir(env);
660
+ if (!dir)
661
+ return null;
662
+ const file = path.join(dir, `${site}.jsonl`);
663
+ let raw;
664
+ try {
665
+ raw = readFileSync(file, 'utf8');
666
+ }
667
+ catch {
668
+ return null; // まだ1件も来ていない
669
+ }
670
+ const lines = raw.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
671
+ let latest = null;
672
+ for (const line of lines) {
673
+ try {
674
+ const o = JSON.parse(line);
675
+ if (typeof o.site === 'string' && o.site === site) {
676
+ latest = {
677
+ site: o.site,
678
+ report: typeof o.report === 'string' ? o.report : '',
679
+ threadId: o.threadId ?? null,
680
+ turnId: o.turnId ?? null,
681
+ };
682
+ }
683
+ }
684
+ catch {
685
+ // 壊れた行は捨てる
686
+ }
687
+ }
688
+ try {
689
+ unlinkSync(file); // 使ったら消す=同じ完了を二度渡さない
690
+ }
691
+ catch {
692
+ /* 消せなくても致命ではない(次回 latest が同じでも patchStatus 側が冪等) */
693
+ }
694
+ return latest;
695
+ }
696
+ export function sliceForDoneScan(text, anchor, delivered) {
697
+ const below = sliceBelowAnchor(text, anchor);
698
+ if (below !== null)
699
+ return below;
700
+ return delivered ? text : null;
701
+ }
417
702
  export function sliceBelowAnchor(text, anchor) {
418
703
  const lines = text.replace(/\r/g, '').split('\n');
419
704
  let anchorIdx = -1;
@@ -555,6 +840,61 @@ export function isTerminalCommandStatus(status) {
555
840
  const s = status.trim().toLowerCase();
556
841
  return s === 'done' || s === 'failed' || s === 'cancelled' || s === 'canceled' || s === 'abandoned';
557
842
  }
843
+ /**
844
+ * 中央が「まだ配達してよい=生きている」と明示した status(これらだけ再送を許す)。
845
+ * queued=未配達 / sent=配達済み作業中 / stalled=無音だが未終端 / perm_wait=承認待ち / held=保留。
846
+ * held は「中央がまだ流す判断をしていない」=再送してよい状態ではないので skip 側に置く。
847
+ */
848
+ const CENTRAL_LIVE_STATUSES = new Set(['queued', 'sent', 'stalled', 'perm_wait']);
849
+ /**
850
+ * 中央で dismissed/timeout も終端。isTerminalCommandStatus(既存・変更しない)は done/failed/
851
+ * cancelled/canceled/abandoned のみを見るので、再配達の可否ではこの2つを足して判断する。
852
+ * - dismissed: ②画面で人が却下(devlog-tracker: commands/[id]/dismiss/route.ts が status='dismissed')
853
+ * - timeout : 中央 sweep が doneAt を打って終端化(devlog-tracker: lib/conductor.ts の sweep)
854
+ */
855
+ const CENTRAL_EXTRA_TERMINAL_STATUSES = new Set(['dismissed', 'timeout']);
856
+ /**
857
+ * 中央がこの指示を「もう終わっている」と言っているか(純粋関数)。
858
+ * done / failed / cancelled / canceled / abandoned / dismissed / timeout のいずれか。
859
+ * null(中央不達・未知)や未知の綴りは false =「終わっているとは言えない」に倒す(保守的)。
860
+ *
861
+ * ★再配達の可否(decideCentralReconcile)と、生存の見張りを畳む判断(keepalive)で
862
+ * 同じ物差しを使うためにここへ切り出した。判定が2か所に分かれて食い違うのを防ぐ。
863
+ */
864
+ export function isCentralFinished(status) {
865
+ if (status === null)
866
+ return false;
867
+ const s = status.trim().toLowerCase();
868
+ return isTerminalCommandStatus(s) || CENTRAL_EXTRA_TERMINAL_STATUSES.has(s);
869
+ }
870
+ /**
871
+ * 「今から同一指示を再送しようとしている」tick で、中央 status と照合して最終判断する純粋関数。
872
+ *
873
+ * 【なぜ必要か】完了通知が conductor_complete(MCP直行)で立つと中央は done になるが、relay が画面の
874
+ * DONE 行を取りこぼすとローカル pending が残る。従来この再送経路(!delivered)は中央を一度も見ずに
875
+ * 同一指示を送り続けていた(既存の中央照合は delivered+アイドルの分岐にしか無く、!delivered な
876
+ * 再送経路とは条件が排他だった)。ここで照合を挟むことで done 済みの再配達を構造的に断つ。
877
+ *
878
+ * 結論:
879
+ * - 'drop' 終端(done/failed/cancelled/canceled/abandoned/dismissed/timeout)。再送せず pending を
880
+ * 解放し abandoned へ入れる(以後二度と配達しない)。
881
+ * - 'resend' 中央が生存(queued/sent/stalled/perm_wait)と明示。従来どおり再送してよい。
882
+ * - 'skip' null(中央不達・404・タイムアウト=未知)、または未知の綴り。このtickは何もせず次tickで
883
+ * 再試行する。曖昧なら送らない安全側に倒す(誤再配達より遅延のほうが安い)。
884
+ *
885
+ * 【縮退の注意】中央に GET /api/conductor/commands/{id} が無い環境では常に null→'skip' となり自動再送は
886
+ * 起きない。固着は従来どおり pending タイムアウト(RELAY_PENDING_TIMEOUT_MS)が解除する。
887
+ */
888
+ export function decideCentralReconcile(centralStatus) {
889
+ if (centralStatus === null)
890
+ return 'skip'; // 未知=照合できず。送らない安全側。
891
+ if (isCentralFinished(centralStatus))
892
+ return 'drop';
893
+ const s = centralStatus.trim().toLowerCase();
894
+ if (CENTRAL_LIVE_STATUSES.has(s))
895
+ return 'resend';
896
+ return 'skip'; // 未知の綴り(将来の新status・held 等)は送らない安全側。
897
+ }
558
898
  /**
559
899
  * sent孤児を 2-a の pending 構造へ変換する純粋関数(★送信はしない)。
560
900
  * - anchor は buildAnchor(id) で再生成(id 不変=アンカー不変)。送信本文末尾のアンカーと一致するため、
@@ -614,12 +954,38 @@ export function buildSendText(body, site, anchor) {
614
954
  ? anchor.slice(SEND_ANCHOR_PREFIX.length + 1)
615
955
  : anchor;
616
956
  const full = `${body}\n\n` +
957
+ // ★確認カードを非エンジニアが読める日本語にする(調査 2987cb8f)。
958
+ // Bash 等の確認に出る一言説明は、職人(モデル)が tool_input.description に自分で書く自由記入欄で、
959
+ // Claude Code 組込みの記入例が英語のため既定では英語になる。ここで日本語を依頼しておく。
960
+ // ★段3の撤去後、この説明の行き先は2つ:
961
+ // (1) 職人の端末に出る確認ダイアログ(人がその場で読む)
962
+ // (2) PermissionRequest フックが管制へ渡す toolInput の中身(カードの「くわしく」に残る)
963
+ // =以前のように relay が画面から拾って中央のカード見出しにする経路(extractPermSummary →
964
+ // summary)は無い。カードの日本語見出しは summary_ja.py がコマンドから組み立てる。
965
+ // ★あくまで書き方の依頼。従わなくても英語の説明が入るだけで、見出しは summary_ja.py が
966
+ // 日本語で作る=壊れない。
967
+ `作業中にユーザー確認(許可を求めるプロンプト)が出る操作では、その一言説明(description)を` +
968
+ `英語ではなく日本語で、「プルリクエストを作成する」「テストを実行する」のように` +
969
+ `「何をするか」が一目で分かる簡潔な形(〜を作成する/実行する/削除する 等)で書いてください` +
970
+ `(この説明は管制の確認カードにそのまま表示され、非エンジニアのお客さまが読んで許可を判断します)。\n\n` +
617
971
  `作業がすべて完了したら、まず報告を ${REPORT_START} と ${REPORT_END} の2行で挟んで出力してください` +
618
972
  `(変更ファイル・要点・チェック結果を簡潔に)。` +
619
973
  `その後の最終行に必ず ${marker} とだけ出力してください。\n\n` +
620
974
  // ★案D(relay非依存の確実な完了通知): 完了したら必ず devlog MCP の conductor_complete を呼ぶ。
621
975
  `そして完了したら必ず、devlog の MCP ツール conductor_complete を commandId="${commandId}" で1回だけ呼んでください` +
622
976
  `(relayに依存しない確実な完了通知です。report 引数に上記の報告本文を添えても構いません)。\n\n` +
977
+ // ★成果引数の案内(devlog-tracker PR #100): conductor_complete に任意の成果を添えられる。
978
+ // すべて任意・分からなければ省略可=渡さなくても従来どおり完了する。既存の完了検出
979
+ // (DONE行/アンカー/スクレイプ)には一切影響しない。アンカーより前・末尾アンカーは不変。
980
+ `また conductor_complete には、可能なら次も一緒に渡してください` +
981
+ `(すべて任意・分かる範囲でよく、渡さなくても従来どおり完了します): ` +
982
+ `branch=作った作業ブランチ名(git rev-parse --abbrev-ref HEAD)、baseBranch=分岐元(通常 main)、` +
983
+ `headSha=最後のコミット(短縮可)、pushed=pushしたか(true/false)、filesChanged=変更したファイル数、` +
984
+ `prTitle=お客さま(非エンジニア)がそのまま読む題名(rebase/squash/lint 等の専門用語を避けやさしい日本語で)、` +
985
+ `prNumber=既にPRがある場合のみ、outcome=pushed(変更してpushした)/no_change(変更不要だった)/` +
986
+ `blocked(衝突・要判断・許可待ち等で完了できなかった)のいずれか。blockedのときも必ず conductor_complete を呼び` +
987
+ `reportに理由を書いてください。ブランチを切らない読み取り専用の作業では outcome=no_change を渡してください` +
988
+ `(branchは省略可)。\n\n` +
623
989
  // アンカー(管理用ジョブ印)は必ず本文の最後に置く。完了検出の境界として使う。
624
990
  `(管理用ジョブ印・変更不要: ${anchor})`;
625
991
  // 改行(と連続改行)を空白1個に潰し、前後を整える。
@@ -710,37 +1076,117 @@ export function tmuxCapture(site) {
710
1076
  }
711
1077
  }
712
1078
  /**
713
- * tmux へ本文を1メッセージとして送り、最後に Enter を1回だけ送る。
714
- * - `--` で本文をオプション扱いさせない。
715
- * - 引数配列で渡すのでシェルのエスケープは不要(日本語・記号もリテラルで渡る)。
716
- * - 本文に生 Enter を含めない(buildSendText で改行を潰してある)。
717
- * 成否を boolean で返す。
1079
+ * 送信用の一時ファイルを置く場所の候補(前から順に試す)。
1080
+ *
1081
+ * 第1候補 `/tmp/claude-<uid>/conductor-relay`:
1082
+ * 職人(Claude Code)が同じ機体で使う使い捨て置き場の下に相間借りする。★職人の鉄則13
1083
+ * 「使い捨てファイルはリポジトリの中に作らない・`/tmp/claude-1000/` に置く」と同じ場所に
1084
+ * 揃えるため(このPCの uid は 1000 なので実体は `/tmp/claude-1000/conductor-relay`)。
1085
+ * conductor が動く機体には必ず Claude Code が居る=この親ディレクトリは既に在る。
1086
+ * 第2候補 `os.tmpdir()/conductor-relay-<uid>`:
1087
+ * `/tmp` が使えない機体(TMPDIR を移している・Mac の一部構成)でも詰まらないための保険。
1088
+ *
1089
+ * どちらも「自分だけが読める」0700 で作り、ファイルは 0600 で置く。指示本文には現場の事情が
1090
+ * 書かれるので、同じ機体の別ユーザーから読めないようにする。
718
1091
  */
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;
1092
+ export function relaySendTmpDirs() {
1093
+ const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
1094
+ return [
1095
+ path.join('/tmp', `claude-${uid}`, 'conductor-relay'),
1096
+ path.join(os.tmpdir(), `conductor-relay-${uid}`),
1097
+ ];
1098
+ }
1099
+ /** tmux バッファ名の接頭辞(他人のバッファと衝突させないため現場・PID・時刻で一意にする)。 */
1100
+ export const RELAY_SEND_BUFFER_PREFIX = 'conductor-send';
1101
+ /** 本文を書いた一時ファイルを作ってパスを返す。どこにも書けなければ例外。 */
1102
+ function writeSendTmpFile(text) {
1103
+ let lastErr = new Error('一時ファイルの置き場が1つも作れませんでした');
1104
+ for (const dir of relaySendTmpDirs()) {
1105
+ try {
1106
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
1107
+ const unique = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1108
+ const file = path.join(dir, `send-${unique}.txt`);
1109
+ writeFileSync(file, text, { encoding: 'utf8', mode: 0o600 });
1110
+ return file;
1111
+ }
1112
+ catch (err) {
1113
+ lastErr = err;
1114
+ }
727
1115
  }
1116
+ throw lastErr;
1117
+ }
1118
+ /** 例外から tmux の言い分(stderr)を1行に畳んで取り出す。ログに理由を残すため。 */
1119
+ function describeExecError(err) {
1120
+ const e = err;
1121
+ const raw = e?.stderr;
1122
+ const stderr = typeof raw === 'string' ? raw : raw ? Buffer.from(raw).toString('utf8') : '';
1123
+ const detail = (stderr.trim() || e?.message || String(err)).replace(/\s+/g, ' ').trim();
1124
+ return detail.slice(0, 500) || '(理由不明)';
1125
+ }
1126
+ /** tmux を1回呼ぶ。失敗時は stderr を積んだ例外がそのまま飛ぶ。 */
1127
+ function runTmux(args) {
1128
+ execFileSync('tmux', args, { stdio: ['ignore', 'ignore', 'pipe'] });
728
1129
  }
729
1130
  /**
730
- * tmux セッションへ「単一キー」を送る(Enter は付けない)。
731
- * 許可プロンプトの番号選択メニューは、数字キー単押しで該当肢を即選択して進む(Enter不要)。
732
- * そのため ALLOW→"1" / DENY→"3" は send-keys でキーだけ送る。`--` Enter も付けない。
733
- * (sendToTmux は本文+Enter=1メッセージ送信用。選択肢キー送信とは用途が異なるので別関数にする。)
734
- * 成否を boolean で返す(失敗は握って false=送らない側)。
1131
+ * tmux へ本文を1メッセージとして送り、最後に Enter を1回だけ送る。
1132
+ *
1133
+ * 【なぜ send-keys の引数に本文を載せないか(実測 2026-09-03 21:34〜の根治)】
1134
+ * tmux のクライアント→サーバの1命令には上限(IPC のメッセージ長・16KB前後)がある。
1135
+ * 本文を `send-keys -- <本文>` の引数に丸ごと載せる作りだと、本文が上限を超えた便で
1136
+ * tmux 自身が命令を受け取れず失敗し、relay は `send-keys failed, keep queued` と出して
1137
+ * 永久に「待っています」のまま止まった(本文 約15KB の便 b14c9d23/devlog-tracker)。
1138
+ *
1139
+ * 【根治】本文を一時ファイルへ書き、`load-buffer`(ファイルから読むので長さの上限が無い)で
1140
+ * tmux のバッファに載せ、`paste-buffer` で職人の窓へ貼る。引数に本文を載せないので、
1141
+ * 本文がどれだけ長くても命令そのものは短いまま=上限に当たらない。
1142
+ *
1143
+ * 【長さで分岐しない】短い本文も同じ経路を通す。「短いときだけ別の道」を残すと、
1144
+ * 境界の前後で挙動が変わる穴(=今回と同じ型の事故)をまた作ることになる(鉄則10 根治)。
1145
+ *
1146
+ * 【`-p`(ブラケットペースト)を付ける理由】
1147
+ * 付けないと、バッファの中の改行がそのまま Enter として届き、本文の途中で送信されてしまう
1148
+ * (=指示が切り刻まれる)。`-p` は「ここからここまでは貼り付け」と印を付けて渡すので、
1149
+ * 受け手の Claude Code は改行込みで1つの入力として受け取る。送信は最後の Enter 1回だけ。
1150
+ * ★これで「本文に改行を含めない」という前提(buildSendText の改行潰し)に頼らなくなる。
1151
+ *
1152
+ * 【後始末】
1153
+ * - 一時ファイルは成功・失敗にかかわらず必ず消す(finally)。
1154
+ * - tmux バッファは `-d` で貼った直後に tmux 自身が消す。貼る前に転んだ場合だけ
1155
+ * delete-buffer で拾い直す(残しておくと `tmux show-buffer` で他人に読まれ得るため)。
1156
+ *
1157
+ * 失敗したときは false を返し(呼び出し側は今までどおり keep queued で残す)、
1158
+ * ★tmux の言い分(stderr)をログに出す。以前は `failed` としか出ず原因が追えなかった。
735
1159
  */
736
- export function sendKeyToTmux(site, key) {
1160
+ export function sendToTmux(site, text) {
1161
+ const bufferName = `${RELAY_SEND_BUFFER_PREFIX}-${site}-${process.pid}-${Date.now()}`;
1162
+ let file = null;
737
1163
  try {
738
- execFileSync('tmux', ['send-keys', '-t', site, key], { stdio: 'ignore' });
1164
+ file = writeSendTmpFile(text);
1165
+ runTmux(['load-buffer', '-b', bufferName, file]);
1166
+ runTmux(['paste-buffer', '-d', '-p', '-b', bufferName, '-t', site]);
1167
+ runTmux(['send-keys', '-t', site, 'Enter']);
739
1168
  return true;
740
1169
  }
741
- catch {
1170
+ catch (err) {
1171
+ log(`WARN site=${site} 本文の投入に失敗(${text.length}文字): ${describeExecError(err)}`);
1172
+ try {
1173
+ execFileSync('tmux', ['delete-buffer', '-b', bufferName], { stdio: 'ignore' });
1174
+ }
1175
+ catch {
1176
+ // 貼れていれば -d で既に消えている。無いバッファを消そうとしたエラーは無視してよい。
1177
+ }
742
1178
  return false;
743
1179
  }
1180
+ finally {
1181
+ if (file !== null) {
1182
+ try {
1183
+ unlinkSync(file);
1184
+ }
1185
+ catch {
1186
+ // 消せなくても送信の成否は変えない(次回の一意名で上書きもされない=溜まるだけ)。
1187
+ }
1188
+ }
1189
+ }
744
1190
  }
745
1191
  /**
746
1192
  * 二重チェックの「アイドル」判定。
@@ -769,14 +1215,14 @@ export async function isIdleStable(site) {
769
1215
  const now = () => new Date().toLocaleTimeString();
770
1216
  const log = (msg) => console.log(`[relay] ${now()} ${msg}`);
771
1217
  /**
772
- * 共有シークレットを Bearer で付ける認可ヘッダを組み立てる(全API共通・コピペ増殖を避ける)。
773
- * 鍵はコードに書かず cfg.secret(環境変数由来)からのみ受け取る。
1218
+ * テナントトークンを Bearer で付ける認可ヘッダを組み立てる(全API共通・コピペ増殖を避ける)。
1219
+ * 鍵はコードに書かず cfg.token(環境変数 CONDUCTOR_TOKEN 由来)からのみ受け取る。
774
1220
  */
775
1221
  function authHeaders(cfg) {
776
- return { Authorization: `Bearer ${cfg.secret}` };
1222
+ return { Authorization: `Bearer ${cfg.token}` };
777
1223
  }
778
1224
  /**
779
- * 中央の緊急停止フラグを取得。GET {url}/api/conductor/control(共有シークレット Bearer)。
1225
+ * 中央の緊急停止フラグを取得。GET {url}/api/conductor/control(テナントトークン Bearer)。
780
1226
  *
781
1227
  * 【最重要・安全側のフェイルセーフ】
782
1228
  * 取得失敗(ネットワーク不通 / HTTP非2xx / 応答が不正)のときは relayHalted=true とみなす。
@@ -786,6 +1232,8 @@ function authHeaders(cfg) {
786
1232
  export async function fetchControl(cfg) {
787
1233
  try {
788
1234
  // ?site= を付けて per-site の relayEnabled も同時に取得(ADR-038)。site 無し応答とも後方互換。
1235
+ // ★段3: 許可応答(permAnswer)の宛先特定用だった ?machine= は、答えを届ける役目ごと撤去したので
1236
+ // 付けない。応答に permAnswer が載っていても relay は読まない(中央側の受け口整理は段3-B)。
789
1237
  const url = `${cfg.url}/api/conductor/control?site=${encodeURIComponent(cfg.site)}`;
790
1238
  const res = await fetch(url, { headers: authHeaders(cfg) });
791
1239
  if (!res.ok) {
@@ -875,7 +1323,7 @@ export async function patchStatus(cfg, id, status, report) {
875
1323
  }
876
1324
  /**
877
1325
  * 指示 {id} 単体の現在 status を中央へ問う(外部完了検出の根治プローブ)。
878
- * GET /api/conductor/commands/{id}(共有シークレット Bearer)。応答 JSON から status 文字列を拾う。
1326
+ * GET /api/conductor/commands/{id}(テナントトークン Bearer)。応答 JSON から status 文字列を拾う。
879
1327
  * - 応答形は `{ status }` / `{ command: { status } }` / 素の command オブジェクトのいずれも許容する。
880
1328
  * - status を確定的に読めた場合のみ小文字化して返す。それ以外(非2xx・404・タイムアウト・通信失敗・
881
1329
  * status 欠落/非文字列)はすべて null=「中央不達 or 未知」を意味し、呼び出し側は従来経路
@@ -907,20 +1355,55 @@ export async function fetchCommandStatus(cfg, id) {
907
1355
  }
908
1356
  }
909
1357
  /**
910
- * 生存ハートビートを中央へ送る(案①・調査 cmqbuch4l)。
911
- * PATCH /api/conductor/commands/{id}/seen(共有シークレット Bearer)。
1358
+ * このtickで生存ハートビート(bare)を送るかを決める純粋関数(I/Oなし=単体テスト可能)。
1359
+ *
1360
+ * 【段3で何を撤去したか】以前ここには perm_wait / working の状態通知があり、承認プロンプトの
1361
+ * 出現・解決のエッジや「質問が切り替わった」ことまで見て中央へ状態を送っていた(⑪の perm_wait
1362
+ * 判定・ObservedState / observeState / lastPromptKey)。確認の受け渡しがフック経路へ移り、
1363
+ * 質問の中身を画面から読まなくなったので、状態通知はまるごと撤去した。残るのは
1364
+ * 「まだ生きている」を伝えるだけの bare heartbeat(keepalive)。
1365
+ *
1366
+ * 【★alive に選択メニュー中も含める理由(撤去による退行を出さないため)】
1367
+ * 中央は lastSeenAt が途切れた sent を sweep で stalled/failed 化する。職人がフックの確認で
1368
+ * 止まっている間は busy シグナル(esc to interrupt)が消えるので、busy だけを生存の根拠にすると
1369
+ * 「人の答えを待っているだけなのに落ちた職人に見える」誤表示が起きる(撤去前は perm_wait が
1370
+ * この役目を兼ねていた)。よって「作業中 または 選択メニュー表示中」を生存とみなす。
1371
+ * ★送るのは従来と同じ body 無しの bare のみ=質問文も状態も中央へ送らない。
1372
+ *
1373
+ * - alive : 作業中シグナルあり、または選択メニュー表示中。
1374
+ * - throttleOk : lastHeartbeatAt から RELAY_HEARTBEAT_INTERVAL_MS 以上経過したか。
1375
+ */
1376
+ export function decideHeartbeat(alive, throttleOk) {
1377
+ if (!alive)
1378
+ return 'none'; // 完全なアイドルは中央へ送るものが無い(従来どおり)。
1379
+ return throttleOk ? 'bare' : 'none';
1380
+ }
1381
+ /**
1382
+ * 生存ハートビート(bare)を中央へ送る(案①・調査 cmqbuch4l)。
1383
+ * PATCH /api/conductor/commands/{id}/seen(テナントトークン Bearer・★body 無し)。
912
1384
  * 中央は where{id,status:'sent'} の指示にだけ lastSeenAt=now() を刻む(終端後は無視=冪等)。
913
1385
  *
914
1386
  * 【方針】これは「中央へ生存を知らせるだけ」の最善努力通知。失敗は致命ではないので patchStatus 同様
915
1387
  * 例外を外へ投げず、ここで握りつぶしてログのみ出す。中央不達時は lastSeenAt が更新されず、
916
1388
  * 中央側は従来どおり sentAt 基準(15分 TTL)へ無損失で縮退する(後方互換)。
1389
+ *
1390
+ * 【段3で何を撤去したか】以前はここから state='perm_wait'/'working' と、質問全文(question)・種別・
1391
+ * 選択肢・1行説明(summary)・番人の再判定(verdict)・promptResolved を JSON body で送っていた
1392
+ * (=/seen の perm 系)。画面から質問を読む処理ごと撤去したため、送るのは body 無しの生存通知だけ。
1393
+ * 確認そのものは PermissionRequest フックが別の口(hook-requests)でデータとして届ける。
917
1394
  */
918
- export async function sendHeartbeat(cfg, id) {
1395
+ export async function sendHeartbeat(cfg, id, busy = false) {
919
1396
  try {
920
- const res = await fetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(id)}/seen`, {
921
- method: 'PATCH',
922
- headers: authHeaders(cfg),
923
- });
1397
+ // ★busy が【真偽値の true】のときだけ body を載せる。それ以外は今までと1バイトも変えない
1398
+ // (body 無しの PATCH)。中央も "true" や 1 のような真偽値でない値は無視する仕様。
1399
+ const init = busy === true
1400
+ ? {
1401
+ method: 'PATCH',
1402
+ headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
1403
+ body: JSON.stringify({ busy: true }),
1404
+ }
1405
+ : { method: 'PATCH', headers: authHeaders(cfg) };
1406
+ const res = await fetch(`${cfg.url}/api/conductor/commands/${encodeURIComponent(id)}/seen`, init);
924
1407
  if (!res.ok) {
925
1408
  log(`site=${cfg.site} id=${id} heartbeat -> HTTP ${res.status} NG(次回再試行・縮退で安全)`);
926
1409
  }
@@ -930,7 +1413,207 @@ export async function sendHeartbeat(cfg, id) {
930
1413
  }
931
1414
  }
932
1415
  /**
933
- * 自分宛(machine+site)の sent孤児を取得。GET /api/conductor/commands/orphans(共有シークレット Bearer)。
1416
+ * いま「作業中です」と手を挙げる(busy:true を載せる)かを決める純粋関数。
1417
+ *
1418
+ * 【★手を挙げるのは「手を動かしている」印があるときだけ】
1419
+ * 使うのは busy シグナル(入力枠フッターの `esc to interrupt` / スピナー=hasBusySignal)。
1420
+ * これは「職人がいま処理を回している」ことを画面から直に読める、いちばん確かな印である。
1421
+ *
1422
+ * 【★選択メニュー中は挙げない】
1423
+ * メニューは【人の答え待ち】で、職人の手は止まっている。ここで手を挙げ続けると、
1424
+ * 誰も答えないまま現場が長時間ふさがる。生存通知(lastSeenAt)は従来どおりメニュー中も
1425
+ * 送るので、「落ちた職人」と誤認されることはない=退行しない。
1426
+ *
1427
+ * 【★迷ったら挙げない】
1428
+ * busy とメニューが同時に見えるような読み取りは、どちらとも言い切れない。挙げなければ
1429
+ * 従来どおり(中央は30分で解放)=いまと同じ動きに戻るだけなので、そちらへ倒す。
1430
+ */
1431
+ export function shouldRaiseBusy(busySignal, menuOpen) {
1432
+ if (menuOpen)
1433
+ return false;
1434
+ return busySignal === true;
1435
+ }
1436
+ /**
1437
+ * 手を挙げた/下ろしたの【変わり目だけ】をログに出す。状態は state.busyRaisedId が持つ。
1438
+ * ★毎回のハートビートで出すと現場のログが埋まるので、変わったときだけ1行。
1439
+ * ★秘密は出さない(現場名と指示idだけ)。
1440
+ */
1441
+ export function noteBusyEdge(state, site, id, raise, logLine) {
1442
+ if (raise) {
1443
+ if (state.busyRaisedId !== id) {
1444
+ state.busyRaisedId = id;
1445
+ logLine(`site=${site} id=${id} 作業中の手を挙げました(中央の時間切れを保留させます)`);
1446
+ }
1447
+ return;
1448
+ }
1449
+ if (state.busyRaisedId === id) {
1450
+ state.busyRaisedId = null;
1451
+ logLine(`site=${site} id=${id} 作業中の手を下ろしました(中央は従来どおり時間で解放します)`);
1452
+ }
1453
+ }
1454
+ // ───────────────────────────────────────────────────────────────────────────
1455
+ // 生存の見張り(keepalive)— 配達の追跡(pending)とは別の寿命で回す(調査 176aebcf・案①-a)
1456
+ //
1457
+ // 【なぜ分けるのか】pending の上限(15分)は「固着して次の指示を拾えない」を防ぐための時計で、
1458
+ // 短いほうがよい。ところが従来はそれを過ぎると生存通知(/seen)まで一緒に止まっていた。
1459
+ // 生存通知の送り手は relay だけなので、以後その指示には二度と通知が飛ばず、中央では
1460
+ // sent →(5分)→ stalled →(30分)→ timeout と一方通行に落ちる。職人が実際には作業中でも
1461
+ // 管制は「返事がありません」のまま戻らない(2026-09-01 実地)。中央側の戻り道は既にある
1462
+ // (/seen の body 無し通知が stalled → sent へ戻す)ので、送り手を絶やさないだけで直る。
1463
+ //
1464
+ // 【この経路がやらないこと】★再送は一切しない(本文も再送回数も持たない)。やるのは
1465
+ // ①生存通知 ②画面の DONE 行を見つけたときの完了報告 ③中央が終端なら畳む、の3つだけ。
1466
+ // ───────────────────────────────────────────────────────────────────────────
1467
+ /**
1468
+ * 見張りを引き継ぐ(pending → keepalive)。★I/Oなし=単体テスト可能。
1469
+ *
1470
+ * 引き継がない条件(どちらも「引き継ぐ根拠がない/壊す」ケース):
1471
+ * - 未着信(delivered=false): アンカーが画面に出ていない=職人に届いた確証がない。
1472
+ * 生きているかどうかを語る根拠が無いので、生存通知を送ってはいけない。
1473
+ * - 既に別の見張りがある: 新しい指示で古い見張りを上書きしない(便の縛り)。同一現場に
1474
+ * 複数の in-flight が並ぶかどうかの整理は中央の inFlight ガードの仕事で、relay 側で
1475
+ * 取り合いをすると「どちらの生存も伝わらない」時間が生まれる。
1476
+ *
1477
+ * @returns 引き継いだら true(呼び出し側はログを出す)。
1478
+ */
1479
+ export function startKeepalive(state, pending, now) {
1480
+ if (!pending.delivered)
1481
+ return false;
1482
+ if (state.keepalive !== null)
1483
+ return false;
1484
+ state.keepalive = {
1485
+ id: pending.id,
1486
+ site: pending.site,
1487
+ anchor: pending.anchor,
1488
+ startedAt: now,
1489
+ lastHeartbeatAt: null,
1490
+ screenDoneUsable: true,
1491
+ };
1492
+ return true;
1493
+ }
1494
+ /**
1495
+ * このtickで見張りが何をするかを決める純粋関数(I/Oなし=単体テスト可能)。
1496
+ *
1497
+ * 判定の順序には理由がある:
1498
+ * 1) expired … 上限を過ぎたら他を見ずに畳む(無限に送り続けない)。
1499
+ * 2) capOk … 画面が読めないtickは何も決めない(断定しない・次tick再確認)。
1500
+ * 3) doneSeen … 完了は最優先で拾う(生存通知より先。畳む前に必ず報告する)。
1501
+ * 4) alive … 作業中/選択メニュー表示中は生きている。スロットルが空いていれば通知。
1502
+ * 5) idle … 完全に静止=作業が終わったか止まったか。中央に答え合わせをする。
1503
+ * ★alive でも idle でもない中間(画面は動いているが busy 印が無い等)は 'wait'=黙って待つ。
1504
+ */
1505
+ export function decideKeepalive(input) {
1506
+ if (input.expired)
1507
+ return 'expire';
1508
+ if (!input.capOk)
1509
+ return 'wait';
1510
+ if (input.doneSeen)
1511
+ return 'done';
1512
+ if (input.alive)
1513
+ return input.throttleOk ? 'heartbeat' : 'wait';
1514
+ if (input.idle)
1515
+ return 'probe';
1516
+ return 'wait';
1517
+ }
1518
+ const DEFAULT_KEEPALIVE_DEPS = {
1519
+ capture: (site) => tmuxCapture(site),
1520
+ heartbeat: (cfg, id, busy) => sendHeartbeat(cfg, id, busy === true),
1521
+ complete: (cfg, id, report) => patchStatus(cfg, id, 'done', report),
1522
+ centralStatus: (cfg, id) => fetchCommandStatus(cfg, id),
1523
+ now: () => Date.now(),
1524
+ logLine: (msg) => log(msg),
1525
+ };
1526
+ /**
1527
+ * 見張りの1tick(I/Oあり)。★relayTick から pending 分岐より前に呼ぶ。
1528
+ *
1529
+ * ここで return しても relayTick は続行する=**見張り中でも新しい指示の取得・送信は従来どおり**
1530
+ * 動く(pending を落とす本来の目的を殺さない)。判断そのものは decideKeepalive に委ねる。
1531
+ */
1532
+ export async function keepaliveTick(cfg, state, deps = {}) {
1533
+ const ka = state.keepalive;
1534
+ if (!ka)
1535
+ return;
1536
+ const { capture, heartbeat, complete, centralStatus: getStatus, now: clock, logLine } = {
1537
+ ...DEFAULT_KEEPALIVE_DEPS,
1538
+ ...deps,
1539
+ };
1540
+ // ★窓の持ち主が替わったら、画面からの完了検出はやめる(取り違えた完了を構造的に防ぐ)。
1541
+ // 別の指示が同じ窓で動くと、その出力も古いアンカーより下に並ぶため、そのまま読むと
1542
+ // 新しい指示の DONE 行と報告を、見張っている古い指示の完了として送ってしまう。
1543
+ if (ka.screenDoneUsable && state.pending !== null && state.pending.id !== ka.id) {
1544
+ ka.screenDoneUsable = false;
1545
+ logLine(`site=${cfg.site} id=${ka.id} 見張り継続: 画面からの完了検出は打ち切り` +
1546
+ `(同じ窓で別の指示 id=${state.pending.id} が動き始めたため)。生存通知は続けます`);
1547
+ }
1548
+ const now = clock();
1549
+ const expired = now - ka.startedAt >= RELAY_KEEPALIVE_MAX_MS;
1550
+ // 上限を過ぎているなら画面は見ない(畳むと決まっているので capture の費用をかけない)。
1551
+ const cap = expired ? null : capture(ka.site);
1552
+ // ★見張りに入っている=その指示は必ず着信済み(startKeepalive が delivered を要求する)。
1553
+ // よってアンカーが流れて消えていても、画面全体を見てよい(ADR-020)。
1554
+ const below = cap === null ? null : sliceForDoneScan(cap, ka.anchor, true);
1555
+ // ★「作業中です」の印は busy シグナルだけで決める(メニュー中は挙げない・shouldRaiseBusy)。
1556
+ // 生存通知の可否(alive)は従来どおり busy ∪ メニュー=1文字も変えない。
1557
+ const kaBusySignal = cap !== null && hasBusySignalFor(cap, kindOf(cfg));
1558
+ const kaMenu = cap !== null && hasChoiceMenuFor(cap, kindOf(cfg));
1559
+ const action = decideKeepalive({
1560
+ expired,
1561
+ capOk: cap !== null,
1562
+ doneSeen: ka.screenDoneUsable && below !== null && hasStandaloneDone(below, ka.site),
1563
+ alive: cap !== null && (kaBusySignal || kaMenu),
1564
+ idle: cap !== null && looksIdleFor(cap, kindOf(cfg)),
1565
+ throttleOk: ka.lastHeartbeatAt === null || now - ka.lastHeartbeatAt >= RELAY_HEARTBEAT_INTERVAL_MS,
1566
+ });
1567
+ if (action === 'wait')
1568
+ return;
1569
+ if (action === 'heartbeat') {
1570
+ ka.lastHeartbeatAt = now;
1571
+ const raise = shouldRaiseBusy(kaBusySignal, kaMenu);
1572
+ noteBusyEdge(state, cfg.site, ka.id, raise, logLine);
1573
+ // ★継続のログ。出るのは最大でも RELAY_HEARTBEAT_INTERVAL_MS に1回(スロットルに相乗り)。
1574
+ logLine(`site=${cfg.site} id=${ka.id} 見張り継続: 職人は生きています→生存通知` +
1575
+ `(配達の追跡は解除済み・経過${Math.round((now - ka.startedAt) / 1000)}s)`);
1576
+ await heartbeat(cfg, ka.id, raise);
1577
+ return;
1578
+ }
1579
+ if (action === 'done') {
1580
+ // 完了は従来の経路(画面の DONE 行+報告本文)にそのまま乗せる。報告も落とさない。
1581
+ const report = below === null ? null : extractReport(below);
1582
+ if (await complete(cfg, ka.id, report)) {
1583
+ logLine(`site=${cfg.site} id=${ka.id} 見張り終了: 画面のDONEを検出し完了を報告` +
1584
+ `${report != null ? '(報告添付)' : ''}`);
1585
+ // ★終わったら必ず手を下ろす(挙げっぱなしにすると現場が長くふさがる)。
1586
+ noteBusyEdge(state, cfg.site, ka.id, false, logLine);
1587
+ state.keepalive = null;
1588
+ }
1589
+ // 報告に失敗したら畳まない(次tickで再試行。上限が最終的に打ち切る)。
1590
+ return;
1591
+ }
1592
+ // 'probe' / 'expire' … どちらも中央に答え合わせをしてから畳むかを決める。
1593
+ const centralStatus = await getStatus(cfg, ka.id);
1594
+ if (action === 'expire') {
1595
+ // ★上限は無条件で畳む(無限に送り続けない)。中央の言い分はログに残すだけ。
1596
+ logLine(`site=${cfg.site} id=${ka.id} 見張り終了: 上限${Math.round(RELAY_KEEPALIVE_MAX_MS / 60000)}分に到達` +
1597
+ `(中央status=${centralStatus ?? '不明'})`);
1598
+ noteBusyEdge(state, cfg.site, ka.id, false, logLine);
1599
+ state.keepalive = null;
1600
+ return;
1601
+ }
1602
+ if (isCentralFinished(centralStatus)) {
1603
+ logLine(`site=${cfg.site} id=${ka.id} 見張り終了: 中央で終端(status=${centralStatus})を確認`);
1604
+ noteBusyEdge(state, cfg.site, ka.id, false, logLine);
1605
+ state.keepalive = null;
1606
+ }
1607
+ // ★'probe' で終端でなかった場合(職人が静止しているだけ)は生存通知を送らない=
1608
+ // 手も挙げない。ここで挙げっぱなしにすると「止まっている職人」で現場をふさぐ。
1609
+ if (action === 'probe') {
1610
+ noteBusyEdge(state, cfg.site, ka.id, false, logLine);
1611
+ }
1612
+ // 終端でない(まだ生きている/中央不達)なら畳まない。職人が静止しているだけなので、
1613
+ // 生存通知は送らずに見張りだけ続ける=中央は従来どおり stalled へ落とせる(誤魔化さない)。
1614
+ }
1615
+ /**
1616
+ * 自分宛(machine+site)の sent孤児を取得。GET /api/conductor/commands/orphans(テナントトークン Bearer)。
934
1617
  * - タイムアウト(RELAY_ORPHAN_FETCH_TIMEOUT_MS)を付け、devlog 到達不能でも起動がハングしない。
935
1618
  * - 空配列/エラー/未到達/不正応答はすべて [] を返す(起動を妨げないフェイルセーフ)。
936
1619
  * - sentAt は数値(ms)でも ISO 文字列でも受け、解釈不能なら現在時刻にフォールバック
@@ -1019,6 +1702,10 @@ export async function relayTick(cfg, state) {
1019
1702
  if (state.running)
1020
1703
  return; // 前回の tick が走行中ならスキップ
1021
1704
  state.running = true;
1705
+ // 承認待ちの観測値は毎tick「出ていない」から始める(下でプロンプトを検知したときだけ true にする)。
1706
+ // ここで false に戻しておくと、プロンプトが消えた/pending が無い/早期 return のどの経路でも、
1707
+ // 次のtick間隔が自動的に通常(15秒)へ戻る。★判定ロジックはこの値を一切参照しない(観測専用)。
1708
+ state.promptPending = false;
1022
1709
  try {
1023
1710
  // ── sent孤児の復元(2-b・起動後1回) ──
1024
1711
  // cli.ts を触らず「起動時1回」を実現するため、最初の tick でだけ復元する。
@@ -1028,6 +1715,21 @@ export async function relayTick(cfg, state) {
1028
1715
  state.restored = true;
1029
1716
  await restoreOrphans(cfg, state);
1030
1717
  }
1718
+ // ── 手を挙げっぱなしを防ぐ最後の砦(★割り込みの根治・便2/3) ──
1719
+ // 「作業中です」と手を挙げた指示が、もう追跡対象(pending でも keepalive でもない)で
1720
+ // なくなっていたら、必ず下ろす。完了・中央での終端・放棄・再起動のどの道で抜けても、
1721
+ // ここを通れば下りる=★挙げっぱなしで現場が長時間ふさがることが構造的に起きない。
1722
+ if (state.busyRaisedId !== null &&
1723
+ state.pending?.id !== state.busyRaisedId &&
1724
+ state.keepalive?.id !== state.busyRaisedId) {
1725
+ noteBusyEdge(state, cfg.site, state.busyRaisedId, false, log);
1726
+ }
1727
+ // ── 生存の見張り(keepalive・pending とは別の寿命) ──
1728
+ // pending の上限で「配達の追跡」を畳んだ指示について、職人が生きて見える限り中央へ
1729
+ // 生存通知を送り続ける(調査 176aebcf・案①-a)。★ここでは return しない=この下の
1730
+ // pending 分岐も新規指示の取得も従来どおり動く(pending を落とす目的を殺さない)。
1731
+ // 停止中(relayHalted)でも続けるのは、既に送った指示の後始末は進めるという既存方針と同じ。
1732
+ await keepaliveTick(cfg, state);
1031
1733
  // ── 完了マーカー検出(pending があれば最優先・停止中でも継続) ──
1032
1734
  if (state.pending) {
1033
1735
  const cap = tmuxCapture(cfg.site);
@@ -1052,7 +1754,22 @@ export async function relayTick(cfg, state) {
1052
1754
  }
1053
1755
  // アンカー(今回送信の境界)より下だけを見て DONE:{site} 単独行を探す。
1054
1756
  // churn で古い DONE が押し出されても・過去ジョブの DONE が窓に残っても、誤検出/見逃しなし。
1055
- const below = sliceBelowAnchor(cap, state.pending.anchor);
1757
+ // ★アンカーが既に流れて消えている場合は、着信を確認済み(delivered)なら画面全体を見る
1758
+ // (ADR-020。全画面表示の職人はさかのぼれる行が無く、アンカーが数秒で消えるため)。
1759
+ // ★Codex は完了と報告が notify でデータとして届く(ADR-021)。画面より先にそちらを見る。
1760
+ // 届いていれば、そのまま【既存の完了経路】へ橋渡しする(道は1本のまま)。
1761
+ if (kindOf(cfg) === 'codex') {
1762
+ const notice = takeCodexNotice(cfg.site);
1763
+ if (notice !== null) {
1764
+ const report = notice.report.trim().length > 0 ? notice.report : null;
1765
+ if (await patchStatus(cfg, state.pending.id, 'done', report)) {
1766
+ log(`site=${cfg.site} id=${state.pending.id} done(Codexの通知${report != null ? '・報告添付' : ''})`);
1767
+ state.pending = null;
1768
+ }
1769
+ return;
1770
+ }
1771
+ }
1772
+ const below = sliceForDoneScan(cap, state.pending.anchor, state.pending.delivered);
1056
1773
  if (below !== null && hasStandaloneDone(below, cfg.site)) {
1057
1774
  // 報告本文も「アンカーより下」から抜き出す(今回ジョブの報告に限定。過去報告の誤添付を防ぐ)。
1058
1775
  const report = extractReport(below);
@@ -1075,7 +1792,7 @@ export async function relayTick(cfg, state) {
1075
1792
  // 従来の detectExternalDone(別idが配れる=終端の確証)へフォールバックする。中央側 status API が
1076
1793
  // 入るまではこの副シグナルが従来どおり効き、入った後は主シグナル(status)が確実に固着を断つ。
1077
1794
  // HTTP コストを抑えるため delivered+アイドルのときだけ probe する(busyな通常作業中は叩かない)。
1078
- const idle = looksIdle(cap);
1795
+ const idle = looksIdleFor(cap, kindOf(cfg));
1079
1796
  if (state.pending.delivered && idle) {
1080
1797
  const centralStatus = await fetchCommandStatus(cfg, state.pending.id);
1081
1798
  if (isTerminalCommandStatus(centralStatus)) {
@@ -1105,17 +1822,43 @@ export async function relayTick(cfg, state) {
1105
1822
  }
1106
1823
  }
1107
1824
  // ── 生存ハートビート(案①・調査 cmqbuch4l) ──
1108
- // 職人が確実に busy(esc to interrupt 等)なら「まだ作業中」を中央へ通知し lastSeenAt を更新する。
1109
- // 中央はこれを生存基準(COALESCE(lastSeenAt,sentAt)>cutoff)に使い、長時間作業中の sent を
1110
- // 15分 TTL で誤って failed 化しない。毎tick送らず RELAY_HEARTBEAT_INTERVAL_MS でスロットルする。
1825
+ // 職人が生きている(作業中、または人の答えを待って選択メニューで止まっている)なら
1826
+ // 「まだ動いている」を中央へ通知し lastSeenAt を更新する。中央はこれを生存基準
1827
+ // (COALESCE(lastSeenAt,sentAt)>cutoff)に使い、長時間作業中の sent を 15分 TTL で誤って
1828
+ // failed 化しない。毎tick送らず RELAY_HEARTBEAT_INTERVAL_MS でスロットルする。
1111
1829
  // 送信失敗は握りつぶし(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);
1830
+ //
1831
+ // 【段3の撤去点】以前はここで承認プロンプトの中身を読み、perm_wait / working と質問全文・
1832
+ // 選択肢・1行説明・番人の再判定を中央へ送っていた(⑪の perm_wait 判定・⑫⑬⑯)。
1833
+ // 確認の受け渡しはフック経路(permission_request_hook.py hook-requests)へ移したので、
1834
+ // ここは「生存を伝えるだけ」に戻した。選択メニュー中も生存に数えるのは、busy シグナルが
1835
+ // 消えるこの区間で中央に「落ちた職人」と誤認させないため(decideHeartbeat のコメント参照)。
1836
+ const menu = hasChoiceMenuFor(cap, kindOf(cfg));
1837
+ // 職人が選択メニューで止まっている観測値。cli.ts の適応スケジューラが次tick間隔に使うだけで、
1838
+ // relayTick の判定・送信には一切影響しない(観測専用)。
1839
+ state.promptPending = menu;
1840
+ const nowMs = Date.now();
1841
+ const throttleOk = state.lastHeartbeatAt === null ||
1842
+ nowMs - state.lastHeartbeatAt >= RELAY_HEARTBEAT_INTERVAL_MS;
1843
+ // ★「作業中です」の印は busy シグナルだけで決める(メニュー中は挙げない・shouldRaiseBusy)。
1844
+ // 生存通知の可否は従来どおり busy ∪ メニュー=1文字も変えない。
1845
+ const busySignal = hasBusySignalFor(cap, kindOf(cfg));
1846
+ const decision = decideHeartbeat(busySignal || menu, throttleOk);
1847
+ if (decision === 'bare') {
1848
+ state.lastHeartbeatAt = nowMs;
1849
+ // ★通らなかった理由を1行残す(PR#88 と同じ作法): 撤去によって「黙って止まる」を作らない。
1850
+ // フックが配線されていない/管制へ届かない現場では、職人が選択メニューで止まったまま
1851
+ // 誰にも知らされない(relay はもう質問を管制へ運ばない)。せめて現場のログに痕跡を残す。
1852
+ // ★質問の中身は読まないので出せない=「メニューで止まっている」事実だけを出す。
1853
+ // 生存HBのスロットルに相乗りするので、出るのは最大でも RELAY_HEARTBEAT_INTERVAL_MS に1回。
1854
+ if (menu) {
1855
+ log(`WARN site=${cfg.site} id=${state.pending.id} 職人が確認メニューで停止中` +
1856
+ '(relayは答えない。PermissionRequestフックが管制へ渡しているか、' +
1857
+ 'フック未配線なら職人の端末で直接answerが必要)');
1118
1858
  }
1859
+ const raise = shouldRaiseBusy(busySignal, menu);
1860
+ noteBusyEdge(state, cfg.site, state.pending.id, raise, log);
1861
+ await sendHeartbeat(cfg, state.pending.id, raise);
1119
1862
  }
1120
1863
  // ── 生存中の自動再送(2-a)。DONE 未達のとき、未着信+アイドルなら同一指示を再送する ──
1121
1864
  // 冪等性最優先: 着信判定(anchor可視)は cap から即わかる。アイドルの二重チェック
@@ -1150,6 +1893,28 @@ export async function relayTick(cfg, state) {
1150
1893
  },
1151
1894
  });
1152
1895
  if (decision.verdict === 'resend') {
1896
+ // ── 中央status照合(done済み再配達の根治・2026-07-09 実地バグ) ──
1897
+ // 完了が conductor_complete(MCP直行) で立つと中央は done になるが、relay が画面のDONE行を
1898
+ // 取りこぼすとローカル pending が残り、この経路が同一指示を延々と再配達していた。上の
1899
+ // 「外部完了検出」は delivered+アイドルでしか走らず、この !delivered な再送経路とは条件が
1900
+ // 排他なので一度も中央を見ていなかった。再送しようとする今このtickでだけ照合する
1901
+ // (毎tickのポーリングにはしない=中央への負荷を増やさない)。
1902
+ const centralStatus = await fetchCommandStatus(cfg, state.pending.id);
1903
+ const reconcile = decideCentralReconcile(centralStatus);
1904
+ if (reconcile === 'drop') {
1905
+ state.abandoned.add(state.pending.id); // 以後この id は二度と配達しない。
1906
+ log(`site=${cfg.site} id=${state.pending.id} 中央で${centralStatus}確認` +
1907
+ '→再送中止・ローカル解放(done済み再配達の根治)');
1908
+ state.pending = null;
1909
+ state.lastHeartbeatAt = null;
1910
+ return; // 次tickで halt確認→fetchCommand→通常経路。
1911
+ }
1912
+ if (reconcile === 'skip') {
1913
+ // 中央不達/未知。再送を見送り次tickで再試行(照合失敗で relay 本体は落とさない)。
1914
+ log(`site=${cfg.site} id=${state.pending.id} 中央status照合できず` +
1915
+ `(status=${centralStatus ?? 'null'})→このtickは再送見送り(次tick再試行)`);
1916
+ return;
1917
+ }
1153
1918
  // ── 配達回数の絶対上限(保険・無限ループの構造的封じ込め) ──
1154
1919
  // slow相は打ち切らず継続する設計+pendingタイムアウト後の再取得で、職人窓が永久に
1155
1920
  // 未着信/未DONEだと事実上無限に再配達し得る。累計が上限を超えたらこの command を放棄し
@@ -1167,7 +1932,7 @@ export async function relayTick(cfg, state) {
1167
1932
  }
1168
1933
  // 送信直前ガード(looksLikeClaude)も初回送信と同様に尊重し、非claude窓への誤爆を防ぐ。
1169
1934
  const guardCap = tmuxCapture(cfg.site);
1170
- if (guardCap === null || !looksLikeClaude(guardCap)) {
1935
+ if (guardCap === null || !looksLikeAgent(guardCap, kindOf(cfg))) {
1171
1936
  log(`WARN site=${cfg.site} id=${state.pending.id} 再送直前にClaude Codeの箱を` +
1172
1937
  '確認できず再送中止(次tick再判定)');
1173
1938
  return;
@@ -1177,7 +1942,8 @@ export async function relayTick(cfg, state) {
1177
1942
  // id 不変=anchor 不変なので、初回と同一の送信テキストを再生成する。
1178
1943
  const text = buildSendText(state.pending.body, cfg.site, state.pending.anchor);
1179
1944
  if (!sendToTmux(cfg.site, text)) {
1180
- log(`再送 send-keys failed, keep pending: ${cfg.site}/${state.pending.id}`);
1945
+ // 失敗の理由(tmux stderr)は sendToTmux が直前の行に出している。
1946
+ log(`再送 本文の投入に失敗, keep pending: ${cfg.site}/${state.pending.id}`);
1181
1947
  return;
1182
1948
  }
1183
1949
  state.pending.resendCount += 1;
@@ -1199,10 +1965,20 @@ export async function relayTick(cfg, state) {
1199
1965
  }
1200
1966
  // DONE 未達のまま上限時間を超えたら、固着回避のため relay 内部で pending を解除して次へ進む。
1201
1967
  // (CC2 の異常終了等で DONE が永遠に来ないケースの根治。サーバ status は戻さない=relay内部のみ。)
1968
+ //
1969
+ // ★ここで畳むのは「配達の追跡」だけ(調査 176aebcf・案①-a)。生存の見張りは keepalive へ
1970
+ // 引き継ぎ、職人が生きて見える限り中央への生存通知を続ける。従来はここで通知まで止まり、
1971
+ // 作業中の職人が中央で stalled → timeout へ一方通行に落ちていた。
1202
1972
  const elapsedMs = Date.now() - state.pending.sentAt;
1203
1973
  if (elapsedMs > RELAY_PENDING_TIMEOUT_MS) {
1974
+ const handedOver = startKeepalive(state, state.pending, Date.now());
1204
1975
  log(`WARN site=${cfg.site} id=${state.pending.id} が pending タイムアウト` +
1205
- `(${Math.round(elapsedMs / 1000)}s)。DONE未達のため解除し次へ進む`);
1976
+ `(${Math.round(elapsedMs / 1000)}s)。DONE未達のため解除し次へ進む` +
1977
+ (handedOver
1978
+ ? `/★生存の見張りは継続(上限${Math.round(RELAY_KEEPALIVE_MAX_MS / 60000)}分・再送はしない)`
1979
+ : state.keepalive !== null
1980
+ ? `/見張りは引き継がず(別の指示 id=${state.keepalive.id} を見張り中)`
1981
+ : '/見張りは引き継がず(未着信=生きている根拠がないため)'));
1206
1982
  state.pending = null;
1207
1983
  }
1208
1984
  return; // sent 中は新規取得しない
@@ -1269,7 +2045,7 @@ export async function relayTick(cfg, state) {
1269
2045
  // 送信直前に capture し直し、Claude Code 固有マーカーが確認できる時だけ送る。
1270
2046
  // 確認できなければ sent 遷移もせずスキップ+WARN(サーバ status は queued のまま温存)。
1271
2047
  const guardCap = tmuxCapture(cfg.site);
1272
- if (guardCap === null || !looksLikeClaude(guardCap)) {
2048
+ if (guardCap === null || !looksLikeAgent(guardCap, kindOf(cfg))) {
1273
2049
  log(`WARN site=${cfg.site} id=${cmd.id} Claude Codeの箱と確認できないため送信せずスキップ` +
1274
2050
  '(名前一致でも非claude窓への誤爆を防止)');
1275
2051
  return;
@@ -1282,7 +2058,8 @@ export async function relayTick(cfg, state) {
1282
2058
  const text = buildSendText(cmd.body, cfg.site, anchor);
1283
2059
  if (!sendToTmux(cfg.site, text)) {
1284
2060
  // 送信失敗(sendToTmux は内部で例外も握って false を返す)。status は queued のまま据え置く。
1285
- log(`send-keys failed, keep queued: ${cfg.site}/${cmd.id}`);
2061
+ // 失敗の理由(tmux stderr)は sendToTmux が直前の行に出している。
2062
+ log(`本文の投入に失敗, keep queued: ${cfg.site}/${cmd.id}`);
1286
2063
  return;
1287
2064
  }
1288
2065
  // 送信成功 → pending を立てて DONE を待つ。★ただし sent 化はここではしない(不具合B 根治)。