@cocorograph/hub-agent 0.7.97 → 0.7.99
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/hub-bundle.mjs +159 -26
- package/src/main.mjs +82 -5
- package/src/ws-client.mjs +97 -14
package/package.json
CHANGED
package/src/hub-bundle.mjs
CHANGED
|
@@ -471,7 +471,7 @@ export async function applyMcpServers(
|
|
|
471
471
|
*/
|
|
472
472
|
export async function applyCodexMcpServers(
|
|
473
473
|
bundle,
|
|
474
|
-
{ logger, nonInteractive = false, resolveBinImpl, runImpl } = {},
|
|
474
|
+
{ agentId, agentToken, logger, nonInteractive = false, resolveBinImpl, runImpl } = {},
|
|
475
475
|
) {
|
|
476
476
|
const servers = Array.isArray(bundle?.mcp_servers) ? bundle.mcp_servers : []
|
|
477
477
|
if (servers.length === 0) {
|
|
@@ -512,25 +512,34 @@ export async function applyCodexMcpServers(
|
|
|
512
512
|
}
|
|
513
513
|
}
|
|
514
514
|
|
|
515
|
-
// MCP
|
|
515
|
+
// MCP 承認モードと Hub のホスト資格ヘッダを `~/.codex/config.toml` に反映する。
|
|
516
516
|
// ⚠️ `codex mcp add` には承認モードの永続設定オプションが無いため、Bundle が宣言した
|
|
517
517
|
// `default_tools_approval_mode` を config.toml の `[mcp_servers.<name>]` テーブルへ直接
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
518
|
+
// マージする。また Codex CLI 0.145.0 時点では `mcp add --header` が無いため、Claude と同じ
|
|
519
|
+
// `X-Cockpit-Agent` を公式設定 `http_headers` へ直接マージする。ヘッダは `hub` / `hub_staff`
|
|
520
|
+
// の両方だけに付け、外部 MCP には付けない。
|
|
521
|
+
//
|
|
522
|
+
// 対話 add の成否に関わらず全 target に対して実行する(add が「already exists」で失敗しても
|
|
523
|
+
// 設定は更新する要件)。非対話モードでは onlyExisting=true で「既存テーブルの補完」だけを行い、
|
|
524
|
+
// 未登録 server には新規テーブルを作らず pending 報告する。I/O・ネットワーク失敗(transient)は
|
|
525
|
+
// approvalError として上位(syncBundle)へ返し、state に「再試行すべき失敗」として記録させる。
|
|
526
|
+
// config 破壊は _atomicWriteFilePreserving が防ぐ。
|
|
523
527
|
let approvalUpdated = []
|
|
524
528
|
let approvalPending = []
|
|
525
529
|
let approvalSkipped = []
|
|
526
530
|
let approvalError = null
|
|
527
531
|
try {
|
|
528
|
-
const res = await applyCodexMcpApprovalModes(servers, {
|
|
532
|
+
const res = await applyCodexMcpApprovalModes(servers, {
|
|
533
|
+
agentId,
|
|
534
|
+
agentToken,
|
|
535
|
+
logger,
|
|
536
|
+
onlyExisting: nonInteractive,
|
|
537
|
+
})
|
|
529
538
|
approvalUpdated = res.updated
|
|
530
539
|
approvalPending = res.pending || []
|
|
531
540
|
approvalSkipped = res.skipped || []
|
|
532
541
|
} catch (err) {
|
|
533
|
-
logger?.warn({ err: err.message }, "codex MCP
|
|
542
|
+
logger?.warn({ err: err.message }, "codex MCP 設定の config.toml 反映に失敗")
|
|
534
543
|
approvalError = err?.message || String(err)
|
|
535
544
|
}
|
|
536
545
|
return { added, skipped, approvalUpdated, approvalPending, approvalSkipped, approvalError }
|
|
@@ -554,7 +563,8 @@ function resolveCodexConfigPath() {
|
|
|
554
563
|
|
|
555
564
|
/**
|
|
556
565
|
* config.toml テキストへ、`[mcp_servers.<name>]` テーブルの `url` /
|
|
557
|
-
* `
|
|
566
|
+
* `http_headers.X-Cockpit-Agent` / `default_tools_approval_mode` を**テーブル単位で
|
|
567
|
+
* サージカルに** upsert して返す(純関数)。
|
|
558
568
|
*
|
|
559
569
|
* 設計(config 全体を再生成しない・既存を壊さない):
|
|
560
570
|
* - 対象テーブルの行範囲(ヘッダ行の次〜次の `[...]` テーブルヘッダ直前)だけを編集する。
|
|
@@ -567,18 +577,24 @@ function resolveCodexConfigPath() {
|
|
|
567
577
|
* - 不正な approvalMode は無視(url のみ・または no-op)。改行コード(LF/CRLF)は維持する。
|
|
568
578
|
*
|
|
569
579
|
* @param {string} configText
|
|
570
|
-
* @param {{name: string, url?: string, approvalMode?: string, onlyExisting?: boolean}} opts
|
|
580
|
+
* @param {{name: string, url?: string, approvalMode?: string, hostCredential?: string, onlyExisting?: boolean}} opts
|
|
571
581
|
* onlyExisting=true のとき、標準テーブルが**まだ無い** server には新規テーブルを追記しない
|
|
572
582
|
* (config を無変更で返す)。自動同期(非対話)で使う: 未登録 MCP に承認モードだけ書くと
|
|
573
583
|
* OAuth 未認証の「半登録テーブル」を作ってしまうため、登録は対話経路(`codex mcp add`)に
|
|
574
584
|
* 限定し、自動同期は既存テーブルの承認モード補完だけを行う。
|
|
575
585
|
* @returns {string}
|
|
576
586
|
*/
|
|
577
|
-
export function upsertCodexMcpApprovalMode(
|
|
587
|
+
export function upsertCodexMcpApprovalMode(
|
|
588
|
+
configText,
|
|
589
|
+
{ name, url, approvalMode, hostCredential, onlyExisting = false } = {},
|
|
590
|
+
) {
|
|
578
591
|
if (!name) return configText
|
|
579
592
|
const mode = _CODEX_MCP_APPROVAL_MODES.has(approvalMode) ? approvalMode : null
|
|
580
593
|
const kv = []
|
|
581
594
|
if (typeof url === "string" && url) kv.push(["url", JSON.stringify(url)])
|
|
595
|
+
if (typeof hostCredential === "string" && hostCredential) {
|
|
596
|
+
kv.push(["http_headers", `{ "X-Cockpit-Agent" = ${JSON.stringify(hostCredential)} }`])
|
|
597
|
+
}
|
|
582
598
|
if (mode) kv.push(["default_tools_approval_mode", JSON.stringify(mode)])
|
|
583
599
|
if (kv.length === 0) return configText
|
|
584
600
|
|
|
@@ -634,8 +650,22 @@ export function upsertCodexMcpApprovalMode(configText, { name, url, approvalMode
|
|
|
634
650
|
}
|
|
635
651
|
}
|
|
636
652
|
if (found >= 0) {
|
|
637
|
-
|
|
638
|
-
|
|
653
|
+
if (key === "http_headers") {
|
|
654
|
+
// `http_headers` はユーザーが別のヘッダを持ち得る。行全体を置換せず、管理対象の
|
|
655
|
+
// X-Cockpit-Agent だけを inline table へマージする。安全に解釈できない形式なら
|
|
656
|
+
// config 全体を無変更で返し、呼び出し側の検証で skipped 扱いにする。
|
|
657
|
+
const merged = _upsertTomlInlineStringMapValue(
|
|
658
|
+
lines[found],
|
|
659
|
+
"http_headers",
|
|
660
|
+
"X-Cockpit-Agent",
|
|
661
|
+
JSON.stringify(hostCredential),
|
|
662
|
+
)
|
|
663
|
+
if (merged === null) return configText
|
|
664
|
+
lines[found] = merged
|
|
665
|
+
} else {
|
|
666
|
+
// 値だけ差し替え、インデント・キー左辺・**行末インラインコメント**を保持する。
|
|
667
|
+
lines[found] = _replaceTomlStringValue(lines[found], key, valStr)
|
|
668
|
+
}
|
|
639
669
|
if (key === "url") urlLineIdx = found
|
|
640
670
|
} else {
|
|
641
671
|
toInsert.push(`${key} = ${valStr}`)
|
|
@@ -651,8 +681,8 @@ export function upsertCodexMcpApprovalMode(configText, { name, url, approvalMode
|
|
|
651
681
|
}
|
|
652
682
|
|
|
653
683
|
/**
|
|
654
|
-
* Bundle の mcp_servers
|
|
655
|
-
*
|
|
684
|
+
* Bundle の mcp_servers のうち承認モードを宣言したもの、またはホスト資格を付ける Hub MCP
|
|
685
|
+
* について、`~/.codex/config.toml` の該当テーブルへ承認モード・url・http_headers を安全マージする。
|
|
656
686
|
*
|
|
657
687
|
* - 承認モードが許可値でない server はスキップ(不正値を端末設定へ流さない)。
|
|
658
688
|
* - 変更が無ければ書き込まない(完全冪等)。
|
|
@@ -660,7 +690,7 @@ export function upsertCodexMcpApprovalMode(configText, { name, url, approvalMode
|
|
|
660
690
|
* 残さない)。rename が失敗した場合は破壊的フォールバックを行わず中止し、既存 config を無変更で維持する。
|
|
661
691
|
*
|
|
662
692
|
* @param {Array<{name?: string, url?: string, default_tools_approval_mode?: string}>} servers
|
|
663
|
-
* @param {{configPath?: string, logger?: any, fsImpl?: any, isWindows?: boolean, onlyExisting?: boolean}} [opts]
|
|
693
|
+
* @param {{configPath?: string, logger?: any, fsImpl?: any, isWindows?: boolean, onlyExisting?: boolean, agentId?: string, agentToken?: string}} [opts]
|
|
664
694
|
* configPath 省略時は resolveCodexConfigPath()。fsImpl / isWindows はテスト用の注入点(既定は実 fs / 実 OS)。
|
|
665
695
|
* onlyExisting=true(自動同期・非対話)のとき、標準テーブルが**まだ無い** server には新規
|
|
666
696
|
* テーブルを追記せず `pending` として報告する(未登録 MCP に承認モードだけ書いて OAuth 未認証の
|
|
@@ -670,10 +700,22 @@ export function upsertCodexMcpApprovalMode(configText, { name, url, approvalMode
|
|
|
670
700
|
*/
|
|
671
701
|
export async function applyCodexMcpApprovalModes(
|
|
672
702
|
servers,
|
|
673
|
-
{
|
|
703
|
+
{
|
|
704
|
+
configPath,
|
|
705
|
+
logger,
|
|
706
|
+
fsImpl = fs,
|
|
707
|
+
isWindows = process.platform === "win32",
|
|
708
|
+
onlyExisting = false,
|
|
709
|
+
agentId,
|
|
710
|
+
agentToken,
|
|
711
|
+
} = {},
|
|
674
712
|
) {
|
|
713
|
+
const hostCredential = agentId && agentToken ? `${agentId}:${agentToken}` : null
|
|
675
714
|
const targets = (Array.isArray(servers) ? servers : []).filter(
|
|
676
|
-
(s) =>
|
|
715
|
+
(s) =>
|
|
716
|
+
s?.name &&
|
|
717
|
+
(_CODEX_MCP_APPROVAL_MODES.has(s?.default_tools_approval_mode) ||
|
|
718
|
+
(hostCredential && HUB_MCP_NAMES.has(s.name))),
|
|
677
719
|
)
|
|
678
720
|
if (targets.length === 0) return { updated: [], skipped: [], pending: [] }
|
|
679
721
|
|
|
@@ -696,6 +738,7 @@ export async function applyCodexMcpApprovalModes(
|
|
|
696
738
|
const skipped = []
|
|
697
739
|
const pending = []
|
|
698
740
|
for (const s of targets) {
|
|
741
|
+
const serverHostCredential = hostCredential && HUB_MCP_NAMES.has(s.name) ? hostCredential : null
|
|
699
742
|
// 自動同期(onlyExisting)で標準テーブルが無い server は、新規テーブルを作らない。
|
|
700
743
|
// 非標準形(空白入り/ドット記法/インライン)で既に定義されていれば「安全に編集できない」
|
|
701
744
|
// → skipped、真に未登録なら「登録が必要」→ pending として区別する(ブラウザは開かない)。
|
|
@@ -719,12 +762,13 @@ export async function applyCodexMcpApprovalModes(
|
|
|
719
762
|
name: s.name,
|
|
720
763
|
url: s.url,
|
|
721
764
|
approvalMode: s.default_tools_approval_mode,
|
|
765
|
+
hostCredential: serverHostCredential,
|
|
722
766
|
onlyExisting,
|
|
723
767
|
})
|
|
724
768
|
if (candidate === next) {
|
|
725
769
|
// 変化なし。原因が「既に正しく反映済み(= no-op)」か「非標準形で upsert が編集を拒否した」
|
|
726
770
|
// かを検証で区別する。標準テーブルが健全なら no-op、そうでなければ適用不能としてスキップ報告。
|
|
727
|
-
if (!_verifyCodexServerTable(next, s.name, s.default_tools_approval_mode)) {
|
|
771
|
+
if (!_verifyCodexServerTable(next, s.name, s.default_tools_approval_mode, serverHostCredential)) {
|
|
728
772
|
logger?.warn(
|
|
729
773
|
{ name: s.name },
|
|
730
774
|
"codex config.toml の該当テーブルを安全に編集できないためスキップ(既存 config は不変)",
|
|
@@ -737,7 +781,7 @@ export async function applyCodexMcpApprovalModes(
|
|
|
737
781
|
// 期待値でちょうど 1 つ・url は高々 1 つ・他形式(空白入り/インライン)の重複定義が無い」
|
|
738
782
|
// ことを検証する。素の文字列編集が非標準 config で誤動作しても、ここで弾いて **既存 config を
|
|
739
783
|
// 一切書き換えない**(worst case = 承認設定が付かない=現状維持)。
|
|
740
|
-
if (!_verifyCodexServerTable(candidate, s.name, s.default_tools_approval_mode)) {
|
|
784
|
+
if (!_verifyCodexServerTable(candidate, s.name, s.default_tools_approval_mode, serverHostCredential)) {
|
|
741
785
|
logger?.warn(
|
|
742
786
|
{ name: s.name },
|
|
743
787
|
"codex config.toml の該当テーブルを安全に編集できないためスキップ(既存 config は不変)",
|
|
@@ -752,7 +796,7 @@ export async function applyCodexMcpApprovalModes(
|
|
|
752
796
|
|
|
753
797
|
// ⚠️ 失敗しても既存 config.toml を必ず維持する fail-safe な atomic write。
|
|
754
798
|
await _atomicWriteFilePreserving(cfgPath, next, fileMode, { fsImpl, isWindows, logger })
|
|
755
|
-
logger?.info({ updated, skipped, pending }, "codex MCP
|
|
799
|
+
logger?.info({ updated, skipped, pending }, "codex MCP 設定を config.toml へ反映")
|
|
756
800
|
return { updated, skipped, pending }
|
|
757
801
|
}
|
|
758
802
|
|
|
@@ -862,6 +906,81 @@ function _replaceTomlStringValue(line, key, quotedVal) {
|
|
|
862
906
|
return `${indent}${key} = ${quotedVal}`
|
|
863
907
|
}
|
|
864
908
|
|
|
909
|
+
/**
|
|
910
|
+
* TOML inline table の string entry を 1 件だけ upsert する。
|
|
911
|
+
*
|
|
912
|
+
* 例: `http_headers = { "Authorization" = "..." } # keep` に
|
|
913
|
+
* `"X-Cockpit-Agent" = "ag:token"` を追加する。既存 entry・行末コメント・他ヘッダは保持する。
|
|
914
|
+
* inline table として安全に読めない場合は null(呼び出し側は config を変更しない)。
|
|
915
|
+
*/
|
|
916
|
+
function _upsertTomlInlineStringMapValue(line, key, entryKey, quotedVal) {
|
|
917
|
+
const keyRe = new RegExp(`^(\\s*${_escapeRegExp(key)}\\s*=\\s*)\\{`)
|
|
918
|
+
const prefixMatch = line.match(keyRe)
|
|
919
|
+
if (!prefixMatch) return null
|
|
920
|
+
|
|
921
|
+
// `#` は TOML 文字列の外側だけコメント開始。コメント内の `}` を inline table の終端と
|
|
922
|
+
// 誤認しないよう、quote / escape を追跡してコード部分を切り出す。
|
|
923
|
+
let inDouble = false
|
|
924
|
+
let inSingle = false
|
|
925
|
+
let escaped = false
|
|
926
|
+
let commentIdx = line.length
|
|
927
|
+
for (let i = prefixMatch[0].length; i < line.length; i++) {
|
|
928
|
+
const ch = line[i]
|
|
929
|
+
if (escaped) {
|
|
930
|
+
escaped = false
|
|
931
|
+
continue
|
|
932
|
+
}
|
|
933
|
+
if (inDouble && ch === "\\") {
|
|
934
|
+
escaped = true
|
|
935
|
+
continue
|
|
936
|
+
}
|
|
937
|
+
if (!inSingle && ch === '"') inDouble = !inDouble
|
|
938
|
+
else if (!inDouble && ch === "'") inSingle = !inSingle
|
|
939
|
+
else if (!inDouble && !inSingle && ch === "#") {
|
|
940
|
+
commentIdx = i
|
|
941
|
+
break
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
if (inDouble || inSingle || escaped) return null
|
|
945
|
+
|
|
946
|
+
const code = line.slice(0, commentIdx)
|
|
947
|
+
const openIdx = prefixMatch[0].lastIndexOf("{")
|
|
948
|
+
const closeIdx = code.lastIndexOf("}")
|
|
949
|
+
if (closeIdx <= openIdx || code.slice(closeIdx + 1).trim() !== "") return null
|
|
950
|
+
|
|
951
|
+
const body = code.slice(openIdx + 1, closeIdx)
|
|
952
|
+
const entryRe = new RegExp(
|
|
953
|
+
`((?:"${_escapeRegExp(entryKey)}"|${_escapeRegExp(entryKey)})\\s*=\\s*)"(?:[^"\\\\]|\\\\.)*"`,
|
|
954
|
+
)
|
|
955
|
+
let nextBody
|
|
956
|
+
if (entryRe.test(body)) {
|
|
957
|
+
nextBody = body.replace(entryRe, (_whole, lhs) => `${lhs}${quotedVal}`)
|
|
958
|
+
} else {
|
|
959
|
+
const trailing = body.match(/\\s*$/)?.[0] || ""
|
|
960
|
+
const content = body.slice(0, body.length - trailing.length)
|
|
961
|
+
const entry = `${JSON.stringify(entryKey)} = ${quotedVal}`
|
|
962
|
+
nextBody = content.trim() ? `${content}, ${entry}${trailing}` : ` ${entry} `
|
|
963
|
+
}
|
|
964
|
+
return `${line.slice(0, openIdx + 1)}${nextBody}${line.slice(closeIdx)}`
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** 対象 inline table 内の string entry の raw quoted value と出現数を返す。 */
|
|
968
|
+
function _readTomlInlineStringMapValue(line, key, entryKey) {
|
|
969
|
+
const keyRe = new RegExp(`^\\s*${_escapeRegExp(key)}\\s*=\\s*\\{(.*)\\}\\s*(?:#.*)?$`)
|
|
970
|
+
const m = line.match(keyRe)
|
|
971
|
+
if (!m) return { count: 0, value: null, valid: false }
|
|
972
|
+
const entryRe = new RegExp(
|
|
973
|
+
`(?:"${_escapeRegExp(entryKey)}"|${_escapeRegExp(entryKey)})\\s*=\\s*("(?:[^"\\\\]|\\\\.)*")`,
|
|
974
|
+
"g",
|
|
975
|
+
)
|
|
976
|
+
const matches = [...m[1].matchAll(entryRe)]
|
|
977
|
+
return {
|
|
978
|
+
count: matches.length,
|
|
979
|
+
value: matches.length === 1 ? matches[0][1] : null,
|
|
980
|
+
valid: true,
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
865
984
|
/**
|
|
866
985
|
* マージ結果の config.toml テキストで、`name` の MCP サーバーテーブルが**安全に**編集された
|
|
867
986
|
* 状態か検証する(書き込み前のフェイルセーフ)。以下を全て満たすときのみ true:
|
|
@@ -872,7 +991,7 @@ function _replaceTomlStringValue(line, key, quotedVal) {
|
|
|
872
991
|
* - `url` は高々 1 つ(重複していない)。
|
|
873
992
|
* いずれか外れたら false(=呼び出し側は書き込まず既存 config を保持する)。
|
|
874
993
|
*/
|
|
875
|
-
function _verifyCodexServerTable(text, name, mode) {
|
|
994
|
+
function _verifyCodexServerTable(text, name, mode, hostCredential = null) {
|
|
876
995
|
const n = _escapeRegExp(name)
|
|
877
996
|
const lines = text.split(/\r?\n/)
|
|
878
997
|
const bracketRe = new RegExp(`^\\s*\\[\\s*mcp_servers\\s*\\.\\s*"?${n}"?\\s*\\]\\s*$`)
|
|
@@ -904,8 +1023,10 @@ function _verifyCodexServerTable(text, name, mode) {
|
|
|
904
1023
|
}
|
|
905
1024
|
let modeCount = 0
|
|
906
1025
|
let urlCount = 0
|
|
1026
|
+
let httpHeadersCount = 0
|
|
907
1027
|
let modeOk = false
|
|
908
|
-
const expectedVal = JSON.stringify(mode) // 例: "approve"
|
|
1028
|
+
const expectedVal = _CODEX_MCP_APPROVAL_MODES.has(mode) ? JSON.stringify(mode) : null // 例: "approve"
|
|
1029
|
+
let hostHeaderOk = !hostCredential
|
|
909
1030
|
// 値(クォート文字列)だけを解析し、行末インラインコメント(` # ...`)は許容する。
|
|
910
1031
|
// 行全体の厳密一致にすると `default_tools_approval_mode = "approve" # keep` を弾いてしまい、
|
|
911
1032
|
// upsert が正しく更新しても書き込みがスキップされて古い値が残る(2026-07-15 レビュー指摘)。
|
|
@@ -917,8 +1038,20 @@ function _verifyCodexServerTable(text, name, mode) {
|
|
|
917
1038
|
if (m && m[1] === expectedVal) modeOk = true
|
|
918
1039
|
}
|
|
919
1040
|
if (/^\s*url\s*=/.test(lines[i])) urlCount++
|
|
1041
|
+
if (/^\s*http_headers\s*=/.test(lines[i])) {
|
|
1042
|
+
httpHeadersCount++
|
|
1043
|
+
if (hostCredential) {
|
|
1044
|
+
const parsed = _readTomlInlineStringMapValue(lines[i], "http_headers", "X-Cockpit-Agent")
|
|
1045
|
+
hostHeaderOk =
|
|
1046
|
+
parsed.valid && parsed.count === 1 && parsed.value === JSON.stringify(hostCredential)
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
920
1049
|
}
|
|
921
|
-
|
|
1050
|
+
const modeValid = expectedVal ? modeCount === 1 && modeOk : modeCount <= 1
|
|
1051
|
+
const headersValid = hostCredential
|
|
1052
|
+
? httpHeadersCount === 1 && hostHeaderOk
|
|
1053
|
+
: httpHeadersCount <= 1
|
|
1054
|
+
return modeValid && headersValid && urlCount <= 1
|
|
922
1055
|
}
|
|
923
1056
|
|
|
924
1057
|
/**
|
|
@@ -1384,7 +1517,7 @@ async function _syncBundleLocked({ hubUrl, agentId, agentToken, logger, fetchImp
|
|
|
1384
1517
|
let mcp = { added: [], skipped: [] }
|
|
1385
1518
|
try {
|
|
1386
1519
|
mcp = cliKind === "codex"
|
|
1387
|
-
? await applyCodexMcpServers(bundle, { logger, nonInteractive })
|
|
1520
|
+
? await applyCodexMcpServers(bundle, { agentId, agentToken, logger, nonInteractive })
|
|
1388
1521
|
: await applyMcpServers(bundle, { agentId, agentToken, logger, nonInteractive })
|
|
1389
1522
|
} catch (err) {
|
|
1390
1523
|
logger?.warn({ cliKind, err: err.message }, "applyMcpServers failed (continuing)")
|
package/src/main.mjs
CHANGED
|
@@ -2861,9 +2861,12 @@ const STREAMS_SYNC_GRACE_MS = 45_000
|
|
|
2861
2861
|
* Plan ε: `agent.streams.sync.response` を受けて backend ↔ agent の現存
|
|
2862
2862
|
* stream_id を能動同期する。
|
|
2863
2863
|
*
|
|
2864
|
-
* - local にあって backend にない → ptyBridge.detach
|
|
2865
|
-
* -
|
|
2866
|
-
*
|
|
2864
|
+
* - PTY: local にあって backend にない → ptyBridge.detach、逆差分 → pty.error
|
|
2865
|
+
* - Claude/Codex: mode 別 snapshot から外れた古い subscriber を grace 付きで掃除
|
|
2866
|
+
*
|
|
2867
|
+
* active_stream_ids だけを全件 PTY とみなすと、正常な Claude/Codex に
|
|
2868
|
+
* pty.error(stream_not_found_on_agent) を返して再 attach storm を誘発するため、
|
|
2869
|
+
* 新 backend の active_streams を優先する。
|
|
2867
2870
|
*
|
|
2868
2871
|
* dispatch から呼ぶ用のヘルパーだが、テストから直接叩けるよう export している。
|
|
2869
2872
|
*/
|
|
@@ -2871,6 +2874,32 @@ export function handleStreamsSyncResponse(msg, ctx) {
|
|
|
2871
2874
|
const activeIds = new Set(
|
|
2872
2875
|
Array.isArray(msg?.active_stream_ids) ? msg.active_stream_ids : [],
|
|
2873
2876
|
)
|
|
2877
|
+
const activeByMode = { pty: new Set(), claude: new Set(), codex: new Set() }
|
|
2878
|
+
const classifiedIds = new Set()
|
|
2879
|
+
if (Array.isArray(msg?.active_streams)) {
|
|
2880
|
+
for (const item of msg.active_streams) {
|
|
2881
|
+
const streamId = item?.stream_id
|
|
2882
|
+
const mode = item?.mode
|
|
2883
|
+
const modeSet = activeByMode[mode]
|
|
2884
|
+
if (typeof streamId !== "string" || !streamId || !(modeSet instanceof Set)) continue
|
|
2885
|
+
modeSet.add(streamId)
|
|
2886
|
+
classifiedIds.add(streamId)
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
// Blue-Green ローリング中に旧 BrowserConsumer の mode 無し応答が混ざった場合は
|
|
2890
|
+
// typed snapshot を authoritative とみなさない。既知 chat stream だけ除外して
|
|
2891
|
+
// legacy 互換を維持し、正常な Claude/Codex に ghost PTY error を返さない。
|
|
2892
|
+
const haveCompleteModeSnapshot =
|
|
2893
|
+
Array.isArray(msg?.active_streams)
|
|
2894
|
+
&& classifiedIds.size === activeIds.size
|
|
2895
|
+
&& Array.from(classifiedIds).every((id) => activeIds.has(id))
|
|
2896
|
+
const knownChatIds = new Set([
|
|
2897
|
+
...Array.from(ctx.claudeBridge?.sessions?.keys?.() ?? []),
|
|
2898
|
+
...Array.from(ctx.codexBridge?.sessions?.keys?.() ?? []),
|
|
2899
|
+
])
|
|
2900
|
+
const activePtyIds = haveCompleteModeSnapshot
|
|
2901
|
+
? activeByMode.pty
|
|
2902
|
+
: new Set(Array.from(activeIds).filter((id) => !knownChatIds.has(id)))
|
|
2874
2903
|
const localIds = new Set(ctx.ptyBridge.list())
|
|
2875
2904
|
// churn 退化スナップショット保護 (ack_lost 根治 / 2026-06-17 根因 rank3)。
|
|
2876
2905
|
// backend は browser 群へ _STREAMS_SYNC_TIMEOUT_S(0.5s) だけ問い合わせて active set を
|
|
@@ -2881,7 +2910,7 @@ export function handleStreamsSyncResponse(msg, ctx) {
|
|
|
2881
2910
|
// 空 set は信用せず kill を一切行わない (真の孤児は 10min GC + maxStreams 上限が回収する)。
|
|
2882
2911
|
const haveAuthoritativeActiveSet = activeIds.size > 0
|
|
2883
2912
|
for (const sid of localIds) {
|
|
2884
|
-
if (
|
|
2913
|
+
if (activePtyIds.has(sid)) continue
|
|
2885
2914
|
if (!haveAuthoritativeActiveSet) continue
|
|
2886
2915
|
// grace guard: 直近 STREAMS_SYNC_GRACE_MS 以内に touch (attach/write/resize/pty 出力) された
|
|
2887
2916
|
// stream は「browser が再接続中で 0.5s 集計に間に合わなかっただけ」の可能性が高い。猶予内は
|
|
@@ -2901,7 +2930,7 @@ export function handleStreamsSyncResponse(msg, ctx) {
|
|
|
2901
2930
|
)
|
|
2902
2931
|
ctx.ptyBridge.detach({ stream_id: sid })
|
|
2903
2932
|
}
|
|
2904
|
-
for (const sid of
|
|
2933
|
+
for (const sid of activePtyIds) {
|
|
2905
2934
|
if (!localIds.has(sid)) {
|
|
2906
2935
|
ctx.logger?.warn?.(
|
|
2907
2936
|
{ stream_id: sid, request_id: msg?.request_id },
|
|
@@ -2914,6 +2943,51 @@ export function handleStreamsSyncResponse(msg, ctx) {
|
|
|
2914
2943
|
})
|
|
2915
2944
|
}
|
|
2916
2945
|
}
|
|
2946
|
+
|
|
2947
|
+
if (haveCompleteModeSnapshot) {
|
|
2948
|
+
// reconnect 集計は browser ごとの復帰順で一時的に部分集合になり得る。
|
|
2949
|
+
// 同じ mode の現役購読を1件も確認できない段階では、その mode 全体を掃除しない。
|
|
2950
|
+
if (activeByMode.claude.size > 0) {
|
|
2951
|
+
pruneInactiveChatSubscribers(
|
|
2952
|
+
ctx.claudeBridge,
|
|
2953
|
+
activeByMode.claude,
|
|
2954
|
+
"claude",
|
|
2955
|
+
ctx.logger,
|
|
2956
|
+
)
|
|
2957
|
+
}
|
|
2958
|
+
if (activeByMode.codex.size > 0) {
|
|
2959
|
+
pruneInactiveChatSubscribers(
|
|
2960
|
+
ctx.codexBridge,
|
|
2961
|
+
activeByMode.codex,
|
|
2962
|
+
"codex",
|
|
2963
|
+
ctx.logger,
|
|
2964
|
+
)
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
/** backend の mode-aware snapshot に存在しない死 browser subscriber を除去する。
|
|
2970
|
+
* 再接続が 0.5s 集計に間に合わなかった場合に備え、直近活性が grace 内の stream は保護する。 */
|
|
2971
|
+
export function pruneInactiveChatSubscribers(bridge, activeIds, mode, logger, now = Date.now()) {
|
|
2972
|
+
if (!bridge?.sessions || typeof bridge.detach !== "function") return 0
|
|
2973
|
+
let removed = 0
|
|
2974
|
+
for (const session of new Set(bridge.sessions.values())) {
|
|
2975
|
+
for (const streamId of Array.from(session?.streamIds ?? [])) {
|
|
2976
|
+
if (activeIds.has(streamId)) continue
|
|
2977
|
+
const lastSeen = session?.lastActivityByStream?.get?.(streamId) ?? 0
|
|
2978
|
+
if (lastSeen > 0 && now - lastSeen < STREAMS_SYNC_GRACE_MS) continue
|
|
2979
|
+
bridge.detach({ stream_id: streamId })
|
|
2980
|
+
// Codex は live session の索引を最低1件残す不変条件があり、最後の1件では
|
|
2981
|
+
// detachRequested を立てるだけで streamIds からは消さない。実際に消えた件だけ数える。
|
|
2982
|
+
if (session.streamIds?.has?.(streamId)) continue
|
|
2983
|
+
removed += 1
|
|
2984
|
+
logger?.info?.(
|
|
2985
|
+
{ stream_id: streamId, mode, idle_ms: lastSeen > 0 ? now - lastSeen : null },
|
|
2986
|
+
"inactive chat subscriber pruned after streams sync",
|
|
2987
|
+
)
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
return removed
|
|
2917
2991
|
}
|
|
2918
2992
|
|
|
2919
2993
|
/**
|
|
@@ -3522,6 +3596,9 @@ async function dispatch(msg, ctx) {
|
|
|
3522
3596
|
// 5s タイムアウトのような明示的な timer は持たず、再接続時に再度 request
|
|
3523
3597
|
// が飛ぶので無害。
|
|
3524
3598
|
handleStreamsSyncResponse(msg, ctx)
|
|
3599
|
+
// buffered pty/control event は browser の購読集合が確定してから再送する。
|
|
3600
|
+
// request_id は WsClient 側で現 reconnect 世代と照合され、古い応答は無視される。
|
|
3601
|
+
ctx.client.completeStreamsSync?.(msg)
|
|
3525
3602
|
return
|
|
3526
3603
|
case "claude.attach": {
|
|
3527
3604
|
// Sprint G: stream-json モードで Claude Code を起動。
|
package/src/ws-client.mjs
CHANGED
|
@@ -66,8 +66,9 @@ const PTY_BUFFER_MAX_AGE_MS = 30_000
|
|
|
66
66
|
// `claude.jsonl.event ... "ws send skipped (not open)"` のドロップを観測)。
|
|
67
67
|
// - pty.input.ack: 喪失すると browser の pendingAcks が永久滞留し「送信待ち(未配信)」が
|
|
68
68
|
// 消えない (S1/S3)。再接続 flush で遅れて届けば即解消する。
|
|
69
|
-
// - claude
|
|
70
|
-
//
|
|
69
|
+
// - claude/codex の event / ready / exit / error / permission / input ack,nack:
|
|
70
|
+
// 喪失すると新規セッションの楽観バブルが本物へ昇格できず、再 attach も固着する。
|
|
71
|
+
// - claude.jsonl.event / session.event: TUI と一覧の確定状態を復元する。
|
|
71
72
|
// - pty.exit / pty.ready / pty.error: stream ライフサイクルの節目。落とすと browser の
|
|
72
73
|
// 再 attach/復帰判断が狂う。
|
|
73
74
|
// pty.data とは別バッファにするのは、高頻度な pty.data の洪水で低頻度な ack/event が
|
|
@@ -80,11 +81,29 @@ const BUFFERED_CTRL_TYPES = new Set([
|
|
|
80
81
|
"pty.error",
|
|
81
82
|
"claude.jsonl.event",
|
|
82
83
|
"claude.event",
|
|
84
|
+
"claude.ready",
|
|
85
|
+
"claude.exit",
|
|
86
|
+
"claude.error",
|
|
87
|
+
"claude.permission.request",
|
|
88
|
+
"claude.permission.cancel",
|
|
89
|
+
"claude.input.ack",
|
|
90
|
+
"claude.input.nack",
|
|
91
|
+
"codex.event",
|
|
92
|
+
"codex.ready",
|
|
93
|
+
"codex.exit",
|
|
94
|
+
"codex.error",
|
|
95
|
+
"codex.permission.request",
|
|
96
|
+
"codex.input.ack",
|
|
97
|
+
"codex.input.nack",
|
|
83
98
|
"session.event",
|
|
84
99
|
])
|
|
85
100
|
// 制御/イベントバッファの上限。低頻度なので frames は小さめ、age は pty と揃える。
|
|
86
101
|
const CTRL_BUFFER_MAX_FRAMES = 200
|
|
87
102
|
const CTRL_BUFFER_MAX_AGE_MS = 30_000
|
|
103
|
+
// reconnect 後の buffered event は backend が browser の active stream 集合を
|
|
104
|
+
// 回収し終えるまで流さない。通常は 0.5s で応答する。旧 backend 互換のため 2s で
|
|
105
|
+
// barrier を解除するが、通常時の送受信経路には一切待ちを追加しない。
|
|
106
|
+
const STREAMS_SYNC_BARRIER_TIMEOUT_MS = 2_000
|
|
88
107
|
|
|
89
108
|
/** SDK-P2 (2026-07-08): claude.event が「ephemeral な生成中 delta」かどうか。
|
|
90
109
|
* stream_event の content_block_delta (text_delta / thinking_delta) は高頻度で流れる
|
|
@@ -97,6 +116,12 @@ function isEphemeralDeltaEvent(obj) {
|
|
|
97
116
|
return ev.event?.type === "content_block_delta"
|
|
98
117
|
}
|
|
99
118
|
|
|
119
|
+
/** Codex App Server の item/agentMessage/delta 等は再水和可能な高頻度delta。 */
|
|
120
|
+
function isEphemeralCodexDeltaEvent(obj) {
|
|
121
|
+
const method = obj?.event?.method
|
|
122
|
+
return typeof method === "string" && method.toLowerCase().endsWith("/delta")
|
|
123
|
+
}
|
|
124
|
+
|
|
100
125
|
export class WsClient extends EventEmitter {
|
|
101
126
|
/**
|
|
102
127
|
* @param {{ hub_url: string, agent_id: string, agent_token: string }} config
|
|
@@ -149,6 +174,10 @@ export class WsClient extends EventEmitter {
|
|
|
149
174
|
// 制御/イベント系 (ack / jsonl.event 等) の outbound buffer。pty.data とは別管理で、
|
|
150
175
|
// 高頻度 pty.data の洪水に evict されないようにする (機構③)。entry: { obj, ts }
|
|
151
176
|
this.ctrlOutboundBuffer = []
|
|
177
|
+
// reconnect 世代ごとの stream sync barrier。古い request_id の遅延応答で
|
|
178
|
+
// 現接続の buffer を flush しないよう request_id を照合する。
|
|
179
|
+
this.pendingStreamsSyncRequestId = null
|
|
180
|
+
this.streamsSyncBarrierTimer = null
|
|
152
181
|
// 直前の close 原因が CF/origin の 5xx か。次回 backoff の下限を引き上げる。
|
|
153
182
|
this.lastCloseWas5xx = false
|
|
154
183
|
// 接続が STABLE_CONNECTION_MS 維持できたら backoff/5xx フラグをリセットする
|
|
@@ -190,6 +219,7 @@ export class WsClient extends EventEmitter {
|
|
|
190
219
|
})
|
|
191
220
|
|
|
192
221
|
ws.on("close", (code, reason) => {
|
|
222
|
+
this._clearStreamsSyncBarrier()
|
|
193
223
|
this._clearStableReset()
|
|
194
224
|
this._stopHeartbeat()
|
|
195
225
|
this._stopBundleWatcher()
|
|
@@ -236,11 +266,8 @@ export class WsClient extends EventEmitter {
|
|
|
236
266
|
// 1〜2 秒間隔の再接続ループに陥らない。
|
|
237
267
|
this._armStableReset()
|
|
238
268
|
this.logger?.info("ws open")
|
|
239
|
-
// 切断中に積んだ
|
|
240
|
-
//
|
|
241
|
-
// 同期は backend が承認するため hello 送信後の同期 send_json で問題ない。
|
|
242
|
-
// ただし flush は hello 直後ではなく streams.sync.request の後に置く方が
|
|
243
|
-
// 安全 (sync 完了で stream_id がサーバに復元される想定)。
|
|
269
|
+
// 切断中に積んだ frame は hello 後も即時 flush しない。streams.sync.response で
|
|
270
|
+
// browser の購読集合が復元されたことを確認してから、現役 stream 宛だけ再送する。
|
|
244
271
|
// hello 前に最新の bundle version / CLI 別ステータスを読み直す (再接続時の鮮度確保)
|
|
245
272
|
this._refreshBundleVersion()
|
|
246
273
|
this._refreshBundleStatus()
|
|
@@ -260,12 +287,12 @@ export class WsClient extends EventEmitter {
|
|
|
260
287
|
// ptyBridge.list() と差分を取って kill する。request_id は uuid で
|
|
261
288
|
// 重複排除。backend が古い (= Plan ε 未対応) なら "unknown_type" error が
|
|
262
289
|
// 返るだけで動作には影響しない。
|
|
290
|
+
const streamsSyncRequestId = randomUUID()
|
|
263
291
|
this._sendJson({
|
|
264
292
|
type: "agent.streams.sync.request",
|
|
265
|
-
request_id:
|
|
293
|
+
request_id: streamsSyncRequestId,
|
|
266
294
|
})
|
|
267
|
-
this.
|
|
268
|
-
this._flushCtrlBuffer()
|
|
295
|
+
this._armStreamsSyncBarrier(streamsSyncRequestId)
|
|
269
296
|
this._startHeartbeat()
|
|
270
297
|
this._startBundleWatcher()
|
|
271
298
|
this.emit("open")
|
|
@@ -278,8 +305,13 @@ export class WsClient extends EventEmitter {
|
|
|
278
305
|
* buffer しない (warn のみ)。ack / event 系は 1 件ずつ意味を持つので別バッファに保持する。
|
|
279
306
|
*/
|
|
280
307
|
send(obj) {
|
|
281
|
-
|
|
282
|
-
|
|
308
|
+
const type = obj?.type
|
|
309
|
+
const wsOpen = this.ws?.readyState === WebSocket.OPEN
|
|
310
|
+
// sync barrier 中に新しく発生した frame も同じ buffer へ積み、切断中 frame より
|
|
311
|
+
// 先に送って時系列を逆転させない。heartbeat 等の非 buffer 制御系は通常どおり送る。
|
|
312
|
+
const holdForStreamsSync = Boolean(this.pendingStreamsSyncRequestId)
|
|
313
|
+
&& (type === "pty.data" || BUFFERED_CTRL_TYPES.has(type))
|
|
314
|
+
if (!wsOpen || holdForStreamsSync) {
|
|
283
315
|
if (type === "pty.data") {
|
|
284
316
|
this._bufferPtyData(obj)
|
|
285
317
|
return false
|
|
@@ -294,11 +326,16 @@ export class WsClient extends EventEmitter {
|
|
|
294
326
|
if (type === "claude.event" && isEphemeralDeltaEvent(obj)) {
|
|
295
327
|
return false
|
|
296
328
|
}
|
|
329
|
+
if (type === "codex.event" && isEphemeralCodexDeltaEvent(obj)) {
|
|
330
|
+
return false
|
|
331
|
+
}
|
|
297
332
|
this._bufferCtrl(obj)
|
|
298
333
|
return false
|
|
299
334
|
}
|
|
300
|
-
|
|
301
|
-
|
|
335
|
+
if (!wsOpen) {
|
|
336
|
+
this.logger?.warn({ type }, "ws send skipped (not open)")
|
|
337
|
+
return false
|
|
338
|
+
}
|
|
302
339
|
}
|
|
303
340
|
return this._sendJson(obj)
|
|
304
341
|
}
|
|
@@ -389,9 +426,55 @@ export class WsClient extends EventEmitter {
|
|
|
389
426
|
)
|
|
390
427
|
}
|
|
391
428
|
|
|
429
|
+
/** reconnect stream sync の完了通知。現接続の request_id と一致し、browser の
|
|
430
|
+
* active stream が1件以上復元されたときだけ buffered frame を flush する。
|
|
431
|
+
* @returns {boolean} 現在の barrier を解除した場合 true。 */
|
|
432
|
+
completeStreamsSync(msg) {
|
|
433
|
+
const requestId = msg?.request_id
|
|
434
|
+
if (!requestId || requestId !== this.pendingStreamsSyncRequestId) return false
|
|
435
|
+
|
|
436
|
+
const activeIds = new Set(
|
|
437
|
+
Array.isArray(msg?.active_stream_ids)
|
|
438
|
+
? msg.active_stream_ids.filter((id) => typeof id === "string" && id)
|
|
439
|
+
: [],
|
|
440
|
+
)
|
|
441
|
+
// backend の 0.5s 集計窓では、browser がまだ再接続しておらず空 snapshot が
|
|
442
|
+
// 返ることがある。空を authoritative として flush/drop すると live stream の
|
|
443
|
+
// frame を失うため、2s compatibility timeout まで barrier を維持する。
|
|
444
|
+
if (activeIds.size === 0) return false
|
|
445
|
+
this._clearStreamsSyncBarrier()
|
|
446
|
+
this._flushPtyBuffer()
|
|
447
|
+
this._flushCtrlBuffer()
|
|
448
|
+
return true
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
_armStreamsSyncBarrier(requestId) {
|
|
452
|
+
this._clearStreamsSyncBarrier()
|
|
453
|
+
this.pendingStreamsSyncRequestId = requestId
|
|
454
|
+
this.streamsSyncBarrierTimer = setTimeout(() => {
|
|
455
|
+
if (this.pendingStreamsSyncRequestId !== requestId) return
|
|
456
|
+
this.pendingStreamsSyncRequestId = null
|
|
457
|
+
this.streamsSyncBarrierTimer = null
|
|
458
|
+
this.logger?.warn?.(
|
|
459
|
+
{ request_id: requestId },
|
|
460
|
+
"streams sync barrier timed out; flushing buffered frames for legacy backend",
|
|
461
|
+
)
|
|
462
|
+
this._flushPtyBuffer()
|
|
463
|
+
this._flushCtrlBuffer()
|
|
464
|
+
}, STREAMS_SYNC_BARRIER_TIMEOUT_MS)
|
|
465
|
+
this.streamsSyncBarrierTimer.unref?.()
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
_clearStreamsSyncBarrier() {
|
|
469
|
+
if (this.streamsSyncBarrierTimer) clearTimeout(this.streamsSyncBarrierTimer)
|
|
470
|
+
this.streamsSyncBarrierTimer = null
|
|
471
|
+
this.pendingStreamsSyncRequestId = null
|
|
472
|
+
}
|
|
473
|
+
|
|
392
474
|
/** Reconnect を止めて切断する。 */
|
|
393
475
|
stop() {
|
|
394
476
|
this.stopped = true
|
|
477
|
+
this._clearStreamsSyncBarrier()
|
|
395
478
|
this._clearStableReset()
|
|
396
479
|
this._stopHeartbeat()
|
|
397
480
|
this._stopBundleWatcher()
|