@cocorograph/hub-agent 0.8.19 → 0.8.20
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 -0
- package/src/pointer-context.mjs +196 -0
package/package.json
CHANGED
package/src/main.mjs
CHANGED
|
@@ -108,6 +108,7 @@ import {
|
|
|
108
108
|
} from "./tmux.mjs"
|
|
109
109
|
import { SiblingInjectBridge } from "./sibling-inject.mjs"
|
|
110
110
|
import { isSessionWarm } from "./live-sessions.mjs"
|
|
111
|
+
import { applyPointerContexts, resolvePointerContexts } from "./pointer-context.mjs"
|
|
111
112
|
import {
|
|
112
113
|
boundSessionId,
|
|
113
114
|
codexContextPercentFromTokenUsage,
|
|
@@ -5067,10 +5068,32 @@ async function dispatch(msg, ctx) {
|
|
|
5067
5068
|
}
|
|
5068
5069
|
return { sessions: enriched, worktree_stubs: worktreeStubs }
|
|
5069
5070
|
})
|
|
5071
|
+
// 行ごとの「次に開く会話」の context% (任意)。宛先 (context_targets) は要求ごとに
|
|
5072
|
+
// 違うため、共有される snapshot キャッシュの**外**で解決し、触る行だけ複製して
|
|
5073
|
+
// 載せる (他クライアントの応答を汚さない)。context_targets が無ければ従来どおりの
|
|
5074
|
+
// 応答 (後方互換)。
|
|
5075
|
+
let sessions = snapshot.sessions
|
|
5076
|
+
if (Array.isArray(msg.context_targets) && msg.context_targets.length > 0) {
|
|
5077
|
+
try {
|
|
5078
|
+
const pointerByName = await resolvePointerContexts(
|
|
5079
|
+
msg.context_targets,
|
|
5080
|
+
sessions,
|
|
5081
|
+
{ logger: ctx.logger },
|
|
5082
|
+
)
|
|
5083
|
+
sessions = applyPointerContexts(sessions, pointerByName)
|
|
5084
|
+
} catch (err) {
|
|
5085
|
+
// pointer context はあくまで付加情報。失敗してもセッション一覧は返す。
|
|
5086
|
+
ctx.logger?.warn?.(
|
|
5087
|
+
{ err: err?.message },
|
|
5088
|
+
"resolvePointerContexts failed",
|
|
5089
|
+
)
|
|
5090
|
+
}
|
|
5091
|
+
}
|
|
5070
5092
|
ctx.client.send({
|
|
5071
5093
|
type: "tmux.sessions",
|
|
5072
5094
|
request_id: msg.request_id,
|
|
5073
5095
|
...snapshot,
|
|
5096
|
+
sessions,
|
|
5074
5097
|
})
|
|
5075
5098
|
} catch (err) {
|
|
5076
5099
|
ctx.client.send({
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「その行が次に開く会話」の context% (= pointer context) を解決する。
|
|
3
|
+
*
|
|
4
|
+
* ## 背景 (WHY)
|
|
5
|
+
* Cockpit のサイドバーは、コンテキスト使用率を **その行が次に開く会話に帰属させて**表示する
|
|
6
|
+
* ように直した (Hub 側 PR #4534)。しかし供給側 (agent) は「いま動いている会話」の値しか
|
|
7
|
+
* 出しておらず、一度も開いていない行・閉じた行では使用率が出ない。
|
|
8
|
+
*
|
|
9
|
+
* 従来は「その cwd で mtime が一番新しい jsonl」に落として埋めていたが、それが
|
|
10
|
+
* **サブエージェント / headless `claude -p` / 過去セッションの数字が混ざる**原因だったので
|
|
11
|
+
* 撤去した (cross-activity contamination)。よってここでは **frontend が行ごとに指定した
|
|
12
|
+
* 会話 ID (`session_id`) でしか値を引かない**。ID が無い行は推測せず null を返す。
|
|
13
|
+
*
|
|
14
|
+
* ## 不変条件 (ここを崩すと汚染が戻る)
|
|
15
|
+
* 1. `session_id` が渡されていない行では `contextForCwd` を **呼ばない** (mtime 最新への
|
|
16
|
+
* フォールバックが走るため)。
|
|
17
|
+
* 2. `session_id` が渡された行でも、戻り値の `uuid` が要求 ID と**一致するときだけ**採用する。
|
|
18
|
+
* `jsonlContextForCwd` は「その ID の jsonl が存在しない」とき mtime 最新へフォールバック
|
|
19
|
+
* する仕様のため、件数ではなく**実体 (uuid) の同一性**で確認する。
|
|
20
|
+
* 3. 鮮度 (mtime) による足切りをしない。止まっている会話の使用率は古くならない
|
|
21
|
+
* (次に開けばその値から再開する) ので、statusLine 経路の `SESSION_STALE_MS` は適用しない。
|
|
22
|
+
*
|
|
23
|
+
* ## N+1 対策
|
|
24
|
+
* サイドバーは 8 秒周期でポーリングし、複数タブが位相をずらして同時に叩く。行数分の jsonl
|
|
25
|
+
* 読み取りをそのまま毎回走らせないよう、`(cwd, session_id)` をキーにした短期 TTL +
|
|
26
|
+
* single-flight キャッシュを噛ませる (同時要求は 1 本の I/O に畳む)。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { cliKindForSessionName } from "./session-names.mjs"
|
|
30
|
+
import { jsonlContextForCwd } from "./usage.mjs"
|
|
31
|
+
|
|
32
|
+
/** pointer context のキャッシュ TTL (ms)。tmux スナップショットキャッシュと同じ 5 秒。 */
|
|
33
|
+
const POINTER_CONTEXT_TTL_MS = Number(
|
|
34
|
+
process.env.HUB_AGENT_POINTER_CONTEXT_TTL_MS ?? 5_000,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
/** キャッシュ保持数の上限 (会話 ID は増え続けるため LRU で頭打ちにする)。 */
|
|
38
|
+
const POINTER_CONTEXT_MAX_ENTRIES = 512
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* キー付きの短期 TTL + single-flight キャッシュ。
|
|
42
|
+
*
|
|
43
|
+
* `AsyncTtlCache` (単一値版) と役割は同じだが、こちらは `(cwd, session_id)` ごとに独立した
|
|
44
|
+
* エントリを持つ。build 失敗はキャッシュしない (次の要求で再試行できる)。
|
|
45
|
+
*/
|
|
46
|
+
export class KeyedAsyncTtlCache {
|
|
47
|
+
constructor({ ttlMs, now = () => Date.now(), maxEntries = POINTER_CONTEXT_MAX_ENTRIES } = {}) {
|
|
48
|
+
this.ttlMs = Math.max(0, Number(ttlMs) || 0)
|
|
49
|
+
this.now = now
|
|
50
|
+
this.maxEntries = Math.max(1, Number(maxEntries) || 1)
|
|
51
|
+
this.entries = new Map() // key → {value, expiresAt, inFlight}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async get(key, build) {
|
|
55
|
+
const hit = this.entries.get(key)
|
|
56
|
+
if (hit) {
|
|
57
|
+
if (hit.inFlight) return hit.inFlight // 同時要求は 1 本の I/O に畳む
|
|
58
|
+
if (this.now() < hit.expiresAt) return hit.value
|
|
59
|
+
}
|
|
60
|
+
const pending = Promise.resolve().then(build)
|
|
61
|
+
const entry = { value: undefined, expiresAt: 0, inFlight: pending }
|
|
62
|
+
// delete → set で挿入順 = 最近使った順にし、prune を LRU として使えるようにする。
|
|
63
|
+
this.entries.delete(key)
|
|
64
|
+
this.entries.set(key, entry)
|
|
65
|
+
try {
|
|
66
|
+
const value = await pending
|
|
67
|
+
entry.value = value
|
|
68
|
+
entry.expiresAt = this.now() + this.ttlMs
|
|
69
|
+
entry.inFlight = null
|
|
70
|
+
this.prune()
|
|
71
|
+
return value
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (this.entries.get(key) === entry) this.entries.delete(key)
|
|
74
|
+
throw err
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 期限切れエントリを落とし、上限を超えていれば古い順に捨てる。 */
|
|
79
|
+
prune() {
|
|
80
|
+
const now = this.now()
|
|
81
|
+
for (const [key, e] of this.entries) {
|
|
82
|
+
if (!e.inFlight && now >= e.expiresAt) this.entries.delete(key)
|
|
83
|
+
}
|
|
84
|
+
while (this.entries.size > this.maxEntries) {
|
|
85
|
+
const oldest = this.entries.keys().next()
|
|
86
|
+
if (oldest.done) break
|
|
87
|
+
this.entries.delete(oldest.value)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
clear() {
|
|
92
|
+
this.entries.clear()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 既定のプロセス共有キャッシュ (tmux.list_sessions ハンドラ用)。 */
|
|
97
|
+
const defaultPointerContextCache = new KeyedAsyncTtlCache({ ttlMs: POINTER_CONTEXT_TTL_MS })
|
|
98
|
+
|
|
99
|
+
/** テスト用: 既定キャッシュを空にする。 */
|
|
100
|
+
export function _resetPointerContextCache() {
|
|
101
|
+
defaultPointerContextCache.clear()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** cwd と会話 ID の連結。区切りは両者に現れ得ない制御文字 (エスケープ表記で書く)。 */
|
|
105
|
+
function cacheKey(cwd, sessionId) {
|
|
106
|
+
return `${cwd}\u0000${sessionId}`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `context_targets` (行ごとの「次に開く会話」) を解決し、`session_name → context% | null` の
|
|
111
|
+
* Map を返す。
|
|
112
|
+
*
|
|
113
|
+
* 値が確定しなかった行にも **null を明示的に入れる** (「見に行った上で不明」を表す)。
|
|
114
|
+
* `context_targets` に含まれない行は Map に載らない = 応答にフィールドを付けない。
|
|
115
|
+
*
|
|
116
|
+
* @param {Array<{session_name?: string, session_id?: string|null}>|undefined} targets
|
|
117
|
+
* @param {Array<{name?: string, cwd?: string|null}>} rows tmux.list_sessions の行 (cwd 解決用)
|
|
118
|
+
* @param {{contextForCwd?: Function, cache?: KeyedAsyncTtlCache, logger?: object}} [opts]
|
|
119
|
+
* @returns {Promise<Map<string, number|null>>}
|
|
120
|
+
*/
|
|
121
|
+
export async function resolvePointerContexts(targets, rows, opts = {}) {
|
|
122
|
+
const {
|
|
123
|
+
contextForCwd = jsonlContextForCwd,
|
|
124
|
+
cache = defaultPointerContextCache,
|
|
125
|
+
logger,
|
|
126
|
+
} = opts
|
|
127
|
+
const out = new Map()
|
|
128
|
+
if (!Array.isArray(targets) || targets.length === 0) return out
|
|
129
|
+
|
|
130
|
+
const cwdByName = new Map()
|
|
131
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
132
|
+
if (row && typeof row.name === "string") cwdByName.set(row.name, row.cwd || null)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const jobs = []
|
|
136
|
+
for (const target of targets) {
|
|
137
|
+
const name = typeof target?.session_name === "string" ? target.session_name : ""
|
|
138
|
+
if (!name || out.has(name)) continue // 同名の重複指定は先勝ち
|
|
139
|
+
out.set(name, null)
|
|
140
|
+
|
|
141
|
+
const sessionId = typeof target?.session_id === "string" ? target.session_id.trim() : ""
|
|
142
|
+
// 不変条件 1: ID が無ければ読みに行かない (mtime 最新フォールバックを踏まない)。
|
|
143
|
+
if (!sessionId) continue
|
|
144
|
+
const cwd = cwdByName.get(name)
|
|
145
|
+
if (!cwd) continue
|
|
146
|
+
// codex 行は claude の jsonl を持たない。読んでも uuid 不一致で null になるだけなので、
|
|
147
|
+
// 無駄な readdir/stat を避けるためここで落とす (結果は同じ)。
|
|
148
|
+
if (cliKindForSessionName(name) !== "claude") continue
|
|
149
|
+
jobs.push({ name, cwd, sessionId })
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await Promise.all(
|
|
153
|
+
jobs.map(async ({ name, cwd, sessionId }) => {
|
|
154
|
+
try {
|
|
155
|
+
const pct = await cache.get(cacheKey(cwd, sessionId), async () => {
|
|
156
|
+
const res = await contextForCwd(cwd, sessionId)
|
|
157
|
+
// 不変条件 2: 実体 (uuid) が一致したときだけ採用する。
|
|
158
|
+
if (!res || res.uuid !== sessionId) return null
|
|
159
|
+
return typeof res.percent === "number" ? res.percent : null
|
|
160
|
+
})
|
|
161
|
+
if (typeof pct === "number") out.set(name, pct)
|
|
162
|
+
} catch (err) {
|
|
163
|
+
logger?.warn?.(
|
|
164
|
+
{ err: err?.message, session: name },
|
|
165
|
+
"pointer context resolve failed",
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}),
|
|
169
|
+
)
|
|
170
|
+
return out
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 解決済みの pointer context を session 行へ載せる (元配列・元オブジェクトは変更しない)。
|
|
175
|
+
*
|
|
176
|
+
* `tmux.list_sessions` の応答行は TTL スナップショットキャッシュで**複数の要求に共有される**
|
|
177
|
+
* ため、要求ごとに違う値を直接書き込むと他クライアントの応答を汚す。触る行だけ複製する。
|
|
178
|
+
*
|
|
179
|
+
* 既存の `context_pct` (いま動いている会話の値) とは**別フィールド**にする。混ぜると
|
|
180
|
+
* 「どちらの会話の値か」が曖昧になるため。
|
|
181
|
+
*
|
|
182
|
+
* @param {Array<object>} sessions
|
|
183
|
+
* @param {Map<string, number|null>} pointerByName
|
|
184
|
+
* @returns {Array<object>}
|
|
185
|
+
*/
|
|
186
|
+
export function applyPointerContexts(sessions, pointerByName) {
|
|
187
|
+
if (!Array.isArray(sessions)) return []
|
|
188
|
+
if (!(pointerByName instanceof Map) || pointerByName.size === 0) return sessions
|
|
189
|
+
return sessions.map((s) =>
|
|
190
|
+
s && pointerByName.has(s.name)
|
|
191
|
+
? { ...s, pointer_context_pct: pointerByName.get(s.name) }
|
|
192
|
+
: s,
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export default { KeyedAsyncTtlCache, resolvePointerContexts, applyPointerContexts }
|