opencode-studio-server 1.28.3 → 2.1.0

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.
Files changed (5) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +1 -1
  3. package/cli.js +39 -19
  4. package/index.js +643 -213
  5. package/package.json +4 -4
package/AGENTS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # SERVER LAYER
2
2
 
3
- Express API backend (port 3001). Single-file architecture.
3
+ Express API backend (port 1920). Single-file architecture.
4
4
 
5
5
  ## STRUCTURE
6
6
 
package/README.md CHANGED
@@ -24,7 +24,7 @@ Start the server manually:
24
24
  opencode-studio-server
25
25
  ```
26
26
 
27
- The server runs on port **3001** and provides an API for managing your local OpenCode configuration.
27
+ The server runs on port **1920** (auto-detects next available) and provides an API for managing your local OpenCode configuration.
28
28
 
29
29
  ## Features
30
30
 
package/cli.js CHANGED
@@ -4,9 +4,27 @@ const path = require('path');
4
4
  const { exec } = require('child_process');
5
5
  const os = require('os');
6
6
  const fs = require('fs');
7
+ const net = require('net');
7
8
 
8
9
  const args = process.argv.slice(2);
9
10
 
11
+ async function findAvailablePort(startPort, maxTries = 10) {
12
+ return new Promise((resolve) => {
13
+ const server = net.createServer();
14
+ server.listen(startPort, () => {
15
+ server.once('close', () => resolve(startPort));
16
+ server.close();
17
+ });
18
+ server.on('error', () => {
19
+ if (maxTries > 1) {
20
+ findAvailablePort(startPort + 1, maxTries - 1).then(resolve);
21
+ } else {
22
+ resolve(startPort);
23
+ }
24
+ });
25
+ });
26
+ }
27
+
10
28
  // Handle --register flag
11
29
  if (args.includes('--register')) {
12
30
  require('./register-protocol');
@@ -103,27 +121,29 @@ if (pendingAction) {
103
121
 
104
122
  // Open browser if requested (only for fully local mode)
105
123
  if (shouldOpenBrowser) {
106
- const openUrl = 'http://localhost:3000';
107
- const platform = os.platform();
108
-
109
- setTimeout(() => {
110
- let cmd;
111
- if (platform === 'win32') {
112
- cmd = `start "" "${openUrl}"`;
113
- } else if (platform === 'darwin') {
114
- cmd = `open "${openUrl}"`;
115
- } else {
116
- cmd = `xdg-open "${openUrl}"`;
117
- }
118
-
119
- exec(cmd, (err) => {
120
- if (err) {
121
- console.log(`Open ${openUrl} in your browser`);
124
+ findAvailablePort(1080).then(port => {
125
+ const openUrl = `http://localhost:${port}`;
126
+ const platform = os.platform();
127
+
128
+ setTimeout(() => {
129
+ let cmd;
130
+ if (platform === 'win32') {
131
+ cmd = `start "" "${openUrl}"`;
132
+ } else if (platform === 'darwin') {
133
+ cmd = `open "${openUrl}"`;
122
134
  } else {
123
- console.log(`Opened ${openUrl}`);
135
+ cmd = `xdg-open "${openUrl}"`;
124
136
  }
125
- });
126
- }, 1500); // Give server time to start
137
+
138
+ exec(cmd, (err) => {
139
+ if (err) {
140
+ console.log(`Open ${openUrl} in your browser`);
141
+ } else {
142
+ console.log(`Opened ${openUrl}`);
143
+ }
144
+ });
145
+ }, 2000);
146
+ });
127
147
  }
128
148
 
129
149
  // Start the server
package/index.js CHANGED
@@ -5,7 +5,8 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const os = require('os');
7
7
  const crypto = require('crypto');
8
- const { spawn, exec } = require('child_process');
8
+ const { spawn, exec, execSync } = require('child_process');
9
+ const yaml = require('js-yaml');
9
10
 
10
11
  const pkg = require('./package.json');
11
12
  const profileManager = require('./profile-manager');
@@ -41,8 +42,22 @@ const atomicWriteFileSync = (filePath, data, options = 'utf8') => {
41
42
  };
42
43
 
43
44
  const app = express();
44
- const PORT = 3001;
45
- const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
45
+ const DEFAULT_PORT = 1920;
46
+ const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
47
+
48
+ function findAvailablePort(startPort) {
49
+ const net = require('net');
50
+ return new Promise((resolve) => {
51
+ const server = net.createServer();
52
+ server.listen(startPort, () => {
53
+ server.once('close', () => resolve(startPort));
54
+ server.close();
55
+ });
56
+ server.on('error', () => {
57
+ findAvailablePort(startPort + 1).then(resolve);
58
+ });
59
+ });
60
+ }
46
61
 
47
62
  let idleTimer = null;
48
63
 
@@ -62,8 +77,10 @@ app.use((req, res, next) => {
62
77
  });
63
78
 
64
79
  const ALLOWED_ORIGINS = [
65
- 'http://localhost:3000',
66
- 'http://127.0.0.1:3000',
80
+ 'http://localhost:1080',
81
+ 'http://127.0.0.1:1080',
82
+ /^http:\/\/localhost:108\d$/,
83
+ /^http:\/\/127\.0\.0\.1:108\d$/,
67
84
  'https://opencode-studio.vercel.app',
68
85
  'https://opencode.micr.dev',
69
86
  'https://opencode-studio.micr.dev',
@@ -85,15 +102,47 @@ app.use(bodyParser.json({ limit: '50mb' }));
85
102
  app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
86
103
  app.use(bodyParser.text({ type: ['text/*', 'application/yaml'], limit: '50mb' }));
87
104
 
88
- const HOME_DIR = os.homedir();
89
- const STUDIO_CONFIG_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'studio.json');
90
- const PENDING_ACTION_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'pending-action.json');
91
- const ANTIGRAVITY_ACCOUNTS_PATH = path.join(HOME_DIR, '.config', 'opencode', 'antigravity-accounts.json');
92
- const LOG_DIR = path.join(HOME_DIR, '.local', 'share', 'opencode', 'log');
105
+ const HOME_DIR = os.homedir();
106
+ const STUDIO_CONFIG_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'studio.json');
107
+ const PENDING_ACTION_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'pending-action.json');
108
+ const ANTIGRAVITY_ACCOUNTS_PATH = path.join(HOME_DIR, '.config', 'opencode', 'antigravity-accounts.json');
109
+ const LOG_DIR = path.join(HOME_DIR, '.local', 'share', 'opencode', 'log');
110
+
111
+ const LOG_BUFFER_SIZE = 100;
112
+ const logBuffer = [];
113
+ const logSubscribers = new Set();
93
114
 
94
- let logWatcher = null;
95
- let currentLogFile = null;
96
- let logReadStream = null;
115
+ let logWatcher = null;
116
+ let currentLogFile = null;
117
+ let logReadStream = null;
118
+
119
+ function enqueueLogLine(line) {
120
+ const entry = { timestamp: Date.now(), line };
121
+ logBuffer.push(entry);
122
+ if (logBuffer.length > LOG_BUFFER_SIZE) logBuffer.shift();
123
+
124
+ for (const sub of logSubscribers) {
125
+ sub.queue.push(`data: ${JSON.stringify(entry)}\n\n`);
126
+ flushSubscriber(sub);
127
+ }
128
+ }
129
+
130
+ function flushSubscriber(sub) {
131
+ if (sub.draining || sub.closed) return;
132
+ while (sub.queue.length > 0) {
133
+ const chunk = sub.queue[0];
134
+ const ok = sub.res.write(chunk);
135
+ if (!ok) {
136
+ sub.draining = true;
137
+ sub.res.once('drain', () => {
138
+ sub.draining = false;
139
+ flushSubscriber(sub);
140
+ });
141
+ return;
142
+ }
143
+ sub.queue.shift();
144
+ }
145
+ }
97
146
 
98
147
  function setupLogWatcher() {
99
148
  if (!fs.existsSync(LOG_DIR)) return;
@@ -172,11 +221,12 @@ function setupLogWatcher() {
172
221
  });
173
222
  }
174
223
 
175
- function processLogLine(line) {
176
- // Detect LLM usage: service=llm providerID=... modelID=...
177
- // Example: service=llm providerID=openai modelID=gpt-5.2-codex sessionID=...
178
- const isUsage = line.includes('service=llm') && line.includes('stream');
179
- const isError = line.includes('service=llm') && (line.includes('error=') || line.includes('status=429'));
224
+ function processLogLine(line) {
225
+ enqueueLogLine(line);
226
+ // Detect LLM usage: service=llm providerID=... modelID=...
227
+ // Example: service=llm providerID=openai modelID=gpt-5.2-codex sessionID=...
228
+ const isUsage = line.includes('service=llm') && line.includes('stream');
229
+ const isError = line.includes('service=llm') && (line.includes('error=') || line.includes('status=429'));
180
230
 
181
231
  if (!isUsage && !isError) return;
182
232
 
@@ -288,12 +338,13 @@ function processLogLine(line) {
288
338
 
289
339
  let pendingActionMemory = null;
290
340
 
291
- function loadStudioConfig() {
292
- const defaultConfig = {
293
- disabledSkills: [],
294
- disabledPlugins: [],
295
- activeProfiles: {},
296
- activeGooglePlugin: 'gemini',
341
+ function loadStudioConfig() {
342
+ const defaultConfig = {
343
+ disabledSkills: [],
344
+ disabledPlugins: [],
345
+ disabledAgents: [],
346
+ activeProfiles: {},
347
+ activeGooglePlugin: 'gemini',
297
348
  availableGooglePlugins: [],
298
349
  presets: [],
299
350
  githubRepo: null,
@@ -341,9 +392,9 @@ function loadStudioConfig() {
341
392
  "variants": {
342
393
  "low": { "options": { "thinkingConfig": { "thinkingLevel": "low", "includeThoughts": true } } },
343
394
  "medium": { "options": { "thinkingConfig": { "thinkingLevel": "medium", "includeThoughts": true } } },
344
- "high": { "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } } },
345
- "xhigh": { "options": { "thinkingConfig": { "thinkingLevel": "xhigh", "includeThoughts": true } } }
346
- }
395
+ "high": { "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } } },
396
+ "xhigh": { "options": { "thinkingConfig": { "thinkingLevel": "xhigh", "includeThoughts": true } } }
397
+ }
347
398
  },
348
399
  "gemini-3-flash": {
349
400
  "id": "gemini-3-flash",
@@ -360,9 +411,9 @@ function loadStudioConfig() {
360
411
  "minimal": { "options": { "thinkingConfig": { "thinkingLevel": "minimal", "includeThoughts": true } } },
361
412
  "low": { "options": { "thinkingConfig": { "thinkingLevel": "low", "includeThoughts": true } } },
362
413
  "medium": { "options": { "thinkingConfig": { "thinkingLevel": "medium", "includeThoughts": true } } },
363
- "high": { "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } } },
364
- "xhigh": { "options": { "thinkingConfig": { "thinkingLevel": "xhigh", "includeThoughts": true } } }
365
- }
414
+ "high": { "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } } },
415
+ "xhigh": { "options": { "thinkingConfig": { "thinkingLevel": "xhigh", "includeThoughts": true } } }
416
+ }
366
417
  },
367
418
  "gemini-2.5-flash-lite": {
368
419
  "id": "gemini-2.5-flash-lite",
@@ -383,9 +434,9 @@ function loadStudioConfig() {
383
434
  "minimal": { "options": { "thinkingConfig": { "thinkingLevel": "minimal", "includeThoughts": true } } },
384
435
  "low": { "options": { "thinkingConfig": { "thinkingLevel": "low", "includeThoughts": true } } },
385
436
  "medium": { "options": { "thinkingConfig": { "thinkingLevel": "medium", "includeThoughts": true } } },
386
- "high": { "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } } },
387
- "xhigh": { "options": { "thinkingConfig": { "thinkingLevel": "xhigh", "includeThoughts": true } } }
388
- }
437
+ "high": { "options": { "thinkingConfig": { "thinkingLevel": "high", "includeThoughts": true } } },
438
+ "xhigh": { "options": { "thinkingConfig": { "thinkingLevel": "xhigh", "includeThoughts": true } } }
439
+ }
389
440
  },
390
441
  "opencode/glm-4.7-free": {
391
442
  "id": "opencode/glm-4.7-free",
@@ -411,9 +462,9 @@ function loadStudioConfig() {
411
462
  "none": { "reasoning": false, "options": { "thinkingConfig": { "includeThoughts": false } } },
412
463
  "low": { "options": { "thinkingConfig": { "thinkingBudget": 4000, "includeThoughts": true } } },
413
464
  "medium": { "options": { "thinkingConfig": { "thinkingBudget": 16000, "includeThoughts": true } } },
414
- "high": { "options": { "thinkingConfig": { "thinkingBudget": 32000, "includeThoughts": true } } },
415
- "xhigh": { "options": { "thinkingConfig": { "thinkingBudget": 64000, "includeThoughts": true } } }
416
- }
465
+ "high": { "options": { "thinkingConfig": { "thinkingBudget": 32000, "includeThoughts": true } } },
466
+ "xhigh": { "options": { "thinkingConfig": { "thinkingBudget": 64000, "includeThoughts": true } } }
467
+ }
417
468
  },
418
469
  "gemini-claude-opus-4-5-thinking": {
419
470
  "id": "gemini-claude-opus-4-5-thinking",
@@ -428,9 +479,9 @@ function loadStudioConfig() {
428
479
  "variants": {
429
480
  "low": { "options": { "thinkingConfig": { "thinkingBudget": 4000, "includeThoughts": true } } },
430
481
  "medium": { "options": { "thinkingConfig": { "thinkingBudget": 16000, "includeThoughts": true } } },
431
- "high": { "options": { "thinkingConfig": { "thinkingBudget": 32000, "includeThoughts": true } } },
432
- "xhigh": { "options": { "thinkingConfig": { "thinkingBudget": 64000, "includeThoughts": true } } }
433
- }
482
+ "high": { "options": { "thinkingConfig": { "thinkingBudget": 32000, "includeThoughts": true } } },
483
+ "xhigh": { "options": { "thinkingConfig": { "thinkingBudget": 64000, "includeThoughts": true } } }
484
+ }
434
485
  }
435
486
  }
436
487
  }
@@ -495,15 +546,15 @@ const getPaths = () => {
495
546
  }
496
547
  }
497
548
 
498
- const studioConfig = loadStudioConfig();
499
- let manualPath = studioConfig.configPath;
500
-
501
- if (manualPath && fs.existsSync(manualPath) && fs.statSync(manualPath).isDirectory()) {
502
- const potentialFile = path.join(manualPath, 'opencode.json');
503
- if (fs.existsSync(potentialFile)) {
504
- manualPath = potentialFile;
505
- }
506
- }
549
+ const studioConfig = loadStudioConfig();
550
+ let manualPath = studioConfig.configPath;
551
+
552
+ if (manualPath && fs.existsSync(manualPath) && fs.statSync(manualPath).isDirectory()) {
553
+ const potentialFile = path.join(manualPath, 'opencode.json');
554
+ if (fs.existsSync(potentialFile)) {
555
+ manualPath = potentialFile;
556
+ }
557
+ }
507
558
 
508
559
  let detected = null;
509
560
  for (const p of candidates) {
@@ -527,7 +578,101 @@ const getOhMyOpenCodeConfigPath = () => {
527
578
  return path.join(path.dirname(cp), 'oh-my-opencode.json');
528
579
  };
529
580
 
530
- const getConfigPath = () => getPaths().current;
581
+ const getConfigPath = () => getPaths().current;
582
+
583
+ const getAgentDirs = () => {
584
+ const dirs = [];
585
+ const configPath = getConfigPath();
586
+ const configDir = configPath ? path.dirname(configPath) : null;
587
+
588
+ if (configDir) {
589
+ dirs.push(path.join(configDir, '.opencode', 'agents'));
590
+ dirs.push(path.join(configDir, '.opencode', 'agent'));
591
+ }
592
+
593
+ const xdg = process.env.XDG_CONFIG_HOME;
594
+ if (xdg) {
595
+ dirs.push(path.join(xdg, 'opencode', 'agents'));
596
+ dirs.push(path.join(xdg, 'opencode', 'agent'));
597
+ }
598
+
599
+ dirs.push(path.join(HOME_DIR, '.config', 'opencode', 'agents'));
600
+ dirs.push(path.join(HOME_DIR, '.config', 'opencode', 'agent'));
601
+
602
+ return [...new Set(dirs)];
603
+ };
604
+
605
+ const parseAgentMarkdown = (content) => {
606
+ const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?([\s\S]*)$/);
607
+ if (!match) return { data: {}, body: content };
608
+ let data = {};
609
+ try {
610
+ data = yaml.load(match[1]) || {};
611
+ } catch {
612
+ data = {};
613
+ }
614
+ return { data, body: match[2] || '' };
615
+ };
616
+
617
+ const buildAgentMarkdown = (frontmatter, body) => {
618
+ const yamlText = yaml.dump(frontmatter, { lineWidth: 120, noRefs: true, quotingType: '"' });
619
+ const content = body || '';
620
+ return `---\n${yamlText}---\n\n${content}`;
621
+ };
622
+
623
+ const validatePermissionValue = (value) => {
624
+ const allowed = ['ask', 'allow', 'deny'];
625
+ if (value === undefined || value === null) return true;
626
+ if (typeof value === 'string') return allowed.includes(value);
627
+ if (typeof value !== 'object') return false;
628
+
629
+ const keys = Object.keys(value);
630
+ const isAllowDeny = keys.every((k) => k === 'allow' || k === 'deny');
631
+ if (isAllowDeny) {
632
+ return (!value.allow || Array.isArray(value.allow)) && (!value.deny || Array.isArray(value.deny));
633
+ }
634
+
635
+ for (const v of Object.values(value)) {
636
+ if (typeof v === 'string') {
637
+ if (!allowed.includes(v)) return false;
638
+ } else if (typeof v === 'object') {
639
+ if (!validatePermissionValue(v)) return false;
640
+ } else if (v !== undefined && v !== null) {
641
+ return false;
642
+ }
643
+ }
644
+ return true;
645
+ };
646
+
647
+ const findRulesFile = () => {
648
+ const configPath = getConfigPath();
649
+ if (!configPath) return { path: null, source: 'none' };
650
+
651
+ let dir = path.dirname(configPath);
652
+ let last = null;
653
+ while (dir && dir !== last) {
654
+ const agentsPath = path.join(dir, 'AGENTS.md');
655
+ if (fs.existsSync(agentsPath)) return { path: agentsPath, source: 'AGENTS.md' };
656
+ const claudePath = path.join(dir, 'CLAUDE.md');
657
+ if (fs.existsSync(claudePath)) return { path: claudePath, source: 'CLAUDE.md' };
658
+ last = dir;
659
+ dir = path.dirname(dir);
660
+ }
661
+
662
+ return { path: null, source: 'none' };
663
+ };
664
+
665
+ const detectTool = (tool) => {
666
+ const command = process.platform === 'win32' ? `where ${tool}` : `which ${tool}`;
667
+ try {
668
+ const output = execSync(command, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
669
+ if (!output) return { name: tool, available: false };
670
+ const first = output.split(/\r?\n/)[0];
671
+ return { name: tool, available: true, path: first };
672
+ } catch {
673
+ return { name: tool, available: false };
674
+ }
675
+ };
531
676
 
532
677
  const loadConfig = () => {
533
678
  const configPath = getConfigPath();
@@ -609,20 +754,20 @@ app.get('/api/debug/auth', (req, res) => {
609
754
  });
610
755
  });
611
756
 
612
- app.post('/api/paths', (req, res) => {
613
- const { configPath } = req.body;
614
- const studioConfig = loadStudioConfig();
615
-
616
- if (configPath && fs.existsSync(configPath) && fs.statSync(configPath).isDirectory()) {
617
- const potentialFile = path.join(configPath, 'opencode.json');
618
- studioConfig.configPath = potentialFile;
619
- } else {
620
- studioConfig.configPath = configPath;
621
- }
622
-
623
- saveStudioConfig(studioConfig);
624
- res.json({ success: true, current: getConfigPath() });
625
- });
757
+ app.post('/api/paths', (req, res) => {
758
+ const { configPath } = req.body;
759
+ const studioConfig = loadStudioConfig();
760
+
761
+ if (configPath && fs.existsSync(configPath) && fs.statSync(configPath).isDirectory()) {
762
+ const potentialFile = path.join(configPath, 'opencode.json');
763
+ studioConfig.configPath = potentialFile;
764
+ } else {
765
+ studioConfig.configPath = configPath;
766
+ }
767
+
768
+ saveStudioConfig(studioConfig);
769
+ res.json({ success: true, current: getConfigPath() });
770
+ });
626
771
 
627
772
  app.get('/api/config', (req, res) => {
628
773
  const config = loadConfig();
@@ -632,6 +777,9 @@ app.get('/api/config', (req, res) => {
632
777
 
633
778
  app.post('/api/config', (req, res) => {
634
779
  try {
780
+ if (!validatePermissionValue(req.body?.permission)) {
781
+ return res.status(400).json({ error: 'Invalid permission value. Must be ask, allow, or deny.' });
782
+ }
635
783
  saveConfig(req.body);
636
784
  triggerGitHubAutoSync();
637
785
  res.json({ success: true });
@@ -639,6 +787,286 @@ app.post('/api/config', (req, res) => {
639
787
  res.status(500).json({ error: err.message });
640
788
  }
641
789
  });
790
+
791
+ app.get('/api/agents', (req, res) => {
792
+ try {
793
+ const config = loadConfig() || {};
794
+ const studio = loadStudioConfig();
795
+ const disabledAgents = studio.disabledAgents || [];
796
+ const agentMap = new Map();
797
+
798
+ const configAgents = config.agent || {};
799
+ for (const [name, agentConfig] of Object.entries(configAgents)) {
800
+ agentMap.set(name, {
801
+ name,
802
+ source: 'json',
803
+ disabled: disabledAgents.includes(name),
804
+ ...agentConfig,
805
+ permission: agentConfig.permission || agentConfig.permissions,
806
+ permissions: agentConfig.permission || agentConfig.permissions
807
+ });
808
+ }
809
+
810
+ for (const dir of getAgentDirs()) {
811
+ if (!fs.existsSync(dir)) continue;
812
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
813
+ files.forEach((file) => {
814
+ const fp = path.join(dir, file);
815
+ const content = fs.readFileSync(fp, 'utf8');
816
+ const { data, body } = parseAgentMarkdown(content);
817
+ const name = path.basename(file, '.md');
818
+ agentMap.set(name, {
819
+ name,
820
+ source: 'markdown',
821
+ disabled: disabledAgents.includes(name),
822
+ description: data.description,
823
+ mode: data.mode,
824
+ model: data.model,
825
+ temperature: data.temperature,
826
+ tools: data.tools,
827
+ permission: data.permission,
828
+ permissions: data.permission,
829
+ maxSteps: data.maxSteps,
830
+ disable: data.disable,
831
+ hidden: data.hidden,
832
+ prompt: body
833
+ });
834
+ });
835
+ }
836
+
837
+ ['build', 'plan'].forEach((name) => {
838
+ if (!agentMap.has(name)) {
839
+ agentMap.set(name, { name, source: 'builtin', mode: 'primary', disabled: disabledAgents.includes(name) });
840
+ }
841
+ });
842
+
843
+ res.json({ agents: Array.from(agentMap.values()) });
844
+ } catch (err) {
845
+ res.status(500).json({ error: err.message });
846
+ }
847
+ });
848
+
849
+ app.post('/api/agents', (req, res) => {
850
+ try {
851
+ const { name, config: agentConfig, source, scope } = req.body || {};
852
+ if (!name || typeof name !== 'string') return res.status(400).json({ error: 'Missing agent name' });
853
+ if (!/^[a-zA-Z0-9 _-]+$/.test(name)) return res.status(400).json({ error: 'Invalid agent name' });
854
+
855
+ const config = loadConfig() || {};
856
+ if (!config.agent) config.agent = {};
857
+
858
+ const normalizedConfig = { ...(agentConfig || {}) };
859
+ if (normalizedConfig.permissions && !normalizedConfig.permission) {
860
+ normalizedConfig.permission = normalizedConfig.permissions;
861
+ delete normalizedConfig.permissions;
862
+ }
863
+
864
+ const shouldWriteMarkdown = source === 'markdown' || !!normalizedConfig?.mode;
865
+ if (shouldWriteMarkdown) {
866
+ const configPath = getConfigPath();
867
+ const baseDir = configPath ? path.dirname(configPath) : HOME_DIR;
868
+ const projectDir = path.join(baseDir, '.opencode', 'agents');
869
+ const globalDir = path.join(HOME_DIR, '.config', 'opencode', 'agents');
870
+ const targetDir = scope === 'project' ? projectDir : globalDir;
871
+ if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
872
+
873
+ const frontmatter = {
874
+ description: normalizedConfig?.description,
875
+ mode: normalizedConfig?.mode,
876
+ model: normalizedConfig?.model,
877
+ temperature: normalizedConfig?.temperature,
878
+ tools: normalizedConfig?.tools,
879
+ permission: normalizedConfig?.permission,
880
+ maxSteps: normalizedConfig?.maxSteps,
881
+ disable: normalizedConfig?.disable,
882
+ hidden: normalizedConfig?.hidden
883
+ };
884
+
885
+ const markdown = buildAgentMarkdown(frontmatter, normalizedConfig?.prompt || '');
886
+ atomicWriteFileSync(path.join(targetDir, `${name}.md`), markdown);
887
+
888
+ if (config.agent[name]) {
889
+ delete config.agent[name];
890
+ saveConfig(config);
891
+ }
892
+ } else {
893
+ config.agent[name] = normalizedConfig;
894
+ saveConfig(config);
895
+ }
896
+
897
+ triggerGitHubAutoSync();
898
+ res.json({ success: true });
899
+ } catch (err) {
900
+ res.status(500).json({ error: err.message });
901
+ }
902
+ });
903
+
904
+ app.put('/api/agents/:name', (req, res) => {
905
+ try {
906
+ const { name } = req.params;
907
+ const { config: agentConfig } = req.body || {};
908
+
909
+ const normalizedConfig = { ...(agentConfig || {}) };
910
+ if (normalizedConfig.permissions && !normalizedConfig.permission) {
911
+ normalizedConfig.permission = normalizedConfig.permissions;
912
+ delete normalizedConfig.permissions;
913
+ }
914
+
915
+ const config = loadConfig() || {};
916
+ if (!config.agent) config.agent = {};
917
+
918
+ const markdownDirs = getAgentDirs().filter((d) => fs.existsSync(d));
919
+ const markdownPath = markdownDirs
920
+ .map((d) => path.join(d, `${name}.md`))
921
+ .find((p) => fs.existsSync(p));
922
+
923
+ if (markdownPath) {
924
+ const frontmatter = {
925
+ description: normalizedConfig?.description,
926
+ mode: normalizedConfig?.mode,
927
+ model: normalizedConfig?.model,
928
+ temperature: normalizedConfig?.temperature,
929
+ tools: normalizedConfig?.tools,
930
+ permission: normalizedConfig?.permission,
931
+ maxSteps: normalizedConfig?.maxSteps,
932
+ disable: normalizedConfig?.disable,
933
+ hidden: normalizedConfig?.hidden
934
+ };
935
+ const markdown = buildAgentMarkdown(frontmatter, normalizedConfig?.prompt || '');
936
+ atomicWriteFileSync(markdownPath, markdown);
937
+ } else {
938
+ config.agent[name] = normalizedConfig;
939
+ saveConfig(config);
940
+ }
941
+
942
+ triggerGitHubAutoSync();
943
+ res.json({ success: true });
944
+ } catch (err) {
945
+ res.status(500).json({ error: err.message });
946
+ }
947
+ });
948
+
949
+ app.delete('/api/agents/:name', (req, res) => {
950
+ try {
951
+ const { name } = req.params;
952
+ const config = loadConfig() || {};
953
+
954
+ if (config.agent && config.agent[name]) {
955
+ delete config.agent[name];
956
+ saveConfig(config);
957
+ }
958
+
959
+ for (const dir of getAgentDirs()) {
960
+ const fp = path.join(dir, `${name}.md`);
961
+ if (fs.existsSync(fp)) fs.unlinkSync(fp);
962
+ }
963
+
964
+ triggerGitHubAutoSync();
965
+ res.json({ success: true });
966
+ } catch (err) {
967
+ res.status(500).json({ error: err.message });
968
+ }
969
+ });
970
+
971
+ app.post('/api/agents/:name/toggle', (req, res) => {
972
+ try {
973
+ const { name } = req.params;
974
+ const studio = loadStudioConfig();
975
+ const disabled = new Set(studio.disabledAgents || []);
976
+ if (disabled.has(name)) disabled.delete(name); else disabled.add(name);
977
+ studio.disabledAgents = Array.from(disabled);
978
+ saveStudioConfig(studio);
979
+ res.json({ success: true, disabled: studio.disabledAgents.includes(name) });
980
+ } catch (err) {
981
+ res.status(500).json({ error: err.message });
982
+ }
983
+ });
984
+
985
+ app.get('/api/logs/stream', (req, res) => {
986
+ res.setHeader('Content-Type', 'text/event-stream');
987
+ res.setHeader('Cache-Control', 'no-cache');
988
+ res.setHeader('Connection', 'keep-alive');
989
+ res.setHeader('X-Accel-Buffering', 'no');
990
+ if (res.flushHeaders) res.flushHeaders();
991
+
992
+ setupLogWatcher();
993
+
994
+ const sub = { res, queue: [], draining: false, closed: false };
995
+ logSubscribers.add(sub);
996
+
997
+ logBuffer.forEach((entry) => {
998
+ sub.queue.push(`data: ${JSON.stringify(entry)}\n\n`);
999
+ });
1000
+ flushSubscriber(sub);
1001
+
1002
+ const keepalive = setInterval(() => {
1003
+ if (sub.closed) return;
1004
+ res.write(': keepalive\n\n');
1005
+ }, 20000);
1006
+
1007
+ req.on('close', () => {
1008
+ sub.closed = true;
1009
+ clearInterval(keepalive);
1010
+ logSubscribers.delete(sub);
1011
+ try { res.end(); } catch {}
1012
+ });
1013
+ });
1014
+
1015
+ app.get('/api/system/tools', (req, res) => {
1016
+ const knownTools = [
1017
+ 'go', 'gofmt', 'gopls',
1018
+ 'rust-analyzer', 'rustfmt',
1019
+ 'prettier', 'eslint',
1020
+ 'typescript-language-server', 'tsserver',
1021
+ 'pyright', 'ruff', 'python',
1022
+ 'clangd', 'clang-format',
1023
+ 'dart', 'jdtls',
1024
+ 'kotlin-language-server', 'ktlint',
1025
+ 'deno', 'lua-language-server',
1026
+ 'ocamllsp', 'nixd',
1027
+ 'swift', 'sourcekit-lsp'
1028
+ ];
1029
+
1030
+ const tools = knownTools.map((tool) => detectTool(tool));
1031
+ res.json(tools);
1032
+ });
1033
+
1034
+ app.get('/api/project/rules', (req, res) => {
1035
+ try {
1036
+ const found = findRulesFile();
1037
+ if (!found.path) return res.json({ content: '', source: 'none', path: null });
1038
+ const content = fs.readFileSync(found.path, 'utf8');
1039
+ res.json({ content, source: found.source, path: found.path });
1040
+ } catch (err) {
1041
+ res.status(500).json({ error: err.message });
1042
+ }
1043
+ });
1044
+
1045
+ app.post('/api/project/rules', (req, res) => {
1046
+ try {
1047
+ const { content, source } = req.body || {};
1048
+ const configPath = getConfigPath();
1049
+ if (!configPath) return res.status(400).json({ error: 'No config path found' });
1050
+
1051
+ const targetName = source === 'CLAUDE.md' ? 'CLAUDE.md' : 'AGENTS.md';
1052
+ const found = findRulesFile();
1053
+
1054
+ let targetPath = null;
1055
+ if (found.path && path.basename(found.path) === targetName) {
1056
+ targetPath = found.path;
1057
+ }
1058
+
1059
+ if (!targetPath) {
1060
+ const baseDir = path.dirname(configPath);
1061
+ targetPath = path.join(baseDir, targetName);
1062
+ }
1063
+
1064
+ atomicWriteFileSync(targetPath, content || '');
1065
+ res.json({ success: true, path: targetPath, source: targetName });
1066
+ } catch (err) {
1067
+ res.status(500).json({ error: err.message });
1068
+ }
1069
+ });
642
1070
 
643
1071
  app.get('/api/backup', (req, res) => {
644
1072
  try {
@@ -1094,11 +1522,11 @@ app.post('/api/ohmyopencode', (req, res) => {
1094
1522
 
1095
1523
  saveOhMyOpenCodeConfig(currentConfig);
1096
1524
 
1097
- const ohMyPath = getOhMyOpenCodeConfigPath();
1098
- triggerGitHubAutoSync();
1099
- res.json({
1100
- success: true,
1101
- path: ohMyPath,
1525
+ const ohMyPath = getOhMyOpenCodeConfigPath();
1526
+ triggerGitHubAutoSync();
1527
+ res.json({
1528
+ success: true,
1529
+ path: ohMyPath,
1102
1530
  exists: true,
1103
1531
  config: currentConfig,
1104
1532
  preferences,
@@ -1202,107 +1630,107 @@ function execPromise(cmd, opts = {}) {
1202
1630
  });
1203
1631
  }
1204
1632
 
1205
-
1206
- let autoSyncTimer = null;
1207
-
1208
- async function performGitHubBackup(options = {}) {
1209
- const { owner, repo, branch } = options;
1210
- let tempDir = null;
1211
- try {
1212
- const token = await getGitHubToken();
1213
- if (!token) throw new Error('Not logged in to gh CLI. Run: gh auth login');
1214
-
1215
- const user = await getGitHubUser(token);
1216
- if (!user) throw new Error('Failed to get GitHub user');
1217
-
1218
- const studio = loadStudioConfig();
1219
-
1220
- const finalOwner = owner || studio.githubBackup?.owner || user.login;
1221
- const finalRepo = repo || studio.githubBackup?.repo;
1222
- const finalBranch = branch || studio.githubBackup?.branch || 'main';
1223
-
1224
- if (!finalRepo) throw new Error('No repo name provided');
1225
-
1226
- const repoName = `${finalOwner}/${finalRepo}`;
1227
-
1228
- await ensureGitHubRepo(token, repoName);
1229
-
1230
- const opencodeConfig = getConfigPath();
1231
- if (!opencodeConfig) throw new Error('No opencode config path found');
1232
-
1233
- const opencodeDir = path.dirname(opencodeConfig);
1234
- const studioDir = path.join(HOME_DIR, '.config', 'opencode-studio');
1235
-
1236
- tempDir = path.join(os.tmpdir(), `opencode-backup-${Date.now()}`);
1237
- fs.mkdirSync(tempDir, { recursive: true });
1238
-
1239
- // Clone or init
1240
- try {
1241
- await execPromise(`git clone --depth 1 https://x-access-token:${token}@github.com/${repoName}.git .`, { cwd: tempDir });
1242
- } catch (e) {
1243
- // If clone fails (empty repo?), try init
1244
- await execPromise('git init', { cwd: tempDir });
1245
- await execPromise(`git remote add origin https://x-access-token:${token}@github.com/${repoName}.git`, { cwd: tempDir });
1246
- await execPromise(`git checkout -b ${finalBranch}`, { cwd: tempDir });
1247
- }
1248
-
1249
- const backupOpencodeDir = path.join(tempDir, 'opencode');
1250
- const backupStudioDir = path.join(tempDir, 'opencode-studio');
1251
-
1252
- if (fs.existsSync(backupOpencodeDir)) fs.rmSync(backupOpencodeDir, { recursive: true });
1253
- if (fs.existsSync(backupStudioDir)) fs.rmSync(backupStudioDir, { recursive: true });
1254
-
1255
- copyDirContents(opencodeDir, backupOpencodeDir);
1256
- copyDirContents(studioDir, backupStudioDir);
1257
-
1258
- await execPromise('git add -A', { cwd: tempDir });
1259
-
1260
- const timestamp = new Date().toISOString();
1261
- const commitMessage = `OpenCode Studio backup ${timestamp}`;
1262
-
1263
- let result = { success: true, timestamp, url: `https://github.com/${repoName}` };
1264
-
1265
- try {
1266
- await execPromise(`git commit -m "${commitMessage}"`, { cwd: tempDir });
1267
- await execPromise(`git push origin ${finalBranch}`, { cwd: tempDir });
1268
- } catch (e) {
1269
- if (e.message.includes('nothing to commit')) {
1270
- result.message = 'No changes to backup';
1271
- } else {
1272
- throw e;
1273
- }
1274
- }
1275
-
1276
- studio.githubBackup = { owner: finalOwner, repo: finalRepo, branch: finalBranch };
1277
- studio.lastGithubBackup = timestamp;
1278
- saveStudioConfig(studio);
1279
-
1280
- return result;
1281
- } finally {
1282
- if (tempDir && fs.existsSync(tempDir)) {
1283
- try { fs.rmSync(tempDir, { recursive: true }); } catch (e) {}
1284
- }
1285
- }
1286
- }
1287
-
1288
- function triggerGitHubAutoSync() {
1289
- const studio = loadStudioConfig();
1290
- if (!studio.githubAutoSync) return;
1291
-
1292
- if (autoSyncTimer) clearTimeout(autoSyncTimer);
1293
-
1294
- console.log('[AutoSync] Change detected, scheduling GitHub backup in 15s...');
1295
- autoSyncTimer = setTimeout(async () => {
1296
- console.log('[AutoSync] Starting GitHub backup...');
1297
- try {
1298
- const result = await performGitHubBackup();
1299
- console.log(`[AutoSync] Backup completed: ${result.message || 'Pushed to GitHub'}`);
1300
- } catch (err) {
1301
- console.error('[AutoSync] Backup failed:', err.message);
1302
- }
1303
- }, 15000); // 15s debounce
1304
- }
1305
-
1633
+
1634
+ let autoSyncTimer = null;
1635
+
1636
+ async function performGitHubBackup(options = {}) {
1637
+ const { owner, repo, branch } = options;
1638
+ let tempDir = null;
1639
+ try {
1640
+ const token = await getGitHubToken();
1641
+ if (!token) throw new Error('Not logged in to gh CLI. Run: gh auth login');
1642
+
1643
+ const user = await getGitHubUser(token);
1644
+ if (!user) throw new Error('Failed to get GitHub user');
1645
+
1646
+ const studio = loadStudioConfig();
1647
+
1648
+ const finalOwner = owner || studio.githubBackup?.owner || user.login;
1649
+ const finalRepo = repo || studio.githubBackup?.repo;
1650
+ const finalBranch = branch || studio.githubBackup?.branch || 'main';
1651
+
1652
+ if (!finalRepo) throw new Error('No repo name provided');
1653
+
1654
+ const repoName = `${finalOwner}/${finalRepo}`;
1655
+
1656
+ await ensureGitHubRepo(token, repoName);
1657
+
1658
+ const opencodeConfig = getConfigPath();
1659
+ if (!opencodeConfig) throw new Error('No opencode config path found');
1660
+
1661
+ const opencodeDir = path.dirname(opencodeConfig);
1662
+ const studioDir = path.join(HOME_DIR, '.config', 'opencode-studio');
1663
+
1664
+ tempDir = path.join(os.tmpdir(), `opencode-backup-${Date.now()}`);
1665
+ fs.mkdirSync(tempDir, { recursive: true });
1666
+
1667
+ // Clone or init
1668
+ try {
1669
+ await execPromise(`git clone --depth 1 https://x-access-token:${token}@github.com/${repoName}.git .`, { cwd: tempDir });
1670
+ } catch (e) {
1671
+ // If clone fails (empty repo?), try init
1672
+ await execPromise('git init', { cwd: tempDir });
1673
+ await execPromise(`git remote add origin https://x-access-token:${token}@github.com/${repoName}.git`, { cwd: tempDir });
1674
+ await execPromise(`git checkout -b ${finalBranch}`, { cwd: tempDir });
1675
+ }
1676
+
1677
+ const backupOpencodeDir = path.join(tempDir, 'opencode');
1678
+ const backupStudioDir = path.join(tempDir, 'opencode-studio');
1679
+
1680
+ if (fs.existsSync(backupOpencodeDir)) fs.rmSync(backupOpencodeDir, { recursive: true });
1681
+ if (fs.existsSync(backupStudioDir)) fs.rmSync(backupStudioDir, { recursive: true });
1682
+
1683
+ copyDirContents(opencodeDir, backupOpencodeDir);
1684
+ copyDirContents(studioDir, backupStudioDir);
1685
+
1686
+ await execPromise('git add -A', { cwd: tempDir });
1687
+
1688
+ const timestamp = new Date().toISOString();
1689
+ const commitMessage = `OpenCode Studio backup ${timestamp}`;
1690
+
1691
+ let result = { success: true, timestamp, url: `https://github.com/${repoName}` };
1692
+
1693
+ try {
1694
+ await execPromise(`git commit -m "${commitMessage}"`, { cwd: tempDir });
1695
+ await execPromise(`git push origin ${finalBranch}`, { cwd: tempDir });
1696
+ } catch (e) {
1697
+ if (e.message.includes('nothing to commit')) {
1698
+ result.message = 'No changes to backup';
1699
+ } else {
1700
+ throw e;
1701
+ }
1702
+ }
1703
+
1704
+ studio.githubBackup = { owner: finalOwner, repo: finalRepo, branch: finalBranch };
1705
+ studio.lastGithubBackup = timestamp;
1706
+ saveStudioConfig(studio);
1707
+
1708
+ return result;
1709
+ } finally {
1710
+ if (tempDir && fs.existsSync(tempDir)) {
1711
+ try { fs.rmSync(tempDir, { recursive: true }); } catch (e) {}
1712
+ }
1713
+ }
1714
+ }
1715
+
1716
+ function triggerGitHubAutoSync() {
1717
+ const studio = loadStudioConfig();
1718
+ if (!studio.githubAutoSync) return;
1719
+
1720
+ if (autoSyncTimer) clearTimeout(autoSyncTimer);
1721
+
1722
+ console.log('[AutoSync] Change detected, scheduling GitHub backup in 15s...');
1723
+ autoSyncTimer = setTimeout(async () => {
1724
+ console.log('[AutoSync] Starting GitHub backup...');
1725
+ try {
1726
+ const result = await performGitHubBackup();
1727
+ console.log(`[AutoSync] Backup completed: ${result.message || 'Pushed to GitHub'}`);
1728
+ } catch (err) {
1729
+ console.error('[AutoSync] Backup failed:', err.message);
1730
+ }
1731
+ }, 15000); // 15s debounce
1732
+ }
1733
+
1306
1734
  app.get('/api/github/backup/status', async (req, res) => {
1307
1735
  try {
1308
1736
  const token = await getGitHubToken();
@@ -1334,15 +1762,15 @@ app.get('/api/github/backup/status', async (req, res) => {
1334
1762
  }
1335
1763
  });
1336
1764
 
1337
- app.post('/api/github/backup', async (req, res) => {
1338
- try {
1339
- const result = await performGitHubBackup(req.body);
1340
- res.json(result);
1341
- } catch (err) {
1342
- console.error('GitHub backup error:', err);
1343
- res.status(500).json({ error: err.message });
1344
- }
1345
- });
1765
+ app.post('/api/github/backup', async (req, res) => {
1766
+ try {
1767
+ const result = await performGitHubBackup(req.body);
1768
+ res.json(result);
1769
+ } catch (err) {
1770
+ console.error('GitHub backup error:', err);
1771
+ res.status(500).json({ error: err.message });
1772
+ }
1773
+ });
1346
1774
 
1347
1775
  app.post('/api/github/restore', async (req, res) => {
1348
1776
  let tempDir = null;
@@ -1401,10 +1829,10 @@ app.post('/api/github/restore', async (req, res) => {
1401
1829
  app.post('/api/github/autosync', async (req, res) => {
1402
1830
  const studio = loadStudioConfig();
1403
1831
  const enabled = req.body.enabled;
1404
- studio.githubAutoSync = enabled;
1405
- saveStudioConfig(studio);
1406
- if (enabled) triggerGitHubAutoSync();
1407
- res.json({ success: true, enabled });
1832
+ studio.githubAutoSync = enabled;
1833
+ saveStudioConfig(studio);
1834
+ if (enabled) triggerGitHubAutoSync();
1835
+ res.json({ success: true, enabled });
1408
1836
  });
1409
1837
 
1410
1838
  const getSkillDir = () => {
@@ -1441,9 +1869,9 @@ app.post('/api/skills/:name', (req, res) => {
1441
1869
  if (!sd) return res.status(404).json({ error: 'No config' });
1442
1870
  const dp = path.join(sd, req.params.name);
1443
1871
  if (!fs.existsSync(dp)) fs.mkdirSync(dp, { recursive: true });
1444
- fs.writeFileSync(path.join(dp, 'SKILL.md'), req.body.content, 'utf8');
1445
- triggerGitHubAutoSync();
1446
- res.json({ success: true });
1872
+ fs.writeFileSync(path.join(dp, 'SKILL.md'), req.body.content, 'utf8');
1873
+ triggerGitHubAutoSync();
1874
+ res.json({ success: true });
1447
1875
  });
1448
1876
 
1449
1877
  app.delete('/api/skills/:name', (req, res) => {
@@ -1452,9 +1880,9 @@ app.delete('/api/skills/:name', (req, res) => {
1452
1880
  }
1453
1881
  const sd = getSkillDir();
1454
1882
  const dp = sd ? path.join(sd, req.params.name) : null;
1455
- if (dp && fs.existsSync(dp)) fs.rmSync(dp, { recursive: true, force: true });
1456
- triggerGitHubAutoSync();
1457
- res.json({ success: true });
1883
+ if (dp && fs.existsSync(dp)) fs.rmSync(dp, { recursive: true, force: true });
1884
+ triggerGitHubAutoSync();
1885
+ res.json({ success: true });
1458
1886
  });
1459
1887
 
1460
1888
  app.post('/api/skills/:name/toggle', (req, res) => {
@@ -1468,9 +1896,9 @@ app.post('/api/skills/:name/toggle', (req, res) => {
1468
1896
  studio.disabledSkills.push(name);
1469
1897
  }
1470
1898
 
1471
- saveStudioConfig(studio);
1472
- triggerGitHubAutoSync();
1473
- res.json({ success: true, enabled: !studio.disabledSkills.includes(name) });
1899
+ saveStudioConfig(studio);
1900
+ triggerGitHubAutoSync();
1901
+ res.json({ success: true, enabled: !studio.disabledSkills.includes(name) });
1474
1902
  });
1475
1903
 
1476
1904
  const getPluginDir = () => {
@@ -1547,10 +1975,10 @@ app.post('/api/plugins/:name', (req, res) => {
1547
1975
  if (!fs.existsSync(pd)) fs.mkdirSync(pd, { recursive: true });
1548
1976
 
1549
1977
  // Default to .js if new
1550
- const filePath = path.join(pd, name.endsWith('.js') || name.endsWith('.ts') ? name : name + '.js');
1551
- atomicWriteFileSync(filePath, content);
1552
- triggerGitHubAutoSync();
1553
- res.json({ success: true });
1978
+ const filePath = path.join(pd, name.endsWith('.js') || name.endsWith('.ts') ? name : name + '.js');
1979
+ atomicWriteFileSync(filePath, content);
1980
+ triggerGitHubAutoSync();
1981
+ res.json({ success: true });
1554
1982
  });
1555
1983
 
1556
1984
  app.delete('/api/plugins/:name', (req, res) => {
@@ -1575,10 +2003,10 @@ app.delete('/api/plugins/:name', (req, res) => {
1575
2003
  }
1576
2004
  }
1577
2005
 
1578
- if (deleted) {
1579
- triggerGitHubAutoSync();
1580
- res.json({ success: true });
1581
- } else res.status(404).json({ error: 'Plugin not found' });
2006
+ if (deleted) {
2007
+ triggerGitHubAutoSync();
2008
+ res.json({ success: true });
2009
+ } else res.status(404).json({ error: 'Plugin not found' });
1582
2010
  });
1583
2011
 
1584
2012
  app.post('/api/plugins/:name/toggle', (req, res) => {
@@ -1592,9 +2020,9 @@ app.post('/api/plugins/:name/toggle', (req, res) => {
1592
2020
  studio.disabledPlugins.push(name);
1593
2021
  }
1594
2022
 
1595
- saveStudioConfig(studio);
1596
- triggerGitHubAutoSync();
1597
- res.json({ success: true, enabled: !studio.disabledPlugins.includes(name) });
2023
+ saveStudioConfig(studio);
2024
+ triggerGitHubAutoSync();
2025
+ res.json({ success: true, enabled: !studio.disabledPlugins.includes(name) });
1598
2026
  });
1599
2027
 
1600
2028
  const getActiveGooglePlugin = () => {
@@ -3489,18 +3917,20 @@ app.post('/api/presets/:id/apply', (req, res) => {
3489
3917
  });
3490
3918
 
3491
3919
  // Start watcher on server start
3492
- function startServer() {
3920
+ async function startServer() {
3493
3921
  ['google', 'anthropic', 'openai', 'xai', 'openrouter', 'together', 'mistral', 'deepseek', 'amazon-bedrock', 'azure', 'github-copilot'].forEach(p => importCurrentAuthToPool(p));
3494
- app.listen(PORT, () => {
3495
- console.log(`Server running at http://localhost:${PORT}`);
3496
- // Initial sync on startup if enabled
3497
- setTimeout(() => {
3498
- const studio = loadStudioConfig();
3499
- if (studio.githubAutoSync) {
3500
- console.log('[AutoSync] Triggering initial sync...');
3501
- triggerGitHubAutoSync();
3502
- }
3503
- }, 5000);
3922
+
3923
+ const port = await findAvailablePort(DEFAULT_PORT);
3924
+ app.listen(port, () => {
3925
+ console.log(`Server running at http://localhost:${port}`);
3926
+ // Initial sync on startup if enabled
3927
+ setTimeout(() => {
3928
+ const studio = loadStudioConfig();
3929
+ if (studio.githubAutoSync) {
3930
+ console.log('[AutoSync] Triggering initial sync...');
3931
+ triggerGitHubAutoSync();
3932
+ }
3933
+ }, 5000);
3504
3934
  });
3505
3935
  }
3506
3936
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
- {
2
- "name": "opencode-studio-server",
3
- "version": "1.28.3",
4
- "description": "Backend server for OpenCode Studio - manages opencode configurations",
1
+ {
2
+ "name": "opencode-studio-server",
3
+ "version": "2.1.0",
4
+ "description": "Backend server for OpenCode Studio - manages opencode configurations",
5
5
  "main": "index.js",
6
6
  "bin": {
7
7
  "opencode-studio-server": "cli.js"