@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/README.md
CHANGED
|
@@ -62,9 +62,7 @@ hub-agent stop # 停止方法のヒント表示
|
|
|
62
62
|
hub-agent install-service [--bin PATH]
|
|
63
63
|
hub-agent uninstall-service
|
|
64
64
|
hub-agent plugins list # ~/.hub/plugins/ から読み込み済プラグイン
|
|
65
|
-
hub-agent plugins
|
|
66
|
-
hub-agent plugins install <name> [--force]
|
|
67
|
-
hub-agent plugins uninstall <name>
|
|
65
|
+
hub-agent plugins uninstall <name> # ~/.hub/plugins/<prio>-<name>/ を削除
|
|
68
66
|
```
|
|
69
67
|
|
|
70
68
|
## 設定ファイル
|
|
@@ -86,7 +84,8 @@ hub-agent/
|
|
|
86
84
|
│ ├── config.mjs # ~/.hub/agent.json 管理
|
|
87
85
|
│ ├── enroll.mjs # enrollment フロー
|
|
88
86
|
│ ├── ws-client.mjs # outbound WSS + reconnect with jitter
|
|
89
|
-
│ ├──
|
|
87
|
+
│ ├── claude-stream-bridge.mjs # Claude Agent SDK チャット中継
|
|
88
|
+
│ ├── codex-stream-bridge.mjs # Codex app-server チャット中継
|
|
90
89
|
│ ├── tmux.mjs # tmux exec/list/create/kill
|
|
91
90
|
│ ├── state.mjs # session status / context_pct 検知
|
|
92
91
|
│ ├── skills.mjs # ~/.claude/skills 集計
|
|
@@ -98,7 +97,8 @@ hub-agent/
|
|
|
98
97
|
│ ├── co.cocorograph.hub-agent.plist
|
|
99
98
|
│ └── hub-agent.service
|
|
100
99
|
├── scripts/
|
|
101
|
-
│
|
|
100
|
+
│ ├── install.sh # ワンライナーセットアップ (macOS / Linux / WSL)
|
|
101
|
+
│ └── install.ps1 # Windows (WSL2 導入 + Task Scheduler 登録)
|
|
102
102
|
└── test/
|
|
103
103
|
```
|
|
104
104
|
|
|
@@ -107,21 +107,22 @@ hub-agent/
|
|
|
107
107
|
- [x] Sprint A: 仕様書 (Hub knowledge id=6080)
|
|
108
108
|
- [x] Sprint B: Hub 側 enrollment API + WS endpoint 雛形
|
|
109
109
|
- [x] Sprint C: 本リポジトリの雛形 + outbound WSS + hook system
|
|
110
|
-
- [x] Sprint D: pty bridge
|
|
111
110
|
- [x] Sprint E: tmux exec/list/create/kill 移植 + auto list UI
|
|
112
111
|
- [x] Sprint F: Hub Browser↔Agent multiplex 中継
|
|
113
112
|
- [x] Sprint G: Hub frontend /user/cockpit/{agents,terminal} ページ
|
|
114
113
|
- [x] Sprint H: skills / state dot / usage badge リレー
|
|
115
114
|
- [x] Sprint I: 接続復旧 (jitter + force reconnect + Celery stale 検知)
|
|
116
115
|
- [x] Sprint J: 本リポジトリ publish 準備 + launchd/systemd 連携
|
|
117
|
-
- [x] Sprint K: remote-ops プラグイン例 (`plugins/10-tailscale-remote/`)
|
|
118
116
|
|
|
119
|
-
##
|
|
117
|
+
## プラグイン
|
|
120
118
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
119
|
+
プラグインは利用者が `~/.hub/plugins/<prio>-<name>/plugin.mjs` を自分で置く運用です。
|
|
120
|
+
置けば `plugins list` から読み込まれ、`plugins uninstall <prio>-<name>` で削除できます。
|
|
121
|
+
|
|
122
|
+
リポジトリ同梱の example プラグインはありません。唯一同梱していた
|
|
123
|
+
`plugins/10-tailscale-remote/` は pty spawn を横取りするプラグインで、pty 中継の撤去と
|
|
124
|
+
同時に削除しました。同梱 example が 0 件になったため、それを配る `plugins examples` /
|
|
125
|
+
`plugins install` と `package.json` の `files` の `plugins/` も外しています。
|
|
125
126
|
|
|
126
127
|
## ライセンス
|
|
127
128
|
|
package/bin/hub-agent.mjs
CHANGED
|
@@ -34,12 +34,8 @@ if (!process.env.LC_ALL) process.env.LC_ALL = "C.UTF-8"
|
|
|
34
34
|
import { hasConfig, readConfig } from "../src/config.mjs"
|
|
35
35
|
import { enroll } from "../src/enroll.mjs"
|
|
36
36
|
import { resolveSyncTargets, syncBundleTargets } from "../src/hub-bundle.mjs"
|
|
37
|
-
import {
|
|
38
|
-
installPlugin,
|
|
39
|
-
listInstallableExamples,
|
|
40
|
-
uninstallPlugin,
|
|
41
|
-
} from "../src/plugin-install.mjs"
|
|
42
37
|
import { loadPlugins } from "../src/plugin-loader.mjs"
|
|
38
|
+
import { uninstallPlugin } from "../src/plugin-uninstall.mjs"
|
|
43
39
|
import {
|
|
44
40
|
installService,
|
|
45
41
|
restartService,
|
|
@@ -264,6 +260,11 @@ program
|
|
|
264
260
|
}
|
|
265
261
|
})
|
|
266
262
|
|
|
263
|
+
// プラグインは利用者が `~/.hub/plugins/<prio>-<name>/` へ自分で配置する運用なので、
|
|
264
|
+
// CLI が提供するのは利用者配置分の管理 (list / uninstall) だけ。リポ同梱 example を配る
|
|
265
|
+
// `examples` / `install` は、唯一の example (`plugins/10-tailscale-remote/`) が pty spawn を
|
|
266
|
+
// 横取りするプラグインで pty 撤去と同時に削除され、同梱 example が 0 件になったため撤去した
|
|
267
|
+
// (あわせて package.json の files から `plugins/` を外している)。
|
|
267
268
|
const plugins = program.command("plugins").description("プラグイン管理")
|
|
268
269
|
plugins
|
|
269
270
|
.command("list")
|
|
@@ -279,40 +280,9 @@ plugins
|
|
|
279
280
|
}
|
|
280
281
|
})
|
|
281
282
|
|
|
282
|
-
plugins
|
|
283
|
-
.command("examples")
|
|
284
|
-
.description("リポ同梱の install 可能 example プラグイン一覧")
|
|
285
|
-
.action(async () => {
|
|
286
|
-
const list = await listInstallableExamples()
|
|
287
|
-
if (list.length === 0) {
|
|
288
|
-
console.log("(no examples bundled)")
|
|
289
|
-
return
|
|
290
|
-
}
|
|
291
|
-
for (const name of list) {
|
|
292
|
-
console.log(`- ${name}`)
|
|
293
|
-
}
|
|
294
|
-
})
|
|
295
|
-
|
|
296
|
-
plugins
|
|
297
|
-
.command("install <name>")
|
|
298
|
-
.description("repo plugins/<name>/ を ~/.hub/plugins/<name>/ にコピー (config.json は温存)")
|
|
299
|
-
.option("--force", "既存ディレクトリを上書きする")
|
|
300
|
-
.action(async (name, opts) => {
|
|
301
|
-
try {
|
|
302
|
-
const r = await installPlugin(name, { force: !!opts.force })
|
|
303
|
-
console.log(`installed: ${r.dest}`)
|
|
304
|
-
console.log("次のステップ:")
|
|
305
|
-
console.log(` $EDITOR ${r.dest}/config.json # 既存なら温存、新規なら example をコピー`)
|
|
306
|
-
console.log(` hub-agent restart (launchctl kickstart / systemctl restart)`)
|
|
307
|
-
} catch (err) {
|
|
308
|
-
console.error(`plugins install failed: ${err.message}`)
|
|
309
|
-
process.exit(1)
|
|
310
|
-
}
|
|
311
|
-
})
|
|
312
|
-
|
|
313
283
|
plugins
|
|
314
284
|
.command("uninstall <name>")
|
|
315
|
-
.description("~/.hub/plugins/<name>/
|
|
285
|
+
.description("~/.hub/plugins/<prio>-<name>/ を削除する")
|
|
316
286
|
.action(async (name) => {
|
|
317
287
|
try {
|
|
318
288
|
const r = await uninstallPlugin(name)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cocorograph/hub-agent",
|
|
3
|
-
"version": "0.8.
|
|
4
|
-
"description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの
|
|
3
|
+
"version": "0.8.25",
|
|
4
|
+
"description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"private": false,
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
"scripts": {
|
|
20
20
|
"start": "node bin/hub-agent.mjs start",
|
|
21
21
|
"test": "node --test test/*.test.mjs",
|
|
22
|
-
"postinstall": "node scripts/fix-node-pty-perms.mjs",
|
|
23
22
|
"check:publish-on-main": "node scripts/check-publish-on-main.mjs",
|
|
24
23
|
"prepublishOnly": "node scripts/check-publish-on-main.mjs && npm test"
|
|
25
24
|
},
|
|
@@ -28,13 +27,11 @@
|
|
|
28
27
|
"src/",
|
|
29
28
|
"scripts/",
|
|
30
29
|
"templates/",
|
|
31
|
-
"plugins/",
|
|
32
30
|
"README.md",
|
|
33
31
|
"LICENSE"
|
|
34
32
|
],
|
|
35
33
|
"dependencies": {
|
|
36
34
|
"@anthropic-ai/claude-agent-sdk": "^0.3.158",
|
|
37
|
-
"@lydell/node-pty": "1.2.0-beta.12",
|
|
38
35
|
"commander": "^12.1.0",
|
|
39
36
|
"pino": "^9.0.0",
|
|
40
37
|
"ws": "^8.18.0"
|
package/scripts/install.sh
CHANGED
|
@@ -518,10 +518,10 @@ _install_node_lts_linux() {
|
|
|
518
518
|
}
|
|
519
519
|
|
|
520
520
|
# ネイティブアドオンのビルドに必要な make / g++ / python3 を導入する。
|
|
521
|
-
#
|
|
522
|
-
#
|
|
523
|
-
#
|
|
524
|
-
#
|
|
521
|
+
# 現在の依存はすべて純 JS で通常は native ビルド不要だが、optional な native 依存
|
|
522
|
+
# (ws の bufferutil 等) が prebuild なしで拾われると node-gyp rebuild にフォールバック
|
|
523
|
+
# する。クリーンな Ubuntu には build-essential が無く `gyp ERR! not found: make` で
|
|
524
|
+
# 失敗する (WSL クリーン Ubuntu で発覚) ため保険で導入する。
|
|
525
525
|
# macOS は Xcode CLT 前提なので何もしない。
|
|
526
526
|
ensure_build_tools() {
|
|
527
527
|
[[ "$(uname)" == "Darwin" ]] && return 0
|
|
@@ -812,7 +812,7 @@ macos_perm_guidance() {
|
|
|
812
812
|
cat <<EOF
|
|
813
813
|
|
|
814
814
|
📣 macOS の権限ダイアログについて
|
|
815
|
-
hub-agent は tmux/
|
|
815
|
+
hub-agent は tmux セッションとローカルの CLI (Claude / Codex) を中継するため、初回起動時に macOS から
|
|
816
816
|
「node がローカルネットワーク上の機器を検出することを求めています」
|
|
817
817
|
「node がアクセシビリティを制御することを求めています」
|
|
818
818
|
「node がほかのアプリからのデータへのアクセス権を求めています」
|
|
@@ -15,8 +15,7 @@
|
|
|
15
15
|
* - これは Claude CLI の `claude --resume <id> -p "<msg>"` を毎ターン叩くのと同じ
|
|
16
16
|
* モデルで、SDK の挙動と素直に噛み合う
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
* 受け持つ。テスト時は SDK を `{ query }` shape で stub 注入する。
|
|
18
|
+
* `claude.*` 系メッセージを受け持つ。テスト時は SDK を `{ query }` shape で stub 注入する。
|
|
20
19
|
*/
|
|
21
20
|
import { EventEmitter } from "node:events"
|
|
22
21
|
import { randomUUID } from "node:crypto"
|
|
@@ -33,6 +32,8 @@ import {
|
|
|
33
32
|
} from "./tool-result-mask.mjs"
|
|
34
33
|
import { getOrCreateSiblingToken } from "./sibling-token.mjs"
|
|
35
34
|
import { recordInjectSourcePersistent } from "./inject-source-store.mjs"
|
|
35
|
+
import { readPositiveIntEnv, sanitizePositiveInt } from "./positive-int-env.mjs"
|
|
36
|
+
import { nextSessionSeq } from "./session-seq.mjs"
|
|
36
37
|
|
|
37
38
|
/**
|
|
38
39
|
* sibling-inject (2026-07-04) 用: spawn する claude プロセス自身の環境変数に、
|
|
@@ -181,20 +182,87 @@ function readNeedsAuthCacheNames() {
|
|
|
181
182
|
* sessions に stream_id が永久残留し、最後の 1 台が外れないと idle softDetach が起動しない。
|
|
182
183
|
* 正常に開いている端末は input が無くても WS heartbeat 等で活性更新されないため、閾値は
|
|
183
184
|
* 長め (既定 1 時間) にして「明らかに死んだ」端末のみを掃除する。env で調整可能。 */
|
|
184
|
-
const DEAD_TERMINAL_TTL_MS =
|
|
185
|
-
|
|
185
|
+
const DEAD_TERMINAL_TTL_MS = readPositiveIntEnv(
|
|
186
|
+
"HUB_AGENT_DEAD_TERMINAL_TTL_MS",
|
|
187
|
+
60 * 60 * 1000,
|
|
188
|
+
)
|
|
186
189
|
|
|
187
|
-
/** 子⑤ (T06544): warm 保持を「各 cwd
|
|
190
|
+
/** 子⑤ (T06544): warm 保持を「各 cwd K 本 × 同時 warm 保持する cwd 数 N(既定 20)」で有界化する
|
|
188
191
|
* 上限(codex-stream-bridge.mjs の WARM_CWD_MAX と対)。claude は従来 per-session の softDetach
|
|
189
192
|
* タイマー(7 日)だけで件数上限が無かったため、多数の cwd を渡り歩くと warm セッション(= SDK
|
|
190
193
|
* 常駐 query プロセス)が N 個を超えて蓄積し得た。cwd 単位の LRU で最古 cwd から早期 reap する。
|
|
191
194
|
* env HUB_AGENT_WARM_CWD_MAX で調整可能(codex と共有)。 */
|
|
192
|
-
const WARM_CWD_MAX =
|
|
195
|
+
const WARM_CWD_MAX = readPositiveIntEnv("HUB_AGENT_WARM_CWD_MAX", 20)
|
|
196
|
+
|
|
197
|
+
/** 1 cwd あたりの live セッション上限 K(既定 3)。旧仕様の per-cwd=1 を一般化したもの。
|
|
198
|
+
* 「総量は増やさず配分の自由度だけ上げる」ため、総 live 上限 M(warm-bounds-total.mjs)と
|
|
199
|
+
* セットで効かせる。⚠️ 既存 env(HUB_AGENT_WARM_CWD_MAX = 同時 warm 保持する cwd 数 N)の
|
|
200
|
+
* 意味は変えない。新しい上限は新しい env 名で足す(codex 側の WARM_PER_CWD_MAX と対)。 */
|
|
201
|
+
const WARM_PER_CWD_MAX = readPositiveIntEnv("HUB_AGENT_WARM_PER_CWD_MAX", 3)
|
|
202
|
+
|
|
203
|
+
/** タスク実行中セッションを warm 境界の刈り取りから保護する際の**失効時間**(**鼓動が観測できる
|
|
204
|
+
* 種別**用。既定 30 分)。「最後に生存を確認してから」の相対時間で、`task_progress` が届くたびに
|
|
205
|
+
* 先送りされる。実測(/tmp/bgprobe/raw2.jsonl)で `local_agent`(Agent ツールのサブエージェント)
|
|
206
|
+
* は task_progress の鼓動が届くため、この短い TTL で十分に走行中を守れる。
|
|
207
|
+
* ⚠️ これは**仕様上の最大保護時間**であり、これを超えて鼓動が途絶えたタスクは保護されない
|
|
208
|
+
* (終了イベントを取りこぼしたときに保護が永久化しないための上限として必須)。 */
|
|
209
|
+
const TASK_PROTECT_TTL_MS = readPositiveIntEnv(
|
|
210
|
+
"HUB_AGENT_TASK_PROTECT_TTL_MS",
|
|
211
|
+
30 * 60 * 1000,
|
|
212
|
+
)
|
|
193
213
|
|
|
194
|
-
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
|
|
214
|
+
/** **鼓動が来ない種別**(`local_bash` = run_in_background の Bash、および未知の task_type)用の
|
|
215
|
+
* 失効時間(既定 4 時間)。
|
|
216
|
+
*
|
|
217
|
+
* 【なぜ分けるか】実測で `local_bash` は task_progress が **1 件も来ない**(40 秒でも 240 秒でも
|
|
218
|
+
* 0 件。/tmp/bgprobe/raw2.jsonl, raw4.jsonl)。全種別を共通 TTL にすると lastSeenAt が開始時刻から
|
|
219
|
+
* 動かず、実質「開始から 30 分」になり、**30 分を超える正常な背景コマンド(ビルド・同期・
|
|
220
|
+
* サーバー等)が実行中なのに保護を失う**。終了イベント(task_updated / task_notification)は
|
|
221
|
+
* 240 秒後でも確実に届くことを実測済みなので、TTL を延ばしても通常は終了で解ける。
|
|
222
|
+
*
|
|
223
|
+
* 【未知の task_type もこちら】鼓動が来るか分からないものを短い TTL で切ると実行中を刈る。
|
|
224
|
+
* 「未知の status を終端扱いしない」(TASK_TERMINAL_STATUSES)と同じく安全側へ倒す。
|
|
225
|
+
*
|
|
226
|
+
* ⚠️ これも**仕様上の最大保護時間**で、**4 時間を超える背景コマンドは保護されない**。
|
|
227
|
+
*
|
|
228
|
+
* 【有界なのは「時間」だけで、「件数」ではない】(cross-review round2 minor)
|
|
229
|
+
* 1 エントリあたりの保護時間は TTL で有界(4 時間で必ず頭打ち)。一方で **同時に保護されうる
|
|
230
|
+
* セッション数には K(per-cwd)も N(cwd 数)も M(総 live 上限、warm-bounds-total.mjs)も効かない**。
|
|
231
|
+
* すべての境界が protected を skip する設計なので、4 時間以内に別セッションで背景コマンドを
|
|
232
|
+
* 増やせば、保護中の合計は M を超えて増え続ける。これは「走行中を絶対に殺さない」を優先した
|
|
233
|
+
* **明示的な超過受容**であり、**M をハードな資源上限と読んではいけない**。
|
|
234
|
+
* 超過量は warm-bounds-total.mjs が返す `liveTotal` / `unresolved` のログで観測できる
|
|
235
|
+
* (運用判断はその実測値を見る)。
|
|
236
|
+
*
|
|
237
|
+
* ⚠️ 将来ハード上限(保護中でも刈る)を足すなら、**実行中タスクを中断するという新しい失敗モード**
|
|
238
|
+
* が生まれる。この TTL 設計の延長で暗黙に足さないこと。足すなら victim の選び方・利用者への通知・
|
|
239
|
+
* 再開可否を別仕様として設計する。 */
|
|
240
|
+
const BG_COMMAND_PROTECT_TTL_MS = readPositiveIntEnv(
|
|
241
|
+
"HUB_AGENT_BG_COMMAND_PROTECT_TTL_MS",
|
|
242
|
+
4 * 60 * 60 * 1000,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
/** `task_progress` の鼓動が届くと**実測で確認済み**の task_type。ここに無い種別(`local_bash` /
|
|
246
|
+
* 未知)は鼓動が来ない前提で長い TTL(BG_COMMAND_PROTECT_TTL_MS)を適用する。 */
|
|
247
|
+
const HEARTBEAT_TASK_TYPES = new Set(["local_agent"])
|
|
248
|
+
|
|
249
|
+
/** task_type に応じた保護の失効時間を返す。未知の種別は**長い方**(安全側)。 */
|
|
250
|
+
function taskProtectTtlFor(taskType) {
|
|
251
|
+
return HEARTBEAT_TASK_TYPES.has(taskType) ? TASK_PROTECT_TTL_MS : BG_COMMAND_PROTECT_TTL_MS
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** SDK の `system:task_updated` が patch.status に載せる**終端**ステータス。
|
|
255
|
+
* 出典: node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts の `SDKTaskUpdatedMessage`
|
|
256
|
+
* (`status?: 'pending' | 'running' | 'completed' | 'failed' | 'killed' | 'paused'`, sdk.d.ts:4101)。
|
|
257
|
+
* ⚠️ `paused` は終端に含めない(再開しうるため)。ここに載っていない未知の値は「まだ走行中かも
|
|
258
|
+
* しれない」とみなして保護を解除しない(未知の状態を勝手に終端扱いして走行中タスクを刈らない
|
|
259
|
+
* ため。保護の上限は失効時間が保証する)。
|
|
260
|
+
* ⚠️ SDK 更新で union が変わったら test/claude-task-status-drift.test.mjs が落ちる。 */
|
|
261
|
+
export const TASK_TERMINAL_STATUSES = new Set(["completed", "failed", "killed"])
|
|
262
|
+
|
|
263
|
+
/** 上と対になる**非終端**ステータス(同じ union の残り全部)。分類の網羅性をテストで検査するために
|
|
264
|
+
* 明示的に持つ(SDK に未知の値が増えたら「どちらでもない」ものとして検出できる)。 */
|
|
265
|
+
export const TASK_NONTERMINAL_STATUSES = new Set(["pending", "running", "paused"])
|
|
198
266
|
|
|
199
267
|
/**
|
|
200
268
|
* モデル能力レジストリ (2026-07-11): モデル名 → { supportsEffort, supportsXhighEffort }。
|
|
@@ -391,8 +459,10 @@ export class ClaudeStreamSession {
|
|
|
391
459
|
* 使った時刻」が残り、最古 cwd から LRU reap できる。 */
|
|
392
460
|
this._lastActivityMs = Date.now()
|
|
393
461
|
/** 子⑤ round5 Finding 1: 生成順の単調増加シーケンス(warm 境界のタイブレーク用。値が大きい =
|
|
394
|
-
* 新しい)。recency 同値時に per-cwd=
|
|
395
|
-
|
|
462
|
+
* 新しい)。recency 同値時に per-cwd=K が新しいセッションを残せるようにする。
|
|
463
|
+
* ⚠️ カウンタは **claude / codex 共通**(session-seq.mjs)。総 live 上限 M は両 CLI を合算して
|
|
464
|
+
* 古い順に刈るため、CLI ごとに別カウンタだと CLI をまたいだ比較が成立しない。 */
|
|
465
|
+
this._seq = nextSessionSeq()
|
|
396
466
|
this.cwd = cwd
|
|
397
467
|
this.model = model || null
|
|
398
468
|
this.permissionMode = permissionMode || null
|
|
@@ -496,6 +566,20 @@ export class ClaudeStreamSession {
|
|
|
496
566
|
* 本文一致で _emit が消費してタグ付けする。人間の実入力や tool_result は本 set に無いので
|
|
497
567
|
* タグされない (誤タグ防止)。echo が来ない稀ケースの無限成長は上限で古い順に切る。 */
|
|
498
568
|
this._pendingInjectSources = []
|
|
569
|
+
|
|
570
|
+
/** 走行中タスク(Agent ツールのサブエージェント / 背景 Bash コマンド)の台帳。
|
|
571
|
+
* key = SDK の `task_id`、value = { toolUseId, taskType, startedAt, lastSeenAt }。
|
|
572
|
+
*
|
|
573
|
+
* ⚠️ key を `tool_use_id` にしてはいけない: 終了イベントの片方(`system:task_updated`)は
|
|
574
|
+
* `tool_use_id` を持たず `task_id` しか載せないため、tool_use_id を鍵にすると終了を
|
|
575
|
+
* 取りこぼして保護が居座る(実測ログ /tmp/bgprobe/raw2.jsonl で確認)。
|
|
576
|
+
*
|
|
577
|
+
* ⚠️ 前面 / 背景を区別しない(確定した設計判断)。前面実行中はそもそも `_busy` で保護される
|
|
578
|
+
* ので区別しても挙動は変わらず、区別するには tool_use の `run_in_background` と突き合わせる
|
|
579
|
+
* 第 2 の仕組みが要る。一方、背景コマンドの終了イベントは**ターン終了後**に届く(実測:
|
|
580
|
+
* `result` の 14 秒後)ため、背景は保護対象に含める必要がある。 */
|
|
581
|
+
this._liveTasks = new Map()
|
|
582
|
+
|
|
499
583
|
/** ultracode (0.6.28): 常駐 query へ現在適用済みの ultracode 状態。ターン単位の
|
|
500
584
|
* ワンショット適用を applyFlagSettings で reconcile する際の差分判定に使う。
|
|
501
585
|
* query を (再)起動すると flag settings は既定に戻るため、_runResidentQuery 冒頭で
|
|
@@ -620,7 +704,109 @@ export class ClaudeStreamSession {
|
|
|
620
704
|
if (idx !== -1) this._pendingInjectSources.splice(idx, 1)
|
|
621
705
|
}
|
|
622
706
|
|
|
707
|
+
/**
|
|
708
|
+
* SDK の `system:task_*` を観測して走行中タスク台帳(_liveTasks)を更新する。
|
|
709
|
+
*
|
|
710
|
+
* 呼び出しは `_emit` の先頭 1 箇所だけ。常駐ループ・per-message ループ・jsonl watcher の
|
|
711
|
+
* 3 経路が必ず `_emit` を通るため、ここが唯一の合流点になる(経路ごとに配線すると、
|
|
712
|
+
* どれか 1 本を足したときに無言で観測漏れする)。
|
|
713
|
+
*
|
|
714
|
+
* 実測(/tmp/bgprobe/raw2.jsonl, raw3.jsonl)に基づく扱い:
|
|
715
|
+
* - `task_started` : 新規登録(前面 / 背景の両方が出す)
|
|
716
|
+
* - `task_progress` : **既存エントリがあるときだけ** lastSeenAt を延ばす。エントリが
|
|
717
|
+
* 無ければ新規作成しない(終了後に遅れて届いた進捗でよみがえるのを
|
|
718
|
+
* 防ぐ)。⚠️ ここは「安全側」ではない(cross-review round2 nit):
|
|
719
|
+
* 開始イベント(task_started)を本当に取りこぼしていた場合、実行中
|
|
720
|
+
* なのに保護が付かず reap されうる。**過剰保護と終了後の復活を避ける
|
|
721
|
+
* ため、旧挙動(保護しない)へ fail-open している**というトレードオフ。
|
|
722
|
+
* ⚠️ この記述を根拠に「未知の progress から新規登録する」実装へ
|
|
723
|
+
* 変えないこと。それをやると遅延イベントで保護が復活する。
|
|
724
|
+
* - `task_updated` : patch.status が終端なら削除。**未知の値なら削除せず lastSeenAt 更新**
|
|
725
|
+
* - `task_notification` : status によらず削除(冪等。同一 task_id が複数回来ても壊れない)
|
|
726
|
+
*
|
|
727
|
+
* ⚠️ 例外を外へ投げないこと。`_emit` は全メッセージが通る経路なので、ここで throw すると
|
|
728
|
+
* ストリーム全体が壊れる。すべて握って logger?.warn に留める。
|
|
729
|
+
* @param {object} msg SDK メッセージ
|
|
730
|
+
* @param {number} [now] 時刻(テストから注入可能)
|
|
731
|
+
*/
|
|
732
|
+
_observeTaskEvent(msg, now = Date.now()) {
|
|
733
|
+
try {
|
|
734
|
+
if (!msg || msg.type !== "system") return
|
|
735
|
+
const subtype = msg.subtype
|
|
736
|
+
if (
|
|
737
|
+
subtype !== "task_started" &&
|
|
738
|
+
subtype !== "task_progress" &&
|
|
739
|
+
subtype !== "task_updated" &&
|
|
740
|
+
subtype !== "task_notification"
|
|
741
|
+
) {
|
|
742
|
+
return
|
|
743
|
+
}
|
|
744
|
+
const taskId = msg.task_id
|
|
745
|
+
if (typeof taskId !== "string" || !taskId) return
|
|
746
|
+
if (subtype === "task_started") {
|
|
747
|
+
this._liveTasks.set(taskId, {
|
|
748
|
+
toolUseId: typeof msg.tool_use_id === "string" ? msg.tool_use_id : null,
|
|
749
|
+
taskType: typeof msg.task_type === "string" ? msg.task_type : null,
|
|
750
|
+
startedAt: now,
|
|
751
|
+
lastSeenAt: now,
|
|
752
|
+
})
|
|
753
|
+
return
|
|
754
|
+
}
|
|
755
|
+
if (subtype === "task_notification") {
|
|
756
|
+
this._liveTasks.delete(taskId)
|
|
757
|
+
return
|
|
758
|
+
}
|
|
759
|
+
if (subtype === "task_updated") {
|
|
760
|
+
const status = msg.patch?.status
|
|
761
|
+
if (typeof status === "string" && TASK_TERMINAL_STATUSES.has(status)) {
|
|
762
|
+
this._liveTasks.delete(taskId)
|
|
763
|
+
return
|
|
764
|
+
}
|
|
765
|
+
// 未知 / 非終端の status: 走行中とみなして生存を更新する(失効時間が上限を保証する)。
|
|
766
|
+
const entry = this._liveTasks.get(taskId)
|
|
767
|
+
if (entry) entry.lastSeenAt = now
|
|
768
|
+
return
|
|
769
|
+
}
|
|
770
|
+
// task_progress: 生存の鼓動。既存エントリのみ延命する(新規作成はしない)。
|
|
771
|
+
const entry = this._liveTasks.get(taskId)
|
|
772
|
+
if (entry) entry.lastSeenAt = now
|
|
773
|
+
} catch (err) {
|
|
774
|
+
this.logger?.warn(
|
|
775
|
+
{ err: err?.message, stream_id: this.stream_id },
|
|
776
|
+
"observe task event threw",
|
|
777
|
+
)
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** 走行中タスクを持つか(warm 境界の保護判定用)。失効したエントリを掃除してから判定する。
|
|
782
|
+
* 失効は「最後に生存を確認してから」の相対時間で、**task_type ごとに TTL が違う**
|
|
783
|
+
* (鼓動が来る local_agent は 30 分 / 鼓動が来ない local_bash・未知は 4 時間)。
|
|
784
|
+
* ⚠️ 失効させて保護を落とす = そのセッションは kill されうる、という意味なので、**必ず warn を
|
|
785
|
+
* 残す**(黙って kill 可能にしない。後から「なぜ実行中に畳まれたのか」を追えるようにする)。 */
|
|
786
|
+
_hasLiveTask(now = Date.now()) {
|
|
787
|
+
if (!this._liveTasks || this._liveTasks.size === 0) return false
|
|
788
|
+
for (const [taskId, entry] of this._liveTasks) {
|
|
789
|
+
const ttl = taskProtectTtlFor(entry?.taskType)
|
|
790
|
+
const elapsed = now - (entry?.lastSeenAt ?? 0)
|
|
791
|
+
if (elapsed <= ttl) continue
|
|
792
|
+
this._liveTasks.delete(taskId)
|
|
793
|
+
this.logger?.warn?.(
|
|
794
|
+
{
|
|
795
|
+
stream_id: this.stream_id,
|
|
796
|
+
task_id: taskId,
|
|
797
|
+
task_type: entry?.taskType ?? null,
|
|
798
|
+
elapsed_s: Math.round(elapsed / 1000),
|
|
799
|
+
ttl_ms: ttl,
|
|
800
|
+
},
|
|
801
|
+
"task protection expired (session is reapable again; a still-running task would be killed)",
|
|
802
|
+
)
|
|
803
|
+
}
|
|
804
|
+
return this._liveTasks.size > 0
|
|
805
|
+
}
|
|
806
|
+
|
|
623
807
|
_emit(event) {
|
|
808
|
+
// 走行中タスクの観測は全経路の合流点であるここで 1 回だけ行う(例外は内部で握る)。
|
|
809
|
+
this._observeTaskEvent(event)
|
|
624
810
|
if (!this.onEvent) return
|
|
625
811
|
let out = event
|
|
626
812
|
if (MASK_ENABLED) {
|
|
@@ -2240,7 +2426,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2240
2426
|
/**
|
|
2241
2427
|
* 指定 cwd / session_id に紐づく SDK セッションを**即停止**する (T04784 TUI bind 用)。
|
|
2242
2428
|
*
|
|
2243
|
-
* TUI モードに切り替えた session
|
|
2429
|
+
* TUI モードに切り替えた session は tmux ペインの対話 claude が write を担うため、
|
|
2244
2430
|
* SDK セッションを生かしたままだと同一 cwd の jsonl を二重ホストして競合する
|
|
2245
2431
|
* (DESIGN.md C7)。soft-detach (7 日 resident) では止まらないので、ここで明示 close する。
|
|
2246
2432
|
*
|
|
@@ -3353,14 +3539,26 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3353
3539
|
* セッションも保護する(reap すると未処理入力が失われる)。
|
|
3354
3540
|
* R1(c) 多ウィンドウ相互 reap 抑止: 2 端末以上が現在ライブ購読中の多端末共有セッション
|
|
3355
3541
|
* (T06542) も保護する。同一 cwd で別 session_id を開いた新規 1 本を残すために、複数
|
|
3356
|
-
* ウィンドウが見ている共有セッションを per-cwd
|
|
3357
|
-
* (単端末の重複セッション collapse は従来どおり働く: 単端末は count=1 で非保護)。
|
|
3358
|
-
|
|
3542
|
+
* ウィンドウが見ている共有セッションを per-cwd / cwd-LRU が刈ってしまう事故を防ぐ
|
|
3543
|
+
* (単端末の重複セッション collapse は従来どおり働く: 単端末は count=1 で非保護)。
|
|
3544
|
+
* タスク実行中 (_hasLiveTask) も保護する: 背景サブエージェント / 背景コマンドはターン終了後も
|
|
3545
|
+
* 走り続ける(実測で終了イベントは result の 14 秒後)ため、_busy だけでは守れない。保護は
|
|
3546
|
+
* 失効時間つきで、終了イベントを取りこぼしても永久化しない。
|
|
3547
|
+
* ⚠️ 旧コメントは「codex 側には対応物を置いていない(意図的な非対称)」としていたが、これは
|
|
3548
|
+
* **事実として誤り**だったので訂正する。codex-cli 0.145.0 の実測(/tmp/satoru_bgprobe/raw2.jsonl,
|
|
3549
|
+
* raw4.jsonl)では `item/started(commandExecution, status=inProgress)` → `turn/completed` の順に
|
|
3550
|
+
* 届き、**turn/completed の後もそのコマンドは走り続ける**(sleep 120 で確認)。自然終了でも
|
|
3551
|
+
* `item/completed` は届く(sleep 20 の追試で turn/completed の 17 秒後)。したがって codex 側にも
|
|
3552
|
+
* 対応物(`liveExecItems` 台帳 + `_hasLiveExec`)を置いた(codex-stream-bridge.mjs)。
|
|
3553
|
+
* 観測は **通知の台帳**で行い、権威照会 `thread/backgroundTerminals/list` は使わない
|
|
3554
|
+
* (実験 API の有効化が要るうえ、threadId ごとの照会なので子スレッドの背景コマンドを取りこぼす)。 */
|
|
3555
|
+
_isSessionProtected(session, now = Date.now()) {
|
|
3359
3556
|
if (!session) return false
|
|
3360
3557
|
if (session._busy) return true
|
|
3361
3558
|
if (Array.isArray(session._pendingMessages) && session._pendingMessages.length > 0) {
|
|
3362
3559
|
return true
|
|
3363
3560
|
}
|
|
3561
|
+
if (session._hasLiveTask?.(now)) return true
|
|
3364
3562
|
return this._liveTerminalCount(session) >= 2
|
|
3365
3563
|
}
|
|
3366
3564
|
|
|
@@ -3382,26 +3580,62 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3382
3580
|
}
|
|
3383
3581
|
|
|
3384
3582
|
/**
|
|
3385
|
-
*
|
|
3583
|
+
* 総 live 上限 M(warm-bounds-total.mjs)用の **CLI 非依存アダプタ**。live セッションを
|
|
3584
|
+
* `{ kind, cwd, recency, seq, protected, reap }` の共通形へ包んで返すだけ(新しい判断は持たない)。
|
|
3585
|
+
* warm セッションは this.sessions から外れ _liveBySession に残るため union を dedup して走査する。
|
|
3586
|
+
* @param {number} [now]
|
|
3587
|
+
* @returns {Array<object>}
|
|
3588
|
+
*/
|
|
3589
|
+
listWarmCandidates(now = Date.now()) {
|
|
3590
|
+
const out = []
|
|
3591
|
+
const seen = new Set()
|
|
3592
|
+
for (const s of [...this.sessions.values(), ...this._liveBySession.values()]) {
|
|
3593
|
+
if (!s || s._closed || seen.has(s)) continue
|
|
3594
|
+
seen.add(s)
|
|
3595
|
+
out.push({
|
|
3596
|
+
kind: "claude",
|
|
3597
|
+
cwd: s.cwd,
|
|
3598
|
+
recency: this._sessionRecency(s),
|
|
3599
|
+
seq: s._seq ?? 0,
|
|
3600
|
+
protected: this._isSessionProtected(s, now),
|
|
3601
|
+
reap: (reason) => this._reapForWarmBounds(s, reason),
|
|
3602
|
+
})
|
|
3603
|
+
}
|
|
3604
|
+
return out
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3607
|
+
/**
|
|
3608
|
+
* 子⑤ (T06544): warm 保持を「各 cwd K 本 × 同時 warm 保持する cwd 数 N(既定 20)」で有界化する
|
|
3386
3609
|
* (codex-stream-bridge.mjs の `_reconcileWarmBounds` と対)。state loop から周期実行する。
|
|
3387
|
-
* Phase 1 (per-cwd=
|
|
3388
|
-
*
|
|
3389
|
-
*
|
|
3610
|
+
* Phase 1 (per-cwd=K): 同一 cwd に K 本を超える live session があれば最終活性が新しい順に
|
|
3611
|
+
* 先頭 K 本を残し、それ以降の **非 busy** を reap する(K の既定は 3。旧仕様の per-cwd=1 を
|
|
3612
|
+
* 一般化したもので、1 cwd で複数会話を同時に生かすため。総量は Phase 2 と総 live 上限 M
|
|
3613
|
+
* (warm-bounds-total.mjs)で押さえる)。
|
|
3390
3614
|
* Phase 2 (N-cwd LRU): live session を持つ distinct cwd 数が上限を超えたら、cwd の最終活性が
|
|
3391
3615
|
* 最も古い cwd から、その cwd の非 busy session を reap して cwd 数を上限まで戻す。
|
|
3392
3616
|
* busy(走行中ターン)は Phase 1/2 いずれでも reap しない(完走を待つ)。warm セッションは
|
|
3393
3617
|
* this.sessions から外れ _liveBySession に残るため、両索引の union を dedup して走査する。
|
|
3394
|
-
* @param {{ maxCwds?: number, now?: number }} [opts] テストから上限/時刻を注入可能。
|
|
3618
|
+
* @param {{ maxCwds?: number, maxPerCwd?: number, now?: number }} [opts] テストから上限/時刻を注入可能。
|
|
3395
3619
|
* @returns {number} reap 数
|
|
3396
3620
|
*/
|
|
3397
|
-
reconcileWarmBounds({ maxCwds
|
|
3398
|
-
|
|
3621
|
+
reconcileWarmBounds({ maxCwds, maxPerCwd, now = Date.now() } = {}) {
|
|
3622
|
+
// ⚠️ 上限は **正の整数**でなければならない。1.5 のような値が for の開始添字に入ると、
|
|
3623
|
+
// undefined の reap が no-op なのに reaped だけ加算され「畳んでいないのに畳んだと報告する」。
|
|
3624
|
+
// 不正値は既定へ戻して warn(黙って floor しない。positive-int-env.mjs 参照)。
|
|
3625
|
+
const cwdLimit = sanitizePositiveInt(maxCwds, WARM_CWD_MAX, {
|
|
3626
|
+
logger: this.logger,
|
|
3627
|
+
label: "claude.warmBounds.maxCwds",
|
|
3628
|
+
})
|
|
3629
|
+
const keepPerCwd = sanitizePositiveInt(maxPerCwd, WARM_PER_CWD_MAX, {
|
|
3630
|
+
logger: this.logger,
|
|
3631
|
+
label: "claude.warmBounds.maxPerCwd",
|
|
3632
|
+
})
|
|
3399
3633
|
const liveSessions = () =>
|
|
3400
3634
|
Array.from(new Set([...this.sessions.values(), ...this._liveBySession.values()])).filter(
|
|
3401
3635
|
(s) => s && !s._closed,
|
|
3402
3636
|
)
|
|
3403
3637
|
let reaped = 0
|
|
3404
|
-
// Phase 1: per-cwd=
|
|
3638
|
+
// Phase 1: per-cwd=K。cwd ごとに最終活性が新しい順へ並べ、先頭 K 本以外の非 busy を reap。
|
|
3405
3639
|
const byCwd = new Map()
|
|
3406
3640
|
for (const s of liveSessions()) {
|
|
3407
3641
|
const list = byCwd.get(s.cwd)
|
|
@@ -3409,14 +3643,14 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3409
3643
|
else byCwd.set(s.cwd, [s])
|
|
3410
3644
|
}
|
|
3411
3645
|
for (const list of byCwd.values()) {
|
|
3412
|
-
if (list.length <=
|
|
3646
|
+
if (list.length <= keepPerCwd) continue
|
|
3413
3647
|
// 新しい順。recency 同値は _seq(生成順)で必ず新しい方を先頭(= 残す)にする(round5 Finding 1)。
|
|
3414
3648
|
list.sort(
|
|
3415
3649
|
(a, b) =>
|
|
3416
3650
|
this._sessionRecency(b) - this._sessionRecency(a) || (b._seq ?? 0) - (a._seq ?? 0),
|
|
3417
3651
|
)
|
|
3418
|
-
for (let i =
|
|
3419
|
-
if (this._isSessionProtected(list[i])) continue // 走行中/pending
|
|
3652
|
+
for (let i = keepPerCwd; i < list.length; i += 1) {
|
|
3653
|
+
if (this._isSessionProtected(list[i], now)) continue // 走行中/pending/タスク実行中は不可侵(一時的に >K を許容)
|
|
3420
3654
|
this._reapForWarmBounds(list[i], "warm-bounds:per-cwd")
|
|
3421
3655
|
reaped += 1
|
|
3422
3656
|
}
|
|
@@ -3432,10 +3666,10 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3432
3666
|
const r = this._sessionRecency(s)
|
|
3433
3667
|
if (r > info.recency) info.recency = r
|
|
3434
3668
|
if ((s._seq ?? 0) > info.seq) info.seq = s._seq ?? 0 // round5 Finding 1: cwd タイブレーク用
|
|
3435
|
-
if (this._isSessionProtected(s)) info.hasProtected = true
|
|
3669
|
+
if (this._isSessionProtected(s, now)) info.hasProtected = true
|
|
3436
3670
|
info.sessions.push(s)
|
|
3437
3671
|
}
|
|
3438
|
-
const overflow = cwdInfo.size -
|
|
3672
|
+
const overflow = cwdInfo.size - cwdLimit
|
|
3439
3673
|
if (overflow > 0) {
|
|
3440
3674
|
// protected(走行中/pending)を含む cwd は退避対象外(上限にカウントはするが reap しない)。
|
|
3441
3675
|
// 最古 recency の cwd から。recency 同値は seq(新しさ)で必ず新しい cwd を残す。
|
|
@@ -3446,7 +3680,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
3446
3680
|
for (const info of evictableCwds) {
|
|
3447
3681
|
if (cwdsEvicted >= overflow) break
|
|
3448
3682
|
for (const s of info.sessions) {
|
|
3449
|
-
if (this._isSessionProtected(s)) continue
|
|
3683
|
+
if (this._isSessionProtected(s, now)) continue
|
|
3450
3684
|
this._reapForWarmBounds(s, "warm-bounds:cwd-lru")
|
|
3451
3685
|
reaped += 1
|
|
3452
3686
|
}
|