@cocorograph/hub-agent 0.6.84 → 0.6.86
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 +2 -2
- package/package.json +2 -2
- package/scripts/fix-node-pty-perms.mjs +45 -11
- package/scripts/install.sh +6 -4
- package/src/main.mjs +6 -1
- package/src/pty-bridge.mjs +190 -36
- package/src/state.mjs +55 -22
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.
|
|
3
|
+
"version": "0.6.86",
|
|
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 経由インストールだと
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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 未インストール。サイレント終了。
|
package/scripts/install.sh
CHANGED
|
@@ -512,10 +512,12 @@ _install_node_lts_linux() {
|
|
|
512
512
|
color_ok "node $(node --version 2>/dev/null || echo '?') を導入"
|
|
513
513
|
}
|
|
514
514
|
|
|
515
|
-
#
|
|
516
|
-
# node-pty
|
|
517
|
-
#
|
|
518
|
-
#
|
|
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
|
-
|
|
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 モードのみ無効化)。
|
package/src/pty-bridge.mjs
CHANGED
|
@@ -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()
|
|
@@ -90,6 +96,11 @@ export class PtyBridge extends EventEmitter {
|
|
|
90
96
|
*/
|
|
91
97
|
gcIntervalMs,
|
|
92
98
|
gcStaleMs,
|
|
99
|
+
/**
|
|
100
|
+
* 同時 attach 上限 (安全弁)。超過すると新規 attach 前に最古の stream を強制
|
|
101
|
+
* reap する。リーク回帰があっても PTY 枯渇に至らせないためのハードリミット。
|
|
102
|
+
*/
|
|
103
|
+
maxStreams,
|
|
93
104
|
} = {}) {
|
|
94
105
|
super()
|
|
95
106
|
if (!ptyModule || typeof ptyModule.spawn !== "function") {
|
|
@@ -106,10 +117,21 @@ export class PtyBridge extends EventEmitter {
|
|
|
106
117
|
env: process.env,
|
|
107
118
|
}))
|
|
108
119
|
this.coalesceMs = coalesceMs ?? DEFAULT_COALESCE_MS
|
|
120
|
+
this.maxStreams = maxStreams ?? DEFAULT_MAX_STREAMS
|
|
109
121
|
/** @type {Map<string, import('node-pty').IPty>} */
|
|
110
122
|
this.streams = new Map()
|
|
111
123
|
/** @type {Map<string, {buf: string, timer: ReturnType<typeof setTimeout>|null}>} */
|
|
112
124
|
this.coalesceState = new Map()
|
|
125
|
+
/**
|
|
126
|
+
* stream_id → onData/onExit の IDisposable[]。
|
|
127
|
+
* reap 時に dispose() しないと listener が IPty を参照し続け、master fd が
|
|
128
|
+
* close されず leak する (= ユーザー指摘「リスナ経由の参照残り」)。
|
|
129
|
+
* @type {Map<string, Array<{dispose: () => void}>>}
|
|
130
|
+
*/
|
|
131
|
+
this.disposables = new Map()
|
|
132
|
+
// 可観測性: 起動からの累計 spawn / reap 数。差分 (= held) が単調増加したら leak。
|
|
133
|
+
this._spawnedTotal = 0
|
|
134
|
+
this._reapedTotal = 0
|
|
113
135
|
|
|
114
136
|
// orphan GC 設定。デフォルトは 1 分周期で 10 分間 idle の stream を kill。
|
|
115
137
|
this.gcIntervalMs = gcIntervalMs ?? DEFAULT_GC_INTERVAL_MS
|
|
@@ -119,7 +141,10 @@ export class PtyBridge extends EventEmitter {
|
|
|
119
141
|
/** @type {ReturnType<typeof setInterval>|null} */
|
|
120
142
|
this._gcTimer = null
|
|
121
143
|
if (this.gcStaleMs > 0 && this.gcIntervalMs > 0) {
|
|
122
|
-
this._gcTimer = setInterval(() =>
|
|
144
|
+
this._gcTimer = setInterval(() => {
|
|
145
|
+
this._gcStaleStreams()
|
|
146
|
+
this._logStats()
|
|
147
|
+
}, this.gcIntervalMs)
|
|
123
148
|
// unref で Node の event loop を止めない (test 環境で hang しないため)。
|
|
124
149
|
if (typeof this._gcTimer.unref === "function") this._gcTimer.unref()
|
|
125
150
|
}
|
|
@@ -143,28 +168,127 @@ export class PtyBridge extends EventEmitter {
|
|
|
143
168
|
}
|
|
144
169
|
|
|
145
170
|
/**
|
|
146
|
-
* `gcStaleMs` を超えて idle な stream を一括
|
|
171
|
+
* `gcStaleMs` を超えて idle な stream を一括 reap する。
|
|
147
172
|
*
|
|
148
173
|
* `_gcTimer` から `gcIntervalMs` 周期で呼ばれる。手動でも呼べる (テスト用)。
|
|
149
|
-
*
|
|
174
|
+
*
|
|
175
|
+
* ⚠️ 走査対象は `this.streams` (= 実際に fd を保持している正本)。旧実装は
|
|
176
|
+
* `lastSeenAt` を走査していたが、`detach` が kill 前に `lastSeenAt` を消す一方で
|
|
177
|
+
* `streams` からの削除を `onExit` 頼みにしていたため、`onExit` が取りこぼされると
|
|
178
|
+
* 「streams に残る・lastSeenAt は無い」孤児が GC から不可視になり永久 leak した
|
|
179
|
+
* (2026-06-16 障害の主因)。streams を正本に走査することでこの不可視孤児を根絶する。
|
|
180
|
+
* @returns {string[]} reap した stream_id 一覧 (debug / test 用)
|
|
150
181
|
*/
|
|
151
182
|
_gcStaleStreams() {
|
|
152
183
|
if (!(this.gcStaleMs > 0)) return []
|
|
153
184
|
const cutoff = this._now() - this.gcStaleMs
|
|
154
185
|
const killed = []
|
|
155
|
-
for (const
|
|
156
|
-
|
|
186
|
+
for (const stream_id of Array.from(this.streams.keys())) {
|
|
187
|
+
// lastSeenAt 欠落 (= 不可視孤児) は 0 扱い = 即 stale 判定で確実に回収する。
|
|
188
|
+
const ts = this.lastSeenAt.get(stream_id) ?? 0
|
|
189
|
+
if (ts < cutoff) {
|
|
157
190
|
this.logger?.warn(
|
|
158
191
|
{ stream_id, stale_ms: this._now() - ts, gc_stale_ms: this.gcStaleMs },
|
|
159
192
|
"pty GC: orphaned stream killed",
|
|
160
193
|
)
|
|
161
|
-
this.
|
|
194
|
+
this._reap(stream_id, { emitExit: true, code: null })
|
|
162
195
|
killed.push(stream_id)
|
|
163
196
|
}
|
|
164
197
|
}
|
|
165
198
|
return killed
|
|
166
199
|
}
|
|
167
200
|
|
|
201
|
+
/**
|
|
202
|
+
* 1 つの stream の後始末を一元化する (= 全リーク経路の単一出口)。
|
|
203
|
+
*
|
|
204
|
+
* 正常終了 (onExit) / browser detach / GC / 上限超過 / shutdown の全経路がここを
|
|
205
|
+
* 通る。冪等: 既に reap 済みなら何もしない (false を返す)。
|
|
206
|
+
*
|
|
207
|
+
* 順序が重要:
|
|
208
|
+
* 1. `streams` から **最初に** 削除する。`pty.kill()` が onExit を同期発火させる
|
|
209
|
+
* 実装 (テスト stub / 一部の node-pty 経路) で onExit ハンドラが再入しても、
|
|
210
|
+
* その時点で stream が消えているので二重 emit / 二重カウントを防げる。
|
|
211
|
+
* 2. 残バッファを flush (取りこぼし防止)。
|
|
212
|
+
* 3. onData/onExit listener を dispose (参照残りによる fd 保持を断つ)。
|
|
213
|
+
* 4. 補助 Map をクリアし、最後に `pty.kill()` で master fd を解放する。
|
|
214
|
+
*
|
|
215
|
+
* @param {string} stream_id
|
|
216
|
+
* @param {{emitExit?: boolean, code?: number|null}} [opts]
|
|
217
|
+
* @returns {boolean} 実際に reap したら true
|
|
218
|
+
*/
|
|
219
|
+
_reap(stream_id, { emitExit = false, code = null } = {}) {
|
|
220
|
+
const existed = this.streams.has(stream_id)
|
|
221
|
+
const pty = this.streams.get(stream_id)
|
|
222
|
+
// (1) streams から最初に外す → 以降の同期 onExit 再入は no-op。
|
|
223
|
+
this.streams.delete(stream_id)
|
|
224
|
+
// (2) 残バッファを取りこぼさない。
|
|
225
|
+
this._flushCoalesce(stream_id)
|
|
226
|
+
// (3) listener を dispose (node-pty の onData/onExit は IDisposable を返す)。
|
|
227
|
+
const disps = this.disposables.get(stream_id)
|
|
228
|
+
if (disps) {
|
|
229
|
+
for (const d of disps) {
|
|
230
|
+
try {
|
|
231
|
+
d?.dispose?.()
|
|
232
|
+
} catch {
|
|
233
|
+
/* ignore */
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
this.disposables.delete(stream_id)
|
|
238
|
+
this.coalesceState.delete(stream_id)
|
|
239
|
+
this.lastSeenAt.delete(stream_id)
|
|
240
|
+
// (4) master fd を解放。
|
|
241
|
+
// ⚠️ `pty.kill()` は子プロセスに SIGHUP を送るだけで、master fd を握る
|
|
242
|
+
// 内部 socket を破棄しない。macOS では子終了後に socket の 'close' が
|
|
243
|
+
// 発火せず fd が残り続ける → これが 2026-06-16 の PTY 枯渇 (504/511) の真因。
|
|
244
|
+
// node-pty UnixTerminal の `destroy()` は `_socket.destroy()` で fd を即時
|
|
245
|
+
// close してから SIGHUP を送るので、こちらを使う (公開 typings に無い内部 API
|
|
246
|
+
// のため feature-detect。Windows 等で未定義なら kill にフォールバック)。
|
|
247
|
+
if (pty) {
|
|
248
|
+
try {
|
|
249
|
+
if (typeof pty.destroy === "function") pty.destroy()
|
|
250
|
+
else pty.kill()
|
|
251
|
+
} catch {
|
|
252
|
+
/* already dead / socket already closed */
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (existed) {
|
|
256
|
+
this._reapedTotal += 1
|
|
257
|
+
if (emitExit) this.emit("exit", { stream_id, code })
|
|
258
|
+
}
|
|
259
|
+
return existed
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 現在保持している pty 本数と累計 spawn/reap をログ出力する (可観測性)。
|
|
264
|
+
* `leak_delta` は理論上常に 0 (held === spawned - reaped)。非 0 が続いたら異常。
|
|
265
|
+
*/
|
|
266
|
+
_logStats() {
|
|
267
|
+
const held = this.streams.size
|
|
268
|
+
this.logger?.info(
|
|
269
|
+
{
|
|
270
|
+
held,
|
|
271
|
+
spawned_total: this._spawnedTotal,
|
|
272
|
+
reaped_total: this._reapedTotal,
|
|
273
|
+
leak_delta: this._spawnedTotal - this._reapedTotal - held,
|
|
274
|
+
max_streams: this.maxStreams,
|
|
275
|
+
},
|
|
276
|
+
"pty stats",
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* 現在の保持本数・累計カウンタを返す (テスト / debug 用)。
|
|
282
|
+
* @returns {{held: number, spawnedTotal: number, reapedTotal: number}}
|
|
283
|
+
*/
|
|
284
|
+
stats() {
|
|
285
|
+
return {
|
|
286
|
+
held: this.streams.size,
|
|
287
|
+
spawnedTotal: this._spawnedTotal,
|
|
288
|
+
reapedTotal: this._reapedTotal,
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
168
292
|
/**
|
|
169
293
|
* stream の coalesce buffer を即時 flush して emit する。
|
|
170
294
|
* detach / exit / shutdown のタイミングで残バッファの取りこぼしを防ぐ。
|
|
@@ -195,6 +319,26 @@ export class PtyBridge extends EventEmitter {
|
|
|
195
319
|
if (this.streams.has(stream_id)) {
|
|
196
320
|
throw new Error(`stream_id "${stream_id}" は既に attach 済みです`)
|
|
197
321
|
}
|
|
322
|
+
// 安全弁: 上限到達時は最古 (lastSeenAt 最小) の stream を強制 reap してから
|
|
323
|
+
// spawn する。リークが残っても PTY 枯渇に至らせないハードリミット。
|
|
324
|
+
if (this.maxStreams > 0 && this.streams.size >= this.maxStreams) {
|
|
325
|
+
let oldestId = null
|
|
326
|
+
let oldestTs = Infinity
|
|
327
|
+
for (const sid of this.streams.keys()) {
|
|
328
|
+
const ts = this.lastSeenAt.get(sid) ?? 0
|
|
329
|
+
if (ts < oldestTs) {
|
|
330
|
+
oldestTs = ts
|
|
331
|
+
oldestId = sid
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (oldestId) {
|
|
335
|
+
this.logger?.warn(
|
|
336
|
+
{ stream_id, evicted: oldestId, held: this.streams.size, max_streams: this.maxStreams },
|
|
337
|
+
"pty cap exceeded: reaping oldest stream",
|
|
338
|
+
)
|
|
339
|
+
this._reap(oldestId, { emitExit: true, code: null })
|
|
340
|
+
}
|
|
341
|
+
}
|
|
198
342
|
const ctx = {
|
|
199
343
|
logger: this.logger,
|
|
200
344
|
sessionName,
|
|
@@ -224,40 +368,46 @@ export class PtyBridge extends EventEmitter {
|
|
|
224
368
|
}
|
|
225
369
|
|
|
226
370
|
this.streams.set(stream_id, pty)
|
|
371
|
+
this._spawnedTotal += 1
|
|
227
372
|
this._touch(stream_id)
|
|
228
373
|
this.logger?.info(
|
|
229
374
|
{ stream_id, sessionName, command: spec.command, plugin: hookResult?.plugin || null },
|
|
230
375
|
"pty attached",
|
|
231
376
|
)
|
|
232
377
|
|
|
378
|
+
// onData/onExit は IDisposable を返す。reap 時に dispose() するため保持する。
|
|
379
|
+
const disposables = []
|
|
233
380
|
if (this.coalesceMs > 0) {
|
|
234
381
|
const state = { buf: "", timer: null }
|
|
235
382
|
this.coalesceState.set(stream_id, state)
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
state.timer
|
|
240
|
-
state.timer =
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
383
|
+
disposables.push(
|
|
384
|
+
pty.onData((data) => {
|
|
385
|
+
state.buf += data
|
|
386
|
+
if (!state.timer) {
|
|
387
|
+
state.timer = setTimeout(() => {
|
|
388
|
+
state.timer = null
|
|
389
|
+
const out = state.buf
|
|
390
|
+
if (!out) return
|
|
391
|
+
state.buf = ""
|
|
392
|
+
this.emit("output", { stream_id, data: out })
|
|
393
|
+
}, this.coalesceMs)
|
|
394
|
+
}
|
|
395
|
+
}),
|
|
396
|
+
)
|
|
248
397
|
} else {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
398
|
+
disposables.push(
|
|
399
|
+
pty.onData((data) => {
|
|
400
|
+
this.emit("output", { stream_id, data })
|
|
401
|
+
}),
|
|
402
|
+
)
|
|
252
403
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
})
|
|
404
|
+
// 正常終了経路。全後始末を _reap に一元化 (flush → dispose → fd 解放 → exit emit)。
|
|
405
|
+
disposables.push(
|
|
406
|
+
pty.onExit(({ exitCode }) => {
|
|
407
|
+
this._reap(stream_id, { emitExit: true, code: exitCode })
|
|
408
|
+
}),
|
|
409
|
+
)
|
|
410
|
+
this.disposables.set(stream_id, disposables)
|
|
261
411
|
|
|
262
412
|
return {
|
|
263
413
|
plugin: hookResult?.plugin || null,
|
|
@@ -299,16 +449,20 @@ export class PtyBridge extends EventEmitter {
|
|
|
299
449
|
detach({ stream_id }) {
|
|
300
450
|
const pty = this.streams.get(stream_id)
|
|
301
451
|
if (!pty) return false
|
|
302
|
-
// kill 前に残バッファを emit しておく (
|
|
303
|
-
//
|
|
452
|
+
// kill 前に残バッファを emit しておく (detach は browser 側都合なので最新を
|
|
453
|
+
// 確実に届けたい)。
|
|
304
454
|
this._flushCoalesce(stream_id)
|
|
305
|
-
// GC tracking もここでクリア。pty.onExit でも消すが、onExit が来る前に
|
|
306
|
-
// 同じ stream_id が再 attach されると古い lastSeenAt が残るリスクを避ける。
|
|
307
|
-
this.lastSeenAt.delete(stream_id)
|
|
308
455
|
try {
|
|
309
456
|
pty.kill()
|
|
310
457
|
} catch {
|
|
311
|
-
/* ignore
|
|
458
|
+
/* ignore */
|
|
459
|
+
}
|
|
460
|
+
// kill が onExit を同期発火する実装 (stub 等) では上の kill 内で _reap 済み。
|
|
461
|
+
// 実 node-pty は onExit が非同期なので、onExit を待たず即 reap して master fd を
|
|
462
|
+
// 解放する (= onExit 取りこぼし時の永久 leak を防ぐ核心)。_reap は冪等なので、
|
|
463
|
+
// 後から非同期 onExit が来ても no-op になる。
|
|
464
|
+
if (this.streams.has(stream_id)) {
|
|
465
|
+
this._reap(stream_id, { emitExit: true, code: null })
|
|
312
466
|
}
|
|
313
467
|
return true
|
|
314
468
|
}
|
|
@@ -320,7 +474,7 @@ export class PtyBridge extends EventEmitter {
|
|
|
320
474
|
this._gcTimer = null
|
|
321
475
|
}
|
|
322
476
|
for (const stream_id of Array.from(this.streams.keys())) {
|
|
323
|
-
this.
|
|
477
|
+
this._reap(stream_id, { emitExit: true, code: null })
|
|
324
478
|
}
|
|
325
479
|
}
|
|
326
480
|
|
package/src/state.mjs
CHANGED
|
@@ -132,34 +132,67 @@ function footerRegion(text, lines = 8) {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* 現行 claude TUI の生成中フッターは語が毎ターン変わる (実機採取 2026-06-15):
|
|
135
|
+
* 作業スピナー行 (ライブステータス行) の構造パターン。実機採取 (2026-06-15):
|
|
138
136
|
* "· Blanching… (2m 4s · ↓ 7.5k tokens)" (出力中)
|
|
139
137
|
* "✽ Scurrying… (16m 55s · ↓ 66.8k tokens)" (出力中)
|
|
140
138
|
* "· Puzzling… (11m 12s · thinking)" (思考中・tokens 語が無い)
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
139
|
+
* = 行頭グリフ (·/✻/✽/✶… 1 文字) + 語 + 省略記号 + 括弧で囲まれたライブ経過タイマー。
|
|
140
|
+
* 語 (Blanching/Puzzling/Orbiting…) は無数にあり版で増減するため語リストにしない。行頭は
|
|
141
|
+
* 英数字・空白・本文の箇条書き記号 (-*>#|) を除く 1 文字に限定し、本文プローズ (英字始まり)
|
|
142
|
+
* や箇条書きへの誤マッチを防ぐ。完了サマリー "✻ Brewed for 2m 52s" は省略記号も括弧タイマー
|
|
143
|
+
* も持たないので一致しない。
|
|
144
|
+
*/
|
|
145
|
+
const SPINNER_LINE_RE =
|
|
146
|
+
/^\s{0,4}[^\sA-Za-z0-9\-*>#|]\s*[A-Za-z]+(?:…|\.\.\.)\s*\(\s*(?:\d+\s*m\s*)?\d+\s*s\b/
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* スピナー行 i の「下」が入力欄チロム (空行/罫線/❯ プロンプト/tips/字下げ継続/権限バナー)
|
|
150
|
+
* のみかを判定する。ライブのスピナーはペイン最下部の入力欄直上に描かれ、下には本文行が
|
|
151
|
+
* 来ない。一方、本文中に書かれた「スピナー行の引用」(解説テキスト・表セル・コードブロック等)
|
|
152
|
+
* は下に列 0 の本文行が続く。これを使って「未生成なのにローダーが点灯する」誤検出を防ぐ。
|
|
153
|
+
* 列 0 の本文行が下に在れば引用とみなし false。
|
|
154
|
+
*/
|
|
155
|
+
function _belowIsOnlyChrome(lines, i) {
|
|
156
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
157
|
+
const ln = lines[j]
|
|
158
|
+
if (ln.trim() === "") continue // 空行
|
|
159
|
+
if (/^\s*─{3,}\s*$/.test(ln)) continue // 入力欄の罫線
|
|
160
|
+
if (/^\s*[❯>]/.test(ln)) continue // 入力プロンプト
|
|
161
|
+
if (/^\s*⎿/.test(ln)) continue // tips / ツリー装飾行
|
|
162
|
+
if (/^\s{4,}\S/.test(ln)) continue // tips 折り返し等の字下げ継続
|
|
163
|
+
if (
|
|
164
|
+
/shift\+tab|← for|for shortcuts|accept edits|plan mode|auto mode|bypass permissions|new task\?|to save|auto-compact|context left/i.test(
|
|
165
|
+
ln,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
continue // 権限バナー / フッターのヒント
|
|
169
|
+
return false // 列 0 の本文行 = スピナー行の引用とみなす
|
|
170
|
+
}
|
|
171
|
+
return true
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 作業スピナーのフッター行を検出する (生成中=processing の補助シグナル)。
|
|
146
176
|
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
177
|
+
* 現行版は "esc to interrupt" をフッターに出さない (主シグナルが実機で死んでいる) ため、
|
|
178
|
+
* これが生成中検出の実効シグナルになる。フッター領域 (末尾 12 行) を下から走査し、次の
|
|
179
|
+
* いずれかに一致しかつ「下が入力欄チロムのみ (本文行の引用でない)」行があれば生成中とみなす:
|
|
180
|
+
* (a) スピナー構造行 (SPINNER_LINE_RE)。tokens の有無に依存しないので thinking も拾う。
|
|
181
|
+
* (b) "tokens" 語 + 経過秒の同一行同居 (回帰防止。"(↑ 3.4k tokens · 7s)" の語順も拾う)。
|
|
182
|
+
*
|
|
183
|
+
* 下チロム判定 (_belowIsOnlyChrome) が肝: スピナー行の構造は、本文に「生成中フッターは
|
|
184
|
+
* … (Ns) です」と書いた引用や表セルにも現れ得る。それらは下に本文行が続くため除外し、
|
|
185
|
+
* ペイン最下部の入力欄直上にあるライブのスピナーだけを生成中と判定する (誤点灯防止)。
|
|
154
186
|
*/
|
|
155
187
|
function detectWorkingSpinner(text) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
188
|
+
const lines = text.split("\n")
|
|
189
|
+
const start = Math.max(0, lines.length - 12)
|
|
190
|
+
for (let i = lines.length - 1; i >= start; i--) {
|
|
191
|
+
const line = lines[i]
|
|
192
|
+
const isSpinner = SPINNER_LINE_RE.test(line)
|
|
193
|
+
const isTokenFooter =
|
|
194
|
+
/\btokens\b/i.test(line) && /(?:^|[\s(])\d+\s*s\b/.test(line)
|
|
195
|
+
if ((isSpinner || isTokenFooter) && _belowIsOnlyChrome(lines, i)) return true
|
|
163
196
|
}
|
|
164
197
|
return false
|
|
165
198
|
}
|