@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
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* claude.input → input() (idle は turn/start、生成中は次ターン FIFO)
|
|
12
12
|
* claude.interrupt → interrupt() (turn/interrupt)
|
|
13
13
|
* claude.detach → detach() (TTL 付き graceful close)
|
|
14
|
-
* (codex 固有) → keepalive() (
|
|
14
|
+
* (codex 固有) → keepalive() (閲覧中の生存シグナル)
|
|
15
15
|
* claude.permission.reply → permissionReply() (client.respond/respondError)
|
|
16
16
|
* claude.event (生 SDK イベント素通し) → 'event' (生 notification 素通し、同じ思想)
|
|
17
17
|
* claude.permission.request → 'permission' (承認/入力要求の生 method+params 素通し)
|
|
@@ -52,6 +52,8 @@ import {
|
|
|
52
52
|
verifyCodexDeliveryAudit,
|
|
53
53
|
} from "./codex-delivery-audit.mjs"
|
|
54
54
|
import { getOrCreateSiblingToken } from "./sibling-token.mjs"
|
|
55
|
+
import { readPositiveIntEnv, sanitizePositiveInt } from "./positive-int-env.mjs"
|
|
56
|
+
import { nextSessionSeq } from "./session-seq.mjs"
|
|
55
57
|
import { codexRolloutPresence, listCodexThreadsFromDisk } from "./usage.mjs"
|
|
56
58
|
|
|
57
59
|
/**
|
|
@@ -92,49 +94,90 @@ function siblingEnvFor(cwd) {
|
|
|
92
94
|
// これにより意味論は「**閲覧中=無期限(keepalive で LRU 先頭維持)/ 直近 5 セッションは
|
|
93
95
|
// detach しても 7 日 warm / 上限超過は LRU で 1 個 reap / busy は不可侵 / stuck は救済**」。
|
|
94
96
|
|
|
97
|
+
// ⚠️ 以下の上限 / TTL 系 env は **すべて readPositiveIntEnv を通す**(positive-int-env.mjs)。
|
|
98
|
+
// 素の `Number(env) || 既定` だと "1.5" や "Infinity" が通り、上限が for の開始添字に入ると
|
|
99
|
+
// `list[1.5]` が undefined になって `_isProtected(undefined)` で TypeError になる。
|
|
100
|
+
// 既定値と意味は従来のまま。不正値は既定へ戻して warn する(黙って丸めない)。
|
|
101
|
+
|
|
95
102
|
// warm 保持の idle TTL(非 busy がこの時間 無活性なら reap の対象。上限内でも 7 日で頭打ち)。
|
|
96
|
-
const IDLE_DETACH_TTL_MS =
|
|
97
|
-
|
|
103
|
+
const IDLE_DETACH_TTL_MS = readPositiveIntEnv(
|
|
104
|
+
"HUB_AGENT_CODEX_IDLE_DETACH_TTL_MS",
|
|
105
|
+
7 * 24 * 60 * 60 * 1000,
|
|
106
|
+
)
|
|
98
107
|
|
|
99
|
-
// 子⑤ (T06544): warm 保持の上限を「マシン全体の総セッション数」から「各 cwd
|
|
108
|
+
// 子⑤ (T06544): warm 保持の上限を「マシン全体の総セッション数」から「各 cwd K 本 × 同時 warm
|
|
100
109
|
// 保持する cwd 数 N」へ再定義(親 T06539「1 cwd 1 本化」に整合)。旧 WARM_POOL_MAX(総数 LRU=5)は
|
|
101
110
|
// 廃止し、境界を cwd 単位で定義する。1 セッション ≈ 120-140MB(node ラッパー + 本体 + MCP 子)
|
|
102
111
|
// なので既定 20 cwd(~2.4-2.8GB 上限)。端末リソースで無理なら env で下げる。
|
|
103
112
|
// 後方互換: 旧 HUB_AGENT_CODEX_WARM_POOL_MAX を設定していても新 cwd 上限として解釈する。
|
|
104
|
-
const WARM_CWD_MAX =
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
113
|
+
const WARM_CWD_MAX = readPositiveIntEnv(
|
|
114
|
+
["HUB_AGENT_WARM_CWD_MAX", "HUB_AGENT_CODEX_WARM_POOL_MAX"],
|
|
115
|
+
20,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
// 1 cwd あたりの live セッション上限 K(既定 3)。旧仕様の per-cwd=1 を一般化したもので、
|
|
119
|
+
// 「1 cwd で複数会話を同時に生かす」ため。総量は増やさず配分の自由度だけ上げる方針なので、
|
|
120
|
+
// 総 live 上限 M(warm-bounds-total.mjs、claude と合算)とセットで効かせる。
|
|
121
|
+
// ⚠️ 既存 env(HUB_AGENT_WARM_CWD_MAX / HUB_AGENT_CODEX_WARM_POOL_MAX = 同時 warm 保持する
|
|
122
|
+
// cwd 数 N)の意味は変えない。新しい上限は新しい env 名で足す。
|
|
123
|
+
const WARM_PER_CWD_MAX = readPositiveIntEnv("HUB_AGENT_WARM_PER_CWD_MAX", 3)
|
|
114
124
|
|
|
115
125
|
// stuck turn 検出の閾値。activeTurnId が立っているのに app-server 通知がこの時間途絶えた
|
|
116
126
|
// セッションは「ハングしたターン」とみなし protected から外して reap する(healthy な長ターンは
|
|
117
127
|
// reasoning/output delta で都度 touch されるため該当しない)。旧 `HUB_AGENT_DEAD_TERMINAL_TTL_MS`
|
|
118
128
|
// を後方互換で受ける。既定 1h。
|
|
119
|
-
const STUCK_TURN_TTL_MS =
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
129
|
+
const STUCK_TURN_TTL_MS = readPositiveIntEnv(
|
|
130
|
+
["HUB_AGENT_CODEX_STUCK_TURN_TTL_MS", "HUB_AGENT_DEAD_TERMINAL_TTL_MS"],
|
|
131
|
+
60 * 60 * 1000,
|
|
132
|
+
)
|
|
123
133
|
|
|
124
134
|
// 案B (T06542): 死端末 GC の TTL。多端末購読で、応答を返さなくなった(クラッシュ / タブ強制終了で
|
|
125
135
|
// codex.detach を送れなかった)購読端末を購読集合から掃除する閾値。claude-stream-bridge.mjs の
|
|
126
136
|
// DEAD_TERMINAL_TTL_MS(既定 1h)と同型・同既定。stuck turn TTL とは別軸(あちらはターン固着、
|
|
127
137
|
// こちらは端末の無応答)だが既定は揃える。
|
|
128
|
-
const DEAD_TERMINAL_TTL_MS =
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
138
|
+
const DEAD_TERMINAL_TTL_MS = readPositiveIntEnv(
|
|
139
|
+
["HUB_AGENT_CODEX_DEAD_TERMINAL_TTL_MS", "HUB_AGENT_DEAD_TERMINAL_TTL_MS"],
|
|
140
|
+
60 * 60 * 1000,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
// 実行中の背景コマンド(commandExecution)を warm 境界の刈り取りから保護する失効時間(既定 4 時間)。
|
|
144
|
+
//
|
|
145
|
+
// 【なぜ必要か(旧「意図的な非対称」の訂正)】codex-cli 0.145.0 の実測
|
|
146
|
+
// (/tmp/satoru_bgprobe/raw2.jsonl)で、`item/started(commandExecution, status=inProgress)` →
|
|
147
|
+
// `turn/completed` の順に届いたあと **そのコマンドはまだ走っている**(sleep 120 で確認)。
|
|
148
|
+
// つまり activeTurnId(= busy)だけでは実行中の背景コマンドを守れず、claude 側の _liveTasks と
|
|
149
|
+
// 同型の台帳が要る。自然終了でも `item/completed` は届く(sleep 20 の追試で turn/completed の
|
|
150
|
+
// 17 秒後に status=completed。/tmp/satoru_bgprobe/raw4.jsonl)ので、通常は終了で解ける。
|
|
151
|
+
//
|
|
152
|
+
// 【なぜ claude の local_bash と同じ長さか】codex の `item/commandExecution/outputDelta`(鼓動)は
|
|
153
|
+
// **コマンド依存で来ないことがある**(出力の無いコマンドでは 1 件も来ない)。鼓動が保証されない
|
|
154
|
+
// 種別は長い方に揃える、という claude 側と同じ方針。
|
|
155
|
+
//
|
|
156
|
+
// ⚠️ これは**仕様上の最大保護時間**であり、**4 時間を超える背景コマンドは保護されない**。
|
|
157
|
+
//
|
|
158
|
+
// 【有界なのは「時間」だけで、「件数」ではない】(cross-review round2 minor)
|
|
159
|
+
// 1 エントリあたりの保護時間は TTL で有界(4 時間で頭打ち)。一方で **同時に保護されうるセッション
|
|
160
|
+
// 数には K(per-cwd)も N(cwd 数)も M(総 live 上限、warm-bounds-total.mjs)も効かない**。すべての
|
|
161
|
+
// 境界が protected を skip する設計なので、4 時間以内に別セッションで背景コマンドを増やせば、保護中の
|
|
162
|
+
// 合計は M を超えて増え続ける。これは「走行中を絶対に殺さない」を優先した**明示的な超過受容**であり、
|
|
163
|
+
// **M をハードな資源上限と読んではいけない**。超過量は warm-bounds-total.mjs が返す
|
|
164
|
+
// `liveTotal` / `unresolved` のログで観測できる(運用判断はその実測値を見る)。
|
|
165
|
+
//
|
|
166
|
+
// ⚠️ 将来ハード上限(保護中でも刈る)を足すなら、**実行中コマンドを中断するという新しい失敗モード**
|
|
167
|
+
// が生まれる。この TTL 設計の延長で暗黙に足さないこと。足すなら victim の選び方・利用者への通知・
|
|
168
|
+
// 再開可否を別仕様として設計する。
|
|
169
|
+
const CODEX_EXEC_PROTECT_TTL_MS = readPositiveIntEnv(
|
|
170
|
+
"HUB_AGENT_CODEX_EXEC_PROTECT_TTL_MS",
|
|
171
|
+
4 * 60 * 60 * 1000,
|
|
172
|
+
)
|
|
132
173
|
|
|
133
174
|
// 案B (T06542, codex-review round3 major#2): turn/start RPC 成功後に turn/started 通知が来るのを待つ
|
|
134
175
|
// 上限。これを超えたら「ターンが正しく始まらなかった」とみなし、pump が待機中の queue を reject して
|
|
135
176
|
// 状態を解除する(turn/started/completed/exit が来ないケースで promise が永久宙吊りになるのを防ぐ)。
|
|
136
|
-
const TURN_START_TIMEOUT_MS =
|
|
137
|
-
|
|
177
|
+
const TURN_START_TIMEOUT_MS = readPositiveIntEnv(
|
|
178
|
+
"HUB_AGENT_CODEX_TURN_START_TIMEOUT_MS",
|
|
179
|
+
30 * 1000,
|
|
180
|
+
)
|
|
138
181
|
|
|
139
182
|
// browser が ack を取りこぼしても queue snapshot から受理済みと再確認できるよう、
|
|
140
183
|
// 直近 input_id を bounded ring で保持する (Claude queue_state.accepted と同じ契約)。
|
|
@@ -143,8 +186,10 @@ const ACCEPTED_INPUT_ID_RING_MAX = 256
|
|
|
143
186
|
// sibling-inject の turn RPC が成功した後、app-server が同じ userMessage を
|
|
144
187
|
// item/completed で echo するまで待つ上限。RPC accepted と userMessage delivered を
|
|
145
188
|
// 分離し、旧 stream / 初期化競合で rollout に残らない入力を成功扱いしない。
|
|
146
|
-
const DELIVERY_ECHO_TIMEOUT_MS =
|
|
147
|
-
|
|
189
|
+
const DELIVERY_ECHO_TIMEOUT_MS = readPositiveIntEnv(
|
|
190
|
+
"HUB_AGENT_CODEX_DELIVERY_ECHO_TIMEOUT_MS",
|
|
191
|
+
8 * 1000,
|
|
192
|
+
)
|
|
148
193
|
|
|
149
194
|
const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
|
|
150
195
|
|
|
@@ -158,9 +203,11 @@ const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0
|
|
|
158
203
|
// が起きる。thread/resume 自体はモデル側の会話コンテキスト復元のため全量が必要だが、
|
|
159
204
|
// **browser へ渡す表示用履歴は末尾 N ターン分に絞る** ことで断崖を回避する。上限は
|
|
160
205
|
// 「item 数」と「概算バイト数」の 2 軸で、末尾から詰めてどちらか先に達したら truncated。
|
|
161
|
-
const HISTORY_MAX_ITEMS =
|
|
162
|
-
const HISTORY_MAX_BYTES =
|
|
163
|
-
|
|
206
|
+
const HISTORY_MAX_ITEMS = readPositiveIntEnv("HUB_AGENT_CODEX_HISTORY_MAX_ITEMS", 200)
|
|
207
|
+
const HISTORY_MAX_BYTES = readPositiveIntEnv(
|
|
208
|
+
"HUB_AGENT_CODEX_HISTORY_MAX_BYTES",
|
|
209
|
+
1.5 * 1024 * 1024,
|
|
210
|
+
)
|
|
164
211
|
|
|
165
212
|
// Slice 6: thread/resume が返す Thread.turns[].items[] を単一の時系列配列へ平坦化する。
|
|
166
213
|
// turns は開始ターン順の配列、各 turn.items も時系列順のため単純結合で会話順を保てる。
|
|
@@ -318,6 +365,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
318
365
|
clientFactory,
|
|
319
366
|
detachTtlMs,
|
|
320
367
|
warmCwdMax,
|
|
368
|
+
warmPerCwdMax,
|
|
321
369
|
stuckTurnTtlMs,
|
|
322
370
|
deadTerminalTtlMs,
|
|
323
371
|
turnStartTimeoutMs,
|
|
@@ -337,9 +385,19 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
337
385
|
this._clientFactory = clientFactory || ((opts) => new CodexAppServerClient(opts))
|
|
338
386
|
// Stage 5: warm 保持の idle TTL(既定 7 日)。テストから短縮できるよう注入可能。
|
|
339
387
|
this._detachTtlMs = detachTtlMs ?? IDLE_DETACH_TTL_MS
|
|
340
|
-
// 子⑤ (T06544): 同時 warm 保持する cwd
|
|
341
|
-
//
|
|
342
|
-
|
|
388
|
+
// 子⑤ (T06544): 同時 warm 保持する cwd 数の上限 N(既定 20)。各 cwd は K 本に畳む。
|
|
389
|
+
// テストから小さい値を注入可能。⚠️ 正の整数以外(1.5 / Infinity / NaN)は既定へ戻して warn する。
|
|
390
|
+
// 1.5 が for の開始添字に入ると `list[1.5]` が undefined になり `_isProtected(undefined)` で
|
|
391
|
+
// TypeError(positive-int-env.mjs 参照)。
|
|
392
|
+
this._warmCwdMax = sanitizePositiveInt(warmCwdMax, WARM_CWD_MAX, {
|
|
393
|
+
logger: this.logger,
|
|
394
|
+
label: "codex.warmCwdMax",
|
|
395
|
+
})
|
|
396
|
+
// 1 cwd あたりの live セッション上限 K(既定 3)。テストから 1 を注入すると旧 per-cwd=1 挙動。
|
|
397
|
+
this._warmPerCwdMax = sanitizePositiveInt(warmPerCwdMax, WARM_PER_CWD_MAX, {
|
|
398
|
+
logger: this.logger,
|
|
399
|
+
label: "codex.warmPerCwdMax",
|
|
400
|
+
})
|
|
343
401
|
// Stage 5: stuck turn 検出 TTL(既定 1h)。テストから短縮可能。
|
|
344
402
|
this._stuckTurnTtlMs = stuckTurnTtlMs ?? STUCK_TURN_TTL_MS
|
|
345
403
|
// 案B (T06542): 死端末 GC の TTL(既定 1h)。テストから短縮可能。
|
|
@@ -542,8 +600,10 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
542
600
|
// (streamIds が真の購読者集合)。app-server 通知や emit は都度 session.stream_id を読む。
|
|
543
601
|
stream_id,
|
|
544
602
|
// 子⑤ round5 Finding 1: 生成順の単調増加シーケンス(warm 境界のタイブレーク用。値が大きい =
|
|
545
|
-
// 新しい)。recency 同値時に per-cwd=
|
|
546
|
-
seq
|
|
603
|
+
// 新しい)。recency 同値時に per-cwd=K が新しいセッションを残せるようにする。
|
|
604
|
+
// ⚠️ カウンタは **claude / codex 共通**(session-seq.mjs)。総 live 上限 M は両 CLI を合算して
|
|
605
|
+
// 古い順に刈るため、CLI ごとに別カウンタだと CLI をまたいだ比較が成立しない。
|
|
606
|
+
seq: nextSessionSeq(),
|
|
547
607
|
cwd,
|
|
548
608
|
// 案B (T06542): 多端末購読集合。この live session を購読中の全端末 stream_id。reattach は
|
|
549
609
|
// 「所有権付け替え」ではなく「購読者追加」。detach で当該端末を外し、最後の1台が外れたら
|
|
@@ -626,6 +686,24 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
626
686
|
* deliveryId: string, sourceStreamId: string, deliveryPending: boolean,
|
|
627
687
|
* deliveryPromise: Promise<object>, settleDelivery: Function}>} */
|
|
628
688
|
pendingInjectSources: [],
|
|
689
|
+
/** 実行中の背景コマンド(commandExecution)の台帳。claude 側 `_liveTasks` と同型。
|
|
690
|
+
* key = `item.id`(`item/started` / `item/completed` の両方に載る唯一の安定 ID)、
|
|
691
|
+
* value = { threadId, startedAt, lastSeenAt }。
|
|
692
|
+
*
|
|
693
|
+
* ⚠️ `turn/completed` の後もコマンドは走り続ける(実測 /tmp/satoru_bgprobe/raw2.jsonl)ので、
|
|
694
|
+
* activeTurnId(busy)だけでは守れない。この台帳で `_isProtected` を延長する。
|
|
695
|
+
*
|
|
696
|
+
* ⚠️ **コマンド本文(`item.command`)は保持しない**。台帳に載せると失効 warn 等のログ経由で
|
|
697
|
+
* `~/.hub/agent.log`(launchd の StandardOutPath で 0644 のまま長期保存される)へ本文が流れ、
|
|
698
|
+
* Bearer トークン・API キー・個人情報が引数や heredoc ごと永続化される。会話 UI に流れること
|
|
699
|
+
* (認可された閲覧者だけが見る)と、永続ログに長期保存されることは境界が違う。
|
|
700
|
+
* ⚠️ ハッシュ化で代替しないこと。コマンド行はエントロピーが低く辞書で復元できるため、
|
|
701
|
+
* 「本文を出さない」の代わりにならない。診断は item_id / thread_id で辿る。
|
|
702
|
+
*
|
|
703
|
+
* ⚠️ threadId は**記録するだけ**で判定には使わない(帰属ガードは本 PR のスコープ外)。
|
|
704
|
+
* @type {Map<string, {threadId: string|null,
|
|
705
|
+
* startedAt: number, lastSeenAt: number}>} */
|
|
706
|
+
liveExecItems: new Map(),
|
|
629
707
|
}
|
|
630
708
|
session.ready = new Promise((resolve) => {
|
|
631
709
|
settleReady = resolve
|
|
@@ -752,8 +830,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
752
830
|
total_ms: Date.now() - attachStartedAt,
|
|
753
831
|
timing,
|
|
754
832
|
})
|
|
755
|
-
// 子⑤ (T06544): genuine attach で 1 本増えたので warm 境界(per-cwd=
|
|
756
|
-
// 適用する(次の reconnect まで ~5s
|
|
833
|
+
// 子⑤ (T06544): genuine attach で 1 本増えたので warm 境界(per-cwd=K / N-cwd LRU)を即時
|
|
834
|
+
// 適用する(次の reconnect まで ~5s 待たず、cwd 内の K 本超過や cwd 数超過が居座るのを防ぐ)。
|
|
757
835
|
// 新セッションはたった今 touch 済み = 各判定で最新なので退避対象にならず、最も古い非 protected
|
|
758
836
|
// が落ちる。
|
|
759
837
|
this._reconcileWarmBounds(Date.now(), "warm-bounds-on-attach")
|
|
@@ -1345,12 +1423,111 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1345
1423
|
if (this.sessions.get(session.stream_id) === session) this.sessions.delete(session.stream_id)
|
|
1346
1424
|
}
|
|
1347
1425
|
|
|
1426
|
+
/**
|
|
1427
|
+
* app-server 通知を観測して、実行中の背景コマンド台帳(`session.liveExecItems`)を更新する。
|
|
1428
|
+
*
|
|
1429
|
+
* 呼び出しは `_onNotification` の先頭 1 箇所だけ(claude 側 `_observeTaskEvent` と同じ理由で、
|
|
1430
|
+
* switch の中に散らさない)。実測(codex-cli 0.145.0 / /tmp/satoru_bgprobe/raw2.jsonl, raw4.jsonl)
|
|
1431
|
+
* に基づく扱い:
|
|
1432
|
+
* - `item/started` + `item.type === "commandExecution"` + `item.status === "inProgress"` → 登録
|
|
1433
|
+
* - `item/completed` + `item.type === "commandExecution"` → `item.id` で削除(冪等)
|
|
1434
|
+
* - `item/commandExecution/outputDelta`(`params.itemId`)→ **既存エントリがあるときだけ**延命。
|
|
1435
|
+
* 新規作成しない(完了後に遅れて届いた delta で蘇らせない。claude の task_progress と同じ)
|
|
1436
|
+
*
|
|
1437
|
+
* ⚠️ `item.type` であって `item_type` ではない(実測のフィールド名)。
|
|
1438
|
+
* ⚠️ 対象は `commandExecution` に限る。`mcpToolCall` も長時間化しうるが「turn/completed の後も
|
|
1439
|
+
* 走り続けるか」を**実測していない**ため含めない(未実測のものを保護に入れると、逆に
|
|
1440
|
+
* 解除されない保護を作りかねない)。実測できたら同じ台帳に足せばよい。
|
|
1441
|
+
* ⚠️ 例外を外へ投げないこと。`_onNotification` は全通知が通るので、ここで throw すると
|
|
1442
|
+
* ストリーム全体が壊れる。
|
|
1443
|
+
*
|
|
1444
|
+
* @param {object} session
|
|
1445
|
+
* @param {object} msg app-server 通知({ method, params })
|
|
1446
|
+
* @param {number} [now] 時刻(テストから注入可能)
|
|
1447
|
+
*/
|
|
1448
|
+
_observeExecItem(session, msg, now = Date.now()) {
|
|
1449
|
+
try {
|
|
1450
|
+
const method = msg?.method
|
|
1451
|
+
if (typeof method !== "string" || !method) return
|
|
1452
|
+
const ledger = session?.liveExecItems
|
|
1453
|
+
if (!ledger) return
|
|
1454
|
+
const params = msg.params
|
|
1455
|
+
if (method === "item/started" || method === "item/completed") {
|
|
1456
|
+
const item = params?.item
|
|
1457
|
+
if (!item || item.type !== "commandExecution") return
|
|
1458
|
+
const id = item.id
|
|
1459
|
+
if (typeof id !== "string" || !id) return
|
|
1460
|
+
if (method === "item/completed") {
|
|
1461
|
+
ledger.delete(id)
|
|
1462
|
+
return
|
|
1463
|
+
}
|
|
1464
|
+
if (item.status !== "inProgress") return
|
|
1465
|
+
ledger.set(id, {
|
|
1466
|
+
// 帰属判定には使わない(記録のみ)。別件で子スレッド起因を切り分けたくなったとき用。
|
|
1467
|
+
threadId: typeof params?.threadId === "string" ? params.threadId : null,
|
|
1468
|
+
// ⚠️ `item.command`(コマンド本文)はここで**捨てる**。台帳へ持つと失効 warn 経由で
|
|
1469
|
+
// 永続ログ(~/.hub/agent.log)に本文が残り、秘密情報が長期保存される。
|
|
1470
|
+
startedAt: now,
|
|
1471
|
+
lastSeenAt: now,
|
|
1472
|
+
})
|
|
1473
|
+
return
|
|
1474
|
+
}
|
|
1475
|
+
if (method === "item/commandExecution/outputDelta") {
|
|
1476
|
+
const itemId = params?.itemId
|
|
1477
|
+
if (typeof itemId !== "string" || !itemId) return
|
|
1478
|
+
const entry = ledger.get(itemId)
|
|
1479
|
+
if (entry) entry.lastSeenAt = now
|
|
1480
|
+
}
|
|
1481
|
+
} catch (err) {
|
|
1482
|
+
this.logger?.warn?.(
|
|
1483
|
+
{ err: err?.message, stream_id: session?.stream_id },
|
|
1484
|
+
"codex observe exec item threw",
|
|
1485
|
+
)
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/** 実行中の背景コマンドを持つか(warm 境界の保護判定用)。**先に失効エントリを掃除してから**
|
|
1490
|
+
* 判定する。失効は「最後に生存を確認してから CODEX_EXEC_PROTECT_TTL_MS」。
|
|
1491
|
+
* ⚠️ 失効させる = そのセッションは kill されうる、という意味なので必ず warn を残す
|
|
1492
|
+
* (黙って kill 可能にしない)。
|
|
1493
|
+
* ⚠️ warn に**コマンド本文を載せない**(この warn は永続ログへ出る。理由は liveExecItems の
|
|
1494
|
+
* コメント参照)。診断は `item_id` と 2 つの thread id で辿る:
|
|
1495
|
+
* - `thread_id` … セッションの root スレッド(`session.threadId`)。**意味を変えないこと**
|
|
1496
|
+
* (既存のログ集計がこのキーで root を前提にしている)
|
|
1497
|
+
* - `exec_thread_id`… その commandExecution の通知に載っていた `params.threadId`(子スレッド
|
|
1498
|
+
* 起因かを切り分けたいとき用。root を上書きせず**別名**で出す) */
|
|
1499
|
+
_hasLiveExec(session, now = Date.now()) {
|
|
1500
|
+
const ledger = session?.liveExecItems
|
|
1501
|
+
if (!ledger || ledger.size === 0) return false
|
|
1502
|
+
for (const [itemId, entry] of ledger) {
|
|
1503
|
+
const elapsed = now - (entry?.lastSeenAt ?? 0)
|
|
1504
|
+
if (elapsed <= CODEX_EXEC_PROTECT_TTL_MS) continue
|
|
1505
|
+
ledger.delete(itemId)
|
|
1506
|
+
this.logger?.warn?.(
|
|
1507
|
+
{
|
|
1508
|
+
stream_id: session?.stream_id,
|
|
1509
|
+
thread_id: session?.threadId ?? null,
|
|
1510
|
+
item_id: itemId,
|
|
1511
|
+
exec_thread_id: entry?.threadId ?? null,
|
|
1512
|
+
elapsed_s: Math.round(elapsed / 1000),
|
|
1513
|
+
ttl_ms: CODEX_EXEC_PROTECT_TTL_MS,
|
|
1514
|
+
},
|
|
1515
|
+
"codex exec protection expired (session is reapable again; a still-running command would be killed)",
|
|
1516
|
+
)
|
|
1517
|
+
}
|
|
1518
|
+
return ledger.size > 0
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1348
1521
|
_onNotification(session, msg) {
|
|
1349
1522
|
const { stream_id } = session
|
|
1350
1523
|
// app-server からの通知 = ターンが進行している証跡。これで touch することで、
|
|
1351
1524
|
// 正常に走っているターンは reaper に回収されず、通知が途絶えたスタックターンだけが
|
|
1352
1525
|
// 放棄セッションとして回収される。
|
|
1353
1526
|
this._touch(session)
|
|
1527
|
+
// 実行中の背景コマンド台帳の更新は **ここ 1 箇所** で行う(switch の中に散らさない)。
|
|
1528
|
+
// 全通知がこの関数を通るので、ここが唯一の合流点になる(経路ごとに配線すると、method を
|
|
1529
|
+
// 足したときに無言で観測漏れする)。claude 側の `_emit` → `_observeTaskEvent` と同じ設計。
|
|
1530
|
+
this._observeExecItem(session, msg)
|
|
1354
1531
|
// 連携カード種別 (Phase 2, cross-review round1 major#2): タグ判定を switch より前に行い、
|
|
1355
1532
|
// **history にもタグ済み item を積む**。旧実装は未タグの msg.params.item を history へ入れてから
|
|
1356
1533
|
// ライブ emit だけタグしていたため、cwd reattach で historyItems が返ると連携カードが通常の青い
|
|
@@ -2289,6 +2466,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2289
2466
|
* - healthy running turn: `activeTurnId` が立ち、app-server 通知が STUCK_TTL 以内に届いている。
|
|
2290
2467
|
* activeTurnId 固着で通知が STUCK_TTL 途絶えたもの(stuck/ハング)は protected から外し reap
|
|
2291
2468
|
* 対象にする(プロセスリーク・固着の救済)。
|
|
2469
|
+
* - 実行中の背景コマンド(`liveExecItems` が非空): `turn/completed` の後も走り続ける
|
|
2470
|
+
* commandExecution を守る(実測 /tmp/satoru_bgprobe/raw2.jsonl)。claude 側のタスク保護
|
|
2471
|
+
* (`_hasLiveTask`)と対になる仕組みで、失効時間つきなので終了通知を落としても永久化しない。
|
|
2292
2472
|
* @param {object} session
|
|
2293
2473
|
* @param {number} now
|
|
2294
2474
|
* @returns {boolean}
|
|
@@ -2301,6 +2481,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2301
2481
|
// turn RPC accepted 後、対応 userMessage echo を待っている間も protected。
|
|
2302
2482
|
// ここを外すと別 attach の per-cwd reconcile が ACK 先を配達確認前に reap できる。
|
|
2303
2483
|
if (session.pendingInjectSources?.some((entry) => entry.deliveryPending)) return true
|
|
2484
|
+
// 実行中の背景コマンド(commandExecution)を保護する。**この 1 箇所**で足すことで、reap の
|
|
2485
|
+
// 3 経路(per-cwd / N-cwd LRU / idle-TTL・stuck)と総 live 上限 M のすべてに同時に効く
|
|
2486
|
+
// (経路ごとに書くと、経路が増えたときに片方だけ守られる非対称が生まれる)。
|
|
2487
|
+
// ⚠️ activeTurnId の判定より **前** に置くこと。背景コマンドが残るのは turn/completed の
|
|
2488
|
+
// 後(= activeTurnId が null に戻った後)なので、下の early return を通すと届かない。
|
|
2489
|
+
if (this._hasLiveExec(session, now)) return true
|
|
2304
2490
|
if (!session.activeTurnId) return false
|
|
2305
2491
|
return now - (session.lastActivityMs ?? now) <= this._stuckTurnTtlMs
|
|
2306
2492
|
}
|
|
@@ -2341,24 +2527,46 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2341
2527
|
}
|
|
2342
2528
|
|
|
2343
2529
|
/**
|
|
2344
|
-
*
|
|
2345
|
-
*
|
|
2346
|
-
*
|
|
2347
|
-
*
|
|
2348
|
-
*
|
|
2530
|
+
* 総 live 上限 M(warm-bounds-total.mjs)用の **CLI 非依存アダプタ**。live セッションを
|
|
2531
|
+
* `{ kind, cwd, recency, seq, protected, reap }` の共通形へ包んで返すだけ(新しい判断は持たない)。
|
|
2532
|
+
* 多端末購読は _liveSessions() で dedup 済み。
|
|
2533
|
+
* @param {number} [now]
|
|
2534
|
+
* @returns {Array<object>}
|
|
2535
|
+
*/
|
|
2536
|
+
listWarmCandidates(now = Date.now()) {
|
|
2537
|
+
return this._liveSessions().map((s) => ({
|
|
2538
|
+
kind: "codex",
|
|
2539
|
+
cwd: s.cwd,
|
|
2540
|
+
recency: s.lastActivityMs ?? 0,
|
|
2541
|
+
seq: s.seq ?? 0,
|
|
2542
|
+
protected: this._isProtected(s, now),
|
|
2543
|
+
reap: (reason) => this._reapSession(s, now, reason),
|
|
2544
|
+
}))
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
/**
|
|
2548
|
+
* 子⑤ (T06544): warm 保持を「各 cwd K 本(既定 3)× 同時 warm 保持する cwd 数 N(既定 20)」で
|
|
2549
|
+
* 有界化する。旧 `_evictWarmPoolOverflow`(マシン全体の総セッション数 LRU=5)を置換し、境界を
|
|
2550
|
+
* 「総数」ではなく「cwd 単位」で定義する。
|
|
2551
|
+
* Phase 1 (per-cwd=K): 同一 cwd に K 本を超える live session があれば、最終活性が新しい順に
|
|
2552
|
+
* 先頭 K 本を残し、それ以降の **非 protected** を reap する(K=1 なら旧挙動。総量は Phase 2 と
|
|
2553
|
+
* 総 live 上限 M(warm-bounds-total.mjs)で押さえる)。
|
|
2349
2554
|
* Phase 2 (N-cwd LRU): live session を持つ distinct cwd 数が warmCwdMax を超えたら、cwd の最終活性
|
|
2350
2555
|
* が最も古い cwd から、その cwd の非 protected session を reap して cwd 数を上限まで戻す。
|
|
2351
2556
|
* protected(healthy running turn / stuck 未満)は Phase 1/2 いずれでも reap しない(busy 不可侵)。
|
|
2352
2557
|
* 全 cwd が busy 等で上限に収められないときは一時的に超過を許容する(走行中ターンを絶対に殺さない)。
|
|
2353
2558
|
* 多端末購読は 1 session に集約されるため(_liveSessions で dedup)、purely な多端末利用では
|
|
2354
|
-
* per-cwd=
|
|
2559
|
+
* per-cwd=K は自動的に満たされ、Phase 1 は「同一 cwd に別 app-server が K 本を超えてできたとき」
|
|
2560
|
+
* だけ発火する(旧 per-cwd=1 では「2 本以上できたとき」だった)。
|
|
2355
2561
|
* genuine attach 直後と reconcile の両方から呼ぶ。
|
|
2356
2562
|
* @param {number} now
|
|
2357
2563
|
* @returns {number} reap した数
|
|
2358
2564
|
*/
|
|
2359
2565
|
_reconcileWarmBounds(now, reason = "warm-bounds") {
|
|
2360
2566
|
let evicted = 0
|
|
2361
|
-
// Phase 1: per-cwd=
|
|
2567
|
+
// Phase 1: per-cwd=K。cwd ごとに最終活性が新しい順へ並べ、先頭 K 本以外の非 protected を reap。
|
|
2568
|
+
// K はコンストラクタで正の整数へ検証済み(不正値は既定へ戻して warn 済み)。
|
|
2569
|
+
const keepPerCwd = this._warmPerCwdMax
|
|
2362
2570
|
const byCwd = new Map()
|
|
2363
2571
|
for (const s of this._liveSessions()) {
|
|
2364
2572
|
const list = byCwd.get(s.cwd)
|
|
@@ -2366,13 +2574,13 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2366
2574
|
else byCwd.set(s.cwd, [s])
|
|
2367
2575
|
}
|
|
2368
2576
|
for (const list of byCwd.values()) {
|
|
2369
|
-
if (list.length <=
|
|
2577
|
+
if (list.length <= keepPerCwd) continue
|
|
2370
2578
|
// 新しい順。recency 同値は seq(生成順)で必ず新しい方を先頭(= 残す)にする(round5 Finding 1)。
|
|
2371
2579
|
list.sort(
|
|
2372
2580
|
(a, b) =>
|
|
2373
2581
|
(b.lastActivityMs ?? 0) - (a.lastActivityMs ?? 0) || (b.seq ?? 0) - (a.seq ?? 0),
|
|
2374
2582
|
)
|
|
2375
|
-
for (let i =
|
|
2583
|
+
for (let i = keepPerCwd; i < list.length; i += 1) {
|
|
2376
2584
|
if (this._isProtected(list[i], now)) continue // busy は残す(一時的に >1 を許容)
|
|
2377
2585
|
this._reapSession(list[i], now, `${reason}:per-cwd`)
|
|
2378
2586
|
evicted += 1
|
|
@@ -2402,7 +2610,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2402
2610
|
for (const info of evictableCwds) {
|
|
2403
2611
|
if (cwdsEvicted >= overflow) break
|
|
2404
2612
|
for (const s of info.sessions) {
|
|
2405
|
-
// per-cwd
|
|
2613
|
+
// per-cwd 畳み込み後も各 cwd に**最大 K 本**残る(旧 per-cwd=1 の「1 本」ではない)ので、
|
|
2614
|
+
// cwd 内の全 session を回して非 protected だけを reap する。
|
|
2406
2615
|
if (this._isProtected(s, now)) continue
|
|
2407
2616
|
this._reapSession(s, now, `${reason}:cwd-lru`)
|
|
2408
2617
|
evicted += 1
|
|
@@ -2426,8 +2635,11 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2426
2635
|
* 1. **stuck turn**: activeTurnId 固着だが app-server 通知が STUCK_TTL 途絶えた(ハング)→ reap
|
|
2427
2636
|
* (healthy な長ターンは reasoning/output delta で touch されるため該当しない)。
|
|
2428
2637
|
* 2. **long idle**: 非 busy が idle TTL(既定 7 日)無活性 → reap(warm 保持の期限切れ)。
|
|
2429
|
-
* 3. **warm 境界**(子⑤ T06544): `_reconcileWarmBounds` で「各 cwd
|
|
2430
|
-
*
|
|
2638
|
+
* 3. **warm 境界**(子⑤ T06544): `_reconcileWarmBounds` で「各 cwd **K 本(現行の既定 3)**
|
|
2639
|
+
* × 同時 warm cwd 数 N(既定 20)」に有界化。cwd 内で K 本を超えたぶんは最終活性が古い方から
|
|
2640
|
+
* reap し、cwd 数超過は最古 cwd から reap する。
|
|
2641
|
+
* (**旧仕様**は per-cwd=1 =「同一 cwd の重複は最新 1 本へ畳む」だった。1 cwd で複数会話を
|
|
2642
|
+
* 同時に生かすため K へ一般化し、総量は総 live 上限 M(warm-bounds-total.mjs)で押さえる。)
|
|
2431
2643
|
* busy(healthy running turn)は 1〜3 いずれの対象にもならない(走行中ターンを絶対に殺さない)。
|
|
2432
2644
|
*
|
|
2433
2645
|
* 旧 `gcAbandonedSessions`(1h 無活性一律 reap)を置換。detach 済み warm セッションを 1h で
|
|
@@ -2455,7 +2667,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2455
2667
|
reaped += 1
|
|
2456
2668
|
}
|
|
2457
2669
|
}
|
|
2458
|
-
// 3: warm 境界(per-cwd=
|
|
2670
|
+
// 3: warm 境界(per-cwd=K / N-cwd LRU)を適用(stuck/idle reap 後の生存数で判定)。
|
|
2459
2671
|
reaped += this._reconcileWarmBounds(now, "warm-bounds")
|
|
2460
2672
|
return reaped
|
|
2461
2673
|
}
|
package/src/hooks.mjs
CHANGED
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
export const HOOK_NAMES = Object.freeze([
|
|
18
18
|
"onAgentStart",
|
|
19
19
|
"onAgentStop",
|
|
20
|
-
"interceptPtySpawn",
|
|
21
20
|
"interceptTmuxExec",
|
|
22
21
|
"interceptSessionCreate",
|
|
23
22
|
"interceptSessionKill",
|
|
@@ -26,21 +25,6 @@ export const HOOK_NAMES = Object.freeze([
|
|
|
26
25
|
"transformStatusDetection",
|
|
27
26
|
])
|
|
28
27
|
|
|
29
|
-
/**
|
|
30
|
-
* @typedef {Object} PtySpawnArgs
|
|
31
|
-
* @property {string} sessionName
|
|
32
|
-
* @property {number} cols
|
|
33
|
-
* @property {number} rows
|
|
34
|
-
* @property {Record<string, string>} env
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* @typedef {Object} PtySpawnDecision
|
|
39
|
-
* @property {string} command
|
|
40
|
-
* @property {string[]} args
|
|
41
|
-
* @property {Record<string, string>} [env]
|
|
42
|
-
*/
|
|
43
|
-
|
|
44
28
|
/**
|
|
45
29
|
* @typedef {Object} TmuxExecArgs
|
|
46
30
|
* @property {string[]} args
|
|
@@ -60,7 +44,6 @@ export const HOOK_NAMES = Object.freeze([
|
|
|
60
44
|
* @property {{
|
|
61
45
|
* onAgentStart?: (ctx: HookContext) => Promise<void>,
|
|
62
46
|
* onAgentStop?: (ctx: HookContext) => Promise<void>,
|
|
63
|
-
* interceptPtySpawn?: (ctx: HookContext & PtySpawnArgs) => Promise<PtySpawnDecision | null>,
|
|
64
47
|
* interceptTmuxExec?: (ctx: HookContext & TmuxExecArgs) => Promise<TmuxExecDecision | null>,
|
|
65
48
|
* }} hooks
|
|
66
49
|
*/
|