@cocorograph/hub-agent 0.5.13 → 0.5.14

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/pty-bridge.mjs +58 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.5.13",
3
+ "version": "0.5.14",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -20,6 +20,14 @@ import { runHookChain } from "./plugin-loader.mjs"
20
20
  const DEFAULT_COLS = 120
21
21
  const DEFAULT_ROWS = 32
22
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
+
23
31
  function resolveBin(name) {
24
32
  try {
25
33
  return execFileSync("/usr/bin/which", [name], { encoding: "utf-8" }).trim()
@@ -43,8 +51,9 @@ export class PtyBridge extends EventEmitter {
43
51
  * @param {(args: {sessionName: string, cols: number, rows: number, env: object}) => {command: string, args: string[], env?: object}} [opts.defaultSpawnCommand]
44
52
  * - plugin が null を返したときに使うデフォルト spawn 仕様。省略時は
45
53
  * `/bin/sh -c "exec tmux attach -t <sessionName>"`
54
+ * @param {number} [opts.coalesceMs] - pty 出力を coalesce する間隔 (ms)。0 で無効。
46
55
  */
47
- constructor({ ptyModule, logger, plugins = [], defaultSpawnCommand } = {}) {
56
+ constructor({ ptyModule, logger, plugins = [], defaultSpawnCommand, coalesceMs } = {}) {
48
57
  super()
49
58
  if (!ptyModule || typeof ptyModule.spawn !== "function") {
50
59
  throw new TypeError("PtyBridge requires { ptyModule: { spawn } }")
@@ -59,8 +68,30 @@ export class PtyBridge extends EventEmitter {
59
68
  args: ["-c", `exec ${tmuxBin()} attach -t ${sessionName}`],
60
69
  env: process.env,
61
70
  }))
71
+ this.coalesceMs = coalesceMs ?? DEFAULT_COALESCE_MS
62
72
  /** @type {Map<string, import('node-pty').IPty>} */
63
73
  this.streams = new Map()
74
+ /** @type {Map<string, {buf: string, timer: ReturnType<typeof setTimeout>|null}>} */
75
+ this.coalesceState = new Map()
76
+ }
77
+
78
+ /**
79
+ * stream の coalesce buffer を即時 flush して emit する。
80
+ * detach / exit / shutdown のタイミングで残バッファの取りこぼしを防ぐ。
81
+ * @param {string} stream_id
82
+ */
83
+ _flushCoalesce(stream_id) {
84
+ const state = this.coalesceState.get(stream_id)
85
+ if (!state) return
86
+ if (state.timer) {
87
+ clearTimeout(state.timer)
88
+ state.timer = null
89
+ }
90
+ if (state.buf) {
91
+ const out = state.buf
92
+ state.buf = ""
93
+ this.emit("output", { stream_id, data: out })
94
+ }
64
95
  }
65
96
 
66
97
  /**
@@ -108,10 +139,30 @@ export class PtyBridge extends EventEmitter {
108
139
  "pty attached",
109
140
  )
110
141
 
111
- pty.onData((data) => {
112
- this.emit("output", { stream_id, data })
113
- })
142
+ if (this.coalesceMs > 0) {
143
+ const state = { buf: "", timer: null }
144
+ this.coalesceState.set(stream_id, state)
145
+ pty.onData((data) => {
146
+ state.buf += data
147
+ if (!state.timer) {
148
+ state.timer = setTimeout(() => {
149
+ state.timer = null
150
+ const out = state.buf
151
+ if (!out) return
152
+ state.buf = ""
153
+ this.emit("output", { stream_id, data: out })
154
+ }, this.coalesceMs)
155
+ }
156
+ })
157
+ } else {
158
+ pty.onData((data) => {
159
+ this.emit("output", { stream_id, data })
160
+ })
161
+ }
114
162
  pty.onExit(({ exitCode }) => {
163
+ // 残バッファを取りこぼさないよう flush してから exit を emit する。
164
+ this._flushCoalesce(stream_id)
165
+ this.coalesceState.delete(stream_id)
115
166
  this.streams.delete(stream_id)
116
167
  this.emit("exit", { stream_id, code: exitCode })
117
168
  })
@@ -154,6 +205,9 @@ export class PtyBridge extends EventEmitter {
154
205
  detach({ stream_id }) {
155
206
  const pty = this.streams.get(stream_id)
156
207
  if (!pty) return false
208
+ // kill 前に残バッファを emit しておく (onExit にも flush はあるが、
209
+ // detach は browser 側都合なので最新を確実に届けたい)。
210
+ this._flushCoalesce(stream_id)
157
211
  try {
158
212
  pty.kill()
159
213
  } catch {