feat: add DB persistence, real-time WebSocket, goal preprocessor, and Android companion app

- Add device/session/step DB persistence in server agent loop
- Add goal preprocessor for compound goals (e.g., "open YouTube and search X")
- Add step-level logging to agent loop
- Fix dashboard WebSocket auth (direct DB token lookup instead of auth.api)
- Fix web layout to use locals.session.token instead of cookie
- Add dashboard-ws.svelte.ts WebSocket store with auto-reconnect
- Rewrite devices page with direct DB queries and real-time updates
- Add device detail page with live step display and session history
- Add Android companion app resources, themes, and screen capture consent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sanju Sivalingam
2026-02-17 20:12:41 +05:30
parent ea707af83e
commit c395f9d83e
101 changed files with 8824 additions and 82 deletions

View File

@@ -25,14 +25,21 @@ import {
type LLMConfig,
} from "./llm.js";
import { createStuckDetector } from "./stuck.js";
import { db } from "../db.js";
import { agentSession, agentStep } from "../schema.js";
import { eq } from "drizzle-orm";
import type { UIElement, ActionDecision } from "@droidclaw/shared";
// ─── Public Types ───────────────────────────────────────────────
export interface AgentLoopOptions {
deviceId: string;
/** Persistent device ID from DB (for FK references) */
persistentDeviceId?: string;
userId: string;
goal: string;
/** Original goal before preprocessing (if different from goal) */
originalGoal?: string;
llmConfig: LLMConfig;
maxSteps?: number;
onStep?: (step: AgentStep) => void;
@@ -211,8 +218,10 @@ export async function runAgentLoop(
): Promise<AgentResult> {
const {
deviceId,
persistentDeviceId,
userId,
goal,
originalGoal,
llmConfig,
maxSteps = 30,
onStep,
@@ -230,12 +239,28 @@ export async function runAgentLoop(
const recentActions: string[] = [];
let lastActionFeedback = "";
// Persist session to DB
if (persistentDeviceId) {
try {
await db.insert(agentSession).values({
id: sessionId,
userId,
deviceId: persistentDeviceId,
goal: originalGoal ?? goal,
status: "running",
stepsUsed: 0,
});
} catch (err) {
console.error(`[Agent ${sessionId}] Failed to create DB session: ${err}`);
}
}
// Notify dashboard that a goal has started
sessions.notifyDashboard(userId, {
type: "goal_started",
sessionId,
goal,
deviceId,
goal: originalGoal ?? goal,
deviceId: persistentDeviceId ?? deviceId,
});
let stepsUsed = 0;
@@ -397,7 +422,10 @@ export async function runAgentLoop(
recentActions.push(actionSig);
if (recentActions.length > 8) recentActions.shift();
// ── 7. Done? ────────────────────────────────────────────
// ── 7. Log + Done check ────────────────────────────────
const reason = action.reason ?? "";
console.log(`[Agent ${sessionId}] Step ${step + 1}: ${actionSig}${reason}`);
if (action.action === "done") {
success = true;
break;
@@ -421,6 +449,23 @@ export async function runAgentLoop(
screenHash,
});
// ── 8b. Persist step to DB ────────────────────────────────
const stepId = crypto.randomUUID();
if (persistentDeviceId) {
db.insert(agentStep)
.values({
id: stepId,
sessionId,
stepNumber: step + 1,
screenHash,
action: action as unknown as Record<string, unknown>,
reasoning: action.reason ?? "",
})
.catch((err) =>
console.error(`[Agent ${sessionId}] Failed to save step ${step + 1}: ${err}`)
);
}
// ── 9. Execute on device ────────────────────────────────
const command = actionToCommand(action);
try {
@@ -431,8 +476,19 @@ export async function runAgentLoop(
};
const resultSuccess = result.success !== false;
lastActionFeedback = `${actionSig} -> ${resultSuccess ? "OK" : "FAILED"}: ${result.error ?? result.data ?? "completed"}`;
console.log(`[Agent ${sessionId}] Step ${step + 1} result: ${lastActionFeedback}`);
// Update step result in DB
if (persistentDeviceId) {
db.update(agentStep).set({ result: lastActionFeedback }).where(eq(agentStep.id, stepId))
.catch(() => {});
}
} catch (err) {
lastActionFeedback = `${actionSig} -> FAILED: ${(err as Error).message}`;
console.log(`[Agent ${sessionId}] Step ${step + 1} result: ${lastActionFeedback}`);
if (persistentDeviceId) {
db.update(agentStep).set({ result: lastActionFeedback }).where(eq(agentStep.id, stepId))
.catch(() => {});
}
console.error(
`[Agent ${sessionId}] Command error at step ${step + 1}: ${(err as Error).message}`
);
@@ -447,6 +503,20 @@ export async function runAgentLoop(
const result: AgentResult = { success, stepsUsed, sessionId };
// Update session in DB
if (persistentDeviceId) {
db.update(agentSession)
.set({
status: success ? "completed" : "failed",
stepsUsed,
completedAt: new Date(),
})
.where(eq(agentSession.id, sessionId))
.catch((err) =>
console.error(`[Agent ${sessionId}] Failed to update DB session: ${err}`)
);
}
sessions.notifyDashboard(userId, {
type: "goal_completed",
sessionId,

View File

@@ -0,0 +1,166 @@
/**
* Goal preprocessor for DroidClaw agent loop.
*
* Intercepts simple goals (like "open youtube") and executes direct
* actions before the LLM loop starts. This avoids wasting 20 steps
* on what should be a 2-step task, especially with weaker LLMs that
* navigate via UI instead of using programmatic launch commands.
*/
import { sessions } from "../ws/sessions.js";
// ─── App Name → Package Name Map ────────────────────────────
const APP_PACKAGES: Record<string, string> = {
youtube: "com.google.android.youtube",
gmail: "com.google.android.gm",
chrome: "com.android.chrome",
maps: "com.google.android.apps.maps",
photos: "com.google.android.apps.photos",
drive: "com.google.android.apps.docs",
calendar: "com.google.android.calendar",
contacts: "com.google.android.contacts",
messages: "com.google.android.apps.messaging",
phone: "com.google.android.dialer",
clock: "com.google.android.deskclock",
calculator: "com.google.android.calculator",
camera: "com.android.camera",
settings: "com.android.settings",
files: "com.google.android.apps.nbu.files",
play: "com.android.vending",
"play store": "com.android.vending",
"google play": "com.android.vending",
whatsapp: "com.whatsapp",
telegram: "org.telegram.messenger",
instagram: "com.instagram.android",
facebook: "com.facebook.katana",
twitter: "com.twitter.android",
x: "com.twitter.android",
spotify: "com.spotify.music",
netflix: "com.netflix.mediaclient",
tiktok: "com.zhiliaoapp.musically",
snapchat: "com.snapchat.android",
reddit: "com.reddit.frontpage",
discord: "com.discord",
slack: "com.Slack",
zoom: "us.zoom.videomeetings",
teams: "com.microsoft.teams",
outlook: "com.microsoft.office.outlook",
"google meet": "com.google.android.apps.tachyon",
meet: "com.google.android.apps.tachyon",
keep: "com.google.android.keep",
notes: "com.google.android.keep",
sheets: "com.google.android.apps.docs.editors.sheets",
docs: "com.google.android.apps.docs.editors.docs",
slides: "com.google.android.apps.docs.editors.slides",
translate: "com.google.android.apps.translate",
weather: "com.google.android.apps.weather",
news: "com.google.android.apps.magazines",
podcasts: "com.google.android.apps.podcasts",
fitbit: "com.fitbit.FitbitMobile",
uber: "com.ubercab",
lyft: "me.lyft.android",
amazon: "com.amazon.mShop.android.shopping",
ebay: "com.ebay.mobile",
linkedin: "com.linkedin.android",
pinterest: "com.pinterest",
twitch: "tv.twitch.android.app",
};
// ─── Goal Pattern Matching ───────────────────────────────────
interface PreprocessResult {
/** Whether the preprocessor handled the goal */
handled: boolean;
/** Command sent to device (if any) */
command?: Record<string, unknown>;
/** Updated goal text for the LLM (optional) */
refinedGoal?: string;
}
/**
* Try to find a known app name at the start of a goal string.
* Returns the package name and remaining text, or null.
*/
function matchAppName(lower: string): { pkg: string; appName: string; rest: string } | null {
// Try longest app names first (e.g. "google meet" before "meet")
const sorted = Object.keys(APP_PACKAGES).sort((a, b) => b.length - a.length);
for (const name of sorted) {
// Match: "open <app> [app] and <rest>" or "open <app> [app]"
const pattern = new RegExp(
`^(?:open|launch|start|go to)\\s+(?:the\\s+)?${escapeRegex(name)}(?:\\s+app)?(?:\\s+(?:and|then)\\s+(.+))?$`
);
const m = lower.match(pattern);
if (m) {
return { pkg: APP_PACKAGES[name], appName: name, rest: m[1]?.trim() ?? "" };
}
}
return null;
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Attempt to preprocess a goal before the LLM loop.
*
* Three outcomes:
* 1. { handled: true, refinedGoal: undefined } — goal fully handled (pure "open X")
* 2. { handled: true, refinedGoal: "..." } — app launched, LLM continues with refined goal
* 3. { handled: false } — preprocessor can't help, LLM gets full goal
*/
export async function preprocessGoal(
deviceId: string,
goal: string
): Promise<PreprocessResult> {
const lower = goal.toLowerCase().trim();
// ── Pattern: "open <app> [and <remaining>]" ───────────────
const appMatch = matchAppName(lower);
if (appMatch) {
try {
await sessions.sendCommand(deviceId, {
type: "launch",
packageName: appMatch.pkg,
});
if (appMatch.rest) {
// Compound goal: app launched, pass remaining instructions to LLM
console.log(`[Preprocessor] Launched ${appMatch.pkg}, refined goal: ${appMatch.rest}`);
return {
handled: true,
command: { type: "launch", packageName: appMatch.pkg },
refinedGoal: appMatch.rest,
};
}
// Pure "open X" — fully handled
console.log(`[Preprocessor] Launched ${appMatch.pkg} for goal: ${goal}`);
return { handled: true, command: { type: "launch", packageName: appMatch.pkg } };
} catch (err) {
console.warn(`[Preprocessor] Failed to launch ${appMatch.pkg}: ${err}`);
// Fall through to LLM
}
}
// ── Pattern: "open <url>" or "go to <url>" ────────────────
const urlMatch = lower.match(
/^(?:open|go to|visit|navigate to)\s+(https?:\/\/\S+)$/
);
if (urlMatch) {
const url = urlMatch[1];
try {
await sessions.sendCommand(deviceId, { type: "open_url", url });
console.log(`[Preprocessor] Opened URL: ${url}`);
return { handled: true, command: { type: "open_url", url } };
} catch (err) {
console.warn(`[Preprocessor] Failed to open URL: ${err}`);
}
}
return { handled: false };
}

View File

@@ -2,10 +2,18 @@ import { betterAuth } from "better-auth";
import { apiKey } from "better-auth/plugins";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "./db.js";
import * as schema from "./schema.js";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
schema,
}),
plugins: [apiKey()],
plugins: [
apiKey({
rateLimit: {
enabled: false,
},
}),
],
});

View File

@@ -1,6 +1,7 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { env } from "./env.js";
import * as schema from "./schema.js";
const client = postgres(env.DATABASE_URL);
export const db = drizzle(client);
export const db = drizzle(client, { schema });

View File

@@ -63,6 +63,8 @@ const server = Bun.serve<WebSocketData>({
return app.fetch(req);
},
websocket: {
idleTimeout: 120,
sendPings: true,
open(ws) {
console.log(`WebSocket opened: ${ws.data.path}`);
},
@@ -73,12 +75,17 @@ const server = Bun.serve<WebSocketData>({
: new TextDecoder().decode(message);
if (ws.data.path === "/ws/device") {
handleDeviceMessage(ws, raw);
handleDeviceMessage(ws, raw).catch((err) => {
console.error(`Device message handler error: ${err}`);
});
} else if (ws.data.path === "/ws/dashboard") {
handleDashboardMessage(ws, raw);
handleDashboardMessage(ws, raw).catch((err) => {
console.error(`Dashboard message handler error: ${err}`);
});
}
},
close(ws) {
close(ws, code, reason) {
console.log(`WebSocket closed: ${ws.data.path} device=${ws.data.deviceId ?? "unknown"} code=${code} reason=${reason}`);
if (ws.data.path === "/ws/device") {
handleDeviceClose(ws);
} else if (ws.data.path === "/ws/dashboard") {

View File

@@ -1,22 +1,80 @@
import { Hono } from "hono";
import { sessionMiddleware, type AuthEnv } from "../middleware/auth.js";
import { sessions } from "../ws/sessions.js";
import { db } from "../db.js";
import { device, agentSession, agentStep } from "../schema.js";
import { eq, desc, and } from "drizzle-orm";
const devices = new Hono<AuthEnv>();
devices.use("*", sessionMiddleware);
devices.get("/", (c) => {
/** List all devices for the authenticated user (from DB, includes offline) */
devices.get("/", async (c) => {
const user = c.get("user");
const userDevices = sessions.getDevicesForUser(user.id);
const dbDevices = await db
.select()
.from(device)
.where(eq(device.userId, user.id))
.orderBy(desc(device.lastSeen));
return c.json(
userDevices.map((d) => ({
deviceId: d.deviceId,
name: d.deviceInfo?.model ?? "Unknown Device",
dbDevices.map((d) => ({
deviceId: d.id,
name: d.name,
status: d.status,
deviceInfo: d.deviceInfo,
connectedAt: d.connectedAt.toISOString(),
lastSeen: d.lastSeen?.toISOString() ?? d.createdAt.toISOString(),
connectedAt: d.createdAt.toISOString(),
}))
);
});
/** List agent sessions for a specific device */
devices.get("/:deviceId/sessions", async (c) => {
const user = c.get("user");
const deviceId = c.req.param("deviceId");
const deviceSessions = await db
.select()
.from(agentSession)
.where(
and(
eq(agentSession.deviceId, deviceId),
eq(agentSession.userId, user.id)
)
)
.orderBy(desc(agentSession.startedAt))
.limit(50);
return c.json(deviceSessions);
});
/** List steps for a specific agent session */
devices.get("/:deviceId/sessions/:sessionId/steps", async (c) => {
const user = c.get("user");
const sessionId = c.req.param("sessionId");
// Verify session belongs to user
const sess = await db
.select()
.from(agentSession)
.where(
and(eq(agentSession.id, sessionId), eq(agentSession.userId, user.id))
)
.limit(1);
if (sess.length === 0) {
return c.json({ error: "Session not found" }, 404);
}
const steps = await db
.select()
.from(agentStep)
.where(eq(agentStep.sessionId, sessionId))
.orderBy(agentStep.stepNumber);
return c.json(steps);
});
export { devices };

142
server/src/schema.ts Normal file
View File

@@ -0,0 +1,142 @@
import { pgTable, text, timestamp, boolean, integer, jsonb } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => new Date())
.notNull(),
});
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
export const apikey = pgTable("apikey", {
id: text("id").primaryKey(),
name: text("name"),
start: text("start"),
prefix: text("prefix"),
key: text("key").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
refillInterval: integer("refill_interval"),
refillAmount: integer("refill_amount"),
lastRefillAt: timestamp("last_refill_at"),
enabled: boolean("enabled").default(true),
rateLimitEnabled: boolean("rate_limit_enabled").default(true),
rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
rateLimitMax: integer("rate_limit_max").default(10),
requestCount: integer("request_count").default(0),
remaining: integer("remaining"),
lastRequest: timestamp("last_request"),
expiresAt: timestamp("expires_at"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
permissions: text("permissions"),
metadata: text("metadata"),
});
export const llmConfig = pgTable("llm_config", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
provider: text("provider").notNull(),
apiKey: text("api_key").notNull(),
model: text("model"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
export const device = pgTable("device", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
name: text("name").notNull(),
lastSeen: timestamp("last_seen"),
status: text("status").notNull().default("offline"),
deviceInfo: jsonb("device_info"),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const agentSession = pgTable("agent_session", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
deviceId: text("device_id")
.notNull()
.references(() => device.id, { onDelete: "cascade" }),
goal: text("goal").notNull(),
status: text("status").notNull().default("running"),
stepsUsed: integer("steps_used").default(0),
startedAt: timestamp("started_at").defaultNow().notNull(),
completedAt: timestamp("completed_at"),
});
export const agentStep = pgTable("agent_step", {
id: text("id").primaryKey(),
sessionId: text("session_id")
.notNull()
.references(() => agentSession.id, { onDelete: "cascade" }),
stepNumber: integer("step_number").notNull(),
screenHash: text("screen_hash"),
action: jsonb("action"),
reasoning: text("reasoning"),
result: text("result"),
timestamp: timestamp("timestamp").defaultNow().notNull(),
});

View File

@@ -1,5 +1,7 @@
import type { ServerWebSocket } from "bun";
import { auth } from "../auth.js";
import { db } from "../db.js";
import { session as sessionTable } from "../schema.js";
import { eq } from "drizzle-orm";
import { sessions, type WebSocketData } from "./sessions.js";
interface DashboardAuthMessage {
@@ -28,21 +30,25 @@ export async function handleDashboardMessage(
if (msg.type === "auth") {
try {
// Verify the session token by constructing a request with the cookie header
const sessionResult = await auth.api.getSession({
headers: new Headers({
cookie: `better-auth.session_token=${msg.token}`,
}),
});
if (!sessionResult) {
ws.send(
JSON.stringify({ type: "auth_error", message: "Invalid session" })
);
const token = msg.token;
if (!token) {
ws.send(JSON.stringify({ type: "auth_error", message: "No token" }));
return;
}
const userId = sessionResult.user.id;
// Look up session directly in DB
const rows = await db
.select({ userId: sessionTable.userId })
.from(sessionTable)
.where(eq(sessionTable.token, token))
.limit(1);
if (rows.length === 0) {
ws.send(JSON.stringify({ type: "auth_error", message: "Invalid session" }));
return;
}
const userId = rows[0].userId;
// Mark connection as authenticated
ws.data.authenticated = true;

View File

@@ -1,7 +1,63 @@
import type { ServerWebSocket } from "bun";
import type { DeviceMessage } from "@droidclaw/shared";
import { eq, and } from "drizzle-orm";
import { auth } from "../auth.js";
import { db } from "../db.js";
import { llmConfig, device, agentSession, agentStep } from "../schema.js";
import { sessions, type WebSocketData } from "./sessions.js";
import { runAgentLoop } from "../agent/loop.js";
import { preprocessGoal } from "../agent/preprocessor.js";
import type { LLMConfig } from "../agent/llm.js";
/** Track running agent sessions to prevent duplicates per device */
const activeSessions = new Map<string, string>();
/**
* Send a JSON message to a device WebSocket (safe — catches send errors).
*/
function sendToDevice(ws: ServerWebSocket<WebSocketData>, msg: Record<string, unknown>) {
try {
ws.send(JSON.stringify(msg));
} catch {
// device disconnected
}
}
/**
* Upsert a device record in the DB. Returns the persistent device ID.
* Matches on userId + model name so reconnects reuse the same record.
*/
async function upsertDevice(
userId: string,
name: string,
deviceInfo?: Record<string, unknown>
): Promise<string> {
// Try to find existing device by userId + name
const existing = await db
.select()
.from(device)
.where(and(eq(device.userId, userId), eq(device.name, name)))
.limit(1);
if (existing.length > 0) {
await db
.update(device)
.set({ status: "online", lastSeen: new Date(), deviceInfo: deviceInfo ?? null })
.where(eq(device.id, existing[0].id));
return existing[0].id;
}
const id = crypto.randomUUID();
await db.insert(device).values({
id,
userId,
name,
status: "online",
lastSeen: new Date(),
deviceInfo: deviceInfo ?? null,
});
return id;
}
/**
* Handle an incoming message from an Android device WebSocket.
@@ -39,10 +95,29 @@ export async function handleDeviceMessage(
const deviceId = crypto.randomUUID();
const userId = result.key.userId;
// Build device name from device info
const name = msg.deviceInfo
? `${msg.deviceInfo.model} (Android ${msg.deviceInfo.androidVersion})`
: "Unknown Device";
// Persist device to DB (upsert by userId + name)
let persistentDeviceId: string;
try {
persistentDeviceId = await upsertDevice(
userId,
name,
msg.deviceInfo as unknown as Record<string, unknown>
);
} catch (err) {
console.error(`[Device] Failed to upsert device record: ${err}`);
persistentDeviceId = deviceId; // fallback to ephemeral ID
}
// Mark connection as authenticated
ws.data.authenticated = true;
ws.data.userId = userId;
ws.data.deviceId = deviceId;
ws.data.persistentDeviceId = persistentDeviceId;
// Register device in session manager
sessions.addDevice({
@@ -57,17 +132,13 @@ export async function handleDeviceMessage(
ws.send(JSON.stringify({ type: "auth_ok", deviceId }));
// Notify dashboard subscribers
const name = msg.deviceInfo
? `${msg.deviceInfo.model} (Android ${msg.deviceInfo.androidVersion})`
: deviceId;
sessions.notifyDashboard(userId, {
type: "device_online",
deviceId,
deviceId: persistentDeviceId,
name,
});
console.log(`Device authenticated: ${deviceId} for user ${userId}`);
console.log(`Device authenticated: ${deviceId} (db: ${persistentDeviceId}) for user ${userId}`);
} catch (err) {
ws.send(
JSON.stringify({
@@ -91,7 +162,6 @@ export async function handleDeviceMessage(
switch (msg.type) {
case "screen": {
// Device is reporting its screen state in response to a get_screen command
sessions.resolveRequest(msg.requestId, {
type: "screen",
elements: msg.elements,
@@ -102,7 +172,6 @@ export async function handleDeviceMessage(
}
case "result": {
// Device is reporting the result of an action command
sessions.resolveRequest(msg.requestId, {
type: "result",
success: msg.success,
@@ -113,16 +182,148 @@ export async function handleDeviceMessage(
}
case "goal": {
// Device is requesting a goal to be executed
// Task 6 wires up the agent loop here
console.log(
`Goal request from device ${ws.data.deviceId}: ${msg.text}`
);
const deviceId = ws.data.deviceId!;
const userId = ws.data.userId!;
const persistentDeviceId = ws.data.persistentDeviceId!;
const goal = msg.text;
if (!goal) {
sendToDevice(ws, { type: "goal_failed", message: "Empty goal" });
break;
}
if (activeSessions.has(deviceId)) {
sendToDevice(ws, { type: "goal_failed", message: "Agent already running on this device" });
break;
}
// Fetch user's LLM config
let userLlmConfig: LLMConfig;
try {
const configs = await db
.select()
.from(llmConfig)
.where(eq(llmConfig.userId, userId))
.limit(1);
if (configs.length === 0) {
sendToDevice(ws, { type: "goal_failed", message: "No LLM provider configured. Set it up in the web dashboard Settings." });
break;
}
const cfg = configs[0];
userLlmConfig = {
provider: cfg.provider,
apiKey: cfg.apiKey,
model: cfg.model ?? undefined,
};
} catch (err) {
console.error(`[Agent] Failed to fetch LLM config for user ${userId}:`, err);
sendToDevice(ws, { type: "goal_failed", message: "Failed to load LLM configuration" });
break;
}
// Preprocess: handle simple goals directly, or extract "open X" prefix
let effectiveGoal = goal;
try {
const preResult = await preprocessGoal(deviceId, goal);
if (preResult.handled) {
await new Promise((r) => setTimeout(r, 1500));
if (preResult.refinedGoal) {
effectiveGoal = preResult.refinedGoal;
sendToDevice(ws, {
type: "step",
step: 0,
action: preResult.command,
reasoning: "Preprocessor: launched app directly",
});
} else {
// Pure "open X" — fully handled. Persist to DB then return.
const sessionId = crypto.randomUUID();
try {
await db.insert(agentSession).values({
id: sessionId,
userId,
deviceId: persistentDeviceId,
goal,
status: "completed",
stepsUsed: 1,
completedAt: new Date(),
});
await db.insert(agentStep).values({
id: crypto.randomUUID(),
sessionId,
stepNumber: 1,
action: preResult.command ?? null,
reasoning: `Preprocessor: direct ${preResult.command?.type} action`,
result: "OK",
});
} catch (err) {
console.error(`[DB] Failed to save preprocessor session: ${err}`);
}
sendToDevice(ws, { type: "goal_started", sessionId, goal });
sendToDevice(ws, {
type: "step",
step: 1,
action: preResult.command,
reasoning: `Preprocessor: direct ${preResult.command?.type} action`,
});
sendToDevice(ws, { type: "goal_completed", success: true, stepsUsed: 1 });
sessions.notifyDashboard(userId, { type: "goal_completed", sessionId, success: true, stepsUsed: 1 });
console.log(`[Preprocessor] Goal handled directly: ${goal}`);
break;
}
}
} catch (err) {
console.warn(`[Preprocessor] Error (falling through to LLM): ${err}`);
}
console.log(`[Agent] Starting goal for device ${deviceId}: ${effectiveGoal}${effectiveGoal !== goal ? ` (original: ${goal})` : ""}`);
activeSessions.set(deviceId, goal);
sendToDevice(ws, { type: "goal_started", sessionId: deviceId, goal });
// Run agent loop in background (DB persistence happens inside the loop)
runAgentLoop({
deviceId,
persistentDeviceId,
userId,
goal: effectiveGoal,
originalGoal: goal !== effectiveGoal ? goal : undefined,
llmConfig: userLlmConfig,
onStep(step) {
sendToDevice(ws, {
type: "step",
step: step.stepNumber,
action: step.action,
reasoning: step.reasoning,
});
},
onComplete(result) {
activeSessions.delete(deviceId);
sendToDevice(ws, {
type: "goal_completed",
success: result.success,
stepsUsed: result.stepsUsed,
});
console.log(
`[Agent] Completed on ${deviceId}: ${result.success ? "success" : "incomplete"} in ${result.stepsUsed} steps`
);
},
}).catch((err) => {
activeSessions.delete(deviceId);
sendToDevice(ws, { type: "goal_failed", message: String(err) });
console.error(`[Agent] Error on ${deviceId}:`, err);
});
break;
}
case "pong": {
// Heartbeat response — no-op
break;
}
@@ -141,15 +342,24 @@ export async function handleDeviceMessage(
export function handleDeviceClose(
ws: ServerWebSocket<WebSocketData>
): void {
const { deviceId, userId } = ws.data;
const { deviceId, userId, persistentDeviceId } = ws.data;
if (!deviceId) return;
activeSessions.delete(deviceId);
sessions.removeDevice(deviceId);
// Update device status in DB
if (persistentDeviceId) {
db.update(device)
.set({ status: "offline", lastSeen: new Date() })
.where(eq(device.id, persistentDeviceId))
.catch((err) => console.error(`[DB] Failed to update device status: ${err}`));
}
if (userId) {
sessions.notifyDashboard(userId, {
type: "device_offline",
deviceId,
deviceId: persistentDeviceId ?? deviceId,
});
}

View File

@@ -6,6 +6,8 @@ export interface WebSocketData {
path: "/ws/device" | "/ws/dashboard";
userId?: string;
deviceId?: string;
/** Persistent device ID from the `device` DB table (survives reconnects) */
persistentDeviceId?: string;
authenticated: boolean;
}