diff --git a/src/components/chat/ToolCallDisplay.tsx b/src/components/chat/ToolCallDisplay.tsx
index cf884bb4..746b4745 100644
--- a/src/components/chat/ToolCallDisplay.tsx
+++ b/src/components/chat/ToolCallDisplay.tsx
@@ -106,38 +106,41 @@ export function ToolCallDisplay({
{formatOutput(toolCall.output)}
) : (
- <>
- {toolCall.input !== undefined && toolCall.input !== null && (
-
-
- Input:
-
-
- {formatOutput(toolCall.input)}
-
-
- )}
- {toolCall.output !== undefined && toolCall.output !== null && (
-
-
- Output:
-
-
- {formatOutput(toolCall.output).slice(0, 500)}
-
-
- )}
- {toolCall.error !== undefined && (
-
-
- Error:
-
-
- {formatOutput(toolCall.error)}
-
-
- )}
- >
+ (() => {
+ const details = toolInfo.getDetails?.({
+ input: toolCall.input,
+ output: toolCall.output,
+ status: toolCall.status,
+ });
+ return (
+ <>
+ {details && (
+
+ {details}
+
+ )}
+ {toolCall.error !== undefined && (
+
+ {formatOutput(toolCall.error)}
+
+ )}
+ {!details && toolCall.error === undefined && (
+ <>
+ {toolCall.output !== undefined &&
+ toolCall.output !== null ? (
+
+ {formatOutput(toolCall.output).slice(0, 600)}
+
+ ) : (
+
+ No additional details.
+
+ )}
+ >
+ )}
+ >
+ );
+ })()
)}
)}
diff --git a/src/components/chat/tools/registry.tsx b/src/components/chat/tools/registry.tsx
index 68bb626e..adb7a2bf 100644
--- a/src/components/chat/tools/registry.tsx
+++ b/src/components/chat/tools/registry.tsx
@@ -31,6 +31,16 @@ export interface ToolInfo {
openFileOnClick?: boolean;
/** Extract file path from tool input for openFileOnClick */
getFilePath?: (input: unknown) => string | null;
+ /**
+ * Short text shown when the tool call is expanded. Should be a few short
+ * lines at most. Return null/empty to render nothing extra (errors are
+ * always shown by the host).
+ */
+ getDetails?: (args: {
+ input: unknown;
+ output: unknown;
+ status: "running" | "success" | "error";
+ }) => string | null;
}
/**
@@ -321,3 +331,46 @@ registerTool("context7_get-library-docs", {
return typeof obj.topic === "string" ? obj.topic : null;
},
});
+
+// Doce Preview tools (internal)
+function readField(value: unknown, key: string): string | null {
+ if (!value || typeof value !== "object") return null;
+ const v = (value as Record)[key];
+ return typeof v === "string" && v.trim() ? v.trim() : null;
+}
+
+registerTool("get_doce_preview_status", {
+ name: "Preview Status",
+ icon: Search,
+ getDetails: ({ output }) =>
+ readField(output, "summary") ?? readField(output, "error"),
+});
+
+registerTool("read_doce_preview_logs", {
+ name: "Preview Logs",
+ icon: FileText,
+ getContext: (input) => {
+ if (!input || typeof input !== "object") return null;
+ const mode = (input as Record).mode as string;
+ return mode ?? "summary";
+ },
+ getDetails: ({ output }) => {
+ const signal = readField(output, "extractedSignal");
+ const summary = readField(output, "summary");
+ const error = readField(output, "error");
+ if (signal && summary) return `${summary}\n${signal}`;
+ return summary ?? signal ?? error;
+ },
+});
+
+registerTool("restart_doce_preview", {
+ name: "Restart Preview",
+ icon: RefreshCw,
+ getDetails: ({ input, output }) => {
+ const reason = readField(input, "reason");
+ const summary = readField(output, "summary");
+ const error = readField(output, "error");
+ const parts = [reason, summary ?? error].filter(Boolean);
+ return parts.length ? parts.join("\n") : null;
+ },
+});
diff --git a/src/pages/api/internal/ai-tools/preview/logs.ts b/src/pages/api/internal/ai-tools/preview/logs.ts
new file mode 100644
index 00000000..adc0c5aa
--- /dev/null
+++ b/src/pages/api/internal/ai-tools/preview/logs.ts
@@ -0,0 +1,42 @@
+import type { APIRoute } from "astro";
+import { readDocePreviewLogs } from "@/server/ai-tools/doce-preview";
+import { readDocePreviewLogsInput } from "@/server/ai-tools/doce-preview/schemas";
+import {
+ authorizeInternalCall,
+ jsonResponse,
+ parseInternalRequest,
+} from "@/server/ai-tools/internalRequest";
+import { logger } from "@/server/logger";
+
+export const POST: APIRoute = async ({ request }) => {
+ const parsed = await parseInternalRequest(request);
+ if (!parsed.ok) return parsed.response;
+
+ const auth = await authorizeInternalCall(parsed.body);
+ if (!auth.ok) return auth.response;
+
+ const inputResult = readDocePreviewLogsInput.safeParse({
+ projectId: auth.result.projectId,
+ mode: parsed.body.mode,
+ maxBytes: parsed.body.maxBytes,
+ offset: parsed.body.offset,
+ });
+ if (!inputResult.success) {
+ return jsonResponse(400, { error: inputResult.error.message });
+ }
+
+ try {
+ const output = await readDocePreviewLogs(
+ inputResult.data,
+ auth.result.ownerUserId,
+ );
+ return jsonResponse(200, output);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Internal error";
+ logger.error(
+ { projectId: auth.result.projectId, error: message },
+ "read_doce_preview_logs failed",
+ );
+ return jsonResponse(500, { error: message });
+ }
+};
diff --git a/src/pages/api/internal/ai-tools/preview/restart.ts b/src/pages/api/internal/ai-tools/preview/restart.ts
new file mode 100644
index 00000000..db4b79f4
--- /dev/null
+++ b/src/pages/api/internal/ai-tools/preview/restart.ts
@@ -0,0 +1,40 @@
+import type { APIRoute } from "astro";
+import { restartDocePreview } from "@/server/ai-tools/doce-preview";
+import { restartDocePreviewInput } from "@/server/ai-tools/doce-preview/schemas";
+import {
+ authorizeInternalCall,
+ jsonResponse,
+ parseInternalRequest,
+} from "@/server/ai-tools/internalRequest";
+import { logger } from "@/server/logger";
+
+export const POST: APIRoute = async ({ request }) => {
+ const parsed = await parseInternalRequest(request);
+ if (!parsed.ok) return parsed.response;
+
+ const auth = await authorizeInternalCall(parsed.body);
+ if (!auth.ok) return auth.response;
+
+ const inputResult = restartDocePreviewInput.safeParse({
+ projectId: auth.result.projectId,
+ reason: parsed.body.reason,
+ });
+ if (!inputResult.success) {
+ return jsonResponse(400, { error: inputResult.error.message });
+ }
+
+ try {
+ const output = await restartDocePreview(
+ inputResult.data,
+ auth.result.ownerUserId,
+ );
+ return jsonResponse(200, output);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Internal error";
+ logger.error(
+ { projectId: auth.result.projectId, error: message },
+ "restart_doce_preview failed",
+ );
+ return jsonResponse(500, { error: message });
+ }
+};
diff --git a/src/pages/api/internal/ai-tools/preview/status.ts b/src/pages/api/internal/ai-tools/preview/status.ts
new file mode 100644
index 00000000..af6e4256
--- /dev/null
+++ b/src/pages/api/internal/ai-tools/preview/status.ts
@@ -0,0 +1,39 @@
+import type { APIRoute } from "astro";
+import { getDocePreviewStatus } from "@/server/ai-tools/doce-preview";
+import { getDocePreviewStatusInput } from "@/server/ai-tools/doce-preview/schemas";
+import {
+ authorizeInternalCall,
+ jsonResponse,
+ parseInternalRequest,
+} from "@/server/ai-tools/internalRequest";
+import { logger } from "@/server/logger";
+
+export const POST: APIRoute = async ({ request }) => {
+ const parsed = await parseInternalRequest(request);
+ if (!parsed.ok) return parsed.response;
+
+ const auth = await authorizeInternalCall(parsed.body);
+ if (!auth.ok) return auth.response;
+
+ const inputResult = getDocePreviewStatusInput.safeParse({
+ projectId: auth.result.projectId,
+ });
+ if (!inputResult.success) {
+ return jsonResponse(400, { error: inputResult.error.message });
+ }
+
+ try {
+ const output = await getDocePreviewStatus(
+ inputResult.data,
+ auth.result.ownerUserId,
+ );
+ return jsonResponse(200, output);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Internal error";
+ logger.error(
+ { projectId: auth.result.projectId, error: message },
+ "get_doce_preview_status failed",
+ );
+ return jsonResponse(500, { error: message });
+ }
+};
diff --git a/src/server/ai-tools/doce-preview/index.ts b/src/server/ai-tools/doce-preview/index.ts
new file mode 100644
index 00000000..9c5c6c33
--- /dev/null
+++ b/src/server/ai-tools/doce-preview/index.ts
@@ -0,0 +1,23 @@
+export {
+ getDocePreviewStatus,
+ readDocePreviewLogs,
+ restartDocePreview,
+} from "./service";
+
+export {
+ getDocePreviewStatusInput,
+ getDocePreviewStatusOutput,
+ readDocePreviewLogsInput,
+ readDocePreviewLogsOutput,
+ restartDocePreviewInput,
+ restartDocePreviewOutput,
+} from "./schemas";
+
+export type {
+ GetDocePreviewStatusInput,
+ GetDocePreviewStatusOutput,
+ ReadDocePreviewLogsInput,
+ ReadDocePreviewLogsOutput,
+ RestartDocePreviewInput,
+ RestartDocePreviewOutput,
+} from "./schemas";
diff --git a/src/server/ai-tools/doce-preview/schemas.ts b/src/server/ai-tools/doce-preview/schemas.ts
new file mode 100644
index 00000000..3c73ea85
--- /dev/null
+++ b/src/server/ai-tools/doce-preview/schemas.ts
@@ -0,0 +1,91 @@
+import { z } from "zod";
+
+// ============================================================================
+// get_doce_preview_status
+// ============================================================================
+
+export const getDocePreviewStatusInput = z.object({
+ projectId: z.string().min(1),
+});
+
+export const getDocePreviewStatusOutput = z.object({
+ ok: z.boolean(),
+ projectId: z.string(),
+ projectStatus: z.string(),
+ preview: z.object({
+ reachable: z.boolean(),
+ url: z.string().optional(),
+ httpStatus: z.number().int().optional(),
+ }),
+ containers: z.array(
+ z.object({
+ service: z.string(),
+ state: z.string(),
+ health: z.string().optional(),
+ }),
+ ),
+ logStreamingActive: z.boolean().optional(),
+ summary: z.string(),
+});
+
+export type GetDocePreviewStatusInput = z.infer<
+ typeof getDocePreviewStatusInput
+>;
+export type GetDocePreviewStatusOutput = z.infer<
+ typeof getDocePreviewStatusOutput
+>;
+
+// ============================================================================
+// read_doce_preview_logs
+// ============================================================================
+
+export const readDocePreviewLogsInput = z.object({
+ projectId: z.string().min(1),
+ mode: z.enum(["summary", "tail", "sinceOffset"]).default("summary"),
+ maxBytes: z.number().int().min(256).max(16384).optional(),
+ offset: z.number().int().min(0).optional(),
+});
+
+export const readDocePreviewLogsOutput = z.object({
+ ok: z.boolean(),
+ projectId: z.string(),
+ mode: z.enum(["summary", "tail", "sinceOffset"]),
+ content: z.string().optional(),
+ nextOffset: z.number().int().optional(),
+ truncated: z.boolean().optional(),
+ extractedSignal: z.string().nullable().optional(),
+ summary: z.string(),
+});
+
+export type ReadDocePreviewLogsInput = z.infer<
+ typeof readDocePreviewLogsInput
+>;
+export type ReadDocePreviewLogsOutput = z.infer<
+ typeof readDocePreviewLogsOutput
+>;
+
+// ============================================================================
+// restart_doce_preview
+// ============================================================================
+
+export const restartDocePreviewInput = z.object({
+ projectId: z.string().min(1),
+ reason: z.string().max(300).optional(),
+});
+
+export const restartDocePreviewOutput = z.object({
+ ok: z.boolean(),
+ projectId: z.string(),
+ restarted: z.boolean(),
+ command: z.literal("docker compose restart preview"),
+ previewReachableAfterRestart: z.boolean().optional(),
+ summary: z.string(),
+ error: z.string().optional(),
+});
+
+export type RestartDocePreviewInput = z.infer<
+ typeof restartDocePreviewInput
+>;
+export type RestartDocePreviewOutput = z.infer<
+ typeof restartDocePreviewOutput
+>;
diff --git a/src/server/ai-tools/doce-preview/service.ts b/src/server/ai-tools/doce-preview/service.ts
new file mode 100644
index 00000000..c6b0920f
--- /dev/null
+++ b/src/server/ai-tools/doce-preview/service.ts
@@ -0,0 +1,308 @@
+import * as path from "node:path";
+import { composePs, parseComposePs, runComposeCommand } from "@/server/docker/compose";
+import {
+ extractLastErrorLine,
+ readLogFromOffset,
+ readLogTail,
+} from "@/server/docker/logs";
+import { logger } from "@/server/logger";
+import { checkPreviewReady } from "@/server/projects/health";
+import { getProjectPreviewPath } from "@/server/projects/paths";
+import { getProjectById } from "@/server/projects/projects.model";
+import type {
+ GetDocePreviewStatusInput,
+ GetDocePreviewStatusOutput,
+ ReadDocePreviewLogsInput,
+ ReadDocePreviewLogsOutput,
+ RestartDocePreviewInput,
+ RestartDocePreviewOutput,
+} from "./schemas";
+
+// ============================================================================
+// get_doce_preview_status
+// ============================================================================
+
+export async function getDocePreviewStatus(
+ input: GetDocePreviewStatusInput,
+ userId: string,
+): Promise {
+ const { projectId } = input;
+
+ const project = await loadAndVerifyProject(projectId, userId);
+ if (!project) {
+ return buildErrorStatus(projectId, "Project not found or access denied");
+ }
+
+ const previewPath = getProjectPreviewPath(projectId);
+ const composeResult = await composePs(projectId, previewPath);
+ const containers = composeResult.success
+ ? parseComposePs(composeResult.stdout)
+ : [];
+
+ const previewReachable = await checkPreviewReady(projectId);
+
+ const summary = buildStatusSummary({
+ projectStatus: project.status,
+ previewReachable,
+ containers,
+ });
+
+ return {
+ ok: true,
+ projectId,
+ projectStatus: project.status,
+ preview: {
+ reachable: previewReachable,
+ },
+ containers: containers.map((c) => ({
+ service: c.service,
+ state: c.state,
+ health: c.health,
+ })),
+ summary,
+ };
+}
+
+function buildStatusSummary(args: {
+ projectStatus: string;
+ previewReachable: boolean;
+ containers: Array<{ service: string; state: string }>;
+}): string {
+ if (args.previewReachable) {
+ return "Preview is reachable and serving traffic.";
+ }
+
+ const previewContainer = args.containers.find((c) => c.service === "preview");
+ if (!previewContainer) {
+ return "Preview container not found; containers may not be running.";
+ }
+
+ if (previewContainer.state !== "running") {
+ return `Preview container is ${previewContainer.state}.`;
+ }
+
+ return "Preview container is running but not responding to health checks.";
+}
+
+function buildErrorStatus(
+ projectId: string,
+ message: string,
+): GetDocePreviewStatusOutput {
+ return {
+ ok: false,
+ projectId,
+ projectStatus: "unknown",
+ preview: { reachable: false },
+ containers: [],
+ summary: message,
+ };
+}
+
+// ============================================================================
+// read_doce_preview_logs
+// ============================================================================
+
+export async function readDocePreviewLogs(
+ input: ReadDocePreviewLogsInput,
+ userId: string,
+): Promise {
+ const { projectId, mode, maxBytes = 8192, offset } = input;
+
+ const project = await loadAndVerifyProject(projectId, userId);
+ if (!project) {
+ return buildErrorLogs(projectId, mode, "Project not found or access denied");
+ }
+
+ const previewPath = getProjectPreviewPath(projectId);
+ const logsDir = path.join(previewPath, "logs");
+
+ try {
+ if (mode === "summary") {
+ return await readLogsSummary(projectId, logsDir);
+ }
+
+ if (mode === "tail") {
+ return await readLogsTail(projectId, logsDir, maxBytes);
+ }
+
+ if (mode === "sinceOffset" && offset !== undefined) {
+ return await readLogsSince(projectId, logsDir, offset);
+ }
+
+ return buildErrorLogs(projectId, mode, "Invalid offset for sinceOffset mode");
+ } catch (error) {
+ logger.error({ error, projectId, mode }, "Failed to read preview logs");
+ return buildErrorLogs(
+ projectId,
+ mode,
+ error instanceof Error ? error.message : "Unknown error",
+ );
+ }
+}
+
+async function readLogsSummary(
+ projectId: string,
+ logsDir: string,
+): Promise {
+ const signal = await extractLastErrorLine(logsDir);
+
+ return {
+ ok: true,
+ projectId,
+ mode: "summary",
+ extractedSignal: signal,
+ summary: signal
+ ? `Last error found in recent logs: ${signal}`
+ : "No obvious error found in recent logs.",
+ };
+}
+
+async function readLogsTail(
+ projectId: string,
+ logsDir: string,
+ maxBytes: number,
+): Promise {
+ const { content, offset, truncated } = await readLogTail(logsDir, maxBytes);
+ const signal = await extractLastErrorLine(logsDir);
+
+ return {
+ ok: true,
+ projectId,
+ mode: "tail",
+ content,
+ nextOffset: offset,
+ truncated,
+ extractedSignal: signal,
+ summary: signal
+ ? `Returned recent log tail; likely issue: ${signal}`
+ : "Returned recent log tail; no obvious error detected.",
+ };
+}
+
+async function readLogsSince(
+ projectId: string,
+ logsDir: string,
+ offset: number,
+): Promise {
+ const { content, nextOffset } = await readLogFromOffset(logsDir, offset);
+
+ return {
+ ok: true,
+ projectId,
+ mode: "sinceOffset",
+ content,
+ nextOffset,
+ summary: content
+ ? `Read ${content.length} bytes of new log content.`
+ : "No new log content since last offset.",
+ };
+}
+
+function buildErrorLogs(
+ projectId: string,
+ mode: string,
+ message: string,
+): ReadDocePreviewLogsOutput {
+ return {
+ ok: false,
+ projectId,
+ mode: mode as "summary" | "tail" | "sinceOffset",
+ summary: message,
+ };
+}
+
+// ============================================================================
+// restart_doce_preview
+// ============================================================================
+
+const RESTART_CHECK_TIMEOUT_MS = 30_000;
+const RESTART_CHECK_INTERVAL_MS = 1000;
+
+export async function restartDocePreview(
+ input: RestartDocePreviewInput,
+ userId: string,
+): Promise {
+ const { projectId, reason } = input;
+
+ logger.info({ projectId, reason }, "Restarting preview container");
+
+ const project = await loadAndVerifyProject(projectId, userId);
+ if (!project) {
+ return buildErrorRestart(projectId, "Project not found or access denied");
+ }
+
+ const previewPath = getProjectPreviewPath(projectId);
+
+ const result = await runComposeCommand(projectId, previewPath, [
+ "restart",
+ "preview",
+ ]);
+
+ if (!result.success) {
+ logger.error(
+ { projectId, error: result.stderr },
+ "Failed to restart preview container",
+ );
+ return buildErrorRestart(
+ projectId,
+ `Restart command failed: ${result.stderr.slice(0, 200)}`,
+ );
+ }
+
+ const reachable = await waitForPreviewReady(projectId);
+
+ return {
+ ok: reachable,
+ projectId,
+ restarted: true,
+ command: "docker compose restart preview",
+ previewReachableAfterRestart: reachable,
+ summary: reachable
+ ? "Preview restarted successfully and is reachable."
+ : "Restart command succeeded but preview is still unhealthy.",
+ };
+}
+
+async function waitForPreviewReady(projectId: string): Promise {
+ const deadline = Date.now() + RESTART_CHECK_TIMEOUT_MS;
+
+ while (Date.now() < deadline) {
+ if (await checkPreviewReady(projectId)) {
+ return true;
+ }
+ await sleep(RESTART_CHECK_INTERVAL_MS);
+ }
+
+ return false;
+}
+
+function buildErrorRestart(
+ projectId: string,
+ message: string,
+): RestartDocePreviewOutput {
+ return {
+ ok: false,
+ projectId,
+ restarted: false,
+ command: "docker compose restart preview",
+ previewReachableAfterRestart: false,
+ summary: message,
+ error: message,
+ };
+}
+
+// ============================================================================
+// Shared helpers
+// ============================================================================
+
+async function loadAndVerifyProject(projectId: string, userId: string) {
+ const project = await getProjectById(projectId);
+ if (!project || project.ownerUserId !== userId) {
+ return null;
+ }
+ return project;
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
diff --git a/src/server/ai-tools/internalRequest.ts b/src/server/ai-tools/internalRequest.ts
new file mode 100644
index 00000000..1b6abf46
--- /dev/null
+++ b/src/server/ai-tools/internalRequest.ts
@@ -0,0 +1,90 @@
+import { logger } from "@/server/logger";
+import { getProjectById } from "@/server/projects/projects.model";
+import { verifyProjectInternalToken } from "./projectToken";
+
+export interface InternalCallResult {
+ projectId: string;
+ ownerUserId: string;
+}
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+export interface InternalRequestBody {
+ projectId?: unknown;
+ token?: unknown;
+}
+
+export async function parseInternalRequest(
+ request: Request,
+): Promise<
+ { ok: true; body: Record } | { ok: false; response: Response }
+> {
+ let raw: unknown;
+ try {
+ raw = await request.json();
+ } catch {
+ return { ok: false, response: jsonResponse(400, { error: "Invalid JSON" }) };
+ }
+
+ if (typeof raw !== "object" || raw === null) {
+ return {
+ ok: false,
+ response: jsonResponse(400, { error: "Body must be an object" }),
+ };
+ }
+
+ return { ok: true, body: raw as Record };
+}
+
+export async function authorizeInternalCall(
+ body: Record,
+): Promise<
+ { ok: true; result: InternalCallResult } | { ok: false; response: Response }
+> {
+ const projectId =
+ typeof body.projectId === "string" ? body.projectId : undefined;
+ const token = typeof body.token === "string" ? body.token : undefined;
+
+ if (!projectId) {
+ return {
+ ok: false,
+ response: jsonResponse(400, { error: "projectId is required" }),
+ };
+ }
+
+ if (!token) {
+ return {
+ ok: false,
+ response: jsonResponse(401, { error: "token is required" }),
+ };
+ }
+
+ const valid = await verifyProjectInternalToken(projectId, token);
+ if (!valid) {
+ logger.warn({ projectId }, "Rejected internal tool call: invalid token");
+ return {
+ ok: false,
+ response: jsonResponse(403, { error: "Invalid project token" }),
+ };
+ }
+
+ const project = await getProjectById(projectId);
+ if (!project) {
+ return {
+ ok: false,
+ response: jsonResponse(404, { error: "Project not found" }),
+ };
+ }
+
+ return {
+ ok: true,
+ result: { projectId, ownerUserId: project.ownerUserId },
+ };
+}
+
+export { jsonResponse };
diff --git a/src/server/ai-tools/projectToken.ts b/src/server/ai-tools/projectToken.ts
new file mode 100644
index 00000000..7ab50584
--- /dev/null
+++ b/src/server/ai-tools/projectToken.ts
@@ -0,0 +1,61 @@
+import { randomBytes } from "node:crypto";
+import * as fs from "node:fs/promises";
+import * as path from "node:path";
+import { getProjectPreviewPath } from "@/server/projects/paths";
+
+const TOKEN_FILENAME = ".doce-internal-token";
+const TOKEN_BYTES = 32;
+
+function getTokenPath(projectId: string): string {
+ return path.join(getProjectPreviewPath(projectId), TOKEN_FILENAME);
+}
+
+export async function ensureProjectInternalToken(
+ projectId: string,
+): Promise {
+ const tokenPath = getTokenPath(projectId);
+
+ try {
+ const existing = (await fs.readFile(tokenPath, "utf-8")).trim();
+ if (existing.length >= 32) {
+ return existing;
+ }
+ } catch (error) {
+ const code = (error as NodeJS.ErrnoException).code;
+ if (code !== "ENOENT") {
+ throw error;
+ }
+ }
+
+ const token = randomBytes(TOKEN_BYTES).toString("hex");
+ await fs.mkdir(path.dirname(tokenPath), { recursive: true });
+ await fs.writeFile(tokenPath, `${token}\n`, { mode: 0o600 });
+ return token;
+}
+
+export async function readProjectInternalToken(
+ projectId: string,
+): Promise {
+ try {
+ const token = (await fs.readFile(getTokenPath(projectId), "utf-8")).trim();
+ return token.length >= 32 ? token : null;
+ } catch {
+ return null;
+ }
+}
+
+export async function verifyProjectInternalToken(
+ projectId: string,
+ candidate: string | null | undefined,
+): Promise {
+ if (!candidate) return false;
+ const stored = await readProjectInternalToken(projectId);
+ if (!stored) return false;
+ if (stored.length !== candidate.length) return false;
+
+ let mismatch = 0;
+ for (let i = 0; i < stored.length; i += 1) {
+ mismatch |= stored.charCodeAt(i) ^ candidate.charCodeAt(i);
+ }
+ return mismatch === 0;
+}
diff --git a/src/server/opencode/config.ts b/src/server/opencode/config.ts
index 930b4969..f5c7d253 100644
--- a/src/server/opencode/config.ts
+++ b/src/server/opencode/config.ts
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
import * as path from "node:path";
import { logger } from "@/server/logger";
import { DOCE_COMPACTION_PLUGIN_SOURCE } from "@/server/opencode/doceCompactionPluginSource";
+import { DOCE_PREVIEW_TOOL_FILES } from "@/server/opencode/docePreviewToolsSource";
import {
getDataPath,
getGlobalOpencodeConfigPath,
@@ -105,6 +106,21 @@ async function ensureGlobalDoceCompactionPlugin(): Promise {
);
}
+async function ensureGlobalDocePreviewTools(): Promise {
+ const toolsDirectory = path.join(getDataPath(), "opencode", "tools");
+ await fs.mkdir(toolsDirectory, { recursive: true });
+
+ for (const { filename, source } of DOCE_PREVIEW_TOOL_FILES) {
+ const filePath = path.join(toolsDirectory, filename);
+ await fs.writeFile(filePath, source);
+ }
+
+ logger.debug(
+ { toolsDirectory, count: DOCE_PREVIEW_TOOL_FILES.length },
+ "Ensured doce preview OpenCode custom tools",
+ );
+}
+
export async function ensureGlobalOpencodeConfig(): Promise {
const configPath = getGlobalOpencodeConfigPath();
await fs.mkdir(path.dirname(configPath), { recursive: true });
@@ -118,5 +134,6 @@ export async function ensureGlobalOpencodeConfig(): Promise {
await fs.writeFile(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`);
await ensureGlobalDoceCompactionPlugin();
+ await ensureGlobalDocePreviewTools();
logger.debug({ configPath }, "Ensured permissive global OpenCode config");
}
diff --git a/src/server/opencode/docePreviewToolsSource.ts b/src/server/opencode/docePreviewToolsSource.ts
new file mode 100644
index 00000000..ee309758
--- /dev/null
+++ b/src/server/opencode/docePreviewToolsSource.ts
@@ -0,0 +1,138 @@
+const SHARED_HELPER = `
+import * as fs from "node:fs/promises";
+
+const TOKEN_FILENAME = ".doce-internal-token";
+const SEP = "/";
+
+function resolveDirectory(context) {
+ if (!context) return null;
+ return (
+ context.directory ||
+ context.worktree ||
+ context.cwd ||
+ (context.session && (context.session.directory || context.session.worktree)) ||
+ null
+ );
+}
+
+async function readToken(directory) {
+ try {
+ const tokenPath = directory.replace(/\\/+$/, "") + SEP + TOKEN_FILENAME;
+ const content = await fs.readFile(tokenPath, "utf-8");
+ return content.trim() || null;
+ } catch {
+ return null;
+ }
+}
+
+function extractProjectId(directory) {
+ if (!directory || typeof directory !== "string") return null;
+ const segments = directory.split(SEP).filter(Boolean);
+ const previewIndex = segments.lastIndexOf("preview");
+ if (previewIndex < 1) return null;
+ return segments[previewIndex - 1] ?? null;
+}
+
+function getBaseUrl() {
+ return process.env.DOCE_INTERNAL_BASE_URL || "http://127.0.0.1:4321";
+}
+
+async function callDoceInternal(action, context, extra) {
+ const directory = resolveDirectory(context);
+ if (!directory) {
+ return {
+ ok: false,
+ error: "OpenCode context did not provide a directory",
+ contextKeys: context ? Object.keys(context) : [],
+ };
+ }
+
+ const projectId = extractProjectId(directory);
+ if (!projectId) {
+ return {
+ ok: false,
+ error: "Could not resolve projectId from session directory",
+ directory,
+ };
+ }
+
+ const token = await readToken(directory);
+ if (!token) {
+ return { ok: false, error: "Missing internal project token", directory };
+ }
+
+ const url = getBaseUrl() + "/api/internal/ai-tools/preview/" + action;
+ const body = { projectId, token, ...(extra || {}) };
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+
+ const text = await response.text();
+ let parsed;
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ parsed = { raw: text };
+ }
+
+ if (!response.ok) {
+ return { ok: false, status: response.status, error: parsed?.error || text };
+ }
+
+ return parsed;
+}
+`.trim();
+
+function toolFile(body: string): string {
+ return `import { tool } from "@opencode-ai/plugin";\n${SHARED_HELPER}\n\n${body}\n`;
+}
+
+// NOTE: We intentionally do NOT declare an "args" field on these tool
+// definitions. OpenCode 1.14.48 has a bug where any tool that declares an
+// "args" field (even an empty {}) fails its first call with
+// 'undefined is not an object (evaluating "g.split")' inside the runtime
+// wrapper. Tools without "args" execute correctly. So these tools take no
+// arguments from the model and read sensible defaults from the session
+// context (directory + project token).
+
+export const GET_DOCE_PREVIEW_STATUS_SOURCE = toolFile(`
+export default tool({
+ description:
+ "Check whether the doce preview server is healthy and reachable. Returns container state and a human-readable summary. Use this first when the preview seems broken. Takes no arguments.",
+ async execute(_args, context) {
+ return callDoceInternal("status", context);
+ },
+});
+`.trim());
+
+export const READ_DOCE_PREVIEW_LOGS_SOURCE = toolFile(`
+export default tool({
+ description:
+ "Read recent doce preview logs (last error line + a tail of recent log bytes). Use this to diagnose preview issues. Takes no arguments.",
+ async execute(_args, context) {
+ return callDoceInternal("logs", context, { mode: "tail", maxBytes: 4096 });
+ },
+});
+`.trim());
+
+export const RESTART_DOCE_PREVIEW_SOURCE = toolFile(`
+export default tool({
+ description:
+ "Restart the doce preview container. Only call this after confirming via get_doce_preview_status and read_doce_preview_logs that the preview is actually unhealthy. Takes no arguments.",
+ async execute(_args, context) {
+ return callDoceInternal("restart", context, { reason: "Agent-triggered preview restart" });
+ },
+});
+`.trim());
+
+export const DOCE_PREVIEW_TOOL_FILES: Array<{
+ filename: string;
+ source: string;
+}> = [
+ { filename: "get_doce_preview_status.ts", source: GET_DOCE_PREVIEW_STATUS_SOURCE },
+ { filename: "read_doce_preview_logs.ts", source: READ_DOCE_PREVIEW_LOGS_SOURCE },
+ { filename: "restart_doce_preview.ts", source: RESTART_DOCE_PREVIEW_SOURCE },
+];
diff --git a/src/server/opencode/runtime.ts b/src/server/opencode/runtime.ts
index 45702105..06fd772d 100644
--- a/src/server/opencode/runtime.ts
+++ b/src/server/opencode/runtime.ts
@@ -217,6 +217,9 @@ function getOpencodeEnvironment(): NodeJS.ProcessEnv {
// Use the real HOME so the runtime shares auth and data with the CLI
XDG_CONFIG_HOME: dataPath,
XDG_CACHE_HOME: `${dataPath}/cache`,
+ // Base URL used by internal OpenCode custom tools to reach the doce API
+ DOCE_INTERNAL_BASE_URL:
+ process.env.DOCE_INTERNAL_BASE_URL || "http://127.0.0.1:4321",
};
}
@@ -239,6 +242,11 @@ async function waitForOpencodeReady(): Promise {
async function startOpencodeProcess(): Promise {
await ensureRequiredOpencodeVersion();
+ // Always ensure our config + custom tools + plugins are up-to-date on disk
+ // so a reused opencode picks them up on its next reload, and a fresh spawn
+ // sees them immediately.
+ await ensureOpencodeDirectories();
+
if (await checkOpencodeServerReady(getOpencodePort(), 1_000)) {
logger.info(
{
diff --git a/src/server/projects/setup.ts b/src/server/projects/setup.ts
index 28c87834..aafd2db8 100644
--- a/src/server/projects/setup.ts
+++ b/src/server/projects/setup.ts
@@ -1,6 +1,7 @@
import { spawn } from "node:child_process";
import * as fs from "node:fs/promises";
import * as path from "node:path";
+import { ensureProjectInternalToken } from "@/server/ai-tools/projectToken";
import { logger } from "@/server/logger";
import { allocateProjectProductionPort } from "@/server/ports/allocate";
import { resolveHostPath } from "./hostPaths";
@@ -57,6 +58,9 @@ export async function setupProjectFilesystem(
await fs.mkdir(path.join(previewPath, "logs"), { recursive: true });
await fs.mkdir(path.join(productionPath, "logs"), { recursive: true });
+ // Generate per-project token used by internal OpenCode tools
+ await ensureProjectInternalToken(projectId);
+
return { projectPath, productionPort };
}
diff --git a/src/server/queue/handlers/opencodeSessionCreate.ts b/src/server/queue/handlers/opencodeSessionCreate.ts
index 814e7ec3..8eeb1f37 100644
--- a/src/server/queue/handlers/opencodeSessionCreate.ts
+++ b/src/server/queue/handlers/opencodeSessionCreate.ts
@@ -1,4 +1,5 @@
import { Effect } from "effect";
+import { ensureProjectInternalToken } from "@/server/ai-tools/projectToken";
import { FALLBACK_MODEL } from "@/server/config/models";
import { OpenCodeSessionError, ProjectError } from "@/server/effect/errors";
import type { QueueJobContext } from "@/server/effect/queue.worker";
@@ -51,6 +52,18 @@ export function handleOpencodeSessionCreate(
let isSessionRecovery = false;
const projectDirectory = getProjectPreviewPathFromRoot(project.pathOnDisk);
+ // Ensure the internal token exists so OpenCode custom tools can authenticate
+ yield* Effect.tryPromise({
+ try: () => ensureProjectInternalToken(project.id),
+ catch: (error) =>
+ new ProjectError({
+ projectId: project.id,
+ operation: "ensureProjectInternalToken",
+ message: error instanceof Error ? error.message : String(error),
+ cause: error,
+ }),
+ });
+
if (project.bootstrapSessionId) {
const hasExistingSession = yield* Effect.tryPromise({
try: async () => {
diff --git a/templates/astro-starter/.gitignore b/templates/astro-starter/.gitignore
index cd48c0d0..edb4817b 100644
--- a/templates/astro-starter/.gitignore
+++ b/templates/astro-starter/.gitignore
@@ -17,6 +17,9 @@ pnpm-debug.log*
.env.local
.env.*.local
+# Doce internal
+.doce-internal-token
+
# IDE
.vscode/
.idea/
diff --git a/templates/astro-starter/DOCE.md b/templates/astro-starter/DOCE.md
index fd90ecb4..a1d70975 100644
--- a/templates/astro-starter/DOCE.md
+++ b/templates/astro-starter/DOCE.md
@@ -22,7 +22,10 @@ You are running inside **doce.dev**, a web-based AI development environment. The
- **Icons**: lucide-react
- **Dev server**: Already running on port 4321 (handled by the platform)
- **Working directory**: `/app`
-- **Recovery tools**: Custom tools `restart_dev_server` and `read_server_logs` are available for preview troubleshooting
+- **Recovery tools**: Custom tools are available for preview troubleshooting:
+ - `get_doce_preview_status` - Check if the preview is healthy and running
+ - `read_doce_preview_logs` - Read recent logs to diagnose issues (prefer "summary" mode first)
+ - `restart_doce_preview` - Restart the preview server when it's stuck or crashed
## Available UI Components
@@ -62,10 +65,13 @@ If you need additional shadcn components not in the starter:
- Don't tell the user to run commands - the platform handles that
- Don't reinstall or reconfigure Tailwind - it's already set up correctly
- Don't run any commands that interfere with Doce, like "pnpm build" or "pnpm dev". There's already a dev server running and you might break it.
-- If the preview server gets stuck or stops responding, use `restart_dev_server` instead of trying to start a second dev server manually.
-- Use `restart_dev_server` only when needed for preview recovery, not as a routine step.
-- When debugging preview issues, use `read_server_logs` to inspect recent app/docker logs before guessing.
-- Prefer reading logs first, then restarting only if the logs or preview behavior indicate the dev server is unhealthy.
+- If the preview server gets stuck or stops responding, use `restart_doce_preview` instead of trying to start a second dev server manually.
+- Use `restart_doce_preview` only when needed for preview recovery, not as a routine step.
+- When debugging preview issues, follow this order:
+ 1. Check `get_doce_preview_status` to see if the preview is reachable
+ 2. Use `read_doce_preview_logs` with mode "summary" to quickly identify the issue
+ 3. Only use `restart_doce_preview` if status/logs indicate the preview is unhealthy
+- Avoid repeated raw log reads unless necessary.
## What TO do