@cocorograph/hub-agent 0.7.104 → 0.8.0

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.7.104",
3
+ "version": "0.8.0",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -647,116 +647,3 @@ export async function listSessions({ cwd, projectsRoot, limit = 30, logger }) {
647
647
  * @param {{viewingSessionId?: string|null, newestSessionId?: string|null, lastNotifiedNewId?: string|null, lastNotifiedAt?: number|null, now?: number|null, reNotifyMs?: number}} args
648
648
  * @returns {{rotated: boolean, newSessionId?: string}}
649
649
  */
650
- /**
651
- * 回転候補 jsonl が「bound TUI セッションの本物の /clear 先」か「別アクティビティの jsonl」かを
652
- * head 4KB だけ読んで判定する純粋ヘルパ (2a 根治)。
653
- *
654
- * 背景: 同一 cwd-encoded dir には bound TUI 以外の jsonl が混入し得る:
655
- * - Task tool subagent (`parent_uuid` / `parentUuid` 付き)
656
- * - SDK headless `claude -p` (末尾近傍に `type=result`)
657
- * - 並走 cockpit / 別ターミナル起動の別 TUI (cwd は一致するため弱い signal)
658
- * - 既存 jsonl が touch されて mtime 最新化 (cwd が viewCwd と不一致なケースは cross-cwd 汚染)
659
- * これらに対して decideSessionRotation は newest≠viewing で無条件発火するため、ローテに乗ると
660
- * viewer が別 jsonl へ rotate-bind されて /clear 後の三点リーダー固着・誤生成中表示を起こす。
661
- *
662
- * 検出基準 (どれか 1 つでも該当したら reject = 抑止):
663
- * - `parent_uuid` / `parentUuid` / `parent_session_id` / `parentSessionId` (subagent marker)
664
- * - `type === 'result'` (SDK headless の完了 marker。先頭 4KB 内に現れたら頭 0KB の極短セッション)
665
- * - `cwd` フィールドが `viewCwd` と完全不一致 (cross-cwd 汚染)
666
- *
667
- * 失敗安全: head 読み失敗 / parse 失敗時は accept (旧挙動 = 即時 rotate へ degrade)。
668
- * 「/clear が壊れる」リスクは取らないため、不確実時は安全側 (accept) に倒す。
669
- *
670
- * @param {{candidatePath?: string|null, viewCwd?: string|null, readImpl?: (p:string,n:number)=>Promise<string|null>, logger?: import('pino').Logger}} args
671
- * @returns {Promise<{accept: boolean, reason: string}>}
672
- */
673
- export async function validateRotationCandidate({
674
- candidatePath,
675
- viewCwd,
676
- readImpl,
677
- logger,
678
- } = {}) {
679
- if (!candidatePath || !viewCwd) {
680
- return { accept: true, reason: "missing_args" }
681
- }
682
- let text
683
- try {
684
- if (typeof readImpl === "function") {
685
- text = await readImpl(candidatePath, 4096)
686
- } else {
687
- const handle = await open(candidatePath, "r")
688
- try {
689
- const buf = Buffer.allocUnsafe(4096)
690
- const { bytesRead } = await handle.read(buf, 0, buf.length, 0)
691
- text = buf.toString("utf-8", 0, bytesRead)
692
- } finally {
693
- await handle.close().catch(() => {})
694
- }
695
- }
696
- } catch (err) {
697
- logger?.warn(
698
- { err: err?.message, candidatePath },
699
- "rotation candidate head read failed → accept (degrade to legacy behavior)",
700
- )
701
- return { accept: true, reason: `read_failed:${err?.code || "unknown"}` }
702
- }
703
- if (!text) return { accept: true, reason: "empty_text" }
704
- for (const line of text.split("\n")) {
705
- if (!line) continue
706
- let obj
707
- try {
708
- obj = JSON.parse(line)
709
- } catch {
710
- continue
711
- }
712
- if (!obj || typeof obj !== "object") continue
713
- if (
714
- obj.parent_uuid ||
715
- obj.parentUuid ||
716
- obj.parent_session_id ||
717
- obj.parentSessionId
718
- ) {
719
- return { accept: false, reason: "subagent_marker" }
720
- }
721
- if (obj.type === "result") {
722
- return { accept: false, reason: "headless_result_marker" }
723
- }
724
- if (typeof obj.cwd === "string" && obj.cwd && obj.cwd !== viewCwd) {
725
- return { accept: false, reason: "cwd_mismatch" }
726
- }
727
- }
728
- return { accept: true, reason: "passed" }
729
- }
730
-
731
- export function decideSessionRotation({
732
- viewingSessionId,
733
- newestSessionId,
734
- lastNotifiedNewId,
735
- lastNotifiedAt,
736
- lastNotifiedCount = 0,
737
- now,
738
- reNotifyMs = 4000,
739
- maxReNotify = 5,
740
- } = {}) {
741
- if (!newestSessionId || !viewingSessionId) return { rotated: false }
742
- if (newestSessionId === viewingSessionId) return { rotated: false }
743
- if (newestSessionId === lastNotifiedNewId) {
744
- // 同一 new。時刻情報が揃っていて再通知間隔を超えていれば、固着救済のため再通知する。
745
- // ただし安全弁: 同一 (viewing→newest) ペアを maxReNotify 回再通知してもフロントが
746
- // 採用しない場合は打ち切る (capped)。フロントが viewing を更新しない=外来 id 汚染や
747
- // 別 cwd の rotated 誤採用の兆候で、これを無制限に再通知すると rotated 無限ピンポンに
748
- // なる (perf監査: cockpit↔cocomiru で rotated 244件/h を実測)。
749
- if (lastNotifiedCount >= maxReNotify) {
750
- return { rotated: false, capped: true }
751
- }
752
- if (
753
- typeof now === "number" &&
754
- typeof lastNotifiedAt === "number" &&
755
- now - lastNotifiedAt >= reNotifyMs
756
- ) {
757
- return { rotated: true, newSessionId: newestSessionId }
758
- }
759
- return { rotated: false }
760
- }
761
- return { rotated: true, newSessionId: newestSessionId }
762
- }
@@ -181,6 +181,49 @@ export async function getActiveConfigDir() {
181
181
  return (await getActiveProfile()).configDir
182
182
  }
183
183
 
184
+ /**
185
+ * profile_id から Codex ホーム (configDir) を解決する (self-describing read 用)。
186
+ *
187
+ * 【なぜ env でなくリクエストの id から引くのか — Read-plane 案C ①】
188
+ * `process.env.CODEX_HOME` はプロファイル切替 (applyActiveCodexEnv) で書き換わる揮発値。
189
+ * read (一覧・履歴) を並行に走らせると、切替の瞬間に in-flight の read が別アカウントの
190
+ * rollout dir を読む競合が起きる。read リクエスト自身が profile_id を搬送し、読む対象を
191
+ * リクエスト時点で固定することで根絶する。profiles.mjs の同名関数と対称。
192
+ *
193
+ * @param {string|null|undefined} profileId 未指定/空/未知 → active へフォールバック
194
+ * @returns {Promise<{configDir:string, profileId:string, resolved:boolean}>}
195
+ */
196
+ export async function resolveConfigDir(profileId) {
197
+ const reg = await loadRegistry()
198
+ if (profileId) {
199
+ const hit = reg.profiles.find((p) => p.id === profileId)
200
+ if (hit) {
201
+ return { configDir: hit.configDir, profileId: hit.id, resolved: true }
202
+ }
203
+ }
204
+ const active =
205
+ reg.profiles.find((p) => p.id === reg.activeId) || makeDefaultCodexProfile()
206
+ return { configDir: active.configDir, profileId: active.id, resolved: false }
207
+ }
208
+
209
+ /**
210
+ * profile_id から rollout の保存ルート (`<configDir>/sessions`) を解決する。
211
+ *
212
+ * ⚠️ `HUB_CODEX_SESSIONS_DIR` (テスト/特殊配置用の明示 override) は usage.mjs の
213
+ * codexSessionsDir() と同じ優先順位で最優先する。override が効かなくなると
214
+ * テストが実ホームを読んでしまうため。
215
+ *
216
+ * @param {string|null|undefined} profileId
217
+ * @returns {Promise<{sessionsDir:string, profileId:string, resolved:boolean}>}
218
+ */
219
+ export async function resolveSessionsDir(profileId) {
220
+ const r = await resolveConfigDir(profileId)
221
+ const sessionsDir = process.env.HUB_CODEX_SESSIONS_DIR
222
+ ? process.env.HUB_CODEX_SESSIONS_DIR
223
+ : path.join(r.configDir, "sessions")
224
+ return { sessionsDir, profileId: r.profileId, resolved: r.resolved }
225
+ }
226
+
184
227
  /**
185
228
  * active プロファイルを切り替える。存在しない id はエラー。
186
229
  * env (CODEX_HOME) の反映と全 Codex セッション再起動は呼び出し側 (main.mjs) の責務。
@@ -44,7 +44,7 @@ import { EventEmitter } from "node:events"
44
44
 
45
45
  import { CodexAppServerClient } from "./codex-appserver-client.mjs"
46
46
  import { getOrCreateSiblingToken } from "./sibling-token.mjs"
47
- import { codexRolloutPresence } from "./usage.mjs"
47
+ import { codexRolloutPresence, listCodexThreadsFromDisk } from "./usage.mjs"
48
48
 
49
49
  /**
50
50
  * sibling-inject (2026-07-04) 用: spawn する codex app-server プロセス自身の
@@ -306,6 +306,7 @@ export class CodexStreamBridge extends EventEmitter {
306
306
  deadTerminalTtlMs,
307
307
  turnStartTimeoutMs,
308
308
  rolloutPresence,
309
+ listThreadsFromDisk,
309
310
  historyMaxItems,
310
311
  historyMaxBytes,
311
312
  } = {}) {
@@ -326,6 +327,9 @@ export class CodexStreamBridge extends EventEmitter {
326
327
  this._turnStartTimeoutMs = turnStartTimeoutMs ?? TURN_START_TIMEOUT_MS
327
328
  // #4 失敗分類の rollout 存在チェック (tri-state。テストから注入可能)。
328
329
  this._rolloutPresence = rolloutPresence || codexRolloutPresence
330
+ // Phase 3 (Read-plane スライス2a): 一覧を rollout の disk 走査で作る (app-server 非 spawn)。
331
+ // テストから stub を注入できるよう injectable。
332
+ this._listThreadsFromDisk = listThreadsFromDisk || listCodexThreadsFromDisk
329
333
  // T06499: 履歴 hydrate の上限 (テストから小さい値を注入可能)。
330
334
  this._historyLimits = {
331
335
  maxItems: historyMaxItems ?? HISTORY_MAX_ITEMS,
@@ -334,6 +338,10 @@ export class CodexStreamBridge extends EventEmitter {
334
338
  /** @type {Map<string, object>} stream_id -> session record */
335
339
  this.sessions = new Map()
336
340
  this._accountRateLimitsByProfile = new Map()
341
+ // Phase 3 (Read-plane): listThreads の single-flight。key(`cwd::limit`) -> in-flight Promise。
342
+ // codex.sessions.request を直列キューから外したため、同一 cwd への同時リクエストが
343
+ // app-server の同時 spawn (storm) を起こさないよう 1 本に畳む。
344
+ this._listThreadsInFlight = new Map()
337
345
  }
338
346
 
339
347
  /**
@@ -753,45 +761,42 @@ export class CodexStreamBridge extends EventEmitter {
753
761
  * cwd 配下の Codex thread 一覧を返す (Cockpit Codex 両対応 セッションピッカー/最新
754
762
  * セッション自動選択用。claude 側 listSessions と対称)。
755
763
  *
756
- * `thread/list` は特定のスレッドに紐付かない app-server のグローバル RPC (公式スキーマ
757
- * `ThreadListParams`/`ThreadListResponse` で確認済み: cwd フィルタ・sortKey='recency_at'・
758
- * sortDirection='desc' が使える) なので、attach() のような常駐セッションは作らず、
759
- * 呼び出しごとに使い捨てクライアントで initialize → thread/list → stop する
760
- * (list のためだけに長寿命プロセスを維持しない = リーク防止)。
764
+ * Phase 3 (Read-plane スライス2a, 2026-07-24): **app-server を spawn せず rollout の disk 走査**で
765
+ * 一覧を作る (`listCodexThreadsFromDisk`)。旧実装は使い捨て app-server で `thread/list` を
766
+ * 叩いていたが、完了基準「Codex 一覧で app-server を起動しない」に反し、Read-plane で off-queue
767
+ * 化しても spawn コスト自体が残っていた。一覧に要る 4 フィールド (thread_id/cwd/preview/
768
+ * updated_at_ms) は rollout の header + mtime から安価に取れる。返り値 shape は従来と同一。
769
+ *
770
+ * single-flight (下記 in-flight 畳み込み) は spawn storm 防止用だったが、disk 走査化後は
771
+ * 「同一 (cwd,limit) の同時走査の冗長 I/O 抑止」として維持する (無害・低コスト)。
761
772
  *
762
773
  * @returns {Promise<Array<{thread_id: string, cwd: string, preview: string, updated_at_ms: number}>>}
763
774
  */
764
- async listThreads({ cwd, limit } = {}) {
765
- const client = this._clientFactory({ cwd, codexCmd: this.codexCmd, logger: this.logger })
766
- client.start()
767
- try {
768
- await client.request("initialize", { clientInfo: CLIENT_INFO })
769
- const result = await client.request("thread/list", {
770
- cwd: cwd || undefined,
771
- limit: limit || undefined,
772
- // ⚠️ updated_at を使う (recency_at ではない)。recency_at は「読み込み(=スレッドを
773
- // 開くだけ)も含めた使用感」の並び順なので、古いスレッドを開いただけでサイドバー最上位
774
- // に浮上してしまう (症状⑨=実害)。サイドバーは「会話への書き込み(=ターン境界)」で
775
- // 並べたいので、最終書き込み時刻の updated_at を採用する。
776
- // (Claude 側 listSessions も jsonl 内の最終メッセージ timestamp = ターン境界に揃えた)
777
- sortKey: "updated_at",
778
- sortDirection: "desc",
779
- })
780
- const threads = Array.isArray(result?.data) ? result.data : []
781
- return threads.map((t) => ({
782
- thread_id: t.id,
783
- cwd: t.cwd || "",
784
- preview: typeof t.preview === "string" ? t.preview.slice(0, 80) : "",
785
- // updatedAt 優先 (無ければ recencyAt fallback) を ms へ変換。claude 側
786
- // ClaudeSessionSummary.mtime (ms epoch) と同じ単位に揃え、frontend が同じ並び替え
787
- // ロジックを使い回せるようにする。並び順の source of truth は上の sortKey なので、
788
- // ここは表示用の mtime も updatedAt を優先させて sortKey と一貫させる。
789
- updated_at_ms:
790
- (typeof t.updatedAt === "number" ? t.updatedAt : t.recencyAt || 0) * 1000,
791
- }))
792
- } finally {
793
- client.stop()
794
- }
775
+ async listThreads({ cwd, limit, sessionsDir } = {}) {
776
+ // Phase 3 (Read-plane): single-flight。同一 (読み取り先, cwd, limit) への同時リクエストを
777
+ // 1 本の in-flight Promise に畳む。
778
+ //
779
+ // key の第 1 要素は **実際に読む sessionsDir** にする (Read-plane 案C ①)。
780
+ // 以前は `process.env.CODEX_HOME` を使っていたが、env は切替で書き換わる揮発値なので
781
+ // 「この in-flight がどのアカウントの結果か」を表せず、切替中に別プロファイルの結果を
782
+ // 取り違えうる。解決済みの読み取り先そのものを key にすれば同一性が構造的に保証される。
783
+ // sessionsDir 未指定 (旧経路) のときだけ env 由来の値へフォールバックする。
784
+ const readRoot = sessionsDir || process.env.CODEX_HOME || ""
785
+ const key = `${readRoot}::${cwd || ""}::${limit ?? ""}`
786
+ const existing = this._listThreadsInFlight.get(key)
787
+ if (existing) return existing
788
+ const p = this._doListThreads({ cwd, limit, sessionsDir }).finally(() => {
789
+ this._listThreadsInFlight.delete(key)
790
+ })
791
+ this._listThreadsInFlight.set(key, p)
792
+ return p
793
+ }
794
+
795
+ async _doListThreads({ cwd, limit, sessionsDir } = {}) {
796
+ // Phase 3 (Read-plane スライス2a): app-server を spawn せず rollout の disk 走査で一覧を作る。
797
+ // updated_at_ms は rollout ファイルの mtime (= 最終書き込み時刻 = ターン境界に相当) で、
798
+ // claude 側 ClaudeSessionSummary.mtime と単位・意味を揃える。並びは mtime desc。
799
+ return this._listThreadsFromDisk({ cwd, limit, sessionsDir, logger: this.logger })
795
800
  }
796
801
 
797
802
  /**