From a8d183f38ebfa42f73fa7ad72cd3bf721f5678fc Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Wed, 20 May 2026 10:41:13 -0400 Subject: [PATCH 01/50] add desktop codex login and analyst copilot --- .beads/issues.jsonl | 2 + apps/desktop/package.json | 3 + apps/desktop/src/desktop-ai-ipc.ts | 8 + apps/desktop/src/desktop-ai.test.ts | 174 +++ apps/desktop/src/desktop-ai.ts | 1222 +++++++++++++++++ apps/desktop/src/main.ts | 96 +- apps/desktop/src/preload.ts | 43 + apps/desktop/tsconfig.json | 4 +- apps/web/app/charts/page.tsx | 4 +- apps/web/app/desktop-ai-panels.tsx | 924 +++++++++++++ apps/web/app/desktop-ai.tsx | 179 +++ apps/web/app/globals.css | 377 ++++- apps/web/app/layout.tsx | 5 +- apps/web/app/replay/page.tsx | 4 +- apps/web/app/routes.test.ts | 38 +- apps/web/app/settings/page.tsx | 7 + apps/web/app/signals/page.tsx | 4 +- apps/web/app/terminal.test.ts | 26 +- apps/web/app/terminal.tsx | 256 +++- apps/web/next-env.d.ts | 3 +- bun.lock | 3 + ...05-20-codex-desktop-login-and-copilot.html | 538 ++++++++ packages/types/src/desktop-ai.ts | 303 ++++ packages/types/src/index.ts | 1 + 24 files changed, 4127 insertions(+), 97 deletions(-) create mode 100644 apps/desktop/src/desktop-ai-ipc.ts create mode 100644 apps/desktop/src/desktop-ai.test.ts create mode 100644 apps/desktop/src/desktop-ai.ts create mode 100644 apps/desktop/src/preload.ts create mode 100644 apps/web/app/desktop-ai-panels.tsx create mode 100644 apps/web/app/desktop-ai.tsx create mode 100644 apps/web/app/settings/page.tsx create mode 100644 docs/turns/2026-05-20-codex-desktop-login-and-copilot.html create mode 100644 packages/types/src/desktop-ai.ts diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 245689b..8653c73 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -16,6 +16,7 @@ {"_type":"issue","id":"islandflow-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-6tn","title":"Add Codex desktop login and usage bridge","description":"Implement a desktop-only Codex integration for the Islandflow Electron app using the official codex app-server with managed ChatGPT login, native IPC, settings UI, usage tracking, and clean web degradation.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-20T14:01:36Z","created_by":"dirtydishes","updated_at":"2026-05-20T14:40:49Z","started_at":"2026-05-20T14:01:48Z","closed_at":"2026-05-20T14:40:49Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-laq","title":"fix native alpaca news deploy and auth","description":"Why this issue exists and what needs to be done:\\n\\nNative Islandflow rollout is incomplete because services/ingest-news is not healthy on the VPS. The checked-in native user units and helper scripts do not fully include ingest-news, and the current service uses bearer-style auth that returns 401 against Alpaca news endpoints.\\n\\nThis task should verify the current Alpaca news auth requirements against official docs, update the repo code and native deployment assets as needed, install and enable the missing VPS unit, verify news events flow end-to-end, and document the work.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T23:47:07Z","created_by":"dirtydishes","updated_at":"2026-05-20T00:05:20Z","started_at":"2026-05-19T23:47:12Z","closed_at":"2026-05-20T00:05:20Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-fmg","title":"Fix native deploy SSH path and verification cwd assumptions","description":"Native deploys over SSH assumed bun was already on PATH and that remote verification would run from the repository root. On the live VPS, non-login SSH shells omitted /home/delta/.bun/bin and remote native verification could not find deployment/native/check-native-infra.sh because it ran from the home directory. Update the deploy helper to prepend /Users/kell/.bun/bin when present and cd into the repo before native verification checks run.","status":"closed","priority":2,"issue_type":"bug","owner":"dishes@dpdrm.com","created_at":"2026-05-19T23:38:32Z","created_by":"dirtydishes","updated_at":"2026-05-19T23:40:33Z","closed_at":"2026-05-19T23:40:33Z","close_reason":"Updated native SSH deploy flow to prepend Bun's home install path when present and run native verification from the repo root before health scripts.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-wf5","title":"Harden native options provider configuration after synthetic recovery","description":"Native production recovery restored OPTIONS_INGEST_ADAPTER=synthetic because the current Alpaca setup fails authentication and crash-loops ingest-options. Follow up by deciding whether production options should remain synthetic or move to a supported live provider auth path, then add a deploy-time smoke test or config validation that catches provider auth failures before native cutover.","status":"open","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-19T23:27:51Z","created_by":"dirtydishes","updated_at":"2026-05-19T23:27:51Z","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -61,6 +62,7 @@ {"_type":"issue","id":"islandflow-zs0","title":"Migrate terminal UI to smart-money profiles","description":"Migrate apps/web terminal rendering to consume SmartMoneyEvent directly: primary profile, probability ladder, reason codes, and suppression/abstention state, while preserving legacy alert/classifier displays during the bridge.","status":"closed","priority":2,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-04T21:35:23Z","created_by":"dirtydishes","updated_at":"2026-05-05T05:39:58Z","closed_at":"2026-05-05T05:39:58Z","close_reason":"Completed terminal smart-money profile migration","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-igk","title":"Add plan mode","description":"Implement a user-facing plan mode in the application so users can switch into planning before taking action. Scope to be clarified from existing app patterns.","status":"closed","priority":2,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-04T04:22:37Z","created_by":"dirtydishes","updated_at":"2026-05-04T04:26:18Z","started_at":"2026-05-04T04:22:40Z","closed_at":"2026-05-04T04:26:18Z","close_reason":"Implemented as a global pi extension toggled with Shift+P","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-biq","title":"Finish raw live options delivery and filter/backpressure observability","description":"The smart-money signal path and Tape filters are in place, but the next firehose pass should finish server-side selective raw live delivery for options subscriptions and add explicit filtered-out/backpressure observability for API/web counters. This was discovered while landing islandflow-e4r.\n","status":"in_progress","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:28:58Z","created_by":"dirtydishes","updated_at":"2026-04-29T03:54:12Z","started_at":"2026-04-29T03:54:12Z","dependencies":[{"issue_id":"islandflow-biq","depends_on_id":"islandflow-e4r","type":"discovered-from","created_at":"2026-04-28T16:28:58Z","created_by":"auto-import","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-8vr","title":"Summarize 2026-05-19 git activity for standup","description":"Create the daily git summary for 2026-05-19 in docs/general using yesterday's commits, touched files, and validation evidence only.","status":"closed","priority":3,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-20T13:02:41Z","created_by":"dirtydishes","updated_at":"2026-05-20T13:04:50Z","started_at":"2026-05-20T13:02:47Z","closed_at":"2026-05-20T13:04:50Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-0ty","title":"Recreate May 18 standup summary after merge","description":"Regenerate docs/daily-git/2026-05-19-standup-summary-2026-05-18.html using merged history so it reflects all commits in the May 18 window, including native deployment and merge commits.","status":"closed","priority":3,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T18:53:48Z","created_by":"dirtydishes","updated_at":"2026-05-19T18:55:33Z","started_at":"2026-05-19T18:53:52Z","closed_at":"2026-05-19T18:55:33Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-2df","title":"Publish 2026-05-18 git standup summary","description":"Why: the daily automation needs a grounded standup summary for May 18, 2026. What: review commits from 2026-05-18, create a scannable HTML summary in docs/daily-git, and capture only commit/file-backed statements.","status":"closed","priority":3,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-19T18:41:07Z","created_by":"dirtydishes","updated_at":"2026-05-19T18:42:42Z","started_at":"2026-05-19T18:41:10Z","closed_at":"2026-05-19T18:42:42Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-x70","title":"Create 2026-05-17 git standup summary","description":"Why this issue exists and what needs to be done:\\n- Produce the daily automation summary for 2026-05-17 git activity.\\n- Ground statements in commits, PRs, and touched files only.\\n- Create a user-readable HTML document in docs/general and update automation memory.\\n- Complete the Beads sync and git push workflow after documenting the run.","status":"closed","priority":3,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-18T13:01:43Z","created_by":"dirtydishes","updated_at":"2026-05-18T13:05:37Z","started_at":"2026-05-18T13:01:53Z","closed_at":"2026-05-18T13:05:37Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c46915b..8a3c3e5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -12,6 +12,9 @@ "package": "bun run build && electron-forge package", "make": "bun run build && electron-forge make" }, + "dependencies": { + "@islandflow/types": "workspace:*" + }, "devDependencies": { "@electron-forge/cli": "^7.8.1", "@electron-forge/core": "^7.11.1", diff --git a/apps/desktop/src/desktop-ai-ipc.ts b/apps/desktop/src/desktop-ai-ipc.ts new file mode 100644 index 0000000..25e53f6 --- /dev/null +++ b/apps/desktop/src/desktop-ai-ipc.ts @@ -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"; diff --git a/apps/desktop/src/desktop-ai.test.ts b/apps/desktop/src/desktop-ai.test.ts new file mode 100644 index 0000000..53f4822 --- /dev/null +++ b/apps/desktop/src/desktop-ai.test.ts @@ -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 => { + 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." }); + }); +}); diff --git a/apps/desktop/src/desktop-ai.ts b/apps/desktop/src/desktop-ai.ts new file mode 100644 index 0000000..8d695a2 --- /dev/null +++ b/apps/desktop/src/desktop-ai.ts @@ -0,0 +1,1222 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { + IslandflowAiCompiledScreenSchema, + IslandflowAiProfileModeSchema, + IslandflowAiReasoningEffortSchema, + IslandflowAiTaskRequestSchema, + type IslandflowAiCompiledScreen, + type IslandflowAiModelSummary, + type IslandflowAiPlanType, + type IslandflowAiPricing, + type IslandflowAiProfileMode, + type IslandflowAiRateLimitSnapshot, + type IslandflowAiReasoningEffort, + type IslandflowAiState, + type IslandflowAiTaskKind, + type IslandflowAiTaskRequest, + type IslandflowAiTaskSnapshot, + type IslandflowAiTokenBreakdown, + type IslandflowAiUsageRollup, + type IslandflowAiUsageTurnRecord +} from "@islandflow/types"; + +const MANAGED_CHATGPT_PROFILE_ID = "managed-chatgpt"; +const WORKSPACE_PROVIDER_PROFILE_ID = "workspace-provider"; +const APP_SERVER_SERVICE_NAME = "Islandflow Analyst Copilot"; +const APP_SERVER_SANDBOX_CWD = "copilot-sandbox"; +const PREFERENCES_FILE = "copilot-preferences.json"; +const USAGE_FILE = "copilot-usage.json"; +const DEFAULT_REASONING = IslandflowAiReasoningEffortSchema.parse("high"); + +const EMPTY_BREAKDOWN: IslandflowAiTokenBreakdown = { + totalTokens: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0 +}; + +type JsonRpcSuccess = { + id: number; + result: unknown; +}; + +type JsonRpcFailure = { + id: number; + error: { + message?: string; + code?: number; + data?: unknown; + }; +}; + +type JsonRpcNotification = { + method: string; + params?: unknown; + id?: never; +}; + +type JsonRpcServerRequest = { + id: number; + method: string; + params?: unknown; +}; + +type JsonRpcMessage = JsonRpcSuccess | JsonRpcFailure | JsonRpcNotification | JsonRpcServerRequest; + +type CodexModelRecord = { + id: string; + model: string; + displayName: string; + description: string; + hidden: boolean; + isDefault: boolean; + supportedReasoningEfforts: Array<{ reasoningEffort: IslandflowAiReasoningEffort }>; + defaultReasoningEffort: IslandflowAiReasoningEffort | null; +}; + +type CodexThreadStartResult = { + thread: { + id: string; + }; + model: string; + reasoningEffort: IslandflowAiReasoningEffort | null; +}; + +type CodexTurnStartResult = { + turn: { + id: string; + }; +}; + +type PersistedUsageStore = { + version: 1; + turns: Record; +}; + +type PersistedPreferences = { + model: string | null; + reasoningEffort: IslandflowAiReasoningEffort | null; +}; + +type OpenExternalFn = (url: string) => Promise; + +type ActiveTaskContext = { + taskId: string; + taskKind: IslandflowAiTaskKind; + taskTitle: string; + profileId: string; +}; + +const MODEL_PRICING: Record = { + "gpt-5.5": { + inputUsdPer1MTokens: 5, + cachedInputUsdPer1MTokens: 0.5, + outputUsdPer1MTokens: 30, + sourceLabel: "OpenAI GPT-5.5 model pricing", + sourceUrl: "https://developers.openai.com/api/docs/models/gpt-5.5" + }, + "gpt-5.4": { + inputUsdPer1MTokens: 2.5, + cachedInputUsdPer1MTokens: 0.25, + outputUsdPer1MTokens: 15, + sourceLabel: "OpenAI GPT-5.4 model pricing", + sourceUrl: "https://developers.openai.com/api/docs/models/gpt-5.4" + }, + "gpt-5.4-mini": { + inputUsdPer1MTokens: 0.75, + cachedInputUsdPer1MTokens: 0.075, + outputUsdPer1MTokens: 4.5, + sourceLabel: "OpenAI GPT-5.4 mini model pricing", + sourceUrl: "https://developers.openai.com/api/docs/models/gpt-5.4-mini" + }, + "gpt-5.3-codex": { + inputUsdPer1MTokens: 1.75, + cachedInputUsdPer1MTokens: 0.175, + outputUsdPer1MTokens: 14, + sourceLabel: "OpenAI GPT-5.3-Codex model pricing", + sourceUrl: "https://developers.openai.com/api/docs/models/gpt-5.3-codex" + }, + "gpt-5.2": { + inputUsdPer1MTokens: 1.75, + cachedInputUsdPer1MTokens: 0.175, + outputUsdPer1MTokens: 14, + sourceLabel: "OpenAI GPT-5.2 model pricing", + sourceUrl: "https://developers.openai.com/api/docs/models/gpt-5.2" + }, + "gpt-5.2-codex": { + inputUsdPer1MTokens: 1.75, + cachedInputUsdPer1MTokens: 0.175, + outputUsdPer1MTokens: 14, + sourceLabel: "OpenAI GPT-5.2-Codex model pricing", + sourceUrl: "https://developers.openai.com/api/docs/models/gpt-5.2-codex" + }, + "gpt-5-codex": { + inputUsdPer1MTokens: 1.25, + cachedInputUsdPer1MTokens: 0.125, + outputUsdPer1MTokens: 10, + sourceLabel: "OpenAI GPT-5-Codex model pricing", + sourceUrl: "https://developers.openai.com/api/docs/models/gpt-5-codex" + }, + "codex-mini-latest": { + inputUsdPer1MTokens: 1.5, + cachedInputUsdPer1MTokens: 0.375, + outputUsdPer1MTokens: 6, + sourceLabel: "OpenAI codex-mini-latest model pricing", + sourceUrl: "https://developers.openai.com/api/docs/models/codex-mini-latest" + } +}; + +const createEmptyUsageRollup = (): IslandflowAiUsageRollup => ({ + breakdown: { ...EMPTY_BREAKDOWN }, + normalizedCostUsd: 0, + turnCount: 0, + activeDays: 0 +}); + +const createInitialState = (): IslandflowAiState => ({ + desktopAvailable: true, + transportStatus: "starting", + transportError: null, + profiles: [ + { + id: MANAGED_CHATGPT_PROFILE_ID, + label: "Managed ChatGPT login", + description: "User-scoped ChatGPT or Codex sign-in managed by the official app-server.", + mode: "managed-chatgpt", + enabled: true, + selected: true, + statusLabel: "Active" + }, + { + id: WORKSPACE_PROVIDER_PROFILE_ID, + label: "Workspace provider slot", + description: "Reserved for future shared API-key or enterprise access-token flows.", + mode: "workspace-provider", + enabled: false, + selected: false, + statusLabel: "Reserved" + } + ], + selectedProfileId: MANAGED_CHATGPT_PROFILE_ID, + account: { + loggedIn: false, + email: null, + planType: null, + authMode: null, + requiresOpenaiAuth: true, + login: { status: "idle", message: null } + }, + preferences: { + model: null, + reasoningEffort: DEFAULT_REASONING + }, + models: [], + rateLimitsByLimitId: {}, + usage: { + today: createEmptyUsageRollup(), + lifetime: createEmptyUsageRollup(), + recentTurns: [] + }, + tasks: [], + updatedAt: Date.now() +}); + +const buildUsageKey = (threadId: string, turnId: string): string => `${threadId}:${turnId}`; + +const normalizeBreakdown = (value: Partial | null | undefined): IslandflowAiTokenBreakdown => ({ + totalTokens: value?.totalTokens ?? 0, + inputTokens: value?.inputTokens ?? 0, + cachedInputTokens: value?.cachedInputTokens ?? 0, + outputTokens: value?.outputTokens ?? 0, + reasoningOutputTokens: value?.reasoningOutputTokens ?? 0 +}); + +const addBreakdowns = ( + left: IslandflowAiTokenBreakdown, + right: IslandflowAiTokenBreakdown +): IslandflowAiTokenBreakdown => ({ + totalTokens: left.totalTokens + right.totalTokens, + inputTokens: left.inputTokens + right.inputTokens, + cachedInputTokens: left.cachedInputTokens + right.cachedInputTokens, + outputTokens: left.outputTokens + right.outputTokens, + reasoningOutputTokens: left.reasoningOutputTokens + right.reasoningOutputTokens +}); + +const isoDayKey = (timestampMs: number): string => new Date(timestampMs).toISOString().slice(0, 10); + +const sanitizeJsonText = (value: string): string => { + const trimmed = value.trim(); + if (trimmed.startsWith("```")) { + return trimmed.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, ""); + } + return trimmed; +}; + +const estimateNormalizedCost = ( + model: string | null, + breakdown: IslandflowAiTokenBreakdown +): number | null => { + if (!model) { + return null; + } + const pricing = MODEL_PRICING[model]; + if (!pricing) { + return null; + } + const outputBillableTokens = breakdown.outputTokens + breakdown.reasoningOutputTokens; + const usd = + (breakdown.inputTokens / 1_000_000) * pricing.inputUsdPer1MTokens + + (breakdown.cachedInputTokens / 1_000_000) * pricing.cachedInputUsdPer1MTokens + + (outputBillableTokens / 1_000_000) * pricing.outputUsdPer1MTokens; + return Number(usd.toFixed(6)); +}; + +const compactTaskList = (tasks: IslandflowAiTaskSnapshot[]): IslandflowAiTaskSnapshot[] => + [...tasks].sort((left, right) => right.updatedAt - left.updatedAt).slice(0, 24); + +export const summarizeRateLimit = (snapshot: any): IslandflowAiRateLimitSnapshot => ({ + limitId: typeof snapshot?.limitId === "string" ? snapshot.limitId : null, + limitName: typeof snapshot?.limitName === "string" ? snapshot.limitName : null, + primary: snapshot?.primary + ? { + usedPercent: Number(snapshot.primary.usedPercent ?? 0), + windowDurationMins: + snapshot.primary.windowDurationMins === null || snapshot.primary.windowDurationMins === undefined + ? null + : Number(snapshot.primary.windowDurationMins), + resetsAt: + snapshot.primary.resetsAt === null || snapshot.primary.resetsAt === undefined + ? null + : Number(snapshot.primary.resetsAt) + } + : null, + secondary: snapshot?.secondary + ? { + usedPercent: Number(snapshot.secondary.usedPercent ?? 0), + windowDurationMins: + snapshot.secondary.windowDurationMins === null || snapshot.secondary.windowDurationMins === undefined + ? null + : Number(snapshot.secondary.windowDurationMins), + resetsAt: + snapshot.secondary.resetsAt === null || snapshot.secondary.resetsAt === undefined + ? null + : Number(snapshot.secondary.resetsAt) + } + : null, + planType: snapshot?.planType ?? null, + reachedType: snapshot?.rateLimitReachedType ?? null, + hasCredits: + snapshot?.credits?.hasCredits === undefined || snapshot?.credits?.hasCredits === null + ? null + : Boolean(snapshot.credits.hasCredits), + unlimitedCredits: + snapshot?.credits?.unlimited === undefined || snapshot?.credits?.unlimited === null + ? null + : Boolean(snapshot.credits.unlimited), + creditsBalance: typeof snapshot?.credits?.balance === "string" ? snapshot.credits.balance : null +}); + +export const createAppServerChildEnv = ( + profileMode: IslandflowAiProfileMode, + baseEnv: NodeJS.ProcessEnv = process.env +): NodeJS.ProcessEnv => { + const childEnv = { ...baseEnv }; + if (profileMode !== "api-key") { + delete childEnv.OPENAI_API_KEY; + delete childEnv.CODEX_API_KEY; + } + return childEnv; +}; + +const createTaskSnapshot = (request: IslandflowAiTaskRequest): Pick => { + switch (request.kind) { + case "smart-money-explain": + return { + kind: request.kind, + title: "Explain smart money event", + subtitle: `${request.context.event.underlying_id} · ${request.context.event.primary_direction}` + }; + case "smart-money-skeptic": + return { + kind: request.kind, + title: "Counter-thesis pass", + subtitle: `${request.context.event.underlying_id} · skepticism` + }; + case "smart-money-burst-summary": + return { + kind: request.kind, + title: "Burst summary", + subtitle: `${request.context.event.underlying_id} · related packets` + }; + case "watchlist-synthesis": + return { + kind: request.kind, + title: "Watchlist synthesis", + subtitle: `${request.context.event.underlying_id} · setups` + }; + case "replay-postmortem": + return { + kind: request.kind, + title: "Replay postmortem", + subtitle: `${request.context.ticker ?? "All symbols"} · replay session` + }; + case "screen-compile": + return { + kind: request.kind, + title: "Natural-language screen", + subtitle: request.context.prompt + }; + } + + throw new Error("Unsupported Copilot task kind."); +}; + +const SMART_MONEY_RUBRIC = [ + "Treat the deterministic classifier and event payload as the source of truth.", + "Act as an evidence interpreter, not the live classifier.", + "Use only the provided structured payloads, do not call tools or inspect the filesystem.", + "Lead with the clearest thesis, but include uncertainty, missing evidence, and alternate explanations.", + "Prefer practical market structure language: aggressor side, concentration, event timing, IV shock, NBBO quality, and packet construction.", + "Do not pretend to know price action or fundamentals beyond the supplied data.", + "When the data suggests retail frenzy, dealer hedging, volatility selling, or arbitrage, say so plainly.", + "Keep the answer terse, structured, and useful under pressure." +].join("\n"); + +const BASE_INSTRUCTIONS = [ + "You are Islandflow Analyst Copilot.", + "Work only from the structured Islandflow context provided in the user message.", + "Never call tools, never browse, and never inspect files.", + "If evidence is missing or ambiguous, say that directly." +].join("\n"); + +const buildUserPrompt = (request: IslandflowAiTaskRequest): string => { + switch (request.kind) { + case "smart-money-explain": + return [ + "Explain this selected smart-money event for a trader who wants the key evidence fast.", + "Output sections named Thesis, Evidence, Caveats, and What To Watch.", + JSON.stringify(request.context, null, 2) + ].join("\n\n"); + case "smart-money-skeptic": + return [ + "Run a skepticism pass on this selected smart-money event.", + "Output sections named Why It Might Be Wrong, Alternate Microstructure Explanations, Missing Evidence, and Confidence Check.", + JSON.stringify(request.context, null, 2) + ].join("\n\n"); + case "smart-money-burst-summary": + return [ + "Summarize the burst across the related packets for this selected smart-money event.", + "Output sections named Burst Read, Packet Relationships, Quality Flags, and Trading Relevance.", + JSON.stringify(request.context, null, 2) + ].join("\n\n"); + case "watchlist-synthesis": + return [ + "Turn this event into a practical watchlist and setup brief.", + "Output sections named Watchlist, Trigger Levels Or Conditions, Invalidations, and Session Notes.", + JSON.stringify(request.context, null, 2) + ].join("\n\n"); + case "replay-postmortem": + return [ + "Write a replay postmortem from this structured replay slice.", + "Output sections named Session Read, Best Evidence, What Was Noise, and Follow-up Questions.", + JSON.stringify(request.context, null, 2) + ].join("\n\n"); + case "screen-compile": + return [ + "Compile this natural-language screen into the existing Islandflow filter model where possible.", + "Return only valid JSON that matches the requested schema.", + JSON.stringify(request.context, null, 2) + ].join("\n\n"); + } + + throw new Error("Unsupported Copilot task kind."); +}; + +const buildScreenOutputSchema = () => ({ + type: "object", + additionalProperties: false, + required: ["compiledFilters", "rationale", "unhandledClauses", "sanitizedPrompt"], + properties: { + compiledFilters: { + anyOf: [ + { + type: "object", + additionalProperties: false, + properties: { + view: { type: "string", enum: ["signal", "raw"] }, + securityTypes: { + type: "array", + items: { type: "string", enum: ["stock", "etf"] } + }, + nbboSides: { + type: "array", + items: { type: "string", enum: ["AA", "A", "MID", "B", "BB", "MISSING", "STALE"] } + }, + optionTypes: { + type: "array", + items: { type: "string", enum: ["call", "put"] } + }, + minNotional: { type: "number", minimum: 0 } + } + }, + { type: "null" } + ] + }, + rationale: { type: "string" }, + unhandledClauses: { + type: "array", + items: { type: "string" } + }, + sanitizedPrompt: { type: "string" } + } +}); + +const createUsageStore = (): PersistedUsageStore => ({ + version: 1, + turns: {} +}); + +class CodexAppServerClient { + private child: ChildProcessWithoutNullStreams | null = null; + private readonly pending = new Map< + number, + { resolve: (value: any) => void; reject: (error: Error) => void; timeout: ReturnType } + >(); + private buffer = ""; + private nextId = 1; + + constructor( + private readonly sandboxCwd: string, + private readonly onNotification: (method: string, params: unknown) => Promise | void, + private readonly onExit: (reason: string) => Promise | void + ) {} + + async start(profileMode: IslandflowAiProfileMode): Promise { + if (this.child) { + return; + } + + await mkdir(this.sandboxCwd, { recursive: true }); + + this.child = spawn("codex", ["app-server"], { + stdio: ["pipe", "pipe", "pipe"], + env: createAppServerChildEnv(profileMode) + }); + + this.child.stdout.setEncoding("utf8"); + this.child.stderr.setEncoding("utf8"); + + this.child.stdout.on("data", (chunk: string) => { + this.buffer += chunk; + void this.flushBuffer(); + }); + + this.child.stderr.on("data", (chunk: string) => { + console.warn(`[desktop-ai] ${chunk.trim()}`); + }); + + this.child.once("exit", (code, signal) => { + this.child = null; + this.buffer = ""; + for (const [id, pending] of this.pending.entries()) { + clearTimeout(pending.timeout); + pending.reject(new Error(`Codex app-server exited before replying to request ${id}.`)); + } + this.pending.clear(); + void this.onExit(`app-server exited${code !== null ? ` (${code})` : ""}${signal ? ` via ${signal}` : ""}`); + }); + + await this.request("initialize", { + clientInfo: { + name: "islandflow-desktop", + title: "Islandflow Desktop", + version: "0.1.0" + }, + capabilities: { + experimentalApi: true, + requestAttestation: false, + optOutNotificationMethods: [ + "app/list/updated", + "remoteControl/status/changed", + "skills/changed", + "plugin/installed" + ] + } + }); + + this.notify("initialized"); + } + + async stop(): Promise { + if (!this.child) { + return; + } + this.child.kill("SIGTERM"); + this.child = null; + } + + async request(method: string, params: unknown): Promise { + if (!this.child) { + throw new Error("Codex app-server is not running."); + } + + const id = this.nextId++; + const payload = JSON.stringify({ id, method, params }) + "\n"; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Timed out waiting for ${method}.`)); + }, 30_000); + + this.pending.set(id, { resolve, reject, timeout }); + this.child?.stdin.write(payload); + }); + } + + private notify(method: string, params?: unknown): void { + if (!this.child) { + return; + } + this.child.stdin.write(JSON.stringify(params === undefined ? { method } : { method, params }) + "\n"); + } + + private async flushBuffer(): Promise { + while (true) { + const newlineIndex = this.buffer.indexOf("\n"); + if (newlineIndex === -1) { + return; + } + + const line = this.buffer.slice(0, newlineIndex).trim(); + this.buffer = this.buffer.slice(newlineIndex + 1); + if (!line) { + continue; + } + + const message = JSON.parse(line) as JsonRpcMessage; + if ("id" in message && "result" in message) { + const pending = this.pending.get(message.id); + if (pending) { + clearTimeout(pending.timeout); + this.pending.delete(message.id); + pending.resolve(message.result); + } + continue; + } + + if ("id" in message && "error" in message) { + const pending = this.pending.get(message.id); + if (pending) { + clearTimeout(pending.timeout); + this.pending.delete(message.id); + pending.reject(new Error(message.error.message ?? `Request ${message.id} failed.`)); + } + continue; + } + + if (typeof (message as Partial).id === "number" && "method" in message) { + this.respondUnsupported(message as JsonRpcServerRequest); + continue; + } + + if ("method" in message) { + await this.onNotification(message.method, message.params); + } + } + } + + private respondUnsupported(message: JsonRpcServerRequest): void { + if (!this.child) { + return; + } + this.child.stdin.write( + JSON.stringify({ + id: message.id, + error: { + message: `Islandflow desktop does not support server request ${message.method}.` + } + }) + "\n" + ); + } +} + +export class IslandflowDesktopAiService { + private readonly preferencesPath: string; + private readonly usagePath: string; + private readonly sandboxCwd: string; + private readonly client: CodexAppServerClient; + private readonly activeTasksByThreadId = new Map(); + private usageStore: PersistedUsageStore = createUsageStore(); + private state: IslandflowAiState = createInitialState(); + private serviceTier: string | null = null; + private started = false; + + constructor( + userDataPath: string, + private readonly openExternalUrl: OpenExternalFn, + private readonly publishState: (state: IslandflowAiState) => void + ) { + this.preferencesPath = path.join(userDataPath, PREFERENCES_FILE); + this.usagePath = path.join(userDataPath, USAGE_FILE); + this.sandboxCwd = path.join(userDataPath, APP_SERVER_SANDBOX_CWD); + this.client = new CodexAppServerClient( + this.sandboxCwd, + async (method, params) => { + await this.handleNotification(method, params); + }, + async (reason) => { + this.state.transportStatus = "restarting"; + this.state.transportError = reason; + this.failActiveTasks(reason); + this.emitState(); + } + ); + } + + async start(): Promise { + if (this.started) { + return; + } + this.started = true; + await mkdir(path.dirname(this.preferencesPath), { recursive: true }); + await mkdir(this.sandboxCwd, { recursive: true }); + await this.loadPreferences(); + await this.loadUsageStore(); + await this.ensureClientReady(); + } + + getState(): IslandflowAiState { + return this.state; + } + + async loginWithBrowser(): Promise { + await this.start(); + await this.ensureClientReady(); + + const result = await this.client.request("account/login/start", { + type: "chatgpt", + codexStreamlinedLogin: true + }); + + this.state.account.login = { + status: "browser_pending", + message: "Waiting for browser sign-in to complete.", + loginId: String(result.loginId), + authUrl: String(result.authUrl) + }; + this.emitState(); + await this.openExternalUrl(String(result.authUrl)); + } + + async loginWithDeviceCode(): Promise { + await this.start(); + await this.ensureClientReady(); + + const result = await this.client.request("account/login/start", { + type: "chatgptDeviceCode" + }); + + this.state.account.login = { + status: "device_code_pending", + message: "Enter the device code in your browser to finish sign-in.", + loginId: String(result.loginId), + verificationUrl: String(result.verificationUrl), + userCode: String(result.userCode) + }; + this.emitState(); + await this.openExternalUrl(String(result.verificationUrl)); + } + + async cancelLogin(): Promise { + const login = this.state.account.login; + if (login.status !== "browser_pending" && login.status !== "device_code_pending") { + return; + } + await this.client.request("account/login/cancel", { loginId: login.loginId }); + this.state.account.login = { status: "idle", message: "Login cancelled." }; + this.emitState(); + } + + async logout(): Promise { + await this.client.request("account/logout", {}); + this.state.account.loggedIn = false; + this.state.account.email = null; + this.state.account.planType = null; + this.state.account.login = { status: "idle", message: "Logged out." }; + this.emitState(); + } + + async updatePreferences( + next: Partial<{ model: string | null; reasoningEffort: IslandflowAiReasoningEffort | null }> + ): Promise { + this.state.preferences = { + model: next.model === undefined ? this.state.preferences.model : next.model, + reasoningEffort: + next.reasoningEffort === undefined + ? this.state.preferences.reasoningEffort + : next.reasoningEffort + }; + await this.savePreferences(); + this.emitState(); + } + + async runTask(rawRequest: unknown): Promise<{ taskId: string }> { + await this.start(); + + if (!this.state.account.loggedIn) { + throw new Error("Log into a ChatGPT or Codex account first."); + } + + const request = IslandflowAiTaskRequestSchema.parse(rawRequest); + const meta = createTaskSnapshot(request); + const taskId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const task: IslandflowAiTaskSnapshot = { + taskId, + kind: meta.kind, + title: meta.title, + subtitle: meta.subtitle, + status: "queued", + createdAt: Date.now(), + updatedAt: Date.now(), + threadId: null, + turnId: null, + model: this.state.preferences.model, + reasoningEffort: this.state.preferences.reasoningEffort, + text: "", + error: null, + compiledScreen: null + }; + + this.state.tasks = compactTaskList([task, ...this.state.tasks]); + this.emitState(); + + try { + await this.ensureClientReady(); + + const thread = await this.client.request("thread/start", { + model: this.state.preferences.model ?? undefined, + cwd: this.sandboxCwd, + approvalPolicy: "never", + sandbox: "read-only", + serviceName: APP_SERVER_SERVICE_NAME, + baseInstructions: BASE_INSTRUCTIONS, + developerInstructions: SMART_MONEY_RUBRIC, + ephemeral: true, + serviceTier: this.serviceTier ?? undefined + }); + + this.activeTasksByThreadId.set(thread.thread.id, { + taskId, + taskKind: task.kind, + taskTitle: task.title, + profileId: this.state.selectedProfileId + }); + this.patchTask(taskId, { + status: "running", + threadId: thread.thread.id, + model: thread.model, + reasoningEffort: thread.reasoningEffort ?? this.state.preferences.reasoningEffort + }); + + const turn = await this.client.request("turn/start", { + threadId: thread.thread.id, + input: [ + { + type: "text", + text: buildUserPrompt(request), + text_elements: [] + } + ], + model: this.state.preferences.model ?? undefined, + effort: this.state.preferences.reasoningEffort ?? undefined, + serviceTier: this.serviceTier ?? undefined, + outputSchema: request.kind === "screen-compile" ? buildScreenOutputSchema() : undefined + }); + + this.patchTask(taskId, { + turnId: turn.turn.id + }); + + return { taskId }; + } catch (error) { + this.patchTask(taskId, { + status: "failed", + error: error instanceof Error ? error.message : String(error) + }); + throw error; + } + } + + private async ensureClientReady(): Promise { + const selectedProfile = this.resolveSelectedProfileMode(); + this.state.transportStatus = this.state.transportStatus === "restarting" ? "restarting" : "starting"; + this.emitState(); + + try { + await this.client.start(selectedProfile); + await this.refreshServerState(); + this.state.transportStatus = "ready"; + this.state.transportError = null; + this.emitState(); + } catch (error) { + this.state.transportStatus = "error"; + this.state.transportError = error instanceof Error ? error.message : String(error); + this.emitState(); + throw error; + } + } + + private async refreshServerState(): Promise { + const [config, models, account, auth, rateLimits] = await Promise.all([ + this.client.request("config/read", {}), + this.client.request("model/list", {}), + this.client.request("account/read", { refreshToken: false }), + this.client.request("getAuthStatus", {}), + this.client.request("account/rateLimits/read", {}) + ]); + + this.serviceTier = typeof config?.config?.service_tier === "string" ? config.config.service_tier : null; + const configModel = typeof config?.config?.model === "string" ? config.config.model : null; + const configReasoning = + config?.config?.model_reasoning_effort === null || config?.config?.model_reasoning_effort === undefined + ? null + : IslandflowAiReasoningEffortSchema.parse(config.config.model_reasoning_effort); + + if (!this.state.preferences.model) { + this.state.preferences.model = configModel; + } + if (!this.state.preferences.reasoningEffort) { + this.state.preferences.reasoningEffort = configReasoning ?? DEFAULT_REASONING; + } + + this.state.models = (Array.isArray(models?.data) ? models.data : []) + .filter((model: CodexModelRecord) => !model.hidden) + .map((model: CodexModelRecord): IslandflowAiModelSummary => ({ + id: model.id, + model: model.model, + displayName: model.displayName, + description: model.description, + isDefault: Boolean(model.isDefault), + supportedReasoningEfforts: model.supportedReasoningEfforts.map((entry) => entry.reasoningEffort), + defaultReasoningEffort: model.defaultReasoningEffort, + pricing: MODEL_PRICING[model.model] ?? null + })); + + this.state.account.loggedIn = Boolean(account?.account); + this.state.account.email = + account?.account?.type === "chatgpt" && typeof account.account.email === "string" + ? account.account.email + : null; + this.state.account.planType = + account?.account?.type === "chatgpt" ? (account.account.planType as IslandflowAiPlanType) : null; + this.state.account.authMode = auth?.authMethod ?? null; + this.state.account.requiresOpenaiAuth = Boolean(account?.requiresOpenaiAuth ?? auth?.requiresOpenaiAuth ?? true); + if (this.state.account.login.status === "idle") { + this.state.account.login = { + status: "idle", + message: this.state.account.loggedIn ? "Connected." : null + }; + } + + this.state.rateLimitsByLimitId = this.normalizeRateLimitBuckets(rateLimits); + this.rebuildUsageDashboard(); + } + + private normalizeRateLimitBuckets(payload: any): Record { + const bucketEntries = Object.entries(payload?.rateLimitsByLimitId ?? {}); + if (bucketEntries.length === 0 && payload?.rateLimits) { + const single = summarizeRateLimit(payload.rateLimits); + return { + [single.limitId ?? "default"]: single + }; + } + + return Object.fromEntries( + bucketEntries.map(([key, value]) => [key, summarizeRateLimit(value)]) + ); + } + + private async handleNotification(method: string, params: unknown): Promise { + switch (method) { + case "account/updated": { + const payload = params as { authMode: string | null; planType: IslandflowAiPlanType | null }; + this.state.account.authMode = payload.authMode as any; + this.state.account.planType = payload.planType; + this.emitState(); + return; + } + case "account/login/completed": { + const payload = params as { success: boolean; error: string | null }; + if (payload.success) { + this.state.account.login = { status: "idle", message: "Connected." }; + await this.refreshServerState(); + } else { + this.state.account.login = { + status: "error", + message: payload.error ?? "Login failed.", + loginId: null + }; + } + this.emitState(); + return; + } + case "account/rateLimits/updated": { + const payload = summarizeRateLimit((params as { rateLimits: unknown }).rateLimits); + this.state.rateLimitsByLimitId = { + ...this.state.rateLimitsByLimitId, + [payload.limitId ?? "default"]: payload + }; + this.emitState(); + return; + } + case "item/agentMessage/delta": { + const payload = params as { threadId: string; delta: string }; + const activeTask = this.activeTasksByThreadId.get(payload.threadId); + if (!activeTask) { + return; + } + const current = this.state.tasks.find((task) => task.taskId === activeTask.taskId); + if (!current) { + return; + } + this.patchTask(activeTask.taskId, { + text: current.text + payload.delta + }); + return; + } + case "item/completed": { + const payload = params as { + threadId: string; + item: { type: string; text?: string }; + }; + if (payload.item.type !== "agentMessage") { + return; + } + const activeTask = this.activeTasksByThreadId.get(payload.threadId); + if (!activeTask) { + return; + } + if (typeof payload.item.text === "string") { + this.patchTask(activeTask.taskId, { + text: payload.item.text + }); + } + return; + } + case "thread/tokenUsage/updated": { + const payload = params as { + threadId: string; + turnId: string; + tokenUsage: { + total: IslandflowAiTokenBreakdown; + last: IslandflowAiTokenBreakdown; + }; + }; + this.recordUsage(payload.threadId, payload.turnId, payload.tokenUsage.last); + return; + } + case "turn/completed": { + const payload = params as { + threadId: string; + turn: { + id: string; + status: string; + error: { message: string } | null; + }; + }; + const activeTask = this.activeTasksByThreadId.get(payload.threadId); + if (!activeTask) { + return; + } + const current = this.state.tasks.find((task) => task.taskId === activeTask.taskId); + if (!current) { + return; + } + + if (payload.turn.status === "failed") { + this.patchTask(activeTask.taskId, { + status: "failed", + error: payload.turn.error?.message ?? "The Copilot turn failed." + }); + } else { + let compiledScreen: IslandflowAiCompiledScreen | null = null; + let nextText = current.text; + if (current.kind === "screen-compile") { + compiledScreen = this.tryParseCompiledScreen(current.text); + if (compiledScreen) { + nextText = compiledScreen.rationale; + } + } + + this.patchTask(activeTask.taskId, { + status: "completed", + compiledScreen, + text: nextText, + error: null + }); + } + + this.activeTasksByThreadId.delete(payload.threadId); + return; + } + default: + return; + } + } + + private tryParseCompiledScreen(text: string): IslandflowAiCompiledScreen | null { + try { + return IslandflowAiCompiledScreenSchema.parse(JSON.parse(sanitizeJsonText(text))); + } catch { + return null; + } + } + + private recordUsage(threadId: string, turnId: string, rawBreakdown: IslandflowAiTokenBreakdown): void { + const activeTask = this.activeTasksByThreadId.get(threadId); + const currentTask = activeTask + ? this.state.tasks.find((task) => task.taskId === activeTask.taskId) + : null; + const breakdown = normalizeBreakdown(rawBreakdown); + const record: IslandflowAiUsageTurnRecord = { + threadId, + turnId, + taskId: currentTask?.taskId ?? null, + taskKind: currentTask?.kind ?? null, + taskTitle: currentTask?.title ?? null, + dayKey: isoDayKey(Date.now()), + profileId: activeTask?.profileId ?? this.state.selectedProfileId, + accountEmail: this.state.account.email, + planType: this.state.account.planType, + model: currentTask?.model ?? this.state.preferences.model, + breakdown, + normalizedCostUsd: estimateNormalizedCost(currentTask?.model ?? this.state.preferences.model, breakdown), + updatedAt: Date.now() + }; + + this.usageStore.turns[buildUsageKey(threadId, turnId)] = record; + void this.saveUsageStore(); + this.rebuildUsageDashboard(); + this.emitState(); + } + + private rebuildUsageDashboard(): void { + const records = Object.values(this.usageStore.turns).filter((record) => { + if (record.profileId !== this.state.selectedProfileId) { + return false; + } + if (this.state.account.email) { + return record.accountEmail === this.state.account.email; + } + return true; + }); + + const todayKey = isoDayKey(Date.now()); + this.state.usage = { + today: this.rollupUsage(records.filter((record) => record.dayKey === todayKey)), + lifetime: this.rollupUsage(records), + recentTurns: [...records].sort((left, right) => right.updatedAt - left.updatedAt).slice(0, 12) + }; + } + + private rollupUsage(records: IslandflowAiUsageTurnRecord[]): IslandflowAiUsageRollup { + const breakdown = records.reduce( + (accumulator, record) => addBreakdowns(accumulator, record.breakdown), + { ...EMPTY_BREAKDOWN } + ); + const normalizedCostUsd = records.reduce((accumulator, record) => accumulator + (record.normalizedCostUsd ?? 0), 0); + return { + breakdown, + normalizedCostUsd: Number(normalizedCostUsd.toFixed(6)), + turnCount: records.length, + activeDays: new Set(records.map((record) => record.dayKey)).size + }; + } + + private failActiveTasks(reason: string): void { + for (const activeTask of this.activeTasksByThreadId.values()) { + this.patchTask(activeTask.taskId, { + status: "failed", + error: reason + }); + } + this.activeTasksByThreadId.clear(); + } + + private patchTask(taskId: string, updates: Partial): void { + this.state.tasks = compactTaskList( + this.state.tasks.map((task) => + task.taskId === taskId + ? { + ...task, + ...updates, + updatedAt: Date.now() + } + : task + ) + ); + this.emitState(); + } + + private emitState(): void { + this.state.updatedAt = Date.now(); + this.publishState({ + ...this.state, + profiles: this.state.profiles.map((profile) => ({ + ...profile, + selected: profile.id === this.state.selectedProfileId + })), + tasks: compactTaskList(this.state.tasks) + }); + } + + private resolveSelectedProfileMode(): IslandflowAiProfileMode { + const selected = this.state.profiles.find((profile) => profile.id === this.state.selectedProfileId); + return IslandflowAiProfileModeSchema.parse(selected?.mode ?? "managed-chatgpt"); + } + + private async loadPreferences(): Promise { + try { + const raw = await readFile(this.preferencesPath, "utf8"); + const parsed = JSON.parse(raw) as PersistedPreferences; + this.state.preferences = { + model: typeof parsed.model === "string" ? parsed.model : null, + reasoningEffort: + parsed.reasoningEffort === null || parsed.reasoningEffort === undefined + ? DEFAULT_REASONING + : IslandflowAiReasoningEffortSchema.parse(parsed.reasoningEffort) + }; + } catch { + // Use defaults on first run or after malformed local state. + } + } + + private async savePreferences(): Promise { + const payload: PersistedPreferences = { + model: this.state.preferences.model, + reasoningEffort: this.state.preferences.reasoningEffort + }; + await writeFile(this.preferencesPath, JSON.stringify(payload, null, 2), "utf8"); + } + + private async loadUsageStore(): Promise { + try { + const raw = await readFile(this.usagePath, "utf8"); + const parsed = JSON.parse(raw) as PersistedUsageStore; + if (parsed.version === 1 && parsed.turns) { + this.usageStore = parsed; + } + } catch { + this.usageStore = createUsageStore(); + } + this.rebuildUsageDashboard(); + } + + private async saveUsageStore(): Promise { + await writeFile(this.usagePath, JSON.stringify(this.usageStore, null, 2), "utf8"); + } +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index e5006df..41d24a5 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -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", () => { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts new file mode 100644 index 0000000..ed6f8df --- /dev/null +++ b/apps/desktop/src/preload.ts @@ -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 => ipcRenderer.invoke(DESKTOP_AI_GET_STATE), + loginWithBrowser: (): Promise => ipcRenderer.invoke(DESKTOP_AI_LOGIN_BROWSER), + loginWithDeviceCode: (): Promise => ipcRenderer.invoke(DESKTOP_AI_LOGIN_DEVICE), + cancelLogin: (): Promise => ipcRenderer.invoke(DESKTOP_AI_CANCEL_LOGIN), + logout: (): Promise => ipcRenderer.invoke(DESKTOP_AI_LOGOUT), + updatePreferences: ( + next: Partial<{ model: string | null; reasoningEffort: IslandflowAiReasoningEffort | null }> + ): Promise => 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); diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 5895037..de3fb15 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -2,8 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", + "module": "ESNext", + "moduleResolution": "Bundler", "lib": ["ES2022"], "types": ["node"], "rootDir": "src", diff --git a/apps/web/app/charts/page.tsx b/apps/web/app/charts/page.tsx index 9d82bba..a2eb858 100644 --- a/apps/web/app/charts/page.tsx +++ b/apps/web/app/charts/page.tsx @@ -1,7 +1,7 @@ -import { redirect } from "next/navigation"; +import { ChartsRoute } from "../terminal"; export const dynamic = "force-dynamic"; export default function Page() { - redirect("/"); + return ; } diff --git a/apps/web/app/desktop-ai-panels.tsx b/apps/web/app/desktop-ai-panels.tsx new file mode 100644 index 0000000..65524dc --- /dev/null +++ b/apps/web/app/desktop-ai-panels.tsx @@ -0,0 +1,924 @@ +"use client"; + +import Link from "next/link"; +import { useMemo, useState, type ReactNode } from "react"; +import type { + AlertEvent, + ClassifierHitEvent, + FlowPacket, + IslandflowAiCompiledScreen, + IslandflowAiPlanType, + IslandflowAiRateLimitSnapshot, + IslandflowAiReasoningEffort, + IslandflowAiTaskKind, + OptionFlowFilters, + OptionPrint, + SmartMoneyEvent +} from "@islandflow/types"; +import { useDesktopAi } from "./desktop-ai"; + +const numberFormatter = new Intl.NumberFormat("en-US"); +const usdFormatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 4 +}); + +const humanizeValue = (value: string | null | undefined): string => { + if (!value) { + return "Unknown"; + } + return value + .replace(/_/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); +}; + +const formatTokens = (value: number): string => numberFormatter.format(value); + +const formatUsd = (value: number | null): string => (value === null ? "Unavailable" : usdFormatter.format(value)); + +const formatTimestamp = (value: number | null): string => { + if (!value) { + return "Not reported"; + } + return new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short" + }).format(value); +}; + +const formatPercent = (value: number): string => `${Math.round(value)}%`; + +const getTaskStatusLabel = (value: string): string => humanizeValue(value); + +const findTask = (tasks: T[], taskId: string | null): T | null => { + if (!taskId) { + return null; + } + return tasks.find((task) => task.taskId === taskId) ?? null; +}; + +const getCompiledScreenSummary = (compiled: IslandflowAiCompiledScreen): string[] => { + const filters = compiled.compiledFilters; + if (!filters) { + return []; + } + + const parts: string[] = []; + if (filters.view) { + parts.push(`View: ${filters.view}`); + } + if (filters.securityTypes?.length) { + parts.push(`Security: ${filters.securityTypes.join(", ")}`); + } + if (filters.optionTypes?.length) { + parts.push(`Options: ${filters.optionTypes.join(", ")}`); + } + if (filters.nbboSides?.length) { + parts.push(`NBBO: ${filters.nbboSides.join(", ")}`); + } + if (typeof filters.minNotional === "number") { + parts.push(`Min notional: $${numberFormatter.format(filters.minNotional)}`); + } + + return parts; +}; + +const CopilotPane = ({ + title, + eyebrow, + actions, + wide = false, + children +}: { + title: string; + eyebrow?: string; + actions?: ReactNode; + wide?: boolean; + children: ReactNode; +}) => { + return ( +
+
+
+
+ {eyebrow ?
{eyebrow}
: null} +

{title}

+
+
+ {actions ?
{actions}
: null} +
+
{children}
+
+ ); +}; + +const UsageBreakdown = ({ + title, + breakdown, + normalizedCostUsd, + turnCount, + activeDays +}: { + title: string; + breakdown: { + totalTokens: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + reasoningOutputTokens: number; + }; + normalizedCostUsd: number | null; + turnCount: number; + activeDays: number; +}) => { + return ( +
+
+

{title}

+ {formatUsd(normalizedCostUsd)} +
+
+
+ Total tokens + {formatTokens(breakdown.totalTokens)} +
+
+ Input + {formatTokens(breakdown.inputTokens)} +
+
+ Cached input + {formatTokens(breakdown.cachedInputTokens)} +
+
+ Output + {formatTokens(breakdown.outputTokens)} +
+
+ Reasoning + {formatTokens(breakdown.reasoningOutputTokens)} +
+
+ Turns + {formatTokens(turnCount)} +
+
+ Active days + {formatTokens(activeDays)} +
+
+
+ ); +}; + +const RateLimitBoard = ({ limit }: { limit: IslandflowAiRateLimitSnapshot }) => { + return ( +
+
+
+ {limit.limitName ?? "Default rate window"} +

+ {limit.planType ? `Plan ${humanizeValue(limit.planType)}` : "Plan not reported"} +

+
+ {limit.reachedType ? {humanizeValue(limit.reachedType)} : null} +
+
+ {limit.primary ? ( +
+ Primary + {formatPercent(limit.primary.usedPercent)} +

Resets {formatTimestamp(limit.primary.resetsAt)}

+
+ ) : null} + {limit.secondary ? ( +
+ Secondary + {formatPercent(limit.secondary.usedPercent)} +

Resets {formatTimestamp(limit.secondary.resetsAt)}

+
+ ) : null} +
+ {limit.creditsBalance || limit.unlimitedCredits !== null ? ( +

+ Credits:{" "} + {limit.unlimitedCredits + ? "unlimited" + : limit.creditsBalance + ? limit.creditsBalance + : limit.hasCredits === false + ? "none" + : "not reported"} +

+ ) : null} +
+ ); +}; + +const TaskOutput = ({ + taskId, + emptyMessage +}: { + taskId: string | null; + emptyMessage: string; +}) => { + const { state } = useDesktopAi(); + const task = findTask(state.tasks, taskId); + + if (!task) { + return

{emptyMessage}

; + } + + return ( +
+
+
+ {task.title} +

+ {task.subtitle} · {getTaskStatusLabel(task.status)} +

+
+ {getTaskStatusLabel(task.status)} +
+ {task.error ?

{task.error}

: null} + {task.text ?
{task.text}
: null} + {task.compiledScreen ? : null} +
+ ); +}; + +const CompiledScreenResult = ({ compiled }: { compiled: IslandflowAiCompiledScreen }) => { + const summary = getCompiledScreenSummary(compiled); + + return ( +
+ {summary.length > 0 ? ( +
+ {summary.map((item) => ( + + {item} + + ))} +
+ ) : ( +

No filter fields were compiled from this prompt.

+ )} + {compiled.unhandledClauses.length > 0 ? ( +
+
Unhandled clauses
+ {compiled.unhandledClauses.map((item) => ( +
+ {item} +
+ ))} +
+ ) : null} +
+ ); +}; + +const AccountSummary = ({ + loggedIn, + email, + planType +}: { + loggedIn: boolean; + email: string | null; + planType: IslandflowAiPlanType | null; +}) => { + return ( +
+
+

Desktop-only official Codex bridge

+

Analyst Copilot

+

+ Managed ChatGPT login stays user-scoped, deterministic smart-money classification stays in charge, and every + AI turn is tracked with exact token telemetry from the app-server. +

+
+
+
+ Account + {loggedIn ? email ?? "Connected" : "Disconnected"} +
+
+ Plan + {loggedIn ? humanizeValue(planType) : "Not connected"} +
+
+
+ ); +}; + +const LoginStatePanel = () => { + const { state, loginWithBrowser, loginWithDeviceCode, cancelLogin, logout } = useDesktopAi(); + const [busyAction, setBusyAction] = useState(null); + const [actionError, setActionError] = useState(null); + const loginState = state.account.login; + const actionsDisabled = busyAction !== null || !state.desktopAvailable; + + const runAction = async (label: string, action: () => Promise) => { + setBusyAction(label); + setActionError(null); + try { + await action(); + } catch (error) { + setActionError(error instanceof Error ? error.message : String(error)); + } finally { + setBusyAction(null); + } + }; + + return ( + + {state.account.loggedIn ? ( + + ) : ( + <> + + + + )} + {(loginState.status === "browser_pending" || loginState.status === "device_code_pending") && !state.account.loggedIn ? ( + + ) : null} + + } + > + +
+
+
Profile slots
+ {state.profiles.map((profile) => ( +
+
+ {profile.label} +

{profile.description}

+
+ + {profile.selected ? "Selected" : profile.statusLabel} + +
+ ))} +
+
+
Session status
+
+ Transport + {humanizeValue(state.transportStatus)} +
+
+ Auth mode + {humanizeValue(state.account.authMode)} +
+
+ OpenAI auth required + {state.account.requiresOpenaiAuth ? "Yes" : "No"} +
+ {state.transportError ?

{state.transportError}

: null} + {loginState.message ?

{loginState.message}

: null} + {loginState.status === "browser_pending" ? ( +
+ Browser login in progress +

Finish the ChatGPT sign-in flow in your browser. Islandflow will update automatically.

+
+ ) : null} + {loginState.status === "device_code_pending" ? ( +
+ Device code +
{loginState.userCode}
+

Visit {loginState.verificationUrl} in any browser and enter the code above.

+
+ ) : null} + {actionError ?

{actionError}

: null} +
+
+
+ ); +}; + +export function DesktopAiSettingsRoute() { + const { state, updatePreferences } = useDesktopAi(); + const [busyPreference, setBusyPreference] = useState<"model" | "reasoning" | null>(null); + const [preferenceError, setPreferenceError] = useState(null); + const rateLimits = Object.values(state.rateLimitsByLimitId); + const selectedModel = state.preferences.model ?? ""; + const selectedReasoning = state.preferences.reasoningEffort ?? ""; + + const savePreference = async ( + key: "model" | "reasoning", + next: Partial<{ model: string | null; reasoningEffort: IslandflowAiReasoningEffort | null }> + ) => { + setBusyPreference(key); + setPreferenceError(null); + try { + await updatePreferences(next); + } catch (error) { + setPreferenceError(error instanceof Error ? error.message : String(error)); + } finally { + setBusyPreference(null); + } + }; + + return ( +
+ {!state.desktopAvailable ? ( + +
+

+ AI controls are intentionally read-only in the browser build. Open Islandflow Desktop to use managed ChatGPT + login, structured Copilot turns, and app-server token telemetry. +

+
+
+ ) : null} + + + +
+ +
+ + +
+
+ {state.models.map((model) => ( +
+
+ {model.displayName} +

{model.description}

+
+
+ {model.model} + {model.pricing ? {formatUsd(model.pricing.inputUsdPer1MTokens)} / 1M input : null} +
+
+ ))} +
+ {state.models.find((model) => model.model === state.preferences.model)?.pricing ? ( +

+ Normalized estimates use current API pricing for the selected model, not your literal ChatGPT subscription bill. +

+ ) : null} + {preferenceError ?

{preferenceError}

: null} +
+ + + {rateLimits.length === 0 ? ( +

No rate-limit snapshots have been reported yet.

+ ) : ( +
+ {rateLimits.map((limit) => ( + + ))} +
+ )} +
+ + +
+ + +
+
+ + + {state.usage.recentTurns.length === 0 ? ( +

No tracked turns yet.

+ ) : ( +
+ {state.usage.recentTurns.map((turn) => ( +
+
+ {turn.taskTitle ?? "Ad hoc turn"} +

+ {turn.model ?? "default"} · {formatTimestamp(turn.updatedAt)} +

+
+
+ {formatTokens(turn.breakdown.totalTokens)} tok + {formatUsd(turn.normalizedCostUsd)} +
+
+ ))} +
+ )} +
+ + + {state.tasks.length === 0 ? ( +

No Copilot tasks have been run yet.

+ ) : ( +
+ {state.tasks.map((task) => ( +
+
+ {task.title} +

+ {task.subtitle} · {humanizeValue(task.model)} +

+
+ {getTaskStatusLabel(task.status)} +
+ ))} +
+ )} +
+
+
+ ); +} + +const requireDesktopActionCopy = (desktopAvailable: boolean, loggedIn: boolean): string => { + if (!desktopAvailable) { + return "This control is desktop-only. Open Islandflow Desktop to run Copilot tasks."; + } + if (!loggedIn) { + return "Connect a ChatGPT or Codex account in Settings before running Copilot analysis."; + } + return ""; +}; + +const SmartMoneyTaskButton = ({ + label, + kind, + symbol, + disabled, + busyKind, + onRun +}: { + label: string; + kind: IslandflowAiTaskKind; + symbol: string; + disabled: boolean; + busyKind: IslandflowAiTaskKind | null; + onRun: (kind: IslandflowAiTaskKind) => void; +}) => { + return ( + + ); +}; + +export function SmartMoneyCopilotPanel({ + event, + flowPacket, + evidencePrints, + relatedPackets +}: { + event: SmartMoneyEvent; + flowPacket: FlowPacket | null; + evidencePrints: OptionPrint[]; + relatedPackets: FlowPacket[]; +}) { + const { bridgeAvailable, state, runTask } = useDesktopAi(); + const [busyKind, setBusyKind] = useState(null); + const [activeTaskId, setActiveTaskId] = useState(null); + const [taskError, setTaskError] = useState(null); + const disabledCopy = requireDesktopActionCopy(bridgeAvailable, state.account.loggedIn); + const actionsDisabled = !bridgeAvailable || !state.account.loggedIn; + + const handleRun = async (kind: IslandflowAiTaskKind) => { + setBusyKind(kind); + setTaskError(null); + try { + const result = await runTask({ + kind: kind as + | "smart-money-explain" + | "smart-money-skeptic" + | "smart-money-burst-summary" + | "watchlist-synthesis", + context: { + event, + flowPacket, + evidencePrints, + relatedPackets + } + }); + setActiveTaskId(result.taskId); + } catch (error) { + setTaskError(error instanceof Error ? error.message : String(error)); + } finally { + setBusyKind(null); + } + }; + + return ( +
+
+
+
Analyst Copilot
+

Structured interpretation only, the deterministic classifier remains the source of truth.

+
+ + AI settings + +
+
+ void handleRun(kind)} + /> + void handleRun(kind)} + /> + void handleRun(kind)} + /> + void handleRun(kind)} + /> +
+ {disabledCopy ?

{disabledCopy}

: null} + {taskError ?

{taskError}

: null} + +
+ ); +} + +export function ReplayCopilotPanel({ + ticker, + flowFilters, + alerts, + smartMoneyEvents, + classifierHits, + flowPackets, + optionPrints +}: { + ticker: string | null; + flowFilters: OptionFlowFilters; + alerts: AlertEvent[]; + smartMoneyEvents: SmartMoneyEvent[]; + classifierHits: ClassifierHitEvent[]; + flowPackets: FlowPacket[]; + optionPrints: OptionPrint[]; +}) { + const { bridgeAvailable, state, runTask } = useDesktopAi(); + const [activeTaskId, setActiveTaskId] = useState(null); + const [busy, setBusy] = useState(false); + const [taskError, setTaskError] = useState(null); + const disabledCopy = requireDesktopActionCopy(bridgeAvailable, state.account.loggedIn); + const actionsDisabled = busy || !bridgeAvailable || !state.account.loggedIn; + + const handleRun = async () => { + setBusy(true); + setTaskError(null); + try { + const result = await runTask({ + kind: "replay-postmortem", + context: { + ticker, + flowFilters, + alerts, + smartMoneyEvents, + classifierHits, + flowPackets, + optionPrints + } + }); + setActiveTaskId(result.taskId); + } catch (error) { + setTaskError(error instanceof Error ? error.message : String(error)); + } finally { + setBusy(false); + } + }; + + return ( + + + AI settings + + + + } + > +

+ Copilot uses the current replay slice only: ticker scope, flow filters, visible alerts, classifier hits, packets, and option prints. +

+ {disabledCopy ?

{disabledCopy}

: null} + {taskError ?

{taskError}

: null} + +
+ ); +} + +export function ScreenCompilerPanel({ + currentFilters, + onApplyFilters +}: { + currentFilters: OptionFlowFilters; + onApplyFilters: (next: OptionFlowFilters) => void; +}) { + const { bridgeAvailable, state, runTask } = useDesktopAi(); + const [prompt, setPrompt] = useState(""); + const [activeTaskId, setActiveTaskId] = useState(null); + const [busy, setBusy] = useState(false); + const [taskError, setTaskError] = useState(null); + const activeTask = useMemo(() => findTask(state.tasks, activeTaskId), [state.tasks, activeTaskId]); + const disabledCopy = requireDesktopActionCopy(bridgeAvailable, state.account.loggedIn); + const actionsDisabled = busy || !bridgeAvailable || !state.account.loggedIn; + + const handleCompile = async () => { + const trimmedPrompt = prompt.trim(); + if (!trimmedPrompt) { + setTaskError("Write a screen request first."); + return; + } + + setBusy(true); + setTaskError(null); + try { + const result = await runTask({ + kind: "screen-compile", + context: { + prompt: trimmedPrompt, + currentFilters + } + }); + setActiveTaskId(result.taskId); + } catch (error) { + setTaskError(error instanceof Error ? error.message : String(error)); + } finally { + setBusy(false); + } + }; + + const compiledFilters = activeTask?.compiledScreen?.compiledFilters ?? null; + + return ( + + + AI settings + + + + } + > +
+