@cocorograph/hub-agent 0.7.37 → 0.7.42
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 +138 -14
- package/src/codex-appserver-client.mjs +47 -3
- package/src/codex-stream-bridge.mjs +121 -7
- package/src/main.mjs +468 -38
- package/src/sibling-inject.mjs +150 -0
- package/src/sibling-token.mjs +62 -0
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
|
|
@@ -31,6 +31,25 @@ import {
|
|
|
31
31
|
createEventMasker,
|
|
32
32
|
maskJsonlFileAtRest,
|
|
33
33
|
} from "./tool-result-mask.mjs"
|
|
34
|
+
import { getOrCreateSiblingToken } from "./sibling-token.mjs"
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* sibling-inject (2026-07-04) 用: spawn する claude プロセス自身の環境変数に、
|
|
38
|
+
* この cwd の capability トークンを乗せる。ディスクには書かず、プロセス環境変数
|
|
39
|
+
* としてのみ渡す (src/sibling-token.mjs のコメント参照)。
|
|
40
|
+
* @param {string} cwd
|
|
41
|
+
* @returns {{COCKPIT_SIBLING_TOKEN: string, COCKPIT_SIBLING_CWD: string}}
|
|
42
|
+
*/
|
|
43
|
+
function siblingEnvFor(cwd) {
|
|
44
|
+
return {
|
|
45
|
+
...process.env,
|
|
46
|
+
COCKPIT_SIBLING_TOKEN: getOrCreateSiblingToken(cwd),
|
|
47
|
+
COCKPIT_SIBLING_CWD: cwd,
|
|
48
|
+
// ask-sibling スキル (cockpit_orch.py) が「自分は claude/codex どちらか」を
|
|
49
|
+
// 環境変数から確実に判定できるようにする (ヒューリスティック不要)。
|
|
50
|
+
COCKPIT_SIBLING_SELF_KIND: "claude",
|
|
51
|
+
}
|
|
52
|
+
}
|
|
34
53
|
|
|
35
54
|
/** browser 切断後、セッションを生かしたまま再接続を待つ idle TTL (ミリ秒)。これを過ぎ、
|
|
36
55
|
* かつ走行中でなければ撤去する。端末スリープ/長期離席後の再接続も吸収できるよう 7 日に
|
|
@@ -272,6 +291,14 @@ class ClaudeStreamSession {
|
|
|
272
291
|
this._residentQuery = null
|
|
273
292
|
/** 常駐 query を起動済みか (二重起動防止)。 */
|
|
274
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
|
|
275
302
|
|
|
276
303
|
/** 改修3: per-message セッションで busy 中に届いた送信を退避する pending キュー。
|
|
277
304
|
* 常駐 query 化 (改修2) とは別レイヤー。resume チェーンは維持したまま、現ターン
|
|
@@ -629,11 +656,16 @@ class ClaudeStreamSession {
|
|
|
629
656
|
* ユーザーメッセージ 1 件を 1 query() として実行する。
|
|
630
657
|
* 既存ターン実行中 (busy) は破棄せず pending キューへ退避し、現ターン完了時に drain する
|
|
631
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 を組み立てる。
|
|
632
664
|
*/
|
|
633
665
|
async sendMessage(message, opts = {}) {
|
|
634
|
-
if (this._closed) return
|
|
666
|
+
if (this._closed) return { accepted: false, reason: "queue_dead" }
|
|
635
667
|
const prompt = extractPromptText(message)
|
|
636
|
-
if (!prompt) return
|
|
668
|
+
if (!prompt) return { accepted: false, reason: "empty_message" }
|
|
637
669
|
|
|
638
670
|
// ultracode ワンショット (0.6.28): このターンのみ xhigh effort + 常時 dynamic-workflow
|
|
639
671
|
// オーケストレーションを有効化する。セッション既定としては持たず (トークン消費が
|
|
@@ -664,7 +696,7 @@ class ClaudeStreamSession {
|
|
|
664
696
|
"resident busy, message queued",
|
|
665
697
|
)
|
|
666
698
|
this._emitQueueState()
|
|
667
|
-
return
|
|
699
|
+
return { accepted: true }
|
|
668
700
|
}
|
|
669
701
|
this._busy = true
|
|
670
702
|
// 改修4 (A): 死亡ガード。常駐 query が未起動 or (エラー等で) 終了済み (_residentQuery=null)
|
|
@@ -678,23 +710,49 @@ class ClaudeStreamSession {
|
|
|
678
710
|
this._startResidentQuery()
|
|
679
711
|
}
|
|
680
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" }
|
|
681
725
|
this._inputQueue.push(toSDKUserMessage(prompt))
|
|
682
|
-
return
|
|
726
|
+
return { accepted: true }
|
|
683
727
|
}
|
|
684
728
|
|
|
685
729
|
// 従来 per-message: 1 メッセージ = 1 query (resume チェーン)。既存セッション (resume) 用。
|
|
686
730
|
// 改修3: busy 中の送信は破棄せず pending キューへ退避し、現ターン完了時に drain する
|
|
687
731
|
// (ターミナル流の「積む→待機→順次実行」)。常駐 query 化はしないので暴走リスクは増えない。
|
|
688
|
-
|
|
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) {
|
|
689
738
|
this._enqueuePending(prompt, ultracode)
|
|
690
739
|
this.logger?.info(
|
|
691
740
|
{ stream_id: this.stream_id, queued: this._pendingMessages.length },
|
|
692
741
|
"claude busy, message queued",
|
|
693
742
|
)
|
|
694
743
|
this._emitQueueState()
|
|
695
|
-
return
|
|
744
|
+
return { accepted: true }
|
|
696
745
|
}
|
|
697
|
-
|
|
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 }
|
|
698
756
|
}
|
|
699
757
|
|
|
700
758
|
/** per-message 1 ターンを実行する (resume チェーン)。busy 中に届いた送信は sendMessage
|
|
@@ -709,6 +767,7 @@ class ClaudeStreamSession {
|
|
|
709
767
|
|
|
710
768
|
const options = {
|
|
711
769
|
cwd: this.cwd,
|
|
770
|
+
env: siblingEnvFor(this.cwd),
|
|
712
771
|
canUseTool: (toolName, input) => this._canUseTool(toolName, input),
|
|
713
772
|
includePartialMessages: true,
|
|
714
773
|
abortController: this._abortController,
|
|
@@ -1017,6 +1076,7 @@ class ClaudeStreamSession {
|
|
|
1017
1076
|
async _runResidentQuery() {
|
|
1018
1077
|
const options = {
|
|
1019
1078
|
cwd: this.cwd,
|
|
1079
|
+
env: siblingEnvFor(this.cwd),
|
|
1020
1080
|
canUseTool: (toolName, input) => this._canUseTool(toolName, input),
|
|
1021
1081
|
includePartialMessages: true,
|
|
1022
1082
|
}
|
|
@@ -1028,6 +1088,10 @@ class ClaudeStreamSession {
|
|
|
1028
1088
|
// 適用済み状態の追跡を false にリセットし、次ターンの reconcile が正しく差分判定できる
|
|
1029
1089
|
// ようにする (異常終了→resume 再起動時にも確実にリセット)。
|
|
1030
1090
|
this._ultracodeCurrent = false
|
|
1091
|
+
// A-2 (2026-07-05): 新しい query を起動するたびに「まだ generator は返却されていない」
|
|
1092
|
+
// 状態へリセットする (前回の finally で true になったままだと、この新しい query が
|
|
1093
|
+
// 生きている間も sendMessage が誤って「返却済み」と判定してしまう)。
|
|
1094
|
+
this._residentQueryReturned = false
|
|
1031
1095
|
// 改修4: 起動時に sessionId (= resumeSessionId) があれば resume チェーンで文脈を引き継ぐ。
|
|
1032
1096
|
// query 起動時点の値のみ有効 (起動後に確定/変化する session_id は同一 query 内で継続される)。
|
|
1033
1097
|
if (this.sessionId) options.resume = this.sessionId
|
|
@@ -1085,6 +1149,11 @@ class ClaudeStreamSession {
|
|
|
1085
1149
|
}
|
|
1086
1150
|
}
|
|
1087
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
|
|
1088
1157
|
this._residentQuery = null
|
|
1089
1158
|
this._busy = false
|
|
1090
1159
|
denyPending("closed")
|
|
@@ -1461,23 +1530,34 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1461
1530
|
return { stream_id, resuming: !!resumeSessionId }
|
|
1462
1531
|
}
|
|
1463
1532
|
|
|
1464
|
-
/**
|
|
1465
|
-
|
|
1533
|
+
/**
|
|
1534
|
+
* browser → claude の user メッセージ。1 件 = 1 query (resume チェーン)。
|
|
1535
|
+
*
|
|
1536
|
+
* 配達保証 (S2, 2026-07-05): 戻り値は `{ ok: boolean, reason?: string }`
|
|
1537
|
+
* (旧: boolean のみ)。呼び出し元 (main.mjs) はこれを見て `claude.input.ack` /
|
|
1538
|
+
* `claude.input.nack` を browser へ送出する。`ok:true` は「ターン完走」ではなく
|
|
1539
|
+
* 「キュー/常駐 query への投入が完了した」ことを意味する (ターン完走まで待つと
|
|
1540
|
+
* ack が大幅に遅延するため)。ターン実行自体の結果は従来どおり onEvent 経由で届く。
|
|
1541
|
+
*/
|
|
1542
|
+
async input({ stream_id, message, ultracode }) {
|
|
1466
1543
|
const s = this.sessions.get(stream_id)
|
|
1467
1544
|
if (!s) {
|
|
1468
1545
|
this.logger?.warn({ stream_id }, "claude.input but stream missing")
|
|
1469
|
-
return false
|
|
1546
|
+
return { ok: false, reason: "stream_missing" }
|
|
1470
1547
|
}
|
|
1471
1548
|
s.touch(stream_id) // B11: この端末の最終アクティビティを更新 (死端末 GC 用)
|
|
1472
|
-
// 非同期でターン実行 (完了は result イベント + onEvent 経由で browser に届く)
|
|
1473
1549
|
// ultracode (0.6.28): このメッセージのみ ultracode ワンショットを適用するフラグ。
|
|
1474
|
-
|
|
1550
|
+
try {
|
|
1551
|
+
const result = await s.sendMessage(message, { ultracode: ultracode === true })
|
|
1552
|
+
if (result?.accepted) return { ok: true }
|
|
1553
|
+
return { ok: false, reason: result?.reason || "queue_dead" }
|
|
1554
|
+
} catch (err) {
|
|
1475
1555
|
this.logger?.error(
|
|
1476
1556
|
{ stream_id, err: err?.message },
|
|
1477
1557
|
"claude sendMessage threw unexpectedly",
|
|
1478
1558
|
)
|
|
1479
|
-
|
|
1480
|
-
|
|
1559
|
+
return { ok: false, reason: "error" }
|
|
1560
|
+
}
|
|
1481
1561
|
}
|
|
1482
1562
|
|
|
1483
1563
|
/** browser → claude の permission 応答。allow/deny + 加工された input を SDK に返す。 */
|
|
@@ -1678,6 +1758,50 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1678
1758
|
return best
|
|
1679
1759
|
}
|
|
1680
1760
|
|
|
1761
|
+
/**
|
|
1762
|
+
* 指定 cwd でライブなセッションへメッセージを直接注入する (sibling-inject 用、
|
|
1763
|
+
* 2026-07-04)。同一マシン内の別プロセス (ask-sibling スキル経由の cockpit_orch.py)
|
|
1764
|
+
* が、Cockpit ブラウザの WS 接続を経由せず、hub-agent デーモンへローカル IPC
|
|
1765
|
+
* (src/sibling-inject.mjs) 経由でこの cwd の生きているチャットセッションへ新規
|
|
1766
|
+
* ターンを開始させる。liveSessionIdForCwd と同じ走査ロジックだが、返すのは
|
|
1767
|
+
* sessionId 文字列ではなく session オブジェクト自体 (sendMessage を直接呼べるように
|
|
1768
|
+
* するため)。同一 cwd に複数ライブセッションがある場合は最終アクティビティが最も
|
|
1769
|
+
* 新しいものを採用する。
|
|
1770
|
+
*
|
|
1771
|
+
* ⚠️ このメソッド自体は認証を行わない (呼び出し元の sibling-inject.mjs が
|
|
1772
|
+
* src/sibling-token.mjs の capability トークンで入力元を検証する前提)。
|
|
1773
|
+
*
|
|
1774
|
+
* @param {string} cwd
|
|
1775
|
+
* @param {string} text
|
|
1776
|
+
* @returns {boolean} 送信できたか (対象セッションが見つからなければ false)
|
|
1777
|
+
*/
|
|
1778
|
+
sendToCwd(cwd, text) {
|
|
1779
|
+
if (!cwd || !text) return false
|
|
1780
|
+
let best = null
|
|
1781
|
+
let bestAt = -1
|
|
1782
|
+
const seen = new Set()
|
|
1783
|
+
for (const session of this.sessions.values()) {
|
|
1784
|
+
if (seen.has(session) || session.cwd !== cwd) continue
|
|
1785
|
+
seen.add(session)
|
|
1786
|
+
let at = 0
|
|
1787
|
+
for (const t of session.lastActivityByStream.values()) {
|
|
1788
|
+
if (t > at) at = t
|
|
1789
|
+
}
|
|
1790
|
+
if (at >= bestAt) {
|
|
1791
|
+
bestAt = at
|
|
1792
|
+
best = session
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
if (!best) return false
|
|
1796
|
+
best.sendMessage({ role: "user", content: text }).catch((err) => {
|
|
1797
|
+
this.logger?.error(
|
|
1798
|
+
{ cwd, err: err?.message },
|
|
1799
|
+
"sendToCwd: sendMessage threw unexpectedly",
|
|
1800
|
+
)
|
|
1801
|
+
})
|
|
1802
|
+
return true
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1681
1805
|
/**
|
|
1682
1806
|
* `mcpServerStatus()` の結果に、needs-auth キャッシュにあるが結果へ含まれない
|
|
1683
1807
|
* サーバーを `needs-auth` として補完する。
|
|
@@ -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
|
|
|
@@ -34,6 +34,26 @@
|
|
|
34
34
|
import { EventEmitter } from "node:events"
|
|
35
35
|
|
|
36
36
|
import { CodexAppServerClient } from "./codex-appserver-client.mjs"
|
|
37
|
+
import { getOrCreateSiblingToken } from "./sibling-token.mjs"
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* sibling-inject (2026-07-04) 用: spawn する codex app-server プロセス自身の
|
|
41
|
+
* 環境変数に、この cwd の capability トークンを乗せる。ディスクには書かず、
|
|
42
|
+
* プロセス環境変数としてのみ渡す (src/sibling-token.mjs のコメント参照、
|
|
43
|
+
* claude-stream-bridge.mjs の同名ヘルパーと対)。
|
|
44
|
+
* @param {string} cwd
|
|
45
|
+
* @returns {NodeJS.ProcessEnv}
|
|
46
|
+
*/
|
|
47
|
+
function siblingEnvFor(cwd) {
|
|
48
|
+
return {
|
|
49
|
+
...process.env,
|
|
50
|
+
COCKPIT_SIBLING_TOKEN: getOrCreateSiblingToken(cwd),
|
|
51
|
+
COCKPIT_SIBLING_CWD: cwd,
|
|
52
|
+
// ask-sibling スキル (cockpit_orch.py) が「自分は claude/codex どちらか」を
|
|
53
|
+
// 環境変数から確実に判定できるようにする (ヒューリスティック不要)。
|
|
54
|
+
COCKPIT_SIBLING_SELF_KIND: "codex",
|
|
55
|
+
}
|
|
56
|
+
}
|
|
37
57
|
|
|
38
58
|
// detach 後、非 busy なセッションを生かしておく猶予。claude 側 (7 日) と違い短くする:
|
|
39
59
|
// Slice 2 は in-memory reattach 非対応のため、browser の WS 再接続ごとに新 stream_id で
|
|
@@ -57,6 +77,37 @@ function flattenThreadItems(thread) {
|
|
|
57
77
|
return items
|
|
58
78
|
}
|
|
59
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
|
+
|
|
60
111
|
function userInputFromMessage(message) {
|
|
61
112
|
const content = message?.content
|
|
62
113
|
if (typeof content === "string") {
|
|
@@ -89,7 +140,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
89
140
|
if (this.sessions.has(stream_id)) {
|
|
90
141
|
throw new Error(`stream_id already attached: ${stream_id}`)
|
|
91
142
|
}
|
|
92
|
-
const client = this._clientFactory({
|
|
143
|
+
const client = this._clientFactory({
|
|
144
|
+
cwd,
|
|
145
|
+
codexCmd: this.codexCmd,
|
|
146
|
+
logger: this.logger,
|
|
147
|
+
env: siblingEnvFor(cwd),
|
|
148
|
+
})
|
|
93
149
|
const session = {
|
|
94
150
|
stream_id,
|
|
95
151
|
cwd,
|
|
@@ -255,12 +311,41 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
255
311
|
const session = this._require(stream_id)
|
|
256
312
|
const input = userInputFromMessage(message)
|
|
257
313
|
if (session.activeTurnId) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
+
}
|
|
264
349
|
}
|
|
265
350
|
const params = { threadId: session.threadId, input }
|
|
266
351
|
if (model) params.model = model
|
|
@@ -317,6 +402,35 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
317
402
|
return null
|
|
318
403
|
}
|
|
319
404
|
|
|
405
|
+
/**
|
|
406
|
+
* 指定 cwd でライブなセッションへメッセージを直接注入する (sibling-inject 用、
|
|
407
|
+
* 2026-07-04)。Claude 版 `sendToCwd` の対。同一 cwd に複数ライブセッションがある
|
|
408
|
+
* 場合は `threadIdForCwd` と同じく先に見つかったものを使う。
|
|
409
|
+
*
|
|
410
|
+
* ⚠️ このメソッド自体は認証を行わない (呼び出し元の sibling-inject.mjs が
|
|
411
|
+
* src/sibling-token.mjs の capability トークンで入力元を検証する前提)。
|
|
412
|
+
*
|
|
413
|
+
* @param {string} cwd
|
|
414
|
+
* @param {string} text
|
|
415
|
+
* @returns {boolean} 送信できたか (対象セッションが見つからなければ false)
|
|
416
|
+
*/
|
|
417
|
+
sendToCwd(cwd, text) {
|
|
418
|
+
if (!cwd || !text) return false
|
|
419
|
+
for (const session of this.sessions.values()) {
|
|
420
|
+
if (session.cwd !== cwd) continue
|
|
421
|
+
this.input({ stream_id: session.stream_id, message: { role: "user", content: text } }).catch(
|
|
422
|
+
(err) => {
|
|
423
|
+
this.logger?.error(
|
|
424
|
+
{ cwd, err: err?.message },
|
|
425
|
+
"sendToCwd: input threw unexpectedly",
|
|
426
|
+
)
|
|
427
|
+
},
|
|
428
|
+
)
|
|
429
|
+
return true
|
|
430
|
+
}
|
|
431
|
+
return false
|
|
432
|
+
}
|
|
433
|
+
|
|
320
434
|
_maybeReapAfterIdle(session) {
|
|
321
435
|
if (session.activeTurnId) return // 完走まで待つ (turn/completed で再評価される)
|
|
322
436
|
this._clearDetachTimer(session)
|