@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.
@@ -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", { clientInfo: CLIENT_INFO })
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", { clientInfo: CLIENT_INFO })
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
- session.pendingApprovals.set(msg.id, {
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
- if (info.turnId !== completedTurnId) continue
2585
- session.pendingApprovals.delete(requestId)
2586
- session.client.respond(requestId, { decision: "decline" })
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
- session.pendingApprovals.delete(request_id)
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"]?.(