@cocorograph/hub-agent 0.5.27 → 0.5.29

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.5.27",
3
+ "version": "0.5.29",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/main.mjs CHANGED
@@ -403,6 +403,45 @@ export async function maybeSyncClaudeSettings(msg, ctx) {
403
403
  }
404
404
  }
405
405
 
406
+ /**
407
+ * Plan ε: `agent.streams.sync.response` を受けて backend ↔ agent の現存
408
+ * stream_id を能動同期する。
409
+ *
410
+ * - local にあって backend にない → ptyBridge.detach で即時 kill (孤児)
411
+ * - backend にあって local にない → backend に pty.error を通知 (browser に
412
+ * "pty 死んでます" を表示するため)
413
+ *
414
+ * dispatch から呼ぶ用のヘルパーだが、テストから直接叩けるよう export している。
415
+ */
416
+ export function handleStreamsSyncResponse(msg, ctx) {
417
+ const activeIds = new Set(
418
+ Array.isArray(msg?.active_stream_ids) ? msg.active_stream_ids : [],
419
+ )
420
+ const localIds = new Set(ctx.ptyBridge.list())
421
+ for (const sid of localIds) {
422
+ if (!activeIds.has(sid)) {
423
+ ctx.logger?.warn?.(
424
+ { stream_id: sid, request_id: msg?.request_id },
425
+ "Plan ε: orphan stream detected, detaching",
426
+ )
427
+ ctx.ptyBridge.detach({ stream_id: sid })
428
+ }
429
+ }
430
+ for (const sid of activeIds) {
431
+ if (!localIds.has(sid)) {
432
+ ctx.logger?.warn?.(
433
+ { stream_id: sid, request_id: msg?.request_id },
434
+ "Plan ε: stream missing on agent, notifying backend (pty.error)",
435
+ )
436
+ ctx.client.send({
437
+ type: "pty.error",
438
+ stream_id: sid,
439
+ error: "stream_not_found_on_agent",
440
+ })
441
+ }
442
+ }
443
+ }
444
+
406
445
  async function dispatch(msg, ctx) {
407
446
  const t = msg?.type || ""
408
447
  try {
@@ -456,6 +495,15 @@ async function dispatch(msg, ctx) {
456
495
  case "pty.detach":
457
496
  ctx.ptyBridge.detach({ stream_id: msg.stream_id })
458
497
  return
498
+ case "agent.streams.sync.response":
499
+ // Plan ε: backend ↔ agent の現存 stream_id 能動同期 (WS reconnect 直後に
500
+ // ws-client が agent.streams.sync.request を送って取得した結果)。
501
+ // ロジックは handleStreamsSyncResponse へ抽出 (テストから直接呼べる)。
502
+ // backend が旧 (= Plan ε 未対応) で response が一切来ないケースは何もしない。
503
+ // 5s タイムアウトのような明示的な timer は持たず、再接続時に再度 request
504
+ // が飛ぶので無害。
505
+ handleStreamsSyncResponse(msg, ctx)
506
+ return
459
507
  case "tmux.exec": {
460
508
  const args = Array.isArray(msg.args) ? msg.args : []
461
509
  const hookResult = await runHookChain(ctx.plugins, "interceptTmuxExec", {
@@ -28,6 +28,18 @@ const DEFAULT_ROWS = 32
28
28
  // に見える (1 フレーム 16.7ms より短いので体感レイテンシは増えない)。
29
29
  const DEFAULT_COALESCE_MS = 12
30
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
+
31
43
  function resolveBin(name) {
32
44
  try {
33
45
  return execFileSync("/usr/bin/which", [name], { encoding: "utf-8" }).trim()
@@ -53,7 +65,32 @@ export class PtyBridge extends EventEmitter {
53
65
  * `/bin/sh -c "exec tmux attach -t <sessionName>"`
54
66
  * @param {number} [opts.coalesceMs] - pty 出力を coalesce する間隔 (ms)。0 で無効。
55
67
  */
56
- constructor({ ptyModule, logger, plugins = [], defaultSpawnCommand, coalesceMs } = {}) {
68
+ constructor({
69
+ ptyModule,
70
+ logger,
71
+ plugins = [],
72
+ defaultSpawnCommand,
73
+ coalesceMs,
74
+ /**
75
+ * 孤児 stream の GC 設定。
76
+ *
77
+ * 背景: 旧設計では WS reconnect / Cockpit window リロード / タブ閉じ等で
78
+ * `pty.detach` が agent に届かないケースがあり、pty fd が leak していた。
79
+ * macOS の `kern.tty.ptmx_max` は 511 個でその上限に到達すると新規
80
+ * posix_spawnp が `EAGAIN` で失敗する (= "posix_spawnp failed" の真因)。
81
+ *
82
+ * 対処: 各 stream に `lastSeenAt` を持たせ、`attach` / `write` / `resize`
83
+ * のたびに touch する。`gcIntervalMs` 周期で `gcStaleMs` を超えた stream を
84
+ * 自動 detach する (= orphan GC)。**アクティブな stream は browser からの
85
+ * pty.data / pty.resize で常に touch されるため絶対に kill されない**。
86
+ * リロード時の短時間切断 (秒〜十数秒) も `gcStaleMs` (デフォルト 10 分)
87
+ * 未満なので無傷。何時間も放置された孤児だけ掃除される。
88
+ *
89
+ * gcStaleMs を 0 以下にすると GC を無効化する (旧挙動)。テスト・特殊用途向け。
90
+ */
91
+ gcIntervalMs,
92
+ gcStaleMs,
93
+ } = {}) {
57
94
  super()
58
95
  if (!ptyModule || typeof ptyModule.spawn !== "function") {
59
96
  throw new TypeError("PtyBridge requires { ptyModule: { spawn } }")
@@ -73,6 +110,59 @@ export class PtyBridge extends EventEmitter {
73
110
  this.streams = new Map()
74
111
  /** @type {Map<string, {buf: string, timer: ReturnType<typeof setTimeout>|null}>} */
75
112
  this.coalesceState = new Map()
113
+
114
+ // orphan GC 設定。デフォルトは 1 分周期で 10 分間 idle の stream を kill。
115
+ this.gcIntervalMs = gcIntervalMs ?? DEFAULT_GC_INTERVAL_MS
116
+ this.gcStaleMs = gcStaleMs ?? DEFAULT_GC_STALE_MS
117
+ /** @type {Map<string, number>} stream_id → lastSeenAt (ms epoch) */
118
+ this.lastSeenAt = new Map()
119
+ /** @type {ReturnType<typeof setInterval>|null} */
120
+ this._gcTimer = null
121
+ if (this.gcStaleMs > 0 && this.gcIntervalMs > 0) {
122
+ this._gcTimer = setInterval(() => this._gcStaleStreams(), this.gcIntervalMs)
123
+ // unref で Node の event loop を止めない (test 環境で hang しないため)。
124
+ if (typeof this._gcTimer.unref === "function") this._gcTimer.unref()
125
+ }
126
+ }
127
+
128
+ /**
129
+ * 現在時刻を ms epoch で返す。テストで now を差し替えるため override 可能に。
130
+ * @returns {number}
131
+ */
132
+ _now() {
133
+ return Date.now()
134
+ }
135
+
136
+ /**
137
+ * stream_id を「最近 browser からアクセスがあった」と record する。
138
+ * attach / write / resize の各 entry point から呼ぶ。
139
+ * @param {string} stream_id
140
+ */
141
+ _touch(stream_id) {
142
+ this.lastSeenAt.set(stream_id, this._now())
143
+ }
144
+
145
+ /**
146
+ * `gcStaleMs` を超えて idle な stream を一括 detach する。
147
+ *
148
+ * `_gcTimer` から `gcIntervalMs` 周期で呼ばれる。手動でも呼べる (テスト用)。
149
+ * @returns {string[]} kill した stream_id 一覧 (debug / test 用)
150
+ */
151
+ _gcStaleStreams() {
152
+ if (!(this.gcStaleMs > 0)) return []
153
+ const cutoff = this._now() - this.gcStaleMs
154
+ const killed = []
155
+ for (const [stream_id, ts] of this.lastSeenAt) {
156
+ if (ts < cutoff && this.streams.has(stream_id)) {
157
+ this.logger?.warn(
158
+ { stream_id, stale_ms: this._now() - ts, gc_stale_ms: this.gcStaleMs },
159
+ "pty GC: orphaned stream killed",
160
+ )
161
+ this.detach({ stream_id })
162
+ killed.push(stream_id)
163
+ }
164
+ }
165
+ return killed
76
166
  }
77
167
 
78
168
  /**
@@ -134,6 +224,7 @@ export class PtyBridge extends EventEmitter {
134
224
  }
135
225
 
136
226
  this.streams.set(stream_id, pty)
227
+ this._touch(stream_id)
137
228
  this.logger?.info(
138
229
  { stream_id, sessionName, command: spec.command, plugin: hookResult?.plugin || null },
139
230
  "pty attached",
@@ -164,6 +255,7 @@ export class PtyBridge extends EventEmitter {
164
255
  this._flushCoalesce(stream_id)
165
256
  this.coalesceState.delete(stream_id)
166
257
  this.streams.delete(stream_id)
258
+ this.lastSeenAt.delete(stream_id)
167
259
  this.emit("exit", { stream_id, code: exitCode })
168
260
  })
169
261
 
@@ -181,6 +273,7 @@ export class PtyBridge extends EventEmitter {
181
273
  this.logger?.warn({ stream_id }, "pty.write but stream missing")
182
274
  return false
183
275
  }
276
+ this._touch(stream_id)
184
277
  pty.write(data)
185
278
  return true
186
279
  }
@@ -192,6 +285,7 @@ export class PtyBridge extends EventEmitter {
192
285
  this.logger?.warn({ stream_id }, "pty.resize but stream missing")
193
286
  return false
194
287
  }
288
+ this._touch(stream_id)
195
289
  try {
196
290
  pty.resize(cols, rows)
197
291
  return true
@@ -208,6 +302,9 @@ export class PtyBridge extends EventEmitter {
208
302
  // kill 前に残バッファを emit しておく (onExit にも flush はあるが、
209
303
  // detach は browser 側都合なので最新を確実に届けたい)。
210
304
  this._flushCoalesce(stream_id)
305
+ // GC tracking もここでクリア。pty.onExit でも消すが、onExit が来る前に
306
+ // 同じ stream_id が再 attach されると古い lastSeenAt が残るリスクを避ける。
307
+ this.lastSeenAt.delete(stream_id)
211
308
  try {
212
309
  pty.kill()
213
310
  } catch {
@@ -218,6 +315,10 @@ export class PtyBridge extends EventEmitter {
218
315
 
219
316
  /** 全 pty を即時 kill (agent shutdown 用)。 */
220
317
  shutdown() {
318
+ if (this._gcTimer) {
319
+ clearInterval(this._gcTimer)
320
+ this._gcTimer = null
321
+ }
221
322
  for (const stream_id of Array.from(this.streams.keys())) {
222
323
  this.detach({ stream_id })
223
324
  }
package/src/ws-client.mjs CHANGED
@@ -11,6 +11,7 @@
11
11
  *
12
12
  * 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
13
13
  */
14
+ import { randomUUID } from "node:crypto"
14
15
  import { EventEmitter } from "node:events"
15
16
  import fs from "node:fs"
16
17
  import os from "node:os"
@@ -68,22 +69,7 @@ export class WsClient extends EventEmitter {
68
69
  const ws = new WebSocket(wsUrl, { headers })
69
70
  this.ws = ws
70
71
 
71
- ws.on("open", () => {
72
- this.backoff = MIN_BACKOFF_MS
73
- this.logger?.info("ws open")
74
- // hello 前に最新の bundle version を読み直す (再接続時の鮮度確保)
75
- this._refreshBundleVersion()
76
- this._sendJson({
77
- type: "hello",
78
- agent_id: this.config.agent_id,
79
- hostname: this.hostname,
80
- version: this.version,
81
- bundle_version: this.bundleVersion,
82
- })
83
- this._startHeartbeat()
84
- this._startBundleWatcher()
85
- this.emit("open")
86
- })
72
+ ws.on("open", () => this._onOpen())
87
73
 
88
74
  ws.on("message", (data) => {
89
75
  let msg
@@ -111,6 +97,43 @@ export class WsClient extends EventEmitter {
111
97
  })
112
98
  }
113
99
 
100
+ /**
101
+ * WS open 時の初期送信 + 後続タスク起動。
102
+ *
103
+ * - backoff をリセット
104
+ * - bundle version を最新化 → hello 送信
105
+ * - Plan ε: agent.streams.sync.request を送信 (毎 reconnect で能動同期)
106
+ * - heartbeat + bundle watcher を起動
107
+ *
108
+ * test からも直接呼べるように method として切り出している (`ws.on("open")`
109
+ * のクロージャだと spy しづらい)。
110
+ */
111
+ _onOpen() {
112
+ this.backoff = MIN_BACKOFF_MS
113
+ this.logger?.info("ws open")
114
+ // hello 前に最新の bundle version を読み直す (再接続時の鮮度確保)
115
+ this._refreshBundleVersion()
116
+ this._sendJson({
117
+ type: "hello",
118
+ agent_id: this.config.agent_id,
119
+ hostname: this.hostname,
120
+ version: this.version,
121
+ bundle_version: this.bundleVersion,
122
+ })
123
+ // Plan ε: 毎回 reconnect 直後に backend ↔ agent の stream_id を能動同期する
124
+ // (orphan stream の即時 kill 用)。response は main.mjs の dispatch が拾い、
125
+ // ptyBridge.list() と差分を取って kill する。request_id は uuid で
126
+ // 重複排除。backend が古い (= Plan ε 未対応) なら "unknown_type" error が
127
+ // 返るだけで動作には影響しない。
128
+ this._sendJson({
129
+ type: "agent.streams.sync.request",
130
+ request_id: randomUUID(),
131
+ })
132
+ this._startHeartbeat()
133
+ this._startBundleWatcher()
134
+ this.emit("open")
135
+ }
136
+
114
137
  /** メッセージを送る。未接続なら no-op (logger.warn)。 */
115
138
  send(obj) {
116
139
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {