@cocorograph/hub-agent 0.7.6 → 0.7.8

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.7.6",
3
+ "version": "0.7.8",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -17,6 +17,13 @@ import { open, readFile, readdir, stat } from "node:fs/promises"
17
17
  import os from "node:os"
18
18
  import path from "node:path"
19
19
 
20
+ import {
21
+ MASK_ENABLED,
22
+ collectToolUseNames,
23
+ maskMessageObject,
24
+ redactSecrets,
25
+ } from "./tool-result-mask.mjs"
26
+
20
27
  export const MAX_HISTORY_LINES = 500
21
28
 
22
29
  /** UI 表示対象の SDK message type (それ以外は jsonl 内部メタなので除外)。 */
@@ -106,6 +113,20 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
106
113
  const truncated = total_lines > maxLines
107
114
  const slice = truncated ? lines.slice(-maxLines) : lines
108
115
 
116
+ // マスク (2026-06-23): tool_use 行が表示スライス外へ truncate されても tool_result の
117
+ // スキル判定が効くよう、全行を事前走査して tool_use_id→name を集めておく。
118
+ const toolNames = new Map()
119
+ if (MASK_ENABLED) {
120
+ for (const line of lines) {
121
+ if (!line.includes('"tool_use"')) continue
122
+ try {
123
+ collectToolUseNames(JSON.parse(line), toolNames)
124
+ } catch {
125
+ /* ignore */
126
+ }
127
+ }
128
+ }
129
+
109
130
  const events = []
110
131
  for (const line of slice) {
111
132
  let obj
@@ -117,7 +138,10 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
117
138
  if (!obj || typeof obj !== "object") continue
118
139
  if (!DISPLAY_TYPES.has(obj.type)) continue
119
140
  // SDK message と同じ shape にする (余分な meta は落とす)。整形は共通関数に集約。
120
- events.push(normalizeHistoryEvent(obj))
141
+ let event = normalizeHistoryEvent(obj)
142
+ // 秘匿情報 / 非公開スキルプロンプト本文を hydrate 時にも伏せる (古いセッション再表示対策)。
143
+ if (MASK_ENABLED) event = maskMessageObject(event, toolNames)
144
+ events.push(event)
121
145
  }
122
146
  return { events, total_lines, truncated }
123
147
  }
@@ -226,7 +250,8 @@ async function extractPreview(filePath) {
226
250
  if (textBlock) str = textBlock.text
227
251
  }
228
252
  const preview = normalizePreviewText(str)
229
- if (preview) return preview
253
+ // 一覧の preview にユーザーが貼った秘匿情報が出ないよう伏せる。
254
+ if (preview) return MASK_ENABLED ? redactSecrets(preview) : preview
230
255
  }
231
256
  }
232
257
  return ""
@@ -26,6 +26,11 @@ import path from "node:path"
26
26
 
27
27
  import { jsonlPath } from "./claude-history.mjs"
28
28
  import { watchSessionFile } from "./claude-history-watch.mjs"
29
+ import {
30
+ MASK_ENABLED,
31
+ createEventMasker,
32
+ maskJsonlFileAtRest,
33
+ } from "./tool-result-mask.mjs"
29
34
 
30
35
  /** browser 切断後、セッションを生かしたまま再接続を待つ idle TTL (ミリ秒)。これを過ぎ、
31
36
  * かつ走行中でなければ撤去する。端末スリープ/長期離席後の再接続も吸収できるよう 7 日に
@@ -305,10 +310,62 @@ class ClaudeStreamSession {
305
310
  this._watcher = null
306
311
  /** watcher が監視中の session_id (変化時に張り替え) */
307
312
  this._watchedSessionId = null
313
+
314
+ /** マスク (2026-06-23): ライブ stream / watch 用のステートフル masker。tool_use_id→name を
315
+ * 蓄積し、tool_result の秘匿情報 / 非公開スキルプロンプト本文を表示・配信前に伏せる。
316
+ * モデル消費後のコピーに作用するためスキル実行は壊れない。 */
317
+ this._eventMasker = createEventMasker()
318
+ /** オンディスク jsonl をマスク済みか (close 時の二重書き換えを防ぐ冪等ガード)。 */
319
+ this._jsonlMasked = false
320
+
308
321
  // resume セッションがあれば即 watch 開始 (閲覧中の外部進行をライブ反映)
309
322
  if (this.sessionId) this._ensureWatch()
310
323
  }
311
324
 
325
+ /** マスク済みイベントを onEvent へ流す共通経路。秘匿情報 / 非公開スキルプロンプト本文を
326
+ * 表示・WS 配信前に伏せる。マスク失敗時は素のイベントを流す (表示が止まるより素通し優先)。 */
327
+ _emit(event) {
328
+ if (!this.onEvent) return
329
+ let out = event
330
+ if (MASK_ENABLED) {
331
+ try {
332
+ out = this._eventMasker(event)
333
+ } catch (err) {
334
+ this.logger?.warn(
335
+ { err: err?.message, stream_id: this.stream_id },
336
+ "event mask failed",
337
+ )
338
+ out = event
339
+ }
340
+ }
341
+ this.onEvent(out)
342
+ }
343
+
344
+ /** セッション終了時にオンディスク jsonl をマスク書き換えする (冪等)。Claude CLI が当該
345
+ * ファイルを掴んでいない「終了後」にのみ呼ぶこと (ライブ追記中の書き換えは会話本体を壊す)。
346
+ * display は _emit で常時マスク済みのため、これは at-rest ファイルの掃除専用。 */
347
+ _maskJsonlAtRest() {
348
+ if (this._jsonlMasked || !MASK_ENABLED) return
349
+ if (!this.sessionId || !this.cwd) return
350
+ this._jsonlMasked = true
351
+ const filePath = jsonlPath({ cwd: this.cwd, session_id: this.sessionId })
352
+ maskJsonlFileAtRest(filePath, { logger: this.logger })
353
+ .then((changed) => {
354
+ if (changed) {
355
+ this.logger?.info(
356
+ { stream_id: this.stream_id, session_id: this.sessionId },
357
+ "jsonl masked at rest",
358
+ )
359
+ }
360
+ })
361
+ .catch((err) =>
362
+ this.logger?.warn(
363
+ { err: err?.message, stream_id: this.stream_id },
364
+ "mask jsonl at rest threw",
365
+ ),
366
+ )
367
+ }
368
+
312
369
  /** 現在の sessionId の jsonl を watch する (既に同じものを watch 中なら何もしない)。 */
313
370
  _ensureWatch() {
314
371
  if (this._closed || !this.sessionId || !this.cwd) return
@@ -333,7 +390,7 @@ class ClaudeStreamSession {
333
390
  // (二重表示防止)。ターン完了時に skipToEnd で読み飛ばす。
334
391
  if (this._busy) return
335
392
  try {
336
- this.onEvent?.(event)
393
+ this._emit(event)
337
394
  } catch (err) {
338
395
  this.logger?.warn(
339
396
  { err: err.message, stream_id: this.stream_id },
@@ -690,7 +747,7 @@ class ClaudeStreamSession {
690
747
  if (typeof msg.session_id === "string") this.sessionId = msg.session_id
691
748
  }
692
749
  try {
693
- this.onEvent?.(msg)
750
+ this._emit(msg)
694
751
  } catch (err) {
695
752
  this.logger?.warn(
696
753
  { err: err.message, stream_id: this.stream_id },
@@ -764,6 +821,10 @@ class ClaudeStreamSession {
764
821
  // 停止 (abort) 後も温存したキューはそのまま流す。
765
822
  this._drainPending()
766
823
  }
824
+ // マスク (2026-06-23): 走行中に外部 close されたターンの後始末。ターン完走後 (= CLI が
825
+ // jsonl を掴んでいない) のこの地点で、closed 済みならオンディスク jsonl をマスクする。
826
+ // reap 経路は上の close() 内で既にマスク済み (冪等ガードで二重実行されない)。
827
+ if (this._closed) this._maskJsonlAtRest()
767
828
  }
768
829
  }
769
830
 
@@ -978,7 +1039,7 @@ class ClaudeStreamSession {
978
1039
  )
979
1040
  }
980
1041
  try {
981
- this.onEvent?.(msg)
1042
+ this._emit(msg)
982
1043
  } catch (err) {
983
1044
  this.logger?.warn(
984
1045
  { err: err.message, stream_id: this.stream_id },
@@ -1028,6 +1089,9 @@ class ClaudeStreamSession {
1028
1089
  this._inputQueue.push(toSDKUserMessage(next.text))
1029
1090
  this._emitQueueState([next.text])
1030
1091
  }
1092
+ // マスク (2026-06-23): 常駐 query 終了後 (= CLI が jsonl を掴んでいない) のこの地点で、
1093
+ // closed 済みならオンディスク jsonl をマスクする。reap 経路は close() 内で実行済み。
1094
+ if (this._closed) this._maskJsonlAtRest()
1031
1095
  }
1032
1096
  }
1033
1097
 
@@ -1105,6 +1169,10 @@ class ClaudeStreamSession {
1105
1169
  }
1106
1170
  }
1107
1171
  this._permissionResolvers.clear()
1172
+ // マスク (2026-06-23): アイドル close (走行ターン無し = CLI が jsonl を掴んでいない) は
1173
+ // ここで即マスクする。走行中 close (abortTurn 経由) は CLI 終了を待つため run-loop の
1174
+ // finally 側でマスクする (上の _maskJsonlAtRest 冪等ガードで二重実行は防がれる)。
1175
+ if (!this._busy) this._maskJsonlAtRest()
1108
1176
  }
1109
1177
  }
1110
1178
 
package/src/main.mjs CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  detectSessionState,
40
40
  invalidateSessionCache,
41
41
  listSessionStates,
42
+ resolveBindSnapshotStatus,
42
43
  StallTracker,
43
44
  } from "./state.mjs"
44
45
  import {
@@ -2335,25 +2336,25 @@ async function dispatch(msg, ctx) {
2335
2336
  noCache: true,
2336
2337
  logger,
2337
2338
  })
2338
- // 症状C対策: 生成中(armed / pane=processing で判定済みの generating)に TUIビューを
2339
- // 再マウントすると、frontend は全 ref/state がリセットされ、capture 再描画ギャップで
2340
- // この noCache スナップショットの status が一瞬 'waiting' に化けると「生成中なのに
2341
- // 三点リーダーが消える」偽陰性になり、shouldQueue=false でメッセージが PTY(TUI
2342
- // ネイティブキュー)へ流入する。generating を真として status='processing' を配ることで
2343
- // capture 誤読を masking し、frontend の再点灯(turnActive)と shouldQueue 第1ゲートを
2344
- // 即座に効かせる。proc_busy は配らない: outputActive(出力残り火)を含むため、ターン
2345
- // 終了直後の真 idle セッションでも proc_busy=true となり高速消灯(症状B)を再発させる。
2346
- // 終了後(非 generating)は snap.status(waiting)で従来どおり高速消灯する。
2339
+ // 【問題1 根治 2026-06-22】配る status はプロセス実体の busy を権威に解決する
2340
+ // (resolveBindSnapshotStatus)。capture(SPINNER_LINE_RE)の偽陽性に勝たせる。
2341
+ // - busy=true (ターン進行中): capture gap で snap.status waiting に化けても
2342
+ // processing を強制 = 症状C(再マウントで生成中の三点リーダーが消える偽陰性)の masking。
2343
+ // - busy=false (プロセス実体 idle): snap.status processing でも、それは静的完了行の
2344
+ // 偽陽性(無生成時の誤点灯=問題1)なので processing を配らず waiting へ降格する。bind
2345
+ // noCache 単発読みで freeze 履歴が無く偽陽性を弾けないため、ここで busy を権威にする。
2346
+ // generating(capture 由来)でなく busy(isArmed + 子孫数>baseline)を使うのが肝。
2347
+ // proc_busy は配らない: outputActive(出力残り火)を含み真 idle でも true になり高速消灯
2348
+ // (症状B)を再発させるため。
2349
+ const snapStatus = resolveBindSnapshotStatus(busy, snap.status)
2347
2350
  ctx.client.send({
2348
2351
  type: "session.state",
2349
2352
  session_name: sessionName,
2350
- status: generating ? "processing" : snap.status,
2353
+ status: snapStatus,
2351
2354
  context_pct: snap.context_pct,
2352
2355
  permission_mode: snap.permission_mode,
2353
2356
  stable:
2354
- !generating &&
2355
- snap.status !== "processing" &&
2356
- snap.stable === true,
2357
+ !busy && snapStatus !== "processing" && snap.stable === true,
2357
2358
  })
2358
2359
  } catch (err) {
2359
2360
  logger?.warn(
package/src/state.mjs CHANGED
@@ -243,8 +243,13 @@ function workingSpinnerLine(text) {
243
243
  * 不変」なら凍結とみなす。閾値は capture キャッシュ TTL (2.5s) と state loop 周期 (5s) を跨ぐ
244
244
  * 6s に取り、ライブタイマーの 1 サンプル取りこぼしでは誤判定しないようにする。
245
245
  */
246
+ // 静的な完了行 (● Updated…/⏺ Ran tool (2s) 等、ターン終了後もアイドル画面に残る) を
247
+ // SPINNER_LINE_RE が誤検出して processing に張り付く「無生成時の三点リーダー誤点灯」(問題1) の
248
+ // 滞留時間を短縮するため 6000→3500 に下げた (2026-06-22)。実スピナーは経過秒 (Ns) かグリフ
249
+ // アニメで毎秒変化するため frozen には絶対ならず、閾値短縮で偽陰性(生成中の消失)は再発しない
250
+ // (3500ms ≫ 1s アニメ周期で十分な余裕)。env で上書き可。
246
251
  const SPINNER_FREEZE_CONFIRM_MS = Number(
247
- process.env.HUB_AGENT_SPINNER_FREEZE_MS ?? 6000,
252
+ process.env.HUB_AGENT_SPINNER_FREEZE_MS ?? 3500,
248
253
  )
249
254
  /** @type {Map<string, {line: string, at: number}>} session名 → 最初にその行を見た時刻 */
250
255
  const _spinnerFreezeByName = new Map()
@@ -277,6 +282,29 @@ export function detectStatusFromText(text) {
277
282
  return "idle"
278
283
  }
279
284
 
285
+ /**
286
+ * 【問題1 根治 2026-06-22】bind 時の即時ステータススナップショットで配る status を、
287
+ * プロセス実体の busy を権威として解決する。capture (SPINNER_LINE_RE) の偽陽性に勝たせる。
288
+ *
289
+ * - busy=true (isArmed or pane_pid 子孫数 > baseline = ターン進行中): capture gap で
290
+ * capturedStatus が waiting に化けても processing を強制 (症状C=再マウントで生成中の三点
291
+ * リーダーが消える偽陰性の masking)。
292
+ * - busy=false (プロセス実体 idle): capturedStatus が processing でも、それは静的完了行の
293
+ * SPINNER_LINE_RE 偽陽性であって生成中ではないので processing を配らない (= waiting へ降格)。
294
+ * bind は noCache 単発読みで freeze 履歴が無く偽陽性を弾けないため、ここで busy を権威にする。
295
+ * 万一 no-hook セッションの無音生成中だった場合も、次の state loop tick(≤5s)が outputActive で
296
+ * processing に復帰させるので実害は限定的。
297
+ *
298
+ * @param {boolean} busy プロセス実体の busy (isArmed or 子孫数>baseline)
299
+ * @param {string} capturedStatus detectSessionState の status
300
+ * @returns {string}
301
+ */
302
+ export function resolveBindSnapshotStatus(busy, capturedStatus) {
303
+ if (busy) return "processing"
304
+ if (capturedStatus === "processing") return "waiting"
305
+ return capturedStatus
306
+ }
307
+
280
308
  export function detectContextPctFromText(text) {
281
309
  for (const re of CONTEXT_PATTERNS) {
282
310
  const m = text.match(re)
@@ -0,0 +1,319 @@
1
+ /**
2
+ * ツールリザルト・メッセージ本文のマスク (秘匿情報 + 非公開スキルプロンプト本文)。
3
+ *
4
+ * 背景 (2026-06-23): Cockpit TUI チャットモードは hub-agent が Claude Agent SDK の
5
+ * stream を中継してブラウザに表示する。ターミナル版 Claude Code が持つ「シークレット
6
+ * 自動伏せ字」は **ハーネス側の機能** で、この独自経路には継承されていなかったため、
7
+ * tool_result に含まれる API キー / トークン / パスワード等が素のまま画面・WS 配信・
8
+ * jsonl へ流れていた。さらに Hub ナレッジに格納した **非公開スキルプロンプト本文**
9
+ * (`get_skill_prompt` MCP の戻り値) も毎回リザルトに露出していた。
10
+ *
11
+ * 本モジュールは「表示・配信・記録されるコピー」だけをマスクする。モデルが実際に受け取る
12
+ * 入力は SDK stream の上流 (Claude CLI) で確定しており、hub-agent に届くのは **モデルが
13
+ * 消費した後** のイベントなので、ここでマスクしてもモデルの動作・スキル実行は壊れない
14
+ * (詳細は claude-stream-bridge.mjs の emit 経路コメント参照)。
15
+ *
16
+ * 2 種類のマスク:
17
+ * - 秘匿情報: 正規表現で既知トークン形 / sensitive な KEY=VALUE 代入を伏せ字化する。
18
+ * モデルは元々不要なので、オンディスク jsonl をマスクしても resume は無傷。
19
+ * - スキルプロンプト本文: `get_skill_prompt` (MCP 名前空間込み) の tool_result 本文を
20
+ * まるごとプレースホルダへ置換する。tool_use → tool_result の id 対応が必要なため、
21
+ * stream にはステートフルな masker (createEventMasker)、jsonl 一括には 1 パスの
22
+ * maskJsonlText を用意する。
23
+ *
24
+ * ⚠️ 限定事項 (v1): includePartialMessages の **部分デルタ (stream_event)** はマスク
25
+ * しない。秘匿情報の主出所である tool_result / .env ダンプは完成済みメッセージとして
26
+ * 届くためここで捕捉でき、最終メッセージ・jsonl・hydrate も全てマスクされる。アシスタント
27
+ * が逐次生成する途中テキストにシークレットが現れる確率は低いと判断し v1 では対象外。
28
+ */
29
+ import { readFile, rename, rm, stat, utimes, writeFile } from "node:fs/promises"
30
+
31
+ /** env HUB_AGENT_TOOL_RESULT_MASK="0" で無効化 (既定 ON)。誤マスク時の即時ロールバック用。 */
32
+ export const MASK_ENABLED = process.env.HUB_AGENT_TOOL_RESULT_MASK !== "0"
33
+
34
+ /** 秘匿情報の置換マーカー (grep しやすい固定文字列)。 */
35
+ export const SECRET_PLACEHOLDER = "[REDACTED]"
36
+
37
+ /** スキルプロンプト本文の置換マーカー。再取得可能であることを明示する。 */
38
+ export const SKILL_PROMPT_PLACEHOLDER =
39
+ "[get_skill_prompt の本文はマスクされています(必要なら再取得できます)]"
40
+
41
+ /**
42
+ * 既知の秘匿情報パターン (誤検知を抑えるため、形が明確なトークンに限定する)。
43
+ * 各要素は global フラグ付き RegExp。replace で SECRET_PLACEHOLDER に置換する。
44
+ */
45
+ const SECRET_PATTERNS = [
46
+ // PEM 秘密鍵ブロック (複数行)。RSA/EC/OPENSSH/DSA/PGP の修飾子に対応。
47
+ /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,
48
+ // Anthropic / OpenAI 系 API キー (sk-ant-... を先に置換するため先頭に)。
49
+ /sk-ant-[A-Za-z0-9_-]{20,}/g,
50
+ /sk-[A-Za-z0-9]{20,}/g,
51
+ // AWS アクセスキー ID。
52
+ /\bAKIA[0-9A-Z]{16}\b/g,
53
+ // GitHub トークン (ghp_/gho_/ghu_/ghs_/ghr_ と fine-grained PAT)。
54
+ /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
55
+ /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
56
+ // Slack トークン。
57
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
58
+ // Google API キー (固定長 35 のため末尾境界は課さない)。
59
+ /\bAIza[0-9A-Za-z_-]{35}/g,
60
+ // JWT (header.payload.signature)。
61
+ /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
62
+ // Authorization ヘッダ (Bearer / Basic)。値だけでなくスキームごと伏せる。
63
+ /\bBearer\s+[A-Za-z0-9._~+/-]{12,}=*/gi,
64
+ /\bBasic\s+[A-Za-z0-9+/]{12,}=*/g,
65
+ ]
66
+
67
+ /**
68
+ * sensitive な名前の KEY=VALUE / KEY: VALUE 代入を伏せ字化する。キー名は残し値だけ消す
69
+ * (例: `DB_PASSWORD=hunter2` → `DB_PASSWORD=[REDACTED]`)。.env ダンプ / 環境変数出力対策。
70
+ * 値は 4 文字以上のとき対象 (短い値の誤検知回避)。引用符は維持する。
71
+ */
72
+ const ASSIGN_PATTERN =
73
+ /\b([A-Za-z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CLIENT[_-]?SECRET|AUTH[_-]?TOKEN)[A-Za-z0-9_]*)(\s*[=:]\s*)(['"]?)([^\s'"]{4,})\3/gi
74
+
75
+ /**
76
+ * 文字列中の秘匿情報を伏せ字化する。マスク対象が無ければ同一文字列をそのまま返す。
77
+ * @param {string} text
78
+ * @returns {string}
79
+ */
80
+ export function redactSecrets(text) {
81
+ if (typeof text !== "string" || text.length === 0) return text
82
+ let out = text
83
+ for (const re of SECRET_PATTERNS) {
84
+ out = out.replace(re, SECRET_PLACEHOLDER)
85
+ }
86
+ out = out.replace(
87
+ ASSIGN_PATTERN,
88
+ (_m, key, sep, quote, _val) => `${key}${sep}${quote}${SECRET_PLACEHOLDER}${quote}`,
89
+ )
90
+ return out
91
+ }
92
+
93
+ /**
94
+ * tool 名が非公開スキルプロンプト取得ツールか判定する。MCP 名前空間が付くため
95
+ * (`mcp__hub__get_skill_prompt` / `mcp__hub_staff__get_skill_prompt`)、末尾一致でも拾う。
96
+ * @param {unknown} name
97
+ * @returns {boolean}
98
+ */
99
+ export function isSkillPromptTool(name) {
100
+ return (
101
+ typeof name === "string" &&
102
+ (name === "get_skill_prompt" || name.endsWith("__get_skill_prompt"))
103
+ )
104
+ }
105
+
106
+ /** tool_result の content (string | block[]) をプレースホルダ 1 本へ潰す。 */
107
+ function skillPlaceholderContent() {
108
+ return [{ type: "text", text: SKILL_PROMPT_PLACEHOLDER }]
109
+ }
110
+
111
+ /** content blocks 配列の text を redactSecrets で処理した新配列を返す (非破壊)。 */
112
+ function redactBlocks(blocks) {
113
+ if (!Array.isArray(blocks)) return blocks
114
+ return blocks.map((b) => {
115
+ if (b && b.type === "text" && typeof b.text === "string") {
116
+ const masked = redactSecrets(b.text)
117
+ return masked === b.text ? b : { ...b, text: masked }
118
+ }
119
+ return b
120
+ })
121
+ }
122
+
123
+ /**
124
+ * SDK message 形 (jsonl 1 行 / live event) のオブジェクトをマスクした **コピー** を返す。
125
+ * 変更が無ければ元オブジェクトをそのまま返す (参照同一で「変化なし」を呼び出し側が判定可)。
126
+ *
127
+ * @param {Record<string, unknown>} obj type: 'assistant' | 'user' | ... を持つメッセージ
128
+ * @param {Map<string,string>} toolNames tool_use_id → tool 名。assistant を通すたびに更新する。
129
+ * @returns {Record<string, unknown>}
130
+ */
131
+ export function maskMessageObject(obj, toolNames) {
132
+ if (!obj || typeof obj !== "object") return obj
133
+ const message = obj.message
134
+ if (!message || typeof message !== "object") return obj
135
+ const content = message.content
136
+
137
+ // assistant: tool_use の id→name を記録しつつ text ブロックの秘匿情報を伏せる。
138
+ if (obj.type === "assistant") {
139
+ if (Array.isArray(content)) {
140
+ for (const b of content) {
141
+ if (b && b.type === "tool_use" && typeof b.id === "string") {
142
+ toolNames.set(b.id, b.name)
143
+ }
144
+ }
145
+ const masked = redactBlocks(content)
146
+ if (masked !== content && masked.some((b, i) => b !== content[i])) {
147
+ return { ...obj, message: { ...message, content: masked } }
148
+ }
149
+ } else if (typeof content === "string") {
150
+ const masked = redactSecrets(content)
151
+ if (masked !== content) {
152
+ return { ...obj, message: { ...message, content: masked } }
153
+ }
154
+ }
155
+ return obj
156
+ }
157
+
158
+ // user: tool_result をスキル判定 or 秘匿情報マスク。素のテキスト発言も秘匿情報を伏せる。
159
+ if (obj.type === "user") {
160
+ if (Array.isArray(content)) {
161
+ let changed = false
162
+ const next = content.map((b) => {
163
+ if (b && b.type === "tool_result") {
164
+ const name = toolNames.get(b.tool_use_id)
165
+ if (isSkillPromptTool(name)) {
166
+ changed = true
167
+ return { ...b, content: skillPlaceholderContent() }
168
+ }
169
+ // 非スキル tool_result: 中身 (string | block[]) の秘匿情報を伏せる。
170
+ if (typeof b.content === "string") {
171
+ const masked = redactSecrets(b.content)
172
+ if (masked !== b.content) {
173
+ changed = true
174
+ return { ...b, content: masked }
175
+ }
176
+ return b
177
+ }
178
+ if (Array.isArray(b.content)) {
179
+ const mc = redactBlocks(b.content)
180
+ if (mc.some((x, i) => x !== b.content[i])) {
181
+ changed = true
182
+ return { ...b, content: mc }
183
+ }
184
+ return b
185
+ }
186
+ return b
187
+ }
188
+ if (b && b.type === "text" && typeof b.text === "string") {
189
+ const masked = redactSecrets(b.text)
190
+ if (masked !== b.text) {
191
+ changed = true
192
+ return { ...b, text: masked }
193
+ }
194
+ }
195
+ return b
196
+ })
197
+ if (changed) return { ...obj, message: { ...message, content: next } }
198
+ } else if (typeof content === "string") {
199
+ const masked = redactSecrets(content)
200
+ if (masked !== content) {
201
+ return { ...obj, message: { ...message, content: masked } }
202
+ }
203
+ }
204
+ return obj
205
+ }
206
+
207
+ return obj
208
+ }
209
+
210
+ /**
211
+ * assistant メッセージ obj から tool_use の id→name を toolNames Map へ記録する
212
+ * (マスクはしない)。履歴 hydrate で「tool_use 行が表示スライスの外へ truncate されても
213
+ * tool_result のスキル判定が効く」よう、全行を事前走査して id→name を集めるのに使う。
214
+ * @param {Record<string, unknown>} obj
215
+ * @param {Map<string,string>} toolNames
216
+ */
217
+ export function collectToolUseNames(obj, toolNames) {
218
+ if (obj?.type === "assistant" && Array.isArray(obj.message?.content)) {
219
+ for (const b of obj.message.content) {
220
+ if (b && b.type === "tool_use" && typeof b.id === "string") {
221
+ toolNames.set(b.id, b.name)
222
+ }
223
+ }
224
+ }
225
+ }
226
+
227
+ /**
228
+ * ライブ stream / watch 用のステートフル masker を生成する。tool_use → tool_result が
229
+ * 別イベントで届くため、id→name を内部 Map に蓄積しながら 1 イベントずつマスクする。
230
+ * 1 セッション 1 インスタンスで使う (stream と watch は同一セッション内で直列化される)。
231
+ *
232
+ * @returns {(event: object) => object} マスク済みイベント (変化なしなら同一参照)
233
+ */
234
+ export function createEventMasker() {
235
+ const toolNames = new Map()
236
+ return function maskEvent(event) {
237
+ if (!MASK_ENABLED) return event
238
+ return maskMessageObject(event, toolNames)
239
+ }
240
+ }
241
+
242
+ /**
243
+ * jsonl ファイル全文 (改行区切り JSON) を 1 パスでマスクした文字列を返す。
244
+ * tool_use を先に通すと id→name が貯まるので、行順 (assistant→user) のまま走査すれば
245
+ * tool_result のスキル判定が成立する。マスク対象が 1 件も無ければ null を返す
246
+ * (呼び出し側が「書き換え不要」を判定して mtime を温存できる)。
247
+ *
248
+ * @param {string} text jsonl 全文
249
+ * @returns {string|null} マスク後の全文 (末尾改行は入力に合わせる)。変化なしは null。
250
+ */
251
+ export function maskJsonlText(text) {
252
+ if (!MASK_ENABLED || typeof text !== "string" || text.length === 0) return null
253
+ const toolNames = new Map()
254
+ const hadTrailingNewline = text.endsWith("\n")
255
+ const rawLines = text.split("\n")
256
+ // 末尾の空要素 (trailing newline 由来) は出力時に再付与するので走査から外す。
257
+ if (hadTrailingNewline) rawLines.pop()
258
+ let changed = false
259
+ const outLines = rawLines.map((line) => {
260
+ if (!line) return line
261
+ let obj
262
+ try {
263
+ obj = JSON.parse(line)
264
+ } catch {
265
+ return line // パース不能行はそのまま (壊さない)
266
+ }
267
+ const masked = maskMessageObject(obj, toolNames)
268
+ if (masked !== obj) {
269
+ changed = true
270
+ return JSON.stringify(masked)
271
+ }
272
+ return line
273
+ })
274
+ if (!changed) return null
275
+ return outLines.join("\n") + (hadTrailingNewline ? "\n" : "")
276
+ }
277
+
278
+ /**
279
+ * オンディスク jsonl をマスク書き換えする (セッション終了時に呼ぶ)。
280
+ *
281
+ * ⚠️ 呼び出しタイミングは「Claude CLI がそのファイルを掴んでいない (= セッション終了後)」
282
+ * に限ること。ライブ追記中に書き換えると会話本体を壊しセッションが死ぬ (jsonl は CLI が
283
+ * 追記オーナー)。終了後なら誰も掴んでいないので tmp 書き出し → rename で安全に置換できる。
284
+ *
285
+ * mtime を温存する: listSessions が mtime 降順でセッションを並べるため、マスク書き換えで
286
+ * 古いセッションが「最新」へ浮上する誤表示を防ぐ。マスク対象が無ければ書き込まない (no-op)。
287
+ *
288
+ * @param {string} filePath
289
+ * @param {{logger?: import('pino').Logger}} [opts]
290
+ * @returns {Promise<boolean>} 実際に書き換えたら true
291
+ */
292
+ export async function maskJsonlFileAtRest(filePath, { logger } = {}) {
293
+ if (!MASK_ENABLED || !filePath) return false
294
+ let text
295
+ let st
296
+ try {
297
+ st = await stat(filePath)
298
+ text = await readFile(filePath, "utf-8")
299
+ } catch (err) {
300
+ if (err?.code !== "ENOENT") {
301
+ logger?.warn({ err: err?.message, filePath }, "mask jsonl read failed")
302
+ }
303
+ return false
304
+ }
305
+ const masked = maskJsonlText(text)
306
+ if (masked == null) return false // マスク対象なし → 触らない (mtime 温存)
307
+ const tmp = `${filePath}.mask-tmp-${process.pid}`
308
+ try {
309
+ await writeFile(tmp, masked, "utf-8")
310
+ await rename(tmp, filePath)
311
+ // rename 後の新ファイルは現在時刻 mtime になるので、元の atime/mtime へ戻す。
312
+ await utimes(filePath, st.atime, st.mtime).catch(() => {})
313
+ return true
314
+ } catch (err) {
315
+ logger?.warn({ err: err?.message, filePath }, "mask jsonl write failed")
316
+ await rm(tmp, { force: true }).catch(() => {})
317
+ return false
318
+ }
319
+ }