@cocorograph/hub-agent 0.8.23 → 0.8.25
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/README.md +13 -12
- package/bin/hub-agent.mjs +7 -37
- package/package.json +2 -5
- package/scripts/install.sh +5 -5
- package/src/claude-stream-bridge.mjs +264 -30
- package/src/codex-stream-bridge.mjs +261 -49
- package/src/hooks.mjs +0 -17
- package/src/main.mjs +113 -293
- package/src/plugin-loader.mjs +49 -4
- package/src/plugin-uninstall.mjs +61 -0
- package/src/positive-int-env.mjs +81 -0
- package/src/session-seq.mjs +22 -0
- package/src/tmux.mjs +4 -32
- package/src/warm-bounds-total.mjs +150 -0
- package/src/ws-client.mjs +19 -89
- package/plugins/10-tailscale-remote/README.md +0 -71
- package/plugins/10-tailscale-remote/config.example.json +0 -6
- package/plugins/10-tailscale-remote/plugin.mjs +0 -99
- package/scripts/fix-node-pty-perms.mjs +0 -89
- package/src/plugin-install.mjs +0 -111
- package/src/pty-bridge.mjs +0 -510
package/src/plugin-loader.mjs
CHANGED
|
@@ -13,7 +13,45 @@ import { pathToFileURL } from "node:url"
|
|
|
13
13
|
|
|
14
14
|
import { paths } from "./config.mjs"
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* プラグインディレクトリ名の形 (`<prio>-<name>`)。
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ これは形の一部でしかない。受理判定は必ず `parsePluginDirName()` を使うこと
|
|
20
|
+
* (正規表現だけを共有しても「読める形」と「消せる形」は揃わない)。
|
|
21
|
+
*/
|
|
22
|
+
export const PLUGIN_DIR_REGEX = /^(\d+)-(.+)$/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* プラグインディレクトリ名を検証し、`{ prio, name }` に分解する。受理しない名前は null。
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ **受理集合の定義はこの 1 関数だけに置く**。discovery (loadPlugins) と削除
|
|
28
|
+
* (plugin-uninstall.mjs) が同じ集合を使わないと「置けるのに消せない」「読めるのに
|
|
29
|
+
* 拒否される」ディレクトリが生まれる。以前は正規表現だけを共有し、uninstall 側に
|
|
30
|
+
* `name.includes("..")` を足していたため、`10-my..plugin` を loader は読み込むのに
|
|
31
|
+
* uninstall は拒否していた。
|
|
32
|
+
*
|
|
33
|
+
* 判定順:
|
|
34
|
+
* 1. 文字列でない / 空 → 拒否
|
|
35
|
+
* 2. `/` または `\` を含む → 拒否 (パス区切りの持ち込みを許さない。POSIX / WSL では
|
|
36
|
+
* `\` は正当なファイル名文字なので、loader 側もここで揃えて読み飛ばす)
|
|
37
|
+
* 3. `.` / `..` そのもの → 拒否
|
|
38
|
+
* 4. `PLUGIN_DIR_REGEX` に一致しない → 拒否
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ セグメント内部の連続ドット (`10-my..plugin`) は **許可** する。区切り文字を
|
|
41
|
+
* 既に拒否済みなので単一セグメントであり、上位への移動にはならない
|
|
42
|
+
* (`path.resolve` は `10-..` を親参照として扱わない)。
|
|
43
|
+
*
|
|
44
|
+
* @param {unknown} name
|
|
45
|
+
* @returns {{ prio: number, name: string } | null}
|
|
46
|
+
*/
|
|
47
|
+
export function parsePluginDirName(name) {
|
|
48
|
+
if (typeof name !== "string" || name === "") return null
|
|
49
|
+
if (name.includes("/") || name.includes("\\")) return null
|
|
50
|
+
if (name === "." || name === "..") return null
|
|
51
|
+
const m = PLUGIN_DIR_REGEX.exec(name)
|
|
52
|
+
if (!m) return null
|
|
53
|
+
return { prio: Number(m[1]), name: m[2] }
|
|
54
|
+
}
|
|
17
55
|
|
|
18
56
|
/**
|
|
19
57
|
* Plugins discovery。読み込み順は dirname の数値昇順。
|
|
@@ -33,9 +71,16 @@ export async function loadPlugins(logger) {
|
|
|
33
71
|
const candidates = entries
|
|
34
72
|
.filter((d) => d.isDirectory())
|
|
35
73
|
.map((d) => {
|
|
36
|
-
const
|
|
37
|
-
if (!
|
|
38
|
-
|
|
74
|
+
const parsed = parsePluginDirName(d.name)
|
|
75
|
+
if (!parsed) {
|
|
76
|
+
// 読み飛ばしを黙って隠すと、利用者は「置いたのに動かない」理由に辿り着けない。
|
|
77
|
+
logger?.warn(
|
|
78
|
+
{ dir: d.name },
|
|
79
|
+
"plugin dir name rejected (skipped): expected <prio>-<name> without path separators (e.g. 10-my-plugin)",
|
|
80
|
+
)
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
return { dir: d.name, prio: parsed.prio, name: parsed.name }
|
|
39
84
|
})
|
|
40
85
|
.filter(Boolean)
|
|
41
86
|
.sort((a, b) => a.prio - b.prio)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hub-agent plugins uninstall <name>` の実装。
|
|
3
|
+
*
|
|
4
|
+
* プラグインは利用者が `~/.hub/plugins/<prio>-<name>/` へ自分で配置する運用なので、
|
|
5
|
+
* CLI が提供するのは「読み込み済み一覧 (list)」と「配置済みディレクトリの削除
|
|
6
|
+
* (uninstall)」だけ。リポ同梱 example を配る install 系は持たない。
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ セキュリティ: 引数の `name` はそのままパス結合すると `../` で plugins ディレクトリの
|
|
9
|
+
* 外を再帰削除できてしまう (旧 plugin-install.mjs の uninstallPlugin はこれを塞いでいな
|
|
10
|
+
* かった)。次の 2 段で塞ぐ:
|
|
11
|
+
* 1. ディレクトリ名を `plugin-loader.mjs` の `parsePluginDirName()` で検証する
|
|
12
|
+
* (discovery 側と **同じ 1 つの受理集合** を再利用する。判定を書き写すと
|
|
13
|
+
* 「読める形」と「消せる形」がずれ、置けるのに消せないディレクトリが生まれる)
|
|
14
|
+
* 2. 削除直前に `path.resolve` した結果が plugins ディレクトリ配下に収まっているか
|
|
15
|
+
* を再確認する (形の検証をすり抜けた場合の最後の砦)
|
|
16
|
+
*/
|
|
17
|
+
import { promises as fs } from "node:fs"
|
|
18
|
+
import path from "node:path"
|
|
19
|
+
|
|
20
|
+
import { paths as configPaths } from "./config.mjs"
|
|
21
|
+
import { parsePluginDirName } from "./plugin-loader.mjs"
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `~/.hub/plugins/<name>/` を再帰削除する。存在しない場合も成功扱い (冪等)。
|
|
25
|
+
*
|
|
26
|
+
* @param {string} name プラグインディレクトリ名 (`<prio>-<name>` 形式)
|
|
27
|
+
* @returns {Promise<{ dest: string, removed: boolean }>}
|
|
28
|
+
*/
|
|
29
|
+
export async function uninstallPlugin(name) {
|
|
30
|
+
if (!name || typeof name !== "string") {
|
|
31
|
+
throw new Error("plugin name is required")
|
|
32
|
+
}
|
|
33
|
+
// ⚠️ 受理集合は loader と共有する 1 つの validator が決める (ここで条件を足さない)。
|
|
34
|
+
if (!parsePluginDirName(name)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`invalid plugin name "${name}": expected <prio>-<name> (e.g. 10-my-plugin)`,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const pluginsRoot = path.resolve(configPaths.pluginsDir)
|
|
41
|
+
const dest = path.resolve(path.join(pluginsRoot, name))
|
|
42
|
+
// 封じ込めの再確認。ここに来る時点で形は検証済みだが、削除は不可逆なので結合結果も見る。
|
|
43
|
+
// ⚠️ ここで見ているのは「**設定された論理 root** (configPaths.pluginsDir) 配下に収まって
|
|
44
|
+
// いるか」であり、実 (real) パスではない。`path.resolve` は字句正規化で symlink を
|
|
45
|
+
// 解決しないため、root 自体が別ボリュームへの symlink ならその先が正式な plugins root と
|
|
46
|
+
// して扱われる (loader も同じ論理 root を読むので一貫する)。`realpath` による拒否は
|
|
47
|
+
// 入れない — `.hub` や plugins を別ボリュームへ置く環境を壊し、かつ不存在時に ENOENT で
|
|
48
|
+
// 「存在しなくても成功 (冪等)」の契約も壊れるため。
|
|
49
|
+
if (!dest.startsWith(pluginsRoot + path.sep)) {
|
|
50
|
+
throw new Error(`refusing to remove "${dest}": outside of ${pluginsRoot}`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await fs.rm(dest, { recursive: true, force: true })
|
|
55
|
+
} catch (err) {
|
|
56
|
+
throw new Error(`failed to remove ${dest}: ${err.message}`)
|
|
57
|
+
}
|
|
58
|
+
return { dest, removed: true }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default { uninstallPlugin }
|
|
@@ -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
|
+
}
|
package/src/tmux.mjs
CHANGED
|
@@ -588,7 +588,7 @@ export async function setTmuxGlobalEnv(name, value, opts = {}) {
|
|
|
588
588
|
}
|
|
589
589
|
|
|
590
590
|
/**
|
|
591
|
-
* 汎用 tmux
|
|
591
|
+
* 汎用 tmux 実行 (raw)。任意の tmux サブコマンドを実行して結果を返す低レベル API。
|
|
592
592
|
*
|
|
593
593
|
* @returns {Promise<{stdout: string, stderr: string, exit_code: number}>}
|
|
594
594
|
*/
|
|
@@ -913,16 +913,9 @@ export async function createSession(name, cwd, opts = {}) {
|
|
|
913
913
|
// has-session が非 0 = セッション無し
|
|
914
914
|
}
|
|
915
915
|
await execFileP(tmuxBin(opts), ["new-session", "-d", "-s", name, "-c", resolvedCwd])
|
|
916
|
-
//
|
|
916
|
+
// mouse mode を ON にする (スマホの touch swipe → SGR wheel escape を
|
|
917
917
|
// tmux が copy-mode スクロールとして拾えるようにするため)。tmux 2.1+ で `mouse`
|
|
918
918
|
// option は session-scoped、`-t <session>` 指定で他 session への副作用なし。
|
|
919
|
-
//
|
|
920
|
-
// ⚠️ ただしこれは「初期既定値」にすぎない。実際の値は接続してきた端末に応じて
|
|
921
|
-
// pty.attach 時に setSessionMouse() で上書きされる: デスクトップ attach は off
|
|
922
|
-
// (= マウスのドラッグを xterm のローカル選択にして、コピー奪取・button release
|
|
923
|
-
// 取りこぼしによる操作不能を防ぐ。0.5.11 で全 session を mouse on にしたことが
|
|
924
|
-
// デスクトップのドラッグ選択を壊した回帰の修正)。スマホ attach は on のまま。
|
|
925
|
-
// 旧フロント (mouse フィールド未送信) はこの既定値 on がそのまま使われる。
|
|
926
919
|
try {
|
|
927
920
|
await execFileP(tmuxBin(opts), ["set-option", "-t", name, "mouse", "on"])
|
|
928
921
|
} catch (err) {
|
|
@@ -995,27 +988,6 @@ export async function createSession(name, cwd, opts = {}) {
|
|
|
995
988
|
}
|
|
996
989
|
}
|
|
997
990
|
|
|
998
|
-
/**
|
|
999
|
-
* 指定 session の tmux mouse mode を on/off する。pty.attach 時に接続端末の
|
|
1000
|
-
* 種別に応じて呼ぶ (デスクトップ=off / スマホ=on)。失敗してもセッションの
|
|
1001
|
-
* 基本機能は動くので warn のみで握り潰す。
|
|
1002
|
-
* @param {string} name session 名
|
|
1003
|
-
* @param {boolean} on true=mouse on / false=mouse off
|
|
1004
|
-
* @param {{logger?: any, tmuxBin?: string}} [opts]
|
|
1005
|
-
*/
|
|
1006
|
-
export async function setSessionMouse(name, on, opts = {}) {
|
|
1007
|
-
if (!name) return
|
|
1008
|
-
const value = on ? "on" : "off"
|
|
1009
|
-
try {
|
|
1010
|
-
await execFileP(tmuxBin(opts), ["set-option", "-t", name, "mouse", value])
|
|
1011
|
-
} catch (err) {
|
|
1012
|
-
opts.logger?.warn?.(
|
|
1013
|
-
{ session: name, mouse: value, err: err?.message || String(err) },
|
|
1014
|
-
"tmux set-option mouse failed",
|
|
1015
|
-
)
|
|
1016
|
-
}
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
991
|
/** session_id として安全な形だけ許可する (send-keys へ流すためコマンド注入を防ぐ)。
|
|
1020
992
|
* Claude の session_id は UUID (ASCII 英数 + ハイフン) なので、それ以外は弾く。 */
|
|
1021
993
|
export function isSafeSessionId(sessionId) {
|
|
@@ -1172,8 +1144,8 @@ const _delay = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
|
1172
1144
|
* resumeWithMessage は改行を空白へ畳む単一行用 (誤確定防止) でチャット本文の複数行を壊すため、
|
|
1173
1145
|
* こちらは **bracketed paste** (ESC[200~ … ESC[201~) で本文を「貼り付け」として送り、改行を
|
|
1174
1146
|
* 保ったまま入力欄へ入れてから、確定の Enter (CR) を別途送る。これは frontend の通常送信
|
|
1175
|
-
* (`\x1b[200~${text}\x1b[201~` → CR) と同じ手順を agent 側で再現したもの。
|
|
1176
|
-
*
|
|
1147
|
+
* (`\x1b[200~${text}\x1b[201~` → CR) と同じ手順を agent 側で再現したもの。tmux send-keys
|
|
1148
|
+
* で直接ペインへ届くので、ブラウザ側が何も開いていなくても投入できる。
|
|
1177
1149
|
*
|
|
1178
1150
|
* @param {string} name tmux セッション名
|
|
1179
1151
|
* @param {string} text 投入する本文 (複数行可)
|
|
@@ -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
|
+
}
|
package/src/ws-client.mjs
CHANGED
|
@@ -49,36 +49,17 @@ const STABLE_CONNECTION_MS = 10_000
|
|
|
49
49
|
// 当たって連発失敗するため、最低 5s 待つ。30s リトライまで段階的に伸びる。
|
|
50
50
|
const MIN_BACKOFF_AFTER_5XX_MS = 5_000
|
|
51
51
|
|
|
52
|
-
// outbound pty.data buffer のサイズ上限。
|
|
53
|
-
// WS not OPEN 中に pty.data を捨てると Cockpit 上のターミナル描画が欠落して
|
|
54
|
-
// 「動かない / 一部だけ表示される」体感を生むため、reconnect 後に flush する。
|
|
55
|
-
// pty.data は冪等で順序維持できれば xterm 側で正しく再現できる。
|
|
56
|
-
// 1 frame は典型的に 数十〜数百 bytes (claude TUI の partial redraw)。
|
|
57
|
-
// 500 frame ≒ 数十KB を 1 切断あたりの上限とする。
|
|
58
|
-
const PTY_BUFFER_MAX_FRAMES = 500
|
|
59
|
-
// outbound buffer に残った pty.data が古すぎると、ターミナルの履歴として
|
|
60
|
-
// 再生する意味が薄れる (ユーザーが視覚的に「過去のもの」として無視する範囲)。
|
|
61
|
-
// 30 秒以上経過した pty.data は flush 時に破棄する。
|
|
62
|
-
const PTY_BUFFER_MAX_AGE_MS = 30_000
|
|
63
|
-
|
|
64
52
|
// agent→browser の制御/イベント系メッセージのうち、WS not OPEN 中に捨てると
|
|
65
53
|
// Cockpit の挙動が壊れるものを reconnect 越しに保持する (機構③: 本番 agent.log で
|
|
66
54
|
// `claude.jsonl.event ... "ws send skipped (not open)"` のドロップを観測)。
|
|
67
|
-
// -
|
|
68
|
-
//
|
|
69
|
-
// - claude/codex の event / ready / exit / error / permission
|
|
55
|
+
// - claude/codex の input ack,nack: 喪失すると browser の pendingAcks が永久滞留し
|
|
56
|
+
// 「送信待ち(未配信)」が消えない (S1/S3)。再接続 flush で遅れて届けば即解消する。
|
|
57
|
+
// - claude/codex の event / ready / exit / error / permission:
|
|
70
58
|
// 喪失すると新規セッションの楽観バブルが本物へ昇格できず、再 attach も固着する。
|
|
71
59
|
// - claude.jsonl.event / session.event: TUI と一覧の確定状態を復元する。
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
// pty.data とは別バッファにするのは、高頻度な pty.data の洪水で低頻度な ack/event が
|
|
75
|
-
// リング evict されるのを防ぐため (pty.data は冪等で最新フレームだけ届けば良いが、
|
|
76
|
-
// ack/event は 1 件ずつ意味を持つ)。
|
|
60
|
+
// 高頻度の delta 系はここに積まず (下の isEphemeral*DeltaEvent で除外)、低頻度な
|
|
61
|
+
// ack/event がリング evict されるのを防ぐ (ack/event は 1 件ずつ意味を持つ)。
|
|
77
62
|
const BUFFERED_CTRL_TYPES = new Set([
|
|
78
|
-
"pty.input.ack",
|
|
79
|
-
"pty.exit",
|
|
80
|
-
"pty.ready",
|
|
81
|
-
"pty.error",
|
|
82
63
|
"claude.jsonl.event",
|
|
83
64
|
"claude.event",
|
|
84
65
|
"claude.ready",
|
|
@@ -97,7 +78,7 @@ const BUFFERED_CTRL_TYPES = new Set([
|
|
|
97
78
|
"codex.input.nack",
|
|
98
79
|
"session.event",
|
|
99
80
|
])
|
|
100
|
-
// 制御/イベントバッファの上限。低頻度なので frames
|
|
81
|
+
// 制御/イベントバッファの上限。低頻度なので frames は小さめ。
|
|
101
82
|
const CTRL_BUFFER_MAX_FRAMES = 200
|
|
102
83
|
const CTRL_BUFFER_MAX_AGE_MS = 30_000
|
|
103
84
|
// reconnect 後の buffered event は backend が browser の active stream 集合を
|
|
@@ -168,11 +149,8 @@ export class WsClient extends EventEmitter {
|
|
|
168
149
|
this.backoff = MIN_BACKOFF_MS
|
|
169
150
|
this.stopped = false
|
|
170
151
|
this.startedAt = Date.now()
|
|
171
|
-
//
|
|
172
|
-
// entry: { obj, ts }
|
|
173
|
-
this.ptyOutboundBuffer = []
|
|
174
|
-
// 制御/イベント系 (ack / jsonl.event 等) の outbound buffer。pty.data とは別管理で、
|
|
175
|
-
// 高頻度 pty.data の洪水に evict されないようにする (機構③)。entry: { obj, ts }
|
|
152
|
+
// 制御/イベント系 (ack / jsonl.event 等) の outbound buffer
|
|
153
|
+
// (WS not OPEN 中に積み、open 後 flush)。entry: { obj, ts }
|
|
176
154
|
this.ctrlOutboundBuffer = []
|
|
177
155
|
// reconnect 世代ごとの stream sync barrier。古い request_id の遅延応答で
|
|
178
156
|
// 現接続の buffer を flush しないよう request_id を照合する。
|
|
@@ -282,11 +260,10 @@ export class WsClient extends EventEmitter {
|
|
|
282
260
|
available_clis: this.availableClis,
|
|
283
261
|
...this.bundleStatus,
|
|
284
262
|
})
|
|
285
|
-
// Plan ε: 毎回 reconnect 直後に backend ↔ agent の stream_id
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
// 返るだけで動作には影響しない。
|
|
263
|
+
// Plan ε: 毎回 reconnect 直後に backend ↔ agent の stream_id を能動同期する。
|
|
264
|
+
// response は main.mjs の dispatch が拾い、mode 別 snapshot から外れた
|
|
265
|
+
// chat subscriber を掃除する。request_id は uuid で重複排除。backend が
|
|
266
|
+
// 古い (= Plan ε 未対応) なら "unknown_type" error が返るだけで動作には影響しない。
|
|
290
267
|
const streamsSyncRequestId = randomUUID()
|
|
291
268
|
this._sendJson({
|
|
292
269
|
type: "agent.streams.sync.request",
|
|
@@ -298,11 +275,11 @@ export class WsClient extends EventEmitter {
|
|
|
298
275
|
this.emit("open")
|
|
299
276
|
}
|
|
300
277
|
|
|
301
|
-
/**
|
|
278
|
+
/** メッセージを送る。未接続時は制御/イベント系 (ack / jsonl.event 等) を
|
|
302
279
|
* buffer に積み、reconnect 後に flush する。
|
|
303
280
|
*
|
|
304
281
|
* heartbeat / hello / agent.streams.sync.* など「古くなると意味が無い」制御系は
|
|
305
|
-
* buffer しない (warn のみ)。ack / event 系は 1
|
|
282
|
+
* buffer しない (warn のみ)。ack / event 系は 1 件ずつ意味を持つのでバッファに保持する。
|
|
306
283
|
*/
|
|
307
284
|
send(obj) {
|
|
308
285
|
const type = obj?.type
|
|
@@ -310,12 +287,8 @@ export class WsClient extends EventEmitter {
|
|
|
310
287
|
// sync barrier 中に新しく発生した frame も同じ buffer へ積み、切断中 frame より
|
|
311
288
|
// 先に送って時系列を逆転させない。heartbeat 等の非 buffer 制御系は通常どおり送る。
|
|
312
289
|
const holdForStreamsSync = Boolean(this.pendingStreamsSyncRequestId)
|
|
313
|
-
&&
|
|
290
|
+
&& BUFFERED_CTRL_TYPES.has(type)
|
|
314
291
|
if (!wsOpen || holdForStreamsSync) {
|
|
315
|
-
if (type === "pty.data") {
|
|
316
|
-
this._bufferPtyData(obj)
|
|
317
|
-
return false
|
|
318
|
-
}
|
|
319
292
|
if (BUFFERED_CTRL_TYPES.has(type)) {
|
|
320
293
|
// SDK-P2 (2026-07-08): ephemeral な stream_event の content_block_delta (text/thinking
|
|
321
294
|
// delta) は ctrl バッファに積まない。切断中は生成が続くと大量の delta が流れ、200 frame
|
|
@@ -340,50 +313,6 @@ export class WsClient extends EventEmitter {
|
|
|
340
313
|
return this._sendJson(obj)
|
|
341
314
|
}
|
|
342
315
|
|
|
343
|
-
/** pty.data を outbound buffer に積む。リング (drop oldest on overflow)。 */
|
|
344
|
-
_bufferPtyData(obj) {
|
|
345
|
-
this.ptyOutboundBuffer.push({ obj, ts: Date.now() })
|
|
346
|
-
if (this.ptyOutboundBuffer.length > PTY_BUFFER_MAX_FRAMES) {
|
|
347
|
-
const dropped = this.ptyOutboundBuffer.length - PTY_BUFFER_MAX_FRAMES
|
|
348
|
-
this.ptyOutboundBuffer.splice(0, dropped)
|
|
349
|
-
this.logger?.warn(
|
|
350
|
-
{ dropped, kept: this.ptyOutboundBuffer.length },
|
|
351
|
-
"pty outbound buffer overflow (oldest dropped)"
|
|
352
|
-
)
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/** open 直後に buffer を flush。古すぎる entry は破棄する。 */
|
|
357
|
-
_flushPtyBuffer() {
|
|
358
|
-
if (this.ptyOutboundBuffer.length === 0) return
|
|
359
|
-
const now = Date.now()
|
|
360
|
-
// B10: バッファを退避してから処理する。送信失敗 (_sendJson が false) で break
|
|
361
|
-
// した場合、未送信の残りフレームを ptyOutboundBuffer の先頭へ戻して順序を保持する
|
|
362
|
-
// (空配列で上書きしたまま break すると残りが欠落する)。
|
|
363
|
-
const buf = this.ptyOutboundBuffer
|
|
364
|
-
this.ptyOutboundBuffer = []
|
|
365
|
-
let sent = 0
|
|
366
|
-
let expired = 0
|
|
367
|
-
for (let i = 0; i < buf.length; i++) {
|
|
368
|
-
const entry = buf[i]
|
|
369
|
-
if (now - entry.ts > PTY_BUFFER_MAX_AGE_MS) {
|
|
370
|
-
expired += 1
|
|
371
|
-
continue
|
|
372
|
-
}
|
|
373
|
-
const ok = this._sendJson(entry.obj)
|
|
374
|
-
if (!ok) {
|
|
375
|
-
// 未送信分 (現エントリ含む i 以降) を先頭へ戻す。次回 open / flush で再送する。
|
|
376
|
-
this.ptyOutboundBuffer = buf.slice(i).concat(this.ptyOutboundBuffer)
|
|
377
|
-
break
|
|
378
|
-
}
|
|
379
|
-
sent += 1
|
|
380
|
-
}
|
|
381
|
-
this.logger?.info(
|
|
382
|
-
{ sent, expired, total: buf.length, requeued: this.ptyOutboundBuffer.length },
|
|
383
|
-
"pty outbound buffer flushed"
|
|
384
|
-
)
|
|
385
|
-
}
|
|
386
|
-
|
|
387
316
|
/** 制御/イベント系 (ack / jsonl.event 等) を outbound buffer に積む。リング (drop oldest)。 */
|
|
388
317
|
_bufferCtrl(obj) {
|
|
389
318
|
this.ctrlOutboundBuffer.push({ obj, ts: Date.now() })
|
|
@@ -398,7 +327,10 @@ export class WsClient extends EventEmitter {
|
|
|
398
327
|
}
|
|
399
328
|
|
|
400
329
|
/** open 直後に制御/イベントバッファを flush。古すぎる entry は破棄する。
|
|
401
|
-
*
|
|
330
|
+
*
|
|
331
|
+
* B10: バッファを退避してから処理する。送信失敗 (_sendJson が false) で break した
|
|
332
|
+
* 場合、未送信の残りフレームを ctrlOutboundBuffer の先頭へ戻して順序を保持する
|
|
333
|
+
* (空配列で上書きしたまま break すると残りが欠落する)。
|
|
402
334
|
*/
|
|
403
335
|
_flushCtrlBuffer() {
|
|
404
336
|
if (this.ctrlOutboundBuffer.length === 0) return
|
|
@@ -443,7 +375,6 @@ export class WsClient extends EventEmitter {
|
|
|
443
375
|
// frame を失うため、2s compatibility timeout まで barrier を維持する。
|
|
444
376
|
if (activeIds.size === 0) return false
|
|
445
377
|
this._clearStreamsSyncBarrier()
|
|
446
|
-
this._flushPtyBuffer()
|
|
447
378
|
this._flushCtrlBuffer()
|
|
448
379
|
return true
|
|
449
380
|
}
|
|
@@ -459,7 +390,6 @@ export class WsClient extends EventEmitter {
|
|
|
459
390
|
{ request_id: requestId },
|
|
460
391
|
"streams sync barrier timed out; flushing buffered frames for legacy backend",
|
|
461
392
|
)
|
|
462
|
-
this._flushPtyBuffer()
|
|
463
393
|
this._flushCtrlBuffer()
|
|
464
394
|
}, STREAMS_SYNC_BARRIER_TIMEOUT_MS)
|
|
465
395
|
this.streamsSyncBarrierTimer.unref?.()
|