@cocorograph/hub-agent 0.8.77 → 0.8.78

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.77",
3
+ "version": "0.8.78",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -1,9 +1,110 @@
1
+ import path from "node:path"
1
2
  import { listChatTargets } from "./chat-targets.mjs"
3
+ import { getChatSignal as defaultGetChatSignal } from "./chat-signals.mjs"
2
4
  import { describeLiveSessionsUnion } from "./live-sessions.mjs"
5
+ import { sessionNameFor } from "./session-names.mjs"
6
+ import { sanitizeTmuxName } from "./tmux.mjs"
7
+
8
+ /**
9
+ * 会話 1 本の表示用シグナル。
10
+ *
11
+ * ⚠️ 出どころは `chat-signals.mjs` の `(cwd, cli_kind)` キーのストアで、**tmux に依存しない**。
12
+ * 旧 `tmux.list_sessions` 経路は同じ信号を「tmux セッション名をキーにした sidecar
13
+ * (`/tmp/cockpit_session_events/<名前>.json`) と state loop の snapshot」という間接経路で
14
+ * 配っていたが、どちらも tmux セッション行が無いと更新されない (bundle hook も
15
+ * `TMUX_PANE` が無ければ書かずに終了する)。会話を tmux から切り離す移行では、その経路は
16
+ * 静かに止まる。ここで cwd キーの信号を直接載せることで、移行後も入力待ち・生成中・
17
+ * 使用率が出る。名前をキーにしないので、同名 worktree でも帰属を取り違えない。
18
+ */
19
+ function attachChatSignal(item, cwd, getChatSignal, logger) {
20
+ let sig
21
+ try {
22
+ sig = cwd ? getChatSignal(cwd, item.kind) : null
23
+ } catch (err) {
24
+ // 信号の取得失敗で会話一覧まで失わない。未取得の判定キーは作らない。
25
+ logger?.warn?.(
26
+ { err: err?.message || String(err), cwd },
27
+ "chat.sessions: chat signal failed",
28
+ )
29
+ return item
30
+ }
31
+ if (!sig) return item
32
+ item.chat_signal = {
33
+ // 'processing' | 'waiting' | null (null = 鮮度切れ。断定しない)
34
+ status: sig.status ?? null,
35
+ // permission / AskUserQuestion の応答待ち。入力待ちバッジの本来の出どころ。
36
+ input_pending: sig.inputPending === true,
37
+ context_pct: typeof sig.context_pct === "number" ? sig.context_pct : null,
38
+ // この信号がどの会話のものかの帰属 (frontend の使用率の帰属判定に使う)。
39
+ session_id: typeof sig.sessionId === "string" ? sig.sessionId : null,
40
+ // 最後にターンが動いた時刻 (ms)。
41
+ turn_at: typeof sig.turnAt === "number" ? sig.turnAt : 0,
42
+ }
43
+ return item
44
+ }
45
+
46
+ /** 直近に動いた方を優先するための比較値。負値 / 非数は「不明」として最後に回す。 */
47
+ function idleRank(item) {
48
+ return typeof item?.idle_ms === "number" && item.idle_ms >= 0 ? item.idle_ms : Infinity
49
+ }
3
50
 
4
51
  /** ローカル検出した宛先と、毎回取得するライブ会話を返す。 */
5
- export async function buildChatSessionsSnapshot({ bridges, logger }) {
6
- const live = describeLiveSessionsUnion(bridges)
52
+ export async function buildChatSessionsSnapshot({
53
+ bridges,
54
+ logger,
55
+ getChatSignal = defaultGetChatSignal,
56
+ }) {
57
+ const descriptors = describeLiveSessionsUnion(bridges)
7
58
  const { targets, errors } = await listChatTargets({ logger })
59
+ const targetByCwd = new Map(targets.map((target) => [target.cwd, target]))
60
+ const named = descriptors.map((descriptor) => {
61
+ const item = { ...descriptor }
62
+ // ⚠️ cwd が無い記述子で path.basename に undefined を渡すと throw し、map ごと失敗して
63
+ // 会話一覧が丸ごと空になる (呼び出し側の catch が targets:[] live:[] を返す)。名前を
64
+ // 解決できない 1 本のために全部を失わない。
65
+ const cwd = typeof item.cwd === "string" ? item.cwd : ""
66
+ const dir = targetByCwd.get(cwd)?.name ?? (cwd ? sanitizeTmuxName(path.basename(cwd)) : "")
67
+ return { item, cwd, sessionName: dir ? sessionNameFor(dir, item.kind) : "" }
68
+ })
69
+
70
+ // ⚠️ session_name は dir 名から作るので、別 workspace 配下の同名 worktree は同じ名前へ
71
+ // 解決される (この端末でも cloudflare-plugin が 3 つの workspace 配下に実在する)。
72
+ //
73
+ // ここで 2 本返してはいけない。frontend のサイドバーは行キーが session_name で、
74
+ // 行・下書き・ポインタ・選択状態のすべてが「1 名前 = 1 行」を前提に Map で引いている。
75
+ // 旧 tmux 一覧経路では tmux のセッション名空間が一意だったので、この不変条件はタダで
76
+ // 成立していた (同名 worktree は片方しか起動できなかった)。会話を tmux から切り離すと
77
+ // 両方ライブになれるため、agent 側で畳んで不変条件を維持する。
78
+ //
79
+ // 残す 1 本は「直近に動いた方」。frontend の adaptChatSessions が同名を畳むときと
80
+ // 同じ規則 (idle_ms が小さい方、同値なら先勝ち) にして、どちらで畳んでも同じ行が残る。
81
+ // ⚠️ 表示シグナルは cwd キーなので、畳んで残った会話のものが正しく付く (畳み込みは
82
+ // 行の一意性のためで、帰属のためではない)。
83
+ const live = []
84
+ const indexByName = new Map()
85
+ for (const { item, cwd, sessionName } of named) {
86
+ if (!sessionName) {
87
+ live.push(item)
88
+ continue
89
+ }
90
+ item.session_name = sessionName
91
+ const prior = indexByName.get(sessionName)
92
+ if (prior === undefined) {
93
+ indexByName.set(sessionName, live.length)
94
+ live.push(attachChatSignal(item, cwd, getChatSignal, logger))
95
+ continue
96
+ }
97
+ const kept = live[prior]
98
+ const replace = idleRank(item) < idleRank(kept)
99
+ logger?.warn?.(
100
+ {
101
+ session_name: sessionName,
102
+ kept_cwd: replace ? cwd : kept.cwd,
103
+ dropped_cwd: replace ? kept.cwd : cwd,
104
+ },
105
+ "chat.sessions: session name collision (folded)",
106
+ )
107
+ if (replace) live[prior] = attachChatSignal(item, cwd, getChatSignal, logger)
108
+ }
8
109
  return { targets, live, errors }
9
110
  }
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises"
2
2
  import path from "node:path"
3
3
  import { hubProjectsBase } from "./chat-workspace.mjs"
4
- import { listWorktreeEntries } from "./tmux.mjs"
4
+ import { listWorktreeEntries, sanitizeTmuxName } from "./tmux.mjs"
5
5
  import { AsyncTtlCache } from "./async-ttl-cache.mjs"
6
6
 
7
7
  export const chatTargetsCache = new AsyncTtlCache({ ttlMs: 3000 })
@@ -23,7 +23,7 @@ export async function listChatTargets({ logger } = {}) {
23
23
  try {
24
24
  workspaces = (await fs.readdir(base, { withFileTypes: true }))
25
25
  .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
26
- .map((entry) => ({ kind: "workspace", name: entry.name, cwd: path.join(base, entry.name) }))
26
+ .map((entry) => ({ kind: "workspace", name: sanitizeTmuxName(entry.name), cwd: path.join(base, entry.name) }))
27
27
  } catch (err) {
28
28
  // ⚠️ ENOENT は「base がまだ作られていない」であって走査の失敗ではない。worktree 側
29
29
  // (tmux.mjs の listWorktreeEntries) と扱いを揃える。ここで errors に入れると、正常な
package/src/tmux.mjs CHANGED
@@ -31,7 +31,7 @@ const execFileP = promisify(execFile)
31
31
 
32
32
  const DEFAULT_TMUX_BIN = "tmux"
33
33
 
34
- function sanitizeTmuxName(s) {
34
+ export function sanitizeTmuxName(s) {
35
35
  return s.replace(/[.:]/g, "-")
36
36
  }
37
37