@cocorograph/hub-agent 0.7.8 → 0.7.10

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.8",
3
+ "version": "0.7.10",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -23,8 +23,15 @@
23
23
  * で別途 hydrate 済みのため、tail は新規分だけでよい (二重 push は frontend が uuid で排除)。
24
24
  */
25
25
 
26
+ import { readFile } from "node:fs/promises"
27
+
26
28
  import { watchSessionFile } from "./claude-history-watch.mjs"
27
29
  import { jsonlPath } from "./claude-history.mjs"
30
+ import {
31
+ MASK_ENABLED,
32
+ collectToolUseNames,
33
+ createEventMasker,
34
+ } from "./tool-result-mask.mjs"
28
35
 
29
36
  /** watcher 鮮度 (ms)。ブラウザのハートビート間隔 (5s) の 3 倍を既定とする。 */
30
37
  const WATCHER_TTL_MS = Number(process.env.COCKPIT_JSONL_WATCH_TTL_MS) || 15_000
@@ -84,6 +91,14 @@ export class JsonlLiveWatchers {
84
91
  // 起動処理中に別経路で登録済みになっていないか再チェック。
85
92
  if (this._entries.has(session_id)) return
86
93
  const filePath = jsonlPath({ cwd, session_id, projectsRoot })
94
+ // マスク (2026-06-23): TUI ライブ閲覧で逐次 push する tool_result の秘匿情報 / 非公開
95
+ // スキルプロンプト本文を伏せる。この経路は SDK チャットブリッジ (_emit) とは別系統で、
96
+ // フックを通さず jsonl を直接 tail するため、ここで独自にマスクしないと素通しになる。
97
+ // tail は fromEnd=true で監視開始後の追記のみを拾うので、開始前に書かれた tool_use の
98
+ // id→name を既存 jsonl から事前シードし、スキル tool_result の判定取りこぼしを防ぐ。
99
+ const masker = MASK_ENABLED
100
+ ? createEventMasker(await this._seedToolNames(filePath))
101
+ : null
87
102
  const watcher = watchSessionFile({
88
103
  filePath,
89
104
  fromEnd: true,
@@ -94,7 +109,7 @@ export class JsonlLiveWatchers {
94
109
  type: "claude.jsonl.event",
95
110
  session_id,
96
111
  cwd,
97
- event,
112
+ event: masker ? masker(event) : event,
98
113
  })
99
114
  } catch (err) {
100
115
  this.logger?.warn(
@@ -136,6 +151,30 @@ export class JsonlLiveWatchers {
136
151
  }
137
152
  }
138
153
 
154
+ /**
155
+ * 既存 jsonl を 1 度だけ読み、tool_use_id→name を集めた Map を返す (マスカーのシード用)。
156
+ * 読めない / 未存在なら空 Map。watcher 起動の冷経路で 1 回だけ走るので全文読みでよい。
157
+ * @param {string} filePath
158
+ * @returns {Promise<Map<string,string>>}
159
+ */
160
+ async _seedToolNames(filePath) {
161
+ const seed = new Map()
162
+ try {
163
+ const text = await readFile(filePath, "utf-8")
164
+ for (const line of text.split("\n")) {
165
+ if (!line || !line.includes('"tool_use"')) continue
166
+ try {
167
+ collectToolUseNames(JSON.parse(line), seed)
168
+ } catch {
169
+ /* ignore broken line */
170
+ }
171
+ }
172
+ } catch {
173
+ /* ファイル未存在等: シードなしで続行 */
174
+ }
175
+ return seed
176
+ }
177
+
139
178
  async _resolveProjectsRoot() {
140
179
  try {
141
180
  const r = this.getProjectsRoot?.()
package/src/main.mjs CHANGED
@@ -76,6 +76,7 @@ import {
76
76
  removeWorktree as removeWorktreeDir,
77
77
  resumeWithMessage,
78
78
  pasteToSessionByName,
79
+ sendInterruptKey,
79
80
  setSessionMouse,
80
81
  setTmuxGlobalEnv,
81
82
  setTuiEffort,
@@ -1519,6 +1520,156 @@ export function handleUntrackedPtyData(msg, ctx) {
1519
1520
  const _seenFlushIds = new Set()
1520
1521
  const _SEEN_FLUSH_CAP = 2000
1521
1522
 
1523
+ /**
1524
+ * claude.tui.queue.flush の処理 (離席中キュー自動投入の agent 側受け口)。
1525
+ * ブラウザの常駐ドレイン (useCockpitTuiQueueDrainAll) が、表示していないセッションの送信待ち
1526
+ * キュー先頭 1 件をここへ送る。tmux send-keys (PTY 非依存) で投入するので、そのセッションの
1527
+ * ターミナルを誰も開いていなくても届く。結果を flush.ack で返し、ブラウザは ack を受けて
1528
+ * はじめて localStorage から 1 件 dequeue する。
1529
+ *
1530
+ * 冪等性は 2 段:
1531
+ * 1. flush_id 単位 (_seenFlushIds): WS 再送・ack 取りこぼしによる同一 flush の二重投入を防ぐ。
1532
+ * 2. 論理 item id 単位 (cross-path): browser の active 経路 (tracked PTY paste) は同一 item を
1533
+ * input_id `qd:<itemId>:p` / `:r` で送る。ここと単一レジストリ (seenInputIds) を共有し、同一
1534
+ * item がどちらの経路で配送されても agent は at-most-once でしか paste しない (二重送信の根治)。
1535
+ *
1536
+ * テストから直接呼べるよう dispatch から切り出して export する。paste 実体は ctx で差し替え可能。
1537
+ */
1538
+ export async function handleQueueFlush(msg, ctx) {
1539
+ const paste = ctx.pasteToSessionByName || pasteToSessionByName
1540
+ const sessionName =
1541
+ typeof msg.session_name === "string" ? msg.session_name : ""
1542
+ const text = typeof msg.text === "string" ? msg.text : ""
1543
+ const flushId = typeof msg.flush_id === "string" ? msg.flush_id : ""
1544
+ const itemId = typeof msg.item_id === "string" ? msg.item_id : ""
1545
+ const pasteKey = itemId ? `qd:${itemId}:p` : ""
1546
+ const crKey = itemId ? `qd:${itemId}:r` : ""
1547
+ const ackFail = (reason) => {
1548
+ if (flushId && sessionName) {
1549
+ ctx.client.send({
1550
+ type: "claude.tui.queue.flush.ack",
1551
+ session_name: sessionName,
1552
+ flush_id: flushId,
1553
+ ok: false,
1554
+ error: reason,
1555
+ })
1556
+ }
1557
+ }
1558
+ const ackOk = () => {
1559
+ if (_seenFlushIds.size >= _SEEN_FLUSH_CAP) _seenFlushIds.clear()
1560
+ _seenFlushIds.add(flushId)
1561
+ ctx.client.send({
1562
+ type: "claude.tui.queue.flush.ack",
1563
+ session_name: sessionName,
1564
+ flush_id: flushId,
1565
+ ok: true,
1566
+ })
1567
+ }
1568
+ if (!sessionName || !flushId || !text) {
1569
+ ackFail("missing session_name / flush_id / text")
1570
+ return
1571
+ }
1572
+ // 冪等化1 (message 再送)。
1573
+ if (_seenFlushIds.has(flushId)) {
1574
+ ackOk()
1575
+ return
1576
+ }
1577
+ // 冪等化2 (cross-path): 同一 item を active 経路 (tracked PTY paste) が既に配送済みなら
1578
+ // 再 paste せず ok を返す (browser は ack で dequeue する)。
1579
+ if (pasteKey && seenInputIds.has(pasteKey)) {
1580
+ ackOk()
1581
+ return
1582
+ }
1583
+ // paste 前に cross-path キーを登録する。await 中に並走する active 経路の tracked paste
1584
+ // (handleTrackedPtyData) を dedup させ二重 paste を防ぐため (登録は paste 完了より先)。
1585
+ // 失敗時は登録解除し、再試行 (別 flush_id) で再配送できるようにする。
1586
+ if (pasteKey) {
1587
+ rememberInputId(pasteKey)
1588
+ rememberInputId(crKey)
1589
+ }
1590
+ const result = await paste(sessionName, text, { logger: ctx.logger })
1591
+ if (result.ok) {
1592
+ if (_seenFlushIds.size >= _SEEN_FLUSH_CAP) _seenFlushIds.clear()
1593
+ _seenFlushIds.add(flushId)
1594
+ } else if (pasteKey) {
1595
+ seenInputIds.delete(pasteKey)
1596
+ seenInputIds.delete(crKey)
1597
+ }
1598
+ ctx.client.send({
1599
+ type: "claude.tui.queue.flush.ack",
1600
+ session_name: sessionName,
1601
+ flush_id: flushId,
1602
+ ok: !!result.ok,
1603
+ error: result.ok ? undefined : result.error,
1604
+ })
1605
+ }
1606
+
1607
+ /**
1608
+ * claude.tui.interrupt の処理 (確認付き中断 = Phase3)。生 ESC を tracked pty.data で best-effort
1609
+ * 送出する旧経路 (stream 欠落で無言ドロップ + ESC 到達でも止まったか未検証) を、agent が ESC を
1610
+ * tmux send-keys (PTY 非依存で確実配達) で送り、ペイン status で生成停止を観測してから結果を返す
1611
+ * 方式に置き換える。
1612
+ *
1613
+ * 不変条件: 中断指示は、claude と同一ホストで生成状態を観測できる agent が「停止を観測 or deadline」
1614
+ * まで責任を持つ。止まらなければ deadline 内で 1 度だけ ESC を再送する (初回が一過性 mode に吸われた
1615
+ * 場合の保険)。Ctrl+C へはエスカレーションしない (claude を終了させる事故を避けるため。別判断)。
1616
+ *
1617
+ * テストから直接呼べるよう export。送出 (sendInterruptKey) / 観測 (detectSessionState) / 待機 (delay)
1618
+ * は ctx で差し替え可能。
1619
+ */
1620
+ export async function handleTuiInterrupt(msg, ctx) {
1621
+ const sessionName =
1622
+ typeof msg.session_name === "string" ? msg.session_name : ""
1623
+ const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
1624
+ const requestId = typeof msg.request_id === "string" ? msg.request_id : ""
1625
+ if (!sessionName || !requestId) return
1626
+ const sendEsc = ctx.sendInterruptKey || sendInterruptKey
1627
+ const detect = ctx.detectSessionState || detectSessionState
1628
+ const delay = ctx.delay || ((ms) => new Promise((r) => setTimeout(r, ms)))
1629
+ const settleMs = ctx.interruptSettleMs ?? 350
1630
+ const pollMs = ctx.interruptPollMs ?? 350
1631
+ const maxPolls = ctx.interruptMaxPolls ?? 7 // ~2.5s deadline
1632
+ const reply = (stopped, attempts) =>
1633
+ ctx.client.send({
1634
+ type: "claude.tui.interrupt.result",
1635
+ request_id: requestId,
1636
+ session_name: sessionName,
1637
+ cwd: cwd || undefined,
1638
+ stopped: !!stopped,
1639
+ attempts,
1640
+ })
1641
+ const stillProcessing = async () => {
1642
+ try {
1643
+ const snap = await detect(sessionName, { logger: ctx.logger })
1644
+ return (snap?.status ?? "processing") === "processing"
1645
+ } catch {
1646
+ // capture 失敗時は判定不能 → 保守的に「まだ生成中かも」と扱う (誤って stopped=true にしない)。
1647
+ return true
1648
+ }
1649
+ }
1650
+ // 1) ESC 送出 (= 中断)。
1651
+ await sendEsc(sessionName, { logger: ctx.logger })
1652
+ let attempts = 1
1653
+ let resent = false
1654
+ // 2) 停止 (status が processing を離れる) を deadline 内で観測。
1655
+ for (let i = 0; i < maxPolls; i++) {
1656
+ await delay(i === 0 ? settleMs : pollMs)
1657
+ if (!(await stillProcessing())) {
1658
+ reply(true, attempts)
1659
+ return
1660
+ }
1661
+ // deadline 中盤でまだ processing なら 1 度だけ ESC 再送 (初回が一過性 mode に吸われた保険)。
1662
+ // status=processing 継続が条件なので、アイドル箱への二度押し (rewind ダイアログ誤爆) は避ける。
1663
+ if (!resent && i >= Math.floor(maxPolls / 2)) {
1664
+ resent = true
1665
+ await sendEsc(sessionName, { logger: ctx.logger })
1666
+ attempts++
1667
+ }
1668
+ }
1669
+ // 3) deadline 到達でまだ processing。停止確認できず (frontend は旧 tracked-ESC 経路へ degrade)。
1670
+ reply(false, attempts)
1671
+ }
1672
+
1522
1673
  async function dispatch(msg, ctx) {
1523
1674
  const t = msg?.type || ""
1524
1675
  try {
@@ -1865,6 +2016,15 @@ async function dispatch(msg, ctx) {
1865
2016
  old_session_id: viewSid,
1866
2017
  new_session_id: newSessionId,
1867
2018
  })
2019
+ // ★回転 (= session_id 境界) で、この NAME に紐づく状態トラッカーを忘れる。生成中を表す
2020
+ // 信号 (proc_busy=ReadinessTracker baseline/armed, status gate ラッチ, spinner freeze,
2021
+ // capture cache) は全て NAME キーのため、忘れないと旧ターンの busy が新 session_id の
2022
+ // 表示へ漏れ、フロントで「/clear 後に三点リーダーが固着」する。発生源 (agent) で断つのが
2023
+ // 主、フロントの再点灯レジスタ reset が従の不変条件 (name→id 漏れの根治)。
2024
+ if (viewName) {
2025
+ ctx.readinessTracker?.forget(viewName)
2026
+ invalidateSessionCache(viewName)
2027
+ }
1868
2028
  logger.info(
1869
2029
  { session: viewName, cwd: viewCwd, old: viewSid, new: newSessionId },
1870
2030
  "tui session rotated (/clear) → notified browser",
@@ -2060,56 +2220,17 @@ async function dispatch(msg, ctx) {
2060
2220
  })()
2061
2221
  return
2062
2222
  }
2223
+ case "claude.tui.interrupt": {
2224
+ // 確認付き中断 (Phase3)。ロジックは handleTuiInterrupt に切り出し (テスト可能化)。
2225
+ // async だが dispatch をブロックしないよう投げっぱなし (自前で result を返す)。
2226
+ void handleTuiInterrupt(msg, ctx)
2227
+ return
2228
+ }
2063
2229
  case "claude.tui.queue.flush": {
2064
2230
  // 離席中(非アクティブセッション)キュー自動投入 (案A の agent 側受け口)。
2065
- // ブラウザの常駐ドレイン (useCockpitTuiQueueDrainAll) が、表示していないセッションの
2066
- // 送信待ちキュー先頭 1 件をここへ送る。tmux send-keys (PTY 非依存) で投入するので、
2067
- // そのセッションのターミナルを誰も開いていなくても届く。結果を flush.ack で返し、
2068
- // ブラウザは ack を受けてはじめて localStorage から 1 件 dequeue する (冪等)。
2069
- const sessionName =
2070
- typeof msg.session_name === "string" ? msg.session_name : ""
2071
- const text = typeof msg.text === "string" ? msg.text : ""
2072
- const flushId = typeof msg.flush_id === "string" ? msg.flush_id : ""
2073
- const ackFail = (reason) => {
2074
- if (flushId && sessionName) {
2075
- ctx.client.send({
2076
- type: "claude.tui.queue.flush.ack",
2077
- session_name: sessionName,
2078
- flush_id: flushId,
2079
- ok: false,
2080
- error: reason,
2081
- })
2082
- }
2083
- }
2084
- if (!sessionName || !flushId || !text) {
2085
- ackFail("missing session_name / flush_id / text")
2086
- return
2087
- }
2088
- // 冪等化: 同一 flush_id を既に処理済みなら再 paste せず ok を返す (ブラウザは dequeue 済み
2089
- // を再確認するだけ)。WS 再送・ack 取りこぼしによる二重投入を防ぐ。
2090
- if (_seenFlushIds.has(flushId)) {
2091
- ctx.client.send({
2092
- type: "claude.tui.queue.flush.ack",
2093
- session_name: sessionName,
2094
- flush_id: flushId,
2095
- ok: true,
2096
- })
2097
- return
2098
- }
2099
- ;(async () => {
2100
- const result = await pasteToSessionByName(sessionName, text, { logger })
2101
- if (result.ok) {
2102
- if (_seenFlushIds.size >= _SEEN_FLUSH_CAP) _seenFlushIds.clear()
2103
- _seenFlushIds.add(flushId)
2104
- }
2105
- ctx.client.send({
2106
- type: "claude.tui.queue.flush.ack",
2107
- session_name: sessionName,
2108
- flush_id: flushId,
2109
- ok: !!result.ok,
2110
- error: result.ok ? undefined : result.error,
2111
- })
2112
- })()
2231
+ // ロジックは handleQueueFlush に切り出し (テスト可能化 + cross-path 冪等)。async だが
2232
+ // dispatch をブロックしないよう投げっぱなしにする (自前で flush.ack を返す)
2233
+ void handleQueueFlush(msg, ctx)
2113
2234
  return
2114
2235
  }
2115
2236
  case "claude.tui.rehydratePermissions": {
package/src/tmux.mjs CHANGED
@@ -964,6 +964,35 @@ export async function resumeWithMessage(name, text, opts = {}) {
964
964
  }
965
965
  }
966
966
 
967
+ /**
968
+ * 対話 TUI claude へ中断 (Esc) を tmux send-keys で送る。PTY stream に依存しないため、
969
+ * zombie WS / stream 欠落窓でも確実に届く (生 ESC を tracked pty.data で送る旧経路は stream
970
+ * 欠落で無言ドロップしていた = 症状4の配達失敗半分)。copy-mode 等に入っているとキーが奪われる
971
+ * ので先に -X cancel で抜ける。ESC は claude TUI の生成中断キー (素の入力欄では概ね無害)。
972
+ * @param {string} name tmux セッション名
973
+ * @param {{logger?:object,tmuxBin?:string}} [opts]
974
+ * @returns {Promise<{ok:boolean, error?:string}>}
975
+ */
976
+ export async function sendInterruptKey(name, opts = {}) {
977
+ if (!isSafeSessionName(name)) return { ok: false, error: "unsafe session name" }
978
+ const bin = tmuxBin(opts)
979
+ try {
980
+ try {
981
+ await execFileP(bin, ["send-keys", "-t", name, "-X", "cancel"])
982
+ } catch {
983
+ /* copy-mode でなければ "not in a mode" エラー = 無視 */
984
+ }
985
+ await execFileP(bin, ["send-keys", "-t", name, "Escape"])
986
+ return { ok: true }
987
+ } catch (err) {
988
+ opts.logger?.warn(
989
+ { session: name, err: err?.message },
990
+ "sendInterruptKey failed",
991
+ )
992
+ return { ok: false, error: err?.message || "send_failed" }
993
+ }
994
+ }
995
+
967
996
  /**
968
997
  * 離席中キュー投入: 送信待ちメッセージ 1 件を、ブラウザがそのセッションのターミナルを
969
998
  * マウントしていなくても tmux send-keys で対話 claude TUI へ投入する (案A の agent 側実体)。
@@ -229,10 +229,13 @@ export function collectToolUseNames(obj, toolNames) {
229
229
  * 別イベントで届くため、id→name を内部 Map に蓄積しながら 1 イベントずつマスクする。
230
230
  * 1 セッション 1 インスタンスで使う (stream と watch は同一セッション内で直列化される)。
231
231
  *
232
+ * @param {Map<string,string>} [initialToolNames] tool_use_id→name の事前シード。jsonl を
233
+ * 途中から tail する経路 (JsonlLiveWatchers) で、tail 開始前に書かれた tool_use の名前を
234
+ * 先に流し込み、スキル tool_result の判定を取りこぼさないために使う。
232
235
  * @returns {(event: object) => object} マスク済みイベント (変化なしなら同一参照)
233
236
  */
234
- export function createEventMasker() {
235
- const toolNames = new Map()
237
+ export function createEventMasker(initialToolNames) {
238
+ const toolNames = initialToolNames instanceof Map ? initialToolNames : new Map()
236
239
  return function maskEvent(event) {
237
240
  if (!MASK_ENABLED) return event
238
241
  return maskMessageObject(event, toolNames)