@cocorograph/hub-agent 0.8.5 → 0.8.6

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.8.5",
3
+ "version": "0.8.6",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 読み取り専用スナップショット向けの短期 TTL + single-flight キャッシュ。
3
+ *
4
+ * - TTL 内は直前の成功値を即返す
5
+ * - TTL 切れ後に複数 caller が重なった場合も build は 1 本だけ実行する
6
+ * - build 失敗はキャッシュせず、次回 caller が再試行できる
7
+ * - clear() は進行中 build の呼び出し元も新世代の build へ合流し直す
8
+ */
9
+ export class AsyncTtlCache {
10
+ constructor({ ttlMs, now = () => Date.now() }) {
11
+ this.ttlMs = Math.max(0, Number(ttlMs) || 0)
12
+ this.now = now
13
+ this.hasValue = false
14
+ this.value = undefined
15
+ this.expiresAt = 0
16
+ this.inFlight = null
17
+ this.generation = 0
18
+ }
19
+
20
+ async get(build) {
21
+ if (this.hasValue && this.now() < this.expiresAt) return this.value
22
+ if (this.inFlight) return this.inFlight
23
+
24
+ const generation = this.generation
25
+ const pending = Promise.resolve().then(build)
26
+ this.inFlight = pending
27
+ try {
28
+ const value = await pending
29
+ // build 中に mutation / state push が走った場合、旧値を最初の caller にも返さない。
30
+ // clear() が inFlight を外しているため、新世代の build を開始するか既存の新 build に
31
+ // 合流する。read-only snapshot 専用なので再実行は安全。
32
+ if (this.generation !== generation) return this.get(build)
33
+ this.value = value
34
+ this.hasValue = true
35
+ this.expiresAt = this.now() + this.ttlMs
36
+ return value
37
+ } finally {
38
+ if (this.inFlight === pending) this.inFlight = null
39
+ }
40
+ }
41
+
42
+ clear() {
43
+ this.generation += 1
44
+ this.hasValue = false
45
+ this.value = undefined
46
+ this.expiresAt = 0
47
+ // 旧 build 自体は安全に完走させるが、新 caller はそこへ合流させない。旧 build の
48
+ // finally は identity 比較により、新世代の inFlight を誤って消さない。
49
+ this.inFlight = null
50
+ }
51
+ }
@@ -43,6 +43,7 @@
43
43
  import { EventEmitter } from "node:events"
44
44
  import { randomUUID } from "node:crypto"
45
45
 
46
+ import { AsyncTtlCache } from "./async-ttl-cache.mjs"
46
47
  import { CodexAppServerClient } from "./codex-appserver-client.mjs"
47
48
  import {
48
49
  snapshotCodexDeliveryAudit,
@@ -323,6 +324,7 @@ export class CodexStreamBridge extends EventEmitter {
323
324
  listThreadsFromDisk,
324
325
  historyMaxItems,
325
326
  historyMaxBytes,
327
+ listThreadsCacheTtlMs,
326
328
  } = {}) {
327
329
  super()
328
330
  this.codexCmd = codexCmd
@@ -357,10 +359,12 @@ export class CodexStreamBridge extends EventEmitter {
357
359
  /** @type {Map<string, object>} stream_id -> session record */
358
360
  this.sessions = new Map()
359
361
  this._accountRateLimitsByProfile = new Map()
360
- // Phase 3 (Read-plane): listThreads の single-flight。key(`cwd::limit`) -> in-flight Promise。
361
- // codex.sessions.request を直列キューから外したため、同一 cwd への同時リクエストが
362
- // app-server の同時 spawn (storm) を起こさないよう 1 本に畳む。
363
- this._listThreadsInFlight = new Map()
362
+ // 複数 Cockpit タブ + pre-attach/ready の連続 request が、同じ cwd で rollout
363
+ // header 最大 800 件を短時間に何度も走査するのを防ぐ。thread picker の読み取り専用
364
+ // データなので数秒の鮮度差は許容し、実 turn/attach の正本には使わない。各 key の
365
+ // AsyncTtlCache が single-flight と、thread/start 中の世代切替をまとめて担う。
366
+ this._listThreadsCacheTtlMs = listThreadsCacheTtlMs ?? 5_000
367
+ this._listThreadsCache = new Map()
364
368
  }
365
369
 
366
370
  /**
@@ -576,6 +580,9 @@ export class CodexStreamBridge extends EventEmitter {
576
580
  ? await client.request("thread/resume", { ...startParams, threadId: resumeThreadId })
577
581
  : await client.request("thread/start", startParams)
578
582
  session.threadId = result?.thread?.id
583
+ // thread/start は一覧そのものを変更するため、pre-attach で得た短期キャッシュを破棄する。
584
+ // resume/reattach は既存 thread の再閲覧なのでキャッシュを維持し、ready 後の重複走査を防ぐ。
585
+ if (!resuming) this._invalidateListThreadsCacheForCwd(cwd)
579
586
  this.logger?.info?.(
580
587
  {
581
588
  stream_id,
@@ -836,13 +843,33 @@ export class CodexStreamBridge extends EventEmitter {
836
843
  // sessionsDir 未指定 (旧経路) のときだけ env 由来の値へフォールバックする。
837
844
  const readRoot = sessionsDir || process.env.CODEX_HOME || ""
838
845
  const key = `${readRoot}::${cwd || ""}::${limit ?? ""}`
839
- const existing = this._listThreadsInFlight.get(key)
840
- if (existing) return existing
841
- const p = this._doListThreads({ cwd, limit, sessionsDir }).finally(() => {
842
- this._listThreadsInFlight.delete(key)
843
- })
844
- this._listThreadsInFlight.set(key, p)
845
- return p
846
+ let entry = this._listThreadsCache.get(key)
847
+ if (!entry) {
848
+ entry = {
849
+ cwd: cwd || "",
850
+ cache: new AsyncTtlCache({ ttlMs: this._listThreadsCacheTtlMs }),
851
+ }
852
+ this._listThreadsCache.set(key, entry)
853
+ this._pruneListThreadsCache()
854
+ }
855
+ return entry.cache.get(() => this._doListThreads({ cwd, limit, sessionsDir }))
856
+ }
857
+
858
+ _invalidateListThreadsCacheForCwd(cwd) {
859
+ const normalizedCwd = cwd || ""
860
+ for (const entry of this._listThreadsCache.values()) {
861
+ if (entry.cwd === normalizedCwd) entry.cache.clear()
862
+ }
863
+ }
864
+
865
+ _pruneListThreadsCache() {
866
+ if (this._listThreadsCache.size <= 256) return
867
+ for (const [key, entry] of this._listThreadsCache) {
868
+ if (this._listThreadsCache.size <= 256) break
869
+ // 走査中の entry を map から外すと、同じ key の次 caller が別走査を開始して
870
+ // single-flight を破る。完了済みの古い entry だけを先に落とす。
871
+ if (!entry.cache.inFlight) this._listThreadsCache.delete(key)
872
+ }
846
873
  }
847
874
 
848
875
  async _doListThreads({ cwd, limit, sessionsDir } = {}) {
package/src/main.mjs CHANGED
@@ -48,6 +48,7 @@ import {
48
48
  import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs"
49
49
  import { WsClient } from "./ws-client.mjs"
50
50
  import { DispatchScheduler } from "./dispatch-scheduler.mjs"
51
+ import { AsyncTtlCache } from "./async-ttl-cache.mjs"
51
52
  import { PtyBridge } from "./pty-bridge.mjs"
52
53
  import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
53
54
  import { CodexMcpControl } from "./codex-mcp-control.mjs"
@@ -92,6 +93,7 @@ import {
92
93
  } from "./claude-credentials.mjs"
93
94
  import {
94
95
  buildClaudeCmd,
96
+ buildWorktreeIndex,
95
97
  createSession as createTmuxSession,
96
98
  createWorktreeDir,
97
99
  execTmux,
@@ -1717,6 +1719,11 @@ const lastTurnTextsCache = new Map()
1717
1719
  */
1718
1720
  const lastTurnTextsInFlight = new Map()
1719
1721
 
1722
+ // 複数 Cockpit タブは各自 8 秒 poll を持つため、位相がずれると agent は同じ
1723
+ // tmux/worktree/jsonl スナップショットを数秒おきに再構築してしまう。5 秒だけ成功値を
1724
+ // 共有し、同時要求は single-flight に畳む。create/kill/worktree 変更時は明示 clear する。
1725
+ const tmuxSessionsSnapshotCache = new AsyncTtlCache({ ttlMs: 5_000 })
1726
+
1720
1727
  /** cold cache 時に全セッションを一斉に読まないための同時実行上限。 */
1721
1728
  const LAST_TEXTS_CONCURRENCY = 4
1722
1729
 
@@ -1950,6 +1957,9 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
1950
1957
  "session.event push",
1951
1958
  )
1952
1959
  }
1960
+ // 直前に作った一覧 snapshot がこの event より古い場合、次の poll で last_event を
1961
+ // 逆戻りさせない。build 中なら AsyncTtlCache が新世代へ合流し直す。
1962
+ tmuxSessionsSnapshotCache.clear()
1953
1963
  client.send({
1954
1964
  type: "session.event",
1955
1965
  session_name: sessionName,
@@ -2274,6 +2284,7 @@ export async function pushImmediateChatState({
2274
2284
  for (const s of states) {
2275
2285
  if (s.cwd !== cwd) continue
2276
2286
  if (cliKindForSessionName(s.session_name) !== cliKind) continue
2287
+ tmuxSessionsSnapshotCache.clear()
2277
2288
  client.send({
2278
2289
  type: "session.state",
2279
2290
  session_name: s.session_name,
@@ -2380,6 +2391,7 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2380
2391
  // これで再接続直後の次 tick (≤intervalMs) に短縮する。初回接続時は空 Map の clear で no-op。
2381
2392
  const onReopen = () => {
2382
2393
  lastByName.clear()
2394
+ tmuxSessionsSnapshotCache.clear()
2383
2395
  }
2384
2396
  client.on("open", onReopen)
2385
2397
 
@@ -2678,6 +2690,9 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2678
2690
  "session.state push",
2679
2691
  )
2680
2692
  }
2693
+ // state push より古い一覧を後続 poll が返すと sidebar 行の status/context が
2694
+ // 逆戻りするため、送信前に snapshot 世代を切り替える。
2695
+ tmuxSessionsSnapshotCache.clear()
2681
2696
  client.send({
2682
2697
  type: "session.state",
2683
2698
  session_name: s.session_name,
@@ -2715,6 +2730,7 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2715
2730
  // の消滅はここが唯一の検知点。frontend が states/lastEvent を忘れてドットを
2716
2731
  // 'down' に倒せるよう、ローカル Map を forget する前に通知する。
2717
2732
  try {
2733
+ tmuxSessionsSnapshotCache.clear()
2718
2734
  client.send({ type: "session.gone", session_name: name })
2719
2735
  } catch {
2720
2736
  /* ignore */
@@ -4434,6 +4450,7 @@ async function dispatch(msg, ctx) {
4434
4450
  ctx.claudeBridge?.shutdown()
4435
4451
  // tmux TUI セッションも旧アカウントのまま残さない: kill → 同名・同 cwd で再作成。
4436
4452
  const tmuxResult = await restartAllTmuxSessionsForProfileSwitch(ctx)
4453
+ tmuxSessionsSnapshotCache.clear()
4437
4454
  // 切替先の config ディレクトリに認証情報が無ければ要ログイン (バッジ/案内用)。
4438
4455
  const hasCreds = isKeychainPlatform()
4439
4456
  ? await profileHasKeychainCredentials(target.configDir)
@@ -4573,6 +4590,7 @@ async function dispatch(msg, ctx) {
4573
4590
  // Codex セッションだけ撤去 (Claude セッションは残す = 独立切替)。
4574
4591
  const before = ctx.codexBridge?.sessions?.size ?? 0
4575
4592
  ctx.codexBridge?.shutdown()
4593
+ tmuxSessionsSnapshotCache.clear()
4576
4594
  const hasCreds = await codexProfiles.hasCodexCredentials(target.configDir)
4577
4595
  const needsLogin = !hasCreds
4578
4596
  const isDefaultTarget = target.id === codexProfiles.DEFAULT_CODEX_PROFILE_ID
@@ -4706,48 +4724,42 @@ async function dispatch(msg, ctx) {
4706
4724
  }
4707
4725
  case "tmux.list_sessions": {
4708
4726
  try {
4709
- // boundSessions: 各行の context% (ドーナツ) を bound session の jsonl から引く
4710
- // (cwd dir 内の別セッション jsonl を最新と取り違える=メタ欠陥#1 の根治)。
4711
- // claudeBridge: TUI bind が無い SDK チャット cwd 向けの同根治フォールバック
4712
- // (2026-07 追加。tmux.mjs listSessions 参照)。
4713
- const sessions = await listTmuxSessions({
4714
- plugins: ctx.plugins,
4715
- logger: ctx.logger,
4716
- boundSessions: ctx.tuiReboundSessions,
4717
- claudeBridge: ctx.claudeBridge,
4727
+ const snapshot = await tmuxSessionsSnapshotCache.get(async () => {
4728
+ // worktree index は listSessions の親判定と stub 列挙で共用する。従来は同じ
4729
+ // 全 workspace + git branch 走査を 1 応答内で 2 回直列実行していた。
4730
+ const worktreeIndex = await buildWorktreeIndex()
4731
+ // boundSessions: 各行の context% (ドーナツ) を bound session の jsonl から引く
4732
+ // (cwd dir 内の別セッション jsonl を最新と取り違える=メタ欠陥#1 の根治)。
4733
+ const sessions = await listTmuxSessions({
4734
+ plugins: ctx.plugins,
4735
+ logger: ctx.logger,
4736
+ boundSessions: ctx.tuiReboundSessions,
4737
+ claudeBridge: ctx.claudeBridge,
4738
+ worktreeIndex,
4739
+ })
4740
+ const [lastEventByName, lastTextsByName] = await Promise.all([
4741
+ readAllSessionEvents(),
4742
+ readAllSessionLastTexts(sessions, ctx.getSessionStateSnapshot),
4743
+ ])
4744
+ const enriched = enrichTmuxSessionsForListResponse(
4745
+ sessions,
4746
+ lastEventByName,
4747
+ ctx.getSessionStateSnapshot,
4748
+ lastTextsByName,
4749
+ )
4750
+ const liveNames = new Set(enriched.map((s) => s.name))
4751
+ let worktreeStubs = []
4752
+ try {
4753
+ worktreeStubs = await listWorktreeStubs(liveNames, { worktreeIndex })
4754
+ } catch (err) {
4755
+ ctx.logger?.warn?.({ err: err?.message }, "listWorktreeStubs failed")
4756
+ }
4757
+ return { sessions: enriched, worktree_stubs: worktreeStubs }
4718
4758
  })
4719
- // 各 session の最新 hook event を /tmp/cockpit_session_events/ から読んで
4720
- // レスポンスに含める (Phase 4: ハードリロード時の lastEvent 復元用)。
4721
- // fs.watch 由来の session.event push は揮発性のため、frontend が新規
4722
- // マウントすると過去 event を取れず全グレー表示になる事象を解消する。
4723
- const lastEventByName = await readAllSessionEvents()
4724
- // サイドバー行に「何を依頼したか」を出すため、各セッションの jsonl 末尾から
4725
- // 最新の依頼 / 応答テキストを読む (mtime キャッシュ付き)。失敗は空に落ちる。
4726
- const lastTextsByName = await readAllSessionLastTexts(
4727
- sessions,
4728
- ctx.getSessionStateSnapshot,
4729
- )
4730
- const enriched = enrichTmuxSessionsForListResponse(
4731
- sessions,
4732
- lastEventByName,
4733
- ctx.getSessionStateSnapshot,
4734
- lastTextsByName,
4735
- )
4736
- // cockpit (PR 1719) で未起動 worktree をサイドバーに可視化するために
4737
- // filesystem 上は存在するが tmux session が無い worktree dir のリストを
4738
- // 同梱する。古い cockpit は worktree_stubs を無視するので互換 OK。
4739
- const liveNames = new Set(enriched.map((s) => s.name))
4740
- let worktreeStubs = []
4741
- try {
4742
- worktreeStubs = await listWorktreeStubs(liveNames)
4743
- } catch (err) {
4744
- ctx.logger?.warn?.({ err: err?.message }, "listWorktreeStubs failed")
4745
- }
4746
4759
  ctx.client.send({
4747
4760
  type: "tmux.sessions",
4748
4761
  request_id: msg.request_id,
4749
- sessions: enriched,
4750
- worktree_stubs: worktreeStubs,
4762
+ ...snapshot,
4751
4763
  })
4752
4764
  } catch (err) {
4753
4765
  ctx.client.send({
@@ -4797,6 +4809,7 @@ async function dispatch(msg, ctx) {
4797
4809
  typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
4798
4810
  logger: ctx.logger,
4799
4811
  })
4812
+ tmuxSessionsSnapshotCache.clear()
4800
4813
  ctx.client.send({
4801
4814
  type: "tmux.create_session.result",
4802
4815
  request_id: msg.request_id,
@@ -4867,6 +4880,7 @@ async function dispatch(msg, ctx) {
4867
4880
  typeof msg.initial_prompt === "string" ? msg.initial_prompt : undefined,
4868
4881
  logger: ctx.logger,
4869
4882
  })
4883
+ tmuxSessionsSnapshotCache.clear()
4870
4884
  ctx.client.send({
4871
4885
  type: "worktree.create.result",
4872
4886
  request_id: msg.request_id,
@@ -4944,6 +4958,7 @@ async function dispatch(msg, ctx) {
4944
4958
  const force = msg.force === true
4945
4959
  try {
4946
4960
  const result = await removeWorktreeDir(name, { force })
4961
+ if (result.dirty !== true) tmuxSessionsSnapshotCache.clear()
4947
4962
  ctx.client.send({
4948
4963
  type: "worktree.remove.result",
4949
4964
  request_id: msg.request_id,
@@ -4966,6 +4981,7 @@ async function dispatch(msg, ctx) {
4966
4981
  }
4967
4982
  case "tmux.kill_session": {
4968
4983
  await handleKillSession(msg, ctx)
4984
+ tmuxSessionsSnapshotCache.clear()
4969
4985
  return
4970
4986
  }
4971
4987
  case "skills.request": {
package/src/tmux.mjs CHANGED
@@ -174,7 +174,7 @@ export async function createWorktreeDir(parentDir, branch) {
174
174
  *
175
175
  * 移植元: D00000_cockpit/webapp/lib/workspaces.ts (findWorktrees)
176
176
  */
177
- async function buildWorktreeIndex() {
177
+ export async function buildWorktreeIndex() {
178
178
  const out = new Map()
179
179
  const projectsBase = hubProjectsBase()
180
180
  let projects
@@ -240,8 +240,11 @@ async function buildWorktreeParentMap() {
240
240
  * worktree (`<wt>__codex`) が「未起動 stub」としても同時に列挙され、サイドバーに live 行と
241
241
  * stub 行が二重表示される事故を防ぐ。
242
242
  */
243
- export async function listWorktreeStubs(liveSessionNames) {
244
- const idx = await buildWorktreeIndex()
243
+ export async function listWorktreeStubs(liveSessionNames, opts = {}) {
244
+ const idx =
245
+ opts.worktreeIndex instanceof Map
246
+ ? opts.worktreeIndex
247
+ : await buildWorktreeIndex()
245
248
  const liveBaseNames = new Set()
246
249
  for (const n of liveSessionNames) liveBaseNames.add(baseSessionName(n))
247
250
  const stubs = []
@@ -649,7 +652,16 @@ export async function listSessions(opts = {}) {
649
652
  // pane 解析 (state.detectSessionState) はフォーマット変動に脆いので、
650
653
  // statusLine cache がある cwd では優先採用する。
651
654
  const [wtParentMap, sessionUsages] = await Promise.all([
652
- buildWorktreeParentMap(),
655
+ opts.worktreeIndex instanceof Map
656
+ ? Promise.resolve(
657
+ new Map(
658
+ [...opts.worktreeIndex].map(([name, info]) => [
659
+ name,
660
+ info.parent_session_name,
661
+ ]),
662
+ ),
663
+ )
664
+ : buildWorktreeParentMap(),
653
665
  getSessionUsages().catch(() => []),
654
666
  ])
655
667
  // cwd → context_pct の lookup (同一 cwd で複数 session_id ある場合は最新 mtime 優先)