@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.25",
3
+ "version": "0.8.27",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -8,15 +8,18 @@
8
8
  * 設計:
9
9
  * - 対象ファイルのバイトオフセットを記録し、増分だけ読んで行単位でパース
10
10
  * - fs.watch(file) の change イベントで増分読み取り (取りこぼし対策に軽いポーリング併用)
11
- * - DISPLAY_TYPES (user/assistant/system/result) の行だけ onEvent に渡す
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 で jsonl→SDK message 整形を共通化 (フィールド取りこぼし防止)。
19
- import { DISPLAY_TYPES, normalizeHistoryEvent } from "./claude-history.mjs"
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
- if (!obj || !DISPLAY_TYPES.has(obj.type)) continue
102
- const event = normalizeHistoryEvent(obj)
104
+ const event = historyEventFromLine(obj)
105
+ if (!event) continue
103
106
  try {
104
107
  onEvent(event)
105
108
  } catch (err) {
@@ -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
- if (!obj || typeof obj !== "object") continue
301
- if (!DISPLAY_TYPES.has(obj.type)) continue
302
- // SDK message と同じ shape にする (余分な meta は落とす)。整形は共通関数に集約。
303
- let event = normalizeHistoryEvent(obj)
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
- const deliveryId = randomUUID()
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,
@@ -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 は stream_id が常に一意なので候補を見るまでもなく名指しできる。
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
- remedy = `stream_id で名指ししてください (--list で確認)。`
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
- : "sibling-inject dispatched"
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)