99 KiB
name, version, description
| name | version | description |
|---|---|---|
| atomk-desktop-dev | 1.0 | Modifying AtomK-Desktop Electron app — IPC, i18n, components, build patterns and pitfalls |
AtomK Desktop Development
Modifying the AtomK-Desktop Electron app. The primary working directory is /home/ubuntu/AtomK-Desktop/ (Gitea remote origin at https://gitea9webs.sh3.ikuai7.com/admin9webs/AtomK-Desktop.git, branch main).
Architecture Overview
- Electron + Vite:
electron-vitefor build/dev (v3.6.0+) - Three layers:
src/main/(Node),src/preload/(bridge),src/renderer/(React/Vite) - i18n:
src/shared/i18n/— locale files per domain inlocales/{en,zh-CN}/, registered insrc/shared/i18n/index.ts - IPC pattern: Renderer calls
window.hermesAPI.xxx(), preload exposes viaipcRenderer.invoke("channel"), main registersipcMain.handle("channel", handler) - Type declarations:
src/preload/index.d.ts—HermesAPIinterface must match preload API exactly - Sidebar:
src/renderer/src/screens/Layout/Layout.tsx— 4NavGroupblocks with dividers; NO collapsible groups (Accounts was flattened to a single nav item with tabbed content inside the page) - Nav groups: (1) Chat/Sessions/Prompts (2) Skills/Tools/Files/Accounts (3) Products/Listings/Posts/Mail — all AtomListing API backend except Mail (coming soon) and Sessions (local SQLite via better-sqlite3或远程 via AtomK Bridge API) (4) Schedules/Cloud Bridge/Settings
- Screens:
src/renderer/src/screens/— one folder per route
Welcome Screen & Startup Flow
App.tsx startup priority (v3.8.7+)
The App.tsx startup check (runStartupCheck) follows this priority:
- Atomlisting auth → Check
operationGetAuth()for saved token- If token exists → call
operationApplyAgent()to configure Bridge from serverbridges[]list - If apply-agent succeeds → go to main (Bridge configured)
- If apply-agent fails → still go to main with warning banner (Bridge unreachable, user can reconnect from Settings)
- Never block authenticated users at Welcome screen due to Bridge connectivity
- If token exists → call
- Legacy connection config → Fallback for existing users who set up Remote/SSH before auth was added
- SSH mode → start tunnel → go to main
- Remote URL → test connection → go to main
- No auth, no connection → show Welcome (login screen)
Welcome screen (v3.9.3+)
The Welcome screen (src/renderer/src/screens/Welcome/Welcome.tsx) shows only an Atomlisting.com login form:
- Username + Password fields
- "Sign In" button → calls
window.hermesAPI.operationLogin() - On success: calls
window.hermesAPI.operationApplyAgent()to auto-configure Bridge from the server'sbridges[]list, thenonRecheck() - On failure: show localized error (invalid credentials / network error / generic)
- ⚠️ Do NOT use
instance.base_urlas the Bridge URL — it's the Agent server, not the Bridge. See pitfall below.
⚠️ Do NOT add SSH or Remote connection options to the Welcome screen. New users should only see the Atomlisting.com login. The old SSH/Remote connection modes are:
- Only accessible via Settings → Connection → Advanced (for power users)
- Automatically used as fallback in App.tsx startup if atomlisting auth is absent but legacy config exists
- Never shown as a choice to a brand-new user
i18n keys for Welcome login
| Key | EN | zh-CN |
|---|---|---|
welcome.loginTitle |
Sign in to AtomK | 登录 AtomK |
welcome.loginSubtitle |
Enter your Atomlisting.com account to get started. | 请输入你的 Atomlisting.com 账号以开始使用。 |
welcome.username |
Username | 用户名 |
welcome.password |
Password | 密码 |
welcome.login |
Sign In | 登录 |
welcome.loggingIn |
Signing in | 登录中 |
welcome.loginRequired |
Please enter both username and password. | 请输入用户名和密码。 |
welcome.loginInvalid |
Invalid username or password. | 用户名或密码错误。 |
welcome.loginNetworkError |
Cannot connect to the server. Please check your network. | 无法连接服务器,请检查网络。 |
welcome.loginHint |
Use the same account as atomlisting.com... | 使用 atomlisting.com 的账号登录... |
Adding New Feature (e.g. new tab in existing screen)
1. Create component files in screen folder
src/renderer/src/screens/ScreenName/NewFeature.tsx
2. Add i18n translations
- Create
src/shared/i18n/locales/{en,zh-CN}/domain.ts - CRITICAL: When editing
src/shared/i18n/index.ts, do NOT useread_fileoutput for patch/replace — it contains line-number prefixes that corrupt the file. Use Python string replacement on raw file content:
with open('src/shared/i18n/index.ts', 'r') as f:
content = f.read()
content = content.replace(old_line, new_line)
with open('src/shared/i18n/index.ts', 'w') as f:
f.write(content)
- Add import after each locale's last import:
import domainXx from "./locales/xx/domain"; - Add key to each locale's translation object:
domain: domainXx,
Removing Locales (i18n pruning workflow)
When reducing supported locales, follow this exact sequence to avoid cascading errors:
types.ts— PruneAppLocaleunion type (e.g."en" | "zh-CN")config.ts— PruneAPP_LOCALESarray (e.g.["en", "zh-CN"])index.ts— Remove all imports andresourcesblocks for deleted locales. Use Python file I/O (notread_file+patch) to avoid line-number contamination.- Delete locale directories —
rm -rf src/shared/i18n/locales/{es,id,ja,pt-BR}(or whichever are being removed) Settings.tsx— PruneLANGUAGE_NATIVE_NAMESto match remaining locales- Tests — Update
index.test.tsandI18nProvider.test.tsxto use remaining locales instead of deleted ones - Verify:
npx tsc --noEmit— must be zero errors - Git: Stage all changes including deletions (
git add -A)
3. Add preload IPC APIs
In src/preload/index.ts, add methods to the hermesAPI object:
featureMethod: (param: Type): Promise<Result> =>
ipcRenderer.invoke("feature:method", param),
4. Add type declarations
In src/preload/index.d.ts, add matching signatures to HermesAPI interface.
5. Add main process handlers
In src/main/index.ts:
ipcMain.handle("feature:method", async (_e, data) => { ... });
Import implementation functions from a dedicated module like src/main/feature.ts.
Build & Test
cd /home/ubuntu/hermes-desktop
# Typecheck Node layer only (fast)
npx tsc --noEmit -p tsconfig.node.json --composite false
# Full build (use Node API — CLI hangs in some environments)
node -e "const {build} = require('electron-vite'); build().then(() => console.log('OK')).catch(e => console.error(e.message))"
TypeScript prerequisite
If tsc not found: npm install typescript --save-dev
Cloud Bridge Tab Refactoring Pattern
When ChromeBridge.tsx grew to 904 lines, it was refactored into a tab-based layout:
- BridgeConnection.tsx — relay server, extension, CDP browser connection logic (extracted from original)
- CookieManager.tsx — cookie CRUD, search, import/export, cross-domain transfer
- AutomationScripts.tsx — script discovery, run/stop, logs, variable management
- ChromeBridge.tsx — thin tab container (441 lines) importing the 3 sub-components
IPC channels added for cookies and scripts via chrome-bridge channel pattern:
// Preload
chromeBridgeGetCookies: (domain: string) => ipcRenderer.invoke("chrome-bridge", { action: "getCookies", domain }),
// Main handler delegates to automation.ts module
ipcMain.handle("chrome-bridge", async (_e, data) => { ... });
Backend module src/main/automation.ts implements CDP-based cookie read/write and Python script execution via child_process.
Global Brand / Cross-cutting Renames
When renaming a string that appears across all i18n locales + main process + renderer:
- Search broadly first:
grep -rn 'OldName' src/ --include='*.ts' --include='*.tsx'to find all hits. - Batch replace with exclusions: Use
sed -i 's/OldName/NewName/g'on matched files, excluding any locale-specific overrides (e.g. zh-CN/common.ts if it needs a different translation like "原子列智能体" instead of "AtomK AI Agent"). - Key locations to check:
src/renderer/src/screens/Layout/Layout.tsx— sidebar brand name (often hardcoded, not i18n-keyed)src/shared/i18n/locales/{en,zh-CN,es,id,ja,pt-BR}/common.ts—appNamekeysrc/shared/i18n/locales/*/install.ts—installingAtomKkeysrc/shared/i18n/locales/*/settings.ts—hermesAgentkeysrc/main/installer.ts— update/download dialog titlessrc/main/index.ts— remote update dialog title
- Verify:
grep -rn 'OldName' src/andnpx tsc --noEmitafter changes.
Chat /status command (local, v3.9.0+)
/status is a local slash command (runs in renderer, no backend call). Output:
Agent Status
- Connection: remote
- Remote URL: ws://49.51.249.171:9228/ws
- Model: hermes-agent (or "not set")
- Provider: auto
- Session: <hermesSessionId> (or "none")
- Messages: <count>
- Loading: yes/no
When "Model: not set" appears, it means getModelConfig().model is empty (config.yaml has no default: key). The chat fallback mc.model || "hermes-agent" may still work if the Bridge's Hermes backend accepts that model name, but the UI won't show a selected model in ModelPicker.
Bridge /v1/models endpoint (v4.0)
Bridge v4.0 serves OpenAI-compatible /v1/models on the same HTTP port (9228). Returns:
{"object": "list", "data": [{"id": "hermes-agent", "object": "model", ...}]}
Desktop uses this in applyAgent to auto-select a default model. The endpoint requires Authorization: Bearer <bridge-key>. If the Bridge doesn't serve this endpoint (v3.0), the fetch silently fails and the user must manually pick a model.
Model config flow
getModelConfig() reads from config.yaml:
provider:→mc.provider(default:"auto")default:→mc.model(default:"")base_url:→mc.baseUrl(default:"")
setModelConfig(provider, model, baseUrl) writes these fields back. The useModelConfig React hook in Chat reads this via IPC on mount and whenever profile changes.
Model auto-detect in CloudBridge remote mode (v3.9.7+)
Problem: Desktop in CloudBridge remote mode read local model config (which was empty after fresh install), never called Bridge /v1/models to auto-select a model. Chat fell back to mc.model || "hermes-agent" which may not exist on the server.
Solution (two-layer):
-
Startup (
applyAgentinindex.ts): After connecting Bridge, fetch/v1/modelsand callsetModelConfig("auto", modelId, httpUrl)if no model configured. This has existed since v3.9.6. -
On-demand (
get-model-configIPC handler): WhengetModelConfig()returns an emptymodelANDconn.cloudBridgeUrl+conn.apiKeyare available, automatically fetch/v1/modelsfrom the Bridge, cache the first available model viasetModelConfig(), and return the updated config. This ensures that even if the startup fetch failed (e.g. Bridge was slow), subsequent UI queries for model config will still auto-detect. The handler was changed from sync toasyncfor this reason.
Key code pattern (in index.ts get-model-config handler):
const mc = getModelConfig(profile);
if (!mc.model && conn.cloudBridgeUrl && conn.apiKey) {
const httpUrl = conn.cloudBridgeUrl
.replace(/^wss:\\/\\//i, "https://")
.replace(/^ws:\\/\\//i, "http://")
.replace(/\\/ws$/, "");
const resp = await fetch(`${httpUrl}/v1/models`, {
headers: { Authorization: `Bearer ${conn.apiKey}` },
signal: AbortSignal.timeout(8000),
});
// ... parse and setModelConfig
}
⚠️ apiKey = b.key (Bridge key), NOT agent_api_key: The conn.apiKey used in Authorization: Bearer ${conn.apiKey} is the Bridge's own key (b.key from the server bridges list). It is NOT agent_api_key. Bridge's hermes_api_proxy_handler strips all incoming Authorization headers and injects its own HERMES_API_KEY before forwarding to Gateway. So Desktop only needs Bridge key to pass check_auth() — Bridge handles Gateway auth automatically. Using agent_api_key would be incorrect and counterproductive (Bridge strips it anyway).
⚠️ Prerequisite: getApiUrl() must also resolve cloudBridgeUrl (ws→http) — see "Remote API URL Resolution" section. Before v3.9.7, the chat streaming function used getApiUrl() which only checked conn.remoteUrl, so in CloudBridge mode it threw "No remote URL configured" — breaking chat entirely.
Build & Deploy Workflow
Per user preference:
- Always push to Gitea first after code changes:
git add -A && git commit && git push atomk main - Only build when user explicitly says "build" — do NOT auto-build after every change.
- When building:
npm run build:win, then upload the exe to COS:coscmd upload dist/atomk-desktop-{version}-setup.exe atomk-desktop-{version}-setup.exe(bucket=9websclub-1251422183, region=ap-hongkong). - Working directory is
/home/ubuntu/AtomK-Desktop(local branchmain, remoteorigin/main). - Branch consolidation: The old
clean-mainandorigin/mainhad completely independent histories (no common ancestor).clean-mainwas force-pushed as the newmainto all remotes. Old branches (clean-main, master, merge-target) were deleted. When encountering divergent branches with no common ancestor, confirm with user — force-push one branch as canonical is often simplest.
Known Code Issues (v3.5.0 Review)
From comprehensive code review of main分支 edb2d91:
P0 — Security Critical
- Relay Server无认证:
chrome-bridge.tsExpress+WS服务器零认证,任何本地进程可连接执行CDP命令。需加API key或HMAC签名。 - security.ts仅77行: CSP策略宽松,webviewTag:true开启但只允许localhost http,无速率限制、无CORS限制、无输入消毒。需大幅加固。
- 硬编码Cloud Bridge提示词:
hermes.ts内置系统提示词,任何代码改动可注入恶意指令。
P1 — Architecture
- index.ts 1704行: ~80个IPC handler全在一个文件,应按功能拆分(chrome-bridge, automation, config等)。
- chrome-bridge.ts 1472行: CDP直连+Express+WS+Extension混合,应拆分为relay-server.ts, cdp-client.ts, extension-handler.ts。
- snapshotPool内存泄漏: 截图buffer缓存无上限无过期,长运行会吃内存。需LRU+TTL淘汰。
P2 — Incomplete Features
- 6个"Coming Soon"占位: Mail/Listings/Products/Stores/Social/Posts screens,Layout.tsx中有导航入口但无实际功能。
- ChromeBridge.tsx: 大量内联CSS样式(~200行),应抽取为CSS Modules/Tailwind。
P2 — Build/Config
electron-builder.ymlforceCodeSigning:false,生产构建未强制签名。- 敏感数据(API keys, tokens)明文存储在desktop.json,无加密。
Adding a Sidebar Module
To add a new navigation item and its screen:
-
Create the screen component at
src/renderer/src/screens/ScreenName/ScreenName.tsx. Use thescreen-placeholderclass for coming-soon pages — it now has flex centering built in:import { useI18n } from "../../components/useI18n"; import { IconName } from "lucide-react"; function ScreenName(): React.JSX.Element { const { t } = useI18n(); return ( <div className="screen-placeholder"> <IconName size={48} strokeWidth={1.2} /> <h2>{t("navigation.screenKey")}</h2> <p>Coming soon.</p> </div> ); } export default ScreenName; -
Add i18n navigation key in both locale files:
src/shared/i18n/locales/{en,zh-CN}/navigation.ts- Add
screenKey: "Label"beforesettingskey - Mail EN label: Use
"Mail"NOT"Email"(user preference)
-
Update Layout.tsx — 5 changes:
- Import the screen component
- Import the icon from
lucide-react(checksrc/renderer/src/assets/icons/index.tsxfirst — some icons are re-exported with aliases there; import from whichever path avoids duplicates) - Add
| "screenKey"to theViewtype union - Add nav item to the appropriate
NavGroupinNAV_ENTRIES(or create a newNavCollapsiblegroup withparentLabelKey/parentIcon/children/childViews, optionally with flatitemssiblings) - Collapsible groups use
expandedGroups: Set<string>state (keyed byparentLabelKey), auto-expand on child navigation, toggle on parent click - Flat
itemson aNavCollapsibleshare the same visual group (no divider) — useful for merging business items under an accounts umbrella - Add
{visitedViews.has("screenKey") && (<div style={paneStyle("screenKey")}><ScreenName /></div>)}in the correct position in the JSX
-
Verify:
npx tsc --noEmit— zero errors.
Removing a Sidebar Module
To completely remove a navigation item and its screen:
-
Layout.tsx — 4 changes needed:
- Remove the component
importat the top - Remove the
Viewtype union member (e.g.| "office") - Remove the entry from
NAV_ENTRIES(from the relevantNavGroup.itemsorNavCollapsible.children; if it was the last child in aNavCollapsible, remove the whole collapsible entry) - Remove the
{visitedViews.has("xxx") && (...)}render block - Clean up any now-unused icon imports (e.g.
Buildingwas only used by Office) - Clean up blank lines left by deletions
- Remove the component
-
TypeScript verify:
npx tsc --noEmit— must be zero errors after removal. -
Don't delete the screen folder (e.g.
src/renderer/src/screens/Office/) — leave it in case it's needed later. Only disconnect it from Layout.
Merging atomk/main into local main
The remote atomk/main branch receives independent commits. To merge them into local main:
git fetch atomk # fetch all remote branches
git log atomk/main --oneline -10 # check what's new
git merge <commit-hash> -X theirs --no-edit # merge with auto-resolve preferring theirs
Post-merge fixes required (auto-resolve is unreliable):
-
Icon files —
build/icon.ico,build/icon.png,resources/icon.pngget overwritten with old icons. Immediately restore:git checkout HEAD~1 -- build/icon.ico build/icon.png resources/icon.png git add build/icon.ico build/icon.png resources/icon.png -
chromeBridge.tsx JSX corruption — When both branches restructured the same JSX region,
-X theirscan interleave unrelated fragments (e.g. Page Snapshot code inside Cloud Bridge<>...</>). Must manually inspect and fix:- Look for stray
</div>or orphaned{...}( - Ensure
function ChromeBridgematchesexport default ChromeBridge(merge can produceexport default CloudBridge)
- Look for stray
-
hermes.png (sidebar logo) gets reverted —
-X theirsrestores the old light-blue logo. Must copy our new green logo back:cp build/icon.png src/renderer/src/assets/hermes.png git add src/renderer/src/assets/hermes.png -
TypeScript check before build — Always run
npx tsc --noEmitafter merge before building. Don't trust the merge to be clean. -
QuickPrompts password desensitization — Merge may restore plaintext passwords. Verify
***count stays at 7. -
Products page
img.startsWith is not a function—ProductItem.imagesis typed asstring[]but claimed products returnProductImage[](objects withimage_url). ThegetImageUrl()helper must do a runtimetypeof img === "object"check before calling.startsWith():const img = product.images[0]; if (typeof img === "object" && img !== null) { const url = (img as ProductImage).image_url; if (url?.startsWith("http")) return url; return url ? `${COS_URL}/${url}` : null; } // string path if ((img as string).startsWith("http")) return img as string; return `${COS_URL}/${img}`;
- Hermes security interceptor: Token/password plaintext in shell commands (e.g.
curl -H "Authorization: Bearer xxx") triggers the 401 interceptor. When the Cloud Bridge system prompt instructs Agent to call the API, it must useexecute_codewith Pythonrequestsinstead ofterminal()curl. This is now documented in the CLOUD_BRIDGE_SYSTEM_PROMPT section 7. - Auth key leakage in chat replies: The Cloud Bridge system prompt (hermes.ts section 7) must explicitly forbid outputting auth key plaintext in conversation replies. Without this, the agent reads the key from Hermes memory and may paste it directly in its response text. Add:
⚠️ 在对话回复中禁止输出认证密钥明文!只允许在代码中以变量引用(如 f'Bearer {key}'),回复中一律用 *** 替代。The prompt itself should only reference***for auth headers, never the real key.
AtomListing Auth Architecture
See references/atomlisting-api.md for the complete confirmed API endpoint map (Stores, Social Accounts, Products, Listings, Posts).
See references/atomlisting-auth-bridge-api.md for the AtomListing login API response schema, Bridge fields for Settings page display, IPC channel details, and localStorage cache keys.
See references/browser-agent-architecture.md for the browser-use ecosystem analysis, DOM serialization algorithm, token compression layers, AgentOutput JSON schema, and browser-harness/workflow-use patterns ported to AtomK.
See references/state-db-sessions.md for SQLite access paths, session schema, and the resume race condition fix details.
See references/atomlisting-desktop-ui.md for AtomListing Web ↔ AtomK Desktop UI alignment guidance: keep functional parity with atomlisting.com, but implement a compact Desktop workbench layout with light AtomListing brand styling rather than copying the Web pages verbatim.
See references/three-layer-architecture.md for the holistic Server → Bridge → Desktop architecture overview, data flow, auth model, and deployment map.
See references/hermes-gateway-integration.md for Desktop → Bridge → Gateway request flow, X-Hermes-Session-Id header handling, system prompt processing, and auth key chain.
Two separate auth systems exist, both writing to the same OperationAuthConfig in desktop.json:
-
Settings page →
operation:loginIPC →loginOperation()inoperation-api.ts- Used when user manually logs in via Settings
- Calls
/api/v1/auth/login, saves token+baseUrl+systemCode viasetOperationAuthConfig()
-
Business pages (Products/Listings/Stores/Posts) →
atomlisting-login-storedIPC →atomkAPI.loginWithStoredCredentials()inatomlisting.ts- Called automatically on page mount when
atomlisting-auth-statereportsauthenticated: false - Must read saved config via
getOperationAuthConfig()(NOT privateauth["auth"]accessor) - Flow: check
auth.isAuthenticated()(local JWT decode) →auth.verify()(server check) →auth.refresh()(if expired) → return LoginResponse or null
- Called automatically on page mount when
Key pitfall: loginWithStoredCredentials() was initially implemented as return null, making all business pages permanently 401. It must use the public getOperationAuthConfig() function from config.ts to read the saved auth state — the AuthManager private get auth() getter is not accessible from the atomkAPI object.
API URL config: OperationAuthConfig.baseUrl stores the AtomListing API endpoint. Default "https://www.atomlisting.com". Settings page has an input field for it. Both operation-api.ts and atomlisting.ts read this base URL from the saved config rather than hardcoding it.
Fixing Screens That Show Raw i18n Keys (e.g. products.remoteTab)
When a screen displays raw keys like products.remoteTab instead of translations, two things are missing:
-
Namespace file doesn't exist: Create
src/shared/i18n/locales/{en,zh-CN}/domain.tsfor each locale. Follow theas constpattern:export default { key: "English value", } as const; -
Not registered in index.ts: Three additions per locale in
src/shared/i18n/index.ts:- Import statement after locale's last import:
import domainXx from "./locales/xx/domain"; - Key in locale's translation object:
domain: domainXx,
- Import statement after locale's last import:
Also check common.ts for missing keys — screens often use common.connecting, common.all, common.empty etc. that may not exist in all remaining locales. Add them if missing.
Upcoming screens likely needing this fix: Listings, Stores, Posts (all from the atomlisting merge).
Icon Update Pitfall
When updating the brand icon (e.g. replacing build/icon.png with a new design), you must also regenerate build/icon.ico — Windows title bar and taskbar icons come from the ICO file, not the PNG. If you only update build/icon.png and resources/icon.png, the app will still show the old/stale icon on Windows.
PIL Image.save("ico") only writes one size — must manually assemble a multi-size ICO with PNG-encoded entries:
from PIL import Image
import struct, io
src = Image.open('build/icon.png').convert('RGBA')
sizes = [16, 24, 32, 48, 64, 128, 256]
png_data = []
for s in sizes:
im = src.resize((s, s), Image.LANCZOS)
buf = io.BytesIO()
im.save(buf, format='PNG')
png_data.append(buf.getvalue())
# Build ICO binary: header + directory entries + PNG data
count = len(sizes)
header = struct.pack('<HHH', 0, 1, count)
data_offset = 6 + count * 16
entries, offset = [], data_offset
for i, s in enumerate(sizes):
w = s if s < 256 else 0 # 0 = 256 in ICO spec
entries.append(struct.pack('<BBBBHHII', w, w, 0, 0, 1, 32, len(png_data[i]), offset))
offset += len(png_data[i])
with open('build/icon.ico', 'wb') as f:
f.write(header)
for e in entries: f.write(e)
for d in png_data: f.write(d)
Files to update when changing the brand icon:
build/icon.png— Linux icon + source for ICO generationbuild/icon.ico— Windows title bar + taskbar (regenerate from PNG!)build/icon.icns— macOS (convert from PNG if needed)resources/icon.png— bundled resource (extraResources in electron-builder.yml)src/renderer/src/assets/hermes.png— sidebar logo in renderer
Sub-Agent Settings → Delegation Config Bridge
Settings page has a「子Agent设置」section with preset Chinese AI providers. The key connection to Hermes is:
Data flow:
Settings UI (subAgents array)
→ handleSaveSubAgents()
→ config.yaml sub_agents (full JSON array, persistence)
→ config.yaml delegation.base_url / api_key / model (the default/starred entry)
→ Hermes delegate_task → _resolve_delegation_credentials(cfg)
→ reads delegation.base_url first (direct endpoint)
→ falls back to delegation.provider (provider resolver)
→ if both empty, child inherits parent's credentials
Implementation pattern:
SubAgentEntryhasisDefault?: boolean— only one entry can be default (starred ★)- On save, the default entry's
baseUrl,apiKey,modelare written todelegation.base_url,delegation.api_key,delegation.modelviawindow.hermesAPI.setConfig(key, value, profile) delegation.provideris set to""when a default exists (sobase_urlpath takes priority over provider resolver path)- When no default exists, delegation fields are cleared → children inherit from parent agent
- The ★ star button immediately saves (no need to press bottom Save button)
Preset providers array (SUB_AGENT_PRESETS in Settings.tsx):
{ id: "siliconflow", name: "硅基流动 SiliconFlow", baseUrl: "https://api.siliconflow.cn/v1" },
{ id: "dashscope", name: "阿里云百炼 DashScope", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1" },
{ id: "minimax", name: "MiniMax", baseUrl: "https://api.minimax.chat/v1" },
{ id: "hunyuan", name: "腾讯混元 Hunyuan", baseUrl: "https://api.hunyuan.cloud.tencent.com/v1" },
{ id: "zhipu", name: "智谱 AI GlM", baseUrl: "https://open.bigmodel.cn/api/paas/v4" },
{ id: "moonshot", name: "月之暗面 Moonshot", baseUrl: "https://api.moonshot.cn/v1" },
{ id: "yi", name: "零一万物 Yi", baseUrl: "https://api.lingyiwanwu.com/v1" },
{ id: "baichuan", name: "百川智能 Baichuan", baseUrl: "https://api.baichuan-ai.com/v1" },
{ id: "qwen", name: "通义千问 Qwen", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1" },
{ id: "deepseek", name: "DeepSeek", baseUrl: "https://api.deepseek.com/v1" },
{ id: "spark", name: "讯飞星火 Spark", baseUrl: "https://spark-api-open.xf-yun.com/v1" },
{ id: "custom", name: "自定义 Custom", baseUrl: "" },
Hermes _resolve_delegation_credentials() logic (in ~/.hermes/hermes-agent/tools/delegate_tool.py):
- If
delegation.base_urlis set → returns{base_url, api_key, model, provider:"custom", api_mode: auto-detected }- Auto-detection:
/anthropicsuffix →anthropic_messagesmode;api.kimi.com/coding→anthropic_messages;chatgpt.com/backend-api/codex→codex_responses; elsechat_completions delegation.api_modeconfig override always wins
- Auto-detection:
- If
delegation.provideris set (without base_url) → resolves full credential bundle via runtime provider system - If neither → returns None values → child inherits parent's
base_url,api_key,model
Note: Delegation config takes effect on next Hermes Agent startup. There's no hot-reload of config.yaml for delegation fields.
Adding Session CRUD Features
Session management features (delete, prune, clear) follow a consistent full-stack pattern:
src/main/sessions.ts → Backend functions (SQLite + file cleanup)
src/main/index.ts → IPC handlers (ipcMain.handle)
src/preload/index.ts → Preload API (ipcRenderer.invoke)
src/preload/index.d.ts → Type declarations (HermesAPI interface)
src/renderer/.../Sessions.tsx → UI component
src/renderer/.../main.css → Styling
src/shared/i18n/locales/*/sessions.ts → i18n keys (en + zh-CN only)
Adding AtomListing CRUD Features (Stores, Social Accounts, etc.)
When adding CRUD for an AtomListing entity, follow this full-stack pattern. See references/atomlisting-api.md for the complete API endpoint map.
1. Update types in src/main/atomlisting-types.ts
- Match actual API response fields (test with Python
requests— the API often returns more fields than documented, e.g.user_id,is_active,updated_at) - Add
CreateXxxInputinterface for POST bodies - Do NOT add
UpdateXxxInput— update handlers useRecord<string, unknown>for flexibility
2. Add API methods to src/main/atomlisting.ts
- Import new types at top
- Add methods:
getXxx(),createXxx(data),updateXxx(id, data),deleteXxx(id) - Follow existing pattern:
createClient().get/post/put/delete(url)
3. Add IPC handlers to src/main/index.ts
ipcMain.handle("atomlisting-xxx-list", async () => atomkAPI.getXxx());
ipcMain.handle("atomlisting-xxx-create", async (_event, data) => atomkAPI.createXxx(data));
ipcMain.handle("atomlisting-xxx-update", async (_event, id: number, data: Record<string, unknown>) => atomkAPI.updateXxx(id, data));
ipcMain.handle("atomlisting-xxx-delete", async (_event, id: number) => { await atomkAPI.deleteXxx(id); });
4. Add preload bridge in src/preload/index.ts + types in index.d.ts
5. Build the UI component
- UX preference: When Accounts has sub-categories (Stores, Social Accounts), do NOT use sidebar collapsible/folding menus. Use a single "Accounts" nav item → tabbed content area inside the page. The user explicitly corrected this: "不是在菜单里折叠 而是在右侧主体框里分两个不同区域显示"
- Tab switcher at top:
accounts-tabs+accounts-tab.active - Each section: header with title + actions (refresh, add button), card grid for items
- Card layout: status indicator | info (name + platform badge + URL) | actions (toggle, edit, delete)
- Add/Edit modal with
form-groupfields (select, input) - Delete confirmation modal
- Toggle active/inactive via
updateXxx(id, { is_active: !current })
6. Add i18n in src/shared/i18n/locales/{en,zh-CN}/accounts.ts + register in index.ts
7. Add CSS in main.css — use .accounts-* class prefix
8. Test API with Python (avoiding Hermes security interceptor)
The Hermes interceptor blocks token/password in shell commands. Test API endpoints via Python inside execute_code:
import requests
r = requests.post("https://www.atomlisting.com/api/v1/auth/login", json={"username":"...","password":"..."}, timeout=15)
token = r.json()["token"]
headers = {"Authorization": f"Bearer {token}"}
# Test each endpoint
r2 = requests.get("https://www.atomlisting.com/api/v1/xxx", headers=headers)
- 422 = endpoint exists but validation failed (good sign!)
- 404 with non-existent ID = endpoint exists, just not found (good sign!)
- 404 on the base path = endpoint does NOT exist
Backend (sessions.ts)
- Always open DB read-write for mutations (not
readonly: true). - Use
try/finally { db.close() }pattern but hoist mutable state (e.g. collected deletion IDs) to function scope with aletplaceholder — variables declared insidetryare unreachable infinally. - For
deleteSession: orphan child sessions (UPDATE SET parent_session_id = NULL) before deleting parent — FK constraint requires this. - Clean up on-disk transcript files in
~/.hermes/sessions/(.json,.jsonl,.json.bak,request_dump_*prefixes). - For
pruneEmptySessions: build a list of IDs first, then batch-delete. Use placeholder string (ids.map(() => "?").join(",")) for dynamicIN (?)clauses.
IPC → Preload → Types
Standard triplet — see the IPC pattern section above. For SSH mode, session deletion returns early (return false / return 0).
UI (Sessions.tsx)
When adding overlay controls (like a delete button) to a card that was previously a single <button>:
- Change the card element from
<button>to<div className="sessions-card">. - Wrap the clickable content in
<button className="sessions-card-body">(seamless, no visual change). - Add positioned overlay button (e.g.
sessions-card-delete) withopacity: 0→opacity: 1on.sessions-card:hover. - CSS gotcha: The card body needs
padding-right: 40pxto prevent text overlapping the delete button area. - CSS inheritance: When refactoring from
<button>to<div>, the old.sessions-cardstyles (cursor, text-align, font-family, padding) must move to.sessions-card-bodyor be removed — leaving them on the wrapper div causes layout anomalies.
Confirm Dialog Pattern
Use a state-driven modal (confirmDialog state with title/message/confirmLabel/onConfirm). The ConfirmDialog component renders a fixed overlay with backdrop-filter: blur(2px), ESC key cancellation, and a red .btn-danger confirm button.
i18n Batch Update
When adding keys to all locale files, use execute_code with Python patch() calls to avoid line-number contamination from read_file.
Adding a Collapsible Section to an Existing Page (e.g. Tools)
When adding a new feature panel inside an existing screen (not a new sidebar route), follow this pattern — exemplified by the A2A Inbox panel embedded as the first section in Tools.tsx:
Full-stack checklist (6 files)
- Main process module (
src/main/feature.ts) — API call functions + exported IPC handler registration - Preload bridge (
src/preload/index.ts) — add methods tohermesAPIobject - Preload types (
src/preload/index.d.ts) — add matching interface signatures - IPC registration (
src/main/index.ts) — import module + registeripcMain.handlechannels - UI component — add collapsible section inside the target screen with state, useEffect for auto-refresh, and action handlers
- i18n — add keys in
locales/{en,zh-CN}/domain.ts - CSS — add
.domain-*class styles inmain.css(use CSS variables, NOT hardcoded colors)
Section UI pattern
{/* Inside the screen's return JSX, before the existing content grid */}
<div className="feature-section">
<div className="feature-section-title" onClick={() => setFeatureExpanded(!featureExpanded)}>
{featureExpanded ? <ChevronDown size={14}/> : <ChevronRight size={14}/>}
<Inbox size={14}/>
<span>{t("domain.featureName")}</span>
{unreadCount > 0 && <span className="feature-badge">{unreadCount}</span>}
</div>
{featureExpanded && (
<div className="feature-section-body">
{/* Status bar / credentials info */}
{/* Content list (messages, items, etc.) */}
{/* Empty state when no items */}
</div>
)}
</div>
Auto-refresh pattern
useEffect(() => {
loadFeatureData();
const timer = setInterval(loadFeatureData, 30000); // 30s
return () => clearInterval(timer);
}, []);
CSS variables requirement
All new section styles MUST use CSS variables (var(--text-primary), var(--bg-secondary), var(--border)) — NEVER hardcoded hex colors. This ensures compatibility with the user's light-theme preference.
Collapsible section CSS skeleton
.feature-section { margin-bottom: 20px; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.feature-section-title { display: flex; align-items: center; gap: 6px; padding: 10px 14px; cursor: pointer; background: var(--bg-secondary); font-weight: 500; font-size: 13px; color: var(--text-primary); user-select: none; }
.feature-section-title:hover { background: var(--bg-hover); }
.feature-section-body { padding: 14px; }
.feature-badge { background: var(--accent-text); color: #fff; font-size: 11px; padding: 1px 6px; border-radius: 10px; margin-left: 4px; }
A2A Inbox specific notes
- Main module:
src/main/a2a-inbox.ts— calls A2A Gateway ata2a9websgateway.sh3.ikuai7.com - Credentials stored in
~/.hermes/a2atoken.json(agent_id + token) - 6 IPC channels:
a2a:getMessages,a2a:getUnreadCount,a2a:markAsRead,a2a:deleteMessage,a2a:sendMessage,a2a:getCredentialsInfo - Safety: token/password must NOT appear in shell command plaintext (Hermes security interceptor). Use Python
requestsfor testing.
SQLite Access: better-sqlite3 vs sql.js — Pick Based on DB Size
Two SQLite access paths exist in the codebase, and choosing the wrong one causes silent failures:
better-sqlite3 (sessions.ts) |
sql.js WASM (session-cache.ts) |
|
|---|---|---|
| Type | Native C++ addon | Pure WASM, no native deps |
| Cross-compile | ❌ Fails (Linux .node won't load on Windows) |
✅ Works everywhere |
| Memory | Opens DB via mmap, constant memory | readFileSync entire DB into JS + WASM copy (~2× file size) |
| Speed | Fast (native) | Slow (WASM + full file read) |
| When DB >100MB | ✅ Fine | ❌ OOM or timeout, silently fails |
| IPC | Sync ipcMain.handle |
Requires async handlers |
Decision rule for local (non-cross-compiled) mode:
- If
state.dbis small (<50MB): either works - If
state.dbis large (100MB+): always usebetter-sqlite3(sessions.tsfunctions vialist-sessions/search-sessionsIPC channels) - If cross-compiling for Windows on Linux: must use
sql.jsbut warn that it breaks at scale
Real incident: state.db grew to 443MB. session-cache.ts (sql.js) needed ~900MB RAM (readFileSync + WASM copy), silently failed on load → Sessions page showed zero items. Fix: switch to listSessions() (better-sqlite3) via existing list-sessions IPC channel.
The better-sqlite3 cross-compile fix: When cross-compiling on Linux for Windows, the compiled .node is Linux ELF — Windows Electron cannot load it. In that scenario, sql.js is the only option, but you must:
- Keep
state.dbsmall (prune old sessions regularly) - Add
asarUnpack: ["**/sql-wasm.wasm"]toelectron-builder.yml - Make all IPC handlers
async (_event, ...) => - Add error logging to every
catch {}block — sql.js failures are always silent otherwise
Session Resume Race Condition (hermesSessionId clears mid-resume)
Symptom: User clicks an old session → briefly sees it selected → first message creates a brand-new session instead of continuing the old one. "每次new chat开始后旧的就找不到了" (old sessions become unreachable after new chat).
Root cause chain:
onResumeSessioncallssetMessages([])thensetView("chat")- Chat.tsx
useEffectwatchesmessages.length === 0 && sessionId === null→ clearshermesSessionIdtonull - First
sendMessagehas nosession_id→ backend creates new session - Old session is "lost" (still in DB but never resumed)
Fix pattern (Layout.tsx onResumeSession):
const onResumeSession = useCallback(async (sessionId: string) => {
// Load history BEFORE navigating — prevents messages.length===0 clearing hermesSessionId
const msgs = await window.hermesAPI.getSessionMessages(sessionId);
setMessages(msgs || []);
setCurrentSessionId(sessionId);
setView("chat");
}, []);
Fix pattern (Chat.tsx — guard hermesSessionId clearing):
// Only clear when truly starting fresh — not during resume
useEffect(() => {
if (messages.length === 0 && sessionId === null) {
setHermesSessionId(null);
}
}, [messages.length, sessionId]);
// Sync: if sessionId is set but hermesSessionId is null, adopt it immediately
useEffect(() => {
if (sessionId && !hermesSessionId) {
setHermesSessionId(sessionId);
}
}, [sessionId, hermesSessionId]);
General principle: Any React state machine that resets an ID on "empty" must distinguish "genuinely empty" from "loading/in-transition." Guard the clear action with ALL relevant conditions, or load data before transitioning.
Remote Mode: Don't Hide Entire Screens — Use Granular Feature Flags
Symptom: Tools page completely empty in remote mode. A2A Inbox (which works via API, no local access needed) invisible.
Root cause: Layout.tsx checks remoteMode and swaps entire <Tools> component for <RemoteNotice>. Any feature within that screen — even API-driven ones — gets hidden.
Fix pattern: Always render the full screen component, pass remoteMode as a prop, and let the screen itself decide what to hide:
// Layout.tsx — BAD
{remoteMode ? <RemoteNotice feature="Tools" /> : <Tools />}
// Layout.tsx — GOOD
<Tools profile={activeProfile} remoteMode={remoteMode} />
Inside the screen, hide only local-filesystem-dependent features:
// Tools.tsx
{remoteMode ? (
<div className="remote-notice">
<h3>{t("tools.remoteNoticeTitle")}</h3>
<p>{t("tools.remoteNoticeMsg")}</p>
</div>
) : (
<>
{/* Tool toggles, MCP servers — require local config.yaml */}
</>
)}
{/* A2A Inbox — works via API, always visible */}
<A2AInboxSection />
General principle: Remote mode affects capabilities, not screens. Never gate an entire route on remoteMode — let individual features opt out.
Slash Command Execution Order (isLoading bypass)
Symptom: /status command appears to do nothing when clicked during agent execution.
Root cause: useChatActions.ts handleSend checks isLoadingRef.current BEFORE localCommands.isLocal(text). When agent is running, isLoading=true causes immediate return — the command is silently dropped before it can be identified as a local command.
Fix pattern (useChatActions.ts handleSend):
const handleSend = useCallback(async (text: string, ...) => {
if (!text) return;
// Local commands ALWAYS work, even while agent is running
if (localCommands.isLocal(text)) {
const cmd = text.split(/\s+/)[0].toLowerCase();
if (cmd !== "/new" && cmd !== "/clear") pushUser(text);
await localCommands.executeLocal(text);
return;
}
// Non-local commands blocked while agent is running
if (isLoadingRef.current) return;
// ... sendToAgent
}, [...]);
Key principle: isLocal() check must come BEFORE isLoading check. Any local command (/status, /model, /memory, /help, etc.) should work at any time — their entire purpose is to query state during execution.
Making /status a local command: /status with category: "agent" but no local: true gets sent to backend as a chat message — useless and slow. Mark it local: true in slashCommands.ts and implement it in useLocalCommands.ts to show connection mode, remote URL, model, session ID, message count, loading state.
Remote API URL Resolution (AtomK Bridge v3.0 / CloudBridge v4.0)
AtomK Bridge v3.0 serves WS + CDP + REST API + WebUI all on a single port (9228). No port mapping or swap logic needed — REST API calls go to the same URL as the WebSocket connection.
URL resolution priority (v3.9.7+)
getApiUrl() and getRemoteApiBaseUrl() in hermes.ts resolve the API endpoint in this order:
- SSH mode → SSH tunnel URL
- CloudBridge mode →
conn.cloudBridgeUrlconverted fromws://→http://(strip/wssuffix) - Remote mode →
conn.remoteUrl - No URL → throw error
⚠️ Critical: CloudBridge mode stores the WS URL in cloudBridgeUrl (e.g. ws://49.51.249.171:9228/ws), but REST API calls need the HTTP base (e.g. http://49.51.249.171:9228). The conversion:
const httpUrl = conn.cloudBridgeUrl
.replace(/^wss:\/\//i, "https://")
.replace(/^ws:\/\//i, "http://")
.replace(/\/ws$/, "");
Historical note: Before v3.9.7, getApiUrl() only checked conn.remoteUrl, which was empty in CloudBridge mode (Bridge URL was stored as cloudBridgeUrl). This meant all REST API calls (sessions, messages, chat completions) would throw "No remote URL configured" — silently breaking chat sessions and model auto-detect in CloudBridge remote mode.
All remote REST API calls (list-sessions, get-session-messages, delete-session, search-sessions, prune-empty-sessions) must use getApiUrl() or getRemoteApiBaseUrl() instead of raw conn.remoteUrl.
Desktop URL normalization for Bridge v3.0
When aligning Desktop with AtomK Bridge v3.0, normalize user-entered URLs instead of requiring an exact format:
- HTTP/API base should normalize to
http://host:9228(strip trailing/,/v1,/ws, and convertws:///wss://tohttp:///https://for REST). - Cloud Bridge WebSocket should normalize to
ws://host:9228/ws(converthttp:///https://to WS scheme and append/wswhen missing). - Accept common pasted forms:
host:9228,http://host:9228,http://host:9228/v1,ws://host:9228/ws. - Never suggest or rewrite to legacy
8642,9229, or split-port URLs in Desktop UI.
Desktop connection test pattern
testRemoteConnection() should test Bridge v3.0 endpoints in priority order, with the configured Authorization header:
GET /cloud-bridge/healthGET /healthGET /v1/models
This catches both Bridge health and Hermes API proxy/header-pass-through problems before the user reaches Sessions or chat. Do not rely on only /health; older/newer deployments may expose different health aliases.
UI copy checklist
When changing remote-mode or Cloud Bridge copy, update all relevant surfaces together:
- Welcome/setup remote address placeholder
- Settings remote URL placeholder/help text
- Cloud Bridge page explanatory text
- i18n files for both
enandzh-CN
Use examples based on http://192.168.1.100:9228 for HTTP/API/WebUI and ws://192.168.1.100:9228/ws only when explicitly describing WebSocket.
⚠️ Pre-v3.0 history: The old Cloud Bridge used two ports (9228 WS + 9229 HTTP proxy), requiring port-swap logic in getRemoteApiBaseUrl(). This was fragile — port 9229 was often firewalled. AtomK Bridge v3.0 eliminated this by consolidating to a single port. See references/cloud-bridge-proxy.md for the full architecture diagram and migration history.
Adding Backend API Integration via Electron IPC (Full-Stack Pattern)
When a Settings field (or any renderer UI) needs to call an external backend API (e.g. AtomListing auth), the full-stack pattern is:
1. Main process: IPC handler with electron.net
In src/main/index.ts, add ipcMain.handle. Use electron.net (NOT fetch — not available in main process, NOT axios — not a dep):
ipcMain.handle("atomlisting:login", async (_event, opts: { apiUrl: string; username: string; password: string }) => {
try {
const { net } = await import("electron");
return await new Promise((resolve) => {
const body = JSON.stringify({ username: opts.username, password: opts.password });
const url = new URL("/api/v1/auth/login", opts.apiUrl);
const request = net.request({ method: "POST", url: url.toString() });
request.setHeader("Content-Type", "application/json");
let data = "";
request.on("response", (response) => {
if (response.statusCode !== 200) {
let errBody = "";
response.on("data", (chunk: Buffer) => { errBody += chunk.toString(); });
response.on("end", () => {
try { resolve({ success: false, error: JSON.parse(errBody).detail || `HTTP ${response.statusCode}` }); }
catch { resolve({ success: false, error: `HTTP ${response.statusCode}: ${errBody.slice(0, 200)}` }); }
});
return;
}
response.on("data", (chunk: Buffer) => { data += chunk.toString(); });
response.on("end", () => {
try { resolve({ success: true, data: JSON.parse(data) }); }
catch { resolve({ success: false, error: "Invalid JSON response" }); }
});
});
request.on("error", (err: Error) => { resolve({ success: false, error: err.message }); });
request.write(body);
request.end();
});
} catch (e: any) { return { success: false, error: e.message || String(e) }; }
});
2. Preload bridge
In src/preload/index.ts, add method to hermesAPI:
atomlistingLogin: (opts: { apiUrl: string; username: string; password: string }): Promise<{
success: boolean; error?: string;
data?: { token: string; /* ... full response shape */ };
}> => ipcRenderer.invoke("atomlisting:login", opts),
3. Type declarations
In src/preload/index.d.ts, add matching signature to HermesAPI interface.
4. Renderer: Call on save, cache results
async function handleSaveAtomListing(): Promise<void> {
setAtomListingLoggingIn(true);
// Save credentials to localStorage
localStorage.setItem("atomk_atomlisting_creds", JSON.stringify({ username, password }));
// Call login API
const result = await window.hermesAPI.atomlistingLogin({
apiUrl: "https://atomlisting.com", username, password,
});
if (result.success && result.data) {
setAtomListingToken(result.data.token);
setAtomListingBridges(result.data.bridges || []);
setAtomListingRecommendedBridge(result.data.recommended_bridge || null);
// Cache for instant display on next load
localStorage.setItem("atomk_atomlisting_login", JSON.stringify({
token: result.data.token, bridges: result.data.bridges, recommended_bridge: result.data.recommended_bridge,
}));
setAtomListingStatus(t("settings.loginSuccess"));
} else {
localStorage.removeItem("atomk_atomlisting_login");
setAtomListingStatus(result.error || t("settings.loginFailed"));
}
setAtomListingLoggingIn(false);
}
5. Restore cached results on load
In the loadConfig callback:
const cachedLogin = localStorage.getItem("atomk_atomlisting_login");
if (cachedLogin) {
const loginData = JSON.parse(cachedLogin);
setAtomListingToken(loginData.token);
setAtomListingBridges(loginData.bridges || []);
setAtomListingRecommendedBridge(loginData.recommended_bridge || null);
}
Key principles
- Always use
electron.netfor HTTP from main process —fetchdoesn't exist there,axiosis not a dependency - Return
{ success, error?, data? }envelope — never throw from IPC handlers, always resolve - Cache login results in localStorage — APIs are slow (embedding, network), cache for instant display next time
- Button should show loading state — rename "Save" to "Login & Save" with spinner during API call
- Handle HTTP errors gracefully — parse JSON error body for
detailfield, fallback to raw status code
Adding Cookie Persistent Backup (v3.8.5+)
Electron's session.defaultSession.cookies gets wiped when the userData directory is recreated during app upgrades. Solution: dual-layer persistence.
Local filesystem backup (primary — always works)
Store cookie JSON outside Electron's userData directory so upgrades don't touch it:
// Windows: %APPDATA%/AtomK/cookie-backups/
// Linux/Mac: ~/.atomk/cookie-backups/
const LOCAL_BACKUP_DIR = path.join(os.platform() === "win32"
? path.join(process.env.APPDATA || os.homedir(), "AtomK")
: path.join(os.homedir(), ".atomk"), "cookie-backups");
- Always write
latest.json(overwrites) + timestamped copy2026-05-27T10-30-00.jsonfor history - Auto-backup on
app.on("before-quit")— usessession.defaultSession.cookies.get({})to dump all cookies - Auto-restore on
app.whenReady()with 5s delay — callssession.defaultSession.cookies.set()for each cookie
Cloud backup (secondary — needs atomlisting backend)
Upload/download cookie JSON via atomlisting.com API endpoints:
POST /api/v1/cookies/backup— body:{cookies: [...], source, label, device}GET /api/v1/cookies/restore— returns latest backup envelopeGET /api/v1/cookies/list— returns backup history
Backend stores to COS per user: cookies/backups/{username}/{label}.json. Requires user to be logged in to atomlisting.com first.
Auto-restore priority on startup
- Try cloud restore (if
atomkAPI.auth.isAuthenticated()) - Fall back to local backup (
latest.json) - Log result to console:
[CookieAutoRestore] Restored N cookies from cloud/local
Restoration logic
for (const cookie of cookies) {
try {
const domain = cookie.domain || "";
const url = (cookie.secure ? "https://" : "http://")
+ domain.replace(/^\./, "")
+ (cookie.path || "/");
await ses.cookies.set({
url, name: cookie.name, value: cookie.value || "",
domain: cookie.domain, path: cookie.path,
secure: cookie.secure, httpOnly: cookie.httpOnly,
expirationDate: cookie.expirationDate,
});
imported++;
} catch { skipped++; } // Session cookies / invalid ones — skip gracefully
}
Key files
src/main/cookie-backup.ts— Core backup/restore/cloud functionssrc/main/cookies.ts— Low-level Electron session cookie CRUD (was "dead code" until v3.8.5)src/main/index.ts— IPC handlers forcookies:local-backup,cookies:local-restore,cookies:cloud-backup,cookies:cloud-restoresrc/preload/index.ts+index.d.ts— Preload bridge + typessrc/renderer/src/screens/ChromeBridge/ChromeBridge.tsx— UI buttons (本地备份/恢复, 云端备份/恢复)
See references/cookie-backup-impl.md for full implementation details, serialization format, and the backend API spec (not yet deployed).
Pitfall: "Dead" IPC modules
The cookies.ts module existed with full exports (getAllCookies, exportCookiesJSON, importCookiesJSON, etc.) but zero IPC handlers were registered in index.ts — all functions were dead code. When adding a feature, always check whether existing utility modules just need wiring up before writing new code. The activation pattern is:
- Import functions in
index.ts - Add
ipcMain.handle("cookies:xxx", handler)entries - Add preload bridge methods + type declarations
- Add UI buttons
Adding Stub IPC APIs for Rebase-Merged Components
When a git rebase or merge brings in new renderer components that call window.hermesAPI.xxx() methods that don't yet exist in preload/main, the build fails with TS errors on every missing method. The fastest fix is a 3-file stub pattern — add minimal declarations that compile, then implement later:
Step 1: Add type declarations (src/preload/index.d.ts)
Add each missing method to the HermesAPI interface with permissive return types:
chromeBridgeGetScripts: () => Promise<any[]>,
chromeBridgeGetScriptRuns: (scriptId: number) => Promise<any[]>,
chromeBridgeCloudConnect: (config: any) => Promise<any>,
// ... etc
Step 2: Add preload bridge (src/preload/index.ts)
Wire each method to an IPC channel:
chromeBridgeGetScripts: () => ipcRenderer.invoke("chrome-bridge:getScripts"),
chromeBridgeGetScriptRuns: (scriptId: number) => ipcRenderer.invoke("chrome-bridge:getScriptRuns", scriptId),
chromeBridgeCloudConnect: (config: any) => ipcRenderer.invoke("chrome-bridge:cloudConnect", config),
Step 3: Add stub handlers (src/main/index.ts)
Return empty arrays or defaults — sufficient for compile and runtime:
ipcMain.handle("chrome-bridge:getScripts", async () => []);
ipcMain.handle("chrome-bridge:getScriptRuns", async () => []);
ipcMain.handle("chrome-bridge:cloudConnect", async () => ({ success: false, error: "Not implemented" }));
Step 4: Fix component type errors with as any
Components that call the stub APIs may have type mismatches (e.g., expected Script[] but got any[]). Use targeted as any casts on the call sites, NOT on the return types:
// ❌ Don't cast the variable — loses type safety
const scripts: any = await window.hermesAPI.chromeBridgeGetScripts();
// ✅ Cast at the call site — minimal, targeted
const scripts = await (window.hermesAPI.chromeBridgeGetScripts as any)()
When to replace stubs with real implementations: When the feature is prioritized for the next sprint. Until then, stubs prevent build failures and the as any casts are explicitly temporary.
Common TS errors from missing stubs: TS2339 (property doesn't exist on HermesAPI), TS2345 (type mismatch on return), TS6133 (unused imports after removing dead code that referenced the missing APIs).
Browser Agent Integration (v3.9.8+)
The Browser Agent module (src/main/browser-agent/) provides an autonomous observe→think→act→verify loop that can control the CDP-connected browser to complete tasks described in natural language.
Architecture
src/main/browser-agent/
types.ts — AgentAction, AgentTask, SerializedDOMState, InteractiveElement, etc.
dom-service.ts — DOM intelligent serialization: CDP 3-way parallel fetch → merge → interactive element detection → [index] annotation → token compression
prompts.ts — System Prompt teaching LLM to read DOM snapshots, output structured AgentOutput JSON
agent-loop.ts — Core observe→think→act→verify loop + ActionLoopDetector + 6-layer error recovery
index.ts — Module exports
Key design decisions (ported from browser-use ecosystem)
- DOM 3-way CDP parallel fetch:
DOMSnapshot.captureSnapshot+DOM.getDocument(depth=-1, pierce=true)+Accessibility.getFullAXTree→ merged → interactive element extraction →[index]annotation - Token compression (5 core layers from browser-use's 9): paint-order filtering, interactive-only indexing, attribute whitelist (~40), text ≤100 chars,
<page_stats>summary - AgentOutput JSON:
{thinking, evaluation_previous_goal, memory, next_goal, action:[{click:{index:5}}]} - 6-layer error recovery: LLM retry+fallback, consecutive failure count→force done, CDP reconnect, ActionLoopDetector (hash dedup+page fingerprint stagnation), replan after 3 failures, step timeout
- Coordinate-based clicking: Reuses existing
/cdp/click(Input.dispatchMouseEvent) which auto-penetrates iframe/Shadow DOM — ported from browser-harness's "minimal wrapper" philosophy
Full-stack integration pattern
Adding Browser Agent required changes across 7 existing files + 6 new files:
- chrome-bridge.ts — Export
sendCDP()soagent-loop.tscan call CDP directly - index.ts — 6 IPC handlers (
browser-agent:start/cancel/pause/get-state/get-snapshot/capture-screenshot), LLM bridge wiring viasendMessage()with system prompt in history array, CDP availability sync - preload/index.ts —
browserAgentnamespace with 7 methods +onStateChangelistener - preload/index.d.ts —
HermesAPI.browserAgenttype declarations - Layout.tsx — Add
Viewtype| "browser-agent",Zapicon,NAV_GROUPSentry, render pane - i18n navigation —
browserAgent: "Browser Agent"/browserAgent: "浏览器智能体" - BrowserAgent.tsx — Full UI: task input, quick-task templates, step visualization, DOM tree viewer, screenshot preview, loop warning
LLM bridge pattern (reusing sendMessageViaApi)
The sendMessage() API doesn't accept a systemPrompt parameter. The workaround injects the system prompt as the first message in the history array and calls sendMessageViaApi() directly:
import { sendMessageViaApi } from './hermes'
_setLLMBridge(async (systemPrompt, userMessage) => {
return new Promise((resolve, reject) => {
let fullResponse = "";
const history = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
];
// Pass empty message string — actual prompt is in history
const result = sendMessageViaApi('', 'default', null, history);
// Collect streaming chunks via onChunk, resolve on done
result.onChunk?.(chunk => { fullResponse += chunk; });
result.onDone?.(() => resolve(fullResponse));
result.onError?.(err => reject(new Error(err)));
});
});
⚠️ sendMessageViaApi returns a streaming result object, not a plain string. You must consume the stream (collect chunks, resolve on done). Simply await sendMessageViaApi(...) returns the stream controller, not the final text.
IPC handler registration pattern for complex modules
When a module has its own internal state and needs CDP + LLM access:
- Import the module class in
index.ts - Instantiate with
setCDPBridge(sendCDP)— pass the CDP sending function - Instantiate with
setLLMBridge(async (sys, user) => ...)— wire to Hermes chat - Register IPC handlers that delegate to the module's public methods
- Add state-change listener:
module.onStateChange(state => mainWindow?.webContents.send("browser-agent:state-changed", state)) - Sync external state changes (e.g. CDP browser start/stop) via
module.setCdpAvailable(bool)
CDP availability sync pattern
The Browser Agent needs to know when CDP is available (browser started/stopped). Wire this via the existing onChromeBridgeStateChange callback in index.ts:
onChromeBridgeStateChange((state) => {
mainWindow?.webContents.send("chrome-bridge:state-changed", state);
// Sync CDP availability to browser agent
browserAgent.setCdpAvailable(state.cdpEnabled || state.cdpBrowserRunning);
});
Note: inside index.ts, the Chrome Bridge state getter is aliased as getCBridgeState() (not getChromeBridgeState()). Always use the alias that's imported at the top of the file.
Cookie Backup Issues
👉 Detailed architecture: references/cookie-backup-architecture.md — cloud backup flow, missing backend endpoints, local vs cloud status, and the auth token gap between isAuthenticated() and needsRefresh().
TL;DR: The three cloud cookie endpoints (/api/v1/cookies/backup, /restore, /list) don't exist in Atomlisting_Server backend. Cloud backup buttons in ChromeBridge.tsx (1377-1419) always fail with {success: false} → misleading "请先登录 Atomlisting.com 账号" message. Local backup to ~/.atomk/cookie-backups/ works fine. Either implement the 3 endpoints or switch to COS upload.
Pitfalls
- 🔥 Session resume race condition: When
onResumeSessionclears messages before loading history, Chat'suseEffectinterprets "empty messages + no sessionId" as "fresh start" and clearshermesSessionId, permanently losing the session. Always load session history before transitioning to chat view. See dedicated section above. - 🔥 Remote mode hides entire screens:
Layout.tsxpattern{remoteMode ? <RemoteNotice /> : <Screen />}hides ALL features of a screen, including API-driven ones like A2A Inbox. Always render the screen, passremoteModeprop, hide only local-filesystem features inside. See dedicated section above. - 🔥 IPC handlers silently returning empty arrays on remote API errors: When
fetch()to the remote API returns non-200 (401, 403, 500, network timeout), the IPC handler mustthrowthe error, NOT return[]. Returning empty fallbacks makes the UI appear blank with zero diagnostic info — the user thinks "no data" when actually "API auth failed." Always propagate errors to the frontend, and addloadErrorstate + error banner + retry button. Real incident: v3.8.0 Sessions page showed empty despite 6296 existing sessions, because!resp.ok → return []on HTTP 404 was silently swallowed. The 404 itself was because the old Cloud Bridge only handled WebSocket on port 9228 —/v1/sessionsREST calls had no handler. AtomK Bridge v3.0 fixed this by serving REST API on the same port. - 🔥 401 errors must show friendly Chinese messages: When remote API returns HTTP 401 (Invalid API key), the raw error
Remote API returned 401 (Unauthorized): {"error":{"message":"Invalid API key"...}}is meaningless to non-technical users. Always checkresp.status === 401before the generic throw, and throw a friendly Chinese message:throw new Error('API Key 无效或未设置,请在设置中检查 API Key 是否正确。(HTTP 401: ...)'). This must be done in every IPC handler that calls the remote API (list-sessions,get-session-messages,delete-session,search-sessions,prune-empty-sessions) AND in the chat streaming handler (hermes.ts→finish("API Key 无效或未设置...")). Real incident: v3.8.3 showed rawInvalid API keyJSON on the Sessions page, causing user confusion. - 🔥 sql.js OOM on large state.db:
session-cache.tsusesreadFileSyncto load the entire DB into WASM memory. At 443MB DB size → ~900MB RAM usage → silent failure. For DBs >100MB, always usebetter-sqlite3viasessions.tsIPC. See dedicated section above. loadToolsetsmissing error handling:Promise.all([getToolsets(), getEnabledToolsets()])without try/catch leavessetLoading(true)hanging forever if either IPC rejects. Always wrap in try/finally.parseEnabledToolsetsregex\w+doesn't match hyphenated toolset names: Config entrycli: [hermes-cli]only matcheshermesbecause\wexcludes-. Use[\w-]+instead.- ⚠️ ChromeBridge.tsx duplicate section fields: When ChromeBridge.tsx grows large (~900+ lines), it's easy for Cloud Bridge connection fields (Server URL, Client ID, Server, Center-bound Node) to accidentally appear in BOTH the Cloud Bridge section AND the Page Snapshot section. This happens during merges or incremental edits where a block is copy-pasted and the old copy isn't removed. Symptom: user sees "Center-bound Node / Use Bound URL / Server URL / Client ID / Server" under Page Snapshot heading — these belong ONLY in the Cloud Bridge section. Fix: grep for duplicate field labels (
Center-bound,client-id,server-url) and ensure each appears exactly once in the file, in the correct section. - ⚠️ Navigation items silently dropped during Layout.tsx edits: When editing
Layout.tsxto add/remove/reorder sidebar items, always verify that ALL screen folders insrc/renderer/src/screens/with a default-exported component still have a correspondingNAV_GROUPSentry. Cross-check againstsrc/shared/i18n/locales/{en,zh-CN}/navigation.ts— any key there without a nav entry was likely lost. This pitfall recurs almost every time Layout.tsx is touched — it has happened independently at least 5 times (Gateway/Messaging removal, Products/Listings/Posts/Mail, Sessions, during group reordering, and branch merges). Real incident: user ran the app for 1+ day without noticing Products/Listings/Posts/Mail were all gone — they existed as components + i18n keys but had noNAV_GROUPSentries. Mandatory verification step: after ANY Layout.tsx change, runls src/renderer/src/screens/and compare each folder againstNAV_GROUPSentries — if a screen folder exists but has no nav entry, add it back. See alsoatomk-desktop-sidebarskill for the same pitfall with more context. - Sessions component requires props: Unlike most screen components that take no props,
Sessionsrequires{ onResumeSession, onNewChat, currentSessionId, visible }. When adding it to Layout.tsx, you must pass these from Layout's state — NOT just<Sessions />. TheonResumeSessioncallback should setcurrentSessionId, clear messages, and navigate to chat view.visibleshould beview === "sessions". If you forget the props, tsc will error withTS2739: Type '{}' is missing the following properties from type 'SessionsProps'. - 🔥 HermesAPI type mismatch when calling browserAgent.getState(): The
index.d.tsdeclaresbrowserAgent.getState()returnsPromise<Record<string, unknown>>, but the renderer component uses a localBrowserAgentStateinterface. You cannot directly castRecord<string, unknown>to a specific interface — TypeScript rejects it as "neither type sufficiently overlaps." Use the double-cast pattern:s as unknown as BrowserAgentState. This applies to ANY IPC method that returns structured JSON from main process — the type declaration usesRecord<string, unknown>for flexibility, but renderers need specific types. - 🔥 agent-loop sendCDP() only accepts 2 args: When
executeCDPAction()is exported as a standalone function (not wired through the internal_executeCDPActionbridge), thesendCDPparameter is typed as(method: string, params?: Record<string, unknown>) => Promise<any>. CallingsendCDP('Page.enable', {}, 5000)with a third timeout arg causes TS2554. Remove the timeout arg — CDP commands don't use it. - ⚠️ Unused variables in browser-agent modules trigger TS6133:
tsconfig.node.jsonenablesnoUnusedLocals. Unused function parameters (likenodeinbuildXPath(node, localName)) must be prefixed with_(_node). Unused imports (likecaptureScreenshotfrom dom-service) must be removed. Declared-but-unused interfaces (likeRawNode) must be deleted. - ⚠️ Map.get() returns
T | undefined, notT | null: When building maps from CDP data (axMap.get(backendNodeId)), TypeScript'sMap.get()returnsT | undefined. Functions typed to acceptT | nullwill rejectT | undefined. Fix:const raw = map.get(key); const val = raw === undefined ? null : raw. - RelayFetch (frontend HTTP→Relay) is fragile for CDP operations: Renderer code that calls
fetch http://localhost:3928/cdp/senddepends on the Relay HTTP server being up. When Relay crashes or restarts, these calls fail with "Failed to fetch" even though the underlying CDP WebSocket connection may still be alive. Correct pattern: use IPC to call main-processsendCDP()directly over the CDP WebSocket — bypass Relay HTTP entirely. If adding a feature that needs CDP data (cookies, targets, snapshots, etc.), add an IPC channel (chrome-bridge:get-cookies,chrome-bridge:set-cookie) and have the main process callsendCDP(). Real incident: Cookie extraction UI existed but usedrelayFetch(/cdp/send)→ always failed when Relay was down → user ran app for 1+ day with zero cookies extracted. - read_file line-number contamination:
read_fileoutput has42|prefixes. Never pipe this intopatchold_string orwrite_file. Always use Python file I/O for i18n index edits, orgit checkoutto undo and redo clean. - Trailing comma in parameter types: In preload API definitions, object type params like
(data: { x: Array<T> })must NOT have extra>. Triple-check arrow function signatures. - try/finally variable scoping: Variables declared inside a
try {}block are unreachable infinally {}. When you need to access mutation results (e.g. deleted session IDs) infinallyfor cleanup, declarelet deletedIds: string[] = []at function scope and assign insidetry. - electron-builder semver: 3-segment only. 4-part = error. syncExtensionApiKey(): Required after any
setConnectionConfig()that changesapiKey; missing it = Chrome Extension auth failure (fixed v3.9.15). - Collapsible nav CSS animation: Uses
grid-template-rows: 0fr → 1frfor smooth expand/collapse. The.sidebar-nav-childrencontainer is a grid;.openclass togglesgrid-template-rows. Do NOT usemax-heightanimation — it's janky and requires guessing a max value. - electron-vite CLI hangs: Use Node API instead:
node -e "require('electron-vite').build()". - Node builtin imports in main process:
tsconfig.node.jsondoes NOT enableesModuleInterop, soimport https from 'https'fails with TS1192/TS1259. Useimport * as https from 'https'for ALL Node builtins (https, http, fs, path, etc.) insrc/main/files. - Custom icon aliases:
src/renderer/src/assets/icons/index.tsxre-exports some lucide-react icons with different names (e.g.Trash2 as Trash,MessageSquare as ChatBubble,RefreshCw as Refresh). If you import the original name from this module it fails TS (e.g.import { Trash2 } from "../../assets/icons"→ TS2724). Either import the aliased name from icons module, or import the original name directly fromlucide-react— never both paths for the same icon. - Unused import = build error:
tsconfig.node.jsonhasnoUnusedLocals: true(implicit via composite mode). Any unused import insrc/main/files causes a build-breaking TS6133 error. Remove unused imports before building. - Pre-existing i18n errors: Deleted locale dirs (es/id/ja/pt-BR) had missing commas in settings.ts — no longer relevant after locale pruning. If re-adding locales, verify their files carefully.
- Lucide-react icons: Not all icon names exist. If an icon fails, use a fallback like
ShieldorKey.Staricon works for "default" indicators withfill="currentColor"for active state. - Client identifier display: When displaying platform info to users (e.g. "客户端: AtomK Desktop (win32) v3.6.8"), use
${process.platform}-${process.arch}(e.g.win32-x64,darwin-arm64) instead of bareprocess.platform(win32), so users can tell 32/64-bit architecture at a glance. Change location: wherever the registrationinfoobject is constructed (e.g.chrome-bridge.tsregister message). - Icon dual-import pitfall:
src/renderer/src/assets/icons/index.tsxre-exports somelucide-reacticons with aliases (e.g.ChatBubble = MessageSquare,Trash2 = Trash,Settings). If an icon is there, import from../../assets/icons; otherwise fromlucide-reactdirectly. Never both — duplicate identifiers fail TypeScript.ChevronRightis NOT re-exported — always fromlucide-react. - 🔥 Don't SSH to production servers for backend deployment: When backend API changes are needed (e.g. atomlisting.com), the agent should NOT directly SSH to production servers. Write the backend code locally, commit to git, and let the user handle deployment. The user explicitly corrected this: "为什么要连接 SSH 到 atomlisting.com 服务器?" — deployment is the user's responsibility, not the agent's.
- ⚠️ Atomlisting_Server git remote is
origin: When pushing Server changes, usegit push origin main(notatomk). Theatomkremote only exists in the Desktop repo. The Server repo is at/home/ubuntu/Atomlisting_Server/withorigin→ Gitea. - ⚠️ Atomlisting_Server config.py hardcoded API key pitfall:
HERMES_GATEWAY_API_KEYinbackend/app/config.pywas hardcoded to"5HH551..."(an old/wrong key). The actual Gateway API key isBing2026Cao$$$(same as Bridge key on this deployment). Always leave config defaults as empty string""and set the real value via environment variable on the server. The5b49c04commit added this field toBridgeResponse(returned by/api/v1/bridges/), but Desktop doesn't useagent_api_keydirectly — Bridge injectsHERMES_API_KEYautomatically. Thedefault_modelanddefault_providerfields are useful though (Desktop readsb.default_modelto auto-select model). - 🔥 Model not set after first login → chat stuck loading (v3.9.6 fix): After login +
applyAgent,getModelConfig().modelis empty →/statusshows "Model: not set" → chat sends with fallbackhermes-agentmodel name → fails or hangs. Fix: inoperation:apply-agent, after connecting Bridge, automatically fetch/v1/modelsfrom Bridge HTTP API andsetModelConfig("auto", firstModelId, httpUrl)if no model configured. The HTTP URL is derived from the WS URL:bridgeUrl.replace(/^ws/, "http").replace(/\/ws$/, ""). Auth header uses the Bridge key:Authorization: Bearer ${b.key}. If the fetch fails (e.g. Bridge v3.0 doesn't serve/v1/models), user can still manually select a model from the ModelPicker in Chat UI. Verified working: Bridge-CN-1 (local v4.0) returns{"data": [{"id": "hermes-agent"}]}from/v1/models— chat completions proxy through Bridge to Hermes backend successfully. - Bridge-CN-1 runs on this machine (v4.0): Bridge-CN-1 is NOT a remote server — it's
/home/ubuntu/atomk-page-bridge/cloud-bridge/server.pyrunning on THIS machine as PID 4806. It's already v4.0 (server.py= v4.0,server_multi.py= v3.1 legacy). Start command:python3 server.py --key Bing2026Cao$$$ --proxy-port 9228 --bridge-name Bridge-CN-1 --cdp-timeout 30 --proxy-timeout 60. Health check:curl -s http://127.0.0.1:9228/health -H "Authorization: Bearer <key>"returns version, uptime, slots. Chat API:POST /v1/chat/completionsproxies to the local Hermes agent. Models API:GET /v1/modelsreturns available models. All endpoints require Bearer auth with the Bridge key. - 🔥 Bridge auth key must propagate through all connection paths (v3.9.5 fix): Cloud Bridge v4 WS requires
{type:"auth", key: apiKey}within 5s.connectCloudBridge()inchrome-bridge.tsonly sends authif (config.apiKey)— passingapiKey: ""silently skips auth → 4001 rejection. The key originates frombridges[0].keyin the login/bridges API response. Three code paths must all pass the key: (1)operationApplyAgentin index.ts — saveb.keytoconnectionConfig.apiKey, thenconnectCloudBridge({ serverUrl, apiKey: b.key }); (2)handleConnectRecommendedBridgein Settings.tsx — passtarget.keytochromeBridgeCloudConnect; (3)handleCloudConnectin BridgeConnection.tsx/ChromeBridge.tsx — read saved key viachromeBridgeCloudGetConfig()which returns{ serverUrl, apiKey }. Supporting changes:UserBridgeInfointerface must includekey: string;getCloudBridgeConfig()in config.ts must return{ serverUrl, apiKey }(not justserverUrl);chrome-bridge:cloud-connecthandler must persistconfig.apiKeytoconnectionConfig. Real incident: v3.9.4 connected with empty apiKey → 4001 → "Server rejected connection" with no way to proceed. - 🔥 Duplicate IPC handlers crash Electron on startup: When adding stub IPC handlers in
src/main/index.ts, you MUST check that the same channel name is not already registered elsewhere in the same file.ipcMain.handle()throwsError: Attempted to register a second handler for 'channel'if called twice for the same channel — this crashes the entire app immediately, with no window shown. Real incident: After adding ChromeBridge stub handlers (cloud-connect, cloud-disconnect, cloud-get-state, cloud-get-config, set-bridge-list) at ~line 1514, the app had real implementations for the same 5 channels at ~line 1583. The duplicate registration killed the app — user clicked the installed .exe and nothing happened. Prevention: before adding anyipcMain.handle("xxx", ...), grep for"xxx"in the file to verify no prior registration exists. - 🔥
instance.base_urlis NOT the Bridge URL: The login API response has two different URLs —instance.base_url(e.g.https://us1.atomk.cn) is the Agent server, whilebridges[0].host:port(e.g.49.51.249.171:9228) is the Bridge server. Usinginstance.base_urlas a Bridge URL causes health check failures (returns 404), blocking the user from entering the app. Correct pattern: After login, calloperationApplyAgent()which fetches the bridges list from the server and configures Cloud Bridge WebSocket frombridges[0]. Never manually setsetConnectionConfig("remote", instance.base_url, ""). - 🔥 Never block authenticated users at Welcome screen: If the user has a valid login token, they must always proceed to the main screen — even if Bridge connection fails. Show a dismissible warning banner (
bridgeErrorprop onLayout) instead of trapping them at the Welcome/login page. Real incident: v3.9.0 showed "Cannot reach AtomK Bridge at https://us1.atomk.cn" on Welcome screen with no way to proceed, because Bridge health check failed andnext = "welcome". Fix: authenticated users always getnext = "main", Bridge failures become non-blocking warnings. - ⚠️
npmRebuild: falsein electron-builder.yml means native modules aren't rebuilt for target platform: If the project uses native Node addons (e.g.better-sqlite3), the Linux-compiled.nodebinary gets packaged into the Windows installer as-is. On Windows, loading a Linux ELF.nodecauses a silent crash. Either setnpmRebuild: true(works if target toolchain is available), or exclude the native module from the package and ensure pure-JS alternatives (likesql.js) are used for cross-platform builds. - 🔥 Welcome screen must not show SSH/Remote options to new users: The Welcome screen (
Welcome.tsx) must only show Atomlisting.com login (username + password). "Connect Via SSH" and "Connect to Remote AtomK" must NOT appear as buttons or panels. New users see ONLY the login form. SSH/Remote is accessible only through Settings → Connection → Advanced, and used as legacy fallback in App.tsx startup. Real incident: v3.8.7 still showed "Connect Via SSH" button on Welcome screen, confusing new users who expected a simple account login. - 🔥 Silent async failures in UI handlers: When renderer-side
await window.hermesAPI.xxx()returns a falsy value (e.g.deleteSession()returnsfalse), the UI must display an error — never silently ignore it. Pattern:const ok = await api(); if (!ok) { setErrorMsg(t("domain.actionFailed", { id })); return; }. Real incident: v3.8.4 Sessions page showed delete confirmation dialog, user confirmed,deleteSession()returnedfalse(auth failure), but UI just sat there — no error badge, no toast, no retry. User saw sessions but could never delete them, with zero diagnostic feedback. Always add error state + error banner for any async action that can fail. - ⚠️ i18n label clarity for auth keys: When Desktop connects to a remote server (Bridge), the Settings field labeled "API Key" is ambiguous — users may think it's their Hermes API key or OpenAI key, when it's actually the Bridge
--keyparameter. Always label auth fields with context: "Bridge/API Key" / "Bridge/API 密钥" instead of bare "API Key" / "API 密钥". Update bothen/settings.tsandzh-CN/settings.ts. Real incident: user confused "API Key" in Settings with their Hermes backend API key, leading to auth failures. - write_file encoding pitfall for TypeScript: When creating
.tsfiles withwrite_file, if the first line is a// comment, the tool can prepend a stray character (e.g..) that causesDeclaration or statement expectederrors. Also, shell heredocs (cat << 'EOF') fail because&in TypeScript code gets interpreted as shell backgrounding. Reliable workaround: Use Pythonopen(path, 'w')viaexecute_codeorterminalto write the file content, avoiding both encoding issues and shell escaping problems. - NavCollapsible.items for merged groups: When two sidebar groups need to visually merge (no divider), add flat
items?: NavItem[]to theNavCollapsibleentry. These render below the collapsible children as regular.sidebar-nav-itembuttons. - ⚠️ Interpret shared images in project context first: When the user shares an image in an AtomK/Atomlisting session, assume it relates to the project (architecture diagrams, screenshots, mockups) rather than interpreting it generically (e.g. as a social media post). Always lead with the domain interpretation. If unsure, ask — but the project context should be the default hypothesis, not a last resort.
- 🔥 session_id must go in X-Hermes-Session-Id header, NOT request body (v3.9.7+ fix): Hermes Gateway reads session continuity from the
X-Hermes-Session-IdHTTP header only — it does NOT readbody.session_id. Puttingsession_idin the JSON body is silently ignored. Without session continuity, every chat request creates a brand-new Hermes session, so Quick Prompts (injected asusermessages) are lost after the first turn — the agent never remembers login credentials or context from previous messages. The Bridge proxy (hermes_api_proxy_handler) passes ALL request headers through to Gateway (only stripshost,transfer-encoding,connection,upgrade,authorization), soX-Hermes-Session-Idis correctly forwarded. Gateway returns the session ID in theX-Hermes-Session-Idresponse header, which Desktop already reads (hermes.ts line ~354:res.headers["x-hermes-session-id"]). Fix pattern in hermes.ts:Real impact: Without this fix, all Quick Prompts (店小秘, AtomK, Ozon, etc.) were completely non-functional — every conversation turn started fresh with no memory of injected context. User reported "prompts没有作用".// ❌ WRONG — Gateway ignores body.session_id const body = JSON.stringify({ model, messages, stream: true, session_id: sid }); // ✅ CORRECT — Gateway reads X-Hermes-Session-Id header const body = JSON.stringify({ model, messages, stream: true }); const headers = { "Content-Type": "application/json", ...getRemoteAuthHeader(), ...(sid ? { "X-Hermes-Session-Id": sid } : {}), }; - 🔥 Extension relay-config.json API key desync (v3.9.7 fix): When
getConnectionConfig().apiKeychanges (e.g. after atomlisting.com login returns a new bridge key), the Chrome Extension'srelay-config.jsonfile is NOT automatically updated. The Extension reads its key fromrelay-config.jsonon startup (viafetch('chrome-extension://.../relay-config.json')) or fromchrome.storage.sync. If the key is stale, every WS connection to the local Relay (port 3928) gets[Relay WS] Auth failed: invalid keyin a tight reconnect loop (every 2s). Fix:syncExtensionApiKey()function inchrome-bridge.tswritesgetConnectionConfig().apiKeytorelay-config.jsonon every CloudBridge registration success and everyapplyAgentcall. The function skips write if content unchanged. Call sites: (1) aftercloudBridgeState.registered = truein WSon('message')handler, (2) afterconnectCloudBridge()inapplyAgent(index.ts). When adding new connection paths, always callsyncExtensionApiKey()after anysetConnectionConfig()that changesapiKey. - 🔥 ipcMain.handle "reply was never sent" crash (v3.9.7 fix): When an
ipcMain.handlecallback throws a synchronous exception (e.g.getApiUrl()throws "No remote URL configured"), Electron reportsError: Error invoking remote method 'send-message': reply was never sentin the renderer — the promise rejects with no useful error info. Thesend-messagehandler is particularly vulnerable becausesendMessage()callsgetApiUrl()which throws if no URL is configured. Fix pattern: wrap the entire handler body in try-catch; on error, send achat-errorIPC event AND return a safe default:NEVER assume async functions called inside ipcMain.handle will always resolve — any throw propagates to Electron's internal IPC machinery which produces the unhelpful "reply was never sent" error.ipcMain.handle("send-message", async (event, ...args) => { try { // ... existing handler logic return promise; } catch (err) { const msg = err instanceof Error ? err.message : String(err); event.sender.send("chat-error", msg); return { response: "", sessionId: undefined }; } }); - Bridge protocol work: When modifying Desktop's Cloud Bridge connection logic (WS auth, commands, heartbeat, failover), load the
atomk-three-layer-archskill first — it contains the WS protocol version comparison table, BridgeInfo schema alignment, and command handling patterns. - 🔥 Bridge list dedup needed (v3.9.7 fix): The atomlisting.com
/api/v1/bridges/API returns BOTH LAN and public IP entries for the same physical Bridge (e.g.192.168.9.173:9228AND49.51.249.171:9228).setBridgeList()inchrome-bridge.tsMUST deduplicate by fingerprint (port:key:name) before storing. Crucially, when duplicates share the same fingerprint, prefer the entry with a public (non-RFC1918) IP over the private one — external Desktop clients can't reach LAN IPs. Without dedup,tryNextBridge()cycles between the two entries on every disconnect — LAN is unreachable from Windows client → failover → connects to public IP → disconnects → failover back to LAN → loop. Real incident: v3.9.6 Bridge status showed constant flickering with rapid connect/disconnect every 2 seconds. - 🔥 WS flapping guard for reconnect (v3.9.7+ fix): When a Bridge WS connection survives less than 10 seconds before disconnecting, it's "flapping" — typically a network issue or server-side rejection. The reconnect logic in
chrome-bridge.tson-close handler must: (1) trackcloudBridgeState.connectedAt = Date.now()on WS open, clear on close; (2) compareDate.now() - connectedAt— if <10000ms, add a 15s penalty delay on top of the normal exponential backoff; (3) capreconnectAttemptat 8 to prevent overflow, and raise max backoff to 30s. Without the penalty, flapping connections create a reconnect storm (1s → 2s → 4s... which is too fast for transient network issues to resolve). Logalive=Nsin the close handler for diagnosis. - 🔥 Redundant connectCloudBridge() causes flapping (v3.9.7 fix):
connectCloudBridge()MUST skip when already connected+registered to the same URL. Without this guard, any code path that callsconnectCloudBridge()while a healthy WS is already open will close+reopen the connection (the old-ws-close-then-new pattern), causing a 2-second disconnect-reconnect flap. The guard checks:cloudBridgeState.connected && cloudBridgeState.registered && cloudBridgeWs?.readyState === WebSocket.OPEN && cloudBridgeConfig?.serverUrl === config.serverUrl→returnimmediately. Real incident: Desktop showed constant Bridge status flickering becauseapplyAgentcalledconnectCloudBridgeon every startup even when already connected. - 🔥 applyAgent must prefer public IP bridges (v3.9.7 fix): The atomlisting
/api/v1/bridges/API returns both LAN (192.168.x.x) and public IP entries. Usingbridges[0]blindly often selects the LAN IP, which is unreachable from external Desktop clients.applyAgentinindex.tsmust usebridges.find(br => !isPrivateIp(br.host)) || bridges[0]whereisPrivateIpchecks RFC1918 ranges (10.x, 172.16-31.x, 192.168.x). Similarly,setBridgeList()dedup must prefer public IPs: when two entries share the same fingerprint (port:key:name), keep the one with a public IP. - ⚠️ Patch tool
***masking artifacts: When writing assignment expressions with keys/passwords (e.g.API_KEY = args.key), the safety filter replaces token values with***. If the***consumes an adjacent newline, two logically separate lines merge into one (e.g.API_KEY=*** log.info("...")). Alwaysread_filethe patched region after any assignment involving secrets. - ⚠️ Alembic not in system venv: Atomlisting_Server uses Alembic but it's not installed globally. Write migration files manually in
alembic/versions/following existing patterns. Checkalembic/versions/for the latestrevisionID to set asdown_revision. - 🔥 Config getter guard condition pitfall: When writing
getXxxConfig()functions that return an object or null, never guard on a derived/optional field likecloud_bridge_ws_url— guard on a primary field likesystem_codeoridthat is always present from the server. If the derived field is empty (common when server env vars aren't set), the entire config becomes null and all its other fields (domain, agent_api_url etc.) are hidden. Real incident:getEdgeInstanceConfig()checked!instance.cloud_bridge_ws_url→ returned null → Settings page showed zero Bridge info after successful login, because the server hadn't configuredINSTANCE_DEFAULT_DOMAINsocloud_bridge_ws_urlwas empty string whiledomain,agent_api_url, andsystem_codewere all valid. - ⚠️ Login API response fields silently discarded: The login endpoint
/api/v1/auth/loginreturnsbridgesandrecommended_bridgealongsidetokenandinstance, butloginOperation()inoperation-api.tsoriginally only saved auth and instance, discarding bridges entirely. When integrating any API, always inspect the full response schema and persist all useful fields — especially arrays like bridges that the frontend will need for display. The fix required addingUserBridgeInfotype,setUserBridgesConfig()in config.ts, new IPC channeloperation:get-bridges-cached, and Bridge card UI in Settings.tsx. - ⚠️ Incomplete locale directories: When adding i18n keys, verify that ALL locale directories (
ja/,es/,id/,pt-BR/) contain the relevant domain file (e.g.settings.ts). Some locales may only havebridge.tsand be missing other domain files entirely. Missing files cause silent fallback to English — functional but inconsistent. When adding new keys, either create the missing domain.ts files with full translations, or at minimum create them re-exporting English keys with the 1–2 new keys translated. - ⚠️ execute_code batch locale patch contamination: When using
execute_codewithpatch_toolto batch-update multiple locale files, theold_stringmust be written manually — NEVER derived fromread_fileoutput.read_fileprefixes each line withN|(e.g.113| recommended:), and if this prefix ends up inold_string, the patch succeeds but injects the line number into the key name (e.g.113| recommended:becomes the actual key in the file), causing build failures (Expected ":" but found "|"}). Always constructold_stringfrom knowledge of the file content, not fromread_filepartial output. - 🔥 Connection Settings V4.0 pattern: The Settings → Connection section must reflect V4.0 Cloud Bridge as the primary flow. Primary UI: show logged-in user's assigned Bridge list (from
operation:get-bridges-cached), one-click "Connect Recommended Bridge" button, and live CloudBridge connection status indicator (registered/registering/reconnecting/disconnected, refreshed every 3s). Secondary UI (collapsed under "Advanced / Manual V3"): the old Remote URL + API Key + SSH tunnel fields. The atomlisting.com login → bridge list → connect flow is the core user journey; V3 manual fields are power-user fallback only. Real incident: v3.8.7 showed bare Remote URL / API Key / SSH fields as the primary Connection UI, making V4.0 Bridge invisible even after successful login.
Multi-Profile Bridge Manager (BridgeManager)
When adding per-profile Cloud Bridge connections (each Hermes profile gets its own independent WebSocket), use the BridgeManager singleton pattern at src/main/bridge-manager.ts.
Architecture
BridgeManager (singleton)
└─ Map<profileName, BridgeConnection>
├─ ws: WebSocket — independent WS per profile
├─ config: CloudBridgeConfig — URL + apiKey per profile
├─ state: CloudBridgeState — connected/registered/reconnectAttempt/lastError
├─ heartbeatTimer — independent 15s ping
├─ reconnectTimer — independent exponential backoff
└─ intentionalClose / connecting — per-profile state machine flags
Full-stack integration pattern (7 files)
Adding profile-aware bridge operations requires changes across the standard 4-layer IPC stack, plus the new module + UI:
| Layer | File | What to add |
|---|---|---|
| New module | src/main/bridge-manager.ts |
BridgeManager singleton with Map<profile, BridgeConnection> |
| Exports | src/main/chrome-bridge.ts |
connectProfileBridge(), disconnectProfileBridge(), getProfileBridgeState(), getProfileBridgeSummary(), removeProfileBridge(), disconnectAllProfileBridges() — all delegate to BridgeManager |
| IPC handlers | src/main/index.ts |
bridge:profile-connect, bridge:profile-disconnect, bridge:profile-get-state, bridge:profile-get-summary, bridge:profile-remove, bridge:profile-disconnect-all |
| Preload | src/preload/index.ts |
bridgeProfileConnect(), bridgeProfileDisconnect(), bridgeProfileGetState(), bridgeProfileGetSummary(), bridgeProfileRemove(), bridgeProfileDisconnectAll() |
| Types | src/preload/index.d.ts |
Matching signatures on HermesAPI interface |
| UI | src/renderer/src/screens/ChromeBridge/ChromeBridge.tsx |
Collapsible "Profile Bridges" section listing all profiles + their states + Connect/Disconnect/Remove buttons |
Key design decisions
- Each profile owns its own WebSocket, heartbeat, and reconnect timer — no cross-profile interference.
- Backward compatible: The legacy single-connection
connectCloudBridge()/disconnectCloudBridge()in chrome-bridge.ts is preserved unchanged. - Default profile sync: When
profile === "default", the BridgeManager additionally persists todesktop.json(the legacy config path) so the existing Settings → Cloud Bridge UI still works. - Reconnect logic is per-profile: Exponential backoff (1s→2s→4s→...→30s cap), flapping guard (10s threshold + 15s penalty), and intentional-close bypass are all scoped per
BridgeConnection. - UI polls via
getSummary()every 5 seconds rather than using event listeners — simpler to implement and sufficient for the use case. onProfileBridgeStateChange()listener pattern is available (viabridgeManager.onStateChange()) for future push-based updates.
BridgeManager state machine
disconnected → connect() → connecting → WebSocket.open → auth → registered → ✅ connected
↓ (code 4001/4003)
auth failed → ❌ config cleared, no reconnect
↓ (other close)
exponential backoff → reconnect
↓ intentionalClose=true → stop
⚠️ Pitfalls
- Duplicate import blocks: When adding new imports to the existing
import { ... }block in index.ts, verify the block doesn't get corrupted (nestedimport {inside anotherimport {). Alwaysread_fileto verify afterpatch. - BridgeManager is additive: Do NOT remove or modify existing
connectCloudBridge()/disconnectCloudBridge()— BridgeManager is only for the new multi-profile UI. - BridgeConnection config and ws are nullable: Every access to
conn.config?.serverUrlandconn.ws?.readyStatemust use optional chaining. - WS send on closed socket: All
ws.send()calls must checkws.readyState === WebSocket.OPENfirst to avoidWebSocket is not openerrors. - Profile Bridge auto-assignment rule: When Cloud Bridge is connected and
profileBridgeslist is empty,refreshProfileBridges()inChromeBridge.tsxauto-syncs the global cloud connection to the "default" profile viabridgeProfileConnect("default", ...). This is intentional — users should NOT manually select a bridge from a dropdown. If implementing a profile bridge panel, show only a status indicator ("No bridge connected" / "1 profile connected"), not a selector. The auto-sync triggers when bothsummary.length === 0 && cloudState.connected && cloudState.registered.