@cocorograph/hub-agent 0.7.20 → 0.7.22
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 +1 -1
- package/src/extract-paragraph.mjs +31 -1
- package/src/jsonl-live-watchers.mjs +63 -4
- package/src/main.mjs +49 -0
- package/src/tui-permission-bridge.mjs +36 -0
package/package.json
CHANGED
|
@@ -31,10 +31,25 @@ const _TOOL_CALL_RE = /^[A-Z][A-Za-z0-9_]*\s*\(/
|
|
|
31
31
|
|
|
32
32
|
const _BOX_LINE_RE = /^[─-▟]/
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
// 状態 / スピナー行。Claude 2.1.x は「考え中」スピナーに ✻ ✳ ✽ ✶ ✵ 等の星形グリフを
|
|
35
|
+
// 循環使用する (例: "✻ Sautéed for 2s" / "✳ Frolicking… (3s · ↓ 34 tokens)")。説明段落に
|
|
36
|
+
// 紛れ込ませないよう除外グリフを広く取る (旧版は ✨✹●⚫◯ のみで ✻✳✽ を取りこぼしていた)。
|
|
37
|
+
const _STATUS_LINE_RE = /^[✨✹✺✻✷✸✹✳✲✱✽✶✵✴✦✧⋆●⚫◯◉]\s/
|
|
35
38
|
|
|
36
39
|
const _ELLIPSIS_TAIL_RE = /\n[ \t]*…\s*\+\d+\s+lines[^\n]*$/
|
|
37
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
|
+
|
|
38
53
|
/**
|
|
39
54
|
* @param {string|undefined|null} paneText capturePane の出力 (ANSI 除去済み)
|
|
40
55
|
* @returns {string|null} 直近のアシスタント説明 (失敗時 null)
|
|
@@ -49,11 +64,20 @@ export function extractLastAssistantText(paneText) {
|
|
|
49
64
|
if (!line.startsWith(ASSIST_PREFIX + " ")) continue
|
|
50
65
|
const rest = line.slice(ASSIST_PREFIX.length + 1).trimStart()
|
|
51
66
|
if (_TOOL_CALL_RE.test(rest)) continue
|
|
67
|
+
if (_STOP_HOOK_SUMMARY_RE.test(rest)) continue
|
|
52
68
|
startIdx = i
|
|
53
69
|
break
|
|
54
70
|
}
|
|
55
71
|
if (startIdx < 0) return null
|
|
56
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
|
+
|
|
57
81
|
const parts = []
|
|
58
82
|
let consecBlank = 0
|
|
59
83
|
for (let i = startIdx; i < lines.length; i++) {
|
|
@@ -62,6 +86,8 @@ export function extractLastAssistantText(paneText) {
|
|
|
62
86
|
if (line.startsWith(ASSIST_PREFIX + " ")) break
|
|
63
87
|
if (_BOX_LINE_RE.test(line)) break
|
|
64
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
|
|
65
91
|
}
|
|
66
92
|
if (!line.trim()) {
|
|
67
93
|
consecBlank++
|
|
@@ -116,4 +142,8 @@ export const __test = {
|
|
|
116
142
|
_MIN_CHARS,
|
|
117
143
|
_TOOL_CALL_RE,
|
|
118
144
|
_BOX_LINE_RE,
|
|
145
|
+
_STATUS_LINE_RE,
|
|
146
|
+
_STOP_HOOK_SUMMARY_RE,
|
|
147
|
+
_TOOL_RESULT_RE,
|
|
148
|
+
_INPUT_ECHO_RE,
|
|
119
149
|
}
|
|
@@ -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({
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|