@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.
@@ -1,510 +0,0 @@
1
- /**
2
- * node-pty ベースの pty 多重化ブリッジ (Sprint D)。
3
- *
4
- * - 1 agent プロセス内で複数 stream を `Map<stream_id, PtyProcess>` で管理
5
- * - `attach()` で pty を spawn (plugin `interceptPtySpawn` が override 可)
6
- * - `write()` / `resize()` / `detach()` は stream_id で個別操作
7
- * - pty 出力は `'output'` イベント、終了は `'exit'` イベントで EventEmitter 経由
8
- *
9
- * デフォルト spawn は `tmux attach -t <session_name>` (Sprint E で tmux モジュール
10
- * が attach 前に session が存在することを保証する想定)。
11
- *
12
- * 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
13
- * 移植元: D00000_cockpit/webapp/server/ws-server.mjs
14
- */
15
- import { EventEmitter } from "node:events"
16
- import { execFileSync } from "node:child_process"
17
-
18
- import { runHookChain } from "./plugin-loader.mjs"
19
-
20
- const DEFAULT_COLS = 120
21
- const DEFAULT_ROWS = 32
22
-
23
- // pty.onData の chunk を coalesce する待ち時間 (ms)。
24
- // 0 だと chunk 1 つにつき 1 WebSocket メッセージ + 1 JSON.stringify になり、
25
- // claude TUI の連続 redraw で秒間 100+ メッセージ → Hub → channels_redis pub/sub
26
- // → ブラウザ JSON.parse が詰まって瞬断・固まりの原因になる。12ms バッファに
27
- // 溜めて 80Hz 程度に丸めるとメッセージ数が 1/5〜1/10 に減り、画面は十分滑らか
28
- // に見える (1 フレーム 16.7ms より短いので体感レイテンシは増えない)。
29
- const DEFAULT_COALESCE_MS = 12
30
-
31
- // orphan stream GC 設定。
32
- // - DEFAULT_GC_INTERVAL_MS: GC スキャン周期 (60s)。短すぎても CPU 負担、長すぎても
33
- // pty 枯渇に到達してから掃除されるまでに時間がかかる。1 分は妥当な balance。
34
- // - DEFAULT_GC_STALE_MS: stream idle 判定の閾値 (10 min)。Cockpit window が
35
- // 通常使用中は 5s 周期で session.state 等で touch されるので絶対に達しない。
36
- // リロード時の切断 (秒〜十数秒) も無傷。何時間も放置された孤児だけ掃除される。
37
- // ※ アクティブ stream が「10 分間まったく操作されない」= ユーザー席離れ等。
38
- // その場合のみ kill されるが、tmux session 自体は別 process なので作業内容
39
- // は無傷、リロードで再 attach できる。
40
- const DEFAULT_GC_INTERVAL_MS = 60_000
41
- const DEFAULT_GC_STALE_MS = 10 * 60_000
42
-
43
- // 安全弁: 同時 attach できる pty の上限本数。万一リークが残っても macOS の
44
- // `kern.tty.ptmx_max` (=511) に到達する前に最古の stream を強制 reap して枯渇を
45
- // 防ぐ (504/511 まで溜めて新規ターミナルが開けなくなった 2026-06-16 障害の再発防止)。
46
- // 0 以下で無効化 (テスト用)。
47
- const DEFAULT_MAX_STREAMS = 256
48
-
49
- function resolveBin(name) {
50
- try {
51
- return execFileSync("/usr/bin/which", [name], { encoding: "utf-8" }).trim()
52
- } catch {
53
- return name
54
- }
55
- }
56
-
57
- let _tmuxBin = null
58
- function tmuxBin() {
59
- if (_tmuxBin === null) _tmuxBin = resolveBin("tmux")
60
- return _tmuxBin
61
- }
62
-
63
- export class PtyBridge extends EventEmitter {
64
- /**
65
- * @param {object} opts
66
- * @param {object} opts.ptyModule - `await import('node-pty')` の結果 (テストで stub 注入可)
67
- * @param {import('pino').Logger} [opts.logger]
68
- * @param {Array} [opts.plugins] - plugin-loader.mjs の loadPlugins() 結果
69
- * @param {(args: {sessionName: string, cols: number, rows: number, env: object}) => {command: string, args: string[], env?: object}} [opts.defaultSpawnCommand]
70
- * - plugin が null を返したときに使うデフォルト spawn 仕様。省略時は
71
- * `/bin/sh -c "exec tmux attach -t <sessionName>"`
72
- * @param {number} [opts.coalesceMs] - pty 出力を coalesce する間隔 (ms)。0 で無効。
73
- */
74
- constructor({
75
- ptyModule,
76
- logger,
77
- plugins = [],
78
- defaultSpawnCommand,
79
- coalesceMs,
80
- /**
81
- * 孤児 stream の GC 設定。
82
- *
83
- * 背景: 旧設計では WS reconnect / Cockpit window リロード / タブ閉じ等で
84
- * `pty.detach` が agent に届かないケースがあり、pty fd が leak していた。
85
- * macOS の `kern.tty.ptmx_max` は 511 個でその上限に到達すると新規
86
- * posix_spawnp が `EAGAIN` で失敗する (= "posix_spawnp failed" の真因)。
87
- *
88
- * 対処: 各 stream に `lastSeenAt` を持たせ、`attach` / `write` / `resize`
89
- * および **pty 出力 (onData)** のたびに touch する。`gcIntervalMs` 周期で
90
- * `gcStaleMs` を超えた stream を自動 detach する (= orphan GC)。**アクティブな
91
- * stream は browser からの pty.data / pty.resize、または claude の出力で常に
92
- * touch されるため絶対に kill されない** (生成中で出力は流れるが入力が間遠い
93
- * 閲覧中の TUI チャットが reap される A-4 を出力 touch で塞ぐ)。
94
- * リロード時の短時間切断 (秒〜十数秒) も `gcStaleMs` (デフォルト 10 分)
95
- * 未満なので無傷。何時間も放置された孤児だけ掃除される。
96
- *
97
- * gcStaleMs を 0 以下にすると GC を無効化する (旧挙動)。テスト・特殊用途向け。
98
- */
99
- gcIntervalMs,
100
- gcStaleMs,
101
- /**
102
- * 同時 attach 上限 (安全弁)。超過すると新規 attach 前に最古の stream を強制
103
- * reap する。リーク回帰があっても PTY 枯渇に至らせないためのハードリミット。
104
- */
105
- maxStreams,
106
- } = {}) {
107
- super()
108
- if (!ptyModule || typeof ptyModule.spawn !== "function") {
109
- throw new TypeError("PtyBridge requires { ptyModule: { spawn } }")
110
- }
111
- this.ptyModule = ptyModule
112
- this.logger = logger
113
- this.plugins = plugins
114
- this.defaultSpawnCommand =
115
- defaultSpawnCommand ||
116
- (({ sessionName }) => ({
117
- command: "/bin/sh",
118
- args: ["-c", `exec ${tmuxBin()} attach -t ${sessionName}`],
119
- env: process.env,
120
- }))
121
- this.coalesceMs = coalesceMs ?? DEFAULT_COALESCE_MS
122
- this.maxStreams = maxStreams ?? DEFAULT_MAX_STREAMS
123
- /** @type {Map<string, import('node-pty').IPty>} */
124
- this.streams = new Map()
125
- /** @type {Map<string, {buf: string, timer: ReturnType<typeof setTimeout>|null}>} */
126
- this.coalesceState = new Map()
127
- /**
128
- * stream_id → onData/onExit の IDisposable[]。
129
- * reap 時に dispose() しないと listener が IPty を参照し続け、master fd が
130
- * close されず leak する (= ユーザー指摘「リスナ経由の参照残り」)。
131
- * @type {Map<string, Array<{dispose: () => void}>>}
132
- */
133
- this.disposables = new Map()
134
- // 可観測性: 起動からの累計 spawn / reap 数。差分 (= held) が単調増加したら leak。
135
- this._spawnedTotal = 0
136
- this._reapedTotal = 0
137
-
138
- // orphan GC 設定。デフォルトは 1 分周期で 10 分間 idle の stream を kill。
139
- this.gcIntervalMs = gcIntervalMs ?? DEFAULT_GC_INTERVAL_MS
140
- this.gcStaleMs = gcStaleMs ?? DEFAULT_GC_STALE_MS
141
- /** @type {Map<string, number>} stream_id → lastSeenAt (ms epoch) */
142
- this.lastSeenAt = new Map()
143
- /** @type {ReturnType<typeof setInterval>|null} */
144
- this._gcTimer = null
145
- if (this.gcStaleMs > 0 && this.gcIntervalMs > 0) {
146
- this._gcTimer = setInterval(() => {
147
- this._gcStaleStreams()
148
- this._logStats()
149
- }, this.gcIntervalMs)
150
- // unref で Node の event loop を止めない (test 環境で hang しないため)。
151
- if (typeof this._gcTimer.unref === "function") this._gcTimer.unref()
152
- }
153
- }
154
-
155
- /**
156
- * 現在時刻を ms epoch で返す。テストで now を差し替えるため override 可能に。
157
- * @returns {number}
158
- */
159
- _now() {
160
- return Date.now()
161
- }
162
-
163
- /**
164
- * stream_id を「最近 browser からアクセスがあった」と record する。
165
- * attach / write / resize の各 entry point から呼ぶ。
166
- * @param {string} stream_id
167
- */
168
- _touch(stream_id) {
169
- this.lastSeenAt.set(stream_id, this._now())
170
- }
171
-
172
- /**
173
- * stream の最終 touch 時刻 (ms epoch) を返す。未登録 / 未 touch は 0。
174
- * Plan ε (handleStreamsSyncResponse) の grace 判定で「直近 attach/write/resize/出力が
175
- * あった stream は browser が再 attach 中なだけ」と見なして誤殺を防ぐために使う。
176
- * @param {string} stream_id
177
- * @returns {number}
178
- */
179
- getLastSeen(stream_id) {
180
- return this.lastSeenAt.get(stream_id) ?? 0
181
- }
182
-
183
- /**
184
- * `gcStaleMs` を超えて idle な stream を一括 reap する。
185
- *
186
- * `_gcTimer` から `gcIntervalMs` 周期で呼ばれる。手動でも呼べる (テスト用)。
187
- *
188
- * ⚠️ 走査対象は `this.streams` (= 実際に fd を保持している正本)。旧実装は
189
- * `lastSeenAt` を走査していたが、`detach` が kill 前に `lastSeenAt` を消す一方で
190
- * `streams` からの削除を `onExit` 頼みにしていたため、`onExit` が取りこぼされると
191
- * 「streams に残る・lastSeenAt は無い」孤児が GC から不可視になり永久 leak した
192
- * (2026-06-16 障害の主因)。streams を正本に走査することでこの不可視孤児を根絶する。
193
- * @returns {string[]} reap した stream_id 一覧 (debug / test 用)
194
- */
195
- _gcStaleStreams() {
196
- if (!(this.gcStaleMs > 0)) return []
197
- const cutoff = this._now() - this.gcStaleMs
198
- const killed = []
199
- for (const stream_id of Array.from(this.streams.keys())) {
200
- // lastSeenAt 欠落 (= 不可視孤児) は 0 扱い = 即 stale 判定で確実に回収する。
201
- const ts = this.lastSeenAt.get(stream_id) ?? 0
202
- if (ts < cutoff) {
203
- this.logger?.warn(
204
- { stream_id, stale_ms: this._now() - ts, gc_stale_ms: this.gcStaleMs },
205
- "pty GC: orphaned stream killed",
206
- )
207
- this._reap(stream_id, { emitExit: true, code: null })
208
- killed.push(stream_id)
209
- }
210
- }
211
- return killed
212
- }
213
-
214
- /**
215
- * 1 つの stream の後始末を一元化する (= 全リーク経路の単一出口)。
216
- *
217
- * 正常終了 (onExit) / browser detach / GC / 上限超過 / shutdown の全経路がここを
218
- * 通る。冪等: 既に reap 済みなら何もしない (false を返す)。
219
- *
220
- * 順序が重要:
221
- * 1. `streams` から **最初に** 削除する。`pty.kill()` が onExit を同期発火させる
222
- * 実装 (テスト stub / 一部の node-pty 経路) で onExit ハンドラが再入しても、
223
- * その時点で stream が消えているので二重 emit / 二重カウントを防げる。
224
- * 2. 残バッファを flush (取りこぼし防止)。
225
- * 3. onData/onExit listener を dispose (参照残りによる fd 保持を断つ)。
226
- * 4. 補助 Map をクリアし、最後に `pty.kill()` で master fd を解放する。
227
- *
228
- * @param {string} stream_id
229
- * @param {{emitExit?: boolean, code?: number|null}} [opts]
230
- * @returns {boolean} 実際に reap したら true
231
- */
232
- _reap(stream_id, { emitExit = false, code = null } = {}) {
233
- const existed = this.streams.has(stream_id)
234
- const pty = this.streams.get(stream_id)
235
- // (1) streams から最初に外す → 以降の同期 onExit 再入は no-op。
236
- this.streams.delete(stream_id)
237
- // (2) 残バッファを取りこぼさない。
238
- this._flushCoalesce(stream_id)
239
- // (3) listener を dispose (node-pty の onData/onExit は IDisposable を返す)。
240
- const disps = this.disposables.get(stream_id)
241
- if (disps) {
242
- for (const d of disps) {
243
- try {
244
- d?.dispose?.()
245
- } catch {
246
- /* ignore */
247
- }
248
- }
249
- }
250
- this.disposables.delete(stream_id)
251
- this.coalesceState.delete(stream_id)
252
- this.lastSeenAt.delete(stream_id)
253
- // (4) master fd を解放。
254
- // ⚠️ `pty.kill()` は子プロセスに SIGHUP を送るだけで、master fd を握る
255
- // 内部 socket を破棄しない。macOS では子終了後に socket の 'close' が
256
- // 発火せず fd が残り続ける → これが 2026-06-16 の PTY 枯渇 (504/511) の真因。
257
- // node-pty UnixTerminal の `destroy()` は `_socket.destroy()` で fd を即時
258
- // close してから SIGHUP を送るので、こちらを使う (公開 typings に無い内部 API
259
- // のため feature-detect。Windows 等で未定義なら kill にフォールバック)。
260
- if (pty) {
261
- try {
262
- if (typeof pty.destroy === "function") pty.destroy()
263
- else pty.kill()
264
- } catch {
265
- /* already dead / socket already closed */
266
- }
267
- }
268
- if (existed) {
269
- this._reapedTotal += 1
270
- if (emitExit) this.emit("exit", { stream_id, code })
271
- }
272
- return existed
273
- }
274
-
275
- /**
276
- * 現在保持している pty 本数と累計 spawn/reap をログ出力する (可観測性)。
277
- * `leak_delta` は理論上常に 0 (held === spawned - reaped)。非 0 が続いたら異常。
278
- */
279
- _logStats() {
280
- const held = this.streams.size
281
- this.logger?.info(
282
- {
283
- held,
284
- spawned_total: this._spawnedTotal,
285
- reaped_total: this._reapedTotal,
286
- leak_delta: this._spawnedTotal - this._reapedTotal - held,
287
- max_streams: this.maxStreams,
288
- },
289
- "pty stats",
290
- )
291
- }
292
-
293
- /**
294
- * 現在の保持本数・累計カウンタを返す (テスト / debug 用)。
295
- * @returns {{held: number, spawnedTotal: number, reapedTotal: number}}
296
- */
297
- stats() {
298
- return {
299
- held: this.streams.size,
300
- spawnedTotal: this._spawnedTotal,
301
- reapedTotal: this._reapedTotal,
302
- }
303
- }
304
-
305
- /**
306
- * stream の coalesce buffer を即時 flush して emit する。
307
- * detach / exit / shutdown のタイミングで残バッファの取りこぼしを防ぐ。
308
- * @param {string} stream_id
309
- */
310
- _flushCoalesce(stream_id) {
311
- const state = this.coalesceState.get(stream_id)
312
- if (!state) return
313
- if (state.timer) {
314
- clearTimeout(state.timer)
315
- state.timer = null
316
- }
317
- if (state.buf) {
318
- const out = state.buf
319
- state.buf = ""
320
- this.emit("output", { stream_id, data: out })
321
- }
322
- }
323
-
324
- /**
325
- * 新しい pty を spawn して stream_id に紐付ける。
326
- *
327
- * @param {{stream_id: string, sessionName?: string, cols?: number, rows?: number, env?: object}} args
328
- * @returns {Promise<{plugin: string|null, command: string, args: string[]}>}
329
- */
330
- async attach({ stream_id, sessionName = "", cols = DEFAULT_COLS, rows = DEFAULT_ROWS, env }) {
331
- if (!stream_id) throw new TypeError("attach requires stream_id")
332
- if (this.streams.has(stream_id)) {
333
- throw new Error(`stream_id "${stream_id}" は既に attach 済みです`)
334
- }
335
- // 安全弁: 上限到達時は最古 (lastSeenAt 最小) の stream を強制 reap してから
336
- // spawn する。リークが残っても PTY 枯渇に至らせないハードリミット。
337
- if (this.maxStreams > 0 && this.streams.size >= this.maxStreams) {
338
- let oldestId = null
339
- let oldestTs = Infinity
340
- for (const sid of this.streams.keys()) {
341
- const ts = this.lastSeenAt.get(sid) ?? 0
342
- if (ts < oldestTs) {
343
- oldestTs = ts
344
- oldestId = sid
345
- }
346
- }
347
- if (oldestId) {
348
- this.logger?.warn(
349
- { stream_id, evicted: oldestId, held: this.streams.size, max_streams: this.maxStreams },
350
- "pty cap exceeded: reaping oldest stream",
351
- )
352
- this._reap(oldestId, { emitExit: true, code: null })
353
- }
354
- }
355
- const ctx = {
356
- logger: this.logger,
357
- sessionName,
358
- cols,
359
- rows,
360
- env: env || process.env,
361
- }
362
-
363
- const hookResult = await runHookChain(this.plugins, "interceptPtySpawn", ctx)
364
- const spec = hookResult?.result ?? this.defaultSpawnCommand(ctx)
365
-
366
- let pty
367
- try {
368
- pty = this.ptyModule.spawn(spec.command, spec.args, {
369
- name: "xterm-256color",
370
- cols,
371
- rows,
372
- cwd: spec.env?.HOME || process.env.HOME,
373
- env: spec.env || process.env,
374
- })
375
- } catch (err) {
376
- this.logger?.error(
377
- { stream_id, command: spec.command, err: err.message },
378
- "pty spawn failed",
379
- )
380
- throw err
381
- }
382
-
383
- this.streams.set(stream_id, pty)
384
- this._spawnedTotal += 1
385
- this._touch(stream_id)
386
- this.logger?.info(
387
- { stream_id, sessionName, command: spec.command, plugin: hookResult?.plugin || null },
388
- "pty attached",
389
- )
390
-
391
- // onData/onExit は IDisposable を返す。reap 時に dispose() するため保持する。
392
- const disposables = []
393
- if (this.coalesceMs > 0) {
394
- const state = { buf: "", timer: null }
395
- this.coalesceState.set(stream_id, state)
396
- disposables.push(
397
- pty.onData((data) => {
398
- // 出力でも lastSeenAt を touch する (A-4 修正)。orphan GC は lastSeenAt が
399
- // gcStaleMs 古い stream を reap するが、従来は write/resize/attach でしか
400
- // touch していなかったため「生成中で出力は流れているが入力が間遠い閲覧中の
401
- // TUI チャット」が 10 分で reap され、以後の送信が stream_missing で落ちていた
402
- // (本番 agent.log で reaped_total 多数 + 同 input_id の再送 dedup を観測)。
403
- // 出力が流れている = まだ生きて使われている stream なので touch して延命する。
404
- // 真にブラウザが去った stream は claude がターン完了後に redraw を止めて出力が
405
- // 止まり、gcStaleMs 経過で reap される。万一の runaway 出力 orphan は maxStreams
406
- // 上限の最古 reap が backstop になる。
407
- this._touch(stream_id)
408
- state.buf += data
409
- if (!state.timer) {
410
- state.timer = setTimeout(() => {
411
- state.timer = null
412
- const out = state.buf
413
- if (!out) return
414
- state.buf = ""
415
- this.emit("output", { stream_id, data: out })
416
- }, this.coalesceMs)
417
- }
418
- }),
419
- )
420
- } else {
421
- disposables.push(
422
- pty.onData((data) => {
423
- // 出力でも touch (A-4 修正)。理由は上の coalesce 経路のコメント参照。
424
- this._touch(stream_id)
425
- this.emit("output", { stream_id, data })
426
- }),
427
- )
428
- }
429
- // 正常終了経路。全後始末を _reap に一元化 (flush → dispose → fd 解放 → exit emit)。
430
- disposables.push(
431
- pty.onExit(({ exitCode }) => {
432
- this._reap(stream_id, { emitExit: true, code: exitCode })
433
- }),
434
- )
435
- this.disposables.set(stream_id, disposables)
436
-
437
- return {
438
- plugin: hookResult?.plugin || null,
439
- command: spec.command,
440
- args: spec.args,
441
- }
442
- }
443
-
444
- /** ブラウザから来た入力を pty に流す。 */
445
- write({ stream_id, data }) {
446
- const pty = this.streams.get(stream_id)
447
- if (!pty) {
448
- this.logger?.warn({ stream_id }, "pty.write but stream missing")
449
- return false
450
- }
451
- this._touch(stream_id)
452
- pty.write(data)
453
- return true
454
- }
455
-
456
- /** ターミナルサイズ変更。 */
457
- resize({ stream_id, cols, rows }) {
458
- const pty = this.streams.get(stream_id)
459
- if (!pty) {
460
- this.logger?.warn({ stream_id }, "pty.resize but stream missing")
461
- return false
462
- }
463
- this._touch(stream_id)
464
- try {
465
- pty.resize(cols, rows)
466
- return true
467
- } catch (err) {
468
- this.logger?.warn({ stream_id, err: err.message }, "pty.resize failed")
469
- return false
470
- }
471
- }
472
-
473
- /** pty を kill する。tmux session 自体は残る (detach 相当)。 */
474
- detach({ stream_id }) {
475
- const pty = this.streams.get(stream_id)
476
- if (!pty) return false
477
- // kill 前に残バッファを emit しておく (detach は browser 側都合なので最新を
478
- // 確実に届けたい)。
479
- this._flushCoalesce(stream_id)
480
- try {
481
- pty.kill()
482
- } catch {
483
- /* ignore */
484
- }
485
- // kill が onExit を同期発火する実装 (stub 等) では上の kill 内で _reap 済み。
486
- // 実 node-pty は onExit が非同期なので、onExit を待たず即 reap して master fd を
487
- // 解放する (= onExit 取りこぼし時の永久 leak を防ぐ核心)。_reap は冪等なので、
488
- // 後から非同期 onExit が来ても no-op になる。
489
- if (this.streams.has(stream_id)) {
490
- this._reap(stream_id, { emitExit: true, code: null })
491
- }
492
- return true
493
- }
494
-
495
- /** 全 pty を即時 kill (agent shutdown 用)。 */
496
- shutdown() {
497
- if (this._gcTimer) {
498
- clearInterval(this._gcTimer)
499
- this._gcTimer = null
500
- }
501
- for (const stream_id of Array.from(this.streams.keys())) {
502
- this._reap(stream_id, { emitExit: true, code: null })
503
- }
504
- }
505
-
506
- /** 現在 attach 中の stream_id 一覧 (debug 用)。 */
507
- list() {
508
- return Array.from(this.streams.keys())
509
- }
510
- }