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
commit e3c92a20f1
96 changed files with 15366 additions and 1684 deletions

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>
);
}