@cocorograph/hub-agent 0.7.105 → 0.8.1

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/src/usage.mjs CHANGED
@@ -126,8 +126,10 @@ async function readTail(fp, maxBytes = 64 * 1024) {
126
126
  const start = size > maxBytes ? size - maxBytes : 0
127
127
  const len = size - start
128
128
  const buf = Buffer.allocUnsafe(len)
129
- await fh.read(buf, 0, len, start)
130
- let text = buf.toString("utf-8")
129
+ // ⚠️ bytesRead で切る (allocUnsafe は未初期化。並行 truncate 等で len 未満しか読めなかった
130
+ // とき、残りのゴミ領域を文字列化しない。cross-review minor)。
131
+ const { bytesRead } = await fh.read(buf, 0, len, start)
132
+ let text = buf.toString("utf-8", 0, bytesRead)
131
133
  // 途中から読んだ場合、先頭の不完全な行を捨てる (最初の改行まで)。
132
134
  if (start > 0) {
133
135
  const nl = text.indexOf("\n")
@@ -145,6 +147,41 @@ async function readTail(fp, maxBytes = 64 * 1024) {
145
147
  }
146
148
  }
147
149
 
150
+ /**
151
+ * ファイル先頭だけを読む (head)。readTail の対称。rollout の session_meta ヘッダー行
152
+ * (先頭行) + 先頭近傍の最初の user_message (preview) を取り出すのに使う。maxBytes まで
153
+ * 読み、末尾の不完全な行を捨てる (最後の改行以降を落とす)。size <= maxBytes なら全体。失敗時 null。
154
+ */
155
+ async function readHead(fp, maxBytes = 32 * 1024) {
156
+ let fh
157
+ try {
158
+ fh = await fs.open(fp, "r")
159
+ const st = await fh.stat()
160
+ const size = st.size
161
+ if (size === 0) return ""
162
+ const len = size > maxBytes ? maxBytes : size
163
+ const buf = Buffer.allocUnsafe(len)
164
+ // ⚠️ bytesRead で切る (allocUnsafe は未初期化。並行 truncate 等で len 未満しか読めなかった
165
+ // とき、残りのゴミ領域を文字列化しない。cross-review minor)。
166
+ const { bytesRead } = await fh.read(buf, 0, len, 0)
167
+ let text = buf.toString("utf-8", 0, bytesRead)
168
+ // EOF まで読み切っていない (maxBytes で頭だけ / 途中 truncate) 場合、末尾の不完全な行を捨てる。
169
+ if (bytesRead < size) {
170
+ const nl = text.lastIndexOf("\n")
171
+ text = nl >= 0 ? text.slice(0, nl) : text
172
+ }
173
+ return text
174
+ } catch {
175
+ return null
176
+ } finally {
177
+ try {
178
+ await fh?.close()
179
+ } catch {
180
+ /* ignore */
181
+ }
182
+ }
183
+ }
184
+
148
185
  /** P5(perf): latestJsonlContext の結果を {mtimeMs,size} キーでメモ化し再パースを避ける。 */
149
186
  const _jsonlCtxMemo = new Map() // fp → { mtimeMs, size, result }
150
187
 
@@ -1299,7 +1336,15 @@ export async function getSessionUsages() {
1299
1336
  // ---------------------------------------------------------------------------
1300
1337
 
1301
1338
  function codexSessionsDir() {
1302
- return configPath("HUB_CODEX_SESSIONS_DIR", os.homedir(), ".codex", "sessions")
1339
+ // 優先順: 明示 override (HUB_CODEX_SESSIONS_DIR。テスト/特殊配置用) → active Codex プロファイルの
1340
+ // CODEX_HOME (profile 切替で applyActiveCodexEnv が process.env.CODEX_HOME にセット) → 既定 ~/.codex。
1341
+ // ⚠️ 非既定プロファイルの rollout は <CODEX_HOME>/sessions に書かれる。CODEX_HOME を無視すると
1342
+ // 別アカウントの一覧/履歴を返す (2026-07-24 cross-review blocker)。app-server 経路が env 継承で
1343
+ // プロファイル対応だったのと挙動を揃える。findCodexRolloutFile / codexRolloutPresence /
1344
+ // latestTokenCountFromRollout も本関数経由なので同時にプロファイル対応化される。
1345
+ if (process.env.HUB_CODEX_SESSIONS_DIR) return process.env.HUB_CODEX_SESSIONS_DIR
1346
+ if (process.env.CODEX_HOME) return path.join(process.env.CODEX_HOME, "sessions")
1347
+ return path.join(os.homedir(), ".codex", "sessions")
1303
1348
  }
1304
1349
 
1305
1350
  // rate_limits.primary/secondary が無いときの limit は「% 表示のみ」の意味で 100 固定
@@ -1449,6 +1494,142 @@ export async function codexRolloutPresence(threadId) {
1449
1494
  return sawUnknownError ? "unknown" : "absent"
1450
1495
  }
1451
1496
 
1497
+ // ---------------------------------------------------------------------------
1498
+ // Phase 3 (Read-plane スライス2a): Codex セッション一覧を rollout の disk 走査で作る。
1499
+ // 従来 CodexStreamBridge.listThreads は使い捨て app-server を spawn して thread/list を
1500
+ // 叩いていた (完了基準「Codex 一覧で app-server を起動しない」に反する上、Read-plane で
1501
+ // off-queue 化しても spawn コスト自体は残る)。rollout は既にローカルにあり、一覧に必要な
1502
+ // 4 フィールド (thread_id / cwd / preview / updated_at_ms) はすべて rollout から安価に取れる:
1503
+ // - thread_id: session_meta.session_id (無ければファイル名末尾の UUID)
1504
+ // - cwd: session_meta.payload.cwd (先頭行)
1505
+ // - preview: 先頭近傍の最初の event_msg/user_message.message (clean な生プロンプト)
1506
+ // - updated_at_ms: ファイル mtime (Claude 側 listSessions の mtime と単位を揃える)
1507
+ // ---------------------------------------------------------------------------
1508
+
1509
+ /** rollout ファイル名末尾の thread UUID を取り出す (session_meta 欠落時の fallback)。
1510
+ * 例: rollout-2026-07-22T20-08-05-019f8982-e8de-7f80-b304-4ad4a29ba170.jsonl */
1511
+ function threadIdFromRolloutFilename(fp) {
1512
+ const m = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i.exec(fp)
1513
+ return m ? m[1] : ""
1514
+ }
1515
+
1516
+ /** rollout の先頭 (head) から一覧用メタ (thread_id / cwd / preview) を取り出す。
1517
+ * header (session_meta) は先頭行、preview は先頭近傍の最初の user_message。null=読めない。 */
1518
+ async function parseCodexRolloutHead(fp) {
1519
+ const text = await readHead(fp, 32 * 1024)
1520
+ if (text == null) return null
1521
+ let thread_id = threadIdFromRolloutFilename(fp)
1522
+ let cwd = ""
1523
+ let preview = ""
1524
+ for (const line of text.split("\n")) {
1525
+ if (!line) continue
1526
+ let o
1527
+ try {
1528
+ o = JSON.parse(line)
1529
+ } catch {
1530
+ continue
1531
+ }
1532
+ if (o.type === "session_meta" && o.payload && typeof o.payload === "object") {
1533
+ if (typeof o.payload.cwd === "string") cwd = o.payload.cwd
1534
+ if (typeof o.payload.session_id === "string" && o.payload.session_id) {
1535
+ thread_id = o.payload.session_id
1536
+ }
1537
+ } else if (
1538
+ !preview &&
1539
+ o.type === "event_msg" &&
1540
+ o.payload &&
1541
+ o.payload.type === "user_message" &&
1542
+ typeof o.payload.message === "string"
1543
+ ) {
1544
+ preview = o.payload.message.slice(0, 80)
1545
+ }
1546
+ if (cwd && preview) break // 両方揃ったら以降は読まない (先頭近傍で足りる)。
1547
+ }
1548
+ if (!thread_id) return null
1549
+ return { thread_id, cwd, preview }
1550
+ }
1551
+
1552
+ /**
1553
+ * cwd 配下の Codex thread 一覧を rollout の disk 走査だけで作る (app-server を spawn しない)。
1554
+ * 直近 lookbackDays の日付シャードから rollout-*.jsonl を集め、mtime 降順に並べ、header を
1555
+ * 読んで cwd 一致分を limit 件まで返す。返り値の shape は旧 listThreads と同一:
1556
+ * {thread_id, cwd, preview, updated_at_ms}[]
1557
+ *
1558
+ * ⚠️ rollout は「作成日」シャードに置かれ以後追記される。作成が lookbackDays より前で今日
1559
+ * resume された長寿命スレッドは窓外で拾えない (findCodexRolloutFile の高速パスと同じ限界)。
1560
+ * 一覧 (picker) 用途では直近が支配的なので許容。header 読み取り件数は maxScan で上限を掛け、
1561
+ * 打ち切ったら logger.warn で可視化する (silent truncation を避ける)。
1562
+ *
1563
+ * @returns {Promise<Array<{thread_id:string, cwd:string, preview:string, updated_at_ms:number}>>}
1564
+ */
1565
+ export async function listCodexThreadsFromDisk({
1566
+ cwd,
1567
+ limit,
1568
+ lookbackDays = 180,
1569
+ maxScan = 800,
1570
+ now = Date.now(),
1571
+ logger,
1572
+ sessionsDir,
1573
+ } = {}) {
1574
+ // cwd-scoped picker 用。cwd 未指定なら「全 workspace を返す」ではなく空を返す (cross-review
1575
+ // major3: 空 cwd で全 workspace の rollout が混入するのを防ぐ。呼び出し側 frontend は常に cwd を
1576
+ // 送る。全件走査が必要な用途は現状無い)。
1577
+ if (!cwd) return []
1578
+ // sessionsDir は self-describing read (Read-plane 案C ①) で呼び出し側が
1579
+ // 「リクエストの profile_id から解決した読み取り先」を明示注入する経路。
1580
+ // 未指定なら従来どおり env 由来の active (codexSessionsDir) にフォールバックする。
1581
+ const base = sessionsDir || codexSessionsDir()
1582
+ // 1. lookback 窓内の全 rollout を stat して {fp, mtimeMs} を集める。
1583
+ // ⚠️ updated_at_ms (= mtime) 降順の契約を守るには「どれが更新新しいか」を知る必要があり、
1584
+ // それには窓内の全 stat が要る (cross-review round2: 収集を作成日 shard 順で打ち切ると、
1585
+ // 作成が古く更新が新しい長寿命 rollout を落として mtime 降順に反する)。stat は 1 syscall で
1586
+ // 安価かつ lookbackDays で有界。高コストな header open+read の方を maxScan で上限化する。
1587
+ const candidates = [] // {fp, mtimeMs}
1588
+ for (let i = 0; i < lookbackDays; i++) {
1589
+ const d = new Date(now - i * 24 * 60 * 60 * 1000)
1590
+ const y = String(d.getFullYear())
1591
+ const m = String(d.getMonth() + 1).padStart(2, "0")
1592
+ const day = String(d.getDate()).padStart(2, "0")
1593
+ const dir = path.join(base, y, m, day)
1594
+ const files = await fs.readdir(dir).catch(() => null)
1595
+ if (!files) continue
1596
+ for (const f of files) {
1597
+ if (!f.startsWith("rollout-") || !f.endsWith(".jsonl")) continue
1598
+ const fp = path.join(dir, f)
1599
+ const st = await fs.stat(fp).catch(() => null)
1600
+ if (st) candidates.push({ fp, mtimeMs: st.mtimeMs })
1601
+ }
1602
+ }
1603
+ // 2. 更新が新しい順 (mtime desc)。
1604
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
1605
+ // 3. mtime 新しい順に header を読み、cwd 一致分を limit まで集める。header 読み (open+read) は
1606
+ // maxScan で上限化する。上限打ち切りは mtime 新しい側から読むので、直近のスレッドが優先的に残る。
1607
+ const out = []
1608
+ let opened = 0
1609
+ for (const { fp, mtimeMs } of candidates) {
1610
+ if (limit && out.length >= limit) break
1611
+ if (opened >= maxScan) break
1612
+ opened++
1613
+ const meta = await parseCodexRolloutHead(fp)
1614
+ if (!meta) continue
1615
+ // header の cwd が一致するものだけ (cwd 未読/不一致は除外 = 無関係スレッドの混入防止)。
1616
+ if (meta.cwd !== cwd) continue
1617
+ out.push({
1618
+ thread_id: meta.thread_id,
1619
+ cwd: meta.cwd,
1620
+ preview: meta.preview,
1621
+ updated_at_ms: mtimeMs,
1622
+ })
1623
+ }
1624
+ if (opened >= maxScan && (!limit || out.length < limit)) {
1625
+ logger?.warn?.(
1626
+ { opened, total: candidates.length, cwd, matched: out.length },
1627
+ "listCodexThreadsFromDisk: maxScan で header 走査を打ち切り (mtime が更に古い側の一部が一覧に出ない可能性)",
1628
+ )
1629
+ }
1630
+ return out
1631
+ }
1632
+
1452
1633
  /** P5(perf): rollout ファイルの token_count 抽出結果を mtime+size でメモ化。 */
1453
1634
  const _codexTokenCountMemo = new Map() // fp → { mtimeMs, size, result }
1454
1635
 
@@ -1,149 +0,0 @@
1
- /**
2
- * claude TUI (Claude Code 2.1.x) のペイン出力から、
3
- * 「直近のアシスタント説明テキスト段落」を保守的に抽出する。
4
- *
5
- * 目的: PreToolUse フック発火時点で jsonl はまだ前ターン止まりのため、フックの
6
- * transcript ベース context_text 抽出は前ターンの古い説明を掴む「取り違え」を起こす。
7
- * 一方、claude TUI のペインには「いま回答中の assistant 本文」が既に描画されている
8
- * ため、tmux capture-pane の結果からこれを抜き出して permission.request に同梱する
9
- * のが回答前に説明文をユーザーへ届ける唯一の経路。
10
- *
11
- * 出力フォーマット観察 (2026-06-24 実機 claude 2.1.x):
12
- * ⏺ <本文テキスト> ← アシスタント説明 (これを抽出したい)
13
- * ⏺ Bash(<cmd>) ← ツール呼び出し (除外したい)
14
- * ⏺ Read(...) ← ツール呼び出し
15
- * ⎿ <結果> ← ツール結果
16
- *
17
- * 抽出戦略: ペイン末尾から後ろ向き走査で「⏺ で始まり、続く語がツール名形式
18
- * (CamelCase + `(`) でない最初の行」を探し、そこから下方向に連続する「説明文の段落」
19
- * を取る。少しでも自信が無いケースは null 返却して呼び出し側で「説明なし」へ縮退。
20
- *
21
- * 安全方針: 抽出失敗 = null (壊さない)。誤抽出より null の方が UX 上安全。
22
- */
23
-
24
- const ASSIST_PREFIX = "⏺"
25
-
26
- const _MAX_CHARS = 2000
27
-
28
- const _MIN_CHARS = 6
29
-
30
- const _TOOL_CALL_RE = /^[A-Z][A-Za-z0-9_]*\s*\(/
31
-
32
- const _BOX_LINE_RE = /^[─-▟]/
33
-
34
- // 状態 / スピナー行。Claude 2.1.x は「考え中」スピナーに ✻ ✳ ✽ ✶ ✵ 等の星形グリフを
35
- // 循環使用する (例: "✻ Sautéed for 2s" / "✳ Frolicking… (3s · ↓ 34 tokens)")。説明段落に
36
- // 紛れ込ませないよう除外グリフを広く取る (旧版は ✨✹●⚫◯ のみで ✻✳✽ を取りこぼしていた)。
37
- const _STATUS_LINE_RE = /^[✨✹✺✻✷✸✹✳✲✱✽✶✵✴✦✧⋆●⚫◯◉]\s/
38
-
39
- const _ELLIPSIS_TAIL_RE = /\n[ \t]*…\s*\+\d+\s+lines[^\n]*$/
40
-
41
- // stop hook サマリ行。Claude 2.1.x は "Ran N stop hooks (ctrl+o to expand)" を
42
- // アシスタント本文と同じ ⏺ プレフィックス付きで描画するため、ツール呼び出しと同様に
43
- // 「説明段落の始点」候補から除外する (誤って始点にすると hook エラー出力を巻き込む)。
44
- const _STOP_HOOK_SUMMARY_RE = /^Ran\s+\d+\s+stop\s+hooks?\b/i
45
-
46
- // ツール結果 / フック結果の継続行 (⎿)。説明段落の途中で現れたら打ち切る。
47
- const _TOOL_RESULT_RE = /^\s*⎿/
48
-
49
- // 入力欄に映るユーザープロンプトのエコー (❯ + 本文)。メニュー選択肢 (❯ 1. ...) と
50
- // 空の入力欄 (❯ のみ) は除外する。これがある = ターン境界の目印として使う。
51
- const _INPUT_ECHO_RE = /^❯\s+(?!\d+\.\s)\S/
52
-
53
- /**
54
- * @param {string|undefined|null} paneText capturePane の出力 (ANSI 除去済み)
55
- * @returns {string|null} 直近のアシスタント説明 (失敗時 null)
56
- */
57
- export function extractLastAssistantText(paneText) {
58
- if (typeof paneText !== "string" || !paneText) return null
59
- const lines = paneText.replace(/\r/g, "").split("\n")
60
-
61
- let startIdx = -1
62
- for (let i = lines.length - 1; i >= 0; i--) {
63
- const line = lines[i]
64
- if (!line.startsWith(ASSIST_PREFIX + " ")) continue
65
- const rest = line.slice(ASSIST_PREFIX.length + 1).trimStart()
66
- if (_TOOL_CALL_RE.test(rest)) continue
67
- if (_STOP_HOOK_SUMMARY_RE.test(rest)) continue
68
- startIdx = i
69
- break
70
- }
71
- if (startIdx < 0) return null
72
-
73
- // 前ターン取り違え防止: startIdx より下 (= より新しい) に現ターンのユーザープロンプト
74
- // エコーがあるなら、その ⏺ は前ターンの説明である。現ターンはツールへ直行 (説明文なし) と
75
- // 判断し null へ縮退する (古い説明を出すより「説明なし」が安全。capture-pane が処理中の
76
- // ノイズだらけのペインを撮ったときに前ターン本文や hook 出力を掴むのを防ぐ)。
77
- for (let j = startIdx + 1; j < lines.length; j++) {
78
- if (_INPUT_ECHO_RE.test(lines[j])) return null
79
- }
80
-
81
- const parts = []
82
- let consecBlank = 0
83
- for (let i = startIdx; i < lines.length; i++) {
84
- const line = lines[i]
85
- if (i > startIdx) {
86
- if (line.startsWith(ASSIST_PREFIX + " ")) break
87
- if (_BOX_LINE_RE.test(line)) break
88
- if (_STATUS_LINE_RE.test(line.trim())) break
89
- if (_TOOL_RESULT_RE.test(line)) break
90
- if (_INPUT_ECHO_RE.test(line)) break
91
- }
92
- if (!line.trim()) {
93
- consecBlank++
94
- if (consecBlank >= 2) break
95
- parts.push(line)
96
- continue
97
- }
98
- consecBlank = 0
99
- parts.push(line)
100
- }
101
-
102
- let text = parts.join("\n").trimEnd()
103
- if (text.startsWith(ASSIST_PREFIX)) {
104
- text = text.slice(ASSIST_PREFIX.length).trimStart()
105
- }
106
- text = text.replace(_ELLIPSIS_TAIL_RE, "").trim()
107
- if (text.length < _MIN_CHARS) return null
108
-
109
- if (text.length > _MAX_CHARS) {
110
- text = "…" + text.slice(-_MAX_CHARS)
111
- }
112
- return text
113
- }
114
-
115
- /**
116
- * permission/質問カードに出す「直前アシスタント説明」の採用優先順位を決める純関数。
117
- *
118
- * 症状4 根治: フック (bundle) 由来の context_text は PreToolUse 発火時点で jsonl が
119
- * 前ターン止まり (フックがブロック中は claude が現ターンを commit しない=デッドロック)
120
- * のため「前ターンの古い説明」を掴む取り違えを起こす。一方 hub-agent が tmux
121
- * capture-pane で抜く liveContext は「いま描画中の現ターン本文」なので回答前に
122
- * 信頼できる唯一のソース。よって **liveContext を最優先**し、フック由来は後方互換の
123
- * フォールバックとしてのみ採用する (旧 bundle が context_text を送ってきても汚染しない)。
124
- * 両方とも空/非文字列なら null (= 説明なしで安全縮退)。
125
- *
126
- * @param {unknown} hookContextText フック (bundle) が同梱した context_text (旧経路)
127
- * @param {unknown} liveContext capture-pane から抽出した現ターン説明
128
- * @returns {string|null}
129
- */
130
- export function pickContextText(hookContextText, liveContext) {
131
- const live =
132
- typeof liveContext === "string" && liveContext.trim() ? liveContext : null
133
- const hook =
134
- typeof hookContextText === "string" && hookContextText.trim()
135
- ? hookContextText
136
- : null
137
- return live || hook || null
138
- }
139
-
140
- export const __test = {
141
- _MAX_CHARS,
142
- _MIN_CHARS,
143
- _TOOL_CALL_RE,
144
- _BOX_LINE_RE,
145
- _STATUS_LINE_RE,
146
- _STOP_HOOK_SUMMARY_RE,
147
- _TOOL_RESULT_RE,
148
- _INPUT_ECHO_RE,
149
- }
@@ -1,283 +0,0 @@
1
- /**
2
- * jsonl ライブ追従 watcher の束ね役 (T04784 逐次表示)。
3
- *
4
- * Cockpit の TUI チャットは「対話 TUI (tmux/PTY) の claude が書く jsonl」を表示源にする。
5
- * 従来は session.event (ターン境界) を合図に jsonl 全体を再 hydrate して「どさっと」更新
6
- * していたが、ターン中の途中経過は 2.5s ポーリングで拾うしかなく、逐次性が弱かった。
7
- *
8
- * 本 manager は、ブラウザが送る `claude.tui.viewing {session_id, cwd}` ハートビート
9
- * (= まさにその session を TUI チャットで閲覧している瞬間) を起点に、対象 session の
10
- * jsonl を byte offset で tail し (claude-history-watch.mjs)、追記行を 1 行ずつ
11
- * `claude.jsonl.event` として WS push する。これでターン中の本文・ツール結果が逐次
12
- * ブラウザに届き、再 hydrate / ポーリングが不要になる。
13
- *
14
- * ライフサイクルは watcher ゲートの TuiViewerRegistry と相似形:
15
- * - note(): 閲覧ハートビートで watcher を起動 or TTL 延長。
16
- * - unwatch(): claude.tui.unviewing で即停止。
17
- * - _sweep(): TTL 失効した watcher を掃除 (unviewing 取りこぼし対策)。
18
- *
19
- * 課金枠への影響なし: jsonl の読み取りはローカル FS の追従であり、モデル呼び出しを
20
- * 伴わない (SDK query を起こさない)。TUI モード = サブスク枠のまま。
21
- *
22
- * fromEnd=true で監視開始時点の既存内容はスキップする。初回履歴は claude.history.request
23
- * で別途 hydrate 済みのため、tail は新規分だけでよい (二重 push は frontend が uuid で排除)。
24
- */
25
-
26
- import { readFile } from "node:fs/promises"
27
-
28
- import { watchSessionFile } from "./claude-history-watch.mjs"
29
- import { jsonlPath } from "./claude-history.mjs"
30
- import {
31
- MASK_ENABLED,
32
- collectToolUseNames,
33
- createEventMasker,
34
- } from "./tool-result-mask.mjs"
35
-
36
- /** watcher 鮮度 (ms)。ブラウザのハートビート間隔 (5s) の 3 倍を既定とする。 */
37
- const WATCHER_TTL_MS = Number(process.env.COCKPIT_JSONL_WATCH_TTL_MS) || 15_000
38
- /** TTL 失効 watcher を掃除する周期 (ms)。 */
39
- const SWEEP_INTERVAL_MS = 30 * 1000
40
-
41
- /**
42
- * session_id ごとに 1 つの jsonl tail watcher を保持し、閲覧ハートビートで延命する。
43
- */
44
- export class JsonlLiveWatchers {
45
- /**
46
- * @param {object} args
47
- * @param {(obj: object) => unknown} args.send - WS 送信 (client.send バインド済み)
48
- * @param {() => Promise<string|undefined>|string|undefined} args.getProjectsRoot
49
- * - ~/.claude/projects の実効ルート解決 (アカウント切替を反映)
50
- * @param {number} [args.ttlMs]
51
- * @param {(info: {session_id: string, cwd: string, toolName: string, tool_use_id: string}) => void} [args.onToolResolved]
52
- * - jsonl に tool_result が着弾し「あるツールが解決された」と検知したときのコールバック
53
- * (症状1b 根治: ターミナルのネイティブメニューで回答 / 承認された pending カードを畳む)。
54
- * @param {import('pino').Logger} [args.logger]
55
- */
56
- constructor({
57
- send,
58
- getProjectsRoot,
59
- ttlMs = WATCHER_TTL_MS,
60
- onToolResolved,
61
- logger,
62
- } = {}) {
63
- this.send = send
64
- this.getProjectsRoot = getProjectsRoot
65
- this.ttlMs = ttlMs
66
- this.onToolResolved = onToolResolved
67
- this.logger = logger
68
- /** @type {Map<string, {watcher: {stop: () => void}, cwd: string, expiresAt: number}>} */
69
- this._entries = new Map()
70
- this._sweepTimer = null
71
- /** 同一 session に対する note() の競合を直列化する (二重 watcher 防止)。 */
72
- this._starting = new Set()
73
- }
74
-
75
- /** 周期 sweep を張る。 */
76
- start() {
77
- this._sweepTimer = setInterval(() => this._sweep(), SWEEP_INTERVAL_MS)
78
- // 他 timer 同様 unref して agent のアイドル終了を妨げない。
79
- this._sweepTimer?.unref?.()
80
- }
81
-
82
- /**
83
- * 閲覧ハートビートを受けて watcher を起動 or 延命する。
84
- *
85
- * @param {{session_id?: string|null, cwd?: string|null}} info
86
- * @returns {Promise<void>}
87
- */
88
- async note({ session_id, cwd } = {}) {
89
- if (!session_id || !cwd) return
90
- const existing = this._entries.get(session_id)
91
- if (existing) {
92
- // 既に追従中: TTL を延長するだけ (cwd が変わるのは想定外なので無視)。
93
- existing.expiresAt = Date.now() + this.ttlMs
94
- return
95
- }
96
- // 起動中の重複呼び出しを弾く (ハートビートが連続して来ても watcher は 1 本)。
97
- if (this._starting.has(session_id)) return
98
- this._starting.add(session_id)
99
- try {
100
- const projectsRoot = await this._resolveProjectsRoot()
101
- // 起動処理中に別経路で登録済みになっていないか再チェック。
102
- if (this._entries.has(session_id)) return
103
- const filePath = jsonlPath({ cwd, session_id, projectsRoot })
104
- // マスク (2026-06-23): TUI ライブ閲覧で逐次 push する tool_result の秘匿情報 / 非公開
105
- // スキルプロンプト本文を伏せる。この経路は SDK チャットブリッジ (_emit) とは別系統で、
106
- // フックを通さず jsonl を直接 tail するため、ここで独自にマスクしないと素通しになる。
107
- // tail は fromEnd=true で監視開始後の追記のみを拾うので、開始前に書かれた tool_use の
108
- // id→name を既存 jsonl から事前シードし、スキル tool_result の判定取りこぼしを防ぐ。
109
- // tool_use(id→name) の事前シード。マスカーとネイティブ回答検知が共用する
110
- // (tail は fromEnd=true で開始後の追記のみ拾うため、開始前の tool_use 名を先に流し込む)。
111
- const detect = typeof this.onToolResolved === "function"
112
- const seed =
113
- MASK_ENABLED || detect ? await this._seedToolNames(filePath) : new Map()
114
- const masker = MASK_ENABLED ? createEventMasker(seed) : null
115
- // 検知用は別 Map (マスカー内部 Map と相互に汚染しないようコピー)。
116
- const detectNames = detect ? new Map(seed) : null
117
- const watcher = watchSessionFile({
118
- filePath,
119
- fromEnd: true,
120
- logger: this.logger,
121
- onEvent: (event) => {
122
- // ネイティブ回答検知 (症状1b 根治): browser を経由せずターミナルのネイティブメニューで
123
- // 回答 / 承認されたケースを jsonl の tool_result 着弾で検知する。.decision は browser
124
- // 回答でしか書かれずネイティブ回答には効かないため、jsonl tool_result が唯一の権威
125
- // ソース (resume が読む実体と同じ)。マスク前の素 event を見る (構造のみ参照)。
126
- if (detectNames) {
127
- try {
128
- this._detectResolved(event, detectNames, session_id, cwd)
129
- } catch (err) {
130
- this.logger?.debug?.(
131
- { err: err?.message },
132
- "jsonl tool_result detect failed",
133
- )
134
- }
135
- }
136
- try {
137
- this.send?.({
138
- type: "claude.jsonl.event",
139
- session_id,
140
- cwd,
141
- event: masker ? masker(event) : event,
142
- })
143
- } catch (err) {
144
- this.logger?.warn(
145
- { err: err?.message },
146
- "jsonl live watch send failed",
147
- )
148
- }
149
- },
150
- })
151
- this._entries.set(session_id, {
152
- watcher,
153
- cwd,
154
- expiresAt: Date.now() + this.ttlMs,
155
- })
156
- } catch (err) {
157
- this.logger?.warn(
158
- { err: err?.message, session_id },
159
- "jsonl live watch start failed",
160
- )
161
- } finally {
162
- this._starting.delete(session_id)
163
- }
164
- }
165
-
166
- /**
167
- * 閲覧終了を受けて watcher を即停止する。
168
- *
169
- * @param {{session_id?: string|null}} info
170
- */
171
- unwatch({ session_id } = {}) {
172
- if (!session_id) return
173
- const entry = this._entries.get(session_id)
174
- if (!entry) return
175
- this._entries.delete(session_id)
176
- try {
177
- entry.watcher.stop()
178
- } catch {
179
- /* ignore */
180
- }
181
- }
182
-
183
- /**
184
- * 既存 jsonl を 1 度だけ読み、tool_use_id→name を集めた Map を返す (マスカーのシード用)。
185
- * 読めない / 未存在なら空 Map。watcher 起動の冷経路で 1 回だけ走るので全文読みでよい。
186
- * @param {string} filePath
187
- * @returns {Promise<Map<string,string>>}
188
- */
189
- async _seedToolNames(filePath) {
190
- const seed = new Map()
191
- try {
192
- const text = await readFile(filePath, "utf-8")
193
- for (const line of text.split("\n")) {
194
- if (!line || !line.includes('"tool_use"')) continue
195
- try {
196
- collectToolUseNames(JSON.parse(line), seed)
197
- } catch {
198
- /* ignore broken line */
199
- }
200
- }
201
- } catch {
202
- /* ファイル未存在等: シードなしで続行 */
203
- }
204
- return seed
205
- }
206
-
207
- /**
208
- * 1 イベントを走査し、(1) assistant の tool_use(id→name) を蓄積、(2) user の tool_result
209
- * 着弾を検知して onToolResolved を発火する。tool_use と tool_result は別イベントで届くので、
210
- * id→name を貯めながら tool_use_id 一致で「そのツールが解決された」と判定する
211
- * (collectToolUseNames はマスカーと共用の純関数)。
212
- *
213
- * @param {object} event normalizeHistoryEvent 済みイベント (type + message を持つ)
214
- * @param {Map<string,string>} toolNames tool_use_id→tool_name の蓄積 Map
215
- * @param {string} session_id
216
- * @param {string} cwd
217
- */
218
- _detectResolved(event, toolNames, session_id, cwd) {
219
- // 新規 tool_use(id→name) を吸収する (assistant イベントのみ反応)。
220
- collectToolUseNames(event, toolNames)
221
- if (event?.type !== "user" || !Array.isArray(event.message?.content)) return
222
- for (const b of event.message.content) {
223
- if (b && b.type === "tool_result" && typeof b.tool_use_id === "string") {
224
- const toolName = toolNames.get(b.tool_use_id)
225
- if (toolName) {
226
- this.onToolResolved?.({
227
- session_id,
228
- cwd,
229
- toolName,
230
- tool_use_id: b.tool_use_id,
231
- })
232
- }
233
- }
234
- }
235
- }
236
-
237
- async _resolveProjectsRoot() {
238
- try {
239
- const r = this.getProjectsRoot?.()
240
- return r && typeof r.then === "function" ? await r : r
241
- } catch {
242
- return undefined
243
- }
244
- }
245
-
246
- /** TTL を過ぎた watcher を停止・削除する (unviewing 取りこぼし対策)。 */
247
- _sweep() {
248
- const now = Date.now()
249
- for (const [session_id, entry] of this._entries) {
250
- if (entry.expiresAt <= now) {
251
- this._entries.delete(session_id)
252
- try {
253
- entry.watcher.stop()
254
- } catch {
255
- /* ignore */
256
- }
257
- }
258
- }
259
- }
260
-
261
- /** 全 watcher と sweep timer を停止する。 */
262
- stop() {
263
- if (this._sweepTimer) {
264
- clearInterval(this._sweepTimer)
265
- this._sweepTimer = null
266
- }
267
- for (const entry of this._entries.values()) {
268
- try {
269
- entry.watcher.stop()
270
- } catch {
271
- /* ignore */
272
- }
273
- }
274
- this._entries.clear()
275
- }
276
-
277
- /** テスト用: 現在追従中の session_id 数。 */
278
- get size() {
279
- return this._entries.size
280
- }
281
- }
282
-
283
- export default JsonlLiveWatchers