Initialize repository with harness code and assets

Initial import of all source code, config, and README assets: the
packages workspace (cli, core, server, web, docs, landing, skills),
build scripts, tooling config, and CI workflows.

Includes the data-layout revision made on this branch: the local data
root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the
installer keeps its binaries in ~/.penguin), and every Agent lives
under <project>/agents/<agent>/ — path helpers, the three
agent-enumeration scans, the system prompt, built-in Skills, tests
and docs all follow the new layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
Yaowei Zheng
2026-07-19 14:06:53 +08:00
committed by GitHub
parent 056bed7aeb
commit 45bfae6e94
543 changed files with 92949 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
/**
* fetch wrapper: JSON request/response, unified errors -> ApiError,
* same-origin cookie auth (credentials: same-origin; CSRF relies on SameSite=Lax + JSON
* Content-Type, see server README).
*
* When the session becomes invalid (server 401, e.g. database rebuilt, cookie expired),
* notifies AuthProvider to clear the current user, letting the route guard redirect to the
* login page — instead of each page popping its own "unauthorized" error.
*/
import { S } from "../lib/strings";
/** Unified API error: carries the HTTP status code and server error code (server error body {error:{code,message}}). */
export class ApiError extends Error {
readonly status: number;
readonly code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
}
}
/** Session-invalidation callback (registered by AuthProvider; not triggered by 401s from the login/register endpoints themselves). */
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(handler: (() => void) | null): void {
onUnauthorized = handler;
}
/** 401/409 from auth endpoints themselves are business failures (e.g. wrong password) and must not trigger a global logout. */
function isAuthEndpoint(path: string): boolean {
return path.startsWith("/api/auth/");
}
export interface ApiFetchOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
/** JSON request body (auto-serialized with Content-Type: application/json). */
body?: unknown;
/** Query parameters (undefined values are skipped). */
query?: Record<string, string | number | undefined>;
}
/** Makes an API request; non-2xx responses uniformly throw ApiError; 204/empty body returns undefined. */
export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}): Promise<T> {
let url = path;
if (options.query) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(options.query)) {
if (value !== undefined) params.set(key, String(value));
}
const qs = params.toString();
if (qs) url += `?${qs}`;
}
let response: Response;
try {
response = await fetch(url, {
method: options.method ?? "GET",
credentials: "same-origin",
...(options.body !== undefined
? {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(options.body),
}
: {}),
});
} catch {
throw new ApiError(0, "network_error", S.errors.networkError);
}
if (!response.ok) {
let code = "http_error";
let message: string = S.common.unknownError;
try {
const body = (await response.json()) as { error?: { code?: string; message?: string } };
if (body.error?.code) code = body.error.code;
if (body.error?.message) message = body.error.message;
} catch {
// Non-JSON error body: fall back to the default message.
}
if (response.status === 401 && !isAuthEndpoint(path)) onUnauthorized?.();
throw new ApiError(response.status, code, message);
}
if (response.status === 204) return undefined as T;
const text = await response.text();
if (!text) return undefined as T;
return JSON.parse(text) as T;
}
+424
View File
@@ -0,0 +1,424 @@
/**
* API endpoint wrappers: one function per API.
* DTO types come from @prismshadow/penguin-server/api (**type import only**, resolved via
* tsconfig paths to the server contract file types.ts; must not be a value import — server
* code must not enter the browser bundle).
*/
import type {
AdminPasswordResetRequest,
AdminUserCreateRequest,
AdminUserCreateResponse,
AdminUsersResponse,
AgentConfigResponse,
AgentConfigUpdateRequest,
AgentCreateRequest,
AgentCreateResponse,
AgentImportRequest,
AgentImportResponse,
AgentSkillsResponse,
AgentsResponse,
AgentTracesResponse,
ApprovalDecisionRequest,
AuthLoginRequest,
AuthResponse,
BenchmarksResponse,
DirListResponse,
FilesStatRequest,
FilesStatResponse,
MeResponse,
MemberAddRequest,
MemberAddResponse,
MembersResponse,
MessagesResponse,
ModelsResponse,
ModelsUpdateRequest,
ModelTestRequest,
ModelTestResponse,
PasswordChangeRequest,
PrefsResponse,
ProjectCreateRequest,
ProjectCreateResponse,
ProjectsResponse,
ScheduleItem,
SchedulesResponse,
ScheduleUpsertRequest,
SessionCreateRequest,
SessionCreateResponse,
SessionPatchRequest,
SessionResponse,
SessionsResponse,
SessionTracesResponse,
SkillInstallRequest,
SkillLibraryResponse,
TaskCreateRequest,
TaskCreateResponse,
TraceAnalysisResponse,
TraceEventsResponse,
UiPrefs,
UsageGroupBy,
UsageResponse,
VaultResponse,
VaultUpdateRequest,
WorkspaceFilesResponse,
} from "@prismshadow/penguin-server/api";
import { apiFetch } from "./client";
// Auth & user -----------------------------------------------------------------
export const login = (body: AuthLoginRequest) =>
apiFetch<AuthResponse>("/api/auth/login", { method: "POST", body });
export const logout = () => apiFetch<void>("/api/auth/logout", { method: "POST", body: {} });
export const getMe = () => apiFetch<MeResponse>("/api/me");
export const changePassword = (body: PasswordChangeRequest) =>
apiFetch<void>("/api/me/password", { method: "PUT", body });
export const getPrefs = () => apiFetch<PrefsResponse>("/api/me/prefs");
export const putPrefs = (prefs: UiPrefs) =>
apiFetch<PrefsResponse>("/api/me/prefs", { method: "PUT", body: prefs });
// Admin user management (admin only) -----------------------------------------------------
export const adminListUsers = () => apiFetch<AdminUsersResponse>("/api/admin/users");
export const adminCreateUser = (body: AdminUserCreateRequest) =>
apiFetch<AdminUserCreateResponse>("/api/admin/users", { method: "POST", body });
export const adminResetPassword = (userId: string, body: AdminPasswordResetRequest) =>
apiFetch<void>(`/api/admin/users/${encodeURIComponent(userId)}/password`, {
method: "POST",
body,
});
export const adminDeleteUser = (userId: string) =>
apiFetch<void>(`/api/admin/users/${encodeURIComponent(userId)}`, { method: "DELETE" });
// Project & members --------------------------------------------------------------
export const listProjects = () => apiFetch<ProjectsResponse>("/api/projects");
export const createProject = (body: ProjectCreateRequest) =>
apiFetch<ProjectCreateResponse>("/api/projects", { method: "POST", body });
export const deleteProject = (projectId: string) =>
apiFetch<void>(`/api/projects/${encodeURIComponent(projectId)}`, { method: "DELETE" });
export const listMembers = (projectId: string) =>
apiFetch<MembersResponse>(`/api/projects/${encodeURIComponent(projectId)}/members`);
export const addMember = (projectId: string, body: MemberAddRequest) =>
apiFetch<MemberAddResponse>(`/api/projects/${encodeURIComponent(projectId)}/members`, {
method: "POST",
body,
});
export const removeMember = (projectId: string, username: string) =>
apiFetch<void>(
`/api/projects/${encodeURIComponent(projectId)}/members/${encodeURIComponent(username)}`,
{ method: "DELETE" },
);
// Model configuration -------------------------------------------------------------------
export const getModels = (projectId: string) =>
apiFetch<ModelsResponse>(`/api/projects/${encodeURIComponent(projectId)}/models`);
export const putModels = (projectId: string, body: ModelsUpdateRequest) =>
apiFetch<ModelsResponse>(`/api/projects/${encodeURIComponent(projectId)}/models`, {
method: "PUT",
body,
});
/** Connectivity test: model reference (provider, modelId) is passed in the request body (may include an unsaved apiKey / baseUrl). */
export const testModel = (projectId: string, body: ModelTestRequest) =>
apiFetch<ModelTestResponse>(`/api/projects/${encodeURIComponent(projectId)}/models/test`, {
method: "POST",
body,
});
// Vault environment variables (Agent-level) -------------------------------------------------------
export const getVault = (projectId: string, agentId: string) =>
apiFetch<VaultResponse>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/vault`,
);
export const putVault = (projectId: string, agentId: string, body: VaultUpdateRequest) =>
apiFetch<VaultResponse>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/vault`,
{ method: "PUT", body },
);
// Agent & its configuration ----------------------------------------------------------------
export const listAgents = (projectId: string) =>
apiFetch<AgentsResponse>(`/api/projects/${encodeURIComponent(projectId)}/agents`);
export const createAgent = (projectId: string, body: AgentCreateRequest) =>
apiFetch<AgentCreateResponse>(`/api/projects/${encodeURIComponent(projectId)}/agents`, {
method: "POST",
body,
});
export const getAgentConfig = (projectId: string, agentId: string) =>
apiFetch<AgentConfigResponse>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/config`,
);
export const putAgentConfig = (
projectId: string,
agentId: string,
body: AgentConfigUpdateRequest,
) =>
apiFetch<AgentConfigResponse>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/config`,
{ method: "PUT", body },
);
export const getAgentTraces = (projectId: string, agentId: string) =>
apiFetch<AgentTracesResponse>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/traces`,
);
// Session ---------------------------------------------------------------------
export const listSessions = (projectId: string, agentId: string) =>
apiFetch<SessionsResponse>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/sessions`,
);
/** Server directory browsing: `path` is an absolute path; empty means start from the server's home directory. */
export const listDirs = (projectId: string, path = "") =>
apiFetch<DirListResponse>(
`/api/projects/${encodeURIComponent(projectId)}/dirs?path=${encodeURIComponent(path)}`,
);
export const createSession = (projectId: string, agentId: string, body: SessionCreateRequest) =>
apiFetch<SessionCreateResponse>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/sessions`,
{ method: "POST", body },
);
export const getSession = (sessionId: string) =>
apiFetch<SessionResponse>(`/api/sessions/${encodeURIComponent(sessionId)}`);
export const patchSession = (sessionId: string, body: SessionPatchRequest) =>
apiFetch<SessionResponse>(`/api/sessions/${encodeURIComponent(sessionId)}`, {
method: "PATCH",
body,
});
export const deleteSession = (sessionId: string) =>
apiFetch<void>(`/api/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" });
export const getMessages = (sessionId: string) =>
apiFetch<MessagesResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/messages`);
// Task execution, approval, abort, compaction ------------------------------------------------------
export const postTask = (sessionId: string, body: TaskCreateRequest) =>
apiFetch<TaskCreateResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/tasks`, {
method: "POST",
body,
});
export const postApproval = (
sessionId: string,
toolCallId: string,
body: ApprovalDecisionRequest,
) =>
apiFetch<void>(
`/api/sessions/${encodeURIComponent(sessionId)}/approvals/${encodeURIComponent(toolCallId)}`,
{ method: "POST", body },
);
export const postAbort = (sessionId: string) =>
apiFetch<void>(`/api/sessions/${encodeURIComponent(sessionId)}/abort`, {
method: "POST",
body: {},
});
export const postCompact = (sessionId: string) =>
// Same shape as tasks: the response carries the actual current session_id (a new id after self-healing; the frontend updates its route accordingly).
apiFetch<TaskCreateResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/compact`, {
method: "POST",
body: {},
});
// Trace browsing & performance analysis -----------------------------------------------------------
export const getSessionTraces = (sessionId: string) =>
apiFetch<SessionTracesResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/traces`);
export const getTraceEvents = (sessionId: string, index: number, offset: number, limit: number) =>
apiFetch<TraceEventsResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/traces/${index}`, {
query: { offset, limit },
});
export const getTraceAnalysis = (sessionId: string, index: number) =>
apiFetch<TraceAnalysisResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/traces/${index}/analysis`,
);
// Agent-level Trace details (read-only, independent of sessions-table registration): the Trace
// page's directory tree comes from an Agent-level scan (including subagent child Sessions and
// Sessions created by the CLI); details go through the Agent-level endpoint to avoid 404s for
// unregistered sessions.
export const getAgentTraceEvents = (
projectId: string,
agentId: string,
sessionId: string,
index: number,
offset: number,
limit: number,
) =>
apiFetch<TraceEventsResponse>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}` +
`/traces/${encodeURIComponent(sessionId)}/${index}`,
{ query: { offset, limit } },
);
export const getAgentTraceAnalysis = (
projectId: string,
agentId: string,
sessionId: string,
index: number,
) =>
apiFetch<TraceAnalysisResponse>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}` +
`/traces/${encodeURIComponent(sessionId)}/${index}/analysis`,
);
// Usage statistics ----------------------------------------------------------------------
export const getUsage = (
projectId: string,
params: {
from?: string;
to?: string;
groupBy: UsageGroupBy;
agentId?: string;
/** Model filters are given as a pair (only takes effect when both provider and modelId are supplied). */
provider?: string;
modelId?: string;
},
) =>
apiFetch<UsageResponse>(`/api/projects/${encodeURIComponent(projectId)}/usage`, {
query: {
from: params.from,
to: params.to,
groupBy: params.groupBy,
agentId: params.agentId,
provider: params.provider,
modelId: params.modelId,
},
});
// Agent deletion & Workspace files --------------------------------------------------
export const deleteAgent = (projectId: string, agentId: string) =>
apiFetch<void>(`/api/projects/${projectId}/agents/${agentId}`, { method: "DELETE" });
export const listWorkspaceFiles = (sessionId: string, path: string) =>
apiFetch<WorkspaceFilesResponse>(`/api/sessions/${sessionId}/files`, { query: { path } });
/** File content URL (inline preview / download=1 triggers download; usable directly in <a>/<img>/fetch). */
export const workspaceFileUrl = (sessionId: string, path: string, download = false): string =>
`/api/sessions/${sessionId}/files/content?path=${encodeURIComponent(path)}${download ? "&download=1" : ""}`;
export const uploadWorkspaceFile = (sessionId: string, path: string, dataBase64: string) =>
apiFetch<void>(`/api/sessions/${sessionId}/files/content`, {
method: "PUT",
body: { dataBase64 },
query: { path },
});
/** Batch file-existence check (message file cards): both out-of-bounds and missing paths simply don't appear in `existing`; always returns 200. */
export const statSessionFiles = (sessionId: string, paths: string[]) =>
apiFetch<FilesStatResponse>(`/api/sessions/${sessionId}/files/stat`, {
method: "POST",
body: { paths } satisfies FilesStatRequest,
});
// Scheduled tasks ----------------------------------------------------------------------
export const listSchedules = (projectId: string, agentId: string) =>
apiFetch<SchedulesResponse>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/schedules`,
);
export const createSchedule = (
projectId: string,
agentId: string,
body: ScheduleUpsertRequest & { name: string },
) =>
apiFetch<ScheduleItem>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/schedules`,
{ method: "POST", body },
);
export const updateSchedule = (
projectId: string,
agentId: string,
name: string,
body: ScheduleUpsertRequest,
) =>
apiFetch<ScheduleItem>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}` +
`/schedules/${encodeURIComponent(name)}`,
{ method: "PUT", body },
);
export const deleteSchedule = (projectId: string, agentId: string, name: string) =>
apiFetch<void>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}` +
`/schedules/${encodeURIComponent(name)}`,
{ method: "DELETE" },
);
// Skill library & Agent-installed Skills ------------------------------------------------------
/** Skill library (available to any logged-in user): groups and metadata, excludes SKILL.md body content. */
export const getSkillLibrary = () => apiFetch<SkillLibraryResponse>("/api/skills");
export const getAgentSkills = (projectId: string, agentId: string) =>
apiFetch<AgentSkillsResponse>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/skills`,
);
/** Installs (if already installed, overwrites with the library content); 201 returns the Agent's latest installed list. */
export const installAgentSkills = (projectId: string, agentId: string, names: string[]) =>
apiFetch<AgentSkillsResponse>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/skills`,
{ method: "POST", body: { names } satisfies SkillInstallRequest },
);
export const removeAgentSkill = (projectId: string, agentId: string, name: string) =>
apiFetch<void>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}` +
`/skills/${encodeURIComponent(name)}`,
{ method: "DELETE" },
);
// Benchmark scoring (read-only display) -------------------------------------------------------
export const listBenchmarks = (projectId: string, agentId: string) =>
apiFetch<BenchmarksResponse>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/benchmarks`,
);
// Agent State snapshot export / import ------------------------------------------------------
/** Snapshot bundle (tar.gz) download URL: the server sets Content-Disposition attachment, usable directly in <a download>. */
export const agentExportUrl = (projectId: string, agentId: string): string =>
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/export`;
export const importAgent = (projectId: string, agentId: string, body: AgentImportRequest) =>
apiFetch<AgentImportResponse>(
`/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/import`,
{ method: "POST", body },
);
+65
View File
@@ -0,0 +1,65 @@
/**
* SSE (EventSource) wrapper.
*
* - OmniMessage uses the default event (no `event:` line); data is the message envelope as
* raw JSON;
* - Server events use `event: server_event` (approval_request / task_state / resync_required / hello);
* - EventSource can't set custom request headers, so auth relies on same-origin cookies; on
* disconnect, the browser auto-reconnects and attaches a `Last-Event-ID` header (the server
* replays from its ring buffer; if the event was already evicted, it pushes resync_required
* instead).
* Docs: /docs/server-api § "Streaming (SSE)".
*/
import type { OmniMessage } from "@prismshadow/penguin-core/omnimessage";
import type { ServerEvent } from "@prismshadow/penguin-server/api";
export interface StreamHandlers {
/** A single OmniMessage (full/streaming/event, envelope as-is). */
onOmniMessage: (msg: OmniMessage) => void;
/** A single server event. */
onServerEvent: (event: ServerEvent) => void;
/** Connection established (including a successful auto-reconnect). */
onOpen?: () => void;
/**
* Connection error. `closed` is true when the browser has deemed the connection fatally
* broken and closed it (e.g. the handshake returned 401/403, so it won't auto-reconnect);
* when false, the browser will auto-reconnect and no manual handling is needed.
*/
onError?: (closed: boolean) => void;
}
export interface StreamConnection {
close: () => void;
}
function subscribe(url: string, handlers: StreamHandlers): StreamConnection {
const source = new EventSource(url);
source.onmessage = (e: MessageEvent<string>) => {
try {
handlers.onOmniMessage(JSON.parse(e.data) as OmniMessage);
} catch {
// Ignore lines that fail to parse (the protocol guarantees single-line JSON data, so this shouldn't normally happen).
}
};
source.addEventListener("server_event", (e: MessageEvent<string>) => {
try {
handlers.onServerEvent(JSON.parse(e.data) as ServerEvent);
} catch {
// Same as above.
}
});
if (handlers.onOpen) source.onopen = handlers.onOpen;
const { onError } = handlers;
if (onError) source.onerror = () => onError(source.readyState === EventSource.CLOSED);
return { close: () => source.close() };
}
/** Subscribes to a Session's output stream (GET /api/sessions/:sessionId/stream). */
export function openSessionStream(sessionId: string, handlers: StreamHandlers): StreamConnection {
return subscribe(`/api/sessions/${encodeURIComponent(sessionId)}/stream`, handlers);
}
/** Subscribes to the user-level server event stream (GET /api/events; reserved for scheduled-task notifications). */
export function openUserEvents(handlers: StreamHandlers): StreamConnection {
return subscribe("/api/events", handlers);
}