From 0e6425fdc0bce4280bfd2d49be3638877954058b Mon Sep 17 00:00:00 2001 From: nullxnothing Date: Fri, 3 Jul 2026 17:20:48 -0600 Subject: [PATCH 01/26] fix(db): refresh superseded seeded agent model ids on upgraded installs Fresh installs seed current model aliases, but upgraded DBs kept agent rows pointing at claude-sonnet-4-20250514 / claude-opus-4-20250514 from old seeds, and the shared MODEL_MAP still hardcoded the same dated ids. V59 rewrites only those exact known-stale ids to the current aliases (a valid model the user picked on purpose is never touched) and the shared shorthand maps now resolve to claude-sonnet-4-6 / claude-opus-4-8. --- electron/db/migrations.ts | 31 +++++++ packages/shared/src/constants.ts | 7 +- packages/shared/src/validation.ts | 4 +- test/services/AgentModelMigration.test.ts | 98 +++++++++++++++++++++++ test/shared/modelConstants.test.ts | 32 ++++++++ 5 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 test/services/AgentModelMigration.test.ts create mode 100644 test/shared/modelConstants.test.ts diff --git a/electron/db/migrations.ts b/electron/db/migrations.ts index e717db0e..d1340614 100644 --- a/electron/db/migrations.ts +++ b/electron/db/migrations.ts @@ -706,6 +706,17 @@ export function runMigrations(db: Database.Database) { })() } + if (currentVersion < 59) { + db.transaction(() => { + // Fresh installs seed agents with current model aliases, but upgraded + // installs kept rows pointing at models DAEMON seeded in old releases + // that Anthropic has since superseded. Refresh only those exact IDs — + // a still-valid model the user picked on purpose is never rewritten. + refreshSupersededAgentModels(db) + db.prepare('INSERT INTO _migrations (version) VALUES (?)').run(59) + })() + } + // Ensure Solana agent exists (idempotent — handles existing DBs before it was seeded) try { const hasSolanaAgent = db.prepare("SELECT id FROM agents WHERE id = 'solana-agent'").get() @@ -893,6 +904,26 @@ Output: bullet points with inline citations. Be direct. No fluff.`, db.prepare('DELETE FROM active_sessions').run() } +/** + * Model IDs DAEMON itself seeded in past releases that no longer resolve as + * defaults on current Anthropic surfaces, mapped to their current aliases. + * Deliberately an exact allowlist: anything else on an agent row — including + * still-valid dated snapshots like the Haiku 4.5 ID — is treated as a user + * choice and left alone. + */ +export const SUPERSEDED_AGENT_MODELS: Record = { + 'claude-sonnet-4-20250514': 'claude-sonnet-4-6', + 'claude-opus-4-20250514': 'claude-opus-4-8', +} + +/** Rewrite agent rows whose model is a known-superseded seed ID (exact match only). */ +export function refreshSupersededAgentModels(db: Database.Database): void { + const update = db.prepare('UPDATE agents SET model = ? WHERE model = ?') + for (const [staleId, currentId] of Object.entries(SUPERSEDED_AGENT_MODELS)) { + update.run(currentId, staleId) + } +} + function seedDefaults(db: Database.Database) { const agentCount = (db.prepare('SELECT COUNT(*) as c FROM agents').get() as { c: number }).c if (agentCount > 0) return diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 13fcf688..b438dccb 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -17,10 +17,13 @@ export const SOLANA_ENDPOINTS = { heliusMainnet: (apiKey: string) => `https://mainnet.helius-rpc.com/?api-key=${apiKey}`, } as const +// Current model aliases. Sonnet/Opus aliases are dateless and complete as +// written — never append a date suffix. Keep in sync with resolveModelName +// in validation.ts and the desktop maps in electron/services/providers. export const MODEL_MAP: Record = { haiku: 'claude-haiku-4-5-20251001', - sonnet: 'claude-sonnet-4-20250514', - opus: 'claude-opus-4-20250514', + sonnet: 'claude-sonnet-4-6', + opus: 'claude-opus-4-8', } as const export const DEFAULT_MAX_TOKENS = 4096 diff --git a/packages/shared/src/validation.ts b/packages/shared/src/validation.ts index 83267347..96e2fc8b 100644 --- a/packages/shared/src/validation.ts +++ b/packages/shared/src/validation.ts @@ -21,8 +21,8 @@ export function sanitizeString(input: string, maxLength = 256): string { export function resolveModelName(shorthand: string): string { const modelMap: Record = { haiku: 'claude-haiku-4-5-20251001', - sonnet: 'claude-sonnet-4-20250514', - opus: 'claude-opus-4-20250514', + sonnet: 'claude-sonnet-4-6', + opus: 'claude-opus-4-8', } return modelMap[shorthand] ?? shorthand } diff --git a/test/services/AgentModelMigration.test.ts b/test/services/AgentModelMigration.test.ts new file mode 100644 index 00000000..5040fa9e --- /dev/null +++ b/test/services/AgentModelMigration.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest' +import type Database from 'better-sqlite3' +import { SUPERSEDED_AGENT_MODELS, refreshSupersededAgentModels } from '../../electron/db/migrations' + +// Minimal in-memory stand-in for the better-sqlite3 surface the refresh uses. +// Avoids loading the Electron-ABI native module under vitest's plain-Node runtime. +interface AgentRow { id: string; model: string } + +class FakeDb { + agents: AgentRow[] + + constructor(rows: AgentRow[]) { + this.agents = rows.map((r) => ({ ...r })) + } + + prepare(sql: string) { + if (sql.trim() !== 'UPDATE agents SET model = ? WHERE model = ?') { + throw new Error(`unexpected sql: ${sql}`) + } + return { + run: (nextModel: string, prevModel: string) => { + let changes = 0 + for (const row of this.agents) { + if (row.model === prevModel) { + row.model = nextModel + changes++ + } + } + return { changes } + }, + } + } +} + +function asDb(fake: FakeDb): Database.Database { + return fake as unknown as Database.Database +} + +describe('refreshSupersededAgentModels (V59 upgrade migration)', () => { + it('rewrites the superseded seeded IDs to the current aliases', () => { + const db = new FakeDb([ + { id: 'daemon-debug', model: 'claude-sonnet-4-20250514' }, + { id: 'solana-agent', model: 'claude-opus-4-20250514' }, + ]) + refreshSupersededAgentModels(asDb(db)) + expect(db.agents).toEqual([ + { id: 'daemon-debug', model: 'claude-sonnet-4-6' }, + { id: 'solana-agent', model: 'claude-opus-4-8' }, + ]) + }) + + it('leaves the still-valid dated Haiku snapshot untouched', () => { + const db = new FakeDb([{ id: 'git-agent', model: 'claude-haiku-4-5-20251001' }]) + refreshSupersededAgentModels(asDb(db)) + expect(db.agents[0].model).toBe('claude-haiku-4-5-20251001') + }) + + it('never rewrites a deliberate non-default model that is still valid', () => { + const db = new FakeDb([ + { id: 'custom-a', model: 'claude-opus-4-5' }, + { id: 'custom-b', model: 'claude-sonnet-4-6' }, + { id: 'custom-c', model: 'claude-opus-4-8' }, + ]) + refreshSupersededAgentModels(asDb(db)) + expect(db.agents.map((a) => a.model)).toEqual([ + 'claude-opus-4-5', + 'claude-sonnet-4-6', + 'claude-opus-4-8', + ]) + }) + + it('is idempotent — a second run changes nothing', () => { + const db = new FakeDb([ + { id: 'daemon-debug', model: 'claude-sonnet-4-20250514' }, + { id: 'git-agent', model: 'claude-haiku-4-5-20251001' }, + ]) + refreshSupersededAgentModels(asDb(db)) + const afterFirst = db.agents.map((a) => ({ ...a })) + refreshSupersededAgentModels(asDb(db)) + expect(db.agents).toEqual(afterFirst) + }) + + it('maps only superseded IDs onto exact current aliases', () => { + expect(SUPERSEDED_AGENT_MODELS).toEqual({ + 'claude-sonnet-4-20250514': 'claude-sonnet-4-6', + 'claude-opus-4-20250514': 'claude-opus-4-8', + }) + // Targets are dateless current aliases — never invented date suffixes. + for (const target of Object.values(SUPERSEDED_AGENT_MODELS)) { + expect(target).not.toMatch(/-20\d{6}$/) + } + // No key maps to itself and no still-valid ID appears as a key. + for (const [stale, current] of Object.entries(SUPERSEDED_AGENT_MODELS)) { + expect(stale).not.toBe(current) + expect(current in SUPERSEDED_AGENT_MODELS).toBe(false) + } + }) +}) diff --git a/test/shared/modelConstants.test.ts b/test/shared/modelConstants.test.ts new file mode 100644 index 00000000..67a4828d --- /dev/null +++ b/test/shared/modelConstants.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import { MODEL_MAP } from '../../packages/shared/src/constants' +import { resolveModelName } from '../../packages/shared/src/validation' + +// Guards the shared model shorthand maps against reintroducing retired +// date-suffixed Sonnet/Opus IDs (the current aliases are dateless; only the +// Haiku 4.5 snapshot legitimately carries a date). +describe('shared model constants', () => { + it('maps shorthands to the current model aliases', () => { + expect(MODEL_MAP).toEqual({ + haiku: 'claude-haiku-4-5-20251001', + sonnet: 'claude-sonnet-4-6', + opus: 'claude-opus-4-8', + }) + }) + + it('sonnet/opus aliases carry no date suffix', () => { + expect(MODEL_MAP.sonnet).not.toMatch(/-20\d{6}$/) + expect(MODEL_MAP.opus).not.toMatch(/-20\d{6}$/) + }) + + it('resolveModelName agrees with MODEL_MAP for every shorthand', () => { + for (const [shorthand, id] of Object.entries(MODEL_MAP)) { + expect(resolveModelName(shorthand)).toBe(id) + } + }) + + it('resolveModelName passes explicit model IDs through unchanged', () => { + expect(resolveModelName('claude-opus-4-5')).toBe('claude-opus-4-5') + expect(resolveModelName('claude-sonnet-4-6')).toBe('claude-sonnet-4-6') + }) +}) From b8ca298a73d66e08bd6f4fb3b11608fb49808faa Mon Sep 17 00:00:00 2001 From: nullxnothing Date: Fri, 3 Jul 2026 17:21:03 -0600 Subject: [PATCH 02/26] fix(aria): call out rejected api keys instead of silently dropping tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A disabled or invalid ANTHROPIC_API_KEY inherited from the shell made the operator loop throw, and the catch quietly fell back to the tool-less CLI answer — ARIA lost every tool with no explanation. The fallback now detects auth rejections (401/403/authentication_error), names which credential failed (stored key vs shell env, mirroring the loop's stored-first precedence), and prepends an actionable notice to the degraded reply. Key values never appear in messages or logs. --- electron/services/AriaAgentService.ts | 22 +++-- electron/services/providers/ClaudeProvider.ts | 10 +++ electron/services/providers/claudeAuth.ts | 51 ++++++++++++ test/services/claudeAuth.test.ts | 82 +++++++++++++++++++ 4 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 electron/services/providers/claudeAuth.ts create mode 100644 test/services/claudeAuth.test.ts diff --git a/electron/services/AriaAgentService.ts b/electron/services/AriaAgentService.ts index 3bcd921e..3993208b 100644 --- a/electron/services/AriaAgentService.ts +++ b/electron/services/AriaAgentService.ts @@ -9,7 +9,8 @@ */ import crypto from 'node:crypto' import { getDb } from '../db/db' -import { runClaudeAgentTurn } from './providers/ClaudeProvider' +import { runClaudeAgentTurn, getClaudeKeySource } from './providers/ClaudeProvider' +import { isAnthropicAuthError, describeClaudeAuthFailure } from './providers/claudeAuth' import * as ProviderRegistry from './providers/ProviderRegistry' import { resolveOperatorBackend, getGlmEndpoint, type OperatorBackend, type OperatorEndpoint } from './providers/glmConfig' import { recordLocalAiUsage } from './DaemonAIService' @@ -361,11 +362,18 @@ export async function sendMessage( messages.push({ role: 'user', content: toolResults }) } } catch (err) { + // A rejected Anthropic credential (e.g. a disabled-org ANTHROPIC_API_KEY + // inherited from the shell) must not silently downgrade the operator to + // the tool-less CLI path — name the failing key source and how to fix it. + // GLM turns (endpoint set) fail on the ZAI key, not the Anthropic one. + const authNotice = !endpoint && isAnthropicAuthError(err) + ? describeClaudeAuthFailure(getClaudeKeySource()) + : null const fallback = ProviderRegistry.getFeatureProvider('aria') if (fallback.id === 'claude' && await verifyPreferredProvider('claude')) { - return legacyAnswer(sessionId, userMessage, fallback, text, transport) + return legacyAnswer(sessionId, userMessage, fallback, text, transport, authNotice ?? undefined) } - finalText = `Error: ${(err as Error).message}` + finalText = authNotice ?? `Error: ${(err as Error).message}` } // Any plan steps left un-flipped are done once the loop settles. @@ -711,13 +719,16 @@ function describeIntent(tool: AriaTool, input: Record): string return clusterMark(`${tool.name}${detail}`) } -/** Non-Claude providers: single-shot text answer, no tools. */ +/** Non-Claude providers: single-shot text answer, no tools. When the caller is + * degrading here because of an auth failure, `notice` is prepended to the + * reply so the loss of operator tools is never silent. */ async function legacyAnswer( sessionId: string, userMessage: string, provider: ReturnType, text: ConversationEntry[], transport: AriaTransport, + notice?: string, ): Promise { const prompt = [ 'ARIA side-panel conversation:', @@ -725,7 +736,8 @@ async function legacyAnswer( '', 'Respond as ARIA, concise and direct.', ].join('\n') - const out = await provider.runPrompt({ prompt, model: 'sonnet', effort: 'low', maxTokens: 1024, timeoutMs: 60_000 }) + const answer = await provider.runPrompt({ prompt, model: 'sonnet', effort: 'low', maxTokens: 1024, timeoutMs: 60_000 }) + const out = notice ? `${notice}\n\n${answer}` : answer text.push({ role: 'assistant', content: out }) persistMessage({ role: 'assistant', content: out, metadata: '{}', session_id: sessionId }) transport.emit({ kind: 'done', messageId: sessionId, text: out }) diff --git a/electron/services/providers/ClaudeProvider.ts b/electron/services/providers/ClaudeProvider.ts index cbc7f875..22b9ba6b 100644 --- a/electron/services/providers/ClaudeProvider.ts +++ b/electron/services/providers/ClaudeProvider.ts @@ -8,6 +8,7 @@ import * as SecureKey from '../SecureKeyService' import { TIMEOUTS } from '../../config/constants' import { writeProjectMcpConfig, readProjectMcpConfig, getRegistryMcps, hasProjectMcpFile } from '../McpConfig' import { parseContextTags, stripContextTags, buildPortMap, buildEmailContext, buildMppContext } from './contextUtils' +import { resolveClaudeKeySource, type ClaudeKeySource } from './claudeAuth' import type { ProviderInterface, ProviderConnection, ProviderBuildResult, ProviderRunPromptOpts, AgentRow, ProjectRow } from './ProviderInterface' import type { RunAgentTurnOpts, AgentTurnResult, AgentToolUse } from './agentTurn' @@ -369,6 +370,15 @@ function withToolCache(tools: RunAgentTurnOpts['tools']): unknown[] { ) } +/** + * Where runClaudeAgentTurn's key would come from right now (stored key shadows + * the shell env — same order as the resolution below). Used to explain auth + * failures to the user without ever exposing key material. + */ +export function getClaudeKeySource(): ClaudeKeySource { + return resolveClaudeKeySource(() => SecureKey.getKey('ANTHROPIC_API_KEY'), process.env) +} + export async function runClaudeAgentTurn( opts: RunAgentTurnOpts, endpoint?: AgentTurnEndpoint, diff --git a/electron/services/providers/claudeAuth.ts b/electron/services/providers/claudeAuth.ts new file mode 100644 index 00000000..e5033ebc --- /dev/null +++ b/electron/services/providers/claudeAuth.ts @@ -0,0 +1,51 @@ +/** + * Anthropic credential-source introspection for the ARIA operator loop. + * + * runClaudeAgentTurn resolves its API key as stored (SecureKeyService) first, + * then the shell environment. When the key in play is rejected, the operator + * must tell the user which credential failed and how to fix it instead of + * silently degrading to the tool-less CLI fallback. This module is pure so it + * stays trivially testable; key VALUES never pass through it — sources only. + */ + +export type ClaudeKeySource = 'stored' | 'env' | 'none' + +/** + * Mirror of runClaudeAgentTurn's key precedence: a stored key shadows the + * shell environment. A throwing key reader (secure storage unavailable) + * falls through to the environment, matching the runtime behavior. + */ +export function resolveClaudeKeySource( + readStoredKey: () => string | null | undefined, + env: Record, +): ClaudeKeySource { + try { + if (readStoredKey()) return 'stored' + } catch { /* secure storage unavailable — fall through to env */ } + if (env.ANTHROPIC_API_KEY) return 'env' + return 'none' +} + +/** + * True for Anthropic auth/permission rejections (invalid, revoked, or + * disabled-org keys). Rate limits, overloads, and network failures are NOT + * auth errors — those should keep their original error message. + */ +export function isAnthropicAuthError(err: unknown): boolean { + if (!err || typeof err !== 'object') return false + const status = (err as { status?: unknown }).status + if (status === 401 || status === 403) return true + const message = err instanceof Error ? err.message : '' + return /authentication_error|permission_error|invalid x-api-key|organization has been disabled/i.test(message) +} + +/** Actionable user-facing explanation for an auth failure, keyed by credential source. */ +export function describeClaudeAuthFailure(source: ClaudeKeySource): string { + if (source === 'env') { + return 'ARIA operator tools are unavailable: the ANTHROPIC_API_KEY inherited from your shell environment was rejected by the Anthropic API (invalid or disabled). Unset that environment variable or save a valid key in Settings > AI Providers to restore tools. Answering without tools for now.' + } + if (source === 'stored') { + return 'ARIA operator tools are unavailable: the Anthropic API key saved in Settings was rejected (invalid or disabled). Replace it in Settings > AI Providers to restore tools. Answering without tools for now.' + } + return 'ARIA operator tools are unavailable: Anthropic API authentication failed. Add a valid API key in Settings > AI Providers to restore tools. Answering without tools for now.' +} diff --git a/test/services/claudeAuth.test.ts b/test/services/claudeAuth.test.ts new file mode 100644 index 00000000..ed7041db --- /dev/null +++ b/test/services/claudeAuth.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest' +import { + resolveClaudeKeySource, + isAnthropicAuthError, + describeClaudeAuthFailure, +} from '../../electron/services/providers/claudeAuth' + +describe('resolveClaudeKeySource (mirrors runClaudeAgentTurn precedence)', () => { + it('prefers the stored key over a shell env key when both exist', () => { + const source = resolveClaudeKeySource(() => 'stored-key', { ANTHROPIC_API_KEY: 'env-key' }) + expect(source).toBe('stored') + }) + + it('falls back to the shell env key when no stored key exists', () => { + expect(resolveClaudeKeySource(() => null, { ANTHROPIC_API_KEY: 'env-key' })).toBe('env') + expect(resolveClaudeKeySource(() => undefined, { ANTHROPIC_API_KEY: 'env-key' })).toBe('env') + }) + + it('reports none when neither credential exists', () => { + expect(resolveClaudeKeySource(() => null, {})).toBe('none') + }) + + it('treats an empty env value as absent', () => { + expect(resolveClaudeKeySource(() => null, { ANTHROPIC_API_KEY: '' })).toBe('none') + }) + + it('falls through to env when secure storage throws', () => { + const source = resolveClaudeKeySource( + () => { throw new Error('keychain locked') }, + { ANTHROPIC_API_KEY: 'env-key' }, + ) + expect(source).toBe('env') + }) +}) + +describe('isAnthropicAuthError', () => { + it('matches 401/403 SDK errors by status', () => { + expect(isAnthropicAuthError(Object.assign(new Error('bad'), { status: 401 }))).toBe(true) + expect(isAnthropicAuthError(Object.assign(new Error('bad'), { status: 403 }))).toBe(true) + }) + + it('matches auth error types by message when status is missing', () => { + expect(isAnthropicAuthError(new Error('400 {"type":"authentication_error"}'))).toBe(true) + expect(isAnthropicAuthError(new Error('permission_error: org restricted'))).toBe(true) + expect(isAnthropicAuthError(new Error('invalid x-api-key'))).toBe(true) + expect(isAnthropicAuthError(new Error('Your organization has been disabled.'))).toBe(true) + }) + + it('does not classify rate limits, overloads, or network failures as auth errors', () => { + expect(isAnthropicAuthError(Object.assign(new Error('rate_limit_error'), { status: 429 }))).toBe(false) + expect(isAnthropicAuthError(Object.assign(new Error('overloaded_error'), { status: 529 }))).toBe(false) + expect(isAnthropicAuthError(new Error('ECONNRESET'))).toBe(false) + expect(isAnthropicAuthError(null)).toBe(false) + expect(isAnthropicAuthError('authentication_error')).toBe(false) + }) +}) + +describe('describeClaudeAuthFailure', () => { + it('tells the user the SHELL key failed when the env credential was in play', () => { + const msg = describeClaudeAuthFailure('env') + expect(msg).toContain('ANTHROPIC_API_KEY') + expect(msg).toContain('shell environment') + expect(msg).toContain('Settings') + }) + + it('points at Settings when the stored key failed', () => { + const msg = describeClaudeAuthFailure('stored') + expect(msg).toContain('saved in Settings') + expect(msg).not.toContain('shell environment') + }) + + it('gives a generic actionable message when no source is known', () => { + const msg = describeClaudeAuthFailure('none') + expect(msg).toContain('Settings') + }) + + it('never asks for or echoes key material', () => { + for (const source of ['env', 'stored', 'none'] as const) { + expect(describeClaudeAuthFailure(source)).not.toMatch(/sk-ant/i) + } + }) +}) From 4d9ebc55be1e56b2c8cbd3518f46a5233bd670ed Mon Sep 17 00:00:00 2001 From: nullxnothing Date: Fri, 3 Jul 2026 17:29:22 -0600 Subject: [PATCH 03/26] refactor(models): derive every claude model id from one shared constant CLAUDE_MODEL_IDS in packages/shared is now the canonical id set; MODEL_MAP, resolveModelName, the desktop provider maps (ClaudeRouter/ClaudeProvider), ClaudeAgentService defaults, and the Agent Launcher / Agent Station pickers all derive from it, so a model bump is a one-line change. Station picker drops the superseded claude-sonnet-4-5 option. --- electron/services/ClaudeAgentService.ts | 9 +++++---- electron/services/ClaudeRouter.ts | 8 ++------ electron/services/providers/ClaudeProvider.ts | 7 +------ packages/shared/src/constants.ts | 19 +++++++++++++------ packages/shared/src/validation.ts | 9 +++------ src/panels/AgentLauncher/AgentForm.tsx | 9 +++++---- src/panels/AgentStation/AgentStation.tsx | 5 +++-- 7 files changed, 32 insertions(+), 34 deletions(-) diff --git a/electron/services/ClaudeAgentService.ts b/electron/services/ClaudeAgentService.ts index 4f77e75f..23531140 100644 --- a/electron/services/ClaudeAgentService.ts +++ b/electron/services/ClaudeAgentService.ts @@ -1,6 +1,7 @@ import fs from 'node:fs' import path from 'node:path' import os from 'node:os' +import { CLAUDE_MODEL_IDS } from '../../packages/shared/src/constants' import type { ClaudeAgentFile } from '../shared/types' export type { ClaudeAgentFile } @@ -76,12 +77,12 @@ function parseSimpleFrontmatter(frontmatter: string): Record { function normalizeModel(model: string | undefined): string { switch ((model ?? '').toLowerCase()) { case 'opus': - return 'claude-opus-4-8' + return CLAUDE_MODEL_IDS.opus case 'sonnet': - return 'claude-sonnet-4-6' + return CLAUDE_MODEL_IDS.sonnet case 'haiku': - return 'claude-haiku-4-5-20251001' + return CLAUDE_MODEL_IDS.haiku default: - return model && model.length > 0 ? model : 'claude-sonnet-4-6' + return model && model.length > 0 ? model : CLAUDE_MODEL_IDS.sonnet } } diff --git a/electron/services/ClaudeRouter.ts b/electron/services/ClaudeRouter.ts index adcdafc9..fefc6378 100644 --- a/electron/services/ClaudeRouter.ts +++ b/electron/services/ClaudeRouter.ts @@ -7,6 +7,7 @@ import * as SecureKey from './SecureKeyService' import { sanitizeAiPrompt } from '../security/PrivacyGuard' import { writeProjectMcpConfig, readProjectMcpConfig, getRegistryMcps, hasProjectMcpFile } from './McpConfig' import { getRegisteredPorts } from './PortService' +import { MODEL_MAP } from '../../packages/shared/src/constants' import type { ClaudeConnection } from '../shared/types' // --- In-memory cache --- @@ -385,12 +386,7 @@ function buildPortMap(): string { } function resolveModelName(shorthand: string): string { - const modelMap: Record = { - 'haiku': 'claude-haiku-4-5-20251001', - 'sonnet': 'claude-sonnet-4-6', - 'opus': 'claude-opus-4-8', - } - return modelMap[shorthand] ?? shorthand + return MODEL_MAP[shorthand] ?? shorthand } function buildSubscriptionEnv(): NodeJS.ProcessEnv { diff --git a/electron/services/providers/ClaudeProvider.ts b/electron/services/providers/ClaudeProvider.ts index 22b9ba6b..13ab8a4e 100644 --- a/electron/services/providers/ClaudeProvider.ts +++ b/electron/services/providers/ClaudeProvider.ts @@ -9,6 +9,7 @@ import { TIMEOUTS } from '../../config/constants' import { writeProjectMcpConfig, readProjectMcpConfig, getRegistryMcps, hasProjectMcpFile } from '../McpConfig' import { parseContextTags, stripContextTags, buildPortMap, buildEmailContext, buildMppContext } from './contextUtils' import { resolveClaudeKeySource, type ClaudeKeySource } from './claudeAuth' +import { MODEL_MAP } from '../../../packages/shared/src/constants' import type { ProviderInterface, ProviderConnection, ProviderBuildResult, ProviderRunPromptOpts, AgentRow, ProjectRow } from './ProviderInterface' import type { RunAgentTurnOpts, AgentTurnResult, AgentToolUse } from './agentTurn' @@ -19,12 +20,6 @@ let cachedClaudePath: string | null = null // --- Model Resolution --- -const MODEL_MAP: Record = { - 'haiku': 'claude-haiku-4-5-20251001', - 'sonnet': 'claude-sonnet-4-6', - 'opus': 'claude-opus-4-8', -} - function resolveModelName(shorthand: string): string { return MODEL_MAP[shorthand] ?? shorthand } diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index b438dccb..f9e22ac2 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -17,13 +17,20 @@ export const SOLANA_ENDPOINTS = { heliusMainnet: (apiKey: string) => `https://mainnet.helius-rpc.com/?api-key=${apiKey}`, } as const -// Current model aliases. Sonnet/Opus aliases are dateless and complete as -// written — never append a date suffix. Keep in sync with resolveModelName -// in validation.ts and the desktop maps in electron/services/providers. -export const MODEL_MAP: Record = { - haiku: 'claude-haiku-4-5-20251001', - sonnet: 'claude-sonnet-4-6', +// Canonical Claude model IDs — the single source of truth for every seeded +// agent, shorthand resolution, migration target, and UI picker across desktop +// and mobile. The Sonnet/Opus aliases are dateless and complete as written — +// never append a date suffix to them. +export const CLAUDE_MODEL_IDS = { opus: 'claude-opus-4-8', + sonnet: 'claude-sonnet-4-6', + haiku: 'claude-haiku-4-5-20251001', } as const +export type ClaudeModelShorthand = keyof typeof CLAUDE_MODEL_IDS + +// Shorthand → full model ID. Derived from CLAUDE_MODEL_IDS so the two can +// never drift apart. +export const MODEL_MAP: Record = { ...CLAUDE_MODEL_IDS } + export const DEFAULT_MAX_TOKENS = 4096 diff --git a/packages/shared/src/validation.ts b/packages/shared/src/validation.ts index 96e2fc8b..1de0925b 100644 --- a/packages/shared/src/validation.ts +++ b/packages/shared/src/validation.ts @@ -1,5 +1,7 @@ // Portable validation helpers shared between desktop and mobile. +import { MODEL_MAP } from './constants' + const SOLANA_ADDRESS_REGEX = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/ export function isValidSolanaAddress(address: string): boolean { @@ -19,10 +21,5 @@ export function sanitizeString(input: string, maxLength = 256): string { } export function resolveModelName(shorthand: string): string { - const modelMap: Record = { - haiku: 'claude-haiku-4-5-20251001', - sonnet: 'claude-sonnet-4-6', - opus: 'claude-opus-4-8', - } - return modelMap[shorthand] ?? shorthand + return MODEL_MAP[shorthand] ?? shorthand } diff --git a/src/panels/AgentLauncher/AgentForm.tsx b/src/panels/AgentLauncher/AgentForm.tsx index 0821f25f..1b708d89 100644 --- a/src/panels/AgentLauncher/AgentForm.tsx +++ b/src/panels/AgentLauncher/AgentForm.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { CLAUDE_MODEL_IDS } from '../../../packages/shared/src/constants' const PROVIDER_OPTIONS = [ { value: 'claude', label: 'Claude' }, @@ -7,9 +8,9 @@ const PROVIDER_OPTIONS = [ ] const CLAUDE_MODEL_OPTIONS = [ - { value: 'claude-opus-4-8', label: 'Opus' }, - { value: 'claude-sonnet-4-6', label: 'Sonnet' }, - { value: 'claude-haiku-4-5-20251001', label: 'Haiku' }, + { value: CLAUDE_MODEL_IDS.opus, label: 'Opus' }, + { value: CLAUDE_MODEL_IDS.sonnet, label: 'Sonnet' }, + { value: CLAUDE_MODEL_IDS.haiku, label: 'Haiku' }, ] const CODEX_MODEL_OPTIONS = [ @@ -27,7 +28,7 @@ interface AgentFormProps { export function AgentForm({ agent, onSave, onCancel }: AgentFormProps) { const [name, setName] = useState(agent?.name ?? '') const [provider, setProvider] = useState(agent?.provider ?? (agent ? 'auto' : 'claude')) - const [model, setModel] = useState(agent?.model ?? 'claude-sonnet-4-6') + const [model, setModel] = useState(agent?.model ?? CLAUDE_MODEL_IDS.sonnet) const [prompt, setPrompt] = useState(agent?.system_prompt ?? '') const [shortcut, setShortcut] = useState(agent?.shortcut ?? '') const [nameError, setNameError] = useState('') diff --git a/src/panels/AgentStation/AgentStation.tsx b/src/panels/AgentStation/AgentStation.tsx index cca391a7..e9c11e89 100644 --- a/src/panels/AgentStation/AgentStation.tsx +++ b/src/panels/AgentStation/AgentStation.tsx @@ -3,6 +3,7 @@ import { daemon } from '../../lib/daemonBridge' import { SkeletonRows } from '../../components/Panel' import { useAppActions } from '../../store/appActions' import { useUIStore } from '../../store/ui' +import { CLAUDE_MODEL_IDS } from '../../../packages/shared/src/constants' import type { SynapseSapAgent, SynapseSapCluster, SynapseSapDiscoveryResult, WalletListEntry } from '../../types/daemon' import css from './AgentStation.module.css' @@ -173,8 +174,8 @@ export function CreateForm({ onCreated, onCancel }: CreateFormProps) { - - + + From 1610c290194ca0ae06989e04d598fc924bdc887d Mon Sep 17 00:00:00 2001 From: nullxnothing Date: Fri, 3 Jul 2026 17:29:22 -0600 Subject: [PATCH 04/26] fix(db): remap superseded agent station models on upgrade V60 companion to the V59 agent refresh: agent_station_configs rows only ever hold values the station picker offered, so claude-opus-4-20250514 and claude-sonnet-4-5 there are DAEMON-originated and safe to remap to the current aliases. Non-Claude rows (gpt-*) pass through untouched. Migration targets now reference CLAUDE_MODEL_IDS so they cannot drift from the canonical set. --- electron/db/migrations.ts | 34 +++++++++++- test/services/AgentModelMigration.test.ts | 68 ++++++++++++++++++++--- 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/electron/db/migrations.ts b/electron/db/migrations.ts index d1340614..c8f052d1 100644 --- a/electron/db/migrations.ts +++ b/electron/db/migrations.ts @@ -1,5 +1,6 @@ import type Database from 'better-sqlite3' import { SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7, SCHEMA_V8, SCHEMA_V9, SCHEMA_V10, SCHEMA_V11, SCHEMA_V12, SCHEMA_V13, SCHEMA_V14, SCHEMA_V15, SCHEMA_V16, SCHEMA_V17, SCHEMA_V18, SCHEMA_V19, SCHEMA_V20, SCHEMA_V21, SCHEMA_V22, SCHEMA_V23, SCHEMA_V24, SCHEMA_V25, SCHEMA_V26, SCHEMA_V27, SCHEMA_V28, SCHEMA_V29, SCHEMA_V30, SCHEMA_V31, SCHEMA_V32, SCHEMA_V33, SCHEMA_V34, SCHEMA_V35, SCHEMA_V36, SCHEMA_V37, SCHEMA_V38, SCHEMA_V39, SCHEMA_V40, SCHEMA_V41, SCHEMA_V42, SCHEMA_V43, SCHEMA_V44, SCHEMA_V45, SCHEMA_V46, SCHEMA_V47, SCHEMA_V48, SCHEMA_V49, SCHEMA_V50, SCHEMA_V51, SCHEMA_V52, SCHEMA_V53, SCHEMA_V54, SCHEMA_V55, SCHEMA_V56, SCHEMA_V57 } from './schema' +import { CLAUDE_MODEL_IDS } from '../../packages/shared/src/constants' export function runMigrations(db: Database.Database) { db.exec(` @@ -717,6 +718,16 @@ export function runMigrations(db: Database.Database) { })() } + if (currentVersion < 60) { + db.transaction(() => { + // Same refresh for Agent Station rows: the station's model picker itself + // offered superseded Claude IDs in earlier releases, so those values are + // DAEMON-originated — not user inventions — and safe to remap. + refreshSupersededStationModels(db) + db.prepare('INSERT INTO _migrations (version) VALUES (?)').run(60) + })() + } + // Ensure Solana agent exists (idempotent — handles existing DBs before it was seeded) try { const hasSolanaAgent = db.prepare("SELECT id FROM agents WHERE id = 'solana-agent'").get() @@ -912,8 +923,19 @@ Output: bullet points with inline citations. Be direct. No fluff.`, * choice and left alone. */ export const SUPERSEDED_AGENT_MODELS: Record = { - 'claude-sonnet-4-20250514': 'claude-sonnet-4-6', - 'claude-opus-4-20250514': 'claude-opus-4-8', + 'claude-sonnet-4-20250514': CLAUDE_MODEL_IDS.sonnet, + 'claude-opus-4-20250514': CLAUDE_MODEL_IDS.opus, +} + +/** + * Model IDs the Agent Station picker offered in past releases that are no + * longer in the sanctioned set. Station rows only ever hold picker values, so + * every entry here is DAEMON-originated. Non-Claude models (gpt-*) pass + * through untouched. + */ +export const SUPERSEDED_STATION_MODELS: Record = { + 'claude-opus-4-20250514': CLAUDE_MODEL_IDS.opus, + 'claude-sonnet-4-5': CLAUDE_MODEL_IDS.sonnet, } /** Rewrite agent rows whose model is a known-superseded seed ID (exact match only). */ @@ -924,6 +946,14 @@ export function refreshSupersededAgentModels(db: Database.Database): void { } } +/** Rewrite Agent Station rows whose model is a superseded picker value (exact match only). */ +export function refreshSupersededStationModels(db: Database.Database): void { + const update = db.prepare('UPDATE agent_station_configs SET model = ? WHERE model = ?') + for (const [staleId, currentId] of Object.entries(SUPERSEDED_STATION_MODELS)) { + update.run(currentId, staleId) + } +} + function seedDefaults(db: Database.Database) { const agentCount = (db.prepare('SELECT COUNT(*) as c FROM agents').get() as { c: number }).c if (agentCount > 0) return diff --git a/test/services/AgentModelMigration.test.ts b/test/services/AgentModelMigration.test.ts index 5040fa9e..dbcf514f 100644 --- a/test/services/AgentModelMigration.test.ts +++ b/test/services/AgentModelMigration.test.ts @@ -1,26 +1,36 @@ import { describe, it, expect } from 'vitest' import type Database from 'better-sqlite3' -import { SUPERSEDED_AGENT_MODELS, refreshSupersededAgentModels } from '../../electron/db/migrations' +import { + SUPERSEDED_AGENT_MODELS, + SUPERSEDED_STATION_MODELS, + refreshSupersededAgentModels, + refreshSupersededStationModels, +} from '../../electron/db/migrations' -// Minimal in-memory stand-in for the better-sqlite3 surface the refresh uses. +// Minimal in-memory stand-in for the better-sqlite3 surface the refreshes use. // Avoids loading the Electron-ABI native module under vitest's plain-Node runtime. interface AgentRow { id: string; model: string } class FakeDb { agents: AgentRow[] + stations: AgentRow[] - constructor(rows: AgentRow[]) { + constructor(rows: AgentRow[], stations: AgentRow[] = []) { this.agents = rows.map((r) => ({ ...r })) + this.stations = stations.map((r) => ({ ...r })) } prepare(sql: string) { - if (sql.trim() !== 'UPDATE agents SET model = ? WHERE model = ?') { - throw new Error(`unexpected sql: ${sql}`) - } + const table = sql.trim() === 'UPDATE agents SET model = ? WHERE model = ?' + ? this.agents + : sql.trim() === 'UPDATE agent_station_configs SET model = ? WHERE model = ?' + ? this.stations + : null + if (!table) throw new Error(`unexpected sql: ${sql}`) return { run: (nextModel: string, prevModel: string) => { let changes = 0 - for (const row of this.agents) { + for (const row of table) { if (row.model === prevModel) { row.model = nextModel changes++ @@ -96,3 +106,47 @@ describe('refreshSupersededAgentModels (V59 upgrade migration)', () => { } }) }) + +describe('refreshSupersededStationModels (V60 upgrade migration)', () => { + it('rewrites superseded picker values to the current aliases', () => { + const db = new FakeDb([], [ + { id: 'station-1', model: 'claude-opus-4-20250514' }, + { id: 'station-2', model: 'claude-sonnet-4-5' }, + ]) + refreshSupersededStationModels(asDb(db)) + expect(db.stations.map((s) => s.model)).toEqual(['claude-opus-4-8', 'claude-sonnet-4-6']) + }) + + it('leaves non-Claude and current-alias station rows untouched', () => { + const db = new FakeDb([], [ + { id: 'station-1', model: 'gpt-4o' }, + { id: 'station-2', model: 'claude-opus-4-8' }, + { id: 'station-3', model: 'claude-sonnet-4-6' }, + ]) + refreshSupersededStationModels(asDb(db)) + expect(db.stations.map((s) => s.model)).toEqual(['gpt-4o', 'claude-opus-4-8', 'claude-sonnet-4-6']) + }) + + it('never touches agent rows and stays idempotent', () => { + const db = new FakeDb( + [{ id: 'daemon-debug', model: 'claude-sonnet-4-20250514' }], + [{ id: 'station-1', model: 'claude-sonnet-4-5' }], + ) + refreshSupersededStationModels(asDb(db)) + expect(db.agents[0].model).toBe('claude-sonnet-4-20250514') + const afterFirst = db.stations.map((s) => ({ ...s })) + refreshSupersededStationModels(asDb(db)) + expect(db.stations).toEqual(afterFirst) + }) + + it('station map targets are exact current aliases', () => { + expect(SUPERSEDED_STATION_MODELS).toEqual({ + 'claude-opus-4-20250514': 'claude-opus-4-8', + 'claude-sonnet-4-5': 'claude-sonnet-4-6', + }) + for (const [stale, current] of Object.entries(SUPERSEDED_STATION_MODELS)) { + expect(stale).not.toBe(current) + expect(current).not.toMatch(/-20\d{6}$/) + } + }) +}) From 924348dcf8f05e292eb604c63ea26dd7bc96be38 Mon Sep 17 00:00:00 2001 From: nullxnothing Date: Fri, 3 Jul 2026 17:29:22 -0600 Subject: [PATCH 05/26] fix(aria): render auth degradation as a transcript banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the rejected-key notice: instead of prepending the warning to the reply text, emit a dedicated 'notice' transcript event rendered as an amber-lined banner (5px dot language) above the turn, persist it in message metadata so it survives history reloads, and log only the key SOURCE (stored vs shell env) — key values never reach logs or the transcript. --- electron/services/AriaAgentService.ts | 37 +++++++++++++------ electron/shared/types.ts | 2 + src/panels/AgentWorkbench/AgentTranscript.tsx | 7 ++++ src/panels/AgentWorkbench/AgentWorkbench.css | 23 ++++++++++++ src/store/aria.ts | 11 +++++- 5 files changed, 67 insertions(+), 13 deletions(-) diff --git a/electron/services/AriaAgentService.ts b/electron/services/AriaAgentService.ts index 3993208b..9d9283e0 100644 --- a/electron/services/AriaAgentService.ts +++ b/electron/services/AriaAgentService.ts @@ -304,6 +304,7 @@ export async function sendMessage( const endpoint = backend === 'glm' ? getGlmEndpoint() ?? undefined : undefined let finalText = '' + let turnNotice: string | null = null const deadline = Date.now() + AGENT_DEADLINE_MS let promptForUsage = userMessage let retriedStaleNoToolResponse = false @@ -366,14 +367,22 @@ export async function sendMessage( // inherited from the shell) must not silently downgrade the operator to // the tool-less CLI path — name the failing key source and how to fix it. // GLM turns (endpoint set) fail on the ZAI key, not the Anthropic one. - const authNotice = !endpoint && isAnthropicAuthError(err) - ? describeClaudeAuthFailure(getClaudeKeySource()) - : null + const failedKeySource = !endpoint && isAnthropicAuthError(err) ? getClaudeKeySource() : null + const authNotice = failedKeySource !== null ? describeClaudeAuthFailure(failedKeySource) : null + if (authNotice) { + // Surface as a warning banner, separate from the reply text. Log the + // key SOURCE only — key values never reach logs or the transcript. + console.warn(`[AriaAgent] Anthropic auth rejected (key source: ${failedKeySource}); operator tools are offline for this turn.`) + transport.emit({ kind: 'notice', messageId: sessionId, level: 'warn', text: authNotice }) + } const fallback = ProviderRegistry.getFeatureProvider('aria') if (fallback.id === 'claude' && await verifyPreferredProvider('claude')) { return legacyAnswer(sessionId, userMessage, fallback, text, transport, authNotice ?? undefined) } - finalText = authNotice ?? `Error: ${(err as Error).message}` + turnNotice = authNotice + finalText = authNotice + ? 'Anthropic API authentication failed, so this turn could not run any operator tools.' + : `Error: ${(err as Error).message}` } // Any plan steps left un-flipped are done once the loop settles. @@ -403,6 +412,7 @@ export async function sendMessage( toolCalls, ...(turnState.plan.length ? { plan: turnState.plan } : {}), ...(turnState.patch ? { patch: turnState.patch } : {}), + ...(turnNotice ? { notice: turnNotice } : {}), }), session_id: sessionId, }) @@ -720,8 +730,9 @@ function describeIntent(tool: AriaTool, input: Record): string } /** Non-Claude providers: single-shot text answer, no tools. When the caller is - * degrading here because of an auth failure, `notice` is prepended to the - * reply so the loss of operator tools is never silent. */ + * degrading here because of an auth failure, it emits a `notice` banner first + * and passes the text along so the condition also survives a history reload + * (via message metadata) — the reply itself stays clean. */ async function legacyAnswer( sessionId: string, userMessage: string, @@ -737,11 +748,15 @@ async function legacyAnswer( 'Respond as ARIA, concise and direct.', ].join('\n') const answer = await provider.runPrompt({ prompt, model: 'sonnet', effort: 'low', maxTokens: 1024, timeoutMs: 60_000 }) - const out = notice ? `${notice}\n\n${answer}` : answer - text.push({ role: 'assistant', content: out }) - persistMessage({ role: 'assistant', content: out, metadata: '{}', session_id: sessionId }) - transport.emit({ kind: 'done', messageId: sessionId, text: out }) - return { text: out, actions: [], toolCalls: [] } + text.push({ role: 'assistant', content: answer }) + persistMessage({ + role: 'assistant', + content: answer, + metadata: notice ? JSON.stringify({ notice }) : '{}', + session_id: sessionId, + }) + transport.emit({ kind: 'done', messageId: sessionId, text: answer }) + return { text: answer, actions: [], toolCalls: [] } } export function getHistory(sessionId: string, limit = 50): AriaMessage[] { diff --git a/electron/shared/types.ts b/electron/shared/types.ts index 4e8f0672..2c98913f 100644 --- a/electron/shared/types.ts +++ b/electron/shared/types.ts @@ -2915,6 +2915,8 @@ export type AriaToolEvent = | { kind: 'action-result'; proposalId: string; action: AriaPatchAction; status: 'applied' | 'rejected' | 'failed'; meta?: string } | { kind: 'memory-suggestion'; messageId: string; suggestion: AriaMemorySuggestionLite } | { kind: 'memory-recall'; messageId: string; recalled: AriaMemorySuggestionLite[] } + /** Out-of-band condition the user must see (e.g. auth degradation) — rendered as a banner, never merged into the reply text. */ + | { kind: 'notice'; messageId: string; level: 'warn'; text: string } | { kind: 'done'; messageId: string; text: string } /** A memory the operator captured from its own work, pending the user's keep/dismiss. */ diff --git a/src/panels/AgentWorkbench/AgentTranscript.tsx b/src/panels/AgentWorkbench/AgentTranscript.tsx index 6e680f52..eb63df7e 100644 --- a/src/panels/AgentWorkbench/AgentTranscript.tsx +++ b/src/panels/AgentWorkbench/AgentTranscript.tsx @@ -49,6 +49,13 @@ export function AgentTranscript({ turns, isLoading }: { turns: AriaTurn[]; isLoa ))} + {(turn.notices ?? []).map((notice, i) => ( +
+
+ ))} + {turn.text ? ( turn.role === 'assistant' ? diff --git a/src/panels/AgentWorkbench/AgentWorkbench.css b/src/panels/AgentWorkbench/AgentWorkbench.css index 2e0bd5d4..f123508d 100644 --- a/src/panels/AgentWorkbench/AgentWorkbench.css +++ b/src/panels/AgentWorkbench/AgentWorkbench.css @@ -745,6 +745,29 @@ padding: var(--space-xs) 0; } +/* Out-of-band warning banner (e.g. auth degradation) — amber-lined, never part + of the reply markdown. Dot mirrors the 5px status-dot language. */ +.agent-tr-notice { + display: flex; + align-items: flex-start; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + border: 1px solid color-mix(in srgb, var(--amber) 40%, transparent); + background: var(--amber-glow); + color: var(--t1); + font-size: var(--fs-12); + line-height: 1.5; +} +.agent-tr-notice-dot { + position: relative; + top: 6px; + flex: none; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--amber); +} + /* ---------------- approval cards ---------------- */ .agent-approval { display: grid; diff --git a/src/store/aria.ts b/src/store/aria.ts index 036571b8..cfe15f75 100644 --- a/src/store/aria.ts +++ b/src/store/aria.ts @@ -48,6 +48,8 @@ export interface AriaTurn { actionState?: AriaActionState memorySuggestions?: AriaMemorySuggestionLite[] recalledMemories?: AriaMemorySuggestionLite[] + /** Out-of-band warnings for this turn (e.g. auth degradation) rendered as banners. */ + notices?: string[] } const DEFAULT_LANE: DaemonAiModelLane = 'auto' @@ -375,22 +377,24 @@ export const useAriaStore = create((set, get) => ({ let toolCalls: AriaToolCallLive[] = [] let plan: AriaPlanStep[] | undefined let patch: AriaPatchProposalLite | undefined + let notices: string[] | undefined try { const meta = JSON.parse(m.metadata || '{}') as { - toolCalls?: AriaToolCallRecord[]; plan?: AriaPlanStep[]; patch?: AriaPatchProposalLite + toolCalls?: AriaToolCallRecord[]; plan?: AriaPlanStep[]; patch?: AriaPatchProposalLite; notice?: string } toolCalls = (meta.toolCalls ?? []).map((tc) => ({ callId: tc.callId, name: tc.name, label: tc.name, toolKind: tc.toolKind, risk: tc.risk, status: tc.status, meta: tc.summary, })) plan = meta.plan patch = meta.patch + if (typeof meta.notice === 'string' && meta.notice) notices = [meta.notice] } catch { /* ignore malformed metadata */ } const actionState = patch ? patch.status === 'applied' ? 'applied' : patch.status === 'rejected' ? 'rejected' : 'idle' : undefined return { id: m.id, role: m.role as 'user' | 'assistant', text: m.content, createdAt: m.created_at, - toolCalls, approvals: [], plan, patch, actionState, + toolCalls, approvals: [], plan, patch, actionState, notices, } }) set({ turns }) @@ -464,6 +468,9 @@ function applyEvent( case 'memory-recall': patchActive(set, (t) => ({ ...t, recalledMemories: ev.recalled }), sid) break + case 'notice': + patchActive(set, (t) => ({ ...t, notices: [...(t.notices ?? []), ev.text] }), sid) + break case 'action-result': set((s) => ({ turns: s.turns.map((t) => From 54a4671a9ee1ae59358d51bce0b47e5be3589955 Mon Sep 17 00:00:00 2001 From: nullxnothing Date: Fri, 3 Jul 2026 17:32:08 -0600 Subject: [PATCH 06/26] test: real-sql migration chain + aria auth degradation coverage MigrationRunner drives the FULL runMigrations chain against node:sqlite (real SQL engine, no Electron-ABI binding needed): fresh installs seed only sanctioned model IDs, a simulated pre-V59 install gets its agent and station rows remapped, re-running is a no-op, and every refresh-map target is proven to be a canonical CLAUDE_MODEL_IDS value. AriaAuthNotice exercises sendMessage end to end with the real claudeAuth classifier: warn banner names the failing key source, reply text stays clean, metadata survives reloads, GLM turns and network errors never blame the Anthropic key. --- test/services/AriaAuthNotice.test.ts | 190 ++++++++++++++++++++++++++ test/services/MigrationRunner.test.ts | 138 +++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 test/services/AriaAuthNotice.test.ts create mode 100644 test/services/MigrationRunner.test.ts diff --git a/test/services/AriaAuthNotice.test.ts b/test/services/AriaAuthNotice.test.ts new file mode 100644 index 00000000..a437e9f6 --- /dev/null +++ b/test/services/AriaAuthNotice.test.ts @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AriaTransport } from '../../electron/services/AriaAgentService' + +// Mock the operator loop's heavy import chain. providers/claudeAuth stays REAL: +// these tests exercise the actual auth-error classification and user-facing copy. +const insertedMessages: Array<{ role: string; content: string; metadata: string }> = [] +vi.mock('../../electron/db/db', () => ({ + getDb: () => ({ + prepare: (sql: string) => ({ + run: (...params: unknown[]) => { + if (sql.startsWith('INSERT INTO aria_messages')) { + insertedMessages.push({ + role: String(params[1]), + content: String(params[2]), + metadata: String(params[3]), + }) + } + return { changes: 1 } + }, + get: () => undefined, + all: () => [], + }), + }), +})) + +const runClaudeAgentTurn = vi.fn() +const getClaudeKeySource = vi.fn(() => 'env') +vi.mock('../../electron/services/providers/ClaudeProvider', () => ({ + runClaudeAgentTurn: (...args: unknown[]) => runClaudeAgentTurn(...args), + getClaudeKeySource: () => getClaudeKeySource(), +})) + +const getFeatureProvider = vi.fn() +vi.mock('../../electron/services/providers/ProviderRegistry', () => ({ + get: vi.fn(() => ({ getConnection: () => ({ isAuthenticated: true, authMode: 'cli' }) })), + getFeatureProvider: (...args: unknown[]) => getFeatureProvider(...args), + getPreferences: vi.fn(() => ({ aria: { provider: 'claude', model: 'standard' } })), +})) + +const resolveOperatorBackend = vi.fn(() => 'claude') +const getGlmEndpoint = vi.fn(() => null) +vi.mock('../../electron/services/providers/glmConfig', () => ({ + resolveOperatorBackend: () => resolveOperatorBackend(), + getGlmEndpoint: () => getGlmEndpoint(), +})) + +vi.mock('../../electron/services/DaemonAIService', () => ({ recordLocalAiUsage: vi.fn() })) +vi.mock('../../electron/services/MemoryService', () => ({ createSuggestion: vi.fn() })) +vi.mock('../../electron/services/aria/contextAssembler', () => ({ + assembleSystemPrompt: vi.fn(async () => ({ system: 'sys', recalled: [] })), +})) +vi.mock('../../electron/services/aria/patchUtils', () => ({ + laneToClaudeModel: vi.fn(() => 'sonnet'), + buildPlanSteps: vi.fn(() => []), + buildPatchProposal: vi.fn(), +})) +vi.mock('../../electron/services/aria/tools/shared', () => ({ + clusterMark: (summary: string) => summary, +})) +vi.mock('../../electron/services/aria/toolCatalog', () => ({ + ARIA_TOOLS: [], + getTool: () => undefined, +})) +vi.mock('../../electron/services/aria/AriaTool', () => ({ + toAnthropicTools: () => [], +})) + +import { sendMessage } from '../../electron/services/AriaAgentService' + +const snapshot = { + activeProjectId: null, + activeProjectPath: null, + currentPanelId: null, + openFilePath: null, + chips: { activeFile: false, projectTree: false, gitDiff: false, terminalLogs: false, walletContext: false }, +} as never + +// Long enough (and question-shaped) to never hit the direct-tool fast path. +const QUESTION = 'Can you walk me through what the swap orchestrator does on a failed quote?' + +function makeTransport() { + return { + emit: vi.fn(), + requestApproval: vi.fn(async () => true), + requestPatchDecision: vi.fn(async () => 'discard' as const), + runUiEffect: vi.fn(async () => undefined), + } satisfies AriaTransport +} + +function noticeEvents(transport: ReturnType) { + return transport.emit.mock.calls + .map(([ev]) => ev as { kind: string; level?: string; text?: string; messageId?: string }) + .filter((ev) => ev.kind === 'notice') +} + +const authError = () => Object.assign(new Error('401 {"type":"authentication_error"}'), { status: 401 }) + +beforeEach(() => { + vi.clearAllMocks() + insertedMessages.length = 0 + resolveOperatorBackend.mockReturnValue('claude') + getGlmEndpoint.mockReturnValue(null) + getClaudeKeySource.mockReturnValue('env') +}) + +describe('sendMessage — Anthropic auth degradation is surfaced, never silent', () => { + it('emits a warn banner naming the shell env key, then answers via CLI without merging the notice into the reply', async () => { + runClaudeAgentTurn.mockRejectedValue(authError()) + getFeatureProvider.mockReturnValue({ + id: 'claude', + getConnection: () => ({ isAuthenticated: true, authMode: 'cli' }), + runPrompt: vi.fn(async () => 'plain cli answer'), + }) + + const transport = makeTransport() + const res = await sendMessage('sess-1', QUESTION, snapshot, transport) + + const notices = noticeEvents(transport) + expect(notices).toHaveLength(1) + expect(notices[0].level).toBe('warn') + expect(notices[0].messageId).toBe('sess-1') + expect(notices[0].text).toContain('shell environment') + expect(notices[0].text).toContain('ANTHROPIC_API_KEY') + // The reply stays clean — the warning lives in the banner, not the answer. + expect(res.text).toBe('plain cli answer') + // The condition survives a history reload via message metadata. + const assistant = insertedMessages.find((m) => m.role === 'assistant') + expect(assistant).toBeDefined() + expect(JSON.parse(assistant!.metadata).notice).toContain('shell environment') + }) + + it('names the stored key when that credential was in play', async () => { + getClaudeKeySource.mockReturnValue('stored') + runClaudeAgentTurn.mockRejectedValue(authError()) + getFeatureProvider.mockReturnValue({ + id: 'claude', + getConnection: () => ({ isAuthenticated: true, authMode: 'cli' }), + runPrompt: vi.fn(async () => 'answer'), + }) + + const transport = makeTransport() + await sendMessage('sess-2', QUESTION, snapshot, transport) + + const notices = noticeEvents(transport) + expect(notices).toHaveLength(1) + expect(notices[0].text).toContain('saved in Settings') + expect(notices[0].text).not.toContain('shell environment') + }) + + it('still surfaces the banner when no CLI fallback exists and keeps the reply text short', async () => { + runClaudeAgentTurn.mockRejectedValue(authError()) + getFeatureProvider.mockReturnValue({ id: 'codex' }) + + const transport = makeTransport() + const res = await sendMessage('sess-3', QUESTION, snapshot, transport) + + expect(noticeEvents(transport)).toHaveLength(1) + expect(res.text).toContain('authentication failed') + const assistant = insertedMessages.find((m) => m.role === 'assistant') + expect(JSON.parse(assistant!.metadata).notice).toContain('ANTHROPIC_API_KEY') + }) + + it('does not blame the Anthropic key when a GLM endpoint turn fails auth', async () => { + resolveOperatorBackend.mockReturnValue('glm') + getGlmEndpoint.mockReturnValue({ apiKey: 'z', baseURL: 'https://api.z.ai/api/anthropic', model: 'glm-4.7' } as never) + runClaudeAgentTurn.mockRejectedValue(authError()) + getFeatureProvider.mockReturnValue({ id: 'codex' }) + + const transport = makeTransport() + const res = await sendMessage('sess-4', QUESTION, snapshot, transport) + + expect(noticeEvents(transport)).toHaveLength(0) + expect(res.text).toContain('Error:') + }) + + it('does not classify network failures as auth degradation', async () => { + runClaudeAgentTurn.mockRejectedValue(new Error('ECONNRESET')) + getFeatureProvider.mockReturnValue({ + id: 'claude', + getConnection: () => ({ isAuthenticated: true, authMode: 'cli' }), + runPrompt: vi.fn(async () => 'cli answer'), + }) + + const transport = makeTransport() + const res = await sendMessage('sess-5', QUESTION, snapshot, transport) + + expect(noticeEvents(transport)).toHaveLength(0) + expect(res.text).toBe('cli answer') + }) +}) diff --git a/test/services/MigrationRunner.test.ts b/test/services/MigrationRunner.test.ts new file mode 100644 index 00000000..660cc963 --- /dev/null +++ b/test/services/MigrationRunner.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { DatabaseSync } from 'node:sqlite' +import type Database from 'better-sqlite3' +import { + runMigrations, + SUPERSEDED_AGENT_MODELS, + SUPERSEDED_STATION_MODELS, +} from '../../electron/db/migrations' +import { CLAUDE_MODEL_IDS } from '../../packages/shared/src/constants' + +/** + * Real-SQL migration harness. node:sqlite ships with the test runtime and + * exposes the same prepare/run/get/all surface runMigrations uses, so the + * full migration chain executes against a real SQLite engine without the + * Electron-ABI better-sqlite3 binding that vitest's plain-Node runtime + * cannot load. + */ +function makeDb(): Database.Database { + const raw = new DatabaseSync(':memory:') + const adapter = { + exec: (sql: string) => raw.exec(sql), + prepare: (sql: string) => { + const stmt = raw.prepare(sql) + return { + run: (...params: unknown[]) => stmt.run(...(params as never[])), + get: (...params: unknown[]) => stmt.get(...(params as never[])), + all: (...params: unknown[]) => stmt.all(...(params as never[])), + } + }, + transaction: (fn: (...args: unknown[]) => unknown) => + (...args: unknown[]) => { + raw.exec('BEGIN') + try { + const out = fn(...args) + raw.exec('COMMIT') + return out + } catch (err) { + raw.exec('ROLLBACK') + throw err + } + }, + } + return adapter as unknown as Database.Database +} + +function agentModels(db: Database.Database): Array<{ id: string; model: string }> { + return db.prepare('SELECT id, model FROM agents ORDER BY id').all() as Array<{ id: string; model: string }> +} + +function maxVersion(db: Database.Database): number { + return (db.prepare('SELECT MAX(version) v FROM _migrations').get() as { v: number }).v +} + +describe('runMigrations — fresh install', () => { + it('reaches the current schema version and seeds only sanctioned Claude model IDs', () => { + const db = makeDb() + runMigrations(db) + + expect(maxVersion(db)).toBeGreaterThanOrEqual(60) + + const sanctioned = new Set(Object.values(CLAUDE_MODEL_IDS)) + const rows = agentModels(db) + expect(rows.length).toBeGreaterThan(0) + for (const row of rows) { + expect(sanctioned.has(row.model), `${row.id} seeded with ${row.model}`).toBe(true) + expect(row.model in SUPERSEDED_AGENT_MODELS).toBe(false) + } + }) +}) + +describe('runMigrations — upgraded install', () => { + /** Build a DB that looks like a pre-V59 install carrying stale model rows. */ + function makeStaleDb(): Database.Database { + const db = makeDb() + runMigrations(db) + // Rewind the model-refresh migrations, then reintroduce the stale IDs the + // old releases seeded / offered in pickers. + db.prepare('DELETE FROM _migrations WHERE version >= 59').run() + db.prepare('UPDATE agents SET model = ? WHERE id = ?').run('claude-sonnet-4-20250514', 'daemon-debug') + db.prepare('UPDATE agents SET model = ? WHERE id = ?').run('claude-opus-4-20250514', 'solana-agent') + const insertStation = db.prepare( + 'INSERT INTO agent_station_configs (id, name, template, plugins, model, status) VALUES (?,?,?,?,?,?)' + ) + insertStation.run('st-sonnet', 'Old Sonnet Station', 'basic', '[]', 'claude-sonnet-4-5', 'idle') + insertStation.run('st-opus', 'Old Opus Station', 'basic', '[]', 'claude-opus-4-20250514', 'idle') + insertStation.run('st-gpt', 'GPT Station', 'basic', '[]', 'gpt-4o', 'idle') + return db + } + + it('remaps stale seeded agent models to the current aliases (V59)', () => { + const db = makeStaleDb() + runMigrations(db) + + const rows = Object.fromEntries(agentModels(db).map((r) => [r.id, r.model])) + expect(rows['daemon-debug']).toBe(CLAUDE_MODEL_IDS.sonnet) + expect(rows['solana-agent']).toBe(CLAUDE_MODEL_IDS.opus) + expect(maxVersion(db)).toBeGreaterThanOrEqual(60) + }) + + it('remaps stale Agent Station picker models but never non-Claude rows (V60)', () => { + const db = makeStaleDb() + runMigrations(db) + + const rows = Object.fromEntries( + (db.prepare('SELECT id, model FROM agent_station_configs').all() as Array<{ id: string; model: string }>) + .map((r) => [r.id, r.model]), + ) + expect(rows['st-sonnet']).toBe(CLAUDE_MODEL_IDS.sonnet) + expect(rows['st-opus']).toBe(CLAUDE_MODEL_IDS.opus) + expect(rows['st-gpt']).toBe('gpt-4o') + }) + + it('is idempotent — re-running the full chain changes nothing', () => { + const db = makeStaleDb() + runMigrations(db) + const afterFirst = agentModels(db) + runMigrations(db) + expect(agentModels(db)).toEqual(afterFirst) + expect(maxVersion(db)).toBeGreaterThanOrEqual(60) + }) +}) + +describe('model refresh maps stay inside the sanctioned set', () => { + it('every refresh target is a canonical CLAUDE_MODEL_IDS value', () => { + const sanctioned = new Set(Object.values(CLAUDE_MODEL_IDS)) + for (const target of Object.values({ ...SUPERSEDED_AGENT_MODELS, ...SUPERSEDED_STATION_MODELS })) { + expect(sanctioned.has(target), `${target} is not a sanctioned model ID`).toBe(true) + } + }) + + it('no stale key survives as a value and no key maps to itself', () => { + const merged = { ...SUPERSEDED_AGENT_MODELS, ...SUPERSEDED_STATION_MODELS } + for (const [stale, current] of Object.entries(merged)) { + expect(stale).not.toBe(current) + expect(current in merged).toBe(false) + } + }) +}) From 3a5e774f59110833848a5ed62d1a3c1dd3fb3b19 Mon Sep 17 00:00:00 2001 From: nullxnothing Date: Mon, 6 Jul 2026 14:42:38 -0600 Subject: [PATCH 07/26] feat(games): scaffold, swarm-build, preview, and deploy a Solana game from ARIA Adds an end-to-end game-build flow: a phaser-solana-game project template, ARIA tools to orchestrate it, and the connective tissue to run and merge the result. - ProjectStarter: new self-contained Phaser + TS game template (Starfall ported into gameTemplateFiles.ts) with git-init on scaffold so a fresh project can host a swarm, plus auto dev-server + in-app preview (generalized from the meme path). - WorktreeService.mergeLane: commit a finished lane's work and merge --no-ff into the base branch; done-only guard. Re-exported from SwarmOrchestrator. - ARIA gameStudio tools: scaffold_game, run_dev_server, preview_app, swarm_merge_lane, deploy_app. run_dev_server runs only a discovered dev script (CheckRunnerService.discoverDevScript), never a raw command. - uiEffects open_preview / start_dev_server / open_scaffold, with a scaffoldPreset handoff so scaffold_game preloads the wizard. - ARIA system prompt gains a build-a-game playbook (two-message choreography: launch the swarm and return, then merge/preview/deploy). Test: ProjectStarter.runtime asserts the game template's structure; game template gets an .env.example to keep the universal-files invariant. Gate loop green (typecheck + 1064 tests + build). Co-Authored-By: Claude Opus 4.8 (1M context) --- electron/services/CheckRunnerService.ts | 27 +- electron/services/SwarmOrchestrator.ts | 5 + electron/services/WorktreeService.ts | 52 +++ electron/services/aria/contextAssembler.ts | 7 + electron/services/aria/toolCatalog.ts | 2 + electron/services/aria/tools/gameStudio.ts | 170 ++++++++ electron/shared/types.ts | 3 + src/lib/ariaUiEffects.ts | 43 ++ src/panels/ProjectStarter/ProjectStarter.tsx | 330 ++++++++++++++- .../ProjectStarter/gameTemplateFiles.ts | 400 ++++++++++++++++++ src/store/ui.ts | 5 + test/panels/ProjectStarter.runtime.test.ts | 9 + 12 files changed, 1037 insertions(+), 16 deletions(-) create mode 100644 electron/services/aria/tools/gameStudio.ts create mode 100644 src/panels/ProjectStarter/gameTemplateFiles.ts diff --git a/electron/services/CheckRunnerService.ts b/electron/services/CheckRunnerService.ts index aa614dd0..91805231 100644 --- a/electron/services/CheckRunnerService.ts +++ b/electron/services/CheckRunnerService.ts @@ -21,13 +21,38 @@ const SAFE_SCRIPT_CHECKS: Array<{ name: string; kind: CheckKind }> = [ const DEPLOY_RE = /\b(deploy|publish|release|push|--dangerously|program deploy|anchor deploy)\b/i -function detectManager(projectPath: string): string { +export function detectManager(projectPath: string): string { if (fs.existsSync(path.join(projectPath, 'pnpm-lock.yaml'))) return 'pnpm' if (fs.existsSync(path.join(projectPath, 'yarn.lock'))) return 'yarn' if (fs.existsSync(path.join(projectPath, 'bun.lockb'))) return 'bun' return 'npm' } +/** + * Discover a dev-server script (dev/start/serve) from package.json, honoring the + * same deploy/publish exclusion as checks. Returns the package-manager command + * (e.g. "npm run dev") or null. NOT arbitrary exec: only a named package script. + */ +export function discoverDevScript(projectPath: string): { command: string; script: string } | null { + const pkgPath = path.join(projectPath, 'package.json') + if (!fs.existsSync(pkgPath)) return null + let scripts: Record = {} + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { scripts?: Record } + scripts = pkg.scripts ?? {} + } catch { + return null + } + const manager = detectManager(projectPath) + for (const name of ['dev', 'start', 'serve']) { + const body = scripts[name] + if (typeof body !== 'string') continue + if (DEPLOY_RE.test(body) || DEPLOY_RE.test(name)) continue + return { command: `${manager} run ${name}`, script: name } + } + return null +} + /** * Pure discovery: read package.json scripts (and framework files) and return runnable * check definitions. Excludes any script whose body looks like a deploy/publish action. diff --git a/electron/services/SwarmOrchestrator.ts b/electron/services/SwarmOrchestrator.ts index 3e506644..714f941f 100644 --- a/electron/services/SwarmOrchestrator.ts +++ b/electron/services/SwarmOrchestrator.ts @@ -390,6 +390,11 @@ export function collectLaneResults(laneId: string): string | null { try { return fs.readFileSync(lane.results_path, 'utf8') } catch { return null } } +/** Merge a finished lane's branch into the main repo. See WorktreeService.mergeLane. */ +export async function mergeLane(laneId: string): Promise { + return Worktree.mergeLane(laneId) +} + /** * Cancel/dismiss a whole run: kill any live lane processes and ALWAYS tear down * every worktree + branch (RESULTS are already snapshotted to the cache on exit, diff --git a/electron/services/WorktreeService.ts b/electron/services/WorktreeService.ts index 612b9b53..03fa54e2 100644 --- a/electron/services/WorktreeService.ts +++ b/electron/services/WorktreeService.ts @@ -196,6 +196,58 @@ export async function removeWorktree(projectPath: string, worktreePath: string, } catch { /* best-effort */ } } +export interface MergeLaneResult { + ok: boolean + branch: string + /** short SHA merged into base, when ok */ + mergedSha?: string + /** the base branch the lane was merged into */ + baseBranch?: string + error?: string +} + +/** + * Merge a finished lane's work back into the main repo. Lanes are told NOT to + * commit (so RESULTS/BUILD_NOTES stay reviewable in the worktree), so this first + * commits any uncommitted lane changes onto the lane branch, then merges that + * branch (--no-ff) into the base branch in the MAIN working copy. Read-only for + * the lane worktree beyond the single commit; the human/tool decides when to run + * it. Guarded to `done` lanes only. + */ +export async function mergeLane(laneId: string): Promise { + const lane = getLane(laneId) + if (!lane) return { ok: false, branch: '', error: 'Lane not found.' } + if (lane.status !== 'done') { + return { ok: false, branch: lane.branch, error: `Lane is ${lane.status}, only 'done' lanes can be merged.` } + } + const run = getRun(lane.run_id) + if (!run) return { ok: false, branch: lane.branch, error: 'Run not found.' } + + const laneGit = simpleGit(lane.worktree_path) + const mainGit = simpleGit(run.project_path) + try { + // 1) Commit any uncommitted lane work onto the lane branch (lanes don't self-commit). + const status = await laneGit.status() + if (status.files.length > 0) { + await laneGit.add(['-A']) + await laneGit.raw(['-c', 'user.email=daemon@local', '-c', 'user.name=DAEMON', 'commit', '-m', `swarm: ${lane.task.slice(0, 72)}`]) + } + // 2) Resolve the base branch to merge into (explicit base, else current HEAD of main). + const baseBranch = run.base_branch?.trim() + || (await mainGit.revparse(['--abbrev-ref', 'HEAD'])).trim() + // 3) Merge the lane branch into base in the MAIN repo. --no-ff keeps the lane visible. + await mainGit.raw(['checkout', baseBranch]) + await mainGit.raw(['merge', '--no-ff', lane.branch, '-m', `Merge swarm lane ${lane.branch}`]) + const mergedSha = (await mainGit.revparse(['--short', 'HEAD'])).trim() + return { ok: true, branch: lane.branch, mergedSha, baseBranch } + } catch (err) { + // Leave the repo as-is (a conflicted merge stays for the human to resolve in the Git panel). + const message = err instanceof Error ? err.message : String(err) + LogService.warn('Swarm', `Merge failed for lane ${laneId}`, { branch: lane.branch, error: message }) + return { ok: false, branch: lane.branch, error: message } + } +} + /** * Reconcile-on-boot: any lane already terminal but whose worktree may linger * gets its worktree removed. Also marks orphaned non-terminal lanes (a crash diff --git a/electron/services/aria/contextAssembler.ts b/electron/services/aria/contextAssembler.ts index 9efd817a..d5cc1547 100644 --- a/electron/services/aria/contextAssembler.ts +++ b/electron/services/aria/contextAssembler.ts @@ -20,8 +20,15 @@ CAPABILITIES (call the matching tool — do not just explain): - Token launches: tokenlaunch_list_launchpads, tokenlaunch_preflight, tokenlaunch_create. - Flywheel: preview/configure a fee split, run the flywheel (flywheel_*). - Git: stage + commit in the active project (git_commit). You never push. +- Game studio: scaffold a playable Solana game (scaffold_game), run its dev server (run_dev_server), preview it in-app (preview_app), merge a finished swarm lane (swarm_merge_lane), deploy the pre-wired project (deploy_app). +- Swarms: run tasks as parallel worktree-isolated Claude agents (swarm_launch), monitor them (swarm_status), read their results (swarm_collect). - Memory: remember durable project facts (remember_fact), list what you know (recall_memories), correct or forget them (update_memory / forget_memory). Never store secrets. +BUILD-A-GAME FLOW (when the user asks you to build/make a game): +- Message 1: present_plan, then scaffold_game with a short project name, then swarm_launch with ONE task describing the game (the lane authors it from the template). Then STOP and tell the user the lane is building — do not wait in-loop; the swarm runs in the background past this turn. +- Message 2 (after the user says it's done, or on the next turn): swarm_status to confirm the lane is "done", swarm_merge_lane on that lane, run_dev_server, then preview_app so the user can play it. Offer deploy_app last. +- The game code is written by the swarm lane, not by you. Do not scaffold_file the game yourself. + RULES: - When the user tells you to remember something, or a stable project convention is established (package manager, a constraint, a fix that should not be repeated), call remember_fact. If unsure whether a fact is already known, recall_memories first. Never remember secrets — keys, seed phrases, credentials. - Be concise and direct. No filler, no emoji. diff --git a/electron/services/aria/toolCatalog.ts b/electron/services/aria/toolCatalog.ts index 0ed876cc..f820079c 100644 --- a/electron/services/aria/toolCatalog.ts +++ b/electron/services/aria/toolCatalog.ts @@ -35,6 +35,7 @@ import { gitTools } from './tools/git' import { swarmTools } from './tools/swarm' import { memoryTools } from './tools/memory' import { autopilotTools } from './tools/autopilot' +import { gameStudioTools } from './tools/gameStudio' /** Planning + patch tools — intercepted in AriaAgentService.executeTool. */ const planningTools: AriaTool[] = [ @@ -95,6 +96,7 @@ export const ARIA_TOOLS: AriaTool[] = [ ...swarmTools, ...memoryTools, ...autopilotTools, + ...gameStudioTools, ] export function getTool(name: string): AriaTool | undefined { diff --git a/electron/services/aria/tools/gameStudio.ts b/electron/services/aria/tools/gameStudio.ts new file mode 100644 index 00000000..9949a2f0 --- /dev/null +++ b/electron/services/aria/tools/gameStudio.ts @@ -0,0 +1,170 @@ +/** + * Game Studio tools — the connective tissue for the "build me a Solana game" + * live flow. ARIA orchestrates: scaffold a game project, launch a swarm lane to + * author the game (via the existing swarm_launch), merge the finished lane, run + * the dev server, preview it in-app, and trigger a pre-wired deploy. + * + * Design boundary (matches toolCatalog.ts): NO raw shell. run_dev_server runs + * only a dev/start script DISCOVERED from package.json (CheckRunnerService), and + * arbitrary code generation stays inside sandboxed swarm lanes. Terminal + port + * side effects go through the renderer via uiEffects, mirroring ProjectStarter. + */ +import * as SwarmOrchestrator from '../../SwarmOrchestrator' +import * as PortService from '../../PortService' +import * as DeployService from '../../DeployService' +import { discoverDevScript } from '../../CheckRunnerService' +import { isPathSafe } from '../../../shared/pathValidation' +import type { AriaTool } from '../AriaTool' + +const GAME_TEMPLATE_ID = 'phaser-solana-game' +// Preferred dev ports, same range ProjectStarter uses for the meme/game preview. +const PREFERRED_PORTS = [3000, 3001, 3002, 3003, 3004, 3005, 3006, 3007, 3008, 3009, 3010] + +function pickDevPort(): number { + const taken = new Set(PortService.getRegisteredPorts().map((p) => p.port)) + return PREFERRED_PORTS.find((p) => !taken.has(p)) ?? 3011 +} + +export const gameStudioTools: AriaTool[] = [ + { + name: 'scaffold_game', + description: 'Open the DAEMON project wizard preloaded with the Solana game template (playable Phaser + TypeScript arcade with seedless wallet, cNFT assets, and policy-gated signing pre-wired). Provide a projectName. The scaffold writes the template, runs npm install + an initial git commit (so a swarm can author the game), then serves and previews it. The user confirms the target folder in the wizard.', + kind: 'edit', + risk: 'write', + input: { + type: 'object', + properties: { projectName: { type: 'string' } }, + required: ['projectName'], + }, + async handler(input, ctx) { + const projectName = String(input.projectName ?? '').trim() + if (!projectName) return { ok: false, summary: 'A projectName is required.' } + const effect = { type: 'open_scaffold' as const, templateId: GAME_TEMPLATE_ID, projectName } + await ctx.runUiEffect(effect, false) + return { + ok: true, + summary: `Opened the game scaffold for "${projectName}". Confirm the folder in the wizard to build.`, + uiEffect: effect, + } + }, + }, + { + name: 'run_dev_server', + description: 'Start the active project\'s dev server in a terminal and register its port so it can be previewed. Runs ONLY a dev/start/serve script found in package.json — never an arbitrary command. Returns the local URL. Use preview_app afterward to open it in the embedded browser.', + kind: 'run', + risk: 'write', + async handler(_input, ctx) { + const projectPath = ctx.snapshot.activeProjectPath + if (!projectPath || !isPathSafe(projectPath)) { + return { ok: false, summary: 'Open a registered project before starting a dev server.' } + } + const dev = discoverDevScript(projectPath) + if (!dev) { + return { ok: false, summary: 'No dev/start/serve script found in package.json.' } + } + const port = pickDevPort() + const effect = { + type: 'start_dev_server' as const, + command: dev.command, + port, + projectPath, + label: `Dev: ${dev.script}`, + } + const result = await ctx.runUiEffect(effect, true) as { ok?: boolean; url?: string; error?: string } | null + if (!result?.ok) { + return { ok: false, summary: result?.error ?? 'Failed to start the dev server.' } + } + return { + ok: true, + summary: `Started "${dev.command}" at ${result.url}. Preview it with preview_app.`, + data: { url: result.url, port, script: dev.script }, + } + }, + input: { type: 'object', properties: {} }, + }, + { + name: 'preview_app', + description: 'Open a running localhost app in the embedded DAEMON browser so it can be played/tested. With no port, uses the active project\'s most recently registered dev-server port. Only loopback (127.0.0.1 / localhost) is allowed.', + kind: 'read', + risk: 'read', + input: { type: 'object', properties: { port: { type: 'number' } } }, + async handler(input, ctx) { + let port = typeof input.port === 'number' ? input.port : null + if (!port) { + const projectId = ctx.snapshot.activeProjectId + const registered = PortService.getRegisteredPorts() + const mine = projectId ? registered.filter((p) => p.projectId === projectId) : registered + port = mine.length ? mine[mine.length - 1].port : null + } + if (!port) { + return { ok: false, summary: 'No dev-server port found. Start one with run_dev_server first.' } + } + const url = `http://127.0.0.1:${port}` + const effect = { type: 'open_preview' as const, url } + await ctx.runUiEffect(effect, false) + return { ok: true, summary: `Opened ${url} in the DAEMON browser.`, uiEffect: effect, data: { url } } + }, + }, + { + name: 'swarm_merge_lane', + description: 'Merge a finished swarm lane\'s work into the project\'s base branch. Commits any uncommitted lane changes onto its branch first, then merges (--no-ff) into base in the main repo. Only lanes with status "done" can be merged. On a conflict the merge is left for the Git panel to resolve.', + kind: 'run', + risk: 'write', + input: { type: 'object', properties: { laneId: { type: 'string' } }, required: ['laneId'] }, + async handler(input) { + const laneId = String(input.laneId ?? '').trim() + if (!laneId) return { ok: false, summary: 'A laneId is required.' } + const result = await SwarmOrchestrator.mergeLane(laneId) + if (!result.ok) { + return { ok: false, summary: result.error ?? `Could not merge lane ${laneId}.`, data: result } + } + return { + ok: true, + summary: `Merged ${result.branch} into ${result.baseBranch} (${result.mergedSha}).`, + data: result, + } + }, + }, + { + name: 'deploy_app', + description: 'Deploy the active project via its pre-wired Vercel/Railway link, and report the latest deploy status + URL. This is a live/production action — it pauses for confirmation. Requires the project to be linked in the Deploy panel and pushed to a GitHub remote (redeploy triggers the provider build).', + kind: 'run', + risk: 'sensitive', + input: { type: 'object', properties: {} }, + async handler(_input, ctx) { + const projectId = ctx.snapshot.activeProjectId + if (!projectId) return { ok: false, summary: 'Open a registered project before deploying.' } + const infra = DeployService.getProjectInfra(projectId) + if (!infra.vercel && !infra.railway) { + // Surface the Deploy panel so the user can link a provider, rather than failing silently. + await ctx.runUiEffect({ type: 'open_tool', toolId: 'deploy' }, false) + return { ok: false, summary: 'No Vercel/Railway link yet. Opened the Deploy panel to link a provider first.' } + } + try { + if (infra.vercel) { + const token = DeployService.getToken('vercel') + if (!token) { + await ctx.runUiEffect({ type: 'open_tool', toolId: 'deploy' }, false) + return { ok: false, summary: 'Vercel is linked but not authorized. Opened the Deploy panel to connect.' } + } + const res = await DeployService.triggerVercelRedeploy(token, infra.vercel.projectId, infra.vercel.teamId) + await ctx.runUiEffect({ type: 'open_tool', toolId: 'deploy' }, false) + return { ok: true, summary: `Triggered a Vercel deploy${res.url ? ` — ${res.url}` : ''}.`, data: res } + } + if (infra.railway) { + const token = DeployService.getToken('railway') + if (!token) { + await ctx.runUiEffect({ type: 'open_tool', toolId: 'deploy' }, false) + return { ok: false, summary: 'Railway is linked but not authorized. Opened the Deploy panel to connect.' } + } + const ok = await DeployService.triggerRailwayDeploy(token, infra.railway.serviceId, infra.railway.environmentId) + await ctx.runUiEffect({ type: 'open_tool', toolId: 'deploy' }, false) + return { ok, summary: ok ? 'Triggered a Railway deploy.' : 'Railway deploy request was not accepted.' } + } + return { ok: false, summary: 'No deployable provider link found.' } + } catch (err) { + return { ok: false, summary: err instanceof Error ? err.message : String(err) } + } + }, + }, +] diff --git a/electron/shared/types.ts b/electron/shared/types.ts index 06876ff9..23e4672b 100644 --- a/electron/shared/types.ts +++ b/electron/shared/types.ts @@ -2900,6 +2900,9 @@ export type AriaUiEffect = | { type: 'add_terminal'; terminalId: string; name: string; agentId?: string } | { type: 'run_integration'; actionId: string } | { type: 'set_integration_enabled'; integrationId: string; enabled: boolean } + | { type: 'open_preview'; url: string } + | { type: 'start_dev_server'; command: string; port: number; projectPath: string; label: string } + | { type: 'open_scaffold'; templateId: string; projectName: string } /** Streamed transcript events from the operator loop to the renderer. */ export type AriaToolEvent = diff --git a/src/lib/ariaUiEffects.ts b/src/lib/ariaUiEffects.ts index 0bb97fd1..f555fd8e 100644 --- a/src/lib/ariaUiEffects.ts +++ b/src/lib/ariaUiEffects.ts @@ -6,6 +6,7 @@ import type { AriaUiEffect } from '../../electron/shared/types' import { useUIStore } from '../store/ui' import { useWorkflowShellStore } from '../store/workflowShell' +import { useBrowserStore } from '../store/browser' const INTEGRATION_ENABLE_STORAGE_KEY = 'daemon:integration-command-center:enabled' @@ -110,11 +111,53 @@ export function applyUiEffect(effect: AriaUiEffect): void { // full context; headless execution needs the ICC's IntegrationContext. useUIStore.getState().openWorkspaceTool('integrations') break + case 'open_preview': + // Load a localhost dev-server URL in the embedded browser (BrowserMode). + // Loopback is allowlisted by the webview security guard; remote http is not. + useBrowserStore.getState().setUrl(effect.url) + useUIStore.getState().openBrowserTab() + break + case 'start_dev_server': + // Fire-and-forget path: kick off the dev server without awaiting the id. + // The two-phase path (runUiEffectWithData) is preferred; it returns the port. + void startDevServer(effect) + break + case 'open_scaffold': + // Preselect the template + name, then open the ProjectStarter wizard. + useUIStore.getState().setScaffoldPreset({ templateId: effect.templateId, projectName: effect.projectName }) + useUIStore.getState().openWorkspaceTool('starter') + break } } +/** + * Create a PTY terminal that runs the discovered dev command, register its port, + * and add it to the terminal store — the same flow ProjectStarter uses for the + * meme site / game preview. Returns the created terminal id + preview url. + */ +async function startDevServer(effect: Extract): Promise<{ ok: boolean; terminalId?: string; url?: string; error?: string }> { + const ui = useUIStore.getState() + const activeProjectId = ui.activeProjectId + if (!activeProjectId) return { ok: false, error: 'No active project.' } + const startupCommand = `${effect.command} -- --host 127.0.0.1 --port ${effect.port}` + const res = await window.daemon.terminal.create({ + cwd: effect.projectPath, + startupCommand, + userInitiated: true, + }) + if (!res.ok || !res.data) return { ok: false, error: res.error ?? 'Failed to start dev server terminal.' } + ui.addTerminal(activeProjectId, res.data.id, effect.label, null) + await window.daemon.ports.register(effect.port, activeProjectId, effect.label) + ui.setCenterMode('canvas') + return { ok: true, terminalId: res.data.id, url: `http://127.0.0.1:${effect.port}` } +} + /** Apply a two-phase effect and return data for the tool_result. */ export async function runUiEffectWithData(effect: AriaUiEffect): Promise { + if (effect.type === 'start_dev_server') { + // Await terminal creation so the tool_result carries the real port/url + status. + return startDevServer(effect) + } applyUiEffect(effect) if (effect.type === 'run_integration') { return { opened: 'integrations', actionId: effect.actionId, note: 'Opened Integrations — run the check there.' } diff --git a/src/panels/ProjectStarter/ProjectStarter.tsx b/src/panels/ProjectStarter/ProjectStarter.tsx index 8837cc75..cb9c7a17 100644 --- a/src/panels/ProjectStarter/ProjectStarter.tsx +++ b/src/panels/ProjectStarter/ProjectStarter.tsx @@ -4,6 +4,13 @@ import { useWorkflowShellStore } from '../../store/workflowShell' import { useNotificationsStore } from '../../store/notifications' import { useAppActions } from '../../store/appActions' import { useBrowserStore } from '../../store/browser' +import { + GAME_MAIN_TS, + GAME_SCENE_TS, + GAME_DAEMON_INDEX_TS, + GAME_DAEMON_TYPES_TS, + GAME_DAEMON_STUB_TS, +} from './gameTemplateFiles' import './ProjectStarter.css' // --- Template definitions --- @@ -18,6 +25,7 @@ export interface Template { } const MEME_COIN_WEBSITE_TEMPLATE_ID = 'meme-coin-website' +const PHASER_GAME_TEMPLATE_ID = 'phaser-solana-game' export const TEMPLATES: Template[] = [ { @@ -74,6 +82,14 @@ Initialize git repo. Use @solana/kit and Helius or QuickNode as the transport la - README with dev server and deployment instructions Initialize git repo. Prefer @solana/client, @solana/react-hooks, and @solana/web3-compat only when compatibility shims are needed.`, }, + { + id: PHASER_GAME_TEMPLATE_ID, + name: 'Solana Game', + description: 'Playable Phaser + TypeScript arcade with seedless wallet, cNFT assets, and policy-gated signing pre-wired', + tags: ['Game', 'Phaser'], + icon: 'M6 12h4m-2-2v4m5-1h.01M18 13h.01M7 6h10a4 4 0 014 4v4a4 4 0 01-4 4H7a4 4 0 01-4-4v-4a4 4 0 014-4z', + prompt: `Transform this Phaser + TypeScript arcade starter into the game the user described, keeping the four DAEMON hub seams intact. The game must import on-chain capability ONLY through src/daemon (getBridge() + the interfaces in src/daemon/types.ts) and never import an SDK directly. Keep connect/getSession, mintAsset, recordOutcome working and update src/daemon/manifest.ts (slug, title, category, shortDescription, signingPolicy, monetization). monetization must be 'none' | 'cosmetics' | 'entry-fee' with no earn loop. Keep signingPolicy minimal (value cap 0 unless a transfer is truly needed). Keep seedless onboarding. It must typecheck and build: npm run build.`, + }, { id: MEME_COIN_WEBSITE_TEMPLATE_ID, name: 'Meme Coin Website', @@ -296,6 +312,10 @@ function defaultMemeSettings(): MemeCoinWebsiteSettings { } } +function isGameTemplate(templateId: string | null | undefined): boolean { + return templateId === PHASER_GAME_TEMPLATE_ID +} + function isMemeCoinWebsiteTemplate(templateId: string | null | undefined): boolean { return templateId === MEME_COIN_WEBSITE_TEMPLATE_ID } @@ -686,6 +706,41 @@ function buildMemeWebsiteStartupCommand(port: number): string { ].join(' && ') } +/** + * Startup command for the game template. Installs deps, commits an initial + * snapshot (a swarm lane needs the project to have >=1 commit before it can add + * a worktree — see WorktreeService.addWorktree), then serves the Vite dev build + * so BrowserMode can preview it. The git init/commit is best-effort: if git is + * absent the dev server still starts, and the app can commit later before a swarm. + */ +function buildGameStartupCommand(port: number): string { + const url = `http://127.0.0.1:${port}` + const isWindows = typeof navigator !== 'undefined' && /windows/i.test(navigator.userAgent) + if (isWindows) { + return [ + 'Write-Host "DAEMON: installing game dependencies..."', + 'npm install', + 'if ($LASTEXITCODE -ne 0) { Write-Host "DAEMON: install failed"; exit $LASTEXITCODE }', + 'if (-not (Test-Path .git)) { git init -q; git add -A; git -c user.email=daemon@local -c user.name=DAEMON commit -qm "chore: initial game scaffold" }', + 'Write-Host "DAEMON: building the game..."', + 'npm run build', + 'if ($LASTEXITCODE -ne 0) { Write-Host "DAEMON: build failed"; exit $LASTEXITCODE }', + `Write-Host "DAEMON: starting game at ${url}"`, + `npm run dev -- --host 127.0.0.1 --port ${port}`, + ].join('; ') + } + + return [ + 'printf "DAEMON: installing game dependencies...\\n"', + 'npm install', + '(test -d .git || (git init -q && git add -A && git -c user.email=daemon@local -c user.name=DAEMON commit -qm "chore: initial game scaffold"))', + 'printf "DAEMON: building the game...\\n"', + 'npm run build', + `printf "DAEMON: starting game at ${url}\\n"`, + `npm run dev -- --host 127.0.0.1 --port ${port}`, + ].join(' && ') +} + async function isPortListening(port: number): Promise { try { const scanRes = await window.daemon.ports.scan() @@ -726,7 +781,11 @@ async function openMemeWebsiteWhenReady(input: { projectId: string projectName: string sessionId: string + /** 'website' (default) or 'game' — only affects user-facing copy. */ + kind?: 'website' | 'game' }) { + const noun = input.kind === 'game' ? 'game' : 'website' + const Noun = input.kind === 'game' ? 'Game' : 'Website' const url = `http://127.0.0.1:${input.port}` const ready = await waitForMemeWebsiteReady(input.terminalId, input.port) const notifications = useNotificationsStore.getState() @@ -734,7 +793,7 @@ async function openMemeWebsiteWhenReady(input: { notifications.addActivity({ kind: 'warning', context: 'Scaffold', - message: `Website build did not report a running server for ${input.projectName}. Check the terminal for install or build errors.`, + message: `${Noun} build did not report a running server for ${input.projectName}. Check the terminal for install or build errors.`, sessionId: input.sessionId, sessionStatus: 'blocked', projectId: input.projectId, @@ -742,8 +801,8 @@ async function openMemeWebsiteWhenReady(input: { }) notifications.pushToast({ kind: 'warning', - context: 'Meme Website', - message: 'Website build needs attention. Check the terminal output.', + context: Noun, + message: `${Noun} build needs attention. Check the terminal output.`, }) return } @@ -753,14 +812,14 @@ async function openMemeWebsiteWhenReady(input: { notifications.addActivity({ kind: 'success', context: 'Scaffold', - message: `Website is running for ${input.projectName} at ${url}.`, + message: `${Noun} is running for ${input.projectName} at ${url}.`, sessionId: input.sessionId, sessionStatus: 'complete', projectId: input.projectId, projectName: input.projectName, - artifacts: [{ type: 'project', label: 'Local website', value: url, href: url }], + artifacts: [{ type: 'project', label: `Local ${noun}`, value: url, href: url }], }) - notifications.pushSuccess(`Opened ${input.projectName} in DAEMON browser`, 'Meme Website') + notifications.pushSuccess(`Opened ${input.projectName} in DAEMON browser`, Noun) } function escapeMarkup(value: string): string { @@ -1716,6 +1775,198 @@ function nodeAppFiles(template: Template): ScaffoldFile[] { ] } +/** + * Full self-contained file set for the Phaser Solana game template ("Starfall"). + * Unlike the node/next templates this brings its own package.json, tsconfig, + * README, and .gitignore, so it bypasses commonFiles entirely. The four DAEMON + * hub seams (src/daemon/*) ship as working stubs so the game is playable the + * moment it is scaffolded; a swarm lane later customizes src/game and manifest. + */ +function gameFiles(projectName: string): ScaffoldFile[] { + const pkgName = packageName(projectName) + return [ + { + path: 'package.json', + content: quotedJson({ + name: pkgName, + version: '0.1.0', + private: true, + type: 'module', + description: 'DAEMON Game Hub template: a playable Phaser arcade with seedless wallet, cNFT assets, and policy-gated signing pre-wired.', + scripts: { + dev: 'vite', + build: 'tsc --noEmit && vite build', + preview: 'vite preview', + typecheck: 'tsc --noEmit', + }, + dependencies: { phaser: '^3.90.0' }, + devDependencies: { typescript: '^5.7.0', vite: '^6.0.0' }, + }), + }, + { path: '.gitignore', content: 'node_modules\ndist\n.env\n.DS_Store\n' }, + { + path: '.env.example', + content: [ + '# The game runs standalone with stub seams — no env needed to play.', + '# These are the hub-swap targets the DAEMON swarm fills at publish time.', + '# VITE_HELIUS_API_KEY= # RPC/DAS when a real bridge is wired', + '# VITE_ONBOARDING_PROVIDER= # gameshift | privy | turnkey (seedless wallet)', + '', + ].join('\n'), + }, + { + path: 'tsconfig.json', + content: quotedJson({ + compilerOptions: { + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'bundler', + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + strict: true, + noUnusedLocals: true, + noUnusedParameters: true, + noFallthroughCasesInSwitch: true, + esModuleInterop: true, + skipLibCheck: true, + resolveJsonModule: true, + isolatedModules: true, + noEmit: true, + }, + include: ['src'], + }), + }, + { + path: 'vite.config.ts', + content: [ + "import { defineConfig } from 'vite'", + '', + '// The DAEMON Game Hub loads this build into an embedded player (iframe/webview).', + "// base: './' keeps asset paths relative so it runs from any mount point.", + 'export default defineConfig({', + " base: './',", + ' build: {', + " target: 'es2022',", + " outDir: 'dist',", + ' sourcemap: false,', + ' },', + '})', + '', + ].join('\n'), + }, + { + path: 'index.html', + content: [ + '', + '', + ' ', + ' ', + ' ', + ` ${projectName}`, + ' ', + ' ', + ' ', + '
', + ' ', + ' ', + '', + '', + ].join('\n'), + }, + { path: 'README.md', content: gameReadme(projectName) }, + { path: 'src/main.ts', content: GAME_MAIN_TS }, + { path: 'src/game/GameScene.ts', content: GAME_SCENE_TS }, + { path: 'src/daemon/index.ts', content: GAME_DAEMON_INDEX_TS }, + { path: 'src/daemon/types.ts', content: GAME_DAEMON_TYPES_TS }, + { path: 'src/daemon/manifest.ts', content: gameManifest(projectName) }, + { path: 'src/daemon/stub.ts', content: GAME_DAEMON_STUB_TS }, + ] +} + +function gameReadme(projectName: string): string { + return [ + `# ${projectName}`, + '', + 'A DAEMON Game Hub arcade template — a real, playable Phaser + TypeScript game with', + 'the four hub seams pre-wired. It runs standalone the moment you scaffold it;', + 'published through the hub, the swarm swaps the stubs for real SDKs without', + 'touching the game code.', + '', + '## Run it', + '```bash', + 'npm install', + 'npm run dev # play at the printed localhost URL', + 'npm run build # typecheck + production build (what the hub embeds)', + '```', + '', + 'Controls: arrow keys or tap left/right to move, SPACE / tap to start.', + '', + '## The four hub seams (`src/daemon/`)', + '| Seam | File | Stub does | Real impl (swarm swaps in) |', + '|---|---|---|---|', + '| Wallet + seedless onboarding | `types.ts` `connect/getSession` | fake session, no seed | GameShift / Privy / Turnkey |', + '| Assets (cNFT) | `mintAsset` | local trophy | Metaplex Bubblegum / Core |', + '| Signing (safe by construction) | `recordOutcome` + `GameSigningPolicy` | local policy check | DAEMON `SignerGuardService` |', + '| Publish record | `manifest.ts` | listing metadata + policy | `game_registry` provenance row |', + '', + 'The game imports **only** the interfaces in `src/daemon/types.ts` via `getBridge()`.', + 'It never imports an SDK directly. Swap `src/daemon/stub.ts` for a real bridge and', + 'the game is unchanged. That is the studio-in-a-box contract.', + '', + '## Safe-by-construction', + '`GameSigningPolicy` caps this game to a single program (memo) and **zero value', + "transfer**. `stub.ts` enforces it locally exactly as DAEMON's", + '`SignerGuardService.assertTransactionAllowed()` will on-chain.', + '', + ].join('\n') +} + +function gameManifest(projectName: string): string { + const slug = packageName(projectName) + return [ + '// Publish manifest — what the hub reads to list this game in game_registry.', + '//', + '// The swarm fills this in (or a dev edits it) before publish. The `signingPolicy`', + '// here becomes the on-chain SignerGuardPolicy the game is capped to. `onboarding`', + "// must be true to pass the hub's listing policy (hide-the-chain invariant).", + '', + "import type { GameSigningPolicy } from './types'", + '', + 'export interface GameManifest {', + ' /** slug used in the store URL */', + ' slug: string', + ' title: string', + " category: 'arcade' | 'card' | 'battler' | 'casual' | 'puzzle'", + ' shortDescription: string', + ' /** author wallet (base58); set by the hub at publish from the session */', + ' author?: string', + ' /** must be true — seedless onboarding present — or the listing is rejected */', + ' onboarding: boolean', + ' /** the per-game signing cap; enforced by DAEMON SignerGuardService */', + ' signingPolicy: GameSigningPolicy', + ' /** NOT an extractive P2E economy. Listing policy rejects earn-loops. */', + " monetization: 'none' | 'cosmetics' | 'entry-fee'", + '}', + '', + 'export const manifest: GameManifest = {', + ` slug: ${JSON.stringify(slug)},`, + ` title: ${JSON.stringify(projectName)},`, + " category: 'arcade',", + " shortDescription: 'A 60-second score-attack. Dodge, survive, mint your best run.',", + ' onboarding: true,', + ' signingPolicy: {', + " allowedPrograms: ['MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'],", + ' maxLamportsPerAction: 0,', + ' approvalThresholdLamports: 0,', + ' },', + " monetization: 'cosmetics',", + '}', + '', + ].join('\n') +} + function commonFiles(template: Template, projectName: string, memeSettings?: MemeCoinWebsiteScaffoldSettings | null): ScaffoldFile[] { return [ { path: 'package.json', content: quotedJson(buildPackageJson(template, projectName)) }, @@ -1731,6 +1982,20 @@ export function buildDeterministicScaffold( projectName: string, options: { memeSettings?: MemeCoinWebsiteScaffoldSettings | null } = {}, ): DeterministicScaffold { + // The game template is fully self-contained (own package.json/tsconfig/README), + // so it bypasses commonFiles entirely. + if (template.id === PHASER_GAME_TEMPLATE_ID) { + const gameFileSet = gameFiles(projectName) + const gameDirs = new Set() + for (const file of gameFileSet) { + const parts = file.path.split('/').slice(0, -1) + for (let i = 1; i <= parts.length; i += 1) { + gameDirs.add(parts.slice(0, i).join('/')) + } + } + return { dirs: [...gameDirs].filter(Boolean), files: gameFileSet } + } + const isNext = isNextTemplate(template.id) const files = [ ...commonFiles(template, projectName, options.memeSettings), @@ -1855,6 +2120,27 @@ export function ProjectStarter() { setError(null) }, [activeProjectPath]) + // Consume an ARIA scaffold preset (from scaffold_game): jump to the configure + // step with the template preselected and the name filled, then clear it so a + // later manual open of the wizard starts clean. + useEffect(() => { + const preset = useUIStore.getState().scaffoldPreset + if (!preset) return + const template = TEMPLATES.find((t) => t.id === preset.templateId) + useUIStore.getState().setScaffoldPreset(null) + if (!template) return + const suggestedSavePath = activeProjectPath ? pathDirName(activeProjectPath) : '' + setWizard({ + step: 'configure', + template, + projectName: preset.projectName, + savePath: suggestedSavePath, + targetMode: 'new', + meme: defaultMemeSettings(), + }) + setError(null) + }, [activeProjectPath]) + const goBack = useCallback(() => { setWizard({ step: 'templates', template: null, projectName: '', savePath: '', targetMode: 'new', meme: defaultMemeSettings() }) setError(null) @@ -1904,7 +2190,11 @@ export function ProjectStarter() { const memeSettings = isMemeCoinWebsiteTemplate(wizard.template.id) ? normalizeMemeSettings(wizard.meme, name) : null - const memeDevPort = memeSettings ? await chooseMemeWebsiteDevPort() : null + const isGame = isGameTemplate(wizard.template.id) + // Both the meme site and the game auto-serve + auto-preview; pick a dev port for either. + const devPort = (memeSettings || isGame) ? await chooseMemeWebsiteDevPort() : null + const memeDevPort = memeSettings ? devPort : null + const gameDevPort = isGame ? devPort : null const sessionId = `scaffold-${crypto.randomUUID()}` setWizard((prev) => ({ ...prev, step: 'building' })) @@ -2042,22 +2332,31 @@ export function ProjectStarter() { return } + // Both meme site and game auto-serve + preview; pick the right startup command + label. + const previewPort = memeDevPort ?? gameDevPort + const startupCommand = memeDevPort + ? buildMemeWebsiteStartupCommand(memeDevPort) + : gameDevPort + ? buildGameStartupCommand(gameDevPort) + : undefined + const previewKind = gameDevPort ? 'game' : 'website' + const termRes = await window.daemon.terminal.create({ cwd: projectPath, - startupCommand: memeDevPort ? buildMemeWebsiteStartupCommand(memeDevPort) : undefined, + startupCommand, userInitiated: true, }) if (termRes.ok && termRes.data) { - addTerminal(newProject.id, termRes.data.id, memeDevPort ? `Website: ${name}` : `Terminal: ${name}`, null) - if (memeDevPort) { - await window.daemon.ports.register(memeDevPort, newProject.id, `${name} website`) + addTerminal(newProject.id, termRes.data.id, previewPort ? `${gameDevPort ? 'Game' : 'Website'}: ${name}` : `Terminal: ${name}`, null) + if (previewPort) { + await window.daemon.ports.register(previewPort, newProject.id, `${name} ${previewKind}`) } useNotificationsStore.getState().addActivity({ kind: 'success', context: 'Scaffold', - message: memeDevPort - ? `Project scaffold written for ${name}. Installing dependencies, building, then starting http://127.0.0.1:${memeDevPort}.` + message: previewPort + ? `Project scaffold written for ${name}. Installing dependencies, building, then starting http://127.0.0.1:${previewPort}.` : `Project scaffold written for ${name}. Open terminal is idle; run pnpm install when ready.`, sessionId, sessionStatus: 'running', @@ -2068,13 +2367,14 @@ export function ProjectStarter() { setActiveWorkspaceTool(null) focusTerminal() closeDrawer() - if (memeDevPort) { + if (previewPort) { void openMemeWebsiteWhenReady({ terminalId: termRes.data.id, - port: memeDevPort, + port: previewPort, projectId: newProject.id, projectName: name, sessionId, + kind: gameDevPort ? 'game' : 'website', }) } } else { diff --git a/src/panels/ProjectStarter/gameTemplateFiles.ts b/src/panels/ProjectStarter/gameTemplateFiles.ts new file mode 100644 index 00000000..fc93d019 --- /dev/null +++ b/src/panels/ProjectStarter/gameTemplateFiles.ts @@ -0,0 +1,400 @@ +/** + * Raw source for the Phaser Solana game template ("Starfall"), embedded as + * plain strings so ProjectStarter can write them verbatim during scaffolding. + * + * These are stored as line arrays joined with '\n' rather than template + * literals because the game source itself contains backticks and ${} — keeping + * them as data avoids any template-literal escaping ambiguity. The four hub + * seams (types/index/stub/manifest) match daemon-game-studio's arcade-starter. + */ + +export const GAME_MAIN_TS = [ + "import Phaser from 'phaser'", + "import { GameScene } from './game/GameScene'", + "import { manifest } from './daemon'", + '', + "// Entry point. The hub's GamePlayer mounts this build in an embedded surface;", + '// standalone it runs from `npm run dev`.', + '', + 'document.title = manifest.title', + '', + 'new Phaser.Game({', + ' type: Phaser.AUTO,', + " parent: 'app',", + ' width: 480,', + ' height: 720,', + " backgroundColor: '#0a0a12',", + ' physics: {', + " default: 'arcade',", + ' arcade: { gravity: { x: 0, y: 0 }, debug: false },', + ' },', + ' scale: {', + ' mode: Phaser.Scale.FIT,', + ' autoCenter: Phaser.Scale.CENTER_BOTH,', + ' },', + ' scene: [GameScene],', + '})', + '', +].join('\n') + +export const GAME_SCENE_TS = [ + "import Phaser from 'phaser'", + "import { getBridge } from '../daemon'", + '', + '// Starfall: a 60-second score-attack. Move the ship left/right, catch stars for', + '// points, dodge falling rocks. When time runs out, the run\'s score is recorded', + '// on-chain (via the DAEMON bridge) and a trophy cNFT is minted for a new best.', + '//', + '// This is a REAL loop, not a skeleton: input, spawning, collision, scoring,', + '// difficulty ramp, game-over, and the on-chain hook are all implemented.', + '', + 'const GAME_MS = 60_000', + 'const SHIP_SPEED = 420', + 'const STAR_POINTS = 10', + '', + 'export class GameScene extends Phaser.Scene {', + ' private ship!: Phaser.GameObjects.Rectangle', + ' private stars!: Phaser.Physics.Arcade.Group', + ' private rocks!: Phaser.Physics.Arcade.Group', + ' private cursors!: Phaser.Types.Input.Keyboard.CursorKeys', + ' private scoreText!: Phaser.GameObjects.Text', + ' private timeText!: Phaser.GameObjects.Text', + ' private statusText!: Phaser.GameObjects.Text', + ' private score = 0', + ' private endsAt = 0', + ' private spawnEvent?: Phaser.Time.TimerEvent', + ' private running = false', + " private best = Number(localStorage.getItem('starfall_best') ?? '0')", + '', + ' constructor() {', + " super('game')", + ' }', + '', + ' create() {', + ' const { width, height } = this.scale', + '', + ' this.add.rectangle(width / 2, height / 2, width, height, 0x0a0a12)', + ' this.ship = this.add.rectangle(width / 2, height - 40, 44, 20, 0x4cd4c4)', + ' this.physics.add.existing(this.ship)', + ' ;(this.ship.body as Phaser.Physics.Arcade.Body).setCollideWorldBounds(true)', + '', + ' this.stars = this.physics.add.group()', + ' this.rocks = this.physics.add.group()', + '', + " this.scoreText = this.add.text(16, 14, 'SCORE 0', fontStyle())", + " this.timeText = this.add.text(width - 16, 14, '60', fontStyle()).setOrigin(1, 0)", + ' this.statusText = this.add', + " .text(width / 2, height / 2, '', fontStyle(20))", + ' .setOrigin(0.5)', + '', + ' this.cursors = this.input.keyboard!.createCursorKeys()', + " this.input.keyboard!.on('keydown-SPACE', () => {", + ' if (!this.running) this.startRun()', + ' })', + " this.input.on('pointerdown', () => {", + ' if (!this.running) this.startRun()', + ' })', + '', + ' this.physics.add.overlap(this.ship, this.stars, (_s, star) => {', + ' ;(star as Phaser.GameObjects.Rectangle).destroy()', + ' this.score += STAR_POINTS', + ' this.scoreText.setText(`SCORE ${this.score}`)', + ' })', + " this.physics.add.overlap(this.ship, this.rocks, () => this.endRun('HIT'))", + '', + ' this.showIdle()', + ' }', + '', + ' private showIdle() {', + ' this.statusText.setText(`STARFALL\\nbest ${this.best}\\n\\nSPACE / tap to start`).setAlpha(1)', + ' }', + '', + ' private startRun() {', + ' this.score = 0', + " this.scoreText.setText('SCORE 0')", + ' this.stars.clear(true, true)', + ' this.rocks.clear(true, true)', + ' this.statusText.setAlpha(0)', + ' this.running = true', + ' this.endsAt = this.time.now + GAME_MS', + ' this.spawnEvent = this.time.addEvent({', + ' delay: 650,', + ' loop: true,', + ' callback: () => this.spawn(),', + ' })', + ' }', + '', + ' private spawn() {', + ' const x = Phaser.Math.Between(20, this.scale.width - 20)', + ' const elapsed = 1 - Math.max(0, this.endsAt - this.time.now) / GAME_MS', + ' const rockChance = 0.35 + elapsed * 0.3 // difficulty ramps up', + ' const isRock = Math.random() < rockChance', + ' const group = isRock ? this.rocks : this.stars', + ' const color = isRock ? 0xff5a6a : 0xffd447', + ' const obj = this.add.rectangle(x, -20, isRock ? 22 : 16, isRock ? 22 : 16, color)', + ' this.physics.add.existing(obj)', + ' const body = obj.body as Phaser.Physics.Arcade.Body', + ' body.setVelocityY(160 + elapsed * 180)', + ' group.add(obj)', + ' }', + '', + " private endRun(reason: 'TIME' | 'HIT') {", + ' if (!this.running) return', + ' this.running = false', + ' this.spawnEvent?.remove()', + ' this.stars.setVelocityY(0)', + ' this.rocks.setVelocityY(0)', + '', + ' const isBest = this.score > this.best', + ' if (isBest) {', + ' this.best = this.score', + " localStorage.setItem('starfall_best', String(this.best))", + ' }', + " this.statusText.setText(`${reason === 'HIT' ? 'CRASHED' : 'TIME'}\\nscore ${this.score}`).setAlpha(1)", + ' void this.settle(isBest)', + ' }', + '', + ' /** Record the run on-chain and mint a trophy on a new best (via DAEMON). */', + ' private async settle(isBest: boolean) {', + ' const bridge = getBridge()', + ' try {', + ' if (!bridge.getSession()) await bridge.connect()', + ' const outcome = await bridge.recordOutcome({ score: this.score })', + " let line = outcome.ok ? 'run recorded' : `not recorded: ${outcome.error}`", + ' if (isBest && outcome.ok) {', + " const asset = await bridge.mintAsset({ name: `Starfall ${this.score}`, kind: 'trophy' })", + " line += `\\ntrophy minted (${asset.onChain ? 'on-chain' : 'local'})`", + ' }', + ' this.statusText.setText(`${this.statusText.text}\\n${line}\\n\\nSPACE / tap to retry`)', + ' } catch (err) {', + ' this.statusText.setText(`${this.statusText.text}\\n(settle error)\\n\\nSPACE / tap to retry`)', + ' console.error(err)', + ' }', + ' }', + '', + ' update() {', + ' if (this.running) {', + ' const body = this.ship.body as Phaser.Physics.Arcade.Body', + ' const left = this.cursors.left.isDown || this.pointerLeft()', + ' const right = this.cursors.right.isDown || this.pointerRight()', + ' body.setVelocityX(left ? -SHIP_SPEED : right ? SHIP_SPEED : 0)', + '', + ' const remaining = Math.max(0, this.endsAt - this.time.now)', + ' this.timeText.setText(String(Math.ceil(remaining / 1000)))', + " if (remaining <= 0) this.endRun('TIME')", + '', + ' this.cull(this.stars)', + ' this.cull(this.rocks)', + ' }', + ' }', + '', + ' private pointerLeft() {', + ' return this.input.activePointer.isDown && this.input.activePointer.x < this.scale.width / 2', + ' }', + ' private pointerRight() {', + ' return this.input.activePointer.isDown && this.input.activePointer.x >= this.scale.width / 2', + ' }', + '', + ' private cull(group: Phaser.Physics.Arcade.Group) {', + ' group.children.each((child) => {', + ' const go = child as Phaser.GameObjects.Rectangle', + ' if (go.y > this.scale.height + 30) go.destroy()', + ' return true', + ' })', + ' }', + '}', + '', + 'function fontStyle(size = 16): Phaser.Types.GameObjects.Text.TextStyle {', + ' return {', + " fontFamily: 'monospace',", + ' fontSize: `${size}px`,', + " color: '#e8e8f0',", + " align: 'center',", + ' }', + '}', + '', +].join('\n') + +export const GAME_DAEMON_TYPES_TS = [ + '// DAEMON Game Hub — integration contract.', + '//', + '// These are the four seams every hub game plugs into. The starter ships with', + '// working *stub* implementations (see ./stub.ts) so the game is playable the', + '// moment you clone it. When a game is published through the hub, the DAEMON', + '// swarm replaces the stubs with real implementations backed by:', + '// - wallet/onboarding -> GameShift or Privy/Turnkey (seedless, gasless)', + '// - assets -> Metaplex (cNFT via Bubblegum / Core)', + '// - signing -> DAEMON SignerGuardService (per-game policy caps)', + '// - publish manifest -> game_registry provenance record', + '//', + '// The game code depends ONLY on these interfaces, never on a concrete SDK.', + '// That is the studio-in-a-box promise: swap the impl, the game is unchanged.', + '', + '/** A player\'s session identity. Seedless: no seed phrase ever shown. */', + 'export interface PlayerSession {', + ' /** base58 public key of the session/embedded wallet */', + ' address: string', + ' /** display handle if the onboarding provider gives one */', + ' label?: string', + ' /** true when a real (non-stub) wallet is connected */', + ' live: boolean', + '}', + '', + "/** Result of routing an on-chain action through DAEMON's signing guard. */", + 'export interface SignResult {', + ' ok: boolean', + ' /** tx signature when ok; error reason when not */', + ' signature?: string', + ' error?: string', + ' /** true if the action was blocked by the per-game signing policy */', + ' blockedByPolicy?: boolean', + '}', + '', + '/** A minted in-game asset (e.g. a high-score trophy cNFT). */', + 'export interface MintedAsset {', + ' assetId: string', + ' name: string', + ' /** true if minted on-chain, false if this is a stub/local mint */', + ' onChain: boolean', + '}', + '', + '/**', + ' * The per-game signing policy the hub attaches at publish time. The game', + ' * declares the programs it needs; DAEMON\'s SignerGuardService enforces that', + ' * the game can invoke NOTHING else and cannot exceed the value cap. This is', + ' * why a hub game is safe-by-construction: it literally cannot drain a player.', + ' */', + 'export interface GameSigningPolicy {', + ' /** program IDs this game is allowed to invoke */', + ' allowedPrograms: string[]', + ' /** max lamports the game may move per action (0 = no value transfer) */', + ' maxLamportsPerAction: number', + ' /** require an explicit player approval card above this lamports amount */', + ' approvalThresholdLamports: number', + '}', + '', + '/** The DAEMON bridge the game talks to. Injected by the hub\'s GamePlayer. */', + 'export interface DaemonBridge {', + ' /** Connect a seedless session wallet (no seed phrase). */', + ' connect(): Promise', + ' /** Current session, or null if not connected. */', + ' getSession(): PlayerSession | null', + ' /** Mint an in-game asset. Routed through the signing guard. */', + " mintAsset(input: { name: string; kind: 'trophy' | 'cosmetic' }): Promise", + ' /** Record a game outcome on-chain (score/win). Routed through the guard. */', + ' recordOutcome(input: { score: number; meta?: Record }): Promise', + ' /** The signing policy in force for this game (for display/debug). */', + ' policy(): GameSigningPolicy', + '}', + '', +].join('\n') + +export const GAME_DAEMON_INDEX_TS = [ + "// Bridge resolver. The hub's GamePlayer injects a real DaemonBridge on", + '// window.__DAEMON__ at runtime. When absent (standalone dev), we fall back to', + '// the stub so the game always runs.', + '', + "import type { DaemonBridge } from './types'", + "import { stubBridge } from './stub'", + '', + 'declare global {', + ' interface Window {', + ' __DAEMON__?: DaemonBridge', + ' }', + '}', + '', + 'export function getBridge(): DaemonBridge {', + " if (typeof window !== 'undefined' && window.__DAEMON__) {", + ' return window.__DAEMON__', + ' }', + ' return stubBridge', + '}', + '', + "export * from './types'", + "export { manifest } from './manifest'", + '', +].join('\n') + +export const GAME_DAEMON_STUB_TS = [ + '// Stub DaemonBridge — makes the template playable standalone (no SDKs, no chain).', + '//', + '// Every method mimics the real behavior locally so a dev can run the game and', + '// see the full loop (connect -> play -> mint trophy -> record outcome) before', + '// any real integration. The swarm replaces this file with a real bridge at', + '// publish time; the game code never changes.', + '//', + '// The signing policy below is REAL in spirit: it demonstrates the safe-by-', + '// construction guarantee. mintAsset/recordOutcome check the policy locally the', + '// same way SignerGuardService.assertTransactionAllowed() will on-chain.', + '', + 'import type {', + ' DaemonBridge,', + ' GameSigningPolicy,', + ' MintedAsset,', + ' PlayerSession,', + ' SignResult,', + "} from './types'", + '', + '// Memo program — the only thing this game needs to write a score/outcome memo.', + '// A real build would swap in the actual program(s); the point is the game is', + '// capped to THIS list and cannot invoke anything else.', + "const MEMO_PROGRAM = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'", + '', + 'const POLICY: GameSigningPolicy = {', + ' allowedPrograms: [MEMO_PROGRAM],', + ' maxLamportsPerAction: 0, // this game never moves value — pure score/asset writes', + ' approvalThresholdLamports: 0,', + '}', + '', + 'function fakeAddress(): string {', + ' // deterministic-ish base58-looking string; no crypto, this is a stub', + " const chars = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'", + " let s = ''", + ' for (let i = 0; i < 44; i++) s += chars[(i * 7 + 13) % chars.length]', + ' return s', + '}', + '', + 'let session: PlayerSession | null = null', + 'let assetCounter = 0', + '', + '/** Local policy check mirroring SignerGuardService.assertTransactionAllowed. */', + 'function policyAllows(programId: string, lamports: number): { ok: boolean; reason?: string } {', + ' if (!POLICY.allowedPrograms.includes(programId)) {', + ' return { ok: false, reason: `program ${programId} not in game policy` }', + ' }', + ' if (lamports > POLICY.maxLamportsPerAction) {', + ' return { ok: false, reason: `value ${lamports} exceeds cap ${POLICY.maxLamportsPerAction}` }', + ' }', + ' return { ok: true }', + '}', + '', + 'export const stubBridge: DaemonBridge = {', + ' async connect(): Promise {', + " session = { address: fakeAddress(), label: 'guest', live: false }", + ' return session', + ' },', + '', + ' getSession(): PlayerSession | null {', + ' return session', + ' },', + '', + ' async mintAsset({ name, kind }): Promise {', + ' const check = policyAllows(MEMO_PROGRAM, 0)', + ' if (!check.ok) throw new Error(`mint blocked: ${check.reason}`)', + ' assetCounter += 1', + ' return { assetId: `stub-${kind}-${assetCounter}`, name, onChain: false }', + ' },', + '', + ' async recordOutcome({ score }): Promise {', + ' const check = policyAllows(MEMO_PROGRAM, 0)', + ' if (!check.ok) return { ok: false, error: check.reason, blockedByPolicy: true }', + ' // stub "signature"', + ' return { ok: true, signature: `stub-sig-${score}-${Date.now().toString(36)}` }', + ' },', + '', + ' policy(): GameSigningPolicy {', + ' return POLICY', + ' },', + '}', + '', +].join('\n') diff --git a/src/store/ui.ts b/src/store/ui.ts index 6a1e4c03..9b6cb17c 100644 --- a/src/store/ui.ts +++ b/src/store/ui.ts @@ -51,6 +51,8 @@ interface UIState { activeWorkspaceToolId: string | null integrationCommandSelectionId: string | null pendingSubView: string | null + /** Preselect a ProjectStarter template + name (set by ARIA scaffold_game, consumed by the wizard). */ + scaffoldPreset: { templateId: string; projectName: string } | null rightPanelTab: RightPanelTab dashboardTabOpen: boolean dashboardTabActive: boolean @@ -92,6 +94,7 @@ interface UIState { setActiveWorkspaceTool: (toolId: string | null) => void setIntegrationCommandSelectionId: (integrationId: string | null) => void setPendingSubView: (subView: string | null) => void + setScaffoldPreset: (preset: { templateId: string; projectName: string } | null) => void toggleWorkspaceTool: (toolId: string) => void setRightPanelTab: (tab: RightPanelTab) => void toggleDashboardTab: () => void @@ -151,6 +154,7 @@ export const useUIStore = create((set, get) => ({ activeWorkspaceToolId: null, integrationCommandSelectionId: null, pendingSubView: null, + scaffoldPreset: null, rightPanelTab: 'claude' as RightPanelTab, dashboardTabOpen: false, dashboardTabActive: false, @@ -373,6 +377,7 @@ export const useUIStore = create((set, get) => ({ }, setIntegrationCommandSelectionId: (integrationId) => set({ integrationCommandSelectionId: integrationId }), setPendingSubView: (subView) => set({ pendingSubView: subView }), + setScaffoldPreset: (preset) => set({ scaffoldPreset: preset }), toggleWorkspaceTool: (toolId) => set((state) => { const alias = resolveToolAlias(toolId) if (alias.toolId !== toolId) { diff --git a/test/panels/ProjectStarter.runtime.test.ts b/test/panels/ProjectStarter.runtime.test.ts index 5a53661f..cf015f53 100644 --- a/test/panels/ProjectStarter.runtime.test.ts +++ b/test/panels/ProjectStarter.runtime.test.ts @@ -217,6 +217,15 @@ describe('deterministic project scaffold', () => { 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 (template.id === 'phaser-solana-game') { + // Self-contained Phaser game: entry point + the four DAEMON hub seams. + expect(filePaths.has('src/main.ts')).toBe(true) + expect(filePaths.has('src/game/GameScene.ts')).toBe(true) + expect(filePaths.has('src/daemon/types.ts')).toBe(true) + expect(filePaths.has('src/daemon/stub.ts')).toBe(true) + expect(filePaths.has('src/daemon/manifest.ts')).toBe(true) + expect(filePaths.has('index.html')).toBe(true) + expect(JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).dependencies.phaser).toBeTruthy() } else if (['dapp-nextjs', 'solana-foundation', 'perps-frontend', 'meme-coin-website'].includes(template.id)) { expect(filePaths.has('app/layout.tsx')).toBe(true) expect(filePaths.has('app/page.tsx')).toBe(true) From 46a010af742847d88b6d14728c9c0bb7aecdc6b0 Mon Sep 17 00:00:00 2001 From: nullxnothing Date: Tue, 7 Jul 2026 07:58:55 -0600 Subject: [PATCH 08/26] fix(games): keep the swarm approval alive and commit the scaffold deterministically Two bugs surfaced on the first live run of the build-a-game flow: - scaffold_game switched the active project mid-turn, which starts a fresh per-project ARIA session and discarded the pending swarm approval card. Make scaffold_game the last action of its turn and split the flow into explicit turns (scaffold, then build in the new project's session). Updated the tool summary and the ARIA build-a-game playbook accordingly. - The scaffold's initial git commit was chained inside the shell startup command (PowerShell ';' continues past failures), so git init could run while the commit silently did not, leaving an unborn HEAD a swarm can't branch from. Add a deterministic git:init-commit IPC (init + add -A + commit, idempotent) and call it from the scaffold handler right after files are written; drop the git step from buildGameStartupCommand. Gate loop green (typecheck + 1110 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- electron/ipc/git.ts | 18 ++++++++++++ electron/preload/index.ts | 1 + electron/services/aria/contextAssembler.ts | 7 +++-- electron/services/aria/tools/gameStudio.ts | 4 +-- electron/shared/channels.ts | 1 + src/panels/ProjectStarter/ProjectStarter.tsx | 30 +++++++++++++++----- src/types/daemon.d.ts | 1 + 7 files changed, 50 insertions(+), 12 deletions(-) diff --git a/electron/ipc/git.ts b/electron/ipc/git.ts index ddeab240..0977ecd8 100644 --- a/electron/ipc/git.ts +++ b/electron/ipc/git.ts @@ -122,6 +122,24 @@ export function registerGitHandlers() { await git.commit(message) })) + // Deterministic init + stage-all + initial commit for a freshly scaffolded project. + // Ordered and error-checked in one place (the shell-chained equivalent in a startup + // command was fragile: a `.git` created by init but a failed commit left an unborn + // HEAD, which a swarm can't branch from). Idempotent: skips if a commit already exists. + ipcMain.handle('git:init-commit', ipcHandler(async (_event, cwd: string, message: string) => { + validateCwd(cwd) + const git = simpleGit(cwd) + await ensureGitRepository(cwd) + // Already has a commit? Nothing to do. + try { + await git.revparse(['HEAD']) + return { committed: false, reason: 'already has commits' } + } catch { /* unborn HEAD — proceed to first commit */ } + await git.add(['-A']) + await git.commit(message.trim() || 'chore: initial scaffold') + return { committed: true } + })) + ipcMain.handle('git:push', ipcHandler(async (_event, cwd: string) => { validateCwd(cwd) const ensured = await ensureGitRepository(cwd) diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 6d41e270..c670f96a 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -280,6 +280,7 @@ contextBridge.exposeInMainWorld('daemon', { stage: (cwd: string, files: string[]) => ipcRenderer.invoke('git:stage', cwd, files), unstage: (cwd: string, files: string[]) => ipcRenderer.invoke('git:unstage', cwd, files), commit: (cwd: string, message: string) => ipcRenderer.invoke('git:commit', cwd, message), + initCommit: (cwd: string, message: string) => ipcRenderer.invoke('git:init-commit', cwd, message), push: (cwd: string) => ipcRenderer.invoke('git:push', cwd), log: (cwd: string, count?: number) => ipcRenderer.invoke('git:log', cwd, count), diff: (cwd: string, filePath?: string) => ipcRenderer.invoke('git:diff', cwd, filePath), diff --git a/electron/services/aria/contextAssembler.ts b/electron/services/aria/contextAssembler.ts index d5cc1547..99d909cc 100644 --- a/electron/services/aria/contextAssembler.ts +++ b/electron/services/aria/contextAssembler.ts @@ -25,9 +25,10 @@ CAPABILITIES (call the matching tool — do not just explain): - Memory: remember durable project facts (remember_fact), list what you know (recall_memories), correct or forget them (update_memory / forget_memory). Never store secrets. BUILD-A-GAME FLOW (when the user asks you to build/make a game): -- Message 1: present_plan, then scaffold_game with a short project name, then swarm_launch with ONE task describing the game (the lane authors it from the template). Then STOP and tell the user the lane is building — do not wait in-loop; the swarm runs in the background past this turn. -- Message 2 (after the user says it's done, or on the next turn): swarm_status to confirm the lane is "done", swarm_merge_lane on that lane, run_dev_server, then preview_app so the user can play it. Offer deploy_app last. -- The game code is written by the swarm lane, not by you. Do not scaffold_file the game yourself. +- STEP 1 (scaffold): call scaffold_game with a short project name. This opens the wizard and, once the user confirms the folder, switches the workbench to the NEW project — which starts a fresh ARIA session for it. So scaffold_game is the LAST action of this turn: after calling it, tell the user "Project scaffolded and previewing. In the new project, tell me to build the game and I'll launch the swarm." Do NOT call swarm_launch in the same turn — the project switch ends this session and would discard a pending approval. +- STEP 2 (build — in the NEW project's session, after the user asks): swarm_launch with ONE task describing the game (the lane authors it from the template). Then STOP and tell the user the lane is building — the swarm runs in the background past this turn. +- STEP 3 (finish — after the user says it's done, or on a later turn): swarm_status to confirm the lane is "done", swarm_merge_lane on that lane, run_dev_server, then preview_app so the user can play it. Offer deploy_app last. +- The game code is written by the swarm lane, not by you. Do not scaffold_file the game yourself. Never call swarm_launch in the same turn as scaffold_game. RULES: - When the user tells you to remember something, or a stable project convention is established (package manager, a constraint, a fix that should not be repeated), call remember_fact. If unsure whether a fact is already known, recall_memories first. Never remember secrets — keys, seed phrases, credentials. diff --git a/electron/services/aria/tools/gameStudio.ts b/electron/services/aria/tools/gameStudio.ts index 9949a2f0..da615775 100644 --- a/electron/services/aria/tools/gameStudio.ts +++ b/electron/services/aria/tools/gameStudio.ts @@ -28,7 +28,7 @@ function pickDevPort(): number { export const gameStudioTools: AriaTool[] = [ { name: 'scaffold_game', - description: 'Open the DAEMON project wizard preloaded with the Solana game template (playable Phaser + TypeScript arcade with seedless wallet, cNFT assets, and policy-gated signing pre-wired). Provide a projectName. The scaffold writes the template, runs npm install + an initial git commit (so a swarm can author the game), then serves and previews it. The user confirms the target folder in the wizard.', + description: 'Open the DAEMON project wizard preloaded with the Solana game template (playable Phaser + TypeScript arcade with seedless wallet, cNFT assets, and policy-gated signing pre-wired). Provide a projectName. The scaffold writes the template, runs npm install + an initial git commit (so a swarm can author the game), then serves and previews it. The user confirms the target folder in the wizard. IMPORTANT: this switches the workbench to the new project, which starts a fresh ARIA session — so this must be the LAST tool call of the turn. Do not call swarm_launch after it in the same turn; wait for the user to ask again in the new project.', kind: 'edit', risk: 'write', input: { @@ -43,7 +43,7 @@ export const gameStudioTools: AriaTool[] = [ await ctx.runUiEffect(effect, false) return { ok: true, - summary: `Opened the game scaffold for "${projectName}". Confirm the folder in the wizard to build.`, + summary: `Opened the game scaffold for "${projectName}". Once the user confirms the folder, the workbench switches to the new project and this session ends. Do NOT call more tools now — tell the user: in the new project, ask me to build the game and I'll launch the swarm.`, uiEffect: effect, } }, diff --git a/electron/shared/channels.ts b/electron/shared/channels.ts index 5e580535..66ec5cf8 100644 --- a/electron/shared/channels.ts +++ b/electron/shared/channels.ts @@ -115,6 +115,7 @@ export interface ChannelMap { 'git:stage': { input: [cwd: string, files: string[]]; output: void } 'git:unstage': { input: [cwd: string, files: string[]]; output: void } 'git:commit': { input: [cwd: string, message: string]; output: void } + 'git:init-commit': { input: [cwd: string, message: string]; output: { committed: boolean; reason?: string } } 'git:push': { input: string; output: string } 'git:log': { input: [cwd: string, count?: number]; output: GitCommit[] } 'git:diff': { input: [cwd: string, filePath?: string]; output: string } diff --git a/src/panels/ProjectStarter/ProjectStarter.tsx b/src/panels/ProjectStarter/ProjectStarter.tsx index cb9c7a17..bd38784e 100644 --- a/src/panels/ProjectStarter/ProjectStarter.tsx +++ b/src/panels/ProjectStarter/ProjectStarter.tsx @@ -707,11 +707,10 @@ function buildMemeWebsiteStartupCommand(port: number): string { } /** - * Startup command for the game template. Installs deps, commits an initial - * snapshot (a swarm lane needs the project to have >=1 commit before it can add - * a worktree — see WorktreeService.addWorktree), then serves the Vite dev build - * so BrowserMode can preview it. The git init/commit is best-effort: if git is - * absent the dev server still starts, and the app can commit later before a swarm. + * Startup command for the game template. Installs deps, then serves the Vite dev + * build so BrowserMode can preview it. The initial git commit (required so a swarm + * lane can branch a worktree) is done deterministically in the scaffold handler + * before this runs — not here — because shell-chained git was fragile on Windows. */ function buildGameStartupCommand(port: number): string { const url = `http://127.0.0.1:${port}` @@ -721,7 +720,6 @@ function buildGameStartupCommand(port: number): string { 'Write-Host "DAEMON: installing game dependencies..."', 'npm install', 'if ($LASTEXITCODE -ne 0) { Write-Host "DAEMON: install failed"; exit $LASTEXITCODE }', - 'if (-not (Test-Path .git)) { git init -q; git add -A; git -c user.email=daemon@local -c user.name=DAEMON commit -qm "chore: initial game scaffold" }', 'Write-Host "DAEMON: building the game..."', 'npm run build', 'if ($LASTEXITCODE -ne 0) { Write-Host "DAEMON: build failed"; exit $LASTEXITCODE }', @@ -733,7 +731,6 @@ function buildGameStartupCommand(port: number): string { return [ 'printf "DAEMON: installing game dependencies...\\n"', 'npm install', - '(test -d .git || (git init -q && git add -A && git -c user.email=daemon@local -c user.name=DAEMON commit -qm "chore: initial game scaffold"))', 'printf "DAEMON: building the game...\\n"', 'npm run build', `printf "DAEMON: starting game at ${url}\\n"`, @@ -2316,6 +2313,25 @@ export function ProjectStarter() { throw new Error(fileRes.error ?? `Failed to write ${file.path}`) } } + + // Game projects must have >=1 commit so a swarm lane can branch a worktree + // (WorktreeService.addWorktree). Do it deterministically here rather than in + // the fragile shell-chained startup command. Best-effort: a git failure must + // not block the scaffold — the user can commit later before launching a swarm. + if (isGameTemplate(wizard.template.id)) { + const initRes = await window.daemon.git.initCommit(projectPath, 'chore: initial game scaffold') + if (!initRes.ok) { + useNotificationsStore.getState().addActivity({ + kind: 'warning', + context: 'Scaffold', + message: `Game scaffolded, but the initial git commit failed (${initRes.error ?? 'unknown'}). Commit before launching a swarm.`, + sessionId, + sessionStatus: 'running', + projectId: newProject.id, + projectName: name, + }) + } + } } catch (scaffoldErr) { useNotificationsStore.getState().addActivity({ kind: 'error', diff --git a/src/types/daemon.d.ts b/src/types/daemon.d.ts index b3b89d9e..126fad0b 100644 --- a/src/types/daemon.d.ts +++ b/src/types/daemon.d.ts @@ -641,6 +641,7 @@ declare global { stage: (cwd: string, files: string[]) => Promise unstage: (cwd: string, files: string[]) => Promise commit: (cwd: string, message: string) => Promise + initCommit: (cwd: string, message: string) => Promise> push: (cwd: string) => Promise> log: (cwd: string, count?: number) => Promise> diff: (cwd: string, filePath?: string) => Promise> From 358210b35f0a0bffa7785f3b7a50d1fee78860b4 Mon Sep 17 00:00:00 2001 From: nullxnothing Date: Fri, 10 Jul 2026 08:03:55 -0600 Subject: [PATCH 09/26] feat(aria): read-only Robinhood Chain awareness - bundle docs knowledge synced from docs.robinhood.com/chain (17 sections) - canonical token registry: WETH, USDG, 20 stock tokens, 5 ETFs - new tools: rh_chain_info, rh_chain_knowledge, rh_stock_tokens, rh_chain_rpc - fetch-based JSON-RPC reader against the public endpoints, no signing --- electron/services/RobinhoodChainService.ts | 156 ++++++++++++ electron/services/aria/contextAssembler.ts | 1 + .../services/aria/knowledge/robinhoodChain.ts | 95 ++++++++ .../aria/knowledge/robinhoodChainDocs.ts | 220 +++++++++++++++++ electron/services/aria/toolCatalog.ts | 2 + .../services/aria/tools/robinhoodChain.ts | 167 +++++++++++++ test/services/RobinhoodChainTools.test.ts | 223 ++++++++++++++++++ 7 files changed, 864 insertions(+) create mode 100644 electron/services/RobinhoodChainService.ts create mode 100644 electron/services/aria/knowledge/robinhoodChain.ts create mode 100644 electron/services/aria/knowledge/robinhoodChainDocs.ts create mode 100644 electron/services/aria/tools/robinhoodChain.ts create mode 100644 test/services/RobinhoodChainTools.test.ts diff --git a/electron/services/RobinhoodChainService.ts b/electron/services/RobinhoodChainService.ts new file mode 100644 index 00000000..7a671b33 --- /dev/null +++ b/electron/services/RobinhoodChainService.ts @@ -0,0 +1,156 @@ +/** + * Read-only JSON-RPC client for Robinhood Chain (EVM / Arbitrum Orbit L2). + * Talks to the public rate-limited endpoints — fine for ARIA's ad-hoc reads, + * not for indexing. No signing, no key material, no writes. + */ +import { getRhNetwork, type RhNetworkId } from './aria/knowledge/robinhoodChain' + +const RPC_TIMEOUT_MS = 10_000 +const WEI_PER_ETH = 10n ** 18n + +/** Well-known ERC-20 function selectors (stable ABI constants). */ +const SELECTOR = { + name: '0x06fdde03', + symbol: '0x95d89b41', + decimals: '0x313ce567', + totalSupply: '0x18160ddd', + balanceOf: '0x70a08231', +} as const + +export function isEvmAddress(value: string): boolean { + return /^0x[0-9a-fA-F]{40}$/.test(value) +} + +export function isTxHash(value: string): boolean { + return /^0x[0-9a-fA-F]{64}$/.test(value) +} + +interface JsonRpcResponse { + result?: unknown + error?: { code: number; message: string } +} + +async function rpcCall(network: RhNetworkId, method: string, params: unknown[]): Promise { + const { rpcUrl, name } = getRhNetwork(network) + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + signal: AbortSignal.timeout(RPC_TIMEOUT_MS), + }) + if (!response.ok) throw new Error(`${name} RPC HTTP ${response.status} for ${method}.`) + const payload = (await response.json()) as JsonRpcResponse + if (payload.error) throw new Error(`${name} RPC error for ${method}: ${payload.error.message}`) + return payload.result +} + +function hexToBigInt(value: unknown): bigint { + if (typeof value !== 'string' || !value.startsWith('0x')) { + throw new Error(`Expected hex quantity, got ${JSON.stringify(value)}.`) + } + return BigInt(value) +} + +/** Format a wei quantity as a decimal string without float precision loss. */ +export function formatUnits(value: bigint, decimals: number): string { + const base = 10n ** BigInt(decimals) + const whole = value / base + const fraction = (value % base).toString().padStart(decimals, '0').replace(/0+$/, '') + return fraction ? `${whole}.${fraction}` : whole.toString() +} + +/** Decode an ABI-encoded string return (offset + length + utf8 bytes). */ +function decodeAbiString(hex: unknown): string { + if (typeof hex !== 'string' || hex === '0x' || !hex.startsWith('0x')) return '' + const data = hex.slice(2) + if (data.length < 128) return '' + const length = Number(BigInt(`0x${data.slice(64, 128)}`)) + const bytes = data.slice(128, 128 + length * 2) + return Buffer.from(bytes, 'hex').toString('utf8') +} + +async function erc20Call(network: RhNetworkId, token: string, data: string): Promise { + return rpcCall(network, 'eth_call', [{ to: token, data }, 'latest']) +} + +export interface RhChainStatus { + network: string + chainId: number + blockNumber: number + gasPriceGwei: string +} + +export async function getChainStatus(network: RhNetworkId): Promise { + const [chainIdHex, blockHex, gasHex] = await Promise.all([ + rpcCall(network, 'eth_chainId', []), + rpcCall(network, 'eth_blockNumber', []), + rpcCall(network, 'eth_gasPrice', []), + ]) + return { + network: getRhNetwork(network).name, + chainId: Number(hexToBigInt(chainIdHex)), + blockNumber: Number(hexToBigInt(blockHex)), + gasPriceGwei: formatUnits(hexToBigInt(gasHex), 9), + } +} + +export interface RhBalance { + address: string + wei: string + eth: string +} + +export async function getBalance(network: RhNetworkId, address: string): Promise { + if (!isEvmAddress(address)) throw new Error(`"${address}" is not a valid 0x address.`) + const wei = hexToBigInt(await rpcCall(network, 'eth_getBalance', [address, 'latest'])) + return { address, wei: wei.toString(), eth: formatUnits(wei, 18) } +} + +export interface RhErc20Info { + address: string + name: string + symbol: string + decimals: number + totalSupply: string + holder?: { address: string; balance: string } +} + +export async function getErc20Info(network: RhNetworkId, token: string, holder?: string): Promise { + if (!isEvmAddress(token)) throw new Error(`"${token}" is not a valid token address.`) + if (holder !== undefined && !isEvmAddress(holder)) throw new Error(`"${holder}" is not a valid holder address.`) + const [name, symbol, decimalsHex, supplyHex] = await Promise.all([ + erc20Call(network, token, SELECTOR.name), + erc20Call(network, token, SELECTOR.symbol), + erc20Call(network, token, SELECTOR.decimals), + erc20Call(network, token, SELECTOR.totalSupply), + ]) + const decimals = Number(hexToBigInt(decimalsHex)) + const info: RhErc20Info = { + address: token, + name: decodeAbiString(name), + symbol: decodeAbiString(symbol), + decimals, + totalSupply: formatUnits(hexToBigInt(supplyHex), decimals), + } + if (holder) { + const data = SELECTOR.balanceOf + holder.slice(2).toLowerCase().padStart(64, '0') + const balance = hexToBigInt(await erc20Call(network, token, data)) + info.holder = { address: holder, balance: formatUnits(balance, decimals) } + } + return info +} + +export interface RhTransaction { + transaction: unknown + receipt: unknown +} + +export async function getTransaction(network: RhNetworkId, hash: string): Promise { + if (!isTxHash(hash)) throw new Error(`"${hash}" is not a valid transaction hash.`) + const [transaction, receipt] = await Promise.all([ + rpcCall(network, 'eth_getTransactionByHash', [hash]), + rpcCall(network, 'eth_getTransactionReceipt', [hash]), + ]) + if (transaction === null) throw new Error(`Transaction ${hash} not found on ${getRhNetwork(network).name}.`) + return { transaction, receipt } +} diff --git a/electron/services/aria/contextAssembler.ts b/electron/services/aria/contextAssembler.ts index 99d909cc..ca65f66d 100644 --- a/electron/services/aria/contextAssembler.ts +++ b/electron/services/aria/contextAssembler.ts @@ -22,6 +22,7 @@ CAPABILITIES (call the matching tool — do not just explain): - Git: stage + commit in the active project (git_commit). You never push. - Game studio: scaffold a playable Solana game (scaffold_game), run its dev server (run_dev_server), preview it in-app (preview_app), merge a finished swarm lane (swarm_merge_lane), deploy the pre-wired project (deploy_app). - Swarms: run tasks as parallel worktree-isolated Claude agents (swarm_launch), monitor them (swarm_status), read their results (swarm_collect). +- Robinhood Chain (EVM L2): bundled docs knowledge (rh_chain_knowledge — answer any Robinhood Chain question from it before guessing), network constants (rh_chain_info), the canonical stock-token/ETF registry (rh_stock_tokens), and live read-only RPC reads (rh_chain_rpc). All read-only — you cannot sign, send, bridge, or trade on Robinhood Chain. - Memory: remember durable project facts (remember_fact), list what you know (recall_memories), correct or forget them (update_memory / forget_memory). Never store secrets. BUILD-A-GAME FLOW (when the user asks you to build/make a game): diff --git a/electron/services/aria/knowledge/robinhoodChain.ts b/electron/services/aria/knowledge/robinhoodChain.ts new file mode 100644 index 00000000..d845817a --- /dev/null +++ b/electron/services/aria/knowledge/robinhoodChain.ts @@ -0,0 +1,95 @@ +/** + * Bundled Robinhood Chain reference for ARIA — network constants and the + * canonical token registry, distilled from docs.robinhood.com/chain. + * Last synced: 2026-07-10. Addresses and feeds can move; the docs site is the + * source of truth and rh_chain_rpc reads live state. + */ + +export type RhNetworkId = 'mainnet' | 'testnet' + +export interface RhChainNetwork { + id: RhNetworkId + name: string + chainId: number + rpcUrl: string + sequencerFeedUrl: string + explorerUrl: string + parentChain: string + gasToken: string +} + +export const ROBINHOOD_CHAIN_DOCS_URL = 'https://docs.robinhood.com/chain/' +export const ROBINHOOD_CHAIN_STATUS_URL = 'http://status.robinhoodchain.offchain.io/' +export const ROBINHOOD_CHAIN_BRIDGE_URL = + 'https://portal.arbitrum.io/bridge?destinationChain=robinhood-chain&sourceChain=ethereum' +export const CHAINLINK_FEEDS_URL = + 'https://docs.chain.link/data-feeds/price-feeds/addresses?network=robinhood' + +export const ROBINHOOD_CHAIN_NETWORKS: RhChainNetwork[] = [ + { + id: 'mainnet', + name: 'Robinhood Chain', + chainId: 4663, + rpcUrl: 'https://rpc.mainnet.chain.robinhood.com', + sequencerFeedUrl: 'wss://feed.mainnet.chain.robinhood.com', + explorerUrl: 'https://robinhoodchain.blockscout.com', + parentChain: 'Ethereum', + gasToken: 'ETH', + }, + { + id: 'testnet', + name: 'Robinhood Chain Testnet', + chainId: 46630, + rpcUrl: 'https://rpc.testnet.chain.robinhood.com', + sequencerFeedUrl: 'wss://feed.testnet.chain.robinhood.com', + explorerUrl: 'https://explorer.testnet.chain.robinhood.com', + parentChain: 'Ethereum Sepolia', + gasToken: 'ETH', + }, +] + +export function getRhNetwork(id: RhNetworkId): RhChainNetwork { + const network = ROBINHOOD_CHAIN_NETWORKS.find((n) => n.id === id) + if (!network) throw new Error(`Unknown Robinhood Chain network "${id}".`) + return network +} + +export type RhTokenKind = 'core' | 'stock' | 'etf' + +export interface RhToken { + symbol: string + kind: RhTokenKind + /** Canonical mainnet contract address. A same-ticker token at another address is NOT canonical. */ + address: string +} + +/** Canonical mainnet token registry (docs.robinhood.com/chain/contracts). */ +export const ROBINHOOD_CHAIN_TOKENS: RhToken[] = [ + { symbol: 'WETH', kind: 'core', address: '0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73' }, + { symbol: 'USDG', kind: 'core', address: '0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168' }, + { symbol: 'AAPL', kind: 'stock', address: '0xaF3D76f1834A1d425780943C99Ea8A608f8a93f9' }, + { symbol: 'AMD', kind: 'stock', address: '0x86923f96303D656E4aa86D9d42D1e57ad2023fdC' }, + { symbol: 'AMZN', kind: 'stock', address: '0x12f190a9F9d7D37a250758b26824B97CE941bF54' }, + { symbol: 'BABA', kind: 'stock', address: '0xad25Ac6C84D497db898fa1E8387bf6Af3532a1c4' }, + { symbol: 'BE', kind: 'stock', address: '0x822CC93fFD030293E9842c30BBD678F530701867' }, + { symbol: 'COIN', kind: 'stock', address: '0x6330D8C3178a418788dF01a47479c0ce7CCF450b' }, + { symbol: 'CRCL', kind: 'stock', address: '0xdF0992E440dD0be65BD8439b609d6D4366bf1CB5' }, + { symbol: 'CRWV', kind: 'stock', address: '0x5f10A1C971B69e47e059e1dC91901B59b3fB49C3' }, + { symbol: 'GOOGL', kind: 'stock', address: '0x2e0847E8910a9732eB3fb1bb4b70a580ADAD4FE3' }, + { symbol: 'INTC', kind: 'stock', address: '0xc72b96e0E48ecd4DC75E1e45396e26300BC39681' }, + { symbol: 'META', kind: 'stock', address: '0xc0D6457C16Cc70d6790Dd43521C899C87ce02f35' }, + { symbol: 'MSFT', kind: 'stock', address: '0xe93237C50D904957Cf27E7B1133b510C669c2e74' }, + { symbol: 'MU', kind: 'stock', address: '0xfF080c8ce2E5feadaCa0Da81314Ae59D232d4afD' }, + { symbol: 'NVDA', kind: 'stock', address: '0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC' }, + { symbol: 'ORCL', kind: 'stock', address: '0xb0992820E760d836549ba69BC7598b4af75dEE03' }, + { symbol: 'PLTR', kind: 'stock', address: '0x894E1EC2D74FFE5AEF8Dc8A9e84686acCB964F2A' }, + { symbol: 'SNDK', kind: 'stock', address: '0xB90A19fF0Af67f7779afF50A882A9CfF42446400' }, + { symbol: 'SPCX', kind: 'stock', address: '0x4a0E65A3EcceC6dBe60AE065F2e7bb85Fae35eEa' }, + { symbol: 'TSLA', kind: 'stock', address: '0x322F0929c4625eD5bAd873c95208D54E1c003b2d' }, + { symbol: 'USAR', kind: 'stock', address: '0xd917B029C761D264c6A312BBbcDA868658eF86a6' }, + { symbol: 'QQQ', kind: 'etf', address: '0xD5f3879160bc7c32ebb4dC785F8a4F505888de68' }, + { symbol: 'SGOV', kind: 'etf', address: '0x92FD66527192E3e61d4DDd13322Aa222DE86F9B5' }, + { symbol: 'SLV', kind: 'etf', address: '0x411eFb0E7f985935DAec3D4C3ebaEa0d0AD7D89f' }, + { symbol: 'SPY', kind: 'etf', address: '0x117cc2133c37B721F49dE2A7a74833232B3B4C0C' }, + { symbol: 'CUSO', kind: 'etf', address: '0xa30FA36Db767ad9eD3f7a60fC79526fB4d56D344' }, +] diff --git a/electron/services/aria/knowledge/robinhoodChainDocs.ts b/electron/services/aria/knowledge/robinhoodChainDocs.ts new file mode 100644 index 00000000..a9d7049f --- /dev/null +++ b/electron/services/aria/knowledge/robinhoodChainDocs.ts @@ -0,0 +1,220 @@ +/** + * Robinhood Chain docs knowledge for ARIA, distilled from docs.robinhood.com/chain. + * One section per docs page; details keep every hard fact (IDs, URLs, addresses, + * mechanics) and drop the prose. Last synced: 2026-07-10. + */ + +export interface RhKnowledgeSection { + topic: string + title: string + summary: string + details: string + sourceUrl: string +} + +const DOCS = 'https://docs.robinhood.com/chain' + +export const ROBINHOOD_CHAIN_KNOWLEDGE: RhKnowledgeSection[] = [ + { + topic: 'overview', + title: 'About Robinhood Chain', + summary: 'Permissionless, EVM-compatible Arbitrum L2 optimized for tokenized real-world assets; live on mainnet with ETH gas.', + details: `- Ethereum L2 built on Arbitrum Dedicated Blockchains (Nitro); posts data to Ethereum via blobs; ETH is the native gas token. +- Optimized for tokenized RWAs: equities, ETFs, private assets. Flagship product is Robinhood Stock Tokens. +- First-come, first-served sequencing by sequencer arrival time — no priority gas auctions. +- Fully EVM-compatible: Solidity/Vyper deploy unmodified; Hardhat, Foundry, ethers.js, viem, Wagmi work out of the box. +- First-class ERC-4337 account abstraction (gas sponsorship, batching, session keys). +- Ecosystem: Alchemy (recommended RPC + AA), LayerZero (bridge), Chainlink (oracles), Fireblocks/BitGo (custody), Allium (analytics), Uniswap (public DEX), Rialto (proprietary AMM), Morpho (lending), Lighter + Arcus (perps), Paxos USDG (stablecoin), Zerion (wallet data), CoinGecko (tracking). +- Status page: http://status.robinhoodchain.offchain.io/ · Support: chain-developers-group@robinhood.com`, + sourceUrl: `${DOCS}/`, + }, + { + topic: 'connecting', + title: 'Connecting to Robinhood Chain', + summary: 'Chain IDs, RPC endpoints (public + providers), sequencer feeds, and explorers for mainnet and testnet.', + details: `- Mainnet: chain ID 4663 · ETH gas · explorer https://robinhoodchain.blockscout.com +- Testnet: chain ID 46630 · ETH gas · explorer https://explorer.testnet.chain.robinhood.com +- Public RPC (rate-limited, not for production): mainnet https://rpc.mainnet.chain.robinhood.com · testnet https://rpc.testnet.chain.robinhood.com +- Sequencer feed: wss://feed.mainnet.chain.robinhood.com (testnet: wss://feed.testnet.chain.robinhood.com) · Sequencer: https://sequencer.mainnet.chain.robinhood.com +- Alchemy (recommended for production): https://robinhood-mainnet.g.alchemy.com/v2/{API_KEY} (wss:// same host); testnet robinhood-testnet. Also supported: QuickNode ({ENDPOINT}.robinhood-mainnet.quiknode.pro/{TOKEN}), Blockdaemon, dRPC, Validation Cloud. +- Archive endpoints (for historical reads/indexing) available via providers such as Alchemy.`, + sourceUrl: `${DOCS}/connecting`, + }, + { + topic: 'add-network-to-wallet', + title: 'Add network to your wallet', + summary: 'Wallet configuration values for MetaMask-style manual add; Robinhood Wallet supports the chain natively.', + details: `- Works with any EVM wallet (MetaMask, Phantom, etc.). Robinhood Wallet (iOS/Android) supports it natively. +- Manual add — mainnet: chain ID 4663, RPC https://rpc.mainnet.chain.robinhood.com/, symbol ETH, explorer https://robinhoodchain.blockscout.com +- Manual add — testnet: chain ID 46630, RPC https://rpc.testnet.chain.robinhood.com, symbol ETH, explorer https://explorer.testnet.chain.robinhood.com`, + sourceUrl: `${DOCS}/add-network-to-wallet`, + }, + { + topic: 'bridging', + title: 'Bridging', + summary: 'Canonical Arbitrum bridge (trustless, 7-day withdrawal) plus fast third-party routes: Stargate/LayerZero, CCIP, Relay, Across, LiFi/0x.', + details: `- Canonical bridge (trustless, security from Ethereum): https://portal.arbitrum.io/bridge?destinationChain=robinhood-chain&sourceChain=ethereum — deposits ~10 min; withdrawals: initiate on L2, wait 7-day challenge period, then claim on L1 (costs L1 gas). +- Deposits use Arbitrum retryable tickets: a failed L2 leg can be manually redeemed within 7 days — funds are not lost. +- Fast routes: LayerZero OFT / Stargate (WBTC, USDG, other OFTs, minutes) · Chainlink CCIP (programmable transfer + action) · Relay (intents, seconds, bridge-and-execute) · Across (intents, seconds) · LiFi / 0x (swap-and-bridge). +- Programmatic bridging: interact with the Delayed Inbox on L1 (see protocol-contracts). A bridged ERC-20 has a DIFFERENT address on L2 than on Ethereum — resolve via calculateL2TokenAddress on the L2 Gateway Router.`, + sourceUrl: `${DOCS}/bridging`, + }, + { + topic: 'stock-tokens', + title: 'Stock Tokens', + summary: 'Tokenized debt securities (issuer: Robinhood Assets (Jersey) Ltd) giving economic exposure to US equities/ETFs as standard ERC-20s with Chainlink feeds.', + details: `- Standard ERC-20, 18 decimals; one token per underlying equity/ETF identified by ticker. Held, transferred, and composed like any ERC-20. +- Legally: tokenized DEBT securities issued by Robinhood Assets (Jersey) Limited (RHJ). Economic exposure only — no legal/beneficial rights in the underlying. Not offered to US persons (Reg S); also restricted in UK, Canada, Switzerland. Prospectus: http://docs.robinhood.com/rhj +- Primary market: only Authorised Participants (at issuance, BBVI) can subscribe/redeem after KYB. Developers compose with existing tokens; there is no public mint. +- Corporate actions (dividends, splits) are handled by an onchain multiplier (ERC-8056 Scaled UI Amount): raw balanceOf()/totalSupply() stay fixed; uiMultiplier() (1e18 fixed-point) scales shares-per-token. Dividends are reinvested via the multiplier, so tokens track TOTAL return. +- Live per-token Chainlink price feeds; the feed price already includes the multiplier. +- Trading is RFQ at launch (e.g. 0x RFQ quoting vs USDG).`, + sourceUrl: `${DOCS}/stock-tokens`, + }, + { + topic: 'building-with-stock-tokens', + title: 'Building with Stock Tokens', + summary: 'Integration patterns: ERC-20 ops, ERC-8056 multiplier math, UI-adjusted views, events, and price-feed usage.', + details: `- All standard ERC-20 ops work unmodified (balanceOf/transfer/approve). 18 decimals. +- ERC-8056 interfaces: uiMultiplier() current multiplier (1e18 = 1.0, launch value 1e18); newUIMultiplier() + effectiveAt() expose a scheduled pending multiplier; balanceOfUI(account) and totalSupplyUI() return underlying-share-adjusted views; events UIMultiplierUpdated(old, new, effectiveAtTimestamp) and TransferWithScaledUI(from, to, value, uiValue). +- Conversion: underlying shares = raw amount x uiMultiplier / 1e18. Not a rebasing token. +- Price: each token has a Chainlink AggregatorV3Interface feed (latestRoundData(), typically 8 decimals). Feed price is multiplier-adjusted — do NOT apply the multiplier again. USD value = balance x price / 1e8 (for 8-decimal feeds). +- Use cases: portfolio display, RFQ trading widgets, lending collateral (e.g. Morpho), index baskets, yield vaults, price-triggered contracts, perps margin. +- Getting started: pick a token address from the registry, read balanceOf, read latestRoundData() on its feed, compose.`, + sourceUrl: `${DOCS}/building-with-stock-tokens`, + }, + { + topic: 'token-contracts', + title: 'Token Contracts (canonical addresses)', + summary: 'Canonical mainnet addresses for WETH, USDG, 20 stock tokens, and 5 tokenized ETFs.', + details: `- Canonical registry is bundled in ROBINHOOD_CHAIN_TOKENS and served by the rh_stock_tokens tool: WETH, USDG (core); AAPL, AMD, AMZN, BABA, BE, COIN, CRCL, CRWV, GOOGL, INTC, META, MSFT, MU, NVDA, ORCL, PLTR, SNDK, SPCX, TSLA, USAR (stocks); QQQ, SGOV, SLV, SPY, CUSO (ETFs). +- CRITICAL: a token with a matching name/ticker at a different address is NOT a Robinhood Stock Token — always verify against the canonical address list. +- WETH mainnet: 0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73 · USDG: 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168`, + sourceUrl: `${DOCS}/contracts`, + }, + { + topic: 'protocol-contracts', + title: 'Protocol Contracts', + summary: 'L1 core/messaging contracts, token-bridge gateways (L1+L2), Arbitrum precompiles, and misc deployments.', + details: `- L1 core (Ethereum mainnet): Rollup 0x23A19d23e89166adedbDcB432518AB01e4272D94 · Sequencer Inbox 0xBd0D173EEb87D57A09521c24388a12789F33ba96 · CoreProxyAdmin 0x1232813BDd40aa9d53066A880dE78a4Be70B90FD +- L1 messaging: Delayed Inbox 0x1A07cc4BD17E0118BdB54D70990D2158AbAD7a2D · Bridge 0xDf8755334ce7A73cCF6b581C02eA649AE3E864b3 · Outbox 0xf0ce991ea4A0d2400A4AB49b20ae333f6Dce3DE9 +- L1 token bridge: Gateway Router 0x6a2E3a1e16FC29f27Ce61429746D558d656975bB · ERC20 Gateway 0x85001CC4867C5e1C22dA4B79BB8852B9e2a06da0 · Custom Gateway 0x9368EAEbFe6E063C69dcF8126711A6997E0eCeE1 · WETH Gateway 0xF7e12b9614b509C747ab4423bC4ACF923759Cf1B +- L2 token bridge: Gateway Router 0x1E324B9316138CA9a73F960213621AD1aaf01B89 · ERC20 Gateway 0xfd9b17206278C16DdaacF6AC8f05dBf97EdCb31e · Custom Gateway 0x912285144fC0f6e89d3Ed16F5Ab72f87A1878959 · WETH Gateway 0x1D187C3E2dA52D72BC9C41e3AbA0fdFa6a7bF055 · Proxy Admin 0xa3Acd31AFb851B4eB9DAD00F5204c01D924267dF +- Precompiles (standard Arbitrum addresses on both networks): ArbSys 0x...64 · ArbInfo 0x...65 · ArbAddressTable 0x...66 · ArbFunctionTable 0x...68 · ArbOwnerPublic 0x...6b · ArbGasInfo 0x...6C · ArbAggregator 0x...6D · ArbRetryableTx 0x...6E · ArbStatistics 0x...6F · ArbOwner 0x...70 · ArbWasm 0x...71 · ArbWasmCache 0x...72 · NodeInterface 0x...C8 +- Misc L2: Multicall 0x2cAC2D899eCC914d704FeaAE33ac1bF36277DaD1 · Permit2 0x000000000022D473030F116dDEE9F6B43aC78BA3 +- Testnet variants exist for all of the above (parent: Sepolia) — see the docs page for the full testnet table.`, + sourceUrl: `${DOCS}/protocol-contracts`, + }, + { + topic: 'gas-and-fees', + title: 'Gas & Fees', + summary: 'ETH-denominated fees with two components: L2 execution gas plus an L1 data fee proportional to calldata size.', + details: `- Fee = L2 execution (gas used x L2 gas price, low and stable) + L1 data fee (posting calldata to Ethereum, varies with L1 congestion). +- Both are bundled into normal gas — eth_estimateGas and wallet previews account for both automatically. +- Optimize by minimizing calldata: pack arguments, avoid unnecessary data, batch operations (AA batched UserOperations help). +- Query live gas pricing onchain via the ArbGasInfo precompile (0x...6C).`, + sourceUrl: `${DOCS}/gas-and-fees`, + }, + { + topic: 'transaction-finality', + title: 'Transaction Finality', + summary: 'Three stages: sub-second sequencer soft confirmation, batch posted to Ethereum (minutes), Ethereum finality (~13 min after posting).', + details: `- Soft confirmation: sequencer accepts/orders/executes, returns a receipt sub-second. Reversible only if the sequencer posts a different order. Fine for everyday UX. +- Posted to Ethereum: ordering fixed unless Ethereum itself reorgs. Minutes. +- Ethereum finality: ~13 minutes after posting — irreversible, full Ethereum security. Use for high-value/irreversible actions. +- Withdrawal delay (7-day challenge period) is separate from finality — it applies to canonical-bridge exits only.`, + sourceUrl: `${DOCS}/transaction-finality`, + }, + { + topic: 'differences-from-ethereum', + title: 'Differences from Ethereum', + summary: 'Arbitrum Nitro quirks: block.number is L1-ish, no prevrandao randomness, aliased L1 senders, 96KB contracts, FCFS ordering, sequencer-level screening.', + details: `- block.number returns an ESTIMATE of the L1 block number, updated periodically — use ArbSys(0x...64).arbBlockNumber() for the real L2 block. +- block.prevrandao / block.difficulty are constant — never use for randomness (use Chainlink VRF). blockhash(n) only reliable for recent blocks. block.coinbase is the network fee account. +- Address aliasing: an L1 contract calling L2 appears as its aliased address (original + fixed offset) in msg.sender — account for this in access control. +- Contract size: 96 KB max code (vs 24 KB on Ethereum), 192 KB max init code. +- Ordering: first-come first-served by sequencer arrival — priority fees do NOT reorder queued transactions. +- Transaction screening: sequencer-level compliance filtering — transactions associated with sanctioned addresses are excluded from inclusion. Reads (eth_call, eth_getLogs, balances) are unaffected. +- Fees have an L1 data component; gasleft()/estimation behave accordingly (see gas-and-fees).`, + sourceUrl: `${DOCS}/differences-from-ethereum`, + }, + { + topic: 'cross-chain-messaging', + title: 'Cross-Chain Messaging', + summary: 'Arbitrum-native L1<->L2 messaging: retryable tickets down (minutes), ArbSys up (7-day challenge), via @arbitrum/sdk.', + details: `- L1 -> L2: retryable tickets through the Delayed Inbox (0x1A07cc4BD17E0118BdB54D70990D2158AbAD7a2D); completes in minutes; failed L2 legs redeemable within 7 days. +- L2 -> L1: ArbSys precompile (0x...64) sendTxToL1; execute on L1 via the Outbox after the 7-day challenge period. +- Use @arbitrum/sdk; register the chain first with registerCustomArbitrumNetwork({ chainId: 4663, parentChainId: 1, confirmPeriodBlocks: 45818, ethBridge: { bridge, inbox, sequencerInbox, outbox, rollup } }). +- Address aliasing applies to L1->L2 calls; the SDK has applyAlias/undoAlias helpers.`, + sourceUrl: `${DOCS}/cross-chain-messaging`, + }, + { + topic: 'account-abstraction', + title: 'Account Abstraction', + summary: 'First-class ERC-4337 plus EIP-7702; Alchemy-powered with ZeroDev and Privy alternatives; standard entrypoints deployed.', + details: `- Supports ERC-4337 and EIP-7702 (EOAs delegating to contract code — smart-account features without migrating address). +- Providers: Alchemy (@alchemy/wallet-apis, Gas Manager sponsorship policies, chain export robinhoodMainnet in @alchemy/common/chains) · ZeroDev (Kernel accounts, https://rpc.zerodev.app/api/v3/{PROJECT_ID}/chain/4663) · Privy (embedded wallets). viem/chains also exports robinhoodMainnet. +- Entrypoints: v0.6.0 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789 · v0.7.0 0x0000000071727De22E5E9d8BAf0edAc6f37da032 · v0.8.0 0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108 +- SenderCreators: v0.6 0x7fc98430eAEdbb6070B35B39D798725049088348 · v0.7 0xEFC2c1444eBCC4Db75e7613d20C6a62fF67A167C · v0.8 0x449ED7C3e6Fee6a97311d4b55475DF59C44AdD33 +- Safe: Module Setup v0.3.0 0x2dd68b007B46fBe91B9A7c3EDa5A7a1063cB5b47 · Safe 4337 Module v0.3.0 0x75cf11467937ce3F2f357CE24ffc3DBF8fD5c226 +- Blockscout shows UserOps at https://robinhoodchain.blockscout.com/op/{hash}.`, + sourceUrl: `${DOCS}/account-abstraction`, + }, + { + topic: 'oracles-and-price-feeds', + title: 'Oracles & Price Feeds', + summary: 'Chainlink AggregatorV3Interface feeds for crypto and every Stock Token; multiplier-adjusted prices, 24/5 updates, sequencer-uptime and pause checks.', + details: `- All feeds implement AggregatorV3Interface (latestRoundData() via the feed proxy). Most USD feeds use 8 decimals — always call decimals(), never hardcode. +- Stock Token feeds return the PER-TOKEN price = underlying share price x uiMultiplier — already multiplier-adjusted; do not apply the multiplier again. Because dividends reinvest via the multiplier, the token tracks total return and drifts above the headline share price over time. +- Presentation math: underlying share price = feedPrice x 1e18 / uiMultiplier() · share-equivalent units = balance x uiMultiplier() / 1e18. +- Stock feeds update 24/5, following market hours. +- Feed addresses: read from Chainlink's Robinhood page (source of truth): https://docs.chain.link/data-feeds/price-feeds/addresses?network=robinhood — do not hardcode. +- L2 hygiene: check the Chainlink L2 Sequencer Uptime Feed (status 0 = up, honor a grace period) before trusting prices; check staleness (updatedAt vs heartbeat); reject zero/negative answers. +- Corporate actions pause the oracle: read oraclePaused() on the token; treat true as "price temporarily unavailable" — but the flag is advisory, keep the staleness check as the primary guard.`, + sourceUrl: `${DOCS}/oracles-and-price-feeds`, + }, + { + topic: 'deploy-smart-contracts', + title: 'Deploy a Contract', + summary: 'Standard Foundry/Hardhat deployment; verify against Blockscout (chain 4663 mainnet / 46630 testnet).', + details: `- Foundry: forge create --rpc-url https://rpc.mainnet.chain.robinhood.com --private-key $PRIVATE_KEY --broadcast; verify with forge verify-contract --chain-id 4663 --verifier blockscout --verifier-url https://robinhoodchain.blockscout.com/api/ +- Hardhat: network { url, chainId: 4663, accounts }; etherscan customChains apiURL https://robinhoodchain.blockscout.com/api (apiKey can be "empty"); npx hardhat verify --network robinhood
. +- Testnet: chain ID 46630, RPC https://rpc.testnet.chain.robinhood.com, verifier https://explorer.testnet.chain.robinhood.com/api/ — deploy to testnet first. +- Needs ETH on Robinhood Chain for gas. Never commit a real private key; prefer a throwaway deployer for testing.`, + sourceUrl: `${DOCS}/deploy-smart-contracts`, + }, + { + topic: 'run-a-full-node', + title: 'Run a full node', + summary: 'Arbitrum Nitro node (docker offchainlabs/nitro-node) needing L1 execution + beacon endpoints, the Robinhood genesis JSON, and heavy hardware.', + details: `- Hardware: 8+ modern cores, 64 GB RAM (128 recommended), local NVMe sized (2 x chain size) + 20%. +- Requires an Ethereum L1 execution RPC AND an L1 beacon endpoint (for blob reads); L1 must be fully synced. Docker required. +- Run: docker run offchainlabs/nitro-node:v3.11 --parent-chain.connection.url= --parent-chain.blob-client.beacon-url= --chain.id=4663 --init.genesis-json-file=robinhood-genesis.json --http.addr=0.0.0.0 --http.port=8547 --http.api=net,web3,eth (ports 8547 HTTP / 8548 WS). +- Genesis config: https://cdn.robinhood.com/assets/generated_assets/hoodchain_docsite/chain-node-configs/robinhood-genesis.json (testnet config alongside). +- Optional: sequencer feed --node.feed.input.url=wss://feed.mainnet.chain.robinhood.com (must be wss://) · snapshot sync --init.url=. +- Runs ArbOS 61. Validators: BoLD dispute resolution, permissioned allowlist, 1 WETH bond — contact Robinhood. +- Check sync with eth_syncing (false = synced); "nonce has already been used" errors mean the node is still syncing.`, + sourceUrl: `${DOCS}/run-a-full-node`, + }, + { + topic: 'notices-and-upgrades', + title: 'Notices & Upgrades', + summary: 'ArbOS upgrade notice board; un-upgraded nodes stop cleanly at the activation block and resume after updating.', + details: `- Runs Arbitrum Nitro; ArbOS upgrades activate onchain at scheduled times. Node operators must run a compatible Nitro version beforehand; an un-upgraded node stops cleanly and resumes after updating, no data loss. +- Most upgrades need no dApp/user action, but some include EVM behavior changes — review each notice. +- Notice table was empty as of the 2026-07-10 sync; monitor ${DOCS}/notices-and-upgrades.`, + sourceUrl: `${DOCS}/notices-and-upgrades`, + }, +] + +export function getKnowledgeSection(topic: string): RhKnowledgeSection | undefined { + return ROBINHOOD_CHAIN_KNOWLEDGE.find((s) => s.topic === topic) +} + +export function searchKnowledge(query: string): RhKnowledgeSection[] { + const needle = query.trim().toLowerCase() + if (!needle) return [] + return ROBINHOOD_CHAIN_KNOWLEDGE.filter((s) => + [s.topic, s.title, s.summary, s.details].some((text) => text.toLowerCase().includes(needle)), + ) +} diff --git a/electron/services/aria/toolCatalog.ts b/electron/services/aria/toolCatalog.ts index f820079c..86c46c5b 100644 --- a/electron/services/aria/toolCatalog.ts +++ b/electron/services/aria/toolCatalog.ts @@ -25,6 +25,7 @@ import { workspaceTools } from './tools/workspace' import { walletTools } from './tools/wallet' import { clawpumpTools } from './tools/clawpump' import { hyperliquidTools } from './tools/hyperliquid' +import { robinhoodChainTools } from './tools/robinhoodChain' import { forensicsTools } from './tools/forensics' import { venumTools } from './tools/venum' import { agentStationTools } from './tools/agentStation' @@ -86,6 +87,7 @@ export const ARIA_TOOLS: AriaTool[] = [ ...walletTools, ...clawpumpTools, ...hyperliquidTools, + ...robinhoodChainTools, ...forensicsTools, ...venumTools, ...agentStationTools, diff --git a/electron/services/aria/tools/robinhoodChain.ts b/electron/services/aria/tools/robinhoodChain.ts new file mode 100644 index 00000000..fe80d35b --- /dev/null +++ b/electron/services/aria/tools/robinhoodChain.ts @@ -0,0 +1,167 @@ +/** + * Robinhood Chain ARIA tools — bundled docs knowledge, canonical network/token + * constants, and live read-only JSON-RPC reads via RobinhoodChainService. + * + * Awareness only: every tool is risk 'read'. There is deliberately no signing, + * transaction, or bridging tool here — Robinhood Chain money paths are out of + * scope until they get the same guardrails as the Solana surfaces. + */ +import * as Rh from '../../RobinhoodChainService' +import { + CHAINLINK_FEEDS_URL, + ROBINHOOD_CHAIN_BRIDGE_URL, + ROBINHOOD_CHAIN_DOCS_URL, + ROBINHOOD_CHAIN_NETWORKS, + ROBINHOOD_CHAIN_STATUS_URL, + ROBINHOOD_CHAIN_TOKENS, + type RhNetworkId, +} from '../knowledge/robinhoodChain' +import { + getKnowledgeSection, + ROBINHOOD_CHAIN_KNOWLEDGE, + searchKnowledge, +} from '../knowledge/robinhoodChainDocs' +import type { AriaTool } from '../AriaTool' + +const KNOWLEDGE_TOPICS = ROBINHOOD_CHAIN_KNOWLEDGE.map((s) => s.topic) + +function parseNetwork(input: Record): RhNetworkId { + const value = String(input.network ?? 'mainnet') + if (value !== 'mainnet' && value !== 'testnet') { + throw new Error('network must be "mainnet" or "testnet".') + } + return value +} + +export const robinhoodChainTools: AriaTool[] = [ + { + name: 'rh_chain_info', + description: + 'Robinhood Chain network constants: chain IDs, RPC/sequencer-feed/explorer URLs for mainnet and testnet, bridge and status links. Bundled reference, no network call. Read-only.', + kind: 'read', + risk: 'read', + input: { type: 'object', properties: {} }, + async handler() { + return { + ok: true, + summary: 'Robinhood Chain network reference.', + data: { + networks: ROBINHOOD_CHAIN_NETWORKS, + docsUrl: ROBINHOOD_CHAIN_DOCS_URL, + statusUrl: ROBINHOOD_CHAIN_STATUS_URL, + canonicalBridgeUrl: ROBINHOOD_CHAIN_BRIDGE_URL, + chainlinkFeedsUrl: CHAINLINK_FEEDS_URL, + }, + } + }, + }, + { + name: 'rh_chain_knowledge', + description: + `Look up bundled Robinhood Chain documentation (synced from docs.robinhood.com/chain). Pass topic for one section (${KNOWLEDGE_TOPICS.join(', ')}), query for a keyword search, or neither to list all topics. Read-only.`, + kind: 'read', + risk: 'read', + input: { + type: 'object', + properties: { + topic: { type: 'string', enum: KNOWLEDGE_TOPICS, description: 'Exact section to fetch.' }, + query: { type: 'string', description: 'Keyword search across all sections.' }, + }, + }, + async handler(input) { + const topic = input.topic ? String(input.topic) : '' + if (topic) { + const section = getKnowledgeSection(topic) + if (!section) return { ok: false, summary: `Unknown topic "${topic}".` } + return { ok: true, summary: `Robinhood Chain docs: ${section.title}.`, data: section } + } + const query = input.query ? String(input.query) : '' + if (query) { + const sections = searchKnowledge(query) + if (sections.length === 0) return { ok: false, summary: `No Robinhood Chain docs match "${query}".` } + return { ok: true, summary: `${sections.length} Robinhood Chain docs section(s) match "${query}".`, data: sections } + } + const index = ROBINHOOD_CHAIN_KNOWLEDGE.map(({ topic: t, title, summary }) => ({ topic: t, title, summary })) + return { ok: true, summary: 'Robinhood Chain docs topics.', data: index } + }, + }, + { + name: 'rh_stock_tokens', + description: + 'Canonical Robinhood Chain token registry (mainnet): WETH, USDG, stock tokens, and tokenized ETFs with contract addresses. Optionally filter by symbol or kind (core|stock|etf). A same-ticker token at a different address is NOT canonical. Read-only.', + kind: 'read', + risk: 'read', + input: { + type: 'object', + properties: { + symbol: { type: 'string', description: 'Ticker filter, e.g. NVDA.' }, + kind: { type: 'string', enum: ['core', 'stock', 'etf'] }, + }, + }, + async handler(input) { + const symbol = String(input.symbol ?? '').trim().toUpperCase() + const kind = String(input.kind ?? '').trim() + let tokens = ROBINHOOD_CHAIN_TOKENS + if (symbol) tokens = tokens.filter((t) => t.symbol === symbol) + if (kind) tokens = tokens.filter((t) => t.kind === kind) + if (tokens.length === 0) { + return { ok: false, summary: `No canonical Robinhood Chain token matches ${symbol || kind}.` } + } + return { + ok: true, + summary: `${tokens.length} canonical Robinhood Chain token(s). Registry synced 2026-07-10 — verify new listings against docs.robinhood.com/chain/contracts.`, + data: tokens, + } + }, + }, + { + name: 'rh_chain_rpc', + description: + 'Live read-only Robinhood Chain RPC query via the public endpoint. action: status (chain id, block, gas price) | balance (ETH of address) | token (ERC-20 name/symbol/decimals/supply, plus holder balance when holder is set) | tx (transaction + receipt by txHash). Defaults to mainnet. Read-only, never signs or sends.', + kind: 'read', + risk: 'read', + input: { + type: 'object', + properties: { + action: { type: 'string', enum: ['status', 'balance', 'token', 'tx'] }, + network: { type: 'string', enum: ['mainnet', 'testnet'] }, + address: { type: 'string', description: '0x account address (balance action).' }, + token: { type: 'string', description: '0x token contract address (token action).' }, + holder: { type: 'string', description: 'Optional 0x holder for a token balance (token action).' }, + txHash: { type: 'string', description: '0x transaction hash (tx action).' }, + }, + required: ['action'], + }, + async handler(input) { + const network = parseNetwork(input) + const action = String(input.action ?? '') + switch (action) { + case 'status': { + const data = await Rh.getChainStatus(network) + return { ok: true, summary: `${data.network} at block ${data.blockNumber}, gas ${data.gasPriceGwei} gwei.`, data } + } + case 'balance': { + const address = String(input.address ?? '').trim() + if (!address) return { ok: false, summary: 'An address is required for the balance action.' } + const data = await Rh.getBalance(network, address) + return { ok: true, summary: `${data.eth} ETH at ${address} (${network}).`, data } + } + case 'token': { + const token = String(input.token ?? '').trim() + if (!token) return { ok: false, summary: 'A token address is required for the token action.' } + const holder = input.holder ? String(input.holder).trim() : undefined + const data = await Rh.getErc20Info(network, token, holder) + return { ok: true, summary: `${data.symbol || 'ERC-20'} (${data.name || token}) on ${network}.`, data } + } + case 'tx': { + const txHash = String(input.txHash ?? '').trim() + if (!txHash) return { ok: false, summary: 'A txHash is required for the tx action.' } + const data = await Rh.getTransaction(network, txHash) + return { ok: true, summary: `Transaction ${txHash} on ${network}.`, data } + } + default: + return { ok: false, summary: `Unknown action "${action}".` } + } + }, + }, +] diff --git a/test/services/RobinhoodChainTools.test.ts b/test/services/RobinhoodChainTools.test.ts new file mode 100644 index 00000000..da7daa35 --- /dev/null +++ b/test/services/RobinhoodChainTools.test.ts @@ -0,0 +1,223 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + formatUnits, + getBalance, + getChainStatus, + getErc20Info, + getTransaction, + isEvmAddress, + isTxHash, +} from '../../electron/services/RobinhoodChainService' +import { + getRhNetwork, + ROBINHOOD_CHAIN_NETWORKS, + ROBINHOOD_CHAIN_TOKENS, +} from '../../electron/services/aria/knowledge/robinhoodChain' +import { + getKnowledgeSection, + ROBINHOOD_CHAIN_KNOWLEDGE, + searchKnowledge, +} from '../../electron/services/aria/knowledge/robinhoodChainDocs' +import { robinhoodChainTools } from '../../electron/services/aria/tools/robinhoodChain' + +const NVDA = '0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC' +const HOLDER = '0x1111111111111111111111111111111111111111' +const TX_HASH = `0x${'ab'.repeat(32)}` + +const fetchMock = vi.fn() + +function rpcResult(result: unknown) { + return { ok: true, json: async () => ({ result }) } as Response +} + +/** ABI-encode a string return value (offset + length + padded utf8). */ +function abiString(value: string): string { + const bytes = Buffer.from(value, 'utf8').toString('hex') + const padded = bytes.padEnd(Math.ceil(bytes.length / 64) * 64, '0') + const length = value.length.toString(16).padStart(64, '0') + return `0x${'20'.padStart(64, '0')}${length}${padded}` +} + +function toolByName(name: string) { + const tool = robinhoodChainTools.find((t) => t.name === name) + if (!tool) throw new Error(`Tool ${name} not registered`) + return tool +} + +const context = { + sessionId: 'session-1', + snapshot: {} as never, + runUiEffect: vi.fn(), +} + +beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() + fetchMock.mockReset() +}) + +describe('robinhoodChain knowledge', () => { + it('bundles all 17 docs sections with source URLs', () => { + expect(ROBINHOOD_CHAIN_KNOWLEDGE).toHaveLength(17) + for (const section of ROBINHOOD_CHAIN_KNOWLEDGE) { + expect(section.sourceUrl).toMatch(/^https:\/\/docs\.robinhood\.com\/chain/) + expect(section.details.length).toBeGreaterThan(50) + } + }) + + it('exposes the network constants from the docs', () => { + expect(getRhNetwork('mainnet')).toMatchObject({ chainId: 4663, gasToken: 'ETH' }) + expect(getRhNetwork('testnet')).toMatchObject({ chainId: 46630 }) + expect(ROBINHOOD_CHAIN_NETWORKS.map((n) => n.id)).toEqual(['mainnet', 'testnet']) + }) + + it('looks up sections by topic and by keyword', () => { + expect(getKnowledgeSection('stock-tokens')?.details).toContain('ERC-8056') + expect(getKnowledgeSection('nope')).toBeUndefined() + const hits = searchKnowledge('uiMultiplier') + expect(hits.map((s) => s.topic)).toContain('building-with-stock-tokens') + expect(searchKnowledge('')).toEqual([]) + }) + + it('keeps the canonical token registry well-formed', () => { + for (const token of ROBINHOOD_CHAIN_TOKENS) { + expect(isEvmAddress(token.address)).toBe(true) + expect(['core', 'stock', 'etf']).toContain(token.kind) + } + expect(ROBINHOOD_CHAIN_TOKENS.find((t) => t.symbol === 'NVDA')?.address).toBe(NVDA) + }) +}) + +describe('robinhoodChain tools', () => { + it('registers four read-only tools', () => { + expect(robinhoodChainTools.map((t) => t.name)).toEqual([ + 'rh_chain_info', + 'rh_chain_knowledge', + 'rh_stock_tokens', + 'rh_chain_rpc', + ]) + for (const tool of robinhoodChainTools) { + expect(tool.risk).toBe('read') + expect(tool.kind).toBe('read') + } + }) + + it('rh_chain_info returns the bundled network reference', async () => { + const result = await toolByName('rh_chain_info').handler({}, context) + expect(result.ok).toBe(true) + expect(result.data).toMatchObject({ networks: ROBINHOOD_CHAIN_NETWORKS }) + }) + + it('rh_chain_knowledge serves topic, query, and index modes', async () => { + const tool = toolByName('rh_chain_knowledge') + const byTopic = await tool.handler({ topic: 'bridging' }, context) + expect(byTopic.ok).toBe(true) + expect((byTopic.data as { details: string }).details).toContain('7-day challenge period') + + const byQuery = await tool.handler({ query: 'sequencer uptime' }, context) + expect(byQuery.ok).toBe(true) + + const index = await tool.handler({}, context) + expect(index.ok).toBe(true) + expect(index.data).toHaveLength(ROBINHOOD_CHAIN_KNOWLEDGE.length) + + const miss = await tool.handler({ query: 'zzz-no-such-thing' }, context) + expect(miss.ok).toBe(false) + }) + + it('rh_stock_tokens filters by symbol and kind', async () => { + const tool = toolByName('rh_stock_tokens') + const nvda = await tool.handler({ symbol: 'nvda' }, context) + expect(nvda.ok).toBe(true) + expect(nvda.data).toEqual([{ symbol: 'NVDA', kind: 'stock', address: NVDA }]) + + const etfs = await tool.handler({ kind: 'etf' }, context) + expect(etfs.ok).toBe(true) + expect((etfs.data as unknown[]).length).toBe(5) + + const miss = await tool.handler({ symbol: 'DOGE' }, context) + expect(miss.ok).toBe(false) + }) + + it('rh_chain_rpc validates inputs before any network call', async () => { + const tool = toolByName('rh_chain_rpc') + expect((await tool.handler({ action: 'balance' }, context)).ok).toBe(false) + expect((await tool.handler({ action: 'token' }, context)).ok).toBe(false) + expect((await tool.handler({ action: 'tx' }, context)).ok).toBe(false) + expect((await tool.handler({ action: 'nope' }, context)).ok).toBe(false) + await expect(tool.handler({ action: 'status', network: 'devnet' }, context)).rejects.toThrow( + 'network must be "mainnet" or "testnet"', + ) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rh_chain_rpc status reads chain id, block, and gas price', async () => { + fetchMock + .mockResolvedValueOnce(rpcResult('0x1237')) // eth_chainId → 4663 + .mockResolvedValueOnce(rpcResult('0x10')) + .mockResolvedValueOnce(rpcResult('0x3b9aca00')) // 1 gwei + const result = await toolByName('rh_chain_rpc').handler({ action: 'status' }, context) + expect(result.ok).toBe(true) + expect(result.data).toMatchObject({ chainId: 4663, blockNumber: 16, gasPriceGwei: '1' }) + const firstCall = fetchMock.mock.calls[0] as [string, RequestInit] + expect(firstCall[0]).toBe('https://rpc.mainnet.chain.robinhood.com') + }) +}) + +describe('RobinhoodChainService', () => { + it('validates addresses and hashes', () => { + expect(isEvmAddress(NVDA)).toBe(true) + expect(isEvmAddress('0x123')).toBe(false) + expect(isTxHash(TX_HASH)).toBe(true) + expect(isTxHash(NVDA)).toBe(false) + }) + + it('formats units without float precision loss', () => { + expect(formatUnits(10n ** 18n, 18)).toBe('1') + expect(formatUnits(1500000000000000000n, 18)).toBe('1.5') + expect(formatUnits(1n, 18)).toBe('0.000000000000000001') + expect(formatUnits(0n, 18)).toBe('0') + }) + + it('getBalance rejects bad addresses and decodes wei', async () => { + await expect(getBalance('mainnet', 'bogus')).rejects.toThrow('not a valid 0x address') + fetchMock.mockResolvedValueOnce(rpcResult('0xde0b6b3a7640000')) // 1 ETH + const balance = await getBalance('mainnet', HOLDER) + expect(balance).toMatchObject({ eth: '1', wei: '1000000000000000000' }) + }) + + it('getErc20Info decodes metadata and holder balance', async () => { + fetchMock + .mockResolvedValueOnce(rpcResult(abiString('NVIDIA Stock Token'))) + .mockResolvedValueOnce(rpcResult(abiString('NVDA'))) + .mockResolvedValueOnce(rpcResult(`0x${(18).toString(16).padStart(64, '0')}`)) + .mockResolvedValueOnce(rpcResult(`0x${(10n ** 18n * 5n).toString(16).padStart(64, '0')}`)) + .mockResolvedValueOnce(rpcResult(`0x${(10n ** 18n * 2n).toString(16).padStart(64, '0')}`)) + const info = await getErc20Info('mainnet', NVDA, HOLDER) + expect(info).toMatchObject({ + name: 'NVIDIA Stock Token', + symbol: 'NVDA', + decimals: 18, + totalSupply: '5', + holder: { address: HOLDER, balance: '2' }, + }) + const balanceCall = JSON.parse((fetchMock.mock.calls[4] as [string, RequestInit])[1].body as string) + expect(balanceCall.params[0].data).toBe(`0x70a08231${HOLDER.slice(2).padStart(64, '0')}`) + }) + + it('surfaces RPC errors and missing transactions', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ error: { code: -32000, message: 'rate limited' } }), + } as Response) + await expect(getChainStatus('testnet')).rejects.toThrow('rate limited') + + fetchMock.mockReset() + fetchMock.mockResolvedValueOnce(rpcResult(null)).mockResolvedValueOnce(rpcResult(null)) + await expect(getTransaction('mainnet', TX_HASH)).rejects.toThrow('not found') + }) +}) From 9d929fdfd82d5c16aee1e40edfe6f46967bc891a Mon Sep 17 00:00:00 2001 From: nullxnothing Date: Fri, 10 Jul 2026 17:37:45 -0600 Subject: [PATCH 10/26] feat: DAEMON Lite standalone agent app Ship DAEMON Lite as a separate, small Windows download (~106MB) that is just the ARIA chatbox: paste one BYOK key and chat, no editor, terminal, or project system. Its own installer (com.daemon.lite), appId, and userData so it coexists with the full app. Built from one repo via a second Vite config and a module-swap plugin that redirects toolCatalog/contextAssembler/ProService/EmailTools/ forensics to .lite variants, keeping the heavy Solana/Monaco SDKs out of the Lite bundle. A computed dependency whitelist plus check-lite-deps gate the installer under 110MB and ban heavy SDKs from re-entering. Phase 0 seams keep the full app green: ariaHost port decouples the ARIA runtime from the IDE stores (IDE installs ideAriaHost, Lite a null-project host), and secure-key + planning tools are extracted to shared modules. v1.1 adds DAEMON-focused tools behind the same approval gate: - Wallet: read-only balance/holdings watch, optional wallet create - Trade: token search, watchlist, typed-confirm swaps through ARIA (capped) - Scanner: one-shot forensics rug check with an IDE upsell - Pop-out browser: main-process WebContentsView, https/loopback only, no guest preload Rename the in-IDE free tier display copy from "Light" to "Free" so the standalone app owns the Lite name. Note: shared preload/type surfaces also carry this branch's in-flight autopilot arm-review declarations; the Lite build and full test suite (1,186 tests) are green with them. --- .gitignore | 4 + README.md | 65 +++-- Whatsnew.md | 28 +- electron-builder.lite.cjs | 56 ++++ electron/ipc/claude.ts | 15 +- electron/ipc/forensics.lite.ts | 34 +++ electron/ipc/lite.ts | 74 +++++ electron/ipc/popout.ts | 51 ++++ electron/ipc/secureKeys.ts | 24 ++ electron/main/lite.ts | 245 +++++++++++++++++ electron/preload/index.ts | 24 +- electron/preload/popout.ts | 19 ++ electron/services/PopoutBrowserService.ts | 158 +++++++++++ electron/services/ProService.lite.ts | 31 +++ .../services/aria/contextAssembler.lite.ts | 55 ++++ electron/services/aria/planningTools.ts | 47 ++++ electron/services/aria/toolCatalog.lite.ts | 35 +++ electron/services/aria/toolCatalog.ts | 46 +--- electron/services/aria/tools/litePreview.ts | 43 +++ electron/services/aria/tools/liteTrade.ts | 128 +++++++++ electron/services/email/EmailTools.lite.ts | 11 + lite.html | 13 + package.json | 9 +- popout.html | 12 + scripts/check-lite-deps.mjs | 64 +++++ scripts/lite-deps.cjs | 109 ++++++++ scripts/smoke/lite-app-smoke.mjs | 131 +++++++++ src/lite/LiteApp.module.css | 43 +++ src/lite/LiteApp.tsx | 117 ++++++++ src/lite/LiteChat.module.css | 27 ++ src/lite/LiteChat.tsx | 40 +++ src/lite/LiteComposer.module.css | 86 ++++++ src/lite/LiteComposer.tsx | 63 +++++ src/lite/LiteHome.module.css | 105 +++++++ src/lite/LiteHome.tsx | 85 ++++++ src/lite/LiteOnboarding.module.css | 115 ++++++++ src/lite/LiteOnboarding.tsx | 102 +++++++ src/lite/LiteSettings.module.css | 157 +++++++++++ src/lite/LiteSettings.tsx | 121 +++++++++ src/lite/LiteSidebar.module.css | 257 ++++++++++++++++++ src/lite/LiteSidebar.tsx | 170 ++++++++++++ src/lite/components/LiteToolHeader.module.css | 39 +++ src/lite/components/LiteToolHeader.tsx | 24 ++ src/lite/main-lite.tsx | 108 ++++++++ src/lite/popout/PopoutChrome.module.css | 75 +++++ src/lite/popout/PopoutChrome.tsx | 90 ++++++ src/lite/popout/main-popout.tsx | 10 + src/lite/scanner/LiteScanner.module.css | 205 ++++++++++++++ src/lite/scanner/LiteScanner.tsx | 133 +++++++++ src/lite/trade/LiteTrade.module.css | 153 +++++++++++ src/lite/trade/LiteTrade.tsx | 132 +++++++++ src/lite/wallet/LiteWallet.module.css | 246 +++++++++++++++++ src/lite/wallet/LiteWallet.tsx | 168 ++++++++++++ src/main.tsx | 5 + .../plugins/Subscriptions/Subscriptions.tsx | 4 +- src/store/aria.ts | 33 +-- src/store/ariaHost.ts | 58 ++++ src/store/ariaHostIde.ts | 37 +++ src/types/daemon.d.ts | 26 +- test/panels/LiteShell.dom.test.tsx | 175 ++++++++++++ test/panels/SubscriptionsPanel.dom.test.tsx | 4 +- test/store/ariaHost.test.ts | 64 +++++ vite.lite.config.ts | 167 ++++++++++++ 63 files changed, 4859 insertions(+), 116 deletions(-) create mode 100644 electron-builder.lite.cjs create mode 100644 electron/ipc/forensics.lite.ts create mode 100644 electron/ipc/lite.ts create mode 100644 electron/ipc/popout.ts create mode 100644 electron/ipc/secureKeys.ts create mode 100644 electron/main/lite.ts create mode 100644 electron/preload/popout.ts create mode 100644 electron/services/PopoutBrowserService.ts create mode 100644 electron/services/ProService.lite.ts create mode 100644 electron/services/aria/contextAssembler.lite.ts create mode 100644 electron/services/aria/planningTools.ts create mode 100644 electron/services/aria/toolCatalog.lite.ts create mode 100644 electron/services/aria/tools/litePreview.ts create mode 100644 electron/services/aria/tools/liteTrade.ts create mode 100644 electron/services/email/EmailTools.lite.ts create mode 100644 lite.html create mode 100644 popout.html create mode 100644 scripts/check-lite-deps.mjs create mode 100644 scripts/lite-deps.cjs create mode 100644 scripts/smoke/lite-app-smoke.mjs create mode 100644 src/lite/LiteApp.module.css create mode 100644 src/lite/LiteApp.tsx create mode 100644 src/lite/LiteChat.module.css create mode 100644 src/lite/LiteChat.tsx create mode 100644 src/lite/LiteComposer.module.css create mode 100644 src/lite/LiteComposer.tsx create mode 100644 src/lite/LiteHome.module.css create mode 100644 src/lite/LiteHome.tsx create mode 100644 src/lite/LiteOnboarding.module.css create mode 100644 src/lite/LiteOnboarding.tsx create mode 100644 src/lite/LiteSettings.module.css create mode 100644 src/lite/LiteSettings.tsx create mode 100644 src/lite/LiteSidebar.module.css create mode 100644 src/lite/LiteSidebar.tsx create mode 100644 src/lite/components/LiteToolHeader.module.css create mode 100644 src/lite/components/LiteToolHeader.tsx create mode 100644 src/lite/main-lite.tsx create mode 100644 src/lite/popout/PopoutChrome.module.css create mode 100644 src/lite/popout/PopoutChrome.tsx create mode 100644 src/lite/popout/main-popout.tsx create mode 100644 src/lite/scanner/LiteScanner.module.css create mode 100644 src/lite/scanner/LiteScanner.tsx create mode 100644 src/lite/trade/LiteTrade.module.css create mode 100644 src/lite/trade/LiteTrade.tsx create mode 100644 src/lite/wallet/LiteWallet.module.css create mode 100644 src/lite/wallet/LiteWallet.tsx create mode 100644 src/store/ariaHost.ts create mode 100644 src/store/ariaHostIde.ts create mode 100644 test/panels/LiteShell.dom.test.tsx create mode 100644 test/store/ariaHost.test.ts create mode 100644 vite.lite.config.ts diff --git a/.gitignore b/.gitignore index 0798d510..f5a3abe9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,11 +10,14 @@ node_modules dist dist-ssr dist-electron +dist-electron-lite +dist-lite dist-cloud dist-bridge packages/bridge-shim/daemon-bridge-shim.mjs packages/bridge-shim/*.tgz release +release-lite *.tsbuildinfo *.local @@ -73,6 +76,7 @@ features/ # Internal dev artifacts .design/ /screenshots/ +/recordings/ NVIDIA Corporation/ .agents/ .codex-run/ diff --git a/README.md b/README.md index ad18c3c4..79e451ed 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@

DAEMON

-

An AI-native Solana development environment for agents, wallets, launches, deployments, and hosted DAEMON AI.

+

AI agents that work inside your Solana stack, under your authority.

+

Build, inspect, and operate from one local-first workbench. Writes, Git pushes, and fund movement stay behind explicit review.

@@ -8,7 +9,6 @@ Release Downloads License - Tests

@@ -25,20 +25,22 @@ ---

- $DAEMON CA: 4vpf4qNtNVkvz2dm5qL2mT6jBXH9gDY8qH2QsHN5pump + DAEMON 4.7 project templates with the ARIA console and guarded first-mission actions

---- +**[Frontier demo runbook](FRONTIER_SUBMISSION.md#2-minute-demo-runbook)** — 2-minute submission flow from project open to devnet settlement. -

- DAEMON agent workbench with editor, terminal, and sidebar -

+DAEMON is a standalone Electron workbench for serious Solana builders, auditors, and technical operators. It gives agents project context and typed tools without handing them silent authority over code, Git, keys, or funds. It is not a VS Code fork. -**[Frontier demo runbook](FRONTIER_SUBMISSION.md#2-minute-demo-runbook)** — 2-minute submission flow from project open to devnet settlement. +## How authority works + +1. **Inspect:** enabled read tools gather project, Git, wallet, and runtime context. +2. **Review:** writes pause for approval. Guarded sensitive flows use typed checks, and direct Autopilot arming ends in an OS-native review of every bound mainnet term. +3. **Verify:** diffs, test output, signatures, and explorer links keep results inspectable after execution. -DAEMON is a standalone Electron development environment for Solana builders who use AI agents to ship. It combines an offline editor, real PTY terminals, DAEMON AI, Claude/Codex agent spawning, MCP management, wallet/RPC readiness, token launches, deployments, integrations, and an Anchor-backed registry for publishing verifiable agent work receipts. Not a VS Code fork. +The free tier stays free for local work and bring-your-own-key AI. Advanced trading, launch, and hosted AI surfaces are optional capability packs, not prerequisites for the core build loop. -DAEMON Light stays free and useful for local work and bring-your-own-key AI. DAEMON Pro and holder access unlock hosted DAEMON AI, Pro Skills, Arena, MCP sync, priority workflows, and higher model lanes as they go live. +Just want the agent? **DAEMON Lite** is a separate, small (~106MB) Windows download: one chatbox, bring your own key, plus wallet, trade, and rug-scanner tools that route through the same approval gate. See [DAEMON Lite](#daemon-lite). ## Install @@ -83,27 +85,46 @@ pnpm run package Requires **Node.js 22+** and **pnpm 9+**. -## Features + -

- Editor with multiple tabs, breadcrumbs, and file tree -

+## DAEMON Lite + +DAEMON Lite is a separate, focused download for people who just want the agent. It ships the ARIA chatbox and nothing else from the IDE: no editor, no terminal, no project system. Paste one API key (Anthropic or GLM/Z.AI), and start chatting. Keys are encrypted with your OS keychain and never leave the device. + +It is its own installer (`com.daemon.lite`, separate userData), so it coexists with the full DAEMON on the same machine. At ~106MB it is roughly half the size of the full app. + +Beyond chat, Lite carries a small set of DAEMON-focused tools that route through the same approval gate as the full app: + +- **Wallet** — watch balances and holdings read-only; create a wallet if you want one. +- **Trade** — search tokens, keep a watchlist, and swap through ARIA. Swaps are typed-confirm and capped. +- **Scanner** — a one-shot rug check (mint/freeze authority, snipers, bundles, cabal links) with an upsell to the full cabal map in the IDE. +- **Pop-out browser** — a real browser pane for previews and dashboards, restricted to https and loopback. + +Build it from source: + +```bash +pnpm run package:lite # → release-lite//DAEMON-Lite-setup.exe +``` + +## Features **Editor** — Monaco running fully offline via a custom protocol handler. Multi-tab, breadcrumbs, syntax highlighting, Ctrl+S save. No CDN dependency. **Terminal** — Real PTY sessions powered by node-pty and xterm.js. Multiple tabs, split panes, command history search (Ctrl+R), tab-completion hints, and dedicated agent session management. -

- Agent launcher with model selection and MCP config -

- **Agent Launcher** — Spawn Claude Code agents with custom system prompts, model selection, and per-project MCP configurations. Agents run as real CLI sessions in dedicated terminal tabs. +**ARIA Game Studio Beta:** Create a playable local Phaser starter, install dependencies, verify a +production build, and open the preview inside DAEMON. With approval, ARIA can send one focused +`build_game` lane into a separate Git worktree. The starter has typed seams for future Solana +integration, but the beta uses local stubs. It does not connect a live wallet, write onchain, mint, +publish, or deploy. `deploy_app` opens the Deploy panel for a manual handoff. + **VS Code-style shell + capability packs** — Explorer, editor, a bottom-panel terminal, and the DAEMON Console on the right rail. Domain features ship as toggleable capability packs (Solana, Wallet, Launch, Agents, Memory, Sites, Markets, Create); disabling a pack quiesces its tools, integrations, sidebar icon, console commands, and background work — IPC handlers included. The Capability Manager shows how many packs are active and how much backend work is idle. **DAEMON Console (ARIA operator)** — The right-rail AI operator drives the whole IDE from natural language, chat-first with `>` and `/` command accelerators. Per-project chat sessions (new / switch / rename / archive / delete) with memory that survives restarts and compounds: the console proposes durable facts after real work (Keep/Dismiss), cites which taught facts a turn drew on, and strengthens proven facts over time. It runs DAEMON itself — agent wallets, token preflight/launch, Flywheel config, git — through a registry of typed tools with typed confirmation for sensitive on-chain actions (and a `[MAINNET]` guard). It never pushes to git autonomously. -**ARIA Autopilot** — Standing, structured trading mandates parsed from natural language and executed unattended on mainnet on a fixed cadence, with exit rules (take-profit / stop-loss / liquidity floor), a hard exposure cap, arm/disarm/kill-switch, and a "The Desk" panel showing live unrealized P&L and the action tape. Every tick claims its ledger row before it swaps, so a crash mid-tick is held for review, never replayed into a double-buy; a cluster switch auto-holds armed mandates; unattended slippage and price impact are capped tighter than a human-confirmed trade. +**ARIA Autopilot (experimental):** Bounded mainnet mandates run on a fixed cadence after a typed review of the wallet, mint, clip, exposure cap, slippage, and exits. The Desk shows estimated P&L and an action tape. Every tick claims its ledger row before it swaps, so a crash mid-tick is held for review, never replayed into a double-buy; a cluster switch auto-holds armed mandates; unattended slippage and price impact are capped tighter than a human-confirmed trade. Disarming stops future ticks, but a submitted swap may still settle. **Hyperliquid (via HypurrClaw)** — ARIA reads Hyperliquid markets and trades perps/spot by driving the agent-first `hyperliquid` CLI through a single execFile gate (no raw shell). Network defaults to testnet, DAEMON never holds a Hyperliquid key (the CLI's encrypted wallet signs), and every signing action stops for typed confirmation with an `[HL-MAINNET]`/`[HL-TESTNET]` marker. @@ -123,10 +144,6 @@ Requires **Node.js 22+** and **pnpm 9+**. **Wallet** — Live Solana portfolio tracking via Helius. SOL balance and SPL token holdings with USD values from Jupiter. -

- Wallet panel showing token balances -

- **Settings** — API keys encrypted via the OS keychain. MCP integrations, agent defaults, and display preferences. **Tools Browser** — Create, import, and run scripts (TypeScript, Python, Shell) with per-language execution. @@ -154,6 +171,8 @@ DAEMON AI is the hosted agent layer for project-aware chat, patch workflows, Sol Holder access starts with a simple rule: hold 1,000,000 $DAEMON to claim DAEMON Pro with included monthly AI usage. Higher holder tiers can unlock higher limits, discounts, badges, and early access later. Holder access does not mean unlimited AI usage. +**$DAEMON contract address:** `4vpf4qNtNVkvz2dm5qL2mT6jBXH9gDY8qH2QsHN5pump` + DAEMON also includes a Zauth integration surface for x402 database and Provider Hub management. Payment and entitlement enforcement should remain server-side through DAEMON AI Cloud and the relevant provider backends. ## Architecture diff --git a/Whatsnew.md b/Whatsnew.md index bdac8d07..e1575539 100644 --- a/Whatsnew.md +++ b/Whatsnew.md @@ -1,9 +1,19 @@ -# DAEMON v4.6 +# DAEMON v4.7 -DAEMON v4.6 turns the operator into a full trading and execution surface: unattended mandates, a second venue, a transparent fee line, and a bridge that lets external agents drive DAEMON's gated tools, all on top of the VS Code-style shell and capability packs introduced in v4.3. +DAEMON v4.7 adds ARIA Game Studio Beta: a local-first path from a playable Phaser starter to an +agent-assisted build and in-app preview. The starter runs locally. Its typed wallet, score, and +trophy interfaces use local stubs, so the beta makes no onchain calls. ## Highlights +- **ARIA Game Studio Beta:** Choose the game starter, install its dependencies, verify a production + build, and open the local preview in DAEMON. The starter includes typed seams for future Solana + integration backed by local stubs. +- **Focused agent build:** The approved `build_game` action runs one lane in a separate Git worktree + with Game Studio constraints supplied by DAEMON. Lane output still goes through project, branch, + and clean-worktree checks before merge. +- **Manual deploy handoff:** `deploy_app` opens the Deploy panel. The beta does not perform a live + wallet connection, onchain write, mint, publish, or deployment. - **DAEMON Console + capability packs** — a VS Code-style shell (explorer, editor, bottom terminal, right-rail console) with toggleable packs. Turn a pack off and its tools, integrations, and background work go quiet. - **ARIA Autopilot** — standing trading mandates parsed from natural language and run unattended on mainnet with exit rules, a hard exposure cap, and arm/disarm/kill switches. "The Desk" shows live unrealized P&L and the action tape. - **Hyperliquid via HypurrClaw** — ARIA reads Hyperliquid markets and trades perps/spot through the agent-first CLI. Testnet by default; DAEMON never holds a Hyperliquid key. @@ -13,8 +23,20 @@ DAEMON v4.6 turns the operator into a full trading and execution surface: unatte - **Agent economy control tower** — track agent-routed execution, fees, and paid-resource activity in one panel. - **Venum** — a first-class Solana execution provider in the Markets pack (live/batch prices, ranked swap quotes). +## DAEMON Lite + +- **A separate, small download that is just the agent.** DAEMON Lite ships the ARIA chatbox on its own — no editor, terminal, or project system. Paste one key (Anthropic or GLM/Z.AI) and chat. Keys are encrypted with the OS keychain and stay on the device. +- **Coexists with the full app.** Its own installer, appId, and userData, at roughly half the size (~106MB). Install both side by side. +- **DAEMON-focused tools, same gate.** A collapsible Tools section adds Wallet (read-only watch), Trade (token search, watchlist, and typed-confirm swaps through ARIA with a hard cap), and Scanner (one-shot rug check on mint/freeze authority, snipers, bundles, and cabal links). Every write and swap runs through the same approval gate as the full app. +- **Pop-out browser.** A real browser pane for previews and dashboards, restricted to https and loopback URLs, owned by the main process with no preload on the guest page. +- **Beginner-first onboarding.** One screen, bring-your-own-key, with an "Open in DAEMON IDE" handoff when you outgrow the chatbox. + ## Hardening +- Game starter files and prompts redact RPC and credential-bearing URLs. Project names are strict, + and scaffolding requires a target folder that does not already exist. +- The generated lockfile is committed only after install, production build verification, and a + real local preview listener. Git push blocking applies only to the build lane's worktree. - Autopilot ticks claim their ledger row before swapping, so a crash mid-tick is held for review rather than replayed into a double-buy; a cluster switch auto-holds armed mandates; unattended slippage and price impact are capped tighter than a human-confirmed trade. - Swap price impact is normalized to a single unit end to end, so ordinary low-impact swaps are never spuriously blocked. - ARIA streamed events are tagged per session so approval cards can never attach to the wrong conversation; the approval-resolution channels reject untrusted senders. @@ -32,3 +54,5 @@ DAEMON v4.6 turns the operator into a full trading and execution surface: unatte - `pnpm run typecheck && pnpm run test && pnpm run build` - `pnpm run lint:styles` +- `pnpm run test:ci` +- Packaged Windows executable exercised through the Game Studio desktop/mobile smoke flow. diff --git a/electron-builder.lite.cjs b/electron-builder.lite.cjs new file mode 100644 index 00000000..1318f04e --- /dev/null +++ b/electron-builder.lite.cjs @@ -0,0 +1,56 @@ +/** + * DAEMON Lite packaging — separate installer that coexists with full DAEMON. + * files is a computed WHITELIST (see scripts/lite-deps.cjs): only the runtime + * dependency closure of the built lite bundles ships. No publish block and no + * auto-update in v1 — the two apps must never cross-update. + */ +const { liteExcludePatterns } = require('./scripts/lite-deps.cjs') + +module.exports = { + appId: 'com.daemon.lite', + productName: 'DAEMON Lite', + asar: true, + npmRebuild: false, + compression: 'maximum', + directories: { + output: 'release-lite/${version}', + }, + extraMetadata: { + name: 'daemon-lite', + main: 'dist-electron-lite/main/lite.js', + }, + files: [ + 'dist-electron-lite/**', + 'dist-lite/**', + // electron-builder auto-collects the app's full prod dependency tree; + // negate everything the lite bundles never import (computed complement). + ...liteExcludePatterns(), + '!node_modules/@types/**', + // Keep better-sqlite3's built binary, drop its sources and vendored deps. + '!node_modules/better-sqlite3/{deps,src}/**', + '!node_modules/better-sqlite3/build/Release/{obj,sqlite3.a,test_extension.node}', + '!**/*.map', + ], + electronLanguages: ['en-US'], + asarUnpack: [ + 'node_modules/better-sqlite3/**', + ], + win: { + icon: 'resources/icon.ico', + target: [ + { + target: 'nsis', + arch: ['x64'], + }, + ], + artifactName: 'DAEMON-Lite-setup.${ext}', + }, + nsis: { + oneClick: false, + perMachine: false, + allowToChangeInstallationDirectory: true, + deleteAppDataOnUninstall: false, + createDesktopShortcut: true, + createStartMenuShortcut: true, + }, +} diff --git a/electron/ipc/claude.ts b/electron/ipc/claude.ts index 75e9d3b3..f4d5c406 100644 --- a/electron/ipc/claude.ts +++ b/electron/ipc/claude.ts @@ -15,6 +15,7 @@ import { broadcast } from '../services/EventBus' import { getDb } from '../db/db' import { isPathSafe } from '../shared/pathValidation' import { ipcHandler, withValidation } from '../services/IpcHandlerFactory' +import { registerSecureKeyHandlers } from './secureKeys' import { restartProviderInPty, restartAllProviderSessions } from '../shared/providerRestart' import type { McpAddInput } from '../shared/types' @@ -162,19 +163,9 @@ ${content}`, return tidied.replace(/^```(?:markdown|md)?\s*\n?/, '').replace(/\n?```\s*$/, '') })) - // --- Secure Keys --- + // --- Secure Keys (extracted; Lite registers them without this module) --- - ipcMain.handle('claude:store-key', ipcHandler(async (_event, name: string, value: string) => { - SecureKey.storeKey(name, value) - })) - - ipcMain.handle('claude:list-keys', ipcHandler(async () => { - return SecureKey.listKeys() - })) - - ipcMain.handle('claude:delete-key', ipcHandler(async (_event, name: string) => { - SecureKey.deleteKey(name) - })) + registerSecureKeyHandlers() // --- CLAUDE.md --- diff --git a/electron/ipc/forensics.lite.ts b/electron/ipc/forensics.lite.ts new file mode 100644 index 00000000..d54b0a44 --- /dev/null +++ b/electron/ipc/forensics.lite.ts @@ -0,0 +1,34 @@ +/** + * DAEMON Lite forensics IPC — swapped in for forensics.ts by vite.lite.config.ts. + * Registers scan/expand/blacklist/poll only. Drops the RicoMaps embed handlers + * (RicoMapsEmbedService spawns a Node dev-server child process — out of scope + * for Lite, and it keeps that import off the lite main graph). + */ +import { clipboard, ipcMain } from 'electron' +import { ipcHandler } from '../services/IpcHandlerFactory' +import * as RicoMapsService from '../services/RicoMapsService' +import type { ForensicsExpandInput, ForensicsScanInput } from '../shared/types' + +export function registerForensicsHandlers() { + ipcMain.handle('forensics:scan', ipcHandler(async (_event, input: ForensicsScanInput) => { + return RicoMapsService.scan(input) + })) + + ipcMain.handle('forensics:expand', ipcHandler(async (_event, input: ForensicsExpandInput) => { + return RicoMapsService.expandNode(input) + })) + + ipcMain.handle('forensics:blacklist', ipcHandler(async () => { + return RicoMapsService.listBlacklist() + })) + + ipcMain.handle('forensics:export-blacklist', ipcHandler(async () => { + const csv = RicoMapsService.exportBlacklistCsv() + clipboard.writeText(csv) + return { csv, copied: true } + })) + + ipcMain.handle('forensics:poll-holders', ipcHandler(async (_event, mint: string) => { + return RicoMapsService.pollHolders(mint) + })) +} diff --git a/electron/ipc/lite.ts b/electron/ipc/lite.ts new file mode 100644 index 00000000..cbb016cb --- /dev/null +++ b/electron/ipc/lite.ts @@ -0,0 +1,74 @@ +/** + * DAEMON Lite IPC — flavor info, first-run flag, and the "Open in DAEMON IDE" + * handoff. Registered only by the Lite main entry (electron/main/lite.ts). + */ +import { ipcMain, app } from 'electron' +import { spawn } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { createRequire } from 'node:module' +import { ipcHandler } from '../services/IpcHandlerFactory' +import { getBooleanSetting, setBooleanSetting } from '../services/SettingsService' +import { openSafeExternalUrl } from '../security/externalNavigation' + +const IDE_DOWNLOAD_URL = 'https://daemon-landing.vercel.app' +const LITE_ONBOARDING_KEY = 'lite_onboarding_complete' +const LITE_SHOW_TOOLS_KEY = 'lite_show_tools' + +/** Packaged: the installer's version. Dev: app.getVersion() is Electron's own + * version, so read the repo package.json instead. */ +function appVersion(): string { + if (app.isPackaged) return app.getVersion() + try { + const require = createRequire(import.meta.url) + return (require(path.join(process.env.APP_ROOT ?? '', 'package.json')) as { version: string }).version + } catch { + return app.getVersion() + } +} + +/** Full-DAEMON NSIS per-user install location; null when not installed. */ +function fullIdeExePath(): string | null { + const base = process.env.LOCALAPPDATA + if (!base) return null + const exe = path.join(base, 'Programs', 'DAEMON', 'DAEMON.exe') + return fs.existsSync(exe) ? exe : null +} + +export function registerLiteHandlers() { + ipcMain.handle('lite:get-flavor-info', ipcHandler(async () => ({ + flavor: 'lite' as const, + version: appVersion(), + ideInstalled: fullIdeExePath() !== null, + }))) + + ipcMain.handle('lite:is-onboarding-complete', ipcHandler(async () => { + return getBooleanSetting(LITE_ONBOARDING_KEY, false) + })) + + ipcMain.handle('lite:set-onboarding-complete', ipcHandler(async (_event, complete: boolean) => { + setBooleanSetting(LITE_ONBOARDING_KEY, Boolean(complete)) + })) + + // "Tools" section (wallet / trade / scanner) — off by default so a fresh + // install stays a plain chatbox until the user (or an agent tool) opts in. + ipcMain.handle('lite:get-show-tools', ipcHandler(async () => { + return getBooleanSetting(LITE_SHOW_TOOLS_KEY, false) + })) + + ipcMain.handle('lite:set-show-tools', ipcHandler(async (_event, show: boolean) => { + setBooleanSetting(LITE_SHOW_TOOLS_KEY, Boolean(show)) + })) + + // Launch the full IDE when installed; otherwise open the download page. + ipcMain.handle('lite:open-in-ide', ipcHandler(async () => { + const exe = fullIdeExePath() + if (exe) { + const child = spawn(exe, [], { detached: true, stdio: 'ignore' }) + child.unref() + return { launched: true } + } + await openSafeExternalUrl(IDE_DOWNLOAD_URL) + return { launched: false } + })) +} diff --git a/electron/ipc/popout.ts b/electron/ipc/popout.ts new file mode 100644 index 00000000..1f4456f6 --- /dev/null +++ b/electron/ipc/popout.ts @@ -0,0 +1,51 @@ +/** + * DAEMON Lite pop-out browser IPC. lite:popout-open is called from the main + * renderer (trusted). The popout:* nav channels are called from each pop-out's + * own chrome renderer via the minimal popout preload; they carry the window id + * so a chrome window can only drive its own guest. + */ +import { BrowserWindow, ipcMain } from 'electron' +import type { WebContents } from 'electron' +import { ipcHandler } from '../services/IpcHandlerFactory' +import { isTrustedSender } from '../security/ipcSender' +import { + openPopout, popoutNavigate, popoutBack, popoutForward, popoutReload, +} from '../services/PopoutBrowserService' + +function ownerWindowId(sender: WebContents): number | undefined { + return BrowserWindow.fromWebContents(sender)?.id +} + +export function registerPopoutHandlers() { + ipcMain.handle('lite:popout-open', ipcHandler(async (event, url: string) => { + if (!isTrustedSender(event)) return { opened: false } + const result = openPopout(url) + return { opened: result.opened } + })) + + // Nav channels come from the chrome renderer (its own trusted origin); each + // scopes to the sender window's id so it can only steer its own guest. + ipcMain.handle('popout:navigate', ipcHandler(async (event, url: string) => { + if (!isTrustedSender(event)) return false + const windowId = ownerWindowId(event.sender) + return windowId !== undefined ? popoutNavigate(windowId, url) : false + })) + + ipcMain.on('popout:back', (event) => { + if (!isTrustedSender(event)) return + const windowId = ownerWindowId(event.sender) + if (windowId !== undefined) popoutBack(windowId) + }) + + ipcMain.on('popout:forward', (event) => { + if (!isTrustedSender(event)) return + const windowId = ownerWindowId(event.sender) + if (windowId !== undefined) popoutForward(windowId) + }) + + ipcMain.on('popout:reload', (event) => { + if (!isTrustedSender(event)) return + const windowId = ownerWindowId(event.sender) + if (windowId !== undefined) popoutReload(windowId) + }) +} diff --git a/electron/ipc/secureKeys.ts b/electron/ipc/secureKeys.ts new file mode 100644 index 00000000..b491aa3c --- /dev/null +++ b/electron/ipc/secureKeys.ts @@ -0,0 +1,24 @@ +/** + * Secure-key IPC — OS-keychain-encrypted key storage (SecureKeyService). + * Extracted from claude.ts so shells that need key management without the + * Claude-CLI machinery (DAEMON Lite) can register just this surface. Channel + * names keep their historical claude: prefix — the preload bridge and every + * renderer call site depend on them. + */ +import { ipcMain } from 'electron' +import * as SecureKey from '../services/SecureKeyService' +import { ipcHandler } from '../services/IpcHandlerFactory' + +export function registerSecureKeyHandlers() { + ipcMain.handle('claude:store-key', ipcHandler(async (_event, name: string, value: string) => { + SecureKey.storeKey(name, value) + })) + + ipcMain.handle('claude:list-keys', ipcHandler(async () => { + return SecureKey.listKeys() + })) + + ipcMain.handle('claude:delete-key', ipcHandler(async (_event, name: string) => { + SecureKey.deleteKey(name) + })) +} diff --git a/electron/main/lite.ts b/electron/main/lite.ts new file mode 100644 index 00000000..d74a488b --- /dev/null +++ b/electron/main/lite.ts @@ -0,0 +1,245 @@ +/** + * DAEMON Lite main entry — a chat-only agent shell. Deliberately written + * fresh instead of forking main/index.ts: one window, the minimal IPC surface + * (aria, provider, memory, secure keys, lite), separate userData, no editor, + * terminals, packs, protocols, schedulers, bridge, or auto-update. + */ +import 'dotenv/config' +import { app, BrowserWindow, ipcMain, session } from 'electron' +import { fileURLToPath } from 'node:url' +import path from 'node:path' +import crypto from 'node:crypto' +import { getDb, closeDb } from '../db/db' +import { registerAriaHandlers } from '../ipc/aria' +import { registerProviderHandlers } from '../ipc/provider' +import { registerMemoryHandlers } from '../ipc/memory' +import { registerSecureKeyHandlers } from '../ipc/secureKeys' +import { registerLiteHandlers } from '../ipc/lite' +import { registerWalletHandlers } from '../ipc/wallet' +import { registerPnlHandlers } from '../ipc/pnl' +import { registerForensicsHandlers } from '../ipc/forensics' +import { registerPopoutHandlers } from '../ipc/popout' +import { configurePopoutBrowser, closeAllPopouts } from '../services/PopoutBrowserService' +import { ClaudeProvider, CodexProvider, ProviderRegistry } from '../services/providers' +import { getKeyEncryptionWarning, getStorageBackend } from '../services/SecureKeyService' +import { isSafeExternalUrl, openSafeExternalUrl } from '../security/externalNavigation' +import { isTrustedSender, setTrustedIpcOrigin } from '../security/ipcSender' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +process.env.DAEMON_FLAVOR = 'lite' +process.env.APP_ROOT = path.join(__dirname, '../..') +const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist-lite') +const VITE_DEV_SERVER_URL = app.isPackaged ? undefined : process.env.VITE_DEV_SERVER_URL +const SMOKE_TEST_MODE = process.env.DAEMON_SMOKE_TEST === '1' + +process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL + ? path.join(process.env.APP_ROOT, 'public') + : RENDERER_DIST + +// Lite owns its own userData so both apps install and run side by side. +// The smoke-test override mirrors main/index.ts. +app.setPath( + 'userData', + process.env.DAEMON_USER_DATA_DIR ?? path.join(app.getPath('appData'), 'daemon-lite'), +) + +if (SMOKE_TEST_MODE) { + app.commandLine.appendSwitch('remote-debugging-port', process.env.DAEMON_SMOKE_CDP_PORT ?? '9333') +} else if (!app.isPackaged) { + app.commandLine.appendSwitch('remote-debugging-port', process.env.DAEMON_DEV_CDP_PORT ?? '9224') +} + +if (process.platform === 'win32') app.setAppUserModelId('com.daemon.lite') + +function recordAppCrash(type: string, message: string, stack = '') { + try { + const db = getDb() + db.prepare('INSERT INTO app_crashes (id, type, message, stack, created_at) VALUES (?,?,?,?,?)').run( + crypto.randomUUID(), type, message, stack, Date.now() + ) + } catch { /* DB may not be ready */ } +} + +process.on('uncaughtException', (error) => { + recordAppCrash('uncaughtException', error.message, error.stack ?? '') +}) + +process.on('unhandledRejection', (reason) => { + const message = reason instanceof Error ? reason.message : String(reason) + const stack = reason instanceof Error ? reason.stack ?? '' : '' + recordAppCrash('unhandledRejection', message, stack) +}) + +if (!SMOKE_TEST_MODE && !app.requestSingleInstanceLock()) { + app.quit() + process.exit(0) +} + +let win: BrowserWindow | null = null +let ipcRegistered = false +let shutdownStarted = false +const preload = path.join(__dirname, '../preload/index.mjs') +const popoutPreload = path.join(__dirname, '../preload/popout.mjs') +const liteHtml = path.join(RENDERER_DIST, 'lite.html') + +function popoutChromeUrl(): string { + if (VITE_DEV_SERVER_URL) return new URL('popout.html', VITE_DEV_SERVER_URL).toString() + return `file://${path.join(RENDERER_DIST, 'popout.html')}` +} + +function beginShutdownCleanup() { + if (shutdownStarted) return + shutdownStarted = true + closeAllPopouts() + closeDb() +} + +function registerLiteIpc() { + if (ipcRegistered) return + ipcRegistered = true + + ProviderRegistry.register(ClaudeProvider) + ProviderRegistry.register(CodexProvider) + + registerProviderHandlers() + registerSecureKeyHandlers() + registerAriaHandlers() + registerMemoryHandlers() + registerLiteHandlers() + registerWalletHandlers() + registerPnlHandlers() + registerForensicsHandlers() + registerPopoutHandlers() + + configurePopoutBrowser({ preloadPath: popoutPreload, chromeUrl: () => popoutChromeUrl() }) + + ipcMain.handle('shell:open-external', async (event, url: string) => { + if (!isTrustedSender(event)) return + await openSafeExternalUrl(url) + }) +} + +async function createWindow() { + if (SMOKE_TEST_MODE) console.log('[smoke] createWindow:start') + setTrustedIpcOrigin(VITE_DEV_SERVER_URL ? new URL(VITE_DEV_SERVER_URL).origin : 'file://') + registerLiteIpc() + + // Tight CSP in production — the Lite renderer talks only over IPC; all model + // API calls happen in the main process. Dev needs Vite HMR, so skip there. + if (!VITE_DEV_SERVER_URL) { + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + callback({ + responseHeaders: { + ...details.responseHeaders, + 'Content-Security-Policy': ["default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'"], + }, + }) + }) + } + + win = new BrowserWindow({ + title: 'DAEMON Lite', + width: 1100, + height: 760, + minWidth: 900, + minHeight: 620, + show: false, + autoHideMenuBar: true, + // Cursor-style chrome: hidden native titlebar with dark overlay controls; + // the renderer draws a draggable strip that blends into the app. + titleBarStyle: 'hidden', + titleBarOverlay: { + color: '#0c0e0d', + symbolColor: '#9fa19d', + height: 34, + }, + backgroundColor: '#0c0e0d', + icon: path.join(process.env.VITE_PUBLIC, 'daemon-icon.png'), + webPreferences: { + preload, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }) + + win.once('ready-to-show', () => { + if (SMOKE_TEST_MODE) console.log('[smoke] createWindow:show:ready-to-show') + win?.show() + }) + + if (VITE_DEV_SERVER_URL) { + win.loadURL(new URL('lite.html', VITE_DEV_SERVER_URL).toString()) + if (process.env.DAEMON_OPEN_DEVTOOLS === '1') { + win.webContents.openDevTools() + } + } else { + win.loadFile(liteHtml) + } + + win.webContents.setWindowOpenHandler(({ url }) => { + if (isSafeExternalUrl(url)) void openSafeExternalUrl(url) + return { action: 'deny' } + }) + + // Block navigation away from the app origin. + win.webContents.on('will-navigate', (event, url) => { + const appOrigin = VITE_DEV_SERVER_URL ? new URL(VITE_DEV_SERVER_URL).origin : 'file://' + if (new URL(url).origin !== appOrigin) event.preventDefault() + }) + + win.webContents.on('render-process-gone', (_event, details) => { + recordAppCrash('render-process-gone', JSON.stringify(details)) + }) + if (SMOKE_TEST_MODE) { + win.webContents.on('did-finish-load', () => console.log('[smoke] createWindow:did-finish-load')) + } +} + +app.whenReady().then(() => { + if (SMOKE_TEST_MODE) console.log('[smoke] app:ready') + getDb() + + const keyEncryptionWarning = getKeyEncryptionWarning() + if (keyEncryptionWarning) { + console.warn('[secure-key]', keyEncryptionWarning, '(backend:', getStorageBackend(), ')') + recordAppCrash('key-encryption-degraded', keyEncryptionWarning, `backend=${getStorageBackend() ?? 'n/a'}`) + } + + void createWindow() +}) + +app.on('before-quit', () => { + beginShutdownCleanup() +}) + +app.on('window-all-closed', () => { + beginShutdownCleanup() + win = null + app.quit() +}) + +app.on('second-instance', () => { + if (!win || win.isDestroyed()) return + if (win.isMinimized()) win.restore() + win.focus() +}) + +app.on('activate', () => { + if (shutdownStarted) return + const allWindows = BrowserWindow.getAllWindows() + if (allWindows.length) { + allWindows[0].focus() + } else { + void createWindow() + } +}) + +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.once(signal, () => { + beginShutdownCleanup() + app.quit() + process.exit(0) + }) +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index c670f96a..ea716cca 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -529,7 +529,7 @@ contextBridge.exposeInMainWorld('daemon', { projects: { list: () => ipcRenderer.invoke('projects:list'), - create: (project: { name: string; path: string }) => ipcRenderer.invoke('projects:create', project), + create: (project: { name: string; path: string; requireNewDirectory?: boolean }) => ipcRenderer.invoke('projects:create', project), createDemoWorkspace: () => ipcRenderer.invoke('projects:createDemoWorkspace'), delete: (id: string) => ipcRenderer.invoke('projects:delete', id), openDialog: () => ipcRenderer.invoke('projects:openDialog'), @@ -540,6 +540,17 @@ contextBridge.exposeInMainWorld('daemon', { openExternal: (url: string) => ipcRenderer.invoke('shell:open-external', url), }, + // DAEMON Lite flavor surface — handlers exist only in the Lite main entry. + lite: { + getFlavorInfo: () => ipcRenderer.invoke('lite:get-flavor-info'), + isOnboardingComplete: () => ipcRenderer.invoke('lite:is-onboarding-complete'), + setOnboardingComplete: (complete: boolean) => ipcRenderer.invoke('lite:set-onboarding-complete', complete), + getShowTools: () => ipcRenderer.invoke('lite:get-show-tools'), + setShowTools: (show: boolean) => ipcRenderer.invoke('lite:set-show-tools', show), + openInIde: () => ipcRenderer.invoke('lite:open-in-ide'), + popoutOpen: (url: string) => ipcRenderer.invoke('lite:popout-open', url), + }, + pumpfun: { bondingCurve: (mint: string) => ipcRenderer.invoke('pumpfun:bonding-curve', mint), createToken: (input: object) => ipcRenderer.invoke('pumpfun:create-token', input), @@ -691,7 +702,8 @@ contextBridge.exposeInMainWorld('daemon', { autopilot: { state: () => ipcRenderer.invoke('autopilot:state'), create: (input: unknown) => ipcRenderer.invoke('autopilot:create', input), - arm: (id: string) => ipcRenderer.invoke('autopilot:arm', id), + armReview: (id: string) => ipcRenderer.invoke('autopilot:arm-review', id), + arm: (input: unknown) => ipcRenderer.invoke('autopilot:arm', input), disarm: (id: string) => ipcRenderer.invoke('autopilot:disarm', id), disarmAll: () => ipcRenderer.invoke('autopilot:disarm-all'), delete: (id: string) => ipcRenderer.invoke('autopilot:delete', id), @@ -998,7 +1010,7 @@ function useLoading() { flex-direction: column; align-items: center; justify-content: center; - background: #0a0a0a; + background: #0c0e0d; z-index: 9; gap: 28px; transition: opacity 0.4s ease, visibility 0.4s ease; @@ -1035,11 +1047,11 @@ function useLoading() { gap: 2px; } .daemon-loading__letter { - font-family: 'Plus Jakarta Sans', system-ui, sans-serif; + font-family: 'Geist', 'Plus Jakarta Sans', system-ui, sans-serif; font-size: 22px; font-weight: 700; letter-spacing: 0.12em; - color: #f0f0f0; + color: #eceee9; display: inline-block; animation: dl-pulse 2.8s ease-in-out infinite; } @@ -1075,7 +1087,7 @@ function useLoading() { to { transform: rotate(360deg); } } @keyframes dl-pulse { - 0%, 60%, 100% { color: #f0f0f0; text-shadow: none; } + 0%, 60%, 100% { color: #eceee9; text-shadow: none; } 30% { color: #3ecf8e; text-shadow: 0 0 12px rgba(62,207,142,0.5); } } @keyframes dl-sweep { diff --git a/electron/preload/popout.ts b/electron/preload/popout.ts new file mode 100644 index 00000000..199b3c75 --- /dev/null +++ b/electron/preload/popout.ts @@ -0,0 +1,19 @@ +/** + * Minimal preload for the DAEMON Lite pop-out chrome window. Exposes ONLY the + * navigation channels for its own guest view — deliberately NOT the full + * window.daemon.* surface (the chrome strip has no business reaching wallet, + * keys, or the agent). isTrustedSender in main is the enforcement boundary. + */ +import { contextBridge, ipcRenderer } from 'electron' + +contextBridge.exposeInMainWorld('daemonPopout', { + navigate: (url: string) => ipcRenderer.invoke('popout:navigate', url), + back: () => ipcRenderer.send('popout:back'), + forward: () => ipcRenderer.send('popout:forward'), + reload: () => ipcRenderer.send('popout:reload'), + onNavState: (handler: (state: unknown) => void) => { + const listener = (_event: unknown, state: unknown) => handler(state) + ipcRenderer.on('popout:nav-state', listener) + return () => ipcRenderer.removeListener('popout:nav-state', listener) + }, +}) diff --git a/electron/services/PopoutBrowserService.ts b/electron/services/PopoutBrowserService.ts new file mode 100644 index 00000000..62836f8b --- /dev/null +++ b/electron/services/PopoutBrowserService.ts @@ -0,0 +1,158 @@ +/** + * DAEMON Lite pop-out preview browser. A child BrowserWindow renders a thin + * chrome strip (URL bar + back/forward/reload) from popout.html; the page it + * previews loads in a main-process-owned WebContentsView guest that gets NO + * preload and NO node access. Every navigation is validated against + * isAllowedWebviewUrl (https or loopback http only), so the agent/user can + * never point a preview at cleartext-remote or credentialed URLs. + */ +import { BrowserWindow, WebContentsView } from 'electron' +import path from 'node:path' +import { isAllowedWebviewUrl, openSafeExternalUrl } from '../security/externalNavigation' + +const CHROME_HEIGHT = 88 // titlebar (34) + nav strip (54) +const GUEST_PARTITION = 'persist:lite-popout' +const MAX_POPOUTS = 4 + +interface Popout { + window: BrowserWindow + guest: WebContentsView +} + +const popouts = new Set() + +interface PopoutDeps { + preloadPath: string + chromeUrl: (id: number) => string +} + +let deps: PopoutDeps | null = null + +export function configurePopoutBrowser(next: PopoutDeps): void { + deps = next +} + +function layoutGuest(popout: Popout): void { + const [width, height] = popout.window.getContentSize() + popout.guest.setBounds({ x: 0, y: CHROME_HEIGHT, width, height: Math.max(0, height - CHROME_HEIGHT) }) +} + +function sendNavState(popout: Popout): void { + const wc = popout.guest.webContents + if (popout.window.isDestroyed()) return + popout.window.webContents.send('popout:nav-state', { + url: wc.getURL(), + canGoBack: wc.navigationHistory.canGoBack(), + canGoForward: wc.navigationHistory.canGoForward(), + loading: wc.isLoading(), + }) +} + +/** Open (or focus a fresh) preview window at the given allowlisted URL. */ +export function openPopout(url: string): { opened: boolean; reason?: string } { + if (!deps) return { opened: false, reason: 'popout browser not configured' } + if (!isAllowedWebviewUrl(url)) return { opened: false, reason: 'URL not allowed (https or localhost only)' } + + // LRU cap: close the oldest when at capacity. + if (popouts.size >= MAX_POPOUTS) { + const oldest = popouts.values().next().value + if (oldest && !oldest.window.isDestroyed()) oldest.window.close() + } + + const window = new BrowserWindow({ + width: 1024, + height: 768, + minWidth: 480, + minHeight: 360, + title: 'Preview', + backgroundColor: '#0c0e0d', + titleBarStyle: 'hidden', + titleBarOverlay: { color: '#0c0e0d', symbolColor: '#9fa19d', height: 34 }, + webPreferences: { + preload: deps.preloadPath, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }) + + const guest = new WebContentsView({ + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + // No preload — the guest is untrusted web content. + partition: GUEST_PARTITION, + }, + }) + + const popout: Popout = { window, guest } + popouts.add(popout) + + window.contentView.addChildView(guest) + layoutGuest(popout) + + const gwc = guest.webContents + + // Block any navigation to a non-allowlisted URL; hand https off to the OS + // browser instead of silently dropping it. + gwc.on('will-navigate', (event, target) => { + if (!isAllowedWebviewUrl(target)) { + event.preventDefault() + void openSafeExternalUrl(target) + } + }) + gwc.setWindowOpenHandler(({ url: target }) => { + void openSafeExternalUrl(target) + return { action: 'deny' } + }) + gwc.session.on('will-download', (event) => event.preventDefault()) + + gwc.on('did-navigate', () => sendNavState(popout)) + gwc.on('did-navigate-in-page', () => sendNavState(popout)) + gwc.on('did-start-loading', () => sendNavState(popout)) + gwc.on('did-stop-loading', () => sendNavState(popout)) + + window.on('resize', () => layoutGuest(popout)) + window.on('closed', () => { + popouts.delete(popout) + }) + + window.loadURL(deps.chromeUrl(window.id)) + void gwc.loadURL(url) + + return { opened: true } +} + +function findPopout(windowId: number): Popout | undefined { + for (const popout of popouts) { + if (!popout.window.isDestroyed() && popout.window.id === windowId) return popout + } + return undefined +} + +export function popoutNavigate(windowId: number, url: string): boolean { + const popout = findPopout(windowId) + if (!popout || !isAllowedWebviewUrl(url)) return false + void popout.guest.webContents.loadURL(url) + return true +} + +export function popoutBack(windowId: number): void { + findPopout(windowId)?.guest.webContents.navigationHistory.goBack() +} + +export function popoutForward(windowId: number): void { + findPopout(windowId)?.guest.webContents.navigationHistory.goForward() +} + +export function popoutReload(windowId: number): void { + findPopout(windowId)?.guest.webContents.reload() +} + +export function closeAllPopouts(): void { + for (const popout of popouts) { + if (!popout.window.isDestroyed()) popout.window.close() + } + popouts.clear() +} diff --git a/electron/services/ProService.lite.ts b/electron/services/ProService.lite.ts new file mode 100644 index 00000000..7fe7e419 --- /dev/null +++ b/electron/services/ProService.lite.ts @@ -0,0 +1,31 @@ +/** + * DAEMON Lite stub for ProService (swapped in by vite.lite.config.ts). + * Lite is the free tier — there is no subscription, x402 payment, or holder + * gating, so this severs the @x402 / @solana/kit / SolanaService import chain + * from the Lite bundle. Only getLocalSubscriptionState is imported by the + * Lite graph (DaemonAIService, EntitlementGuardService). + */ +import type { ProSubscriptionState } from '../shared/types' + +export function getLocalSubscriptionState(): ProSubscriptionState { + return { + active: false, + plan: 'light', + walletId: null, + walletAddress: null, + expiresAt: null, + features: [], + tier: null, + accessSource: 'free', + holderStatus: { + enabled: false, + eligible: false, + mint: null, + minAmount: null, + currentAmount: null, + symbol: 'DAEMON', + }, + priceUsdc: null, + durationDays: null, + } +} diff --git a/electron/services/aria/contextAssembler.lite.ts b/electron/services/aria/contextAssembler.lite.ts new file mode 100644 index 00000000..dd98fb7f --- /dev/null +++ b/electron/services/aria/contextAssembler.lite.ts @@ -0,0 +1,55 @@ +/** + * DAEMON Lite system prompt. Swapped in for contextAssembler.ts by the + * resolveId hook in vite.lite.config.ts. Lite has no project, wallet, network, + * or filesystem, so the persona is a focused coding assistant: explain, debug, + * plan, remember. Memories are global (null project id). + */ +import { buildContextBundle } from '../MemoryInjectionService' +import { getMemory } from '../MemoryService' +import type { AriaContextSnapshot, AriaMemorySuggestionLite } from '../../shared/types' + +const LITE_AGENT_SYSTEM = `You are the DAEMON Lite assistant — a focused AI agent for developers. + +CAPABILITIES: +- Explain code the user pastes, in plain language. +- Debug errors: read the error message, identify the likely cause, and show the fix. +- Plan projects and features step by step. +- Memory: store durable facts about the user's work (remember_fact), list them (recall_memories), correct or forget them (update_memory / forget_memory). Never store secrets — keys, seed phrases, and credentials are rejected. +- Wallet: read balances/holdings (read_wallet), create a wallet (generate_wallet), send SOL (transfer_sol). Sends require the user's typed confirmation. +- Trading: search tokens (token_search), get a swap quote (token_quote), and swap tokens via Jupiter (swap_tokens). Always quote first and show the price impact before proposing a swap. Swaps require typed confirmation and are capped at $500 in Lite. +- Token/wallet safety: check a mint or wallet for risks — mint/freeze authority, snipers, bundles, cabal links (forensic_scan_token, forensic_trace_wallet). +- Preview: open a sandboxed browser window for a localhost dev server or an https page (open_preview). + +RULES: +- For any request that needs more than one step, FIRST call present_plan with 3–6 short step titles, then walk through the steps in your answer. +- Be concise and direct, and assume the user may be early in their coding journey: explain the "why" in plain language, define jargon the first time it appears, and prefer small working examples over abstract descriptions. +- Format with markdown: bold section titles (no trailing colons) and "-" bullets for lists. Code in fenced blocks with a language tag. Keep prose in short paragraphs. No filler, no emoji. +- When the user tells you to remember something, or a stable preference is established (their stack, their conventions), call remember_fact. If unsure whether a fact is already known, recall_memories first. +- Money is real. Before any swap or SOL transfer, state the amount, the token, and (for swaps) the price impact, then let the confirmation card gate it. Never move funds without the user's typed confirm. On mainnet, treat every amount as real money. +- You have NO access to the user's files, terminal, or git. Never claim you read a file or ran a command. If a task needs file editing, terminals, or git, say so plainly and mention that the full DAEMON IDE does that. +- Never invent file paths, API keys, addresses, mints, or version numbers. +- When finished with multi-step work, end with a one-line summary.` + +export interface AssembledPrompt { + system: string + /** Memories actually injected into this prompt — surfaced as "recalled" in the transcript. */ + recalled: AriaMemorySuggestionLite[] +} + +export async function assembleSystemPrompt(snapshot: AriaContextSnapshot): Promise { + // Lite memories are global: project id is always null here. + let memoryBlock = '' + const recalled: AriaMemorySuggestionLite[] = [] + if (snapshot.chips.projectMemory) { + try { + const bundle = buildContextBundle(null, { usedIn: 'aria_prompt' }) + if (bundle.block) memoryBlock = `\n\n${bundle.block}` + for (const id of bundle.usedMemoryIds) { + const mem = getMemory(id) + if (mem) recalled.push({ id: mem.id, kind: mem.kind, title: mem.title, value: mem.value }) + } + } catch { /* memory unavailable */ } + } + + return { system: `${LITE_AGENT_SYSTEM}${memoryBlock}`, recalled } +} diff --git a/electron/services/aria/planningTools.ts b/electron/services/aria/planningTools.ts new file mode 100644 index 00000000..50d694e6 --- /dev/null +++ b/electron/services/aria/planningTools.ts @@ -0,0 +1,47 @@ +/** + * Planning + patch tools — intercepted in AriaAgentService.executeTool (they + * drive transcript UI, not side effects). Extracted from toolCatalog.ts so the + * Lite catalog (toolCatalog.lite.ts) can compose them without importing the + * full IDE/Solana tool domains. + */ +import type { AriaTool } from './AriaTool' + +export const planningTools: AriaTool[] = [ + { + name: 'present_plan', + description: 'Present an ordered plan for the task BEFORE acting, as a short list of steps (3–6). Call this first whenever a request needs more than one action so the user can see the approach.', + kind: 'read', + risk: 'read', + input: { + type: 'object', + properties: { + steps: { + type: 'array', + items: { type: 'object', properties: { title: { type: 'string' } }, required: ['title'] }, + }, + }, + required: ['steps'], + }, + async handler() { + return { ok: true, summary: 'Plan presented.' } + }, + }, + { + name: 'propose_patch', + description: 'Propose a code change as a unified diff for the user to keep or discard. Provide a short title, a one-paragraph summary, and the unified diff (git format, paths relative to the project root). The change is NOT applied until the user approves.', + kind: 'edit', + risk: 'write', + input: { + type: 'object', + properties: { + title: { type: 'string' }, + summary: { type: 'string' }, + unifiedDiff: { type: 'string' }, + }, + required: ['title', 'unifiedDiff'], + }, + async handler() { + return { ok: true, summary: 'Patch proposed.' } + }, + }, +] diff --git a/electron/services/aria/toolCatalog.lite.ts b/electron/services/aria/toolCatalog.lite.ts new file mode 100644 index 00000000..0420b658 --- /dev/null +++ b/electron/services/aria/toolCatalog.lite.ts @@ -0,0 +1,35 @@ +/** + * DAEMON Lite tool catalog. Swapped in for toolCatalog.ts by a resolveId hook + * in vite.lite.config.ts, so the Lite main bundle never imports the IDE/Solana + * tool domains that pull heavy SDKs (raydium, metaplex, meteora, launchpads). + * + * v1.1 surface: planning + memory (chat), wallet reads + gated SOL transfer, + * gated Jupiter swap, forensics scans, and the sandboxed preview browser. + * Everything money-moving is `sensitive` risk → typed-confirm ApprovalCard. + * The tool modules below only import @solana/web3.js (already shipped) + + * WalletService/FeeService/RicoMapsService (fetch-based, no heavy SDK). + */ +import type { AriaTool } from './AriaTool' +import { planningTools } from './planningTools' +import { memoryTools } from './tools/memory' +import { walletTools } from './tools/wallet' +import { forensicsTools } from './tools/forensics' +import { liteTradeTools } from './tools/liteTrade' +import { litePreviewTools } from './tools/litePreview' + +// Wallet tools the Lite app exposes: reads + SOL transfer + wallet creation. +// Excludes IDE-only project-assignment tools. +const LITE_WALLET_TOOL_NAMES = new Set(['read_wallet', 'transfer_sol', 'generate_wallet', 'set_default_wallet', 'store_helius_key']) + +export const ARIA_TOOLS: AriaTool[] = [ + ...planningTools.filter((t) => t.name !== 'propose_patch'), + ...memoryTools, + ...walletTools.filter((t) => LITE_WALLET_TOOL_NAMES.has(t.name)), + ...liteTradeTools, + ...forensicsTools, + ...litePreviewTools, +] + +export function getTool(name: string): AriaTool | undefined { + return ARIA_TOOLS.find((t) => t.name === name) +} diff --git a/electron/services/aria/toolCatalog.ts b/electron/services/aria/toolCatalog.ts index 86c46c5b..ce32611b 100644 --- a/electron/services/aria/toolCatalog.ts +++ b/electron/services/aria/toolCatalog.ts @@ -2,8 +2,8 @@ * ARIA operator tool catalog — aggregator over domain modules in ./tools/*. * * Adding a tool = append to the relevant domain file; this file just composes - * them. The planning/patch tools below are intercepted by AriaAgentService - * (they drive transcript UI, not side effects) so they live here. + * them. The planning/patch tools (./planningTools.ts) are intercepted by + * AriaAgentService — they drive transcript UI, not side effects. * * Risk gating (read = auto-run · write = inline approve · sensitive = typed * confirm) is enforced centrally in AriaAgentService.executeTool, not here. @@ -19,6 +19,7 @@ * ToolApprovalService.classifyToolRisk. Never ship the tool without the guard. */ import type { AriaTool } from './AriaTool' +import { planningTools } from './planningTools' import { navigationTools } from './tools/navigation' import { settingsTools } from './tools/settings' import { workspaceTools } from './tools/workspace' @@ -38,47 +39,6 @@ import { memoryTools } from './tools/memory' import { autopilotTools } from './tools/autopilot' import { gameStudioTools } from './tools/gameStudio' -/** Planning + patch tools — intercepted in AriaAgentService.executeTool. */ -const planningTools: AriaTool[] = [ - { - name: 'present_plan', - description: 'Present an ordered plan for the task BEFORE acting, as a short list of steps (3–6). Call this first whenever a request needs more than one action so the user can see the approach.', - kind: 'read', - risk: 'read', - input: { - type: 'object', - properties: { - steps: { - type: 'array', - items: { type: 'object', properties: { title: { type: 'string' } }, required: ['title'] }, - }, - }, - required: ['steps'], - }, - async handler() { - return { ok: true, summary: 'Plan presented.' } - }, - }, - { - name: 'propose_patch', - description: 'Propose a code change as a unified diff for the user to keep or discard. Provide a short title, a one-paragraph summary, and the unified diff (git format, paths relative to the project root). The change is NOT applied until the user approves.', - kind: 'edit', - risk: 'write', - input: { - type: 'object', - properties: { - title: { type: 'string' }, - summary: { type: 'string' }, - unifiedDiff: { type: 'string' }, - }, - required: ['title', 'unifiedDiff'], - }, - async handler() { - return { ok: true, summary: 'Patch proposed.' } - }, - }, -] - export const ARIA_TOOLS: AriaTool[] = [ ...planningTools, ...navigationTools, diff --git a/electron/services/aria/tools/litePreview.ts b/electron/services/aria/tools/litePreview.ts new file mode 100644 index 00000000..f505db74 --- /dev/null +++ b/electron/services/aria/tools/litePreview.ts @@ -0,0 +1,43 @@ +/** + * DAEMON Lite preview tool. Opens a sandboxed pop-out browser window at a + * localhost or https URL. Loopback previews are read-risk (the agent commonly + * opens a user's local dev server); remote https is write-risk so the user + * approves before the agent points a window at an external site. + */ +import { openPopout } from '../../PopoutBrowserService' +import { isAllowedWebviewUrl } from '../../../security/externalNavigation' +import type { AriaTool } from '../AriaTool' + +function isLoopback(url: string): boolean { + try { + const u = new URL(url) + return u.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]', '::1'].includes(u.hostname) + } catch { return false } +} + +export const litePreviewTools: AriaTool[] = [ + { + name: 'open_preview', + description: 'Open a preview browser window for a localhost dev server or an https page. Use this to show the user a running local app or a web page you are referencing.', + kind: 'run', + // Risk is decided per-URL in the handler wrapper below; loopback = read, + // remote https = write. Declared as read here; the catalog wrapper upgrades + // remote URLs. (Kept simple: mark write so remote always gates; the model + // is told loopback is safe.) + risk: 'write', + input: { + type: 'object', + properties: { url: { type: 'string', description: 'http://localhost:* or an https URL.' } }, + required: ['url'], + }, + async handler(input) { + const url = String(input.url ?? '').trim() + if (!isAllowedWebviewUrl(url)) { + return { ok: false, summary: 'Only localhost or https URLs can be previewed.' } + } + const result = openPopout(url) + if (!result.opened) return { ok: false, summary: result.reason ?? 'Could not open the preview.' } + return { ok: true, summary: `Opened a preview of ${url}${isLoopback(url) ? ' (local)' : ''}.` } + }, + }, +] diff --git a/electron/services/aria/tools/liteTrade.ts b/electron/services/aria/tools/liteTrade.ts new file mode 100644 index 00000000..90adbb07 --- /dev/null +++ b/electron/services/aria/tools/liteTrade.ts @@ -0,0 +1,128 @@ +/** + * DAEMON Lite trading tools. Search + quote are read-only; swap_tokens is + * `sensitive` so the typed-confirm ApprovalCard always gates it, even in an + * approved plan. Execution reuses WalletService.executeSwap — the same + * server-side price-impact and signer-guard path the IDE uses — so an + * agent-initiated swap is gated identically to a UI-initiated one. A soft USD + * ceiling caps a single Lite swap; larger trades belong in the full IDE. + */ +import { LAMPORTS_PER_SOL } from '@solana/web3.js' +import * as WalletService from '../../WalletService' +import { quoteExecutionFee } from '../../FeeService' +import { clusterMark } from './shared' +import type { AriaTool } from '../AriaTool' + +const SOL_MINT = 'So11111111111111111111111111111111111111112' +const MAX_SWAP_USD = 500 + +function shortAddress(address: string): string { + return address.length > 12 ? `${address.slice(0, 4)}…${address.slice(-4)}` : address +} + +async function defaultWalletId(): Promise { + const dashboard = await WalletService.getDashboard(null) + return dashboard.activeWallet?.id ?? dashboard.wallets[0]?.id ?? null +} + +export const liteTradeTools: AriaTool[] = [ + { + name: 'token_search', + description: 'Search Solana tokens by name, symbol, or mint. Read-only. Returns price, liquidity, holders, and safety flags.', + kind: 'read', + risk: 'read', + input: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + }, + async handler(input) { + const query = String(input.query ?? '').trim() + if (!query) return { ok: false, summary: 'A search query is required.' } + const results = await WalletService.searchJupiterTokens(query) + const top = results.slice(0, 8).map((t) => ({ + mint: t.mint, symbol: t.symbol, name: t.name, usdPrice: t.usdPrice, + liquidity: t.liquidity, verified: t.verified, isSus: t.isSus, + })) + return { ok: true, summary: `${top.length} token${top.length === 1 ? '' : 's'} found.`, data: { tokens: top } } + }, + }, + { + name: 'token_quote', + description: 'Get a Jupiter swap quote (read-only, no execution). Shows expected output, price impact, and route before the user decides to swap.', + kind: 'read', + risk: 'read', + input: { + type: 'object', + properties: { + inputMint: { type: 'string', description: 'Input token mint (use the SOL mint for SOL).' }, + outputMint: { type: 'string', description: 'Output token mint.' }, + amount: { type: 'number', description: 'Amount of the input token (UI units).' }, + }, + required: ['inputMint', 'outputMint', 'amount'], + }, + async handler(input) { + const inputMint = String(input.inputMint ?? '').trim() + const outputMint = String(input.outputMint ?? '').trim() + const amount = Number(input.amount ?? 0) + if (!inputMint || !outputMint) return { ok: false, summary: 'Both input and output mints are required.' } + if (!Number.isFinite(amount) || amount <= 0) return { ok: false, summary: 'Amount must be greater than 0.' } + const walletId = await defaultWalletId() + if (!walletId) return { ok: false, summary: 'No wallet available — add or create one first.' } + const quote = await WalletService.getSwapQuote(walletId, inputMint, outputMint, amount, 50) + return { + ok: true, + summary: `Quote: ${amount} → ${quote.outAmount} (impact ${quote.priceImpactPct}%).`, + data: { outAmount: quote.outAmount, priceImpactPct: quote.priceImpactPct, route: quote.routePlan }, + } + }, + }, + { + name: 'swap_tokens', + description: 'Swap one token for another via Jupiter from the default wallet. Requires explicit user approval. On mainnet this moves real money and the DAEMON execution fee applies. Single Lite swaps are capped at $500 — larger trades need the full DAEMON IDE.', + kind: 'run', + risk: 'sensitive', + input: { + type: 'object', + properties: { + inputMint: { type: 'string' }, + outputMint: { type: 'string' }, + amount: { type: 'number', description: 'Amount of the input token (UI units).' }, + slippageBps: { type: 'number', description: 'Slippage tolerance in basis points (default 50).' }, + }, + required: ['inputMint', 'outputMint', 'amount'], + }, + feePreview(input) { + // Only SOL-denominated legs carry the execution fee (SOL transfers). + const inputMint = String(input.inputMint ?? '') + const amount = Number(input.amount ?? 0) + if (inputMint !== SOL_MINT || !Number.isFinite(amount) || amount <= 0) return null + return quoteExecutionFee(Math.round(amount * LAMPORTS_PER_SOL)) + }, + async handler(input) { + const inputMint = String(input.inputMint ?? '').trim() + const outputMint = String(input.outputMint ?? '').trim() + const amount = Number(input.amount ?? 0) + const slippageBps = Number(input.slippageBps ?? 50) + if (!inputMint || !outputMint) return { ok: false, summary: 'Both input and output mints are required.' } + if (!Number.isFinite(amount) || amount <= 0) return { ok: false, summary: 'Amount must be greater than 0.' } + + const walletId = await defaultWalletId() + if (!walletId) return { ok: false, summary: 'No signing wallet available — create one in the Wallet panel first.' } + + // Soft USD ceiling: price the input leg and refuse oversized Lite swaps. + const quote = await WalletService.getSwapQuote(walletId, inputMint, outputMint, amount, slippageBps) + const [priced] = await WalletService.searchJupiterTokens(inputMint) + const inputUsd = priced?.usdPrice ? priced.usdPrice * amount : null + if (inputUsd !== null && inputUsd > MAX_SWAP_USD) { + return { ok: false, summary: `That swap is ~$${inputUsd.toFixed(0)}, over the $${MAX_SWAP_USD} Lite limit. Use the full DAEMON IDE for larger trades.` } + } + + const result = await WalletService.executeSwap(walletId, inputMint, outputMint, amount, slippageBps, quote.rawQuoteResponse) + return { + ok: true, + summary: clusterMark(`Swapped ${amount} ${shortAddress(inputMint)} → ${shortAddress(outputMint)}.`), + data: { signature: result.signature, priceImpactPct: quote.priceImpactPct }, + } + }, + }, +] diff --git a/electron/services/email/EmailTools.lite.ts b/electron/services/email/EmailTools.lite.ts new file mode 100644 index 00000000..580911d5 --- /dev/null +++ b/electron/services/email/EmailTools.lite.ts @@ -0,0 +1,11 @@ +/** + * DAEMON Lite stub for EmailTools (swapped in by vite.lite.config.ts). + * Lite has no email integration; this severs the nodemailer / imapflow / + * mailparser chain from the Lite bundle. Only the two context helpers are + * imported by the Lite graph (providers/contextUtils.ts). + */ +export async function getEmailAccountSummary(): Promise { + return '' +} + +export const EMAIL_TOOL_NAMES = '' diff --git a/lite.html b/lite.html new file mode 100644 index 00000000..3f9e61dc --- /dev/null +++ b/lite.html @@ -0,0 +1,13 @@ + + + + + + + DAEMON Lite + + +
+ + + diff --git a/package.json b/package.json index 43211b8f..9bb89537 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "daemon", - "version": "4.6.3", + "version": "4.7.0", "main": "dist-electron/main/index.js", "description": "Solana-native agent workbench for verifiable AI development", "author": "nullxnothing", @@ -18,7 +18,9 @@ "scripts": { "dev": "vite", "dev:debug": "powershell -NoProfile -Command \"$env:DAEMON_OPEN_DEVTOOLS='1'; vite\"", + "dev:lite": "vite --config vite.lite.config.ts", "build": "tsc && vite build", + "build:lite": "tsc && vite build --config vite.lite.config.ts", "aria": "node scripts/aria.mjs", "build:daemon-ai-cloud": "vite build --config vite.cloud.config.ts", "build:bridge": "vite build --config vite.bridge.config.ts", @@ -29,6 +31,8 @@ "mobile:seeker:android": "npm --prefix apps/seeker-mobile run android", "mobile:seeker:typecheck": "npm --prefix apps/seeker-mobile run typecheck", "package": "pnpm run build && pnpm run build:bridge && pnpm run rebuild && electron-builder", + "package:lite": "pnpm run build:lite && node scripts/check-lite-deps.mjs && pnpm run rebuild:sqlite && electron-builder --config electron-builder.lite.cjs && node scripts/check-lite-deps.mjs", + "test:lite-packaged-smoke": "pnpm run package:lite && node scripts/smoke/lite-app-smoke.mjs", "postinstall": "pnpm run rebuild:native", "rebuild": "pnpm run rebuild:native", "rebuild:sqlite": "electron-rebuild -f --only better-sqlite3", @@ -58,7 +62,7 @@ "test:security": "vitest run test/security test/services/SecureKeyService.test.ts test/services/ValidationService.test.ts test/services/ProjectSafetyService.test.ts", "test:smoke:core": "pnpm run test:smoke && pnpm run test:mcp-stress && pnpm run test:pro-entitlement", "test:smoke:ui": "pnpm run test:journeys && pnpm run test:responsive && pnpm run test:layout && pnpm run test:visual", - "test:ci": "pnpm install --frozen-lockfile --ignore-scripts && pnpm run typecheck && pnpm -r run typecheck && pnpm run lint:styles && pnpm run security:audit && pnpm run test:unit && pnpm run test:ui && pnpm run test:a11y && pnpm run test:keyboard && pnpm run test:solana && pnpm run test:security && pnpm run test:smoke", + "test:ci": "pnpm install --frozen-lockfile --ignore-scripts && pnpm run typecheck && pnpm -r run typecheck && pnpm run build:lite && node scripts/check-lite-deps.mjs && pnpm run lint:styles && pnpm run security:audit && pnpm run test:unit && pnpm run test:ui && pnpm run test:a11y && pnpm run test:keyboard && pnpm run test:solana && pnpm run test:security && pnpm run test:smoke", "test:release": "pnpm run release:check:v4:local", "test:all": "pnpm run test:ci && pnpm run test:smoke:core && pnpm run test:smoke:ui", "test:smoke": "pnpm run build && pnpm run rebuild && node scripts/smoke/electron-smoke.mjs", @@ -151,6 +155,7 @@ "electron-builder-squirrel-windows": "^26.8.1", "happy-dom": "^20.8.9", "monaco-editor": "^0.55.1", + "obs-websocket-js": "^5.0.8", "patch-package": "^8.0.1", "pixelmatch": "^7.1.0", "playwright": "^1.59.0", diff --git a/popout.html b/popout.html new file mode 100644 index 00000000..68d1fd9c --- /dev/null +++ b/popout.html @@ -0,0 +1,12 @@ + + + + + + Preview + + +
+ + + diff --git a/scripts/check-lite-deps.mjs b/scripts/check-lite-deps.mjs new file mode 100644 index 00000000..86b2761f --- /dev/null +++ b/scripts/check-lite-deps.mjs @@ -0,0 +1,64 @@ +/** + * DAEMON Lite packaging gate. Fails the build when: + * 1. a banned heavy package appears in the lite runtime dependency closure + * (someone re-introduced an IDE/Solana import into the lite main graph), or + * 2. the built installer exceeds the size budget. + * + * Run after build:lite (closure check) and again after electron-builder + * (size check picks up the installer when present). + */ +import { createRequire } from 'node:module' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const require = createRequire(import.meta.url) +const { liteRuntimePackages } = require('./lite-deps.cjs') + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') +// Electron 41's runtime compresses to ~90MB alone; the app payload (36MB asar) +// adds ~15MB. 110MB is the practical NSIS floor for this Electron major. +const MAX_INSTALLER_BYTES = 110 * 1024 * 1024 + +const BANNED = [ + '@raydium-io/raydium-sdk-v2', + 'monaco-editor', + '@monaco-editor/react', + 'node-pty', + '@xterm/xterm', + 'pyright', + 'typescript-language-server', + 'playwright', + 'puppeteer-core', + 'viem', + 'ethers', +] +const BANNED_PREFIXES = ['@metaplex-foundation/', '@meteora-ag/', '@xterm/', '@playwright/'] + +const packages = liteRuntimePackages() +const banned = packages.filter( + (name) => BANNED.includes(name) || BANNED_PREFIXES.some((p) => name.startsWith(p)), +) + +if (banned.length > 0) { + console.error('[lite-gate] FAIL — banned packages in the Lite runtime closure:') + for (const name of banned) console.error(` - ${name}`) + console.error('A new import in the lite main graph is dragging these in. Sever it (module swap or lazy import).') + process.exit(1) +} + +console.log(`[lite-gate] closure ok — ${packages.length} runtime packages, none banned`) + +const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')) +const installer = path.join(ROOT, 'release-lite', pkg.version, 'DAEMON-Lite-setup.exe') +if (fs.existsSync(installer)) { + const bytes = fs.statSync(installer).size + const mb = (bytes / 1024 / 1024).toFixed(1) + if (bytes > MAX_INSTALLER_BYTES) { + console.error(`[lite-gate] FAIL — installer ${mb} MB exceeds the ${MAX_INSTALLER_BYTES / 1024 / 1024} MB budget`) + process.exit(1) + } + console.log(`[lite-gate] installer ok — ${mb} MB`) +} else { + console.log('[lite-gate] installer not built yet — size check skipped') +} diff --git a/scripts/lite-deps.cjs b/scripts/lite-deps.cjs new file mode 100644 index 00000000..eb1d6417 --- /dev/null +++ b/scripts/lite-deps.cjs @@ -0,0 +1,109 @@ +/** + * DAEMON Lite dependency closure. Scans the built lite main/preload bundles + * for external module specifiers, then walks package.json dependencies + * (pnpm hoisted layout — every package is at node_modules/) to the + * full runtime closure. electron-builder.lite.cjs turns that into a files + * WHITELIST, so the Lite installer ships only what the chat runtime imports — + * a blacklist would silently regress the moment anyone adds a heavy import. + */ +const fs = require('node:fs') +const path = require('node:path') +const { builtinModules } = require('node:module') + +const ROOT = path.join(__dirname, '..') +const DIST = path.join(ROOT, 'dist-electron-lite') + +const SPECIFIER_PATTERNS = [ + /require\(\s*["']([^"']+)["']\s*\)/g, + /from\s*["']([^"']+)["']/g, + /import\(\s*["']([^"']+)["']\s*\)/g, + /import\s*["']([^"']+)["']/g, +] + +function isBuiltin(spec) { + const head = spec.startsWith('node:') ? spec.slice(5) : spec + return builtinModules.includes(head.split('/')[0]) +} + +function toPackageName(spec) { + return spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/') : spec.split('/')[0] +} + +/** External package names imported by the built lite bundles. */ +function scanExternals(distDir = DIST) { + if (!fs.existsSync(distDir)) { + throw new Error(`lite bundles not built: ${distDir} missing — run build:lite first`) + } + const packages = new Set() + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name) + if (entry.isDirectory()) { + walk(p) + } else if (/\.(js|mjs|cjs)$/.test(entry.name)) { + const code = fs.readFileSync(p, 'utf8') + for (const re of SPECIFIER_PATTERNS) { + re.lastIndex = 0 + let match + while ((match = re.exec(code))) { + const spec = match[1] + if (spec.startsWith('.') || isBuiltin(spec) || spec === 'electron') continue + // Regex over minified code can cross string boundaries — accept + // only plausible module specifiers. + if (!/^(@[\w.-]+\/)?[\w.-]+(\/[\w.-]+)*$/.test(spec)) continue + packages.add(toPackageName(spec)) + } + } + } + } + } + walk(distDir) + return packages +} + +/** BFS over dependencies + optionalDependencies from the given roots. */ +function dependencyClosure(roots) { + const seen = new Set() + const queue = [...roots] + while (queue.length > 0) { + const name = queue.shift() + if (seen.has(name)) continue + const pkgJsonPath = path.join(ROOT, 'node_modules', name, 'package.json') + if (!fs.existsSync(pkgJsonPath)) continue // optional dep not installed + seen.add(name) + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')) + for (const dep of [ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.optionalDependencies ?? {}), + ]) { + if (dep !== 'electron') queue.push(dep) + } + } + return seen +} + +function liteRuntimePackages() { + return [...dependencyClosure([...scanExternals()])].sort() +} + +/** + * Packages electron-builder would auto-collect (the app's production + * dependency tree). files globs cannot ADD node_modules content — the walker + * collects every prod dep — so exclusion must be expressed as negations of + * this set minus the lite closure. + */ +function appProdPackages() { + const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')) + return dependencyClosure(Object.keys(pkg.dependencies ?? {})) +} + +/** Negation patterns for every collected package the Lite runtime never imports. */ +function liteExcludePatterns() { + const needed = new Set(liteRuntimePackages()) + return [...appProdPackages()] + .filter((name) => !needed.has(name)) + .sort() + .map((name) => `!node_modules/${name}/**`) +} + +module.exports = { scanExternals, dependencyClosure, liteRuntimePackages, appProdPackages, liteExcludePatterns } diff --git a/scripts/smoke/lite-app-smoke.mjs b/scripts/smoke/lite-app-smoke.mjs new file mode 100644 index 00000000..10a8dad3 --- /dev/null +++ b/scripts/smoke/lite-app-smoke.mjs @@ -0,0 +1,131 @@ +/** + * DAEMON Lite packaged smoke: boot the packaged Lite exe with a fresh + * userData sandbox, assert onboarding renders, bypass it, assert the home + * shell mounts and aria:models round-trips. Mirrors packaged-app-smoke.mjs. + */ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +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 repoRoot = path.resolve(__dirname, '..', '..') +const pkg = JSON.parse(readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) +const defaultExePath = path.join( + repoRoot, 'release-lite', pkg.version, 'win-unpacked', + process.platform === 'win32' ? 'DAEMON Lite.exe' : 'DAEMON Lite', +) +const packagedExe = process.env.DAEMON_PACKAGED_EXE || defaultExePath + +const sandboxRoot = mkdtempSync(path.join(tmpdir(), 'daemon-lite-smoke-')) +const userDataDir = path.join(sandboxRoot, 'userData') + +let appProcess +let browser + +function logStep(message) { + console.log(`[lite-smoke] ${message}`) +} + +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() + 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) return reject(new Error(`Timed out waiting for port ${port}`)) + setTimeout(tryConnect, 250) + }) + } + tryConnect() + }) +} + +async function main() { + assert.ok(existsSync(packagedExe), `packaged Lite exe missing: ${packagedExe} — run package:lite first`) + const cdpPort = await getFreePort() + + logStep(`launching ${packagedExe}`) + appProcess = spawn(packagedExe, [], { + env: { + ...process.env, + NODE_OPTIONS: '', + DAEMON_SMOKE_TEST: '1', + DAEMON_SMOKE_CDP_PORT: String(cdpPort), + DAEMON_USER_DATA_DIR: userDataDir, + }, + stdio: 'ignore', + detached: false, + }) + + await waitForPort(cdpPort) + browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`) + const context = browser.contexts()[0] + const page = context.pages().find((p) => p.url().includes('lite.html')) ?? context.pages()[0] + assert.ok(page, 'no renderer page found over CDP') + + logStep('waiting for onboarding') + await page.waitForSelector('text=Your AI coding agent.', { timeout: 30000 }) + + logStep('bypassing onboarding') + await page.evaluate(() => window.daemon.lite.setOnboardingComplete(true)) + await page.reload() + + logStep('waiting for home shell') + await page.waitForSelector('text=New Agent', { timeout: 30000 }) + await page.waitForSelector('textarea', { timeout: 15000 }) + + logStep('checking aria:models round-trip') + const models = await page.evaluate(() => window.daemon.aria.models()) + assert.equal(models.ok, true, `aria:models failed: ${models.error ?? 'unknown'}`) + assert.ok(Array.isArray(models.data) && models.data.length > 0, 'aria:models returned no models') + + logStep('checking pop-out browser allowlist') + const disallowed = await page.evaluate(() => window.daemon.lite.popoutOpen('http://evil.example.com')) + assert.equal(disallowed.data?.opened, false, 'pop-out allowlist should reject remote http') + const allowed = await page.evaluate(() => window.daemon.lite.popoutOpen('https://example.com')) + assert.equal(allowed.data?.opened, true, 'pop-out should open an https URL') + + logStep('checking Tools section reveal + Scanner route') + // Start collapsed, then expand via the Tools header so the state is deterministic. + await page.evaluate(() => window.daemon.lite.setShowTools(false)) + await page.reload() + await page.click('text=Tools') + await page.click('text=Scanner') + await page.waitForSelector('text=/check authorities, snipers, and bundles/', { timeout: 15000 }) + + logStep(`PASS — onboarding, shell, ${models.data.length} models, pop-out, and tools verified`) +} + +main() + .then(() => process.exitCode = 0) + .catch((err) => { + console.error('[lite-smoke] FAIL:', err.message) + process.exitCode = 1 + }) + .finally(async () => { + try { await browser?.close() } catch { /* already closed */ } + try { appProcess?.kill() } catch { /* already dead */ } + setTimeout(() => { + try { rmSync(sandboxRoot, { recursive: true, force: true }) } catch { /* locked on Windows */ } + process.exit(process.exitCode ?? 0) + }, 1500) + }) diff --git a/src/lite/LiteApp.module.css b/src/lite/LiteApp.module.css new file mode 100644 index 00000000..d918ff35 --- /dev/null +++ b/src/lite/LiteApp.module.css @@ -0,0 +1,43 @@ +.app { + display: flex; + flex-direction: column; + height: 100vh; + background: var(--bg-app); + color: var(--t1); + font-family: var(--font-ui); + overflow: hidden; +} + +/* Draggable strip under the hidden native titlebar — blends with the app so + the window controls sit on the same dark surface (Cursor-style). */ +.titlebar { + height: 34px; + flex-shrink: 0; + display: flex; + align-items: center; + padding: 0 14px; + -webkit-app-region: drag; + user-select: none; +} + +.titlebarText { + font-size: var(--fs-11); + color: var(--t4); + letter-spacing: 0.04em; +} + +.body { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 248px 1fr; +} + +.main { + position: relative; + display: flex; + flex-direction: column; + min-width: 0; + background: var(--bg-workspace); + overflow: hidden; +} diff --git a/src/lite/LiteApp.tsx b/src/lite/LiteApp.tsx new file mode 100644 index 00000000..4ac2f9d1 --- /dev/null +++ b/src/lite/LiteApp.tsx @@ -0,0 +1,117 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useAriaStore } from '../store/aria' +import { LiteSidebar } from './LiteSidebar' +import { LiteHome } from './LiteHome' +import { LiteChat } from './LiteChat' +import { LiteSettings } from './LiteSettings' +import { LiteOnboarding } from './LiteOnboarding' +import { LiteWallet } from './wallet/LiteWallet' +import { LiteTrade } from './trade/LiteTrade' +import { LiteScanner } from './scanner/LiteScanner' +import '../panels/AgentWorkbench/AgentWorkbench.css' +import styles from './LiteApp.module.css' + +export type LiteView = 'chat' | 'settings' | 'wallet' | 'trade' | 'scanner' + +export default function LiteApp() { + const [onboarded, setOnboarded] = useState(null) + const [view, setView] = useState('chat') + const [showTools, setShowTools] = useState(false) + const [draft, setDraft] = useState('') + const mainRef = useRef(null) + const turns = useAriaStore((s) => s.turns) + + useEffect(() => { + void window.daemon.lite.isOnboardingComplete().then((res) => { + setOnboarded(res.ok ? Boolean(res.data) : false) + }) + void window.daemon.lite.getShowTools().then((res) => { + if (res.ok) setShowTools(Boolean(res.data)) + }) + }, []) + + useEffect(() => { + if (!onboarded) return + const store = useAriaStore.getState() + const unsubscribe = store.subscribe() + void store.initSessions() + void store.loadModels() + void store.loadProviderStatus() + return unsubscribe + }, [onboarded]) + + const focusComposer = useCallback(() => { + // The shared Composer exposes no input ref; the shell owns one textarea. + requestAnimationFrame(() => { + mainRef.current?.querySelector('textarea')?.focus() + }) + }, []) + + const prefillDraft = useCallback((text: string) => { + setView('chat') + setDraft(text) + focusComposer() + }, [focusComposer]) + + const sendDraft = useCallback(() => { + const content = draft.trim() + if (!content) return + setDraft('') + void useAriaStore.getState().sendMessage(content) + }, [draft]) + + const toggleTools = useCallback(() => { + setShowTools((prev) => { + const next = !prev + void window.daemon.lite.setShowTools(next) + return next + }) + }, []) + + if (onboarded === null) return null + + const showHome = view === 'chat' && turns.length === 0 + + return ( +
+
+ DAEMON Lite +
+ {!onboarded ? ( + setOnboarded(true)} /> + ) : ( +
+ { + setView('chat') + setDraft('') + void useAriaStore.getState().newChat() + focusComposer() + }} + onSelectView={setView} + onOpenSettings={() => setView(view === 'settings' ? 'chat' : 'settings')} + onPickSession={() => setView('chat')} + /> +
+ {view === 'settings' ? ( + setView('chat')} /> + ) : view === 'wallet' ? ( + + ) : view === 'trade' ? ( + + ) : view === 'scanner' ? ( + + ) : showHome ? ( + + ) : ( + + )} +
+
+ )} +
+ ) +} diff --git a/src/lite/LiteChat.module.css b/src/lite/LiteChat.module.css new file mode 100644 index 00000000..dfa1b0d8 --- /dev/null +++ b/src/lite/LiteChat.module.css @@ -0,0 +1,27 @@ +.chat { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 20px 24px 8px; +} + +.thread { + max-width: 760px; + margin: 0 auto; +} + +.dock { + padding: 8px 24px 16px; +} + +.composer { + max-width: 760px; + margin: 0 auto; +} diff --git a/src/lite/LiteChat.tsx b/src/lite/LiteChat.tsx new file mode 100644 index 00000000..73ccc121 --- /dev/null +++ b/src/lite/LiteChat.tsx @@ -0,0 +1,40 @@ +import { useRef } from 'react' +import { useAriaStore } from '../store/aria' +import { AgentTranscript } from '../panels/AgentWorkbench/AgentTranscript' +import { useStickyScroll } from '../hooks/useStickyScroll' +import { LiteComposer } from './LiteComposer' +import styles from './LiteChat.module.css' + +interface LiteChatProps { + draft: string + onDraftChange: (value: string) => void + onSend: () => void +} + +export function LiteChat({ draft, onDraftChange, onSend }: LiteChatProps) { + const turns = useAriaStore((s) => s.turns) + const isLoading = useAriaStore((s) => s.isLoading) + const scrollRef = useRef(null) + useStickyScroll(scrollRef, [turns, isLoading]) + + return ( +
+
+
+ +
+
+
+
+ +
+
+
+ ) +} diff --git a/src/lite/LiteComposer.module.css b/src/lite/LiteComposer.module.css new file mode 100644 index 00000000..3004875f --- /dev/null +++ b/src/lite/LiteComposer.module.css @@ -0,0 +1,86 @@ +.composer { + display: flex; + flex-direction: column; + gap: 4px; + padding: 11px 12px 8px; + background: var(--s1); + border: 1px solid var(--line); + border-radius: 12px; + transition: border-color 120ms ease; +} + +.composer:focus-within { + border-color: var(--line-2); +} + +.input { + width: 100%; + min-height: 38px; + max-height: 200px; + resize: none; + border: none; + background: transparent; + color: var(--t1); + font-family: var(--font-ui); + font-size: var(--fs-13); + line-height: 1.5; +} + +.input::placeholder { + color: var(--t4); +} + +.input:focus, +.input:focus-visible { + outline: none; + box-shadow: none; +} + +.row { + display: flex; + align-items: center; + gap: 8px; +} + +.modeChip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 999px; + background: var(--accent-green-glow); + color: var(--accent-green); + font-size: var(--fs-10); + font-weight: 600; +} + +.model { + color: var(--t3); +} + +.spacer { + flex: 1; +} + +.send { + display: inline-flex; + align-items: center; + justify-content: center; + width: 23px; + height: 23px; + border: none; + border-radius: 50%; + background: var(--accent-green); + color: #06110c; + cursor: pointer; + transition: opacity 120ms ease; +} + +.send:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent-green) 88%, white); +} + +.send:disabled { + opacity: 0.35; + cursor: default; +} diff --git a/src/lite/LiteComposer.tsx b/src/lite/LiteComposer.tsx new file mode 100644 index 00000000..328cf968 --- /dev/null +++ b/src/lite/LiteComposer.tsx @@ -0,0 +1,63 @@ +import { useEffect, useRef } from 'react' +import type { KeyboardEvent } from 'react' +import { ArrowUp, ChatCircle } from '@phosphor-icons/react' +import { ModelDropdown } from '../components/Panel' +import styles from './LiteComposer.module.css' + +interface LiteComposerProps { + value: string + onChange: (value: string) => void + onSend: () => void + placeholder?: string + disabled?: boolean + autoFocus?: boolean +} + +/** Cursor-style composer: large rounded card, textarea on top, mode chip + + * model picker + send arrow on the bottom row. */ +export function LiteComposer({ value, onChange, onSend, placeholder, disabled, autoFocus }: LiteComposerProps) { + const inputRef = useRef(null) + + useEffect(() => { + if (autoFocus) inputRef.current?.focus() + }, [autoFocus]) + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Enter' || event.shiftKey) return + event.preventDefault() + if (!disabled && value.trim()) onSend() + } + + return ( +
+