add desktop codex login and analyst copilot

This commit is contained in:
dirtydishes 2026-05-20 10:41:13 -04:00
parent fb25b5ac97
commit a8d183f38e
24 changed files with 4127 additions and 97 deletions

View file

@ -0,0 +1,8 @@
export const DESKTOP_AI_STATE_CHANNEL = "islandflow:desktop-ai:state";
export const DESKTOP_AI_GET_STATE = "islandflow:desktop-ai:get-state";
export const DESKTOP_AI_LOGIN_BROWSER = "islandflow:desktop-ai:login-browser";
export const DESKTOP_AI_LOGIN_DEVICE = "islandflow:desktop-ai:login-device";
export const DESKTOP_AI_CANCEL_LOGIN = "islandflow:desktop-ai:cancel-login";
export const DESKTOP_AI_LOGOUT = "islandflow:desktop-ai:logout";
export const DESKTOP_AI_UPDATE_PREFERENCES = "islandflow:desktop-ai:update-preferences";
export const DESKTOP_AI_RUN_TASK = "islandflow:desktop-ai:run-task";

View file

@ -0,0 +1,174 @@
import { afterEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createAppServerChildEnv, IslandflowDesktopAiService, summarizeRateLimit } from "./desktop-ai.js";
const tempDirs: string[] = [];
const makeTempDir = async (): Promise<string> => {
const dir = await mkdtemp(path.join(tmpdir(), "islandflow-desktop-ai-"));
tempDirs.push(dir);
return dir;
};
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))
);
});
describe("desktop ai auth environment", () => {
it("scrubs global OpenAI keys for managed ChatGPT sessions", () => {
const env = createAppServerChildEnv("managed-chatgpt", {
OPENAI_API_KEY: "openai-test",
CODEX_API_KEY: "codex-test",
HOME: "/tmp/home"
});
expect(env.OPENAI_API_KEY).toBeUndefined();
expect(env.CODEX_API_KEY).toBeUndefined();
expect(env.HOME).toBe("/tmp/home");
});
it("preserves keys for api-key mode", () => {
const env = createAppServerChildEnv("api-key", {
OPENAI_API_KEY: "openai-test",
CODEX_API_KEY: "codex-test"
});
expect(env.OPENAI_API_KEY).toBe("openai-test");
expect(env.CODEX_API_KEY).toBe("codex-test");
});
});
describe("desktop ai usage and state tracking", () => {
it("records exact token usage notifications into usage rollups", async () => {
const dir = await makeTempDir();
const service = new IslandflowDesktopAiService(dir, async () => {}, () => {});
const internal = service as any;
internal.state.account.email = "analyst@example.com";
internal.state.account.planType = "plus";
internal.state.preferences.model = "gpt-5.4";
internal.state.tasks = [
{
taskId: "task-1",
kind: "smart-money-explain",
title: "Explain smart money event",
subtitle: "AAPL",
status: "running",
createdAt: Date.now(),
updatedAt: Date.now(),
threadId: "thread-1",
turnId: "turn-1",
model: "gpt-5.4",
reasoningEffort: "high",
text: "",
error: null,
compiledScreen: null
}
];
internal.activeTasksByThreadId.set("thread-1", {
taskId: "task-1",
taskKind: "smart-money-explain",
taskTitle: "Explain smart money event",
profileId: "managed-chatgpt"
});
await internal.handleNotification("thread/tokenUsage/updated", {
threadId: "thread-1",
turnId: "turn-1",
tokenUsage: {
total: {
totalTokens: 1800,
inputTokens: 1000,
cachedInputTokens: 500,
outputTokens: 250,
reasoningOutputTokens: 50
},
last: {
totalTokens: 1800,
inputTokens: 1000,
cachedInputTokens: 500,
outputTokens: 250,
reasoningOutputTokens: 50
}
}
});
expect(service.getState().usage.today.breakdown).toEqual({
totalTokens: 1800,
inputTokens: 1000,
cachedInputTokens: 500,
outputTokens: 250,
reasoningOutputTokens: 50
});
expect(service.getState().usage.today.turnCount).toBe(1);
expect(service.getState().usage.recentTurns[0]?.normalizedCostUsd).toBeCloseTo(0.007125, 6);
});
it("stores rate-limit snapshots with reset times", async () => {
const dir = await makeTempDir();
const service = new IslandflowDesktopAiService(dir, async () => {}, () => {});
const internal = service as any;
await internal.handleNotification("account/rateLimits/updated", {
rateLimits: {
limitId: "chatgpt_plus",
limitName: "ChatGPT Plus",
primary: {
usedPercent: 38.4,
windowDurationMins: 180,
resetsAt: 1_710_000_000_000
},
secondary: {
usedPercent: 12.1,
windowDurationMins: 1440,
resetsAt: 1_710_003_600_000
},
planType: "plus"
}
});
expect(service.getState().rateLimitsByLimitId.chatgpt_plus).toEqual(
summarizeRateLimit({
limitId: "chatgpt_plus",
limitName: "ChatGPT Plus",
primary: {
usedPercent: 38.4,
windowDurationMins: 180,
resetsAt: 1_710_000_000_000
},
secondary: {
usedPercent: 12.1,
windowDurationMins: 1440,
resetsAt: 1_710_003_600_000
},
planType: "plus"
})
);
});
it("clears local account state on logout", async () => {
const dir = await makeTempDir();
const service = new IslandflowDesktopAiService(dir, async () => {}, () => {});
const internal = service as any;
internal.client = {
request: async () => ({})
};
internal.state.account.loggedIn = true;
internal.state.account.email = "analyst@example.com";
internal.state.account.planType = "plus";
internal.state.account.login = { status: "browser_pending", message: "Waiting", loginId: "login-1", authUrl: "https://example.com" };
await service.logout();
expect(service.getState().account.loggedIn).toBe(false);
expect(service.getState().account.email).toBeNull();
expect(service.getState().account.planType).toBeNull();
expect(service.getState().account.login).toEqual({ status: "idle", message: "Logged out." });
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
import { app, BrowserWindow, shell } from "electron";
import type { Event as ElectronEvent } from "electron";
import { app, BrowserWindow, ipcMain, shell } from "electron";
import type { Event as ElectronEvent, IpcMainInvokeEvent } from "electron";
import { fileURLToPath } from "node:url";
import {
DESKTOP_PRODUCTION_URL,
@ -7,11 +8,25 @@ import {
isTrustedAppUrl,
resolveDesktopStartUrl
} from "./security.js";
import { IslandflowDesktopAiService } from "./desktop-ai.js";
import {
DESKTOP_AI_CANCEL_LOGIN,
DESKTOP_AI_GET_STATE,
DESKTOP_AI_LOGIN_BROWSER,
DESKTOP_AI_LOGIN_DEVICE,
DESKTOP_AI_LOGOUT,
DESKTOP_AI_RUN_TASK,
DESKTOP_AI_STATE_CHANNEL,
DESKTOP_AI_UPDATE_PREFERENCES
} from "./desktop-ai-ipc.js";
const WINDOW_BACKGROUND_COLOR = "#06080b";
const WINDOW_TITLE = "Islandflow";
let mainWindow: BrowserWindow | null = null;
let desktopAiService: IslandflowDesktopAiService | null = null;
const PRELOAD_PATH = fileURLToPath(new URL("./preload.js", import.meta.url));
const canOpenExternalUrl = (sourceUrl: string, targetUrl: string): boolean => {
return isTrustedAppUrl(sourceUrl) && isSafeExternalUrl(targetUrl);
@ -61,6 +76,7 @@ const createMainWindow = (): BrowserWindow => {
title: WINDOW_TITLE,
backgroundColor: WINDOW_BACKGROUND_COLOR,
webPreferences: {
preload: PRELOAD_PATH,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
@ -92,6 +108,68 @@ const createMainWindow = (): BrowserWindow => {
return window;
};
const broadcastDesktopAiState = (): void => {
if (!desktopAiService) {
return;
}
const state = desktopAiService.getState();
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send(DESKTOP_AI_STATE_CHANNEL, state);
}
};
const getTrustedSenderUrl = (event: IpcMainInvokeEvent): string => {
const senderUrl = event.senderFrame?.url || event.sender.getURL();
if (!isTrustedAppUrl(senderUrl)) {
throw new Error(`Rejected desktop AI IPC from untrusted origin: ${senderUrl || "unknown"}`);
}
return senderUrl;
};
const registerDesktopAiIpc = (service: IslandflowDesktopAiService): void => {
const guard = (event: IpcMainInvokeEvent): void => {
getTrustedSenderUrl(event);
};
ipcMain.handle(DESKTOP_AI_GET_STATE, async (event) => {
guard(event);
await service.start();
return service.getState();
});
ipcMain.handle(DESKTOP_AI_LOGIN_BROWSER, async (event) => {
guard(event);
await service.loginWithBrowser();
});
ipcMain.handle(DESKTOP_AI_LOGIN_DEVICE, async (event) => {
guard(event);
await service.loginWithDeviceCode();
});
ipcMain.handle(DESKTOP_AI_CANCEL_LOGIN, async (event) => {
guard(event);
await service.cancelLogin();
});
ipcMain.handle(DESKTOP_AI_LOGOUT, async (event) => {
guard(event);
await service.logout();
});
ipcMain.handle(DESKTOP_AI_UPDATE_PREFERENCES, async (event, next) => {
guard(event);
await service.updatePreferences(next);
});
ipcMain.handle(DESKTOP_AI_RUN_TASK, async (event, request) => {
guard(event);
return service.runTask(request);
});
};
const ensureMainWindow = (): void => {
if (mainWindow) {
return;
@ -101,6 +179,20 @@ const ensureMainWindow = (): void => {
};
app.whenReady().then(() => {
desktopAiService = new IslandflowDesktopAiService(
app.getPath("userData"),
async (url) => {
await shell.openExternal(url);
},
() => {
broadcastDesktopAiState();
}
);
registerDesktopAiIpc(desktopAiService);
void desktopAiService.start().catch((error) => {
console.error("[desktop-ai] Failed to start Codex bridge:", error);
broadcastDesktopAiState();
});
ensureMainWindow();
app.on("activate", () => {

View file

@ -0,0 +1,43 @@
import { contextBridge, ipcRenderer } from "electron";
import type {
IslandflowAiReasoningEffort,
IslandflowAiState,
IslandflowAiTaskRequest
} from "@islandflow/types";
import {
DESKTOP_AI_CANCEL_LOGIN,
DESKTOP_AI_GET_STATE,
DESKTOP_AI_LOGIN_BROWSER,
DESKTOP_AI_LOGIN_DEVICE,
DESKTOP_AI_LOGOUT,
DESKTOP_AI_RUN_TASK,
DESKTOP_AI_STATE_CHANNEL,
DESKTOP_AI_UPDATE_PREFERENCES
} from "./desktop-ai-ipc.js";
const bridge = {
ai: {
getState: (): Promise<IslandflowAiState> => ipcRenderer.invoke(DESKTOP_AI_GET_STATE),
loginWithBrowser: (): Promise<void> => ipcRenderer.invoke(DESKTOP_AI_LOGIN_BROWSER),
loginWithDeviceCode: (): Promise<void> => ipcRenderer.invoke(DESKTOP_AI_LOGIN_DEVICE),
cancelLogin: (): Promise<void> => ipcRenderer.invoke(DESKTOP_AI_CANCEL_LOGIN),
logout: (): Promise<void> => ipcRenderer.invoke(DESKTOP_AI_LOGOUT),
updatePreferences: (
next: Partial<{ model: string | null; reasoningEffort: IslandflowAiReasoningEffort | null }>
): Promise<void> => ipcRenderer.invoke(DESKTOP_AI_UPDATE_PREFERENCES, next),
runTask: (request: IslandflowAiTaskRequest): Promise<{ taskId: string }> =>
ipcRenderer.invoke(DESKTOP_AI_RUN_TASK, request),
subscribe: (listener: (state: IslandflowAiState) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, state: IslandflowAiState) => {
listener(state);
};
ipcRenderer.on(DESKTOP_AI_STATE_CHANNEL, handler);
return () => {
ipcRenderer.off(DESKTOP_AI_STATE_CHANNEL, handler);
};
}
}
};
contextBridge.exposeInMainWorld("islandflowDesktop", bridge);