@openchamber/web 1.4.3 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server/index.js CHANGED
@@ -6,6 +6,7 @@ import fs from 'fs';
6
6
  import http from 'http';
7
7
  import { fileURLToPath } from 'url';
8
8
  import os from 'os';
9
+ import crypto from 'crypto';
9
10
  import { createUiAuth } from './lib/ui-auth.js';
10
11
  import { startCloudflareTunnel, printTunnelWarning, checkCloudflaredAvailable } from './lib/cloudflare-tunnel.js';
11
12
 
@@ -315,6 +316,76 @@ const writeSettingsToDisk = async (settings) => {
315
316
  }
316
317
  };
317
318
 
319
+ const resolveDirectoryCandidate = (value) => {
320
+ if (typeof value !== 'string') {
321
+ return null;
322
+ }
323
+ const trimmed = value.trim();
324
+ if (!trimmed) {
325
+ return null;
326
+ }
327
+ const normalized = normalizeDirectoryPath(trimmed);
328
+ return path.resolve(normalized);
329
+ };
330
+
331
+ const validateDirectoryPath = async (candidate) => {
332
+ const resolved = resolveDirectoryCandidate(candidate);
333
+ if (!resolved) {
334
+ return { ok: false, error: 'Directory parameter is required' };
335
+ }
336
+ try {
337
+ const stats = await fsPromises.stat(resolved);
338
+ if (!stats.isDirectory()) {
339
+ return { ok: false, error: 'Specified path is not a directory' };
340
+ }
341
+ return { ok: true, directory: resolved };
342
+ } catch (error) {
343
+ const err = error;
344
+ if (err && typeof err === 'object' && err.code === 'ENOENT') {
345
+ return { ok: false, error: 'Directory not found' };
346
+ }
347
+ if (err && typeof err === 'object' && err.code === 'EACCES') {
348
+ return { ok: false, error: 'Access to directory denied' };
349
+ }
350
+ return { ok: false, error: 'Failed to validate directory' };
351
+ }
352
+ };
353
+
354
+ const resolveProjectDirectory = async (req) => {
355
+ const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
356
+ const queryDirectory = Array.isArray(req.query?.directory)
357
+ ? req.query.directory[0]
358
+ : req.query?.directory;
359
+ const requested = headerDirectory || queryDirectory || null;
360
+
361
+ if (requested) {
362
+ const validated = await validateDirectoryPath(requested);
363
+ if (!validated.ok) {
364
+ return { directory: null, error: validated.error };
365
+ }
366
+ return { directory: validated.directory, error: null };
367
+ }
368
+
369
+ const settings = await readSettingsFromDiskMigrated();
370
+ const projects = sanitizeProjects(settings.projects) || [];
371
+ if (projects.length === 0) {
372
+ return { directory: null, error: 'Directory parameter or active project is required' };
373
+ }
374
+
375
+ const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : '';
376
+ const active = projects.find((project) => project.id === activeId) || projects[0];
377
+ if (!active || !active.path) {
378
+ return { directory: null, error: 'Directory parameter or active project is required' };
379
+ }
380
+
381
+ const validated = await validateDirectoryPath(active.path);
382
+ if (!validated.ok) {
383
+ return { directory: null, error: validated.error };
384
+ }
385
+
386
+ return { directory: validated.directory, error: null };
387
+ };
388
+
318
389
  const sanitizeTypographySizesPartial = (input) => {
319
390
  if (!input || typeof input !== 'object') {
320
391
  return undefined;
@@ -384,6 +455,67 @@ const sanitizeSkillCatalogs = (input) => {
384
455
  return result;
385
456
  };
386
457
 
458
+ const sanitizeProjects = (input) => {
459
+ if (!Array.isArray(input)) {
460
+ return undefined;
461
+ }
462
+
463
+ const result = [];
464
+ const seenIds = new Set();
465
+ const seenPaths = new Set();
466
+
467
+ for (const entry of input) {
468
+ if (!entry || typeof entry !== 'object') continue;
469
+
470
+ const candidate = entry;
471
+ const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
472
+ const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
473
+ const normalizedPath = rawPath ? path.resolve(normalizeDirectoryPath(rawPath)) : '';
474
+ const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
475
+ const addedAt = Number.isFinite(candidate.addedAt) ? Number(candidate.addedAt) : null;
476
+ const lastOpenedAt = Number.isFinite(candidate.lastOpenedAt)
477
+ ? Number(candidate.lastOpenedAt)
478
+ : null;
479
+
480
+ if (!id || !normalizedPath) continue;
481
+ if (seenIds.has(id)) continue;
482
+ if (seenPaths.has(normalizedPath)) continue;
483
+
484
+ seenIds.add(id);
485
+ seenPaths.add(normalizedPath);
486
+
487
+ const project = {
488
+ id,
489
+ path: normalizedPath,
490
+ ...(label ? { label } : {}),
491
+ ...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}),
492
+ ...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}),
493
+ };
494
+
495
+ // Preserve worktreeDefaults
496
+ if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') {
497
+ const wt = candidate.worktreeDefaults;
498
+ const defaults = {};
499
+ if (typeof wt.branchPrefix === 'string' && wt.branchPrefix.trim()) {
500
+ defaults.branchPrefix = wt.branchPrefix.trim();
501
+ }
502
+ if (typeof wt.baseBranch === 'string' && wt.baseBranch.trim()) {
503
+ defaults.baseBranch = wt.baseBranch.trim();
504
+ }
505
+ if (typeof wt.autoCreateWorktree === 'boolean') {
506
+ defaults.autoCreateWorktree = wt.autoCreateWorktree;
507
+ }
508
+ if (Object.keys(defaults).length > 0) {
509
+ project.worktreeDefaults = defaults;
510
+ }
511
+ }
512
+
513
+ result.push(project);
514
+ }
515
+
516
+ return result;
517
+ };
518
+
387
519
  const sanitizeSettingsUpdate = (payload) => {
388
520
  if (!payload || typeof payload !== 'object') {
389
521
  return {};
@@ -413,6 +545,15 @@ const sanitizeSettingsUpdate = (payload) => {
413
545
  if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) {
414
546
  result.homeDirectory = candidate.homeDirectory;
415
547
  }
548
+ if (Array.isArray(candidate.projects)) {
549
+ const projects = sanitizeProjects(candidate.projects);
550
+ if (projects) {
551
+ result.projects = projects;
552
+ }
553
+ }
554
+ if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) {
555
+ result.activeProjectId = candidate.activeProjectId;
556
+ }
416
557
 
417
558
  if (Array.isArray(candidate.approvedDirectories)) {
418
559
  result.approvedDirectories = normalizeStringArray(candidate.approvedDirectories);
@@ -458,6 +599,9 @@ const sanitizeSettingsUpdate = (payload) => {
458
599
  if (typeof candidate.queueModeEnabled === 'boolean') {
459
600
  result.queueModeEnabled = candidate.queueModeEnabled;
460
601
  }
602
+ if (typeof candidate.autoCreateWorktree === 'boolean') {
603
+ result.autoCreateWorktree = candidate.autoCreateWorktree;
604
+ }
461
605
 
462
606
  const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
463
607
  if (skillCatalogs) {
@@ -481,6 +625,16 @@ const mergePersistedSettings = (current, changes) => {
481
625
  if (typeof changes.homeDirectory === 'string' && changes.homeDirectory.length > 0) {
482
626
  additionalApproved.push(changes.homeDirectory);
483
627
  }
628
+ const projectEntries = Array.isArray(changes.projects)
629
+ ? changes.projects
630
+ : Array.isArray(current.projects)
631
+ ? current.projects
632
+ : [];
633
+ projectEntries.forEach((project) => {
634
+ if (project && typeof project.path === 'string' && project.path.length > 0) {
635
+ additionalApproved.push(project.path);
636
+ }
637
+ });
484
638
  const approvedSource = [...baseApproved, ...additionalApproved];
485
639
 
486
640
  const baseBookmarks = Array.isArray(changes.securityScopedBookmarks)
@@ -535,10 +689,124 @@ const formatSettingsResponse = (settings) => {
535
689
  };
536
690
  };
537
691
 
692
+ const validateProjectEntries = async (projects) => {
693
+ if (!Array.isArray(projects)) {
694
+ return [];
695
+ }
696
+
697
+ const results = [];
698
+ for (const project of projects) {
699
+ if (!project || typeof project.path !== 'string' || project.path.length === 0) {
700
+ continue;
701
+ }
702
+ try {
703
+ const stats = await fsPromises.stat(project.path);
704
+ if (!stats.isDirectory()) {
705
+ continue;
706
+ }
707
+ results.push(project);
708
+ } catch (error) {
709
+ const err = error;
710
+ if (err && typeof err === 'object' && err.code === 'ENOENT') {
711
+ continue;
712
+ }
713
+ continue;
714
+ }
715
+ }
716
+
717
+ return results;
718
+ };
719
+
720
+ const migrateSettingsFromLegacyLastDirectory = async (current) => {
721
+ const settings = current && typeof current === 'object' ? current : {};
722
+ const now = Date.now();
723
+
724
+ const sanitizedProjects = sanitizeProjects(settings.projects) || [];
725
+ let nextProjects = sanitizedProjects;
726
+ let nextActiveProjectId =
727
+ typeof settings.activeProjectId === 'string' ? settings.activeProjectId : undefined;
728
+
729
+ let changed = false;
730
+
731
+ if (nextProjects.length === 0) {
732
+ const legacy = typeof settings.lastDirectory === 'string' ? settings.lastDirectory.trim() : '';
733
+ const candidate = legacy ? resolveDirectoryCandidate(legacy) : null;
734
+
735
+ if (candidate) {
736
+ try {
737
+ const stats = await fsPromises.stat(candidate);
738
+ if (stats.isDirectory()) {
739
+ const id = crypto.randomUUID();
740
+ nextProjects = [
741
+ {
742
+ id,
743
+ path: candidate,
744
+ addedAt: now,
745
+ lastOpenedAt: now,
746
+ },
747
+ ];
748
+ nextActiveProjectId = id;
749
+ changed = true;
750
+ }
751
+ } catch {
752
+ // ignore invalid lastDirectory
753
+ }
754
+ }
755
+ }
756
+
757
+ if (nextProjects.length > 0) {
758
+ const active = nextProjects.find((project) => project.id === nextActiveProjectId) || null;
759
+ if (!active) {
760
+ nextActiveProjectId = nextProjects[0].id;
761
+ changed = true;
762
+ }
763
+ } else if (nextActiveProjectId) {
764
+ nextActiveProjectId = undefined;
765
+ changed = true;
766
+ }
767
+
768
+ if (!changed) {
769
+ return { settings, changed: false };
770
+ }
771
+
772
+ const merged = mergePersistedSettings(settings, {
773
+ ...settings,
774
+ projects: nextProjects,
775
+ ...(nextActiveProjectId ? { activeProjectId: nextActiveProjectId } : { activeProjectId: undefined }),
776
+ });
777
+
778
+ return { settings: merged, changed: true };
779
+ };
780
+
781
+ const readSettingsFromDiskMigrated = async () => {
782
+ const current = await readSettingsFromDisk();
783
+ const { settings, changed } = await migrateSettingsFromLegacyLastDirectory(current);
784
+ if (changed) {
785
+ await writeSettingsToDisk(settings);
786
+ }
787
+ return settings;
788
+ };
789
+
538
790
  const persistSettings = async (changes) => {
539
791
  const current = await readSettingsFromDisk();
540
792
  const sanitized = sanitizeSettingsUpdate(changes);
541
- const next = mergePersistedSettings(current, sanitized);
793
+ let next = mergePersistedSettings(current, sanitized);
794
+
795
+ if (Array.isArray(next.projects)) {
796
+ const validated = await validateProjectEntries(next.projects);
797
+ next = { ...next, projects: validated };
798
+ }
799
+
800
+ if (Array.isArray(next.projects) && next.projects.length > 0) {
801
+ const activeId = typeof next.activeProjectId === 'string' ? next.activeProjectId : '';
802
+ const active = next.projects.find((project) => project.id === activeId) || null;
803
+ if (!active) {
804
+ next = { ...next, activeProjectId: next.projects[0].id };
805
+ }
806
+ } else if (next.activeProjectId) {
807
+ next = { ...next, activeProjectId: undefined };
808
+ }
809
+
542
810
  await writeSettingsToDisk(next);
543
811
  return formatSettingsResponse(next);
544
812
  };
@@ -551,7 +819,7 @@ const getHmrState = () => {
551
819
  globalThis[HMR_STATE_KEY] = {
552
820
  openCodeProcess: null,
553
821
  openCodePort: null,
554
- openCodeWorkingDirectory: process.cwd(),
822
+ openCodeWorkingDirectory: os.homedir(),
555
823
  isShuttingDown: false,
556
824
  signalsAttached: false,
557
825
  };
@@ -937,7 +1205,16 @@ function parseSseDataPayload(block) {
937
1205
  }
938
1206
 
939
1207
  try {
940
- return JSON.parse(payloadText);
1208
+ const parsed = JSON.parse(payloadText);
1209
+ if (
1210
+ parsed &&
1211
+ typeof parsed === 'object' &&
1212
+ typeof parsed.payload === 'object' &&
1213
+ parsed.payload !== null
1214
+ ) {
1215
+ return parsed.payload;
1216
+ }
1217
+ return parsed;
941
1218
  } catch {
942
1219
  return null;
943
1220
  }
@@ -950,7 +1227,7 @@ function deriveSessionActivity(payload) {
950
1227
 
951
1228
  if (payload.type === 'session.status') {
952
1229
  const status = payload.properties?.status;
953
- const sessionId = payload.properties?.sessionID;
1230
+ const sessionId = payload.properties?.sessionID ?? payload.properties?.sessionId;
954
1231
  const statusType = status?.type;
955
1232
 
956
1233
  if (typeof sessionId === 'string' && sessionId.length > 0 && typeof statusType === 'string') {
@@ -961,7 +1238,17 @@ function deriveSessionActivity(payload) {
961
1238
 
962
1239
  if (payload.type === 'message.updated') {
963
1240
  const info = payload.properties?.info;
964
- const sessionId = info?.sessionID;
1241
+ const sessionId = info?.sessionID ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.sessionId;
1242
+ const role = info?.role;
1243
+ const finish = info?.finish;
1244
+ if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
1245
+ return { sessionId, phase: 'cooldown' };
1246
+ }
1247
+ }
1248
+
1249
+ if (payload.type === 'message.part.updated') {
1250
+ const info = payload.properties?.info;
1251
+ const sessionId = info?.sessionID ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.sessionId;
965
1252
  const role = info?.role;
966
1253
  const finish = info?.finish;
967
1254
  if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
@@ -970,7 +1257,7 @@ function deriveSessionActivity(payload) {
970
1257
  }
971
1258
 
972
1259
  if (payload.type === 'session.idle') {
973
- const sessionId = payload.properties?.sessionID;
1260
+ const sessionId = payload.properties?.sessionID ?? payload.properties?.sessionId;
974
1261
  if (typeof sessionId === 'string' && sessionId.length > 0) {
975
1262
  return { sessionId, phase: 'idle' };
976
1263
  }
@@ -2072,11 +2359,124 @@ async function main(options = {}) {
2072
2359
  }
2073
2360
  });
2074
2361
 
2075
- app.get('/api/event', async (req, res) => {
2076
- if (!openCodePort) {
2362
+ app.get('/api/global/event', async (req, res) => {
2363
+ if (!openCodeApiPrefixDetected) {
2364
+ try {
2365
+ await detectOpenCodeApiPrefix();
2366
+ } catch {
2367
+ // ignore detection failures
2368
+ }
2369
+ }
2370
+
2371
+ let targetUrl;
2372
+ try {
2373
+ const prefix = openCodeApiPrefixDetected ? openCodeApiPrefix : '';
2374
+ targetUrl = new URL(buildOpenCodeUrl('/global/event', prefix));
2375
+ } catch (error) {
2077
2376
  return res.status(503).json({ error: 'OpenCode service unavailable' });
2078
2377
  }
2079
2378
 
2379
+ const headers = {
2380
+ Accept: 'text/event-stream',
2381
+ 'Cache-Control': 'no-cache',
2382
+ Connection: 'keep-alive'
2383
+ };
2384
+
2385
+ const lastEventId = req.header('Last-Event-ID');
2386
+ if (typeof lastEventId === 'string' && lastEventId.length > 0) {
2387
+ headers['Last-Event-ID'] = lastEventId;
2388
+ }
2389
+
2390
+ const controller = new AbortController();
2391
+ const cleanup = () => {
2392
+ if (!controller.signal.aborted) {
2393
+ controller.abort();
2394
+ }
2395
+ };
2396
+
2397
+ req.on('close', cleanup);
2398
+ req.on('error', cleanup);
2399
+
2400
+ let upstream;
2401
+ try {
2402
+ upstream = await fetch(targetUrl.toString(), {
2403
+ headers,
2404
+ signal: controller.signal,
2405
+ });
2406
+ } catch (error) {
2407
+ return res.status(502).json({ error: 'Failed to connect to OpenCode event stream' });
2408
+ }
2409
+
2410
+ if (!upstream.ok || !upstream.body) {
2411
+ return res.status(502).json({ error: `OpenCode event stream unavailable (${upstream.status})` });
2412
+ }
2413
+
2414
+ res.setHeader('Content-Type', 'text/event-stream');
2415
+ res.setHeader('Cache-Control', 'no-cache');
2416
+ res.setHeader('Connection', 'keep-alive');
2417
+ res.setHeader('X-Accel-Buffering', 'no');
2418
+
2419
+ if (typeof res.flushHeaders === 'function') {
2420
+ res.flushHeaders();
2421
+ }
2422
+
2423
+ const heartbeatInterval = setInterval(() => {
2424
+ writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() });
2425
+ }, 30000);
2426
+
2427
+ const decoder = new TextDecoder();
2428
+ const reader = upstream.body.getReader();
2429
+ let buffer = '';
2430
+
2431
+ const forwardBlock = (block) => {
2432
+ if (!block) return;
2433
+ res.write(`${block}\n\n`);
2434
+ const payload = parseSseDataPayload(block);
2435
+ const activity = deriveSessionActivity(payload);
2436
+ if (activity) {
2437
+ writeSseEvent(res, {
2438
+ type: 'openchamber:session-activity',
2439
+ properties: {
2440
+ sessionId: activity.sessionId,
2441
+ phase: activity.phase,
2442
+ }
2443
+ });
2444
+ }
2445
+ };
2446
+
2447
+ try {
2448
+ while (true) {
2449
+ const { value, done } = await reader.read();
2450
+ if (done) break;
2451
+ buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
2452
+
2453
+ let separatorIndex;
2454
+ while ((separatorIndex = buffer.indexOf('\n\n')) !== -1) {
2455
+ const block = buffer.slice(0, separatorIndex);
2456
+ buffer = buffer.slice(separatorIndex + 2);
2457
+ forwardBlock(block);
2458
+ }
2459
+ }
2460
+
2461
+ if (buffer.trim().length > 0) {
2462
+ forwardBlock(buffer.trim());
2463
+ }
2464
+ } catch (error) {
2465
+ if (!controller.signal.aborted) {
2466
+ console.warn('SSE proxy stream error:', error);
2467
+ }
2468
+ } finally {
2469
+ clearInterval(heartbeatInterval);
2470
+ cleanup();
2471
+ try {
2472
+ res.end();
2473
+ } catch {
2474
+ // ignore
2475
+ }
2476
+ }
2477
+ });
2478
+
2479
+ app.get('/api/event', async (req, res) => {
2080
2480
  if (!openCodeApiPrefixDetected) {
2081
2481
  try {
2082
2482
  await detectOpenCodeApiPrefix();
@@ -2093,11 +2493,13 @@ async function main(options = {}) {
2093
2493
  return res.status(503).json({ error: 'OpenCode service unavailable' });
2094
2494
  }
2095
2495
 
2496
+ const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
2096
2497
  const directoryParam = Array.isArray(req.query.directory)
2097
2498
  ? req.query.directory[0]
2098
2499
  : req.query.directory;
2099
- if (typeof directoryParam === 'string' && directoryParam.trim().length > 0) {
2100
- targetUrl.searchParams.set('directory', directoryParam.trim());
2500
+ const resolvedDirectory = headerDirectory || directoryParam || null;
2501
+ if (typeof resolvedDirectory === 'string' && resolvedDirectory.trim().length > 0) {
2502
+ targetUrl.searchParams.set('directory', resolvedDirectory.trim());
2101
2503
  }
2102
2504
 
2103
2505
  const headers = {
@@ -2144,6 +2546,10 @@ async function main(options = {}) {
2144
2546
  res.flushHeaders();
2145
2547
  }
2146
2548
 
2549
+ const heartbeatInterval = setInterval(() => {
2550
+ writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() });
2551
+ }, 30000);
2552
+
2147
2553
  const decoder = new TextDecoder();
2148
2554
  const reader = upstream.body.getReader();
2149
2555
  let buffer = '';
@@ -2186,6 +2592,7 @@ async function main(options = {}) {
2186
2592
  console.warn('SSE proxy stream error:', error);
2187
2593
  }
2188
2594
  } finally {
2595
+ clearInterval(heartbeatInterval);
2189
2596
  cleanup();
2190
2597
  try {
2191
2598
  res.end();
@@ -2197,7 +2604,7 @@ async function main(options = {}) {
2197
2604
 
2198
2605
  app.get('/api/config/settings', async (_req, res) => {
2199
2606
  try {
2200
- const settings = await readSettingsFromDisk();
2607
+ const settings = await readSettingsFromDiskMigrated();
2201
2608
  res.json(formatSettingsResponse(settings));
2202
2609
  } catch (error) {
2203
2610
  console.error('Failed to load settings:', error);
@@ -2218,6 +2625,7 @@ async function main(options = {}) {
2218
2625
  const {
2219
2626
  getAgentSources,
2220
2627
  getAgentScope,
2628
+ getAgentConfig,
2221
2629
  createAgent,
2222
2630
  updateAgent,
2223
2631
  deleteAgent,
@@ -2230,16 +2638,23 @@ async function main(options = {}) {
2230
2638
  COMMAND_SCOPE
2231
2639
  } = await import('./lib/opencode-config.js');
2232
2640
 
2233
- app.get('/api/config/agents/:name', (req, res) => {
2641
+ app.get('/api/config/agents/:name', async (req, res) => {
2234
2642
  try {
2235
2643
  const agentName = req.params.name;
2236
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2237
- const sources = getAgentSources(agentName, workingDirectory);
2644
+ const { directory, error } = await resolveProjectDirectory(req);
2645
+ if (!directory) {
2646
+ return res.status(400).json({ error });
2647
+ }
2648
+ const sources = getAgentSources(agentName, directory);
2649
+
2650
+ const scope = sources.md.exists
2651
+ ? sources.md.scope
2652
+ : (sources.json.exists ? sources.json.scope : null);
2238
2653
 
2239
2654
  res.json({
2240
2655
  name: agentName,
2241
2656
  sources: sources,
2242
- scope: sources.md.scope,
2657
+ scope,
2243
2658
  isBuiltIn: !sources.md.exists && !sources.json.exists
2244
2659
  });
2245
2660
  } catch (error) {
@@ -2248,17 +2663,36 @@ async function main(options = {}) {
2248
2663
  }
2249
2664
  });
2250
2665
 
2666
+ app.get('/api/config/agents/:name/config', async (req, res) => {
2667
+ try {
2668
+ const agentName = req.params.name;
2669
+ const { directory, error } = await resolveProjectDirectory(req);
2670
+ if (!directory) {
2671
+ return res.status(400).json({ error });
2672
+ }
2673
+
2674
+ const configInfo = getAgentConfig(agentName, directory);
2675
+ res.json(configInfo);
2676
+ } catch (error) {
2677
+ console.error('Failed to get agent config:', error);
2678
+ res.status(500).json({ error: 'Failed to get agent configuration' });
2679
+ }
2680
+ });
2681
+
2251
2682
  app.post('/api/config/agents/:name', async (req, res) => {
2252
2683
  try {
2253
2684
  const agentName = req.params.name;
2254
2685
  const { scope, ...config } = req.body;
2255
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2686
+ const { directory, error } = await resolveProjectDirectory(req);
2687
+ if (!directory) {
2688
+ return res.status(400).json({ error });
2689
+ }
2256
2690
 
2257
2691
  console.log('[Server] Creating agent:', agentName);
2258
2692
  console.log('[Server] Config received:', JSON.stringify(config, null, 2));
2259
- console.log('[Server] Scope:', scope, 'Working directory:', workingDirectory);
2693
+ console.log('[Server] Scope:', scope, 'Working directory:', directory);
2260
2694
 
2261
- createAgent(agentName, config, workingDirectory, scope);
2695
+ createAgent(agentName, config, directory, scope);
2262
2696
  await refreshOpenCodeAfterConfigChange('agent creation', {
2263
2697
  agentName
2264
2698
  });
@@ -2279,13 +2713,16 @@ async function main(options = {}) {
2279
2713
  try {
2280
2714
  const agentName = req.params.name;
2281
2715
  const updates = req.body;
2282
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2716
+ const { directory, error } = await resolveProjectDirectory(req);
2717
+ if (!directory) {
2718
+ return res.status(400).json({ error });
2719
+ }
2283
2720
 
2284
2721
  console.log(`[Server] Updating agent: ${agentName}`);
2285
2722
  console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
2286
- console.log('[Server] Working directory:', workingDirectory);
2723
+ console.log('[Server] Working directory:', directory);
2287
2724
 
2288
- updateAgent(agentName, updates, workingDirectory);
2725
+ updateAgent(agentName, updates, directory);
2289
2726
  await refreshOpenCodeAfterConfigChange('agent update');
2290
2727
 
2291
2728
  console.log(`[Server] Agent ${agentName} updated successfully`);
@@ -2306,9 +2743,12 @@ async function main(options = {}) {
2306
2743
  app.delete('/api/config/agents/:name', async (req, res) => {
2307
2744
  try {
2308
2745
  const agentName = req.params.name;
2309
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2746
+ const { directory, error } = await resolveProjectDirectory(req);
2747
+ if (!directory) {
2748
+ return res.status(400).json({ error });
2749
+ }
2310
2750
 
2311
- deleteAgent(agentName, workingDirectory);
2751
+ deleteAgent(agentName, directory);
2312
2752
  await refreshOpenCodeAfterConfigChange('agent deletion');
2313
2753
 
2314
2754
  res.json({
@@ -2323,16 +2763,23 @@ async function main(options = {}) {
2323
2763
  }
2324
2764
  });
2325
2765
 
2326
- app.get('/api/config/commands/:name', (req, res) => {
2766
+ app.get('/api/config/commands/:name', async (req, res) => {
2327
2767
  try {
2328
2768
  const commandName = req.params.name;
2329
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2330
- const sources = getCommandSources(commandName, workingDirectory);
2769
+ const { directory, error } = await resolveProjectDirectory(req);
2770
+ if (!directory) {
2771
+ return res.status(400).json({ error });
2772
+ }
2773
+ const sources = getCommandSources(commandName, directory);
2774
+
2775
+ const scope = sources.md.exists
2776
+ ? sources.md.scope
2777
+ : (sources.json.exists ? sources.json.scope : null);
2331
2778
 
2332
2779
  res.json({
2333
2780
  name: commandName,
2334
2781
  sources: sources,
2335
- scope: sources.md.scope,
2782
+ scope,
2336
2783
  isBuiltIn: !sources.md.exists && !sources.json.exists
2337
2784
  });
2338
2785
  } catch (error) {
@@ -2345,13 +2792,16 @@ async function main(options = {}) {
2345
2792
  try {
2346
2793
  const commandName = req.params.name;
2347
2794
  const { scope, ...config } = req.body;
2348
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2795
+ const { directory, error } = await resolveProjectDirectory(req);
2796
+ if (!directory) {
2797
+ return res.status(400).json({ error });
2798
+ }
2349
2799
 
2350
2800
  console.log('[Server] Creating command:', commandName);
2351
2801
  console.log('[Server] Config received:', JSON.stringify(config, null, 2));
2352
- console.log('[Server] Scope:', scope, 'Working directory:', workingDirectory);
2802
+ console.log('[Server] Scope:', scope, 'Working directory:', directory);
2353
2803
 
2354
- createCommand(commandName, config, workingDirectory, scope);
2804
+ createCommand(commandName, config, directory, scope);
2355
2805
  await refreshOpenCodeAfterConfigChange('command creation', {
2356
2806
  commandName
2357
2807
  });
@@ -2372,13 +2822,16 @@ async function main(options = {}) {
2372
2822
  try {
2373
2823
  const commandName = req.params.name;
2374
2824
  const updates = req.body;
2375
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2825
+ const { directory, error } = await resolveProjectDirectory(req);
2826
+ if (!directory) {
2827
+ return res.status(400).json({ error });
2828
+ }
2376
2829
 
2377
2830
  console.log(`[Server] Updating command: ${commandName}`);
2378
2831
  console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
2379
- console.log('[Server] Working directory:', workingDirectory);
2832
+ console.log('[Server] Working directory:', directory);
2380
2833
 
2381
- updateCommand(commandName, updates, workingDirectory);
2834
+ updateCommand(commandName, updates, directory);
2382
2835
  await refreshOpenCodeAfterConfigChange('command update');
2383
2836
 
2384
2837
  console.log(`[Server] Command ${commandName} updated successfully`);
@@ -2399,9 +2852,12 @@ async function main(options = {}) {
2399
2852
  app.delete('/api/config/commands/:name', async (req, res) => {
2400
2853
  try {
2401
2854
  const commandName = req.params.name;
2402
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2855
+ const { directory, error } = await resolveProjectDirectory(req);
2856
+ if (!directory) {
2857
+ return res.status(400).json({ error });
2858
+ }
2403
2859
 
2404
- deleteCommand(commandName, workingDirectory);
2860
+ deleteCommand(commandName, directory);
2405
2861
  await refreshOpenCodeAfterConfigChange('command deletion');
2406
2862
 
2407
2863
  res.json({
@@ -2432,14 +2888,17 @@ async function main(options = {}) {
2432
2888
  } = await import('./lib/opencode-config.js');
2433
2889
 
2434
2890
  // List all discovered skills
2435
- app.get('/api/config/skills', (req, res) => {
2891
+ app.get('/api/config/skills', async (req, res) => {
2436
2892
  try {
2437
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2438
- const skills = discoverSkills(workingDirectory);
2893
+ const { directory, error } = await resolveProjectDirectory(req);
2894
+ if (!directory) {
2895
+ return res.status(400).json({ error });
2896
+ }
2897
+ const skills = discoverSkills(directory);
2439
2898
 
2440
2899
  // Enrich with full sources info
2441
2900
  const enrichedSkills = skills.map(skill => {
2442
- const sources = getSkillSources(skill.name, workingDirectory);
2901
+ const sources = getSkillSources(skill.name, directory);
2443
2902
  return {
2444
2903
  ...skill,
2445
2904
  sources
@@ -2489,7 +2948,10 @@ async function main(options = {}) {
2489
2948
 
2490
2949
  app.get('/api/config/skills/catalog', async (req, res) => {
2491
2950
  try {
2492
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2951
+ const { directory, error } = await resolveProjectDirectory(req);
2952
+ if (!directory) {
2953
+ return res.status(400).json({ error });
2954
+ }
2493
2955
  const refresh = String(req.query.refresh || '').toLowerCase() === 'true';
2494
2956
 
2495
2957
  const curatedSources = getCuratedSkillsSources();
@@ -2507,7 +2969,7 @@ async function main(options = {}) {
2507
2969
 
2508
2970
  const sources = [...curatedSources, ...customSources];
2509
2971
 
2510
- const discovered = discoverSkills(workingDirectory);
2972
+ const discovered = discoverSkills(directory);
2511
2973
  const installedByName = new Map(discovered.map((s) => [s.name, s]));
2512
2974
 
2513
2975
  const itemsBySource = {};
@@ -2611,12 +3073,16 @@ async function main(options = {}) {
2611
3073
  conflictDecisions,
2612
3074
  } = req.body || {};
2613
3075
 
2614
- const workingDirectory = req.query.directory;
2615
- if (scope === 'project' && !workingDirectory) {
2616
- return res.status(400).json({
2617
- ok: false,
2618
- error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' },
2619
- });
3076
+ let workingDirectory = null;
3077
+ if (scope === 'project') {
3078
+ const resolved = await resolveProjectDirectory(req);
3079
+ if (!resolved.directory) {
3080
+ return res.status(400).json({
3081
+ ok: false,
3082
+ error: { kind: 'invalidSource', message: resolved.error || 'Project installs require a directory parameter' },
3083
+ });
3084
+ }
3085
+ workingDirectory = resolved.directory;
2620
3086
  }
2621
3087
  const identity = resolveGitIdentity(gitIdentityId);
2622
3088
 
@@ -2658,11 +3124,14 @@ async function main(options = {}) {
2658
3124
  });
2659
3125
 
2660
3126
  // Get single skill sources
2661
- app.get('/api/config/skills/:name', (req, res) => {
3127
+ app.get('/api/config/skills/:name', async (req, res) => {
2662
3128
  try {
2663
3129
  const skillName = req.params.name;
2664
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2665
- const sources = getSkillSources(skillName, workingDirectory);
3130
+ const { directory, error } = await resolveProjectDirectory(req);
3131
+ if (!directory) {
3132
+ return res.status(400).json({ error });
3133
+ }
3134
+ const sources = getSkillSources(skillName, directory);
2666
3135
 
2667
3136
  res.json({
2668
3137
  name: skillName,
@@ -2678,13 +3147,16 @@ async function main(options = {}) {
2678
3147
  });
2679
3148
 
2680
3149
  // Get skill supporting file content
2681
- app.get('/api/config/skills/:name/files/*filePath', (req, res) => {
3150
+ app.get('/api/config/skills/:name/files/*filePath', async (req, res) => {
2682
3151
  try {
2683
3152
  const skillName = req.params.name;
2684
3153
  const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
2685
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
3154
+ const { directory, error } = await resolveProjectDirectory(req);
3155
+ if (!directory) {
3156
+ return res.status(400).json({ error });
3157
+ }
2686
3158
 
2687
- const sources = getSkillSources(skillName, workingDirectory);
3159
+ const sources = getSkillSources(skillName, directory);
2688
3160
  if (!sources.md.exists || !sources.md.dir) {
2689
3161
  return res.status(404).json({ error: 'Skill not found' });
2690
3162
  }
@@ -2706,12 +3178,15 @@ async function main(options = {}) {
2706
3178
  try {
2707
3179
  const skillName = req.params.name;
2708
3180
  const { scope, ...config } = req.body;
2709
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
3181
+ const { directory, error } = await resolveProjectDirectory(req);
3182
+ if (!directory) {
3183
+ return res.status(400).json({ error });
3184
+ }
2710
3185
 
2711
3186
  console.log('[Server] Creating skill:', skillName);
2712
- console.log('[Server] Scope:', scope, 'Working directory:', workingDirectory);
3187
+ console.log('[Server] Scope:', scope, 'Working directory:', directory);
2713
3188
 
2714
- createSkill(skillName, config, workingDirectory, scope);
3189
+ createSkill(skillName, config, directory, scope);
2715
3190
  // Skills are just files - OpenCode loads them on-demand, no restart needed
2716
3191
 
2717
3192
  res.json({
@@ -2730,12 +3205,15 @@ async function main(options = {}) {
2730
3205
  try {
2731
3206
  const skillName = req.params.name;
2732
3207
  const updates = req.body;
2733
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
3208
+ const { directory, error } = await resolveProjectDirectory(req);
3209
+ if (!directory) {
3210
+ return res.status(400).json({ error });
3211
+ }
2734
3212
 
2735
3213
  console.log(`[Server] Updating skill: ${skillName}`);
2736
- console.log('[Server] Working directory:', workingDirectory);
3214
+ console.log('[Server] Working directory:', directory);
2737
3215
 
2738
- updateSkill(skillName, updates, workingDirectory);
3216
+ updateSkill(skillName, updates, directory);
2739
3217
  // Skills are just files - OpenCode loads them on-demand, no restart needed
2740
3218
 
2741
3219
  res.json({
@@ -2755,9 +3233,12 @@ async function main(options = {}) {
2755
3233
  const skillName = req.params.name;
2756
3234
  const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
2757
3235
  const { content } = req.body;
2758
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
3236
+ const { directory, error } = await resolveProjectDirectory(req);
3237
+ if (!directory) {
3238
+ return res.status(400).json({ error });
3239
+ }
2759
3240
 
2760
- const sources = getSkillSources(skillName, workingDirectory);
3241
+ const sources = getSkillSources(skillName, directory);
2761
3242
  if (!sources.md.exists || !sources.md.dir) {
2762
3243
  return res.status(404).json({ error: 'Skill not found' });
2763
3244
  }
@@ -2779,9 +3260,12 @@ async function main(options = {}) {
2779
3260
  try {
2780
3261
  const skillName = req.params.name;
2781
3262
  const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
2782
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
3263
+ const { directory, error } = await resolveProjectDirectory(req);
3264
+ if (!directory) {
3265
+ return res.status(400).json({ error });
3266
+ }
2783
3267
 
2784
- const sources = getSkillSources(skillName, workingDirectory);
3268
+ const sources = getSkillSources(skillName, directory);
2785
3269
  if (!sources.md.exists || !sources.md.dir) {
2786
3270
  return res.status(404).json({ error: 'Skill not found' });
2787
3271
  }
@@ -2802,9 +3286,12 @@ async function main(options = {}) {
2802
3286
  app.delete('/api/config/skills/:name', async (req, res) => {
2803
3287
  try {
2804
3288
  const skillName = req.params.name;
2805
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
3289
+ const { directory, error } = await resolveProjectDirectory(req);
3290
+ if (!directory) {
3291
+ return res.status(400).json({ error });
3292
+ }
2806
3293
 
2807
- deleteSkill(skillName, workingDirectory);
3294
+ deleteSkill(skillName, directory);
2808
3295
  // Skills are just files - OpenCode loads them on-demand, no restart needed
2809
3296
 
2810
3297
  res.json({
@@ -3331,6 +3818,30 @@ async function main(options = {}) {
3331
3818
  }
3332
3819
  });
3333
3820
 
3821
+
3822
+ app.put('/api/git/branches/rename', async (req, res) => {
3823
+ const { renameBranch } = await getGitLibraries();
3824
+ try {
3825
+ const directory = req.query.directory;
3826
+ if (!directory) {
3827
+ return res.status(400).json({ error: 'directory parameter is required' });
3828
+ }
3829
+
3830
+ const { oldName, newName } = req.body;
3831
+ if (!oldName) {
3832
+ return res.status(400).json({ error: 'oldName is required' });
3833
+ }
3834
+ if (!newName) {
3835
+ return res.status(400).json({ error: 'newName is required' });
3836
+ }
3837
+
3838
+ const result = await renameBranch(directory, oldName, newName);
3839
+ res.json(result);
3840
+ } catch (error) {
3841
+ console.error('Failed to rename branch:', error);
3842
+ res.status(500).json({ error: error.message || 'Failed to rename branch' });
3843
+ }
3844
+ });
3334
3845
  app.delete('/api/git/remote-branches', async (req, res) => {
3335
3846
  const { deleteRemoteBranch } = await getGitLibraries();
3336
3847
  try {
@@ -3543,47 +4054,326 @@ async function main(options = {}) {
3543
4054
  }
3544
4055
  });
3545
4056
 
3546
- app.post('/api/opencode/directory', async (req, res) => {
4057
+ // Read file contents
4058
+ app.get('/api/fs/read', async (req, res) => {
4059
+ const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
4060
+ if (!filePath) {
4061
+ return res.status(400).json({ error: 'Path is required' });
4062
+ }
4063
+
3547
4064
  try {
3548
- const requestedPath = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
3549
- if (!requestedPath) {
3550
- return res.status(400).json({ error: 'Path is required' });
4065
+ const resolvedPath = path.resolve(normalizeDirectoryPath(filePath));
4066
+ if (resolvedPath.includes('..')) {
4067
+ return res.status(400).json({ error: 'Invalid path: path traversal not allowed' });
4068
+ }
4069
+
4070
+ const stats = await fsPromises.stat(resolvedPath);
4071
+ if (!stats.isFile()) {
4072
+ return res.status(400).json({ error: 'Specified path is not a file' });
4073
+ }
4074
+
4075
+ const content = await fsPromises.readFile(resolvedPath, 'utf8');
4076
+ res.type('text/plain').send(content);
4077
+ } catch (error) {
4078
+ const err = error;
4079
+ if (err && typeof err === 'object' && err.code === 'ENOENT') {
4080
+ return res.status(404).json({ error: 'File not found' });
4081
+ }
4082
+ if (err && typeof err === 'object' && err.code === 'EACCES') {
4083
+ return res.status(403).json({ error: 'Access to file denied' });
4084
+ }
4085
+ console.error('Failed to read file:', error);
4086
+ res.status(500).json({ error: (error && error.message) || 'Failed to read file' });
4087
+ }
4088
+ });
4089
+
4090
+ // Write file contents
4091
+ app.post('/api/fs/write', async (req, res) => {
4092
+ const { path: filePath, content } = req.body || {};
4093
+ if (!filePath || typeof filePath !== 'string') {
4094
+ return res.status(400).json({ error: 'Path is required' });
4095
+ }
4096
+ if (typeof content !== 'string') {
4097
+ return res.status(400).json({ error: 'Content is required' });
4098
+ }
4099
+
4100
+ try {
4101
+ const resolvedPath = path.resolve(normalizeDirectoryPath(filePath));
4102
+ if (resolvedPath.includes('..')) {
4103
+ return res.status(400).json({ error: 'Invalid path: path traversal not allowed' });
4104
+ }
4105
+
4106
+ // Ensure parent directory exists
4107
+ await fsPromises.mkdir(path.dirname(resolvedPath), { recursive: true });
4108
+ await fsPromises.writeFile(resolvedPath, content, 'utf8');
4109
+ res.json({ success: true, path: resolvedPath });
4110
+ } catch (error) {
4111
+ const err = error;
4112
+ if (err && typeof err === 'object' && err.code === 'EACCES') {
4113
+ return res.status(403).json({ error: 'Access denied' });
4114
+ }
4115
+ console.error('Failed to write file:', error);
4116
+ res.status(500).json({ error: (error && error.message) || 'Failed to write file' });
4117
+ }
4118
+ });
4119
+
4120
+ // Execute shell commands in a directory (for worktree setup)
4121
+ // NOTE: This route supports background execution to avoid tying up browser connections.
4122
+ const execJobs = new Map();
4123
+ const EXEC_JOB_TTL_MS = 30 * 60 * 1000;
4124
+ const COMMAND_TIMEOUT_MS = 60000;
4125
+
4126
+ const pruneExecJobs = () => {
4127
+ const now = Date.now();
4128
+ for (const [jobId, job] of execJobs.entries()) {
4129
+ if (!job || typeof job !== 'object') {
4130
+ execJobs.delete(jobId);
4131
+ continue;
4132
+ }
4133
+ const updatedAt = typeof job.updatedAt === 'number' ? job.updatedAt : 0;
4134
+ if (updatedAt && now - updatedAt > EXEC_JOB_TTL_MS) {
4135
+ execJobs.delete(jobId);
4136
+ }
4137
+ }
4138
+ };
4139
+
4140
+ const runCommandInDirectory = (shell, shellFlag, command, resolvedCwd) => {
4141
+ return new Promise((resolve) => {
4142
+ let stdout = '';
4143
+ let stderr = '';
4144
+ let timedOut = false;
4145
+
4146
+ const child = spawn(shell, [shellFlag, command], {
4147
+ cwd: resolvedCwd,
4148
+ env: process.env,
4149
+ stdio: ['ignore', 'pipe', 'pipe'],
4150
+ });
4151
+
4152
+ const timeout = setTimeout(() => {
4153
+ timedOut = true;
4154
+ try {
4155
+ child.kill('SIGKILL');
4156
+ } catch {
4157
+ // ignore
4158
+ }
4159
+ }, COMMAND_TIMEOUT_MS);
4160
+
4161
+ child.stdout?.on('data', (chunk) => {
4162
+ stdout += chunk.toString();
4163
+ });
4164
+
4165
+ child.stderr?.on('data', (chunk) => {
4166
+ stderr += chunk.toString();
4167
+ });
4168
+
4169
+ child.on('error', (error) => {
4170
+ clearTimeout(timeout);
4171
+ resolve({
4172
+ command,
4173
+ success: false,
4174
+ exitCode: undefined,
4175
+ stdout: stdout.trim(),
4176
+ stderr: stderr.trim(),
4177
+ error: (error && error.message) || 'Command execution failed',
4178
+ });
4179
+ });
4180
+
4181
+ child.on('close', (code, signal) => {
4182
+ clearTimeout(timeout);
4183
+ const exitCode = typeof code === 'number' ? code : undefined;
4184
+ const base = {
4185
+ command,
4186
+ success: exitCode === 0 && !timedOut,
4187
+ exitCode,
4188
+ stdout: stdout.trim(),
4189
+ stderr: stderr.trim(),
4190
+ };
4191
+
4192
+ if (timedOut) {
4193
+ resolve({
4194
+ ...base,
4195
+ success: false,
4196
+ error: `Command timed out after ${COMMAND_TIMEOUT_MS}ms` + (signal ? ` (${signal})` : ''),
4197
+ });
4198
+ return;
4199
+ }
4200
+
4201
+ resolve(base);
4202
+ });
4203
+ });
4204
+ };
4205
+
4206
+ const runExecJob = async (job) => {
4207
+ job.status = 'running';
4208
+ job.updatedAt = Date.now();
4209
+
4210
+ const results = [];
4211
+
4212
+ for (const command of job.commands) {
4213
+ if (typeof command !== 'string' || !command.trim()) {
4214
+ results.push({ command, success: false, error: 'Invalid command' });
4215
+ continue;
3551
4216
  }
3552
4217
 
3553
- const resolvedPath = path.resolve(normalizeDirectoryPath(requestedPath));
3554
- let stats;
3555
4218
  try {
3556
- stats = await fsPromises.stat(resolvedPath);
4219
+ const result = await runCommandInDirectory(job.shell, job.shellFlag, command, job.resolvedCwd);
4220
+ results.push(result);
3557
4221
  } catch (error) {
3558
- const err = error;
3559
- if (err && typeof err === 'object' && 'code' in err) {
3560
- if (err.code === 'ENOENT') {
3561
- return res.status(404).json({ error: 'Directory not found' });
3562
- }
3563
- if (err.code === 'EACCES') {
3564
- return res.status(403).json({ error: 'Access to directory denied' });
3565
- }
3566
- }
3567
- throw error;
4222
+ results.push({
4223
+ command,
4224
+ success: false,
4225
+ error: (error && error.message) || 'Command execution failed',
4226
+ });
3568
4227
  }
3569
4228
 
4229
+ job.results = results;
4230
+ job.updatedAt = Date.now();
4231
+ }
4232
+
4233
+ job.results = results;
4234
+ job.success = results.every((r) => r.success);
4235
+ job.status = 'done';
4236
+ job.finishedAt = Date.now();
4237
+ job.updatedAt = Date.now();
4238
+ };
4239
+
4240
+ app.post('/api/fs/exec', async (req, res) => {
4241
+ const { commands, cwd, background } = req.body || {};
4242
+ if (!Array.isArray(commands) || commands.length === 0) {
4243
+ return res.status(400).json({ error: 'Commands array is required' });
4244
+ }
4245
+ if (!cwd || typeof cwd !== 'string') {
4246
+ return res.status(400).json({ error: 'Working directory (cwd) is required' });
4247
+ }
4248
+
4249
+ pruneExecJobs();
4250
+
4251
+ try {
4252
+ const resolvedCwd = path.resolve(normalizeDirectoryPath(cwd));
4253
+ const stats = await fsPromises.stat(resolvedCwd);
3570
4254
  if (!stats.isDirectory()) {
3571
- return res.status(400).json({ error: 'Specified path is not a directory' });
4255
+ return res.status(400).json({ error: 'Specified cwd is not a directory' });
3572
4256
  }
3573
4257
 
3574
- if (openCodeWorkingDirectory === resolvedPath && openCodeProcess && openCodeProcess.exitCode === null) {
3575
- return res.json({ success: true, restarted: false, path: resolvedPath });
4258
+ const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh');
4259
+ const shellFlag = process.platform === 'win32' ? '/c' : '-c';
4260
+
4261
+ const jobId = crypto.randomUUID();
4262
+ const job = {
4263
+ jobId,
4264
+ status: 'queued',
4265
+ success: null,
4266
+ commands,
4267
+ resolvedCwd,
4268
+ shell,
4269
+ shellFlag,
4270
+ results: [],
4271
+ startedAt: Date.now(),
4272
+ finishedAt: null,
4273
+ updatedAt: Date.now(),
4274
+ };
4275
+
4276
+ execJobs.set(jobId, job);
4277
+
4278
+ const isBackground = background === true;
4279
+ if (isBackground) {
4280
+ void runExecJob(job).catch((error) => {
4281
+ job.status = 'done';
4282
+ job.success = false;
4283
+ job.results = Array.isArray(job.results) ? job.results : [];
4284
+ job.results.push({
4285
+ command: '',
4286
+ success: false,
4287
+ error: (error && error.message) || 'Command execution failed',
4288
+ });
4289
+ job.finishedAt = Date.now();
4290
+ job.updatedAt = Date.now();
4291
+ });
4292
+
4293
+ return res.status(202).json({
4294
+ jobId,
4295
+ status: 'running',
4296
+ });
3576
4297
  }
3577
4298
 
3578
- openCodeWorkingDirectory = resolvedPath;
3579
- syncToHmrState();
4299
+ await runExecJob(job);
4300
+ res.json({
4301
+ jobId,
4302
+ status: job.status,
4303
+ success: job.success === true,
4304
+ results: job.results,
4305
+ });
4306
+ } catch (error) {
4307
+ console.error('Failed to execute commands:', error);
4308
+ res.status(500).json({ error: (error && error.message) || 'Failed to execute commands' });
4309
+ }
4310
+ });
4311
+
4312
+ app.get('/api/fs/exec/:jobId', (req, res) => {
4313
+ const jobId = typeof req.params?.jobId === 'string' ? req.params.jobId : '';
4314
+ if (!jobId) {
4315
+ return res.status(400).json({ error: 'Job id is required' });
4316
+ }
4317
+
4318
+ pruneExecJobs();
4319
+
4320
+ const job = execJobs.get(jobId);
4321
+ if (!job) {
4322
+ return res.status(404).json({ error: 'Job not found' });
4323
+ }
4324
+
4325
+ job.updatedAt = Date.now();
4326
+
4327
+ return res.json({
4328
+ jobId: job.jobId,
4329
+ status: job.status,
4330
+ success: job.success === true,
4331
+ results: Array.isArray(job.results) ? job.results : [],
4332
+ });
4333
+ });
4334
+
4335
+ app.post('/api/opencode/directory', async (req, res) => {
4336
+ try {
4337
+ const requestedPath = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
4338
+ if (!requestedPath) {
4339
+ return res.status(400).json({ error: 'Path is required' });
4340
+ }
3580
4341
 
3581
- await refreshOpenCodeAfterConfigChange('directory change');
4342
+ const validated = await validateDirectoryPath(requestedPath);
4343
+ if (!validated.ok) {
4344
+ return res.status(400).json({ error: validated.error });
4345
+ }
4346
+
4347
+ const resolvedPath = validated.directory;
4348
+ const currentSettings = await readSettingsFromDisk();
4349
+ const existingProjects = sanitizeProjects(currentSettings.projects) || [];
4350
+ const existing = existingProjects.find((project) => project.path === resolvedPath) || null;
4351
+
4352
+ const nextProjects = existing
4353
+ ? existingProjects
4354
+ : [
4355
+ ...existingProjects,
4356
+ {
4357
+ id: crypto.randomUUID(),
4358
+ path: resolvedPath,
4359
+ addedAt: Date.now(),
4360
+ lastOpenedAt: Date.now(),
4361
+ },
4362
+ ];
4363
+
4364
+ const activeProjectId = existing ? existing.id : nextProjects[nextProjects.length - 1].id;
4365
+
4366
+ const updated = await persistSettings({
4367
+ projects: nextProjects,
4368
+ activeProjectId,
4369
+ lastDirectory: resolvedPath,
4370
+ });
3582
4371
 
3583
4372
  res.json({
3584
4373
  success: true,
3585
- restarted: true,
3586
- path: resolvedPath
4374
+ restarted: false,
4375
+ path: resolvedPath,
4376
+ settings: updated,
3587
4377
  });
3588
4378
  } catch (error) {
3589
4379
  console.error('Failed to update OpenCode working directory:', error);