@cocorograph/hub-agent 0.6.85 → 0.6.87

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 CHANGED
@@ -86,7 +86,7 @@ hub-agent/
86
86
  │ ├── config.mjs # ~/.hub/agent.json 管理
87
87
  │ ├── enroll.mjs # enrollment フロー
88
88
  │ ├── ws-client.mjs # outbound WSS + reconnect with jitter
89
- │ ├── pty-bridge.mjs # node-pty 多重化
89
+ │ ├── pty-bridge.mjs # @lydell/node-pty 多重化 (fd leak 回避フォーク)
90
90
  │ ├── tmux.mjs # tmux exec/list/create/kill
91
91
  │ ├── state.mjs # session status / context_pct 検知
92
92
  │ ├── skills.mjs # ~/.claude/skills 集計
@@ -98,7 +98,7 @@ hub-agent/
98
98
  │ ├── co.cocorograph.hub-agent.plist
99
99
  │ └── hub-agent.service
100
100
  ├── scripts/
101
- │ └── fix-node-pty-perms.mjs # node-pty spawn-helper +x 修復 (postinstall)
101
+ │ └── fix-node-pty-perms.mjs # @lydell/node-pty spawn-helper +x 修復 (postinstall)
102
102
  └── test/
103
103
  ```
104
104
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.6.85",
3
+ "version": "0.6.87",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -34,8 +34,8 @@
34
34
  ],
35
35
  "dependencies": {
36
36
  "@anthropic-ai/claude-agent-sdk": "^0.3.158",
37
+ "@lydell/node-pty": "1.2.0-beta.12",
37
38
  "commander": "^12.1.0",
38
- "node-pty": "^1.0.0",
39
39
  "pino": "^9.0.0",
40
40
  "ws": "^8.18.0"
41
41
  },
@@ -1,12 +1,17 @@
1
1
  #!/usr/bin/env node
2
- // pnpm 経由インストールだと node-pty/prebuilds/*/spawn-helper の実行権限が
3
- // 剥がれて posix_spawnp が失敗する事象への workaround.
4
- // postinstall で確実に +x を付与する。
2
+ // pnpm 経由インストールだと prebuilds/*/spawn-helper の実行権限が剥がれて
3
+ // posix_spawnp が失敗する事象への workaround. postinstall で確実に +x を付与する。
4
+ //
5
+ // PTY backend は @lydell/node-pty (Microsoft 公式の fd-leak 修正フォーク) に移行済み。
6
+ // prebuild は @lydell/node-pty-<platform>/prebuilds/<platform>/spawn-helper に入る
7
+ // (npm install では +x 付きで展開されるが、pnpm 等で剥がれるケースに備えて修復する)。
8
+ // 旧 node-pty の配置も後方互換で走査する。
5
9
  import { readdirSync, statSync, chmodSync } from "node:fs";
6
10
  import { join } from "node:path";
7
11
 
8
12
  const PNPM_GLOB = "node_modules/.pnpm";
9
- const FALLBACK = "node_modules/node-pty/prebuilds";
13
+ // prebuilds ディレクトリの候補 (node-pty 旧配置 + @lydell platform パッケージ)。
14
+ const FALLBACK_DIRS = ["node_modules/node-pty/prebuilds"];
10
15
 
11
16
  function fixDir(dir) {
12
17
  let count = 0;
@@ -26,12 +31,39 @@ function fixDir(dir) {
26
31
  return count;
27
32
  }
28
33
 
34
+ // @lydell の platform パッケージ (@lydell/node-pty-darwin-arm64 等) を走査する。
35
+ function findLydellPrebuilds() {
36
+ const out = [];
37
+ const scopeDir = "node_modules/@lydell";
38
+ try {
39
+ for (const entry of readdirSync(scopeDir)) {
40
+ if (!entry.startsWith("node-pty-")) continue; // platform バイナリパッケージのみ
41
+ const p = join(scopeDir, entry, "prebuilds");
42
+ try {
43
+ statSync(p);
44
+ out.push(p);
45
+ } catch { /* prebuilds 無し */ }
46
+ }
47
+ } catch { /* @lydell scope 無し */ }
48
+ return out;
49
+ }
50
+
29
51
  function findPnpmPrebuilds() {
30
52
  const out = [];
31
53
  try {
32
54
  for (const entry of readdirSync(PNPM_GLOB)) {
33
- if (!entry.startsWith("node-pty@")) continue;
34
- const p = join(PNPM_GLOB, entry, "node_modules/node-pty/prebuilds");
55
+ let inner = null;
56
+ if (entry.startsWith("node-pty@")) {
57
+ // 旧 node-pty: node_modules/.pnpm/node-pty@x/node_modules/node-pty/prebuilds
58
+ inner = "node_modules/node-pty/prebuilds";
59
+ } else if (entry.startsWith("@lydell+node-pty-")) {
60
+ // @lydell platform: @lydell+node-pty-darwin-arm64@x → pkg 名 = node-pty-darwin-arm64
61
+ const pkg = entry.slice("@lydell+".length).split("@")[0];
62
+ inner = join("node_modules/@lydell", pkg, "prebuilds");
63
+ } else {
64
+ continue;
65
+ }
66
+ const p = join(PNPM_GLOB, entry, inner);
35
67
  try {
36
68
  statSync(p);
37
69
  out.push(p);
@@ -42,13 +74,15 @@ function findPnpmPrebuilds() {
42
74
  }
43
75
 
44
76
  let fixed = 0;
45
- for (const dir of findPnpmPrebuilds()) {
77
+ for (const dir of [...findPnpmPrebuilds(), ...findLydellPrebuilds()]) {
46
78
  fixed += fixDir(dir);
47
79
  }
48
- try {
49
- statSync(FALLBACK);
50
- fixed += fixDir(FALLBACK);
51
- } catch { /* not hoisted */ }
80
+ for (const fallback of FALLBACK_DIRS) {
81
+ try {
82
+ statSync(fallback);
83
+ fixed += fixDir(fallback);
84
+ } catch { /* not hoisted */ }
85
+ }
52
86
 
53
87
  if (fixed === 0) {
54
88
  // 何も修正しなかった = 既に OK or node-pty 未インストール。サイレント終了。
@@ -512,10 +512,12 @@ _install_node_lts_linux() {
512
512
  color_ok "node $(node --version 2>/dev/null || echo '?') を導入"
513
513
  }
514
514
 
515
- # ネイティブアドオン (node-pty 等) のビルドに必要な make / g++ / python3 を導入する。
516
- # node-pty prebuild が無いと node-gyp rebuild にフォールバックするが、クリーンな
517
- # Ubuntu には build-essential が無く `gyp ERR! not found: make` で失敗する
518
- # (WSL クリーン Ubuntu で発覚)。macOS Xcode CLT 前提なので何もしない。
515
+ # ネイティブアドオンのビルドに必要な make / g++ / python3 を導入する。
516
+ # PTY backend は @lydell/node-pty に移行済みで全 OS prebuild を同梱するため通常は
517
+ # native ビルド不要だが、prebuild が当たらない環境では node-gyp rebuild
518
+ # フォールバックする。クリーンな Ubuntu には build-essential が無く
519
+ # `gyp ERR! not found: make` で失敗する (WSL クリーン Ubuntu で発覚) ため保険で導入する。
520
+ # macOS は Xcode CLT 前提なので何もしない。
519
521
  ensure_build_tools() {
520
522
  [[ "$(uname)" == "Darwin" ]] && return 0
521
523
  if present make && present g++; then
package/src/main.mjs CHANGED
@@ -383,7 +383,12 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
383
383
 
384
384
  await runHookBroadcast(plugins, "onAgentStart", ctx)
385
385
 
386
- const resolvedPty = ptyModule || (await import("node-pty"))
386
+ // node-pty (Microsoft 公式) macOS arm64 で PTY master fd を spawn ごとに leak し
387
+ // (kill/destroy でも解放されず)、kern.tty.ptmx_max=511 到達で「新規ターミナルが
388
+ // 開けない」障害を起こした (2026-06-16)。node 20/22/24 全てで再現。API 互換の保守
389
+ // フォーク @lydell/node-pty は同ケースで leak ゼロ + 全 OS prebuilt 同梱なので
390
+ // 差し替えた。WHY 詳細: Hub log/2026-06-16/0041_cockpit。
391
+ const resolvedPty = ptyModule || (await import("@lydell/node-pty"))
387
392
  const ptyBridge = new PtyBridge({ ptyModule: resolvedPty, logger, plugins })
388
393
 
389
394
  // Claude SDK は optional dep 扱い (未インストール時は stream モードのみ無効化)。
@@ -1132,6 +1137,20 @@ export function handleTrackedPtyData(msg, ctx) {
1132
1137
  }
1133
1138
  }
1134
1139
 
1140
+ /**
1141
+ * 無印 (input_id 無し / fire-and-forget) pty.data の処理。raw 打鍵 (term.onData) が
1142
+ * これを通る。stream 不在 (reap 済み等) なら pty.error を返して browser を再 attach →
1143
+ * 自己修復させる (T-6)。従来は write の戻り値を見ずに捨てていたため、reap 済み stream
1144
+ * への打鍵が無言ドロップされていた。tracked 経路 (handleTrackedPtyData) と対称。
1145
+ * テストから直接呼べるよう dispatch から切り出して export する。
1146
+ */
1147
+ export function handleUntrackedPtyData(msg, ctx) {
1148
+ const { stream_id } = msg
1149
+ if (!ctx.ptyBridge.write({ stream_id, data: msg.data })) {
1150
+ ctx.client.send({ type: "pty.error", stream_id, error: "stream_missing" })
1151
+ }
1152
+ }
1153
+
1135
1154
  async function dispatch(msg, ctx) {
1136
1155
  const t = msg?.type || ""
1137
1156
  try {
@@ -1198,7 +1217,7 @@ async function dispatch(msg, ctx) {
1198
1217
  handleTrackedPtyData(msg, ctx)
1199
1218
  return
1200
1219
  }
1201
- ctx.ptyBridge.write({ stream_id: msg.stream_id, data: msg.data })
1220
+ handleUntrackedPtyData(msg, ctx)
1202
1221
  return
1203
1222
  case "pty.resize":
1204
1223
  ctx.ptyBridge.resize({
@@ -40,6 +40,12 @@ const DEFAULT_COALESCE_MS = 12
40
40
  const DEFAULT_GC_INTERVAL_MS = 60_000
41
41
  const DEFAULT_GC_STALE_MS = 10 * 60_000
42
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
+
43
49
  function resolveBin(name) {
44
50
  try {
45
51
  return execFileSync("/usr/bin/which", [name], { encoding: "utf-8" }).trim()
@@ -80,9 +86,11 @@ export class PtyBridge extends EventEmitter {
80
86
  * posix_spawnp が `EAGAIN` で失敗する (= "posix_spawnp failed" の真因)。
81
87
  *
82
88
  * 対処: 各 stream に `lastSeenAt` を持たせ、`attach` / `write` / `resize`
83
- * のたびに touch する。`gcIntervalMs` 周期で `gcStaleMs` を超えた stream を
84
- * 自動 detach する (= orphan GC)。**アクティブな stream は browser からの
85
- * pty.data / pty.resize で常に touch されるため絶対に kill されない**。
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 で塞ぐ)。
86
94
  * リロード時の短時間切断 (秒〜十数秒) も `gcStaleMs` (デフォルト 10 分)
87
95
  * 未満なので無傷。何時間も放置された孤児だけ掃除される。
88
96
  *
@@ -90,6 +98,11 @@ export class PtyBridge extends EventEmitter {
90
98
  */
91
99
  gcIntervalMs,
92
100
  gcStaleMs,
101
+ /**
102
+ * 同時 attach 上限 (安全弁)。超過すると新規 attach 前に最古の stream を強制
103
+ * reap する。リーク回帰があっても PTY 枯渇に至らせないためのハードリミット。
104
+ */
105
+ maxStreams,
93
106
  } = {}) {
94
107
  super()
95
108
  if (!ptyModule || typeof ptyModule.spawn !== "function") {
@@ -106,10 +119,21 @@ export class PtyBridge extends EventEmitter {
106
119
  env: process.env,
107
120
  }))
108
121
  this.coalesceMs = coalesceMs ?? DEFAULT_COALESCE_MS
122
+ this.maxStreams = maxStreams ?? DEFAULT_MAX_STREAMS
109
123
  /** @type {Map<string, import('node-pty').IPty>} */
110
124
  this.streams = new Map()
111
125
  /** @type {Map<string, {buf: string, timer: ReturnType<typeof setTimeout>|null}>} */
112
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
113
137
 
114
138
  // orphan GC 設定。デフォルトは 1 分周期で 10 分間 idle の stream を kill。
115
139
  this.gcIntervalMs = gcIntervalMs ?? DEFAULT_GC_INTERVAL_MS
@@ -119,7 +143,10 @@ export class PtyBridge extends EventEmitter {
119
143
  /** @type {ReturnType<typeof setInterval>|null} */
120
144
  this._gcTimer = null
121
145
  if (this.gcStaleMs > 0 && this.gcIntervalMs > 0) {
122
- this._gcTimer = setInterval(() => this._gcStaleStreams(), this.gcIntervalMs)
146
+ this._gcTimer = setInterval(() => {
147
+ this._gcStaleStreams()
148
+ this._logStats()
149
+ }, this.gcIntervalMs)
123
150
  // unref で Node の event loop を止めない (test 環境で hang しないため)。
124
151
  if (typeof this._gcTimer.unref === "function") this._gcTimer.unref()
125
152
  }
@@ -143,28 +170,127 @@ export class PtyBridge extends EventEmitter {
143
170
  }
144
171
 
145
172
  /**
146
- * `gcStaleMs` を超えて idle な stream を一括 detach する。
173
+ * `gcStaleMs` を超えて idle な stream を一括 reap する。
147
174
  *
148
175
  * `_gcTimer` から `gcIntervalMs` 周期で呼ばれる。手動でも呼べる (テスト用)。
149
- * @returns {string[]} kill した stream_id 一覧 (debug / test 用)
176
+ *
177
+ * ⚠️ 走査対象は `this.streams` (= 実際に fd を保持している正本)。旧実装は
178
+ * `lastSeenAt` を走査していたが、`detach` が kill 前に `lastSeenAt` を消す一方で
179
+ * `streams` からの削除を `onExit` 頼みにしていたため、`onExit` が取りこぼされると
180
+ * 「streams に残る・lastSeenAt は無い」孤児が GC から不可視になり永久 leak した
181
+ * (2026-06-16 障害の主因)。streams を正本に走査することでこの不可視孤児を根絶する。
182
+ * @returns {string[]} reap した stream_id 一覧 (debug / test 用)
150
183
  */
151
184
  _gcStaleStreams() {
152
185
  if (!(this.gcStaleMs > 0)) return []
153
186
  const cutoff = this._now() - this.gcStaleMs
154
187
  const killed = []
155
- for (const [stream_id, ts] of this.lastSeenAt) {
156
- if (ts < cutoff && this.streams.has(stream_id)) {
188
+ for (const stream_id of Array.from(this.streams.keys())) {
189
+ // lastSeenAt 欠落 (= 不可視孤児) 0 扱い = 即 stale 判定で確実に回収する。
190
+ const ts = this.lastSeenAt.get(stream_id) ?? 0
191
+ if (ts < cutoff) {
157
192
  this.logger?.warn(
158
193
  { stream_id, stale_ms: this._now() - ts, gc_stale_ms: this.gcStaleMs },
159
194
  "pty GC: orphaned stream killed",
160
195
  )
161
- this.detach({ stream_id })
196
+ this._reap(stream_id, { emitExit: true, code: null })
162
197
  killed.push(stream_id)
163
198
  }
164
199
  }
165
200
  return killed
166
201
  }
167
202
 
203
+ /**
204
+ * 1 つの stream の後始末を一元化する (= 全リーク経路の単一出口)。
205
+ *
206
+ * 正常終了 (onExit) / browser detach / GC / 上限超過 / shutdown の全経路がここを
207
+ * 通る。冪等: 既に reap 済みなら何もしない (false を返す)。
208
+ *
209
+ * 順序が重要:
210
+ * 1. `streams` から **最初に** 削除する。`pty.kill()` が onExit を同期発火させる
211
+ * 実装 (テスト stub / 一部の node-pty 経路) で onExit ハンドラが再入しても、
212
+ * その時点で stream が消えているので二重 emit / 二重カウントを防げる。
213
+ * 2. 残バッファを flush (取りこぼし防止)。
214
+ * 3. onData/onExit listener を dispose (参照残りによる fd 保持を断つ)。
215
+ * 4. 補助 Map をクリアし、最後に `pty.kill()` で master fd を解放する。
216
+ *
217
+ * @param {string} stream_id
218
+ * @param {{emitExit?: boolean, code?: number|null}} [opts]
219
+ * @returns {boolean} 実際に reap したら true
220
+ */
221
+ _reap(stream_id, { emitExit = false, code = null } = {}) {
222
+ const existed = this.streams.has(stream_id)
223
+ const pty = this.streams.get(stream_id)
224
+ // (1) streams から最初に外す → 以降の同期 onExit 再入は no-op。
225
+ this.streams.delete(stream_id)
226
+ // (2) 残バッファを取りこぼさない。
227
+ this._flushCoalesce(stream_id)
228
+ // (3) listener を dispose (node-pty の onData/onExit は IDisposable を返す)。
229
+ const disps = this.disposables.get(stream_id)
230
+ if (disps) {
231
+ for (const d of disps) {
232
+ try {
233
+ d?.dispose?.()
234
+ } catch {
235
+ /* ignore */
236
+ }
237
+ }
238
+ }
239
+ this.disposables.delete(stream_id)
240
+ this.coalesceState.delete(stream_id)
241
+ this.lastSeenAt.delete(stream_id)
242
+ // (4) master fd を解放。
243
+ // ⚠️ `pty.kill()` は子プロセスに SIGHUP を送るだけで、master fd を握る
244
+ // 内部 socket を破棄しない。macOS では子終了後に socket の 'close' が
245
+ // 発火せず fd が残り続ける → これが 2026-06-16 の PTY 枯渇 (504/511) の真因。
246
+ // node-pty UnixTerminal の `destroy()` は `_socket.destroy()` で fd を即時
247
+ // close してから SIGHUP を送るので、こちらを使う (公開 typings に無い内部 API
248
+ // のため feature-detect。Windows 等で未定義なら kill にフォールバック)。
249
+ if (pty) {
250
+ try {
251
+ if (typeof pty.destroy === "function") pty.destroy()
252
+ else pty.kill()
253
+ } catch {
254
+ /* already dead / socket already closed */
255
+ }
256
+ }
257
+ if (existed) {
258
+ this._reapedTotal += 1
259
+ if (emitExit) this.emit("exit", { stream_id, code })
260
+ }
261
+ return existed
262
+ }
263
+
264
+ /**
265
+ * 現在保持している pty 本数と累計 spawn/reap をログ出力する (可観測性)。
266
+ * `leak_delta` は理論上常に 0 (held === spawned - reaped)。非 0 が続いたら異常。
267
+ */
268
+ _logStats() {
269
+ const held = this.streams.size
270
+ this.logger?.info(
271
+ {
272
+ held,
273
+ spawned_total: this._spawnedTotal,
274
+ reaped_total: this._reapedTotal,
275
+ leak_delta: this._spawnedTotal - this._reapedTotal - held,
276
+ max_streams: this.maxStreams,
277
+ },
278
+ "pty stats",
279
+ )
280
+ }
281
+
282
+ /**
283
+ * 現在の保持本数・累計カウンタを返す (テスト / debug 用)。
284
+ * @returns {{held: number, spawnedTotal: number, reapedTotal: number}}
285
+ */
286
+ stats() {
287
+ return {
288
+ held: this.streams.size,
289
+ spawnedTotal: this._spawnedTotal,
290
+ reapedTotal: this._reapedTotal,
291
+ }
292
+ }
293
+
168
294
  /**
169
295
  * stream の coalesce buffer を即時 flush して emit する。
170
296
  * detach / exit / shutdown のタイミングで残バッファの取りこぼしを防ぐ。
@@ -195,6 +321,26 @@ export class PtyBridge extends EventEmitter {
195
321
  if (this.streams.has(stream_id)) {
196
322
  throw new Error(`stream_id "${stream_id}" は既に attach 済みです`)
197
323
  }
324
+ // 安全弁: 上限到達時は最古 (lastSeenAt 最小) の stream を強制 reap してから
325
+ // spawn する。リークが残っても PTY 枯渇に至らせないハードリミット。
326
+ if (this.maxStreams > 0 && this.streams.size >= this.maxStreams) {
327
+ let oldestId = null
328
+ let oldestTs = Infinity
329
+ for (const sid of this.streams.keys()) {
330
+ const ts = this.lastSeenAt.get(sid) ?? 0
331
+ if (ts < oldestTs) {
332
+ oldestTs = ts
333
+ oldestId = sid
334
+ }
335
+ }
336
+ if (oldestId) {
337
+ this.logger?.warn(
338
+ { stream_id, evicted: oldestId, held: this.streams.size, max_streams: this.maxStreams },
339
+ "pty cap exceeded: reaping oldest stream",
340
+ )
341
+ this._reap(oldestId, { emitExit: true, code: null })
342
+ }
343
+ }
198
344
  const ctx = {
199
345
  logger: this.logger,
200
346
  sessionName,
@@ -224,40 +370,58 @@ export class PtyBridge extends EventEmitter {
224
370
  }
225
371
 
226
372
  this.streams.set(stream_id, pty)
373
+ this._spawnedTotal += 1
227
374
  this._touch(stream_id)
228
375
  this.logger?.info(
229
376
  { stream_id, sessionName, command: spec.command, plugin: hookResult?.plugin || null },
230
377
  "pty attached",
231
378
  )
232
379
 
380
+ // onData/onExit は IDisposable を返す。reap 時に dispose() するため保持する。
381
+ const disposables = []
233
382
  if (this.coalesceMs > 0) {
234
383
  const state = { buf: "", timer: null }
235
384
  this.coalesceState.set(stream_id, state)
236
- pty.onData((data) => {
237
- state.buf += data
238
- if (!state.timer) {
239
- state.timer = setTimeout(() => {
240
- state.timer = null
241
- const out = state.buf
242
- if (!out) return
243
- state.buf = ""
244
- this.emit("output", { stream_id, data: out })
245
- }, this.coalesceMs)
246
- }
247
- })
385
+ disposables.push(
386
+ pty.onData((data) => {
387
+ // 出力でも lastSeenAt を touch する (A-4 修正)。orphan GC は lastSeenAt が
388
+ // gcStaleMs 古い stream を reap するが、従来は write/resize/attach でしか
389
+ // touch していなかったため「生成中で出力は流れているが入力が間遠い閲覧中の
390
+ // TUI チャット」が 10 分で reap され、以後の送信が stream_missing で落ちていた
391
+ // (本番 agent.log で reaped_total 多数 + 同 input_id の再送 dedup を観測)
392
+ // 出力が流れている = まだ生きて使われている stream なので touch して延命する。
393
+ // 真にブラウザが去った stream claude がターン完了後に redraw を止めて出力が
394
+ // 止まり、gcStaleMs 経過で reap される。万一の runaway 出力 orphan は maxStreams
395
+ // 上限の最古 reap が backstop になる。
396
+ this._touch(stream_id)
397
+ state.buf += data
398
+ if (!state.timer) {
399
+ state.timer = setTimeout(() => {
400
+ state.timer = null
401
+ const out = state.buf
402
+ if (!out) return
403
+ state.buf = ""
404
+ this.emit("output", { stream_id, data: out })
405
+ }, this.coalesceMs)
406
+ }
407
+ }),
408
+ )
248
409
  } else {
249
- pty.onData((data) => {
250
- this.emit("output", { stream_id, data })
251
- })
410
+ disposables.push(
411
+ pty.onData((data) => {
412
+ // 出力でも touch (A-4 修正)。理由は上の coalesce 経路のコメント参照。
413
+ this._touch(stream_id)
414
+ this.emit("output", { stream_id, data })
415
+ }),
416
+ )
252
417
  }
253
- pty.onExit(({ exitCode }) => {
254
- // 残バッファを取りこぼさないよう flush してから exit を emit する。
255
- this._flushCoalesce(stream_id)
256
- this.coalesceState.delete(stream_id)
257
- this.streams.delete(stream_id)
258
- this.lastSeenAt.delete(stream_id)
259
- this.emit("exit", { stream_id, code: exitCode })
260
- })
418
+ // 正常終了経路。全後始末を _reap に一元化 (flush dispose fd 解放 → exit emit)。
419
+ disposables.push(
420
+ pty.onExit(({ exitCode }) => {
421
+ this._reap(stream_id, { emitExit: true, code: exitCode })
422
+ }),
423
+ )
424
+ this.disposables.set(stream_id, disposables)
261
425
 
262
426
  return {
263
427
  plugin: hookResult?.plugin || null,
@@ -299,16 +463,20 @@ export class PtyBridge extends EventEmitter {
299
463
  detach({ stream_id }) {
300
464
  const pty = this.streams.get(stream_id)
301
465
  if (!pty) return false
302
- // kill 前に残バッファを emit しておく (onExit にも flush はあるが、
303
- // detach は browser 側都合なので最新を確実に届けたい)。
466
+ // kill 前に残バッファを emit しておく (detach browser 側都合なので最新を
467
+ // 確実に届けたい)。
304
468
  this._flushCoalesce(stream_id)
305
- // GC tracking もここでクリア。pty.onExit でも消すが、onExit が来る前に
306
- // 同じ stream_id が再 attach されると古い lastSeenAt が残るリスクを避ける。
307
- this.lastSeenAt.delete(stream_id)
308
469
  try {
309
470
  pty.kill()
310
471
  } catch {
311
- /* ignore — onExit で Map から消える */
472
+ /* ignore */
473
+ }
474
+ // kill が onExit を同期発火する実装 (stub 等) では上の kill 内で _reap 済み。
475
+ // 実 node-pty は onExit が非同期なので、onExit を待たず即 reap して master fd を
476
+ // 解放する (= onExit 取りこぼし時の永久 leak を防ぐ核心)。_reap は冪等なので、
477
+ // 後から非同期 onExit が来ても no-op になる。
478
+ if (this.streams.has(stream_id)) {
479
+ this._reap(stream_id, { emitExit: true, code: null })
312
480
  }
313
481
  return true
314
482
  }
@@ -320,7 +488,7 @@ export class PtyBridge extends EventEmitter {
320
488
  this._gcTimer = null
321
489
  }
322
490
  for (const stream_id of Array.from(this.streams.keys())) {
323
- this.detach({ stream_id })
491
+ this._reap(stream_id, { emitExit: true, code: null })
324
492
  }
325
493
  }
326
494
 
package/src/ws-client.mjs CHANGED
@@ -58,6 +58,31 @@ const PTY_BUFFER_MAX_FRAMES = 500
58
58
  // 30 秒以上経過した pty.data は flush 時に破棄する。
59
59
  const PTY_BUFFER_MAX_AGE_MS = 30_000
60
60
 
61
+ // agent→browser の制御/イベント系メッセージのうち、WS not OPEN 中に捨てると
62
+ // Cockpit の挙動が壊れるものを reconnect 越しに保持する (機構③: 本番 agent.log で
63
+ // `claude.jsonl.event ... "ws send skipped (not open)"` のドロップを観測)。
64
+ // - pty.input.ack: 喪失すると browser の pendingAcks が永久滞留し「送信待ち(未配信)」が
65
+ // 消えない (S1/S3)。再接続 flush で遅れて届けば即解消する。
66
+ // - claude.jsonl.event / claude.event / session.event: 喪失すると新規セッションの
67
+ // 楽観バブルが本物へ昇格できず「生成は始まったがバブルが出ない」(S5)。
68
+ // - pty.exit / pty.ready / pty.error: stream ライフサイクルの節目。落とすと browser の
69
+ // 再 attach/復帰判断が狂う。
70
+ // pty.data とは別バッファにするのは、高頻度な pty.data の洪水で低頻度な ack/event が
71
+ // リング evict されるのを防ぐため (pty.data は冪等で最新フレームだけ届けば良いが、
72
+ // ack/event は 1 件ずつ意味を持つ)。
73
+ const BUFFERED_CTRL_TYPES = new Set([
74
+ "pty.input.ack",
75
+ "pty.exit",
76
+ "pty.ready",
77
+ "pty.error",
78
+ "claude.jsonl.event",
79
+ "claude.event",
80
+ "session.event",
81
+ ])
82
+ // 制御/イベントバッファの上限。低頻度なので frames は小さめ、age は pty と揃える。
83
+ const CTRL_BUFFER_MAX_FRAMES = 200
84
+ const CTRL_BUFFER_MAX_AGE_MS = 30_000
85
+
61
86
  export class WsClient extends EventEmitter {
62
87
  /**
63
88
  * @param {{ hub_url: string, agent_id: string, agent_token: string }} config
@@ -94,6 +119,9 @@ export class WsClient extends EventEmitter {
94
119
  // pty.data の outbound buffer (WS not OPEN 中に積み、open 後 flush)。
95
120
  // entry: { obj, ts }
96
121
  this.ptyOutboundBuffer = []
122
+ // 制御/イベント系 (ack / jsonl.event 等) の outbound buffer。pty.data とは別管理で、
123
+ // 高頻度 pty.data の洪水に evict されないようにする (機構③)。entry: { obj, ts }
124
+ this.ctrlOutboundBuffer = []
97
125
  // 直前の close 原因が CF/origin の 5xx か。次回 backoff の下限を引き上げる。
98
126
  this.lastCloseWas5xx = false
99
127
  // 接続が STABLE_CONNECTION_MS 維持できたら backoff/5xx フラグをリセットする
@@ -203,23 +231,30 @@ export class WsClient extends EventEmitter {
203
231
  request_id: randomUUID(),
204
232
  })
205
233
  this._flushPtyBuffer()
234
+ this._flushCtrlBuffer()
206
235
  this._startHeartbeat()
207
236
  this._startBundleWatcher()
208
237
  this.emit("open")
209
238
  }
210
239
 
211
- /** メッセージを送る。未接続時は pty.data だけ buffer に積み、reconnect 後に flush。
240
+ /** メッセージを送る。未接続時は pty.data と制御/イベント系 (ack / jsonl.event 等) を
241
+ * buffer に積み、reconnect 後に flush する。
212
242
  *
213
- * heartbeat / hello / agent.streams.sync.* など制御系は古くなると意味が無いので
214
- * buffer しない (warn のみ)。
243
+ * heartbeat / hello / agent.streams.sync.* など「古くなると意味が無い」制御系は
244
+ * buffer しない (warn のみ)。ack / event 系は 1 件ずつ意味を持つので別バッファに保持する。
215
245
  */
216
246
  send(obj) {
217
247
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
218
- if (obj?.type === "pty.data") {
248
+ const type = obj?.type
249
+ if (type === "pty.data") {
219
250
  this._bufferPtyData(obj)
220
251
  return false
221
252
  }
222
- this.logger?.warn({ type: obj?.type }, "ws send skipped (not open)")
253
+ if (BUFFERED_CTRL_TYPES.has(type)) {
254
+ this._bufferCtrl(obj)
255
+ return false
256
+ }
257
+ this.logger?.warn({ type }, "ws send skipped (not open)")
223
258
  return false
224
259
  }
225
260
  return this._sendJson(obj)
@@ -269,6 +304,48 @@ export class WsClient extends EventEmitter {
269
304
  )
270
305
  }
271
306
 
307
+ /** 制御/イベント系 (ack / jsonl.event 等) を outbound buffer に積む。リング (drop oldest)。 */
308
+ _bufferCtrl(obj) {
309
+ this.ctrlOutboundBuffer.push({ obj, ts: Date.now() })
310
+ if (this.ctrlOutboundBuffer.length > CTRL_BUFFER_MAX_FRAMES) {
311
+ const dropped = this.ctrlOutboundBuffer.length - CTRL_BUFFER_MAX_FRAMES
312
+ this.ctrlOutboundBuffer.splice(0, dropped)
313
+ this.logger?.warn(
314
+ { dropped, kept: this.ctrlOutboundBuffer.length },
315
+ "ctrl outbound buffer overflow (oldest dropped)"
316
+ )
317
+ }
318
+ }
319
+
320
+ /** open 直後に制御/イベントバッファを flush。古すぎる entry は破棄する。
321
+ * 構造は _flushPtyBuffer と同じ (退避 → 期限切れ skip → 送信失敗で残りを順序保持で戻す)。
322
+ */
323
+ _flushCtrlBuffer() {
324
+ if (this.ctrlOutboundBuffer.length === 0) return
325
+ const now = Date.now()
326
+ const buf = this.ctrlOutboundBuffer
327
+ this.ctrlOutboundBuffer = []
328
+ let sent = 0
329
+ let expired = 0
330
+ for (let i = 0; i < buf.length; i++) {
331
+ const entry = buf[i]
332
+ if (now - entry.ts > CTRL_BUFFER_MAX_AGE_MS) {
333
+ expired += 1
334
+ continue
335
+ }
336
+ const ok = this._sendJson(entry.obj)
337
+ if (!ok) {
338
+ this.ctrlOutboundBuffer = buf.slice(i).concat(this.ctrlOutboundBuffer)
339
+ break
340
+ }
341
+ sent += 1
342
+ }
343
+ this.logger?.info(
344
+ { sent, expired, total: buf.length, requeued: this.ctrlOutboundBuffer.length },
345
+ "ctrl outbound buffer flushed"
346
+ )
347
+ }
348
+
272
349
  /** Reconnect を止めて切断する。 */
273
350
  stop() {
274
351
  this.stopped = true