@cocorograph/hub-agent 0.7.66 → 0.7.68

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/bin/hub-agent.mjs CHANGED
@@ -92,6 +92,11 @@ program
92
92
  "ローカルで検出された CLI 全て (claude / codex) を既定で同期する",
93
93
  )
94
94
  .option("--cli-kind <kind>", "'claude' または 'codex' のみ同期する (省略時は検出された全 CLI)")
95
+ .option(
96
+ "--auto",
97
+ "非対話(自動同期)モード。codex mcp add / OAuth / ブラウザ起動を一切行わず、" +
98
+ "既存登録済み MCP の承認モード補完のみを行う (SessionStart フォールバック / daemon 用)",
99
+ )
95
100
  .action(async (opts) => {
96
101
  try {
97
102
  const cfg = await readConfig()
@@ -122,6 +127,9 @@ program
122
127
  hubUrl: cfg.hub_url,
123
128
  agentId: cfg.agent_id,
124
129
  agentToken: cfg.agent_token,
130
+ // --auto(非対話): SessionStart フォールバック / daemon 由来の自動同期で使う。
131
+ // codex mcp add / OAuth を呼ばず、既存テーブルの承認モード補完のみ行う。
132
+ nonInteractive: Boolean(opts.auto),
125
133
  })
126
134
  for (const cliKind of targets) {
127
135
  const r = results[cliKind]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.7.66",
3
+ "version": "0.7.68",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -74,6 +74,12 @@ export class CodexAppServerClient extends EventEmitter {
74
74
  cwd: this.cwd,
75
75
  env: this.env || process.env,
76
76
  stdio: ["pipe", "pipe", "pipe"],
77
+ // detached にして子 (node ランチャー codex.js) を新しいプロセスグループの leader に
78
+ // する。こうすると stop() で `process.kill(-pid)` によりグループごと落とせ、node
79
+ // ランチャーが spawn する native codex バイナリ (孫) まで確実に終了できる。detached
80
+ // 単体では親終了時に孫が生き残るが、stop()/shutdown() で必ずグループ kill するため
81
+ // orphan 化しない (逆に child.kill() 単体だと node だけ落ちて native が orphan 化する)。
82
+ detached: true,
77
83
  })
78
84
  this.child.stdout.on("data", (chunk) => this._onStdout(chunk))
79
85
  this.child.stderr.on("data", (chunk) => {
@@ -224,15 +230,32 @@ export class CodexAppServerClient extends EventEmitter {
224
230
  if (!this.child || this.closed) return
225
231
  this.closed = true
226
232
  this._settleAllPending(new Error("codex app-server client stopped"))
233
+ const child = this.child
227
234
  try {
228
- this.child.stdin.end()
235
+ child.stdin.end()
229
236
  } catch {
230
237
  // stdin が既に閉じている等は無視して良い (stop の冪等性を優先)。
231
238
  }
232
- try {
233
- this.child.kill()
234
- } catch {
235
- // プロセスが既に無い場合等は無視して良い。
239
+ // detached spawn によりプロセスグループ leader なので、グループごと (孫の native
240
+ // codex バイナリ含む) 落とす。グループが無い/権限エラー時は単体 kill にフォールバック。
241
+ const killGroup = (signal) => {
242
+ try {
243
+ if (typeof child.pid === "number") {
244
+ process.kill(-child.pid, signal)
245
+ return
246
+ }
247
+ } catch {
248
+ // グループが既に消えている / 未 detached 等 → 単体 kill にフォールバック。
249
+ }
250
+ try {
251
+ child.kill(signal)
252
+ } catch {
253
+ // プロセスが既に無い場合等は無視して良い。
254
+ }
236
255
  }
256
+ killGroup("SIGTERM")
257
+ // SIGTERM で落ちない (ハング等) 場合に備え、猶予後 SIGKILL でグループごと強制終了。
258
+ const t = setTimeout(() => killGroup("SIGKILL"), 3000)
259
+ t.unref?.()
237
260
  }
238
261
  }
@@ -64,6 +64,16 @@ function siblingEnvFor(cwd) {
64
64
  // 関わらず完走まで絶対に落とさない (_maybeReapAfterIdle が busy 中は予約しない)。
65
65
  const IDLE_DETACH_TTL_MS = 5 * 60 * 1000
66
66
 
67
+ // 放棄セッションの reaper 閾値。claude-stream-bridge.mjs の gcDeadTerminals と同じ思想・
68
+ // 同じ env (HUB_AGENT_DEAD_TERMINAL_TTL_MS、既定 1 時間)。detach を送らず消えた端末や、
69
+ // codex エラーで turn/completed が来ず activeTurnId が固着したスタックセッションは、
70
+ // detach→TTL(5分) の graceful reap 経路に乗らないため app-server が永久に残る (リーク)。
71
+ // これを補い、「一定時間まったく通信 (browser 入力 / app-server 通知) が無い」セッションを
72
+ // activeTurnId の有無に関わらず定期回収する。進行中の正常なターンは app-server 通知で
73
+ // 都度 touch されるため回収されない (通知が途絶えたスタックターンのみ回収される)。
74
+ const ABANDONED_SESSION_TTL_MS =
75
+ Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) || 60 * 60 * 1000
76
+
67
77
  const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
68
78
 
69
79
  // Slice 6: thread/resume が返す Thread.turns[].items[] を単一の時系列配列へ平坦化する。
@@ -191,6 +201,9 @@ export class CodexStreamBridge extends EventEmitter {
191
201
  pendingApprovals: new Map(), // request_id -> { turnId }
192
202
  detachTimer: null,
193
203
  dead: false,
204
+ // 放棄セッション reaper (gcAbandonedSessions) 用の最終活性時刻。browser 入力と
205
+ // app-server 通知 (ターン進捗) の両方で更新する。Date.now() は hub-agent 実行環境で利用可。
206
+ lastActivityMs: Date.now(),
194
207
  }
195
208
  this.sessions.set(stream_id, session)
196
209
  this._wireClient(session)
@@ -284,8 +297,17 @@ export class CodexStreamBridge extends EventEmitter {
284
297
  })
285
298
  }
286
299
 
300
+ /** gcAbandonedSessions 用の最終活性更新。browser 入力 / app-server 通知の両方で呼ぶ。 */
301
+ _touch(session) {
302
+ if (session) session.lastActivityMs = Date.now()
303
+ }
304
+
287
305
  _onNotification(session, msg) {
288
306
  const { stream_id } = session
307
+ // app-server からの通知 = ターンが進行している証跡。これで touch することで、
308
+ // 正常に走っているターンは reaper に回収されず、通知が途絶えたスタックターンだけが
309
+ // 放棄セッションとして回収される。
310
+ this._touch(session)
289
311
  switch (msg.method) {
290
312
  case "turn/started":
291
313
  session.activeTurnId = msg.params?.turn?.id || null
@@ -361,6 +383,7 @@ export class CodexStreamBridge extends EventEmitter {
361
383
  */
362
384
  async input({ stream_id, message, model, effort }) {
363
385
  const session = this._require(stream_id)
386
+ this._touch(session)
364
387
  const input = userInputFromMessage(message)
365
388
  if (session.activeTurnId) {
366
389
  try {
@@ -408,6 +431,7 @@ export class CodexStreamBridge extends EventEmitter {
408
431
  /** @returns {Promise<void>} */
409
432
  async interrupt({ stream_id }) {
410
433
  const session = this._require(stream_id)
434
+ this._touch(session)
411
435
  if (!session.activeTurnId) return
412
436
  await session.client.request("turn/interrupt", {
413
437
  threadId: session.threadId,
@@ -418,6 +442,7 @@ export class CodexStreamBridge extends EventEmitter {
418
442
  /** サーバー発の承認/入力要求への応答。result の shape は method ごとに呼び出し側が組み立てる。 */
419
443
  permissionReply({ stream_id, request_id, result }) {
420
444
  const session = this._require(stream_id)
445
+ this._touch(session)
421
446
  if (!session.pendingApprovals.has(request_id)) {
422
447
  this.logger?.warn?.({ stream_id, request_id }, "codex permissionReply: unknown request_id (ignored)")
423
448
  return
@@ -508,6 +533,49 @@ export class CodexStreamBridge extends EventEmitter {
508
533
  return session
509
534
  }
510
535
 
536
+ /**
537
+ * 放棄セッションの reaper。claude-stream-bridge.mjs の gcDeadTerminals と同じ思想:
538
+ * 一定時間まったく通信 (browser 入力 / app-server 通知) が無いセッションの app-server を
539
+ * 停止して回収する。detach を送らず消えた端末や、codex エラーで turn/completed が来ず
540
+ * activeTurnId が固着したスタックターンは detach→TTL の graceful reap 経路に乗らないため、
541
+ * これが唯一の回収経路になる (プロセスリーク / 生成中固着の根治)。
542
+ *
543
+ * 進行中の正常なターンは app-server 通知で都度 touch されるので回収されない。
544
+ * main.mjs の state loop から claudeBridge.gcDeadTerminals と並べて定期実行する想定。
545
+ *
546
+ * @param {number} [ttlMs] 無通信とみなす閾値 (テスト用に上書き可)。
547
+ * @returns {number} 回収したセッション数。
548
+ */
549
+ gcAbandonedSessions(ttlMs = ABANDONED_SESSION_TTL_MS) {
550
+ const now = Date.now()
551
+ let reaped = 0
552
+ for (const session of Array.from(this.sessions.values())) {
553
+ if (session.dead) continue
554
+ const last = session.lastActivityMs ?? 0
555
+ if (now - last < ttlMs) continue
556
+ this._clearDetachTimer(session)
557
+ session.dead = true
558
+ this.sessions.delete(session.stream_id)
559
+ try {
560
+ session.client.stop()
561
+ } catch (err) {
562
+ this.logger?.warn?.({ stream_id: session.stream_id, err: err?.message }, "gcAbandonedSessions: stop threw")
563
+ }
564
+ reaped += 1
565
+ this.logger?.info?.(
566
+ { stream_id: session.stream_id, thread_id: session.threadId, idle_ms: now - last },
567
+ "codex abandoned session reaped (no activity past TTL)",
568
+ )
569
+ this.emit("exit", {
570
+ stream_id: session.stream_id,
571
+ thread_id: session.threadId,
572
+ code: 0,
573
+ reason: "idle-reaped",
574
+ })
575
+ }
576
+ return reaped
577
+ }
578
+
511
579
  /** 全セッションを即座に終了する (agent shutdown 用。ターン完走は待たない)。 */
512
580
  shutdown() {
513
581
  for (const session of this.sessions.values()) {
@@ -16,11 +16,11 @@
16
16
  *
17
17
  * 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
18
18
  */
19
- import { promises as fs } from "node:fs"
19
+ import { promises as fs, readFileSync } from "node:fs"
20
20
  import path from "node:path"
21
21
  import os from "node:os"
22
22
  import { spawn } from "node:child_process"
23
- import { randomBytes } from "node:crypto"
23
+ import { randomBytes, createHash } from "node:crypto"
24
24
 
25
25
  const HUB_AI_RULES_BEGIN = "<!-- BEGIN HUB-AI-RULES -->"
26
26
  const HUB_AI_RULES_END = "<!-- END HUB-AI-RULES -->"
@@ -32,9 +32,19 @@ const _CLI_DIR_NAME = { claude: ".claude", codex: ".codex" }
32
32
 
33
33
  function resolveClaudeDir(cliKind = "claude") {
34
34
  // CLAUDE_DIR env override は既存の claude 専用テスト/運用で使われているため
35
- // cli_kind='claude' のときのみ効かせる (codex は CODEX_DIR で別途上書き可能)。
35
+ // cli_kind='claude' のときのみ効かせる。
36
+ //
37
+ // ⚠️ codex は **config / state / lock / bundle files で同一の canonical resolver を使う**
38
+ // (2026-07-16 修正)。以前は本関数が `CODEX_HOME` を無視して `CODEX_DIR || ~/.codex` を返す一方、
39
+ // config.toml のパス解決だけ `CODEX_HOME` を最優先していたため、active profile(CODEX_HOME 指定)
40
+ // 環境で「config は profile 側・state/lock/files は ~/.codex 側」に分裂した。codex 本体と同じく
41
+ // `CODEX_HOME` を最優先し、次に hub-agent の `CODEX_DIR` override、最後に `~/.codex` に揃える。
36
42
  if (cliKind === "codex") {
37
- return process.env.CODEX_DIR || path.join(os.homedir(), _CLI_DIR_NAME.codex)
43
+ return (
44
+ process.env.CODEX_HOME ||
45
+ process.env.CODEX_DIR ||
46
+ path.join(os.homedir(), _CLI_DIR_NAME.codex)
47
+ )
38
48
  }
39
49
  return process.env.CLAUDE_DIR || path.join(os.homedir(), _CLI_DIR_NAME.claude)
40
50
  }
@@ -353,12 +363,27 @@ function runOnce(argv, { logger } = {}) {
353
363
  */
354
364
  export const HUB_MCP_NAMES = new Set(["hub", "hub_staff"])
355
365
 
356
- export async function applyMcpServers(bundle, { agentId, agentToken, logger } = {}) {
366
+ export async function applyMcpServers(
367
+ bundle,
368
+ { agentId, agentToken, logger, nonInteractive = false, resolveBinImpl, runImpl } = {},
369
+ ) {
357
370
  const servers = Array.isArray(bundle?.mcp_servers) ? bundle.mcp_servers : []
358
371
  if (servers.length === 0) return { added: [], skipped: [] }
372
+ // CLI 解決 / 実行はテスト注入可能(既定は実 which/spawn)。決定的テストのため。
373
+ const resolveBin = resolveBinImpl || resolveCliBin
374
+ const run = runImpl || runOnce
375
+
376
+ // ⚠️ 非対話(自動同期)モードでは Claude MCP の登録に一切触らない(2026-07-16 修正)。
377
+ // Claude 版は既存 server を **remove → add** で再登録するため、add が失敗すると正常に動いていた
378
+ // Hub MCP が消えてしまう(破壊的)。自動同期は files/hooks/setup(+ codex 承認補完)が目的で、
379
+ // Claude MCP の登録は enroll / 明示手動 sync(対話)に委ねる。ここでは非破壊に全 server を skip する。
380
+ if (nonInteractive) {
381
+ logger?.info?.("非対話モードのため Claude MCP 登録(remove→add)はスキップ(既存登録を破壊しない)")
382
+ return { added: [], skipped: servers.map((s) => s?.name || "<unnamed>") }
383
+ }
359
384
 
360
385
  // `claude` CLI の存在確認 (which / where 相当)。なければ no-op。
361
- const claudeBin = await resolveCliBin("claude")
386
+ const claudeBin = await resolveBin("claude")
362
387
  if (!claudeBin) {
363
388
  logger?.info("claude CLI が PATH に無いため MCP server 自動登録をスキップ")
364
389
  return { added: [], skipped: servers.map((s) => s.name) }
@@ -411,12 +436,12 @@ export async function applyMcpServers(bundle, { agentId, agentToken, logger } =
411
436
  if (wantHeader) {
412
437
  // 既存があると add が弾かれるため、先に落とす(無ければ失敗するので無視)。
413
438
  try {
414
- await runOnce([claudeBin, "mcp", "remove", s.name, "-s", scope], { logger })
439
+ await run([claudeBin, "mcp", "remove", s.name, "-s", scope], { logger })
415
440
  } catch {
416
441
  // 未登録 → remove は失敗するが正常系。
417
442
  }
418
443
  }
419
- await runOnce(argv, { logger })
444
+ await run(argv, { logger })
420
445
  added.push(s.name)
421
446
  } catch (err) {
422
447
  // ヘッダ不要の外部 MCP は既存なら "already exists" で失敗する。これは「既に正しく
@@ -444,45 +469,71 @@ export async function applyMcpServers(bundle, { agentId, agentToken, logger } =
444
469
  * ユーザー自身が `codex mcp login hub`/`hub_staff` を一度実行する運用に
445
470
  * なる可能性が高い)。
446
471
  */
447
- export async function applyCodexMcpServers(bundle, { logger } = {}) {
472
+ export async function applyCodexMcpServers(
473
+ bundle,
474
+ { logger, nonInteractive = false, resolveBinImpl, runImpl } = {},
475
+ ) {
448
476
  const servers = Array.isArray(bundle?.mcp_servers) ? bundle.mcp_servers : []
449
- if (servers.length === 0) return { added: [], skipped: [] }
450
-
451
- const codexBin = await resolveCliBin("codex")
452
- if (!codexBin) {
453
- logger?.info("codex CLI が PATH に無いため MCP server 自動登録をスキップ")
454
- return { added: [], skipped: servers.map((s) => s.name) }
477
+ if (servers.length === 0) {
478
+ return { added: [], skipped: [], approvalUpdated: [], approvalPending: [], approvalSkipped: [], approvalError: null }
455
479
  }
480
+ // CLI バイナリ解決 / 実行はテスト注入可能(既定は実 which/spawn)。実 subprocess に依存しない
481
+ // 決定的テストのため(環境の TMPDIR noexec / PATH 解決差で偽バイナリ方式が不安定になる問題の対処)。
482
+ const resolveBin = resolveBinImpl || resolveCliBin
483
+ const run = runImpl || runOnce
456
484
 
457
485
  const added = []
458
486
  const skipped = []
459
- for (const s of servers) {
460
- if (!s?.name || !s?.url) {
461
- skipped.push(s?.name || "<unnamed>")
462
- continue
463
- }
464
- const argv = [codexBin, "mcp", "add", s.name, "--url", s.url]
465
- try {
466
- await runOnce(argv, { logger })
467
- added.push(s.name)
468
- } catch (err) {
469
- logger?.warn({ name: s.name, err: err.message }, "codex mcp add failed")
470
- skipped.push(s.name)
487
+
488
+ // ⚠️ 非対話(自動同期)モードでは **`codex mcp add` を一切呼ばない**。`codex mcp add` は Hub MCP の
489
+ // OAuth 2.0 + PKCE 登録を伴い、ブラウザ認証ポップアップを起動しうる(バックグラウンド自動同期が
490
+ // ユーザーの操作を勝手に奪う)。登録=対話(enroll / 明示手動 sync / 明示的認証)に限定し、自動同期は
491
+ // 「既に登録済み(= OAuth 認証済み)テーブルへ承認モードを補完する」ことだけを行う。
492
+ if (!nonInteractive) {
493
+ const codexBin = await resolveBin("codex")
494
+ if (!codexBin) {
495
+ logger?.info("codex CLI が PATH に無いため MCP server 自動登録をスキップ")
496
+ skipped.push(...servers.map((s) => s?.name || "<unnamed>"))
497
+ } else {
498
+ for (const s of servers) {
499
+ if (!s?.name || !s?.url) {
500
+ skipped.push(s?.name || "<unnamed>")
501
+ continue
502
+ }
503
+ const argv = [codexBin, "mcp", "add", s.name, "--url", s.url]
504
+ try {
505
+ await run(argv, { logger })
506
+ added.push(s.name)
507
+ } catch (err) {
508
+ logger?.warn({ name: s.name, err: err.message }, "codex mcp add failed")
509
+ skipped.push(s.name)
510
+ }
511
+ }
471
512
  }
472
513
  }
514
+
473
515
  // MCP 承認モードを `~/.codex/config.toml` に反映する(2026-07-15)。
474
516
  // ⚠️ `codex mcp add` には承認モードの永続設定オプションが無いため、Bundle が宣言した
475
517
  // `default_tools_approval_mode` を config.toml の `[mcp_servers.<name>]` テーブルへ直接
476
- // マージする。add の成否に関わらず全 target に対して実行する(add が「already exists」で
477
- // 失敗しても承認設定は更新する要件)。config 破壊を避けるため fail-soft(warn のみ)。
518
+ // マージする。対話 add の成否に関わらず全 target に対して実行する(add が「already exists」で
519
+ // 失敗しても承認設定は更新する要件)。非対話モードでは onlyExisting=true で「既存テーブルの承認モード
520
+ // 補完」だけを行い、未登録 server には新規テーブルを作らず pending 報告する。I/O・ネットワーク失敗
521
+ // (transient)は approvalError として上位(syncBundle)へ返し、state に「再試行すべき失敗」として
522
+ // 記録させる。config 破壊は _atomicWriteFilePreserving が防ぐ。
478
523
  let approvalUpdated = []
524
+ let approvalPending = []
525
+ let approvalSkipped = []
526
+ let approvalError = null
479
527
  try {
480
- const res = await applyCodexMcpApprovalModes(servers, { logger })
528
+ const res = await applyCodexMcpApprovalModes(servers, { logger, onlyExisting: nonInteractive })
481
529
  approvalUpdated = res.updated
530
+ approvalPending = res.pending || []
531
+ approvalSkipped = res.skipped || []
482
532
  } catch (err) {
483
533
  logger?.warn({ err: err.message }, "codex MCP approval mode の config.toml 反映に失敗")
534
+ approvalError = err?.message || String(err)
484
535
  }
485
- return { added, skipped, approvalUpdated }
536
+ return { added, skipped, approvalUpdated, approvalPending, approvalSkipped, approvalError }
486
537
  }
487
538
 
488
539
  // Codex の MCP サーバーごと承認モードとして `~/.codex/config.toml` へ書いてよい許可値
@@ -497,9 +548,8 @@ const _CODEX_MCP_APPROVAL_MODES = new Set(["auto", "prompt", "writes", "approve"
497
548
  * Windows/macOS/Linux とも `os.homedir()`(Windows は USERPROFILE)で解決する。
498
549
  */
499
550
  function resolveCodexConfigPath() {
500
- const dir =
501
- process.env.CODEX_HOME || process.env.CODEX_DIR || path.join(os.homedir(), ".codex")
502
- return path.join(dir, "config.toml")
551
+ // config / state / lock / bundle files で同一 resolver を使う(2026-07-16 修正)。
552
+ return path.join(resolveClaudeDir("codex"), "config.toml")
503
553
  }
504
554
 
505
555
  /**
@@ -517,10 +567,14 @@ function resolveCodexConfigPath() {
517
567
  * - 不正な approvalMode は無視(url のみ・または no-op)。改行コード(LF/CRLF)は維持する。
518
568
  *
519
569
  * @param {string} configText
520
- * @param {{name: string, url?: string, approvalMode?: string}} opts
570
+ * @param {{name: string, url?: string, approvalMode?: string, onlyExisting?: boolean}} opts
571
+ * onlyExisting=true のとき、標準テーブルが**まだ無い** server には新規テーブルを追記しない
572
+ * (config を無変更で返す)。自動同期(非対話)で使う: 未登録 MCP に承認モードだけ書くと
573
+ * OAuth 未認証の「半登録テーブル」を作ってしまうため、登録は対話経路(`codex mcp add`)に
574
+ * 限定し、自動同期は既存テーブルの承認モード補完だけを行う。
521
575
  * @returns {string}
522
576
  */
523
- export function upsertCodexMcpApprovalMode(configText, { name, url, approvalMode } = {}) {
577
+ export function upsertCodexMcpApprovalMode(configText, { name, url, approvalMode, onlyExisting = false } = {}) {
524
578
  if (!name) return configText
525
579
  const mode = _CODEX_MCP_APPROVAL_MODES.has(approvalMode) ? approvalMode : null
526
580
  const kv = []
@@ -548,6 +602,9 @@ export function upsertCodexMcpApprovalMode(configText, { name, url, approvalMode
548
602
  // 既に定義されている場合、標準テーブルを追記すると重複定義(不正 TOML)になる。安全側で
549
603
  // 何も変えずに返す(apply 側がスキップ扱いにする)。2026-07-15 レビュー指摘対応。
550
604
  if (_codexServerAlreadyDefined(configText, name)) return configText
605
+ // 自動同期(非対話)は未登録テーブルを作らない。OAuth 未認証の半登録を避け、登録は
606
+ // 対話経路(codex mcp add)に委ねる。呼び出し側が pending として記録する。
607
+ if (onlyExisting) return configText
551
608
  // 真に未定義 → 末尾に新規追記(Bundle 由来 server のみ・呼び出し側が保証)。
552
609
  const block = [headerBare, ...kv.map(([k, v]) => `${k} = ${v}`)].join(eol)
553
610
  let base = configText
@@ -603,18 +660,22 @@ export function upsertCodexMcpApprovalMode(configText, { name, url, approvalMode
603
660
  * 残さない)。rename が失敗した場合は破壊的フォールバックを行わず中止し、既存 config を無変更で維持する。
604
661
  *
605
662
  * @param {Array<{name?: string, url?: string, default_tools_approval_mode?: string}>} servers
606
- * @param {{configPath?: string, logger?: any, fsImpl?: any, isWindows?: boolean}} [opts]
663
+ * @param {{configPath?: string, logger?: any, fsImpl?: any, isWindows?: boolean, onlyExisting?: boolean}} [opts]
607
664
  * configPath 省略時は resolveCodexConfigPath()。fsImpl / isWindows はテスト用の注入点(既定は実 fs / 実 OS)。
608
- * @returns {Promise<{updated: string[], skipped: string[]}>}
665
+ * onlyExisting=true(自動同期・非対話)のとき、標準テーブルが**まだ無い** server には新規
666
+ * テーブルを追記せず `pending` として報告する(未登録 MCP に承認モードだけ書いて OAuth 未認証の
667
+ * 半登録を作らない。登録は対話経路に委ねる)。
668
+ * @returns {Promise<{updated: string[], skipped: string[], pending: string[]}>}
669
+ * pending: onlyExisting で未登録のためスキップした server(対話 sync / codex mcp login が必要)。
609
670
  */
610
671
  export async function applyCodexMcpApprovalModes(
611
672
  servers,
612
- { configPath, logger, fsImpl = fs, isWindows = process.platform === "win32" } = {},
673
+ { configPath, logger, fsImpl = fs, isWindows = process.platform === "win32", onlyExisting = false } = {},
613
674
  ) {
614
675
  const targets = (Array.isArray(servers) ? servers : []).filter(
615
676
  (s) => s?.name && _CODEX_MCP_APPROVAL_MODES.has(s?.default_tools_approval_mode),
616
677
  )
617
- if (targets.length === 0) return { updated: [], skipped: [] }
678
+ if (targets.length === 0) return { updated: [], skipped: [], pending: [] }
618
679
 
619
680
  const cfgPath = configPath || resolveCodexConfigPath()
620
681
  let text = ""
@@ -633,11 +694,32 @@ export async function applyCodexMcpApprovalModes(
633
694
  let next = text
634
695
  const updated = []
635
696
  const skipped = []
697
+ const pending = []
636
698
  for (const s of targets) {
699
+ // 自動同期(onlyExisting)で標準テーブルが無い server は、新規テーブルを作らない。
700
+ // 非標準形(空白入り/ドット記法/インライン)で既に定義されていれば「安全に編集できない」
701
+ // → skipped、真に未登録なら「登録が必要」→ pending として区別する(ブラウザは開かない)。
702
+ if (onlyExisting && !_codexStandardTableExists(next, s.name)) {
703
+ if (_codexServerAlreadyDefined(next, s.name)) {
704
+ logger?.warn(
705
+ { name: s.name },
706
+ "codex config.toml の該当テーブルが非標準形のため自動同期では編集できずスキップ(既存 config は不変)",
707
+ )
708
+ skipped.push(s.name)
709
+ } else {
710
+ logger?.info(
711
+ { name: s.name },
712
+ "codex MCP が未登録のため承認モードの自動反映を保留(対話 sync / codex mcp login が必要)",
713
+ )
714
+ pending.push(s.name)
715
+ }
716
+ continue
717
+ }
637
718
  const candidate = upsertCodexMcpApprovalMode(next, {
638
719
  name: s.name,
639
720
  url: s.url,
640
721
  approvalMode: s.default_tools_approval_mode,
722
+ onlyExisting,
641
723
  })
642
724
  if (candidate === next) {
643
725
  // 変化なし。原因が「既に正しく反映済み(= no-op)」か「非標準形で upsert が編集を拒否した」
@@ -666,12 +748,12 @@ export async function applyCodexMcpApprovalModes(
666
748
  next = candidate
667
749
  updated.push(s.name)
668
750
  }
669
- if (next === text) return { updated: [], skipped } // 冪等 no-op / 全スキップ
751
+ if (next === text) return { updated: [], skipped, pending } // 冪等 no-op / 全スキップ
670
752
 
671
753
  // ⚠️ 失敗しても既存 config.toml を必ず維持する fail-safe な atomic write。
672
754
  await _atomicWriteFilePreserving(cfgPath, next, fileMode, { fsImpl, isWindows, logger })
673
- logger?.info({ updated, skipped }, "codex MCP approval mode を config.toml へ反映")
674
- return { updated, skipped }
755
+ logger?.info({ updated, skipped, pending }, "codex MCP approval mode を config.toml へ反映")
756
+ return { updated, skipped, pending }
675
757
  }
676
758
 
677
759
  /**
@@ -752,6 +834,19 @@ function _codexServerAlreadyDefined(text, name) {
752
834
  return bracket.test(text) || dotted.test(text)
753
835
  }
754
836
 
837
+ /**
838
+ * `name` の MCP サーバーが **標準形ヘッダ** `[mcp_servers.<name>]`(空白/引用ゆらぎ含む)で
839
+ * 定義されているかを判定する。自動同期(onlyExisting)で「既に登録済み(承認モードだけ補完すれば
840
+ * よい)」server と「未登録(登録=OAuth が必要なため保留すべき)」server を区別するのに使う。
841
+ * 非標準形(ドット記法/インライン/空白入り)は標準テーブルではないため false を返す
842
+ * (その場合の可否判定は `_codexServerAlreadyDefined` と組み合わせて呼び出し側が行う)。
843
+ */
844
+ function _codexStandardTableExists(text, name) {
845
+ const n = _escapeRegExp(name)
846
+ const bracket = new RegExp(`^\\s*\\[\\s*mcp_servers\\s*\\.\\s*"?${n}"?\\s*\\]\\s*$`, "m")
847
+ return bracket.test(text)
848
+ }
849
+
755
850
  /**
756
851
  * TOML の `key = "..."` 行について、**値(クォート文字列)だけ**を差し替えて返す。
757
852
  * インデント・キー左辺・行末インラインコメント(`# ...`)を保持する。値がクォート文字列で
@@ -867,6 +962,351 @@ export function resolveSyncTargets(availableClis) {
867
962
  return detected.length > 0 ? detected : ["claude"]
868
963
  }
869
964
 
965
+ /**
966
+ * `<cliDir>/.hub_ai_state.json` の絶対パス。setup_hub_ai.py(Python)が `installed_version` を書き、
967
+ * hub-agent(JS)が `mcp_approval_applied_version` / `last_sync_*` を書く共有 state。
968
+ * 同一 syncBundle シーケンス内では post_install(setup) → 承認反映の順で直列に書き、跨プロセスの
969
+ * 競合は acquireBundleSyncLock で防ぐ(read-modify-write の lost update を避ける)。
970
+ */
971
+ export function hubAiStatePath(cliKind = "claude") {
972
+ return path.join(resolveClaudeDir(cliKind), ".hub_ai_state.json")
973
+ }
974
+
975
+ /**
976
+ * ドット区切り version 文字列を数値比較する。a < b で -1、a === b で 0、a > b で 1。
977
+ * 解析不能な要素は 0 扱い(session_start.py の `_version_tuple` と同じ寛容さ)。
978
+ */
979
+ export function compareBundleVersions(a, b) {
980
+ const pa = String(a || "0").split(".").map((x) => parseInt(x, 10) || 0)
981
+ const pb = String(b || "0").split(".").map((x) => parseInt(x, 10) || 0)
982
+ const len = Math.max(pa.length, pb.length)
983
+ for (let i = 0; i < len; i++) {
984
+ const da = pa[i] || 0
985
+ const db = pb[i] || 0
986
+ if (da < db) return -1
987
+ if (da > db) return 1
988
+ }
989
+ return 0
990
+ }
991
+
992
+ // codex の恒久 skip とみなす last_sync_error(同一 config・同一 version 再試行を抑止する終端状態)。
993
+ const _CODEX_TERMINAL_ERRORS = new Set(["nonstandard_config", "mcp_not_registered"])
994
+
995
+ /**
996
+ * state + 現在の config.toml テキストから、**有効な** 恒久 skip version を返す(無効なら null)。
997
+ * `last_sync_error` が terminal(非標準 config / 未登録)**かつ** 記録した config fingerprint が現在の
998
+ * config と一致するときだけ有効。config が変われば(あるいは io にリセットされれば)null を返し、再試行を許す。
999
+ * heartbeat 報告(sync)と daemon の `_bundleTargetSatisfied`(async)で同一判定を使うための共通純関数。
1000
+ */
1001
+ export function codexTerminalSkipVersion(state, configText) {
1002
+ if (!state || !_CODEX_TERMINAL_ERRORS.has(state.last_sync_error)) return null
1003
+ const v = state.mcp_approval_skipped_version
1004
+ const h = state.mcp_approval_skipped_config_hash
1005
+ if (!v || !h) return null
1006
+ const cur = createHash("sha256")
1007
+ .update(typeof configText === "string" ? configText : "")
1008
+ .digest("hex")
1009
+ return h === cur ? v : null
1010
+ }
1011
+
1012
+ /** `.hub_ai_state.json` を **sync** で読む(壊れていれば {})。heartbeat 用。 */
1013
+ export function readHubAiStateSync(cliKind = "claude") {
1014
+ try {
1015
+ const obj = JSON.parse(readFileSync(hubAiStatePath(cliKind), "utf-8"))
1016
+ return obj && typeof obj === "object" ? obj : {}
1017
+ } catch {
1018
+ return {}
1019
+ }
1020
+ }
1021
+
1022
+ /** codex config.toml のテキストを読む(読めなければ空文字)。terminal skip 判定用。 */
1023
+ export async function readCodexConfigText({ fsImpl = fs } = {}) {
1024
+ try {
1025
+ return await fsImpl.readFile(resolveCodexConfigPath(), "utf-8")
1026
+ } catch {
1027
+ return ""
1028
+ }
1029
+ }
1030
+
1031
+ /**
1032
+ * heartbeat が報告する **setup 完了済み** version(`installed_version`)を sync で読む。
1033
+ * ⚠️ `<cliDir>/scripts/manifest.json` は post_install/setup **より先に** 配置されるため、setup が失敗しても
1034
+ * manifest だけ新版になる。backend の「反映済み」判定に manifest を使うと setup 失敗を「適用済み」と誤報告して
1035
+ * 再要求が止まる。正本は setup 完了時に書かれる `installed_version`(2026-07-16 修正)。未セットアップは null。
1036
+ */
1037
+ export function readCliInstalledVersionSync(cliKind) {
1038
+ const st = readHubAiStateSync(cliKind)
1039
+ return typeof st.installed_version === "string" ? st.installed_version : null
1040
+ }
1041
+
1042
+ /**
1043
+ * `<cliDir>/scripts/manifest.json` の version を **sync** で読む(未セットアップは null)。
1044
+ * これは「配置済み(setup 完了とは限らない)」= downloaded version。backend の反映判定には使わず、参考報告用。
1045
+ */
1046
+ export function readCliBundleVersionSync(cliKind) {
1047
+ try {
1048
+ const p = path.join(resolveClaudeDir(cliKind), "scripts", "manifest.json")
1049
+ const data = JSON.parse(readFileSync(p, "utf-8"))
1050
+ return typeof data?.version === "string" ? data.version : null
1051
+ } catch {
1052
+ return null
1053
+ }
1054
+ }
1055
+
1056
+ /**
1057
+ * codex の承認反映状況を **sync** で読む(heartbeat 用)。
1058
+ * `applied` = 承認反映済み version。`skipped` = **fingerprint 検証済み** の有効な恒久 skip version
1059
+ * (config が変われば無効化されて null。io リセット後も null)。backend はこれで再要求を抑止/再開する。
1060
+ */
1061
+ export function readCodexApprovalStatusSync() {
1062
+ const state = readHubAiStateSync("codex")
1063
+ let configText = ""
1064
+ try {
1065
+ configText = readFileSync(resolveCodexConfigPath(), "utf-8")
1066
+ } catch {
1067
+ /* config 無し → 空文字(skip fingerprint は一致しないので skip は無効化される) */
1068
+ }
1069
+ return {
1070
+ applied:
1071
+ typeof state.mcp_approval_applied_version === "string"
1072
+ ? state.mcp_approval_applied_version
1073
+ : null,
1074
+ skipped: codexTerminalSkipVersion(state, configText),
1075
+ }
1076
+ }
1077
+
1078
+ /** `.hub_ai_state.json` を読む(壊れていれば {})。 */
1079
+ export async function readHubAiState(cliKind = "claude", { fsImpl = fs } = {}) {
1080
+ try {
1081
+ const obj = JSON.parse(await fsImpl.readFile(hubAiStatePath(cliKind), "utf-8"))
1082
+ return obj && typeof obj === "object" ? obj : {}
1083
+ } catch {
1084
+ return {}
1085
+ }
1086
+ }
1087
+
1088
+ /**
1089
+ * `.hub_ai_state.json` に patch をマージ保存する(既存キーは保持)。失敗は warn のみ。
1090
+ * ⚠️ **atomic write**(temp → rename)で torn read / 破損を防ぐ(2026-07-16)。並行書き込みの主保護は
1091
+ * bundle 同期ロックだが、SessionStart(Python)等ロック外の reader/writer もあるため atomic を defense-in-depth
1092
+ * で担保する(読む側は常に「古い完全な JSON」か「新しい完全な JSON」のどちらかを見る)。
1093
+ */
1094
+ export async function updateHubAiState(cliKind, patch, { fsImpl = fs, logger } = {}) {
1095
+ const p = hubAiStatePath(cliKind)
1096
+ const state = await readHubAiState(cliKind, { fsImpl })
1097
+ const next = { ...state, ...patch }
1098
+ let mode = 0o644
1099
+ try {
1100
+ mode = (await fsImpl.stat(p)).mode & 0o777
1101
+ } catch {
1102
+ /* 新規 → 0o644 */
1103
+ }
1104
+ try {
1105
+ await fsImpl.mkdir(path.dirname(p), { recursive: true })
1106
+ await _atomicWriteFilePreserving(p, JSON.stringify(next, null, 2), mode, { fsImpl, logger })
1107
+ } catch (err) {
1108
+ logger?.warn?.({ err: err?.message }, ".hub_ai_state.json の書き込みに失敗")
1109
+ }
1110
+ return next
1111
+ }
1112
+
1113
+ /**
1114
+ * 跨プロセス同期ロック(CLI 種別 = configDir 単位)。daemon / SessionStart 経由の
1115
+ * `hub-agent sync-bundle` / 手動 sync が同時に走っても、同一 cli_kind の bundle 同期は 1 本に直列化する。
1116
+ * mkdir は atomic なので EEXIST でロック保持を検知できる。TTL 超過の stale ロックは回収する
1117
+ * (プロセスがクラッシュしてロックを残した場合の自己回復)。取得できなければ null を返す(呼び出し側は skip)。
1118
+ *
1119
+ * @returns {Promise<null | (() => Promise<void>)>} release 関数(取得成功時)、または null(保持中)。
1120
+ */
1121
+ /** PID が生存しているか(signal 0)。EPERM は「存在するが権限なし=生きている」。 */
1122
+ function _pidAlive(pid) {
1123
+ if (!Number.isInteger(pid) || pid <= 0) return false
1124
+ try {
1125
+ process.kill(pid, 0)
1126
+ return true
1127
+ } catch (err) {
1128
+ return err?.code === "EPERM"
1129
+ }
1130
+ }
1131
+
1132
+ export async function acquireBundleSyncLock(
1133
+ cliKind = "claude",
1134
+ { fsImpl = fs, ttlMs = 5 * 60 * 1000, nowMs, logger, pidAliveImpl } = {},
1135
+ ) {
1136
+ const dir = resolveClaudeDir(cliKind)
1137
+ const lockDir = path.join(dir, ".hub_ai_sync.lock")
1138
+ const metaPath = path.join(lockDir, "owner.json")
1139
+ const now = typeof nowMs === "number" ? nowMs : Date.now()
1140
+ const isAlive = pidAliveImpl || _pidAlive
1141
+ await fsImpl.mkdir(dir, { recursive: true })
1142
+
1143
+ // 各取得ごとに一意な token を発行し、**自分の token と一致するときだけ lockDir を消す** release を返す。
1144
+ // stale 回収でロックが他者へ渡った後に、元保持者の release が他者のロックを誤削除するのを防ぐ。
1145
+ const makeRelease = (token) => async () => {
1146
+ try {
1147
+ const meta = JSON.parse(await fsImpl.readFile(metaPath, "utf-8"))
1148
+ if (meta?.token && meta.token !== token) return // 自分のものでない → 触らない
1149
+ } catch {
1150
+ /* meta 無し/破損: 自分が取得直後で meta を書けなかった等 → 下で後始末する */
1151
+ }
1152
+ try {
1153
+ await fsImpl.rm(lockDir, { recursive: true, force: true })
1154
+ } catch {
1155
+ /* best-effort */
1156
+ }
1157
+ }
1158
+
1159
+ const tryAcquire = async () => {
1160
+ const token = randomBytes(12).toString("hex")
1161
+ try {
1162
+ await fsImpl.mkdir(lockDir) // recursive:false → 既存なら EEXIST(atomic)
1163
+ } catch (err) {
1164
+ if (err?.code === "EEXIST") return null
1165
+ throw err
1166
+ }
1167
+ // ロック取得成功。owner.json(token / pid / at)を書く。
1168
+ // ⚠️ owner.json の書き込みに失敗したら **ロック取得自体を失敗させる**(2026-07-16 修正)。
1169
+ // owner 無しのロックを残すと staleness 判定が pid ベースにならず、初期化窓の曖昧さが復活するため。
1170
+ try {
1171
+ await fsImpl.writeFile(
1172
+ metaPath,
1173
+ JSON.stringify({ token, pid: process.pid, at: now }),
1174
+ { mode: 0o600 },
1175
+ )
1176
+ } catch (err) {
1177
+ logger?.warn?.({ err: err?.message, cliKind }, "lock owner.json 書き込み失敗 → ロックを解放")
1178
+ try {
1179
+ await fsImpl.rm(lockDir, { recursive: true, force: true })
1180
+ } catch {
1181
+ /* best-effort */
1182
+ }
1183
+ return null
1184
+ }
1185
+ return makeRelease(token)
1186
+ }
1187
+
1188
+ // stale ロックの所有権を **atomic rename で獲得** する(2026-07-16 修正)。複数プロセスが同一 stale を
1189
+ // 検知しても、lockDir を一意な隔離名へ rename できるのは 1 プロセスだけ(他は ENOENT)。`rm→mkdir` の
1190
+ // 非原子的な回収では両者が mkdir 成功して二重取得できたため、rename で「回収の勝者」を 1 本に絞る。
1191
+ const reclaimStale = async () => {
1192
+ const isoName = `${lockDir}.stale-${randomBytes(6).toString("hex")}`
1193
+ try {
1194
+ await fsImpl.rename(lockDir, isoName)
1195
+ } catch {
1196
+ return false // 別プロセスが先に回収した(ENOENT 等)→ 敗者
1197
+ }
1198
+ try {
1199
+ await fsImpl.rm(isoName, { recursive: true, force: true })
1200
+ } catch {
1201
+ /* 隔離ディレクトリの掃除失敗は無害 */
1202
+ }
1203
+ return true
1204
+ }
1205
+
1206
+ const first = await tryAcquire()
1207
+ if (first) return first
1208
+
1209
+ // 保持中 → stale 判定。
1210
+ // ⚠️ 生存中 PID からは TTL だけでロックを奪わない(2026-07-16 修正)。5 分以上かかる正当な同期を
1211
+ // 途中から並行実行させないため。回収するのは「保持プロセスが死亡」または「owner.json 不在かつ
1212
+ // lockDir mtime が TTL 超過(= crash で owner を書けなかった / 初期化窓を大幅超過)」のときだけ。
1213
+ let stale = false
1214
+ let ownerMeta = null
1215
+ try {
1216
+ ownerMeta = JSON.parse(await fsImpl.readFile(metaPath, "utf-8"))
1217
+ } catch {
1218
+ ownerMeta = null
1219
+ }
1220
+ if (ownerMeta && Number.isInteger(ownerMeta.pid)) {
1221
+ // owner がいる。死んでいれば回収、生きていれば(TTL 超過でも)奪わない。
1222
+ if (!isAlive(ownerMeta.pid)) stale = true
1223
+ } else {
1224
+ // owner.json 不在/破損 = 初期化中 or crash。lockDir の mtime で短い猶予を見て TTL 超過なら回収。
1225
+ try {
1226
+ const st = await fsImpl.stat(lockDir)
1227
+ const mtimeMs = st.mtimeMs ?? (st.mtime ? st.mtime.getTime() : now)
1228
+ if (now - mtimeMs > ttlMs) stale = true
1229
+ } catch {
1230
+ /* stat 失敗(レース中に消えた等)→ 回収しない */
1231
+ }
1232
+ }
1233
+
1234
+ if (!stale) return null
1235
+
1236
+ logger?.warn?.({ cliKind }, "stale な bundle 同期ロックを回収します")
1237
+ const won = await reclaimStale()
1238
+ if (!won) return null // 回収レースの敗者は取得しない
1239
+ // 回収に勝ったら新規取得(lockDir は rename で消えているので mkdir は成功するはず)。
1240
+ return (await tryAcquire()) || null
1241
+ }
1242
+
1243
+ /** codex config.toml の内容ハッシュ(恒久 skip の fingerprint 用。読めなければ null)。 */
1244
+ async function _hashCodexConfig(fsImpl) {
1245
+ try {
1246
+ const text = await fsImpl.readFile(resolveCodexConfigPath(), "utf-8")
1247
+ return createHash("sha256").update(text).digest("hex")
1248
+ } catch {
1249
+ return null
1250
+ }
1251
+ }
1252
+
1253
+ /**
1254
+ * codex 承認反映の結果を `.hub_ai_state.json` に記録する。
1255
+ * - 完全反映 → `mcp_approval_applied_version = version`、skip マーカー消去
1256
+ * - I/O 等の一時失敗(approvalError)→ `last_sync_error="io"`・**retriable**(applied 進めず再試行)
1257
+ * - 恒久 skip(非標準 config / 未登録)→ skip version + **config fingerprint** を記録し、同一 config・同一
1258
+ * version では再試行しない(session_start / daemon の無限ループ防止)
1259
+ * 併せて role フィルタ済み expected server 名を保存し、session_start がハードコードに頼らず判定できるようにする。
1260
+ *
1261
+ * @returns {{approvalComplete: boolean, retriable: boolean, patch: object}}
1262
+ */
1263
+ async function _recordCodexApprovalState({ version, mcp, bundle, fsImpl, logger }) {
1264
+ const expected = (Array.isArray(bundle?.mcp_servers) ? bundle.mcp_servers : [])
1265
+ .filter((s) => _CODEX_MCP_APPROVAL_MODES.has(s?.default_tools_approval_mode))
1266
+ .map((s) => s.name)
1267
+ const patch = {
1268
+ last_sync_attempt: new Date().toISOString(),
1269
+ codex_expected_mcp_servers: expected,
1270
+ }
1271
+ let approvalComplete = false
1272
+ let retriable = false
1273
+ const _markComplete = () => {
1274
+ patch.mcp_approval_applied_version = version
1275
+ patch.last_sync_error = null
1276
+ patch.mcp_approval_skipped_version = null
1277
+ patch.mcp_approval_skipped_config_hash = null
1278
+ approvalComplete = true
1279
+ }
1280
+ if (expected.length === 0) {
1281
+ // 承認モードを宣言する server が無い(後方互換の旧レスポンス)→ 承認反映は不要。
1282
+ _markComplete()
1283
+ } else if (mcp?.approvalError) {
1284
+ // I/O・ネットワーク等の一時失敗 → applied は進めず再試行させる。
1285
+ // ⚠️ 旧 skip マーカーを **消す**(2026-07-16 修正)。config を直して再同期→I/O 失敗した場合、
1286
+ // 古い skip version/hash が残っていると heartbeat が「恒久 skip 済み」と誤報告し、backend が再要求を
1287
+ // 止めてしまう。io は retriable なので skip は必ずクリアする。
1288
+ patch.last_sync_error = "io"
1289
+ patch.mcp_approval_skipped_version = null
1290
+ patch.mcp_approval_skipped_config_hash = null
1291
+ retriable = true
1292
+ } else {
1293
+ const pending = mcp?.approvalPending || []
1294
+ const skipped = mcp?.approvalSkipped || []
1295
+ const unresolved = expected.filter((n) => pending.includes(n) || skipped.includes(n))
1296
+ if (unresolved.length === 0) {
1297
+ _markComplete()
1298
+ } else {
1299
+ // 恒久 skip(非標準 config で安全に編集不可 / MCP 未登録で OAuth 必要)。config fingerprint を
1300
+ // 記録し、同一 config・同一 version では再試行しない(無限ループ防止)。config が変われば再試行する。
1301
+ patch.last_sync_error = skipped.length > 0 ? "nonstandard_config" : "mcp_not_registered"
1302
+ patch.mcp_approval_skipped_version = version
1303
+ patch.mcp_approval_skipped_config_hash = await _hashCodexConfig(fsImpl)
1304
+ }
1305
+ }
1306
+ await updateHubAiState("codex", patch, { fsImpl, logger })
1307
+ return { approvalComplete, retriable, patch }
1308
+ }
1309
+
870
1310
  /**
871
1311
  * fetch + apply + post_install(setup) + MCP server 自動登録を **同期的に** まとめて実行。
872
1312
  * cliKind='codex' で Codex 向け(`~/.codex/`、`codex mcp add`)に切り替わる
@@ -886,7 +1326,43 @@ export function resolveSyncTargets(availableClis) {
886
1326
  * (手動 `hub-agent sync-bundle` は setup 完了まで同期的に待つ)。CLI 別パスは backend が
887
1327
  * `{{CLI_DIR_NAME}}` を解決して返すため、Codex 同期で `~/.claude` の setup を誤実行しない。
888
1328
  */
889
- export async function syncBundle({ hubUrl, agentId, agentToken, logger, fetchImpl, cliKind = "claude" } = {}) {
1329
+ export async function syncBundle({
1330
+ hubUrl,
1331
+ agentId,
1332
+ agentToken,
1333
+ logger,
1334
+ fetchImpl,
1335
+ cliKind = "claude",
1336
+ nonInteractive = false,
1337
+ lock = true,
1338
+ fsImpl = fs,
1339
+ } = {}) {
1340
+ // 跨プロセス/経路の同時実行を直列化する(daemon / SessionStart 経由 CLI / 手動 sync)。
1341
+ // 取得できなければ他が同期中なので skip(冪等・重複作業回避)。lock:false でテスト時に無効化可能。
1342
+ let release = null
1343
+ if (lock) {
1344
+ release = await acquireBundleSyncLock(cliKind, { fsImpl, logger })
1345
+ if (!release) {
1346
+ logger?.info?.({ cliKind }, "別経路が bundle 同期中のためスキップ(lock 取得できず)")
1347
+ return {
1348
+ version: null,
1349
+ written: [],
1350
+ skipped_same: [],
1351
+ merged_claude_md: false,
1352
+ mcp: { added: [], skipped: [] },
1353
+ skipped: true,
1354
+ reason: "locked",
1355
+ }
1356
+ }
1357
+ }
1358
+ try {
1359
+ return await _syncBundleLocked({ hubUrl, agentId, agentToken, logger, fetchImpl, cliKind, nonInteractive, fsImpl })
1360
+ } finally {
1361
+ if (release) await release()
1362
+ }
1363
+ }
1364
+
1365
+ async function _syncBundleLocked({ hubUrl, agentId, agentToken, logger, fetchImpl, cliKind, nonInteractive, fsImpl }) {
890
1366
  const bundle = await fetchBundle({ hubUrl, agentId, agentToken, fetchImpl, cliKind })
891
1367
  const result = await applyBundle({ bundle, logger, cliKind })
892
1368
  try {
@@ -908,12 +1384,31 @@ export async function syncBundle({ hubUrl, agentId, agentToken, logger, fetchImp
908
1384
  let mcp = { added: [], skipped: [] }
909
1385
  try {
910
1386
  mcp = cliKind === "codex"
911
- ? await applyCodexMcpServers(bundle, { logger })
912
- : await applyMcpServers(bundle, { agentId, agentToken, logger })
1387
+ ? await applyCodexMcpServers(bundle, { logger, nonInteractive })
1388
+ : await applyMcpServers(bundle, { agentId, agentToken, logger, nonInteractive })
913
1389
  } catch (err) {
914
1390
  logger?.warn({ cliKind, err: err.message }, "applyMcpServers failed (continuing)")
915
1391
  }
916
- return { version: bundle.version, ...result, mcp }
1392
+ // Codex のみ: 承認モード反映の結果を `.hub_ai_state.json` に記録する。
1393
+ // installed_version(files/hooks 済み)とは別に mcp_approval_applied_version を持つことで、
1394
+ // 「files は更新済みだが承認未反映」の状態を「完了」と誤認せず、次回の自己修復対象にできる。
1395
+ // approvalComplete=false かつ retriable(I/O 失敗)の場合は、bundle 本体の version が最新でも
1396
+ // 「component 未完了」として呼び出し側(syncBundleTargets / daemon)へ再試行を促す。
1397
+ let approvalComplete = true // claude / 承認不要 codex は完了扱い
1398
+ let approvalRetriable = false
1399
+ if (cliKind === "codex") {
1400
+ try {
1401
+ const rec = await _recordCodexApprovalState({ version: bundle.version, mcp, bundle, fsImpl, logger })
1402
+ approvalComplete = rec.approvalComplete
1403
+ approvalRetriable = rec.retriable
1404
+ } catch (err) {
1405
+ // 記録自体に失敗 = 完了を保証できない → 再試行対象。
1406
+ logger?.warn?.({ err: err?.message }, "codex approval state の記録に失敗(再試行対象)")
1407
+ approvalComplete = false
1408
+ approvalRetriable = true
1409
+ }
1410
+ }
1411
+ return { version: bundle.version, ...result, mcp, approvalComplete, approvalRetriable }
917
1412
  }
918
1413
 
919
1414
  /**
@@ -933,13 +1428,32 @@ export async function syncBundleTargets({
933
1428
  fetchImpl,
934
1429
  logger,
935
1430
  syncImpl,
1431
+ nonInteractive = false,
1432
+ lock,
936
1433
  } = {}) {
937
1434
  const sync = syncImpl || syncBundle
938
1435
  const results = {}
939
1436
  const failures = []
940
1437
  for (const cliKind of Array.isArray(targets) ? targets : []) {
941
1438
  try {
942
- results[cliKind] = await sync({ hubUrl, agentId, agentToken, fetchImpl, cliKind, logger })
1439
+ const r = await sync({
1440
+ hubUrl,
1441
+ agentId,
1442
+ agentToken,
1443
+ fetchImpl,
1444
+ cliKind,
1445
+ logger,
1446
+ nonInteractive,
1447
+ ...(lock !== undefined ? { lock } : {}),
1448
+ })
1449
+ results[cliKind] = r
1450
+ // ⚠️ component 単位の未完了を failure として扱う(2026-07-16 修正)。codex の承認反映が
1451
+ // I/O 等で一時失敗(retriable)した場合、bundle 本体の version が最新になっても「承認だけ」を
1452
+ // 再試行対象にする(daemon の backoff・backend の再要求が続くよう failure に載せる)。
1453
+ // 恒久 skip(非標準 config / 未登録)は retriable=false なので failure にはしない(無限リトライ回避)。
1454
+ if (r && r.approvalRetriable) {
1455
+ failures.push(cliKind)
1456
+ }
943
1457
  } catch (err) {
944
1458
  // exit code を message から救い出す(runOnce は "... (exit N): ..." を投げる)。
945
1459
  const m = /exit (\d+)/.exec(err?.cause?.message || err?.message || "")
package/src/main.mjs CHANGED
@@ -20,6 +20,17 @@ import path from "node:path"
20
20
  import pino from "pino"
21
21
 
22
22
  import { detectAvailableClis, detectPlatform, readConfig, writeConfig } from "./config.mjs"
23
+ import {
24
+ codexTerminalSkipVersion,
25
+ compareBundleVersions,
26
+ readCliBundleVersionSync,
27
+ readCliInstalledVersionSync,
28
+ readCodexApprovalStatusSync,
29
+ readCodexConfigText,
30
+ readHubAiState,
31
+ resolveSyncTargets,
32
+ syncBundleTargets,
33
+ } from "./hub-bundle.mjs"
23
34
  import { extractLastAssistantText, pickContextText } from "./extract-paragraph.mjs"
24
35
  import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs"
25
36
  import { WsClient } from "./ws-client.mjs"
@@ -496,6 +507,152 @@ export async function kickSetupHubAiForDetectedClis({
496
507
  return targets
497
508
  }
498
509
 
510
+ // ── bundle.sync.request(backend server-driven 自動同期)の状態管理 ──────────────
511
+ //
512
+ // backend が heartbeat の `bundle_version` とリモート最新版の差分を検知すると agent へ
513
+ // `bundle.sync.request` を送る。agent はそれを受けて **非対話** で syncBundleTargets を実行する
514
+ // (files/hooks/setup + codex 承認モード補完。codex mcp add / OAuth は呼ばない)。
515
+ //
516
+ // 連続 heartbeat ごとに full bundle を取りに行かないよう、以下のガードを重ねる:
517
+ // - running: 同期中の再入を弾く
518
+ // - debounce: 直近 _BUNDLE_SYNC_MIN_INTERVAL_MS 以内の要求は間引く
519
+ // - version-guard: 要求 version が既に files+承認とも反映済みなら fetch せず skip(冪等・無駄取得回避)
520
+ // - backoff: 失敗が続くほど次回まで待つ(指数、上限あり)
521
+ // - auto_update:false(.hub_ai_state.json)を尊重し自動同期しない
522
+ const _BUNDLE_SYNC_MIN_INTERVAL_MS = 60_000
523
+ const _BUNDLE_SYNC_BACKOFF_BASE_MS = 30_000
524
+ const _BUNDLE_SYNC_BACKOFF_MAX_MS = 10 * 60_000
525
+ const _bundleSyncState = { running: false, lastAttemptAt: 0, lastFailureAt: 0, failureCount: 0 }
526
+
527
+ /** テスト用: 自動同期のガード状態をリセットする。 */
528
+ export function _resetBundleSyncStateForTest() {
529
+ _bundleSyncState.running = false
530
+ _bundleSyncState.lastAttemptAt = 0
531
+ _bundleSyncState.lastFailureAt = 0
532
+ _bundleSyncState.failureCount = 0
533
+ }
534
+
535
+ /**
536
+ * ある target(cli_kind)について、要求 version が既に反映済みかを判定する。
537
+ * - codex: mcp_approval_applied_version(無ければ installed_version)が要求以上
538
+ * - claude: installed_version が要求以上
539
+ * auto_update:false のときは「対象外(=要求は満たされたものとして扱う=同期しない)」を返す。
540
+ */
541
+ async function _bundleTargetSatisfied(cliKind, requestedVersion, readStateImpl, readConfigImpl) {
542
+ const st = (await (readStateImpl || readHubAiState)(cliKind)) || {}
543
+ if (st.auto_update === false) return true // opt-out は「同期不要」扱い
544
+ if (!requestedVersion) return false
545
+ const filesVer = st.installed_version || "0.0.0"
546
+ if (compareBundleVersions(filesVer, requestedVersion) < 0) return false
547
+ if (cliKind === "codex") {
548
+ const approvalVer = st.mcp_approval_applied_version || "0.0.0"
549
+ const approved = compareBundleVersions(approvalVer, requestedVersion) >= 0
550
+ // 恒久 skip は heartbeat と同一の共通判定(codexTerminalSkipVersion)で **fingerprint 検証済み**
551
+ // のときだけ有効。config が変わっている / io にリセットされた skip は無効化され、再試行される。
552
+ const configText = await (readConfigImpl || readCodexConfigText)()
553
+ const skipV = codexTerminalSkipVersion(st, configText)
554
+ const terminallySkipped = skipV ? compareBundleVersions(skipV, requestedVersion) >= 0 : false
555
+ if (!approved && !terminallySkipped) return false
556
+ }
557
+ return true
558
+ }
559
+
560
+ /**
561
+ * `bundle.sync.request` を処理する。非対話 syncBundleTargets を debounce/version-guard/backoff 付きで実行。
562
+ * 主経路(daemon による事前同期)。テスト注入点: syncTargetsImpl / detectImpl / readStateImpl / nowMs。
563
+ */
564
+ export async function handleBundleSyncRequest(msg, ctx, opts = {}) {
565
+ const logger = ctx?.logger
566
+ const requestedVersion = typeof msg?.version === "string" ? msg.version : null
567
+ const now = typeof opts.nowMs === "number" ? opts.nowMs : Date.now()
568
+
569
+ // ⚠️ 再入防止フラグは **await をまたぐ前に同期的に** claim する。await を挟んでから running を
570
+ // 立てると、fire-and-forget で並んだ 2 本目が running を false のまま通過して二重 sync する
571
+ // (dispatch の bundle.sync.request は直列キューを塞がない fire-and-forget のため実際に競合しうる)。
572
+ if (_bundleSyncState.running) {
573
+ logger?.info?.("bundle.sync.request: 既に同期中のため skip")
574
+ return { skipped: "running" }
575
+ }
576
+ _bundleSyncState.running = true
577
+ try {
578
+ if (now - _bundleSyncState.lastAttemptAt < _BUNDLE_SYNC_MIN_INTERVAL_MS) {
579
+ logger?.debug?.("bundle.sync.request: debounce により skip")
580
+ return { skipped: "debounced" }
581
+ }
582
+ if (_bundleSyncState.failureCount > 0) {
583
+ const backoff = Math.min(
584
+ _BUNDLE_SYNC_BACKOFF_BASE_MS * 2 ** (_bundleSyncState.failureCount - 1),
585
+ _BUNDLE_SYNC_BACKOFF_MAX_MS,
586
+ )
587
+ if (now - _bundleSyncState.lastFailureAt < backoff) {
588
+ logger?.debug?.({ backoff }, "bundle.sync.request: backoff 中のため skip")
589
+ return { skipped: "backoff" }
590
+ }
591
+ }
592
+
593
+ const config = ctx?.config
594
+ if (!config?.hub_url || !config?.agent_id || !config?.agent_token) {
595
+ logger?.warn?.("bundle.sync.request: agent 設定が不足しているため skip")
596
+ return { skipped: "no_config" }
597
+ }
598
+
599
+ const detect = opts.detectImpl || detectAvailableClis
600
+ const readStateImpl = opts.readStateImpl || readHubAiState
601
+ let targets
602
+ try {
603
+ targets = resolveSyncTargets(await detect())
604
+ } catch {
605
+ targets = ["claude"]
606
+ }
607
+
608
+ // version-guard: 全 target が要求 version 反映済みなら fetch せず skip(冪等・無駄取得回避)。
609
+ const satisfied = await Promise.all(
610
+ targets.map((c) => _bundleTargetSatisfied(c, requestedVersion, readStateImpl, opts.readConfigImpl)),
611
+ )
612
+ if (satisfied.every(Boolean)) {
613
+ logger?.info?.({ requestedVersion }, "bundle.sync.request: 既に反映済みのため skip(fetch しない)")
614
+ // 反映済みなら試行時刻だけ進めて過剰 debounce 判定を安定させる(失敗ではないので backoff は触らない)。
615
+ _bundleSyncState.lastAttemptAt = now
616
+ return { skipped: "already_satisfied" }
617
+ }
618
+
619
+ _bundleSyncState.lastAttemptAt = now
620
+ try {
621
+ const syncTargets = opts.syncTargetsImpl || syncBundleTargets
622
+ const { results, failures } = await syncTargets({
623
+ targets,
624
+ hubUrl: config.hub_url,
625
+ agentId: config.agent_id,
626
+ agentToken: config.agent_token,
627
+ logger,
628
+ nonInteractive: true, // ⚠️ 自動同期は完全非対話(codex mcp add / OAuth を呼ばない)
629
+ })
630
+ if (failures.length > 0) {
631
+ _bundleSyncState.failureCount += 1
632
+ _bundleSyncState.lastFailureAt = Date.now()
633
+ } else {
634
+ _bundleSyncState.failureCount = 0
635
+ }
636
+ // 観測用に結果を backend へ返す(次回 heartbeat で bundle_version も更新される)。
637
+ ctx?.client?.send?.({
638
+ type: "bundle.sync.result",
639
+ version: requestedVersion,
640
+ ok: failures.length === 0,
641
+ failures,
642
+ })
643
+ logger?.info?.({ requestedVersion, failures }, "bundle.sync.request 完了")
644
+ return { results, failures }
645
+ } catch (err) {
646
+ _bundleSyncState.failureCount += 1
647
+ _bundleSyncState.lastFailureAt = Date.now()
648
+ logger?.warn?.({ err: err?.message }, "bundle.sync.request 実行に失敗")
649
+ return { error: err?.message || String(err) }
650
+ }
651
+ } finally {
652
+ _bundleSyncState.running = false
653
+ }
654
+ }
655
+
499
656
  export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
500
657
  const config = await readConfig()
501
658
  if (!config) {
@@ -597,6 +754,27 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
597
754
  logger.info({ bundleVersion }, "hub bundle version detected")
598
755
  }
599
756
 
757
+ // CLI 別(claude / codex)の bundle version + codex 承認反映状況を毎 heartbeat 報告する provider。
758
+ // backend が per-CLI に stale / 承認未反映を判定して bundle.sync.request を送る(Codex-only 環境や
759
+ // 「Claude 最新・Codex 承認未反映」でも正しく検知させるため。~/.claude の manifest だけに依存しない)。
760
+ const bundleStatusProvider = () => {
761
+ const codexApproval = readCodexApprovalStatusSync()
762
+ return {
763
+ // ⚠️ backend の「反映済み」判定に使う version は **setup 完了済み** の installed_version(manifest
764
+ // ではない。manifest は setup 前に配置されるため、setup 失敗を「適用済み」と誤報告してしまう)。
765
+ bundle_versions: {
766
+ claude: readCliInstalledVersionSync("claude"),
767
+ codex: readCliInstalledVersionSync("codex"),
768
+ },
769
+ // 参考: 配置済み(setup 完了とは限らない)manifest version。判定には使わない(観測用)。
770
+ downloaded_versions: {
771
+ claude: readCliBundleVersionSync("claude"),
772
+ codex: readCliBundleVersionSync("codex"),
773
+ },
774
+ codex_approval: codexApproval,
775
+ }
776
+ }
777
+
600
778
  const client = new WsClient(config, {
601
779
  logger,
602
780
  version,
@@ -604,6 +782,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
604
782
  availableClis,
605
783
  bundleVersion,
606
784
  bundleVersionProvider: readBundleVersionSync,
785
+ bundleStatusProvider,
607
786
  bundleManifestPath: BUNDLE_MANIFEST_PATH,
608
787
  })
609
788
 
@@ -1871,6 +2050,14 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
1871
2050
  } catch (err) {
1872
2051
  logger?.warn({ err: err?.message }, "gcDeadTerminals failed")
1873
2052
  }
2053
+ // Codex 版 (claude の gcDeadTerminals と対): detach を送らず消えた / turn/completed が
2054
+ // 来ず activeTurnId が固着したスタックセッションの app-server を回収する (プロセス
2055
+ // リーク・生成中固着の根治)。進行中の正常ターンは app-server 通知で touch され残る。
2056
+ try {
2057
+ codexBridge?.gcAbandonedSessions?.()
2058
+ } catch (err) {
2059
+ logger?.warn({ err: err?.message }, "gcAbandonedSessions failed")
2060
+ }
1874
2061
  // 実コンテキスト窓サイズ (1M ベータ等) を反映。contextPctFromUsage の分母を
1875
2062
  // 5s ごとに最新化し、ドーナツが 200k 固定で振り切れるのを防ぐ。
1876
2063
  await refreshContextWindow()
@@ -2749,6 +2936,15 @@ async function dispatch(msg, ctx) {
2749
2936
  case "error":
2750
2937
  ctx.logger.warn({ msg }, "hub error")
2751
2938
  return
2939
+ case "bundle.sync.request": {
2940
+ // backend が heartbeat の bundle_version 差分を検知して送る自動同期要求。
2941
+ // 非対話 syncBundleTargets を debounce/version-guard/backoff/lock 付きで実行する
2942
+ // (codex mcp add / OAuth は呼ばない)。fire-and-forget で dispatch を塞がない。
2943
+ handleBundleSyncRequest(msg, ctx).catch((err) => {
2944
+ ctx.logger?.warn?.({ err: err?.message }, "handleBundleSyncRequest threw")
2945
+ })
2946
+ return
2947
+ }
2752
2948
  case "agent.shutdown": {
2753
2949
  // Cockpit の「エージェント削除 (ゴミ箱)」由来。この端末をエージェントとして
2754
2950
  // 撤去する: サービス停止・無効化・autostart 撤去のみ。ユーザーデータ
package/src/ws-client.mjs CHANGED
@@ -119,6 +119,12 @@ export class WsClient extends EventEmitter {
119
119
  typeof opts.bundleVersionProvider === "function"
120
120
  ? opts.bundleVersionProvider
121
121
  : null
122
+ // CLI 別(claude / codex)の bundle version と codex 承認反映状況を返す provider。
123
+ // backend が per-CLI に stale/承認未反映を判定して bundle.sync.request を送るために報告する。
124
+ // 返り値例: { bundle_versions: {claude, codex}, codex_approval: {applied, skipped} }
125
+ this.bundleStatusProvider =
126
+ typeof opts.bundleStatusProvider === "function" ? opts.bundleStatusProvider : null
127
+ this.bundleStatus = {}
122
128
  this.bundleManifestPath = opts.bundleManifestPath || null
123
129
  this.bundleWatcher = null
124
130
  this.bundleWatchDebounceTimer = null
@@ -230,8 +236,9 @@ export class WsClient extends EventEmitter {
230
236
  // 同期は backend が承認するため hello 送信後の同期 send_json で問題ない。
231
237
  // ただし flush は hello 直後ではなく streams.sync.request の後に置く方が
232
238
  // 安全 (sync 完了で stream_id がサーバに復元される想定)。
233
- // hello 前に最新の bundle version を読み直す (再接続時の鮮度確保)
239
+ // hello 前に最新の bundle version / CLI 別ステータスを読み直す (再接続時の鮮度確保)
234
240
  this._refreshBundleVersion()
241
+ this._refreshBundleStatus()
235
242
  this._sendJson({
236
243
  type: "hello",
237
244
  agent_id: this.config.agent_id,
@@ -240,6 +247,7 @@ export class WsClient extends EventEmitter {
240
247
  bundle_version: this.bundleVersion,
241
248
  platform: this.platform,
242
249
  available_clis: this.availableClis,
250
+ ...this.bundleStatus,
243
251
  })
244
252
  // Plan ε: 毎回 reconnect 直後に backend ↔ agent の stream_id を能動同期する
245
253
  // (orphan stream の即時 kill 用)。response は main.mjs の dispatch が拾い、
@@ -446,9 +454,10 @@ export class WsClient extends EventEmitter {
446
454
  this._forceReconnect()
447
455
  return
448
456
  }
449
- // heartbeat 都度 provider 経由で bundle version を最新化する。
457
+ // heartbeat 都度 provider 経由で bundle version / CLI 別ステータスを最新化する。
450
458
  // fs.watch を取り逃した変更 (atomic rename / 別マウント越し等) への保険。
451
459
  this._refreshBundleVersion()
460
+ this._refreshBundleStatus()
452
461
  // 送信失敗 (= ws not OPEN) を検知したら即時 close + reconnect 発火。
453
462
  // setInterval を放置すると死んだコネクションに heartbeat を投げ続けて
454
463
  // 30s 間気付かないので、close を強制トリガーする。
@@ -456,6 +465,7 @@ export class WsClient extends EventEmitter {
456
465
  type: "heartbeat",
457
466
  uptime_sec: Math.floor((Date.now() - this.startedAt) / 1000),
458
467
  bundle_version: this.bundleVersion,
468
+ ...this.bundleStatus,
459
469
  })
460
470
  if (!ok) {
461
471
  this.logger?.warn("heartbeat send failed, forcing reconnect")
@@ -495,6 +505,21 @@ export class WsClient extends EventEmitter {
495
505
  }
496
506
  }
497
507
 
508
+ /**
509
+ * CLI 別 bundle version + codex 承認状況を provider から読み直して `this.bundleStatus` を更新する。
510
+ * hello / heartbeat に spread して backend へ報告する(backend は per-CLI に stale 判定する)。
511
+ * 失敗しても空オブジェクトのまま(旧 backend / provider 未設定でも従来どおり動く)。
512
+ */
513
+ _refreshBundleStatus() {
514
+ if (!this.bundleStatusProvider) return
515
+ try {
516
+ const s = this.bundleStatusProvider()
517
+ this.bundleStatus = s && typeof s === "object" ? s : {}
518
+ } catch (err) {
519
+ this.logger?.warn({ err: err.message }, "bundle status refresh failed")
520
+ }
521
+ }
522
+
498
523
  /**
499
524
  * `bundleManifestPath` を fs.watch で監視し、変更時に即時 heartbeat を送る。
500
525
  * Cockpit UI への反映ラグを 30s heartbeat 周期から ~15s (UI poll) に短縮する。
@@ -553,10 +578,12 @@ export class WsClient extends EventEmitter {
553
578
  this.bundleWatchDebounceTimer = null
554
579
  const changed = this._refreshBundleVersion()
555
580
  if (!changed) return
581
+ this._refreshBundleStatus()
556
582
  const ok = this._sendJson({
557
583
  type: "heartbeat",
558
584
  uptime_sec: Math.floor((Date.now() - this.startedAt) / 1000),
559
585
  bundle_version: this.bundleVersion,
586
+ ...this.bundleStatus,
560
587
  })
561
588
  if (!ok) {
562
589
  this.logger?.warn("bundle-change heartbeat send failed, forcing reconnect")