@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.
- package/README.md +13 -12
- package/bin/hub-agent.mjs +7 -37
- package/package.json +2 -5
- package/scripts/install.sh +5 -5
- package/src/claude-stream-bridge.mjs +264 -30
- package/src/codex-stream-bridge.mjs +261 -49
- package/src/hooks.mjs +0 -17
- package/src/main.mjs +113 -293
- package/src/plugin-loader.mjs +49 -4
- package/src/plugin-uninstall.mjs +61 -0
- package/src/positive-int-env.mjs +81 -0
- package/src/session-seq.mjs +22 -0
- package/src/tmux.mjs +4 -32
- package/src/warm-bounds-total.mjs +150 -0
- package/src/ws-client.mjs +19 -89
- package/plugins/10-tailscale-remote/README.md +0 -71
- package/plugins/10-tailscale-remote/config.example.json +0 -6
- package/plugins/10-tailscale-remote/plugin.mjs +0 -99
- package/scripts/fix-node-pty-perms.mjs +0 -89
- package/src/plugin-install.mjs +0 -111
- package/src/pty-bridge.mjs +0 -510
package/src/pty-bridge.mjs
DELETED
|
@@ -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
|
-
}
|