@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/bin/cli.js +44 -9
- package/dist/assets/{ToolOutputDialog-C7D95isZ.js → ToolOutputDialog-DYNTzpsQ.js} +2 -2
- package/dist/assets/{index-C9fZPpn0.js → index-g64tpi5J.js} +2 -2
- package/dist/assets/index-hru9kOov.css +1 -0
- package/dist/assets/main-BucgrSbb.js +128 -0
- package/dist/assets/{vendor-.bun-B7CnnRlj.js → vendor-.bun-B2HtLj-d.js} +421 -417
- package/dist/index.html +3 -3
- package/package.json +3 -2
- package/server/index.js +904 -97
- package/server/lib/git-service.js +30 -1
- package/server/lib/opencode-config.js +306 -29
- package/dist/assets/index-BFtIIPKJ.css +0 -1
- package/dist/assets/main-DzISCzsJ.js +0 -122
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'
|
|
453
|
-
|
|
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'
|
|
456
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
2100
|
-
|
|
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
|
|
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
|
|
2237
|
-
|
|
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
|
|
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
|
|
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:',
|
|
2702
|
+
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
|
2260
2703
|
|
|
2261
|
-
createAgent(agentName, config,
|
|
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
|
|
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:',
|
|
2732
|
+
console.log('[Server] Working directory:', directory);
|
|
2287
2733
|
|
|
2288
|
-
updateAgent(agentName, updates,
|
|
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
|
|
2755
|
+
const { directory, error } = await resolveProjectDirectory(req);
|
|
2756
|
+
if (!directory) {
|
|
2757
|
+
return res.status(400).json({ error });
|
|
2758
|
+
}
|
|
2310
2759
|
|
|
2311
|
-
deleteAgent(agentName,
|
|
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
|
|
2330
|
-
|
|
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
|
|
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
|
|
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:',
|
|
2811
|
+
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
|
2353
2812
|
|
|
2354
|
-
createCommand(commandName, config,
|
|
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
|
|
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:',
|
|
2841
|
+
console.log('[Server] Working directory:', directory);
|
|
2380
2842
|
|
|
2381
|
-
updateCommand(commandName, updates,
|
|
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
|
|
2864
|
+
const { directory, error } = await resolveProjectDirectory(req);
|
|
2865
|
+
if (!directory) {
|
|
2866
|
+
return res.status(400).json({ error });
|
|
2867
|
+
}
|
|
2403
2868
|
|
|
2404
|
-
deleteCommand(commandName,
|
|
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
|
|
2438
|
-
|
|
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,
|
|
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
|
|
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(
|
|
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
|
-
|
|
2615
|
-
if (scope === 'project'
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
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
|
|
2665
|
-
|
|
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
|
|
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,
|
|
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
|
|
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:',
|
|
3196
|
+
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
|
2713
3197
|
|
|
2714
|
-
createSkill(skillName, config,
|
|
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
|
|
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:',
|
|
3223
|
+
console.log('[Server] Working directory:', directory);
|
|
2737
3224
|
|
|
2738
|
-
updateSkill(skillName, updates,
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
3298
|
+
const { directory, error } = await resolveProjectDirectory(req);
|
|
3299
|
+
if (!directory) {
|
|
3300
|
+
return res.status(400).json({ error });
|
|
3301
|
+
}
|
|
2806
3302
|
|
|
2807
|
-
deleteSkill(skillName,
|
|
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
|
-
|
|
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
|
|
3549
|
-
if (
|
|
3550
|
-
return res.status(400).json({ error: '
|
|
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
|
-
|
|
4236
|
+
const result = await runCommandInDirectory(job.shell, job.shellFlag, command, job.resolvedCwd);
|
|
4237
|
+
results.push(result);
|
|
3557
4238
|
} catch (error) {
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
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
|
|
4272
|
+
return res.status(400).json({ error: 'Specified cwd is not a directory' });
|
|
3572
4273
|
}
|
|
3573
4274
|
|
|
3574
|
-
|
|
3575
|
-
|
|
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
|
-
|
|
3579
|
-
|
|
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
|
|
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:
|
|
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);
|