@cocorograph/hub-agent 0.8.10 → 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.
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.11",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -523,11 +523,11 @@ export class ClaudeStreamSession {
523
523
  this._permissionResolvers = new Map()
524
524
  /** 現在ターン実行中か (多重 query 防止) */
525
525
  this._busy = false
526
- /** SDK-P0-3 (2026-07-10): 現ターンが既に終端処理済みか (result / idle / abort の
526
+ /** SDK-P0-3 (2026-07-10): 現ターンが既に終端処理済みか (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` が流れる。通常完了では idle だけが settle し、
530
+ * 明示 interrupt だけが先行して settled を立てる。ターン開始時に false へ戻す。 */
531
531
  this._turnSettled = false
532
532
  /** SDK-P0-3: 現ターンが 1 件でもストリームイベントを出したか。
533
533
  * 「前ターンの遅延 idle が、drain で始まったばかりの次ターンを誤終了させる」レースの
@@ -539,9 +539,9 @@ export class ClaudeStreamSession {
539
539
  this._turnProgressed = false
540
540
  /** SDK-P0-4 (2026-07-13): ターン連番。ログと帰属デバッグ用 (_beginResidentTurn で ++)。 */
541
541
  this._turnSeq = 0
542
- /** SDK-P0-4: 「直前ターンの idle がこれから 1 件届きうる」状態。result で settle したとき、
543
- * および abort (interrupt) でターンを畳んだときに arm する (SDK は result → idle の順で
544
- * 流し、interrupt 後にも中断ターンの idle を流すため)。arm 中に届いた idle は
542
+ /** SDK-P0-4: 「直前ターンの idle がこれから 1 件届きうる」状態。abort (interrupt) で
543
+ * ターンを先行して畳んだときに arm する (interrupt 後にも中断ターンの idle が流れるため)。
544
+ * 通常完了は idle 自身で畳むので arm しない。arm 中に届いた idle は
545
545
  * **前ターンのもの**として 1 件だけ握り潰し、現ターンを終端しない。
546
546
  * 解除条件: (a) idle を 1 件消費した (b) SDK の running が届いた (= 前ターンの全イベントは
547
547
  * 順序保証により既に通過済み) (c) 現ターンの result が届いた (d) STALE_IDLE_GUARD_MS 経過。 */
@@ -1131,9 +1131,10 @@ export class ClaudeStreamSession {
1131
1131
  this._busy = true
1132
1132
  this._abortController = new AbortController()
1133
1133
  let aborted = false
1134
- // RC-4: このターンで SDK の result イベントが届いたか。届かずに終わった
1135
- // (abort / 異常終了) ときだけ onTurnSettled でチャット信号を waiting へ落とす。
1134
+ // `session_state_changed:idle` がターン終了の正本。SDK が idle を流さず generator が
1135
+ // 閉じた旧/異常経路だけ合成 idle と onTurnSettled で waiting へ落とす。
1136
1136
  let resultDelivered = false
1137
+ let idleDelivered = false
1137
1138
 
1138
1139
  const options = {
1139
1140
  cwd: this.cwd,
@@ -1185,6 +1186,7 @@ export class ClaudeStreamSession {
1185
1186
  resultDelivered = true
1186
1187
  if (typeof msg.session_id === "string") this.sessionId = msg.session_id
1187
1188
  }
1189
+ if (isSessionIdleEvent(msg)) idleDelivered = true
1188
1190
  try {
1189
1191
  this._emit(msg)
1190
1192
  } catch (err) {
@@ -1224,12 +1226,9 @@ export class ClaudeStreamSession {
1224
1226
  if (aborted) {
1225
1227
  this.logger?.info({ stream_id: this.stream_id }, "claude turn aborted")
1226
1228
  }
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) {
1229
+ // RC-4: idle を届けずに generator が閉じた旧/異常経路では、チャット信号と browser の
1230
+ // 双方へ同じ終了境界を補う。_drainPending で次ターンを始める前に行うこと。
1231
+ if (!idleDelivered) {
1233
1232
  try {
1234
1233
  this.onTurnSettled?.()
1235
1234
  } catch {
@@ -1244,11 +1243,24 @@ export class ClaudeStreamSession {
1244
1243
  // フッターで表示する)。uuid を持たせるのは重複排除 (isDuplicateEvent) が result
1245
1244
  // 署名 (session_id + num_turns + duration_ms + cost) で判定するため — 合成 result
1246
1245
  // はこれらが毎回同値になり、2 回目以降の中断で「重複」と誤判定され捨てられるのを防ぐ。
1246
+ if (!resultDelivered) {
1247
+ try {
1248
+ this._emit({
1249
+ type: "result",
1250
+ subtype: aborted ? "aborted_by_user" : "turn_settled",
1251
+ uuid: randomUUID(),
1252
+ session_id: this.sessionId ?? undefined,
1253
+ timestamp: new Date().toISOString(),
1254
+ })
1255
+ } catch {
1256
+ /* ignore */
1257
+ }
1258
+ }
1247
1259
  try {
1248
1260
  this._emit({
1249
- type: "result",
1250
- subtype: aborted ? "aborted_by_user" : "turn_settled",
1251
- uuid: randomUUID(),
1261
+ type: "system",
1262
+ subtype: "session_state_changed",
1263
+ state: "idle",
1252
1264
  session_id: this.sessionId ?? undefined,
1253
1265
  timestamp: new Date().toISOString(),
1254
1266
  })
@@ -1618,10 +1630,10 @@ export class ClaudeStreamSession {
1618
1630
  }
1619
1631
  }
1620
1632
 
1621
- /** SDK-P0-3 (2026-07-10): resident の「ターン終端」を 1 箇所に集約する (result / idle 共通)。
1622
- * 同一ターンで 2 回以上呼ばれても最初の 1 回しか作用しない (_turnSettled ガード)。
1623
- * これにより `result` → `idle` の順で両方届く通常ケースでも drain は 1 回だけになる。
1624
- * @param {string} reason "result" | "idle" — ログ用
1633
+ /** resident の権威的な「ターン終端」を 1 箇所に集約する。
1634
+ * 通常完了は SDK の session_state_changed:idle だけが呼ぶ。result は最終結果の
1635
+ * flush であり、バックグラウンド処理を含むターン全体の終了境界ではない。
1636
+ * @param {string} reason "idle" — ログ用
1625
1637
  * @param {(msg: string) => void} [denyPending] 未解決 permission を閉じるクロージャ
1626
1638
  */
1627
1639
  _settleResidentTurn(reason, denyPending) {
@@ -1629,11 +1641,6 @@ export class ClaudeStreamSession {
1629
1641
  this._turnSettled = true
1630
1642
  denyPending?.("turn ended")
1631
1643
  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 を消費済みなので張らない。
1636
- if (reason === "result") this._armStaleIdleGuard()
1637
1644
  // 改修4 (A): シリアライズした pending があれば次の 1 件を InputQueue へ流す。
1638
1645
  // ultracode (0.6.28): _drainResidentPending は applyFlagSettings を await するため
1639
1646
  // async。for await ループ内からは fire-and-forget で呼ぶ (最初の await より前に
@@ -1649,7 +1656,7 @@ export class ClaudeStreamSession {
1649
1656
  /** 改修4 (A): ターン完了時に pending の先頭 1 件を InputQueue へ流す (ターンのシリアライズ)。
1650
1657
  * queue_state を更新して送信待ちチップを drain させる (frontend がバブル昇格する)。
1651
1658
  * ultracode (0.6.28): 次ターンの目標 ultracode 状態へ reconcile してから push する
1652
- * (await するため async 化。result ハンドラからは fire-and-forget で呼ばれる)。 */
1659
+ * (await するため async 化。idle ハンドラからは fire-and-forget で呼ばれる)。 */
1653
1660
  async _drainResidentPending() {
1654
1661
  if (this._closed) return
1655
1662
  if (this._pendingMessages.length === 0) {
@@ -1693,7 +1700,7 @@ export class ClaudeStreamSession {
1693
1700
 
1694
1701
  /** 改修2+4: 常駐 query を 1 回だけ起動し、streaming input キューから複数ターンを処理する。
1695
1702
  * system/init は最初の 1 回のみ (毎ターン init = 文脈/キャッシュ温め直しを解消)。各ターンは
1696
- * 終端シグナル (result / session_state_changed:idle) で _busy=false にするが query は継続し
1703
+ * 権威的終端シグナル (session_state_changed:idle) で _busy=false にするが query は継続し
1697
1704
  * 次 input を待つ。close (inputQueue.close) で generator が終わり query が完走する。
1698
1705
  * SDK-P0-3 (2026-07-10): 終端を result だけに頼ると、SDK が idle でターンを終えたときに
1699
1706
  * _busy が固着し、以降の送信が pending へ永久滞留する (T06052)。
@@ -1749,6 +1756,7 @@ export class ClaudeStreamSession {
1749
1756
  const q = this.sdk.query({ prompt: this._inputQueue, options })
1750
1757
  this._residentQuery = q
1751
1758
  for await (const msg of q) {
1759
+ let suppressEmit = false
1752
1760
  if (
1753
1761
  msg?.type === "system" &&
1754
1762
  msg?.subtype === "init" &&
@@ -1758,12 +1766,12 @@ export class ClaudeStreamSession {
1758
1766
  }
1759
1767
  if (msg?.type === "result") {
1760
1768
  if (typeof msg.session_id === "string") this.sessionId = msg.session_id
1761
- // SDK-P0-4: 現ターンの result が届いた = 前ターンの遅延 idle は順序保証により
1762
- // 既に通過済み (届いたなら消費済み / 届かないなら二度と来ない)。ガードを解除してから
1763
- // settle する (settle が result 用のガードを張り直す)。
1769
+ // result は結果 flush でありターン終了ではない。SDK の型定義が権威的な
1770
+ // turn-over signal とする session_state_changed:idle まで busy を維持し、
1771
+ // pending を streaming input へ流さない。
1772
+ // 現ターンの result が届いた時点で前ターンの遅延 idle は順序保証により
1773
+ // 既に通過済みなので、interrupt 由来のガードだけ解除できる。
1764
1774
  this._clearStaleIdleGuard()
1765
- // ターン完了: 未解決 permission を閉じ、次 input 受付へ (query は継続)。
1766
- this._settleResidentTurn("result", denyPending)
1767
1775
  } else if (isSessionIdleEvent(msg)) {
1768
1776
  // SDK-P0-3 (2026-07-10): result が届かずに idle でターンが終わるケースの救済。
1769
1777
  // これを見ていなかったため _busy が true で固着し、以降の送信が
@@ -1777,11 +1785,14 @@ export class ClaudeStreamSession {
1777
1785
  // 2) それ以外で _busy かつ (running を観測済み or 何かイベントを出した) → 現ターンの終端。
1778
1786
  if (this._staleIdleExpected) {
1779
1787
  this._clearStaleIdleGuard()
1788
+ // browser にも転送しない。前ターンの遅延 idle を新ターンの reducer が受けると、
1789
+ // 3点リーダーと送信ボタンだけが誤って idle になる。
1790
+ suppressEmit = true
1780
1791
  this.logger?.debug?.(
1781
1792
  { stream_id: this.stream_id, turn: this._turnSeq },
1782
1793
  "stale idle from previous turn ignored",
1783
1794
  )
1784
- } else if (this._busy && (this._turnRunningSeen || this._turnProgressed)) {
1795
+ } else if (this._busy) {
1785
1796
  this._settleResidentTurn("idle", denyPending)
1786
1797
  // per-message の RC-4 と対称に、チャット信号 (サイドバードット) を
1787
1798
  // processing→waiting へ落とす。result 経由なら main.mjs が result を見て
@@ -1804,13 +1815,15 @@ export class ClaudeStreamSession {
1804
1815
  // および session_state_changed の running / requires_action もここに含む。
1805
1816
  this._turnProgressed = true
1806
1817
  }
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
- )
1818
+ if (!suppressEmit) {
1819
+ try {
1820
+ this._emit(msg)
1821
+ } catch (err) {
1822
+ this.logger?.warn(
1823
+ { err: err.message, stream_id: this.stream_id },
1824
+ "onEvent callback threw",
1825
+ )
1826
+ }
1814
1827
  }
1815
1828
  }
1816
1829
  } catch (err) {
@@ -1949,6 +1962,19 @@ export class ClaudeStreamSession {
1949
1962
  } catch {
1950
1963
  /* ignore */
1951
1964
  }
1965
+ // 通常完了と同じ権威境界を browser へ即時提示する。SDK が後から流す
1966
+ // 中断ターン由来の実 idle は _staleIdleExpected で抑止する。
1967
+ try {
1968
+ this._emit({
1969
+ type: "system",
1970
+ subtype: "session_state_changed",
1971
+ state: "idle",
1972
+ session_id: this.sessionId ?? undefined,
1973
+ timestamp: new Date().toISOString(),
1974
+ })
1975
+ } catch {
1976
+ /* ignore */
1977
+ }
1952
1978
  }
1953
1979
  // T05699 残タスク #2-1 (2026-07-04): interrupt() は現ターンの result を発火しない
1954
1980
  // (query は継続、次 input 受付継続のみ)。そのため abort 時点で _pendingMessages に
@@ -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
@@ -3937,6 +3944,9 @@ async function dispatch(msg, ctx) {
3937
3944
  turn_active: info.turnActive === true,
3938
3945
  thread_status: info.threadStatus || null,
3939
3946
  last_turn_status: info.lastTurnStatus || null,
3947
+ // Codex の通常送信 FIFO。ready と同じ封筒に初期 snapshot を載せ、
3948
+ // reattach / reload でも送信待ち表示を即時復元する。
3949
+ queue_state: ctx.codexBridge.queueState(stream_id),
3940
3950
  })
3941
3951
  // S1 (2026-07): claude.attach と対称の identity 正本化。Codex は thread/start
3942
3952
  // 応答時点で threadId が確定するため (Claude の fresh とは異なり)、fresh でも
@@ -4073,15 +4083,23 @@ async function dispatch(msg, ctx) {
4073
4083
  return
4074
4084
  }
4075
4085
  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 内)。
4086
+ // 通常送信は idle なら turn/start、busy なら hub-agent の次ターン FIFO。
4087
+ // turn/steer / turn/interrupt は明示操作の別経路として扱う。
4083
4088
  handleCodexChatInput(msg, ctx)
4084
4089
  return
4090
+ case "codex.queue.cancel":
4091
+ try {
4092
+ ctx.codexBridge?.cancelQueued({
4093
+ stream_id: msg.stream_id,
4094
+ id: msg.id,
4095
+ })
4096
+ } catch (err) {
4097
+ ctx.logger?.warn?.(
4098
+ { err: err?.message, stream_id: msg.stream_id, input_id: msg.id },
4099
+ "codex queued input cancel failed",
4100
+ )
4101
+ }
4102
+ return
4085
4103
  case "codex.permission.reply":
4086
4104
  if (!ctx.codexBridge) return
4087
4105
  ctx.codexBridge.permissionReply({