@cocorograph/hub-agent 0.8.48 → 0.8.50
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/bin/hub-agent.mjs +36 -7
- package/package.json +2 -2
- package/src/claude-stream-bridge.mjs +18 -5
- package/src/codex-artifact-template-picker.mjs +198 -0
- package/src/codex-dynamic-tools.mjs +191 -0
- package/src/codex-stream-bridge.mjs +405 -10
- package/src/codex-workspace-dependencies.mjs +331 -0
- package/src/main.mjs +174 -4
- package/src/service-install.mjs +51 -17
- package/src/sibling-inject.mjs +177 -23
|
@@ -47,6 +47,17 @@ import { recordInjectSourcePersistent } from "./inject-source-store.mjs"
|
|
|
47
47
|
|
|
48
48
|
import { AsyncTtlCache } from "./async-ttl-cache.mjs"
|
|
49
49
|
import { CodexAppServerClient } from "./codex-appserver-client.mjs"
|
|
50
|
+
import {
|
|
51
|
+
buildCodexArtifactTemplatePickerConfig,
|
|
52
|
+
preflightCodexArtifactTemplatePicker,
|
|
53
|
+
} from "./codex-artifact-template-picker.mjs"
|
|
54
|
+
import {
|
|
55
|
+
codexDynamicToolFailure,
|
|
56
|
+
createCodexDynamicToolExecutor,
|
|
57
|
+
sanitizeCodexDynamicErrorCode,
|
|
58
|
+
sanitizeCodexDynamicToolName,
|
|
59
|
+
} from "./codex-dynamic-tools.mjs"
|
|
60
|
+
import { resolveCodexWorkspaceDependencies } from "./codex-workspace-dependencies.mjs"
|
|
50
61
|
import {
|
|
51
62
|
snapshotCodexDeliveryAudit,
|
|
52
63
|
verifyCodexDeliveryAudit,
|
|
@@ -61,6 +72,120 @@ function parseTurnMismatch(reason) {
|
|
|
61
72
|
return mismatch ? { expected: mismatch[1], found: mismatch[2] } : null
|
|
62
73
|
}
|
|
63
74
|
|
|
75
|
+
function codexHostCapabilityErrorCode(error, fallback) {
|
|
76
|
+
return sanitizeCodexDynamicErrorCode(error?.code, fallback)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function flattenCodexSkillsList(result) {
|
|
80
|
+
if (!result || !Array.isArray(result.data)) {
|
|
81
|
+
const error = new Error("invalid skills/list response")
|
|
82
|
+
error.code = "CODEX_SKILLS_LIST_RESPONSE_INVALID"
|
|
83
|
+
throw error
|
|
84
|
+
}
|
|
85
|
+
const skills = []
|
|
86
|
+
for (const entry of result.data) {
|
|
87
|
+
if (!entry || !Array.isArray(entry.skills)) {
|
|
88
|
+
const error = new Error("invalid skills/list response")
|
|
89
|
+
error.code = "CODEX_SKILLS_LIST_RESPONSE_INVALID"
|
|
90
|
+
throw error
|
|
91
|
+
}
|
|
92
|
+
// SkillMetadata は Codex が正本。未知 field を選別・再構築せず、そのまま picker へ渡す。
|
|
93
|
+
skills.push(...entry.skills)
|
|
94
|
+
}
|
|
95
|
+
return skills
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function deadlineError(code) {
|
|
99
|
+
const error = new Error(code)
|
|
100
|
+
error.code = code
|
|
101
|
+
return error
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function withDeadline(promise, timeoutMs, code) {
|
|
105
|
+
let timer
|
|
106
|
+
const timeout = new Promise((_, reject) => {
|
|
107
|
+
timer = setTimeout(() => reject(deadlineError(code)), timeoutMs)
|
|
108
|
+
timer.unref?.()
|
|
109
|
+
})
|
|
110
|
+
try {
|
|
111
|
+
return await Promise.race([Promise.resolve(promise), timeout])
|
|
112
|
+
} finally {
|
|
113
|
+
clearTimeout(timer)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function buildCodexHostCapabilities({
|
|
118
|
+
client,
|
|
119
|
+
cwd,
|
|
120
|
+
artifactTemplatePickerConfigBuilder,
|
|
121
|
+
artifactTemplatePickerPreflight,
|
|
122
|
+
resolveWorkspaceDependencies,
|
|
123
|
+
}) {
|
|
124
|
+
let preflight
|
|
125
|
+
try {
|
|
126
|
+
preflight = await artifactTemplatePickerPreflight()
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return {
|
|
129
|
+
config: null,
|
|
130
|
+
warning: {
|
|
131
|
+
error_code: codexHostCapabilityErrorCode(
|
|
132
|
+
error,
|
|
133
|
+
"CODEX_ARTIFACT_TEMPLATE_PICKER_PREFLIGHT_FAILED",
|
|
134
|
+
),
|
|
135
|
+
skill_count: 0,
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
let skills
|
|
140
|
+
try {
|
|
141
|
+
skills = flattenCodexSkillsList(await client.request("skills/list", { cwds: [cwd] }))
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return {
|
|
144
|
+
config: null,
|
|
145
|
+
warning: {
|
|
146
|
+
error_code: codexHostCapabilityErrorCode(error, "CODEX_SKILLS_LIST_FAILED"),
|
|
147
|
+
skill_count: 0,
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const diagnosticCounts = new Map()
|
|
153
|
+
try {
|
|
154
|
+
const config = await artifactTemplatePickerConfigBuilder({
|
|
155
|
+
skills,
|
|
156
|
+
preflight,
|
|
157
|
+
resolveWorkspaceDependencies,
|
|
158
|
+
onDiagnostic: (item) => {
|
|
159
|
+
const code = codexHostCapabilityErrorCode(
|
|
160
|
+
item,
|
|
161
|
+
"CODEX_ARTIFACT_TEMPLATE_PICKER_DIAGNOSTIC",
|
|
162
|
+
)
|
|
163
|
+
diagnosticCounts.set(code, (diagnosticCounts.get(code) || 0) + 1)
|
|
164
|
+
},
|
|
165
|
+
})
|
|
166
|
+
return {
|
|
167
|
+
config: config && typeof config === "object" && !Array.isArray(config) ? config : null,
|
|
168
|
+
warning: diagnosticCounts.size > 0
|
|
169
|
+
? { diagnostic_counts: Object.fromEntries(diagnosticCounts), skill_count: skills.length }
|
|
170
|
+
: null,
|
|
171
|
+
}
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return {
|
|
174
|
+
config: null,
|
|
175
|
+
warning: {
|
|
176
|
+
error_code: codexHostCapabilityErrorCode(
|
|
177
|
+
error,
|
|
178
|
+
"CODEX_ARTIFACT_TEMPLATE_PICKER_CONFIG_FAILED",
|
|
179
|
+
),
|
|
180
|
+
...(diagnosticCounts.size > 0
|
|
181
|
+
? { diagnostic_counts: Object.fromEntries(diagnosticCounts) }
|
|
182
|
+
: {}),
|
|
183
|
+
skill_count: skills.length,
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
64
189
|
/**
|
|
65
190
|
* sibling-inject (2026-07-04) 用: spawn する codex app-server プロセス自身の
|
|
66
191
|
* 環境変数に、この cwd の capability トークンを乗せる。ディスクには書かず、
|
|
@@ -209,6 +334,24 @@ const DELIVERY_ECHO_TIMEOUT_MS = readPositiveIntEnv(
|
|
|
209
334
|
)
|
|
210
335
|
|
|
211
336
|
const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
|
|
337
|
+
const WORKSPACE_DEPENDENCIES_CACHE_TTL_MS = 30_000
|
|
338
|
+
const WORKSPACE_DEPENDENCIES_FAILURE_TTL_MS = readPositiveIntEnv(
|
|
339
|
+
"HUB_AGENT_CODEX_WORKSPACE_DEPENDENCIES_FAILURE_TTL_MS",
|
|
340
|
+
30_000,
|
|
341
|
+
)
|
|
342
|
+
const WORKSPACE_DEPENDENCIES_DEADLINE_MS = readPositiveIntEnv(
|
|
343
|
+
"HUB_AGENT_CODEX_WORKSPACE_DEPENDENCIES_DEADLINE_MS",
|
|
344
|
+
45_000,
|
|
345
|
+
)
|
|
346
|
+
const HOST_CAPABILITIES_DEADLINE_MS = readPositiveIntEnv(
|
|
347
|
+
"HUB_AGENT_CODEX_HOST_CAPABILITIES_DEADLINE_MS",
|
|
348
|
+
10_000,
|
|
349
|
+
)
|
|
350
|
+
const FORM_ELICITATION_TIMEOUT_MS = readPositiveIntEnv(
|
|
351
|
+
"HUB_AGENT_CODEX_FORM_ELICITATION_TIMEOUT_MS",
|
|
352
|
+
10 * 60_000,
|
|
353
|
+
)
|
|
354
|
+
const DYNAMIC_TOOL_REQUEST_LEDGER_MAX = 256
|
|
212
355
|
|
|
213
356
|
// T06499 (2026-07-18): 巨大スレッドの resume ガード。長期運用したスレッドの rollout は
|
|
214
357
|
// 20MB 超まで肥大し得る (実測: メイン repo 540KB が正常なのに対し 20MB のスレッドあり)。
|
|
@@ -466,11 +609,80 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
466
609
|
historyMaxBytes,
|
|
467
610
|
listThreadsCacheTtlMs,
|
|
468
611
|
accountRateLimitsCacheTtlMs,
|
|
612
|
+
workspaceDependenciesCacheTtlMs,
|
|
613
|
+
workspaceDependenciesFailureTtlMs,
|
|
614
|
+
workspaceDependenciesDeadlineMs,
|
|
615
|
+
hostCapabilitiesDeadlineMs,
|
|
616
|
+
formElicitationTimeoutMs,
|
|
617
|
+
resolveWorkspaceDependencies,
|
|
618
|
+
dynamicToolExecutorFactory,
|
|
619
|
+
artifactTemplatePickerConfigBuilder,
|
|
620
|
+
artifactTemplatePickerPreflight,
|
|
469
621
|
} = {}) {
|
|
470
622
|
super()
|
|
471
623
|
this.codexCmd = codexCmd
|
|
472
624
|
this.logger = logger
|
|
473
625
|
this._clientFactory = clientFactory || ((opts) => new CodexAppServerClient(opts))
|
|
626
|
+
const uncachedWorkspaceDependencies = resolveWorkspaceDependencies || resolveCodexWorkspaceDependencies
|
|
627
|
+
this._workspaceDependenciesCacheTtlMs = sanitizePositiveInt(
|
|
628
|
+
workspaceDependenciesCacheTtlMs,
|
|
629
|
+
WORKSPACE_DEPENDENCIES_CACHE_TTL_MS,
|
|
630
|
+
{ logger: this.logger, label: "codex.workspaceDependenciesCacheTtlMs" },
|
|
631
|
+
)
|
|
632
|
+
this._workspaceDependenciesFailureTtlMs = sanitizePositiveInt(
|
|
633
|
+
workspaceDependenciesFailureTtlMs,
|
|
634
|
+
WORKSPACE_DEPENDENCIES_FAILURE_TTL_MS,
|
|
635
|
+
{ logger: this.logger, label: "codex.workspaceDependenciesFailureTtlMs" },
|
|
636
|
+
)
|
|
637
|
+
this._workspaceDependenciesDeadlineMs = sanitizePositiveInt(
|
|
638
|
+
workspaceDependenciesDeadlineMs,
|
|
639
|
+
WORKSPACE_DEPENDENCIES_DEADLINE_MS,
|
|
640
|
+
{ logger: this.logger, label: "codex.workspaceDependenciesDeadlineMs" },
|
|
641
|
+
)
|
|
642
|
+
this._hostCapabilitiesDeadlineMs = sanitizePositiveInt(
|
|
643
|
+
hostCapabilitiesDeadlineMs,
|
|
644
|
+
HOST_CAPABILITIES_DEADLINE_MS,
|
|
645
|
+
{ logger: this.logger, label: "codex.hostCapabilitiesDeadlineMs" },
|
|
646
|
+
)
|
|
647
|
+
this._formElicitationTimeoutMs = sanitizePositiveInt(
|
|
648
|
+
formElicitationTimeoutMs,
|
|
649
|
+
FORM_ELICITATION_TIMEOUT_MS,
|
|
650
|
+
{ logger: this.logger, label: "codex.formElicitationTimeoutMs" },
|
|
651
|
+
)
|
|
652
|
+
this._workspaceDependenciesCache = new AsyncTtlCache({
|
|
653
|
+
ttlMs: this._workspaceDependenciesCacheTtlMs,
|
|
654
|
+
})
|
|
655
|
+
this._workspaceDependenciesFailure = null
|
|
656
|
+
this._resolveWorkspaceDependencies = async () => {
|
|
657
|
+
const cachedFailure = this._workspaceDependenciesFailure
|
|
658
|
+
if (cachedFailure && cachedFailure.expiresAt > Date.now()) {
|
|
659
|
+
throw deadlineError(cachedFailure.code)
|
|
660
|
+
}
|
|
661
|
+
this._workspaceDependenciesFailure = null
|
|
662
|
+
try {
|
|
663
|
+
const dependencies = await this._workspaceDependenciesCache.get(
|
|
664
|
+
() => withDeadline(
|
|
665
|
+
Promise.resolve().then(uncachedWorkspaceDependencies),
|
|
666
|
+
this._workspaceDependenciesDeadlineMs,
|
|
667
|
+
"CODEX_WORKSPACE_DEPENDENCIES_TIMEOUT",
|
|
668
|
+
),
|
|
669
|
+
)
|
|
670
|
+
this._workspaceDependenciesFailure = null
|
|
671
|
+
return dependencies
|
|
672
|
+
} catch (error) {
|
|
673
|
+
// Negative cacheには安全なcodeと期限だけを保持する。cause/messageは秘密を含み得る。
|
|
674
|
+
this._workspaceDependenciesFailure = {
|
|
675
|
+
code: codexHostCapabilityErrorCode(error, "CODEX_WORKSPACE_DEPENDENCIES_FAILED"),
|
|
676
|
+
expiresAt: Date.now() + this._workspaceDependenciesFailureTtlMs,
|
|
677
|
+
}
|
|
678
|
+
throw deadlineError(this._workspaceDependenciesFailure.code)
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
this._dynamicToolExecutorFactory = dynamicToolExecutorFactory || createCodexDynamicToolExecutor
|
|
682
|
+
this._artifactTemplatePickerConfigBuilder = artifactTemplatePickerConfigBuilder
|
|
683
|
+
|| buildCodexArtifactTemplatePickerConfig
|
|
684
|
+
this._artifactTemplatePickerPreflight = artifactTemplatePickerPreflight
|
|
685
|
+
|| preflightCodexArtifactTemplatePicker
|
|
474
686
|
// Stage 5: warm 保持の idle TTL(既定 7 日)。テストから短縮できるよう注入可能。
|
|
475
687
|
this._detachTtlMs = detachTtlMs ?? IDLE_DETACH_TTL_MS
|
|
476
688
|
// 子⑤ (T06544): 同時 warm 保持する cwd 数の上限 N(既定 20)。各 cwd は K 本に畳む。
|
|
@@ -741,6 +953,14 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
741
953
|
// request_id -> { turnId, method, params }。major#1: reattach で新 stream へ未応答の承認要求を
|
|
742
954
|
// 再送できるよう、元の method/params も保持する (Claude の _permissionResolvers と同型)。
|
|
743
955
|
pendingApprovals: new Map(),
|
|
956
|
+
// host dynamic tool は permission request と別経路で即時応答する。
|
|
957
|
+
dynamicToolExecutor: this._dynamicToolExecutorFactory({
|
|
958
|
+
resolveWorkspaceDependencies: this._resolveWorkspaceDependencies,
|
|
959
|
+
logger: this.logger,
|
|
960
|
+
}),
|
|
961
|
+
// JSON-RPC request id ごとの exactly-once 応答台帳。callId の resolver single-flight は
|
|
962
|
+
// executor が別に担い、同じ callId でも request id が違えば各 request へ応答する。
|
|
963
|
+
handledDynamicToolRequestIds: new Set(),
|
|
744
964
|
// ブラウザから codex.detach を受け取った (= 画面の持ち主が居なくなった) か。診断・ログ用。
|
|
745
965
|
// Stage 5: 回収の可否は detachRequested ではなく lastActivityMs(LRU)と activeTurnId
|
|
746
966
|
// (busy/stuck)で決める。detach してもすぐには殺さず warm 保持する。
|
|
@@ -827,7 +1047,13 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
827
1047
|
{ stream_id, cwd, resuming, lifecycle_stage: "initialize_started" },
|
|
828
1048
|
"codex app-server session initialization",
|
|
829
1049
|
)
|
|
830
|
-
await client.request("initialize", {
|
|
1050
|
+
await client.request("initialize", {
|
|
1051
|
+
clientInfo: CLIENT_INFO,
|
|
1052
|
+
capabilities: {
|
|
1053
|
+
experimentalApi: true,
|
|
1054
|
+
mcpServerOpenaiFormElicitation: true,
|
|
1055
|
+
},
|
|
1056
|
+
})
|
|
831
1057
|
// ここまでが「app-server プロセスが起動して JSON-RPC に応答できるようになるまで」。
|
|
832
1058
|
timing.initialize_ms = lap()
|
|
833
1059
|
// 公式 App Server handshake: initialize 応答後、同じ transport 上で initialized を
|
|
@@ -837,10 +1063,43 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
837
1063
|
{ stream_id, cwd, resuming, lifecycle_stage: "initialized_sent" },
|
|
838
1064
|
"codex app-server session initialization",
|
|
839
1065
|
)
|
|
840
|
-
reachedThreadOp = true
|
|
841
1066
|
const startParams = { cwd, model: model || undefined, sandbox: sandbox || undefined }
|
|
842
1067
|
if (effort) startParams.effort = effort
|
|
843
1068
|
if (approvalPolicy) startParams.approvalPolicy = approvalPolicy
|
|
1069
|
+
// app-serverを跨ぐcold resumeではthreadのhost側MCP/dynamic tool登録が復元されない実測が
|
|
1070
|
+
// あるため、fresh startと同じcapabilityを毎回再注入する。in-memory reattachはこのcold経路へ
|
|
1071
|
+
// 入らないので、既存process上での再buildは発生しない。
|
|
1072
|
+
try {
|
|
1073
|
+
const hostCapabilities = await withDeadline(
|
|
1074
|
+
buildCodexHostCapabilities({
|
|
1075
|
+
client,
|
|
1076
|
+
cwd,
|
|
1077
|
+
artifactTemplatePickerConfigBuilder: this._artifactTemplatePickerConfigBuilder,
|
|
1078
|
+
artifactTemplatePickerPreflight: this._artifactTemplatePickerPreflight,
|
|
1079
|
+
resolveWorkspaceDependencies: this._resolveWorkspaceDependencies,
|
|
1080
|
+
}),
|
|
1081
|
+
this._hostCapabilitiesDeadlineMs,
|
|
1082
|
+
"CODEX_HOST_CAPABILITIES_TIMEOUT",
|
|
1083
|
+
)
|
|
1084
|
+
if (hostCapabilities.config) startParams.config = { ...hostCapabilities.config }
|
|
1085
|
+
if (hostCapabilities.warning) {
|
|
1086
|
+
this.logger?.warn?.(
|
|
1087
|
+
hostCapabilities.warning,
|
|
1088
|
+
"codex artifact template picker degraded",
|
|
1089
|
+
)
|
|
1090
|
+
}
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
this.logger?.warn?.(
|
|
1093
|
+
{
|
|
1094
|
+
error_code: codexHostCapabilityErrorCode(error, "CODEX_HOST_CAPABILITIES_FAILED"),
|
|
1095
|
+
skill_count: 0,
|
|
1096
|
+
},
|
|
1097
|
+
"codex artifact template picker degraded",
|
|
1098
|
+
)
|
|
1099
|
+
}
|
|
1100
|
+
startParams.dynamicTools = session.dynamicToolExecutor.definitions
|
|
1101
|
+
startParams.runtimeWorkspaceRoots = [cwd]
|
|
1102
|
+
timing.host_capabilities_ms = lap()
|
|
844
1103
|
this.logger?.info?.(
|
|
845
1104
|
{
|
|
846
1105
|
stream_id,
|
|
@@ -851,6 +1110,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
851
1110
|
},
|
|
852
1111
|
"codex app-server session initialization",
|
|
853
1112
|
)
|
|
1113
|
+
reachedThreadOp = true
|
|
854
1114
|
const result = resuming
|
|
855
1115
|
? await client.request("thread/resume", { ...startParams, threadId: resumeThreadId })
|
|
856
1116
|
: await client.request("thread/start", startParams)
|
|
@@ -951,6 +1211,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
951
1211
|
}
|
|
952
1212
|
} catch (err) {
|
|
953
1213
|
this.sessions.delete(stream_id)
|
|
1214
|
+
this._settleAllPendingApprovals(session, { respond: false })
|
|
954
1215
|
client.stop()
|
|
955
1216
|
settleReady(null) // reattach 待ちへ「失敗」を通知 (reject しない)
|
|
956
1217
|
// 失敗も 1 行に落とす。**_classifyAttachFailure より前**に出す (分類は rollout の disk 走査を
|
|
@@ -987,6 +1248,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
987
1248
|
* ## 読み方
|
|
988
1249
|
* - `warm=true` / `mode=reattach_*` … app-server を立てていない (所要は `reattach_wait_ms` だけ)
|
|
989
1250
|
* - `warm=false` / `mode=cold_*` … `spawn_ms`(同期 spawn) + `initialize_ms`(プロセス起動待ち)
|
|
1251
|
+
* + `host_capabilities_ms`(cold start/resume の skills/runtime 構築)
|
|
990
1252
|
* + `thread_op_ms`(thread/start|resume。resume は rollout 読み込み込み) + `history_flatten_ms`
|
|
991
1253
|
*
|
|
992
1254
|
* ログ量: attach 時のみ (ターン毎には出ない)。既存の "codex session attached" と同オーダー。
|
|
@@ -1000,6 +1262,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1000
1262
|
reattach_wait_ms: timing.reattach_wait_ms ?? null,
|
|
1001
1263
|
spawn_ms: timing.spawn_ms ?? null,
|
|
1002
1264
|
initialize_ms: timing.initialize_ms ?? null,
|
|
1265
|
+
host_capabilities_ms: timing.host_capabilities_ms ?? null,
|
|
1003
1266
|
thread_op_ms: timing.thread_op_ms ?? null,
|
|
1004
1267
|
history_flatten_ms: timing.history_flatten_ms ?? null,
|
|
1005
1268
|
},
|
|
@@ -1249,9 +1512,22 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1249
1512
|
/** app-server を 1 本 spawn して account/rateLimits/read を取る実処理 (キャッシュ miss 時のみ)。 */
|
|
1250
1513
|
async _fetchAccountRateLimits({ cwd, profileId }) {
|
|
1251
1514
|
const client = this._clientFactory({ cwd: cwd || process.cwd(), codexCmd: this.codexCmd, logger: this.logger })
|
|
1515
|
+
client.on("error", (error) => {
|
|
1516
|
+
this.logger?.warn?.(
|
|
1517
|
+
{
|
|
1518
|
+
error_code: codexHostCapabilityErrorCode(
|
|
1519
|
+
error,
|
|
1520
|
+
"CODEX_ACCOUNT_RATE_LIMITS_CLIENT_ERROR",
|
|
1521
|
+
),
|
|
1522
|
+
},
|
|
1523
|
+
"codex account rate limits client error",
|
|
1524
|
+
)
|
|
1525
|
+
})
|
|
1252
1526
|
client.start()
|
|
1253
1527
|
try {
|
|
1254
|
-
await client.request("initialize", {
|
|
1528
|
+
await client.request("initialize", {
|
|
1529
|
+
clientInfo: CLIENT_INFO,
|
|
1530
|
+
})
|
|
1255
1531
|
client.notify("initialized", {})
|
|
1256
1532
|
const result = await client.request("account/rateLimits/read", {})
|
|
1257
1533
|
this._accountRateLimitsByProfile.set(profileId, result)
|
|
@@ -2540,13 +2816,27 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2540
2816
|
}
|
|
2541
2817
|
|
|
2542
2818
|
_onServerRequest(session, msg) {
|
|
2819
|
+
if (msg?.method === "item/tool/call") {
|
|
2820
|
+
this._onDynamicToolCall(session, msg)
|
|
2821
|
+
return
|
|
2822
|
+
}
|
|
2543
2823
|
const { stream_id } = session
|
|
2824
|
+
const previous = session.pendingApprovals.get(msg.id)
|
|
2825
|
+
if (previous?.timer) clearTimeout(previous.timer)
|
|
2544
2826
|
// major#1: reattach 時に新 stream へ再送できるよう method/params も保持する。
|
|
2545
|
-
|
|
2827
|
+
const pending = {
|
|
2546
2828
|
turnId: session.activeTurnId,
|
|
2547
2829
|
method: msg.method,
|
|
2548
2830
|
params: msg.params,
|
|
2549
|
-
|
|
2831
|
+
timer: null,
|
|
2832
|
+
}
|
|
2833
|
+
if (msg.method === "mcpServer/elicitation/request") {
|
|
2834
|
+
pending.timer = setTimeout(() => {
|
|
2835
|
+
this._expireFormElicitation(session, msg.id, pending)
|
|
2836
|
+
}, this._formElicitationTimeoutMs)
|
|
2837
|
+
pending.timer.unref?.()
|
|
2838
|
+
}
|
|
2839
|
+
session.pendingApprovals.set(msg.id, pending)
|
|
2550
2840
|
this.emit("permission", {
|
|
2551
2841
|
stream_id,
|
|
2552
2842
|
thread_id: session.threadId,
|
|
@@ -2557,6 +2847,49 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2557
2847
|
})
|
|
2558
2848
|
}
|
|
2559
2849
|
|
|
2850
|
+
_onDynamicToolCall(session, msg) {
|
|
2851
|
+
if (session.handledDynamicToolRequestIds.has(msg?.id)) return
|
|
2852
|
+
session.handledDynamicToolRequestIds.add(msg?.id)
|
|
2853
|
+
if (session.handledDynamicToolRequestIds.size > DYNAMIC_TOOL_REQUEST_LEDGER_MAX) {
|
|
2854
|
+
session.handledDynamicToolRequestIds.delete(
|
|
2855
|
+
session.handledDynamicToolRequestIds.values().next().value,
|
|
2856
|
+
)
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
const tool = sanitizeCodexDynamicToolName(msg?.params?.tool)
|
|
2860
|
+
let execution
|
|
2861
|
+
const requestThreadId = msg?.params?.threadId
|
|
2862
|
+
const allowedThread = requestThreadId === session.threadId
|
|
2863
|
+
// 表示台帳と停止台帳は観測経路・寿命が異なる。どちらか一方で既知なら、このsessionが
|
|
2864
|
+
// 所有するsubagent threadとして許可する意図的なOR結合(積集合へ狭めない)。
|
|
2865
|
+
|| session.subagents.has(requestThreadId)
|
|
2866
|
+
|| session.subagentInterruptRegistry.has(requestThreadId)
|
|
2867
|
+
if (!allowedThread) {
|
|
2868
|
+
this.logger?.warn?.(
|
|
2869
|
+
{ tool, error_code: "THREAD_MISMATCH" },
|
|
2870
|
+
"codex dynamic tool call rejected",
|
|
2871
|
+
)
|
|
2872
|
+
execution = Promise.resolve(codexDynamicToolFailure("THREAD_MISMATCH"))
|
|
2873
|
+
} else {
|
|
2874
|
+
execution = Promise.resolve().then(() => session.dynamicToolExecutor.execute(msg?.params))
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
void execution.catch(() => {
|
|
2878
|
+
this.logger?.warn?.(
|
|
2879
|
+
{ tool, error_code: "DYNAMIC_TOOL_EXECUTOR_REJECTED" },
|
|
2880
|
+
"codex dynamic tool call failed",
|
|
2881
|
+
)
|
|
2882
|
+
return codexDynamicToolFailure("DYNAMIC_TOOL_EXECUTOR_REJECTED")
|
|
2883
|
+
}).then((result) => {
|
|
2884
|
+
session.client.respond(msg.id, result)
|
|
2885
|
+
}).catch(() => {
|
|
2886
|
+
this.logger?.warn?.(
|
|
2887
|
+
{ tool, error_code: "DYNAMIC_TOOL_RESPONSE_FAILED" },
|
|
2888
|
+
"codex dynamic tool response failed",
|
|
2889
|
+
)
|
|
2890
|
+
})
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2560
2893
|
/**
|
|
2561
2894
|
* major#1: 未応答の承認要求を現在の stream へ再送する (reattach から呼ぶ)。Claude の
|
|
2562
2895
|
* _replayPendingPermissions と同型。frontend は request_id で重複排除するため、既に表示済みの
|
|
@@ -2576,14 +2909,63 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2576
2909
|
}
|
|
2577
2910
|
}
|
|
2578
2911
|
|
|
2912
|
+
_pendingApprovalResult(info) {
|
|
2913
|
+
return info?.method === "mcpServer/elicitation/request"
|
|
2914
|
+
? { action: "cancel", content: null }
|
|
2915
|
+
: { decision: "decline" }
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
_settlePendingApproval(session, requestId, result, { respond = true } = {}) {
|
|
2919
|
+
const info = session?.pendingApprovals?.get(requestId)
|
|
2920
|
+
if (!info) return false
|
|
2921
|
+
session.pendingApprovals.delete(requestId)
|
|
2922
|
+
if (info.timer) clearTimeout(info.timer)
|
|
2923
|
+
if (respond) session.client.respond(requestId, result)
|
|
2924
|
+
return true
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
_settleAllPendingApprovals(session, { respond = true } = {}) {
|
|
2928
|
+
for (const [requestId, info] of session?.pendingApprovals || []) {
|
|
2929
|
+
this._settlePendingApproval(
|
|
2930
|
+
session,
|
|
2931
|
+
requestId,
|
|
2932
|
+
this._pendingApprovalResult(info),
|
|
2933
|
+
{ respond },
|
|
2934
|
+
)
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
_expireFormElicitation(session, requestId, expected) {
|
|
2939
|
+
if (session?.pendingApprovals?.get(requestId) !== expected) return
|
|
2940
|
+
if (!this._settlePendingApproval(
|
|
2941
|
+
session,
|
|
2942
|
+
requestId,
|
|
2943
|
+
{ action: "cancel", content: null },
|
|
2944
|
+
)) return
|
|
2945
|
+
this.emit("event", {
|
|
2946
|
+
stream_id: session.stream_id,
|
|
2947
|
+
producer_seq: session.seq,
|
|
2948
|
+
thread_id: session.threadId,
|
|
2949
|
+
cwd: session.cwd,
|
|
2950
|
+
notification: {
|
|
2951
|
+
method: "cockpit/permission_expired",
|
|
2952
|
+
params: { request_id: requestId, method: expected.method },
|
|
2953
|
+
},
|
|
2954
|
+
})
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2579
2957
|
// ターン完了時、そのターンに紐づく未解決の承認要求は自動 decline する
|
|
2580
2958
|
// (browser が応答する前にターンが打ち切られた/クラッシュ相当のケース。
|
|
2581
2959
|
// claude-stream-bridge.mjs の「ターン終了時に未解決分は自動 deny」と同じ設計)。
|
|
2582
2960
|
_autoResolveStalePendingApprovals(session, completedTurnId) {
|
|
2583
2961
|
for (const [requestId, info] of session.pendingApprovals) {
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2962
|
+
const belongsToCompletedTurn = info.turnId !== null && info.turnId === completedTurnId
|
|
2963
|
+
if (!belongsToCompletedTurn) continue
|
|
2964
|
+
this._settlePendingApproval(
|
|
2965
|
+
session,
|
|
2966
|
+
requestId,
|
|
2967
|
+
this._pendingApprovalResult(info),
|
|
2968
|
+
)
|
|
2587
2969
|
}
|
|
2588
2970
|
}
|
|
2589
2971
|
|
|
@@ -2595,6 +2977,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2595
2977
|
this._settleTurnActiveWaiter(session)
|
|
2596
2978
|
this._drainRejectQueue(session, { reason: "queue_dead" })
|
|
2597
2979
|
this._settlePendingInjectDeliveries(session, "session_dead")
|
|
2980
|
+
this._settleAllPendingApprovals(session, { respond: false })
|
|
2598
2981
|
// 案B: app-server が死んだら全購読 stream_id キーを Map から外す(1 端末分だけでは
|
|
2599
2982
|
// 他の購読キーが dangling で残る)。
|
|
2600
2983
|
this._dropAllStreamKeys(session)
|
|
@@ -2947,8 +3330,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2947
3330
|
this.logger?.warn?.({ stream_id, request_id }, "codex permissionReply: unknown request_id (ignored)")
|
|
2948
3331
|
return
|
|
2949
3332
|
}
|
|
2950
|
-
|
|
2951
|
-
session.client.respond(request_id, result)
|
|
3333
|
+
this._settlePendingApproval(session, request_id, result)
|
|
2952
3334
|
}
|
|
2953
3335
|
|
|
2954
3336
|
/**
|
|
@@ -3567,6 +3949,17 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
3567
3949
|
return now - (session.lastActivityMs ?? now) <= this._stuckTurnTtlMs
|
|
3568
3950
|
}
|
|
3569
3951
|
|
|
3952
|
+
/**
|
|
3953
|
+
* daemon の deferred restart が待つべき実作業があるか。
|
|
3954
|
+
* warm 保持とは異なり、購読端末数や detach 状態は作業として数えない。
|
|
3955
|
+
*/
|
|
3956
|
+
hasActiveWork(now = Date.now()) {
|
|
3957
|
+
for (const session of this._liveSessions()) {
|
|
3958
|
+
if (this._isProtected(session, now)) return true
|
|
3959
|
+
}
|
|
3960
|
+
return false
|
|
3961
|
+
}
|
|
3962
|
+
|
|
3570
3963
|
_hasLiveSubagentTurn(session, now = Date.now()) {
|
|
3571
3964
|
const registry = session?.subagentInterruptRegistry
|
|
3572
3965
|
if (!registry || registry.size === 0) return false
|
|
@@ -3618,6 +4011,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
3618
4011
|
this._settleTurnActiveWaiter(session)
|
|
3619
4012
|
this._drainRejectQueue(session, { reason: "queue_dead" })
|
|
3620
4013
|
this._settlePendingInjectDeliveries(session, "session_dead")
|
|
4014
|
+
this._settleAllPendingApprovals(session)
|
|
3621
4015
|
// 案B: 全購読 stream_id キーを Map から外す(primary 1 個だけ消すと dangling が残る)。
|
|
3622
4016
|
this._dropAllStreamKeys(session)
|
|
3623
4017
|
try {
|
|
@@ -3806,6 +4200,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
3806
4200
|
this._settleTurnActiveWaiter(session)
|
|
3807
4201
|
this._drainRejectQueue(session, { reason: "queue_dead" })
|
|
3808
4202
|
this._settlePendingInjectDeliveries(session, "session_dead")
|
|
4203
|
+
this._settleAllPendingApprovals(session)
|
|
3809
4204
|
// hub-agent 再起動時にターン実行中のセッションを落とすと thread が汚染されうる
|
|
3810
4205
|
// (exec 中の kill → output 欠落)。因果を後から辿れるよう必ず記録する。
|
|
3811
4206
|
this.logger?.[session.activeTurnId ? "warn" : "info"]?.(
|