@cocorograph/hub-agent 0.7.15 → 0.7.16

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.15",
3
+ "version": "0.7.16",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/tmux.mjs CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  detectSessionState,
28
28
  getSessionCwd,
29
29
  } from "./state.mjs"
30
- import { getSessionUsages } from "./usage.mjs"
30
+ import { getSessionUsages, jsonlContextForCwd } from "./usage.mjs"
31
31
 
32
32
  const execFileP = promisify(execFile)
33
33
 
@@ -634,10 +634,23 @@ export async function listSessions(opts = {}) {
634
634
  detectSessionState(s.name, opts),
635
635
  getSessionCwd(s.name, opts),
636
636
  ])
637
- // statusLine 由来 context% を最優先 (USED %), なければ pane 解析の fallback
637
+ // context% (USED %) は以下の順で採用する:
638
+ // 1. jsonl per-cwd で「`/clear` 直後 (uuid 変化) かつ新 jsonl に応答未着」
639
+ // と確定したら 0% を即採用 (= リセット最速反映)。statusLine cache は
640
+ // 前セッションの値を抱えたまま居座り、次のプロンプトまで更新されない。
641
+ // 2. それ以外は statusLine cache (USED %) を最優先 (claude 公式値)。
642
+ // 3. statusLine が無ければ jsonl per-cwd の末尾 assistant.usage 由来 %。
643
+ // 4. それも無ければ pane scrape の正規表現フォールバック。
638
644
  const fromStatusLine = cwd ? ctxByCwd.get(cwd)?.contextPercent : null
645
+ const jsonlInfo = cwd ? await jsonlContextForCwd(cwd) : null
639
646
  const context_pct =
640
- typeof fromStatusLine === "number" ? fromStatusLine : state.context_pct
647
+ jsonlInfo?.isReset && typeof jsonlInfo.percent === "number"
648
+ ? jsonlInfo.percent
649
+ : typeof fromStatusLine === "number"
650
+ ? fromStatusLine
651
+ : typeof jsonlInfo?.percent === "number"
652
+ ? jsonlInfo.percent
653
+ : state.context_pct
641
654
  return {
642
655
  ...s,
643
656
  status: state.status,
package/src/usage.mjs CHANGED
@@ -21,6 +21,8 @@ import { randomUUID } from "node:crypto"
21
21
  import os from "node:os"
22
22
  import path from "node:path"
23
23
 
24
+ import { encodeCwdToDirName } from "./claude-history.mjs"
25
+
24
26
  function configPath(envKey, ...fallback) {
25
27
  return process.env[envKey] || path.join(...fallback)
26
28
  }
@@ -529,6 +531,132 @@ async function latestJsonlContext(now) {
529
531
  return result
530
532
  }
531
533
 
534
+ /**
535
+ * cwd → 最後に観測した newest jsonl uuid。`/clear` 検知 (uuid 変化 → 新セッション)
536
+ * に使う。`jsonlContextForCwd` の呼び出しごとに更新される。
537
+ */
538
+ const _jsonlSessionUuidByCwd = new Map()
539
+
540
+ /**
541
+ * 指定 cwd の Claude セッション jsonl から context% (USED %) を算出する。
542
+ *
543
+ * 役割:
544
+ * - `latestJsonlContext` が「直近全プロジェクト共通の最新 1 件」を返すのに対し、
545
+ * こちらは「この cwd の最新セッション」を返す。tmux セッション 1 つに対する
546
+ * ドーナツ表示の per-session ソースとして使う。
547
+ * - `/clear` 直後は新規 jsonl ファイル (新 uuid) が作成される。assistant.usage が
548
+ * まだ書かれていない瞬間は前回 uuid と異なれば「コンテキスト全消し」と確定でき、
549
+ * statusLine cache が前セッションの値を抱えたまま 0% に落ちない不具合を回避する。
550
+ *
551
+ * 動作:
552
+ * 1. cwd → encoded dir → `*.jsonl` のうち最新 mtime を newest として選ぶ
553
+ * 2. newest jsonl の末尾 assistant.usage を `(input + cache_read + cache_creation
554
+ * + output) / window_size` で % 化
555
+ * 3. 末尾に assistant.usage が無い (= 新セッション直後で 1 通も応答していない)
556
+ * 場合、前回観測 uuid と異なれば `{ percent: 0, isReset: true }` を返す。
557
+ * 同じ uuid のまま (= 初回ロード直後) なら `{ percent: null, isReset: false }`。
558
+ *
559
+ * 呼び出し側 (`tmux.mjs listSessionStates`) の優先順位:
560
+ * - `isReset === true` のとき statusLine cache の前セッション値より優先 (= 0% を採用)
561
+ * - そうでなければ statusLine > jsonl > pane scrape の順
562
+ *
563
+ * @param {string} cwd
564
+ * @returns {Promise<{ percent: number | null, uuid: string | null, isReset: boolean }>}
565
+ */
566
+ export async function jsonlContextForCwd(cwd) {
567
+ const empty = { percent: null, uuid: null, isReset: false }
568
+ if (!cwd) return empty
569
+ const dirName = encodeCwdToDirName(cwd)
570
+ if (!dirName) return empty
571
+ const dir = path.join(projectsDir(), dirName)
572
+ const files = await fs.readdir(dir).catch(() => null)
573
+ if (!files) return empty
574
+
575
+ let newest = null // { fp, mtimeMs, size, uuid }
576
+ await Promise.all(
577
+ files.map(async (f) => {
578
+ if (!f.endsWith(".jsonl")) return
579
+ const fp = path.join(dir, f)
580
+ const st = await fs.stat(fp).catch(() => null)
581
+ if (!st) return
582
+ if (!newest || st.mtimeMs > newest.mtimeMs) {
583
+ newest = {
584
+ fp,
585
+ mtimeMs: st.mtimeMs,
586
+ size: st.size,
587
+ uuid: f.replace(/\.jsonl$/, ""),
588
+ }
589
+ }
590
+ }),
591
+ )
592
+ if (!newest) return empty
593
+
594
+ // 末尾 assistant.usage を探す。`_jsonlCtxMemo` は fp+mtime+size をキーにしているので
595
+ // 同 jsonl の連続呼び出しは I/O ゼロでヒットする。
596
+ const memo = _jsonlCtxMemo.get(newest.fp)
597
+ let percent = null
598
+ if (memo && memo.mtimeMs === newest.mtimeMs && memo.size === newest.size) {
599
+ percent = memo.result ? memo.result.percent : null
600
+ } else {
601
+ const windowSize = await contextWindowSize()
602
+ const scan = (text) => {
603
+ const lines = text.split("\n")
604
+ for (let i = lines.length - 1; i >= 0; i--) {
605
+ const line = lines[i]
606
+ if (!line || !line.includes('"usage"')) continue
607
+ let d
608
+ try {
609
+ d = JSON.parse(line)
610
+ } catch {
611
+ continue
612
+ }
613
+ if (d.type !== "assistant") continue
614
+ const u = d.message?.usage
615
+ if (!u) continue
616
+ const tokens =
617
+ (u.input_tokens || 0) +
618
+ (u.cache_read_input_tokens || 0) +
619
+ (u.cache_creation_input_tokens || 0) +
620
+ (u.output_tokens || 0)
621
+ if (tokens <= 0) continue
622
+ return Math.min(100, Math.round((tokens / windowSize) * 1000) / 10)
623
+ }
624
+ return null
625
+ }
626
+ const tail = await readTail(newest.fp)
627
+ if (tail != null) percent = scan(tail)
628
+ if (percent == null) {
629
+ const full = await readOrNull(newest.fp)
630
+ if (full != null) percent = scan(full)
631
+ }
632
+ const result = percent == null ? null : { percent, mtimeMs: newest.mtimeMs }
633
+ _jsonlCtxMemo.set(newest.fp, {
634
+ mtimeMs: newest.mtimeMs,
635
+ size: newest.size,
636
+ result,
637
+ })
638
+ }
639
+
640
+ const prevUuid = _jsonlSessionUuidByCwd.get(cwd)
641
+ _jsonlSessionUuidByCwd.set(cwd, newest.uuid)
642
+ const uuidChanged = prevUuid !== undefined && prevUuid !== newest.uuid
643
+
644
+ if (percent == null) {
645
+ // assistant.usage が見つからない = まだ 1 通も応答していない新セッション。
646
+ // 直前まで別 uuid を観測していたなら `/clear` 直後と確定でき 0% を返す。
647
+ if (uuidChanged) {
648
+ return { percent: 0, uuid: newest.uuid, isReset: true }
649
+ }
650
+ return { percent: null, uuid: newest.uuid, isReset: false }
651
+ }
652
+ return { percent, uuid: newest.uuid, isReset: uuidChanged }
653
+ }
654
+
655
+ /** テスト用: per-cwd uuid 観測ストアをクリアする。 */
656
+ export function _resetJsonlSessionUuids() {
657
+ _jsonlSessionUuidByCwd.clear()
658
+ }
659
+
532
660
  /**
533
661
  * 5h / 7d 集計の UsageStats を返す。statusLine が無い環境では transcript
534
662
  * から推定する。