@plexor-dev/claude-code-plugin 0.1.0-beta.16 → 0.1.0-beta.19

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.
@@ -40,3 +40,5 @@ Other commands:
40
40
  - /plexor-provider - Change provider
41
41
  - /plexor-enabled - Enable/disable proxy
42
42
  ```
43
+
44
+ **IMPORTANT**: After completing the task, STOP. Do not run additional commands or explore the codebase.
@@ -52,3 +52,5 @@ Plexor Proxy: DISABLED
52
52
  Your prompts will go directly to Anthropic at full price.
53
53
  Run /plexor-enabled to re-enable optimization.
54
54
  ```
55
+
56
+ **IMPORTANT**: After completing the task, STOP. Do not run additional commands or explore the codebase.
@@ -82,3 +82,5 @@ Dashboard: https://plexor.dev/dashboard
82
82
  ```
83
83
 
84
84
  If the API call fails, tell the user the key may be invalid and ask them to check it.
85
+
86
+ **IMPORTANT**: After completing the task, STOP. Do not run additional commands or explore the codebase.
@@ -40,3 +40,5 @@ To use Plexor again, run /plexor-login
40
40
 
41
41
  Dashboard: https://plexor.dev/dashboard
42
42
  ```
43
+
44
+ **IMPORTANT**: After completing the task, STOP. Do not run additional commands or explore the codebase.
@@ -45,3 +45,5 @@ Mode Details:
45
45
  Current mode: [mode]
46
46
  Run /plexor-status to see your savings.
47
47
  ```
48
+
49
+ **IMPORTANT**: After completing the task, STOP. Do not run additional commands or explore the codebase.
@@ -45,3 +45,5 @@ Provider options:
45
45
  Current provider: [preferredProvider]
46
46
  Run /plexor-status to see your usage.
47
47
  ```
48
+
49
+ **IMPORTANT**: After completing the task, STOP. Do not run additional commands or explore the codebase.
@@ -56,3 +56,5 @@ Options:
56
56
  4. **Timeout** - Change request timeout
57
57
 
58
58
  Then update `~/.plexor/config.json` with the new value using the Write tool.
59
+
60
+ **IMPORTANT**: After completing the task, STOP. Do not run additional commands or explore the codebase.
@@ -92,8 +92,18 @@ async function main() {
92
92
  const cacheEnabled = localCache ? 'Enabled' : 'Disabled';
93
93
  const cacheRate = formatPct((summary.cache_hit_rate || 0) * 100);
94
94
 
95
- // Build dashboard URL
96
- const dashboardUrl = apiUrl.replace('.api.', '.').replace('/api', '') + '/dashboard.html';
95
+ // Build dashboard URL from API URL
96
+ // API: https://api.plexor.dev or https://staging.api.plexor.dev
97
+ // Dashboard: https://plexor.dev/dashboard or https://staging.plexor.dev/dashboard
98
+ let dashboardUrl = 'https://plexor.dev/dashboard';
99
+ try {
100
+ const url = new URL(apiUrl);
101
+ // Remove 'api.' prefix from hostname if present
102
+ const host = url.hostname.replace(/^api\./, '').replace(/\.api\./, '.');
103
+ dashboardUrl = `${url.protocol}//${host}/dashboard`;
104
+ } catch {
105
+ // If URL parsing fails, use default
106
+ }
97
107
 
98
108
  // Output formatted status - each line is exactly 43 chars inner width
99
109
  const line = (content) => ` │ ${content.padEnd(43)}│`;
@@ -10,4 +10,12 @@ Run this command to display Plexor statistics:
10
10
  node ~/.claude/plugins/plexor/commands/plexor-status.js
11
11
  ```
12
12
 
13
- Use the Bash tool to execute this single command. Do not read files manually or format output yourself.
13
+ Use the Bash tool to execute this single command.
14
+
15
+ **IMPORTANT**: After running this command and displaying the output, STOP. Do not:
16
+ - Read any files
17
+ - Explore the codebase
18
+ - Run additional commands
19
+ - Ask follow-up questions
20
+
21
+ The command output is the complete response. Simply show the output and wait for the user's next input.
@@ -1,17 +1,39 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Plexor Interception Hook
4
+ * Plexor Interception Hook - Phase 3 Hypervisor Architecture
5
5
  *
6
- * This script intercepts Claude Code prompts before they are sent to the LLM.
7
- * It optimizes the prompt and optionally routes to a cheaper provider.
6
+ * This script intercepts Claude Code requests before they reach the LLM.
7
+ *
8
+ * In HYPERVISOR MODE (ANTHROPIC_BASE_URL points to Plexor):
9
+ * - All requests pass through unchanged
10
+ * - Server handles all optimization, routing, and quality control
11
+ * - Plugin just adds session tracking metadata
12
+ * - This is the recommended setup for best user experience
13
+ *
14
+ * In PLUGIN MODE (direct to Anthropic):
15
+ * - Plugin does local optimization (legacy behavior)
16
+ * - Less reliable due to client-side state management
8
17
  *
9
18
  * Input: JSON object with messages, model, max_tokens, etc.
10
- * Output: Modified JSON object with optimized messages
19
+ * Output: Passthrough (hypervisor mode) or optimized messages (plugin mode)
11
20
  */
12
21
 
13
22
  const fs = require('fs');
14
23
  const path = require('path');
24
+ const crypto = require('crypto');
25
+
26
+ /**
27
+ * Generate a unique request ID for tracking.
28
+ * Format: pass_<timestamp>_<random> for passthrough requests
29
+ * This ensures every request can be tracked, even passthroughs.
30
+ * Issue #701: Missing request_id caused response tracking to fail.
31
+ */
32
+ function generateRequestId(prefix = 'pass') {
33
+ const timestamp = Date.now().toString(36);
34
+ const random = crypto.randomBytes(4).toString('hex');
35
+ return `${prefix}_${timestamp}_${random}`;
36
+ }
15
37
 
16
38
  // Try to load lib modules, fall back to inline implementations
17
39
  let ConfigManager, SessionManager, LocalCache, Logger, PlexorClient;
@@ -157,12 +179,27 @@ async function main() {
157
179
  input = await readStdin();
158
180
  request = JSON.parse(input);
159
181
 
160
- // Issue #687: Auto-disable hook when ANTHROPIC_BASE_URL points to Plexor
161
- // If already using Plexor as proxy, don't run local hook (avoid double-processing)
182
+ // Phase 3 Hypervisor Mode Detection
183
+ // When ANTHROPIC_BASE_URL points to Plexor, all intelligence is server-side
184
+ // The plugin just passes through - server handles optimization, routing, quality
162
185
  const baseUrl = process.env.ANTHROPIC_BASE_URL || '';
163
- if (baseUrl.includes('plexor')) {
164
- logger.debug('Plexor proxy detected via ANTHROPIC_BASE_URL, passing through');
165
- return output(request);
186
+ const isHypervisorMode = baseUrl.includes('plexor') || baseUrl.includes('staging.api');
187
+
188
+ if (isHypervisorMode) {
189
+ // HYPERVISOR MODE: Server handles everything
190
+ // Just pass through with minimal metadata for session tracking
191
+ logger.debug('[Plexor] Hypervisor mode active - server handles all optimization');
192
+
193
+ // Add session tracking metadata (server will use this for analytics)
194
+ return output({
195
+ ...request,
196
+ _plexor_client: {
197
+ mode: 'hypervisor',
198
+ plugin_version: '0.1.0-beta.18',
199
+ cwd: process.cwd(),
200
+ timestamp: Date.now(),
201
+ }
202
+ });
166
203
  }
167
204
 
168
205
  // CRITICAL: Check for slash commands FIRST (before agentic check)
@@ -175,6 +212,7 @@ async function main() {
175
212
  ...request,
176
213
  plexor_cwd: process.cwd(),
177
214
  _plexor: {
215
+ request_id: generateRequestId('slash'), // Issue #701: Add request_id for tracking
178
216
  source: 'passthrough_slash_command',
179
217
  reason: 'slash_command_detected',
180
218
  cwd: process.cwd(),
@@ -192,6 +230,7 @@ async function main() {
192
230
  ...request,
193
231
  plexor_cwd: process.cwd(),
194
232
  _plexor: {
233
+ request_id: generateRequestId('cli'), // Issue #701: Add request_id for tracking
195
234
  source: 'passthrough_cli',
196
235
  reason: 'cli_tool_execution_detected',
197
236
  cwd: process.cwd(),
@@ -209,6 +248,7 @@ async function main() {
209
248
  ...request,
210
249
  plexor_cwd: process.cwd(),
211
250
  _plexor: {
251
+ request_id: generateRequestId('agent'), // Issue #701: Add request_id for tracking
212
252
  source: 'passthrough_agentic',
213
253
  reason: 'tool_use_detected',
214
254
  cwd: process.cwd(),
@@ -221,12 +261,30 @@ async function main() {
221
261
 
222
262
  if (!settings.enabled) {
223
263
  logger.debug('Plexor disabled, passing through');
224
- return output(request);
264
+ session.recordPassthrough();
265
+ return output({
266
+ ...request,
267
+ _plexor: {
268
+ request_id: generateRequestId('disabled'), // Issue #701: Add request_id for tracking
269
+ source: 'passthrough_disabled',
270
+ reason: 'plexor_disabled',
271
+ latency_ms: Date.now() - startTime
272
+ }
273
+ });
225
274
  }
226
275
 
227
276
  if (!settings.apiKey) {
228
277
  logger.info('Not authenticated. Run /plexor-login to enable optimization.');
229
- return output(request);
278
+ session.recordPassthrough();
279
+ return output({
280
+ ...request,
281
+ _plexor: {
282
+ request_id: generateRequestId('noauth'), // Issue #701: Add request_id for tracking
283
+ source: 'passthrough_no_auth',
284
+ reason: 'not_authenticated',
285
+ latency_ms: Date.now() - startTime
286
+ }
287
+ });
230
288
  }
231
289
 
232
290
  const client = new PlexorClient({
@@ -248,6 +306,7 @@ async function main() {
248
306
  return output({
249
307
  ...request,
250
308
  _plexor: {
309
+ request_id: generateRequestId('cache'), // Issue #701: Add request_id for tracking
251
310
  source: 'local_cache',
252
311
  latency_ms: Date.now() - startTime
253
312
  }
@@ -312,11 +371,15 @@ async function main() {
312
371
  logger.error(`[Plexor] Error: ${error.message}`);
313
372
  logger.debug(error.stack);
314
373
 
374
+ const errorRequestId = generateRequestId('error'); // Issue #701: Add request_id for tracking
375
+
315
376
  // Use already-parsed request if available, otherwise pass through raw
316
377
  if (request) {
378
+ session.recordPassthrough();
317
379
  return output({
318
380
  ...request,
319
381
  _plexor: {
382
+ request_id: errorRequestId,
320
383
  error: error.message,
321
384
  source: 'passthrough_error'
322
385
  }
@@ -325,9 +388,11 @@ async function main() {
325
388
  // Try to parse the input we already read
326
389
  try {
327
390
  const req = JSON.parse(input);
391
+ session.recordPassthrough();
328
392
  return output({
329
393
  ...req,
330
394
  _plexor: {
395
+ request_id: errorRequestId,
331
396
  error: error.message,
332
397
  source: 'passthrough_error'
333
398
  }
@@ -476,11 +541,10 @@ function isAgenticRequest(request) {
476
541
  }
477
542
  }
478
543
 
479
- // Check for multi-turn conversations (likely agentic)
480
- const assistantMessages = messages.filter(m => m.role === 'assistant');
481
- if (assistantMessages.length > 2) {
482
- return true;
483
- }
544
+ // NOTE: Removed multi-turn check (Issue #701)
545
+ // Multi-turn conversations WITHOUT tool use are NOT agentic
546
+ // The old check caused normal conversations to be marked as agentic
547
+ // after just 2 assistant messages, leading to infinite loops
484
548
 
485
549
  return false;
486
550
  }
@@ -6,6 +6,10 @@
6
6
  * This script runs after the LLM response is received.
7
7
  * It tracks response metrics for analytics and updates session stats.
8
8
  *
9
+ * Issue #701 Fix: Now tracks ALL responses including passthroughs.
10
+ * Previously, responses without request_id would exit early without tracking,
11
+ * leading to "0 tokens" display and potential context issues.
12
+ *
9
13
  * Input: JSON object with response content, tokens used, etc.
10
14
  * Output: Passthrough (no modifications)
11
15
  */
@@ -13,17 +17,22 @@
13
17
  const path = require('path');
14
18
 
15
19
  // Use lib modules
16
- let ConfigManager, SessionManager, LocalCache, Logger, PlexorClient;
20
+ let ConfigManager, SessionManager, LocalCache, Logger, ServerSync;
17
21
  try {
18
22
  ConfigManager = require('../lib/config');
19
23
  SessionManager = require('../lib/session');
20
24
  LocalCache = require('../lib/cache');
21
25
  Logger = require('../lib/logger');
22
- PlexorClient = require('../lib/plexor-client');
26
+ // Issue #701: Phase 2 - Server sync for persistent session state
27
+ const serverSyncModule = require('../lib/server-sync');
28
+ ServerSync = serverSyncModule.getServerSync;
23
29
  } catch {
24
30
  // Fallback inline implementations if lib not found
25
31
  const fs = require('fs');
26
32
  const CONFIG_PATH = path.join(process.env.HOME || '', '.plexor', 'config.json');
33
+ const SESSION_PATH = path.join(process.env.HOME || '', '.plexor', 'session.json');
34
+ const PLEXOR_DIR = path.join(process.env.HOME || '', '.plexor');
35
+ const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
27
36
 
28
37
  ConfigManager = class {
29
38
  async load() {
@@ -40,9 +49,84 @@ try {
40
49
  }
41
50
  };
42
51
 
52
+ // Inline SessionManager for fallback (Issue #701: proper tracking)
43
53
  SessionManager = class {
44
- recordOptimization() {}
45
- recordPassthrough() {}
54
+ constructor() {
55
+ this.sessionPath = SESSION_PATH;
56
+ }
57
+
58
+ load() {
59
+ try {
60
+ const data = fs.readFileSync(this.sessionPath, 'utf8');
61
+ const session = JSON.parse(data);
62
+ if (Date.now() - session.last_activity > SESSION_TIMEOUT_MS) {
63
+ return this.createNew();
64
+ }
65
+ return session;
66
+ } catch {
67
+ return this.createNew();
68
+ }
69
+ }
70
+
71
+ createNew() {
72
+ const session = {
73
+ session_id: `session_${Date.now()}`,
74
+ started_at: new Date().toISOString(),
75
+ last_activity: Date.now(),
76
+ requests: 0, optimizations: 0, cache_hits: 0, passthroughs: 0,
77
+ original_tokens: 0, optimized_tokens: 0, tokens_saved: 0,
78
+ output_tokens: 0, // Issue #701: Track output tokens
79
+ baseline_cost: 0, actual_cost: 0, cost_saved: 0
80
+ };
81
+ this.save(session);
82
+ return session;
83
+ }
84
+
85
+ save(session) {
86
+ try {
87
+ if (!fs.existsSync(PLEXOR_DIR)) {
88
+ fs.mkdirSync(PLEXOR_DIR, { recursive: true });
89
+ }
90
+ session.last_activity = Date.now();
91
+ fs.writeFileSync(this.sessionPath, JSON.stringify(session, null, 2));
92
+ } catch {}
93
+ }
94
+
95
+ recordOptimization(result) {
96
+ const s = this.load();
97
+ s.requests++;
98
+ s.optimizations++;
99
+ s.original_tokens += result.original_tokens || 0;
100
+ s.optimized_tokens += result.optimized_tokens || 0;
101
+ s.tokens_saved += result.tokens_saved || 0;
102
+ s.output_tokens += result.output_tokens || 0; // Issue #701
103
+ s.baseline_cost += result.baseline_cost || 0;
104
+ s.actual_cost += result.estimated_cost || result.actual_cost || 0;
105
+ s.cost_saved += (result.baseline_cost || 0) - (result.estimated_cost || result.actual_cost || 0);
106
+ this.save(s);
107
+ return s;
108
+ }
109
+
110
+ recordCacheHit() {
111
+ const s = this.load();
112
+ s.requests++;
113
+ s.cache_hits++;
114
+ this.save(s);
115
+ return s;
116
+ }
117
+
118
+ recordPassthrough(outputTokens = 0) {
119
+ const s = this.load();
120
+ s.requests++;
121
+ s.passthroughs = (s.passthroughs || 0) + 1;
122
+ s.output_tokens += outputTokens; // Issue #701: Track output even for passthroughs
123
+ this.save(s);
124
+ return s;
125
+ }
126
+
127
+ getStats() {
128
+ return this.load();
129
+ }
46
130
  };
47
131
 
48
132
  LocalCache = class {
@@ -50,10 +134,28 @@ try {
50
134
  };
51
135
 
52
136
  Logger = class {
53
- info(msg) { console.error(msg); }
54
- error(msg) { console.error(`[ERROR] ${msg}`); }
55
- debug() {}
137
+ constructor(name) { this.name = name; }
138
+ info(msg, data) {
139
+ if (data) {
140
+ console.error(`[${this.name}] ${msg}`, JSON.stringify(data));
141
+ } else {
142
+ console.error(`[${this.name}] ${msg}`);
143
+ }
144
+ }
145
+ error(msg) { console.error(`[${this.name}] [ERROR] ${msg}`); }
146
+ debug(msg) {
147
+ if (process.env.PLEXOR_DEBUG) {
148
+ console.error(`[${this.name}] [DEBUG] ${msg}`);
149
+ }
150
+ }
56
151
  };
152
+
153
+ // Issue #701: Fallback ServerSync that does nothing
154
+ ServerSync = () => ({
155
+ syncSession: async () => ({ synced: false, message: 'Server sync not available' }),
156
+ scheduleSync: () => {},
157
+ needsSync: () => false
158
+ });
57
159
  }
58
160
 
59
161
  const logger = new Logger('track-response');
@@ -61,69 +163,185 @@ const config = new ConfigManager();
61
163
  const cache = new LocalCache();
62
164
  const session = new SessionManager();
63
165
 
166
+ // Issue #701: Phase 2 - Initialize server sync (lazy, initialized on first use)
167
+ let serverSync = null;
168
+
169
+ async function getServerSync() {
170
+ if (serverSync) return serverSync;
171
+
172
+ try {
173
+ const settings = await config.load();
174
+ if (settings.apiKey && settings.enabled) {
175
+ serverSync = ServerSync({
176
+ apiKey: settings.apiKey,
177
+ baseUrl: settings.apiUrl || 'https://api.plexor.dev',
178
+ enabled: settings.serverSyncEnabled !== false
179
+ });
180
+ } else {
181
+ serverSync = ServerSync({ enabled: false });
182
+ }
183
+ } catch {
184
+ serverSync = ServerSync({ enabled: false });
185
+ }
186
+ return serverSync;
187
+ }
188
+
64
189
  async function main() {
190
+ let response;
191
+ let input;
192
+
65
193
  try {
66
- const input = await readStdin();
67
- const response = JSON.parse(input);
194
+ input = await readStdin();
195
+ response = JSON.parse(input);
68
196
 
69
- const settings = await config.load();
197
+ // Calculate output tokens for ALL responses (Issue #701)
198
+ const outputTokens = estimateOutputTokens(response);
70
199
 
71
- // If Plexor is disabled or no API key, just pass through
72
- if (!settings.enabled || !settings.apiKey) {
73
- return output(response);
200
+ // Get Plexor metadata if present
201
+ const plexorMeta = response._plexor;
202
+
203
+ // Issue #701: Track ALL responses, not just when enabled
204
+ // This ensures session stats are always accurate
205
+ if (plexorMeta) {
206
+ await trackResponseWithMetadata(plexorMeta, outputTokens);
207
+ } else {
208
+ // No Plexor metadata - still track the output tokens
209
+ session.recordPassthrough(outputTokens);
210
+ logger.debug('Response tracked without Plexor metadata');
74
211
  }
75
212
 
76
- // Check if this response has Plexor metadata
77
- const plexorMeta = response._plexor;
78
- if (!plexorMeta || !plexorMeta.request_id) {
79
- // No Plexor metadata, but still record the request
80
- session.recordPassthrough();
213
+ // Issue #701: Phase 2 - Schedule server sync (non-blocking)
214
+ // This syncs local session stats to the server for persistence
215
+ try {
216
+ const sync = await getServerSync();
217
+ const localStats = session.getStats();
218
+ sync.scheduleSync(localStats);
219
+ } catch (syncError) {
220
+ // Server sync failures should not affect response passthrough
221
+ logger.debug(`Server sync scheduling failed: ${syncError.message}`);
222
+ }
223
+
224
+ // Pass through unchanged
225
+ return output(response);
226
+
227
+ } catch (error) {
228
+ logger.error(`Tracking error: ${error.message}`);
229
+
230
+ // On any error, try to pass through the response unchanged
231
+ if (response) {
81
232
  return output(response);
233
+ } else if (input) {
234
+ try {
235
+ return output(JSON.parse(input));
236
+ } catch {
237
+ process.exit(1);
238
+ }
239
+ } else {
240
+ process.exit(1);
82
241
  }
242
+ }
243
+ }
83
244
 
84
- // Get stored metadata for this request
85
- const metadata = await cache.getMetadata(plexorMeta.request_id);
245
+ /**
246
+ * Track response using Plexor metadata.
247
+ * Issue #701: Handles ALL source types, not just plexor_api.
248
+ */
249
+ async function trackResponseWithMetadata(plexorMeta, outputTokens) {
250
+ const source = plexorMeta.source || 'unknown';
251
+ const requestId = plexorMeta.request_id;
86
252
 
87
- // Calculate output tokens (approximate)
88
- const outputTokens = estimateTokens(response.content || '');
253
+ logger.debug(`Tracking response: source=${source}, request_id=${requestId}`);
89
254
 
90
- // Update session stats with response data
91
- if (plexorMeta.source === 'plexor_api') {
255
+ switch (source) {
256
+ case 'plexor_api':
257
+ // Full optimization was applied
92
258
  session.recordOptimization({
93
259
  original_tokens: plexorMeta.original_tokens || 0,
94
260
  optimized_tokens: plexorMeta.optimized_tokens || 0,
95
261
  tokens_saved: plexorMeta.tokens_saved || 0,
96
262
  baseline_cost: plexorMeta.baseline_cost || 0,
97
- estimated_cost: plexorMeta.estimated_cost || 0
263
+ estimated_cost: plexorMeta.estimated_cost || 0,
264
+ output_tokens: outputTokens
98
265
  });
99
-
100
- logger.info('[Plexor] Response tracked', {
101
- request_id: plexorMeta.request_id,
266
+ logger.info('[Plexor] Optimized response tracked', {
267
+ request_id: requestId,
102
268
  input_tokens: plexorMeta.optimized_tokens,
103
269
  output_tokens: outputTokens,
270
+ savings_percent: plexorMeta.savings_percent,
104
271
  provider: plexorMeta.recommended_provider
105
272
  });
106
- } else if (plexorMeta.source === 'local_cache') {
273
+ break;
274
+
275
+ case 'local_cache':
107
276
  session.recordCacheHit();
108
- logger.info('[Plexor] Cache hit recorded');
109
- } else {
110
- session.recordPassthrough();
111
- }
277
+ logger.info('[Plexor] Cache hit recorded', { request_id: requestId });
278
+ break;
112
279
 
113
- // Pass through unchanged
114
- return output(response);
280
+ case 'passthrough_agentic':
281
+ case 'passthrough_slash_command':
282
+ case 'passthrough_cli':
283
+ case 'passthrough_disabled':
284
+ case 'passthrough_no_auth':
285
+ case 'passthrough_error':
286
+ case 'passthrough':
287
+ // Passthrough - still track output tokens
288
+ session.recordPassthrough(outputTokens);
289
+ logger.debug(`Passthrough recorded: ${source}`, {
290
+ request_id: requestId,
291
+ reason: plexorMeta.reason,
292
+ output_tokens: outputTokens
293
+ });
294
+ break;
115
295
 
116
- } catch (error) {
117
- logger.error(`Tracking error: ${error.message}`);
296
+ default:
297
+ // Unknown source - treat as passthrough
298
+ session.recordPassthrough(outputTokens);
299
+ logger.debug(`Unknown source tracked as passthrough: ${source}`, {
300
+ request_id: requestId,
301
+ output_tokens: outputTokens
302
+ });
303
+ }
304
+ }
118
305
 
119
- // On any error, pass through unchanged
120
- try {
121
- const input = await readStdin();
122
- return output(JSON.parse(input));
123
- } catch {
124
- process.exit(1);
306
+ /**
307
+ * Estimate output tokens from response content.
308
+ * Handles both string and structured content formats.
309
+ */
310
+ function estimateOutputTokens(response) {
311
+ if (!response) return 0;
312
+
313
+ // Try to get content from various response formats
314
+ let text = '';
315
+
316
+ // Anthropic format: content array with text blocks
317
+ if (Array.isArray(response.content)) {
318
+ for (const block of response.content) {
319
+ if (block.type === 'text' && block.text) {
320
+ text += block.text;
321
+ } else if (block.type === 'tool_use') {
322
+ // Tool use blocks contribute to token count
323
+ text += JSON.stringify(block.input || {});
324
+ }
125
325
  }
126
326
  }
327
+ // Simple string content
328
+ else if (typeof response.content === 'string') {
329
+ text = response.content;
330
+ }
331
+ // OpenAI format: choices array
332
+ else if (response.choices && response.choices[0]) {
333
+ const choice = response.choices[0];
334
+ if (choice.message && choice.message.content) {
335
+ text = choice.message.content;
336
+ } else if (choice.text) {
337
+ text = choice.text;
338
+ }
339
+ }
340
+
341
+ if (!text) return 0;
342
+
343
+ // Approximate: ~4 characters per token (rough estimate)
344
+ return Math.max(1, Math.ceil(text.length / 4));
127
345
  }
128
346
 
129
347
  async function readStdin() {
@@ -152,12 +370,6 @@ function output(data) {
152
370
  process.exit(0);
153
371
  }
154
372
 
155
- function estimateTokens(text) {
156
- if (!text) return 0;
157
- // Approximate: ~4 characters per token
158
- return Math.max(1, Math.ceil(text.length / 4));
159
- }
160
-
161
373
  main().catch((error) => {
162
374
  console.error(`[Plexor] Fatal error: ${error.message}`);
163
375
  process.exit(1);
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Plexor Server Sync Module
3
+ *
4
+ * Issue #701: Syncs local plugin session state to the Plexor server.
5
+ * This ensures accurate token tracking even for passthrough requests.
6
+ *
7
+ * Phase 2 of the hypervisor architecture implementation.
8
+ */
9
+
10
+ const https = require('https');
11
+ const http = require('http');
12
+
13
+ class ServerSync {
14
+ constructor(options = {}) {
15
+ this.apiKey = options.apiKey;
16
+ this.baseUrl = options.baseUrl || 'https://api.plexor.dev';
17
+ this.timeout = options.timeout || 5000;
18
+ this.enabled = options.enabled !== false;
19
+ this.lastSyncTime = null;
20
+ this.syncInterval = options.syncInterval || 60000; // 1 minute default
21
+ this.pendingSync = null;
22
+ this.serverSessionId = null;
23
+ }
24
+
25
+ /**
26
+ * Sync local session stats to the server.
27
+ * Issue #701: Ensures server has accurate metrics even for passthroughs.
28
+ *
29
+ * @param {Object} localSession - Local session stats from SessionManager
30
+ * @returns {Promise<Object>} Server response with synced metrics
31
+ */
32
+ async syncSession(localSession) {
33
+ if (!this.enabled || !this.apiKey) {
34
+ return { synced: false, message: 'Server sync disabled or no API key' };
35
+ }
36
+
37
+ try {
38
+ const syncData = {
39
+ session_id: this.serverSessionId,
40
+ requests: localSession.requests || 0,
41
+ optimizations: localSession.optimizations || 0,
42
+ cache_hits: localSession.cache_hits || 0,
43
+ passthroughs: localSession.passthroughs || 0,
44
+ original_tokens: localSession.original_tokens || 0,
45
+ optimized_tokens: localSession.optimized_tokens || 0,
46
+ tokens_saved: localSession.tokens_saved || 0,
47
+ output_tokens: localSession.output_tokens || 0,
48
+ baseline_cost: localSession.baseline_cost || 0,
49
+ actual_cost: localSession.actual_cost || 0,
50
+ cost_saved: localSession.cost_saved || 0
51
+ };
52
+
53
+ const response = await this._postToServer('/v1/plugin/session/sync', syncData);
54
+
55
+ if (response.synced) {
56
+ this.serverSessionId = response.server_session_id;
57
+ this.lastSyncTime = Date.now();
58
+ }
59
+
60
+ return response;
61
+ } catch (error) {
62
+ // Don't throw - server sync failures shouldn't break the plugin
63
+ return {
64
+ synced: false,
65
+ message: `Server sync failed: ${error.message}`,
66
+ error: error.message
67
+ };
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Get server-side session state.
73
+ *
74
+ * @param {string} sessionId - Session ID to query
75
+ * @returns {Promise<Object>} Server session state
76
+ */
77
+ async getServerSession(sessionId) {
78
+ if (!this.enabled || !this.apiKey) {
79
+ return { found: false, message: 'Server sync disabled or no API key' };
80
+ }
81
+
82
+ try {
83
+ return await this._getFromServer(`/v1/plugin/session/${sessionId}`);
84
+ } catch (error) {
85
+ return {
86
+ found: false,
87
+ message: `Failed to get server session: ${error.message}`
88
+ };
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Schedule periodic sync (debounced).
94
+ *
95
+ * @param {Object} localSession - Local session stats
96
+ */
97
+ scheduleSync(localSession) {
98
+ // Debounce: don't sync more than once per interval
99
+ if (this.pendingSync) {
100
+ clearTimeout(this.pendingSync);
101
+ }
102
+
103
+ this.pendingSync = setTimeout(async () => {
104
+ await this.syncSession(localSession);
105
+ this.pendingSync = null;
106
+ }, 1000); // Sync 1 second after last request
107
+ }
108
+
109
+ /**
110
+ * POST data to the Plexor server.
111
+ *
112
+ * @param {string} path - API path
113
+ * @param {Object} data - Data to send
114
+ * @returns {Promise<Object>} Parsed JSON response
115
+ */
116
+ _postToServer(path, data) {
117
+ return new Promise((resolve, reject) => {
118
+ const url = new URL(path, this.baseUrl);
119
+ const isHttps = url.protocol === 'https:';
120
+ const client = isHttps ? https : http;
121
+
122
+ const options = {
123
+ hostname: url.hostname,
124
+ port: url.port || (isHttps ? 443 : 80),
125
+ path: url.pathname,
126
+ method: 'POST',
127
+ headers: {
128
+ 'Content-Type': 'application/json',
129
+ 'Authorization': `Bearer ${this.apiKey}`,
130
+ 'X-Plexor-Plugin-Version': '0.1.0-beta.5',
131
+ 'X-Plexor-Issue': '701'
132
+ },
133
+ timeout: this.timeout
134
+ };
135
+
136
+ const req = client.request(options, (res) => {
137
+ let body = '';
138
+ res.on('data', chunk => body += chunk);
139
+ res.on('end', () => {
140
+ try {
141
+ const parsed = JSON.parse(body);
142
+ resolve(parsed);
143
+ } catch (e) {
144
+ reject(new Error(`Invalid JSON response: ${body.substring(0, 100)}`));
145
+ }
146
+ });
147
+ });
148
+
149
+ req.on('error', reject);
150
+ req.on('timeout', () => {
151
+ req.destroy();
152
+ reject(new Error('Request timeout'));
153
+ });
154
+
155
+ req.write(JSON.stringify(data));
156
+ req.end();
157
+ });
158
+ }
159
+
160
+ /**
161
+ * GET data from the Plexor server.
162
+ *
163
+ * @param {string} path - API path
164
+ * @returns {Promise<Object>} Parsed JSON response
165
+ */
166
+ _getFromServer(path) {
167
+ return new Promise((resolve, reject) => {
168
+ const url = new URL(path, this.baseUrl);
169
+ const isHttps = url.protocol === 'https:';
170
+ const client = isHttps ? https : http;
171
+
172
+ const options = {
173
+ hostname: url.hostname,
174
+ port: url.port || (isHttps ? 443 : 80),
175
+ path: url.pathname,
176
+ method: 'GET',
177
+ headers: {
178
+ 'Authorization': `Bearer ${this.apiKey}`,
179
+ 'X-Plexor-Plugin-Version': '0.1.0-beta.5'
180
+ },
181
+ timeout: this.timeout
182
+ };
183
+
184
+ const req = client.request(options, (res) => {
185
+ let body = '';
186
+ res.on('data', chunk => body += chunk);
187
+ res.on('end', () => {
188
+ try {
189
+ resolve(JSON.parse(body));
190
+ } catch (e) {
191
+ reject(new Error(`Invalid JSON response: ${body.substring(0, 100)}`));
192
+ }
193
+ });
194
+ });
195
+
196
+ req.on('error', reject);
197
+ req.on('timeout', () => {
198
+ req.destroy();
199
+ reject(new Error('Request timeout'));
200
+ });
201
+
202
+ req.end();
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Check if sync is needed based on time elapsed.
208
+ */
209
+ needsSync() {
210
+ if (!this.lastSyncTime) return true;
211
+ return Date.now() - this.lastSyncTime > this.syncInterval;
212
+ }
213
+ }
214
+
215
+ // Singleton instance
216
+ let _serverSync = null;
217
+
218
+ function getServerSync(options = {}) {
219
+ if (!_serverSync) {
220
+ _serverSync = new ServerSync(options);
221
+ } else if (options.apiKey && options.apiKey !== _serverSync.apiKey) {
222
+ // Update API key if changed
223
+ _serverSync.apiKey = options.apiKey;
224
+ _serverSync.baseUrl = options.baseUrl || _serverSync.baseUrl;
225
+ }
226
+ return _serverSync;
227
+ }
228
+
229
+ function resetServerSync() {
230
+ _serverSync = null;
231
+ }
232
+
233
+ module.exports = {
234
+ ServerSync,
235
+ getServerSync,
236
+ resetServerSync
237
+ };
package/lib/session.js CHANGED
@@ -38,9 +38,11 @@ class SessionManager {
38
38
  requests: 0,
39
39
  optimizations: 0,
40
40
  cache_hits: 0,
41
+ passthroughs: 0, // Issue #701: Track passthroughs
41
42
  original_tokens: 0,
42
43
  optimized_tokens: 0,
43
44
  tokens_saved: 0,
45
+ output_tokens: 0, // Issue #701: Track output tokens
44
46
  baseline_cost: 0,
45
47
  actual_cost: 0,
46
48
  cost_saved: 0
@@ -71,6 +73,7 @@ class SessionManager {
71
73
  session.original_tokens += stats.original_tokens || 0;
72
74
  session.optimized_tokens += stats.optimized_tokens || 0;
73
75
  session.tokens_saved += stats.tokens_saved || 0;
76
+ session.output_tokens = (session.output_tokens || 0) + (stats.output_tokens || 0); // Issue #701
74
77
  session.baseline_cost += stats.baseline_cost || 0;
75
78
  session.actual_cost += stats.actual_cost || 0;
76
79
  session.cost_saved += stats.cost_saved || 0;
@@ -80,6 +83,12 @@ class SessionManager {
80
83
  session.cache_hits++;
81
84
  }
82
85
 
86
+ // Issue #701: Track passthroughs and their output tokens
87
+ if (stats.passthrough) {
88
+ session.passthroughs = (session.passthroughs || 0) + 1;
89
+ session.output_tokens = (session.output_tokens || 0) + (stats.output_tokens || 0);
90
+ }
91
+
83
92
  this.save(session);
84
93
  return session;
85
94
  }
@@ -90,6 +99,7 @@ class SessionManager {
90
99
  original_tokens: result.original_tokens || 0,
91
100
  optimized_tokens: result.optimized_tokens || 0,
92
101
  tokens_saved: result.tokens_saved || 0,
102
+ output_tokens: result.output_tokens || 0, // Issue #701: Track output tokens
93
103
  baseline_cost: result.baseline_cost || 0,
94
104
  actual_cost: result.estimated_cost || result.actual_cost || 0,
95
105
  cost_saved: (result.baseline_cost || 0) - (result.estimated_cost || result.actual_cost || 0)
@@ -100,8 +110,9 @@ class SessionManager {
100
110
  return this.recordRequest({ cache_hit: true });
101
111
  }
102
112
 
103
- recordPassthrough() {
104
- return this.recordRequest({});
113
+ recordPassthrough(outputTokens = 0) {
114
+ // Issue #701: Track passthroughs and their output tokens
115
+ return this.recordRequest({ passthrough: true, output_tokens: outputTokens });
105
116
  }
106
117
 
107
118
  getStats() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plexor-dev/claude-code-plugin",
3
- "version": "0.1.0-beta.16",
3
+ "version": "0.1.0-beta.19",
4
4
  "description": "LLM cost optimization plugin for Claude Code - Save up to 90% on AI costs",
5
5
  "main": "lib/constants.js",
6
6
  "scripts": {
@@ -1,18 +1,31 @@
1
1
  #!/bin/bash
2
2
  # Plexor CLI - Claude Code with intelligent optimization
3
+ # This wrapper auto-enables hypervisor mode for direct gateway routing
3
4
 
4
- # Only show Plexor branding if ANTHROPIC_BASE_URL points to Plexor
5
- if [[ "$ANTHROPIC_BASE_URL" == *"plexor"* ]]; then
6
- # Colors
7
- CYAN='\033[0;36m'
8
- GREEN='\033[0;32m'
9
- YELLOW='\033[0;33m'
10
- DIM='\033[2m'
11
- NC='\033[0m' # No Color
5
+ # Colors
6
+ CYAN='\033[0;36m'
7
+ GREEN='\033[0;32m'
8
+ YELLOW='\033[0;33m'
9
+ DIM='\033[2m'
10
+ NC='\033[0m' # No Color
12
11
 
13
- # Plexor ASCII art
14
- echo -e "${CYAN}"
15
- cat << 'EOF'
12
+ CONFIG_FILE="$HOME/.plexor/config.json"
13
+
14
+ # Auto-configure ANTHROPIC_BASE_URL if Plexor is enabled
15
+ if [ -f "$CONFIG_FILE" ]; then
16
+ ENABLED=$(python3 -c "import json; c=json.load(open('$CONFIG_FILE')); print(c.get('settings',{}).get('enabled', False))" 2>/dev/null)
17
+ API_URL=$(python3 -c "import json; c=json.load(open('$CONFIG_FILE')); print(c.get('settings',{}).get('apiUrl', ''))" 2>/dev/null)
18
+ API_KEY=$(python3 -c "import json; c=json.load(open('$CONFIG_FILE')); print(c.get('auth',{}).get('api_key', ''))" 2>/dev/null)
19
+ MODE=$(python3 -c "import json; c=json.load(open('$CONFIG_FILE')); print(c.get('settings',{}).get('mode', 'balanced'))" 2>/dev/null)
20
+
21
+ if [ "$ENABLED" = "True" ] && [ -n "$API_URL" ] && [ -n "$API_KEY" ]; then
22
+ # Set ANTHROPIC_BASE_URL to Plexor gateway (hypervisor mode)
23
+ export ANTHROPIC_BASE_URL="${API_URL}/gateway/anthropic/v1"
24
+ export ANTHROPIC_API_KEY="$API_KEY"
25
+
26
+ # Show Plexor branding
27
+ echo -e "${CYAN}"
28
+ cat << 'EOF'
16
29
  ____ __
17
30
  / __ \/ /__ _ ______ _____
18
31
  / /_/ / / _ \| |/_/ __ \/ ___/
@@ -20,19 +33,13 @@ if [[ "$ANTHROPIC_BASE_URL" == *"plexor"* ]]; then
20
33
  /_/ /_/\___/_/|_|\____/_/
21
34
 
22
35
  EOF
23
- echo -e "${NC}"
24
-
25
- # Show status
26
- CONFIG_FILE="$HOME/.plexor/config.json"
27
- if [ -f "$CONFIG_FILE" ]; then
28
- ENABLED=$(jq -r '.settings.enabled // false' "$CONFIG_FILE" 2>/dev/null)
29
- MODE=$(jq -r '.settings.mode // "balanced"' "$CONFIG_FILE" 2>/dev/null)
30
-
31
- if [ "$ENABLED" = "true" ]; then
32
- echo -e "${GREEN}●${NC} Optimization: ${GREEN}Active${NC} (${MODE} mode)"
33
- else
34
- echo -e "${YELLOW}○${NC} Optimization: ${DIM}Disabled${NC}"
35
- fi
36
+ echo -e "${NC}"
37
+ echo -e "${GREEN}●${NC} Hypervisor Mode: ${GREEN}Active${NC}"
38
+ echo -e " Gateway: ${DIM}${ANTHROPIC_BASE_URL}${NC}"
39
+ echo -e " Mode: ${MODE}"
40
+ echo ""
41
+ else
42
+ echo -e "${YELLOW}○${NC} Plexor: ${DIM}Disabled${NC} (run /plexor-login to enable)"
36
43
  echo ""
37
44
  fi
38
45
  fi
@@ -11,23 +11,49 @@ const fs = require('fs');
11
11
  const path = require('path');
12
12
  const os = require('os');
13
13
 
14
+ /**
15
+ * Get the correct home directory, accounting for sudo.
16
+ * When running with sudo, os.homedir() returns /root, but we want
17
+ * the actual user's home directory.
18
+ */
19
+ function getHomeDir() {
20
+ // Check if running with sudo - SUDO_USER contains the original username
21
+ if (process.env.SUDO_USER) {
22
+ // On Linux/Mac, home directories are typically /home/<user> or /Users/<user>
23
+ const platform = os.platform();
24
+ if (platform === 'darwin') {
25
+ return path.join('/Users', process.env.SUDO_USER);
26
+ } else if (platform === 'linux') {
27
+ return path.join('/home', process.env.SUDO_USER);
28
+ }
29
+ }
30
+ return os.homedir();
31
+ }
32
+
33
+ const HOME_DIR = getHomeDir();
14
34
  const COMMANDS_SOURCE = path.join(__dirname, '..', 'commands');
15
- const CLAUDE_COMMANDS_DIR = path.join(os.homedir(), '.claude', 'commands');
16
- const PLEXOR_CONFIG_DIR = path.join(os.homedir(), '.plexor');
35
+ const CLAUDE_COMMANDS_DIR = path.join(HOME_DIR, '.claude', 'commands');
36
+ const PLEXOR_PLUGINS_DIR = path.join(HOME_DIR, '.claude', 'plugins', 'plexor', 'commands');
37
+ const PLEXOR_CONFIG_DIR = path.join(HOME_DIR, '.plexor');
17
38
 
18
39
  function main() {
19
40
  try {
20
41
  // Create ~/.claude/commands/ if not exists
21
42
  fs.mkdirSync(CLAUDE_COMMANDS_DIR, { recursive: true });
22
43
 
44
+ // Create ~/.claude/plugins/plexor/commands/ for JS executors
45
+ fs.mkdirSync(PLEXOR_PLUGINS_DIR, { recursive: true });
46
+
23
47
  // Create ~/.plexor/ with secure permissions (owner only)
24
48
  fs.mkdirSync(PLEXOR_CONFIG_DIR, { recursive: true, mode: 0o700 });
25
49
 
26
- // Get list of command files
27
- const files = fs.readdirSync(COMMANDS_SOURCE)
50
+ // Get list of command files (.md for Claude, .js for executors)
51
+ const mdFiles = fs.readdirSync(COMMANDS_SOURCE)
28
52
  .filter(f => f.endsWith('.md'));
53
+ const jsFiles = fs.readdirSync(COMMANDS_SOURCE)
54
+ .filter(f => f.endsWith('.js'));
29
55
 
30
- if (files.length === 0) {
56
+ if (mdFiles.length === 0) {
31
57
  console.error('No command files found in package. Installation may be corrupt.');
32
58
  process.exit(1);
33
59
  }
@@ -35,7 +61,8 @@ function main() {
35
61
  const installed = [];
36
62
  const backed_up = [];
37
63
 
38
- for (const file of files) {
64
+ // Copy .md command files to ~/.claude/commands/
65
+ for (const file of mdFiles) {
39
66
  const src = path.join(COMMANDS_SOURCE, file);
40
67
  const dest = path.join(CLAUDE_COMMANDS_DIR, file);
41
68
 
@@ -55,6 +82,17 @@ function main() {
55
82
  installed.push(file.replace('.md', ''));
56
83
  }
57
84
 
85
+ // Copy .js executor files to ~/.claude/plugins/plexor/commands/
86
+ const jsInstalled = [];
87
+ for (const file of jsFiles) {
88
+ const src = path.join(COMMANDS_SOURCE, file);
89
+ const dest = path.join(PLEXOR_PLUGINS_DIR, file);
90
+ fs.copyFileSync(src, dest);
91
+ // Make executable
92
+ fs.chmodSync(dest, 0o755);
93
+ jsInstalled.push(file);
94
+ }
95
+
58
96
  // Print success message
59
97
  console.log('');
60
98
  console.log(' ╔═══════════════════════════════════════════════════════════╗');
@@ -74,6 +112,12 @@ function main() {
74
112
  backed_up.forEach(f => console.log(` ${f}`));
75
113
  }
76
114
 
115
+ if (jsInstalled.length > 0) {
116
+ console.log('');
117
+ console.log(' Executors installed to ~/.claude/plugins/plexor/commands/:');
118
+ jsInstalled.forEach(f => console.log(` ${f}`));
119
+ }
120
+
77
121
  console.log('');
78
122
  console.log(' Next steps:');
79
123
  console.log(' 1. Open Claude Code');