@cocorograph/hub-agent 0.7.67 → 0.7.69

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/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
 
@@ -2757,6 +2936,15 @@ async function dispatch(msg, ctx) {
2757
2936
  case "error":
2758
2937
  ctx.logger.warn({ msg }, "hub error")
2759
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
+ }
2760
2948
  case "agent.shutdown": {
2761
2949
  // Cockpit の「エージェント削除 (ゴミ箱)」由来。この端末をエージェントとして
2762
2950
  // 撤去する: サービス停止・無効化・autostart 撤去のみ。ユーザーデータ
@@ -112,9 +112,13 @@ export class SiblingInjectBridge {
112
112
  const sent = bridge?.sendToCwd?.(cwd, text) ?? false
113
113
  result = sent
114
114
  ? { ok: true }
115
- : { ok: false, error: "no_live_session_for_cwd" }
115
+ : { ok: false, error: "no_live_session_for_cwd", ...this._diagnose(cwd, target_cli_kind) }
116
116
  }
117
117
 
118
+ this.logger?.[result.ok ? "info" : "warn"]?.(
119
+ { request_id, cwd, target_cli_kind, ...(result.ok ? {} : { error: result.error, live_sessions: result.live_sessions }) },
120
+ result.ok ? "sibling-inject delivered" : "sibling-inject failed",
121
+ )
118
122
  await this._writeResult(request_id, result)
119
123
  // 要求ファイルは処理後に必ず削除する (成否問わず、再処理させないため)。
120
124
  try {
@@ -124,6 +128,32 @@ export class SiblingInjectBridge {
124
128
  }
125
129
  }
126
130
 
131
+ /**
132
+ * `no_live_session_for_cwd` に「一時的か恒久的か」「TUI と混同していないか」を
133
+ * 切り分ける材料を添える (2026-07-17: エラー文字列だけでは (a) 1 回失敗 → 再送で成功
134
+ * (b) 3 回失敗 → 本当に消滅 の区別が付かず誤判断した)。
135
+ *
136
+ * ⚠️ ここに出るのは hub-agent が spawn した **チャットセッションのみ**。tmux 配下で
137
+ * 動く TUI codex は ask-sibling の宛先にならないため、一覧が空でも「codex は画面上
138
+ * 動いている」ことは普通に起きる。その旨を hint に明記する。
139
+ * @param {string} cwd
140
+ * @param {string} targetKind
141
+ */
142
+ _diagnose(cwd, targetKind) {
143
+ const all = [
144
+ ...(this.claudeBridge?.describeLiveSessions?.() ?? []),
145
+ ...(this.codexBridge?.describeLiveSessions?.() ?? []),
146
+ ]
147
+ const sameCwd = all.filter((s) => s.cwd === cwd)
148
+ const hint = sameCwd.length
149
+ ? `この cwd には ${sameCwd.map((s) => s.kind).join("/")} のチャットセッションはあるが ${targetKind} は無い。`
150
+ : `この cwd には hub-agent 管理下のチャットセッションが 1 本も無い。` +
151
+ `tmux の TUI codex は ask-sibling の宛先にならない (hub-agent が spawn した` +
152
+ ` Cockpit のチャットセッションのみが対象)。Cockpit のブラウザで対象 cwd の` +
153
+ ` ${targetKind} チャットを開き直してから再送すること。`
154
+ return { live_sessions: all, hint }
155
+ }
156
+
127
157
  /** 結果ファイルを atomic write する (呼び出し側 CLI がポーリングする)。 */
128
158
  async _writeResult(request_id, result) {
129
159
  const fp = path.join(this.dir, `${request_id}.result.json`)
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")