@gemus/mcp-proxy 0.1.0 → 0.1.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemus/mcp-proxy",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Local stdio<->HTTP MCP proxy for Codex desktop: transparent passthrough + execute tap + imagegen backfill (Issue #1751, P1).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@modelcontextprotocol/sdk": "^1.29.0",
28
- "@gemus/codex-backfill-core": "0.1.0"
28
+ "@gemus/codex-backfill-core": "0.1.1"
29
29
  },
30
30
  "engines": {
31
31
  "node": ">=18"
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
11
11
  addImageToCanvas: vi.fn(),
12
12
  readRollout: vi.fn(),
13
13
  findRolloutFile: vi.fn(),
14
+ readGeneratedImageFiles: vi.fn(),
14
15
  }))
15
16
 
16
17
  vi.mock('@gemus/codex-backfill-core', async (importOriginal) => {
@@ -21,6 +22,7 @@ vi.mock('@gemus/codex-backfill-core', async (importOriginal) => {
21
22
  addImageToCanvas: mocks.addImageToCanvas,
22
23
  readRollout: mocks.readRollout,
23
24
  findRolloutFile: mocks.findRolloutFile,
25
+ readGeneratedImageFiles: mocks.readGeneratedImageFiles,
24
26
  }
25
27
  })
26
28
 
@@ -71,6 +73,8 @@ beforeEach(() => {
71
73
  mocks.addImageToCanvas.mockReset().mockResolvedValue({ ok: true, entityId: 'agent_x' })
72
74
  mocks.readRollout.mockReset().mockReturnValue('')
73
75
  mocks.findRolloutFile.mockReset().mockReturnValue('/fake/rollout.jsonl')
76
+ // Default: generated_images unavailable → backfill falls back to the rollout source (existing tests).
77
+ mocks.readGeneratedImageFiles.mockReset().mockReturnValue({ available: false, images: [] })
74
78
  })
75
79
 
76
80
  describe('normalizeToolName (tap → tracker)', () => {
@@ -213,6 +217,38 @@ describe('backfillTurn — idempotency + guards', () => {
213
217
  expect(r2.delivered).toBe(1)
214
218
  })
215
219
 
220
+ it('serializes concurrent passes so a racing salvage does not double-place an in-flight claim (#1751)', async () => {
221
+ // Real-machine bug (dev.log 1400–1544): a claim pass (Stop hook) and a salvage pass (idle timer)
222
+ // ran concurrently over the same session rollout. Because `seen` is only marked AFTER each slow
223
+ // awaited deliver, the salvage pass read the still-in-flight images as "fresh" and orphaned them —
224
+ // the same image landed BOTH on its planned gen node AND as a duplicate image-upload node.
225
+ const bf = makeBackfiller()
226
+ observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
227
+ observeExecuteAnchor(bf, 2, 't1', 's1', 'wf_real', 'nodeB')
228
+ mocks.readRollout.mockReturnValue(rollout([IMG_A, IMG_B]))
229
+
230
+ // deliver resolves only when released — models the 6-20s awaited media job seen in the logs.
231
+ let releaseDeliver!: () => void
232
+ const gate = new Promise<void>((res) => {
233
+ releaseDeliver = res
234
+ })
235
+ mocks.deliverImageToNode.mockImplementation(async () => {
236
+ await gate
237
+ return { ok: true, status: 'processing' }
238
+ })
239
+
240
+ // Fire the claim pass (parks on the deliver gate), then a racing salvage pass — do NOT await between.
241
+ const claimPass = bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
242
+ const salvagePass = bf.backfillTurn({ turnKey: 't1', rolloutPath: null, salvageOnly: true })
243
+ releaseDeliver()
244
+ const [claimResult, salvageResult] = await Promise.all([claimPass, salvagePass])
245
+
246
+ // Both images delivered to their planned nodes; the racing salvage placed NO duplicate orphans.
247
+ expect(claimResult).toEqual({ delivered: 2, orphaned: 0, dropped: 0 })
248
+ expect(salvageResult).toEqual({ delivered: 0, orphaned: 0, dropped: 0 })
249
+ expect(mocks.addImageToCanvas).not.toHaveBeenCalled()
250
+ })
251
+
216
252
  it('does nothing (no placement) when no workflowId was ever captured', async () => {
217
253
  const bf = makeBackfiller()
218
254
  // No tool call carried a workflowId this turn.
@@ -223,3 +259,84 @@ describe('backfillTurn — idempotency + guards', () => {
223
259
  expect(mocks.addImageToCanvas).not.toHaveBeenCalled()
224
260
  })
225
261
  })
262
+
263
+ describe('backfillTurn — footprint-0 call passthrough + timeout semantics (#1756 Layer 1a)', () => {
264
+ it('threads the injected `call` into deliver AND orphan (footprint-0)', async () => {
265
+ const call = vi.fn()
266
+ const bf = createBackfiller({
267
+ server: 'http://localhost:3000',
268
+ apiKey: 'mak_test',
269
+ codexHome: '/fake/.codex',
270
+ call,
271
+ })
272
+ observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
273
+ mocks.readRollout.mockReturnValue(rollout([IMG_A]))
274
+ mocks.deliverImageToNode.mockResolvedValue({ ok: false, code: 'ALREADY_COMPLETED' }) // force orphan fallback
275
+
276
+ await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
277
+
278
+ expect(mocks.deliverImageToNode.mock.calls[0][0]).toMatchObject({ call })
279
+ expect(mocks.addImageToCanvas.mock.calls[0][0]).toMatchObject({ call })
280
+ })
281
+
282
+ it('a timed-out deliver leaves the image FRESH — no orphan, not marked seen (no duplicate)', async () => {
283
+ const bf = makeBackfiller()
284
+ observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
285
+ mocks.readRollout.mockReturnValue(rollout([IMG_A]))
286
+ // deliver times out → result unknown; the platform job may still land, so we must NOT orphan.
287
+ mocks.deliverImageToNode.mockResolvedValue({ ok: false, timedOut: true, error: 'inject timeout' })
288
+
289
+ const r1 = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
290
+ expect(r1).toEqual({ delivered: 0, orphaned: 0, dropped: 0 })
291
+ expect(mocks.addImageToCanvas).not.toHaveBeenCalled() // NO orphan on timeout
292
+
293
+ // Image stayed fresh (not seen): a later Stop-hook pass can still deliver it once the job lands.
294
+ mocks.deliverImageToNode.mockResolvedValue({ ok: true, status: 'processing' })
295
+ const r2 = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
296
+ expect(r2.delivered).toBe(1)
297
+ })
298
+ })
299
+
300
+ describe('backfillTurn — Layer 2 file byte source (#1756)', () => {
301
+ it('uses generated_images when available and does NOT read the rollout', async () => {
302
+ const bf = makeBackfiller()
303
+ observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
304
+ // generated_images available with one fresh image → file source wins, rollout untouched.
305
+ mocks.readGeneratedImageFiles.mockReturnValue({
306
+ available: true,
307
+ images: [{ callId: 'ig_a', base64: 'iVBORw0KGgoAAAA1', mimeType: 'image/png' }],
308
+ })
309
+
310
+ const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
311
+
312
+ expect(mocks.readGeneratedImageFiles).toHaveBeenCalled()
313
+ expect(mocks.readRollout).not.toHaveBeenCalled() // file source is authoritative when available
314
+ expect(r.delivered).toBe(1)
315
+ expect(mocks.deliverImageToNode.mock.calls[0][0]).toMatchObject({ nodeId: 'nodeA', image: { callId: 'ig_a' } })
316
+ })
317
+
318
+ it('available but empty → no delivery, and does NOT fall back to rollout (mtime guard preserved)', async () => {
319
+ const bf = makeBackfiller()
320
+ observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
321
+ mocks.readGeneratedImageFiles.mockReturnValue({ available: true, images: [] })
322
+ // Even though the rollout has an image, availability=true must lock us to the (empty) file source.
323
+ mocks.readRollout.mockReturnValue(rollout([IMG_A]))
324
+
325
+ const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
326
+
327
+ expect(mocks.readRollout).not.toHaveBeenCalled()
328
+ expect(r).toEqual({ delivered: 0, orphaned: 0, dropped: 0 })
329
+ })
330
+
331
+ it('unavailable → falls back to rollout-scrape (older codex)', async () => {
332
+ const bf = makeBackfiller()
333
+ observeExecuteAnchor(bf, 1, 't1', 's1', 'wf_real', 'nodeA')
334
+ mocks.readGeneratedImageFiles.mockReturnValue({ available: false, images: [] })
335
+ mocks.readRollout.mockReturnValue(rollout([IMG_A]))
336
+
337
+ const r = await bf.backfillTurn({ turnKey: 't1', sessionId: 's1', rolloutPath: '/x', salvageOnly: false })
338
+
339
+ expect(mocks.readRollout).toHaveBeenCalledWith('/x')
340
+ expect(r.delivered).toBe(1)
341
+ })
342
+ })
package/src/backfill.mjs CHANGED
@@ -16,6 +16,10 @@
16
16
  // a later trigger no-ops. salvageOnly NEVER calls claim() — mirrors bridge salvage semantics (图不丢, 不认领).
17
17
  import * as core from '@gemus/codex-backfill-core'
18
18
 
19
+ // #1756 Layer 2 mtime cutoff:只认本进程启动后落盘的 generated_images 文件,防 proxy 重启后把该
20
+ // session 历史图当 fresh 重复交付(session_id 跨 resume 复用、seen 是进程内存)。模块加载≈proxy 启动。
21
+ const procStartMs = Date.now()
22
+
19
23
  /**
20
24
  * Normalize a raw MCP wire tool name to the tracker's expected form. The proxy sees `execute` /
21
25
  * `batch_execute` on the wire; ExecuteClaimTracker only matches `mcp__gemus__execute` /
@@ -71,9 +75,12 @@ export function injectSteering(msg) {
71
75
  * @param {string} deps.server 平台 origin(不含 /api/mcp)
72
76
  * @param {string} deps.apiKey 用户 mak_ key
73
77
  * @param {string} deps.codexHome ~/.codex(idle-timer fallback 用 findRolloutFile(session_id) 定位 rollout)
78
+ * @param {import('@gemus/codex-backfill-core').GemusToolCall} [deps.call] footprint-0 交付 transport
79
+ * (#1756 Layer 1a):省略 → connect-per-call(每图新开 key-only session);传入 → 在 proxy 已有
80
+ * upstream 连接上注入 execute/canvas_edit(不占新槽,即便 cap 被泄漏填满仍能交付)。
74
81
  * @param {(msg: string, obj?: unknown) => void} [deps.log]
75
82
  */
76
- export function createBackfiller({ server, apiKey, codexHome, log = () => {} }) {
83
+ export function createBackfiller({ server, apiKey, codexHome, call, log = () => {} }) {
77
84
  /** @type {Map<string, { tracker: InstanceType<typeof core.ExecuteClaimTracker>, workflowId?: string, sessionId?: string }>} */
78
85
  const turns = new Map() // keyed by turn_id; bounded (evict oldest) so a long session can't leak
79
86
  const TURN_CAP = 64
@@ -123,7 +130,7 @@ export function createBackfiller({ server, apiKey, codexHome, log = () => {} })
123
130
 
124
131
  /** orphan 一张图(Mode B 需真实 workflowId),**只在成功后**标记 seen;返回是否成功。 */
125
132
  async function orphanOne(workflowId, image) {
126
- const r = await core.addImageToCanvas({ server, apiKey, workflowId, image })
133
+ const r = await core.addImageToCanvas({ server, apiKey, workflowId, image, call })
127
134
  if (r.ok) {
128
135
  seen.add(image.callId)
129
136
  return true
@@ -140,17 +147,30 @@ export function createBackfiller({ server, apiKey, codexHome, log = () => {} })
140
147
  * timer 在生成中途误触发、或 session-cap 交付失败)绝不能烧掉图 —— 否则真正的 Stop hook 认领时
141
148
  * fresh 为空。seen 只在成功交付后加入;turn 状态保留供后续 authoritative Stop hook 认领。
142
149
  */
143
- async function backfillTurn({ turnKey, sessionId, rolloutPath, salvageOnly }) {
150
+ async function backfillTurnInner({ turnKey, sessionId, rolloutPath, salvageOnly }) {
144
151
  const t = turns.get(turnKey)
145
152
  const sid = sessionId || t?.sessionId
146
- const path = rolloutPath || core.findRolloutFile(codexHome, sid)
147
- const jsonl = core.readRollout(path)
148
- const all = core.extractImageOutputs(jsonl)
153
+ // #1756 Layer 2: 优先读 generated_images 落盘文件(去 rollout-scrape 脆化)。三态 fallback——
154
+ // 仅目录整体不可用(旧 codex 不落盘)才回退 rollout;目录可用即锁定文件源(即便过滤后空),
155
+ // 否则会绕过 mtime 守卫把历史图从 rollout 重新捞出重复交付。
156
+ const fileResult = sid
157
+ ? core.readGeneratedImageFiles(codexHome, sid, procStartMs)
158
+ : { available: false, images: [] }
159
+ let all
160
+ let source
161
+ if (fileResult.available) {
162
+ all = fileResult.images
163
+ source = 'generated_images'
164
+ } else {
165
+ const path = rolloutPath || core.findRolloutFile(codexHome, sid)
166
+ all = core.extractImageOutputs(core.readRollout(path))
167
+ source = path || '(none)'
168
+ }
149
169
  const fresh = all.filter((img) => !seen.has(img.callId))
150
170
 
151
171
  if (fresh.length === 0) {
152
172
  // 图还没 flush(idle 过早触发)或都已交付 —— 都不动 seen/turn 状态,留给后续 hook。
153
- log('backfill: no fresh images', { turnKey, salvageOnly, path: path || '(none)' })
173
+ log('backfill: no fresh images', { turnKey, salvageOnly, source })
154
174
  return { delivered: 0, orphaned: 0, dropped: 0 }
155
175
  }
156
176
 
@@ -175,12 +195,17 @@ export function createBackfiller({ server, apiKey, codexHome, log = () => {} })
175
195
  let delivered = 0
176
196
  let orphaned = 0
177
197
  for (const { nodeId, image } of claims) {
178
- const r = await core.deliverImageToNode({ server, apiKey, workflowId, nodeId, image })
198
+ const r = await core.deliverImageToNode({ server, apiKey, workflowId, nodeId, image, call })
179
199
  if (r.ok) {
180
200
  seen.add(image.callId)
181
201
  delivered++
202
+ } else if (r.timedOut) {
203
+ // #1756: 注入交付超时 = 结果未知(平台 job 可能仍在跑并最终写 DB)。**不 orphan、不标 seen**——
204
+ // 否则同图既落节点又多一个 image-upload 孤儿。留 fresh,交给下轮 pass(Stop hook/idle)在 job
205
+ // 落地后经 execute 幂等门重判(already_completed 才安全 orphan)。
206
+ log('backfill: deliver timed out — leaving image fresh (no orphan)', { nodeId, callId: image.callId })
182
207
  } else if (await orphanOne(workflowId, image)) {
183
- // already_completed / 其它失败 → orphan 保底(镜像 bridge:新图未落节点则至少 image-upload)。
208
+ // already_completed / 其它确定性失败 → orphan 保底(镜像 bridge:新图未落节点则至少 image-upload)。
184
209
  orphaned++
185
210
  } else {
186
211
  log('backfill: deliver+orphan both failed', { nodeId, callId: image.callId, error: r.error ?? r.code })
@@ -191,5 +216,20 @@ export function createBackfiller({ server, apiKey, codexHome, log = () => {} })
191
216
  return { delivered, orphaned, dropped }
192
217
  }
193
218
 
219
+ // Serialize backfillTurn. The Stop-hook claim pass and the idle-timer salvage pass can fire
220
+ // concurrently over the same session rollout; `seen` is only marked AFTER each slow awaited deliver,
221
+ // so a racing pass would read still-in-flight images as "fresh" and orphan them — the same image
222
+ // then lands BOTH on its planned gen node AND as a duplicate image-upload node (#1751 real-machine
223
+ // bug, dev.log 1400–1544). Running passes one-at-a-time makes `seen` authoritative for the next pass
224
+ // WITHOUT weakening the seen-on-success invariant (a failed deliver stays fresh, so a later hook still
225
+ // retries it). Passes are meant to be idempotent alternatives, not parallel work — serializing costs
226
+ // nothing but the (already-awaited) wait for the winner.
227
+ let tail = Promise.resolve()
228
+ function backfillTurn(opts) {
229
+ const result = tail.then(() => backfillTurnInner(opts))
230
+ tail = result.catch(() => {}) // advance the chain even if a pass throws; callers still see the rejection
231
+ return result
232
+ }
233
+
194
234
  return { observeCall, observeResult, backfillTurn, _turns: turns, _seen: seen }
195
235
  }
package/src/proxy.mjs CHANGED
@@ -24,6 +24,7 @@ import path from 'node:path'
24
24
  import fs from 'node:fs'
25
25
  import { randomBytes } from 'node:crypto'
26
26
  import { createBackfiller, injectSteering } from './backfill.mjs'
27
+ import { unwrapMcpContent } from '@gemus/codex-backfill-core'
27
28
 
28
29
  const KEY = process.env.GEMUS_KEY || ''
29
30
  const GEMUS_URL = process.env.GEMUS_URL || 'https://gemus.ai/api/mcp'
@@ -33,16 +34,68 @@ const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
33
34
  // calls, so a short idle looks like "turn ended" mid-generation and fires prematurely. A trusted Stop
34
35
  // hook fires at turn end and clears this timer, so trusted users never hit it (#1751 real-machine fix).
35
36
  const IDLE_MS = Number(process.env.PROXY_BACKFILL_IDLE_MS) || 180_000
37
+ // footprint-0 注入交付超时(#1756 Layer 1a)。必须 ≥ 平台对 codex-imagen 回填节点的同步等待上限
38
+ // (deriveWaitTimeoutMs('gen-image-generation')=180s,见 src/server/generation/waitTimeout.ts),否则
39
+ // proxy 先超时 → 假失败 → orphan 回退 → 同图重复交付。240s = 180s + 余量。别调到 180s 以下。
40
+ const INJECT_TIMEOUT_MS = Number(process.env.PROXY_INJECT_TIMEOUT_MS) || 240_000
36
41
  const LOG = process.env.PROXY_LOG // optional debug log path
37
42
  const log = (dir, m) => {
38
43
  if (!LOG) return
39
44
  fs.appendFileSync(LOG, `[${new Date().toISOString()}] ${dir} ${typeof m === 'string' ? m : JSON.stringify(m).slice(0, 1000)}\n`)
40
45
  }
41
46
 
47
+ // ── footprint-0 交付注入(#1756 Layer 1a)─────────────────────────────────────────────────────────
48
+ // 交付/orphan 不再每图新开 key-only session(connect-per-call,抢 KEY_ONLY_SESSION_CAP 槽),而是把
49
+ // execute/canvas_edit 的 tools/call **注入到 proxy 已建好、已计入 cap 的那条 upstream 连接**上(footprint
50
+ // 0)——即便 cap 被泄漏填满也能交付。注入用字符串 id 命名空间 `gemus-bf-*`(绝不撞 Codex 的数字 id),
51
+ // 响应在 upstream.onmessage 里按 id 自消费、不转发给 Codex。`upstream` 在下方声明,注入在 turn 末才调,
52
+ // 故闭包内按调用时解析、无 TDZ 问题。
53
+ /** in-flight 注入:id(string) → { settle }。settle 幂等收敛(响应/超时/取消/关停任一先到即结算)。 */
54
+ const pendingInjections = new Map()
55
+ let injectSeq = 0
56
+
57
+ /** 把 JSON-RPC 响应转成与 core `callGemusTool` 逐字一致的 CallGemusToolResult 形状。 */
58
+ function interpretInjection(m) {
59
+ if (m?.error) return { ok: false, error: m.error.message || `MCP error ${m.error.code}` }
60
+ const result = m?.result
61
+ if (result && result.isError === true) {
62
+ const first = Array.isArray(result.content) ? result.content[0] : undefined
63
+ return { ok: false, error: (first && first.text) || 'MCP tool error' }
64
+ }
65
+ return { ok: true, result: unwrapMcpContent(result && result.content) }
66
+ }
67
+
68
+ /** @type {import('@gemus/codex-backfill-core').GemusToolCall} */
69
+ function injectedCall(toolName, args, opts = {}) {
70
+ const { progressToken, signal } = opts
71
+ const id = `gemus-bf-${++injectSeq}`
72
+ const req = {
73
+ jsonrpc: '2.0',
74
+ id,
75
+ method: 'tools/call',
76
+ params: { name: toolName, arguments: args, ...(progressToken ? { _meta: { progressToken } } : {}) },
77
+ }
78
+ return new Promise((resolve) => {
79
+ const settle = (v) => {
80
+ clearTimeout(timer)
81
+ if (signal) signal.removeEventListener('abort', onAbort)
82
+ pendingInjections.delete(id)
83
+ resolve(v)
84
+ }
85
+ const onAbort = () => settle({ ok: false, error: 'aborted' })
86
+ const timer = setTimeout(() => settle({ ok: false, timedOut: true, error: 'inject timeout' }), INJECT_TIMEOUT_MS)
87
+ pendingInjections.set(id, { settle })
88
+ if (signal) signal.addEventListener('abort', onAbort)
89
+ upstream.send(req).catch((e) => settle({ ok: false, error: String(e) }))
90
+ log('INJECT', { id, toolName, progressToken })
91
+ })
92
+ }
93
+
42
94
  const backfiller = createBackfiller({
43
95
  server: SERVER_ORIGIN,
44
96
  apiKey: KEY,
45
97
  codexHome: CODEX_HOME,
98
+ call: injectedCall,
46
99
  log: (msg, obj) => log('BACKFILL', obj ? `${msg} ${JSON.stringify(obj)}` : msg),
47
100
  })
48
101
 
@@ -196,18 +249,49 @@ const upstream = new StreamableHTTPClientTransport(new URL(GEMUS_URL), {
196
249
  const stdio = new StdioServerTransport()
197
250
 
198
251
  stdio.onmessage = (m) => { tapClientToServer(m); log('C->G', m); upstream.send(m).catch((e) => log('ERR-http-send', String(e))) }
199
- upstream.onmessage = (m) => { tapServerToClient(m); steerOnInit(m); log('G->C', m); stdio.send(m).catch((e) => log('ERR-stdio-send', String(e))) }
252
+ upstream.onmessage = (m) => {
253
+ // footprint-0 (#1756): our injected execute/canvas_edit responses carry a `gemus-bf-*` string id.
254
+ // Self-consume them here (settle the pending injection) and DO NOT forward to Codex — Codex never
255
+ // issued these calls, so it must never see the responses.
256
+ if (typeof m?.id === 'string' && m.id.startsWith('gemus-bf-')) {
257
+ const p = pendingInjections.get(m.id)
258
+ if (p) p.settle(interpretInjection(m))
259
+ return
260
+ }
261
+ // #1756 liveness heartbeat: our own `ping` pongs (gemus-ping-*) are self-consumed — Codex never
262
+ // issued them, so it must never see the responses.
263
+ if (typeof m?.id === 'string' && m.id.startsWith('gemus-ping-')) return
264
+ tapServerToClient(m); steerOnInit(m); log('G->C', m); stdio.send(m).catch((e) => log('ERR-stdio-send', String(e)))
265
+ }
200
266
 
201
267
  // ── session hygiene (spike: KEY_ONLY_SESSION_CAP=3) ────────────────────────────────────────────
202
268
  // We must DELETE the upstream session or we leak a key-only slot when codex ends the conversation.
203
269
  // NOTE: transport.close() does NOT issue DELETE (it only aborts + fires onclose, streamableHttp.js:280);
204
270
  // terminateSession() sends the DELETE (:431). Call it first, then close(). Also clean up local artifacts.
271
+ // #1756 proxy-death detection: send an MCP `ping` every 30s so the platform can tell a live proxy
272
+ // (keeps pinging) from a dead one (hard-killed → no graceful DELETE → leaked session). This is
273
+ // **independent of Codex tool activity**, so a proxy mid-imagegen (silent to Codex for minutes) still
274
+ // proves liveness → the platform reclaims only genuinely-dead sessions (short floor), never a live one.
275
+ const HEARTBEAT_MS = Number(process.env.PROXY_HEARTBEAT_MS) || 30_000
276
+ let pingSeq = 0
277
+ let heartbeatTimer
278
+ function startHeartbeat() {
279
+ heartbeatTimer = setInterval(() => {
280
+ upstream.send({ jsonrpc: '2.0', id: `gemus-ping-${++pingSeq}`, method: 'ping' }).catch((e) => log('PING-ERR', String(e)))
281
+ }, HEARTBEAT_MS)
282
+ if (typeof heartbeatTimer.unref === 'function') heartbeatTimer.unref() // don't keep the process alive on this alone
283
+ }
284
+
205
285
  let shuttingDown = false
206
286
  async function shutdown() {
207
287
  if (shuttingDown) return
208
288
  shuttingDown = true
289
+ if (heartbeatTimer) clearInterval(heartbeatTimer)
209
290
  for (const t of idleTimers.values()) clearTimeout(t)
210
291
  idleTimers.clear()
292
+ // Settle any in-flight footprint-0 injections so a mid-delivery shutdown doesn't hang their awaits.
293
+ for (const p of pendingInjections.values()) p.settle({ ok: false, error: 'proxy shutting down' })
294
+ pendingInjections.clear()
211
295
  try { hookServer.close() } catch { /* ignore */ }
212
296
  if (discoveryFile) { try { fs.unlinkSync(discoveryFile) } catch { /* ignore */ } }
213
297
  try { await upstream.terminateSession() } catch { /* ignore */ }
@@ -223,4 +307,5 @@ process.on('SIGINT', shutdown)
223
307
 
224
308
  await upstream.start()
225
309
  await stdio.start()
226
- log('STARTED', { GEMUS_URL, SERVER_ORIGIN, hasKey: Boolean(KEY), pid: process.pid, idleMs: IDLE_MS })
310
+ startHeartbeat()
311
+ log('STARTED', { GEMUS_URL, SERVER_ORIGIN, hasKey: Boolean(KEY), pid: process.pid, idleMs: IDLE_MS, heartbeatMs: HEARTBEAT_MS })