@cocorograph/hub-agent 0.5.13 → 0.5.15
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 +1 -1
- package/src/pty-bridge.mjs +58 -4
- package/src/service-install.mjs +52 -2
package/package.json
CHANGED
package/src/pty-bridge.mjs
CHANGED
|
@@ -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
|
-
|
|
112
|
-
|
|
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 {
|
package/src/service-install.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* テンプレ内の __HUB_AGENT_BIN__ / __HOME__ / __PATH__ を実行時に置換する。
|
|
10
10
|
* 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
|
|
11
11
|
*/
|
|
12
|
-
import { promises as fs } from "node:fs"
|
|
12
|
+
import { promises as fs, existsSync, realpathSync } from "node:fs"
|
|
13
13
|
import os from "node:os"
|
|
14
14
|
import path from "node:path"
|
|
15
15
|
import { spawnSync } from "node:child_process"
|
|
@@ -63,10 +63,59 @@ function run(cmd, args, opts = {}) {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* `which hub-agent` の結果を、launchd / systemd から長期間 exec 可能な
|
|
68
|
+
* 安定パスに正規化する。pure 関数。`opts` で fs を差し替えてテスト可能。
|
|
69
|
+
*
|
|
70
|
+
* 背景: fnm 利用者の `which hub-agent` は現在のシェル専用の一時パス
|
|
71
|
+
* (`~/.local/state/fnm_multishells/<PID>_<ts>/bin/hub-agent`) を返す。
|
|
72
|
+
* それを plist に書き込むと、install-service を叩いたシェル終了後に
|
|
73
|
+
* symlink が消えて launchd が exec できず、agent が無音で停止する事故が
|
|
74
|
+
* 発生する (2026-05-19, シェル PID 4683 で install したまま放置されて
|
|
75
|
+
* 検知が遅れた)。
|
|
76
|
+
*
|
|
77
|
+
* 優先順:
|
|
78
|
+
* 1. fnm default alias `~/.local/share/fnm/aliases/default/bin/hub-agent`
|
|
79
|
+
* が存在すればそれを返す (fnm 利用者にとって最も安定)
|
|
80
|
+
* 2. whichPath が `/fnm_multishells/` を含むなら realpath で
|
|
81
|
+
* `~/.local/share/fnm/node-versions/vX.Y.Z/.../bin/hub-agent.mjs`
|
|
82
|
+
* に展開 (node アップグレード前提でも install-service 再実行で済む)
|
|
83
|
+
* 3. それ以外 (brew / nvm / 通常 PATH) は whichPath をそのまま返す
|
|
84
|
+
*/
|
|
85
|
+
function normalizeBinPath(whichPath, opts = {}) {
|
|
86
|
+
const home = opts.home ?? os.homedir()
|
|
87
|
+
const fileExists = opts.fileExists ?? existsSync
|
|
88
|
+
const resolveReal = opts.realpath ?? realpathSync
|
|
89
|
+
|
|
90
|
+
const fnmDefaultBin = path.join(
|
|
91
|
+
home,
|
|
92
|
+
".local",
|
|
93
|
+
"share",
|
|
94
|
+
"fnm",
|
|
95
|
+
"aliases",
|
|
96
|
+
"default",
|
|
97
|
+
"bin",
|
|
98
|
+
"hub-agent",
|
|
99
|
+
)
|
|
100
|
+
if (fileExists(fnmDefaultBin)) return fnmDefaultBin
|
|
101
|
+
|
|
102
|
+
if (!whichPath) return null
|
|
103
|
+
if (whichPath.includes("/fnm_multishells/")) {
|
|
104
|
+
try {
|
|
105
|
+
return resolveReal(whichPath)
|
|
106
|
+
} catch {
|
|
107
|
+
// realpath 失敗時は whichPath をそのまま返す (既存挙動の保持)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return whichPath
|
|
111
|
+
}
|
|
112
|
+
|
|
66
113
|
/** インストール先の hub-agent CLI のフルパスを返す。`hub-agent` が PATH にある前提。 */
|
|
67
114
|
function detectHubAgentBin() {
|
|
68
115
|
const r = spawnSync("/usr/bin/which", ["hub-agent"], { encoding: "utf-8" })
|
|
69
|
-
|
|
116
|
+
const whichPath = r.status === 0 && r.stdout ? r.stdout.trim() : null
|
|
117
|
+
const normalized = normalizeBinPath(whichPath)
|
|
118
|
+
if (normalized) return normalized
|
|
70
119
|
// fallback: node $repo/bin/hub-agent.mjs
|
|
71
120
|
return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "bin", "hub-agent.mjs")
|
|
72
121
|
}
|
|
@@ -179,6 +228,7 @@ export async function uninstallService() {
|
|
|
179
228
|
export const _internal = {
|
|
180
229
|
expandTemplate,
|
|
181
230
|
detectHubAgentBin,
|
|
231
|
+
normalizeBinPath,
|
|
182
232
|
macPlistPath,
|
|
183
233
|
linuxUnitPath,
|
|
184
234
|
repoTemplatesDir,
|