@cocorograph/hub-agent 0.8.9 → 0.8.11

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.
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Claude 側との対応 (詳細は main.mjs の codex.* dispatch = Slice 3 で配線):
10
10
  * claude.attach → attach() (initialize → thread/start か thread/resume)
11
- * claude.input → input() (turn/start、ターン中は turn/steer)
11
+ * claude.input → input() (idle は turn/start、生成中は次ターン FIFO)
12
12
  * claude.interrupt → interrupt() (turn/interrupt)
13
13
  * claude.detach → detach() (TTL 付き graceful close)
14
14
  * (codex 固有) → keepalive() (閲覧中の生存シグナル。pty.resize 相当)
@@ -43,6 +43,8 @@
43
43
  import { EventEmitter } from "node:events"
44
44
  import { randomUUID } from "node:crypto"
45
45
 
46
+ import { recordInjectSourcePersistent } from "./inject-source-store.mjs"
47
+
46
48
  import { AsyncTtlCache } from "./async-ttl-cache.mjs"
47
49
  import { CodexAppServerClient } from "./codex-appserver-client.mjs"
48
50
  import {
@@ -134,6 +136,10 @@ const DEAD_TERMINAL_TTL_MS =
134
136
  const TURN_START_TIMEOUT_MS =
135
137
  Number(process.env.HUB_AGENT_CODEX_TURN_START_TIMEOUT_MS) || 30 * 1000
136
138
 
139
+ // browser が ack を取りこぼしても queue snapshot から受理済みと再確認できるよう、
140
+ // 直近 input_id を bounded ring で保持する (Claude queue_state.accepted と同じ契約)。
141
+ const ACCEPTED_INPUT_ID_RING_MAX = 256
142
+
137
143
  // sibling-inject の turn RPC が成功した後、app-server が同じ userMessage を
138
144
  // item/completed で echo するまで待つ上限。RPC accepted と userMessage delivered を
139
145
  // 分離し、旧 stream / 初期化競合で rollout に残らない入力を成功扱いしない。
@@ -253,9 +259,8 @@ export function deriveResumeTurnState(thread) {
253
259
  const lastTurnStatus = typeof lastTurn?.status === "string" ? lastTurn.status : null
254
260
  // 権威シグナル: スレッドが active のときだけ「ターン実行中」。fresh resume は idle。
255
261
  const turnActive = threadStatus === "active"
256
- // active かつ最後のターンが inProgress のときのみ activeTurnId を復元する
257
- // (input() が turn/steer へ正しく分岐できるように)。それ以外は null のまま
258
- // = 次の入力は turn/start になり、固着しない。
262
+ // active かつ最後のターンが inProgress のときのみ activeTurnId を復元する。
263
+ // 通常入力はこの ID がある間は次ターン FIFO で待ち、一致する completed 後に start する。
259
264
  const activeTurnId =
260
265
  turnActive && lastTurnStatus === "inProgress" && typeof lastTurn?.id === "string"
261
266
  ? lastTurn.id
@@ -267,10 +272,9 @@ export function deriveResumeTurnState(thread) {
267
272
  * 配達保証 (S2, 2026-07-05): `input()` が reject したエラーを `codex.input.nack` の
268
273
  * `reason` へ分類する。
269
274
  *
270
- * S3 Codex 堅牢化 (同日マージ) 以降、`CodexAppServerClient.request()` のタイムアウトや
271
- * `CodexStreamBridge.input()` の turn/steer→turn/start 自動リトライ失敗は、reject する
272
- * Error に権威ある `.reason` (`'timeout'` / `'turn_mismatch_retry_failed'`) を直接
273
- * 付与するようになった。付与されていればそれを最優先で採用し (main.mjs が組み立てる
275
+ * `CodexAppServerClient.request()` のタイムアウト等、bridge が判断できる失敗は reject する
276
+ * Error に権威ある `.reason` を直接付与する。付与されていればそれを最優先で採用し
277
+ * (main.mjs が組み立てる
274
278
  * `codex.error.reason` と同じ値になり、frontend 側の分岐を一致させられる)、付与されて
275
279
  * いない場合 (例: `_require()` の「セッション不在」) のみメッセージ文言からの
276
280
  * ベストエフォート分類にフォールバックする。
@@ -479,19 +483,20 @@ export class CodexStreamBridge extends EventEmitter {
479
483
  // この session を引き当て _tryReattach の session.ready 合流に入り、二重 app-server を作らない。
480
484
  resumeThreadId: resumeThreadId || null,
481
485
  activeTurnId: null,
482
- // 案B (T06542, codex-review round3 major#1): 同時入力を **session 単位の単一 FIFO + 単一 pump**で
483
- // 直列化し、二重 turn/start・queue 追い越し・並行 steer を構造的に排除する。旧 turnStarting/
484
- // pendingInputs の二重路 (queue 経路 + 直通経路) が overtaking と並行リトライ二重ターンの温床だった。
486
+ // 通常入力は session 単位の次ターン FIFO。activeTurnId がある間は App Server へ
487
+ // turn/steer せずここで待ち、対象 turn/completed 後に 1 件ずつ turn/start する。
485
488
  /** @type {Array<{input: object[], model?: string, effort?: string, resolve: Function,
486
- * reject: Function, retriedAsStart?: boolean, deliveryId?: string,
487
- * sourceStreamId?: string}>} round3 major#1: **session 単位の単一 FIFO**。
488
- * 全 input() はここに積まれ、単一 pump (_pumpInputs) が **1 件ずつ直列**に処理する。直通経路を
489
- * 廃したので、drain 中の新規 input が queue を追い越したり、並行 turn/start・steer を発行して
490
- * 二重ターンになる事故が起きない。各 promise は **実 delivery (turn/start か turn/steer の RPC)**で
491
- * settle する (main.mjs が codex.input.ack/nack を実配達に合わせて出せる)。 */
489
+ * reject: Function, deliveryId?: string, inputId?: string, text: string,
490
+ * sourceStreamId?: string, acceptedAsQueued?: boolean}>} */
492
491
  inputQueue: [],
493
492
  /** round3 major#1: pump が走行中か (単一 pump を保証する singleflight フラグ)。 */
494
493
  pumping: false,
494
+ /** turn/start RPC を送出中の item。queue snapshot では pending から除外する。 */
495
+ dispatchingInput: null,
496
+ /** queue_state の session 内単調増加版番号。generation は session.seq を使う。 */
497
+ queueStateSeq: 0,
498
+ /** ack 喪失からの自己回復用、受理済み browser input_id の bounded ring。 */
499
+ acceptedInputIds: [],
495
500
  /** @type {{resolve: Function, timer: any} | null} round3 major#2: turn/start 後に turn/started
496
501
  * (activeTurnId 確定) を待つ barrier の待機者。timeout 付きでハングを防ぐ。 */
497
502
  _turnActiveWaiter: null,
@@ -941,6 +946,82 @@ export class CodexStreamBridge extends EventEmitter {
941
946
  return Array.from(new Set(this.sessions.values())).filter((s) => !s.dead)
942
947
  }
943
948
 
949
+ _recordAcceptedInput(session, inputId) {
950
+ if (!session || typeof inputId !== "string" || !inputId) return
951
+ const ids = session.acceptedInputIds || (session.acceptedInputIds = [])
952
+ const at = ids.indexOf(inputId)
953
+ if (at !== -1) ids.splice(at, 1)
954
+ ids.push(inputId)
955
+ if (ids.length > ACCEPTED_INPUT_ID_RING_MAX) {
956
+ ids.splice(0, ids.length - ACCEPTED_INPUT_ID_RING_MAX)
957
+ }
958
+ }
959
+
960
+ _pendingInputItems(session) {
961
+ if (!session || !Array.isArray(session.inputQueue)) return []
962
+ return session.inputQueue.filter((item) => item !== session.dispatchingInput)
963
+ }
964
+
965
+ /** Claude の queue_state と同じ payload を Codex notification 上へ載せる。 */
966
+ queueState(stream_id) {
967
+ const session = this._require(stream_id)
968
+ const pending = this._pendingInputItems(session)
969
+ return {
970
+ type: "queue_state",
971
+ pending: pending.length,
972
+ messages: pending.map((item) => item.text),
973
+ items: pending.map((item) => ({ id: item.inputId || item.deliveryId || "", text: item.text })),
974
+ seq: session.queueStateSeq || 0,
975
+ generation: session.seq,
976
+ accepted: [...(session.acceptedInputIds || [])],
977
+ }
978
+ }
979
+
980
+ /** 次ターン FIFO に残っている browser 入力を id 指定で取り消す。
981
+ * dispatchingInput は既に turn/start RPC 中なので対象外(実行開始済みとして扱う)。 */
982
+ cancelQueued({ stream_id, id }) {
983
+ const session = this._require(stream_id)
984
+ if (!id) return false
985
+ const index = session.inputQueue.findIndex(
986
+ (item) =>
987
+ (item?.inputId === id || item?.deliveryId === id) &&
988
+ item !== session.dispatchingInput,
989
+ )
990
+ if (index < 0) {
991
+ this._emitQueueState(session)
992
+ return false
993
+ }
994
+ const [item] = session.inputQueue.splice(index, 1)
995
+ if (!item.acceptedAsQueued) {
996
+ const err = Object.assign(new Error("codex queued input cancelled"), {
997
+ reason: "cancelled",
998
+ })
999
+ item.reject(err)
1000
+ }
1001
+ this._emitQueueState(session)
1002
+ return true
1003
+ }
1004
+
1005
+ _emitQueueState(session, startedItem = null) {
1006
+ if (!session || session.dead) return
1007
+ session.queueStateSeq = (session.queueStateSeq || 0) + 1
1008
+ const snapshot = this.queueState(session.stream_id)
1009
+ if (startedItem?.text) {
1010
+ snapshot.started = [startedItem.text]
1011
+ snapshot.startedIds = [startedItem.inputId || startedItem.deliveryId || null]
1012
+ } else {
1013
+ snapshot.started = []
1014
+ snapshot.startedIds = []
1015
+ }
1016
+ this.emit("event", {
1017
+ stream_id: session.stream_id,
1018
+ producer_seq: session.seq,
1019
+ thread_id: session.threadId,
1020
+ cwd: session.cwd,
1021
+ notification: { method: "cockpit/queue_state", params: snapshot },
1022
+ })
1023
+ }
1024
+
944
1025
  /** 連携カード種別 + 配達確認: 注入本文 → entry を session の FIFO に控える。sendToCwd/
945
1026
  * sendToStream が input() を呼ぶ直前に記録し、直後の userMessage echo を
946
1027
  * _tagInjectSourceOnItem が拾う。entry 自体が delivery promise の handle であり、
@@ -1118,7 +1199,27 @@ export class CodexStreamBridge extends EventEmitter {
1118
1199
  )
1119
1200
  break
1120
1201
  case "turn/completed":
1121
- session.activeTurnId = null
1202
+ {
1203
+ const completedTurnId = msg.params?.turn?.id || null
1204
+ // App Server の対象 turn と現在の権威 activeTurnId が一致したときだけ idle 化する。
1205
+ // 遅延した別 turn の completed で現ターンを落として FIFO を早期排出しない。
1206
+ if (
1207
+ session.activeTurnId &&
1208
+ completedTurnId !== session.activeTurnId
1209
+ ) {
1210
+ this.logger?.warn?.(
1211
+ {
1212
+ stream_id: session.stream_id,
1213
+ thread_id: session.threadId,
1214
+ active_turn_id: session.activeTurnId,
1215
+ completed_turn_id: completedTurnId,
1216
+ },
1217
+ "ignored stale codex turn/completed",
1218
+ )
1219
+ break
1220
+ }
1221
+ session.activeTurnId = null
1222
+ }
1122
1223
  // round3 major#2: turn/started を取りこぼしたまま turn/completed が来た異常時も barrier が
1123
1224
  // 固着しないよう解除する (pump が次 item を fresh に turn/start できる)。
1124
1225
  this._settleTurnActiveWaiter(session)
@@ -1136,6 +1237,8 @@ export class CodexStreamBridge extends EventEmitter {
1136
1237
  )
1137
1238
  // Stage 5: turn 完了後の reap 予約は不要。以降 idle になれば周期 reconcile が
1138
1239
  // 長 idle TTL / LRU で扱う(detach 済みでも 7 日 warm 保持)。
1240
+ // 通常送信 FIFO は matching turn/completed だけを排出境界にする。
1241
+ void this._pumpInputs(session)
1139
1242
  break
1140
1243
  case "item/completed":
1141
1244
  // T06541 案1: 完了した ThreadItem を高忠実度履歴に追記する(cwd reattach で返す用)。
@@ -1256,176 +1359,142 @@ export class CodexStreamBridge extends EventEmitter {
1256
1359
  * @param {string} [args.effort] - 公式スキーマ `TurnStartParams.effort`
1257
1360
  * ("Override the reasoning effort for this turn and subsequent turns")。turn/start でのみ有効。
1258
1361
  * @param {string} [args.deliveryId] sibling-inject の内部相関 ID
1259
- * @returns {Promise<{method: "turn/start"|"turn/steer", deliveryId?: string}>}
1260
- *
1261
- * ⚠️ `turn/steer` (`TurnSteerParams`) には model/effort フィールドが存在しない
1262
- * (公式スキーマで未対応と確認済み、2026-07-03)。そのためターン実行中 (busy) に
1263
- * model/effort を渡しても turn/steer 経由では反映されず、次の非 busy な input()
1264
- * (= turn/start) から有効になる。呼び出し側 (main.mjs) はこれを承知の上で常に
1265
- * model/effort を渡してよい (steer 分岐では単に無視される)。
1362
+ * @param {string} [args.inputId] browser の配達確認・キュー表示用 ID
1363
+ * @returns {Promise<{method: "turn/start"|"queued", deliveryId?: string}>}
1266
1364
  */
1267
- async input({ stream_id, message, model, effort, deliveryId }) {
1365
+ async input({ stream_id, message, model, effort, deliveryId, inputId }) {
1268
1366
  const session = this._require(stream_id)
1269
1367
  // Stage 5: _touch で lastActivityMs を更新 = LRU 先頭へ。ターン開始で activeTurnId が
1270
1368
  // 立ち、reconcile は healthy busy を protected 扱いにするので実行中に kill されない。
1271
1369
  // 案B: stream_id を渡し端末別活性も更新(死端末 GC が入力継続端末を掃除しない)。
1272
1370
  this._touch(session, stream_id)
1273
1371
  const input = userInputFromMessage(message)
1274
- // round3 major#1: 全 input を **session 単位の単一 FIFO** に積み、単一 pump に処理させる。
1275
- // promise は pump が実 delivery で settle する (即 resolve しない)。
1372
+ const text = input.map((part) => part?.text || "").join("\n")
1373
+ if (inputId && session.acceptedInputIds?.includes(inputId)) {
1374
+ return { method: session.activeTurnId ? "queued" : "turn/start", deliveryId }
1375
+ }
1376
+ const queuedAtArrival =
1377
+ !!session.activeTurnId ||
1378
+ session.pumping === true ||
1379
+ !!session._turnActiveWaiter
1380
+ this._recordAcceptedInput(session, inputId)
1276
1381
  return new Promise((resolve, reject) => {
1277
- session.inputQueue.push({
1382
+ const item = {
1278
1383
  input,
1384
+ text,
1279
1385
  model,
1280
1386
  effort,
1281
1387
  deliveryId,
1388
+ inputId,
1282
1389
  sourceStreamId: stream_id,
1283
1390
  resolve,
1284
1391
  reject,
1285
- retriedAsStart: false,
1286
- })
1392
+ acceptedAsQueued: queuedAtArrival && !!inputId,
1393
+ }
1394
+ session.inputQueue.push(item)
1395
+ if (item.acceptedAsQueued) {
1396
+ resolve({ method: "queued", deliveryId })
1397
+ this.logger?.info?.(
1398
+ {
1399
+ stream_id,
1400
+ thread_id: session.threadId,
1401
+ input_id: inputId,
1402
+ queued: this._pendingInputItems(session).length,
1403
+ active_turn_id: session.activeTurnId,
1404
+ },
1405
+ "codex busy, message queued for next turn",
1406
+ )
1407
+ this._emitQueueState(session)
1408
+ }
1287
1409
  void this._pumpInputs(session)
1288
1410
  })
1289
1411
  }
1290
1412
 
1291
1413
  /**
1292
- * round3 major#1 (案B / T06542): session 単位の単一 FIFO pump。**1 件ずつ直列**に処理し、
1293
- * 並行 turn/start・turn/steer や queue の追い越しを構造的に排除する (singleflight)。
1294
- * - 非 busy (activeTurnId 無し): turn/start で新ターン。成功後、次を steer にするため
1295
- * turn/started (activeTurnId 確定) を **timeout 付き**で待つ (round3 major#2: ハング防止)。
1296
- * - busy (activeTurnId 有り): turn/steer で追い打ち。
1297
- * - turn/steer 失敗 (expectedTurnId 不一致など): activeTurnId を捨て、同 item を **1 回だけ**
1298
- * turn/start にフォールバックして自己修復する (旧 self-heal を FIFO 内で継続。並行はしない)。
1299
- * 各 item の promise は実 delivery の RPC 結果で settle する。
1414
+ * 通常送信の次ターン FIFO pump。activeTurnId がある間は何も App Server へ送らず、
1415
+ * matching turn/completed が activeTurnId を解除した後だけ先頭を turn/start する。
1416
+ * これにより通常クリックと明示的な turn/steer / turn/interrupt の意味を分離する。
1300
1417
  */
1301
1418
  async _pumpInputs(session) {
1302
- if (session.pumping) return
1419
+ if (session.pumping || session.activeTurnId) return
1303
1420
  session.pumping = true
1304
1421
  try {
1305
1422
  while (session.inputQueue.length > 0) {
1423
+ if (session.activeTurnId) return
1306
1424
  if (session.dead) {
1307
1425
  this._drainRejectQueue(session, { reason: "queue_dead" })
1308
1426
  return
1309
1427
  }
1310
1428
  const item = session.inputQueue[0]
1311
- // round4 major#3: 試行種別 (steer/start) を **await の前に** 確定する。await 中に
1312
- // turn/completed が来て session.activeTurnId が null に戻ると、catch 時点の値では steer 試行が
1313
- // start と誤判定され、self-heal リトライがスキップされて item が誤 reject される。
1314
- const attemptedSteer = !!session.activeTurnId
1429
+ session.dispatchingInput = item
1315
1430
  try {
1316
- if (attemptedSteer) {
1317
- await session.client.request("turn/steer", {
1318
- threadId: session.threadId,
1319
- expectedTurnId: session.activeTurnId,
1320
- input: item.input,
1321
- })
1322
- session.inputQueue.shift()
1323
- this.logger?.info?.(
1324
- {
1325
- delivery_id: item.deliveryId || null,
1326
- stream_id: item.sourceStreamId || session.stream_id,
1327
- current_stream_id: session.stream_id,
1328
- thread_id: session.threadId,
1329
- attempted_steer: true,
1330
- rpc_method: "turn/steer",
1331
- rpc_result: "accepted",
1332
- },
1333
- "codex input RPC settled",
1334
- )
1335
- item.resolve({ method: "turn/steer", deliveryId: item.deliveryId })
1336
- } else {
1337
- // round4 major#2: turn/start dispatch の **前に** 世代を控える。start 応答後に
1338
- // turn/started(+completed) が pump 再開前に処理されても、世代前進で「ターンが始まった」を
1339
- // 検知でき、誤 timeout しない。
1340
- const genBefore = session.turnGeneration
1341
- const params = { threadId: session.threadId, input: item.input }
1342
- if (item.model) params.model = item.model
1343
- if (item.effort) params.effort = item.effort
1344
- await session.client.request("turn/start", params)
1345
- session.inputQueue.shift()
1346
- this.logger?.info?.(
1347
- {
1348
- delivery_id: item.deliveryId || null,
1349
- stream_id: item.sourceStreamId || session.stream_id,
1350
- current_stream_id: session.stream_id,
1351
- thread_id: session.threadId,
1352
- attempted_steer: false,
1353
- rpc_method: "turn/start",
1354
- rpc_result: "accepted",
1355
- },
1356
- "codex input RPC settled",
1357
- )
1431
+ const genBefore = session.turnGeneration
1432
+ const params = { threadId: session.threadId, input: item.input }
1433
+ if (item.model) params.model = item.model
1434
+ if (item.effort) params.effort = item.effort
1435
+ await session.client.request("turn/start", params)
1436
+ session.inputQueue.shift()
1437
+ session.dispatchingInput = null
1438
+ this.logger?.info?.(
1439
+ {
1440
+ delivery_id: item.deliveryId || null,
1441
+ input_id: item.inputId || null,
1442
+ stream_id: item.sourceStreamId || session.stream_id,
1443
+ current_stream_id: session.stream_id,
1444
+ thread_id: session.threadId,
1445
+ rpc_method: "turn/start",
1446
+ rpc_result: "accepted",
1447
+ },
1448
+ "codex input RPC settled",
1449
+ )
1450
+ if (!item.acceptedAsQueued) {
1358
1451
  item.resolve({ method: "turn/start", deliveryId: item.deliveryId })
1359
- // 次の item を steer にするため turn/started (= 世代前進) を待つ。timeout でハングを防ぐ。
1360
- // timeout したら「ターンが正しく始まらなかった」とみなし残り queue を reject して打ち切る。
1361
- const becameActive = await this._waitTurnActive(session, genBefore)
1362
- if (!becameActive) {
1363
- this._drainRejectQueue(session, { reason: "turn_start_timeout" })
1364
- return
1365
- }
1366
1452
  }
1367
- } catch (err) {
1368
- if (attemptedSteer && !item.retriedAsStart) {
1369
- this.logger?.warn?.(
1370
- {
1371
- delivery_id: item.deliveryId || null,
1372
- stream_id: item.sourceStreamId || session.stream_id,
1373
- current_stream_id: session.stream_id,
1374
- thread_id: session.threadId,
1375
- attempted_steer: true,
1376
- rpc_method: "turn/steer",
1377
- rpc_result: "retry_as_start",
1378
- err: err instanceof Error ? err.message : String(err),
1379
- },
1380
- "codex input RPC settled",
1381
- )
1382
- // turn/steer 失敗 → 自己修復: activeTurnId を捨て、同 item を turn/start で 1 回だけリトライ
1383
- // (shift しない = 同 item を次ループの start 経路で再処理)。並行はしない (単一 pump)。
1384
- // 元の steer エラーを stash して、リトライも失敗したとき合成メッセージを作れるようにする。
1385
- session.activeTurnId = null
1386
- item.retriedAsStart = true
1387
- item.priorSteerError = err instanceof Error ? err : new Error(String(err))
1388
- continue
1453
+ this._emitQueueState(session, item.acceptedAsQueued ? item : null)
1454
+ const becameActive = await this._waitTurnActive(session, genBefore)
1455
+ if (!becameActive) {
1456
+ this._drainRejectQueue(session, { reason: "turn_start_timeout" })
1457
+ return
1389
1458
  }
1390
- // start 失敗 or リトライ済み steer 失敗 → この item を reject。activeTurnId は捨てる
1391
- // (ターンは始まっていない/不明)。次の item は fresh に turn/start する。
1459
+ // 通常は active のままなので matching turn/completed まで停止する。start 応答前に
1460
+ // started+completed 済みの fast turn だけ、そのまま次の FIFO item へ進める。
1461
+ if (session.activeTurnId) return
1462
+ } catch (err) {
1392
1463
  session.inputQueue.shift()
1393
- session.activeTurnId = null
1394
- let e
1395
- if (item.retriedAsStart && item.priorSteerError) {
1396
- // steer→start リトライがともに失敗: 合成メッセージ + reason (main.mjs の classifyCodexInputError
1397
- // が reason を優先採用。従来の単一 input() 実装と同じ診断を保つ)。
1398
- e = Object.assign(
1399
- new Error(
1400
- `turn/steer failed (${item.priorSteerError.message}) and turn/start retry also failed: ${err instanceof Error ? err.message : String(err)}`,
1401
- ),
1402
- { reason: "turn_mismatch_retry_failed", cause: err },
1403
- )
1404
- } else {
1405
- e = err instanceof Error ? err : new Error(String(err))
1406
- if (!e.reason) e.reason = "start_error"
1407
- }
1464
+ session.dispatchingInput = null
1465
+ const e = err instanceof Error ? err : new Error(String(err))
1466
+ if (!e.reason) e.reason = "start_error"
1408
1467
  this.logger?.error?.(
1409
1468
  {
1410
1469
  delivery_id: item.deliveryId || null,
1470
+ input_id: item.inputId || null,
1411
1471
  stream_id: item.sourceStreamId || session.stream_id,
1412
1472
  current_stream_id: session.stream_id,
1413
1473
  thread_id: session.threadId,
1414
- attempted_steer: attemptedSteer,
1415
- rpc_method: attemptedSteer ? "turn/steer" : "turn/start",
1474
+ rpc_method: "turn/start",
1416
1475
  rpc_result: "failed",
1417
1476
  err: e.message,
1418
1477
  reason: e.reason || null,
1419
1478
  },
1420
1479
  "codex input RPC settled",
1421
1480
  )
1422
- item.reject(e)
1481
+ if (item.acceptedAsQueued) {
1482
+ this.emit("error", {
1483
+ stream_id: item.sourceStreamId || session.stream_id,
1484
+ thread_id: session.threadId,
1485
+ error: e.message,
1486
+ })
1487
+ } else {
1488
+ item.reject(e)
1489
+ }
1490
+ this._emitQueueState(session)
1423
1491
  }
1424
1492
  }
1425
1493
  } finally {
1494
+ session.dispatchingInput = null
1426
1495
  session.pumping = false
1427
1496
  // pumping=false 直後の enqueue 競合窓を拾い直す (取りこぼし防止)。再帰は 1 段のみ。
1428
- if (session.inputQueue.length > 0 && !session.dead) {
1497
+ if (session.inputQueue.length > 0 && !session.dead && !session.activeTurnId) {
1429
1498
  void this._pumpInputs(session)
1430
1499
  }
1431
1500
  }
@@ -1484,15 +1553,24 @@ export class CodexStreamBridge extends EventEmitter {
1484
1553
  if (!session || !Array.isArray(session.inputQueue) || session.inputQueue.length === 0) return
1485
1554
  const pending = session.inputQueue
1486
1555
  session.inputQueue = []
1556
+ session.dispatchingInput = null
1487
1557
  const reason = cause?.reason || "queue_dead"
1488
- for (const { reject } of pending) {
1489
- reject(
1490
- Object.assign(new Error("codex input dropped: turn could not be delivered"), {
1491
- reason,
1492
- cause,
1493
- }),
1558
+ for (const item of pending) {
1559
+ const error = Object.assign(
1560
+ new Error("codex input dropped: turn could not be delivered"),
1561
+ { reason, cause },
1494
1562
  )
1563
+ if (item.acceptedAsQueued) {
1564
+ this.emit("error", {
1565
+ stream_id: item.sourceStreamId || session.stream_id,
1566
+ thread_id: session.threadId,
1567
+ error: error.message,
1568
+ })
1569
+ } else {
1570
+ item.reject(error)
1571
+ }
1495
1572
  }
1573
+ if (!session.dead) this._emitQueueState(session)
1496
1574
  }
1497
1575
 
1498
1576
  /** @returns {Promise<void>} */
@@ -1947,6 +2025,9 @@ export class CodexStreamBridge extends EventEmitter {
1947
2025
  reason: delivery?.reason || "echo_timeout",
1948
2026
  }
1949
2027
  }
2028
+ // Claude と同じ永続ストアへ記録し、hub-agent 再起動後に rollout を読む場合も
2029
+ // peer / orchestrator 注入を通常の user 依頼として表示しない。
2030
+ recordInjectSourcePersistent(session.cwd, text, source).catch(() => {})
1950
2031
  // rollout は内部形式・flush 遅延の影響を受けるため primary oracle にしない。echo delivered を
1951
2032
  // 返した後、注入前 offset + threadId + 本文 hash で非同期監査し、結果だけを構造化ログへ残す。
1952
2033
  void this._deliveryAuditVerify(durableAudit)