@cocorograph/hub-agent 0.8.25 → 0.8.26
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/claude-history-watch.mjs +8 -5
- package/src/claude-history.mjs +149 -4
- package/src/main.mjs +13 -0
package/package.json
CHANGED
|
@@ -8,15 +8,18 @@
|
|
|
8
8
|
* 設計:
|
|
9
9
|
* - 対象ファイルのバイトオフセットを記録し、増分だけ読んで行単位でパース
|
|
10
10
|
* - fs.watch(file) の change イベントで増分読み取り (取りこぼし対策に軽いポーリング併用)
|
|
11
|
-
* -
|
|
11
|
+
* - historyEventFromLine が表示対象と判定した行だけ onEvent に渡す
|
|
12
|
+
* (DISPLAY_TYPES の 4 種 + queue-operation から復元したタスク終端イベント)
|
|
12
13
|
* - 各行の uuid を含めて渡す (frontend 側で重複排除に使う)
|
|
13
14
|
* - ファイル不在時は出現を待つ (ディレクトリ監視はせず、ポーリングで存在チェック)
|
|
14
15
|
*/
|
|
15
16
|
import { watch as fsWatch } from "node:fs"
|
|
16
17
|
import { open, realpath, stat } from "node:fs/promises"
|
|
17
18
|
|
|
18
|
-
// 履歴 hydrate と live watch
|
|
19
|
-
|
|
19
|
+
// 履歴 hydrate と live watch で「どの行を拾うか + jsonl→SDK message 整形」を共通化
|
|
20
|
+
// (フィールド取りこぼし・片方だけ直すドリフトの防止)。queue-operation からの終端イベント
|
|
21
|
+
// 合成もこの関数の中に閉じているので、live watch も自動的に同じ挙動になる。
|
|
22
|
+
import { historyEventFromLine } from "./claude-history.mjs"
|
|
20
23
|
|
|
21
24
|
// アダプティブ・ポーリング: 最後の追記から FAST_WINDOW_MS 以内は fast、以降は idle 周期。
|
|
22
25
|
// ~/.claude が iCloud Drive への symlink のため fs.watch (FSEvents) が遅延/不発しやすく、
|
|
@@ -98,8 +101,8 @@ export function watchSessionFile({ filePath, onEvent, fromEnd = true, logger })
|
|
|
98
101
|
} catch {
|
|
99
102
|
continue
|
|
100
103
|
}
|
|
101
|
-
|
|
102
|
-
|
|
104
|
+
const event = historyEventFromLine(obj)
|
|
105
|
+
if (!event) continue
|
|
103
106
|
try {
|
|
104
107
|
onEvent(event)
|
|
105
108
|
} catch (err) {
|
package/src/claude-history.mjs
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* - cwd エンコード規則: `/`, `.`, `_` などの非英数字を `-` 置換 (Claude CLI と同じ)
|
|
11
11
|
* - 1 行 = 1 JSON。`type` が 'user' / 'assistant' / 'system' / 'result' のものだけ抽出
|
|
12
12
|
* (内部 type 'attachment' / 'last-prompt' / 'permission-mode' 等は UI 表示対象外)
|
|
13
|
+
* 例外として `queue-operation` の未配送タスク通知だけは終端イベントへ合成する
|
|
14
|
+
* (synthesizeTaskNotificationEvent 参照。行そのものを表示するわけではない)
|
|
13
15
|
* - 上限 MAX_HISTORY_LINES (デフォルト 500) で末尾から切る
|
|
14
16
|
* - ファイル不在 (新規セッション) なら空配列を返す (エラーにしない)
|
|
15
17
|
*/
|
|
@@ -210,6 +212,149 @@ export function normalizeHistoryEvent(obj) {
|
|
|
210
212
|
return event
|
|
211
213
|
}
|
|
212
214
|
|
|
215
|
+
const TASK_NOTIFICATION_MARKER = "<task-notification>"
|
|
216
|
+
const TASK_NOTIFICATION_CLOSE = "</task-notification>"
|
|
217
|
+
// ヘッダ領域の終わり。ここから先は自由文 (報告全文) なので、鍵と状態の抽出対象にしない。
|
|
218
|
+
const TASK_NOTIFICATION_BODY_TAGS = ["<result>", "<summary>", "<note>"]
|
|
219
|
+
// マスクを素通りする経路なので、長さでも歯止めを掛ける (実データの tool_use_id は 30〜40 字)。
|
|
220
|
+
const MAX_TOOL_USE_ID_LEN = 200
|
|
221
|
+
const MAX_STATUS_LEN = 64
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 「通知の体裁を持つのに合成しなかった」件数。**内容は一切持たない**。
|
|
225
|
+
*
|
|
226
|
+
* ⚠️ これは観測窓であって判定ではない。封筒の検査を厳しくすると、Claude CLI が将来ラッパー・
|
|
227
|
+
* 空白・タグ順を変えたときに**正当な通知まで落ちて幽霊カードが静かに戻る**。その「静かな戻り」に
|
|
228
|
+
* 気づけるよう、件数だけを数える (内容をログへ出すと、まさに伏せ字を回避したい本文が漏れる)。
|
|
229
|
+
*/
|
|
230
|
+
let taskNotificationEnvelopeDrops = 0
|
|
231
|
+
|
|
232
|
+
/** 封筒の検査で合成しなかった**累計**件数を返す (テスト用)。 */
|
|
233
|
+
export function getTaskNotificationEnvelopeDrops() {
|
|
234
|
+
return taskNotificationEnvelopeDrops
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 前回 drain 以降に増えた分を返して 0 に戻す (ログへ出す側が使う)。 */
|
|
238
|
+
let taskNotificationEnvelopeDropsReported = 0
|
|
239
|
+
export function drainTaskNotificationEnvelopeDrops() {
|
|
240
|
+
const delta = taskNotificationEnvelopeDrops - taskNotificationEnvelopeDropsReported
|
|
241
|
+
taskNotificationEnvelopeDropsReported = taskNotificationEnvelopeDrops
|
|
242
|
+
return delta
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** `<tag>…</tag>` の **最初の** 中身を返す。見つからなければ ""。 */
|
|
246
|
+
function firstTagValue(text, tag) {
|
|
247
|
+
const open = `<${tag}>`
|
|
248
|
+
const start = text.indexOf(open)
|
|
249
|
+
if (start < 0) return ""
|
|
250
|
+
const from = start + open.length
|
|
251
|
+
const end = text.indexOf(`</${tag}>`, from)
|
|
252
|
+
if (end < 0) return ""
|
|
253
|
+
return text.slice(from, end).trim()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* `queue-operation` 行に埋まっている「タスク終端の宣言」を、画面が扱える最小イベントへ
|
|
258
|
+
* 合成する (2026-08-05)。
|
|
259
|
+
*
|
|
260
|
+
* ## なぜ必要か
|
|
261
|
+
* サブエージェント / バックグラウンドタスクの完了通知は、CLI がまず **キューへ積む**
|
|
262
|
+
* (`type:"queue-operation", operation:"enqueue"`) 形で jsonl に書かれ、その後キューから
|
|
263
|
+
* 取り出された時に初めて `type:"user"` の行として配送される。ところが配送前に
|
|
264
|
+
* セッションが閉じる・`remove` されるなどで **user 行が書かれないまま終わる**ケースが多く
|
|
265
|
+
* (2026-08-05 に手元の全 jsonl 2,136 本を走査した実測で、終端宣言 788 件 (tool_use_id
|
|
266
|
+
* ユニーク) のうち 332 件 = 42% が未配送。内訳 completed 285 / failed 29 / killed 18)、
|
|
267
|
+
* 画面側の台帳はそのカードを
|
|
268
|
+
* 「実行中」のまま抱え続ける (= リロードで残る幽霊カード)。
|
|
269
|
+
*
|
|
270
|
+
* enqueue 行の `content` には `<status>` と `<tool-use-id>` が入っており、これは
|
|
271
|
+
* **CLI 自身が書いた終端宣言**なので、時間で推測せずに終端を復元できる。
|
|
272
|
+
*
|
|
273
|
+
* ## ⚠️ 載せてよいのは「鍵」と「状態」と「時刻」だけ
|
|
274
|
+
* `<result>` にはサブエージェントの報告全文が入る (実データで確認済み)。しかしマスク処理
|
|
275
|
+
* `maskMessageObject` (src/tool-result-mask.mjs:165-168) は `const message = obj.message;
|
|
276
|
+
* if (!message …) return obj` で **即 return** するため、`message` を持たないこの合成イベントは
|
|
277
|
+
* **マスクを素通りする**。よって `<result>` / `<summary>` / `<note>` / `<output-file>` の本文は
|
|
278
|
+
* 絶対に載せない (載せると秘匿情報がマスクされないまま browser へ流れる)。`<task-id>` も
|
|
279
|
+
* 画面側が使っていないので載せない。
|
|
280
|
+
*
|
|
281
|
+
* 画面側 (Hub 本体 frontend/lib/cockpit/agentRequest.ts の applyTaskNotificationEvent) は
|
|
282
|
+
* `type:"system" / subtype:"task_notification" / tool_use_id / status` を終端として処理し、
|
|
283
|
+
* `requireRegistered: true` なので未知の tool_use_id で幻のカードが増えることはない。
|
|
284
|
+
* `timestamp` は猶予表示の起点になるので必ず載せる。
|
|
285
|
+
*
|
|
286
|
+
* @param {Record<string, unknown>} obj jsonl 1 行をパースした生 object
|
|
287
|
+
* @returns {Record<string, unknown>|null} 合成イベント。対象外なら null
|
|
288
|
+
*/
|
|
289
|
+
export function synthesizeTaskNotificationEvent(obj) {
|
|
290
|
+
if (!obj || obj.type !== "queue-operation" || obj.operation !== "enqueue") return null
|
|
291
|
+
const content = obj.content
|
|
292
|
+
if (typeof content !== "string") return null
|
|
293
|
+
const trimmed = content.trim()
|
|
294
|
+
// ⚠️ **「通知を含む」ではなく「通知そのものである」ことを要求する** (2026-08-05 のクロス
|
|
295
|
+
// レビュー minor)。含むだけで拾うと、**利用者が通知の文面を本文へ貼っただけの入力**を終端
|
|
296
|
+
// 宣言として扱ってしまう。実測でも、このレビューのやり取り自体がキューに積まれ、状態として
|
|
297
|
+
// 日本語の文章を、鍵として 215 字の文字列を拾っていた (実データ 1,692 行中 2 件)。
|
|
298
|
+
// 実データでは正規の通知 1,648 件すべてが `<task-notification>` で始まり、閉じタグを持つ。
|
|
299
|
+
if (!trimmed.startsWith(TASK_NOTIFICATION_MARKER)) {
|
|
300
|
+
if (trimmed.includes(TASK_NOTIFICATION_MARKER)) taskNotificationEnvelopeDrops += 1
|
|
301
|
+
return null
|
|
302
|
+
}
|
|
303
|
+
// ⚠️ 閉じタグは **最後の出現**を採る。最初の出現にすると、`<result>` の本文が
|
|
304
|
+
// `</task-notification>` を引用しているだけで封筒が途中で切れ、正当な通知を落とす。
|
|
305
|
+
const closeAt = trimmed.lastIndexOf(TASK_NOTIFICATION_CLOSE)
|
|
306
|
+
if (closeAt < TASK_NOTIFICATION_MARKER.length) {
|
|
307
|
+
taskNotificationEnvelopeDrops += 1
|
|
308
|
+
return null
|
|
309
|
+
}
|
|
310
|
+
// 閉じタグの後ろに本文が続くものは通知そのものではない (貼り付けの typical な形)。
|
|
311
|
+
if (trimmed.slice(closeAt + TASK_NOTIFICATION_CLOSE.length).trim()) {
|
|
312
|
+
taskNotificationEnvelopeDrops += 1
|
|
313
|
+
return null
|
|
314
|
+
}
|
|
315
|
+
const block = trimmed.slice(TASK_NOTIFICATION_MARKER.length, closeAt)
|
|
316
|
+
// 鍵と状態は **ヘッダ領域** (自由文の本文タグより前) からだけ採る。本文に紛れたタグや、
|
|
317
|
+
// 正規の `<status>` が欠けたときに本文側のタグを拾い上げることを防ぐ。
|
|
318
|
+
let headerEnd = block.length
|
|
319
|
+
for (const tag of TASK_NOTIFICATION_BODY_TAGS) {
|
|
320
|
+
const at = block.indexOf(tag)
|
|
321
|
+
if (at >= 0 && at < headerEnd) headerEnd = at
|
|
322
|
+
}
|
|
323
|
+
const header = block.slice(0, headerEnd)
|
|
324
|
+
const tool_use_id = firstTagValue(header, "tool-use-id")
|
|
325
|
+
const status = firstTagValue(header, "status")
|
|
326
|
+
// 鍵か状態が欠けた通知は画面側でも無視されるので、流すだけ無駄 → 合成しない。
|
|
327
|
+
if (!tool_use_id || !status) {
|
|
328
|
+
taskNotificationEnvelopeDrops += 1
|
|
329
|
+
return null
|
|
330
|
+
}
|
|
331
|
+
// マスクを素通りする経路なので、長さでも歯止めを掛ける (異常に長い値は封筒が壊れたサイン)。
|
|
332
|
+
if (tool_use_id.length > MAX_TOOL_USE_ID_LEN || status.length > MAX_STATUS_LEN) {
|
|
333
|
+
taskNotificationEnvelopeDrops += 1
|
|
334
|
+
return null
|
|
335
|
+
}
|
|
336
|
+
const event = { type: "system", subtype: "task_notification", tool_use_id, status }
|
|
337
|
+
if (obj.timestamp !== undefined) event.timestamp = obj.timestamp
|
|
338
|
+
return event
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* jsonl 1 行 (パース済み) を UI 表示用イベントに変換する。表示対象外なら null。
|
|
343
|
+
*
|
|
344
|
+
* ⚠️ **認識と合成はこの 1 関数に閉じる**。履歴 hydrate (readSessionHistory) と live watch
|
|
345
|
+
* (claude-history-watch.mjs) の両方がここを通ること。過去、行のフィルタ / 整形が 2 箇所に
|
|
346
|
+
* 分散して片方だけ更新されるドリフトが起きているため (normalizeHistoryEvent の注記参照)。
|
|
347
|
+
*
|
|
348
|
+
* @param {Record<string, unknown>} obj
|
|
349
|
+
* @returns {Record<string, unknown>|null}
|
|
350
|
+
*/
|
|
351
|
+
export function historyEventFromLine(obj) {
|
|
352
|
+
if (!obj || typeof obj !== "object") return null
|
|
353
|
+
// 1 行から出すイベントは最大 1 個 (画面側の終端は先勝ちなので重複は無害だが、増やさない)。
|
|
354
|
+
if (DISPLAY_TYPES.has(obj.type)) return normalizeHistoryEvent(obj)
|
|
355
|
+
return synthesizeTaskNotificationEvent(obj)
|
|
356
|
+
}
|
|
357
|
+
|
|
213
358
|
/**
|
|
214
359
|
* cwd 文字列を Claude Code の project dir 名に変換する。
|
|
215
360
|
* 例: `/Users/kaz/hub/projects/D00585_partition-lab`
|
|
@@ -297,10 +442,10 @@ export async function readSessionHistory(filePath, { maxLines = MAX_HISTORY_LINE
|
|
|
297
442
|
} catch {
|
|
298
443
|
continue
|
|
299
444
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
445
|
+
// SDK message と同じ shape にする (余分な meta は落とす)。認識・整形・合成は
|
|
446
|
+
// historyEventFromLine 1 箇所に集約 (live watch も同じ関数を通る)。
|
|
447
|
+
let event = historyEventFromLine(obj)
|
|
448
|
+
if (!event) continue
|
|
304
449
|
// 秘匿情報 / 非公開スキルプロンプト本文を hydrate 時にも伏せる (古いセッション再表示対策)。
|
|
305
450
|
if (MASK_ENABLED) event = maskMessageObject(event, toolNames)
|
|
306
451
|
events.push(event)
|
package/src/main.mjs
CHANGED
|
@@ -54,6 +54,7 @@ import { enforceTotalLiveBound } from "./warm-bounds-total.mjs"
|
|
|
54
54
|
import { UploadManager } from "./claude-upload.mjs"
|
|
55
55
|
import { requestSelfUninstall } from "./service-install.mjs"
|
|
56
56
|
import {
|
|
57
|
+
drainTaskNotificationEnvelopeDrops,
|
|
57
58
|
fetchSessionHistory,
|
|
58
59
|
listSessions,
|
|
59
60
|
} from "./claude-history.mjs"
|
|
@@ -4401,6 +4402,18 @@ async function dispatch(msg, ctx) {
|
|
|
4401
4402
|
projectsRoot,
|
|
4402
4403
|
logger: ctx.logger,
|
|
4403
4404
|
})
|
|
4405
|
+
// ⚠️ **観測窓を必ずどこかから読む**。タスク終端通知の封筒検査は、Claude CLI が
|
|
4406
|
+
// ラッパー・タグ順・空白を変えると正当な通知まで落とし、**幽霊カードが静かに戻る**。
|
|
4407
|
+
// 誰も読まないカウンタは「対策した気になるだけの飾り」になる (バンドル自己更新の
|
|
4408
|
+
// 通知で発火率 0 の検査を残していた前例あり)。件数だけを出す (中身は伏せ字を
|
|
4409
|
+
// 素通りするので絶対に出さない)。
|
|
4410
|
+
const envelopeDrops = drainTaskNotificationEnvelopeDrops()
|
|
4411
|
+
if (envelopeDrops > 0) {
|
|
4412
|
+
ctx.logger?.info?.(
|
|
4413
|
+
{ drops: envelopeDrops },
|
|
4414
|
+
"history: task-notification の封筒検査で合成しなかった行があります (CLI の形式変更なら通知の復元が効かなくなります)",
|
|
4415
|
+
)
|
|
4416
|
+
}
|
|
4404
4417
|
ctx.client.send({
|
|
4405
4418
|
type: "claude.history.response",
|
|
4406
4419
|
stream_id,
|