diff --git a/electron/ipc/projects.ts b/electron/ipc/projects.ts index 148a989c..08f53f27 100644 --- a/electron/ipc/projects.ts +++ b/electron/ipc/projects.ts @@ -26,6 +26,10 @@ export function registerProjectHandlers() { })) ipcMain.handle('projects:openDialog', ipcHandler(async () => { + if (process.env.DAEMON_SMOKE_TEST === '1' && process.env.DAEMON_SMOKE_PROJECT_DIALOG_PATH) { + return process.env.DAEMON_SMOKE_PROJECT_DIALOG_PATH + } + const result = await dialog.showOpenDialog({ properties: ['openDirectory'], title: 'Select Project Folder', diff --git a/electron/ipc/spawnagents.ts b/electron/ipc/spawnagents.ts new file mode 100644 index 00000000..ec6f6ead --- /dev/null +++ b/electron/ipc/spawnagents.ts @@ -0,0 +1,53 @@ +import { ipcMain } from 'electron' +import { ipcHandler } from '../services/IpcHandlerFactory' +import * as SpawnAgents from '../services/SpawnAgentsService' + +export function registerSpawnAgentsHandlers() { + ipcMain.handle('spawnagents:list', ipcHandler(async (_event, ownerPubkey: string) => { + return SpawnAgents.listAgents(ownerPubkey) + })) + + ipcMain.handle('spawnagents:get', ipcHandler(async (_event, agentId: string) => { + return SpawnAgents.getAgent(agentId) + })) + + ipcMain.handle('spawnagents:trades', ipcHandler(async (_event, agentId: string, limit?: number, offset?: number) => { + return SpawnAgents.getTrades(agentId, limit, offset) + })) + + ipcMain.handle('spawnagents:positions', ipcHandler(async (_event, agentId: string) => { + return SpawnAgents.getPositions(agentId) + })) + + ipcMain.handle('spawnagents:events', ipcHandler(async (_event, since: number, agentId?: string, limit?: number) => { + return SpawnAgents.getEvents(since, agentId, limit) + })) + + ipcMain.handle('spawnagents:spawn-status', ipcHandler(async (_event, ref: string) => { + return SpawnAgents.pollSpawnStatus(ref) + })) + + ipcMain.handle('spawnagents:initiate-spawn', ipcHandler(async (_event, input: SpawnAgents.SpawnInput) => { + return SpawnAgents.initiateSpawn(input) + })) + + ipcMain.handle('spawnagents:initiate-spawn-child', ipcHandler(async (_event, parentAgentId: string, walletId: string, input: SpawnAgents.SpawnChildInput) => { + return SpawnAgents.initiateSpawnChild(parentAgentId, walletId, input) + })) + + ipcMain.handle('spawnagents:withdraw', ipcHandler(async (_event, agentId: string, walletId: string, amountSol: number) => { + return SpawnAgents.withdraw(agentId, walletId, amountSol) + })) + + ipcMain.handle('spawnagents:kill', ipcHandler(async (_event, agentId: string, walletId: string) => { + return SpawnAgents.killAgent(agentId, walletId) + })) + + ipcMain.handle('spawnagents:spawn-and-fund', ipcHandler(async (_event, walletId: string, input: SpawnAgents.SpawnInput) => { + return SpawnAgents.spawnAndFund(walletId, input) + })) + + ipcMain.handle('spawnagents:spawn-child-and-fund', ipcHandler(async (_event, parentAgentId: string, walletId: string, input: SpawnAgents.SpawnChildInput) => { + return SpawnAgents.spawnChildAndFund(parentAgentId, walletId, input) + })) +} diff --git a/electron/main/index.ts b/electron/main/index.ts index 3ca6988d..3c89a5d8 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -27,6 +27,8 @@ import { registerRecoveryHandlers } from '../ipc/recovery' import { registerEngineHandlers } from '../ipc/engine' import { registerToolHandlers } from '../ipc/tools' import { registerPumpFunHandlers } from '../ipc/pumpfun' +import { registerSpawnAgentsHandlers } from '../ipc/spawnagents' +import { startEventStream as startSpawnAgentsEventStream, stopEventStream as stopSpawnAgentsEventStream } from '../services/SpawnAgentsService' import { registerBrowserHandlers } from '../ipc/browser' import { registerDeployHandlers } from '../ipc/deploy' import { registerEmailHandlers } from '../ipc/email' @@ -45,6 +47,7 @@ import { registerAgentStationHandlers } from '../ipc/agentStation' import { registerReplayHandlers } from '../ipc/replay' import { registerLspHandlers } from '../ipc/lsp' import { registerTelemetryHandlers, initTelemetry } from '../ipc/telemetry' +import { flushRemoteTelemetry } from '../services/RemoteTelemetryService' import { clearLoadedWallets } from '../services/RecoveryService' import { maybeRecoverUnstableUiState, type UiRecoveryResult } from '../services/SettingsService' import { shutdownAllLspSessions } from '../services/LspService' @@ -124,6 +127,7 @@ const indexHtml = path.join(RENDERER_DIST, 'index.html') function cleanupRuntimeState() { killAllSessions() shutdownAllLspSessions() + stopSpawnAgentsEventStream() clearLoadedWallets() closeDb() } @@ -177,6 +181,8 @@ function registerAllIpc() { registerEngineHandlers() registerToolHandlers() registerPumpFunHandlers() + registerSpawnAgentsHandlers() + startSpawnAgentsEventStream() registerBrowserHandlers() registerDeployHandlers() registerEmailHandlers() @@ -403,6 +409,9 @@ async function createWindow() { app.whenReady().then(() => { if (SMOKE_TEST_MODE) console.log('[smoke] app:ready') initTelemetry(app.getVersion() || '3.0.8') + flushRemoteTelemetry().catch((err) => { + console.warn('[telemetry] Remote telemetry startup failed:', err instanceof Error ? err.message : String(err)) + }) createWindow().catch((err) => { console.error('[smoke] createWindow:error', err) }) diff --git a/electron/preload/index.ts b/electron/preload/index.ts index e4e05287..8c2716c0 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -418,6 +418,26 @@ contextBridge.exposeInMainWorld('daemon', { importKeypair: (walletId: string) => ipcRenderer.invoke('pumpfun:import-keypair', walletId), }, + spawnAgents: { + list: (ownerPubkey: string) => ipcRenderer.invoke('spawnagents:list', ownerPubkey), + get: (agentId: string) => ipcRenderer.invoke('spawnagents:get', agentId), + trades: (agentId: string, limit?: number, offset?: number) => ipcRenderer.invoke('spawnagents:trades', agentId, limit, offset), + positions: (agentId: string) => ipcRenderer.invoke('spawnagents:positions', agentId), + events: (since: number, agentId?: string, limit?: number) => ipcRenderer.invoke('spawnagents:events', since, agentId, limit), + spawnStatus: (ref: string) => ipcRenderer.invoke('spawnagents:spawn-status', ref), + initiateSpawn: (input: import('../services/SpawnAgentsService').SpawnInput) => ipcRenderer.invoke('spawnagents:initiate-spawn', input), + initiateSpawnChild: (parentAgentId: string, walletId: string, input: import('../services/SpawnAgentsService').SpawnChildInput) => ipcRenderer.invoke('spawnagents:initiate-spawn-child', parentAgentId, walletId, input), + spawnAndFund: (walletId: string, input: import('../services/SpawnAgentsService').SpawnInput) => ipcRenderer.invoke('spawnagents:spawn-and-fund', walletId, input), + spawnChildAndFund: (parentAgentId: string, walletId: string, input: import('../services/SpawnAgentsService').SpawnChildInput) => ipcRenderer.invoke('spawnagents:spawn-child-and-fund', parentAgentId, walletId, input), + withdraw: (agentId: string, walletId: string, amountSol: number) => ipcRenderer.invoke('spawnagents:withdraw', agentId, walletId, amountSol), + kill: (agentId: string, walletId: string) => ipcRenderer.invoke('spawnagents:kill', agentId, walletId), + onEvent: (callback: (ev: import('../services/SpawnAgentsService').SpawnEvent) => void) => { + const handler = (_e: unknown, ev: import('../services/SpawnAgentsService').SpawnEvent) => callback(ev) + ipcRenderer.on('spawnagents:event', handler) + return () => { ipcRenderer.off('spawnagents:event', handler) } + }, + }, + launch: { listLaunchpads: () => ipcRenderer.invoke('launch:list-launchpads'), listWalletOptions: (projectId?: string | null) => ipcRenderer.invoke('launch:list-wallet-options', projectId), diff --git a/electron/services/RemoteTelemetryService.ts b/electron/services/RemoteTelemetryService.ts new file mode 100644 index 00000000..66ef06a7 --- /dev/null +++ b/electron/services/RemoteTelemetryService.ts @@ -0,0 +1,154 @@ +import { randomUUID } from 'node:crypto' +import os from 'node:os' +import { app } from 'electron' + +import { getDb } from '../db/db' +import { sanitizeErrorMessage } from '../security/PrivacyGuard' + +const DEFAULT_TELEMETRY_ENDPOINT = 'https://daemon-landing.vercel.app/api/telemetry' +const STATE_TABLE = 'remote_telemetry_state' + +type TelemetryEventName = 'first_open' | 'daily_active' + +type TelemetryPayload = { + schemaVersion: 1 + eventName: TelemetryEventName + eventId: string + installId: string + timestamp: number + appVersion: string + platform: NodeJS.Platform + arch: NodeJS.Architecture + osVersion: string + locale: string + isPackaged: boolean + isBackfill?: boolean + estimatedFirstSeenAt?: number +} + +function remoteTelemetryEnabled(): boolean { + if (process.env.DAEMON_TELEMETRY_DISABLED === '1') return false + if (process.env.DAEMON_REMOTE_TELEMETRY_DEV === '1') return true + return app.isPackaged +} + +function endpoint(): string { + return process.env.DAEMON_TELEMETRY_ENDPOINT?.trim() || DEFAULT_TELEMETRY_ENDPOINT +} + +function utcDay(timestamp = Date.now()): string { + return new Date(timestamp).toISOString().slice(0, 10) +} + +function ensureStateTable(): void { + const db = getDb() + db.exec(` + CREATE TABLE IF NOT EXISTS ${STATE_TABLE} ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + `) +} + +function getState(key: string): string | null { + ensureStateTable() + const row = getDb() + .prepare(`SELECT value FROM ${STATE_TABLE} WHERE key = ?`) + .get(key) as { value: string } | undefined + return row?.value ?? null +} + +function setState(key: string, value: string): void { + ensureStateTable() + getDb() + .prepare(` + INSERT INTO ${STATE_TABLE} (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `) + .run(key, value, Date.now()) +} + +function getInstallId(): string { + const existing = getState('install_id') + if (existing && /^[0-9a-f-]{36}$/i.test(existing)) return existing + + const installId = randomUUID() + setState('install_id', installId) + setState('install_created_at', String(Date.now())) + return installId +} + +function firstLocalTelemetryTimestamp(): number | null { + try { + const row = getDb() + .prepare('SELECT MIN(timestamp) as firstSeenAt FROM telemetry_events') + .get() as { firstSeenAt: number | null } | undefined + return typeof row?.firstSeenAt === 'number' ? row.firstSeenAt : null + } catch { + return null + } +} + +function buildPayload(eventName: TelemetryEventName, installId: string): TelemetryPayload { + const firstSeenAt = firstLocalTelemetryTimestamp() + const installCreatedAt = Number(getState('install_created_at') ?? '') + const estimatedFirstSeenAt = firstSeenAt ?? (Number.isFinite(installCreatedAt) ? installCreatedAt : null) + + return { + schemaVersion: 1, + eventName, + eventId: randomUUID(), + installId, + timestamp: Date.now(), + appVersion: app.getVersion() || 'unknown', + platform: process.platform, + arch: process.arch, + osVersion: os.release(), + locale: app.getLocale() || Intl.DateTimeFormat().resolvedOptions().locale || 'unknown', + isPackaged: app.isPackaged, + ...(eventName === 'first_open' && firstSeenAt ? { isBackfill: true } : {}), + ...(estimatedFirstSeenAt ? { estimatedFirstSeenAt } : {}), + } +} + +async function postTelemetry(payload: TelemetryPayload): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5_000) + + try { + const res = await fetch(endpoint(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: controller.signal, + }) + + if (!res.ok) { + throw new Error(`Telemetry POST failed with HTTP ${res.status}`) + } + } finally { + clearTimeout(timeout) + } +} + +async function sendOnce(eventName: TelemetryEventName, stateKey: string, installId: string): Promise { + if (getState(stateKey)) return + + const payload = buildPayload(eventName, installId) + await postTelemetry(payload) + setState(stateKey, String(payload.timestamp)) +} + +export async function flushRemoteTelemetry(): Promise { + if (!remoteTelemetryEnabled()) return + + try { + const installId = getInstallId() + await sendOnce('first_open', 'first_open_sent_at', installId) + await sendOnce('daily_active', `daily_active_sent:${utcDay()}`, installId) + } catch (err) { + console.warn('[telemetry] Remote telemetry flush failed:', sanitizeErrorMessage(err)) + } +} diff --git a/electron/services/SpawnAgentsService.ts b/electron/services/SpawnAgentsService.ts new file mode 100644 index 00000000..25d3cb79 --- /dev/null +++ b/electron/services/SpawnAgentsService.ts @@ -0,0 +1,418 @@ +import nacl from 'tweetnacl' +import { LAMPORTS_PER_SOL, PublicKey, SystemProgram, TransactionInstruction } from '@solana/web3.js' +import { broadcast } from './EventBus' +import { LogService } from './LogService' +import { executeInstructions, getConnection, withKeypair } from './SolanaService' +import { getDb } from '../db/db' + +const BASE = 'https://spawnagents.fun/v1' +const EVENT_POLL_INTERVAL_MS = 5000 +const SPAWN_STATUS_POLL_INTERVAL_MS = 3500 +const SPAWN_STATUS_TIMEOUT_MS = 5 * 60 * 1000 + +// ------------------------------------------------------------------ types --- + +export interface SpawnAgentDna { + trades_memecoins?: boolean + trades_prediction?: boolean + aggression?: number + patience?: number + risk_tolerance?: number + sell_profit_pct?: number + sell_loss_pct?: number + max_position_pct?: number + sniper?: boolean + launchpads?: string[] + max_trade_sol?: number + buy_threshold_holders?: number + buy_threshold_volume?: number + buy_threshold_volume_1h?: number + max_positions_memecoin?: number + min_mcap?: number + max_mcap?: number + max_pair_age_hours?: number + trailing_stop_pct?: number + buy_cooldown_min?: number + require_dex_paid?: boolean + require_socials?: boolean + reproduction_cost_sol?: number + royalty_pct?: number + pm_categories?: string[] + pm_edge_threshold?: number + pm_max_position_pct?: number + pm_max_positions?: number + pm_sell_strategy?: 'target' | 'trail' | 'hold' + pm_target_pct?: number + pm_trail_pct?: number + pm_max_days_to_resolution?: number + pm_min_liquidity_usd?: number + pm_stop_loss_pct?: number + pm_price_zone?: 'any' | 'balanced' | 'cheap' | 'premium' + pm_min_confidence?: 'any' | 'medium' | 'high' + pm_max_trade_usd?: number +} + +export interface SpawnAgentRecord { + id: string + name: string + owner_wallet: string + agent_wallet: string + status: 'alive' | 'dead' + generation: number + parent_id: string | null + born_at: string + died_at: string | null + death_reason: string | null + total_pnl_sol: number + total_trades: number + initial_capital_sol: number + total_withdrawn_sol: number + total_deposited_sol: number + pnl_mode: string + paused: boolean + signing_mode: string + metaplex_asset_address: string + avatar: string | null + bio: string | null + dna: SpawnAgentDna +} + +export interface SpawnDepositInstruction { + payment_id: string + agent_id: string + agent_name: string + amount: number + reference: string + recipient: string + dna: SpawnAgentDna +} + +export interface SpawnStatusResult { + status: 'pending' | 'confirmed' | 'funding_failed' | 'expired' + tx_signature: string | null + buyer_wallet: string + agent_id: string + amount: number + agent_wallet: string | null +} + +export interface SpawnTrade { + id: number + agent_id: string + token_address: string + action: 'buy' | 'sell' + amount_sol: number + token_amount: number + pnl_sol: number | null + tx_signature: string + timestamp: string +} + +export interface SpawnMemePosition { + type: 'memecoin' + token_address: string + symbol: string + token_amount: number + value_sol: number + cost_basis_sol: number + unrealized_pnl_sol: number + unrealized_pnl_pct: number +} + +export interface SpawnPmPosition { + type: 'prediction' + id: string + market_id: string + event_title: string + market_title: string + side: 'YES' | 'NO' + contracts: number + contracts_remaining: number + cost_basis_usd: number + buy_price_cents: number + peak_price_cents: number + tp_level_sold: number + realized_pnl_usd: number + opened_at: string + force_close_pending: boolean +} + +export interface SpawnAgentPositions { + memecoin: SpawnMemePosition[] + prediction: SpawnPmPosition[] +} + +export interface SpawnEvent { + id: number + type: string + agent_id: string + data: Record + timestamp: number +} + +export interface SpawnEventsResult { + events: SpawnEvent[] + cursor: number + has_more: boolean +} + +export interface SpawnChildInput { + name: string + sol_amount: number +} + +export interface SpawnInput { + owner_wallet: string + name: string + sol_amount: number + dna: SpawnAgentDna + meta?: { avatar?: string; bio?: string } +} + +export interface WithdrawResult { + tx_signature: string + amount_sol: number + new_balance_sol: number +} + +export interface KillResult { + killed: boolean + refund_sol: number + tx_signature: string +} + +// ----------------------------------------------------------------- helpers --- + +async function apiFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + ...init, + headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) }, + }) + const body = await res.json() as T & { error?: string } + if (!res.ok) throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`) + return body +} + +function nonce(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + +async function sign(walletId: string, message: string): Promise<{ owner_wallet: string; signature: string; message: string }> { + return withKeypair(walletId, async (keypair) => { + const db = getDb() + const row = db.prepare('SELECT address FROM wallets WHERE id = ?').get(walletId) as { address: string } | undefined + if (!row) throw new Error('Wallet not found') + const messageBytes = new TextEncoder().encode(message) + const sig = nacl.sign.detached(messageBytes, keypair.secretKey) + const signature = Buffer.from(sig).toString('base64') + return { owner_wallet: row.address, signature, message } + }) +} + +// ------------------------------------------------------------------- reads --- + +export async function listAgents(ownerPubkey: string): Promise { + const data = await apiFetch<{ agents: SpawnAgentRecord[] }>(`/agents?owner=${ownerPubkey}`) + return data.agents +} + +export async function getAgent(agentId: string): Promise { + const data = await apiFetch<{ agent: SpawnAgentRecord }>(`/agents/${agentId}`) + return data.agent +} + +export async function getTrades(agentId: string, limit = 100, offset = 0): Promise<{ trades: SpawnTrade[]; limit: number; offset: number }> { + return apiFetch(`/agents/${agentId}/trades?limit=${limit}&offset=${offset}`) +} + +export async function getPositions(agentId: string): Promise { + return apiFetch(`/agents/${agentId}/positions`) +} + +export async function getEvents(since: number, agentId?: string, limit = 200): Promise { + const params = new URLSearchParams({ since: String(since), limit: String(limit) }) + if (agentId) params.set('agent_id', agentId) + return apiFetch(`/events?${params}`) +} + +export async function pollSpawnStatus(ref: string): Promise { + return apiFetch(`/spawn/status?ref=${encodeURIComponent(ref)}`) +} + +// ------------------------------------------------------------------ writes --- + +export async function initiateSpawn(input: SpawnInput): Promise { + return apiFetch('/agents', { + method: 'POST', + body: JSON.stringify(input), + }) +} + +export async function initiateSpawnChild(parentAgentId: string, walletId: string, input: SpawnChildInput): Promise { + const msg = `spawn-child:${parentAgentId}:${nonce()}` + const auth = await sign(walletId, msg) + return apiFetch(`/agents/${parentAgentId}/spawn-child`, { + method: 'POST', + body: JSON.stringify({ ...auth, ...input }), + }) +} + +export async function withdraw(agentId: string, walletId: string, amountSol: number): Promise { + const msg = `withdraw:${agentId}:${amountSol}:${nonce()}` + const auth = await sign(walletId, msg) + return apiFetch(`/agents/${agentId}/withdraw`, { + method: 'POST', + body: JSON.stringify({ ...auth, amount_sol: amountSol, method: 'phantom' }), + }) +} + +export async function killAgent(agentId: string, walletId: string): Promise { + const msg = `kill:${agentId}:${nonce()}` + const auth = await sign(walletId, msg) + return apiFetch(`/agents/${agentId}`, { + method: 'DELETE', + body: JSON.stringify({ ...auth, death_reason: 'manual' }), + }) +} + +// ----------------------------------------------------- funded spawn (saga) --- + +export interface SpawnAndFundResult { + agent: SpawnAgentRecord + deposit: SpawnDepositInstruction + funding_tx_signature: string +} + +async function sendFundingDeposit( + walletId: string, + recipient: string, + reference: string, + amountSol: number, +): Promise { + const recipientPk = new PublicKey(recipient) + const referencePk = new PublicKey(reference) + const lamports = Math.round(amountSol * LAMPORTS_PER_SOL) + if (lamports <= 0) throw new Error('Invalid deposit amount') + + return withKeypair(walletId, async (kp) => { + const transfer = SystemProgram.transfer({ + fromPubkey: kp.publicKey, + toPubkey: recipientPk, + lamports, + }) + // Solana Pay pattern — embed reference as a readonly key so the indexer can match + const ix = new TransactionInstruction({ + programId: transfer.programId, + keys: [...transfer.keys, { pubkey: referencePk, isSigner: false, isWritable: false }], + data: transfer.data, + }) + const result = await executeInstructions(getConnection(), [ix], [kp], { timeoutMs: 60_000 }) + return result.signature + }) +} + +async function pollUntilConfirmed(ref: string): Promise { + const deadline = Date.now() + SPAWN_STATUS_TIMEOUT_MS + while (Date.now() < deadline) { + const status = await pollSpawnStatus(ref) + if (status.status === 'confirmed') return status + if (status.status === 'funding_failed' || status.status === 'expired') { + throw new Error(`Spawn ${status.status}`) + } + await new Promise((r) => setTimeout(r, SPAWN_STATUS_POLL_INTERVAL_MS)) + } + throw new Error('Timed out waiting for spawn confirmation') +} + +export async function spawnAndFund(walletId: string, input: SpawnInput): Promise { + const deposit = await initiateSpawn(input) + const funding_tx_signature = await sendFundingDeposit(walletId, deposit.recipient, deposit.reference, deposit.amount) + await pollUntilConfirmed(deposit.reference) + const agent = await getAgent(deposit.agent_id) + return { agent, deposit, funding_tx_signature } +} + +export async function spawnChildAndFund( + parentAgentId: string, + walletId: string, + input: SpawnChildInput, +): Promise { + const deposit = await initiateSpawnChild(parentAgentId, walletId, input) + const funding_tx_signature = await sendFundingDeposit(walletId, deposit.recipient, deposit.reference, deposit.amount) + await pollUntilConfirmed(deposit.reference) + const agent = await getAgent(deposit.agent_id) + return { agent, deposit, funding_tx_signature } +} + +// ------------------------------------------------------- event poll stream --- + +let eventTimer: ReturnType | null = null +let eventCursor = Date.now() + +async function tickEvents(): Promise { + try { + const res = await getEvents(eventCursor, undefined, 200) + if (res.events.length > 0) { + for (const ev of res.events) { + broadcast('spawnagents:event', ev) + } + eventCursor = res.cursor + broadcast('spawnagents:cursor', eventCursor) + } + } catch (err) { + LogService.warn('spawnagents', `event poll failed: ${err instanceof Error ? err.message : String(err)}`) + } +} + +export function startEventStream(): void { + if (eventTimer) return + eventCursor = Date.now() + eventTimer = setInterval(() => { void tickEvents() }, EVENT_POLL_INTERVAL_MS) +} + +export function stopEventStream(): void { + if (eventTimer) { + clearInterval(eventTimer) + eventTimer = null + } +} + +// --------------------------------------------------- client-side gate utils --- + +export interface AgentGateStatus { + can_kill: boolean + can_withdraw: boolean + kill_reason?: string + withdraw_reason?: string +} + +const KILL_COOLDOWN_MS = 24 * 60 * 60 * 1000 +const CHILD_ACTIVITY_GATE_TRADES = 10 +const CHILD_ACTIVITY_GATE_MS = 7 * 24 * 60 * 60 * 1000 + +export function computeGate(agent: SpawnAgentRecord): AgentGateStatus { + const out: AgentGateStatus = { can_kill: true, can_withdraw: true } + if (agent.status !== 'alive') { + return { can_kill: false, can_withdraw: false, kill_reason: 'Agent is dead', withdraw_reason: 'Agent is dead' } + } + const bornMs = Date.parse(agent.born_at.replace(' ', 'T') + 'Z') + if (Number.isFinite(bornMs) && Date.now() - bornMs < KILL_COOLDOWN_MS) { + out.can_kill = false + const hoursLeft = Math.ceil((KILL_COOLDOWN_MS - (Date.now() - bornMs)) / (60 * 60 * 1000)) + out.kill_reason = `24h post-spawn cooldown — ${hoursLeft}h left` + } + if (agent.parent_id) { + const ageOk = Number.isFinite(bornMs) && Date.now() - bornMs >= CHILD_ACTIVITY_GATE_MS + const tradesOk = agent.total_trades >= CHILD_ACTIVITY_GATE_TRADES + if (!ageOk && !tradesOk) { + const reason = `Child agents need ${CHILD_ACTIVITY_GATE_TRADES} trades or 7 days (${agent.total_trades}/${CHILD_ACTIVITY_GATE_TRADES} trades)` + out.can_withdraw = false + out.withdraw_reason = reason + if (out.can_kill) { + out.can_kill = false + out.kill_reason = reason + } + } + } + return out +} diff --git a/package.json b/package.json index 811c2365..ca2dcd94 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "daemon", - "version": "3.0.12", + "version": "3.0.13", "main": "dist-electron/main/index.js", "description": "Solana-native agent workbench for verifiable AI development", "author": "nullxnothing", diff --git a/public/07d9eb3d-9103-431f-9b41-b103a31ae640.png b/public/07d9eb3d-9103-431f-9b41-b103a31ae640.png new file mode 100644 index 00000000..971a37b0 Binary files /dev/null and b/public/07d9eb3d-9103-431f-9b41-b103a31ae640.png differ diff --git a/public/fd4cea3d-e8b7-46d6-bbb9-e5ad52f59b84.png b/public/fd4cea3d-e8b7-46d6-bbb9-e5ad52f59b84.png new file mode 100644 index 00000000..d6857375 Binary files /dev/null and b/public/fd4cea3d-e8b7-46d6-bbb9-e5ad52f59b84.png differ diff --git a/public/spawnagents.jpg b/public/spawnagents.jpg new file mode 100644 index 00000000..f8a44203 Binary files /dev/null and b/public/spawnagents.jpg differ diff --git a/public/spawnagents.png b/public/spawnagents.png new file mode 100644 index 00000000..409e0303 Binary files /dev/null and b/public/spawnagents.png differ diff --git a/scripts/smoke/project-scaffold.mjs b/scripts/smoke/project-scaffold.mjs new file mode 100644 index 00000000..c466c81f --- /dev/null +++ b/scripts/smoke/project-scaffold.mjs @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { createRequire } from 'node:module' +import net from 'node:net' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const require = createRequire(import.meta.url) +const repoRoot = path.resolve(__dirname, '..', '..') +const electronBinary = process.env.DAEMON_SMOKE_ELECTRON || require('electron') +const mainEntry = path.join(repoRoot, 'dist-electron', 'main', 'index.js') +const userDataDir = mkdtempSync(path.join(tmpdir(), 'daemon-scaffold-user-')) +const scaffoldRoot = mkdtempSync(path.join(tmpdir(), 'daemon-scaffold-target-')) +const projectName = `SmokeScaffold${Date.now()}` +const projectPath = path.join(scaffoldRoot, projectName) +const displayProjectPath = `${scaffoldRoot}/${projectName}` + +let electronProcess +let browser +const rendererFailures = [] + +function getFreePort() { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.unref() + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Unable to allocate port'))) + return + } + server.close(() => resolve(address.port)) + }) + }) +} + +function waitForPort(port, timeoutMs = 30000) { + const deadline = Date.now() + timeoutMs + return new Promise((resolve, reject) => { + const tryConnect = () => { + const socket = net.connect({ port, host: '127.0.0.1' }) + socket.once('connect', () => { + socket.destroy() + resolve() + }) + socket.once('error', () => { + socket.destroy() + if (Date.now() >= deadline) { + reject(new Error(`Timed out waiting for port ${port}`)) + return + } + setTimeout(tryConnect, 250) + }) + } + tryConnect() + }) +} + +async function getPage() { + const deadline = Date.now() + 30000 + while (Date.now() < deadline) { + const context = browser?.contexts()?.[0] + const page = context?.pages()?.[0] + if (page) return page + await new Promise((resolve) => setTimeout(resolve, 250)) + } + throw new Error('Timed out waiting for a BrowserWindow page') +} + +function attachPageDiagnostics(page) { + page.on('pageerror', (error) => { + rendererFailures.push(error.message) + }) + page.on('console', (message) => { + if (message.type() === 'error') rendererFailures.push(message.text()) + }) +} + +async function waitForAppReady(page) { + await page.waitForFunction(() => !!window.daemon, { timeout: 30000 }) + await page.waitForSelector('.titlebar', { timeout: 30000 }) + await page.waitForSelector('.main-layout', { timeout: 30000 }) + await page.waitForSelector('.app[data-app-ready="true"]', { timeout: 30000 }) +} + +async function seedAppState(page) { + await page.evaluate(async () => { + await window.daemon.settings.setOnboardingComplete(true) + await window.daemon.settings.setWorkspaceProfile({ name: 'custom', toolVisibility: {} }) + }) +} + +async function openNewProject(page) { + const drawerVisible = await page.locator('.command-drawer').isVisible().catch(() => false) + if (!drawerVisible) { + await page.locator('.sidebar-icon--tools').click() + await page.waitForSelector('.command-drawer', { timeout: 30000 }) + } + + const drawerSearchVisible = await page.locator('.drawer-search').isVisible().catch(() => false) + if (!drawerSearchVisible) { + await page.keyboard.press('Escape') + await page.waitForSelector('.drawer-search', { timeout: 30000 }) + } + + await page.locator('.drawer-tool-card', { hasText: 'New Project' }).first().click() + await page.waitForSelector('.starter-panel', { timeout: 30000 }) +} + +async function runScaffoldFlow(page) { + await page.getByRole('button', { name: /Trading Bot Jupiter swap bot/i }).click() + await page.getByPlaceholder('my-solana-project').fill(projectName) + await page.getByRole('button', { name: 'Browse' }).click() + await page.waitForFunction((expectedPath) => { + return document.body.textContent?.includes(expectedPath) + }, displayProjectPath, { timeout: 30000 }) + await page.getByRole('button', { name: 'Build Project' }).click() + await page.waitForSelector('.terminal-tab.active', { timeout: 30000 }) + await page.waitForFunction(() => !document.querySelector('.starter-panel'), { timeout: 30000 }) +} + +function verifyScaffoldFiles() { + const expectedFiles = [ + 'package.json', + 'README.md', + '.env.example', + 'tsconfig.json', + path.join('src', 'config.ts'), + path.join('src', 'index.ts'), + path.join('src', 'strategy.ts'), + ] + + for (const file of expectedFiles) { + assert.equal(existsSync(path.join(projectPath, file)), true, `${file} was not written`) + } + + const packageJson = JSON.parse(readFileSync(path.join(projectPath, 'package.json'), 'utf8')) + assert.equal(packageJson.name, projectName.toLowerCase()) + + const combined = expectedFiles + .filter((file) => file.endsWith('.json') || file.endsWith('.md') || file.endsWith('.ts') || file.endsWith('.example')) + .map((file) => readFileSync(path.join(projectPath, file), 'utf8')) + .join('\n') + assert.equal(combined.includes('claude --model'), false, 'scaffold includes old Claude terminal command') + assert.equal(combined.includes('dangerously-skip-permissions'), false, 'scaffold includes unsafe agent flag') +} + +async function run() { + const cdpPort = await getFreePort() + const electronArgs = process.platform === 'linux' || process.env.CI + ? ['--no-sandbox', mainEntry] + : [mainEntry] + + electronProcess = spawn(electronBinary, electronArgs, { + cwd: repoRoot, + env: { + ...process.env, + DAEMON_SMOKE_TEST: '1', + DAEMON_SMOKE_CDP_PORT: String(cdpPort), + DAEMON_SMOKE_PROJECT_DIALOG_PATH: scaffoldRoot, + DAEMON_USER_DATA_DIR: userDataDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + electronProcess.stdout.on('data', (chunk) => process.stdout.write(chunk)) + electronProcess.stderr.on('data', (chunk) => process.stderr.write(chunk)) + + await waitForPort(cdpPort) + browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`) + + const page = await getPage() + attachPageDiagnostics(page) + await waitForAppReady(page) + await seedAppState(page) + await page.reload() + await waitForAppReady(page) + + await openNewProject(page) + await runScaffoldFlow(page) + verifyScaffoldFiles() + + assert.equal(rendererFailures.length, 0, `renderer failures detected:\n${rendererFailures.join('\n')}`) +} + +try { + await run() + console.log('Project scaffold smoke passed') +} finally { + await browser?.close().catch(() => {}) + if (electronProcess && electronProcess.exitCode === null) { + electronProcess.kill('SIGTERM') + await new Promise((resolve) => { + const timer = setTimeout(() => { + electronProcess.kill('SIGKILL') + resolve() + }, 5000) + electronProcess.once('exit', () => { + clearTimeout(timer) + resolve() + }) + }) + } + rmSync(userDataDir, { recursive: true, force: true }) + rmSync(scaffoldRoot, { recursive: true, force: true }) +} diff --git a/src/components/CommandDrawer/CommandDrawer.tsx b/src/components/CommandDrawer/CommandDrawer.tsx index f4e1548e..5db91757 100644 --- a/src/components/CommandDrawer/CommandDrawer.tsx +++ b/src/components/CommandDrawer/CommandDrawer.tsx @@ -314,6 +314,18 @@ function AgentWorkIcon({ size = 18 }: { size?: number }) { ) } +function SpawnAgentsIcon({ size = 18 }: { size?: number }) { + return ( + + + + + + + + ) +} + export const TOOL_ICONS: Record> = { git: GitIcon, deploy: DeployIcon, env: EnvIcon, wallet: WalletIcon, email: EmailIcon, browser: BrowserIcon, @@ -323,6 +335,7 @@ export const TOOL_ICONS: Record> = { dashboard: DashboardIcon, sessions: SessionsIcon, hackathon: HackathonIcon, plugins: PluginsIcon, recovery: RecoveryIcon, pro: ProIcon, activity: ActivityIcon, 'agent-station': AgentStationIcon, 'agent-work': AgentWorkIcon, + 'spawnagents': SpawnAgentsIcon, } // Tool name lookup @@ -355,6 +368,7 @@ const loadActivityTimeline = () => import('../../panels/ActivityTimeline/Activit const loadAgentStation = () => import('../../panels/AgentStation/AgentStation') const loadReplayEngine = () => import('../../panels/ReplayEngine/ReplayEngine') const loadAgentWork = () => import('../../panels/AgentWork/AgentWork') +const loadSpawnAgents = () => import('../../panels/SpawnAgents/SpawnAgentsPanel') const GitPanel = lazyNamedWithReload('git-panel', loadGitPanel, (m) => m.GitPanel) const EnvManager = lazyNamedWithReload('env-manager', loadEnvManager, (m) => m.EnvManager) @@ -382,6 +396,7 @@ const ActivityTimeline = lazyNamedWithReload('activity-timeline', loadActivityTi const AgentStationPanel = lazyNamedWithReload('agent-station', loadAgentStation, (m) => m.AgentStation) const ReplayEngine = lazyNamedWithReload('replay-engine', loadReplayEngine, (m) => m.ReplayEngine) const AgentWork = lazyNamedWithReload('agent-work', loadAgentWork, (m) => m.AgentWork) +const SpawnAgentsPanel = lazyNamedWithReload('spawnagents', loadSpawnAgents, (m) => m.SpawnAgentsPanel) // Per-tool accent colors for the drawer grid and sidebar export const TOOL_COLORS: Record = { @@ -412,12 +427,13 @@ export const TOOL_COLORS: Record = { activity: '#2dd4bf', 'agent-station': '#c4b5fd', 'agent-work': '#38bdf8', + 'spawnagents': '#c41e3a', } // Built-in tools registry — exported so other modules can enumerate all tool IDs // Note: 'browser' is intentionally excluded — it opens as a pinned editor tab (Ctrl+Shift+B), not a drawer panel export const BUILTIN_TOOLS: DrawerTool[] = [ - { id: 'starter', name: 'New Project', description: 'Scaffold a Solana project with AI', icon: StarterIcon, component: ProjectStarter, preload: () => { void loadProjectStarter() }, category: 'dev' }, + { id: 'starter', name: 'New Project', description: 'Scaffold a Solana project template', icon: StarterIcon, component: ProjectStarter, preload: () => { void loadProjectStarter() }, category: 'dev' }, { id: 'git', name: 'Git', description: 'Source control', icon: GitIcon, component: GitPanel, preload: () => { void loadGitPanel() }, category: 'dev' }, { id: 'deploy', name: 'Deploy', description: 'Vercel & Railway', icon: DeployIcon, component: DeployPanel, preload: () => { void loadDeployPanel() }, category: 'dev' }, { id: 'env', name: 'Env', description: 'Environment variables', icon: EnvIcon, component: EnvManager, preload: () => { void loadEnvManager() }, category: 'dev' }, @@ -443,6 +459,7 @@ export const BUILTIN_TOOLS: DrawerTool[] = [ { id: 'plugins', name: 'Plugins', description: 'Manage plugins', icon: PluginsIcon, component: PluginManager, preload: () => { void loadPluginManager() }, category: 'system' }, { id: 'recovery', name: 'Recovery', description: 'Crash recovery and snapshots', icon: RecoveryIcon, component: RecoveryPanel, preload: () => { void loadRecoveryPanel() }, category: 'system' }, { id: 'agent-station', name: 'Agent Station', description: 'Scaffold and run Solana AI agents powered by SAK', icon: AgentStationIcon, component: AgentStationPanel, preload: () => { void loadAgentStation() }, category: 'crypto' }, + { id: 'spawnagents', name: 'SpawnAgents', description: 'Spawn autonomous Solana trading agents with custom DNA', icon: SpawnAgentsIcon, component: SpawnAgentsPanel, preload: () => { void loadSpawnAgents() }, category: 'crypto' }, ] const BUILTIN_TOOL_PRELOADERS = new Map( diff --git a/src/constants/toolRegistry.ts b/src/constants/toolRegistry.ts index a0c63bdc..91f13c24 100644 --- a/src/constants/toolRegistry.ts +++ b/src/constants/toolRegistry.ts @@ -35,6 +35,7 @@ export const TOOL_REGISTRY: ToolRegistryEntry[] = [ { id: 'recovery', name: 'Recovery', moduleClass: 'addon', surface: 'drawer' }, { id: 'activity', name: 'Activity', moduleClass: 'addon', surface: 'drawer' }, { id: 'agent-station', name: 'Agent Station', moduleClass: 'addon', surface: 'drawer' }, + { id: 'spawnagents', name: 'SpawnAgents', moduleClass: 'addon', surface: 'drawer' }, { id: 'browser', name: 'Browser', moduleClass: 'addon', surface: 'tab' }, ] as const diff --git a/src/panels/Editor/Editor.tsx b/src/panels/Editor/Editor.tsx index 606b3ef0..75baebe9 100644 --- a/src/panels/Editor/Editor.tsx +++ b/src/panels/Editor/Editor.tsx @@ -1,6 +1,23 @@ import { Suspense, useRef, useCallback, useState, useEffect, useMemo, type ComponentType, type LazyExoticComponent } from 'react' import MonacoEditor, { type OnMount, type BeforeMount, loader } from '@monaco-editor/react' -import * as monaco from 'monaco-editor' +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api' +import 'monaco-editor/esm/vs/basic-languages/typescript/typescript.contribution' +import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution' +import 'monaco-editor/esm/vs/basic-languages/css/css.contribution' +import 'monaco-editor/esm/vs/basic-languages/html/html.contribution' +import 'monaco-editor/esm/vs/basic-languages/markdown/markdown.contribution' +import 'monaco-editor/esm/vs/basic-languages/python/python.contribution' +import 'monaco-editor/esm/vs/basic-languages/rust/rust.contribution' +import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution' +import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution' +import 'monaco-editor/esm/vs/basic-languages/shell/shell.contribution' +import 'monaco-editor/esm/vs/basic-languages/xml/xml.contribution' +import 'monaco-editor/esm/vs/basic-languages/dockerfile/dockerfile.contribution' +import 'monaco-editor/esm/vs/basic-languages/ini/ini.contribution' +import 'monaco-editor/esm/vs/language/json/monaco.contribution' +import 'monaco-editor/esm/vs/language/typescript/monaco.contribution' +import 'monaco-editor/esm/vs/language/css/monaco.contribution' +import 'monaco-editor/esm/vs/language/html/monaco.contribution' import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker' import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker' diff --git a/src/panels/GitPanel/GitPanel.tsx b/src/panels/GitPanel/GitPanel.tsx index dc343ca1..13177084 100644 --- a/src/panels/GitPanel/GitPanel.tsx +++ b/src/panels/GitPanel/GitPanel.tsx @@ -1,19 +1,18 @@ import { useState, useEffect, useCallback } from 'react' import { useUIStore } from '../../store/ui' +import { useGitStore, useGitProject } from '../../store/git' import { useWorkflowShellStore } from '../../store/workflowShell' import { useOnboardingStore } from '../../store/onboarding' import { confirm } from '../../store/confirm' import { useNotificationsStore } from '../../store/notifications' -import type { GitFile, GitCommit, DeployStatus } from '../../../electron/shared/types' +import type { DeployStatus } from '../../../electron/shared/types' import './GitPanel.css' export function GitPanel() { const projectPath = useUIStore((s) => s.activeProjectPath) const activeProjectId = useUIStore((s) => s.activeProjectId) - const [branch, setBranch] = useState(null) - const [branches, setBranches] = useState([]) - const [files, setFiles] = useState([]) - const [commits, setCommits] = useState([]) + const gitState = useGitProject(projectPath) + const { branch, branches, files, commits, stashCount, latestStashMessage, error: storeError } = gitState const [commitMsg, setCommitMsg] = useState('') const [pushing, setPushing] = useState(false) const [committing, setCommitting] = useState(false) @@ -33,10 +32,9 @@ export function GitPanel() { const [savingStash, setSavingStash] = useState(false) const [poppingStash, setPoppingStash] = useState(false) const [syncing, setSyncing] = useState(false) - const [stashCount, setStashCount] = useState(0) - const [latestStashMessage, setLatestStashMessage] = useState(null) - const [error, setError] = useState(null) + const [localError, setLocalError] = useState(null) const [deployStatus, setDeployStatus] = useState(null) + const error = localError ?? storeError const loadDeployStatus = useCallback(async () => { if (!activeProjectId) return @@ -64,32 +62,23 @@ export function GitPanel() { const load = useCallback(async () => { if (!projectPath) return - setError(null) - - const [brRes, statusRes, logRes, stashRes] = await Promise.all([ - window.daemon.git.branches(projectPath), - window.daemon.git.status(projectPath), - window.daemon.git.log(projectPath), - window.daemon.git.stashList(projectPath), - ]) - - if (brRes.ok && brRes.data) { - setBranch(brRes.data.current) - setBranches(brRes.data.branches) - } - if (statusRes.ok && statusRes.data) setFiles(statusRes.data) - else { - maybeShowGitHubOnboarding(statusRes.error) - setError(parseGitError(statusRes.error)) - } - if (logRes.ok && logRes.data) setCommits(logRes.data) - if (stashRes.ok && stashRes.data) { - setStashCount(stashRes.data.length) - setLatestStashMessage(stashRes.data[0]?.message ?? null) - } + setLocalError(null) + await useGitStore.getState().refresh(projectPath) + const next = useGitStore.getState().byProject[projectPath] + if (next?.error) maybeShowGitHubOnboarding(next.error) + }, [projectPath]) + + useEffect(() => { + if (!projectPath) return + void useGitStore.getState().refreshIfStale(projectPath) }, [projectPath]) - useEffect(() => { load() }, [load]) + useEffect(() => { + if (!projectPath) return + const onFocus = () => { void useGitStore.getState().refresh(projectPath) } + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) + }, [projectPath]) const handleStage = async (filePath: string) => { if (!projectPath) return @@ -125,12 +114,12 @@ export function GitPanel() { if (!hasStagedFiles) return setCommitting(true) - setError(null) + setLocalError(null) const commitRes = await window.daemon.git.commit(projectPath, commitMsg.trim()) if (!commitRes.ok) { maybeShowGitHubOnboarding(commitRes.error) - setError(parseGitError(commitRes.error) ?? 'Commit failed') + setLocalError(parseGitError(commitRes.error) ?? 'Commit failed') setCommitting(false) return } @@ -143,25 +132,25 @@ export function GitPanel() { const handleGenerateCommitMsg = async () => { if (!projectPath) return setGeneratingCommitMsg(true) - setError(null) + setLocalError(null) const diffRes = await window.daemon.git.diffStaged(projectPath) if (!diffRes.ok) { maybeShowGitHubOnboarding(diffRes.error) - setError(parseGitError(diffRes.error)) + setLocalError(parseGitError(diffRes.error)) setGeneratingCommitMsg(false) return } if (!diffRes.data?.trim()) { - setError('Stage files first, then generate a smart commit message.') + setLocalError('Stage files first, then generate a smart commit message.') setGeneratingCommitMsg(false) return } const suggestionRes = await window.daemon.claude.suggestCommitMessage(diffRes.data) if (!suggestionRes.ok || !suggestionRes.data) { - setError(suggestionRes.error ?? 'Failed to generate commit message') + setLocalError(suggestionRes.error ?? 'Failed to generate commit message') setGeneratingCommitMsg(false) return } @@ -184,13 +173,13 @@ export function GitPanel() { if (!ok) return } setPushing(true) - setError(null) + setLocalError(null) const res = await window.daemon.git.push(projectPath) setPushing(false) if (!res.ok) { maybeShowGitHubOnboarding(res.error) const msg = parseGitError(res.error) ?? 'Push failed' - setError(msg) + setLocalError(msg) useNotificationsStore.getState().pushError(msg, 'Git push') } else { useNotificationsStore.getState().pushSuccess(`Pushed ${branch ?? 'branch'}`, 'Git') @@ -203,18 +192,18 @@ export function GitPanel() { if (!projectPath) return const res = await window.daemon.git.checkout(projectPath, br) if (res.ok) load() - else setError(res.error ?? 'Checkout failed') + else setLocalError(res.error ?? 'Checkout failed') } const handleCreateBranch = async () => { if (!projectPath || !newBranchName.trim()) return setCreatingBranch(true) - setError(null) + setLocalError(null) const res = await window.daemon.git.createBranch(projectPath, newBranchName.trim()) if (!res.ok) { - setError(parseGitError(res.error) ?? 'Failed to create branch') + setLocalError(parseGitError(res.error) ?? 'Failed to create branch') setCreatingBranch(false) return } @@ -230,11 +219,11 @@ export function GitPanel() { if (!projectPath || !newTagName.trim()) return setCreatingTag(true) - setError(null) + setLocalError(null) const res = await window.daemon.git.createTag(projectPath, newTagName.trim()) if (!res.ok) { - setError(parseGitError(res.error) ?? 'Failed to create tag') + setLocalError(parseGitError(res.error) ?? 'Failed to create tag') setCreatingTag(false) return } @@ -249,12 +238,12 @@ export function GitPanel() { const handleFetch = async () => { if (!projectPath) return setSyncing(true) - setError(null) + setLocalError(null) const res = await window.daemon.git.fetch(projectPath) setSyncing(false) if (!res.ok) { maybeShowGitHubOnboarding(res.error) - setError(parseGitError(res.error) ?? 'Fetch failed') + setLocalError(parseGitError(res.error) ?? 'Fetch failed') return } load() @@ -263,12 +252,12 @@ export function GitPanel() { const handlePull = async () => { if (!projectPath) return setSyncing(true) - setError(null) + setLocalError(null) const res = await window.daemon.git.pull(projectPath) setSyncing(false) if (!res.ok) { maybeShowGitHubOnboarding(res.error) - setError(parseGitError(res.error) ?? 'Pull failed') + setLocalError(parseGitError(res.error) ?? 'Pull failed') return } load() @@ -277,11 +266,11 @@ export function GitPanel() { const handleStashSave = async () => { if (!projectPath) return setSavingStash(true) - setError(null) + setLocalError(null) const res = await window.daemon.git.stashSave(projectPath, stashMessage) setSavingStash(false) if (!res.ok) { - setError(parseGitError(res.error) ?? 'Failed to save stash') + setLocalError(parseGitError(res.error) ?? 'Failed to save stash') return } @@ -294,11 +283,11 @@ export function GitPanel() { const handleStashPop = async () => { if (!projectPath) return setPoppingStash(true) - setError(null) + setLocalError(null) const res = await window.daemon.git.stashPop(projectPath) setPoppingStash(false) if (!res.ok) { - setError(parseGitError(res.error) ?? 'Failed to restore stash') + setLocalError(parseGitError(res.error) ?? 'Failed to restore stash') return } @@ -331,11 +320,11 @@ export function GitPanel() { confirmLabel: 'Discard', }) if (!ok) return - setError(null) + setLocalError(null) const res = await window.daemon.git.discard(projectPath, filePath) if (!res.ok) { const msg = res.error ?? 'Discard failed' - setError(msg) + setLocalError(msg) useNotificationsStore.getState().pushError(msg, 'Git discard') return } diff --git a/src/panels/IconSidebar/IconSidebar.tsx b/src/panels/IconSidebar/IconSidebar.tsx index 858c3600..25c6f19a 100644 --- a/src/panels/IconSidebar/IconSidebar.tsx +++ b/src/panels/IconSidebar/IconSidebar.tsx @@ -81,17 +81,20 @@ function HackathonGlyph({ size = 16 }: { size?: number }) { ) } -function ToolsGlyph({ size = 18 }: { size?: number }) { +function ToolsGlyph({ size = 20 }: { size?: number }) { const gradientId = useId() return ( - + - + + + + ) } @@ -353,21 +356,6 @@ export function IconSidebar({ showExplorer, onToggleExplorer, onOpenAgentLaunche {SettingsIcon ? : null} - {/* Colosseum / Hackathon — opens in drawer */} - - {/* Social Links */} {children}\n}\n`, + }, + { + path: 'app/page.tsx', + content: `import { runtimeConfig } from '../src/config'\n\nexport default function Home() {\n return (\n
\n
\n

DAEMON Scaffold

\n

${projectName}

\n

${template.description}

\n
\n
\n
RPC{runtimeConfig.rpcUrl}
\n
Venue{runtimeConfig.venue}
\n
Cluster{runtimeConfig.cluster}
\n
\n
\n )\n}\n`, + }, + { + path: 'app/globals.css', + content: `:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #080b0f; color: #f5f7fb; }\nbody { margin: 0; }\n.page-shell { min-height: 100vh; padding: 48px; background: #080b0f; }\n.workspace-header { max-width: 760px; }\n.eyebrow { color: #14f195; font-size: 12px; letter-spacing: .12em; text-transform: uppercase; }\nh1 { font-size: 44px; margin: 8px 0; }\np { color: #a8b3c7; line-height: 1.6; }\n.panel-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 32px; max-width: 960px; }\n.panel-grid div { border: 1px solid #263040; border-radius: 8px; padding: 16px; background: #101620; }\n.panel-grid span { display: block; color: #7d8aa3; font-size: 12px; margin-bottom: 8px; }\n.panel-grid strong { font-size: 14px; overflow-wrap: anywhere; }\n@media (max-width: 720px) { .page-shell { padding: 28px; } .panel-grid { grid-template-columns: 1fr; } h1 { font-size: 34px; } }\n`, + }, + { path: 'next.config.mjs', content: `const nextConfig = {}\nexport default nextConfig\n` }, + ] +} + +function anchorFiles(projectName: string): ScaffoldFile[] { + const crateName = packageName(projectName).replace(/-/g, '_') + return [ + { path: 'Anchor.toml', content: `[features]\nseeds = false\nskip-lint = false\n\n[programs.localnet]\n${crateName} = "11111111111111111111111111111111"\n\n[provider]\ncluster = "localnet"\nwallet = "~/.config/solana/id.json"\n\n[scripts]\ntest = "pnpm vitest run"\n` }, + { path: 'programs/' + crateName + '/Cargo.toml', content: `[package]\nname = "${crateName}"\nversion = "0.1.0"\nedition = "2021"\n\n[lib]\ncrate-type = ["cdylib", "lib"]\nname = "${crateName}"\n\n[dependencies]\nanchor-lang = "0.32.0"\n` }, + { path: 'programs/' + crateName + '/src/lib.rs', content: `use anchor_lang::prelude::*;\n\ndeclare_id!("11111111111111111111111111111111");\n\n#[program]\npub mod ${crateName} {\n use super::*;\n\n pub fn initialize(ctx: Context, value: u64) -> Result<()> {\n ctx.accounts.state.authority = ctx.accounts.authority.key();\n ctx.accounts.state.value = value;\n Ok(())\n }\n\n pub fn update(ctx: Context, value: u64) -> Result<()> {\n ctx.accounts.state.value = value;\n Ok(())\n }\n}\n\n#[derive(Accounts)]\npub struct Initialize<'info> {\n #[account(init, payer = authority, space = 8 + State::INIT_SPACE)]\n pub state: Account<'info, State>,\n #[account(mut)]\n pub authority: Signer<'info>,\n pub system_program: Program<'info, System>,\n}\n\n#[derive(Accounts)]\npub struct Update<'info> {\n #[account(mut, has_one = authority)]\n pub state: Account<'info, State>,\n pub authority: Signer<'info>,\n}\n\n#[account]\n#[derive(InitSpace)]\npub struct State {\n pub authority: Pubkey,\n pub value: u64,\n}\n` }, + { path: 'tests/' + crateName + '.test.ts', content: `import { describe, expect, it } from 'vitest'\n\ndescribe('${crateName}', () => {\n it('has a placeholder client test', () => {\n expect('${crateName}').toContain('${crateName}')\n })\n})\n` }, + ] +} + +function nodeAppFiles(template: Template): ScaffoldFile[] { + const title = template.name + return [ + { + path: 'src/config.ts', + content: `import 'dotenv/config'\n\nexport const config = {\n rpcUrl: process.env.RPC_URL ?? 'https://api.devnet.solana.com',\n heliusApiKey: process.env.HELIUS_API_KEY ?? '',\n walletPath: process.env.WALLET_PATH ?? '~/.config/solana/id.json',\n venue: process.env.VENUE ?? 'drift',\n marketIndex: Number(process.env.MARKET_INDEX ?? '0'),\n}\n`, + }, + { + path: 'src/logger.ts', + content: `import pino from 'pino'\n\nexport const logger = pino({\n level: process.env.LOG_LEVEL ?? 'info',\n})\n`, + }, + { + path: 'src/index.ts', + content: `import { config } from './config'\nimport { logger } from './logger'\n\nlet shuttingDown = false\n\nasync function main() {\n logger.info({ template: '${title}', rpcUrl: config.rpcUrl, venue: config.venue }, 'starting DAEMON scaffold')\n logger.info('replace src/strategy.ts with your project-specific logic')\n}\n\nprocess.on('SIGINT', () => { shuttingDown = true; logger.warn({ shuttingDown }, 'shutdown requested'); process.exit(0) })\nprocess.on('SIGTERM', () => { shuttingDown = true; logger.warn({ shuttingDown }, 'shutdown requested'); process.exit(0) })\n\nmain().catch((err) => {\n logger.error({ err }, 'fatal startup error')\n process.exit(1)\n})\n`, + }, + { + path: 'src/strategy.ts', + content: `export interface StrategySignal {\n action: 'hold' | 'buy' | 'sell' | 'open-long' | 'open-short' | 'close'\n reason: string\n}\n\nexport async function evaluateStrategy(): Promise {\n return { action: 'hold', reason: 'starter scaffold: implement your signal logic here' }\n}\n`, + }, + ] +} + +function commonFiles(template: Template, projectName: string): ScaffoldFile[] { + return [ + { path: 'package.json', content: quotedJson(buildPackageJson(template, projectName)) }, + { path: '.gitignore', content: `node_modules\ndist\n.next\n.env\n.DS_Store\ntarget\n.anchor\n` }, + { path: '.env.example', content: envForTemplate(template.id) }, + { path: 'README.md', content: readmeForTemplate(template, projectName) }, + { path: 'tsconfig.json', content: quotedJson({ compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', strict: true, esModuleInterop: true, skipLibCheck: true, outDir: 'dist' }, include: ['src', 'app', 'tests'] }) }, + ] +} + +export function buildDeterministicScaffold(template: Template, projectName: string): DeterministicScaffold { + const isNext = ['dapp-nextjs', 'solana-foundation', 'perps-frontend'].includes(template.id) + const files = [ + ...commonFiles(template, projectName), + ...(template.id === 'anchor-program' ? anchorFiles(projectName) : isNext ? nextAppFiles(template, projectName) : nodeAppFiles(template)), + ] + + const dirs = new Set(['src']) + for (const file of files) { + const parts = file.path.split('/').slice(0, -1) + for (let i = 1; i <= parts.length; i += 1) { + dirs.add(parts.slice(0, i).join('/')) + } + } + + return { + dirs: [...dirs].filter(Boolean), + files, + } +} + export function ProjectStarter() { const activeProjectId = useUIStore((s) => s.activeProjectId) const addTerminal = useUIStore((s) => s.addTerminal) @@ -434,35 +820,48 @@ export function ProjectStarter() { } - // Spawn a terminal with Claude agent to scaffold the project - const runtimePrompt = buildRuntimePrompt(walletInfrastructure) - const templateSpecificPrompt = buildTemplateSpecificPrompt(wizard.template.id, walletInfrastructure) - const agentPrompt = [ - `You are scaffolding a new project called "${name}" in the current directory.`, - `The directory is empty and ready for you to create files.`, - ``, - wizard.template.prompt, - runtimePrompt, - templateSpecificPrompt, - ``, - `Create or update project files so the app runtime matches \`daemon.solana-runtime.json\`.`, - `IMPORTANT: Create all files directly. Do not ask questions. Just build it.`, - `After scaffolding, run any install commands (npm install / cargo build) as needed.`, - `Keep output concise. When done, print "Project scaffolding complete."`, - ].filter(Boolean).join('\n') + const scaffold = buildDeterministicScaffold(wizard.template, name) + try { + for (const dir of scaffold.dirs) { + const dirRes = await window.daemon.fs.createDir(`${projectPath}/${dir}`) + if (!dirRes.ok) { + throw new Error(dirRes.error ?? `Failed to create ${dir}`) + } + } + + for (const file of scaffold.files) { + const fileRes = await window.daemon.fs.writeFile(`${projectPath}/${file.path}`, file.content) + if (!fileRes.ok) { + throw new Error(fileRes.error ?? `Failed to write ${file.path}`) + } + } + } catch (scaffoldErr) { + useNotificationsStore.getState().addActivity({ + kind: 'error', + context: 'Scaffold', + message: scaffoldErr instanceof Error ? scaffoldErr.message : String(scaffoldErr), + sessionId, + sessionStatus: 'failed', + projectId: newProject.id, + projectName: name, + }) + await cleanupProject() + setError(scaffoldErr instanceof Error ? scaffoldErr.message : String(scaffoldErr)) + setWizard((prev) => ({ ...prev, step: 'configure' })) + return + } const termRes = await window.daemon.terminal.create({ cwd: projectPath, - startupCommand: `claude --model claude-sonnet-4-20250514 --dangerously-skip-permissions -p "${agentPrompt.replace(/"/g, '\\"')}"`, userInitiated: true, }) if (termRes.ok && termRes.data) { - addTerminal(newProject.id, termRes.data.id, `Build: ${name}`, null) + addTerminal(newProject.id, termRes.data.id, `Terminal: ${name}`, null) useNotificationsStore.getState().addActivity({ kind: 'success', context: 'Scaffold', - message: `Build agent started for ${name}; runtime preset ${runtimePreset ? 'written' : 'not available'}.`, + message: `Project scaffold written for ${name}. Open terminal is idle; run pnpm install when ready.`, sessionId, sessionStatus: 'running', projectId: newProject.id, @@ -476,14 +875,13 @@ export function ProjectStarter() { useNotificationsStore.getState().addActivity({ kind: 'error', context: 'Scaffold', - message: termRes.error ?? `Failed to start build agent for ${name}`, + message: termRes.error ?? `Scaffold written, but setup terminal failed for ${name}`, sessionId, sessionStatus: 'failed', projectId: newProject.id, projectName: name, }) - await cleanupProject() - setError(termRes.error ?? 'Failed to start build agent') + setError(termRes.error ?? 'Scaffold written, but setup terminal failed') setWizard((prev) => ({ ...prev, step: 'configure' })) } } catch (err) { @@ -644,7 +1042,7 @@ export function ProjectStarter() {

Scaffolding {wizard.projectName}...

- Build agent running for {wizard.template?.name}. + Writing {wizard.template?.name} starter files.

diff --git a/src/panels/SettingsPanel/SettingsPanel.tsx b/src/panels/SettingsPanel/SettingsPanel.tsx index b0e11b73..64b1fede 100644 --- a/src/panels/SettingsPanel/SettingsPanel.tsx +++ b/src/panels/SettingsPanel/SettingsPanel.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react' import { useUIStore } from '../../store/ui' import { useOnboardingStore } from '../../store/onboarding' +import { useWalletStore } from '../../store/wallet' import { useWorkspaceProfileStore } from '../../store/workspaceProfile' import { useNotificationsStore } from '../../store/notifications' import { Toggle } from '../../components/Toggle' @@ -682,29 +683,13 @@ const PROFILE_OPTIONS: { name: WorkspaceProfileName; label: string }[] = [ ] function DisplaySection() { - const [showMarketTape, setShowMarketTape] = useState(true) - const [showTitlebarWallet, setShowTitlebarWallet] = useState(true) + const showMarketTape = useWalletStore((s) => s.showMarketTape) + const showTitlebarWallet = useWalletStore((s) => s.showTitlebarWallet) + const setShowMarketTape = useWalletStore((s) => s.setShowMarketTape) + const setShowTitlebarWallet = useWalletStore((s) => s.setShowTitlebarWallet) - useEffect(() => { - let cancelled = false - window.daemon.settings.getUi().then((res) => { - if (!cancelled && res.ok && res.data) { - setShowMarketTape(res.data.showMarketTape) - setShowTitlebarWallet(res.data.showTitlebarWallet) - } - }) - return () => { cancelled = true } - }, []) - - const handleToggleMarketTape = async (enabled: boolean) => { - setShowMarketTape(enabled) - await window.daemon.settings.setShowMarketTape(enabled) - } - - const handleToggleTitlebarWallet = async (enabled: boolean) => { - setShowTitlebarWallet(enabled) - await window.daemon.settings.setShowTitlebarWallet(enabled) - } + const handleToggleMarketTape = (enabled: boolean) => { void setShowMarketTape(enabled) } + const handleToggleTitlebarWallet = (enabled: boolean) => { void setShowTitlebarWallet(enabled) } return (
diff --git a/src/panels/SpawnAgents/SpawnAgentsPanel.css b/src/panels/SpawnAgents/SpawnAgentsPanel.css new file mode 100644 index 00000000..7286a0bb --- /dev/null +++ b/src/panels/SpawnAgents/SpawnAgentsPanel.css @@ -0,0 +1,528 @@ +/* SpawnAgents Panel */ + +.sa-root { + display: flex; + flex-direction: column; + height: 100%; + background: var(--panel-bg, #0d0d0e); + color: var(--text-primary, #e8e6e3); + font-size: 13px; + overflow: hidden; +} + +.sa-root :root { + --sa-red: #c41e3a; + --sa-green: #3fb950; + --sa-muted: #6b7280; +} + +/* ---- vars ---- */ +:root { + --sa-red: #c41e3a; + --sa-green: #3fb950; + --sa-muted: #6b7280; +} + +/* ---- topbar ---- */ +.sa-topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + flex-shrink: 0; +} + +.sa-topbar-left { + display: flex; + align-items: center; + gap: 10px; +} + +.sa-brand { + font-weight: 600; + color: #c41e3a; + letter-spacing: 0.02em; + font-size: 14px; +} + +.sa-owner { + font-size: 11px; + font-family: 'JetBrains Mono', monospace; + color: var(--sa-muted); + background: rgba(255,255,255,0.05); + padding: 2px 7px; + border-radius: 3px; +} + +.sa-topbar-right { + display: flex; + align-items: center; + gap: 6px; +} + +.sa-tab-btn { + background: none; + border: none; + color: var(--sa-muted); + padding: 4px 10px; + cursor: pointer; + font-size: 12px; + border-radius: 4px; + transition: color 0.15s, background 0.15s; +} +.sa-tab-btn:hover { background: rgba(255,255,255,0.06); color: var(--text-primary, #e8e6e3); } +.sa-tab-btn.active { color: #c41e3a; background: rgba(196, 30, 58, 0.12); } + +.sa-spawn-btn { + margin-left: 4px; +} + +/* ---- body ---- */ +.sa-body { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +/* ---- agent grid ---- */ +.sa-agent-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 10px; +} + +.sa-agent-card { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + padding: 12px; + cursor: pointer; + text-align: left; + transition: border-color 0.15s, background 0.15s; + display: flex; + flex-direction: column; + gap: 6px; +} +.sa-agent-card:hover { border-color: rgba(196, 30, 58, 0.4); background: rgba(196, 30, 58, 0.06); } +.sa-agent-card.selected { border-color: #c41e3a; background: rgba(196, 30, 58, 0.1); } +.sa-agent-card.dead { opacity: 0.5; } + +.sa-card-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.sa-card-name { + font-weight: 600; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 110px; +} + +.sa-card-status { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 1px 5px; + border-radius: 3px; +} +.sa-card-status.alive { background: rgba(63, 185, 80, 0.15); color: #3fb950; } +.sa-card-status.dead { background: rgba(255, 255, 255, 0.05); color: var(--sa-muted); } +.sa-card-status.spawning { background: rgba(196, 30, 58, 0.15); color: #c41e3a; } + +.sa-card-meta { + display: flex; + gap: 6px; + font-size: 11px; + color: var(--sa-muted); +} + +.sa-card-gen { font-family: 'JetBrains Mono', monospace; } +.sa-card-lineage { color: #c41e3a; } + +.sa-card-pnl { font-weight: 600; font-size: 14px; } +.sa-card-trades { font-size: 11px; color: var(--sa-muted); } + +/* ---- shared inputs / buttons ---- */ +.sa-input { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 5px; + color: inherit; + padding: 6px 10px; + font-size: 13px; + width: 100%; + outline: none; + transition: border-color 0.15s; +} +.sa-input:focus { border-color: #c41e3a; } +.sa-input-sm { width: 100px; } + +.sa-btn-primary { + background: #c41e3a; + border: none; + border-radius: 5px; + color: #fff; + cursor: pointer; + font-size: 13px; + font-weight: 600; + padding: 6px 14px; + transition: background 0.15s; +} +.sa-btn-primary:hover { background: #a01830; } +.sa-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } + +.sa-btn-ghost { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 5px; + color: inherit; + cursor: pointer; + font-size: 13px; + padding: 6px 12px; + transition: background 0.15s; +} +.sa-btn-ghost:hover { background: rgba(255, 255, 255, 0.1); } +.sa-btn-ghost:disabled { opacity: 0.4; cursor: not-allowed; } + +.sa-btn-danger { + background: rgba(196, 30, 58, 0.15); + border: 1px solid rgba(196, 30, 58, 0.4); + border-radius: 5px; + color: #c41e3a; + cursor: pointer; + font-size: 13px; + font-weight: 600; + padding: 6px 12px; + transition: background 0.15s; +} +.sa-btn-danger:hover { background: rgba(196, 30, 58, 0.3); } +.sa-btn-danger:disabled { opacity: 0.4; cursor: not-allowed; } + +/* ---- error / msg ---- */ +.sa-error { + color: #f85149; + font-size: 12px; + padding: 6px 0; +} +.sa-msg { + color: #3fb950; + font-size: 12px; + padding: 6px 0; +} + +/* ---- empty states ---- */ +.sa-empty { color: var(--sa-muted); font-size: 12px; padding: 8px 0; } +.sa-loading { color: var(--sa-muted); font-size: 12px; padding: 8px 0; } + +.sa-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 60px 20px; + text-align: center; + color: var(--sa-muted); +} +.sa-empty-title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary, #e8e6e3); +} + +.sa-no-wallet { + align-items: center; + justify-content: center; +} +.sa-no-wallet-msg { + max-width: 360px; + text-align: center; + display: flex; + flex-direction: column; + gap: 10px; + padding: 40px 20px; + color: var(--sa-muted); +} +.sa-no-wallet-title { font-size: 16px; font-weight: 600; color: var(--text-primary, #e8e6e3); } + +/* ---- spawn form ---- */ +.sa-spawn-form { + max-width: 540px; +} + +.sa-form-title { + font-size: 15px; + font-weight: 600; + margin-bottom: 16px; +} + +.sa-form-field { + display: flex; + flex-direction: column; + gap: 5px; + margin-bottom: 14px; +} +.sa-form-field label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--sa-muted); } +.sa-field-hint { font-size: 11px; color: var(--sa-muted); } + +.sa-form-actions { + display: flex; + gap: 8px; + margin-top: 20px; +} + +/* ---- DNA section ---- */ +.sa-dna-section { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 7px; + padding: 14px; + margin-bottom: 14px; +} + +.sa-dna-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--sa-muted); + margin-bottom: 10px; +} + +.sa-dna-modes { + display: flex; + gap: 16px; + margin-bottom: 12px; +} + +.sa-check { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + cursor: pointer; + user-select: none; +} +.sa-check input[type="checkbox"] { + accent-color: #c41e3a; + width: 14px; + height: 14px; +} + +.sa-dna-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.sa-dna-label { + font-size: 12px; + color: var(--sa-muted); + min-width: 130px; +} + +.sa-dna-slider { + flex: 1; + accent-color: #c41e3a; + height: 3px; +} + +.sa-dna-val { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + min-width: 36px; + text-align: right; + color: var(--text-primary, #e8e6e3); +} + +/* ---- deposit step ---- */ +.sa-deposit-step { + max-width: 500px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.sa-deposit-copy { color: var(--sa-muted); font-size: 13px; margin: 0; line-height: 1.6; } +.sa-deposit-copy strong { color: var(--text-primary, #e8e6e3); } + +.sa-deposit-addr-row { + display: flex; + align-items: center; + gap: 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + padding: 8px 12px; +} + +.sa-deposit-addr { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + flex: 1; + word-break: break-all; + color: #c41e3a; +} + +.sa-copy-btn { flex-shrink: 0; font-size: 11px; padding: 3px 8px; } + +.sa-deposit-meta { + display: flex; + gap: 16px; + font-size: 12px; + color: var(--sa-muted); +} + +.sa-deposit-status { + padding: 8px 12px; + border-radius: 5px; + font-size: 13px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.07); +} +.sa-deposit-status.polling, +.sa-deposit-status.pending { border-color: rgba(196, 30, 58, 0.3); color: #c41e3a; } +.sa-deposit-status.confirmed { border-color: rgba(63, 185, 80, 0.3); color: #3fb950; } +.sa-deposit-status.funding_failed, +.sa-deposit-status.expired { border-color: rgba(248, 81, 73, 0.3); color: #f85149; } + +/* ---- detail ---- */ +.sa-back-btn { + background: none; + border: none; + color: var(--sa-muted); + cursor: pointer; + font-size: 12px; + padding: 0; + margin-bottom: 14px; + transition: color 0.15s; +} +.sa-back-btn:hover { color: var(--text-primary, #e8e6e3); } + +.sa-detail-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 14px; +} + +.sa-detail-name { font-size: 16px; font-weight: 600; margin-bottom: 4px; } +.sa-detail-sub { display: flex; gap: 10px; font-size: 12px; color: var(--sa-muted); align-items: center; } +.sa-detail-pnl { font-size: 20px; font-weight: 700; } + +.sa-detail-stats { + display: flex; + gap: 16px; + padding: 10px 0; + border-top: 1px solid rgba(255,255,255,0.06); + border-bottom: 1px solid rgba(255,255,255,0.06); + margin-bottom: 14px; +} +.sa-stat { + display: flex; + flex-direction: column; + gap: 2px; +} +.sa-stat span { font-size: 11px; color: var(--sa-muted); } +.sa-stat strong { font-size: 14px; } + +.sa-actions-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + margin-bottom: 14px; +} + +.sa-withdraw-row { + display: flex; + gap: 6px; + align-items: center; +} + +.sa-confirm-kill { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + font-size: 12px; + color: var(--sa-muted); +} + +/* ---- tabs ---- */ +.sa-tabs { + display: flex; + gap: 2px; + border-bottom: 1px solid rgba(255,255,255,0.07); + margin-bottom: 12px; +} +.sa-tabs button { + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--sa-muted); + cursor: pointer; + font-size: 12px; + padding: 6px 12px; + transition: color 0.15s, border-color 0.15s; +} +.sa-tabs button:hover { color: var(--text-primary, #e8e6e3); } +.sa-tabs button.active { color: #c41e3a; border-bottom-color: #c41e3a; } + +/* ---- positions ---- */ +.sa-pos-list, .sa-trades-list { display: flex; flex-direction: column; gap: 4px; } + +.sa-pos-row { + display: flex; + gap: 12px; + align-items: center; + padding: 7px 10px; + background: rgba(255,255,255,0.03); + border-radius: 5px; + font-size: 12px; +} +.sa-pos-sym { font-family: 'JetBrains Mono', monospace; font-weight: 600; min-width: 80px; } +.sa-pos-val { color: var(--text-primary, #e8e6e3); } +.sa-pos-side { font-size: 10px; font-weight: 600; text-transform: uppercase; color: #c41e3a; } + +/* ---- trades ---- */ +.sa-trade-row { + display: flex; + gap: 12px; + align-items: center; + padding: 6px 10px; + border-radius: 5px; + font-size: 12px; + background: rgba(255,255,255,0.02); +} +.sa-trade-action { font-weight: 700; font-size: 10px; text-transform: uppercase; min-width: 32px; } +.sa-trade-row.buy .sa-trade-action { color: #3fb950; } +.sa-trade-row.sell .sa-trade-action { color: #c41e3a; } +.sa-trade-addr { font-family: 'JetBrains Mono', monospace; color: var(--sa-muted); } +.sa-trade-time { margin-left: auto; color: var(--sa-muted); font-size: 10px; } + +/* ---- events ---- */ +.sa-events-view { display: flex; flex-direction: column; gap: 10px; } +.sa-events-header { font-size: 13px; font-weight: 600; } +.sa-events-scope { color: var(--sa-muted); font-weight: 400; } + +.sa-event-feed { display: flex; flex-direction: column; gap: 3px; } +.sa-event-row { + display: flex; + gap: 12px; + align-items: center; + padding: 5px 10px; + border-radius: 4px; + font-size: 11px; + background: rgba(255,255,255,0.02); + font-family: 'JetBrains Mono', monospace; +} +.sa-event-type { min-width: 120px; color: #c41e3a; font-weight: 600; } +.sa-event-agent { color: var(--sa-muted); min-width: 90px; } +.sa-event-time { margin-left: auto; color: var(--sa-muted); } diff --git a/src/panels/SpawnAgents/SpawnAgentsPanel.tsx b/src/panels/SpawnAgents/SpawnAgentsPanel.tsx new file mode 100644 index 00000000..848f0b47 --- /dev/null +++ b/src/panels/SpawnAgents/SpawnAgentsPanel.tsx @@ -0,0 +1,601 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { daemon } from '../../lib/daemonBridge' +import { useWalletStore } from '../../store/wallet' +import type { + SpawnAgentRecord, + SpawnAgentDna, + SpawnDepositInstruction, + SpawnStatusResult, + SpawnTrade, + SpawnAgentPositions, + SpawnEvent, +} from '../../../electron/services/SpawnAgentsService' +import './SpawnAgentsPanel.css' + +// ------------------------------------------------------------------ helpers -- + +const KILL_COOLDOWN_MS = 24 * 60 * 60 * 1000 +const CHILD_ACTIVITY_GATE_TRADES = 10 +const CHILD_ACTIVITY_GATE_MS = 7 * 24 * 60 * 60 * 1000 + +function computeGate(agent: SpawnAgentRecord) { + const out = { can_kill: true, can_withdraw: true, kill_reason: '', withdraw_reason: '' } + if (agent.status !== 'alive') { + return { can_kill: false, can_withdraw: false, kill_reason: 'Agent is dead', withdraw_reason: 'Agent is dead' } + } + const bornMs = Date.parse(agent.born_at.replace(' ', 'T') + 'Z') + if (Number.isFinite(bornMs) && Date.now() - bornMs < KILL_COOLDOWN_MS) { + out.can_kill = false + const hoursLeft = Math.ceil((KILL_COOLDOWN_MS - (Date.now() - bornMs)) / (60 * 60 * 1000)) + out.kill_reason = `24h post-spawn cooldown — ${hoursLeft}h left` + } + if (agent.parent_id) { + const ageOk = Number.isFinite(bornMs) && Date.now() - bornMs >= CHILD_ACTIVITY_GATE_MS + const tradesOk = agent.total_trades >= CHILD_ACTIVITY_GATE_TRADES + if (!ageOk && !tradesOk) { + const reason = `Child needs ${CHILD_ACTIVITY_GATE_TRADES} trades or 7 days (${agent.total_trades}/${CHILD_ACTIVITY_GATE_TRADES})` + out.can_withdraw = false + out.withdraw_reason = reason + if (out.can_kill) { + out.can_kill = false + out.kill_reason = reason + } + } + } + return out +} + +function pnlColor(val: number) { + if (val > 0) return 'var(--sa-green)' + if (val < 0) return 'var(--sa-red)' + return 'var(--sa-muted)' +} + +function fmt(val: number, decimals = 4) { + return val.toLocaleString(undefined, { maximumFractionDigits: decimals, minimumFractionDigits: decimals }) +} + +function truncate(addr: string, len = 6) { + return addr ? `${addr.slice(0, len)}…${addr.slice(-4)}` : '' +} + +// ----------------------------------------------------------------- AgentCard -- + +function AgentCard({ agent, selected, onClick }: { agent: SpawnAgentRecord; selected: boolean; onClick: () => void }) { + const pnl = agent.total_pnl_sol + return ( + + ) +} + +// ---------------------------------------------------------------- DNA editor -- + +const DEFAULT_DNA: SpawnAgentDna = { + trades_memecoins: true, + trades_prediction: false, + aggression: 0.5, + patience: 0.5, + risk_tolerance: 0.5, + sell_profit_pct: 100, + sell_loss_pct: 25, + max_position_pct: 20, + sniper: false, + reproduction_cost_sol: 0.3, + royalty_pct: 0.05, +} + +function DnaSlider({ label, field, value, min, max, step = 0.01, onChange }: { + label: string; field: keyof SpawnAgentDna; value: number; min: number; max: number; step?: number; onChange: (v: number) => void +}) { + return ( +
+ + onChange(parseFloat(e.target.value))} + className="sa-dna-slider" + /> + {value} +
+ ) +} + +// --------------------------------------------------------------- Spawn form -- + +interface SpawnFormProps { + ownerWallet: string + walletId: string | null + onCancel: () => void + onDeposit: (instr: SpawnDepositInstruction) => void + onFunded: () => void +} + +function SpawnForm({ ownerWallet, walletId, onCancel, onDeposit, onFunded }: SpawnFormProps) { + const [name, setName] = useState('') + const [solAmount, setSolAmount] = useState(0.5) + const [dna, setDna] = useState(DEFAULT_DNA) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + function setDnaField(k: K, v: SpawnAgentDna[K]) { + setDna((prev) => ({ ...prev, [k]: v })) + } + + function validate(): boolean { + if (!name.trim()) { setError('Name is required'); return false } + if (solAmount < 0.2) { setError('Minimum deposit is 0.2 SOL'); return false } + return true + } + + async function handleSpawn() { + if (!validate()) return + setBusy(true) + setError(null) + const res = await daemon.spawnAgents.initiateSpawn({ owner_wallet: ownerWallet, name: name.trim(), sol_amount: solAmount, dna }) + setBusy(false) + if (!res.ok) { setError(res.error ?? 'Spawn failed'); return } + onDeposit(res.data!) + } + + async function handleSpawnAndFund() { + if (!validate()) return + if (!walletId) { setError('No DAEMON wallet with a keypair available'); return } + setBusy(true) + setError(null) + const res = await daemon.spawnAgents.spawnAndFund(walletId, { owner_wallet: ownerWallet, name: name.trim(), sol_amount: solAmount, dna }) + setBusy(false) + if (!res.ok) { setError(res.error ?? 'Spawn-and-fund failed'); return } + onFunded() + } + + return ( +
+
Spawn new agent
+ +
+ + setName(e.target.value)} placeholder="Agent name" maxLength={32} /> +
+ +
+ + setSolAmount(parseFloat(e.target.value))} /> + Minimum 0.2 SOL · goes 100% to agent wallet +
+ +
+
DNA
+ +
+ + +
+ + {dna.trades_memecoins && ( + <> + setDnaField('aggression', v)} /> + setDnaField('patience', v)} /> + setDnaField('risk_tolerance', v)} /> + setDnaField('sell_profit_pct', v)} /> + setDnaField('sell_loss_pct', v)} /> + setDnaField('max_position_pct', v)} /> + + + )} + + {dna.trades_prediction && ( + <> + setDnaField('pm_edge_threshold', v)} /> + setDnaField('pm_max_position_pct', v)} /> + + )} +
+ + {error &&
{error}
} + +
+ + + +
+
+ ) +} + +// --------------------------------------------------------- Deposit flow step -- + +function DepositStep({ instr, onDone, onCancel }: { instr: SpawnDepositInstruction; onDone: () => void; onCancel: () => void }) { + const [status, setStatus] = useState('polling') + const pollRef = useRef | null>(null) + + const poll = useCallback(async () => { + const res = await daemon.spawnAgents.spawnStatus(instr.reference) + if (!res.ok) return + setStatus(res.data!.status) + if (res.data!.status === 'confirmed' || res.data!.status === 'funding_failed' || res.data!.status === 'expired') { + if (pollRef.current) clearInterval(pollRef.current) + if (res.data!.status === 'confirmed') onDone() + } + }, [instr.reference, onDone]) + + useEffect(() => { + pollRef.current = setInterval(poll, 4000) + return () => { if (pollRef.current) clearInterval(pollRef.current) } + }, [poll]) + + async function copyAddress() { + await navigator.clipboard.writeText(instr.recipient) + } + + return ( +
+
Fund your agent
+

Send exactly {fmt(instr.amount, 6)} SOL to the address below from your owner wallet. DAEMON will poll for confirmation.

+ +
+ {instr.recipient} + +
+ +
+ Agent: {instr.agent_name} + Ref: {truncate(instr.reference, 8)} +
+ +
+ {status === 'polling' && '⏳ Waiting for deposit…'} + {status === 'pending' && '⏳ Deposit seen, awaiting confirmation…'} + {status === 'confirmed' && '✓ Confirmed — agent is alive'} + {status === 'funding_failed' && '✗ Funding failed'} + {status === 'expired' && '✗ Expired — start a new spawn'} +
+ + {(status === 'funding_failed' || status === 'expired') && ( + + )} +
+ ) +} + +// ---------------------------------------------------------- Agent detail tab -- + +function AgentDetail({ agent, walletId, onRefresh }: { agent: SpawnAgentRecord; walletId: string | null; onRefresh: () => void }) { + const [tab, setTab] = useState<'positions' | 'trades'>('positions') + const [positions, setPositions] = useState(null) + const [trades, setTrades] = useState(null) + const [withdrawAmt, setWithdrawAmt] = useState('') + const [busy, setBusy] = useState(false) + const [msg, setMsg] = useState(null) + const [confirmKill, setConfirmKill] = useState(false) + + useEffect(() => { + if (tab === 'positions') { + daemon.spawnAgents.positions(agent.id).then((r) => { if (r.ok) setPositions(r.data!) }) + } else { + daemon.spawnAgents.trades(agent.id).then((r) => { if (r.ok) setTrades(r.data!.trades) }) + } + }, [agent.id, tab]) + + async function handleWithdraw() { + if (!walletId) { setMsg('No DAEMON wallet with keypair selected'); return } + const amt = parseFloat(withdrawAmt) + if (!amt || amt <= 0) { setMsg('Enter a valid amount'); return } + setBusy(true) + setMsg(null) + const res = await daemon.spawnAgents.withdraw(agent.id, walletId, amt) + setBusy(false) + if (!res.ok) { setMsg(res.error ?? 'Withdraw failed'); return } + setMsg(`✓ Withdrawn ${fmt(res.data!.amount_sol)} SOL — new balance: ${fmt(res.data!.new_balance_sol)}`) + setWithdrawAmt('') + onRefresh() + } + + async function handleKill() { + if (!walletId) { setMsg('No DAEMON wallet with keypair selected'); return } + setBusy(true) + setMsg(null) + const res = await daemon.spawnAgents.kill(agent.id, walletId) + setBusy(false) + if (!res.ok) { setMsg(res.error ?? 'Kill failed'); return } + setMsg(`✓ Agent killed · ${fmt(res.data!.refund_sol)} SOL refunded`) + setConfirmKill(false) + onRefresh() + } + + return ( +
+
+
+
{agent.name}
+
+ {agent.status} + gen {agent.generation} + wallet: {truncate(agent.agent_wallet)} +
+
+
+ {agent.total_pnl_sol >= 0 ? '+' : ''}{fmt(agent.total_pnl_sol)} SOL +
+
+ +
+
Initial{fmt(agent.initial_capital_sol)} SOL
+
Trades{agent.total_trades}
+
Withdrawn{fmt(agent.total_withdrawn_sol)} SOL
+
+ + {agent.status === 'alive' && (() => { + const gate = computeGate(agent) + return ( +
+
+ setWithdrawAmt(e.target.value)} disabled={!gate.can_withdraw} /> + +
+ {!confirmKill + ? + : ( +
+ Kill {agent.name}? This liquidates all positions. + + +
+ ) + } + {(gate.kill_reason || gate.withdraw_reason) && ( +
{gate.kill_reason || gate.withdraw_reason}
+ )} +
+ ) + })()} + + {msg &&
{msg}
} + +
+ + +
+ + {tab === 'positions' && positions && ( +
+ {positions.memecoin.length === 0 && positions.prediction.length === 0 && ( +
No open positions
+ )} + {positions.memecoin.map((p) => ( +
+ {p.symbol || truncate(p.token_address)} + {fmt(p.value_sol)} SOL + {p.unrealized_pnl_pct >= 0 ? '+' : ''}{p.unrealized_pnl_pct.toFixed(1)}% +
+ ))} + {positions.prediction.map((p) => ( +
+ {p.event_title} + {p.side} + {p.contracts_remaining} contracts +
+ ))} +
+ )} + + {tab === 'trades' && trades && ( +
+ {trades.length === 0 &&
No trades yet
} + {trades.map((t) => ( +
+ {t.action.toUpperCase()} + {truncate(t.token_address)} + {fmt(t.amount_sol)} SOL + {t.pnl_sol != null && ( + {t.pnl_sol >= 0 ? '+' : ''}{fmt(t.pnl_sol)} + )} + {new Date(t.timestamp).toLocaleTimeString()} +
+ ))} +
+ )} +
+ ) +} + +// ---------------------------------------------------------------- Event feed -- + +function EventFeed({ agentId }: { agentId?: string }) { + const [events, setEvents] = useState([]) + + useEffect(() => { + const unsubscribe = daemon.spawnAgents.onEvent((ev: SpawnEvent) => { + if (agentId && ev.agent_id !== agentId) return + setEvents((prev) => [ev, ...prev].slice(0, 100)) + }) + return () => { unsubscribe?.() } + }, [agentId]) + + if (events.length === 0) return
Waiting for events…
+ + return ( +
+ {events.map((e) => ( +
+ {e.type} + {e.agent_id} + {new Date(e.timestamp).toLocaleTimeString()} +
+ ))} +
+ ) +} + +// ------------------------------------------------------------------ Main panel -- + +type View = 'list' | 'spawn' | 'deposit' | 'detail' | 'events' + +export function SpawnAgentsPanel() { + const defaultWallet = useWalletStore((s) => { + const wallets = s.dashboard?.wallets ?? [] + return wallets.find((w) => w.isDefault) ?? wallets[0] ?? null + }) + const [agents, setAgents] = useState([]) + const [selected, setSelected] = useState(null) + const [view, setView] = useState('list') + const [depositInstr, setDepositInstr] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const ownerWallet = defaultWallet?.address ?? null + + const loadAgents = useCallback(async () => { + if (!ownerWallet) return + setLoading(true) + setError(null) + const res = await daemon.spawnAgents.list(ownerWallet) + setLoading(false) + if (!res.ok) { setError(res.error ?? 'Failed to load agents'); return } + setAgents(res.data!) + }, [ownerWallet]) + + useEffect(() => { void loadAgents() }, [loadAgents]) + + function selectAgent(agent: SpawnAgentRecord) { + setSelected(agent) + setView('detail') + } + + function handleDeposit(instr: SpawnDepositInstruction) { + setDepositInstr(instr) + setView('deposit') + } + + function handleDepositDone() { + void loadAgents() + setView('list') + setDepositInstr(null) + } + + if (!ownerWallet) { + return ( +
+
+
No wallet connected
+

Set a default DAEMON wallet with a keypair to use SpawnAgents. The keypair is needed to sign agent actions (withdraw, kill, spawn-child).

+
+
+ ) + } + + return ( +
+
+
+ SpawnAgents + {truncate(ownerWallet)} +
+
+ + + {view !== 'spawn' && ( + + )} +
+
+ +
+ {view === 'list' && ( + <> + {loading &&
Loading agents…
} + {error &&
{error}
} + {!loading && !error && agents.length === 0 && ( +
+
No agents yet
+

Spawn your first autonomous Solana trading agent.

+ +
+ )} +
+ {agents.map((a) => ( + selectAgent(a)} /> + ))} +
+ + )} + + {view === 'spawn' && ( + setView('list')} + onDeposit={handleDeposit} + onFunded={() => { + void loadAgents() + setView('list') + }} + /> + )} + + {view === 'deposit' && depositInstr && ( + setView('spawn')} + /> + )} + + {view === 'detail' && selected && ( + <> + + { + void loadAgents() + daemon.spawnAgents.get(selected.id).then((r) => { if (r.ok) setSelected(r.data!) }) + }} + /> + + )} + + {view === 'events' && ( +
+
+ Live event feed + {selected && · {selected.name}} +
+ +
+ )} +
+
+ ) +} + +export default SpawnAgentsPanel diff --git a/src/panels/Titlebar/Titlebar.css b/src/panels/Titlebar/Titlebar.css index f5b928df..8692b25e 100644 --- a/src/panels/Titlebar/Titlebar.css +++ b/src/panels/Titlebar/Titlebar.css @@ -70,7 +70,7 @@ align-items: center; gap: 6px; padding: 0 var(--space-md); - overflow-x: auto; + overflow: visible; min-width: 0; max-width: min(52vw, 720px); -webkit-app-region: no-drag; @@ -159,6 +159,50 @@ border-color: var(--border); } +.project-tab-add.active { + background: var(--green-glow); + color: var(--green); + border-color: color-mix(in srgb, var(--green) 30%, var(--border)); +} + +.project-add-wrap { + position: relative; + flex-shrink: 0; +} + +.project-add-menu { + position: absolute; + top: calc(100% + var(--space-xs)); + left: 0; + min-width: 168px; + padding: var(--space-xs); + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + border: 1px solid var(--border-glass); + border-radius: var(--radius-card); + box-shadow: var(--shadow-float); + z-index: 70; + -webkit-app-region: no-drag; +} + +.project-add-menu-item { + width: 100%; + height: var(--btn-h-md); + display: flex; + align-items: center; + padding: 0 var(--space-md); + border-radius: var(--radius-md); + font-size: var(--fs-11); + font-weight: var(--fw-medium); + color: var(--t2); + background: transparent; +} + +.project-add-menu-item:hover { + background: var(--hover-bg); + color: var(--t1); +} + .titlebar-controls { display: flex; -webkit-app-region: no-drag; @@ -333,6 +377,12 @@ background: var(--hover-bg); } +.titlebar-switcher-actions { + margin-top: var(--space-xs); + padding-top: var(--space-xs); + border-top: 1px solid var(--border); +} + .titlebar-overflow { position: relative; margin-right: var(--space-2xs); } .titlebar-btn-overflow { width: 30px; } .titlebar-overflow-menu { diff --git a/src/panels/Titlebar/Titlebar.tsx b/src/panels/Titlebar/Titlebar.tsx index 24cc01d9..ad557ee7 100644 --- a/src/panels/Titlebar/Titlebar.tsx +++ b/src/panels/Titlebar/Titlebar.tsx @@ -25,6 +25,10 @@ export function Titlebar({ projects, onAddProject, onRemoveProject }: TitlebarPr [activeProjectId, projects], ) + const openProjectStarter = () => { + useUIStore.getState().openWorkspaceTool('starter') + } + const showProjectTabs = isDesktop || isCompact const showPortfolioInline = isDesktop const showBrandText = !isTablet && !isSmall @@ -39,6 +43,7 @@ export function Titlebar({ projects, onAddProject, onRemoveProject }: TitlebarPr projects={projects} activeProjectId={activeProjectId} onAddProject={onAddProject} + onScaffoldProject={openProjectStarter} onRemoveProject={onRemoveProject} onSelectProject={setActiveProject} /> @@ -47,6 +52,7 @@ export function Titlebar({ projects, onAddProject, onRemoveProject }: TitlebarPr activeProject={activeProject} projects={projects} onAddProject={onAddProject} + onScaffoldProject={openProjectStarter} onRemoveProject={onRemoveProject} onSelectProject={setActiveProject} /> @@ -99,15 +105,21 @@ function ProjectTabs({ projects, activeProjectId, onAddProject, + onScaffoldProject, onRemoveProject, onSelectProject, }: { projects: Project[] activeProjectId: string | null onAddProject: () => void + onScaffoldProject: () => void onRemoveProject: (projectId: string) => void onSelectProject: (id: string | null, path: string | null) => void }) { + const [isAddOpen, setIsAddOpen] = useState(false) + const addRef = useRef(null) + useDismissOnOutsideClick(isAddOpen, addRef, () => setIsAddOpen(false)) + return (
{projects.map((project) => ( @@ -133,7 +145,14 @@ function ProjectTabs({ ))} - +
) } @@ -142,12 +161,14 @@ function ProjectSwitcher({ activeProject, projects, onAddProject, + onScaffoldProject, onRemoveProject, onSelectProject, }: { activeProject: Project | null projects: Project[] onAddProject: () => void + onScaffoldProject: () => void onRemoveProject: (projectId: string) => void onSelectProject: (id: string | null, path: string | null) => void }) { @@ -195,14 +216,80 @@ function ProjectSwitcher({
))} +
+ + +
+ + )} + + ) +} + +function ProjectAddMenu({ + isOpen, + setIsOpen, + onAddProject, + onScaffoldProject, + refEl, + buttonClassName, +}: { + isOpen: boolean + setIsOpen: (value: boolean) => void + onAddProject: () => void + onScaffoldProject: () => void + refEl: RefObject + buttonClassName: string +}) { + return ( +
+ + {isOpen && ( +
+
)} diff --git a/src/store/git.ts b/src/store/git.ts new file mode 100644 index 00000000..ea9327de --- /dev/null +++ b/src/store/git.ts @@ -0,0 +1,131 @@ +import { create } from 'zustand' +import { daemon } from '../lib/daemonBridge' +import type { GitFile, GitCommit } from '../../electron/shared/types' + +const STALE_MS = 2_000 + +interface GitProjectState { + branch: string | null + branches: string[] + files: GitFile[] + commits: GitCommit[] + stashCount: number + latestStashMessage: string | null + error: string | null + lastFetch: number +} + +const EMPTY: GitProjectState = { + branch: null, + branches: [], + files: [], + commits: [], + stashCount: 0, + latestStashMessage: null, + error: null, + lastFetch: 0, +} + +interface GitStoreState { + byProject: Record + inflight: Record | undefined> + /** Always fetches. */ + refresh: (projectPath: string) => Promise + /** Skips fetch if state was refreshed within STALE_MS. */ + refreshIfStale: (projectPath: string) => Promise + /** Clears cached state for a project (call on project deletion). */ + invalidate: (projectPath: string) => void + /** Marks all cached projects stale so the next refreshIfStale fetches. */ + invalidateAll: () => void +} + +function selectProject(state: GitStoreState, projectPath: string | null | undefined): GitProjectState { + if (!projectPath) return EMPTY + return state.byProject[projectPath] ?? EMPTY +} + +export const useGitStore = create((set, get) => ({ + byProject: {}, + inflight: {}, + + refresh: async (projectPath: string) => { + if (!projectPath) return + const existing = get().inflight[projectPath] + if (existing) return existing + + const promise = (async () => { + try { + const [brRes, statusRes, logRes, stashRes] = await Promise.all([ + daemon.git.branches(projectPath), + daemon.git.status(projectPath), + daemon.git.log(projectPath), + daemon.git.stashList(projectPath), + ]) + + const prev = get().byProject[projectPath] ?? EMPTY + const next: GitProjectState = { ...prev, lastFetch: Date.now() } + + if (brRes.ok && brRes.data) { + next.branch = brRes.data.current + next.branches = brRes.data.branches + } + if (statusRes.ok && statusRes.data) { + next.files = statusRes.data + next.error = null + } else { + next.error = statusRes.error ?? 'Git operation failed' + } + if (logRes.ok && logRes.data) next.commits = logRes.data + if (stashRes.ok && stashRes.data) { + next.stashCount = stashRes.data.length + next.latestStashMessage = stashRes.data[0]?.message ?? null + } + + set((state) => ({ byProject: { ...state.byProject, [projectPath]: next } })) + } finally { + set((state) => { + const { [projectPath]: _, ...rest } = state.inflight + return { inflight: rest } + }) + } + })() + + set((state) => ({ inflight: { ...state.inflight, [projectPath]: promise } })) + return promise + }, + + refreshIfStale: async (projectPath: string) => { + if (!projectPath) return + const project = get().byProject[projectPath] + if (project && Date.now() - project.lastFetch < STALE_MS) return + await get().refresh(projectPath) + }, + + invalidate: (projectPath: string) => { + set((state) => { + const { [projectPath]: _, ...rest } = state.byProject + return { byProject: rest } + }) + }, + + invalidateAll: () => { + set((state) => { + const next: Record = {} + for (const [key, value] of Object.entries(state.byProject)) { + next[key] = { ...value, lastFetch: 0 } + } + return { byProject: next } + }) + }, +})) + +if (typeof window !== 'undefined') { + window.addEventListener('focus', () => { + useGitStore.getState().invalidateAll() + }) +} + +/** Hook returning the cached state for a project, or EMPTY when unknown. */ +export function useGitProject(projectPath: string | null | undefined): GitProjectState { + return useGitStore((state) => selectProject(state, projectPath)) +} diff --git a/src/types/daemon.d.ts b/src/types/daemon.d.ts index 5531705d..e54f886c 100644 --- a/src/types/daemon.d.ts +++ b/src/types/daemon.d.ts @@ -1092,6 +1092,37 @@ declare global { feedback: DaemonFeedback agentStation: DaemonAgentStation replay: DaemonReplay + spawnAgents: DaemonSpawnAgents + } + + type SpawnAgentDna = import('../../electron/services/SpawnAgentsService').SpawnAgentDna + type SpawnAgentRecord = import('../../electron/services/SpawnAgentsService').SpawnAgentRecord + type SpawnDepositInstruction = import('../../electron/services/SpawnAgentsService').SpawnDepositInstruction + type SpawnStatusResult = import('../../electron/services/SpawnAgentsService').SpawnStatusResult + type SpawnTrade = import('../../electron/services/SpawnAgentsService').SpawnTrade + type SpawnAgentPositions = import('../../electron/services/SpawnAgentsService').SpawnAgentPositions + type SpawnEvent = import('../../electron/services/SpawnAgentsService').SpawnEvent + type SpawnEventsResult = import('../../electron/services/SpawnAgentsService').SpawnEventsResult + type SpawnInput = import('../../electron/services/SpawnAgentsService').SpawnInput + type SpawnChildInput = import('../../electron/services/SpawnAgentsService').SpawnChildInput + type SpawnAndFundResult = import('../../electron/services/SpawnAgentsService').SpawnAndFundResult + type WithdrawResult = import('../../electron/services/SpawnAgentsService').WithdrawResult + type KillResult = import('../../electron/services/SpawnAgentsService').KillResult + + interface DaemonSpawnAgents { + list: (ownerPubkey: string) => Promise> + get: (agentId: string) => Promise> + trades: (agentId: string, limit?: number, offset?: number) => Promise> + positions: (agentId: string) => Promise> + events: (since: number, agentId?: string, limit?: number) => Promise> + spawnStatus: (ref: string) => Promise> + initiateSpawn: (input: SpawnInput) => Promise> + initiateSpawnChild: (parentAgentId: string, walletId: string, input: SpawnChildInput) => Promise> + spawnAndFund: (walletId: string, input: SpawnInput) => Promise> + spawnChildAndFund: (parentAgentId: string, walletId: string, input: SpawnChildInput) => Promise> + withdraw: (agentId: string, walletId: string, amountSol: number) => Promise> + kill: (agentId: string, walletId: string) => Promise> + onEvent: (callback: (ev: SpawnEvent) => void) => () => void } interface DaemonReplay { diff --git a/test/panels/AppSurfaces.dom.test.tsx b/test/panels/AppSurfaces.dom.test.tsx index e4b2ef3d..ddc06ac6 100644 --- a/test/panels/AppSurfaces.dom.test.tsx +++ b/test/panels/AppSurfaces.dom.test.tsx @@ -83,10 +83,10 @@ function installDaemonBridge(options: { { id: 'project-1', name: 'daemon-app', path: 'C:/work/daemon-app' }, ], }) - const createTerminal = vi.fn().mockImplementation(async ({ startupCommand }: { startupCommand?: string }) => ({ + const createTerminal = vi.fn().mockImplementation(async ({ cwd, startupCommand }: { cwd?: string; startupCommand?: string }) => ({ ok: true, data: { - id: startupCommand?.includes('MyFirstBot') + id: cwd?.endsWith('/MyFirstBot') || cwd?.endsWith('\\MyFirstBot') ? 'terminal-project-build' : startupCommand?.includes('agent:first-solana') ? 'terminal-sendai-run' @@ -97,6 +97,16 @@ function installDaemonBridge(options: { agentId: null, }, })) + const spawnAgent = vi.fn().mockImplementation(async ({ initialPrompt }: { initialPrompt?: string }) => ({ + ok: true, + data: { + id: initialPrompt?.includes('MyFirstBot') ? 'terminal-project-build' : 'terminal-agent', + pid: 123, + agentId: 'solana-agent', + agentName: 'Solana Agent', + localSessionId: 'local-session-1', + }, + })) const transactionPreview = vi.fn().mockResolvedValue({ ok: true, data: { @@ -220,6 +230,7 @@ function installDaemonBridge(options: { }, terminal: { create: createTerminal, + spawnAgent, }, projects: { list: listProjects, @@ -418,9 +429,16 @@ describe('App surface DOM coverage', () => { await waitFor(() => { expect(window.daemon.terminal.create).toHaveBeenCalledWith(expect.objectContaining({ cwd: 'C:/work/MyFirstBot', - startupCommand: expect.stringContaining('MyFirstBot'), })) + expect(window.daemon.terminal.create).not.toHaveBeenCalledWith(expect.objectContaining({ + startupCommand: expect.any(String), + })) + expect(window.daemon.terminal.spawnAgent).not.toHaveBeenCalled() }) + expect(window.daemon.fs.writeFile).toHaveBeenCalledWith( + 'C:/work/MyFirstBot/package.json', + expect.stringContaining('"name": "myfirstbot"'), + ) expect(useUIStore.getState().activeProjectId).toBe('project-new') expect(useUIStore.getState().activeWorkspaceToolId).toBeNull() expect(useUIStore.getState().activeTerminalIdByProject['project-new']).toBe('terminal-project-build') diff --git a/test/panels/IntegrationCommandCenter.registry.test.ts b/test/panels/IntegrationCommandCenter.registry.test.ts index d2676d00..0b1ed0f7 100644 --- a/test/panels/IntegrationCommandCenter.registry.test.ts +++ b/test/panels/IntegrationCommandCenter.registry.test.ts @@ -53,4 +53,20 @@ describe('Integration Command Center registry', () => { expect(resolveIntegrationStatus(sendAi!, context).status).toBe('ready') expect(resolveIntegrationStatus(helius!, context).status).toBe('ready') }) + + it('has SpawnAgents as a native DAEMON integration with wallet requirement and panel action', () => { + const spawnAgents = INTEGRATION_REGISTRY.find((integration) => integration.id === 'spawnagents') + + expect(spawnAgents).toBeDefined() + expect(spawnAgents!.docsUrl).toBe('https://spawnagents.fun/how') + expect(spawnAgents!.requirements).toContainEqual({ + type: 'wallet', + key: 'default-wallet', + label: 'DAEMON wallet with keypair (for signing agent actions)', + }) + expect(spawnAgents!.actions.map((action) => action.id)).toEqual([ + 'open-spawnagents-panel', + 'open-spawnagents-live', + ]) + }) }) diff --git a/test/panels/ProjectStarter.runtime.test.ts b/test/panels/ProjectStarter.runtime.test.ts index 4d1f8ed8..2ae8cfb8 100644 --- a/test/panels/ProjectStarter.runtime.test.ts +++ b/test/panels/ProjectStarter.runtime.test.ts @@ -1,5 +1,16 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { buildRuntimePreset, buildRuntimePrompt } from '../../src/panels/ProjectStarter/ProjectStarter' +import { + buildDeterministicScaffold, + buildPerpsPromptAddon, + buildRuntimePreset, + buildRuntimePrompt, + PERPS_TEMPLATE_IDS, + TEMPLATES, + type Template, +} from '../../src/panels/ProjectStarter/ProjectStarter' describe('ProjectStarter runtime preset helpers', () => { it('returns null when no wallet infrastructure settings are available', () => { @@ -45,3 +56,160 @@ describe('ProjectStarter runtime preset helpers', () => { vi.useRealTimers() }) }) + +describe('Perps template prompt addon', () => { + const settings = { + rpcProvider: 'helius' as const, + quicknodeRpcUrl: '', + customRpcUrl: '', + swapProvider: 'jupiter' as const, + preferredWallet: 'phantom' as const, + executionMode: 'jito' as const, + jitoBlockEngineUrl: 'https://mainnet.block-engine.jito.wtf/api/v1/transactions', + } + + it('exposes the four perps template ids', () => { + expect(PERPS_TEMPLATE_IDS).toEqual([ + 'perps-trading-bot', + 'perps-vault', + 'perps-frontend', + 'perps-liquidator', + ]) + }) + + it('returns empty string when settings are absent', () => { + expect(buildPerpsPromptAddon('perps-trading-bot', null)).toBe('') + }) + + it('emits Helius + Sender + Jito wiring for the trading bot template', () => { + const out = buildPerpsPromptAddon('perps-trading-bot', settings) + expect(out).toContain('Perps architecture requirements:') + expect(out).toContain('Helius RPC') + expect(out).toContain('Helius Sender') + expect(out).toContain('Jito') + expect(out).toContain('Ranger SDK') + expect(out).toContain('VENUE=drift|jupiter|ranger') + expect(out).toContain('kill-switch') + }) + + it('emits Drift Vaults guidance for the vault template', () => { + const out = buildPerpsPromptAddon('perps-vault', settings) + expect(out).toContain('Drift Vaults SDK') + expect(out).toContain('NAV') + expect(out).toContain('Vitest') + }) + + it('emits server-proxy + LaserStream guidance for the frontend template', () => { + const out = buildPerpsPromptAddon('perps-frontend', settings) + expect(out).toContain('Phantom Connect SDK') + expect(out).toContain('HELIUS_API_KEY never reaches the browser') + expect(out).toContain('LaserStream') + expect(out).toContain('Jupiter Perps') + }) + + it('emits Pyth refresh + profit guard guidance for the liquidator template', () => { + const out = buildPerpsPromptAddon('perps-liquidator', settings) + expect(out).toContain('Helius LaserStream') + expect(out).toContain('Pyth price refresh') + expect(out).toContain('MIN_PROFIT_USD') + expect(out).toContain('Jito bundles') + }) + + it('falls back to common section for an unrecognized perps id', () => { + const out = buildPerpsPromptAddon('perps-unknown', settings) + expect(out).toContain('Perps architecture requirements:') + expect(out).not.toContain('Drift Vaults SDK') + }) +}) + +describe('deterministic project scaffold', () => { + function writeScaffoldToDisk(template: Template, projectName: string, root: string) { + const scaffold = buildDeterministicScaffold(template, projectName) + for (const dir of scaffold.dirs) { + fs.mkdirSync(path.join(root, dir), { recursive: true }) + } + for (const file of scaffold.files) { + const target = path.join(root, file.path) + fs.mkdirSync(path.dirname(target), { recursive: true }) + fs.writeFileSync(target, file.content, 'utf8') + } + return scaffold + } + + it('creates files and setup command without requiring an agent prompt', () => { + const template: Template = { + id: 'trading-bot', + name: 'Trading Bot', + description: 'Trading scaffold', + tags: ['Bot'], + icon: '', + prompt: 'Scaffold a trading bot.', + } + + const scaffold = buildDeterministicScaffold(template, 'My First Bot') + expect(scaffold.files.some((file) => file.path === 'package.json' && file.content.includes('"name": "my-first-bot"'))).toBe(true) + expect(scaffold.files.some((file) => file.path === 'src/index.ts')).toBe(true) + expect(scaffold.files.some((file) => file.content.includes('claude --model'))).toBe(false) + }) + + it('writes the expected project structure to disk', () => { + const template: Template = { + id: 'perps-trading-bot', + name: 'Perps Trading Bot', + description: 'Perps scaffold', + tags: ['Perps'], + icon: '', + prompt: 'Scaffold a Solana perps trading bot.', + } + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'daemon-scaffold-')) + + try { + writeScaffoldToDisk(template, 'PerpsTradingBotManualTest', root) + + expect(fs.existsSync(path.join(root, 'package.json'))).toBe(true) + expect(fs.existsSync(path.join(root, 'README.md'))).toBe(true) + expect(fs.existsSync(path.join(root, '.env.example'))).toBe(true) + expect(fs.existsSync(path.join(root, 'src', 'index.ts'))).toBe(true) + expect(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).toContain('"name": "perpstradingbotmanualtest"') + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) + + it.each(TEMPLATES)('writes the %s template to disk', (template) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `daemon-${template.id}-`)) + + try { + const scaffold = writeScaffoldToDisk(template, `${template.id}-Smoke`, root) + const filePaths = new Set(scaffold.files.map((file) => file.path)) + const allContent = scaffold.files.map((file) => file.content).join('\n') + + expect(fs.existsSync(path.join(root, 'package.json'))).toBe(true) + expect(fs.existsSync(path.join(root, 'README.md'))).toBe(true) + expect(fs.existsSync(path.join(root, '.env.example'))).toBe(true) + expect(fs.existsSync(path.join(root, 'tsconfig.json'))).toBe(true) + expect(JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).name).toBeTruthy() + expect(allContent).not.toContain('claude --model') + expect(allContent).not.toContain('dangerously-skip-permissions') + + if (template.id === 'anchor-program') { + expect(filePaths.has('Anchor.toml')).toBe(true) + expect([...filePaths].some((filePath) => filePath.startsWith('programs/') && filePath.endsWith('/src/lib.rs'))).toBe(true) + expect([...filePaths].some((filePath) => filePath.startsWith('tests/') && filePath.endsWith('.test.ts'))).toBe(true) + } else if (['dapp-nextjs', 'solana-foundation', 'perps-frontend'].includes(template.id)) { + expect(filePaths.has('app/layout.tsx')).toBe(true) + expect(filePaths.has('app/page.tsx')).toBe(true) + expect(filePaths.has('app/globals.css')).toBe(true) + expect(filePaths.has('next.config.mjs')).toBe(true) + expect(fs.existsSync(path.join(root, 'app', 'page.tsx'))).toBe(true) + } else { + expect(filePaths.has('src/config.ts')).toBe(true) + expect(filePaths.has('src/index.ts')).toBe(true) + expect(filePaths.has('src/strategy.ts')).toBe(true) + expect(fs.existsSync(path.join(root, 'src', 'index.ts'))).toBe(true) + } + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/test/panels/ShellChrome.dom.test.tsx b/test/panels/ShellChrome.dom.test.tsx index f754faac..5ba9171b 100644 --- a/test/panels/ShellChrome.dom.test.tsx +++ b/test/panels/ShellChrome.dom.test.tsx @@ -249,6 +249,26 @@ describe('Shell chrome DOM coverage', () => { expect(windowControls.close).toHaveBeenCalledTimes(1) }) + it('asks whether the project plus should open a codebase or scaffold a project', async () => { + const onAddProject = vi.fn() + + render( + , + ) + + await userEvent.click(screen.getByRole('button', { name: 'Add project' })) + expect(screen.getByRole('menuitem', { name: 'Open Codebase' })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: 'Scaffold Project' })).toBeInTheDocument() + + await userEvent.click(screen.getByRole('menuitem', { name: 'Scaffold Project' })) + expect(useUIStore.getState().activeWorkspaceToolId).toBe('starter') + expect(onAddProject).not.toHaveBeenCalled() + }) + it('routes settings search to display and persists display toggles', async () => { const { setShowMarketTape, setShowTitlebarWallet } = installDaemonBridge() diff --git a/vite.config.ts b/vite.config.ts index 02e73123..6cbfc508 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,25 @@ import react from '@vitejs/plugin-react' import electron from 'vite-plugin-electron/simple' import pkg from './package.json' +function rendererManualChunks(id: string) { + if (!id.includes('node_modules')) return undefined + + if (id.includes('monaco-editor') || id.includes('@monaco-editor')) { + if (id.includes('/language/typescript/') || id.includes('\\language\\typescript\\')) return 'monaco-typescript' + if (id.includes('/language/json/') || id.includes('\\language\\json\\')) return 'monaco-json' + if (id.includes('/language/css/') || id.includes('\\language\\css\\')) return 'monaco-css' + if (id.includes('/language/html/') || id.includes('\\language\\html\\')) return 'monaco-html' + if (id.includes('/basic-languages/') || id.includes('\\basic-languages\\')) return 'monaco-basic-languages' + return 'monaco-core' + } + + if (id.includes('@xterm')) return 'xterm' + if (id.includes('react') || id.includes('scheduler')) return 'react-vendor' + if (id.includes('zustand')) return 'state-vendor' + + return 'vendor' +} + export default defineConfig(({ command }) => { rmSync('dist-electron', { recursive: true, force: true }) @@ -75,5 +94,15 @@ export default defineConfig(({ command }) => { worker: { format: 'es' as const, }, + build: { + // DAEMON ships Monaco inside Electron, so Vite's 500 kB browser-page + // default produces noise for the intentionally local editor bundle. + chunkSizeWarningLimit: 4096, + rollupOptions: { + output: { + manualChunks: rendererManualChunks, + }, + }, + }, } })