@openchamber/web 1.4.3 → 1.4.5

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);
@@ -449,15 +590,24 @@ const sanitizeSettingsUpdate = (payload) => {
449
590
  result.typographySizes = typography;
450
591
  }
451
592
 
452
- if (typeof candidate.defaultModel === 'string' && candidate.defaultModel.length > 0) {
453
- result.defaultModel = candidate.defaultModel;
593
+ if (typeof candidate.defaultModel === 'string') {
594
+ const trimmed = candidate.defaultModel.trim();
595
+ result.defaultModel = trimmed.length > 0 ? trimmed : undefined;
596
+ }
597
+ if (typeof candidate.defaultVariant === 'string') {
598
+ const trimmed = candidate.defaultVariant.trim();
599
+ result.defaultVariant = trimmed.length > 0 ? trimmed : undefined;
454
600
  }
455
- if (typeof candidate.defaultAgent === 'string' && candidate.defaultAgent.length > 0) {
456
- result.defaultAgent = candidate.defaultAgent;
601
+ if (typeof candidate.defaultAgent === 'string') {
602
+ const trimmed = candidate.defaultAgent.trim();
603
+ result.defaultAgent = trimmed.length > 0 ? trimmed : undefined;
457
604
  }
458
605
  if (typeof candidate.queueModeEnabled === 'boolean') {
459
606
  result.queueModeEnabled = candidate.queueModeEnabled;
460
607
  }
608
+ if (typeof candidate.autoCreateWorktree === 'boolean') {
609
+ result.autoCreateWorktree = candidate.autoCreateWorktree;
610
+ }
461
611
 
462
612
  const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
463
613
  if (skillCatalogs) {
@@ -481,6 +631,16 @@ const mergePersistedSettings = (current, changes) => {
481
631
  if (typeof changes.homeDirectory === 'string' && changes.homeDirectory.length > 0) {
482
632
  additionalApproved.push(changes.homeDirectory);
483
633
  }
634
+ const projectEntries = Array.isArray(changes.projects)
635
+ ? changes.projects
636
+ : Array.isArray(current.projects)
637
+ ? current.projects
638
+ : [];
639
+ projectEntries.forEach((project) => {
640
+ if (project && typeof project.path === 'string' && project.path.length > 0) {
641
+ additionalApproved.push(project.path);
642
+ }
643
+ });
484
644
  const approvedSource = [...baseApproved, ...additionalApproved];
485
645
 
486
646
  const baseBookmarks = Array.isArray(changes.securityScopedBookmarks)
@@ -535,10 +695,124 @@ const formatSettingsResponse = (settings) => {
535
695
  };
536
696
  };
537
697
 
698
+ const validateProjectEntries = async (projects) => {
699
+ if (!Array.isArray(projects)) {
700
+ return [];
701
+ }
702
+
703
+ const results = [];
704
+ for (const project of projects) {
705
+ if (!project || typeof project.path !== 'string' || project.path.length === 0) {
706
+ continue;
707
+ }
708
+ try {
709
+ const stats = await fsPromises.stat(project.path);
710
+ if (!stats.isDirectory()) {
711
+ continue;
712
+ }
713
+ results.push(project);
714
+ } catch (error) {
715
+ const err = error;
716
+ if (err && typeof err === 'object' && err.code === 'ENOENT') {
717
+ continue;
718
+ }
719
+ continue;
720
+ }
721
+ }
722
+
723
+ return results;
724
+ };
725
+
726
+ const migrateSettingsFromLegacyLastDirectory = async (current) => {
727
+ const settings = current && typeof current === 'object' ? current : {};
728
+ const now = Date.now();
729
+
730
+ const sanitizedProjects = sanitizeProjects(settings.projects) || [];
731
+ let nextProjects = sanitizedProjects;
732
+ let nextActiveProjectId =
733
+ typeof settings.activeProjectId === 'string' ? settings.activeProjectId : undefined;
734
+
735
+ let changed = false;
736
+
737
+ if (nextProjects.length === 0) {
738
+ const legacy = typeof settings.lastDirectory === 'string' ? settings.lastDirectory.trim() : '';
739
+ const candidate = legacy ? resolveDirectoryCandidate(legacy) : null;
740
+
741
+ if (candidate) {
742
+ try {
743
+ const stats = await fsPromises.stat(candidate);
744
+ if (stats.isDirectory()) {
745
+ const id = crypto.randomUUID();
746
+ nextProjects = [
747
+ {
748
+ id,
749
+ path: candidate,
750
+ addedAt: now,
751
+ lastOpenedAt: now,
752
+ },
753
+ ];
754
+ nextActiveProjectId = id;
755
+ changed = true;
756
+ }
757
+ } catch {
758
+ // ignore invalid lastDirectory
759
+ }
760
+ }
761
+ }
762
+
763
+ if (nextProjects.length > 0) {
764
+ const active = nextProjects.find((project) => project.id === nextActiveProjectId) || null;
765
+ if (!active) {
766
+ nextActiveProjectId = nextProjects[0].id;
767
+ changed = true;
768
+ }
769
+ } else if (nextActiveProjectId) {
770
+ nextActiveProjectId = undefined;
771
+ changed = true;
772
+ }
773
+
774
+ if (!changed) {
775
+ return { settings, changed: false };
776
+ }
777
+
778
+ const merged = mergePersistedSettings(settings, {
779
+ ...settings,
780
+ projects: nextProjects,
781
+ ...(nextActiveProjectId ? { activeProjectId: nextActiveProjectId } : { activeProjectId: undefined }),
782
+ });
783
+
784
+ return { settings: merged, changed: true };
785
+ };
786
+
787
+ const readSettingsFromDiskMigrated = async () => {
788
+ const current = await readSettingsFromDisk();
789
+ const { settings, changed } = await migrateSettingsFromLegacyLastDirectory(current);
790
+ if (changed) {
791
+ await writeSettingsToDisk(settings);
792
+ }
793
+ return settings;
794
+ };
795
+
538
796
  const persistSettings = async (changes) => {
539
797
  const current = await readSettingsFromDisk();
540
798
  const sanitized = sanitizeSettingsUpdate(changes);
541
- const next = mergePersistedSettings(current, sanitized);
799
+ let next = mergePersistedSettings(current, sanitized);
800
+
801
+ if (Array.isArray(next.projects)) {
802
+ const validated = await validateProjectEntries(next.projects);
803
+ next = { ...next, projects: validated };
804
+ }
805
+
806
+ if (Array.isArray(next.projects) && next.projects.length > 0) {
807
+ const activeId = typeof next.activeProjectId === 'string' ? next.activeProjectId : '';
808
+ const active = next.projects.find((project) => project.id === activeId) || null;
809
+ if (!active) {
810
+ next = { ...next, activeProjectId: next.projects[0].id };
811
+ }
812
+ } else if (next.activeProjectId) {
813
+ next = { ...next, activeProjectId: undefined };
814
+ }
815
+
542
816
  await writeSettingsToDisk(next);
543
817
  return formatSettingsResponse(next);
544
818
  };
@@ -551,7 +825,7 @@ const getHmrState = () => {
551
825
  globalThis[HMR_STATE_KEY] = {
552
826
  openCodeProcess: null,
553
827
  openCodePort: null,
554
- openCodeWorkingDirectory: process.cwd(),
828
+ openCodeWorkingDirectory: os.homedir(),
555
829
  isShuttingDown: false,
556
830
  signalsAttached: false,
557
831
  };
@@ -715,18 +989,21 @@ function resolveBinaryFromPath(binaryName, searchPath) {
715
989
  }
716
990
 
717
991
  function getOpencodeSpawnConfig() {
992
+ const envPath = buildAugmentedPath();
993
+ const resolvedEnv = { ...process.env, PATH: envPath };
994
+
718
995
  if (OPENCODE_BINARY_ENV) {
719
- const explicit = resolveBinaryFromPath(OPENCODE_BINARY_ENV, process.env.PATH);
996
+ const explicit = resolveBinaryFromPath(OPENCODE_BINARY_ENV, envPath);
720
997
  if (explicit) {
721
998
  console.log(`Using OpenCode binary from OPENCODE_BINARY: ${explicit}`);
722
- return { command: explicit, env: undefined };
999
+ return { command: explicit, env: resolvedEnv };
723
1000
  }
724
1001
  console.warn(
725
1002
  `OPENCODE_BINARY path "${OPENCODE_BINARY_ENV}" not found. Falling back to search.`
726
1003
  );
727
1004
  }
728
1005
 
729
- return { command: 'opencode', env: undefined };
1006
+ return { command: 'opencode', env: resolvedEnv };
730
1007
  }
731
1008
 
732
1009
  const ENV_CONFIGURED_OPENCODE_PORT = (() => {
@@ -937,7 +1214,16 @@ function parseSseDataPayload(block) {
937
1214
  }
938
1215
 
939
1216
  try {
940
- return JSON.parse(payloadText);
1217
+ const parsed = JSON.parse(payloadText);
1218
+ if (
1219
+ parsed &&
1220
+ typeof parsed === 'object' &&
1221
+ typeof parsed.payload === 'object' &&
1222
+ parsed.payload !== null
1223
+ ) {
1224
+ return parsed.payload;
1225
+ }
1226
+ return parsed;
941
1227
  } catch {
942
1228
  return null;
943
1229
  }
@@ -950,7 +1236,7 @@ function deriveSessionActivity(payload) {
950
1236
 
951
1237
  if (payload.type === 'session.status') {
952
1238
  const status = payload.properties?.status;
953
- const sessionId = payload.properties?.sessionID;
1239
+ const sessionId = payload.properties?.sessionID ?? payload.properties?.sessionId;
954
1240
  const statusType = status?.type;
955
1241
 
956
1242
  if (typeof sessionId === 'string' && sessionId.length > 0 && typeof statusType === 'string') {
@@ -961,7 +1247,17 @@ function deriveSessionActivity(payload) {
961
1247
 
962
1248
  if (payload.type === 'message.updated') {
963
1249
  const info = payload.properties?.info;
964
- const sessionId = info?.sessionID;
1250
+ const sessionId = info?.sessionID ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.sessionId;
1251
+ const role = info?.role;
1252
+ const finish = info?.finish;
1253
+ if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
1254
+ return { sessionId, phase: 'cooldown' };
1255
+ }
1256
+ }
1257
+
1258
+ if (payload.type === 'message.part.updated') {
1259
+ const info = payload.properties?.info;
1260
+ const sessionId = info?.sessionID ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.sessionId;
965
1261
  const role = info?.role;
966
1262
  const finish = info?.finish;
967
1263
  if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
@@ -970,7 +1266,7 @@ function deriveSessionActivity(payload) {
970
1266
  }
971
1267
 
972
1268
  if (payload.type === 'session.idle') {
973
- const sessionId = payload.properties?.sessionID;
1269
+ const sessionId = payload.properties?.sessionID ?? payload.properties?.sessionId;
974
1270
  if (typeof sessionId === 'string' && sessionId.length > 0) {
975
1271
  return { sessionId, phase: 'idle' };
976
1272
  }
@@ -2072,11 +2368,124 @@ async function main(options = {}) {
2072
2368
  }
2073
2369
  });
2074
2370
 
2075
- app.get('/api/event', async (req, res) => {
2076
- if (!openCodePort) {
2371
+ app.get('/api/global/event', async (req, res) => {
2372
+ if (!openCodeApiPrefixDetected) {
2373
+ try {
2374
+ await detectOpenCodeApiPrefix();
2375
+ } catch {
2376
+ // ignore detection failures
2377
+ }
2378
+ }
2379
+
2380
+ let targetUrl;
2381
+ try {
2382
+ const prefix = openCodeApiPrefixDetected ? openCodeApiPrefix : '';
2383
+ targetUrl = new URL(buildOpenCodeUrl('/global/event', prefix));
2384
+ } catch (error) {
2077
2385
  return res.status(503).json({ error: 'OpenCode service unavailable' });
2078
2386
  }
2079
2387
 
2388
+ const headers = {
2389
+ Accept: 'text/event-stream',
2390
+ 'Cache-Control': 'no-cache',
2391
+ Connection: 'keep-alive'
2392
+ };
2393
+
2394
+ const lastEventId = req.header('Last-Event-ID');
2395
+ if (typeof lastEventId === 'string' && lastEventId.length > 0) {
2396
+ headers['Last-Event-ID'] = lastEventId;
2397
+ }
2398
+
2399
+ const controller = new AbortController();
2400
+ const cleanup = () => {
2401
+ if (!controller.signal.aborted) {
2402
+ controller.abort();
2403
+ }
2404
+ };
2405
+
2406
+ req.on('close', cleanup);
2407
+ req.on('error', cleanup);
2408
+
2409
+ let upstream;
2410
+ try {
2411
+ upstream = await fetch(targetUrl.toString(), {
2412
+ headers,
2413
+ signal: controller.signal,
2414
+ });
2415
+ } catch (error) {
2416
+ return res.status(502).json({ error: 'Failed to connect to OpenCode event stream' });
2417
+ }
2418
+
2419
+ if (!upstream.ok || !upstream.body) {
2420
+ return res.status(502).json({ error: `OpenCode event stream unavailable (${upstream.status})` });
2421
+ }
2422
+
2423
+ res.setHeader('Content-Type', 'text/event-stream');
2424
+ res.setHeader('Cache-Control', 'no-cache');
2425
+ res.setHeader('Connection', 'keep-alive');
2426
+ res.setHeader('X-Accel-Buffering', 'no');
2427
+
2428
+ if (typeof res.flushHeaders === 'function') {
2429
+ res.flushHeaders();
2430
+ }
2431
+
2432
+ const heartbeatInterval = setInterval(() => {
2433
+ writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() });
2434
+ }, 30000);
2435
+
2436
+ const decoder = new TextDecoder();
2437
+ const reader = upstream.body.getReader();
2438
+ let buffer = '';
2439
+
2440
+ const forwardBlock = (block) => {
2441
+ if (!block) return;
2442
+ res.write(`${block}\n\n`);
2443
+ const payload = parseSseDataPayload(block);
2444
+ const activity = deriveSessionActivity(payload);
2445
+ if (activity) {
2446
+ writeSseEvent(res, {
2447
+ type: 'openchamber:session-activity',
2448
+ properties: {
2449
+ sessionId: activity.sessionId,
2450
+ phase: activity.phase,
2451
+ }
2452
+ });
2453
+ }
2454
+ };
2455
+
2456
+ try {
2457
+ while (true) {
2458
+ const { value, done } = await reader.read();
2459
+ if (done) break;
2460
+ buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
2461
+
2462
+ let separatorIndex;
2463
+ while ((separatorIndex = buffer.indexOf('\n\n')) !== -1) {
2464
+ const block = buffer.slice(0, separatorIndex);
2465
+ buffer = buffer.slice(separatorIndex + 2);
2466
+ forwardBlock(block);
2467
+ }
2468
+ }
2469
+
2470
+ if (buffer.trim().length > 0) {
2471
+ forwardBlock(buffer.trim());
2472
+ }
2473
+ } catch (error) {
2474
+ if (!controller.signal.aborted) {
2475
+ console.warn('SSE proxy stream error:', error);
2476
+ }
2477
+ } finally {
2478
+ clearInterval(heartbeatInterval);
2479
+ cleanup();
2480
+ try {
2481
+ res.end();
2482
+ } catch {
2483
+ // ignore
2484
+ }
2485
+ }
2486
+ });
2487
+
2488
+ app.get('/api/event', async (req, res) => {
2080
2489
  if (!openCodeApiPrefixDetected) {
2081
2490
  try {
2082
2491
  await detectOpenCodeApiPrefix();
@@ -2093,11 +2502,13 @@ async function main(options = {}) {
2093
2502
  return res.status(503).json({ error: 'OpenCode service unavailable' });
2094
2503
  }
2095
2504
 
2505
+ const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
2096
2506
  const directoryParam = Array.isArray(req.query.directory)
2097
2507
  ? req.query.directory[0]
2098
2508
  : req.query.directory;
2099
- if (typeof directoryParam === 'string' && directoryParam.trim().length > 0) {
2100
- targetUrl.searchParams.set('directory', directoryParam.trim());
2509
+ const resolvedDirectory = headerDirectory || directoryParam || null;
2510
+ if (typeof resolvedDirectory === 'string' && resolvedDirectory.trim().length > 0) {
2511
+ targetUrl.searchParams.set('directory', resolvedDirectory.trim());
2101
2512
  }
2102
2513
 
2103
2514
  const headers = {
@@ -2144,6 +2555,10 @@ async function main(options = {}) {
2144
2555
  res.flushHeaders();
2145
2556
  }
2146
2557
 
2558
+ const heartbeatInterval = setInterval(() => {
2559
+ writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() });
2560
+ }, 30000);
2561
+
2147
2562
  const decoder = new TextDecoder();
2148
2563
  const reader = upstream.body.getReader();
2149
2564
  let buffer = '';
@@ -2186,6 +2601,7 @@ async function main(options = {}) {
2186
2601
  console.warn('SSE proxy stream error:', error);
2187
2602
  }
2188
2603
  } finally {
2604
+ clearInterval(heartbeatInterval);
2189
2605
  cleanup();
2190
2606
  try {
2191
2607
  res.end();
@@ -2197,7 +2613,7 @@ async function main(options = {}) {
2197
2613
 
2198
2614
  app.get('/api/config/settings', async (_req, res) => {
2199
2615
  try {
2200
- const settings = await readSettingsFromDisk();
2616
+ const settings = await readSettingsFromDiskMigrated();
2201
2617
  res.json(formatSettingsResponse(settings));
2202
2618
  } catch (error) {
2203
2619
  console.error('Failed to load settings:', error);
@@ -2218,6 +2634,7 @@ async function main(options = {}) {
2218
2634
  const {
2219
2635
  getAgentSources,
2220
2636
  getAgentScope,
2637
+ getAgentConfig,
2221
2638
  createAgent,
2222
2639
  updateAgent,
2223
2640
  deleteAgent,
@@ -2230,16 +2647,23 @@ async function main(options = {}) {
2230
2647
  COMMAND_SCOPE
2231
2648
  } = await import('./lib/opencode-config.js');
2232
2649
 
2233
- app.get('/api/config/agents/:name', (req, res) => {
2650
+ app.get('/api/config/agents/:name', async (req, res) => {
2234
2651
  try {
2235
2652
  const agentName = req.params.name;
2236
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2237
- const sources = getAgentSources(agentName, workingDirectory);
2653
+ const { directory, error } = await resolveProjectDirectory(req);
2654
+ if (!directory) {
2655
+ return res.status(400).json({ error });
2656
+ }
2657
+ const sources = getAgentSources(agentName, directory);
2658
+
2659
+ const scope = sources.md.exists
2660
+ ? sources.md.scope
2661
+ : (sources.json.exists ? sources.json.scope : null);
2238
2662
 
2239
2663
  res.json({
2240
2664
  name: agentName,
2241
2665
  sources: sources,
2242
- scope: sources.md.scope,
2666
+ scope,
2243
2667
  isBuiltIn: !sources.md.exists && !sources.json.exists
2244
2668
  });
2245
2669
  } catch (error) {
@@ -2248,17 +2672,36 @@ async function main(options = {}) {
2248
2672
  }
2249
2673
  });
2250
2674
 
2675
+ app.get('/api/config/agents/:name/config', async (req, res) => {
2676
+ try {
2677
+ const agentName = req.params.name;
2678
+ const { directory, error } = await resolveProjectDirectory(req);
2679
+ if (!directory) {
2680
+ return res.status(400).json({ error });
2681
+ }
2682
+
2683
+ const configInfo = getAgentConfig(agentName, directory);
2684
+ res.json(configInfo);
2685
+ } catch (error) {
2686
+ console.error('Failed to get agent config:', error);
2687
+ res.status(500).json({ error: 'Failed to get agent configuration' });
2688
+ }
2689
+ });
2690
+
2251
2691
  app.post('/api/config/agents/:name', async (req, res) => {
2252
2692
  try {
2253
2693
  const agentName = req.params.name;
2254
2694
  const { scope, ...config } = req.body;
2255
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2695
+ const { directory, error } = await resolveProjectDirectory(req);
2696
+ if (!directory) {
2697
+ return res.status(400).json({ error });
2698
+ }
2256
2699
 
2257
2700
  console.log('[Server] Creating agent:', agentName);
2258
2701
  console.log('[Server] Config received:', JSON.stringify(config, null, 2));
2259
- console.log('[Server] Scope:', scope, 'Working directory:', workingDirectory);
2702
+ console.log('[Server] Scope:', scope, 'Working directory:', directory);
2260
2703
 
2261
- createAgent(agentName, config, workingDirectory, scope);
2704
+ createAgent(agentName, config, directory, scope);
2262
2705
  await refreshOpenCodeAfterConfigChange('agent creation', {
2263
2706
  agentName
2264
2707
  });
@@ -2279,13 +2722,16 @@ async function main(options = {}) {
2279
2722
  try {
2280
2723
  const agentName = req.params.name;
2281
2724
  const updates = req.body;
2282
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2725
+ const { directory, error } = await resolveProjectDirectory(req);
2726
+ if (!directory) {
2727
+ return res.status(400).json({ error });
2728
+ }
2283
2729
 
2284
2730
  console.log(`[Server] Updating agent: ${agentName}`);
2285
2731
  console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
2286
- console.log('[Server] Working directory:', workingDirectory);
2732
+ console.log('[Server] Working directory:', directory);
2287
2733
 
2288
- updateAgent(agentName, updates, workingDirectory);
2734
+ updateAgent(agentName, updates, directory);
2289
2735
  await refreshOpenCodeAfterConfigChange('agent update');
2290
2736
 
2291
2737
  console.log(`[Server] Agent ${agentName} updated successfully`);
@@ -2306,9 +2752,12 @@ async function main(options = {}) {
2306
2752
  app.delete('/api/config/agents/:name', async (req, res) => {
2307
2753
  try {
2308
2754
  const agentName = req.params.name;
2309
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2755
+ const { directory, error } = await resolveProjectDirectory(req);
2756
+ if (!directory) {
2757
+ return res.status(400).json({ error });
2758
+ }
2310
2759
 
2311
- deleteAgent(agentName, workingDirectory);
2760
+ deleteAgent(agentName, directory);
2312
2761
  await refreshOpenCodeAfterConfigChange('agent deletion');
2313
2762
 
2314
2763
  res.json({
@@ -2323,16 +2772,23 @@ async function main(options = {}) {
2323
2772
  }
2324
2773
  });
2325
2774
 
2326
- app.get('/api/config/commands/:name', (req, res) => {
2775
+ app.get('/api/config/commands/:name', async (req, res) => {
2327
2776
  try {
2328
2777
  const commandName = req.params.name;
2329
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2330
- const sources = getCommandSources(commandName, workingDirectory);
2778
+ const { directory, error } = await resolveProjectDirectory(req);
2779
+ if (!directory) {
2780
+ return res.status(400).json({ error });
2781
+ }
2782
+ const sources = getCommandSources(commandName, directory);
2783
+
2784
+ const scope = sources.md.exists
2785
+ ? sources.md.scope
2786
+ : (sources.json.exists ? sources.json.scope : null);
2331
2787
 
2332
2788
  res.json({
2333
2789
  name: commandName,
2334
2790
  sources: sources,
2335
- scope: sources.md.scope,
2791
+ scope,
2336
2792
  isBuiltIn: !sources.md.exists && !sources.json.exists
2337
2793
  });
2338
2794
  } catch (error) {
@@ -2345,13 +2801,16 @@ async function main(options = {}) {
2345
2801
  try {
2346
2802
  const commandName = req.params.name;
2347
2803
  const { scope, ...config } = req.body;
2348
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2804
+ const { directory, error } = await resolveProjectDirectory(req);
2805
+ if (!directory) {
2806
+ return res.status(400).json({ error });
2807
+ }
2349
2808
 
2350
2809
  console.log('[Server] Creating command:', commandName);
2351
2810
  console.log('[Server] Config received:', JSON.stringify(config, null, 2));
2352
- console.log('[Server] Scope:', scope, 'Working directory:', workingDirectory);
2811
+ console.log('[Server] Scope:', scope, 'Working directory:', directory);
2353
2812
 
2354
- createCommand(commandName, config, workingDirectory, scope);
2813
+ createCommand(commandName, config, directory, scope);
2355
2814
  await refreshOpenCodeAfterConfigChange('command creation', {
2356
2815
  commandName
2357
2816
  });
@@ -2372,13 +2831,16 @@ async function main(options = {}) {
2372
2831
  try {
2373
2832
  const commandName = req.params.name;
2374
2833
  const updates = req.body;
2375
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2834
+ const { directory, error } = await resolveProjectDirectory(req);
2835
+ if (!directory) {
2836
+ return res.status(400).json({ error });
2837
+ }
2376
2838
 
2377
2839
  console.log(`[Server] Updating command: ${commandName}`);
2378
2840
  console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
2379
- console.log('[Server] Working directory:', workingDirectory);
2841
+ console.log('[Server] Working directory:', directory);
2380
2842
 
2381
- updateCommand(commandName, updates, workingDirectory);
2843
+ updateCommand(commandName, updates, directory);
2382
2844
  await refreshOpenCodeAfterConfigChange('command update');
2383
2845
 
2384
2846
  console.log(`[Server] Command ${commandName} updated successfully`);
@@ -2399,9 +2861,12 @@ async function main(options = {}) {
2399
2861
  app.delete('/api/config/commands/:name', async (req, res) => {
2400
2862
  try {
2401
2863
  const commandName = req.params.name;
2402
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2864
+ const { directory, error } = await resolveProjectDirectory(req);
2865
+ if (!directory) {
2866
+ return res.status(400).json({ error });
2867
+ }
2403
2868
 
2404
- deleteCommand(commandName, workingDirectory);
2869
+ deleteCommand(commandName, directory);
2405
2870
  await refreshOpenCodeAfterConfigChange('command deletion');
2406
2871
 
2407
2872
  res.json({
@@ -2432,14 +2897,17 @@ async function main(options = {}) {
2432
2897
  } = await import('./lib/opencode-config.js');
2433
2898
 
2434
2899
  // List all discovered skills
2435
- app.get('/api/config/skills', (req, res) => {
2900
+ app.get('/api/config/skills', async (req, res) => {
2436
2901
  try {
2437
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2438
- const skills = discoverSkills(workingDirectory);
2902
+ const { directory, error } = await resolveProjectDirectory(req);
2903
+ if (!directory) {
2904
+ return res.status(400).json({ error });
2905
+ }
2906
+ const skills = discoverSkills(directory);
2439
2907
 
2440
2908
  // Enrich with full sources info
2441
2909
  const enrichedSkills = skills.map(skill => {
2442
- const sources = getSkillSources(skill.name, workingDirectory);
2910
+ const sources = getSkillSources(skill.name, directory);
2443
2911
  return {
2444
2912
  ...skill,
2445
2913
  sources
@@ -2489,7 +2957,10 @@ async function main(options = {}) {
2489
2957
 
2490
2958
  app.get('/api/config/skills/catalog', async (req, res) => {
2491
2959
  try {
2492
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2960
+ const { directory, error } = await resolveProjectDirectory(req);
2961
+ if (!directory) {
2962
+ return res.status(400).json({ error });
2963
+ }
2493
2964
  const refresh = String(req.query.refresh || '').toLowerCase() === 'true';
2494
2965
 
2495
2966
  const curatedSources = getCuratedSkillsSources();
@@ -2507,7 +2978,7 @@ async function main(options = {}) {
2507
2978
 
2508
2979
  const sources = [...curatedSources, ...customSources];
2509
2980
 
2510
- const discovered = discoverSkills(workingDirectory);
2981
+ const discovered = discoverSkills(directory);
2511
2982
  const installedByName = new Map(discovered.map((s) => [s.name, s]));
2512
2983
 
2513
2984
  const itemsBySource = {};
@@ -2611,12 +3082,16 @@ async function main(options = {}) {
2611
3082
  conflictDecisions,
2612
3083
  } = req.body || {};
2613
3084
 
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
- });
3085
+ let workingDirectory = null;
3086
+ if (scope === 'project') {
3087
+ const resolved = await resolveProjectDirectory(req);
3088
+ if (!resolved.directory) {
3089
+ return res.status(400).json({
3090
+ ok: false,
3091
+ error: { kind: 'invalidSource', message: resolved.error || 'Project installs require a directory parameter' },
3092
+ });
3093
+ }
3094
+ workingDirectory = resolved.directory;
2620
3095
  }
2621
3096
  const identity = resolveGitIdentity(gitIdentityId);
2622
3097
 
@@ -2658,11 +3133,14 @@ async function main(options = {}) {
2658
3133
  });
2659
3134
 
2660
3135
  // Get single skill sources
2661
- app.get('/api/config/skills/:name', (req, res) => {
3136
+ app.get('/api/config/skills/:name', async (req, res) => {
2662
3137
  try {
2663
3138
  const skillName = req.params.name;
2664
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
2665
- const sources = getSkillSources(skillName, workingDirectory);
3139
+ const { directory, error } = await resolveProjectDirectory(req);
3140
+ if (!directory) {
3141
+ return res.status(400).json({ error });
3142
+ }
3143
+ const sources = getSkillSources(skillName, directory);
2666
3144
 
2667
3145
  res.json({
2668
3146
  name: skillName,
@@ -2678,13 +3156,16 @@ async function main(options = {}) {
2678
3156
  });
2679
3157
 
2680
3158
  // Get skill supporting file content
2681
- app.get('/api/config/skills/:name/files/*filePath', (req, res) => {
3159
+ app.get('/api/config/skills/:name/files/*filePath', async (req, res) => {
2682
3160
  try {
2683
3161
  const skillName = req.params.name;
2684
3162
  const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
2685
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
3163
+ const { directory, error } = await resolveProjectDirectory(req);
3164
+ if (!directory) {
3165
+ return res.status(400).json({ error });
3166
+ }
2686
3167
 
2687
- const sources = getSkillSources(skillName, workingDirectory);
3168
+ const sources = getSkillSources(skillName, directory);
2688
3169
  if (!sources.md.exists || !sources.md.dir) {
2689
3170
  return res.status(404).json({ error: 'Skill not found' });
2690
3171
  }
@@ -2706,12 +3187,15 @@ async function main(options = {}) {
2706
3187
  try {
2707
3188
  const skillName = req.params.name;
2708
3189
  const { scope, ...config } = req.body;
2709
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
3190
+ const { directory, error } = await resolveProjectDirectory(req);
3191
+ if (!directory) {
3192
+ return res.status(400).json({ error });
3193
+ }
2710
3194
 
2711
3195
  console.log('[Server] Creating skill:', skillName);
2712
- console.log('[Server] Scope:', scope, 'Working directory:', workingDirectory);
3196
+ console.log('[Server] Scope:', scope, 'Working directory:', directory);
2713
3197
 
2714
- createSkill(skillName, config, workingDirectory, scope);
3198
+ createSkill(skillName, config, directory, scope);
2715
3199
  // Skills are just files - OpenCode loads them on-demand, no restart needed
2716
3200
 
2717
3201
  res.json({
@@ -2730,12 +3214,15 @@ async function main(options = {}) {
2730
3214
  try {
2731
3215
  const skillName = req.params.name;
2732
3216
  const updates = req.body;
2733
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
3217
+ const { directory, error } = await resolveProjectDirectory(req);
3218
+ if (!directory) {
3219
+ return res.status(400).json({ error });
3220
+ }
2734
3221
 
2735
3222
  console.log(`[Server] Updating skill: ${skillName}`);
2736
- console.log('[Server] Working directory:', workingDirectory);
3223
+ console.log('[Server] Working directory:', directory);
2737
3224
 
2738
- updateSkill(skillName, updates, workingDirectory);
3225
+ updateSkill(skillName, updates, directory);
2739
3226
  // Skills are just files - OpenCode loads them on-demand, no restart needed
2740
3227
 
2741
3228
  res.json({
@@ -2755,9 +3242,12 @@ async function main(options = {}) {
2755
3242
  const skillName = req.params.name;
2756
3243
  const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
2757
3244
  const { content } = req.body;
2758
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
3245
+ const { directory, error } = await resolveProjectDirectory(req);
3246
+ if (!directory) {
3247
+ return res.status(400).json({ error });
3248
+ }
2759
3249
 
2760
- const sources = getSkillSources(skillName, workingDirectory);
3250
+ const sources = getSkillSources(skillName, directory);
2761
3251
  if (!sources.md.exists || !sources.md.dir) {
2762
3252
  return res.status(404).json({ error: 'Skill not found' });
2763
3253
  }
@@ -2779,9 +3269,12 @@ async function main(options = {}) {
2779
3269
  try {
2780
3270
  const skillName = req.params.name;
2781
3271
  const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
2782
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
3272
+ const { directory, error } = await resolveProjectDirectory(req);
3273
+ if (!directory) {
3274
+ return res.status(400).json({ error });
3275
+ }
2783
3276
 
2784
- const sources = getSkillSources(skillName, workingDirectory);
3277
+ const sources = getSkillSources(skillName, directory);
2785
3278
  if (!sources.md.exists || !sources.md.dir) {
2786
3279
  return res.status(404).json({ error: 'Skill not found' });
2787
3280
  }
@@ -2802,9 +3295,12 @@ async function main(options = {}) {
2802
3295
  app.delete('/api/config/skills/:name', async (req, res) => {
2803
3296
  try {
2804
3297
  const skillName = req.params.name;
2805
- const workingDirectory = req.query.directory || openCodeWorkingDirectory;
3298
+ const { directory, error } = await resolveProjectDirectory(req);
3299
+ if (!directory) {
3300
+ return res.status(400).json({ error });
3301
+ }
2806
3302
 
2807
- deleteSkill(skillName, workingDirectory);
3303
+ deleteSkill(skillName, directory);
2808
3304
  // Skills are just files - OpenCode loads them on-demand, no restart needed
2809
3305
 
2810
3306
  res.json({
@@ -3331,6 +3827,30 @@ async function main(options = {}) {
3331
3827
  }
3332
3828
  });
3333
3829
 
3830
+
3831
+ app.put('/api/git/branches/rename', async (req, res) => {
3832
+ const { renameBranch } = await getGitLibraries();
3833
+ try {
3834
+ const directory = req.query.directory;
3835
+ if (!directory) {
3836
+ return res.status(400).json({ error: 'directory parameter is required' });
3837
+ }
3838
+
3839
+ const { oldName, newName } = req.body;
3840
+ if (!oldName) {
3841
+ return res.status(400).json({ error: 'oldName is required' });
3842
+ }
3843
+ if (!newName) {
3844
+ return res.status(400).json({ error: 'newName is required' });
3845
+ }
3846
+
3847
+ const result = await renameBranch(directory, oldName, newName);
3848
+ res.json(result);
3849
+ } catch (error) {
3850
+ console.error('Failed to rename branch:', error);
3851
+ res.status(500).json({ error: error.message || 'Failed to rename branch' });
3852
+ }
3853
+ });
3334
3854
  app.delete('/api/git/remote-branches', async (req, res) => {
3335
3855
  const { deleteRemoteBranch } = await getGitLibraries();
3336
3856
  try {
@@ -3543,47 +4063,334 @@ async function main(options = {}) {
3543
4063
  }
3544
4064
  });
3545
4065
 
3546
- app.post('/api/opencode/directory', async (req, res) => {
4066
+ // Read file contents
4067
+ app.get('/api/fs/read', async (req, res) => {
4068
+ const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
4069
+ if (!filePath) {
4070
+ return res.status(400).json({ error: 'Path is required' });
4071
+ }
4072
+
3547
4073
  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' });
4074
+ const resolvedPath = path.resolve(normalizeDirectoryPath(filePath));
4075
+ if (resolvedPath.includes('..')) {
4076
+ return res.status(400).json({ error: 'Invalid path: path traversal not allowed' });
4077
+ }
4078
+
4079
+ const stats = await fsPromises.stat(resolvedPath);
4080
+ if (!stats.isFile()) {
4081
+ return res.status(400).json({ error: 'Specified path is not a file' });
4082
+ }
4083
+
4084
+ const content = await fsPromises.readFile(resolvedPath, 'utf8');
4085
+ res.type('text/plain').send(content);
4086
+ } catch (error) {
4087
+ const err = error;
4088
+ if (err && typeof err === 'object' && err.code === 'ENOENT') {
4089
+ return res.status(404).json({ error: 'File not found' });
4090
+ }
4091
+ if (err && typeof err === 'object' && err.code === 'EACCES') {
4092
+ return res.status(403).json({ error: 'Access to file denied' });
4093
+ }
4094
+ console.error('Failed to read file:', error);
4095
+ res.status(500).json({ error: (error && error.message) || 'Failed to read file' });
4096
+ }
4097
+ });
4098
+
4099
+ // Write file contents
4100
+ app.post('/api/fs/write', async (req, res) => {
4101
+ const { path: filePath, content } = req.body || {};
4102
+ if (!filePath || typeof filePath !== 'string') {
4103
+ return res.status(400).json({ error: 'Path is required' });
4104
+ }
4105
+ if (typeof content !== 'string') {
4106
+ return res.status(400).json({ error: 'Content is required' });
4107
+ }
4108
+
4109
+ try {
4110
+ const resolvedPath = path.resolve(normalizeDirectoryPath(filePath));
4111
+ if (resolvedPath.includes('..')) {
4112
+ return res.status(400).json({ error: 'Invalid path: path traversal not allowed' });
4113
+ }
4114
+
4115
+ // Ensure parent directory exists
4116
+ await fsPromises.mkdir(path.dirname(resolvedPath), { recursive: true });
4117
+ await fsPromises.writeFile(resolvedPath, content, 'utf8');
4118
+ res.json({ success: true, path: resolvedPath });
4119
+ } catch (error) {
4120
+ const err = error;
4121
+ if (err && typeof err === 'object' && err.code === 'EACCES') {
4122
+ return res.status(403).json({ error: 'Access denied' });
4123
+ }
4124
+ console.error('Failed to write file:', error);
4125
+ res.status(500).json({ error: (error && error.message) || 'Failed to write file' });
4126
+ }
4127
+ });
4128
+
4129
+ // Execute shell commands in a directory (for worktree setup)
4130
+ // NOTE: This route supports background execution to avoid tying up browser connections.
4131
+ const execJobs = new Map();
4132
+ const EXEC_JOB_TTL_MS = 30 * 60 * 1000;
4133
+ const COMMAND_TIMEOUT_MS = (() => {
4134
+ const raw = Number(process.env.OPENCHAMBER_FS_EXEC_TIMEOUT_MS);
4135
+ if (Number.isFinite(raw) && raw > 0) return raw;
4136
+ // `bun install` (common worktree setup cmd) often takes >60s.
4137
+ return 5 * 60 * 1000;
4138
+ })();
4139
+
4140
+ const pruneExecJobs = () => {
4141
+ const now = Date.now();
4142
+ for (const [jobId, job] of execJobs.entries()) {
4143
+ if (!job || typeof job !== 'object') {
4144
+ execJobs.delete(jobId);
4145
+ continue;
4146
+ }
4147
+ const updatedAt = typeof job.updatedAt === 'number' ? job.updatedAt : 0;
4148
+ if (updatedAt && now - updatedAt > EXEC_JOB_TTL_MS) {
4149
+ execJobs.delete(jobId);
4150
+ }
4151
+ }
4152
+ };
4153
+
4154
+ const runCommandInDirectory = (shell, shellFlag, command, resolvedCwd) => {
4155
+ return new Promise((resolve) => {
4156
+ let stdout = '';
4157
+ let stderr = '';
4158
+ let timedOut = false;
4159
+
4160
+ const envPath = buildAugmentedPath();
4161
+ const execEnv = { ...process.env, PATH: envPath };
4162
+
4163
+ const child = spawn(shell, [shellFlag, command], {
4164
+ cwd: resolvedCwd,
4165
+ env: execEnv,
4166
+ stdio: ['ignore', 'pipe', 'pipe'],
4167
+ });
4168
+
4169
+ const timeout = setTimeout(() => {
4170
+ timedOut = true;
4171
+ try {
4172
+ child.kill('SIGKILL');
4173
+ } catch {
4174
+ // ignore
4175
+ }
4176
+ }, COMMAND_TIMEOUT_MS);
4177
+
4178
+ child.stdout?.on('data', (chunk) => {
4179
+ stdout += chunk.toString();
4180
+ });
4181
+
4182
+ child.stderr?.on('data', (chunk) => {
4183
+ stderr += chunk.toString();
4184
+ });
4185
+
4186
+ child.on('error', (error) => {
4187
+ clearTimeout(timeout);
4188
+ resolve({
4189
+ command,
4190
+ success: false,
4191
+ exitCode: undefined,
4192
+ stdout: stdout.trim(),
4193
+ stderr: stderr.trim(),
4194
+ error: (error && error.message) || 'Command execution failed',
4195
+ });
4196
+ });
4197
+
4198
+ child.on('close', (code, signal) => {
4199
+ clearTimeout(timeout);
4200
+ const exitCode = typeof code === 'number' ? code : undefined;
4201
+ const base = {
4202
+ command,
4203
+ success: exitCode === 0 && !timedOut,
4204
+ exitCode,
4205
+ stdout: stdout.trim(),
4206
+ stderr: stderr.trim(),
4207
+ };
4208
+
4209
+ if (timedOut) {
4210
+ resolve({
4211
+ ...base,
4212
+ success: false,
4213
+ error: `Command timed out after ${COMMAND_TIMEOUT_MS}ms` + (signal ? ` (${signal})` : ''),
4214
+ });
4215
+ return;
4216
+ }
4217
+
4218
+ resolve(base);
4219
+ });
4220
+ });
4221
+ };
4222
+
4223
+ const runExecJob = async (job) => {
4224
+ job.status = 'running';
4225
+ job.updatedAt = Date.now();
4226
+
4227
+ const results = [];
4228
+
4229
+ for (const command of job.commands) {
4230
+ if (typeof command !== 'string' || !command.trim()) {
4231
+ results.push({ command, success: false, error: 'Invalid command' });
4232
+ continue;
3551
4233
  }
3552
4234
 
3553
- const resolvedPath = path.resolve(normalizeDirectoryPath(requestedPath));
3554
- let stats;
3555
4235
  try {
3556
- stats = await fsPromises.stat(resolvedPath);
4236
+ const result = await runCommandInDirectory(job.shell, job.shellFlag, command, job.resolvedCwd);
4237
+ results.push(result);
3557
4238
  } 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;
4239
+ results.push({
4240
+ command,
4241
+ success: false,
4242
+ error: (error && error.message) || 'Command execution failed',
4243
+ });
3568
4244
  }
3569
4245
 
4246
+ job.results = results;
4247
+ job.updatedAt = Date.now();
4248
+ }
4249
+
4250
+ job.results = results;
4251
+ job.success = results.every((r) => r.success);
4252
+ job.status = 'done';
4253
+ job.finishedAt = Date.now();
4254
+ job.updatedAt = Date.now();
4255
+ };
4256
+
4257
+ app.post('/api/fs/exec', async (req, res) => {
4258
+ const { commands, cwd, background } = req.body || {};
4259
+ if (!Array.isArray(commands) || commands.length === 0) {
4260
+ return res.status(400).json({ error: 'Commands array is required' });
4261
+ }
4262
+ if (!cwd || typeof cwd !== 'string') {
4263
+ return res.status(400).json({ error: 'Working directory (cwd) is required' });
4264
+ }
4265
+
4266
+ pruneExecJobs();
4267
+
4268
+ try {
4269
+ const resolvedCwd = path.resolve(normalizeDirectoryPath(cwd));
4270
+ const stats = await fsPromises.stat(resolvedCwd);
3570
4271
  if (!stats.isDirectory()) {
3571
- return res.status(400).json({ error: 'Specified path is not a directory' });
4272
+ return res.status(400).json({ error: 'Specified cwd is not a directory' });
3572
4273
  }
3573
4274
 
3574
- if (openCodeWorkingDirectory === resolvedPath && openCodeProcess && openCodeProcess.exitCode === null) {
3575
- return res.json({ success: true, restarted: false, path: resolvedPath });
4275
+ const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh');
4276
+ const shellFlag = process.platform === 'win32' ? '/c' : '-c';
4277
+
4278
+ const jobId = crypto.randomUUID();
4279
+ const job = {
4280
+ jobId,
4281
+ status: 'queued',
4282
+ success: null,
4283
+ commands,
4284
+ resolvedCwd,
4285
+ shell,
4286
+ shellFlag,
4287
+ results: [],
4288
+ startedAt: Date.now(),
4289
+ finishedAt: null,
4290
+ updatedAt: Date.now(),
4291
+ };
4292
+
4293
+ execJobs.set(jobId, job);
4294
+
4295
+ const isBackground = background === true;
4296
+ if (isBackground) {
4297
+ void runExecJob(job).catch((error) => {
4298
+ job.status = 'done';
4299
+ job.success = false;
4300
+ job.results = Array.isArray(job.results) ? job.results : [];
4301
+ job.results.push({
4302
+ command: '',
4303
+ success: false,
4304
+ error: (error && error.message) || 'Command execution failed',
4305
+ });
4306
+ job.finishedAt = Date.now();
4307
+ job.updatedAt = Date.now();
4308
+ });
4309
+
4310
+ return res.status(202).json({
4311
+ jobId,
4312
+ status: 'running',
4313
+ });
3576
4314
  }
3577
4315
 
3578
- openCodeWorkingDirectory = resolvedPath;
3579
- syncToHmrState();
4316
+ await runExecJob(job);
4317
+ res.json({
4318
+ jobId,
4319
+ status: job.status,
4320
+ success: job.success === true,
4321
+ results: job.results,
4322
+ });
4323
+ } catch (error) {
4324
+ console.error('Failed to execute commands:', error);
4325
+ res.status(500).json({ error: (error && error.message) || 'Failed to execute commands' });
4326
+ }
4327
+ });
4328
+
4329
+ app.get('/api/fs/exec/:jobId', (req, res) => {
4330
+ const jobId = typeof req.params?.jobId === 'string' ? req.params.jobId : '';
4331
+ if (!jobId) {
4332
+ return res.status(400).json({ error: 'Job id is required' });
4333
+ }
4334
+
4335
+ pruneExecJobs();
4336
+
4337
+ const job = execJobs.get(jobId);
4338
+ if (!job) {
4339
+ return res.status(404).json({ error: 'Job not found' });
4340
+ }
4341
+
4342
+ job.updatedAt = Date.now();
4343
+
4344
+ return res.json({
4345
+ jobId: job.jobId,
4346
+ status: job.status,
4347
+ success: job.success === true,
4348
+ results: Array.isArray(job.results) ? job.results : [],
4349
+ });
4350
+ });
4351
+
4352
+ app.post('/api/opencode/directory', async (req, res) => {
4353
+ try {
4354
+ const requestedPath = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
4355
+ if (!requestedPath) {
4356
+ return res.status(400).json({ error: 'Path is required' });
4357
+ }
3580
4358
 
3581
- await refreshOpenCodeAfterConfigChange('directory change');
4359
+ const validated = await validateDirectoryPath(requestedPath);
4360
+ if (!validated.ok) {
4361
+ return res.status(400).json({ error: validated.error });
4362
+ }
4363
+
4364
+ const resolvedPath = validated.directory;
4365
+ const currentSettings = await readSettingsFromDisk();
4366
+ const existingProjects = sanitizeProjects(currentSettings.projects) || [];
4367
+ const existing = existingProjects.find((project) => project.path === resolvedPath) || null;
4368
+
4369
+ const nextProjects = existing
4370
+ ? existingProjects
4371
+ : [
4372
+ ...existingProjects,
4373
+ {
4374
+ id: crypto.randomUUID(),
4375
+ path: resolvedPath,
4376
+ addedAt: Date.now(),
4377
+ lastOpenedAt: Date.now(),
4378
+ },
4379
+ ];
4380
+
4381
+ const activeProjectId = existing ? existing.id : nextProjects[nextProjects.length - 1].id;
4382
+
4383
+ const updated = await persistSettings({
4384
+ projects: nextProjects,
4385
+ activeProjectId,
4386
+ lastDirectory: resolvedPath,
4387
+ });
3582
4388
 
3583
4389
  res.json({
3584
4390
  success: true,
3585
- restarted: true,
3586
- path: resolvedPath
4391
+ restarted: false,
4392
+ path: resolvedPath,
4393
+ settings: updated,
3587
4394
  });
3588
4395
  } catch (error) {
3589
4396
  console.error('Failed to update OpenCode working directory:', error);