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
@@ -0,0 +1,49 @@
/**
* Session activity sparkline (Agents list card, GitHub-repo-Pulse-graph style): a
* polyline + faint fill of daily active Session counts over the last N days,
* normalized to the max value within the window — shows only relative ups and downs,
* no scale/ticks. All-zero / empty data renders a flat baseline at the bottom. Pure
* SVG, no dependencies; color follows the light/dark theme.
*/
const W = 100;
const H = 30;
const PAD = 2;
export function ActivitySparkline({
data,
label,
className = "",
}: {
data: number[];
label: string;
className?: string;
}) {
const max = Math.max(1, ...data);
const step = data.length > 1 ? (W - 2 * PAD) / (data.length - 1) : 0;
const pts = data.map((v, i) => [PAD + i * step, H - PAD - (v / max) * (H - 2 * PAD)] as const);
const baseline = `${PAD},${H - PAD} ${W - PAD},${H - PAD}`;
const line = pts.length > 1 ? pts.map(([x, y]) => `${x},${y}`).join(" ") : baseline;
const area = pts.length > 1 ? `${line} ${W - PAD},${H - PAD} ${PAD},${H - PAD}` : null;
return (
<svg
width={W}
height={H}
viewBox={`0 0 ${W} ${H}`}
role="img"
aria-label={label}
className={`text-emerald-600 dark:text-emerald-500 ${className}`}
>
<title>{label}</title>
{area !== null && <polygon points={area} fill="currentColor" fillOpacity="0.12" />}
<polyline
points={line}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,736 @@
/**
* Agent settings page: six tabs —
* Overview (name/description/State path/active count/State version + snapshot
* export-import), Prompt (AGENTS.md and system_prompt editors + placeholder
* reference), Runtime (max_turns, model.*, compaction.*), Tools (editable built-in
* tools table, MCP Server read-only JSON), Vault (vault-tab.tsx), Schedule
* (schedules-tab.tsx).
* Save = PUT config (sends only the changed keys; YAML comments are preserved
* server-side).
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { ChangeEvent } from "react";
import { useNavigate, useParams } from "react-router";
import type {
AgentConfigResponse,
AgentConfigUpdateRequest,
AgentCompactionConfigDto,
AgentModelConfigDto,
} from "@prismshadow/penguin-server/api";
import type { ToolDefinitionConfig, ToolPermission } from "@prismshadow/penguin-core/interfaces";
import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings";
import { useDocumentTitle } from "../../lib/use-document-title";
import { useProject } from "../../state/project";
import { Tabs } from "../../components/ui/tabs";
import { Button } from "../../components/ui/button";
import { Input, Textarea } from "../../components/ui/input";
import { OptionMenu, type OptionMenuChoice } from "../../components/ui/option-menu";
import { Modal } from "../../components/ui/modal";
import { Skeleton } from "../../components/ui/skeleton";
import { VaultTab } from "./vault-tab";
import { SchedulesTab } from "./schedules-tab";
type TabKey = "overview" | "prompt" | "runtime" | "tools" | "vault" | "schedules";
/** "" represents not overridden (falls back to the current config), corresponding to the defaultValue placeholder row. */
function withDefaultOption(
entries: ReadonlyArray<readonly [string, string]>,
): ReadonlyArray<OptionMenuChoice<string>> {
return entries.map(([value, description]) => ({
value,
triggerLabel: value || S.agent.defaultValue,
label: value || S.agent.defaultValue,
description,
}));
}
/** Numeric input's string state → number (empty/invalid = undefined, meaning no change). */
function parseNum(s: string): number | undefined {
const trimmed = s.trim();
if (!trimmed) return undefined;
const n = Number(trimmed);
return Number.isFinite(n) ? n : undefined;
}
function numToStr(n: number | undefined): string {
return n === undefined ? "" : String(n);
}
export function AgentSettingsPage() {
// Read inside the component: after a language switch remount, this picks up the current dictionary.
const TABS = [
{ key: "overview", label: S.agent.tabOverview },
{ key: "prompt", label: S.agent.tabPrompt },
{ key: "runtime", label: S.agent.tabRuntime },
{ key: "tools", label: S.agent.tabTools },
{ key: "vault", label: S.agent.tabVault },
{ key: "schedules", label: S.agent.tabSchedules },
] as const;
const navigate = useNavigate();
const params = useParams<{ agentId: string }>();
const agentId = params.agentId ?? "";
useDocumentTitle(S.agent.settings);
const { currentProject, reloadAgents } = useProject();
const projectId = currentProject?.projectId ?? null;
const [data, setData] = useState<AgentConfigResponse | null>(null);
const [tab, setTab] = useState<TabKey>("overview");
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const load = useCallback(() => {
if (!projectId || !agentId) return;
setData(null);
setError(null);
api
.getAgentConfig(projectId, agentId)
.then(setData)
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : S.common.unknownError));
}, [projectId, agentId]);
useEffect(() => {
load();
}, [load]);
/** Snapshot import succeeded: show the new version and reload the whole config (import overwrites the entire Agent State, so every tab's data needs a refresh). */
const onImported = useCallback(
(version: number) => {
setNotice(S.agent.importDone(version));
load();
void reloadAgents();
},
[load, reloadAgents],
);
const save = useCallback(
async (update: AgentConfigUpdateRequest) => {
if (!projectId || !agentId) return;
setError(null);
setNotice(null);
try {
const res = await api.putAgentConfig(projectId, agentId, update);
setData(res);
setNotice(S.common.saved);
// Name/description changes affect the breadcrumb and list display.
if (update.config?.name !== undefined || update.config?.description !== undefined) {
void reloadAgents();
}
} catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError);
}
},
[projectId, agentId, reloadAgents],
);
if (!projectId) return null;
if (error && !data) {
return <p className="p-6 text-sm text-red-600 dark:text-red-400">{error}</p>;
}
if (!data) {
return (
<div className="space-y-3 p-6">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-32 w-full" />
</div>
);
}
return (
<div className="no-scrollbar h-full overflow-y-auto p-4 md:p-6">
<div className="mx-auto max-w-3xl">
<Button
variant="ghost"
size="sm"
onClick={() => navigate("/agents")}
className="-ml-2 mb-3 text-gray-500 dark:text-gray-400"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M15 18l-6-6 6-6" />
<path d="M9 12h12" />
</svg>
{S.agent.backToList}
</Button>
<h1 className="mb-1 text-xl font-semibold">{data.config.name ?? agentId}</h1>
<p className="mb-4 font-mono text-xs text-gray-400">{agentId}</p>
<Tabs items={TABS} active={tab} onChange={setTab} />
<div className="py-4">
{tab === "overview" && (
<OverviewTab data={data} agentId={agentId} onSave={save} onImported={onImported} />
)}
{tab === "prompt" && <PromptTab data={data} onSave={save} />}
{tab === "runtime" && <RuntimeTab data={data} onSave={save} />}
{tab === "tools" && <ToolsTab data={data} onSave={save} />}
{tab === "vault" && <VaultTab agentId={agentId} />}
{tab === "schedules" && <SchedulesTab agentId={agentId} />}
</div>
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
{notice && <p className="text-xs text-emerald-600 dark:text-emerald-400">{notice}</p>}
</div>
</div>
);
}
type SaveFn = (update: AgentConfigUpdateRequest) => Promise<void>;
/** <a download>/<label> version of the button look (matches Button secondary sm; the Button component only renders <button>). */
const TRANSFER_BUTTON_CLASS =
"inline-flex cursor-pointer items-center justify-center gap-1 rounded-md border border-gray-300 " +
"bg-white px-2.5 py-1 text-xs font-medium text-gray-800 transition-colors duration-150 " +
"hover:bg-gray-50 focus-within:ring-2 focus-within:ring-gray-400/30 " +
"dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800";
function OverviewTab({
data,
agentId,
onSave,
onImported,
}: {
data: AgentConfigResponse;
agentId: string;
onSave: SaveFn;
onImported: (version: number) => void;
}) {
const { currentProject } = useProject();
const projectId = currentProject?.projectId ?? null;
const isOwner = currentProject?.role === "owner";
const [name, setName] = useState(data.config.name ?? "");
const [description, setDescription] = useState(data.config.description ?? "");
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
// base64 of the snapshot package pending confirmation for a version conflict (409 version_conflict); non-null shows the confirm modal.
const [conflict, setConflict] = useState<string | null>(null);
const submit = () => {
const config: NonNullable<AgentConfigUpdateRequest["config"]> = {};
if (name.trim() !== (data.config.name ?? "")) config.name = name.trim();
if (description.trim() !== (data.config.description ?? "")) {
config.description = description.trim();
}
if (Object.keys(config).length === 0) return;
void onSave({ config });
};
const runImport = async (dataBase64: string, confirm: boolean) => {
if (!projectId) return;
setImporting(true);
setImportError(null);
try {
const res = await api.importAgent(projectId, agentId, {
dataBase64,
...(confirm ? { confirm: true } : {}),
});
setConflict(null);
onImported(res.version);
} catch (e) {
if (e instanceof ApiError && e.status === 409 && e.code === "version_conflict") {
setConflict(dataBase64); // resend with confirm: true after confirming
} else {
setConflict(null);
setImportError(e instanceof ApiError ? e.message : S.common.unknownError);
}
} finally {
setImporting(false);
}
};
const onPickFile = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setImportError(null);
const reader = new FileReader();
reader.onload = () => {
const url = reader.result as string;
void runImport(url.slice(url.indexOf(",") + 1), false); // strip the data:...;base64, prefix
};
reader.onerror = () => setImportError(S.common.unknownError);
reader.readAsDataURL(file);
};
return (
<div className="space-y-4">
<Input
size="sm"
label={S.agent.name}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Textarea
label={S.agent.description}
size="sm"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div>
<p className="mb-1 text-xs font-medium text-gray-500">{S.agent.stateDir}</p>
<p className="break-all font-mono text-xs text-gray-500 dark:text-gray-400">
{data.stateDir}
</p>
</div>
<div>
<p className="mb-1 text-xs font-medium text-gray-500">{S.agent.activeSessions}</p>
<p className="text-sm">{data.activeSessionCount}</p>
</div>
<div>
<p className="mb-1 text-xs font-medium text-gray-500">{S.agent.stateVersion}</p>
<p className="font-mono text-sm">v{data.config.version}</p>
</div>
{/* Snapshot export / import: export is available to any member; import overwrites the entire Agent State, visible only to owners. */}
<div>
<p className="mb-1 text-xs font-medium text-gray-500">{S.agent.transferTitle}</p>
<p className="mb-2 text-xs text-gray-500 dark:text-gray-400">{S.agent.transferDesc}</p>
<div className="flex flex-wrap items-center gap-2">
{projectId && (
<a
href={api.agentExportUrl(projectId, agentId)}
download
className={TRANSFER_BUTTON_CLASS}
>
{S.agent.exportSnapshot}
</a>
)}
{isOwner && (
<label
className={`${TRANSFER_BUTTON_CLASS} ${importing ? "pointer-events-none opacity-60" : ""}`}
>
{/* sr-only rather than hidden: keeps it keyboard-Tab-focusable (same as the workspace-browser upload). */}
<input
type="file"
accept=".tar.gz,.tgz"
className="sr-only"
disabled={importing}
onChange={onPickFile}
/>
{importing ? S.agent.importing : S.agent.importSnapshot}
</label>
)}
</div>
{importError && (
<p className="mt-1.5 text-xs text-red-600 dark:text-red-400">{importError}</p>
)}
</div>
<Button size="sm" variant="primary" onClick={submit}>
{S.common.save}
</Button>
{/* Version conflict confirmation: resend the same package with confirm: true after confirming. */}
<Modal
open={conflict !== null}
title={S.agent.importConflictTitle}
onClose={() => setConflict(null)}
footer={
<>
<Button onClick={() => setConflict(null)}>{S.common.cancel}</Button>
<Button
variant="danger"
disabled={importing}
onClick={() => {
if (conflict !== null) void runImport(conflict, true);
}}
>
{S.common.confirm}
</Button>
</>
}
>
<p className="text-sm text-gray-600 dark:text-gray-300">{S.agent.importConflictBody}</p>
</Modal>
</div>
);
}
function PromptTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn }) {
const [agentsMd, setAgentsMd] = useState(data.agentsMd);
const [systemPrompt, setSystemPrompt] = useState(data.config.systemPrompt);
const promptRef = useRef<HTMLTextAreaElement>(null);
const submit = () => {
const update: AgentConfigUpdateRequest = {};
if (agentsMd !== data.agentsMd) update.agentsMd = agentsMd;
if (systemPrompt !== data.config.systemPrompt) update.config = { systemPrompt };
if (update.agentsMd === undefined && update.config === undefined) return;
void onSave(update);
};
/**
* Quickly insert a placeholder at the system_prompt cursor position (appends to the
* end when unfocused). Prefers execCommand insertText — it writes to the browser's
* undo stack (undoable with Ctrl/⌘+Z) and fires an input event, which the
* controlled onChange syncs into state; falls back to directly mutating state when
* unsupported (no undo).
*/
const insertPlaceholder = (ph: string) => {
const el = promptRef.current;
if (el) {
el.focus();
// execCommand is deprecated but still the only available way to preserve the textarea's native undo stack.
const inserted = document.execCommand?.("insertText", false, ph);
if (inserted) return; // onChange will update state from e.target.value
}
const start = el ? el.selectionStart : systemPrompt.length;
const end = el ? el.selectionEnd : systemPrompt.length;
setSystemPrompt(systemPrompt.slice(0, start) + ph + systemPrompt.slice(end));
requestAnimationFrame(() => {
if (!el) return;
el.focus();
const caret = start + ph.length;
el.setSelectionRange(caret, caret);
});
};
return (
<div className="space-y-4">
<Textarea
label={S.agent.agentsMd}
mono
size="sm"
rows={14}
value={agentsMd}
onChange={(e) => setAgentsMd(e.target.value)}
/>
<Textarea
ref={promptRef}
label={S.agent.systemPrompt}
mono
size="sm"
rows={12}
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
/>
<div className="rounded-md border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-900">
<p className="mb-2 text-xs font-semibold text-gray-500">{S.agent.placeholdersTitle}</p>
<ul className="space-y-1">
{S.agent.placeholders.map(([ph, desc]) => (
<li key={ph} className="flex items-center gap-3 text-xs">
<button
type="button"
onClick={() => insertPlaceholder(ph)}
title={S.agent.insertPlaceholder}
className="shrink-0 rounded border border-gray-200 bg-white px-1.5 py-0.5 font-mono font-semibold text-gray-800 transition-colors duration-150 hover:border-gray-400 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:border-gray-500 dark:hover:bg-gray-700"
>
{ph}
</button>
<span className="text-gray-500 dark:text-gray-400">{desc}</span>
</li>
))}
</ul>
</div>
<Button size="sm" variant="primary" onClick={submit}>
{S.common.save}
</Button>
</div>
);
}
function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn }) {
const cfg = data.config;
const [maxTurns, setMaxTurns] = useState(numToStr(cfg.maxTurns));
const [maxTokens, setMaxTokens] = useState(numToStr(cfg.model?.maxTokens));
const [thinkingLevel, setThinkingLevel] = useState(cfg.model?.thinkingLevel ?? "");
const [timeoutMs, setTimeoutMs] = useState(numToStr(cfg.model?.timeoutMs));
const [maxContextLength, setMaxContextLength] = useState(
numToStr(cfg.compaction?.maxContextLength),
);
const [maxSessionTurns, setMaxSessionTurns] = useState(numToStr(cfg.compaction?.maxSessionTurns));
const [mode, setMode] = useState(cfg.compaction?.mode ?? "");
const [prompt, setPrompt] = useState(cfg.compaction?.prompt ?? "");
const [localError, setLocalError] = useState<string | null>(null);
const submit = () => {
setLocalError(null);
const config: NonNullable<AgentConfigUpdateRequest["config"]> = {};
const mt = parseNum(maxTurns);
if (mt !== undefined && mt !== cfg.maxTurns) {
if (mt <= 0 && mt !== -1) {
setLocalError(S.agent.maxTurnsInvalid);
return;
}
config.maxTurns = mt;
}
const model: AgentModelConfigDto = {};
const tok = parseNum(maxTokens);
if (tok !== undefined && tok !== cfg.model?.maxTokens) model.maxTokens = tok;
if (thinkingLevel && thinkingLevel !== (cfg.model?.thinkingLevel ?? "")) {
model.thinkingLevel = thinkingLevel as AgentModelConfigDto["thinkingLevel"];
}
const tmo = parseNum(timeoutMs);
if (tmo !== undefined && tmo !== cfg.model?.timeoutMs) {
if (tmo <= 0 && tmo !== -1) {
setLocalError(S.agent.timeoutInvalid);
return;
}
model.timeoutMs = tmo;
}
if (Object.keys(model).length > 0) config.model = model;
const compaction: AgentCompactionConfigDto = {};
const mcl = parseNum(maxContextLength);
if (mcl !== undefined && mcl !== cfg.compaction?.maxContextLength) {
compaction.maxContextLength = mcl;
}
const mst = parseNum(maxSessionTurns);
if (mst !== undefined && mst !== cfg.compaction?.maxSessionTurns) {
compaction.maxSessionTurns = mst;
}
if (mode && mode !== (cfg.compaction?.mode ?? "")) {
compaction.mode = mode as AgentCompactionConfigDto["mode"];
}
if (prompt !== (cfg.compaction?.prompt ?? "")) compaction.prompt = prompt;
if (Object.keys(compaction).length > 0) config.compaction = compaction;
if (Object.keys(config).length === 0) return;
void onSave({ config });
};
// S is reassigned on language switch (live binding), so read it during render rather than hoisting to a module-level constant.
const thinkingLevelOptions = withDefaultOption(S.agent.thinkingLevelOptions);
const compactionModeOptions = withDefaultOption(S.agent.compactionModeOptions);
return (
<div className="space-y-4">
<div className="overflow-hidden rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
<div className="p-3">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<Input
label={S.agent.maxTurns}
size="sm"
value={maxTurns}
onChange={(e) => setMaxTurns(e.target.value)}
inputMode="numeric"
className="font-mono"
/>
<Input
label={S.agent.maxTokens}
size="sm"
value={maxTokens}
onChange={(e) => setMaxTokens(e.target.value)}
inputMode="numeric"
className="font-mono"
/>
<OptionMenu
label={S.agent.thinkingLevel}
fullWidth
size="sm"
value={thinkingLevel}
onChange={setThinkingLevel}
options={thinkingLevelOptions}
/>
<Input
label={S.agent.timeoutMs}
hint={S.agent.timeoutMsHint}
size="sm"
value={timeoutMs}
onChange={(e) => setTimeoutMs(e.target.value)}
inputMode="numeric"
className="font-mono"
/>
</div>
</div>
<div className="border-t border-b border-gray-200 bg-gray-50/80 px-3 py-2 text-xs font-semibold text-gray-500 dark:border-gray-800 dark:bg-gray-900">
{S.agent.compaction}
</div>
<div className="p-3">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<Input
label={S.agent.maxContextLength}
hint={S.agent.maxContextLengthHint}
size="sm"
value={maxContextLength}
onChange={(e) => setMaxContextLength(e.target.value)}
inputMode="numeric"
className="font-mono"
/>
<Input
label={S.agent.maxSessionTurns}
hint={S.agent.maxSessionTurnsHint}
size="sm"
value={maxSessionTurns}
onChange={(e) => setMaxSessionTurns(e.target.value)}
inputMode="numeric"
className="font-mono"
/>
<OptionMenu
label={S.agent.compactionMode}
fullWidth
size="sm"
value={mode}
onChange={setMode}
options={compactionModeOptions}
/>
</div>
</div>
<div className="border-t border-b border-gray-200 bg-gray-50/80 px-3 py-2 text-xs font-semibold text-gray-500 dark:border-gray-800 dark:bg-gray-900">
{S.agent.compactionPrompt}
</div>
<div className="p-3">
<Textarea
aria-label={S.agent.compactionPrompt}
mono
size="sm"
rows={4}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
</div>
</div>
{localError && <p className="text-xs text-red-600 dark:text-red-400">{localError}</p>}
<Button size="sm" variant="primary" onClick={submit}>
{S.common.save}
</Button>
</div>
);
}
/** Local edit state for a tool row: numeric columns use string state (can be cleared then re-entered; empty string = clear the override and revert to default). */
interface ToolRowState {
base: ToolDefinitionConfig;
timeoutMs: string;
maxOutputLength: string;
}
function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn }) {
// S is reassigned on language switch (live binding), so read it during render rather than hoisting to a module-level constant.
const permissionOptions: ReadonlyArray<OptionMenuChoice<ToolPermission>> = [
{
value: "r",
triggerLabel: "r",
label: S.agent.permissionReadLabel,
description: S.agent.permissionReadDescription,
},
{
value: "rw",
triggerLabel: "rw",
label: S.agent.permissionReadWriteLabel,
description: S.agent.permissionReadWriteDescription,
},
];
const [rows, setRows] = useState<ToolRowState[]>(() =>
data.config.toolsBuiltin.map((t) => ({
base: { ...t },
timeoutMs: numToStr(t.timeoutMs),
maxOutputLength: numToStr(t.maxOutputLength),
})),
);
const [localError, setLocalError] = useState<string | null>(null);
const update = (index: number, patch: Partial<ToolRowState>) => {
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, ...patch } : r)));
};
const submit = () => {
setLocalError(null);
// toolsBuiltin is submitted as a full table: an empty string omits that key (revert to default); non-empty values are validated per the server's rules.
const tools: ToolDefinitionConfig[] = [];
for (const row of rows) {
const tool: ToolDefinitionConfig = { ...row.base };
delete tool.timeoutMs;
delete tool.maxOutputLength;
const timeout = row.timeoutMs.trim();
if (timeout) {
const n = Number(timeout);
if (!Number.isInteger(n) || (n <= 0 && n !== -1)) {
setLocalError(S.agent.toolFieldInvalid(row.base.name, "timeoutMs"));
return;
}
tool.timeoutMs = n;
}
const maxOutput = row.maxOutputLength.trim();
if (maxOutput) {
const n = Number(maxOutput);
if (!Number.isInteger(n) || (n <= 0 && n !== -1)) {
setLocalError(S.agent.toolFieldInvalid(row.base.name, "maxOutputLength"));
return;
}
tool.maxOutputLength = n;
}
tools.push(tool);
}
void onSave({ config: { toolsBuiltin: tools } });
};
return (
<div className="space-y-4">
<div className="overflow-x-auto overflow-y-clip rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
<table className="w-full min-w-[520px] text-left text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50/80 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900">
<th className="px-3 py-2">{S.agent.toolName}</th>
<th className="px-3 py-2">{S.agent.toolPermission}</th>
<th className="px-3 py-2">{S.agent.toolTimeout}</th>
<th className="px-3 py-2">{S.agent.toolMaxOutput}</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={row.base.name} className="border-b border-gray-100 dark:border-gray-800/60">
<td className="px-3 py-2 font-mono text-xs">{row.base.name}</td>
<td className="px-3 py-2">
<OptionMenu
mono
size="sm"
aria-label={S.agent.toolPermission}
placeholder={S.agent.defaultValue}
options={permissionOptions}
value={row.base.permission}
onChange={(v) => update(i, { base: { ...row.base, permission: v } })}
/>
</td>
<td className="px-3 py-2">
<Input
size="sm"
value={row.timeoutMs}
inputMode="numeric"
className="font-mono"
onChange={(e) => update(i, { timeoutMs: e.target.value })}
/>
</td>
<td className="px-3 py-2">
<Input
size="sm"
value={row.maxOutputLength}
inputMode="numeric"
className="font-mono"
onChange={(e) => update(i, { maxOutputLength: e.target.value })}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{localError && <p className="text-xs text-red-600 dark:text-red-400">{localError}</p>}
<div>
<p className="mb-1 text-xs font-medium text-gray-500">{S.agent.mcpServers}</p>
<pre className="max-h-64 overflow-auto rounded-md border border-gray-200 bg-gray-50 p-3 text-xs dark:border-gray-800 dark:bg-gray-900">
{JSON.stringify(data.config.mcpServers, null, 2)}
</pre>
</div>
<Button size="sm" variant="primary" onClick={submit}>
{S.common.save}
</Button>
</div>
);
}
@@ -0,0 +1,415 @@
/**
* Agents list page: entry point for creating,
* deleting, and editing Agents. Laid out as GitHub-repo-list-style single-column compact rows:
* one horizontal band of "info | 30-day activity sparkline | button group" per row.
* Info column has three lines: title line (small avatar + bold name + agentId); single-line
* truncated description; and a stats line — icon + number only (Session count / tool count) plus
* relative time (today/yesterday/n days ago), with meaning folded into the hover title.
* Buttons sit to the right of the sparkline: "New Chat" (draft state, same as sidebar group
* header) and "Settings" (goes to settings page) show text labels; "Usage" / "Traces" (deep link
* via ?agentId= to the usage center / trace observability; traces use an eye line icon =
* observability) and "Delete" (with confirmation; built-in Agents show a non-interactive light
* gray placeholder with an undeletable tooltip) are square icon buttons (tooltip shows the full
* name); "Create Agent" only fills in name + description.
*/
import { useState } from "react";
import { useNavigate } from "react-router";
import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings";
import { SEMANTIC_ID_PATTERN } from "../../lib/semantic-id";
import { formatDateTime, formatRelativeDays } from "../../lib/format";
import { useDocumentTitle } from "../../lib/use-document-title";
import { useLocale } from "../../state/locale";
import { agentDisplayName, useProject } from "../../state/project";
import { Button } from "../../components/ui/button";
import { Input, Textarea } from "../../components/ui/input";
import { Modal } from "../../components/ui/modal";
import { Badge } from "../../components/ui/badge";
import { Skeleton, SkeletonCard } from "../../components/ui/skeleton";
import { EmptyState } from "../../components/ui/empty-state";
import { AgentAvatar } from "../../components/ui/agent-avatar";
import { GlyphIcon } from "../../components/ui/glyph-icon";
import { STAT_ICONS } from "../../lib/stat-icons";
import { DRAFT_SESSION_ID } from "../chat/chat-page";
import { ActivitySparkline } from "./activity-sparkline";
/** Built-in Agent shipped with every Project (default_agent only; the server also rejects deletion, so no delete entry point is shown here). */
const BUILTIN_AGENT_IDS = new Set(["default_agent"]);
/** Card button icons (24x24 line path, rendered via GlyphIcon). */
const CARD_ICONS = {
/** New chat (plus sign) */
newChat: "M12 5v14M5 12h14",
/** Settings (gear, same as sidebar user menu) */
settings:
"M10.3 4.3a2 2 0 0 1 3.4 0l.5.8a2 2 0 0 0 1.8 1l1-.1a2 2 0 0 1 1.7 3l-.5.8a2 2 0 0 0 0 2l.5.8a2 2 0 0 1-1.7 3l-1-.1a2 2 0 0 0-1.8 1l-.5.8a2 2 0 0 1-3.4 0l-.5-.8a2 2 0 0 0-1.8-1l-1 .1a2 2 0 0 1-1.7-3l.5-.8a2 2 0 0 0 0-2l-.5-.8a2 2 0 0 1 1.7-3l1 .1a2 2 0 0 0 1.8-1zM12 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4z",
/** Delete (trash can) */
trash:
"M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m3 0l-1 13a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 7m4 4v6m4-6v6",
/** Total session count (chat bubble) */
sessions: "M8 10h8M8 14h5M21 12a9 9 0 1 1-4-7.5",
/** Vault key count (key: bow + teeth) */
vaultKeys: "M15.5 7.5l3 3L22 7l-3-3M21 2l-9.6 9.6M13 15.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z",
/** Schedule count (alarm clock: dial + hands + twin bells, distinct from the plain clock face used for "last modified") */
schedules: "M12 21a7 7 0 1 0 0-14 7 7 0 0 0 0 14zm0-10v3l2 1.5M5 3L2.5 5.5M19 3l2.5 2.5",
/** Usage (bar chart, same as sidebar "Usage Center") */
usage: "M4 20V10m6 10V4m6 16v-7m4 7H2",
/** Traces (eye line icon: observability; follows text color, no fill) */
traces: "M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7zM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z",
} as const;
export function AgentsPage() {
const navigate = useNavigate();
useDocumentTitle(S.nav.agents);
const { locale } = useLocale();
const { currentProject, agents, agentsLoading, reloadAgents, setCurrentAgentId } = useProject();
const [createOpen, setCreateOpen] = useState(false);
const [agentId, setAgentId] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
/** Open the create dialog: don't keep the previous draft, always start from an empty form. */
const openCreate = () => {
setAgentId("");
setName("");
setDescription("");
setError(null);
setCreateOpen(true);
};
/** Agent pending delete confirmation (null = none). */
const [deleting, setDeleting] = useState<{ agentId: string; name: string } | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const projectId = currentProject?.projectId;
const create = async () => {
if (!projectId) return;
const id = agentId.trim();
if (!id) {
setError(S.common.requiredField);
return;
}
if (!SEMANTIC_ID_PATTERN.test(id)) {
setError(S.agent.idHint);
return;
}
setBusy(true);
setError(null);
try {
// Name defaults to the id (leave blank to let the server fill it in from the id).
const body: { agentId: string; name?: string; description?: string } = { agentId: id };
if (name.trim()) body.name = name.trim();
if (description.trim()) body.description = description.trim();
const res = await api.createAgent(projectId, body);
setCreateOpen(false);
await reloadAgents();
setCurrentAgentId(res.agent.agentId);
navigate(`/agents/${res.agent.agentId}`);
} catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError);
} finally {
setBusy(false);
}
};
/**
* "New Chat": enters draft state (same as sidebar group header) — the Session is only
* actually created when the first message is sent. agentId travels via route state: when the
* draft view restores from cache it prefers the cached agentId, but the route state explicitly
* overrides it, ensuring that clicking "New Chat" on a given card always lands on that Agent
* rather than the previous one from the cache.
*/
const newChat = (agentId: string) => {
setCurrentAgentId(agentId);
navigate(`/chat/${DRAFT_SESSION_ID}`, { state: { agentId } });
};
const doDelete = async () => {
if (!projectId || !deleting) return;
setBusy(true);
setDeleteError(null);
try {
await api.deleteAgent(projectId, deleting.agentId);
setDeleting(null);
await reloadAgents();
} catch (e) {
setDeleteError(e instanceof ApiError ? e.message : S.common.unknownError);
} finally {
setBusy(false);
}
};
return (
<div className="h-full overflow-y-auto p-4 md:p-6">
<div className="mx-auto max-w-5xl">
<div className="mb-4 flex items-center justify-between gap-2">
<h1 className="text-xl font-semibold">{S.agent.listTitle}</h1>
<Button variant="primary" onClick={openCreate}>
{S.agent.create}
</Button>
</div>
{agentsLoading ? (
/* Same single-column row styling as the real list (space-y-3 + px-5 py-4), with a
three-line info column plus sparkline/button-group placeholders, so no layout shift
occurs once the skeleton disappears */
<div className="space-y-3">
{Array.from({ length: 4 }, (_, i) => (
<SkeletonCard
key={i}
className="flex flex-wrap items-center gap-x-6 gap-y-2 px-5 py-4"
>
<div className="min-w-[14rem] flex-1">
<Skeleton className="h-[18px] w-40" />
<Skeleton className="mt-1.5 h-4 w-2/3" />
<Skeleton className="mt-1.5 h-4 w-48" />
</div>
<Skeleton className="hidden h-9 w-40 md:block" />
<Skeleton className="h-8 w-52" />
</SkeletonCard>
))}
</div>
) : agents.length === 0 ? (
<EmptyState title={S.common.none} />
) : (
/* GitHub-repo-list-style single column: separate cards with row spacing; each row is
one horizontal band of "info | sparkline | button group", with the info column
compressed to two lines of text (name line + combined description/stats line) to
minimize row height */
<div className="space-y-3">
{agents.map((a) => {
const builtin = BUILTIN_AGENT_IDS.has(a.agentId);
return (
<div
key={a.agentId}
className="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-md border border-gray-200 bg-white px-5 py-4 dark:border-gray-800 dark:bg-gray-900"
>
{/* Info column: once it can't fit within 14rem, everything after it
(sparkline/buttons) wraps as a whole. The avatar counts as the first line
(same line as the name); description/stats share the same left edge as the
avatar (the column's left edge) */}
<div className="min-w-[14rem] flex-1">
{/* Title line: small avatar + name + agentId + active badge */}
<div className="flex items-center gap-2">
<AgentAvatar id={a.agentId} size={18} className="shrink-0 rounded" />
{/* min-w-0: flex children don't shrink below their content by default; needed here to truncate overly long names */}
<span className="min-w-0 truncate text-base font-bold">
{agentDisplayName(a)}
</span>
<span className="hidden shrink-0 font-mono text-xs text-gray-400 md:inline dark:text-gray-500">
{a.agentId}
</span>
<Badge tone="gray">v{a.version}</Badge>
{a.activeSessionCount > 0 && (
<Badge tone="brand">
{S.agent.activeSessions} {a.activeSessionCount}
</Badge>
)}
</div>
{/* Description truncated to one line (an empty description still takes up a line, keeping card heights equal) */}
<p className="mt-1.5 min-h-4 truncate text-xs text-gray-500 dark:text-gray-400">
{a.description ?? ""}
</p>
{/* Stats on their own line: same color/font size as the description; each
reserves a minimum width so they align vertically across cards; meaning
folded into the hover title */}
<div className="mt-1.5 flex items-center gap-x-2.5 text-xs text-gray-500 dark:text-gray-400">
<span
className="inline-flex min-w-[2.25rem] shrink-0 items-center gap-1 tabular-nums"
title={S.agent.sessionCount(a.sessionCount)}
>
<GlyphIcon d={CARD_ICONS.sessions} size={12} />
{a.sessionCount}
</span>
<span
className="inline-flex min-w-[2.25rem] shrink-0 items-center gap-1 tabular-nums"
title={S.agent.toolCount(a.toolCount)}
>
<GlyphIcon d={STAT_ICONS.toolCalls} size={12} />
{a.toolCount}
</span>
<span
className="inline-flex min-w-[2.25rem] shrink-0 items-center gap-1 tabular-nums"
title={S.agent.vaultKeyCount(a.vaultKeyCount)}
>
<GlyphIcon d={CARD_ICONS.vaultKeys} size={12} />
{a.vaultKeyCount}
</span>
<span
className="inline-flex min-w-[2.25rem] shrink-0 items-center gap-1 tabular-nums"
title={S.agent.scheduleCount(a.scheduleCount)}
>
<GlyphIcon d={CARD_ICONS.schedules} size={12} />
{a.scheduleCount}
</span>
<span
className="inline-flex shrink-0 items-center gap-1"
title={`${S.agent.updatedAt} ${a.updatedAt ? formatDateTime(a.updatedAt) : "—"}`}
>
<GlyphIcon d={STAT_ICONS.elapsed} size={12} />
{a.updatedAt ? formatRelativeDays(a.updatedAt, locale) : "—"}
</span>
</div>
</div>
{/* Session activity sparkline (hidden on narrow screens first, giving the horizontal space back to content and buttons) */}
<ActivitySparkline
data={a.sessionActivity}
label={S.agent.activity(a.sessionActivity.length || 30)}
className="hidden shrink-0 md:block"
/>
{/* Button group to the right of the sparkline: "New Chat" shows text, the rest are square icon buttons (tooltip shows the full name) */}
<div className="flex shrink-0 items-center gap-2">
<Button size="sm" variant="primary" onClick={() => newChat(a.agentId)}>
<GlyphIcon d={CARD_ICONS.newChat} />
{S.chat.newSessionMenu}
</Button>
<Button
size="sm"
onClick={() => {
setCurrentAgentId(a.agentId);
navigate(`/agents/${a.agentId}`);
}}
>
<GlyphIcon
d={CARD_ICONS.settings}
className="text-gray-600 dark:text-gray-300"
/>
{S.common.settings}
</Button>
<Button
size="icon"
title={S.nav.usage}
aria-label={S.nav.usage}
onClick={() => navigate(`/usage?agentId=${encodeURIComponent(a.agentId)}`)}
>
<GlyphIcon
d={CARD_ICONS.usage}
size={15}
className="text-gray-600 dark:text-gray-300"
/>
</Button>
<Button
size="icon"
title={S.nav.traces}
aria-label={S.nav.traces}
onClick={() => navigate(`/traces?agentId=${encodeURIComponent(a.agentId)}`)}
>
<GlyphIcon
d={CARD_ICONS.traces}
size={15}
className="text-gray-600 dark:text-gray-300"
/>
</Button>
{/* Built-in Agents can't be deleted: shown as a non-button light gray
placeholder (no border/background, no hover response, disabled cursor,
explained via tooltip); the transparent border keeps the same box size as
an icon button so column widths stay consistent across cards */}
{builtin ? (
<span
role="img"
title={S.agent.builtinUndeletable}
aria-label={S.agent.builtinUndeletable}
className="inline-flex cursor-not-allowed items-center justify-center rounded-md border border-transparent p-1.5 text-gray-300 dark:text-gray-600"
>
<GlyphIcon d={CARD_ICONS.trash} size={15} />
</span>
) : (
<Button
size="icon"
variant="danger"
title={S.agent.deleteAgent}
aria-label={S.agent.deleteAgent}
onClick={() =>
setDeleting({ agentId: a.agentId, name: agentDisplayName(a) })
}
>
<GlyphIcon d={CARD_ICONS.trash} size={15} />
</Button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
<Modal
open={createOpen}
title={S.agent.createTitle}
onClose={() => setCreateOpen(false)}
footer={
<>
<Button onClick={() => setCreateOpen(false)}>{S.common.cancel}</Button>
<Button variant="primary" disabled={busy} onClick={() => void create()}>
{S.common.create}
</Button>
</>
}
>
<div className="space-y-3">
<Input
label={S.agent.id}
size="sm"
value={agentId}
onChange={(e) => setAgentId(e.target.value)}
hint={S.agent.idHint}
autoFocus
/>
<Input
label={S.agent.name}
size="sm"
value={name}
onChange={(e) => setName(e.target.value)}
hint={S.agent.nameHint}
/>
<Textarea
label={S.agent.description}
size="sm"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
</div>
</Modal>
{/* Delete confirmation */}
<Modal
open={deleting !== null}
title={S.agent.deleteAgent}
onClose={() => {
setDeleting(null);
setDeleteError(null);
}}
footer={
<>
<Button
onClick={() => {
setDeleting(null);
setDeleteError(null);
}}
>
{S.common.cancel}
</Button>
<Button variant="danger" disabled={busy} onClick={() => void doDelete()}>
{S.common.confirm}
</Button>
</>
}
>
<p className="text-sm text-gray-600 dark:text-gray-300">
{deleting ? S.agent.deleteConfirm(deleting.name) : ""}
</p>
{deleteError && (
<p className="mt-2 text-xs text-red-600 dark:text-red-400">{deleteError}</p>
)}
</Modal>
</div>
);
}
@@ -0,0 +1,572 @@
/**
* Agent settings page "Schedule" tab: a table view over
* agent_state/schedule/*.toml (status badge derived from run state; "next / last
* fired" shown as two stacked rows) plus a shared create/edit modal form.
* Readable by any member; toggle/edit/delete are owner-only — PUT has whole-file
* replace semantics, so toggling also resends every field and only flips `enabled`.
* startAt/endAt use datetime-local inputs (local timezone), converted to ISO 8601 on
* submit; the "new Session each run" mode can also pick a Model (a provider + modelId
* pair; defaults to the Project default; mutual exclusivity with sessionId is
* validated server-side).
*/
import { useCallback, useEffect, useState } from "react";
import type {
ModelInfo,
ScheduleItem,
SchedulesResponse,
ScheduleStatus,
ScheduleUpsertRequest,
} from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings";
import { formatDateTime } from "../../lib/format";
import { useProject } from "../../state/project";
import { providerInfo } from "@prismshadow/penguin-core/model-catalog";
import { Badge, type BadgeTone } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Input, Textarea } from "../../components/ui/input";
import { Select } from "../../components/ui/select";
import { Modal } from "../../components/ui/modal";
import { SkeletonList } from "../../components/ui/skeleton";
/** Display status → badge tone. */
const STATUS_TONE: Record<ScheduleStatus, BadgeTone> = {
active: "green",
disabled: "gray",
expired: "amber",
done: "brand",
missed: "amber",
invalid: "red",
};
/** ISO → datetime-local input value (local timezone, minute precision); returns "" when missing/invalid. */
function toLocalInput(iso: string | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/**
* The model selection value in the form: a paired reference. provider may be
* omitted — when a hand-written TOML only specifies model_id, it's resolved by
* unique match at parse time; editing never fills in a provider on its own.
*/
interface ScheduleModelRef {
provider?: string;
modelId: string;
}
/**
* Model reference ↔ native select option value: encoded as a JSON array
* ([provider ?? null, modelId]), used only as a transient DOM-value serialization —
* it never enters storage or requests; the persisted/submitted data still keeps
* provider and modelId as two separate fields (no concatenation anywhere in the
* pipeline). "" means the Project default.
*/
const modelOptionValue = (ref: ScheduleModelRef): string =>
JSON.stringify([ref.provider ?? null, ref.modelId]);
const parseModelOption = (v: string): ScheduleModelRef | null => {
if (!v) return null;
const [provider, modelId] = JSON.parse(v) as [string | null, string];
return provider === null ? { modelId } : { provider, modelId };
};
/** Display label for a model option: upstream id + provider name (shown side by side, not a composite id). */
const modelOptionLabel = (ref: ScheduleModelRef): string =>
ref.provider === undefined
? ref.modelId
: `${ref.modelId} · ${providerInfo(ref.provider)?.label ?? ref.provider}`;
/** Modal form state (shared by create/edit): non-null editing means editing that task (name locked). */
interface FormState {
editing: string | null;
name: string;
prompt: string;
enabled: boolean;
/** datetime-local input value (converted to ISO on submit). */
startAt: string;
endAt: string;
period: string;
target: "new" | "session";
sessionId: string;
workspace: string;
/** Model for the new-Session mode (null = Project default, modelId/provider omitted). */
model: ScheduleModelRef | null;
}
const EMPTY_FORM: FormState = {
editing: null,
name: "",
prompt: "",
enabled: true,
startAt: "",
endAt: "",
period: "",
target: "new",
sessionId: "",
workspace: "",
model: null,
};
export function SchedulesTab({ agentId }: { agentId: string }) {
const { currentProject } = useProject();
const projectId = currentProject?.projectId ?? null;
const isOwner = currentProject?.role === "owner";
const [data, setData] = useState<SchedulesResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// Modal form: non-null means open (EMPTY_FORM for create / prefilled row for edit).
const [form, setForm] = useState<FormState | null>(null);
const [formError, setFormError] = useState<string | null>(null);
// Name of the task pending deletion confirmation (non-null shows the confirm modal).
const [deleting, setDeleting] = useState<string | null>(null);
// Model dropdown data (needed only for owners); load failure doesn't block the form — falling back to "Project default" is fine.
const [models, setModels] = useState<ModelInfo[]>([]);
const load = useCallback(async () => {
if (!projectId || !agentId) return;
setData(null);
setError(null);
try {
setData(await api.listSchedules(projectId, agentId));
} catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError);
}
}, [projectId, agentId]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (!projectId || !isOwner) return;
api
.getModels(projectId)
.then((res) => setModels(res.models))
.catch(() => setModels([]));
}, [projectId, isOwner]);
const set = (patch: Partial<FormState>) =>
setForm((prev) => (prev === null ? prev : { ...prev, ...patch }));
const openForm = (next: FormState) => {
setFormError(null);
setForm(next);
};
const submit = async () => {
if (!projectId || form === null) return;
setFormError(null);
setNotice(null);
const name = form.editing ?? form.name.trim();
// sessionId is required in bind-to-Session mode — leaving it blank would silently downgrade to "new Session", changing the user's intended choice.
if (
!name ||
!form.prompt.trim() ||
!form.startAt ||
(form.target === "session" && !form.sessionId.trim())
) {
setFormError(S.common.requiredField);
return;
}
// Empty-string keys are always omitted; target is one of two choices — sessionId is
// sent only when binding to a Session, and workspace plus the model reference
// (modelId + provider pair) only when creating a new Session.
const body: ScheduleUpsertRequest = {
prompt: form.prompt,
enabled: form.enabled,
startAt: new Date(form.startAt).toISOString(),
...(form.period.trim() ? { period: form.period.trim() } : {}),
...(form.endAt ? { endAt: new Date(form.endAt).toISOString() } : {}),
...(form.target === "session" && form.sessionId.trim()
? { sessionId: form.sessionId.trim() }
: {}),
...(form.target === "new" && form.workspace.trim()
? { workspace: form.workspace.trim() }
: {}),
...(form.target === "new" && form.model
? {
modelId: form.model.modelId,
...(form.model.provider !== undefined ? { provider: form.model.provider } : {}),
}
: {}),
};
setBusy(true);
try {
if (form.editing !== null) await api.updateSchedule(projectId, agentId, form.editing, body);
else await api.createSchedule(projectId, agentId, { name, ...body });
setForm(null);
setNotice(S.common.saved);
await load();
} catch (e) {
// Errors like 400 (validated with the same rules as hand-written files) land under the modal form.
setFormError(e instanceof ApiError ? e.message : S.common.unknownError);
} finally {
setBusy(false);
}
};
/** Toggle: whole-file-replace semantics — resend original fields, only flip enabled. */
const toggle = async (item: ScheduleItem) => {
if (!projectId) return;
setBusy(true);
setError(null);
setNotice(null);
try {
await api.updateSchedule(projectId, agentId, item.name, {
prompt: item.prompt,
enabled: !item.enabled,
startAt: item.startAt,
...(item.period !== undefined ? { period: item.period } : {}),
...(item.endAt !== undefined ? { endAt: item.endAt } : {}),
...(item.sessionId !== undefined ? { sessionId: item.sessionId } : {}),
...(item.workspace !== undefined ? { workspace: item.workspace } : {}),
// Model reference is resent as a pair (a hand-written file with provider omitted stays omitted).
...(item.modelId !== undefined ? { modelId: item.modelId } : {}),
...(item.provider !== undefined ? { provider: item.provider } : {}),
});
setNotice(S.common.saved);
await load();
} catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError);
} finally {
setBusy(false);
}
};
/** Edit: prefill this row into the modal form (submits via PUT; model reference prefilled as a pair, provider not auto-filled when absent). */
const startEdit = (item: ScheduleItem) => {
openForm({
editing: item.name,
name: item.name,
prompt: item.prompt,
enabled: item.enabled,
startAt: toLocalInput(item.startAt),
endAt: toLocalInput(item.endAt),
period: item.period ?? "",
target: item.sessionId ? "session" : "new",
sessionId: item.sessionId ?? "",
workspace: item.workspace ?? "",
model:
item.modelId !== undefined
? {
modelId: item.modelId,
...(item.provider !== undefined ? { provider: item.provider } : {}),
}
: null,
});
};
/** Confirm modal's "Confirm": closes the modal after deletion; if the deleted task is currently being edited, close the form too. */
const confirmRemove = async () => {
if (!projectId || deleting === null) return;
setBusy(true);
setError(null);
setNotice(null);
try {
await api.deleteSchedule(projectId, agentId, deleting);
if (form?.editing === deleting) setForm(null);
await load();
} catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError);
} finally {
setBusy(false);
setDeleting(null);
}
};
if (!projectId) return null;
const schedules = data?.schedules ?? [];
const invalidFiles = data?.invalidFiles ?? [];
return (
<div className="space-y-4">
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">{S.schedule.desc}</p>
{!isOwner && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{S.schedule.readOnlyHint}</p>
)}
</div>
{data === null ? (
<SkeletonList rows={4} />
) : schedules.length === 0 ? (
// Plain-text empty state (settings area doesn't use the penguin-icon EmptyState, keeps the same gray level as the table area).
<p className="py-2 text-xs text-gray-400 dark:text-gray-500">{S.schedule.empty}</p>
) : (
<div className="overflow-x-auto overflow-y-clip rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
<table className="w-full min-w-[640px] text-left text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50/80 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900">
<th className="px-3 py-2.5">{S.schedule.colName}</th>
<th className="px-3 py-2.5">{S.schedule.colStatus}</th>
<th className="px-3 py-2.5">{S.schedule.colPeriod}</th>
<th className="px-3 py-2.5">{S.schedule.colTarget}</th>
<th className="px-3 py-2.5">{S.schedule.colFireTimes}</th>
<th className="px-3 py-2.5">{S.schedule.colQueued}</th>
{isOwner && <th className="px-3 py-2.5" />}
</tr>
</thead>
<tbody>
{schedules.map((item) => (
<tr
key={item.name}
className="border-b border-gray-100 transition-colors duration-150 last:border-b-0 hover:bg-gray-50 dark:border-gray-800/60 dark:hover:bg-gray-800/40"
>
<td className="px-3 py-2 font-mono text-xs">{item.name}</td>
<td className="px-3 py-2">
{/* invalid reason is folded into the hover title. */}
<span title={item.invalidReason}>
<Badge tone={STATUS_TONE[item.status]}>
{S.schedule.statusNames[item.status] ?? item.status}
</Badge>
</span>
</td>
<td className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
{item.period !== undefined ? (
<span className="font-mono">{item.period}</span>
) : (
S.schedule.once
)}
</td>
<td
className="max-w-36 truncate px-3 py-2 text-xs text-gray-500 dark:text-gray-400"
title={item.sessionId}
>
{item.sessionId !== undefined ? (
<span className="font-mono">{item.sessionId}</span>
) : (
S.schedule.newSession
)}
</td>
{/* Top row: next fire time; bottom row: last fired time (both show — when absent). */}
<td className="px-3 py-2 text-xs">
<span className="block text-gray-600 dark:text-gray-300">
{item.nextFireAt ? formatDateTime(item.nextFireAt) : "—"}
</span>
<span className="block text-gray-400 dark:text-gray-500">
{item.lastFiredAt ? formatDateTime(item.lastFiredAt) : "—"}
</span>
</td>
<td className="px-3 py-2">
{item.queued && <Badge tone="brand">{S.schedule.queued}</Badge>}
</td>
{isOwner && (
<td className="whitespace-nowrap px-3 py-2 text-right">
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => void toggle(item)}
>
{item.enabled ? S.schedule.disable : S.schedule.enable}
</Button>
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => startEdit(item)}
>
{S.common.edit}
</Button>
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => setDeleting(item.name)}
>
{S.common.delete}
</Button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{invalidFiles.length > 0 && (
<div className="text-xs text-red-600 dark:text-red-400">
<p className="font-medium">{S.schedule.invalidFiles}</p>
<ul className="mt-0.5 space-y-0.5 font-mono">
{invalidFiles.map((f) => (
<li key={f.name}>
{f.name}: {f.error}
</li>
))}
</ul>
</div>
)}
{/* Create entry point (owner): the form lives in a modal; the inline "Edit" button reuses the same modal. */}
{isOwner && data !== null && (
<Button size="sm" variant="primary" disabled={busy} onClick={() => openForm(EMPTY_FORM)}>
{S.schedule.addTitle}
</Button>
)}
{/* Shared create/edit modal form. */}
<Modal
open={form !== null}
title={form?.editing != null ? S.schedule.editTitle(form.editing) : S.schedule.addTitle}
onClose={() => setForm(null)}
widthClass="sm:max-w-lg"
footer={
<>
<Button onClick={() => setForm(null)}>{S.common.cancel}</Button>
<Button variant="primary" disabled={busy} onClick={() => void submit()}>
{form?.editing != null ? S.common.save : S.common.create}
</Button>
</>
}
>
{form !== null && (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<Input
size="sm"
label={S.schedule.name}
hint={S.schedule.nameHint}
value={form.name}
disabled={form.editing !== null}
onChange={(e) => set({ name: e.target.value })}
className="font-mono"
placeholder="daily_report"
autoComplete="off"
/>
<Input
size="sm"
label={S.schedule.period}
value={form.period}
onChange={(e) => set({ period: e.target.value })}
className="font-mono"
placeholder={S.schedule.periodPlaceholder}
autoComplete="off"
/>
<Input
size="sm"
label={S.schedule.startAt}
type="datetime-local"
value={form.startAt}
onChange={(e) => set({ startAt: e.target.value })}
className="font-mono"
/>
<Input
size="sm"
label={S.schedule.endAt}
type="datetime-local"
value={form.endAt}
onChange={(e) => set({ endAt: e.target.value })}
className="font-mono"
/>
<Select
size="sm"
label={S.schedule.target}
value={form.target}
onChange={(e) => set({ target: e.target.value as FormState["target"] })}
>
<option value="new">{S.schedule.targetNew}</option>
<option value="session">{S.schedule.targetSession}</option>
</Select>
{form.target === "session" ? (
<Input
size="sm"
label={S.schedule.sessionId}
value={form.sessionId}
onChange={(e) => set({ sessionId: e.target.value })}
className="font-mono"
autoComplete="off"
/>
) : (
<>
<Select
size="sm"
label={S.schedule.model}
value={form.model ? modelOptionValue(form.model) : ""}
onChange={(e) => set({ model: parseModelOption(e.target.value) })}
>
<option value="">{S.schedule.modelDefault}</option>
{/* When the prefilled reference is no longer in the model config (including a
hand-written file missing only provider), add an extra option so it isn't
displayed as "Project default". */}
{form.model &&
!models.some(
(m) =>
m.modelId === form.model!.modelId && m.provider === form.model!.provider,
) && (
<option value={modelOptionValue(form.model)}>
{modelOptionLabel(form.model)}
</option>
)}
{models.map((m) => (
<option
key={`${m.provider}:${m.modelId}`}
value={modelOptionValue({ provider: m.provider, modelId: m.modelId })}
>
{modelOptionLabel({ provider: m.provider, modelId: m.modelId })}
</option>
))}
</Select>
<Input
size="sm"
label={S.schedule.workspace}
value={form.workspace}
onChange={(e) => set({ workspace: e.target.value })}
className="font-mono"
autoComplete="off"
/>
</>
)}
</div>
<Textarea
label={S.schedule.prompt}
size="sm"
rows={4}
value={form.prompt}
onChange={(e) => set({ prompt: e.target.value })}
/>
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
<input
type="checkbox"
checked={form.enabled}
onChange={(e) => set({ enabled: e.target.checked })}
/>
{S.schedule.enabled}
</label>
{formError && <p className="text-xs text-red-600 dark:text-red-400">{formError}</p>}
</div>
)}
</Modal>
{/* Delete confirmation (same pattern as Vault / Agent deletion: Modal + cancel/danger-confirm). */}
<Modal
open={deleting !== null}
title={S.schedule.deleteTitle}
onClose={() => setDeleting(null)}
footer={
<>
<Button onClick={() => setDeleting(null)}>{S.common.cancel}</Button>
<Button variant="danger" disabled={busy} onClick={() => void confirmRemove()}>
{S.common.confirm}
</Button>
</>
}
>
<p className="text-sm text-gray-600 dark:text-gray-300">
{deleting !== null ? S.schedule.deleteConfirm(deleting) : ""}
</p>
</Modal>
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
{notice && <p className="text-xs text-emerald-600 dark:text-emerald-400">{notice}</p>}
</div>
);
}
@@ -0,0 +1,248 @@
/**
* Agent settings page "Vault" tab: an Agent-level key-value vault
* (agent_state/.vault.toml) — a table (key, masked value, delete) plus an "Add" modal
* (key + value, value uses a password field). Saving goes through PUT with
* whole-table replace semantics: keys absent from the body are deleted, and
* resending only the key name means keep the original value (plaintext never comes
* back to the frontend); only owners can edit, members are read-only.
* The key name is injected into the Agent's system prompt to inform the model; the
* value is injected only into the exec_command subprocess environment, never into
* the model context.
*/
import { useCallback, useEffect, useState } from "react";
import type { VaultEntryInfo, VaultUpdateRequest } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings";
import { useProject } from "../../state/project";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import { PasswordInput } from "../../components/ui/password-input";
import { Modal } from "../../components/ui/modal";
import { SkeletonList } from "../../components/ui/skeleton";
/** Vault key naming rule (consistent with core/server): shell environment variable name. */
const VAULT_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
export function VaultTab({ agentId }: { agentId: string }) {
const { currentProject } = useProject();
const projectId = currentProject?.projectId ?? null;
const isOwner = currentProject?.role === "owner";
const [entries, setEntries] = useState<VaultEntryInfo[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// Add modal: both form state and errors travel with the modal (tab-level error would be hidden behind the modal).
const [adding, setAdding] = useState(false);
const [keyInput, setKeyInput] = useState("");
const [valueInput, setValueInput] = useState("");
const [addError, setAddError] = useState<string | null>(null);
// Key pending deletion confirmation (non-null shows the confirm modal).
const [deleting, setDeleting] = useState<string | null>(null);
const load = useCallback(async () => {
if (!projectId || !agentId) return;
setEntries(null);
setError(null);
try {
const res = await api.getVault(projectId, agentId);
setEntries(res.entries);
} catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError);
}
}, [projectId, agentId]);
useEffect(() => {
void load();
}, [load]);
/** Persist a change immediately (add / delete): returns null on success, an error message on failure — the caller decides whether it lands inside the modal or at the tab level. */
const persist = async (body: VaultUpdateRequest): Promise<string | null> => {
if (!projectId || !agentId) return S.common.unknownError;
setBusy(true);
setNotice(null);
try {
const res = await api.putVault(projectId, agentId, body);
setEntries(res.entries);
setNotice(S.common.saved);
return null;
} catch (e) {
return e instanceof ApiError ? e.message : S.common.unknownError;
} finally {
setBusy(false);
}
};
/** Keep existing keys (resending only the key name = keep the original value), excluding excludeKey. */
const keepEntries = (excludeKey?: string) =>
(entries ?? [])
.filter((e) => e.key !== excludeKey)
.map((e): VaultUpdateRequest["entries"][number] => ({ key: e.key }));
/** Open the add modal (reset form and error state). */
const openAdd = () => {
setKeyInput("");
setValueInput("");
setAddError(null);
setAdding(true);
};
const addEntry = async () => {
const key = keyInput.trim();
if (!key) {
setAddError(S.common.requiredField);
return;
}
if (!VAULT_KEY_PATTERN.test(key)) {
setAddError(S.vault.keyInvalid);
return;
}
if (!valueInput) {
setAddError(S.vault.valueRequired);
return;
}
setAddError(null);
// Upsert by same key name: don't resend the existing entry too, to avoid a 400 from PUT's duplicate-key validation.
const err = await persist({ entries: [...keepEntries(key), { key, value: valueInput }] });
if (err !== null) {
setAddError(err);
return;
}
setAdding(false);
};
/** Confirm modal's "Confirm": closes the modal after deletion (on failure, the error lands on the tab's error line). */
const confirmRemove = async () => {
if (deleting === null) return;
setError(null);
const err = await persist({ entries: keepEntries(deleting) });
if (err !== null) setError(err);
setDeleting(null);
};
if (!projectId) return null;
return (
<div className="space-y-4">
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">{S.vault.desc}</p>
{!isOwner && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{S.vault.readOnlyHint}</p>
)}
</div>
{entries === null ? (
<SkeletonList rows={4} />
) : entries.length === 0 ? (
// Plain-text empty state (settings area doesn't use the penguin-icon EmptyState, keeps the same gray level as the table area).
<p className="py-2 text-xs text-gray-400 dark:text-gray-500">{S.vault.empty}</p>
) : (
<div className="overflow-x-auto overflow-y-clip rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
<table className="w-full min-w-[420px] text-left text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50/80 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900">
<th className="px-3 py-2.5">{S.vault.key}</th>
<th className="px-3 py-2.5">{S.vault.valueMasked}</th>
<th className="px-3 py-2.5" />
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr
key={entry.key}
className="border-b border-gray-100 transition-colors duration-150 last:border-b-0 hover:bg-gray-50 dark:border-gray-800/60 dark:hover:bg-gray-800/40"
>
<td className="px-3 py-2 font-mono text-xs">{entry.key}</td>
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-gray-400">
{entry.valueMasked}
</td>
<td className="px-3 py-2 text-right">
{isOwner && (
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => setDeleting(entry.key)}
>
{S.vault.remove}
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Add entry point (owner): the form lives in a modal; submitting the same key name overwrites the original value. */}
{isOwner && entries !== null && (
<Button size="sm" variant="primary" disabled={busy} onClick={openAdd}>
{S.vault.add}
</Button>
)}
<Modal
open={adding}
title={S.vault.addTitle}
onClose={() => setAdding(false)}
footer={
<>
<Button onClick={() => setAdding(false)}>{S.common.cancel}</Button>
<Button variant="primary" disabled={busy} onClick={() => void addEntry()}>
{S.vault.add}
</Button>
</>
}
>
<div className="space-y-3">
<Input
size="sm"
label={S.vault.key}
hint={S.vault.keyHint}
value={keyInput}
onChange={(e) => setKeyInput(e.target.value)}
className="font-mono"
placeholder="OPENAI_API_KEY"
autoComplete="off"
/>
<PasswordInput
size="sm"
label={S.vault.value}
value={valueInput}
onChange={(e) => setValueInput(e.target.value)}
className="font-mono"
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter" && !busy) void addEntry();
}}
/>
{addError && <p className="text-xs text-red-600 dark:text-red-400">{addError}</p>}
</div>
</Modal>
{/* Delete confirmation (same pattern as Agent / Session deletion: Modal + cancel/danger-confirm). */}
<Modal
open={deleting !== null}
title={S.vault.deleteTitle}
onClose={() => setDeleting(null)}
footer={
<>
<Button onClick={() => setDeleting(null)}>{S.common.cancel}</Button>
<Button variant="danger" disabled={busy} onClick={() => void confirmRemove()}>
{S.common.confirm}
</Button>
</>
}
>
<p className="text-sm text-gray-600 dark:text-gray-300">
{deleting !== null ? S.vault.deleteConfirm(deleting) : ""}
</p>
</Modal>
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
{notice && <p className="text-xs text-emerald-600 dark:text-emerald-400">{notice}</p>}
</div>
);
}