Merge remote-tracking branch 'public-gh/master' into paperclip-routines

* public-gh/master: (46 commits)
  chore(lockfile): refresh pnpm-lock.yaml (#1377)
  fix: manage codex home per company by default
  Ensure agent home directories exist before use
  Handle directory entries in imported zip archives
  Fix portability import and org chart test blockers
  Fix PR verify failures after merge
  fix: address greptile follow-up feedback
  Address remaining Greptile portability feedback
  docs: clarify quickstart npx usage
  Add guarded dev restart handling
  Fix PAP-576 settings toggles and transcript default
  Add username log censor setting
  fix: use standard toggle component for permission controls
  fix: add missing setPrincipalPermission mock in portability tests
  fix: use fixed 1280x640 dimensions for org chart export image
  Adjust default CEO onboarding task copy
  fix: link Agent Company to agentcompanies.io in export README
  fix: strip agents and projects sections from COMPANY.md export body
  fix: default company export page to README.md instead of first file
  Add default agent instructions bundle
  ...

# Conflicts:
#	packages/adapters/pi-local/src/server/execute.ts
#	packages/db/src/migrations/meta/0039_snapshot.json
#	packages/db/src/migrations/meta/_journal.json
#	server/src/__tests__/agent-permissions-routes.test.ts
#	server/src/__tests__/agent-skills-routes.test.ts
#	server/src/services/company-portability.ts
#	skills/paperclip/references/company-skills.md
#	ui/src/api/agents.ts
This commit is contained in:
dotta
2026-03-20 15:04:55 -05:00
96 changed files with 15366 additions and 1684 deletions

View File

@@ -28,6 +28,7 @@ import { CompanySkills } from "./pages/CompanySkills";
import { CompanyExport } from "./pages/CompanyExport";
import { CompanyImport } from "./pages/CompanyImport";
import { DesignGuide } from "./pages/DesignGuide";
import { InstanceGeneralSettings } from "./pages/InstanceGeneralSettings";
import { InstanceSettings } from "./pages/InstanceSettings";
import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings";
import { PluginManager } from "./pages/PluginManager";
@@ -181,7 +182,7 @@ function InboxRootRedirect() {
function LegacySettingsRedirect() {
const location = useLocation();
return <Navigate to={`/instance/settings/heartbeats${location.search}${location.hash}`} replace />;
return <Navigate to={`/instance/settings/general${location.search}${location.hash}`} replace />;
}
function OnboardingRoutePage() {
@@ -306,9 +307,10 @@ export function App() {
<Route element={<CloudAccessGate />}>
<Route index element={<CompanyRootRedirect />} />
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="instance" element={<Navigate to="/instance/settings/heartbeats" replace />} />
<Route path="instance" element={<Navigate to="/instance/settings/general" replace />} />
<Route path="instance/settings" element={<Layout />}>
<Route index element={<Navigate to="heartbeats" replace />} />
<Route index element={<Navigate to="general" replace />} />
<Route path="general" element={<InstanceGeneralSettings />} />
<Route path="heartbeats" element={<InstanceSettings />} />
<Route path="experimental" element={<InstanceExperimentalSettings />} />
<Route path="plugins" element={<PluginManager />} />

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { buildTranscript, type RunLogChunk } from "./transcript";
describe("buildTranscript", () => {
const ts = "2026-03-20T13:00:00.000Z";
const chunks: RunLogChunk[] = [
{ ts, stream: "stdout", chunk: "opened /Users/dotta/project\n" },
{ ts, stream: "stderr", chunk: "stderr /Users/dotta/project" },
];
it("defaults username censoring to off when options are omitted", () => {
const entries = buildTranscript(chunks, (line, entryTs) => [{ kind: "stdout", ts: entryTs, text: line }]);
expect(entries).toEqual([
{ kind: "stdout", ts, text: "opened /Users/dotta/project" },
{ kind: "stderr", ts, text: "stderr /Users/dotta/project" },
]);
});
it("still redacts usernames when explicitly enabled", () => {
const entries = buildTranscript(chunks, (line, entryTs) => [{ kind: "stdout", ts: entryTs, text: line }], {
censorUsernameInLogs: true,
});
expect(entries).toEqual([
{ kind: "stdout", ts, text: "opened /Users/d****/project" },
{ kind: "stderr", ts, text: "stderr /Users/d****/project" },
]);
});
});

View File

@@ -2,6 +2,7 @@ import { redactHomePathUserSegments, redactTranscriptEntryPaths } from "@papercl
import type { TranscriptEntry, StdoutLineParser } from "./types";
export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
type TranscriptBuildOptions = { censorUsernameInLogs?: boolean };
export function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) {
if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) {
@@ -21,17 +22,22 @@ export function appendTranscriptEntries(entries: TranscriptEntry[], incoming: Tr
}
}
export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser): TranscriptEntry[] {
export function buildTranscript(
chunks: RunLogChunk[],
parser: StdoutLineParser,
opts?: TranscriptBuildOptions,
): TranscriptEntry[] {
const entries: TranscriptEntry[] = [];
let stdoutBuffer = "";
const redactionOptions = { enabled: opts?.censorUsernameInLogs ?? false };
for (const chunk of chunks) {
if (chunk.stream === "stderr") {
entries.push({ kind: "stderr", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk) });
entries.push({ kind: "stderr", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk, redactionOptions) });
continue;
}
if (chunk.stream === "system") {
entries.push({ kind: "system", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk) });
entries.push({ kind: "system", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk, redactionOptions) });
continue;
}
@@ -41,14 +47,14 @@ export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser)
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
appendTranscriptEntries(entries, parser(trimmed, chunk.ts).map(redactTranscriptEntryPaths));
appendTranscriptEntries(entries, parser(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
}
}
const trailing = stdoutBuffer.trim();
if (trailing) {
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
appendTranscriptEntries(entries, parser(trailing, ts).map(redactTranscriptEntryPaths));
appendTranscriptEntries(entries, parser(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
}
return entries;

View File

@@ -1,3 +1,17 @@
export type DevServerHealthStatus = {
enabled: true;
restartRequired: boolean;
reason: "backend_changes" | "pending_migrations" | "backend_changes_and_pending_migrations" | null;
lastChangedAt: string | null;
changedPathCount: number;
changedPathsSample: string[];
pendingMigrations: string[];
autoRestartEnabled: boolean;
activeRunCount: number;
waitingForIdle: boolean;
lastRestartAt: string | null;
};
export type HealthStatus = {
status: "ok";
version?: string;
@@ -9,6 +23,7 @@ export type HealthStatus = {
features?: {
companyDeletionEnabled?: boolean;
};
devServer?: DevServerHealthStatus;
};
export const healthApi = {

View File

@@ -1,10 +1,16 @@
import type {
InstanceExperimentalSettings,
InstanceGeneralSettings,
PatchInstanceGeneralSettings,
PatchInstanceExperimentalSettings,
} from "@paperclipai/shared";
import { api } from "./client";
export const instanceSettingsApi = {
getGeneral: () =>
api.get<InstanceGeneralSettings>("/instance/settings/general"),
updateGeneral: (patch: PatchInstanceGeneralSettings) =>
api.patch<InstanceGeneralSettings>("/instance/settings/general", patch),
getExperimental: () =>
api.get<InstanceExperimentalSettings>("/instance/settings/experimental"),
updateExperimental: (patch: PatchInstanceExperimentalSettings) =>

View File

@@ -44,6 +44,7 @@ import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-field
import { MarkdownEditor } from "./MarkdownEditor";
import { ChoosePathButton } from "./PathInstructionsModal";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config";
/* ---- Create mode values ---- */
@@ -297,6 +298,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
adapterType === "opencode_local" ||
adapterType === "pi_local" ||
adapterType === "cursor";
const showLegacyWorkingDirectoryField =
isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
// Fetch adapter models for the effective adapter type
@@ -590,8 +593,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)}
{/* Working directory */}
{isLocal && (
<Field label="Working directory" hint={help.cwd}>
{showLegacyWorkingDirectoryField && (
<Field label="Working directory (deprecated)" hint={help.cwd}>
<div className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<DraftInput

View File

@@ -0,0 +1,89 @@
import { AlertTriangle, RotateCcw, TimerReset } from "lucide-react";
import type { DevServerHealthStatus } from "../api/health";
function formatRelativeTimestamp(value: string | null): string | null {
if (!value) return null;
const timestamp = new Date(value).getTime();
if (Number.isNaN(timestamp)) return null;
const deltaMs = Date.now() - timestamp;
if (deltaMs < 60_000) return "just now";
const deltaMinutes = Math.round(deltaMs / 60_000);
if (deltaMinutes < 60) return `${deltaMinutes}m ago`;
const deltaHours = Math.round(deltaMinutes / 60);
if (deltaHours < 24) return `${deltaHours}h ago`;
const deltaDays = Math.round(deltaHours / 24);
return `${deltaDays}d ago`;
}
function describeReason(devServer: DevServerHealthStatus): string {
if (devServer.reason === "backend_changes_and_pending_migrations") {
return "backend files changed and migrations are pending";
}
if (devServer.reason === "pending_migrations") {
return "pending migrations need a fresh boot";
}
return "backend files changed since this server booted";
}
export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthStatus }) {
if (!devServer?.enabled || !devServer.restartRequired) return null;
const changedAt = formatRelativeTimestamp(devServer.lastChangedAt);
const sample = devServer.changedPathsSample.slice(0, 3);
return (
<div className="border-b border-amber-300/60 bg-amber-50 text-amber-950 dark:border-amber-500/25 dark:bg-amber-500/10 dark:text-amber-100">
<div className="flex flex-col gap-3 px-3 py-2.5 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2 text-[12px] font-semibold uppercase tracking-[0.18em]">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
<span>Restart Required</span>
{devServer.autoRestartEnabled ? (
<span className="rounded-full bg-amber-900/10 px-2 py-0.5 text-[10px] tracking-[0.14em] dark:bg-amber-100/10">
Auto-Restart On
</span>
) : null}
</div>
<p className="mt-1 text-sm">
{describeReason(devServer)}
{changedAt ? ` · updated ${changedAt}` : ""}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-amber-900/80 dark:text-amber-100/75">
{sample.length > 0 ? (
<span>
Changed: {sample.join(", ")}
{devServer.changedPathCount > sample.length ? ` +${devServer.changedPathCount - sample.length} more` : ""}
</span>
) : null}
{devServer.pendingMigrations.length > 0 ? (
<span>
Pending migrations: {devServer.pendingMigrations.slice(0, 2).join(", ")}
{devServer.pendingMigrations.length > 2 ? ` +${devServer.pendingMigrations.length - 2} more` : ""}
</span>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs font-medium">
{devServer.waitingForIdle ? (
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
<TimerReset className="h-3.5 w-3.5" />
<span>Waiting for {devServer.activeRunCount} live run{devServer.activeRunCount === 1 ? "" : "s"} to finish</span>
</div>
) : devServer.autoRestartEnabled ? (
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
<RotateCcw className="h-3.5 w-3.5" />
<span>Auto-restart will trigger when the instance is idle</span>
</div>
) : (
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
<RotateCcw className="h-3.5 w-3.5" />
<span>Restart <code>pnpm dev:once</code> after the active work is safe to interrupt</span>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { Clock3, FlaskConical, Puzzle, Settings } from "lucide-react";
import { Clock3, FlaskConical, Puzzle, Settings, SlidersHorizontal } from "lucide-react";
import { NavLink } from "@/lib/router";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
@@ -22,6 +22,7 @@ export function InstanceSidebar() {
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/instance/settings/general" label="General" icon={SlidersHorizontal} end />
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
<SidebarNavItem to="/instance/settings/experimental" label="Experimental" icon={FlaskConical} />
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />

View File

@@ -15,6 +15,7 @@ import { NewAgentDialog } from "./NewAgentDialog";
import { ToastViewport } from "./ToastViewport";
import { MobileBottomNav } from "./MobileBottomNav";
import { WorktreeBanner } from "./WorktreeBanner";
import { DevRestartBanner } from "./DevRestartBanner";
import { useDialog } from "../context/DialogContext";
import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext";
@@ -78,6 +79,11 @@ export function Layout() {
queryKey: queryKeys.health,
queryFn: () => healthApi.get(),
retry: false,
refetchInterval: (query) => {
const data = query.state.data as { devServer?: { enabled?: boolean } } | undefined;
return data?.devServer?.enabled ? 2000 : false;
},
refetchIntervalInBackground: true,
});
useEffect(() => {
@@ -266,6 +272,7 @@ export function Layout() {
Skip to Main Content
</a>
<WorktreeBanner />
<DevRestartBanner devServer={health?.devServer} />
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}>
{isMobile && sidebarOpen && (
<button

View File

@@ -0,0 +1,31 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { ThemeProvider } from "../context/ThemeContext";
import { MarkdownBody } from "./MarkdownBody";
describe("MarkdownBody", () => {
it("renders markdown images without a resolver", () => {
const html = renderToStaticMarkup(
<ThemeProvider>
<MarkdownBody>{"![](/api/attachments/test/content)"}</MarkdownBody>
</ThemeProvider>,
);
expect(html).toContain('<img src="/api/attachments/test/content" alt=""/>');
});
it("resolves relative image paths when a resolver is provided", () => {
const html = renderToStaticMarkup(
<ThemeProvider>
<MarkdownBody resolveImageSrc={(src) => `/resolved/${src}`}>
{"![Org chart](images/org-chart.png)"}
</MarkdownBody>
</ThemeProvider>,
);
expect(html).toContain('src="/resolved/images/org-chart.png"');
expect(html).toContain('alt="Org chart"');
});
});

View File

@@ -1,5 +1,5 @@
import { isValidElement, useEffect, useId, useState, type CSSProperties, type ReactNode } from "react";
import Markdown from "react-markdown";
import Markdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { parseProjectMentionHref } from "@paperclipai/shared";
import { cn } from "../lib/utils";
@@ -8,6 +8,8 @@ import { useTheme } from "../context/ThemeContext";
interface MarkdownBodyProps {
children: string;
className?: string;
/** Optional resolver for relative image paths (e.g. within export packages) */
resolveImageSrc?: (src: string) => string | null;
}
let mermaidLoaderPromise: Promise<typeof import("mermaid").default> | null = null;
@@ -112,8 +114,44 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b
);
}
export function MarkdownBody({ children, className }: MarkdownBodyProps) {
export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownBodyProps) {
const { theme } = useTheme();
const components: Components = {
pre: ({ node: _node, children: preChildren, ...preProps }) => {
const mermaidSource = extractMermaidSource(preChildren);
if (mermaidSource) {
return <MermaidDiagramBlock source={mermaidSource} darkMode={theme === "dark"} />;
}
return <pre {...preProps}>{preChildren}</pre>;
},
a: ({ href, children: linkChildren }) => {
const parsed = href ? parseProjectMentionHref(href) : null;
if (parsed) {
const label = linkChildren;
return (
<a
href={`/projects/${parsed.projectId}`}
className="paperclip-project-mention-chip"
style={mentionChipStyle(parsed.color)}
>
{label}
</a>
);
}
return (
<a href={href} rel="noreferrer">
{linkChildren}
</a>
);
},
};
if (resolveImageSrc) {
components.img = ({ node: _node, src, alt, ...imgProps }) => {
const resolved = src ? resolveImageSrc(src) : null;
return <img {...imgProps} src={resolved ?? src} alt={alt ?? ""} />;
};
}
return (
<div
className={cn(
@@ -122,38 +160,7 @@ export function MarkdownBody({ children, className }: MarkdownBodyProps) {
className,
)}
>
<Markdown
remarkPlugins={[remarkGfm]}
components={{
pre: ({ node: _node, children: preChildren, ...preProps }) => {
const mermaidSource = extractMermaidSource(preChildren);
if (mermaidSource) {
return <MermaidDiagramBlock source={mermaidSource} darkMode={theme === "dark"} />;
}
return <pre {...preProps}>{preChildren}</pre>;
},
a: ({ href, children: linkChildren }) => {
const parsed = href ? parseProjectMentionHref(href) : null;
if (parsed) {
const label = linkChildren;
return (
<a
href={`/projects/${parsed.projectId}`}
className="paperclip-project-mention-chip"
style={mentionChipStyle(parsed.color)}
>
{label}
</a>
);
}
return (
<a href={href} rel="noreferrer">
{linkChildren}
</a>
);
},
}}
>
<Markdown remarkPlugins={[remarkGfm]} components={components}>
{children}
</Markdown>
</div>

View File

@@ -32,8 +32,6 @@ import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
import { AsciiArtAnimation } from "./AsciiArtAnimation";
import { ChoosePathButton } from "./PathInstructionsModal";
import { HintIcon } from "./agent-config-primitives";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import {
Building2,
@@ -49,7 +47,6 @@ import {
MousePointer2,
Check,
Loader2,
FolderOpen,
ChevronDown,
X
} from "lucide-react";
@@ -62,17 +59,14 @@ type AdapterType =
| "opencode_local"
| "pi_local"
| "cursor"
| "process"
| "http"
| "openclaw_gateway";
const DEFAULT_TASK_DESCRIPTION = `Setup yourself as the CEO. Use the ceo persona found here:
const DEFAULT_TASK_DESCRIPTION = `You are the CEO. You set the direction for the company.
https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md
Ensure you have a folder agents/ceo and then download this AGENTS.md, and sibling HEARTBEAT.md, SOUL.md, and TOOLS.md. and set that AGENTS.md as the path to your agents instruction file
After that, hire yourself a Founding Engineer agent and then plan the roadmap and tasks for your new company.`;
- hire a founding engineer
- write a hiring plan
- break the roadmap into concrete tasks and start delegating work`;
export function OnboardingWizard() {
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
@@ -113,7 +107,6 @@ export function OnboardingWizard() {
// Step 2
const [agentName, setAgentName] = useState("CEO");
const [adapterType, setAdapterType] = useState<AdapterType>("claude_local");
const [cwd, setCwd] = useState("");
const [model, setModel] = useState("");
const [command, setCommand] = useState("");
const [args, setArgs] = useState("");
@@ -128,7 +121,9 @@ export function OnboardingWizard() {
const [showMoreAdapters, setShowMoreAdapters] = useState(false);
// Step 3
const [taskTitle, setTaskTitle] = useState("Create your CEO HEARTBEAT.md");
const [taskTitle, setTaskTitle] = useState(
"Hire your first engineer and create a hiring plan"
);
const [taskDescription, setTaskDescription] = useState(
DEFAULT_TASK_DESCRIPTION
);
@@ -217,7 +212,7 @@ export function OnboardingWizard() {
if (step !== 2) return;
setAdapterEnvResult(null);
setAdapterEnvError(null);
}, [step, adapterType, cwd, model, command, args, url]);
}, [step, adapterType, model, command, args, url]);
const selectedModel = (adapterModels ?? []).find((m) => m.id === model);
const hasAnthropicApiKeyOverrideCheck =
@@ -273,7 +268,6 @@ export function OnboardingWizard() {
setCompanyGoal("");
setAgentName("CEO");
setAdapterType("claude_local");
setCwd("");
setModel("");
setCommand("");
setArgs("");
@@ -283,7 +277,7 @@ export function OnboardingWizard() {
setAdapterEnvLoading(false);
setForceUnsetAnthropicApiKey(false);
setUnsetAnthropicLoading(false);
setTaskTitle("Create your CEO HEARTBEAT.md");
setTaskTitle("Hire your first engineer and create a hiring plan");
setTaskDescription(DEFAULT_TASK_DESCRIPTION);
setCreatedCompanyId(null);
setCreatedCompanyPrefix(null);
@@ -301,7 +295,6 @@ export function OnboardingWizard() {
const config = adapter.buildAdapterConfig({
...defaultCreateValues,
adapterType,
cwd,
model:
adapterType === "codex_local"
? model || DEFAULT_CODEX_LOCAL_MODEL
@@ -787,12 +780,6 @@ export function OnboardingWizard() {
icon: Gem,
desc: "Local Gemini agent"
},
{
value: "process" as const,
label: "Process",
icon: Terminal,
desc: "Run a local command"
},
{
value: "opencode_local" as const,
label: "OpenCode",
@@ -874,24 +861,6 @@ export function OnboardingWizard() {
adapterType === "pi_local" ||
adapterType === "cursor") && (
<div className="space-y-3">
<div>
<div className="flex items-center gap-1.5 mb-1">
<label className="text-xs text-muted-foreground">
Working directory
</label>
<HintIcon text="Paperclip works best if you create a new folder for your agents to keep their memories and stay organized. Create a new folder and put the path here." />
</div>
<div className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<input
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/50"
placeholder="/path/to/project"
value={cwd}
onChange={(e) => setCwd(e.target.value)}
/>
<ChoosePathButton />
</div>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Model
@@ -1110,33 +1079,6 @@ export function OnboardingWizard() {
</div>
)}
{adapterType === "process" && (
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Command
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="e.g. node, python"
value={command}
onChange={(e) => setCommand(e.target.value)}
/>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
Args (comma-separated)
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder="e.g. script.js, --flag"
value={args}
onChange={(e) => setArgs(e.target.value)}
/>
</div>
</div>
)}
{(adapterType === "http" ||
adapterType === "openclaw_gateway") && (
<div>

View File

@@ -25,7 +25,7 @@ export const help: Record<string, string> = {
reportsTo: "The agent this one reports to in the org hierarchy.",
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw Gateway, spawned process, or generic HTTP webhook.",
cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.",
cwd: "Deprecated legacy working directory fallback for local adapters. Existing agents may still carry this value, but new configurations should use project workspaces instead.",
promptTemplate: "Sent on every heartbeat. Keep this small and dynamic. Use it for current-task framing, not large static instructions. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} and other template variables.",
model: "Override the default model used by the adapter.",
thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.",

View File

@@ -1,7 +1,10 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { LiveEvent } from "@paperclipai/shared";
import { instanceSettingsApi } from "../../api/instanceSettings";
import { heartbeatsApi, type LiveRunForIssue } from "../../api/heartbeats";
import { buildTranscript, getUIAdapter, type RunLogChunk, type TranscriptEntry } from "../../adapters";
import { queryKeys } from "../../lib/queryKeys";
const LOG_POLL_INTERVAL_MS = 2000;
const LOG_READ_LIMIT_BYTES = 256_000;
@@ -65,6 +68,10 @@ export function useLiveRunTranscripts({
const seenChunkKeysRef = useRef(new Set<string>());
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
const logOffsetByRunRef = useRef(new Map<string, number>());
const { data: generalSettings } = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
});
const runById = useMemo(() => new Map(runs.map((run) => [run.id, run])), [runs]);
const activeRunIds = useMemo(
@@ -267,12 +274,18 @@ export function useLiveRunTranscripts({
const transcriptByRun = useMemo(() => {
const next = new Map<string, TranscriptEntry[]>();
const censorUsernameInLogs = generalSettings?.censorUsernameInLogs === true;
for (const run of runs) {
const adapter = getUIAdapter(run.adapterType);
next.set(run.id, buildTranscript(chunksByRun.get(run.id) ?? [], adapter.parseStdoutLine));
next.set(
run.id,
buildTranscript(chunksByRun.get(run.id) ?? [], adapter.parseStdoutLine, {
censorUsernameInLogs,
}),
);
}
return next;
}, [chunksByRun, runs]);
}, [chunksByRun, generalSettings?.censorUsernameInLogs, runs]);
return {
transcriptByRun,

View File

@@ -6,6 +6,9 @@ import {
describe("normalizeRememberedInstanceSettingsPath", () => {
it("keeps known instance settings pages", () => {
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/general")).toBe(
"/instance/settings/general",
);
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/experimental")).toBe(
"/instance/settings/experimental",
);

View File

@@ -1,4 +1,4 @@
export const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/heartbeats";
export const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/general";
export function normalizeRememberedInstanceSettingsPath(rawPath: string | null): string {
if (!rawPath) return DEFAULT_INSTANCE_SETTINGS_PATH;
@@ -9,6 +9,7 @@ export function normalizeRememberedInstanceSettingsPath(rawPath: string | null):
const hash = match?.[3] ?? "";
if (
pathname === "/instance/settings/general" ||
pathname === "/instance/settings/heartbeats" ||
pathname === "/instance/settings/plugins" ||
pathname === "/instance/settings/experimental"

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
hasLegacyWorkingDirectory,
shouldShowLegacyWorkingDirectoryField,
} from "./legacy-agent-config";
describe("legacy agent config helpers", () => {
it("treats non-empty cwd values as legacy working directories", () => {
expect(hasLegacyWorkingDirectory("/tmp/workspace")).toBe(true);
expect(hasLegacyWorkingDirectory(" /tmp/workspace ")).toBe(true);
});
it("ignores nullish and blank cwd values", () => {
expect(hasLegacyWorkingDirectory("")).toBe(false);
expect(hasLegacyWorkingDirectory(" ")).toBe(false);
expect(hasLegacyWorkingDirectory(null)).toBe(false);
expect(hasLegacyWorkingDirectory(undefined)).toBe(false);
});
it("shows the deprecated field only for edit forms with an existing cwd", () => {
expect(
shouldShowLegacyWorkingDirectoryField({
isCreate: true,
adapterConfig: { cwd: "/tmp/workspace" },
}),
).toBe(false);
expect(
shouldShowLegacyWorkingDirectoryField({
isCreate: false,
adapterConfig: { cwd: "" },
}),
).toBe(false);
expect(
shouldShowLegacyWorkingDirectoryField({
isCreate: false,
adapterConfig: { cwd: "/tmp/workspace" },
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,17 @@
function asNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function hasLegacyWorkingDirectory(value: unknown): boolean {
return asNonEmptyString(value) !== null;
}
export function shouldShowLegacyWorkingDirectoryField(input: {
isCreate: boolean;
adapterConfig: Record<string, unknown> | null | undefined;
}): boolean {
if (input.isCreate) return false;
return hasLegacyWorkingDirectory(input.adapterConfig?.cwd);
}

View File

@@ -86,6 +86,7 @@ export const queryKeys = {
session: ["auth", "session"] as const,
},
instance: {
generalSettings: ["instance", "general-settings"] as const,
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,
experimentalSettings: ["instance", "experimental-settings"] as const,
},

View File

@@ -1,5 +1,6 @@
// @vitest-environment node
import { deflateRawSync } from "node:zlib";
import { describe, expect, it } from "vitest";
import { createZipArchive, readZipArchive } from "./zip";
@@ -20,6 +21,167 @@ function readString(bytes: Uint8Array, offset: number, length: number) {
return new TextDecoder().decode(bytes.slice(offset, offset + length));
}
function writeUint16(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
}
function writeUint32(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
target[offset + 2] = (value >>> 16) & 0xff;
target[offset + 3] = (value >>> 24) & 0xff;
}
function crc32(bytes: Uint8Array) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function createDeflatedZipArchive(files: Record<string, string>, rootPath: string) {
const encoder = new TextEncoder();
const localChunks: Uint8Array[] = [];
const centralChunks: Uint8Array[] = [];
let localOffset = 0;
let entryCount = 0;
for (const [relativePath, content] of Object.entries(files).sort(([a], [b]) => a.localeCompare(b))) {
const fileName = encoder.encode(`${rootPath}/${relativePath}`);
const rawBody = encoder.encode(content);
const deflatedBody = new Uint8Array(deflateRawSync(rawBody));
const checksum = crc32(rawBody);
const localHeader = new Uint8Array(30 + fileName.length);
writeUint32(localHeader, 0, 0x04034b50);
writeUint16(localHeader, 4, 20);
writeUint16(localHeader, 6, 0x0800);
writeUint16(localHeader, 8, 8);
writeUint32(localHeader, 14, checksum);
writeUint32(localHeader, 18, deflatedBody.length);
writeUint32(localHeader, 22, rawBody.length);
writeUint16(localHeader, 26, fileName.length);
localHeader.set(fileName, 30);
const centralHeader = new Uint8Array(46 + fileName.length);
writeUint32(centralHeader, 0, 0x02014b50);
writeUint16(centralHeader, 4, 20);
writeUint16(centralHeader, 6, 20);
writeUint16(centralHeader, 8, 0x0800);
writeUint16(centralHeader, 10, 8);
writeUint32(centralHeader, 16, checksum);
writeUint32(centralHeader, 20, deflatedBody.length);
writeUint32(centralHeader, 24, rawBody.length);
writeUint16(centralHeader, 28, fileName.length);
writeUint32(centralHeader, 42, localOffset);
centralHeader.set(fileName, 46);
localChunks.push(localHeader, deflatedBody);
centralChunks.push(centralHeader);
localOffset += localHeader.length + deflatedBody.length;
entryCount += 1;
}
const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0);
const archive = new Uint8Array(
localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22,
);
let offset = 0;
for (const chunk of localChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
const centralDirectoryOffset = offset;
for (const chunk of centralChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
writeUint32(archive, offset, 0x06054b50);
writeUint16(archive, offset + 8, entryCount);
writeUint16(archive, offset + 10, entryCount);
writeUint32(archive, offset + 12, centralDirectoryLength);
writeUint32(archive, offset + 16, centralDirectoryOffset);
return archive;
}
function createZipArchiveWithDirectoryEntries(rootPath: string) {
const encoder = new TextEncoder();
const entries = [
{ path: `${rootPath}/`, body: new Uint8Array(0), compressionMethod: 0 },
{ path: `${rootPath}/agents/`, body: new Uint8Array(0), compressionMethod: 0 },
{ path: `${rootPath}/agents/ceo/`, body: new Uint8Array(0), compressionMethod: 0 },
{ path: `${rootPath}/COMPANY.md`, body: encoder.encode("# Company\n"), compressionMethod: 8 },
{ path: `${rootPath}/agents/ceo/AGENTS.md`, body: encoder.encode("# CEO\n"), compressionMethod: 8 },
].map((entry) => ({
...entry,
data: entry.compressionMethod === 8 ? new Uint8Array(deflateRawSync(entry.body)) : entry.body,
checksum: crc32(entry.body),
}));
const localChunks: Uint8Array[] = [];
const centralChunks: Uint8Array[] = [];
let localOffset = 0;
for (const entry of entries) {
const fileName = encoder.encode(entry.path);
const localHeader = new Uint8Array(30 + fileName.length);
writeUint32(localHeader, 0, 0x04034b50);
writeUint16(localHeader, 4, 20);
writeUint16(localHeader, 6, 0x0800);
writeUint16(localHeader, 8, entry.compressionMethod);
writeUint32(localHeader, 14, entry.checksum);
writeUint32(localHeader, 18, entry.data.length);
writeUint32(localHeader, 22, entry.body.length);
writeUint16(localHeader, 26, fileName.length);
localHeader.set(fileName, 30);
const centralHeader = new Uint8Array(46 + fileName.length);
writeUint32(centralHeader, 0, 0x02014b50);
writeUint16(centralHeader, 4, 20);
writeUint16(centralHeader, 6, 20);
writeUint16(centralHeader, 8, 0x0800);
writeUint16(centralHeader, 10, entry.compressionMethod);
writeUint32(centralHeader, 16, entry.checksum);
writeUint32(centralHeader, 20, entry.data.length);
writeUint32(centralHeader, 24, entry.body.length);
writeUint16(centralHeader, 28, fileName.length);
writeUint32(centralHeader, 42, localOffset);
centralHeader.set(fileName, 46);
localChunks.push(localHeader, entry.data);
centralChunks.push(centralHeader);
localOffset += localHeader.length + entry.data.length;
}
const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0);
const archive = new Uint8Array(
localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22,
);
let offset = 0;
for (const chunk of localChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
const centralDirectoryOffset = offset;
for (const chunk of centralChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
writeUint32(archive, offset, 0x06054b50);
writeUint16(archive, offset + 8, entries.length);
writeUint16(archive, offset + 10, entries.length);
writeUint32(archive, offset + 12, centralDirectoryLength);
writeUint32(archive, offset + 16, centralDirectoryOffset);
return archive;
}
describe("createZipArchive", () => {
it("writes a zip archive with the export root path prefixed into each entry", () => {
const archive = createZipArchive(
@@ -51,7 +213,7 @@ describe("createZipArchive", () => {
expect(readUint16(archive, endOffset + 10)).toBe(2);
});
it("reads a Paperclip zip archive back into rootPath and file contents", () => {
it("reads a Paperclip zip archive back into rootPath and file contents", async () => {
const archive = createZipArchive(
{
"COMPANY.md": "# Company\n",
@@ -61,7 +223,7 @@ describe("createZipArchive", () => {
"paperclip-demo",
);
expect(readZipArchive(archive)).toEqual({
await expect(readZipArchive(archive)).resolves.toEqual({
rootPath: "paperclip-demo",
files: {
"COMPANY.md": "# Company\n",
@@ -71,7 +233,7 @@ describe("createZipArchive", () => {
});
});
it("round-trips binary image files without coercing them to text", () => {
it("round-trips binary image files without coercing them to text", async () => {
const archive = createZipArchive(
{
"images/company-logo.png": {
@@ -83,7 +245,7 @@ describe("createZipArchive", () => {
"paperclip-demo",
);
expect(readZipArchive(archive)).toEqual({
await expect(readZipArchive(archive)).resolves.toEqual({
rootPath: "paperclip-demo",
files: {
"images/company-logo.png": {
@@ -94,4 +256,34 @@ describe("createZipArchive", () => {
},
});
});
it("reads standard DEFLATE zip archives created outside Paperclip", async () => {
const archive = createDeflatedZipArchive(
{
"COMPANY.md": "# Company\n",
"agents/ceo/AGENTS.md": "# CEO\n",
},
"paperclip-demo",
);
await expect(readZipArchive(archive)).resolves.toEqual({
rootPath: "paperclip-demo",
files: {
"COMPANY.md": "# Company\n",
"agents/ceo/AGENTS.md": "# CEO\n",
},
});
});
it("ignores directory entries from standard zip archives", async () => {
const archive = createZipArchiveWithDirectoryEntries("paperclip-demo");
await expect(readZipArchive(archive)).resolves.toEqual({
rootPath: "paperclip-demo",
files: {
"COMPANY.md": "# Company\n",
"agents/ceo/AGENTS.md": "# CEO\n",
},
});
});
});

View File

@@ -136,10 +136,24 @@ function portableFileEntryToBytes(entry: CompanyPortabilityFileEntry): Uint8Arra
return base64ToBytes(entry.data);
}
export function readZipArchive(source: ArrayBuffer | Uint8Array): {
async function inflateZipEntry(compressionMethod: number, bytes: Uint8Array) {
if (compressionMethod === 0) return bytes;
if (compressionMethod !== 8) {
throw new Error("Unsupported zip archive: only STORE and DEFLATE entries are supported.");
}
if (typeof DecompressionStream !== "function") {
throw new Error("Unsupported zip archive: this browser cannot read compressed zip entries.");
}
const body = new Uint8Array(bytes.byteLength);
body.set(bytes);
const stream = new Blob([body]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
export async function readZipArchive(source: ArrayBuffer | Uint8Array): Promise<{
rootPath: string | null;
files: Record<string, CompanyPortabilityFileEntry>;
} {
}> {
const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
const entries: Array<{ path: string; body: CompanyPortabilityFileEntry }> = [];
let offset = 0;
@@ -164,9 +178,6 @@ export function readZipArchive(source: ArrayBuffer | Uint8Array): {
if ((generalPurposeFlag & 0x0008) !== 0) {
throw new Error("Unsupported zip archive: data descriptors are not supported.");
}
if (compressionMethod !== 0) {
throw new Error("Unsupported zip archive: only uncompressed entries are supported.");
}
const nameOffset = offset + 30;
const bodyOffset = nameOffset + fileNameLength + extraFieldLength;
@@ -175,13 +186,14 @@ export function readZipArchive(source: ArrayBuffer | Uint8Array): {
throw new Error("Invalid zip archive: truncated file contents.");
}
const archivePath = normalizeArchivePath(
textDecoder.decode(bytes.slice(nameOffset, nameOffset + fileNameLength)),
);
if (archivePath && !archivePath.endsWith("/")) {
const rawArchivePath = textDecoder.decode(bytes.slice(nameOffset, nameOffset + fileNameLength));
const archivePath = normalizeArchivePath(rawArchivePath);
const isDirectoryEntry = /\/$/.test(rawArchivePath.replace(/\\/g, "/"));
if (archivePath && !isDirectoryEntry) {
const entryBytes = await inflateZipEntry(compressionMethod, bytes.slice(bodyOffset, bodyEnd));
entries.push({
path: archivePath,
body: bytesToPortableFileEntry(archivePath, bytes.slice(bodyOffset, bodyEnd)),
body: bytesToPortableFileEntry(archivePath, entryBytes),
});
}

View File

@@ -5,12 +5,12 @@ import {
agentsApi,
type AgentKey,
type ClaudeLoginResult,
type AvailableSkill,
type AgentPermissionUpdate,
} from "../api/agents";
import { companySkillsApi } from "../api/companySkills";
import { budgetsApi } from "../api/budgets";
import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
import { ApiError } from "../api/client";
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
import { activityApi } from "../api/activity";
@@ -109,13 +109,21 @@ const SECRET_ENV_KEY_RE =
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/;
function redactPathText(value: string, censorUsernameInLogs: boolean) {
return redactHomePathUserSegments(value, { enabled: censorUsernameInLogs });
}
function redactPathValue<T>(value: T, censorUsernameInLogs: boolean): T {
return redactHomePathUserSegmentsInValue(value, { enabled: censorUsernameInLogs });
}
function shouldRedactSecretValue(key: string, value: unknown): boolean {
if (SECRET_ENV_KEY_RE.test(key)) return true;
if (typeof value !== "string") return false;
return JWT_VALUE_RE.test(value);
}
function redactEnvValue(key: string, value: unknown): string {
function redactEnvValue(key: string, value: unknown, censorUsernameInLogs: boolean): string {
if (
typeof value === "object" &&
value !== null &&
@@ -126,11 +134,11 @@ function redactEnvValue(key: string, value: unknown): string {
}
if (shouldRedactSecretValue(key, value)) return REDACTED_ENV_VALUE;
if (value === null || value === undefined) return "";
if (typeof value === "string") return redactHomePathUserSegments(value);
if (typeof value === "string") return redactPathText(value, censorUsernameInLogs);
try {
return JSON.stringify(redactHomePathUserSegmentsInValue(value));
return JSON.stringify(redactPathValue(value, censorUsernameInLogs));
} catch {
return redactHomePathUserSegments(String(value));
return redactPathText(String(value), censorUsernameInLogs);
}
}
@@ -138,7 +146,7 @@ function isMarkdown(pathValue: string) {
return pathValue.toLowerCase().endsWith(".md");
}
function formatEnvForDisplay(envValue: unknown): string {
function formatEnvForDisplay(envValue: unknown, censorUsernameInLogs: boolean): string {
const env = asRecord(envValue);
if (!env) return "<unable-to-parse>";
@@ -147,7 +155,7 @@ function formatEnvForDisplay(envValue: unknown): string {
return keys
.sort()
.map((key) => `${key}=${redactEnvValue(key, env[key])}`)
.map((key) => `${key}=${redactEnvValue(key, env[key], censorUsernameInLogs)}`)
.join("\n");
}
@@ -338,7 +346,13 @@ function WorkspaceOperationStatusBadge({ status }: { status: WorkspaceOperation[
);
}
function WorkspaceOperationLogViewer({ operation }: { operation: WorkspaceOperation }) {
function WorkspaceOperationLogViewer({
operation,
censorUsernameInLogs,
}: {
operation: WorkspaceOperation;
censorUsernameInLogs: boolean;
}) {
const [open, setOpen] = useState(false);
const { data: logData, isLoading, error } = useQuery({
queryKey: ["workspace-operation-log", operation.id],
@@ -391,7 +405,7 @@ function WorkspaceOperationLogViewer({ operation }: { operation: WorkspaceOperat
>
[{chunk.stream}]
</span>
<span className="whitespace-pre-wrap break-all">{redactHomePathUserSegments(chunk.chunk)}</span>
<span className="whitespace-pre-wrap break-all">{redactPathText(chunk.chunk, censorUsernameInLogs)}</span>
</div>
))}
</div>
@@ -402,7 +416,13 @@ function WorkspaceOperationLogViewer({ operation }: { operation: WorkspaceOperat
);
}
function WorkspaceOperationsSection({ operations }: { operations: WorkspaceOperation[] }) {
function WorkspaceOperationsSection({
operations,
censorUsernameInLogs,
}: {
operations: WorkspaceOperation[];
censorUsernameInLogs: boolean;
}) {
if (operations.length === 0) return null;
return (
@@ -467,7 +487,7 @@ function WorkspaceOperationsSection({ operations }: { operations: WorkspaceOpera
<div>
<div className="mb-1 text-xs text-red-700 dark:text-red-300">stderr excerpt</div>
<pre className="rounded-md bg-red-50 p-2 text-xs whitespace-pre-wrap break-all text-red-800 dark:bg-neutral-950 dark:text-red-100">
{redactHomePathUserSegments(operation.stderrExcerpt)}
{redactPathText(operation.stderrExcerpt, censorUsernameInLogs)}
</pre>
</div>
)}
@@ -475,11 +495,16 @@ function WorkspaceOperationsSection({ operations }: { operations: WorkspaceOpera
<div>
<div className="mb-1 text-xs text-muted-foreground">stdout excerpt</div>
<pre className="rounded-md bg-neutral-100 p-2 text-xs whitespace-pre-wrap break-all dark:bg-neutral-950">
{redactHomePathUserSegments(operation.stdoutExcerpt)}
{redactPathText(operation.stdoutExcerpt, censorUsernameInLogs)}
</pre>
</div>
)}
{operation.logRef && <WorkspaceOperationLogViewer operation={operation} />}
{operation.logRef && (
<WorkspaceOperationLogViewer
operation={operation}
censorUsernameInLogs={censorUsernameInLogs}
/>
)}
</div>
);
})}
@@ -1474,10 +1499,14 @@ function ConfigurationTab({
Lets this agent create or hire agents and implicitly assign tasks.
</p>
</div>
<Button
variant={canCreateAgents ? "default" : "outline"}
size="sm"
className="h-7 px-2.5 text-xs"
<button
type="button"
role="switch"
aria-checked={canCreateAgents}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 disabled:cursor-not-allowed disabled:opacity-50",
canCreateAgents ? "bg-green-600" : "bg-muted",
)}
onClick={() =>
updatePermissions.mutate({
canCreateAgents: !canCreateAgents,
@@ -1486,8 +1515,13 @@ function ConfigurationTab({
}
disabled={updatePermissions.isPending}
>
{canCreateAgents ? "Enabled" : "Disabled"}
</Button>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
canCreateAgents ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div>
<div className="flex items-center justify-between gap-4 text-sm">
<div className="space-y-1">
@@ -1501,10 +1535,8 @@ function ConfigurationTab({
role="switch"
aria-checked={canAssignTasks}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
canAssignTasks
? "bg-green-500 focus-visible:ring-green-500/70"
: "bg-input/50 focus-visible:ring-ring",
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 disabled:cursor-not-allowed disabled:opacity-50",
canAssignTasks ? "bg-green-600" : "bg-muted",
)}
onClick={() =>
updatePermissions.mutate({
@@ -1516,8 +1548,8 @@ function ConfigurationTab({
>
<span
className={cn(
"inline-block h-4 w-4 transform rounded-full bg-background transition-transform",
canAssignTasks ? "translate-x-6" : "translate-x-1",
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
canAssignTasks ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
@@ -3538,13 +3570,21 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
};
}, [isLive, run.companyId, run.id, run.agentId]);
const censorUsernameInLogs = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
}).data?.censorUsernameInLogs === true;
const adapterInvokePayload = useMemo(() => {
const evt = events.find((e) => e.eventType === "adapter.invoke");
return redactHomePathUserSegmentsInValue(asRecord(evt?.payload ?? null));
}, [events]);
return redactPathValue(asRecord(evt?.payload ?? null), censorUsernameInLogs);
}, [censorUsernameInLogs, events]);
const adapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
const transcript = useMemo(() => buildTranscript(logLines, adapter.parseStdoutLine), [logLines, adapter]);
const transcript = useMemo(
() => buildTranscript(logLines, adapter.parseStdoutLine, { censorUsernameInLogs }),
[adapter, censorUsernameInLogs, logLines],
);
useEffect(() => {
setTranscriptMode("nice");
@@ -3572,7 +3612,10 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return (
<div className="space-y-3">
<WorkspaceOperationsSection operations={workspaceOperations} />
<WorkspaceOperationsSection
operations={workspaceOperations}
censorUsernameInLogs={censorUsernameInLogs}
/>
{adapterInvokePayload && (
<div className="rounded-lg border border-border bg-background/60 p-3 space-y-2">
<div className="text-xs font-medium text-muted-foreground">Invocation</div>
@@ -3614,8 +3657,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div className="text-xs text-muted-foreground mb-1">Prompt</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{typeof adapterInvokePayload.prompt === "string"
? redactHomePathUserSegments(adapterInvokePayload.prompt)
: JSON.stringify(redactHomePathUserSegmentsInValue(adapterInvokePayload.prompt), null, 2)}
? redactPathText(adapterInvokePayload.prompt, censorUsernameInLogs)
: JSON.stringify(redactPathValue(adapterInvokePayload.prompt, censorUsernameInLogs), null, 2)}
</pre>
</div>
)}
@@ -3623,7 +3666,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div>
<div className="text-xs text-muted-foreground mb-1">Context</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(redactHomePathUserSegmentsInValue(adapterInvokePayload.context), null, 2)}
{JSON.stringify(redactPathValue(adapterInvokePayload.context, censorUsernameInLogs), null, 2)}
</pre>
</div>
)}
@@ -3631,7 +3674,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div>
<div className="text-xs text-muted-foreground mb-1">Environment</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap font-mono">
{formatEnvForDisplay(adapterInvokePayload.env)}
{formatEnvForDisplay(adapterInvokePayload.env, censorUsernameInLogs)}
</pre>
</div>
)}
@@ -3707,14 +3750,14 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{run.error && (
<div className="text-xs text-red-600 dark:text-red-200">
<span className="text-red-700 dark:text-red-300">Error: </span>
{redactHomePathUserSegments(run.error)}
{redactPathText(run.error, censorUsernameInLogs)}
</div>
)}
{run.stderrExcerpt && run.stderrExcerpt.trim() && (
<div>
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stderr excerpt</div>
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
{redactHomePathUserSegments(run.stderrExcerpt)}
{redactPathText(run.stderrExcerpt, censorUsernameInLogs)}
</pre>
</div>
)}
@@ -3722,7 +3765,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div>
<div className="text-xs text-red-700 dark:text-red-300 mb-1">adapter result JSON</div>
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
{JSON.stringify(redactHomePathUserSegmentsInValue(run.resultJson), null, 2)}
{JSON.stringify(redactPathValue(run.resultJson, censorUsernameInLogs), null, 2)}
</pre>
</div>
)}
@@ -3730,7 +3773,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div>
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stdout excerpt</div>
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
{redactHomePathUserSegments(run.stdoutExcerpt)}
{redactPathText(run.stdoutExcerpt, censorUsernameInLogs)}
</pre>
</div>
)}
@@ -3757,9 +3800,9 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
</span>
<span className={cn("break-all", color)}>
{evt.message
? redactHomePathUserSegments(evt.message)
? redactPathText(evt.message, censorUsernameInLogs)
: evt.payload
? JSON.stringify(redactHomePathUserSegmentsInValue(evt.payload))
? JSON.stringify(redactPathValue(evt.payload, censorUsernameInLogs))
: ""}
</span>
</div>

View File

@@ -343,36 +343,6 @@ const ROLE_LABELS: Record<string, string> = {
vp: "VP", manager: "Manager", engineer: "Engineer", agent: "Agent",
};
/** Sanitize slug for use as a Mermaid node ID. */
function mermaidId(slug: string): string {
return slug.replace(/[^a-zA-Z0-9_]/g, "_");
}
/** Escape text for Mermaid node labels. */
function mermaidEscape(s: string): string {
return s.replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/** Generate a Mermaid org chart from the selected agents. */
function generateOrgChartMermaid(agents: CompanyPortabilityManifest["agents"]): string | null {
if (agents.length === 0) return null;
const lines: string[] = [];
lines.push("```mermaid");
lines.push("graph TD");
for (const agent of agents) {
const roleLabel = ROLE_LABELS[agent.role] ?? agent.role;
lines.push(` ${mermaidId(agent.slug)}["${mermaidEscape(agent.name)}<br/><small>${mermaidEscape(roleLabel)}</small>"]`);
}
const slugSet = new Set(agents.map((a) => a.slug));
for (const agent of agents) {
if (agent.reportsToSlug && slugSet.has(agent.reportsToSlug)) {
lines.push(` ${mermaidId(agent.reportsToSlug)} --> ${mermaidId(agent.slug)}`);
}
}
lines.push("```");
return lines.join("\n");
}
/**
* Regenerate README.md content based on the currently checked files.
* Only counts/lists entities whose files are in the checked set.
@@ -400,10 +370,9 @@ function generateReadmeFromSelection(
lines.push(`> ${companyDescription}`);
lines.push("");
}
// Org chart as Mermaid diagram
const mermaid = generateOrgChartMermaid(agents);
if (mermaid) {
lines.push(mermaid);
// Org chart image (generated during export as images/org-chart.png)
if (agents.length > 0) {
lines.push("![Org Chart](images/org-chart.png)");
lines.push("");
}
@@ -470,10 +439,12 @@ function generateReadmeFromSelection(
function ExportPreviewPane({
selectedFile,
content,
allFiles,
onSkillClick,
}: {
selectedFile: string | null;
content: CompanyPortabilityFileEntry | null;
allFiles: Record<string, CompanyPortabilityFileEntry>;
onSkillClick?: (skill: string) => void;
}) {
if (!selectedFile || content === null) {
@@ -487,6 +458,20 @@ function ExportPreviewPane({
const parsed = isMarkdown && textContent ? parseFrontmatter(textContent) : null;
const imageSrc = isPortableImageFile(selectedFile, content) ? getPortableFileDataUrl(selectedFile, content) : null;
// Resolve relative image paths within the export package (e.g. images/org-chart.png)
const resolveImageSrc = isMarkdown
? (src: string) => {
// Skip absolute URLs and data URIs
if (/^(?:https?:|data:)/i.test(src)) return null;
// Resolve relative to the directory of the current markdown file
const dir = selectedFile.includes("/") ? selectedFile.slice(0, selectedFile.lastIndexOf("/") + 1) : "";
const resolved = dir + src;
const entry = allFiles[resolved] ?? allFiles[src];
if (!entry) return null;
return getPortableFileDataUrl(resolved in allFiles ? resolved : src, entry);
}
: undefined;
return (
<div className="min-w-0">
<div className="border-b border-border px-5 py-3">
@@ -496,10 +481,10 @@ function ExportPreviewPane({
{parsed ? (
<>
<FrontmatterCard data={parsed.data} onSkillClick={onSkillClick} />
{parsed.body.trim() && <MarkdownBody>{parsed.body}</MarkdownBody>}
{parsed.body.trim() && <MarkdownBody resolveImageSrc={resolveImageSrc}>{parsed.body}</MarkdownBody>}
</>
) : isMarkdown ? (
<MarkdownBody>{textContent ?? ""}</MarkdownBody>
<MarkdownBody resolveImageSrc={resolveImageSrc}>{textContent ?? ""}</MarkdownBody>
) : imageSrc ? (
<div className="flex min-h-[520px] items-center justify-center rounded-lg border border-border bg-accent/10 p-6">
<img src={imageSrc} alt={selectedFile} className="max-h-[480px] max-w-full object-contain" />
@@ -624,10 +609,12 @@ export function CompanyExport() {
const ancestors = expandAncestors(urlFile);
setExpandedDirs(new Set([...topDirs, ...ancestors]));
} else {
// Select first file and update URL
const firstFile = Object.keys(result.files)[0];
if (firstFile) {
selectFile(firstFile, true);
// Default to README.md if present, otherwise fall back to first file
const defaultFile = "README.md" in result.files
? "README.md"
: Object.keys(result.files)[0];
if (defaultFile) {
selectFile(defaultFile, true);
}
setExpandedDirs(topDirs);
}
@@ -924,7 +911,7 @@ export function CompanyExport() {
</div>
</aside>
<div className="min-w-0 overflow-y-auto pl-6">
<ExportPreviewPane selectedFile={selectedFile} content={previewContent} onSkillClick={handleSkillClick} />
<ExportPreviewPane selectedFile={selectedFile} content={previewContent} allFiles={effectiveFiles} onSkillClick={handleSkillClick} />
</div>
</div>
</div>

View File

@@ -177,11 +177,13 @@ function importFileRowClassName(_node: FileTreeNode, checked: boolean) {
function ImportPreviewPane({
selectedFile,
content,
allFiles,
action,
renamedTo,
}: {
selectedFile: string | null;
content: CompanyPortabilityFileEntry | null;
allFiles: Record<string, CompanyPortabilityFileEntry>;
action: string | null;
renamedTo: string | null;
}) {
@@ -197,6 +199,18 @@ function ImportPreviewPane({
const imageSrc = isPortableImageFile(selectedFile, content) ? getPortableFileDataUrl(selectedFile, content) : null;
const actionColor = action ? (ACTION_COLORS[action] ?? ACTION_COLORS.skip) : "";
// Resolve relative image paths within the import package
const resolveImageSrc = isMarkdown
? (src: string) => {
if (/^(?:https?:|data:)/i.test(src)) return null;
const dir = selectedFile.includes("/") ? selectedFile.slice(0, selectedFile.lastIndexOf("/") + 1) : "";
const resolved = dir + src;
const entry = allFiles[resolved] ?? allFiles[src];
if (!entry) return null;
return getPortableFileDataUrl(resolved in allFiles ? resolved : src, entry);
}
: undefined;
return (
<div className="min-w-0">
<div className="border-b border-border px-5 py-3">
@@ -223,10 +237,10 @@ function ImportPreviewPane({
{parsed ? (
<>
<FrontmatterCard data={parsed.data} />
{parsed.body.trim() && <MarkdownBody>{parsed.body}</MarkdownBody>}
{parsed.body.trim() && <MarkdownBody resolveImageSrc={resolveImageSrc}>{parsed.body}</MarkdownBody>}
</>
) : isMarkdown ? (
<MarkdownBody>{textContent ?? ""}</MarkdownBody>
<MarkdownBody resolveImageSrc={resolveImageSrc}>{textContent ?? ""}</MarkdownBody>
) : imageSrc ? (
<div className="flex min-h-[520px] items-center justify-center rounded-lg border border-border bg-accent/10 p-6">
<img src={imageSrc} alt={selectedFile} className="max-h-[480px] max-w-full object-contain" />
@@ -574,7 +588,7 @@ async function readLocalPackageZip(file: File): Promise<{
if (!/\.zip$/i.test(file.name)) {
throw new Error("Select a .zip company package.");
}
const archive = readZipArchive(await file.arrayBuffer());
const archive = await readZipArchive(await file.arrayBuffer());
if (Object.keys(archive.files).length === 0) {
throw new Error("No package files were found in the selected zip archive.");
}
@@ -641,6 +655,9 @@ export function CompanyImport() {
return ceo?.adapterType ?? "claude_local";
}, [companyAgents]);
const localZipHelpText =
"Upload a .zip exported directly from Paperclip. Re-zipped archives created by Finder, Explorer, or other zip tools may not import correctly.";
useEffect(() => {
setBreadcrumbs([
{ label: "Org Chart", href: "/org" },
@@ -1079,7 +1096,7 @@ export function CompanyImport() {
</div>
{!localPackage && (
<p className="mt-2 text-xs text-muted-foreground">
Upload a `.zip` exported from Paperclip that contains COMPANY.md and the related package files.
{localZipHelpText}
</p>
)}
</div>
@@ -1265,6 +1282,7 @@ export function CompanyImport() {
<ImportPreviewPane
selectedFile={selectedFile}
content={previewContent}
allFiles={importPreview?.files ?? {}}
action={selectedAction}
renamedTo={selectedFile ? (renameMap.get(selectedFile) ?? null) : null}
/>

View File

@@ -148,6 +148,8 @@ function sourceMeta(sourceBadge: CompanySkillSourceBadge, sourceLabel: string |
normalizedLabel.includes("skills.sh") || normalizedLabel.includes("vercel-labs/skills");
switch (sourceBadge) {
case "skills_sh":
return { icon: VercelMark, label: sourceLabel ?? "skills.sh", managedLabel: "skills.sh managed" };
case "github":
return isSkillsShManaged
? { icon: VercelMark, label: sourceLabel ?? "skills.sh", managedLabel: "skills.sh managed" }

View File

@@ -24,11 +24,14 @@ export function InstanceExperimentalSettings() {
});
const toggleMutation = useMutation({
mutationFn: async (enabled: boolean) =>
instanceSettingsApi.updateExperimental({ enableIsolatedWorkspaces: enabled }),
mutationFn: async (patch: { enableIsolatedWorkspaces?: boolean; autoRestartDevServerWhenIdle?: boolean }) =>
instanceSettingsApi.updateExperimental(patch),
onSuccess: async () => {
setActionError(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings });
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }),
queryClient.invalidateQueries({ queryKey: queryKeys.health }),
]);
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to update experimental settings.");
@@ -50,6 +53,7 @@ export function InstanceExperimentalSettings() {
}
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
return (
<div className="max-w-4xl space-y-6">
@@ -72,7 +76,7 @@ export function InstanceExperimentalSettings() {
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Enabled Isolated Workspaces</h2>
<h2 className="text-sm font-semibold">Enable Isolated Workspaces</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Show execution workspace controls in project configuration and allow isolated workspace behavior for new
and existing issue runs.
@@ -83,15 +87,46 @@ export function InstanceExperimentalSettings() {
aria-label="Toggle isolated workspaces experimental setting"
disabled={toggleMutation.isPending}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
enableIsolatedWorkspaces ? "bg-green-600" : "bg-muted",
)}
onClick={() => toggleMutation.mutate(!enableIsolatedWorkspaces)}
onClick={() => toggleMutation.mutate({ enableIsolatedWorkspaces: !enableIsolatedWorkspaces })}
>
<span
className={cn(
"inline-block h-4.5 w-4.5 rounded-full bg-white transition-transform",
enableIsolatedWorkspaces ? "translate-x-6" : "translate-x-0.5",
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
enableIsolatedWorkspaces ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Auto-Restart Dev Server When Idle</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
In `pnpm dev:once`, wait for all queued and running local agent runs to finish, then restart the server
automatically when backend changes or migrations make the current boot stale.
</p>
</div>
<button
type="button"
aria-label="Toggle guarded dev-server auto-restart"
disabled={toggleMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
autoRestartDevServerWhenIdle ? "bg-green-600" : "bg-muted",
)}
onClick={() =>
toggleMutation.mutate({ autoRestartDevServerWhenIdle: !autoRestartDevServerWhenIdle })
}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
autoRestartDevServerWhenIdle ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>

View File

@@ -0,0 +1,103 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { SlidersHorizontal } from "lucide-react";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
export function InstanceGeneralSettings() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
setBreadcrumbs([
{ label: "Instance Settings" },
{ label: "General" },
]);
}, [setBreadcrumbs]);
const generalQuery = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
});
const toggleMutation = useMutation({
mutationFn: async (enabled: boolean) =>
instanceSettingsApi.updateGeneral({ censorUsernameInLogs: enabled }),
onSuccess: async () => {
setActionError(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.instance.generalSettings });
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to update general settings.");
},
});
if (generalQuery.isLoading) {
return <div className="text-sm text-muted-foreground">Loading general settings...</div>;
}
if (generalQuery.error) {
return (
<div className="text-sm text-destructive">
{generalQuery.error instanceof Error
? generalQuery.error.message
: "Failed to load general settings."}
</div>
);
}
const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true;
return (
<div className="max-w-4xl space-y-6">
<div className="space-y-2">
<div className="flex items-center gap-2">
<SlidersHorizontal className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">General</h1>
</div>
<p className="text-sm text-muted-foreground">
Configure instance-wide defaults that affect how operator-visible logs are displayed.
</p>
</div>
{actionError && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{actionError}
</div>
)}
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Censor username in logs</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Hide the username segment in home-directory paths and similar operator-visible log output. Standalone
username mentions outside of paths are not yet masked in the live transcript view. This is off by
default.
</p>
</div>
<button
type="button"
aria-label="Toggle username log censoring"
disabled={toggleMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
censorUsernameInLogs ? "bg-green-600" : "bg-muted",
)}
onClick={() => toggleMutation.mutate(!censorUsernameInLogs)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
censorUsernameInLogs ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div>
</section>
</div>
);
}