@cocorograph/hub-agent 0.7.14 → 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 +1 -1
- package/src/main.mjs +7 -3
- package/src/tmux.mjs +16 -3
- package/src/usage.mjs +128 -0
- package/src/ws-client.mjs +33 -11
package/package.json
CHANGED
package/src/main.mjs
CHANGED
|
@@ -1672,9 +1672,13 @@ export async function handleTuiInterrupt(msg, ctx) {
|
|
|
1672
1672
|
const sendEsc = ctx.sendInterruptKey || sendInterruptKey
|
|
1673
1673
|
const detect = ctx.detectSessionState || detectSessionState
|
|
1674
1674
|
const delay = ctx.delay || ((ms) => new Promise((r) => setTimeout(r, ms)))
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1675
|
+
// 症状⑤: 初回チェックまでの settle と poll 間隔を 350→150ms に詰め、maxPolls を 7→16 に
|
|
1676
|
+
// 増やす。総 deadline は ~2.4s で従来(~2.5s)とほぼ同じだが、実際に停止した瞬間の観測が
|
|
1677
|
+
// 約2倍速くなり「停止押下→画面が止まる」体感ラグを縮める。capture-pane スクレイプ回数は
|
|
1678
|
+
// 増えるが 150ms 間隔なら許容範囲。Ctrl+C 非エスカレーション方針は不変。
|
|
1679
|
+
const settleMs = ctx.interruptSettleMs ?? 150
|
|
1680
|
+
const pollMs = ctx.interruptPollMs ?? 150
|
|
1681
|
+
const maxPolls = ctx.interruptMaxPolls ?? 16 // ~2.4s deadline (150 + 150*15)
|
|
1678
1682
|
const reply = (stopped, attempts) =>
|
|
1679
1683
|
ctx.client.send({
|
|
1680
1684
|
type: "claude.tui.interrupt.result",
|
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,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
|
* から推定する。
|
package/src/ws-client.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* - 接続: `Authorization: Bearer <agent_id>:<agent_token>`
|
|
5
5
|
* - 起動時に `hello`、30s おきに `heartbeat` を送信
|
|
6
|
-
* - 切断時は exponential backoff (1s, 2s, 4s,
|
|
6
|
+
* - 切断時は exponential backoff (1s, 2s, 4s, 8s max) で再接続
|
|
7
7
|
* - サーバから受け取った JSON は `onMessage` callback に渡す
|
|
8
8
|
* - `bundleVersionProvider` + `bundleManifestPath` を渡すと、manifest.json の
|
|
9
9
|
* 変更を fs.watch で検知して即時 heartbeat を送信し Cockpit UI に最新版を
|
|
@@ -20,7 +20,10 @@ import WebSocket from "ws"
|
|
|
20
20
|
|
|
21
21
|
const HEARTBEAT_INTERVAL_MS = 30_000
|
|
22
22
|
const MIN_BACKOFF_MS = 1_000
|
|
23
|
-
|
|
23
|
+
// 症状①(接続復帰の遅さ)対策: 旧 30s。本番ログで再接続 p50=1.9s に対し、フラッピング時に
|
|
24
|
+
// backoff が指数増加して復帰レイテンシのテール(p90=9s, 最大35s)を作っていた。実測の切断は
|
|
25
|
+
// ほぼ瞬断(CF/NW)で 8s も待てば回復に十分。jitter(±20%)で thundering herd は引き続き回避。
|
|
26
|
+
const MAX_BACKOFF_MS = 8_000
|
|
24
27
|
const BUNDLE_WATCH_DEBOUNCE_MS = 500
|
|
25
28
|
|
|
26
29
|
// zombie WS (half-open TCP) 検知のしきい値。backend は heartbeat (30s) のたびに
|
|
@@ -196,15 +199,18 @@ export class WsClient extends EventEmitter {
|
|
|
196
199
|
* のクロージャだと spy しづらい)。
|
|
197
200
|
*/
|
|
198
201
|
_onOpen() {
|
|
199
|
-
// backoff の即時リセットはしない。接続が STABLE_CONNECTION_MS 維持できてから
|
|
200
|
-
// _armStableReset() でリセットする (open→即 close フラッピング時の再接続
|
|
201
|
-
// ストロボ対策)。すぐ切れた接続では backoff がリセットされず指数バックオフが
|
|
202
|
-
// 効き続けるため、1〜2 秒間隔の再接続ループに陥らない。
|
|
203
|
-
this._armStableReset()
|
|
204
202
|
// open 自体が生存の証拠。直前接続の古い lastRecvAt で開幕直後に zombie 誤検知
|
|
205
|
-
//
|
|
203
|
+
// しないようリセットする。⚠️ _armStableReset() の armedAt 捕捉より「前」に設定すること。
|
|
204
|
+
// ack ベース判定 (lastRecvAt > armedAt) を初期状態で偽にするため (順序を逆にすると
|
|
205
|
+
// armedAt < lastRecvAt となり「inbound 受信済み」と誤判定し、half-open でも backoff を
|
|
206
|
+
// リセットしてしまう)。
|
|
206
207
|
this.lastRecvAt = Date.now()
|
|
207
208
|
this.lastTickAt = Date.now()
|
|
209
|
+
// backoff の即時リセットはしない。接続が STABLE_CONNECTION_MS 維持 + inbound 受信できてから
|
|
210
|
+
// _armStableReset() でリセットする (open→即 close フラッピング時の再接続ストロボ対策)。
|
|
211
|
+
// すぐ切れた / half-open の接続では backoff がリセットされず指数バックオフが効き続けるため、
|
|
212
|
+
// 1〜2 秒間隔の再接続ループに陥らない。
|
|
213
|
+
this._armStableReset()
|
|
208
214
|
this.logger?.info("ws open")
|
|
209
215
|
// 切断中に積んだ pty.data を flush。hello より先に送ると backend で stream
|
|
210
216
|
// 未登録のため unknown_stream error になる可能性があるが、hello の応答待ち
|
|
@@ -583,11 +589,27 @@ export class WsClient extends EventEmitter {
|
|
|
583
589
|
*/
|
|
584
590
|
_armStableReset() {
|
|
585
591
|
this._clearStableReset()
|
|
592
|
+
// 症状①: backoff リセットを「ack ベース」にする。armedAt(≈open 時刻) を捕捉し、
|
|
593
|
+
// STABLE_CONNECTION_MS 後に「open 以降に inbound を受信したか(lastRecvAt > armedAt)」で
|
|
594
|
+
// 判定する。受信あり = 双方向に健全とみなし backoff/5xx をリセット。受信ゼロ =
|
|
595
|
+
// half-open(send は kernel buffer に書けて成功扱いだが TCP は死)とみなし、75s の zombie
|
|
596
|
+
// 検知を待たずに即 forceReconnect する(再接続後の half-open を 10s で回収し復帰を早める)。
|
|
597
|
+
// _onOpen は lastRecvAt=now を armedAt より前に設定するため、初期状態では
|
|
598
|
+
// lastRecvAt <= armedAt となり「未受信」と正しく判定される。
|
|
599
|
+
const armedAt = Date.now()
|
|
586
600
|
this.stableResetTimer = setTimeout(() => {
|
|
587
601
|
this.stableResetTimer = null
|
|
588
|
-
this.
|
|
589
|
-
|
|
590
|
-
|
|
602
|
+
if (this.lastRecvAt > armedAt) {
|
|
603
|
+
this.backoff = MIN_BACKOFF_MS
|
|
604
|
+
this.lastCloseWas5xx = false
|
|
605
|
+
this.logger?.debug("ws connection stable (inbound received), backoff reset")
|
|
606
|
+
} else {
|
|
607
|
+
this.logger?.warn(
|
|
608
|
+
{ sinceArmedMs: Date.now() - armedAt },
|
|
609
|
+
"ws stable window elapsed with no inbound (half-open suspected), forcing reconnect",
|
|
610
|
+
)
|
|
611
|
+
this._forceReconnect()
|
|
612
|
+
}
|
|
591
613
|
}, STABLE_CONNECTION_MS)
|
|
592
614
|
this.stableResetTimer.unref?.()
|
|
593
615
|
}
|