@cocorograph/hub-agent 0.8.25 → 0.8.27
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/codex-stream-bridge.mjs +120 -3
- package/src/main.mjs +13 -0
- package/src/sibling-inject.mjs +69 -5
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)
|
|
@@ -315,6 +315,29 @@ export function deriveResumeTurnState(thread) {
|
|
|
315
315
|
return { threadStatus, lastTurnStatus, turnActive, activeTurnId }
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
+
/**
|
|
319
|
+
* sibling 依頼の識別子 (`request_id`) から配送 ID を **決定的に** 導出する (2026-08-05)。
|
|
320
|
+
*
|
|
321
|
+
* ⚠️ なぜランダムではいけないか: `_deliverInjection` は導出した値を `input({inputId})` に渡し、
|
|
322
|
+
* `input()` はその id を `session.acceptedInputIds` に控えて **同じ id の再到着を no-op にする**
|
|
323
|
+
* (重複ガード)。ここが `randomUUID()` だと同一依頼の再処理 (hub-agent 再起動後の sweep で同じ
|
|
324
|
+
* 要求ファイルを読み直す / 送信側がタイムアウトと誤認して同じ依頼を投げ直す) が毎回「別物」と
|
|
325
|
+
* 見なされ、ガードが一度も効かない = 同じ本文が二重に実行される。
|
|
326
|
+
*
|
|
327
|
+
* ⚠️ 冪等キーなので **異なる依頼同士で衝突させてはいけない**。`request_id` は要求ファイル名
|
|
328
|
+
* (cockpit_orch.py は uuid4 の hex) であり依頼ごとに一意なので、加工せず丸ごと使う (切り詰め・
|
|
329
|
+
* 正規化をすると衝突の余地が生まれる)。`sib-` の接頭辞は browser 入力の inputId と名前空間を
|
|
330
|
+
* 分けるためだけのもの。
|
|
331
|
+
*
|
|
332
|
+
* @param {unknown} requestId sibling 要求ファイルの `<request_id>`
|
|
333
|
+
* @returns {string|null} 導出した配送 ID。識別子が無い経路では null (呼び出し側が randomUUID)
|
|
334
|
+
*/
|
|
335
|
+
export function siblingDeliveryId(requestId) {
|
|
336
|
+
if (typeof requestId !== "string") return null
|
|
337
|
+
const trimmed = requestId.trim()
|
|
338
|
+
return trimmed ? `sib-${trimmed}` : null
|
|
339
|
+
}
|
|
340
|
+
|
|
318
341
|
/**
|
|
319
342
|
* 配達保証 (S2, 2026-07-05): `input()` が reject したエラーを `codex.input.nack` の
|
|
320
343
|
* `reason` へ分類する。
|
|
@@ -1216,6 +1239,21 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1216
1239
|
}
|
|
1217
1240
|
}
|
|
1218
1241
|
|
|
1242
|
+
/**
|
|
1243
|
+
* 重複ガードの台帳から id を取り消す (2026-08-05)。
|
|
1244
|
+
*
|
|
1245
|
+
* `input()` は turn/start を打つ **前** に `_recordAcceptedInput` するため、RPC が失敗して
|
|
1246
|
+
* 「1 度もターンが起きていない」ときでも id が受理済みとして残る。sibling 注入はここで
|
|
1247
|
+
* `delivery_failed` (= 再送してよい) を返すので、id を残したままだと**正当な再送を重複と
|
|
1248
|
+
* 誤判定して握り潰す**。受理を取り消した経路だけで呼ぶこと。
|
|
1249
|
+
*/
|
|
1250
|
+
_forgetAcceptedInput(session, inputId) {
|
|
1251
|
+
const ids = session?.acceptedInputIds
|
|
1252
|
+
if (!Array.isArray(ids) || typeof inputId !== "string" || !inputId) return
|
|
1253
|
+
const at = ids.indexOf(inputId)
|
|
1254
|
+
if (at !== -1) ids.splice(at, 1)
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1219
1257
|
_pendingInputItems(session) {
|
|
1220
1258
|
if (!session || !Array.isArray(session.inputQueue)) return []
|
|
1221
1259
|
return session.inputQueue.filter((item) => item !== session.dispatchingInput)
|
|
@@ -2158,15 +2196,18 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2158
2196
|
*
|
|
2159
2197
|
* @param {string} cwd
|
|
2160
2198
|
* @param {string} text
|
|
2199
|
+
* @param {'peer'|'orchestrator'} [source]
|
|
2200
|
+
* @param {string} [requestId] sibling 要求の識別子 (配送 ID を決定的に導出し重複を止める)
|
|
2161
2201
|
* @returns {Promise<
|
|
2162
2202
|
* {status:"delivered", session:object, delivery_id:string, accepted_via:string}
|
|
2203
|
+
* | {status:"delivery_queued", session:object, delivery_id:string, accepted_via:string, reason:string}
|
|
2163
2204
|
* | {status:"delivery_unconfirmed", session:object, delivery_id:string, accepted_via:string, reason:string}
|
|
2164
2205
|
* | {status:"delivery_failed", session:object, delivery_id:string, reason:string}
|
|
2165
2206
|
* | {status:"ambiguous", candidates:object[]}
|
|
2166
2207
|
* | {status:"not_found"}
|
|
2167
2208
|
* >}
|
|
2168
2209
|
*/
|
|
2169
|
-
async sendToCwd(cwd, text, source) {
|
|
2210
|
+
async sendToCwd(cwd, text, source, requestId) {
|
|
2170
2211
|
if (!cwd || !text) return { status: "not_found" }
|
|
2171
2212
|
// 案B: distinct session で走査(多対一 Map の重複を除く)。
|
|
2172
2213
|
const cands = this._liveSessions().filter((s) => s.cwd === cwd)
|
|
@@ -2206,6 +2247,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2206
2247
|
cwd,
|
|
2207
2248
|
selection: "sole-candidate",
|
|
2208
2249
|
candidates: this.describeLiveSessions(cwd).length,
|
|
2250
|
+
request_id: requestId,
|
|
2209
2251
|
})
|
|
2210
2252
|
}
|
|
2211
2253
|
|
|
@@ -2234,9 +2276,11 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2234
2276
|
* @param {string} cwd 呼び出し元がトークン検証した cwd (この cwd のセッションにのみ注入)
|
|
2235
2277
|
* @param {string} id 宛先 stream_id (一意・推奨) または thread_id
|
|
2236
2278
|
* @param {string} text
|
|
2279
|
+
* @param {'peer'|'orchestrator'} [source]
|
|
2280
|
+
* @param {string} [requestId] sibling 要求の識別子 (配送 ID を決定的に導出し重複を止める)
|
|
2237
2281
|
* @returns {Promise<object>}
|
|
2238
2282
|
*/
|
|
2239
|
-
async sendToStream(cwd, id, text, source) {
|
|
2283
|
+
async sendToStream(cwd, id, text, source, requestId) {
|
|
2240
2284
|
if (!cwd || !id || !text) return { status: "not_found" }
|
|
2241
2285
|
let bySid = null
|
|
2242
2286
|
const byThread = []
|
|
@@ -2266,6 +2310,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2266
2310
|
cwd,
|
|
2267
2311
|
requested_id: id,
|
|
2268
2312
|
selection: bySid ? "stream_id" : "thread_id",
|
|
2313
|
+
request_id: requestId,
|
|
2269
2314
|
})
|
|
2270
2315
|
}
|
|
2271
2316
|
|
|
@@ -2274,10 +2319,41 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2274
2319
|
* dispatched (本関数開始) → accepted (turn RPC成功) →
|
|
2275
2320
|
* delivered (同一sessionの userMessage item/completed echo) → durable (rollout監査、別層)。
|
|
2276
2321
|
* ここでは delivered までを同期的に確認し、echo timeout は成功扱いしない。
|
|
2322
|
+
*
|
|
2323
|
+
* ⚠️ 例外は「相手が応答生成中」(2026-08-05)。この場合メッセージは次ターン FIFO に受理される
|
|
2324
|
+
* だけで **echo は次のターンが始まるまで来ない**ので、待つと最長でターン 1 本ぶん (実測 30 分)
|
|
2325
|
+
* ブロックする。旧実装は `inputId` を渡していなかったため `input()` の即時 resolve 経路にも
|
|
2326
|
+
* 乗らず、送信元 CLI が待ちきれず「hub-agent が起動していない」と誤報 → 再送 → 二重配送を
|
|
2327
|
+
* 起こしていた。キューが「待たせる席」なので、hub-agent が送信元を待たせる必要は無い。
|
|
2328
|
+
* 順番待ちは `delivery_queued` (echo 欠落 = `delivery_unconfirmed` とは別の状態) で即返す。
|
|
2277
2329
|
*/
|
|
2278
2330
|
async _deliverInjection(session, text, source, context = {}) {
|
|
2279
|
-
|
|
2331
|
+
// 冪等キー: sibling 依頼の識別子から決定的に導出する (無い経路のみランダム)。
|
|
2332
|
+
const deliveryId = siblingDeliveryId(context.request_id) || randomUUID()
|
|
2280
2333
|
const sourceStreamId = session.stream_id
|
|
2334
|
+
// 同一依頼の再処理 (hub-agent 再起動後の sweep 等) は、ここで打ち切って二重実行しない。
|
|
2335
|
+
// 見ているのは `input()` の重複ガードと同じ `acceptedInputIds` なので判定は原理的に一致する
|
|
2336
|
+
// (ここで弾き損ねても input() 側が turn を二重に起こさない = 二段構え)。
|
|
2337
|
+
if (deliveryId && session.acceptedInputIds?.includes(deliveryId)) {
|
|
2338
|
+
this.logger?.info?.(
|
|
2339
|
+
{
|
|
2340
|
+
...context,
|
|
2341
|
+
delivery_id: deliveryId,
|
|
2342
|
+
stream_id: sourceStreamId,
|
|
2343
|
+
thread_id: session.threadId,
|
|
2344
|
+
delivery_state: "queued",
|
|
2345
|
+
reason: "duplicate_request",
|
|
2346
|
+
},
|
|
2347
|
+
"codex sibling injection duplicate request ignored (already accepted)",
|
|
2348
|
+
)
|
|
2349
|
+
return {
|
|
2350
|
+
status: "delivery_queued",
|
|
2351
|
+
session: this._describe(session),
|
|
2352
|
+
delivery_id: deliveryId,
|
|
2353
|
+
accepted_via: "duplicate",
|
|
2354
|
+
reason: "duplicate_request",
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2281
2357
|
const injectHandle = this._recordInjectSource(
|
|
2282
2358
|
session,
|
|
2283
2359
|
text,
|
|
@@ -2320,8 +2396,16 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2320
2396
|
stream_id: sourceStreamId,
|
|
2321
2397
|
message: { role: "user", content: text },
|
|
2322
2398
|
deliveryId,
|
|
2399
|
+
// ⚠️ inputId を渡すのが「順番待ちで即返す」の肝。渡さないと input() の
|
|
2400
|
+
// `acceptedAsQueued = queuedAtArrival && !!inputId` が常に false になり、
|
|
2401
|
+
// 実際に turn/start を打つまで promise が解決しない (= 相手のターンが終わるまで
|
|
2402
|
+
// 送信元をブロックする)。browser 送信は既に inputId を渡して即返しになっている。
|
|
2403
|
+
inputId: deliveryId,
|
|
2323
2404
|
})
|
|
2324
2405
|
} catch (err) {
|
|
2406
|
+
// turn/start は 1 度も成功していない = 再送してよい。重複ガードの台帳から取り消して
|
|
2407
|
+
// おかないと、同じ request_id での正当な再送を「重複」と誤判定して握り潰す。
|
|
2408
|
+
this._forgetAcceptedInput(session, deliveryId)
|
|
2325
2409
|
this._removeInjectSource(session, injectHandle)
|
|
2326
2410
|
this.logger?.error?.(
|
|
2327
2411
|
{
|
|
@@ -2355,6 +2439,39 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2355
2439
|
},
|
|
2356
2440
|
"codex sibling injection accepted",
|
|
2357
2441
|
)
|
|
2442
|
+
if (accepted?.method === "queued") {
|
|
2443
|
+
// 相手が応答生成中 (または pump 進行中): 次ターン FIFO に受理済み。echo は次のターンが
|
|
2444
|
+
// 始まるまで来ないので **待たない** (待てばターン 1 本ぶんブロックする)。injectHandle は
|
|
2445
|
+
// 消さずに残す — 後で届く userMessage echo を _tagInjectSourceOnItem が拾い、受け手画面で
|
|
2446
|
+
// 連携カードとして表示するために要る。
|
|
2447
|
+
// 永続記録は claude 版と同じ「受理時点」で行う (hub-agent 再起動後の hydrate で、この注入を
|
|
2448
|
+
// 通常の user 依頼として表示しないため)。
|
|
2449
|
+
recordInjectSourcePersistent(session.cwd, text, source).catch(() => {})
|
|
2450
|
+
this.logger?.info?.(
|
|
2451
|
+
{
|
|
2452
|
+
...context,
|
|
2453
|
+
delivery_id: deliveryId,
|
|
2454
|
+
stream_id: sourceStreamId,
|
|
2455
|
+
current_stream_id: session.stream_id,
|
|
2456
|
+
thread_id: session.threadId,
|
|
2457
|
+
accepted_via: accepted?.method || null,
|
|
2458
|
+
delivery_state: "queued",
|
|
2459
|
+
reason: "queued_behind_active_turn",
|
|
2460
|
+
queued: this._pendingInputItems(session).length,
|
|
2461
|
+
active_turn_id: session.activeTurnId || null,
|
|
2462
|
+
},
|
|
2463
|
+
"codex sibling injection queued behind active turn",
|
|
2464
|
+
)
|
|
2465
|
+
return {
|
|
2466
|
+
// ⚠️ delivery_unconfirmed と混ぜない。あちらは「届いたか分からない」、こちらは
|
|
2467
|
+
// 「受理済み・順番待ち・再送不要」で、送信元が取るべき行動が正反対になる。
|
|
2468
|
+
status: "delivery_queued",
|
|
2469
|
+
session: this._describe(session),
|
|
2470
|
+
delivery_id: deliveryId,
|
|
2471
|
+
accepted_via: accepted?.method || null,
|
|
2472
|
+
reason: "queued_behind_active_turn",
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2358
2475
|
const delivery = await this._waitInjectDelivery(injectHandle)
|
|
2359
2476
|
if (!delivery?.delivered) {
|
|
2360
2477
|
this.logger?.warn?.(
|
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,
|
package/src/sibling-inject.mjs
CHANGED
|
@@ -37,6 +37,15 @@
|
|
|
37
37
|
* candidates_nameable?}` を書く (`candidates_nameable` は曖昧系エラーのときのみ。CLI が
|
|
38
38
|
* 「候補を実行可能な `--to` として並べてよいか」を hint の文言に依存せず判定するための boolean)。
|
|
39
39
|
*
|
|
40
|
+
* ⚠️ codex の `delivery_state` は 4 値ある (2026-08-05 に `queued` を追加):
|
|
41
|
+
* - `delivered` … userMessage echo まで確認済み (ok:true)
|
|
42
|
+
* - `queued` … **相手が応答生成中**。次ターンのキューに受理済みで、echo は次の
|
|
43
|
+
* ターンが始まるまで来ない。**再送不要** (ok:true)
|
|
44
|
+
* - `accepted_unconfirmed` … turn RPC は通ったが echo を確認できない (ok:false)
|
|
45
|
+
* - `failed` … turn RPC が失敗。再送してよい (ok:false)
|
|
46
|
+
* `queued` を待たずに即返すのが要点で、待つと相手のターン (実測 30 分) ぶん送信元がブロックし、
|
|
47
|
+
* 待ちきれない CLI が「hub-agent が起動していない」と誤報して再送 → 二重配送になる。
|
|
48
|
+
*
|
|
40
49
|
* ⚠️ 曖昧系のエラーコードは 2 つある (2026-08-02 クロスレビュー round2 major):
|
|
41
50
|
* - `ambiguous_target` … 候補を `--to` で名指しできる (解消手段がある)
|
|
42
51
|
* - `ambiguous_target_unnameable` … 名指しでは解消できない (session_id 未確定 / resume 競合)
|
|
@@ -64,6 +73,17 @@ const DELIVERY_UNCONFIRMED_HINT =
|
|
|
64
73
|
"再送すると重複配送になります。再送前に対象セッションの受信状態または rollout 監査を確認してください。"
|
|
65
74
|
const DELIVERY_FAILED_HINT =
|
|
66
75
|
"turn RPC の受付前または受付中に失敗しました。対象セッションを確認して再送してください。"
|
|
76
|
+
/**
|
|
77
|
+
* 「受理済み・順番待ち」= 相手が応答生成中なのでメッセージは次ターンのキューに入った (2026-08-05)。
|
|
78
|
+
*
|
|
79
|
+
* ⚠️ `delivery_unconfirmed` と語彙を分ける。あちらは「届いたか分からない (再送すると重複の恐れ)」、
|
|
80
|
+
* こちらは「確実に受理された。相手のターンが終われば実行される」で、**送信元が取るべき行動が違う**
|
|
81
|
+
* (前者は受信状態の確認、後者は待つだけ)。旧実装はここで hub-agent が送信元をブロックし続け、
|
|
82
|
+
* 待ちきれない CLI が「hub-agent が起動していない」と誤報して再送 → 二重配送を起こしていた。
|
|
83
|
+
*/
|
|
84
|
+
const DELIVERY_QUEUED_HINT =
|
|
85
|
+
"相手セッションが応答生成中のため、メッセージは次ターンのキューに入りました (受理済み)。" +
|
|
86
|
+
"相手のターンが終わり次第そのまま実行されるので、再送は不要です。"
|
|
67
87
|
|
|
68
88
|
/** 曖昧だが `--to` で名指しして解消できる (候補を実行可能な宛先として案内してよい)。 */
|
|
69
89
|
export const AMBIGUOUS_TARGET_ERROR = "ambiguous_target"
|
|
@@ -148,7 +168,9 @@ function claudeCandidatesNameability(candidates) {
|
|
|
148
168
|
*/
|
|
149
169
|
function ambiguousTargetOutcome(cliKind, targetSessionId, candidates) {
|
|
150
170
|
// 名指し可否の判定はここ 1 回だけ。error も hint もこの値から導くので三者は原理的に一致する。
|
|
151
|
-
// codex
|
|
171
|
+
// codex は候補ごとに必ず一意な接続 ID (stream_id) を持つため、候補を見るまでもなく名指しで
|
|
172
|
+
// 解消できる (既定の宛先は会話 ID = thread_id だが、同じ会話に複数の接続がぶら下がると会話 ID
|
|
173
|
+
// では一意に定まらない。そのときに接続 ID で絞り込む、という降格した位置づけ)。
|
|
152
174
|
const nameability = cliKind === "codex" ? "nameable" : claudeCandidatesNameability(candidates)
|
|
153
175
|
const nameable = nameability === "nameable"
|
|
154
176
|
const lead = targetSessionId
|
|
@@ -158,7 +180,19 @@ function ambiguousTargetOutcome(cliKind, targetSessionId, candidates) {
|
|
|
158
180
|
: `この cwd には live な ${cliKind} セッションが複数あるため、宛先を省略すると一意に定まりません。`
|
|
159
181
|
let remedy
|
|
160
182
|
if (cliKind === "codex") {
|
|
161
|
-
|
|
183
|
+
// ⚠️ 「stream_id を指定」とだけ案内しない (2026-08-05)。--list / --list-all が既定で表示する
|
|
184
|
+
// 宛先は **会話 ID (thread_id)** で、stream_id は「同じ会話に複数の接続がある稀なケース」の
|
|
185
|
+
// 曖昧解消用へ降格した。利用者が画面で見ているのは --list が候補に併記する `stream=…` の
|
|
186
|
+
// 表記なので、案内もその表記に合わせる (見えていない用語を指示すると手詰まりになる)。
|
|
187
|
+
// ⚠️ 曖昧の中身は 2 通りで、必要な id が違うので案内も分ける:
|
|
188
|
+
// - 宛先省略 (targetSessionId 無し) … **別々の会話**が同居している。会話 ID で名指しできる。
|
|
189
|
+
// - 明示宛先が曖昧 (会話 ID 指定) … **同じ会話に複数の接続**。会話 ID では絞れず接続 ID が要る。
|
|
190
|
+
remedy = targetSessionId
|
|
191
|
+
? `同じ会話に複数の接続がぶら下がっているため、会話 ID では一意に定まりません。` +
|
|
192
|
+
`--list が候補に併記する接続 ID (stream=…) を --to に指定してください。`
|
|
193
|
+
: `--list が表示する宛先 (会話 ID) を --to に指定して名指ししてください。` +
|
|
194
|
+
`同じ会話に複数の接続がぶら下がっていて会話 ID でも一意に定まらない場合は、` +
|
|
195
|
+
`--list が併記する接続 ID (stream=…) を --to に指定してください。`
|
|
162
196
|
} else if (nameability === "nameable") {
|
|
163
197
|
remedy =
|
|
164
198
|
`候補はそれぞれ別の会話で session_id が異なるため、session_id で名指しできます ` +
|
|
@@ -191,6 +225,27 @@ function deliveredStateFor(kind) {
|
|
|
191
225
|
return kind === "codex" ? "delivered" : "dispatched"
|
|
192
226
|
}
|
|
193
227
|
|
|
228
|
+
/**
|
|
229
|
+
* 「受理済み・順番待ち」(`delivery_queued`) の結果を組み立てる。
|
|
230
|
+
*
|
|
231
|
+
* ⚠️ 明示宛先 / 宛先省略の **両分岐で同じ形**を返すため 1 箇所に置く (delivered /
|
|
232
|
+
* delivery_unconfirmed は 2 か所に書かれており、実際にキーが片方だけずれる余地がある)。
|
|
233
|
+
* `ok: true` にするのは、これが失敗ではなく **受理** だから。送信元に「失敗した」と読ませると
|
|
234
|
+
* 再送を誘発し、キューに 2 通並ぶ (今回直した二重配送そのもの)。ただし配達済みとも読ませない
|
|
235
|
+
* よう `delivery_state` は `delivered` ではなく `queued` にし、hint で待てばよいことを伝える。
|
|
236
|
+
*/
|
|
237
|
+
function queuedResult(r) {
|
|
238
|
+
return {
|
|
239
|
+
ok: true,
|
|
240
|
+
delivered_to: r.session ?? null,
|
|
241
|
+
delivery_id: r.delivery_id,
|
|
242
|
+
delivery_state: "queued",
|
|
243
|
+
accepted_via: r.accepted_via,
|
|
244
|
+
reason: r.reason,
|
|
245
|
+
hint: DELIVERY_QUEUED_HINT,
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
194
249
|
export class SiblingInjectBridge {
|
|
195
250
|
/**
|
|
196
251
|
* @param {{dir?: string, claudeBridge?: object, codexBridge?: object, logger?: object}} [opts]
|
|
@@ -343,8 +398,10 @@ export class SiblingInjectBridge {
|
|
|
343
398
|
} else if (isExplicit) {
|
|
344
399
|
// 明示宛先: session_id (claude) / stream_id|thread_id (codex) で cwd 一致下に確定解決。
|
|
345
400
|
// sendToStream は {status:"delivered"|"not_found"|"ambiguous"} を返す。
|
|
401
|
+
// request_id を渡すのは、bridge 側が配送 ID を **決定的に** 導出して同一依頼の再処理を
|
|
402
|
+
// 重複ガードで止められるようにするため (claude bridge は余分な引数を無視する)。
|
|
346
403
|
const r = bridge?.sendToStream
|
|
347
|
-
? await bridge.sendToStream(cwd, target_session_id, message, injectSource)
|
|
404
|
+
? await bridge.sendToStream(cwd, target_session_id, message, injectSource, request_id)
|
|
348
405
|
: { status: "not_found" }
|
|
349
406
|
if (r.status === "delivered") {
|
|
350
407
|
result = {
|
|
@@ -356,6 +413,8 @@ export class SiblingInjectBridge {
|
|
|
356
413
|
accepted_via: r.accepted_via,
|
|
357
414
|
item_id: r.item_id,
|
|
358
415
|
}
|
|
416
|
+
} else if (r.status === "delivery_queued") {
|
|
417
|
+
result = queuedResult(r)
|
|
359
418
|
} else if (r.status === "delivery_unconfirmed" || r.status === "delivery_failed") {
|
|
360
419
|
result = {
|
|
361
420
|
ok: false,
|
|
@@ -410,7 +469,7 @@ export class SiblingInjectBridge {
|
|
|
410
469
|
// あった。sendToCwd を status 返却にして解決と配送を 1 呼び出しに統合し、delivered_to は
|
|
411
470
|
// **実配送先 (r.session)** を返す。
|
|
412
471
|
const r = bridge?.sendToCwd
|
|
413
|
-
? await bridge.sendToCwd(cwd, text, injectSource)
|
|
472
|
+
? await bridge.sendToCwd(cwd, text, injectSource, request_id)
|
|
414
473
|
: { status: "not_found" }
|
|
415
474
|
if (r.status === "delivered") {
|
|
416
475
|
result = {
|
|
@@ -422,6 +481,8 @@ export class SiblingInjectBridge {
|
|
|
422
481
|
accepted_via: r.accepted_via,
|
|
423
482
|
item_id: r.item_id,
|
|
424
483
|
}
|
|
484
|
+
} else if (r.status === "delivery_queued") {
|
|
485
|
+
result = queuedResult(r)
|
|
425
486
|
} else if (r.status === "delivery_unconfirmed" || r.status === "delivery_failed") {
|
|
426
487
|
result = {
|
|
427
488
|
ok: false,
|
|
@@ -467,6 +528,7 @@ export class SiblingInjectBridge {
|
|
|
467
528
|
delivery_id: result.delivery_id,
|
|
468
529
|
delivery_state: result.delivery_state,
|
|
469
530
|
durable_state: result.durable_state,
|
|
531
|
+
reason: result.reason,
|
|
470
532
|
}
|
|
471
533
|
: {
|
|
472
534
|
error: result.error,
|
|
@@ -478,7 +540,9 @@ export class SiblingInjectBridge {
|
|
|
478
540
|
result.ok
|
|
479
541
|
? result.delivery_state === "delivered"
|
|
480
542
|
? "sibling-inject delivered"
|
|
481
|
-
:
|
|
543
|
+
: result.delivery_state === "queued"
|
|
544
|
+
? "sibling-inject queued"
|
|
545
|
+
: "sibling-inject dispatched"
|
|
482
546
|
: "sibling-inject failed",
|
|
483
547
|
)
|
|
484
548
|
await this._writeResult(request_id, result)
|