@cocorograph/hub-agent 0.8.94 → 0.8.96
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/agents.mjs +12 -3
- package/src/claude-history.mjs +48 -8
- package/src/main.mjs +196 -128
- package/src/skills.mjs +31 -7
- package/src/startup-metrics.mjs +52 -0
package/package.json
CHANGED
package/src/agents.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks"
|
|
2
|
+
import { logPhaseTiming } from "./startup-metrics.mjs"
|
|
1
3
|
/**
|
|
2
4
|
* Claude Code の subagent (`~/.claude/agents/<category>/<name>.md`) を集計する。
|
|
3
5
|
*
|
|
@@ -28,13 +30,19 @@ function parseH1(text) {
|
|
|
28
30
|
return { title: "", description: "" }
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
export async function listAgents() {
|
|
33
|
+
export async function listAgents({ logger } = {}) {
|
|
34
|
+
const started = performance.now()
|
|
35
|
+
let scanned_files = 0
|
|
36
|
+
const finish = (agents) => {
|
|
37
|
+
logPhaseTiming(logger, "agents list timing", { total_ms: performance.now() - started, count: agents.length, scanned_files })
|
|
38
|
+
return agents
|
|
39
|
+
}
|
|
32
40
|
const agents = []
|
|
33
41
|
let categories
|
|
34
42
|
try {
|
|
35
43
|
categories = await fs.readdir(AGENTS_DIR)
|
|
36
44
|
} catch {
|
|
37
|
-
return []
|
|
45
|
+
return finish([])
|
|
38
46
|
}
|
|
39
47
|
for (const cat of categories) {
|
|
40
48
|
if (cat.startsWith(".") || cat.startsWith("_")) continue
|
|
@@ -55,6 +63,7 @@ export async function listAgents() {
|
|
|
55
63
|
for (const f of files) {
|
|
56
64
|
if (!f.endsWith(".md")) continue
|
|
57
65
|
if (f.startsWith("_")) continue
|
|
66
|
+
scanned_files += 1
|
|
58
67
|
const fp = path.join(catPath, f)
|
|
59
68
|
try {
|
|
60
69
|
const text = await fs.readFile(fp, "utf-8")
|
|
@@ -75,5 +84,5 @@ export async function listAgents() {
|
|
|
75
84
|
agents.sort(
|
|
76
85
|
(a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name),
|
|
77
86
|
)
|
|
78
|
-
return agents
|
|
87
|
+
return finish(agents)
|
|
79
88
|
}
|
package/src/claude-history.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks"
|
|
2
|
+
import { logPhaseTiming } from "./startup-metrics.mjs"
|
|
1
3
|
import { maskTurnText, sanitizeTurnText, truncateTurnText } from "./last-turn-texts.mjs"
|
|
2
4
|
/**
|
|
3
5
|
* Claude Code のローカル会話履歴 (`~/.claude/projects/<cwd-encoded>/<session-id>.jsonl`)
|
|
@@ -192,6 +194,28 @@ export function resetTitleMaxCarryBytesForTest() {
|
|
|
192
194
|
titleMaxCarryBytesForTest = 0
|
|
193
195
|
}
|
|
194
196
|
|
|
197
|
+
const CUSTOM_TITLE_MARKER = Buffer.from('"custom-title"')
|
|
198
|
+
const AI_TITLE_MARKER = Buffer.from('"ai-title"')
|
|
199
|
+
|
|
200
|
+
/** 印 ("custom-title" / "ai-title") を含む行だけをファイル順に返す (末尾の改行は含まない)。
|
|
201
|
+
* takeTitleFromLine は印を含まない行を捨てるだけなので、全行を分割して文字列化するのと結果は
|
|
202
|
+
* 同じ (印は ASCII で、UTF-8 のバイト列検索と文字列検索は一致する)。再起動直後の初回走査は
|
|
203
|
+
* 全文を読むため、全行の分割・文字列化が一覧取得の大半を占めていた。 */
|
|
204
|
+
function* titleMarkerLines(buf) {
|
|
205
|
+
let nextCustom = buf.indexOf(CUSTOM_TITLE_MARKER)
|
|
206
|
+
let nextAi = buf.indexOf(AI_TITLE_MARKER)
|
|
207
|
+
while (nextCustom >= 0 || nextAi >= 0) {
|
|
208
|
+
const hit = nextCustom < 0 ? nextAi : nextAi < 0 ? nextCustom : Math.min(nextCustom, nextAi)
|
|
209
|
+
const start = hit === 0 ? 0 : buf.lastIndexOf(NEWLINE_BYTE, hit - 1) + 1
|
|
210
|
+
const newline = buf.indexOf(NEWLINE_BYTE, hit)
|
|
211
|
+
const end = newline < 0 ? buf.length : newline
|
|
212
|
+
yield buf.subarray(start, end)
|
|
213
|
+
if (newline < 0) return
|
|
214
|
+
if (nextCustom >= 0 && nextCustom <= newline) nextCustom = buf.indexOf(CUSTOM_TITLE_MARKER, newline + 1)
|
|
215
|
+
if (nextAi >= 0 && nextAi <= newline) nextAi = buf.indexOf(AI_TITLE_MARKER, newline + 1)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
195
219
|
function takeTitleFromLine(line, entry) {
|
|
196
220
|
if (!line.includes('"custom-title"') && !line.includes('"ai-title"')) return
|
|
197
221
|
let obj
|
|
@@ -326,7 +350,7 @@ async function extractTitleCached(fileMap, filePath, size, mtimeMs) {
|
|
|
326
350
|
continue
|
|
327
351
|
}
|
|
328
352
|
const complete = region.subarray(0, lastNewline)
|
|
329
|
-
for (const lineBuf of
|
|
353
|
+
for (const lineBuf of titleMarkerLines(complete)) {
|
|
330
354
|
if (lineBuf.length > LAST_TURN_MAX_LINE) continue
|
|
331
355
|
const titleState = { customTitle, aiTitle }
|
|
332
356
|
takeTitleFromLine(lineBuf.toString("utf-8"), titleState)
|
|
@@ -337,9 +361,11 @@ async function extractTitleCached(fileMap, filePath, size, mtimeMs) {
|
|
|
337
361
|
titleMaxCarryBytesForTest = Math.max(titleMaxCarryBytesForTest, carry.length)
|
|
338
362
|
scannedBytes = position - carry.length
|
|
339
363
|
const consumed = region.subarray(0, lastNewline + 1)
|
|
340
|
-
fingerprintTail =
|
|
341
|
-
|
|
342
|
-
|
|
364
|
+
fingerprintTail = consumed.length >= TITLE_FINGERPRINT_SIZE
|
|
365
|
+
? consumed.subarray(consumed.length - TITLE_FINGERPRINT_SIZE)
|
|
366
|
+
: Buffer.concat([fingerprintTail, consumed]).subarray(
|
|
367
|
+
Math.max(0, fingerprintTail.length + consumed.length - TITLE_FINGERPRINT_SIZE),
|
|
368
|
+
)
|
|
343
369
|
}
|
|
344
370
|
|
|
345
371
|
const current = fileMap.get(filePath) || {}
|
|
@@ -715,6 +741,7 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
|
|
|
715
741
|
const cached = getHydrateCache(filePath, st.mtimeMs, st.size, maxLines)
|
|
716
742
|
if (cached) return cached
|
|
717
743
|
|
|
744
|
+
const readStarted = performance.now()
|
|
718
745
|
let text
|
|
719
746
|
try {
|
|
720
747
|
text = await readFile(filePath, "utf-8")
|
|
@@ -725,6 +752,7 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
|
|
|
725
752
|
logger?.warn({ err: err.message, filePath }, "claude history read failed")
|
|
726
753
|
throw err
|
|
727
754
|
}
|
|
755
|
+
const parseStarted = performance.now()
|
|
728
756
|
// 末尾 newline は捨てる
|
|
729
757
|
const lines = text.split("\n").filter((l) => l.length > 0)
|
|
730
758
|
const total_lines = lines.length
|
|
@@ -761,6 +789,11 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
|
|
|
761
789
|
if (MASK_ENABLED) event = maskMessageObject(event, toolNames)
|
|
762
790
|
events.push(event)
|
|
763
791
|
}
|
|
792
|
+
const finished = performance.now()
|
|
793
|
+
logPhaseTiming(logger, "claude history read timing", {
|
|
794
|
+
file_bytes: st.size, lines: total_lines,
|
|
795
|
+
read_ms: parseStarted - readStarted, parse_ms: finished - parseStarted, total_ms: finished - readStarted,
|
|
796
|
+
})
|
|
764
797
|
const result = { events, total_lines, truncated }
|
|
765
798
|
setHydrateCache(filePath, st.mtimeMs, st.size, maxLines, result)
|
|
766
799
|
return result
|
|
@@ -1215,7 +1248,13 @@ export async function extractLastTurnTexts(filePath, opts = {}) {
|
|
|
1215
1248
|
* @param {{cwd: string, projectsRoot?: string, limit?: number, logger?: import('pino').Logger}} args
|
|
1216
1249
|
*/
|
|
1217
1250
|
export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
|
|
1218
|
-
|
|
1251
|
+
const started = performance.now()
|
|
1252
|
+
let scanned_files = 0
|
|
1253
|
+
const finish = (result) => {
|
|
1254
|
+
logPhaseTiming(logger, "claude sessions timing", { total_ms: performance.now() - started, count: result.sessions.length, scanned_files })
|
|
1255
|
+
return result
|
|
1256
|
+
}
|
|
1257
|
+
if (!cwd) return finish({ sessions: [] })
|
|
1219
1258
|
const dir = path.join(
|
|
1220
1259
|
projectsRoot || path.join(os.homedir(), ".claude", "projects"),
|
|
1221
1260
|
encodeCwdToDirName(cwd),
|
|
@@ -1224,9 +1263,9 @@ export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
|
|
|
1224
1263
|
try {
|
|
1225
1264
|
files = await readdir(dir)
|
|
1226
1265
|
} catch (err) {
|
|
1227
|
-
if (err.code === "ENOENT") return { sessions: [] }
|
|
1266
|
+
if (err.code === "ENOENT") return finish({ sessions: [] })
|
|
1228
1267
|
logger?.warn({ err: err.message, dir }, "claude history list failed")
|
|
1229
|
-
return { sessions: [], error: err.message }
|
|
1268
|
+
return finish({ sessions: [], error: err.message })
|
|
1230
1269
|
}
|
|
1231
1270
|
// この dir 専用の last_turn_at_ms キャッシュ (dir 単位 LRU。スキャン汚染しない。finding 3/round2)。
|
|
1232
1271
|
const fileMap = getLastTurnDirMap(dir)
|
|
@@ -1234,6 +1273,7 @@ export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
|
|
|
1234
1273
|
const stats = []
|
|
1235
1274
|
for (const f of files) {
|
|
1236
1275
|
if (!f.endsWith(".jsonl")) continue
|
|
1276
|
+
scanned_files += 1
|
|
1237
1277
|
const session_id = f.slice(0, -".jsonl".length)
|
|
1238
1278
|
const filePath = path.join(dir, f)
|
|
1239
1279
|
try {
|
|
@@ -1271,7 +1311,7 @@ export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
|
|
|
1271
1311
|
const title = await extractTitleCached(fileMap, s.file_path, s.size_bytes, s.mtime)
|
|
1272
1312
|
sessions.push({ ...s, preview, ...(title === undefined ? {} : { title }) })
|
|
1273
1313
|
}
|
|
1274
|
-
return { sessions }
|
|
1314
|
+
return finish({ sessions })
|
|
1275
1315
|
}
|
|
1276
1316
|
|
|
1277
1317
|
/**
|
package/src/main.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks"
|
|
2
|
+
import { createHash } from "node:crypto"
|
|
3
|
+
import { logPhaseTiming, startEventLoopMetrics } from "./startup-metrics.mjs"
|
|
1
4
|
/**
|
|
2
5
|
* hub-agent daemon 本体。
|
|
3
6
|
*
|
|
@@ -230,6 +233,9 @@ async function loadClaudeSdk(logger) {
|
|
|
230
233
|
/** アカウント利用量取得の時間制限 (ms)。超えたらレーンを解放して次へ進む。 */
|
|
231
234
|
export const ACCOUNT_USAGE_TIMEOUT_MS = 8_000
|
|
232
235
|
|
|
236
|
+
/** Codex アカウント利用制限の取得待ちの上限 (ms)。 */
|
|
237
|
+
export const CODEX_RATE_LIMITS_TIMEOUT_MS = 8_000
|
|
238
|
+
|
|
233
239
|
/**
|
|
234
240
|
* `promise` に時間制限を設ける。制限を超えたら `null` で解決し `onTimeout` を呼ぶ。
|
|
235
241
|
*
|
|
@@ -332,6 +338,8 @@ export function isReadOnlyRpc(type) {
|
|
|
332
338
|
)
|
|
333
339
|
}
|
|
334
340
|
|
|
341
|
+
const coalesceCounts = new WeakMap()
|
|
342
|
+
|
|
335
343
|
/**
|
|
336
344
|
* 同じ key の処理が実行中なら、その Promise を返して相乗りさせる (完了後は保持しない)。
|
|
337
345
|
* 結果をキャッシュするのではなく「同時に来た同一要求を 1 回の処理にまとめる」だけなので、
|
|
@@ -341,15 +349,28 @@ export function isReadOnlyRpc(type) {
|
|
|
341
349
|
* @param {Map<string, Promise<T>>} inflight
|
|
342
350
|
* @param {string} key
|
|
343
351
|
* @param {() => Promise<T>} fn
|
|
352
|
+
* @param {(counts: {joined: number}) => void} [onComplete] 計測用。返却値は変更しない。
|
|
344
353
|
* @returns {Promise<T>}
|
|
345
354
|
*/
|
|
346
|
-
export function coalesceInflight(inflight, key, fn) {
|
|
355
|
+
export function coalesceInflight(inflight, key, fn, onComplete) {
|
|
347
356
|
const running = inflight.get(key)
|
|
348
|
-
if (running)
|
|
357
|
+
if (running) {
|
|
358
|
+
const counts = coalesceCounts.get(running)
|
|
359
|
+
if (counts) counts.joined += 1
|
|
360
|
+
return running
|
|
361
|
+
}
|
|
349
362
|
const promise = Promise.resolve().then(fn)
|
|
350
363
|
inflight.set(key, promise)
|
|
364
|
+
const counts = { joined: 1 }
|
|
365
|
+
coalesceCounts.set(promise, counts)
|
|
351
366
|
const clear = () => {
|
|
352
367
|
if (inflight.get(key) === promise) inflight.delete(key)
|
|
368
|
+
// 計測用の通知は結果に影響させない (ここで投げると未処理 rejection になる)。
|
|
369
|
+
try {
|
|
370
|
+
onComplete?.(counts)
|
|
371
|
+
} catch {
|
|
372
|
+
// 無視する
|
|
373
|
+
}
|
|
353
374
|
}
|
|
354
375
|
promise.then(clear, clear)
|
|
355
376
|
return promise
|
|
@@ -884,6 +905,7 @@ export function wireBridgeEvents(client, { claudeBridge, codexBridge }) {
|
|
|
884
905
|
}
|
|
885
906
|
|
|
886
907
|
export async function startDaemon({ version, claudeSdk } = {}) {
|
|
908
|
+
startEventLoopMetrics(logger)
|
|
887
909
|
const config = await readConfig()
|
|
888
910
|
if (!config) {
|
|
889
911
|
throw new Error(
|
|
@@ -2818,136 +2840,164 @@ export async function buildUsagePayload(ctx) {
|
|
|
2818
2840
|
let localUsageMs = 0
|
|
2819
2841
|
let codexRateLimitsMs = 0
|
|
2820
2842
|
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2843
|
+
// 各 provider は独立して取得する。Claude 内は snapshot → ローカル集計の順を保つ。
|
|
2844
|
+
async function buildClaudeUsage() {
|
|
2845
|
+
const credentialsStartedAt = Date.now()
|
|
2846
|
+
const activeClaudeProfile = await getActiveProfile()
|
|
2847
|
+
const claudeHasCredentials = isKeychainPlatform()
|
|
2848
|
+
? await profileHasKeychainCredentials(activeClaudeProfile.configDir)
|
|
2849
|
+
: await hasFileCredentialsCached(activeClaudeProfile.configDir)
|
|
2850
|
+
credentialsMs = Date.now() - credentialsStartedAt
|
|
2851
|
+
|
|
2852
|
+
// 5h/7d の本命ソース: SDK control request (アカウント全体、session 非依存)。
|
|
2853
|
+
// statusLine が rate_limits を出さなくなった環境 (2026-07 時点の Claude CLI) でも
|
|
2854
|
+
// ここでライブ更新できる。frontend の usage.request ポーリング (既定 60s) 頻度に
|
|
2855
|
+
// 相乗りするだけで十分な鮮度が保てるため、専用タイマーは設けない。EXPERIMENTAL
|
|
2856
|
+
// API の失敗は getAccountUsage 内で握りつぶし null を返す (recordChatUsageSnapshot
|
|
2857
|
+
// は no-op) ので、既存の statusLine/transcript フォールバックは無傷で残る。
|
|
2858
|
+
if (ctx.claudeBridge) {
|
|
2859
|
+
const accountUsageStartedAt = Date.now()
|
|
2860
|
+
try {
|
|
2861
|
+
// ⚠️ getAccountUsage は SDK のサブプロセスを spawn する。**時間制限が無いと
|
|
2862
|
+
// 返ってこないときに usage レーンが止まり、後続の usage.request (60 秒周期) が
|
|
2863
|
+
// 無期限に詰まる**。2026-09-04 までは control レーン (tmux.list_sessions や
|
|
2864
|
+
// セッション停止が並ぶ) に乗っていて head-of-line blocking を起こしていたため
|
|
2865
|
+
// dispatch-scheduler の _laneFor で専用 "usage" レーンへ分離した。同種の詰まりで
|
|
2866
|
+
// cold p95 40〜54 秒を計測した記録が isReadOnlyRpc の注記にある。
|
|
2867
|
+
// 時間切れは握りつぶさず warn で残す (これ自体が「詰まりの実測値」になる)。
|
|
2868
|
+
const acct = await withDeadline(
|
|
2869
|
+
ctx.claudeBridge.getAccountUsage(),
|
|
2870
|
+
ACCOUNT_USAGE_TIMEOUT_MS,
|
|
2871
|
+
() =>
|
|
2872
|
+
ctx.logger?.warn?.(
|
|
2873
|
+
{ timeout_ms: ACCOUNT_USAGE_TIMEOUT_MS },
|
|
2874
|
+
"account usage timed out (dispatch lane released)",
|
|
2875
|
+
),
|
|
2876
|
+
)
|
|
2877
|
+
if (acct) recordChatUsageSnapshot(acct, Date.now(), activeClaudeProfile.id)
|
|
2878
|
+
} catch (err) {
|
|
2879
|
+
ctx.logger?.debug?.({ err: err?.message }, "account usage snapshot skipped")
|
|
2880
|
+
}
|
|
2881
|
+
accountUsageMs = Date.now() - accountUsageStartedAt
|
|
2856
2882
|
}
|
|
2857
|
-
accountUsageMs = Date.now() - accountUsageStartedAt
|
|
2858
|
-
}
|
|
2859
2883
|
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2884
|
+
const localUsageStartedAt = Date.now()
|
|
2885
|
+
let [usage, sessions] = await Promise.all([
|
|
2886
|
+
getUsage({ profileId: activeClaudeProfile.id }),
|
|
2887
|
+
getSessionUsages(),
|
|
2888
|
+
])
|
|
2889
|
+
localUsageMs = Date.now() - localUsageStartedAt
|
|
2890
|
+
// 未ログインで残ったローカル transcript/cache をアカウント利用量として見せない。
|
|
2891
|
+
if (!claudeHasCredentials) {
|
|
2892
|
+
usage = null
|
|
2893
|
+
sessions = []
|
|
2894
|
+
}
|
|
2895
|
+
return { usage, sessions }
|
|
2870
2896
|
}
|
|
2871
2897
|
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
const
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2898
|
+
async function buildCodexUsage() {
|
|
2899
|
+
// Codex 版 (Stream B3, 2026-07-03): rollout jsonl の token_count イベントから
|
|
2900
|
+
// 5h/7d + context% を取る。⚠️ Codex の 5h/7d は ChatGPT/OpenAI サブスクリプション
|
|
2901
|
+
// の残量であり、上の `usage` (Claude/Anthropic サブスクリプション) とは別契約の
|
|
2902
|
+
// 別数値のため、`usage` にマージせず独立フィールド `codexUsage`/`codexSessions`
|
|
2903
|
+
// として返す (frontend 側でどちらのアカウントの残量か混同しないため)。
|
|
2904
|
+
let codexUsage = null
|
|
2905
|
+
let codexContextCwd = null
|
|
2906
|
+
let codexSessions = []
|
|
2907
|
+
const activeCodexProfile = await codexProfiles.getActiveProfile()
|
|
2908
|
+
const codexHasCredentials = await codexProfiles.hasCodexCredentials(activeCodexProfile.configDir)
|
|
2909
|
+
if (ctx.codexBridge && codexHasCredentials) {
|
|
2910
|
+
try {
|
|
2911
|
+
// キャッシュは従来の cwd / アカウントデータだけを作る。
|
|
2912
|
+
// 代表会話の値は handleUsageRequest の送信直前に付ける。
|
|
2913
|
+
const cwds = new Set()
|
|
2914
|
+
for (const s of ctx.codexBridge.sessions.values()) {
|
|
2915
|
+
if (s.threadId && s.cwd) cwds.add(s.cwd)
|
|
2916
|
+
}
|
|
2917
|
+
const liveCodex = []
|
|
2918
|
+
for (const cwd of cwds) {
|
|
2919
|
+
const authoritative = ctx.codexBridge.liveSessionForCwd?.(cwd)
|
|
2920
|
+
if (authoritative?.threadId) {
|
|
2921
|
+
liveCodex.push({
|
|
2922
|
+
sessionId: authoritative.threadId,
|
|
2923
|
+
cwd: authoritative.cwd,
|
|
2924
|
+
threadId: authoritative.threadId,
|
|
2925
|
+
producerSeq: authoritative.seq,
|
|
2926
|
+
})
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
if (liveCodex.length > 0) {
|
|
2930
|
+
// 5h/7d はアカウント全体の値 (どの live session から読んでも同じ契約の残量)
|
|
2931
|
+
// なので、代表として最初の 1 件から取る (context% はその producer の live を使う)。
|
|
2932
|
+
codexContextCwd = liveCodex[0].cwd
|
|
2933
|
+
codexUsage = await codexUsageForThread(liveCodex[0].threadId, {
|
|
2934
|
+
producerSeq: liveCodex[0].producerSeq,
|
|
2899
2935
|
})
|
|
2936
|
+
codexSessions = await codexSessionUsages(liveCodex)
|
|
2937
|
+
// ⚠️ codex-review round2 major#2: codexSessionUsages の rollout I/O 中に cwd の
|
|
2938
|
+
// 権威 producer が別 session へ移りうる。await 後に各 cwd の現権威 seq を再検証し、
|
|
2939
|
+
// 集計に使った producer が現権威でない entry は破棄する (古い producer の context を
|
|
2940
|
+
// codexCtxByCwd へ出さない)。破棄した cwd は frontend が state loop の
|
|
2941
|
+
// live.context_pct (毎 tick 権威を同期解決=TOCTOU 無し) にフォールバックする。
|
|
2942
|
+
const seqForCwd = (cwd) => ctx.codexBridge.liveSessionForCwd?.(cwd)?.seq
|
|
2943
|
+
const producerByCwd = new Map(liveCodex.map((e) => [e.cwd, e.producerSeq]))
|
|
2944
|
+
codexSessions = filterCodexSessionsByAuthority(
|
|
2945
|
+
codexSessions,
|
|
2946
|
+
producerByCwd,
|
|
2947
|
+
seqForCwd,
|
|
2948
|
+
)
|
|
2949
|
+
// codex-review round3 minor#1: 集約 codexUsage.context は代表 producer
|
|
2950
|
+
// (liveCodex[0]) の値。await 中に代表が権威を失っていたら旧 context を出さない
|
|
2951
|
+
// (context だけ null 化。5h/7d は account-wide なので保持する)。
|
|
2952
|
+
if (codexUsage && seqForCwd(liveCodex[0].cwd) !== liveCodex[0].producerSeq) {
|
|
2953
|
+
codexUsage = { ...codexUsage, context: null }
|
|
2954
|
+
}
|
|
2900
2955
|
}
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
codexSessions = filterCodexSessionsByAuthority(
|
|
2918
|
-
codexSessions,
|
|
2919
|
-
producerByCwd,
|
|
2920
|
-
seqForCwd,
|
|
2921
|
-
)
|
|
2922
|
-
// codex-review round3 minor#1: 集約 codexUsage.context は代表 producer
|
|
2923
|
-
// (liveCodex[0]) の値。await 中に代表が権威を失っていたら旧 context を出さない
|
|
2924
|
-
// (context だけ null 化。5h/7d は account-wide なので保持する)。
|
|
2925
|
-
if (codexUsage && seqForCwd(liveCodex[0].cwd) !== liveCodex[0].producerSeq) {
|
|
2926
|
-
codexUsage = { ...codexUsage, context: null }
|
|
2956
|
+
// ライブ session の有無にかかわらず、起動直後の account RPC を優先する。
|
|
2957
|
+
// rollout は context の補助だけにし、5h/7d はプロファイル固有の最新実値にする。
|
|
2958
|
+
const codexRateLimitsStartedAt = Date.now()
|
|
2959
|
+
let rateLimits
|
|
2960
|
+
try {
|
|
2961
|
+
// 取得自体は止めず、完了値は bridge 内のキャッシュで次回に再利用する。
|
|
2962
|
+
rateLimits = await withDeadline(
|
|
2963
|
+
ctx.codexBridge.readAccountRateLimits({ profileId: activeCodexProfile.id }),
|
|
2964
|
+
CODEX_RATE_LIMITS_TIMEOUT_MS,
|
|
2965
|
+
() => ctx.logger?.warn?.(
|
|
2966
|
+
{ timeout_ms: CODEX_RATE_LIMITS_TIMEOUT_MS },
|
|
2967
|
+
"codex account rate limits timed out (dispatch lane released)",
|
|
2968
|
+
),
|
|
2969
|
+
)
|
|
2970
|
+
} finally {
|
|
2971
|
+
codexRateLimitsMs = Date.now() - codexRateLimitsStartedAt
|
|
2927
2972
|
}
|
|
2973
|
+
const accountUsage = codexUsageFromAccountRateLimits(rateLimits)
|
|
2974
|
+
if (accountUsage) {
|
|
2975
|
+
codexUsage = { ...accountUsage, context: codexUsage?.context ?? null }
|
|
2976
|
+
recordAccountUsageSnapshot("codex", activeCodexProfile.id, codexUsage)
|
|
2977
|
+
}
|
|
2978
|
+
} catch (err) {
|
|
2979
|
+
ctx.logger?.debug?.({ err: err?.message }, "codex usage snapshot skipped")
|
|
2928
2980
|
}
|
|
2929
|
-
// ライブ session の有無にかかわらず、起動直後の account RPC を優先する。
|
|
2930
|
-
// rollout は context の補助だけにし、5h/7d はプロファイル固有の最新実値にする。
|
|
2931
|
-
const codexRateLimitsStartedAt = Date.now()
|
|
2932
|
-
const rateLimits = await ctx.codexBridge.readAccountRateLimits({
|
|
2933
|
-
profileId: activeCodexProfile.id,
|
|
2934
|
-
})
|
|
2935
|
-
codexRateLimitsMs = Date.now() - codexRateLimitsStartedAt
|
|
2936
|
-
const accountUsage = codexUsageFromAccountRateLimits(rateLimits)
|
|
2937
|
-
if (accountUsage) {
|
|
2938
|
-
codexUsage = { ...accountUsage, context: codexUsage?.context ?? null }
|
|
2939
|
-
recordAccountUsageSnapshot("codex", activeCodexProfile.id, codexUsage)
|
|
2940
|
-
}
|
|
2941
|
-
} catch (err) {
|
|
2942
|
-
ctx.logger?.debug?.({ err: err?.message }, "codex usage snapshot skipped")
|
|
2943
2981
|
}
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2982
|
+
// Agent 再起動後や一時的な account RPC 失敗時も、同一 profile の最終取得値だけを
|
|
2983
|
+
// 表示する。未ログインならここへ到達せず、表示も完全に隠す。
|
|
2984
|
+
if (codexHasCredentials && !codexUsage) {
|
|
2985
|
+
codexUsage = await readAccountUsageSnapshot("codex", activeCodexProfile.id)
|
|
2986
|
+
}
|
|
2987
|
+
return { codexUsage, codexSessions, codexContextCwd }
|
|
2949
2988
|
}
|
|
2950
2989
|
|
|
2990
|
+
const [{ usage, sessions }, { codexUsage, codexSessions, codexContextCwd }] = await Promise.all([
|
|
2991
|
+
buildClaudeUsage().catch((err) => {
|
|
2992
|
+
ctx.logger?.debug?.({ err: err?.message }, "claude usage build skipped")
|
|
2993
|
+
return { usage: null, sessions: [] }
|
|
2994
|
+
}),
|
|
2995
|
+
buildCodexUsage().catch((err) => {
|
|
2996
|
+
ctx.logger?.debug?.({ err: err?.message }, "codex usage build skipped")
|
|
2997
|
+
return { codexUsage: null, codexSessions: [], codexContextCwd: null }
|
|
2998
|
+
}),
|
|
2999
|
+
])
|
|
3000
|
+
|
|
2951
3001
|
return {
|
|
2952
3002
|
payload: { usage, sessions, codexUsage, codexSessions },
|
|
2953
3003
|
codexContextCwd,
|
|
@@ -4643,6 +4693,8 @@ export async function dispatch(msg, ctx) {
|
|
|
4643
4693
|
// 添えれば Codex 向け skills (~/.codex/skills/) を返す。未指定は
|
|
4644
4694
|
// 従来どおり claude 向け。
|
|
4645
4695
|
const skillsCliKind = msg.cli_kind === "codex" ? "codex" : "claude"
|
|
4696
|
+
const timing = { sdk_wait_ms: 0, list_skills_ms: 0 }
|
|
4697
|
+
let started
|
|
4646
4698
|
const skills = await coalesceInflight(
|
|
4647
4699
|
listRequestsInflight,
|
|
4648
4700
|
skillsRequestKey(skillsCliKind, msg.session_name),
|
|
@@ -4650,16 +4702,32 @@ export async function dispatch(msg, ctx) {
|
|
|
4650
4702
|
// T06117: claude は SDK supportedCommands() を権威に一覧を作る (組込 +
|
|
4651
4703
|
// グローバル skill + plugin/dynamic を FS 走査の取りこぼしなく反映)。
|
|
4652
4704
|
// 取れなければ null で listSkills が FS 走査へフォールバックする。
|
|
4705
|
+
started = performance.now()
|
|
4653
4706
|
let sdkCommands = null
|
|
4654
4707
|
if (skillsCliKind === "claude" && ctx.claudeBridge?.listSupportedCommands) {
|
|
4655
|
-
|
|
4708
|
+
try {
|
|
4709
|
+
sdkCommands = await ctx.claudeBridge.listSupportedCommands()
|
|
4710
|
+
} finally {
|
|
4711
|
+
timing.sdk_wait_ms = performance.now() - started
|
|
4712
|
+
}
|
|
4713
|
+
}
|
|
4714
|
+
timing.sdk_wait_ms = performance.now() - started
|
|
4715
|
+
const listStarted = performance.now()
|
|
4716
|
+
try {
|
|
4717
|
+
return await listSkills({
|
|
4718
|
+
sessionName: msg.session_name,
|
|
4719
|
+
cliKind: skillsCliKind,
|
|
4720
|
+
sdkCommands,
|
|
4721
|
+
timing,
|
|
4722
|
+
})
|
|
4723
|
+
} finally {
|
|
4724
|
+
timing.list_skills_ms = performance.now() - listStarted
|
|
4656
4725
|
}
|
|
4657
|
-
return listSkills({
|
|
4658
|
-
sessionName: msg.session_name,
|
|
4659
|
-
cliKind: skillsCliKind,
|
|
4660
|
-
sdkCommands,
|
|
4661
|
-
})
|
|
4662
4726
|
},
|
|
4727
|
+
({ joined }) => logPhaseTiming(ctx.logger, "skills request timing", {
|
|
4728
|
+
coalesce_key_hash: createHash("sha256").update(skillsRequestKey(skillsCliKind, msg.session_name)).digest("hex"),
|
|
4729
|
+
joined, ...timing, total_ms: performance.now() - started,
|
|
4730
|
+
}),
|
|
4663
4731
|
)
|
|
4664
4732
|
ctx.client.send({
|
|
4665
4733
|
type: "skills.response",
|
|
@@ -4681,7 +4749,7 @@ export async function dispatch(msg, ctx) {
|
|
|
4681
4749
|
case "agents.request": {
|
|
4682
4750
|
try {
|
|
4683
4751
|
// 引数を持たない要求なので、実行中の取得があれば全員で結果を共有する。
|
|
4684
|
-
const agents = await coalesceInflight(listRequestsInflight, "agents", () => listAgents())
|
|
4752
|
+
const agents = await coalesceInflight(listRequestsInflight, "agents", () => listAgents({ logger: ctx.logger }))
|
|
4685
4753
|
ctx.client.send({
|
|
4686
4754
|
type: "agents.response",
|
|
4687
4755
|
request_id: msg.request_id,
|
package/src/skills.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks"
|
|
1
2
|
/**
|
|
2
3
|
* Claude Code の skill / slash command を集計する (Sprint H)。
|
|
3
4
|
*
|
|
@@ -28,6 +29,20 @@ function skillsDirFor(cliKind) {
|
|
|
28
29
|
return cliKind === "codex" ? CODEX_GLOBAL_SKILLS_DIR : GLOBAL_SKILLS_DIR
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
// 同じ共通ディレクトリの実行中の走査だけを共有する。結果は保持しない。
|
|
33
|
+
const globalScansInflight = new Map()
|
|
34
|
+
|
|
35
|
+
async function loadGlobalDir(loader, dir, source) {
|
|
36
|
+
const key = JSON.stringify([loader.name, dir, source])
|
|
37
|
+
let running = globalScansInflight.get(key)
|
|
38
|
+
if (!running) {
|
|
39
|
+
running = loader(dir, source).finally(() => globalScansInflight.delete(key))
|
|
40
|
+
globalScansInflight.set(key, running)
|
|
41
|
+
}
|
|
42
|
+
// 要素は文字列フィールドのみ。配列・要素を呼び出し元ごとに独立させる。
|
|
43
|
+
return (await running).map((entry) => ({ ...entry }))
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
// Claude Code 組み込みスラッシュコマンド。
|
|
32
47
|
// ⚠️ T06117: 通常経路では **SDK supportedCommands() を権威** に組込コマンドを得るため
|
|
33
48
|
// このハードコードは使わない。ここに残すのは「SDK が引けない (未実装 SDK / 一時エラー /
|
|
@@ -160,17 +175,20 @@ export async function listSkills(opts = {}) {
|
|
|
160
175
|
// FS の frontmatter から name でオーバーレイし、control query の cwd 外にある
|
|
161
176
|
// project-local skill は FS 走査で補完する。
|
|
162
177
|
if (cliKind === "claude" && Array.isArray(opts.sdkCommands)) {
|
|
163
|
-
return await listSkillsFromSdk(opts.sdkCommands, sessionName, projectsRoot)
|
|
178
|
+
return await listSkillsFromSdk(opts.sdkCommands, sessionName, projectsRoot, opts.timing)
|
|
164
179
|
}
|
|
165
180
|
|
|
166
181
|
// フォールバック経路: SDK 未取得 / Codex は従来どおり FS 走査 + 組込ハードコード。
|
|
167
182
|
const out = []
|
|
168
|
-
out.push(...(cliKind === "codex" ? CODEX_BUILT_IN_COMMANDS : BUILT_IN_COMMANDS))
|
|
169
|
-
|
|
183
|
+
out.push(...(cliKind === "codex" ? CODEX_BUILT_IN_COMMANDS : BUILT_IN_COMMANDS).map((entry) => ({ ...entry })))
|
|
184
|
+
const globalStarted = performance.now()
|
|
185
|
+
out.push(...(await loadGlobalDir(loadSkillsDir, skillsDirFor(cliKind), "skill")))
|
|
170
186
|
if (cliKind === "claude") {
|
|
171
|
-
out.push(...(await loadCommandsDir
|
|
187
|
+
out.push(...(await loadGlobalDir(loadCommandsDir, GLOBAL_COMMANDS_DIR, "command")))
|
|
172
188
|
}
|
|
173
189
|
|
|
190
|
+
if (opts.timing) opts.timing.global_scan_ms = performance.now() - globalStarted
|
|
191
|
+
const projectStarted = performance.now()
|
|
174
192
|
if (sessionName && /^D\d{5}_/.test(sessionName)) {
|
|
175
193
|
const projectDirName = cliKind === "codex" ? ".codex" : ".claude"
|
|
176
194
|
const projectDir = path.join(projectsRoot, sessionName, projectDirName)
|
|
@@ -180,6 +198,8 @@ export async function listSkills(opts = {}) {
|
|
|
180
198
|
}
|
|
181
199
|
}
|
|
182
200
|
|
|
201
|
+
if (opts.timing) opts.timing.project_scan_ms = performance.now() - projectStarted
|
|
202
|
+
|
|
183
203
|
// 同名は最初を採用、名前順 sort
|
|
184
204
|
const seen = new Set()
|
|
185
205
|
const deduped = out.filter((s) => {
|
|
@@ -218,7 +238,7 @@ export function sourceFromCommandSuffix(desc) {
|
|
|
218
238
|
* - control query の cwd 外にある project-local skill/command を FS 走査で補完
|
|
219
239
|
* - 同名 dedup / 名前順 sort
|
|
220
240
|
*/
|
|
221
|
-
async function listSkillsFromSdk(sdkCommands, sessionName, projectsRoot) {
|
|
241
|
+
async function listSkillsFromSdk(sdkCommands, sessionName, projectsRoot, timing) {
|
|
222
242
|
const entries = []
|
|
223
243
|
const seen = new Set()
|
|
224
244
|
for (const c of sdkCommands) {
|
|
@@ -232,10 +252,13 @@ async function listSkillsFromSdk(sdkCommands, sessionName, projectsRoot) {
|
|
|
232
252
|
}
|
|
233
253
|
|
|
234
254
|
// label_ja オーバーレイ用の FS 走査 (global) + project-local の補完材料。
|
|
255
|
+
const globalStarted = performance.now()
|
|
235
256
|
const globalLabelSources = [
|
|
236
|
-
...(await loadSkillsDir
|
|
237
|
-
...(await loadCommandsDir
|
|
257
|
+
...(await loadGlobalDir(loadSkillsDir, GLOBAL_SKILLS_DIR, "skill")),
|
|
258
|
+
...(await loadGlobalDir(loadCommandsDir, GLOBAL_COMMANDS_DIR, "command")),
|
|
238
259
|
]
|
|
260
|
+
if (timing) timing.global_scan_ms = performance.now() - globalStarted
|
|
261
|
+
const projectStarted = performance.now()
|
|
239
262
|
let projectEntries = []
|
|
240
263
|
if (sessionName && /^D\d{5}_/.test(sessionName)) {
|
|
241
264
|
const projectDir = path.join(projectsRoot, sessionName, ".claude")
|
|
@@ -245,6 +268,7 @@ async function listSkillsFromSdk(sdkCommands, sessionName, projectsRoot) {
|
|
|
245
268
|
]
|
|
246
269
|
}
|
|
247
270
|
|
|
271
|
+
if (timing) timing.project_scan_ms = performance.now() - projectStarted
|
|
248
272
|
const labelMap = new Map()
|
|
249
273
|
for (const s of [...globalLabelSources, ...projectEntries]) {
|
|
250
274
|
if (s.label) labelMap.set(s.name, s.label)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { monitorEventLoopDelay, performance } from "node:perf_hooks"
|
|
2
|
+
|
|
3
|
+
// performance.now() はプロセス起動を原点とする単調時計。テストでは _setNowForTest で差し替える。
|
|
4
|
+
let defaultNow = () => performance.now()
|
|
5
|
+
|
|
6
|
+
/** テスト専用: 既定の時計を差し替える (省略で元に戻す)。直前の時計を返す。 */
|
|
7
|
+
export function _setNowForTest(fn) {
|
|
8
|
+
const prev = defaultNow
|
|
9
|
+
defaultNow = fn ?? (() => performance.now())
|
|
10
|
+
return prev
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isStartupWindow(now = defaultNow) {
|
|
14
|
+
return now() <= 60_000
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 計測ログを出す。⚠️ 計測は処理結果に影響させない: logger が例外を投げても外へ出さない
|
|
19
|
+
* (呼び出し元の処理・キャッシュ・Promise の完了通知を壊さないため)。
|
|
20
|
+
*/
|
|
21
|
+
export function logPhaseTiming(logger, msg, fields, now = defaultNow) {
|
|
22
|
+
try {
|
|
23
|
+
if (isStartupWindow(now) || fields.total_ms >= 500) logger?.info?.(fields, msg)
|
|
24
|
+
} catch {
|
|
25
|
+
// 計測ログの失敗は無視する
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function startEventLoopMetrics(logger, {
|
|
30
|
+
now = defaultNow,
|
|
31
|
+
monitor = monitorEventLoopDelay,
|
|
32
|
+
schedule = setTimeout,
|
|
33
|
+
} = {}) {
|
|
34
|
+
const histogram = monitor({ resolution: 20 })
|
|
35
|
+
histogram.enable()
|
|
36
|
+
for (const deadline of [10_000, 30_000, 60_000]) {
|
|
37
|
+
schedule(() => {
|
|
38
|
+
try {
|
|
39
|
+
logger?.info?.({
|
|
40
|
+
since_start_ms: now(),
|
|
41
|
+
p50_ms: histogram.percentile(50) / 1e6,
|
|
42
|
+
p99_ms: histogram.percentile(99) / 1e6,
|
|
43
|
+
max_ms: histogram.max / 1e6,
|
|
44
|
+
}, "startup event loop delay")
|
|
45
|
+
} catch {
|
|
46
|
+
// 計測ログの失敗は無視する
|
|
47
|
+
} finally {
|
|
48
|
+
if (deadline === 60_000) histogram.disable()
|
|
49
|
+
}
|
|
50
|
+
}, Math.max(0, deadline - now())).unref()
|
|
51
|
+
}
|
|
52
|
+
}
|