12330 lines
400 KiB
JavaScript
12330 lines
400 KiB
JavaScript
|
|
import express from 'express';
|
|||
|
|
import path from 'path';
|
|||
|
|
import { spawn, spawnSync } from 'child_process';
|
|||
|
|
import fs from 'fs';
|
|||
|
|
import http from 'http';
|
|||
|
|
import net from 'net';
|
|||
|
|
import { WebSocketServer } from 'ws';
|
|||
|
|
import { fileURLToPath } from 'url';
|
|||
|
|
import os from 'os';
|
|||
|
|
import crypto from 'crypto';
|
|||
|
|
import { createUiAuth } from './lib/opencode/ui-auth.js';
|
|||
|
|
|
|||
|
|
import { prepareNotificationLastMessage } from './lib/notifications/index.js';
|
|||
|
|
import {
|
|||
|
|
TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES,
|
|||
|
|
TERMINAL_INPUT_WS_PATH,
|
|||
|
|
createTerminalInputWsControlFrame,
|
|||
|
|
isRebindRateLimited,
|
|||
|
|
normalizeTerminalInputWsMessageToText,
|
|||
|
|
parseRequestPathname,
|
|||
|
|
pruneRebindTimestamps,
|
|||
|
|
readTerminalInputWsControlFrame,
|
|||
|
|
} from './lib/terminal/index.js';
|
|||
|
|
import webPush from 'web-push';
|
|||
|
|
|
|||
|
|
const __filename = fileURLToPath(import.meta.url);
|
|||
|
|
const __dirname = path.dirname(__filename);
|
|||
|
|
|
|||
|
|
const DEFAULT_PORT = 3000;
|
|||
|
|
const DESKTOP_NOTIFY_PREFIX = '[OpenChamberDesktopNotify] ';
|
|||
|
|
const uiNotificationClients = new Set();
|
|||
|
|
const HEALTH_CHECK_INTERVAL = 15000;
|
|||
|
|
const SHUTDOWN_TIMEOUT = 10000;
|
|||
|
|
const MODELS_DEV_API_URL = 'https://models.dev/api.json';
|
|||
|
|
const MODELS_METADATA_CACHE_TTL = 5 * 60 * 1000;
|
|||
|
|
const CLIENT_RELOAD_DELAY_MS = 800;
|
|||
|
|
const OPEN_CODE_READY_GRACE_MS = 12000;
|
|||
|
|
const LONG_REQUEST_TIMEOUT_MS = 4 * 60 * 1000;
|
|||
|
|
const OPENCHAMBER_VERSION = (() => {
|
|||
|
|
try {
|
|||
|
|
const packagePath = path.resolve(__dirname, '..', 'package.json');
|
|||
|
|
const raw = fs.readFileSync(packagePath, 'utf8');
|
|||
|
|
const pkg = JSON.parse(raw);
|
|||
|
|
if (pkg && typeof pkg.version === 'string' && pkg.version.trim().length > 0) {
|
|||
|
|
return pkg.version.trim();
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
}
|
|||
|
|
return 'unknown';
|
|||
|
|
})();
|
|||
|
|
const fsPromises = fs.promises;
|
|||
|
|
const FILE_SEARCH_MAX_CONCURRENCY = 5;
|
|||
|
|
const FILE_SEARCH_EXCLUDED_DIRS = new Set([
|
|||
|
|
'node_modules',
|
|||
|
|
'.git',
|
|||
|
|
'dist',
|
|||
|
|
'build',
|
|||
|
|
'.next',
|
|||
|
|
'.turbo',
|
|||
|
|
'.cache',
|
|||
|
|
'coverage',
|
|||
|
|
'tmp',
|
|||
|
|
'logs'
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
// Lock to prevent race conditions in persistSettings
|
|||
|
|
let persistSettingsLock = Promise.resolve();
|
|||
|
|
|
|||
|
|
const normalizeDirectoryPath = (value) => {
|
|||
|
|
if (typeof value !== 'string') {
|
|||
|
|
return value;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const trimmed = value.trim();
|
|||
|
|
if (!trimmed) {
|
|||
|
|
return trimmed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (trimmed === '~') {
|
|||
|
|
return os.homedir();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
|
|||
|
|
return path.join(os.homedir(), trimmed.slice(2));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return trimmed;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const OPENCHAMBER_USER_CONFIG_ROOT = path.join(os.homedir(), '.config', 'openchamber');
|
|||
|
|
const OPENCHAMBER_USER_THEMES_DIR = path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'themes');
|
|||
|
|
|
|||
|
|
const MAX_THEME_JSON_BYTES = 512 * 1024;
|
|||
|
|
|
|||
|
|
const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0;
|
|||
|
|
|
|||
|
|
const isValidThemeColor = (value) => isNonEmptyString(value);
|
|||
|
|
|
|||
|
|
const normalizeThemeJson = (raw) => {
|
|||
|
|
if (!raw || typeof raw !== 'object') {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const metadata = raw.metadata && typeof raw.metadata === 'object' ? raw.metadata : null;
|
|||
|
|
const colors = raw.colors && typeof raw.colors === 'object' ? raw.colors : null;
|
|||
|
|
if (!metadata || !colors) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const id = metadata.id;
|
|||
|
|
const name = metadata.name;
|
|||
|
|
const variant = metadata.variant;
|
|||
|
|
if (!isNonEmptyString(id) || !isNonEmptyString(name) || (variant !== 'light' && variant !== 'dark')) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const primary = colors.primary;
|
|||
|
|
const surface = colors.surface;
|
|||
|
|
const interactive = colors.interactive;
|
|||
|
|
const status = colors.status;
|
|||
|
|
const syntax = colors.syntax;
|
|||
|
|
const syntaxBase = syntax && typeof syntax === 'object' ? syntax.base : null;
|
|||
|
|
const syntaxHighlights = syntax && typeof syntax === 'object' ? syntax.highlights : null;
|
|||
|
|
|
|||
|
|
if (!primary || !surface || !interactive || !status || !syntaxBase || !syntaxHighlights) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Minimal fields required by CSSVariableGenerator and diff/syntax rendering.
|
|||
|
|
const required = [
|
|||
|
|
primary.base,
|
|||
|
|
primary.foreground,
|
|||
|
|
surface.background,
|
|||
|
|
surface.foreground,
|
|||
|
|
surface.muted,
|
|||
|
|
surface.mutedForeground,
|
|||
|
|
surface.elevated,
|
|||
|
|
surface.elevatedForeground,
|
|||
|
|
surface.subtle,
|
|||
|
|
interactive.border,
|
|||
|
|
interactive.selection,
|
|||
|
|
interactive.selectionForeground,
|
|||
|
|
interactive.focusRing,
|
|||
|
|
interactive.hover,
|
|||
|
|
status.error,
|
|||
|
|
status.errorForeground,
|
|||
|
|
status.errorBackground,
|
|||
|
|
status.errorBorder,
|
|||
|
|
status.warning,
|
|||
|
|
status.warningForeground,
|
|||
|
|
status.warningBackground,
|
|||
|
|
status.warningBorder,
|
|||
|
|
status.success,
|
|||
|
|
status.successForeground,
|
|||
|
|
status.successBackground,
|
|||
|
|
status.successBorder,
|
|||
|
|
status.info,
|
|||
|
|
status.infoForeground,
|
|||
|
|
status.infoBackground,
|
|||
|
|
status.infoBorder,
|
|||
|
|
syntaxBase.background,
|
|||
|
|
syntaxBase.foreground,
|
|||
|
|
syntaxBase.keyword,
|
|||
|
|
syntaxBase.string,
|
|||
|
|
syntaxBase.number,
|
|||
|
|
syntaxBase.function,
|
|||
|
|
syntaxBase.variable,
|
|||
|
|
syntaxBase.type,
|
|||
|
|
syntaxBase.comment,
|
|||
|
|
syntaxBase.operator,
|
|||
|
|
syntaxHighlights.diffAdded,
|
|||
|
|
syntaxHighlights.diffRemoved,
|
|||
|
|
syntaxHighlights.lineNumber,
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
if (!required.every(isValidThemeColor)) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const tags = Array.isArray(metadata.tags)
|
|||
|
|
? metadata.tags.filter((tag) => typeof tag === 'string' && tag.trim().length > 0)
|
|||
|
|
: [];
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
...raw,
|
|||
|
|
metadata: {
|
|||
|
|
...metadata,
|
|||
|
|
id: id.trim(),
|
|||
|
|
name: name.trim(),
|
|||
|
|
description: typeof metadata.description === 'string' ? metadata.description : '',
|
|||
|
|
version: typeof metadata.version === 'string' && metadata.version.trim().length > 0 ? metadata.version : '1.0.0',
|
|||
|
|
variant,
|
|||
|
|
tags,
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const readCustomThemesFromDisk = async () => {
|
|||
|
|
try {
|
|||
|
|
const entries = await fsPromises.readdir(OPENCHAMBER_USER_THEMES_DIR, { withFileTypes: true });
|
|||
|
|
const themes = [];
|
|||
|
|
const seen = new Set();
|
|||
|
|
|
|||
|
|
for (const entry of entries) {
|
|||
|
|
if (!entry.isFile()) continue;
|
|||
|
|
if (!entry.name.toLowerCase().endsWith('.json')) continue;
|
|||
|
|
|
|||
|
|
const filePath = path.join(OPENCHAMBER_USER_THEMES_DIR, entry.name);
|
|||
|
|
try {
|
|||
|
|
const stat = await fsPromises.stat(filePath);
|
|||
|
|
if (!stat.isFile()) continue;
|
|||
|
|
if (stat.size > MAX_THEME_JSON_BYTES) {
|
|||
|
|
console.warn(`[themes] Skip ${entry.name}: too large (${stat.size} bytes)`);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const rawText = await fsPromises.readFile(filePath, 'utf8');
|
|||
|
|
const parsed = JSON.parse(rawText);
|
|||
|
|
const normalized = normalizeThemeJson(parsed);
|
|||
|
|
if (!normalized) {
|
|||
|
|
console.warn(`[themes] Skip ${entry.name}: invalid theme JSON`);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const id = normalized.metadata.id;
|
|||
|
|
if (seen.has(id)) {
|
|||
|
|
console.warn(`[themes] Skip ${entry.name}: duplicate theme id "${id}"`);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
seen.add(id);
|
|||
|
|
themes.push(normalized);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.warn(`[themes] Failed to read ${entry.name}:`, error);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return themes;
|
|||
|
|
} catch (error) {
|
|||
|
|
// Missing dir is fine.
|
|||
|
|
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
console.warn('[themes] Failed to list custom themes dir:', error);
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const isPathWithinRoot = (resolvedPath, rootPath) => {
|
|||
|
|
const resolvedRoot = path.resolve(rootPath || os.homedir());
|
|||
|
|
const relative = path.relative(resolvedRoot, resolvedPath);
|
|||
|
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
return true;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const resolveWorkspacePath = (targetPath, baseDirectory) => {
|
|||
|
|
const normalized = normalizeDirectoryPath(targetPath);
|
|||
|
|
if (!normalized || typeof normalized !== 'string') {
|
|||
|
|
return { ok: false, error: 'Path is required' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const resolved = path.resolve(normalized);
|
|||
|
|
const resolvedBase = path.resolve(baseDirectory || os.homedir());
|
|||
|
|
|
|||
|
|
if (isPathWithinRoot(resolved, resolvedBase)) {
|
|||
|
|
return { ok: true, base: resolvedBase, resolved };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Allow writing OpenChamber per-project config under ~/.config/openchamber.
|
|||
|
|
// LEGACY_PROJECT_CONFIG: migration target root; allowed outside workspace.
|
|||
|
|
if (isPathWithinRoot(resolved, OPENCHAMBER_USER_CONFIG_ROOT)) {
|
|||
|
|
return { ok: true, base: path.resolve(OPENCHAMBER_USER_CONFIG_ROOT), resolved };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { ok: false, error: 'Path is outside of active workspace' };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const resolveWorkspacePathFromWorktrees = async (targetPath, baseDirectory) => {
|
|||
|
|
const normalized = normalizeDirectoryPath(targetPath);
|
|||
|
|
if (!normalized || typeof normalized !== 'string') {
|
|||
|
|
return { ok: false, error: 'Path is required' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const resolved = path.resolve(normalized);
|
|||
|
|
const resolvedBase = path.resolve(baseDirectory || os.homedir());
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const { getWorktrees } = await import('./lib/git/index.js');
|
|||
|
|
const worktrees = await getWorktrees(resolvedBase);
|
|||
|
|
|
|||
|
|
for (const worktree of worktrees) {
|
|||
|
|
const candidatePath = typeof worktree?.path === 'string'
|
|||
|
|
? worktree.path
|
|||
|
|
: (typeof worktree?.worktree === 'string' ? worktree.worktree : '');
|
|||
|
|
const candidate = normalizeDirectoryPath(candidatePath);
|
|||
|
|
if (!candidate) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
const candidateResolved = path.resolve(candidate);
|
|||
|
|
if (isPathWithinRoot(resolved, candidateResolved)) {
|
|||
|
|
return { ok: true, base: candidateResolved, resolved };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.warn('Failed to resolve worktree roots:', error);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { ok: false, error: 'Path is outside of active workspace' };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const resolveWorkspacePathFromContext = async (req, targetPath) => {
|
|||
|
|
const resolvedProject = await resolveProjectDirectory(req);
|
|||
|
|
if (!resolvedProject.directory) {
|
|||
|
|
return { ok: false, error: resolvedProject.error || 'Active workspace is required' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const resolved = resolveWorkspacePath(targetPath, resolvedProject.directory);
|
|||
|
|
if (resolved.ok || resolved.error !== 'Path is outside of active workspace') {
|
|||
|
|
return resolved;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return resolveWorkspacePathFromWorktrees(targetPath, resolvedProject.directory);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
|
|||
|
|
const normalizeRelativeSearchPath = (rootPath, targetPath) => {
|
|||
|
|
const relative = path.relative(rootPath, targetPath) || path.basename(targetPath);
|
|||
|
|
return relative.split(path.sep).join('/') || targetPath;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const shouldSkipSearchDirectory = (name, includeHidden) => {
|
|||
|
|
if (!name) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
if (!includeHidden && name.startsWith('.')) {
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
return FILE_SEARCH_EXCLUDED_DIRS.has(name.toLowerCase());
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const listDirectoryEntries = async (dirPath) => {
|
|||
|
|
try {
|
|||
|
|
return await fsPromises.readdir(dirPath, { withFileTypes: true });
|
|||
|
|
} catch {
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Fuzzy match scoring function.
|
|||
|
|
* Returns a score > 0 if the query fuzzy-matches the candidate, null otherwise.
|
|||
|
|
* Higher scores indicate better matches.
|
|||
|
|
*/
|
|||
|
|
const fuzzyMatchScoreNormalized = (normalizedQuery, candidate) => {
|
|||
|
|
if (!normalizedQuery) return 0;
|
|||
|
|
|
|||
|
|
const q = normalizedQuery;
|
|||
|
|
const c = candidate.toLowerCase();
|
|||
|
|
|
|||
|
|
// Fast path: exact substring match gets high score
|
|||
|
|
if (c.includes(q)) {
|
|||
|
|
const idx = c.indexOf(q);
|
|||
|
|
// Bonus for match at start or after word boundary
|
|||
|
|
let bonus = 0;
|
|||
|
|
if (idx === 0) {
|
|||
|
|
bonus = 20;
|
|||
|
|
} else {
|
|||
|
|
const prev = c[idx - 1];
|
|||
|
|
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
|
|||
|
|
bonus = 15;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return 100 + bonus - Math.min(idx, 20) - Math.floor(c.length / 5);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Fuzzy match: all query chars must appear in order
|
|||
|
|
let score = 0;
|
|||
|
|
let lastIndex = -1;
|
|||
|
|
let consecutive = 0;
|
|||
|
|
|
|||
|
|
for (let i = 0; i < q.length; i++) {
|
|||
|
|
const ch = q[i];
|
|||
|
|
if (!ch || ch === ' ') continue;
|
|||
|
|
|
|||
|
|
const idx = c.indexOf(ch, lastIndex + 1);
|
|||
|
|
if (idx === -1) {
|
|||
|
|
return null; // No match
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const gap = idx - lastIndex - 1;
|
|||
|
|
if (gap === 0) {
|
|||
|
|
consecutive++;
|
|||
|
|
} else {
|
|||
|
|
consecutive = 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
score += 10;
|
|||
|
|
score += Math.max(0, 18 - idx); // Prefer matches near start
|
|||
|
|
score -= Math.min(gap, 10); // Penalize gaps
|
|||
|
|
|
|||
|
|
// Bonus for word boundary matches
|
|||
|
|
if (idx === 0) {
|
|||
|
|
score += 12;
|
|||
|
|
} else {
|
|||
|
|
const prev = c[idx - 1];
|
|||
|
|
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
|
|||
|
|
score += 10;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
score += consecutive > 0 ? 12 : 0; // Bonus for consecutive matches
|
|||
|
|
lastIndex = idx;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Prefer shorter paths
|
|||
|
|
score += Math.max(0, 24 - Math.floor(c.length / 3));
|
|||
|
|
|
|||
|
|
return score;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const searchFilesystemFiles = async (rootPath, options) => {
|
|||
|
|
const { limit, query, includeHidden, respectGitignore } = options;
|
|||
|
|
const includeHiddenEntries = Boolean(includeHidden);
|
|||
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|||
|
|
const matchAll = normalizedQuery.length === 0;
|
|||
|
|
const queue = [rootPath];
|
|||
|
|
const visited = new Set([rootPath]);
|
|||
|
|
const shouldRespectGitignore = respectGitignore !== false;
|
|||
|
|
// Collect more candidates for fuzzy matching, then sort and trim
|
|||
|
|
const collectLimit = matchAll ? limit : Math.max(limit * 3, 200);
|
|||
|
|
const candidates = [];
|
|||
|
|
|
|||
|
|
while (queue.length > 0 && candidates.length < collectLimit) {
|
|||
|
|
const batch = queue.splice(0, FILE_SEARCH_MAX_CONCURRENCY);
|
|||
|
|
|
|||
|
|
const dirResults = await Promise.all(
|
|||
|
|
batch.map(async (dir) => {
|
|||
|
|
if (!shouldRespectGitignore) {
|
|||
|
|
return { dir, dirents: await listDirectoryEntries(dir), ignoredPaths: new Set() };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const dirents = await listDirectoryEntries(dir);
|
|||
|
|
const pathsToCheck = dirents.map((dirent) => dirent.name).filter(Boolean);
|
|||
|
|
if (pathsToCheck.length === 0) {
|
|||
|
|
return { dir, dirents, ignoredPaths: new Set() };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await new Promise((resolve) => {
|
|||
|
|
const child = spawn('git', ['check-ignore', '--', ...pathsToCheck], {
|
|||
|
|
cwd: dir,
|
|||
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
let stdout = '';
|
|||
|
|
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
|||
|
|
child.on('close', () => resolve(stdout));
|
|||
|
|
child.on('error', () => resolve(''));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const ignoredNames = new Set(
|
|||
|
|
String(result)
|
|||
|
|
.split('\n')
|
|||
|
|
.map((name) => name.trim())
|
|||
|
|
.filter(Boolean)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
return { dir, dirents, ignoredPaths: ignoredNames };
|
|||
|
|
} catch {
|
|||
|
|
return { dir, dirents: await listDirectoryEntries(dir), ignoredPaths: new Set() };
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
for (const { dir: currentDir, dirents, ignoredPaths } of dirResults) {
|
|||
|
|
for (const dirent of dirents) {
|
|||
|
|
const entryName = dirent.name;
|
|||
|
|
if (!entryName || (!includeHiddenEntries && entryName.startsWith('.'))) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (shouldRespectGitignore && ignoredPaths.has(entryName)) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const entryPath = path.join(currentDir, entryName);
|
|||
|
|
|
|||
|
|
if (dirent.isDirectory()) {
|
|||
|
|
if (shouldSkipSearchDirectory(entryName, includeHiddenEntries)) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
if (!visited.has(entryPath)) {
|
|||
|
|
visited.add(entryPath);
|
|||
|
|
queue.push(entryPath);
|
|||
|
|
}
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!dirent.isFile()) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const relativePath = normalizeRelativeSearchPath(rootPath, entryPath);
|
|||
|
|
const extension = entryName.includes('.') ? entryName.split('.').pop()?.toLowerCase() : undefined;
|
|||
|
|
|
|||
|
|
if (matchAll) {
|
|||
|
|
candidates.push({
|
|||
|
|
name: entryName,
|
|||
|
|
path: entryPath,
|
|||
|
|
relativePath,
|
|||
|
|
extension,
|
|||
|
|
score: 0
|
|||
|
|
});
|
|||
|
|
} else {
|
|||
|
|
// Try fuzzy match against relative path (includes filename)
|
|||
|
|
const score = fuzzyMatchScoreNormalized(normalizedQuery, relativePath);
|
|||
|
|
if (score !== null) {
|
|||
|
|
candidates.push({
|
|||
|
|
name: entryName,
|
|||
|
|
path: entryPath,
|
|||
|
|
relativePath,
|
|||
|
|
extension,
|
|||
|
|
score
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (candidates.length >= collectLimit) {
|
|||
|
|
queue.length = 0;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (candidates.length >= collectLimit) {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Sort by score descending, then by path length, then alphabetically
|
|||
|
|
if (!matchAll) {
|
|||
|
|
candidates.sort((a, b) => {
|
|||
|
|
if (b.score !== a.score) return b.score - a.score;
|
|||
|
|
if (a.relativePath.length !== b.relativePath.length) {
|
|||
|
|
return a.relativePath.length - b.relativePath.length;
|
|||
|
|
}
|
|||
|
|
return a.relativePath.localeCompare(b.relativePath);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Return top results without the score field
|
|||
|
|
return candidates.slice(0, limit).map(({ name, path: filePath, relativePath, extension }) => ({
|
|||
|
|
name,
|
|||
|
|
path: filePath,
|
|||
|
|
relativePath,
|
|||
|
|
extension
|
|||
|
|
}));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const createTimeoutSignal = (timeoutMs) => {
|
|||
|
|
const controller = new AbortController();
|
|||
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|||
|
|
return {
|
|||
|
|
signal: controller.signal,
|
|||
|
|
cleanup: () => clearTimeout(timer),
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/** Humanize a project label: replace dashes/underscores with spaces, title-case each word. Mirrors the UI's formatProjectLabel. */
|
|||
|
|
const formatProjectLabel = (label) => {
|
|||
|
|
if (!label || typeof label !== 'string') return '';
|
|||
|
|
return label
|
|||
|
|
.replace(/[-_]/g, ' ')
|
|||
|
|
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const resolveNotificationTemplate = (template, variables) => {
|
|||
|
|
if (!template || typeof template !== 'string') return '';
|
|||
|
|
return template.replace(/\{(\w+)\}/g, (_match, key) => {
|
|||
|
|
const value = variables[key];
|
|||
|
|
if (value === undefined || value === null) return '';
|
|||
|
|
return String(value);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const shouldApplyResolvedTemplateMessage = (template, resolved, variables) => {
|
|||
|
|
if (!resolved) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (typeof template !== 'string') {
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (template.includes('{last_message}')) {
|
|||
|
|
return typeof variables?.last_message === 'string' && variables.last_message.trim().length > 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return true;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const ZEN_DEFAULT_MODEL = 'gpt-5-nano';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Validated fallback zen model determined at startup by checking available free
|
|||
|
|
* models from the zen API. When `null`, startup validation hasn't run yet (or
|
|||
|
|
* failed), so `resolveZenModel` falls back to `ZEN_DEFAULT_MODEL`.
|
|||
|
|
*/
|
|||
|
|
let validatedZenFallback = null;
|
|||
|
|
|
|||
|
|
/** Cached free zen models response and timestamp (shared by startup + endpoint). */
|
|||
|
|
let cachedZenModels = null;
|
|||
|
|
let cachedZenModelsTimestamp = 0;
|
|||
|
|
const ZEN_MODELS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Fetch free models from the zen API with caching. Returns an array of
|
|||
|
|
* `{ id, owned_by }` objects (may be empty on failure). Results are cached
|
|||
|
|
* for `ZEN_MODELS_CACHE_TTL` ms.
|
|||
|
|
*/
|
|||
|
|
const fetchFreeZenModels = async () => {
|
|||
|
|
const now = Date.now();
|
|||
|
|
if (cachedZenModels && now - cachedZenModelsTimestamp < ZEN_MODELS_CACHE_TTL) {
|
|||
|
|
return cachedZenModels.models;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|||
|
|
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null;
|
|||
|
|
try {
|
|||
|
|
const response = await fetch('https://opencode.ai/zen/v1/models', {
|
|||
|
|
signal: controller?.signal,
|
|||
|
|
headers: { Accept: 'application/json' },
|
|||
|
|
});
|
|||
|
|
if (!response.ok) {
|
|||
|
|
throw new Error(`zen/v1/models responded with status ${response.status}`);
|
|||
|
|
}
|
|||
|
|
const data = await response.json();
|
|||
|
|
const allModels = Array.isArray(data?.data) ? data.data : [];
|
|||
|
|
const freeModels = allModels
|
|||
|
|
.filter((m) => typeof m?.id === 'string' && m.id.endsWith('-free'))
|
|||
|
|
.map((m) => ({ id: m.id, owned_by: m.owned_by }));
|
|||
|
|
|
|||
|
|
cachedZenModels = { models: freeModels };
|
|||
|
|
cachedZenModelsTimestamp = Date.now();
|
|||
|
|
return freeModels;
|
|||
|
|
} finally {
|
|||
|
|
if (timeout) clearTimeout(timeout);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Resolve the zen model to use. Checks the provided override first,
|
|||
|
|
* then falls back to the stored zenModel setting, then to the validated
|
|||
|
|
* startup fallback, then to the hardcoded default.
|
|||
|
|
*/
|
|||
|
|
const resolveZenModel = async (override) => {
|
|||
|
|
if (typeof override === 'string' && override.trim().length > 0) {
|
|||
|
|
return override.trim();
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
const settings = await readSettingsFromDisk();
|
|||
|
|
if (typeof settings?.zenModel === 'string' && settings.zenModel.trim().length > 0) {
|
|||
|
|
return settings.zenModel.trim();
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
return validatedZenFallback || ZEN_DEFAULT_MODEL;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const validateZenModelAtStartup = async () => {
|
|||
|
|
try {
|
|||
|
|
const freeModels = await fetchFreeZenModels();
|
|||
|
|
const freeModelIds = freeModels.map((m) => m.id);
|
|||
|
|
|
|||
|
|
if (freeModelIds.length > 0) {
|
|||
|
|
validatedZenFallback = freeModelIds[0];
|
|||
|
|
|
|||
|
|
const settings = await readSettingsFromDisk();
|
|||
|
|
const storedModel = typeof settings?.zenModel === 'string' ? settings.zenModel.trim() : '';
|
|||
|
|
|
|||
|
|
if (!storedModel || !freeModelIds.includes(storedModel)) {
|
|||
|
|
const fallback = freeModelIds[0];
|
|||
|
|
console.log(
|
|||
|
|
storedModel
|
|||
|
|
? `[zen] Stored model "${storedModel}" not found in free models, falling back to "${fallback}"`
|
|||
|
|
: `[zen] No model configured, setting default to "${fallback}"`
|
|||
|
|
);
|
|||
|
|
await persistSettings({ zenModel: fallback });
|
|||
|
|
} else {
|
|||
|
|
console.log(`[zen] Stored model "${storedModel}" verified as available`);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
console.warn('[zen] No free models returned from API, skipping validation');
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.warn('[zen] Startup model validation failed (non-blocking):', error?.message || error);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
|
|||
|
|
const summarizeText = async (text, targetLength, zenModel) => {
|
|||
|
|
if (!text || typeof text !== 'string' || text.trim().length === 0) return text;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const prompt = `Summarize the following text in approximately ${targetLength} characters. Be concise and capture the key point. Output ONLY the summary text, nothing else.\n\nText:\n${text}`;
|
|||
|
|
|
|||
|
|
const completionTimeout = createTimeoutSignal(15000);
|
|||
|
|
let response;
|
|||
|
|
try {
|
|||
|
|
response = await fetch('https://opencode.ai/zen/v1/responses', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
model: zenModel || ZEN_DEFAULT_MODEL,
|
|||
|
|
input: [{ role: 'user', content: prompt }],
|
|||
|
|
max_output_tokens: 1000,
|
|||
|
|
stream: false,
|
|||
|
|
reasoning: { effort: 'low' },
|
|||
|
|
}),
|
|||
|
|
signal: completionTimeout.signal,
|
|||
|
|
});
|
|||
|
|
} finally {
|
|||
|
|
completionTimeout.cleanup();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!response.ok) return text;
|
|||
|
|
|
|||
|
|
const data = await response.json();
|
|||
|
|
const summary = data?.output?.find((item) => item?.type === 'message')
|
|||
|
|
?.content?.find((item) => item?.type === 'output_text')?.text?.trim();
|
|||
|
|
|
|||
|
|
return summary || text;
|
|||
|
|
} catch {
|
|||
|
|
return text;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const NOTIFICATION_BODY_MAX_CHARS = 1000;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Extract text from parts array (used when parts are available inline or fetched from API).
|
|||
|
|
*/
|
|||
|
|
const extractTextFromParts = (parts, maxLength = NOTIFICATION_BODY_MAX_CHARS) => {
|
|||
|
|
if (!Array.isArray(parts) || parts.length === 0) return '';
|
|||
|
|
|
|||
|
|
const textParts = parts
|
|||
|
|
.filter((p) => p && (p.type === 'text' || typeof p.text === 'string' || typeof p.content === 'string'))
|
|||
|
|
.map((p) => p.text || p.content || '')
|
|||
|
|
.filter(Boolean);
|
|||
|
|
|
|||
|
|
let text = textParts.length > 0 ? textParts.join('\n').trim() : '';
|
|||
|
|
|
|||
|
|
// Truncate to prevent oversized notification payloads
|
|||
|
|
if (maxLength > 0 && text.length > maxLength) {
|
|||
|
|
text = text.slice(0, maxLength);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return text;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Try to extract message text from the payload itself (fast path).
|
|||
|
|
* Note: message.updated events from the OpenCode SSE stream typically do NOT include
|
|||
|
|
* parts inline — parts are sent via separate message.part.updated events. This function
|
|||
|
|
* is a fast path for the rare case where parts are included.
|
|||
|
|
*/
|
|||
|
|
const extractLastMessageText = (payload, maxLength = NOTIFICATION_BODY_MAX_CHARS) => {
|
|||
|
|
const info = payload?.properties?.info;
|
|||
|
|
if (!info) return '';
|
|||
|
|
|
|||
|
|
// Try inline parts on info or on properties
|
|||
|
|
const parts = info.parts || payload?.properties?.parts;
|
|||
|
|
const text = extractTextFromParts(parts, maxLength);
|
|||
|
|
if (text) return text;
|
|||
|
|
|
|||
|
|
// Fallback: try content array (legacy)
|
|||
|
|
const content = info.content;
|
|||
|
|
if (Array.isArray(content)) {
|
|||
|
|
const textContent = content
|
|||
|
|
.filter((c) => c && (c.type === 'text' || typeof c.text === 'string'))
|
|||
|
|
.map((c) => c.text || '')
|
|||
|
|
.filter(Boolean);
|
|||
|
|
if (textContent.length > 0) {
|
|||
|
|
let result = textContent.join('\n').trim();
|
|||
|
|
if (maxLength > 0 && result.length > maxLength) {
|
|||
|
|
result = result.slice(0, maxLength);
|
|||
|
|
}
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return '';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Fetch the last assistant message text from the OpenCode API.
|
|||
|
|
* This is needed because message.updated events don't include parts;
|
|||
|
|
* we must fetch them separately via the session messages endpoint.
|
|||
|
|
*/
|
|||
|
|
const fetchLastAssistantMessageText = async (sessionId, messageId, maxLength = NOTIFICATION_BODY_MAX_CHARS) => {
|
|||
|
|
if (!sessionId) return '';
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// Fetch last few messages to find the one that triggered the notification
|
|||
|
|
const url = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, '');
|
|||
|
|
const response = await fetch(`${url}?limit=5`, {
|
|||
|
|
method: 'GET',
|
|||
|
|
headers: {
|
|||
|
|
Accept: 'application/json',
|
|||
|
|
...getOpenCodeAuthHeaders(),
|
|||
|
|
},
|
|||
|
|
signal: AbortSignal.timeout(3000),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) return '';
|
|||
|
|
|
|||
|
|
const messages = await response.json().catch(() => null);
|
|||
|
|
if (!Array.isArray(messages)) return '';
|
|||
|
|
|
|||
|
|
// Find the specific message by ID, or fall back to the last assistant message
|
|||
|
|
let target = null;
|
|||
|
|
if (messageId) {
|
|||
|
|
target = messages.find((m) => m?.info?.id === messageId && m?.info?.role === 'assistant');
|
|||
|
|
}
|
|||
|
|
if (!target) {
|
|||
|
|
// Find the last assistant message with finish === 'stop'
|
|||
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|||
|
|
const m = messages[i];
|
|||
|
|
if (m?.info?.role === 'assistant' && m?.info?.finish === 'stop') {
|
|||
|
|
target = m;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!target || !Array.isArray(target.parts)) return '';
|
|||
|
|
|
|||
|
|
return extractTextFromParts(target.parts, maxLength);
|
|||
|
|
} catch {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* In-memory cache of session titles populated from SSE session.updated / session.created events.
|
|||
|
|
* This is the preferred source for session titles since it is populated passively and doesn't
|
|||
|
|
* require a separate API call.
|
|||
|
|
*/
|
|||
|
|
const sessionTitleCache = new Map();
|
|||
|
|
|
|||
|
|
const cacheSessionTitle = (sessionId, title) => {
|
|||
|
|
if (typeof sessionId === 'string' && sessionId.length > 0 &&
|
|||
|
|
typeof title === 'string' && title.length > 0) {
|
|||
|
|
sessionTitleCache.set(sessionId, title);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getCachedSessionTitle = (sessionId) => {
|
|||
|
|
return sessionTitleCache.get(sessionId) ?? null;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Extract and cache session title from session.updated / session.created SSE events.
|
|||
|
|
* Called by the global event watcher to passively maintain the title cache.
|
|||
|
|
*/
|
|||
|
|
const maybeCacheSessionInfoFromEvent = (payload) => {
|
|||
|
|
if (!payload || typeof payload !== 'object') return;
|
|||
|
|
const type = payload.type;
|
|||
|
|
if (type !== 'session.updated' && type !== 'session.created') return;
|
|||
|
|
const info = payload.properties?.info;
|
|||
|
|
if (!info || typeof info !== 'object') return;
|
|||
|
|
const sessionId = info.id;
|
|||
|
|
const title = info.title;
|
|||
|
|
cacheSessionTitle(sessionId, title);
|
|||
|
|
// Also cache parentID from session events to ensure subtask detection works correctly
|
|||
|
|
const parentID = info.parentID;
|
|||
|
|
if (sessionId && parentID !== undefined) {
|
|||
|
|
setCachedSessionParentId(sessionId, parentID);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Fetch session metadata (title, directory) from the OpenCode API.
|
|||
|
|
* Cached for 60s per session to avoid repeated API calls.
|
|||
|
|
*/
|
|||
|
|
const sessionInfoCache = new Map();
|
|||
|
|
const SESSION_INFO_CACHE_TTL_MS = 60 * 1000;
|
|||
|
|
|
|||
|
|
const fetchSessionInfo = async (sessionId) => {
|
|||
|
|
if (!sessionId) return null;
|
|||
|
|
|
|||
|
|
const cached = sessionInfoCache.get(sessionId);
|
|||
|
|
if (cached && Date.now() - cached.at < SESSION_INFO_CACHE_TTL_MS) {
|
|||
|
|
return cached.data;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const url = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, '');
|
|||
|
|
const response = await fetch(url, {
|
|||
|
|
method: 'GET',
|
|||
|
|
headers: { Accept: 'application/json' },
|
|||
|
|
signal: AbortSignal.timeout(2000),
|
|||
|
|
});
|
|||
|
|
if (!response.ok) {
|
|||
|
|
console.warn(`[Notification] fetchSessionInfo: ${response.status} for session ${sessionId}`);
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
const data = await response.json().catch(() => null);
|
|||
|
|
if (data && typeof data === 'object') {
|
|||
|
|
sessionInfoCache.set(sessionId, { data, at: Date.now() });
|
|||
|
|
return data;
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
} catch (err) {
|
|||
|
|
console.warn(`[Notification] fetchSessionInfo failed for ${sessionId}:`, err?.message || err);
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const buildTemplateVariables = async (payload, sessionId) => {
|
|||
|
|
const info = payload?.properties?.info || {};
|
|||
|
|
|
|||
|
|
// Session title — try inline payload, then SSE cache, then API fetch
|
|||
|
|
let sessionTitle = payload?.properties?.sessionTitle ||
|
|||
|
|
payload?.properties?.session?.title ||
|
|||
|
|
(typeof info.sessionTitle === 'string' ? info.sessionTitle : '') ||
|
|||
|
|
'';
|
|||
|
|
|
|||
|
|
// Try the SSE-populated session title cache (filled from session.updated / session.created events)
|
|||
|
|
if (!sessionTitle && sessionId) {
|
|||
|
|
const cached = getCachedSessionTitle(sessionId);
|
|||
|
|
if (cached) {
|
|||
|
|
sessionTitle = cached;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Last resort: fetch session info from the API
|
|||
|
|
let sessionInfo = null;
|
|||
|
|
if (!sessionTitle && sessionId) {
|
|||
|
|
sessionInfo = await fetchSessionInfo(sessionId);
|
|||
|
|
if (sessionInfo && typeof sessionInfo.title === 'string') {
|
|||
|
|
sessionTitle = sessionInfo.title;
|
|||
|
|
// Populate the SSE cache so future notifications don't need an API call
|
|||
|
|
cacheSessionTitle(sessionId, sessionTitle);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Agent name from mode or agent field (v2 has both mode and agent)
|
|||
|
|
const agentName = (() => {
|
|||
|
|
const mode = typeof info.agent === 'string' && info.agent.trim().length > 0
|
|||
|
|
? info.agent.trim()
|
|||
|
|
: (typeof info.mode === 'string' ? info.mode.trim() : '');
|
|||
|
|
if (!mode) return 'Agent';
|
|||
|
|
return mode.split(/[-_\s]+/).filter(Boolean)
|
|||
|
|
.map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(' ');
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
// Model name — v2 has modelID directly on info, v1 user messages nest it under info.model.modelID
|
|||
|
|
const modelName = (() => {
|
|||
|
|
const raw = typeof info.modelID === 'string' ? info.modelID.trim()
|
|||
|
|
: (typeof info.model?.modelID === 'string' ? info.model.modelID.trim() : '');
|
|||
|
|
if (!raw) return 'Assistant';
|
|||
|
|
return raw.split(/[-_]+/).filter(Boolean)
|
|||
|
|
.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(' ');
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
// Project name, branch, worktree — derived from multiple sources with fallbacks
|
|||
|
|
let projectName = '';
|
|||
|
|
let branch = '';
|
|||
|
|
let worktreeDir = '';
|
|||
|
|
|
|||
|
|
// 1. Primary source: the message payload's path (always accurate for the session)
|
|||
|
|
const infoPath = info.path;
|
|||
|
|
if (typeof infoPath?.root === 'string' && infoPath.root.length > 0) {
|
|||
|
|
worktreeDir = infoPath.root;
|
|||
|
|
} else if (typeof infoPath?.cwd === 'string' && infoPath.cwd.length > 0) {
|
|||
|
|
worktreeDir = infoPath.cwd;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. Look up the user-facing project label from stored settings
|
|||
|
|
try {
|
|||
|
|
const settings = await readSettingsFromDisk();
|
|||
|
|
const projects = Array.isArray(settings.projects) ? settings.projects : [];
|
|||
|
|
|
|||
|
|
if (worktreeDir) {
|
|||
|
|
// Match the session directory against stored projects to find the label
|
|||
|
|
const normalizedDir = worktreeDir.replace(/\/+$/, '');
|
|||
|
|
const matchedProject = projects.find((p) => {
|
|||
|
|
if (!p || typeof p.path !== 'string') return false;
|
|||
|
|
return p.path.replace(/\/+$/, '') === normalizedDir;
|
|||
|
|
});
|
|||
|
|
if (matchedProject && typeof matchedProject.label === 'string' && matchedProject.label.trim().length > 0) {
|
|||
|
|
projectName = matchedProject.label.trim();
|
|||
|
|
} else {
|
|||
|
|
// No label stored — derive from directory name
|
|||
|
|
projectName = normalizedDir.split('/').filter(Boolean).pop() || '';
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// No directory from payload — fall back to active project
|
|||
|
|
const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : '';
|
|||
|
|
const activeProject = activeId ? projects.find((p) => p && p.id === activeId) : projects[0];
|
|||
|
|
if (activeProject) {
|
|||
|
|
projectName = typeof activeProject.label === 'string' && activeProject.label.trim().length > 0
|
|||
|
|
? activeProject.label.trim()
|
|||
|
|
: typeof activeProject.path === 'string'
|
|||
|
|
? activeProject.path.split('/').pop() || ''
|
|||
|
|
: '';
|
|||
|
|
worktreeDir = typeof activeProject.path === 'string' ? activeProject.path : '';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// Settings read failed — derive from directory if available
|
|||
|
|
if (worktreeDir && !projectName) {
|
|||
|
|
projectName = worktreeDir.split('/').filter(Boolean).pop() || '';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. Get branch from git
|
|||
|
|
if (worktreeDir) {
|
|||
|
|
try {
|
|||
|
|
const { simpleGit } = await import('simple-git');
|
|||
|
|
const git = simpleGit(worktreeDir);
|
|||
|
|
branch = await Promise.race([
|
|||
|
|
git.revparse(['--abbrev-ref', 'HEAD']),
|
|||
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error('git timeout')), 3000)),
|
|||
|
|
]).catch(() => '');
|
|||
|
|
} catch {
|
|||
|
|
// ignore — git may not be available
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
project_name: formatProjectLabel(projectName),
|
|||
|
|
worktree: worktreeDir,
|
|||
|
|
branch: typeof branch === 'string' ? branch.trim() : '',
|
|||
|
|
session_name: sessionTitle,
|
|||
|
|
agent_name: agentName,
|
|||
|
|
model_name: modelName,
|
|||
|
|
last_message: '', // Populated by caller
|
|||
|
|
session_id: sessionId || '',
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
|
|||
|
|
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
|
|||
|
|
: path.join(os.homedir(), '.config', 'openchamber');
|
|||
|
|
const SETTINGS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
|
|||
|
|
const PUSH_SUBSCRIPTIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'push-subscriptions.json');
|
|||
|
|
const PROJECT_ICONS_DIR_PATH = path.join(OPENCHAMBER_DATA_DIR, 'project-icons');
|
|||
|
|
const PROJECT_ICON_MIME_TO_EXTENSION = {
|
|||
|
|
'image/png': 'png',
|
|||
|
|
'image/jpeg': 'jpg',
|
|||
|
|
'image/svg+xml': 'svg',
|
|||
|
|
'image/webp': 'webp',
|
|||
|
|
'image/x-icon': 'ico',
|
|||
|
|
};
|
|||
|
|
const PROJECT_ICON_EXTENSION_TO_MIME = Object.fromEntries(
|
|||
|
|
Object.entries(PROJECT_ICON_MIME_TO_EXTENSION).map(([mime, ext]) => [ext, mime])
|
|||
|
|
);
|
|||
|
|
const PROJECT_ICON_SUPPORTED_MIMES = new Set(Object.keys(PROJECT_ICON_MIME_TO_EXTENSION));
|
|||
|
|
const PROJECT_ICON_MAX_BYTES = 5 * 1024 * 1024;
|
|||
|
|
const PROJECT_ICON_THEME_COLORS = {
|
|||
|
|
light: '#111111',
|
|||
|
|
dark: '#f5f5f5',
|
|||
|
|
};
|
|||
|
|
const PROJECT_ICON_HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{4}|[\da-fA-F]{6}|[\da-fA-F]{8})$/;
|
|||
|
|
|
|||
|
|
const normalizeProjectIconMime = (value) => {
|
|||
|
|
if (typeof value !== 'string') {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const normalized = value.trim().toLowerCase();
|
|||
|
|
if (normalized === 'image/jpg') {
|
|||
|
|
return 'image/jpeg';
|
|||
|
|
}
|
|||
|
|
if (PROJECT_ICON_SUPPORTED_MIMES.has(normalized)) {
|
|||
|
|
return normalized;
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const projectIconBaseName = (projectId) => {
|
|||
|
|
const hash = crypto.createHash('sha1').update(projectId).digest('hex');
|
|||
|
|
return `project-${hash}`;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const projectIconPathForMime = (projectId, mime) => {
|
|||
|
|
const normalizedMime = normalizeProjectIconMime(mime);
|
|||
|
|
if (!normalizedMime) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
const ext = PROJECT_ICON_MIME_TO_EXTENSION[normalizedMime];
|
|||
|
|
return path.join(PROJECT_ICONS_DIR_PATH, `${projectIconBaseName(projectId)}.${ext}`);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const projectIconPathCandidates = (projectId) => {
|
|||
|
|
const base = projectIconBaseName(projectId);
|
|||
|
|
return Object.values(PROJECT_ICON_MIME_TO_EXTENSION).map((ext) => path.join(PROJECT_ICONS_DIR_PATH, `${base}.${ext}`));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const removeProjectIconFiles = async (projectId, keepPath) => {
|
|||
|
|
const candidates = projectIconPathCandidates(projectId);
|
|||
|
|
await Promise.all(candidates.map(async (candidatePath) => {
|
|||
|
|
if (keepPath && candidatePath === keepPath) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
await fsPromises.unlink(candidatePath);
|
|||
|
|
} catch (error) {
|
|||
|
|
if (!error || typeof error !== 'object' || error.code !== 'ENOENT') {
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const parseProjectIconDataUrl = (value) => {
|
|||
|
|
if (typeof value !== 'string') {
|
|||
|
|
return { ok: false, error: 'dataUrl is required' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const trimmed = value.trim();
|
|||
|
|
const match = trimmed.match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/i);
|
|||
|
|
if (!match) {
|
|||
|
|
return { ok: false, error: 'Invalid dataUrl format' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const mime = normalizeProjectIconMime(match[1]);
|
|||
|
|
if (!mime || !['image/png', 'image/jpeg', 'image/svg+xml'].includes(mime)) {
|
|||
|
|
return { ok: false, error: 'Icon must be PNG, JPEG, or SVG' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const base64 = match[2].replace(/\s+/g, '');
|
|||
|
|
const bytes = Buffer.from(base64, 'base64');
|
|||
|
|
if (bytes.length === 0) {
|
|||
|
|
return { ok: false, error: 'Icon content is empty' };
|
|||
|
|
}
|
|||
|
|
if (bytes.length > PROJECT_ICON_MAX_BYTES) {
|
|||
|
|
return { ok: false, error: 'Icon exceeds size limit (5 MB)' };
|
|||
|
|
}
|
|||
|
|
return { ok: true, mime, bytes };
|
|||
|
|
} catch {
|
|||
|
|
return { ok: false, error: 'Failed to decode icon data' };
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const normalizeProjectIconThemeVariant = (value) => {
|
|||
|
|
if (typeof value !== 'string') {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const normalized = value.trim().toLowerCase();
|
|||
|
|
if (normalized === 'light' || normalized === 'dark') {
|
|||
|
|
return normalized;
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const normalizeProjectIconColor = (value) => {
|
|||
|
|
if (typeof value !== 'string') {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const normalized = value.trim();
|
|||
|
|
if (!PROJECT_ICON_HEX_COLOR_PATTERN.test(normalized)) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
return normalized;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const applyProjectIconSvgTheme = (svgMarkup, themeVariant, iconColor) => {
|
|||
|
|
if (typeof svgMarkup !== 'string') {
|
|||
|
|
return svgMarkup;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const color = iconColor || PROJECT_ICON_THEME_COLORS[themeVariant];
|
|||
|
|
if (!color) {
|
|||
|
|
return svgMarkup;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const svgTagIndex = svgMarkup.search(/<svg\b/i);
|
|||
|
|
if (svgTagIndex === -1) {
|
|||
|
|
return svgMarkup;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const svgOpenTagEndIndex = svgMarkup.indexOf('>', svgTagIndex);
|
|||
|
|
if (svgOpenTagEndIndex === -1) {
|
|||
|
|
return svgMarkup;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const overrideStyle = `<style data-openchamber-theme-icon="1">:root{color:${color}!important;}</style>`;
|
|||
|
|
return `${svgMarkup.slice(0, svgOpenTagEndIndex + 1)}${overrideStyle}${svgMarkup.slice(svgOpenTagEndIndex + 1)}`;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const findProjectById = (settings, projectId) => {
|
|||
|
|
const projects = sanitizeProjects(settings?.projects) || [];
|
|||
|
|
const index = projects.findIndex((project) => project.id === projectId);
|
|||
|
|
if (index === -1) {
|
|||
|
|
return { projects, index: -1, project: null };
|
|||
|
|
}
|
|||
|
|
return { projects, index, project: projects[index] };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const readSettingsFromDisk = async () => {
|
|||
|
|
try {
|
|||
|
|
const raw = await fsPromises.readFile(SETTINGS_FILE_PATH, 'utf8');
|
|||
|
|
const parsed = JSON.parse(raw);
|
|||
|
|
if (parsed && typeof parsed === 'object') {
|
|||
|
|
return parsed;
|
|||
|
|
}
|
|||
|
|
return {};
|
|||
|
|
} catch (error) {
|
|||
|
|
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
|||
|
|
return {};
|
|||
|
|
}
|
|||
|
|
console.warn('Failed to read settings file:', error);
|
|||
|
|
return {};
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const writeSettingsToDisk = async (settings) => {
|
|||
|
|
try {
|
|||
|
|
await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true });
|
|||
|
|
await fsPromises.writeFile(SETTINGS_FILE_PATH, JSON.stringify(settings, null, 2), 'utf8');
|
|||
|
|
} catch (error) {
|
|||
|
|
console.warn('Failed to write settings file:', error);
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const PUSH_SUBSCRIPTIONS_VERSION = 1;
|
|||
|
|
let persistPushSubscriptionsLock = Promise.resolve();
|
|||
|
|
|
|||
|
|
const readPushSubscriptionsFromDisk = async () => {
|
|||
|
|
try {
|
|||
|
|
const raw = await fsPromises.readFile(PUSH_SUBSCRIPTIONS_FILE_PATH, 'utf8');
|
|||
|
|
const parsed = JSON.parse(raw);
|
|||
|
|
if (!parsed || typeof parsed !== 'object') {
|
|||
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} };
|
|||
|
|
}
|
|||
|
|
if (typeof parsed.version !== 'number' || parsed.version !== PUSH_SUBSCRIPTIONS_VERSION) {
|
|||
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const subscriptionsBySession =
|
|||
|
|
parsed.subscriptionsBySession && typeof parsed.subscriptionsBySession === 'object'
|
|||
|
|
? parsed.subscriptionsBySession
|
|||
|
|
: {};
|
|||
|
|
|
|||
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession };
|
|||
|
|
} catch (error) {
|
|||
|
|
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
|||
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} };
|
|||
|
|
}
|
|||
|
|
console.warn('Failed to read push subscriptions file:', error);
|
|||
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: {} };
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const writePushSubscriptionsToDisk = async (data) => {
|
|||
|
|
await fsPromises.mkdir(path.dirname(PUSH_SUBSCRIPTIONS_FILE_PATH), { recursive: true });
|
|||
|
|
await fsPromises.writeFile(PUSH_SUBSCRIPTIONS_FILE_PATH, JSON.stringify(data, null, 2), 'utf8');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const persistPushSubscriptionUpdate = async (mutate) => {
|
|||
|
|
persistPushSubscriptionsLock = persistPushSubscriptionsLock.then(async () => {
|
|||
|
|
await fsPromises.mkdir(path.dirname(PUSH_SUBSCRIPTIONS_FILE_PATH), { recursive: true });
|
|||
|
|
const current = await readPushSubscriptionsFromDisk();
|
|||
|
|
const next = mutate({
|
|||
|
|
version: PUSH_SUBSCRIPTIONS_VERSION,
|
|||
|
|
subscriptionsBySession: current.subscriptionsBySession || {},
|
|||
|
|
});
|
|||
|
|
await writePushSubscriptionsToDisk(next);
|
|||
|
|
return next;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return persistPushSubscriptionsLock;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const resolveDirectoryCandidate = (value) => {
|
|||
|
|
if (typeof value !== 'string') {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
const trimmed = value.trim();
|
|||
|
|
if (!trimmed) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
const normalized = normalizeDirectoryPath(trimmed);
|
|||
|
|
return path.resolve(normalized);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const validateDirectoryPath = async (candidate) => {
|
|||
|
|
const resolved = resolveDirectoryCandidate(candidate);
|
|||
|
|
if (!resolved) {
|
|||
|
|
return { ok: false, error: 'Directory parameter is required' };
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
const stats = await fsPromises.stat(resolved);
|
|||
|
|
if (!stats.isDirectory()) {
|
|||
|
|
return { ok: false, error: 'Specified path is not a directory' };
|
|||
|
|
}
|
|||
|
|
return { ok: true, directory: resolved };
|
|||
|
|
} catch (error) {
|
|||
|
|
const err = error;
|
|||
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|||
|
|
return { ok: false, error: 'Directory not found' };
|
|||
|
|
}
|
|||
|
|
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
|||
|
|
return { ok: false, error: 'Access to directory denied' };
|
|||
|
|
}
|
|||
|
|
return { ok: false, error: 'Failed to validate directory' };
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const resolveProjectDirectory = async (req) => {
|
|||
|
|
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
|||
|
|
const queryDirectory = Array.isArray(req.query?.directory)
|
|||
|
|
? req.query.directory[0]
|
|||
|
|
: req.query?.directory;
|
|||
|
|
const requested = headerDirectory || queryDirectory || null;
|
|||
|
|
|
|||
|
|
if (requested) {
|
|||
|
|
const validated = await validateDirectoryPath(requested);
|
|||
|
|
if (!validated.ok) {
|
|||
|
|
return { directory: null, error: validated.error };
|
|||
|
|
}
|
|||
|
|
return { directory: validated.directory, error: null };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const settings = await readSettingsFromDiskMigrated();
|
|||
|
|
const projects = sanitizeProjects(settings.projects) || [];
|
|||
|
|
if (projects.length === 0) {
|
|||
|
|
return { directory: null, error: 'Directory parameter or active project is required' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : '';
|
|||
|
|
const active = projects.find((project) => project.id === activeId) || projects[0];
|
|||
|
|
if (!active || !active.path) {
|
|||
|
|
return { directory: null, error: 'Directory parameter or active project is required' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const validated = await validateDirectoryPath(active.path);
|
|||
|
|
if (!validated.ok) {
|
|||
|
|
return { directory: null, error: validated.error };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { directory: validated.directory, error: null };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const isUnsafeSkillRelativePath = (value) => {
|
|||
|
|
if (typeof value !== 'string' || value.length === 0) {
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const normalized = value.replace(/\\/g, '/');
|
|||
|
|
if (path.posix.isAbsolute(normalized)) {
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return normalized.split('/').some((segment) => segment === '..');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const resolveOptionalProjectDirectory = async (req) => {
|
|||
|
|
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
|||
|
|
const queryDirectory = Array.isArray(req.query?.directory)
|
|||
|
|
? req.query.directory[0]
|
|||
|
|
: req.query?.directory;
|
|||
|
|
const requested = headerDirectory || queryDirectory || null;
|
|||
|
|
|
|||
|
|
if (!requested) {
|
|||
|
|
return { directory: null, error: null };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const validated = await validateDirectoryPath(requested);
|
|||
|
|
if (!validated.ok) {
|
|||
|
|
return { directory: null, error: validated.error };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { directory: validated.directory, error: null };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const sanitizeTypographySizesPartial = (input) => {
|
|||
|
|
if (!input || typeof input !== 'object') {
|
|||
|
|
return undefined;
|
|||
|
|
}
|
|||
|
|
const candidate = input;
|
|||
|
|
const result = {};
|
|||
|
|
let populated = false;
|
|||
|
|
|
|||
|
|
const assign = (key) => {
|
|||
|
|
if (typeof candidate[key] === 'string' && candidate[key].length > 0) {
|
|||
|
|
result[key] = candidate[key];
|
|||
|
|
populated = true;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
assign('markdown');
|
|||
|
|
assign('code');
|
|||
|
|
assign('uiHeader');
|
|||
|
|
assign('uiLabel');
|
|||
|
|
assign('meta');
|
|||
|
|
assign('micro');
|
|||
|
|
|
|||
|
|
return populated ? result : undefined;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const normalizeStringArray = (input) => {
|
|||
|
|
if (!Array.isArray(input)) {
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
return Array.from(
|
|||
|
|
new Set(
|
|||
|
|
input.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|||
|
|
)
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const sanitizeModelRefs = (input, limit) => {
|
|||
|
|
if (!Array.isArray(input)) {
|
|||
|
|
return undefined;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = [];
|
|||
|
|
const seen = new Set();
|
|||
|
|
|
|||
|
|
for (const entry of input) {
|
|||
|
|
if (!entry || typeof entry !== 'object') continue;
|
|||
|
|
const providerID = typeof entry.providerID === 'string' ? entry.providerID.trim() : '';
|
|||
|
|
const modelID = typeof entry.modelID === 'string' ? entry.modelID.trim() : '';
|
|||
|
|
if (!providerID || !modelID) continue;
|
|||
|
|
const key = `${providerID}/${modelID}`;
|
|||
|
|
if (seen.has(key)) continue;
|
|||
|
|
seen.add(key);
|
|||
|
|
result.push({ providerID, modelID });
|
|||
|
|
if (result.length >= limit) break;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const sanitizeSkillCatalogs = (input) => {
|
|||
|
|
if (!Array.isArray(input)) {
|
|||
|
|
return undefined;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = [];
|
|||
|
|
const seen = new Set();
|
|||
|
|
|
|||
|
|
for (const entry of input) {
|
|||
|
|
if (!entry || typeof entry !== 'object') continue;
|
|||
|
|
|
|||
|
|
const id = typeof entry.id === 'string' ? entry.id.trim() : '';
|
|||
|
|
const label = typeof entry.label === 'string' ? entry.label.trim() : '';
|
|||
|
|
const source = typeof entry.source === 'string' ? entry.source.trim() : '';
|
|||
|
|
const subpath = typeof entry.subpath === 'string' ? entry.subpath.trim() : '';
|
|||
|
|
const gitIdentityId = typeof entry.gitIdentityId === 'string' ? entry.gitIdentityId.trim() : '';
|
|||
|
|
|
|||
|
|
if (!id || !label || !source) continue;
|
|||
|
|
if (seen.has(id)) continue;
|
|||
|
|
seen.add(id);
|
|||
|
|
|
|||
|
|
result.push({
|
|||
|
|
id,
|
|||
|
|
label,
|
|||
|
|
source,
|
|||
|
|
...(subpath ? { subpath } : {}),
|
|||
|
|
...(gitIdentityId ? { gitIdentityId } : {}),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const sanitizeProjects = (input) => {
|
|||
|
|
if (!Array.isArray(input)) {
|
|||
|
|
return undefined;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const hexColorPattern = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/;
|
|||
|
|
const normalizeIconBackground = (value) => {
|
|||
|
|
if (typeof value !== 'string') {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
const trimmed = value.trim();
|
|||
|
|
if (!trimmed) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
return hexColorPattern.test(trimmed) ? trimmed.toLowerCase() : null;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const result = [];
|
|||
|
|
const seenIds = new Set();
|
|||
|
|
const seenPaths = new Set();
|
|||
|
|
|
|||
|
|
for (const entry of input) {
|
|||
|
|
if (!entry || typeof entry !== 'object') continue;
|
|||
|
|
|
|||
|
|
const candidate = entry;
|
|||
|
|
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
|||
|
|
const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
|
|||
|
|
const normalizedPath = rawPath ? path.resolve(normalizeDirectoryPath(rawPath)) : '';
|
|||
|
|
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
|
|||
|
|
const icon = typeof candidate.icon === 'string' ? candidate.icon.trim() : '';
|
|||
|
|
const iconImage = candidate.iconImage && typeof candidate.iconImage === 'object'
|
|||
|
|
? candidate.iconImage
|
|||
|
|
: null;
|
|||
|
|
const iconBackground = normalizeIconBackground(candidate.iconBackground);
|
|||
|
|
const color = typeof candidate.color === 'string' ? candidate.color.trim() : '';
|
|||
|
|
const addedAt = Number.isFinite(candidate.addedAt) ? Number(candidate.addedAt) : null;
|
|||
|
|
const lastOpenedAt = Number.isFinite(candidate.lastOpenedAt)
|
|||
|
|
? Number(candidate.lastOpenedAt)
|
|||
|
|
: null;
|
|||
|
|
|
|||
|
|
if (!id || !normalizedPath) continue;
|
|||
|
|
if (seenIds.has(id)) continue;
|
|||
|
|
if (seenPaths.has(normalizedPath)) continue;
|
|||
|
|
|
|||
|
|
seenIds.add(id);
|
|||
|
|
seenPaths.add(normalizedPath);
|
|||
|
|
|
|||
|
|
const project = {
|
|||
|
|
id,
|
|||
|
|
path: normalizedPath,
|
|||
|
|
...(label ? { label } : {}),
|
|||
|
|
...(icon ? { icon } : {}),
|
|||
|
|
...(iconBackground ? { iconBackground } : {}),
|
|||
|
|
...(color ? { color } : {}),
|
|||
|
|
...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}),
|
|||
|
|
...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (candidate.iconImage === null) {
|
|||
|
|
project.iconImage = null;
|
|||
|
|
} else if (iconImage) {
|
|||
|
|
const mime = typeof iconImage.mime === 'string' ? iconImage.mime.trim() : '';
|
|||
|
|
const updatedAt = typeof iconImage.updatedAt === 'number' && Number.isFinite(iconImage.updatedAt)
|
|||
|
|
? Math.max(0, Math.round(iconImage.updatedAt))
|
|||
|
|
: 0;
|
|||
|
|
const source = iconImage.source === 'custom' || iconImage.source === 'auto'
|
|||
|
|
? iconImage.source
|
|||
|
|
: null;
|
|||
|
|
if (mime && updatedAt > 0 && source) {
|
|||
|
|
project.iconImage = { mime, updatedAt, source };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (candidate.iconBackground === null) {
|
|||
|
|
project.iconBackground = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (typeof candidate.sidebarCollapsed === 'boolean') {
|
|||
|
|
project.sidebarCollapsed = candidate.sidebarCollapsed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
result.push(project);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const sanitizeSettingsUpdate = (payload) => {
|
|||
|
|
if (!payload || typeof payload !== 'object') {
|
|||
|
|
return {};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const candidate = payload;
|
|||
|
|
const result = {};
|
|||
|
|
|
|||
|
|
if (typeof candidate.themeId === 'string' && candidate.themeId.length > 0) {
|
|||
|
|
result.themeId = candidate.themeId;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.themeVariant === 'string' && (candidate.themeVariant === 'light' || candidate.themeVariant === 'dark')) {
|
|||
|
|
result.themeVariant = candidate.themeVariant;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.useSystemTheme === 'boolean') {
|
|||
|
|
result.useSystemTheme = candidate.useSystemTheme;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.lightThemeId === 'string' && candidate.lightThemeId.length > 0) {
|
|||
|
|
result.lightThemeId = candidate.lightThemeId;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.darkThemeId === 'string' && candidate.darkThemeId.length > 0) {
|
|||
|
|
result.darkThemeId = candidate.darkThemeId;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.splashBgLight === 'string' && candidate.splashBgLight.trim().length > 0) {
|
|||
|
|
result.splashBgLight = candidate.splashBgLight.trim();
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.splashFgLight === 'string' && candidate.splashFgLight.trim().length > 0) {
|
|||
|
|
result.splashFgLight = candidate.splashFgLight.trim();
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.splashBgDark === 'string' && candidate.splashBgDark.trim().length > 0) {
|
|||
|
|
result.splashBgDark = candidate.splashBgDark.trim();
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.splashFgDark === 'string' && candidate.splashFgDark.trim().length > 0) {
|
|||
|
|
result.splashFgDark = candidate.splashFgDark.trim();
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.lastDirectory === 'string' && candidate.lastDirectory.length > 0) {
|
|||
|
|
result.lastDirectory = candidate.lastDirectory;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) {
|
|||
|
|
result.homeDirectory = candidate.homeDirectory;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Absolute path to the opencode CLI binary (optional override).
|
|||
|
|
// Accept empty-string to clear (we persist an empty string sentinel so the running
|
|||
|
|
// process can reliably drop a previously applied OPENCODE_BINARY override).
|
|||
|
|
if (typeof candidate.opencodeBinary === 'string') {
|
|||
|
|
const normalized = normalizeDirectoryPath(candidate.opencodeBinary).trim();
|
|||
|
|
result.opencodeBinary = normalized;
|
|||
|
|
}
|
|||
|
|
if (Array.isArray(candidate.projects)) {
|
|||
|
|
const projects = sanitizeProjects(candidate.projects);
|
|||
|
|
if (projects) {
|
|||
|
|
result.projects = projects;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) {
|
|||
|
|
result.activeProjectId = candidate.activeProjectId;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (Array.isArray(candidate.approvedDirectories)) {
|
|||
|
|
result.approvedDirectories = normalizeStringArray(candidate.approvedDirectories);
|
|||
|
|
}
|
|||
|
|
if (Array.isArray(candidate.securityScopedBookmarks)) {
|
|||
|
|
result.securityScopedBookmarks = normalizeStringArray(candidate.securityScopedBookmarks);
|
|||
|
|
}
|
|||
|
|
if (Array.isArray(candidate.pinnedDirectories)) {
|
|||
|
|
result.pinnedDirectories = normalizeStringArray(candidate.pinnedDirectories);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
if (typeof candidate.uiFont === 'string' && candidate.uiFont.length > 0) {
|
|||
|
|
result.uiFont = candidate.uiFont;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.monoFont === 'string' && candidate.monoFont.length > 0) {
|
|||
|
|
result.monoFont = candidate.monoFont;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.markdownDisplayMode === 'string' && candidate.markdownDisplayMode.length > 0) {
|
|||
|
|
result.markdownDisplayMode = candidate.markdownDisplayMode;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.githubClientId === 'string') {
|
|||
|
|
const trimmed = candidate.githubClientId.trim();
|
|||
|
|
if (trimmed.length > 0) {
|
|||
|
|
result.githubClientId = trimmed;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.githubScopes === 'string') {
|
|||
|
|
const trimmed = candidate.githubScopes.trim();
|
|||
|
|
if (trimmed.length > 0) {
|
|||
|
|
result.githubScopes = trimmed;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.showReasoningTraces === 'boolean') {
|
|||
|
|
result.showReasoningTraces = candidate.showReasoningTraces;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.showTextJustificationActivity === 'boolean') {
|
|||
|
|
result.showTextJustificationActivity = candidate.showTextJustificationActivity;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.showDeletionDialog === 'boolean') {
|
|||
|
|
result.showDeletionDialog = candidate.showDeletionDialog;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.nativeNotificationsEnabled === 'boolean') {
|
|||
|
|
result.nativeNotificationsEnabled = candidate.nativeNotificationsEnabled;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.notificationMode === 'string') {
|
|||
|
|
const mode = candidate.notificationMode.trim();
|
|||
|
|
if (mode === 'always' || mode === 'hidden-only') {
|
|||
|
|
result.notificationMode = mode;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.notifyOnSubtasks === 'boolean') {
|
|||
|
|
result.notifyOnSubtasks = candidate.notifyOnSubtasks;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.notifyOnCompletion === 'boolean') {
|
|||
|
|
result.notifyOnCompletion = candidate.notifyOnCompletion;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.notifyOnError === 'boolean') {
|
|||
|
|
result.notifyOnError = candidate.notifyOnError;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.notifyOnQuestion === 'boolean') {
|
|||
|
|
result.notifyOnQuestion = candidate.notifyOnQuestion;
|
|||
|
|
}
|
|||
|
|
if (candidate.notificationTemplates && typeof candidate.notificationTemplates === 'object') {
|
|||
|
|
result.notificationTemplates = candidate.notificationTemplates;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.summarizeLastMessage === 'boolean') {
|
|||
|
|
result.summarizeLastMessage = candidate.summarizeLastMessage;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.summaryThreshold === 'number' && Number.isFinite(candidate.summaryThreshold)) {
|
|||
|
|
result.summaryThreshold = Math.max(0, Math.round(candidate.summaryThreshold));
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.summaryLength === 'number' && Number.isFinite(candidate.summaryLength)) {
|
|||
|
|
result.summaryLength = Math.max(10, Math.round(candidate.summaryLength));
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.maxLastMessageLength === 'number' && Number.isFinite(candidate.maxLastMessageLength)) {
|
|||
|
|
result.maxLastMessageLength = Math.max(10, Math.round(candidate.maxLastMessageLength));
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.usageAutoRefresh === 'boolean') {
|
|||
|
|
result.usageAutoRefresh = candidate.usageAutoRefresh;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.usageRefreshIntervalMs === 'number' && Number.isFinite(candidate.usageRefreshIntervalMs)) {
|
|||
|
|
result.usageRefreshIntervalMs = Math.max(30000, Math.min(300000, Math.round(candidate.usageRefreshIntervalMs)));
|
|||
|
|
}
|
|||
|
|
if (candidate.usageDisplayMode === 'usage' || candidate.usageDisplayMode === 'remaining') {
|
|||
|
|
result.usageDisplayMode = candidate.usageDisplayMode;
|
|||
|
|
}
|
|||
|
|
if (Array.isArray(candidate.usageDropdownProviders)) {
|
|||
|
|
result.usageDropdownProviders = normalizeStringArray(candidate.usageDropdownProviders);
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.autoDeleteEnabled === 'boolean') {
|
|||
|
|
result.autoDeleteEnabled = candidate.autoDeleteEnabled;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) {
|
|||
|
|
const normalizedDays = Math.max(1, Math.min(365, Math.round(candidate.autoDeleteAfterDays)));
|
|||
|
|
result.autoDeleteAfterDays = normalizedDays;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const typography = sanitizeTypographySizesPartial(candidate.typographySizes);
|
|||
|
|
if (typography) {
|
|||
|
|
result.typographySizes = typography;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (typeof candidate.defaultModel === 'string') {
|
|||
|
|
const trimmed = candidate.defaultModel.trim();
|
|||
|
|
result.defaultModel = trimmed.length > 0 ? trimmed : undefined;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.defaultVariant === 'string') {
|
|||
|
|
const trimmed = candidate.defaultVariant.trim();
|
|||
|
|
result.defaultVariant = trimmed.length > 0 ? trimmed : undefined;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.defaultAgent === 'string') {
|
|||
|
|
const trimmed = candidate.defaultAgent.trim();
|
|||
|
|
result.defaultAgent = trimmed.length > 0 ? trimmed : undefined;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.defaultGitIdentityId === 'string') {
|
|||
|
|
const trimmed = candidate.defaultGitIdentityId.trim();
|
|||
|
|
result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.queueModeEnabled === 'boolean') {
|
|||
|
|
result.queueModeEnabled = candidate.queueModeEnabled;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.autoCreateWorktree === 'boolean') {
|
|||
|
|
result.autoCreateWorktree = candidate.autoCreateWorktree;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.gitmojiEnabled === 'boolean') {
|
|||
|
|
result.gitmojiEnabled = candidate.gitmojiEnabled;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.zenModel === 'string') {
|
|||
|
|
const trimmed = candidate.zenModel.trim();
|
|||
|
|
result.zenModel = trimmed.length > 0 ? trimmed : undefined;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.gitProviderId === 'string') {
|
|||
|
|
const trimmed = candidate.gitProviderId.trim();
|
|||
|
|
result.gitProviderId = trimmed.length > 0 ? trimmed : undefined;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.gitModelId === 'string') {
|
|||
|
|
const trimmed = candidate.gitModelId.trim();
|
|||
|
|
result.gitModelId = trimmed.length > 0 ? trimmed : undefined;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.toolCallExpansion === 'string') {
|
|||
|
|
const mode = candidate.toolCallExpansion.trim();
|
|||
|
|
if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed' || mode === 'changes') {
|
|||
|
|
result.toolCallExpansion = mode;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.inputSpellcheckEnabled === 'boolean') {
|
|||
|
|
result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.userMessageRenderingMode === 'string') {
|
|||
|
|
const mode = candidate.userMessageRenderingMode.trim();
|
|||
|
|
if (mode === 'markdown' || mode === 'plain') {
|
|||
|
|
result.userMessageRenderingMode = mode;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.stickyUserHeader === 'boolean') {
|
|||
|
|
result.stickyUserHeader = candidate.stickyUserHeader;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.fontSize === 'number' && Number.isFinite(candidate.fontSize)) {
|
|||
|
|
result.fontSize = Math.max(50, Math.min(200, Math.round(candidate.fontSize)));
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.terminalFontSize === 'number' && Number.isFinite(candidate.terminalFontSize)) {
|
|||
|
|
result.terminalFontSize = Math.max(9, Math.min(52, Math.round(candidate.terminalFontSize)));
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.padding === 'number' && Number.isFinite(candidate.padding)) {
|
|||
|
|
result.padding = Math.max(50, Math.min(200, Math.round(candidate.padding)));
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.cornerRadius === 'number' && Number.isFinite(candidate.cornerRadius)) {
|
|||
|
|
result.cornerRadius = Math.max(0, Math.min(32, Math.round(candidate.cornerRadius)));
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.inputBarOffset === 'number' && Number.isFinite(candidate.inputBarOffset)) {
|
|||
|
|
result.inputBarOffset = Math.max(0, Math.min(100, Math.round(candidate.inputBarOffset)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const favoriteModels = sanitizeModelRefs(candidate.favoriteModels, 64);
|
|||
|
|
if (favoriteModels) {
|
|||
|
|
result.favoriteModels = favoriteModels;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const recentModels = sanitizeModelRefs(candidate.recentModels, 16);
|
|||
|
|
if (recentModels) {
|
|||
|
|
result.recentModels = recentModels;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.diffLayoutPreference === 'string') {
|
|||
|
|
const mode = candidate.diffLayoutPreference.trim();
|
|||
|
|
if (mode === 'dynamic' || mode === 'inline' || mode === 'side-by-side') {
|
|||
|
|
result.diffLayoutPreference = mode;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.diffViewMode === 'string') {
|
|||
|
|
const mode = candidate.diffViewMode.trim();
|
|||
|
|
if (mode === 'single' || mode === 'stacked') {
|
|||
|
|
result.diffViewMode = mode;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.directoryShowHidden === 'boolean') {
|
|||
|
|
result.directoryShowHidden = candidate.directoryShowHidden;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.filesViewShowGitignored === 'boolean') {
|
|||
|
|
result.filesViewShowGitignored = candidate.filesViewShowGitignored;
|
|||
|
|
}
|
|||
|
|
if (typeof candidate.openInAppId === 'string') {
|
|||
|
|
const trimmed = candidate.openInAppId.trim();
|
|||
|
|
if (trimmed.length > 0) {
|
|||
|
|
result.openInAppId = trimmed;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Message limit — single setting for fetch / trim / Load More chunk
|
|||
|
|
if (typeof candidate.messageLimit === 'number' && Number.isFinite(candidate.messageLimit)) {
|
|||
|
|
result.messageLimit = Math.max(10, Math.min(500, Math.round(candidate.messageLimit)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
|
|||
|
|
if (skillCatalogs) {
|
|||
|
|
result.skillCatalogs = skillCatalogs;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Usage model selections - which models appear in dropdown
|
|||
|
|
if (candidate.usageSelectedModels && typeof candidate.usageSelectedModels === 'object') {
|
|||
|
|
const sanitized = {};
|
|||
|
|
for (const [providerId, models] of Object.entries(candidate.usageSelectedModels)) {
|
|||
|
|
if (typeof providerId === 'string' && Array.isArray(models)) {
|
|||
|
|
const validModels = models.filter((m) => typeof m === 'string' && m.length > 0);
|
|||
|
|
if (validModels.length > 0) {
|
|||
|
|
sanitized[providerId] = validModels;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (Object.keys(sanitized).length > 0) {
|
|||
|
|
result.usageSelectedModels = sanitized;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Usage page collapsed families - for "Other Models" section
|
|||
|
|
if (candidate.usageCollapsedFamilies && typeof candidate.usageCollapsedFamilies === 'object') {
|
|||
|
|
const sanitized = {};
|
|||
|
|
for (const [providerId, families] of Object.entries(candidate.usageCollapsedFamilies)) {
|
|||
|
|
if (typeof providerId === 'string' && Array.isArray(families)) {
|
|||
|
|
const validFamilies = families.filter((f) => typeof f === 'string' && f.length > 0);
|
|||
|
|
if (validFamilies.length > 0) {
|
|||
|
|
sanitized[providerId] = validFamilies;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (Object.keys(sanitized).length > 0) {
|
|||
|
|
result.usageCollapsedFamilies = sanitized;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Header dropdown expanded families (inverted - stores EXPANDED, default all collapsed)
|
|||
|
|
if (candidate.usageExpandedFamilies && typeof candidate.usageExpandedFamilies === 'object') {
|
|||
|
|
const sanitized = {};
|
|||
|
|
for (const [providerId, families] of Object.entries(candidate.usageExpandedFamilies)) {
|
|||
|
|
if (typeof providerId === 'string' && Array.isArray(families)) {
|
|||
|
|
const validFamilies = families.filter((f) => typeof f === 'string' && f.length > 0);
|
|||
|
|
if (validFamilies.length > 0) {
|
|||
|
|
sanitized[providerId] = validFamilies;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (Object.keys(sanitized).length > 0) {
|
|||
|
|
result.usageExpandedFamilies = sanitized;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Custom model groups configuration
|
|||
|
|
if (candidate.usageModelGroups && typeof candidate.usageModelGroups === 'object') {
|
|||
|
|
const sanitized = {};
|
|||
|
|
for (const [providerId, config] of Object.entries(candidate.usageModelGroups)) {
|
|||
|
|
if (typeof providerId !== 'string') continue;
|
|||
|
|
|
|||
|
|
const providerConfig = {};
|
|||
|
|
|
|||
|
|
// customGroups: array of {id, label, models, order}
|
|||
|
|
if (Array.isArray(config.customGroups)) {
|
|||
|
|
const validGroups = config.customGroups
|
|||
|
|
.filter((g) => g && typeof g.id === 'string' && typeof g.label === 'string')
|
|||
|
|
.map((g) => ({
|
|||
|
|
id: g.id.slice(0, 64),
|
|||
|
|
label: g.label.slice(0, 128),
|
|||
|
|
models: Array.isArray(g.models)
|
|||
|
|
? g.models.filter((m) => typeof m === 'string').slice(0, 500)
|
|||
|
|
: [],
|
|||
|
|
order: typeof g.order === 'number' ? g.order : 0,
|
|||
|
|
}));
|
|||
|
|
if (validGroups.length > 0) {
|
|||
|
|
providerConfig.customGroups = validGroups;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// modelAssignments: Record<modelName, groupId>
|
|||
|
|
if (config.modelAssignments && typeof config.modelAssignments === 'object') {
|
|||
|
|
const assignments = {};
|
|||
|
|
for (const [model, groupId] of Object.entries(config.modelAssignments)) {
|
|||
|
|
if (typeof model === 'string' && typeof groupId === 'string') {
|
|||
|
|
assignments[model] = groupId;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (Object.keys(assignments).length > 0) {
|
|||
|
|
providerConfig.modelAssignments = assignments;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// renamedGroups: Record<groupId, label>
|
|||
|
|
if (config.renamedGroups && typeof config.renamedGroups === 'object') {
|
|||
|
|
const renamed = {};
|
|||
|
|
for (const [groupId, label] of Object.entries(config.renamedGroups)) {
|
|||
|
|
if (typeof groupId === 'string' && typeof label === 'string') {
|
|||
|
|
renamed[groupId] = label.slice(0, 128);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (Object.keys(renamed).length > 0) {
|
|||
|
|
providerConfig.renamedGroups = renamed;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (Object.keys(providerConfig).length > 0) {
|
|||
|
|
sanitized[providerId] = providerConfig;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (Object.keys(sanitized).length > 0) {
|
|||
|
|
result.usageModelGroups = sanitized;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const mergePersistedSettings = (current, changes) => {
|
|||
|
|
const baseApproved = Array.isArray(changes.approvedDirectories)
|
|||
|
|
? changes.approvedDirectories
|
|||
|
|
: Array.isArray(current.approvedDirectories)
|
|||
|
|
? current.approvedDirectories
|
|||
|
|
: [];
|
|||
|
|
|
|||
|
|
const additionalApproved = [];
|
|||
|
|
if (typeof changes.lastDirectory === 'string' && changes.lastDirectory.length > 0) {
|
|||
|
|
additionalApproved.push(changes.lastDirectory);
|
|||
|
|
}
|
|||
|
|
if (typeof changes.homeDirectory === 'string' && changes.homeDirectory.length > 0) {
|
|||
|
|
additionalApproved.push(changes.homeDirectory);
|
|||
|
|
}
|
|||
|
|
const projectEntries = Array.isArray(changes.projects)
|
|||
|
|
? changes.projects
|
|||
|
|
: Array.isArray(current.projects)
|
|||
|
|
? current.projects
|
|||
|
|
: [];
|
|||
|
|
projectEntries.forEach((project) => {
|
|||
|
|
if (project && typeof project.path === 'string' && project.path.length > 0) {
|
|||
|
|
additionalApproved.push(project.path);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
const approvedSource = [...baseApproved, ...additionalApproved];
|
|||
|
|
|
|||
|
|
const baseBookmarks = Array.isArray(changes.securityScopedBookmarks)
|
|||
|
|
? changes.securityScopedBookmarks
|
|||
|
|
: Array.isArray(current.securityScopedBookmarks)
|
|||
|
|
? current.securityScopedBookmarks
|
|||
|
|
: [];
|
|||
|
|
|
|||
|
|
const nextTypographySizes = changes.typographySizes
|
|||
|
|
? {
|
|||
|
|
...(current.typographySizes || {}),
|
|||
|
|
...changes.typographySizes
|
|||
|
|
}
|
|||
|
|
: current.typographySizes;
|
|||
|
|
|
|||
|
|
const next = {
|
|||
|
|
...current,
|
|||
|
|
...changes,
|
|||
|
|
approvedDirectories: Array.from(
|
|||
|
|
new Set(
|
|||
|
|
approvedSource.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|||
|
|
)
|
|||
|
|
),
|
|||
|
|
securityScopedBookmarks: Array.from(
|
|||
|
|
new Set(
|
|||
|
|
baseBookmarks.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|||
|
|
)
|
|||
|
|
),
|
|||
|
|
typographySizes: nextTypographySizes
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return next;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const formatSettingsResponse = (settings) => {
|
|||
|
|
const sanitized = sanitizeSettingsUpdate(settings);
|
|||
|
|
const approved = normalizeStringArray(settings.approvedDirectories);
|
|||
|
|
const bookmarks = normalizeStringArray(settings.securityScopedBookmarks);
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
...sanitized,
|
|||
|
|
approvedDirectories: approved,
|
|||
|
|
securityScopedBookmarks: bookmarks,
|
|||
|
|
pinnedDirectories: normalizeStringArray(settings.pinnedDirectories),
|
|||
|
|
typographySizes: sanitizeTypographySizesPartial(settings.typographySizes),
|
|||
|
|
showReasoningTraces:
|
|||
|
|
typeof settings.showReasoningTraces === 'boolean'
|
|||
|
|
? settings.showReasoningTraces
|
|||
|
|
: typeof sanitized.showReasoningTraces === 'boolean'
|
|||
|
|
? sanitized.showReasoningTraces
|
|||
|
|
: false
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const validateProjectEntries = async (projects) => {
|
|||
|
|
console.log(`[validateProjectEntries] Starting validation for ${projects.length} projects`);
|
|||
|
|
|
|||
|
|
if (!Array.isArray(projects)) {
|
|||
|
|
console.warn(`[validateProjectEntries] Input is not an array, returning empty`);
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const validations = projects.map(async (project) => {
|
|||
|
|
if (!project || typeof project.path !== 'string' || project.path.length === 0) {
|
|||
|
|
console.error(`[validateProjectEntries] Invalid project entry: missing or empty path`, project);
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
const stats = await fsPromises.stat(project.path);
|
|||
|
|
if (!stats.isDirectory()) {
|
|||
|
|
console.error(`[validateProjectEntries] Project path is not a directory: ${project.path}`);
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
return project;
|
|||
|
|
} catch (error) {
|
|||
|
|
const err = error;
|
|||
|
|
console.error(`[validateProjectEntries] Failed to validate project "${project.path}": ${err.code || err.message || err}`);
|
|||
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|||
|
|
console.log(`[validateProjectEntries] Removing project with ENOENT: ${project.path}`);
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
console.log(`[validateProjectEntries] Keeping project despite non-ENOENT error: ${project.path}`);
|
|||
|
|
return project;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const results = (await Promise.all(validations)).filter((p) => p !== null);
|
|||
|
|
|
|||
|
|
console.log(`[validateProjectEntries] Validation complete: ${results.length}/${projects.length} projects valid`);
|
|||
|
|
return results;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const migrateSettingsFromLegacyLastDirectory = async (current) => {
|
|||
|
|
const settings = current && typeof current === 'object' ? current : {};
|
|||
|
|
const now = Date.now();
|
|||
|
|
|
|||
|
|
const sanitizedProjects = sanitizeProjects(settings.projects) || [];
|
|||
|
|
let nextProjects = sanitizedProjects;
|
|||
|
|
let nextActiveProjectId =
|
|||
|
|
typeof settings.activeProjectId === 'string' ? settings.activeProjectId : undefined;
|
|||
|
|
|
|||
|
|
let changed = false;
|
|||
|
|
|
|||
|
|
if (nextProjects.length === 0) {
|
|||
|
|
const legacy = typeof settings.lastDirectory === 'string' ? settings.lastDirectory.trim() : '';
|
|||
|
|
const candidate = legacy ? resolveDirectoryCandidate(legacy) : null;
|
|||
|
|
|
|||
|
|
if (candidate) {
|
|||
|
|
try {
|
|||
|
|
const stats = await fsPromises.stat(candidate);
|
|||
|
|
if (stats.isDirectory()) {
|
|||
|
|
const id = crypto.randomUUID();
|
|||
|
|
nextProjects = [
|
|||
|
|
{
|
|||
|
|
id,
|
|||
|
|
path: candidate,
|
|||
|
|
addedAt: now,
|
|||
|
|
lastOpenedAt: now,
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
nextActiveProjectId = id;
|
|||
|
|
changed = true;
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore invalid lastDirectory
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (nextProjects.length > 0) {
|
|||
|
|
const active = nextProjects.find((project) => project.id === nextActiveProjectId) || null;
|
|||
|
|
if (!active) {
|
|||
|
|
nextActiveProjectId = nextProjects[0].id;
|
|||
|
|
changed = true;
|
|||
|
|
}
|
|||
|
|
} else if (nextActiveProjectId) {
|
|||
|
|
nextActiveProjectId = undefined;
|
|||
|
|
changed = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!changed) {
|
|||
|
|
return { settings, changed: false };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const merged = mergePersistedSettings(settings, {
|
|||
|
|
...settings,
|
|||
|
|
projects: nextProjects,
|
|||
|
|
...(nextActiveProjectId ? { activeProjectId: nextActiveProjectId } : { activeProjectId: undefined }),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return { settings: merged, changed: true };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const migrateSettingsFromLegacyThemePreferences = async (current) => {
|
|||
|
|
const settings = current && typeof current === 'object' ? current : {};
|
|||
|
|
|
|||
|
|
const themeId = typeof settings.themeId === 'string' ? settings.themeId.trim() : '';
|
|||
|
|
const themeVariant = typeof settings.themeVariant === 'string' ? settings.themeVariant.trim() : '';
|
|||
|
|
|
|||
|
|
const hasLight = typeof settings.lightThemeId === 'string' && settings.lightThemeId.trim().length > 0;
|
|||
|
|
const hasDark = typeof settings.darkThemeId === 'string' && settings.darkThemeId.trim().length > 0;
|
|||
|
|
|
|||
|
|
if (hasLight && hasDark) {
|
|||
|
|
return { settings, changed: false };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const defaultLight = 'flexoki-light';
|
|||
|
|
const defaultDark = 'flexoki-dark';
|
|||
|
|
|
|||
|
|
let nextLightThemeId = hasLight ? settings.lightThemeId : undefined;
|
|||
|
|
let nextDarkThemeId = hasDark ? settings.darkThemeId : undefined;
|
|||
|
|
|
|||
|
|
if (!hasLight) {
|
|||
|
|
if (themeId && themeVariant === 'light') {
|
|||
|
|
nextLightThemeId = themeId;
|
|||
|
|
} else {
|
|||
|
|
nextLightThemeId = defaultLight;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!hasDark) {
|
|||
|
|
if (themeId && themeVariant === 'dark') {
|
|||
|
|
nextDarkThemeId = themeId;
|
|||
|
|
} else {
|
|||
|
|
nextDarkThemeId = defaultDark;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const merged = mergePersistedSettings(settings, {
|
|||
|
|
...settings,
|
|||
|
|
...(nextLightThemeId ? { lightThemeId: nextLightThemeId } : {}),
|
|||
|
|
...(nextDarkThemeId ? { darkThemeId: nextDarkThemeId } : {}),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return { settings: merged, changed: true };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const migrateSettingsFromLegacyCollapsedProjects = async (current) => {
|
|||
|
|
const settings = current && typeof current === 'object' ? current : {};
|
|||
|
|
const collapsed = Array.isArray(settings.collapsedProjects)
|
|||
|
|
? normalizeStringArray(settings.collapsedProjects)
|
|||
|
|
: [];
|
|||
|
|
|
|||
|
|
if (collapsed.length === 0 || !Array.isArray(settings.projects)) {
|
|||
|
|
if (collapsed.length === 0) {
|
|||
|
|
return { settings, changed: false };
|
|||
|
|
}
|
|||
|
|
// Nothing to apply to; drop legacy key.
|
|||
|
|
const next = { ...settings };
|
|||
|
|
delete next.collapsedProjects;
|
|||
|
|
return { settings: next, changed: true };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const set = new Set(collapsed);
|
|||
|
|
const projects = sanitizeProjects(settings.projects) || [];
|
|||
|
|
let changed = false;
|
|||
|
|
|
|||
|
|
const nextProjects = projects.map((project) => {
|
|||
|
|
const shouldCollapse = set.has(project.id);
|
|||
|
|
if (project.sidebarCollapsed !== shouldCollapse) {
|
|||
|
|
changed = true;
|
|||
|
|
return { ...project, sidebarCollapsed: shouldCollapse };
|
|||
|
|
}
|
|||
|
|
return project;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!changed) {
|
|||
|
|
// Still drop legacy key if present.
|
|||
|
|
if (Object.prototype.hasOwnProperty.call(settings, 'collapsedProjects')) {
|
|||
|
|
const next = { ...settings };
|
|||
|
|
delete next.collapsedProjects;
|
|||
|
|
return { settings: next, changed: true };
|
|||
|
|
}
|
|||
|
|
return { settings, changed: false };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const next = { ...settings, projects: nextProjects };
|
|||
|
|
delete next.collapsedProjects;
|
|||
|
|
return { settings: next, changed: true };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const DEFAULT_NOTIFICATION_TEMPLATES = {
|
|||
|
|
completion: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
|
|||
|
|
error: { title: 'Tool error', message: '{last_message}' },
|
|||
|
|
question: { title: 'Input needed', message: '{last_message}' },
|
|||
|
|
subtask: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const ensureNotificationTemplateShape = (templates) => {
|
|||
|
|
const input = templates && typeof templates === 'object' ? templates : {};
|
|||
|
|
let changed = false;
|
|||
|
|
const next = {};
|
|||
|
|
|
|||
|
|
for (const event of Object.keys(DEFAULT_NOTIFICATION_TEMPLATES)) {
|
|||
|
|
const currentEntry = input[event];
|
|||
|
|
const base = DEFAULT_NOTIFICATION_TEMPLATES[event];
|
|||
|
|
const currentTitle = typeof currentEntry?.title === 'string' ? currentEntry.title : base.title;
|
|||
|
|
const currentMessage = typeof currentEntry?.message === 'string' ? currentEntry.message : base.message;
|
|||
|
|
if (!currentEntry || typeof currentEntry.title !== 'string' || typeof currentEntry.message !== 'string') {
|
|||
|
|
changed = true;
|
|||
|
|
}
|
|||
|
|
next[event] = { title: currentTitle, message: currentMessage };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { templates: next, changed };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const migrateSettingsNotificationDefaults = async (current) => {
|
|||
|
|
const settings = current && typeof current === 'object' ? current : {};
|
|||
|
|
let changed = false;
|
|||
|
|
const next = { ...settings };
|
|||
|
|
|
|||
|
|
if (typeof settings.notifyOnSubtasks !== 'boolean') {
|
|||
|
|
next.notifyOnSubtasks = true;
|
|||
|
|
changed = true;
|
|||
|
|
}
|
|||
|
|
if (typeof settings.notifyOnCompletion !== 'boolean') {
|
|||
|
|
next.notifyOnCompletion = true;
|
|||
|
|
changed = true;
|
|||
|
|
}
|
|||
|
|
if (typeof settings.notifyOnError !== 'boolean') {
|
|||
|
|
next.notifyOnError = true;
|
|||
|
|
changed = true;
|
|||
|
|
}
|
|||
|
|
if (typeof settings.notifyOnQuestion !== 'boolean') {
|
|||
|
|
next.notifyOnQuestion = true;
|
|||
|
|
changed = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { templates, changed: templatesChanged } = ensureNotificationTemplateShape(settings.notificationTemplates);
|
|||
|
|
if (templatesChanged || !settings.notificationTemplates || typeof settings.notificationTemplates !== 'object') {
|
|||
|
|
next.notificationTemplates = templates;
|
|||
|
|
changed = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { settings: changed ? next : settings, changed };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const readSettingsFromDiskMigrated = async () => {
|
|||
|
|
const current = await readSettingsFromDisk();
|
|||
|
|
const migration1 = await migrateSettingsFromLegacyLastDirectory(current);
|
|||
|
|
const migration2 = await migrateSettingsFromLegacyThemePreferences(migration1.settings);
|
|||
|
|
const migration3 = await migrateSettingsFromLegacyCollapsedProjects(migration2.settings);
|
|||
|
|
const migration4 = await migrateSettingsNotificationDefaults(migration3.settings);
|
|||
|
|
if (migration1.changed || migration2.changed || migration3.changed || migration4.changed) {
|
|||
|
|
await writeSettingsToDisk(migration4.settings);
|
|||
|
|
}
|
|||
|
|
return migration4.settings;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getOrCreateVapidKeys = async () => {
|
|||
|
|
const settings = await readSettingsFromDiskMigrated();
|
|||
|
|
const existing = settings?.vapidKeys;
|
|||
|
|
if (existing && typeof existing.publicKey === 'string' && typeof existing.privateKey === 'string') {
|
|||
|
|
return { publicKey: existing.publicKey, privateKey: existing.privateKey };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const generated = webPush.generateVAPIDKeys();
|
|||
|
|
const next = {
|
|||
|
|
...settings,
|
|||
|
|
vapidKeys: {
|
|||
|
|
publicKey: generated.publicKey,
|
|||
|
|
privateKey: generated.privateKey,
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
await writeSettingsToDisk(next);
|
|||
|
|
return { publicKey: generated.publicKey, privateKey: generated.privateKey };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getUiSessionTokenFromRequest = (req) => {
|
|||
|
|
const cookieHeader = req?.headers?.cookie;
|
|||
|
|
if (!cookieHeader || typeof cookieHeader !== 'string') {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
const segments = cookieHeader.split(';');
|
|||
|
|
for (const segment of segments) {
|
|||
|
|
const [rawName, ...rest] = segment.split('=');
|
|||
|
|
const name = rawName?.trim();
|
|||
|
|
if (!name) continue;
|
|||
|
|
if (name !== 'oc_ui_session') continue;
|
|||
|
|
const value = rest.join('=').trim();
|
|||
|
|
try {
|
|||
|
|
return decodeURIComponent(value || '');
|
|||
|
|
} catch {
|
|||
|
|
return value || null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW = 128;
|
|||
|
|
const TERMINAL_INPUT_WS_REBIND_WINDOW_MS = 60 * 1000;
|
|||
|
|
const TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS = 15 * 1000;
|
|||
|
|
|
|||
|
|
const rejectWebSocketUpgrade = (socket, statusCode, reason) => {
|
|||
|
|
if (!socket || socket.destroyed) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const message = typeof reason === 'string' && reason.trim().length > 0 ? reason.trim() : 'Bad Request';
|
|||
|
|
const body = Buffer.from(message, 'utf8');
|
|||
|
|
const statusText = {
|
|||
|
|
400: 'Bad Request',
|
|||
|
|
401: 'Unauthorized',
|
|||
|
|
403: 'Forbidden',
|
|||
|
|
404: 'Not Found',
|
|||
|
|
500: 'Internal Server Error',
|
|||
|
|
}[statusCode] || 'Bad Request';
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
socket.write(
|
|||
|
|
`HTTP/1.1 ${statusCode} ${statusText}\r\n` +
|
|||
|
|
'Connection: close\r\n' +
|
|||
|
|
'Content-Type: text/plain; charset=utf-8\r\n' +
|
|||
|
|
`Content-Length: ${body.length}\r\n\r\n`
|
|||
|
|
);
|
|||
|
|
socket.write(body);
|
|||
|
|
} catch {
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
socket.destroy();
|
|||
|
|
} catch {
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
|
|||
|
|
const getRequestOriginCandidates = async (req) => {
|
|||
|
|
const origins = new Set();
|
|||
|
|
const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string'
|
|||
|
|
? req.headers['x-forwarded-proto'].split(',')[0].trim().toLowerCase()
|
|||
|
|
: '';
|
|||
|
|
const protocol = forwardedProto || (req.socket?.encrypted ? 'https' : 'http');
|
|||
|
|
|
|||
|
|
const forwardedHost = typeof req.headers['x-forwarded-host'] === 'string'
|
|||
|
|
? req.headers['x-forwarded-host'].split(',')[0].trim()
|
|||
|
|
: '';
|
|||
|
|
const host = forwardedHost || (typeof req.headers.host === 'string' ? req.headers.host.trim() : '');
|
|||
|
|
|
|||
|
|
if (host) {
|
|||
|
|
origins.add(`${protocol}://${host}`);
|
|||
|
|
const [hostname, port] = host.split(':');
|
|||
|
|
const normalizedHost = typeof hostname === 'string' ? hostname.toLowerCase() : '';
|
|||
|
|
const portSuffix = typeof port === 'string' && port.length > 0 ? `:${port}` : '';
|
|||
|
|
if (normalizedHost === 'localhost') {
|
|||
|
|
origins.add(`${protocol}://127.0.0.1${portSuffix}`);
|
|||
|
|
origins.add(`${protocol}://[::1]${portSuffix}`);
|
|||
|
|
} else if (normalizedHost === '127.0.0.1' || normalizedHost === '[::1]') {
|
|||
|
|
origins.add(`${protocol}://localhost${portSuffix}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const settings = await readSettingsFromDiskMigrated();
|
|||
|
|
if (typeof settings?.publicOrigin === 'string' && settings.publicOrigin.trim().length > 0) {
|
|||
|
|
origins.add(new URL(settings.publicOrigin.trim()).origin);
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return origins;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const isRequestOriginAllowed = async (req) => {
|
|||
|
|
const originHeader = typeof req.headers.origin === 'string' ? req.headers.origin.trim() : '';
|
|||
|
|
if (!originHeader) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let normalizedOrigin = '';
|
|||
|
|
try {
|
|||
|
|
normalizedOrigin = new URL(originHeader).origin;
|
|||
|
|
} catch {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const allowedOrigins = await getRequestOriginCandidates(req);
|
|||
|
|
return allowedOrigins.has(normalizedOrigin);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const normalizePushSubscriptions = (record) => {
|
|||
|
|
if (!Array.isArray(record)) return [];
|
|||
|
|
return record
|
|||
|
|
.map((entry) => {
|
|||
|
|
if (!entry || typeof entry !== 'object') return null;
|
|||
|
|
const endpoint = entry.endpoint;
|
|||
|
|
const p256dh = entry.p256dh;
|
|||
|
|
const auth = entry.auth;
|
|||
|
|
if (typeof endpoint !== 'string' || typeof p256dh !== 'string' || typeof auth !== 'string') {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
endpoint,
|
|||
|
|
p256dh,
|
|||
|
|
auth,
|
|||
|
|
createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null,
|
|||
|
|
};
|
|||
|
|
})
|
|||
|
|
.filter(Boolean);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getPushSubscriptionsForUiSession = async (uiSessionToken) => {
|
|||
|
|
if (!uiSessionToken) return [];
|
|||
|
|
const store = await readPushSubscriptionsFromDisk();
|
|||
|
|
const record = store.subscriptionsBySession?.[uiSessionToken];
|
|||
|
|
return normalizePushSubscriptions(record);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent) => {
|
|||
|
|
if (!uiSessionToken) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await ensurePushInitialized();
|
|||
|
|
|
|||
|
|
const now = Date.now();
|
|||
|
|
|
|||
|
|
await persistPushSubscriptionUpdate((current) => {
|
|||
|
|
const subsBySession = { ...(current.subscriptionsBySession || {}) };
|
|||
|
|
const existing = Array.isArray(subsBySession[uiSessionToken]) ? subsBySession[uiSessionToken] : [];
|
|||
|
|
|
|||
|
|
const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== subscription.endpoint);
|
|||
|
|
|
|||
|
|
filtered.unshift({
|
|||
|
|
endpoint: subscription.endpoint,
|
|||
|
|
p256dh: subscription.p256dh,
|
|||
|
|
auth: subscription.auth,
|
|||
|
|
createdAt: now,
|
|||
|
|
lastSeenAt: now,
|
|||
|
|
userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
subsBySession[uiSessionToken] = filtered.slice(0, 10);
|
|||
|
|
|
|||
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession };
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const removePushSubscription = async (uiSessionToken, endpoint) => {
|
|||
|
|
if (!uiSessionToken || !endpoint) return;
|
|||
|
|
|
|||
|
|
await ensurePushInitialized();
|
|||
|
|
|
|||
|
|
await persistPushSubscriptionUpdate((current) => {
|
|||
|
|
const subsBySession = { ...(current.subscriptionsBySession || {}) };
|
|||
|
|
const existing = Array.isArray(subsBySession[uiSessionToken]) ? subsBySession[uiSessionToken] : [];
|
|||
|
|
const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== endpoint);
|
|||
|
|
if (filtered.length === 0) {
|
|||
|
|
delete subsBySession[uiSessionToken];
|
|||
|
|
} else {
|
|||
|
|
subsBySession[uiSessionToken] = filtered;
|
|||
|
|
}
|
|||
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession };
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const removePushSubscriptionFromAllSessions = async (endpoint) => {
|
|||
|
|
if (!endpoint) return;
|
|||
|
|
|
|||
|
|
await ensurePushInitialized();
|
|||
|
|
|
|||
|
|
await persistPushSubscriptionUpdate((current) => {
|
|||
|
|
const subsBySession = { ...(current.subscriptionsBySession || {}) };
|
|||
|
|
for (const [token, entries] of Object.entries(subsBySession)) {
|
|||
|
|
if (!Array.isArray(entries)) continue;
|
|||
|
|
const filtered = entries.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== endpoint);
|
|||
|
|
if (filtered.length === 0) {
|
|||
|
|
delete subsBySession[token];
|
|||
|
|
} else {
|
|||
|
|
subsBySession[token] = filtered;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return { version: PUSH_SUBSCRIPTIONS_VERSION, subscriptionsBySession: subsBySession };
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const buildSessionDeepLinkUrl = (sessionId) => {
|
|||
|
|
if (!sessionId || typeof sessionId !== 'string') {
|
|||
|
|
return '/';
|
|||
|
|
}
|
|||
|
|
return `/?session=${encodeURIComponent(sessionId)}`;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const sendPushToSubscription = async (sub, payload) => {
|
|||
|
|
await ensurePushInitialized();
|
|||
|
|
const body = JSON.stringify(payload);
|
|||
|
|
|
|||
|
|
const pushSubscription = {
|
|||
|
|
endpoint: sub.endpoint,
|
|||
|
|
keys: {
|
|||
|
|
p256dh: sub.p256dh,
|
|||
|
|
auth: sub.auth,
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await webPush.sendNotification(pushSubscription, body);
|
|||
|
|
} catch (error) {
|
|||
|
|
const statusCode = typeof error?.statusCode === 'number' ? error.statusCode : null;
|
|||
|
|
if (statusCode === 410 || statusCode === 404) {
|
|||
|
|
await removePushSubscriptionFromAllSessions(sub.endpoint);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
console.warn('[Push] Failed to send notification:', error);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const sendPushToAllUiSessions = async (payload, options = {}) => {
|
|||
|
|
const requireNoSse = options.requireNoSse === true;
|
|||
|
|
const store = await readPushSubscriptionsFromDisk();
|
|||
|
|
const sessions = store.subscriptionsBySession || {};
|
|||
|
|
const subscriptionsByEndpoint = new Map();
|
|||
|
|
|
|||
|
|
for (const [token, record] of Object.entries(sessions)) {
|
|||
|
|
const subscriptions = normalizePushSubscriptions(record);
|
|||
|
|
if (subscriptions.length === 0) continue;
|
|||
|
|
|
|||
|
|
for (const sub of subscriptions) {
|
|||
|
|
if (!subscriptionsByEndpoint.has(sub.endpoint)) {
|
|||
|
|
subscriptionsByEndpoint.set(sub.endpoint, sub);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await Promise.all(Array.from(subscriptionsByEndpoint.entries()).map(async ([endpoint, sub]) => {
|
|||
|
|
if (requireNoSse && isAnyUiVisible()) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
await sendPushToSubscription(sub, payload);
|
|||
|
|
}));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
let pushInitialized = false;
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
const uiVisibilityByToken = new Map();
|
|||
|
|
let globalVisibilityState = false;
|
|||
|
|
|
|||
|
|
const updateUiVisibility = (token, visible) => {
|
|||
|
|
if (!token) return;
|
|||
|
|
const now = Date.now();
|
|||
|
|
const nextVisible = Boolean(visible);
|
|||
|
|
uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now });
|
|||
|
|
globalVisibilityState = nextVisible;
|
|||
|
|
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const isAnyUiVisible = () => globalVisibilityState === true;
|
|||
|
|
|
|||
|
|
const isUiVisible = (token) => uiVisibilityByToken.get(token)?.visible === true;
|
|||
|
|
|
|||
|
|
// Session activity tracking (mirrors desktop session_activity.rs)
|
|||
|
|
const sessionActivityPhases = new Map(); // sessionId -> { phase: 'idle'|'busy'|'cooldown', updatedAt: number }
|
|||
|
|
const sessionActivityCooldowns = new Map(); // sessionId -> timeoutId
|
|||
|
|
const SESSION_COOLDOWN_DURATION_MS = 2000;
|
|||
|
|
|
|||
|
|
// Complete session status tracking - source of truth for web clients
|
|||
|
|
// This maintains the authoritative state, clients only cache it
|
|||
|
|
const sessionStates = new Map(); // sessionId -> {
|
|||
|
|
// status: 'idle'|'busy'|'retry',
|
|||
|
|
// lastUpdateAt: number,
|
|||
|
|
// lastEventId: string,
|
|||
|
|
// metadata: { attempt?: number, message?: string, next?: number }
|
|||
|
|
// }
|
|||
|
|
const SESSION_STATE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|||
|
|
const SESSION_STATE_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
|||
|
|
|
|||
|
|
const updateSessionState = (sessionId, status, eventId, metadata = {}) => {
|
|||
|
|
if (!sessionId || typeof sessionId !== 'string') return;
|
|||
|
|
|
|||
|
|
const now = Date.now();
|
|||
|
|
const existing = sessionStates.get(sessionId);
|
|||
|
|
|
|||
|
|
// Only update if this is a newer event (simple ordering protection)
|
|||
|
|
if (existing && existing.lastUpdateAt > now - 5000 && status === existing.status) {
|
|||
|
|
// Same status within 5 seconds, skip to reduce noise
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
sessionStates.set(sessionId, {
|
|||
|
|
status,
|
|||
|
|
lastUpdateAt: now,
|
|||
|
|
lastEventId: eventId || `server-${now}`,
|
|||
|
|
metadata: { ...existing?.metadata, ...metadata }
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Update attention tracking state (must be called before broadcasting)
|
|||
|
|
updateSessionAttentionStatus(sessionId, status, eventId);
|
|||
|
|
|
|||
|
|
// Broadcast status change to connected web clients via SSE
|
|||
|
|
// This enables real-time updates without polling
|
|||
|
|
// Include needsAttention in the same event to ensure atomic updates
|
|||
|
|
if (uiNotificationClients.size > 0 && (!existing || existing.status !== status)) {
|
|||
|
|
const state = sessionStates.get(sessionId);
|
|||
|
|
const attentionState = sessionAttentionStates.get(sessionId);
|
|||
|
|
for (const res of uiNotificationClients) {
|
|||
|
|
try {
|
|||
|
|
writeSseEvent(res, {
|
|||
|
|
type: 'openchamber:session-status',
|
|||
|
|
properties: {
|
|||
|
|
sessionId,
|
|||
|
|
status: state.status,
|
|||
|
|
timestamp: state.lastUpdateAt,
|
|||
|
|
metadata: state.metadata,
|
|||
|
|
needsAttention: attentionState?.needsAttention ?? false
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
} catch {
|
|||
|
|
// Client disconnected, will be cleaned up by close handler
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Also update activity phases for backward compatibility
|
|||
|
|
const phase = status === 'busy' || status === 'retry' ? 'busy' : 'idle';
|
|||
|
|
setSessionActivityPhase(sessionId, phase);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getSessionStateSnapshot = () => {
|
|||
|
|
const result = {};
|
|||
|
|
const now = Date.now();
|
|||
|
|
|
|||
|
|
for (const [sessionId, data] of sessionStates) {
|
|||
|
|
// Skip very old states (session likely gone)
|
|||
|
|
if (now - data.lastUpdateAt > SESSION_STATE_MAX_AGE_MS) continue;
|
|||
|
|
|
|||
|
|
result[sessionId] = {
|
|||
|
|
status: data.status,
|
|||
|
|
lastUpdateAt: data.lastUpdateAt,
|
|||
|
|
metadata: data.metadata
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getSessionState = (sessionId) => {
|
|||
|
|
if (!sessionId) return null;
|
|||
|
|
return sessionStates.get(sessionId) || null;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Session attention tracking - authoritative source for unread/needs-attention state
|
|||
|
|
// Tracks which sessions need user attention based on activity and view state
|
|||
|
|
const sessionAttentionStates = new Map(); // sessionId -> {
|
|||
|
|
// needsAttention: boolean,
|
|||
|
|
// lastUserMessageAt: number | null,
|
|||
|
|
// lastStatusChangeAt: number,
|
|||
|
|
// viewedByClients: Set<clientId>,
|
|||
|
|
// status: 'idle' | 'busy' | 'retry'
|
|||
|
|
// }
|
|||
|
|
const SESSION_ATTENTION_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|||
|
|
|
|||
|
|
const getOrCreateAttentionState = (sessionId) => {
|
|||
|
|
if (!sessionId || typeof sessionId !== 'string') return null;
|
|||
|
|
|
|||
|
|
let state = sessionAttentionStates.get(sessionId);
|
|||
|
|
if (!state) {
|
|||
|
|
state = {
|
|||
|
|
needsAttention: false,
|
|||
|
|
lastUserMessageAt: null,
|
|||
|
|
lastStatusChangeAt: Date.now(),
|
|||
|
|
viewedByClients: new Set(),
|
|||
|
|
status: 'idle'
|
|||
|
|
};
|
|||
|
|
sessionAttentionStates.set(sessionId, state);
|
|||
|
|
}
|
|||
|
|
return state;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const updateSessionAttentionStatus = (sessionId, status, eventId) => {
|
|||
|
|
const state = getOrCreateAttentionState(sessionId);
|
|||
|
|
if (!state) return;
|
|||
|
|
|
|||
|
|
const prevStatus = state.status;
|
|||
|
|
state.status = status;
|
|||
|
|
state.lastStatusChangeAt = Date.now();
|
|||
|
|
|
|||
|
|
// Check if we need to mark as needsAttention
|
|||
|
|
// Condition: transitioning from busy/retry to idle + user sent message + not currently viewed
|
|||
|
|
// Note: The actual broadcast with needsAttention is done in updateSessionState
|
|||
|
|
// to ensure both status and attention are sent in a single event
|
|||
|
|
if ((prevStatus === 'busy' || prevStatus === 'retry') && status === 'idle') {
|
|||
|
|
if (state.lastUserMessageAt && state.viewedByClients.size === 0) {
|
|||
|
|
state.needsAttention = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const markSessionViewed = (sessionId, clientId) => {
|
|||
|
|
const state = getOrCreateAttentionState(sessionId);
|
|||
|
|
if (!state) return;
|
|||
|
|
|
|||
|
|
const wasNeedsAttention = state.needsAttention;
|
|||
|
|
state.viewedByClients.add(clientId);
|
|||
|
|
|
|||
|
|
// Clear needsAttention when viewed
|
|||
|
|
if (wasNeedsAttention) {
|
|||
|
|
state.needsAttention = false;
|
|||
|
|
|
|||
|
|
// Broadcast attention cleared event
|
|||
|
|
if (uiNotificationClients.size > 0) {
|
|||
|
|
for (const res of uiNotificationClients) {
|
|||
|
|
try {
|
|||
|
|
writeSseEvent(res, {
|
|||
|
|
type: 'openchamber:session-status',
|
|||
|
|
properties: {
|
|||
|
|
sessionId,
|
|||
|
|
status: state.status,
|
|||
|
|
timestamp: Date.now(),
|
|||
|
|
metadata: {},
|
|||
|
|
needsAttention: false
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
} catch {
|
|||
|
|
// Client disconnected
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const markSessionUnviewed = (sessionId, clientId) => {
|
|||
|
|
const state = sessionAttentionStates.get(sessionId);
|
|||
|
|
if (!state) return;
|
|||
|
|
|
|||
|
|
state.viewedByClients.delete(clientId);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const markUserMessageSent = (sessionId) => {
|
|||
|
|
const state = getOrCreateAttentionState(sessionId);
|
|||
|
|
if (!state) return;
|
|||
|
|
|
|||
|
|
state.lastUserMessageAt = Date.now();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getSessionAttentionSnapshot = () => {
|
|||
|
|
const result = {};
|
|||
|
|
const now = Date.now();
|
|||
|
|
|
|||
|
|
for (const [sessionId, state] of sessionAttentionStates) {
|
|||
|
|
// Skip very old states
|
|||
|
|
if (now - state.lastStatusChangeAt > SESSION_ATTENTION_MAX_AGE_MS) continue;
|
|||
|
|
|
|||
|
|
result[sessionId] = {
|
|||
|
|
needsAttention: state.needsAttention,
|
|||
|
|
lastUserMessageAt: state.lastUserMessageAt,
|
|||
|
|
lastStatusChangeAt: state.lastStatusChangeAt,
|
|||
|
|
status: state.status,
|
|||
|
|
isViewed: state.viewedByClients.size > 0
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getSessionAttentionState = (sessionId) => {
|
|||
|
|
if (!sessionId) return null;
|
|||
|
|
const state = sessionAttentionStates.get(sessionId);
|
|||
|
|
if (!state) return null;
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
needsAttention: state.needsAttention,
|
|||
|
|
lastUserMessageAt: state.lastUserMessageAt,
|
|||
|
|
lastStatusChangeAt: state.lastStatusChangeAt,
|
|||
|
|
status: state.status,
|
|||
|
|
isViewed: state.viewedByClients.size > 0
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const cleanupOldSessionStates = () => {
|
|||
|
|
const now = Date.now();
|
|||
|
|
let cleaned = 0;
|
|||
|
|
|
|||
|
|
for (const [sessionId, data] of sessionStates) {
|
|||
|
|
if (now - data.lastUpdateAt > SESSION_STATE_MAX_AGE_MS) {
|
|||
|
|
sessionStates.delete(sessionId);
|
|||
|
|
cleaned++;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Also cleanup attention states
|
|||
|
|
for (const [sessionId, state] of sessionAttentionStates) {
|
|||
|
|
if (now - state.lastStatusChangeAt > SESSION_ATTENTION_MAX_AGE_MS) {
|
|||
|
|
sessionAttentionStates.delete(sessionId);
|
|||
|
|
cleaned++;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (cleaned > 0) {
|
|||
|
|
console.info(`[SessionState] Cleaned up ${cleaned} old session states`);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Start periodic cleanup
|
|||
|
|
setInterval(cleanupOldSessionStates, SESSION_STATE_CLEANUP_INTERVAL_MS);
|
|||
|
|
|
|||
|
|
const setSessionActivityPhase = (sessionId, phase) => {
|
|||
|
|
if (!sessionId || typeof sessionId !== 'string') return false;
|
|||
|
|
|
|||
|
|
const current = sessionActivityPhases.get(sessionId);
|
|||
|
|
if (current?.phase === phase) return false; // No change
|
|||
|
|
|
|||
|
|
// Match desktop semantics: only enter cooldown from busy.
|
|||
|
|
if (phase === 'cooldown' && current?.phase !== 'busy') {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Cancel existing cooldown timer only on phase change.
|
|||
|
|
const existingTimer = sessionActivityCooldowns.get(sessionId);
|
|||
|
|
if (existingTimer) {
|
|||
|
|
clearTimeout(existingTimer);
|
|||
|
|
sessionActivityCooldowns.delete(sessionId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
sessionActivityPhases.set(sessionId, { phase, updatedAt: Date.now() });
|
|||
|
|
|
|||
|
|
// Schedule transition from cooldown to idle
|
|||
|
|
if (phase === 'cooldown') {
|
|||
|
|
const timer = setTimeout(() => {
|
|||
|
|
const now = sessionActivityPhases.get(sessionId);
|
|||
|
|
if (now?.phase === 'cooldown') {
|
|||
|
|
sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: Date.now() });
|
|||
|
|
}
|
|||
|
|
sessionActivityCooldowns.delete(sessionId);
|
|||
|
|
}, SESSION_COOLDOWN_DURATION_MS);
|
|||
|
|
sessionActivityCooldowns.set(sessionId, timer);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return true;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getSessionActivitySnapshot = () => {
|
|||
|
|
const result = {};
|
|||
|
|
for (const [sessionId, data] of sessionActivityPhases) {
|
|||
|
|
result[sessionId] = { type: data.phase };
|
|||
|
|
}
|
|||
|
|
return result;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const resetAllSessionActivityToIdle = () => {
|
|||
|
|
// Cancel all cooldown timers
|
|||
|
|
for (const timer of sessionActivityCooldowns.values()) {
|
|||
|
|
clearTimeout(timer);
|
|||
|
|
}
|
|||
|
|
sessionActivityCooldowns.clear();
|
|||
|
|
|
|||
|
|
// Reset all phases to idle
|
|||
|
|
const now = Date.now();
|
|||
|
|
for (const [sessionId] of sessionActivityPhases) {
|
|||
|
|
sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: now });
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const resolveVapidSubject = async () => {
|
|||
|
|
const configured = process.env.OPENCHAMBER_VAPID_SUBJECT;
|
|||
|
|
if (typeof configured === 'string' && configured.trim().length > 0) {
|
|||
|
|
return configured.trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const originEnv = process.env.OPENCHAMBER_PUBLIC_ORIGIN;
|
|||
|
|
if (typeof originEnv === 'string' && originEnv.trim().length > 0) {
|
|||
|
|
const trimmed = originEnv.trim();
|
|||
|
|
// Convert http://localhost to mailto for VAPID compatibility
|
|||
|
|
if (trimmed.startsWith('http://localhost')) {
|
|||
|
|
return 'mailto:openchamber@localhost';
|
|||
|
|
}
|
|||
|
|
return trimmed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const settings = await readSettingsFromDiskMigrated();
|
|||
|
|
const stored = settings?.publicOrigin;
|
|||
|
|
if (typeof stored === 'string' && stored.trim().length > 0) {
|
|||
|
|
const trimmed = stored.trim();
|
|||
|
|
// Convert http://localhost to mailto for VAPID compatibility
|
|||
|
|
if (trimmed.startsWith('http://localhost')) {
|
|||
|
|
return 'mailto:openchamber@localhost';
|
|||
|
|
}
|
|||
|
|
return trimmed;
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return 'mailto:openchamber@localhost';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const ensurePushInitialized = async () => {
|
|||
|
|
if (pushInitialized) return;
|
|||
|
|
const keys = await getOrCreateVapidKeys();
|
|||
|
|
const subject = await resolveVapidSubject();
|
|||
|
|
|
|||
|
|
if (subject === 'mailto:openchamber@localhost') {
|
|||
|
|
console.warn('[Push] No public origin configured for VAPID; set OPENCHAMBER_VAPID_SUBJECT or enable push once from a real origin.');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
webPush.setVapidDetails(subject, keys.publicKey, keys.privateKey);
|
|||
|
|
pushInitialized = true;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const persistSettings = async (changes) => {
|
|||
|
|
// Serialize concurrent calls using lock
|
|||
|
|
persistSettingsLock = persistSettingsLock.then(async () => {
|
|||
|
|
console.log(`[persistSettings] Called with changes:`, JSON.stringify(changes, null, 2));
|
|||
|
|
const current = await readSettingsFromDisk();
|
|||
|
|
console.log(`[persistSettings] Current projects count:`, Array.isArray(current.projects) ? current.projects.length : 'N/A');
|
|||
|
|
const sanitized = sanitizeSettingsUpdate(changes);
|
|||
|
|
let next = mergePersistedSettings(current, sanitized);
|
|||
|
|
|
|||
|
|
if (Array.isArray(next.projects)) {
|
|||
|
|
console.log(`[persistSettings] Validating ${next.projects.length} projects...`);
|
|||
|
|
const validated = await validateProjectEntries(next.projects);
|
|||
|
|
console.log(`[persistSettings] After validation: ${validated.length} projects remain`);
|
|||
|
|
next = { ...next, projects: validated };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (Array.isArray(next.projects) && next.projects.length > 0) {
|
|||
|
|
const activeId = typeof next.activeProjectId === 'string' ? next.activeProjectId : '';
|
|||
|
|
const active = next.projects.find((project) => project.id === activeId) || null;
|
|||
|
|
if (!active) {
|
|||
|
|
console.log(`[persistSettings] Active project ID ${activeId} not found, switching to ${next.projects[0].id}`);
|
|||
|
|
next = { ...next, activeProjectId: next.projects[0].id };
|
|||
|
|
}
|
|||
|
|
} else if (next.activeProjectId) {
|
|||
|
|
console.log(`[persistSettings] No projects found, clearing activeProjectId ${next.activeProjectId}`);
|
|||
|
|
next = { ...next, activeProjectId: undefined };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await writeSettingsToDisk(next);
|
|||
|
|
console.log(`[persistSettings] Successfully saved ${next.projects?.length || 0} projects to disk`);
|
|||
|
|
return formatSettingsResponse(next);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return persistSettingsLock;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// HMR-persistent state via globalThis
|
|||
|
|
// These values survive Vite HMR reloads to prevent zombie OpenCode processes
|
|||
|
|
const HMR_STATE_KEY = '__openchamberHmrState';
|
|||
|
|
const getHmrState = () => {
|
|||
|
|
if (!globalThis[HMR_STATE_KEY]) {
|
|||
|
|
globalThis[HMR_STATE_KEY] = {
|
|||
|
|
openCodeProcess: null,
|
|||
|
|
openCodePort: null,
|
|||
|
|
openCodeWorkingDirectory: os.homedir(),
|
|||
|
|
isShuttingDown: false,
|
|||
|
|
signalsAttached: false,
|
|||
|
|
userProvidedOpenCodePassword: undefined,
|
|||
|
|
openCodeAuthPassword: null,
|
|||
|
|
openCodeAuthSource: null,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
return globalThis[HMR_STATE_KEY];
|
|||
|
|
};
|
|||
|
|
const hmrState = getHmrState();
|
|||
|
|
|
|||
|
|
const normalizeOpenCodePassword = (value) => {
|
|||
|
|
if (typeof value !== 'string') {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
return value.trim();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (typeof hmrState.userProvidedOpenCodePassword === 'undefined') {
|
|||
|
|
const initialPassword = normalizeOpenCodePassword(process.env.OPENCODE_SERVER_PASSWORD);
|
|||
|
|
hmrState.userProvidedOpenCodePassword = initialPassword || null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Non-HMR state (safe to reset on reload)
|
|||
|
|
let healthCheckInterval = null;
|
|||
|
|
let server = null;
|
|||
|
|
let cachedModelsMetadata = null;
|
|||
|
|
let cachedModelsMetadataTimestamp = 0;
|
|||
|
|
let expressApp = null;
|
|||
|
|
let currentRestartPromise = null;
|
|||
|
|
let isRestartingOpenCode = false;
|
|||
|
|
let openCodeApiPrefix = '';
|
|||
|
|
let openCodeApiPrefixDetected = true;
|
|||
|
|
let openCodeApiDetectionTimer = null;
|
|||
|
|
let lastOpenCodeError = null;
|
|||
|
|
let isOpenCodeReady = false;
|
|||
|
|
let openCodeNotReadySince = 0;
|
|||
|
|
let isExternalOpenCode = false;
|
|||
|
|
let exitOnShutdown = true;
|
|||
|
|
let uiAuthController = null;
|
|||
|
|
let terminalInputWsServer = null;
|
|||
|
|
const userProvidedOpenCodePassword =
|
|||
|
|
typeof hmrState.userProvidedOpenCodePassword === 'string' && hmrState.userProvidedOpenCodePassword.length > 0
|
|||
|
|
? hmrState.userProvidedOpenCodePassword
|
|||
|
|
: null;
|
|||
|
|
let openCodeAuthPassword =
|
|||
|
|
typeof hmrState.openCodeAuthPassword === 'string' && hmrState.openCodeAuthPassword.length > 0
|
|||
|
|
? hmrState.openCodeAuthPassword
|
|||
|
|
: userProvidedOpenCodePassword;
|
|||
|
|
let openCodeAuthSource =
|
|||
|
|
typeof hmrState.openCodeAuthSource === 'string' && hmrState.openCodeAuthSource.length > 0
|
|||
|
|
? hmrState.openCodeAuthSource
|
|||
|
|
: (userProvidedOpenCodePassword ? 'user-env' : null);
|
|||
|
|
|
|||
|
|
// Sync helper - call after modifying any HMR state variable
|
|||
|
|
const syncToHmrState = () => {
|
|||
|
|
hmrState.openCodeProcess = openCodeProcess;
|
|||
|
|
hmrState.openCodePort = openCodePort;
|
|||
|
|
hmrState.openCodeBaseUrl = openCodeBaseUrl;
|
|||
|
|
hmrState.isShuttingDown = isShuttingDown;
|
|||
|
|
hmrState.signalsAttached = signalsAttached;
|
|||
|
|
hmrState.openCodeWorkingDirectory = openCodeWorkingDirectory;
|
|||
|
|
hmrState.openCodeAuthPassword = openCodeAuthPassword;
|
|||
|
|
hmrState.openCodeAuthSource = openCodeAuthSource;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Sync helper - call to restore state from HMR (e.g., on module reload)
|
|||
|
|
const syncFromHmrState = () => {
|
|||
|
|
openCodeProcess = hmrState.openCodeProcess;
|
|||
|
|
openCodePort = hmrState.openCodePort;
|
|||
|
|
openCodeBaseUrl = hmrState.openCodeBaseUrl ?? null;
|
|||
|
|
isShuttingDown = hmrState.isShuttingDown;
|
|||
|
|
signalsAttached = hmrState.signalsAttached;
|
|||
|
|
openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory;
|
|||
|
|
openCodeAuthPassword =
|
|||
|
|
typeof hmrState.openCodeAuthPassword === 'string' && hmrState.openCodeAuthPassword.length > 0
|
|||
|
|
? hmrState.openCodeAuthPassword
|
|||
|
|
: userProvidedOpenCodePassword;
|
|||
|
|
openCodeAuthSource =
|
|||
|
|
typeof hmrState.openCodeAuthSource === 'string' && hmrState.openCodeAuthSource.length > 0
|
|||
|
|
? hmrState.openCodeAuthSource
|
|||
|
|
: (userProvidedOpenCodePassword ? 'user-env' : null);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Module-level variables that shadow HMR state
|
|||
|
|
// These are synced to/from hmrState to survive HMR reloads
|
|||
|
|
let openCodeProcess = hmrState.openCodeProcess;
|
|||
|
|
let openCodePort = hmrState.openCodePort;
|
|||
|
|
let openCodeBaseUrl = hmrState.openCodeBaseUrl ?? null;
|
|||
|
|
let isShuttingDown = hmrState.isShuttingDown;
|
|||
|
|
let signalsAttached = hmrState.signalsAttached;
|
|||
|
|
let openCodeWorkingDirectory = hmrState.openCodeWorkingDirectory;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Check if an existing OpenCode process is still alive and responding
|
|||
|
|
* Used to reuse process across HMR reloads
|
|||
|
|
*/
|
|||
|
|
async function isOpenCodeProcessHealthy() {
|
|||
|
|
if (!openCodeProcess || !openCodePort) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Health check via HTTP since SDK object doesn't expose exitCode
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`http://127.0.0.1:${openCodePort}/session`, {
|
|||
|
|
method: 'GET',
|
|||
|
|
headers: getOpenCodeAuthHeaders(),
|
|||
|
|
signal: AbortSignal.timeout(2000),
|
|||
|
|
});
|
|||
|
|
return response.ok;
|
|||
|
|
} catch {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Probe if an external OpenCode instance is already running on the given port.
|
|||
|
|
* Unlike isOpenCodeProcessHealthy(), this doesn't require openCodeProcess to be set.
|
|||
|
|
* Used to auto-detect and connect to an existing OpenCode instance on startup.
|
|||
|
|
*/
|
|||
|
|
async function probeExternalOpenCode(port, origin) {
|
|||
|
|
if (!port || port <= 0) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const controller = new AbortController();
|
|||
|
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
|||
|
|
const base = origin ?? `http://127.0.0.1:${port}`;
|
|||
|
|
const response = await fetch(`${base}/global/health`, {
|
|||
|
|
method: 'GET',
|
|||
|
|
headers: {
|
|||
|
|
Accept: 'application/json',
|
|||
|
|
...getOpenCodeAuthHeaders(),
|
|||
|
|
},
|
|||
|
|
signal: controller.signal,
|
|||
|
|
});
|
|||
|
|
clearTimeout(timeout);
|
|||
|
|
if (!response.ok) return false;
|
|||
|
|
const body = await response.json().catch(() => null);
|
|||
|
|
return body?.healthy === true;
|
|||
|
|
} catch {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const ENV_CONFIGURED_OPENCODE_PORT = (() => {
|
|||
|
|
const raw =
|
|||
|
|
process.env.OPENCODE_PORT ||
|
|||
|
|
process.env.OPENCHAMBER_OPENCODE_PORT ||
|
|||
|
|
process.env.OPENCHAMBER_INTERNAL_PORT;
|
|||
|
|
if (!raw) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
const parsed = parseInt(raw, 10);
|
|||
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
const ENV_CONFIGURED_OPENCODE_HOST = (() => {
|
|||
|
|
const raw = process.env.OPENCODE_HOST?.trim();
|
|||
|
|
if (!raw) return null;
|
|||
|
|
|
|||
|
|
const warnInvalidHost = (reason) => {
|
|||
|
|
console.warn(`[config] Ignoring OPENCODE_HOST=${JSON.stringify(raw)}: ${reason}`);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
let url;
|
|||
|
|
try {
|
|||
|
|
url = new URL(raw);
|
|||
|
|
} catch {
|
|||
|
|
warnInvalidHost('not a valid URL');
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|||
|
|
warnInvalidHost(`must use http or https scheme (got ${JSON.stringify(url.protocol)})`);
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
const port = parseInt(url.port, 10);
|
|||
|
|
if (!Number.isFinite(port) || port <= 0) {
|
|||
|
|
warnInvalidHost('must include an explicit port (example: http://hostname:4096)');
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
if (url.pathname !== '/' || url.search || url.hash) {
|
|||
|
|
warnInvalidHost('must not include path, query, or hash');
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
return { origin: url.origin, port };
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
// OPENCODE_HOST takes precedence over OPENCODE_PORT when both are set
|
|||
|
|
const ENV_EFFECTIVE_PORT = ENV_CONFIGURED_OPENCODE_HOST?.port ?? ENV_CONFIGURED_OPENCODE_PORT;
|
|||
|
|
|
|||
|
|
const ENV_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_START === 'true' ||
|
|||
|
|
process.env.OPENCHAMBER_SKIP_OPENCODE_START === 'true';
|
|||
|
|
const ENV_DESKTOP_NOTIFY = process.env.OPENCHAMBER_DESKTOP_NOTIFY === 'true';
|
|||
|
|
const ENV_CONFIGURED_OPENCODE_WSL_DISTRO =
|
|||
|
|
typeof process.env.OPENCODE_WSL_DISTRO === 'string' && process.env.OPENCODE_WSL_DISTRO.trim().length > 0
|
|||
|
|
? process.env.OPENCODE_WSL_DISTRO.trim()
|
|||
|
|
: (
|
|||
|
|
typeof process.env.OPENCHAMBER_OPENCODE_WSL_DISTRO === 'string' &&
|
|||
|
|
process.env.OPENCHAMBER_OPENCODE_WSL_DISTRO.trim().length > 0
|
|||
|
|
? process.env.OPENCHAMBER_OPENCODE_WSL_DISTRO.trim()
|
|||
|
|
: null
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// OpenCode server authentication (Basic Auth with username "opencode")
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Returns auth headers for OpenCode server requests if OPENCODE_SERVER_PASSWORD is set.
|
|||
|
|
* Uses Basic Auth with username "opencode" and the password from the env variable.
|
|||
|
|
*/
|
|||
|
|
function getOpenCodeAuthHeaders() {
|
|||
|
|
const password = normalizeOpenCodePassword(openCodeAuthPassword || process.env.OPENCODE_SERVER_PASSWORD || '');
|
|||
|
|
|
|||
|
|
if (!password) {
|
|||
|
|
return {};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const credentials = Buffer.from(`opencode:${password}`).toString('base64');
|
|||
|
|
return { Authorization: `Basic ${credentials}` };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isOpenCodeConnectionSecure() {
|
|||
|
|
return Object.prototype.hasOwnProperty.call(getOpenCodeAuthHeaders(), 'Authorization');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function generateSecureOpenCodePassword() {
|
|||
|
|
return crypto
|
|||
|
|
.randomBytes(32)
|
|||
|
|
.toString('base64')
|
|||
|
|
.replace(/\+/g, '-')
|
|||
|
|
.replace(/\//g, '_')
|
|||
|
|
.replace(/=+$/g, '');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isValidOpenCodePassword(password) {
|
|||
|
|
return typeof password === 'string' && password.trim().length > 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setOpenCodeAuthState(password, source) {
|
|||
|
|
const normalized = normalizeOpenCodePassword(password);
|
|||
|
|
if (!isValidOpenCodePassword(normalized)) {
|
|||
|
|
openCodeAuthPassword = null;
|
|||
|
|
openCodeAuthSource = null;
|
|||
|
|
delete process.env.OPENCODE_SERVER_PASSWORD;
|
|||
|
|
syncToHmrState();
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
openCodeAuthPassword = normalized;
|
|||
|
|
openCodeAuthSource = source;
|
|||
|
|
process.env.OPENCODE_SERVER_PASSWORD = normalized;
|
|||
|
|
syncToHmrState();
|
|||
|
|
return normalized;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function ensureLocalOpenCodeServerPassword({ rotateManaged = false } = {}) {
|
|||
|
|
if (isValidOpenCodePassword(userProvidedOpenCodePassword)) {
|
|||
|
|
return setOpenCodeAuthState(userProvidedOpenCodePassword, 'user-env');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (rotateManaged) {
|
|||
|
|
const rotatedPassword = setOpenCodeAuthState(generateSecureOpenCodePassword(), 'rotated');
|
|||
|
|
console.log('Rotated secure password for managed local OpenCode instance');
|
|||
|
|
return rotatedPassword;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (isValidOpenCodePassword(openCodeAuthPassword)) {
|
|||
|
|
return setOpenCodeAuthState(openCodeAuthPassword, openCodeAuthSource || 'generated');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const generatedPassword = setOpenCodeAuthState(generateSecureOpenCodePassword(), 'generated');
|
|||
|
|
console.log('Generated secure password for managed local OpenCode instance');
|
|||
|
|
return generatedPassword;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let cachedLoginShellEnvSnapshot = undefined;
|
|||
|
|
|
|||
|
|
function parseNullSeparatedEnvSnapshot(raw) {
|
|||
|
|
if (typeof raw !== 'string' || raw.length === 0) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = {};
|
|||
|
|
const entries = raw.split('\0');
|
|||
|
|
for (const entry of entries) {
|
|||
|
|
if (!entry) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
const idx = entry.indexOf('=');
|
|||
|
|
if (idx <= 0) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
const key = entry.slice(0, idx);
|
|||
|
|
const value = entry.slice(idx + 1);
|
|||
|
|
result[key] = value;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return Object.keys(result).length > 0 ? result : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getLoginShellEnvSnapshot() {
|
|||
|
|
if (cachedLoginShellEnvSnapshot !== undefined) {
|
|||
|
|
return cachedLoginShellEnvSnapshot;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (process.platform === 'win32') {
|
|||
|
|
const windowsSnapshot = getWindowsShellEnvSnapshot();
|
|||
|
|
cachedLoginShellEnvSnapshot = windowsSnapshot;
|
|||
|
|
return windowsSnapshot;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const shellCandidates = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean);
|
|||
|
|
|
|||
|
|
for (const shellPath of shellCandidates) {
|
|||
|
|
if (!isExecutable(shellPath)) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const result = spawnSync(shellPath, ['-lic', 'env -0'], {
|
|||
|
|
encoding: 'utf8',
|
|||
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|||
|
|
maxBuffer: 10 * 1024 * 1024,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (result.status !== 0) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const parsed = parseNullSeparatedEnvSnapshot(result.stdout || '');
|
|||
|
|
if (parsed) {
|
|||
|
|
cachedLoginShellEnvSnapshot = parsed;
|
|||
|
|
return parsed;
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
cachedLoginShellEnvSnapshot = null;
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getWindowsShellEnvSnapshot() {
|
|||
|
|
const parseResult = (stdout) => parseNullSeparatedEnvSnapshot(typeof stdout === 'string' ? stdout : '');
|
|||
|
|
|
|||
|
|
const psScript =
|
|||
|
|
"Get-ChildItem Env: | ForEach-Object { [Console]::Out.Write($_.Name); [Console]::Out.Write('='); [Console]::Out.Write($_.Value); [Console]::Out.Write([char]0) }";
|
|||
|
|
|
|||
|
|
const powershellCandidates = [
|
|||
|
|
'pwsh.exe',
|
|||
|
|
'powershell.exe',
|
|||
|
|
path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
for (const shellPath of powershellCandidates) {
|
|||
|
|
try {
|
|||
|
|
const result = spawnSync(shellPath, ['-NoLogo', '-Command', psScript], {
|
|||
|
|
encoding: 'utf8',
|
|||
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|||
|
|
maxBuffer: 10 * 1024 * 1024,
|
|||
|
|
});
|
|||
|
|
if (result.status !== 0) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
const parsed = parseResult(result.stdout);
|
|||
|
|
if (parsed) {
|
|||
|
|
return parsed;
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const comspec = process.env.ComSpec || 'cmd.exe';
|
|||
|
|
try {
|
|||
|
|
const result = spawnSync(comspec, ['/d', '/s', '/c', 'set'], {
|
|||
|
|
encoding: 'utf8',
|
|||
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|||
|
|
maxBuffer: 10 * 1024 * 1024,
|
|||
|
|
});
|
|||
|
|
if (result.status === 0 && typeof result.stdout === 'string' && result.stdout.length > 0) {
|
|||
|
|
return parseNullSeparatedEnvSnapshot(result.stdout.replace(/\r?\n/g, '\0'));
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function mergePathValues(preferred, fallback) {
|
|||
|
|
const merged = new Set();
|
|||
|
|
|
|||
|
|
const addSegments = (value) => {
|
|||
|
|
if (typeof value !== 'string' || !value) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
for (const segment of value.split(path.delimiter)) {
|
|||
|
|
if (segment) {
|
|||
|
|
merged.add(segment);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
addSegments(preferred);
|
|||
|
|
addSegments(fallback);
|
|||
|
|
|
|||
|
|
return Array.from(merged).join(path.delimiter);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function applyLoginShellEnvSnapshot() {
|
|||
|
|
const snapshot = getLoginShellEnvSnapshot();
|
|||
|
|
if (!snapshot) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_']);
|
|||
|
|
|
|||
|
|
for (const [key, value] of Object.entries(snapshot)) {
|
|||
|
|
if (skipKeys.has(key)) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
const existing = process.env[key];
|
|||
|
|
if (typeof existing === 'string' && existing.length > 0) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
process.env[key] = value;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
process.env.PATH = mergePathValues(snapshot.PATH || '', process.env.PATH || '');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
applyLoginShellEnvSnapshot();
|
|||
|
|
|
|||
|
|
const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix(
|
|||
|
|
process.env.OPENCODE_API_PREFIX || process.env.OPENCHAMBER_API_PREFIX || ''
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (ENV_CONFIGURED_API_PREFIX && ENV_CONFIGURED_API_PREFIX !== '') {
|
|||
|
|
console.warn('Ignoring configured OpenCode API prefix; API runs at root.');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let globalEventWatcherAbortController = null;
|
|||
|
|
|
|||
|
|
let resolvedOpencodeBinary = null;
|
|||
|
|
let resolvedOpencodeBinarySource = null;
|
|||
|
|
let resolvedNodeBinary = null;
|
|||
|
|
let resolvedBunBinary = null;
|
|||
|
|
let useWslForOpencode = false;
|
|||
|
|
let resolvedWslBinary = null;
|
|||
|
|
let resolvedWslOpencodePath = null;
|
|||
|
|
let resolvedWslDistro = null;
|
|||
|
|
|
|||
|
|
function isExecutable(filePath) {
|
|||
|
|
try {
|
|||
|
|
const stat = fs.statSync(filePath);
|
|||
|
|
if (!stat.isFile()) return false;
|
|||
|
|
if (process.platform === 'win32') {
|
|||
|
|
const ext = path.extname(filePath).toLowerCase();
|
|||
|
|
if (!ext) return true;
|
|||
|
|
return ['.exe', '.cmd', '.bat', '.com'].includes(ext);
|
|||
|
|
}
|
|||
|
|
fs.accessSync(filePath, fs.constants.X_OK);
|
|||
|
|
return true;
|
|||
|
|
} catch {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function prependToPath(dir) {
|
|||
|
|
const trimmed = typeof dir === 'string' ? dir.trim() : '';
|
|||
|
|
if (!trimmed) return;
|
|||
|
|
const current = process.env.PATH || '';
|
|||
|
|
const parts = current.split(path.delimiter).filter(Boolean);
|
|||
|
|
if (parts.includes(trimmed)) return;
|
|||
|
|
process.env.PATH = [trimmed, ...parts].join(path.delimiter);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function searchPathFor(binaryName) {
|
|||
|
|
const current = process.env.PATH || '';
|
|||
|
|
const parts = current.split(path.delimiter).filter(Boolean);
|
|||
|
|
for (const dir of parts) {
|
|||
|
|
const candidate = path.join(dir, binaryName);
|
|||
|
|
if (isExecutable(candidate)) {
|
|||
|
|
return candidate;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isWslExecutableValue(value) {
|
|||
|
|
if (typeof value !== 'string') return false;
|
|||
|
|
const trimmed = value.trim();
|
|||
|
|
if (!trimmed) return false;
|
|||
|
|
return /(^|[\\/])wsl(\.exe)?$/i.test(trimmed);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function clearWslOpencodeResolution() {
|
|||
|
|
useWslForOpencode = false;
|
|||
|
|
resolvedWslBinary = null;
|
|||
|
|
resolvedWslOpencodePath = null;
|
|||
|
|
resolvedWslDistro = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resolveWslExecutablePath() {
|
|||
|
|
if (process.platform !== 'win32') {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const explicit = [process.env.WSL_BINARY, process.env.OPENCHAMBER_WSL_BINARY]
|
|||
|
|
.map((v) => (typeof v === 'string' ? v.trim() : ''))
|
|||
|
|
.filter(Boolean);
|
|||
|
|
|
|||
|
|
for (const candidate of explicit) {
|
|||
|
|
if (isExecutable(candidate)) {
|
|||
|
|
return candidate;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const result = spawnSync('where', ['wsl'], {
|
|||
|
|
encoding: 'utf8',
|
|||
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|||
|
|
});
|
|||
|
|
if (result.status === 0) {
|
|||
|
|
const lines = (result.stdout || '')
|
|||
|
|
.split(/\r?\n/)
|
|||
|
|
.map((line) => line.trim())
|
|||
|
|
.filter(Boolean);
|
|||
|
|
const found = lines.find((line) => isExecutable(line));
|
|||
|
|
if (found) {
|
|||
|
|
return found;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const systemRoot = process.env.SystemRoot || 'C:\\Windows';
|
|||
|
|
const fallback = path.join(systemRoot, 'System32', 'wsl.exe');
|
|||
|
|
if (isExecutable(fallback)) {
|
|||
|
|
return fallback;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildWslExecArgs(execArgs, distroOverride = null) {
|
|||
|
|
const distro = typeof distroOverride === 'string' && distroOverride.trim().length > 0
|
|||
|
|
? distroOverride.trim()
|
|||
|
|
: ENV_CONFIGURED_OPENCODE_WSL_DISTRO;
|
|||
|
|
|
|||
|
|
const prefix = distro ? ['-d', distro] : [];
|
|||
|
|
return [...prefix, '--exec', ...execArgs];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function probeWslForOpencode() {
|
|||
|
|
if (process.platform !== 'win32') {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const wslBinary = resolveWslExecutablePath();
|
|||
|
|
if (!wslBinary) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const result = spawnSync(
|
|||
|
|
wslBinary,
|
|||
|
|
buildWslExecArgs(['sh', '-lc', 'command -v opencode']),
|
|||
|
|
{
|
|||
|
|
encoding: 'utf8',
|
|||
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|||
|
|
timeout: 6000,
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (result.status !== 0) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const lines = (result.stdout || '')
|
|||
|
|
.split(/\r?\n/)
|
|||
|
|
.map((line) => line.trim())
|
|||
|
|
.filter(Boolean);
|
|||
|
|
const found = lines[0] || '';
|
|||
|
|
if (!found) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
wslBinary,
|
|||
|
|
opencodePath: found,
|
|||
|
|
distro: ENV_CONFIGURED_OPENCODE_WSL_DISTRO,
|
|||
|
|
};
|
|||
|
|
} catch {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function applyWslOpencodeResolution({ wslBinary, opencodePath, source = 'wsl', distro = null } = {}) {
|
|||
|
|
const resolvedWsl = wslBinary || resolveWslExecutablePath();
|
|||
|
|
if (!resolvedWsl) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
useWslForOpencode = true;
|
|||
|
|
resolvedWslBinary = resolvedWsl;
|
|||
|
|
resolvedWslOpencodePath = typeof opencodePath === 'string' && opencodePath.trim().length > 0
|
|||
|
|
? opencodePath.trim()
|
|||
|
|
: 'opencode';
|
|||
|
|
resolvedWslDistro = typeof distro === 'string' && distro.trim().length > 0 ? distro.trim() : ENV_CONFIGURED_OPENCODE_WSL_DISTRO;
|
|||
|
|
resolvedOpencodeBinary = `wsl:${resolvedWslOpencodePath}`;
|
|||
|
|
resolvedOpencodeBinarySource = source;
|
|||
|
|
|
|||
|
|
// Keep OPENCODE_BINARY empty in WSL mode to avoid native spawn attempts.
|
|||
|
|
delete process.env.OPENCODE_BINARY;
|
|||
|
|
return resolvedOpencodeBinary;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resolveOpencodeCliPath() {
|
|||
|
|
const explicit = [
|
|||
|
|
process.env.OPENCODE_BINARY,
|
|||
|
|
process.env.OPENCODE_PATH,
|
|||
|
|
process.env.OPENCHAMBER_OPENCODE_PATH,
|
|||
|
|
process.env.OPENCHAMBER_OPENCODE_BIN,
|
|||
|
|
]
|
|||
|
|
.map((v) => (typeof v === 'string' ? v.trim() : ''))
|
|||
|
|
.filter(Boolean);
|
|||
|
|
|
|||
|
|
for (const candidate of explicit) {
|
|||
|
|
if (isExecutable(candidate)) {
|
|||
|
|
clearWslOpencodeResolution();
|
|||
|
|
resolvedOpencodeBinarySource = 'env';
|
|||
|
|
return candidate;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const resolvedFromPath = searchPathFor('opencode');
|
|||
|
|
if (resolvedFromPath) {
|
|||
|
|
clearWslOpencodeResolution();
|
|||
|
|
resolvedOpencodeBinarySource = 'path';
|
|||
|
|
return resolvedFromPath;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const home = os.homedir();
|
|||
|
|
const unixFallbacks = [
|
|||
|
|
path.join(home, '.opencode', 'bin', 'opencode'),
|
|||
|
|
path.join(home, '.bun', 'bin', 'opencode'),
|
|||
|
|
path.join(home, '.local', 'bin', 'opencode'),
|
|||
|
|
path.join(home, 'bin', 'opencode'),
|
|||
|
|
'/opt/homebrew/bin/opencode',
|
|||
|
|
'/usr/local/bin/opencode',
|
|||
|
|
'/usr/bin/opencode',
|
|||
|
|
'/bin/opencode',
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const winFallbacks = (() => {
|
|||
|
|
const userProfile = process.env.USERPROFILE || home;
|
|||
|
|
const appData = process.env.APPDATA || '';
|
|||
|
|
const localAppData = process.env.LOCALAPPDATA || '';
|
|||
|
|
const programData = process.env.ProgramData || 'C:\\ProgramData';
|
|||
|
|
|
|||
|
|
return [
|
|||
|
|
path.join(userProfile, '.opencode', 'bin', 'opencode.exe'),
|
|||
|
|
path.join(userProfile, '.opencode', 'bin', 'opencode.cmd'),
|
|||
|
|
path.join(appData, 'npm', 'opencode.cmd'),
|
|||
|
|
path.join(userProfile, 'scoop', 'shims', 'opencode.cmd'),
|
|||
|
|
path.join(programData, 'chocolatey', 'bin', 'opencode.exe'),
|
|||
|
|
path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'),
|
|||
|
|
path.join(userProfile, '.bun', 'bin', 'opencode.exe'),
|
|||
|
|
path.join(userProfile, '.bun', 'bin', 'opencode.cmd'),
|
|||
|
|
localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '',
|
|||
|
|
].filter(Boolean);
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks;
|
|||
|
|
for (const candidate of fallbacks) {
|
|||
|
|
if (isExecutable(candidate)) {
|
|||
|
|
clearWslOpencodeResolution();
|
|||
|
|
resolvedOpencodeBinarySource = 'fallback';
|
|||
|
|
return candidate;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (process.platform === 'win32') {
|
|||
|
|
try {
|
|||
|
|
const result = spawnSync('where', ['opencode'], {
|
|||
|
|
encoding: 'utf8',
|
|||
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|||
|
|
});
|
|||
|
|
if (result.status === 0) {
|
|||
|
|
const lines = (result.stdout || '')
|
|||
|
|
.split(/\r?\n/)
|
|||
|
|
.map((line) => line.trim())
|
|||
|
|
.filter(Boolean);
|
|||
|
|
const found = lines.find((line) => isExecutable(line));
|
|||
|
|
if (found) {
|
|||
|
|
clearWslOpencodeResolution();
|
|||
|
|
resolvedOpencodeBinarySource = 'where';
|
|||
|
|
return found;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
const wsl = probeWslForOpencode();
|
|||
|
|
if (wsl) {
|
|||
|
|
return applyWslOpencodeResolution({
|
|||
|
|
wslBinary: wsl.wslBinary,
|
|||
|
|
opencodePath: wsl.opencodePath,
|
|||
|
|
source: 'wsl',
|
|||
|
|
distro: wsl.distro,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean);
|
|||
|
|
for (const shell of shells) {
|
|||
|
|
if (!isExecutable(shell)) continue;
|
|||
|
|
try {
|
|||
|
|
const result = spawnSync(shell, ['-lic', 'command -v opencode'], {
|
|||
|
|
encoding: 'utf8',
|
|||
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|||
|
|
});
|
|||
|
|
if (result.status === 0) {
|
|||
|
|
const found = (result.stdout || '').trim().split(/\s+/).pop() || '';
|
|||
|
|
if (found && isExecutable(found)) {
|
|||
|
|
clearWslOpencodeResolution();
|
|||
|
|
resolvedOpencodeBinarySource = 'shell';
|
|||
|
|
return found;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resolveNodeCliPath() {
|
|||
|
|
const explicit = [process.env.NODE_BINARY, process.env.OPENCHAMBER_NODE_BINARY]
|
|||
|
|
.map((v) => (typeof v === 'string' ? v.trim() : ''))
|
|||
|
|
.filter(Boolean);
|
|||
|
|
|
|||
|
|
for (const candidate of explicit) {
|
|||
|
|
if (isExecutable(candidate)) {
|
|||
|
|
return candidate;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const resolvedFromPath = searchPathFor('node');
|
|||
|
|
if (resolvedFromPath) {
|
|||
|
|
return resolvedFromPath;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const unixFallbacks = [
|
|||
|
|
'/opt/homebrew/bin/node',
|
|||
|
|
'/usr/local/bin/node',
|
|||
|
|
'/usr/bin/node',
|
|||
|
|
'/bin/node',
|
|||
|
|
];
|
|||
|
|
for (const candidate of unixFallbacks) {
|
|||
|
|
if (isExecutable(candidate)) {
|
|||
|
|
return candidate;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (process.platform === 'win32') {
|
|||
|
|
try {
|
|||
|
|
const result = spawnSync('where', ['node'], {
|
|||
|
|
encoding: 'utf8',
|
|||
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|||
|
|
});
|
|||
|
|
if (result.status === 0) {
|
|||
|
|
const lines = (result.stdout || '')
|
|||
|
|
.split(/\r?\n/)
|
|||
|
|
.map((line) => line.trim())
|
|||
|
|
.filter(Boolean);
|
|||
|
|
const found = lines.find((line) => isExecutable(line));
|
|||
|
|
if (found) return found;
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean);
|
|||
|
|
for (const shell of shells) {
|
|||
|
|
if (!isExecutable(shell)) continue;
|
|||
|
|
try {
|
|||
|
|
const result = spawnSync(shell, ['-lic', 'command -v node'], {
|
|||
|
|
encoding: 'utf8',
|
|||
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|||
|
|
});
|
|||
|
|
if (result.status === 0) {
|
|||
|
|
const found = (result.stdout || '').trim().split(/\s+/).pop() || '';
|
|||
|
|
if (found && isExecutable(found)) {
|
|||
|
|
return found;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resolveBunCliPath() {
|
|||
|
|
const explicit = [process.env.BUN_BINARY, process.env.OPENCHAMBER_BUN_BINARY]
|
|||
|
|
.map((v) => (typeof v === 'string' ? v.trim() : ''))
|
|||
|
|
.filter(Boolean);
|
|||
|
|
|
|||
|
|
for (const candidate of explicit) {
|
|||
|
|
if (isExecutable(candidate)) {
|
|||
|
|
return candidate;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const resolvedFromPath = searchPathFor('bun');
|
|||
|
|
if (resolvedFromPath) {
|
|||
|
|
return resolvedFromPath;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const home = os.homedir();
|
|||
|
|
const unixFallbacks = [
|
|||
|
|
path.join(home, '.bun', 'bin', 'bun'),
|
|||
|
|
'/opt/homebrew/bin/bun',
|
|||
|
|
'/usr/local/bin/bun',
|
|||
|
|
'/usr/bin/bun',
|
|||
|
|
'/bin/bun',
|
|||
|
|
];
|
|||
|
|
for (const candidate of unixFallbacks) {
|
|||
|
|
if (isExecutable(candidate)) {
|
|||
|
|
return candidate;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (process.platform === 'win32') {
|
|||
|
|
const userProfile = process.env.USERPROFILE || home;
|
|||
|
|
const winFallbacks = [
|
|||
|
|
path.join(userProfile, '.bun', 'bin', 'bun.exe'),
|
|||
|
|
path.join(userProfile, '.bun', 'bin', 'bun.cmd'),
|
|||
|
|
];
|
|||
|
|
for (const candidate of winFallbacks) {
|
|||
|
|
if (isExecutable(candidate)) return candidate;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const result = spawnSync('where', ['bun'], {
|
|||
|
|
encoding: 'utf8',
|
|||
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|||
|
|
});
|
|||
|
|
if (result.status === 0) {
|
|||
|
|
const lines = (result.stdout || '')
|
|||
|
|
.split(/\r?\n/)
|
|||
|
|
.map((line) => line.trim())
|
|||
|
|
.filter(Boolean);
|
|||
|
|
const found = lines.find((line) => isExecutable(line));
|
|||
|
|
if (found) return found;
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean);
|
|||
|
|
for (const shell of shells) {
|
|||
|
|
if (!isExecutable(shell)) continue;
|
|||
|
|
try {
|
|||
|
|
const result = spawnSync(shell, ['-lic', 'command -v bun'], {
|
|||
|
|
encoding: 'utf8',
|
|||
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|||
|
|
});
|
|||
|
|
if (result.status === 0) {
|
|||
|
|
const found = (result.stdout || '').trim().split(/\s+/).pop() || '';
|
|||
|
|
if (found && isExecutable(found)) {
|
|||
|
|
return found;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ensureBunCliEnv() {
|
|||
|
|
if (resolvedBunBinary) {
|
|||
|
|
return resolvedBunBinary;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const resolved = resolveBunCliPath();
|
|||
|
|
if (resolved) {
|
|||
|
|
prependToPath(path.dirname(resolved));
|
|||
|
|
resolvedBunBinary = resolved;
|
|||
|
|
return resolved;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ensureNodeCliEnv() {
|
|||
|
|
if (resolvedNodeBinary) {
|
|||
|
|
return resolvedNodeBinary;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const resolved = resolveNodeCliPath();
|
|||
|
|
if (resolved) {
|
|||
|
|
prependToPath(path.dirname(resolved));
|
|||
|
|
resolvedNodeBinary = resolved;
|
|||
|
|
return resolved;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function readShebang(opencodePath) {
|
|||
|
|
if (!opencodePath || typeof opencodePath !== 'string') {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
// Best effort: detect "#!/usr/bin/env <runtime>" without reading whole file.
|
|||
|
|
const fd = fs.openSync(opencodePath, 'r');
|
|||
|
|
try {
|
|||
|
|
const buf = Buffer.alloc(256);
|
|||
|
|
const bytes = fs.readSync(fd, buf, 0, buf.length, 0);
|
|||
|
|
const head = buf.subarray(0, bytes).toString('utf8');
|
|||
|
|
const firstLine = head.split(/\r?\n/, 1)[0] || '';
|
|||
|
|
if (!firstLine.startsWith('#!')) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
const shebang = firstLine.slice(2).trim();
|
|||
|
|
if (!shebang) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
return shebang;
|
|||
|
|
} finally {
|
|||
|
|
try {
|
|||
|
|
fs.closeSync(fd);
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function opencodeShimInterpreter(opencodePath) {
|
|||
|
|
const shebang = readShebang(opencodePath);
|
|||
|
|
if (!shebang) return null;
|
|||
|
|
if (/\bnode\b/i.test(shebang)) return 'node';
|
|||
|
|
if (/\bbun\b/i.test(shebang)) return 'bun';
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ensureOpencodeShimRuntime(opencodePath) {
|
|||
|
|
const runtime = opencodeShimInterpreter(opencodePath);
|
|||
|
|
if (runtime === 'node') {
|
|||
|
|
ensureNodeCliEnv();
|
|||
|
|
}
|
|||
|
|
if (runtime === 'bun') {
|
|||
|
|
ensureBunCliEnv();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function normalizeOpencodeBinarySetting(raw) {
|
|||
|
|
if (typeof raw !== 'string') {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
const trimmed = normalizeDirectoryPath(raw).trim();
|
|||
|
|
if (!trimmed) {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const stat = fs.statSync(trimmed);
|
|||
|
|
if (stat.isDirectory()) {
|
|||
|
|
const bin = process.platform === 'win32' ? 'opencode.exe' : 'opencode';
|
|||
|
|
return path.join(trimmed, bin);
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return trimmed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function applyOpencodeBinaryFromSettings() {
|
|||
|
|
try {
|
|||
|
|
const settings = await readSettingsFromDiskMigrated();
|
|||
|
|
if (!settings || typeof settings !== 'object') {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
if (!Object.prototype.hasOwnProperty.call(settings, 'opencodeBinary')) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const normalized = normalizeOpencodeBinarySetting(settings.opencodeBinary);
|
|||
|
|
|
|||
|
|
if (normalized === '') {
|
|||
|
|
delete process.env.OPENCODE_BINARY;
|
|||
|
|
resolvedOpencodeBinary = null;
|
|||
|
|
resolvedOpencodeBinarySource = null;
|
|||
|
|
clearWslOpencodeResolution();
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const raw = typeof settings.opencodeBinary === 'string' ? settings.opencodeBinary.trim() : '';
|
|||
|
|
|
|||
|
|
const explicitWslPath = process.platform === 'win32' && typeof raw === 'string'
|
|||
|
|
? raw.match(/^wsl:\s*(.+)$/i)
|
|||
|
|
: null;
|
|||
|
|
|
|||
|
|
if (explicitWslPath && explicitWslPath[1] && explicitWslPath[1].trim().length > 0) {
|
|||
|
|
const probe = probeWslForOpencode();
|
|||
|
|
const applied = applyWslOpencodeResolution({
|
|||
|
|
wslBinary: probe?.wslBinary || resolveWslExecutablePath(),
|
|||
|
|
opencodePath: explicitWslPath[1].trim(),
|
|||
|
|
source: 'settings-wsl-path',
|
|||
|
|
distro: probe?.distro || ENV_CONFIGURED_OPENCODE_WSL_DISTRO,
|
|||
|
|
});
|
|||
|
|
if (applied) {
|
|||
|
|
return applied;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (process.platform === 'win32' && (isWslExecutableValue(raw) || isWslExecutableValue(normalized || ''))) {
|
|||
|
|
const probe = probeWslForOpencode();
|
|||
|
|
const applied = applyWslOpencodeResolution({
|
|||
|
|
wslBinary: probe?.wslBinary || normalized || raw || null,
|
|||
|
|
opencodePath: probe?.opencodePath || 'opencode',
|
|||
|
|
source: 'settings-wsl',
|
|||
|
|
distro: probe?.distro || ENV_CONFIGURED_OPENCODE_WSL_DISTRO,
|
|||
|
|
});
|
|||
|
|
if (applied) {
|
|||
|
|
return applied;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (normalized && isExecutable(normalized)) {
|
|||
|
|
clearWslOpencodeResolution();
|
|||
|
|
process.env.OPENCODE_BINARY = normalized;
|
|||
|
|
prependToPath(path.dirname(normalized));
|
|||
|
|
resolvedOpencodeBinary = normalized;
|
|||
|
|
resolvedOpencodeBinarySource = 'settings';
|
|||
|
|
ensureOpencodeShimRuntime(normalized);
|
|||
|
|
return normalized;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (raw) {
|
|||
|
|
console.warn(`Configured settings.opencodeBinary is not executable: ${raw}`);
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ensureOpencodeCliEnv() {
|
|||
|
|
if (resolvedOpencodeBinary) {
|
|||
|
|
if (useWslForOpencode) {
|
|||
|
|
return resolvedOpencodeBinary;
|
|||
|
|
}
|
|||
|
|
ensureOpencodeShimRuntime(resolvedOpencodeBinary);
|
|||
|
|
return resolvedOpencodeBinary;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const existing = typeof process.env.OPENCODE_BINARY === 'string' ? process.env.OPENCODE_BINARY.trim() : '';
|
|||
|
|
if (existing && isExecutable(existing)) {
|
|||
|
|
clearWslOpencodeResolution();
|
|||
|
|
resolvedOpencodeBinary = existing;
|
|||
|
|
resolvedOpencodeBinarySource = resolvedOpencodeBinarySource || 'env';
|
|||
|
|
prependToPath(path.dirname(existing));
|
|||
|
|
ensureOpencodeShimRuntime(existing);
|
|||
|
|
return resolvedOpencodeBinary;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const resolved = resolveOpencodeCliPath();
|
|||
|
|
if (resolved) {
|
|||
|
|
if (useWslForOpencode) {
|
|||
|
|
resolvedOpencodeBinary = resolved;
|
|||
|
|
resolvedOpencodeBinarySource = resolvedOpencodeBinarySource || 'wsl';
|
|||
|
|
console.log(`Resolved opencode CLI via WSL: ${resolvedWslOpencodePath || 'opencode'}`);
|
|||
|
|
return resolved;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
process.env.OPENCODE_BINARY = resolved;
|
|||
|
|
prependToPath(path.dirname(resolved));
|
|||
|
|
ensureOpencodeShimRuntime(resolved);
|
|||
|
|
resolvedOpencodeBinary = resolved;
|
|||
|
|
resolvedOpencodeBinarySource = resolvedOpencodeBinarySource || 'unknown';
|
|||
|
|
console.log(`Resolved opencode CLI: ${resolved}`);
|
|||
|
|
return resolved;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
clearWslOpencodeResolution();
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const startGlobalEventWatcher = async () => {
|
|||
|
|
if (globalEventWatcherAbortController) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await waitForOpenCodePort();
|
|||
|
|
|
|||
|
|
globalEventWatcherAbortController = new AbortController();
|
|||
|
|
const signal = globalEventWatcherAbortController.signal;
|
|||
|
|
|
|||
|
|
let attempt = 0;
|
|||
|
|
|
|||
|
|
const run = async () => {
|
|||
|
|
while (!signal.aborted) {
|
|||
|
|
attempt += 1;
|
|||
|
|
let upstream;
|
|||
|
|
let reader;
|
|||
|
|
try {
|
|||
|
|
const url = buildOpenCodeUrl('/global/event', '');
|
|||
|
|
upstream = await fetch(url, {
|
|||
|
|
headers: {
|
|||
|
|
Accept: 'text/event-stream',
|
|||
|
|
'Cache-Control': 'no-cache',
|
|||
|
|
Connection: 'keep-alive',
|
|||
|
|
...getOpenCodeAuthHeaders(),
|
|||
|
|
},
|
|||
|
|
signal,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!upstream.ok || !upstream.body) {
|
|||
|
|
throw new Error(`bad status ${upstream.status}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('[PushWatcher] connected');
|
|||
|
|
|
|||
|
|
const decoder = new TextDecoder();
|
|||
|
|
reader = upstream.body.getReader();
|
|||
|
|
let buffer = '';
|
|||
|
|
|
|||
|
|
while (!signal.aborted) {
|
|||
|
|
const { value, done } = await reader.read();
|
|||
|
|
if (done) {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
|
|||
|
|
|
|||
|
|
let separatorIndex = buffer.indexOf('\n\n');
|
|||
|
|
while (separatorIndex !== -1) {
|
|||
|
|
const block = buffer.slice(0, separatorIndex);
|
|||
|
|
buffer = buffer.slice(separatorIndex + 2);
|
|||
|
|
separatorIndex = buffer.indexOf('\n\n');
|
|||
|
|
const payload = parseSseDataPayload(block);
|
|||
|
|
// Cache session titles from session.updated/session.created events
|
|||
|
|
maybeCacheSessionInfoFromEvent(payload);
|
|||
|
|
void maybeSendPushForTrigger(payload);
|
|||
|
|
// Track session activity independently of UI (mirrors Tauri desktop behavior)
|
|||
|
|
const transitions = deriveSessionActivityTransitions(payload);
|
|||
|
|
if (transitions && transitions.length > 0) {
|
|||
|
|
for (const activity of transitions) {
|
|||
|
|
setSessionActivityPhase(activity.sessionId, activity.phase);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Update authoritative session state from OpenCode events
|
|||
|
|
if (payload && payload.type === 'session.status') {
|
|||
|
|
const update = extractSessionStatusUpdate(payload);
|
|||
|
|
if (update) {
|
|||
|
|
updateSessionState(update.sessionId, update.type, update.eventId || `sse-${Date.now()}`, {
|
|||
|
|
attempt: update.attempt,
|
|||
|
|
message: update.message,
|
|||
|
|
next: update.next,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
if (signal.aborted) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
console.warn('[PushWatcher] disconnected', error?.message ?? error);
|
|||
|
|
} finally {
|
|||
|
|
try {
|
|||
|
|
if (reader) {
|
|||
|
|
await reader.cancel();
|
|||
|
|
reader.releaseLock();
|
|||
|
|
} else if (upstream?.body && !upstream.body.locked) {
|
|||
|
|
await upstream.body.cancel();
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const backoffMs = Math.min(1000 * Math.pow(2, Math.min(attempt, 5)), 30000);
|
|||
|
|
await new Promise((r) => setTimeout(r, backoffMs));
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
void run();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const stopGlobalEventWatcher = () => {
|
|||
|
|
if (!globalEventWatcherAbortController) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
globalEventWatcherAbortController.abort();
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
globalEventWatcherAbortController = null;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
|
|||
|
|
function setOpenCodePort(port) {
|
|||
|
|
if (!Number.isFinite(port) || port <= 0) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const numericPort = Math.trunc(port);
|
|||
|
|
const portChanged = openCodePort !== numericPort;
|
|||
|
|
|
|||
|
|
if (portChanged || openCodePort === null) {
|
|||
|
|
openCodePort = numericPort;
|
|||
|
|
syncToHmrState();
|
|||
|
|
console.log(`Detected OpenCode port: ${openCodePort}`);
|
|||
|
|
|
|||
|
|
if (portChanged) {
|
|||
|
|
isOpenCodeReady = false;
|
|||
|
|
}
|
|||
|
|
openCodeNotReadySince = Date.now();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
lastOpenCodeError = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function waitForOpenCodePort(timeoutMs = 15000) {
|
|||
|
|
if (openCodePort !== null) {
|
|||
|
|
return openCodePort;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const deadline = Date.now() + timeoutMs;
|
|||
|
|
while (Date.now() < deadline) {
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|||
|
|
if (openCodePort !== null) {
|
|||
|
|
return openCodePort;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
throw new Error('Timed out waiting for OpenCode port');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getLoginShellPath() {
|
|||
|
|
const snapshot = getLoginShellEnvSnapshot();
|
|||
|
|
if (!snapshot || typeof snapshot.PATH !== 'string' || snapshot.PATH.length === 0) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
return snapshot.PATH;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildAugmentedPath() {
|
|||
|
|
const augmented = new Set();
|
|||
|
|
|
|||
|
|
const loginShellPath = getLoginShellPath();
|
|||
|
|
if (loginShellPath) {
|
|||
|
|
for (const segment of loginShellPath.split(path.delimiter)) {
|
|||
|
|
if (segment) {
|
|||
|
|
augmented.add(segment);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const current = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
|||
|
|
for (const segment of current) {
|
|||
|
|
augmented.add(segment);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return Array.from(augmented).join(path.delimiter);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const API_PREFIX_CANDIDATES = [''];
|
|||
|
|
|
|||
|
|
async function waitForReady(url, timeoutMs = 10000) {
|
|||
|
|
const start = Date.now();
|
|||
|
|
while (Date.now() - start < timeoutMs) {
|
|||
|
|
try {
|
|||
|
|
const controller = new AbortController();
|
|||
|
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
|||
|
|
const res = await fetch(`${url.replace(/\/+$/, '')}/global/health`, {
|
|||
|
|
method: 'GET',
|
|||
|
|
headers: {
|
|||
|
|
Accept: 'application/json',
|
|||
|
|
...getOpenCodeAuthHeaders(),
|
|||
|
|
},
|
|||
|
|
signal: controller.signal
|
|||
|
|
});
|
|||
|
|
clearTimeout(timeout);
|
|||
|
|
|
|||
|
|
if (res.ok) {
|
|||
|
|
const body = await res.json().catch(() => null);
|
|||
|
|
if (body?.healthy === true) {
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
await new Promise(r => setTimeout(r, 100));
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function normalizeApiPrefix(prefix) {
|
|||
|
|
if (!prefix) {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (prefix.includes('://')) {
|
|||
|
|
try {
|
|||
|
|
const parsed = new URL(prefix);
|
|||
|
|
return normalizeApiPrefix(parsed.pathname);
|
|||
|
|
} catch (error) {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const trimmed = prefix.trim();
|
|||
|
|
if (!trimmed || trimmed === '/') {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|||
|
|
return withLeading.endsWith('/') ? withLeading.slice(0, -1) : withLeading;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setDetectedOpenCodeApiPrefix() {
|
|||
|
|
openCodeApiPrefix = '';
|
|||
|
|
openCodeApiPrefixDetected = true;
|
|||
|
|
if (openCodeApiDetectionTimer) {
|
|||
|
|
clearTimeout(openCodeApiDetectionTimer);
|
|||
|
|
openCodeApiDetectionTimer = null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getCandidateApiPrefixes() {
|
|||
|
|
return API_PREFIX_CANDIDATES;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildOpenCodeUrl(path, prefixOverride) {
|
|||
|
|
if (!openCodePort) {
|
|||
|
|
throw new Error('OpenCode port is not available');
|
|||
|
|
}
|
|||
|
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|||
|
|
const prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : '');
|
|||
|
|
const fullPath = `${prefix}${normalizedPath}`;
|
|||
|
|
const base = openCodeBaseUrl ?? `http://localhost:${openCodePort}`;
|
|||
|
|
return `${base}${fullPath}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function parseSseDataPayload(block) {
|
|||
|
|
if (!block || typeof block !== 'string') {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
const dataLines = block
|
|||
|
|
.split('\n')
|
|||
|
|
.filter((line) => line.startsWith('data:'))
|
|||
|
|
.map((line) => line.slice(5).replace(/^\s/, ''));
|
|||
|
|
|
|||
|
|
if (dataLines.length === 0) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const payloadText = dataLines.join('\n').trim();
|
|||
|
|
if (!payloadText) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const parsed = JSON.parse(payloadText);
|
|||
|
|
if (
|
|||
|
|
parsed &&
|
|||
|
|
typeof parsed === 'object' &&
|
|||
|
|
typeof parsed.payload === 'object' &&
|
|||
|
|
parsed.payload !== null
|
|||
|
|
) {
|
|||
|
|
return parsed.payload;
|
|||
|
|
}
|
|||
|
|
return parsed;
|
|||
|
|
} catch {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function extractSessionStatusUpdate(payload) {
|
|||
|
|
if (!payload || typeof payload !== 'object' || payload.type !== 'session.status') {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const props = payload.properties ?? {};
|
|||
|
|
const status =
|
|||
|
|
props.status ??
|
|||
|
|
props.session?.status ??
|
|||
|
|
props.sessionInfo?.status;
|
|||
|
|
const metadata =
|
|||
|
|
props.metadata ??
|
|||
|
|
(typeof status === 'object' && status !== null ? status.metadata : null);
|
|||
|
|
|
|||
|
|
const sessionId = props.sessionID ?? props.sessionId;
|
|||
|
|
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const statusType =
|
|||
|
|
typeof status === 'string'
|
|||
|
|
? status
|
|||
|
|
: typeof status?.type === 'string'
|
|||
|
|
? status.type
|
|||
|
|
: typeof status?.status === 'string'
|
|||
|
|
? status.status
|
|||
|
|
: typeof props.type === 'string'
|
|||
|
|
? props.type
|
|||
|
|
: typeof props.phase === 'string'
|
|||
|
|
? props.phase
|
|||
|
|
: typeof props.state === 'string'
|
|||
|
|
? props.state
|
|||
|
|
: null;
|
|||
|
|
|
|||
|
|
const normalizedType =
|
|||
|
|
statusType === 'idle' || statusType === 'busy' || statusType === 'retry'
|
|||
|
|
? statusType
|
|||
|
|
: null;
|
|||
|
|
|
|||
|
|
if (!normalizedType) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const attempt =
|
|||
|
|
typeof status?.attempt === 'number'
|
|||
|
|
? status.attempt
|
|||
|
|
: typeof props.attempt === 'number'
|
|||
|
|
? props.attempt
|
|||
|
|
: typeof metadata?.attempt === 'number'
|
|||
|
|
? metadata.attempt
|
|||
|
|
: undefined;
|
|||
|
|
const message =
|
|||
|
|
typeof status?.message === 'string'
|
|||
|
|
? status.message
|
|||
|
|
: typeof props.message === 'string'
|
|||
|
|
? props.message
|
|||
|
|
: typeof metadata?.message === 'string'
|
|||
|
|
? metadata.message
|
|||
|
|
: undefined;
|
|||
|
|
const next =
|
|||
|
|
typeof status?.next === 'number'
|
|||
|
|
? status.next
|
|||
|
|
: typeof props.next === 'number'
|
|||
|
|
? props.next
|
|||
|
|
: typeof metadata?.next === 'number'
|
|||
|
|
? metadata.next
|
|||
|
|
: undefined;
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
sessionId,
|
|||
|
|
type: normalizedType,
|
|||
|
|
attempt,
|
|||
|
|
message,
|
|||
|
|
next,
|
|||
|
|
eventId: typeof props.eventId === 'string' ? props.eventId : null,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function emitDesktopNotification(payload) {
|
|||
|
|
if (!ENV_DESKTOP_NOTIFY) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!payload || typeof payload !== 'object') {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// One-line protocol consumed by the Tauri shell.
|
|||
|
|
process.stdout.write(`${DESKTOP_NOTIFY_PREFIX}${JSON.stringify(payload)}\n`);
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function broadcastUiNotification(payload) {
|
|||
|
|
if (!payload || typeof payload !== 'object') {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (uiNotificationClients.size === 0) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const res of uiNotificationClients) {
|
|||
|
|
try {
|
|||
|
|
writeSseEvent(res, {
|
|||
|
|
type: 'openchamber:notification',
|
|||
|
|
properties: {
|
|||
|
|
...payload,
|
|||
|
|
// Tell the UI whether the sidecar stdout notification channel is active.
|
|||
|
|
// When true, the desktop UI should skip this SSE notification to avoid duplicates.
|
|||
|
|
// When false (e.g. tauri dev), the UI must handle this SSE notification itself.
|
|||
|
|
desktopStdoutActive: ENV_DESKTOP_NOTIFY,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isStreamingAssistantPart(properties) {
|
|||
|
|
if (!properties || typeof properties !== 'object') {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const info = properties?.info;
|
|||
|
|
const role = info?.role;
|
|||
|
|
if (role !== 'assistant') {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const part = properties?.part;
|
|||
|
|
const partType = part?.type;
|
|||
|
|
return (
|
|||
|
|
partType === 'step-start' ||
|
|||
|
|
partType === 'text' ||
|
|||
|
|
partType === 'tool' ||
|
|||
|
|
partType === 'reasoning' ||
|
|||
|
|
partType === 'file' ||
|
|||
|
|
partType === 'patch'
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function deriveSessionActivityTransitions(payload) {
|
|||
|
|
if (!payload || typeof payload !== 'object') {
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (payload.type === 'session.status') {
|
|||
|
|
const update = extractSessionStatusUpdate(payload);
|
|||
|
|
if (update) {
|
|||
|
|
const phase = update.type === 'busy' || update.type === 'retry' ? 'busy' : 'idle';
|
|||
|
|
return [{ sessionId: update.sessionId, phase }];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (payload.type === 'message.updated') {
|
|||
|
|
const info = payload.properties?.info;
|
|||
|
|
const sessionId = info?.sessionID ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.sessionId;
|
|||
|
|
const role = info?.role;
|
|||
|
|
const finish = info?.finish;
|
|||
|
|
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
|
|||
|
|
return [{ sessionId, phase: 'cooldown' }];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (payload.type === 'message.part.updated' || payload.type === 'message.part.delta') {
|
|||
|
|
const info = payload.properties?.info;
|
|||
|
|
const sessionId = info?.sessionID ?? info?.sessionId ?? payload.properties?.sessionID ?? payload.properties?.sessionId;
|
|||
|
|
const role = info?.role;
|
|||
|
|
const finish = info?.finish;
|
|||
|
|
|
|||
|
|
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant') {
|
|||
|
|
const transitions = [];
|
|||
|
|
|
|||
|
|
// Desktop parity: mark busy when we see assistant parts streaming.
|
|||
|
|
if (isStreamingAssistantPart(payload.properties)) {
|
|||
|
|
transitions.push({ sessionId, phase: 'busy' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Desktop parity: enter cooldown when finish==stop.
|
|||
|
|
if (finish === 'stop') {
|
|||
|
|
transitions.push({ sessionId, phase: 'cooldown' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return transitions;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (payload.type === 'session.idle') {
|
|||
|
|
const sessionId = payload.properties?.sessionID ?? payload.properties?.sessionId;
|
|||
|
|
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
|||
|
|
return [{ sessionId, phase: 'idle' }];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const PUSH_READY_COOLDOWN_MS = 5000;
|
|||
|
|
const PUSH_QUESTION_DEBOUNCE_MS = 500;
|
|||
|
|
const PUSH_PERMISSION_DEBOUNCE_MS = 500;
|
|||
|
|
const pushQuestionDebounceTimers = new Map();
|
|||
|
|
const pushPermissionDebounceTimers = new Map();
|
|||
|
|
const notifiedPermissionRequests = new Set();
|
|||
|
|
const lastReadyNotificationAt = new Map();
|
|||
|
|
|
|||
|
|
// Cache: sessionId -> parentID (string) or null (no parent). Undefined = unknown.
|
|||
|
|
const sessionParentIdCache = new Map();
|
|||
|
|
const SESSION_PARENT_CACHE_TTL_MS = 60 * 1000;
|
|||
|
|
|
|||
|
|
const getCachedSessionParentId = (sessionId) => {
|
|||
|
|
const entry = sessionParentIdCache.get(sessionId);
|
|||
|
|
if (!entry) return undefined;
|
|||
|
|
if (Date.now() - entry.at > SESSION_PARENT_CACHE_TTL_MS) {
|
|||
|
|
sessionParentIdCache.delete(sessionId);
|
|||
|
|
return undefined;
|
|||
|
|
}
|
|||
|
|
return entry.parentID;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const setCachedSessionParentId = (sessionId, parentID) => {
|
|||
|
|
sessionParentIdCache.set(sessionId, { parentID: parentID ?? null, at: Date.now() });
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const fetchSessionParentId = async (sessionId) => {
|
|||
|
|
if (!sessionId) return undefined;
|
|||
|
|
|
|||
|
|
const cached = getCachedSessionParentId(sessionId);
|
|||
|
|
if (cached !== undefined) return cached;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(buildOpenCodeUrl('/session', ''), {
|
|||
|
|
method: 'GET',
|
|||
|
|
headers: {
|
|||
|
|
Accept: 'application/json',
|
|||
|
|
...getOpenCodeAuthHeaders(),
|
|||
|
|
},
|
|||
|
|
signal: AbortSignal.timeout(2000),
|
|||
|
|
});
|
|||
|
|
if (!response.ok) {
|
|||
|
|
return undefined;
|
|||
|
|
}
|
|||
|
|
const data = await response.json().catch(() => null);
|
|||
|
|
if (!Array.isArray(data)) {
|
|||
|
|
return undefined;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const match = data.find((s) => s && typeof s === 'object' && s.id === sessionId);
|
|||
|
|
const parentID = match?.parentID ? match.parentID : null;
|
|||
|
|
setCachedSessionParentId(sessionId, parentID);
|
|||
|
|
return parentID;
|
|||
|
|
} catch {
|
|||
|
|
return undefined;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const extractSessionIdFromPayload = (payload) => {
|
|||
|
|
if (!payload || typeof payload !== 'object') return null;
|
|||
|
|
const props = payload.properties;
|
|||
|
|
const info = props?.info;
|
|||
|
|
const sessionId =
|
|||
|
|
info?.sessionID ??
|
|||
|
|
info?.sessionId ??
|
|||
|
|
props?.sessionID ??
|
|||
|
|
props?.sessionId ??
|
|||
|
|
props?.session ??
|
|||
|
|
null;
|
|||
|
|
return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const maybeSendPushForTrigger = async (payload) => {
|
|||
|
|
if (!payload || typeof payload !== 'object') {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const sessionId = extractSessionIdFromPayload(payload);
|
|||
|
|
|
|||
|
|
const formatMode = (raw) => {
|
|||
|
|
const value = typeof raw === 'string' ? raw.trim() : '';
|
|||
|
|
const normalized = value.length > 0 ? value : 'agent';
|
|||
|
|
return normalized
|
|||
|
|
.split(/[-_\s]+/)
|
|||
|
|
.filter(Boolean)
|
|||
|
|
.map((token) => token.charAt(0).toUpperCase() + token.slice(1))
|
|||
|
|
.join(' ');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const formatModelId = (raw) => {
|
|||
|
|
const value = typeof raw === 'string' ? raw.trim() : '';
|
|||
|
|
if (!value) {
|
|||
|
|
return 'Assistant';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const tokens = value.split(/[-_]+/).filter(Boolean);
|
|||
|
|
const result = [];
|
|||
|
|
for (let i = 0; i < tokens.length; i += 1) {
|
|||
|
|
const current = tokens[i];
|
|||
|
|
const next = tokens[i + 1];
|
|||
|
|
if (/^\d+$/.test(current) && next && /^\d+$/.test(next)) {
|
|||
|
|
result.push(`${current}.${next}`);
|
|||
|
|
i += 1;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
result.push(current);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result
|
|||
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|||
|
|
.join(' ');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (payload.type === 'message.updated') {
|
|||
|
|
const info = payload.properties?.info;
|
|||
|
|
if (info?.role === 'assistant' && info?.finish === 'stop' && sessionId) {
|
|||
|
|
// Check if this is a subtask and if we should notify for subtasks
|
|||
|
|
const settings = await readSettingsFromDisk();
|
|||
|
|
|
|||
|
|
if (settings.notifyOnSubtasks === false) {
|
|||
|
|
// Prefer parentID on payload (if present), else fetch from sessions list.
|
|||
|
|
const sessionInfo = payload.properties?.session;
|
|||
|
|
const parentIDFromPayload = sessionInfo?.parentID ?? payload.properties?.parentID;
|
|||
|
|
const parentID = parentIDFromPayload
|
|||
|
|
? parentIDFromPayload
|
|||
|
|
: await fetchSessionParentId(sessionId);
|
|||
|
|
|
|||
|
|
// Fail open: if parentID cannot be resolved, send notification.
|
|||
|
|
if (parentID) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Check if completion notifications are enabled
|
|||
|
|
if (settings.notifyOnCompletion === false) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const now = Date.now();
|
|||
|
|
const lastAt = lastReadyNotificationAt.get(sessionId) ?? 0;
|
|||
|
|
if (now - lastAt < PUSH_READY_COOLDOWN_MS) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
lastReadyNotificationAt.set(sessionId, now);
|
|||
|
|
|
|||
|
|
// Resolve templates with fallback to legacy hardcoded values
|
|||
|
|
let title = `${formatMode(info?.mode)} agent is ready`;
|
|||
|
|
let body = `${formatModelId(info?.modelID)} completed the task`;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const templates = settings.notificationTemplates || {};
|
|||
|
|
const isSubtask = await fetchSessionParentId(sessionId);
|
|||
|
|
const completionTemplate = isSubtask && settings.notifyOnSubtasks !== false
|
|||
|
|
? (templates.subtask || templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' })
|
|||
|
|
: (templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' });
|
|||
|
|
|
|||
|
|
const variables = await buildTemplateVariables(payload, sessionId);
|
|||
|
|
|
|||
|
|
// Try fast-path (inline parts) first, then fetch from API
|
|||
|
|
const messageId = info?.id;
|
|||
|
|
let lastMessage = extractLastMessageText(payload);
|
|||
|
|
if (!lastMessage) {
|
|||
|
|
lastMessage = await fetchLastAssistantMessageText(sessionId, messageId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const notifZenModel = await resolveZenModel(settings?.zenModel);
|
|||
|
|
variables.last_message = await prepareNotificationLastMessage({
|
|||
|
|
message: lastMessage,
|
|||
|
|
settings,
|
|||
|
|
summarize: (text, len) => summarizeText(text, len, notifZenModel),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const resolvedTitle = resolveNotificationTemplate(completionTemplate.title, variables);
|
|||
|
|
const resolvedBody = resolveNotificationTemplate(completionTemplate.message, variables);
|
|||
|
|
if (resolvedTitle) title = resolvedTitle;
|
|||
|
|
if (shouldApplyResolvedTemplateMessage(completionTemplate.message, resolvedBody, variables)) body = resolvedBody;
|
|||
|
|
} catch (err) {
|
|||
|
|
console.warn('[Notification] Template resolution failed, using defaults:', err?.message || err);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (settings.nativeNotificationsEnabled) {
|
|||
|
|
const notificationPayload = {
|
|||
|
|
title,
|
|||
|
|
body,
|
|||
|
|
tag: `ready-${sessionId}`,
|
|||
|
|
kind: 'ready',
|
|||
|
|
sessionId,
|
|||
|
|
requireHidden: settings.notificationMode !== 'always',
|
|||
|
|
};
|
|||
|
|
emitDesktopNotification(notificationPayload);
|
|||
|
|
broadcastUiNotification(notificationPayload);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await sendPushToAllUiSessions(
|
|||
|
|
{
|
|||
|
|
title,
|
|||
|
|
body,
|
|||
|
|
tag: `ready-${sessionId}`,
|
|||
|
|
data: {
|
|||
|
|
url: buildSessionDeepLinkUrl(sessionId),
|
|||
|
|
sessionId,
|
|||
|
|
type: 'ready',
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{ requireNoSse: true }
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Check for error finish
|
|||
|
|
if (info?.role === 'assistant' && info?.finish === 'error' && sessionId) {
|
|||
|
|
const settings = await readSettingsFromDisk();
|
|||
|
|
if (settings.notifyOnError === false) return;
|
|||
|
|
|
|||
|
|
let title = 'Tool error';
|
|||
|
|
let body = 'An error occurred';
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const variables = await buildTemplateVariables(payload, sessionId);
|
|||
|
|
|
|||
|
|
// Try fast-path (inline parts) first, then fetch from API
|
|||
|
|
const errorMessageId = info?.id;
|
|||
|
|
let lastMessage = extractLastMessageText(payload);
|
|||
|
|
if (!lastMessage) {
|
|||
|
|
lastMessage = await fetchLastAssistantMessageText(sessionId, errorMessageId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const errZenModel = await resolveZenModel(settings?.zenModel);
|
|||
|
|
variables.last_message = await prepareNotificationLastMessage({
|
|||
|
|
message: lastMessage,
|
|||
|
|
settings,
|
|||
|
|
summarize: (text, len) => summarizeText(text, len, errZenModel),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const errorTemplate = (settings.notificationTemplates || {}).error || { title: 'Tool error', message: '{last_message}' };
|
|||
|
|
const resolvedTitle = resolveNotificationTemplate(errorTemplate.title, variables);
|
|||
|
|
const resolvedBody = resolveNotificationTemplate(errorTemplate.message, variables);
|
|||
|
|
if (resolvedTitle) title = resolvedTitle;
|
|||
|
|
if (shouldApplyResolvedTemplateMessage(errorTemplate.message, resolvedBody, variables)) body = resolvedBody;
|
|||
|
|
} catch (err) {
|
|||
|
|
console.warn('[Notification] Error template resolution failed, using defaults:', err?.message || err);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (settings.nativeNotificationsEnabled) {
|
|||
|
|
const notificationPayload = {
|
|||
|
|
title,
|
|||
|
|
body,
|
|||
|
|
tag: `error-${sessionId}`,
|
|||
|
|
kind: 'error',
|
|||
|
|
sessionId,
|
|||
|
|
requireHidden: settings.notificationMode !== 'always',
|
|||
|
|
};
|
|||
|
|
emitDesktopNotification(notificationPayload);
|
|||
|
|
broadcastUiNotification(notificationPayload);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await sendPushToAllUiSessions(
|
|||
|
|
{
|
|||
|
|
title,
|
|||
|
|
body,
|
|||
|
|
tag: `error-${sessionId}`,
|
|||
|
|
data: {
|
|||
|
|
url: buildSessionDeepLinkUrl(sessionId),
|
|||
|
|
sessionId,
|
|||
|
|
type: 'error',
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{ requireNoSse: true }
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
if (payload.type === 'question.asked' && sessionId) {
|
|||
|
|
const existingTimer = pushQuestionDebounceTimers.get(sessionId);
|
|||
|
|
if (existingTimer) {
|
|||
|
|
clearTimeout(existingTimer);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const timer = setTimeout(async () => {
|
|||
|
|
pushQuestionDebounceTimers.delete(sessionId);
|
|||
|
|
|
|||
|
|
const settings = await readSettingsFromDisk();
|
|||
|
|
|
|||
|
|
// Check if question notifications are enabled
|
|||
|
|
if (settings.notifyOnQuestion === false) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!settings.nativeNotificationsEnabled) {
|
|||
|
|
// Still send push even if native notifications are disabled
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const firstQuestion = payload.properties?.questions?.[0];
|
|||
|
|
const header = typeof firstQuestion?.header === 'string' ? firstQuestion.header.trim() : '';
|
|||
|
|
const questionText = typeof firstQuestion?.question === 'string' ? firstQuestion.question.trim() : '';
|
|||
|
|
|
|||
|
|
// Legacy fallback title
|
|||
|
|
let title = /plan\s*mode/i.test(header)
|
|||
|
|
? 'Switch to plan mode'
|
|||
|
|
: /build\s*agent/i.test(header)
|
|||
|
|
? 'Switch to build mode'
|
|||
|
|
: header || 'Input needed';
|
|||
|
|
let body = questionText || 'Agent is waiting for your response';
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// Build template variables
|
|||
|
|
const variables = await buildTemplateVariables(payload, sessionId);
|
|||
|
|
variables.last_message = questionText || header || '';
|
|||
|
|
|
|||
|
|
// Get question template
|
|||
|
|
const templates = settings.notificationTemplates || {};
|
|||
|
|
const questionTemplate = templates.question || { title: 'Input needed', message: '{last_message}' };
|
|||
|
|
|
|||
|
|
// Resolve templates with fallback to legacy behavior
|
|||
|
|
const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables);
|
|||
|
|
const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables);
|
|||
|
|
if (resolvedTitle) title = resolvedTitle;
|
|||
|
|
if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody;
|
|||
|
|
} catch (err) {
|
|||
|
|
console.warn('[Notification] Question template resolution failed, using defaults:', err?.message || err);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (settings.nativeNotificationsEnabled) {
|
|||
|
|
emitDesktopNotification({
|
|||
|
|
kind: 'question',
|
|||
|
|
title,
|
|||
|
|
body,
|
|||
|
|
tag: `question-${sessionId}`,
|
|||
|
|
sessionId,
|
|||
|
|
requireHidden: settings.notificationMode !== 'always',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
broadcastUiNotification({
|
|||
|
|
kind: 'question',
|
|||
|
|
title,
|
|||
|
|
body,
|
|||
|
|
tag: `question-${sessionId}`,
|
|||
|
|
sessionId,
|
|||
|
|
requireHidden: settings.notificationMode !== 'always',
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void sendPushToAllUiSessions(
|
|||
|
|
{
|
|||
|
|
title,
|
|||
|
|
body,
|
|||
|
|
tag: `question-${sessionId}`,
|
|||
|
|
data: {
|
|||
|
|
url: buildSessionDeepLinkUrl(sessionId),
|
|||
|
|
sessionId,
|
|||
|
|
type: 'question',
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{ requireNoSse: true }
|
|||
|
|
);
|
|||
|
|
}, PUSH_QUESTION_DEBOUNCE_MS);
|
|||
|
|
|
|||
|
|
pushQuestionDebounceTimers.set(sessionId, timer);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (payload.type === 'permission.asked' && sessionId) {
|
|||
|
|
const requestId = payload.properties?.id;
|
|||
|
|
const permission = payload.properties?.permission;
|
|||
|
|
const requestKey = typeof requestId === 'string' ? `${sessionId}:${requestId}` : null;
|
|||
|
|
if (requestKey && notifiedPermissionRequests.has(requestKey)) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const existingTimer = pushPermissionDebounceTimers.get(sessionId);
|
|||
|
|
if (existingTimer) {
|
|||
|
|
clearTimeout(existingTimer);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const timer = setTimeout(async () => {
|
|||
|
|
pushPermissionDebounceTimers.delete(sessionId);
|
|||
|
|
const settings = await readSettingsFromDisk();
|
|||
|
|
|
|||
|
|
// Permission requests use the question event toggle (since permission requests are a type of "agent needs input")
|
|||
|
|
if (settings.notifyOnQuestion === false) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!settings.nativeNotificationsEnabled) {
|
|||
|
|
// Still send push even if native notifications are disabled
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const sessionTitle = payload.properties?.sessionTitle;
|
|||
|
|
const permissionText = typeof permission === 'string' && permission.length > 0 ? permission : '';
|
|||
|
|
const fallbackMessage = typeof sessionTitle === 'string' && sessionTitle.trim().length > 0
|
|||
|
|
? sessionTitle.trim()
|
|||
|
|
: permissionText || 'Agent is waiting for your approval';
|
|||
|
|
|
|||
|
|
let title = 'Permission required';
|
|||
|
|
let body = fallbackMessage;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// Build template variables
|
|||
|
|
const variables = await buildTemplateVariables(payload, sessionId);
|
|||
|
|
variables.last_message = fallbackMessage;
|
|||
|
|
|
|||
|
|
// Get question template (permission uses question template since it's an input request)
|
|||
|
|
const templates = settings.notificationTemplates || {};
|
|||
|
|
const questionTemplate = templates.question || { title: 'Permission required', message: '{last_message}' };
|
|||
|
|
|
|||
|
|
// Resolve templates with fallback to legacy behavior
|
|||
|
|
const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables);
|
|||
|
|
const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables);
|
|||
|
|
if (resolvedTitle) title = resolvedTitle;
|
|||
|
|
if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody;
|
|||
|
|
} catch (err) {
|
|||
|
|
console.warn('[Notification] Permission template resolution failed, using defaults:', err?.message || err);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (settings.nativeNotificationsEnabled) {
|
|||
|
|
emitDesktopNotification({
|
|||
|
|
kind: 'permission',
|
|||
|
|
title,
|
|||
|
|
body,
|
|||
|
|
tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`,
|
|||
|
|
sessionId,
|
|||
|
|
requireHidden: settings.notificationMode !== 'always',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
broadcastUiNotification({
|
|||
|
|
kind: 'permission',
|
|||
|
|
title,
|
|||
|
|
body,
|
|||
|
|
tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`,
|
|||
|
|
sessionId,
|
|||
|
|
requireHidden: settings.notificationMode !== 'always',
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (requestKey) {
|
|||
|
|
notifiedPermissionRequests.add(requestKey);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void sendPushToAllUiSessions(
|
|||
|
|
{
|
|||
|
|
title,
|
|||
|
|
body,
|
|||
|
|
tag: `permission-${sessionId}`,
|
|||
|
|
data: {
|
|||
|
|
url: buildSessionDeepLinkUrl(sessionId),
|
|||
|
|
sessionId,
|
|||
|
|
type: 'permission',
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{ requireNoSse: true }
|
|||
|
|
);
|
|||
|
|
}, PUSH_PERMISSION_DEBOUNCE_MS);
|
|||
|
|
|
|||
|
|
pushPermissionDebounceTimers.set(sessionId, timer);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function writeSseEvent(res, payload) {
|
|||
|
|
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function extractApiPrefixFromUrl() {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function detectOpenCodeApiPrefix() {
|
|||
|
|
openCodeApiPrefixDetected = true;
|
|||
|
|
openCodeApiPrefix = '';
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ensureOpenCodeApiPrefix() {
|
|||
|
|
return detectOpenCodeApiPrefix();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function scheduleOpenCodeApiDetection() {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function parseArgs(argv = process.argv.slice(2)) {
|
|||
|
|
const args = Array.isArray(argv) ? [...argv] : [];
|
|||
|
|
const envPassword =
|
|||
|
|
process.env.OPENCHAMBER_UI_PASSWORD ||
|
|||
|
|
process.env.OPENCODE_UI_PASSWORD ||
|
|||
|
|
null;
|
|||
|
|
const options = { port: DEFAULT_PORT, uiPassword: envPassword };
|
|||
|
|
|
|||
|
|
const consumeValue = (currentIndex, inlineValue) => {
|
|||
|
|
if (typeof inlineValue === 'string') {
|
|||
|
|
return { value: inlineValue, nextIndex: currentIndex };
|
|||
|
|
}
|
|||
|
|
const nextArg = args[currentIndex + 1];
|
|||
|
|
if (typeof nextArg === 'string' && !nextArg.startsWith('--')) {
|
|||
|
|
return { value: nextArg, nextIndex: currentIndex + 1 };
|
|||
|
|
}
|
|||
|
|
return { value: undefined, nextIndex: currentIndex };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
for (let i = 0; i < args.length; i++) {
|
|||
|
|
const arg = args[i];
|
|||
|
|
if (!arg.startsWith('--')) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const eqIndex = arg.indexOf('=');
|
|||
|
|
const optionName = eqIndex >= 0 ? arg.slice(2, eqIndex) : arg.slice(2);
|
|||
|
|
const inlineValue = eqIndex >= 0 ? arg.slice(eqIndex + 1) : undefined;
|
|||
|
|
|
|||
|
|
if (optionName === 'port' || optionName === 'p') {
|
|||
|
|
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|||
|
|
i = nextIndex;
|
|||
|
|
const parsedPort = parseInt(value ?? '', 10);
|
|||
|
|
options.port = Number.isFinite(parsedPort) ? parsedPort : DEFAULT_PORT;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (optionName === 'ui-password') {
|
|||
|
|
const { value, nextIndex } = consumeValue(i, inlineValue);
|
|||
|
|
i = nextIndex;
|
|||
|
|
options.uiPassword = typeof value === 'string' ? value : '';
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return options;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function killProcessOnPort(port) {
|
|||
|
|
if (!port) return;
|
|||
|
|
try {
|
|||
|
|
// Kill any process listening on our port to clean up orphaned children.
|
|||
|
|
const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000 });
|
|||
|
|
const output = result.stdout || '';
|
|||
|
|
const myPid = process.pid;
|
|||
|
|
for (const pidStr of output.split(/\s+/)) {
|
|||
|
|
const pid = parseInt(pidStr.trim(), 10);
|
|||
|
|
if (pid && pid !== myPid) {
|
|||
|
|
try {
|
|||
|
|
spawnSync('kill', ['-9', String(pid)], { stdio: 'ignore', timeout: 2000 });
|
|||
|
|
} catch {
|
|||
|
|
// Ignore
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// Ignore - process may already be dead
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function createManagedOpenCodeServerProcess({
|
|||
|
|
hostname,
|
|||
|
|
port,
|
|||
|
|
timeout,
|
|||
|
|
cwd,
|
|||
|
|
env,
|
|||
|
|
}) {
|
|||
|
|
let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
|
|||
|
|
let args = ['serve', '--hostname', hostname, '--port', String(port)];
|
|||
|
|
|
|||
|
|
if (process.platform === 'win32' && useWslForOpencode) {
|
|||
|
|
const wslBinary = resolvedWslBinary || resolveWslExecutablePath();
|
|||
|
|
if (!wslBinary) {
|
|||
|
|
throw new Error('WSL executable not found while attempting to launch OpenCode from WSL');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const wslOpencode = resolvedWslOpencodePath && resolvedWslOpencodePath.trim().length > 0
|
|||
|
|
? resolvedWslOpencodePath.trim()
|
|||
|
|
: 'opencode';
|
|||
|
|
const serveHost = hostname === '127.0.0.1' ? '0.0.0.0' : hostname;
|
|||
|
|
|
|||
|
|
binary = wslBinary;
|
|||
|
|
args = buildWslExecArgs([
|
|||
|
|
wslOpencode,
|
|||
|
|
'serve',
|
|||
|
|
'--hostname',
|
|||
|
|
serveHost,
|
|||
|
|
'--port',
|
|||
|
|
String(port),
|
|||
|
|
], resolvedWslDistro);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// On Windows, Bun/Node cannot directly spawn shell wrapper scripts (#!/bin/sh).
|
|||
|
|
// Detect if the resolved binary is a shim that wraps a Node/Bun script and
|
|||
|
|
// resolve the actual target so we can spawn it with the correct interpreter.
|
|||
|
|
if (process.platform === 'win32' && !useWslForOpencode) {
|
|||
|
|
const interpreter = opencodeShimInterpreter(binary);
|
|||
|
|
if (interpreter) {
|
|||
|
|
// Binary itself has a node/bun shebang – spawn via that interpreter.
|
|||
|
|
args.unshift(binary);
|
|||
|
|
binary = interpreter;
|
|||
|
|
} else {
|
|||
|
|
// The wrapper might be a shell shim generated by npm. Try to find the
|
|||
|
|
// real JS entry point next to it (e.g. node_modules/opencode-ai/bin/opencode).
|
|||
|
|
try {
|
|||
|
|
const shimContent = fs.readFileSync(binary, 'utf8');
|
|||
|
|
const jsMatch = shimContent.match(/node_modules[\\/]opencode[^\s"']*/);
|
|||
|
|
if (jsMatch) {
|
|||
|
|
const candidate = path.resolve(path.dirname(binary), jsMatch[0]);
|
|||
|
|
if (fs.existsSync(candidate)) {
|
|||
|
|
const realInterp = opencodeShimInterpreter(candidate);
|
|||
|
|
if (realInterp) {
|
|||
|
|
args.unshift(candidate);
|
|||
|
|
binary = realInterp;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore – fall through to default spawn
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const child = spawn(binary, args, {
|
|||
|
|
cwd,
|
|||
|
|
env,
|
|||
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const url = await new Promise((resolve, reject) => {
|
|||
|
|
let output = '';
|
|||
|
|
let done = false;
|
|||
|
|
const finish = (handler, value) => {
|
|||
|
|
if (done) return;
|
|||
|
|
done = true;
|
|||
|
|
clearTimeout(timer);
|
|||
|
|
child.stdout?.off('data', onStdout);
|
|||
|
|
child.stderr?.off('data', onStderr);
|
|||
|
|
child.off('exit', onExit);
|
|||
|
|
child.off('error', onError);
|
|||
|
|
handler(value);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const onStdout = (chunk) => {
|
|||
|
|
output += chunk.toString();
|
|||
|
|
const lines = output.split('\n');
|
|||
|
|
for (const line of lines) {
|
|||
|
|
if (!line.startsWith('opencode server listening')) continue;
|
|||
|
|
const match = line.match(/on\s+(https?:\/\/[^\s]+)/);
|
|||
|
|
if (!match) {
|
|||
|
|
finish(reject, new Error(`Failed to parse server url from output: ${line}`));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
finish(resolve, match[1]);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const onStderr = (chunk) => {
|
|||
|
|
output += chunk.toString();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const onExit = (code) => {
|
|||
|
|
finish(reject, new Error(`OpenCode exited with code ${code}. Output: ${output}`));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const onError = (error) => {
|
|||
|
|
finish(reject, error);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const timer = setTimeout(() => {
|
|||
|
|
finish(reject, new Error(`Timeout waiting for OpenCode to start after ${timeout}ms`));
|
|||
|
|
}, timeout);
|
|||
|
|
|
|||
|
|
child.stdout?.on('data', onStdout);
|
|||
|
|
child.stderr?.on('data', onStderr);
|
|||
|
|
child.on('exit', onExit);
|
|||
|
|
child.on('error', onError);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
url,
|
|||
|
|
close() {
|
|||
|
|
try {
|
|||
|
|
child.kill('SIGTERM');
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function resolveManagedOpenCodePort(requestedPort) {
|
|||
|
|
if (typeof requestedPort === 'number' && Number.isFinite(requestedPort) && requestedPort > 0) {
|
|||
|
|
return requestedPort;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return await new Promise((resolve, reject) => {
|
|||
|
|
const server = net.createServer();
|
|||
|
|
const cleanup = () => {
|
|||
|
|
server.removeAllListeners('error');
|
|||
|
|
server.removeAllListeners('listening');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
server.once('error', (error) => {
|
|||
|
|
cleanup();
|
|||
|
|
reject(error);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
server.once('listening', () => {
|
|||
|
|
const address = server.address();
|
|||
|
|
const port = address && typeof address === 'object' ? address.port : 0;
|
|||
|
|
server.close(() => {
|
|||
|
|
cleanup();
|
|||
|
|
if (port > 0) {
|
|||
|
|
resolve(port);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
reject(new Error('Failed to allocate OpenCode port'));
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
server.listen(0, '127.0.0.1');
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function startOpenCode() {
|
|||
|
|
const desiredPort = ENV_CONFIGURED_OPENCODE_PORT ?? 0;
|
|||
|
|
const spawnPort = await resolveManagedOpenCodePort(desiredPort);
|
|||
|
|
console.log(
|
|||
|
|
desiredPort > 0
|
|||
|
|
? `Starting OpenCode on requested port ${desiredPort}...`
|
|||
|
|
: `Starting OpenCode on allocated port ${spawnPort}...`
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
await applyOpencodeBinaryFromSettings();
|
|||
|
|
ensureOpencodeCliEnv();
|
|||
|
|
const openCodePassword = await ensureLocalOpenCodeServerPassword({
|
|||
|
|
rotateManaged: true,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const serverInstance = await createManagedOpenCodeServerProcess({
|
|||
|
|
hostname: '127.0.0.1',
|
|||
|
|
port: spawnPort,
|
|||
|
|
timeout: 30000,
|
|||
|
|
cwd: openCodeWorkingDirectory,
|
|||
|
|
env: {
|
|||
|
|
...process.env,
|
|||
|
|
OPENCODE_SERVER_PASSWORD: openCodePassword,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!serverInstance || !serverInstance.url) {
|
|||
|
|
throw new Error('OpenCode server started but URL is missing');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const url = new URL(serverInstance.url);
|
|||
|
|
const port = parseInt(url.port, 10);
|
|||
|
|
const prefix = normalizeApiPrefix(url.pathname);
|
|||
|
|
|
|||
|
|
if (await waitForReady(serverInstance.url, 10000)) {
|
|||
|
|
setOpenCodePort(port);
|
|||
|
|
setDetectedOpenCodeApiPrefix(prefix); // SDK URL typically includes the prefix if any
|
|||
|
|
|
|||
|
|
isOpenCodeReady = true;
|
|||
|
|
lastOpenCodeError = null;
|
|||
|
|
openCodeNotReadySince = 0;
|
|||
|
|
|
|||
|
|
return serverInstance;
|
|||
|
|
} else {
|
|||
|
|
try {
|
|||
|
|
serverInstance.close();
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
throw new Error('Server started but health check failed (timeout)');
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
const message = error instanceof Error ? error.message : String(error);
|
|||
|
|
lastOpenCodeError = message;
|
|||
|
|
openCodePort = null;
|
|||
|
|
syncToHmrState();
|
|||
|
|
console.error(`Failed to start OpenCode: ${message}`);
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function restartOpenCode() {
|
|||
|
|
if (isShuttingDown) return;
|
|||
|
|
if (currentRestartPromise) {
|
|||
|
|
await currentRestartPromise;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
currentRestartPromise = (async () => {
|
|||
|
|
isRestartingOpenCode = true;
|
|||
|
|
isOpenCodeReady = false;
|
|||
|
|
openCodeNotReadySince = Date.now();
|
|||
|
|
console.log('Restarting OpenCode process...');
|
|||
|
|
|
|||
|
|
// For external OpenCode servers, re-probe instead of kill + respawn
|
|||
|
|
if (isExternalOpenCode) {
|
|||
|
|
console.log('Re-probing external OpenCode server...');
|
|||
|
|
const probePort = openCodePort || ENV_CONFIGURED_OPENCODE_PORT || 4096;
|
|||
|
|
const probeOrigin = openCodeBaseUrl ?? ENV_CONFIGURED_OPENCODE_HOST?.origin;
|
|||
|
|
const healthy = await probeExternalOpenCode(probePort, probeOrigin);
|
|||
|
|
if (healthy) {
|
|||
|
|
console.log(`External OpenCode server on port ${probePort} is healthy`);
|
|||
|
|
setOpenCodePort(probePort);
|
|||
|
|
isOpenCodeReady = true;
|
|||
|
|
lastOpenCodeError = null;
|
|||
|
|
openCodeNotReadySince = 0;
|
|||
|
|
syncToHmrState();
|
|||
|
|
} else {
|
|||
|
|
lastOpenCodeError = `External OpenCode server on port ${probePort} is not responding`;
|
|||
|
|
console.error(lastOpenCodeError);
|
|||
|
|
throw new Error(lastOpenCodeError);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (expressApp) {
|
|||
|
|
setupProxy(expressApp);
|
|||
|
|
ensureOpenCodeApiPrefix();
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const portToKill = openCodePort;
|
|||
|
|
|
|||
|
|
if (openCodeProcess) {
|
|||
|
|
console.log('Stopping existing OpenCode process...');
|
|||
|
|
try {
|
|||
|
|
openCodeProcess.close();
|
|||
|
|
} catch (error) {
|
|||
|
|
console.warn('Error closing OpenCode process:', error);
|
|||
|
|
}
|
|||
|
|
openCodeProcess = null;
|
|||
|
|
syncToHmrState();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
killProcessOnPort(portToKill);
|
|||
|
|
|
|||
|
|
// Brief delay to allow port release
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|||
|
|
|
|||
|
|
if (ENV_CONFIGURED_OPENCODE_PORT) {
|
|||
|
|
console.log(`Using OpenCode port from environment: ${ENV_CONFIGURED_OPENCODE_PORT}`);
|
|||
|
|
setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT);
|
|||
|
|
} else {
|
|||
|
|
openCodePort = null;
|
|||
|
|
syncToHmrState();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
openCodeApiPrefixDetected = true;
|
|||
|
|
openCodeApiPrefix = '';
|
|||
|
|
if (openCodeApiDetectionTimer) {
|
|||
|
|
clearTimeout(openCodeApiDetectionTimer);
|
|||
|
|
openCodeApiDetectionTimer = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
lastOpenCodeError = null;
|
|||
|
|
openCodeProcess = await startOpenCode();
|
|||
|
|
syncToHmrState();
|
|||
|
|
|
|||
|
|
if (expressApp) {
|
|||
|
|
setupProxy(expressApp);
|
|||
|
|
// Ensure prefix is set correctly (SDK usually handles this, but just in case)
|
|||
|
|
ensureOpenCodeApiPrefix();
|
|||
|
|
}
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await currentRestartPromise;
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error(`Failed to restart OpenCode: ${error.message}`);
|
|||
|
|
lastOpenCodeError = error.message;
|
|||
|
|
if (!ENV_CONFIGURED_OPENCODE_PORT) {
|
|||
|
|
openCodePort = null;
|
|||
|
|
syncToHmrState();
|
|||
|
|
}
|
|||
|
|
openCodeApiPrefixDetected = true;
|
|||
|
|
openCodeApiPrefix = '';
|
|||
|
|
throw error;
|
|||
|
|
} finally {
|
|||
|
|
currentRestartPromise = null;
|
|||
|
|
isRestartingOpenCode = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function waitForOpenCodeReady(timeoutMs = 20000, intervalMs = 400) {
|
|||
|
|
if (!openCodePort) {
|
|||
|
|
throw new Error('OpenCode port is not available');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const deadline = Date.now() + timeoutMs;
|
|||
|
|
let lastError = null;
|
|||
|
|
|
|||
|
|
while (Date.now() < deadline) {
|
|||
|
|
try {
|
|||
|
|
const [configResult, agentResult] = await Promise.all([
|
|||
|
|
fetch(buildOpenCodeUrl('/config', ''), {
|
|||
|
|
method: 'GET',
|
|||
|
|
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }
|
|||
|
|
}).catch((error) => error),
|
|||
|
|
fetch(buildOpenCodeUrl('/agent', ''), {
|
|||
|
|
method: 'GET',
|
|||
|
|
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }
|
|||
|
|
}).catch((error) => error)
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
if (configResult instanceof Error) {
|
|||
|
|
lastError = configResult;
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!configResult.ok) {
|
|||
|
|
lastError = new Error(`OpenCode config endpoint responded with status ${configResult.status}`);
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await configResult.json().catch(() => null);
|
|||
|
|
|
|||
|
|
if (agentResult instanceof Error) {
|
|||
|
|
lastError = agentResult;
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!agentResult.ok) {
|
|||
|
|
lastError = new Error(`Agent endpoint responded with status ${agentResult.status}`);
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await agentResult.json().catch(() => []);
|
|||
|
|
|
|||
|
|
isOpenCodeReady = true;
|
|||
|
|
lastOpenCodeError = null;
|
|||
|
|
return;
|
|||
|
|
} catch (error) {
|
|||
|
|
lastError = error;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (lastError) {
|
|||
|
|
lastOpenCodeError = lastError.message || String(lastError);
|
|||
|
|
throw lastError;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const timeoutError = new Error('Timed out waiting for OpenCode to become ready');
|
|||
|
|
lastOpenCodeError = timeoutError.message;
|
|||
|
|
throw timeoutError;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function waitForAgentPresence(agentName, timeoutMs = 15000, intervalMs = 300) {
|
|||
|
|
if (!openCodePort) {
|
|||
|
|
throw new Error('OpenCode port is not available');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const deadline = Date.now() + timeoutMs;
|
|||
|
|
|
|||
|
|
while (Date.now() < deadline) {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(buildOpenCodeUrl('/agent'), {
|
|||
|
|
method: 'GET',
|
|||
|
|
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (response.ok) {
|
|||
|
|
const agents = await response.json();
|
|||
|
|
if (Array.isArray(agents) && agents.some((agent) => agent?.name === agentName)) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
throw new Error(`Agent "${agentName}" not available after OpenCode restart`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function fetchAgentsSnapshot() {
|
|||
|
|
if (!openCodePort) {
|
|||
|
|
throw new Error('OpenCode port is not available');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const response = await fetch(buildOpenCodeUrl('/agent'), {
|
|||
|
|
method: 'GET',
|
|||
|
|
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
throw new Error(`Failed to fetch agents snapshot (status ${response.status})`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const agents = await response.json().catch(() => null);
|
|||
|
|
if (!Array.isArray(agents)) {
|
|||
|
|
throw new Error('Invalid agents payload from OpenCode');
|
|||
|
|
}
|
|||
|
|
return agents;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function fetchProvidersSnapshot() {
|
|||
|
|
if (!openCodePort) {
|
|||
|
|
throw new Error('OpenCode port is not available');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const response = await fetch(buildOpenCodeUrl('/provider'), {
|
|||
|
|
method: 'GET',
|
|||
|
|
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
throw new Error(`Failed to fetch providers snapshot (status ${response.status})`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const providers = await response.json().catch(() => null);
|
|||
|
|
if (!Array.isArray(providers)) {
|
|||
|
|
throw new Error('Invalid providers payload from OpenCode');
|
|||
|
|
}
|
|||
|
|
return providers;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function fetchModelsSnapshot() {
|
|||
|
|
if (!openCodePort) {
|
|||
|
|
throw new Error('OpenCode port is not available');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const response = await fetch(buildOpenCodeUrl('/model'), {
|
|||
|
|
method: 'GET',
|
|||
|
|
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
throw new Error(`Failed to fetch models snapshot (status ${response.status})`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const models = await response.json().catch(() => null);
|
|||
|
|
if (!Array.isArray(models)) {
|
|||
|
|
throw new Error('Invalid models payload from OpenCode');
|
|||
|
|
}
|
|||
|
|
return models;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function refreshOpenCodeAfterConfigChange(reason, options = {}) {
|
|||
|
|
const { agentName } = options;
|
|||
|
|
|
|||
|
|
console.log(`Refreshing OpenCode after ${reason}`);
|
|||
|
|
|
|||
|
|
// Settings might include a new opencodeBinary; drop cache before restart.
|
|||
|
|
resolvedOpencodeBinary = null;
|
|||
|
|
await applyOpencodeBinaryFromSettings();
|
|||
|
|
|
|||
|
|
await restartOpenCode();
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await waitForOpenCodeReady();
|
|||
|
|
isOpenCodeReady = true;
|
|||
|
|
openCodeNotReadySince = 0;
|
|||
|
|
|
|||
|
|
if (agentName) {
|
|||
|
|
await waitForAgentPresence(agentName);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
isOpenCodeReady = true;
|
|||
|
|
openCodeNotReadySince = 0;
|
|||
|
|
} catch (error) {
|
|||
|
|
|
|||
|
|
isOpenCodeReady = false;
|
|||
|
|
openCodeNotReadySince = Date.now();
|
|||
|
|
console.error(`Failed to refresh OpenCode after ${reason}:`, error.message);
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function bootstrapOpenCodeAtStartup() {
|
|||
|
|
try {
|
|||
|
|
syncFromHmrState();
|
|||
|
|
if (await isOpenCodeProcessHealthy()) {
|
|||
|
|
console.log(`[HMR] Reusing existing OpenCode process on port ${openCodePort}`);
|
|||
|
|
} else if (ENV_SKIP_OPENCODE_START && ENV_EFFECTIVE_PORT) {
|
|||
|
|
const label = ENV_CONFIGURED_OPENCODE_HOST ? ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${ENV_EFFECTIVE_PORT}`;
|
|||
|
|
console.log(`Using external OpenCode server at ${label} (skip-start mode)`);
|
|||
|
|
openCodeBaseUrl = ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null;
|
|||
|
|
setOpenCodePort(ENV_EFFECTIVE_PORT);
|
|||
|
|
isOpenCodeReady = true;
|
|||
|
|
isExternalOpenCode = true;
|
|||
|
|
lastOpenCodeError = null;
|
|||
|
|
openCodeNotReadySince = 0;
|
|||
|
|
syncToHmrState();
|
|||
|
|
} else if (ENV_EFFECTIVE_PORT && await probeExternalOpenCode(ENV_EFFECTIVE_PORT, ENV_CONFIGURED_OPENCODE_HOST?.origin)) {
|
|||
|
|
const label = ENV_CONFIGURED_OPENCODE_HOST ? ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${ENV_EFFECTIVE_PORT}`;
|
|||
|
|
console.log(`Auto-detected existing OpenCode server at ${label}`);
|
|||
|
|
openCodeBaseUrl = ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null;
|
|||
|
|
setOpenCodePort(ENV_EFFECTIVE_PORT);
|
|||
|
|
isOpenCodeReady = true;
|
|||
|
|
isExternalOpenCode = true;
|
|||
|
|
lastOpenCodeError = null;
|
|||
|
|
openCodeNotReadySince = 0;
|
|||
|
|
syncToHmrState();
|
|||
|
|
} else if (!ENV_EFFECTIVE_PORT && await probeExternalOpenCode(4096)) {
|
|||
|
|
console.log('Auto-detected existing OpenCode server on default port 4096');
|
|||
|
|
setOpenCodePort(4096);
|
|||
|
|
isOpenCodeReady = true;
|
|||
|
|
isExternalOpenCode = true;
|
|||
|
|
lastOpenCodeError = null;
|
|||
|
|
openCodeNotReadySince = 0;
|
|||
|
|
syncToHmrState();
|
|||
|
|
} else {
|
|||
|
|
if (ENV_EFFECTIVE_PORT) {
|
|||
|
|
console.log(`Using OpenCode port from environment: ${ENV_EFFECTIVE_PORT}`);
|
|||
|
|
setOpenCodePort(ENV_EFFECTIVE_PORT);
|
|||
|
|
} else {
|
|||
|
|
openCodePort = null;
|
|||
|
|
syncToHmrState();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
lastOpenCodeError = null;
|
|||
|
|
openCodeProcess = await startOpenCode();
|
|||
|
|
syncToHmrState();
|
|||
|
|
}
|
|||
|
|
await waitForOpenCodePort();
|
|||
|
|
try {
|
|||
|
|
await waitForOpenCodeReady();
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error(`OpenCode readiness check failed: ${error.message}`);
|
|||
|
|
scheduleOpenCodeApiDetection();
|
|||
|
|
}
|
|||
|
|
scheduleOpenCodeApiDetection();
|
|||
|
|
startHealthMonitoring();
|
|||
|
|
void startGlobalEventWatcher().catch((error) => {
|
|||
|
|
console.warn(`Global event watcher startup failed: ${error?.message || error}`);
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error(`Failed to start OpenCode: ${error.message}`);
|
|||
|
|
console.log('Continuing without OpenCode integration...');
|
|||
|
|
lastOpenCodeError = error.message;
|
|||
|
|
scheduleOpenCodeApiDetection();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setupProxy(app) {
|
|||
|
|
if (app.get('opencodeProxyConfigured')) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (openCodePort) {
|
|||
|
|
console.log(`Setting up proxy to OpenCode on port ${openCodePort}`);
|
|||
|
|
} else {
|
|||
|
|
console.log('Setting up OpenCode API gate (OpenCode not started yet)');
|
|||
|
|
}
|
|||
|
|
app.set('opencodeProxyConfigured', true);
|
|||
|
|
|
|||
|
|
const stripApiPrefix = (rawUrl) => {
|
|||
|
|
if (typeof rawUrl !== 'string' || !rawUrl) {
|
|||
|
|
return '/';
|
|||
|
|
}
|
|||
|
|
if (rawUrl === '/api') {
|
|||
|
|
return '/';
|
|||
|
|
}
|
|||
|
|
if (rawUrl.startsWith('/api/')) {
|
|||
|
|
return rawUrl.slice(4);
|
|||
|
|
}
|
|||
|
|
return rawUrl;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Keep route matching stable; only rewrite the proxied upstream path.
|
|||
|
|
const rewriteWindowsDirectoryParam = (upstreamPath) => {
|
|||
|
|
if (process.platform !== 'win32') {
|
|||
|
|
return upstreamPath;
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
const parsed = new URL(upstreamPath, 'http://openchamber.local');
|
|||
|
|
const pathname = parsed.pathname || '/';
|
|||
|
|
if (pathname === '/session' || pathname.startsWith('/session/')) {
|
|||
|
|
return upstreamPath;
|
|||
|
|
}
|
|||
|
|
const directory = parsed.searchParams.get('directory');
|
|||
|
|
if (!directory || !directory.includes('/')) {
|
|||
|
|
return upstreamPath;
|
|||
|
|
}
|
|||
|
|
const fixed = directory.replace(/\//g, '\\');
|
|||
|
|
parsed.searchParams.set('directory', fixed);
|
|||
|
|
const rewritten = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|||
|
|
if (rewritten !== upstreamPath) {
|
|||
|
|
console.log(`[Win32PathFix] Rewrote directory: "${directory}" → "${fixed}"`);
|
|||
|
|
console.log(`[Win32PathFix] URL: "${upstreamPath}" → "${rewritten}"`);
|
|||
|
|
}
|
|||
|
|
return rewritten;
|
|||
|
|
} catch {
|
|||
|
|
return upstreamPath;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getUpstreamPathForRequest = (req) => {
|
|||
|
|
const rawUrl = (typeof req.originalUrl === 'string' && req.originalUrl)
|
|||
|
|
? req.originalUrl
|
|||
|
|
: (typeof req.url === 'string' ? req.url : '/');
|
|||
|
|
return rewriteWindowsDirectoryParam(stripApiPrefix(rawUrl));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
app.use('/api', (req, res, next) => {
|
|||
|
|
if (
|
|||
|
|
req.path.startsWith('/themes/custom') ||
|
|||
|
|
req.path.startsWith('/push') ||
|
|||
|
|
req.path.startsWith('/config/agents') ||
|
|||
|
|
req.path.startsWith('/config/opencode-resolution') ||
|
|||
|
|
req.path.startsWith('/config/settings') ||
|
|||
|
|
req.path.startsWith('/config/skills') ||
|
|||
|
|
req.path === '/config/reload' ||
|
|||
|
|
req.path === '/health'
|
|||
|
|
) {
|
|||
|
|
return next();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const waitElapsed = openCodeNotReadySince === 0 ? 0 : Date.now() - openCodeNotReadySince;
|
|||
|
|
const stillWaiting =
|
|||
|
|
(!isOpenCodeReady && (openCodeNotReadySince === 0 || waitElapsed < OPEN_CODE_READY_GRACE_MS)) ||
|
|||
|
|
isRestartingOpenCode ||
|
|||
|
|
!openCodePort;
|
|||
|
|
|
|||
|
|
if (stillWaiting) {
|
|||
|
|
return res.status(503).json({
|
|||
|
|
error: 'OpenCode is restarting',
|
|||
|
|
restarting: true,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
next();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const isSseApiPath = (path) => path === '/event' || path === '/global/event';
|
|||
|
|
|
|||
|
|
const forwardSseRequest = async (req, res) => {
|
|||
|
|
const startedAt = Date.now();
|
|||
|
|
const upstreamPath = getUpstreamPathForRequest(req);
|
|||
|
|
const targetUrl = buildOpenCodeUrl(upstreamPath, '');
|
|||
|
|
const authHeaders = getOpenCodeAuthHeaders();
|
|||
|
|
|
|||
|
|
const requestHeaders = {
|
|||
|
|
...(typeof req.headers.accept === 'string' ? { accept: req.headers.accept } : { accept: 'text/event-stream' }),
|
|||
|
|
'cache-control': 'no-cache',
|
|||
|
|
connection: 'keep-alive',
|
|||
|
|
...(authHeaders.Authorization ? { Authorization: authHeaders.Authorization } : {}),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const controller = new AbortController();
|
|||
|
|
let connectTimer = null;
|
|||
|
|
let idleTimer = null;
|
|||
|
|
let heartbeatTimer = null;
|
|||
|
|
let endedBy = 'upstream-end';
|
|||
|
|
|
|||
|
|
const cleanup = () => {
|
|||
|
|
if (connectTimer) {
|
|||
|
|
clearTimeout(connectTimer);
|
|||
|
|
connectTimer = null;
|
|||
|
|
}
|
|||
|
|
if (idleTimer) {
|
|||
|
|
clearTimeout(idleTimer);
|
|||
|
|
idleTimer = null;
|
|||
|
|
}
|
|||
|
|
if (heartbeatTimer) {
|
|||
|
|
clearInterval(heartbeatTimer);
|
|||
|
|
heartbeatTimer = null;
|
|||
|
|
}
|
|||
|
|
req.off('close', onClientClose);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const resetIdleTimeout = () => {
|
|||
|
|
if (idleTimer) {
|
|||
|
|
clearTimeout(idleTimer);
|
|||
|
|
}
|
|||
|
|
idleTimer = setTimeout(() => {
|
|||
|
|
endedBy = 'idle-timeout';
|
|||
|
|
controller.abort();
|
|||
|
|
}, 5 * 60 * 1000);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const onClientClose = () => {
|
|||
|
|
endedBy = 'client-disconnect';
|
|||
|
|
controller.abort();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
req.on('close', onClientClose);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
connectTimer = setTimeout(() => {
|
|||
|
|
endedBy = 'connect-timeout';
|
|||
|
|
controller.abort();
|
|||
|
|
}, 10 * 1000);
|
|||
|
|
|
|||
|
|
const upstreamResponse = await fetch(targetUrl, {
|
|||
|
|
method: 'GET',
|
|||
|
|
headers: requestHeaders,
|
|||
|
|
signal: controller.signal,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (connectTimer) {
|
|||
|
|
clearTimeout(connectTimer);
|
|||
|
|
connectTimer = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!upstreamResponse.ok || !upstreamResponse.body) {
|
|||
|
|
const body = await upstreamResponse.text().catch(() => '');
|
|||
|
|
cleanup();
|
|||
|
|
if (!res.headersSent) {
|
|||
|
|
if (upstreamResponse.headers.has('content-type')) {
|
|||
|
|
res.setHeader('content-type', upstreamResponse.headers.get('content-type'));
|
|||
|
|
}
|
|||
|
|
res.status(upstreamResponse.status).send(body);
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const upstreamContentType = upstreamResponse.headers.get('content-type') || 'text/event-stream';
|
|||
|
|
res.status(upstreamResponse.status);
|
|||
|
|
res.setHeader('content-type', upstreamContentType);
|
|||
|
|
res.setHeader('cache-control', 'no-cache');
|
|||
|
|
res.setHeader('connection', 'keep-alive');
|
|||
|
|
res.setHeader('x-accel-buffering', 'no');
|
|||
|
|
res.setHeader('x-content-type-options', 'nosniff');
|
|||
|
|
if (typeof res.flushHeaders === 'function') {
|
|||
|
|
res.flushHeaders();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
resetIdleTimeout();
|
|||
|
|
heartbeatTimer = setInterval(() => {
|
|||
|
|
if (res.writableEnded || controller.signal.aborted) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
res.write(': ping\n\n');
|
|||
|
|
resetIdleTimeout();
|
|||
|
|
} catch {
|
|||
|
|
}
|
|||
|
|
}, 30 * 1000);
|
|||
|
|
|
|||
|
|
const reader = upstreamResponse.body.getReader();
|
|||
|
|
try {
|
|||
|
|
while (true) {
|
|||
|
|
const { done, value } = await reader.read();
|
|||
|
|
if (done) {
|
|||
|
|
endedBy = endedBy === 'upstream-end' ? 'upstream-finished' : endedBy;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
if (controller.signal.aborted) {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
if (value && value.length > 0) {
|
|||
|
|
res.write(Buffer.from(value));
|
|||
|
|
resetIdleTimeout();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} finally {
|
|||
|
|
try {
|
|||
|
|
reader.releaseLock();
|
|||
|
|
} catch {
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
cleanup();
|
|||
|
|
if (!res.writableEnded) {
|
|||
|
|
res.end();
|
|||
|
|
}
|
|||
|
|
console.log(`SSE forward ${upstreamPath} closed (${endedBy}) in ${Date.now() - startedAt}ms`);
|
|||
|
|
} catch (error) {
|
|||
|
|
cleanup();
|
|||
|
|
const isTimeout = error?.name === 'TimeoutError' || error?.name === 'AbortError';
|
|||
|
|
if (!res.headersSent) {
|
|||
|
|
res.status(isTimeout ? 504 : 503).json({
|
|||
|
|
error: isTimeout ? 'OpenCode SSE forward timed out' : 'OpenCode SSE forward failed',
|
|||
|
|
});
|
|||
|
|
} else if (!res.writableEnded) {
|
|||
|
|
res.end();
|
|||
|
|
}
|
|||
|
|
console.warn(`SSE forward ${upstreamPath} failed (${endedBy}):`, error?.message || error);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
app.get('/api/event', forwardSseRequest);
|
|||
|
|
app.get('/api/global/event', forwardSseRequest);
|
|||
|
|
|
|||
|
|
app.use('/api', (_req, _res, next) => {
|
|||
|
|
ensureOpenCodeApiPrefix();
|
|||
|
|
next();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.use('/api', (req, res, next) => {
|
|||
|
|
if (
|
|||
|
|
req.path.startsWith('/themes/custom') ||
|
|||
|
|
req.path.startsWith('/config/agents') ||
|
|||
|
|
req.path.startsWith('/config/opencode-resolution') ||
|
|||
|
|
req.path.startsWith('/config/settings') ||
|
|||
|
|
req.path.startsWith('/config/skills') ||
|
|||
|
|
req.path === '/health'
|
|||
|
|
) {
|
|||
|
|
return next();
|
|||
|
|
}
|
|||
|
|
console.log(`API → OpenCode: ${req.method} ${req.path}`);
|
|||
|
|
next();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
|
|||
|
|
const hopByHopRequestHeaders = new Set([
|
|||
|
|
'host',
|
|||
|
|
'connection',
|
|||
|
|
'content-length',
|
|||
|
|
'transfer-encoding',
|
|||
|
|
'keep-alive',
|
|||
|
|
'te',
|
|||
|
|
'trailer',
|
|||
|
|
'upgrade',
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
const hopByHopResponseHeaders = new Set([
|
|||
|
|
'connection',
|
|||
|
|
'content-length',
|
|||
|
|
'transfer-encoding',
|
|||
|
|
'keep-alive',
|
|||
|
|
'te',
|
|||
|
|
'trailer',
|
|||
|
|
'upgrade',
|
|||
|
|
'www-authenticate',
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
const collectForwardHeaders = (req) => {
|
|||
|
|
const authHeaders = getOpenCodeAuthHeaders();
|
|||
|
|
const headers = {};
|
|||
|
|
|
|||
|
|
for (const [key, value] of Object.entries(req.headers || {})) {
|
|||
|
|
if (!value) continue;
|
|||
|
|
const lowerKey = key.toLowerCase();
|
|||
|
|
if (hopByHopRequestHeaders.has(lowerKey)) continue;
|
|||
|
|
headers[lowerKey] = Array.isArray(value) ? value.join(', ') : String(value);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (authHeaders.Authorization) {
|
|||
|
|
headers.Authorization = authHeaders.Authorization;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return headers;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const collectRequestBodyBuffer = async (req) => {
|
|||
|
|
if (Buffer.isBuffer(req.body)) {
|
|||
|
|
return req.body;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (typeof req.body === 'string') {
|
|||
|
|
return Buffer.from(req.body);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (req.body && typeof req.body === 'object') {
|
|||
|
|
return Buffer.from(JSON.stringify(req.body));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (req.readableEnded) {
|
|||
|
|
return Buffer.alloc(0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return await new Promise((resolve, reject) => {
|
|||
|
|
const chunks = [];
|
|||
|
|
req.on('data', (chunk) => {
|
|||
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|||
|
|
});
|
|||
|
|
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|||
|
|
req.on('error', reject);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const forwardGenericApiRequest = async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const upstreamPath = getUpstreamPathForRequest(req);
|
|||
|
|
const targetUrl = buildOpenCodeUrl(upstreamPath, '');
|
|||
|
|
const headers = collectForwardHeaders(req);
|
|||
|
|
const method = String(req.method || 'GET').toUpperCase();
|
|||
|
|
const hasBody = method !== 'GET' && method !== 'HEAD';
|
|||
|
|
const bodyBuffer = hasBody ? await collectRequestBodyBuffer(req) : null;
|
|||
|
|
|
|||
|
|
const upstreamResponse = await fetch(targetUrl, {
|
|||
|
|
method,
|
|||
|
|
headers,
|
|||
|
|
body: hasBody ? bodyBuffer : undefined,
|
|||
|
|
signal: AbortSignal.timeout(LONG_REQUEST_TIMEOUT_MS),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
for (const [key, value] of upstreamResponse.headers.entries()) {
|
|||
|
|
const lowerKey = key.toLowerCase();
|
|||
|
|
if (hopByHopResponseHeaders.has(lowerKey)) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
res.setHeader(key, value);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const upstreamBody = Buffer.from(await upstreamResponse.arrayBuffer());
|
|||
|
|
res.status(upstreamResponse.status).send(upstreamBody);
|
|||
|
|
} catch (error) {
|
|||
|
|
if (!res.headersSent) {
|
|||
|
|
const isTimeout = error?.name === 'TimeoutError' || error?.name === 'AbortError';
|
|||
|
|
res.status(isTimeout ? 504 : 503).json({
|
|||
|
|
error: isTimeout ? 'OpenCode request timed out' : 'OpenCode service unavailable',
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Dedicated forwarder for large session message payloads.
|
|||
|
|
// This avoids edge-cases in generic proxy streaming for multi-file attachments.
|
|||
|
|
app.post('/api/session/:sessionId/message', express.raw({ type: '*/*', limit: '50mb' }), async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const upstreamPath = getUpstreamPathForRequest(req);
|
|||
|
|
const targetUrl = buildOpenCodeUrl(upstreamPath, '');
|
|||
|
|
const authHeaders = getOpenCodeAuthHeaders();
|
|||
|
|
|
|||
|
|
const headers = {
|
|||
|
|
...(typeof req.headers['content-type'] === 'string' ? { 'content-type': req.headers['content-type'] } : { 'content-type': 'application/json' }),
|
|||
|
|
...(typeof req.headers.accept === 'string' ? { accept: req.headers.accept } : {}),
|
|||
|
|
...(authHeaders.Authorization ? { Authorization: authHeaders.Authorization } : {}),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const bodyBuffer = Buffer.isBuffer(req.body)
|
|||
|
|
? req.body
|
|||
|
|
: Buffer.from(typeof req.body === 'string' ? req.body : '');
|
|||
|
|
|
|||
|
|
const upstreamResponse = await fetch(targetUrl, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers,
|
|||
|
|
body: bodyBuffer,
|
|||
|
|
signal: AbortSignal.timeout(LONG_REQUEST_TIMEOUT_MS),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const upstreamBody = Buffer.from(await upstreamResponse.arrayBuffer());
|
|||
|
|
|
|||
|
|
if (upstreamResponse.headers.has('content-type')) {
|
|||
|
|
res.setHeader('content-type', upstreamResponse.headers.get('content-type'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
res.status(upstreamResponse.status).send(upstreamBody);
|
|||
|
|
} catch (error) {
|
|||
|
|
if (!res.headersSent) {
|
|||
|
|
const isTimeout = error?.name === 'TimeoutError' || error?.name === 'AbortError';
|
|||
|
|
res.status(isTimeout ? 504 : 503).json({
|
|||
|
|
error: isTimeout ? 'OpenCode message forward timed out' : 'OpenCode message forward failed',
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.use('/api', async (req, res, next) => {
|
|||
|
|
if (isSseApiPath(req.path)) {
|
|||
|
|
return next();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (req.method === 'POST' && /\/session\/[^/]+\/message$/.test(req.path || '')) {
|
|||
|
|
return next();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Windows: Merge sessions from all project directories on bare GET /session
|
|||
|
|
if (process.platform === 'win32' && req.method === 'GET' && req.path === '/session') {
|
|||
|
|
const rawUrl = req.originalUrl || req.url || '';
|
|||
|
|
if (!rawUrl.includes('directory=')) {
|
|||
|
|
try {
|
|||
|
|
const authHeaders = getOpenCodeAuthHeaders();
|
|||
|
|
const fetchOpts = {
|
|||
|
|
method: 'GET',
|
|||
|
|
headers: { Accept: 'application/json', ...authHeaders },
|
|||
|
|
signal: AbortSignal.timeout(10000),
|
|||
|
|
};
|
|||
|
|
const globalRes = await fetch(buildOpenCodeUrl('/session', ''), fetchOpts);
|
|||
|
|
const globalPayload = globalRes.ok ? await globalRes.json().catch(() => []) : [];
|
|||
|
|
const globalSessions = Array.isArray(globalPayload) ? globalPayload : [];
|
|||
|
|
|
|||
|
|
const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
|
|||
|
|
let projectDirs = [];
|
|||
|
|
try {
|
|||
|
|
const settingsRaw = fs.readFileSync(settingsPath, 'utf8');
|
|||
|
|
const settings = JSON.parse(settingsRaw);
|
|||
|
|
projectDirs = (settings.projects || [])
|
|||
|
|
.map((project) => (typeof project?.path === 'string' ? project.path.trim() : ''))
|
|||
|
|
.filter(Boolean);
|
|||
|
|
} catch {}
|
|||
|
|
|
|||
|
|
const seen = new Set(
|
|||
|
|
globalSessions
|
|||
|
|
.map((session) => (session && typeof session.id === 'string' ? session.id : null))
|
|||
|
|
.filter((id) => typeof id === 'string')
|
|||
|
|
);
|
|||
|
|
const extraSessions = [];
|
|||
|
|
for (const dir of projectDirs) {
|
|||
|
|
const candidates = Array.from(new Set([
|
|||
|
|
dir,
|
|||
|
|
dir.replace(/\\/g, '/'),
|
|||
|
|
dir.replace(/\//g, '\\'),
|
|||
|
|
]));
|
|||
|
|
for (const candidateDir of candidates) {
|
|||
|
|
const encoded = encodeURIComponent(candidateDir);
|
|||
|
|
try {
|
|||
|
|
const dirRes = await fetch(buildOpenCodeUrl(`/session?directory=${encoded}`, ''), fetchOpts);
|
|||
|
|
if (dirRes.ok) {
|
|||
|
|
const dirPayload = await dirRes.json().catch(() => []);
|
|||
|
|
const dirSessions = Array.isArray(dirPayload) ? dirPayload : [];
|
|||
|
|
for (const session of dirSessions) {
|
|||
|
|
const id = session && typeof session.id === 'string' ? session.id : null;
|
|||
|
|
if (id && !seen.has(id)) {
|
|||
|
|
seen.add(id);
|
|||
|
|
extraSessions.push(session);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const merged = [...globalSessions, ...extraSessions];
|
|||
|
|
merged.sort((a, b) => {
|
|||
|
|
const aTime = a && typeof a.time_updated === 'number' ? a.time_updated : 0;
|
|||
|
|
const bTime = b && typeof b.time_updated === 'number' ? b.time_updated : 0;
|
|||
|
|
return bTime - aTime;
|
|||
|
|
});
|
|||
|
|
console.log(`[SessionMerge] ${globalSessions.length} global + ${extraSessions.length} extra = ${merged.length} total`);
|
|||
|
|
return res.json(merged);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.log(`[SessionMerge] Error: ${error.message}, falling through`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return forwardGenericApiRequest(req, res);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function startHealthMonitoring() {
|
|||
|
|
if (healthCheckInterval) {
|
|||
|
|
clearInterval(healthCheckInterval);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
healthCheckInterval = setInterval(async () => {
|
|||
|
|
if (!openCodeProcess || isShuttingDown || isRestartingOpenCode) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const healthy = await isOpenCodeProcessHealthy();
|
|||
|
|
if (!healthy) {
|
|||
|
|
console.log('OpenCode process not running, restarting...');
|
|||
|
|
await restartOpenCode();
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error(`Health check error: ${error.message}`);
|
|||
|
|
}
|
|||
|
|
}, HEALTH_CHECK_INTERVAL);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function gracefulShutdown(options = {}) {
|
|||
|
|
if (isShuttingDown) return;
|
|||
|
|
|
|||
|
|
isShuttingDown = true;
|
|||
|
|
syncToHmrState();
|
|||
|
|
console.log('Starting graceful shutdown...');
|
|||
|
|
const exitProcess = typeof options.exitProcess === 'boolean' ? options.exitProcess : exitOnShutdown;
|
|||
|
|
|
|||
|
|
stopGlobalEventWatcher();
|
|||
|
|
|
|||
|
|
if (healthCheckInterval) {
|
|||
|
|
clearInterval(healthCheckInterval);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (terminalInputWsServer) {
|
|||
|
|
try {
|
|||
|
|
for (const client of terminalInputWsServer.clients) {
|
|||
|
|
try {
|
|||
|
|
client.terminate();
|
|||
|
|
} catch {
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await new Promise((resolve) => {
|
|||
|
|
terminalInputWsServer.close(() => resolve());
|
|||
|
|
});
|
|||
|
|
} catch {
|
|||
|
|
} finally {
|
|||
|
|
terminalInputWsServer = null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Only stop OpenCode if we started it ourselves (not when using external server)
|
|||
|
|
if (!ENV_SKIP_OPENCODE_START && !isExternalOpenCode) {
|
|||
|
|
const portToKill = openCodePort;
|
|||
|
|
|
|||
|
|
if (openCodeProcess) {
|
|||
|
|
console.log('Stopping OpenCode process...');
|
|||
|
|
try {
|
|||
|
|
openCodeProcess.close();
|
|||
|
|
} catch (error) {
|
|||
|
|
console.warn('Error closing OpenCode process:', error);
|
|||
|
|
}
|
|||
|
|
openCodeProcess = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
killProcessOnPort(portToKill);
|
|||
|
|
} else {
|
|||
|
|
console.log('Skipping OpenCode shutdown (external server)');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (server) {
|
|||
|
|
await Promise.race([
|
|||
|
|
new Promise((resolve) => {
|
|||
|
|
server.close(() => {
|
|||
|
|
console.log('HTTP server closed');
|
|||
|
|
resolve();
|
|||
|
|
});
|
|||
|
|
}),
|
|||
|
|
new Promise((resolve) => {
|
|||
|
|
setTimeout(() => {
|
|||
|
|
console.warn('Server close timeout reached, forcing shutdown');
|
|||
|
|
resolve();
|
|||
|
|
}, SHUTDOWN_TIMEOUT);
|
|||
|
|
})
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (uiAuthController) {
|
|||
|
|
uiAuthController.dispose();
|
|||
|
|
uiAuthController = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('Graceful shutdown complete');
|
|||
|
|
if (exitProcess) {
|
|||
|
|
process.exit(0);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function main(options = {}) {
|
|||
|
|
const port = Number.isFinite(options.port) && options.port >= 0 ? Math.trunc(options.port) : DEFAULT_PORT;
|
|||
|
|
const attachSignals = options.attachSignals !== false;
|
|||
|
|
if (typeof options.exitOnShutdown === 'boolean') {
|
|||
|
|
exitOnShutdown = options.exitOnShutdown;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log(`Starting OpenChamber on port ${port === 0 ? 'auto' : port}`);
|
|||
|
|
|
|||
|
|
// Startup model validation is best-effort and runs in background.
|
|||
|
|
void validateZenModelAtStartup();
|
|||
|
|
|
|||
|
|
const app = express();
|
|||
|
|
const serverStartedAt = new Date().toISOString();
|
|||
|
|
app.set('trust proxy', true);
|
|||
|
|
expressApp = app;
|
|||
|
|
server = http.createServer(app);
|
|||
|
|
|
|||
|
|
app.get('/health', (req, res) => {
|
|||
|
|
res.json({
|
|||
|
|
status: 'ok',
|
|||
|
|
timestamp: new Date().toISOString(),
|
|||
|
|
openCodePort: openCodePort,
|
|||
|
|
openCodeRunning: Boolean(openCodePort && isOpenCodeReady && !isRestartingOpenCode),
|
|||
|
|
openCodeSecureConnection: isOpenCodeConnectionSecure(),
|
|||
|
|
openCodeAuthSource: openCodeAuthSource || null,
|
|||
|
|
openCodeApiPrefix: '',
|
|||
|
|
openCodeApiPrefixDetected: true,
|
|||
|
|
isOpenCodeReady,
|
|||
|
|
lastOpenCodeError,
|
|||
|
|
opencodeBinaryResolved: resolvedOpencodeBinary || null,
|
|||
|
|
opencodeBinarySource: resolvedOpencodeBinarySource || null,
|
|||
|
|
opencodeShimInterpreter: resolvedOpencodeBinary ? opencodeShimInterpreter(resolvedOpencodeBinary) : null,
|
|||
|
|
opencodeViaWsl: useWslForOpencode,
|
|||
|
|
opencodeWslBinary: resolvedWslBinary || null,
|
|||
|
|
opencodeWslPath: resolvedWslOpencodePath || null,
|
|||
|
|
opencodeWslDistro: resolvedWslDistro || null,
|
|||
|
|
nodeBinaryResolved: resolvedNodeBinary || null,
|
|||
|
|
bunBinaryResolved: resolvedBunBinary || null,
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/system/shutdown', (req, res) => {
|
|||
|
|
res.json({ ok: true });
|
|||
|
|
gracefulShutdown({ exitProcess: false }).catch((error) => {
|
|||
|
|
console.error('Shutdown request failed:', error?.message || error);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/system/info', (req, res) => {
|
|||
|
|
res.json({
|
|||
|
|
openchamberVersion: OPENCHAMBER_VERSION,
|
|||
|
|
runtime: process.env.OPENCHAMBER_RUNTIME || 'web',
|
|||
|
|
pid: process.pid,
|
|||
|
|
startedAt: serverStartedAt,
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.use((req, res, next) => {
|
|||
|
|
if (
|
|||
|
|
req.path.startsWith('/api/config/agents') ||
|
|||
|
|
req.path.startsWith('/api/config/commands') ||
|
|||
|
|
req.path.startsWith('/api/config/mcp') ||
|
|||
|
|
req.path.startsWith('/api/config/settings') ||
|
|||
|
|
req.path.startsWith('/api/config/skills') ||
|
|||
|
|
req.path.startsWith('/api/projects') ||
|
|||
|
|
req.path.startsWith('/api/fs') ||
|
|||
|
|
req.path.startsWith('/api/git') ||
|
|||
|
|
req.path.startsWith('/api/prompts') ||
|
|||
|
|
req.path.startsWith('/api/terminal') ||
|
|||
|
|
req.path.startsWith('/api/opencode') ||
|
|||
|
|
req.path.startsWith('/api/push')
|
|||
|
|
) {
|
|||
|
|
|
|||
|
|
express.json({ limit: '50mb' })(req, res, next);
|
|||
|
|
} else if (req.path.startsWith('/api')) {
|
|||
|
|
|
|||
|
|
next();
|
|||
|
|
} else {
|
|||
|
|
|
|||
|
|
express.json({ limit: '50mb' })(req, res, next);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
|||
|
|
|
|||
|
|
app.use((req, res, next) => {
|
|||
|
|
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
|
|||
|
|
next();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const uiPassword = typeof options.uiPassword === 'string' ? options.uiPassword : null;
|
|||
|
|
uiAuthController = createUiAuth({ password: uiPassword });
|
|||
|
|
if (uiAuthController.enabled) {
|
|||
|
|
console.log('UI password protection enabled for browser sessions');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
app.get('/auth/session', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
await uiAuthController.handleSessionStatus(req, res);
|
|||
|
|
} catch (err) {
|
|||
|
|
res.status(500).json({ error: 'Internal server error' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
app.post('/auth/session', (req, res) => {
|
|||
|
|
return uiAuthController.handleSessionCreate(req, res);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/connect', async (_req, res) => {
|
|||
|
|
res.setHeader('Cache-Control', 'no-store');
|
|||
|
|
return res.status(404).type('text/plain').send('Not found');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.use('/api', async (req, res, next) => {
|
|||
|
|
try {
|
|||
|
|
await uiAuthController.requireAuth(req, res, next);
|
|||
|
|
} catch (err) {
|
|||
|
|
next(err);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const parsePushSubscribeBody = (body) => {
|
|||
|
|
if (!body || typeof body !== 'object') return null;
|
|||
|
|
const endpoint = body.endpoint;
|
|||
|
|
const keys = body.keys;
|
|||
|
|
const p256dh = keys?.p256dh;
|
|||
|
|
const auth = keys?.auth;
|
|||
|
|
|
|||
|
|
if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null;
|
|||
|
|
if (typeof p256dh !== 'string' || p256dh.trim().length === 0) return null;
|
|||
|
|
if (typeof auth !== 'string' || auth.trim().length === 0) return null;
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
endpoint: endpoint.trim(),
|
|||
|
|
keys: { p256dh: p256dh.trim(), auth: auth.trim() },
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const parsePushUnsubscribeBody = (body) => {
|
|||
|
|
if (!body || typeof body !== 'object') return null;
|
|||
|
|
const endpoint = body.endpoint;
|
|||
|
|
if (typeof endpoint !== 'string' || endpoint.trim().length === 0) return null;
|
|||
|
|
return { endpoint: endpoint.trim() };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
app.get('/api/push/vapid-public-key', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
await ensurePushInitialized();
|
|||
|
|
const keys = await getOrCreateVapidKeys();
|
|||
|
|
res.json({ publicKey: keys.publicKey });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.warn('[Push] Failed to load VAPID key:', error);
|
|||
|
|
res.status(500).json({ error: 'Failed to load push key' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/push/subscribe', async (req, res) => {
|
|||
|
|
await ensurePushInitialized();
|
|||
|
|
|
|||
|
|
const uiToken = uiAuthController?.ensureSessionToken
|
|||
|
|
? await uiAuthController.ensureSessionToken(req, res)
|
|||
|
|
: getUiSessionTokenFromRequest(req);
|
|||
|
|
if (!uiToken) {
|
|||
|
|
return res.status(401).json({ error: 'UI session missing' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const parsed = parsePushSubscribeBody(req.body);
|
|||
|
|
if (!parsed) {
|
|||
|
|
return res.status(400).json({ error: 'Invalid body' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { endpoint, keys } = parsed;
|
|||
|
|
|
|||
|
|
const origin = typeof req.body?.origin === 'string' ? req.body.origin.trim() : '';
|
|||
|
|
if (origin.startsWith('http://') || origin.startsWith('https://')) {
|
|||
|
|
try {
|
|||
|
|
const settings = await readSettingsFromDiskMigrated();
|
|||
|
|
if (typeof settings?.publicOrigin !== 'string' || settings.publicOrigin.trim().length === 0) {
|
|||
|
|
await writeSettingsToDisk({
|
|||
|
|
...settings,
|
|||
|
|
publicOrigin: origin,
|
|||
|
|
});
|
|||
|
|
// allow next sends to pick it up
|
|||
|
|
pushInitialized = false;
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await addOrUpdatePushSubscription(
|
|||
|
|
uiToken,
|
|||
|
|
{
|
|||
|
|
endpoint,
|
|||
|
|
p256dh: keys.p256dh,
|
|||
|
|
auth: keys.auth,
|
|||
|
|
},
|
|||
|
|
req.headers['user-agent']
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
res.json({ ok: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
|
|||
|
|
app.delete('/api/push/subscribe', async (req, res) => {
|
|||
|
|
await ensurePushInitialized();
|
|||
|
|
|
|||
|
|
const uiToken = uiAuthController?.ensureSessionToken
|
|||
|
|
? await uiAuthController.ensureSessionToken(req, res)
|
|||
|
|
: getUiSessionTokenFromRequest(req);
|
|||
|
|
if (!uiToken) {
|
|||
|
|
return res.status(401).json({ error: 'UI session missing' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const parsed = parsePushUnsubscribeBody(req.body);
|
|||
|
|
if (!parsed) {
|
|||
|
|
return res.status(400).json({ error: 'Invalid body' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await removePushSubscription(uiToken, parsed.endpoint);
|
|||
|
|
res.json({ ok: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/push/visibility', async (req, res) => {
|
|||
|
|
const uiToken = uiAuthController?.ensureSessionToken
|
|||
|
|
? await uiAuthController.ensureSessionToken(req, res)
|
|||
|
|
: getUiSessionTokenFromRequest(req);
|
|||
|
|
if (!uiToken) {
|
|||
|
|
return res.status(401).json({ error: 'UI session missing' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const visible = req.body && typeof req.body === 'object' ? req.body.visible : null;
|
|||
|
|
updateUiVisibility(uiToken, visible === true);
|
|||
|
|
res.json({ ok: true });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/push/visibility', (req, res) => {
|
|||
|
|
const uiToken = getUiSessionTokenFromRequest(req);
|
|||
|
|
if (!uiToken) {
|
|||
|
|
return res.status(401).json({ error: 'UI session missing' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
ok: true,
|
|||
|
|
visible: isUiVisible(uiToken),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Session activity status endpoint - returns tracked activity phases for all sessions
|
|||
|
|
// Used by UI on visibility restore to get accurate status without waiting for SSE
|
|||
|
|
app.get('/api/session-activity', (_req, res) => {
|
|||
|
|
res.json(getSessionActivitySnapshot());
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// New authoritative session status endpoints
|
|||
|
|
// Server maintains the source of truth, clients only query
|
|||
|
|
|
|||
|
|
// GET /api/sessions/snapshot - Combined status + attention snapshot
|
|||
|
|
app.get('/api/sessions/snapshot', (_req, res) => {
|
|||
|
|
res.json({
|
|||
|
|
statusSessions: getSessionStateSnapshot(),
|
|||
|
|
attentionSessions: getSessionAttentionSnapshot(),
|
|||
|
|
serverTime: Date.now()
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// GET /api/sessions/status - Get status for all sessions
|
|||
|
|
app.get('/api/sessions/status', (_req, res) => {
|
|||
|
|
const snapshot = getSessionStateSnapshot();
|
|||
|
|
res.json({
|
|||
|
|
sessions: snapshot,
|
|||
|
|
serverTime: Date.now()
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// GET /api/sessions/:id/status - Get status for a specific session
|
|||
|
|
app.get('/api/sessions/:id/status', (req, res) => {
|
|||
|
|
const sessionId = req.params.id;
|
|||
|
|
const state = getSessionState(sessionId);
|
|||
|
|
|
|||
|
|
if (!state) {
|
|||
|
|
return res.status(404).json({
|
|||
|
|
error: 'Session not found or no state available',
|
|||
|
|
sessionId
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
sessionId,
|
|||
|
|
...state
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Session attention tracking endpoints
|
|||
|
|
// GET /api/sessions/attention - Get attention state for all sessions
|
|||
|
|
app.get('/api/sessions/attention', (_req, res) => {
|
|||
|
|
const snapshot = getSessionAttentionSnapshot();
|
|||
|
|
res.json({
|
|||
|
|
sessions: snapshot,
|
|||
|
|
serverTime: Date.now()
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// GET /api/sessions/:id/attention - Get attention state for a specific session
|
|||
|
|
app.get('/api/sessions/:id/attention', (req, res) => {
|
|||
|
|
const sessionId = req.params.id;
|
|||
|
|
const state = getSessionAttentionState(sessionId);
|
|||
|
|
|
|||
|
|
if (!state) {
|
|||
|
|
return res.status(404).json({
|
|||
|
|
error: 'Session not found or no attention state available',
|
|||
|
|
sessionId
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
sessionId,
|
|||
|
|
...state
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// POST /api/sessions/:id/view - Client reports viewing this session
|
|||
|
|
app.post('/api/sessions/:id/view', (req, res) => {
|
|||
|
|
const sessionId = req.params.id;
|
|||
|
|
const clientId = req.headers['x-client-id'] || req.ip || 'anonymous';
|
|||
|
|
|
|||
|
|
markSessionViewed(sessionId, clientId);
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
sessionId,
|
|||
|
|
viewed: true
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// POST /api/sessions/:id/unview - Client reports leaving this session
|
|||
|
|
app.post('/api/sessions/:id/unview', (req, res) => {
|
|||
|
|
const sessionId = req.params.id;
|
|||
|
|
const clientId = req.headers['x-client-id'] || req.ip || 'anonymous';
|
|||
|
|
|
|||
|
|
markSessionUnviewed(sessionId, clientId);
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
sessionId,
|
|||
|
|
viewed: false
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// POST /api/sessions/:id/message-sent - User sent a message in this session
|
|||
|
|
app.post('/api/sessions/:id/message-sent', (req, res) => {
|
|||
|
|
const sessionId = req.params.id;
|
|||
|
|
|
|||
|
|
markUserMessageSent(sessionId);
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
sessionId,
|
|||
|
|
messageSent: true
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/openchamber/update-check', async (_req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { checkForUpdates } = await import('./lib/package-manager.js');
|
|||
|
|
const updateInfo = await checkForUpdates();
|
|||
|
|
res.json(updateInfo);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to check for updates:', error);
|
|||
|
|
res.status(500).json({
|
|||
|
|
available: false,
|
|||
|
|
error: error instanceof Error ? error.message : 'Failed to check for updates',
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/openchamber/update-install', async (_req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { spawn: spawnChild } = await import('child_process');
|
|||
|
|
const {
|
|||
|
|
checkForUpdates,
|
|||
|
|
getUpdateCommand,
|
|||
|
|
detectPackageManager,
|
|||
|
|
} = await import('./lib/package-manager.js');
|
|||
|
|
|
|||
|
|
// Verify update is available
|
|||
|
|
const updateInfo = await checkForUpdates();
|
|||
|
|
if (!updateInfo.available) {
|
|||
|
|
return res.status(400).json({ error: 'No update available' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const pm = detectPackageManager();
|
|||
|
|
const updateCmd = getUpdateCommand(pm);
|
|||
|
|
const isContainer =
|
|||
|
|
fs.existsSync('/.dockerenv') ||
|
|||
|
|
Boolean(process.env.CONTAINER) ||
|
|||
|
|
process.env.container === 'docker';
|
|||
|
|
|
|||
|
|
if (isContainer) {
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
message: 'Update starting, server will stay online',
|
|||
|
|
version: updateInfo.version,
|
|||
|
|
packageManager: pm,
|
|||
|
|
autoRestart: false,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
setTimeout(() => {
|
|||
|
|
console.log(`\nInstalling update using ${pm} (container mode)...`);
|
|||
|
|
console.log(`Running: ${updateCmd}`);
|
|||
|
|
|
|||
|
|
const shell = process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : 'sh';
|
|||
|
|
const shellFlag = process.platform === 'win32' ? '/c' : '-c';
|
|||
|
|
const child = spawnChild(shell, [shellFlag, updateCmd], {
|
|||
|
|
detached: true,
|
|||
|
|
stdio: 'ignore',
|
|||
|
|
env: process.env,
|
|||
|
|
});
|
|||
|
|
child.unref();
|
|||
|
|
}, 500);
|
|||
|
|
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get current server port for restart
|
|||
|
|
const currentPort = server.address()?.port || 3000;
|
|||
|
|
|
|||
|
|
// Try to read stored instance options for restart
|
|||
|
|
const tmpDir = os.tmpdir();
|
|||
|
|
const instanceFilePath = path.join(tmpDir, `openchamber-${currentPort}.json`);
|
|||
|
|
let storedOptions = { port: currentPort, daemon: true };
|
|||
|
|
try {
|
|||
|
|
const content = await fs.promises.readFile(instanceFilePath, 'utf8');
|
|||
|
|
storedOptions = JSON.parse(content);
|
|||
|
|
} catch {
|
|||
|
|
// Use defaults
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const isWindows = process.platform === 'win32';
|
|||
|
|
|
|||
|
|
const quotePosix = (value) => `'${String(value).replace(/'/g, "'\\''")}'`;
|
|||
|
|
const quoteCmd = (value) => {
|
|||
|
|
const stringValue = String(value);
|
|||
|
|
return `"${stringValue.replace(/"/g, '""')}"`;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Build restart command using explicit runtime + CLI path.
|
|||
|
|
// Avoids relying on `openchamber` being in PATH for service environments.
|
|||
|
|
const cliPath = path.resolve(__dirname, '..', 'bin', 'cli.js');
|
|||
|
|
const restartParts = [
|
|||
|
|
isWindows ? quoteCmd(process.execPath) : quotePosix(process.execPath),
|
|||
|
|
isWindows ? quoteCmd(cliPath) : quotePosix(cliPath),
|
|||
|
|
'serve',
|
|||
|
|
'--port',
|
|||
|
|
String(storedOptions.port),
|
|||
|
|
'--daemon',
|
|||
|
|
];
|
|||
|
|
let restartCmdPrimary = restartParts.join(' ');
|
|||
|
|
let restartCmdFallback = `openchamber serve --port ${storedOptions.port} --daemon`;
|
|||
|
|
if (storedOptions.uiPassword) {
|
|||
|
|
if (isWindows) {
|
|||
|
|
// Escape for cmd.exe quoted argument
|
|||
|
|
const escapedPw = storedOptions.uiPassword.replace(/"/g, '""');
|
|||
|
|
restartCmdPrimary += ` --ui-password "${escapedPw}"`;
|
|||
|
|
restartCmdFallback += ` --ui-password "${escapedPw}"`;
|
|||
|
|
} else {
|
|||
|
|
// Escape for POSIX single-quoted argument
|
|||
|
|
const escapedPw = storedOptions.uiPassword.replace(/'/g, "'\\''");
|
|||
|
|
restartCmdPrimary += ` --ui-password '${escapedPw}'`;
|
|||
|
|
restartCmdFallback += ` --ui-password '${escapedPw}'`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const restartCmd = `(${restartCmdPrimary}) || (${restartCmdFallback})`;
|
|||
|
|
|
|||
|
|
// Respond immediately - update will happen after response
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
message: 'Update starting, server will restart shortly',
|
|||
|
|
version: updateInfo.version,
|
|||
|
|
packageManager: pm,
|
|||
|
|
autoRestart: true,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Give time for response to be sent
|
|||
|
|
setTimeout(() => {
|
|||
|
|
console.log(`\nInstalling update using ${pm}...`);
|
|||
|
|
console.log(`Running: ${updateCmd}`);
|
|||
|
|
|
|||
|
|
// Create a script that will:
|
|||
|
|
// 1. Wait for current process to exit
|
|||
|
|
// 2. Run the update
|
|||
|
|
// 3. Restart the server with original options
|
|||
|
|
const shell = isWindows ? (process.env.ComSpec || 'cmd.exe') : 'sh';
|
|||
|
|
const shellFlag = isWindows ? '/c' : '-c';
|
|||
|
|
const script = isWindows
|
|||
|
|
? `
|
|||
|
|
timeout /t 2 /nobreak >nul
|
|||
|
|
${updateCmd}
|
|||
|
|
if %ERRORLEVEL% EQU 0 (
|
|||
|
|
echo Update successful, restarting OpenChamber...
|
|||
|
|
${restartCmd}
|
|||
|
|
) else (
|
|||
|
|
echo Update failed
|
|||
|
|
exit /b 1
|
|||
|
|
)
|
|||
|
|
`
|
|||
|
|
: `
|
|||
|
|
sleep 2
|
|||
|
|
${updateCmd}
|
|||
|
|
if [ $? -eq 0 ]; then
|
|||
|
|
echo "Update successful, restarting OpenChamber..."
|
|||
|
|
${restartCmd}
|
|||
|
|
else
|
|||
|
|
echo "Update failed"
|
|||
|
|
exit 1
|
|||
|
|
fi
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
// Spawn detached shell to run update after we exit.
|
|||
|
|
// Capture output to disk so restart failures are diagnosable.
|
|||
|
|
const updateLogPath = path.join(OPENCHAMBER_DATA_DIR, 'update-install.log');
|
|||
|
|
let logFd = null;
|
|||
|
|
try {
|
|||
|
|
fs.mkdirSync(path.dirname(updateLogPath), { recursive: true });
|
|||
|
|
logFd = fs.openSync(updateLogPath, 'a');
|
|||
|
|
} catch (logError) {
|
|||
|
|
console.warn('Failed to open update log file, continuing without log capture:', logError);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const child = spawnChild(shell, [shellFlag, script], {
|
|||
|
|
detached: true,
|
|||
|
|
stdio: logFd !== null ? ['ignore', logFd, logFd] : 'ignore',
|
|||
|
|
env: process.env,
|
|||
|
|
});
|
|||
|
|
child.unref();
|
|||
|
|
|
|||
|
|
if (logFd !== null) {
|
|||
|
|
try {
|
|||
|
|
fs.closeSync(logFd);
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('Update process spawned, shutting down server...');
|
|||
|
|
|
|||
|
|
// Give child process time to start, then exit
|
|||
|
|
setTimeout(() => {
|
|||
|
|
process.exit(0);
|
|||
|
|
}, 500);
|
|||
|
|
}, 500);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to install update:', error);
|
|||
|
|
res.status(500).json({
|
|||
|
|
error: error instanceof Error ? error.message : 'Failed to install update',
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/openchamber/models-metadata', async (req, res) => {
|
|||
|
|
const now = Date.now();
|
|||
|
|
|
|||
|
|
if (cachedModelsMetadata && now - cachedModelsMetadataTimestamp < MODELS_METADATA_CACHE_TTL) {
|
|||
|
|
res.setHeader('Cache-Control', 'public, max-age=60');
|
|||
|
|
return res.json(cachedModelsMetadata);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|||
|
|
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(MODELS_DEV_API_URL, {
|
|||
|
|
signal: controller?.signal,
|
|||
|
|
headers: {
|
|||
|
|
Accept: 'application/json'
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
throw new Error(`models.dev responded with status ${response.status}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const metadata = await response.json();
|
|||
|
|
cachedModelsMetadata = metadata;
|
|||
|
|
cachedModelsMetadataTimestamp = Date.now();
|
|||
|
|
|
|||
|
|
res.setHeader('Cache-Control', 'public, max-age=300');
|
|||
|
|
res.json(metadata);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.warn('Failed to fetch models.dev metadata via server:', error);
|
|||
|
|
|
|||
|
|
if (cachedModelsMetadata) {
|
|||
|
|
res.setHeader('Cache-Control', 'public, max-age=60');
|
|||
|
|
res.json(cachedModelsMetadata);
|
|||
|
|
} else {
|
|||
|
|
const statusCode = error?.name === 'AbortError' ? 504 : 502;
|
|||
|
|
res.status(statusCode).json({ error: 'Failed to retrieve model metadata' });
|
|||
|
|
}
|
|||
|
|
} finally {
|
|||
|
|
if (timeout) {
|
|||
|
|
clearTimeout(timeout);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Zen models endpoint - returns available free models from the zen API
|
|||
|
|
app.get('/api/zen/models', async (_req, res) => {
|
|||
|
|
try {
|
|||
|
|
const models = await fetchFreeZenModels();
|
|||
|
|
res.setHeader('Cache-Control', 'public, max-age=300');
|
|||
|
|
res.json({ models });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.warn('Failed to fetch zen models:', error);
|
|||
|
|
// Serve stale cache if available
|
|||
|
|
if (cachedZenModels) {
|
|||
|
|
res.setHeader('Cache-Control', 'public, max-age=60');
|
|||
|
|
res.json(cachedZenModels);
|
|||
|
|
} else {
|
|||
|
|
const statusCode = error?.name === 'AbortError' ? 504 : 502;
|
|||
|
|
res.status(statusCode).json({ error: 'Failed to retrieve zen models' });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/global/event', async (req, res) => {
|
|||
|
|
let targetUrl;
|
|||
|
|
try {
|
|||
|
|
targetUrl = new URL(buildOpenCodeUrl('/global/event', ''));
|
|||
|
|
} catch {
|
|||
|
|
return res.status(503).json({ error: 'OpenCode service unavailable' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const headers = {
|
|||
|
|
Accept: 'text/event-stream',
|
|||
|
|
'Cache-Control': 'no-cache',
|
|||
|
|
Connection: 'keep-alive',
|
|||
|
|
...getOpenCodeAuthHeaders(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const lastEventId = req.header('Last-Event-ID');
|
|||
|
|
if (typeof lastEventId === 'string' && lastEventId.length > 0) {
|
|||
|
|
headers['Last-Event-ID'] = lastEventId;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const controller = new AbortController();
|
|||
|
|
const cleanup = () => {
|
|||
|
|
if (!controller.signal.aborted) {
|
|||
|
|
controller.abort();
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
req.on('close', cleanup);
|
|||
|
|
req.on('error', cleanup);
|
|||
|
|
|
|||
|
|
let upstream;
|
|||
|
|
try {
|
|||
|
|
upstream = await fetch(targetUrl.toString(), {
|
|||
|
|
headers,
|
|||
|
|
signal: controller.signal,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
return res.status(502).json({ error: 'Failed to connect to OpenCode event stream' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!upstream.ok || !upstream.body) {
|
|||
|
|
return res.status(502).json({ error: `OpenCode event stream unavailable (${upstream.status})` });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
res.setHeader('Content-Type', 'text/event-stream');
|
|||
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|||
|
|
res.setHeader('Connection', 'keep-alive');
|
|||
|
|
res.setHeader('X-Accel-Buffering', 'no');
|
|||
|
|
|
|||
|
|
if (typeof res.flushHeaders === 'function') {
|
|||
|
|
res.flushHeaders();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
uiNotificationClients.add(res);
|
|||
|
|
const cleanupClient = () => {
|
|||
|
|
uiNotificationClients.delete(res);
|
|||
|
|
};
|
|||
|
|
req.on('close', cleanupClient);
|
|||
|
|
req.on('error', cleanupClient);
|
|||
|
|
|
|||
|
|
const heartbeatInterval = setInterval(() => {
|
|||
|
|
writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() });
|
|||
|
|
}, 15000);
|
|||
|
|
|
|||
|
|
const decoder = new TextDecoder();
|
|||
|
|
const reader = upstream.body.getReader();
|
|||
|
|
let buffer = '';
|
|||
|
|
|
|||
|
|
const forwardBlock = (block) => {
|
|||
|
|
if (!block) return;
|
|||
|
|
res.write(`${block}
|
|||
|
|
|
|||
|
|
`);
|
|||
|
|
const payload = parseSseDataPayload(block);
|
|||
|
|
// Cache session titles from session.updated/session.created events (global stream)
|
|||
|
|
maybeCacheSessionInfoFromEvent(payload);
|
|||
|
|
|
|||
|
|
// Keep server-authoritative session state fresh even if the
|
|||
|
|
// background watcher is disconnected.
|
|||
|
|
if (payload && payload.type === 'session.status') {
|
|||
|
|
const update = extractSessionStatusUpdate(payload);
|
|||
|
|
if (update) {
|
|||
|
|
updateSessionState(update.sessionId, update.type, update.eventId || `proxy-${Date.now()}`, {
|
|||
|
|
attempt: update.attempt,
|
|||
|
|
message: update.message,
|
|||
|
|
next: update.next,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const transitions = deriveSessionActivityTransitions(payload);
|
|||
|
|
if (transitions && transitions.length > 0) {
|
|||
|
|
for (const activity of transitions) {
|
|||
|
|
if (setSessionActivityPhase(activity.sessionId, activity.phase)) {
|
|||
|
|
writeSseEvent(res, {
|
|||
|
|
type: 'openchamber:session-activity',
|
|||
|
|
properties: {
|
|||
|
|
sessionId: activity.sessionId,
|
|||
|
|
phase: activity.phase,
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
while (true) {
|
|||
|
|
const { value, done } = await reader.read();
|
|||
|
|
if (done) break;
|
|||
|
|
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
|
|||
|
|
|
|||
|
|
let separatorIndex = buffer.indexOf('\n\n');
|
|||
|
|
while (separatorIndex !== -1) {
|
|||
|
|
const block = buffer.slice(0, separatorIndex);
|
|||
|
|
buffer = buffer.slice(separatorIndex + 2);
|
|||
|
|
forwardBlock(block);
|
|||
|
|
separatorIndex = buffer.indexOf('\n\n');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (buffer.trim().length > 0) {
|
|||
|
|
forwardBlock(buffer.trim());
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
if (!controller.signal.aborted) {
|
|||
|
|
console.warn('SSE proxy stream error:', error);
|
|||
|
|
}
|
|||
|
|
} finally {
|
|||
|
|
clearInterval(heartbeatInterval);
|
|||
|
|
cleanupClient();
|
|||
|
|
cleanup();
|
|||
|
|
try {
|
|||
|
|
res.end();
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/event', async (req, res) => {
|
|||
|
|
let targetUrl;
|
|||
|
|
try {
|
|||
|
|
targetUrl = new URL(buildOpenCodeUrl('/event', ''));
|
|||
|
|
} catch {
|
|||
|
|
return res.status(503).json({ error: 'OpenCode service unavailable' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
|||
|
|
const directoryParam = Array.isArray(req.query.directory)
|
|||
|
|
? req.query.directory[0]
|
|||
|
|
: req.query.directory;
|
|||
|
|
const resolvedDirectory = headerDirectory || directoryParam || null;
|
|||
|
|
if (typeof resolvedDirectory === 'string' && resolvedDirectory.trim().length > 0) {
|
|||
|
|
targetUrl.searchParams.set('directory', resolvedDirectory.trim());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const headers = {
|
|||
|
|
Accept: 'text/event-stream',
|
|||
|
|
'Cache-Control': 'no-cache',
|
|||
|
|
Connection: 'keep-alive',
|
|||
|
|
...getOpenCodeAuthHeaders(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const lastEventId = req.header('Last-Event-ID');
|
|||
|
|
if (typeof lastEventId === 'string' && lastEventId.length > 0) {
|
|||
|
|
headers['Last-Event-ID'] = lastEventId;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const controller = new AbortController();
|
|||
|
|
const cleanup = () => {
|
|||
|
|
if (!controller.signal.aborted) {
|
|||
|
|
controller.abort();
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
req.on('close', cleanup);
|
|||
|
|
req.on('error', cleanup);
|
|||
|
|
|
|||
|
|
let upstream;
|
|||
|
|
try {
|
|||
|
|
upstream = await fetch(targetUrl.toString(), {
|
|||
|
|
headers,
|
|||
|
|
signal: controller.signal,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
return res.status(502).json({ error: 'Failed to connect to OpenCode event stream' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!upstream.ok || !upstream.body) {
|
|||
|
|
return res.status(502).json({ error: `OpenCode event stream unavailable (${upstream.status})` });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
res.setHeader('Content-Type', 'text/event-stream');
|
|||
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|||
|
|
res.setHeader('Connection', 'keep-alive');
|
|||
|
|
res.setHeader('X-Accel-Buffering', 'no');
|
|||
|
|
|
|||
|
|
if (typeof res.flushHeaders === 'function') {
|
|||
|
|
res.flushHeaders();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const heartbeatInterval = setInterval(() => {
|
|||
|
|
writeSseEvent(res, { type: 'openchamber:heartbeat', timestamp: Date.now() });
|
|||
|
|
}, 15000);
|
|||
|
|
|
|||
|
|
const decoder = new TextDecoder();
|
|||
|
|
const reader = upstream.body.getReader();
|
|||
|
|
let buffer = '';
|
|||
|
|
|
|||
|
|
const forwardBlock = (block) => {
|
|||
|
|
if (!block) return;
|
|||
|
|
res.write(`${block}
|
|||
|
|
|
|||
|
|
`);
|
|||
|
|
const payload = parseSseDataPayload(block);
|
|||
|
|
// Cache session titles from session.updated/session.created events (per-session stream)
|
|||
|
|
maybeCacheSessionInfoFromEvent(payload);
|
|||
|
|
|
|||
|
|
if (payload && payload.type === 'session.status') {
|
|||
|
|
const update = extractSessionStatusUpdate(payload);
|
|||
|
|
if (update) {
|
|||
|
|
updateSessionState(update.sessionId, update.type, update.eventId || `proxy-${Date.now()}`, {
|
|||
|
|
attempt: update.attempt,
|
|||
|
|
message: update.message,
|
|||
|
|
next: update.next,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const transitions = deriveSessionActivityTransitions(payload);
|
|||
|
|
if (transitions && transitions.length > 0) {
|
|||
|
|
for (const activity of transitions) {
|
|||
|
|
if (setSessionActivityPhase(activity.sessionId, activity.phase)) {
|
|||
|
|
writeSseEvent(res, {
|
|||
|
|
type: 'openchamber:session-activity',
|
|||
|
|
properties: {
|
|||
|
|
sessionId: activity.sessionId,
|
|||
|
|
phase: activity.phase,
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
while (true) {
|
|||
|
|
const { value, done } = await reader.read();
|
|||
|
|
if (done) break;
|
|||
|
|
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
|
|||
|
|
|
|||
|
|
let separatorIndex = buffer.indexOf('\n\n');
|
|||
|
|
while (separatorIndex !== -1) {
|
|||
|
|
const block = buffer.slice(0, separatorIndex);
|
|||
|
|
buffer = buffer.slice(separatorIndex + 2);
|
|||
|
|
forwardBlock(block);
|
|||
|
|
separatorIndex = buffer.indexOf('\n\n');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (buffer.trim().length > 0) {
|
|||
|
|
forwardBlock(buffer.trim());
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
if (!controller.signal.aborted) {
|
|||
|
|
console.warn('SSE proxy stream error:', error);
|
|||
|
|
}
|
|||
|
|
} finally {
|
|||
|
|
clearInterval(heartbeatInterval);
|
|||
|
|
cleanup();
|
|||
|
|
try {
|
|||
|
|
res.end();
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/config/settings', async (_req, res) => {
|
|||
|
|
try {
|
|||
|
|
const settings = await readSettingsFromDiskMigrated();
|
|||
|
|
res.json(formatSettingsResponse(settings));
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to load settings:', error);
|
|||
|
|
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to load settings' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/config/opencode-resolution', async (_req, res) => {
|
|||
|
|
try {
|
|||
|
|
const settings = await readSettingsFromDiskMigrated();
|
|||
|
|
const configured = typeof settings?.opencodeBinary === 'string' ? settings.opencodeBinary : null;
|
|||
|
|
|
|||
|
|
const previousSource = resolvedOpencodeBinarySource;
|
|||
|
|
const detectedNow = resolveOpencodeCliPath();
|
|||
|
|
const rawDetectedSourceNow = resolvedOpencodeBinarySource;
|
|||
|
|
resolvedOpencodeBinarySource = previousSource;
|
|||
|
|
|
|||
|
|
// Best-effort: apply configured override (if any) and resolve.
|
|||
|
|
await applyOpencodeBinaryFromSettings();
|
|||
|
|
ensureOpencodeCliEnv();
|
|||
|
|
|
|||
|
|
const resolved = resolvedOpencodeBinary || null;
|
|||
|
|
const source = resolvedOpencodeBinarySource || null;
|
|||
|
|
const detectedSourceNow =
|
|||
|
|
detectedNow &&
|
|||
|
|
resolved &&
|
|||
|
|
detectedNow === resolved &&
|
|||
|
|
rawDetectedSourceNow === 'env' &&
|
|||
|
|
source &&
|
|||
|
|
source !== 'env'
|
|||
|
|
? source
|
|||
|
|
: rawDetectedSourceNow;
|
|||
|
|
const shim = resolved ? opencodeShimInterpreter(resolved) : null;
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
configured,
|
|||
|
|
resolved,
|
|||
|
|
resolvedDir: resolved ? path.dirname(resolved) : null,
|
|||
|
|
source,
|
|||
|
|
detectedNow,
|
|||
|
|
detectedSourceNow,
|
|||
|
|
shim,
|
|||
|
|
viaWsl: useWslForOpencode,
|
|||
|
|
wslBinary: resolvedWslBinary || null,
|
|||
|
|
wslPath: resolvedWslOpencodePath || null,
|
|||
|
|
wslDistro: resolvedWslDistro || null,
|
|||
|
|
node: resolvedNodeBinary || null,
|
|||
|
|
bun: resolvedBunBinary || null,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to build opencode resolution snapshot:', error);
|
|||
|
|
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to build snapshot' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/config/themes', async (_req, res) => {
|
|||
|
|
try {
|
|||
|
|
const customThemes = await readCustomThemesFromDisk();
|
|||
|
|
res.json({ themes: customThemes });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to load custom themes:', error);
|
|||
|
|
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to load custom themes' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.put('/api/config/settings', async (req, res) => {
|
|||
|
|
console.log(`[API:PUT /api/config/settings] Received request`);
|
|||
|
|
try {
|
|||
|
|
const updated = await persistSettings(req.body ?? {});
|
|||
|
|
console.log(`[API:PUT /api/config/settings] Success, returning ${updated.projects?.length || 0} projects`);
|
|||
|
|
res.json(updated);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error(`[API:PUT /api/config/settings] Failed to save settings:`, error);
|
|||
|
|
console.error(`[API:PUT /api/config/settings] Error stack:`, error.stack);
|
|||
|
|
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to save settings' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/projects/:projectId/icon', async (req, res) => {
|
|||
|
|
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : '';
|
|||
|
|
if (!projectId) {
|
|||
|
|
return res.status(400).json({ error: 'projectId is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const settings = await readSettingsFromDiskMigrated();
|
|||
|
|
const { project } = findProjectById(settings, projectId);
|
|||
|
|
if (!project) {
|
|||
|
|
return res.status(404).json({ error: 'Project not found' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const metadataMime = normalizeProjectIconMime(project.iconImage?.mime);
|
|||
|
|
const preferredPath = metadataMime ? projectIconPathForMime(projectId, metadataMime) : null;
|
|||
|
|
const candidates = preferredPath
|
|||
|
|
? [preferredPath, ...projectIconPathCandidates(projectId).filter((candidate) => candidate !== preferredPath)]
|
|||
|
|
: projectIconPathCandidates(projectId);
|
|||
|
|
|
|||
|
|
const themeQuery = Array.isArray(req.query?.theme) ? req.query.theme[0] : req.query?.theme;
|
|||
|
|
const requestedThemeVariant = normalizeProjectIconThemeVariant(themeQuery);
|
|||
|
|
const iconColorQuery = Array.isArray(req.query?.iconColor) ? req.query.iconColor[0] : req.query?.iconColor;
|
|||
|
|
const requestedIconColor = normalizeProjectIconColor(iconColorQuery);
|
|||
|
|
|
|||
|
|
for (const iconPath of candidates) {
|
|||
|
|
try {
|
|||
|
|
const data = await fsPromises.readFile(iconPath);
|
|||
|
|
const ext = path.extname(iconPath).slice(1).toLowerCase();
|
|||
|
|
const resolvedMime = metadataMime || PROJECT_ICON_EXTENSION_TO_MIME[ext] || 'application/octet-stream';
|
|||
|
|
const contentType = resolvedMime === 'image/svg+xml' ? 'image/svg+xml; charset=utf-8' : resolvedMime;
|
|||
|
|
|
|||
|
|
if (resolvedMime === 'image/svg+xml' && requestedThemeVariant) {
|
|||
|
|
const svgMarkup = data.toString('utf8');
|
|||
|
|
const themedSvgMarkup = applyProjectIconSvgTheme(svgMarkup, requestedThemeVariant, requestedIconColor);
|
|||
|
|
res.setHeader('Content-Type', contentType);
|
|||
|
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|||
|
|
return res.send(themedSvgMarkup);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (resolvedMime === 'image/svg+xml' && requestedIconColor) {
|
|||
|
|
const svgMarkup = data.toString('utf8');
|
|||
|
|
const themedSvgMarkup = applyProjectIconSvgTheme(svgMarkup, requestedThemeVariant, requestedIconColor);
|
|||
|
|
res.setHeader('Content-Type', contentType);
|
|||
|
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|||
|
|
return res.send(themedSvgMarkup);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
res.setHeader('Content-Type', contentType);
|
|||
|
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|||
|
|
return res.send(data);
|
|||
|
|
} catch (error) {
|
|||
|
|
if (!error || typeof error !== 'object' || error.code !== 'ENOENT') {
|
|||
|
|
console.warn('Failed to read project icon:', error);
|
|||
|
|
return res.status(500).json({ error: 'Failed to read project icon' });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return res.status(404).json({ error: 'Project icon not found' });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.warn('Failed to load project icon:', error);
|
|||
|
|
return res.status(500).json({ error: 'Failed to load project icon' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.put('/api/projects/:projectId/icon', async (req, res) => {
|
|||
|
|
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : '';
|
|||
|
|
if (!projectId) {
|
|||
|
|
return res.status(400).json({ error: 'projectId is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const parsed = parseProjectIconDataUrl(req.body?.dataUrl);
|
|||
|
|
if (!parsed.ok) {
|
|||
|
|
return res.status(400).json({ error: parsed.error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const settings = await readSettingsFromDiskMigrated();
|
|||
|
|
const { projects, project } = findProjectById(settings, projectId);
|
|||
|
|
if (!project) {
|
|||
|
|
return res.status(404).json({ error: 'Project not found' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const iconPath = projectIconPathForMime(projectId, parsed.mime);
|
|||
|
|
if (!iconPath) {
|
|||
|
|
return res.status(400).json({ error: 'Unsupported icon format' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await fsPromises.mkdir(PROJECT_ICONS_DIR_PATH, { recursive: true });
|
|||
|
|
await fsPromises.writeFile(iconPath, parsed.bytes);
|
|||
|
|
await removeProjectIconFiles(projectId, iconPath);
|
|||
|
|
|
|||
|
|
const updatedAt = Date.now();
|
|||
|
|
const nextProjects = projects.map((entry) => (
|
|||
|
|
entry.id === projectId
|
|||
|
|
? { ...entry, iconImage: { mime: parsed.mime, updatedAt, source: 'custom' } }
|
|||
|
|
: entry
|
|||
|
|
));
|
|||
|
|
const updatedSettings = await persistSettings({ projects: nextProjects });
|
|||
|
|
const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null;
|
|||
|
|
|
|||
|
|
return res.json({ project: updatedProject, settings: updatedSettings });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.warn('Failed to upload project icon:', error);
|
|||
|
|
return res.status(500).json({ error: 'Failed to upload project icon' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.delete('/api/projects/:projectId/icon', async (req, res) => {
|
|||
|
|
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : '';
|
|||
|
|
if (!projectId) {
|
|||
|
|
return res.status(400).json({ error: 'projectId is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const settings = await readSettingsFromDiskMigrated();
|
|||
|
|
const { projects, project } = findProjectById(settings, projectId);
|
|||
|
|
if (!project) {
|
|||
|
|
return res.status(404).json({ error: 'Project not found' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await removeProjectIconFiles(projectId);
|
|||
|
|
|
|||
|
|
const nextProjects = projects.map((entry) => (
|
|||
|
|
entry.id === projectId
|
|||
|
|
? { ...entry, iconImage: null }
|
|||
|
|
: entry
|
|||
|
|
));
|
|||
|
|
const updatedSettings = await persistSettings({ projects: nextProjects });
|
|||
|
|
const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null;
|
|||
|
|
|
|||
|
|
return res.json({ project: updatedProject, settings: updatedSettings });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.warn('Failed to remove project icon:', error);
|
|||
|
|
return res.status(500).json({ error: 'Failed to remove project icon' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/projects/:projectId/icon/discover', async (req, res) => {
|
|||
|
|
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : '';
|
|||
|
|
if (!projectId) {
|
|||
|
|
return res.status(400).json({ error: 'projectId is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const settings = await readSettingsFromDiskMigrated();
|
|||
|
|
const { projects, project } = findProjectById(settings, projectId);
|
|||
|
|
if (!project) {
|
|||
|
|
return res.status(404).json({ error: 'Project not found' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const force = req.body?.force === true;
|
|||
|
|
if (project.iconImage?.source === 'custom' && !force) {
|
|||
|
|
return res.json({
|
|||
|
|
project,
|
|||
|
|
skipped: true,
|
|||
|
|
reason: 'custom-icon-present',
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const faviconCandidates = await searchFilesystemFiles(project.path, {
|
|||
|
|
limit: 200,
|
|||
|
|
query: 'favicon',
|
|||
|
|
includeHidden: true,
|
|||
|
|
respectGitignore: false,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const filtered = faviconCandidates
|
|||
|
|
.filter((entry) => /(^|\/)favicon\.(ico|png|svg|jpg|jpeg|webp)$/i.test(entry.path))
|
|||
|
|
.sort((a, b) => a.path.length - b.path.length);
|
|||
|
|
|
|||
|
|
const selected = filtered[0];
|
|||
|
|
if (!selected) {
|
|||
|
|
return res.status(404).json({ error: 'No favicon found in project' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const ext = path.extname(selected.path).slice(1).toLowerCase();
|
|||
|
|
const mime = PROJECT_ICON_EXTENSION_TO_MIME[ext] || null;
|
|||
|
|
if (!mime) {
|
|||
|
|
return res.status(415).json({ error: 'Unsupported favicon format' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const bytes = await fsPromises.readFile(selected.path);
|
|||
|
|
if (bytes.length === 0) {
|
|||
|
|
return res.status(400).json({ error: 'Discovered icon is empty' });
|
|||
|
|
}
|
|||
|
|
if (bytes.length > PROJECT_ICON_MAX_BYTES) {
|
|||
|
|
return res.status(400).json({ error: 'Discovered icon exceeds size limit (5 MB)' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const iconPath = projectIconPathForMime(projectId, mime);
|
|||
|
|
if (!iconPath) {
|
|||
|
|
return res.status(415).json({ error: 'Unsupported favicon format' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await fsPromises.mkdir(PROJECT_ICONS_DIR_PATH, { recursive: true });
|
|||
|
|
await fsPromises.writeFile(iconPath, bytes);
|
|||
|
|
await removeProjectIconFiles(projectId, iconPath);
|
|||
|
|
|
|||
|
|
const updatedAt = Date.now();
|
|||
|
|
const nextProjects = projects.map((entry) => (
|
|||
|
|
entry.id === projectId
|
|||
|
|
? { ...entry, iconImage: { mime, updatedAt, source: 'auto' } }
|
|||
|
|
: entry
|
|||
|
|
));
|
|||
|
|
const updatedSettings = await persistSettings({ projects: nextProjects });
|
|||
|
|
const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null;
|
|||
|
|
|
|||
|
|
return res.json({
|
|||
|
|
project: updatedProject,
|
|||
|
|
settings: updatedSettings,
|
|||
|
|
discoveredPath: selected.path,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.warn('Failed to discover project icon:', error);
|
|||
|
|
return res.status(500).json({ error: 'Failed to discover project icon' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const {
|
|||
|
|
getAgentSources,
|
|||
|
|
getAgentScope,
|
|||
|
|
getAgentConfig,
|
|||
|
|
createAgent,
|
|||
|
|
updateAgent,
|
|||
|
|
deleteAgent,
|
|||
|
|
getCommandSources,
|
|||
|
|
getCommandScope,
|
|||
|
|
createCommand,
|
|||
|
|
updateCommand,
|
|||
|
|
deleteCommand,
|
|||
|
|
getProviderSources,
|
|||
|
|
removeProviderConfig,
|
|||
|
|
AGENT_SCOPE,
|
|||
|
|
COMMAND_SCOPE,
|
|||
|
|
listMcpConfigs,
|
|||
|
|
getMcpConfig,
|
|||
|
|
createMcpConfig,
|
|||
|
|
updateMcpConfig,
|
|||
|
|
deleteMcpConfig,
|
|||
|
|
} = await import('./lib/opencode/index.js');
|
|||
|
|
|
|||
|
|
app.get('/api/config/agents/:name', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const agentName = req.params.name;
|
|||
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
const sources = getAgentSources(agentName, directory);
|
|||
|
|
|
|||
|
|
const scope = sources.md.exists
|
|||
|
|
? sources.md.scope
|
|||
|
|
: (sources.json.exists ? sources.json.scope : null);
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
name: agentName,
|
|||
|
|
sources: sources,
|
|||
|
|
scope,
|
|||
|
|
isBuiltIn: !sources.md.exists && !sources.json.exists
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to get agent sources:', error);
|
|||
|
|
res.status(500).json({ error: 'Failed to get agent configuration metadata' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/config/agents/:name/config', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const agentName = req.params.name;
|
|||
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const configInfo = getAgentConfig(agentName, directory);
|
|||
|
|
res.json(configInfo);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to get agent config:', error);
|
|||
|
|
res.status(500).json({ error: 'Failed to get agent configuration' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/config/agents/:name', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const agentName = req.params.name;
|
|||
|
|
const { scope, ...config } = req.body;
|
|||
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('[Server] Creating agent:', agentName);
|
|||
|
|
console.log('[Server] Config received:', JSON.stringify(config, null, 2));
|
|||
|
|
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
|||
|
|
|
|||
|
|
createAgent(agentName, config, directory, scope);
|
|||
|
|
await refreshOpenCodeAfterConfigChange('agent creation', {
|
|||
|
|
agentName
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
requiresReload: true,
|
|||
|
|
message: `Agent ${agentName} created successfully. Reloading interface…`,
|
|||
|
|
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to create agent:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to create agent' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.patch('/api/config/agents/:name', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const agentName = req.params.name;
|
|||
|
|
const updates = req.body;
|
|||
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log(`[Server] Updating agent: ${agentName}`);
|
|||
|
|
console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
|
|||
|
|
console.log('[Server] Working directory:', directory);
|
|||
|
|
|
|||
|
|
updateAgent(agentName, updates, directory);
|
|||
|
|
await refreshOpenCodeAfterConfigChange('agent update');
|
|||
|
|
|
|||
|
|
console.log(`[Server] Agent ${agentName} updated successfully`);
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
requiresReload: true,
|
|||
|
|
message: `Agent ${agentName} updated successfully. Reloading interface…`,
|
|||
|
|
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('[Server] Failed to update agent:', error);
|
|||
|
|
console.error('[Server] Error stack:', error.stack);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to update agent' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.delete('/api/config/agents/:name', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const agentName = req.params.name;
|
|||
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
deleteAgent(agentName, directory);
|
|||
|
|
await refreshOpenCodeAfterConfigChange('agent deletion');
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
requiresReload: true,
|
|||
|
|
message: `Agent ${agentName} deleted successfully. Reloading interface…`,
|
|||
|
|
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to delete agent:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to delete agent' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ============================================================
|
|||
|
|
// MCP Config Routes
|
|||
|
|
// ============================================================
|
|||
|
|
|
|||
|
|
app.get('/api/config/mcp', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|||
|
|
if (error) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
const configs = listMcpConfigs(directory);
|
|||
|
|
res.json(configs);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('[API:GET /api/config/mcp] Failed:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to list MCP configs' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/config/mcp/:name', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const name = req.params.name;
|
|||
|
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|||
|
|
if (error) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
const config = getMcpConfig(name, directory);
|
|||
|
|
if (!config) {
|
|||
|
|
return res.status(404).json({ error: `MCP server "${name}" not found` });
|
|||
|
|
}
|
|||
|
|
res.json(config);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('[API:GET /api/config/mcp/:name] Failed:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to get MCP config' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/config/mcp/:name', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const name = req.params.name;
|
|||
|
|
const { scope, ...config } = req.body || {};
|
|||
|
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|||
|
|
if (error) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
console.log(`[API:POST /api/config/mcp] Creating MCP server: ${name}`);
|
|||
|
|
|
|||
|
|
createMcpConfig(name, config, directory, scope);
|
|||
|
|
await refreshOpenCodeAfterConfigChange('mcp creation', { mcpName: name });
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
requiresReload: true,
|
|||
|
|
message: `MCP server "${name}" created. Reloading interface…`,
|
|||
|
|
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('[API:POST /api/config/mcp/:name] Failed:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to create MCP server' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.patch('/api/config/mcp/:name', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const name = req.params.name;
|
|||
|
|
const updates = req.body;
|
|||
|
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|||
|
|
if (error) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
console.log(`[API:PATCH /api/config/mcp] Updating MCP server: ${name}`);
|
|||
|
|
|
|||
|
|
updateMcpConfig(name, updates, directory);
|
|||
|
|
await refreshOpenCodeAfterConfigChange('mcp update');
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
requiresReload: true,
|
|||
|
|
message: `MCP server "${name}" updated. Reloading interface…`,
|
|||
|
|
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('[API:PATCH /api/config/mcp/:name] Failed:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to update MCP server' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.delete('/api/config/mcp/:name', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const name = req.params.name;
|
|||
|
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|||
|
|
if (error) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
console.log(`[API:DELETE /api/config/mcp] Deleting MCP server: ${name}`);
|
|||
|
|
|
|||
|
|
deleteMcpConfig(name, directory);
|
|||
|
|
await refreshOpenCodeAfterConfigChange('mcp deletion');
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
requiresReload: true,
|
|||
|
|
message: `MCP server "${name}" deleted. Reloading interface…`,
|
|||
|
|
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('[API:DELETE /api/config/mcp/:name] Failed:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to delete MCP server' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/config/commands/:name', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const commandName = req.params.name;
|
|||
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
const sources = getCommandSources(commandName, directory);
|
|||
|
|
|
|||
|
|
const scope = sources.md.exists
|
|||
|
|
? sources.md.scope
|
|||
|
|
: (sources.json.exists ? sources.json.scope : null);
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
name: commandName,
|
|||
|
|
sources: sources,
|
|||
|
|
scope,
|
|||
|
|
isBuiltIn: !sources.md.exists && !sources.json.exists
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to get command sources:', error);
|
|||
|
|
res.status(500).json({ error: 'Failed to get command configuration metadata' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/config/commands/:name', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const commandName = req.params.name;
|
|||
|
|
const { scope, ...config } = req.body;
|
|||
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('[Server] Creating command:', commandName);
|
|||
|
|
console.log('[Server] Config received:', JSON.stringify(config, null, 2));
|
|||
|
|
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
|||
|
|
|
|||
|
|
createCommand(commandName, config, directory, scope);
|
|||
|
|
await refreshOpenCodeAfterConfigChange('command creation', {
|
|||
|
|
commandName
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
requiresReload: true,
|
|||
|
|
message: `Command ${commandName} created successfully. Reloading interface…`,
|
|||
|
|
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to create command:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to create command' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.patch('/api/config/commands/:name', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const commandName = req.params.name;
|
|||
|
|
const updates = req.body;
|
|||
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log(`[Server] Updating command: ${commandName}`);
|
|||
|
|
console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
|
|||
|
|
console.log('[Server] Working directory:', directory);
|
|||
|
|
|
|||
|
|
updateCommand(commandName, updates, directory);
|
|||
|
|
await refreshOpenCodeAfterConfigChange('command update');
|
|||
|
|
|
|||
|
|
console.log(`[Server] Command ${commandName} updated successfully`);
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
requiresReload: true,
|
|||
|
|
message: `Command ${commandName} updated successfully. Reloading interface…`,
|
|||
|
|
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('[Server] Failed to update command:', error);
|
|||
|
|
console.error('[Server] Error stack:', error.stack);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to update command' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.delete('/api/config/commands/:name', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const commandName = req.params.name;
|
|||
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
deleteCommand(commandName, directory);
|
|||
|
|
await refreshOpenCodeAfterConfigChange('command deletion');
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
requiresReload: true,
|
|||
|
|
message: `Command ${commandName} deleted successfully. Reloading interface…`,
|
|||
|
|
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to delete command:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to delete command' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ============== SKILL ENDPOINTS ==============
|
|||
|
|
|
|||
|
|
const {
|
|||
|
|
getSkillSources,
|
|||
|
|
discoverSkills,
|
|||
|
|
createSkill,
|
|||
|
|
updateSkill,
|
|||
|
|
deleteSkill,
|
|||
|
|
readSkillSupportingFile,
|
|||
|
|
writeSkillSupportingFile,
|
|||
|
|
deleteSkillSupportingFile,
|
|||
|
|
SKILL_SCOPE,
|
|||
|
|
SKILL_DIR,
|
|||
|
|
} = await import('./lib/opencode/index.js');
|
|||
|
|
|
|||
|
|
const findWorktreeRootForSkills = (workingDirectory) => {
|
|||
|
|
if (!workingDirectory) return null;
|
|||
|
|
let current = path.resolve(workingDirectory);
|
|||
|
|
while (true) {
|
|||
|
|
if (fs.existsSync(path.join(current, '.git'))) {
|
|||
|
|
return current;
|
|||
|
|
}
|
|||
|
|
const parent = path.dirname(current);
|
|||
|
|
if (parent === current) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
current = parent;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getSkillProjectAncestors = (workingDirectory) => {
|
|||
|
|
if (!workingDirectory) return [];
|
|||
|
|
const result = [];
|
|||
|
|
let current = path.resolve(workingDirectory);
|
|||
|
|
const stop = findWorktreeRootForSkills(workingDirectory) || current;
|
|||
|
|
while (true) {
|
|||
|
|
result.push(current);
|
|||
|
|
if (current === stop) break;
|
|||
|
|
const parent = path.dirname(current);
|
|||
|
|
if (parent === current) break;
|
|||
|
|
current = parent;
|
|||
|
|
}
|
|||
|
|
return result;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const isPathInside = (candidatePath, parentPath) => {
|
|||
|
|
if (!candidatePath || !parentPath) return false;
|
|||
|
|
const normalizedCandidate = path.resolve(candidatePath);
|
|||
|
|
const normalizedParent = path.resolve(parentPath);
|
|||
|
|
return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(`${normalizedParent}${path.sep}`);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const inferSkillScopeAndSourceFromPath = (skillPath, workingDirectory) => {
|
|||
|
|
const resolvedPath = typeof skillPath === 'string' ? path.resolve(skillPath) : '';
|
|||
|
|
const home = os.homedir();
|
|||
|
|
const source = resolvedPath.includes(`${path.sep}.agents${path.sep}skills${path.sep}`)
|
|||
|
|
? 'agents'
|
|||
|
|
: resolvedPath.includes(`${path.sep}.claude${path.sep}skills${path.sep}`)
|
|||
|
|
? 'claude'
|
|||
|
|
: 'opencode';
|
|||
|
|
|
|||
|
|
const projectAncestors = getSkillProjectAncestors(workingDirectory);
|
|||
|
|
const isProjectScoped = projectAncestors.some((ancestor) => {
|
|||
|
|
const candidates = [
|
|||
|
|
path.join(ancestor, '.opencode'),
|
|||
|
|
path.join(ancestor, '.claude', 'skills'),
|
|||
|
|
path.join(ancestor, '.agents', 'skills'),
|
|||
|
|
];
|
|||
|
|
return candidates.some((candidate) => isPathInside(resolvedPath, candidate));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (isProjectScoped) {
|
|||
|
|
return { scope: SKILL_SCOPE.PROJECT, source };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const userRoots = [
|
|||
|
|
path.join(home, '.config', 'opencode'),
|
|||
|
|
path.join(home, '.opencode'),
|
|||
|
|
path.join(home, '.claude', 'skills'),
|
|||
|
|
path.join(home, '.agents', 'skills'),
|
|||
|
|
process.env.OPENCODE_CONFIG_DIR ? path.resolve(process.env.OPENCODE_CONFIG_DIR) : null,
|
|||
|
|
].filter(Boolean);
|
|||
|
|
|
|||
|
|
if (userRoots.some((root) => isPathInside(resolvedPath, root))) {
|
|||
|
|
return { scope: SKILL_SCOPE.USER, source };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { scope: SKILL_SCOPE.USER, source };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const fetchOpenCodeDiscoveredSkills = async (workingDirectory) => {
|
|||
|
|
if (!openCodePort) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const url = new URL(buildOpenCodeUrl('/skill', ''));
|
|||
|
|
if (workingDirectory) {
|
|||
|
|
url.searchParams.set('directory', workingDirectory);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const response = await fetch(url.toString(), {
|
|||
|
|
method: 'GET',
|
|||
|
|
headers: {
|
|||
|
|
Accept: 'application/json',
|
|||
|
|
...getOpenCodeAuthHeaders(),
|
|||
|
|
},
|
|||
|
|
signal: AbortSignal.timeout(8_000),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const payload = await response.json();
|
|||
|
|
if (!Array.isArray(payload)) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return payload
|
|||
|
|
.map((item) => {
|
|||
|
|
const name = typeof item?.name === 'string' ? item.name.trim() : '';
|
|||
|
|
const location = typeof item?.location === 'string' ? item.location : '';
|
|||
|
|
const description = typeof item?.description === 'string' ? item.description : '';
|
|||
|
|
if (!name || !location) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
const inferred = inferSkillScopeAndSourceFromPath(location, workingDirectory);
|
|||
|
|
return {
|
|||
|
|
name,
|
|||
|
|
path: location,
|
|||
|
|
scope: inferred.scope,
|
|||
|
|
source: inferred.source,
|
|||
|
|
description,
|
|||
|
|
};
|
|||
|
|
})
|
|||
|
|
.filter(Boolean);
|
|||
|
|
} catch {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// List all discovered skills
|
|||
|
|
app.get('/api/config/skills', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
const skills = (await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory);
|
|||
|
|
|
|||
|
|
// Enrich with full sources info
|
|||
|
|
const enrichedSkills = skills.map(skill => {
|
|||
|
|
const sources = getSkillSources(skill.name, directory, skill);
|
|||
|
|
return {
|
|||
|
|
...skill,
|
|||
|
|
sources
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
res.json({ skills: enrichedSkills });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to list skills:', error);
|
|||
|
|
res.status(500).json({ error: 'Failed to list skills' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ============== SKILLS CATALOG + INSTALL ENDPOINTS ==============
|
|||
|
|
|
|||
|
|
const {
|
|||
|
|
getCuratedSkillsSources,
|
|||
|
|
getCacheKey,
|
|||
|
|
getCachedScan,
|
|||
|
|
setCachedScan,
|
|||
|
|
parseSkillRepoSource,
|
|||
|
|
scanSkillsRepository,
|
|||
|
|
installSkillsFromRepository,
|
|||
|
|
scanClawdHubPage,
|
|||
|
|
installSkillsFromClawdHub,
|
|||
|
|
isClawdHubSource,
|
|||
|
|
} = await import('./lib/skills-catalog/index.js');
|
|||
|
|
const { getProfiles, getProfile } = await import('./lib/git/index.js');
|
|||
|
|
|
|||
|
|
const listGitIdentitiesForResponse = () => {
|
|||
|
|
try {
|
|||
|
|
const profiles = getProfiles();
|
|||
|
|
return profiles.map((p) => ({ id: p.id, name: p.name }));
|
|||
|
|
} catch {
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const resolveGitIdentity = (profileId) => {
|
|||
|
|
if (!profileId) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
const profile = getProfile(profileId);
|
|||
|
|
const sshKey = profile?.sshKey;
|
|||
|
|
if (typeof sshKey === 'string' && sshKey.trim()) {
|
|||
|
|
return { sshKey: sshKey.trim() };
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
app.get('/api/config/skills/catalog', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { error } = await resolveOptionalProjectDirectory(req);
|
|||
|
|
if (error) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const curatedSources = getCuratedSkillsSources();
|
|||
|
|
const settings = await readSettingsFromDisk();
|
|||
|
|
const customSourcesRaw = sanitizeSkillCatalogs(settings.skillCatalogs) || [];
|
|||
|
|
|
|||
|
|
const customSources = customSourcesRaw.map((entry) => ({
|
|||
|
|
id: entry.id,
|
|||
|
|
label: entry.label,
|
|||
|
|
description: entry.source,
|
|||
|
|
source: entry.source,
|
|||
|
|
defaultSubpath: entry.subpath,
|
|||
|
|
gitIdentityId: entry.gitIdentityId,
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
const sources = [...curatedSources, ...customSources];
|
|||
|
|
const sourcesForUi = sources.map(({ gitIdentityId, ...rest }) => rest);
|
|||
|
|
|
|||
|
|
res.json({ ok: true, sources: sourcesForUi, itemsBySource: {}, pageInfoBySource: {} });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to load skills catalog:', error);
|
|||
|
|
res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to load catalog' } });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/config/skills/catalog/source', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
|||
|
|
if (error) {
|
|||
|
|
return res.status(400).json({ ok: false, error: { kind: 'invalidSource', message: error } });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const sourceId = typeof req.query.sourceId === 'string' ? req.query.sourceId : null;
|
|||
|
|
if (!sourceId) {
|
|||
|
|
return res.status(400).json({ ok: false, error: { kind: 'invalidSource', message: 'Missing sourceId' } });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const refresh = String(req.query.refresh || '').toLowerCase() === 'true';
|
|||
|
|
const cursor = typeof req.query.cursor === 'string' ? req.query.cursor : null;
|
|||
|
|
|
|||
|
|
const curatedSources = getCuratedSkillsSources();
|
|||
|
|
const settings = await readSettingsFromDisk();
|
|||
|
|
const customSourcesRaw = sanitizeSkillCatalogs(settings.skillCatalogs) || [];
|
|||
|
|
|
|||
|
|
const customSources = customSourcesRaw.map((entry) => ({
|
|||
|
|
id: entry.id,
|
|||
|
|
label: entry.label,
|
|||
|
|
description: entry.source,
|
|||
|
|
source: entry.source,
|
|||
|
|
defaultSubpath: entry.subpath,
|
|||
|
|
gitIdentityId: entry.gitIdentityId,
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
const sources = [...curatedSources, ...customSources];
|
|||
|
|
const src = sources.find((entry) => entry.id === sourceId);
|
|||
|
|
|
|||
|
|
if (!src) {
|
|||
|
|
return res.status(404).json({ ok: false, error: { kind: 'invalidSource', message: 'Unknown source' } });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const discovered = directory
|
|||
|
|
? ((await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory))
|
|||
|
|
: [];
|
|||
|
|
const installedByName = new Map(discovered.map((s) => [s.name, s]));
|
|||
|
|
|
|||
|
|
if (src.sourceType === 'clawdhub' || isClawdHubSource(src.source)) {
|
|||
|
|
const scanned = await scanClawdHubPage({ cursor: cursor || null });
|
|||
|
|
if (!scanned.ok) {
|
|||
|
|
return res.status(500).json({ ok: false, error: scanned.error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const items = (scanned.items || []).map((item) => {
|
|||
|
|
const installed = installedByName.get(item.skillName);
|
|||
|
|
return {
|
|||
|
|
...item,
|
|||
|
|
sourceId: src.id,
|
|||
|
|
installed: installed
|
|||
|
|
? { isInstalled: true, scope: installed.scope, source: installed.source }
|
|||
|
|
: { isInstalled: false },
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return res.json({ ok: true, items, nextCursor: scanned.nextCursor || null });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const parsed = parseSkillRepoSource(src.source);
|
|||
|
|
if (!parsed.ok) {
|
|||
|
|
return res.status(400).json({ ok: false, error: parsed.error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const effectiveSubpath = src.defaultSubpath || parsed.effectiveSubpath || null;
|
|||
|
|
const cacheKey = getCacheKey({
|
|||
|
|
normalizedRepo: parsed.normalizedRepo,
|
|||
|
|
subpath: effectiveSubpath || '',
|
|||
|
|
identityId: src.gitIdentityId || '',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
let scanResult = !refresh ? getCachedScan(cacheKey) : null;
|
|||
|
|
if (!scanResult) {
|
|||
|
|
const scanned = await scanSkillsRepository({
|
|||
|
|
source: src.source,
|
|||
|
|
subpath: src.defaultSubpath,
|
|||
|
|
defaultSubpath: src.defaultSubpath,
|
|||
|
|
identity: resolveGitIdentity(src.gitIdentityId),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!scanned.ok) {
|
|||
|
|
return res.status(500).json({ ok: false, error: scanned.error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
scanResult = scanned;
|
|||
|
|
setCachedScan(cacheKey, scanResult);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const items = (scanResult.items || []).map((item) => {
|
|||
|
|
const installed = installedByName.get(item.skillName);
|
|||
|
|
return {
|
|||
|
|
sourceId: src.id,
|
|||
|
|
...item,
|
|||
|
|
gitIdentityId: src.gitIdentityId,
|
|||
|
|
installed: installed
|
|||
|
|
? { isInstalled: true, scope: installed.scope, source: installed.source }
|
|||
|
|
: { isInstalled: false },
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return res.json({ ok: true, items });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to load catalog source:', error);
|
|||
|
|
return res.status(500).json({
|
|||
|
|
ok: false,
|
|||
|
|
error: { kind: 'unknown', message: error.message || 'Failed to load catalog source' },
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/config/skills/scan', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { source, subpath, gitIdentityId } = req.body || {};
|
|||
|
|
const identity = resolveGitIdentity(gitIdentityId);
|
|||
|
|
|
|||
|
|
const result = await scanSkillsRepository({
|
|||
|
|
source,
|
|||
|
|
subpath,
|
|||
|
|
identity,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!result.ok) {
|
|||
|
|
if (result.error?.kind === 'authRequired') {
|
|||
|
|
return res.status(401).json({
|
|||
|
|
ok: false,
|
|||
|
|
error: {
|
|||
|
|
...result.error,
|
|||
|
|
identities: listGitIdentitiesForResponse(),
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return res.status(400).json({ ok: false, error: result.error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
res.json({ ok: true, items: result.items });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to scan skills repository:', error);
|
|||
|
|
res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to scan repository' } });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/config/skills/install', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const {
|
|||
|
|
source,
|
|||
|
|
subpath,
|
|||
|
|
gitIdentityId,
|
|||
|
|
scope,
|
|||
|
|
targetSource,
|
|||
|
|
selections,
|
|||
|
|
conflictPolicy,
|
|||
|
|
conflictDecisions,
|
|||
|
|
} = req.body || {};
|
|||
|
|
|
|||
|
|
let workingDirectory = null;
|
|||
|
|
if (scope === 'project') {
|
|||
|
|
const resolved = await resolveProjectDirectory(req);
|
|||
|
|
if (!resolved.directory) {
|
|||
|
|
return res.status(400).json({
|
|||
|
|
ok: false,
|
|||
|
|
error: { kind: 'invalidSource', message: resolved.error || 'Project installs require a directory parameter' },
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
workingDirectory = resolved.directory;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Handle ClawdHub sources (ZIP download based)
|
|||
|
|
if (isClawdHubSource(source)) {
|
|||
|
|
const result = await installSkillsFromClawdHub({
|
|||
|
|
scope,
|
|||
|
|
targetSource,
|
|||
|
|
workingDirectory,
|
|||
|
|
userSkillDir: SKILL_DIR,
|
|||
|
|
selections,
|
|||
|
|
conflictPolicy,
|
|||
|
|
conflictDecisions,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!result.ok) {
|
|||
|
|
if (result.error?.kind === 'conflicts') {
|
|||
|
|
return res.status(409).json({ ok: false, error: result.error });
|
|||
|
|
}
|
|||
|
|
return res.status(400).json({ ok: false, error: result.error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const installed = result.installed || [];
|
|||
|
|
const skipped = result.skipped || [];
|
|||
|
|
const requiresReload = installed.length > 0;
|
|||
|
|
|
|||
|
|
if (requiresReload) {
|
|||
|
|
await refreshOpenCodeAfterConfigChange('skills install');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return res.json({
|
|||
|
|
ok: true,
|
|||
|
|
installed,
|
|||
|
|
skipped,
|
|||
|
|
requiresReload,
|
|||
|
|
message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed',
|
|||
|
|
reloadDelayMs: requiresReload ? CLIENT_RELOAD_DELAY_MS : undefined,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Handle GitHub sources (git clone based)
|
|||
|
|
const identity = resolveGitIdentity(gitIdentityId);
|
|||
|
|
|
|||
|
|
const result = await installSkillsFromRepository({
|
|||
|
|
source,
|
|||
|
|
subpath,
|
|||
|
|
identity,
|
|||
|
|
scope,
|
|||
|
|
targetSource,
|
|||
|
|
workingDirectory,
|
|||
|
|
userSkillDir: SKILL_DIR,
|
|||
|
|
selections,
|
|||
|
|
conflictPolicy,
|
|||
|
|
conflictDecisions,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!result.ok) {
|
|||
|
|
if (result.error?.kind === 'conflicts') {
|
|||
|
|
return res.status(409).json({ ok: false, error: result.error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (result.error?.kind === 'authRequired') {
|
|||
|
|
return res.status(401).json({
|
|||
|
|
ok: false,
|
|||
|
|
error: {
|
|||
|
|
...result.error,
|
|||
|
|
identities: listGitIdentitiesForResponse(),
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return res.status(400).json({ ok: false, error: result.error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const installed = result.installed || [];
|
|||
|
|
const skipped = result.skipped || [];
|
|||
|
|
const requiresReload = installed.length > 0;
|
|||
|
|
|
|||
|
|
if (requiresReload) {
|
|||
|
|
await refreshOpenCodeAfterConfigChange('skills install');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
ok: true,
|
|||
|
|
installed,
|
|||
|
|
skipped,
|
|||
|
|
requiresReload,
|
|||
|
|
message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed',
|
|||
|
|
reloadDelayMs: requiresReload ? CLIENT_RELOAD_DELAY_MS : undefined,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to install skills:', error);
|
|||
|
|
res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to install skills' } });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Get single skill sources
|
|||
|
|
app.get('/api/config/skills/:name', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const skillName = req.params.name;
|
|||
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
|
|||
|
|
.find((skill) => skill.name === skillName) || null;
|
|||
|
|
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
name: skillName,
|
|||
|
|
sources: sources,
|
|||
|
|
scope: sources.md.scope,
|
|||
|
|
source: sources.md.source,
|
|||
|
|
exists: sources.md.exists
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to get skill sources:', error);
|
|||
|
|
res.status(500).json({ error: 'Failed to get skill configuration metadata' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Get skill supporting file content
|
|||
|
|
app.get('/api/config/skills/:name/files/*filePath', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const skillName = req.params.name;
|
|||
|
|
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
|
|||
|
|
if (isUnsafeSkillRelativePath(filePath)) {
|
|||
|
|
return res.status(400).json({ error: 'Invalid file path' });
|
|||
|
|
}
|
|||
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
|
|||
|
|
.find((skill) => skill.name === skillName) || null;
|
|||
|
|
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
|||
|
|
if (!sources.md.exists || !sources.md.dir) {
|
|||
|
|
return res.status(404).json({ error: 'Skill not found' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const content = readSkillSupportingFile(sources.md.dir, filePath);
|
|||
|
|
if (content === null) {
|
|||
|
|
return res.status(404).json({ error: 'File not found' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
res.json({ path: filePath, content });
|
|||
|
|
} catch (error) {
|
|||
|
|
if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) {
|
|||
|
|
return res.status(403).json({ error: 'Access to file denied' });
|
|||
|
|
}
|
|||
|
|
console.error('Failed to read skill file:', error);
|
|||
|
|
res.status(500).json({ error: 'Failed to read skill file' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Create new skill
|
|||
|
|
app.post('/api/config/skills/:name', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const skillName = req.params.name;
|
|||
|
|
const { scope, source: skillSource, ...config } = req.body;
|
|||
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('[Server] Creating skill:', skillName);
|
|||
|
|
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
|||
|
|
|
|||
|
|
createSkill(skillName, { ...config, source: skillSource }, directory, scope);
|
|||
|
|
await refreshOpenCodeAfterConfigChange('skill creation');
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
requiresReload: true,
|
|||
|
|
message: `Skill ${skillName} created successfully. Reloading interface…`,
|
|||
|
|
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to create skill:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to create skill' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Update existing skill
|
|||
|
|
app.patch('/api/config/skills/:name', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const skillName = req.params.name;
|
|||
|
|
const updates = req.body;
|
|||
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log(`[Server] Updating skill: ${skillName}`);
|
|||
|
|
console.log('[Server] Working directory:', directory);
|
|||
|
|
|
|||
|
|
updateSkill(skillName, updates, directory);
|
|||
|
|
await refreshOpenCodeAfterConfigChange('skill update');
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
requiresReload: true,
|
|||
|
|
message: `Skill ${skillName} updated successfully. Reloading interface…`,
|
|||
|
|
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('[Server] Failed to update skill:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to update skill' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Update/create supporting file
|
|||
|
|
app.put('/api/config/skills/:name/files/*filePath', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const skillName = req.params.name;
|
|||
|
|
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
|
|||
|
|
if (isUnsafeSkillRelativePath(filePath)) {
|
|||
|
|
return res.status(400).json({ error: 'Invalid file path' });
|
|||
|
|
}
|
|||
|
|
const { content } = req.body;
|
|||
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
|
|||
|
|
.find((skill) => skill.name === skillName) || null;
|
|||
|
|
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
|||
|
|
if (!sources.md.exists || !sources.md.dir) {
|
|||
|
|
return res.status(404).json({ error: 'Skill not found' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
writeSkillSupportingFile(sources.md.dir, filePath, content || '');
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
message: `File ${filePath} saved successfully`,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) {
|
|||
|
|
return res.status(403).json({ error: 'Access to file denied' });
|
|||
|
|
}
|
|||
|
|
console.error('Failed to write skill file:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to write skill file' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Delete supporting file
|
|||
|
|
app.delete('/api/config/skills/:name/files/*filePath', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const skillName = req.params.name;
|
|||
|
|
const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path
|
|||
|
|
if (isUnsafeSkillRelativePath(filePath)) {
|
|||
|
|
return res.status(400).json({ error: 'Invalid file path' });
|
|||
|
|
}
|
|||
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
|
|||
|
|
.find((skill) => skill.name === skillName) || null;
|
|||
|
|
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
|||
|
|
if (!sources.md.exists || !sources.md.dir) {
|
|||
|
|
return res.status(404).json({ error: 'Skill not found' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
deleteSkillSupportingFile(sources.md.dir, filePath);
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
message: `File ${filePath} deleted successfully`,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) {
|
|||
|
|
return res.status(403).json({ error: 'Access to file denied' });
|
|||
|
|
}
|
|||
|
|
console.error('Failed to delete skill file:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to delete skill file' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Delete skill
|
|||
|
|
app.delete('/api/config/skills/:name', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const skillName = req.params.name;
|
|||
|
|
const { directory, error } = await resolveProjectDirectory(req);
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
deleteSkill(skillName, directory);
|
|||
|
|
await refreshOpenCodeAfterConfigChange('skill deletion');
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
requiresReload: true,
|
|||
|
|
message: `Skill ${skillName} deleted successfully. Reloading interface…`,
|
|||
|
|
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to delete skill:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to delete skill' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/config/reload', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
console.log('[Server] Manual configuration reload requested');
|
|||
|
|
|
|||
|
|
await refreshOpenCodeAfterConfigChange('manual configuration reload');
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
requiresReload: true,
|
|||
|
|
message: 'Configuration reloaded successfully. Refreshing interface…',
|
|||
|
|
reloadDelayMs: CLIENT_RELOAD_DELAY_MS,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('[Server] Failed to reload configuration:', error);
|
|||
|
|
res.status(500).json({
|
|||
|
|
error: error.message || 'Failed to reload configuration',
|
|||
|
|
success: false
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
let authLibrary = null;
|
|||
|
|
const getAuthLibrary = async () => {
|
|||
|
|
if (!authLibrary) {
|
|||
|
|
authLibrary = await import('./lib/opencode/auth.js');
|
|||
|
|
}
|
|||
|
|
return authLibrary;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
let quotaProviders = null;
|
|||
|
|
const getQuotaProviders = async () => {
|
|||
|
|
if (!quotaProviders) {
|
|||
|
|
quotaProviders = await import('./lib/quota/index.js');
|
|||
|
|
}
|
|||
|
|
return quotaProviders;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ================= GitHub OAuth (Device Flow) =================
|
|||
|
|
|
|||
|
|
// Note: scopes may be overridden via OPENCHAMBER_GITHUB_SCOPES or settings.json (see lib/github/auth.js).
|
|||
|
|
|
|||
|
|
let githubLibraries = null;
|
|||
|
|
const getGitHubLibraries = async () => {
|
|||
|
|
if (!githubLibraries) {
|
|||
|
|
githubLibraries = await import('./lib/github/index.js');
|
|||
|
|
}
|
|||
|
|
return githubLibraries;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getGitHubUserSummary = async (octokit) => {
|
|||
|
|
const me = await octokit.rest.users.getAuthenticated();
|
|||
|
|
|
|||
|
|
let email = typeof me.data.email === 'string' ? me.data.email : null;
|
|||
|
|
if (!email) {
|
|||
|
|
try {
|
|||
|
|
const emails = await octokit.rest.users.listEmailsForAuthenticatedUser({ per_page: 100 });
|
|||
|
|
const list = Array.isArray(emails?.data) ? emails.data : [];
|
|||
|
|
const primaryVerified = list.find((e) => e && e.primary && e.verified && typeof e.email === 'string');
|
|||
|
|
const anyVerified = list.find((e) => e && e.verified && typeof e.email === 'string');
|
|||
|
|
email = primaryVerified?.email || anyVerified?.email || null;
|
|||
|
|
} catch {
|
|||
|
|
// ignore (scope might be missing)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
login: me.data.login,
|
|||
|
|
id: me.data.id,
|
|||
|
|
avatarUrl: me.data.avatar_url,
|
|||
|
|
name: typeof me.data.name === 'string' ? me.data.name : null,
|
|||
|
|
email,
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const isGitHubAuthInvalid = (error) => error?.status === 401 || error?.status === 403;
|
|||
|
|
const isGitHubResourceUnavailable = (error) => error?.status === 403 || error?.status === 404;
|
|||
|
|
|
|||
|
|
app.get('/api/github/auth/status', async (_req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries();
|
|||
|
|
const auth = getGitHubAuth();
|
|||
|
|
const accounts = getGitHubAuthAccounts();
|
|||
|
|
if (!auth?.accessToken) {
|
|||
|
|
return res.json({ connected: false, accounts });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const octokit = getOctokitOrNull();
|
|||
|
|
if (!octokit) {
|
|||
|
|
return res.json({ connected: false, accounts });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let user = null;
|
|||
|
|
try {
|
|||
|
|
user = await getGitHubUserSummary(octokit);
|
|||
|
|
} catch (error) {
|
|||
|
|
if (isGitHubAuthInvalid(error)) {
|
|||
|
|
clearGitHubAuth();
|
|||
|
|
return res.json({ connected: false, accounts: getGitHubAuthAccounts() });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const fallback = auth.user;
|
|||
|
|
const mergedUser = user || fallback;
|
|||
|
|
|
|||
|
|
return res.json({
|
|||
|
|
connected: true,
|
|||
|
|
user: mergedUser,
|
|||
|
|
scope: auth.scope,
|
|||
|
|
accounts,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to get GitHub auth status:', error);
|
|||
|
|
return res.status(500).json({ error: error.message || 'Failed to get GitHub auth status' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/github/auth/start', async (_req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { getGitHubClientId, getGitHubScopes, startDeviceFlow } = await getGitHubLibraries();
|
|||
|
|
const clientId = getGitHubClientId();
|
|||
|
|
if (!clientId) {
|
|||
|
|
return res.status(400).json({
|
|||
|
|
error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.',
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const scope = getGitHubScopes();
|
|||
|
|
|
|||
|
|
const payload = await startDeviceFlow({
|
|||
|
|
clientId,
|
|||
|
|
scope,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return res.json({
|
|||
|
|
deviceCode: payload.device_code,
|
|||
|
|
userCode: payload.user_code,
|
|||
|
|
verificationUri: payload.verification_uri,
|
|||
|
|
verificationUriComplete: payload.verification_uri_complete,
|
|||
|
|
expiresIn: payload.expires_in,
|
|||
|
|
interval: payload.interval,
|
|||
|
|
scope,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to start GitHub device flow:', error);
|
|||
|
|
return res.status(500).json({ error: error.message || 'Failed to start GitHub device flow' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/github/auth/complete', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries();
|
|||
|
|
const clientId = getGitHubClientId();
|
|||
|
|
if (!clientId) {
|
|||
|
|
return res.status(400).json({
|
|||
|
|
error: 'GitHub OAuth client not configured. Set OPENCHAMBER_GITHUB_CLIENT_ID.',
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const deviceCode = typeof req.body?.deviceCode === 'string'
|
|||
|
|
? req.body.deviceCode
|
|||
|
|
: (typeof req.body?.device_code === 'string' ? req.body.device_code : '');
|
|||
|
|
|
|||
|
|
if (!deviceCode) {
|
|||
|
|
return res.status(400).json({ error: 'deviceCode is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const payload = await exchangeDeviceCode({ clientId, deviceCode });
|
|||
|
|
|
|||
|
|
if (payload?.error) {
|
|||
|
|
return res.json({
|
|||
|
|
connected: false,
|
|||
|
|
status: payload.error,
|
|||
|
|
error: payload.error_description || payload.error,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const accessToken = payload?.access_token;
|
|||
|
|
if (!accessToken) {
|
|||
|
|
return res.status(500).json({ error: 'Missing access_token from GitHub' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { Octokit } = await import('@octokit/rest');
|
|||
|
|
const octokit = new Octokit({ auth: accessToken });
|
|||
|
|
const user = await getGitHubUserSummary(octokit);
|
|||
|
|
|
|||
|
|
setGitHubAuth({
|
|||
|
|
accessToken,
|
|||
|
|
scope: typeof payload.scope === 'string' ? payload.scope : '',
|
|||
|
|
tokenType: typeof payload.token_type === 'string' ? payload.token_type : 'bearer',
|
|||
|
|
user,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return res.json({
|
|||
|
|
connected: true,
|
|||
|
|
user,
|
|||
|
|
scope: typeof payload.scope === 'string' ? payload.scope : '',
|
|||
|
|
accounts: getGitHubAuthAccounts(),
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to complete GitHub device flow:', error);
|
|||
|
|
return res.status(500).json({ error: error.message || 'Failed to complete GitHub device flow' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/github/auth/activate', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { activateGitHubAuth, getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries();
|
|||
|
|
const accountId = typeof req.body?.accountId === 'string' ? req.body.accountId : '';
|
|||
|
|
if (!accountId) {
|
|||
|
|
return res.status(400).json({ error: 'accountId is required' });
|
|||
|
|
}
|
|||
|
|
const activated = activateGitHubAuth(accountId);
|
|||
|
|
if (!activated) {
|
|||
|
|
return res.status(404).json({ error: 'GitHub account not found' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const auth = getGitHubAuth();
|
|||
|
|
const accounts = getGitHubAuthAccounts();
|
|||
|
|
if (!auth?.accessToken) {
|
|||
|
|
return res.json({ connected: false, accounts });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const octokit = getOctokitOrNull();
|
|||
|
|
if (!octokit) {
|
|||
|
|
return res.json({ connected: false, accounts });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let user = auth.user || null;
|
|||
|
|
try {
|
|||
|
|
user = await getGitHubUserSummary(octokit);
|
|||
|
|
} catch (error) {
|
|||
|
|
if (isGitHubAuthInvalid(error)) {
|
|||
|
|
clearGitHubAuth();
|
|||
|
|
return res.json({ connected: false, accounts: getGitHubAuthAccounts() });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return res.json({
|
|||
|
|
connected: true,
|
|||
|
|
user,
|
|||
|
|
scope: auth.scope,
|
|||
|
|
accounts,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to activate GitHub account:', error);
|
|||
|
|
return res.status(500).json({ error: error.message || 'Failed to activate GitHub account' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.delete('/api/github/auth', async (_req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { clearGitHubAuth } = await getGitHubLibraries();
|
|||
|
|
const removed = clearGitHubAuth();
|
|||
|
|
return res.json({ success: true, removed });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to disconnect GitHub:', error);
|
|||
|
|
return res.status(500).json({ error: error.message || 'Failed to disconnect GitHub' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/github/me', async (_req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { getOctokitOrNull, clearGitHubAuth } = await getGitHubLibraries();
|
|||
|
|
const octokit = getOctokitOrNull();
|
|||
|
|
if (!octokit) {
|
|||
|
|
return res.status(401).json({ error: 'GitHub not connected' });
|
|||
|
|
}
|
|||
|
|
let user;
|
|||
|
|
try {
|
|||
|
|
user = await getGitHubUserSummary(octokit);
|
|||
|
|
} catch (error) {
|
|||
|
|
if (isGitHubAuthInvalid(error)) {
|
|||
|
|
clearGitHubAuth();
|
|||
|
|
return res.status(401).json({ error: 'GitHub token expired or revoked' });
|
|||
|
|
}
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
return res.json(user);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to fetch GitHub user:', error);
|
|||
|
|
return res.status(500).json({ error: error.message || 'Failed to fetch GitHub user' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ================= GitHub PR APIs =================
|
|||
|
|
|
|||
|
|
app.get('/api/github/pr/status', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
|||
|
|
const branch = typeof req.query?.branch === 'string' ? req.query.branch.trim() : '';
|
|||
|
|
const remote = typeof req.query?.remote === 'string' ? req.query.remote.trim() : 'origin';
|
|||
|
|
if (!directory || !branch) {
|
|||
|
|
return res.status(400).json({ error: 'directory and branch are required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { getOctokitOrNull, getGitHubAuth } = await getGitHubLibraries();
|
|||
|
|
const octokit = getOctokitOrNull();
|
|||
|
|
if (!octokit) {
|
|||
|
|
return res.json({ connected: false });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { resolveGitHubPrStatus } = await import('./lib/github/pr-status.js');
|
|||
|
|
const resolvedStatus = await resolveGitHubPrStatus({
|
|||
|
|
octokit,
|
|||
|
|
directory,
|
|||
|
|
branch,
|
|||
|
|
remoteName: remote,
|
|||
|
|
});
|
|||
|
|
const searchRepo = resolvedStatus.repo;
|
|||
|
|
const first = resolvedStatus.pr;
|
|||
|
|
if (!searchRepo) {
|
|||
|
|
return res.json({ connected: true, repo: null, branch, pr: null, checks: null, canMerge: false, defaultBranch: null, resolvedRemoteName: null });
|
|||
|
|
}
|
|||
|
|
if (!first) {
|
|||
|
|
return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false, defaultBranch: resolvedStatus.defaultBranch ?? null, resolvedRemoteName: resolvedStatus.resolvedRemoteName ?? null });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Enrich with mergeability fields
|
|||
|
|
const prFull = await octokit.rest.pulls.get({ owner: searchRepo.owner, repo: searchRepo.repo, pull_number: first.number });
|
|||
|
|
const prData = prFull?.data;
|
|||
|
|
if (!prData) {
|
|||
|
|
return res.json({ connected: true, repo: searchRepo, branch, pr: null, checks: null, canMerge: false });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Checks summary: prefer check-runs (Actions), fallback to classic statuses.
|
|||
|
|
let checks = null;
|
|||
|
|
const sha = prData.head?.sha;
|
|||
|
|
if (sha) {
|
|||
|
|
try {
|
|||
|
|
const runs = await octokit.rest.checks.listForRef({
|
|||
|
|
owner: searchRepo.owner,
|
|||
|
|
repo: searchRepo.repo,
|
|||
|
|
ref: sha,
|
|||
|
|
per_page: 100,
|
|||
|
|
});
|
|||
|
|
const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : [];
|
|||
|
|
if (checkRuns.length > 0) {
|
|||
|
|
const counts = { success: 0, failure: 0, pending: 0 };
|
|||
|
|
for (const run of checkRuns) {
|
|||
|
|
const status = run?.status;
|
|||
|
|
const conclusion = run?.conclusion;
|
|||
|
|
if (status === 'queued' || status === 'in_progress') {
|
|||
|
|
counts.pending += 1;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
if (!conclusion) {
|
|||
|
|
counts.pending += 1;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
|
|||
|
|
counts.success += 1;
|
|||
|
|
} else {
|
|||
|
|
counts.failure += 1;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const total = counts.success + counts.failure + counts.pending;
|
|||
|
|
const state = counts.failure > 0
|
|||
|
|
? 'failure'
|
|||
|
|
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
|||
|
|
checks = { state, total, ...counts };
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore and fall back
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!checks) {
|
|||
|
|
try {
|
|||
|
|
const combined = await octokit.rest.repos.getCombinedStatusForRef({
|
|||
|
|
owner: searchRepo.owner,
|
|||
|
|
repo: searchRepo.repo,
|
|||
|
|
ref: sha,
|
|||
|
|
});
|
|||
|
|
const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : [];
|
|||
|
|
const counts = { success: 0, failure: 0, pending: 0 };
|
|||
|
|
statuses.forEach((s) => {
|
|||
|
|
if (s.state === 'success') counts.success += 1;
|
|||
|
|
else if (s.state === 'failure' || s.state === 'error') counts.failure += 1;
|
|||
|
|
else if (s.state === 'pending') counts.pending += 1;
|
|||
|
|
});
|
|||
|
|
const total = counts.success + counts.failure + counts.pending;
|
|||
|
|
const state = counts.failure > 0
|
|||
|
|
? 'failure'
|
|||
|
|
: (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
|||
|
|
checks = { state, total, ...counts };
|
|||
|
|
} catch {
|
|||
|
|
checks = null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Permission check (best-effort)
|
|||
|
|
let canMerge = false;
|
|||
|
|
try {
|
|||
|
|
const auth = getGitHubAuth();
|
|||
|
|
const username = auth?.user?.login;
|
|||
|
|
if (username) {
|
|||
|
|
const perm = await octokit.rest.repos.getCollaboratorPermissionLevel({
|
|||
|
|
owner: searchRepo.owner,
|
|||
|
|
repo: searchRepo.repo,
|
|||
|
|
username,
|
|||
|
|
});
|
|||
|
|
const level = perm?.data?.permission;
|
|||
|
|
canMerge = level === 'admin' || level === 'maintain' || level === 'write';
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
canMerge = false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const isMerged = Boolean(prData.merged || prData.merged_at);
|
|||
|
|
const mergedState = isMerged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open');
|
|||
|
|
|
|||
|
|
return res.json({
|
|||
|
|
connected: true,
|
|||
|
|
repo: searchRepo,
|
|||
|
|
branch,
|
|||
|
|
pr: {
|
|||
|
|
number: prData.number,
|
|||
|
|
title: prData.title,
|
|||
|
|
body: prData.body || '',
|
|||
|
|
url: prData.html_url,
|
|||
|
|
state: mergedState,
|
|||
|
|
draft: Boolean(prData.draft),
|
|||
|
|
base: prData.base?.ref,
|
|||
|
|
head: prData.head?.ref,
|
|||
|
|
headSha: prData.head?.sha,
|
|||
|
|
mergeable: prData.mergeable,
|
|||
|
|
mergeableState: prData.mergeable_state,
|
|||
|
|
},
|
|||
|
|
checks,
|
|||
|
|
canMerge,
|
|||
|
|
defaultBranch: resolvedStatus.defaultBranch ?? null,
|
|||
|
|
resolvedRemoteName: resolvedStatus.resolvedRemoteName ?? null,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
if (error?.status === 401) {
|
|||
|
|
const { clearGitHubAuth } = await getGitHubLibraries();
|
|||
|
|
clearGitHubAuth();
|
|||
|
|
return res.json({ connected: false });
|
|||
|
|
}
|
|||
|
|
if (isGitHubResourceUnavailable(error)) {
|
|||
|
|
return res.json({
|
|||
|
|
connected: true,
|
|||
|
|
repo: null,
|
|||
|
|
branch: typeof req.query?.branch === 'string' ? req.query.branch.trim() : '',
|
|||
|
|
pr: null,
|
|||
|
|
checks: null,
|
|||
|
|
canMerge: false,
|
|||
|
|
defaultBranch: null,
|
|||
|
|
resolvedRemoteName: null,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
console.error('Failed to load GitHub PR status:', error);
|
|||
|
|
return res.status(500).json({ error: error.message || 'Failed to load GitHub PR status' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/github/pr/create', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
|
|||
|
|
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
|
|||
|
|
const head = typeof req.body?.head === 'string' ? req.body.head.trim() : '';
|
|||
|
|
const requestedBase = typeof req.body?.base === 'string' ? req.body.base.trim() : '';
|
|||
|
|
const body = typeof req.body?.body === 'string' ? req.body.body : undefined;
|
|||
|
|
const draft = typeof req.body?.draft === 'boolean' ? req.body.draft : undefined;
|
|||
|
|
// remote = target repo (where PR is created, e.g., 'upstream' for forks)
|
|||
|
|
const remote = typeof req.body?.remote === 'string' ? req.body.remote.trim() : 'origin';
|
|||
|
|
// headRemote = source repo (where head branch lives, e.g., 'origin' for forks)
|
|||
|
|
const headRemote = typeof req.body?.headRemote === 'string' ? req.body.headRemote.trim() : '';
|
|||
|
|
if (!directory || !title || !head || !requestedBase) {
|
|||
|
|
return res.status(400).json({ error: 'directory, title, head, base are required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { getOctokitOrNull } = await getGitHubLibraries();
|
|||
|
|
const octokit = getOctokitOrNull();
|
|||
|
|
if (!octokit) {
|
|||
|
|
return res.status(401).json({ error: 'GitHub not connected' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js');
|
|||
|
|
const { repo } = await resolveGitHubRepoFromDirectory(directory, remote);
|
|||
|
|
if (!repo) {
|
|||
|
|
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const normalizeBranchRef = (value, remoteNames = new Set()) => {
|
|||
|
|
if (!value) {
|
|||
|
|
return value;
|
|||
|
|
}
|
|||
|
|
let normalized = value.trim();
|
|||
|
|
if (normalized.startsWith('refs/heads/')) {
|
|||
|
|
normalized = normalized.substring('refs/heads/'.length);
|
|||
|
|
}
|
|||
|
|
if (normalized.startsWith('heads/')) {
|
|||
|
|
normalized = normalized.substring('heads/'.length);
|
|||
|
|
}
|
|||
|
|
if (normalized.startsWith('remotes/')) {
|
|||
|
|
normalized = normalized.substring('remotes/'.length);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const slashIndex = normalized.indexOf('/');
|
|||
|
|
if (slashIndex > 0) {
|
|||
|
|
const maybeRemote = normalized.slice(0, slashIndex);
|
|||
|
|
if (remoteNames.has(maybeRemote)) {
|
|||
|
|
const withoutRemotePrefix = normalized.slice(slashIndex + 1).trim();
|
|||
|
|
if (withoutRemotePrefix) {
|
|||
|
|
normalized = withoutRemotePrefix;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return normalized;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Determine the source remote for the head branch
|
|||
|
|
// Priority: 1) explicit headRemote, 2) tracking branch remote, 3) 'origin' if targeting non-origin
|
|||
|
|
let sourceRemote = headRemote;
|
|||
|
|
const { getStatus, getRemotes } = await import('./lib/git/index.js');
|
|||
|
|
|
|||
|
|
// If no explicit headRemote, check the branch's tracking info
|
|||
|
|
if (!sourceRemote) {
|
|||
|
|
const status = await getStatus(directory).catch(() => null);
|
|||
|
|
if (status?.tracking) {
|
|||
|
|
// tracking is like "gsxdsm/fix/multi-remote-branch-creation" or "origin/main"
|
|||
|
|
const trackingRemote = status.tracking.split('/')[0];
|
|||
|
|
if (trackingRemote) {
|
|||
|
|
sourceRemote = trackingRemote;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Fallback: if targeting non-origin and no tracking info, try 'origin'
|
|||
|
|
if (!sourceRemote && remote !== 'origin') {
|
|||
|
|
sourceRemote = 'origin';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const remoteNames = new Set([remote]);
|
|||
|
|
const remotes = await getRemotes(directory).catch(() => []);
|
|||
|
|
for (const item of remotes) {
|
|||
|
|
if (item?.name) {
|
|||
|
|
remoteNames.add(item.name);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (sourceRemote) {
|
|||
|
|
remoteNames.add(sourceRemote);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const base = normalizeBranchRef(requestedBase, remoteNames);
|
|||
|
|
if (!base) {
|
|||
|
|
return res.status(400).json({ error: 'Invalid base branch name' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// For fork workflows: we need to determine the correct head reference
|
|||
|
|
let headRef = head;
|
|||
|
|
|
|||
|
|
if (sourceRemote && sourceRemote !== remote) {
|
|||
|
|
// The branch is on a different remote than the target - this is a cross-repo PR
|
|||
|
|
const { repo: headRepo } = await resolveGitHubRepoFromDirectory(directory, sourceRemote);
|
|||
|
|
if (headRepo) {
|
|||
|
|
// Always use owner:branch format for cross-repo PRs
|
|||
|
|
// GitHub API requires this when head is from a different repo/fork
|
|||
|
|
if (headRepo.owner !== repo.owner || headRepo.repo !== repo.repo) {
|
|||
|
|
headRef = `${headRepo.owner}:${head}`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// For cross-repo PRs, verify the branch exists on the head repo first
|
|||
|
|
if (headRef.includes(':')) {
|
|||
|
|
const [headOwner] = headRef.split(':');
|
|||
|
|
const headRepoName = sourceRemote
|
|||
|
|
? (await resolveGitHubRepoFromDirectory(directory, sourceRemote)).repo?.repo
|
|||
|
|
: repo.repo;
|
|||
|
|
|
|||
|
|
if (headRepoName) {
|
|||
|
|
try {
|
|||
|
|
await octokit.rest.repos.getBranch({
|
|||
|
|
owner: headOwner,
|
|||
|
|
repo: headRepoName,
|
|||
|
|
branch: head,
|
|||
|
|
});
|
|||
|
|
} catch (branchError) {
|
|||
|
|
if (branchError?.status === 404) {
|
|||
|
|
return res.status(400).json({
|
|||
|
|
error: `Branch "${head}" not found on ${headOwner}/${headRepoName}. Please push your branch first: git push ${sourceRemote || 'origin'} ${head}`,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
// For other errors, continue - let the PR create attempt handle it
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const created = await octokit.rest.pulls.create({
|
|||
|
|
owner: repo.owner,
|
|||
|
|
repo: repo.repo,
|
|||
|
|
title,
|
|||
|
|
head: headRef,
|
|||
|
|
base,
|
|||
|
|
...(typeof body === 'string' ? { body } : {}),
|
|||
|
|
...(typeof draft === 'boolean' ? { draft } : {}),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const pr = created?.data;
|
|||
|
|
if (!pr) {
|
|||
|
|
return res.status(500).json({ error: 'Failed to create PR' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return res.json({
|
|||
|
|
number: pr.number,
|
|||
|
|
title: pr.title,
|
|||
|
|
body: pr.body || '',
|
|||
|
|
url: pr.html_url,
|
|||
|
|
state: pr.state === 'closed' ? 'closed' : 'open',
|
|||
|
|
draft: Boolean(pr.draft),
|
|||
|
|
base: pr.base?.ref,
|
|||
|
|
head: pr.head?.ref,
|
|||
|
|
headSha: pr.head?.sha,
|
|||
|
|
mergeable: pr.mergeable,
|
|||
|
|
mergeableState: pr.mergeable_state,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to create GitHub PR:', error);
|
|||
|
|
|
|||
|
|
// Check for head validation error (common with fork PRs)
|
|||
|
|
const errorMessage = error.message || '';
|
|||
|
|
const isHeadValidationError =
|
|||
|
|
errorMessage.includes('Validation Failed') &&
|
|||
|
|
errorMessage.includes('"field":"head"') &&
|
|||
|
|
errorMessage.includes('"code":"invalid"');
|
|||
|
|
|
|||
|
|
if (isHeadValidationError) {
|
|||
|
|
return res.status(400).json({
|
|||
|
|
error: 'Unable to create PR: You must have write access to the source repository. Make sure you have pushed your branch to a repository you own (your fork), and that the branch exists on the remote.'
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return res.status(500).json({ error: error.message || 'Failed to create GitHub PR' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/github/pr/update', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
|
|||
|
|
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
|||
|
|
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
|
|||
|
|
const body = typeof req.body?.body === 'string' ? req.body.body : undefined;
|
|||
|
|
if (!directory || !number || !title) {
|
|||
|
|
return res.status(400).json({ error: 'directory, number, title are required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { getOctokitOrNull } = await getGitHubLibraries();
|
|||
|
|
const octokit = getOctokitOrNull();
|
|||
|
|
if (!octokit) {
|
|||
|
|
return res.status(401).json({ error: 'GitHub not connected' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js');
|
|||
|
|
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
|||
|
|
if (!repo) {
|
|||
|
|
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let updated;
|
|||
|
|
try {
|
|||
|
|
updated = await octokit.rest.pulls.update({
|
|||
|
|
owner: repo.owner,
|
|||
|
|
repo: repo.repo,
|
|||
|
|
pull_number: number,
|
|||
|
|
title,
|
|||
|
|
...(typeof body === 'string' ? { body } : {}),
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
if (error?.status === 401) {
|
|||
|
|
return res.status(401).json({ error: 'GitHub not connected' });
|
|||
|
|
}
|
|||
|
|
if (error?.status === 403) {
|
|||
|
|
return res.status(403).json({ error: 'Not authorized to edit this PR' });
|
|||
|
|
}
|
|||
|
|
if (error?.status === 404) {
|
|||
|
|
return res.status(404).json({ error: 'PR not found in this repository' });
|
|||
|
|
}
|
|||
|
|
if (error?.status === 422) {
|
|||
|
|
const apiMessage = error?.response?.data?.message;
|
|||
|
|
const firstError = Array.isArray(error?.response?.data?.errors) && error.response.data.errors.length > 0
|
|||
|
|
? (error.response.data.errors[0]?.message || error.response.data.errors[0]?.code)
|
|||
|
|
: null;
|
|||
|
|
const message = [apiMessage, firstError].filter(Boolean).join(' · ') || 'Invalid PR update payload';
|
|||
|
|
return res.status(422).json({ error: message });
|
|||
|
|
}
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const pr = updated?.data;
|
|||
|
|
if (!pr) {
|
|||
|
|
return res.status(500).json({ error: 'Failed to update PR' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return res.json({
|
|||
|
|
number: pr.number,
|
|||
|
|
title: pr.title,
|
|||
|
|
body: pr.body || '',
|
|||
|
|
url: pr.html_url,
|
|||
|
|
state: pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open'),
|
|||
|
|
draft: Boolean(pr.draft),
|
|||
|
|
base: pr.base?.ref,
|
|||
|
|
head: pr.head?.ref,
|
|||
|
|
headSha: pr.head?.sha,
|
|||
|
|
mergeable: pr.mergeable,
|
|||
|
|
mergeableState: pr.mergeable_state,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to update GitHub PR:', error);
|
|||
|
|
return res.status(500).json({ error: error.message || 'Failed to update GitHub PR' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/github/pr/merge', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
|
|||
|
|
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
|||
|
|
const method = typeof req.body?.method === 'string' ? req.body.method : 'merge';
|
|||
|
|
if (!directory || !number) {
|
|||
|
|
return res.status(400).json({ error: 'directory and number are required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { getOctokitOrNull } = await getGitHubLibraries();
|
|||
|
|
const octokit = getOctokitOrNull();
|
|||
|
|
if (!octokit) {
|
|||
|
|
return res.status(401).json({ error: 'GitHub not connected' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js');
|
|||
|
|
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
|||
|
|
if (!repo) {
|
|||
|
|
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const result = await octokit.rest.pulls.merge({
|
|||
|
|
owner: repo.owner,
|
|||
|
|
repo: repo.repo,
|
|||
|
|
pull_number: number,
|
|||
|
|
merge_method: method,
|
|||
|
|
});
|
|||
|
|
return res.json({ merged: Boolean(result?.data?.merged), message: result?.data?.message });
|
|||
|
|
} catch (error) {
|
|||
|
|
if (error?.status === 403) {
|
|||
|
|
return res.status(403).json({ error: 'Not authorized to merge this PR' });
|
|||
|
|
}
|
|||
|
|
if (error?.status === 405 || error?.status === 409) {
|
|||
|
|
return res.json({ merged: false, message: error?.message || 'PR not mergeable' });
|
|||
|
|
}
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to merge GitHub PR:', error);
|
|||
|
|
return res.status(500).json({ error: error.message || 'Failed to merge GitHub PR' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/github/pr/ready', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const directory = typeof req.body?.directory === 'string' ? req.body.directory.trim() : '';
|
|||
|
|
const number = typeof req.body?.number === 'number' ? req.body.number : null;
|
|||
|
|
if (!directory || !number) {
|
|||
|
|
return res.status(400).json({ error: 'directory and number are required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { getOctokitOrNull } = await getGitHubLibraries();
|
|||
|
|
const octokit = getOctokitOrNull();
|
|||
|
|
if (!octokit) {
|
|||
|
|
return res.status(401).json({ error: 'GitHub not connected' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js');
|
|||
|
|
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
|||
|
|
if (!repo) {
|
|||
|
|
return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const pr = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number });
|
|||
|
|
const nodeId = pr?.data?.node_id;
|
|||
|
|
if (!nodeId) {
|
|||
|
|
return res.status(500).json({ error: 'Failed to resolve PR node id' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (pr?.data?.draft === false) {
|
|||
|
|
return res.json({ ready: true });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await octokit.graphql(
|
|||
|
|
`mutation($pullRequestId: ID!) {\n markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {\n pullRequest {\n id\n isDraft\n }\n }\n}`,
|
|||
|
|
{ pullRequestId: nodeId }
|
|||
|
|
);
|
|||
|
|
} catch (error) {
|
|||
|
|
if (error?.status === 403) {
|
|||
|
|
return res.status(403).json({ error: 'Not authorized to mark PR ready' });
|
|||
|
|
}
|
|||
|
|
throw error;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return res.json({ ready: true });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to mark PR ready:', error);
|
|||
|
|
return res.status(500).json({ error: error.message || 'Failed to mark PR ready' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ================= GitHub Issue APIs =================
|
|||
|
|
|
|||
|
|
app.get('/api/github/issues/list', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
|||
|
|
const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { getOctokitOrNull } = await getGitHubLibraries();
|
|||
|
|
const octokit = getOctokitOrNull();
|
|||
|
|
if (!octokit) {
|
|||
|
|
return res.json({ connected: false });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js');
|
|||
|
|
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
|||
|
|
if (!repo) {
|
|||
|
|
return res.json({ connected: true, repo: null, issues: [] });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const list = await octokit.rest.issues.listForRepo({
|
|||
|
|
owner: repo.owner,
|
|||
|
|
repo: repo.repo,
|
|||
|
|
state: 'open',
|
|||
|
|
per_page: 50,
|
|||
|
|
page: Number.isFinite(page) && page > 0 ? page : 1,
|
|||
|
|
});
|
|||
|
|
const link = typeof list?.headers?.link === 'string' ? list.headers.link : '';
|
|||
|
|
const hasMore = /rel="next"/.test(link);
|
|||
|
|
const issues = (Array.isArray(list?.data) ? list.data : [])
|
|||
|
|
.filter((item) => !item?.pull_request)
|
|||
|
|
.map((item) => ({
|
|||
|
|
number: item.number,
|
|||
|
|
title: item.title,
|
|||
|
|
url: item.html_url,
|
|||
|
|
state: item.state === 'closed' ? 'closed' : 'open',
|
|||
|
|
author: item.user ? { login: item.user.login, id: item.user.id, avatarUrl: item.user.avatar_url } : null,
|
|||
|
|
labels: Array.isArray(item.labels)
|
|||
|
|
? item.labels
|
|||
|
|
.map((label) => {
|
|||
|
|
if (typeof label === 'string') return null;
|
|||
|
|
const name = typeof label?.name === 'string' ? label.name : '';
|
|||
|
|
if (!name) return null;
|
|||
|
|
return { name, color: typeof label?.color === 'string' ? label.color : undefined };
|
|||
|
|
})
|
|||
|
|
.filter(Boolean)
|
|||
|
|
: [],
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
return res.json({ connected: true, repo, issues, page: Number.isFinite(page) && page > 0 ? page : 1, hasMore });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to list GitHub issues:', error);
|
|||
|
|
return res.status(500).json({ error: error.message || 'Failed to list GitHub issues' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/github/issues/get', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
|||
|
|
const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
|
|||
|
|
if (!directory || !number) {
|
|||
|
|
return res.status(400).json({ error: 'directory and number are required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { getOctokitOrNull } = await getGitHubLibraries();
|
|||
|
|
const octokit = getOctokitOrNull();
|
|||
|
|
if (!octokit) {
|
|||
|
|
return res.json({ connected: false });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js');
|
|||
|
|
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
|||
|
|
if (!repo) {
|
|||
|
|
return res.json({ connected: true, repo: null, issue: null });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await octokit.rest.issues.get({ owner: repo.owner, repo: repo.repo, issue_number: number });
|
|||
|
|
const issue = result?.data;
|
|||
|
|
if (!issue || issue.pull_request) {
|
|||
|
|
return res.status(400).json({ error: 'Not a GitHub issue' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return res.json({
|
|||
|
|
connected: true,
|
|||
|
|
repo,
|
|||
|
|
issue: {
|
|||
|
|
number: issue.number,
|
|||
|
|
title: issue.title,
|
|||
|
|
url: issue.html_url,
|
|||
|
|
state: issue.state === 'closed' ? 'closed' : 'open',
|
|||
|
|
body: issue.body || '',
|
|||
|
|
createdAt: issue.created_at,
|
|||
|
|
updatedAt: issue.updated_at,
|
|||
|
|
author: issue.user ? { login: issue.user.login, id: issue.user.id, avatarUrl: issue.user.avatar_url } : null,
|
|||
|
|
assignees: Array.isArray(issue.assignees)
|
|||
|
|
? issue.assignees
|
|||
|
|
.map((u) => (u ? { login: u.login, id: u.id, avatarUrl: u.avatar_url } : null))
|
|||
|
|
.filter(Boolean)
|
|||
|
|
: [],
|
|||
|
|
labels: Array.isArray(issue.labels)
|
|||
|
|
? issue.labels
|
|||
|
|
.map((label) => {
|
|||
|
|
if (typeof label === 'string') return null;
|
|||
|
|
const name = typeof label?.name === 'string' ? label.name : '';
|
|||
|
|
if (!name) return null;
|
|||
|
|
return { name, color: typeof label?.color === 'string' ? label.color : undefined };
|
|||
|
|
})
|
|||
|
|
.filter(Boolean)
|
|||
|
|
: [],
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to fetch GitHub issue:', error);
|
|||
|
|
return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/github/issues/comments', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
|||
|
|
const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
|
|||
|
|
if (!directory || !number) {
|
|||
|
|
return res.status(400).json({ error: 'directory and number are required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { getOctokitOrNull } = await getGitHubLibraries();
|
|||
|
|
const octokit = getOctokitOrNull();
|
|||
|
|
if (!octokit) {
|
|||
|
|
return res.json({ connected: false });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js');
|
|||
|
|
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
|||
|
|
if (!repo) {
|
|||
|
|
return res.json({ connected: true, repo: null, comments: [] });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await octokit.rest.issues.listComments({
|
|||
|
|
owner: repo.owner,
|
|||
|
|
repo: repo.repo,
|
|||
|
|
issue_number: number,
|
|||
|
|
per_page: 100,
|
|||
|
|
});
|
|||
|
|
const comments = (Array.isArray(result?.data) ? result.data : [])
|
|||
|
|
.map((comment) => ({
|
|||
|
|
id: comment.id,
|
|||
|
|
url: comment.html_url,
|
|||
|
|
body: comment.body || '',
|
|||
|
|
createdAt: comment.created_at,
|
|||
|
|
updatedAt: comment.updated_at,
|
|||
|
|
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
return res.json({ connected: true, repo, comments });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to fetch GitHub issue comments:', error);
|
|||
|
|
return res.status(500).json({ error: error.message || 'Failed to fetch GitHub issue comments' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ================= GitHub Pull Request Context APIs =================
|
|||
|
|
|
|||
|
|
app.get('/api/github/pulls/list', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
|||
|
|
const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { getOctokitOrNull } = await getGitHubLibraries();
|
|||
|
|
const octokit = getOctokitOrNull();
|
|||
|
|
if (!octokit) {
|
|||
|
|
return res.json({ connected: false });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js');
|
|||
|
|
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
|||
|
|
if (!repo) {
|
|||
|
|
return res.json({ connected: true, repo: null, prs: [] });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const list = await octokit.rest.pulls.list({
|
|||
|
|
owner: repo.owner,
|
|||
|
|
repo: repo.repo,
|
|||
|
|
state: 'open',
|
|||
|
|
per_page: 50,
|
|||
|
|
page: Number.isFinite(page) && page > 0 ? page : 1,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const link = typeof list?.headers?.link === 'string' ? list.headers.link : '';
|
|||
|
|
const hasMore = /rel="next"/.test(link);
|
|||
|
|
|
|||
|
|
const prs = (Array.isArray(list?.data) ? list.data : []).map((pr) => {
|
|||
|
|
const mergedState = pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open');
|
|||
|
|
const headRepo = pr.head?.repo
|
|||
|
|
? {
|
|||
|
|
owner: pr.head.repo.owner?.login,
|
|||
|
|
repo: pr.head.repo.name,
|
|||
|
|
url: pr.head.repo.html_url,
|
|||
|
|
cloneUrl: pr.head.repo.clone_url,
|
|||
|
|
sshUrl: pr.head.repo.ssh_url,
|
|||
|
|
}
|
|||
|
|
: null;
|
|||
|
|
return {
|
|||
|
|
number: pr.number,
|
|||
|
|
title: pr.title,
|
|||
|
|
url: pr.html_url,
|
|||
|
|
state: mergedState,
|
|||
|
|
draft: Boolean(pr.draft),
|
|||
|
|
base: pr.base?.ref,
|
|||
|
|
head: pr.head?.ref,
|
|||
|
|
headSha: pr.head?.sha,
|
|||
|
|
mergeable: pr.mergeable,
|
|||
|
|
mergeableState: pr.mergeable_state,
|
|||
|
|
author: pr.user ? { login: pr.user.login, id: pr.user.id, avatarUrl: pr.user.avatar_url } : null,
|
|||
|
|
headLabel: pr.head?.label,
|
|||
|
|
headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url
|
|||
|
|
? headRepo
|
|||
|
|
: null,
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return res.json({ connected: true, repo, prs, page: Number.isFinite(page) && page > 0 ? page : 1, hasMore });
|
|||
|
|
} catch (error) {
|
|||
|
|
if (error?.status === 401) {
|
|||
|
|
const { clearGitHubAuth } = await getGitHubLibraries();
|
|||
|
|
clearGitHubAuth();
|
|||
|
|
return res.json({ connected: false });
|
|||
|
|
}
|
|||
|
|
console.error('Failed to list GitHub PRs:', error);
|
|||
|
|
return res.status(500).json({ error: error.message || 'Failed to list GitHub PRs' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/github/pulls/context', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
|||
|
|
const number = typeof req.query?.number === 'string' ? Number(req.query.number) : null;
|
|||
|
|
const includeDiff = req.query?.diff === '1' || req.query?.diff === 'true';
|
|||
|
|
const includeCheckDetails = req.query?.checkDetails === '1' || req.query?.checkDetails === 'true';
|
|||
|
|
if (!directory || !number) {
|
|||
|
|
return res.status(400).json({ error: 'directory and number are required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { getOctokitOrNull } = await getGitHubLibraries();
|
|||
|
|
const octokit = getOctokitOrNull();
|
|||
|
|
if (!octokit) {
|
|||
|
|
return res.json({ connected: false });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { resolveGitHubRepoFromDirectory } = await import('./lib/github/index.js');
|
|||
|
|
const { repo } = await resolveGitHubRepoFromDirectory(directory);
|
|||
|
|
if (!repo) {
|
|||
|
|
return res.json({ connected: true, repo: null, pr: null });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const prResp = await octokit.rest.pulls.get({ owner: repo.owner, repo: repo.repo, pull_number: number });
|
|||
|
|
const prData = prResp?.data;
|
|||
|
|
if (!prData) {
|
|||
|
|
return res.status(404).json({ error: 'PR not found' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const headRepo = prData.head?.repo
|
|||
|
|
? {
|
|||
|
|
owner: prData.head.repo.owner?.login,
|
|||
|
|
repo: prData.head.repo.name,
|
|||
|
|
url: prData.head.repo.html_url,
|
|||
|
|
cloneUrl: prData.head.repo.clone_url,
|
|||
|
|
sshUrl: prData.head.repo.ssh_url,
|
|||
|
|
}
|
|||
|
|
: null;
|
|||
|
|
|
|||
|
|
const mergedState = prData.merged ? 'merged' : (prData.state === 'closed' ? 'closed' : 'open');
|
|||
|
|
const pr = {
|
|||
|
|
number: prData.number,
|
|||
|
|
title: prData.title,
|
|||
|
|
url: prData.html_url,
|
|||
|
|
state: mergedState,
|
|||
|
|
draft: Boolean(prData.draft),
|
|||
|
|
base: prData.base?.ref,
|
|||
|
|
head: prData.head?.ref,
|
|||
|
|
headSha: prData.head?.sha,
|
|||
|
|
mergeable: prData.mergeable,
|
|||
|
|
mergeableState: prData.mergeable_state,
|
|||
|
|
author: prData.user ? { login: prData.user.login, id: prData.user.id, avatarUrl: prData.user.avatar_url } : null,
|
|||
|
|
headLabel: prData.head?.label,
|
|||
|
|
headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url ? headRepo : null,
|
|||
|
|
body: prData.body || '',
|
|||
|
|
createdAt: prData.created_at,
|
|||
|
|
updatedAt: prData.updated_at,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const issueCommentsResp = await octokit.rest.issues.listComments({
|
|||
|
|
owner: repo.owner,
|
|||
|
|
repo: repo.repo,
|
|||
|
|
issue_number: number,
|
|||
|
|
per_page: 100,
|
|||
|
|
});
|
|||
|
|
const issueComments = (Array.isArray(issueCommentsResp?.data) ? issueCommentsResp.data : []).map((comment) => ({
|
|||
|
|
id: comment.id,
|
|||
|
|
url: comment.html_url,
|
|||
|
|
body: comment.body || '',
|
|||
|
|
createdAt: comment.created_at,
|
|||
|
|
updatedAt: comment.updated_at,
|
|||
|
|
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
const reviewCommentsResp = await octokit.rest.pulls.listReviewComments({
|
|||
|
|
owner: repo.owner,
|
|||
|
|
repo: repo.repo,
|
|||
|
|
pull_number: number,
|
|||
|
|
per_page: 100,
|
|||
|
|
});
|
|||
|
|
const reviewComments = (Array.isArray(reviewCommentsResp?.data) ? reviewCommentsResp.data : []).map((comment) => ({
|
|||
|
|
id: comment.id,
|
|||
|
|
url: comment.html_url,
|
|||
|
|
body: comment.body || '',
|
|||
|
|
createdAt: comment.created_at,
|
|||
|
|
updatedAt: comment.updated_at,
|
|||
|
|
path: comment.path,
|
|||
|
|
line: typeof comment.line === 'number' ? comment.line : null,
|
|||
|
|
position: typeof comment.position === 'number' ? comment.position : null,
|
|||
|
|
author: comment.user ? { login: comment.user.login, id: comment.user.id, avatarUrl: comment.user.avatar_url } : null,
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
const filesResp = await octokit.rest.pulls.listFiles({
|
|||
|
|
owner: repo.owner,
|
|||
|
|
repo: repo.repo,
|
|||
|
|
pull_number: number,
|
|||
|
|
per_page: 100,
|
|||
|
|
});
|
|||
|
|
const files = (Array.isArray(filesResp?.data) ? filesResp.data : []).map((f) => ({
|
|||
|
|
filename: f.filename,
|
|||
|
|
status: f.status,
|
|||
|
|
additions: f.additions,
|
|||
|
|
deletions: f.deletions,
|
|||
|
|
changes: f.changes,
|
|||
|
|
patch: f.patch,
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// checks summary (same logic as status endpoint)
|
|||
|
|
let checks = null;
|
|||
|
|
let checkRunsOut = undefined;
|
|||
|
|
const sha = prData.head?.sha;
|
|||
|
|
if (sha) {
|
|||
|
|
try {
|
|||
|
|
const runs = await octokit.rest.checks.listForRef({ owner: repo.owner, repo: repo.repo, ref: sha, per_page: 100 });
|
|||
|
|
const checkRuns = Array.isArray(runs?.data?.check_runs) ? runs.data.check_runs : [];
|
|||
|
|
if (checkRuns.length > 0) {
|
|||
|
|
const parsedJobs = new Map();
|
|||
|
|
const parsedAnnotations = new Map();
|
|||
|
|
if (includeCheckDetails) {
|
|||
|
|
// Prefetch actions jobs per runId.
|
|||
|
|
const runIds = new Set();
|
|||
|
|
const jobIds = new Map();
|
|||
|
|
for (const run of checkRuns) {
|
|||
|
|
const details = typeof run.details_url === 'string' ? run.details_url : '';
|
|||
|
|
const match = details.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/);
|
|||
|
|
if (match) {
|
|||
|
|
const runId = Number(match[1]);
|
|||
|
|
const jobId = match[2] ? Number(match[2]) : null;
|
|||
|
|
if (Number.isFinite(runId) && runId > 0) {
|
|||
|
|
runIds.add(runId);
|
|||
|
|
if (jobId && Number.isFinite(jobId) && jobId > 0) {
|
|||
|
|
jobIds.set(details, { runId, jobId });
|
|||
|
|
} else {
|
|||
|
|
jobIds.set(details, { runId, jobId: null });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const runId of runIds) {
|
|||
|
|
try {
|
|||
|
|
const jobsResp = await octokit.rest.actions.listJobsForWorkflowRun({
|
|||
|
|
owner: repo.owner,
|
|||
|
|
repo: repo.repo,
|
|||
|
|
run_id: runId,
|
|||
|
|
per_page: 100,
|
|||
|
|
});
|
|||
|
|
const jobs = Array.isArray(jobsResp?.data?.jobs) ? jobsResp.data.jobs : [];
|
|||
|
|
parsedJobs.set(runId, jobs);
|
|||
|
|
} catch {
|
|||
|
|
parsedJobs.set(runId, []);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const run of checkRuns) {
|
|||
|
|
const runConclusion = typeof run?.conclusion === 'string' ? run.conclusion.toLowerCase() : '';
|
|||
|
|
const shouldLoadAnnotations = Boolean(
|
|||
|
|
run?.id
|
|||
|
|
&& runConclusion
|
|||
|
|
&& !['success', 'neutral', 'skipped'].includes(runConclusion)
|
|||
|
|
);
|
|||
|
|
if (!shouldLoadAnnotations) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const checkRunId = Number(run.id);
|
|||
|
|
if (!Number.isFinite(checkRunId) || checkRunId <= 0) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const annotations = [];
|
|||
|
|
for (let page = 1; page <= 3; page += 1) {
|
|||
|
|
try {
|
|||
|
|
const annotationsResp = await octokit.rest.checks.listAnnotations({
|
|||
|
|
owner: repo.owner,
|
|||
|
|
repo: repo.repo,
|
|||
|
|
check_run_id: checkRunId,
|
|||
|
|
per_page: 50,
|
|||
|
|
page,
|
|||
|
|
});
|
|||
|
|
const chunk = Array.isArray(annotationsResp?.data) ? annotationsResp.data : [];
|
|||
|
|
annotations.push(...chunk);
|
|||
|
|
if (chunk.length < 50) {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (annotations.length > 0) {
|
|||
|
|
parsedAnnotations.set(checkRunId, annotations);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
checkRunsOut = checkRuns.map((run) => {
|
|||
|
|
const detailsUrl = typeof run.details_url === 'string' ? run.details_url : undefined;
|
|||
|
|
let job = undefined;
|
|||
|
|
if (includeCheckDetails && detailsUrl) {
|
|||
|
|
const match = detailsUrl.match(/\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/);
|
|||
|
|
const runId = match ? Number(match[1]) : null;
|
|||
|
|
const jobId = match && match[2] ? Number(match[2]) : null;
|
|||
|
|
if (runId && Number.isFinite(runId)) {
|
|||
|
|
const jobs = parsedJobs.get(runId) || [];
|
|||
|
|
const matched = jobId
|
|||
|
|
? jobs.find((j) => j.id === jobId)
|
|||
|
|
: null;
|
|||
|
|
const picked = matched || jobs.find((j) => j.name === run.name) || null;
|
|||
|
|
if (picked) {
|
|||
|
|
job = {
|
|||
|
|
runId,
|
|||
|
|
jobId: picked.id,
|
|||
|
|
url: picked.html_url,
|
|||
|
|
name: picked.name,
|
|||
|
|
conclusion: picked.conclusion,
|
|||
|
|
steps: Array.isArray(picked.steps)
|
|||
|
|
? picked.steps.map((s) => ({
|
|||
|
|
name: s.name,
|
|||
|
|
status: s.status,
|
|||
|
|
conclusion: s.conclusion,
|
|||
|
|
number: s.number,
|
|||
|
|
startedAt: s.started_at || undefined,
|
|||
|
|
completedAt: s.completed_at || undefined,
|
|||
|
|
}))
|
|||
|
|
: undefined,
|
|||
|
|
};
|
|||
|
|
} else {
|
|||
|
|
job = { runId, ...(jobId ? { jobId } : {}), url: detailsUrl };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
id: run.id,
|
|||
|
|
name: run.name,
|
|||
|
|
app: run.app
|
|||
|
|
? {
|
|||
|
|
name: run.app.name || undefined,
|
|||
|
|
slug: run.app.slug || undefined,
|
|||
|
|
}
|
|||
|
|
: undefined,
|
|||
|
|
status: run.status,
|
|||
|
|
conclusion: run.conclusion,
|
|||
|
|
detailsUrl,
|
|||
|
|
output: run.output
|
|||
|
|
? {
|
|||
|
|
title: run.output.title || undefined,
|
|||
|
|
summary: run.output.summary || undefined,
|
|||
|
|
text: run.output.text || undefined,
|
|||
|
|
}
|
|||
|
|
: undefined,
|
|||
|
|
...(job ? { job } : {}),
|
|||
|
|
...(run.id && parsedAnnotations.has(run.id)
|
|||
|
|
? {
|
|||
|
|
annotations: parsedAnnotations.get(run.id).map((a) => ({
|
|||
|
|
path: a.path || undefined,
|
|||
|
|
startLine: typeof a.start_line === 'number' ? a.start_line : undefined,
|
|||
|
|
endLine: typeof a.end_line === 'number' ? a.end_line : undefined,
|
|||
|
|
level: a.annotation_level || undefined,
|
|||
|
|
message: a.message || '',
|
|||
|
|
title: a.title || undefined,
|
|||
|
|
rawDetails: a.raw_details || undefined,
|
|||
|
|
})).filter((a) => a.message),
|
|||
|
|
}
|
|||
|
|
: {}),
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
const counts = { success: 0, failure: 0, pending: 0 };
|
|||
|
|
for (const run of checkRuns) {
|
|||
|
|
const status = run?.status;
|
|||
|
|
const conclusion = run?.conclusion;
|
|||
|
|
if (status === 'queued' || status === 'in_progress') {
|
|||
|
|
counts.pending += 1;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
if (!conclusion) {
|
|||
|
|
counts.pending += 1;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
if (conclusion === 'success' || conclusion === 'neutral' || conclusion === 'skipped') {
|
|||
|
|
counts.success += 1;
|
|||
|
|
} else {
|
|||
|
|
counts.failure += 1;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const total = counts.success + counts.failure + counts.pending;
|
|||
|
|
const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
|||
|
|
checks = { state, total, ...counts };
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// ignore and fall back
|
|||
|
|
}
|
|||
|
|
if (!checks) {
|
|||
|
|
try {
|
|||
|
|
const combined = await octokit.rest.repos.getCombinedStatusForRef({ owner: repo.owner, repo: repo.repo, ref: sha });
|
|||
|
|
const statuses = Array.isArray(combined?.data?.statuses) ? combined.data.statuses : [];
|
|||
|
|
const counts = { success: 0, failure: 0, pending: 0 };
|
|||
|
|
statuses.forEach((s) => {
|
|||
|
|
if (s.state === 'success') counts.success += 1;
|
|||
|
|
else if (s.state === 'failure' || s.state === 'error') counts.failure += 1;
|
|||
|
|
else if (s.state === 'pending') counts.pending += 1;
|
|||
|
|
});
|
|||
|
|
const total = counts.success + counts.failure + counts.pending;
|
|||
|
|
const state = counts.failure > 0 ? 'failure' : (counts.pending > 0 ? 'pending' : (total > 0 ? 'success' : 'unknown'));
|
|||
|
|
checks = { state, total, ...counts };
|
|||
|
|
} catch {
|
|||
|
|
checks = null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let diff = undefined;
|
|||
|
|
if (includeDiff) {
|
|||
|
|
const diffResp = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', {
|
|||
|
|
owner: repo.owner,
|
|||
|
|
repo: repo.repo,
|
|||
|
|
pull_number: number,
|
|||
|
|
headers: { accept: 'application/vnd.github.v3.diff' },
|
|||
|
|
});
|
|||
|
|
diff = typeof diffResp?.data === 'string' ? diffResp.data : undefined;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return res.json({
|
|||
|
|
connected: true,
|
|||
|
|
repo,
|
|||
|
|
pr,
|
|||
|
|
issueComments,
|
|||
|
|
reviewComments,
|
|||
|
|
files,
|
|||
|
|
...(diff ? { diff } : {}),
|
|||
|
|
checks,
|
|||
|
|
...(Array.isArray(checkRunsOut) ? { checkRuns: checkRunsOut } : {}),
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
if (error?.status === 401) {
|
|||
|
|
const { clearGitHubAuth } = await getGitHubLibraries();
|
|||
|
|
clearGitHubAuth();
|
|||
|
|
return res.json({ connected: false });
|
|||
|
|
}
|
|||
|
|
console.error('Failed to load GitHub PR context:', error);
|
|||
|
|
return res.status(500).json({ error: error.message || 'Failed to load GitHub PR context' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/provider/:providerId/source', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { providerId } = req.params;
|
|||
|
|
if (!providerId) {
|
|||
|
|
return res.status(400).json({ error: 'Provider ID is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
|||
|
|
const queryDirectory = Array.isArray(req.query?.directory)
|
|||
|
|
? req.query.directory[0]
|
|||
|
|
: req.query?.directory;
|
|||
|
|
const requestedDirectory = headerDirectory || queryDirectory || null;
|
|||
|
|
|
|||
|
|
let directory = null;
|
|||
|
|
const resolved = await resolveProjectDirectory(req);
|
|||
|
|
if (resolved.directory) {
|
|||
|
|
directory = resolved.directory;
|
|||
|
|
} else if (requestedDirectory) {
|
|||
|
|
return res.status(400).json({ error: resolved.error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const sources = getProviderSources(providerId, directory);
|
|||
|
|
const { getProviderAuth } = await getAuthLibrary();
|
|||
|
|
const auth = getProviderAuth(providerId);
|
|||
|
|
sources.sources.auth.exists = Boolean(auth);
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
providerId,
|
|||
|
|
sources: sources.sources,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to get provider sources:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to get provider sources' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/quota/providers', async (_req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { listConfiguredQuotaProviders } = await getQuotaProviders();
|
|||
|
|
const providers = listConfiguredQuotaProviders();
|
|||
|
|
res.json({ providers });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to list quota providers:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to list quota providers' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/quota/:providerId', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { providerId } = req.params;
|
|||
|
|
if (!providerId) {
|
|||
|
|
return res.status(400).json({ error: 'Provider ID is required' });
|
|||
|
|
}
|
|||
|
|
const { fetchQuotaForProvider } = await getQuotaProviders();
|
|||
|
|
const result = await fetchQuotaForProvider(providerId);
|
|||
|
|
res.json(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to fetch quota:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to fetch quota' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.delete('/api/provider/:providerId/auth', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { providerId } = req.params;
|
|||
|
|
if (!providerId) {
|
|||
|
|
return res.status(400).json({ error: 'Provider ID is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const scope = typeof req.query?.scope === 'string' ? req.query.scope : 'auth';
|
|||
|
|
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
|||
|
|
const queryDirectory = Array.isArray(req.query?.directory)
|
|||
|
|
? req.query.directory[0]
|
|||
|
|
: req.query?.directory;
|
|||
|
|
const requestedDirectory = headerDirectory || queryDirectory || null;
|
|||
|
|
let directory = null;
|
|||
|
|
|
|||
|
|
if (scope === 'project' || requestedDirectory) {
|
|||
|
|
const resolved = await resolveProjectDirectory(req);
|
|||
|
|
if (!resolved.directory) {
|
|||
|
|
return res.status(400).json({ error: resolved.error });
|
|||
|
|
}
|
|||
|
|
directory = resolved.directory;
|
|||
|
|
} else {
|
|||
|
|
const resolved = await resolveProjectDirectory(req);
|
|||
|
|
if (resolved.directory) {
|
|||
|
|
directory = resolved.directory;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let removed = false;
|
|||
|
|
if (scope === 'auth') {
|
|||
|
|
const { removeProviderAuth } = await getAuthLibrary();
|
|||
|
|
removed = removeProviderAuth(providerId);
|
|||
|
|
} else if (scope === 'user' || scope === 'project' || scope === 'custom') {
|
|||
|
|
removed = removeProviderConfig(providerId, directory, scope);
|
|||
|
|
} else if (scope === 'all') {
|
|||
|
|
const { removeProviderAuth } = await getAuthLibrary();
|
|||
|
|
const authRemoved = removeProviderAuth(providerId);
|
|||
|
|
const userRemoved = removeProviderConfig(providerId, directory, 'user');
|
|||
|
|
const projectRemoved = directory ? removeProviderConfig(providerId, directory, 'project') : false;
|
|||
|
|
const customRemoved = removeProviderConfig(providerId, directory, 'custom');
|
|||
|
|
removed = authRemoved || userRemoved || projectRemoved || customRemoved;
|
|||
|
|
} else {
|
|||
|
|
return res.status(400).json({ error: 'Invalid scope' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (removed) {
|
|||
|
|
await refreshOpenCodeAfterConfigChange(`provider ${providerId} disconnected (${scope})`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
removed,
|
|||
|
|
requiresReload: removed,
|
|||
|
|
message: removed ? 'Provider disconnected successfully' : 'Provider was not connected',
|
|||
|
|
reloadDelayMs: removed ? CLIENT_RELOAD_DELAY_MS : undefined,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to disconnect provider:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to disconnect provider' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
let gitLibraries = null;
|
|||
|
|
const getGitLibraries = async () => {
|
|||
|
|
if (!gitLibraries) {
|
|||
|
|
gitLibraries = await import('./lib/git/index.js');
|
|||
|
|
}
|
|||
|
|
return gitLibraries;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
app.get('/api/git/identities', async (req, res) => {
|
|||
|
|
const { getProfiles } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const profiles = getProfiles();
|
|||
|
|
res.json(profiles);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to list git identity profiles:', error);
|
|||
|
|
res.status(500).json({ error: 'Failed to list git identity profiles' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/git/identities', async (req, res) => {
|
|||
|
|
const { createProfile } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const profile = createProfile(req.body);
|
|||
|
|
console.log(`Created git identity profile: ${profile.name} (${profile.id})`);
|
|||
|
|
res.json(profile);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to create git identity profile:', error);
|
|||
|
|
res.status(400).json({ error: error.message || 'Failed to create git identity profile' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.put('/api/git/identities/:id', async (req, res) => {
|
|||
|
|
const { updateProfile } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const profile = updateProfile(req.params.id, req.body);
|
|||
|
|
console.log(`Updated git identity profile: ${profile.name} (${profile.id})`);
|
|||
|
|
res.json(profile);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to update git identity profile:', error);
|
|||
|
|
res.status(400).json({ error: error.message || 'Failed to update git identity profile' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.delete('/api/git/identities/:id', async (req, res) => {
|
|||
|
|
const { deleteProfile } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
deleteProfile(req.params.id);
|
|||
|
|
console.log(`Deleted git identity profile: ${req.params.id}`);
|
|||
|
|
res.json({ success: true });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to delete git identity profile:', error);
|
|||
|
|
res.status(400).json({ error: error.message || 'Failed to delete git identity profile' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/git/global-identity', async (req, res) => {
|
|||
|
|
const { getGlobalIdentity } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const identity = await getGlobalIdentity();
|
|||
|
|
res.json(identity);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to get global git identity:', error);
|
|||
|
|
res.status(500).json({ error: 'Failed to get global git identity' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/git/discover-credentials', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { discoverGitCredentials } = await import('./lib/git/index.js');
|
|||
|
|
const credentials = discoverGitCredentials();
|
|||
|
|
res.json(credentials);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to discover git credentials:', error);
|
|||
|
|
res.status(500).json({ error: 'Failed to discover git credentials' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/git/check', async (req, res) => {
|
|||
|
|
const { isGitRepository } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const isRepo = await isGitRepository(directory);
|
|||
|
|
res.json({ isGitRepository: isRepo });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to check git repository:', error);
|
|||
|
|
res.status(500).json({ error: 'Failed to check git repository' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/git/remote-url', async (req, res) => {
|
|||
|
|
const { getRemoteUrl } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
const remote = req.query.remote || 'origin';
|
|||
|
|
|
|||
|
|
const url = await getRemoteUrl(directory, remote);
|
|||
|
|
res.json({ url });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to get remote url:', error);
|
|||
|
|
res.status(500).json({ error: 'Failed to get remote url' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/git/current-identity', async (req, res) => {
|
|||
|
|
const { getCurrentIdentity } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const identity = await getCurrentIdentity(directory);
|
|||
|
|
res.json(identity);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to get current git identity:', error);
|
|||
|
|
res.status(500).json({ error: 'Failed to get current git identity' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/git/has-local-identity', async (req, res) => {
|
|||
|
|
const { hasLocalIdentity } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const hasLocal = await hasLocalIdentity(directory);
|
|||
|
|
res.json({ hasLocalIdentity: hasLocal });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to check local git identity:', error);
|
|||
|
|
res.status(500).json({ error: 'Failed to check local git identity' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/git/set-identity', async (req, res) => {
|
|||
|
|
const { getProfile, setLocalIdentity, getGlobalIdentity } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { profileId } = req.body;
|
|||
|
|
if (!profileId) {
|
|||
|
|
return res.status(400).json({ error: 'profileId is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let profile = null;
|
|||
|
|
|
|||
|
|
if (profileId === 'global') {
|
|||
|
|
const globalIdentity = await getGlobalIdentity();
|
|||
|
|
if (!globalIdentity?.userName || !globalIdentity?.userEmail) {
|
|||
|
|
return res.status(404).json({ error: 'Global identity is not configured' });
|
|||
|
|
}
|
|||
|
|
profile = {
|
|||
|
|
id: 'global',
|
|||
|
|
name: 'Global Identity',
|
|||
|
|
userName: globalIdentity.userName,
|
|||
|
|
userEmail: globalIdentity.userEmail,
|
|||
|
|
sshKey: globalIdentity.sshCommand
|
|||
|
|
? globalIdentity.sshCommand.replace('ssh -i ', '')
|
|||
|
|
: null,
|
|||
|
|
};
|
|||
|
|
} else {
|
|||
|
|
profile = getProfile(profileId);
|
|||
|
|
if (!profile) {
|
|||
|
|
return res.status(404).json({ error: 'Profile not found' });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await setLocalIdentity(directory, profile);
|
|||
|
|
res.json({ success: true, profile });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to set git identity:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to set git identity' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/git/status', async (req, res) => {
|
|||
|
|
const { getStatus, isGitRepository } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const isRepo = await isGitRepository(directory);
|
|||
|
|
if (!isRepo) {
|
|||
|
|
return res.json({ isGitRepository: false, files: [], branch: null, ahead: 0, behind: 0 });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const status = await getStatus(directory);
|
|||
|
|
res.json(status);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to get git status:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to get git status' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/git/diff', async (req, res) => {
|
|||
|
|
const { getDiff } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const path = req.query.path;
|
|||
|
|
if (!path || typeof path !== 'string') {
|
|||
|
|
return res.status(400).json({ error: 'path parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const staged = req.query.staged === 'true';
|
|||
|
|
const context = req.query.context ? parseInt(String(req.query.context), 10) : undefined;
|
|||
|
|
|
|||
|
|
const diff = await getDiff(directory, {
|
|||
|
|
path,
|
|||
|
|
staged,
|
|||
|
|
contextLines: Number.isFinite(context) ? context : 3,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
res.json({ diff });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to get git diff:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to get git diff' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/git/file-diff', async (req, res) => {
|
|||
|
|
const { getFileDiff } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory || typeof directory !== 'string') {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const pathParam = req.query.path;
|
|||
|
|
if (!pathParam || typeof pathParam !== 'string') {
|
|||
|
|
return res.status(400).json({ error: 'path parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const staged = req.query.staged === 'true';
|
|||
|
|
|
|||
|
|
const result = await getFileDiff(directory, {
|
|||
|
|
path: pathParam,
|
|||
|
|
staged,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
original: result.original,
|
|||
|
|
modified: result.modified,
|
|||
|
|
path: result.path,
|
|||
|
|
isBinary: Boolean(result.isBinary),
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to get git file diff:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to get git file diff' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/git/revert', async (req, res) => {
|
|||
|
|
const { revertFile } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { path } = req.body || {};
|
|||
|
|
if (!path || typeof path !== 'string') {
|
|||
|
|
return res.status(400).json({ error: 'path parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await revertFile(directory, path);
|
|||
|
|
res.json({ success: true });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to revert git file:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to revert git file' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/git/pull', async (req, res) => {
|
|||
|
|
const { pull } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await pull(directory, req.body);
|
|||
|
|
res.json(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to pull:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to pull from remote' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/git/push', async (req, res) => {
|
|||
|
|
const { push } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await push(directory, req.body);
|
|||
|
|
res.json(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to push:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to push to remote' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/git/fetch', async (req, res) => {
|
|||
|
|
const { fetch: gitFetch } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await gitFetch(directory, req.body);
|
|||
|
|
res.json(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to fetch:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to fetch from remote' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/git/remotes', async (req, res) => {
|
|||
|
|
const { getRemotes } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const remotes = await getRemotes(directory);
|
|||
|
|
res.json(remotes);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to get remotes:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to get remotes' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/git/rebase', async (req, res) => {
|
|||
|
|
const { rebase } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await rebase(directory, req.body);
|
|||
|
|
res.json(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to rebase:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to rebase' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/git/rebase/abort', async (req, res) => {
|
|||
|
|
const { abortRebase } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await abortRebase(directory);
|
|||
|
|
res.json(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to abort rebase:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to abort rebase' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/git/merge', async (req, res) => {
|
|||
|
|
const { merge } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await merge(directory, req.body);
|
|||
|
|
res.json(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to merge:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to merge' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/git/merge/abort', async (req, res) => {
|
|||
|
|
const { abortMerge } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await abortMerge(directory);
|
|||
|
|
res.json(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to abort merge:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to abort merge' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/git/rebase/continue', async (req, res) => {
|
|||
|
|
const { continueRebase } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await continueRebase(directory);
|
|||
|
|
res.json(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to continue rebase:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to continue rebase' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/git/merge/continue', async (req, res) => {
|
|||
|
|
const { continueMerge } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await continueMerge(directory);
|
|||
|
|
res.json(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to continue merge:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to continue merge' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/git/conflict-details', async (req, res) => {
|
|||
|
|
const { getConflictDetails } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await getConflictDetails(directory);
|
|||
|
|
res.json(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to get conflict details:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to get conflict details' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/git/stash', async (req, res) => {
|
|||
|
|
const { stash } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await stash(directory, req.body);
|
|||
|
|
res.json(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to stash:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to stash' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/git/stash/pop', async (req, res) => {
|
|||
|
|
const { stashPop } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await stashPop(directory);
|
|||
|
|
res.json(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to pop stash:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to pop stash' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/git/commit', async (req, res) => {
|
|||
|
|
const { commit } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { message, addAll, files } = req.body;
|
|||
|
|
if (!message) {
|
|||
|
|
return res.status(400).json({ error: 'message is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await commit(directory, message, {
|
|||
|
|
addAll,
|
|||
|
|
files,
|
|||
|
|
});
|
|||
|
|
res.json(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to commit:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to create commit' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/git/branches', async (req, res) => {
|
|||
|
|
const { getBranches } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const branches = await getBranches(directory);
|
|||
|
|
res.json(branches);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to get branches:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to get branches' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/git/branches', async (req, res) => {
|
|||
|
|
const { createBranch } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { name, startPoint } = req.body;
|
|||
|
|
if (!name) {
|
|||
|
|
return res.status(400).json({ error: 'name is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await createBranch(directory, name, { startPoint });
|
|||
|
|
res.json(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to create branch:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to create branch' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.delete('/api/git/branches', async (req, res) => {
|
|||
|
|
const { deleteBranch } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { branch, force } = req.body;
|
|||
|
|
if (!branch) {
|
|||
|
|
return res.status(400).json({ error: 'branch is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await deleteBranch(directory, branch, { force });
|
|||
|
|
res.json(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to delete branch:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to delete branch' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
|
|||
|
|
app.put('/api/git/branches/rename', async (req, res) => {
|
|||
|
|
const { renameBranch } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { oldName, newName } = req.body;
|
|||
|
|
if (!oldName) {
|
|||
|
|
return res.status(400).json({ error: 'oldName is required' });
|
|||
|
|
}
|
|||
|
|
if (!newName) {
|
|||
|
|
return res.status(400).json({ error: 'newName is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await renameBranch(directory, oldName, newName);
|
|||
|
|
res.json(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to rename branch:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to rename branch' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
app.delete('/api/git/remote-branches', async (req, res) => {
|
|||
|
|
const { deleteRemoteBranch } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { branch, remote } = req.body;
|
|||
|
|
if (!branch) {
|
|||
|
|
return res.status(400).json({ error: 'branch is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await deleteRemoteBranch(directory, { branch, remote });
|
|||
|
|
res.json(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to delete remote branch:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to delete remote branch' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/git/checkout', async (req, res) => {
|
|||
|
|
const { checkoutBranch } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { branch } = req.body;
|
|||
|
|
if (!branch) {
|
|||
|
|
return res.status(400).json({ error: 'branch is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await checkoutBranch(directory, branch);
|
|||
|
|
res.json(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to checkout branch:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to checkout branch' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/git/worktrees', async (req, res) => {
|
|||
|
|
const { getWorktrees } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const worktrees = await getWorktrees(directory);
|
|||
|
|
res.json(worktrees);
|
|||
|
|
} catch (error) {
|
|||
|
|
// Worktrees are an optional feature. Avoid repeated 500s (and repeated client retries)
|
|||
|
|
// when the directory isn't a git repo or uses shell shorthand like "~/".
|
|||
|
|
console.warn('Failed to get worktrees, returning empty list:', error?.message || error);
|
|||
|
|
res.setHeader('X-OpenChamber-Warning', 'git worktrees unavailable');
|
|||
|
|
res.json([]);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/git/worktrees/validate', async (req, res) => {
|
|||
|
|
const { validateWorktreeCreate } = await getGitLibraries();
|
|||
|
|
if (typeof validateWorktreeCreate !== 'function') {
|
|||
|
|
return res.status(501).json({ error: 'Worktree validation is not available' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory || typeof directory !== 'string') {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await validateWorktreeCreate(directory, req.body || {});
|
|||
|
|
res.json(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to validate worktree creation:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to validate worktree creation' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/git/worktrees', async (req, res) => {
|
|||
|
|
const { createWorktree } = await getGitLibraries();
|
|||
|
|
if (typeof createWorktree !== 'function') {
|
|||
|
|
return res.status(501).json({ error: 'Worktree creation is not available' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory || typeof directory !== 'string') {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const created = await createWorktree(directory, req.body || {});
|
|||
|
|
res.json(created);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to create worktree:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to create worktree' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.delete('/api/git/worktrees', async (req, res) => {
|
|||
|
|
const { removeWorktree } = await getGitLibraries();
|
|||
|
|
if (typeof removeWorktree !== 'function') {
|
|||
|
|
return res.status(501).json({ error: 'Worktree removal is not available' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory || typeof directory !== 'string') {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const worktreeDirectory = typeof req.body?.directory === 'string' ? req.body.directory : '';
|
|||
|
|
if (!worktreeDirectory) {
|
|||
|
|
return res.status(400).json({ error: 'worktree directory is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await removeWorktree(directory, {
|
|||
|
|
directory: worktreeDirectory,
|
|||
|
|
deleteLocalBranch: req.body?.deleteLocalBranch === true,
|
|||
|
|
});
|
|||
|
|
res.json({ success: Boolean(result) });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to remove worktree:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to remove worktree' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/git/worktree-type', async (req, res) => {
|
|||
|
|
const { isLinkedWorktree } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const { directory } = req.query;
|
|||
|
|
if (!directory || typeof directory !== 'string') {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
const linked = await isLinkedWorktree(directory);
|
|||
|
|
res.json({ linked });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to determine worktree type:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to determine worktree type' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/git/log', async (req, res) => {
|
|||
|
|
const { getLog } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const directory = req.query.directory;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { maxCount, from, to, file } = req.query;
|
|||
|
|
const log = await getLog(directory, {
|
|||
|
|
maxCount: maxCount ? parseInt(maxCount) : undefined,
|
|||
|
|
from,
|
|||
|
|
to,
|
|||
|
|
file
|
|||
|
|
});
|
|||
|
|
res.json(log);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to get log:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to get commit log' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/git/commit-files', async (req, res) => {
|
|||
|
|
const { getCommitFiles } = await getGitLibraries();
|
|||
|
|
try {
|
|||
|
|
const { directory, hash } = req.query;
|
|||
|
|
if (!directory) {
|
|||
|
|
return res.status(400).json({ error: 'directory parameter is required' });
|
|||
|
|
}
|
|||
|
|
if (!hash) {
|
|||
|
|
return res.status(400).json({ error: 'hash parameter is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await getCommitFiles(directory, hash);
|
|||
|
|
res.json(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to get commit files:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to get commit files' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/fs/home', (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const home = os.homedir();
|
|||
|
|
if (!home || typeof home !== 'string' || home.length === 0) {
|
|||
|
|
return res.status(500).json({ error: 'Failed to resolve home directory' });
|
|||
|
|
}
|
|||
|
|
res.json({ home });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to resolve home directory:', error);
|
|||
|
|
res.status(500).json({ error: (error && error.message) || 'Failed to resolve home directory' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/fs/mkdir', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const { path: dirPath, allowOutsideWorkspace } = req.body ?? {};
|
|||
|
|
|
|||
|
|
if (typeof dirPath !== 'string' || !dirPath.trim()) {
|
|||
|
|
return res.status(400).json({ error: 'Path is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let resolvedPath = '';
|
|||
|
|
|
|||
|
|
if (allowOutsideWorkspace) {
|
|||
|
|
resolvedPath = path.resolve(normalizeDirectoryPath(dirPath));
|
|||
|
|
} else {
|
|||
|
|
const resolved = await resolveWorkspacePathFromContext(req, dirPath);
|
|||
|
|
if (!resolved.ok) {
|
|||
|
|
return res.status(400).json({ error: resolved.error });
|
|||
|
|
}
|
|||
|
|
resolvedPath = resolved.resolved;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await fsPromises.mkdir(resolvedPath, { recursive: true });
|
|||
|
|
|
|||
|
|
res.json({ success: true, path: resolvedPath });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to create directory:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to create directory' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Read file contents
|
|||
|
|
app.get('/api/fs/read', async (req, res) => {
|
|||
|
|
const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
|
|||
|
|
if (!filePath) {
|
|||
|
|
return res.status(400).json({ error: 'Path is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const resolved = await resolveWorkspacePathFromContext(req, filePath);
|
|||
|
|
if (!resolved.ok) {
|
|||
|
|
return res.status(400).json({ error: resolved.error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const [canonicalPath, canonicalBase] = await Promise.all([
|
|||
|
|
fsPromises.realpath(resolved.resolved),
|
|||
|
|
fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
if (!isPathWithinRoot(canonicalPath, canonicalBase)) {
|
|||
|
|
return res.status(403).json({ error: 'Access to file denied' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const stats = await fsPromises.stat(canonicalPath);
|
|||
|
|
if (!stats.isFile()) {
|
|||
|
|
return res.status(400).json({ error: 'Specified path is not a file' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const content = await fsPromises.readFile(canonicalPath, 'utf8');
|
|||
|
|
res.type('text/plain').send(content);
|
|||
|
|
} catch (error) {
|
|||
|
|
const err = error;
|
|||
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|||
|
|
return res.status(404).json({ error: 'File not found' });
|
|||
|
|
}
|
|||
|
|
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
|||
|
|
return res.status(403).json({ error: 'Access to file denied' });
|
|||
|
|
}
|
|||
|
|
console.error('Failed to read file:', error);
|
|||
|
|
res.status(500).json({ error: (error && error.message) || 'Failed to read file' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Read file as raw bytes (images, etc.)
|
|||
|
|
app.get('/api/fs/raw', async (req, res) => {
|
|||
|
|
const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
|
|||
|
|
if (!filePath) {
|
|||
|
|
return res.status(400).json({ error: 'Path is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const resolved = await resolveWorkspacePathFromContext(req, filePath);
|
|||
|
|
if (!resolved.ok) {
|
|||
|
|
return res.status(400).json({ error: resolved.error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const [canonicalPath, canonicalBase] = await Promise.all([
|
|||
|
|
fsPromises.realpath(resolved.resolved),
|
|||
|
|
fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
if (!isPathWithinRoot(canonicalPath, canonicalBase)) {
|
|||
|
|
return res.status(403).json({ error: 'Access to file denied' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const stats = await fsPromises.stat(canonicalPath);
|
|||
|
|
if (!stats.isFile()) {
|
|||
|
|
return res.status(400).json({ error: 'Specified path is not a file' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const ext = path.extname(canonicalPath).toLowerCase();
|
|||
|
|
const mimeMap = {
|
|||
|
|
'.png': 'image/png',
|
|||
|
|
'.jpg': 'image/jpeg',
|
|||
|
|
'.jpeg': 'image/jpeg',
|
|||
|
|
'.gif': 'image/gif',
|
|||
|
|
'.svg': 'image/svg+xml',
|
|||
|
|
'.webp': 'image/webp',
|
|||
|
|
'.ico': 'image/x-icon',
|
|||
|
|
'.bmp': 'image/bmp',
|
|||
|
|
'.avif': 'image/avif',
|
|||
|
|
};
|
|||
|
|
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
|||
|
|
|
|||
|
|
const content = await fsPromises.readFile(canonicalPath);
|
|||
|
|
res.setHeader('Cache-Control', 'no-store');
|
|||
|
|
res.type(mimeType).send(content);
|
|||
|
|
} catch (error) {
|
|||
|
|
const err = error;
|
|||
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|||
|
|
return res.status(404).json({ error: 'File not found' });
|
|||
|
|
}
|
|||
|
|
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
|||
|
|
return res.status(403).json({ error: 'Access to file denied' });
|
|||
|
|
}
|
|||
|
|
console.error('Failed to read raw file:', error);
|
|||
|
|
res.status(500).json({ error: (error && error.message) || 'Failed to read file' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Write file contents
|
|||
|
|
app.post('/api/fs/write', async (req, res) => {
|
|||
|
|
const { path: filePath, content } = req.body || {};
|
|||
|
|
if (!filePath || typeof filePath !== 'string') {
|
|||
|
|
return res.status(400).json({ error: 'Path is required' });
|
|||
|
|
}
|
|||
|
|
if (typeof content !== 'string') {
|
|||
|
|
return res.status(400).json({ error: 'Content is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const resolved = await resolveWorkspacePathFromContext(req, filePath);
|
|||
|
|
if (!resolved.ok) {
|
|||
|
|
return res.status(400).json({ error: resolved.error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Ensure parent directory exists
|
|||
|
|
await fsPromises.mkdir(path.dirname(resolved.resolved), { recursive: true });
|
|||
|
|
await fsPromises.writeFile(resolved.resolved, content, 'utf8');
|
|||
|
|
res.json({ success: true, path: resolved.resolved });
|
|||
|
|
} catch (error) {
|
|||
|
|
const err = error;
|
|||
|
|
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
|||
|
|
return res.status(403).json({ error: 'Access denied' });
|
|||
|
|
}
|
|||
|
|
console.error('Failed to write file:', error);
|
|||
|
|
res.status(500).json({ error: (error && error.message) || 'Failed to write file' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Delete file or directory
|
|||
|
|
app.post('/api/fs/delete', async (req, res) => {
|
|||
|
|
const { path: targetPath } = req.body || {};
|
|||
|
|
if (!targetPath || typeof targetPath !== 'string') {
|
|||
|
|
return res.status(400).json({ error: 'Path is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const resolved = await resolveWorkspacePathFromContext(req, targetPath);
|
|||
|
|
if (!resolved.ok) {
|
|||
|
|
return res.status(400).json({ error: resolved.error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await fsPromises.rm(resolved.resolved, { recursive: true, force: true });
|
|||
|
|
|
|||
|
|
res.json({ success: true, path: resolved.resolved });
|
|||
|
|
} catch (error) {
|
|||
|
|
const err = error;
|
|||
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|||
|
|
return res.status(404).json({ error: 'File or directory not found' });
|
|||
|
|
}
|
|||
|
|
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
|||
|
|
return res.status(403).json({ error: 'Access denied' });
|
|||
|
|
}
|
|||
|
|
console.error('Failed to delete path:', error);
|
|||
|
|
res.status(500).json({ error: (error && error.message) || 'Failed to delete path' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Rename/Move file or directory
|
|||
|
|
app.post('/api/fs/rename', async (req, res) => {
|
|||
|
|
const { oldPath, newPath } = req.body || {};
|
|||
|
|
if (!oldPath || typeof oldPath !== 'string') {
|
|||
|
|
return res.status(400).json({ error: 'oldPath is required' });
|
|||
|
|
}
|
|||
|
|
if (!newPath || typeof newPath !== 'string') {
|
|||
|
|
return res.status(400).json({ error: 'newPath is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const resolvedOld = await resolveWorkspacePathFromContext(req, oldPath);
|
|||
|
|
if (!resolvedOld.ok) {
|
|||
|
|
return res.status(400).json({ error: resolvedOld.error });
|
|||
|
|
}
|
|||
|
|
const resolvedNew = await resolveWorkspacePathFromContext(req, newPath);
|
|||
|
|
if (!resolvedNew.ok) {
|
|||
|
|
return res.status(400).json({ error: resolvedNew.error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (resolvedOld.base !== resolvedNew.base) {
|
|||
|
|
return res.status(400).json({ error: 'Source and destination must share the same workspace root' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await fsPromises.rename(resolvedOld.resolved, resolvedNew.resolved);
|
|||
|
|
|
|||
|
|
res.json({ success: true, path: resolvedNew.resolved });
|
|||
|
|
} catch (error) {
|
|||
|
|
const err = error;
|
|||
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|||
|
|
return res.status(404).json({ error: 'Source path not found' });
|
|||
|
|
}
|
|||
|
|
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
|||
|
|
return res.status(403).json({ error: 'Access denied' });
|
|||
|
|
}
|
|||
|
|
console.error('Failed to rename path:', error);
|
|||
|
|
res.status(500).json({ error: (error && error.message) || 'Failed to rename path' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Reveal a file or folder in the system file manager (Finder on macOS, Explorer on Windows, etc.)
|
|||
|
|
app.post('/api/fs/reveal', async (req, res) => {
|
|||
|
|
const { path: targetPath } = req.body || {};
|
|||
|
|
if (!targetPath || typeof targetPath !== 'string') {
|
|||
|
|
return res.status(400).json({ error: 'Path is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const resolved = path.resolve(targetPath.trim());
|
|||
|
|
|
|||
|
|
// Verify path exists
|
|||
|
|
await fsPromises.access(resolved);
|
|||
|
|
|
|||
|
|
const platform = process.platform;
|
|||
|
|
if (platform === 'darwin') {
|
|||
|
|
// macOS: open -R selects the file in Finder; open opens a folder
|
|||
|
|
const stat = await fsPromises.stat(resolved);
|
|||
|
|
if (stat.isDirectory()) {
|
|||
|
|
spawn('open', [resolved], { stdio: 'ignore', detached: true }).unref();
|
|||
|
|
} else {
|
|||
|
|
spawn('open', ['-R', resolved], { stdio: 'ignore', detached: true }).unref();
|
|||
|
|
}
|
|||
|
|
} else if (platform === 'win32') {
|
|||
|
|
// Windows: explorer /select, highlights the file
|
|||
|
|
spawn('explorer', ['/select,', resolved], { stdio: 'ignore', detached: true }).unref();
|
|||
|
|
} else {
|
|||
|
|
// Linux: xdg-open opens the parent directory
|
|||
|
|
const stat = await fsPromises.stat(resolved);
|
|||
|
|
const dir = stat.isDirectory() ? resolved : path.dirname(resolved);
|
|||
|
|
spawn('xdg-open', [dir], { stdio: 'ignore', detached: true }).unref();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
res.json({ success: true, path: resolved });
|
|||
|
|
} catch (error) {
|
|||
|
|
const err = error;
|
|||
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|||
|
|
return res.status(404).json({ error: 'Path not found' });
|
|||
|
|
}
|
|||
|
|
console.error('Failed to reveal path:', error);
|
|||
|
|
res.status(500).json({ error: (error && error.message) || 'Failed to reveal path' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Execute shell commands in a directory (for worktree setup)
|
|||
|
|
// NOTE: This route supports background execution to avoid tying up browser connections.
|
|||
|
|
const execJobs = new Map();
|
|||
|
|
const EXEC_JOB_TTL_MS = 30 * 60 * 1000;
|
|||
|
|
const COMMAND_TIMEOUT_MS = (() => {
|
|||
|
|
const raw = Number(process.env.OPENCHAMBER_FS_EXEC_TIMEOUT_MS);
|
|||
|
|
if (Number.isFinite(raw) && raw > 0) return raw;
|
|||
|
|
// `bun install` (common worktree setup cmd) often takes >60s.
|
|||
|
|
return 5 * 60 * 1000;
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
const pruneExecJobs = () => {
|
|||
|
|
const now = Date.now();
|
|||
|
|
for (const [jobId, job] of execJobs.entries()) {
|
|||
|
|
if (!job || typeof job !== 'object') {
|
|||
|
|
execJobs.delete(jobId);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
const updatedAt = typeof job.updatedAt === 'number' ? job.updatedAt : 0;
|
|||
|
|
if (updatedAt && now - updatedAt > EXEC_JOB_TTL_MS) {
|
|||
|
|
execJobs.delete(jobId);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const runCommandInDirectory = (shell, shellFlag, command, resolvedCwd) => {
|
|||
|
|
return new Promise((resolve) => {
|
|||
|
|
let stdout = '';
|
|||
|
|
let stderr = '';
|
|||
|
|
let timedOut = false;
|
|||
|
|
|
|||
|
|
const envPath = buildAugmentedPath();
|
|||
|
|
const execEnv = { ...process.env, PATH: envPath };
|
|||
|
|
|
|||
|
|
const child = spawn(shell, [shellFlag, command], {
|
|||
|
|
cwd: resolvedCwd,
|
|||
|
|
env: execEnv,
|
|||
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const timeout = setTimeout(() => {
|
|||
|
|
timedOut = true;
|
|||
|
|
try {
|
|||
|
|
child.kill('SIGKILL');
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
}, COMMAND_TIMEOUT_MS);
|
|||
|
|
|
|||
|
|
child.stdout?.on('data', (chunk) => {
|
|||
|
|
stdout += chunk.toString();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
child.stderr?.on('data', (chunk) => {
|
|||
|
|
stderr += chunk.toString();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
child.on('error', (error) => {
|
|||
|
|
clearTimeout(timeout);
|
|||
|
|
resolve({
|
|||
|
|
command,
|
|||
|
|
success: false,
|
|||
|
|
exitCode: undefined,
|
|||
|
|
stdout: stdout.trim(),
|
|||
|
|
stderr: stderr.trim(),
|
|||
|
|
error: (error && error.message) || 'Command execution failed',
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
child.on('close', (code, signal) => {
|
|||
|
|
clearTimeout(timeout);
|
|||
|
|
const exitCode = typeof code === 'number' ? code : undefined;
|
|||
|
|
const base = {
|
|||
|
|
command,
|
|||
|
|
success: exitCode === 0 && !timedOut,
|
|||
|
|
exitCode,
|
|||
|
|
stdout: stdout.trim(),
|
|||
|
|
stderr: stderr.trim(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (timedOut) {
|
|||
|
|
resolve({
|
|||
|
|
...base,
|
|||
|
|
success: false,
|
|||
|
|
error: `Command timed out after ${COMMAND_TIMEOUT_MS}ms` + (signal ? ` (${signal})` : ''),
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
resolve(base);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const runExecJob = async (job) => {
|
|||
|
|
job.status = 'running';
|
|||
|
|
job.updatedAt = Date.now();
|
|||
|
|
|
|||
|
|
const results = [];
|
|||
|
|
|
|||
|
|
for (const command of job.commands) {
|
|||
|
|
if (typeof command !== 'string' || !command.trim()) {
|
|||
|
|
results.push({ command, success: false, error: 'Invalid command' });
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const result = await runCommandInDirectory(job.shell, job.shellFlag, command, job.resolvedCwd);
|
|||
|
|
results.push(result);
|
|||
|
|
} catch (error) {
|
|||
|
|
results.push({
|
|||
|
|
command,
|
|||
|
|
success: false,
|
|||
|
|
error: (error && error.message) || 'Command execution failed',
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
job.results = results;
|
|||
|
|
job.updatedAt = Date.now();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
job.results = results;
|
|||
|
|
job.success = results.every((r) => r.success);
|
|||
|
|
job.status = 'done';
|
|||
|
|
job.finishedAt = Date.now();
|
|||
|
|
job.updatedAt = Date.now();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
app.post('/api/fs/exec', async (req, res) => {
|
|||
|
|
const { commands, cwd, background } = req.body || {};
|
|||
|
|
if (!Array.isArray(commands) || commands.length === 0) {
|
|||
|
|
return res.status(400).json({ error: 'Commands array is required' });
|
|||
|
|
}
|
|||
|
|
if (!cwd || typeof cwd !== 'string') {
|
|||
|
|
return res.status(400).json({ error: 'Working directory (cwd) is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pruneExecJobs();
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const resolvedCwd = path.resolve(normalizeDirectoryPath(cwd));
|
|||
|
|
const stats = await fsPromises.stat(resolvedCwd);
|
|||
|
|
if (!stats.isDirectory()) {
|
|||
|
|
return res.status(400).json({ error: 'Specified cwd is not a directory' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh');
|
|||
|
|
const shellFlag = process.platform === 'win32' ? '/c' : '-c';
|
|||
|
|
|
|||
|
|
const jobId = crypto.randomUUID();
|
|||
|
|
const job = {
|
|||
|
|
jobId,
|
|||
|
|
status: 'queued',
|
|||
|
|
success: null,
|
|||
|
|
commands,
|
|||
|
|
resolvedCwd,
|
|||
|
|
shell,
|
|||
|
|
shellFlag,
|
|||
|
|
results: [],
|
|||
|
|
startedAt: Date.now(),
|
|||
|
|
finishedAt: null,
|
|||
|
|
updatedAt: Date.now(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
execJobs.set(jobId, job);
|
|||
|
|
|
|||
|
|
const isBackground = background === true;
|
|||
|
|
if (isBackground) {
|
|||
|
|
void runExecJob(job).catch((error) => {
|
|||
|
|
job.status = 'done';
|
|||
|
|
job.success = false;
|
|||
|
|
job.results = Array.isArray(job.results) ? job.results : [];
|
|||
|
|
job.results.push({
|
|||
|
|
command: '',
|
|||
|
|
success: false,
|
|||
|
|
error: (error && error.message) || 'Command execution failed',
|
|||
|
|
});
|
|||
|
|
job.finishedAt = Date.now();
|
|||
|
|
job.updatedAt = Date.now();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return res.status(202).json({
|
|||
|
|
jobId,
|
|||
|
|
status: 'running',
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await runExecJob(job);
|
|||
|
|
res.json({
|
|||
|
|
jobId,
|
|||
|
|
status: job.status,
|
|||
|
|
success: job.success === true,
|
|||
|
|
results: job.results,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to execute commands:', error);
|
|||
|
|
res.status(500).json({ error: (error && error.message) || 'Failed to execute commands' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/fs/exec/:jobId', (req, res) => {
|
|||
|
|
const jobId = typeof req.params?.jobId === 'string' ? req.params.jobId : '';
|
|||
|
|
if (!jobId) {
|
|||
|
|
return res.status(400).json({ error: 'Job id is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pruneExecJobs();
|
|||
|
|
|
|||
|
|
const job = execJobs.get(jobId);
|
|||
|
|
if (!job) {
|
|||
|
|
return res.status(404).json({ error: 'Job not found' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
job.updatedAt = Date.now();
|
|||
|
|
|
|||
|
|
return res.json({
|
|||
|
|
jobId: job.jobId,
|
|||
|
|
status: job.status,
|
|||
|
|
success: job.success === true,
|
|||
|
|
results: Array.isArray(job.results) ? job.results : [],
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/opencode/directory', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
const requestedPath = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
|
|||
|
|
if (!requestedPath) {
|
|||
|
|
return res.status(400).json({ error: 'Path is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const validated = await validateDirectoryPath(requestedPath);
|
|||
|
|
if (!validated.ok) {
|
|||
|
|
return res.status(400).json({ error: validated.error });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const resolvedPath = validated.directory;
|
|||
|
|
const currentSettings = await readSettingsFromDisk();
|
|||
|
|
const existingProjects = sanitizeProjects(currentSettings.projects) || [];
|
|||
|
|
const existing = existingProjects.find((project) => project.path === resolvedPath) || null;
|
|||
|
|
|
|||
|
|
const nextProjects = existing
|
|||
|
|
? existingProjects
|
|||
|
|
: [
|
|||
|
|
...existingProjects,
|
|||
|
|
{
|
|||
|
|
id: crypto.randomUUID(),
|
|||
|
|
path: resolvedPath,
|
|||
|
|
addedAt: Date.now(),
|
|||
|
|
lastOpenedAt: Date.now(),
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const activeProjectId = existing ? existing.id : nextProjects[nextProjects.length - 1].id;
|
|||
|
|
|
|||
|
|
const updated = await persistSettings({
|
|||
|
|
projects: nextProjects,
|
|||
|
|
activeProjectId,
|
|||
|
|
lastDirectory: resolvedPath,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
success: true,
|
|||
|
|
restarted: false,
|
|||
|
|
path: resolvedPath,
|
|||
|
|
settings: updated,
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to update OpenCode working directory:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to update working directory' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/fs/list', async (req, res) => {
|
|||
|
|
const rawPath = typeof req.query.path === 'string' && req.query.path.trim().length > 0
|
|||
|
|
? req.query.path.trim()
|
|||
|
|
: os.homedir();
|
|||
|
|
const respectGitignore = req.query.respectGitignore === 'true';
|
|||
|
|
let resolvedPath = '';
|
|||
|
|
|
|||
|
|
const isPlansDirectory = (value) => {
|
|||
|
|
if (!value || typeof value !== 'string') return false;
|
|||
|
|
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
|
|||
|
|
return normalized.endsWith('/.opencode/plans') || normalized.endsWith('.opencode/plans');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
resolvedPath = path.resolve(normalizeDirectoryPath(rawPath));
|
|||
|
|
|
|||
|
|
const stats = await fsPromises.stat(resolvedPath);
|
|||
|
|
if (!stats.isDirectory()) {
|
|||
|
|
return res.status(400).json({ error: 'Specified path is not a directory' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const dirents = await fsPromises.readdir(resolvedPath, { withFileTypes: true });
|
|||
|
|
|
|||
|
|
// Get gitignored paths if requested
|
|||
|
|
let ignoredPaths = new Set();
|
|||
|
|
if (respectGitignore) {
|
|||
|
|
try {
|
|||
|
|
// Get all entry paths to check (relative to resolvedPath for git check-ignore)
|
|||
|
|
const pathsToCheck = dirents.map((d) => d.name);
|
|||
|
|
|
|||
|
|
if (pathsToCheck.length > 0) {
|
|||
|
|
try {
|
|||
|
|
// Use git check-ignore with paths as arguments
|
|||
|
|
// Pass paths directly as arguments (works for reasonable directory sizes)
|
|||
|
|
const result = await new Promise((resolve) => {
|
|||
|
|
const child = spawn('git', ['check-ignore', '--', ...pathsToCheck], {
|
|||
|
|
cwd: resolvedPath,
|
|||
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
let stdout = '';
|
|||
|
|
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
|||
|
|
child.on('close', () => resolve(stdout));
|
|||
|
|
child.on('error', () => resolve(''));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
result.split('\n').filter(Boolean).forEach((name) => {
|
|||
|
|
const fullPath = path.join(resolvedPath, name.trim());
|
|||
|
|
ignoredPaths.add(fullPath);
|
|||
|
|
});
|
|||
|
|
} catch {
|
|||
|
|
// git check-ignore fails if not a git repo, continue without filtering
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// If git is not available, continue without gitignore filtering
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const entries = await Promise.all(
|
|||
|
|
dirents.map(async (dirent) => {
|
|||
|
|
const entryPath = path.join(resolvedPath, dirent.name);
|
|||
|
|
|
|||
|
|
// Skip gitignored entries
|
|||
|
|
if (respectGitignore && ignoredPaths.has(entryPath)) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let isDirectory = dirent.isDirectory();
|
|||
|
|
const isSymbolicLink = dirent.isSymbolicLink();
|
|||
|
|
|
|||
|
|
if (!isDirectory && isSymbolicLink) {
|
|||
|
|
try {
|
|||
|
|
const linkStats = await fsPromises.stat(entryPath);
|
|||
|
|
isDirectory = linkStats.isDirectory();
|
|||
|
|
} catch {
|
|||
|
|
isDirectory = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
name: dirent.name,
|
|||
|
|
path: entryPath,
|
|||
|
|
isDirectory,
|
|||
|
|
isFile: dirent.isFile(),
|
|||
|
|
isSymbolicLink
|
|||
|
|
};
|
|||
|
|
})
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
path: resolvedPath,
|
|||
|
|
entries: entries.filter(Boolean)
|
|||
|
|
});
|
|||
|
|
} catch (error) {
|
|||
|
|
const err = error;
|
|||
|
|
const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined;
|
|||
|
|
const isPlansPath = code === 'ENOENT' && (isPlansDirectory(resolvedPath) || isPlansDirectory(rawPath));
|
|||
|
|
if (!isPlansPath) {
|
|||
|
|
console.error('Failed to list directory:', error);
|
|||
|
|
}
|
|||
|
|
if (code === 'ENOENT') {
|
|||
|
|
// Return empty result for plans directory (expected to not exist until first use)
|
|||
|
|
if (isPlansPath) {
|
|||
|
|
return res.json({ path: resolvedPath || rawPath, entries: [] });
|
|||
|
|
}
|
|||
|
|
return res.status(404).json({ error: 'Directory not found' });
|
|||
|
|
}
|
|||
|
|
if (code === 'EACCES') {
|
|||
|
|
return res.status(403).json({ error: 'Access to directory denied' });
|
|||
|
|
}
|
|||
|
|
res.status(500).json({ error: (error && error.message) || 'Failed to list directory' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
let ptyProviderPromise = null;
|
|||
|
|
const getPtyProvider = async () => {
|
|||
|
|
if (ptyProviderPromise) {
|
|||
|
|
return ptyProviderPromise;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ptyProviderPromise = (async () => {
|
|||
|
|
const isBunRuntime = typeof globalThis.Bun !== 'undefined';
|
|||
|
|
|
|||
|
|
if (isBunRuntime) {
|
|||
|
|
try {
|
|||
|
|
const bunPty = await import('bun-pty');
|
|||
|
|
console.log('Using bun-pty for terminal sessions');
|
|||
|
|
return { spawn: bunPty.spawn, backend: 'bun-pty' };
|
|||
|
|
} catch (error) {
|
|||
|
|
console.warn('bun-pty unavailable, falling back to node-pty');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const nodePty = await import('node-pty');
|
|||
|
|
console.log('Using node-pty for terminal sessions');
|
|||
|
|
return { spawn: nodePty.spawn, backend: 'node-pty' };
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to load node-pty:', error && error.message ? error.message : error);
|
|||
|
|
if (isBunRuntime) {
|
|||
|
|
throw new Error('No PTY backend available. Install bun-pty or node-pty.');
|
|||
|
|
}
|
|||
|
|
throw new Error('node-pty is not available. Run: npm rebuild node-pty (or install Bun for bun-pty)');
|
|||
|
|
}
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
return ptyProviderPromise;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getTerminalShellCandidates = () => {
|
|||
|
|
if (process.platform === 'win32') {
|
|||
|
|
const windowsCandidates = [
|
|||
|
|
process.env.OPENCHAMBER_TERMINAL_SHELL,
|
|||
|
|
process.env.SHELL,
|
|||
|
|
process.env.ComSpec,
|
|||
|
|
path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
|||
|
|
'pwsh.exe',
|
|||
|
|
'powershell.exe',
|
|||
|
|
'cmd.exe',
|
|||
|
|
].filter(Boolean);
|
|||
|
|
|
|||
|
|
const resolved = [];
|
|||
|
|
const seen = new Set();
|
|||
|
|
for (const candidateRaw of windowsCandidates) {
|
|||
|
|
const candidate = String(candidateRaw).trim();
|
|||
|
|
if (!candidate) continue;
|
|||
|
|
|
|||
|
|
const lookedUp = candidate.includes('\\') || candidate.includes('/')
|
|||
|
|
? candidate
|
|||
|
|
: searchPathFor(candidate);
|
|||
|
|
const executable = lookedUp && isExecutable(lookedUp) ? lookedUp : (isExecutable(candidate) ? candidate : null);
|
|||
|
|
if (!executable || seen.has(executable)) continue;
|
|||
|
|
seen.add(executable);
|
|||
|
|
resolved.push(executable);
|
|||
|
|
}
|
|||
|
|
return resolved;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const unixCandidates = [
|
|||
|
|
process.env.OPENCHAMBER_TERMINAL_SHELL,
|
|||
|
|
process.env.SHELL,
|
|||
|
|
'/bin/zsh',
|
|||
|
|
'/bin/bash',
|
|||
|
|
'/bin/sh',
|
|||
|
|
'zsh',
|
|||
|
|
'bash',
|
|||
|
|
'sh',
|
|||
|
|
].filter(Boolean);
|
|||
|
|
|
|||
|
|
const resolved = [];
|
|||
|
|
const seen = new Set();
|
|||
|
|
for (const candidateRaw of unixCandidates) {
|
|||
|
|
const candidate = String(candidateRaw).trim();
|
|||
|
|
if (!candidate) continue;
|
|||
|
|
|
|||
|
|
const lookedUp = candidate.includes('/') ? candidate : searchPathFor(candidate);
|
|||
|
|
const executable = lookedUp && isExecutable(lookedUp) ? lookedUp : (isExecutable(candidate) ? candidate : null);
|
|||
|
|
if (!executable || seen.has(executable)) continue;
|
|||
|
|
seen.add(executable);
|
|||
|
|
resolved.push(executable);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return resolved;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const spawnTerminalPtyWithFallback = (pty, { cols, rows, cwd, env }) => {
|
|||
|
|
const shellCandidates = getTerminalShellCandidates();
|
|||
|
|
if (shellCandidates.length === 0) {
|
|||
|
|
throw new Error('No executable shell found for terminal session');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let lastError = null;
|
|||
|
|
for (const shell of shellCandidates) {
|
|||
|
|
try {
|
|||
|
|
const ptyProcess = pty.spawn(shell, [], {
|
|||
|
|
name: 'xterm-256color',
|
|||
|
|
cols: cols || 80,
|
|||
|
|
rows: rows || 24,
|
|||
|
|
cwd,
|
|||
|
|
env: {
|
|||
|
|
...env,
|
|||
|
|
TERM: 'xterm-256color',
|
|||
|
|
COLORTERM: 'truecolor',
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return { ptyProcess, shell };
|
|||
|
|
} catch (error) {
|
|||
|
|
lastError = error;
|
|||
|
|
console.warn(`Failed to spawn PTY using shell ${shell}:`, error && error.message ? error.message : error);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const baseMessage = lastError && lastError.message ? lastError.message : 'PTY spawn failed';
|
|||
|
|
throw new Error(`Failed to spawn terminal PTY with available shells (${shellCandidates.join(', ')}): ${baseMessage}`);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const terminalSessions = new Map();
|
|||
|
|
const MAX_TERMINAL_SESSIONS = 20;
|
|||
|
|
const TERMINAL_IDLE_TIMEOUT = 30 * 60 * 1000;
|
|||
|
|
const sanitizeTerminalEnv = (env) => {
|
|||
|
|
const next = { ...env };
|
|||
|
|
delete next.BASH_XTRACEFD;
|
|||
|
|
delete next.BASH_ENV;
|
|||
|
|
delete next.ENV;
|
|||
|
|
return next;
|
|||
|
|
};
|
|||
|
|
const terminalInputCapabilities = {
|
|||
|
|
input: {
|
|||
|
|
preferred: 'ws',
|
|||
|
|
transports: ['http', 'ws'],
|
|||
|
|
ws: {
|
|||
|
|
path: TERMINAL_INPUT_WS_PATH,
|
|||
|
|
v: 1,
|
|||
|
|
enc: 'text+json-bin-control',
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const sendTerminalInputWsControl = (socket, payload) => {
|
|||
|
|
if (!socket || socket.readyState !== 1) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
socket.send(createTerminalInputWsControlFrame(payload), { binary: true });
|
|||
|
|
} catch {
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
terminalInputWsServer = new WebSocketServer({
|
|||
|
|
noServer: true,
|
|||
|
|
maxPayload: TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
terminalInputWsServer.on('connection', (socket) => {
|
|||
|
|
const connectionState = {
|
|||
|
|
boundSessionId: null,
|
|||
|
|
invalidFrames: 0,
|
|||
|
|
rebindTimestamps: [],
|
|||
|
|
lastActivityAt: Date.now(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
sendTerminalInputWsControl(socket, { t: 'ok', v: 1 });
|
|||
|
|
|
|||
|
|
const heartbeatInterval = setInterval(() => {
|
|||
|
|
if (socket.readyState !== 1) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
socket.ping();
|
|||
|
|
} catch {
|
|||
|
|
}
|
|||
|
|
}, TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS);
|
|||
|
|
|
|||
|
|
socket.on('pong', () => {
|
|||
|
|
connectionState.lastActivityAt = Date.now();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
socket.on('message', (message, isBinary) => {
|
|||
|
|
connectionState.lastActivityAt = Date.now();
|
|||
|
|
|
|||
|
|
if (isBinary) {
|
|||
|
|
const controlMessage = readTerminalInputWsControlFrame(message);
|
|||
|
|
if (!controlMessage || typeof controlMessage.t !== 'string') {
|
|||
|
|
connectionState.invalidFrames += 1;
|
|||
|
|
sendTerminalInputWsControl(socket, {
|
|||
|
|
t: 'e',
|
|||
|
|
c: 'BAD_FRAME',
|
|||
|
|
f: connectionState.invalidFrames >= 10,
|
|||
|
|
});
|
|||
|
|
if (connectionState.invalidFrames >= 10) {
|
|||
|
|
socket.close(1008, 'protocol violation');
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (controlMessage.t === 'p') {
|
|||
|
|
sendTerminalInputWsControl(socket, { t: 'po', v: 1 });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (controlMessage.t !== 'b' || typeof controlMessage.s !== 'string') {
|
|||
|
|
connectionState.invalidFrames += 1;
|
|||
|
|
sendTerminalInputWsControl(socket, {
|
|||
|
|
t: 'e',
|
|||
|
|
c: 'BAD_FRAME',
|
|||
|
|
f: connectionState.invalidFrames >= 10,
|
|||
|
|
});
|
|||
|
|
if (connectionState.invalidFrames >= 10) {
|
|||
|
|
socket.close(1008, 'protocol violation');
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const now = Date.now();
|
|||
|
|
connectionState.rebindTimestamps = pruneRebindTimestamps(
|
|||
|
|
connectionState.rebindTimestamps,
|
|||
|
|
now,
|
|||
|
|
TERMINAL_INPUT_WS_REBIND_WINDOW_MS
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (isRebindRateLimited(connectionState.rebindTimestamps, TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW)) {
|
|||
|
|
sendTerminalInputWsControl(socket, { t: 'e', c: 'RATE_LIMIT', f: false });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const nextSessionId = controlMessage.s.trim();
|
|||
|
|
const targetSession = terminalSessions.get(nextSessionId);
|
|||
|
|
if (!targetSession) {
|
|||
|
|
connectionState.boundSessionId = null;
|
|||
|
|
sendTerminalInputWsControl(socket, { t: 'e', c: 'SESSION_NOT_FOUND', f: false });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
connectionState.rebindTimestamps.push(now);
|
|||
|
|
connectionState.boundSessionId = nextSessionId;
|
|||
|
|
sendTerminalInputWsControl(socket, { t: 'bok', v: 1 });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const payload = normalizeTerminalInputWsMessageToText(message);
|
|||
|
|
if (payload.length === 0) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!connectionState.boundSessionId) {
|
|||
|
|
sendTerminalInputWsControl(socket, { t: 'e', c: 'NOT_BOUND', f: false });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const session = terminalSessions.get(connectionState.boundSessionId);
|
|||
|
|
if (!session) {
|
|||
|
|
connectionState.boundSessionId = null;
|
|||
|
|
sendTerminalInputWsControl(socket, { t: 'e', c: 'SESSION_NOT_FOUND', f: false });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
session.ptyProcess.write(payload);
|
|||
|
|
session.lastActivity = Date.now();
|
|||
|
|
} catch {
|
|||
|
|
sendTerminalInputWsControl(socket, { t: 'e', c: 'WRITE_FAIL', f: false });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
socket.on('close', () => {
|
|||
|
|
clearInterval(heartbeatInterval);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
socket.on('error', (error) => {
|
|||
|
|
void error;
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
server.on('upgrade', (req, socket, head) => {
|
|||
|
|
const pathname = parseRequestPathname(req.url);
|
|||
|
|
if (pathname !== TERMINAL_INPUT_WS_PATH) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleUpgrade = async () => {
|
|||
|
|
try {
|
|||
|
|
if (uiAuthController?.enabled) {
|
|||
|
|
// Must be awaited: this call performs async token verification.
|
|||
|
|
const sessionToken = await uiAuthController?.ensureSessionToken?.(req, null);
|
|||
|
|
if (!sessionToken) {
|
|||
|
|
rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const originAllowed = await isRequestOriginAllowed(req);
|
|||
|
|
if (!originAllowed) {
|
|||
|
|
rejectWebSocketUpgrade(socket, 403, 'Invalid origin');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!terminalInputWsServer) {
|
|||
|
|
rejectWebSocketUpgrade(socket, 500, 'Terminal WebSocket unavailable');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
terminalInputWsServer.handleUpgrade(req, socket, head, (ws) => {
|
|||
|
|
terminalInputWsServer.emit('connection', ws, req);
|
|||
|
|
});
|
|||
|
|
} catch {
|
|||
|
|
rejectWebSocketUpgrade(socket, 500, 'Upgrade failed');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
void handleUpgrade();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
setInterval(() => {
|
|||
|
|
const now = Date.now();
|
|||
|
|
for (const [sessionId, session] of terminalSessions.entries()) {
|
|||
|
|
if (now - session.lastActivity > TERMINAL_IDLE_TIMEOUT) {
|
|||
|
|
console.log(`Cleaning up idle terminal session: ${sessionId}`);
|
|||
|
|
try {
|
|||
|
|
session.ptyProcess.kill();
|
|||
|
|
} catch (error) {
|
|||
|
|
|
|||
|
|
}
|
|||
|
|
terminalSessions.delete(sessionId);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}, 5 * 60 * 1000);
|
|||
|
|
|
|||
|
|
app.post('/api/terminal/create', async (req, res) => {
|
|||
|
|
try {
|
|||
|
|
if (terminalSessions.size >= MAX_TERMINAL_SESSIONS) {
|
|||
|
|
return res.status(429).json({ error: 'Maximum terminal sessions reached' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { cwd, cols, rows } = req.body;
|
|||
|
|
if (!cwd) {
|
|||
|
|
return res.status(400).json({ error: 'cwd is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await fs.promises.access(cwd);
|
|||
|
|
} catch {
|
|||
|
|
return res.status(400).json({ error: 'Invalid working directory' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const sessionId = Math.random().toString(36).substring(2, 15) +
|
|||
|
|
Math.random().toString(36).substring(2, 15);
|
|||
|
|
|
|||
|
|
const envPath = buildAugmentedPath();
|
|||
|
|
const resolvedEnv = sanitizeTerminalEnv({ ...process.env, PATH: envPath });
|
|||
|
|
|
|||
|
|
const pty = await getPtyProvider();
|
|||
|
|
const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, {
|
|||
|
|
cols,
|
|||
|
|
rows,
|
|||
|
|
cwd,
|
|||
|
|
env: resolvedEnv,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const session = {
|
|||
|
|
ptyProcess,
|
|||
|
|
ptyBackend: pty.backend,
|
|||
|
|
cwd,
|
|||
|
|
lastActivity: Date.now(),
|
|||
|
|
clients: new Set(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
terminalSessions.set(sessionId, session);
|
|||
|
|
|
|||
|
|
ptyProcess.onExit(({ exitCode, signal }) => {
|
|||
|
|
console.log(`Terminal session ${sessionId} exited with code ${exitCode}, signal ${signal}`);
|
|||
|
|
terminalSessions.delete(sessionId);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
console.log(`Created terminal session: ${sessionId} in ${cwd} using shell ${shell}`);
|
|||
|
|
res.json({ sessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalInputCapabilities });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to create terminal session:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to create terminal session' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('/api/terminal/:sessionId/stream', (req, res) => {
|
|||
|
|
const { sessionId } = req.params;
|
|||
|
|
const session = terminalSessions.get(sessionId);
|
|||
|
|
|
|||
|
|
if (!session) {
|
|||
|
|
return res.status(404).json({ error: 'Terminal session not found' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
res.setHeader('Content-Type', 'text/event-stream');
|
|||
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|||
|
|
res.setHeader('Connection', 'keep-alive');
|
|||
|
|
res.setHeader('X-Accel-Buffering', 'no');
|
|||
|
|
|
|||
|
|
const clientId = Math.random().toString(36).substring(7);
|
|||
|
|
session.clients.add(clientId);
|
|||
|
|
session.lastActivity = Date.now();
|
|||
|
|
|
|||
|
|
const runtime = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun';
|
|||
|
|
const ptyBackend = session.ptyBackend || 'unknown';
|
|||
|
|
res.write(`data: ${JSON.stringify({ type: 'connected', runtime, ptyBackend })}\n\n`);
|
|||
|
|
|
|||
|
|
const heartbeatInterval = setInterval(() => {
|
|||
|
|
try {
|
|||
|
|
|
|||
|
|
res.write(': heartbeat\n\n');
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error(`Heartbeat failed for client ${clientId}:`, error);
|
|||
|
|
clearInterval(heartbeatInterval);
|
|||
|
|
}
|
|||
|
|
}, 15000);
|
|||
|
|
|
|||
|
|
const dataHandler = (data) => {
|
|||
|
|
try {
|
|||
|
|
session.lastActivity = Date.now();
|
|||
|
|
const ok = res.write(`data: ${JSON.stringify({ type: 'data', data })}\n\n`);
|
|||
|
|
if (!ok && session.ptyProcess && typeof session.ptyProcess.pause === 'function') {
|
|||
|
|
session.ptyProcess.pause();
|
|||
|
|
res.once('drain', () => {
|
|||
|
|
if (session.ptyProcess && typeof session.ptyProcess.resume === 'function') {
|
|||
|
|
session.ptyProcess.resume();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error(`Error sending data to client ${clientId}:`, error);
|
|||
|
|
cleanup();
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const exitHandler = ({ exitCode, signal }) => {
|
|||
|
|
try {
|
|||
|
|
res.write(`data: ${JSON.stringify({ type: 'exit', exitCode, signal })}\n\n`);
|
|||
|
|
res.end();
|
|||
|
|
} catch (error) {
|
|||
|
|
|
|||
|
|
}
|
|||
|
|
cleanup();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const dataDisposable = session.ptyProcess.onData(dataHandler);
|
|||
|
|
const exitDisposable = session.ptyProcess.onExit(exitHandler);
|
|||
|
|
|
|||
|
|
const cleanup = () => {
|
|||
|
|
clearInterval(heartbeatInterval);
|
|||
|
|
session.clients.delete(clientId);
|
|||
|
|
|
|||
|
|
if (dataDisposable && typeof dataDisposable.dispose === 'function') {
|
|||
|
|
dataDisposable.dispose();
|
|||
|
|
}
|
|||
|
|
if (exitDisposable && typeof exitDisposable.dispose === 'function') {
|
|||
|
|
exitDisposable.dispose();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
res.end();
|
|||
|
|
} catch (error) {
|
|||
|
|
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log(`Client ${clientId} disconnected from terminal session ${sessionId}`);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
req.on('close', cleanup);
|
|||
|
|
req.on('error', cleanup);
|
|||
|
|
|
|||
|
|
console.log(`Terminal connected: session=${sessionId} client=${clientId} runtime=${runtime} pty=${ptyBackend}`);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/terminal/:sessionId/input', express.text({ type: '*/*' }), (req, res) => {
|
|||
|
|
const { sessionId } = req.params;
|
|||
|
|
const session = terminalSessions.get(sessionId);
|
|||
|
|
|
|||
|
|
if (!session) {
|
|||
|
|
return res.status(404).json({ error: 'Terminal session not found' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const data = typeof req.body === 'string' ? req.body : '';
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
session.ptyProcess.write(data);
|
|||
|
|
session.lastActivity = Date.now();
|
|||
|
|
res.json({ success: true });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to write to terminal:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to write to terminal' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/terminal/:sessionId/resize', (req, res) => {
|
|||
|
|
const { sessionId } = req.params;
|
|||
|
|
const session = terminalSessions.get(sessionId);
|
|||
|
|
|
|||
|
|
if (!session) {
|
|||
|
|
return res.status(404).json({ error: 'Terminal session not found' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { cols, rows } = req.body;
|
|||
|
|
if (!cols || !rows) {
|
|||
|
|
return res.status(400).json({ error: 'cols and rows are required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
session.ptyProcess.resize(cols, rows);
|
|||
|
|
session.lastActivity = Date.now();
|
|||
|
|
res.json({ success: true, cols, rows });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to resize terminal:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to resize terminal' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.delete('/api/terminal/:sessionId', (req, res) => {
|
|||
|
|
const { sessionId } = req.params;
|
|||
|
|
const session = terminalSessions.get(sessionId);
|
|||
|
|
|
|||
|
|
if (!session) {
|
|||
|
|
return res.status(404).json({ error: 'Terminal session not found' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
session.ptyProcess.kill();
|
|||
|
|
terminalSessions.delete(sessionId);
|
|||
|
|
console.log(`Closed terminal session: ${sessionId}`);
|
|||
|
|
res.json({ success: true });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to close terminal:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to close terminal' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/terminal/:sessionId/restart', async (req, res) => {
|
|||
|
|
const { sessionId } = req.params;
|
|||
|
|
const { cwd, cols, rows } = req.body;
|
|||
|
|
|
|||
|
|
if (!cwd) {
|
|||
|
|
return res.status(400).json({ error: 'cwd is required' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const existingSession = terminalSessions.get(sessionId);
|
|||
|
|
if (existingSession) {
|
|||
|
|
try {
|
|||
|
|
existingSession.ptyProcess.kill();
|
|||
|
|
} catch (error) {
|
|||
|
|
}
|
|||
|
|
terminalSessions.delete(sessionId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
try {
|
|||
|
|
const stats = await fs.promises.stat(cwd);
|
|||
|
|
if (!stats.isDirectory()) {
|
|||
|
|
return res.status(400).json({ error: 'Invalid working directory: not a directory' });
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
return res.status(400).json({ error: 'Invalid working directory: not accessible' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const newSessionId = Math.random().toString(36).substring(2, 15) +
|
|||
|
|
Math.random().toString(36).substring(2, 15);
|
|||
|
|
|
|||
|
|
const envPath = buildAugmentedPath();
|
|||
|
|
const resolvedEnv = sanitizeTerminalEnv({ ...process.env, PATH: envPath });
|
|||
|
|
|
|||
|
|
const pty = await getPtyProvider();
|
|||
|
|
const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, {
|
|||
|
|
cols,
|
|||
|
|
rows,
|
|||
|
|
cwd,
|
|||
|
|
env: resolvedEnv,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const session = {
|
|||
|
|
ptyProcess,
|
|||
|
|
ptyBackend: pty.backend,
|
|||
|
|
cwd,
|
|||
|
|
lastActivity: Date.now(),
|
|||
|
|
clients: new Set(),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
terminalSessions.set(newSessionId, session);
|
|||
|
|
|
|||
|
|
ptyProcess.onExit(({ exitCode, signal }) => {
|
|||
|
|
console.log(`Terminal session ${newSessionId} exited with code ${exitCode}, signal ${signal}`);
|
|||
|
|
terminalSessions.delete(newSessionId);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
console.log(`Restarted terminal session: ${sessionId} -> ${newSessionId} in ${cwd} using shell ${shell}`);
|
|||
|
|
res.json({ sessionId: newSessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalInputCapabilities });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to restart terminal session:', error);
|
|||
|
|
res.status(500).json({ error: error.message || 'Failed to restart terminal session' });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.post('/api/terminal/force-kill', (req, res) => {
|
|||
|
|
const { sessionId, cwd } = req.body;
|
|||
|
|
let killedCount = 0;
|
|||
|
|
|
|||
|
|
if (sessionId) {
|
|||
|
|
const session = terminalSessions.get(sessionId);
|
|||
|
|
if (session) {
|
|||
|
|
try {
|
|||
|
|
session.ptyProcess.kill();
|
|||
|
|
} catch (error) {
|
|||
|
|
}
|
|||
|
|
terminalSessions.delete(sessionId);
|
|||
|
|
killedCount++;
|
|||
|
|
}
|
|||
|
|
} else if (cwd) {
|
|||
|
|
for (const [id, session] of terminalSessions) {
|
|||
|
|
if (session.cwd === cwd) {
|
|||
|
|
try {
|
|||
|
|
session.ptyProcess.kill();
|
|||
|
|
} catch (error) {
|
|||
|
|
}
|
|||
|
|
terminalSessions.delete(id);
|
|||
|
|
killedCount++;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
for (const [id, session] of terminalSessions) {
|
|||
|
|
try {
|
|||
|
|
session.ptyProcess.kill();
|
|||
|
|
} catch (error) {
|
|||
|
|
}
|
|||
|
|
terminalSessions.delete(id);
|
|||
|
|
killedCount++;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log(`Force killed ${killedCount} terminal session(s)`);
|
|||
|
|
res.json({ success: true, killedCount });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
setupProxy(app);
|
|||
|
|
scheduleOpenCodeApiDetection();
|
|||
|
|
void bootstrapOpenCodeAtStartup();
|
|||
|
|
|
|||
|
|
const distPath = (() => {
|
|||
|
|
const env = typeof process.env.OPENCHAMBER_DIST_DIR === 'string' ? process.env.OPENCHAMBER_DIST_DIR.trim() : '';
|
|||
|
|
if (env) {
|
|||
|
|
return path.resolve(env);
|
|||
|
|
}
|
|||
|
|
return path.join(__dirname, '..', 'dist');
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
if (fs.existsSync(distPath)) {
|
|||
|
|
console.log(`Serving static files from ${distPath}`);
|
|||
|
|
app.use(express.static(distPath, {
|
|||
|
|
setHeaders(res, filePath) {
|
|||
|
|
// Service workers should never be long-cached; iOS is especially sensitive.
|
|||
|
|
if (typeof filePath === 'string' && filePath.endsWith(`${path.sep}sw.js`)) {
|
|||
|
|
res.setHeader('Cache-Control', 'no-store');
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => {
|
|||
|
|
res.sendFile(path.join(distPath, 'index.html'));
|
|||
|
|
});
|
|||
|
|
} else {
|
|||
|
|
console.warn(`Warning: ${distPath} not found, static files will not be served`);
|
|||
|
|
app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => {
|
|||
|
|
res.status(404).send('Static files not found. Please build the application first.');
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let activePort = port;
|
|||
|
|
|
|||
|
|
const bindHost = typeof process.env.OPENCHAMBER_HOST === 'string' && process.env.OPENCHAMBER_HOST.trim().length > 0
|
|||
|
|
? process.env.OPENCHAMBER_HOST.trim()
|
|||
|
|
: null;
|
|||
|
|
|
|||
|
|
await new Promise((resolve, reject) => {
|
|||
|
|
const onError = (error) => {
|
|||
|
|
server.off('error', onError);
|
|||
|
|
reject(error);
|
|||
|
|
};
|
|||
|
|
server.once('error', onError);
|
|||
|
|
const onListening = async () => {
|
|||
|
|
server.off('error', onError);
|
|||
|
|
const addressInfo = server.address();
|
|||
|
|
activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
process.send?.({ type: 'openchamber:ready', port: activePort });
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log(`OpenChamber server running on port ${activePort}`);
|
|||
|
|
console.log(`Health check: http://localhost:${activePort}/health`);
|
|||
|
|
console.log(`Web interface: http://localhost:${activePort}`);
|
|||
|
|
|
|||
|
|
resolve();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (bindHost) {
|
|||
|
|
server.listen(port, bindHost, onListening);
|
|||
|
|
} else {
|
|||
|
|
server.listen(port, onListening);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (attachSignals && !signalsAttached) {
|
|||
|
|
const handleSignal = async () => {
|
|||
|
|
await gracefulShutdown();
|
|||
|
|
};
|
|||
|
|
process.on('SIGTERM', handleSignal);
|
|||
|
|
process.on('SIGINT', handleSignal);
|
|||
|
|
process.on('SIGQUIT', handleSignal);
|
|||
|
|
signalsAttached = true;
|
|||
|
|
syncToHmrState();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
process.on('unhandledRejection', (reason, promise) => {
|
|||
|
|
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
process.on('uncaughtException', (error) => {
|
|||
|
|
console.error('Uncaught Exception:', error);
|
|||
|
|
gracefulShutdown();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
expressApp: app,
|
|||
|
|
httpServer: server,
|
|||
|
|
getPort: () => activePort,
|
|||
|
|
getOpenCodePort: () => openCodePort,
|
|||
|
|
isReady: () => isOpenCodeReady,
|
|||
|
|
restartOpenCode: () => restartOpenCode(),
|
|||
|
|
stop: (shutdownOptions = {}) =>
|
|||
|
|
gracefulShutdown({ exitProcess: shutdownOptions.exitProcess ?? false })
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const isCliExecution = process.argv[1] === __filename;
|
|||
|
|
|
|||
|
|
if (isCliExecution) {
|
|||
|
|
const cliOptions = parseArgs();
|
|||
|
|
exitOnShutdown = true;
|
|||
|
|
main({
|
|||
|
|
port: cliOptions.port,
|
|||
|
|
attachSignals: true,
|
|||
|
|
exitOnShutdown: true,
|
|||
|
|
uiPassword: cliOptions.uiPassword
|
|||
|
|
}).catch(error => {
|
|||
|
|
console.error('Failed to start server:', error);
|
|||
|
|
process.exit(1);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export { gracefulShutdown, setupProxy, restartOpenCode, main as startWebUiServer, parseArgs };
|