feat(desktop): support chat without workspaces (#12412)

* feat(core): support pathless sessions with temporary workspaces

* fix(desktop): mark editor icons as decorative

* fix(core): omit absent auth request IDs

* test(sdk): restore request_id auth telemetry param in core-events test

The branch's drive-by request_id -> requestId rename was dropped while
resolving the merge conflict with #12444 (which added requestIdDetails on
main), so the public captureAuthLoggedOut/captureAuthRefreshSoftFailure
API keeps its original parameter name.

* refactor(sdk): root pathless session workspaces under the cline data dir

Move the workspace created for pathless session starts from
<os.tmpdir()>/cline/sessions/<id>-temp/project to
<cline-data-dir>/workspaces/<id>/project (default
~/.cline/data/workspaces/<id>/project), per PR review:

- OS tmp reapers (macOS ~3-day purge, systemd-tmpfiles, reboot cleanup)
  silently delete user work created in 'New Project' sessions
- /tmp is a shared namespace on Linux: the first user to create /tmp/cline
  owns it (EACCES for everyone else), and guessable session IDs let a local
  attacker pre-create the workspace directory
- under the data dir the workspace shares the session store's lifecycle and
  the existing CLINE_DATA_DIR / CLINE_DIR overrides for tests and sandboxes

isTemporaryWorkspacePath now matches the .cline/data/workspaces/<id>/project
segment shape, and the -temp suffix is gone since the id-scoped directory no
longer needs to mark itself as reapable.

* feat(sdk): open pathless sessions in one shared chat workspace

Instead of minting a workspace directory per session
(<data>/workspaces/<session-id>/project), all sessions started without a
cwd/workspaceRoot now share <cline-data-dir>/workspaces/chat (default
~/.cline/data/workspaces/chat). Starting a pathless session seeds the
directory with an AGENTS.md rules file (only when missing, so users can
edit it) that tells the agent to treat the session as a chat: don't create
or edit files unprompted, ask where a project should live when the user
wants one built, and default to a new named folder inside the chat
directory that later sessions can reference.

This avoids unbounded per-session directory sprawl, gives chat sessions a
stable home the user can revisit, and groups them naturally in the desktop
sidebar. The desktop app now labels the shared workspace "Chat" (menu
action "Just chat") instead of "New Project", and isChatWorkspacePath
matches only the chat directory itself, so project folders created inside
it behave as regular workspaces.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
This commit is contained in:
Bee
2026-07-23 18:09:03 -07:00
committed by GitHub
parent 6518b05b6c
commit 3e06abc366
43 changed files with 1328 additions and 212 deletions
+6 -1
View File
@@ -141,13 +141,18 @@ function captureRemoteConfigInitialized(bundle: RemoteConfigBundle): void {
export async function prepareCliEnterpriseIntegration(
input: ClineCoreStartInput,
) {
const workspacePath =
input.config.workspaceRoot?.trim() || input.config.cwd?.trim();
if (!workspacePath) {
return undefined;
}
const bundle = await loadCliRemoteConfigBundle();
if (!bundle) {
return undefined;
}
captureRemoteConfigInitialized(bundle);
return prepareRemoteConfigCoreIntegration({
workspacePath: input.config.workspaceRoot ?? input.config.cwd,
workspacePath,
pluginName: "enterprise",
controlPlane: {
name: "cline-account",
@@ -129,6 +129,51 @@ describe("hasProviderChanged", () => {
});
});
describe("pathless session starts", () => {
it("omits workspace paths and returns the SDK-resolved chat workspace", async () => {
const start = vi.fn(async (input: { config: Record<string, unknown> }) => {
expect(input.config).not.toHaveProperty("cwd");
expect(input.config).not.toHaveProperty("workspaceRoot");
return {
sessionId: "session-pathless",
manifest: {
cwd: "/home/host/.cline/data/workspaces/chat",
workspace_root: "/home/host/.cline/data/workspaces/chat",
},
manifestPath: "/tmp/session-pathless.json",
messagesPath: "/tmp/session-pathless.messages.json",
};
});
const ctx = {
liveSessions: new Map(),
sessionManager: { start },
} as unknown as SidecarContext;
const result = (await handleChatSessionCommand(ctx, {
action: "start",
config: {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
enableTools: true,
},
})) as {
sessionId: string;
cwd: string;
workspaceRoot: string;
};
expect(result).toEqual({
sessionId: "session-pathless",
cwd: "/home/host/.cline/data/workspaces/chat",
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
});
expect(ctx.liveSessions.get("session-pathless")?.config).toMatchObject({
cwd: "/home/host/.cline/data/workspaces/chat",
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
});
});
});
describe("first-send connection updates", () => {
const baseConfig = {
provider: "cline",
@@ -5,7 +5,7 @@ import {
buildConnectionUpdate,
buildWorkspaceMetadata,
type ClineCore,
type CoreSessionConfig,
type ClineCoreStartConfig,
createSessionCompactionState,
projectSessionCompactionState,
type SessionCompactionState,
@@ -205,6 +205,11 @@ function readPositiveInteger(value: unknown): number | undefined {
}
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
const rawWorkspaceRoot = config.workspaceRoot ?? config.workspace_root;
const workspaceRoot =
typeof rawWorkspaceRoot === "string" ? rawWorkspaceRoot.trim() : "";
const cwd =
(typeof config.cwd === "string" ? config.cwd.trim() : "") || workspaceRoot;
const thinking =
typeof config.thinking === "boolean" ? config.thinking : undefined;
const reasoningEffort =
@@ -226,8 +231,8 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
baseUrl: config.baseUrl,
headers: config.headers,
providerConfig: config.providerConfig,
workspaceRoot: config.workspaceRoot ?? config.workspace_root ?? "",
cwd: config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
...(workspaceRoot ? { workspaceRoot } : {}),
...(cwd ? { cwd } : {}),
systemPrompt: config.systemPrompt ?? config.system_prompt ?? "",
maxIterations: config.maxIterations ?? config.max_iterations,
enableTools: config.enableTools ?? config.enable_tools ?? true,
@@ -491,7 +496,7 @@ async function handleStart(
modelId: String(coreConfig.modelId ?? ""),
});
const startResult = await manager.start({
...splitCoreSessionConfig(coreConfig as unknown as CoreSessionConfig),
...splitCoreSessionConfig(coreConfig as unknown as ClineCoreStartConfig),
source: SessionSource.DESKTOP,
interactive: true,
...(initialMessages
@@ -500,19 +505,24 @@ async function handleStart(
toolPolicies: resolveToolPolicies(request.config),
});
const sessionId = startResult.sessionId;
const workspaceRoot = startResult.manifest.workspace_root;
const cwd = startResult.manifest.cwd;
ctx.logger?.log("Desktop chat session started", { sessionId });
const session = createLiveSession(request.config, {
messages: initialMessages,
prompt: initialMessages
? derivePromptFromMessages(initialMessages)
: undefined,
title: requestedSessionId
? readSessionMetadataTitle(requestedSessionId)
: undefined,
status: "idle",
});
const session = createLiveSession(
{ ...request.config, cwd, workspaceRoot },
{
messages: initialMessages,
prompt: initialMessages
? derivePromptFromMessages(initialMessages)
: undefined,
title: requestedSessionId
? readSessionMetadataTitle(requestedSessionId)
: undefined,
status: "idle",
},
);
ctx.liveSessions.set(sessionId, session);
return { sessionId };
return { sessionId, cwd, workspaceRoot };
}
async function handleAttach(
@@ -607,7 +617,7 @@ async function startRebuiltSession(
...config,
sessionId,
systemPrompt,
}) as unknown as CoreSessionConfig,
}) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
@@ -989,7 +999,7 @@ async function handleFork(
...forkConfig,
systemPrompt,
initialMessages: sourceMessages,
}) as unknown as CoreSessionConfig,
}) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
@@ -1080,7 +1090,7 @@ async function handleRestoreCheckpoint(
buildCoreSessionConfig({
...request.config,
systemPrompt: await resolveSystemPrompt(request.config),
}) as unknown as CoreSessionConfig,
}) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
+39 -17
View File
@@ -372,6 +372,7 @@ function ChatThreadPane({
pendingToolApprovals,
pendingAskQuestions,
setConfig,
setWorkspacePath,
sendPrompt,
steerPromptInQueue,
updatePromptInQueue,
@@ -416,6 +417,7 @@ function ChatThreadPane({
const hydratedSessionRef = useRef<string | null>(null);
const resetThreadRef = useRef<string | null>(null);
const manualTitleSessionRef = useRef<string | null>(null);
const workspaceSelectionRequestRef = useRef(0);
const workspaceRef = useRef({
cwd: config.cwd,
workspaceRoot: config.workspaceRoot,
@@ -508,12 +510,15 @@ function ChatThreadPane({
);
const refreshGitBranch = useCallback(async () => {
const cwd = getWorkspaceCwd();
if (!cwd) {
setGitBranch("no-git");
return;
}
try {
const payload = await desktopClient.invoke<{ branch?: string }>(
"get_git_branch",
{
cwd: getWorkspaceCwd(),
},
{ cwd },
);
const branch = payload?.branch?.trim();
setGitBranch(branch && branch.length > 0 ? branch : "no-git");
@@ -526,13 +531,15 @@ function ChatThreadPane({
current: string;
branches: string[];
}> => {
const cwd = getWorkspaceCwd();
if (!cwd) {
return { current: "no-git", branches: [] };
}
try {
const payload = await desktopClient.invoke<{
current?: string;
branches?: string[];
}>("list_git_branches", {
cwd: getWorkspaceCwd(),
});
}>("list_git_branches", { cwd });
const current = payload?.current?.trim() || "no-git";
const branches = Array.isArray(payload?.branches)
? payload.branches.filter((item) => item.trim().length > 0)
@@ -545,13 +552,14 @@ function ChatThreadPane({
const switchGitBranch = useCallback(
async (nextBranch: string): Promise<boolean> => {
const cwd = getWorkspaceCwd();
if (!cwd) {
return false;
}
try {
const payload = await desktopClient.invoke<{ branch?: string }>(
"checkout_git_branch",
{
cwd: getWorkspaceCwd(),
branch: nextBranch,
},
{ cwd, branch: nextBranch },
);
const branch = payload?.branch?.trim();
setGitBranch(branch && branch.length > 0 ? branch : "no-git");
@@ -609,6 +617,7 @@ function ChatThreadPane({
if (!nextWorkspace) {
return false;
}
const requestId = ++workspaceSelectionRequestRef.current;
const normalizedNext = normalizeWorkspacePath(nextWorkspace);
const normalizedCurrent = normalizeWorkspacePath(
workspaceRef.current.workspaceRoot || workspaceRef.current.cwd || "",
@@ -624,12 +633,11 @@ function ChatThreadPane({
if (validation.valid !== true) {
return false;
}
if (requestId !== workspaceSelectionRequestRef.current) {
return false;
}
setConfig((prev) => ({
...prev,
workspaceRoot: nextWorkspace,
cwd: nextWorkspace,
}));
setWorkspacePath(nextWorkspace);
setWorkspaces((prev) =>
filterWorkspacePaths(mergeWorkspacePaths(prev, [nextWorkspace])),
);
@@ -640,11 +648,16 @@ function ChatThreadPane({
cwd: nextWorkspace,
})
.then((payload) => {
if (requestId !== workspaceSelectionRequestRef.current) {
return;
}
const branch = payload?.branch?.trim();
setGitBranch(branch && branch.length > 0 ? branch : "no-git");
})
.catch(() => {
setGitBranch("no-git");
if (requestId === workspaceSelectionRequestRef.current) {
setGitBranch("no-git");
}
});
// Refresh the merged history, stored, and current workspace catalog.
@@ -652,9 +665,16 @@ function ChatThreadPane({
return true;
},
[setConfig, refreshWorkspaces],
[refreshWorkspaces, setWorkspacePath],
);
const selectChat = useCallback(async (): Promise<boolean> => {
workspaceSelectionRequestRef.current += 1;
setWorkspacePath("");
setGitBranch("no-git");
return true;
}, [setWorkspacePath]);
const pickWorkspaceDirectory = useCallback(
async (initialPath?: string): Promise<string | null> => {
try {
@@ -1059,6 +1079,7 @@ function ChatThreadPane({
refreshWorkspaces,
switchWorkspace,
pickWorkspaceDirectory,
selectChat,
}),
[
resolvedWorkspaceRoot,
@@ -1067,6 +1088,7 @@ function ChatThreadPane({
refreshWorkspaces,
switchWorkspace,
pickWorkspaceDirectory,
selectChat,
],
);
@@ -60,6 +60,7 @@ describe("ChatInputBar", () => {
refreshWorkspaces: vi.fn(async () => undefined),
switchWorkspace: vi.fn(async () => true),
pickWorkspaceDirectory: vi.fn(async () => null),
selectChat: vi.fn(async () => true),
}}
>
<ChatInputBar
@@ -139,6 +140,7 @@ describe("ChatInputBar", () => {
refreshWorkspaces: vi.fn(async () => undefined),
switchWorkspace: vi.fn(async () => true),
pickWorkspaceDirectory: vi.fn(async () => null),
selectChat: vi.fn(async () => true),
}}
>
<ChatInputBar
@@ -1,37 +1,127 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WorkspaceProvider } from "@/contexts/workspace-context";
import { WelcomeScreen } from "./welcome-chat";
describe("WelcomeScreen", () => {
it("renders every known project instead of capping the project strip", () => {
const workspaces = Array.from(
{ length: 6 },
(_, index) => `/projects/project-${index + 1}`,
);
const html = renderToStaticMarkup(
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
window.matchMedia = vi.fn().mockReturnValue({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.restoreAllMocks();
});
async function renderWelcomeScreen({
workspaceRoot,
workspaces,
selectChat = vi.fn(async () => true),
onListGitBranches = vi.fn(async () => ({
current: "main",
branches: ["main"],
})),
}: {
workspaceRoot: string;
workspaces: string[];
selectChat?: () => Promise<boolean>;
onListGitBranches?: () => Promise<{
current: string;
branches: string[];
}>;
}): Promise<void> {
await act(async () => {
root.render(
<WorkspaceProvider
value={{
workspaceRoot: workspaces[0] ?? "",
workspaceRoot,
workspaces,
listWorkspaces: vi.fn(async () => workspaces),
refreshWorkspaces: vi.fn(async () => undefined),
switchWorkspace: vi.fn(async () => true),
pickWorkspaceDirectory: vi.fn(async () => null),
selectChat,
}}
>
<WelcomeScreen
active
body={null}
composer={null}
gitBranch="main"
onListGitBranches={onListGitBranches}
onStartChat={vi.fn()}
onSwitchGitBranch={vi.fn(async () => true)}
quickActions={[]}
/>
</WorkspaceProvider>,
);
await Promise.resolve();
});
}
async function clickButton(text: string, last = false): Promise<void> {
const buttons = [
...container.querySelectorAll<HTMLButtonElement>("button"),
].filter((candidate) => candidate.textContent?.includes(text));
const button = last ? buttons.at(-1) : buttons[0];
expect(button).toBeDefined();
await act(async () => {
button?.click();
await Promise.resolve();
});
}
describe("WelcomeScreen", () => {
it("renders every known project in the opened workspace menu", async () => {
const workspaces = Array.from(
{ length: 6 },
(_, index) => `/projects/project-${index + 1}`,
);
await renderWelcomeScreen({
workspaceRoot: workspaces[0] ?? "",
workspaces,
});
await clickButton("project-1");
for (let index = 1; index <= workspaces.length; index += 1) {
expect(html).toContain(`project-${index}`);
expect(container.textContent).toContain(`project-${index}`);
}
});
it("selects Just chat from the pathless workspace menu", async () => {
const selectChat = vi.fn(async () => true);
const onListGitBranches = vi.fn(async () => ({
current: "main",
branches: ["main"],
}));
await renderWelcomeScreen({
workspaceRoot: "",
workspaces: ["/projects/existing"],
selectChat,
onListGitBranches,
});
expect(container.querySelector('button[title="main"]')).toBeNull();
expect(onListGitBranches).not.toHaveBeenCalled();
await clickButton("Chat");
expect(container.textContent).toContain("/projects/existing");
await clickButton("Just chat", true);
expect(selectChat).toHaveBeenCalledOnce();
});
});
@@ -99,6 +99,7 @@ export function WelcomeScreen({
refreshWorkspaces,
switchWorkspace,
pickWorkspaceDirectory,
selectChat,
} = useWorkspace();
const actions =
quickActions.length > 0 ? quickActions : DEFAULT_QUICK_ACTIONS;
@@ -140,6 +141,7 @@ export function WelcomeScreen({
onListGitBranches={onListGitBranches}
onPickWorkspaceDirectory={pickWorkspaceDirectory}
onRefreshWorkspaces={refreshWorkspaces}
onSelectChat={selectChat}
onSwitchGitBranch={onSwitchGitBranch}
onSwitchWorkspace={switchWorkspace}
workspaceRoot={workspaceRoot}
@@ -1,6 +1,14 @@
"use client";
import { Check, Folder, GitBranch, Plus, Search } from "lucide-react";
import { isChatWorkspacePath } from "@cline/shared/browser";
import {
Check,
FilePlus2,
Folder,
GitBranch,
Plus,
Search,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -67,6 +75,7 @@ function WorkspacePicker({
onRefreshWorkspaces,
onSwitchWorkspace,
onPickWorkspaceDirectory,
onSelectChat,
}: {
open: boolean;
onToggle: () => void;
@@ -76,10 +85,14 @@ function WorkspacePicker({
onRefreshWorkspaces: () => Promise<void>;
onSwitchWorkspace: (workspacePath: string) => Promise<boolean>;
onPickWorkspaceDirectory: (initialPath?: string) => Promise<string | null>;
onSelectChat: () => Promise<boolean>;
}) {
const [search, setSearch] = useState("");
const [switching, setSwitching] = useState(false);
const [picking, setPicking] = useState(false);
const [selectingChat, setSelectingChat] = useState(false);
const isChatWorkspace =
!workspaceRoot.trim() || isChatWorkspacePath(workspaceRoot);
const normalizedWorkspaceRoot = useMemo(
() => normalizeWorkspacePath(workspaceRoot),
@@ -102,10 +115,10 @@ function WorkspacePicker({
if (trimmed)
byNormalizedPath.set(normalizeWorkspacePath(trimmed), trimmed);
};
register(workspaceRoot);
if (!isChatWorkspace) register(workspaceRoot);
for (const path of workspaces) register(path);
return [...byNormalizedPath.values()];
}, [workspaceRoot, workspaces]);
}, [isChatWorkspace, workspaceRoot, workspaces]);
const filteredWorkspaces = availableWorkspaces.filter((path) =>
path.toLowerCase().includes(search.toLowerCase()),
@@ -125,16 +138,32 @@ function WorkspacePicker({
};
const handleAddWorkspace = async () => {
if (picking || switching) return;
if (picking || selectingChat || switching) return;
setPicking(true);
try {
const picked = await onPickWorkspaceDirectory(workspaceRoot || undefined);
const picked = await onPickWorkspaceDirectory(
isChatWorkspace ? undefined : workspaceRoot || undefined,
);
if (picked?.trim()) await handleSelect(picked.trim());
} finally {
setPicking(false);
}
};
const handleSelectChat = async () => {
if (picking || selectingChat || switching) return;
setSelectingChat(true);
try {
if (await onSelectChat()) onClose();
} finally {
setSelectingChat(false);
}
};
const workspaceLabel = isChatWorkspace
? "Chat"
: workspaceName(workspaceRoot);
return (
<div className="relative shrink-0">
<button
@@ -142,13 +171,11 @@ function WorkspacePicker({
aria-haspopup="menu"
className={TRIGGER_CLASS}
onClick={onToggle}
title={workspaceRoot}
title={workspaceLabel}
type="button"
>
<Folder className="size-4 shrink-0 text-muted-foreground" />
<span className="max-w-44 truncate">
{workspaceName(workspaceRoot)}
</span>
<span className="max-w-44 truncate">{workspaceLabel}</span>
</button>
{open && (
@@ -195,7 +222,7 @@ function WorkspacePicker({
</div>
<Button
className="mt-0.5 w-full justify-start text-xs text-muted-foreground"
disabled={switching || picking}
disabled={switching || picking || selectingChat}
onClick={() => void handleAddWorkspace()}
size="sm"
variant="ghost"
@@ -203,6 +230,16 @@ function WorkspacePicker({
<Plus className="size-3" />
{picking ? "Opening folder picker..." : "Add project..."}
</Button>
<Button
className="w-full justify-start text-xs text-muted-foreground"
disabled={switching || picking || selectingChat}
onClick={() => void handleSelectChat()}
size="sm"
variant="ghost"
>
<FilePlus2 className="size-3" />
{selectingChat ? "Switching to chat..." : "Just chat"}
</Button>
</div>
</div>
)}
@@ -338,6 +375,7 @@ export function WelcomeWorkspaceControls({
onRefreshWorkspaces,
onSwitchWorkspace,
onPickWorkspaceDirectory,
onSelectChat,
currentBranch,
onListGitBranches,
onSwitchGitBranch,
@@ -347,11 +385,14 @@ export function WelcomeWorkspaceControls({
onRefreshWorkspaces: () => Promise<void>;
onSwitchWorkspace: (workspacePath: string) => Promise<boolean>;
onPickWorkspaceDirectory: (initialPath?: string) => Promise<string | null>;
onSelectChat: () => Promise<boolean>;
currentBranch: string;
onListGitBranches: () => Promise<{ current: string; branches: string[] }>;
onSwitchGitBranch: (branch: string) => Promise<boolean>;
}) {
const [openMenu, setOpenMenu] = useState<"workspace" | "branch" | null>(null);
const isChatWorkspace =
!workspaceRoot.trim() || isChatWorkspacePath(workspaceRoot);
const containerRef = useRef<HTMLDivElement>(null);
// Close whichever menu is open when clicking outside the control row.
@@ -375,6 +416,7 @@ export function WelcomeWorkspaceControls({
onClose={() => setOpenMenu(null)}
onPickWorkspaceDirectory={onPickWorkspaceDirectory}
onRefreshWorkspaces={onRefreshWorkspaces}
onSelectChat={onSelectChat}
onSwitchWorkspace={onSwitchWorkspace}
onToggle={() =>
setOpenMenu((current) =>
@@ -385,16 +427,18 @@ export function WelcomeWorkspaceControls({
workspaceRoot={workspaceRoot}
workspaces={workspaces}
/>
<BranchPicker
currentBranch={currentBranch}
onClose={() => setOpenMenu(null)}
onListGitBranches={onListGitBranches}
onSwitchGitBranch={onSwitchGitBranch}
onToggle={() =>
setOpenMenu((current) => (current === "branch" ? null : "branch"))
}
open={openMenu === "branch"}
/>
{!isChatWorkspace ? (
<BranchPicker
currentBranch={currentBranch}
onClose={() => setOpenMenu(null)}
onListGitBranches={onListGitBranches}
onSwitchGitBranch={onSwitchGitBranch}
onToggle={() =>
setOpenMenu((current) => (current === "branch" ? null : "branch"))
}
open={openMenu === "branch"}
/>
) : null}
</div>
);
}
@@ -105,4 +105,32 @@ describe("WorkspaceSelector", () => {
expect(container.textContent).toContain("/workspace/one");
});
});
it("labels the SDK chat workspace as Chat without listing the raw path", async () => {
const temporaryWorkspace = "/home/host/.cline/data/workspaces/chat";
await act(async () => {
root.render(
<WorkspaceSelector
currentBranch="no-git"
onListGitBranches={vi.fn(async () => ({
current: "no-git",
branches: [],
}))}
onPickWorkspaceDirectory={vi.fn(async () => null)}
onRefreshWorkspaces={vi.fn(async () => undefined)}
onSwitchGitBranch={vi.fn(async () => false)}
onSwitchWorkspace={vi.fn(async () => true)}
workspaceRoot={temporaryWorkspace}
workspaces={["/workspace/one"]}
/>,
);
});
expect(container.textContent).toContain("Chat");
await click(container.querySelector("#git-branch-btn") as Element);
await vi.waitFor(() => {
expect(container.textContent).toContain("/workspace/one");
});
expect(container.textContent).not.toContain(temporaryWorkspace);
});
});
@@ -1,5 +1,6 @@
"use client";
import { isChatWorkspacePath } from "@cline/shared/browser";
import { Check, FolderCode, GitBranch, Plus, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -54,9 +55,12 @@ export function WorkspaceSelector({
const [newBranchName, setNewBranchName] = useState("");
const workspaceName = useMemo(() => {
if (isChatWorkspacePath(workspaceRoot)) {
return "Chat";
}
const trimmed = workspaceRoot.trim().replace(/[\\/]+$/, "");
if (!trimmed) {
return "workspace";
return "Chat";
}
const parts = trimmed.split(/[\\/]/);
return parts[parts.length - 1] || "workspace";
@@ -177,9 +181,10 @@ export function WorkspaceSelector({
const byNormalizedPath = new Map<string, string>();
const register = (path: string) => {
const trimmed = path.trim();
if (trimmed) byNormalizedPath.set(normalizeWorkspacePath(trimmed), trimmed);
if (trimmed)
byNormalizedPath.set(normalizeWorkspacePath(trimmed), trimmed);
};
register(workspaceRoot);
if (!isChatWorkspacePath(workspaceRoot)) register(workspaceRoot);
for (const path of workspaces) register(path);
return [...byNormalizedPath.values()];
}, [workspaceRoot, workspaces]);
@@ -9,6 +9,7 @@ type WorkspaceContextValue = {
refreshWorkspaces: () => Promise<void>;
switchWorkspace: (workspacePath: string) => Promise<boolean>;
pickWorkspaceDirectory: (initialPath?: string) => Promise<string | null>;
selectChat: () => Promise<boolean>;
};
const WorkspaceContext = createContext<WorkspaceContextValue | null>(null);
@@ -55,30 +55,58 @@ afterEach(async () => {
});
describe("useChatSession", () => {
it("asks the user to select a workspace before submitting", async () => {
it("starts without a selected workspace and adopts the SDK temporary path", async () => {
let startedSessionId = "";
await act(async () => {
current.setConfig((previous) => ({
...previous,
workspaceRoot: "",
cwd: "",
}));
current.setWorkspacePath("");
});
invokeMock.mockClear();
invokeMock.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command !== "chat_session_command") return [];
const request = args?.request as
| { action?: string; config?: Record<string, unknown> }
| undefined;
if (request?.action === "start") {
const sessionId = String(
request.config?.sessionId ?? "session-pathless",
);
startedSessionId = sessionId;
const workspacePath = "/home/host/.cline/data/workspaces/chat";
return {
sessionId,
cwd: workspacePath,
workspaceRoot: workspacePath,
};
}
if (request?.action === "send") {
return {
ok: true,
result: { text: "done", finishReason: "completed" },
};
}
return [];
},
);
await act(async () => current.sendPrompt("Start the task"));
expect(current.error).toBe("Select a workspace before trying again.");
expect(current.messages.at(-1)).toMatchObject({
role: "error",
content: "Select a workspace before trying again.",
expect(current.error).toBeNull();
expect(startedSessionId).toMatch(/^session_/);
const expectedWorkspacePath = "/home/host/.cline/data/workspaces/chat";
expect(current.config).toMatchObject({
cwd: expectedWorkspacePath,
workspaceRoot: expectedWorkspacePath,
});
expect(invokeMock).toHaveBeenCalledWith("chat_session_command", {
request: expect.objectContaining({
action: "start",
config: expect.objectContaining({ cwd: "", workspaceRoot: "" }),
}),
});
expect(invokeMock).not.toHaveBeenCalledWith(
"chat_session_command",
expect.anything(),
);
});
it("replaces raw workspace manifest errors with actionable copy", async () => {
it("preserves server validation errors", async () => {
invokeMock.mockImplementation(async (command: string) => {
if (command === "get_process_context") {
return { cwd: "/workspace/cline", workspaceRoot: "/workspace/cline" };
@@ -93,10 +121,10 @@ describe("useChatSession", () => {
await act(async () => current.start(current.config));
expect(current.error).toBe("Select a workspace before trying again.");
expect(current.messages.at(-1)?.content).toBe(
"Select a workspace before trying again.",
);
const expected =
'[{"origin":"string","code":"too_small","path":["workspaces","/","hint"],"message":"Too small: expected string to have >=1 characters"}]';
expect(current.error).toBe(expected);
expect(current.messages.at(-1)?.content).toBe(expected);
});
it.each([
@@ -107,7 +135,8 @@ describe("useChatSession", () => {
},
{
finishReason: "error",
expected: "Select a workspace before trying again.",
expected:
'[{"code":"too_small","path":["workspaces","/","hint"],"message":"expected string to have >=1 characters"}]',
},
])("handles schema-like assistant text for $finishReason responses", async ({
finishReason,
@@ -128,7 +157,11 @@ describe("useChatSession", () => {
| { action?: string; config?: { sessionId?: string } }
| undefined;
if (request?.action === "start") {
return { sessionId: request.config?.sessionId ?? "session-test" };
return {
sessionId: request.config?.sessionId ?? "session-test",
cwd: "/workspace/cline",
workspaceRoot: "/workspace/cline",
};
}
if (request?.action === "send") {
return {
@@ -727,11 +760,7 @@ describe("useChatSession", () => {
root = createRoot(container);
await act(async () => root.render(<HookHarness />));
await act(async () => {
current.setConfig((previous) => ({
...previous,
workspaceRoot: "/workspace/selected",
cwd: "/workspace/selected",
}));
current.setWorkspacePath("/workspace/selected");
});
await act(async () => {
@@ -744,4 +773,36 @@ describe("useChatSession", () => {
expect(current.config.workspaceRoot).toBe("/workspace/selected");
expect(current.config.cwd).toBe("/workspace/selected");
});
it("preserves a chat selection while process context is loading", async () => {
await act(async () => root.unmount());
let resolveContext:
| ((value: { cwd: string; workspaceRoot: string }) => void)
| undefined;
const contextResponse = new Promise<{
cwd: string;
workspaceRoot: string;
}>((resolve) => {
resolveContext = resolve;
});
invokeMock.mockImplementation(async (command: string) => {
if (command === "get_process_context") return await contextResponse;
return [];
});
root = createRoot(container);
await act(async () => root.render(<HookHarness />));
await act(async () => {
current.setWorkspacePath("");
});
await act(async () => {
resolveContext?.({
cwd: "/workspace/default",
workspaceRoot: "/workspace/default",
});
await contextResponse;
});
expect(current.config.workspaceRoot).toBe("");
expect(current.config.cwd).toBe("");
});
});
@@ -73,29 +73,12 @@ const BUSY_STATUSES = new Set<ChatSessionStatus>([
"stopping",
]);
const WORKSPACE_SELECTION_REQUIRED_MESSAGE =
"Select a workspace before trying again.";
// ---------------------------------------------------------------------------
// Helpers (pure, no hooks)
// ---------------------------------------------------------------------------
function userFacingMessage(message: string): string {
const normalized = message.toLowerCase();
const isWorkspaceManifestValidationError =
normalized.includes("workspaces") &&
(normalized.includes("too_small") ||
normalized.includes("expected string to have >=1"));
const isMissingWorkspaceConfig = normalized.includes(
"config.cwd or config.workspaceroot is required",
);
return isWorkspaceManifestValidationError || isMissingWorkspaceConfig
? WORKSPACE_SELECTION_REQUIRED_MESSAGE
: message;
}
function errorMessage(err: unknown): string {
return userFacingMessage(err instanceof Error ? err.message : String(err));
return err instanceof Error ? err.message : String(err);
}
function makeErrorChatMessage(
@@ -116,9 +99,6 @@ function validateConfig(
):
| { parsed: ChatSessionConfig; error: null }
| { parsed: null; error: string } {
if (!config.workspaceRoot.trim()) {
return { parsed: null, error: WORKSPACE_SELECTION_REQUIRED_MESSAGE };
}
const runtimeConfig = normalizeRuntimeConfig(config);
const result = ChatSessionConfigSchema.safeParse(runtimeConfig);
if (!result.success) {
@@ -268,6 +248,7 @@ export function useChatSession() {
null,
);
const hydrationRequestIdRef = useRef(0);
const workspaceSelectionRequestRef = useRef(0);
const sessionStartPromiseRef = useRef<Promise<string> | null>(null);
const promptDispatchTailRef = useRef<Promise<void>>(Promise.resolve());
const activePromptSubmissionsRef = useRef(0);
@@ -288,6 +269,16 @@ export function useChatSession() {
messagesRef.current = messages;
}, [messages]);
const setWorkspacePath = useCallback((workspacePath: string): void => {
workspaceSelectionRequestRef.current += 1;
const normalized = workspacePath.trim();
setConfig((previous) => ({
...previous,
workspaceRoot: normalized,
cwd: normalized,
}));
}, []);
// ---- Shared state reset helpers ----
const clearLiveToolRefs = useCallback(() => {
@@ -334,6 +325,8 @@ export function useChatSession() {
const postSession = useCallback(async (body: Record<string, unknown>) => {
return await desktopClient.invoke<{
sessionId?: string;
cwd?: string;
workspaceRoot?: string;
result?: ChatApiResult;
ok?: boolean;
queued?: boolean;
@@ -495,6 +488,7 @@ export function useChatSession() {
// ---- Process context ----
const applyProcessContext = useCallback(async () => {
const requestId = workspaceSelectionRequestRef.current;
try {
const ctx = await desktopClient.invoke<ProcessContext>(
"get_process_context",
@@ -511,6 +505,9 @@ export function useChatSession() {
})
.catch(() => ({ valid: false }))
: { valid: false };
if (requestId !== workspaceSelectionRequestRef.current) {
return;
}
setConfig((prev) => {
const currentWorkspace = (prev.workspaceRoot || prev.cwd || "").trim();
const selectionChangedWhileLoading = Boolean(
@@ -991,6 +988,13 @@ export function useChatSession() {
});
const id = payload.sessionId;
if (!id) throw new Error("Missing session id from server");
const workspaceRoot =
payload.workspaceRoot?.trim() || validatedConfig.workspaceRoot.trim();
const cwd =
payload.cwd?.trim() || validatedConfig.cwd?.trim() || workspaceRoot;
if (!workspaceRoot || !cwd) {
throw new Error("Missing resolved workspace from server");
}
setSessionId(id);
// Mark idle — not running — so the first sendPrompt is not queued.
// The status transitions to "starting"/"running" once a prompt is
@@ -998,7 +1002,12 @@ export function useChatSession() {
if (!options.preserveStatus) {
setStatus("idle");
}
setConfig(validatedConfig);
workspaceSelectionRequestRef.current += 1;
setConfig({
...validatedConfig,
cwd,
workspaceRoot,
});
setHydratedHistorySessionId(null);
return id;
},
@@ -1281,10 +1290,7 @@ export function useChatSession() {
result?.messages,
);
const rawAssistantText = assistantText || fallbackAssistantTurn.text;
const resolvedAssistantText =
result?.finishReason === "error"
? userFacingMessage(rawAssistantText)
: rawAssistantText;
const resolvedAssistantText = rawAssistantText;
if (resolvedAssistantText) {
const assistantMessageId =
activeAssistantMessageIdRef.current ?? makeId("assistant");
@@ -1438,9 +1444,7 @@ export function useChatSession() {
addMessage(
makeErrorChatMessage(
activeSessionId,
(toolError?.trim()
? userFacingMessage(toolError.trim())
: undefined) ||
toolError?.trim() ||
"Runtime turn failed before an assistant response was produced.",
),
);
@@ -1941,6 +1945,7 @@ export function useChatSession() {
pendingToolApprovals,
pendingAskQuestions,
setConfig,
setWorkspacePath,
start,
hydrateSession,
sendPrompt,
@@ -1,5 +1,6 @@
"use client";
import { isChatWorkspacePath } from "@cline/shared/browser";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { normalizeTitle } from "@/components/utils";
import { toast } from "@/hooks/use-toast";
@@ -176,6 +177,7 @@ export function formatRelativeTime(value?: string): string {
export function basenamePath(input?: string): string {
if (!input) return "workspace";
if (isChatWorkspacePath(input)) return "Chat";
const trimmed = input.replace(/[\\/]+$/, "");
if (!trimmed) return "workspace";
const parts = trimmed.split(/[\\/]/);
@@ -2,7 +2,7 @@ import { z } from "zod";
export const ChatSessionConfigSchema = z.object({
sessionId: z.string().min(1).optional(),
workspaceRoot: z.string().min(1),
workspaceRoot: z.string(),
cwd: z.string().optional(),
provider: z.string().min(1),
model: z.string().min(1),
@@ -48,4 +48,12 @@ describe("sidebar session organization", () => {
"cline",
);
});
it("labels chat workspace groups as Chat", () => {
const path = "/home/host/.cline/data/workspaces/chat";
expect(workspaceDisplayName(path)).toBe("Chat");
expect(groupThreadsByProject([thread("temp", path)])[0]?.label).toBe(
"Chat",
);
});
});
@@ -1,3 +1,4 @@
import { isChatWorkspacePath } from "@cline/shared/browser";
import type { SessionThread } from "@/hooks/use-session-history";
import { normalizeWorkspacePath } from "@/lib/workspace-paths";
@@ -11,6 +12,7 @@ export type SidebarProjectGroup = {
};
export function workspaceDisplayName(path: string): string {
if (isChatWorkspacePath(path)) return "Chat";
const trimmed = path.trim().replace(/[\\/]+$/, "");
if (!trimmed) return "";
const segments = trimmed.split(/[\\/]/).filter(Boolean);
@@ -58,7 +60,9 @@ export function groupThreadsByProject(
);
return [...groups.entries()].map(([id, group]) => ({
id,
label: uniqueWorkspaceLabel(group.workspacePath, workspacePaths),
label: isChatWorkspacePath(group.workspacePath)
? "Chat"
: uniqueWorkspaceLabel(group.workspacePath, workspacePaths),
workspacePath: group.workspacePath,
threads: group.threads,
}));
@@ -151,6 +151,29 @@ describe("workspace paths", () => {
).toBe(true);
});
it("excludes the SDK chat workspace from discovery and stored selections", () => {
const temporaryWorkspace = "/home/host/.cline/data/workspaces/chat";
expect(isExcludedWorkspacePath(temporaryWorkspace)).toBe(true);
expect(
workspacePathsFromSessions([
{ workspaceRoot: temporaryWorkspace },
{ workspaceRoot: "/projects/app" },
]),
).toEqual(["/projects/app"]);
expect(
parseWorkspaceSelectionStorage(
JSON.stringify({
lastWorkspace: temporaryWorkspace,
workspaces: [temporaryWorkspace, "/projects/app"],
}),
),
).toEqual({
lastWorkspace: "",
workspaces: ["/projects/app"],
});
});
describe("with a registered host home directory", () => {
afterEach(() => {
registerHostHomeDirectory("");
@@ -1,3 +1,5 @@
import { isChatWorkspacePath } from "@cline/shared/browser";
export const WORKSPACE_SELECTION_STORAGE_KEY =
"cline.code.workspace-selection.v1";
@@ -107,6 +109,9 @@ export function isExcludedWorkspacePath(path: string): boolean {
if (!normalized) {
return false;
}
if (isChatWorkspacePath(normalized)) {
return true;
}
if (normalized.split(/[\\/]/).includes(".cline")) {
return true;
}
@@ -168,10 +173,13 @@ export function parseWorkspaceSelectionStorage(
lastWorkspace?: unknown;
workspaces?: unknown;
};
const lastWorkspace =
const parsedLastWorkspace =
typeof parsed?.lastWorkspace === "string"
? parsed.lastWorkspace.trim()
: "";
const lastWorkspace = isChatWorkspacePath(parsedLastWorkspace)
? ""
: parsedLastWorkspace;
const workspaces = Array.isArray(parsed?.workspaces)
? parsed.workspaces.filter(
(workspace): workspace is string => typeof workspace === "string",
@@ -208,12 +216,15 @@ export function writeWorkspaceSelectionToWindow(
return;
}
try {
const lastWorkspace = isChatWorkspacePath(value.lastWorkspace)
? ""
: value.lastWorkspace.trim();
window.localStorage.setItem(
WORKSPACE_SELECTION_STORAGE_KEY,
JSON.stringify({
lastWorkspace: value.lastWorkspace.trim(),
lastWorkspace,
workspaces: filterWorkspacePaths(
mergeWorkspacePaths(value.workspaces, [value.lastWorkspace]),
mergeWorkspacePaths(value.workspaces, [lastWorkspace]),
),
}),
);
+12
View File
@@ -149,6 +149,18 @@ event payload and `source` field.
8. Hub client adapters exported from `@cline/core/hub` (`NodeHubClient`, `HubSessionClient`, `HubUIClient`, `connectToHub`) translate command/reply and event streams into host-facing APIs.
9. Hub `session.get` records include both canonical root-session usage and explicit aggregate usage from the hub-owned `RuntimeHost`, so attached clients can intentionally render either root-only or root-plus-teammate costs without replaying event streams.
Workspace bootstrap is owned by the runtime that executes the session. Hub
clients preserve an omitted `cwd` and `workspaceRoot` across the transport so
the hub-side execution host can place the session in the shared chat
workspace on its own filesystem at
`<cline-data-dir>/workspaces/chat` (by default
`~/.cline/data/workspaces/chat`). The chat workspace is seeded with an
`AGENTS.md` rules file that tells the agent to treat the session as a chat
and to create a named project folder only when the user asks for one.
The resolved paths are returned in the session snapshot and are the source of
truth for client-side manifests; transport clients must not invent a local path
for a remote runtime.
Detached daemon startup retries transient `ETXTBSY` spawn failures before
polling discovery. This covers package-manager updates that replace the CLI
binary immediately before a command restarts the shared hub.
+8
View File
@@ -203,6 +203,14 @@ const session = await cline.start({
console.log(session.result?.text)
```
If both `cwd` and `workspaceRoot` are omitted, the execution host places the
session in the shared chat workspace at
`<cline-data-dir>/workspaces/chat` (by default
`~/.cline/data/workspaces/chat`), seeded with an `AGENTS.md` rules file that
tells the agent to treat the session as a chat and only create a named
project folder when the user asks for one.
The paths in `session.manifest` are the authoritative resolved workspace paths.
`ClineCore` gives the agent built-in tools (`bash`, `editor`, `read_files`, `apply_patch`, `search`, `fetch_web`), persists sessions to SQLite, discovers config from `.cline/` directories, and optionally connects to an RPC sidecar for scheduled agents and cross-process session management.
## Packages
+12
View File
@@ -51,6 +51,15 @@ console.log(result.result?.text);
await cline.dispose();
```
When both `cwd` and `workspaceRoot` are omitted, the execution host places
the session in the shared chat workspace at
`<cline-data-dir>/workspaces/chat` (by default
`~/.cline/data/workspaces/chat`), seeded with an `AGENTS.md` rules file that
tells the agent to treat the session as a chat and only create a named
project folder when the user asks for one.
Read the resolved paths from `result.manifest.cwd` and
`result.manifest.workspace_root`.
## Session Bootstrap
`ClineCore.create(...)` also accepts `prepare(input)`.
@@ -60,6 +69,9 @@ session starts, then apply watcher/extensions/telemetry inputs through
explicit `localRuntime` bootstrap fields without widening the shared host
contract.
Preparation runs before the execution host resolves an omitted workspace, so
pathless starts expose neither `cwd` nor `workspaceRoot` to `prepare(input)`.
## Main APIs
### Runtime and Sessions
+40
View File
@@ -215,6 +215,46 @@ describe("ClineCore", () => {
expect(listeners).toHaveLength(1);
});
it("preserves an omitted workspace until the execution host resolves it", async () => {
const host = {
runtimeAddress: undefined,
startSession: vi.fn(async (_input: StartSessionInput) =>
createStartResult("session-pathless"),
),
runTurn: vi.fn(),
getAccumulatedUsage: vi.fn(),
abort: vi.fn(),
stopSession: vi.fn(),
dispose: vi.fn(),
getSession: vi.fn(async () => undefined),
listSessions: vi.fn(),
deleteSession: vi.fn(),
readSessionMessages: vi.fn(),
subscribe: vi.fn(() => () => {}),
updateSessionModel: vi.fn(),
};
createRuntimeHostMock.mockResolvedValue(host);
const core = await ClineCore.create();
await core.start({
config: {
providerId: "anthropic",
modelId: "claude-sonnet-4-6",
apiKey: "test",
systemPrompt: "You are concise.",
mode: "act",
enableTools: true,
enableSpawnAgent: false,
enableAgentTeams: false,
},
});
expect(host.startSession).toHaveBeenCalledTimes(1);
const forwarded = host.startSession.mock.calls[0]?.[0];
expect(forwarded?.config).not.toHaveProperty("cwd");
expect(forwarded?.config).not.toHaveProperty("workspaceRoot");
});
it("disposes active session bootstraps when the session ends", async () => {
let listener:
| ((event: { type: string; payload: { sessionId: string } }) => void)
@@ -6,13 +6,13 @@ import type {
StartSessionInput,
} from "../runtime/host/runtime-host";
import { splitCoreSessionConfig } from "../runtime/host/runtime-host";
import type { CoreSessionConfig } from "../types/config";
import type { ClineCoreStartConfig } from "../types/config";
import type { ClineCoreStartInput } from "./types";
export function toClineCoreStartInput(
input: StartSessionInput | ClineCoreStartInput,
): ClineCoreStartInput {
const config = input.config as CoreSessionConfig;
const config = input.config as ClineCoreStartConfig;
return "providerId" in config
? {
...input,
@@ -64,7 +64,7 @@ export function normalizeClineCoreStartInput(
function coreConfigFromLocalRuntime(
localRuntime: LocalRuntimeStartOptions | undefined,
): Partial<CoreSessionConfig> {
): Partial<ClineCoreStartConfig> {
if (!localRuntime) {
return {};
}
+5 -3
View File
@@ -12,7 +12,6 @@ import type {
CronSpecRecord,
} from "../cron/store/sqlite-cron-store";
import type { CheckpointEntry } from "../hooks/checkpoint-hooks";
import type { CheckpointWorkspaceCompareResult } from "../session/checkpoint-diff";
import type { RuntimeCapabilities } from "../runtime/capabilities";
import type { SessionHistoryListOptions } from "../runtime/host/history";
import type { SessionBackend } from "../runtime/host/host";
@@ -23,7 +22,8 @@ import type {
StartSessionResult,
} from "../runtime/host/runtime-host";
import type { FeatureFlagsService } from "../services/feature-flags";
import type { CoreSessionConfig } from "../types/config";
import type { CheckpointWorkspaceCompareResult } from "../session/checkpoint-diff";
import type { ClineCoreStartConfig } from "../types/config";
import type { SessionMessagesArtifactUploader } from "../types/session";
export type { RuntimeHostMode } from "../runtime/host/runtime-host";
@@ -128,7 +128,7 @@ export type ClineCoreListHistoryOptions = SessionHistoryListOptions;
export interface ClineCoreStartInput
extends Omit<StartSessionInput, "config" | "localRuntime"> {
config: CoreSessionConfig;
config: ClineCoreStartConfig;
localRuntime?: LocalRuntimeStartOptions;
}
@@ -272,6 +272,8 @@ export interface ClineCoreOptions {
* Optional hook invoked before each session starts.
* Use this to prepare workspace-scoped runtime state and then return an
* adapter that mutates the shared session input before core starts the run.
* This runs before the execution host resolves an omitted workspace, so
* pathless starts expose neither `cwd` nor `workspaceRoot` to this hook.
*/
prepare?: (
input: ClineCoreStartInput,
@@ -153,6 +153,116 @@ describe("HubRuntimeHost", () => {
});
});
it("uses the hub-resolved workspace in the manifest for a pathless start", async () => {
subscribeMock.mockReturnValue(() => {});
const resolvedWorkspace = "/home/host/.cline/data/workspaces/chat";
commandMock.mockResolvedValue({
payload: {
session: {
sessionId: "sess-pathless",
status: "running",
createdAt: Date.now(),
updatedAt: Date.now(),
workspaceRoot: resolvedWorkspace,
},
},
});
const { HubRuntimeHost } = await import("./hub-runtime-host");
const host = new HubRuntimeHost({ url: "ws://127.0.0.1:25463/hub" });
const started = await host.startSession({
config: {
...createConfig(),
sessionId: "sess-pathless",
cwd: undefined,
workspaceRoot: undefined,
},
source: SessionSource.CORE,
});
expect(started.manifest.cwd).toBe(resolvedWorkspace);
expect(started.manifest.workspace_root).toBe(resolvedWorkspace);
const createPayload = commandMock.mock.calls[0]?.[1] as
| Record<string, unknown>
| undefined;
expect(createPayload?.cwd).toBeUndefined();
expect(createPayload?.workspaceRoot).toBeUndefined();
expect(createPayload?.sessionConfig).toMatchObject({
sessionId: "sess-pathless",
});
expect(createPayload?.sessionConfig).not.toHaveProperty("cwd");
expect(createPayload?.sessionConfig).not.toHaveProperty("workspaceRoot");
});
it("rejects a pathless reply without the execution host workspace", async () => {
const unsubscribe = vi.fn();
subscribeMock.mockReturnValue(unsubscribe);
commandMock.mockResolvedValue({
payload: {
session: {
sessionId: "sess-missing-workspace",
status: "running",
createdAt: Date.now(),
updatedAt: Date.now(),
},
},
});
const { HubRuntimeHost } = await import("./hub-runtime-host");
const host = new HubRuntimeHost({ url: "ws://127.0.0.1:25463/hub" });
await expect(
host.startSession({
config: {
...createConfig(),
sessionId: "sess-missing-workspace",
cwd: undefined,
workspaceRoot: undefined,
},
source: SessionSource.CORE,
}),
).rejects.toThrow("Hub runtime did not return a resolved workspace path.");
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
it("cleans a restored session when its host workspace is missing", async () => {
const unsubscribe = vi.fn();
subscribeMock.mockReturnValue(unsubscribe);
commandMock.mockResolvedValue({
ok: true,
payload: {
session: {
sessionId: "sess-restored-missing-workspace",
status: "running",
createdAt: Date.now(),
updatedAt: Date.now(),
},
checkpoint: {},
},
});
const { HubRuntimeHost } = await import("./hub-runtime-host");
const host = new HubRuntimeHost({ url: "ws://127.0.0.1:25463/hub" });
await expect(
host.restoreSession({
sessionId: "source-session",
checkpointRunCount: 1,
start: {
config: {
...createConfig(),
sessionId: "sess-restored-missing-workspace",
cwd: undefined,
workspaceRoot: undefined,
},
source: SessionSource.CORE,
},
}),
).rejects.toThrow("Hub runtime did not return a resolved workspace path.");
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
it("restarts an idle local hub and retries session.create after startup timeout", async () => {
subscribeMock.mockReturnValue(() => {});
const timeoutError = Object.assign(new Error("session.create timed out"), {
@@ -668,8 +668,12 @@ function buildManifest(
): SessionManifest {
const workspaceRoot =
session?.workspaceRoot?.trim() ||
input.config.workspaceRoot ||
input.config.cwd;
input.config.workspaceRoot?.trim() ||
input.config.cwd?.trim();
if (!workspaceRoot) {
throw new Error("Hub runtime did not return a resolved workspace path.");
}
const cwd = session?.cwd?.trim() || input.config.cwd?.trim() || workspaceRoot;
return SessionManifestSchema.parse({
version: 1,
session_id: sessionId,
@@ -680,7 +684,7 @@ function buildManifest(
interactive: input.interactive === true,
provider: input.config.providerId,
model: input.config.modelId,
cwd: session?.cwd?.trim() || input.config.cwd,
cwd,
workspace_root: workspaceRoot,
team_name: input.config.teamName,
enable_tools: input.config.enableTools,
@@ -901,6 +905,15 @@ export class HubRuntimeHost implements RuntimeHost {
this.cleanupPlannedSession(plannedSessionId);
throw new Error("Hub runtime did not return a session id.");
}
let manifest: SessionManifest;
try {
manifest = snapshot
? buildManifestFromSnapshot(snapshot, input)
: buildManifest(sessionId, input, session);
} catch (error) {
this.cleanupPlannedSession(plannedSessionId);
throw error;
}
if (sessionId !== plannedSessionId) {
this.cleanupPlannedSession(plannedSessionId);
this.registerPlannedSession(
@@ -912,9 +925,7 @@ export class HubRuntimeHost implements RuntimeHost {
return {
sessionId,
manifest: snapshot
? buildManifestFromSnapshot(snapshot, input)
: buildManifest(sessionId, input, session),
manifest,
manifestPath: "",
messagesPath: "",
result: undefined,
@@ -1074,31 +1085,45 @@ export class HubRuntimeHost implements RuntimeHost {
| RestoreSessionResult["checkpoint"]
| undefined;
if (!checkpoint) {
if (newSessionId) {
this.cleanupPlannedSession(newSessionId);
} else if (plannedSessionId) {
this.cleanupPlannedSession(plannedSessionId);
}
throw new Error("Hub checkpoint restore returned no checkpoint");
}
return {
sessionId: newSessionId,
startResult: newSessionId
? {
sessionId: newSessionId,
manifest: snapshot
? buildManifestFromSnapshot(
snapshot,
startConfig ?? ({} as StartSessionInput),
)
: buildManifest(
newSessionId,
startConfig ?? ({} as StartSessionInput),
session,
),
manifestPath: "",
messagesPath: "",
result: undefined,
}
: undefined,
messages,
checkpoint,
};
try {
return {
sessionId: newSessionId,
startResult: newSessionId
? {
sessionId: newSessionId,
manifest: snapshot
? buildManifestFromSnapshot(
snapshot,
startConfig ?? ({} as StartSessionInput),
)
: buildManifest(
newSessionId,
startConfig ?? ({} as StartSessionInput),
session,
),
manifestPath: "",
messagesPath: "",
result: undefined,
}
: undefined,
messages,
checkpoint,
};
} catch (error) {
if (newSessionId) {
this.cleanupPlannedSession(newSessionId);
} else if (plannedSessionId) {
this.cleanupPlannedSession(plannedSessionId);
}
throw error;
}
}
async runTurn(input: SendSessionInput): Promise<AgentResult | undefined> {
@@ -100,6 +100,93 @@ describe("HubServerTransport boundaries", () => {
}
});
it("delegates pathless session.create and returns the host-resolved workspace", async () => {
let resolvedWorkspace = "";
let capturedStartInput: StartSessionInput | undefined;
const startSession = vi.fn(
async (input: StartSessionInput): Promise<StartSessionResult> => {
capturedStartInput = input;
const sessionId = input.config.sessionId?.trim() || "missing-session";
resolvedWorkspace = "/home/host/.cline/data/workspaces/chat";
return {
sessionId,
manifest: {
version: 1,
session_id: sessionId,
source: "core",
pid: 1,
started_at: new Date(0).toISOString(),
status: "running",
interactive: true,
provider: "cline",
model: "test-model",
cwd: resolvedWorkspace,
workspace_root: resolvedWorkspace,
enable_tools: true,
enable_spawn: true,
enable_teams: false,
},
manifestPath: "",
messagesPath: "",
result: undefined,
};
},
);
const transport = createTransport({
sessionHost: {
startSession,
getSession: vi.fn().mockImplementation(async (sessionId: string) => ({
sessionId,
source: "core",
status: "running",
startedAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
interactive: true,
provider: "cline",
model: "test-model",
cwd: resolvedWorkspace,
workspaceRoot: resolvedWorkspace,
enableTools: true,
enableSpawn: true,
enableTeams: false,
isSubagent: false,
})),
},
});
const reply = await transport.handleCommand({
version: "v1",
requestId: "req-pathless-create",
command: "session.create",
clientId: "client-1",
payload: {
sessionConfig: {
sessionId: "session-boundary",
providerId: "cline",
modelId: "test-model",
systemPrompt: "system",
},
metadata: { source: "core", interactive: true },
},
});
expect(reply.ok).toBe(true);
expect(startSession).toHaveBeenCalledTimes(1);
expect(capturedStartInput?.config.sessionId).toBe("session-boundary");
expect(capturedStartInput?.config.cwd).toBeUndefined();
expect(capturedStartInput?.config.workspaceRoot).toBeUndefined();
expect(reply.payload?.session).toMatchObject({
cwd: resolvedWorkspace,
workspaceRoot: resolvedWorkspace,
});
expect(reply.payload?.snapshot).toMatchObject({
workspace: {
cwd: resolvedWorkspace,
root: resolvedWorkspace,
},
});
});
it("denies non-interactive approval requests immediately", async () => {
const transport = createTransport();
const ctx = getContext(transport);
@@ -210,18 +210,7 @@ export async function handleSessionCreate(
? payload.workspaceRoot.trim()
: typeof payload.cwd === "string" && payload.cwd.trim()
? payload.cwd.trim()
: "";
if (!workspaceRoot) {
logHubMessage("warn", "session.create.invalid", {
...baseLogContext,
reason: "missing_workspace_root",
});
return errorReply(
envelope,
"invalid_session_create",
"session.create requires workspaceRoot or cwd",
);
}
: undefined;
const clientId = envelope.clientId?.trim() || "hub-client";
const clientContributions = parseHubClientContributions(
runtimeOptions.clientContributions,
+2
View File
@@ -448,6 +448,7 @@ export type {
SendSessionInput,
SessionAccumulatedUsage,
SessionUsageSummary,
StartSessionConfig,
StartSessionInput,
StartSessionResult,
} from "./runtime/host/runtime-host";
@@ -939,6 +940,7 @@ export type { RuntimeEnvironment } from "./types";
export type { SessionStatus } from "./types/common";
export { SESSION_STATUSES, SessionSource } from "./types/common";
export type {
ClineCoreStartConfig,
CoreAgentMode,
CoreCheckpointConfig,
CoreCheckpointContext,
@@ -6,17 +6,22 @@ import {
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import type { MessageWithMetadata } from "@cline/llms";
import type {
AgentConfig,
AgentEvent,
AgentExtensionAutomationContext,
AgentResult,
AgentRuntimeEvent,
BasicLogger,
import {
type AgentConfig,
type AgentEvent,
type AgentExtensionAutomationContext,
type AgentResult,
type AgentRuntimeEvent,
type BasicLogger,
isChatWorkspacePath,
} from "@cline/shared";
import { setClineDir, setHomeDir } from "@cline/shared/storage";
import {
resolveChatWorkspacePath,
setClineDir,
setHomeDir,
} from "@cline/shared/storage";
import simpleGit from "simple-git";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TelemetryService } from "../../services/telemetry/TelemetryService";
@@ -163,6 +168,7 @@ describe("LocalRuntimeHost", () => {
const envSnapshot = {
HOME: process.env.HOME,
CLINE_DIR: process.env.CLINE_DIR,
CLINE_DATA_DIR: process.env.CLINE_DATA_DIR,
};
let isolatedHomeDir = "";
@@ -170,6 +176,7 @@ describe("LocalRuntimeHost", () => {
isolatedHomeDir = mkdtempSync(join(tmpdir(), "core-session-home-"));
process.env.HOME = isolatedHomeDir;
process.env.CLINE_DIR = join(isolatedHomeDir, ".cline");
delete process.env.CLINE_DATA_DIR;
setHomeDir(isolatedHomeDir);
setClineDir(process.env.CLINE_DIR);
});
@@ -177,11 +184,83 @@ describe("LocalRuntimeHost", () => {
afterEach(() => {
process.env.HOME = envSnapshot.HOME;
process.env.CLINE_DIR = envSnapshot.CLINE_DIR;
if (envSnapshot.CLINE_DATA_DIR === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = envSnapshot.CLINE_DATA_DIR;
}
setHomeDir(envSnapshot.HOME ?? "~");
setClineDir(envSnapshot.CLINE_DIR ?? join("~", ".cline"));
rmSync(isolatedHomeDir, { recursive: true, force: true });
});
it.each([
{ source: "generated", requestedSessionId: undefined },
{ source: "requested", requestedSessionId: "session-explicit" },
] as const)("resolves an omitted workspace with the $source session ID", async ({
requestedSessionId,
}) => {
const runtimeBuilder = {
build: vi.fn().mockReturnValue({
tools: [],
shutdown: vi.fn().mockResolvedValue(undefined),
}),
};
const agent = {
run: vi.fn().mockResolvedValue(createResult()),
continue: vi.fn().mockResolvedValue(createResult()),
getMessages: vi.fn().mockReturnValue([]),
getAgentId: vi.fn().mockReturnValue("agent-temp-workspace"),
getConversationId: vi.fn().mockReturnValue("conv-temp-workspace"),
abort: vi.fn(),
subscribeEvents: vi.fn().mockReturnValue(() => {}),
canStartRun: vi.fn().mockReturnValue(true),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const manager = new RuntimeHostUnderTest({
distinctId,
sessionService: new FileSessionService(join(isolatedHomeDir, "sessions")),
runtimeBuilder: runtimeBuilder as never,
createAgent: () => agent as never,
});
let chatWorkspace = "";
try {
const result = await manager.startSession({
config: {
...(requestedSessionId ? { sessionId: requestedSessionId } : {}),
providerId: "mock-provider",
modelId: "mock-model",
systemPrompt: "You are a test agent",
enableTools: false,
enableSpawnAgent: false,
enableAgentTeams: false,
},
});
chatWorkspace = result.manifest.cwd;
if (requestedSessionId) {
expect(result.sessionId).toBe(requestedSessionId);
}
expect(chatWorkspace).toBe(resolveChatWorkspacePath());
expect(isChatWorkspacePath(chatWorkspace)).toBe(true);
expect(result.manifest.workspace_root).toBe(chatWorkspace);
expect(runtimeBuilder.build).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
cwd: chatWorkspace,
workspaceRoot: chatWorkspace,
}),
}),
);
} finally {
await manager.dispose();
if (chatWorkspace) {
rmSync(dirname(chatWorkspace), { recursive: true, force: true });
}
}
});
it("stores git under metadata and refreshes it after an active turn", async () => {
const workspaceRoot = join(isolatedHomeDir, "workspace");
mkdirSync(workspaceRoot, { recursive: true });
@@ -3354,15 +3433,25 @@ describe("LocalRuntimeHost", () => {
}) as never,
});
await manager.startSession(
normalizeStartInput({
config: createConfig({ sessionId }),
interactive: true,
initialMessages,
}),
);
const pathlessConfig = {
...createConfig({ sessionId }),
cwd: undefined,
};
await manager.startSession({
config: pathlessConfig,
interactive: true,
initialMessages,
});
expect(createRootSessionWithArtifacts).not.toHaveBeenCalled();
expect(runtimeBuilder.build).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
cwd: manifest.cwd,
workspaceRoot: manifest.workspace_root,
}),
}),
);
expect(persistSessionMessages).not.toHaveBeenCalled();
expect(updateSessionStatus).not.toHaveBeenCalled();
expect(updateSession).not.toHaveBeenCalled();
@@ -51,6 +51,7 @@ import {
sumUsageTotals,
} from "../../services/usage";
import { enrichPromptWithMentions } from "../../services/workspace";
import { resolveStartSessionWorkspace } from "../../services/workspace/chat-workspace";
import {
type GitWorkspaceState,
hasCurrentSessionGitMetadata,
@@ -117,6 +118,7 @@ import {
import { loadUserFileContent } from "./local/user-files";
import type {
PendingPromptsServiceApi,
ResolvedStartSessionInput,
RestoreSessionInput,
RestoreSessionResult,
RuntimeHost,
@@ -305,8 +307,8 @@ export class LocalRuntimeHost implements RuntimeHost {
}
private async applyInitialOAuthCredentials(
input: StartSessionInput,
): Promise<StartSessionInput> {
input: ResolvedStartSessionInput,
): Promise<ResolvedStartSessionInput> {
if (input.config.apiKey?.trim()) {
return input;
}
@@ -330,11 +332,46 @@ export class LocalRuntimeHost implements RuntimeHost {
// ── Public API ──────────────────────────────────────────────────────
async startSession(input: StartSessionInput): Promise<StartSessionResult> {
const source = input.source ?? SessionSource.CLI;
const startedAt = nowIso();
const requestedSessionId = input.config.sessionId?.trim() ?? "";
const sessionId = requestedSessionId || createSessionId();
const startInput: StartSessionInput =
const isReadOnlyResumeStart =
requestedSessionId.length > 0 &&
(input.initialMessages?.length ?? 0) > 0 &&
!input.prompt?.trim();
const hasRequestedWorkspace = Boolean(
input.config.cwd?.trim() || input.config.workspaceRoot?.trim(),
);
const existingResumeManifest =
isReadOnlyResumeStart && !hasRequestedWorkspace
? await this.invokeOptionalValue<SessionManifest>(
"readSessionManifest",
sessionId,
)
: undefined;
const config = existingResumeManifest
? {
...input.config,
cwd: existingResumeManifest.cwd,
workspaceRoot: existingResumeManifest.workspace_root,
}
: await resolveStartSessionWorkspace(input.config);
return await this.startResolvedSession(
{ ...input, config },
sessionId,
requestedSessionId.length > 0,
existingResumeManifest,
);
}
private async startResolvedSession(
input: ResolvedStartSessionInput,
sessionId: string,
wasSessionIdRequested: boolean,
existingResumeManifest?: SessionManifest,
): Promise<StartSessionResult> {
const source = input.source ?? SessionSource.CLI;
const startedAt = nowIso();
const startInput: ResolvedStartSessionInput =
await this.applyInitialOAuthCredentials(input);
const initialMessages = startInput.initialMessages ?? [];
const initialUsage =
@@ -379,14 +416,16 @@ export class LocalRuntimeHost implements RuntimeHost {
let resumedArtifacts: RootSessionArtifacts | undefined;
let resumedCompactionState: SessionCompactionState | undefined;
const isReadOnlyResumeStart =
requestedSessionId.length > 0 &&
wasSessionIdRequested &&
initialMessages.length > 0 &&
!startInput.prompt?.trim();
if (isReadOnlyResumeStart) {
const existingManifest = await this.invokeOptionalValue<SessionManifest>(
"readSessionManifest",
sessionId,
);
const existingManifest =
existingResumeManifest ??
(await this.invokeOptionalValue<SessionManifest>(
"readSessionManifest",
sessionId,
));
if (existingManifest) {
manifest = existingManifest;
resumedArtifacts = {
@@ -705,7 +744,7 @@ export class LocalRuntimeHost implements RuntimeHost {
emitSessionCreationTelemetry(
configWithProvider,
sessionId,
requestedSessionId.length > 0,
wasSessionIdRequested,
workspacePath,
rootAgentIdentity,
);
@@ -10,7 +10,10 @@ import type { ProviderSettings } from "../../services/llms/provider-settings";
import type { SessionCompactionState } from "../../session/models/session-compaction";
import type { SessionManifest } from "../../session/models/session-manifest";
import type { SessionSource } from "../../types/common";
import type { CoreSessionConfig } from "../../types/config";
import type {
ClineCoreStartConfig,
CoreSessionConfig,
} from "../../types/config";
import type {
CoreSessionEvent,
SessionPendingPrompt,
@@ -69,6 +72,11 @@ export type RuntimeSessionConfig = Omit<
compaction?: Omit<NonNullable<CoreSessionConfig["compaction"]>, "compact">;
};
/** Workspace paths may be omitted only at the session-start boundary. */
export type StartSessionConfig = Omit<RuntimeSessionConfig, "cwd"> & {
cwd?: string;
};
export type LocalRuntimeBootstrapConfig = Pick<
CoreSessionConfig,
LocalOnlyCoreSessionConfigKeys
@@ -100,7 +108,7 @@ export interface LocalRuntimeStartOptions {
}
export interface StartSessionInput {
config: RuntimeSessionConfig;
config: StartSessionConfig;
source?: SessionSource;
prompt?: string;
interactive?: boolean;
@@ -119,8 +127,14 @@ export interface StartSessionInput {
toolPolicies?: import("@cline/shared").AgentConfig["toolPolicies"];
}
export function splitCoreSessionConfig(config: CoreSessionConfig): {
/** Session input after the execution host has resolved a concrete workspace. */
export interface ResolvedStartSessionInput
extends Omit<StartSessionInput, "config"> {
config: RuntimeSessionConfig;
}
export function splitCoreSessionConfig(config: ClineCoreStartConfig): {
config: StartSessionConfig;
localRuntime?: LocalRuntimeStartOptions;
} {
const {
@@ -37,7 +37,7 @@ import type { RuntimeCapabilities } from "../runtime/capabilities";
import { normalizeRuntimeCapabilities } from "../runtime/capabilities";
import type {
LocalRuntimeStartOptions,
StartSessionInput,
ResolvedStartSessionInput,
} from "../runtime/host/runtime-host";
import type { RuntimeBuilderInput } from "../runtime/orchestration/session-runtime";
import { SessionSource } from "../types/common";
@@ -115,7 +115,7 @@ function hasConfigExtension(
}
function countSeededRootRuns(
messages: StartSessionInput["initialMessages"],
messages: ResolvedStartSessionInput["initialMessages"],
): number {
let count = 0;
for (const message of messages ?? []) {
@@ -136,7 +136,7 @@ function countSeededRootRuns(
function buildProviderConfig(
config: CoreSessionConfig,
sessionId: string,
source: StartSessionInput["source"],
source: ResolvedStartSessionInput["source"],
providerSettingsManager: ProviderSettingsManager,
modelCatalogDefaults?: Partial<ProviderSettings["modelCatalog"]>,
defaultFetch?: typeof fetch,
@@ -216,7 +216,7 @@ function buildProviderConfig(
}
export interface PrepareLocalRuntimeBootstrapOptions {
input: StartSessionInput;
input: ResolvedStartSessionInput;
localRuntime?: LocalRuntimeStartOptions;
sessionId: string;
providerSettingsManager: ProviderSettingsManager;
@@ -244,7 +244,7 @@ export interface PrepareLocalRuntimeBootstrapOptions {
}
export interface LocalRuntimeBootstrap {
effectiveInput: StartSessionInput;
effectiveInput: ResolvedStartSessionInput;
config: CoreSessionConfig;
providerConfig: ProviderConfig;
workspaceMetadata: string;
@@ -0,0 +1,108 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
isChatWorkspacePath,
resolveChatWorkspacePath,
} from "@cline/shared/storage";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { StartSessionConfig } from "../../runtime/host/runtime-host";
import {
ensureChatWorkspace,
resolveStartSessionWorkspace,
} from "./chat-workspace";
function createConfig(
overrides: Partial<StartSessionConfig> = {},
): StartSessionConfig {
return {
providerId: "test-provider",
modelId: "test-model",
systemPrompt: "",
enableTools: false,
enableSpawnAgent: false,
enableAgentTeams: false,
...overrides,
};
}
describe("chat workspace", () => {
let previousDataDir: string | undefined;
let isolatedDataDir: string;
beforeEach(async () => {
previousDataDir = process.env.CLINE_DATA_DIR;
isolatedDataDir = await mkdtemp(join(tmpdir(), "chat-workspace-test-"));
process.env.CLINE_DATA_DIR = isolatedDataDir;
});
afterEach(async () => {
if (previousDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = previousDataDir;
}
await rm(isolatedDataDir, { recursive: true, force: true });
});
it("creates the shared chat workspace under the cline data directory", async () => {
const workspace = await ensureChatWorkspace();
expect(workspace).toBe(resolveChatWorkspacePath());
expect(workspace).toBe(join(isolatedDataDir, "workspaces", "chat"));
});
it("seeds the chat workspace rules file", async () => {
const workspace = await ensureChatWorkspace();
const rules = await readFile(join(workspace, "AGENTS.md"), "utf8");
expect(rules).toContain("shared workspace for chat sessions");
expect(rules).toContain("do not create");
});
it("keeps a user-edited rules file", async () => {
const workspace = await ensureChatWorkspace();
const rulesPath = join(workspace, "AGENTS.md");
await writeFile(rulesPath, "my custom rules");
await ensureChatWorkspace();
expect(await readFile(rulesPath, "utf8")).toBe("my custom rules");
});
it("recognizes the default data-dir layout as the chat workspace", () => {
expect(isChatWorkspacePath("/home/user/.cline/data/workspaces/chat")).toBe(
true,
);
expect(
isChatWorkspacePath("/home/user/.cline/data/workspaces/chat/my-app"),
).toBe(false);
});
it("reuses the chat workspace without clearing its contents", async () => {
const workspace = await ensureChatWorkspace();
const draftPath = join(workspace, "draft.txt");
await writeFile(draftPath, "keep me");
expect(await ensureChatWorkspace()).toBe(workspace);
expect(await readFile(draftPath, "utf8")).toBe("keep me");
});
it("uses one provided workspace path for both resolved fields", async () => {
await expect(
resolveStartSessionWorkspace(createConfig({ cwd: "/repo/app" })),
).resolves.toMatchObject({
cwd: "/repo/app",
workspaceRoot: "/repo/app",
});
await expect(
resolveStartSessionWorkspace(
createConfig({ workspaceRoot: "/repo/root" }),
),
).resolves.toMatchObject({
cwd: "/repo/root",
workspaceRoot: "/repo/root",
});
});
it("assigns the shared chat workspace when both paths are omitted", async () => {
const resolved = await resolveStartSessionWorkspace(createConfig());
expect(resolved.cwd).toBe(resolved.workspaceRoot);
expect(resolved.cwd).toBe(resolveChatWorkspacePath());
});
});
@@ -0,0 +1,65 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import {
AGENTS_RULES_FILE_NAME,
resolveChatWorkspacePath,
} from "@cline/shared/storage";
import type {
RuntimeSessionConfig,
StartSessionConfig,
} from "../../runtime/host/runtime-host";
const CHAT_WORKSPACE_RULES = `# Cline chat workspace
This directory is Cline's shared workspace for chat sessions that were
started without a project. It is not a project of its own, and other chat
sessions use it too. Treat the conversation as a chat first: do not create
or edit files here unless the user asks for something that requires them.
If the user asks you to build something or write code:
1. Ask where they would like the project to live.
2. If they have no preference, create a new folder with a short, descriptive
name inside this directory and do all work inside that folder. Tell the
user where it is so they can open it as a workspace later.
Projects from earlier chat sessions may already exist as folders here; the
user may refer back to them. Never assume loose files in this directory
belong to the current conversation, and keep new work inside a named folder
rather than at the top level.
`;
/**
* Ensure the shared chat workspace exists and is seeded with the rules file
* that tells the agent how to behave in a project-less session. The rules
* file is only written when missing so users can edit it.
*/
export async function ensureChatWorkspace(): Promise<string> {
const workspacePath = resolveChatWorkspacePath();
await mkdir(workspacePath, { recursive: true, mode: 0o700 });
const rulesPath = join(workspacePath, AGENTS_RULES_FILE_NAME);
try {
await writeFile(rulesPath, CHAT_WORKSPACE_RULES, { flag: "wx" });
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
throw error;
}
}
return workspacePath;
}
/** Resolve the optional workspace fields at the execution-host boundary. */
export async function resolveStartSessionWorkspace(
config: StartSessionConfig,
): Promise<RuntimeSessionConfig> {
const requestedCwd = config.cwd?.trim() ?? "";
const requestedRoot = config.workspaceRoot?.trim() ?? "";
const workspacePath =
requestedCwd || requestedRoot || (await ensureChatWorkspace());
return {
...config,
cwd: requestedCwd || workspacePath,
workspaceRoot: requestedRoot || workspacePath,
};
}
+9
View File
@@ -283,3 +283,12 @@ export interface CoreSessionConfig
skills?: string[];
workspaceMetadata?: string;
}
/**
* Public ClineCore start configuration. The execution host resolves `cwd`
* before constructing a runtime, assigning the shared chat workspace when both
* workspace paths are omitted.
*/
export type ClineCoreStartConfig = Omit<CoreSessionConfig, "cwd"> & {
cwd?: string;
};
+5
View File
@@ -444,6 +444,11 @@ export {
} from "./session/runtime-config";
export type { RuntimeEnv } from "./session/runtime-env";
export * from "./session/workspace";
export {
CLINE_CHAT_WORKSPACE_DIRECTORY_NAME,
CLINE_WORKSPACES_DIRECTORY_NAME,
isChatWorkspacePath,
} from "./storage/chat-workspace-paths";
export * from "./team";
export { createTool } from "./tools/create";
export { AUTH_ERROR_PATTERNS, isLikelyAuthError } from "./types/auth";
+5
View File
@@ -496,6 +496,11 @@ export {
} from "./session/runtime-config";
export type { RuntimeEnv } from "./session/runtime-env";
export * from "./session/workspace";
export {
CLINE_CHAT_WORKSPACE_DIRECTORY_NAME,
CLINE_WORKSPACES_DIRECTORY_NAME,
isChatWorkspacePath,
} from "./storage/chat-workspace-paths";
export * from "./team";
export { createTool } from "./tools/create";
export * from "./types";
@@ -0,0 +1,38 @@
export const CLINE_WORKSPACES_DIRECTORY_NAME = "workspaces";
export const CLINE_CHAT_WORKSPACE_DIRECTORY_NAME = "chat";
// Default data-dir anchors for the structural check below. The Node resolver
// derives the real location from resolveClineDataDir(), which defaults to
// `~/.cline/data`.
const CLINE_CONFIG_DIRECTORY_NAME = ".cline";
const CLINE_DATA_DIRECTORY_NAME = "data";
/**
* Browser-safe structural check for the shared chat workspace that hosts
* sessions started without a project: `.cline/data/workspaces/chat`. Matches
* the directory itself only project folders created inside it are regular
* workspaces. Matches the default data-dir layout; explicit `CLINE_DATA_DIR`
* overrides are not detectable from a bare path string.
*/
export function isChatWorkspacePath(path: string): boolean {
const normalizedPath = path.trim();
const isWindowsAbsolute =
/^[A-Za-z]:[\\/]/.test(normalizedPath) || normalizedPath.startsWith("\\\\");
const isPosixAbsolute = normalizedPath.startsWith("/");
if (!isWindowsAbsolute && !isPosixAbsolute) {
return false;
}
const segments = normalizedPath
.split(isWindowsAbsolute ? /[\\/]+/ : /\/+/)
.filter(Boolean);
const chatDirectory = segments.at(-1) ?? "";
const workspacesDirectory = segments.at(-2) ?? "";
const dataDirectory = segments.at(-3) ?? "";
const configDirectory = segments.at(-4) ?? "";
return (
configDirectory === CLINE_CONFIG_DIRECTORY_NAME &&
dataDirectory === CLINE_DATA_DIRECTORY_NAME &&
workspacesDirectory === CLINE_WORKSPACES_DIRECTORY_NAME &&
chatDirectory === CLINE_CHAT_WORKSPACE_DIRECTORY_NAME
);
}
+4
View File
@@ -2,19 +2,23 @@ export { resolveExistingFilePath } from "./path-resolution";
export {
AGENT_CONFIG_DIRECTORY_NAME,
AGENTS_RULES_FILE_NAME,
CLINE_CHAT_WORKSPACE_DIRECTORY_NAME,
CLINE_CONNECTOR_SETTINGS_FILE_NAME,
CLINE_MCP_SETTINGS_FILE_NAME,
CLINE_WORKSPACES_DIRECTORY_NAME,
type CronSpecsScope,
discoverPluginModulePaths,
ensureFileExists,
ensureHookLogDir,
ensureParentDir,
HOOKS_CONFIG_DIRECTORY_NAME,
isChatWorkspacePath,
isPluginModulePath,
type ResolveCronSpecsDirOptions,
RULES_CONFIG_DIRECTORY_NAME,
resolveAgentConfigSearchPaths,
resolveAgentsConfigDirPath,
resolveChatWorkspacePath,
resolveClineDataDir,
resolveClineDir,
resolveConfiguredPluginModulePaths,
@@ -2,11 +2,15 @@ import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
AGENT_CONFIG_DIRECTORY_NAME,
CLINE_CHAT_WORKSPACE_DIRECTORY_NAME,
CLINE_CONNECTOR_SETTINGS_FILE_NAME,
CLINE_MCP_SETTINGS_FILE_NAME,
CLINE_WORKSPACES_DIRECTORY_NAME,
HOOKS_CONFIG_DIRECTORY_NAME,
isChatWorkspacePath,
RULES_CONFIG_DIRECTORY_NAME,
resolveAgentsConfigDirPath,
resolveChatWorkspacePath,
resolveClineDataDir,
resolveConnectorDataDir,
resolveConnectorSettingsPath,
@@ -219,3 +223,58 @@ describe("storage path resolution", () => {
]);
});
});
describe("chat workspace paths", () => {
let snapshot: EnvSnapshot = captureEnv();
afterEach(() => {
restoreEnv(snapshot);
});
it("exports the canonical path segments", () => {
expect(CLINE_WORKSPACES_DIRECTORY_NAME).toBe("workspaces");
expect(CLINE_CHAT_WORKSPACE_DIRECTORY_NAME).toBe("chat");
});
it("resolves the shared chat workspace under the cline data dir", () => {
snapshot = captureEnv();
delete process.env.CLINE_DATA_DIR;
process.env.CLINE_DIR = "/tmp/home/.cline";
expect(resolveChatWorkspacePath()).toBe(
join("/tmp/home/.cline", "data", "workspaces", "chat"),
);
});
it("honors the CLINE_DATA_DIR override", () => {
snapshot = captureEnv();
process.env.CLINE_DATA_DIR = "/tmp/cline-data";
expect(resolveChatWorkspacePath()).toBe(
join("/tmp/cline-data", "workspaces", "chat"),
);
});
it.each([
"/home/user/.cline/data/workspaces/chat",
"//home//user//.cline//data//workspaces//chat//",
"C:\\Users\\dev\\.cline\\data\\workspaces\\chat\\",
"\\\\server\\share\\.cline\\data\\workspaces\\chat",
])("recognizes chat workspace root %s", (path) => {
expect(isChatWorkspacePath(path)).toBe(true);
});
it.each([
".cline/data/workspaces/chat",
"/tmp/chat",
"/tmp/cline/sessions/session-a1b2c3-temp/project",
"/home/user/cline/data/workspaces/chat",
"/home/user/.cline/workspaces/chat",
"/home/user/.cline/data/other/chat",
"/home/user/.cline/data/workspaces/Chat",
"/home/user/.cline/data/workspaces/chat/my-app",
"/home/user/.cline/data/workspaces",
])("rejects non-chat workspace path %s", (path) => {
expect(isChatWorkspacePath(path)).toBe(false);
});
});
+26
View File
@@ -10,6 +10,18 @@ import {
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import type { PluginManifest } from "..";
import {
CLINE_CHAT_WORKSPACE_DIRECTORY_NAME,
CLINE_WORKSPACES_DIRECTORY_NAME,
} from "./chat-workspace-paths";
// Keep the structural pieces browser-safe while exposing them through the
// canonical Node storage-path module alongside the data-dir resolver.
export {
CLINE_CHAT_WORKSPACE_DIRECTORY_NAME,
CLINE_WORKSPACES_DIRECTORY_NAME,
isChatWorkspacePath,
} from "./chat-workspace-paths";
const DEPRECATED_CONFIG_DIR = ".clinerules";
const CLINE_CONFIG_DIR = ".cline";
@@ -23,6 +35,20 @@ export const WORKFLOWS_CONFIG_DIRECTORY_NAME = "workflows";
export const PLUGINS_DIRECTORY_NAME = "plugins";
export const AGENTS_RULES_FILE_NAME = "AGENTS.md";
/**
* Shared workspace for all sessions started without a `cwd`/`workspaceRoot`.
* Lives under the cline data dir (not `os.tmpdir()`) so OS temp reapers never
* delete user work, the path is private to the user on multi-user hosts, and
* the directory shares the session store's lifecycle and env overrides.
*/
export function resolveChatWorkspacePath(): string {
return join(
resolveClineDataDir(),
CLINE_WORKSPACES_DIRECTORY_NAME,
CLINE_CHAT_WORKSPACE_DIRECTORY_NAME,
);
}
export const CLINE_MCP_SETTINGS_FILE_NAME = "cline_mcp_settings.json";
export const CLINE_CONNECTOR_SETTINGS_FILE_NAME = "settings.json";