@cocorograph/hub-agent 0.7.15 → 0.7.17
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 +1 -1
- package/src/main.mjs +23 -1
- package/src/tmux.mjs +16 -3
- package/src/usage.mjs +264 -0
package/package.json
CHANGED
package/src/main.mjs
CHANGED
|
@@ -91,6 +91,7 @@ import {
|
|
|
91
91
|
getSessionUsages,
|
|
92
92
|
getUsage,
|
|
93
93
|
recordChatRateLimit,
|
|
94
|
+
turnActiveForCwd,
|
|
94
95
|
} from "./usage.mjs"
|
|
95
96
|
import {
|
|
96
97
|
clearChatSignal,
|
|
@@ -1298,6 +1299,21 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1298
1299
|
stable,
|
|
1299
1300
|
now: nowMs,
|
|
1300
1301
|
})
|
|
1302
|
+
|
|
1303
|
+
// turn_active: ターンが現在進行中か (true=生成/ツール実行中 / false=待機中 / null=不明)。
|
|
1304
|
+
// CockpitStatusDot の青/赤の権威ソース。サイドバーのドットと送信/停止ボタンを同じ真実で
|
|
1305
|
+
// 同期させ、Esc 中断後の青固着 (PROMPT_STALE_SHORT_MS=60s) を解消する。
|
|
1306
|
+
// - チャットモード: SDK の rate_limit_event/assistant が最も新鮮なので chat.status を採用
|
|
1307
|
+
// - TUI モード: jsonl 末尾 assistant.stop_reason から判定 (turnActiveForCwd)
|
|
1308
|
+
let turnActive = null
|
|
1309
|
+
if (chat?.status === "processing") {
|
|
1310
|
+
turnActive = true
|
|
1311
|
+
} else if (chat?.status === "waiting" || chat?.status === "idle") {
|
|
1312
|
+
turnActive = false
|
|
1313
|
+
} else if (s.cwd) {
|
|
1314
|
+
turnActive = await turnActiveForCwd(s.cwd)
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1301
1317
|
const prev = lastByName.get(s.session_name)
|
|
1302
1318
|
if (
|
|
1303
1319
|
!prev ||
|
|
@@ -1307,7 +1323,8 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1307
1323
|
prev.stable !== stable ||
|
|
1308
1324
|
prev.proc_busy !== procBusy ||
|
|
1309
1325
|
prev.child_busy !== childBusy ||
|
|
1310
|
-
prev.stalled !== stalled
|
|
1326
|
+
prev.stalled !== stalled ||
|
|
1327
|
+
prev.turn_active !== turnActive
|
|
1311
1328
|
) {
|
|
1312
1329
|
lastByName.set(s.session_name, {
|
|
1313
1330
|
status,
|
|
@@ -1317,6 +1334,7 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1317
1334
|
proc_busy: procBusy,
|
|
1318
1335
|
child_busy: childBusy,
|
|
1319
1336
|
stalled,
|
|
1337
|
+
turn_active: turnActive,
|
|
1320
1338
|
})
|
|
1321
1339
|
// 計装 (2026-06-19, 既定 OFF=2026-06-20): 状態変化時に値を記録する。常時 info はログ肥大の
|
|
1322
1340
|
// 主因のため HUB_AGENT_STATE_TRACE=1 のときだけ出す。childBusy/outputActive を分けて出し、
|
|
@@ -1333,6 +1351,7 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1333
1351
|
armed,
|
|
1334
1352
|
stable,
|
|
1335
1353
|
context_pct: contextPct,
|
|
1354
|
+
turn_active: turnActive,
|
|
1336
1355
|
},
|
|
1337
1356
|
"session.state push",
|
|
1338
1357
|
)
|
|
@@ -1349,6 +1368,9 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1349
1368
|
// 出力残り火 (proc_busy 残存) でアイドルなのにキューへ逃がす誤キュー (症状A) を断つために使う。
|
|
1350
1369
|
child_busy: childBusy,
|
|
1351
1370
|
stalled,
|
|
1371
|
+
// turn_active: ステータスドット 4 状態統一の権威ソース。null は旧クライアント互換
|
|
1372
|
+
// (frontend は欠落時に旧 prompt_submit/stale 降格ロジックへフォールバック)。
|
|
1373
|
+
turn_active: turnActive,
|
|
1352
1374
|
})
|
|
1353
1375
|
}
|
|
1354
1376
|
// 消滅セッションの GC (perf監査/メモリリーク対策): tmux から消えたセッションの状態 Map を
|
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
|
-
//
|
|
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
|
|
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,268 @@ 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
|
+
|
|
660
|
+
// ターン完了を示す Anthropic API `stop_reason`。これ以外 (代表的には `tool_use`)
|
|
661
|
+
// は「ターン継続中 = 生成 / ツール処理中」とみなす。
|
|
662
|
+
const TURN_END_STOP_REASONS = new Set([
|
|
663
|
+
"end_turn",
|
|
664
|
+
"stop_sequence",
|
|
665
|
+
"max_tokens",
|
|
666
|
+
])
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* jsonl の末尾を順走査してターンが現在 active かを返す。
|
|
670
|
+
*
|
|
671
|
+
* ステートマシン:
|
|
672
|
+
* - `assistant` 行を見たら `stop_reason` でターン状態を更新する
|
|
673
|
+
* - end_turn / stop_sequence / max_tokens → 完了 (active=false)
|
|
674
|
+
* - tool_use (or それ以外) → 継続 (active=true)
|
|
675
|
+
* - `user` 行を見たとき、それが「実ユーザー入力」(tool_result でない) かつ
|
|
676
|
+
* 直前ターンが完了済みだったら、新ターンが開始されたので active=true
|
|
677
|
+
* - その他の型 (attachment / mode / hook_* / file-history-snapshot 等) は無視
|
|
678
|
+
*
|
|
679
|
+
* 初期値は「ターン完了状態」(lastTurnEnded=true)。空 jsonl や応答未着 のセッション
|
|
680
|
+
* は active=false でフォールバックする。
|
|
681
|
+
*
|
|
682
|
+
* @param {string} text jsonl の (末尾) テキスト
|
|
683
|
+
* @returns {boolean}
|
|
684
|
+
*/
|
|
685
|
+
export function scanTurnActive(text) {
|
|
686
|
+
if (!text) return false
|
|
687
|
+
let active = false
|
|
688
|
+
let lastTurnEnded = true
|
|
689
|
+
const lines = text.split("\n")
|
|
690
|
+
for (const line of lines) {
|
|
691
|
+
if (!line) continue
|
|
692
|
+
let d
|
|
693
|
+
try {
|
|
694
|
+
d = JSON.parse(line)
|
|
695
|
+
} catch {
|
|
696
|
+
continue
|
|
697
|
+
}
|
|
698
|
+
if (d.type === "assistant") {
|
|
699
|
+
const stop = d.message?.stop_reason
|
|
700
|
+
if (stop && TURN_END_STOP_REASONS.has(stop)) {
|
|
701
|
+
active = false
|
|
702
|
+
lastTurnEnded = true
|
|
703
|
+
} else {
|
|
704
|
+
// tool_use / pause_turn / null / 未知 → ターン継続中扱い (安全側)
|
|
705
|
+
active = true
|
|
706
|
+
lastTurnEnded = false
|
|
707
|
+
}
|
|
708
|
+
} else if (d.type === "user") {
|
|
709
|
+
// 「実ユーザー入力」と「tool_result の自動 user ラッパー」を区別する。
|
|
710
|
+
// tool_result が含まれる user は前ターンの一部 (claude code 内部生成) なので、
|
|
711
|
+
// ターン状態を変えない。実ユーザー入力 (text/image/document) が直前ターン完了後に
|
|
712
|
+
// 来たら新ターン開始 → active=true。
|
|
713
|
+
const content = Array.isArray(d.message?.content) ? d.message.content : []
|
|
714
|
+
const isToolResult = content.some((c) => c?.type === "tool_result")
|
|
715
|
+
if (!isToolResult && lastTurnEnded) {
|
|
716
|
+
active = true
|
|
717
|
+
lastTurnEnded = false
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return active
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** P5: turnActiveForCwd のメモ (newest fp + mtime + size をキー)。 */
|
|
725
|
+
const _turnActiveMemo = new Map()
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* 指定 cwd の Claude セッション jsonl から「現在ターンが進行中か」を返す。
|
|
729
|
+
*
|
|
730
|
+
* `CockpitStatusDot` の中央ドットの判定権威ソース。サイドバーのドット (非選択
|
|
731
|
+
* セッション) と送信/停止ボタン (選択セッション、`turnActive` ストリーム由来) を
|
|
732
|
+
* 同じ真実で同期させ、Esc 中断後の「青固着 60s」を解消する。
|
|
733
|
+
*
|
|
734
|
+
* 動作:
|
|
735
|
+
* 1. cwd → encoded dir → 最新 mtime の *.jsonl を選ぶ
|
|
736
|
+
* 2. 末尾 256KB を tail 読みし `scanTurnActive` でターン状態を判定
|
|
737
|
+
* 3. tail で結論が出ない巨大ツール結果ケースのみ全文 read にフォールバック
|
|
738
|
+
*
|
|
739
|
+
* @param {string} cwd
|
|
740
|
+
* @returns {Promise<boolean | null>} true=生成中 / false=待機中 / null=jsonl 不在
|
|
741
|
+
*/
|
|
742
|
+
export async function turnActiveForCwd(cwd) {
|
|
743
|
+
if (!cwd) return null
|
|
744
|
+
const dirName = encodeCwdToDirName(cwd)
|
|
745
|
+
if (!dirName) return null
|
|
746
|
+
const dir = path.join(projectsDir(), dirName)
|
|
747
|
+
const files = await fs.readdir(dir).catch(() => null)
|
|
748
|
+
if (!files) return null
|
|
749
|
+
|
|
750
|
+
let newest = null
|
|
751
|
+
await Promise.all(
|
|
752
|
+
files.map(async (f) => {
|
|
753
|
+
if (!f.endsWith(".jsonl")) return
|
|
754
|
+
const fp = path.join(dir, f)
|
|
755
|
+
const st = await fs.stat(fp).catch(() => null)
|
|
756
|
+
if (!st) return
|
|
757
|
+
if (!newest || st.mtimeMs > newest.mtimeMs) {
|
|
758
|
+
newest = { fp, mtimeMs: st.mtimeMs, size: st.size }
|
|
759
|
+
}
|
|
760
|
+
}),
|
|
761
|
+
)
|
|
762
|
+
if (!newest) return null
|
|
763
|
+
|
|
764
|
+
const memo = _turnActiveMemo.get(newest.fp)
|
|
765
|
+
if (memo && memo.mtimeMs === newest.mtimeMs && memo.size === newest.size) {
|
|
766
|
+
return memo.result
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// 256KB tail で多くの場合は決着がつく (1 ターン分の assistant + tool_use/result サイクル) 。
|
|
770
|
+
// tail 内に assistant 行が 0 件のみのケース (= 巨大 tool_result で先頭を tail から外れた)
|
|
771
|
+
// に備え、結果が「全行で active 状態が変化しない」場合は全文読みにフォールバックする。
|
|
772
|
+
const tail = await readTail(newest.fp, 256 * 1024)
|
|
773
|
+
let active = false
|
|
774
|
+
if (tail != null) {
|
|
775
|
+
active = scanTurnActive(tail)
|
|
776
|
+
}
|
|
777
|
+
// tail で active=false の場合のみ、巨大 tool_result で先頭の assistant 行が
|
|
778
|
+
// 落ちて誤って完了扱いになっていないか全文で確認する (高コストだが必要時のみ)。
|
|
779
|
+
if (active === false) {
|
|
780
|
+
const full = await readOrNull(newest.fp)
|
|
781
|
+
if (full != null) active = scanTurnActive(full)
|
|
782
|
+
}
|
|
783
|
+
_turnActiveMemo.set(newest.fp, {
|
|
784
|
+
mtimeMs: newest.mtimeMs,
|
|
785
|
+
size: newest.size,
|
|
786
|
+
result: active,
|
|
787
|
+
})
|
|
788
|
+
return active
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** テスト用: turnActive メモをクリアする。 */
|
|
792
|
+
export function _resetTurnActiveMemo() {
|
|
793
|
+
_turnActiveMemo.clear()
|
|
794
|
+
}
|
|
795
|
+
|
|
532
796
|
/**
|
|
533
797
|
* 5h / 7d 集計の UsageStats を返す。statusLine が無い環境では transcript
|
|
534
798
|
* から推定する。
|