@cocorograph/hub-agent 0.8.11 → 0.8.13

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/bin/hub-agent.mjs CHANGED
@@ -11,6 +11,12 @@
11
11
  * - stop : daemon 停止 (Sprint J で launchd / systemd と併用)
12
12
  * - plugins list : プラグイン一覧
13
13
  */
14
+ // R3(d): libuv スレッドプールを拡張する。サイドバー snapshot の全セッション jsonl 読み込みが、
15
+ // attach / history の fs と既定 4 スレッドのプールを奪い合って切替が体感で遅くなる。libuv は
16
+ // プール初回利用時に UV_THREADPOOL_SIZE を読むため、いかなる fs/dns/crypto 非同期処理より前
17
+ // (= プロセス最初期・import より前) に設定する。既に設定済みなら運用者の指定を尊重する。
18
+ if (!process.env.UV_THREADPOOL_SIZE) process.env.UV_THREADPOOL_SIZE = "16"
19
+
14
20
  import { readFileSync } from "node:fs"
15
21
  import { fileURLToPath } from "node:url"
16
22
  import path from "node:path"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.11",
3
+ "version": "0.8.13",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -523,11 +523,12 @@ export class ClaudeStreamSession {
523
523
  this._permissionResolvers = new Map()
524
524
  /** 現在ターン実行中か (多重 query 防止) */
525
525
  this._busy = false
526
- /** SDK-P0-3 (2026-07-10): 現ターンが既に終端処理済みか (idle / abort の
526
+ /** SDK-P0-3 (2026-07-10): 現ターンが既に終端処理済みか (result / idle / abort の
527
527
  * いずれか 1 回だけ _busy=false + drain させるための冪等ガード)。resident query は
528
528
  * `result` の後にもバックグラウンド処理が続き、最後に権威的な
529
- * `session_state_changed:idle` が流れる。通常完了では idle だけが settle し、
530
- * 明示 interrupt だけが先行して settled を立てる。ターン開始時に false へ戻す。 */
529
+ * `session_state_changed:idle` が流れることがある。実 SDK では result 後に idle が
530
+ * 来ないターンもあるため、result / idle の先着 1 件だけが settle し、遅延 idle は
531
+ * _staleIdleExpected で次ターンから隔離する。ターン開始時に false へ戻す。 */
531
532
  this._turnSettled = false
532
533
  /** SDK-P0-3: 現ターンが 1 件でもストリームイベントを出したか。
533
534
  * 「前ターンの遅延 idle が、drain で始まったばかりの次ターンを誤終了させる」レースの
@@ -1630,10 +1631,11 @@ export class ClaudeStreamSession {
1630
1631
  }
1631
1632
  }
1632
1633
 
1633
- /** resident の権威的な「ターン終端」を 1 箇所に集約する。
1634
- * 通常完了は SDK の session_state_changed:idle だけが呼ぶ。result は最終結果の
1635
- * flush であり、バックグラウンド処理を含むターン全体の終了境界ではない。
1636
- * @param {string} reason "idle" — ログ用
1634
+ /** resident の「ターン終端」を 1 箇所に集約する。
1635
+ * SDK は result 後に session_state_changed:idle を返すことがあるが、実環境では
1636
+ * idle が欠落するターンもある。result / idle の先着を終端とし、result で畳んだ後の
1637
+ * 遅延 idle はガードで次ターンから隔離する。
1638
+ * @param {string} reason "result" | "idle" — ログ用
1637
1639
  * @param {(msg: string) => void} [denyPending] 未解決 permission を閉じるクロージャ
1638
1640
  */
1639
1641
  _settleResidentTurn(reason, denyPending) {
@@ -1641,6 +1643,10 @@ export class ClaudeStreamSession {
1641
1643
  this._turnSettled = true
1642
1644
  denyPending?.("turn ended")
1643
1645
  this._busy = false
1646
+ // result の後に同一ターンの idle が遅れて届く場合がある。pending drain は同期的に
1647
+ // 次ターンの busy を立てるため、その idle を新ターンへ誤帰属させないよう先にガードする。
1648
+ // idle 自身で畳んだ場合は既に消費済みなので張らない。
1649
+ if (reason === "result") this._armStaleIdleGuard()
1644
1650
  // 改修4 (A): シリアライズした pending があれば次の 1 件を InputQueue へ流す。
1645
1651
  // ultracode (0.6.28): _drainResidentPending は applyFlagSettings を await するため
1646
1652
  // async。for await ループ内からは fire-and-forget で呼ぶ (最初の await より前に
@@ -1714,6 +1720,12 @@ export class ClaudeStreamSession {
1714
1720
  // 正規の中断は interrupt() だが、control request が失敗/無応答のときに generator を畳む
1715
1721
  // 最終手段が無いと、close()/shutdown() で CLI 子プロセスが残留する。
1716
1722
  this._residentAbortController = new AbortController()
1723
+ // R1(a): この run の AbortController を捕捉する。finally で「常駐 generator が返却された理由」を
1724
+ // 判別するのに使う。我々が意図的に畳んだ (close / interrupt フォールバックの keep-alive
1725
+ // hard-abort) ときだけ signal.aborted=true になる。子プロセスが自然終了 (クラッシュ/異常終了)
1726
+ // したときは我々は abort していないので false。this.* 参照だと後続 run が再代入した別
1727
+ // controller を読んでしまうため、必ずこの run 固有のローカル参照で判定する。
1728
+ const myAbort = this._residentAbortController
1717
1729
  const options = {
1718
1730
  cwd: this.cwd,
1719
1731
  env: siblingEnvFor(this.cwd),
@@ -1766,12 +1778,12 @@ export class ClaudeStreamSession {
1766
1778
  }
1767
1779
  if (msg?.type === "result") {
1768
1780
  if (typeof msg.session_id === "string") this.sessionId = msg.session_id
1769
- // result は結果 flush でありターン終了ではない。SDK の型定義が権威的な
1770
- // turn-over signal とする session_state_changed:idle まで busy を維持し、
1771
- // pending を streaming input へ流さない。
1772
1781
  // 現ターンの result が届いた時点で前ターンの遅延 idle は順序保証により
1773
- // 既に通過済みなので、interrupt 由来のガードだけ解除できる。
1782
+ // 既に通過済み。古いガードを解除し、result 用のガードを張り直して settle する。
1783
+ // 実 SDK では result 後に idle が来ないターンがあるため、idle 待ちだけにすると
1784
+ // _busy が永久に true のまま固着し、以後の入力が pending から drain されない。
1774
1785
  this._clearStaleIdleGuard()
1786
+ this._settleResidentTurn("result", denyPending)
1775
1787
  } else if (isSessionIdleEvent(msg)) {
1776
1788
  // SDK-P0-3 (2026-07-10): result が届かずに idle でターンが終わるケースの救済。
1777
1789
  // これを見ていなかったため _busy が true で固着し、以降の送信が
@@ -1876,6 +1888,40 @@ export class ClaudeStreamSession {
1876
1888
  next.ultracode === true,
1877
1889
  )
1878
1890
  this._emitQueueState([next.text], { startedIds: [next.id] })
1891
+ } else if (
1892
+ !this._closed &&
1893
+ !this._residentSendInFlight &&
1894
+ !myAbort?.signal?.aborted
1895
+ ) {
1896
+ // R1(a) 孤児根絶: detach-reap でも pending 再起動でもなく、我々の意図的 fold
1897
+ // (close / interrupt フォールバックの keep-alive hard-abort = myAbort.signal.aborted)
1898
+ // でもないのに常駐 generator が返却された = 子プロセスが自然終了した (クラッシュ/異常終了)。
1899
+ // _residentSendInFlight 中 (ABA/FIFO: 起動済み query の送信が reconcile を await 中) は
1900
+ // await 復帰後の送信が fresh-start で回復させるため除外済み。
1901
+ if (this._detached) {
1902
+ // browser が離れた warm セッションで子が死亡 = 真の孤児。誰も見ておらず、死んだ query は
1903
+ // R1(b) で reattach 対象にもならないため warm 保持する意味が無い。即 close + onReap で
1904
+ // _liveBySession から evict し browser へ exit を通知する (既存 reap 経路と同じ冪等処理)。
1905
+ // 従来はこの経路が無く、warm 中に子が死ぬと _closed=false のまま索引に残って孤児化し、
1906
+ // 枠を占有し続け reattach が死んだ session を引き当てていた。
1907
+ try {
1908
+ this.close()
1909
+ } finally {
1910
+ this.onReap?.({ reason: "child-exited" })
1911
+ }
1912
+ } else {
1913
+ // まだ browser attach 中: 既存の「次 input で resume 付き再起動」recovery (改修4/B6) を
1914
+ // 壊さないため evict しない (即 evict すると同一 stream への継続入力が stream_missing で
1915
+ // 落ちる)。ただし終端シグナルが無いとサイドバーの処理中ドット / UI「停止中…」が固着する
1916
+ // ため、abort 経路と対称に onTurnSettled を出して即時解除する。次 input が来なければ
1917
+ // detach 時の softDetach idle-timer / warm-bounds が回収し、R1(b) が死んだ session への
1918
+ // reattach を防ぐ。
1919
+ try {
1920
+ this.onTurnSettled?.()
1921
+ } catch {
1922
+ /* ignore */
1923
+ }
1924
+ }
1879
1925
  }
1880
1926
  // マスク (2026-06-23): 常駐 query 終了後 (= CLI が jsonl を掴んでいない) のこの地点で、
1881
1927
  // closed 済みならオンディスク jsonl をマスクする。reap 経路は close() 内で実行済み。
@@ -2043,6 +2089,18 @@ export class ClaudeStreamSession {
2043
2089
  return true
2044
2090
  }
2045
2091
 
2092
+ /** R1(b): このセッションへ resume 再アタッチしてよいか。close 済み、または常駐 generator が
2093
+ * 返却済み (_residentQueryReturned) で再走行していない (_residentQuery=null) = 子プロセスが
2094
+ * 死亡した状態には reattach しない (attach 側が新規 spawn へフォールバックする)。R1(a) で
2095
+ * 死亡時は同期 close されるので通常は _closed で弾かれるが、close 到達前の窓や将来経路でも
2096
+ * 死んだ query へ再アタッチしない二重ガードとして効かせる。まだ一度も query を起動していない
2097
+ * idle セッション (_residentQueryReturned=false) は生存中なので reattach 可。 */
2098
+ _isReattachable() {
2099
+ if (this._closed) return false
2100
+ if (this._residentQueryReturned && !this._residentQuery) return false
2101
+ return true
2102
+ }
2103
+
2046
2104
  /** セッション終了。新規ターンを止め、実行中なら中断し、watcher も停止する。 */
2047
2105
  close() {
2048
2106
  this._closed = true
@@ -2255,7 +2313,9 @@ export class ClaudeStreamBridge extends EventEmitter {
2255
2313
  // attach しても引き当たらず、越境 reattach が原理的に起きない (session_id は本来
2256
2314
  // ユニークだが異常系でも cwd を跨がない)。
2257
2315
  const live = this._liveBySession.get(this._liveKeyFor(scwd, resumeSessionId))
2258
- if (live && !live._closed) {
2316
+ // R1(b): !_closed だけでなく「常駐子プロセスが実生存しているか」も確認する。子が死んで
2317
+ // いれば reattach せず下の新規 spawn 経路へフォールバックし、死んだ session を引き当てない。
2318
+ if (live && live._isReattachable()) {
2259
2319
  // 多端末共有 (T06542 で常時): 旧 stream_id を Map から消さない。同一セッションを複数端末が
2260
2320
  // 購読する状態を作り、どの端末の input/permission/interrupt も解決できるようにする
2261
2321
  // (旧端末は reattach.streamIds に足され、backend が session group へ fan-out する)。
@@ -2282,7 +2342,12 @@ export class ClaudeStreamBridge extends EventEmitter {
2282
2342
  },
2283
2343
  "claude stream reattached to live session",
2284
2344
  )
2285
- return { stream_id, resuming: true, reattached: true }
2345
+ return {
2346
+ stream_id,
2347
+ resuming: true,
2348
+ reattached: true,
2349
+ turn_active: live._busy === true,
2350
+ }
2286
2351
  }
2287
2352
  }
2288
2353
  const session = new ClaudeStreamSession({
@@ -2355,14 +2420,15 @@ export class ClaudeStreamBridge extends EventEmitter {
2355
2420
  error: err?.message || String(err),
2356
2421
  })
2357
2422
  },
2358
- onReap: () => {
2359
- // ターン完走後の遅延クローズ。Map / 索引から撤去し exit を emit する。
2360
- // 多端末共有では複数端末キーが Map に載っているのでまとめて外す。
2423
+ onReap: (info) => {
2424
+ // ターン完走後の遅延クローズ、または R1(a) 子プロセス死亡時の即 evict。Map / 索引から
2425
+ // 撤去し exit を emit する。多端末共有では複数端末キーが Map に載っているのでまとめて外す。
2426
+ // reason は既定「detached-after-turn」。R1(a) は「child-exited」を渡して観測可能にする。
2361
2427
  this._dropSessionMappings(session)
2362
2428
  this.emit("exit", {
2363
2429
  stream_id: session.stream_id,
2364
2430
  code: 0,
2365
- reason: "detached-after-turn",
2431
+ reason: info?.reason || "detached-after-turn",
2366
2432
  session_id: session.sessionId,
2367
2433
  })
2368
2434
  },
@@ -2395,7 +2461,11 @@ export class ClaudeStreamBridge extends EventEmitter {
2395
2461
  )
2396
2462
  // per-message モデルでは attach 時点では query() を起動しない (即 ready)。
2397
2463
  // 最初の input で query を起動する。
2398
- return { stream_id, resuming: !!resumeSessionId }
2464
+ return {
2465
+ stream_id,
2466
+ resuming: !!resumeSessionId,
2467
+ turn_active: session._busy === true,
2468
+ }
2399
2469
  }
2400
2470
 
2401
2471
  /**
@@ -3237,13 +3307,32 @@ export class ClaudeStreamBridge extends EventEmitter {
3237
3307
  return at
3238
3308
  }
3239
3309
 
3310
+ /** R1(c): このセッションを現在ライブに購読している端末数 (sessions Map で自分を指す stream key 数)。
3311
+ * 正規の多端末共有 (T06542) では 1 つの session を複数 stream_id が指す。単端末なら 1、
3312
+ * soft-detach 済み warm なら 0。 */
3313
+ _liveTerminalCount(session) {
3314
+ if (!session) return 0
3315
+ let n = 0
3316
+ for (const sid of session.streamIds || []) {
3317
+ if (this.sessions.get(sid) === session) n += 1
3318
+ }
3319
+ return n
3320
+ }
3321
+
3240
3322
  /** 子⑤ round5 Finding 2 対応: warm 境界で reap してはいけない「in-flight」セッション。
3241
3323
  * 走行中ターン(_busy)に加え、送信待ちの pending がある(現ターン完了時に drain される)
3242
- * セッションも保護する(reap すると未処理入力が失われる)。 */
3324
+ * セッションも保護する(reap すると未処理入力が失われる)。
3325
+ * R1(c) 多ウィンドウ相互 reap 抑止: 2 端末以上が現在ライブ購読中の多端末共有セッション
3326
+ * (T06542) も保護する。同一 cwd で別 session_id を開いた新規 1 本を残すために、複数
3327
+ * ウィンドウが見ている共有セッションを per-cwd=1 / cwd-LRU が刈ってしまう事故を防ぐ
3328
+ * (単端末の重複セッション collapse は従来どおり働く: 単端末は count=1 で非保護)。 */
3243
3329
  _isSessionProtected(session) {
3244
3330
  if (!session) return false
3245
3331
  if (session._busy) return true
3246
- return Array.isArray(session._pendingMessages) && session._pendingMessages.length > 0
3332
+ if (Array.isArray(session._pendingMessages) && session._pendingMessages.length > 0) {
3333
+ return true
3334
+ }
3335
+ return this._liveTerminalCount(session) >= 2
3247
3336
  }
3248
3337
 
3249
3338
  /** 子⑤: warm 境界超過セッションを reap する(detach の softDetach onTimeout と同じ撤去処理)。 */
package/src/main.mjs CHANGED
@@ -3433,6 +3433,10 @@ async function dispatch(msg, ctx) {
3433
3433
  type: "claude.ready",
3434
3434
  stream_id,
3435
3435
  resuming: info.resuming,
3436
+ // 再接続時の生成状態は resident session を保持する hub-agent が正本。
3437
+ // jsonl 履歴末尾の推測だけでは「実際は busy なのに UI は idle」となり、
3438
+ // 送信ボタンが楽観バブルを作る一方 agent はキューへ積む二重表示が起きる。
3439
+ turn_active: info.turn_active === true,
3436
3440
  history_pushed: resumeSessionId !== null,
3437
3441
  // id 空間統一 (2026-07-08): この agent は browser の input_id をキュー id として
3438
3442
  // 採用し、楽観送信待ちアイテムの確認前キャンセルに対応することを通知する capability。
@@ -182,7 +182,7 @@ async function findCodexRolloutFile(threadId, sessionsDir) {
182
182
  return null
183
183
  }
184
184
 
185
- async function readCached(filePath, extract, { maxAgeMs = Infinity } = {}) {
185
+ async function readCached(filePath, extract) {
186
186
  if (!filePath) return { user: "", assistant: "" }
187
187
  let fileStat
188
188
  try {
@@ -192,11 +192,15 @@ async function readCached(filePath, extract, { maxAgeMs = Infinity } = {}) {
192
192
  }
193
193
  const version = `${fileStat.mtimeMs}:${fileStat.size}:${fileStat.ctimeMs}`
194
194
  const cached = textCache.get(filePath)
195
- if (
196
- cached &&
197
- cached.version === version &&
198
- Date.now() - cached.checkedAt < maxAgeMs
199
- ) {
195
+ if (cached && cached.version === version) {
196
+ // mtime/size/ctime 未変化 = jsonl 本文不変。tail 読み + パースを丸ごとスキップし、
197
+ // 前回抽出済みテキストをそのまま返す (R3(a) mtime 差分キャッシュ)。stat 1 発だけで済む。
198
+ // 旧実装は maxAgeMs=2s の age ゲートで、8s 周期のサイドバー snapshot が毎回 (2s 超過で)
199
+ // 未変化ファイルまで再抽出し、attach/history の fs と libuv プールを奪い合っていた。
200
+ // LRU 順を保つため hot エントリを末尾へ入れ直す (最古から evict される側から救う)。
201
+ cached.checkedAt = Date.now()
202
+ textCache.delete(filePath)
203
+ textCache.set(filePath, cached)
200
204
  return cached.texts
201
205
  }
202
206
 
@@ -287,10 +291,8 @@ export async function readSidebarMessageSnapshot(
287
291
  session_id: target.conversationId,
288
292
  projectsRoot,
289
293
  })
290
- texts = await readCached(
291
- filePath,
292
- (fp) => extractClaude(fp, { isInjected }),
293
- { maxAgeMs: 2_000 },
294
+ texts = await readCached(filePath, (fp) =>
295
+ extractClaude(fp, { isInjected }),
294
296
  )
295
297
  return {
296
298
  session_name: target.sessionName,
@@ -309,10 +311,8 @@ export async function readSidebarMessageSnapshot(
309
311
  const { sessionsDir, profileId } = await resolveCodexRoot(target.profileId)
310
312
  if (!texts.user || !texts.assistant) {
311
313
  const filePath = await findCodexRolloutFile(target.conversationId, sessionsDir)
312
- const diskTexts = await readCached(
313
- filePath,
314
- (fp) => extractCodex(fp, { isInjected }),
315
- { maxAgeMs: 2_000 },
314
+ const diskTexts = await readCached(filePath, (fp) =>
315
+ extractCodex(fp, { isInjected }),
316
316
  )
317
317
  texts = {
318
318
  user: texts.user || diskTexts.user,