@cocorograph/hub-agent 0.8.101 → 0.8.103

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.101",
3
+ "version": "0.8.103",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -128,6 +128,15 @@ export async function buildChatSessionsSnapshot({
128
128
  getChatSignal = defaultGetChatSignal,
129
129
  }) {
130
130
  const descriptors = describeLiveSessionsUnion(bridges)
131
+ // 行名で畳む前に、完全一致の cwd・CLI ごとに会話を数える。
132
+ // ID 未確定の記述子は同一会話と判断できないため、それぞれ 1 本とする。
133
+ const conversationsByCwd = new Map()
134
+ for (const descriptor of descriptors) {
135
+ if (!conversationsByCwd.has(descriptor.cwd)) conversationsByCwd.set(descriptor.cwd, new Map())
136
+ const byKind = conversationsByCwd.get(descriptor.cwd)
137
+ if (!byKind.has(descriptor.kind)) byKind.set(descriptor.kind, new Set())
138
+ byKind.get(descriptor.kind).add(conversationIdOf(descriptor) ?? Symbol())
139
+ }
131
140
  const { targets, errors } = await listChatTargets({ logger })
132
141
  const targetByCwd = chatTargetsByCwd(targets)
133
142
  // 畳み込みでは同じ (cwd, kind) の信号を何度も引く (所有者判定 ×2 + 付与)。readRowChatState は
@@ -148,7 +157,10 @@ export async function buildChatSessionsSnapshot({
148
157
  return hit.value
149
158
  }
150
159
  const named = descriptors.map((descriptor) => {
151
- const item = { ...descriptor }
160
+ const item = {
161
+ ...descriptor,
162
+ live_count: conversationsByCwd.get(descriptor.cwd).get(descriptor.kind).size,
163
+ }
152
164
  const cwd = typeof item.cwd === "string" ? item.cwd : ""
153
165
  return { item, cwd, sessionName: chatRowSessionName(cwd, item.kind, targetByCwd) }
154
166
  })
@@ -195,7 +207,7 @@ export async function buildChatSessionsSnapshot({
195
207
  // 区別できず、2026-09-17 に実際にログだけでは判別できずに詰まった。attached は
196
208
  // 「bridge から見て今ブラウザが繋がっているか」で、旧インスタンスの残骸は false に
197
209
  // なりやすい (ブラウザ側の実際の生存を保証する値ではない)。
198
- logger?.warn?.(
210
+ logger?.[winner.cwd === loser.cwd ? "debug" : "warn"]?.(
199
211
  {
200
212
  session_name: sessionName,
201
213
  kept_cwd: replace ? cwd : kept.cwd,
@@ -84,7 +84,7 @@ export const DISPLAY_TYPES = new Set(["user", "assistant", "system", "result"])
84
84
  * 数秒〜数分内に再 hydrate されるケースが大半 (行ったり来たりのセッション切替、複数タブ)。
85
85
  * readFile → split → 全行 JSON.parse (+ MASK_ENABLED 時は tool_use 走査) は jsonl が
86
86
  * 大きいほど無視できないコストになるため、`mtimeMs + size` が前回と一致する間は
87
- * パース結果をそのまま再利用し、ディスク読み直し・再パースを丸ごとスキップする。
87
+ * 親のパース結果を再利用する。子は毎回読み直して追記・新規出現を反映する。
88
88
  * ファイルが変わっていれば (ライブ会話が進行 / 別ターミナルから追記等) 通常どおり再読み込み
89
89
  * して上書きするので、常に最新のファイル内容を正しく反映する (stale 表示のリスクは無い)。
90
90
  *
@@ -421,6 +421,7 @@ export function normalizeHistoryEvent(obj) {
421
421
  if (obj.message !== undefined) event.message = obj.message
422
422
  if (obj.subtype !== undefined) event.subtype = obj.subtype
423
423
  if (obj.uuid !== undefined) event.uuid = obj.uuid
424
+ if (obj.parent_tool_use_id !== undefined) event.parent_tool_use_id = obj.parent_tool_use_id
424
425
  // jsonl 各行の ISO8601 タイムスタンプ。Browser 側でバブル上の時刻表示・日付区切りに
425
426
  // 使う。履歴 hydrate と live watch の両経路がこの関数を通るため、ここ 1 箇所で拾えば
426
427
  // 両方に時刻が乗る (純粋なライブ SDK stream には timestamp が無く、Browser が受信時刻で
@@ -443,7 +444,7 @@ const TASK_NOTIFICATION_MARKER = "<task-notification>"
443
444
  const TASK_NOTIFICATION_CLOSE = "</task-notification>"
444
445
  // ヘッダ領域の終わり。ここから先は自由文 (報告全文) なので、鍵と状態の抽出対象にしない。
445
446
  const TASK_NOTIFICATION_BODY_TAGS = ["<result>", "<summary>", "<note>"]
446
- // マスクを素通りする経路なので、長さでも歯止めを掛ける (実データの tool_use_id は 30〜40 字)。
447
+ // 合成する鍵と状態は長さでも制限する (実データの tool_use_id は 30〜40 字)。
447
448
  const MAX_TOOL_USE_ID_LEN = 200
448
449
  const MAX_STATUS_LEN = 64
449
450
 
@@ -498,12 +499,10 @@ function firstTagValue(text, tag) {
498
499
  * **CLI 自身が書いた終端宣言**なので、時間で推測せずに終端を復元できる。
499
500
  *
500
501
  * ## ⚠️ 載せてよいのは「鍵」と「状態」と「時刻」だけ
501
- * `<result>` にはサブエージェントの報告全文が入る (実データで確認済み)。しかしマスク処理
502
- * `maskMessageObject` (src/tool-result-mask.mjs:165-168) は `const message = obj.message;
503
- * if (!message …) return obj` で **即 return** するため、`message` を持たないこの合成イベントは
504
- * **マスクを素通りする**。よって `<result>` / `<summary>` / `<note>` / `<output-file>` の本文は
505
- * 絶対に載せない (載せると秘匿情報がマスクされないまま browser へ流れる)。`<task-id>` も
506
- * 画面側が使っていないので載せない。
502
+ * maskMessageObject は message の有無を判定する前にイベント全体の秘匿値を伏せる。
503
+ * ただし合成イベントは終端の復元に必要な最小情報に限定し、<result> / <summary> /
504
+ * <note> / <output-file> の本文(報告全文等)は新たに載せない方針を維持する。
505
+ * <task-id> も画面側が使っていないので載せない。
507
506
  *
508
507
  * 画面側 (Hub 本体 frontend/lib/cockpit/agentRequest.ts の applyTaskNotificationEvent) は
509
508
  * `type:"system" / subtype:"task_notification" / tool_use_id / status` を終端として処理し、
@@ -714,6 +713,67 @@ export async function deleteSession({
714
713
  return { ok: true }
715
714
  }
716
715
 
716
+ /** 親の取得窓内の背景依頼だけに、直接の子 transcript を結合する。 */
717
+ async function attachBackgroundSubagentHistory(filePath, { result, requests }) {
718
+ if (requests.size === 0) return result
719
+ const subagentsDir = path.join(filePath.slice(0, -".jsonl".length), "subagents")
720
+ let names
721
+ try {
722
+ names = await readdir(subagentsDir)
723
+ } catch {
724
+ return result // subagents 不在は正常系。
725
+ }
726
+ const children = new Map()
727
+ for (const name of names) {
728
+ if (!/^agent-.+\.meta\.json$/.test(name)) continue
729
+ try {
730
+ const meta = JSON.parse(await readFile(path.join(subagentsDir, name), "utf-8"))
731
+ const toolUseId = meta?.toolUseId
732
+ if (typeof toolUseId !== "string" || !toolUseId || !requests.has(toolUseId)) continue
733
+ const text = await readFile(path.join(subagentsDir, name.replace(/\.meta\.json$/, ".jsonl")), "utf-8")
734
+ // 子は実行中にも読まれるので、追記途中の「未完の末尾行」だけは捨てて完成済みの行を残す。
735
+ // ⚠️ 途中の行が壊れている場合は子ごと捨てる。壊れた行が tool_use だと、後続の tool_result を
736
+ // 伏せるためのツール名が引けず、非公開本文 (get_skill_prompt 等) がマスクを素通りするため。
737
+ // 末尾行は後続の行を持たないので、捨ててもこの露出は起きない。
738
+ const lines = text.split("\n").filter((line) => line.trim())
739
+ const rows = []
740
+ for (let i = 0; i < lines.length; i++) {
741
+ try {
742
+ rows.push(JSON.parse(lines[i]))
743
+ } catch (err) {
744
+ if (i === lines.length - 1) break
745
+ throw err
746
+ }
747
+ }
748
+ const toolNames = new Map()
749
+ if (MASK_ENABLED) {
750
+ for (const row of rows) collectToolUseNames(row, toolNames)
751
+ }
752
+ const events = []
753
+ for (const row of rows) {
754
+ if (!DISPLAY_TYPES.has(row?.type)) continue
755
+ // 孫の依頼は親の取得窓にないため再帰しない。子から終端イベントも合成しない。
756
+ let event = normalizeHistoryEvent({ ...row, parent_tool_use_id: toolUseId })
757
+ if (MASK_ENABLED) event = maskMessageObject(event, toolNames)
758
+ events.push(event)
759
+ }
760
+ const index = requests.get(toolUseId)
761
+ if (!children.has(index)) children.set(index, [])
762
+ for (const event of events) children.get(index).push(event)
763
+ } catch {
764
+ // 不完全な meta / transcript は推定で紐付けず、その子だけスキップする。
765
+ }
766
+ }
767
+ if (children.size === 0) return result
768
+ const events = []
769
+ for (let i = 0; i < result.events.length; i++) {
770
+ events.push(result.events[i])
771
+ // 追加取得が先頭イベントを境界にするため、時刻ソートせず親直後に置く。
772
+ for (const child of children.get(i) || []) events.push(child)
773
+ }
774
+ return { ...result, events }
775
+ }
776
+
717
777
  /**
718
778
  * jsonl 1 ファイルを読んで UI 表示用イベント配列を返す。
719
779
  *
@@ -739,7 +799,7 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
739
799
  throw err
740
800
  }
741
801
  const cached = getHydrateCache(filePath, st.mtimeMs, st.size, maxLines)
742
- if (cached) return cached
802
+ if (cached) return attachBackgroundSubagentHistory(filePath, cached)
743
803
 
744
804
  const readStarted = performance.now()
745
805
  let text
@@ -774,6 +834,7 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
774
834
  }
775
835
 
776
836
  const events = []
837
+ const requests = new Map()
777
838
  for (const line of slice) {
778
839
  let obj
779
840
  try {
@@ -785,6 +846,15 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
785
846
  // historyEventFromLine 1 箇所に集約 (live watch も同じ関数を通る)。
786
847
  let event = historyEventFromLine(obj)
787
848
  if (!event) continue
849
+ if (obj.type === "assistant" && Array.isArray(obj.message?.content)) {
850
+ for (const block of obj.message.content) {
851
+ // 作業ログの依頼カードを持つ背景依頼だけが対象。前景は取り込まない。
852
+ if (block?.type === "tool_use" && (block.name === "Agent" || block.name === "Task")
853
+ && block.input?.run_in_background === true && typeof block.id === "string" && block.id) {
854
+ requests.set(block.id, events.length)
855
+ }
856
+ }
857
+ }
788
858
  // 秘匿情報 / 非公開スキルプロンプト本文を hydrate 時にも伏せる (古いセッション再表示対策)。
789
859
  if (MASK_ENABLED) event = maskMessageObject(event, toolNames)
790
860
  events.push(event)
@@ -795,8 +865,9 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
795
865
  read_ms: parseStarted - readStarted, parse_ms: finished - parseStarted, total_ms: finished - readStarted,
796
866
  })
797
867
  const result = { events, total_lines, truncated }
798
- setHydrateCache(filePath, st.mtimeMs, st.size, maxLines, result)
799
- return result
868
+ const parent = { result, requests }
869
+ setHydrateCache(filePath, st.mtimeMs, st.size, maxLines, parent)
870
+ return attachBackgroundSubagentHistory(filePath, parent)
800
871
  }
801
872
 
802
873
  /**
@@ -3501,7 +3501,7 @@ export class ClaudeStreamBridge extends EventEmitter {
3501
3501
  *
3502
3502
  * ⚠️ ここに出るのは SDK チャットセッションのみ (tmux の TUI claude は対象外)。
3503
3503
  * @param {string} [cwd] 指定時はその cwd のみ
3504
- * @returns {Array<{kind: string, cwd: string, session_id: string|null, idle_ms: number}>}
3504
+ * @returns {Array<{kind: string, cwd: string, session_id: string|null, busy: boolean, idle_ms: number}>}
3505
3505
  */
3506
3506
  describeLiveSessions(cwd) {
3507
3507
  return this._liveCandidatesForCwd(cwd, false).map(({ session, attached }) =>
@@ -3578,6 +3578,7 @@ export class ClaudeStreamBridge extends EventEmitter {
3578
3578
  kind: "claude",
3579
3579
  cwd: session.cwd,
3580
3580
  session_id: session.sessionId || null,
3581
+ busy: session._busy === true,
3581
3582
  idle_ms: at ? now - at : -1,
3582
3583
  attached,
3583
3584
  }
package/src/main.mjs CHANGED
@@ -4191,13 +4191,32 @@ export async function dispatch(msg, ctx) {
4191
4191
  projectsRoot,
4192
4192
  logger: ctx.logger,
4193
4193
  })
4194
+ // 履歴一覧に、同じ cwd の bridge が持つ現在の live / busy を併合する。
4195
+ // ⚠️ live の列挙に失敗しても取得済みの履歴は返す。その場合は live / busy を付けない
4196
+ // (= 不明。false にすると「常駐していない」と断定してしまう)。途中まで作った Map は使わない。
4197
+ let liveBySession = new Map()
4198
+ try {
4199
+ const next = new Map()
4200
+ for (const s of ctx.claudeBridge?.describeLiveSessions?.(cwd) ?? []) {
4201
+ if (s.session_id) next.set(s.session_id, { live: true, busy: !!s.busy })
4202
+ }
4203
+ liveBySession = next
4204
+ } catch (err) {
4205
+ liveBySession = null
4206
+ ctx.logger?.warn?.({ err: String(err?.message || err), cwd }, "claude.sessions: live 列挙に失敗 (live/busy を付けずに履歴を返す)")
4207
+ }
4208
+ const withLive = (result.sessions || []).map((s) => (liveBySession ? {
4209
+ ...s,
4210
+ live: liveBySession.get(s.session_id)?.live === true,
4211
+ busy: liveBySession.get(s.session_id)?.busy === true,
4212
+ } : s))
4194
4213
  ctx.client.send({
4195
4214
  type: "claude.sessions.response",
4196
4215
  stream_id,
4197
4216
  cwd,
4198
4217
  epoch,
4199
4218
  profile_id: profileId,
4200
- sessions: result.sessions || [],
4219
+ sessions: withLive,
4201
4220
  error: result.error,
4202
4221
  })
4203
4222
  return