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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,8 +362,8 @@ When `VERCEL_TARGET_ENV` is absent in local development or a script process, tra
- `KILO_BIN_PATH` - Path or name of the `kilo` CLI binary; used by `services/cloud-agent-next/scripts/update-default-slash-commands.mjs`. [SERVER]
- `WORKSPACE_PATH` - Filesystem path of the agent workspace. [SERVER]
- `SESSION_ID` - Reserved session identifier for the `cloud-agent-next` runtime; reserved in `RESERVED_ENV_VARS`. [SERVER]
- `CONTROL_PLANE_IDS` - Comma-separated user or org IDs admitted to the call-home control plane at session creation. Empty admits nobody. `*` includes personal accounts. Does not enable new worktree creation by itself; that also requires `WORKTREE_CREATION_ENABLED_IDS` enrollment. [SERVER]
- `WORKTREE_CREATION_ENABLED_IDS` - Comma-separated user or org IDs allowed to create new worktrees, or `*` for all, including personal accounts. Defaults to empty/off and also requires enrollment in `CONTROL_PLANE_IDS`. Disabling it does not block existing worktrees or sibling chats in them. [SERVER]
- `CONTROL_PLANE_IDS` - Comma-separated user or org IDs admitted to the call-home control plane at session creation. Empty admits nobody. `*` includes personal accounts. Production defaults to empty. Wrangler `dev` defaults to `*`. Does not enable new worktree creation by itself; that also requires `WORKTREE_CREATION_ENABLED_IDS` enrollment. [SERVER]
- `WORKTREE_CREATION_ENABLED_IDS` - Comma-separated user or org IDs allowed to create new worktrees, or `*` for all, including personal accounts. Production defaults to empty/off. Wrangler `dev` defaults to `*`. Also requires enrollment in `CONTROL_PLANE_IDS`. Disabling it does not block existing worktrees or sibling chats in them. [SERVER]
- `VERCEL_SANDBOX_ORG_IDS` - Comma-separated org IDs routed to Vercel sandboxes. Empty is off. `*` includes personal accounts. [SERVER]
- `HOME` - Reserved in `RESERVED_ENV_VARS` for cloud-agent-next session home management. [SYSTEM]

Expand Down
115 changes: 115 additions & 0 deletions apps/web/src/components/cloud-agent-next/ApplyPatchToolCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { FileDiff } from 'lucide-react';
import { ToolCardShell } from './ToolCardShell';
import { ToolDiff, ToolDiffStats, ToolFilePath } from './ToolDiff';
import { ToolCodeBlock } from './ToolOutput';
import {
getUnifiedPatch,
MAX_TOOL_DIFF_CHARACTERS,
readApplyPatchFiles,
sumFileChanges,
} from './toolDiffUtils';
import type { ToolPart } from './types';

type ApplyPatchToolCardProps = {
toolPart: ToolPart;
};

const changeLabels = {
add: 'Added',
update: 'Updated',
delete: 'Deleted',
move: 'Moved',
};

export function ApplyPatchToolCard({ toolPart }: ApplyPatchToolCardProps) {
const state = toolPart.state;
const files = readApplyPatchFiles(state.status === 'pending' ? undefined : state.metadata);
const single = files.length === 1 ? files[0] : undefined;
const patchText = typeof state.input.patchText === 'string' ? state.input.patchText : undefined;
const error = state.status === 'error' ? state.error : undefined;

return (
<ToolCardShell
icon={FileDiff}
title="Apply patch"
subtitle={
single ? (
<ToolFilePath filePath={single.relativePath ?? single.movePath ?? single.filePath} />
) : files.length > 0 ? (
`${files.length} files`
) : undefined
}
badge={<ToolDiffStats {...sumFileChanges(files)} />}
status={state.status}
>
{files.map((file, index) => {
const filePath = file.relativePath ?? file.movePath ?? file.filePath;
return (
<section key={`${index}:${filePath}`} className="min-w-0 space-y-2">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-xs">
<span className="text-muted-foreground">
{file.type ? changeLabels[file.type] : 'Changed'}
</span>
<code className="min-w-0 break-all">{filePath}</code>
<ToolDiffStats additions={file.additions} deletions={file.deletions} />
</div>
{file.type === 'move' && (
<div className="text-muted-foreground space-y-1 text-xs">
<div>
From: <code className="break-all">{file.filePath ?? 'Unknown source'}</code>
</div>
<div>
To:{' '}
<code className="break-all">
{file.movePath ?? file.relativePath ?? 'Unknown destination'}
</code>
</div>
</div>
)}
<ToolDiff
patch={getUnifiedPatch(file.patch) ?? getUnifiedPatch(file.diff)}
filePath={filePath}
/>
</section>
);
})}
{files.length === 0 && (
<div className="space-y-2">
<div className="text-muted-foreground text-xs">
File summaries and diff preview unavailable: no usable file metadata was provided.
</div>
{patchText !== undefined && patchText.length > 0 && (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">Patch input (not an applied diff)</div>
<pre
className="bg-background focus-visible:ring-ring max-h-60 overflow-auto rounded-md p-2 text-xs focus-visible:ring-2 focus-visible:outline-none"
tabIndex={0}
role="region"
aria-label="Patch input"
>
<code>{patchText.slice(0, MAX_TOOL_DIFF_CHARACTERS)}</code>
</pre>
{patchText.length > MAX_TOOL_DIFF_CHARACTERS && (
<div className="text-muted-foreground text-xs">Patch input truncated.</div>
)}
</div>
)}
{state.status === 'completed' && state.output.trim() && (
<ToolCodeBlock content={state.output} label="Output" />
)}
</div>
)}
{error && (
<pre className="bg-background text-destructive max-h-40 overflow-auto rounded-md p-2 text-xs">
<code>{error}</code>
</pre>
)}
{state.status === 'running' && (
<div className="text-muted-foreground text-xs">Applying patch…</div>
)}
{state.status === 'pending' && (
<div className="text-muted-foreground text-xs">Waiting to apply patch…</div>
)}
</ToolCardShell>
);
}
158 changes: 158 additions & 0 deletions apps/web/src/components/cloud-agent-next/BackgroundProcessToolCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { Fragment } from 'react';
import { Terminal } from 'lucide-react';
import * as z from 'zod';
import { ToolCardShell } from './ToolCardShell';
import { ToolCodeBlock } from './ToolOutput';
import { normalizeTerminalOutput } from './normalize-terminal-output';
import type { ToolPart } from './types';

const actionTitles = new Map([
['start', 'Start background process'],
['list', 'List background processes'],
['status', 'Check background process'],
['logs', 'View background logs'],
['stop', 'Stop background process'],
['restart', 'Restart background process'],
]);
const structuredActions = new Set(['start', 'status', 'stop', 'restart']);
const structuredKeys = new Set(['id', 'status', 'pid', 'cwd', 'command', 'last_output', 'output']);
const resultSchema = z.record(z.string(), z.unknown());
const readinessSchema = z.object({
port: z.number().int().positive().optional(),
pattern: z.string().optional(),
timeout: z.number().positive().optional(),
});

function text(value: unknown): string | undefined {
if (typeof value === 'string' && value.trim()) return value;
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
if (typeof value === 'boolean') return String(value);
return undefined;
}

function structuredOutput(raw: string, enabled: boolean) {
const fields = new Map<string, string>();
if (!enabled) return { fields, output: raw };

let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
parsed = undefined;
}
const result = resultSchema.safeParse(parsed);
if (result.success) {
const rest = Object.fromEntries(
Object.entries(result.data).filter(([key, value]) => {
const valueText = text(value);
if (!structuredKeys.has(key) || valueText === undefined) return true;
fields.set(key, valueText);
return false;
})
);
return {
fields,
output:
fields.size === 0 ? raw : Object.keys(rest).length ? JSON.stringify(rest, null, 2) : '',
};
}

const rest: string[] = [];
for (const line of raw.split('\n')) {
const match = /^([a-z_]+):\s*(.*)$/.exec(line);
const key = match?.[1];
const value = match?.[2].trim();
if (key && structuredKeys.has(key) && value && !fields.has(key)) {
fields.set(key, value);
} else {
rest.push(line);
}
}
return { fields, output: rest.join('\n').trimEnd() };
}

export function BackgroundProcessToolCard({ toolPart }: { toolPart: ToolPart }) {
const state = toolPart.state;
const input = state.input;
const metadata = state.status === 'pending' ? undefined : state.metadata;
const action = text(input.action) ?? 'status';
const rawOutput = state.status === 'completed' ? normalizeTerminalOutput(state.output) : '';
const data = structuredOutput(
rawOutput,
state.status === 'completed' && structuredActions.has(action)
);
const id = data.fields.get('id') ?? text(metadata?.processID) ?? text(input.id);
const status = data.fields.get('status') ?? text(metadata?.status);
const command = text(input.command) ?? data.fields.get('command');
const description = text(input.description);
const cwd = data.fields.get('cwd') ?? text(input.cwd) ?? text(input.workdir);
const parsedReadiness = readinessSchema.safeParse(input.ready);
const readiness = parsedReadiness.success ? parsedReadiness.data : undefined;
const rows: [string, string | undefined][] = [
['Description', description],
['Process id', id],
['Status', status],
['PID', data.fields.get('pid')],
['Cwd', cwd],
['Readiness port', text(readiness?.port)],
['Readiness pattern', text(readiness?.pattern)],
['Readiness timeout', readiness?.timeout !== undefined ? `${readiness.timeout} ms` : undefined],
];
const output = normalizeTerminalOutput(
[data.fields.get('output'), data.output].filter(Boolean).join('\n\n')
);
const lastOutput = normalizeTerminalOutput(data.fields.get('last_output') ?? '');
const count = metadata?.count;
const subtitle =
action === 'list' && typeof count === 'number' && Number.isInteger(count) && count >= 0
? `${count} ${count === 1 ? 'process' : 'processes'}`
: (command ?? description ?? id);

return (
<ToolCardShell
icon={Terminal}
title={actionTitles.get(action) ?? 'Background process'}
subtitle={subtitle}
status={state.status}
>
<div
role="region"
tabIndex={0}
aria-label="Background process details"
className="focus-visible:ring-ring max-h-96 min-w-0 space-y-2 overflow-auto focus-visible:ring-2 focus-visible:outline-none"
>
{command && <ToolCodeBlock content={command} label="Command" />}
{rows.some(([, value]) => value !== undefined) && (
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs">
{rows.map(([label, value]) =>
value !== undefined ? (
<Fragment key={label}>
<dt className="text-muted-foreground">{label}</dt>
<dd className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
</Fragment>
) : null
)}
</dl>
)}
{lastOutput && <ToolCodeBlock content={lastOutput} label="Last output" />}
{output.trim() ? <ToolCodeBlock content={output} label="Output" /> : null}
{state.status === 'completed' && !rawOutput.trim() && (
<div className="text-muted-foreground text-xs">No output.</div>
)}
{state.status === 'error' && (
<ToolCodeBlock
content={normalizeTerminalOutput(state.error)}
label="Error"
className="[&_pre]:text-destructive"
/>
)}
{state.status === 'running' && (
<div className="text-muted-foreground text-xs">Waiting for process result...</div>
)}
{state.status === 'pending' && (
<div className="text-muted-foreground text-xs">Waiting...</div>
)}
</div>
</ToolCardShell>
);
}
Loading
Loading