@cocorograph/hub-agent 0.7.39 → 0.7.43
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 +1 -1
- package/src/chat-signals.mjs +45 -5
- package/src/claude-stream-bridge.mjs +105 -14
- package/src/codex-appserver-client.mjs +47 -3
- package/src/codex-stream-bridge.mjs +66 -6
- package/src/main.mjs +408 -40
package/package.json
CHANGED
package/src/chat-signals.mjs
CHANGED
|
@@ -21,9 +21,18 @@
|
|
|
21
21
|
*
|
|
22
22
|
* cwd をキーにする理由: チャットセッションは tmux セッション名を持たず (SDK 子
|
|
23
23
|
* プロセス)、サイドバー行 (= tmux セッション) とは cwd で対応するため。
|
|
24
|
+
*
|
|
25
|
+
* identity 次元 (cliKind/sessionId, S1 2026-07): 同一 cwd 上で Claude⇔Codex の
|
|
26
|
+
* エンジン切替や、プルダウンからの別セッション選択が起こりうる。従来は cwd だけを
|
|
27
|
+
* キーにしていたため、切替後も前のエンジン/セッションが残した status/context_pct を
|
|
28
|
+
* そのまま引き継いでしまい (ドーナツ/ステータスドットが前セッションの値のまま固着する
|
|
29
|
+
* 症状③④の真因)、新しい identity への切替を検知できなかった。recordChatActivity の
|
|
30
|
+
* patch に cliKind/sessionId を明示的に含めることで「この更新はどの CLI・どのセッション
|
|
31
|
+
* のものか」を宣言でき、前回記録した identity と不一致なら温存 (keep-prev) せず
|
|
32
|
+
* status/context_pct をリセットしてから組み立て直す。
|
|
24
33
|
*/
|
|
25
34
|
|
|
26
|
-
// cwd → { status, context_pct, statusAt, inputPending, turnAt, updatedAtMs }
|
|
35
|
+
// cwd → { status, context_pct, statusAt, inputPending, turnAt, updatedAtMs, cliKind, sessionId }
|
|
27
36
|
const _byCwd = new Map()
|
|
28
37
|
|
|
29
38
|
// チャット信号を「生きている」と見なす最大経過時間 (ms)。これを過ぎたら tmux
|
|
@@ -62,21 +71,52 @@ function _now() {
|
|
|
62
71
|
* "確認待ち" を別軸で表現するため)。permission 要求で true、次の assistant / result
|
|
63
72
|
* イベント (= ユーザーが応答してターンが再開/完了) で false に落とす。
|
|
64
73
|
*
|
|
74
|
+
* cliKind / sessionId (S1, 2026-07): 「この更新がどの CLI・どのセッションのものか」を
|
|
75
|
+
* 明示する identity。patch に **どちらか一方でも** キーとして含まれていれば identity
|
|
76
|
+
* 更新とみなし、前回記録した identity (cliKind/sessionId) と比較する:
|
|
77
|
+
* - 一致 (= 同一セッションの継続) → 従来通り前回値へ status/context_pct を温存 (keep-prev)。
|
|
78
|
+
* - 不一致 (= エンジン切替 or 別セッションへの乗り換え) → 前回の status/context_pct/
|
|
79
|
+
* inputPending を温存せずリセットしてから patch を適用する (旧セッションの値が
|
|
80
|
+
* 切替後にも残り続ける症状③④の根治)。
|
|
81
|
+
* patch に cliKind/sessionId のいずれのキーも含まれない (= 既存呼び出し元: permission
|
|
82
|
+
* ハンドラの inputPending 単独更新等) 場合は identity 判定をスキップし、後方互換のため
|
|
83
|
+
* 従来通り温存する。
|
|
84
|
+
*
|
|
65
85
|
* @param {string} cwd セッションの作業ディレクトリ
|
|
66
|
-
* @param {{ status?: string, contextPct?: number|null, inputPending?: boolean }} patch
|
|
86
|
+
* @param {{ status?: string, contextPct?: number|null, inputPending?: boolean, cliKind?: string|null, sessionId?: string|null }} patch
|
|
67
87
|
* @param {number} [now] テスト用の時刻注入 (省略時は Date.now())
|
|
68
88
|
*/
|
|
69
89
|
export function recordChatActivity(cwd, patch = {}, now = _now()) {
|
|
70
90
|
if (!cwd || typeof cwd !== "string") return
|
|
71
|
-
const
|
|
91
|
+
const empty = {
|
|
72
92
|
status: null,
|
|
73
93
|
context_pct: null,
|
|
74
94
|
statusAt: 0,
|
|
75
95
|
inputPending: false,
|
|
76
96
|
turnAt: 0,
|
|
77
97
|
updatedAtMs: 0,
|
|
98
|
+
cliKind: null,
|
|
99
|
+
sessionId: null,
|
|
78
100
|
}
|
|
79
|
-
const
|
|
101
|
+
const prev = _byCwd.get(cwd) || empty
|
|
102
|
+
|
|
103
|
+
const hasCliKind = Object.prototype.hasOwnProperty.call(patch, "cliKind")
|
|
104
|
+
const hasSessionId = Object.prototype.hasOwnProperty.call(patch, "sessionId")
|
|
105
|
+
let base = prev
|
|
106
|
+
let cliKind = prev.cliKind
|
|
107
|
+
let sessionId = prev.sessionId
|
|
108
|
+
if (hasCliKind || hasSessionId) {
|
|
109
|
+
cliKind = hasCliKind ? patch.cliKind ?? null : prev.cliKind
|
|
110
|
+
sessionId = hasSessionId ? patch.sessionId ?? null : prev.sessionId
|
|
111
|
+
const identityChanged = cliKind !== prev.cliKind || sessionId !== prev.sessionId
|
|
112
|
+
if (identityChanged) {
|
|
113
|
+
// 新しい identity: 前回の status/context_pct/inputPending は引き継がない。
|
|
114
|
+
// turnAt/updatedAtMs はこの後どちらにせよ now で上書きするため据え置きで良い。
|
|
115
|
+
base = empty
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const next = { ...base, cliKind, sessionId, turnAt: now, updatedAtMs: now }
|
|
80
120
|
// statusAt: status / context_pct を「実際に SDK イベントが更新した」時刻。
|
|
81
121
|
// inputPending だけの記録 (TUI 権限ブリッジ) では更新しない。これを分けないと、
|
|
82
122
|
// TUI モードの承認カードが届くたびに「過去の SDK チャットが残した status (多くは
|
|
@@ -129,7 +169,7 @@ export function recordChatStreamTick(cwd, now = _now()) {
|
|
|
129
169
|
*
|
|
130
170
|
* @param {string} cwd
|
|
131
171
|
* @param {number} [now]
|
|
132
|
-
* @returns {{ status: string|null, context_pct: number|null, inputPending?: boolean, turnAt: number, updatedAtMs: number } | null}
|
|
172
|
+
* @returns {{ status: string|null, context_pct: number|null, inputPending?: boolean, turnAt: number, updatedAtMs: number, cliKind?: string|null, sessionId?: string|null } | null}
|
|
133
173
|
*/
|
|
134
174
|
export function getChatSignal(cwd, now = _now()) {
|
|
135
175
|
if (!cwd) return null
|
|
@@ -291,6 +291,14 @@ class ClaudeStreamSession {
|
|
|
291
291
|
this._residentQuery = null
|
|
292
292
|
/** 常駐 query を起動済みか (二重起動防止)。 */
|
|
293
293
|
this._residentStarted = false
|
|
294
|
+
/** A-2 (2026-07-05 配達保証): 常駐 query の generator が自然終了 (for await ループが
|
|
295
|
+
* 完走) したかを示すフラグ。_runResidentQuery の finally で _residentQuery=null に
|
|
296
|
+
* なるより前に true を立てる。sendMessage は `await this._reconcileResidentUltracode()`
|
|
297
|
+
* を挟むため、その await 中に generator が終了すると「_residentQuery はまだ non-null
|
|
298
|
+
* (finally 未実行)」に見えたまま push してしまい、誰も consume しない返却済み
|
|
299
|
+
* InputQueue にメッセージが消えることがある。このフラグで「push 直前に本当にまだ
|
|
300
|
+
* 生きているか」を再確認する。新しい query を (再)起動するたびに false へリセットする。 */
|
|
301
|
+
this._residentQueryReturned = false
|
|
294
302
|
|
|
295
303
|
/** 改修3: per-message セッションで busy 中に届いた送信を退避する pending キュー。
|
|
296
304
|
* 常駐 query 化 (改修2) とは別レイヤー。resume チェーンは維持したまま、現ターン
|
|
@@ -648,11 +656,16 @@ class ClaudeStreamSession {
|
|
|
648
656
|
* ユーザーメッセージ 1 件を 1 query() として実行する。
|
|
649
657
|
* 既存ターン実行中 (busy) は破棄せず pending キューへ退避し、現ターン完了時に drain する
|
|
650
658
|
* (改修3)。常駐 query 対象 (新規セッション) は InputQueue へ積む (改修2)。
|
|
659
|
+
*
|
|
660
|
+
* 配達保証 (S2, 2026-07-05): 戻り値は `{ accepted: boolean, reason?: string }`。
|
|
661
|
+
* `accepted` は「キュー/常駐 query への投入が完了した (= ack を返してよい)」ことを
|
|
662
|
+
* 意味し、ターンの完走までは待たない (フル実行を待つと ack が数十秒〜数分遅れるため)。
|
|
663
|
+
* `accepted:false` のときは呼び出し元 (ClaudeStreamBridge.input) が nack を組み立てる。
|
|
651
664
|
*/
|
|
652
665
|
async sendMessage(message, opts = {}) {
|
|
653
|
-
if (this._closed) return
|
|
666
|
+
if (this._closed) return { accepted: false, reason: "queue_dead" }
|
|
654
667
|
const prompt = extractPromptText(message)
|
|
655
|
-
if (!prompt) return
|
|
668
|
+
if (!prompt) return { accepted: false, reason: "empty_message" }
|
|
656
669
|
|
|
657
670
|
// ultracode ワンショット (0.6.28): このターンのみ xhigh effort + 常時 dynamic-workflow
|
|
658
671
|
// オーケストレーションを有効化する。セッション既定としては持たず (トークン消費が
|
|
@@ -683,7 +696,7 @@ class ClaudeStreamSession {
|
|
|
683
696
|
"resident busy, message queued",
|
|
684
697
|
)
|
|
685
698
|
this._emitQueueState()
|
|
686
|
-
return
|
|
699
|
+
return { accepted: true }
|
|
687
700
|
}
|
|
688
701
|
this._busy = true
|
|
689
702
|
// 改修4 (A): 死亡ガード。常駐 query が未起動 or (エラー等で) 終了済み (_residentQuery=null)
|
|
@@ -697,23 +710,49 @@ class ClaudeStreamSession {
|
|
|
697
710
|
this._startResidentQuery()
|
|
698
711
|
}
|
|
699
712
|
await this._reconcileResidentUltracode(ultracode)
|
|
713
|
+
// A-2 修正 (2026-07-05): 上の await 中に常駐 query の generator が自然終了している
|
|
714
|
+
// ことがある (_residentQueryReturned が finally で true になる)。その場合 this._residentQuery
|
|
715
|
+
// はまだ非 null (finally 未実行) に見えても、実体は既に誰も consume しない返却済み
|
|
716
|
+
// generator であり、そのまま push すると消失する。fresh-start と同じ手順 (InputQueue の
|
|
717
|
+
// 再生成 + 再起動) を踏んでから push することで、既存の resident 復活ガード
|
|
718
|
+
// (B6 / 異常終了時の自動再起動) に合流させる。
|
|
719
|
+
if (this._residentQueryReturned || !this._residentQuery) {
|
|
720
|
+
this._inputQueue = new InputQueue()
|
|
721
|
+
this._residentStarted = true
|
|
722
|
+
this._startResidentQuery()
|
|
723
|
+
}
|
|
724
|
+
if (this._closed) return { accepted: false, reason: "queue_dead" }
|
|
700
725
|
this._inputQueue.push(toSDKUserMessage(prompt))
|
|
701
|
-
return
|
|
726
|
+
return { accepted: true }
|
|
702
727
|
}
|
|
703
728
|
|
|
704
729
|
// 従来 per-message: 1 メッセージ = 1 query (resume チェーン)。既存セッション (resume) 用。
|
|
705
730
|
// 改修3: busy 中の送信は破棄せず pending キューへ退避し、現ターン完了時に drain する
|
|
706
731
|
// (ターミナル流の「積む→待機→順次実行」)。常駐 query 化はしないので暴走リスクは増えない。
|
|
707
|
-
|
|
732
|
+
// A-3 (2026-07-05): busy===false でも _pendingMessages が非空なら (本来は
|
|
733
|
+
// _runPerMessage の finally が同一 tick 内で busy=false→drain を行うため到達しない
|
|
734
|
+
// はずだが、将来の変更で両者の間に await が入り込んだ場合の防御) 直接実行へ
|
|
735
|
+
// バイパスさせず、必ず pending 経由の FIFO へ合流させる (resident 側の FIFO 修正
|
|
736
|
+
// (PR #212) と同じ「pending を追い越させない」という設計思想)。
|
|
737
|
+
if (this._busy || this._pendingMessages.length > 0) {
|
|
708
738
|
this._enqueuePending(prompt, ultracode)
|
|
709
739
|
this.logger?.info(
|
|
710
740
|
{ stream_id: this.stream_id, queued: this._pendingMessages.length },
|
|
711
741
|
"claude busy, message queued",
|
|
712
742
|
)
|
|
713
743
|
this._emitQueueState()
|
|
714
|
-
return
|
|
744
|
+
return { accepted: true }
|
|
715
745
|
}
|
|
716
|
-
|
|
746
|
+
// ack はターン完走を待たず「起動できたか」で返すため、フル実行の Promise を
|
|
747
|
+
// 呼び出し元へ伝播させない (fire-and-forget)。例外は _runPerMessage 内部で
|
|
748
|
+
// 握っているため通常は reject しないが、念のため warn だけ残す。
|
|
749
|
+
this._runPerMessage(prompt, { ultracode }).catch((err) => {
|
|
750
|
+
this.logger?.error(
|
|
751
|
+
{ stream_id: this.stream_id, err: err?.message },
|
|
752
|
+
"per-message run threw unexpectedly",
|
|
753
|
+
)
|
|
754
|
+
})
|
|
755
|
+
return { accepted: true }
|
|
717
756
|
}
|
|
718
757
|
|
|
719
758
|
/** per-message 1 ターンを実行する (resume チェーン)。busy 中に届いた送信は sendMessage
|
|
@@ -1049,6 +1088,10 @@ class ClaudeStreamSession {
|
|
|
1049
1088
|
// 適用済み状態の追跡を false にリセットし、次ターンの reconcile が正しく差分判定できる
|
|
1050
1089
|
// ようにする (異常終了→resume 再起動時にも確実にリセット)。
|
|
1051
1090
|
this._ultracodeCurrent = false
|
|
1091
|
+
// A-2 (2026-07-05): 新しい query を起動するたびに「まだ generator は返却されていない」
|
|
1092
|
+
// 状態へリセットする (前回の finally で true になったままだと、この新しい query が
|
|
1093
|
+
// 生きている間も sendMessage が誤って「返却済み」と判定してしまう)。
|
|
1094
|
+
this._residentQueryReturned = false
|
|
1052
1095
|
// 改修4: 起動時に sessionId (= resumeSessionId) があれば resume チェーンで文脈を引き継ぐ。
|
|
1053
1096
|
// query 起動時点の値のみ有効 (起動後に確定/変化する session_id は同一 query 内で継続される)。
|
|
1054
1097
|
if (this.sessionId) options.resume = this.sessionId
|
|
@@ -1106,6 +1149,11 @@ class ClaudeStreamSession {
|
|
|
1106
1149
|
}
|
|
1107
1150
|
}
|
|
1108
1151
|
} finally {
|
|
1152
|
+
// A-2 (2026-07-05): generator が返却された (for await ループが完走した) ことを、
|
|
1153
|
+
// this._residentQuery を null にするより前に記録する。sendMessage 側の
|
|
1154
|
+
// await 中にこのタイミングを跨いだ場合、push 先が誰も consume しない
|
|
1155
|
+
// generator になっていたことを検知できるようにするため。
|
|
1156
|
+
this._residentQueryReturned = true
|
|
1109
1157
|
this._residentQuery = null
|
|
1110
1158
|
this._busy = false
|
|
1111
1159
|
denyPending("closed")
|
|
@@ -1149,12 +1197,44 @@ class ClaudeStreamSession {
|
|
|
1149
1197
|
abortTurn() {
|
|
1150
1198
|
// 常駐 query は interrupt() で現ターンのみ中断する (query は継続、次 input 受付継続)。
|
|
1151
1199
|
if (this._residentQuery && typeof this._residentQuery.interrupt === "function") {
|
|
1200
|
+
const wasBusy = this._busy
|
|
1152
1201
|
try {
|
|
1153
1202
|
this._residentQuery.interrupt()
|
|
1154
1203
|
} catch {
|
|
1155
1204
|
/* ignore */
|
|
1156
1205
|
}
|
|
1157
1206
|
this._busy = false
|
|
1207
|
+
// SDK-P0-2 (2026-07-07): resident 経路のターン終端イベント非対称の解消。
|
|
1208
|
+
// per-message の abort (_runPerMessage finally) は合成 result (aborted_by_user) を
|
|
1209
|
+
// frontend へ emit + onTurnSettled を呼んで UI (turnActive/interrupting) とサイドバー
|
|
1210
|
+
// 信号 (processing→waiting) を即時解除するが、resident の abortTurn は interrupt() が
|
|
1211
|
+
// result を発火しない (下記 T05699 コメント参照) ため、どちらも出さず UI は 120s 無音
|
|
1212
|
+
// ウォッチドッグ / サイドバーは 90s TTL 失効まで「停止中…」で固着していた。ここで
|
|
1213
|
+
// per-message と対称な終端イベントを出し、即時にターン終了を確定させる。
|
|
1214
|
+
// - 実行中ターンがあった (wasBusy) ときだけ emit する (未走行での空 abort に
|
|
1215
|
+
// 「中断しました」フッターを出さない)。
|
|
1216
|
+
// - close()/reap 経由の abort (_closed=true) では browser は既に離脱しているため抑止する。
|
|
1217
|
+
// - drain タイミングは変更しない (既存の同期 drain を維持し、result+idle 二重 drain や
|
|
1218
|
+
// 中断ターンの遅延 idle が新ターンを誤終了させる新種レースを持ち込まない)。
|
|
1219
|
+
// - 合成 result の uuid は重複排除 (isDuplicateEvent の result 署名判定) 回避のため必須。
|
|
1220
|
+
if (wasBusy && !this._closed) {
|
|
1221
|
+
try {
|
|
1222
|
+
this.onTurnSettled?.()
|
|
1223
|
+
} catch {
|
|
1224
|
+
/* ignore */
|
|
1225
|
+
}
|
|
1226
|
+
try {
|
|
1227
|
+
this._emit({
|
|
1228
|
+
type: "result",
|
|
1229
|
+
subtype: "aborted_by_user",
|
|
1230
|
+
uuid: randomUUID(),
|
|
1231
|
+
session_id: this.sessionId ?? undefined,
|
|
1232
|
+
timestamp: new Date().toISOString(),
|
|
1233
|
+
})
|
|
1234
|
+
} catch {
|
|
1235
|
+
/* ignore */
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1158
1238
|
// T05699 残タスク #2-1 (2026-07-04): interrupt() は現ターンの result を発火しない
|
|
1159
1239
|
// (query は継続、次 input 受付継続のみ)。そのため abort 時点で _pendingMessages に
|
|
1160
1240
|
// 溜まっている送信待ちメッセージは、result 経由の drain (_runResidentQuery 内の
|
|
@@ -1482,23 +1562,34 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1482
1562
|
return { stream_id, resuming: !!resumeSessionId }
|
|
1483
1563
|
}
|
|
1484
1564
|
|
|
1485
|
-
/**
|
|
1486
|
-
|
|
1565
|
+
/**
|
|
1566
|
+
* browser → claude の user メッセージ。1 件 = 1 query (resume チェーン)。
|
|
1567
|
+
*
|
|
1568
|
+
* 配達保証 (S2, 2026-07-05): 戻り値は `{ ok: boolean, reason?: string }`
|
|
1569
|
+
* (旧: boolean のみ)。呼び出し元 (main.mjs) はこれを見て `claude.input.ack` /
|
|
1570
|
+
* `claude.input.nack` を browser へ送出する。`ok:true` は「ターン完走」ではなく
|
|
1571
|
+
* 「キュー/常駐 query への投入が完了した」ことを意味する (ターン完走まで待つと
|
|
1572
|
+
* ack が大幅に遅延するため)。ターン実行自体の結果は従来どおり onEvent 経由で届く。
|
|
1573
|
+
*/
|
|
1574
|
+
async input({ stream_id, message, ultracode }) {
|
|
1487
1575
|
const s = this.sessions.get(stream_id)
|
|
1488
1576
|
if (!s) {
|
|
1489
1577
|
this.logger?.warn({ stream_id }, "claude.input but stream missing")
|
|
1490
|
-
return false
|
|
1578
|
+
return { ok: false, reason: "stream_missing" }
|
|
1491
1579
|
}
|
|
1492
1580
|
s.touch(stream_id) // B11: この端末の最終アクティビティを更新 (死端末 GC 用)
|
|
1493
|
-
// 非同期でターン実行 (完了は result イベント + onEvent 経由で browser に届く)
|
|
1494
1581
|
// ultracode (0.6.28): このメッセージのみ ultracode ワンショットを適用するフラグ。
|
|
1495
|
-
|
|
1582
|
+
try {
|
|
1583
|
+
const result = await s.sendMessage(message, { ultracode: ultracode === true })
|
|
1584
|
+
if (result?.accepted) return { ok: true }
|
|
1585
|
+
return { ok: false, reason: result?.reason || "queue_dead" }
|
|
1586
|
+
} catch (err) {
|
|
1496
1587
|
this.logger?.error(
|
|
1497
1588
|
{ stream_id, err: err?.message },
|
|
1498
1589
|
"claude sendMessage threw unexpectedly",
|
|
1499
1590
|
)
|
|
1500
|
-
|
|
1501
|
-
|
|
1591
|
+
return { ok: false, reason: "error" }
|
|
1592
|
+
}
|
|
1502
1593
|
}
|
|
1503
1594
|
|
|
1504
1595
|
/** browser → claude の permission 応答。allow/deny + 加工された input を SDK に返す。 */
|
|
@@ -20,8 +20,22 @@ import { EventEmitter } from "node:events"
|
|
|
20
20
|
|
|
21
21
|
const DEFAULT_CODEX_CMD = "codex"
|
|
22
22
|
|
|
23
|
+
// S3 Codex 堅牢化 (2026-07-05): request() の既定タイムアウト。app-server プロセスが
|
|
24
|
+
// 生きたまま応答しなくなるケース (ハング/デッドロック) では、プロセス exit イベントに
|
|
25
|
+
// 依存する既存の _settleAllPending() が発火せず、turn/start 等の Promise が永久に
|
|
26
|
+
// pending のままになる (「attach 後バブルは出るが Codex が反応しない」報告の根本原因の
|
|
27
|
+
// 一つ)。HUB_CODEX_REQUEST_TIMEOUT_MS で上書き可能。
|
|
28
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 20_000
|
|
29
|
+
|
|
30
|
+
function resolveRequestTimeoutMs(explicit) {
|
|
31
|
+
if (typeof explicit === "number" && explicit >= 0) return explicit
|
|
32
|
+
const raw = process.env.HUB_CODEX_REQUEST_TIMEOUT_MS
|
|
33
|
+
const parsed = raw !== undefined ? Number(raw) : NaN
|
|
34
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_REQUEST_TIMEOUT_MS
|
|
35
|
+
}
|
|
36
|
+
|
|
23
37
|
export class CodexAppServerClient extends EventEmitter {
|
|
24
|
-
constructor({ cwd, codexCmd, env, logger, spawnFn } = {}) {
|
|
38
|
+
constructor({ cwd, codexCmd, env, logger, spawnFn, requestTimeoutMs } = {}) {
|
|
25
39
|
super()
|
|
26
40
|
this.cwd = cwd
|
|
27
41
|
this.codexCmd = codexCmd || DEFAULT_CODEX_CMD
|
|
@@ -30,6 +44,9 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
30
44
|
// テストから spawn をスタブ化できるよう注入可能にする (実 codex バイナリ不要で
|
|
31
45
|
// JSON-RPC 相関ロジックだけを検証するため。既定は node:child_process の spawn)。
|
|
32
46
|
this._spawnFn = spawnFn || spawn
|
|
47
|
+
// テストから短縮できるよう注入可能にする (既定は 20000ms、env でも上書き可能)。
|
|
48
|
+
// 0 を指定するとタイムアウト無効 (旧挙動、テスト以外での使用は非推奨)。
|
|
49
|
+
this._requestTimeoutMs = resolveRequestTimeoutMs(requestTimeoutMs)
|
|
33
50
|
this.child = null
|
|
34
51
|
this.closed = false
|
|
35
52
|
this._buf = ""
|
|
@@ -136,7 +153,15 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
136
153
|
this.logger?.warn?.({ msg }, "codex app-server: unrecognized message shape")
|
|
137
154
|
}
|
|
138
155
|
|
|
139
|
-
/**
|
|
156
|
+
/**
|
|
157
|
+
* クライアント発のリクエストを送り、応答 (result) を Promise で返す。
|
|
158
|
+
*
|
|
159
|
+
* S3 Codex 堅牢化: `this._requestTimeoutMs` (既定 20000ms、`HUB_CODEX_REQUEST_TIMEOUT_MS`
|
|
160
|
+
* で上書き可) を過ぎても応答が来なければ reject する。app-server プロセス自体は生きて
|
|
161
|
+
* いる (exit イベントが発火しない) ハングを想定した保険であり、`_pending` からは
|
|
162
|
+
* タイムアウト時に確実に除去する (遅延応答が届いても再度 resolve/reject されないよう
|
|
163
|
+
* 二重解決を防ぎつつ、マップの肥大化 = メモリリークも防ぐ)。
|
|
164
|
+
*/
|
|
140
165
|
request(method, params) {
|
|
141
166
|
if (this.closed || !this.child) {
|
|
142
167
|
return Promise.reject(new Error("codex app-server is not running"))
|
|
@@ -144,7 +169,26 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
144
169
|
const id = ++this._reqId
|
|
145
170
|
this._write({ jsonrpc: "2.0", id, method, params })
|
|
146
171
|
return new Promise((resolve, reject) => {
|
|
147
|
-
|
|
172
|
+
let timer = null
|
|
173
|
+
const settle = (fn) => (value) => {
|
|
174
|
+
if (timer) clearTimeout(timer)
|
|
175
|
+
this._pending.delete(id)
|
|
176
|
+
fn(value)
|
|
177
|
+
}
|
|
178
|
+
const entry = { resolve: null, reject: null }
|
|
179
|
+
entry.resolve = settle(resolve)
|
|
180
|
+
entry.reject = settle(reject)
|
|
181
|
+
if (this._requestTimeoutMs > 0) {
|
|
182
|
+
timer = setTimeout(() => {
|
|
183
|
+
entry.reject(
|
|
184
|
+
Object.assign(new Error(`codex app-server request timed out: ${method}`), {
|
|
185
|
+
reason: "timeout",
|
|
186
|
+
}),
|
|
187
|
+
)
|
|
188
|
+
}, this._requestTimeoutMs)
|
|
189
|
+
timer.unref?.()
|
|
190
|
+
}
|
|
191
|
+
this._pending.set(id, entry)
|
|
148
192
|
})
|
|
149
193
|
}
|
|
150
194
|
|
|
@@ -77,6 +77,37 @@ function flattenThreadItems(thread) {
|
|
|
77
77
|
return items
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* 配達保証 (S2, 2026-07-05): `input()` が reject したエラーを `codex.input.nack` の
|
|
82
|
+
* `reason` へ分類する。
|
|
83
|
+
*
|
|
84
|
+
* S3 Codex 堅牢化 (同日マージ) 以降、`CodexAppServerClient.request()` のタイムアウトや
|
|
85
|
+
* `CodexStreamBridge.input()` の turn/steer→turn/start 自動リトライ失敗は、reject する
|
|
86
|
+
* Error に権威ある `.reason` (`'timeout'` / `'turn_mismatch_retry_failed'`) を直接
|
|
87
|
+
* 付与するようになった。付与されていればそれを最優先で採用し (main.mjs が組み立てる
|
|
88
|
+
* `codex.error.reason` と同じ値になり、frontend 側の分岐を一致させられる)、付与されて
|
|
89
|
+
* いない場合 (例: `_require()` の「セッション不在」) のみメッセージ文言からの
|
|
90
|
+
* ベストエフォート分類にフォールバックする。
|
|
91
|
+
*
|
|
92
|
+
* - stream_missing: `_require` が投げる「セッション不在」(stream_id 誤り / 既に detach 済み)
|
|
93
|
+
* - queue_dead: app-server プロセスが既に停止/終了している
|
|
94
|
+
* - timeout / turn_mismatch_retry_failed: 上記 S3 由来の err.reason をそのまま採用
|
|
95
|
+
* @param {unknown} err
|
|
96
|
+
* @returns {string}
|
|
97
|
+
*/
|
|
98
|
+
export function classifyCodexInputError(err) {
|
|
99
|
+
if (typeof err?.reason === "string" && err.reason) return err.reason
|
|
100
|
+
const message = String(err?.message || err || "")
|
|
101
|
+
if (/no active codex session/i.test(message)) return "stream_missing"
|
|
102
|
+
if (/not running|exited unexpectedly|client stopped/i.test(message)) {
|
|
103
|
+
return "queue_dead"
|
|
104
|
+
}
|
|
105
|
+
if (/turn/i.test(message) && /(mismatch|expected|stale|unknown)/i.test(message)) {
|
|
106
|
+
return "turn_mismatch"
|
|
107
|
+
}
|
|
108
|
+
return "error"
|
|
109
|
+
}
|
|
110
|
+
|
|
80
111
|
function userInputFromMessage(message) {
|
|
81
112
|
const content = message?.content
|
|
82
113
|
if (typeof content === "string") {
|
|
@@ -280,12 +311,41 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
280
311
|
const session = this._require(stream_id)
|
|
281
312
|
const input = userInputFromMessage(message)
|
|
282
313
|
if (session.activeTurnId) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
314
|
+
try {
|
|
315
|
+
await session.client.request("turn/steer", {
|
|
316
|
+
threadId: session.threadId,
|
|
317
|
+
expectedTurnId: session.activeTurnId,
|
|
318
|
+
input,
|
|
319
|
+
})
|
|
320
|
+
return
|
|
321
|
+
} catch (err) {
|
|
322
|
+
// S3 Codex 堅牢化 (2026-07-05): turn/steer が reject される最大の原因は
|
|
323
|
+
// expectedTurnId 不一致 (turn/completed 通知の取りこぼしで session.activeTurnId
|
|
324
|
+
// が古いまま残るケース) や request() タイムアウト。回復キューが無いと、以降の
|
|
325
|
+
// 入力が全て turn/steer として送られ続けて無反応になる (報告症状②の原因)。
|
|
326
|
+
// ここで activeTurnId を捨てて「busy ではない」扱いに戻し、同一メッセージを
|
|
327
|
+
// 1 回だけ turn/start として自動リトライすることで自己修復する。
|
|
328
|
+
session.activeTurnId = null
|
|
329
|
+
try {
|
|
330
|
+
const retryParams = { threadId: session.threadId, input }
|
|
331
|
+
if (model) retryParams.model = model
|
|
332
|
+
if (effort) retryParams.effort = effort
|
|
333
|
+
await session.client.request("turn/start", retryParams)
|
|
334
|
+
return
|
|
335
|
+
} catch (retryErr) {
|
|
336
|
+
// 呼び出し元 (main.mjs の codex.input dispatch) が既存の .catch() で
|
|
337
|
+
// codex.error を送出する経路に reason を乗せて届ける (bridge 側で二重に
|
|
338
|
+
// 'error' イベントを emit すると frontend へ codex.error が二重送出される
|
|
339
|
+
// ため、ここでは throw のみに留める)。
|
|
340
|
+
const reason = "turn_mismatch_retry_failed"
|
|
341
|
+
throw Object.assign(
|
|
342
|
+
new Error(
|
|
343
|
+
`turn/steer failed (${err.message}) and turn/start retry also failed: ${retryErr.message}`,
|
|
344
|
+
),
|
|
345
|
+
{ reason, cause: retryErr },
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
289
349
|
}
|
|
290
350
|
const params = { threadId: session.threadId, input }
|
|
291
351
|
if (model) params.model = model
|
package/src/main.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs
|
|
|
25
25
|
import { WsClient } from "./ws-client.mjs"
|
|
26
26
|
import { PtyBridge } from "./pty-bridge.mjs"
|
|
27
27
|
import { ClaudeStreamBridge } from "./claude-stream-bridge.mjs"
|
|
28
|
-
import { CodexStreamBridge } from "./codex-stream-bridge.mjs"
|
|
28
|
+
import { CodexStreamBridge, classifyCodexInputError } from "./codex-stream-bridge.mjs"
|
|
29
29
|
import { UploadManager } from "./claude-upload.mjs"
|
|
30
30
|
import { requestSelfUninstall } from "./service-install.mjs"
|
|
31
31
|
import {
|
|
@@ -103,6 +103,7 @@ import {
|
|
|
103
103
|
getSessionUsages,
|
|
104
104
|
getUsage,
|
|
105
105
|
isFreshUnboundBind,
|
|
106
|
+
jsonlContextForCwd,
|
|
106
107
|
recordChatRateLimit,
|
|
107
108
|
recordChatUsageSnapshot,
|
|
108
109
|
turnActiveForCwd,
|
|
@@ -547,13 +548,23 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
547
548
|
status: "processing",
|
|
548
549
|
contextPct: pct,
|
|
549
550
|
inputPending: false,
|
|
551
|
+
// identity (S1): この cwd の chat 信号が「今どの CLI・どのセッションのものか」を
|
|
552
|
+
// 明示する。claude.attach で確定させた identity と一致すれば温存、別セッション/
|
|
553
|
+
// エンジンに切り替わっていれば chat-signals.mjs 側が自動でリセットする。
|
|
554
|
+
cliKind: "claude",
|
|
555
|
+
sessionId: session_id || null,
|
|
550
556
|
})
|
|
551
557
|
} catch {
|
|
552
558
|
/* ignore */
|
|
553
559
|
}
|
|
554
560
|
} else if (event?.type === "result") {
|
|
555
561
|
try {
|
|
556
|
-
recordChatActivity(cwd, {
|
|
562
|
+
recordChatActivity(cwd, {
|
|
563
|
+
status: "waiting",
|
|
564
|
+
inputPending: false,
|
|
565
|
+
cliKind: "claude",
|
|
566
|
+
sessionId: session_id || null,
|
|
567
|
+
})
|
|
557
568
|
} catch {
|
|
558
569
|
/* ignore */
|
|
559
570
|
}
|
|
@@ -621,17 +632,51 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
621
632
|
if (codexBridge) {
|
|
622
633
|
codexBridge.on("event", ({ stream_id, thread_id, cwd, notification }) => {
|
|
623
634
|
if (notification.method === "turn/started") {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
635
|
+
// S1 (2026-07): contextPct を渡していなかったため、Codex 選択中は状態ドット
|
|
636
|
+
// (processing/waiting) こそ更新されるがコンテキスト% は claude 由来の古い値の
|
|
637
|
+
// ままだった (症状③の一因)。codexUsageForThread は rollout ファイルの I/O を
|
|
638
|
+
// 伴うため fire-and-forget (await せず、取得できた時点で反映)。
|
|
639
|
+
;(async () => {
|
|
640
|
+
let contextPct = null
|
|
641
|
+
try {
|
|
642
|
+
const usage = await codexUsageForThread(thread_id)
|
|
643
|
+
contextPct = typeof usage?.context === "number" ? usage.context : null
|
|
644
|
+
} catch {
|
|
645
|
+
/* ignore */
|
|
646
|
+
}
|
|
647
|
+
try {
|
|
648
|
+
recordChatActivity(cwd, {
|
|
649
|
+
status: "processing",
|
|
650
|
+
contextPct,
|
|
651
|
+
inputPending: false,
|
|
652
|
+
cliKind: "codex",
|
|
653
|
+
sessionId: thread_id || null,
|
|
654
|
+
})
|
|
655
|
+
} catch {
|
|
656
|
+
/* ignore */
|
|
657
|
+
}
|
|
658
|
+
})()
|
|
629
659
|
} else if (notification.method === "turn/completed") {
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
660
|
+
;(async () => {
|
|
661
|
+
let contextPct = null
|
|
662
|
+
try {
|
|
663
|
+
const usage = await codexUsageForThread(thread_id)
|
|
664
|
+
contextPct = typeof usage?.context === "number" ? usage.context : null
|
|
665
|
+
} catch {
|
|
666
|
+
/* ignore */
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
recordChatActivity(cwd, {
|
|
670
|
+
status: "waiting",
|
|
671
|
+
contextPct,
|
|
672
|
+
inputPending: false,
|
|
673
|
+
cliKind: "codex",
|
|
674
|
+
sessionId: thread_id || null,
|
|
675
|
+
})
|
|
676
|
+
} catch {
|
|
677
|
+
/* ignore */
|
|
678
|
+
}
|
|
679
|
+
})()
|
|
635
680
|
}
|
|
636
681
|
client.send({ type: "codex.event", stream_id, thread_id, event: notification })
|
|
637
682
|
})
|
|
@@ -1415,6 +1460,59 @@ export function applyChatOverlay(paneStatus, paneContextPct, chat) {
|
|
|
1415
1460
|
return { status, contextPct }
|
|
1416
1461
|
}
|
|
1417
1462
|
|
|
1463
|
+
/**
|
|
1464
|
+
* S1 (2026-07): claude.attach / codex.attach の直後に、その cwd に対応する session.state を
|
|
1465
|
+
* 1 回だけ即時 push する。startStateLoop の周期 (既定 5s) を待つと、エンジン切替や新規
|
|
1466
|
+
* セッション選択の直後もサイドバーのドーナツ/ステータスドットが前セッションの値のまま
|
|
1467
|
+
* 数秒間残ってしまう (症状③④)。claude.tui.bind が既に持つ「即時ステータススナップショット」
|
|
1468
|
+
* (main.mjs 内 detectSessionState 直後の push) と同じ発想で、チャット (SDK/app-server) 側にも
|
|
1469
|
+
* 対称の即時 push を用意する。
|
|
1470
|
+
*
|
|
1471
|
+
* cwd → session_name の解決: claude.attach / codex.attach は tmux の session_name を知らない
|
|
1472
|
+
* (browser は cwd/stream_id しか送らない) ため、listSessionStates で全 tmux session を走査し
|
|
1473
|
+
* cwd が一致するものへ push する (通常は 1 cwd = 1 tmux session)。tmux 自体が無い / 一致が
|
|
1474
|
+
* 無ければ何もしない (安全側、次の poll 系フォールバックに委ねる)。
|
|
1475
|
+
*
|
|
1476
|
+
* `listSessionStatesFn` は単体テスト用の DI ポイント (既定は実 `listSessionStates`、tmux を
|
|
1477
|
+
* 起動する)。実運用では省略してよい。
|
|
1478
|
+
*
|
|
1479
|
+
* @param {{ client: object, plugins: object, logger?: object, cwd: string, cliKind: 'claude'|'codex', sessionId: string|null, contextPct?: number|null, listSessionStatesFn?: Function }} args
|
|
1480
|
+
*/
|
|
1481
|
+
export async function pushImmediateChatState({
|
|
1482
|
+
client,
|
|
1483
|
+
plugins,
|
|
1484
|
+
logger,
|
|
1485
|
+
cwd,
|
|
1486
|
+
cliKind,
|
|
1487
|
+
sessionId,
|
|
1488
|
+
contextPct = 0,
|
|
1489
|
+
listSessionStatesFn = listSessionStates,
|
|
1490
|
+
}) {
|
|
1491
|
+
if (!cwd || !client) return
|
|
1492
|
+
try {
|
|
1493
|
+
const states = await listSessionStatesFn({ plugins, logger })
|
|
1494
|
+
for (const s of states) {
|
|
1495
|
+
if (s.cwd !== cwd) continue
|
|
1496
|
+
client.send({
|
|
1497
|
+
type: "session.state",
|
|
1498
|
+
session_name: s.session_name,
|
|
1499
|
+
status: "waiting",
|
|
1500
|
+
context_pct: typeof contextPct === "number" ? contextPct : 0,
|
|
1501
|
+
permission_mode: s.permission_mode ?? null,
|
|
1502
|
+
stable: true,
|
|
1503
|
+
proc_busy: false,
|
|
1504
|
+
child_busy: false,
|
|
1505
|
+
stalled: false,
|
|
1506
|
+
turn_active: false,
|
|
1507
|
+
cli_kind: cliKind,
|
|
1508
|
+
chat_session_id: sessionId ?? null,
|
|
1509
|
+
})
|
|
1510
|
+
}
|
|
1511
|
+
} catch (err) {
|
|
1512
|
+
logger?.warn?.({ err: err?.message, cwd }, "pushImmediateChatState failed")
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1418
1516
|
/**
|
|
1419
1517
|
* turnActiveForCwd / jsonlContextForCwd に渡す bound session_id を解決する純関数。
|
|
1420
1518
|
*
|
|
@@ -1638,6 +1736,13 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
1638
1736
|
turnActive = null
|
|
1639
1737
|
}
|
|
1640
1738
|
|
|
1739
|
+
// S1 (2026-07): チャット信号 (getChatSignal) が identity (cliKind/sessionId) を
|
|
1740
|
+
// 持っていれば、それをそのまま session.state に同梱する。frontend はこれで「今
|
|
1741
|
+
// 表示中のエンジン/セッションと一致する push か」を判定し、不一致の値を適用しない
|
|
1742
|
+
// (前セッションの値を上書きし続ける症状③④の frontend 側の受け口)。
|
|
1743
|
+
const chatCliKind = chat?.cliKind ?? null
|
|
1744
|
+
const chatSessionId = chat?.sessionId ?? null
|
|
1745
|
+
|
|
1641
1746
|
const prev = lastByName.get(s.session_name)
|
|
1642
1747
|
if (
|
|
1643
1748
|
!prev ||
|
|
@@ -1648,7 +1753,9 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
1648
1753
|
prev.proc_busy !== procBusy ||
|
|
1649
1754
|
prev.child_busy !== childBusy ||
|
|
1650
1755
|
prev.stalled !== stalled ||
|
|
1651
|
-
prev.turn_active !== turnActive
|
|
1756
|
+
prev.turn_active !== turnActive ||
|
|
1757
|
+
prev.cli_kind !== chatCliKind ||
|
|
1758
|
+
prev.chat_session_id !== chatSessionId
|
|
1652
1759
|
) {
|
|
1653
1760
|
lastByName.set(s.session_name, {
|
|
1654
1761
|
status,
|
|
@@ -1659,6 +1766,8 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
1659
1766
|
child_busy: childBusy,
|
|
1660
1767
|
stalled,
|
|
1661
1768
|
turn_active: turnActive,
|
|
1769
|
+
cli_kind: chatCliKind,
|
|
1770
|
+
chat_session_id: chatSessionId,
|
|
1662
1771
|
})
|
|
1663
1772
|
// 計装 (2026-06-19, 既定 OFF=2026-06-20): 状態変化時に値を記録する。常時 info はログ肥大の
|
|
1664
1773
|
// 主因のため HUB_AGENT_STATE_TRACE=1 のときだけ出す。childBusy/outputActive を分けて出し、
|
|
@@ -1676,6 +1785,8 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
1676
1785
|
stable,
|
|
1677
1786
|
context_pct: contextPct,
|
|
1678
1787
|
turn_active: turnActive,
|
|
1788
|
+
cli_kind: chatCliKind,
|
|
1789
|
+
chat_session_id: chatSessionId,
|
|
1679
1790
|
},
|
|
1680
1791
|
"session.state push",
|
|
1681
1792
|
)
|
|
@@ -1695,6 +1806,11 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
|
|
|
1695
1806
|
// turn_active: ステータスドット 4 状態統一の権威ソース。null は旧クライアント互換
|
|
1696
1807
|
// (frontend は欠落時に旧 prompt_submit/stale 降格ロジックへフォールバック)。
|
|
1697
1808
|
turn_active: turnActive,
|
|
1809
|
+
// cli_kind / chat_session_id (S1, 2026-07): このセッションの現在の chat 信号が
|
|
1810
|
+
// どの CLI・どのセッションのものか。チャット信号が無い (TUI 専用/未対応) 行では
|
|
1811
|
+
// null (旧クライアント互換、frontend は無条件マージにフォールバック)。
|
|
1812
|
+
cli_kind: chatCliKind,
|
|
1813
|
+
chat_session_id: chatSessionId,
|
|
1698
1814
|
})
|
|
1699
1815
|
}
|
|
1700
1816
|
// 消滅セッションの GC (perf監査/メモリリーク対策): tmux から消えたセッションの状態 Map を
|
|
@@ -1916,6 +2032,31 @@ function rememberInputId(input_id) {
|
|
|
1916
2032
|
}
|
|
1917
2033
|
}
|
|
1918
2034
|
|
|
2035
|
+
/**
|
|
2036
|
+
* SDK チャット配達保証 (S2, 2026-07-05) 用の冪等化レジストリ。
|
|
2037
|
+
*
|
|
2038
|
+
* `claude.input` / `codex.input` に browser が付与する `input_id` を記憶し、ack
|
|
2039
|
+
* ウォッチドッグ再送 (browser 側が 5s 以内に ack/nack を受け取れず再送するケース) で
|
|
2040
|
+
* 同じメッセージが 2 回ターン実行されないようにする。tracked pty.data
|
|
2041
|
+
* (`seenInputIds` 上) と同じ設計だが、チャンネル別に Set を分ける
|
|
2042
|
+
* (input_id の採番元は browser 側で共通の可能性があるが、pty と chat で
|
|
2043
|
+
* 意味論が異なる衝突を避けるため)。
|
|
2044
|
+
*/
|
|
2045
|
+
function makeSeenInputIdRegistry(max = SEEN_INPUT_IDS_MAX) {
|
|
2046
|
+
const seen = new Set()
|
|
2047
|
+
return {
|
|
2048
|
+
has: (id) => seen.has(id),
|
|
2049
|
+
remember: (id) => {
|
|
2050
|
+
seen.add(id)
|
|
2051
|
+
while (seen.size > max) {
|
|
2052
|
+
seen.delete(seen.values().next().value)
|
|
2053
|
+
}
|
|
2054
|
+
},
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
const seenClaudeChatInputIds = makeSeenInputIdRegistry()
|
|
2058
|
+
const seenCodexChatInputIds = makeSeenInputIdRegistry()
|
|
2059
|
+
|
|
1919
2060
|
/**
|
|
1920
2061
|
* input_id 付き pty.data (tracked 入力) の処理。write 成功で pty.input.ack を返し、
|
|
1921
2062
|
* stream 不在なら pty.error を返して browser の再 attach → 再送に繋げる。
|
|
@@ -1959,6 +2100,138 @@ export function handleUntrackedPtyData(msg, ctx) {
|
|
|
1959
2100
|
}
|
|
1960
2101
|
}
|
|
1961
2102
|
|
|
2103
|
+
/**
|
|
2104
|
+
* SDK チャット配達保証 (S2, 2026-07-05): `claude.input` を処理し、browser が付与した
|
|
2105
|
+
* `input_id` があれば `claude.input.ack` / `claude.input.nack` を返す。無ければ従来
|
|
2106
|
+
* どおり無応答の fire-and-forget (旧 frontend / 内部呼び出しとの後方互換)。
|
|
2107
|
+
*
|
|
2108
|
+
* `ClaudeStreamBridge.input()` の戻り値 `{ ok, reason? }` は「ターン完走」ではなく
|
|
2109
|
+
* 「キュー/常駐 query への投入が完了したか」を表す (ack を素早く返すため)。
|
|
2110
|
+
* テストから直接呼べるよう dispatch から切り出して export する
|
|
2111
|
+
* (handleTrackedPtyData と同じ設計)。
|
|
2112
|
+
*/
|
|
2113
|
+
export function handleClaudeChatInput(msg, ctx) {
|
|
2114
|
+
const stream_id = msg.stream_id
|
|
2115
|
+
const input_id = typeof msg.input_id === "string" && msg.input_id ? msg.input_id : null
|
|
2116
|
+
if (!ctx.claudeBridge) {
|
|
2117
|
+
if (input_id) {
|
|
2118
|
+
ctx.client.send({
|
|
2119
|
+
type: "claude.input.nack",
|
|
2120
|
+
stream_id,
|
|
2121
|
+
input_id,
|
|
2122
|
+
reason: "stream_missing",
|
|
2123
|
+
})
|
|
2124
|
+
}
|
|
2125
|
+
return undefined
|
|
2126
|
+
}
|
|
2127
|
+
if (input_id && seenClaudeChatInputIds.has(input_id)) {
|
|
2128
|
+
// 再送 (ack 喪失想定)。同じターンを 2 回実行しないよう sendMessage を
|
|
2129
|
+
// 呼び直さず ack のみ返す (tracked pty.data の dedup と同じ考え方)。
|
|
2130
|
+
ctx.logger?.info?.({ stream_id, input_id }, "claude.input dedup (ack only)")
|
|
2131
|
+
ctx.client.send({ type: "claude.input.ack", stream_id, input_id })
|
|
2132
|
+
return undefined
|
|
2133
|
+
}
|
|
2134
|
+
return ctx.claudeBridge
|
|
2135
|
+
.input({
|
|
2136
|
+
stream_id,
|
|
2137
|
+
message: msg.message,
|
|
2138
|
+
// ultracode (0.6.28): browser がこのメッセージ単位で送るワンショット指定。
|
|
2139
|
+
// true のときだけそのターンを xhigh effort + dynamic-workflow で実行する。
|
|
2140
|
+
ultracode: msg.ultracode === true,
|
|
2141
|
+
})
|
|
2142
|
+
.then((result) => {
|
|
2143
|
+
if (!input_id) return
|
|
2144
|
+
if (result?.ok) {
|
|
2145
|
+
seenClaudeChatInputIds.remember(input_id)
|
|
2146
|
+
ctx.client.send({ type: "claude.input.ack", stream_id, input_id })
|
|
2147
|
+
} else {
|
|
2148
|
+
ctx.client.send({
|
|
2149
|
+
type: "claude.input.nack",
|
|
2150
|
+
stream_id,
|
|
2151
|
+
input_id,
|
|
2152
|
+
reason: result?.reason || "queue_dead",
|
|
2153
|
+
})
|
|
2154
|
+
}
|
|
2155
|
+
})
|
|
2156
|
+
.catch((err) => {
|
|
2157
|
+
ctx.logger?.error?.(
|
|
2158
|
+
{ stream_id, err: err?.message },
|
|
2159
|
+
"claude.input dispatch threw unexpectedly",
|
|
2160
|
+
)
|
|
2161
|
+
if (input_id) {
|
|
2162
|
+
ctx.client.send({
|
|
2163
|
+
type: "claude.input.nack",
|
|
2164
|
+
stream_id,
|
|
2165
|
+
input_id,
|
|
2166
|
+
reason: "error",
|
|
2167
|
+
})
|
|
2168
|
+
}
|
|
2169
|
+
})
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
/**
|
|
2173
|
+
* SDK チャット配達保証 (S2, 2026-07-05): `codex.input` を処理し、browser が付与した
|
|
2174
|
+
* `input_id` があれば `codex.input.ack` / `codex.input.nack` を返す (claude.input と
|
|
2175
|
+
* 対称)。`CodexStreamBridge.input()` は `turn/start` / `turn/steer` の JSON-RPC 呼び出し
|
|
2176
|
+
* そのものを await する設計のため、resolve/reject がそのまま ack/nack の判定になる。
|
|
2177
|
+
* 既存の `codex.error` 通知も維持する (input_id を知らない旧 frontend や、他の消費者向け)。
|
|
2178
|
+
* テストから直接呼べるよう dispatch から切り出して export する。
|
|
2179
|
+
*/
|
|
2180
|
+
export function handleCodexChatInput(msg, ctx) {
|
|
2181
|
+
const stream_id = msg.stream_id
|
|
2182
|
+
const input_id = typeof msg.input_id === "string" && msg.input_id ? msg.input_id : null
|
|
2183
|
+
if (!ctx.codexBridge) {
|
|
2184
|
+
if (input_id) {
|
|
2185
|
+
ctx.client.send({
|
|
2186
|
+
type: "codex.input.nack",
|
|
2187
|
+
stream_id,
|
|
2188
|
+
input_id,
|
|
2189
|
+
reason: "stream_missing",
|
|
2190
|
+
})
|
|
2191
|
+
}
|
|
2192
|
+
return undefined
|
|
2193
|
+
}
|
|
2194
|
+
if (input_id && seenCodexChatInputIds.has(input_id)) {
|
|
2195
|
+
// 再送 (ack 喪失想定)。turn/start・turn/steer を再送出せず ack のみ返す
|
|
2196
|
+
// (二重ターン実行を避ける。claude.input dedup と同じ考え方)。
|
|
2197
|
+
ctx.logger?.info?.({ stream_id, input_id }, "codex.input dedup (ack only)")
|
|
2198
|
+
ctx.client.send({ type: "codex.input.ack", stream_id, input_id })
|
|
2199
|
+
return undefined
|
|
2200
|
+
}
|
|
2201
|
+
return ctx.codexBridge
|
|
2202
|
+
.input({
|
|
2203
|
+
stream_id,
|
|
2204
|
+
message: msg.message,
|
|
2205
|
+
model: msg.model || undefined,
|
|
2206
|
+
effort: msg.effort || undefined,
|
|
2207
|
+
})
|
|
2208
|
+
.then(() => {
|
|
2209
|
+
if (!input_id) return
|
|
2210
|
+
seenCodexChatInputIds.remember(input_id)
|
|
2211
|
+
ctx.client.send({ type: "codex.input.ack", stream_id, input_id })
|
|
2212
|
+
})
|
|
2213
|
+
.catch((err) => {
|
|
2214
|
+
if (input_id) {
|
|
2215
|
+
ctx.client.send({
|
|
2216
|
+
type: "codex.input.nack",
|
|
2217
|
+
stream_id,
|
|
2218
|
+
input_id,
|
|
2219
|
+
reason: classifyCodexInputError(err),
|
|
2220
|
+
})
|
|
2221
|
+
}
|
|
2222
|
+
// 既存の error 通知も維持する (input_id を知らない旧 frontend や、
|
|
2223
|
+
// input_id 以外の理由でエラー表示を必要とする他の消費者向け)。S3 Codex 堅牢化
|
|
2224
|
+
// 由来の err.reason ('timeout' / 'turn_mismatch_retry_failed') があれば
|
|
2225
|
+
// 引き続き codex.error にも乗せる (S2 導入前からの互換維持)。
|
|
2226
|
+
ctx.client.send({
|
|
2227
|
+
type: "codex.error",
|
|
2228
|
+
stream_id,
|
|
2229
|
+
error: err.message,
|
|
2230
|
+
...(err.reason ? { reason: err.reason } : {}),
|
|
2231
|
+
})
|
|
2232
|
+
})
|
|
2233
|
+
}
|
|
2234
|
+
|
|
1962
2235
|
/**
|
|
1963
2236
|
* 離席中キュー投入 (claude.tui.queue.flush) の冪等化用に、処理済み flush_id を記憶する。
|
|
1964
2237
|
* WS 再送で同じ flush が二重に届いても二重 paste しないため。上限を超えたら全消去 (best-effort)。
|
|
@@ -2290,9 +2563,11 @@ async function dispatch(msg, ctx) {
|
|
|
2290
2563
|
// cwd は明示指定が無ければ agent.json の cwd → HOME の順で fallback
|
|
2291
2564
|
// (将来 cockpit から worktree path を指定する用)。
|
|
2292
2565
|
// model / permissionMode は agent.json の値も使えるよう fallback。
|
|
2566
|
+
const cwdArg = msg.cwd || ctx.config?.claude_cwd || undefined
|
|
2567
|
+
const resumeSessionId = msg.resume_session_id || null
|
|
2293
2568
|
const info = ctx.claudeBridge.attach({
|
|
2294
2569
|
stream_id,
|
|
2295
|
-
cwd:
|
|
2570
|
+
cwd: cwdArg,
|
|
2296
2571
|
model: msg.model || ctx.config?.claude_model || null,
|
|
2297
2572
|
permissionMode:
|
|
2298
2573
|
msg.permission_mode ||
|
|
@@ -2307,13 +2582,67 @@ async function dispatch(msg, ctx) {
|
|
|
2307
2582
|
// effort (Opus 4.6+): browser がチャット既定 (agent 設定由来) を送る。
|
|
2308
2583
|
// 空文字/未指定なら null (SDK / Claude Code 既定 = Opus は high)。
|
|
2309
2584
|
effort: msg.effort || ctx.config?.claude_effort || null,
|
|
2310
|
-
resumeSessionId
|
|
2585
|
+
resumeSessionId,
|
|
2311
2586
|
})
|
|
2312
2587
|
ctx.client.send({
|
|
2313
2588
|
type: "claude.ready",
|
|
2314
2589
|
stream_id,
|
|
2315
2590
|
resuming: info.resuming,
|
|
2316
2591
|
})
|
|
2592
|
+
// S1 (2026-07): セッション状態の identity 正本化。TUI の claude.tui.bind
|
|
2593
|
+
// (main.mjs 内 clearChatSignal 呼び出し箇所参照) と対称に、SDK チャットの
|
|
2594
|
+
// attach 時点でも前セッションのチャット信号を引き継がない。
|
|
2595
|
+
// - fresh (resume 無し): 新規セッションで session_id はまだ確定しない
|
|
2596
|
+
// (SDK が最初の assistant イベントで通知する) ため null。前の信号を破棄し
|
|
2597
|
+
// ゼロ値を即時 push してドーナツ/ドットを即座にリセットする (症状④)。
|
|
2598
|
+
// - resume: bound session の jsonl から実 context% を解決し、cli_kind='claude' +
|
|
2599
|
+
// 実 session_id で identity を確定させて即時 push する (症状③)。
|
|
2600
|
+
const resolvedCwd = cwdArg || process.env.HOME || process.cwd()
|
|
2601
|
+
if (resolvedCwd) {
|
|
2602
|
+
if (!resumeSessionId) {
|
|
2603
|
+
clearChatSignal(resolvedCwd)
|
|
2604
|
+
recordChatActivity(resolvedCwd, {
|
|
2605
|
+
status: "waiting",
|
|
2606
|
+
contextPct: 0,
|
|
2607
|
+
inputPending: false,
|
|
2608
|
+
cliKind: "claude",
|
|
2609
|
+
sessionId: null,
|
|
2610
|
+
})
|
|
2611
|
+
pushImmediateChatState({
|
|
2612
|
+
client: ctx.client,
|
|
2613
|
+
plugins: ctx.plugins,
|
|
2614
|
+
logger: ctx.logger,
|
|
2615
|
+
cwd: resolvedCwd,
|
|
2616
|
+
cliKind: "claude",
|
|
2617
|
+
sessionId: null,
|
|
2618
|
+
contextPct: 0,
|
|
2619
|
+
}).catch(() => {})
|
|
2620
|
+
} else {
|
|
2621
|
+
jsonlContextForCwd(resolvedCwd, resumeSessionId)
|
|
2622
|
+
.catch(() => null)
|
|
2623
|
+
.then((jsonlCtx) => {
|
|
2624
|
+
const contextPct =
|
|
2625
|
+
typeof jsonlCtx?.percent === "number" ? jsonlCtx.percent : 0
|
|
2626
|
+
recordChatActivity(resolvedCwd, {
|
|
2627
|
+
status: "waiting",
|
|
2628
|
+
contextPct,
|
|
2629
|
+
inputPending: false,
|
|
2630
|
+
cliKind: "claude",
|
|
2631
|
+
sessionId: resumeSessionId,
|
|
2632
|
+
})
|
|
2633
|
+
return pushImmediateChatState({
|
|
2634
|
+
client: ctx.client,
|
|
2635
|
+
plugins: ctx.plugins,
|
|
2636
|
+
logger: ctx.logger,
|
|
2637
|
+
cwd: resolvedCwd,
|
|
2638
|
+
cliKind: "claude",
|
|
2639
|
+
sessionId: resumeSessionId,
|
|
2640
|
+
contextPct,
|
|
2641
|
+
})
|
|
2642
|
+
})
|
|
2643
|
+
.catch(() => {})
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2317
2646
|
} catch (err) {
|
|
2318
2647
|
ctx.client.send({
|
|
2319
2648
|
type: "claude.error",
|
|
@@ -2325,14 +2654,7 @@ async function dispatch(msg, ctx) {
|
|
|
2325
2654
|
}
|
|
2326
2655
|
case "claude.input":
|
|
2327
2656
|
// message: { role: 'user', content: string | Array } を期待
|
|
2328
|
-
|
|
2329
|
-
ctx.claudeBridge.input({
|
|
2330
|
-
stream_id: msg.stream_id,
|
|
2331
|
-
message: msg.message,
|
|
2332
|
-
// ultracode (0.6.28): browser がこのメッセージ単位で送るワンショット指定。
|
|
2333
|
-
// true のときだけそのターンを xhigh effort + dynamic-workflow で実行する。
|
|
2334
|
-
ultracode: msg.ultracode === true,
|
|
2335
|
-
})
|
|
2657
|
+
handleClaudeChatInput(msg, ctx)
|
|
2336
2658
|
return
|
|
2337
2659
|
case "claude.upload": {
|
|
2338
2660
|
// Cockpit チャットモードの添付ファイル受信 (チャンク 1 件)。全チャンク到着で
|
|
@@ -3281,14 +3603,16 @@ async function dispatch(msg, ctx) {
|
|
|
3281
3603
|
return
|
|
3282
3604
|
}
|
|
3283
3605
|
try {
|
|
3606
|
+
const resolvedCwd = msg.cwd || ctx.config?.claude_cwd || undefined
|
|
3607
|
+
const resumeThreadId = msg.resume_thread_id || null
|
|
3284
3608
|
const info = await ctx.codexBridge.attach({
|
|
3285
3609
|
stream_id,
|
|
3286
|
-
cwd:
|
|
3610
|
+
cwd: resolvedCwd,
|
|
3287
3611
|
model: msg.model || undefined,
|
|
3288
3612
|
effort: msg.effort || undefined,
|
|
3289
3613
|
approvalPolicy: msg.approval_policy || undefined,
|
|
3290
3614
|
sandbox: msg.sandbox || undefined,
|
|
3291
|
-
resumeThreadId
|
|
3615
|
+
resumeThreadId,
|
|
3292
3616
|
})
|
|
3293
3617
|
ctx.client.send({
|
|
3294
3618
|
type: "codex.ready",
|
|
@@ -3300,11 +3624,65 @@ async function dispatch(msg, ctx) {
|
|
|
3300
3624
|
// 変更は不要 (_AGENT_PER_STREAM_TYPES の codex.ready がそのまま運ぶ)。
|
|
3301
3625
|
history: info.history || [],
|
|
3302
3626
|
})
|
|
3627
|
+
// S1 (2026-07): claude.attach と対称の identity 正本化。Codex は thread/start
|
|
3628
|
+
// 応答時点で threadId が確定するため (Claude の fresh とは異なり)、fresh でも
|
|
3629
|
+
// 既に分かっている threadId を sessionId として記録する (直後の turn/started が
|
|
3630
|
+
// 同じ threadId を伴って届いても identity 一致でリセットが起きないように)。
|
|
3631
|
+
// - fresh (resume 無し): ゼロ値 + 確定済み threadId で即時 push (症状④)。
|
|
3632
|
+
// - resume: codexUsageForThread で実 context% を解決し即時 push (症状③)。
|
|
3633
|
+
if (resolvedCwd) {
|
|
3634
|
+
if (!resumeThreadId) {
|
|
3635
|
+
clearChatSignal(resolvedCwd)
|
|
3636
|
+
recordChatActivity(resolvedCwd, {
|
|
3637
|
+
status: "waiting",
|
|
3638
|
+
contextPct: 0,
|
|
3639
|
+
inputPending: false,
|
|
3640
|
+
cliKind: "codex",
|
|
3641
|
+
sessionId: info.threadId || null,
|
|
3642
|
+
})
|
|
3643
|
+
pushImmediateChatState({
|
|
3644
|
+
client: ctx.client,
|
|
3645
|
+
plugins: ctx.plugins,
|
|
3646
|
+
logger: ctx.logger,
|
|
3647
|
+
cwd: resolvedCwd,
|
|
3648
|
+
cliKind: "codex",
|
|
3649
|
+
sessionId: info.threadId || null,
|
|
3650
|
+
contextPct: 0,
|
|
3651
|
+
}).catch(() => {})
|
|
3652
|
+
} else {
|
|
3653
|
+
codexUsageForThread(info.threadId)
|
|
3654
|
+
.catch(() => null)
|
|
3655
|
+
.then((usage) => {
|
|
3656
|
+
const contextPct = typeof usage?.context === "number" ? usage.context : 0
|
|
3657
|
+
recordChatActivity(resolvedCwd, {
|
|
3658
|
+
status: "waiting",
|
|
3659
|
+
contextPct,
|
|
3660
|
+
inputPending: false,
|
|
3661
|
+
cliKind: "codex",
|
|
3662
|
+
sessionId: info.threadId,
|
|
3663
|
+
})
|
|
3664
|
+
return pushImmediateChatState({
|
|
3665
|
+
client: ctx.client,
|
|
3666
|
+
plugins: ctx.plugins,
|
|
3667
|
+
logger: ctx.logger,
|
|
3668
|
+
cwd: resolvedCwd,
|
|
3669
|
+
cliKind: "codex",
|
|
3670
|
+
sessionId: info.threadId,
|
|
3671
|
+
contextPct,
|
|
3672
|
+
})
|
|
3673
|
+
})
|
|
3674
|
+
.catch(() => {})
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3303
3677
|
} catch (err) {
|
|
3304
3678
|
ctx.client.send({
|
|
3305
3679
|
type: "codex.error",
|
|
3306
3680
|
stream_id,
|
|
3307
3681
|
error: err.message,
|
|
3682
|
+
// S3 Codex 堅牢化: request() タイムアウト (codex-appserver-client.mjs) は
|
|
3683
|
+
// err.reason='timeout' を付与する。frontend が理由別の文言/表示を出し分け
|
|
3684
|
+
// られるよう素通しする (無ければ undefined = キー自体を省略)。
|
|
3685
|
+
...(err.reason ? { reason: err.reason } : {}),
|
|
3308
3686
|
})
|
|
3309
3687
|
}
|
|
3310
3688
|
return
|
|
@@ -3365,21 +3743,11 @@ async function dispatch(msg, ctx) {
|
|
|
3365
3743
|
// model/effort: turn/start (非 busy 時) にのみ反映される override。turn/steer
|
|
3366
3744
|
// (busy 時) は公式スキーマ上 model/effort 非対応のため、その場合は
|
|
3367
3745
|
// codex-stream-bridge.mjs 側で無視され次の非 busy な input() から効く。
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
model: msg.model || undefined,
|
|
3374
|
-
effort: msg.effort || undefined,
|
|
3375
|
-
})
|
|
3376
|
-
.catch((err) => {
|
|
3377
|
-
ctx.client.send({
|
|
3378
|
-
type: "codex.error",
|
|
3379
|
-
stream_id: msg.stream_id,
|
|
3380
|
-
error: err.message,
|
|
3381
|
-
})
|
|
3382
|
-
})
|
|
3746
|
+
// S2/S3 統合 (2026-07-05): S3 の自己修復 (turn/steer 失敗 → turn/start 自動
|
|
3747
|
+
// リトライ) が付与する err.reason ('timeout' / 'turn_mismatch_retry_failed') を
|
|
3748
|
+
// classifyCodexInputError が優先採用し、input_id 付きなら codex.input.nack の
|
|
3749
|
+
// reason としてそのまま使う (handleCodexChatInput 内)。
|
|
3750
|
+
handleCodexChatInput(msg, ctx)
|
|
3383
3751
|
return
|
|
3384
3752
|
case "codex.permission.reply":
|
|
3385
3753
|
if (!ctx.codexBridge) return
|