@cocorograph/hub-agent 0.6.85 → 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 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.85",
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 経由インストールだと node-pty/prebuilds/*/spawn-helper の実行権限が
3
- // 剥がれて posix_spawnp が失敗する事象への workaround.
4
- // postinstall で確実に +x を付与する。
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
- const FALLBACK = "node_modules/node-pty/prebuilds";
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
- if (!entry.startsWith("node-pty@")) continue;
34
- const p = join(PNPM_GLOB, entry, "node_modules/node-pty/prebuilds");
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
- try {
49
- statSync(FALLBACK);
50
- fixed += fixDir(FALLBACK);
51
- } catch { /* not hoisted */ }
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 未インストール。サイレント終了。
@@ -512,10 +512,12 @@ _install_node_lts_linux() {
512
512
  color_ok "node $(node --version 2>/dev/null || echo '?') を導入"
513
513
  }
514
514
 
515
- # ネイティブアドオン (node-pty 等) のビルドに必要な make / g++ / python3 を導入する。
516
- # node-pty prebuild が無いと node-gyp rebuild にフォールバックするが、クリーンな
517
- # Ubuntu には build-essential が無く `gyp ERR! not found: make` で失敗する
518
- # (WSL クリーン Ubuntu で発覚)。macOS Xcode CLT 前提なので何もしない。
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
- const resolvedPty = ptyModule || (await import("node-pty"))
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 モードのみ無効化)。
@@ -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(() => this._gcStaleStreams(), this.gcIntervalMs)
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 を一括 detach する。
171
+ * `gcStaleMs` を超えて idle な stream を一括 reap する。
147
172
  *
148
173
  * `_gcTimer` から `gcIntervalMs` 周期で呼ばれる。手動でも呼べる (テスト用)。
149
- * @returns {string[]} kill した stream_id 一覧 (debug / test 用)
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 [stream_id, ts] of this.lastSeenAt) {
156
- if (ts < cutoff && this.streams.has(stream_id)) {
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.detach({ stream_id })
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
- pty.onData((data) => {
237
- state.buf += data
238
- if (!state.timer) {
239
- state.timer = setTimeout(() => {
240
- state.timer = null
241
- const out = state.buf
242
- if (!out) return
243
- state.buf = ""
244
- this.emit("output", { stream_id, data: out })
245
- }, this.coalesceMs)
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
- pty.onData((data) => {
250
- this.emit("output", { stream_id, data })
251
- })
398
+ disposables.push(
399
+ pty.onData((data) => {
400
+ this.emit("output", { stream_id, data })
401
+ }),
402
+ )
252
403
  }
253
- pty.onExit(({ exitCode }) => {
254
- // 残バッファを取りこぼさないよう flush してから exit を emit する。
255
- this._flushCoalesce(stream_id)
256
- this.coalesceState.delete(stream_id)
257
- this.streams.delete(stream_id)
258
- this.lastSeenAt.delete(stream_id)
259
- this.emit("exit", { stream_id, code: exitCode })
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 しておく (onExit にも flush はあるが、
303
- // detach は browser 側都合なので最新を確実に届けたい)。
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 — onExit で Map から消える */
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.detach({ stream_id })
477
+ this._reap(stream_id, { emitExit: true, code: null })
324
478
  }
325
479
  }
326
480