@cocorograph/hub-agent 0.7.103 → 0.7.105

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.103",
3
+ "version": "0.7.105",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -23,6 +23,46 @@ import {
23
23
  maskMessageObject,
24
24
  redactSecrets,
25
25
  } from "./tool-result-mask.mjs"
26
+ import { lookupInjectSource } from "./inject-source-store.mjs"
27
+
28
+ /** user 履歴イベントの本文を取り出す (string content / text ブロック配列の両対応)。
29
+ * 連携カード source の永続タグ付けで、注入本文との一致判定に使う。 */
30
+ function historyUserText(event) {
31
+ const content = event?.message?.content
32
+ if (typeof content === "string") return content
33
+ if (Array.isArray(content)) {
34
+ return content
35
+ .filter((b) => b && b.type === "text" && typeof b.text === "string")
36
+ .map((b) => b.text)
37
+ .join("\n")
38
+ }
39
+ return ""
40
+ }
41
+
42
+ /**
43
+ * 連携カード種別 (Phase 2 永続化): hydrate した履歴イベントのうち、この cwd で注入と本文一致する
44
+ * user メッセージへ source を付け直す (reattach / cold resume / 別端末でもカードを維持する)。
45
+ * hydrateCache の共有オブジェクトを壊さないよう、タグを付けるイベントだけ **shallow-clone** して
46
+ * 差し替える (source が無いイベントは元のまま = 参照維持でコスト無し)。
47
+ * @param {object[]} events
48
+ * @param {string} cwd
49
+ * @returns {Promise<object[]>}
50
+ */
51
+ export async function tagHistoryInjectSources(events, cwd) {
52
+ if (!cwd || !Array.isArray(events) || events.length === 0) return events
53
+ let out = null // 変更が発生したときだけ配列を複製する (通常セッションはコスト 0)
54
+ for (let i = 0; i < events.length; i++) {
55
+ const ev = events[i]
56
+ if (!ev || ev.type !== "user" || ev.source) continue
57
+ const text = historyUserText(ev)
58
+ if (!text) continue
59
+ const source = await lookupInjectSource(cwd, text)
60
+ if (!source) continue
61
+ if (!out) out = events.slice()
62
+ out[i] = { ...ev, source }
63
+ }
64
+ return out || events
65
+ }
26
66
 
27
67
  export const MAX_HISTORY_LINES = 500
28
68
 
@@ -282,7 +322,10 @@ export async function fetchSessionHistory({ cwd, session_id, maxLines, projectsR
282
322
  const filePath = jsonlPath({ cwd, session_id, projectsRoot })
283
323
  try {
284
324
  const result = await readSessionHistory(filePath, { maxLines, logger })
285
- return { ...result, file_path: filePath }
325
+ // 連携カード種別 (Phase 2 永続化): 注入 user メッセージへ source を付け直す (hydrateCache は
326
+ // filePath キーで cwd を持たないため、cwd を持つここでタグ付けする。クローンで cache 非破壊)。
327
+ const events = await tagHistoryInjectSources(result.events, cwd)
328
+ return { ...result, events, file_path: filePath }
286
329
  } catch (err) {
287
330
  return {
288
331
  events: [],
@@ -32,6 +32,7 @@ import {
32
32
  maskJsonlFileAtRest,
33
33
  } from "./tool-result-mask.mjs"
34
34
  import { getOrCreateSiblingToken } from "./sibling-token.mjs"
35
+ import { recordInjectSourcePersistent } from "./inject-source-store.mjs"
35
36
 
36
37
  /**
37
38
  * sibling-inject (2026-07-04) 用: spawn する claude プロセス自身の環境変数に、
@@ -2715,12 +2716,18 @@ export class ClaudeStreamBridge extends EventEmitter {
2715
2716
  .sendMessage({ role: "user", content: text })
2716
2717
  .then((r) => {
2717
2718
  // Phase 2 (major#3): sendMessage は throw せず {accepted:false} で失敗を返す経路がある
2718
- // (queue_dead / empty_message / pre-canceled)。この場合も echo/started は来ないので
2719
- // stale source entry を掃除する (誤タグ防止)。
2720
- if (r && r.accepted === false) session._removeInjectSource?.(injectHandle)
2719
+ // (queue_dead / empty_message / pre-canceled)。この場合は echo/started は来ないので
2720
+ // stale な live source entry を掃除し、**永続化もしない** (persist round1 major#1: 失敗を
2721
+ // 永続化すると 30 日 stale が残り後続の同文 human を誤カード化する)。
2722
+ if (r && r.accepted === false) {
2723
+ session._removeInjectSource?.(injectHandle)
2724
+ return
2725
+ }
2726
+ // 受理された (queued/started) ときだけ hydrate 用に永続化する (best-effort、live と独立)。
2727
+ recordInjectSourcePersistent(cwd, text, source).catch(() => {})
2721
2728
  })
2722
2729
  .catch((err) => {
2723
- // 配送失敗 (throw) でも echo は来ないので stale な source entry を掃除する。
2730
+ // 配送失敗 (throw) でも echo は来ないので stale な live entry を掃除する (永続化はしない)。
2724
2731
  session._removeInjectSource?.(injectHandle)
2725
2732
  this.logger?.error(
2726
2733
  { cwd, err: err?.message },
@@ -2775,9 +2782,14 @@ export class ClaudeStreamBridge extends EventEmitter {
2775
2782
  session
2776
2783
  .sendMessage({ role: "user", content: text })
2777
2784
  .then((r) => {
2778
- // Phase 2 (major#3): {accepted:false} (queue_dead / empty_message / pre-canceled) も
2779
- // echo/started は来ないので stale source entry を掃除する (誤タグ防止)。
2780
- if (r && r.accepted === false) session._removeInjectSource?.(injectHandle)
2785
+ // Phase 2 (major#3 / persist major#1): {accepted:false} は echo/started 来ないので live を
2786
+ // 掃除し永続化もしない (失敗を永続化すると 30 日 stale で後続同文 human を誤カード化)。
2787
+ if (r && r.accepted === false) {
2788
+ session._removeInjectSource?.(injectHandle)
2789
+ return
2790
+ }
2791
+ // 受理時のみ hydrate 用に永続化 (best-effort)。
2792
+ recordInjectSourcePersistent(cwd, text, source).catch(() => {})
2781
2793
  })
2782
2794
  .catch((err) => {
2783
2795
  session._removeInjectSource?.(injectHandle)
@@ -334,6 +334,10 @@ export class CodexStreamBridge extends EventEmitter {
334
334
  /** @type {Map<string, object>} stream_id -> session record */
335
335
  this.sessions = new Map()
336
336
  this._accountRateLimitsByProfile = new Map()
337
+ // Phase 3 (Read-plane): listThreads の single-flight。key(`cwd::limit`) -> in-flight Promise。
338
+ // codex.sessions.request を直列キューから外したため、同一 cwd への同時リクエストが
339
+ // app-server の同時 spawn (storm) を起こさないよう 1 本に畳む。
340
+ this._listThreadsInFlight = new Map()
337
341
  }
338
342
 
339
343
  /**
@@ -762,6 +766,19 @@ export class CodexStreamBridge extends EventEmitter {
762
766
  * @returns {Promise<Array<{thread_id: string, cwd: string, preview: string, updated_at_ms: number}>>}
763
767
  */
764
768
  async listThreads({ cwd, limit } = {}) {
769
+ // Phase 3 (Read-plane): single-flight。同一 (cwd, limit) への同時リクエストを 1 本の
770
+ // in-flight Promise に畳み、app-server の同時 spawn (storm) を防ぐ。異なる cwd は個別に走る。
771
+ const key = `${cwd || ""}::${limit ?? ""}`
772
+ const existing = this._listThreadsInFlight.get(key)
773
+ if (existing) return existing
774
+ const p = this._doListThreads({ cwd, limit }).finally(() => {
775
+ this._listThreadsInFlight.delete(key)
776
+ })
777
+ this._listThreadsInFlight.set(key, p)
778
+ return p
779
+ }
780
+
781
+ async _doListThreads({ cwd, limit } = {}) {
765
782
  const client = this._clientFactory({ cwd, codexCmd: this.codexCmd, logger: this.logger })
766
783
  client.start()
767
784
  try {
@@ -0,0 +1,206 @@
1
+ /**
2
+ * 連携カード種別 (Phase 2) の **永続ストア** (2026-07-24)。
3
+ *
4
+ * 背景 (WHY): claude の連携カードは注入時の live イベント (queue_state.startedSources) にだけ
5
+ * source が乗り、会話履歴 (jsonl) には書かれない。そのため reattach / cold resume / 別端末
6
+ * (スマホ) で開くと履歴 hydrate 由来の user バブルには source が無く、カードが青バブルへ戻る。
7
+ * 「履歴でも連携カードのまま残したい」(ユーザー要望 2026-07-24) を満たすため、注入本文と source を
8
+ * cwd 単位でサイドカー (`~/.hub/inject-sources.json`) に永続化し、hydrate 時に本文一致で付け直す。
9
+ *
10
+ * ⚠️ 設計 (cross-review 指摘への対応):
11
+ * - **隔離**: cwd 単位でリストを分ける (別 workspace の注入と混ざらない)。
12
+ * - **相関**: 注入本文の完全一致 (footer 込みで一意性が高い)。ID メタ経路が無い claude の制約下で
13
+ * live 経路 (queue_state) と同じ相関方式に揃える。衝突時も誤タグ止まりでクラッシュしない。
14
+ * - **失敗は永続化しない** (persist round1 major#1): 呼び出し側 (claude-stream-bridge) が
15
+ * sendMessage 受理時 (accepted!==false) のみ record を呼ぶ。reject/accepted:false では呼ばない
16
+ * ため、30 日 stale が残って後続同文 human を誤カード化する事故が起きない。
17
+ * - **TTL / 上限** (persist round1 major#2): MAX_AGE_MS 経過 entry は **record 時だけでなく load 時と
18
+ * lookup 時にも** prune する (cold resume / 非活性 cwd の期限切れを永久 lookup しない)。
19
+ * per-cwd 上限 (MAX_PER_CWD) + cwd 総数上限 (MAX_CWDS、最終活性が古い cwd を退避) で無限成長を防ぐ。
20
+ * - **並行/権限** (persist round1 minor): tmp 名は pid + 連番でユニーク化 (複数 process 競合回避)、
21
+ * ファイル 0600 / ディレクトリ 0700 で本文 (平文) の権限を絞る (jsonl と同等の秘匿レベル)。
22
+ * - **書き込み**: 1s デバウンス + atomic write (tmp+rename)。単一 hub-agent プロセス内は event loop で
23
+ * 直列化。デバウンス中のプロセス死で最後の数件がロストしても live 表示は無傷 (hydrate だけ劣化)。
24
+ */
25
+
26
+ import { readFile, writeFile, rename, mkdir } from "node:fs/promises"
27
+ import path from "node:path"
28
+ import os from "node:os"
29
+
30
+ const DEFAULT_PATH = path.join(os.homedir() || "/tmp", ".hub", "inject-sources.json")
31
+ export const STORE_PATH = process.env.HUB_INJECT_SOURCE_STORE || DEFAULT_PATH
32
+ const MAX_PER_CWD = 500
33
+ const MAX_CWDS = 200
34
+ const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000 // 30 日
35
+ const WRITE_DEBOUNCE_MS = 1000
36
+
37
+ /** @type {Map<string, Array<{text: string, source: 'peer'|'orchestrator', at: number}>> | null} */
38
+ let _cache = null
39
+ let _writeScheduled = false
40
+ let _dirty = false
41
+ let _writeChain = Promise.resolve()
42
+ let _loadPromise = null
43
+ let _tmpCounter = 0
44
+
45
+ // round2 minor: at が数値でない entry は「期限切れ」扱いにして prune する (欠落を永久 fresh 扱い
46
+ // にするとリークする)。正常な record は必ず at を付けるので、これは破損/外部書き込み対策。
47
+ function isFresh(entry, nowMs) {
48
+ return typeof entry?.at === "number" && nowMs - entry.at < MAX_AGE_MS
49
+ }
50
+
51
+ /** cwd リストを TTL + 件数上限で prune する (新しい順を保つため末尾側を残す)。 */
52
+ function pruneList(list, nowMs) {
53
+ let out = list.filter((e) => isFresh(e, nowMs))
54
+ if (out.length > MAX_PER_CWD) out = out.slice(out.length - MAX_PER_CWD)
55
+ return out
56
+ }
57
+
58
+ /** cwd 総数上限: 空リスト cwd を落とし、超過分は「最終 entry が最も古い cwd」から退避する。 */
59
+ function enforceCwdCap(cache) {
60
+ for (const [cwd, list] of cache) if (!list.length) cache.delete(cwd)
61
+ if (cache.size <= MAX_CWDS) return
62
+ const byRecency = [...cache.entries()].sort((a, b) => {
63
+ const at = (l) => (l.length ? l[l.length - 1].at || 0 : 0)
64
+ return at(a[1]) - at(b[1])
65
+ })
66
+ const drop = cache.size - MAX_CWDS
67
+ for (let i = 0; i < drop; i++) cache.delete(byRecency[i][0])
68
+ }
69
+
70
+ /** 全 cwd を prune する (load / 定期整理用)。変化があれば true。 */
71
+ function pruneAll(cache, nowMs) {
72
+ let changed = false
73
+ for (const [cwd, list] of cache) {
74
+ const pruned = pruneList(list, nowMs)
75
+ if (pruned.length !== list.length) changed = true
76
+ cache.set(cwd, pruned)
77
+ }
78
+ const before = cache.size
79
+ enforceCwdCap(cache)
80
+ if (cache.size !== before) changed = true
81
+ return changed
82
+ }
83
+
84
+ /** サイドカーを 1 度だけ読み込み in-memory 化する (load 時に prune する)。 */
85
+ async function ensureLoaded() {
86
+ if (_cache) return
87
+ if (_loadPromise) return _loadPromise
88
+ _loadPromise = (async () => {
89
+ const cache = new Map()
90
+ try {
91
+ const raw = await readFile(STORE_PATH, "utf-8")
92
+ const obj = JSON.parse(raw)
93
+ if (obj && typeof obj === "object" && !Array.isArray(obj)) {
94
+ for (const [cwd, list] of Object.entries(obj)) {
95
+ if (!Array.isArray(list)) continue
96
+ const clean = list.filter(
97
+ (e) =>
98
+ e &&
99
+ typeof e.text === "string" &&
100
+ (e.source === "peer" || e.source === "orchestrator"),
101
+ )
102
+ if (clean.length) cache.set(cwd, clean)
103
+ }
104
+ }
105
+ } catch {
106
+ /* 初回はファイル無し = 空 */
107
+ }
108
+ // major#2: load 時に期限切れを prune する (cold resume / 非活性 cwd を永久 lookup しない)。
109
+ if (pruneAll(cache, Date.now())) {
110
+ _cache = cache
111
+ scheduleWrite()
112
+ } else {
113
+ _cache = cache
114
+ }
115
+ })()
116
+ await _loadPromise
117
+ _loadPromise = null
118
+ }
119
+
120
+ function scheduleWrite() {
121
+ _dirty = true
122
+ if (_writeScheduled) return
123
+ _writeScheduled = true
124
+ const t = setTimeout(() => {
125
+ _writeScheduled = false
126
+ // round2 minor (write race): 書き込みを **promise チェーンで直列化**する。デバウンス満了時に
127
+ // _dirty を見て 1 回だけ実行し、実行時点の _cache を snapshot する。これで「遅い I/O の最中に
128
+ // 後続 timer が発火し、古い snapshot の rename が新しい snapshot を後着上書きする」逆転を防ぐ
129
+ // (rename が重ならない = 直列)。実行中に更に record されたら _dirty が立ち、チェーン末尾で再実行。
130
+ _writeChain = _writeChain.then(async () => {
131
+ if (!_dirty) return
132
+ _dirty = false
133
+ try {
134
+ const obj = {}
135
+ if (_cache) for (const [cwd, list] of _cache) if (list.length) obj[cwd] = list
136
+ await mkdir(path.dirname(STORE_PATH), { recursive: true, mode: 0o700 })
137
+ // minor: tmp 名を pid + 連番でユニーク化 (複数 process 同時書き込みの取り違え防止)。
138
+ const tmp = `${STORE_PATH}.${process.pid}.${_tmpCounter++}.tmp`
139
+ // minor: 本文 (平文) を含むため 0600 で書く (jsonl と同等の秘匿レベル)。
140
+ await writeFile(tmp, JSON.stringify(obj), { mode: 0o600 })
141
+ await rename(tmp, STORE_PATH)
142
+ } catch {
143
+ /* best effort: 永続化失敗は live カードには影響しない */
144
+ }
145
+ // 実行中に record されて再度 dirty になっていたら、次の debounce を張って追従する。
146
+ if (_dirty && !_writeScheduled) scheduleWrite()
147
+ })
148
+ }, WRITE_DEBOUNCE_MS)
149
+ t.unref?.()
150
+ }
151
+
152
+ /** 注入が **受理された後** に呼ぶ: この cwd の注入本文と source を永続化する (hydrate で付け直す)。
153
+ * ⚠️ 呼び出し側は sendMessage 受理時のみ呼ぶこと (失敗を永続化しないため。round1 major#1)。
154
+ * @param {string} cwd
155
+ * @param {string} text 注入本文 (jsonl の user メッセージ本文と一致する)
156
+ * @param {'peer'|'orchestrator'|undefined} source
157
+ * @param {number} [nowMs] */
158
+ export async function recordInjectSourcePersistent(cwd, text, source, nowMs = Date.now()) {
159
+ if (!cwd || !text || (source !== "peer" && source !== "orchestrator")) return
160
+ await ensureLoaded()
161
+ const list = _cache.get(cwd) || []
162
+ list.push({ text, source, at: nowMs })
163
+ _cache.set(cwd, pruneList(list, nowMs))
164
+ enforceCwdCap(_cache)
165
+ scheduleWrite()
166
+ }
167
+
168
+ /** hydrate 時に呼ぶ: この cwd で本文が (期限内の) 注入と一致すれば source を返す (最新一致優先)。
169
+ * 期限切れ entry はここでも lazy prune する (major#2)。
170
+ * @param {string} cwd
171
+ * @param {string} text
172
+ * @returns {Promise<'peer'|'orchestrator'|null>} */
173
+ export async function lookupInjectSource(cwd, text) {
174
+ if (!cwd || !text) return null
175
+ await ensureLoaded()
176
+ const list = _cache.get(cwd)
177
+ if (!list || !list.length) return null
178
+ const nowMs = Date.now()
179
+ let hit = null
180
+ let hasStale = false
181
+ for (let i = list.length - 1; i >= 0; i--) {
182
+ const e = list[i]
183
+ if (!isFresh(e, nowMs)) {
184
+ hasStale = true
185
+ continue // 期限切れは一致とみなさない
186
+ }
187
+ if (hit === null && e.text === text) hit = e.source
188
+ }
189
+ if (hasStale) {
190
+ // 期限切れを取り除いて次回以降の lookup/成長を抑える。
191
+ const pruned = list.filter((e) => isFresh(e, nowMs))
192
+ if (pruned.length) _cache.set(cwd, pruned)
193
+ else _cache.delete(cwd)
194
+ scheduleWrite()
195
+ }
196
+ return hit
197
+ }
198
+
199
+ /** テスト用: in-memory キャッシュを破棄して次回 ensureLoaded を強制する。 */
200
+ export function _resetForTest() {
201
+ _cache = null
202
+ _loadPromise = null
203
+ _writeScheduled = false
204
+ _dirty = false
205
+ _writeChain = Promise.resolve()
206
+ }
package/src/main.mjs CHANGED
@@ -342,6 +342,33 @@ export function isFastPathMessage(type) {
342
342
  )
343
343
  }
344
344
 
345
+ /**
346
+ * Phase 3 (Read-plane 2026-07-24): タイムライン/セッション一覧の「読み取り専用 RPC」を判定する。
347
+ * これらは disk (jsonl) / codex rollout を読んで自前の response を送るだけで pane・入力・bridge の
348
+ * 状態を一切変更しない (ハンドラ本体で確認済み: claude.history.request=fetchSessionHistory /
349
+ * claude.sessions.request=listSessions / codex.sessions.request=listThreads、いずれも純粋読み取り)。
350
+ * メッセージ自身が cwd/session_id/stream_id を持ち、frontend も stream_id/cwd 照合で stale 応答を
351
+ * 捨てるため、WS 受信順に対する順序依存が無い。
352
+ *
353
+ * これを直列 dispatchChain から外す理由 (view/spawn 分離の核): 直列キューは「WS 受信順 = pane 反映順」
354
+ * を守るためのものだが、その代償として前方の重い処理 (codex.sessions.request/codex.attach の
355
+ * app-server spawn = 数秒、claude.sessions.request の全ファイル読み) が後続の claude.attach を
356
+ * head-of-line blocking し、attach→ready を数十秒遅延させていた (本番計測 cold p95 40〜54s)。
357
+ * claude/codex が同一 dispatchChain を共有するため codex の spawn が claude の切替まで巻き込む。
358
+ * 読み取り RPC を並行レーンへ逃がすことで、タイムライン閲覧が live 接続 (spawn) の完了を待たなくなる。
359
+ *
360
+ * ⚠️ codex.sessions.request は listThreads が app-server を spawn するため、off-queue 化の副作用で
361
+ * 同一 cwd への同時リクエストが spawn storm を起こしうる。CodexStreamBridge.listThreads 側に
362
+ * single-flight ガードを併設して同時 spawn を 1 本に畳む (B で rollout disk 走査へ置換すれば spawn 自体が消える)。
363
+ */
364
+ export function isReadOnlyRpc(type) {
365
+ return (
366
+ type === "claude.history.request" ||
367
+ type === "claude.sessions.request" ||
368
+ type === "codex.sessions.request"
369
+ )
370
+ }
371
+
345
372
  /**
346
373
  * active プロファイルの configDir を process.env.CLAUDE_CONFIG_DIR へ反映する。
347
374
  * 既定 (`~/.claude`) のときは変数を消して従来挙動 (Claude Code の既定パス) に戻す。
@@ -1122,6 +1149,20 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1122
1149
  })
1123
1150
  return
1124
1151
  }
1152
+ // Phase 3 (Read-plane): 読み取り専用 RPC (history/sessions) も直列キューから外して並行処理する。
1153
+ // 直列キュー前方の重い処理 (codex app-server spawn 等) が claude.attach→ready を HoL blocking
1154
+ // するのを防ぐ (view/spawn 分離)。純粋読み取りで順序依存が無いことは isReadOnlyRpc の説明参照。
1155
+ if (isReadOnlyRpc(msg?.type)) {
1156
+ Promise.resolve(
1157
+ dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, codexBridge, codexMcpControl, uploadManager }),
1158
+ ).catch((err) => {
1159
+ logger.error(
1160
+ { err: err.message, type: msg?.type },
1161
+ "dispatch threw (bypassed read-only RPC lane)",
1162
+ )
1163
+ })
1164
+ return
1165
+ }
1125
1166
  dispatchChain = dispatchChain
1126
1167
  .then(() => dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, codexBridge, codexMcpControl, uploadManager }))
1127
1168
  .catch((err) => {