@cocorograph/hub-agent 0.6.21 → 0.6.22

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.21",
3
+ "version": "0.6.22",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -0,0 +1,85 @@
1
+ /**
2
+ * チャット(SDK stream-json)モードの per-session 状態を、Hub ホスト型 Cockpit の
3
+ * サイドバー信号 (session.state / session.event) に橋渡しするための in-process ストア。
4
+ *
5
+ * 背景:
6
+ * Hub ホスト型 Cockpit のサイドバーは以下を「TUI 専用シグナル」から導出している:
7
+ * - ステータスドット / 各行 context%: hub-agent の startStateLoop が
8
+ * detectSessionState (tmux capture-pane スクレイプ) で算出して session.state を push
9
+ * - 時系列ソート: bundle hook (cockpit_session_event_hook.py) が
10
+ * /tmp/cockpit_session_events/<tmux名>.json を書き、hub-agent が watch して
11
+ * session.event を push → frontend の eventAt sort key
12
+ * チャットモードは tmux ペインに Claude TUI を描画せず、hook も TUI 経由でしか
13
+ * 発火しないため、これらが更新されずドット/ドーナツ/ソートが固まる。
14
+ *
15
+ * 解決:
16
+ * claude-stream-bridge の SDK event (assistant / result) を main.mjs で捕捉し、
17
+ * ここに cwd キーで status / context_pct / 最終アクティビティ時刻を記録する。
18
+ * startStateLoop が tmux セッションを走査する際、session の cwd に一致する新鮮な
19
+ * チャット信号があれば status / context_pct を上書きし、turnAt 更新時には
20
+ * session-event ファイルを書いてソートを動かす。
21
+ *
22
+ * cwd をキーにする理由: チャットセッションは tmux セッション名を持たず (SDK 子
23
+ * プロセス)、サイドバー行 (= tmux セッション) とは cwd で対応するため。
24
+ */
25
+
26
+ // cwd → { status, context_pct, turnAt, updatedAtMs }
27
+ const _byCwd = new Map()
28
+
29
+ // チャット信号を「生きている」と見なす最大経過時間 (ms)。これを過ぎたら tmux
30
+ // スクレイプ結果へフォールバックさせる (チャット終了/別モード移行の検知)。
31
+ const CHAT_SIGNAL_STALE_MS = 15 * 60 * 1000
32
+
33
+ const VALID_STATUS = new Set(["processing", "waiting", "idle"])
34
+
35
+ function _now() {
36
+ return Date.now()
37
+ }
38
+
39
+ /**
40
+ * チャットのアクティビティを記録する。status / context_pct は与えられた分だけ更新し、
41
+ * turnAt (= 最終アクティビティ時刻 / ソート用) は呼ばれるたびに前進させる。
42
+ *
43
+ * @param {string} cwd セッションの作業ディレクトリ
44
+ * @param {{ status?: string, contextPct?: number|null }} patch
45
+ */
46
+ export function recordChatActivity(cwd, patch = {}) {
47
+ if (!cwd || typeof cwd !== "string") return
48
+ const now = _now()
49
+ const prev = _byCwd.get(cwd) || {
50
+ status: null,
51
+ context_pct: null,
52
+ turnAt: 0,
53
+ updatedAtMs: 0,
54
+ }
55
+ const next = { ...prev, turnAt: now, updatedAtMs: now }
56
+ if (typeof patch.status === "string" && VALID_STATUS.has(patch.status)) {
57
+ next.status = patch.status
58
+ }
59
+ if (typeof patch.contextPct === "number" && Number.isFinite(patch.contextPct)) {
60
+ next.context_pct = Math.max(0, Math.min(100, patch.contextPct))
61
+ }
62
+ _byCwd.set(cwd, next)
63
+ }
64
+
65
+ /**
66
+ * cwd に対応する生きているチャット信号を返す。stale / 不在なら null。
67
+ *
68
+ * @param {string} cwd
69
+ * @param {number} [now]
70
+ * @returns {{ status: string|null, context_pct: number|null, turnAt: number, updatedAtMs: number } | null}
71
+ */
72
+ export function getChatSignal(cwd, now = _now()) {
73
+ if (!cwd) return null
74
+ const sig = _byCwd.get(cwd)
75
+ if (!sig) return null
76
+ if (now - sig.updatedAtMs > CHAT_SIGNAL_STALE_MS) return null
77
+ return sig
78
+ }
79
+
80
+ /** テスト用: ストアを空にする。 */
81
+ export function _resetChatSignals() {
82
+ _byCwd.clear()
83
+ }
84
+
85
+ export { CHAT_SIGNAL_STALE_MS }
package/src/main.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  * 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
12
12
  */
13
13
  import { readFileSync, watch as fsWatch } from "node:fs"
14
- import { mkdir, readFile, readdir } from "node:fs/promises"
14
+ import { mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises"
15
15
  import os from "node:os"
16
16
  import path from "node:path"
17
17
 
@@ -37,13 +37,8 @@ import {
37
37
  listWorktreeStubs,
38
38
  removeWorktree as removeWorktreeDir,
39
39
  } from "./tmux.mjs"
40
- import {
41
- getSessionUsages,
42
- getUsage,
43
- recordChatRateLimit,
44
- recordChatSessionContext,
45
- recordChatSessionStatus,
46
- } from "./usage.mjs"
40
+ import { getSessionUsages, getUsage, recordChatRateLimit } from "./usage.mjs"
41
+ import { getChatSignal, recordChatActivity } from "./chat-signals.mjs"
47
42
 
48
43
  const logger = pino({ name: "hub-agent" })
49
44
 
@@ -194,30 +189,22 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
194
189
  /* ignore */
195
190
  }
196
191
  }
197
- // assistant メッセージの usage から session 別 context% を算出してファイルへ
198
- // 書き出す (サイドバー各行のドーナツ用。これも statusLine がチャットでは更新
199
- // されないための補完)。あわせてターン状態を更新する:
200
- // - assistant (生成中) processing
192
+ // Hub ホスト型 Cockpit 用: SDK event から per-session の状態をチャット信号として
193
+ // 記録する。startStateLoop cwd 一致で session.state (ステータスドット/各行
194
+ // ドーナツ) と session.event (ソート) に橋渡しする。チャットは tmux ペインに
195
+ // TUI を出さず、capture-pane スクレイプも bundle hook も発火しないための補完。
196
+ // - assistant (生成中) → processing + usage から context%
201
197
  // - result (ターン完了/入力待ち) → waiting
202
- // webapp のステータスドットは tmux ペインのスクレイプで判定するが、チャットは
203
- // ペインに TUI を出さないため、この chat_status を優先させて補完する。
204
- const sid = session_id || event?.session_id
205
198
  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
- }
199
+ const pct = event.message?.usage ? contextPctFromUsage(event.message.usage) : null
213
200
  try {
214
- recordChatSessionStatus({ sessionId: sid, cwd, status: "processing" })
201
+ recordChatActivity(cwd, { status: "processing", contextPct: pct })
215
202
  } catch {
216
203
  /* ignore */
217
204
  }
218
205
  } else if (event?.type === "result") {
219
206
  try {
220
- recordChatSessionStatus({ sessionId: sid, cwd, status: "waiting" })
207
+ recordChatActivity(cwd, { status: "waiting" })
221
208
  } catch {
222
209
  /* ignore */
223
210
  }
@@ -332,6 +319,48 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
332
319
  const SESSION_EVENTS_DIR =
333
320
  process.env.COCKPIT_SESSION_EVENTS_DIR || "/tmp/cockpit_session_events"
334
321
 
322
+ // context 窓サイズ (トークン)。usage.mjs と同じ既定。1M ベータ等は env で上書き。
323
+ const CONTEXT_WINDOW_TOKENS = Number(process.env.HUB_CONTEXT_WINDOW) || 200000
324
+
325
+ /**
326
+ * SDK assistant メッセージの usage から context 使用率 (%) を概算する。
327
+ * context = input + cache_read + cache_creation + output(直近応答)。
328
+ * プロンプトキャッシュ有効時は cache_* に大半が乗るため 4 種を合算する。
329
+ */
330
+ function contextPctFromUsage(u) {
331
+ if (!u) return null
332
+ const tokens =
333
+ (u.input_tokens || 0) +
334
+ (u.cache_read_input_tokens || 0) +
335
+ (u.cache_creation_input_tokens || 0) +
336
+ (u.output_tokens || 0)
337
+ if (tokens <= 0) return null
338
+ return Math.min(100, Math.round((tokens / CONTEXT_WINDOW_TOKENS) * 1000) / 10)
339
+ }
340
+
341
+ /**
342
+ * sort 用の session-event ファイルをチャットのターン境界で書き出す
343
+ * (`/tmp/cockpit_session_events/<tmux名>.json`)。bundle hook が TUI で書くものと
344
+ * 同形式 (`{ event, at }`)。既存の watcher がこれを拾って session.event を push し、
345
+ * frontend の eventAt sort key を更新する。atomic write、全エラー握りつぶし。
346
+ */
347
+ async function writeSessionEventFile(sessionName, event, at) {
348
+ if (!sessionName || /[/\\]/.test(sessionName)) return
349
+ const fp = path.join(SESSION_EVENTS_DIR, `${sessionName}.json`)
350
+ const tmp = `${fp}.tmp.${process.pid}`
351
+ try {
352
+ await mkdir(SESSION_EVENTS_DIR, { recursive: true })
353
+ await writeFile(tmp, JSON.stringify({ event, at }))
354
+ await rename(tmp, fp)
355
+ } catch {
356
+ try {
357
+ await unlink(tmp)
358
+ } catch {
359
+ /* ignore */
360
+ }
361
+ }
362
+ }
363
+
335
364
  /**
336
365
  * `/tmp/cockpit_session_events/` 全 .json を読んで session_name → {event, at}
337
366
  * の Map を返す (Phase 4: tmux.list_sessions レスポンスに含めて配信する用)。
@@ -448,6 +477,7 @@ async function startSessionEventWatcher({ client, logger }) {
448
477
  */
449
478
  function startStateLoop({ client, plugins, logger, intervalMs }) {
450
479
  const lastByName = new Map() // session_name → {status, context_pct}
480
+ const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
451
481
  let stopped = false
452
482
 
453
483
  const tick = async () => {
@@ -455,21 +485,39 @@ function startStateLoop({ client, plugins, logger, intervalMs }) {
455
485
  try {
456
486
  const states = await listSessionStates({ plugins, logger })
457
487
  for (const s of states) {
488
+ let status = s.status
489
+ let contextPct = s.context_pct
490
+ // チャットモード補完: tmux ペインのスクレイプは TUI 前提で効かないため、
491
+ // session の cwd に一致する新鮮なチャット信号があれば status/context% を
492
+ // 上書きする (Hub ホスト型 Cockpit のステータスドット/各行ドーナツ用)。
493
+ const chat = s.cwd ? getChatSignal(s.cwd) : null
494
+ if (chat) {
495
+ if (chat.status) status = chat.status
496
+ if (typeof chat.context_pct === "number") contextPct = chat.context_pct
497
+ // チャットのターン境界 (turnAt 前進) を sort 用 session-event に橋渡し。
498
+ // tmux ペインが動かず bundle hook が発火しないため、ここで代替発火する。
499
+ const prevTurnAt = lastTurnAtByName.get(s.session_name) || 0
500
+ if (chat.turnAt > prevTurnAt) {
501
+ lastTurnAtByName.set(s.session_name, chat.turnAt)
502
+ const ev = chat.status === "processing" ? "prompt_submit" : "stop"
503
+ writeSessionEventFile(s.session_name, ev, chat.turnAt).catch(() => {})
504
+ }
505
+ }
458
506
  const prev = lastByName.get(s.session_name)
459
507
  if (
460
508
  !prev ||
461
- prev.status !== s.status ||
462
- prev.context_pct !== s.context_pct
509
+ prev.status !== status ||
510
+ prev.context_pct !== contextPct
463
511
  ) {
464
512
  lastByName.set(s.session_name, {
465
- status: s.status,
466
- context_pct: s.context_pct,
513
+ status,
514
+ context_pct: contextPct,
467
515
  })
468
516
  client.send({
469
517
  type: "session.state",
470
518
  session_name: s.session_name,
471
- status: s.status,
472
- context_pct: s.context_pct,
519
+ status,
520
+ context_pct: contextPct,
473
521
  })
474
522
  }
475
523
  }
package/src/state.mjs CHANGED
@@ -83,6 +83,28 @@ export async function capturePane(sessionName, opts = {}) {
83
83
  }
84
84
  }
85
85
 
86
+ /**
87
+ * tmux session の active pane の current_path を取得する (chat 信号との cwd 照合用)。
88
+ * 取れなければ null。
89
+ */
90
+ export async function getSessionCwd(sessionName, opts = {}) {
91
+ const tmuxBin = opts.tmuxBin || "tmux"
92
+ try {
93
+ const { stdout } = await execFileP(tmuxBin, [
94
+ "display-message",
95
+ "-p",
96
+ "-t",
97
+ `${sessionName}:`,
98
+ "-F",
99
+ "#{pane_current_path}",
100
+ ])
101
+ const s = stdout.trim()
102
+ return s || null
103
+ } catch {
104
+ return null
105
+ }
106
+ }
107
+
86
108
  /**
87
109
  * 1 session の現在状態を取得する。plugin hook で上書き可。
88
110
  *
@@ -111,14 +133,17 @@ export async function detectSessionState(sessionName, opts = {}) {
111
133
  return { status: defaultStatus, context_pct: defaultContextPct }
112
134
  }
113
135
 
114
- /** 全 session の現在状態を取得する。 */
136
+ /** 全 session の現在状態を取得する。cwd も含める (chat 信号照合用)。 */
115
137
  export async function listSessionStates(opts = {}) {
116
138
  const names = await listSessionNames(opts)
117
139
  return Promise.all(
118
- names.map(async (name) => ({
119
- session_name: name,
120
- ...(await detectSessionState(name, opts)),
121
- })),
140
+ names.map(async (name) => {
141
+ const [state, cwd] = await Promise.all([
142
+ detectSessionState(name, opts),
143
+ getSessionCwd(name, opts),
144
+ ])
145
+ return { session_name: name, cwd, ...state }
146
+ }),
122
147
  )
123
148
  }
124
149
 
package/src/usage.mjs CHANGED
@@ -217,130 +217,6 @@ export function whenChatRateLimitsPersisted() {
217
217
  return _persistInFlight
218
218
  }
219
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
342
- }
343
-
344
220
  async function readOfficial(now) {
345
221
  const text = await readOrNull(statuslineCache())
346
222
  if (!text) return null