pi sills
This commit is contained in:
@@ -14,8 +14,11 @@ dockRouter.get('/', async (ctx) => {
|
||||
const file = Bun.file(filePath);
|
||||
|
||||
if (await file.exists()) {
|
||||
const data = await file.json();
|
||||
return ctx.json(data);
|
||||
try {
|
||||
return ctx.json(await file.json());
|
||||
} catch {
|
||||
// corrupted file — treat as missing
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.json(null);
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { createRouter } from '../../create-router';
|
||||
import { DATA_PATH } from '@@/data-path';
|
||||
import { CustomError } from '../../custom-errors';
|
||||
|
||||
const configDir = `${homedir()}/.config/officer.dev`;
|
||||
const googleConfigPath = join(configDir, 'google-oauth.json');
|
||||
|
||||
const GOOGLE_SCOPES = [
|
||||
'https://www.googleapis.com/auth/gmail.readonly',
|
||||
'https://www.googleapis.com/auth/calendar.readonly',
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
];
|
||||
|
||||
const ensureDir = (filePath: string) => mkdir(dirname(filePath), { recursive: true });
|
||||
|
||||
export const readGoogleConfig = async () => {
|
||||
try {
|
||||
return await Bun.file(googleConfigPath).json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getUserGoogleFile = (email: string) => join(DATA_PATH, email, 'integrations', 'google.json');
|
||||
|
||||
const readUserGoogle = async (email: string) => {
|
||||
try {
|
||||
return await Bun.file(getUserGoogleFile(email)).json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeUserGoogle = async (email: string, data: Record<string, unknown>) => {
|
||||
const filePath = getUserGoogleFile(email);
|
||||
await ensureDir(filePath);
|
||||
await Bun.write(filePath, JSON.stringify(data, null, 2));
|
||||
};
|
||||
|
||||
export const integrationsRouter = createRouter();
|
||||
|
||||
integrationsRouter.get('/', async (ctx) => {
|
||||
return ctx.json([]);
|
||||
});
|
||||
|
||||
// --- Enterprise: Google OAuth config (Super Admin only) ---
|
||||
|
||||
integrationsRouter.get('/google/config', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') throw new CustomError('Forbidden', 403);
|
||||
return ctx.json(await readGoogleConfig());
|
||||
});
|
||||
|
||||
integrationsRouter.put('/google/config', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') throw new CustomError('Forbidden', 403);
|
||||
|
||||
const body = ctx.get('body') as { clientId?: string; clientSecret?: string };
|
||||
const config = { clientId: body.clientId ?? '', clientSecret: body.clientSecret ?? '' };
|
||||
|
||||
await ensureDir(googleConfigPath);
|
||||
await Bun.write(googleConfigPath, JSON.stringify(config, null, 2));
|
||||
return ctx.json(config);
|
||||
});
|
||||
|
||||
integrationsRouter.get('/google/verify', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') throw new CustomError('Forbidden', 403);
|
||||
|
||||
const config = await readGoogleConfig();
|
||||
if (!config?.clientId || !config?.clientSecret) {
|
||||
return ctx.json({ valid: false, error: 'Missing credentials' });
|
||||
}
|
||||
|
||||
// Send a dummy token exchange — valid credentials return "invalid_grant",
|
||||
// invalid credentials return "invalid_client"
|
||||
const res = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
code: 'invalid_code',
|
||||
redirect_uri: 'https://localhost',
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
const valid = body.error === 'invalid_grant' || body.error === 'redirect_uri_mismatch';
|
||||
|
||||
return ctx.json({ valid, error: valid ? null : body.error_description ?? body.error });
|
||||
});
|
||||
|
||||
// --- Personal: Google account connection status ---
|
||||
|
||||
integrationsRouter.get('/google/status', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const config = await readGoogleConfig();
|
||||
const connection = await readUserGoogle(email);
|
||||
|
||||
return ctx.json({
|
||||
configured: !!(config?.clientId && config?.clientSecret),
|
||||
connected: !!connection?.accessToken,
|
||||
email: connection?.email ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
integrationsRouter.delete('/google/connection', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const filePath = getUserGoogleFile(email);
|
||||
|
||||
const file = Bun.file(filePath);
|
||||
if (await file.exists()) {
|
||||
await Bun.write(filePath, '{}');
|
||||
}
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- OAuth flow: authorize (protected — user must be logged in) ---
|
||||
|
||||
integrationsRouter.get('/google/authorize', async (ctx) => {
|
||||
const config = await readGoogleConfig();
|
||||
if (!config?.clientId || !config?.clientSecret) {
|
||||
throw new CustomError('Google OAuth not configured', 400);
|
||||
}
|
||||
|
||||
const email = ctx.get('user').email;
|
||||
const origin = ctx.req.query('origin');
|
||||
if (!origin) throw new CustomError('Missing origin parameter', 400);
|
||||
const redirectUri = `${origin}/api/integrations/google/callback`;
|
||||
|
||||
const state = Buffer.from(JSON.stringify({ email, redirectUri })).toString('base64url');
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: GOOGLE_SCOPES.join(' '),
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
state,
|
||||
});
|
||||
|
||||
return ctx.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`);
|
||||
});
|
||||
|
||||
// --- OAuth callback (public — called by Google, exported for hono.ts) ---
|
||||
|
||||
export const googleCallbackHandler = async (ctx: any) => {
|
||||
const code = ctx.req.query('code');
|
||||
const stateParam = ctx.req.query('state');
|
||||
const error = ctx.req.query('error');
|
||||
|
||||
if (error || !code || !stateParam) {
|
||||
return ctx.redirect('/settings/integrations?google=error');
|
||||
}
|
||||
|
||||
let email: string;
|
||||
let redirectUri: string;
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.from(stateParam, 'base64url').toString());
|
||||
email = parsed.email;
|
||||
redirectUri = parsed.redirectUri;
|
||||
} catch {
|
||||
return ctx.redirect('/settings/integrations?google=error');
|
||||
}
|
||||
|
||||
const config = await readGoogleConfig();
|
||||
if (!config?.clientId || !config?.clientSecret) {
|
||||
return ctx.redirect('/settings/integrations?google=error');
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
console.error('Google token exchange failed:', await tokenResponse.text());
|
||||
return ctx.redirect('/settings/integrations?google=error');
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
|
||||
// Fetch the user's Google email
|
||||
const userinfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
});
|
||||
|
||||
let googleEmail = email;
|
||||
if (userinfoResponse.ok) {
|
||||
const userinfo = await userinfoResponse.json();
|
||||
googleEmail = userinfo.email ?? email;
|
||||
}
|
||||
|
||||
await writeUserGoogle(email, {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresAt: Date.now() + tokens.expires_in * 1000,
|
||||
email: googleEmail,
|
||||
scope: tokens.scope,
|
||||
});
|
||||
|
||||
return ctx.redirect('/settings/integrations?google=success');
|
||||
};
|
||||
@@ -1,13 +1,33 @@
|
||||
import { join, relative } from "path";
|
||||
import { readdirSync, existsSync, mkdirSync } from "node:fs";
|
||||
import type { Subprocess } from "bun";
|
||||
import type { PiEvent, MessageCost } from "./types";
|
||||
import { readApiKeys } from "../server-settings/pi-mono";
|
||||
import { PI_CONFIG_DIR } from "../../data-path";
|
||||
import { PI_CONFIG_DIR, getGlobalSkillsDir, getUserSkillsDir } from "../../data-path";
|
||||
import { ensureDockerContainer } from "../terminal/websocket";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export type PiEventHandler = (event: PiEvent) => void;
|
||||
|
||||
function collectSkillFlags(email: string): string[] {
|
||||
const flags: string[] = [];
|
||||
const dirs = [getGlobalSkillsDir(), getUserSkillsDir(email)];
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (!existsSync(dir)) continue;
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const skillFile = join(dir, entry.name, 'SKILL.md');
|
||||
if (existsSync(skillFile)) {
|
||||
flags.push('--skill', join(dir, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
type SandboxOptions = {
|
||||
userId: number;
|
||||
username: string;
|
||||
@@ -18,6 +38,7 @@ type SandboxOptions = {
|
||||
export async function spawnPi(
|
||||
cwd: string,
|
||||
model: string,
|
||||
email: string,
|
||||
onEvent: PiEventHandler,
|
||||
sandbox?: SandboxOptions,
|
||||
): Promise<Subprocess> {
|
||||
@@ -59,9 +80,14 @@ export async function spawnPi(
|
||||
logger.info('Spawned Pi in container', { containerId, model });
|
||||
} else {
|
||||
const storedKeys = await readApiKeys();
|
||||
const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes'];
|
||||
const skillFlags = collectSkillFlags(email);
|
||||
const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags];
|
||||
if (model) args.push('--model', model);
|
||||
|
||||
if (!existsSync(cwd)) {
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
}
|
||||
|
||||
proc = Bun.spawn(args, {
|
||||
cwd,
|
||||
stdin: 'pipe',
|
||||
@@ -69,6 +95,8 @@ export async function spawnPi(
|
||||
stderr: 'pipe',
|
||||
env: { ...process.env, ...storedKeys, PI_CODING_AGENT_DIR: PI_CONFIG_DIR },
|
||||
});
|
||||
|
||||
logger.info('Spawned Pi locally', { model, skills: skillFlags.filter((f) => f !== '--skill').length });
|
||||
}
|
||||
|
||||
// Read stdout JSON event stream (runs in background)
|
||||
|
||||
@@ -36,23 +36,25 @@ type WSData = {
|
||||
|
||||
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
const resolveRoot = (email: string, root?: string) => {
|
||||
if (!root || root === 'home') return getHomeDir(email);
|
||||
if (root === '~') return homedir();
|
||||
if (root === 'officer.dev') return resolve(process.cwd(), '..');
|
||||
return getHomeDir(email);
|
||||
const resolveSandboxedCwd = (email: string, cwdRoot?: string, cwd?: string) => {
|
||||
const root = !cwdRoot || cwdRoot === 'home' ? getHomeDir(email) : getHomeDir(email);
|
||||
if (!cwd || cwd === '~') return root;
|
||||
if (cwd.startsWith('~/')) return join(root, cwd.slice(2));
|
||||
if (cwd.startsWith('/')) return join(root, cwd.slice(1));
|
||||
return root;
|
||||
};
|
||||
|
||||
const resolveCwd = (home: string, cwd?: string) => {
|
||||
if (!cwd || cwd === '~') return home;
|
||||
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
|
||||
if (cwd.startsWith('/')) return join(home, cwd.slice(1));
|
||||
return home;
|
||||
const resolveHostCwd = (cwdRoot?: string, cwd?: string) => {
|
||||
if (cwdRoot === 'officer.dev') return resolve(process.cwd(), '..');
|
||||
const root = homedir();
|
||||
if (!cwd || cwd === '~') return root;
|
||||
if (cwd.startsWith('/')) return cwd;
|
||||
if (cwd.startsWith('~/')) return join(root, cwd.slice(2));
|
||||
return join(root, cwd);
|
||||
};
|
||||
|
||||
export const resolveBaseCwd = (email: string, cwdRoot?: string, cwd?: string) => {
|
||||
const root = resolveRoot(email, cwdRoot);
|
||||
return resolveCwd(root, cwd);
|
||||
return resolveHostCwd(cwdRoot, cwd);
|
||||
};
|
||||
|
||||
const wsToSessionMap = new WeakMap<any, string>();
|
||||
@@ -271,11 +273,11 @@ async function handleChat(
|
||||
});
|
||||
|
||||
const homeDir = getHomeDir(email);
|
||||
const rootDir = resolveRoot(email, msg.cwdRoot);
|
||||
const cwd = resolveCwd(rootDir, msg.cwd);
|
||||
const groupSlug = msg.groupSlug || null;
|
||||
|
||||
const sandboxed = msg.sandboxed ?? false;
|
||||
const cwd = sandboxed
|
||||
? resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd)
|
||||
: resolveHostCwd(msg.cwdRoot, msg.cwd);
|
||||
const groupSlug = msg.groupSlug || null;
|
||||
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug);
|
||||
session.sandboxed = sandboxed;
|
||||
session.userId = userId;
|
||||
@@ -286,7 +288,7 @@ async function handleChat(
|
||||
if (!session.piProcess) {
|
||||
try {
|
||||
const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
|
||||
session.piProcess = await piBridge.spawnPi(cwd, model, onEvent, sandboxed ? { userId, username, email, homeDir } : undefined);
|
||||
session.piProcess = await piBridge.spawnPi(cwd, model, email, onEvent, sandboxed ? { userId, username, email, homeDir } : undefined);
|
||||
logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed });
|
||||
} catch (err) {
|
||||
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
|
||||
@@ -355,7 +357,7 @@ async function handleResume(
|
||||
const homeDir = getHomeDir(email);
|
||||
const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined;
|
||||
const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir);
|
||||
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, onEvent, sandbox);
|
||||
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, email, onEvent, sandbox);
|
||||
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed });
|
||||
} catch (err) {
|
||||
logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) });
|
||||
|
||||
@@ -34,19 +34,22 @@ serverSettingsRouter.route('/tts', ttsRouter);
|
||||
serverSettingsRouter.route('/stt', sttRouter);
|
||||
serverSettingsRouter.route('/ocr', ocrRouter);
|
||||
|
||||
const readSettings = async () => {
|
||||
try { return await Bun.file(settingsPath).json(); } catch { return {}; }
|
||||
};
|
||||
|
||||
serverSettingsRouter.get('/settings', async (ctx) => {
|
||||
const settings = await Bun.file(settingsPath).json();
|
||||
return ctx.json(settings);
|
||||
return ctx.json(await readSettings());
|
||||
});
|
||||
|
||||
serverSettingsRouter.get('/onboarding-complete', async (ctx) => {
|
||||
const settings = await Bun.file(settingsPath).json();
|
||||
const settings = await readSettings();
|
||||
return ctx.json({ onboardingComplete: !!settings.onboardingComplete });
|
||||
});
|
||||
|
||||
serverSettingsRouter.put('/', async (ctx) => {
|
||||
const body = await ctx.req.json();
|
||||
const settings = await Bun.file(settingsPath).json();
|
||||
const settings = await readSettings();
|
||||
const updated = { ...settings, ...body };
|
||||
await Bun.write(settingsPath, JSON.stringify(updated, null, 2));
|
||||
return ctx.json(updated);
|
||||
|
||||
@@ -34,7 +34,7 @@ sessionsRouter.get('/sessions/:provider/:id/messages', async (ctx) => {
|
||||
if (provider === 'claude') {
|
||||
const file = Bun.file(join(getSessionDir(email, id), 'messages.json'));
|
||||
if (!(await file.exists())) return ctx.json([]);
|
||||
return ctx.json(await file.json());
|
||||
try { return ctx.json(await file.json()); } catch { return ctx.json([]); }
|
||||
}
|
||||
|
||||
if (provider === 'opencode') {
|
||||
@@ -44,7 +44,7 @@ sessionsRouter.get('/sessions/:provider/:id/messages', async (ctx) => {
|
||||
if (provider === 'pi-mono') {
|
||||
const file = Bun.file(join(getPiMonoSessionDir(email, id), 'messages.json'));
|
||||
if (!(await file.exists())) return ctx.json([]);
|
||||
return ctx.json(await file.json());
|
||||
try { return ctx.json(await file.json()); } catch { return ctx.json([]); }
|
||||
}
|
||||
|
||||
return ctx.json({ error: 'invalid provider' }, 400);
|
||||
@@ -78,7 +78,8 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
|
||||
const dir = getSessionDir(email, id);
|
||||
const metaFile = Bun.file(join(dir, 'meta.json'));
|
||||
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
|
||||
const meta = await metaFile.json();
|
||||
let meta: Record<string, unknown>;
|
||||
try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); }
|
||||
meta.title = body.title.slice(0, 200);
|
||||
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
|
||||
return ctx.json({ ok: true });
|
||||
@@ -88,7 +89,8 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
|
||||
const dir = getOpencodeSessionDir(email, id);
|
||||
const metaFile = Bun.file(join(dir, 'meta.json'));
|
||||
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
|
||||
const meta = await metaFile.json();
|
||||
let meta: Record<string, unknown>;
|
||||
try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); }
|
||||
meta.title = body.title.slice(0, 200);
|
||||
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
|
||||
|
||||
@@ -106,7 +108,8 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
|
||||
const dir = getPiMonoSessionDir(email, id);
|
||||
const metaFile = Bun.file(join(dir, 'meta.json'));
|
||||
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
|
||||
const meta = await metaFile.json();
|
||||
let meta: Record<string, unknown>;
|
||||
try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); }
|
||||
meta.title = body.title.slice(0, 200);
|
||||
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
|
||||
return ctx.json({ ok: true });
|
||||
|
||||
@@ -27,8 +27,11 @@ settingsRouter.get('/settings', async (ctx) => {
|
||||
const file = Bun.file(filePath);
|
||||
|
||||
if (await file.exists()) {
|
||||
const data = await file.json();
|
||||
return ctx.json(data);
|
||||
try {
|
||||
return ctx.json(await file.json());
|
||||
} catch {
|
||||
// corrupted — fall through to defaults
|
||||
}
|
||||
}
|
||||
|
||||
await ensureDir(filePath);
|
||||
@@ -54,8 +57,11 @@ settingsRouter.get('/state', async (ctx) => {
|
||||
const file = Bun.file(filePath);
|
||||
|
||||
if (await file.exists()) {
|
||||
const data = await file.json();
|
||||
return ctx.json(data);
|
||||
try {
|
||||
return ctx.json(await file.json());
|
||||
} catch {
|
||||
// corrupted — fall through to empty
|
||||
}
|
||||
}
|
||||
|
||||
await ensureDir(filePath);
|
||||
@@ -72,7 +78,7 @@ settingsRouter.patch('/state', async (ctx) => {
|
||||
|
||||
let existing: Record<string, unknown> = {};
|
||||
if (await file.exists()) {
|
||||
existing = await file.json();
|
||||
try { existing = await file.json(); } catch { /* corrupted — start fresh */ }
|
||||
}
|
||||
|
||||
const merged = { ...existing, ...body };
|
||||
|
||||
@@ -72,9 +72,13 @@ export function resolveKey(dirs: ResolveDirs, key: string): KeyMapping | null {
|
||||
}
|
||||
|
||||
export async function readJsonFile(path: string): Promise<unknown | null> {
|
||||
const file = Bun.file(path);
|
||||
if (await file.exists()) return file.json();
|
||||
return null;
|
||||
try {
|
||||
const file = Bun.file(path);
|
||||
if (!(await file.exists())) return null;
|
||||
return await file.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeJsonFile(path: string, data: unknown) {
|
||||
@@ -86,7 +90,8 @@ export async function migrateFromState(email: string, dirs: ResolveDirs) {
|
||||
const file = Bun.file(stateFile);
|
||||
if (!(await file.exists())) return;
|
||||
|
||||
const state = (await file.json()) as Record<string, unknown>;
|
||||
let state: Record<string, unknown>;
|
||||
try { state = (await file.json()) as Record<string, unknown>; } catch { return; }
|
||||
const wsKeys = Object.keys(state).filter(
|
||||
(k) => k === 'workspaces' || k.startsWith('ws-layout-') || k.startsWith('ws-terminals-') || k.startsWith('ws-host-terminals-'),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user