@cocorograph/hub-agent 0.8.88 → 0.8.90

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.88",
3
+ "version": "0.8.90",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -50,6 +50,29 @@ function idleRank(item) {
50
50
  return typeof item?.idle_ms === "number" && item.idle_ms >= 0 ? item.idle_ms : Infinity
51
51
  }
52
52
 
53
+ /**
54
+ * この記述子が、その cwd の表示信号の持ち主 (= 代表会話) か。
55
+ *
56
+ * ⚠️ 畳み込みで信号の持ち主を落とすと、frontend (chatSessionsAdapter.ts の
57
+ * signalBelongsTo) が「別の会話の信号」として捨て、生成中でも turn_active=false の赤に
58
+ * なる。idle_ms は attach 中の端末の活性からしか作られず (claude-stream-bridge.mjs
59
+ * `_describeCandidate`)、ワークスペースを離れると -1 になるので、idle_ms だけで選ぶと
60
+ * 離れた後の生成中の会話がほぼ必ず負ける (2026-09-22 に実測)。
61
+ *
62
+ * 会話 ID が片方でも未確定なら持ち主とは言えないので false (従来の idle_ms 規則に委ねる)。
63
+ * 信号の取得失敗も false。失敗の記録は attachChatSignal が行うので、ここでは黙る。
64
+ */
65
+ function ownsChatSignal(item, cwd, getChatSignal) {
66
+ const id = conversationIdOf(item)
67
+ if (!id || !cwd) return false
68
+ try {
69
+ const sig = getChatSignal(cwd, item.kind)
70
+ return typeof sig?.sessionId === "string" && sig.sessionId === id
71
+ } catch {
72
+ return false
73
+ }
74
+ }
75
+
53
76
  /**
54
77
  * 記述子の会話 ID。claude は `session_id`、codex は `thread_id` に入る。
55
78
  * まだ確定していない会話 (初回入力前の attach 等) は両方とも欠けるので null を返す。
@@ -107,6 +130,23 @@ export async function buildChatSessionsSnapshot({
107
130
  const descriptors = describeLiveSessionsUnion(bridges)
108
131
  const { targets, errors } = await listChatTargets({ logger })
109
132
  const targetByCwd = chatTargetsByCwd(targets)
133
+ // 畳み込みでは同じ (cwd, kind) の信号を何度も引く (所有者判定 ×2 + 付与)。readRowChatState は
134
+ // ライブ会話の走査を伴うので、この同期区間の中だけで結果 (例外も含む) を共有する。
135
+ // ⚠️ await をまたいで使わない。送信直前の付け直しは refreshChatSessionsSignals が行う。
136
+ const signalMemo = new Map()
137
+ const signalFor = (cwd, kind) => {
138
+ const key = `${kind}\u0000${cwd}`
139
+ if (!signalMemo.has(key)) {
140
+ try {
141
+ signalMemo.set(key, { value: getChatSignal(cwd, kind) })
142
+ } catch (error) {
143
+ signalMemo.set(key, { error })
144
+ }
145
+ }
146
+ const hit = signalMemo.get(key)
147
+ if (hit.error) throw hit.error
148
+ return hit.value
149
+ }
110
150
  const named = descriptors.map((descriptor) => {
111
151
  const item = { ...descriptor }
112
152
  const cwd = typeof item.cwd === "string" ? item.cwd : ""
@@ -122,10 +162,12 @@ export async function buildChatSessionsSnapshot({
122
162
  // 成立していた (同名 worktree は片方しか起動できなかった)。会話を tmux から切り離すと
123
163
  // 両方ライブになれるため、agent 側で畳んで不変条件を維持する。
124
164
  //
125
- // 残す 1 本は「直近に動いた方」。frontend の adaptChatSessions が同名を畳むときと
126
- // 同じ規則 (idle_ms が小さい方、同値なら先勝ち) にして、どちらで畳んでも同じ行が残る。
127
- // ⚠️ 表示シグナルは cwd キーなので、畳んで残った会話のものが正しく付く (畳み込みは
128
- // 行の一意性のためで、帰属のためではない)。
165
+ // 残す 1 本は、まず「その cwd の表示信号の持ち主 (代表会話)」、次に「直近に動いた方」
166
+ // (idle_ms が小さい方、同値なら先勝ち)。
167
+ // ⚠️ 表示シグナルは cwd キーで代表会話の値なので、代表でない会話を残すと frontend が
168
+ // 帰属不一致で信号を捨てて赤に落ちる (ownsChatSignal 参照)。同じ cwd に会話が複数
169
+ // 生きるのは通常運用 (warm 境界は cwd あたり K=3) なので、ここが実際の分岐になる。
170
+ // frontend の adaptChatSessions も同名を畳むが、それは畳まない旧 agent 向けの互換経路。
129
171
  const live = []
130
172
  const indexByName = new Map()
131
173
  for (const { item, cwd, sessionName } of named) {
@@ -137,11 +179,13 @@ export async function buildChatSessionsSnapshot({
137
179
  const prior = indexByName.get(sessionName)
138
180
  if (prior === undefined) {
139
181
  indexByName.set(sessionName, live.length)
140
- live.push(attachChatSignal(item, cwd, getChatSignal, logger))
182
+ live.push(attachChatSignal(item, cwd, signalFor, logger))
141
183
  continue
142
184
  }
143
185
  const kept = live[prior]
144
- const replace = idleRank(item) < idleRank(kept)
186
+ const itemOwns = ownsChatSignal(item, cwd, signalFor)
187
+ const keptOwns = ownsChatSignal(kept, kept.cwd, signalFor)
188
+ const replace = itemOwns !== keptOwns ? itemOwns : idleRank(item) < idleRank(kept)
145
189
  const winner = replace ? item : kept
146
190
  const loser = replace ? kept : item
147
191
  // ⚠️ cwd だけでは衝突の正体が分からない。想定していたのは「別 workspace 配下の同名
@@ -163,7 +207,7 @@ export async function buildChatSessionsSnapshot({
163
207
  },
164
208
  "chat.sessions: session name collision (folded)",
165
209
  )
166
- if (replace) live[prior] = attachChatSignal(item, cwd, getChatSignal, logger)
210
+ if (replace) live[prior] = attachChatSignal(item, cwd, signalFor, logger)
167
211
  }
168
212
  // chat-targets.mjs の fs.readdir(hubProjectsBase()) と同じ絶対パスに揃える(~ は展開しない)。
169
213
  const projects_base = path.resolve(hubProjectsBase())
@@ -43,7 +43,7 @@ const SLOW_DISPATCH_MS = 1000
43
43
  * 調査対象そのものなので、速かった場合も含めて毎回記録する
44
44
  * (速いことの記録が無いと「詰まっていない」を示せない)。
45
45
  */
46
- const ALWAYS_TIMED_TYPES = new Set(["tmux.kill_session"])
46
+ const ALWAYS_TIMED_TYPES = new Set(["chat.session.stop"])
47
47
 
48
48
  export function isConnectionLifecycleMessage(type) {
49
49
  return LIFECYCLE_TYPES.has(type)
@@ -184,7 +184,7 @@ export class DispatchScheduler {
184
184
  const mutationMode = globalMutationMode(type)
185
185
  if (mutationMode) return `${mutationMode}:global`
186
186
 
187
- // usage build は最大 25 秒かかり、同じ control lane の tmux.list_sessions が
187
+ // usage build は最大 25 秒かかり、同じ control lane の一覧要求 (当時は tmux.list_sessions) が
188
188
  // frontend の 10 秒打ち切りで捨てられていたため分離する (2026-09-04 本番計測)。
189
189
  if (type === "usage.request") return "usage"
190
190
 
@@ -203,8 +203,7 @@ export class DispatchScheduler {
203
203
  }
204
204
  return "control"
205
205
  }
206
- // chat.stop_all は全レーンに跨るため control に置く。その時点で live な会話だけを
207
- // 止める契約であり、接続処理中のまだ live でない会話は対象外。sessions.request も control。
206
+ // chat.sessions.request は control。
208
207
  const mode = chatMode(type)
209
208
  if (!mode) return "control"
210
209
 
package/src/main.mjs CHANGED
@@ -4398,43 +4398,6 @@ export async function dispatch(msg, ctx) {
4398
4398
  ctx.client.send(result)
4399
4399
  return
4400
4400
  }
4401
- case "chat.stop_all": {
4402
- // exit に載る停止理由。bridge 側の既定 ("tui-bind-stop" / "explicit-stop") は
4403
- // それぞれ TUI 切替・個別停止の名前なので、全停止はここで明示して区別する。
4404
- const STOP_ALL_REASON = "chat-stop-all"
4405
- const result = { type: "chat.stop_all.result", request_id: msg.request_id, stopped: 0, failed: [] }
4406
- try {
4407
- const live = describeLiveSessionsUnion({ claudeBridge: ctx.claudeBridge, codexBridge: ctx.codexBridge })
4408
- const claudeCwds = new Set()
4409
- const codexStreams = new Set()
4410
- for (const session of live) {
4411
- let target
4412
- try {
4413
- if (session.kind === "claude") {
4414
- const cwd = session.cwd
4415
- if (claudeCwds.has(cwd)) continue
4416
- claudeCwds.add(cwd)
4417
- // ⚠️ reason を省くと stopSessionsFor の既定 "tui-bind-stop" が exit に載り、
4418
- // 画面から止めた会話が「tmux へ切り替えたせい」で消えたように見える。
4419
- target = { cwd, reason: STOP_ALL_REASON }
4420
- result.stopped += await ctx.claudeBridge.stopSessionsFor(target)
4421
- } else if (session.kind === "codex") {
4422
- const stream_id = session.stream_id
4423
- if (codexStreams.has(stream_id)) continue
4424
- codexStreams.add(stream_id)
4425
- target = { stream_id, reason: STOP_ALL_REASON }
4426
- result.stopped += await ctx.codexBridge.stopSessionForStream(target)
4427
- }
4428
- } catch (err) {
4429
- result.failed.push({ ...target, reason: err?.message || String(err) })
4430
- }
4431
- }
4432
- } catch (err) {
4433
- result.error = err?.message || String(err)
4434
- }
4435
- ctx.client.send(result)
4436
- return
4437
- }
4438
4401
  case "chat.sessions.request": {
4439
4402
  try {
4440
4403
  const snapshot = await buildChatSessionsSnapshot({