@plexor-dev/claude-code-plugin-staging 0.1.0-beta.25 → 0.1.0-beta.27

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.
@@ -115,9 +115,9 @@ function main() {
115
115
  process.exit(1);
116
116
  }
117
117
 
118
- // CRITICAL FIX: Disable Claude Code routing in ~/.claude/settings.json
119
- // This removes ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN so Claude Code
120
- // no longer tries to route through Plexor with removed credentials
118
+ // Disable Claude Code routing in ~/.claude/settings.json.
119
+ // This removes the Plexor gateway URL and managed Plexor headers so Claude
120
+ // no longer routes through Plexor with removed credentials.
121
121
  const routingDisabled = settingsManager.disablePlexorRouting();
122
122
 
123
123
  // Clear session
@@ -93,7 +93,7 @@ function getGatewayLabel(apiUrl) {
93
93
  }
94
94
 
95
95
  function isRunningInsideClaudeSession(env = process.env) {
96
- return Boolean(env.CLAUDECODE);
96
+ return Boolean(env.CLAUDECODE || env.PLEXOR_SETUP_IN_CLAUDE);
97
97
  }
98
98
 
99
99
  function createSkipVerifyResult() {
@@ -143,6 +143,14 @@ function printUsage() {
143
143
  console.log(' PLEXOR_API_KEY=plx_... /plexor-setup');
144
144
  }
145
145
 
146
+ function printRestartBanner() {
147
+ const BRIGHT_YELLOW = '\x1b[93m';
148
+ const BOLD = '\x1b[1m';
149
+ const RESET = '\x1b[0m';
150
+ console.log('');
151
+ console.log(`${BOLD}${BRIGHT_YELLOW}RESTART CLAUDE NOW.${RESET}`);
152
+ }
153
+
146
154
  function printReceipt({ user, gateway, previousAuthPreserved, verifyResult }) {
147
155
  const line = (content) => `│ ${String(content).slice(0, 43).padEnd(43)}│`;
148
156
 
@@ -169,7 +177,11 @@ function printReceipt({ user, gateway, previousAuthPreserved, verifyResult }) {
169
177
  }
170
178
  console.log('└─────────────────────────────────────────────┘');
171
179
 
172
- if (!verifyResult.ok) {
180
+ if (verifyResult.pendingRestart) {
181
+ printRestartBanner();
182
+ }
183
+
184
+ if (!verifyResult.ok && !verifyResult.pendingRestart) {
173
185
  console.log('');
174
186
  console.log(`Verification failed: ${verifyResult.reason}`);
175
187
  }
@@ -1,14 +1,14 @@
1
1
  description: Guided first-run setup for Plexor with Claude Code (user)
2
2
  ---
3
3
 
4
- **RULE: Execute this workflow EXACTLY ONCE. After the Bash tool returns output, your ONLY action is to present that output to the user. DO NOT restart the workflow.**
4
+ **RULE: Execute this workflow EXACTLY ONCE. After the Bash tool returns output, your ONLY action is to present that output to the user. If the output says restart is required, add one standalone line exactly `RESTART CLAUDE NOW.` after the tool output. DO NOT restart the workflow.**
5
5
 
6
6
  Plexor setup is the primary human setup flow.
7
7
 
8
8
  If `$ARGUMENTS` already contains a Plexor API key (`plx_...`), run:
9
9
 
10
10
  ```bash
11
- node ~/.claude/plugins/plexor/commands/plexor-setup.js $ARGUMENTS
11
+ PLEXOR_SETUP_IN_CLAUDE=1 node ~/.claude/plugins/plexor/commands/plexor-setup.js $ARGUMENTS
12
12
  ```
13
13
 
14
14
  If the user did not provide a key yet, ask them:
@@ -18,11 +18,11 @@ If the user did not provide a key yet, ask them:
18
18
  After the user replies with the key, run:
19
19
 
20
20
  ```bash
21
- node ~/.claude/plugins/plexor/commands/plexor-setup.js <user_key>
21
+ PLEXOR_SETUP_IN_CLAUDE=1 node ~/.claude/plugins/plexor/commands/plexor-setup.js <user_key>
22
22
  ```
23
23
 
24
24
  This command:
25
25
  - saves the Plexor key
26
26
  - routes Claude through the Plexor staging gateway
27
- - preserves prior direct Claude auth for restore on logout/uninstall
27
+ - keeps Claude's existing direct auth intact and adds Plexor auth via managed headers
28
28
  - runs a deterministic Claude verification step
@@ -8,6 +8,7 @@
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
10
  const https = require('https');
11
+ const { parseCustomHeaders } = require('../lib/config-utils');
11
12
 
12
13
  // Import centralized constants with HOME directory validation
13
14
  const { HOME_DIR, CONFIG_PATH, SESSION_PATH, SESSION_TIMEOUT_MS } = require('../lib/constants');
@@ -26,12 +27,15 @@ function isManagedGatewayUrl(baseUrl = '') {
26
27
  }
27
28
 
28
29
  function getPlexorAuthKey(env = {}) {
30
+ const headers = parseCustomHeaders(env.ANTHROPIC_CUSTOM_HEADERS);
31
+ const headerApiKey = headers['x-plexor-key'] || '';
29
32
  const apiKey = env.ANTHROPIC_API_KEY || '';
30
33
  const authToken = env.ANTHROPIC_AUTH_TOKEN || '';
31
34
 
35
+ if (headerApiKey.startsWith('plx_')) return headerApiKey;
32
36
  if (apiKey.startsWith('plx_')) return apiKey;
33
37
  if (authToken.startsWith('plx_')) return authToken;
34
- return apiKey || authToken || '';
38
+ return '';
35
39
  }
36
40
 
37
41
  function getDirectClaudeAuthState() {
@@ -18,18 +18,24 @@
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
20
  const os = require('os');
21
+ const { execSync } = require('child_process');
21
22
  const { removeManagedStatusLine } = require('../lib/statusline-manager');
22
23
  const { removeManagedHooks, cleanupLegacyManagedHooksFile } = require('../lib/hooks-manager');
23
24
  const { removeManagedClaudeCustomHeadersFromEnv } = require('../lib/config-utils');
24
25
 
25
26
  // Get home directory, handling sudo case
26
27
  function getHomeDir() {
27
- if (process.env.SUDO_USER) {
28
- const platform = os.platform();
29
- if (platform === 'darwin') {
30
- return path.join('/Users', process.env.SUDO_USER);
31
- } else if (platform === 'linux') {
32
- return path.join('/home', process.env.SUDO_USER);
28
+ if (os.platform() !== 'win32' && process.env.SUDO_USER) {
29
+ try {
30
+ if (typeof process.getuid === 'function' && process.getuid() === 0) {
31
+ const entry = execSync(`getent passwd ${process.env.SUDO_USER}`, { encoding: 'utf8' }).trim();
32
+ const fields = entry.split(':');
33
+ if (fields.length >= 6 && fields[5]) {
34
+ return fields[5];
35
+ }
36
+ }
37
+ } catch {
38
+ // Fall through to HOME/os.homedir below.
33
39
  }
34
40
  }
35
41
  return process.env.HOME || process.env.USERPROFILE || os.homedir();
@@ -12,6 +12,7 @@ const DISABLED_HINT_VALUES = new Set(['', 'auto', 'none', 'off']);
12
12
  const VALID_ORCHESTRATION_MODES = new Set(['supervised', 'autonomous', 'danger-full-auto']);
13
13
  const VALID_ROUTING_MODES = new Set(['eco', 'balanced', 'quality', 'passthrough', 'cost']);
14
14
  const MANAGED_HEADER_KEYS = new Set([
15
+ 'x-plexor-key',
15
16
  'x-force-provider',
16
17
  'x-force-model',
17
18
  'x-allow-providers',
@@ -23,6 +24,10 @@ const MANAGED_HEADER_KEYS = new Set([
23
24
  ]);
24
25
  const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
25
26
 
27
+ function isPlexorApiKey(value = '') {
28
+ return typeof value === 'string' && value.startsWith('plx_');
29
+ }
30
+
26
31
  function normalizeForcedProvider(value) {
27
32
  if (typeof value !== 'string') {
28
33
  return null;
@@ -137,9 +142,39 @@ function removeManagedClaudeCustomHeadersFromEnv(env = {}) {
137
142
  return true;
138
143
  }
139
144
 
145
+ function getManagedPlexorAuthHeader(env = {}) {
146
+ const existing = parseCustomHeaders(env.ANTHROPIC_CUSTOM_HEADERS);
147
+ const managedAuthKey = existing['x-plexor-key'] || '';
148
+ return isPlexorApiKey(managedAuthKey) ? managedAuthKey : '';
149
+ }
150
+
151
+ function upsertManagedPlexorAuthHeader(env = {}, apiKey = '') {
152
+ const existing = parseCustomHeaders(env.ANTHROPIC_CUSTOM_HEADERS);
153
+ const previousHeaders = env.ANTHROPIC_CUSTOM_HEADERS || '';
154
+
155
+ if (isPlexorApiKey(apiKey)) {
156
+ existing['x-plexor-key'] = apiKey;
157
+ } else {
158
+ delete existing['x-plexor-key'];
159
+ }
160
+
161
+ if (Object.keys(existing).length) {
162
+ env.ANTHROPIC_CUSTOM_HEADERS = serializeCustomHeaders(existing);
163
+ } else {
164
+ delete env.ANTHROPIC_CUSTOM_HEADERS;
165
+ }
166
+
167
+ return (env.ANTHROPIC_CUSTOM_HEADERS || '') !== previousHeaders;
168
+ }
169
+
140
170
  function buildManagedAnthropicHeaders(config) {
141
171
  const settings = config?.settings || {};
142
172
  const headers = {};
173
+ const apiKey = config?.auth?.api_key || config?.auth?.apiKey || config?.apiKey || '';
174
+
175
+ if (isPlexorApiKey(apiKey)) {
176
+ headers['x-plexor-key'] = apiKey;
177
+ }
143
178
 
144
179
  const modeRaw = String(settings.mode || '')
145
180
  .trim()
@@ -194,6 +229,25 @@ function buildManagedAnthropicHeaders(config) {
194
229
  return headers;
195
230
  }
196
231
 
232
+ function applyManagedClaudeCustomHeadersToEnv(env = {}, config = {}) {
233
+ const previousHeaders = env.ANTHROPIC_CUSTOM_HEADERS || '';
234
+ const existing = parseCustomHeaders(env.ANTHROPIC_CUSTOM_HEADERS);
235
+ for (const key of MANAGED_HEADER_KEYS) {
236
+ delete existing[key];
237
+ }
238
+
239
+ const managed = buildManagedAnthropicHeaders(config);
240
+ const merged = { ...existing, ...managed };
241
+
242
+ if (Object.keys(merged).length) {
243
+ env.ANTHROPIC_CUSTOM_HEADERS = serializeCustomHeaders(merged);
244
+ } else {
245
+ delete env.ANTHROPIC_CUSTOM_HEADERS;
246
+ }
247
+
248
+ return (env.ANTHROPIC_CUSTOM_HEADERS || '') !== previousHeaders;
249
+ }
250
+
197
251
  function syncClaudeCustomHeaders(config) {
198
252
  try {
199
253
  let settings = {};
@@ -207,19 +261,7 @@ function syncClaudeCustomHeaders(config) {
207
261
  }
208
262
  settings.env = settings.env && typeof settings.env === 'object' ? settings.env : {};
209
263
 
210
- const existing = parseCustomHeaders(settings.env.ANTHROPIC_CUSTOM_HEADERS);
211
- for (const key of MANAGED_HEADER_KEYS) {
212
- delete existing[key];
213
- }
214
-
215
- const managed = buildManagedAnthropicHeaders(config);
216
- const merged = { ...existing, ...managed };
217
-
218
- if (Object.keys(merged).length) {
219
- settings.env.ANTHROPIC_CUSTOM_HEADERS = serializeCustomHeaders(merged);
220
- } else {
221
- delete settings.env.ANTHROPIC_CUSTOM_HEADERS;
222
- }
264
+ applyManagedClaudeCustomHeadersToEnv(settings.env, config);
223
265
 
224
266
  const claudeDir = path.dirname(CLAUDE_SETTINGS_PATH);
225
267
  if (!fs.existsSync(claudeDir)) {
@@ -332,6 +374,9 @@ module.exports = {
332
374
  readSetting,
333
375
  hasForcedHintConflict,
334
376
  validateForcedHintConfig,
377
+ applyManagedClaudeCustomHeadersToEnv,
378
+ getManagedPlexorAuthHeader,
379
+ upsertManagedPlexorAuthHeader,
335
380
  removeManagedClaudeCustomHeadersFromEnv,
336
381
  parseCustomHeaders,
337
382
  serializeCustomHeaders,
@@ -14,12 +14,15 @@ const fs = require('fs');
14
14
  const path = require('path');
15
15
  const os = require('os');
16
16
  const crypto = require('crypto');
17
- const { removeManagedClaudeCustomHeadersFromEnv } = require('./config-utils');
17
+ const {
18
+ getManagedPlexorAuthHeader,
19
+ removeManagedClaudeCustomHeadersFromEnv,
20
+ upsertManagedPlexorAuthHeader
21
+ } = require('./config-utils');
18
22
 
19
23
  const CLAUDE_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude');
20
24
  const CLAUDE_STATE_PATH = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude.json');
21
25
  const SETTINGS_PATH = path.join(CLAUDE_DIR, 'settings.json');
22
- const LOCK_TIMEOUT_MS = 5000; // 5 second lock timeout
23
26
 
24
27
  // Plexor gateway endpoints
25
28
  const PLEXOR_STAGING_URL = 'https://staging.api.plexor.dev/gateway/anthropic';
@@ -49,44 +52,47 @@ function isPlexorApiKey(value = '') {
49
52
  }
50
53
 
51
54
  function getPlexorAuthKey(env = {}) {
55
+ const headerKey = getManagedPlexorAuthHeader(env);
52
56
  const apiKey = env.ANTHROPIC_API_KEY || '';
53
57
  const authToken = env.ANTHROPIC_AUTH_TOKEN || '';
54
58
 
59
+ if (isPlexorApiKey(headerKey)) return headerKey;
55
60
  if (isPlexorApiKey(apiKey)) return apiKey;
56
61
  if (isPlexorApiKey(authToken)) return authToken;
57
- return apiKey || authToken || '';
62
+ return '';
58
63
  }
59
64
 
60
- function hasPlexorManagedAuth(env = {}) {
61
- return (
62
- isManagedGatewayUrl(env.ANTHROPIC_BASE_URL || '') ||
63
- isPlexorApiKey(env.ANTHROPIC_API_KEY || '') ||
64
- isPlexorApiKey(env.ANTHROPIC_AUTH_TOKEN || '')
65
- );
66
- }
67
-
68
- function stashDirectAuthEnv(env, plexorApiKey) {
69
- const alreadyManaged = hasPlexorManagedAuth(env);
70
- const currentApiKey = env.ANTHROPIC_API_KEY || '';
71
- const currentAuthToken = env.ANTHROPIC_AUTH_TOKEN || '';
65
+ function clearLegacyPlexorAuthValue(env = {}, field, backupField) {
66
+ let changed = false;
67
+ const currentValue = env[field] || '';
68
+ const backupValue = env[backupField] || '';
72
69
 
73
- if (currentApiKey && !isPlexorApiKey(currentApiKey) && currentApiKey !== plexorApiKey) {
74
- env[PREVIOUS_API_KEY_ENV] = currentApiKey;
75
- } else if (!alreadyManaged) {
76
- delete env[PREVIOUS_API_KEY_ENV];
70
+ if (isPlexorApiKey(currentValue)) {
71
+ if (backupValue && !isPlexorApiKey(backupValue)) {
72
+ env[field] = backupValue;
73
+ } else {
74
+ delete env[field];
75
+ }
76
+ changed = true;
77
77
  }
78
78
 
79
- if (currentAuthToken && !isPlexorApiKey(currentAuthToken) && currentAuthToken !== plexorApiKey) {
80
- env[PREVIOUS_AUTH_TOKEN_ENV] = currentAuthToken;
81
- } else if (!alreadyManaged) {
82
- delete env[PREVIOUS_AUTH_TOKEN_ENV];
79
+ if (backupValue) {
80
+ delete env[backupField];
81
+ changed = true;
83
82
  }
83
+
84
+ return changed;
84
85
  }
85
86
 
86
- function setPlexorAuthKey(env, apiKey) {
87
- stashDirectAuthEnv(env, apiKey);
88
- env.ANTHROPIC_API_KEY = apiKey;
89
- env.ANTHROPIC_AUTH_TOKEN = apiKey;
87
+ function migrateLegacyPlexorAuthEnv(env = {}, claudeState = {}) {
88
+ let changed = false;
89
+
90
+ changed = clearLegacyPlexorAuthValue(env, 'ANTHROPIC_API_KEY', PREVIOUS_API_KEY_ENV) || changed;
91
+ changed = clearLegacyPlexorAuthValue(env, 'ANTHROPIC_AUTH_TOKEN', PREVIOUS_AUTH_TOKEN_ENV) || changed;
92
+
93
+ changed = restoreDirectPrimaryApiKey(env, claudeState) || changed;
94
+
95
+ return changed;
90
96
  }
91
97
 
92
98
  function clearPlexorRoutingEnv(env = {}) {
@@ -122,17 +128,6 @@ function clearPlexorRoutingEnv(env = {}) {
122
128
  return true;
123
129
  }
124
130
 
125
- function stashDirectPrimaryApiKey(env = {}, claudeState = {}, plexorApiKey = '') {
126
- const currentPrimaryApiKey = claudeState.primaryApiKey || '';
127
- if (!currentPrimaryApiKey || isPlexorApiKey(currentPrimaryApiKey) || currentPrimaryApiKey === plexorApiKey) {
128
- return false;
129
- }
130
-
131
- env[PREVIOUS_PRIMARY_API_KEY_ENV] = currentPrimaryApiKey;
132
- delete claudeState.primaryApiKey;
133
- return true;
134
- }
135
-
136
131
  function restoreDirectPrimaryApiKey(env = {}, claudeState = {}, options = {}) {
137
132
  const { consumeBackup = true } = options;
138
133
  const previousPrimaryApiKey = env[PREVIOUS_PRIMARY_API_KEY_ENV] || '';
@@ -356,11 +351,9 @@ class ClaudeSettingsManager {
356
351
  settings.env = {};
357
352
  }
358
353
 
359
- // Mirror the Plexor key into both Claude auth env vars so every Claude
360
- // runtime path uses the gateway instead of any previously saved direct key.
361
354
  settings.env.ANTHROPIC_BASE_URL = apiUrl;
362
- setPlexorAuthKey(settings.env, apiKey);
363
- const claudeStateChanged = stashDirectPrimaryApiKey(settings.env, claudeState, apiKey);
355
+ upsertManagedPlexorAuthHeader(settings.env, apiKey);
356
+ const claudeStateChanged = migrateLegacyPlexorAuthEnv(settings.env, claudeState);
364
357
 
365
358
  const success = this.save(settings);
366
359
  if (!success) {
@@ -493,9 +486,6 @@ class ClaudeSettingsManager {
493
486
  if (isPlexorUrl && !isPlexorApiKey(authKey)) {
494
487
  return { partial: true, issue: 'Plexor URL set but auth key is not a Plexor key' };
495
488
  }
496
- if (isPlexorUrl && claudeState.primaryApiKey && !isPlexorApiKey(claudeState.primaryApiKey)) {
497
- return { partial: true, issue: 'Plexor URL set but Claude managed API key is still active' };
498
- }
499
489
  return { partial: false, issue: null };
500
490
  } catch {
501
491
  return { partial: false, issue: null };
@@ -515,8 +505,19 @@ class ClaudeSettingsManager {
515
505
  settings.env = {};
516
506
  }
517
507
 
518
- setPlexorAuthKey(settings.env, apiKey);
519
- return this.save(settings);
508
+ const previousSettings = JSON.parse(JSON.stringify(settings));
509
+ const claudeState = this.loadClaudeState();
510
+ upsertManagedPlexorAuthHeader(settings.env, apiKey);
511
+ const claudeStateChanged = migrateLegacyPlexorAuthEnv(settings.env, claudeState);
512
+ const success = this.save(settings);
513
+ if (!success) {
514
+ return false;
515
+ }
516
+ if (claudeStateChanged && !this.saveClaudeState(claudeState)) {
517
+ this.save(previousSettings);
518
+ return false;
519
+ }
520
+ return true;
520
521
  } catch (err) {
521
522
  console.error('Failed to update API key:', err.message);
522
523
  return false;
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Plexor Supervisor Emitter — Phases 1-4
3
+ *
4
+ * Phase 1: Basic routing summary
5
+ * [PLEXOR: Routed to {provider}/{model}, {latency}ms, {routing_source}]
6
+ *
7
+ * Phase 2: Enhanced routing with cohort from response body fields
8
+ * [PLEXOR: {provider}/{model}, {latency}ms, {source} | {cohort}]
9
+ *
10
+ * Phase 3: Zero-tool escalation detection (agent_halt / escalation signals)
11
+ * [PLEXOR: Zero-tool escalation: {provider1} → {provider2}]
12
+ *
13
+ * Phase 4: Scaffolding gate blocked detection
14
+ * [PLEXOR: Scaffolding gate: {model} blocked, using {alternative}]
15
+ *
16
+ * This module is consumed by track-response.js to surface routing
17
+ * decisions to the developer without requiring them to parse verbose logs.
18
+ */
19
+
20
+ const CYAN = '\x1b[36m';
21
+ const YELLOW = '\x1b[33m';
22
+ const RED = '\x1b[31m';
23
+ const RESET = '\x1b[0m';
24
+
25
+ class SupervisorEmitter {
26
+ /**
27
+ * @param {object} [opts]
28
+ * @param {boolean} [opts.enabled] — honour PLEXOR_SUPERVISOR env var (default true)
29
+ */
30
+ constructor(opts = {}) {
31
+ const envFlag = process.env.PLEXOR_SUPERVISOR;
32
+ if (envFlag !== undefined) {
33
+ this.enabled = !/^(0|false|no|off)$/i.test(String(envFlag));
34
+ } else {
35
+ this.enabled = opts.enabled !== false;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Build the Phase 2 enhanced supervisor summary from a gateway response.
41
+ * Reads plexor_provider_used, plexor_selected_model, plexor_latency_ms,
42
+ * plexor_routing_source from the response body (not just headers).
43
+ *
44
+ * Format: [PLEXOR: provider/model, latencyms, source | cohort]
45
+ *
46
+ * @param {object} response — the full LLM response object
47
+ * @param {object} [plexorMeta] — the _plexor metadata block (may be absent)
48
+ * @returns {string|null}
49
+ */
50
+ buildSummary(response, plexorMeta) {
51
+ if (!response || typeof response !== 'object') {
52
+ return null;
53
+ }
54
+
55
+ const provider = this._resolveProvider(response, plexorMeta);
56
+ const model = this._resolveModel(response, plexorMeta);
57
+ const latencyMs = this._resolveLatency(response, plexorMeta);
58
+ const routingSource = this._resolveRoutingSource(response, plexorMeta);
59
+ const cohort = this._resolveCohort(response, plexorMeta);
60
+
61
+ // Need at least provider or model to emit anything useful
62
+ if (!provider && !model) {
63
+ return null;
64
+ }
65
+
66
+ const target = [provider, model].filter(Boolean).join('/');
67
+ const parts = [target];
68
+
69
+ if (latencyMs !== null) {
70
+ parts.push(`${latencyMs}ms`);
71
+ }
72
+
73
+ if (routingSource) {
74
+ parts.push(routingSource);
75
+ }
76
+
77
+ let line = parts.join(', ');
78
+
79
+ if (cohort) {
80
+ line += ` | ${cohort}`;
81
+ }
82
+
83
+ return `[PLEXOR: ${line}]`;
84
+ }
85
+
86
+ /**
87
+ * Phase 3: Detect zero-tool escalation signals in the response.
88
+ * Fires when agent_halt is set or escalation_chain / fallback provider data
89
+ * indicates a provider switch due to tool incapability.
90
+ *
91
+ * @param {object} response
92
+ * @param {object} [plexorMeta]
93
+ * @returns {string|null} — escalation message or null
94
+ */
95
+ buildEscalationNotice(response, plexorMeta) {
96
+ if (!response || typeof response !== 'object') {
97
+ return null;
98
+ }
99
+
100
+ const agentHalt = this._toBool(
101
+ response?.plexor_agent_halt ??
102
+ response?.plexor?.agent_halt ??
103
+ plexorMeta?.agent_halt
104
+ );
105
+
106
+ const escalationChain =
107
+ response?.plexor_escalation_chain ||
108
+ response?.plexor?.escalation_chain ||
109
+ plexorMeta?.escalation_chain ||
110
+ null;
111
+
112
+ const fallbackUsed = this._toBool(
113
+ response?.plexor_fallback_used ??
114
+ response?.fallback_used ??
115
+ response?.plexor?.fallback_used
116
+ );
117
+
118
+ const originalProvider =
119
+ response?.plexor_original_provider ||
120
+ response?.plexor?.original_provider ||
121
+ plexorMeta?.original_provider ||
122
+ null;
123
+
124
+ const currentProvider = this._resolveProvider(response, plexorMeta);
125
+
126
+ // Case 1: Explicit escalation chain present (e.g., ["openai-mini", "openai"])
127
+ if (Array.isArray(escalationChain) && escalationChain.length >= 2) {
128
+ const from = escalationChain[0];
129
+ const to = escalationChain[escalationChain.length - 1];
130
+ return `[PLEXOR: Zero-tool escalation: ${from} \u2192 ${to}]`;
131
+ }
132
+
133
+ // Case 2: agent_halt with fallback — provider switched
134
+ if (agentHalt && fallbackUsed && originalProvider && currentProvider && originalProvider !== currentProvider) {
135
+ return `[PLEXOR: Zero-tool escalation: ${originalProvider} \u2192 ${currentProvider}]`;
136
+ }
137
+
138
+ // Case 3: agent_halt alone (escalation happened but we may not know the full chain)
139
+ if (agentHalt && fallbackUsed) {
140
+ const from = originalProvider || 'original';
141
+ const to = currentProvider || 'fallback';
142
+ return `[PLEXOR: Zero-tool escalation: ${from} \u2192 ${to}]`;
143
+ }
144
+
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * Phase 4: Detect scaffolding gate blocks.
150
+ * Fires when scaffolding_gate_blocked is present in the response,
151
+ * indicating a model was blocked by the scaffolding gate and an
152
+ * alternative was used.
153
+ *
154
+ * @param {object} response
155
+ * @param {object} [plexorMeta]
156
+ * @returns {string|null}
157
+ */
158
+ buildScaffoldingGateNotice(response, plexorMeta) {
159
+ if (!response || typeof response !== 'object') {
160
+ return null;
161
+ }
162
+
163
+ const gateBlocked = this._toBool(
164
+ response?.plexor_scaffolding_gate_blocked ??
165
+ response?.scaffolding_gate_blocked ??
166
+ response?.plexor?.scaffolding_gate_blocked ??
167
+ plexorMeta?.scaffolding_gate_blocked
168
+ );
169
+
170
+ if (!gateBlocked) {
171
+ return null;
172
+ }
173
+
174
+ const blockedModel =
175
+ response?.plexor_scaffolding_blocked_model ||
176
+ response?.plexor?.scaffolding_blocked_model ||
177
+ plexorMeta?.scaffolding_blocked_model ||
178
+ response?.plexor_original_model ||
179
+ response?.plexor?.original_model ||
180
+ plexorMeta?.original_model ||
181
+ 'model';
182
+
183
+ const alternative =
184
+ response?.plexor_selected_model ||
185
+ response?.plexor?.selected_model ||
186
+ plexorMeta?.recommended_model ||
187
+ response?.model ||
188
+ 'alternative';
189
+
190
+ return `[PLEXOR: Scaffolding gate: ${blockedModel} blocked, using ${alternative}]`;
191
+ }
192
+
193
+ /**
194
+ * Emit all applicable supervisor lines to stderr if enabled.
195
+ *
196
+ * @param {object} response
197
+ * @param {object} [plexorMeta]
198
+ */
199
+ emit(response, plexorMeta) {
200
+ if (!this.enabled) {
201
+ return;
202
+ }
203
+
204
+ // Phase 4: Scaffolding gate (highest priority — emit first if present)
205
+ const scaffoldingNotice = this.buildScaffoldingGateNotice(response, plexorMeta);
206
+ if (scaffoldingNotice) {
207
+ process.stderr.write(`${RED}${scaffoldingNotice}${RESET}\n`);
208
+ }
209
+
210
+ // Phase 3: Escalation notice
211
+ const escalationNotice = this.buildEscalationNotice(response, plexorMeta);
212
+ if (escalationNotice) {
213
+ process.stderr.write(`${YELLOW}${escalationNotice}${RESET}\n`);
214
+ }
215
+
216
+ // Phase 2: Enhanced routing summary (always emitted when data available)
217
+ const summary = this.buildSummary(response, plexorMeta);
218
+ if (summary) {
219
+ process.stderr.write(`${CYAN}${summary}${RESET}\n`);
220
+ }
221
+ }
222
+
223
+ // ---- private helpers ----
224
+
225
+ _resolveProvider(response, meta) {
226
+ return (
227
+ response?.plexor_provider_used ||
228
+ response?.plexor?.provider_used ||
229
+ meta?.recommended_provider ||
230
+ null
231
+ );
232
+ }
233
+
234
+ _resolveModel(response, meta) {
235
+ return (
236
+ response?.plexor_selected_model ||
237
+ response?.plexor?.selected_model ||
238
+ meta?.recommended_model ||
239
+ response?.model ||
240
+ null
241
+ );
242
+ }
243
+
244
+ _resolveLatency(response, meta) {
245
+ const raw =
246
+ response?.plexor_latency_ms ??
247
+ response?.plexor?.latency_ms ??
248
+ meta?.latency_ms ??
249
+ null;
250
+ if (raw === null || raw === undefined) {
251
+ return null;
252
+ }
253
+ const n = Number(raw);
254
+ return Number.isFinite(n) ? Math.round(n) : null;
255
+ }
256
+
257
+ _resolveRoutingSource(response, meta) {
258
+ return (
259
+ response?.plexor_routing_source ||
260
+ response?.plexor?.routing_source ||
261
+ meta?.source ||
262
+ null
263
+ );
264
+ }
265
+
266
+ _resolveCohort(response, meta) {
267
+ return (
268
+ response?.plexor_cohort ||
269
+ response?.plexor?.cohort ||
270
+ meta?.cohort ||
271
+ null
272
+ );
273
+ }
274
+
275
+ _toBool(value) {
276
+ if (value === true || value === 'true' || value === '1' || value === 1) return true;
277
+ if (value === false || value === 'false' || value === '0' || value === 0) return false;
278
+ return null;
279
+ }
280
+ }
281
+
282
+ module.exports = { SupervisorEmitter };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plexor-dev/claude-code-plugin-staging",
3
- "version": "0.1.0-beta.25",
3
+ "version": "0.1.0-beta.27",
4
4
  "description": "STAGING - LLM cost optimization plugin for Claude Code (internal testing)",
5
5
  "main": "lib/constants.js",
6
6
  "bin": {
@@ -21,7 +21,12 @@ if [ -f "$CONFIG_FILE" ]; then
21
21
  if [ "$ENABLED" = "True" ] && [ -n "$API_URL" ] && [ -n "$API_KEY" ]; then
22
22
  # Set ANTHROPIC_BASE_URL to Plexor gateway (hypervisor mode)
23
23
  export ANTHROPIC_BASE_URL="${API_URL}/gateway/anthropic/v1"
24
- export ANTHROPIC_API_KEY="$API_KEY"
24
+ if [ -n "$ANTHROPIC_CUSTOM_HEADERS" ]; then
25
+ export ANTHROPIC_CUSTOM_HEADERS="x-plexor-key: ${API_KEY}
26
+ ${ANTHROPIC_CUSTOM_HEADERS}"
27
+ else
28
+ export ANTHROPIC_CUSTOM_HEADERS="x-plexor-key: ${API_KEY}"
29
+ fi
25
30
 
26
31
  # Show Plexor branding
27
32
  echo -e "${CYAN}"
@@ -11,6 +11,10 @@ const fs = require('fs');
11
11
  const path = require('path');
12
12
  const os = require('os');
13
13
  const { execSync } = require('child_process');
14
+ const {
15
+ applyManagedClaudeCustomHeadersToEnv,
16
+ getManagedPlexorAuthHeader
17
+ } = require('../lib/config-utils');
14
18
  const { upsertManagedStatusLine } = require('../lib/statusline-manager');
15
19
  const { upsertManagedHooks, cleanupLegacyManagedHooksFile } = require('../lib/hooks-manager');
16
20
 
@@ -178,31 +182,42 @@ const PREVIOUS_PRIMARY_API_KEY_ENV = 'PLEXOR_PREVIOUS_CLAUDE_PRIMARY_API_KEY';
178
182
  /**
179
183
  * Check for orphaned Plexor routing in settings.json without valid config.
180
184
  * Also detects variant mismatch (e.g., localhost plugin was installed, now
181
- * installing staging plugin) and migrates ANTHROPIC_BASE_URL + syncs
182
- * Claude auth env vars for Plexor-managed gateways.
185
+ * installing staging plugin) and migrates legacy env-based Plexor auth into
186
+ * managed Claude custom headers.
183
187
  */
184
- function selectManagedAuthKey(env = {}) {
188
+ function selectManagedAuthKey(env = {}, config = {}) {
189
+ const configApiKey = config.auth?.api_key || config.auth?.apiKey || config.apiKey || '';
185
190
  const apiKey = env.ANTHROPIC_API_KEY || '';
186
191
  const authToken = env.ANTHROPIC_AUTH_TOKEN || '';
192
+ const headerApiKey = getManagedPlexorAuthHeader(env);
187
193
 
194
+ if (configApiKey.startsWith('plx_')) return configApiKey;
195
+ if (headerApiKey.startsWith('plx_')) return headerApiKey;
188
196
  if (apiKey.startsWith('plx_')) return apiKey;
189
197
  if (authToken.startsWith('plx_')) return authToken;
190
- return apiKey || authToken || '';
198
+ return '';
191
199
  }
192
200
 
193
- function syncManagedAuthEnv(env, managedAuthKey) {
194
- const currentApiKey = env.ANTHROPIC_API_KEY || '';
195
- const currentAuthToken = env.ANTHROPIC_AUTH_TOKEN || '';
201
+ function restoreLegacyManagedAuthEnvValue(env, field, backupField) {
202
+ let changed = false;
203
+ const currentValue = env[field] || '';
204
+ const backupValue = env[backupField] || '';
196
205
 
197
- if (currentApiKey && !currentApiKey.startsWith('plx_') && currentApiKey !== managedAuthKey) {
198
- env[PREVIOUS_API_KEY_ENV] = currentApiKey;
206
+ if (currentValue.startsWith('plx_')) {
207
+ if (backupValue && !backupValue.startsWith('plx_')) {
208
+ env[field] = backupValue;
209
+ } else {
210
+ delete env[field];
211
+ }
212
+ changed = true;
199
213
  }
200
- if (currentAuthToken && !currentAuthToken.startsWith('plx_') && currentAuthToken !== managedAuthKey) {
201
- env[PREVIOUS_AUTH_TOKEN_ENV] = currentAuthToken;
214
+
215
+ if (backupValue) {
216
+ delete env[backupField];
217
+ changed = true;
202
218
  }
203
219
 
204
- env.ANTHROPIC_API_KEY = managedAuthKey;
205
- env.ANTHROPIC_AUTH_TOKEN = managedAuthKey;
220
+ return changed;
206
221
  }
207
222
 
208
223
  function writeJsonAtomically(filePath, value) {
@@ -211,36 +226,36 @@ function writeJsonAtomically(filePath, value) {
211
226
  fs.renameSync(tempPath, filePath);
212
227
  }
213
228
 
214
- function syncManagedPrimaryApiKey(env, managedAuthKey) {
215
- const statePath = path.join(HOME_DIR, '.claude.json');
216
- try {
217
- if (!fs.existsSync(statePath)) {
218
- return { changed: false };
219
- }
229
+ function migrateManagedAuthState(env, claudeState, config = {}) {
230
+ let settingsChanged = false;
231
+ let claudeStateChanged = false;
232
+ const managedAuthKey = selectManagedAuthKey(env, config);
233
+
234
+ if (managedAuthKey) {
235
+ const managedConfig = {
236
+ ...config,
237
+ auth: {
238
+ ...(config.auth || {}),
239
+ api_key: managedAuthKey
240
+ }
241
+ };
242
+ settingsChanged = applyManagedClaudeCustomHeadersToEnv(env, managedConfig) || settingsChanged;
243
+ }
220
244
 
221
- const data = fs.readFileSync(statePath, 'utf8');
222
- if (!data || !data.trim()) {
223
- return { changed: false };
224
- }
245
+ settingsChanged = restoreLegacyManagedAuthEnvValue(env, 'ANTHROPIC_API_KEY', PREVIOUS_API_KEY_ENV) || settingsChanged;
246
+ settingsChanged = restoreLegacyManagedAuthEnvValue(env, 'ANTHROPIC_AUTH_TOKEN', PREVIOUS_AUTH_TOKEN_ENV) || settingsChanged;
225
247
 
226
- const claudeState = JSON.parse(data);
227
- const primaryApiKey = claudeState.primaryApiKey || '';
228
- if (!primaryApiKey || primaryApiKey.startsWith('plx_') || primaryApiKey === managedAuthKey) {
229
- return { changed: false };
248
+ const previousPrimaryApiKey = env[PREVIOUS_PRIMARY_API_KEY_ENV] || '';
249
+ if (previousPrimaryApiKey) {
250
+ if (!claudeState.primaryApiKey) {
251
+ claudeState.primaryApiKey = previousPrimaryApiKey;
252
+ claudeStateChanged = true;
230
253
  }
231
-
232
- env[PREVIOUS_PRIMARY_API_KEY_ENV] = primaryApiKey;
233
- const nextClaudeState = { ...claudeState };
234
- delete nextClaudeState.primaryApiKey;
235
-
236
- return {
237
- changed: true,
238
- statePath,
239
- claudeState: nextClaudeState
240
- };
241
- } catch {
242
- return { changed: false };
254
+ delete env[PREVIOUS_PRIMARY_API_KEY_ENV];
255
+ settingsChanged = true;
243
256
  }
257
+
258
+ return { settingsChanged, claudeStateChanged };
244
259
  }
245
260
 
246
261
  function checkOrphanedRouting() {
@@ -254,32 +269,19 @@ function checkOrphanedRouting() {
254
269
  const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
255
270
  const env = settings.env || {};
256
271
  let settingsChanged = false;
257
- let managedPrimaryApiKeySync = null;
272
+ let claudeStateChanged = false;
273
+ let claudeState = {};
274
+ const statePath = path.join(HOME_DIR, '.claude.json');
258
275
 
259
276
  const hasPlexorUrl = isManagedGatewayUrl(env.ANTHROPIC_BASE_URL);
260
277
 
261
278
  if (hasPlexorUrl) {
262
- // Keep both Claude auth env vars aligned to the Plexor key so Claude API
263
- // auth cannot override the gateway after plugin setup.
264
- const managedAuthKey = selectManagedAuthKey(env);
265
- if (managedAuthKey &&
266
- (env.ANTHROPIC_API_KEY !== managedAuthKey || env.ANTHROPIC_AUTH_TOKEN !== managedAuthKey)) {
267
- syncManagedAuthEnv(env, managedAuthKey);
268
- settings.env = env;
269
- settingsChanged = true;
270
- console.log('\n Synced Plexor auth into ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN');
271
- }
272
- const primaryApiKeySync = managedAuthKey ? syncManagedPrimaryApiKey(env, managedAuthKey) : { changed: false };
273
- if (primaryApiKeySync.changed) {
274
- settings.env = env;
275
- settingsChanged = true;
276
- managedPrimaryApiKeySync = primaryApiKeySync;
277
- }
278
279
  // Check if there's a valid Plexor config
279
280
  let hasValidConfig = false;
281
+ let config = null;
280
282
  try {
281
283
  if (fs.existsSync(configPath)) {
282
- const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
284
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
283
285
  hasValidConfig = (config.auth?.api_key || config.apiKey || '').startsWith('plx_');
284
286
  }
285
287
  } catch (e) {}
@@ -290,6 +292,22 @@ function checkOrphanedRouting() {
290
292
  console.log(' Run /plexor-login to reconfigure, or');
291
293
  console.log(' Run /plexor-uninstall to clean up\n');
292
294
  } else {
295
+ try {
296
+ if (fs.existsSync(statePath)) {
297
+ const data = fs.readFileSync(statePath, 'utf8');
298
+ if (data && data.trim()) {
299
+ claudeState = JSON.parse(data);
300
+ }
301
+ }
302
+ } catch {
303
+ claudeState = {};
304
+ }
305
+
306
+ const migration = migrateManagedAuthState(env, claudeState, config);
307
+ settings.env = env;
308
+ settingsChanged = migration.settingsChanged || settingsChanged;
309
+ claudeStateChanged = migration.claudeStateChanged || claudeStateChanged;
310
+
293
311
  // Fix #2176: Detect variant mismatch and migrate URL
294
312
  const currentUrl = env.ANTHROPIC_BASE_URL;
295
313
  if (currentUrl !== THIS_VARIANT_URL) {
@@ -316,12 +334,12 @@ function checkOrphanedRouting() {
316
334
  fs.renameSync(tempPath, settingsPath);
317
335
  }
318
336
 
319
- if (managedPrimaryApiKeySync?.changed) {
337
+ if (claudeStateChanged) {
320
338
  try {
321
- writeJsonAtomically(managedPrimaryApiKeySync.statePath, managedPrimaryApiKeySync.claudeState);
322
- console.log('\n Suspended Claude managed API key while Plexor is active');
339
+ writeJsonAtomically(statePath, claudeState);
340
+ console.log('\n Restored saved Claude auth alongside Plexor header routing');
323
341
  } catch (e) {
324
- console.log('\n Warning: Saved Claude key backup but could not suspend Claude managed API key');
342
+ console.log('\n Warning: Migrated Plexor auth headers but could not restore saved Claude auth');
325
343
  console.log(` ${e.message}`);
326
344
  }
327
345
  }
@@ -59,7 +59,8 @@ const results = {
59
59
  hooks: false,
60
60
  commands: [],
61
61
  restored: [],
62
- pluginDir: false
62
+ pluginDir: false,
63
+ configDir: false
63
64
  };
64
65
  const PREVIOUS_PRIMARY_API_KEY_ENV = 'PLEXOR_PREVIOUS_CLAUDE_PRIMARY_API_KEY';
65
66
  const CLAUDE_SETTINGS_PATH = path.join(home, '.claude', 'settings.json');
@@ -254,7 +255,14 @@ try {
254
255
  }
255
256
 
256
257
  // Output results
257
- if (results.routing || results.statusLine || results.hooks || results.commands.length > 0 || results.pluginDir) {
258
+ if (
259
+ results.routing ||
260
+ results.statusLine ||
261
+ results.hooks ||
262
+ results.commands.length > 0 ||
263
+ results.pluginDir ||
264
+ results.configDir
265
+ ) {
258
266
  console.log(' Plexor plugin uninstalled');
259
267
  console.log('');
260
268