@cocorograph/hub-agent 0.8.0 → 0.8.2

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/src/main.mjs CHANGED
@@ -327,7 +327,11 @@ export function isReadOnlyRpc(type) {
327
327
  return (
328
328
  type === "claude.history.request" ||
329
329
  type === "claude.sessions.request" ||
330
- type === "codex.sessions.request"
330
+ type === "codex.sessions.request" ||
331
+ // T06603 (2026-07-25): キュー snapshot の pull。in-memory 配列を読むだけで副作用が無く、
332
+ // かつ「直列キュー前方の重い処理で再同期が遅延する」と送信待ちチップの固着が長引く
333
+ // (再同期は固着を直すための経路なので、詰まりやすい経路に置いてはならない)。
334
+ type === "claude.queue.request"
331
335
  )
332
336
  }
333
337
 
@@ -2859,6 +2863,13 @@ const seenCodexChatInputIds = makeSeenInputIdRegistry()
2859
2863
  // Promise へ合流させる (settle 時に元の .then/.catch が 1 回だけ ack/nack を出す)。
2860
2864
  const codexInflightByInputId = new Map()
2861
2865
 
2866
+ // T06603 (2026-07-25): claude.input の **in-flight** dedup 用レジストリ (input_id -> Promise)。
2867
+ // 上の codex 版と同じ理由・同じ流儀。claude 側は sendMessage が resident query への
2868
+ // reconcile (applyFlagSettings の control request 往復) を await するため settle まで数秒
2869
+ // かかることがあり、その窓で ackWatchdog(5s) の再送が入ると `seen` 未登録のまま二重投入され、
2870
+ // 同一 input_id がキューに 2 件積まれて drain 後も 1 件残る (送信待ちチップが消えない)。
2871
+ const claudeInflightByInputId = new Map()
2872
+
2862
2873
  /**
2863
2874
  * input_id 付き pty.data (tracked 入力) の処理。write 成功で pty.input.ack を返し、
2864
2875
  * stream 不在なら pty.error を返して browser の再 attach → 再送に繋げる。
@@ -2933,7 +2944,20 @@ export function handleClaudeChatInput(msg, ctx) {
2933
2944
  ctx.client.send({ type: "claude.input.ack", stream_id, input_id })
2934
2945
  return undefined
2935
2946
  }
2936
- return ctx.claudeBridge
2947
+ if (input_id && claudeInflightByInputId.has(input_id)) {
2948
+ // T06603 (2026-07-25): **処理中** の同一 input_id (browser の ackWatchdog が 5s で
2949
+ // 再送してくるが、こちらはまだ settle していない窓)。codex 側は round4 major#1 で
2950
+ // 対処済みだったが claude 側だけ欠けており、`seen` は settle 後にしか登録されないため
2951
+ // 再送がそのまま二重投入されていた (同じ本文が 2 回実行され、かつ同一 id の項目が
2952
+ // キューに 2 つ積まれて drain 後も 1 つ残る = 「実行されたのにキューから消えない」)。
2953
+ // 既存 Promise へ合流させ、ack/nack は元の 1 回だけ出す。
2954
+ ctx.logger?.info?.(
2955
+ { stream_id, input_id },
2956
+ "claude.input in-flight dedup (join, no re-dispatch)",
2957
+ )
2958
+ return claudeInflightByInputId.get(input_id)
2959
+ }
2960
+ const p = ctx.claudeBridge
2937
2961
  .input({
2938
2962
  stream_id,
2939
2963
  message: msg.message,
@@ -2972,6 +2996,13 @@ export function handleClaudeChatInput(msg, ctx) {
2972
2996
  })
2973
2997
  }
2974
2998
  })
2999
+ .finally(() => {
3000
+ // in-flight レジストリから外す (成功時は seen が以降の再送を dedup、
3001
+ // 失敗時は再送で正当にリトライさせる)。codex 版と同じ流儀。
3002
+ if (input_id) claudeInflightByInputId.delete(input_id)
3003
+ })
3004
+ if (input_id) claudeInflightByInputId.set(input_id, p)
3005
+ return p
2975
3006
  }
2976
3007
 
2977
3008
  /**
@@ -3386,6 +3417,12 @@ async function dispatch(msg, ctx) {
3386
3417
  // 旧 agent にはこのフラグが無く、browser は楽観アイテムの×を出さない (ゴースト
3387
3418
  // 実行防止のフォールバック)。
3388
3419
  supports_queue_input_id: true,
3420
+ // pull 型再同期 capability (T06603, 2026-07-25): この agent は
3421
+ // claude.queue.request に claude.queue.response で答え、queue_state に
3422
+ // 版番号 (seq) と受理済み input_id (accepted) を載せることを通知する。
3423
+ // 旧 agent はこのフラグが無く、browser は従来の「配信到達に依存する」
3424
+ // 挙動へフォールバックする (混在期間も安全に段階デプロイできる)。
3425
+ supports_queue_snapshot: true,
3389
3426
  })
3390
3427
  // dispatch全体はWSメッセージ順を守る直列キュー上にある。ここで履歴I/Oをawaitすると
3391
3428
  // 同一接続のpermission/input等まで停止するため、readyの同期送信後は非同期でpushする。
@@ -3770,11 +3807,67 @@ async function dispatch(msg, ctx) {
3770
3807
  }
3771
3808
  return
3772
3809
  }
3773
- case "claude.queue.cancel":
3810
+ case "claude.queue.cancel": {
3774
3811
  // 送信待ち (pending) メッセージを id 指定で取り消す (0.6.26)。
3775
- if (!ctx.claudeBridge) return
3776
- ctx.claudeBridge.cancelQueued({ stream_id: msg.stream_id, id: msg.id })
3812
+ // T06603 cross-review 4 巡目: 取り消せなかった (stream 不在) ときは browser へ
3813
+ // 失敗を返す。browser は要求時にチップを消さず「取消中」印を付けて権威の確認を
3814
+ // 待つため、無応答だと印が外れず固着する。request_id をエコーして相関させる。
3815
+ // T06603 cross-review 5 巡目: 結末 (cancelled / already_started / stream_missing) を
3816
+ // **必ず** 返す。「pending から消えた」だけでは取消成功と実行開始済みを区別できず、
3817
+ // 特に drain の started フレームを取りこぼしていると、browser は取消成功に見せかけた
3818
+ // まま実行を続行してしまう (発言バブルも欠けたまま)。request_id + input_id で相関させる。
3819
+ const cancelRes = {
3820
+ type: "claude.queue.response",
3821
+ stream_id: msg.stream_id,
3822
+ request_id: msg.request_id || null,
3823
+ input_id: msg.id || null,
3824
+ }
3825
+ if (!ctx.claudeBridge) {
3826
+ ctx.client.send({ ...cancelRes, outcome: "stream_missing" })
3827
+ return
3828
+ }
3829
+ const canceled = ctx.claudeBridge.cancelQueued({
3830
+ stream_id: msg.stream_id,
3831
+ id: msg.id,
3832
+ })
3833
+ // ⚠️ T06603 cross-review 6 巡目: **outcome を先に送る**。キュー snapshot の再表明を
3834
+ // 先に流すと、backend は到着順に relay するため browser は空 snapshot で
3835
+ // 「キューに無い + 受理済み = 実行完了」と判定してチップを消し、後続の outcome は
3836
+ // 対象不在で no-op になる (already_started のバブル補償が発火しない)。
3837
+ ctx.client.send({
3838
+ ...cancelRes,
3839
+ outcome: canceled?.outcome || "stream_missing",
3840
+ })
3841
+ canceled?.flush?.()
3842
+ return
3843
+ }
3844
+ case "claude.queue.request": {
3845
+ // pull 型再同期 (T06603, 2026-07-25): browser が権威キュー snapshot を引きにくる。
3846
+ // 従来キュー表示の是正手段は「agent が turn settle 毎に force 再送する queue_state」
3847
+ // だけで、その配信が 1 通落ちると browser には自己修復の手段が無く送信待ちチップが
3848
+ // 恒久固着した。ここで「いつでも引き直せる」経路を与えるのが根治の中核。
3849
+ // 応答は要求元 stream へ返す (backend が cockpit_stream_<stream_id> へ relay)。
3850
+ const queueReq = {
3851
+ type: "claude.queue.response",
3852
+ stream_id: msg.stream_id,
3853
+ request_id: msg.request_id || null,
3854
+ }
3855
+ if (!ctx.claudeBridge) {
3856
+ ctx.client.send({ ...queueReq, error: "stream_missing" })
3857
+ return
3858
+ }
3859
+ const got = ctx.claudeBridge.queueSnapshot({ stream_id: msg.stream_id })
3860
+ if (!got) {
3861
+ ctx.client.send({ ...queueReq, error: "stream_missing" })
3862
+ return
3863
+ }
3864
+ ctx.client.send({
3865
+ ...queueReq,
3866
+ session_id: got.session_id,
3867
+ event: got.snapshot,
3868
+ })
3777
3869
  return
3870
+ }
3778
3871
  case "claude.detach":
3779
3872
  if (!ctx.claudeBridge) return
3780
3873
  ctx.claudeBridge.detach({ stream_id: msg.stream_id })
@@ -45,6 +45,16 @@ import { verifySiblingTokenAny, siblingTokenCwd } from "./sibling-token.mjs"
45
45
  export const SIBLING_REQUESTS_DIR =
46
46
  process.env.COCKPIT_SIBLING_REQUESTS_DIR || "/tmp/cockpit_sibling_requests"
47
47
 
48
+ const DELIVERY_UNCONFIRMED_HINT =
49
+ "turn RPC は受理されましたが userMessage echo を確認できませんでした。既に届いている可能性があり、" +
50
+ "再送すると重複配送になります。再送前に対象セッションの受信状態または rollout 監査を確認してください。"
51
+ const DELIVERY_FAILED_HINT =
52
+ "turn RPC の受付前または受付中に失敗しました。対象セッションを確認して再送してください。"
53
+
54
+ function deliveredStateFor(kind) {
55
+ return kind === "codex" ? "delivered" : "dispatched"
56
+ }
57
+
48
58
  export class SiblingInjectBridge {
49
59
  /**
50
60
  * @param {{dir?: string, claudeBridge?: object, codexBridge?: object, logger?: object}} [opts]
@@ -186,11 +196,34 @@ export class SiblingInjectBridge {
186
196
  } else if (isExplicit) {
187
197
  // 明示宛先: session_id (claude) / stream_id|thread_id (codex) で cwd 一致下に確定解決。
188
198
  // sendToStream は {status:"delivered"|"not_found"|"ambiguous"} を返す。
189
- const r = bridge?.sendToStream?.(cwd, target_session_id, message, injectSource) ?? {
190
- status: "not_found",
191
- }
199
+ const r = bridge?.sendToStream
200
+ ? await bridge.sendToStream(cwd, target_session_id, message, injectSource)
201
+ : { status: "not_found" }
192
202
  if (r.status === "delivered") {
193
- result = { ok: true, delivered_to: r.session }
203
+ result = {
204
+ ok: true,
205
+ delivered_to: r.session,
206
+ delivery_id: r.delivery_id,
207
+ delivery_state: deliveredStateFor(target_cli_kind),
208
+ durable_state: r.durable_state,
209
+ accepted_via: r.accepted_via,
210
+ item_id: r.item_id,
211
+ }
212
+ } else if (r.status === "delivery_unconfirmed" || r.status === "delivery_failed") {
213
+ result = {
214
+ ok: false,
215
+ error: r.status,
216
+ delivered_to: r.session,
217
+ delivery_id: r.delivery_id,
218
+ delivery_state:
219
+ r.status === "delivery_unconfirmed" ? "accepted_unconfirmed" : "failed",
220
+ accepted_via: r.accepted_via,
221
+ reason: r.reason,
222
+ hint:
223
+ r.status === "delivery_unconfirmed"
224
+ ? DELIVERY_UNCONFIRMED_HINT
225
+ : DELIVERY_FAILED_HINT,
226
+ }
194
227
  } else if (r.status === "ambiguous") {
195
228
  // 宛先 id が同一 cwd の複数 live セッションに一致し一意に定まらない。
196
229
  // ⚠️ 解消手段は CLI で異なる (T06271 レビュー4 minor):
@@ -235,9 +268,34 @@ export class SiblingInjectBridge {
235
268
  // (サイレント誤配送 + delivered_to に実配送先でない sameCwd[0] を報告する成功偽装) が
236
269
  // あった。sendToCwd を status 返却にして解決と配送を 1 呼び出しに統合し、delivered_to は
237
270
  // **実配送先 (r.session)** を返す。
238
- const r = bridge?.sendToCwd?.(cwd, text, injectSource) ?? { status: "not_found" }
271
+ const r = bridge?.sendToCwd
272
+ ? await bridge.sendToCwd(cwd, text, injectSource)
273
+ : { status: "not_found" }
239
274
  if (r.status === "delivered") {
240
- result = { ok: true, delivered_to: r.session ?? null }
275
+ result = {
276
+ ok: true,
277
+ delivered_to: r.session ?? null,
278
+ delivery_id: r.delivery_id,
279
+ delivery_state: deliveredStateFor(target_cli_kind),
280
+ durable_state: r.durable_state,
281
+ accepted_via: r.accepted_via,
282
+ item_id: r.item_id,
283
+ }
284
+ } else if (r.status === "delivery_unconfirmed" || r.status === "delivery_failed") {
285
+ result = {
286
+ ok: false,
287
+ error: r.status,
288
+ delivered_to: r.session ?? null,
289
+ delivery_id: r.delivery_id,
290
+ delivery_state:
291
+ r.status === "delivery_unconfirmed" ? "accepted_unconfirmed" : "failed",
292
+ accepted_via: r.accepted_via,
293
+ reason: r.reason,
294
+ hint:
295
+ r.status === "delivery_unconfirmed"
296
+ ? DELIVERY_UNCONFIRMED_HINT
297
+ : DELIVERY_FAILED_HINT,
298
+ }
241
299
  } else if (r.status === "ambiguous") {
242
300
  result = {
243
301
  ok: false,
@@ -258,10 +316,24 @@ export class SiblingInjectBridge {
258
316
  target_cli_kind,
259
317
  op: op || "normal",
260
318
  ...(result.ok
261
- ? { delivered_to: result.delivered_to }
262
- : { error: result.error, live_sessions: result.live_sessions }),
319
+ ? {
320
+ delivered_to: result.delivered_to,
321
+ delivery_id: result.delivery_id,
322
+ delivery_state: result.delivery_state,
323
+ durable_state: result.durable_state,
324
+ }
325
+ : {
326
+ error: result.error,
327
+ delivery_id: result.delivery_id,
328
+ delivery_state: result.delivery_state,
329
+ live_sessions: result.live_sessions,
330
+ }),
263
331
  },
264
- result.ok ? "sibling-inject delivered" : "sibling-inject failed",
332
+ result.ok
333
+ ? result.delivery_state === "delivered"
334
+ ? "sibling-inject delivered"
335
+ : "sibling-inject dispatched"
336
+ : "sibling-inject failed",
265
337
  )
266
338
  await this._writeResult(request_id, result)
267
339
  // 要求ファイルは処理後に必ず削除する (成否問わず、再処理させないため)。