@cocorograph/hub-agent 0.6.20 → 0.6.21

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.6.20",
3
+ "version": "0.6.21",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -902,7 +902,13 @@ export class ClaudeStreamBridge extends EventEmitter {
902
902
  logger: this.logger,
903
903
  onEvent: (event) => {
904
904
  // stream_id は再アタッチで変わるため session.stream_id (最新) を使う。
905
- this.emit("event", { stream_id: session.stream_id, session_id: session.sessionId, event })
905
+ // cwd per-session context% ドーナツ書き出し (usage.mjs) で使う。
906
+ this.emit("event", {
907
+ stream_id: session.stream_id,
908
+ session_id: session.sessionId,
909
+ cwd: session.cwd,
910
+ event,
911
+ })
906
912
  // session_id 確定後は索引に登録 (冪等)。再接続時の再アタッチに使う。
907
913
  if (session.sessionId) this._liveBySession.set(session.sessionId, session)
908
914
  },
package/src/main.mjs CHANGED
@@ -37,7 +37,13 @@ import {
37
37
  listWorktreeStubs,
38
38
  removeWorktree as removeWorktreeDir,
39
39
  } from "./tmux.mjs"
40
- import { getSessionUsages, getUsage, recordChatRateLimit } from "./usage.mjs"
40
+ import {
41
+ getSessionUsages,
42
+ getUsage,
43
+ recordChatRateLimit,
44
+ recordChatSessionContext,
45
+ recordChatSessionStatus,
46
+ } from "./usage.mjs"
41
47
 
42
48
  const logger = pino({ name: "hub-agent" })
43
49
 
@@ -178,7 +184,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
178
184
  // そのまま browser に転送する。SDK 未インストール時は claudeBridge=null で全 attach が
179
185
  // claude.error を返す経路に分岐 (dispatch 側で判定)。
180
186
  if (claudeBridge) {
181
- claudeBridge.on("event", ({ stream_id, session_id, event }) => {
187
+ claudeBridge.on("event", ({ stream_id, session_id, cwd, event }) => {
182
188
  // SDK の rate_limit_event を捕捉して usage の 5h/7d 実値に反映する
183
189
  // (チャットモードでは statusLine が更新されないため、これが live 値の唯一の源)。
184
190
  if (event?.type === "rate_limit_event" && event.rate_limit_info) {
@@ -188,6 +194,34 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
188
194
  /* ignore */
189
195
  }
190
196
  }
197
+ // assistant メッセージの usage から session 別 context% を算出してファイルへ
198
+ // 書き出す (サイドバー各行のドーナツ用。これも statusLine がチャットでは更新
199
+ // されないための補完)。あわせてターン状態を更新する:
200
+ // - assistant (生成中) → processing
201
+ // - result (ターン完了/入力待ち) → waiting
202
+ // webapp のステータスドットは tmux ペインのスクレイプで判定するが、チャットは
203
+ // ペインに TUI を出さないため、この chat_status を優先させて補完する。
204
+ const sid = session_id || event?.session_id
205
+ if (event?.type === "assistant") {
206
+ if (event.message?.usage) {
207
+ try {
208
+ recordChatSessionContext({ sessionId: sid, cwd, message: event.message })
209
+ } catch {
210
+ /* ignore */
211
+ }
212
+ }
213
+ try {
214
+ recordChatSessionStatus({ sessionId: sid, cwd, status: "processing" })
215
+ } catch {
216
+ /* ignore */
217
+ }
218
+ } else if (event?.type === "result") {
219
+ try {
220
+ recordChatSessionStatus({ sessionId: sid, cwd, status: "waiting" })
221
+ } catch {
222
+ /* ignore */
223
+ }
224
+ }
191
225
  client.send({ type: "claude.event", stream_id, session_id, event })
192
226
  })
193
227
  claudeBridge.on("permission", ({ stream_id, session_id, request_id, tool_name, input }) => {
package/src/usage.mjs CHANGED
@@ -124,9 +124,72 @@ function utilizationToPercent(u) {
124
124
  return u <= 1 ? Math.round(u * 1000) / 10 : Math.round(u * 10) / 10
125
125
  }
126
126
 
127
+ // persist の in-flight promise。テストやシャットダウンで書き込み完了を待てるよう保持。
128
+ let _persistInFlight = Promise.resolve()
129
+
130
+ /**
131
+ * 捕捉済みの chatRateLimits を statusLine cache (`~/.hub/usage/latest.json`) に
132
+ * マージ書き込みする。
133
+ *
134
+ * webapp 側 (`cockpit/webapp/lib/usage.ts`) のフッターは hub-agent の getUsage を
135
+ * 呼ばず、このファイルを直接読む (readOfficial)。そのファイルは TUI の statusLine
136
+ * でしか更新されないため、チャットモードでは 5h/7d が固まる。ここで rate_limit_event
137
+ * の実値をファイルへ書き戻すことで、webapp のフッターもチャット中にライブ更新される。
138
+ *
139
+ * 既存フィールド (context_window 等、TUI が書いた値) は保持し、rate_limits のみ
140
+ * 更新する。書き込みは tmp → rename の atomic write。全エラーは握りつぶす。
141
+ */
142
+ async function persistChatRateLimitsToCache() {
143
+ const p = statuslineCache()
144
+ let base = {}
145
+ const existing = await readOrNull(p)
146
+ if (existing) {
147
+ try {
148
+ const parsed = JSON.parse(existing)
149
+ if (parsed && typeof parsed === "object") base = parsed
150
+ } catch {
151
+ /* 壊れたファイルは作り直す */
152
+ }
153
+ }
154
+ const rl =
155
+ base.rate_limits && typeof base.rate_limits === "object" ? { ...base.rate_limits } : {}
156
+ if (chatRateLimits.five_hour) {
157
+ rl.five_hour = {
158
+ used_percentage: chatRateLimits.five_hour.percent,
159
+ resets_at: chatRateLimits.five_hour.resetAtMs ?? rl.five_hour?.resets_at ?? null,
160
+ }
161
+ }
162
+ if (chatRateLimits.seven_day) {
163
+ rl.seven_day = {
164
+ used_percentage: chatRateLimits.seven_day.percent,
165
+ resets_at: chatRateLimits.seven_day.resetAtMs ?? rl.seven_day?.resets_at ?? null,
166
+ }
167
+ }
168
+ base.rate_limits = rl
169
+ try {
170
+ await fs.mkdir(path.dirname(p), { recursive: true })
171
+ } catch {
172
+ /* ignore */
173
+ }
174
+ const tmp = `${p}.tmp.${process.pid}`
175
+ try {
176
+ await fs.writeFile(tmp, JSON.stringify(base))
177
+ await fs.rename(tmp, p)
178
+ } catch {
179
+ try {
180
+ await fs.unlink(tmp)
181
+ } catch {
182
+ /* ignore */
183
+ }
184
+ }
185
+ }
186
+
127
187
  /**
128
188
  * SDK の SDKRateLimitInfo を取り込む (main.mjs の rate_limit_event ハンドラから呼ぶ)。
129
189
  * rateLimitType: 'five_hour' / 'seven_day*' を 5h / 7d スロットに振り分ける。
190
+ *
191
+ * 取り込んだ値はプロセス内 (hub-agent の getUsage 用) と statusLine cache ファイル
192
+ * (webapp フッター用) の両方に反映する。ファイル書き込みは fire-and-forget。
130
193
  */
131
194
  export function recordChatRateLimit(info) {
132
195
  if (!info || typeof info !== "object") return
@@ -141,6 +204,141 @@ export function recordChatRateLimit(info) {
141
204
  }
142
205
  chatRateLimits[slot] = { percent, resetAtMs }
143
206
  chatRateLimits.updatedAtMs = Date.now()
207
+ // webapp フッター (ファイルベース readOfficial) 用に latest.json へ書き戻す。
208
+ // fire-and-forget だが in-flight promise は保持 (flush 可能にする)。
209
+ _persistInFlight = persistChatRateLimitsToCache()
210
+ }
211
+
212
+ /**
213
+ * 直近の statusLine cache 書き込みの完了を待つ。テストや graceful shutdown 用。
214
+ * @returns {Promise<void>}
215
+ */
216
+ export function whenChatRateLimitsPersisted() {
217
+ return _persistInFlight
218
+ }
219
+
220
+ // session 別ファイル書き込みの in-flight promise (テスト/shutdown 用)。
221
+ // context% (assistant) と chat_status (result) の書き込みは read-modify-write の
222
+ // ため、この chain に直列化して lost-update / 競合を防ぐ。
223
+ let _sessionPersistInFlight = Promise.resolve()
224
+
225
+ /** session_id を安全なファイル名としてバリデーション (path traversal 防止)。 */
226
+ function isSafeSessionId(sid) {
227
+ return typeof sid === "string" && /^[A-Za-z0-9_-]{8,64}$/.test(sid)
228
+ }
229
+
230
+ const VALID_CHAT_STATUS = new Set(["processing", "waiting", "idle"])
231
+
232
+ /**
233
+ * session 別ファイル (`~/.hub/usage/sessions/<sid>.json`) へ patch を
234
+ * マージ書き込みする (read-modify-write, atomic rename)。
235
+ *
236
+ * webapp サイドバーは getSessionUsages (context ドーナツ) と listSessions
237
+ * (ステータスドット) がこのディレクトリを読むが、ファイルは TUI statusLine
238
+ * でしか更新されないため、チャットモードでは両方が固まる。チャット中は SDK の
239
+ * usage / busy 状態からここへ書き出すことで両方ライブ更新される。
240
+ *
241
+ * context_window と chat_status は別タイミング (assistant / result) で書かれる
242
+ * ため、既存値を破壊しないよう deep merge する。
243
+ */
244
+ async function mergeSessionFile(sessionId, cwd, patch) {
245
+ if (!isSafeSessionId(sessionId) || !cwd) return
246
+ const dir = statuslineSessionsDir()
247
+ const fp = path.join(dir, `${sessionId}.json`)
248
+ let base = {}
249
+ const existing = await readOrNull(fp)
250
+ if (existing) {
251
+ try {
252
+ const p = JSON.parse(existing)
253
+ if (p && typeof p === "object") base = p
254
+ } catch {
255
+ /* 壊れたファイルは作り直す */
256
+ }
257
+ }
258
+ const next = {
259
+ ...base,
260
+ ...patch,
261
+ session_id: sessionId,
262
+ workspace: { ...(base.workspace || {}), project_dir: cwd },
263
+ _source: "chat",
264
+ }
265
+ // context_window はネストオブジェクトなので明示マージ。
266
+ if (patch.context_window) {
267
+ next.context_window = { ...(base.context_window || {}), ...patch.context_window }
268
+ }
269
+ try {
270
+ await fs.mkdir(dir, { recursive: true })
271
+ } catch {
272
+ /* ignore */
273
+ }
274
+ const tmp = `${fp}.tmp.${process.pid}`
275
+ try {
276
+ await fs.writeFile(tmp, JSON.stringify(next))
277
+ await fs.rename(tmp, fp)
278
+ } catch {
279
+ try {
280
+ await fs.unlink(tmp)
281
+ } catch {
282
+ /* ignore */
283
+ }
284
+ }
285
+ }
286
+
287
+ /** session 別ファイル書き込みを直列 chain に積む。 */
288
+ function enqueueSessionWrite(fn) {
289
+ _sessionPersistInFlight = _sessionPersistInFlight.catch(() => {}).then(fn)
290
+ return _sessionPersistInFlight
291
+ }
292
+
293
+ /**
294
+ * チャット assistant メッセージを取り込んで session 別 context% を更新する
295
+ * (main.mjs の event ハンドラから assistant メッセージで呼ぶ)。fire-and-forget。
296
+ *
297
+ * context トークン = input + cache_read + cache_creation + output(直近応答)。
298
+ * プロンプトキャッシュ有効時は cache_* に大半が乗るため 4 種を必ず合算する。
299
+ *
300
+ * @param {{ sessionId?: string, cwd?: string, message?: { usage?: object } }} arg
301
+ */
302
+ export function recordChatSessionContext(arg) {
303
+ if (!arg || typeof arg !== "object") return
304
+ const { sessionId, cwd, message } = arg
305
+ const u = message?.usage
306
+ if (!u) return
307
+ const tokens =
308
+ (u.input_tokens || 0) +
309
+ (u.cache_read_input_tokens || 0) +
310
+ (u.cache_creation_input_tokens || 0) +
311
+ (u.output_tokens || 0)
312
+ if (tokens <= 0) return
313
+ enqueueSessionWrite(async () => {
314
+ const windowSize = await contextWindowSize()
315
+ const percent = Math.min(100, Math.round((tokens / windowSize) * 1000) / 10)
316
+ await mergeSessionFile(sessionId, cwd, { context_window: { used_percentage: percent } })
317
+ })
318
+ }
319
+
320
+ /**
321
+ * チャットのターン状態 (processing/waiting/idle) を session 別ファイルへ書き出す。
322
+ * main.mjs の event ハンドラから、assistant→processing / result→waiting で呼ぶ。
323
+ * webapp listSessions が tmux スクレイプの代わりにこれを優先する (チャットモードは
324
+ * ペインに TUI を出さずスクレイプが効かないため)。fire-and-forget。
325
+ *
326
+ * @param {{ sessionId?: string, cwd?: string, status?: string }} arg
327
+ */
328
+ export function recordChatSessionStatus(arg) {
329
+ if (!arg || typeof arg !== "object") return
330
+ const { sessionId, cwd, status } = arg
331
+ if (!VALID_CHAT_STATUS.has(status)) return
332
+ enqueueSessionWrite(() => mergeSessionFile(sessionId, cwd, { chat_status: status }))
333
+ }
334
+
335
+ /**
336
+ * 直近の session 別ファイル書き込み (context%/status 両方) の完了を待つ。
337
+ * テスト/shutdown 用。
338
+ * @returns {Promise<void>}
339
+ */
340
+ export function whenChatSessionContextPersisted() {
341
+ return _sessionPersistInFlight
144
342
  }
145
343
 
146
344
  async function readOfficial(now) {