@cocorograph/hub-agent 0.6.19 → 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.19",
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",
@@ -389,12 +389,17 @@ class ClaudeStreamSession {
389
389
  // 走行中ターンは完走を待ち TTL 後に再チェック (走行中は絶対に撤去しない)
390
390
  if (this._busy) {
391
391
  this._idleTimer = setTimeout(tick, ttlMs)
392
+ // idle TTL は最大 7 日。タイマー単独でイベントループを生かさない (unref)。
393
+ // 本番デーモンは WS 接続等で常駐するので reap は問題なく発火し、テスト/CLI
394
+ // など他に handle が無いプロセスは即座に終了できる。
395
+ this._idleTimer.unref?.()
392
396
  return
393
397
  }
394
398
  this._idleTimer = null
395
399
  onTimeout?.()
396
400
  }
397
401
  this._idleTimer = setTimeout(tick, ttlMs)
402
+ this._idleTimer.unref?.()
398
403
  }
399
404
 
400
405
  /**
@@ -897,7 +902,13 @@ export class ClaudeStreamBridge extends EventEmitter {
897
902
  logger: this.logger,
898
903
  onEvent: (event) => {
899
904
  // stream_id は再アタッチで変わるため session.stream_id (最新) を使う。
900
- 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
+ })
901
912
  // session_id 確定後は索引に登録 (冪等)。再接続時の再アタッチに使う。
902
913
  if (session.sessionId) this._liveBySession.set(session.sessionId, session)
903
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 }) => {
@@ -128,6 +128,67 @@ function run(cmd, args, opts = {}) {
128
128
  }
129
129
  }
130
130
 
131
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
132
+
133
+ /**
134
+ * macOS の `launchctl bootout` は非同期で、graceful shutdown を持つ hub-agent は
135
+ * 実際に launchd の登録から消えるまでに時間がかかる。直後に `bootstrap` を叩くと
136
+ * 旧サービスがまだ抜け切っておらず `Bootstrap failed: 5: Input/output error` 等で
137
+ * 失敗し、plist はディスクにあるのにロードされない (= KeepAlive 不発 → offline)
138
+ * 状態に陥る。本関数は対象 service が launchd の登録から消えるまで polling して待つ。
139
+ * (2026-05-30: install-service の bootout→bootstrap レースで offline 化する事故の修正)
140
+ *
141
+ * @param {number} uid
142
+ * @param {{ timeoutMs?: number, intervalMs?: number }} [opts]
143
+ * @returns {Promise<boolean>} 消えたら true / timeout なら false
144
+ */
145
+ async function waitUntilBootedOut(uid, opts = {}) {
146
+ const timeoutMs = opts.timeoutMs ?? 5000
147
+ const intervalMs = opts.intervalMs ?? 200
148
+ const spawn = opts.spawn ?? spawnSync
149
+ const sleepFn = opts.sleep ?? sleep
150
+ const now = opts.now ?? (() => Date.now())
151
+ const deadline = now() + timeoutMs
152
+ while (now() < deadline) {
153
+ const r = spawn("launchctl", ["print", `gui/${uid}/${PLIST_LABEL}`], {
154
+ stdio: "ignore",
155
+ })
156
+ // 非 0 = 登録に存在しない = bootout 完了
157
+ if (r.status !== 0) return true
158
+ await sleepFn(intervalMs)
159
+ }
160
+ return false
161
+ }
162
+
163
+ /**
164
+ * bootout 後の bootstrap を、過渡的失敗 (旧サービスの抜け残り等) に対して
165
+ * リトライする。`run` と違い最終的に失敗したときだけ throw する。
166
+ *
167
+ * @param {number} uid
168
+ * @param {string} dest plist パス
169
+ * @param {{ attempts?: number, intervalMs?: number }} [opts]
170
+ */
171
+ async function bootstrapWithRetry(uid, dest, opts = {}) {
172
+ const attempts = opts.attempts ?? 4
173
+ const intervalMs = opts.intervalMs ?? 400
174
+ const spawn = opts.spawn ?? spawnSync
175
+ const sleepFn = opts.sleep ?? sleep
176
+ let lastStatus = null
177
+ for (let i = 0; i < attempts; i++) {
178
+ const r = spawn("launchctl", ["bootstrap", `gui/${uid}`, dest], {
179
+ stdio: "inherit",
180
+ })
181
+ if (r.status === 0) return
182
+ // 既にロード済み (status 37 / "service already loaded") は成功とみなす
183
+ if (r.status === 37) return
184
+ lastStatus = r.status
185
+ await sleepFn(intervalMs)
186
+ }
187
+ throw new Error(
188
+ `launchctl bootstrap gui/${uid} ${dest} failed after ${attempts} attempts (last exit ${lastStatus})`
189
+ )
190
+ }
191
+
131
192
  /**
132
193
  * `which hub-agent` の結果を、launchd / systemd から長期間 exec 可能な
133
194
  * 安定パスに正規化する。pure 関数。`opts` で fs を差し替えてテスト可能。
@@ -200,10 +261,17 @@ export async function installService({ bin } = {}) {
200
261
  await fs.writeFile(dest, expanded, { mode: 0o644 })
201
262
 
202
263
  const uid = ensureUnixUid()
203
- // 既存ロード解除 → bootstrap → kickstart
264
+ // 既存ロード解除 → (登録が消えるまで待機) → bootstrap (リトライ) → kickstart
265
+ // bootout は非同期なので待たずに bootstrap するとレースで失敗し offline 化する
266
+ // (2026-05-30 の事故)。waitUntilBootedOut で確実に抜けてから bootstrap する。
204
267
  spawnSync("launchctl", ["bootout", `gui/${uid}`, dest], { stdio: "ignore" })
205
- run("launchctl", ["bootstrap", `gui/${uid}`, dest])
206
- run("launchctl", ["kickstart", "-k", `gui/${uid}/${PLIST_LABEL}`])
268
+ await waitUntilBootedOut(uid)
269
+ await bootstrapWithRetry(uid, dest)
270
+ // kickstart は「既に走っている daemon を再起動」する用途。bootstrap + RunAtLoad で
271
+ // 既に起動済みのことがあり、その場合 kickstart 失敗は致命的でないので throw しない。
272
+ spawnSync("launchctl", ["kickstart", "-k", `gui/${uid}/${PLIST_LABEL}`], {
273
+ stdio: "ignore",
274
+ })
207
275
  return { platform: "darwin", path: dest, label: PLIST_LABEL, bin: hubAgentBin }
208
276
  }
209
277
 
@@ -300,4 +368,6 @@ export const _internal = {
300
368
  macPlistPath,
301
369
  linuxUnitPath,
302
370
  repoTemplatesDir,
371
+ waitUntilBootedOut,
372
+ bootstrapWithRetry,
303
373
  }
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) {