@cocorograph/hub-agent 0.8.100 → 0.8.102

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.100",
3
+ "version": "0.8.102",
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,
@@ -20,7 +20,8 @@ import { recordConversationFocus } from "./chat-signals.mjs"
20
20
  */
21
21
  import { EventEmitter } from "node:events"
22
22
  import { randomUUID } from "node:crypto"
23
- import { readFileSync } from "node:fs"
23
+ import { readFileSync, statSync } from "node:fs"
24
+ import { open } from "node:fs/promises"
24
25
  import os from "node:os"
25
26
  import path from "node:path"
26
27
  import { isUnderRemoval } from "./chat-removal-guard.mjs"
@@ -323,11 +324,12 @@ export function _resetModelCapabilities() {
323
324
  * SDKUserMessage は parent_tool_use_id: string|null が必須フィールド。現行 SDK は入力側で
324
325
  * 寛容なので省略しても動くが、将来の型厳格化に備えて明示する。トップレベルのユーザー入力
325
326
  * なので親 tool_use は無く null。 */
326
- function toSDKUserMessage(text) {
327
+ function toSDKUserMessage(text, uuid) {
327
328
  return {
328
329
  type: "user",
329
330
  message: { role: "user", content: text },
330
331
  parent_tool_use_id: null,
332
+ ...(uuid === undefined ? {} : { uuid }),
331
333
  }
332
334
  }
333
335
 
@@ -429,6 +431,11 @@ const STALE_IDLE_GUARD_MS = 5000
429
431
 
430
432
  // sibling CLI の既定待ち 10 秒より短く受理状態を待つ。超過は失敗と断定しない。
431
433
  export const SIBLING_ACCEPT_WAIT_MS = 1000
434
+ export const SIBLING_CONFIRM_WAIT_MS = 2500
435
+ const SIBLING_CONFIRM_POLL_MS = 50
436
+ // 注入本文の user 行はこれより十分小さい。改行の無い巨大行 (大きな tool result 等) を
437
+ // 抱え込んで再コピー・再検索し続けないよう、超えた行は次の改行まで読み飛ばす。
438
+ const SIBLING_CONFIRM_MAX_LINE_BYTES = 1024 * 1024
432
439
 
433
440
  /** per-message の title control request を待って InputQueue を閉じる上限。 */
434
441
  const SESSION_TITLE_CLOSE_WAIT_MS = 15_000
@@ -1428,7 +1435,7 @@ export class ClaudeStreamSession {
1428
1435
  // 昇格バブルを作るよう started を先出しする (通常メッセージは no-op)。
1429
1436
  this._maybeEmitInjectStarted(prompt)
1430
1437
  this._pendingTitleSeed = prompt
1431
- this._startResidentTurnWithInput(toSDKUserMessage(prompt), ultracode)
1438
+ this._startResidentTurnWithInput(toSDKUserMessage(prompt, opts.message_uuid), ultracode)
1432
1439
  return { accepted: true }
1433
1440
  }
1434
1441
  // 起動済みの常駐 query へ次ターンを流す。init 済みで空窓が無いため、ultracode は走行中
@@ -1436,7 +1443,7 @@ export class ClaudeStreamSession {
1436
1443
  // 旧 query 終了 × 後着 pending の ABA/FIFO 逆転は _reconcileThenDeliver が直列化する
1437
1444
  // (drain 経路 _drainResidentPending と共通)。
1438
1445
  const outcome = await this._reconcileThenDeliver(
1439
- toSDKUserMessage(prompt),
1446
+ toSDKUserMessage(prompt, opts.message_uuid),
1440
1447
  ultracode,
1441
1448
  )
1442
1449
  if (outcome === "closed") return { accepted: false, reason: "queue_dead" }
@@ -1472,7 +1479,7 @@ export class ClaudeStreamSession {
1472
1479
  // ack はターン完走を待たず「起動できたか」で返すため、フル実行の Promise を
1473
1480
  // 呼び出し元へ伝播させない (fire-and-forget)。例外は _runPerMessage 内部で
1474
1481
  // 握っているため通常は reject しないが、念のため warn だけ残す。
1475
- this._runPerMessage(prompt, { ultracode, injectSource }).catch((err) => {
1482
+ this._runPerMessage(prompt, { ultracode, injectSource, message_uuid: opts.message_uuid }).catch((err) => {
1476
1483
  this.logger?.error(
1477
1484
  { stream_id: this.stream_id, err: err?.message },
1478
1485
  "per-message run threw unexpectedly",
@@ -1541,7 +1548,7 @@ export class ClaudeStreamSession {
1541
1548
  // per-message でも単発 InputQueue (1 件 push → 即 close) を prompt に渡し、1 query=1 ターンの
1542
1549
  // resume チェーン挙動は維持しつつ、文字列 prompt + canUseTool の SDK 制約リスクを回避する。
1543
1550
  const inputQueue = new InputQueue()
1544
- inputQueue.push(toSDKUserMessage(prompt))
1551
+ inputQueue.push(toSDKUserMessage(prompt, opts.message_uuid))
1545
1552
  const q = this.sdk.query({ prompt: inputQueue, options })
1546
1553
  const shouldWaitForTitle =
1547
1554
  isFreshStart && this._canGenerateSessionTitle(q, prompt)
@@ -2708,11 +2715,12 @@ export class ClaudeStreamBridge extends EventEmitter {
2708
2715
  * (テストでは `{ query: stubQuery }` を渡す)
2709
2716
  * @param {import('pino').Logger} [opts.logger]
2710
2717
  */
2711
- constructor({ sdk, logger, readNeedsAuthCacheNames: readNeedsAuth } = {}) {
2718
+ constructor({ sdk, logger, readNeedsAuthCacheNames: readNeedsAuth, projectsRoot } = {}) {
2712
2719
  super()
2713
2720
  if (!sdk || typeof sdk.query !== "function") {
2714
2721
  throw new TypeError("ClaudeStreamBridge requires { sdk: { query } }")
2715
2722
  }
2723
+ this._projectsRoot = projectsRoot
2716
2724
  this.sdk = sdk
2717
2725
  this.logger = logger
2718
2726
  /** needs-auth キャッシュ名読み取り (テスト差し替え用。既定は実ファイル読み込み)。 */
@@ -3493,7 +3501,7 @@ export class ClaudeStreamBridge extends EventEmitter {
3493
3501
  *
3494
3502
  * ⚠️ ここに出るのは SDK チャットセッションのみ (tmux の TUI claude は対象外)。
3495
3503
  * @param {string} [cwd] 指定時はその cwd のみ
3496
- * @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}>}
3497
3505
  */
3498
3506
  describeLiveSessions(cwd) {
3499
3507
  return this._liveCandidatesForCwd(cwd, false).map(({ session, attached }) =>
@@ -3570,6 +3578,7 @@ export class ClaudeStreamBridge extends EventEmitter {
3570
3578
  kind: "claude",
3571
3579
  cwd: session.cwd,
3572
3580
  session_id: session.sessionId || null,
3581
+ busy: session._busy === true,
3573
3582
  idle_ms: at ? now - at : -1,
3574
3583
  attached,
3575
3584
  }
@@ -3761,15 +3770,85 @@ export class ClaudeStreamBridge extends EventEmitter {
3761
3770
  return this._sendSiblingMessage(cwd, text, source, session, attached)
3762
3771
  }
3763
3772
 
3764
- /** 受理結果だけを有限時間待つ。遅れて確定した結果も source の後始末には使う。 */
3773
+ /** 追記された完全な JSONL 行だけを確認する。パス変更時は新ファイルの先頭へ戻す。 */
3774
+ async _confirmSiblingTranscript(session, cwd, uuid, cursor, startedAt) {
3775
+ const deadline = startedAt + SIBLING_CONFIRM_WAIT_MS
3776
+ try {
3777
+ while (Date.now() < deadline) {
3778
+ const file = session.sessionId
3779
+ ? jsonlPath({ cwd, session_id: session.sessionId, projectsRoot: this._projectsRoot })
3780
+ : null
3781
+ if (file !== cursor.file) Object.assign(cursor, { file, offset: 0, pending: Buffer.alloc(0), skipping: false })
3782
+ if (file) {
3783
+ let handle
3784
+ try {
3785
+ handle = await open(file, "r")
3786
+ const { size } = await handle.stat()
3787
+ while (cursor.offset < size && Date.now() < deadline) {
3788
+ const buffer = Buffer.alloc(Math.min(64 * 1024, size - cursor.offset))
3789
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, cursor.offset)
3790
+ if (!bytesRead) break
3791
+ cursor.offset += bytesRead
3792
+ const data = Buffer.concat([cursor.pending, buffer.subarray(0, bytesRead)])
3793
+ let start = 0
3794
+ if (cursor.skipping) {
3795
+ // 上限超えの行の続き。途中から JSON として読まず、次の改行の後から再開する。
3796
+ const newline = data.indexOf(10)
3797
+ if (newline === -1) {
3798
+ cursor.pending = Buffer.alloc(0)
3799
+ continue
3800
+ }
3801
+ start = newline + 1
3802
+ cursor.skipping = false
3803
+ }
3804
+ for (let end; (end = data.indexOf(10, start)) !== -1; start = end + 1) {
3805
+ try {
3806
+ const row = JSON.parse(data.subarray(start, end).toString("utf8"))
3807
+ if (row?.type === "user" && row.uuid === uuid) return true
3808
+ } catch { /* 壊れた行は確認の根拠にしない。 */ }
3809
+ }
3810
+ cursor.pending = data.subarray(start)
3811
+ if (cursor.pending.length > SIBLING_CONFIRM_MAX_LINE_BYTES) {
3812
+ cursor.pending = Buffer.alloc(0)
3813
+ cursor.skipping = true
3814
+ }
3815
+ }
3816
+ } catch (err) {
3817
+ if (err.code !== "ENOENT") return false
3818
+ } finally {
3819
+ await handle?.close()
3820
+ }
3821
+ }
3822
+ const remaining = deadline - Date.now()
3823
+ if (remaining > 0) await new Promise((resolve) => setTimeout(resolve, Math.min(SIBLING_CONFIRM_POLL_MS, remaining)))
3824
+ }
3825
+ this.logger?.info(
3826
+ { cwd, session_id: session.sessionId, uuid, elapsed_ms: Date.now() - startedAt },
3827
+ "sibling transcript confirmation timed out; delivery remains unconfirmed",
3828
+ )
3829
+ } catch { /* 確認不能を配送失敗にしない。 */ }
3830
+ return false
3831
+ }
3832
+
3833
+ /** 受理だけを1秒待ち、直接受理された入力は送信開始から2.5秒まで会話記録を確認する。 */
3765
3834
  async _sendSiblingMessage(cwd, text, source, session, attached) {
3766
3835
  const target = this._describeCandidate(session, attached)
3767
3836
  const delivered = { status: "delivered", session: target }
3768
3837
  const injectHandle = session._recordInjectSource?.(text, source)
3838
+ const message_uuid = randomUUID()
3839
+ const transcriptCwd = session.cwd || cwd
3840
+ const startedAt = Date.now()
3841
+ const cursor = { file: null, offset: 0, pending: Buffer.alloc(0), skipping: false }
3842
+ try {
3843
+ if (session.sessionId) {
3844
+ cursor.file = jsonlPath({ cwd: transcriptCwd, session_id: session.sessionId, projectsRoot: this._projectsRoot })
3845
+ cursor.offset = statSync(cursor.file).size
3846
+ }
3847
+ } catch { /* 新規ファイル・読み取り不能なら offset 0。 */ }
3769
3848
  let timer
3770
3849
  const outcome = (async () => {
3771
3850
  try {
3772
- const result = await session.sendMessage({ role: "user", content: text })
3851
+ const result = await session.sendMessage({ role: "user", content: text }, { message_uuid })
3773
3852
  if (result?.accepted === false) {
3774
3853
  session._removeInjectSource?.(injectHandle)
3775
3854
  return { status: "delivery_failed", session: target, reason: result.reason }
@@ -3779,7 +3858,7 @@ export class ClaudeStreamBridge extends EventEmitter {
3779
3858
  if (result?.queued === true) {
3780
3859
  return { status: "delivery_queued", session: target, reason: "queued_behind_active_turn" }
3781
3860
  }
3782
- return delivered
3861
+ return result?.accepted === true ? { ...delivered, confirm: true } : delivered
3783
3862
  } catch (err) {
3784
3863
  session._removeInjectSource?.(injectHandle)
3785
3864
  this.logger?.error(
@@ -3789,8 +3868,9 @@ export class ClaudeStreamBridge extends EventEmitter {
3789
3868
  return delivered
3790
3869
  }
3791
3870
  })()
3871
+ let accepted
3792
3872
  try {
3793
- return await Promise.race([
3873
+ accepted = await Promise.race([
3794
3874
  outcome,
3795
3875
  new Promise((resolve) => {
3796
3876
  timer = setTimeout(() => {
@@ -3805,6 +3885,9 @@ export class ClaudeStreamBridge extends EventEmitter {
3805
3885
  } finally {
3806
3886
  clearTimeout(timer)
3807
3887
  }
3888
+ if (!accepted.confirm) return accepted
3889
+ const confirmed = await this._confirmSiblingTranscript(session, transcriptCwd, message_uuid, cursor, startedAt)
3890
+ return confirmed ? { ...delivered, confirmed_via: "transcript" } : delivered
3808
3891
  }
3809
3892
 
3810
3893
  /**
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
@@ -49,6 +49,7 @@
49
49
  * 書き、不正値でも property があれば明示エラーを書く。未指定の旧 producer は fire-and-forget の
50
50
  * ままなので、新旧 bundle / hub-agent のローリング互換を保つ。
51
51
  *
52
+ * Claude の `delivery_state: "delivered"` は会話記録の user 行の uuid で確認した場合だけ。
52
53
  * ⚠️ codex の `delivery_state` は 4 値ある (2026-08-05 に `queued` を追加):
53
54
  * - `delivered` … userMessage echo まで確認済み (ok:true)
54
55
  * - `queued` … **相手が応答生成中**。次ターンのキューに受理済みで、echo は次の
@@ -397,8 +398,9 @@ function ambiguousTargetOutcome(cliKind, targetSessionId, candidates) {
397
398
  }
398
399
  }
399
400
 
400
- function deliveredStateFor(kind) {
401
- return kind === "codex" ? "delivered" : "dispatched"
401
+ function deliveredStateFor(kind, result) {
402
+ return kind === "codex" || (kind === "claude" && result.confirmed_via === "transcript")
403
+ ? "delivered" : "dispatched"
402
404
  }
403
405
 
404
406
  /**
@@ -1085,7 +1087,8 @@ export class SiblingInjectBridge {
1085
1087
  ok: true,
1086
1088
  delivered_to: r.session,
1087
1089
  delivery_id: r.delivery_id,
1088
- delivery_state: deliveredStateFor(target_cli_kind),
1090
+ delivery_state: deliveredStateFor(target_cli_kind, r),
1091
+ ...(r.confirmed_via ? { confirmed_via: r.confirmed_via } : {}),
1089
1092
  durable_state: r.durable_state,
1090
1093
  accepted_via: r.accepted_via,
1091
1094
  item_id: r.item_id,
@@ -1180,7 +1183,8 @@ export class SiblingInjectBridge {
1180
1183
  ok: true,
1181
1184
  delivered_to: r.session ?? null,
1182
1185
  delivery_id: r.delivery_id,
1183
- delivery_state: deliveredStateFor(target_cli_kind),
1186
+ delivery_state: deliveredStateFor(target_cli_kind, r),
1187
+ ...(r.confirmed_via ? { confirmed_via: r.confirmed_via } : {}),
1184
1188
  durable_state: r.durable_state,
1185
1189
  accepted_via: r.accepted_via,
1186
1190
  item_id: r.item_id,