@cocorograph/hub-agent 0.7.75 → 0.7.76

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.75",
3
+ "version": "0.7.76",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -73,6 +73,68 @@ export function clearHydrateCacheForTest() {
73
73
  hydrateCache.clear()
74
74
  }
75
75
 
76
+ /**
77
+ * listSessions の並び順キー last_turn_at_ms を **cwd(dir) 単位** でキャッシュする。
78
+ *
79
+ * 背景 (finding 3): listSessions は Cockpit の閲覧ハートビート経路 (main.mjs) からも
80
+ * limit:1 で周期的に呼ばれる。毎回 cwd 配下の全 jsonl を stat→open→末尾 read すると、
81
+ * セッション数 N に比例した定期 I/O (最悪 64KB×N) が閲覧者ごとに走る。ほとんどのファイルは
82
+ * 会話が進んでいない (mtime/size 不変) ので、キャッシュヒット時はディスク読みを丸ごと省く。
83
+ *
84
+ * ⚠️ round2 finding: 単一グローバル LRU (上限 N 件) だと、1 dir のファイル数がその上限を
85
+ * 超えたとき「毎回同じ順で全件走査 → 先頭 miss が末尾側の既存エントリを順次追い出す」
86
+ * スキャン汚染で結局ほぼ全 miss になる (実環境に 1 dir 1996 件の履歴あり)。これを避けるため
87
+ * キャッシュを **dir → (filePath→{mtimeMs,size,lastTurnAtMs}) の 2 段**にし、LRU 退避は
88
+ * **dir 単位**でのみ行う。1 dir 内の全ファイルは同時に保持されるので、ファイル総数が
89
+ * どれだけ多くても 1 回の走査でキャッシュが自壊しない。dir 数を上限 (同時に見る cwd 数) で
90
+ * 縛るのでメモリも有界。dir 内は毎回の走査で「今存在するファイル」に prune する。
91
+ */
92
+ const LAST_TURN_DIR_CACHE_MAX = 64 // 同時にキャッシュを保持する cwd(dir) 数の上限
93
+ const lastTurnDirCache = new Map() // dir -> Map<filePath, {mtimeMs, size, lastTurnAtMs}>
94
+
95
+ // テスト用: extractLastTurnAtMs の実 I/O 呼び出し回数 (キャッシュ有効性の回帰テスト用)。
96
+ let extractCallCountForTest = 0
97
+ export function getExtractCallCountForTest() {
98
+ return extractCallCountForTest
99
+ }
100
+ export function resetExtractCallCountForTest() {
101
+ extractCallCountForTest = 0
102
+ }
103
+
104
+ function getLastTurnDirMap(dir) {
105
+ let fileMap = lastTurnDirCache.get(dir)
106
+ if (fileMap) {
107
+ // LRU touch (再挿入で挿入順を更新)。
108
+ lastTurnDirCache.delete(dir)
109
+ lastTurnDirCache.set(dir, fileMap)
110
+ return fileMap
111
+ }
112
+ fileMap = new Map()
113
+ lastTurnDirCache.set(dir, fileMap)
114
+ while (lastTurnDirCache.size > LAST_TURN_DIR_CACHE_MAX) {
115
+ const oldest = lastTurnDirCache.keys().next().value
116
+ lastTurnDirCache.delete(oldest)
117
+ }
118
+ return fileMap
119
+ }
120
+
121
+ async function getLastTurnAtMsCached(fileMap, filePath, mtimeMs, size) {
122
+ const entry = fileMap.get(filePath)
123
+ if (entry && entry.mtimeMs === mtimeMs && entry.size === size) return entry.lastTurnAtMs
124
+ // extract に fallback を渡さず null を受ける。巨大最終行 (>4MB) 等で抽出不能のとき、
125
+ // mtime へ飛ばして再浮上させるのではなく **前回の last_turn_at_ms を維持** する (sticky。
126
+ // round2 minor 対応)。前回値も無ければ最後の手段として mtime。
127
+ const extracted = await extractLastTurnAtMs(filePath, null)
128
+ const lastTurnAtMs = extracted ?? entry?.lastTurnAtMs ?? mtimeMs
129
+ fileMap.set(filePath, { mtimeMs, size, lastTurnAtMs })
130
+ return lastTurnAtMs
131
+ }
132
+
133
+ /** テスト用: last_turn_at_ms キャッシュを全消去する。 */
134
+ export function clearLastTurnCacheForTest() {
135
+ lastTurnDirCache.clear()
136
+ }
137
+
76
138
  /**
77
139
  * jsonl の生 object を、Browser が SDK message として受け取れる表示用 shape に正規化する。
78
140
  * 余分な内部メタは落とし、表示・集計に必要なフィールドだけを superset で拾う。
@@ -318,6 +380,124 @@ async function extractPreview(filePath) {
318
380
  return ""
319
381
  }
320
382
 
383
+ /**
384
+ * jsonl ファイルの「最後の会話ターン境界の時刻」(= 末尾メッセージに埋め込まれた timestamp) を
385
+ * ms epoch で返す。無ければ null。
386
+ *
387
+ * 背景 (症状⑨=実害): サイドバーの並び順はファイル mtime 降順だった (listSessions)。だが
388
+ * mtime は「会話への書き込み」以外の操作 (resume 時の jsonl 書き戻し・compact/summary の
389
+ * 全書き換え・ツールによる touch 等) でも bump するため、古いセッションを開いただけで最上位に
390
+ * 浮上する等の並び順ズレを起こす。会話の各行には ISO8601 の `timestamp` が埋め込まれており、
391
+ * これは resume 書き戻しでも歴史値として保存される (= 実際のターン発生時刻)。末尾行の
392
+ * timestamp を採れば「最後にターンが動いた時刻」で安定して並べられる。
393
+ *
394
+ * 末尾だけを読むため、ファイルサイズに関係なく最後の数十 KB だけを fd で読む
395
+ * (Claude jsonl は append-then-close なので末尾行は必ず完結している)。
396
+ *
397
+ * ⚠️ 2 つの落とし穴を回避している:
398
+ * 1. **巨大な最終行** (tool_result / 画像 base64 埋め込み等で 1 メッセージが 64KB 超も普通)。
399
+ * 固定窓だと最終行が窓境界で切れて JSON.parse できず取りこぼす。→ user/assistant の
400
+ * message 行を拾えるまで窓を倍々に読み広げる (上限 MAX_READ)。
401
+ * 2. **非ターン行の timestamp** (message 後の `attachment` / `system` の stop_hook_summary /
402
+ * turn_duration 等にも timestamp があり、会話ターン境界でない操作で時刻が進む)。
403
+ * → 採用対象を `type in {user, assistant}` かつ `message` を持つ行に限定する。
404
+ *
405
+ * @param {string} filePath
406
+ * @param {number} fallbackMtimeMs stat 済みなら渡す (末尾に会話 message が無い時の保険)
407
+ * @returns {Promise<number|null>}
408
+ */
409
+ const LAST_TURN_TAIL_CHUNK = 65536
410
+ const LAST_TURN_MAX_READ = 4 * 1024 * 1024 // 4MB を超えて遡っても割に合わない → fallback
411
+
412
+ /** テキスト中の「最後の user/assistant message 行」の timestamp(ms) を返す。無ければ null。 */
413
+ function lastTurnTsFromText(text) {
414
+ const lines = text.split("\n")
415
+ for (let i = lines.length - 1; i >= 0; i--) {
416
+ const line = lines[i]
417
+ if (!line) continue
418
+ let obj
419
+ try {
420
+ obj = JSON.parse(line)
421
+ } catch {
422
+ continue
423
+ }
424
+ // 会話ターン境界 = user/assistant の message 行のみ (finding 2)。
425
+ if ((obj?.type === "user" || obj?.type === "assistant") && obj.message) {
426
+ const ts = obj.timestamp
427
+ if (typeof ts === "string") {
428
+ const ms = Date.parse(ts)
429
+ if (!Number.isNaN(ms)) return ms
430
+ }
431
+ }
432
+ }
433
+ return null
434
+ }
435
+
436
+ /**
437
+ * 巨大行 (読み込み上限 4MB を超え、完全な行として parse できない) で正規 parse が空振り
438
+ * したときの最終手段: tail テキストから最後の `"timestamp":"<ISO>"` を正規表現で拾う
439
+ * (round2 minor / round3 対応)。
440
+ *
441
+ * Claude jsonl の各行はトップレベルに `"timestamp"` を持ち、これは message 本文の**後ろ**に
442
+ * 来る (= 行末側)。よって「最後の一致」を採ればトップレベルの timestamp をほぼ確実に拾える
443
+ * (message 本文内に稀に "timestamp" があっても、それより後ろのトップレベル側が勝つ)。
444
+ * 完全 parse で type を検証できない degraded path 専用。mtime 再浮上 (症状⑨) を避けるための
445
+ * 保守的 fallback として、この不確実性を許容する。
446
+ */
447
+ const TIMESTAMP_FIELD_RE = /"timestamp":"([^"]+)"/g
448
+ function regexLastTimestampMs(text) {
449
+ let m
450
+ let last = null
451
+ TIMESTAMP_FIELD_RE.lastIndex = 0
452
+ while ((m = TIMESTAMP_FIELD_RE.exec(text)) !== null) last = m[1]
453
+ if (last == null) return null
454
+ const ms = Date.parse(last)
455
+ return Number.isNaN(ms) ? null : ms
456
+ }
457
+
458
+ async function extractLastTurnAtMs(filePath, fallbackMtimeMs = null) {
459
+ extractCallCountForTest++
460
+ let handle
461
+ try {
462
+ handle = await open(filePath, "r")
463
+ const st = await handle.stat()
464
+ if (st.size <= 0) return fallbackMtimeMs
465
+ let readLen = Math.min(LAST_TURN_TAIL_CHUNK, st.size)
466
+ let hugeLineText = null // 上限まで読んでも完全行を parse できなかった巨大行の raw tail
467
+ for (;;) {
468
+ const start = st.size - readLen
469
+ const buf = Buffer.allocUnsafe(readLen)
470
+ const { bytesRead } = await handle.read(buf, 0, readLen, start)
471
+ const text = buf.toString("utf-8", 0, bytesRead)
472
+ // 窓の先頭が partial の可能性: start>0 なら最初の改行より前 (途中から始まる行) を捨て、
473
+ // 完全な行だけを対象にする。改行が無い (= 1 行が窓より大きい) 場合は下で窓拡張。
474
+ const nl = text.indexOf("\n")
475
+ const usable = start > 0 ? (nl >= 0 ? text.slice(nl + 1) : "") : text
476
+ const ms = lastTurnTsFromText(usable)
477
+ if (ms != null) return ms
478
+ // 見つからず、まだファイル先頭に届いておらず上限内なら窓を倍にして読み足す
479
+ // (巨大な最終行を完全に取り込むまで遡る。finding 1)。
480
+ if (start <= 0 || readLen >= LAST_TURN_MAX_READ) {
481
+ // 上限まで読んでも完全行が取れなかった (start>0 のまま budget 到達) = 巨大最終行。
482
+ // cold path (キャッシュ prev 値も無い) で mtime へ飛んで再浮上しないよう、
483
+ // raw tail から timestamp を正規表現で拾う degraded fallback を試みる。
484
+ if (start > 0 && readLen >= LAST_TURN_MAX_READ) hugeLineText = text
485
+ break
486
+ }
487
+ readLen = Math.min(readLen * 2, st.size, LAST_TURN_MAX_READ)
488
+ }
489
+ if (hugeLineText != null) {
490
+ const ms = regexLastTimestampMs(hugeLineText)
491
+ if (ms != null) return ms
492
+ }
493
+ return fallbackMtimeMs
494
+ } catch {
495
+ return fallbackMtimeMs
496
+ } finally {
497
+ await handle?.close()
498
+ }
499
+ }
500
+
321
501
  /**
322
502
  * cwd 配下の全 jsonl ファイルから session 一覧を返す。
323
503
  * 各ファイルから session_id / 最終更新時刻 / サイズ / 最初の user message preview を抽出。
@@ -341,6 +521,9 @@ export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
341
521
  logger?.warn({ err: err.message, dir }, "claude history list failed")
342
522
  return { sessions: [], error: err.message }
343
523
  }
524
+ // この dir 専用の last_turn_at_ms キャッシュ (dir 単位 LRU。スキャン汚染しない。finding 3/round2)。
525
+ const fileMap = getLastTurnDirMap(dir)
526
+ const seenPaths = new Set()
344
527
  const stats = []
345
528
  for (const f of files) {
346
529
  if (!f.endsWith(".jsonl")) continue
@@ -348,13 +531,32 @@ export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
348
531
  const filePath = path.join(dir, f)
349
532
  try {
350
533
  const st = await stat(filePath)
351
- stats.push({ session_id, file_path: filePath, mtime: st.mtimeMs, size_bytes: st.size })
534
+ seenPaths.add(filePath)
535
+ // last_turn_at_ms = jsonl 末尾メッセージの埋め込み timestamp (会話ターン境界)。
536
+ // サイドバーの並び順はこれを使う (mtime は resume 書き戻し等でも bump し、古い
537
+ // セッションを開いただけで浮上する症状⑨の原因になるため)。抽出不能なら前回値 sticky。
538
+ // (mtimeMs+size キャッシュ経由。ハートビート経路の周期呼び出しで全ファイルを毎回
539
+ // 読み直さない。finding 3)
540
+ const last_turn_at_ms = await getLastTurnAtMsCached(fileMap, filePath, st.mtimeMs, st.size)
541
+ stats.push({
542
+ session_id,
543
+ file_path: filePath,
544
+ mtime: st.mtimeMs,
545
+ last_turn_at_ms,
546
+ size_bytes: st.size,
547
+ })
352
548
  } catch {
353
549
  // ignore individual file stat errors
354
550
  }
355
551
  }
356
- // 最新順 + limit 件に絞ってから preview 抽出 (全ファイル読みを避ける)
357
- stats.sort((a, b) => b.mtime - a.mtime)
552
+ // 消えた jsonl (削除・rotate) のキャッシュエントリを掃除し、dir 内キャッシュを有界に保つ。
553
+ if (fileMap.size > seenPaths.size) {
554
+ for (const key of fileMap.keys()) {
555
+ if (!seenPaths.has(key)) fileMap.delete(key)
556
+ }
557
+ }
558
+ // 会話ターン境界の新しい順 + limit 件に絞ってから preview 抽出 (全ファイル読みを避ける)
559
+ stats.sort((a, b) => b.last_turn_at_ms - a.last_turn_at_ms)
358
560
  const top = stats.slice(0, limit)
359
561
  const sessions = []
360
562
  for (const s of top) {
@@ -535,10 +535,12 @@ export class CodexStreamBridge extends EventEmitter {
535
535
  const result = await client.request("thread/list", {
536
536
  cwd: cwd || undefined,
537
537
  limit: limit || undefined,
538
- // recency_at は「読み込みも含めた実際の使用感」の並び順 (公式スキーマの説明:
539
- // "Unix timestamp used for thread recency ordering")。updated_at だと最終書き込み
540
- // のみで並ぶため、Claude 側の mtime (listSessions) と体感を揃えるならこちらが近い。
541
- sortKey: "recency_at",
538
+ // ⚠️ updated_at を使う (recency_at ではない)。recency_at は「読み込み(=スレッドを
539
+ // 開くだけ)も含めた使用感」の並び順なので、古いスレッドを開いただけでサイドバー最上位
540
+ // に浮上してしまう (症状⑨=実害)。サイドバーは「会話への書き込み(=ターン境界)」で
541
+ // 並べたいので、最終書き込み時刻の updated_at を採用する。
542
+ // (Claude 側 listSessions も jsonl 内の最終メッセージ timestamp = ターン境界に揃えた)
543
+ sortKey: "updated_at",
542
544
  sortDirection: "desc",
543
545
  })
544
546
  const threads = Array.isArray(result?.data) ? result.data : []
@@ -546,10 +548,12 @@ export class CodexStreamBridge extends EventEmitter {
546
548
  thread_id: t.id,
547
549
  cwd: t.cwd || "",
548
550
  preview: typeof t.preview === "string" ? t.preview.slice(0, 80) : "",
549
- // recencyAt 優先 (無ければ updatedAt) を ms へ変換。claude 側 ClaudeSessionSummary.mtime
550
- // (ms epoch) と同じ単位に揃え、frontend が同じ並び替えロジックを使い回せるようにする。
551
+ // updatedAt 優先 (無ければ recencyAt fallback) を ms へ変換。claude 側
552
+ // ClaudeSessionSummary.mtime (ms epoch) と同じ単位に揃え、frontend が同じ並び替え
553
+ // ロジックを使い回せるようにする。並び順の source of truth は上の sortKey なので、
554
+ // ここは表示用の mtime も updatedAt を優先させて sortKey と一貫させる。
551
555
  updated_at_ms:
552
- (typeof t.recencyAt === "number" ? t.recencyAt : t.updatedAt || 0) * 1000,
556
+ (typeof t.updatedAt === "number" ? t.updatedAt : t.recencyAt || 0) * 1000,
553
557
  }))
554
558
  } finally {
555
559
  client.stop()