@cocorograph/hub-agent 0.8.10 → 0.8.12

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.10",
3
+ "version": "0.8.12",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -525,9 +525,10 @@ export class ClaudeStreamSession {
525
525
  this._busy = false
526
526
  /** SDK-P0-3 (2026-07-10): 現ターンが既に終端処理済みか (result / idle / abort の
527
527
  * いずれか 1 回だけ _busy=false + drain させるための冪等ガード)。resident query は
528
- * 1 ターンにつき `result` と `session_state_changed:idle` の**両方**が流れうるため、
529
- * 素朴に両方で drain すると二重 drain (FIFO 崩れ) を起こす。ターン開始時
530
- * (_beginResidentTurn) に false へ戻す。 */
528
+ * `result` の後にもバックグラウンド処理が続き、最後に権威的な
529
+ * `session_state_changed:idle` が流れることがある。実 SDK では result 後に idle が
530
+ * 来ないターンもあるため、result / idle の先着 1 件だけが settle し、遅延 idle は
531
+ * _staleIdleExpected で次ターンから隔離する。ターン開始時に false へ戻す。 */
531
532
  this._turnSettled = false
532
533
  /** SDK-P0-3: 現ターンが 1 件でもストリームイベントを出したか。
533
534
  * 「前ターンの遅延 idle が、drain で始まったばかりの次ターンを誤終了させる」レースの
@@ -539,9 +540,9 @@ export class ClaudeStreamSession {
539
540
  this._turnProgressed = false
540
541
  /** SDK-P0-4 (2026-07-13): ターン連番。ログと帰属デバッグ用 (_beginResidentTurn で ++)。 */
541
542
  this._turnSeq = 0
542
- /** SDK-P0-4: 「直前ターンの idle がこれから 1 件届きうる」状態。result で settle したとき、
543
- * および abort (interrupt) でターンを畳んだときに arm する (SDK は result → idle の順で
544
- * 流し、interrupt 後にも中断ターンの idle を流すため)。arm 中に届いた idle は
543
+ /** SDK-P0-4: 「直前ターンの idle がこれから 1 件届きうる」状態。abort (interrupt) で
544
+ * ターンを先行して畳んだときに arm する (interrupt 後にも中断ターンの idle が流れるため)。
545
+ * 通常完了は idle 自身で畳むので arm しない。arm 中に届いた idle は
545
546
  * **前ターンのもの**として 1 件だけ握り潰し、現ターンを終端しない。
546
547
  * 解除条件: (a) idle を 1 件消費した (b) SDK の running が届いた (= 前ターンの全イベントは
547
548
  * 順序保証により既に通過済み) (c) 現ターンの result が届いた (d) STALE_IDLE_GUARD_MS 経過。 */
@@ -1131,9 +1132,10 @@ export class ClaudeStreamSession {
1131
1132
  this._busy = true
1132
1133
  this._abortController = new AbortController()
1133
1134
  let aborted = false
1134
- // RC-4: このターンで SDK の result イベントが届いたか。届かずに終わった
1135
- // (abort / 異常終了) ときだけ onTurnSettled でチャット信号を waiting へ落とす。
1135
+ // `session_state_changed:idle` がターン終了の正本。SDK が idle を流さず generator が
1136
+ // 閉じた旧/異常経路だけ合成 idle と onTurnSettled で waiting へ落とす。
1136
1137
  let resultDelivered = false
1138
+ let idleDelivered = false
1137
1139
 
1138
1140
  const options = {
1139
1141
  cwd: this.cwd,
@@ -1185,6 +1187,7 @@ export class ClaudeStreamSession {
1185
1187
  resultDelivered = true
1186
1188
  if (typeof msg.session_id === "string") this.sessionId = msg.session_id
1187
1189
  }
1190
+ if (isSessionIdleEvent(msg)) idleDelivered = true
1188
1191
  try {
1189
1192
  this._emit(msg)
1190
1193
  } catch (err) {
@@ -1224,12 +1227,9 @@ export class ClaudeStreamSession {
1224
1227
  if (aborted) {
1225
1228
  this.logger?.info({ stream_id: this.stream_id }, "claude turn aborted")
1226
1229
  }
1227
- // RC-4: result を届けずにターンが終わった (abort / 異常終了) 場合、チャット信号の
1228
- // status=processing が解除されず三点リーダー/ステータスドットが TTL まで固着する。
1229
- // ターン確定として onTurnSettled を呼び processing→waiting へ落とす。正常完了は
1230
- // result が既に waiting にしているので !resultDelivered のときだけ。_drainPending で
1231
- // 次ターン (processing) が始まる前に呼ぶこと (順序: ここ → reap/drain)。
1232
- if (!resultDelivered) {
1230
+ // RC-4: idle を届けずに generator が閉じた旧/異常経路では、チャット信号と browser の
1231
+ // 双方へ同じ終了境界を補う。_drainPending で次ターンを始める前に行うこと。
1232
+ if (!idleDelivered) {
1233
1233
  try {
1234
1234
  this.onTurnSettled?.()
1235
1235
  } catch {
@@ -1244,11 +1244,24 @@ export class ClaudeStreamSession {
1244
1244
  // フッターで表示する)。uuid を持たせるのは重複排除 (isDuplicateEvent) が result
1245
1245
  // 署名 (session_id + num_turns + duration_ms + cost) で判定するため — 合成 result
1246
1246
  // はこれらが毎回同値になり、2 回目以降の中断で「重複」と誤判定され捨てられるのを防ぐ。
1247
+ if (!resultDelivered) {
1248
+ try {
1249
+ this._emit({
1250
+ type: "result",
1251
+ subtype: aborted ? "aborted_by_user" : "turn_settled",
1252
+ uuid: randomUUID(),
1253
+ session_id: this.sessionId ?? undefined,
1254
+ timestamp: new Date().toISOString(),
1255
+ })
1256
+ } catch {
1257
+ /* ignore */
1258
+ }
1259
+ }
1247
1260
  try {
1248
1261
  this._emit({
1249
- type: "result",
1250
- subtype: aborted ? "aborted_by_user" : "turn_settled",
1251
- uuid: randomUUID(),
1262
+ type: "system",
1263
+ subtype: "session_state_changed",
1264
+ state: "idle",
1252
1265
  session_id: this.sessionId ?? undefined,
1253
1266
  timestamp: new Date().toISOString(),
1254
1267
  })
@@ -1618,9 +1631,10 @@ export class ClaudeStreamSession {
1618
1631
  }
1619
1632
  }
1620
1633
 
1621
- /** SDK-P0-3 (2026-07-10): resident の「ターン終端」を 1 箇所に集約する (result / idle 共通)。
1622
- * 同一ターンで 2 回以上呼ばれても最初の 1 回しか作用しない (_turnSettled ガード)。
1623
- * これにより `result` → `idle` の順で両方届く通常ケースでも drain は 1 回だけになる。
1634
+ /** resident の「ターン終端」を 1 箇所に集約する。
1635
+ * SDK は result 後に session_state_changed:idle を返すことがあるが、実環境では
1636
+ * idle が欠落するターンもある。result / idle の先着を終端とし、result で畳んだ後の
1637
+ * 遅延 idle はガードで次ターンから隔離する。
1624
1638
  * @param {string} reason "result" | "idle" — ログ用
1625
1639
  * @param {(msg: string) => void} [denyPending] 未解決 permission を閉じるクロージャ
1626
1640
  */
@@ -1629,10 +1643,9 @@ export class ClaudeStreamSession {
1629
1643
  this._turnSettled = true
1630
1644
  denyPending?.("turn ended")
1631
1645
  this._busy = false
1632
- // SDK-P0-4: result で畳んだターンは、この後に自分の idle を必ず 1 件流す
1633
- // (sdk.d.ts: "'idle' fires after heldBackResult flushes … authoritative turn-over signal")。
1634
- // drain が同期で次ターンを開始するため、その idle が新ターンに誤帰属しないようガードを張る。
1635
- // idle 自身で畳んだ場合は既にその idle を消費済みなので張らない。
1646
+ // result の後に同一ターンの idle が遅れて届く場合がある。pending drain は同期的に
1647
+ // 次ターンの busy を立てるため、その idle を新ターンへ誤帰属させないよう先にガードする。
1648
+ // idle 自身で畳んだ場合は既に消費済みなので張らない。
1636
1649
  if (reason === "result") this._armStaleIdleGuard()
1637
1650
  // 改修4 (A): シリアライズした pending があれば次の 1 件を InputQueue へ流す。
1638
1651
  // ultracode (0.6.28): _drainResidentPending は applyFlagSettings を await するため
@@ -1649,7 +1662,7 @@ export class ClaudeStreamSession {
1649
1662
  /** 改修4 (A): ターン完了時に pending の先頭 1 件を InputQueue へ流す (ターンのシリアライズ)。
1650
1663
  * queue_state を更新して送信待ちチップを drain させる (frontend がバブル昇格する)。
1651
1664
  * ultracode (0.6.28): 次ターンの目標 ultracode 状態へ reconcile してから push する
1652
- * (await するため async 化。result ハンドラからは fire-and-forget で呼ばれる)。 */
1665
+ * (await するため async 化。idle ハンドラからは fire-and-forget で呼ばれる)。 */
1653
1666
  async _drainResidentPending() {
1654
1667
  if (this._closed) return
1655
1668
  if (this._pendingMessages.length === 0) {
@@ -1693,7 +1706,7 @@ export class ClaudeStreamSession {
1693
1706
 
1694
1707
  /** 改修2+4: 常駐 query を 1 回だけ起動し、streaming input キューから複数ターンを処理する。
1695
1708
  * system/init は最初の 1 回のみ (毎ターン init = 文脈/キャッシュ温め直しを解消)。各ターンは
1696
- * 終端シグナル (result / session_state_changed:idle) で _busy=false にするが query は継続し
1709
+ * 権威的終端シグナル (session_state_changed:idle) で _busy=false にするが query は継続し
1697
1710
  * 次 input を待つ。close (inputQueue.close) で generator が終わり query が完走する。
1698
1711
  * SDK-P0-3 (2026-07-10): 終端を result だけに頼ると、SDK が idle でターンを終えたときに
1699
1712
  * _busy が固着し、以降の送信が pending へ永久滞留する (T06052)。
@@ -1749,6 +1762,7 @@ export class ClaudeStreamSession {
1749
1762
  const q = this.sdk.query({ prompt: this._inputQueue, options })
1750
1763
  this._residentQuery = q
1751
1764
  for await (const msg of q) {
1765
+ let suppressEmit = false
1752
1766
  if (
1753
1767
  msg?.type === "system" &&
1754
1768
  msg?.subtype === "init" &&
@@ -1758,11 +1772,11 @@ export class ClaudeStreamSession {
1758
1772
  }
1759
1773
  if (msg?.type === "result") {
1760
1774
  if (typeof msg.session_id === "string") this.sessionId = msg.session_id
1761
- // SDK-P0-4: 現ターンの result が届いた = 前ターンの遅延 idle は順序保証により
1762
- // 既に通過済み (届いたなら消費済み / 届かないなら二度と来ない)。ガードを解除してから
1763
- // settle する (settle が result 用のガードを張り直す)。
1775
+ // 現ターンの result が届いた時点で前ターンの遅延 idle は順序保証により
1776
+ // 既に通過済み。古いガードを解除し、result 用のガードを張り直して settle する。
1777
+ // 実 SDK では result 後に idle が来ないターンがあるため、idle 待ちだけにすると
1778
+ // _busy が永久に true のまま固着し、以後の入力が pending から drain されない。
1764
1779
  this._clearStaleIdleGuard()
1765
- // ターン完了: 未解決 permission を閉じ、次 input 受付へ (query は継続)。
1766
1780
  this._settleResidentTurn("result", denyPending)
1767
1781
  } else if (isSessionIdleEvent(msg)) {
1768
1782
  // SDK-P0-3 (2026-07-10): result が届かずに idle でターンが終わるケースの救済。
@@ -1777,11 +1791,14 @@ export class ClaudeStreamSession {
1777
1791
  // 2) それ以外で _busy かつ (running を観測済み or 何かイベントを出した) → 現ターンの終端。
1778
1792
  if (this._staleIdleExpected) {
1779
1793
  this._clearStaleIdleGuard()
1794
+ // browser にも転送しない。前ターンの遅延 idle を新ターンの reducer が受けると、
1795
+ // 3点リーダーと送信ボタンだけが誤って idle になる。
1796
+ suppressEmit = true
1780
1797
  this.logger?.debug?.(
1781
1798
  { stream_id: this.stream_id, turn: this._turnSeq },
1782
1799
  "stale idle from previous turn ignored",
1783
1800
  )
1784
- } else if (this._busy && (this._turnRunningSeen || this._turnProgressed)) {
1801
+ } else if (this._busy) {
1785
1802
  this._settleResidentTurn("idle", denyPending)
1786
1803
  // per-message の RC-4 と対称に、チャット信号 (サイドバードット) を
1787
1804
  // processing→waiting へ落とす。result 経由なら main.mjs が result を見て
@@ -1804,13 +1821,15 @@ export class ClaudeStreamSession {
1804
1821
  // および session_state_changed の running / requires_action もここに含む。
1805
1822
  this._turnProgressed = true
1806
1823
  }
1807
- try {
1808
- this._emit(msg)
1809
- } catch (err) {
1810
- this.logger?.warn(
1811
- { err: err.message, stream_id: this.stream_id },
1812
- "onEvent callback threw",
1813
- )
1824
+ if (!suppressEmit) {
1825
+ try {
1826
+ this._emit(msg)
1827
+ } catch (err) {
1828
+ this.logger?.warn(
1829
+ { err: err.message, stream_id: this.stream_id },
1830
+ "onEvent callback threw",
1831
+ )
1832
+ }
1814
1833
  }
1815
1834
  }
1816
1835
  } catch (err) {
@@ -1949,6 +1968,19 @@ export class ClaudeStreamSession {
1949
1968
  } catch {
1950
1969
  /* ignore */
1951
1970
  }
1971
+ // 通常完了と同じ権威境界を browser へ即時提示する。SDK が後から流す
1972
+ // 中断ターン由来の実 idle は _staleIdleExpected で抑止する。
1973
+ try {
1974
+ this._emit({
1975
+ type: "system",
1976
+ subtype: "session_state_changed",
1977
+ state: "idle",
1978
+ session_id: this.sessionId ?? undefined,
1979
+ timestamp: new Date().toISOString(),
1980
+ })
1981
+ } catch {
1982
+ /* ignore */
1983
+ }
1952
1984
  }
1953
1985
  // T05699 残タスク #2-1 (2026-07-04): interrupt() は現ターンの result を発火しない
1954
1986
  // (query は継続、次 input 受付継続のみ)。そのため abort 時点で _pendingMessages に
@@ -2256,7 +2288,12 @@ export class ClaudeStreamBridge extends EventEmitter {
2256
2288
  },
2257
2289
  "claude stream reattached to live session",
2258
2290
  )
2259
- return { stream_id, resuming: true, reattached: true }
2291
+ return {
2292
+ stream_id,
2293
+ resuming: true,
2294
+ reattached: true,
2295
+ turn_active: live._busy === true,
2296
+ }
2260
2297
  }
2261
2298
  }
2262
2299
  const session = new ClaudeStreamSession({
@@ -2369,7 +2406,11 @@ export class ClaudeStreamBridge extends EventEmitter {
2369
2406
  )
2370
2407
  // per-message モデルでは attach 時点では query() を起動しない (即 ready)。
2371
2408
  // 最初の input で query を起動する。
2372
- return { stream_id, resuming: !!resumeSessionId }
2409
+ return {
2410
+ stream_id,
2411
+ resuming: !!resumeSessionId,
2412
+ turn_active: session._busy === true,
2413
+ }
2373
2414
  }
2374
2415
 
2375
2416
  /**
@@ -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 相当)
@@ -136,6 +136,10 @@ const DEAD_TERMINAL_TTL_MS =
136
136
  const TURN_START_TIMEOUT_MS =
137
137
  Number(process.env.HUB_AGENT_CODEX_TURN_START_TIMEOUT_MS) || 30 * 1000
138
138
 
139
+ // browser が ack を取りこぼしても queue snapshot から受理済みと再確認できるよう、
140
+ // 直近 input_id を bounded ring で保持する (Claude queue_state.accepted と同じ契約)。
141
+ const ACCEPTED_INPUT_ID_RING_MAX = 256
142
+
139
143
  // sibling-inject の turn RPC が成功した後、app-server が同じ userMessage を
140
144
  // item/completed で echo するまで待つ上限。RPC accepted と userMessage delivered を
141
145
  // 分離し、旧 stream / 初期化競合で rollout に残らない入力を成功扱いしない。
@@ -255,9 +259,8 @@ export function deriveResumeTurnState(thread) {
255
259
  const lastTurnStatus = typeof lastTurn?.status === "string" ? lastTurn.status : null
256
260
  // 権威シグナル: スレッドが active のときだけ「ターン実行中」。fresh resume は idle。
257
261
  const turnActive = threadStatus === "active"
258
- // active かつ最後のターンが inProgress のときのみ activeTurnId を復元する
259
- // (input() が turn/steer へ正しく分岐できるように)。それ以外は null のまま
260
- // = 次の入力は turn/start になり、固着しない。
262
+ // active かつ最後のターンが inProgress のときのみ activeTurnId を復元する。
263
+ // 通常入力はこの ID がある間は次ターン FIFO で待ち、一致する completed 後に start する。
261
264
  const activeTurnId =
262
265
  turnActive && lastTurnStatus === "inProgress" && typeof lastTurn?.id === "string"
263
266
  ? lastTurn.id
@@ -269,10 +272,9 @@ export function deriveResumeTurnState(thread) {
269
272
  * 配達保証 (S2, 2026-07-05): `input()` が reject したエラーを `codex.input.nack` の
270
273
  * `reason` へ分類する。
271
274
  *
272
- * S3 Codex 堅牢化 (同日マージ) 以降、`CodexAppServerClient.request()` のタイムアウトや
273
- * `CodexStreamBridge.input()` の turn/steer→turn/start 自動リトライ失敗は、reject する
274
- * Error に権威ある `.reason` (`'timeout'` / `'turn_mismatch_retry_failed'`) を直接
275
- * 付与するようになった。付与されていればそれを最優先で採用し (main.mjs が組み立てる
275
+ * `CodexAppServerClient.request()` のタイムアウト等、bridge が判断できる失敗は reject する
276
+ * Error に権威ある `.reason` を直接付与する。付与されていればそれを最優先で採用し
277
+ * (main.mjs が組み立てる
276
278
  * `codex.error.reason` と同じ値になり、frontend 側の分岐を一致させられる)、付与されて
277
279
  * いない場合 (例: `_require()` の「セッション不在」) のみメッセージ文言からの
278
280
  * ベストエフォート分類にフォールバックする。
@@ -481,19 +483,20 @@ export class CodexStreamBridge extends EventEmitter {
481
483
  // この session を引き当て _tryReattach の session.ready 合流に入り、二重 app-server を作らない。
482
484
  resumeThreadId: resumeThreadId || null,
483
485
  activeTurnId: null,
484
- // 案B (T06542, codex-review round3 major#1): 同時入力を **session 単位の単一 FIFO + 単一 pump**で
485
- // 直列化し、二重 turn/start・queue 追い越し・並行 steer を構造的に排除する。旧 turnStarting/
486
- // pendingInputs の二重路 (queue 経路 + 直通経路) が overtaking と並行リトライ二重ターンの温床だった。
486
+ // 通常入力は session 単位の次ターン FIFO。activeTurnId がある間は App Server へ
487
+ // turn/steer せずここで待ち、対象 turn/completed 後に 1 件ずつ turn/start する。
487
488
  /** @type {Array<{input: object[], model?: string, effort?: string, resolve: Function,
488
- * reject: Function, retriedAsStart?: boolean, deliveryId?: string,
489
- * sourceStreamId?: string}>} round3 major#1: **session 単位の単一 FIFO**。
490
- * 全 input() はここに積まれ、単一 pump (_pumpInputs) が **1 件ずつ直列**に処理する。直通経路を
491
- * 廃したので、drain 中の新規 input が queue を追い越したり、並行 turn/start・steer を発行して
492
- * 二重ターンになる事故が起きない。各 promise は **実 delivery (turn/start か turn/steer の RPC)**で
493
- * settle する (main.mjs が codex.input.ack/nack を実配達に合わせて出せる)。 */
489
+ * reject: Function, deliveryId?: string, inputId?: string, text: string,
490
+ * sourceStreamId?: string, acceptedAsQueued?: boolean}>} */
494
491
  inputQueue: [],
495
492
  /** round3 major#1: pump が走行中か (単一 pump を保証する singleflight フラグ)。 */
496
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: [],
497
500
  /** @type {{resolve: Function, timer: any} | null} round3 major#2: turn/start 後に turn/started
498
501
  * (activeTurnId 確定) を待つ barrier の待機者。timeout 付きでハングを防ぐ。 */
499
502
  _turnActiveWaiter: null,
@@ -943,6 +946,82 @@ export class CodexStreamBridge extends EventEmitter {
943
946
  return Array.from(new Set(this.sessions.values())).filter((s) => !s.dead)
944
947
  }
945
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
+
946
1025
  /** 連携カード種別 + 配達確認: 注入本文 → entry を session の FIFO に控える。sendToCwd/
947
1026
  * sendToStream が input() を呼ぶ直前に記録し、直後の userMessage echo を
948
1027
  * _tagInjectSourceOnItem が拾う。entry 自体が delivery promise の handle であり、
@@ -1120,7 +1199,27 @@ export class CodexStreamBridge extends EventEmitter {
1120
1199
  )
1121
1200
  break
1122
1201
  case "turn/completed":
1123
- 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
+ }
1124
1223
  // round3 major#2: turn/started を取りこぼしたまま turn/completed が来た異常時も barrier が
1125
1224
  // 固着しないよう解除する (pump が次 item を fresh に turn/start できる)。
1126
1225
  this._settleTurnActiveWaiter(session)
@@ -1138,6 +1237,8 @@ export class CodexStreamBridge extends EventEmitter {
1138
1237
  )
1139
1238
  // Stage 5: turn 完了後の reap 予約は不要。以降 idle になれば周期 reconcile が
1140
1239
  // 長 idle TTL / LRU で扱う(detach 済みでも 7 日 warm 保持)。
1240
+ // 通常送信 FIFO は matching turn/completed だけを排出境界にする。
1241
+ void this._pumpInputs(session)
1141
1242
  break
1142
1243
  case "item/completed":
1143
1244
  // T06541 案1: 完了した ThreadItem を高忠実度履歴に追記する(cwd reattach で返す用)。
@@ -1258,176 +1359,142 @@ export class CodexStreamBridge extends EventEmitter {
1258
1359
  * @param {string} [args.effort] - 公式スキーマ `TurnStartParams.effort`
1259
1360
  * ("Override the reasoning effort for this turn and subsequent turns")。turn/start でのみ有効。
1260
1361
  * @param {string} [args.deliveryId] sibling-inject の内部相関 ID
1261
- * @returns {Promise<{method: "turn/start"|"turn/steer", deliveryId?: string}>}
1262
- *
1263
- * ⚠️ `turn/steer` (`TurnSteerParams`) には model/effort フィールドが存在しない
1264
- * (公式スキーマで未対応と確認済み、2026-07-03)。そのためターン実行中 (busy) に
1265
- * model/effort を渡しても turn/steer 経由では反映されず、次の非 busy な input()
1266
- * (= turn/start) から有効になる。呼び出し側 (main.mjs) はこれを承知の上で常に
1267
- * model/effort を渡してよい (steer 分岐では単に無視される)。
1362
+ * @param {string} [args.inputId] browser の配達確認・キュー表示用 ID
1363
+ * @returns {Promise<{method: "turn/start"|"queued", deliveryId?: string}>}
1268
1364
  */
1269
- async input({ stream_id, message, model, effort, deliveryId }) {
1365
+ async input({ stream_id, message, model, effort, deliveryId, inputId }) {
1270
1366
  const session = this._require(stream_id)
1271
1367
  // Stage 5: _touch で lastActivityMs を更新 = LRU 先頭へ。ターン開始で activeTurnId が
1272
1368
  // 立ち、reconcile は healthy busy を protected 扱いにするので実行中に kill されない。
1273
1369
  // 案B: stream_id を渡し端末別活性も更新(死端末 GC が入力継続端末を掃除しない)。
1274
1370
  this._touch(session, stream_id)
1275
1371
  const input = userInputFromMessage(message)
1276
- // round3 major#1: 全 input を **session 単位の単一 FIFO** に積み、単一 pump に処理させる。
1277
- // 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)
1278
1381
  return new Promise((resolve, reject) => {
1279
- session.inputQueue.push({
1382
+ const item = {
1280
1383
  input,
1384
+ text,
1281
1385
  model,
1282
1386
  effort,
1283
1387
  deliveryId,
1388
+ inputId,
1284
1389
  sourceStreamId: stream_id,
1285
1390
  resolve,
1286
1391
  reject,
1287
- retriedAsStart: false,
1288
- })
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
+ }
1289
1409
  void this._pumpInputs(session)
1290
1410
  })
1291
1411
  }
1292
1412
 
1293
1413
  /**
1294
- * round3 major#1 (案B / T06542): session 単位の単一 FIFO pump。**1 件ずつ直列**に処理し、
1295
- * 並行 turn/start・turn/steer や queue の追い越しを構造的に排除する (singleflight)。
1296
- * - 非 busy (activeTurnId 無し): turn/start で新ターン。成功後、次を steer にするため
1297
- * turn/started (activeTurnId 確定) を **timeout 付き**で待つ (round3 major#2: ハング防止)。
1298
- * - busy (activeTurnId 有り): turn/steer で追い打ち。
1299
- * - turn/steer 失敗 (expectedTurnId 不一致など): activeTurnId を捨て、同 item を **1 回だけ**
1300
- * turn/start にフォールバックして自己修復する (旧 self-heal を FIFO 内で継続。並行はしない)。
1301
- * 各 item の promise は実 delivery の RPC 結果で settle する。
1414
+ * 通常送信の次ターン FIFO pump。activeTurnId がある間は何も App Server へ送らず、
1415
+ * matching turn/completed が activeTurnId を解除した後だけ先頭を turn/start する。
1416
+ * これにより通常クリックと明示的な turn/steer / turn/interrupt の意味を分離する。
1302
1417
  */
1303
1418
  async _pumpInputs(session) {
1304
- if (session.pumping) return
1419
+ if (session.pumping || session.activeTurnId) return
1305
1420
  session.pumping = true
1306
1421
  try {
1307
1422
  while (session.inputQueue.length > 0) {
1423
+ if (session.activeTurnId) return
1308
1424
  if (session.dead) {
1309
1425
  this._drainRejectQueue(session, { reason: "queue_dead" })
1310
1426
  return
1311
1427
  }
1312
1428
  const item = session.inputQueue[0]
1313
- // round4 major#3: 試行種別 (steer/start) を **await の前に** 確定する。await 中に
1314
- // turn/completed が来て session.activeTurnId が null に戻ると、catch 時点の値では steer 試行が
1315
- // start と誤判定され、self-heal リトライがスキップされて item が誤 reject される。
1316
- const attemptedSteer = !!session.activeTurnId
1429
+ session.dispatchingInput = item
1317
1430
  try {
1318
- if (attemptedSteer) {
1319
- await session.client.request("turn/steer", {
1320
- threadId: session.threadId,
1321
- expectedTurnId: session.activeTurnId,
1322
- input: item.input,
1323
- })
1324
- session.inputQueue.shift()
1325
- this.logger?.info?.(
1326
- {
1327
- delivery_id: item.deliveryId || null,
1328
- stream_id: item.sourceStreamId || session.stream_id,
1329
- current_stream_id: session.stream_id,
1330
- thread_id: session.threadId,
1331
- attempted_steer: true,
1332
- rpc_method: "turn/steer",
1333
- rpc_result: "accepted",
1334
- },
1335
- "codex input RPC settled",
1336
- )
1337
- item.resolve({ method: "turn/steer", deliveryId: item.deliveryId })
1338
- } else {
1339
- // round4 major#2: turn/start dispatch の **前に** 世代を控える。start 応答後に
1340
- // turn/started(+completed) が pump 再開前に処理されても、世代前進で「ターンが始まった」を
1341
- // 検知でき、誤 timeout しない。
1342
- const genBefore = session.turnGeneration
1343
- const params = { threadId: session.threadId, input: item.input }
1344
- if (item.model) params.model = item.model
1345
- if (item.effort) params.effort = item.effort
1346
- await session.client.request("turn/start", params)
1347
- session.inputQueue.shift()
1348
- this.logger?.info?.(
1349
- {
1350
- delivery_id: item.deliveryId || null,
1351
- stream_id: item.sourceStreamId || session.stream_id,
1352
- current_stream_id: session.stream_id,
1353
- thread_id: session.threadId,
1354
- attempted_steer: false,
1355
- rpc_method: "turn/start",
1356
- rpc_result: "accepted",
1357
- },
1358
- "codex input RPC settled",
1359
- )
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) {
1360
1451
  item.resolve({ method: "turn/start", deliveryId: item.deliveryId })
1361
- // 次の item を steer にするため turn/started (= 世代前進) を待つ。timeout でハングを防ぐ。
1362
- // timeout したら「ターンが正しく始まらなかった」とみなし残り queue を reject して打ち切る。
1363
- const becameActive = await this._waitTurnActive(session, genBefore)
1364
- if (!becameActive) {
1365
- this._drainRejectQueue(session, { reason: "turn_start_timeout" })
1366
- return
1367
- }
1368
1452
  }
1369
- } catch (err) {
1370
- if (attemptedSteer && !item.retriedAsStart) {
1371
- this.logger?.warn?.(
1372
- {
1373
- delivery_id: item.deliveryId || null,
1374
- stream_id: item.sourceStreamId || session.stream_id,
1375
- current_stream_id: session.stream_id,
1376
- thread_id: session.threadId,
1377
- attempted_steer: true,
1378
- rpc_method: "turn/steer",
1379
- rpc_result: "retry_as_start",
1380
- err: err instanceof Error ? err.message : String(err),
1381
- },
1382
- "codex input RPC settled",
1383
- )
1384
- // turn/steer 失敗 → 自己修復: activeTurnId を捨て、同 item を turn/start で 1 回だけリトライ
1385
- // (shift しない = 同 item を次ループの start 経路で再処理)。並行はしない (単一 pump)。
1386
- // 元の steer エラーを stash して、リトライも失敗したとき合成メッセージを作れるようにする。
1387
- session.activeTurnId = null
1388
- item.retriedAsStart = true
1389
- item.priorSteerError = err instanceof Error ? err : new Error(String(err))
1390
- 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
1391
1458
  }
1392
- // start 失敗 or リトライ済み steer 失敗 → この item を reject。activeTurnId は捨てる
1393
- // (ターンは始まっていない/不明)。次の 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) {
1394
1463
  session.inputQueue.shift()
1395
- session.activeTurnId = null
1396
- let e
1397
- if (item.retriedAsStart && item.priorSteerError) {
1398
- // steer→start リトライがともに失敗: 合成メッセージ + reason (main.mjs の classifyCodexInputError
1399
- // が reason を優先採用。従来の単一 input() 実装と同じ診断を保つ)。
1400
- e = Object.assign(
1401
- new Error(
1402
- `turn/steer failed (${item.priorSteerError.message}) and turn/start retry also failed: ${err instanceof Error ? err.message : String(err)}`,
1403
- ),
1404
- { reason: "turn_mismatch_retry_failed", cause: err },
1405
- )
1406
- } else {
1407
- e = err instanceof Error ? err : new Error(String(err))
1408
- if (!e.reason) e.reason = "start_error"
1409
- }
1464
+ session.dispatchingInput = null
1465
+ const e = err instanceof Error ? err : new Error(String(err))
1466
+ if (!e.reason) e.reason = "start_error"
1410
1467
  this.logger?.error?.(
1411
1468
  {
1412
1469
  delivery_id: item.deliveryId || null,
1470
+ input_id: item.inputId || null,
1413
1471
  stream_id: item.sourceStreamId || session.stream_id,
1414
1472
  current_stream_id: session.stream_id,
1415
1473
  thread_id: session.threadId,
1416
- attempted_steer: attemptedSteer,
1417
- rpc_method: attemptedSteer ? "turn/steer" : "turn/start",
1474
+ rpc_method: "turn/start",
1418
1475
  rpc_result: "failed",
1419
1476
  err: e.message,
1420
1477
  reason: e.reason || null,
1421
1478
  },
1422
1479
  "codex input RPC settled",
1423
1480
  )
1424
- 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)
1425
1491
  }
1426
1492
  }
1427
1493
  } finally {
1494
+ session.dispatchingInput = null
1428
1495
  session.pumping = false
1429
1496
  // pumping=false 直後の enqueue 競合窓を拾い直す (取りこぼし防止)。再帰は 1 段のみ。
1430
- if (session.inputQueue.length > 0 && !session.dead) {
1497
+ if (session.inputQueue.length > 0 && !session.dead && !session.activeTurnId) {
1431
1498
  void this._pumpInputs(session)
1432
1499
  }
1433
1500
  }
@@ -1486,15 +1553,24 @@ export class CodexStreamBridge extends EventEmitter {
1486
1553
  if (!session || !Array.isArray(session.inputQueue) || session.inputQueue.length === 0) return
1487
1554
  const pending = session.inputQueue
1488
1555
  session.inputQueue = []
1556
+ session.dispatchingInput = null
1489
1557
  const reason = cause?.reason || "queue_dead"
1490
- for (const { reject } of pending) {
1491
- reject(
1492
- Object.assign(new Error("codex input dropped: turn could not be delivered"), {
1493
- reason,
1494
- cause,
1495
- }),
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 },
1496
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
+ }
1497
1572
  }
1573
+ if (!session.dead) this._emitQueueState(session)
1498
1574
  }
1499
1575
 
1500
1576
  /** @returns {Promise<void>} */
package/src/main.mjs CHANGED
@@ -853,8 +853,9 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
853
853
  // ドーナツ) と session.event (ソート) に橋渡しする。チャットは tmux ペインに
854
854
  // TUI を出さず、capture-pane スクレイプも bundle hook も発火しないための補完。
855
855
  // - assistant (生成中) → processing + usage から context%
856
- // - result (ターン完了/入力待ち) → waiting
857
- // assistant / result はターンの進行/完了 = permission 応答後に必ず流れるため、
856
+ // - session_state_changed:idle → waiting
857
+ // result は結果 flush であり、SDK が権威的ターン終了と定義する idle より早く届く。
858
+ // assistant / idle はターンの進行/完了 = permission 応答後に必ず流れるため、
858
859
  // ここで inputPending を落として「確認待ち」ドット/通知を自己解消させる。
859
860
  if (event?.type === "assistant") {
860
861
  // 分母は result で学習したセッション固有の窓サイズ (無ければグローバル概算)。
@@ -876,6 +877,11 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
876
877
  } else if (event?.type === "result") {
877
878
  // SDK の権威的な窓サイズ (result.modelUsage[model].contextWindow) をここで学習する。
878
879
  resolveChatContextPct(cwd, "claude", event)
880
+ } else if (
881
+ event?.type === "system" &&
882
+ event?.subtype === "session_state_changed" &&
883
+ event?.state === "idle"
884
+ ) {
879
885
  try {
880
886
  recordChatActivity(cwd, "claude", {
881
887
  status: "waiting",
@@ -3134,8 +3140,8 @@ export function buildAttachHistoryPromise({
3134
3140
  /**
3135
3141
  * SDK チャット配達保証 (S2, 2026-07-05): `codex.input` を処理し、browser が付与した
3136
3142
  * `input_id` があれば `codex.input.ack` / `codex.input.nack` を返す (claude.input と
3137
- * 対称)。`CodexStreamBridge.input()` は `turn/start` / `turn/steer` の JSON-RPC 呼び出し
3138
- * そのものを await する設計のため、resolve/reject がそのまま ack/nack の判定になる。
3143
+ * 対称)。idle 入力は turn/start の受理、生成中入力は hub-agent FIFO への受理をもって
3144
+ * ack する。FIFO の実配送は matching turn/completed 後に bridge が行う。
3139
3145
  * 既存の `codex.error` 通知も維持する (input_id を知らない旧 frontend や、他の消費者向け)。
3140
3146
  * テストから直接呼べるよう dispatch から切り出して export する。
3141
3147
  */
@@ -3172,6 +3178,7 @@ export function handleCodexChatInput(msg, ctx) {
3172
3178
  message: msg.message,
3173
3179
  model: msg.model || undefined,
3174
3180
  effort: msg.effort || undefined,
3181
+ inputId: input_id || undefined,
3175
3182
  })
3176
3183
  .then(() => {
3177
3184
  if (!input_id) return
@@ -3426,6 +3433,10 @@ async function dispatch(msg, ctx) {
3426
3433
  type: "claude.ready",
3427
3434
  stream_id,
3428
3435
  resuming: info.resuming,
3436
+ // 再接続時の生成状態は resident session を保持する hub-agent が正本。
3437
+ // jsonl 履歴末尾の推測だけでは「実際は busy なのに UI は idle」となり、
3438
+ // 送信ボタンが楽観バブルを作る一方 agent はキューへ積む二重表示が起きる。
3439
+ turn_active: info.turn_active === true,
3429
3440
  history_pushed: resumeSessionId !== null,
3430
3441
  // id 空間統一 (2026-07-08): この agent は browser の input_id をキュー id として
3431
3442
  // 採用し、楽観送信待ちアイテムの確認前キャンセルに対応することを通知する capability。
@@ -3937,6 +3948,9 @@ async function dispatch(msg, ctx) {
3937
3948
  turn_active: info.turnActive === true,
3938
3949
  thread_status: info.threadStatus || null,
3939
3950
  last_turn_status: info.lastTurnStatus || null,
3951
+ // Codex の通常送信 FIFO。ready と同じ封筒に初期 snapshot を載せ、
3952
+ // reattach / reload でも送信待ち表示を即時復元する。
3953
+ queue_state: ctx.codexBridge.queueState(stream_id),
3940
3954
  })
3941
3955
  // S1 (2026-07): claude.attach と対称の identity 正本化。Codex は thread/start
3942
3956
  // 応答時点で threadId が確定するため (Claude の fresh とは異なり)、fresh でも
@@ -4073,15 +4087,23 @@ async function dispatch(msg, ctx) {
4073
4087
  return
4074
4088
  }
4075
4089
  case "codex.input":
4076
- // model/effort: turn/start (非 busy 時) にのみ反映される override。turn/steer
4077
- // (busy 時) は公式スキーマ上 model/effort 非対応のため、その場合は
4078
- // codex-stream-bridge.mjs 側で無視され次の非 busy な input() から効く。
4079
- // S2/S3 統合 (2026-07-05): S3 の自己修復 (turn/steer 失敗 → turn/start 自動
4080
- // リトライ) が付与する err.reason ('timeout' / 'turn_mismatch_retry_failed') を
4081
- // classifyCodexInputError が優先採用し、input_id 付きなら codex.input.nack の
4082
- // reason としてそのまま使う (handleCodexChatInput 内)。
4090
+ // 通常送信は idle なら turn/start、busy なら hub-agent の次ターン FIFO。
4091
+ // turn/steer / turn/interrupt は明示操作の別経路として扱う。
4083
4092
  handleCodexChatInput(msg, ctx)
4084
4093
  return
4094
+ case "codex.queue.cancel":
4095
+ try {
4096
+ ctx.codexBridge?.cancelQueued({
4097
+ stream_id: msg.stream_id,
4098
+ id: msg.id,
4099
+ })
4100
+ } catch (err) {
4101
+ ctx.logger?.warn?.(
4102
+ { err: err?.message, stream_id: msg.stream_id, input_id: msg.id },
4103
+ "codex queued input cancel failed",
4104
+ )
4105
+ }
4106
+ return
4085
4107
  case "codex.permission.reply":
4086
4108
  if (!ctx.codexBridge) return
4087
4109
  ctx.codexBridge.permissionReply({