@cocorograph/hub-agent 0.6.28 → 0.6.30

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.6.28",
3
+ "version": "0.6.30",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -20,7 +20,8 @@
20
20
  "start": "node bin/hub-agent.mjs start",
21
21
  "test": "node --test test/*.test.mjs",
22
22
  "postinstall": "node scripts/fix-node-pty-perms.mjs",
23
- "prepublishOnly": "npm test"
23
+ "check:publish-on-main": "node scripts/check-publish-on-main.mjs",
24
+ "prepublishOnly": "node scripts/check-publish-on-main.mjs && npm test"
24
25
  },
25
26
  "files": [
26
27
  "bin/",
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // パブリッシュ前ガード: いま publish しようとしている HEAD が origin/main に
3
+ // マージ済みであることを確認する。
4
+ //
5
+ // 背景 (恒久対策): hub-agent には CI/CD デプロイフローが無く、publish は手元から
6
+ // 手動で行う。過去に feature/ローカルブランチから直接 publish した結果、main の
7
+ // バージョンが publish 済みより古いまま取り残され (例: main=0.6.26 なのに 0.6.28 が
8
+ // publish 済み)、次にその古い main から派生したブランチで「バージョン巻き戻り」が
9
+ // 発生した。これを防ぐため「publish する前に必ず main へマージする」を機械的に強制する。
10
+ //
11
+ // ルール: npm publish (= prepublishOnly) 時に、HEAD のコミットが origin/main から
12
+ // 到達可能 (= main にマージ済み) かつ作業ツリーがクリーンでなければ exit 1 で止める。
13
+ //
14
+ // 緊急時の回避: どうしても main 未マージで publish する必要がある場合のみ
15
+ // ALLOW_PUBLISH_OFF_MAIN=1 npm publish
16
+ // で明示的にバイパスできる (記録が残るよう env を必須にしている)。
17
+
18
+ import { execFileSync } from "node:child_process"
19
+
20
+ const GREEN = "\x1b[32m"
21
+ const RED = "\x1b[31m"
22
+ const YELLOW = "\x1b[33m"
23
+ const RESET = "\x1b[0m"
24
+
25
+ /**
26
+ * git コマンドを同期実行して trim 済み stdout を返す。
27
+ *
28
+ * @param {string[]} args git に渡す引数
29
+ * @param {{ allowFail?: boolean }} [opts] allowFail=true なら失敗時に null を返す
30
+ * @returns {string | null} stdout (trim 済み)、失敗かつ allowFail なら null
31
+ */
32
+ function git(args, opts = {}) {
33
+ try {
34
+ return execFileSync("git", args, {
35
+ encoding: "utf8",
36
+ stdio: ["ignore", "pipe", "ignore"],
37
+ }).trim()
38
+ } catch (err) {
39
+ if (opts.allowFail) return null
40
+ throw err
41
+ }
42
+ }
43
+
44
+ function fail(message) {
45
+ console.error(`${RED}✖ publish ガード: ${message}${RESET}`)
46
+ console.error(
47
+ `${YELLOW} → 正しい手順: PR を origin/main にマージ → main を pull → ` +
48
+ `main 上で npm publish。${RESET}`,
49
+ )
50
+ console.error(
51
+ `${YELLOW} → 緊急回避 (記録が残ります): ` +
52
+ `ALLOW_PUBLISH_OFF_MAIN=1 npm publish${RESET}`,
53
+ )
54
+ process.exit(1)
55
+ }
56
+
57
+ function main() {
58
+ if (process.env.ALLOW_PUBLISH_OFF_MAIN === "1") {
59
+ console.warn(
60
+ `${YELLOW}⚠ ALLOW_PUBLISH_OFF_MAIN=1: main マージ確認をバイパスして ` +
61
+ `publish します (緊急回避)。${RESET}`,
62
+ )
63
+ return
64
+ }
65
+
66
+ // git リポジトリ外なら判定不能 → 安全側で止める。
67
+ const inRepo = git(["rev-parse", "--is-inside-work-tree"], { allowFail: true })
68
+ if (inRepo !== "true") {
69
+ fail("git リポジトリ内で実行されていません (main マージ確認ができません)。")
70
+ }
71
+
72
+ // 作業ツリーがクリーンか (未コミットの変更を含んだまま publish しない)。
73
+ const dirty = git(["status", "--porcelain"], { allowFail: true })
74
+ if (dirty) {
75
+ fail(
76
+ "作業ツリーに未コミットの変更があります。コミット & main マージしてから " +
77
+ "publish してください。",
78
+ )
79
+ }
80
+
81
+ // origin/main を最新化する (オフライン時はローカルの追跡参照で判定)。
82
+ const fetched = git(["fetch", "origin", "main", "--quiet"], {
83
+ allowFail: true,
84
+ })
85
+ if (fetched === null) {
86
+ console.warn(
87
+ `${YELLOW}⚠ origin/main の fetch に失敗しました。ローカルの ` +
88
+ `origin/main 参照で判定します (古い可能性あり)。${RESET}`,
89
+ )
90
+ }
91
+
92
+ const mainRef = git(["rev-parse", "--verify", "origin/main"], {
93
+ allowFail: true,
94
+ })
95
+ if (!mainRef) {
96
+ fail("origin/main が見つかりません (remote 設定を確認してください)。")
97
+ }
98
+
99
+ // HEAD が origin/main から到達可能か = main にマージ済みか。
100
+ const head = git(["rev-parse", "HEAD"])
101
+ const isMerged =
102
+ git(["merge-base", "--is-ancestor", "HEAD", "origin/main"], {
103
+ allowFail: true,
104
+ }) !== null
105
+ // merge-base --is-ancestor は exit code で結果を返す (0=ancestor / 1=not)。
106
+ // allowFail で 1 のとき null になるため、null=未マージと判定する。
107
+
108
+ if (!isMerged) {
109
+ const headShort = head.slice(0, 8)
110
+ const mainShort = mainRef.slice(0, 8)
111
+ fail(
112
+ `現在の HEAD (${headShort}) は origin/main (${mainShort}) に未マージです。` +
113
+ "publish は main にマージしてから行ってください。",
114
+ )
115
+ }
116
+
117
+ console.log(
118
+ `${GREEN}✔ publish ガード: HEAD は origin/main にマージ済み。publish を続行します。${RESET}`,
119
+ )
120
+ }
121
+
122
+ main()
@@ -62,6 +62,14 @@ const CHAT_RESIDENT_RESUME_ENABLED =
62
62
  * 単一 stream_id 挙動と完全に一致する (this.streamIds は未使用のまま)。 */
63
63
  const CHAT_SHARED_ENABLED = process.env.HUB_AGENT_CHAT_SHARED === "1"
64
64
 
65
+ /** B11: 多端末共有時、一定期間 input/permission 等の活性が無い購読端末キーを死端末とみなして
66
+ * GC する閾値 (ミリ秒)。端末がクラッシュして claude.detach を送らず消えると streamIds /
67
+ * sessions に stream_id が永久残留し、最後の 1 台が外れないと idle softDetach が起動しない。
68
+ * 正常に開いている端末は input が無くても WS heartbeat 等で活性更新されないため、閾値は
69
+ * 長め (既定 1 時間) にして「明らかに死んだ」端末のみを掃除する。env で調整可能。 */
70
+ const DEAD_TERMINAL_TTL_MS =
71
+ Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) || 60 * 60 * 1000
72
+
65
73
  /** 文字列を SDK streaming input の SDKUserMessage に包む。
66
74
  * SDKUserMessage は parent_tool_use_id: string|null が必須フィールド。現行 SDK は入力側で
67
75
  * 寛容なので省略しても動くが、将来の型厳格化に備えて明示する。トップレベルのユーザー入力
@@ -153,6 +161,12 @@ class ClaudeStreamSession {
153
161
  * 参照され、reattach で増え detach で減る。最後の 1 台が外れると idle softDetach に入る。
154
162
  * this.stream_id は「最も新しく attach した端末」= legacy emit / primary 用に残す。 */
155
163
  this.streamIds = new Set([stream_id])
164
+ /** B11: 購読端末ごとの最終アクティビティ時刻 (epoch ms)。attach / reattach / input /
165
+ * permission 応答で更新する。CHAT_SHARED_ENABLED 時に「死端末 GC」が参照し、一定期間
166
+ * 無活動の購読端末キーを掃除する。これが無いと端末がクラッシュして claude.detach を
167
+ * 送らず消えると stream_id が streamIds/sessions に永久残留し、最後の 1 台が外れないと
168
+ * idle softDetach が起動しないため死端末がセッションを永久に生かしてしまう。 */
169
+ this.lastActivityByStream = new Map([[stream_id, Date.now()]])
156
170
  this.cwd = cwd
157
171
  this.model = model || null
158
172
  this.permissionMode = permissionMode || null
@@ -295,6 +309,8 @@ class ClaudeStreamSession {
295
309
  const r = this._permissionResolvers.get(request_id)
296
310
  if (!r) return false
297
311
  this._permissionResolvers.delete(request_id)
312
+ // B11: 現 primary 端末からの permission 応答を活性として記録する。
313
+ this.touch(this.stream_id)
298
314
  r.resolve(decision)
299
315
  return true
300
316
  }
@@ -315,12 +331,26 @@ class ClaudeStreamSession {
315
331
  // 多端末共有: この端末を購読集合に追加 (無効時は未使用)。旧端末の stream_id は
316
332
  // bridge.attach 側で sessions Map に残されるため、ここでは増やすだけでよい。
317
333
  this.streamIds.add(stream_id)
334
+ this.touch(stream_id) // B11: 死端末 GC 用の最終アクティビティ更新
318
335
  this._detached = false
319
336
  if (this._idleTimer) {
320
337
  clearTimeout(this._idleTimer)
321
338
  this._idleTimer = null
322
339
  }
323
340
  if (opts) this.applyRuntimeOptions(opts)
341
+ // キュー再表示バグ修正 (0.6.30): 再アタッチした端末は queue_state のライブ配信を
342
+ // 取りこぼしている (jsonl hydrate にはキュー状態が含まれない)。現在の pending を
343
+ // force で再 emit し、再表示端末・後から接続した端末の送信待ちチップを復元する。
344
+ // started=[] なのでバブル昇格は起きずチップ更新のみ (冪等)。onEvent は新しい
345
+ // stream_id 宛の stream_group relay で確実に届き、session_group fanout で他端末にも届く。
346
+ this._emitQueueState([], { force: true })
347
+ }
348
+
349
+ /** B11: 端末の最終アクティビティ時刻を更新する (死端末 GC の生存判定に使う)。 */
350
+ touch(stream_id) {
351
+ if (stream_id && this.streamIds.has(stream_id)) {
352
+ this.lastActivityByStream.set(stream_id, Date.now())
353
+ }
324
354
  }
325
355
 
326
356
  /** 改修5: モデル/権限/拡張思考をランタイムに切り替える。
@@ -464,7 +494,15 @@ class ClaudeStreamSession {
464
494
 
465
495
  // 改修2+4: 常駐query対象セッション。
466
496
  if (this._residentEligible) {
467
- if (!this._inputQueue) this._inputQueue = new InputQueue()
497
+ // B6: 死亡後の ()起動経路。既に query を起動した実績があり (_residentStarted)、かつ
498
+ // 現在 query が無い (_residentQuery=null = 異常終了済み) 場合、既存 _inputQueue は前 query で
499
+ // consume し切った (generator return 済み) インスタンス。同じものを次の sdk.query に渡すと
500
+ // _q/_wake の残留が新 generator と競合し得るため、push 前に必ず作り直す。
501
+ if (this._residentStarted && !this._residentQuery) {
502
+ this._inputQueue = new InputQueue()
503
+ } else if (!this._inputQueue) {
504
+ this._inputQueue = new InputQueue()
505
+ }
468
506
  // 改修4 (A): ターンのシリアライズ。実行中ターン (busy) は InputQueue へ即 push せず
469
507
  // pending へ退避し、現ターンの result 後に 1 件ずつ drain する。前ターン未完了のまま
470
508
  // 次を push すると SDK streaming-input で割り込み扱いになり得るため (公式 interrupt 警告)。
@@ -679,13 +717,18 @@ class ClaudeStreamSession {
679
717
  * @param {string[]} [started] このタイミングで pending から取り出して実行開始した
680
718
  * メッセージ本文。drain 由来の emit でのみ渡す。frontend はこれを user バブルへ
681
719
  * 昇格させる。キャンセル / 追加由来の emit では空 (昇格させない)。これにより
682
- * 「先頭の pending をキャンセルした」のを「実行開始した」と誤認しなくなる (0.6.26)。 */
683
- _emitQueueState(started = []) {
720
+ * 「先頭の pending をキャンセルした」のを「実行開始した」と誤認しなくなる (0.6.26)。
721
+ * @param {{force?: boolean}} [opts] force=true のとき署名重複チェックを無視して必ず
722
+ * emit する。再アタッチ時のキュー snapshot 再送 (キュー再表示バグ修正, 0.6.30) に使う。
723
+ * 署名が前回と同一でも browser がライブ配信を取りこぼしている可能性があるため。 */
724
+ _emitQueueState(started = [], opts = undefined) {
725
+ const force = opts?.force === true
684
726
  const count = this._pendingMessages.length
685
727
  // 署名 = 件数 + id 列。件数が同じでもキャンセルで中身が変われば通知する。
686
728
  const sig = `${count}:${this._pendingMessages.map((m) => m.id).join(",")}`
687
729
  // started があるときは drain なので、sig 変化が無くても (理論上起きないが) 通知する。
688
- if (started.length === 0 && sig === this._lastEmittedQueueSig) return
730
+ // force のときは再アタッチ snapshot なので重複チェックを完全にバイパスする。
731
+ if (!force && started.length === 0 && sig === this._lastEmittedQueueSig) return
689
732
  this._lastEmittedQueueSig = sig
690
733
  try {
691
734
  // messages は全文を載せる。frontend は実行開始 (drain) 時にこれを user バブルへ
@@ -752,10 +795,24 @@ class ClaudeStreamSession {
752
795
  return
753
796
  }
754
797
  const next = this._pendingMessages.shift()
798
+ // B5: busy をセットしてから reconcile / push / emit が例外を投げると
799
+ // _busy=true のまま残り、以降の drain が全て弾かれて pending が永久に積み上がる
800
+ // デッドロックになる。失敗時は取り出したメッセージを先頭へ戻し _busy を解除して、
801
+ // 次の drain がやり直せる状態に必ず回復する (例外は既存方針通り warn で握る)。
755
802
  this._busy = true
756
- await this._reconcileResidentUltracode(next.ultracode === true)
757
- this._inputQueue.push(toSDKUserMessage(next.text))
758
- this._emitQueueState([next.text])
803
+ try {
804
+ // ultracode reconcile (0.6.28) も await で例外を投げうるので try の中に含める。
805
+ await this._reconcileResidentUltracode(next.ultracode === true)
806
+ this._inputQueue.push(toSDKUserMessage(next.text))
807
+ this._emitQueueState([next.text])
808
+ } catch (err) {
809
+ this._busy = false
810
+ this._pendingMessages.unshift(next)
811
+ this.logger?.warn(
812
+ { stream_id: this.stream_id, err: err?.message },
813
+ "drainResidentPending failed, recovered busy state",
814
+ )
815
+ }
759
816
  }
760
817
 
761
818
  /** 改修2+4: 常駐 query を 1 回だけ起動し、streaming input キューから複数ターンを処理する。
@@ -851,12 +908,25 @@ class ClaudeStreamSession {
851
908
  } else if (!this._closed && this._pendingMessages.length > 0) {
852
909
  // 改修4 (A): 異常終了 (close 以外) で pending が残っていれば resume 付きで再起動し
853
910
  // 取りこぼしを防ぐ。_runResidentQuery が options.resume=this.sessionId で文脈を復元する。
911
+ // B6: 既存 _inputQueue の generator は既に return 済み (この query で消費し切った)。
912
+ // 同じインスタンスを次の sdk.query に再利用すると _q/_wake の残留状態が新しい
913
+ // generator と競合し得るため、再起動前に必ず作り直す。close() は this._inputQueue を
914
+ // close() するだけで参照を持ち越さないので、ここで差し替えても整合する。
915
+ this._inputQueue = new InputQueue()
854
916
  const next = this._pendingMessages.shift()
855
917
  this._busy = true
856
918
  this._residentStarted = true
919
+ // ultracode (0.6.29): query を先に起動して _residentQuery を確定させてから
920
+ // reconcile → push する (sendMessage の通常経路と同順)。push してから起動すると
921
+ // applyFlagSettings を発行する先 (_residentQuery) がまだ無く、異常再起動の対象
922
+ // メッセージだけ ultracode フラグが落ちる (0.6.28 由来の取りこぼし) ため、ここで揃える。
923
+ // _startResidentQuery → _runResidentQuery は最初の await (for await) より前に
924
+ // 同期で _residentQuery と _ultracodeCurrent=false を確定するので、直後の reconcile が
925
+ // 正しく差分判定できる。_reconcileResidentUltracode は内部で例外を握るため throw しない。
926
+ this._startResidentQuery()
927
+ await this._reconcileResidentUltracode(next.ultracode === true)
857
928
  this._inputQueue.push(toSDKUserMessage(next.text))
858
929
  this._emitQueueState([next.text])
859
- this._startResidentQuery()
860
930
  }
861
931
  }
862
932
  }
@@ -1106,6 +1176,7 @@ export class ClaudeStreamBridge extends EventEmitter {
1106
1176
  this.logger?.warn({ stream_id }, "claude.input but stream missing")
1107
1177
  return false
1108
1178
  }
1179
+ s.touch(stream_id) // B11: この端末の最終アクティビティを更新 (死端末 GC 用)
1109
1180
  // 非同期でターン実行 (完了は result イベント + onEvent 経由で browser に届く)
1110
1181
  // ultracode (0.6.28): このメッセージのみ ultracode ワンショットを適用するフラグ。
1111
1182
  s.sendMessage(message, { ultracode: ultracode === true }).catch((err) => {
@@ -1163,11 +1234,13 @@ export class ClaudeStreamBridge extends EventEmitter {
1163
1234
  // idle softDetach に進む。無効時は streamIds が常に 1 要素なので即 softDetach に落ちる。
1164
1235
  if (CHAT_SHARED_ENABLED) {
1165
1236
  s.streamIds.delete(stream_id)
1237
+ s.lastActivityByStream.delete(stream_id) // B11: 活性記録も掃除
1166
1238
  if (this.sessions.get(stream_id) === s) this.sessions.delete(stream_id)
1167
1239
  if (s.streamIds.size > 0) {
1168
1240
  // primary を生存端末へ寄せ替え (legacy emit / softDetach 撤去ログ用)。
1241
+ // 末尾 (最も新しく購読した端末) を .at(-1) で明示的に選ぶ。
1169
1242
  if (s.stream_id === stream_id) {
1170
- s.stream_id = Array.from(s.streamIds)[s.streamIds.size - 1]
1243
+ s.stream_id = Array.from(s.streamIds).at(-1)
1171
1244
  }
1172
1245
  return true
1173
1246
  }
@@ -1188,6 +1261,45 @@ export class ClaudeStreamBridge extends EventEmitter {
1188
1261
  return true
1189
1262
  }
1190
1263
 
1264
+ /** B11: 多端末共有時に「死端末」(一定期間 input/permission 等の活性が無い購読端末) を
1265
+ * GC する。端末がクラッシュして claude.detach を送らず消えた stream_id を掃除し、最後の
1266
+ * 1 台が外れたら通常の idle softDetach (走行中は完走を待つ) に進ませる。state loop など
1267
+ * 既存の定期ループから呼ぶ想定。CHAT_SHARED_ENABLED 無効時は何もしない。
1268
+ * @param {number} [ttlMs] 死端末とみなす無活性閾値 (テスト用に上書き可)。
1269
+ * @returns {number} GC した端末キー数。 */
1270
+ gcDeadTerminals(ttlMs = DEAD_TERMINAL_TTL_MS) {
1271
+ if (!CHAT_SHARED_ENABLED) return 0
1272
+ const now = Date.now()
1273
+ let removed = 0
1274
+ for (const session of new Set(this.sessions.values())) {
1275
+ if (session._closed) continue
1276
+ // 複数端末が購読しているセッションのみ対象 (1 台しか居なければ idle softDetach に任せる)。
1277
+ if (session.streamIds.size <= 1) continue
1278
+ for (const sid of Array.from(session.streamIds)) {
1279
+ const last = session.lastActivityByStream.get(sid) ?? 0
1280
+ if (now - last < ttlMs) continue
1281
+ // 死端末: 購読集合 / 活性記録 / sessions Map から外す。
1282
+ session.streamIds.delete(sid)
1283
+ session.lastActivityByStream.delete(sid)
1284
+ if (this.sessions.get(sid) === session) this.sessions.delete(sid)
1285
+ removed += 1
1286
+ // primary が死端末だったら生存端末へ寄せ替える (.at(-1) で末尾を明示選択)。
1287
+ if (session.stream_id === sid && session.streamIds.size > 0) {
1288
+ session.stream_id = Array.from(session.streamIds).at(-1)
1289
+ }
1290
+ this.logger?.info(
1291
+ { stream_id: sid, session_id: session.sessionId },
1292
+ "claude dead terminal GC'd (no activity past TTL)",
1293
+ )
1294
+ }
1295
+ // 全端末が死端末で空になったら、idle softDetach 経路に乗せる (走行中は完走を待つ)。
1296
+ if (session.streamIds.size === 0) {
1297
+ this.detach({ stream_id: session.stream_id })
1298
+ }
1299
+ }
1300
+ return removed
1301
+ }
1302
+
1191
1303
  /** 全セッションを強制停止 (agent shutdown 用。実行中ターンも中断する)。 */
1192
1304
  shutdown() {
1193
1305
  for (const stream_id of Array.from(this.sessions.keys())) {
package/src/main.mjs CHANGED
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { readFileSync, watch as fsWatch } from "node:fs"
14
14
  import { mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises"
15
+ import { randomUUID } from "node:crypto"
15
16
  import os from "node:os"
16
17
  import path from "node:path"
17
18
 
@@ -132,6 +133,16 @@ async function loadClaudeSdk(logger) {
132
133
  }
133
134
  }
134
135
 
136
+ /**
137
+ * B7: 直列 dispatchChain をバイパスして即時処理してよい高頻度・低レイテンシ経路かを判定する。
138
+ * pty 出力データ (pty.data) と resize (pty.resize) のみ true。入力系 (claude.input)・制御系
139
+ * (tmux.exec / permission / cancel→paste 等) は WS 受信順 = pane 反映順を守るため false
140
+ * (= 直列キューに残す)。1 件の tmux.exec ハングで pty 入出力まで止まるのを防ぐ。
141
+ */
142
+ export function isFastPathMessage(type) {
143
+ return type === "pty.data" || type === "pty.resize"
144
+ }
145
+
135
146
  export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
136
147
  const config = await readConfig()
137
148
  if (!config) {
@@ -262,6 +273,22 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
262
273
  // (`ptyBridge.on("output")`) なのでこの直列化の影響を受けない。
263
274
  let dispatchChain = Promise.resolve()
264
275
  client.on("message", (msg) => {
276
+ // B7: pty 出力データ (pty.data) と resize (pty.resize) は順序保証が不要で高頻度な
277
+ // 低レイテンシ経路。これらを直列キュー (dispatchChain) に通すと、1 件の tmux.exec 等
278
+ // のハングで pty 入出力まで全停止してしまう。安全側に「pty 出力データと resize のみ」
279
+ // を直列キューからバイパスして即時処理する。入力系 (claude.input)・制御系 (tmux.exec /
280
+ // permission / cancel→paste 等) は WS 受信順 = pane 反映順を守るため dispatchChain に残す。
281
+ if (isFastPathMessage(msg?.type)) {
282
+ Promise.resolve(
283
+ dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, uploadManager }),
284
+ ).catch((err) => {
285
+ logger.error(
286
+ { err: err.message, type: msg?.type },
287
+ "dispatch threw (bypassed pty fast-path)",
288
+ )
289
+ })
290
+ return
291
+ }
265
292
  dispatchChain = dispatchChain
266
293
  .then(() => dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, uploadManager }))
267
294
  .catch((err) => {
@@ -286,7 +313,13 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
286
313
 
287
314
  // 5s 周期で全 tmux session の状態を捕捉し、変化したものだけ session.state を push。
288
315
  // browser がフォーカスしていない session でも常時更新するためのバックグラウンド職人。
289
- const stateLoop = startStateLoop({ client, plugins, logger, intervalMs: 5_000 })
316
+ const stateLoop = startStateLoop({
317
+ client,
318
+ plugins,
319
+ logger,
320
+ intervalMs: 5_000,
321
+ claudeBridge,
322
+ })
290
323
  // bundle hook (cockpit_session_event_hook.py) が /tmp/cockpit_session_events/<name>.json
291
324
  // に書き出す UserPromptSubmit / Stop の event を fs.watch で拾って WS push する。
292
325
  // text マーカー判定 (detectStatusFromText) より精度が高い「ターン境界」の signal。
@@ -381,7 +414,8 @@ export function contextPctFromUsage(u) {
381
414
  async function writeSessionEventFile(sessionName, event, at) {
382
415
  if (!sessionName || /[/\\]/.test(sessionName)) return
383
416
  const fp = path.join(SESSION_EVENTS_DIR, `${sessionName}.json`)
384
- const tmp = `${fp}.tmp.${process.pid}`
417
+ // tmp 名を randomUUID でユニーク化 (pid 固定だと同一 session への並行書込で衝突する)。
418
+ const tmp = `${fp}.tmp.${randomUUID()}`
385
419
  try {
386
420
  await mkdir(SESSION_EVENTS_DIR, { recursive: true })
387
421
  await writeFile(tmp, JSON.stringify({ event, at }))
@@ -509,7 +543,7 @@ async function startSessionEventWatcher({ client, logger }) {
509
543
  * pty.exit 受信時に処理する)
510
544
  * - tmux 自体が動いてない場合 (listSessionStates → []) は何も push しない
511
545
  */
512
- function startStateLoop({ client, plugins, logger, intervalMs }) {
546
+ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
513
547
  const lastByName = new Map() // session_name → {status, context_pct}
514
548
  const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
515
549
  let stopped = false
@@ -517,6 +551,14 @@ function startStateLoop({ client, plugins, logger, intervalMs }) {
517
551
  const tick = async () => {
518
552
  if (stopped) return
519
553
  try {
554
+ // B11: 多端末共有時、クラッシュして detach を送らず消えた死端末を GC する
555
+ // (活性なし TTL 超過の購読端末キーを掃除し、最後の 1 台が外れたら idle softDetach へ)。
556
+ // CHAT_SHARED_ENABLED 無効時は no-op。例外は state loop 全体の try/catch が拾う。
557
+ try {
558
+ claudeBridge?.gcDeadTerminals?.()
559
+ } catch (err) {
560
+ logger?.warn({ err: err?.message }, "gcDeadTerminals failed")
561
+ }
520
562
  // 実コンテキスト窓サイズ (1M ベータ等) を反映。contextPctFromUsage の分母を
521
563
  // 5s ごとに最新化し、ドーナツが 200k 固定で振り切れるのを防ぐ。
522
564
  await refreshContextWindow()
package/src/state.mjs CHANGED
@@ -20,6 +20,37 @@ const execFileP = promisify(execFile)
20
20
 
21
21
  const STATUSES = Object.freeze(["processing", "waiting", "idle"])
22
22
 
23
+ /**
24
+ * P1: tmux プロセス fork 削減用の短期/長期キャッシュ。
25
+ *
26
+ * 5s 周期の state loop が N セッション毎に capture-pane (pane scrape) +
27
+ * display-message (cwd) を spawn し、list_sessions も同様に二重取得していた。
28
+ * N セッションで毎 5s に 2N+1 プロセスを fork する負荷を、以下のキャッシュで抑える:
29
+ * - capture-pane: 短期キャッシュ (CAPTURE_TTL_MS < state loop 周期)。state loop と
30
+ * 近接時刻の list_sessions が同じ pane scrape を共有する (状態検出の鮮度は維持)。
31
+ * - getSessionCwd: cwd は変化が稀なので長期キャッシュ (CWD_TTL_MS)。毎 tick の
32
+ * display-message spawn を排除し fork 数を半減させる。
33
+ * TTL は env で上書き可能 (テスト/チューニング用)。0 を渡すとキャッシュ無効。
34
+ */
35
+ const CAPTURE_TTL_MS = Number(process.env.HUB_AGENT_CAPTURE_TTL_MS ?? 2500)
36
+ const CWD_TTL_MS = Number(process.env.HUB_AGENT_CWD_TTL_MS ?? 60000)
37
+
38
+ /** @type {Map<string, {at: number, value: string}>} session名 → capture-pane 結果 */
39
+ const _captureCache = new Map()
40
+ /** @type {Map<string, {at: number, value: string|null}>} session名 → cwd */
41
+ const _cwdCache = new Map()
42
+
43
+ /** キャッシュから無効化する (session 終了時等に呼べるよう export)。 */
44
+ export function invalidateSessionCache(sessionName) {
45
+ if (sessionName == null) {
46
+ _captureCache.clear()
47
+ _cwdCache.clear()
48
+ return
49
+ }
50
+ _captureCache.delete(sessionName)
51
+ _cwdCache.delete(sessionName)
52
+ }
53
+
23
54
  const CONTEXT_PATTERNS = [
24
55
  /(\d{1,3})\s*%\s*context\s*left/i,
25
56
  /context\s*[:\-]?\s*(\d{1,3})\s*%/i,
@@ -66,6 +97,12 @@ export async function listSessionNames(opts = {}) {
66
97
 
67
98
  export async function capturePane(sessionName, opts = {}) {
68
99
  const tmuxBin = opts.tmuxBin || "tmux"
100
+ // P1: 短期キャッシュ。state loop と近接時刻の list_sessions の二重 capture-pane を
101
+ // 同一結果で共有する。opts.noCache か TTL=0 でバイパス可能。
102
+ if (!opts.noCache && CAPTURE_TTL_MS > 0) {
103
+ const hit = _captureCache.get(sessionName)
104
+ if (hit && Date.now() - hit.at < CAPTURE_TTL_MS) return hit.value
105
+ }
69
106
  try {
70
107
  const { stdout } = await execFileP(tmuxBin, [
71
108
  "capture-pane",
@@ -77,7 +114,11 @@ export async function capturePane(sessionName, opts = {}) {
77
114
  "-E",
78
115
  "-",
79
116
  ])
80
- return stripAnsi(stdout)
117
+ const value = stripAnsi(stdout)
118
+ if (!opts.noCache && CAPTURE_TTL_MS > 0) {
119
+ _captureCache.set(sessionName, { at: Date.now(), value })
120
+ }
121
+ return value
81
122
  } catch {
82
123
  return ""
83
124
  }
@@ -89,6 +130,12 @@ export async function capturePane(sessionName, opts = {}) {
89
130
  */
90
131
  export async function getSessionCwd(sessionName, opts = {}) {
91
132
  const tmuxBin = opts.tmuxBin || "tmux"
133
+ // P1: cwd は変化が稀なので長期キャッシュ。state loop の毎 tick spawn を排除する。
134
+ // opts.noCache か TTL=0 でバイパス可能。cwd 変化検知は CWD_TTL_MS 経過後の再取得で吸収。
135
+ if (!opts.noCache && CWD_TTL_MS > 0) {
136
+ const hit = _cwdCache.get(sessionName)
137
+ if (hit && Date.now() - hit.at < CWD_TTL_MS) return hit.value
138
+ }
92
139
  try {
93
140
  const { stdout } = await execFileP(tmuxBin, [
94
141
  "display-message",
@@ -99,7 +146,11 @@ export async function getSessionCwd(sessionName, opts = {}) {
99
146
  "#{pane_current_path}",
100
147
  ])
101
148
  const s = stdout.trim()
102
- return s || null
149
+ const value = s || null
150
+ if (!opts.noCache && CWD_TTL_MS > 0) {
151
+ _cwdCache.set(sessionName, { at: Date.now(), value })
152
+ }
153
+ return value
103
154
  } catch {
104
155
  return null
105
156
  }
package/src/tmux.mjs CHANGED
@@ -20,7 +20,7 @@ import path from "node:path"
20
20
  import { promisify } from "node:util"
21
21
 
22
22
  import { ensureClaudeMd } from "./claude-md.mjs"
23
- import { detectSessionState } from "./state.mjs"
23
+ import { detectSessionState, getSessionCwd } from "./state.mjs"
24
24
  import { getSessionUsages } from "./usage.mjs"
25
25
 
26
26
  const execFileP = promisify(execFile)
@@ -356,22 +356,9 @@ export async function execTmux(args, opts = {}) {
356
356
  }
357
357
  }
358
358
 
359
- async function getSessionCwd(name, opts = {}) {
360
- try {
361
- const { stdout } = await execFileP(tmuxBin(opts), [
362
- "display-message",
363
- "-p",
364
- "-t",
365
- `${name}:`,
366
- "-F",
367
- "#{pane_current_path}",
368
- ])
369
- const s = stdout.trim()
370
- return s || null
371
- } catch {
372
- return null
373
- }
374
- }
359
+ // P1 (重複排除): cwd 取得は state.mjs の getSessionCwd (キャッシュ付き) を共用する。
360
+ // 旧 tmux.mjs 私有実装は display-message を毎回 spawn しており、state loop の
361
+ // listSessionStates list_sessions が別キャッシュ無しで二重 fork していた。
375
362
 
376
363
  const TMUX_LIST_FIELDS = [
377
364
  "#{session_name}",
package/src/usage.mjs CHANGED
@@ -17,6 +17,7 @@
17
17
  * 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
18
18
  */
19
19
  import { promises as fs } from "node:fs"
20
+ import { randomUUID } from "node:crypto"
20
21
  import os from "node:os"
21
22
  import path from "node:path"
22
23
 
@@ -107,6 +108,49 @@ async function readOrNull(p) {
107
108
  }
108
109
  }
109
110
 
111
+ /**
112
+ * P5(perf): ファイル末尾だけを読む (tail)。jsonl の末尾 assistant.usage 1 件だけが
113
+ * 欲しい latestJsonlContext 用。全文 readFile + split を避けて巨大 jsonl の再パース
114
+ * コストを削る。末尾 maxBytes を読み、最初の改行以降 (= 完全な行のみ) を返す。
115
+ * size <= maxBytes ならファイル全体を返す。失敗時は null。
116
+ */
117
+ async function readTail(fp, maxBytes = 64 * 1024) {
118
+ let fh
119
+ try {
120
+ fh = await fs.open(fp, "r")
121
+ const st = await fh.stat()
122
+ const size = st.size
123
+ if (size === 0) return ""
124
+ const start = size > maxBytes ? size - maxBytes : 0
125
+ const len = size - start
126
+ const buf = Buffer.allocUnsafe(len)
127
+ await fh.read(buf, 0, len, start)
128
+ let text = buf.toString("utf-8")
129
+ // 途中から読んだ場合、先頭の不完全な行を捨てる (最初の改行まで)。
130
+ if (start > 0) {
131
+ const nl = text.indexOf("\n")
132
+ text = nl >= 0 ? text.slice(nl + 1) : text
133
+ }
134
+ return text
135
+ } catch {
136
+ return null
137
+ } finally {
138
+ try {
139
+ await fh?.close()
140
+ } catch {
141
+ /* ignore */
142
+ }
143
+ }
144
+ }
145
+
146
+ /** P5(perf): latestJsonlContext の結果を {mtimeMs,size} キーでメモ化し再パースを避ける。 */
147
+ const _jsonlCtxMemo = new Map() // fp → { mtimeMs, size, result }
148
+
149
+ /** P5(perf): readEstimate の per-file パース結果 (assistant.usage の {ts,tok} 配列) を
150
+ * {mtimeMs,size} キーでメモ化する。時間窓 (5h/7d) への振り分けは呼び出し毎に now で
151
+ * 再計算するが、jsonl 全文の再パースは mtime 不変なら省ける (集計の重い部分はパース)。 */
152
+ const _estimateFileMemo = new Map() // fp → { mtimeMs, size, records: [{ts,tok}] }
153
+
110
154
  // ---------------------------------------------------------------------------
111
155
  // チャット(SDK) の rate_limit_event から取得した最新 rate-limit (プロセス内共有)。
112
156
  // statusLine cache はターミナルでしか更新されないため、チャットモードでは
@@ -171,7 +215,9 @@ async function persistChatRateLimitsToCache() {
171
215
  } catch {
172
216
  /* ignore */
173
217
  }
174
- const tmp = `${p}.tmp.${process.pid}`
218
+ // P5(bug): tmp 名を randomUUID でユニーク化する。pid 固定 (`${p}.tmp.${pid}`) だと
219
+ // 同一プロセス内で複数の persist が並行すると互いの tmp を上書き/unlink して破壊し合う。
220
+ const tmp = `${p}.tmp.${randomUUID()}`
175
221
  try {
176
222
  await fs.writeFile(tmp, JSON.stringify(base))
177
223
  await fs.rename(tmp, p)
@@ -205,8 +251,15 @@ export function recordChatRateLimit(info) {
205
251
  chatRateLimits[slot] = { percent, resetAtMs }
206
252
  chatRateLimits.updatedAtMs = Date.now()
207
253
  // webapp フッター (ファイルベース readOfficial) 用に latest.json へ書き戻す。
208
- // fire-and-forget だが in-flight promise は保持 (flush 可能にする)。
209
- _persistInFlight = persistChatRateLimitsToCache()
254
+ // P5(bug): 前の _persistInFlight を待ってから次を実行し write を直列化する。
255
+ // 直列化しないと複数の persist が同一 latest.json に read→write→rename を並行実行し、
256
+ // 後勝ちで中間状態を読んだり tmp を破壊し合う。chatRateLimits は最新値を共有参照する
257
+ // ので、待ち合わせ後に走る persist は常に最新スナップショットを書く (取りこぼし無し)。
258
+ // 失敗は persistChatRateLimitsToCache 内で握りつぶすので chain は切れない。
259
+ _persistInFlight = _persistInFlight.then(
260
+ () => persistChatRateLimitsToCache(),
261
+ () => persistChatRateLimitsToCache(),
262
+ )
210
263
  }
211
264
 
212
265
  /**
@@ -279,6 +332,38 @@ async function readEstimate(now) {
279
332
  let oldest5h = null
280
333
  let oldest7d = null
281
334
 
335
+ // P5(perf): per-file の assistant.usage レコード ({ts,tok}) を mtime+size でメモ化し、
336
+ // jsonl 全文の再パース (重い) を省く。時間窓への振り分けだけ now で都度再計算する。
337
+ const perFileRecords = async (fp, st) => {
338
+ const memo = _estimateFileMemo.get(fp)
339
+ if (memo && memo.mtimeMs === st.mtimeMs && memo.size === st.size) {
340
+ return memo.records
341
+ }
342
+ const text = await readOrNull(fp)
343
+ const records = []
344
+ if (text) {
345
+ for (const line of text.split("\n")) {
346
+ if (!line || line.length < 50) continue
347
+ if (!line.includes('"usage"')) continue
348
+ let d
349
+ try {
350
+ d = JSON.parse(line)
351
+ } catch {
352
+ continue
353
+ }
354
+ if (d.type !== "assistant") continue
355
+ const ts = d.timestamp ? Date.parse(d.timestamp) : 0
356
+ if (!ts) continue
357
+ const u = d.message?.usage
358
+ if (!u) continue
359
+ const tok = (u.output_tokens || 0) + (u.input_tokens || 0)
360
+ records.push({ ts, tok })
361
+ }
362
+ }
363
+ _estimateFileMemo.set(fp, { mtimeMs: st.mtimeMs, size: st.size, records })
364
+ return records
365
+ }
366
+
282
367
  await Promise.all(
283
368
  projects.map(async (p) => {
284
369
  const dir = path.join(projectsDir(), p)
@@ -286,29 +371,16 @@ async function readEstimate(now) {
286
371
  for (const f of files) {
287
372
  if (!f.endsWith(".jsonl")) continue
288
373
  const fp = path.join(dir, f)
374
+ let st
289
375
  try {
290
- const st = await fs.stat(fp)
376
+ st = await fs.stat(fp)
291
377
  if (st.mtimeMs < t7d) continue
292
378
  } catch {
293
379
  continue
294
380
  }
295
- const text = await readOrNull(fp)
296
- if (!text) continue
297
- for (const line of text.split("\n")) {
298
- if (!line || line.length < 50) continue
299
- if (!line.includes('"usage"')) continue
300
- let d
301
- try {
302
- d = JSON.parse(line)
303
- } catch {
304
- continue
305
- }
306
- if (d.type !== "assistant") continue
307
- const ts = d.timestamp ? Date.parse(d.timestamp) : 0
308
- if (!ts || ts < t7d) continue
309
- const u = d.message?.usage
310
- if (!u) continue
311
- const tok = (u.output_tokens || 0) + (u.input_tokens || 0)
381
+ const records = await perFileRecords(fp, st)
382
+ for (const { ts, tok } of records) {
383
+ if (ts < t7d) continue
312
384
  tokens7d += tok
313
385
  msgs7d += 1
314
386
  if (oldest7d === null || ts < oldest7d) oldest7d = ts
@@ -390,7 +462,7 @@ async function latestJsonlContext(now) {
390
462
  const projects = await fs.readdir(projectsDir()).catch(() => null)
391
463
  if (!projects) return null
392
464
  const recent = now - CONTEXT_JSONL_RECENT_MS
393
- let best = null // { mtimeMs, fp }
465
+ let best = null // { mtimeMs, size, fp }
394
466
  await Promise.all(
395
467
  projects.map(async (p) => {
396
468
  const dir = path.join(projectsDir(), p)
@@ -401,7 +473,9 @@ async function latestJsonlContext(now) {
401
473
  try {
402
474
  const st = await fs.stat(fp)
403
475
  if (st.mtimeMs < recent) continue
404
- if (!best || st.mtimeMs > best.mtimeMs) best = { mtimeMs: st.mtimeMs, fp }
476
+ if (!best || st.mtimeMs > best.mtimeMs) {
477
+ best = { mtimeMs: st.mtimeMs, size: st.size, fp }
478
+ }
405
479
  } catch {
406
480
  /* ignore */
407
481
  }
@@ -409,33 +483,50 @@ async function latestJsonlContext(now) {
409
483
  }),
410
484
  )
411
485
  if (!best) return null
486
+ // P5(perf): mtime+size が前回と同じなら再パースせずメモ結果を返す。
487
+ const memo = _jsonlCtxMemo.get(best.fp)
488
+ if (memo && memo.mtimeMs === best.mtimeMs && memo.size === best.size) {
489
+ return memo.result
490
+ }
412
491
  const windowSize = await contextWindowSize()
413
- const text = await readOrNull(best.fp)
414
- if (!text) return null
415
- const lines = text.split("\n")
416
- // 末尾から最初に見つかった assistant.usage を採用 (= 現在の文脈サイズ)
417
- for (let i = lines.length - 1; i >= 0; i--) {
418
- const line = lines[i]
419
- if (!line || !line.includes('"usage"')) continue
420
- let d
421
- try {
422
- d = JSON.parse(line)
423
- } catch {
424
- continue
492
+ // 末尾から最初に見つかった assistant.usage tokens に変換する。見つからなければ null。
493
+ const scan = (text) => {
494
+ const lines = text.split("\n")
495
+ for (let i = lines.length - 1; i >= 0; i--) {
496
+ const line = lines[i]
497
+ if (!line || !line.includes('"usage"')) continue
498
+ let d
499
+ try {
500
+ d = JSON.parse(line)
501
+ } catch {
502
+ continue
503
+ }
504
+ if (d.type !== "assistant") continue
505
+ const u = d.message?.usage
506
+ if (!u) continue
507
+ const tokens =
508
+ (u.input_tokens || 0) +
509
+ (u.cache_read_input_tokens || 0) +
510
+ (u.cache_creation_input_tokens || 0) +
511
+ (u.output_tokens || 0)
512
+ if (tokens <= 0) continue
513
+ return Math.min(100, Math.round((tokens / windowSize) * 1000) / 10)
425
514
  }
426
- if (d.type !== "assistant") continue
427
- const u = d.message?.usage
428
- if (!u) continue
429
- const tokens =
430
- (u.input_tokens || 0) +
431
- (u.cache_read_input_tokens || 0) +
432
- (u.cache_creation_input_tokens || 0) +
433
- (u.output_tokens || 0)
434
- if (tokens <= 0) continue
435
- const percent = Math.min(100, Math.round((tokens / windowSize) * 1000) / 10)
436
- return { percent, mtimeMs: best.mtimeMs }
515
+ return null
437
516
  }
438
- return null
517
+ // P5(perf): 末尾 assistant.usage 1 件だけ欲しいので tail 読みで全文 split を避ける。
518
+ // tail 内に見つからなかった稀なケースのみ全文 read にフォールバックする。
519
+ let percent = null
520
+ const tail = await readTail(best.fp)
521
+ if (tail != null) percent = scan(tail)
522
+ if (percent === null) {
523
+ const full = await readOrNull(best.fp)
524
+ if (full != null) percent = scan(full)
525
+ }
526
+ const result = percent === null ? null : { percent, mtimeMs: best.mtimeMs }
527
+ // mtime+size をキーにメモ化 (null 結果もキャッシュして再 tail/full read を避ける)。
528
+ _jsonlCtxMemo.set(best.fp, { mtimeMs: best.mtimeMs, size: best.size, result })
529
+ return result
439
530
  }
440
531
 
441
532
  /**
package/src/ws-client.mjs CHANGED
@@ -202,21 +202,29 @@ export class WsClient extends EventEmitter {
202
202
  _flushPtyBuffer() {
203
203
  if (this.ptyOutboundBuffer.length === 0) return
204
204
  const now = Date.now()
205
+ // B10: バッファを退避してから処理する。送信失敗 (_sendJson が false) で break
206
+ // した場合、未送信の残りフレームを ptyOutboundBuffer の先頭へ戻して順序を保持する
207
+ // (空配列で上書きしたまま break すると残りが欠落する)。
205
208
  const buf = this.ptyOutboundBuffer
206
209
  this.ptyOutboundBuffer = []
207
210
  let sent = 0
208
211
  let expired = 0
209
- for (const entry of buf) {
212
+ for (let i = 0; i < buf.length; i++) {
213
+ const entry = buf[i]
210
214
  if (now - entry.ts > PTY_BUFFER_MAX_AGE_MS) {
211
215
  expired += 1
212
216
  continue
213
217
  }
214
218
  const ok = this._sendJson(entry.obj)
215
- if (!ok) break
219
+ if (!ok) {
220
+ // 未送信分 (現エントリ含む i 以降) を先頭へ戻す。次回 open / flush で再送する。
221
+ this.ptyOutboundBuffer = buf.slice(i).concat(this.ptyOutboundBuffer)
222
+ break
223
+ }
216
224
  sent += 1
217
225
  }
218
226
  this.logger?.info(
219
- { sent, expired, total: buf.length },
227
+ { sent, expired, total: buf.length, requeued: this.ptyOutboundBuffer.length },
220
228
  "pty outbound buffer flushed"
221
229
  )
222
230
  }