├── vitest.setup.ts ├── homepage.png ├── app ├── favicon.ico ├── page.test.tsx ├── test-worker.html ├── layout.tsx ├── globals.css └── page.tsx ├── public ├── audio │ ├── ping.mp3 │ ├── bass_1.mp3 │ ├── sonar_ping_1.mp3 │ ├── sonar_ping_2.mp3 │ ├── sonar_ping_3.mp3 │ ├── sonar_ping_4.mp3 │ └── Study in Shadows.mp3 ├── images │ └── ATC_OG.png ├── vercel.svg ├── fonts │ ├── jet-brains-mono │ │ ├── JetBrainsMono-Bold.ttf │ │ ├── JetBrainsMono-Regular.ttf │ │ └── JetBrainsMono-VariableFont_wght.ttf │ └── source-code-pro │ │ ├── SourceCodePro-Bold.ttf │ │ ├── SourceCodePro-Regular.ttf │ │ └── README.md ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── postcss.config.mjs ├── lib ├── debug.ts ├── constants.test.ts ├── config.test.ts ├── config.ts ├── imports.test.ts ├── rng.test.ts ├── format.ts ├── rng.ts ├── simClient.ts ├── simBridge.test.ts ├── types.ts ├── store.test.ts ├── bridgeToStore.test.ts ├── audio │ └── tracks.ts ├── constants.ts ├── engine.test.ts ├── store.ts ├── bridgeToStore.ts ├── simBridge.ts └── engine.ts ├── vitest.config.mts ├── next.config.ts ├── components ├── ProjectIdDisplay.tsx ├── ProjectDescription.tsx ├── MetricsBar.test.tsx ├── WorkTable.test.tsx ├── MetricsBar.tsx ├── OperatorGroups.tsx ├── TickIndicator.tsx ├── GlobalQueue.tsx ├── ControlBar.tsx ├── WorkTable.tsx ├── TimelineStatusBar.tsx ├── AudioPlayer.tsx └── TopOverview.tsx ├── eslint.config.mjs ├── workers ├── engine.worker.test.ts └── engine.ts ├── .gitignore ├── tsconfig.json ├── plans ├── index.ts ├── types.ts ├── calm.test.ts ├── edits.py └── martianHomecomingPlan.ts ├── package.json ├── README.md └── .planning ├── todo.md └── prd.md /vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; -------------------------------------------------------------------------------- /homepage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gkamradt/agenttrafficcontrol/HEAD/homepage.png -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gkamradt/agenttrafficcontrol/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /public/audio/ping.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gkamradt/agenttrafficcontrol/HEAD/public/audio/ping.mp3 -------------------------------------------------------------------------------- /public/audio/bass_1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gkamradt/agenttrafficcontrol/HEAD/public/audio/bass_1.mp3 -------------------------------------------------------------------------------- /public/images/ATC_OG.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gkamradt/agenttrafficcontrol/HEAD/public/images/ATC_OG.png -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /public/audio/sonar_ping_1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gkamradt/agenttrafficcontrol/HEAD/public/audio/sonar_ping_1.mp3 -------------------------------------------------------------------------------- /public/audio/sonar_ping_2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gkamradt/agenttrafficcontrol/HEAD/public/audio/sonar_ping_2.mp3 -------------------------------------------------------------------------------- /public/audio/sonar_ping_3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gkamradt/agenttrafficcontrol/HEAD/public/audio/sonar_ping_3.mp3 -------------------------------------------------------------------------------- /public/audio/sonar_ping_4.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gkamradt/agenttrafficcontrol/HEAD/public/audio/sonar_ping_4.mp3 -------------------------------------------------------------------------------- /public/audio/Study in Shadows.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gkamradt/agenttrafficcontrol/HEAD/public/audio/Study in Shadows.mp3 -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/fonts/jet-brains-mono/JetBrainsMono-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gkamradt/agenttrafficcontrol/HEAD/public/fonts/jet-brains-mono/JetBrainsMono-Bold.ttf -------------------------------------------------------------------------------- /public/fonts/source-code-pro/SourceCodePro-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gkamradt/agenttrafficcontrol/HEAD/public/fonts/source-code-pro/SourceCodePro-Bold.ttf -------------------------------------------------------------------------------- /public/fonts/jet-brains-mono/JetBrainsMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gkamradt/agenttrafficcontrol/HEAD/public/fonts/jet-brains-mono/JetBrainsMono-Regular.ttf -------------------------------------------------------------------------------- /public/fonts/source-code-pro/SourceCodePro-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gkamradt/agenttrafficcontrol/HEAD/public/fonts/source-code-pro/SourceCodePro-Regular.ttf -------------------------------------------------------------------------------- /public/fonts/jet-brains-mono/JetBrainsMono-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gkamradt/agenttrafficcontrol/HEAD/public/fonts/jet-brains-mono/JetBrainsMono-VariableFont_wght.ttf -------------------------------------------------------------------------------- /lib/debug.ts: -------------------------------------------------------------------------------- 1 | import { DEBUG_LOGS } from './config'; 2 | 3 | export function debugLog(tag: string, ...args: unknown[]) { 4 | if (!DEBUG_LOGS) return; 5 | console.log(`[${tag}]`, ...args); 6 | } 7 | 8 | -------------------------------------------------------------------------------- /public/fonts/source-code-pro/README.md: -------------------------------------------------------------------------------- 1 | Place your font file here: 2 | 3 | - Expected path: `public/fonts/source-code-pro/SourceCodePro-Regular.ttf` 4 | - Used via `next/font/local` in `app/layout.tsx` 5 | - Weight: 400, Style: normal 6 | 7 | Tip: Prefer `.woff2` for best performance if available. 8 | 9 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/page.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { render, screen } from '@testing-library/react'; 3 | import Home from './page'; 4 | 5 | describe('renders-root', () => { 6 | it('renders the root page and shows sentinel text', () => { 7 | render(); 8 | 9 | const heading = screen.getByText('Calming Control Room'); 10 | expect(heading).toBeInTheDocument(); 11 | }); 12 | }); -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import react from '@vitejs/plugin-react'; 3 | import path from 'path'; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | test: { 8 | environment: 'jsdom', 9 | globals: true, 10 | setupFiles: './vitest.setup.ts', 11 | }, 12 | resolve: { 13 | alias: { 14 | '@': path.resolve(__dirname, './'), 15 | }, 16 | }, 17 | }); -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | webpack: (config) => { 5 | // Add support for importing Web Workers 6 | config.module.rules.push({ 7 | test: /\.worker\.(js|ts)$/, 8 | type: 'asset/resource', 9 | generator: { 10 | filename: 'static/[hash][ext][query]', 11 | }, 12 | }); 13 | 14 | return config; 15 | }, 16 | }; 17 | 18 | export default nextConfig; 19 | -------------------------------------------------------------------------------- /components/ProjectIdDisplay.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useSyncExternalStore } from 'react'; 4 | import { DEFAULT_PLAN_NAME } from '@/plans'; 5 | import { appStore } from '@/lib/store'; 6 | 7 | export default function ProjectIdDisplay() { 8 | const plan = useSyncExternalStore( 9 | appStore.subscribe, 10 | () => appStore.getState().plan_name || DEFAULT_PLAN_NAME, 11 | () => appStore.getState().plan_name || DEFAULT_PLAN_NAME, 12 | ); 13 | return {plan}; 14 | } 15 | -------------------------------------------------------------------------------- /lib/constants.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { COST_PER_TOKEN_USD, MAX_CONCURRENT, V_MIN, V_MAX } from './constants'; 3 | 4 | describe('constants-sanity', () => { 5 | it('has valid numeric relationships', () => { 6 | expect(Number.isFinite(COST_PER_TOKEN_USD)).toBe(true); 7 | expect(COST_PER_TOKEN_USD).toBeGreaterThan(0); 8 | expect(MAX_CONCURRENT).toBeGreaterThan(0); 9 | expect(V_MIN).toBeGreaterThan(0); 10 | expect(V_MAX).toBeGreaterThan(V_MIN); 11 | }); 12 | }); 13 | 14 | -------------------------------------------------------------------------------- /lib/config.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { BRIDGE_BATCH_MS, STORE_FLUSH_INTERVAL_MS, ENGINE_TICK_HZ, DEFAULT_SEED } from './config'; 3 | 4 | describe('config defaults', () => { 5 | it('has sane positive numeric defaults', () => { 6 | expect(BRIDGE_BATCH_MS).toBeGreaterThan(0); 7 | expect(STORE_FLUSH_INTERVAL_MS).toBeGreaterThan(0); 8 | expect(ENGINE_TICK_HZ).toBeGreaterThan(0); 9 | }); 10 | 11 | it('has a default seed string', () => { 12 | expect(typeof DEFAULT_SEED).toBe('string'); 13 | expect(DEFAULT_SEED.length).toBeGreaterThan(0); 14 | }); 15 | }); 16 | 17 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | { 15 | ignores: [ 16 | "node_modules/**", 17 | ".next/**", 18 | "out/**", 19 | "build/**", 20 | "next-env.d.ts", 21 | ], 22 | }, 23 | ]; 24 | 25 | export default eslintConfig; 26 | -------------------------------------------------------------------------------- /workers/engine.worker.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { makeInitialState, hzToMs, zeroMetrics } from './engine'; 3 | 4 | describe('engine-handshake helpers', () => { 5 | it('makes an initial state with zeros and defaults', () => { 6 | const s = makeInitialState('seed'); 7 | expect(s.seed).toBe('seed'); 8 | expect(s.items).toEqual({}); 9 | expect(s.agents).toEqual({}); 10 | expect(s.metrics).toEqual(zeroMetrics()); 11 | }); 12 | 13 | it('converts Hz to ms reasonably', () => { 14 | expect(hzToMs(50)).toBeGreaterThan(0); 15 | expect(hzToMs(25)).toBeCloseTo(40, 0); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | 43 | .planning/ 44 | .claude/ -------------------------------------------------------------------------------- /components/ProjectDescription.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useMemo, useSyncExternalStore } from 'react'; 4 | import { getPlanByName, ALL_PLANS, DEFAULT_PLAN_NAME } from '@/plans'; 5 | import { appStore } from '@/lib/store'; 6 | 7 | export default function ProjectDescription() { 8 | const plan = useSyncExternalStore( 9 | appStore.subscribe, 10 | () => appStore.getState().plan_name || DEFAULT_PLAN_NAME, 11 | () => appStore.getState().plan_name || DEFAULT_PLAN_NAME, 12 | ); 13 | 14 | const desc = useMemo(() => { 15 | const p = getPlanByName(plan) || ALL_PLANS[0]; 16 | return p.description || 'Agent Traffic Control'; 17 | }, [plan]); 18 | 19 | return {desc}; 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /lib/config.ts: -------------------------------------------------------------------------------- 1 | // Centralized runtime configuration and tuning knobs 2 | 3 | // Bridge batching cadence (ms) and store flush cadence (ms) are centralized in constants. 4 | // See constants for guidance on tuning. 5 | export { BRIDGE_BATCH_MS, STORE_FLUSH_INTERVAL_MS } from './constants'; 6 | 7 | // Engine tick rate is now centralized in constants for simplicity. 8 | export { ENGINE_TICK_HZ } from './constants'; 9 | 10 | // App defaults 11 | export const DEFAULT_SEED = (process.env.NEXT_PUBLIC_DEFAULT_SEED as string) || 'auto'; 12 | export const RUNNING_DEFAULT = ((process.env.NEXT_PUBLIC_RUNNING_DEFAULT as string) ?? 'true') === 'true'; 13 | 14 | // Debug logging toggle 15 | export const DEBUG_LOGS = ((process.env.NEXT_PUBLIC_DEBUG_LOGS as string) ?? 'true') === 'true'; 16 | -------------------------------------------------------------------------------- /lib/imports.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | describe('imports-don\'t-throw', () => { 4 | it('imports all stub modules without throwing', async () => { 5 | // Test that each module can be imported 6 | const { TYPES_MODULE_LOADED } = await import('./types'); 7 | expect(TYPES_MODULE_LOADED).toBe(true); 8 | 9 | const { CONSTANTS_MODULE_LOADED } = await import('./constants'); 10 | expect(CONSTANTS_MODULE_LOADED).toBe(true); 11 | 12 | const { RNG_MODULE_LOADED } = await import('./rng'); 13 | expect(RNG_MODULE_LOADED).toBe(true); 14 | 15 | const { SIMBRIDGE_MODULE_LOADED } = await import('./simBridge'); 16 | expect(SIMBRIDGE_MODULE_LOADED).toBe(true); 17 | 18 | // Worker module can't be directly imported in test environment, 19 | // but we can verify the file exists 20 | const workerModule = await import('../workers/engine'); 21 | expect(workerModule.ENGINE_WORKER_MODULE_LOADED).toBe(true); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plans/index.ts: -------------------------------------------------------------------------------- 1 | // Import concrete plans so we can build a registry 2 | import { humanoidPlan } from './humanoid'; 3 | import { martianHomecomingPlan } from './martianHomecomingPlan'; 4 | import { franticPlan } from './frantic'; 5 | export { humanoidPlan, martianHomecomingPlan, franticPlan }; 6 | export * from './types'; 7 | 8 | // Central registry to drive UI/worker off plan definitions 9 | import type { PlanDefinition } from './types'; 10 | 11 | export const ALL_PLANS: readonly PlanDefinition[] = [ 12 | humanoidPlan, 13 | martianHomecomingPlan, 14 | franticPlan, 15 | ] as const; 16 | 17 | export const PLAN_NAMES = ALL_PLANS.map(p => p.name) as readonly string[]; 18 | 19 | export const PLAN_REGISTRY: Record = ALL_PLANS.reduce((acc, p) => { 20 | acc[p.name] = p; 21 | return acc; 22 | }, {} as Record); 23 | 24 | export function getPlanByName(name: string): PlanDefinition | undefined { 25 | return PLAN_REGISTRY[name]; 26 | } 27 | 28 | export const DEFAULT_PLAN_NAME: string = 'Humanoid'; 29 | -------------------------------------------------------------------------------- /plans/types.ts: -------------------------------------------------------------------------------- 1 | // Plan types for defining human-readable project plans 2 | import type { Sector } from '@/lib/constants'; 3 | 4 | export interface PlanItemSpec { 5 | id: string; // e.g., 'A1' 6 | group: string; // e.g., 'A', 'B', ... 7 | sector: Sector; // 'Planning' | 'Build' | 'Eval' | 'Deploy' 8 | depends_on: string[]; // upstream item ids 9 | estimate_ms: number; // target duration in ms 10 | tps_min: number; // lower bound tokens/sec 11 | tps_max: number; // upper bound tokens/sec 12 | // Optional work-order description: human text of what's being done 13 | work_desc?: string; 14 | } 15 | 16 | export interface PlanDefinition { 17 | name: string; 18 | description?: string; 19 | items: PlanItemSpec[]; 20 | groups?: WorkGroupDef[]; // optional grouping metadata for items 21 | } 22 | 23 | export interface WorkGroupDef { 24 | id: string; // e.g., 'P', 'B', 'E', 'D' 25 | title: string; // human title for the group 26 | description: string; // human description for the group 27 | } 28 | -------------------------------------------------------------------------------- /lib/rng.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { createRNG, seedFromString } from './rng'; 3 | 4 | describe('rng-determinism', () => { 5 | it('same numeric seed yields same sequence', () => { 6 | const a = createRNG(123456); 7 | const b = createRNG(123456); 8 | const seqA = Array.from({ length: 5 }, () => a.next()); 9 | const seqB = Array.from({ length: 5 }, () => b.next()); 10 | expect(seqA).toEqual(seqB); 11 | }); 12 | 13 | it('same string seed yields same sequence', () => { 14 | const a = createRNG('hello'); 15 | const b = createRNG('hello'); 16 | const seqA = Array.from({ length: 5 }, () => a.int(0, 1000)); 17 | const seqB = Array.from({ length: 5 }, () => b.int(0, 1000)); 18 | expect(seqA).toEqual(seqB); 19 | }); 20 | 21 | it('different seed diverges quickly', () => { 22 | const a = createRNG('hello'); 23 | const b = createRNG('hello2'); 24 | const seqA = Array.from({ length: 5 }, () => a.next()); 25 | const seqB = Array.from({ length: 5 }, () => b.next()); 26 | expect(seqA).not.toEqual(seqB); 27 | }); 28 | 29 | it('seedFromString is stable', () => { 30 | expect(seedFromString('abc')).toEqual(seedFromString('abc')); 31 | }); 32 | }); 33 | 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "calming-control-room", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "eslint", 10 | "test": "vitest", 11 | "test:ui": "vitest --ui", 12 | "test:run": "vitest run", 13 | "test:coverage": "vitest run --coverage" 14 | }, 15 | "dependencies": { 16 | "@vercel/analytics": "^1.5.0", 17 | "next": "15.5.2", 18 | "react": "19.1.0", 19 | "react-dom": "19.1.0", 20 | "zustand": "^5.0.8" 21 | }, 22 | "devDependencies": { 23 | "@eslint/eslintrc": "^3", 24 | "@tailwindcss/postcss": "^4", 25 | "@testing-library/dom": "^10.4.1", 26 | "@testing-library/jest-dom": "^6.8.0", 27 | "@testing-library/react": "^16.3.0", 28 | "@testing-library/user-event": "^14.6.1", 29 | "@types/node": "^20", 30 | "@types/react": "^19", 31 | "@types/react-dom": "^19", 32 | "@vitejs/plugin-react": "^5.0.2", 33 | "comlink-loader": "^2.0.0", 34 | "eslint": "^9", 35 | "eslint-config-next": "15.5.2", 36 | "jsdom": "^26.1.0", 37 | "tailwindcss": "^4", 38 | "typescript": "^5", 39 | "vitest": "^3.2.4", 40 | "worker-loader": "^3.0.8" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/test-worker.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Worker Test 5 | 6 | 7 |

Worker Test

8 |
Loading...
9 |
Tick: 0
10 | 11 | 12 | 36 | 37 | -------------------------------------------------------------------------------- /components/MetricsBar.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { render, screen } from '@testing-library/react'; 3 | import MetricsBar from './MetricsBar'; 4 | import { appStore } from '@/lib/store'; 5 | 6 | describe('MetricsBar', () => { 7 | it('renders metric tiles with formatted values', () => { 8 | appStore.setState({ 9 | metrics: { 10 | active_agents: 3, 11 | total_tokens: 12345, 12 | total_spend_usd: 12.34, 13 | live_tps: 56.7, 14 | live_spend_per_s: 0.12, 15 | completion_rate: 0.42, 16 | }, 17 | }); 18 | 19 | render(); 20 | expect(screen.getByText('Active Agents')).toBeInTheDocument(); 21 | expect(screen.getByText('3')).toBeInTheDocument(); 22 | expect(screen.getByText('Total Tokens')).toBeInTheDocument(); 23 | expect(screen.getByText('12,345')).toBeInTheDocument(); 24 | expect(screen.getByText('Total Spend')).toBeInTheDocument(); 25 | expect(screen.getByText('$12.34')).toBeInTheDocument(); 26 | expect(screen.getByText('Live TPS')).toBeInTheDocument(); 27 | expect(screen.getByText('56.7')).toBeInTheDocument(); 28 | expect(screen.getByText('Completion')).toBeInTheDocument(); 29 | expect(screen.getByText('42%')).toBeInTheDocument(); 30 | }); 31 | }); 32 | 33 | -------------------------------------------------------------------------------- /plans/calm.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { calmPlan } from './calm'; 3 | 4 | describe('calm plan shape', () => { 5 | it('has 12 unique items and valid deps', () => { 6 | const ids = new Set(calmPlan.items.map(i => i.id)); 7 | expect(ids.size).toBe(calmPlan.items.length); 8 | const idList = new Set(calmPlan.items.map(i => i.id)); 9 | for (const item of calmPlan.items) { 10 | for (const dep of item.depends_on) { 11 | expect(idList.has(dep)).toBe(true); 12 | } 13 | expect(item.tps_min).toBeLessThan(item.tps_max); 14 | expect(item.estimate_ms).toBeGreaterThan(0); 15 | } 16 | }); 17 | 18 | it('is acyclic', () => { 19 | const map = new Map(); 20 | calmPlan.items.forEach(i => map.set(i.id, i.depends_on)); 21 | const seen = new Set(); 22 | const stack = new Set(); 23 | const visit = (id: string): boolean => { 24 | if (stack.has(id)) return false; // cycle 25 | if (seen.has(id)) return true; 26 | stack.add(id); 27 | for (const d of map.get(id) || []) if (!visit(d)) return false; 28 | stack.delete(id); 29 | seen.add(id); 30 | return true; 31 | }; 32 | for (const id of map.keys()) { 33 | expect(visit(id)).toBe(true); 34 | } 35 | }); 36 | }); 37 | 38 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/format.ts: -------------------------------------------------------------------------------- 1 | export type FormatAbbrevOptions = { 2 | // When formatting tokens/second, show decimals under 1000 instead of integer-only 3 | tpsMode?: boolean; 4 | // If true and in tpsMode, show 2 decimals when value < 100 5 | extraDecimalUnder100?: boolean; 6 | }; 7 | 8 | export function formatTokensAbbrev(n?: number | null, opts?: FormatAbbrevOptions): string { 9 | const num = typeof n === 'number' && isFinite(n) ? n : 0; 10 | const sign = num < 0 ? '-' : ''; 11 | const v = Math.abs(num); 12 | 13 | const fmtInt = (x: number) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(x); 14 | 15 | if (v < 1000) { 16 | if (opts?.tpsMode) { 17 | const decimals = opts.extraDecimalUnder100 && v < 100 ? 2 : 1; 18 | const rounded = Math.round(v * 10 ** decimals) / 10 ** decimals; 19 | return sign + rounded.toFixed(decimals); 20 | } 21 | return sign + fmtInt(v); 22 | } 23 | const units: Array<[number, string]> = [ 24 | [1_000_000_000_000, 'T'], 25 | [1_000_000_000, 'B'], 26 | [1_000_000, 'M'], 27 | [1_000, 'k'], 28 | ]; 29 | for (const [div, suf] of units) { 30 | if (v >= div) { 31 | const val = v / div; 32 | const s = val.toFixed(1); // always show one decimal for consistency (e.g., 25.0k) 33 | return `${sign}${s}${suf}`; 34 | } 35 | } 36 | return sign + fmtInt(v); 37 | } 38 | -------------------------------------------------------------------------------- /lib/rng.ts: -------------------------------------------------------------------------------- 1 | // Deterministic RNG utilities (mulberry32 + string seeding) 2 | 3 | // Hash a string to a 32-bit unsigned integer (xmur3-like) 4 | export function seedFromString(str: string): number { 5 | let h = 1779033703 ^ str.length; 6 | for (let i = 0; i < str.length; i++) { 7 | h = Math.imul(h ^ str.charCodeAt(i), 3432918353); 8 | h = (h << 13) | (h >>> 19); 9 | } 10 | h = Math.imul(h ^ (h >>> 16), 2246822507); 11 | h = Math.imul(h ^ (h >>> 13), 3266489909); 12 | h ^= h >>> 16; 13 | // Ensure uint32 14 | return h >>> 0; 15 | } 16 | 17 | // mulberry32 PRNG 18 | function mulberry32(seed: number) { 19 | let a = seed >>> 0; 20 | return function next() { 21 | a |= 0; 22 | a = (a + 0x6D2B79F5) | 0; 23 | let t = Math.imul(a ^ (a >>> 15), 1 | a); 24 | t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; 25 | return ((t ^ (t >>> 14)) >>> 0) / 4294967296; // [0,1) 26 | }; 27 | } 28 | 29 | export interface RNG { 30 | seed: number; 31 | next: () => number; // float in [0,1) 32 | float: (min: number, max: number) => number; 33 | int: (min: number, max: number) => number; // inclusive min/max 34 | bool: (p?: number) => boolean; // true with probability p (default 0.5) 35 | } 36 | 37 | export function createRNG(seed: number | string): RNG { 38 | const s = typeof seed === 'string' ? seedFromString(seed) : (seed >>> 0); 39 | const base = mulberry32(s); 40 | const next = () => base(); 41 | const float = (min: number, max: number) => min + (max - min) * next(); 42 | const int = (min: number, max: number) => Math.floor(float(min, max + 1)); 43 | const bool = (p = 0.5) => next() < p; 44 | return { seed: s, next, float, int, bool }; 45 | } 46 | 47 | // Keep stub flag to satisfy existing import smoke test 48 | export const RNG_MODULE_LOADED = true; 49 | -------------------------------------------------------------------------------- /components/WorkTable.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach } from 'vitest'; 2 | import { render, screen, within } from '@testing-library/react'; 3 | import WorkTable from './WorkTable'; 4 | import { appStore } from '@/lib/store'; 5 | 6 | beforeEach(() => { 7 | appStore.setState({ 8 | items: { 9 | A1: { id: 'A1', group: 'A', sector: 'Planning', depends_on: [], estimate_ms: 10000, tps_min: 1, tps_max: 2, tps: 1.5, tokens_done: 12, est_tokens: 15, eta_ms: 8000, status: 'queued' }, 10 | B1: { id: 'B1', group: 'B', sector: 'Build', depends_on: ['A1','X1','X2','X3'], estimate_ms: 10000, tps_min: 1, tps_max: 2, tps: 1.2, tokens_done: 3, est_tokens: 15, eta_ms: 6000, status: 'assigned' }, 11 | C1: { id: 'C1', group: 'C', sector: 'Eval', depends_on: ['A1'], estimate_ms: 5000, tps_min: 1, tps_max: 2, tps: 1.7, tokens_done: 5, est_tokens: 8, eta_ms: 2000, status: 'in_progress', agent_id: 'AG1', started_at: Date.now() - 3000 }, 12 | }, 13 | }); 14 | }); 15 | 16 | describe('WorkTable', () => { 17 | it('renders rows sorted by status then id and truncates deps', () => { 18 | render(); 19 | const rows = screen.getAllByRole('row'); 20 | // rows[0] is header 21 | const dataRows = rows.slice(1); 22 | const first = within(dataRows[0]).getByText('A1'); 23 | expect(first).toBeInTheDocument(); 24 | // Check truncation text and new columns render 25 | expect(screen.getByText(/\+2 more/)).toBeInTheDocument(); 26 | expect(screen.getByText(/Tokens/i)).toBeInTheDocument(); 27 | expect(screen.getByText(/TPS/i)).toBeInTheDocument(); 28 | expect(screen.getByText(/ETA/i)).toBeInTheDocument(); 29 | // A simple spot check for formatted values 30 | expect(screen.getByText(/12\s*\/\s*15/)).toBeInTheDocument(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /lib/simClient.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createSimBridge, type SimBridge } from '@/lib/simBridge'; 4 | import { attachBridgeToStore } from '@/lib/bridgeToStore'; 5 | import { appStore } from '@/lib/store'; 6 | import { debugLog } from '@/lib/debug'; 7 | 8 | let _bridge: SimBridge | null = null; 9 | let _worker: Worker | null = null; 10 | let _link: { destroy: () => void } | null = null; 11 | 12 | export function isConnected() { 13 | return !!_bridge; 14 | } 15 | 16 | export function ensureConnected() { 17 | if (_bridge) return _bridge; 18 | if (typeof window === 'undefined' || typeof Worker === 'undefined') return null; 19 | try { 20 | _worker = new Worker(new URL('../workers/engine.ts', import.meta.url), { type: 'module' }); 21 | debugLog('simClient', 'worker-created'); 22 | _worker.addEventListener('error', (e: ErrorEvent) => debugLog('simClient', 'worker-error', e?.message || e)); 23 | _worker.addEventListener('message', (e: MessageEvent) => debugLog('simClient', 'worker-message', e?.data)); 24 | _bridge = createSimBridge(_worker); 25 | _link = attachBridgeToStore(_bridge, appStore); 26 | // initial handshake 27 | _bridge.postIntent({ type: 'request_snapshot' }); 28 | return _bridge; 29 | } catch (e) { 30 | debugLog('simClient', 'ensureConnected-failed', e); 31 | return null; 32 | } 33 | } 34 | 35 | export function setExternalBridge(bridge: SimBridge, worker: Worker, link: { destroy: () => void }) { 36 | if (_bridge) return; // already connected 37 | _bridge = bridge; _worker = worker; _link = link; 38 | } 39 | 40 | export function postIntent(intent: Parameters[0]) { 41 | if (!_bridge) ensureConnected(); 42 | _bridge?.postIntent(intent); 43 | } 44 | 45 | export function destroyConnection() { 46 | _link?.destroy(); 47 | try { _worker?.terminate(); } catch {} 48 | _link = null; _bridge = null; _worker = null; 49 | } 50 | 51 | -------------------------------------------------------------------------------- /components/MetricsBar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useSyncExternalStore } from 'react'; 4 | import { appStore } from '@/lib/store'; 5 | 6 | function useAppMetrics() { 7 | return useSyncExternalStore( 8 | appStore.subscribe, 9 | () => appStore.getState().metrics, 10 | () => appStore.getState().metrics, 11 | ); 12 | } 13 | 14 | function fmtInt(n: number) { 15 | return new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(n || 0); 16 | } 17 | 18 | function fmtFloat(n: number, frac = 1) { 19 | const v = Number.isFinite(n) ? n : 0; 20 | return v.toFixed(frac); 21 | } 22 | 23 | function fmtUSD(n: number) { 24 | const v = Number.isFinite(n) ? n : 0; 25 | return `$${new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(v)}`; 26 | } 27 | 28 | function fmtPct(n: number) { 29 | const v = Number.isFinite(n) ? n : 0; 30 | return `${(v * 100).toFixed(0)}%`; 31 | } 32 | 33 | export default function MetricsBar() { 34 | const m = useAppMetrics(); 35 | 36 | return ( 37 |
38 |
39 | 40 | 41 | 42 | 43 | 44 | 45 |
46 |
47 | ); 48 | } 49 | 50 | function Metric({ label, value }: { label: string; value: string }) { 51 | return ( 52 |
53 |
{label}
54 |
{value}
55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /lib/simBridge.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { createSimBridge, type PortLike, type MessageEventLike, type SimMsg } from './simBridge'; 3 | 4 | class FakePort implements PortLike { 5 | private handlers = new Set<(e: MessageEventLike) => void>(); 6 | public sent: unknown[] = []; 7 | postMessage(msg: unknown): void { 8 | this.sent.push(msg); 9 | } 10 | addEventListener(event: 'message', handler: (e: MessageEventLike) => void): void { 11 | if (event === 'message') this.handlers.add(handler); 12 | } 13 | removeEventListener(event: 'message', handler: (e: MessageEventLike) => void): void { 14 | if (event === 'message') this.handlers.delete(handler); 15 | } 16 | emit(msg: SimMsg) { 17 | for (const h of this.handlers) h({ data: msg }); 18 | } 19 | } 20 | 21 | describe('simBridge', () => { 22 | it('drops out-of-order tick messages', () => { 23 | const port = new FakePort(); 24 | const bridge = createSimBridge(port, { batchMs: 0 }); 25 | const seen: number[] = []; 26 | bridge.subscribe((msg) => { 27 | if (msg.type === 'tick') seen.push(msg.tick_id); 28 | }); 29 | 30 | port.emit({ type: 'tick', tick_id: 1 }); 31 | port.emit({ type: 'tick', tick_id: 3 }); 32 | port.emit({ type: 'tick', tick_id: 2 }); // should be dropped 33 | port.emit({ type: 'tick', tick_id: 4 }); 34 | 35 | expect(seen).toEqual([1, 3, 4]); 36 | }); 37 | 38 | it('resets ordering on snapshot', () => { 39 | const port = new FakePort(); 40 | const bridge = createSimBridge(port, { batchMs: 0 }); 41 | const seen: SimMsg[] = []; 42 | bridge.subscribe((msg) => seen.push(msg)); 43 | 44 | port.emit({ type: 'tick', tick_id: 2 }); 45 | port.emit({ type: 'snapshot', state: { items: {}, agents: {}, metrics: { active_agents: 0, total_tokens: 0, total_spend_usd: 0, live_tps: 0, live_spend_per_s: 0, completion_rate: 0 }, seed: 's', running: true } }); 46 | port.emit({ type: 'tick', tick_id: 1 }); // accepted after snapshot 47 | 48 | const ticks = seen.filter((m) => m.type === 'tick') as Extract[]; 49 | expect(ticks.map((t) => t.tick_id)).toEqual([2, 1]); 50 | }); 51 | }); 52 | 53 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | // Core TypeScript types for the Calming Control Room 2 | 3 | export type Status = 'queued' | 'assigned' | 'in_progress' | 'blocked' | 'done'; 4 | 5 | export interface WorkItem { 6 | id: string; // e.g., "A1", "B3" 7 | group: string; // 'A', 'B', ... (for grouping/legend) 8 | sector: string; // e.g., 'Planning', 'Build', 'Eval', 'Deploy' 9 | depends_on: string[]; // list of WorkItem ids 10 | desc?: string; // optional human-friendly work order description 11 | 12 | estimate_ms: number; // target duration for this item (ms) 13 | started_at?: number; // epoch ms when entered in_progress 14 | eta_ms?: number; // rolling ETA in ms (recomputed) 15 | 16 | tps_min: number; // tokens/sec lower bound for this item 17 | tps_max: number; // tokens/sec upper bound 18 | tps: number; // current tokens/sec (dynamic within [min,max]) 19 | tokens_done: number; // cumulative tokens produced for this item 20 | est_tokens: number; // derived from estimate + nominal tps 21 | 22 | status: Status; 23 | agent_id?: string; // set when in_progress 24 | } 25 | 26 | export interface Agent { 27 | id: string; // e.g., 'P1','D2','E3','Q7','X584' 28 | work_item_id: string; // current assignment 29 | // Radar motion state (normalized world coords in [-1,1]) 30 | x: number; 31 | y: number; // current position 32 | v: number; // scalar speed (units/frame) mapped from tps 33 | curve_phase: number; // 0..1 for bezier curvature evolution 34 | } 35 | 36 | export interface ProjectMetrics { 37 | active_agents: number; 38 | total_tokens: number; // cumulative across all time 39 | total_spend_usd: number; // cumulative spend 40 | live_tps: number; // sum of in_progress tps 41 | live_spend_per_s: number; 42 | completion_rate: number; // done / eligible (0..1) 43 | } 44 | 45 | export interface AppState { 46 | items: Record; 47 | agents: Record; 48 | metrics: ProjectMetrics; 49 | seed: string; 50 | running: boolean; 51 | } 52 | 53 | // Keep this flag export to satisfy existing stub import tests 54 | export const TYPES_MODULE_LOADED = true; 55 | -------------------------------------------------------------------------------- /lib/store.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { createAppStore } from './store'; 3 | import type { AppState } from './types'; 4 | 5 | describe('reducers-apply-snapshot-and-diff', () => { 6 | it('applies snapshot then tick diff and advances lastTickId', () => { 7 | const store = createAppStore(); 8 | 9 | const snapshot: AppState = { 10 | items: { 11 | A1: { 12 | id: 'A1', 13 | group: 'A', 14 | sector: 'Planning', 15 | depends_on: [], 16 | estimate_ms: 10000, 17 | tps_min: 5, 18 | tps_max: 10, 19 | tps: 6, 20 | tokens_done: 0, 21 | est_tokens: 60, 22 | status: 'queued', 23 | }, 24 | B1: { 25 | id: 'B1', 26 | group: 'B', 27 | sector: 'Build', 28 | depends_on: [], 29 | estimate_ms: 15000, 30 | tps_min: 7, 31 | tps_max: 12, 32 | tps: 8, 33 | tokens_done: 0, 34 | est_tokens: 100, 35 | status: 'assigned', 36 | }, 37 | }, 38 | agents: {}, 39 | metrics: { 40 | active_agents: 0, 41 | total_tokens: 0, 42 | total_spend_usd: 0, 43 | live_tps: 0, 44 | live_spend_per_s: 0, 45 | completion_rate: 0, 46 | }, 47 | seed: 's', 48 | running: true, 49 | }; 50 | 51 | store.getState().applySnapshot(snapshot); 52 | 53 | // Apply a diff: update one item and metrics 54 | store.getState().applyTick({ 55 | tick_id: 1, 56 | items: [ 57 | { id: 'A1', status: 'in_progress', tps: 9, started_at: 123, eta_ms: 9000 }, 58 | ], 59 | metrics: { active_agents: 1, live_tps: 9 }, 60 | }); 61 | 62 | const state = store.getState(); 63 | expect(state.lastTickId).toBe(1); 64 | expect(state.items['A1'].status).toBe('in_progress'); 65 | expect(state.items['A1'].tps).toBe(9); 66 | expect(state.metrics.active_agents).toBe(1); 67 | 68 | // Out-of-order tick should be ignored 69 | store.getState().applyTick({ tick_id: 1, metrics: { live_tps: 5 } }); 70 | expect(store.getState().metrics.live_tps).toBe(9); 71 | }); 72 | }); 73 | 74 | -------------------------------------------------------------------------------- /lib/bridgeToStore.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { attachBridgeToStore } from './bridgeToStore'; 3 | import { createAppStore } from './store'; 4 | import type { SimMsg } from './simBridge'; 5 | 6 | class FakeBridge { 7 | private subs = new Set<(msg: SimMsg) => void>(); 8 | subscribe(handler: (msg: SimMsg) => void) { 9 | this.subs.add(handler); 10 | return () => this.subs.delete(handler); 11 | } 12 | emit(msg: SimMsg) { 13 | for (const h of this.subs) h(msg); 14 | } 15 | } 16 | 17 | function wait(ms: number) { 18 | return new Promise((r) => setTimeout(r, ms)); 19 | } 20 | 21 | describe('throttle-coalesces-updates', () => { 22 | it('coalesces many ticks into a single store update per interval', async () => { 23 | const bridge = new FakeBridge(); 24 | const store = createAppStore(); 25 | let updates = 0; 26 | const unsubStore = store.subscribe(() => updates++); 27 | 28 | const { destroy } = attachBridgeToStore(bridge, store, { intervalMs: 20 }); 29 | 30 | // Emit a burst of ticks 31 | for (let i = 1; i <= 10; i++) { 32 | bridge.emit({ type: 'tick', tick_id: i }); 33 | } 34 | 35 | // Wait a bit longer than interval to allow flush 36 | await wait(30); 37 | 38 | expect(store.getState().lastTickId).toBe(10); 39 | // Should be 1 or a very small number, not 10 40 | expect(updates).toBeLessThanOrEqual(2); 41 | 42 | unsubStore(); 43 | destroy(); 44 | }); 45 | 46 | it('applies snapshot immediately and resets aggregation', async () => { 47 | const bridge = new FakeBridge(); 48 | const store = createAppStore(); 49 | const { destroy } = attachBridgeToStore(bridge, store, { intervalMs: 50 }); 50 | 51 | bridge.emit({ type: 'tick', tick_id: 1 }); 52 | bridge.emit({ 53 | type: 'snapshot', 54 | state: { 55 | items: {}, 56 | agents: {}, 57 | metrics: { active_agents: 0, total_tokens: 0, total_spend_usd: 0, live_tps: 0, live_spend_per_s: 0, completion_rate: 0 }, 58 | seed: 's', 59 | running: true, 60 | }, 61 | }); 62 | 63 | // Next tick id lower than previous should still apply because snapshot reset 64 | bridge.emit({ type: 'tick', tick_id: 1 }); 65 | await wait(60); 66 | expect(store.getState().lastTickId).toBe(1); 67 | 68 | destroy(); 69 | }); 70 | }); 71 | 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Agent Traffic Control 2 | 3 | A minimal Next.js dashboard to monitor and control AI agent traffic. View live at https://agenttrafficcontrol.com. 4 | 5 | Best with full screen and sound on. 6 | 7 | ![Agent Traffic Control](homepage.png) 8 | 9 | ## Getting Started 10 | 11 | ```bash 12 | git clone https://github.com/gkamradt/agenttrafficcontrol && cd agenttrafficcontrol 13 | npm install 14 | cp .env.example .env # add your keys if needed 15 | npm run dev 16 | # Open http://localhost:3000 17 | ``` 18 | 19 | ## How It Works 20 | 21 | On first load the client establishes a connection to a dedicated Web Worker engine (`workers/engine.ts`). Runtime knobs live in `lib/config.ts` and the engine loads a plan definition from `plans/` to build the initial project graph. 22 | 23 | The UI immediately sends intents to the worker (set seed → set plan → start running) and the engine begins ticking at `ENGINE_TICK_HZ`, emitting a full `snapshot` once and `tick` diffs thereafter. 24 | 25 | Worker messages flow through a tiny transport (`lib/simBridge.ts`) that batches events, then into a coalescing adapter (`lib/bridgeToStore.ts`) that applies them to a single Zustand store (`lib/store.ts`). 26 | 27 | All React components read from this store; controls like `components/ControlBar.tsx` post intents (change plan, start/pause, reseed) back to the engine. The store is the UI’s source of truth; the engine is the simulation’s source of truth. 28 | 29 | Includes live streams for ambiance: 30 | 31 | - ATC: https://www.youtube.com/watch?v=mOec9Fu3Jz0 32 | - Music: https://www.youtube.com/watch?v=jfKfPfyJRdk 33 | 34 | ## Architecture 35 | 36 | ``` 37 | .env + lib/config.ts plans/ 38 | │ │ 39 | ▼ ▼ 40 | Web Worker Engine <─ loads plan 41 | (ticks) 42 | │ snapshot/tick 43 | ▼ 44 | lib/simBridge.ts (batch) 45 | │ 46 | ▼ 47 | lib/bridgeToStore.ts (coalesce) 48 | │ 49 | ▼ 50 | lib/store.ts (Zustand appStore) 51 | │ 52 | ▼ 53 | React components (read state) 54 | ▲ 55 | │ intents (set_plan, set_seed, set_running) 56 | components/ControlBar.tsx via lib/simClient.ts 57 | ``` 58 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import localFont from "next/font/local"; 4 | import "./globals.css"; 5 | import { Analytics } from "@vercel/analytics/next" 6 | 7 | const geistSans = Geist({ 8 | variable: "--font-geist-sans", 9 | subsets: ["latin"], 10 | }); 11 | 12 | const geistMono = Geist_Mono({ 13 | variable: "--font-geist-mono", 14 | subsets: ["latin"], 15 | }); 16 | 17 | // Local font: Source Code Pro Regular (applied globally via Tailwind token mapping) 18 | const sourceCodePro = localFont({ 19 | src: [ 20 | { 21 | path: "../public/fonts/source-code-pro/SourceCodePro-Regular.ttf", 22 | weight: "400", 23 | style: "normal", 24 | }, 25 | { 26 | path: "../public/fonts/source-code-pro/SourceCodePro-Bold.ttf", 27 | weight: "700", 28 | style: "normal", 29 | }, 30 | ], 31 | variable: "--font-source-code-pro", 32 | display: "swap", 33 | }); 34 | 35 | // Local font: Source Code Pro Regular (applied globally via Tailwind token mapping) 36 | const jetBrainsMono = localFont({ 37 | src: [ 38 | { 39 | path: "../public/fonts/jet-brains-mono/JetBrainsMono-Regular.ttf", 40 | weight: "400", 41 | style: "normal", 42 | }, 43 | { 44 | path: "../public/fonts/jet-brains-mono/JetBrainsMono-Bold.ttf", 45 | weight: "700", 46 | style: "normal", 47 | }, 48 | ], 49 | variable: "--font-jet-brains-mono", 50 | display: "swap", 51 | }); 52 | 53 | export const metadata: Metadata = { 54 | // Helps Next generate absolute URLs for OG/Twitter images 55 | metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"), 56 | title: "Agent Traffic Control (ATC)", 57 | description: "Direct the vibe of your agents in the Agent Traffic Control Room", 58 | openGraph: { 59 | title: "Agent Traffic Control (ATC)", 60 | description: "Direct the vibe of your agents in the Agent Traffic Control Room", 61 | type: "website", 62 | url: "/", 63 | siteName: "Agent Traffic Control (ATC)", 64 | images: [ 65 | { 66 | url: "/images/ATC_OG.png", 67 | width: 2048, 68 | height: 1280, 69 | alt: "Agent Traffic Control (ATC)", 70 | }, 71 | ], 72 | }, 73 | twitter: { 74 | card: "summary_large_image", 75 | title: "Agent Traffic Control (ATC)", 76 | description: "Direct the vibe of your agents in the Agent Traffic Control Room", 77 | images: ["/images/ATC_OG.png"], 78 | }, 79 | }; 80 | 81 | export default function RootLayout({ 82 | children, 83 | }: Readonly<{ 84 | children: React.ReactNode; 85 | }>) { 86 | return ( 87 | 88 | 89 | 92 | {children} 93 | 94 | 95 | 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | :root { 4 | --background: #000000; /* Force full black */ 5 | --foreground: #ededed; 6 | /* Global theme colors */ 7 | --green-bright: #57ff7a; 8 | /* UI label variables (used for small section titles/badges) */ 9 | --ui-label-font-size: 0.8rem; /* ~10px, about half of text-lg */ 10 | --ui-label-line-height: 1.15; 11 | --ui-label-py: 0.125rem; /* 2px */ 12 | --ui-label-px: 0.5rem; /* 8px */ 13 | --ui-label-bg: #c79325ff; /* Tailwind yellow-300 */ 14 | --ui-label-fg: #000000; 15 | /* Consistent header row height for section headers */ 16 | --ui-header-row-height: 28px; 17 | /* Row status colors (easy to tweak) */ 18 | --row-done-bg: #05291dff; /* emerald-800 */ 19 | --row-done-fg: #047f54ff; /* emerald-200 */ 20 | --row-inprog-bg: #212107ff; /* amber-900 */ 21 | --row-inprog-fg: #9aa40aff; /* amber-300 */ 22 | --row-queued-bg: #0e1d46; /* blue-800 */ 23 | --row-queued-fg: #8aa9cf; /* blue-200 */ 24 | } 25 | 26 | @theme inline { 27 | --color-background: var(--background); 28 | --color-foreground: var(--foreground); 29 | /* Map Tailwind font tokens to Source Code Pro */ 30 | --font-sans: var(--font-jet-brains-mono); 31 | --font-mono: var(--font-jet-brains-mono); 32 | } 33 | 34 | @media (prefers-color-scheme: dark) { 35 | :root { 36 | --background: #000000; /* Full black in dark mode too */ 37 | --foreground: #ededed; 38 | } 39 | } 40 | 41 | body { 42 | background: var(--background); 43 | color: var(--foreground); 44 | } 45 | 46 | /* Reusable small title/label style for consistent height */ 47 | .ui-label { 48 | display: inline-flex; 49 | align-items: center; 50 | font-size: var(--ui-label-font-size); 51 | line-height: var(--ui-label-line-height); 52 | padding: var(--ui-label-py) var(--ui-label-px); 53 | background: var(--ui-label-bg); 54 | color: var(--ui-label-fg); 55 | font-weight: 700; /* use Bold face we loaded */ 56 | } 57 | 58 | /* Variant: make it look like a tab sitting on the card border */ 59 | .ui-label--tab { 60 | border-bottom-left-radius: 0; 61 | border-bottom-right-radius: 0; 62 | margin-top: -1px; /* overlap the top border for a flush tab look */ 63 | } 64 | 65 | /* Tighter horizontal padding (≈2px each side) */ 66 | .ui-label--tight { 67 | padding-left: 0.125rem; /* 2px */ 68 | padding-right: 0.125rem; /* 2px */ 69 | } 70 | 71 | /* Make the label fill the container height */ 72 | .ui-label--fill { 73 | height: 100%; 74 | } 75 | 76 | /* Utility to hide scrollbars while enabling scroll */ 77 | .no-scrollbar { 78 | -ms-overflow-style: none; /* IE and Edge */ 79 | scrollbar-width: none; /* Firefox */ 80 | } 81 | .no-scrollbar::-webkit-scrollbar { 82 | display: none; /* Chrome, Safari and Opera */ 83 | } 84 | 85 | /* Table row status helpers */ 86 | .tr-status-done { 87 | background-color: var(--row-done-bg) !important; 88 | color: var(--row-done-fg) !important; 89 | } 90 | .tr-status-inprogress { 91 | background-color: var(--row-inprog-bg) !important; 92 | color: var(--row-inprog-fg) !important; 93 | } 94 | .tr-status-queued { 95 | background-color: var(--row-queued-bg) !important; 96 | color: var(--row-queued-fg) !important; 97 | } 98 | -------------------------------------------------------------------------------- /plans/edits.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Randomize timing fields in a plan file. 4 | 5 | Defaults: 6 | - estimate_ms: 10,000–30,000 7 | - tps_min: 40–80 8 | - tps_max: 80–120 9 | 10 | Usage: 11 | python3 randomize_plan_fields.py path/to/plan.ts 12 | python3 randomize_plan_fields.py plan.ts --seed 42 # reproducible 13 | python3 randomize_plan_fields.py plan.ts --dry-run # preview to stdout 14 | python3 randomize_plan_fields.py plan.ts --no-backup # skip .bak 15 | # Ranges are overrideable: 16 | python3 randomize_plan_fields.py plan.ts --ms 9000 32000 --tps-min 30 70 --tps-max 70 140 17 | """ 18 | import argparse, random, re, shutil, sys 19 | 20 | KEYS = ("estimate_ms", "tps_min", "tps_max") 21 | 22 | def replace_field(text: str, field: str, lo: int, hi: int): 23 | # Match: estimate_ms: 12345 OR "estimate_ms": 12345 24 | pat = re.compile(rf'(?P(?:"{field}"|\'{field}\'|{field})\s*:\s*)\d+') 25 | def repl(m): 26 | return f"{m.group('prefix')}{random.randint(lo, hi)}" 27 | return pat.subn(repl, text) 28 | 29 | def main(): 30 | ap = argparse.ArgumentParser(description="Randomize estimate_ms, tps_min, tps_max in a plan file.") 31 | ap.add_argument("path", help="Path to .ts/.json-like plan file") 32 | ap.add_argument("--ms", dest="ms", nargs=2, type=int, default=(10_000, 30_000), 33 | metavar=("LOW", "HIGH"), help="Range for estimate_ms") 34 | ap.add_argument("--tps-min", dest="tpsmin", nargs=2, type=int, default=(40, 80), 35 | metavar=("LOW", "HIGH"), help="Range for tps_min") 36 | ap.add_argument("--tps-max", dest="tpsmax", nargs=2, type=int, default=(80, 120), 37 | metavar=("LOW", "HIGH"), help="Range for tps_max") 38 | ap.add_argument("--seed", type=int, help="PRNG seed for reproducible outputs") 39 | ap.add_argument("--dry-run", action="store_true", help="Print result to stdout (don’t write file)") 40 | ap.add_argument("--no-backup", action="store_true", help="Don’t create .bak backup before writing") 41 | args = ap.parse_args() 42 | 43 | if args.seed is not None: 44 | random.seed(args.seed) 45 | 46 | try: 47 | with open(args.path, "r", encoding="utf-8") as f: 48 | txt = f.read() 49 | except OSError as e: 50 | sys.exit(f"Error reading {args.path}: {e}") 51 | 52 | txt, c_ms = replace_field(txt, "estimate_ms", args.ms[0], args.ms[1]) 53 | txt, c_min = replace_field(txt, "tps_min", args.tpsmin[0], args.tpsmin[1]) 54 | txt, c_max = replace_field(txt, "tps_max", args.tpsmax[0], args.tpsmax[1]) 55 | 56 | if args.dry_run: 57 | sys.stdout.write(txt) 58 | return 59 | 60 | if not args.no_backup: 61 | try: 62 | shutil.copy2(args.path, args.path + ".bak") 63 | except OSError as e: 64 | sys.exit(f"Error creating backup: {e}") 65 | 66 | try: 67 | with open(args.path, "w", encoding="utf-8") as f: 68 | f.write(txt) 69 | except OSError as e: 70 | sys.exit(f"Error writing {args.path}: {e}") 71 | 72 | print(f"Updated {args.path} (estimate_ms:{c_ms}, tps_min:{c_min}, tps_max:{c_max})") 73 | 74 | if __name__ == "__main__": 75 | main() 76 | -------------------------------------------------------------------------------- /lib/audio/tracks.ts: -------------------------------------------------------------------------------- 1 | export type AudioSource = 2 | | { type: 'local'; src: string } 3 | | { type: 'youtube'; url: string }; 4 | 5 | export type Track = { 6 | id: string; 7 | title: string; 8 | source: AudioSource; 9 | }; 10 | 11 | // Place your audio files under `public/audio/` and update the mapping below. 12 | // Example local files are placeholders; replace with your own. 13 | export const tracks: Track[] = [ 14 | { 15 | id: 'work-music-for-serious-grind-stay-aligned', 16 | title: 'Work Music for Serious Grind | Stay Aligned', 17 | source: { type: 'youtube', url: 'https://www.youtube.com/watch?v=MYW0TgV67RE&list=RDMYW0TgV67RE&start_radio=1' }, 18 | }, 19 | { 20 | id: 'lofi-hip-hop-radio', 21 | title: 'lofi hip hop radio 📚 beats to relax/study to', 22 | source: { type: 'youtube', url: 'https://www.youtube.com/watch?v=jfKfPfyJRdk' }, 23 | }, 24 | { 25 | id: 'flight-facilities-clair-de-lune-feat-christine-hoberg', 26 | title: 'Flight Facilities - Clair De Lune feat. Christine Hoberg', 27 | source: { type: 'youtube', url: 'https://www.youtube.com/watch?v=Jcu1AHaTchM&list=RDJcu1AHaTchM&start_radio=1' }, 28 | }, 29 | { 30 | id: 'work-music-for-progress-trust-the-process', 31 | title: 'Work Music for Progress | Trust the Process', 32 | source: { type: 'youtube', url: 'https://www.youtube.com/watch?v=Efkc-AMB96c' }, 33 | }, 34 | { 35 | id: 'work-music-for-ambition-build-the-future', 36 | title: 'Work Music for Ambition | Build the Future', 37 | source: { type: 'youtube', url: 'https://www.youtube.com/watch?v=UgT24uQx7-I' }, 38 | }, 39 | { 40 | id: 'work-music-for-deep-hustle-quiet-but-relentless', 41 | title: 'Work Music for Deep Hustle | Quiet but Relentless', 42 | source: { type: 'youtube', url: 'https://www.youtube.com/watch?v=5O-Nmzhkz_4&t=11s' }, 43 | }, 44 | { 45 | id: 'work-music-for-momentum-let-the-flow-build', 46 | title: 'Work Music for Momentum | Let the Flow Build', 47 | source: { type: 'youtube', url: 'https://www.youtube.com/watch?v=rH0iZluJ2fY' }, 48 | }, 49 | { 50 | id: 'work-music-for-clear-focus-calm-and-clear', 51 | title: 'Work Music for Clear Focus | Calm and Clear', 52 | source: { type: 'youtube', url: 'https://www.youtube.com/watch?v=fAxCoB2VUlc' }, 53 | }, 54 | { 55 | id: 'work-music-for-deep-work-focus-with-progress', 56 | title: 'Work Music for Deep Work | Focus With Progress', 57 | source: { type: 'youtube', url: 'https://www.youtube.com/watch?v=WZt8hjTwP20' }, 58 | }, 59 | { 60 | id: 'flight-facilities-live-at-airfields-sydney-full-concert', 61 | title: 'Flight Facilities - Live At Airfields, Sydney (Full Concert)', 62 | source: { type: 'youtube', url: 'https://youtu.be/ts-6KyJUDWY?si=Ib_PS5sd9fwLpzTI' }, 63 | }, 64 | ]; 65 | 66 | export const radio: Track[] = [ 67 | { 68 | id: 'sfo', 69 | title: 'SFO ATC', 70 | source: { type: 'youtube', url: 'https://www.youtube.com/watch?v=mOec9Fu3Jz0' }, 71 | }, 72 | { 73 | id: 'las-vegas', 74 | title: 'Las Vegas ATC', 75 | source: { type: 'youtube', url: 'https://www.youtube.com/watch?v=Z_iF0OHUuz8' }, 76 | }, 77 | { 78 | id: 'jfk', 79 | title: 'JFK ATC', 80 | source: { type: 'youtube', url: 'https://www.youtube.com/watch?v=xq_kuLD8T0A' }, 81 | } 82 | ] -------------------------------------------------------------------------------- /lib/constants.ts: -------------------------------------------------------------------------------- 1 | // Core constants for Calming Control Room (tunable via PRD) 2 | 3 | // Cost model (USD per token). Example: $0.002 per 1K tokens => 0.000002 per token 4 | export const COST_PER_TOKEN_USD = 0.00013; 5 | 6 | // Concurrency and motion tuning 7 | export const MAX_CONCURRENT = 12; 8 | export const V_MIN = 0.002; // world units/frame 9 | export const V_MAX = 0.010; // world units/frame 10 | export const TRAIL_DECAY = 0.08; // alpha per frame on motion buffer 11 | 12 | // Radar visuals 13 | export const RING_COUNT = 5; 14 | 15 | // Radar path curvature controls 16 | // - RADAR_CURVE_AMOUNT: 0 = straight lines to center, 1 = max curve (half turn cap) 17 | // - RADAR_MAX_TURNS: maximum rotations (in turns) allowed over full path. 0.5 = half rotation 18 | // - RADAR_WOBBLE: proportion of the curve budget allocated to side-to-side wobble (random per agent) 19 | export const RADAR_CURVE_AMOUNT = 0.6; // main knob to increase/decrease curvature [0..1] 20 | export const RADAR_MAX_TURNS = 0.4; // cap total spin to half a rotation 21 | export const RADAR_WOBBLE = 0.3; // 0 = pure spiral, 1 = mostly wobble 22 | 23 | // Radar completion pulse controls 24 | // A subtle expanding ring emitted at the center when an agent reaches the target. 25 | export const RADAR_PULSE_MAX_RADIUS = 0.10; // as fraction of radar radius 26 | export const RADAR_PULSE_DURATION_MS = 800; // total life of a pulse 27 | export const RADAR_PULSE_WIDTH = 4; // stroke width in px 28 | export const RADAR_PULSE_SECONDARY = 0.6; // second ring offset multiplier (0 to disable) 29 | 30 | // Radar ping sound volume 31 | export const RADAR_PING_VOLUME = 0.5; 32 | export const RADAR_PING_AUDIO_PATH = '/audio/sonar_ping_3.mp3'; 33 | 34 | // Radar render/update cadence (UI only; not engine tick) 35 | // Controls how often agent positions and effects update on the radar. 36 | export const RADAR_REFRESH_HZ = 30; // e.g., 30 Hz; set 60 for smoother motion 37 | 38 | // Engine tick rate (Hz). Worker internal loop cadence (not UI render). 39 | export const ENGINE_TICK_HZ = 30; 40 | 41 | // TPS dynamics (per-item throughput variability) 42 | // - TPS_ALPHA: smoothing toward the current target per tick (higher = faster moves) 43 | // - TPS_TARGET_HOLD_MS_*: how long to hold a sampled target before choosing a new one 44 | // - TPS_JITTER_FRAC: small per-tick flutter around the held target (as fraction of range) 45 | export const TPS_ALPHA = 0.3; 46 | export const TPS_TARGET_HOLD_MS_MIN = 1600; 47 | export const TPS_TARGET_HOLD_MS_MAX = 3600; 48 | export const TPS_JITTER_FRAC = 0.03; 49 | 50 | // Transport batching and store flush cadences (UI data pipeline) 51 | // - BRIDGE_BATCH_MS: Coalesces raw worker messages before applying to the app store. 52 | // Higher values reduce churn (fewer updates) but can add latency. 53 | // - STORE_FLUSH_INTERVAL_MS: How often coalesced diffs are committed to Zustand. 54 | // Keep similar to BRIDGE_BATCH_MS unless you want extra smoothing. 55 | export const BRIDGE_BATCH_MS = 50; // ms: batch worker messages 56 | export const STORE_FLUSH_INTERVAL_MS = 50; // ms: flush coalesced diffs to store 57 | 58 | // Sectors and colors 59 | export const SECTORS = ['PLANNING', 'BUILD', 'EVAL', 'DEPLOY'] as const; 60 | export type Sector = typeof SECTORS[number]; 61 | export const SECTOR_COLORS: Record = { 62 | PLANNING: '#6EE7B7', 63 | BUILD: '#93C5FD', 64 | EVAL: '#FCA5A5', 65 | DEPLOY: '#FDE68A', 66 | }; 67 | 68 | // Keep stub flag to satisfy existing import smoke test 69 | export const CONSTANTS_MODULE_LOADED = true; 70 | -------------------------------------------------------------------------------- /lib/engine.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { buildItemsFromPlan, detectCycles, promoteQueuedToAssigned, computeMetrics } from './engine'; 3 | import type { PlanDefinition } from '@/plans/types'; 4 | import type { Agent, WorkItem } from './types'; 5 | 6 | describe('deps-resolver', () => { 7 | it('promotes items whose deps are satisfied', () => { 8 | const plan: PlanDefinition = { 9 | name: 'Test', 10 | items: [ 11 | { id: 'A', group: 'X', sector: 'Planning', depends_on: [], estimate_ms: 1000, tps_min: 1, tps_max: 2 }, 12 | { id: 'B', group: 'X', sector: 'Build', depends_on: ['A'], estimate_ms: 1000, tps_min: 1, tps_max: 2 }, 13 | ], 14 | }; 15 | const items = buildItemsFromPlan(plan); 16 | // Initially A has no deps -> should become assigned 17 | const first = promoteQueuedToAssigned(items); 18 | expect(first).toContain('A'); 19 | expect(items['A'].status).toBe('assigned'); 20 | expect(items['B'].status).toBe('queued'); 21 | 22 | // Mark A done -> B becomes assigned 23 | items['A'].status = 'done'; 24 | const second = promoteQueuedToAssigned(items); 25 | expect(second).toContain('B'); 26 | expect(items['B'].status).toBe('assigned'); 27 | }); 28 | 29 | it('metrics-math computes totals and rates', () => { 30 | const plan: PlanDefinition = { 31 | name: 'Metrics', 32 | items: [ 33 | { id: 'A', group: 'G', sector: 'Planning', depends_on: [], estimate_ms: 10000, tps_min: 2, tps_max: 4 }, 34 | { id: 'B', group: 'G', sector: 'Build', depends_on: ['A'], estimate_ms: 5000, tps_min: 5, tps_max: 6 }, 35 | ], 36 | }; 37 | const items = buildItemsFromPlan(plan); 38 | // Make A done with tokens and B in_progress with current tps 39 | items['A'].status = 'done'; 40 | items['A'].tokens_done = 100; 41 | items['B'].status = 'in_progress'; 42 | items['B'].tps = 5; 43 | items['B'].tokens_done = 20; 44 | const agents: Record = { AG1: { id: 'AG1', work_item_id: 'B', x: 0, y: 0, v: 0, curve_phase: 0 } }; 45 | const m = computeMetrics(items as Record, agents); 46 | expect(m.active_agents).toBe(1); 47 | expect(m.total_tokens).toBeCloseTo(120, 5); 48 | expect(m.live_tps).toBeCloseTo(5, 5); 49 | // Time-weighted completion across whole plan: 50 | // A contributes 100% of its 10s, B contributes (20/28) of its 5s 51 | // => (10s + 5s * 20/28) / (10s + 5s) 52 | const estTokensB = Math.round(((5 + 6) / 2) * (5000 / 1000)); 53 | const expected = (10000 + 5000 * (20 / estTokensB)) / (10000 + 5000); 54 | expect(m.completion_rate).toBeCloseTo(expected, 5); 55 | expect(m.total_spend_usd).toBeGreaterThan(0); 56 | expect(m.live_spend_per_s).toBeGreaterThan(0); 57 | }); 58 | 59 | it('detects cycles and leaves them queued', () => { 60 | const plan: PlanDefinition = { 61 | name: 'Cycle', 62 | items: [ 63 | { id: 'A', group: 'X', sector: 'Planning', depends_on: ['B'], estimate_ms: 1000, tps_min: 1, tps_max: 2 }, 64 | { id: 'B', group: 'X', sector: 'Build', depends_on: ['A'], estimate_ms: 1000, tps_min: 1, tps_max: 2 }, 65 | ], 66 | }; 67 | const items = buildItemsFromPlan(plan); 68 | const cycles = detectCycles(items); 69 | expect(cycles.length).toBeGreaterThan(0); 70 | const changed = promoteQueuedToAssigned(items); 71 | expect(changed.length).toBe(0); 72 | expect(items['A'].status).toBe('queued'); 73 | expect(items['B'].status).toBe('queued'); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /components/OperatorGroups.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useMemo, useSyncExternalStore } from 'react'; 4 | import { getPlanByName, ALL_PLANS } from '@/plans'; 5 | import type { PlanDefinition, WorkGroupDef } from '@/plans/types'; 6 | import { appStore } from '@/lib/store'; 7 | import { DEFAULT_PLAN_NAME } from '@/plans'; 8 | 9 | function pickPlan(name: string): PlanDefinition { 10 | return getPlanByName(name) ?? ALL_PLANS[0]; 11 | } 12 | 13 | function deriveGroupsFromItems(items: ReturnType['items']): WorkGroupDef[] { 14 | const seen = new Map(); 15 | for (const it of Object.values(items)) { 16 | seen.set(it.group, (seen.get(it.group) || 0) + 1); 17 | } 18 | const out: WorkGroupDef[] = []; 19 | for (const [id, count] of seen) { 20 | out.push({ id, title: `Group ${id}`, description: `${count} work items.` }); 21 | } 22 | out.sort((a, b) => a.id.localeCompare(b.id)); 23 | return out; 24 | } 25 | 26 | export default function OperatorGroups() { 27 | // Subscribe to live items so completion updates over time 28 | const items = useSyncExternalStore( 29 | appStore.subscribe, 30 | () => appStore.getState().items, 31 | () => appStore.getState().items, 32 | ); 33 | // Subscribe to the applied plan name so Operator Action Items update on Execute 34 | const planName = useSyncExternalStore( 35 | appStore.subscribe, 36 | () => appStore.getState().plan_name || DEFAULT_PLAN_NAME, 37 | () => appStore.getState().plan_name || DEFAULT_PLAN_NAME, 38 | ); 39 | 40 | const groups = useMemo(() => { 41 | const plan = pickPlan(planName); 42 | if (plan.groups && plan.groups.length) return plan.groups; 43 | return deriveGroupsFromItems(items); 44 | }, [planName, items]); 45 | 46 | function percentForGroup(groupId: string): number { 47 | const list = Object.values(items).filter((it) => it.group === groupId); 48 | if (!list.length) return 0; 49 | let sumEst = 0; 50 | let sumElapsed = 0; 51 | const now = Date.now(); 52 | for (const it of list) { 53 | const est = Math.max(0, it.estimate_ms || 0); 54 | if (est <= 0) continue; 55 | sumEst += est; 56 | let elapsed = 0; 57 | if (it.status === 'done') { 58 | elapsed = est; 59 | } else if (it.status === 'in_progress') { 60 | if (typeof it.eta_ms === 'number' && isFinite(it.eta_ms)) { 61 | elapsed = Math.max(0, Math.min(est, est - it.eta_ms)); 62 | } else if (typeof it.started_at === 'number' && isFinite(it.started_at)) { 63 | elapsed = Math.max(0, Math.min(est, now - it.started_at)); 64 | } 65 | } 66 | sumElapsed += elapsed; 67 | } 68 | if (sumEst <= 0) return 0; 69 | return Math.max(0, Math.min(1, sumElapsed / sumEst)); 70 | } 71 | 72 | return ( 73 |
74 | {groups.map((g) => { 75 | const pct = percentForGroup(g.id); 76 | const pctText = `${(pct * 100).toFixed(1)}%`; 77 | return ( 78 |
79 |
83 | {/* Top-left: Group ID */} 84 |
{g.id}
85 | {/* Title to the right of ID */} 86 |
{g.title}
87 | {/* Bottom-left: completion placeholder */} 88 |
{pctText}
89 | {/* Description below the title */} 90 |
{g.description}
91 |
92 |
93 | ); 94 | })} 95 |
96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /lib/store.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from 'zustand/vanilla'; 2 | import type { Agent, AppState, ProjectMetrics, WorkItem } from './types'; 3 | import { DEFAULT_SEED, RUNNING_DEFAULT } from './config'; 4 | 5 | type PartialWithId = Partial & { id: string }; 6 | 7 | export interface UIState extends AppState { 8 | lastTickId: number; 9 | // Currently selected plan name (for UI display) 10 | plan_name?: string; 11 | // UI: whether radar ping sound is enabled 12 | pingAudioEnabled: boolean; 13 | // Reducers 14 | applySnapshot: (snapshot: AppState) => void; 15 | applyTick: (diff: { 16 | tick_id: number; 17 | items?: PartialWithId[]; 18 | agents?: PartialWithId[]; 19 | metrics?: Partial; 20 | agents_remove?: string[]; 21 | }) => void; 22 | setPlanName: (name: string) => void; 23 | setPingAudioEnabled: (enabled: boolean) => void; 24 | togglePingAudio: () => void; 25 | } 26 | 27 | const emptyMetrics: ProjectMetrics = { 28 | active_agents: 0, 29 | total_tokens: 0, 30 | total_spend_usd: 0, 31 | live_tps: 0, 32 | live_spend_per_s: 0, 33 | completion_rate: 0, 34 | }; 35 | 36 | export function createAppStore(initial?: Partial) { 37 | return createStore()((set, get) => ({ 38 | items: initial?.items ?? {}, 39 | agents: initial?.agents ?? {}, 40 | metrics: initial?.metrics ?? emptyMetrics, 41 | seed: initial?.seed ?? DEFAULT_SEED, 42 | running: initial?.running ?? RUNNING_DEFAULT, 43 | lastTickId: 0, 44 | plan_name: initial && (initial as UIState).plan_name, 45 | pingAudioEnabled: false, 46 | 47 | applySnapshot(snapshot) { 48 | set({ 49 | items: snapshot.items ?? {}, 50 | agents: snapshot.agents ?? {}, 51 | metrics: snapshot.metrics ?? emptyMetrics, 52 | seed: snapshot.seed, 53 | running: snapshot.running, 54 | lastTickId: 0, // reset ordering on fresh snapshot 55 | }); 56 | }, 57 | 58 | applyTick(diff) { 59 | if (diff.tick_id <= get().lastTickId) return; // ignore out-of-order 60 | 61 | set((state) => { 62 | // Clone maps shallowly before mutating 63 | const items = { ...state.items } as Record; 64 | const agents = { ...state.agents } as Record; 65 | 66 | if (diff.items) { 67 | for (const patch of diff.items) { 68 | const id = patch.id; 69 | const prev = items[id] ?? ({ id } as WorkItem); 70 | items[id] = { ...prev, ...patch } as WorkItem; 71 | } 72 | } 73 | 74 | if (diff.agents) { 75 | for (const patch of diff.agents) { 76 | const id = patch.id; 77 | const prev = agents[id] ?? ({ id } as Agent); 78 | agents[id] = { ...prev, ...patch } as Agent; 79 | } 80 | } 81 | 82 | if (diff.agents_remove && diff.agents_remove.length) { 83 | for (const id of diff.agents_remove) { 84 | if (id in agents) delete agents[id]; 85 | } 86 | } 87 | 88 | const metrics = diff.metrics ? { ...state.metrics, ...diff.metrics } : state.metrics; 89 | 90 | return { items, agents, metrics, lastTickId: diff.tick_id }; 91 | }); 92 | }, 93 | 94 | setPlanName(name: string) { 95 | set({ plan_name: name }); 96 | }, 97 | 98 | setPingAudioEnabled(enabled: boolean) { 99 | set({ pingAudioEnabled: !!enabled }); 100 | }, 101 | 102 | togglePingAudio() { 103 | const cur = get().pingAudioEnabled; 104 | set({ pingAudioEnabled: !cur }); 105 | }, 106 | })); 107 | } 108 | 109 | // App-level singleton store (UI can import this). 110 | export const appStore = createAppStore(); 111 | -------------------------------------------------------------------------------- /components/TickIndicator.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useRef, useState } from 'react'; 4 | import { createSimBridge, type SimBridge } from '@/lib/simBridge'; 5 | import { attachBridgeToStore } from '@/lib/bridgeToStore'; 6 | import { appStore } from '@/lib/store'; 7 | import { debugLog } from '@/lib/debug'; 8 | import { isConnected, setExternalBridge } from '@/lib/simClient'; 9 | 10 | export default function TickIndicator() { 11 | const [tick, setTick] = useState(0); 12 | const [running, setRunning] = useState(false); 13 | const [wired, setWired] = useState(false); 14 | const bridgeRef = useRef(null); 15 | 16 | useEffect(() => { 17 | // If a connection already exists, just subscribe 18 | if (isConnected()) { 19 | const unsub = appStore.subscribe((state) => { 20 | setTick(state.lastTickId ?? 0); 21 | setRunning(!!state.running); 22 | debugLog('ui', 'state-update', { tick: state.lastTickId, running: state.running }); 23 | }); 24 | setWired(true); 25 | return () => unsub?.(); 26 | } 27 | 28 | // Spin up worker and wire bridge on mount (only in browser) 29 | if (typeof window === 'undefined' || typeof Worker === 'undefined') { 30 | return; // SSR or non-browser test environment 31 | } 32 | let worker: Worker | null = null; 33 | try { 34 | worker = new Worker(new URL('../workers/engine.ts', import.meta.url), { type: 'module' }); 35 | debugLog('ui', 'worker-created'); 36 | worker.addEventListener('error', (e: ErrorEvent) => { 37 | debugLog('ui', 'worker-error', { message: e?.message, filename: e?.filename, lineno: e?.lineno, colno: e?.colno }); 38 | }); 39 | worker.addEventListener('message', (e: MessageEvent) => { 40 | debugLog('ui', 'worker-native-message', e?.data); 41 | }); 42 | } catch (e) { 43 | // Worker not available (tests or older env); bail out 44 | debugLog('ui', 'worker-create-failed', e); 45 | return; 46 | } 47 | const bridge = createSimBridge(worker); 48 | debugLog('ui', 'bridge-created'); 49 | bridgeRef!.current = bridge; 50 | const link = attachBridgeToStore(bridge, appStore); 51 | setExternalBridge(bridge, worker, link); 52 | 53 | // subscribe to lastTickId 54 | const unsub = appStore.subscribe((state) => { 55 | setTick(state.lastTickId ?? 0); 56 | setRunning(!!state.running); 57 | debugLog('ui', 'state-update', { tick: state.lastTickId, running: state.running }); 58 | }); 59 | setWired(true); 60 | // Proactively request a snapshot (handshake) 61 | bridge.postIntent({ type: 'request_snapshot' }); 62 | 63 | return () => { 64 | unsub?.(); 65 | link.destroy(); 66 | bridge.destroy?.(); 67 | worker?.terminate(); 68 | }; 69 | }, []); 70 | 71 | return ( 72 |
73 |
74 | Engine 75 | {wired ? 'connected' : 'connecting...'} 76 | Tick: {tick} 77 | 90 |
91 |
92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /lib/bridgeToStore.ts: -------------------------------------------------------------------------------- 1 | import type { SimMsg } from './simBridge'; 2 | import type { UIState } from './store'; 3 | import type { WorkItem, Agent, ProjectMetrics } from './types'; 4 | import { STORE_FLUSH_INTERVAL_MS } from './config'; 5 | import { debugLog } from './debug'; 6 | 7 | export interface Subscribable { 8 | subscribe: (handler: (msg: M) => void) => () => void; 9 | } 10 | 11 | export interface AttachOptions { 12 | intervalMs?: number; // default 100ms 13 | } 14 | 15 | // Attaches a SimMsg stream to the Zustand store, coalescing diffs 16 | // to reduce render churn. Snapshot applies immediately; ticks merge 17 | // within the interval window and apply as a single reducer call. 18 | export function attachBridgeToStore( 19 | bridge: Subscribable, 20 | store: { getState: () => UIState }, 21 | opts: AttachOptions = {} 22 | ) { 23 | const interval = opts.intervalMs ?? STORE_FLUSH_INTERVAL_MS; 24 | let unsub: (() => void) | null = null; 25 | let timer: ReturnType | null = null; 26 | 27 | // Aggregation state 28 | let latestTick = 0; 29 | const itemMap = new Map & { id: string }>(); 30 | const agentMap = new Map & { id: string }>(); 31 | let metricsPatch: Partial | null = null; 32 | const agentsRemove = new Set(); 33 | 34 | const clearAgg = () => { 35 | latestTick = 0; 36 | itemMap.clear(); 37 | agentMap.clear(); 38 | metricsPatch = null; 39 | agentsRemove.clear(); 40 | }; 41 | 42 | const scheduleFlush = () => { 43 | if (timer != null) return; 44 | timer = setTimeout(() => { 45 | timer = null; 46 | if (latestTick === 0) return; // nothing to apply 47 | const items = itemMap.size ? Array.from(itemMap.values()) : undefined; 48 | const agents = agentMap.size ? Array.from(agentMap.values()) : undefined; 49 | const metrics = metricsPatch ?? undefined; 50 | const agents_remove = agentsRemove.size ? Array.from(agentsRemove) : undefined; 51 | debugLog('bridgeToStore', 'applyTick', { tick_id: latestTick, items: items?.length ?? 0, agents: agents?.length ?? 0, metrics: !!metrics, agents_remove: agents_remove?.length ?? 0 }); 52 | store.getState().applyTick({ tick_id: latestTick, items, agents, metrics, agents_remove }); 53 | clearAgg(); 54 | }, Math.max(0, interval)); 55 | }; 56 | 57 | const onMsg = (msg: SimMsg) => { 58 | if (msg.type === 'snapshot') { 59 | // Apply immediately; reset any pending agg 60 | clearAgg(); 61 | debugLog('bridgeToStore', 'applySnapshot'); 62 | store.getState().applySnapshot(msg.state); 63 | return; 64 | } 65 | if (msg.type === 'tick') { 66 | if (msg.tick_id <= latestTick) { 67 | // already aggregated something newer in this window 68 | return; 69 | } 70 | latestTick = msg.tick_id; 71 | if (msg.items) { 72 | for (const patch of msg.items) { 73 | if (patch.id) itemMap.set(patch.id, { ...(itemMap.get(patch.id) ?? {}), ...patch } as Partial & { id: string }); 74 | } 75 | } 76 | if (msg.agents) { 77 | for (const patch of msg.agents) { 78 | if (patch.id) agentMap.set(patch.id, { ...(agentMap.get(patch.id) ?? {}), ...patch } as Partial & { id: string }); 79 | } 80 | } 81 | if (msg.metrics) metricsPatch = { ...(metricsPatch ?? {}), ...msg.metrics }; 82 | if ('agents_remove' in msg && msg.agents_remove) for (const id of msg.agents_remove) agentsRemove.add(id); 83 | scheduleFlush(); 84 | return; 85 | } 86 | // Other event types can be handled later if needed; ignored for throttling 87 | }; 88 | 89 | unsub = bridge.subscribe(onMsg); 90 | 91 | return { 92 | destroy() { 93 | if (unsub) unsub(); 94 | unsub = null; 95 | if (timer) clearTimeout(timer); 96 | timer = null; 97 | clearAgg(); 98 | }, 99 | }; 100 | } 101 | -------------------------------------------------------------------------------- /lib/simBridge.ts: -------------------------------------------------------------------------------- 1 | // simBridge: UI-side transport boundary for simulation messages 2 | // Hides transport (Worker now, WebSocket later) behind a small API. 3 | 4 | import type { Agent, AppState, ProjectMetrics, WorkItem } from './types'; 5 | import { BRIDGE_BATCH_MS } from './config'; 6 | import { debugLog } from './debug'; 7 | 8 | export type SimMsg = 9 | | { type: 'snapshot'; state: AppState } 10 | | { type: 'deps_cleared'; id: string } 11 | | { type: 'start_item'; id: string; agent: Agent } 12 | | { 13 | type: 'tick'; 14 | tick_id: number; 15 | items?: Partial[]; 16 | agents?: Partial[]; 17 | metrics?: Partial; 18 | agents_remove?: string[]; 19 | } 20 | | { type: 'complete_item'; id: string }; 21 | 22 | export type SimIntent = 23 | | { type: 'set_running'; running: boolean } 24 | | { type: 'set_plan'; plan: string } 25 | | { type: 'set_seed'; seed: string } 26 | | { type: 'set_speed'; speed: number } 27 | | { type: 'request_snapshot' }; 28 | 29 | // Minimal message event/port interfaces so we don't depend on DOM types in tests 30 | export interface MessageEventLike { data: T } 31 | export interface PortLike { 32 | postMessage: (msg: unknown) => void; 33 | addEventListener: (event: 'message', handler: (e: MessageEventLike) => void) => void; 34 | removeEventListener: (event: 'message', handler: (e: MessageEventLike) => void) => void; 35 | } 36 | 37 | export interface SimBridge { 38 | subscribe: (handler: (msg: SimMsg) => void) => () => void; 39 | postIntent: (intent: SimIntent) => void; 40 | getLastTickId: () => number; 41 | destroy: () => void; 42 | } 43 | 44 | export interface BridgeOptions { 45 | batchMs?: number; // coalesce outbound notifications to subscribers 46 | } 47 | 48 | export function createSimBridge(port: PortLike, opts: BridgeOptions = {}): SimBridge { 49 | const subscribers = new Set<(msg: SimMsg) => void>(); 50 | let lastTickId = 0; 51 | const batchMs = opts.batchMs ?? BRIDGE_BATCH_MS; 52 | const queue: SimMsg[] = []; 53 | let flushTimer: ReturnType | null = null; 54 | 55 | const emit = (msg: SimMsg) => { 56 | for (const fn of subscribers) fn(msg); 57 | }; 58 | 59 | const scheduleFlush = () => { 60 | if (flushTimer != null) return; 61 | if (batchMs <= 0) { 62 | flushNow(); 63 | return; 64 | } 65 | flushTimer = setTimeout(flushNow, batchMs); 66 | }; 67 | 68 | const flushNow = () => { 69 | if (flushTimer) clearTimeout(flushTimer); 70 | flushTimer = null; 71 | debugLog('bridge', 'flush', { queued: queue.length }); 72 | while (queue.length) { 73 | const msg = queue.shift()!; 74 | if (msg.type === 'tick') { 75 | if (msg.tick_id <= lastTickId) { 76 | debugLog('bridge', 'drop-late', { tick_id: msg.tick_id, lastTickId }); 77 | continue; // drop out-of-order or duplicate 78 | } 79 | lastTickId = msg.tick_id; 80 | } 81 | if (msg.type === 'snapshot') { 82 | // reset ordering on fresh snapshot 83 | lastTickId = 0; 84 | } 85 | debugLog('bridge', 'emit', { type: msg.type, lastTickId }); 86 | emit(msg); 87 | } 88 | }; 89 | 90 | const onMessage = (e: MessageEventLike) => { 91 | const raw = e?.data; 92 | if (!raw || typeof raw !== 'object' || !('type' in raw)) return; 93 | const msg = raw as SimMsg; 94 | if (msg.type === 'tick') { 95 | // Early drop to keep queue lean 96 | if (msg.tick_id <= lastTickId) { 97 | debugLog('bridge', 'drop-early', { tick_id: msg.tick_id, lastTickId }); 98 | return; 99 | } 100 | } else if (msg.type === 'snapshot') { 101 | // Snapshot should be delivered promptly; reset tick ordering 102 | lastTickId = 0; 103 | } 104 | debugLog('bridge', 'queue', { type: msg.type, tick_id: msg.type === 'tick' ? msg.tick_id : undefined, size: queue.length + 1 }); 105 | queue.push(msg); 106 | scheduleFlush(); 107 | }; 108 | 109 | port.addEventListener('message', onMessage); 110 | debugLog('bridge', 'created', { batchMs }); 111 | 112 | return { 113 | subscribe(handler) { 114 | subscribers.add(handler); 115 | return () => subscribers.delete(handler); 116 | }, 117 | postIntent(intent) { 118 | port.postMessage(intent); 119 | }, 120 | getLastTickId() { 121 | return lastTickId; 122 | }, 123 | destroy() { 124 | port.removeEventListener('message', onMessage); 125 | subscribers.clear(); 126 | queue.length = 0; 127 | if (flushTimer) clearTimeout(flushTimer); 128 | flushTimer = null; 129 | }, 130 | }; 131 | } 132 | 133 | // Keep stub flag for existing smoke test 134 | export const SIMBRIDGE_MODULE_LOADED = true; 135 | -------------------------------------------------------------------------------- /components/GlobalQueue.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useMemo, useSyncExternalStore } from 'react'; 4 | import { appStore } from '@/lib/store'; 5 | import type { UIState } from '@/lib/store'; 6 | import type { WorkItem } from '@/lib/types'; 7 | import { SECTORS } from '@/lib/constants'; 8 | 9 | function useAppSelector(selector: (s: UIState) => T): T { 10 | return useSyncExternalStore( 11 | appStore.subscribe, 12 | () => selector(appStore.getState()), 13 | () => selector(appStore.getState()) 14 | ); 15 | } 16 | 17 | type Bucket = { 18 | active: WorkItem[]; 19 | upNext: WorkItem[]; 20 | queue: WorkItem[]; 21 | }; 22 | 23 | export default function GlobalQueue() { 24 | const items = useAppSelector((s) => s.items); 25 | 26 | const bySector = useMemo(() => { 27 | const buckets = new Map(); 28 | for (const sec of SECTORS) buckets.set(sec, { active: [], upNext: [], queue: [] }); 29 | for (const it of Object.values(items)) { 30 | if (it.status === 'done') continue; // drop completed 31 | const sec = (it.sector || '').toUpperCase(); 32 | if (!buckets.has(sec)) buckets.set(sec, { active: [], upNext: [], queue: [] }); 33 | const b = buckets.get(sec)!; 34 | if (it.status === 'in_progress') b.active.push(it); 35 | else if (it.status === 'assigned') b.upNext.push(it); 36 | else b.queue.push(it); // queued/blocked/default 37 | } 38 | // stable sort by id for readability 39 | for (const b of buckets.values()) { 40 | b.active.sort((a, z) => a.id.localeCompare(z.id)); 41 | b.upNext.sort((a, z) => a.id.localeCompare(z.id)); 42 | b.queue.sort((a, z) => a.id.localeCompare(z.id)); 43 | } 44 | return buckets; 45 | }, [items]); 46 | 47 | return ( 48 |
49 | {/* 4 equal-height sector panes; scroll inside each */} 50 |
51 | {SECTORS.map((sec, idx) => { 52 | const b = bySector.get(sec) || { active: [], upNext: [], queue: [] }; 53 | const nextList = b.upNext.length > 0 ? b.upNext : (b.queue.length > 0 ? [b.queue[0]] : []); 54 | const remaining = b.upNext.length > 0 ? b.queue : b.queue.slice(1); 55 | return ( 56 |
57 | {/* Sector header styled like WorkTable headers */} 58 |
59 | {sec} 60 |
61 |
62 | {/* Active */} 63 | {b.active.length > 0 && ( 64 |
65 | {b.active.map((it) => ( 66 | 67 | ))} 68 |
69 | )} 70 | 71 | {/* Up next: prefer assigned; else first queued item */} 72 | {nextList.length > 0 && ( 73 |
74 | {nextList.map((it) => ( 75 | 76 | ))} 77 |
78 | )} 79 | 80 | {/* Remaining queue (skip the first if it was promoted) */} 81 | {remaining.length > 0 ? ( 82 |
83 | {remaining.map((it) => ( 84 | 85 | ))} 86 |
87 | ) : ( 88 | // Only show "queue empty" when there is truly no next item 89 | nextList.length === 0 ? ( 90 |
Queue empty
91 | ) : null 92 | )} 93 |
94 |
95 | ); 96 | })} 97 |
98 |
99 | ); 100 | } 101 | 102 | function Row({ it, tone, label }: { it: WorkItem; tone: 'active' | 'upnext' | 'queue'; label: string }) { 103 | const cls = tone === 'active' 104 | ? 'bg-[#0a1c0dff] text-[#2e904dff] border-r-2' 105 | : tone === 'upnext' 106 | ? 'bg-sky-900 text-sky-300 border-r-2' 107 | : 'bg-black text-zinc-300'; 108 | return ( 109 |
110 | {/* Status line (blank for queued) */} 111 |
{label || ' '}
112 | {/* ID line */} 113 |
{it.id}
114 |
115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /lib/engine.ts: -------------------------------------------------------------------------------- 1 | import type { WorkItem } from './types'; 2 | import type { PlanDefinition } from '@/plans/types'; 3 | import type { Agent, ProjectMetrics } from './types'; 4 | import { COST_PER_TOKEN_USD } from './constants'; 5 | 6 | export function buildItemsFromPlan(plan: PlanDefinition): Record { 7 | const items: Record = {}; 8 | for (const p of plan.items) { 9 | const est_tokens = Math.round(((p.tps_min + p.tps_max) / 2) * (p.estimate_ms / 1000)); 10 | items[p.id] = { 11 | id: p.id, 12 | group: p.group, 13 | sector: p.sector, 14 | depends_on: [...p.depends_on], 15 | desc: p.work_desc, 16 | estimate_ms: p.estimate_ms, 17 | started_at: undefined, 18 | eta_ms: p.estimate_ms, 19 | tps_min: p.tps_min, 20 | tps_max: p.tps_max, 21 | tps: p.tps_min, 22 | tokens_done: 0, 23 | est_tokens, 24 | status: 'queued', 25 | agent_id: undefined, 26 | }; 27 | } 28 | return items; 29 | } 30 | 31 | // Detect cycles using DFS. Returns list of cycles (each as id array) or empty if none. 32 | export function detectCycles(items: Record): string[][] { 33 | const graph = new Map( 34 | Object.values(items).map((i) => [i.id, i.depends_on]) 35 | ); 36 | const cycles: string[][] = []; 37 | const visited = new Set(); 38 | const stack: string[] = []; 39 | const onstack = new Set(); 40 | 41 | function dfs(u: string) { 42 | visited.add(u); 43 | onstack.add(u); 44 | stack.push(u); 45 | for (const v of graph.get(u) || []) { 46 | if (!visited.has(v)) dfs(v); 47 | else if (onstack.has(v)) { 48 | // found a cycle: slice from v to end 49 | const idx = stack.indexOf(v); 50 | if (idx >= 0) cycles.push(stack.slice(idx)); 51 | } 52 | } 53 | stack.pop(); 54 | onstack.delete(u); 55 | } 56 | 57 | for (const id of graph.keys()) if (!visited.has(id)) dfs(id); 58 | // Deduplicate identical cycles (simple heuristic) 59 | const unique: string[][] = []; 60 | const seen = new Set(); 61 | for (const c of cycles) { 62 | const key = c.slice().sort().join('|'); 63 | if (!seen.has(key)) { 64 | seen.add(key); 65 | unique.push(c); 66 | } 67 | } 68 | return unique; 69 | } 70 | 71 | // Returns true if all dependencies of item id are in status 'done' 72 | export function depsSatisfied(items: Record, id: string): boolean { 73 | const it = items[id]; 74 | if (!it) return false; 75 | return it.depends_on.every((d) => items[d] && items[d].status === 'done'); 76 | } 77 | 78 | // Promote items from queued -> assigned when dependencies are satisfied. 79 | export function promoteQueuedToAssigned(items: Record): string[] { 80 | const changed: string[] = []; 81 | for (const it of Object.values(items)) { 82 | if (it.status === 'queued' && depsSatisfied(items, it.id)) { 83 | it.status = 'assigned'; 84 | changed.push(it.id); 85 | } 86 | } 87 | return changed; 88 | } 89 | 90 | export function countInProgress(items: Record): number { 91 | let n = 0; 92 | for (const it of Object.values(items)) if (it.status === 'in_progress') n++; 93 | return n; 94 | } 95 | 96 | export function computeMetrics(items: Record, agents: Record): ProjectMetrics { 97 | let total_tokens = 0; 98 | let live_tps = 0; 99 | let total_estimate_ms = 0; 100 | let progressed_ms = 0; 101 | const now = Date.now(); 102 | 103 | for (const it of Object.values(items)) { 104 | total_tokens += it.tokens_done || 0; 105 | if (it.status === 'in_progress') live_tps += it.tps || 0; 106 | 107 | const est = Math.max(0, it.estimate_ms || 0); 108 | total_estimate_ms += est; 109 | if (est <= 0) continue; 110 | 111 | if (it.status === 'done') { 112 | progressed_ms += est; 113 | } else if (it.status === 'in_progress') { 114 | // Prefer ETA-based progress when available 115 | if (typeof it.eta_ms === 'number' && isFinite(it.eta_ms)) { 116 | const elapsed = Math.max(0, Math.min(est, est - it.eta_ms)); 117 | progressed_ms += elapsed; 118 | } else if (typeof it.started_at === 'number' && isFinite(it.started_at)) { 119 | const elapsed = Math.max(0, Math.min(est, now - it.started_at)); 120 | progressed_ms += elapsed; 121 | } else if (typeof it.est_tokens === 'number' && it.est_tokens > 0) { 122 | const td = Math.max(0, it.tokens_done || 0); 123 | const frac = Math.max(0, Math.min(1, td / it.est_tokens)); 124 | progressed_ms += frac * est; 125 | } 126 | } 127 | } 128 | 129 | const total_spend_usd = total_tokens * COST_PER_TOKEN_USD; 130 | const live_spend_per_s = live_tps * COST_PER_TOKEN_USD; 131 | const completion_rate = total_estimate_ms > 0 ? progressed_ms / total_estimate_ms : 0; 132 | 133 | return { 134 | active_agents: Object.keys(agents).length, 135 | total_tokens, 136 | total_spend_usd, 137 | live_tps, 138 | live_spend_per_s, 139 | completion_rate, 140 | }; 141 | } 142 | -------------------------------------------------------------------------------- /components/ControlBar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useEffect, useState, useSyncExternalStore } from 'react'; 4 | import { ensureConnected, postIntent } from '@/lib/simClient'; 5 | import AudioPlayer from '@/components/AudioPlayer'; 6 | import { tracks, radio } from '@/lib/audio/tracks'; 7 | import { PLAN_NAMES, DEFAULT_PLAN_NAME } from '@/plans'; 8 | import { appStore } from '@/lib/store'; 9 | 10 | const LS_PREFIX = 'ccr.'; 11 | const LS = { 12 | plan: LS_PREFIX + 'plan', 13 | speed: LS_PREFIX + 'speed', 14 | }; 15 | 16 | export default function ControlBar() { 17 | const [plan, setPlan] = useState(DEFAULT_PLAN_NAME); 18 | const pingEnabled = useSyncExternalStore( 19 | appStore.subscribe, 20 | () => appStore.getState().pingAudioEnabled, 21 | () => appStore.getState().pingAudioEnabled, 22 | ); 23 | // Speed controls temporarily removed for stability 24 | // No longer exposing running/pause in UI 25 | 26 | useEffect(() => { 27 | ensureConnected(); 28 | // On first mount, set seed, apply plan, then start running 29 | try { 30 | const stored = localStorage.getItem(LS.plan) || DEFAULT_PLAN_NAME; 31 | setPlan(stored); 32 | // Reflect selected plan in global UI store for ProjectId/Description 33 | try { appStore.getState().setPlanName(stored); } catch {} 34 | const url = new URL(window.location.href); 35 | const urlSeed = url.searchParams.get('seed'); 36 | const randomSeed = `r${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; 37 | postIntent({ type: 'set_seed', seed: urlSeed || randomSeed }); 38 | // Apply plan before starting engine to avoid pause from later set_plan 39 | postIntent({ type: 'set_plan', plan: stored }); 40 | // Start the engine automatically 41 | postIntent({ type: 'set_running', running: true }); 42 | // Snapshot to sync UI quickly 43 | postIntent({ type: 'request_snapshot' }); 44 | } catch {} 45 | }, []); 46 | 47 | // Persist plan whenever it changes (engine is only updated via Execute or initial mount) 48 | useEffect(() => { 49 | // Persist the user's selection, but do NOT update the displayed 50 | // project id/description until Execute is clicked. 51 | try { localStorage.setItem(LS.plan, plan); } catch {} 52 | }, [plan]); 53 | // Speed persistence removed 54 | // running state persistence removed 55 | 56 | return ( 57 |
58 | 59 | 68 | 79 | 80 | {/* Speed controls removed for now */} 81 | 82 | {/* Right-aligned players: Radio + Music + Ping toggle */} 83 |
84 | {/* Radar ping sound toggle with label above (SFX) */} 85 |
86 |
SFX
87 | 112 |
113 | 114 | 115 |
116 |
117 | ); 118 | } 119 | -------------------------------------------------------------------------------- /components/WorkTable.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useMemo, useSyncExternalStore } from 'react'; 4 | import type { UIState } from '@/lib/store'; 5 | import { appStore } from '@/lib/store'; 6 | import type { WorkItem } from '@/lib/types'; 7 | import { formatTokensAbbrev } from '@/lib/format'; 8 | 9 | function useAppSelector(selector: (s: UIState) => T): T { 10 | return useSyncExternalStore( 11 | appStore.subscribe, 12 | () => selector(appStore.getState()), 13 | () => selector(appStore.getState()) 14 | ); 15 | } 16 | 17 | const statusOrder: Record = { 18 | in_progress: 0, 19 | queued: 1, 20 | assigned: 1, 21 | blocked: 1, 22 | done: 2, 23 | }; 24 | 25 | function fmtETA(ms?: number) { 26 | if (typeof ms !== 'number' || !isFinite(ms) || ms < 0) return '—'; 27 | const total = Math.round(ms / 1000); 28 | const h = Math.floor(total / 3600); 29 | const m = Math.floor((total % 3600) / 60); 30 | const s = total % 60; 31 | const mm = m.toString().padStart(2, '0'); 32 | const ss = s.toString().padStart(2, '0'); 33 | return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`; 34 | } 35 | 36 | type ColumnKey = 'id' | 'agent' | 'sector' | 'work' | 'tokens' | 'tps' | 'eta'; 37 | 38 | export default function WorkTable({ 39 | compact = false, 40 | mini = false, 41 | maxHeight, 42 | columns, 43 | }: { 44 | compact?: boolean; 45 | mini?: boolean; 46 | maxHeight?: number; 47 | columns?: ColumnKey[]; 48 | }) { 49 | const items = useAppSelector((s) => s.items); 50 | 51 | const rows = useMemo(() => { 52 | const list = Object.values(items); 53 | list.sort((a, b) => { 54 | const ao = statusOrder[a.status] ?? 99; 55 | const bo = statusOrder[b.status] ?? 99; 56 | if (ao !== bo) return ao - bo; 57 | return a.id.localeCompare(b.id); 58 | }); 59 | return list; 60 | }, [items]); 61 | 62 | const widthMap: Record = compact 63 | ? { id: 22, agent: 26, sector: 38, work: 150, tokens: 34, tps: 34, eta: 42 } 64 | : { id: 30, agent: 35, sector: 65, work: 225, tokens: 40, tps: 45, eta: 50 }; 65 | const labelMap: Record = { 66 | id: 'ID', 67 | agent: 'AGENT', 68 | sector: 'SECTOR', 69 | work: 'WORK ORDER', 70 | tokens: 'TOKENS', 71 | tps: 'TPS', 72 | eta: 'ETA', 73 | }; 74 | const renderCell: Record React.ReactNode> = { 75 | id: (it, cls) => ( 76 | {it.id} 77 | ), 78 | agent: (it, cls) => ( 79 | {it.agent_id ?? '—'} 80 | ), 81 | sector: (it, cls) => ( 82 | {it.sector} 83 | ), 84 | work: (it, cls) => ( 85 | 86 | {it.desc || '—'} 87 | 88 | ), 89 | tokens: (it, cls) => ( 90 | {formatTokensAbbrev(it.tokens_done)} 91 | ), 92 | tps: (it, cls) => { 93 | const text = it.status === 'in_progress' 94 | ? formatTokensAbbrev(it.tps, { tpsMode: true, extraDecimalUnder100: true }) 95 | : '0'; 96 | return ( 97 | {text} 98 | ); 99 | }, 100 | eta: (it, cls) => ( 101 | {fmtETA(it.eta_ms)} 102 | ), 103 | }; 104 | 105 | const activeColumns: ColumnKey[] = columns && columns.length 106 | ? columns 107 | : ['id', 'agent', 'sector', 'work', 'tokens', 'tps', 'eta']; 108 | 109 | const workMaxWidth = compact ? 200 : 360; 110 | const tableTextCls = compact ? (mini ? 'text-[11px]' : 'text-xs') : 'text-sm'; 111 | const thPad = compact ? (mini ? 'px-1 py-0.5' : 'px-1 py-1') : 'px-1 py-2'; 112 | const tdPad = compact ? (mini ? 'px-1 py-0.5' : 'px-1 py-0.5') : 'px-2 py-1'; 113 | 114 | return ( 115 |
116 |
117 | 126 | 127 | {activeColumns.map((key) => ( 128 | 129 | ))} 130 | 131 | 132 | 133 | {activeColumns.map((key) => ( 134 | 135 | ))} 136 | 137 | 138 | 139 | {rows.map((it) => { 140 | const rowCls = 141 | it.status === 'done' ? 'tr-status-done' : 142 | it.status === 'in_progress' ? 'tr-status-inprogress' : 143 | 'tr-status-queued'; 144 | return ( 145 | 146 | {activeColumns.map((key) => ( 147 | 148 | {renderCell[key](it, tdPad)} 149 | 150 | ))} 151 | 152 | ); 153 | })} 154 | {rows.length === 0 && ( 155 | 156 | 157 | 158 | )} 159 | 160 |
{labelMap[key]}
No items loaded yet.
161 |
162 |
163 | ); 164 | } 165 | -------------------------------------------------------------------------------- /components/TimelineStatusBar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useEffect, useMemo, useState, useSyncExternalStore } from 'react'; 4 | import { appStore } from '@/lib/store'; 5 | import type { UIState } from '@/lib/store'; 6 | import type { WorkItem } from '@/lib/types'; 7 | 8 | function useAppSelector(selector: (s: UIState) => T): T { 9 | return useSyncExternalStore( 10 | appStore.subscribe, 11 | () => selector(appStore.getState()), 12 | () => selector(appStore.getState()) 13 | ); 14 | } 15 | 16 | function fmtHMS(ms: number) { 17 | if (!Number.isFinite(ms) || ms < 0) return '0:00'; 18 | const totalSec = Math.floor(ms / 1000); 19 | const h = Math.floor(totalSec / 3600); 20 | const m = Math.floor((totalSec % 3600) / 60); 21 | const s = totalSec % 60; 22 | const mm = m.toString(); 23 | const ss = s.toString().padStart(2, '0'); 24 | if (h > 0) return `${h}:${mm.padStart(2, '0')}:${ss}`; 25 | return `${mm}:${ss}`; 26 | } 27 | 28 | export default function TimelineStatusBar() { 29 | const items = useAppSelector((s) => s.items); 30 | 31 | // Compute totals and progress across items, plus earliest start and completion 32 | const { totalEstimateMs, progressMs, hasAnyStarted, earliestStart, projectComplete } = useMemo(() => { 33 | let total = 0; 34 | let progressed = 0; 35 | let anyStarted = false; 36 | let earliest: number | undefined = undefined; 37 | let allDone = true; 38 | const now = Date.now(); 39 | const arr = Object.values(items) as WorkItem[]; 40 | if (arr.length === 0) allDone = false; // empty project is not complete 41 | for (const it of arr) { 42 | const est = Math.max(0, it.estimate_ms || 0); 43 | total += est; 44 | if (typeof it.started_at === 'number' && isFinite(it.started_at)) { 45 | if (earliest === undefined || it.started_at < earliest) earliest = it.started_at; 46 | } 47 | if (it.status !== 'queued' || (typeof it.started_at === 'number' && isFinite(it.started_at))) anyStarted = true; 48 | if (it.status !== 'done') allDone = false; 49 | if (est <= 0) continue; 50 | if (it.status === 'done') { 51 | progressed += est; 52 | } else if (it.status === 'in_progress') { 53 | if (typeof it.eta_ms === 'number' && isFinite(it.eta_ms)) { 54 | const elapsed = Math.max(0, Math.min(est, est - it.eta_ms)); 55 | progressed += elapsed; 56 | } else if (typeof it.started_at === 'number' && isFinite(it.started_at)) { 57 | const elapsed = Math.max(0, Math.min(est, now - it.started_at)); 58 | progressed += elapsed; 59 | } 60 | } 61 | } 62 | return { totalEstimateMs: total, progressMs: progressed, hasAnyStarted: anyStarted, earliestStart: earliest, projectComplete: allDone }; 63 | }, [items]); 64 | 65 | const [now, setNow] = useState(() => Date.now()); 66 | const [stoppedAt, setStoppedAt] = useState(undefined); 67 | 68 | // Determine if any items are currently in progress (agents active) 69 | // const anyInProgress = useMemo(() => { 70 | // for (const it of Object.values(items) as WorkItem[]) { 71 | // if (it.status === 'in_progress') return true; 72 | // } 73 | // return false; 74 | // }, [items]); 75 | 76 | const hasStarted = hasAnyStarted; 77 | const isComplete = !!projectComplete; 78 | 79 | // Tick while started and not complete; freeze when complete 80 | useEffect(() => { 81 | let id: number | undefined; 82 | if (hasStarted && !isComplete) { 83 | setStoppedAt(undefined); 84 | // Faster tick for smoother progress updates 85 | id = window.setInterval(() => setNow(Date.now()), 100); 86 | } else if (isComplete) { 87 | // capture the moment we detected completion (freeze timer) 88 | setStoppedAt((prev) => prev ?? Date.now()); 89 | } 90 | return () => { 91 | if (id) clearInterval(id); 92 | }; 93 | }, [hasStarted, isComplete]); 94 | 95 | const denom = totalEstimateMs > 0 ? totalEstimateMs : 1; 96 | // Progress bar uses aggregate item progress (responds to engine speed) 97 | const progressed = hasStarted ? Math.max(0, Math.min(denom, progressMs)) : 0; 98 | const baseProgress = Math.max(0, Math.min(1, progressed / denom)); 99 | const progress = isComplete ? 1 : baseProgress; 100 | // Timer uses wall-clock since first start, freezes on completion 101 | const effectiveNow = !isComplete ? now : (stoppedAt ?? now); 102 | const timerMs = hasStarted && typeof earliestStart === 'number' ? Math.max(0, effectiveNow - earliestStart) : 0; 103 | const statusLabel = !hasStarted ? 'INITIALIZING' : isComplete ? 'COMPLETE' : 'RUNNING'; 104 | 105 | return ( 106 |
107 |
108 |
109 | 110 | 113 | {statusLabel} 114 | 115 | {hasStarted && ( 116 | {fmtHMS(timerMs)} 117 | )} 118 |
119 |
120 | 121 |
122 |
123 |
124 | ); 125 | } 126 | 127 | function ProgressBar({ pct }: { pct: number }) { 128 | const pc = Math.max(0, Math.min(1, pct)); 129 | const percent = pc * 100; 130 | return ( 131 |
132 | {/* filled portion */} 133 |
137 | {/* notch at current progress */} 138 |
142 |
143 | ); 144 | } 145 | 146 | function LiveBadge() { 147 | return ( 148 |
149 | 150 | 151 | 152 | 153 | 154 | LIVE 155 |
156 | ); 157 | } 158 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import WorkTable from '../components/WorkTable'; 2 | import ControlBar from '../components/ControlBar'; 3 | import RadarCanvas from '../components/RadarCanvas'; 4 | import GlobalQueue from '../components/GlobalQueue'; 5 | import ProjectIdDisplay from '../components/ProjectIdDisplay'; 6 | import ProjectDescription from '../components/ProjectDescription'; 7 | import OperatorGroups from '../components/OperatorGroups'; 8 | import TopOverview from '../components/TopOverview'; 9 | import TimelineStatusBar from '../components/TimelineStatusBar'; 10 | 11 | export default function Home() { 12 | return ( 13 |
14 | {/* Header */} 15 |
16 |

AGENT TRAFFIC CONTROL

17 |

18 | 24 | OPEN SOURCE 25 | 26 | 27 | 33 | BY GREG 34 | 35 |

36 |
37 | 38 | {/* Controls row */} 39 |
40 | 41 | {/* Main layout */} 42 |
43 | {/* Desktop layout (unchanged) */} 44 |
45 | {/* LEFT COLUMN: 3 rows -> [Monitoring (auto), Operator Actions (auto), Work/Agent table (1fr = remaining space)] */} 46 |
47 | {/* Monitoring table (top) with three internal rows */} 48 |
49 | 50 |
51 |

MONITORING TABLE

52 |
53 | 54 |
55 | {/* Column 1: 2 parts wide */} 56 |
57 |
Project ID
58 |
59 |
60 | {/* Column 2: 1 part wide */} 61 |
62 |
Project Description
63 |
64 |
65 |
66 |
67 | 68 | {/* Operator Action Items (middle) */} 69 |
70 |
71 |

OPERATOR ACTION ITEMS

72 |
73 |
74 | 75 |
76 |
77 | 78 | {/* Work/Agent table (bottom, 75% height) */} 79 |
80 | 81 |
82 |
83 | 84 | {/* RIGHT COLUMN: rows -> [Top (1fr), Radar area (7fr), Master Control Panel (auto)] */} 85 | 125 |
126 | 127 | {/* Mobile layout */} 128 |
129 |
130 | {/* Top line metrics (compact, hide completion chart) */} 131 |
132 | 133 |
134 | 135 | {/* Radar (no global queue) */} 136 |
137 |
138 |

RADAR

139 |
140 |
141 | 142 |
143 |
144 | 145 | {/* Work items table (compact + mini text, capped height with internal scroll) */} 146 |
147 |
148 |

WORK ITEMS

149 |
150 |
151 | 152 |
153 |
154 | 155 | {/* Project details (ID + Description) */} 156 |
157 |
158 |

PROJECT

159 |
160 |
161 |
162 |
Project ID
163 |
164 |
165 |
166 |
Project Description
167 |
168 |
169 |
170 |
171 | 172 | {/* Master Control Panel */} 173 |
174 |
175 |

MASTER CONTROL PANEL

176 |
177 |
178 |
179 | 180 |
181 | 182 |
183 |
184 |
185 |
186 |
187 |
188 | ); 189 | } 190 | -------------------------------------------------------------------------------- /.planning/todo.md: -------------------------------------------------------------------------------- 1 | # Calming Control Room — TODO (Table‑First MVP) 2 | 3 | This checklist aligns with the PRD and prioritizes the table-first “red thread” to validate end‑to‑end data flow before investing in radar visuals. Each item includes a concrete unit test you can implement with Vitest (and React Testing Library for components). 4 | 5 | ## A) Scaffold & Tooling 6 | 7 | - 1. Task: Initialize Next.js (TS), Tailwind, Zustand, Vitest + RTL, and Worker bundling. 8 | - Status: ✅ DONE 9 | - Output: Project boots with a single page; test runner executes. 10 | - Unit test: `renders-root`: render the root page and assert a sentinel text is present. 11 | 12 | - 2. Task: Add base folders per PRD (`app/`, `components/`, `lib/`, `workers/`). 13 | - Status: ✅ DONE 14 | - Output: Empty stubs for `lib/types.ts`, `lib/constants.ts`, `lib/rng.ts`, `lib/simBridge.ts`, `workers/engine.worker.ts`. 15 | - Unit test: `imports-don't-throw`: import each stub and assert module loads. 16 | 17 | ## B) Types, Constants, RNG 18 | 19 | - 1. Task: Define `WorkItem`, `Agent`, `ProjectMetrics`, `AppState`, `Status` in `lib/types.ts`. 20 | - Status: ✅ DONE 21 | - Output: Shared types across worker/UI. 22 | - Unit test: `type-helpers`: add a tiny pure helper (e.g., `deriveEstTokens(estimateMs, tps)`) and test numeric output for a known input. 23 | 24 | - 2. Task: Add `lib/constants.ts` (costs, caps, speeds, sector colors). 25 | - Status: ✅ DONE 26 | - Output: Central tuning knobs per PRD. 27 | - Unit test: `constants-sanity`: assert `V_MIN < V_MAX`, `MAX_CONCURRENT > 0`, and cost is finite. 28 | 29 | - 3. Task: Implement `lib/rng.ts` (mulberry32 or xorshift) and seed plumbing. 30 | - Status: ✅ DONE 31 | - Output: Deterministic PRNG instance factory. 32 | - Unit test: `rng-determinism`: same seed yields same first N numbers; different seeds diverge. 33 | 34 | ## C) simBridge (Transport Boundary) 35 | 36 | - 1. Task: Implement `lib/simBridge.ts` to abstract Worker transport; expose `connect`, `postIntent`, and an `onMessage` subscription. 37 | - Status: ✅ DONE 38 | - Output: UI-side bridge that can be swapped for WebSocket later; message schema supports `snapshot`, `tick{tick_id}`, and intents. 39 | - Unit test: `bridge-drops-out-of-order`: feed messages with tick_ids [1,3,2,4]; assert subscriber receives 1,3,4 only. 40 | 41 | ## D) Store & Reducers (Projection) 42 | 43 | - 1. Task: Create Zustand store with `{items, agents, metrics, running, seed, lastTickId}` and reducers to apply `snapshot` and `tick` diffs. 44 | - Status: ✅ DONE 45 | - Output: Single source of UI truth; UI writes intents only. 46 | - Unit test: `reducers-apply-snapshot-and-diff`: apply a snapshot then a tick diff updating one item and metrics; assert state matches and `lastTickId` advances. 47 | 48 | - 2. Task: Implement throttled patch application (e.g., coalesce diffs to 100–200ms cadence). 49 | - Status: ✅ DONE 50 | - Output: Stable React render rate. 51 | - Unit test: `throttle-coalesces-updates`: push N rapid diffs; assert reducer applied ≤ ceil(duration/window) times. 52 | 53 | ## E) Worker Engine — Handshake & Red Thread 54 | 55 | - 1. Task: Implement `workers/engine.worker.ts` skeleton: `init(plan, seed) → post snapshot`, loop with `tick_id++` posting `{type:'tick', tick_id}`; handle intents (`set_running`, `set_plan`, `set_seed`, `set_speed`, `request_snapshot`). 56 | - Status: ✅ DONE 57 | - Output: Live heartbeat confirming transport and ordering. 58 | - Unit test: `engine-handshake`: unit-test pure exported helpers used by the worker (e.g., `makeSnapshot(state)` returns normalized shapes; `nextTickId` monotonic). 59 | 60 | - 2. Task: UI “Tick: N” indicator wired through `simBridge` → store. 61 | - Status: ✅ DONE 62 | - Output: Visible counter proving end‑to‑end flow. 63 | - Unit test: `tick-indicator-increments`: render component, simulate incoming ticks 1..3, assert text updates; pause via intent and assert no further increments. 64 | 65 | - 3. Task: Tiny Run/Pause toggle in UI posting `set_running` intent; worker echoes snapshot to reflect `running`. 66 | - Status: ✅ DONE 67 | - Output: Button toggles engine ticking (N stops/starts incrementing), UI shows running state. 68 | - Unit test: can be covered by integration later; manual verify now. 69 | 70 | ## F) Items, Status Machine, Start Policy 71 | 72 | - 1. Task: Implement item generation for a single plan stub (e.g., Calm) with deps and base estimates. 73 | - Status: ✅ DONE 74 | - Output: Items enter `queued`; resolver promotes to `assigned` when deps satisfied. 75 | - Unit test: `deps-resolver`: given a small DAG, assert eligible moves to `assigned`; a cycle triggers detection and safe handling (e.g., later edge ignored). 76 | 77 | - 2. Task: Start cadence with soft concurrency cap (`MAX_CONCURRENT`), Poisson-ish staggering; one agent per item. 78 | - Status: ✅ DONE 79 | - Output: Items transition `assigned → in_progress`; agent created and bound. 80 | - Unit test: `start-policy-cap`: with 20 eligible items and cap=12, at most 12 start; repeated calls don’t exceed cap. 81 | 82 | - 3. Task: In‑progress updates per tick: wobble `tps` within `[min,max]`, accumulate `tokens_done`, roll `eta_ms`. 83 | - Status: ✅ DONE 84 | - Output: Stable per-item progress and ETAs. 85 | - Unit test: `tps-wobble-bounds`: run wobble N times; assert min/max bounds and no NaN/Infinity. 86 | 87 | - 4. Task: Completion: when ETA ≤ 0 (or position reaches center later), transition to `done`; clear agent. 88 | - Status: ✅ DONE 89 | - Output: Items leave the live set and contribute to totals. 90 | - Unit test: `complete-item`: simulate ticks to drive ETA to zero; assert status becomes `done` and agent removed. 91 | 92 | ## G) Metrics (Worker‑Owned) 93 | 94 | - 1. Task: Compute metrics each tick: active agents, total tokens, total spend, live tps, spend/sec, completion rate (eligible denominator). 95 | - Status: ✅ DONE 96 | - Output: Accurate counters sent in tick diffs. 97 | - Unit test: `metrics-math`: construct a small state with known token deltas; assert totals, live rates, and completion rate match expected. 98 | 99 | ## H) Work Items Table (UI) 100 | 101 | - 1. Task: Implement `components/WorkTable.tsx` rendering columns: ID, Sector, Status, Tokens (done/est), TPS (cur/min–max), ETA, Deps, Agent; sort by status→ID. 102 | - Status: ✅ DONE 103 | - Output: Live table that reflects store projection. 104 | - Unit test: `table-sorts-and-formats`: render with a few items; assert order, status chips, and truncated deps (`+n more`). 105 | 106 | ## I) Metrics Bar (UI) 107 | 108 | - 1. Task: Implement `components/MetricsBar.tsx` to display numeric counters; subscribe only to `metrics`. 109 | - Status: ✅ DONE 110 | - Output: Live counters independent from table renders. 111 | - Unit test: `metrics-bar-updates`: update store metrics; assert displayed numbers change without re-rendering table mock. 112 | 113 | ## J) Controls & Persistence 114 | 115 | - 1. Task: Implement `ControlBar` with Run/Pause, Plan, Seed, Speed; dispatch intents; persist selections to `localStorage`. 116 | - Status: ✅ DONE 117 | - Output: User controls that survive refresh. 118 | - Unit test: `controls-persist-and-dispatch`: simulate changes; assert `localStorage` writes and `postIntent` calls with correct payloads. 119 | 120 | ## K) Plans & Seeds 121 | 122 | - 1. Task: Implement Calm/Rush/Web topologies; seed handling via URL `?seed=` and control bar. 123 | - Status: ✅ DONE 124 | - Output: Deterministic run given plan+seed. 125 | - Unit test: `plan-determinism`: same plan+seed produces identical ordered sequence of `start_item`/`complete_item` ids from helper simulation. 126 | 127 | ## L) Radar (Basic, After Table) 128 | 129 | - 1. Task: Minimal `RadarCanvas` drawing only `in_progress` items as moving arrows; simple path progression; optional completion flare. 130 | - Status: ✅ DONE (minimal static frame) 131 | - Output: Visual reinforcement once table pipeline is validated. 132 | - Unit test: `motion-mapping`: unit-test path math helpers (bezier progress, tps→speed mapping); assert continuity and bounds. (Canvas draw tested indirectly.) 133 | 134 | ## M) Performance & Resilience 135 | 136 | - 1. Task: DPR clamp (≤2), offscreen static rings (later), and diff size minimization. 137 | - Status: pending 138 | - Output: Stable 55–60fps under cap. 139 | - Unit test: `dpr-clamp`: unit-test helper returns ≤2 for various devicePixelRatio inputs. 140 | 141 | - 2. Task: Error handling: missing deps (treated as unmet), cycle detection, NaN/Infinity clamping, desync recovery via snapshot. 142 | - Status: pending 143 | - Output: Robust sim under edge conditions. 144 | - Unit test: `edge-cases`: construct bad inputs; assert safe behavior and no thrown errors. 145 | 146 | ## N) Integration Smoke (Optional but Recommended) 147 | 148 | - 1. Task: Playwright spec: snapshot handshake shows Tick:N; table populates; statuses progress; metrics update; Run/Pause works. 149 | - Status: pending 150 | - Output: Confidence the red thread works end‑to‑end. 151 | - Integration test: `app-red-thread.spec`: assert tick increments, then table rows appear and change status over time. 152 | 153 | --- 154 | 155 | Notes 156 | - Keep worker as the single source of truth; UI only projects and sends intents. 157 | - Prefer snapshot→diff protocol with `tick_id` for ordering and coalesced UI updates. 158 | - Defer radar polish until after the table and metrics pipeline proves out. 159 | 160 | ## O) Config & Tuning Centralization 161 | 162 | - 1. Task: Centralize runtime tuning params in `lib/config.ts` (bridge batch ms, store flush interval, engine tick Hz, defaults for seed/running) and wire into bridge/store. 163 | - Status: ✅ DONE 164 | - Output: Single config surface to tune app behavior; environment overrides via `NEXT_PUBLIC_*` supported. 165 | - Unit test: `config defaults`: assert positive defaults and non-empty default seed. 166 | -------------------------------------------------------------------------------- /components/AudioPlayer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useEffect, useMemo, useRef, useState, useId } from 'react'; 4 | import type { Track } from '@/lib/audio/tracks'; 5 | 6 | type YouTubePlayer = { 7 | pauseVideo?: () => void; 8 | playVideo?: () => void; 9 | loadVideoById?: (videoId: string) => void; 10 | cueVideoById?: (videoId: string) => void; 11 | getPlayerState?: () => number; 12 | }; 13 | 14 | type YouTubeAPI = { 15 | Player: new (element: HTMLElement, options: { 16 | width: number; 17 | height: number; 18 | playerVars: Record; 19 | events: { 20 | onReady: () => void; 21 | onStateChange: (e: { data: number }) => void; 22 | }; 23 | }) => YouTubePlayer; 24 | }; 25 | 26 | type Props = { 27 | tracks: Track[]; 28 | initialIndex?: number; 29 | className?: string; 30 | showSourceLink?: boolean; 31 | }; 32 | 33 | export default function AudioPlayer({ tracks, initialIndex = 0, className = '', showSourceLink = false }: Props) { 34 | const validTracks = useMemo(() => tracks.filter(Boolean), [tracks]); 35 | const [index, setIndex] = useState(Math.min(Math.max(0, initialIndex), Math.max(0, validTracks.length - 1))); 36 | const [isPlaying, setIsPlaying] = useState(false); 37 | const ytPlayerRef = useRef(null); 38 | const ytWrapperRef = useRef(null); 39 | const rid = useId(); 40 | const ytInnerId = useMemo(() => 'ytp-' + rid.replace(/[:]/g, ''), [rid]); 41 | 42 | const current = validTracks[index]; 43 | const isYouTube = current?.source.type === 'youtube'; 44 | 45 | // --- Helpers --- 46 | const getYouTubeId = (url: string): string | null => { 47 | try { 48 | const u = new URL(url); 49 | if (u.hostname === 'youtu.be') { 50 | return u.pathname.split('/')[1] || null; 51 | } 52 | if (u.hostname.includes('youtube.com')) { 53 | if (u.pathname === '/watch') return u.searchParams.get('v'); 54 | // e.g., /live/ or /embed/ 55 | const parts = u.pathname.split('/').filter(Boolean); 56 | if (parts.length >= 2 && (parts[0] === 'live' || parts[0] === 'embed')) return parts[1]; 57 | } 58 | } catch {} 59 | return null; 60 | }; 61 | 62 | const ensureYouTubeAPI = (): Promise => { 63 | return new Promise((resolve) => { 64 | if (typeof window === 'undefined') return resolve(null); 65 | const w = window as Window & { YT?: YouTubeAPI }; 66 | if (w.YT && w.YT.Player) return resolve(w.YT); 67 | // Inject script once 68 | const existing = document.getElementById('yt-iframe-api'); 69 | if (!existing) { 70 | const s = document.createElement('script'); 71 | s.id = 'yt-iframe-api'; 72 | s.src = 'https://www.youtube.com/iframe_api'; 73 | document.head.appendChild(s); 74 | } 75 | const check = () => { 76 | if (w.YT && w.YT.Player) resolve(w.YT); 77 | else setTimeout(check, 50); 78 | }; 79 | check(); 80 | }); 81 | }; 82 | 83 | // (local audio removed) 84 | 85 | // Handle YouTube player lifecycle and play/pause sync 86 | useEffect(() => { 87 | if (!isYouTube) { 88 | // Pause YT when leaving a YT track 89 | if (ytPlayerRef.current) { 90 | try { ytPlayerRef.current.pauseVideo?.(); } catch {} 91 | } 92 | return; 93 | } 94 | 95 | const src = current && current.source.type === 'youtube' ? current.source.url : ''; 96 | const videoId = src ? getYouTubeId(src) : null; 97 | if (!videoId) { 98 | console.warn('AudioPlayer: Could not parse YouTube video ID from', src); 99 | return; 100 | } 101 | 102 | let cancelled = false; 103 | ensureYouTubeAPI().then((YT) => { 104 | if (cancelled || !YT) return; 105 | const mountTarget = document.getElementById(ytInnerId) as HTMLElement | null; 106 | if (!mountTarget) return; 107 | // Create player if needed 108 | if (!ytPlayerRef.current) { 109 | ytPlayerRef.current = new YT.Player(mountTarget, { 110 | width: 0, 111 | height: 0, 112 | playerVars: { 113 | autoplay: 0, 114 | controls: 0, 115 | rel: 0, 116 | modestbranding: 1, 117 | playsinline: 1, 118 | }, 119 | events: { 120 | onReady: () => { 121 | // Load or cue based on desired state 122 | if (isPlaying) { 123 | ytPlayerRef.current?.loadVideoById?.(videoId); 124 | // Nudge play in case autoplay fails without a direct gesture 125 | setTimeout(() => { try { ytPlayerRef.current?.playVideo?.(); } catch {} }, 0); 126 | } else { 127 | ytPlayerRef.current?.cueVideoById?.(videoId); 128 | } 129 | }, 130 | onStateChange: (e: { data: number }) => { 131 | // Sync UI with player state 132 | if (e.data === 1) setIsPlaying(true); // playing 133 | else if (e.data === 2 || e.data === 0) setIsPlaying(false); // paused or ended 134 | else if (e.data === 5 && isPlaying) { 135 | // CUED but we want to play 136 | try { ytPlayerRef.current?.playVideo?.(); } catch {} 137 | } 138 | }, 139 | }, 140 | }); 141 | } else { 142 | // Reuse player 143 | try { 144 | if (isPlaying) { 145 | ytPlayerRef.current.loadVideoById?.(videoId); 146 | // Ensure it actually starts 147 | try { ytPlayerRef.current.playVideo?.(); } catch {} 148 | } else { 149 | ytPlayerRef.current.cueVideoById?.(videoId); 150 | } 151 | } catch {} 152 | } 153 | }); 154 | 155 | return () => { 156 | cancelled = true; 157 | }; 158 | // eslint-disable-next-line react-hooks/exhaustive-deps 159 | }, [index, isYouTube]); 160 | 161 | // React to play/pause toggle for YouTube 162 | useEffect(() => { 163 | if (!isYouTube || !ytPlayerRef.current) return; 164 | try { 165 | if (isPlaying) ytPlayerRef.current.playVideo?.(); 166 | else ytPlayerRef.current.pauseVideo?.(); 167 | } catch {} 168 | }, [isPlaying, isYouTube]); 169 | 170 | // If autoplay is blocked or fails, sync UI back to Play after a short check 171 | useEffect(() => { 172 | if (!isYouTube || !isPlaying || !ytPlayerRef.current) return; 173 | const id = setTimeout(() => { 174 | try { 175 | const state = ytPlayerRef.current?.getPlayerState?.(); 176 | // 1: PLAYING, 3: BUFFERING 177 | if (state !== 1 && state !== 3) setIsPlaying(false); 178 | } catch {} 179 | }, 800); 180 | return () => clearTimeout(id); 181 | }, [index, isYouTube, isPlaying]); 182 | 183 | // If track list updates and index goes out of range 184 | useEffect(() => { 185 | if (index >= validTracks.length) setIndex(Math.max(0, validTracks.length - 1)); 186 | }, [validTracks.length, index]); 187 | 188 | const onPrev = () => { 189 | if (!validTracks.length) return; 190 | setIndex((i) => { 191 | const nextIdx = (i - 1 + validTracks.length) % validTracks.length; 192 | const t = validTracks[nextIdx]; 193 | // Always autoplay when navigating 194 | setIsPlaying(true); 195 | // If next is YouTube and player exists, load immediately in this user gesture 196 | if (t?.source.type === 'youtube' && ytPlayerRef.current) { 197 | const id = getYouTubeId(t.source.url); 198 | if (id) { 199 | try { 200 | ytPlayerRef.current.loadVideoById?.(id); 201 | ytPlayerRef.current.playVideo?.(); 202 | } catch {} 203 | } 204 | } 205 | return nextIdx; 206 | }); 207 | }; 208 | 209 | const onToggle = () => { 210 | if (!current || !isYouTube) return; 211 | setIsPlaying((p) => !p); 212 | }; 213 | 214 | const onNext = () => { 215 | if (!validTracks.length) return; 216 | setIndex((i) => { 217 | const nextIdx = (i + 1) % validTracks.length; 218 | const t = validTracks[nextIdx]; 219 | // Always autoplay when navigating 220 | setIsPlaying(true); 221 | // If next is YouTube and player exists, load immediately in this user gesture 222 | if (t?.source.type === 'youtube' && ytPlayerRef.current) { 223 | const id = getYouTubeId(t.source.url); 224 | if (id) { 225 | try { 226 | ytPlayerRef.current.loadVideoById?.(id); 227 | ytPlayerRef.current.playVideo?.(); 228 | } catch {} 229 | } 230 | } 231 | return nextIdx; 232 | }); 233 | }; 234 | 235 | // No autoplay on initial mount; start on user gesture 236 | 237 | return ( 238 |
239 | {/* Hidden YouTube player container for YouTube tracks */} 240 |
241 |
242 |
243 | 244 | {/* Title row (full length) with optional source link */} 245 |
246 | {current ? current.title : 'No track'} 247 | {showSourceLink && isYouTube && current && current.source.type === 'youtube'} 248 |
249 | 250 | {/* Controls row (fixed position under title) */} 251 |
252 | 259 | 266 | 273 |
274 |
275 | ); 276 | } 277 | -------------------------------------------------------------------------------- /components/TopOverview.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useEffect, useRef, useState, useSyncExternalStore } from 'react'; 4 | import { appStore } from '@/lib/store'; 5 | 6 | function useMetrics() { 7 | return useSyncExternalStore( 8 | appStore.subscribe, 9 | () => appStore.getState().metrics, 10 | () => appStore.getState().metrics, 11 | ); 12 | } 13 | 14 | function fmtInt(n?: number) { 15 | const v = Number.isFinite(n as number) ? (n as number) : 0; 16 | return new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(v); 17 | } 18 | function fmtUSD(n?: number) { 19 | const v = Number.isFinite(n as number) ? (n as number) : 0; 20 | return `$${new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(v)}`; 21 | } 22 | 23 | export default function TopOverview({ compact = false, hideCompletion = false }: { compact?: boolean; hideCompletion?: boolean }) { 24 | const m = useMetrics(); 25 | 26 | // Completion over time graph state 27 | const containerRef = useRef(null); 28 | const [points, setPoints] = useState>([]); 29 | const startRef = useRef(Date.now()); 30 | const wasCompleteRef = useRef(false); 31 | 32 | // Collect points over time; start at 0% 33 | useEffect(() => { 34 | // ensure first point at t=0 is 0 35 | setPoints([{ t: 0, v: 0 }]); 36 | const id = window.setInterval(() => { 37 | const state = appStore.getState(); 38 | const arr = Object.values(state.items || {}); 39 | const hasItems = arr.length > 0; 40 | const allDone = hasItems && arr.every((it) => it.status === 'done'); 41 | 42 | const vRaw = state.metrics?.completion_rate || 0; 43 | const v = Math.max(0, Math.min(1, vRaw)); 44 | 45 | // Detect transition: complete -> not complete (new run). Reset series. 46 | if (wasCompleteRef.current && !allDone) { 47 | wasCompleteRef.current = false; 48 | startRef.current = Date.now(); 49 | setPoints([{ t: 0, v: 0 }]); 50 | return; 51 | } 52 | 53 | // While complete, freeze series (do not append new points) 54 | if (allDone) { 55 | wasCompleteRef.current = true; 56 | return; 57 | } 58 | 59 | // If not running, do not advance time/series 60 | if (!state.running) { 61 | return; 62 | } 63 | 64 | // Not complete: append point using current baseline 65 | const t = (Date.now() - startRef.current) / 1000; 66 | setPoints((prev) => { 67 | const next = prev.concat({ t, v }); 68 | // keep last ~600 points to cap memory 69 | return next.length > 600 ? next.slice(next.length - 600) : next; 70 | }); 71 | }, 400); 72 | return () => clearInterval(id); 73 | }, []); 74 | 75 | // Reset the series when a fresh snapshot is applied (e.g., switching plans) 76 | useEffect(() => { 77 | let prevLast = appStore.getState().lastTickId; 78 | const unsub = appStore.subscribe((s) => { 79 | const currentLast = s.lastTickId; 80 | if (prevLast > 0 && currentLast === 0) { 81 | wasCompleteRef.current = false; 82 | startRef.current = Date.now(); 83 | setPoints([{ t: 0, v: 0 }]); 84 | } 85 | prevLast = currentLast; 86 | }); 87 | return () => { 88 | if (typeof unsub === 'function') unsub(); 89 | }; 90 | }, []); 91 | 92 | const gridCols = compact ? '1fr' : '1fr 1fr 1fr'; 93 | const gapPx = compact ? 6 : 8; 94 | const headerCls = compact 95 | ? 'text-sm text-[#d79326ff] pl-2 pr-2 bg-[#130f04ff]' 96 | : 'text-lg text-[#d79326ff] pl-2 pr-2 bg-[#130f04ff]'; 97 | const labelCls = compact ? 'text-xs' : 'text-md'; 98 | const valueCls = compact ? 'text-base' : 'text-xl'; 99 | 100 | return ( 101 |
110 | {/* Main Metrics card */} 111 |
112 |
MAIN METRICS
113 |
114 |
115 |
ACTIVE AGENTS
116 |
{fmtInt(m.active_agents)}
117 |
118 |
119 |
TOTAL TOKENS
120 |
{fmtInt(m.total_tokens)}
121 |
122 |
123 |
TOTAL SPEND
124 |
{fmtUSD(m.total_spend_usd)}
125 |
126 |
127 |
128 | 129 | {/* Live Throughput card */} 130 |
131 |
LIVE THROUGHPUT
132 |
133 |
134 |
TOKENS / SEC
135 |
{fmtInt(m.live_tps)}
136 |
137 |
138 |
SPEND / SEC
139 |
{fmtUSD(m.live_spend_per_s)}
140 |
141 |
142 |
143 | 144 | {/* Project Completion Rate card (optionally hidden on mobile) */} 145 | {hideCompletion ? null : ( 146 |
147 |
COMPLETION RATE
148 |
149 | 150 |
151 |
152 | )} 153 |
154 | ); 155 | } 156 | 157 | function Chart({ points, containerRef }: { points: Array<{ t: number; v: number }>; containerRef: React.RefObject }) { 158 | const [size, setSize] = useState({ w: 300, h: 120 }); 159 | useEffect(() => { 160 | const el = containerRef.current; 161 | if (!el) return; 162 | const r = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight })); 163 | r.observe(el); 164 | setSize({ w: el.clientWidth, h: el.clientHeight }); 165 | return () => r.disconnect(); 166 | }, [containerRef]); 167 | const pad = 12; // general padding 168 | const w = Math.max(10, size.w); 169 | const h = Math.max(10, size.h); 170 | const span = Math.max(5, points.length ? points[points.length - 1].t : 5); 171 | // Dynamic Y max scales with current progress so small movements are visible 172 | const yMax = React.useMemo(() => { 173 | let m = 0; 174 | for (const p of points) if (Number.isFinite(p.v) && p.v > m) m = p.v; 175 | return m; 176 | }, [points]); 177 | const yMaxClamp = Math.max(0.0001, yMax); 178 | const yTopPct = Math.max(1, Math.round(yMaxClamp * 100)); 179 | const d = React.useMemo(() => { 180 | if (!points.length) return ''; 181 | const coords = points.map(({ t, v }) => { 182 | const x = pad + (t / span) * Math.max(1, w - pad * 2); 183 | const vy = Math.max(0, Math.min(1, v / yMaxClamp)); 184 | const y = pad + (1 - vy) * Math.max(1, h - pad * 2); 185 | return `${x.toFixed(1)},${y.toFixed(1)}`; 186 | }); 187 | return `M ${coords.join(' L ')}`; 188 | }, [points, w, h, span, yMaxClamp]); 189 | return ( 190 |
191 | 192 | {/* Axes */} 193 | 194 | 195 | {/* X ticks and moving time label */} 196 | {(() => { 197 | const ticks: React.ReactElement[] = []; 198 | // Choose a friendly tick interval 199 | const tick = span <= 10 ? 1 : span <= 30 ? 5 : span <= 120 ? 10 : 30; 200 | for (let t = 0; t <= span + 0.001; t += tick) { 201 | const x = pad + (t / span) * Math.max(1, w - pad * 2); 202 | ticks.push( 203 | 204 | {/* Keep ticks and labels inside the chart area to avoid clipping */} 205 | 206 | {Math.round(t)}s 207 | 208 | ); 209 | } 210 | // Moving time label at right end 211 | const xr = w - pad; 212 | return ( 213 | 214 | {ticks} 215 | {Math.round(span)}s 216 | 217 | ); 218 | })()} 219 | {/* Axis labels */} 220 | Duration 221 | {/* Progress */} 222 | {/* 0% and dynamic-top markers */} 223 | 0% 224 | {yTopPct}% 225 | {/* Path */} 226 | 227 | 228 |
229 | ); 230 | } 231 | -------------------------------------------------------------------------------- /workers/engine.ts: -------------------------------------------------------------------------------- 1 | // Engine worker: minimal handshake loop (snapshot + ticks) 2 | // Exports tiny pure helpers for tests; only starts the loop when actually 3 | // running in a WorkerGlobalScope (not during Vitest/jsdom imports). 4 | 5 | import { DEFAULT_SEED, RUNNING_DEFAULT } from '../lib/config'; 6 | import { ENGINE_TICK_HZ, TPS_ALPHA, TPS_TARGET_HOLD_MS_MIN, TPS_TARGET_HOLD_MS_MAX, TPS_JITTER_FRAC } from '@/lib/constants'; 7 | import { debugLog } from '../lib/debug'; 8 | import { buildItemsFromPlan, detectCycles, promoteQueuedToAssigned, countInProgress, computeMetrics } from '@/lib/engine'; 9 | import { getPlanByName, ALL_PLANS, DEFAULT_PLAN_NAME } from '@/plans'; 10 | import { createRNG } from '@/lib/rng'; 11 | import { MAX_CONCURRENT } from '@/lib/constants'; 12 | import type { AppState, ProjectMetrics, Agent, WorkItem } from '../lib/types'; 13 | 14 | export const ENGINE_WORKER_MODULE_LOADED = true; 15 | 16 | export type PlanName = string; 17 | 18 | export function zeroMetrics(): ProjectMetrics { 19 | return { 20 | active_agents: 0, 21 | total_tokens: 0, 22 | total_spend_usd: 0, 23 | live_tps: 0, 24 | live_spend_per_s: 0, 25 | completion_rate: 0, 26 | }; 27 | } 28 | 29 | export function makeInitialState(seed: string = DEFAULT_SEED): AppState { 30 | return { 31 | items: {}, 32 | agents: {}, 33 | metrics: zeroMetrics(), 34 | seed, 35 | running: RUNNING_DEFAULT, 36 | }; 37 | } 38 | 39 | export function hzToMs(hz: number): number { 40 | return hz > 0 ? Math.round(1000 / hz) : 1000 / 30; 41 | } 42 | 43 | interface Ctx { 44 | state: AppState; 45 | tickId: number; 46 | running: boolean; 47 | speed: number; // multiplier (1x default) 48 | plan: PlanName; 49 | timer: any; 50 | tickMs: number; 51 | rng: ReturnType; 52 | nextStartAt: number; // epoch ms for next start attempt 53 | agentCounter: number; 54 | lastTickAt: number; 55 | } 56 | 57 | function postSnapshot(ctx: Ctx) { 58 | debugLog('worker', 'postSnapshot', { running: ctx.running, tickId: ctx.tickId, seed: ctx.state.seed, plan: ctx.plan }); 59 | ;(self as any).postMessage({ type: 'snapshot', state: ctx.state }); 60 | } 61 | 62 | 63 | function expDelayMs(mean: number, rng: ReturnType) { 64 | const u = Math.max(1e-6, rng.next()); 65 | return -Math.log(1 - u) * mean; 66 | } 67 | 68 | function tryStartOne(ctx: Ctx, now: number, diffs: { items: Partial[]; agents: Partial[] }) { 69 | const inProg = countInProgress(ctx.state.items); 70 | if (inProg >= MAX_CONCURRENT) return false; 71 | 72 | const assigned = Object.values(ctx.state.items).filter((i) => i.status === 'assigned'); 73 | if (assigned.length === 0) return false; 74 | // pick random assigned 75 | const pick = assigned[Math.floor(ctx.rng.next() * assigned.length)]; 76 | const agentId = `AG${++ctx.agentCounter}`; 77 | const nowMs = now; 78 | // minimal agent (positions unused yet) 79 | ctx.state.agents[agentId] = { id: agentId, work_item_id: pick.id, x: 0, y: 0, v: 0.002, curve_phase: 0 } as Agent; 80 | pick.status = 'in_progress'; 81 | pick.agent_id = agentId; 82 | pick.started_at = nowMs; 83 | // include minimal diffs 84 | diffs.items.push({ id: pick.id, status: 'in_progress', agent_id: agentId, started_at: nowMs }); 85 | diffs.agents.push({ id: agentId, work_item_id: pick.id } as Partial); 86 | debugLog('worker', 'start_item', { id: pick.id, agent: agentId }); 87 | return true; 88 | } 89 | 90 | function stepEngine(ctx: Ctx) { 91 | const now = Date.now(); 92 | const diffs: { items: Partial[]; agents: Partial[]; agents_remove?: string[] } = { items: [], agents: [] }; 93 | // Simulation delta (seconds) 94 | const dtSec = Math.max(0, (now - ctx.lastTickAt) / 1000); 95 | ctx.lastTickAt = now; 96 | 97 | // Promote any newly eligible items 98 | const newlyAssigned = promoteQueuedToAssigned(ctx.state.items); 99 | for (const id of newlyAssigned) diffs.items.push({ id, status: 'assigned' }); 100 | 101 | // Start cadence: Poisson-like with mean 800ms, obey cap 102 | if (ctx.running && now >= ctx.nextStartAt) { 103 | if (tryStartOne(ctx, now, diffs)) { 104 | // schedule next start 105 | const mean = 800 / ctx.speed; 106 | ctx.nextStartAt = now + Math.max(50, expDelayMs(mean, ctx.rng)); 107 | } else { 108 | // nothing to start; check again soon 109 | ctx.nextStartAt = now + 300; 110 | } 111 | } 112 | 113 | // Per-item TPS target state (held targets to create bursty behavior) 114 | type TpsState = { target: number; nextAt: number }; 115 | // Keep this map across ticks by hoisting to module scope; fall back to a property on ctx 116 | // to avoid redeclaring across calls in certain bundlers. 117 | const tpsMap: Map = (stepEngine as any)._tpsMap || new Map(); 118 | (stepEngine as any)._tpsMap = tpsMap; 119 | 120 | // Update in-progress items 121 | for (const it of Object.values(ctx.state.items)) { 122 | if (it.status !== 'in_progress') { 123 | // cleanup any lingering state when item leaves in_progress 124 | if ((it.status === 'done' || it.status === 'queued' || it.status === 'assigned') && tpsMap.has(it.id)) tpsMap.delete(it.id); 125 | continue; 126 | } 127 | 128 | // Sample/hold target for a burst window 129 | let st = tpsMap.get(it.id); 130 | if (!st || now >= st.nextAt) { 131 | const range = Math.max(0, it.tps_max - it.tps_min); 132 | const baseTarget = it.tps_min + range * ctx.rng.next(); 133 | // Hold window scaled by speed so faster sims don't feel too stable 134 | const holdMs = (TPS_TARGET_HOLD_MS_MIN + (TPS_TARGET_HOLD_MS_MAX - TPS_TARGET_HOLD_MS_MIN) * ctx.rng.next()) / Math.max(0.0001, ctx.speed || 1); 135 | st = { target: baseTarget, nextAt: now + Math.max(100, Math.floor(holdMs)) }; 136 | tpsMap.set(it.id, st); 137 | } 138 | 139 | // Small flutter around the held target 140 | const range = Math.max(0, it.tps_max - it.tps_min); 141 | const jitterAmp = TPS_JITTER_FRAC * range; 142 | const jitter = (ctx.rng.next() * 2 - 1) * jitterAmp; 143 | const targetEffective = Math.max(it.tps_min, Math.min(it.tps_max, st.target + jitter)); 144 | 145 | // Exponential smoothing toward target to avoid instant jumps 146 | const prev = Number.isFinite(it.tps as number) ? (it.tps as number) : it.tps_min; 147 | let tps = (1 - TPS_ALPHA) * prev + TPS_ALPHA * targetEffective; 148 | if (!Number.isFinite(tps)) tps = it.tps_min; 149 | tps = Math.max(it.tps_min, Math.min(it.tps_max, tps)); 150 | it.tps = tps; 151 | // accumulate tokens 152 | it.tokens_done += tps * dtSec; 153 | if (!Number.isFinite(it.tokens_done)) it.tokens_done = 0; 154 | // eta by elapsed time 155 | const started = it.started_at ?? now; 156 | // ETA based on real elapsed wall time 157 | it.eta_ms = Math.max(0, it.estimate_ms - (now - started)); 158 | diffs.items.push({ id: it.id, tps: it.tps, tokens_done: it.tokens_done, eta_ms: it.eta_ms }); 159 | // completion 160 | if (it.eta_ms <= 0) { 161 | it.status = 'done'; 162 | // cleanup state on completion 163 | if (tpsMap.has(it.id)) tpsMap.delete(it.id); 164 | const agentId = it.agent_id; 165 | it.agent_id = undefined; 166 | diffs.items.push({ id: it.id, status: 'done', agent_id: undefined }); 167 | if (agentId && (ctx.state.agents as any)[agentId]) { 168 | delete (ctx.state.agents as any)[agentId]; 169 | (diffs.agents_remove ||= []).push(agentId); 170 | } 171 | } 172 | } 173 | return diffs; 174 | } 175 | 176 | function postTick(ctx: Ctx) { 177 | // apply engine step to build diffs 178 | const diffs = stepEngine(ctx); 179 | // metrics 180 | const metrics = computeMetrics(ctx.state.items as any, ctx.state.agents as any); 181 | ctx.state.metrics = metrics; 182 | ctx.tickId += 1; 183 | debugLog('worker', 'postTick', { tickId: ctx.tickId, items: diffs.items.length, agents: diffs.agents.length, agents_remove: diffs.agents_remove?.length ?? 0 }); 184 | (self as any).postMessage({ type: 'tick', tick_id: ctx.tickId, items: diffs.items.length ? diffs.items : undefined, agents: diffs.agents.length ? diffs.agents : undefined, agents_remove: diffs.agents_remove && diffs.agents_remove.length ? diffs.agents_remove : undefined, metrics }); 185 | } 186 | 187 | 188 | function startLoop(ctx: Ctx) { 189 | if (ctx.timer) return; 190 | debugLog('worker', 'startLoop', { tickMs: ctx.tickMs }); 191 | ctx.timer = setInterval(() => { 192 | if (!ctx.running) return; 193 | postTick(ctx); 194 | }, ctx.tickMs); 195 | } 196 | 197 | function stopLoop(ctx: Ctx) { 198 | if (!ctx.timer) return; 199 | debugLog('worker', 'stopLoop'); 200 | clearInterval(ctx.timer); 201 | ctx.timer = null; 202 | } 203 | 204 | function loadPlan(ctx: Ctx, name: PlanName) { 205 | // Choose plan by name from registry (fallback to first available) 206 | const planDef = getPlanByName(name) ?? ALL_PLANS[0]; 207 | const items = buildItemsFromPlan(planDef); 208 | const cycles = detectCycles(items); 209 | if (cycles.length) debugLog('worker', 'plan-cycles-detected', { count: cycles.length }); 210 | // Promote initial eligible (no deps) to assigned 211 | promoteQueuedToAssigned(items); 212 | ctx.state.items = items; 213 | ctx.plan = name; 214 | } 215 | 216 | function handleIntent(ctx: Ctx, intent: any) { 217 | switch (intent?.type) { 218 | case 'set_running': { 219 | ctx.running = !!intent.running; 220 | ctx.state.running = ctx.running; // keep snapshot state in sync 221 | debugLog('worker', 'intent:set_running', { running: ctx.running }); 222 | if (ctx.running) startLoop(ctx); else stopLoop(ctx); 223 | // Echo state so UI reflects running flag 224 | postSnapshot(ctx); 225 | return; 226 | } 227 | case 'set_seed': { 228 | ctx.state.seed = String(intent.seed ?? DEFAULT_SEED); 229 | // Re-seed RNG and reset start scheduling for determinism 230 | ctx.rng = createRNG(ctx.state.seed); 231 | ctx.nextStartAt = Date.now(); 232 | ctx.agentCounter = 0; 233 | debugLog('worker', 'intent:set_seed', { seed: ctx.state.seed }); 234 | postSnapshot(ctx); 235 | return; 236 | } 237 | case 'set_plan': { 238 | const name = (intent.plan as PlanName) ?? DEFAULT_PLAN_NAME; 239 | debugLog('worker', 'intent:set_plan', { plan: name }); 240 | // Load items from plan and pause so user can inspect 241 | loadPlan(ctx, name); 242 | ctx.running = false; 243 | ctx.state.running = false; 244 | stopLoop(ctx); 245 | postSnapshot(ctx); 246 | return; 247 | } 248 | case 'set_speed': { 249 | const s = Number(intent.speed); 250 | ctx.speed = Number.isFinite(s) && s > 0 ? s : 1; 251 | debugLog('worker', 'intent:set_speed', { speed: ctx.speed }); 252 | return; 253 | } 254 | case 'request_snapshot': { 255 | debugLog('worker', 'intent:request_snapshot'); 256 | postSnapshot(ctx); 257 | return; 258 | } 259 | default: 260 | return; 261 | } 262 | } 263 | 264 | function makeCtx(): Ctx { 265 | return { 266 | state: makeInitialState(DEFAULT_SEED), 267 | tickId: 0, 268 | running: RUNNING_DEFAULT, 269 | speed: 1, 270 | plan: DEFAULT_PLAN_NAME, 271 | timer: null, 272 | tickMs: hzToMs(ENGINE_TICK_HZ), 273 | rng: createRNG(DEFAULT_SEED), 274 | nextStartAt: Date.now(), 275 | agentCounter: 0, 276 | lastTickAt: Date.now(), 277 | }; 278 | } 279 | 280 | // Detect dedicated worker context without relying on instanceof (not available across browsers) 281 | const IS_DEDICATED_WORKER = typeof self !== 'undefined' && typeof (self as any).postMessage === 'function' && typeof (globalThis as any).window === 'undefined'; 282 | debugLog('worker', 'bootstrap', { isDedicatedWorker: IS_DEDICATED_WORKER, ENGINE_TICK_HZ }); 283 | 284 | if (IS_DEDICATED_WORKER) { 285 | const ctx = makeCtx(); 286 | // Preload default plan so snapshot includes items for inspection 287 | loadPlan(ctx, DEFAULT_PLAN_NAME); 288 | // Post initial snapshot immediately so UI can latch onto state 289 | postSnapshot(ctx); 290 | // Start ticking if running by default 291 | if (ctx.running) startLoop(ctx); 292 | 293 | self.addEventListener('message', (event: MessageEvent) => { 294 | debugLog('worker', 'message', { data: (event as any).data }); 295 | handleIntent(ctx, (event as any).data); 296 | }); 297 | } 298 | -------------------------------------------------------------------------------- /.planning/prd.md: -------------------------------------------------------------------------------- 1 | # Calming Control Room — Developer-Ready Spec (MVP v1) 2 | 3 | **Goal:** A desktop, fullscreen “calming control room” that renders a radar with moving agent arrows delivering work items to a center checkbox, a minimal metrics panel, and a simple work-items table. These are mock agents completing work items. Think of it as an overview of a project being done by AI Agents. Proc‑gen drives everything. Lean stack, fast first pixel, no Docker, iterate locally. 4 | 5 | --- 6 | 7 | ## 0) Non‑Negotiables for v1 8 | 9 | * **Platform:** Desktop web, fullscreen. 10 | * **Core UI:** 11 | 12 | 1. **Radar** (Canvas 2D) with moving **agents** (arrows) representing an agent working on a **work item**; agents spawn on the rim and curve toward the center checkbox; completion triggers a subtle flare. 13 | 2. **Metrics strip:** live counts (Active agents), cumulatives (Total tokens, Total spend), live throughput (Tokens/sec, Spend/sec), and **Project completion rate**. 14 | 3. **Work Items table:** minimal columns showing ID, sector, status, tokens, ETA, deps, assigned agent. 15 | * **Statuses:** `queued → assigned → in_progress → blocked → done` (no failures in v1). 16 | * **Radar visibility rule:** show **only** items in `in_progress` (blocked/queued/assigned are not visualized on the radar). 17 | * **Lean:** One page app; proc‑gen in a Web Worker; no backend; deploy-ready later. 18 | 19 | --- 20 | 21 | ## 1) Tech Choices (lean & fast) 22 | 23 | * **Framework:** Next.js (App Router) + **TypeScript** (Node 20 LTS). No server routes needed for v1. 24 | * **Styling:** Tailwind CSS + small shadcn/ui subset (Card, Badge, Table, Button, Toggle, Progress). 25 | * **State:** Zustand (single store) + `postMessage` from Worker. 26 | * **Transport:** `simBridge` abstracts message transport; talks to a Web Worker now and can swap to WebSocket later without changing UI reducers (same event schema). 27 | * **Rendering:** Canvas 2D (single ``). Keep WebGL/Three.js as v2. 28 | * **Audio (v1+1, optional):** Howler.js or plain `