Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ CLAUDE.md
.playwright-*/
.vercel
.mcp.json
mcp_servers.json
.env**
playwright/
.claude.backup.*
Expand Down
136 changes: 61 additions & 75 deletions README.md

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions console/src/ui/viewer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { Router, useRouter } from './router';
import { DashboardView, MemoriesView, SessionsView, SpecView, UsageView, VaultView } from './views';
import { LogsDrawer } from './components/LogsModal';
import { CommandPalette } from './components/CommandPalette';
import { LicenseGate } from './components/LicenseGate';
import { useTheme } from './hooks/useTheme';
import { useStats } from './hooks/useStats';
import { useHotkeys } from './hooks/useHotkeys';
import { useLicense } from './hooks/useLicense';
import { ToastProvider, ProjectProvider } from './context';

const routes = [
Expand All @@ -25,6 +27,7 @@ export function App() {
const { path, navigate } = useRouter();
const { resolvedTheme, setThemePreference } = useTheme();
const { workerStatus } = useStats();
const { license, isLoading: licenseLoading, refetch: refetchLicense } = useLicense();

const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 1024;
Expand Down Expand Up @@ -79,6 +82,24 @@ export function App() {

useHotkeys(handleShortcut);

const isLicenseValid = !licenseLoading && license?.valid === true && !license.isExpired;

if (licenseLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-base-200" data-theme={resolvedTheme === 'dark' ? 'claude-pilot' : 'claude-pilot-light'}>
<span className="loading loading-spinner loading-lg text-primary" />
</div>
);
}

if (!isLicenseValid) {
return (
<div data-theme={resolvedTheme === 'dark' ? 'claude-pilot' : 'claude-pilot-light'}>
<LicenseGate license={license} onActivated={refetchLicense} />
</div>
);
}

return (
<ProjectProvider>
<ToastProvider>
Expand Down
118 changes: 118 additions & 0 deletions console/src/ui/viewer/components/LicenseGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import React, { useState, useCallback } from 'react';
import type { LicenseResponse } from '../../../services/worker/http/routes/LicenseRoutes.js';

interface LicenseGateProps {
license: LicenseResponse | null;
onActivated: () => void;
}

export function LicenseGate({ license, onActivated }: LicenseGateProps) {
const [key, setKey] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);

const handleSubmit = useCallback(async () => {
const trimmed = key.trim();
if (!trimmed) return;

setError(null);
setIsSubmitting(true);

try {
const res = await fetch('/api/license/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: trimmed }),
});
const data = await res.json();

if (data.success) {
setKey('');
setError(null);
onActivated();
} else {
setError(data.error ?? 'Activation failed');
}
Comment on lines +21 to +35
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Non-2xx responses produce a misleading error message.

If the server returns a 500 or other non-2xx status with a non-JSON body, res.json() will throw and the catch block shows "Connection failed. Is the Pilot worker running?" — which is misleading for server errors. Check res.ok first.

Proposed fix
       const res = await fetch('/api/license/activate', {
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
         body: JSON.stringify({ key: trimmed }),
       });
+      if (!res.ok) {
+        setError(`Server error (${res.status}). Please try again.`);
+        return;
+      }
       const data = await res.json();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
const res = await fetch('/api/license/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: trimmed }),
});
const data = await res.json();
if (data.success) {
setKey('');
setError(null);
onActivated();
} else {
setError(data.error ?? 'Activation failed');
}
try {
const res = await fetch('/api/license/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: trimmed }),
});
if (!res.ok) {
setError(`Server error (${res.status}). Please try again.`);
return;
}
const data = await res.json();
if (data.success) {
setKey('');
setError(null);
onActivated();
} else {
setError(data.error ?? 'Activation failed');
}
🤖 Prompt for AI Agents
In `@console/src/ui/viewer/components/LicenseGate.tsx` around lines 21 - 35, In
LicenseGate's activation flow, don't call res.json() unconditionally; first
check res.ok on the Response returned by fetch in the async handler inside the
component (where res and data are used), and handle non-2xx statuses separately:
if !res.ok attempt to read res.text() (or safe JSON parse) to extract a server
message and call setError with a message including the HTTP status and server
text, otherwise parse JSON and proceed with the existing success branch (setKey,
setError(null), onActivated()) or failure branch (setError(data.error ??
'Activation failed')); ensure the catch block still reports network/connection
errors but not server-side 5xx as the same message.

} catch {
setError('Connection failed. Is the Pilot worker running?');
} finally {
setIsSubmitting(false);
}
}, [key, onActivated]);

const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !isSubmitting) {
handleSubmit();
}
}, [handleSubmit, isSubmitting]);

const isExpired = license?.isExpired === true;
const title = isExpired ? 'License Expired' : 'License Required';
const subtitle = isExpired
? 'Your Claude Pilot license has expired. Please activate a new license to continue using the Console.'
: 'Claude Pilot Console requires an active license or trial. Activate your license key below to get started.';

return (
<div className="min-h-screen flex items-center justify-center bg-base-200 p-4">
<div className="card bg-base-100 shadow-xl w-full max-w-md">
<div className="card-body items-center text-center gap-4">
<div className="text-5xl mb-2">
{isExpired ? '\u{1F6AB}' : '\u{1F512}'}
</div>

<h1 className="card-title text-2xl">{title}</h1>
<p className="text-base-content/60 text-sm">{subtitle}</p>

<div className="w-full space-y-3 mt-2">
<input
type="text"
className="input input-bordered w-full"
placeholder="Enter your license key"
value={key}
onChange={(e) => { setKey(e.target.value); setError(null); }}
onKeyDown={handleKeyDown}
disabled={isSubmitting}
autoFocus
/>

{error && (
<p className="text-error text-sm text-left">{error}</p>
)}

<button
className="btn btn-primary w-full"
onClick={handleSubmit}
disabled={isSubmitting || !key.trim()}
>
{isSubmitting ? 'Activating...' : 'Activate License'}
</button>
</div>

<div className="divider text-base-content/40 text-xs my-1">or</div>

<a
href="https://claude-pilot.com/#pricing"
target="_blank"
rel="noopener noreferrer"
className="btn btn-outline btn-sm w-full"
>
Get a License
</a>

<p className="text-base-content/40 text-xs mt-2">
Visit{' '}
<a
href="https://claude-pilot.com"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
claude-pilot.com
</a>
{' '}to learn more about Claude Pilot.
</p>
</div>
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions console/src/ui/viewer/hooks/useLicense.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export function useLicense(): UseLicenseResult {

useEffect(() => {
fetchLicense();
const interval = setInterval(() => fetchLicense(true), 60_000);
return () => clearInterval(interval);
}, [fetchLicense]);

const refetch = useCallback(() => fetchLicense(true), [fetchLicense]);
Expand Down
2 changes: 1 addition & 1 deletion console/tests/context/cross-session-isolation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ describe("Cross-session memory isolation (integration)", () => {
});
});

describe("Scenario: Session with handover (new content_session_id, same plan)", () => {
describe("Scenario: Continued session (new content_session_id, same plan)", () => {
it("sees observations from all sessions associated with the same plan", () => {
store.createSDKSession("cc-uuid-session-1-continued", PROJECT, "continue auth");
store.updateMemorySessionId(4, "mem-session-1-cont");
Expand Down
96 changes: 96 additions & 0 deletions console/tests/ui/license-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Tests for LicenseGate component
*
* Tests the full-page license gate screen that blocks console access
* when no valid license is present.
*/
import { describe, it, expect } from "bun:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { LicenseGate } from "../../src/ui/viewer/components/LicenseGate.js";
import type { LicenseResponse } from "../../src/services/worker/http/routes/LicenseRoutes.js";

function renderGate(license: LicenseResponse | null) {
return renderToStaticMarkup(
React.createElement(LicenseGate, { license, onActivated: () => {} }),
);
}

describe("LicenseGate", () => {
it("should render license required title when no license", () => {
const html = renderGate({
valid: false,
tier: null,
email: null,
daysRemaining: null,
isExpired: false,
});

expect(html).toContain("License Required");
expect(html).toContain("Enter your license key");
});

it("should render expired title when license is expired", () => {
const html = renderGate({
valid: false,
tier: "trial",
email: "user@example.com",
daysRemaining: null,
isExpired: true,
});

expect(html).toContain("License Expired");
expect(html).toContain("has expired");
});

it("should contain activation input", () => {
const html = renderGate(null);

expect(html).toContain("Enter your license key");
expect(html).toContain("Activate License");
});

it("should contain link to pricing page", () => {
const html = renderGate(null);

expect(html).toContain("claude-pilot.com/#pricing");
expect(html).toContain("Get a License");
});

it("should contain link to main site", () => {
const html = renderGate(null);

expect(html).toContain("claude-pilot.com");
});

it("should render activate button as disabled by default (empty key)", () => {
const html = renderGate(null);

expect(html).toContain("disabled");
expect(html).toContain("Activate License");
});

it("should use lock icon for no license", () => {
const html = renderGate({
valid: false,
tier: null,
email: null,
daysRemaining: null,
isExpired: false,
});

expect(html).toContain("\u{1F512}");
});

it("should use prohibited icon for expired license", () => {
const html = renderGate({
valid: false,
tier: "trial",
email: null,
daysRemaining: null,
isExpired: true,
});

expect(html).toContain("\u{1F6AB}");
});
});
3 changes: 1 addition & 2 deletions docs/site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,7 @@
"Spec-Driven Development - Plan, approve, implement, verify workflow",
"Quick Mode - Fast bug fixes and small changes",
"Semantic Code Search - Find code by meaning with Vexor",
"Persistent Memory - Context carries across sessions",
"Endless Mode - Seamless session continuity",
"Persistent Memory - Context carries across sessions via Pilot Console",
"Dev Container Support - Works with VS Code, Cursor, Windsurf",
"Python & TypeScript - Quality hooks and linting tools"
],
Expand Down
4 changes: 2 additions & 2 deletions docs/site/src/components/AgentRoster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,13 @@ const agents = [
desc: "Persistent observations across sessions. Past decisions, debugging context, and learnings — always available.",
},
{
name: "ENDLESS",
name: "CONTEXT",
role: "Session Manager",
icon: InfinityIcon,
color: "text-amber-400",
bgColor: "bg-amber-400/10",
borderColor: "border-amber-400/30",
desc: "Monitors context usage and auto-hands off at critical thresholds. No work lost, ever.",
desc: "Monitors context usage. Hooks capture plan state and task progress before compaction, then restore it after — no work lost, ever.",
},
{
name: "PLANNER",
Expand Down
2 changes: 1 addition & 1 deletion docs/site/src/components/ComparisonSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const painSolution = [
{
audience: "Losing context mid-task",
pain: ["Context degrades halfway through", "Every session starts from scratch", "Manual copy-paste to continue"],
solution: ["Endless Mode auto-hands off", "Persistent memory across sessions", "Seamless continuation files"],
solution: ["Hooks capture and restore state across compaction", "Persistent memory across sessions", "Context monitor warns before limits hit"],
},
{
audience: "Inconsistent code quality",
Expand Down
Loading
Loading