@cocorograph/hub-agent 0.7.30 → 0.7.32

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.7.30",
3
+ "version": "0.7.32",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -29,6 +29,50 @@ export const MAX_HISTORY_LINES = 500
29
29
  /** UI 表示対象の SDK message type (それ以外は jsonl 内部メタなので除外)。 */
30
30
  export const DISPLAY_TYPES = new Set(["user", "assistant", "system", "result"])
31
31
 
32
+ /**
33
+ * hydrate 結果のパース済みキャッシュ (2026-07-03: Cockpit セッション切替パフォーマンス改善)。
34
+ *
35
+ * 背景: CockpitChatView は resume セッション切替のたびに毎回 readSessionHistory を呼ぶが
36
+ * (window/page.tsx の `key={selected}` 再マウント設計)、同一ファイルが未変更のまま
37
+ * 数秒〜数分内に再 hydrate されるケースが大半 (行ったり来たりのセッション切替、複数タブ)。
38
+ * readFile → split → 全行 JSON.parse (+ MASK_ENABLED 時は tool_use 走査) は jsonl が
39
+ * 大きいほど無視できないコストになるため、`mtimeMs + size` が前回と一致する間は
40
+ * パース結果をそのまま再利用し、ディスク読み直し・再パースを丸ごとスキップする。
41
+ * ファイルが変わっていれば (ライブ会話が進行 / 別ターミナルから追記等) 通常どおり再読み込み
42
+ * して上書きするので、常に最新のファイル内容を正しく反映する (stale 表示のリスクは無い)。
43
+ *
44
+ * key: filePath, value: { mtimeMs, size, maxLines, result }。maxLines もキーの一部として
45
+ * 比較する (異なる maxLines でのリクエストはキャッシュを再利用しない)。
46
+ */
47
+ const HYDRATE_CACHE_MAX_ENTRIES = 200
48
+ const hydrateCache = new Map()
49
+
50
+ function getHydrateCache(filePath, mtimeMs, size, maxLines) {
51
+ const entry = hydrateCache.get(filePath)
52
+ if (!entry) return null
53
+ if (entry.mtimeMs !== mtimeMs || entry.size !== size || entry.maxLines !== maxLines) return null
54
+ // LRU: 再利用したエントリを Map の末尾へ移し直す (再挿入で挿入順を更新)。
55
+ hydrateCache.delete(filePath)
56
+ hydrateCache.set(filePath, entry)
57
+ return entry.result
58
+ }
59
+
60
+ function setHydrateCache(filePath, mtimeMs, size, maxLines, result) {
61
+ hydrateCache.delete(filePath)
62
+ hydrateCache.set(filePath, { mtimeMs, size, maxLines, result })
63
+ // 上限超過分は挿入順が古いものから GC (長時間稼働の hub-agent プロセスでメモリが
64
+ // 際限なく増えないようにする)。
65
+ while (hydrateCache.size > HYDRATE_CACHE_MAX_ENTRIES) {
66
+ const oldestKey = hydrateCache.keys().next().value
67
+ hydrateCache.delete(oldestKey)
68
+ }
69
+ }
70
+
71
+ /** テスト用: hydrate キャッシュを全消去する。 */
72
+ export function clearHydrateCacheForTest() {
73
+ hydrateCache.clear()
74
+ }
75
+
32
76
  /**
33
77
  * jsonl の生 object を、Browser が SDK message として受け取れる表示用 shape に正規化する。
34
78
  * 余分な内部メタは落とし、表示・集計に必要なフィールドだけを superset で拾う。
@@ -97,6 +141,21 @@ export function jsonlPath({ cwd, session_id, projectsRoot }) {
97
141
  * @returns {Promise<{events: object[], total_lines: number, truncated: boolean}>}
98
142
  */
99
143
  export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINES, logger } = {}) {
144
+ // stat は readFile よりずっと軽い (メタデータのみ)。mtimeMs+size が前回と同じなら
145
+ // ファイル内容も同じとみなし、以降の readFile + 全行 parse をまるごとスキップする。
146
+ let st
147
+ try {
148
+ st = await stat(filePath)
149
+ } catch (err) {
150
+ if (err.code === "ENOENT") {
151
+ return { events: [], total_lines: 0, truncated: false }
152
+ }
153
+ logger?.warn({ err: err.message, filePath }, "claude history stat failed")
154
+ throw err
155
+ }
156
+ const cached = getHydrateCache(filePath, st.mtimeMs, st.size, maxLines)
157
+ if (cached) return cached
158
+
100
159
  let text
101
160
  try {
102
161
  text = await readFile(filePath, "utf-8")
@@ -143,7 +202,9 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
143
202
  if (MASK_ENABLED) event = maskMessageObject(event, toolNames)
144
203
  events.push(event)
145
204
  }
146
- return { events, total_lines, truncated }
205
+ const result = { events, total_lines, truncated }
206
+ setHydrateCache(filePath, st.mtimeMs, st.size, maxLines, result)
207
+ return result
147
208
  }
148
209
 
149
210
  /**
@@ -1647,7 +1647,12 @@ export class ClaudeStreamBridge extends EventEmitter {
1647
1647
  for (const t of session.lastActivityByStream.values()) {
1648
1648
  if (t > at) at = t
1649
1649
  }
1650
- if (at > bestAt) {
1650
+ // `>=` (2026-07-03): Date.now() はミリ秒精度のため、短時間に複数セッションへ
1651
+ // attach+input すると同一ミリ秒でタイが起き得る (テストや高速な連続操作で実測)。
1652
+ // `this.sessions` は Map で挿入順を保持するため、タイ時は後から attach された
1653
+ // (= イテレーション順で後に来る) セッションを採用するのが「最終アクティビティが
1654
+ // 最も新しい」の意図に合う。厳密な `>` だと先勝ちになり意図と逆転する。
1655
+ if (at >= bestAt) {
1651
1656
  bestAt = at
1652
1657
  best = session.sessionId
1653
1658
  }
@@ -23,6 +23,13 @@
23
23
  * - 画像/添付入力 (テキストのみ)。
24
24
  * - acceptForSession や execpolicy/network amendment 等の高度な承認 decision
25
25
  * (permissionReply は decision をそのまま素通しするので呼び出し側が組み立てる)。
26
+ *
27
+ * Slice 6 (履歴 hydrate) で追加: `thread/resume` のレスポンスは既定で
28
+ * `thread.turns[].items[]` (= ThreadItem の時系列) を含む (公式スキーマ
29
+ * `ThreadReadResponse.json` の Thread.turns 説明: "Only populated on thread/resume,
30
+ * thread/rollback, thread/fork, and thread/read (when includeTurns is true)")。
31
+ * 追加の `thread/read` 往復は不要で、attach() の戻り値に `history` として平坦化して
32
+ * 返す (browser 側での re-mount 白紙状態を解消する)。
26
33
  */
27
34
  import { EventEmitter } from "node:events"
28
35
 
@@ -39,6 +46,17 @@ const IDLE_DETACH_TTL_MS = 5 * 60 * 1000
39
46
 
40
47
  const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
41
48
 
49
+ // Slice 6: thread/resume が返す Thread.turns[].items[] を単一の時系列配列へ平坦化する。
50
+ // turns は開始ターン順の配列、各 turn.items も時系列順のため単純結合で会話順を保てる。
51
+ function flattenThreadItems(thread) {
52
+ const turns = Array.isArray(thread?.turns) ? thread.turns : []
53
+ const items = []
54
+ for (const turn of turns) {
55
+ if (Array.isArray(turn?.items)) items.push(...turn.items)
56
+ }
57
+ return items
58
+ }
59
+
42
60
  function userInputFromMessage(message) {
43
61
  const content = message?.content
44
62
  if (typeof content === "string") {
@@ -65,7 +83,7 @@ export class CodexStreamBridge extends EventEmitter {
65
83
  }
66
84
 
67
85
  /**
68
- * @returns {Promise<{resuming: boolean, threadId: string}>}
86
+ * @returns {Promise<{resuming: boolean, threadId: string, history: object[]}>}
69
87
  */
70
88
  async attach({ stream_id, cwd, model, effort, approvalPolicy, sandbox, resumeThreadId }) {
71
89
  if (this.sessions.has(stream_id)) {
@@ -96,7 +114,10 @@ export class CodexStreamBridge extends EventEmitter {
96
114
  ? await client.request("thread/resume", { ...startParams, threadId: resumeThreadId })
97
115
  : await client.request("thread/start", startParams)
98
116
  session.threadId = result?.thread?.id
99
- return { resuming, threadId: session.threadId }
117
+ // 新規スレッド (thread/start) は履歴が無いので空配列。resume 時のみ平坦化する
118
+ // (thread/start のレスポンスは turns を持たない仕様のため呼んでも常に空)。
119
+ const history = resuming ? flattenThreadItems(result?.thread) : []
120
+ return { resuming, threadId: session.threadId, history }
100
121
  } catch (err) {
101
122
  this.sessions.delete(stream_id)
102
123
  client.stop()
package/src/config.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  import { promises as fs, constants as fsConsts, readFileSync } from "node:fs"
11
11
  import path from "node:path"
12
12
  import os from "node:os"
13
- import { execFile } from "node:child_process"
13
+ import { execFile, execFileSync } from "node:child_process"
14
14
  import { promisify } from "node:util"
15
15
 
16
16
  const _execFileP = promisify(execFile)
@@ -21,9 +21,20 @@ const _execFileP = promisify(execFile)
21
21
  * 重要: Windows のエージェントは WSL2 内で動くため `process.platform` は "linux" を
22
22
  * 返す。素の Linux と区別するため、WSL を検出して "wsl" を返す。
23
23
  *
24
+ * 判定は 3 段階のフォールバック:
25
+ * 1. `WSL_DISTRO_NAME` / `WSL_INTEROP` 環境変数 (対話シェルなら通常付く)
26
+ * 2. `/proc/version` の内容 (microsoft/wsl 文字列)
27
+ * 3. (2026-07-03 追加) `uname -r` のカーネルリリース文字列。systemd サービス等
28
+ * 非対話起動では 1 の環境変数が unit に伝播しないケースがある (WSL2 + systemd の
29
+ * 典型的な罠。対話シェルにしか WSL_DISTRO_NAME が無く、hub-agent をサービス化
30
+ * すると引き継がれない)。`uname -r` は環境変数に依存せずカーネル自体から
31
+ * "5.15.x-microsoft-standard-WSL2" のような値を返すため、より確実な最終フォールバック
32
+ * として使う。
33
+ *
34
+ * @param {{execImpl?: (cmd: string, args: string[]) => string}} [opts] テスト用の exec 差し替え
24
35
  * @returns {"darwin"|"wsl"|"linux"|"win32"|string}
25
36
  */
26
- export function detectPlatform() {
37
+ export function detectPlatform({ execImpl } = {}) {
27
38
  if (process.platform === "darwin") return "darwin"
28
39
  if (process.platform === "win32") return "win32"
29
40
  if (process.platform === "linux") {
@@ -32,7 +43,14 @@ export function detectPlatform() {
32
43
  const v = readFileSync("/proc/version", "utf-8").toLowerCase()
33
44
  if (v.includes("microsoft") || v.includes("wsl")) return "wsl"
34
45
  } catch {
35
- /* /proc/version が読めない環境は素の linux 扱い */
46
+ /* /proc/version が読めない環境は次のフォールバックへ */
47
+ }
48
+ try {
49
+ const run = execImpl || ((cmd, args) => execFileSync(cmd, args, { encoding: "utf-8" }))
50
+ const release = run("uname", ["-r"]).toLowerCase()
51
+ if (release.includes("microsoft") || release.includes("wsl")) return "wsl"
52
+ } catch {
53
+ /* uname が無い/失敗する環境は素の linux 扱い */
36
54
  }
37
55
  return "linux"
38
56
  }
package/src/main.mjs CHANGED
@@ -3093,6 +3093,10 @@ async function dispatch(msg, ctx) {
3093
3093
  stream_id,
3094
3094
  resuming: info.resuming,
3095
3095
  thread_id: info.threadId,
3096
+ // Slice 6: 履歴 hydrate。resume 時のみ非空 (thread/start は常に [])。
3097
+ // consumers.py の per-stream relay は content-blind なので backend 側の
3098
+ // 変更は不要 (_AGENT_PER_STREAM_TYPES の codex.ready がそのまま運ぶ)。
3099
+ history: info.history || [],
3096
3100
  })
3097
3101
  } catch (err) {
3098
3102
  ctx.client.send({
package/src/usage.mjs CHANGED
@@ -1152,9 +1152,13 @@ export async function getUsage() {
1152
1152
  // チャット(SDK) の rate_limit_event が statusLine cache より新しければ、5h/7d を
1153
1153
  // その実値 (utilization) で上書きする。ターミナルが statusLine を更新していない
1154
1154
  // チャットモードでも 5h/7d がライブで動くようになる。
1155
+ // `>=` (2026-07-03): ファイル mtime はミリ秒未満の粒度が丸められる環境があり、
1156
+ // 同一ミリ秒 (またはそれ以下) の解像度で statusLine 書き込みと rate_limit_event が
1157
+ // 揃うとタイになり得る。プロセス内の直近イベント (chatRateLimits) の方が
1158
+ // ディスク read-back より鮮度の情報として確実なため、タイ時は上書きを優先する。
1155
1159
  if (
1156
1160
  chatRateLimits.updatedAtMs > 0 &&
1157
- (cacheMtime === null || chatRateLimits.updatedAtMs > cacheMtime)
1161
+ (cacheMtime === null || chatRateLimits.updatedAtMs >= cacheMtime)
1158
1162
  ) {
1159
1163
  if (chatRateLimits.five_hour) {
1160
1164
  result.last5h = {
@@ -1183,7 +1187,9 @@ export async function getUsage() {
1183
1187
  const chatSnap = await readChatUsageSnapshot()
1184
1188
  if (chatSnap && chatSnap.updatedAtMs > 0) {
1185
1189
  const baseline = Math.max(cacheMtime ?? 0, chatRateLimits.updatedAtMs || 0)
1186
- if (chatSnap.updatedAtMs > baseline) {
1190
+ // `>=` (上と同じ理由): ファイル mtime とプロセス内タイムスタンプがタイになり得る。
1191
+ // chatSnap は最も直接的な情報源 (SDK control request 実値) なのでタイ時は優先する。
1192
+ if (chatSnap.updatedAtMs >= baseline) {
1187
1193
  if (chatSnap.five_hour) {
1188
1194
  result.last5h = {
1189
1195
  ...result.last5h,