@raysonmeng/agentbridge 0.1.7 → 0.1.8

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.
@@ -12,7 +12,7 @@
12
12
  {
13
13
  "name": "agentbridge",
14
14
  "description": "Bridge Claude Code and Codex through a shared daemon, push channel delivery, and reply/get_messages tools.",
15
- "version": "0.1.7",
15
+ "version": "0.1.8",
16
16
  "author": {
17
17
  "name": "AgentBridge Contributors",
18
18
  "email": "raysonmeng@qq.com"
package/dist/cli.js CHANGED
@@ -120,7 +120,7 @@ function parsePositiveIntEnv(name, fallback, log = () => {}, env = process.env)
120
120
  var require_package = __commonJS((exports, module) => {
121
121
  module.exports = {
122
122
  name: "@raysonmeng/agentbridge",
123
- version: "0.1.7",
123
+ version: "0.1.8",
124
124
  description: "Bridge between Claude Code and Codex \u2014 bidirectional agent communication via MCP Channel + JSON-RPC",
125
125
  type: "module",
126
126
  packageManager: "bun@1.3.11",
@@ -1047,7 +1047,7 @@ var init_daemon_client = __esm(() => {
1047
1047
  this.ws = null;
1048
1048
  this.rejectPendingReplies("Daemon connection closed");
1049
1049
  }
1050
- async sendReply(message, requireReply) {
1050
+ async sendReply(message, requireReply, onBusy) {
1051
1051
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1052
1052
  return { success: false, error: "AgentBridge daemon is not connected." };
1053
1053
  }
@@ -1062,7 +1062,8 @@ var init_daemon_client = __esm(() => {
1062
1062
  type: "claude_to_codex",
1063
1063
  requestId,
1064
1064
  message,
1065
- ...requireReply ? { requireReply: true } : {}
1065
+ ...requireReply ? { requireReply: true } : {},
1066
+ ...onBusy && onBusy !== "reject" ? { onBusy } : {}
1066
1067
  });
1067
1068
  });
1068
1069
  }
@@ -1164,8 +1165,8 @@ function formatBuildInfo(build) {
1164
1165
  var BUILD_INFO;
1165
1166
  var init_build_info = __esm(() => {
1166
1167
  BUILD_INFO = Object.freeze({
1167
- version: defineString("0.1.7", "0.0.0-source"),
1168
- commit: defineString("1df8b91", "source"),
1168
+ version: defineString("0.1.8", "0.0.0-source"),
1169
+ commit: defineString("c80a7fd", "source"),
1169
1170
  bundle: defineBundle("dist"),
1170
1171
  contractVersion: defineNumber(1, CONTRACT_VERSION)
1171
1172
  });
package/dist/daemon.js CHANGED
@@ -17,8 +17,8 @@ function defineNumber(value, fallback) {
17
17
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
18
18
  }
19
19
  var BUILD_INFO = Object.freeze({
20
- version: defineString("0.1.7", "0.0.0-source"),
21
- commit: defineString("1df8b91", "source"),
20
+ version: defineString("0.1.8", "0.0.0-source"),
21
+ commit: defineString("c80a7fd", "source"),
22
22
  bundle: defineBundle("dist"),
23
23
  contractVersion: defineNumber(1, CONTRACT_VERSION)
24
24
  });
@@ -639,6 +639,7 @@ class CodexAdapter extends EventEmitter {
639
639
  pendingServerResponses = new Map;
640
640
  staleProxyIds = new Map;
641
641
  bridgeRequestIds = new Map;
642
+ bridgeRequestKinds = new Map;
642
643
  intentionalDisconnect = false;
643
644
  pendingTuiMessages = [];
644
645
  reconnectingForNewSession = false;
@@ -797,6 +798,36 @@ class CodexAdapter extends EventEmitter {
797
798
  return false;
798
799
  }
799
800
  }
801
+ steerMessage(text) {
802
+ if (!this.threadId) {
803
+ this.log("Cannot steer: no active thread");
804
+ return false;
805
+ }
806
+ if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) {
807
+ this.log("Cannot steer: app-server WebSocket not connected");
808
+ return false;
809
+ }
810
+ if (!this.turnInProgress) {
811
+ this.log("Cannot steer: no turn in progress (use injectMessage)");
812
+ return false;
813
+ }
814
+ this.log(`Steering message into active Codex turn (${text.length} chars)`);
815
+ const requestId = this.nextInjectionId--;
816
+ this.trackBridgeRequestId(requestId, "steer");
817
+ const params = { threadId: this.threadId, input: [{ type: "text", text }] };
818
+ try {
819
+ this.appServerWs.send(JSON.stringify({
820
+ method: "turn/steer",
821
+ id: requestId,
822
+ params
823
+ }));
824
+ return true;
825
+ } catch (err) {
826
+ this.untrackBridgeRequestId(requestId);
827
+ this.log(`Steer send failed: ${err.message}`);
828
+ return false;
829
+ }
830
+ }
800
831
  async waitForHealthy(maxRetries = 20, delayMs = 500) {
801
832
  for (let i = 0;i < maxRetries; i++) {
802
833
  try {
@@ -1519,14 +1550,22 @@ class CodexAdapter extends EventEmitter {
1519
1550
  this.interceptServerMessage(parsed, mapping.connId);
1520
1551
  return forwarded;
1521
1552
  }
1522
- if (!isNaN(numericId) && this.consumeBridgeRequestId(numericId)) {
1553
+ const bridgeKind = !isNaN(numericId) ? this.consumeBridgeRequestId(numericId) : null;
1554
+ if (bridgeKind) {
1523
1555
  if (parsed.error) {
1524
- this.log(`Bridge-originated request failed (id ${responseId}): ${parsed.error.message ?? "unknown error"}`);
1525
- this.lastTurnEndedAbnormally = true;
1526
- this.emit("turnAborted", `injected turn/start rejected: ${parsed.error.message ?? "unknown error"}`);
1527
- this.notifyPhaseIfChanged();
1556
+ this.log(`Bridge-originated ${bridgeKind} request failed (id ${responseId}): ${parsed.error.message ?? "unknown error"}`);
1557
+ if (bridgeKind === "steer") {
1558
+ this.emit("steerFailed", parsed.error.message ?? "unknown error");
1559
+ } else {
1560
+ this.lastTurnEndedAbnormally = true;
1561
+ this.emit("turnAborted", `injected turn/start rejected: ${parsed.error.message ?? "unknown error"}`);
1562
+ this.notifyPhaseIfChanged();
1563
+ }
1528
1564
  } else {
1529
- this.log(`Bridge-originated request completed (id ${responseId})`);
1565
+ this.log(`Bridge-originated ${bridgeKind} request completed (id ${responseId})`);
1566
+ if (bridgeKind === "steer") {
1567
+ this.emit("steerAccepted");
1568
+ }
1530
1569
  }
1531
1570
  return null;
1532
1571
  }
@@ -1877,18 +1916,23 @@ class CodexAdapter extends EventEmitter {
1877
1916
  consumeStaleProxyId(proxyId) {
1878
1917
  return this.clearTrackedId(this.staleProxyIds, proxyId);
1879
1918
  }
1880
- trackBridgeRequestId(requestId) {
1919
+ trackBridgeRequestId(requestId, kind = "turn-start") {
1881
1920
  this.clearTrackedId(this.bridgeRequestIds, requestId);
1882
1921
  const timer = setTimeout(() => {
1883
1922
  this.bridgeRequestIds.delete(requestId);
1923
+ this.bridgeRequestKinds.delete(requestId);
1884
1924
  }, CodexAdapter.RESPONSE_TRACKING_TTL_MS);
1885
1925
  timer.unref?.();
1886
1926
  this.bridgeRequestIds.set(requestId, timer);
1927
+ this.bridgeRequestKinds.set(requestId, kind);
1887
1928
  }
1888
1929
  consumeBridgeRequestId(requestId) {
1889
- return this.clearTrackedId(this.bridgeRequestIds, requestId);
1930
+ const kind = this.bridgeRequestKinds.get(requestId) ?? "turn-start";
1931
+ this.bridgeRequestKinds.delete(requestId);
1932
+ return this.clearTrackedId(this.bridgeRequestIds, requestId) ? kind : null;
1890
1933
  }
1891
1934
  untrackBridgeRequestId(requestId) {
1935
+ this.bridgeRequestKinds.delete(requestId);
1892
1936
  this.clearTrackedId(this.bridgeRequestIds, requestId);
1893
1937
  }
1894
1938
  clearTrackedId(store, id) {
@@ -1910,6 +1954,7 @@ class CodexAdapter extends EventEmitter {
1910
1954
  clearTimeout(timer);
1911
1955
  }
1912
1956
  this.bridgeRequestIds.clear();
1957
+ this.bridgeRequestKinds.clear();
1913
1958
  }
1914
1959
  clearResponseTrackingState() {
1915
1960
  this.clearTransientResponseTrackingState();
@@ -3947,6 +3992,13 @@ codex.on("turnPhaseChanged", ({ phase, previous }) => {
3947
3992
  tryWriteStatusFile(`turnPhase:${phase}`);
3948
3993
  broadcastStatus();
3949
3994
  });
3995
+ codex.on("steerFailed", (reason) => {
3996
+ log(`Steer rejected by app-server: ${reason}`);
3997
+ emitToClaude(systemMessage("system_steer_failed", `\u26A0\uFE0F Your steer message did NOT reach Codex (${reason}). The original turn continues unaffected \u2014 wait for it to finish, or resend as a normal reply.`));
3998
+ });
3999
+ codex.on("steerAccepted", () => {
4000
+ log("Steer accepted by app-server");
4001
+ });
3950
4002
  codex.on("turnStarted", () => {
3951
4003
  log("Codex turn started");
3952
4004
  emitToClaude(systemMessage("system_turn_started", "\u23F3 Codex is working on the current task. Wait for completion before sending a reply."));
@@ -4184,9 +4236,36 @@ function handleControlMessage(ws, raw) {
4184
4236
  }
4185
4237
  log(`Forwarding Claude \u2192 Codex (${message.message.content.length} chars, requireReply=${requireReply})`);
4186
4238
  const tierOverrides = BUDGET_CONFIG.codexTierControl ? budgetCoordinator?.getCodexTurnOverrides() ?? undefined : undefined;
4239
+ if (codex.turnInProgress && message.onBusy === "steer") {
4240
+ if (requireReply) {
4241
+ sendProtocolMessage(ws, {
4242
+ type: "claude_to_codex_result",
4243
+ requestId: message.requestId,
4244
+ success: false,
4245
+ error: 'require_reply is not supported together with on_busy="steer" yet. Send the steer without require_reply, or wait for the turn to finish.'
4246
+ });
4247
+ return;
4248
+ }
4249
+ const steerContent = `[STEER from Claude]
4250
+ ` + `Mid-turn update for the current Codex turn. Integrate if relevant; do not restart work unless explicitly requested.
4251
+
4252
+ ` + message.message.content;
4253
+ const steered = codex.steerMessage(steerContent);
4254
+ log(`Steer ${steered ? "transport-accepted" : "failed"} (${message.message.content.length} chars)`);
4255
+ if (steered) {
4256
+ clearAttentionWindow();
4257
+ }
4258
+ sendProtocolMessage(ws, {
4259
+ type: "claude_to_codex_result",
4260
+ requestId: message.requestId,
4261
+ success: steered,
4262
+ error: steered ? undefined : "Steer failed: the turn may have just ended or the connection dropped \u2014 retry as a normal reply."
4263
+ });
4264
+ return;
4265
+ }
4187
4266
  const injected = codex.injectMessage(contentToSend, tierOverrides);
4188
4267
  if (!injected) {
4189
- const reason = codex.turnInProgress ? "Codex is busy executing a turn. Wait for it to finish before sending another message." : "Injection failed: no active thread or WebSocket not connected.";
4268
+ const reason = codex.turnInProgress ? 'Codex is busy executing a turn. Options: wait for it to finish, or retry with on_busy="steer" to feed this message into the running turn without interrupting it.' : "Injection failed: no active thread or WebSocket not connected.";
4190
4269
  log(`Injection rejected: ${reason}`);
4191
4270
  sendProtocolMessage(ws, {
4192
4271
  type: "claude_to_codex_result",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raysonmeng/agentbridge",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Bridge between Claude Code and Codex — bidirectional agent communication via MCP Channel + JSON-RPC",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.11",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentbridge",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Bridge Claude Code and Codex with a shared daemon, push channel delivery, and bidirectional reply tooling.",
5
5
  "author": {
6
6
  "name": "AgentBridge Contributors",
@@ -13947,7 +13947,7 @@ var CLAUDE_INSTRUCTIONS = [
13947
13947
  "## Turn coordination",
13948
13948
  "- When you see '\u23F3 Codex is working', do NOT call the reply tool \u2014 wait for '\u2705 Codex finished'.",
13949
13949
  "- After Codex finishes a turn, you have an attention window to review and respond before new messages arrive.",
13950
- "- If the reply tool returns a busy error, Codex is still executing \u2014 wait and try again later.",
13950
+ '- If the reply tool returns a busy error, Codex is still executing. You decide: wait and retry later, or resend with on_busy="steer" to feed the message INTO the running turn (good for mid-course corrections; it does not interrupt or restart the work).',
13951
13951
  "",
13952
13952
  "## Budget awareness",
13953
13953
  "- Use the get_budget tool to check both agents' subscription quota (5h/weekly windows, drift, pause state).",
@@ -14099,6 +14099,11 @@ ${formatted}`
14099
14099
  require_reply: {
14100
14100
  type: "boolean",
14101
14101
  description: "When true, Codex is required to send a reply. All Codex messages from this turn will be forwarded immediately (bypassing STATUS buffering). Use this when you need a direct answer from Codex."
14102
+ },
14103
+ on_busy: {
14104
+ type: "string",
14105
+ enum: ["reject", "steer"],
14106
+ description: `What to do when Codex is mid-turn. "reject" (default): fail with a busy error \u2014 wait and retry. "steer": feed this message INTO the running turn \u2014 Codex sees it immediately and integrates it without losing work. Use steer for mid-course corrections, added constraints, or updated acceptance criteria; it does NOT start a new turn, so don't combine it with require_reply. If you need Codex to STOP and do something else, wait for the turn to finish (interrupt support is coming separately).`
14102
14107
  }
14103
14108
  },
14104
14109
  required: ["text"]
@@ -14157,6 +14162,20 @@ ${formatted}`
14157
14162
  };
14158
14163
  }
14159
14164
  const requireReply = args?.require_reply === true;
14165
+ const onBusyRaw = args?.on_busy;
14166
+ const onBusy = onBusyRaw === "steer" ? "steer" : "reject";
14167
+ if (onBusyRaw !== undefined && onBusyRaw !== "reject" && onBusyRaw !== "steer") {
14168
+ return {
14169
+ content: [{ type: "text", text: `Error: invalid on_busy value ${JSON.stringify(onBusyRaw)} \u2014 use "reject" or "steer".` }],
14170
+ isError: true
14171
+ };
14172
+ }
14173
+ if (onBusy === "steer" && requireReply) {
14174
+ return {
14175
+ content: [{ type: "text", text: 'Error: require_reply cannot be combined with on_busy="steer" yet \u2014 a steer joins the RUNNING turn instead of starting a new one, so reply tracking would mis-arm. Send the steer without require_reply.' }],
14176
+ isError: true
14177
+ };
14178
+ }
14160
14179
  const bridgeMsg = {
14161
14180
  id: args?.chat_id ?? `reply_${Date.now()}`,
14162
14181
  source: "claude",
@@ -14170,7 +14189,7 @@ ${formatted}`
14170
14189
  isError: true
14171
14190
  };
14172
14191
  }
14173
- const result = await this.replySender(bridgeMsg, requireReply);
14192
+ const result = await this.replySender(bridgeMsg, requireReply, onBusy);
14174
14193
  if (!result.success) {
14175
14194
  this.log(`Reply delivery failed: ${result.error}`);
14176
14195
  return {
@@ -14179,7 +14198,7 @@ ${formatted}`
14179
14198
  };
14180
14199
  }
14181
14200
  const pending = this.pendingMessages.length;
14182
- let responseText = "Reply sent to Codex.";
14201
+ let responseText = onBusy === "steer" ? "Reply sent to Codex (will be steered into the running turn if one is active; watch for a system_steer_failed notice if the app-server rejects it)." : "Reply sent to Codex.";
14183
14202
  if (pending > 0) {
14184
14203
  responseText += ` Note: ${pending} unread Codex message${pending > 1 ? "s" : ""} already waiting \u2014 call get_messages to read them.`;
14185
14204
  }
@@ -14208,8 +14227,8 @@ function defineNumber(value, fallback) {
14208
14227
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
14209
14228
  }
14210
14229
  var BUILD_INFO = Object.freeze({
14211
- version: defineString("0.1.7", "0.0.0-source"),
14212
- commit: defineString("1df8b91", "source"),
14230
+ version: defineString("0.1.8", "0.0.0-source"),
14231
+ commit: defineString("c80a7fd", "source"),
14213
14232
  bundle: defineBundle("plugin"),
14214
14233
  contractVersion: defineNumber(1, CONTRACT_VERSION)
14215
14234
  });
@@ -14381,7 +14400,7 @@ class DaemonClient extends EventEmitter2 {
14381
14400
  this.ws = null;
14382
14401
  this.rejectPendingReplies("Daemon connection closed");
14383
14402
  }
14384
- async sendReply(message, requireReply) {
14403
+ async sendReply(message, requireReply, onBusy) {
14385
14404
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
14386
14405
  return { success: false, error: "AgentBridge daemon is not connected." };
14387
14406
  }
@@ -14396,7 +14415,8 @@ class DaemonClient extends EventEmitter2 {
14396
14415
  type: "claude_to_codex",
14397
14416
  requestId,
14398
14417
  message,
14399
- ...requireReply ? { requireReply: true } : {}
14418
+ ...requireReply ? { requireReply: true } : {},
14419
+ ...onBusy && onBusy !== "reject" ? { onBusy } : {}
14400
14420
  });
14401
14421
  });
14402
14422
  }
@@ -15416,7 +15436,7 @@ if (process.env.AGENTBRIDGE_TRACE === "1") {
15416
15436
  });
15417
15437
  } catch {}
15418
15438
  }
15419
- claude.setReplySender(async (msg, requireReply) => {
15439
+ claude.setReplySender(async (msg, requireReply, onBusy) => {
15420
15440
  if (msg.source !== "claude") {
15421
15441
  return { success: false, error: "Invalid message source" };
15422
15442
  }
@@ -15426,7 +15446,7 @@ claude.setReplySender(async (msg, requireReply) => {
15426
15446
  error: disabledReplyError(daemonDisabledReason ?? "killed")
15427
15447
  };
15428
15448
  }
15429
- return daemonClient.sendReply(msg, requireReply);
15449
+ return daemonClient.sendReply(msg, requireReply, onBusy);
15430
15450
  });
15431
15451
  daemonClient.on("codexMessage", (message) => {
15432
15452
  log(`Forwarding daemon \u2192 Claude (${message.content.length} chars)`);
@@ -17,8 +17,8 @@ function defineNumber(value, fallback) {
17
17
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
18
18
  }
19
19
  var BUILD_INFO = Object.freeze({
20
- version: defineString("0.1.7", "0.0.0-source"),
21
- commit: defineString("1df8b91", "source"),
20
+ version: defineString("0.1.8", "0.0.0-source"),
21
+ commit: defineString("c80a7fd", "source"),
22
22
  bundle: defineBundle("plugin"),
23
23
  contractVersion: defineNumber(1, CONTRACT_VERSION)
24
24
  });
@@ -639,6 +639,7 @@ class CodexAdapter extends EventEmitter {
639
639
  pendingServerResponses = new Map;
640
640
  staleProxyIds = new Map;
641
641
  bridgeRequestIds = new Map;
642
+ bridgeRequestKinds = new Map;
642
643
  intentionalDisconnect = false;
643
644
  pendingTuiMessages = [];
644
645
  reconnectingForNewSession = false;
@@ -797,6 +798,36 @@ class CodexAdapter extends EventEmitter {
797
798
  return false;
798
799
  }
799
800
  }
801
+ steerMessage(text) {
802
+ if (!this.threadId) {
803
+ this.log("Cannot steer: no active thread");
804
+ return false;
805
+ }
806
+ if (!this.appServerWs || this.appServerWs.readyState !== WebSocket.OPEN) {
807
+ this.log("Cannot steer: app-server WebSocket not connected");
808
+ return false;
809
+ }
810
+ if (!this.turnInProgress) {
811
+ this.log("Cannot steer: no turn in progress (use injectMessage)");
812
+ return false;
813
+ }
814
+ this.log(`Steering message into active Codex turn (${text.length} chars)`);
815
+ const requestId = this.nextInjectionId--;
816
+ this.trackBridgeRequestId(requestId, "steer");
817
+ const params = { threadId: this.threadId, input: [{ type: "text", text }] };
818
+ try {
819
+ this.appServerWs.send(JSON.stringify({
820
+ method: "turn/steer",
821
+ id: requestId,
822
+ params
823
+ }));
824
+ return true;
825
+ } catch (err) {
826
+ this.untrackBridgeRequestId(requestId);
827
+ this.log(`Steer send failed: ${err.message}`);
828
+ return false;
829
+ }
830
+ }
800
831
  async waitForHealthy(maxRetries = 20, delayMs = 500) {
801
832
  for (let i = 0;i < maxRetries; i++) {
802
833
  try {
@@ -1519,14 +1550,22 @@ class CodexAdapter extends EventEmitter {
1519
1550
  this.interceptServerMessage(parsed, mapping.connId);
1520
1551
  return forwarded;
1521
1552
  }
1522
- if (!isNaN(numericId) && this.consumeBridgeRequestId(numericId)) {
1553
+ const bridgeKind = !isNaN(numericId) ? this.consumeBridgeRequestId(numericId) : null;
1554
+ if (bridgeKind) {
1523
1555
  if (parsed.error) {
1524
- this.log(`Bridge-originated request failed (id ${responseId}): ${parsed.error.message ?? "unknown error"}`);
1525
- this.lastTurnEndedAbnormally = true;
1526
- this.emit("turnAborted", `injected turn/start rejected: ${parsed.error.message ?? "unknown error"}`);
1527
- this.notifyPhaseIfChanged();
1556
+ this.log(`Bridge-originated ${bridgeKind} request failed (id ${responseId}): ${parsed.error.message ?? "unknown error"}`);
1557
+ if (bridgeKind === "steer") {
1558
+ this.emit("steerFailed", parsed.error.message ?? "unknown error");
1559
+ } else {
1560
+ this.lastTurnEndedAbnormally = true;
1561
+ this.emit("turnAborted", `injected turn/start rejected: ${parsed.error.message ?? "unknown error"}`);
1562
+ this.notifyPhaseIfChanged();
1563
+ }
1528
1564
  } else {
1529
- this.log(`Bridge-originated request completed (id ${responseId})`);
1565
+ this.log(`Bridge-originated ${bridgeKind} request completed (id ${responseId})`);
1566
+ if (bridgeKind === "steer") {
1567
+ this.emit("steerAccepted");
1568
+ }
1530
1569
  }
1531
1570
  return null;
1532
1571
  }
@@ -1877,18 +1916,23 @@ class CodexAdapter extends EventEmitter {
1877
1916
  consumeStaleProxyId(proxyId) {
1878
1917
  return this.clearTrackedId(this.staleProxyIds, proxyId);
1879
1918
  }
1880
- trackBridgeRequestId(requestId) {
1919
+ trackBridgeRequestId(requestId, kind = "turn-start") {
1881
1920
  this.clearTrackedId(this.bridgeRequestIds, requestId);
1882
1921
  const timer = setTimeout(() => {
1883
1922
  this.bridgeRequestIds.delete(requestId);
1923
+ this.bridgeRequestKinds.delete(requestId);
1884
1924
  }, CodexAdapter.RESPONSE_TRACKING_TTL_MS);
1885
1925
  timer.unref?.();
1886
1926
  this.bridgeRequestIds.set(requestId, timer);
1927
+ this.bridgeRequestKinds.set(requestId, kind);
1887
1928
  }
1888
1929
  consumeBridgeRequestId(requestId) {
1889
- return this.clearTrackedId(this.bridgeRequestIds, requestId);
1930
+ const kind = this.bridgeRequestKinds.get(requestId) ?? "turn-start";
1931
+ this.bridgeRequestKinds.delete(requestId);
1932
+ return this.clearTrackedId(this.bridgeRequestIds, requestId) ? kind : null;
1890
1933
  }
1891
1934
  untrackBridgeRequestId(requestId) {
1935
+ this.bridgeRequestKinds.delete(requestId);
1892
1936
  this.clearTrackedId(this.bridgeRequestIds, requestId);
1893
1937
  }
1894
1938
  clearTrackedId(store, id) {
@@ -1910,6 +1954,7 @@ class CodexAdapter extends EventEmitter {
1910
1954
  clearTimeout(timer);
1911
1955
  }
1912
1956
  this.bridgeRequestIds.clear();
1957
+ this.bridgeRequestKinds.clear();
1913
1958
  }
1914
1959
  clearResponseTrackingState() {
1915
1960
  this.clearTransientResponseTrackingState();
@@ -3947,6 +3992,13 @@ codex.on("turnPhaseChanged", ({ phase, previous }) => {
3947
3992
  tryWriteStatusFile(`turnPhase:${phase}`);
3948
3993
  broadcastStatus();
3949
3994
  });
3995
+ codex.on("steerFailed", (reason) => {
3996
+ log(`Steer rejected by app-server: ${reason}`);
3997
+ emitToClaude(systemMessage("system_steer_failed", `\u26A0\uFE0F Your steer message did NOT reach Codex (${reason}). The original turn continues unaffected \u2014 wait for it to finish, or resend as a normal reply.`));
3998
+ });
3999
+ codex.on("steerAccepted", () => {
4000
+ log("Steer accepted by app-server");
4001
+ });
3950
4002
  codex.on("turnStarted", () => {
3951
4003
  log("Codex turn started");
3952
4004
  emitToClaude(systemMessage("system_turn_started", "\u23F3 Codex is working on the current task. Wait for completion before sending a reply."));
@@ -4184,9 +4236,36 @@ function handleControlMessage(ws, raw) {
4184
4236
  }
4185
4237
  log(`Forwarding Claude \u2192 Codex (${message.message.content.length} chars, requireReply=${requireReply})`);
4186
4238
  const tierOverrides = BUDGET_CONFIG.codexTierControl ? budgetCoordinator?.getCodexTurnOverrides() ?? undefined : undefined;
4239
+ if (codex.turnInProgress && message.onBusy === "steer") {
4240
+ if (requireReply) {
4241
+ sendProtocolMessage(ws, {
4242
+ type: "claude_to_codex_result",
4243
+ requestId: message.requestId,
4244
+ success: false,
4245
+ error: 'require_reply is not supported together with on_busy="steer" yet. Send the steer without require_reply, or wait for the turn to finish.'
4246
+ });
4247
+ return;
4248
+ }
4249
+ const steerContent = `[STEER from Claude]
4250
+ ` + `Mid-turn update for the current Codex turn. Integrate if relevant; do not restart work unless explicitly requested.
4251
+
4252
+ ` + message.message.content;
4253
+ const steered = codex.steerMessage(steerContent);
4254
+ log(`Steer ${steered ? "transport-accepted" : "failed"} (${message.message.content.length} chars)`);
4255
+ if (steered) {
4256
+ clearAttentionWindow();
4257
+ }
4258
+ sendProtocolMessage(ws, {
4259
+ type: "claude_to_codex_result",
4260
+ requestId: message.requestId,
4261
+ success: steered,
4262
+ error: steered ? undefined : "Steer failed: the turn may have just ended or the connection dropped \u2014 retry as a normal reply."
4263
+ });
4264
+ return;
4265
+ }
4187
4266
  const injected = codex.injectMessage(contentToSend, tierOverrides);
4188
4267
  if (!injected) {
4189
- const reason = codex.turnInProgress ? "Codex is busy executing a turn. Wait for it to finish before sending another message." : "Injection failed: no active thread or WebSocket not connected.";
4268
+ const reason = codex.turnInProgress ? 'Codex is busy executing a turn. Options: wait for it to finish, or retry with on_busy="steer" to feed this message into the running turn without interrupting it.' : "Injection failed: no active thread or WebSocket not connected.";
4190
4269
  log(`Injection rejected: ${reason}`);
4191
4270
  sendProtocolMessage(ws, {
4192
4271
  type: "claude_to_codex_result",