@minniexcode/codex-switch 0.1.2 → 0.1.4

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/README.AI.md CHANGED
@@ -6,8 +6,8 @@ This file summarizes the current operational contract for AI agents, automation
6
6
 
7
7
  - Package: `@minniexcode/codex-switch`
8
8
  - CLI name: `codexs`
9
- - Current repository version: `0.1.1`
10
- - Version status: stable release line
9
+ - Current repository version: `0.1.4`
10
+ - Version status: development line
11
11
  - Runtime contract target: Codex `0.134.0+`
12
12
 
13
13
  ## Product Role
@@ -133,6 +133,7 @@ Important behavioral constraints:
133
133
  - `migrate` remains interactive when provider adoption requires human input.
134
134
  - `status` is the main dual-path summary command.
135
135
  - `doctor` is the deeper repair-oriented diagnostic command.
136
+ - The current `0.1.4` line focuses on bridge stability, surfaced runtime log metadata, and stricter release-hygiene verification rather than command-surface expansion.
136
137
 
137
138
  ## Safety Notes
138
139
 
package/README.CN.md CHANGED
@@ -6,9 +6,9 @@
6
6
 
7
7
  ## 版本定位
8
8
 
9
- 当前包版本:`0.1.2`
9
+ 当前包版本:`0.1.4`
10
10
 
11
- 这是当前稳定发布线。`0.1.2` Copilot runtime 修复版本,包含受管 SDK 固定版本与 Copilot 专用的 `stream_idle_timeout_ms = 300000` 投影,用于避免长 prompt 的空闲超时。
11
+ 这是当前仓库开发线。`0.1.4` 聚焦于 bridge 稳定性与可观测性,包括 bridge 复用探测加固、运行态日志元数据外显,以及更严格的 release hygiene 门禁,同时不扩展 provider 命令面。
12
12
 
13
13
  ## 安装
14
14
 
@@ -209,6 +209,8 @@ npm pack --dry-run
209
209
  - [Design 0.1.1](./docs/Design/codex-switch-v0.1.1-design.md)
210
210
  - [PRD 0.1.2](./docs/PRD/codex-switch-prd-v0.1.2.md)
211
211
  - [Design 0.1.2](./docs/Design/codex-switch-v0.1.2-design.md)
212
+ - [PRD 0.1.4](./docs/PRD/codex-switch-prd-v0.1.4.md)
213
+ - [Design 0.1.4](./docs/Design/codex-switch-v0.1.4-design.md)
212
214
 
213
215
  ## License
214
216
 
package/README.md CHANGED
@@ -8,9 +8,9 @@ Chinese version: [README.CN.md](./README.CN.md)
8
8
 
9
9
  ## Version
10
10
 
11
- Current package version: `0.1.2`
11
+ Current package version: `0.1.4`
12
12
 
13
- This is the current stable documentation line. `0.1.2` is the Copilot runtime repair release, including the managed SDK pin and the Copilot-only `stream_idle_timeout_ms = 300000` projection used to prevent long prompt idle timeouts.
13
+ This is the current repository development line. `0.1.4` is the bridge stability and observability line, focused on bridge reuse hardening, surfaced runtime log metadata, and a stricter release-hygiene gate while keeping the provider surface unchanged.
14
14
 
15
15
  ## Install
16
16
 
@@ -217,7 +217,11 @@ npm pack --dry-run
217
217
  - [PRD 0.1.0](./docs/PRD/codex-switch-prd-v0.1.0.md)
218
218
  - [PRD 0.1.1](./docs/PRD/codex-switch-prd-v0.1.1.md)
219
219
  - [PRD 0.1.2](./docs/PRD/codex-switch-prd-v0.1.2.md)
220
+ - [PRD 0.1.3](./docs/PRD/codex-switch-prd-v0.1.3.md)
221
+ - [PRD 0.1.4](./docs/PRD/codex-switch-prd-v0.1.4.md)
220
222
  - [Design 0.1.2](./docs/Design/codex-switch-v0.1.2-design.md)
223
+ - [Design 0.1.3](./docs/Design/codex-switch-v0.1.3-design.md)
224
+ - [Design 0.1.4](./docs/Design/codex-switch-v0.1.4-design.md)
221
225
 
222
226
  ## License
223
227
 
@@ -58,6 +58,9 @@ async function startBridge(args) {
58
58
  port: bridge.port,
59
59
  reused: bridge.reused,
60
60
  portChanged: bridge.portChanged,
61
+ replaced: bridge.replaced,
62
+ restartReason: bridge.restartReason ?? null,
63
+ logPath: bridge.logPath,
61
64
  defaultPort: DEFAULT_BRIDGE_PORT,
62
65
  },
63
66
  };
@@ -74,6 +77,8 @@ async function stopBridge(args) {
74
77
  provider: null,
75
78
  stopped: true,
76
79
  hadRuntimeState: false,
80
+ logPath: null,
81
+ lastRestartReason: null,
77
82
  },
78
83
  };
79
84
  }
@@ -84,6 +89,8 @@ async function stopBridge(args) {
84
89
  provider: args.providerName,
85
90
  stopped: true,
86
91
  hadRuntimeState: false,
92
+ logPath: null,
93
+ lastRestartReason: null,
87
94
  },
88
95
  };
89
96
  }
@@ -109,6 +116,8 @@ async function stopBridge(args) {
109
116
  provider: target.providerName,
110
117
  stopped: true,
111
118
  hadRuntimeState: Boolean(state),
119
+ logPath: state?.logPath ?? null,
120
+ lastRestartReason: state?.lastRestartReason ?? null,
112
121
  },
113
122
  };
114
123
  }
@@ -146,6 +155,8 @@ async function statusBridge(args) {
146
155
  matches: Boolean(state && state.provider === target.providerName && state.baseUrl === expectedBaseUrl),
147
156
  active: runtimeStatus.ok,
148
157
  health: runtimeStatus,
158
+ logPath: state?.logPath ?? null,
159
+ lastRestartReason: state?.lastRestartReason ?? null,
149
160
  },
150
161
  };
151
162
  }
@@ -88,6 +88,9 @@ async function getStatus(codexDir, configPath, providersPath, authPath, options)
88
88
  runtime: "copilot-bridge",
89
89
  reason: "failed",
90
90
  cause: runtimeStateInspection.parseError ?? "Failed to parse Copilot bridge runtime state.",
91
+ details: {
92
+ logPath: runtimeState?.logPath ?? null,
93
+ },
91
94
  }
92
95
  : bridgeProbeTarget
93
96
  ? await (0, copilot_bridge_1.probeCopilotBridgeRuntime)(bridgeProbeTarget, runtimeState, options?.runtimeDir)
@@ -97,7 +100,10 @@ async function getStatus(codexDir, configPath, providersPath, authPath, options)
97
100
  runtime: "copilot-bridge",
98
101
  reason: "failed",
99
102
  cause: "Copilot bridge runtime state exists but no matching managed Copilot provider is active.",
100
- details: runtimeState,
103
+ details: {
104
+ ...runtimeState,
105
+ logPath: runtimeState.logPath ?? null,
106
+ },
101
107
  }
102
108
  : null;
103
109
  const copilotAuth = activeProvider && (0, providers_1.isCopilotBridgeProvider)(activeProvider)
@@ -148,6 +154,8 @@ async function getStatus(codexDir, configPath, providersPath, authPath, options)
148
154
  copilotAuth,
149
155
  copilotBridge,
150
156
  copilotRuntimeState: runtimeState,
157
+ copilotBridgeLogPath: runtimeState?.logPath ?? null,
158
+ copilotBridgeRestartReason: runtimeState?.lastRestartReason ?? null,
151
159
  liveState,
152
160
  auth: authState,
153
161
  configProfiles: configViews,
@@ -117,6 +117,7 @@ async function runDoctor(args) {
117
117
  issues.push({
118
118
  code: "BRIDGE_STATE_STALE",
119
119
  message: `Copilot bridge runtime state is unreadable: ${runtimeStateInspection.parseError ?? "unknown parse failure"}`,
120
+ logPath: runtimeState?.logPath ?? null,
120
121
  });
121
122
  }
122
123
  if (document?.currentModelProvider && providers) {
@@ -209,6 +210,7 @@ async function runDoctor(args) {
209
210
  }),
210
211
  liveState: drift,
211
212
  auth: authState,
213
+ copilotRuntimeState: runtimeState,
212
214
  },
213
215
  warnings: issues.length === 0 ? [] : [`doctor found ${issues.length} issue(s)`],
214
216
  };
@@ -22,7 +22,9 @@ async function switchProvider(args) {
22
22
  });
23
23
  }
24
24
  const document = (0, config_repo_1.readStructuredConfig)(args.configPath);
25
- const resolvedModel = provider.model ?? document.currentModel;
25
+ const providerProfileSection = document.profiles.find((entry) => entry.name === provider.profile) ?? null;
26
+ const providerModelProviderSection = document.modelProviders.find((entry) => entry.name === provider.profile) ?? null;
27
+ const resolvedModel = provider.model ?? providerProfileSection?.model ?? document.currentModel;
26
28
  if (!resolvedModel) {
27
29
  throw (0, errors_1.cliError)("MANAGED_PROFILE_FIELDS_MISSING", `Provider "${args.providerName}" has no model to switch with.`, {
28
30
  provider: args.providerName,
@@ -90,6 +92,10 @@ async function switchProvider(args) {
90
92
  profile: nextProvider.profile,
91
93
  portChanged: bridge.portChanged,
92
94
  bridgePort: bridge.port,
95
+ bridgeReused: bridge.reused,
96
+ bridgeReplaced: bridge.replaced,
97
+ bridgeRestartReason: bridge.restartReason ?? null,
98
+ bridgeLogPath: bridge.logPath,
93
99
  };
94
100
  },
95
101
  });
@@ -112,7 +118,8 @@ async function switchProvider(args) {
112
118
  ],
113
119
  mutate: () => {
114
120
  const directBaseUrl = provider.baseUrl?.trim() ?? "";
115
- if (!directBaseUrl) {
121
+ const resolvedBaseUrl = directBaseUrl || providerModelProviderSection?.baseUrl?.trim() || "";
122
+ if (!resolvedBaseUrl) {
116
123
  throw (0, errors_1.cliError)("MANAGED_PROFILE_FIELDS_MISSING", `Provider "${args.providerName}" requires base_url before switching.`, {
117
124
  provider: args.providerName,
118
125
  modelProvider: provider.profile,
@@ -123,7 +130,7 @@ async function switchProvider(args) {
123
130
  setCurrentModel: resolvedModel,
124
131
  setCurrentModelProvider: provider.profile,
125
132
  upsertModelProviders: {
126
- [provider.profile]: (0, providers_1.buildDirectModelProviderProjection)(provider.profile, directBaseUrl),
133
+ [provider.profile]: (0, providers_1.buildDirectModelProviderProjection)(provider.profile, resolvedBaseUrl),
127
134
  },
128
135
  deleteLegacyProfile: true,
129
136
  deleteLegacyProfilesByName: [provider.profile],
@@ -144,6 +144,12 @@ function renderHumanSuccess(command, data, warnings) {
144
144
  lines.push(` mapped provider: ${renderStatusMappedProvider(data)}`);
145
145
  lines.push(` provider path: ${renderStatusProviderPath(data)}`);
146
146
  lines.push(` runtime health: ${renderStatusHealth(data)}`);
147
+ if (data?.copilotBridgeLogPath) {
148
+ lines.push(` bridge log: ${String(data.copilotBridgeLogPath)}`);
149
+ }
150
+ if (data?.copilotBridgeRestartReason) {
151
+ lines.push(` bridge restart reason: ${String(data.copilotBridgeRestartReason)}`);
152
+ }
147
153
  lines.push(` warnings: ${warnings.length}`);
148
154
  lines.push(` next step: ${renderStatusNextStep(data, warnings)}`);
149
155
  break;
@@ -167,6 +173,15 @@ function renderHumanSuccess(command, data, warnings) {
167
173
  case "switch":
168
174
  lines.push(`Switched to provider ${String(data?.provider ?? "")} using model provider ${String(data?.modelProvider ?? data?.profile ?? "")}.`);
169
175
  lines.push(`Model: ${String(data?.model ?? "")}`);
176
+ if (data?.bridgeReplaced) {
177
+ lines.push(`Bridge replaced: true`);
178
+ }
179
+ if (data?.bridgeRestartReason) {
180
+ lines.push(`Bridge restart reason: ${String(data?.bridgeRestartReason)}`);
181
+ }
182
+ if (data?.bridgeLogPath) {
183
+ lines.push(`Bridge log: ${String(data?.bridgeLogPath)}`);
184
+ }
170
185
  lines.push(`Backup: ${String(data?.backupPath ?? "")}`);
171
186
  break;
172
187
  case "import":
@@ -227,12 +242,49 @@ function renderHumanSuccess(command, data, warnings) {
227
242
  const issues = data?.issues ?? [];
228
243
  lines.push(healthy ? "Doctor summary: healthy. No action required." : `Doctor summary: ${issues.length} issue(s) need attention.`);
229
244
  lines.push(`target runtime: ${String(data?.codexDir ?? "")}`);
245
+ if (data?.copilotRuntimeState && data.copilotRuntimeState.logPath) {
246
+ lines.push(`bridge log: ${String(data.copilotRuntimeState.logPath)}`);
247
+ }
230
248
  for (const issue of issues) {
231
249
  lines.push(`- ${String(issue.code)}: ${String(issue.message)}`);
232
250
  lines.push(` next step: ${renderDoctorIssueNextStep(issue)}`);
233
251
  }
234
252
  break;
235
253
  }
254
+ case "bridge-start":
255
+ lines.push(`Bridge ready for provider ${String(data?.provider ?? "")}.`);
256
+ lines.push(`Endpoint: ${String(data?.baseUrl ?? "")}`);
257
+ lines.push(`Reused: ${String(data?.reused ?? false)}`);
258
+ lines.push(`Replaced existing: ${String(data?.replaced ?? false)}`);
259
+ if (data?.restartReason) {
260
+ lines.push(`Restart reason: ${String(data?.restartReason)}`);
261
+ }
262
+ if (data?.logPath) {
263
+ lines.push(`Bridge log: ${String(data?.logPath)}`);
264
+ }
265
+ break;
266
+ case "bridge-status":
267
+ lines.push(`Bridge provider: ${String(data?.provider ?? "")}`);
268
+ lines.push(`Active: ${String(data?.active ?? false)}`);
269
+ lines.push(`Expected base URL: ${String(data?.expectedBaseUrl ?? "")}`);
270
+ lines.push(`Matches runtime state: ${String(data?.matches ?? false)}`);
271
+ if (data?.lastRestartReason) {
272
+ lines.push(`Last restart reason: ${String(data?.lastRestartReason)}`);
273
+ }
274
+ if (data?.logPath) {
275
+ lines.push(`Bridge log: ${String(data?.logPath)}`);
276
+ }
277
+ break;
278
+ case "bridge-stop":
279
+ lines.push(`Bridge stopped for provider ${String(data?.provider ?? "(none)")}.`);
280
+ lines.push(`Had runtime state: ${String(data?.hadRuntimeState ?? false)}`);
281
+ if (data?.lastRestartReason) {
282
+ lines.push(`Last restart reason: ${String(data?.lastRestartReason)}`);
283
+ }
284
+ if (data?.logPath) {
285
+ lines.push(`Bridge log: ${String(data?.logPath)}`);
286
+ }
287
+ break;
236
288
  case "backups-list": {
237
289
  const backups = data?.backups ?? [];
238
290
  for (const backup of backups) {
@@ -344,7 +396,7 @@ function renderDoctorIssueNextStep(issue) {
344
396
  case "BRIDGE_STATE_STALE":
345
397
  case "BRIDGE_STATE_MISSING":
346
398
  case "BRIDGE_HEALTHCHECK_FAILED":
347
- return "reselect the provider with `codexs switch <provider>` or inspect bridge state";
399
+ return "reselect the provider with `codexs switch <provider>` or inspect the bridge log/state";
348
400
  case "UNMANAGED_ACTIVE_PROFILE":
349
401
  return "switch to a managed provider or adopt the active route with `codexs migrate`";
350
402
  case "LEGACY_PROFILE_SELECTOR":
@@ -70,18 +70,25 @@ function canPrompt(runtime, jsonMode) {
70
70
  */
71
71
  async function promptForProviderSelection(runtime, providersPath, configPath, message) {
72
72
  const providers = (0, providers_repo_1.readProvidersFile)(providersPath);
73
- const currentModelProvider = fs.existsSync(configPath) ? (0, config_repo_1.readStructuredConfig)(configPath).currentModelProvider : null;
73
+ const document = fs.existsSync(configPath) ? (0, config_repo_1.readStructuredConfig)(configPath) : null;
74
+ const currentModelProvider = document?.currentModelProvider ?? null;
74
75
  const liveState = (0, runtime_state_1.inspectLiveStateDrift)(currentModelProvider, providers);
76
+ const legacyCurrentProvider = !liveState.providerResolvable &&
77
+ document?.legacyProfile &&
78
+ providers.providers[document.legacyProfile]
79
+ ? document.legacyProfile
80
+ : null;
75
81
  const choices = Object.entries(providers.providers)
76
82
  .sort(([left], [right]) => left.localeCompare(right))
77
83
  .map(([providerName, provider]) => {
78
84
  const providerType = (0, providers_1.isCopilotBridgeProvider)(provider) ? "copilot" : "direct";
79
85
  const currentMarker = liveState.providerResolvable && liveState.mappedProvider === providerName ? " | current" : "";
86
+ const legacyMarker = !currentMarker && legacyCurrentProvider === providerName ? " | current" : "";
80
87
  const ambiguousMarker = !liveState.providerResolvable && liveState.mappedProviders.includes(providerName) ? " | current=ambiguous" : "";
81
88
  return {
82
89
  value: providerName,
83
90
  label: providerName,
84
- hint: `profile=${provider.profile} | type=${providerType}${currentMarker}${ambiguousMarker}`,
91
+ hint: `profile=${provider.profile} | type=${providerType}${currentMarker}${legacyMarker}${ambiguousMarker}`,
85
92
  };
86
93
  });
87
94
  if (choices.length === 0) {
@@ -265,14 +265,23 @@ function createCopilotClient(sdk, runtimesDir) {
265
265
  if (!ClientCtor) {
266
266
  throw (0, errors_1.cliError)("COPILOT_SDK_API_UNSUPPORTED", "The installed Copilot SDK does not expose CopilotClient.", {});
267
267
  }
268
- const invocation = (0, copilot_cli_1.resolveCopilotCliInvocation)([], runtimesDir);
269
- const clientOptions = {
270
- copilotCommand: invocation.command,
271
- command: invocation.command,
272
- executable: invocation.command,
273
- };
268
+ const runtimeConnection = resolveRuntimeConnectionFactory(sdk);
269
+ if (!runtimeConnection?.forStdio) {
270
+ throw (0, errors_1.cliError)("COPILOT_SDK_API_UNSUPPORTED", "The installed Copilot SDK does not expose RuntimeConnection.forStdio().", {});
271
+ }
272
+ const runtimeInvocation = (0, copilot_cli_1.resolveCopilotSdkRuntimeInvocation)(runtimesDir);
273
+ if (!runtimeInvocation) {
274
+ throw (0, errors_1.cliError)("COPILOT_SDK_API_UNSUPPORTED", "The installed Copilot runtime is missing the @github/copilot npm loader required by the SDK.", {
275
+ expectedRuntimeFile: "node_modules/@github/copilot/npm-loader.js",
276
+ });
277
+ }
274
278
  try {
275
- return new ClientCtor(clientOptions);
279
+ return new ClientCtor({
280
+ connection: runtimeConnection.forStdio({
281
+ path: runtimeInvocation.path,
282
+ args: runtimeInvocation.args,
283
+ }),
284
+ });
276
285
  }
277
286
  catch (error) {
278
287
  throw (0, errors_1.cliError)("COPILOT_SDK_API_UNSUPPORTED", "The installed Copilot SDK CopilotClient could not be constructed.", {
@@ -434,6 +443,17 @@ function resolveConstructor(target, name) {
434
443
  }
435
444
  return null;
436
445
  }
446
+ function resolveRuntimeConnectionFactory(target) {
447
+ const direct = target.RuntimeConnection;
448
+ if (direct && typeof direct === "object") {
449
+ return direct;
450
+ }
451
+ const nestedDefault = target.default;
452
+ if (nestedDefault) {
453
+ return resolveRuntimeConnectionFactory(nestedDefault);
454
+ }
455
+ return null;
456
+ }
437
457
  function resolveApproveAll(target) {
438
458
  const direct = target.approveAll;
439
459
  if (typeof direct === "function") {
@@ -3,6 +3,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const copilot_bridge_1 = require("./copilot-bridge");
4
4
  const copilot_adapter_1 = require("./copilot-adapter");
5
5
  let requestQueue = Promise.resolve();
6
+ /**
7
+ * Writes one worker lifecycle entry to stderr so the detached parent log capture persists it.
8
+ */
9
+ function logWorkerEvent(message) {
10
+ process.stderr.write(`[${new Date().toISOString()}] ${message}\n`);
11
+ }
6
12
  function enqueueRequest(task) {
7
13
  const run = requestQueue.then(task, task);
8
14
  requestQueue = run.catch(() => undefined);
@@ -14,9 +20,11 @@ async function main() {
14
20
  const port = Number(process.env.CODEX_SWITCH_BRIDGE_PORT ?? "41415");
15
21
  const apiKey = process.env.CODEX_SWITCH_BRIDGE_API_KEY ?? "";
16
22
  const runtimesDir = process.env.CODEX_SWITCH_RUNTIMES_DIR || undefined;
23
+ logWorkerEvent(`worker startup provider=${provider} host=${host} port=${String(port)}`);
17
24
  const runtimeClient = await (0, copilot_adapter_1.createCopilotRuntimeClient)(runtimesDir);
18
25
  await (0, copilot_adapter_1.startCopilotRuntimeClient)(runtimeClient);
19
26
  const stopRuntime = () => {
27
+ logWorkerEvent(`worker shutdown provider=${provider}`);
20
28
  void (0, copilot_adapter_1.stopCopilotRuntimeClient)(runtimeClient).finally(() => process.exit(0));
21
29
  };
22
30
  process.once("SIGINT", stopRuntime);
@@ -41,9 +49,17 @@ async function main() {
41
49
  },
42
50
  })),
43
51
  });
52
+ logWorkerEvent(`worker ready provider=${provider} host=${host} port=${String(port)}`);
44
53
  }
45
54
  if (require.main === module) {
55
+ process.on("uncaughtException", (error) => {
56
+ logWorkerEvent(`worker uncaught exception: ${error.message}`);
57
+ });
58
+ process.on("unhandledRejection", (reason) => {
59
+ logWorkerEvent(`worker unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
60
+ });
46
61
  void main().catch((error) => {
62
+ logWorkerEvent(`worker startup failure: ${error instanceof Error ? error.message : String(error)}`);
47
63
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
48
64
  process.exit(1);
49
65
  });
@@ -50,6 +50,9 @@ const path = __importStar(require("node:path"));
50
50
  const providers_1 = require("../domain/providers");
51
51
  const errors_1 = require("../domain/errors");
52
52
  const runtime_state_repo_1 = require("../storage/runtime-state-repo");
53
+ const BRIDGE_REUSE_ATTEMPTS = 2;
54
+ const BRIDGE_REUSE_TIMEOUT_MS = 2500;
55
+ const BRIDGE_REUSE_DELAY_MS = 250;
53
56
  let spawnImplementation = node_child_process_1.spawn;
54
57
  let cachedBridgeWorkerBuildId = null;
55
58
  /**
@@ -69,13 +72,17 @@ function resetCopilotBridgeSpawnImplementation() {
69
72
  */
70
73
  async function probeCopilotBridgeRuntime(provider, persistedState, runtimeDir) {
71
74
  const state = persistedState === undefined ? (0, runtime_state_repo_1.readCopilotBridgeState)(runtimeDir) : persistedState;
75
+ const logPath = state?.logPath ?? (0, runtime_state_repo_1.getCopilotBridgeLogPath)(runtimeDir);
72
76
  if (state && (!provider || !(0, providers_1.isCopilotBridgeProvider)(provider))) {
73
77
  return {
74
78
  ok: false,
75
79
  runtime: "copilot-bridge",
76
80
  reason: "failed",
77
81
  cause: "Copilot bridge runtime state exists but no active Copilot bridge provider is selected.",
78
- details: state,
82
+ details: {
83
+ ...state,
84
+ logPath,
85
+ },
79
86
  };
80
87
  }
81
88
  if (!provider || !(0, providers_1.isCopilotBridgeProvider)(provider)) {
@@ -100,6 +107,7 @@ async function probeCopilotBridgeRuntime(provider, persistedState, runtimeDir) {
100
107
  cause: "Copilot bridge state manifest is missing.",
101
108
  details: {
102
109
  expectedBaseUrl: (0, providers_1.buildCopilotBridgeBaseUrl)(runtime),
110
+ logPath,
103
111
  },
104
112
  };
105
113
  }
@@ -112,27 +120,44 @@ async function probeCopilotBridgeRuntime(provider, persistedState, runtimeDir) {
112
120
  details: {
113
121
  stateBaseUrl: state.baseUrl,
114
122
  providerBaseUrl: (0, providers_1.buildCopilotBridgeBaseUrl)(runtime),
123
+ logPath,
115
124
  },
116
125
  };
117
126
  }
118
- const healthy = await healthcheckCopilotBridge(state.host, state.port);
127
+ const healthy = await probeBridgeEndpoint({
128
+ host: state.host,
129
+ port: state.port,
130
+ stage: "health",
131
+ });
119
132
  if (!healthy.ok) {
120
133
  return {
121
134
  ok: false,
122
135
  runtime: "copilot-bridge",
123
136
  reason: "failed",
124
- cause: healthy.cause,
125
- details: state,
137
+ cause: healthy.message,
138
+ details: {
139
+ ...state,
140
+ logPath,
141
+ probeStage: healthy.stage,
142
+ probeCause: healthy.cause,
143
+ retryable: healthy.retryable,
144
+ },
126
145
  };
127
146
  }
128
147
  (0, runtime_state_repo_1.writeCopilotBridgeState)({
129
148
  ...state,
130
149
  lastHealthcheckAt: new Date().toISOString(),
150
+ lastProbeAt: new Date().toISOString(),
151
+ logPath,
131
152
  }, runtimeDir);
132
153
  return {
133
154
  ok: true,
134
155
  runtime: "copilot-bridge",
135
- details: state,
156
+ details: {
157
+ ...state,
158
+ logPath,
159
+ lastProbeAt: new Date().toISOString(),
160
+ },
136
161
  };
137
162
  }
138
163
  /**
@@ -160,47 +185,50 @@ async function startOrReuseCopilotBridge(providerName, provider, runtimeDir, run
160
185
  const current = (0, runtime_state_repo_1.readCopilotBridgeState)(runtimeDir);
161
186
  const workerBuildId = getCopilotBridgeWorkerBuildId();
162
187
  let replaced = false;
163
- if (current && current.provider === providerName && current.baseUrl === expectedBaseUrl) {
164
- if (current.workerBuildId === workerBuildId) {
165
- const healthy = await healthcheckCopilotBridge(current.host, current.port);
166
- if (healthy.ok) {
167
- const compatible = await verifyCopilotBridgeAuthorization(current.host, current.port, provider.apiKey);
168
- if (compatible.ok) {
169
- (0, runtime_state_repo_1.writeCopilotBridgeState)({
170
- ...current,
171
- lastHealthcheckAt: new Date().toISOString(),
172
- workerBuildId,
173
- }, runtimeDir);
174
- return {
175
- baseUrl: expectedBaseUrl,
176
- host: current.host,
177
- port: current.port,
178
- reused: true,
179
- portChanged: false,
180
- replaced: false,
181
- };
182
- }
183
- }
184
- stopCopilotBridge(runtimeDir);
185
- replaced = true;
186
- }
187
- else {
188
- stopCopilotBridge(runtimeDir);
189
- replaced = true;
190
- }
188
+ const logPath = current?.logPath ?? (0, runtime_state_repo_1.getCopilotBridgeLogPath)(runtimeDir);
189
+ const reuseDecision = await evaluateBridgeReuse({
190
+ current,
191
+ providerName,
192
+ expectedBaseUrl,
193
+ expectedApiKey: provider.apiKey,
194
+ workerBuildId,
195
+ runtimeDir,
196
+ logPath,
197
+ });
198
+ if (reuseDecision.reuse) {
199
+ (0, runtime_state_repo_1.writeCopilotBridgeState)({
200
+ ...current,
201
+ lastHealthcheckAt: new Date().toISOString(),
202
+ lastProbeAt: reuseDecision.probeAt,
203
+ workerBuildId,
204
+ logPath: reuseDecision.logPath,
205
+ }, runtimeDir);
206
+ appendBridgeLifecycleLog(logPath, `startup success reused provider=${providerName} host=${current.host} port=${String(current.port)}`);
207
+ return {
208
+ baseUrl: expectedBaseUrl,
209
+ host: current.host,
210
+ port: current.port,
211
+ reused: true,
212
+ portChanged: false,
213
+ replaced: false,
214
+ logPath: reuseDecision.logPath,
215
+ };
191
216
  }
192
- if (current && current.provider !== providerName) {
217
+ if (reuseDecision.replacedExisting) {
193
218
  stopCopilotBridge(runtimeDir);
194
219
  replaced = true;
220
+ appendBridgeLifecycleLog(logPath, `replacement reason=${reuseDecision.reason}`);
195
221
  }
196
222
  const selectedPort = await selectBridgePort(runtime.bridgeHost, runtime.bridgePort);
197
223
  const selectedBaseUrl = `http://${runtime.bridgeHost}:${selectedPort}${runtime.bridgePath}`;
198
224
  const workerPath = path.join(__dirname, "copilot-bridge-worker.js");
225
+ ensureBridgeLogFile(logPath);
226
+ appendBridgeLifecycleLog(logPath, `worker start provider=${providerName} host=${runtime.bridgeHost} port=${String(selectedPort)} replaced=${String(replaced)}`);
199
227
  let child;
200
228
  try {
201
229
  child = spawnImplementation(process.execPath, [workerPath], {
202
230
  detached: true,
203
- stdio: "ignore",
231
+ stdio: ["ignore", openBridgeLogFd(logPath), openBridgeLogFd(logPath)],
204
232
  env: {
205
233
  ...process.env,
206
234
  CODEX_SWITCH_BRIDGE_PROVIDER: providerName,
@@ -210,6 +238,7 @@ async function startOrReuseCopilotBridge(providerName, provider, runtimeDir, run
210
238
  CODEX_SWITCH_BRIDGE_BASE_URL: selectedBaseUrl,
211
239
  CODEX_SWITCH_RUNTIME_DIR: runtimeDir ?? "",
212
240
  CODEX_SWITCH_RUNTIMES_DIR: runtimesDir ?? "",
241
+ CODEX_SWITCH_BRIDGE_LOG_PATH: logPath,
213
242
  },
214
243
  });
215
244
  }
@@ -218,6 +247,12 @@ async function startOrReuseCopilotBridge(providerName, provider, runtimeDir, run
218
247
  provider: providerName,
219
248
  host: runtime.bridgeHost,
220
249
  port: selectedPort,
250
+ logPath,
251
+ probeStage: "startup",
252
+ probeCause: "startup-failed",
253
+ retryable: false,
254
+ replacedExisting: replaced,
255
+ providerName,
221
256
  cause: error instanceof Error ? error.message : String(error),
222
257
  });
223
258
  }
@@ -228,17 +263,29 @@ async function startOrReuseCopilotBridge(providerName, provider, runtimeDir, run
228
263
  if (!healthy.ok) {
229
264
  (0, runtime_state_repo_1.clearCopilotBridgeState)(runtimeDir);
230
265
  if (healthy.reason === "start-failed") {
266
+ appendBridgeLifecycleLog(logPath, `startup failure provider=${providerName} cause=${healthy.cause}`);
231
267
  throw (0, errors_1.cliError)("BRIDGE_START_FAILED", "Copilot bridge worker exited before becoming healthy.", {
232
268
  provider: providerName,
233
269
  host: runtime.bridgeHost,
234
270
  port: selectedPort,
271
+ logPath,
272
+ probeStage: "startup",
273
+ probeCause: "startup-failed",
274
+ retryable: false,
275
+ replacedExisting: replaced,
235
276
  cause: healthy.cause,
236
277
  });
237
278
  }
279
+ appendBridgeLifecycleLog(logPath, `startup timeout provider=${providerName} host=${runtime.bridgeHost} port=${String(selectedPort)}`);
238
280
  throw (0, errors_1.cliError)("BRIDGE_HEALTHCHECK_FAILED", "Copilot bridge did not become healthy after startup.", {
239
281
  provider: providerName,
240
282
  host: runtime.bridgeHost,
241
283
  port: selectedPort,
284
+ logPath,
285
+ probeStage: "startup",
286
+ probeCause: "startup-timeout",
287
+ retryable: true,
288
+ replacedExisting: replaced,
242
289
  cause: healthy.cause,
243
290
  });
244
291
  }
@@ -251,8 +298,12 @@ async function startOrReuseCopilotBridge(providerName, provider, runtimeDir, run
251
298
  startedAt,
252
299
  lastHealthcheckAt: new Date().toISOString(),
253
300
  workerBuildId,
301
+ logPath,
302
+ lastProbeAt: new Date().toISOString(),
303
+ lastRestartReason: reuseDecision.reuse ? undefined : reuseDecision.reason,
254
304
  };
255
305
  (0, runtime_state_repo_1.writeCopilotBridgeState)(state, runtimeDir);
306
+ appendBridgeLifecycleLog(logPath, `startup success provider=${providerName} host=${runtime.bridgeHost} port=${String(selectedPort)}`);
256
307
  return {
257
308
  baseUrl: selectedBaseUrl,
258
309
  host: runtime.bridgeHost,
@@ -260,6 +311,8 @@ async function startOrReuseCopilotBridge(providerName, provider, runtimeDir, run
260
311
  reused: false,
261
312
  portChanged: selectedPort !== runtime.bridgePort,
262
313
  replaced,
314
+ logPath,
315
+ restartReason: replaced ? reuseDecision.reason : undefined,
263
316
  };
264
317
  }
265
318
  /**
@@ -353,6 +406,9 @@ function createCopilotBridgeRequestHandler(context) {
353
406
  });
354
407
  clearInterval(heartbeat);
355
408
  const outputText = text || getChatCompletionText(payload);
409
+ if (text.length === 0 && outputText.length > 0) {
410
+ writeResponsesTextDelta(response, messageId, outputText);
411
+ }
356
412
  writeResponsesStreamDone(response, responseId, normalized.model, messageId, outputText);
357
413
  response.end();
358
414
  return;
@@ -809,9 +865,9 @@ function startCopilotBridgeServer(args) {
809
865
  */
810
866
  async function waitForCopilotBridgeHealth(host, port, attempts = 10, delayMs = 150) {
811
867
  for (let index = 0; index < attempts; index += 1) {
812
- const result = await healthcheckCopilotBridge(host, port);
868
+ const result = await probeBridgeEndpoint({ host, port, stage: "health" });
813
869
  if (result.ok) {
814
- return result;
870
+ return { ok: true };
815
871
  }
816
872
  await new Promise((resolve) => setTimeout(resolve, delayMs));
817
873
  }
@@ -896,9 +952,9 @@ async function waitForCopilotBridgeStartup(child, host, port, attempts, delayMs)
896
952
  cause: startupFailure,
897
953
  };
898
954
  }
899
- const result = await healthcheckCopilotBridge(host, port);
955
+ const result = await probeBridgeEndpoint({ host, port, stage: "health" });
900
956
  if (result.ok) {
901
- return result;
957
+ return { ok: true };
902
958
  }
903
959
  await new Promise((resolve) => setTimeout(resolve, delayMs));
904
960
  }
@@ -920,74 +976,222 @@ async function waitForCopilotBridgeStartup(child, host, port, attempts, delayMs)
920
976
  child.off("exit", onExit);
921
977
  }
922
978
  }
923
- async function healthcheckCopilotBridge(host, port) {
924
- return new Promise((resolve) => {
925
- const request = http.request({
926
- host,
927
- port,
928
- method: "GET",
929
- path: "/healthz",
930
- timeout: 1000,
931
- }, (response) => {
932
- response.resume();
933
- if (response.statusCode === 200) {
934
- resolve({ ok: true });
935
- return;
936
- }
937
- resolve({
979
+ async function evaluateBridgeReuse(args) {
980
+ const probeAt = new Date().toISOString();
981
+ if (!args.current) {
982
+ return {
983
+ reuse: false,
984
+ reason: "no persisted bridge state",
985
+ replacedExisting: false,
986
+ probeAt,
987
+ logPath: args.logPath,
988
+ };
989
+ }
990
+ if (args.current.provider !== args.providerName) {
991
+ return {
992
+ reuse: false,
993
+ reason: `provider mismatch: ${args.current.provider} -> ${args.providerName}`,
994
+ replacedExisting: true,
995
+ probeAt,
996
+ logPath: args.logPath,
997
+ probe: {
938
998
  ok: false,
939
- cause: `Health endpoint returned status ${String(response.statusCode ?? 0)}.`,
940
- });
941
- });
942
- request.on("error", (error) => {
943
- resolve({
999
+ stage: "health",
1000
+ attempts: 0,
1001
+ cause: "provider-mismatch",
1002
+ retryable: false,
1003
+ message: "Persisted bridge provider does not match the requested provider.",
1004
+ },
1005
+ };
1006
+ }
1007
+ if (args.current.baseUrl !== args.expectedBaseUrl) {
1008
+ return {
1009
+ reuse: false,
1010
+ reason: `base URL mismatch: ${args.current.baseUrl} -> ${args.expectedBaseUrl}`,
1011
+ replacedExisting: true,
1012
+ probeAt,
1013
+ logPath: args.logPath,
1014
+ probe: {
944
1015
  ok: false,
945
- cause: error.message,
946
- });
947
- });
948
- request.on("timeout", () => {
949
- request.destroy(new Error("Health endpoint timed out."));
950
- });
951
- request.end();
1016
+ stage: "health",
1017
+ attempts: 0,
1018
+ cause: "base-url-mismatch",
1019
+ retryable: false,
1020
+ message: "Persisted bridge base URL does not match the requested provider runtime base URL.",
1021
+ },
1022
+ };
1023
+ }
1024
+ if (args.current.workerBuildId !== args.workerBuildId) {
1025
+ return {
1026
+ reuse: false,
1027
+ reason: "worker build changed",
1028
+ replacedExisting: true,
1029
+ probeAt,
1030
+ logPath: args.logPath,
1031
+ probe: {
1032
+ ok: false,
1033
+ stage: "health",
1034
+ attempts: 0,
1035
+ cause: "worker-build-stale",
1036
+ retryable: false,
1037
+ message: "Persisted bridge worker build is stale.",
1038
+ },
1039
+ };
1040
+ }
1041
+ const health = await probeBridgeEndpoint({
1042
+ host: args.current.host,
1043
+ port: args.current.port,
1044
+ stage: "health",
1045
+ });
1046
+ appendBridgeLifecycleLog(args.logPath, `probe stage=health attempts=${String(health.attempts)} ok=${String(health.ok)}${health.ok ? "" : ` retryable=${String(health.retryable)} cause=${health.cause}`}`);
1047
+ if (!health.ok) {
1048
+ return {
1049
+ reuse: false,
1050
+ reason: `health probe failed: ${health.message}`,
1051
+ replacedExisting: true,
1052
+ probeAt,
1053
+ logPath: args.logPath,
1054
+ probe: health,
1055
+ };
1056
+ }
1057
+ const auth = await probeBridgeEndpoint({
1058
+ host: args.current.host,
1059
+ port: args.current.port,
1060
+ stage: "auth",
1061
+ apiKey: args.expectedApiKey,
952
1062
  });
1063
+ appendBridgeLifecycleLog(args.logPath, `probe stage=auth attempts=${String(auth.attempts)} ok=${String(auth.ok)}${auth.ok ? "" : ` retryable=${String(auth.retryable)} cause=${auth.cause}`}`);
1064
+ if (!auth.ok) {
1065
+ return {
1066
+ reuse: false,
1067
+ reason: `auth probe failed: ${auth.message}`,
1068
+ replacedExisting: true,
1069
+ probeAt,
1070
+ logPath: args.logPath,
1071
+ probe: auth,
1072
+ };
1073
+ }
1074
+ return {
1075
+ reuse: true,
1076
+ health,
1077
+ auth,
1078
+ probeAt,
1079
+ logPath: args.logPath,
1080
+ };
953
1081
  }
954
- /**
955
- * Checks whether a healthy bridge still accepts the provider's current bearer secret.
956
- */
957
- async function verifyCopilotBridgeAuthorization(host, port, apiKey) {
1082
+ async function probeBridgeEndpoint(args) {
1083
+ for (let attempt = 1; attempt <= BRIDGE_REUSE_ATTEMPTS; attempt += 1) {
1084
+ const result = await requestBridgeProbe(args);
1085
+ if (result.ok) {
1086
+ return {
1087
+ ok: true,
1088
+ stage: args.stage,
1089
+ attempts: attempt,
1090
+ };
1091
+ }
1092
+ if (!result.retryable || attempt === BRIDGE_REUSE_ATTEMPTS) {
1093
+ return {
1094
+ ...result,
1095
+ stage: args.stage,
1096
+ attempts: attempt,
1097
+ };
1098
+ }
1099
+ await new Promise((resolve) => setTimeout(resolve, BRIDGE_REUSE_DELAY_MS));
1100
+ }
1101
+ return {
1102
+ ok: false,
1103
+ stage: args.stage,
1104
+ attempts: BRIDGE_REUSE_ATTEMPTS,
1105
+ cause: "transport-timeout",
1106
+ retryable: true,
1107
+ message: "Bridge probe timed out.",
1108
+ };
1109
+ }
1110
+ async function requestBridgeProbe(args) {
958
1111
  return new Promise((resolve) => {
959
1112
  const request = http.request({
960
- host,
961
- port,
1113
+ host: args.host,
1114
+ port: args.port,
962
1115
  method: "GET",
963
- path: "/v1/models",
964
- timeout: 1000,
965
- headers: {
966
- authorization: `Bearer ${apiKey}`,
967
- },
1116
+ path: args.stage === "health" ? "/healthz" : "/v1/models",
1117
+ timeout: BRIDGE_REUSE_TIMEOUT_MS,
1118
+ headers: args.stage === "auth"
1119
+ ? {
1120
+ authorization: `Bearer ${args.apiKey ?? ""}`,
1121
+ }
1122
+ : undefined,
968
1123
  }, (response) => {
969
1124
  response.resume();
970
1125
  if (response.statusCode === 200) {
971
1126
  resolve({ ok: true });
972
1127
  return;
973
1128
  }
1129
+ if (args.stage === "auth" && (response.statusCode === 401 || response.statusCode === 403)) {
1130
+ resolve({
1131
+ ok: false,
1132
+ cause: "auth-rejected",
1133
+ retryable: false,
1134
+ statusCode: response.statusCode,
1135
+ message: `Authorization probe returned status ${String(response.statusCode)}.`,
1136
+ });
1137
+ return;
1138
+ }
974
1139
  resolve({
975
1140
  ok: false,
976
- cause: `Authorization probe returned status ${String(response.statusCode ?? 0)}.`,
1141
+ cause: args.stage === "health" ? "health-non-200" : "transport-error",
1142
+ retryable: false,
1143
+ statusCode: response.statusCode,
1144
+ message: args.stage === "health"
1145
+ ? `Health endpoint returned status ${String(response.statusCode ?? 0)}.`
1146
+ : `Authorization probe returned status ${String(response.statusCode ?? 0)}.`,
977
1147
  });
978
1148
  });
979
1149
  request.on("error", (error) => {
1150
+ const classified = classifyProbeTransportError(error);
980
1151
  resolve({
981
1152
  ok: false,
982
- cause: error.message,
1153
+ cause: classified.cause,
1154
+ retryable: classified.retryable,
1155
+ message: error.message,
983
1156
  });
984
1157
  });
985
1158
  request.on("timeout", () => {
986
- request.destroy(new Error("Authorization probe timed out."));
1159
+ request.destroy(new Error(args.stage === "health" ? "Health endpoint timed out." : "Authorization probe timed out."));
987
1160
  });
988
1161
  request.end();
989
1162
  });
990
1163
  }
1164
+ function classifyProbeTransportError(error) {
1165
+ const message = error.message.toLowerCase();
1166
+ if (message.includes("timed out") ||
1167
+ message.includes("econnrefused") ||
1168
+ message.includes("econnreset") ||
1169
+ message.includes("socket hang up") ||
1170
+ message.includes("epipe")) {
1171
+ return {
1172
+ cause: message.includes("timed out") ? "transport-timeout" : "transport-error",
1173
+ retryable: true,
1174
+ };
1175
+ }
1176
+ return {
1177
+ cause: "transport-error",
1178
+ retryable: false,
1179
+ };
1180
+ }
1181
+ function ensureBridgeLogFile(logPath) {
1182
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
1183
+ if (!fs.existsSync(logPath)) {
1184
+ fs.writeFileSync(logPath, "", "utf8");
1185
+ }
1186
+ }
1187
+ function openBridgeLogFd(logPath) {
1188
+ ensureBridgeLogFile(logPath);
1189
+ return fs.openSync(logPath, "a");
1190
+ }
1191
+ function appendBridgeLifecycleLog(logPath, message) {
1192
+ ensureBridgeLogFile(logPath);
1193
+ fs.appendFileSync(logPath, `[${new Date().toISOString()}] ${message}\n`, "utf8");
1194
+ }
991
1195
  async function readJsonBody(request) {
992
1196
  const chunks = [];
993
1197
  for await (const chunk of request) {
@@ -37,6 +37,7 @@ exports.setCopilotCliSpawnImplementation = setCopilotCliSpawnImplementation;
37
37
  exports.resetCopilotCliSpawnImplementation = resetCopilotCliSpawnImplementation;
38
38
  exports.checkCopilotCliAvailable = checkCopilotCliAvailable;
39
39
  exports.resolveCopilotCliInvocation = resolveCopilotCliInvocation;
40
+ exports.resolveCopilotSdkRuntimeInvocation = resolveCopilotSdkRuntimeInvocation;
40
41
  exports.runCopilotLogin = runCopilotLogin;
41
42
  const fs = __importStar(require("node:fs"));
42
43
  const path = __importStar(require("node:path"));
@@ -85,6 +86,20 @@ function checkCopilotCliAvailable(runtimesDir) {
85
86
  function resolveCopilotCliInvocation(args = [], runtimesDir) {
86
87
  return getCopilotInvocation(args, runtimesDir);
87
88
  }
89
+ /**
90
+ * Resolves the explicit runtime entrypoint required by the Copilot SDK.
91
+ */
92
+ function resolveCopilotSdkRuntimeInvocation(runtimesDir) {
93
+ const installDir = (0, copilot_installer_1.getCopilotRuntimeInstallDir)(runtimesDir);
94
+ const loaderPath = path.join(installDir, "node_modules", "@github", "copilot", "npm-loader.js");
95
+ if (!fs.existsSync(loaderPath)) {
96
+ return null;
97
+ }
98
+ return {
99
+ path: loaderPath,
100
+ args: [],
101
+ };
102
+ }
88
103
  /**
89
104
  * Launches the official `copilot login` flow in the current terminal.
90
105
  */
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.getCopilotBridgeStatePath = getCopilotBridgeStatePath;
37
+ exports.getCopilotBridgeLogPath = getCopilotBridgeLogPath;
37
38
  exports.readCopilotBridgeState = readCopilotBridgeState;
38
39
  exports.inspectCopilotBridgeState = inspectCopilotBridgeState;
39
40
  exports.writeCopilotBridgeState = writeCopilotBridgeState;
@@ -54,6 +55,13 @@ function getCopilotBridgeStatePath(runtimeDir) {
54
55
  const baseRuntimeDir = runtimeDir ? path.resolve(runtimeDir) : path.join((0, codex_paths_1.resolveCodexSwitchHome)(), "runtime");
55
56
  return path.join(baseRuntimeDir, "copilot-bridge-state.json");
56
57
  }
58
+ /**
59
+ * Returns the persisted bridge runtime log path colocated with the bridge state manifest.
60
+ */
61
+ function getCopilotBridgeLogPath(runtimeDir) {
62
+ const statePath = getCopilotBridgeStatePath(runtimeDir);
63
+ return path.join(path.dirname(statePath), "copilot-bridge.log");
64
+ }
57
65
  /**
58
66
  * Reads the Copilot bridge state manifest when present.
59
67
  */
@@ -0,0 +1,10 @@
1
+ # codex-switch v0.1.3 Design
2
+
3
+ `0.1.3` is a targeted Copilot login compatibility repair release.
4
+
5
+ ## Design Notes
6
+
7
+ - The SDK adapter constructs `CopilotClient` through `RuntimeConnection.forStdio({ path })`.
8
+ - The runtime path passed to the SDK resolves to the managed `@github/copilot/npm-loader.js` entrypoint.
9
+ - Human terminal commands such as `copilot --help` and `copilot login` continue to use the bundled `.bin` shim so interactive onboarding behavior remains unchanged.
10
+ - The release adds regression coverage for the constructor compatibility path and runtime loader resolution.
@@ -0,0 +1,18 @@
1
+ # codex-switch v0.1.4 Design
2
+
3
+ `0.1.4` is a bridge reliability and observability repair release.
4
+
5
+ ## Design Notes
6
+
7
+ - Bridge reuse probing is refactored into a structured probe helper that distinguishes transient transport failures from deterministic mismatches.
8
+ - Health and auth reuse probes use 2 attempts, a 2500 ms per-request timeout, and a 250 ms delay between attempts.
9
+ - Existing bridge workers are retried before replacement only for transient timeout and transport failures. Deterministic mismatches still replace immediately: different provider, different base URL, stale worker build, or explicit `401`/`403` auth rejection.
10
+ - Bridge worker stdout/stderr are appended to a persisted runtime log file under the managed runtime-state directory, and that log path is surfaced in bridge results and bridge-related errors.
11
+ - Parent-side lifecycle logging records probe attempts, transient failures, replacement reasons, worker start, startup timeout, startup failure, and startup success without logging secrets.
12
+ - Worker-side stderr logging records startup, shutdown, uncaught exception, and unhandled rejection.
13
+ - The shared test runner discovers `tests/*.spec.js` in sorted order, excludes helper files, and supports both suite export styles already present in the repository: `{ run }` and `{ name, tests: [...] }`.
14
+ - Interactive provider selection uses the structured config once and resolves the visible current hint in this order:
15
+ 1. unique managed mapping from top-level `model_provider`
16
+ 2. fallback to legacy top-level `profile` when top-level `model_provider` is missing or unresolved and the legacy value exactly matches one provider name
17
+ 3. ambiguous marker when multiple providers share the active `model_provider`
18
+ 4. no marker otherwise
@@ -0,0 +1,22 @@
1
+ # codex-switch v0.1.3 PRD
2
+
3
+ ## Version
4
+
5
+ - Version line: `0.1.3`
6
+ - Current repository package version: `0.1.3`
7
+
8
+ ## Summary
9
+
10
+ `0.1.3` is a narrow hotfix release for the broken `login copilot` path. The goal is to restore compatibility with the currently supported official Copilot SDK/runtime pairing without expanding the command surface or the experimental Copilot bridge scope.
11
+
12
+ ## Required Outcome
13
+
14
+ - `codexs login copilot` must no longer fail during `CopilotClient` construction when the managed SDK/runtime is installed.
15
+ - The SDK integration must explicitly point at the managed Copilot runtime loader instead of relying on implicit package discovery.
16
+ - Existing direct-provider behavior and Copilot bridge behavior remain unchanged outside the constructor compatibility fix.
17
+
18
+ ## Non-Goals
19
+
20
+ - No new Copilot features or new upstream families.
21
+ - No migration shims or backward-compatibility preservation for older local experimental runtime state.
22
+ - No changes to the managed SDK pin or Node version requirements.
@@ -0,0 +1,37 @@
1
+ # codex-switch v0.1.4 PRD
2
+
3
+ ## Version
4
+
5
+ - Version line: `0.1.4`
6
+ - Target repository package version: `0.1.4`
7
+
8
+ ## Summary
9
+
10
+ `0.1.4` is a bridge stability and observability release. It is a narrow reliability bridge between the existing Copilot bridge experiment and a stricter release gate, with no new provider families or migration behavior.
11
+
12
+ ## Required Outcome
13
+
14
+ - Copilot bridge reuse must survive one transient health or auth probe failure before the existing worker is replaced.
15
+ - Bridge failures must expose actionable evidence, including the persisted runtime log path and the restart reason when an old worker is recycled.
16
+ - `npm test` must become the widened real release gate by discovering and running the broader repository suite set already present under `tests/`.
17
+ - Interactive provider selection must restore the visible `current` hint for legacy state where only the top-level `profile` is present and `model_provider` is absent.
18
+
19
+ ## Release Scope
20
+
21
+ - Bridge reuse probe hardening for transient transport and timeout failures.
22
+ - Bridge log persistence and surfaced restart metadata across command results and diagnostics.
23
+ - Widened test-runner coverage plus any pre-existing red paths that block the widened suite from becoming the real ship gate.
24
+ - Interactive current-marker repair for legacy top-level `profile` fallback.
25
+
26
+ ## Non-Goals
27
+
28
+ - No new provider families.
29
+ - No migration or backward-compatibility shims beyond the prompt-only legacy `profile` hint fallback.
30
+ - No command-surface expansion outside the new bridge diagnostics and status fields.
31
+
32
+ ## Release Acceptance
33
+
34
+ - `switch` no longer flaps on one transient bridge probe failure.
35
+ - Bridge startup or reuse failures surface the log path and the worker replacement reason when applicable.
36
+ - `npm test` executes the widened deterministic suite set and passes.
37
+ - Interactive provider selection correctly marks the current provider from legacy top-level `profile` state when top-level `model_provider` is absent or unresolved.
@@ -1,6 +1,8 @@
1
1
  # codex-switch Testing Guide
2
2
 
3
3
  This guide records the current `0.1.x` verification contract for release and review work.
4
+
5
+ The current repository line is `0.1.4` and remains an unreleased development line until an explicit release task says otherwise.
4
6
 
5
7
  ## Required checks
6
8
 
@@ -22,7 +24,8 @@ node dist/cli.js --version
22
24
  - Ambiguous active profile: `list`, `status`, and provider pickers must surface ambiguity instead of inventing a unique current provider
23
25
  - `--json` envelope: top-level `ok`, `command`, `data`, `warnings`, and `error` must remain stable
24
26
  - `migrate`: advanced adopt helper only
25
- - `setup`: deprecated entry only
27
+ - `setup`: deprecated entry only
28
+ - Release hygiene: `package.json`, `package-lock.json`, current-line docs, changelog top entry, and current PRD/Design fact sources must agree on the `0.1.4` development line
26
29
 
27
30
  ## Fixture guidance
28
31
 
package/docs/cli-usage.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # codex-switch CLI Usage
2
2
 
3
- This document describes the current `0.1.2` CLI contract for `@minniexcode/codex-switch`, including the Copilot runtime repair boundary.
3
+ This document describes the current `0.1.4` repository development-line CLI contract for `@minniexcode/codex-switch`, including the bridge stability and observability boundary.
4
4
 
5
5
  Executable command name:
6
6
 
@@ -10,9 +10,9 @@ codexs
10
10
 
11
11
  ## 1. Version Context
12
12
 
13
- The current package version in this repository is `0.1.2`.
13
+ The current package version in this repository is `0.1.4`.
14
14
 
15
- This release line targets Codex `0.134.0+`. The public contract assumes runtime routing is selected by top-level `model` plus `model_provider`, while legacy `profile` and `[profiles.*]` remain inspect-and-adopt inputs instead of the recommended runtime path.
15
+ This release line targets Codex `0.134.0+`. The public contract assumes runtime routing is selected by top-level `model` plus `model_provider`, while legacy `profile` and `[profiles.*]` remain inspect-and-adopt inputs instead of the recommended runtime path. The current `0.1.4` development line tightens bridge reliability and release-hygiene verification without expanding the provider command surface.
16
16
 
17
17
  ## 2. Primary Workflows
18
18
 
@@ -287,4 +287,8 @@ codexs rollback [backup-id]
287
287
  - [PRD 0.1.0](./PRD/codex-switch-prd-v0.1.0.md)
288
288
  - [PRD 0.1.1](./PRD/codex-switch-prd-v0.1.1.md)
289
289
  - [PRD 0.1.2](./PRD/codex-switch-prd-v0.1.2.md)
290
+ - [PRD 0.1.3](./PRD/codex-switch-prd-v0.1.3.md)
291
+ - [PRD 0.1.4](./PRD/codex-switch-prd-v0.1.4.md)
290
292
  - [Design 0.1.2](./Design/codex-switch-v0.1.2-design.md)
293
+ - [Design 0.1.3](./Design/codex-switch-v0.1.3-design.md)
294
+ - [Design 0.1.4](./Design/codex-switch-v0.1.4-design.md)
@@ -4,13 +4,17 @@
4
4
 
5
5
  这份文档介绍当前活跃产品事实源下的 `codex-switch` 产品定位。
6
6
 
7
- 当前稳定 release contract 以这些文档为准:
7
+ 当前仓库开发线 fact source 以这些文档为准:
8
8
 
9
9
  - [`cli-usage.md`](./cli-usage.md)
10
10
  - [`PRD/codex-switch-prd-v0.1.0.md`](./PRD/codex-switch-prd-v0.1.0.md)
11
11
  - [`PRD/codex-switch-prd-v0.1.1.md`](./PRD/codex-switch-prd-v0.1.1.md)
12
- - [`PRD/codex-switch-prd-v0.1.2.md`](./PRD/codex-switch-prd-v0.1.2.md)(规划中)
13
- - [`Design/codex-switch-v0.1.2-design.md`](./Design/codex-switch-v0.1.2-design.md)(规划中)
12
+ - [`PRD/codex-switch-prd-v0.1.2.md`](./PRD/codex-switch-prd-v0.1.2.md)
13
+ - [`PRD/codex-switch-prd-v0.1.3.md`](./PRD/codex-switch-prd-v0.1.3.md)
14
+ - [`PRD/codex-switch-prd-v0.1.4.md`](./PRD/codex-switch-prd-v0.1.4.md)
15
+ - [`Design/codex-switch-v0.1.2-design.md`](./Design/codex-switch-v0.1.2-design.md)
16
+ - [`Design/codex-switch-v0.1.3-design.md`](./Design/codex-switch-v0.1.3-design.md)
17
+ - [`Design/codex-switch-v0.1.4-design.md`](./Design/codex-switch-v0.1.4-design.md)
14
18
 
15
19
  ## 产品概述
16
20
 
@@ -88,4 +92,4 @@ codexs migrate
88
92
  - `status` / `doctor` 如何帮助定位下一步
89
93
  - 当前运行态是用顶层 `model` 与 `model_provider` 选择活动路由
90
94
 
91
- `0.1.2` 是规划中的 Copilot runtime 修复线,不是当前已发布包版本。当前实现边界是:Copilot 路径要求 Node.js `>=20`,受管安装默认固定到 `@github/copilot-sdk@1.0.2`,运行时会额外拒绝过旧版本和 prerelease 版本,并在真正创建 client 或 session 时验证 SDK API shape;本地 bridge 仍然只是面向 simple text-oriented turns 的 experimental bridgeDirect provider 路径继续支持 Node.js `>=18`。
95
+ `0.1.4` 是当前仓库开发线,重点不是扩展 provider 面,而是把已有 Copilot bridge 实验路径变得更稳、更可诊断,并把 release hygiene 拉进真实门禁。当前实现边界仍然是:Copilot 路径要求 Node.js `>=20`,受管安装默认固定到 `@github/copilot-sdk@1.0.2`,本地 bridge 仍然只是面向 simple text-oriented turns 的 experimental bridgeDirect provider 路径继续支持 Node.js `>=18`。
@@ -5,10 +5,14 @@
5
5
  - [`cli-usage.md`](./cli-usage.md)
6
6
  - [`PRD/codex-switch-prd-v0.1.0.md`](./PRD/codex-switch-prd-v0.1.0.md)
7
7
  - [`PRD/codex-switch-prd-v0.1.1.md`](./PRD/codex-switch-prd-v0.1.1.md)
8
- - [`PRD/codex-switch-prd-v0.1.2.md`](./PRD/codex-switch-prd-v0.1.2.md) (planned)
8
+ - [`PRD/codex-switch-prd-v0.1.2.md`](./PRD/codex-switch-prd-v0.1.2.md)
9
+ - [`PRD/codex-switch-prd-v0.1.3.md`](./PRD/codex-switch-prd-v0.1.3.md)
10
+ - [`PRD/codex-switch-prd-v0.1.4.md`](./PRD/codex-switch-prd-v0.1.4.md)
9
11
  - [`Design/codex-switch-v0.1.0-design.md`](./Design/codex-switch-v0.1.0-design.md)
10
12
  - [`Design/codex-switch-v0.1.1-design.md`](./Design/codex-switch-v0.1.1-design.md)
11
- - [`Design/codex-switch-v0.1.2-design.md`](./Design/codex-switch-v0.1.2-design.md) (planned)
13
+ - [`Design/codex-switch-v0.1.2-design.md`](./Design/codex-switch-v0.1.2-design.md)
14
+ - [`Design/codex-switch-v0.1.3-design.md`](./Design/codex-switch-v0.1.3-design.md)
15
+ - [`Design/codex-switch-v0.1.4-design.md`](./Design/codex-switch-v0.1.4-design.md)
12
16
 
13
17
  ## Layers
14
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minniexcode/codex-switch",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Local-first CLI for managing and switching Codex provider/model-provider routing.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",