@raysonmeng/agentbridge 0.1.7 → 0.1.9

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.9",
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.9",
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.9", "0.0.0-source"),
1169
+ commit: defineString("10dfd58", "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.9", "0.0.0-source"),
21
+ commit: defineString("10dfd58", "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,45 @@ 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
+ const expectedTurnId = this.currentSteerableTurnId();
815
+ if (!expectedTurnId) {
816
+ this.log("Cannot steer: no addressable active turn id (turn/started carried no id)");
817
+ return false;
818
+ }
819
+ this.log(`Steering message into active Codex turn ${expectedTurnId} (${text.length} chars)`);
820
+ const requestId = this.nextInjectionId--;
821
+ this.trackBridgeRequestId(requestId, "steer");
822
+ const params = {
823
+ threadId: this.threadId,
824
+ expectedTurnId,
825
+ input: [{ type: "text", text }]
826
+ };
827
+ try {
828
+ this.appServerWs.send(JSON.stringify({
829
+ method: "turn/steer",
830
+ id: requestId,
831
+ params
832
+ }));
833
+ return true;
834
+ } catch (err) {
835
+ this.untrackBridgeRequestId(requestId);
836
+ this.log(`Steer send failed: ${err.message}`);
837
+ return false;
838
+ }
839
+ }
800
840
  async waitForHealthy(maxRetries = 20, delayMs = 500) {
801
841
  for (let i = 0;i < maxRetries; i++) {
802
842
  try {
@@ -1519,14 +1559,22 @@ class CodexAdapter extends EventEmitter {
1519
1559
  this.interceptServerMessage(parsed, mapping.connId);
1520
1560
  return forwarded;
1521
1561
  }
1522
- if (!isNaN(numericId) && this.consumeBridgeRequestId(numericId)) {
1562
+ const bridgeKind = !isNaN(numericId) ? this.consumeBridgeRequestId(numericId) : null;
1563
+ if (bridgeKind) {
1523
1564
  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();
1565
+ this.log(`Bridge-originated ${bridgeKind} request failed (id ${responseId}): ${parsed.error.message ?? "unknown error"}`);
1566
+ if (bridgeKind === "steer") {
1567
+ this.emit("steerFailed", parsed.error.message ?? "unknown error");
1568
+ } else {
1569
+ this.lastTurnEndedAbnormally = true;
1570
+ this.emit("turnAborted", `injected turn/start rejected: ${parsed.error.message ?? "unknown error"}`);
1571
+ this.notifyPhaseIfChanged();
1572
+ }
1528
1573
  } else {
1529
- this.log(`Bridge-originated request completed (id ${responseId})`);
1574
+ this.log(`Bridge-originated ${bridgeKind} request completed (id ${responseId})`);
1575
+ if (bridgeKind === "steer") {
1576
+ this.emit("steerAccepted");
1577
+ }
1530
1578
  }
1531
1579
  return null;
1532
1580
  }
@@ -1719,6 +1767,14 @@ class CodexAdapter extends EventEmitter {
1719
1767
  this.log(`Thread detected: ${threadId} (${reason})`);
1720
1768
  this.emit("ready", threadId);
1721
1769
  }
1770
+ currentSteerableTurnId() {
1771
+ let newest = null;
1772
+ for (const id of this.activeTurnIds) {
1773
+ if (!id.startsWith("unknown:"))
1774
+ newest = id;
1775
+ }
1776
+ return newest;
1777
+ }
1722
1778
  get turnPhase() {
1723
1779
  if (this.activeTurnIds.size > 0) {
1724
1780
  const allStalled = [...this.activeTurnIds].every((id) => this.currentlyStalledTurnIds.has(id));
@@ -1737,6 +1793,7 @@ class CodexAdapter extends EventEmitter {
1737
1793
  markTurnStarted(turnId) {
1738
1794
  const wasInProgress = this.turnInProgress;
1739
1795
  const turnKey = typeof turnId === "string" && turnId.length > 0 ? turnId : `unknown:${Date.now()}`;
1796
+ this.activeTurnIds.delete(turnKey);
1740
1797
  this.activeTurnIds.add(turnKey);
1741
1798
  this.stalledTurnIds.delete(turnKey);
1742
1799
  this.currentlyStalledTurnIds.delete(turnKey);
@@ -1877,18 +1934,23 @@ class CodexAdapter extends EventEmitter {
1877
1934
  consumeStaleProxyId(proxyId) {
1878
1935
  return this.clearTrackedId(this.staleProxyIds, proxyId);
1879
1936
  }
1880
- trackBridgeRequestId(requestId) {
1937
+ trackBridgeRequestId(requestId, kind = "turn-start") {
1881
1938
  this.clearTrackedId(this.bridgeRequestIds, requestId);
1882
1939
  const timer = setTimeout(() => {
1883
1940
  this.bridgeRequestIds.delete(requestId);
1941
+ this.bridgeRequestKinds.delete(requestId);
1884
1942
  }, CodexAdapter.RESPONSE_TRACKING_TTL_MS);
1885
1943
  timer.unref?.();
1886
1944
  this.bridgeRequestIds.set(requestId, timer);
1945
+ this.bridgeRequestKinds.set(requestId, kind);
1887
1946
  }
1888
1947
  consumeBridgeRequestId(requestId) {
1889
- return this.clearTrackedId(this.bridgeRequestIds, requestId);
1948
+ const kind = this.bridgeRequestKinds.get(requestId) ?? "turn-start";
1949
+ this.bridgeRequestKinds.delete(requestId);
1950
+ return this.clearTrackedId(this.bridgeRequestIds, requestId) ? kind : null;
1890
1951
  }
1891
1952
  untrackBridgeRequestId(requestId) {
1953
+ this.bridgeRequestKinds.delete(requestId);
1892
1954
  this.clearTrackedId(this.bridgeRequestIds, requestId);
1893
1955
  }
1894
1956
  clearTrackedId(store, id) {
@@ -1910,6 +1972,7 @@ class CodexAdapter extends EventEmitter {
1910
1972
  clearTimeout(timer);
1911
1973
  }
1912
1974
  this.bridgeRequestIds.clear();
1975
+ this.bridgeRequestKinds.clear();
1913
1976
  }
1914
1977
  clearResponseTrackingState() {
1915
1978
  this.clearTransientResponseTrackingState();
@@ -3947,6 +4010,14 @@ codex.on("turnPhaseChanged", ({ phase, previous }) => {
3947
4010
  tryWriteStatusFile(`turnPhase:${phase}`);
3948
4011
  broadcastStatus();
3949
4012
  });
4013
+ codex.on("steerFailed", (reason) => {
4014
+ log(`Steer rejected by app-server: ${reason}`);
4015
+ const advice = codex.turnInProgress ? "wait for it to finish (\u2705), then send normally" : "the turn has ended \u2014 resend as a normal reply";
4016
+ emitToClaude(systemMessage("system_steer_failed", `\u26A0\uFE0F Your steer message did NOT reach Codex (${reason}). The original turn continues unaffected \u2014 ${advice}.`));
4017
+ });
4018
+ codex.on("steerAccepted", () => {
4019
+ log("Steer accepted by app-server");
4020
+ });
3950
4021
  codex.on("turnStarted", () => {
3951
4022
  log("Codex turn started");
3952
4023
  emitToClaude(systemMessage("system_turn_started", "\u23F3 Codex is working on the current task. Wait for completion before sending a reply."));
@@ -4184,9 +4255,37 @@ function handleControlMessage(ws, raw) {
4184
4255
  }
4185
4256
  log(`Forwarding Claude \u2192 Codex (${message.message.content.length} chars, requireReply=${requireReply})`);
4186
4257
  const tierOverrides = BUDGET_CONFIG.codexTierControl ? budgetCoordinator?.getCodexTurnOverrides() ?? undefined : undefined;
4258
+ if (codex.turnInProgress && message.onBusy === "steer") {
4259
+ if (requireReply) {
4260
+ sendProtocolMessage(ws, {
4261
+ type: "claude_to_codex_result",
4262
+ requestId: message.requestId,
4263
+ success: false,
4264
+ 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.'
4265
+ });
4266
+ return;
4267
+ }
4268
+ const steerContent = `[STEER from Claude]
4269
+ ` + `Mid-turn update for the current Codex turn. Integrate if relevant; do not restart work unless explicitly requested.
4270
+
4271
+ ` + message.message.content;
4272
+ const steered = codex.steerMessage(steerContent);
4273
+ log(`Steer ${steered ? "transport-accepted" : "failed"} (${message.message.content.length} chars)`);
4274
+ if (steered) {
4275
+ clearAttentionWindow();
4276
+ }
4277
+ const steerFailureAdvice = codex.turnInProgress ? "Steer failed: the running turn cannot be steered right now \u2014 wait for it to finish (\u2705), then send normally." : "Steer failed: the turn may have just ended or the connection dropped \u2014 retry as a normal reply.";
4278
+ sendProtocolMessage(ws, {
4279
+ type: "claude_to_codex_result",
4280
+ requestId: message.requestId,
4281
+ success: steered,
4282
+ error: steered ? undefined : steerFailureAdvice
4283
+ });
4284
+ return;
4285
+ }
4187
4286
  const injected = codex.injectMessage(contentToSend, tierOverrides);
4188
4287
  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.";
4288
+ 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
4289
  log(`Injection rejected: ${reason}`);
4191
4290
  sendProtocolMessage(ws, {
4192
4291
  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.9",
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.9",
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.9", "0.0.0-source"),
14231
+ commit: defineString("10dfd58", "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.9", "0.0.0-source"),
21
+ commit: defineString("10dfd58", "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,45 @@ 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
+ const expectedTurnId = this.currentSteerableTurnId();
815
+ if (!expectedTurnId) {
816
+ this.log("Cannot steer: no addressable active turn id (turn/started carried no id)");
817
+ return false;
818
+ }
819
+ this.log(`Steering message into active Codex turn ${expectedTurnId} (${text.length} chars)`);
820
+ const requestId = this.nextInjectionId--;
821
+ this.trackBridgeRequestId(requestId, "steer");
822
+ const params = {
823
+ threadId: this.threadId,
824
+ expectedTurnId,
825
+ input: [{ type: "text", text }]
826
+ };
827
+ try {
828
+ this.appServerWs.send(JSON.stringify({
829
+ method: "turn/steer",
830
+ id: requestId,
831
+ params
832
+ }));
833
+ return true;
834
+ } catch (err) {
835
+ this.untrackBridgeRequestId(requestId);
836
+ this.log(`Steer send failed: ${err.message}`);
837
+ return false;
838
+ }
839
+ }
800
840
  async waitForHealthy(maxRetries = 20, delayMs = 500) {
801
841
  for (let i = 0;i < maxRetries; i++) {
802
842
  try {
@@ -1519,14 +1559,22 @@ class CodexAdapter extends EventEmitter {
1519
1559
  this.interceptServerMessage(parsed, mapping.connId);
1520
1560
  return forwarded;
1521
1561
  }
1522
- if (!isNaN(numericId) && this.consumeBridgeRequestId(numericId)) {
1562
+ const bridgeKind = !isNaN(numericId) ? this.consumeBridgeRequestId(numericId) : null;
1563
+ if (bridgeKind) {
1523
1564
  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();
1565
+ this.log(`Bridge-originated ${bridgeKind} request failed (id ${responseId}): ${parsed.error.message ?? "unknown error"}`);
1566
+ if (bridgeKind === "steer") {
1567
+ this.emit("steerFailed", parsed.error.message ?? "unknown error");
1568
+ } else {
1569
+ this.lastTurnEndedAbnormally = true;
1570
+ this.emit("turnAborted", `injected turn/start rejected: ${parsed.error.message ?? "unknown error"}`);
1571
+ this.notifyPhaseIfChanged();
1572
+ }
1528
1573
  } else {
1529
- this.log(`Bridge-originated request completed (id ${responseId})`);
1574
+ this.log(`Bridge-originated ${bridgeKind} request completed (id ${responseId})`);
1575
+ if (bridgeKind === "steer") {
1576
+ this.emit("steerAccepted");
1577
+ }
1530
1578
  }
1531
1579
  return null;
1532
1580
  }
@@ -1719,6 +1767,14 @@ class CodexAdapter extends EventEmitter {
1719
1767
  this.log(`Thread detected: ${threadId} (${reason})`);
1720
1768
  this.emit("ready", threadId);
1721
1769
  }
1770
+ currentSteerableTurnId() {
1771
+ let newest = null;
1772
+ for (const id of this.activeTurnIds) {
1773
+ if (!id.startsWith("unknown:"))
1774
+ newest = id;
1775
+ }
1776
+ return newest;
1777
+ }
1722
1778
  get turnPhase() {
1723
1779
  if (this.activeTurnIds.size > 0) {
1724
1780
  const allStalled = [...this.activeTurnIds].every((id) => this.currentlyStalledTurnIds.has(id));
@@ -1737,6 +1793,7 @@ class CodexAdapter extends EventEmitter {
1737
1793
  markTurnStarted(turnId) {
1738
1794
  const wasInProgress = this.turnInProgress;
1739
1795
  const turnKey = typeof turnId === "string" && turnId.length > 0 ? turnId : `unknown:${Date.now()}`;
1796
+ this.activeTurnIds.delete(turnKey);
1740
1797
  this.activeTurnIds.add(turnKey);
1741
1798
  this.stalledTurnIds.delete(turnKey);
1742
1799
  this.currentlyStalledTurnIds.delete(turnKey);
@@ -1877,18 +1934,23 @@ class CodexAdapter extends EventEmitter {
1877
1934
  consumeStaleProxyId(proxyId) {
1878
1935
  return this.clearTrackedId(this.staleProxyIds, proxyId);
1879
1936
  }
1880
- trackBridgeRequestId(requestId) {
1937
+ trackBridgeRequestId(requestId, kind = "turn-start") {
1881
1938
  this.clearTrackedId(this.bridgeRequestIds, requestId);
1882
1939
  const timer = setTimeout(() => {
1883
1940
  this.bridgeRequestIds.delete(requestId);
1941
+ this.bridgeRequestKinds.delete(requestId);
1884
1942
  }, CodexAdapter.RESPONSE_TRACKING_TTL_MS);
1885
1943
  timer.unref?.();
1886
1944
  this.bridgeRequestIds.set(requestId, timer);
1945
+ this.bridgeRequestKinds.set(requestId, kind);
1887
1946
  }
1888
1947
  consumeBridgeRequestId(requestId) {
1889
- return this.clearTrackedId(this.bridgeRequestIds, requestId);
1948
+ const kind = this.bridgeRequestKinds.get(requestId) ?? "turn-start";
1949
+ this.bridgeRequestKinds.delete(requestId);
1950
+ return this.clearTrackedId(this.bridgeRequestIds, requestId) ? kind : null;
1890
1951
  }
1891
1952
  untrackBridgeRequestId(requestId) {
1953
+ this.bridgeRequestKinds.delete(requestId);
1892
1954
  this.clearTrackedId(this.bridgeRequestIds, requestId);
1893
1955
  }
1894
1956
  clearTrackedId(store, id) {
@@ -1910,6 +1972,7 @@ class CodexAdapter extends EventEmitter {
1910
1972
  clearTimeout(timer);
1911
1973
  }
1912
1974
  this.bridgeRequestIds.clear();
1975
+ this.bridgeRequestKinds.clear();
1913
1976
  }
1914
1977
  clearResponseTrackingState() {
1915
1978
  this.clearTransientResponseTrackingState();
@@ -3947,6 +4010,14 @@ codex.on("turnPhaseChanged", ({ phase, previous }) => {
3947
4010
  tryWriteStatusFile(`turnPhase:${phase}`);
3948
4011
  broadcastStatus();
3949
4012
  });
4013
+ codex.on("steerFailed", (reason) => {
4014
+ log(`Steer rejected by app-server: ${reason}`);
4015
+ const advice = codex.turnInProgress ? "wait for it to finish (\u2705), then send normally" : "the turn has ended \u2014 resend as a normal reply";
4016
+ emitToClaude(systemMessage("system_steer_failed", `\u26A0\uFE0F Your steer message did NOT reach Codex (${reason}). The original turn continues unaffected \u2014 ${advice}.`));
4017
+ });
4018
+ codex.on("steerAccepted", () => {
4019
+ log("Steer accepted by app-server");
4020
+ });
3950
4021
  codex.on("turnStarted", () => {
3951
4022
  log("Codex turn started");
3952
4023
  emitToClaude(systemMessage("system_turn_started", "\u23F3 Codex is working on the current task. Wait for completion before sending a reply."));
@@ -4184,9 +4255,37 @@ function handleControlMessage(ws, raw) {
4184
4255
  }
4185
4256
  log(`Forwarding Claude \u2192 Codex (${message.message.content.length} chars, requireReply=${requireReply})`);
4186
4257
  const tierOverrides = BUDGET_CONFIG.codexTierControl ? budgetCoordinator?.getCodexTurnOverrides() ?? undefined : undefined;
4258
+ if (codex.turnInProgress && message.onBusy === "steer") {
4259
+ if (requireReply) {
4260
+ sendProtocolMessage(ws, {
4261
+ type: "claude_to_codex_result",
4262
+ requestId: message.requestId,
4263
+ success: false,
4264
+ 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.'
4265
+ });
4266
+ return;
4267
+ }
4268
+ const steerContent = `[STEER from Claude]
4269
+ ` + `Mid-turn update for the current Codex turn. Integrate if relevant; do not restart work unless explicitly requested.
4270
+
4271
+ ` + message.message.content;
4272
+ const steered = codex.steerMessage(steerContent);
4273
+ log(`Steer ${steered ? "transport-accepted" : "failed"} (${message.message.content.length} chars)`);
4274
+ if (steered) {
4275
+ clearAttentionWindow();
4276
+ }
4277
+ const steerFailureAdvice = codex.turnInProgress ? "Steer failed: the running turn cannot be steered right now \u2014 wait for it to finish (\u2705), then send normally." : "Steer failed: the turn may have just ended or the connection dropped \u2014 retry as a normal reply.";
4278
+ sendProtocolMessage(ws, {
4279
+ type: "claude_to_codex_result",
4280
+ requestId: message.requestId,
4281
+ success: steered,
4282
+ error: steered ? undefined : steerFailureAdvice
4283
+ });
4284
+ return;
4285
+ }
4187
4286
  const injected = codex.injectMessage(contentToSend, tierOverrides);
4188
4287
  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.";
4288
+ 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
4289
  log(`Injection rejected: ${reason}`);
4191
4290
  sendProtocolMessage(ws, {
4192
4291
  type: "claude_to_codex_result",