@cocorograph/hub-agent 0.8.22 → 0.8.24

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
@@ -52,6 +52,7 @@ import { PtyBridge } from "./pty-bridge.mjs"
52
52
  import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
53
53
  import { CodexMcpControl } from "./codex-mcp-control.mjs"
54
54
  import { CodexStreamBridge, classifyCodexInputError } from "./codex-stream-bridge.mjs"
55
+ import { enforceTotalLiveBound } from "./warm-bounds-total.mjs"
55
56
  import { UploadManager } from "./claude-upload.mjs"
56
57
  import { requestSelfUninstall } from "./service-install.mjs"
57
58
  import {
@@ -2239,6 +2240,11 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2239
2240
  // 権威的 stall 判定 (arm 非依存・cap 非依存)。state loop が観測する signal だけで turn の開閉と
2240
2241
  // 無進捗を判定する。旧 lastProgressAtByName + armed ゲートの「フック依存 + cap 140s で消える」死角を解消。
2241
2242
  const stallTracker = new StallTracker({ stallWarnMs: STALL_WARN_MS })
2243
+ // 総 live 上限 M のログ抑制状態。⚠️ **この state loop インスタンスが所有する**(module-global に
2244
+ // 置くと、複数インスタンスや並行テストが互いのログを抑制してしまう)。「超過しているが全部
2245
+ // 保護されていて刈れない」tick は 5 秒ごとに同じ行を出し続けるので、状態が変わったときと
2246
+ // 一定間隔でだけ記録する(warm-bounds-total.mjs の _shouldLog)。
2247
+ const warmBoundsLogState = { lastUnresolved: 0, lastLoggedAtMs: 0 }
2242
2248
  let stopped = false
2243
2249
 
2244
2250
  // RC-8: WS 再接続のたびに差分送信の基準 (lastByName) をクリアし、次 tick で全 session.state を
@@ -2263,8 +2269,10 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2263
2269
  } catch (err) {
2264
2270
  logger?.warn({ err: err?.message }, "gcDeadTerminals failed")
2265
2271
  }
2266
- // 子⑤ (T06544): claude 側 warm 境界(per-cwd=1 / N-cwd LRU)。従来 claude は per-session の
2267
- // softDetach タイマー(7 日)だけで件数上限が無かったため、多数の cwd を渡り歩くと warm な
2272
+ // 子⑤ (T06544): claude 側 warm 境界(per-cwd=K / N-cwd LRU)。K は現行の既定 3
2273
+ // (**旧仕様**は per-cwd=1 で「各 cwd 1 本」だった。1 cwd で複数会話を同時に生かすため
2274
+ // 一般化し、総量は下の総 live 上限 M で押さえる)。従来 claude は per-session の softDetach
2275
+ // タイマー(7 日)だけで件数上限が無かったため、多数の cwd を渡り歩くと warm な
2268
2276
  // SDK 常駐 query が蓄積し得た。codex の reconcileSessions と対で cwd 単位に有界化する。
2269
2277
  try {
2270
2278
  claudeBridge?.reconcileWarmBounds?.()
@@ -2279,6 +2287,18 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2279
2287
  } catch (err) {
2280
2288
  logger?.warn({ err: err?.message }, "gcAbandonedSessions failed")
2281
2289
  }
2290
+ // 総 live 上限 M(claude + codex 合算、既定 20)。per-cwd を 1 → K(既定 3)へ緩めたぶん、
2291
+ // cwd 単位の境界だけでは総量が膨らむため、cwd をまたいだ天井をここで適用する。
2292
+ // ⚠️ 両 CLI の reconcile が **終わった後**に呼ぶ(cwd 単位で畳んだ結果の生存数で判定する)。
2293
+ try {
2294
+ enforceTotalLiveBound({
2295
+ bridges: [claudeBridge, codexBridge].filter(Boolean),
2296
+ logger,
2297
+ logState: warmBoundsLogState,
2298
+ })
2299
+ } catch (err) {
2300
+ logger?.warn({ err: err?.message }, "enforceTotalLiveBound failed")
2301
+ }
2282
2302
  // 実コンテキスト窓サイズ (1M ベータ等) を反映。contextPctFromUsage の分母を
2283
2303
  // 5s ごとに最新化し、ドーナツが 200k 固定で振り切れるのを防ぐ。
2284
2304
  await refreshContextWindow()
@@ -0,0 +1,81 @@
1
+ /**
2
+ * 上限 / TTL 系の env を **1 箇所で検証**する共通ヘルパー(cross-review minor 6 対応)。
3
+ *
4
+ * 【なぜ必要か】旧実装は `Number(process.env.X) || <既定>` で読み、`Math.max(1, v)` しか
5
+ * 掛けていなかった。`Number("1.5")` は 1.5 を通し `Math.max(1, 1.5) === 1.5` なので、
6
+ * その値が **for ループの開始添字**(`for (let i = keepPerCwd; ...)`)に入ると実害が出る:
7
+ * - codex: `list[1.5]` が undefined のまま `_isProtected(undefined, now)` へ渡り
8
+ * `session.pumping` の参照で TypeError
9
+ * - claude: undefined の reap が no-op なのに `reaped` だけ加算され、
10
+ * **実際には畳んでいないのに畳んだと報告する**
11
+ * `Infinity` は `Number("Infinity")` が通るため実質「上限なし」になる。
12
+ *
13
+ * 【なぜ Math.floor で丸めないか】設定ミスを黙って別の値に読み替えると、運用者は
14
+ * 「設定したつもりの値」と「効いている値」がズレたまま気づけない。**既定値へ戻して warn**
15
+ * に統一し、必ず痕跡が残るようにする。
16
+ *
17
+ * 【なぜ「正の安全整数」か】上限(K / M / N)は「何本残すか」の個数、TTL は「何ミリ秒」で、
18
+ * いずれも 1 未満・非整数・非有限に意味が無い。`Number.isSafeInteger` は NaN / Infinity /
19
+ * 小数 をまとめて弾ける唯一の述語なので、これ 1 本で判定する。
20
+ */
21
+
22
+ /** 正の安全整数(>= 1)か。NaN / Infinity / 小数 / 0 / 負数 はすべて false。 */
23
+ export function isPositiveInt(value) {
24
+ return Number.isSafeInteger(value) && value >= 1
25
+ }
26
+
27
+ /** warn の出力先。logger が無い(モジュール読み込み時など)場合は stderr へ落とす。 */
28
+ function warn(logger, fields, message) {
29
+ if (typeof logger?.warn === "function") {
30
+ logger.warn(fields, message)
31
+ return
32
+ }
33
+ // eslint-disable-next-line no-console
34
+ console.warn(`[hub-agent] ${message}`, fields)
35
+ }
36
+
37
+ /**
38
+ * env から正の整数を読む。**不正値は既定値へ戻して warn**(黙って丸めない)。
39
+ *
40
+ * @param {string|string[]} names 環境変数名。配列なら「先に設定されている方を優先」して順に評価し、
41
+ * 不正値だった名前は warn したうえで次の候補へ落ちる(後方互換 env を持つ設定のため)。
42
+ * @param {number} fallback 既定値(正の安全整数であること)
43
+ * @param {object} [logger]
44
+ * @returns {number}
45
+ */
46
+ export function readPositiveIntEnv(names, fallback, logger) {
47
+ const list = Array.isArray(names) ? names : [names]
48
+ for (const name of list) {
49
+ const raw = process.env[name]
50
+ if (raw === undefined || raw === null || String(raw).trim() === "") continue
51
+ const parsed = Number(raw)
52
+ if (isPositiveInt(parsed)) return parsed
53
+ warn(
54
+ logger,
55
+ { env: name, value: String(raw), fallback },
56
+ "invalid env value (expected a positive integer); falling back to the default",
57
+ )
58
+ }
59
+ return fallback
60
+ }
61
+
62
+ /**
63
+ * 実行時に注入された上限値(テスト / 呼び出し側 opts)を検証する。env と同じ規則で、
64
+ * 不正なら **既定値へ戻して warn** する(env だけ守っても opts 経由で同じ穴が開くため)。
65
+ *
66
+ * @param {*} value
67
+ * @param {number} fallback
68
+ * @param {{ logger?: object, label?: string }} [opts]
69
+ * @returns {number}
70
+ */
71
+ export function sanitizePositiveInt(value, fallback, { logger, label = "bound" } = {}) {
72
+ if (value === undefined || value === null) return fallback
73
+ const parsed = typeof value === "number" ? value : Number(value)
74
+ if (isPositiveInt(parsed)) return parsed
75
+ warn(
76
+ logger,
77
+ { setting: label, value: String(value), fallback },
78
+ "invalid bound value (expected a positive integer); falling back to the default",
79
+ )
80
+ return fallback
81
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * **プロセス内で 1 本**のセッション生成順シーケンス(cross-review minor 4 対応)。
3
+ *
4
+ * 【なぜ CLI ごとに持ってはいけないか】warm 境界の recency が同値になったときのタイブレークに
5
+ * seq を使う。cwd 単位の境界(per-cwd=K / N-cwd LRU)は同一 CLI 内でしか比較しないので
6
+ * CLI ごとのカウンタでも成立したが、**総 live 上限 M(warm-bounds-total.mjs)は claude と codex を
7
+ * 合算して「古い方から刈る」**ため、別カウンタだと CLI をまたいだ比較が成立しない。
8
+ * 例: 古い codex が seq=100 / 新しい claude が seq=2 のとき、recency 同値だと **新しい claude が
9
+ * 先に刈られる**。プロセス内で単一の単調増加にすれば、この比較が常に生成順と一致する。
10
+ *
11
+ * cwd 内のタイブレークは「単調増加であること」しか使っていないため、共有化しても挙動は変わらない
12
+ * (値の絶対値には意味が無い。⚠️ そのためテストで絶対値を assert しないこと。module-global は
13
+ * テスト間で値が持ち越される)。
14
+ */
15
+
16
+ let SESSION_SEQ = 0
17
+
18
+ /** 次のセッション seq(1 始まりの単調増加)を返す。 */
19
+ export function nextSessionSeq() {
20
+ SESSION_SEQ += 1
21
+ return SESSION_SEQ
22
+ }
@@ -33,7 +33,20 @@
33
33
  * - 全一覧: `{op:"list-all", token}` → `{ok, live_sessions}` (全 cwd・両 CLI。クロス cwd
34
34
  * 宛先探索用、2026-07-23。cwd/target_cli_kind 不要、token のみ必須)。
35
35
  * 処理後は必ず削除し (成否問わず)、呼び出し側が待ち受けられるよう
36
- * `<request_id>.result.json` = `{ok, error?, delivered_to?, candidates?, live_sessions?, hint?}` を書く。
36
+ * `<request_id>.result.json` = `{ok, error?, delivered_to?, candidates?, live_sessions?, hint?,
37
+ * candidates_nameable?}` を書く (`candidates_nameable` は曖昧系エラーのときのみ。CLI が
38
+ * 「候補を実行可能な `--to` として並べてよいか」を hint の文言に依存せず判定するための boolean)。
39
+ *
40
+ * ⚠️ 曖昧系のエラーコードは 2 つある (2026-08-02 クロスレビュー round2 major):
41
+ * - `ambiguous_target` … 候補を `--to` で名指しできる (解消手段がある)
42
+ * - `ambiguous_target_unnameable` … 名指しでは解消できない (session_id 未確定 / resume 競合)
43
+ * 分けている理由は **版ずれが片方向にしか効かない**のを塞ぐため。`candidates_nameable` を足す
44
+ * だけだと、**新しい hub-agent × 旧 CLI** の組み合わせで旧 CLI が boolean を無視し、従来の
45
+ * `ambiguous_target` 分岐に入って**無効な `--to` を並べる** (同一 session_id の候補 2 件に対し
46
+ * 同じアドレスを 2 回案内する。旧 CLI 実物へ新応答を渡して再現済み)。エラーコードを変えると
47
+ * 旧 CLI は未知エラーとして generic 分岐へ落ち、`--to` を出さず診断 (hint / live_sessions) だけを
48
+ * 表示する (実測で確認済み)。`candidates_nameable` は新 CLI が両コードを共通処理するための
49
+ * 判断材料として **外部契約に残す**。
37
50
  */
38
51
 
39
52
  import { watch } from "node:fs"
@@ -52,6 +65,128 @@ const DELIVERY_UNCONFIRMED_HINT =
52
65
  const DELIVERY_FAILED_HINT =
53
66
  "turn RPC の受付前または受付中に失敗しました。対象セッションを確認して再送してください。"
54
67
 
68
+ /** 曖昧だが `--to` で名指しして解消できる (候補を実行可能な宛先として案内してよい)。 */
69
+ export const AMBIGUOUS_TARGET_ERROR = "ambiguous_target"
70
+ /** 曖昧で、かつ `--to` では解消できない (名指ししても一意にならない)。 */
71
+ export const AMBIGUOUS_TARGET_UNNAMEABLE_ERROR = "ambiguous_target_unnameable"
72
+
73
+ /**
74
+ * claude の曖昧候補が `--to <session_id>` で名指しできるか、できないなら**なぜか** (2026-08-02)。
75
+ *
76
+ * 名指しできる条件は「**候補の session_id が全て非 null かつ相異なる**」こと。破れるのは 2 つで、
77
+ * **待てば解決するか否かが正反対**なので理由まで返す (2026-08-02 クロスレビュー round2 minor:
78
+ * 両方に「Cockpit で 1 本に整理」と案内していたため、待てば直る状況で不要な操作を誘発していた):
79
+ * - `"unresolved"` (**null が混ざる**): `sendToCwd` は `_liveCandidatesForCwd(cwd, false)` で
80
+ * 候補を集めるため、session_id 未確定の fresh セッション (attach 直後〜SDK の system/init
81
+ * 到達前) が候補に入る。実測: `attach({cwd})` 直後の `describeLiveSessions` / `sendToCwd` の
82
+ * 候補は `session_id: null` を返す。null は宛先として書けないが、**初期化が終われば確定する**
83
+ * ので「待って `--list` で再確認」が正しい案内。
84
+ * - `"duplicate"` (**重複する**): 同一 session_id の別 session が併存する resume 競合
85
+ * (`sendToStream` が ambiguous を返す状況)。id を書いても再び複数一致し、**待っても解決しない**
86
+ * ので「1 本に整理してから再送」が正しい案内。
87
+ *
88
+ * ⚠️ 両方が同時に起きたら `"duplicate"` を優先する。待てば null は確定するが重複は残るため、
89
+ * 「待て」と案内すると手詰まりになる (保守的に整理へ倒す)。
90
+ * ⚠️ 理由は **hint の文面を決めるためだけ**に使い、外部契約 (`candidates_nameable`) は boolean の
91
+ * ままにする。CLI 側に原因判定を持ち込むと名指し可否の判定が 2 リポジトリへ二重化する。
92
+ *
93
+ * @param {object[]} [candidates] `{session_id}` を持つ候補記述子の配列
94
+ * @returns {"nameable"|"unresolved"|"duplicate"|"unknown"} `unknown` = 候補が空 (理由を特定できない)
95
+ */
96
+ function claudeCandidatesNameability(candidates) {
97
+ if (!Array.isArray(candidates) || candidates.length === 0) return "unknown"
98
+ const seen = new Set()
99
+ let hasUnresolved = false
100
+ for (const c of candidates) {
101
+ const id = c?.session_id
102
+ if (!id) hasUnresolved = true // fresh (session_id 未確定) は宛先に書けない
103
+ else if (seen.has(id)) return "duplicate" // resume 競合は待っても解決しない (優先)
104
+ else seen.add(id)
105
+ }
106
+ return hasUnresolved ? "unresolved" : "nameable"
107
+ }
108
+
109
+ /**
110
+ * 曖昧エラーの **エラーコード・案内文・機械可読な判定を 1 回の呼び出しで一緒に返す** 単一定義。
111
+ *
112
+ * ⚠️ なぜ 1 箇所に集約するか (2026-08-02): 案内文は当初「明示宛先 (sendToStream) が曖昧」の
113
+ * 分岐にしか無く、**宛先省略 (sendToCwd) が曖昧なとき**は候補一覧だけ返して解消手段を返して
114
+ * いなかった。今後「1 つの作業ディレクトリに複数の会話を同時に生かす」方針へ変わり、曖昧に
115
+ * なる状況は今より起こりやすくなるため、利用者が手詰まりにならない案内を両分岐で共有する。
116
+ * ここを 2 か所に書くと、CLI 種別や解消手段が変わったときに片方だけ古くなる。
117
+ *
118
+ * ⚠️ 解消手段は **CLI 種別ではなく「候補が名指し可能か」** で決める (2026-08-02 訂正)。旧実装は
119
+ * 「claude は一意な宛先指定子が無いので --to では解消不能」と CLI 種別だけで断定していたが、
120
+ * これは claude の 2 つの曖昧を混同していた:
121
+ * - **明示宛先の曖昧** (`sendToStream`): 同一 session_id の別 session が複数 = resume 競合。
122
+ * 候補の session_id は全て同じなので、確かに --to では解消できない → Cockpit で整理する案内。
123
+ * - **宛先省略の曖昧** (`sendToCwd`): 同一 cwd に**別々の会話**が 2 本以上。候補の session_id は
124
+ * 互いに異なるので **`--to <session_id>` で名指しできる** → 名指しを案内する。ここを「解消
125
+ * 不能」と案内すると、実際には解決できる状況で利用者が手詰まりになる (1 つの作業ディレクトリ
126
+ * に複数の会話を同時に生かす方針では、これが通常起こりうる状況になる)。
127
+ * 例外として、宛先省略でも候補に session_id 未確定の fresh が混ざると名指しできないため、その
128
+ * ときは名指し以外の案内へ落とす (判定は claudeCandidatesNameability)。
129
+ * codex は stream_id が常に一意なので、従来どおり stream_id での名指しを案内する。
130
+ *
131
+ * ⚠️ 名指しできない理由が 2 つあり、**案内が正反対になる** (2026-08-02 round2 minor):
132
+ * - 未確定 (`unresolved`) → 待てば session_id が確定する → 「初期化を待って --list で再確認」
133
+ * - 重複 (`duplicate`) → 待っても解決しない → 「1 本に整理してから再送」
134
+ * 両方に「整理」と案内すると、待てば直る状況で不要な操作 (チャットの開き直し) を誘発する。
135
+ *
136
+ * ⚠️ error / hint / `candidates_nameable` を **必ず同じ呼び出しで一緒に返す** (2026-08-02 クロス
137
+ * レビュー major / round2 major): 呼び出し側 (配布中の `cockpit_orch.py`) は「候補を実行可能な
138
+ * `--to` として並べてよいか」をこの機械可読な boolean だけで分岐し、**旧 CLI** はエラーコードだけで
139
+ * 分岐する。**hint の日本語を CLI にパースさせない** (文言を変えた瞬間に壊れるため)。判定を
140
+ * 「hint 用」「フィールド用」「エラーコード用」と 3 回呼ぶ形にすると将来ずれるので、1 つの判定から
141
+ * 全部を組み立てて構造的に食い違えないようにしている。
142
+ *
143
+ * @param {string} cliKind 宛先 CLI ("codex" | "claude")
144
+ * @param {string} [targetSessionId] 明示宛先で指定された id (省略 = 宛先省略の曖昧)
145
+ * @param {object[]} [candidates] 曖昧になった候補一覧 (claude の名指し可否判定に使う)
146
+ * @returns {{error: string, hint: string, candidates_nameable: boolean}}
147
+ * エラーコードと、空にならない案内文と、機械可読な判定
148
+ */
149
+ function ambiguousTargetOutcome(cliKind, targetSessionId, candidates) {
150
+ // 名指し可否の判定はここ 1 回だけ。error も hint もこの値から導くので三者は原理的に一致する。
151
+ // codex は stream_id が常に一意なので候補を見るまでもなく名指しできる。
152
+ const nameability = cliKind === "codex" ? "nameable" : claudeCandidatesNameability(candidates)
153
+ const nameable = nameability === "nameable"
154
+ const lead = targetSessionId
155
+ ? cliKind === "codex"
156
+ ? `指定した id (${targetSessionId}) はこの cwd で複数の live セッションに一致します。`
157
+ : `指定した session_id (${targetSessionId}) がこの cwd で複数の live セッションに一致します。`
158
+ : `この cwd には live な ${cliKind} セッションが複数あるため、宛先を省略すると一意に定まりません。`
159
+ let remedy
160
+ if (cliKind === "codex") {
161
+ remedy = `stream_id で名指ししてください (--list で確認)。`
162
+ } else if (nameability === "nameable") {
163
+ remedy =
164
+ `候補はそれぞれ別の会話で session_id が異なるため、session_id で名指しできます ` +
165
+ `(--list が表示する --to の宛先をそのまま使ってください)。`
166
+ } else if (nameability === "unresolved") {
167
+ // 待てば確定する。ここで「1 本に整理」を案内すると不要な開き直しを誘発する。
168
+ remedy =
169
+ `候補に session_id が未確定のセッション (開始直後で初期化が完了していないもの) が含まれるため、` +
170
+ `いま --to には書けません。初期化の完了を待ってから --list で最新の宛先を確認して再送してください。`
171
+ } else if (nameability === "duplicate") {
172
+ // 待っても解決しない。Cockpit 側で 1 本に減らす操作が要る。
173
+ remedy =
174
+ `候補の session_id が重複している (同じ会話を複数 resume した競合) ため、--to で書き直しても` +
175
+ `再び複数一致します。Cockpit で対象 cwd の Claude チャットを開き直して1本に整理してから` +
176
+ `再送してください。`
177
+ } else {
178
+ // 候補が空 = 理由を特定できない。待って直るとは断定できないので保守的に整理側へ倒す。
179
+ remedy =
180
+ `候補を特定できないため --to では一意に定まりません。--list で最新の宛先を確認し、` +
181
+ `解消しなければ Cockpit で対象 cwd の Claude チャットを1本に整理してから再送してください。`
182
+ }
183
+ return {
184
+ error: nameable ? AMBIGUOUS_TARGET_ERROR : AMBIGUOUS_TARGET_UNNAMEABLE_ERROR,
185
+ hint: lead + remedy,
186
+ candidates_nameable: nameable,
187
+ }
188
+ }
189
+
55
190
  function deliveredStateFor(kind) {
56
191
  return kind === "codex" ? "delivered" : "dispatched"
57
192
  }
@@ -238,23 +373,17 @@ export class SiblingInjectBridge {
238
373
  }
239
374
  } else if (r.status === "ambiguous") {
240
375
  // 宛先 id が同一 cwd の複数 live セッションに一致し一意に定まらない。
241
- // ⚠️ 解消手段は CLI で異なる (T06271 レビュー4 minor):
242
- // - codex: stream_id は一意なので stream_id で名指しできる
243
- // - claude: 一意な宛先指定子が無い (session_id は重複、stream_id は非公開) ため
244
- // --to では解消不能。Cockpit で該当チャットを開き直す / 再一覧を案内する
376
+ // 解消手段の案内は ambiguousTargetOutcome に集約 (宛先省略側の分岐と共有する)。候補を
377
+ // 渡して「その id で名指しできるか」を判定させる (claude の resume 競合はできない)。
378
+ // ⚠️ error もこの単一判定から来る (名指し不能なら ambiguous_target_unnameable)。ここに
379
+ // error をベタ書きすると、判定と食い違ったコードを返して旧 CLI の分岐を誤らせる。
380
+ // candidates / live_sessions / hint / candidates_nameable は**落とさない** (旧 CLI が
381
+ // 未知エラーとして generic 分岐に落ちたとき、診断だけは読めるようにするため)。
245
382
  result = {
246
383
  ok: false,
247
- error: "ambiguous_target",
248
384
  candidates: r.candidates,
249
385
  live_sessions: r.candidates,
250
- hint:
251
- target_cli_kind === "codex"
252
- ? `指定した id (${target_session_id}) はこの cwd で複数の live セッションに一致します。` +
253
- `stream_id で名指ししてください (--list で確認)。`
254
- : `指定した session_id (${target_session_id}) がこの cwd で複数の live セッションに ` +
255
- `一致します。Claude は一意な宛先指定子が無いため --to では解消できません。Cockpit で` +
256
- `対象 cwd の Claude チャットを開き直して1本に整理してから再送してください` +
257
- `(通常は attach 時の重複解消で発生しません)。`,
386
+ ...ambiguousTargetOutcome(target_cli_kind, target_session_id, r.candidates),
258
387
  }
259
388
  } else {
260
389
  result = {
@@ -309,11 +438,16 @@ export class SiblingInjectBridge {
309
438
  : DELIVERY_FAILED_HINT,
310
439
  }
311
440
  } else if (r.status === "ambiguous") {
441
+ // 宛先省略でも「候補一覧だけ返して終わり」にせず、明示宛先側と同じ案内を返す
442
+ // (CLI 種別で案内の有無が変わるのは不整合)。ここの候補は **別々の会話** なので、
443
+ // claude でも session_id が相異なれば --to で名指しできる (candidates を渡して判定)。
444
+ // error / hint / candidates_nameable は同じ判定から一緒に返る (新 CLI はこの boolean で
445
+ // 表示分岐し、旧 CLI は error だけを見る)。診断フィールドは落とさない。
312
446
  result = {
313
447
  ok: false,
314
- error: "ambiguous_target",
315
448
  candidates: r.candidates,
316
449
  live_sessions: r.candidates,
450
+ ...ambiguousTargetOutcome(target_cli_kind, undefined, r.candidates),
317
451
  }
318
452
  } else {
319
453
  result = { ok: false, error: "no_live_session_for_cwd", ...this._diagnose(cwd, target_cli_kind) }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * 総 live 上限 M(既定 20): claude / codex を **合算** した live セッション数の天井。
3
+ *
4
+ * 【なぜ必要か】per-cwd の上限を 1 → K(既定 3)へ緩めたので、cwd 単位の境界(per-cwd=K ×
5
+ * N-cwd LRU)だけではマシン全体の総量が K 倍まで膨らみうる。方針は「総量は増やさず配分の
6
+ * 自由度だけ上げる」なので、cwd をまたいだ総量の天井をここで別に持つ。
7
+ *
8
+ * 【なぜ 1 モジュールで合算できるか】claude / codex の両ブリッジは同一プロセスに同居している
9
+ * (src/main.mjs の agent 起動時に両方 new される)ため、プロセス内で足し合わせれば実際の
10
+ * リソース消費(1 セッション ≈ 120-140MB)と一致する。
11
+ *
12
+ * 【CLI 分岐を持たない設計】各ブリッジが `listWarmCandidates(now)` という **CLI 非依存の共通
13
+ * アダプタ** を提供し、本モジュールはその配列だけを見る。ここに `kind === 'claude'` のような
14
+ * 分岐を入れると、CLI が増えるたびに刈り取りロジックが分岐して腐るため入れない。
15
+ * テストはアダプタ形状の stub 2 本で書ける。
16
+ */
17
+ import { readPositiveIntEnv, sanitizePositiveInt } from "./positive-int-env.mjs"
18
+
19
+ /** 総 live 上限 M(claude + codex 合算)。env で調整可能。ブリッジ側に重複定義しないこと。
20
+ *
21
+ * 【なぜ 20 か】2026-08-03 の実測で、同時 live は **17 本**(claude 13 / codex 4)だった。
22
+ * 内訳は distinct な (cwd, 種別) 16 バケットに対しほぼ 1 本ずつで、2 本あるのは 1 バケットのみ。
23
+ * つまり資源を消費しているのは「1 cwd に何本開くか(深さ)」ではなく「同時に開いている cwd の
24
+ * 数(広さ)」である。したがって:
25
+ * - K=3(深さ)を許しても今日の使い方の総量は変わらない
26
+ * - M=10 にすると **広さを切る**ことになり、導入した瞬間に 7 本が畳まれる(当初案を却下)
27
+ * 構造上の天井は、**旧 per-cwd=1 時代**が 40 本(cwd 数 N=20 × per-cwd 1 × ブリッジ 2)、
28
+ * **本 PR の per-cwd=K=3 では M が無ければ 120 本**(N=20 × K=3 × ブリッジ 2)。M=20 はその
29
+ * どちらより小さく、旧天井 40 の半分でありながら実測 17 本を切らない値になる(K を緩めても
30
+ * 総量は増やさない、という方針がこの M で担保される)。
31
+ * ⚠️ 畳んでも会話は消えない(resume で復帰する)が、温まった状態を失って切替が遅くなる。
32
+ * この待ち時間は 2026-07-31 に課題として挙がっている論点なので、安易に下げないこと。 */
33
+ export const WARM_TOTAL_MAX = readPositiveIntEnv("HUB_AGENT_WARM_TOTAL_MAX", 20)
34
+
35
+ /** 「超過しているが保護されていて刈れない」状態を info で出す最小間隔(既定 5 分)。
36
+ * state loop は 5 秒周期なので、抑制しないと同じ 1 行が 1 時間に約 720 件出る。 */
37
+ export const UNRESOLVED_LOG_INTERVAL_MS = 5 * 60 * 1000
38
+
39
+ /**
40
+ * @typedef {object} WarmCandidate
41
+ * @property {string} kind 'claude' | 'codex' 等(ログ用。刈り取り判断には使わない)
42
+ * @property {string} cwd 作業ディレクトリ(ログ用)
43
+ * @property {number} recency 最終活性(epoch ms。小さいほど古い = 先に刈る)
44
+ * @property {number} seq 生成順の単調増加値(recency 同値のタイブレーク。小さいほど古い)
45
+ * @property {boolean} protected 走行中ターン / pending / タスク実行中 等で不可侵か
46
+ * @property {(reason: string) => void} reap 実際に撤去する
47
+ */
48
+
49
+ /**
50
+ * 総 live 上限を適用する。**保護されているものは刈らない**(全部保護なら上限超過を許容する
51
+ * = 走行中を殺さない方を優先する。上限は次の tick で改めて試みられる)。
52
+ *
53
+ * ⚠️ **M はハードな資源上限ではない**(cross-review round2 minor)。保護(走行中ターン / 実行中の
54
+ * 背景タスク・コマンド)は必ず skip するので、保護が同時に多数付けば live 総数は M を超えたまま
55
+ * 増え続けうる。有界なのは **1 エントリあたりの保護時間**(各ブリッジの TTL)だけで、**同時に
56
+ * 保護されうる件数には K も N も M も効かない**。ここは「一時的な超過を明示的に受容する」設計で、
57
+ * 実際の超過量は戻り値の `liveTotal` / `unresolved`(と対応する info ログ)で観測する。
58
+ * 上限を守れているかの運用判断は、M という設定値ではなくその実測値を見ること。
59
+ *
60
+ * ⚠️ 将来「保護中でも刈る」ハード上限を足すなら、**実行中の作業を中断するという新しい失敗モード**
61
+ * が生まれる。ここに暗黙に足さないこと。足すなら victim の選び方・利用者への通知・再開可否を
62
+ * 別仕様として設計する。
63
+ *
64
+ * 【ログの抑制】state loop は 5 秒周期で回るため、「超過しているが全部保護されていて刈れない」
65
+ * 状態が続くと同じ info が 1 時間に約 720 件出る(旧実装は `reaped > 0 || overflow > 0` という
66
+ * **常に真の死んだ条件**だったので毎 tick 出ていた。overflow <= 0 は手前で return 済み)。
67
+ * そこで **実際に reap した tick は従来どおり info**、**unresolved だけの tick は「状態が変わった
68
+ * とき」か「一定間隔(既定 5 分)」でのみ**出す。
69
+ *
70
+ * ⚠️ 抑制の状態は **module-global に置かない**(複数インスタンス・並行テストが互いのログを
71
+ * 抑制してしまう)。呼び出し側が持つ `logState` オブジェクトに書く。渡さなければ抑制しない。
72
+ *
73
+ * @param {{ bridges?: Array<{ listWarmCandidates?: (now: number) => WarmCandidate[] }>,
74
+ * max?: number, now?: number, logger?: object,
75
+ * logState?: {lastUnresolved?: number, lastLoggedAtMs?: number} }} [opts]
76
+ * @returns {{reaped: number, unresolved: number, liveTotal: number}}
77
+ * reaped=実際に刈った数 / unresolved=保護されて刈れなかった超過ぶん / liveTotal=候補総数
78
+ */
79
+ export function enforceTotalLiveBound({
80
+ bridges = [],
81
+ max,
82
+ now = Date.now(),
83
+ logger,
84
+ logState,
85
+ } = {}) {
86
+ // ⚠️ 上限は正の整数(1.5 / Infinity / NaN は既定へ戻して warn)。positive-int-env.mjs 参照。
87
+ const limit = sanitizePositiveInt(max, WARM_TOTAL_MAX, {
88
+ logger,
89
+ label: "warmBounds.totalMax",
90
+ })
91
+ /** @type {WarmCandidate[]} */
92
+ const candidates = []
93
+ for (const bridge of bridges) {
94
+ if (!bridge || typeof bridge.listWarmCandidates !== "function") continue
95
+ try {
96
+ const list = bridge.listWarmCandidates(now)
97
+ if (Array.isArray(list)) {
98
+ for (const c of list) if (c) candidates.push(c)
99
+ }
100
+ } catch (err) {
101
+ logger?.warn?.({ err: err?.message }, "listWarmCandidates threw")
102
+ }
103
+ }
104
+ const overflow = candidates.length - limit
105
+ if (overflow <= 0) {
106
+ // 超過が解消した = 抑制状態をリセットする。次に新しく超過したときは即座に記録される。
107
+ if (logState) {
108
+ logState.lastUnresolved = 0
109
+ logState.lastLoggedAtMs = 0
110
+ }
111
+ return { reaped: 0, unresolved: 0, liveTotal: candidates.length }
112
+ }
113
+ // 古い順(recency 昇順、同値は seq 昇順)に、非保護だけを刈る。
114
+ const evictable = candidates
115
+ .filter((c) => !c.protected)
116
+ .sort((a, b) => (a.recency ?? 0) - (b.recency ?? 0) || (a.seq ?? 0) - (b.seq ?? 0))
117
+ let reaped = 0
118
+ for (const c of evictable) {
119
+ if (reaped >= overflow) break
120
+ try {
121
+ c.reap("warm-bounds:total")
122
+ reaped += 1
123
+ } catch (err) {
124
+ logger?.warn?.({ err: err?.message, kind: c.kind, cwd: c.cwd }, "total warm bound reap threw")
125
+ }
126
+ }
127
+ // 保護されて刈れなかったぶん(>0 なら一時的に上限超過を許容している)。
128
+ const unresolved = overflow - reaped
129
+ if (_shouldLog({ reaped, unresolved, now, logState })) {
130
+ logger?.info?.(
131
+ { live_total: candidates.length, max: limit, overflow, reaped, unresolved },
132
+ "warm bounds: total live limit applied",
133
+ )
134
+ if (logState) {
135
+ logState.lastUnresolved = unresolved
136
+ logState.lastLoggedAtMs = now
137
+ }
138
+ }
139
+ return { reaped, unresolved, liveTotal: candidates.length }
140
+ }
141
+
142
+ /** ログを出すか。reap した tick は常に出す。unresolved だけの tick は、状態が変わったときか
143
+ * 一定間隔が空いたときだけ出す(logState 未指定なら抑制しない)。 */
144
+ function _shouldLog({ reaped, unresolved, now, logState }) {
145
+ if (reaped > 0) return true
146
+ if (unresolved <= 0) return false
147
+ if (!logState) return true
148
+ if (logState.lastUnresolved !== unresolved) return true
149
+ return now - (logState.lastLoggedAtMs ?? 0) >= UNRESOLVED_LOG_INTERVAL_MS
150
+ }