feat(daemon): persist code agent startup (#255)

* feat(daemon): persist code agent startup

* fix: complete all suggestions

* fix: types for app config

* chore: revert local origin

* chore: format to single quotes

* fix: duplicate headers

* fix: isLocalSameOrigin rewriting issue

---------

Co-authored-by: mrcfps <mrc@powerformer.com>
This commit is contained in:
Ajay Satish
2026-05-03 12:14:04 +08:00
committed by GitHub
co-authored by mrcfps
parent 9c71fc20eb
commit 9d700ec74f
8 changed files with 1295 additions and 313 deletions
+153
View File
@@ -0,0 +1,153 @@
// Daemon-backed app preferences (onboarding state, agent/skill/DS selection).
//
// The web frontend pushes non-sensitive preferences here via PUT
// /api/app-config; the daemon persists them to <dataDir>/app-config.json
// (where dataDir defaults to <projectRoot>/.od but follows OD_DATA_DIR when
// set, keeping test and multi-namespace runs isolated).
// This survives browser storage resets and origin changes so onboarding
// and agent selection don't reappear unexpectedly.
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import path from 'node:path';
export interface AgentModelPrefs {
model?: string;
reasoning?: string;
}
export interface AppConfigPrefs {
onboardingCompleted?: boolean;
agentId?: string | null;
agentModels?: Record<string, AgentModelPrefs>;
skillId?: string | null;
designSystemId?: string | null;
}
const ALLOWED_KEYS: ReadonlySet<keyof AppConfigPrefs> = new Set([
'onboardingCompleted',
'agentId',
'agentModels',
'skillId',
'designSystemId',
] as const);
function configFile(dataDir: string): string {
return path.join(dataDir, 'app-config.json');
}
const AGENT_MODEL_KEYS: ReadonlySet<string> = new Set(['model', 'reasoning']);
function isValidAgentModelEntry(v: unknown): v is AgentModelPrefs {
if (!v || typeof v !== 'object' || Array.isArray(v)) return false;
const obj = v as Record<string, unknown>;
for (const k of Object.keys(obj)) {
if (!AGENT_MODEL_KEYS.has(k)) return false;
if (obj[k] !== undefined && typeof obj[k] !== 'string') return false;
}
return true;
}
function validateAgentModels(
raw: unknown,
): Record<string, AgentModelPrefs> | undefined {
if (raw === undefined || raw === null) return undefined;
if (typeof raw !== 'object' || Array.isArray(raw)) return undefined;
const result: Record<string, AgentModelPrefs> = Object.create(null);
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
if (k === '__proto__' || k === 'constructor') continue;
if (isValidAgentModelEntry(v)) {
result[k] = v;
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
function applyConfigValue(
target: Record<string, unknown>,
key: keyof AppConfigPrefs,
value: unknown,
): void {
if (key === 'onboardingCompleted') {
if (typeof value === 'boolean') target[key] = value;
return;
}
if (key === 'agentId' || key === 'skillId' || key === 'designSystemId') {
if (typeof value === 'string' || value === null) target[key] = value;
return;
}
if (key === 'agentModels') {
const validated = validateAgentModels(value);
if (validated !== undefined) {
target[key] = validated;
} else {
delete target[key];
}
}
}
function filterAllowedKeys(obj: Record<string, unknown>): AppConfigPrefs {
const result: Record<string, unknown> = Object.create(null);
for (const key of Object.keys(obj)) {
if (ALLOWED_KEYS.has(key as keyof AppConfigPrefs)) {
applyConfigValue(result, key as keyof AppConfigPrefs, obj[key]);
}
}
return result as AppConfigPrefs;
}
export async function readAppConfig(dataDir: string): Promise<AppConfigPrefs> {
try {
const raw = await readFile(configFile(dataDir), 'utf8');
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return filterAllowedKeys(parsed as Record<string, unknown>);
}
console.warn('[app-config] Invalid shape in config file, returning empty');
return {};
} catch (err: unknown) {
const e = err as { code?: string; name?: string; message?: string };
if (e.code === 'ENOENT') return {};
if (e.name === 'SyntaxError') {
console.error('[app-config] Corrupted JSON, returning empty:', e.message);
return {};
}
throw err;
}
}
// Serialize concurrent writes to the same dataDir so the read-modify-write
// cycle doesn't lose updates when two PUT requests overlap.
const writeLocks = new Map<string, Promise<unknown>>();
export async function writeAppConfig(
dataDir: string,
partial: Record<string, unknown>,
): Promise<AppConfigPrefs> {
const prev = writeLocks.get(dataDir) ?? Promise.resolve();
const task = prev.catch(() => {}).then(() => doWrite(dataDir, partial));
writeLocks.set(dataDir, task);
try {
return await task;
} finally {
if (writeLocks.get(dataDir) === task) writeLocks.delete(dataDir);
}
}
async function doWrite(
dataDir: string,
partial: Record<string, unknown>,
): Promise<AppConfigPrefs> {
const existing = await readAppConfig(dataDir);
const next: Record<string, unknown> = { ...existing };
for (const key of Object.keys(partial)) {
if (!ALLOWED_KEYS.has(key as keyof AppConfigPrefs)) continue;
applyConfigValue(next, key as keyof AppConfigPrefs, partial[key]);
}
const file = configFile(dataDir);
await mkdir(path.dirname(file), { recursive: true });
const tmp = file + '.' + randomBytes(4).toString('hex') + '.tmp';
await writeFile(tmp, JSON.stringify(next, null, 2), 'utf8');
await rename(tmp, file);
return next as AppConfigPrefs;
}
+593 -261
View File
File diff suppressed because it is too large Load Diff
+354
View File
@@ -0,0 +1,354 @@
import http from 'node:http';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import express from 'express';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
} from 'vitest';
import { readAppConfig, writeAppConfig } from '../src/app-config.js';
import { isLocalSameOrigin } from '../src/server.js';
describe('app-config', () => {
let dataDir: string;
beforeEach(async () => {
dataDir = await mkdtemp(path.join(tmpdir(), 'od-appconfig-'));
});
afterEach(async () => {
await rm(dataDir, { recursive: true, force: true });
});
describe('readAppConfig', () => {
it('returns {} when config file does not exist', async () => {
expect(await readAppConfig(dataDir)).toEqual({});
});
it('returns parsed config from existing file', async () => {
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({ onboardingCompleted: true }),
);
const cfg = await readAppConfig(dataDir);
expect(cfg.onboardingCompleted).toBe(true);
});
it('returns {} for corrupted JSON without crashing', async () => {
await writeFile(path.join(dataDir, 'app-config.json'), '{not valid');
const cfg = await readAppConfig(dataDir);
expect(cfg).toEqual({});
});
it('returns {} when file contains a JSON array', async () => {
await writeFile(path.join(dataDir, 'app-config.json'), '[1,2,3]');
const cfg = await readAppConfig(dataDir);
expect(cfg).toEqual({});
});
it('returns {} when file contains a JSON primitive', async () => {
await writeFile(path.join(dataDir, 'app-config.json'), '"hello"');
const cfg = await readAppConfig(dataDir);
expect(cfg).toEqual({});
});
it('filters out unknown keys from stored file', async () => {
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({ agentId: 'claude', rogue: 'value', __proto: 'x' }),
);
const cfg = await readAppConfig(dataDir);
expect(cfg).toEqual({ agentId: 'claude' });
expect(cfg).not.toHaveProperty('rogue');
expect(cfg).not.toHaveProperty('__proto');
});
it('filters out invalid scalar values from stored file', async () => {
await writeFile(
path.join(dataDir, 'app-config.json'),
JSON.stringify({
onboardingCompleted: 'yes',
agentId: 123,
skillId: { id: 'bad' },
designSystemId: ['bad'],
}),
);
const cfg = await readAppConfig(dataDir);
expect(cfg).toEqual({});
});
});
describe('writeAppConfig', () => {
it('creates data directory if missing', async () => {
const nested = path.join(dataDir, 'sub', 'dir');
await writeAppConfig(nested, { onboardingCompleted: true });
const cfg = await readAppConfig(nested);
expect(cfg.onboardingCompleted).toBe(true);
});
it('only persists ALLOWED_KEYS, filtering unknown keys', async () => {
await writeAppConfig(dataDir, {
onboardingCompleted: true,
unknownKey: 'should be dropped',
agentId: 'claude',
});
const cfg = await readAppConfig(dataDir);
expect(cfg).toEqual({ onboardingCompleted: true, agentId: 'claude' });
expect(cfg).not.toHaveProperty('unknownKey');
});
it('does not persist invalid scalar values', async () => {
await writeAppConfig(dataDir, {
onboardingCompleted: 'yes',
agentId: 123,
skillId: false,
designSystemId: { id: 'bad' },
});
const cfg = await readAppConfig(dataDir);
expect(cfg).toEqual({});
});
it('merges with existing config', async () => {
await writeAppConfig(dataDir, { agentId: 'claude' });
await writeAppConfig(dataDir, { skillId: 'coder' });
const cfg = await readAppConfig(dataDir);
expect(cfg.agentId).toBe('claude');
expect(cfg.skillId).toBe('coder');
});
it('clears a key when null is sent', async () => {
await writeAppConfig(dataDir, { agentId: 'claude', skillId: 'coder' });
await writeAppConfig(dataDir, { agentId: null });
const cfg = await readAppConfig(dataDir);
expect(cfg.agentId).toBeNull();
expect(cfg.skillId).toBe('coder');
});
it('clears agentModels when null is sent', async () => {
await writeAppConfig(dataDir, {
agentModels: { a: { model: 'gpt-4' } },
onboardingCompleted: true,
});
expect((await readAppConfig(dataDir)).agentModels).toBeDefined();
await writeAppConfig(dataDir, { agentModels: null });
const cfg = await readAppConfig(dataDir);
expect(cfg.agentModels).toBeUndefined();
expect(cfg.onboardingCompleted).toBe(true);
});
it('clears agentModels when empty object is sent', async () => {
await writeAppConfig(dataDir, {
agentModels: { a: { model: 'gpt-4' } },
});
await writeAppConfig(dataDir, { agentModels: {} });
const cfg = await readAppConfig(dataDir);
expect(cfg.agentModels).toBeUndefined();
});
it('validates agentModels entries, dropping invalid shapes', async () => {
await writeAppConfig(dataDir, {
agentModels: {
validAgent: { model: 'gpt-4', reasoning: 'fast' },
invalidAgent: 'not-an-object',
arrayAgent: [1, 2, 3],
badKeys: { model: 'ok', extra: 42 },
},
});
const cfg = await readAppConfig(dataDir);
expect(cfg.agentModels).toEqual({
validAgent: { model: 'gpt-4', reasoning: 'fast' },
});
});
it('drops agentModels entirely when no entries are valid', async () => {
await writeAppConfig(dataDir, {
onboardingCompleted: true,
agentModels: { bad: 'string-value' },
});
const cfg = await readAppConfig(dataDir);
expect(cfg.onboardingCompleted).toBe(true);
expect(cfg.agentModels).toBeUndefined();
});
it('handles corrupted existing file gracefully on write', async () => {
await writeFile(path.join(dataDir, 'app-config.json'), 'CORRUPT');
await writeAppConfig(dataDir, { agentId: 'test' });
const cfg = await readAppConfig(dataDir);
expect(cfg.agentId).toBe('test');
});
});
});
// ---------------------------------------------------------------------------
// HTTP-layer origin guard
// ---------------------------------------------------------------------------
function httpRequest(
url: string,
opts: { method?: string; headers?: Record<string, string>; body?: string },
): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const req = http.request(
{
hostname: parsed.hostname,
port: Number(parsed.port),
path: parsed.pathname,
method: opts.method ?? 'GET',
headers: opts.headers ?? {},
},
(res) => {
let data = '';
res.on('data', (c) => (data += c));
res.on('end', () => resolve({ status: res.statusCode!, body: data }));
},
);
req.on('error', reject);
if (opts.body) req.write(opts.body);
req.end();
});
}
describe('app-config origin guard', () => {
let server: http.Server;
let port: number;
let baseUrl: string;
beforeAll(
() =>
new Promise<void>((resolve) => {
const app = express();
app.use(express.json());
app.get('/api/app-config', (req, res) => {
if (!isLocalSameOrigin(req, port)) {
return res
.status(403)
.json({ error: 'cross-origin request rejected' });
}
res.json({ config: {} });
});
app.put('/api/app-config', (req, res) => {
if (!isLocalSameOrigin(req, port)) {
return res
.status(403)
.json({ error: 'cross-origin request rejected' });
}
res.json({ config: req.body });
});
server = app.listen(0, '127.0.0.1', () => {
port = (server.address() as { port: number }).port;
baseUrl = `http://127.0.0.1:${port}`;
resolve();
});
}),
);
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
it('allows GET from same-origin (no Origin header)', async () => {
const res = await httpRequest(`${baseUrl}/api/app-config`, {
headers: { Host: `127.0.0.1:${port}` },
});
expect(res.status).toBe(200);
});
it('allows PUT from same-origin', async () => {
const res = await httpRequest(`${baseUrl}/api/app-config`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Host: `127.0.0.1:${port}`,
Origin: `http://127.0.0.1:${port}`,
},
body: JSON.stringify({ onboardingCompleted: true }),
});
expect(res.status).toBe(200);
});
it('rejects GET with cross-origin Origin header', async () => {
const res = await httpRequest(`${baseUrl}/api/app-config`, {
headers: {
Host: `127.0.0.1:${port}`,
Origin: 'https://evil.com',
},
});
expect(res.status).toBe(403);
});
it('rejects PUT with cross-origin Origin header', async () => {
const res = await httpRequest(`${baseUrl}/api/app-config`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Host: `127.0.0.1:${port}`,
Origin: 'https://evil.com',
},
body: JSON.stringify({ agentId: 'hacked' }),
});
expect(res.status).toBe(403);
});
it('rejects request with wrong Host header', async () => {
const res = await httpRequest(`${baseUrl}/api/app-config`, {
headers: { Host: 'evil.com:9999' },
});
expect(res.status).toBe(403);
});
it('allows GET when Origin is the trusted web port (proxy flow)', async () => {
const webPort = port + 1;
process.env.OD_WEB_PORT = String(webPort);
try {
const res = await httpRequest(`${baseUrl}/api/app-config`, {
headers: {
Host: `127.0.0.1:${port}`,
Origin: `http://127.0.0.1:${webPort}`,
},
});
expect(res.status).toBe(200);
} finally {
delete process.env.OD_WEB_PORT;
}
});
it('allows PUT when Origin is the trusted web port (proxy flow)', async () => {
const webPort = port + 1;
process.env.OD_WEB_PORT = String(webPort);
try {
const res = await httpRequest(`${baseUrl}/api/app-config`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Host: `127.0.0.1:${port}`,
Origin: `http://localhost:${webPort}`,
},
body: JSON.stringify({ onboardingCompleted: true }),
});
expect(res.status).toBe(200);
} finally {
delete process.env.OD_WEB_PORT;
}
});
it('still rejects cross-origin even when OD_WEB_PORT is set', async () => {
process.env.OD_WEB_PORT = String(port + 1);
try {
const res = await httpRequest(`${baseUrl}/api/app-config`, {
headers: {
Host: `127.0.0.1:${port}`,
Origin: 'https://evil.com',
},
});
expect(res.status).toBe(403);
} finally {
delete process.env.OD_WEB_PORT;
}
});
});
+118 -43
View File
@@ -4,7 +4,10 @@ import type { CreateInput } from './components/NewProjectPanel';
import { PetOverlay } from './components/pet/PetOverlay';
import { migrateCustomPetAtlas } from './components/pet/pets';
import { ProjectView } from './components/ProjectView';
import { SettingsDialog, type SettingsSection } from './components/SettingsDialog';
import {
SettingsDialog,
type SettingsSection,
} from './components/SettingsDialog';
import {
daemonIsLive,
fetchAppVersionInfo,
@@ -15,10 +18,12 @@ import {
} from './providers/registry';
import { navigate, useRoute } from './router';
import {
fetchDaemonConfig,
DEFAULT_PET,
hasAnyConfiguredProvider,
loadConfig,
saveConfig,
syncConfigToDaemon,
syncMediaProvidersToDaemon,
} from './state/config';
import {
@@ -44,17 +49,21 @@ export function App() {
const [config, setConfig] = useState<AppConfig>(() => loadConfig());
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsWelcome, setSettingsWelcome] = useState(false);
const [settingsSection, setSettingsSection] = useState<SettingsSection | undefined>(
undefined,
);
const [settingsSection, setSettingsSection] = useState<
SettingsSection | undefined
>(undefined);
const [daemonLive, setDaemonLive] = useState(false);
const [agents, setAgents] = useState<AgentInfo[]>([]);
const [skills, setSkills] = useState<SkillSummary[]>([]);
const [designSystems, setDesignSystems] = useState<DesignSystemSummary[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [templates, setTemplates] = useState<ProjectTemplate[]>([]);
const [promptTemplates, setPromptTemplates] = useState<PromptTemplateSummary[]>([]);
const [appVersionInfo, setAppVersionInfo] = useState<AppVersionInfo | null>(null);
const [promptTemplates, setPromptTemplates] = useState<
PromptTemplateSummary[]
>([]);
const [appVersionInfo, setAppVersionInfo] = useState<AppVersionInfo | null>(
null,
);
// Goes false once the bootstrap effect has finished its initial round of
// fetches. The entry view uses this to show shimmer / skeleton states
// instead of an "empty" page that flickers before data lands.
@@ -81,18 +90,29 @@ export function App() {
const alive = await daemonIsLive();
if (cancelled) return;
setDaemonLive(alive);
const [agentList, skillList, dsList, projectList, templateList, promptTemplateList, versionInfo] =
await Promise.all([
alive ? fetchAgents() : Promise.resolve([] as AgentInfo[]),
alive ? fetchSkills() : Promise.resolve([] as SkillSummary[]),
alive
? fetchDesignSystems()
: Promise.resolve([] as DesignSystemSummary[]),
alive ? listProjects() : Promise.resolve([] as Project[]),
alive ? listTemplates() : Promise.resolve([] as ProjectTemplate[]),
alive ? fetchPromptTemplates() : Promise.resolve([] as PromptTemplateSummary[]),
alive ? fetchAppVersionInfo() : Promise.resolve(null),
]);
const [
agentList,
skillList,
dsList,
projectList,
templateList,
promptTemplateList,
versionInfo,
daemonConfig,
] = await Promise.all([
alive ? fetchAgents() : Promise.resolve([] as AgentInfo[]),
alive ? fetchSkills() : Promise.resolve([] as SkillSummary[]),
alive
? fetchDesignSystems()
: Promise.resolve([] as DesignSystemSummary[]),
alive ? listProjects() : Promise.resolve([] as Project[]),
alive ? listTemplates() : Promise.resolve([] as ProjectTemplate[]),
alive
? fetchPromptTemplates()
: Promise.resolve([] as PromptTemplateSummary[]),
alive ? fetchAppVersionInfo() : Promise.resolve(null),
alive ? fetchDaemonConfig() : Promise.resolve(null),
]);
if (cancelled) return;
setAgents(agentList);
setSkills(skillList);
@@ -104,14 +124,38 @@ export function App() {
setConfig((prev) => {
const next = { ...prev };
// Merge daemon-persisted config — daemon values win for the fields
// it tracks so that the choice survives origin/storage resets.
if (daemonConfig) {
if (daemonConfig.onboardingCompleted != null) {
next.onboardingCompleted = daemonConfig.onboardingCompleted;
}
if (daemonConfig.agentId !== undefined) {
next.agentId = daemonConfig.agentId;
}
if (daemonConfig.skillId !== undefined) {
next.skillId = daemonConfig.skillId;
}
if (daemonConfig.designSystemId !== undefined) {
next.designSystemId = daemonConfig.designSystemId;
}
if (daemonConfig.agentModels) {
next.agentModels = {
...(next.agentModels ?? {}),
...daemonConfig.agentModels,
};
}
}
if (alive) {
if (!next.agentId) {
const firstAvailable = agentList.find((a) => a.available);
if (firstAvailable) next.agentId = firstAvailable.id;
}
if (!next.designSystemId && dsList.length > 0) {
next.designSystemId = dsList.find((d) => d.id === 'default')?.id
?? dsList[0]!.id;
next.designSystemId =
dsList.find((d) => d.id === 'default')?.id ?? dsList[0]!.id;
}
} else {
next.mode = 'api';
@@ -120,6 +164,12 @@ export function App() {
if (alive && hasAnyConfiguredProvider(next.mediaProviders)) {
void syncMediaProvidersToDaemon(next.mediaProviders);
}
// Migrate localStorage prefs to daemon on first boot with the new
// endpoint. If daemon already had values the merge above used them;
// writing back is idempotent and ensures both sides stay in sync.
if (alive) {
void syncConfigToDaemon(next);
}
// Pop the onboarding modal only on the first run. Once the user has
// saved or skipped past it once, we trust their stored config and
@@ -181,7 +231,10 @@ export function App() {
// configuration, so future page loads can skip the auto-popup.
const withOnboarding: AppConfig = { ...next, onboardingCompleted: true };
saveConfig(withOnboarding);
void syncMediaProvidersToDaemon(withOnboarding.mediaProviders, { force: true });
void syncMediaProvidersToDaemon(withOnboarding.mediaProviders, {
force: true,
});
void syncConfigToDaemon(withOnboarding);
setConfig(withOnboarding);
setSettingsOpen(false);
}, []);
@@ -199,6 +252,7 @@ export function App() {
(agentId: string) => {
const next = { ...config, agentId };
saveConfig(next);
void syncConfigToDaemon(next);
setConfig(next);
},
[config],
@@ -208,9 +262,13 @@ export function App() {
(agentId: string, choice: { model?: string; reasoning?: string }) => {
const prev = config.agentModels?.[agentId] ?? {};
const merged = { ...prev, ...choice };
const nextAgentModels = { ...(config.agentModels ?? {}), [agentId]: merged };
const nextAgentModels = {
...(config.agentModels ?? {}),
[agentId]: merged,
};
const next = { ...config, agentModels: nextAgentModels };
saveConfig(next);
void syncConfigToDaemon(next);
setConfig(next);
},
[config],
@@ -220,6 +278,7 @@ export function App() {
(designSystemId: string) => {
const next = { ...config, designSystemId };
saveConfig(next);
void syncConfigToDaemon(next);
setConfig(next);
},
[config],
@@ -244,8 +303,15 @@ export function App() {
metadata: input.metadata,
});
if (!result) return;
setProjects((curr) => [result.project, ...curr.filter((p) => p.id !== result.project.id)]);
navigate({ kind: 'project', projectId: result.project.id, fileName: null });
setProjects((curr) => [
result.project,
...curr.filter((p) => p.id !== result.project.id),
]);
navigate({
kind: 'project',
projectId: result.project.id,
fileName: null,
});
},
[],
);
@@ -253,7 +319,10 @@ export function App() {
const handleImportClaudeDesign = useCallback(async (file: File) => {
const result = await importClaudeDesignZip(file);
if (!result) return;
setProjects((curr) => [result.project, ...curr.filter((p) => p.id !== result.project.id)]);
setProjects((curr) => [
result.project,
...curr.filter((p) => p.id !== result.project.id),
]);
navigate({
kind: 'project',
projectId: result.project.id,
@@ -265,22 +334,24 @@ export function App() {
navigate({ kind: 'project', projectId: id, fileName: null });
}, []);
const handleDeleteProject = useCallback(async (id: string) => {
const ok = await deleteProjectApi(id);
if (!ok) return;
setProjects((curr) => curr.filter((p) => p.id !== id));
if (route.kind === 'project' && route.projectId === id) {
navigate({ kind: 'home' });
}
}, [route]);
const handleDeleteProject = useCallback(
async (id: string) => {
const ok = await deleteProjectApi(id);
if (!ok) return;
setProjects((curr) => curr.filter((p) => p.id !== id));
if (route.kind === 'project' && route.projectId === id) {
navigate({ kind: 'home' });
}
},
[route],
);
const handleBack = useCallback(() => {
navigate({ kind: 'home' });
}, []);
const handleClearPendingPrompt = useCallback(() => {
const projectId =
route.kind === 'project' ? route.projectId : null;
const projectId = route.kind === 'project' ? route.projectId : null;
if (!projectId) return;
setProjects((curr) =>
curr.map((p) =>
@@ -291,8 +362,7 @@ export function App() {
}, [route]);
const handleTouchProject = useCallback(() => {
const projectId =
route.kind === 'project' ? route.projectId : null;
const projectId = route.kind === 'project' ? route.projectId : null;
if (!projectId) return;
const updatedAt = Date.now();
setProjects((curr) =>
@@ -302,14 +372,12 @@ export function App() {
}, [route]);
const handleProjectChange = useCallback((updated: Project) => {
setProjects((curr) =>
curr.map((p) => (p.id === updated.id ? updated : p)),
);
setProjects((curr) => curr.map((p) => (p.id === updated.id ? updated : p)));
}, []);
const activeProject =
route.kind === 'project'
? projects.find((p) => p.id === route.projectId) ?? null
? (projects.find((p) => p.id === route.projectId) ?? null)
: null;
// Deep-linked route to a project we don't have yet (e.g. after a refresh
@@ -359,13 +427,19 @@ export function App() {
});
}, []);
const handleTuckPet = useCallback(() => handleSetPetEnabled(false), [handleSetPetEnabled]);
const handleTuckPet = useCallback(
() => handleSetPetEnabled(false),
[handleSetPetEnabled],
);
// Toggle wake/tuck — used by the pet rail and the composer button.
const handleTogglePet = useCallback(() => {
setConfig((curr) => {
const prev = curr.pet ?? DEFAULT_PET;
const next: AppConfig = { ...curr, pet: { ...prev, enabled: !prev.enabled } };
const next: AppConfig = {
...curr,
pet: { ...prev, enabled: !prev.enabled },
};
saveConfig(next);
return next;
});
@@ -465,6 +539,7 @@ export function App() {
if (settingsWelcome && !config.onboardingCompleted) {
const next: AppConfig = { ...config, onboardingCompleted: true };
saveConfig(next);
void syncConfigToDaemon(next);
setConfig(next);
}
setSettingsOpen(false);
+56 -5
View File
@@ -1,5 +1,11 @@
import type { AppConfigPrefs } from '@open-design/contracts';
import { isOpenAICompatible } from '../providers/openai-compatible';
import type { ApiProtocol, AppConfig, MediaProviderCredentials, PetConfig } from '../types';
import type {
ApiProtocol,
AppConfig,
MediaProviderCredentials,
PetConfig,
} from '../types';
const STORAGE_KEY = 'open-design:config';
const CONFIG_MIGRATION_VERSION = 1;
@@ -74,7 +80,12 @@ export const KNOWN_PROVIDERS: KnownProvider[] = [
protocol: 'anthropic',
baseUrl: 'https://api.deepseek.com/anthropic',
model: 'deepseek-chat',
models: ['deepseek-chat', 'deepseek-reasoner', 'deepseek-v4-flash', 'deepseek-v4-pro'],
models: [
'deepseek-chat',
'deepseek-reasoner',
'deepseek-v4-flash',
'deepseek-v4-pro',
],
},
{
label: 'MiniMax — Anthropic',
@@ -103,7 +114,12 @@ export const KNOWN_PROVIDERS: KnownProvider[] = [
protocol: 'openai',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-chat',
models: ['deepseek-chat', 'deepseek-reasoner', 'deepseek-v4-flash', 'deepseek-v4-pro'],
models: [
'deepseek-chat',
'deepseek-reasoner',
'deepseek-v4-flash',
'deepseek-v4-pro',
],
},
{
label: 'MiniMax — OpenAI',
@@ -163,7 +179,10 @@ export function loadConfig(): AppConfig {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_CONFIG, pet: normalizePet(DEFAULT_PET) };
const parsed = JSON.parse(raw) as Partial<AppConfig>;
const parsedHasApiProtocol = Object.prototype.hasOwnProperty.call(parsed, 'apiProtocol');
const parsedHasApiProtocol = Object.prototype.hasOwnProperty.call(
parsed,
'apiProtocol',
);
const merged: AppConfig = {
...DEFAULT_CONFIG,
...parsed,
@@ -183,7 +202,9 @@ export function loadConfig(): AppConfig {
// whether the user is on a known provider and switch defaults appropriately.
// null means "custom/unknown provider" so the protocol switch won't override
// their custom base URL.
const knownProvider = KNOWN_PROVIDERS.find((p) => p.baseUrl === merged.baseUrl);
const knownProvider = KNOWN_PROVIDERS.find(
(p) => p.baseUrl === merged.baseUrl,
);
merged.apiProviderBaseUrl = knownProvider?.baseUrl ?? null;
}
merged.configMigrationVersion = CONFIG_MIGRATION_VERSION;
@@ -223,3 +244,33 @@ export async function syncMediaProvidersToDaemon(
// Daemon offline; localStorage keeps the user's copy for the next save.
}
}
export async function fetchDaemonConfig(): Promise<AppConfigPrefs | null> {
try {
const res = await fetch('/api/app-config');
if (!res.ok) return null;
const data = await res.json();
return data?.config ?? null;
} catch {
return null;
}
}
export async function syncConfigToDaemon(config: AppConfig): Promise<void> {
const prefs: AppConfigPrefs = {
onboardingCompleted: config.onboardingCompleted,
agentId: config.agentId,
agentModels: config.agentModels,
skillId: config.skillId,
designSystemId: config.designSystemId,
};
try {
await fetch('/api/app-config', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(prefs),
});
} catch {
// Daemon offline; localStorage keeps the user's copy for the next save.
}
}
+2 -4
View File
@@ -1,5 +1,6 @@
import type {
AgentInfo,
AgentModelPrefs,
AppVersionInfo,
AppVersionResponse,
AudioKind,
@@ -46,10 +47,7 @@ export interface MediaProviderCredentials {
// keeps its own slot so flipping between Codex and Gemini doesn't reset the
// other one's choice. Missing entries fall back to the agent's first
// declared model (`'default'` — let the CLI pick).
export interface AgentModelChoice {
model?: string;
reasoning?: string;
}
export type AgentModelChoice = AgentModelPrefs;
export type AppTheme = 'system' | 'light' | 'dark';
+18
View File
@@ -0,0 +1,18 @@
export interface AgentModelPrefs {
model?: string;
reasoning?: string;
}
export interface AppConfigPrefs {
onboardingCompleted?: boolean;
agentId?: string | null;
agentModels?: Record<string, AgentModelPrefs>;
skillId?: string | null;
designSystemId?: string | null;
}
export interface AppConfigResponse {
config: AppConfigPrefs;
}
export type UpdateAppConfigRequest = Partial<AppConfigPrefs>;
+1
View File
@@ -1,6 +1,7 @@
export * from './common';
export * from './errors';
export * from './tasks';
export * from './api/app-config';
export * from './api/artifacts';
export * from './api/chat';
export * from './api/comments';