@cocorograph/hub-agent 0.7.20 → 0.7.21

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.20",
3
+ "version": "0.7.21",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -48,12 +48,22 @@ export class JsonlLiveWatchers {
48
48
  * @param {() => Promise<string|undefined>|string|undefined} args.getProjectsRoot
49
49
  * - ~/.claude/projects の実効ルート解決 (アカウント切替を反映)
50
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 カードを畳む)。
51
54
  * @param {import('pino').Logger} [args.logger]
52
55
  */
53
- constructor({ send, getProjectsRoot, ttlMs = WATCHER_TTL_MS, logger } = {}) {
56
+ constructor({
57
+ send,
58
+ getProjectsRoot,
59
+ ttlMs = WATCHER_TTL_MS,
60
+ onToolResolved,
61
+ logger,
62
+ } = {}) {
54
63
  this.send = send
55
64
  this.getProjectsRoot = getProjectsRoot
56
65
  this.ttlMs = ttlMs
66
+ this.onToolResolved = onToolResolved
57
67
  this.logger = logger
58
68
  /** @type {Map<string, {watcher: {stop: () => void}, cwd: string, expiresAt: number}>} */
59
69
  this._entries = new Map()
@@ -96,14 +106,33 @@ export class JsonlLiveWatchers {
96
106
  // フックを通さず jsonl を直接 tail するため、ここで独自にマスクしないと素通しになる。
97
107
  // tail は fromEnd=true で監視開始後の追記のみを拾うので、開始前に書かれた tool_use の
98
108
  // id→name を既存 jsonl から事前シードし、スキル tool_result の判定取りこぼしを防ぐ。
99
- const masker = MASK_ENABLED
100
- ? createEventMasker(await this._seedToolNames(filePath))
101
- : null
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
102
117
  const watcher = watchSessionFile({
103
118
  filePath,
104
119
  fromEnd: true,
105
120
  logger: this.logger,
106
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
+ }
107
136
  try {
108
137
  this.send?.({
109
138
  type: "claude.jsonl.event",
@@ -175,6 +204,36 @@ export class JsonlLiveWatchers {
175
204
  return seed
176
205
  }
177
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
+
178
237
  async _resolveProjectsRoot() {
179
238
  try {
180
239
  const r = this.getProjectsRoot?.()
package/src/main.mjs CHANGED
@@ -768,6 +768,33 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
768
768
  send: (obj) => client.send(obj),
769
769
  getProjectsRoot: getActiveProjectsRoot,
770
770
  logger,
771
+ // ネイティブ回答検知 (症状1b 根治): tail 中の jsonl に tool_result が着弾し「あるツールが
772
+ // ターミナル側で解決された」と分かったら、その session の同名ツールの pending 権限カードを
773
+ // 畳み、browser へ claude.permission.cancel を送って残存カードを消す。browser が先に
774
+ // resolve() 済みなら _pending は既に空で dropBySession は [] を返す (= no-op、cancel も出ない)。
775
+ onToolResolved: ({ session_id, cwd, toolName }) => {
776
+ const dropped =
777
+ tuiPermissionBridge.dropBySession({ session_id, cwd, toolName }) || []
778
+ for (const request_id of dropped) {
779
+ try {
780
+ client.send({
781
+ type: "claude.permission.cancel",
782
+ request_id,
783
+ session_id,
784
+ cwd,
785
+ reason: "answered_in_terminal",
786
+ })
787
+ } catch {
788
+ /* ignore */
789
+ }
790
+ }
791
+ if (dropped.length) {
792
+ logger.info(
793
+ { session_id, cwd, tool: toolName, count: dropped.length },
794
+ "tui permission resolved natively → cancelled browser card",
795
+ )
796
+ }
797
+ },
771
798
  })
772
799
  jsonlLiveWatchers.start()
773
800
  ctx.jsonlLiveWatchers = jsonlLiveWatchers
@@ -2238,6 +2265,28 @@ async function dispatch(msg, ctx) {
2238
2265
  ctx.readinessTracker?.forget(viewName)
2239
2266
  invalidateSessionCache(viewName)
2240
2267
  }
2268
+ // 回転 = 旧 session_id 境界。旧 session に紐づく pending 権限カードは orphan に
2269
+ // なる (claude は新会話へ移った) ので畳み、browser へ cancel して残存カードを消す
2270
+ // (症状1b 根治)。旧 session_id 完全一致で drop するため、回転直後に新 session で
2271
+ // 出たばかりのカード (payload.session_id === newSessionId) は誤って巻き込まない。
2272
+ if (ctx.tuiPermissionBridge) {
2273
+ const dropped = ctx.tuiPermissionBridge.dropBySession({
2274
+ session_id: viewSid,
2275
+ })
2276
+ for (const request_id of dropped) {
2277
+ try {
2278
+ ctx.client.send({
2279
+ type: "claude.permission.cancel",
2280
+ request_id,
2281
+ session_id: viewSid,
2282
+ cwd: viewCwd,
2283
+ reason: "session_rotated",
2284
+ })
2285
+ } catch {
2286
+ /* ignore */
2287
+ }
2288
+ }
2289
+ }
2241
2290
  logger.info(
2242
2291
  { session: viewName, cwd: viewCwd, old: viewSid, new: newSessionId },
2243
2292
  "tui session rotated (/clear) → notified browser",
@@ -147,6 +147,42 @@ export class TuiPermissionBridge extends EventEmitter {
147
147
  this._seen.delete(request_id)
148
148
  }
149
149
 
150
+ /**
151
+ * セッションに紐づく pending を **まとめて** 落とす (set/clear 非対称の解消)。
152
+ *
153
+ * `_pending` を畳む経路は元々 `resolve()`(browser 回答) と `drop(request_id)` の 2 本しか
154
+ * 無く、(a) ターミナルでネイティブ回答された / (b) `/clear` で session が回転した /
155
+ * (c) kill された、という「browser を経由しない終端」では pending が 6h TTL まで残り、
156
+ * 復帰時 `listPending`→rehydrate が古いカードを反復再 push していた (症状1b)。本メソッドは
157
+ * その終端を session 同一性で一括 drop するためのもの。
158
+ *
159
+ * マッチ規則は `listPending` (L134-142) と同じ OR セマンティクス: payload.session_id ===
160
+ * session_id **または** payload.cwd === cwd。さらに `toolName` を渡すと payload.tool_name 一致
161
+ * のものだけに絞る (ネイティブ回答検知で「いま解決したツールのカードだけ」を畳む用途)。
162
+ * 落とした request_id を返すので、呼び出し側は browser へ `claude.permission.cancel` を
163
+ * 送ってカードを消せる。
164
+ *
165
+ * `drop(request_id)` との違い: drop は request_id 既知の 1 件 (resume 経路)、dropBySession は
166
+ * session 同一性で選んだ **集合** を落とし、落とした id 一覧を返す。
167
+ *
168
+ * @param {{session_id?: string|null, cwd?: string|null, toolName?: string|null}} q
169
+ * @returns {string[]} 落とした request_id の配列
170
+ */
171
+ dropBySession({ session_id = null, cwd = null, toolName = null } = {}) {
172
+ const dropped = []
173
+ if (!session_id && !cwd) return dropped
174
+ for (const [request_id, { payload }] of this._pending) {
175
+ const sidOk = session_id && payload.session_id === session_id
176
+ const cwdOk = cwd && payload.cwd === cwd
177
+ if (!(sidOk || cwdOk)) continue
178
+ if (toolName && payload.tool_name !== toolName) continue
179
+ this._pending.delete(request_id)
180
+ this._seen.delete(request_id)
181
+ dropped.push(request_id)
182
+ }
183
+ return dropped
184
+ }
185
+
150
186
  /**
151
187
  * browser の決定をフックへ返す。対象 request_id なら `.decision` を atomic
152
188
  * 書き込みし、要求ファイルを掃除して true を返す。対象外なら false