@cocorograph/hub-agent 0.7.30 → 0.7.32
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/claude-history.mjs +62 -1
- package/src/claude-stream-bridge.mjs +6 -1
- package/src/codex-stream-bridge.mjs +23 -2
- package/src/config.mjs +21 -3
- package/src/main.mjs +4 -0
- package/src/usage.mjs +8 -2
package/package.json
CHANGED
package/src/claude-history.mjs
CHANGED
|
@@ -29,6 +29,50 @@ export const MAX_HISTORY_LINES = 500
|
|
|
29
29
|
/** UI 表示対象の SDK message type (それ以外は jsonl 内部メタなので除外)。 */
|
|
30
30
|
export const DISPLAY_TYPES = new Set(["user", "assistant", "system", "result"])
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* hydrate 結果のパース済みキャッシュ (2026-07-03: Cockpit セッション切替パフォーマンス改善)。
|
|
34
|
+
*
|
|
35
|
+
* 背景: CockpitChatView は resume セッション切替のたびに毎回 readSessionHistory を呼ぶが
|
|
36
|
+
* (window/page.tsx の `key={selected}` 再マウント設計)、同一ファイルが未変更のまま
|
|
37
|
+
* 数秒〜数分内に再 hydrate されるケースが大半 (行ったり来たりのセッション切替、複数タブ)。
|
|
38
|
+
* readFile → split → 全行 JSON.parse (+ MASK_ENABLED 時は tool_use 走査) は jsonl が
|
|
39
|
+
* 大きいほど無視できないコストになるため、`mtimeMs + size` が前回と一致する間は
|
|
40
|
+
* パース結果をそのまま再利用し、ディスク読み直し・再パースを丸ごとスキップする。
|
|
41
|
+
* ファイルが変わっていれば (ライブ会話が進行 / 別ターミナルから追記等) 通常どおり再読み込み
|
|
42
|
+
* して上書きするので、常に最新のファイル内容を正しく反映する (stale 表示のリスクは無い)。
|
|
43
|
+
*
|
|
44
|
+
* key: filePath, value: { mtimeMs, size, maxLines, result }。maxLines もキーの一部として
|
|
45
|
+
* 比較する (異なる maxLines でのリクエストはキャッシュを再利用しない)。
|
|
46
|
+
*/
|
|
47
|
+
const HYDRATE_CACHE_MAX_ENTRIES = 200
|
|
48
|
+
const hydrateCache = new Map()
|
|
49
|
+
|
|
50
|
+
function getHydrateCache(filePath, mtimeMs, size, maxLines) {
|
|
51
|
+
const entry = hydrateCache.get(filePath)
|
|
52
|
+
if (!entry) return null
|
|
53
|
+
if (entry.mtimeMs !== mtimeMs || entry.size !== size || entry.maxLines !== maxLines) return null
|
|
54
|
+
// LRU: 再利用したエントリを Map の末尾へ移し直す (再挿入で挿入順を更新)。
|
|
55
|
+
hydrateCache.delete(filePath)
|
|
56
|
+
hydrateCache.set(filePath, entry)
|
|
57
|
+
return entry.result
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function setHydrateCache(filePath, mtimeMs, size, maxLines, result) {
|
|
61
|
+
hydrateCache.delete(filePath)
|
|
62
|
+
hydrateCache.set(filePath, { mtimeMs, size, maxLines, result })
|
|
63
|
+
// 上限超過分は挿入順が古いものから GC (長時間稼働の hub-agent プロセスでメモリが
|
|
64
|
+
// 際限なく増えないようにする)。
|
|
65
|
+
while (hydrateCache.size > HYDRATE_CACHE_MAX_ENTRIES) {
|
|
66
|
+
const oldestKey = hydrateCache.keys().next().value
|
|
67
|
+
hydrateCache.delete(oldestKey)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** テスト用: hydrate キャッシュを全消去する。 */
|
|
72
|
+
export function clearHydrateCacheForTest() {
|
|
73
|
+
hydrateCache.clear()
|
|
74
|
+
}
|
|
75
|
+
|
|
32
76
|
/**
|
|
33
77
|
* jsonl の生 object を、Browser が SDK message として受け取れる表示用 shape に正規化する。
|
|
34
78
|
* 余分な内部メタは落とし、表示・集計に必要なフィールドだけを superset で拾う。
|
|
@@ -97,6 +141,21 @@ export function jsonlPath({ cwd, session_id, projectsRoot }) {
|
|
|
97
141
|
* @returns {Promise<{events: object[], total_lines: number, truncated: boolean}>}
|
|
98
142
|
*/
|
|
99
143
|
export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINES, logger } = {}) {
|
|
144
|
+
// stat は readFile よりずっと軽い (メタデータのみ)。mtimeMs+size が前回と同じなら
|
|
145
|
+
// ファイル内容も同じとみなし、以降の readFile + 全行 parse をまるごとスキップする。
|
|
146
|
+
let st
|
|
147
|
+
try {
|
|
148
|
+
st = await stat(filePath)
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (err.code === "ENOENT") {
|
|
151
|
+
return { events: [], total_lines: 0, truncated: false }
|
|
152
|
+
}
|
|
153
|
+
logger?.warn({ err: err.message, filePath }, "claude history stat failed")
|
|
154
|
+
throw err
|
|
155
|
+
}
|
|
156
|
+
const cached = getHydrateCache(filePath, st.mtimeMs, st.size, maxLines)
|
|
157
|
+
if (cached) return cached
|
|
158
|
+
|
|
100
159
|
let text
|
|
101
160
|
try {
|
|
102
161
|
text = await readFile(filePath, "utf-8")
|
|
@@ -143,7 +202,9 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
|
|
|
143
202
|
if (MASK_ENABLED) event = maskMessageObject(event, toolNames)
|
|
144
203
|
events.push(event)
|
|
145
204
|
}
|
|
146
|
-
|
|
205
|
+
const result = { events, total_lines, truncated }
|
|
206
|
+
setHydrateCache(filePath, st.mtimeMs, st.size, maxLines, result)
|
|
207
|
+
return result
|
|
147
208
|
}
|
|
148
209
|
|
|
149
210
|
/**
|
|
@@ -1647,7 +1647,12 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1647
1647
|
for (const t of session.lastActivityByStream.values()) {
|
|
1648
1648
|
if (t > at) at = t
|
|
1649
1649
|
}
|
|
1650
|
-
|
|
1650
|
+
// `>=` (2026-07-03): Date.now() はミリ秒精度のため、短時間に複数セッションへ
|
|
1651
|
+
// attach+input すると同一ミリ秒でタイが起き得る (テストや高速な連続操作で実測)。
|
|
1652
|
+
// `this.sessions` は Map で挿入順を保持するため、タイ時は後から attach された
|
|
1653
|
+
// (= イテレーション順で後に来る) セッションを採用するのが「最終アクティビティが
|
|
1654
|
+
// 最も新しい」の意図に合う。厳密な `>` だと先勝ちになり意図と逆転する。
|
|
1655
|
+
if (at >= bestAt) {
|
|
1651
1656
|
bestAt = at
|
|
1652
1657
|
best = session.sessionId
|
|
1653
1658
|
}
|
|
@@ -23,6 +23,13 @@
|
|
|
23
23
|
* - 画像/添付入力 (テキストのみ)。
|
|
24
24
|
* - acceptForSession や execpolicy/network amendment 等の高度な承認 decision
|
|
25
25
|
* (permissionReply は decision をそのまま素通しするので呼び出し側が組み立てる)。
|
|
26
|
+
*
|
|
27
|
+
* Slice 6 (履歴 hydrate) で追加: `thread/resume` のレスポンスは既定で
|
|
28
|
+
* `thread.turns[].items[]` (= ThreadItem の時系列) を含む (公式スキーマ
|
|
29
|
+
* `ThreadReadResponse.json` の Thread.turns 説明: "Only populated on thread/resume,
|
|
30
|
+
* thread/rollback, thread/fork, and thread/read (when includeTurns is true)")。
|
|
31
|
+
* 追加の `thread/read` 往復は不要で、attach() の戻り値に `history` として平坦化して
|
|
32
|
+
* 返す (browser 側での re-mount 白紙状態を解消する)。
|
|
26
33
|
*/
|
|
27
34
|
import { EventEmitter } from "node:events"
|
|
28
35
|
|
|
@@ -39,6 +46,17 @@ const IDLE_DETACH_TTL_MS = 5 * 60 * 1000
|
|
|
39
46
|
|
|
40
47
|
const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
|
|
41
48
|
|
|
49
|
+
// Slice 6: thread/resume が返す Thread.turns[].items[] を単一の時系列配列へ平坦化する。
|
|
50
|
+
// turns は開始ターン順の配列、各 turn.items も時系列順のため単純結合で会話順を保てる。
|
|
51
|
+
function flattenThreadItems(thread) {
|
|
52
|
+
const turns = Array.isArray(thread?.turns) ? thread.turns : []
|
|
53
|
+
const items = []
|
|
54
|
+
for (const turn of turns) {
|
|
55
|
+
if (Array.isArray(turn?.items)) items.push(...turn.items)
|
|
56
|
+
}
|
|
57
|
+
return items
|
|
58
|
+
}
|
|
59
|
+
|
|
42
60
|
function userInputFromMessage(message) {
|
|
43
61
|
const content = message?.content
|
|
44
62
|
if (typeof content === "string") {
|
|
@@ -65,7 +83,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
65
83
|
}
|
|
66
84
|
|
|
67
85
|
/**
|
|
68
|
-
* @returns {Promise<{resuming: boolean, threadId: string}>}
|
|
86
|
+
* @returns {Promise<{resuming: boolean, threadId: string, history: object[]}>}
|
|
69
87
|
*/
|
|
70
88
|
async attach({ stream_id, cwd, model, effort, approvalPolicy, sandbox, resumeThreadId }) {
|
|
71
89
|
if (this.sessions.has(stream_id)) {
|
|
@@ -96,7 +114,10 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
96
114
|
? await client.request("thread/resume", { ...startParams, threadId: resumeThreadId })
|
|
97
115
|
: await client.request("thread/start", startParams)
|
|
98
116
|
session.threadId = result?.thread?.id
|
|
99
|
-
|
|
117
|
+
// 新規スレッド (thread/start) は履歴が無いので空配列。resume 時のみ平坦化する
|
|
118
|
+
// (thread/start のレスポンスは turns を持たない仕様のため呼んでも常に空)。
|
|
119
|
+
const history = resuming ? flattenThreadItems(result?.thread) : []
|
|
120
|
+
return { resuming, threadId: session.threadId, history }
|
|
100
121
|
} catch (err) {
|
|
101
122
|
this.sessions.delete(stream_id)
|
|
102
123
|
client.stop()
|
package/src/config.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { promises as fs, constants as fsConsts, readFileSync } from "node:fs"
|
|
11
11
|
import path from "node:path"
|
|
12
12
|
import os from "node:os"
|
|
13
|
-
import { execFile } from "node:child_process"
|
|
13
|
+
import { execFile, execFileSync } from "node:child_process"
|
|
14
14
|
import { promisify } from "node:util"
|
|
15
15
|
|
|
16
16
|
const _execFileP = promisify(execFile)
|
|
@@ -21,9 +21,20 @@ const _execFileP = promisify(execFile)
|
|
|
21
21
|
* 重要: Windows のエージェントは WSL2 内で動くため `process.platform` は "linux" を
|
|
22
22
|
* 返す。素の Linux と区別するため、WSL を検出して "wsl" を返す。
|
|
23
23
|
*
|
|
24
|
+
* 判定は 3 段階のフォールバック:
|
|
25
|
+
* 1. `WSL_DISTRO_NAME` / `WSL_INTEROP` 環境変数 (対話シェルなら通常付く)
|
|
26
|
+
* 2. `/proc/version` の内容 (microsoft/wsl 文字列)
|
|
27
|
+
* 3. (2026-07-03 追加) `uname -r` のカーネルリリース文字列。systemd サービス等
|
|
28
|
+
* 非対話起動では 1 の環境変数が unit に伝播しないケースがある (WSL2 + systemd の
|
|
29
|
+
* 典型的な罠。対話シェルにしか WSL_DISTRO_NAME が無く、hub-agent をサービス化
|
|
30
|
+
* すると引き継がれない)。`uname -r` は環境変数に依存せずカーネル自体から
|
|
31
|
+
* "5.15.x-microsoft-standard-WSL2" のような値を返すため、より確実な最終フォールバック
|
|
32
|
+
* として使う。
|
|
33
|
+
*
|
|
34
|
+
* @param {{execImpl?: (cmd: string, args: string[]) => string}} [opts] テスト用の exec 差し替え
|
|
24
35
|
* @returns {"darwin"|"wsl"|"linux"|"win32"|string}
|
|
25
36
|
*/
|
|
26
|
-
export function detectPlatform() {
|
|
37
|
+
export function detectPlatform({ execImpl } = {}) {
|
|
27
38
|
if (process.platform === "darwin") return "darwin"
|
|
28
39
|
if (process.platform === "win32") return "win32"
|
|
29
40
|
if (process.platform === "linux") {
|
|
@@ -32,7 +43,14 @@ export function detectPlatform() {
|
|
|
32
43
|
const v = readFileSync("/proc/version", "utf-8").toLowerCase()
|
|
33
44
|
if (v.includes("microsoft") || v.includes("wsl")) return "wsl"
|
|
34
45
|
} catch {
|
|
35
|
-
/* /proc/version
|
|
46
|
+
/* /proc/version が読めない環境は次のフォールバックへ */
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const run = execImpl || ((cmd, args) => execFileSync(cmd, args, { encoding: "utf-8" }))
|
|
50
|
+
const release = run("uname", ["-r"]).toLowerCase()
|
|
51
|
+
if (release.includes("microsoft") || release.includes("wsl")) return "wsl"
|
|
52
|
+
} catch {
|
|
53
|
+
/* uname が無い/失敗する環境は素の linux 扱い */
|
|
36
54
|
}
|
|
37
55
|
return "linux"
|
|
38
56
|
}
|
package/src/main.mjs
CHANGED
|
@@ -3093,6 +3093,10 @@ async function dispatch(msg, ctx) {
|
|
|
3093
3093
|
stream_id,
|
|
3094
3094
|
resuming: info.resuming,
|
|
3095
3095
|
thread_id: info.threadId,
|
|
3096
|
+
// Slice 6: 履歴 hydrate。resume 時のみ非空 (thread/start は常に [])。
|
|
3097
|
+
// consumers.py の per-stream relay は content-blind なので backend 側の
|
|
3098
|
+
// 変更は不要 (_AGENT_PER_STREAM_TYPES の codex.ready がそのまま運ぶ)。
|
|
3099
|
+
history: info.history || [],
|
|
3096
3100
|
})
|
|
3097
3101
|
} catch (err) {
|
|
3098
3102
|
ctx.client.send({
|
package/src/usage.mjs
CHANGED
|
@@ -1152,9 +1152,13 @@ export async function getUsage() {
|
|
|
1152
1152
|
// チャット(SDK) の rate_limit_event が statusLine cache より新しければ、5h/7d を
|
|
1153
1153
|
// その実値 (utilization) で上書きする。ターミナルが statusLine を更新していない
|
|
1154
1154
|
// チャットモードでも 5h/7d がライブで動くようになる。
|
|
1155
|
+
// `>=` (2026-07-03): ファイル mtime はミリ秒未満の粒度が丸められる環境があり、
|
|
1156
|
+
// 同一ミリ秒 (またはそれ以下) の解像度で statusLine 書き込みと rate_limit_event が
|
|
1157
|
+
// 揃うとタイになり得る。プロセス内の直近イベント (chatRateLimits) の方が
|
|
1158
|
+
// ディスク read-back より鮮度の情報として確実なため、タイ時は上書きを優先する。
|
|
1155
1159
|
if (
|
|
1156
1160
|
chatRateLimits.updatedAtMs > 0 &&
|
|
1157
|
-
(cacheMtime === null || chatRateLimits.updatedAtMs
|
|
1161
|
+
(cacheMtime === null || chatRateLimits.updatedAtMs >= cacheMtime)
|
|
1158
1162
|
) {
|
|
1159
1163
|
if (chatRateLimits.five_hour) {
|
|
1160
1164
|
result.last5h = {
|
|
@@ -1183,7 +1187,9 @@ export async function getUsage() {
|
|
|
1183
1187
|
const chatSnap = await readChatUsageSnapshot()
|
|
1184
1188
|
if (chatSnap && chatSnap.updatedAtMs > 0) {
|
|
1185
1189
|
const baseline = Math.max(cacheMtime ?? 0, chatRateLimits.updatedAtMs || 0)
|
|
1186
|
-
|
|
1190
|
+
// `>=` (上と同じ理由): ファイル mtime とプロセス内タイムスタンプがタイになり得る。
|
|
1191
|
+
// chatSnap は最も直接的な情報源 (SDK control request 実値) なのでタイ時は優先する。
|
|
1192
|
+
if (chatSnap.updatedAtMs >= baseline) {
|
|
1187
1193
|
if (chatSnap.five_hour) {
|
|
1188
1194
|
result.last5h = {
|
|
1189
1195
|
...result.last5h,
|