mirror of
https://github.com/cline/cline.git
synced 2026-09-09 06:45:53 +08:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e0aac53a2 | ||
|
|
ad442cbb6a | ||
|
|
225f65cc0e | ||
|
|
8a619a9ea6 | ||
|
|
2e46676952 | ||
|
|
102e08f5f4 | ||
|
|
942f76e8e7 | ||
|
|
57eb181bab | ||
|
|
63e9c99031 | ||
|
|
afab68f3a3 | ||
|
|
d3d3bd8749 | ||
|
|
afab86dcbd | ||
|
|
6d7e745fb7 | ||
|
|
fcd5a9e0fb | ||
|
|
c8afb44368 |
@@ -1,5 +1,46 @@
|
||||
# Changelog
|
||||
|
||||
## [4.1.10]
|
||||
|
||||
Everything in this release lands through the SDK bundle, so it applies to windows running that bundle and not the legacy one. The legacy bundle is unchanged from 4.1.9.
|
||||
|
||||
### Added
|
||||
|
||||
- Let models that support it search the web during a task, with a toggle in Feature Settings to turn it on. Search calls and their results appear in the conversation and persist across reloads.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stop two Cline installations on different builds from shutting each other's Hub daemon down in a loop, which killed live sessions with an abnormal socket close. Build identity is now compared through a total order, so at most one side of a pair can decide to retire the other.
|
||||
- Leave a Hub that is still serving sessions in place instead of replacing it mid-handshake; the swap happens once it goes idle.
|
||||
- Reclaim idle plugin sandbox processes instead of leaving them running for the life of the session.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refresh the model catalog, which adds Crusoe as a provider and updates model lists and per-provider default models across the board.
|
||||
|
||||
## [4.1.9]
|
||||
|
||||
### Changed
|
||||
|
||||
- Use the editor's foreground color for diff block text, so diffs stay legible in themes where the previous hardcoded color washed them out.
|
||||
- Switch the interface to Inter and Geist Mono.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Don't discard a successfully refreshed Cline token when the old one was already past expiry, which made the first request after a long idle period fail despite valid credentials.
|
||||
- Stop the legacy-task migration backlog from spamming telemetry, and record a migration outcome only once the seeded session actually persists, so a failed migration is no longer reported as a success.
|
||||
- Report involuntary Cline logouts (a rejected refresh token) instead of clearing credentials silently.
|
||||
|
||||
### Fixed (SDK bundle only)
|
||||
|
||||
These land through SDK v0.0.74 and therefore apply to windows running the SDK bundle, not the legacy one.
|
||||
|
||||
- Fix the Claude Code provider being unusable for agentic work: it now runs its own native tools instead of receiving tool definitions it cannot bridge, anchors the session on your workspace directory, and loads `~/.claude` plus project settings so your permission rules apply.
|
||||
- Reject truncated tool-call JSON instead of silently "repairing" it into wrong arguments.
|
||||
- Fix strict providers rejecting a turn with "user message must have content" when a message's content held only empty text parts.
|
||||
- Fix a mid-turn crash on streamed tool calls with non-zero or non-contiguous indexes, hit through LiteLLM's Anthropic passthrough.
|
||||
- Report disjoint per-request token buckets instead of re-counting the whole cached conversation on every request, which inflated per-task totals roughly 5x on cache-heavy sessions.
|
||||
|
||||
## [4.1.8]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.55
|
||||
|
||||
- Auto-updates no longer install while a CLI is attached to the Hub. The update is recorded at startup and installed on exit, once the Hub confirms nothing else is attached, so a background update can no longer swap the package out from under a live session and kill it with `Hub connection closed (code=1006)`. `cline update` still installs immediately and now tells you the update applies on next start
|
||||
- Added protections for an update landing under CLI 3.0.54 and earlier, whose updater restarts the Hub mid-session and then rejects every replacement, bricking a running session. The newly installed package defuses that path during install instead of leaving it to fire
|
||||
- Fixed two Cline installations on different builds shutting each other's Hub daemon down in a loop, which killed every live session with an abnormal socket close. Build identity is now compared through a total order, so at most one side of a pair can ever decide to retire the other (from SDK v0.0.75)
|
||||
- A newer build no longer replaces a Hub that is still serving sessions — it attaches to it and the swap happens on a later launch, instead of the sessions dying mid-handshake (from SDK v0.0.75)
|
||||
- Removed the "outdated Hub" notice. It reported a state you cannot act on, and the toast was capped narrower than the message, so it rendered cut off before the reassuring half of the sentence at every terminal width. The prompt for a genuine build mismatch, where there is something to do, is unchanged
|
||||
- Streaming assistant markdown no longer flashes back to raw text. Settled headings, links, and code stay rendered as new chunks arrive instead of the whole message being rebuilt and re-highlighted on every chunk, which also stops the transcript from jumping vertically mid-stream
|
||||
- Web search calls and their results from models that run search natively now render in the transcript (from SDK v0.0.75)
|
||||
- Idle plugin sandbox processes are now reclaimed instead of lingering for the life of the session (from SDK v0.0.75)
|
||||
- `cline doctor fix` now reports honestly: processes that survived a kill are separated from ones that appeared while the fix ran, a live parent respawning a daemon is named, and a startup lock held by a running process is reported as held rather than leaked (from SDK v0.0.75)
|
||||
- Refreshed the model catalog, which adds Crusoe as a provider and updates model lists and per-provider default models across the board (from SDK v0.0.75)
|
||||
|
||||
## 3.0.54
|
||||
|
||||
- Fixed the Claude Code provider being unusable for agentic work: the provider now runs its own native tools instead of receiving tool definitions it cannot bridge, the session is anchored on your workspace directory instead of inheriting the host's cwd, and `~/.claude` plus project settings are loaded so your permission rules apply. File edits under the workspace are auto-approved; command execution stays gated by your own Claude settings (from SDK v0.0.74)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.54",
|
||||
"version": "3.0.55",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -17,6 +17,35 @@ import { fileURLToPath } from "node:url";
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// CLI versions <= 3.0.54 restart the hub daemon after a background
|
||||
// auto-update even while it is serving live sessions, killing those sessions
|
||||
// mid-turn — and their build-fingerprint check then rejects every replacement
|
||||
// hub, bricking the running TUI. That restart code is the *old* version's, so
|
||||
// it cannot be patched here; but it bails out harmlessly when no hub
|
||||
// discovery record exists, and it runs only after this install (and this
|
||||
// script) completes. Setting the record aside protects any attached clients:
|
||||
// a running hub keeps serving its established connections, clients that share
|
||||
// its build fingerprint rebuild the record from a port probe, and the next
|
||||
// fresh launch retires stale hubs regardless of the record.
|
||||
function shieldRunningHubDiscovery() {
|
||||
const explicitPath = process.env.CLINE_HUB_DISCOVERY_PATH?.trim();
|
||||
const dataDir =
|
||||
process.env.CLINE_DATA_DIR?.trim() ||
|
||||
path.join(
|
||||
process.env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline"),
|
||||
"data",
|
||||
);
|
||||
const recordPath =
|
||||
explicitPath || path.join(dataDir, "locks", "hub", "production.json");
|
||||
if (!fs.existsSync(recordPath)) {
|
||||
return;
|
||||
}
|
||||
const asidePath = `${recordPath}.superseded`;
|
||||
fs.rmSync(asidePath, { force: true });
|
||||
fs.renameSync(recordPath, asidePath);
|
||||
console.log("Set aside hub discovery record for the updated CLI");
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (os.platform() === "win32") {
|
||||
// On Windows, npm creates .cmd shims from the bin field.
|
||||
@@ -79,6 +108,14 @@ function main() {
|
||||
console.log(`Cached cline binary at ${target}`);
|
||||
}
|
||||
|
||||
try {
|
||||
shieldRunningHubDiscovery();
|
||||
} catch (error) {
|
||||
// Best-effort: without the shield the worst case is the pre-3.0.55
|
||||
// restart-while-busy behavior, never a broken install.
|
||||
console.error(`postinstall: hub discovery shield skipped: ${error.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
ProviderSettingsManager,
|
||||
SessionSource,
|
||||
} from "@cline/core";
|
||||
import { isLikelyAuthError, type Message } from "@cline/shared";
|
||||
import { isLikelyAuthError, type MessageWithMetadata } from "@cline/shared";
|
||||
import { getPersistedProviderApiKey } from "../commands/auth";
|
||||
import { resolveSystemPrompt } from "../runtime/prompt";
|
||||
import { subscribeToAgentEvents } from "../runtime/session-events";
|
||||
@@ -100,7 +100,7 @@ interface SessionState {
|
||||
*/
|
||||
fatalError?: Error;
|
||||
/** Messages to inject into the next session manager for conversation continuity. */
|
||||
pendingInitialMessages?: Message[];
|
||||
pendingInitialMessages?: MessageWithMetadata[];
|
||||
}
|
||||
|
||||
export class AcpAgent implements Agent {
|
||||
@@ -240,7 +240,7 @@ export class AcpAgent implements Agent {
|
||||
this.isSessionReady();
|
||||
|
||||
let session = this.sessions.get(params.sessionId);
|
||||
let messages: Message[];
|
||||
let messages: MessageWithMetadata[];
|
||||
|
||||
if (session?.sessionManager && session.activeSessionId) {
|
||||
// The session is still live in this connection — replay its current
|
||||
@@ -676,7 +676,7 @@ export class AcpAgent implements Agent {
|
||||
session: SessionState,
|
||||
acpSessionId: string,
|
||||
options?: { resume?: boolean },
|
||||
): Promise<Message[] | undefined> {
|
||||
): Promise<MessageWithMetadata[] | undefined> {
|
||||
if (session.sessionManager) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -695,7 +695,7 @@ export class AcpAgent implements Agent {
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
});
|
||||
|
||||
let initialMessages: Message[] | undefined;
|
||||
let initialMessages: MessageWithMetadata[] | undefined;
|
||||
if (options?.resume) {
|
||||
initialMessages = await sessionManager
|
||||
.readMessages(acpSessionId)
|
||||
|
||||
@@ -225,6 +225,76 @@ describe("translateHistoricalMessage", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("replays provider model tools with the ordinary ACP tool updates", () => {
|
||||
expect(
|
||||
translateHistoricalMessage({
|
||||
role: "assistant",
|
||||
content: "Found it",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun release" },
|
||||
output: "Bun 1.3.14",
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Parameters<typeof translateHistoricalMessage>[0]),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "search-1",
|
||||
title: expect.any(String),
|
||||
kind: "search",
|
||||
status: "pending",
|
||||
rawInput: { query: "latest Bun release" },
|
||||
},
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "search-1",
|
||||
status: "completed",
|
||||
rawOutput: "Bun 1.3.14",
|
||||
},
|
||||
{
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "Found it" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves structured native web-search results", () => {
|
||||
const nativeResult = {
|
||||
type: "web_search_result",
|
||||
url: "https://bun.sh/blog/bun-v1.3.14",
|
||||
title: "Bun v1.3.14",
|
||||
pageAge: "2026-08-12",
|
||||
encryptedContent: "encrypted",
|
||||
};
|
||||
const updates = translateHistoricalMessage({
|
||||
role: "assistant",
|
||||
content: "Found it",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-native",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun" },
|
||||
output: [nativeResult],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Parameters<typeof translateHistoricalMessage>[0]);
|
||||
|
||||
expect(updates[1]).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "search-native",
|
||||
rawOutput: JSON.stringify(nativeResult),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaySessionHistory", () => {
|
||||
|
||||
@@ -2,10 +2,11 @@ import type {
|
||||
AgentSideConnection,
|
||||
SessionUpdate,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { projectSessionMessagesForDisplay } from "@cline/core";
|
||||
import {
|
||||
type ContentBlock,
|
||||
formatDisplayUserInput,
|
||||
type Message,
|
||||
type MessageWithMetadata,
|
||||
type ToolResultContent,
|
||||
} from "@cline/shared";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../runtime/interactive/mode";
|
||||
@@ -29,7 +30,7 @@ function isSyntheticUserText(text: string): boolean {
|
||||
export async function replaySessionHistory(
|
||||
conn: AgentSideConnection,
|
||||
sessionId: string,
|
||||
messages: Message[],
|
||||
messages: MessageWithMetadata[],
|
||||
): Promise<void> {
|
||||
for (const message of messages) {
|
||||
for (const update of translateHistoricalMessage(message)) {
|
||||
@@ -38,7 +39,17 @@ export async function replaySessionHistory(
|
||||
}
|
||||
}
|
||||
|
||||
export function translateHistoricalMessage(message: Message): SessionUpdate[] {
|
||||
export function translateHistoricalMessage(
|
||||
message: MessageWithMetadata,
|
||||
): SessionUpdate[] {
|
||||
return projectSessionMessagesForDisplay([message]).flatMap(({ message }) =>
|
||||
translateProjectedHistoricalMessage(message),
|
||||
);
|
||||
}
|
||||
|
||||
function translateProjectedHistoricalMessage(
|
||||
message: MessageWithMetadata,
|
||||
): SessionUpdate[] {
|
||||
const blocks: ContentBlock[] =
|
||||
typeof message.content === "string"
|
||||
? [{ type: "text", text: message.content }]
|
||||
@@ -133,8 +144,14 @@ function flattenToolResultContent(
|
||||
return part.text;
|
||||
case "file":
|
||||
return part.content;
|
||||
default:
|
||||
case "image":
|
||||
return "[image]";
|
||||
default:
|
||||
try {
|
||||
return JSON.stringify(part);
|
||||
} catch {
|
||||
return String(part);
|
||||
}
|
||||
}
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
@@ -17,6 +17,7 @@ const TOOL_KIND_MAP: Record<string, ToolKind> = {
|
||||
WebFetch: "fetch",
|
||||
fetch_web_content: "fetch",
|
||||
WebSearch: "search",
|
||||
web_search: "search",
|
||||
Agent: "think",
|
||||
spawn_agent: "think",
|
||||
NotebookEdit: "edit",
|
||||
|
||||
@@ -18,6 +18,7 @@ const {
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockReadHubDiscovery,
|
||||
mockReadSupersededHubDiscovery,
|
||||
mockProbeHubServer,
|
||||
mockClearHubDiscovery,
|
||||
mockStopLocalHubServerGracefully,
|
||||
@@ -48,6 +49,7 @@ const {
|
||||
),
|
||||
})),
|
||||
mockReadHubDiscovery: vi.fn(),
|
||||
mockReadSupersededHubDiscovery: vi.fn(() => undefined as unknown),
|
||||
mockProbeHubServer: vi.fn(),
|
||||
mockClearHubDiscovery: vi.fn(),
|
||||
mockStopLocalHubServerGracefully: vi.fn(async () => false),
|
||||
@@ -73,6 +75,7 @@ vi.mock("@cline/core", () => ({
|
||||
clearHubDiscovery: mockClearHubDiscovery,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
readSupersededHubDiscovery: mockReadSupersededHubDiscovery,
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
ensureFileExists: mockEnsureFileExists,
|
||||
listActiveConnectors: mockListActiveConnectors,
|
||||
@@ -186,6 +189,65 @@ describe("runDoctorCommand", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sees the hub through the set-aside record during the shielded update window", async () => {
|
||||
const cwd = "/workspace";
|
||||
// The npm postinstall shield renamed the discovery record aside; the
|
||||
// hub is alive and serving an old client's sessions.
|
||||
mockReadHubDiscovery.mockResolvedValue(undefined);
|
||||
mockReadSupersededHubDiscovery.mockReturnValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "shielded-token",
|
||||
pid: 50174,
|
||||
});
|
||||
mockProbeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
});
|
||||
mockSpawnSync.mockImplementation((command: string, args?: string[]) => {
|
||||
if (command === "lsof") {
|
||||
return { status: 0, stdout: "50174\n" };
|
||||
}
|
||||
if (
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[2] === "--cline-hub-daemon"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: "50174 /usr/local/bin/cline --cline-hub-daemon\n",
|
||||
};
|
||||
}
|
||||
return { status: 1, stdout: "" };
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(mockProbeHubServer).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
{
|
||||
authToken: "shielded-token",
|
||||
},
|
||||
);
|
||||
// Without the fallback the live daemon reads as stale and doctor's
|
||||
// advice (\"run doctor fix\") would kill the sessions the shield exists
|
||||
// to protect.
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
hubHealthy: true,
|
||||
staleHubPids: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports CLI and running hub Core versions", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
@@ -624,3 +686,42 @@ describe("doctor supervision reporting", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeProcessesStartedDuringFix", () => {
|
||||
const { describeProcessesStartedDuringFix } = __test__;
|
||||
const liveParents = new Map([
|
||||
[100, 10],
|
||||
[200, 20],
|
||||
]);
|
||||
const resolveLiveParent = (pid: number) => liveParents.get(pid);
|
||||
|
||||
it("says nothing when no process started during the fix", () => {
|
||||
expect(
|
||||
describeProcessesStartedDuringFix([], resolveLiveParent),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blames the parent only when every process has a live one", () => {
|
||||
expect(
|
||||
describeProcessesStartedDuringFix([100, 200], resolveLiveParent),
|
||||
).toBe(
|
||||
"\nThese processes were respawned by a live parent. Stop the parent process listed above, then re-run.",
|
||||
);
|
||||
});
|
||||
|
||||
// A process can start on its own mid-repair - a user opening a new session,
|
||||
// say - and telling them to go kill an unrelated parent would be wrong.
|
||||
it("states the facts when no process has a live parent", () => {
|
||||
expect(describeProcessesStartedDuringFix([777], resolveLiveParent)).toBe(
|
||||
"\nThese processes started after the fix began, so they were not targeted. Re-run to see whether they persist.",
|
||||
);
|
||||
});
|
||||
|
||||
it("separates respawns from independent starts in a mixed batch", () => {
|
||||
expect(
|
||||
describeProcessesStartedDuringFix([100, 777], resolveLiveParent),
|
||||
).toBe(
|
||||
"\nSome of these were respawned by a live parent (100); stop the parent process listed above, then re-run. The rest started after the fix began and were not targeted.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+149
-14
@@ -7,6 +7,7 @@ import {
|
||||
listActiveConnectors,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
readSupersededHubDiscovery,
|
||||
resolveClineDataDir,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
@@ -384,6 +385,12 @@ async function clearHubStartupArtifacts(
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
clearedDiscovery = 1;
|
||||
}
|
||||
if (options?.clearDiscovery) {
|
||||
// The set-aside copy the npm postinstall shield leaves behind. Once
|
||||
// doctor has deliberately stopped everything, keeping it risks a much
|
||||
// later launch SIGTERMing whatever process has recycled its pid.
|
||||
clearPathIfExists(`${owner.discoveryPath}.superseded`);
|
||||
}
|
||||
return {
|
||||
startupLocks: clearedStartupLocks,
|
||||
discovery: clearedDiscovery,
|
||||
@@ -411,7 +418,25 @@ function resolveCliHubOwnerContext() {
|
||||
|
||||
async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
// The npm postinstall shield sets the discovery record aside (see
|
||||
// readSupersededHubDiscovery) while an older hub finishes serving its
|
||||
// sessions. Without the fallback, doctor cannot see that hub, classifies
|
||||
// the live daemon as stale, and its "run doctor fix" advice kills the
|
||||
// sessions the shield exists to protect.
|
||||
const recorded = await readHubDiscovery(owner.discoveryPath);
|
||||
// The set-aside record carries only url/token/pid; widen so the two
|
||||
// sources read uniformly below.
|
||||
const discovery:
|
||||
| {
|
||||
url?: string;
|
||||
authToken?: string;
|
||||
pid?: number;
|
||||
port?: number;
|
||||
coreVersion?: string;
|
||||
}
|
||||
| undefined = recorded?.url
|
||||
? recorded
|
||||
: readSupersededHubDiscovery(owner.discoveryPath);
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url, { authToken: discovery.authToken })
|
||||
: undefined;
|
||||
@@ -449,6 +474,80 @@ function formatPidList(label: string, pids: number[]): string {
|
||||
return `${label} ${c.dim}${pids.join(", ")}${c.reset}`;
|
||||
}
|
||||
|
||||
function readParentPid(pid: number): number | undefined {
|
||||
try {
|
||||
const output = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const parsed = Number(output.stdout?.trim());
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function liveParentPid(pid: number): number | undefined {
|
||||
const parent = readParentPid(pid);
|
||||
return parent && isProcessRunning(parent) ? parent : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A daemon whose parent is still running was almost certainly just spawned by
|
||||
* that parent, and killing it only invites the parent to spawn another. Naming
|
||||
* the parent points at the process the user actually has to stop.
|
||||
*/
|
||||
function formatDaemonPidList(label: string, pids: number[]): string {
|
||||
if (pids.length === 0) {
|
||||
return `${label} ${c.dim}0${c.reset}`;
|
||||
}
|
||||
const described = pids.map((pid) => {
|
||||
const parent = liveParentPid(pid);
|
||||
return parent ? `${pid} (spawned by ${parent})` : String(pid);
|
||||
});
|
||||
return `${label} ${c.dim}${described.join(", ")}${c.reset}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advice for processes first seen during the fix. Only a process with a live
|
||||
* parent is known to have been respawned by it; anything else may have been
|
||||
* started independently (a user opening a new session mid-repair), so it gets
|
||||
* a statement of fact rather than an instruction to go kill something.
|
||||
*/
|
||||
export function describeProcessesStartedDuringFix(
|
||||
pids: number[],
|
||||
resolveLiveParent: (pid: number) => number | undefined,
|
||||
): string | undefined {
|
||||
if (pids.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const respawned = pids.filter((pid) => resolveLiveParent(pid) !== undefined);
|
||||
if (respawned.length === 0) {
|
||||
return "\nThese processes started after the fix began, so they were not targeted. Re-run to see whether they persist.";
|
||||
}
|
||||
if (respawned.length === pids.length) {
|
||||
return "\nThese processes were respawned by a live parent. Stop the parent process listed above, then re-run.";
|
||||
}
|
||||
return `\nSome of these were respawned by a live parent (${respawned.join(", ")}); stop the parent process listed above, then re-run. The rest started after the fix began and were not targeted.`;
|
||||
}
|
||||
|
||||
function formatStartupLockList(
|
||||
label: string,
|
||||
locks: StartupArtifact[],
|
||||
): string {
|
||||
const described = locks
|
||||
.map((lock) => {
|
||||
if (lock.pid === undefined) {
|
||||
return "unreadable";
|
||||
}
|
||||
return lock.stale ? `${lock.pid} (stale)` : `${lock.pid} (held, live)`;
|
||||
})
|
||||
.filter((entry) => entry.length > 0);
|
||||
if (described.length === 0) {
|
||||
return `${label} ${c.dim}0${c.reset}`;
|
||||
}
|
||||
return `${label} ${c.dim}${described.join(", ")}${c.reset}`;
|
||||
}
|
||||
|
||||
function formatRecentSpawnedProcess(record: SpawnedProcessRecord): string {
|
||||
const pieces = [
|
||||
record.timestamp ?? "unknown-time",
|
||||
@@ -531,6 +630,7 @@ function killPids(pids: number[]): number {
|
||||
export const __test__ = {
|
||||
decideForeignContainer,
|
||||
CONTAINER_CGROUP_PATTERN,
|
||||
describeProcessesStartedDuringFix,
|
||||
formatSupervisedConnector,
|
||||
};
|
||||
|
||||
@@ -556,13 +656,8 @@ export async function runDoctorCommand(
|
||||
);
|
||||
writeln(`hub uptime ${c.dim}${before.hubUptime ?? "n/a"}${c.reset}`);
|
||||
writeln(formatPidList("hub listeners", before.listeningPids));
|
||||
writeln(formatPidList("stale hub daemons", before.staleHubPids));
|
||||
writeln(
|
||||
formatPidList(
|
||||
"hub startup locks",
|
||||
before.hubStartupLocks.map((a) => a.pid ?? -1).filter((pid) => pid > 0),
|
||||
),
|
||||
);
|
||||
writeln(formatDaemonPidList("stale hub daemons", before.staleHubPids));
|
||||
writeln(formatStartupLockList("hub startup locks", before.hubStartupLocks));
|
||||
writeln(formatPidList("cli processes", before.staleCliPids));
|
||||
writeln(formatPidList("sidecar processes", before.staleSidecarPids));
|
||||
if (before.activeConnectors.length === 0) {
|
||||
@@ -673,16 +768,56 @@ export async function runDoctorCommand(
|
||||
`cleared hub discovery records ${c.dim}${clearedArtifacts.discovery}${c.reset}`,
|
||||
);
|
||||
writeln(`hub healthy after fix: ${after.hubHealthy ? "yes" : "no"}`);
|
||||
writeln(formatPidList("remaining hub listeners", after.listeningPids));
|
||||
writeln(formatPidList("remaining stale hub daemons", after.staleHubPids));
|
||||
// "Remaining" means a process this run tried to kill and failed to. A
|
||||
// re-scan alone cannot tell that apart from a process that appeared while
|
||||
// the fix was running, and reporting the two together reads as a failure
|
||||
// to kill something that was never targeted.
|
||||
const survived = (targets: number[], remaining: number[]) =>
|
||||
remaining.filter((pid) => targets.includes(pid));
|
||||
const appeared = (targets: number[], remaining: number[]) =>
|
||||
remaining.filter((pid) => !targets.includes(pid));
|
||||
writeln(
|
||||
formatPidList(
|
||||
"remaining hub startup locks",
|
||||
after.hubStartupLocks.map((a) => a.pid ?? -1).filter((pid) => pid > 0),
|
||||
"remaining hub listeners",
|
||||
survived(refreshedAfterGracefulStop.listeningPids, after.listeningPids),
|
||||
),
|
||||
);
|
||||
writeln(formatPidList("remaining cli processes", after.staleCliPids));
|
||||
writeln(formatPidList("remaining sidecar processes", after.staleSidecarPids));
|
||||
writeln(
|
||||
formatDaemonPidList(
|
||||
"remaining stale hub daemons",
|
||||
survived(staleHubTargets, after.staleHubPids),
|
||||
),
|
||||
);
|
||||
writeln(
|
||||
formatStartupLockList("remaining hub startup locks", after.hubStartupLocks),
|
||||
);
|
||||
writeln(
|
||||
formatPidList(
|
||||
"remaining cli processes",
|
||||
survived(staleCliTargets, after.staleCliPids),
|
||||
),
|
||||
);
|
||||
writeln(
|
||||
formatPidList(
|
||||
"remaining sidecar processes",
|
||||
survived(staleSidecarTargets, after.staleSidecarPids),
|
||||
),
|
||||
);
|
||||
const spawnedDuringFix = [
|
||||
...appeared(staleHubTargets, after.staleHubPids),
|
||||
...appeared(staleCliTargets, after.staleCliPids),
|
||||
...appeared(staleSidecarTargets, after.staleSidecarPids),
|
||||
];
|
||||
if (spawnedDuringFix.length > 0) {
|
||||
writeln(formatDaemonPidList("started during fix", spawnedDuringFix));
|
||||
const advice = describeProcessesStartedDuringFix(
|
||||
spawnedDuringFix,
|
||||
liveParentPid,
|
||||
);
|
||||
if (advice) {
|
||||
io.writeln(advice);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockEnsureCliHubServer, mockSpawn } = vi.hoisted(() => ({
|
||||
mockEnsureCliHubServer: vi.fn(),
|
||||
const { mockSpawn } = vi.hoisted(() => ({
|
||||
mockSpawn: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -18,14 +16,10 @@ vi.mock("node:child_process", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/hub-runtime", () => ({
|
||||
ensureCliHubServer: mockEnsureCliHubServer,
|
||||
}));
|
||||
|
||||
import {
|
||||
applyDeferredUpdate,
|
||||
autoUpdateOnStartup,
|
||||
checkForUpdates,
|
||||
ensureCliHubServerAfterUpdate,
|
||||
getInstallationInfo,
|
||||
PackageManager,
|
||||
resolveCliHubOwnerContext,
|
||||
@@ -42,14 +36,6 @@ const originalIsDev = process.env.IS_DEV;
|
||||
const originalNoAutoUpdate = process.env.CLINE_NO_AUTO_UPDATE;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createChildProcessThatCloses(exitCode: number): ChildProcess {
|
||||
const child = new EventEmitter();
|
||||
queueMicrotask(() => {
|
||||
child.emit("close", exitCode);
|
||||
});
|
||||
return child as ChildProcess;
|
||||
}
|
||||
|
||||
function createFile(path: string): string {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, "");
|
||||
@@ -265,68 +251,100 @@ describe("hub restart owner selection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("post-update hub launch", () => {
|
||||
describe("deferred auto update", () => {
|
||||
afterEach(() => {
|
||||
mockEnsureCliHubServer.mockReset();
|
||||
mockSpawn.mockReset();
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
if (originalHubDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
|
||||
}
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the freshly installed wrapper instead of the current executable", async () => {
|
||||
mockSpawn.mockReturnValue(createChildProcessThatCloses(0));
|
||||
const env = {
|
||||
CLINE_WRAPPER_PATH: "/opt/cline/lib/node_modules/cline/bin/cline",
|
||||
CLINE_NO_AUTO_UPDATE: "0",
|
||||
};
|
||||
|
||||
await ensureCliHubServerAfterUpdate("/workspace/project", env, "linux");
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
"/opt/cline/lib/node_modules/cline/bin/cline",
|
||||
["hub", "ensure"],
|
||||
{
|
||||
cwd: "/workspace/project",
|
||||
env: {
|
||||
...env,
|
||||
CLINE_NO_AUTO_UPDATE: "1",
|
||||
},
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
expect(mockEnsureCliHubServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the in-process ensure path when no executable cache can be deleted", async () => {
|
||||
mockEnsureCliHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
|
||||
await ensureCliHubServerAfterUpdate(
|
||||
"C:\\workspace\\project",
|
||||
{ CLINE_WRAPPER_PATH: "C:\\npm\\node_modules\\cline\\bin\\cline" },
|
||||
"win32",
|
||||
);
|
||||
|
||||
expect(mockEnsureCliHubServer).toHaveBeenCalledWith(
|
||||
"C:\\workspace\\project",
|
||||
);
|
||||
it("does nothing when no update was recorded", async () => {
|
||||
expect(await applyDeferredUpdate(undefined)).toBe("none");
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a failure from the freshly installed CLI", async () => {
|
||||
mockSpawn.mockReturnValue(createChildProcessThatCloses(1));
|
||||
it("starts the detached install when no hub is discoverable", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-update-test-"));
|
||||
tempDirs.push(root);
|
||||
process.env.CLINE_BUILD_ENV = "production";
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = join(root, "production.json");
|
||||
const unref = vi.fn();
|
||||
mockSpawn.mockReturnValue({ unref } as unknown as ChildProcess);
|
||||
|
||||
await expect(
|
||||
ensureCliHubServerAfterUpdate(
|
||||
"/workspace/project",
|
||||
{ CLINE_WRAPPER_PATH: "/opt/cline/bin/cline" },
|
||||
"linux",
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"freshly installed Cline failed to start the hub (exit code 1)",
|
||||
const outcome = await applyDeferredUpdate({
|
||||
command: "npm update -g cline --tag latest --min-release-age=0",
|
||||
});
|
||||
|
||||
expect(outcome).toBe("started");
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
"npm update -g cline --tag latest --min-release-age=0",
|
||||
expect.objectContaining({
|
||||
detached: true,
|
||||
shell: true,
|
||||
stdio: "ignore",
|
||||
}),
|
||||
);
|
||||
expect(unref).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defers while another cli client is attached to the hub", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-update-test-"));
|
||||
tempDirs.push(root);
|
||||
const discoveryPath = join(root, "production.json");
|
||||
process.env.CLINE_BUILD_ENV = "production";
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = discoveryPath;
|
||||
const {
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
NodeHubClient,
|
||||
startHubWebSocketServer,
|
||||
} = await import("@cline/core");
|
||||
const server = await startHubWebSocketServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
owner: { ownerId: "update-test", discoveryPath },
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
});
|
||||
const cliClient = new NodeHubClient({
|
||||
url: server.url,
|
||||
authToken: server.authToken,
|
||||
clientType: "cli",
|
||||
displayName: "fake attached cli",
|
||||
});
|
||||
try {
|
||||
await cliClient.command("client.list", {});
|
||||
|
||||
expect(await applyDeferredUpdate({ command: "echo update" })).toBe(
|
||||
"deferred",
|
||||
);
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
|
||||
await cliClient.dispose();
|
||||
const unref = vi.fn();
|
||||
mockSpawn.mockReturnValue({ unref } as unknown as ChildProcess);
|
||||
// The hub unregisters the client when its socket closes; poll
|
||||
// briefly rather than assuming the close is processed instantly.
|
||||
let outcome = "deferred";
|
||||
const deadline = Date.now() + 3_000;
|
||||
while (outcome === "deferred" && Date.now() < deadline) {
|
||||
outcome = await applyDeferredUpdate({ command: "echo update" });
|
||||
}
|
||||
expect(outcome).toBe("started");
|
||||
} finally {
|
||||
await cliClient.dispose().catch(() => undefined);
|
||||
await server.close();
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
describe("withMinimumReleaseAgeBypass", () => {
|
||||
|
||||
+137
-134
@@ -1,17 +1,14 @@
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { realpathSync } from "node:fs";
|
||||
import {
|
||||
clearHubDiscovery,
|
||||
isAutoUpdateEnabledGlobally,
|
||||
probeHubServer,
|
||||
NodeHubClient,
|
||||
readHubDiscovery,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { resolveClineBuildEnv } from "@cline/shared";
|
||||
import { version } from "../../package.json";
|
||||
import { ensureCliHubServer } from "../utils/hub-runtime";
|
||||
import { c, writeErr, writeln } from "../utils/output";
|
||||
import {
|
||||
getInstalledKanbanVersion,
|
||||
@@ -237,50 +234,6 @@ async function runKanbanUpdate(
|
||||
return waitForProcessExit(updateProcess);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the hub through the freshly installed CLI after a self-update.
|
||||
*
|
||||
* On Unix, the npm wrapper normally starts the CLI from bin/.cline. npm 12 may
|
||||
* remove that cached executable while replacing the package and then block the
|
||||
* postinstall script that recreates it. The current process keeps running from
|
||||
* the unlinked executable, but process.execPath is no longer spawnable. Going
|
||||
* back through the wrapper makes it resolve the newly installed platform
|
||||
* binary instead.
|
||||
*
|
||||
* Windows does not create the bin/.cline cache, and development builds do not
|
||||
* have CLINE_WRAPPER_PATH, so those cases keep using the normal in-process
|
||||
* ensure path.
|
||||
*/
|
||||
export async function ensureCliHubServerAfterUpdate(
|
||||
workspaceRoot: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<void> {
|
||||
const wrapperPath = env.CLINE_WRAPPER_PATH?.trim();
|
||||
if (!wrapperPath || platform === "win32") {
|
||||
await ensureCliHubServer(workspaceRoot);
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(wrapperPath, ["hub", "ensure"], {
|
||||
cwd: workspaceRoot,
|
||||
env: {
|
||||
...env,
|
||||
// The fresh CLI only exists to start the hub. Do not let it launch
|
||||
// another background update check while this update is finishing.
|
||||
CLINE_NO_AUTO_UPDATE: "1",
|
||||
},
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
});
|
||||
const exitCode = await waitForProcessExit(child);
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(
|
||||
`freshly installed Cline failed to start the hub (exit code ${exitCode})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function formatUpdateSummaryTargets(targets: string[]): string {
|
||||
if (targets.length === 0) {
|
||||
return "";
|
||||
@@ -318,86 +271,40 @@ export function getPreferredKanbanInstaller(
|
||||
);
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
export function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
async function waitForHubToStop(
|
||||
url: string,
|
||||
authToken: string | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const check = await probeHubServer(url, { authToken }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
if (!check?.url) return true;
|
||||
await sleep(100);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let pendingAutoUpdate: ManualUpdateCommand | undefined;
|
||||
let pendingAutoUpdateCheck: Promise<void> | undefined;
|
||||
|
||||
/**
|
||||
* Restart the hub server if one is currently running.
|
||||
* Gracefully asks the running hub process to stop, falls back to process signals,
|
||||
* clears stale discovery, then re-ensures a fresh instance is spawned.
|
||||
*/
|
||||
async function restartHubServerIfRunning(): Promise<void> {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath).catch(
|
||||
() => undefined,
|
||||
);
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url, {
|
||||
authToken: discovery.authToken,
|
||||
}).catch(() => undefined)
|
||||
: undefined;
|
||||
if (!discovery || !health?.url) return;
|
||||
// How long the exit sequence will wait for a still-in-flight startup version
|
||||
// check before giving up on it. Long enough for a typical registry response,
|
||||
// short enough that one-shot commands do not feel it.
|
||||
const UPDATE_CHECK_EXIT_GRACE_MS = 250;
|
||||
|
||||
const pid = discovery?.pid;
|
||||
writeln(`${c.dim}[hub] restarting server…${c.reset}`);
|
||||
|
||||
let stopped = await stopLocalHubServerGracefully(owner).catch(() => false);
|
||||
if (!stopped && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
stopped = await waitForHubToStop(health.url, discovery.authToken, 3_000);
|
||||
if (!stopped && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
stopped = await waitForHubToStop(health.url, discovery.authToken, 2_000);
|
||||
}
|
||||
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
|
||||
// Re-ensure a fresh hub instance is spawned.
|
||||
try {
|
||||
await ensureCliHubServerAfterUpdate(process.cwd());
|
||||
writeln(`${c.green}✓${c.reset} ${c.dim}[hub] server restarted${c.reset}`);
|
||||
} catch (err) {
|
||||
writeErr(
|
||||
`[hub] failed to restart server: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Hard cap on the exit-time hub query. The hub client's default connect and
|
||||
// command timeouts add up to tens of seconds against a wedged hub, and this
|
||||
// runs while the user is waiting for their shell prompt back.
|
||||
const CLIENT_COUNT_EXIT_TIMEOUT_MS = 3_000;
|
||||
|
||||
/**
|
||||
* Non-blocking auto-update check for CLI startup.
|
||||
* Spawns a detached install process if a newer version is available.
|
||||
*
|
||||
* Deliberately does NOT install right away: replacing the npm package while
|
||||
* cline processes are running swaps the binary under them — their respawn
|
||||
* paths break on the new build fingerprint — and historically also restarted
|
||||
* the hub daemon out from under live sessions. The check only records that an
|
||||
* update is available; the CLI entrypoint calls applyDeferredUpdate() from
|
||||
* its exit sequence (an explicit process.exit() follows, so a beforeExit hook
|
||||
* would never fire), and the install runs only when no other CLI is attached
|
||||
* to the hub — at that point nothing is running that the swap could hurt.
|
||||
* The next launch picks up the new binary and a fresh hub.
|
||||
*
|
||||
* Skipped for npx, dev, unknown installs. Disable with CLINE_NO_AUTO_UPDATE=1.
|
||||
*/
|
||||
export function autoUpdateOnStartup(): void {
|
||||
@@ -409,35 +316,129 @@ export function autoUpdateOnStartup(): void {
|
||||
getInstallationInfo(version);
|
||||
if (!updateCommand) return;
|
||||
|
||||
void (async () => {
|
||||
pendingAutoUpdateCheck = (async () => {
|
||||
try {
|
||||
const latest = await getLatestVersion(packageName, version);
|
||||
if (!latest || compareVersions(version, latest) >= 0) return;
|
||||
const autoUpdateCommand = withMinimumReleaseAgeBypass(
|
||||
pendingAutoUpdate = withMinimumReleaseAgeBypass(
|
||||
updateCommand,
|
||||
packageManager,
|
||||
);
|
||||
const child = spawn(autoUpdateCommand.command, {
|
||||
shell: true,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: autoUpdateCommand.env
|
||||
? { ...process.env, ...autoUpdateCommand.env }
|
||||
: process.env,
|
||||
// Prevent a console window from flashing on Windows; detached
|
||||
// processes otherwise allocate a new visible console.
|
||||
windowsHide: true,
|
||||
});
|
||||
const exitCode = await waitForProcessExit(child);
|
||||
if (exitCode === 0) {
|
||||
await restartHubServerIfRunning();
|
||||
}
|
||||
} catch {
|
||||
// Best-effort, silently ignore
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a hub is reachable and another cli* client is attached to it.
|
||||
* Only cli* clients run the npm-installed binary — desktop sidecars and
|
||||
* connectors ship their own — so only they make the swap unsafe. This runs
|
||||
* after the entrypoint's disposeAll(), so this process's own registrations
|
||||
* are closed and any cli client still listed belongs to another process. Errors count as attached:
|
||||
* never install unless the hub positively confirms nothing would be hurt.
|
||||
*/
|
||||
async function otherCliClientsAttached(): Promise<boolean> {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath).catch(
|
||||
() => undefined,
|
||||
);
|
||||
if (!discovery?.url) {
|
||||
return false;
|
||||
}
|
||||
const client = new NodeHubClient({
|
||||
url: discovery.url,
|
||||
authToken: discovery.authToken,
|
||||
clientType: "cli-update-check",
|
||||
displayName: "cline update check",
|
||||
});
|
||||
try {
|
||||
const reply = await client.command("client.list", {}, undefined, {
|
||||
timeoutMs: CLIENT_COUNT_EXIT_TIMEOUT_MS,
|
||||
});
|
||||
const clients =
|
||||
(reply.payload as { clients?: Array<{ clientType?: unknown }> })
|
||||
.clients ?? [];
|
||||
if (
|
||||
clients.some(
|
||||
(entry) =>
|
||||
typeof entry?.clientType === "string" &&
|
||||
entry.clientType.startsWith("cli") &&
|
||||
entry.clientType !== "cli-update-check",
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// A TUI's registration can be lost in transport churn while its session
|
||||
// connection survives (observed in review), so an empty client list is
|
||||
// not proof of safety. Cross-check for sessions somebody is attached to.
|
||||
// Participants, not session status: finished sessions can linger idle
|
||||
// forever and must not pin updates, and participant-less scheduled runs
|
||||
// live in the hub process, which a binary swap does not touch.
|
||||
const sessions = await client.command(
|
||||
"session.list",
|
||||
{ limit: 500 },
|
||||
undefined,
|
||||
{ timeoutMs: CLIENT_COUNT_EXIT_TIMEOUT_MS },
|
||||
);
|
||||
const sessionRecords =
|
||||
(sessions.payload as { sessions?: Array<{ participants?: unknown }> })
|
||||
.sessions ?? [];
|
||||
return sessionRecords.some(
|
||||
(session) =>
|
||||
Array.isArray(session?.participants) && session.participants.length > 0,
|
||||
);
|
||||
} finally {
|
||||
await client.dispose().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns the recorded update install, detached, if no other CLI would be
|
||||
* affected by the package swap. Fire-and-forget: the install outlives this
|
||||
* process and its postinstall never blocks an exit.
|
||||
*/
|
||||
export async function applyDeferredUpdate(
|
||||
pending?: ManualUpdateCommand,
|
||||
): Promise<"none" | "deferred" | "started"> {
|
||||
if (!pending) {
|
||||
// Short-lived commands can reach exit before the startup version check
|
||||
// resolves; give it a brief grace so one-shot-only usage still updates.
|
||||
if (pendingAutoUpdateCheck) {
|
||||
await Promise.race([
|
||||
pendingAutoUpdateCheck,
|
||||
sleep(UPDATE_CHECK_EXIT_GRACE_MS),
|
||||
]);
|
||||
}
|
||||
pending = pendingAutoUpdate;
|
||||
}
|
||||
if (!pending) {
|
||||
return "none";
|
||||
}
|
||||
// The whole query is bounded: the user is waiting on their prompt, and a
|
||||
// wedged hub must not turn a finished command into a hung one. A timeout
|
||||
// counts as "attached" — never install unless the hub positively confirms.
|
||||
const attached = await Promise.race([
|
||||
otherCliClientsAttached(),
|
||||
sleep(CLIENT_COUNT_EXIT_TIMEOUT_MS).then(() => true),
|
||||
]).catch(() => true);
|
||||
if (attached) {
|
||||
return "deferred";
|
||||
}
|
||||
pendingAutoUpdate = undefined;
|
||||
const child = spawn(pending.command, {
|
||||
shell: true,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: pending.env ? { ...process.env, ...pending.env } : process.env,
|
||||
// Prevent a console window from flashing on Windows; detached
|
||||
// processes otherwise allocate a new visible console.
|
||||
windowsHide: true,
|
||||
});
|
||||
child.unref();
|
||||
return "started";
|
||||
}
|
||||
|
||||
export interface CheckForUpdatesOptions {
|
||||
verbose?: boolean;
|
||||
includeKanban?: boolean;
|
||||
@@ -554,7 +555,9 @@ export async function checkForUpdates(
|
||||
const exitCode = await runCliUpdate(manualUpdateCommand);
|
||||
if (exitCode === 0) {
|
||||
installedUpdates.push(`${packageName}@${latestVersion}`);
|
||||
await restartHubServerIfRunning();
|
||||
writeln(
|
||||
`${c.dim}The update takes effect the next time cline starts.${c.reset}`,
|
||||
);
|
||||
} else {
|
||||
writeErr(
|
||||
`Cline update failed (exit code ${exitCode}). Try running: ${manualUpdateCommand.command}`,
|
||||
|
||||
@@ -165,7 +165,6 @@ describe("buildConnectorStartRequest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("isReusableConnectorSession", () => {
|
||||
it("rejects missing and terminal sessions", () => {
|
||||
expect(isReusableConnectorSession(undefined)).toBe(false);
|
||||
|
||||
@@ -235,9 +235,7 @@ export async function getOrCreateSessionId<
|
||||
transport: input.transport,
|
||||
threadId: input.thread.id,
|
||||
sessionId: existing,
|
||||
...(existingSession?.status
|
||||
? { status: existingSession.status }
|
||||
: {}),
|
||||
...(existingSession?.status ? { status: existingSession.status } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,6 +97,15 @@ if (!isMainThread) {
|
||||
} finally {
|
||||
await disposeAll();
|
||||
}
|
||||
// The explicit process.exit below means beforeExit never fires, so a
|
||||
// startup-recorded auto-update must be applied here, after all runtime
|
||||
// teardown. It spawns detached and only when no other CLI is attached.
|
||||
try {
|
||||
const { applyDeferredUpdate } = await import("./commands/update");
|
||||
await applyDeferredUpdate();
|
||||
} catch {
|
||||
// Best-effort; never block exit on the updater.
|
||||
}
|
||||
process.exit(exitCode || (process.exitCode as number) || 0);
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -1012,7 +1012,9 @@ Review with the bundled skill.`,
|
||||
const linear = data.mcp.find((item) => item.name === "linear");
|
||||
const docs = data.mcp.find((item) => item.name === "docs");
|
||||
|
||||
expect(linear?.description).toBe("streamableHttp, oauth error, timeout 60s");
|
||||
expect(linear?.description).toBe(
|
||||
"streamableHttp, oauth error, timeout 60s",
|
||||
);
|
||||
expect(linear?.loadError).toBe("OAuth authorization failed");
|
||||
expect(docs?.description).toBe("sse, oauth authorized, timeout 60s");
|
||||
expect(docs?.loadError).toBeUndefined();
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type ToolApprovalResult,
|
||||
type UserInstructionConfigService,
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/shared";
|
||||
import type { MessageWithMetadata } from "@cline/shared";
|
||||
import { createCliCore } from "../../session/session";
|
||||
import { submitAndExitInTerminal } from "../../utils/approval";
|
||||
import type {
|
||||
@@ -56,11 +56,11 @@ type AskQuestionRef = {
|
||||
current: ((question: string, options: string[]) => Promise<string>) | null;
|
||||
};
|
||||
type CurrentMessagesRead =
|
||||
| { messages: Message[]; status: "read" }
|
||||
| { messages: Message[]; status: "recovered" }
|
||||
| { messages: Message[]; status: "stale" };
|
||||
| { messages: MessageWithMetadata[]; status: "read" }
|
||||
| { messages: MessageWithMetadata[]; status: "recovered" }
|
||||
| { messages: MessageWithMetadata[]; status: "stale" };
|
||||
type MissingSessionRecovery = {
|
||||
messages: Message[];
|
||||
messages: MessageWithMetadata[];
|
||||
};
|
||||
type ToolPolicyResolver = (
|
||||
toolName: string,
|
||||
@@ -210,7 +210,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
};
|
||||
|
||||
const startFreshSession = async (
|
||||
initial: Message[] = [],
|
||||
initial: MessageWithMetadata[] = [],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
// Restarting an old session associate with this ID,
|
||||
@@ -243,7 +243,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
|
||||
const startResumedSession = async (
|
||||
resumeId: string,
|
||||
initial: Message[] | undefined,
|
||||
initial: MessageWithMetadata[] | undefined,
|
||||
): Promise<void> => {
|
||||
const generation = sessionStartGeneration;
|
||||
const manager = await ensureSessionManager();
|
||||
@@ -421,7 +421,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
};
|
||||
|
||||
const restartWithMessages = async (
|
||||
messages: Message[],
|
||||
messages: MessageWithMetadata[],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
options?: { preserveSessionId?: boolean },
|
||||
@@ -659,7 +659,9 @@ export function createInteractiveSessionRuntime(input: {
|
||||
};
|
||||
};
|
||||
|
||||
const resumeSession = async (sessionId: string): Promise<Message[]> => {
|
||||
const resumeSession = async (
|
||||
sessionId: string,
|
||||
): Promise<MessageWithMetadata[]> => {
|
||||
const manager = await ensureSessionManager();
|
||||
const sessionRecord = await manager.get(sessionId);
|
||||
if (!sessionRecord) {
|
||||
@@ -754,7 +756,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
|
||||
const getCheckpointData = async (): Promise<
|
||||
| {
|
||||
messages: Message[];
|
||||
messages: MessageWithMetadata[];
|
||||
checkpointHistory: CheckpointEntry[];
|
||||
}
|
||||
| undefined
|
||||
@@ -777,7 +779,9 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const restoreCheckpoint = async (
|
||||
runCount: number,
|
||||
restoreWorkspace: boolean,
|
||||
): Promise<{ newSessionId: string; messages: Message[] } | undefined> => {
|
||||
): Promise<
|
||||
{ newSessionId: string; messages: MessageWithMetadata[] } | undefined
|
||||
> => {
|
||||
const manager = sessionManager;
|
||||
if (!manager || !activeSessionId) {
|
||||
return undefined;
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type BuiltinToolAvailabilityContext,
|
||||
getCoreBuiltinToolCatalog,
|
||||
resolveDisabledToolNames,
|
||||
resolveModelToolSettings,
|
||||
type ToolCatalogEntry,
|
||||
} from "@cline/core";
|
||||
|
||||
@@ -10,8 +11,14 @@ export type { ToolCatalogEntry } from "@cline/core";
|
||||
export function getToolCatalog(
|
||||
availabilityContext?: BuiltinToolAvailabilityContext,
|
||||
): ToolCatalogEntry[] {
|
||||
const modelToolSettings = resolveModelToolSettings();
|
||||
return getCoreBuiltinToolCatalog({
|
||||
disabledToolIds: resolveDisabledToolNames(),
|
||||
enabledModelToolIds: new Set(
|
||||
Object.entries(modelToolSettings)
|
||||
.filter(([, setting]) => setting?.enabled === true)
|
||||
.map(([name]) => name),
|
||||
),
|
||||
...availabilityContext,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateConversationHTML } from "./export";
|
||||
|
||||
describe("generateConversationHTML", () => {
|
||||
it("renders provider model activity with the ordinary tool HTML", () => {
|
||||
const html = generateConversationHTML(
|
||||
{
|
||||
version: 1,
|
||||
updated_at: "2026-08-13T00:00:00.000Z",
|
||||
messages: [
|
||||
{
|
||||
id: "assistant-search",
|
||||
role: "assistant",
|
||||
content: "Bun 1.3.14 is current.",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun release" },
|
||||
output: "Bun 1.3.14",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"session",
|
||||
);
|
||||
|
||||
expect(html).toContain("web_search");
|
||||
expect(html).toContain("latest Bun release");
|
||||
expect(html).toContain('<span class="success">Success</span>');
|
||||
expect(html).toContain("Bun 1.3.14 is current.");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { projectSessionMessagesForDisplay } from "@cline/core";
|
||||
import {
|
||||
type ContentBlock,
|
||||
formatDisplayUserInput,
|
||||
@@ -30,9 +31,12 @@ export function generateConversationHTML(
|
||||
data: ConversationHistory,
|
||||
fileName: string,
|
||||
): string {
|
||||
const displayMessages = projectSessionMessagesForDisplay(data.messages).map(
|
||||
({ message }) => message,
|
||||
);
|
||||
// Build tool results map
|
||||
const toolResultsMap = new Map<string, ToolResultContent>();
|
||||
data.messages.forEach((msg) => {
|
||||
displayMessages.forEach((msg) => {
|
||||
if (!isStringContent(msg.content)) {
|
||||
msg.content.forEach((block) => {
|
||||
if (block.type === "tool_result") {
|
||||
@@ -43,7 +47,7 @@ export function generateConversationHTML(
|
||||
});
|
||||
|
||||
// Filter messages (same logic as viewer)
|
||||
const filteredMessages = data.messages.filter((msg) => {
|
||||
const filteredMessages = displayMessages.filter((msg) => {
|
||||
if (msg.role === "assistant") return true;
|
||||
if (isStringContent(msg.content)) {
|
||||
return msg.content.trim().length > 0;
|
||||
|
||||
@@ -2,10 +2,9 @@ import {
|
||||
type ClineAccountBalance,
|
||||
type ClineAccountOrganization,
|
||||
type ClineAccountOrganizationBalance,
|
||||
type ClineSubscriptionPlan,
|
||||
type UserCurrentPlan,
|
||||
ClineAccountService,
|
||||
type ClineAccountUser,
|
||||
type ClineSubscriptionPlan,
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey,
|
||||
getProviderOAuthCredentialsFromSettings,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
type ProviderSettings,
|
||||
ProviderSettingsManager,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
type UserCurrentPlan,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { formatCreditBalance, normalizeCreditBalance } from "../utils/output";
|
||||
|
||||
@@ -640,10 +640,23 @@ export function ChatEntryView(props: {
|
||||
)}
|
||||
</box>
|
||||
<box flexGrow={1}>
|
||||
{/*
|
||||
* internalBlockMode="top-level" keeps each markdown block as its
|
||||
* own renderable. The default coalesced mode merges the whole
|
||||
* message into one block that is torn down and re-highlighted on
|
||||
* every streamed chunk, which flashes already-rendered headings
|
||||
* and links back to raw uncolored markdown while tree-sitter
|
||||
* re-highlights asynchronously. Top-level blocks are reused by
|
||||
* token identity, so settled content never re-renders.
|
||||
* tableOptions preserves the bordered table style that coalesced
|
||||
* mode used by default (top-level defaults to borderless columns).
|
||||
*/}
|
||||
<markdown
|
||||
content={content}
|
||||
syntaxStyle={getSyntaxStyle(theme, mode)}
|
||||
streaming={entry.streaming}
|
||||
internalBlockMode="top-level"
|
||||
tableOptions={{ style: "grid" }}
|
||||
fg={defaultFg}
|
||||
/>
|
||||
</box>
|
||||
|
||||
@@ -584,6 +584,18 @@ function App(props: TuiProps) {
|
||||
if (!hubBuildMismatch) return;
|
||||
setHubBuildMismatch(null);
|
||||
const hubCoreVersion = hubBuildMismatch.hubCoreVersion;
|
||||
if (hubBuildMismatch.reason === "outdated_hub") {
|
||||
// This CLI is already the newer build. The Hub is behind only because
|
||||
// retiring it would kill the sessions it is serving, and it is
|
||||
// replaced on its own at the next launch. Nothing is wrong, nothing is
|
||||
// asked, and nothing the user can act on differs - so say nothing, the
|
||||
// same conclusion the desktop surface reached.
|
||||
//
|
||||
// The classification still earns its keep here: it is what stops the
|
||||
// update-and-restart prompt below from firing at someone who has
|
||||
// nothing to update.
|
||||
return;
|
||||
}
|
||||
void dialog
|
||||
.choice<boolean>({
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
TeamEvent,
|
||||
} from "@cline/core";
|
||||
import type {
|
||||
Message,
|
||||
MessageWithMetadata,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResult,
|
||||
} from "@cline/shared";
|
||||
@@ -92,7 +92,7 @@ export interface InteractiveTurnResult {
|
||||
}
|
||||
|
||||
export interface ResumedSessionResult {
|
||||
messages: Message[];
|
||||
messages: MessageWithMetadata[];
|
||||
totalCost?: number;
|
||||
currentContextSize?: number;
|
||||
}
|
||||
@@ -145,7 +145,7 @@ export interface TuiProps {
|
||||
initialPrompt?: string;
|
||||
initialNotice?: CliMigrationNotice;
|
||||
onInitialNoticeShown?: (notice: CliMigrationNotice) => void | Promise<void>;
|
||||
initialMessages?: Message[];
|
||||
initialMessages?: MessageWithMetadata[];
|
||||
loadDeferredInitialMessages?: () => Promise<ResumedSessionResult>;
|
||||
initialRepoStatus?: RepoStatus;
|
||||
workflowSlashCommands?: InteractiveSlashCommand[];
|
||||
@@ -218,12 +218,18 @@ export interface TuiProps {
|
||||
| undefined
|
||||
>;
|
||||
getCheckpointData: () => Promise<
|
||||
{ messages: Message[]; checkpointHistory: CheckpointEntry[] } | undefined
|
||||
| {
|
||||
messages: MessageWithMetadata[];
|
||||
checkpointHistory: CheckpointEntry[];
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
onRestoreCheckpoint: (
|
||||
runCount: number,
|
||||
restoreWorkspace: boolean,
|
||||
) => Promise<{ newSessionId: string; messages: Message[] } | undefined>;
|
||||
) => Promise<
|
||||
{ newSessionId: string; messages: MessageWithMetadata[] } | undefined
|
||||
>;
|
||||
setToolApprover: (
|
||||
approver:
|
||||
| ((request: ToolApprovalRequest) => Promise<ToolApprovalResult>)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Message } from "@cline/shared";
|
||||
import type { Message, MessageWithMetadata } from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../../runtime/interactive/mode";
|
||||
import { hydrateSessionMessages } from "./hydrate-messages";
|
||||
@@ -106,6 +106,7 @@ describe("hydrateSessionMessages", () => {
|
||||
},
|
||||
{
|
||||
kind: "tool_call",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "switch_to_act_mode",
|
||||
inputSummary: expect.any(String),
|
||||
rawInput: {},
|
||||
@@ -155,6 +156,7 @@ describe("hydrateSessionMessages", () => {
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{
|
||||
kind: "tool_call",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "run_commands",
|
||||
inputSummary: "",
|
||||
rawInput: { command: null },
|
||||
@@ -180,4 +182,87 @@ describe("hydrateSessionMessages", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates provider model tools through the ordinary tool card path", () => {
|
||||
const messages: MessageWithMetadata[] = [
|
||||
{
|
||||
id: "assistant-search",
|
||||
role: "assistant",
|
||||
content: "Bun 1.3.14 is the latest stable release.",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun stable release" },
|
||||
output: { sources: ["https://bun.sh/blog/bun-v1.3.14"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{
|
||||
kind: "tool_call",
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
inputSummary: expect.any(String),
|
||||
rawInput: { query: "latest Bun stable release" },
|
||||
streaming: false,
|
||||
mode: undefined,
|
||||
result: {
|
||||
outputSummary: '{"sources":["https://bun.sh/blog/bun-v1.3.14"]}',
|
||||
rawOutput: '{"sources":["https://bun.sh/blog/bun-v1.3.14"]}',
|
||||
error: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "assistant_text",
|
||||
text: "Bun 1.3.14 is the latest stable release.",
|
||||
streaming: false,
|
||||
mode: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates structured native search output and mirrors live error payloads", () => {
|
||||
const nativeResult = {
|
||||
type: "web_search_result",
|
||||
url: "https://bun.sh/blog/bun-v1.3.14",
|
||||
title: "Bun v1.3.14",
|
||||
pageAge: "2026-08-12",
|
||||
encryptedContent: "encrypted",
|
||||
};
|
||||
const messages: MessageWithMetadata[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Search failed.",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-native",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun" },
|
||||
output: [nativeResult],
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const [toolEntry] = hydrateSessionMessages(messages);
|
||||
expect(toolEntry).toMatchObject({
|
||||
kind: "tool_call",
|
||||
toolCallId: "search-native",
|
||||
result: {
|
||||
outputSummary: "",
|
||||
rawOutput: undefined,
|
||||
error: JSON.stringify([nativeResult]),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import type { AgentMode } from "@cline/core";
|
||||
import { type AgentMode, projectSessionMessagesForDisplay } from "@cline/core";
|
||||
import {
|
||||
formatDisplayUserInput,
|
||||
type Message,
|
||||
type MessageWithMetadata,
|
||||
parseUserInputMode,
|
||||
} from "@cline/shared";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../../runtime/interactive/mode";
|
||||
import { formatToolInput } from "../../utils/helpers";
|
||||
import type { ChatEntry } from "../types";
|
||||
|
||||
type PersistedMessage = Message & {
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function getDisplayRole(msg: PersistedMessage): string | undefined {
|
||||
function getDisplayRole(msg: MessageWithMetadata): string | undefined {
|
||||
const role = msg.metadata?.displayRole;
|
||||
return typeof role === "string" ? role.trim().toLowerCase() : undefined;
|
||||
}
|
||||
@@ -33,13 +29,29 @@ function stringifyToolResult(
|
||||
return block.text;
|
||||
if (block.type === "file" && typeof block.path === "string")
|
||||
return `Attached file: ${block.path}`;
|
||||
return "";
|
||||
if (block.type === "image") return "[image]";
|
||||
try {
|
||||
return JSON.stringify(block);
|
||||
} catch {
|
||||
return String(block);
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
function stringifyToolError(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
try {
|
||||
return JSON.stringify(content) ?? String(content);
|
||||
} catch {
|
||||
return String(content);
|
||||
}
|
||||
}
|
||||
|
||||
export function hydrateSessionMessages(
|
||||
messages: MessageWithMetadata[],
|
||||
): ChatEntry[] {
|
||||
const entries: ChatEntry[] = [];
|
||||
const toolUseMap = new Map<string, number>();
|
||||
// Mode each entry was produced in, recovered from <user_input mode="...">
|
||||
@@ -49,7 +61,7 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
// wrappers on session restarts).
|
||||
let mode: AgentMode | undefined;
|
||||
|
||||
for (const msg of messages as PersistedMessage[]) {
|
||||
for (const { message: msg } of projectSessionMessagesForDisplay(messages)) {
|
||||
const displayRole = getDisplayRole(msg);
|
||||
if (displayRole === "system" || displayRole === "status") {
|
||||
continue;
|
||||
@@ -107,6 +119,7 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
if (block.type === "tool_use") {
|
||||
entries.push({
|
||||
kind: "tool_call",
|
||||
toolCallId: block.id,
|
||||
toolName: block.name,
|
||||
inputSummary: formatToolInput(block.name, block.input),
|
||||
rawInput: block.input,
|
||||
@@ -132,11 +145,16 @@ export function hydrateSessionMessages(messages: Message[]): ChatEntry[] {
|
||||
| string
|
||||
| Array<{ type: string; text?: string; path?: string }>,
|
||||
);
|
||||
entry.result = {
|
||||
outputSummary: resultText.slice(0, 500),
|
||||
rawOutput: block.content,
|
||||
error: block.is_error ? resultText : undefined,
|
||||
};
|
||||
const error = block.is_error
|
||||
? stringifyToolError(block.content)
|
||||
: undefined;
|
||||
entry.result = error
|
||||
? { outputSummary: "", rawOutput: undefined, error }
|
||||
: {
|
||||
outputSummary: resultText.slice(0, 500),
|
||||
rawOutput: block.content,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("cline-pass-errors", () => {
|
||||
expect(isClinePassSubscriptionError(formatted)).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(sdkFormatted))).toBe(formatted);
|
||||
expect(formatCliErrorMessage(new Error(formatted))).toBe(formatted);
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes and formats organization account individual subscription errors", () => {
|
||||
const raw =
|
||||
|
||||
@@ -21,11 +21,11 @@ export { getClineOrgIndividualInferenceSubscriptionMessage };
|
||||
export const CLI_PROMO_CODE = "";
|
||||
|
||||
export function getCliSubscriptionUrl(): string {
|
||||
if(!CLI_PROMO_CODE) {
|
||||
if (!CLI_PROMO_CODE) {
|
||||
return new URL(
|
||||
`/dashboard/subscription?personal=true`,
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString()
|
||||
).toString();
|
||||
}
|
||||
|
||||
return `${new URL(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ClineCore } from "@cline/core";
|
||||
import type { Message } from "@cline/shared";
|
||||
import type { MessageWithMetadata } from "@cline/shared";
|
||||
|
||||
export async function loadInteractiveResumeMessages(
|
||||
sessionManager: ClineCore,
|
||||
resumeSessionId?: string,
|
||||
): Promise<Message[] | undefined> {
|
||||
): Promise<MessageWithMetadata[] | undefined> {
|
||||
const target = resumeSessionId?.trim();
|
||||
if (!target) {
|
||||
return undefined;
|
||||
|
||||
@@ -50,6 +50,7 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
include: ["src/**/*.test.ts"],
|
||||
exclude: ["src/**/*.e2e.test.ts", "src/tests/**"],
|
||||
// Default 5s is tight on CI: each test uses `resetModules()` + dynamic `import("./main")`
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Unit tests (and any subprocess they spawn, via env inheritance) must never
|
||||
// touch the developer's real ~/.cline: a test that reaches core startup can
|
||||
// otherwise spawn a real hub daemon against the real discovery record, or
|
||||
// trigger a real background auto-update. Point everything at a per-worker
|
||||
// temp dir before any test file is imported. Tests that need specific paths
|
||||
// still override these per-test.
|
||||
const isolatedRoot = mkdtempSync(join(tmpdir(), "cline-cli-vitest-"));
|
||||
process.env.CLINE_DIR = join(isolatedRoot, ".cline");
|
||||
process.env.CLINE_DATA_DIR = join(isolatedRoot, "data");
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = join(isolatedRoot, "hub-discovery.json");
|
||||
process.env.CLINE_NO_AUTO_UPDATE = "1";
|
||||
@@ -2,6 +2,10 @@ import { describe, expect, it } from "vitest";
|
||||
import { mapHistoryToWebviewMessages } from "./session-mapping";
|
||||
|
||||
describe("mapHistoryToWebviewMessages", () => {
|
||||
it("preserves tolerant handling of malformed history entries", () => {
|
||||
expect(() => mapHistoryToWebviewMessages([null, 42])).not.toThrow();
|
||||
});
|
||||
|
||||
it("hydrates assistant tool uses with following user tool results", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
@@ -228,4 +232,79 @@ describe("mapHistoryToWebviewMessages", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates provider model activities through ordinary tool events", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-search",
|
||||
role: "assistant",
|
||||
content: "Bun 1.3.14 is current.",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun release" },
|
||||
output: { answer: "1.3.14" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
toolEvents: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
name: "web_search",
|
||||
state: "output-available",
|
||||
input: { query: "latest Bun release" },
|
||||
output: '{"answer":"1.3.14"}',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(messages[1]).toMatchObject({
|
||||
id: "assistant-search",
|
||||
role: "assistant",
|
||||
text: "Bun 1.3.14 is current.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps id-less history row ids stable as a provider result completes", () => {
|
||||
const source = {
|
||||
role: "assistant",
|
||||
content: "Bun 1.3.14 is current.",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun release" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const pending = mapHistoryToWebviewMessages([source]);
|
||||
const completed = mapHistoryToWebviewMessages([
|
||||
{
|
||||
...source,
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
...source.metadata.modelToolActivities[0],
|
||||
output: "1.3.14",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(pending.at(-1)?.id).toBe("history-0");
|
||||
expect(completed.at(-1)?.id).toBe("history-0");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { formatDisplayUserInput } from "@cline/shared";
|
||||
import { projectSessionMessagesForDisplay } from "@cline/core";
|
||||
import {
|
||||
formatDisplayUserInput,
|
||||
type MessageWithMetadata,
|
||||
} from "@cline/shared";
|
||||
import type {
|
||||
WebviewActionSessionSummary,
|
||||
WebviewChatMessage,
|
||||
@@ -193,13 +197,17 @@ export function mapHistoryToWebviewMessages(
|
||||
): WebviewChatMessage[] {
|
||||
const mapped: WebviewChatMessage[] = [];
|
||||
const toolLocations = new Map<string, HistoryToolLocation>();
|
||||
const displayHistory = projectSessionMessagesForDisplay(
|
||||
history as MessageWithMetadata[],
|
||||
);
|
||||
|
||||
for (const [index, entry] of history.entries()) {
|
||||
for (const entry of displayHistory) {
|
||||
const { message, sourceIndex } = entry;
|
||||
const record =
|
||||
entry && typeof entry === "object"
|
||||
? (entry as Record<string, unknown>)
|
||||
: { content: entry };
|
||||
const messageKey = asString(record.id) ?? `history-${index}`;
|
||||
message && typeof message === "object"
|
||||
? (message as unknown as Record<string, unknown>)
|
||||
: { content: message };
|
||||
const messageKey = asString(record.id) ?? `history-${sourceIndex}`;
|
||||
const rawRole = asString(record.role)?.toLowerCase();
|
||||
let role: WebviewChatMessage["role"] =
|
||||
rawRole === "user" || rawRole === "assistant" || rawRole === "error"
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type SessionRecord,
|
||||
SessionSource,
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/llms";
|
||||
import type { MessageWithMetadata } from "@cline/llms";
|
||||
import type { WebviewConfig, WebviewReasonLevel } from "../webview-protocol";
|
||||
import { rejectPendingApprovalsForSession } from "./approvals";
|
||||
import { providerSettingsManager, workspaceRoot } from "./deps";
|
||||
@@ -89,7 +89,7 @@ function buildSessionStartInput(
|
||||
teamName?: string;
|
||||
source?: SessionSource;
|
||||
sessionMetadata?: Record<string, unknown>;
|
||||
initialMessages?: Message[];
|
||||
initialMessages?: MessageWithMetadata[];
|
||||
},
|
||||
): ClineCoreStartInput {
|
||||
const mode = options?.mode === "plan" ? "plan" : "act";
|
||||
@@ -137,7 +137,7 @@ function buildStartInputFromSession(
|
||||
session: SessionRecord,
|
||||
options?: {
|
||||
sessionMetadata?: Record<string, unknown>;
|
||||
initialMessages?: Message[];
|
||||
initialMessages?: MessageWithMetadata[];
|
||||
},
|
||||
) {
|
||||
const metadata =
|
||||
@@ -348,7 +348,7 @@ export async function forkPeerSession(
|
||||
try {
|
||||
const rawMessages = (await ctx.cline.readMessages(
|
||||
forkedFromSessionId,
|
||||
)) as Message[];
|
||||
)) as MessageWithMetadata[];
|
||||
if (rawMessages.length === 0) {
|
||||
ctx.send(peer, {
|
||||
type: "fork_error",
|
||||
|
||||
@@ -330,7 +330,6 @@ describe("session forks", () => {
|
||||
expect(result).toEqual({
|
||||
sessionId: "edited-fork",
|
||||
forkedFromSessionId: sourceSessionId,
|
||||
messages: expectedMessages,
|
||||
});
|
||||
expect(ctx.liveSessions.get("edited-fork")?.messages).toEqual(
|
||||
expectedMessages,
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
splitCoreSessionConfig,
|
||||
trimMessagesBeforeUserRun,
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/llms";
|
||||
import type { MessageWithMetadata } from "@cline/llms";
|
||||
import { buildClineSystemPrompt, formatUserCommandBlock } from "@cline/shared";
|
||||
import {
|
||||
deleteMaterializedAttachments,
|
||||
@@ -282,7 +282,9 @@ export function refreshWorkspaceMetadata(cwd: string): void {
|
||||
// Session data helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readPersistedChatMessages(sessionId: string): unknown[] | null {
|
||||
function readPersistedChatMessages(
|
||||
sessionId: string,
|
||||
): MessageWithMetadata[] | null {
|
||||
const path = join(
|
||||
sharedSessionDataDir(),
|
||||
sessionId,
|
||||
@@ -291,8 +293,8 @@ function readPersistedChatMessages(sessionId: string): unknown[] | null {
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8").trim()) as
|
||||
| { messages?: unknown[] }
|
||||
| unknown[];
|
||||
| { messages?: MessageWithMetadata[] }
|
||||
| MessageWithMetadata[];
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
return Array.isArray(parsed.messages) ? parsed.messages : null;
|
||||
} catch {
|
||||
@@ -669,9 +671,7 @@ async function handleStart(
|
||||
...splitCoreSessionConfig(coreConfig as unknown as ClineCoreStartConfig),
|
||||
source: SessionSource.DESKTOP,
|
||||
interactive: true,
|
||||
...(initialMessages
|
||||
? { initialMessages: initialMessages as Message[] }
|
||||
: {}),
|
||||
...(initialMessages ? { initialMessages } : {}),
|
||||
toolPolicies: resolveToolPolicies(request.config),
|
||||
});
|
||||
const sessionId = startResult.sessionId;
|
||||
@@ -775,7 +775,7 @@ async function startRebuiltSession(
|
||||
sessionId: string,
|
||||
config: JsonRecord,
|
||||
systemPrompt: string,
|
||||
messages: Message[],
|
||||
messages: MessageWithMetadata[],
|
||||
compactionState: SessionCompactionState | undefined,
|
||||
): Promise<void> {
|
||||
const projectedMessages = compactionState
|
||||
@@ -1049,7 +1049,7 @@ async function handleSend(
|
||||
});
|
||||
if (session && ownsBusyState) {
|
||||
session.status = "idle";
|
||||
if (result?.messages) session.messages = result.messages as unknown[];
|
||||
if (result?.messages) session.messages = result.messages;
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
@@ -1247,10 +1247,7 @@ async function handleForkUnlocked(
|
||||
let forkMessages =
|
||||
forkBeforeRunCount === undefined
|
||||
? sourceMessages
|
||||
: trimMessagesBeforeUserRun(
|
||||
sourceMessages as Message[],
|
||||
forkBeforeRunCount,
|
||||
);
|
||||
: trimMessagesBeforeUserRun(sourceMessages, forkBeforeRunCount);
|
||||
const forkMetadata: JsonRecord = {
|
||||
...(sourceMetadata ?? {}),
|
||||
fork: {
|
||||
@@ -1305,7 +1302,7 @@ async function handleForkUnlocked(
|
||||
} else {
|
||||
const started = await manager.start({
|
||||
...startInput,
|
||||
initialMessages: forkMessages as Message[],
|
||||
initialMessages: forkMessages,
|
||||
});
|
||||
newSessionId = started.sessionId;
|
||||
}
|
||||
@@ -1334,7 +1331,6 @@ async function handleForkUnlocked(
|
||||
return {
|
||||
sessionId: newSessionId,
|
||||
forkedFromSessionId: sourceSessionId,
|
||||
messages: forkMessages,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1420,14 +1416,8 @@ async function handleRestoreCheckpoint(
|
||||
);
|
||||
sendPromptsInQueueSnapshot(ctx, sourceSessionId);
|
||||
sendPromptsInQueueSnapshot(ctx, sessionId);
|
||||
let messages: unknown[] = restoredMessages;
|
||||
try {
|
||||
const read = await manager.readMessages(sessionId);
|
||||
if (read?.length > 0) messages = read;
|
||||
} catch {}
|
||||
return {
|
||||
sessionId,
|
||||
messages,
|
||||
restoredCheckpoint: restored.checkpoint,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
saveLocalProviderSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setMcpServerDisabled,
|
||||
setModelToolEnabledGlobally,
|
||||
setTelemetryOptOutGlobally,
|
||||
updateLocalProvider,
|
||||
updateMcpSettingsFileSync,
|
||||
@@ -1525,6 +1526,13 @@ export async function handleCommand(
|
||||
setAutoUpdateEnabledGlobally(args.auto_update_enabled);
|
||||
return readGlobalSettings();
|
||||
}
|
||||
if (command === "set_web_search_enabled") {
|
||||
if (typeof args?.web_search_enabled !== "boolean") {
|
||||
throw new Error("web_search_enabled must be a boolean");
|
||||
}
|
||||
setModelToolEnabledGlobally("web_search", args.web_search_enabled);
|
||||
return readGlobalSettings();
|
||||
}
|
||||
|
||||
// ── Connector channels ─────────────────────────────────────────────
|
||||
if (command === "list_connector_channels") {
|
||||
|
||||
@@ -243,7 +243,12 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
config: {},
|
||||
messages: [],
|
||||
promptsInQueue: [
|
||||
{ id: "prompt-1", prompt: "hi there", steer: false, attachmentCount: 0 },
|
||||
{
|
||||
id: "prompt-1",
|
||||
prompt: "hi there",
|
||||
steer: false,
|
||||
attachmentCount: 0,
|
||||
},
|
||||
],
|
||||
busy: false,
|
||||
startedAt: Date.now(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { SqliteSessionStore } from "@cline/core";
|
||||
import type { MessageWithMetadata } from "@cline/shared";
|
||||
|
||||
/**
|
||||
* Child agents of a chat session: `spawn_agent` subagent runs and team-task
|
||||
@@ -31,14 +32,14 @@ export type SessionAgentRecord = {
|
||||
const TEAM_TASK_MARKER = "__teamtask__";
|
||||
const LAST_ACTION_LIMIT = 160;
|
||||
|
||||
function readMessagesFile(path: string): unknown[] | null {
|
||||
function readMessagesFile(path: string): MessageWithMetadata[] | null {
|
||||
if (!path || !existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as
|
||||
| { messages?: unknown[] }
|
||||
| unknown[];
|
||||
| { messages?: MessageWithMetadata[] }
|
||||
| MessageWithMetadata[];
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
@@ -64,7 +65,9 @@ function resolveChildMessagesPath(record: {
|
||||
* nothing has been written yet. Lets the ordinary session-reading path open a
|
||||
* subagent session without knowing where child artifacts live.
|
||||
*/
|
||||
export function readChildSessionMessages(sessionId: string): unknown[] | null {
|
||||
export function readChildSessionMessages(
|
||||
sessionId: string,
|
||||
): MessageWithMetadata[] | null {
|
||||
const trimmed = sessionId.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
|
||||
@@ -2,6 +2,30 @@ import { describe, expect, it } from "vitest";
|
||||
import { readSessionMessages } from "./messages";
|
||||
|
||||
describe("readSessionMessages", () => {
|
||||
it("continues past malformed persisted entries", async () => {
|
||||
const sessionId = `malformed-projection-${Date.now()}`;
|
||||
const liveSessions = new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
messages: [null, 42, { role: "assistant", content: "Still here" }],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
content: "Still here",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves each stored message timestamp across projected blocks", async () => {
|
||||
const sessionId = `timestamp-projection-${Date.now()}`;
|
||||
const userTimestamp = 1_781_041_621_282;
|
||||
@@ -178,7 +202,7 @@ describe("readSessionMessages", () => {
|
||||
]);
|
||||
|
||||
const projected = (await readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
{ liveSessions } as unknown as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
@@ -222,7 +246,7 @@ describe("readSessionMessages", () => {
|
||||
]);
|
||||
|
||||
const projected = (await readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
{ liveSessions } as unknown as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
@@ -253,4 +277,115 @@ describe("readSessionMessages", () => {
|
||||
meta: { runCount: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it("projects provider model activities through the ordinary tool payload", async () => {
|
||||
const sessionId = `provider-tool-projection-${Date.now()}`;
|
||||
const liveSessions = new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
id: "assistant-search",
|
||||
role: "assistant",
|
||||
content: "Bun 1.3.14 is current.",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun release" },
|
||||
output: { answer: "1.3.14" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const projected = (await readSessionMessages(
|
||||
{ liveSessions } as unknown as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(projected).toHaveLength(2);
|
||||
expect(projected[0]).toMatchObject({
|
||||
role: "tool",
|
||||
meta: {
|
||||
toolName: "web_search",
|
||||
hookEventName: "history_tool_result",
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(String(projected[0]?.content))).toMatchObject({
|
||||
toolName: "web_search",
|
||||
input: { query: "latest Bun release" },
|
||||
result: '{"answer":"1.3.14"}',
|
||||
});
|
||||
expect(projected[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "Bun 1.3.14 is current.",
|
||||
});
|
||||
const sorted = [...projected].sort((left, right) => {
|
||||
const time = Number(left.createdAt) - Number(right.createdAt);
|
||||
return time || String(left.id).localeCompare(String(right.id));
|
||||
});
|
||||
expect(sorted.map((message) => message.role)).toEqual([
|
||||
"tool",
|
||||
"assistant",
|
||||
]);
|
||||
expect(Number(projected[0]?.createdAt)).toBeLessThan(
|
||||
Number(projected[1]?.createdAt),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the absolute run anchor for a truncated tool-only provider turn", async () => {
|
||||
const sessionId = `provider-tool-run-anchor-${Date.now()}`;
|
||||
const liveSessions = new Map([
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "First prompt" },
|
||||
{ role: "assistant", content: "First response" },
|
||||
{ role: "user", content: "Second prompt" },
|
||||
{
|
||||
id: "tool-only-assistant",
|
||||
role: "assistant",
|
||||
content: [],
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-tool-only",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun" },
|
||||
output: "Bun 1.3.14",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const projected = (await readSessionMessages(
|
||||
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
|
||||
sessionId,
|
||||
1,
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(projected).toHaveLength(1);
|
||||
expect(projected[0]).toMatchObject({
|
||||
role: "tool",
|
||||
meta: {
|
||||
runCount: 2,
|
||||
toolName: "web_search",
|
||||
hookEventName: "history_tool_result",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { getUserRunSpan, resolveMessageDisplayRole } from "@cline/core";
|
||||
import { validateImageMedia } from "@cline/shared";
|
||||
import {
|
||||
getUserRunSpan,
|
||||
projectSessionMessagesForDisplay,
|
||||
resolveMessageDisplayRole,
|
||||
} from "@cline/core";
|
||||
import { type MessageWithMetadata, validateImageMedia } from "@cline/shared";
|
||||
import {
|
||||
readSessionManifest,
|
||||
sharedSessionMessagesPath,
|
||||
@@ -144,15 +148,17 @@ function extractImageBlock(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function readPersistedChatMessages(sessionId: string): unknown[] | null {
|
||||
export function readPersistedChatMessages(
|
||||
sessionId: string,
|
||||
): MessageWithMetadata[] | null {
|
||||
const path = sharedSessionMessagesPath(sessionId);
|
||||
if (!existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as
|
||||
| { messages?: unknown[] }
|
||||
| unknown[];
|
||||
| { messages?: MessageWithMetadata[] }
|
||||
| MessageWithMetadata[];
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
@@ -336,6 +342,12 @@ export async function readSessionMessages(
|
||||
: (ctx.liveSessions.get(sessionId)?.messages ?? []);
|
||||
const max = Math.max(1, maxMessages);
|
||||
const start = Math.max(0, messages.length - max);
|
||||
const displayMessages = projectSessionMessagesForDisplay(
|
||||
messages.slice(start),
|
||||
).map((entry) => ({
|
||||
message: entry.message,
|
||||
sourceIndex: start + entry.sourceIndex,
|
||||
}));
|
||||
const baseTs = nowMs() - messages.length;
|
||||
const out: JsonRecord[] = [];
|
||||
const checkpointsByRunCount = readCheckpointEntriesByRunCount(sessionId);
|
||||
@@ -346,7 +358,7 @@ export async function readSessionMessages(
|
||||
if (!rawMessage || typeof rawMessage !== "object") {
|
||||
continue;
|
||||
}
|
||||
const message = rawMessage as JsonRecord;
|
||||
const message = rawMessage as unknown as JsonRecord;
|
||||
const metadata = readMessageMetadata(message);
|
||||
userRunCount += getUserRunSpan({
|
||||
role: normalizeRole(message.role),
|
||||
@@ -355,13 +367,24 @@ export async function readSessionMessages(
|
||||
});
|
||||
}
|
||||
|
||||
for (let idx = start; idx < messages.length; idx += 1) {
|
||||
const rawMessage = messages[idx];
|
||||
let previousCreatedAt: number | undefined;
|
||||
for (const projectedMessage of displayMessages) {
|
||||
const idx = projectedMessage.sourceIndex;
|
||||
const rawMessage = projectedMessage.message;
|
||||
if (!rawMessage || typeof rawMessage !== "object") {
|
||||
continue;
|
||||
}
|
||||
const message = rawMessage as JsonRecord;
|
||||
const createdAt = resolveMessageCreatedAt(message, baseTs + idx);
|
||||
const message = rawMessage as unknown as JsonRecord;
|
||||
const storedCreatedAt = resolveMessageCreatedAt(message, baseTs + idx);
|
||||
// Provider activity projects to messages immediately before its owning
|
||||
// assistant answer. Give projected messages a stable chronological order
|
||||
// even when they share the source timestamp: the webview sorts timestamp
|
||||
// ties by id, which would otherwise put the answer before its tool card.
|
||||
const createdAt =
|
||||
previousCreatedAt === undefined
|
||||
? storedCreatedAt
|
||||
: Math.max(storedCreatedAt, previousCreatedAt + 1);
|
||||
previousCreatedAt = createdAt;
|
||||
let textMeta = extractMessageUsageMeta(message);
|
||||
const storedMeta = extractStoredMessageMeta(message);
|
||||
if (storedMeta) {
|
||||
@@ -511,6 +534,9 @@ export async function readSessionMessages(
|
||||
isError,
|
||||
);
|
||||
target.meta = {
|
||||
...(target.meta && typeof target.meta === "object"
|
||||
? (target.meta as JsonRecord)
|
||||
: {}),
|
||||
toolName,
|
||||
hookEventName: "history_tool_result",
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
NodeHubClient,
|
||||
ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
import type { MessageWithMetadata } from "@cline/llms";
|
||||
|
||||
export type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -49,7 +50,7 @@ export type PromptInQueue = {
|
||||
|
||||
export type LiveSession = {
|
||||
config: JsonRecord;
|
||||
messages: unknown[];
|
||||
messages: MessageWithMetadata[];
|
||||
promptsInQueue: PromptInQueue[];
|
||||
busy: boolean;
|
||||
startedAt: number;
|
||||
|
||||
@@ -16,7 +16,7 @@ rfd = "0.15"
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSResponder"] }
|
||||
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSEvent", "NSImage", "NSMenu", "NSMenuItem", "NSResponder"] }
|
||||
objc2-foundation = { version = "0.3", features = ["NSString"] }
|
||||
|
||||
[features]
|
||||
|
||||
@@ -9,6 +9,8 @@ use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::menu::{Menu, MenuItemKind, PredefinedMenuItem, Submenu};
|
||||
use tauri::{
|
||||
menu::{MenuBuilder, MenuItem},
|
||||
tray::TrayIconBuilder,
|
||||
@@ -24,6 +26,12 @@ const TRAY_OPEN_MENU_ID: &str = "tray-open";
|
||||
const TRAY_NEW_SESSION_MENU_ID: &str = "tray-new-session";
|
||||
const TRAY_SETTINGS_MENU_ID: &str = "tray-settings";
|
||||
const TRAY_QUIT_MENU_ID: &str = "tray-quit";
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
const VIEW_ZOOM_IN_MENU_ID: &str = "view-zoom-in";
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
const VIEW_ZOOM_OUT_MENU_ID: &str = "view-zoom-out";
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
const VIEW_ZOOM_RESET_MENU_ID: &str = "view-zoom-reset";
|
||||
const DESKTOP_MENU_ACTION_PENDING_EVENT: &str = "desktop-menu-action-pending";
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -808,6 +816,101 @@ fn queue_desktop_menu_action(app: &tauri::AppHandle, action: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn application_menu_action(menu_id: &str) -> Option<&'static str> {
|
||||
match menu_id {
|
||||
VIEW_ZOOM_IN_MENU_ID => Some("zoom-in"),
|
||||
VIEW_ZOOM_OUT_MENU_ID => Some("zoom-out"),
|
||||
VIEW_ZOOM_RESET_MENU_ID => Some("zoom-reset"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn setup_application_menu(app: &tauri::App) -> tauri::Result<()> {
|
||||
let menu = Menu::default(app.handle())?;
|
||||
let zoom_in = MenuItem::with_id(app, VIEW_ZOOM_IN_MENU_ID, "Zoom In", true, None::<&str>)?;
|
||||
let zoom_out = MenuItem::with_id(
|
||||
app,
|
||||
VIEW_ZOOM_OUT_MENU_ID,
|
||||
"Zoom Out",
|
||||
true,
|
||||
Some("CmdOrCtrl+-"),
|
||||
)?;
|
||||
let zoom_reset = MenuItem::with_id(
|
||||
app,
|
||||
VIEW_ZOOM_RESET_MENU_ID,
|
||||
"Actual Size",
|
||||
true,
|
||||
Some("CmdOrCtrl+0"),
|
||||
)?;
|
||||
let separator = PredefinedMenuItem::separator(app)?;
|
||||
|
||||
let mut view_menu = None;
|
||||
for item in menu.items()? {
|
||||
if let MenuItemKind::Submenu(submenu) = item {
|
||||
if submenu.text()? == "View" {
|
||||
view_menu = Some(submenu);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(view_menu) = view_menu {
|
||||
view_menu.prepend_items(&[&zoom_in, &zoom_out, &zoom_reset, &separator])?;
|
||||
} else {
|
||||
let view_menu =
|
||||
Submenu::with_items(app, "View", true, &[&zoom_in, &zoom_out, &zoom_reset])?;
|
||||
menu.append(&view_menu)?;
|
||||
}
|
||||
|
||||
app.set_menu(menu)?;
|
||||
set_macos_menu_key_equivalent("View", "Zoom In", "+")?;
|
||||
app.on_menu_event(|app, event| {
|
||||
if let Some(action) = application_menu_action(event.id().as_ref()) {
|
||||
queue_desktop_menu_action(app, action);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_macos_menu_key_equivalent(
|
||||
menu_title: &str,
|
||||
item_title: &str,
|
||||
key_equivalent: &str,
|
||||
) -> tauri::Result<()> {
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSApplication, NSEventModifierFlags};
|
||||
use objc2_foundation::NSString;
|
||||
|
||||
let missing = |description: &str| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("native menu item not found: {description}"),
|
||||
)
|
||||
};
|
||||
let mtm = MainThreadMarker::new()
|
||||
.ok_or_else(|| std::io::Error::other("native menu setup must run on the main thread"))?;
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
let main_menu = app.mainMenu().ok_or_else(|| missing("main menu"))?;
|
||||
let menu_item = main_menu
|
||||
.itemWithTitle(&NSString::from_str(menu_title))
|
||||
.ok_or_else(|| missing(menu_title))?;
|
||||
let submenu = menu_item
|
||||
.submenu()
|
||||
.ok_or_else(|| missing(&format!("{menu_title} submenu")))?;
|
||||
let item = submenu
|
||||
.itemWithTitle(&NSString::from_str(item_title))
|
||||
.ok_or_else(|| missing(item_title))?;
|
||||
|
||||
// Tauri 2.11's accelerator parser cannot represent the `+` character.
|
||||
// Set the AppKit key equivalent directly so the menu displays and handles ⌘+.
|
||||
item.setKeyEquivalent(&NSString::from_str(key_equivalent));
|
||||
item.setKeyEquivalentModifierMask(NSEventModifierFlags::Command);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setup_tray_icon(app: &tauri::App) -> tauri::Result<()> {
|
||||
let status = MenuItem::new(app, "Status: Healthy", false, None::<&str>)?;
|
||||
let running_sessions = MenuItem::new(app, "0 sessions running", false, None::<&str>)?;
|
||||
@@ -906,6 +1009,8 @@ fn main() {
|
||||
.manage(Arc::new(UpdateState::default()))
|
||||
.manage(DesktopMenuActionState::default())
|
||||
.setup(|app| {
|
||||
#[cfg(target_os = "macos")]
|
||||
setup_application_menu(app)?;
|
||||
setup_tray_icon(app)?;
|
||||
let app_context = app.state::<AppContext>().inner().clone();
|
||||
let backend_state = app.state::<Arc<DesktopBackendState>>().inner().clone();
|
||||
@@ -984,6 +1089,23 @@ mod tests {
|
||||
assert!(state.drain().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_menu_ids_map_to_zoom_actions() {
|
||||
assert_eq!(
|
||||
application_menu_action(VIEW_ZOOM_IN_MENU_ID),
|
||||
Some("zoom-in")
|
||||
);
|
||||
assert_eq!(
|
||||
application_menu_action(VIEW_ZOOM_OUT_MENU_ID),
|
||||
Some("zoom-out")
|
||||
);
|
||||
assert_eq!(
|
||||
application_menu_action(VIEW_ZOOM_RESET_MENU_ID),
|
||||
Some("zoom-reset")
|
||||
);
|
||||
assert_eq!(application_menu_action("unknown"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tray_status_prioritizes_update_progress_over_hub_health() {
|
||||
let status = |state: &str| UpdateStatus {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { DesktopErrorTelemetry } from "@/components/desktop-error-telemetry";
|
||||
import { NativeShell } from "@/components/native-shell";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { APP_FONT_SIZE_BOOTSTRAP_SCRIPT } from "@/lib/app-font-size";
|
||||
import { HUB_THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme";
|
||||
import "./globals.css";
|
||||
|
||||
@@ -40,6 +41,13 @@ export default function RootLayout({
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<script
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: static bootstrap must run before the first paint
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: APP_FONT_SIZE_BOOTSTRAP_SCRIPT,
|
||||
}}
|
||||
id="cline-app-font-size-bootstrap"
|
||||
/>
|
||||
<script
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: static bootstrap must run before the first paint
|
||||
dangerouslySetInnerHTML={{ __html: HUB_THEME_BOOTSTRAP_SCRIPT }}
|
||||
|
||||
@@ -43,6 +43,7 @@ import { useChatSession } from "@/hooks/use-chat-session";
|
||||
import { useSessionAgents } from "@/hooks/use-session-agents";
|
||||
import { useSessionHistory } from "@/hooks/use-session-history";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { applyAppZoomAction, syncAppFontSize } from "@/lib/app-font-size";
|
||||
import { syncAppIcon } from "@/lib/app-icon";
|
||||
import type { ChatSessionConfig } from "@/lib/chat-schema";
|
||||
import {
|
||||
@@ -199,6 +200,7 @@ export default function Home() {
|
||||
useEffect(() => {
|
||||
syncHubTheme();
|
||||
syncHubAccent();
|
||||
syncAppFontSize();
|
||||
return watchSystemHubTheme();
|
||||
}, []);
|
||||
|
||||
@@ -308,6 +310,11 @@ export default function Home() {
|
||||
case "open-settings":
|
||||
handleViewChange("settings");
|
||||
break;
|
||||
case "zoom-in":
|
||||
case "zoom-out":
|
||||
case "zoom-reset":
|
||||
applyAppZoomAction(action);
|
||||
break;
|
||||
}
|
||||
}),
|
||||
[handleNewThread, handleViewChange],
|
||||
|
||||
@@ -79,6 +79,14 @@ export function HubUpdateRequiredDialog() {
|
||||
setPhase("idle");
|
||||
}, []);
|
||||
|
||||
// `outdated_hub` is purely informational: this app is already the newer
|
||||
// build, nothing is asked of the user, and the Hub is replaced on its own
|
||||
// once its sessions end. Interrupting with a modal to say "ignore me"
|
||||
// helps nobody, so that reason renders nothing.
|
||||
if (mismatch?.reason === "outdated_hub") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
|
||||
@@ -6,6 +6,10 @@ import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Slider({
|
||||
"aria-describedby": ariaDescribedBy,
|
||||
"aria-label": ariaLabel,
|
||||
"aria-labelledby": ariaLabelledBy,
|
||||
"aria-valuetext": ariaValueText,
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
@@ -22,10 +26,18 @@ function Slider({
|
||||
: [min, max],
|
||||
[value, defaultValue, min, max],
|
||||
);
|
||||
const thumbKeyCounts = new Map<string, number>();
|
||||
const thumbKeyPrefix = React.useId();
|
||||
const thumbKeys = Array.from(
|
||||
{ length: _values.length },
|
||||
(_, index) => `${thumbKeyPrefix}-${index}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
aria-describedby={ariaDescribedBy}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-valuetext={ariaValueText}
|
||||
data-interactive=""
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
@@ -51,18 +63,17 @@ function Slider({
|
||||
}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{_values.map((thumbValue) => {
|
||||
const valueKey = String(thumbValue);
|
||||
const occurrence = (thumbKeyCounts.get(valueKey) ?? 0) + 1;
|
||||
thumbKeyCounts.set(valueKey, occurrence);
|
||||
return (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={`${valueKey}-${occurrence}`}
|
||||
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{thumbKeys.map((thumbKey) => (
|
||||
<SliderPrimitive.Thumb
|
||||
aria-describedby={ariaDescribedBy}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-valuetext={ariaValueText}
|
||||
data-slot="slider-thumb"
|
||||
key={thumbKey}
|
||||
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// @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 {
|
||||
APP_FONT_SIZE_STORAGE_KEY,
|
||||
applyAppZoomAction,
|
||||
} from "@/lib/app-font-size";
|
||||
import { SettingsView } from "./settings-view";
|
||||
|
||||
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { invoke },
|
||||
isTauriAvailable: vi.fn(() => false),
|
||||
openExternalUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
class ResizeObserverStub {
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, {
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
ResizeObserver: ResizeObserverStub,
|
||||
});
|
||||
window.localStorage.clear();
|
||||
document.documentElement.style.removeProperty("font-size");
|
||||
delete document.documentElement.dataset.clineFontSize;
|
||||
invoke.mockReset();
|
||||
invoke.mockResolvedValue({
|
||||
telemetryOptOut: false,
|
||||
autoUpdateEnabled: true,
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
describe("SettingsView font size", () => {
|
||||
it("loads the saved size and updates it from the General settings controls", async () => {
|
||||
window.localStorage.setItem(APP_FONT_SIZE_STORAGE_KEY, "17");
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SettingsView onNavigateSection={vi.fn()} section="General" />,
|
||||
);
|
||||
});
|
||||
|
||||
const slider = container.querySelector<HTMLElement>(
|
||||
'[role="slider"][aria-label="Font size"]',
|
||||
);
|
||||
const increaseButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Increase font size"]',
|
||||
);
|
||||
expect(slider?.getAttribute("aria-valuenow")).toBe("17");
|
||||
expect(increaseButton).not.toBeNull();
|
||||
expect(increaseButton?.disabled).toBe(false);
|
||||
expect(container.textContent).toContain("17px");
|
||||
|
||||
await act(async () => {
|
||||
increaseButton?.click();
|
||||
});
|
||||
|
||||
expect(window.localStorage.getItem(APP_FONT_SIZE_STORAGE_KEY)).toBe("18");
|
||||
expect(document.documentElement.style.fontSize).toBe("18px");
|
||||
const updatedSlider = container.querySelector<HTMLElement>(
|
||||
'[role="slider"][aria-label="Font size"]',
|
||||
);
|
||||
expect(updatedSlider).toBe(slider);
|
||||
expect(updatedSlider?.getAttribute("aria-valuenow")).toBe("18");
|
||||
|
||||
await act(async () => {
|
||||
updatedSlider?.focus();
|
||||
updatedSlider?.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { bubbles: true, key: "ArrowRight" }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(window.localStorage.getItem(APP_FONT_SIZE_STORAGE_KEY)).toBe("19");
|
||||
expect(document.documentElement.style.fontSize).toBe("19px");
|
||||
expect(updatedSlider?.getAttribute("aria-valuenow")).toBe("19");
|
||||
|
||||
await act(async () => {
|
||||
applyAppZoomAction("zoom-in");
|
||||
});
|
||||
|
||||
expect(window.localStorage.getItem(APP_FONT_SIZE_STORAGE_KEY)).toBe("20");
|
||||
expect(container.textContent).toContain("20px");
|
||||
expect(updatedSlider?.getAttribute("aria-valuenow")).toBe("20");
|
||||
expect(increaseButton?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,17 @@
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { Minus, Plus, RotateCcw } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
DEFAULT_APP_FONT_SIZE,
|
||||
isAppFontSize,
|
||||
MAX_APP_FONT_SIZE,
|
||||
MIN_APP_FONT_SIZE,
|
||||
readStoredAppFontSize,
|
||||
setStoredAppFontSize,
|
||||
subscribeToAppFontSize,
|
||||
} from "@/lib/app-font-size";
|
||||
import {
|
||||
APP_ICONS,
|
||||
type AppIconId,
|
||||
@@ -57,6 +67,7 @@ export {
|
||||
type GlobalSettingsResponse = {
|
||||
telemetryOptOut: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
tools?: Partial<Record<"web_search", { enabled: boolean }>>;
|
||||
};
|
||||
|
||||
const PROVIDER_CATALOG_CACHE_TTL_MS = 60_000;
|
||||
@@ -502,6 +513,10 @@ function GeneralSettingsContent() {
|
||||
if (typeof window === "undefined") return "violet";
|
||||
return readStoredHubAccent();
|
||||
});
|
||||
const [fontSize, setFontSize] = useState(() => {
|
||||
if (typeof window === "undefined") return DEFAULT_APP_FONT_SIZE;
|
||||
return readStoredAppFontSize();
|
||||
});
|
||||
const [appIcon, setAppIcon] = useState<AppIconId>(() => {
|
||||
if (typeof window === "undefined") return DEFAULT_APP_ICON;
|
||||
return readStoredAppIcon();
|
||||
@@ -516,25 +531,36 @@ function GeneralSettingsContent() {
|
||||
const [autoUpdateLoading, setAutoUpdateLoading] = useState(true);
|
||||
const [autoUpdateSaving, setAutoUpdateSaving] = useState(false);
|
||||
const [autoUpdateError, setAutoUpdateError] = useState<string | null>(null);
|
||||
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
|
||||
const [webSearchLoading, setWebSearchLoading] = useState(true);
|
||||
const [webSearchSaving, setWebSearchSaving] = useState(false);
|
||||
const [webSearchError, setWebSearchError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => subscribeToAppFontSize(setFontSize), []);
|
||||
|
||||
const loadGlobalSettings = useCallback(async () => {
|
||||
setTelemetryLoading(true);
|
||||
setTelemetryError(null);
|
||||
setAutoUpdateLoading(true);
|
||||
setAutoUpdateError(null);
|
||||
setWebSearchLoading(true);
|
||||
setWebSearchError(null);
|
||||
try {
|
||||
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
|
||||
"get_global_settings",
|
||||
);
|
||||
setTelemetryOptOut(settings.telemetryOptOut);
|
||||
setAutoUpdateEnabled(settings.autoUpdateEnabled);
|
||||
setWebSearchEnabled(settings.tools?.web_search?.enabled === true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setTelemetryError(message);
|
||||
setAutoUpdateError(message);
|
||||
setWebSearchError(message);
|
||||
} finally {
|
||||
setTelemetryLoading(false);
|
||||
setAutoUpdateLoading(false);
|
||||
setWebSearchLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -585,6 +611,26 @@ function GeneralSettingsContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const updateWebSearchEnabled = async (nextValue: boolean) => {
|
||||
const previousValue = webSearchEnabled;
|
||||
setWebSearchEnabled(nextValue);
|
||||
setWebSearchSaving(true);
|
||||
setWebSearchError(null);
|
||||
try {
|
||||
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
|
||||
"set_web_search_enabled",
|
||||
{ web_search_enabled: nextValue },
|
||||
);
|
||||
setWebSearchEnabled(settings.tools?.web_search?.enabled === true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setWebSearchEnabled(previousValue);
|
||||
setWebSearchError(message);
|
||||
} finally {
|
||||
setWebSearchSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateTheme = (darkModeEnabled: boolean) => {
|
||||
const nextTheme = darkModeEnabled ? "dark" : "light";
|
||||
setTheme(setStoredHubTheme(nextTheme));
|
||||
@@ -594,6 +640,16 @@ function GeneralSettingsContent() {
|
||||
setAccent(setStoredHubAccent(nextAccent));
|
||||
};
|
||||
|
||||
const updateFontSizePreference = (nextFontSize: number) => {
|
||||
if (isAppFontSize(nextFontSize)) {
|
||||
setFontSize(setStoredAppFontSize(nextFontSize));
|
||||
}
|
||||
};
|
||||
|
||||
const updateFontSize = ([nextFontSize]: number[]) => {
|
||||
updateFontSizePreference(nextFontSize);
|
||||
};
|
||||
|
||||
const updateAppIcon = async (nextIcon: AppIconId) => {
|
||||
const requestId = ++appIconRequestRef.current;
|
||||
const previousIcon = appIcon;
|
||||
@@ -641,6 +697,53 @@ function GeneralSettingsContent() {
|
||||
onCheckedChange={updateTheme}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-5 border-b py-4 max-[720px]:flex-col max-[720px]:items-stretch">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold text-foreground">Font size</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Adjust the size of text and interface elements throughout the app.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-64 shrink-0 items-center gap-3 max-[720px]:w-full">
|
||||
<Button
|
||||
aria-label="Decrease font size"
|
||||
className="size-7"
|
||||
disabled={fontSize === MIN_APP_FONT_SIZE}
|
||||
onClick={() => updateFontSizePreference(fontSize - 1)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Minus />
|
||||
</Button>
|
||||
<Slider
|
||||
aria-label="Font size"
|
||||
aria-valuetext={`${fontSize} pixels`}
|
||||
max={MAX_APP_FONT_SIZE}
|
||||
min={MIN_APP_FONT_SIZE}
|
||||
onValueChange={updateFontSize}
|
||||
step={1}
|
||||
value={[fontSize]}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Increase font size"
|
||||
className="size-7"
|
||||
disabled={fontSize === MAX_APP_FONT_SIZE}
|
||||
onClick={() => updateFontSizePreference(fontSize + 1)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
<output
|
||||
aria-label="Selected font size"
|
||||
className="w-10 shrink-0 text-right font-mono text-sm tabular-nums text-foreground"
|
||||
>
|
||||
{fontSize}px
|
||||
</output>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex py-4 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
@@ -717,6 +820,28 @@ function GeneralSettingsContent() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex py-4 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
Web search
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Let the model search the web when the selected provider and model
|
||||
support it. Applies to new sessions.
|
||||
</p>
|
||||
{webSearchError ? (
|
||||
<p className="mt-2 text-xs text-destructive" role="alert">
|
||||
Failed to update web search setting: {webSearchError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="Web search"
|
||||
checked={webSearchEnabled}
|
||||
disabled={webSearchLoading || webSearchSaving}
|
||||
onCheckedChange={(checked) => void updateWebSearchEnabled(checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex py-4 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
|
||||
@@ -2158,7 +2158,6 @@ export function useChatSession() {
|
||||
config,
|
||||
})) as {
|
||||
sessionId?: string;
|
||||
messages?: ChatMessage[];
|
||||
};
|
||||
const nextSessionId =
|
||||
typeof payload.sessionId === "string" ? payload.sessionId.trim() : "";
|
||||
@@ -2166,12 +2165,13 @@ export function useChatSession() {
|
||||
throw new Error("Checkpoint restore did not return a new session id");
|
||||
}
|
||||
|
||||
const nextMessages = Array.isArray(payload.messages)
|
||||
? (payload.messages as ChatMessage[])
|
||||
: await desktopClient.invoke<ChatMessage[]>("read_session_messages", {
|
||||
sessionId: nextSessionId,
|
||||
maxMessages: MAX_MESSAGES,
|
||||
});
|
||||
const nextMessages = await desktopClient.invoke<ChatMessage[]>(
|
||||
"read_session_messages",
|
||||
{
|
||||
sessionId: nextSessionId,
|
||||
maxMessages: MAX_MESSAGES,
|
||||
},
|
||||
);
|
||||
|
||||
setSessionId(nextSessionId);
|
||||
activeSessionIdRef.current = nextSessionId;
|
||||
@@ -2453,7 +2453,6 @@ export function useChatSession() {
|
||||
})) as {
|
||||
sessionId?: string;
|
||||
forkedFromSessionId?: string;
|
||||
messages?: ChatMessage[];
|
||||
};
|
||||
const newSessionId =
|
||||
typeof payload.sessionId === "string" ? payload.sessionId.trim() : "";
|
||||
@@ -2464,12 +2463,13 @@ export function useChatSession() {
|
||||
typeof payload.forkedFromSessionId === "string"
|
||||
? payload.forkedFromSessionId
|
||||
: activeSessionId;
|
||||
const nextMessages = Array.isArray(payload.messages)
|
||||
? (payload.messages as ChatMessage[])
|
||||
: await desktopClient.invoke<ChatMessage[]>("read_session_messages", {
|
||||
sessionId: newSessionId,
|
||||
maxMessages: MAX_MESSAGES,
|
||||
});
|
||||
const nextMessages = await desktopClient.invoke<ChatMessage[]>(
|
||||
"read_session_messages",
|
||||
{
|
||||
sessionId: newSessionId,
|
||||
maxMessages: MAX_MESSAGES,
|
||||
},
|
||||
);
|
||||
return { newSessionId, forkedFromSessionId, messages: nextMessages };
|
||||
},
|
||||
[config, postSession, status],
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
APP_FONT_SIZE_BOOTSTRAP_SCRIPT,
|
||||
APP_FONT_SIZE_STORAGE_KEY,
|
||||
applyAppZoomAction,
|
||||
DEFAULT_APP_FONT_SIZE,
|
||||
isAppFontSize,
|
||||
readStoredAppFontSize,
|
||||
setStoredAppFontSize,
|
||||
subscribeToAppFontSize,
|
||||
syncAppFontSize,
|
||||
} from "./app-font-size";
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
document.documentElement.style.removeProperty("font-size");
|
||||
delete document.documentElement.dataset.clineFontSize;
|
||||
});
|
||||
|
||||
function runFontSizeBootstrap(): void {
|
||||
runInNewContext(APP_FONT_SIZE_BOOTSTRAP_SCRIPT, { document, window });
|
||||
}
|
||||
|
||||
describe("app font size", () => {
|
||||
it("defaults invalid and missing preferences to 15px", () => {
|
||||
expect(readStoredAppFontSize()).toBe(DEFAULT_APP_FONT_SIZE);
|
||||
expect(isAppFontSize(12)).toBe(true);
|
||||
expect(isAppFontSize(20)).toBe(true);
|
||||
expect(isAppFontSize(11)).toBe(false);
|
||||
expect(isAppFontSize(15.5)).toBe(false);
|
||||
|
||||
window.localStorage.setItem(APP_FONT_SIZE_STORAGE_KEY, "large");
|
||||
expect(readStoredAppFontSize()).toBe(DEFAULT_APP_FONT_SIZE);
|
||||
});
|
||||
|
||||
it("persists and applies the selected size", () => {
|
||||
expect(setStoredAppFontSize(18)).toBe(18);
|
||||
expect(window.localStorage.getItem(APP_FONT_SIZE_STORAGE_KEY)).toBe("18");
|
||||
expect(document.documentElement.style.fontSize).toBe("18px");
|
||||
expect(document.documentElement.dataset.clineFontSize).toBe("18");
|
||||
|
||||
document.documentElement.style.removeProperty("font-size");
|
||||
expect(syncAppFontSize()).toBe(18);
|
||||
expect(document.documentElement.style.fontSize).toBe("18px");
|
||||
});
|
||||
|
||||
it("applies zoom actions, clamps the range, and notifies subscribers", () => {
|
||||
const onChange = vi.fn();
|
||||
const unsubscribe = subscribeToAppFontSize(onChange);
|
||||
setStoredAppFontSize(19);
|
||||
onChange.mockClear();
|
||||
|
||||
expect(applyAppZoomAction("zoom-in")).toBe(20);
|
||||
expect(onChange).toHaveBeenLastCalledWith(20);
|
||||
|
||||
onChange.mockClear();
|
||||
expect(applyAppZoomAction("zoom-in")).toBe(20);
|
||||
expect(onChange).toHaveBeenLastCalledWith(20);
|
||||
|
||||
onChange.mockClear();
|
||||
expect(applyAppZoomAction("zoom-out")).toBe(19);
|
||||
expect(onChange).toHaveBeenLastCalledWith(19);
|
||||
|
||||
onChange.mockClear();
|
||||
expect(applyAppZoomAction("zoom-reset")).toBe(DEFAULT_APP_FONT_SIZE);
|
||||
expect(onChange).toHaveBeenLastCalledWith(DEFAULT_APP_FONT_SIZE);
|
||||
|
||||
unsubscribe();
|
||||
onChange.mockClear();
|
||||
applyAppZoomAction("zoom-out");
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the saved size before the first paint", () => {
|
||||
window.localStorage.setItem(APP_FONT_SIZE_STORAGE_KEY, "19");
|
||||
|
||||
runFontSizeBootstrap();
|
||||
|
||||
expect(document.documentElement.style.fontSize).toBe("19px");
|
||||
expect(document.documentElement.dataset.clineFontSize).toBe("19");
|
||||
});
|
||||
|
||||
it("uses the default before paint when storage is invalid", () => {
|
||||
window.localStorage.setItem(APP_FONT_SIZE_STORAGE_KEY, "21");
|
||||
|
||||
runFontSizeBootstrap();
|
||||
|
||||
expect(document.documentElement.style.fontSize).toBe("15px");
|
||||
expect(document.documentElement.dataset.clineFontSize).toBe("15");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
export const APP_FONT_SIZE_STORAGE_KEY = "cline.code.font-size.v1";
|
||||
export const APP_FONT_SIZE_CHANGE_EVENT = "cline-app-font-size-change";
|
||||
|
||||
export const APP_FONT_SIZES = [12, 13, 14, 15, 16, 17, 18, 19, 20] as const;
|
||||
|
||||
export type AppFontSize = (typeof APP_FONT_SIZES)[number];
|
||||
|
||||
export const DEFAULT_APP_FONT_SIZE: AppFontSize = 15;
|
||||
export const MIN_APP_FONT_SIZE: AppFontSize = APP_FONT_SIZES[0];
|
||||
export const MAX_APP_FONT_SIZE: AppFontSize =
|
||||
APP_FONT_SIZES[APP_FONT_SIZES.length - 1];
|
||||
|
||||
export const APP_ZOOM_ACTIONS = ["zoom-in", "zoom-out", "zoom-reset"] as const;
|
||||
|
||||
export type AppZoomAction = (typeof APP_ZOOM_ACTIONS)[number];
|
||||
|
||||
export function isAppFontSize(value: unknown): value is AppFontSize {
|
||||
return (
|
||||
typeof value === "number" &&
|
||||
(APP_FONT_SIZES as readonly number[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function isAppZoomAction(value: unknown): value is AppZoomAction {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(APP_ZOOM_ACTIONS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
function parseAppFontSize(value: string | null): AppFontSize | null {
|
||||
if (value === null || value.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return isAppFontSize(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs from the document head before the webview paints. Keep this
|
||||
* self-contained: the browser executes it before the client bundle loads.
|
||||
*/
|
||||
export const APP_FONT_SIZE_BOOTSTRAP_SCRIPT = `(() => {
|
||||
const root = document.documentElement;
|
||||
let fontSize = ${JSON.stringify(DEFAULT_APP_FONT_SIZE)};
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(${JSON.stringify(APP_FONT_SIZE_STORAGE_KEY)});
|
||||
const parsed = Number(stored);
|
||||
if (
|
||||
stored !== null &&
|
||||
stored.trim() !== "" &&
|
||||
Number.isInteger(parsed) &&
|
||||
parsed >= ${JSON.stringify(MIN_APP_FONT_SIZE)} &&
|
||||
parsed <= ${JSON.stringify(MAX_APP_FONT_SIZE)}
|
||||
) {
|
||||
fontSize = parsed;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
root.style.fontSize = fontSize + "px";
|
||||
root.dataset.clineFontSize = String(fontSize);
|
||||
})();`;
|
||||
|
||||
export function readStoredAppFontSize(): AppFontSize {
|
||||
try {
|
||||
return (
|
||||
parseAppFontSize(
|
||||
window.localStorage.getItem(APP_FONT_SIZE_STORAGE_KEY),
|
||||
) ?? DEFAULT_APP_FONT_SIZE
|
||||
);
|
||||
} catch {
|
||||
return DEFAULT_APP_FONT_SIZE;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyAppFontSize(fontSize: AppFontSize): AppFontSize {
|
||||
document.documentElement.style.fontSize = `${fontSize}px`;
|
||||
document.documentElement.dataset.clineFontSize = String(fontSize);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<AppFontSize>(APP_FONT_SIZE_CHANGE_EVENT, {
|
||||
detail: fontSize,
|
||||
}),
|
||||
);
|
||||
return fontSize;
|
||||
}
|
||||
|
||||
export function syncAppFontSize(): AppFontSize {
|
||||
return applyAppFontSize(readStoredAppFontSize());
|
||||
}
|
||||
|
||||
export function setStoredAppFontSize(fontSize: AppFontSize): AppFontSize {
|
||||
try {
|
||||
window.localStorage.setItem(APP_FONT_SIZE_STORAGE_KEY, String(fontSize));
|
||||
} catch {
|
||||
// Applying still works for this session when persistence is unavailable.
|
||||
}
|
||||
return applyAppFontSize(fontSize);
|
||||
}
|
||||
|
||||
export function applyAppZoomAction(action: AppZoomAction): AppFontSize {
|
||||
if (action === "zoom-reset") {
|
||||
return setStoredAppFontSize(DEFAULT_APP_FONT_SIZE);
|
||||
}
|
||||
|
||||
const currentIndex = APP_FONT_SIZES.indexOf(readStoredAppFontSize());
|
||||
const offset = action === "zoom-in" ? 1 : -1;
|
||||
const nextIndex = Math.min(
|
||||
APP_FONT_SIZES.length - 1,
|
||||
Math.max(0, currentIndex + offset),
|
||||
);
|
||||
return setStoredAppFontSize(APP_FONT_SIZES[nextIndex]);
|
||||
}
|
||||
|
||||
export function subscribeToAppFontSize(
|
||||
onChange: (fontSize: AppFontSize) => void,
|
||||
): () => void {
|
||||
const handleChange = (event: Event) => {
|
||||
if (event instanceof CustomEvent && isAppFontSize(event.detail)) {
|
||||
onChange(event.detail);
|
||||
}
|
||||
};
|
||||
window.addEventListener(APP_FONT_SIZE_CHANGE_EVENT, handleChange);
|
||||
return () =>
|
||||
window.removeEventListener(APP_FONT_SIZE_CHANGE_EVENT, handleChange);
|
||||
}
|
||||
@@ -46,7 +46,7 @@ describe("desktop tray", () => {
|
||||
let eventHandler: MenuEventHandler | undefined;
|
||||
const pendingBatches: unknown[][] = [
|
||||
["new-session", "unexpected"],
|
||||
["open-settings"],
|
||||
["open-settings", "zoom-in", "zoom-out", "zoom-reset"],
|
||||
];
|
||||
mocks.listen.mockImplementation(
|
||||
async (_eventName: string, handler: MenuEventHandler) => {
|
||||
@@ -78,7 +78,13 @@ describe("desktop tray", () => {
|
||||
eventHandler?.({ payload: undefined });
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(onAction.mock.calls).toEqual([["new-session"], ["open-settings"]]),
|
||||
expect(onAction.mock.calls).toEqual([
|
||||
["new-session"],
|
||||
["open-settings"],
|
||||
["zoom-in"],
|
||||
["zoom-out"],
|
||||
["zoom-reset"],
|
||||
]),
|
||||
);
|
||||
unsubscribe();
|
||||
expect(mocks.unlisten).toHaveBeenCalledOnce();
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { type AppZoomAction, isAppZoomAction } from "@/lib/app-font-size";
|
||||
import { desktopClient, isTauriAvailable } from "@/lib/desktop-client";
|
||||
|
||||
export const DESKTOP_MENU_ACTION_PENDING_EVENT = "desktop-menu-action-pending";
|
||||
const TRAY_STATUS_REFRESH_INTERVAL_MS = 5_000;
|
||||
|
||||
export type DesktopMenuAction = "new-session" | "open-settings";
|
||||
export type DesktopMenuAction = "new-session" | "open-settings" | AppZoomAction;
|
||||
|
||||
type ProcessContext = {
|
||||
runningSessionCount?: unknown;
|
||||
@@ -15,7 +16,11 @@ type ProcessContext = {
|
||||
};
|
||||
|
||||
function isDesktopMenuAction(value: unknown): value is DesktopMenuAction {
|
||||
return value === "new-session" || value === "open-settings";
|
||||
return (
|
||||
value === "new-session" ||
|
||||
value === "open-settings" ||
|
||||
isAppZoomAction(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function subscribeToDesktopMenuActions(
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"build": "bun run build:webview && bun build ./src/extension.ts ./src/hub-daemon.ts --outdir ./dist --target=node --format=cjs --external=vscode --minify",
|
||||
"watch": "bun build ./src/extension.ts ./src/hub-daemon.ts --outdir ./dist --target=node --format=cjs --external=vscode --minify --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"dev": "bun run watch"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { MessageWithMetadata } from "@cline/shared";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("vscode", () => ({}));
|
||||
|
||||
import { mapPersistedMessagesToWebviewMessages } from "./extension";
|
||||
|
||||
describe("mapPersistedMessagesToWebviewMessages", () => {
|
||||
it("hydrates provider activity through the ordinary tool card path", () => {
|
||||
const nativeResult = [
|
||||
{
|
||||
type: "web_search_result",
|
||||
url: "https://bun.sh/blog/bun-v1.3.14",
|
||||
title: "Bun v1.3.14",
|
||||
pageAge: "2026-08-12",
|
||||
encryptedContent: "encrypted",
|
||||
},
|
||||
];
|
||||
const messages: MessageWithMetadata[] = [
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: '<user_input mode="act">Find Bun</user_input>',
|
||||
},
|
||||
{
|
||||
id: "assistant-search",
|
||||
role: "assistant",
|
||||
content: "Bun 1.3.14 is current.",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun" },
|
||||
output: nativeResult,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = mapPersistedMessagesToWebviewMessages(messages, {
|
||||
history: [{ ref: "checkpoint-1", createdAt: 1, runCount: 1 }],
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toMatchObject({
|
||||
role: "user",
|
||||
text: "Find Bun",
|
||||
checkpoint: { ref: "checkpoint-1", runCount: 1 },
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
toolEvents: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
name: "web_search",
|
||||
state: "output-available",
|
||||
input: { query: "latest Bun" },
|
||||
output: nativeResult,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result[2]).toMatchObject({
|
||||
id: "assistant-search",
|
||||
role: "assistant",
|
||||
text: "Bun 1.3.14 is current.",
|
||||
});
|
||||
});
|
||||
|
||||
it("pairs canonical local tool messages across message boundaries", () => {
|
||||
const result = mapPersistedMessagesToWebviewMessages([
|
||||
{
|
||||
id: "local-use",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "read-1",
|
||||
name: "read_files",
|
||||
input: { paths: ["a.ts"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "local-result",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "read-1",
|
||||
name: "read_files",
|
||||
content: "contents",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.toolEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
toolCallId: "read-1",
|
||||
state: "output-available",
|
||||
output: "contents",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps id-less history row ids stable as a provider result completes", () => {
|
||||
const activity = {
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
execution: "provider" as const,
|
||||
input: { query: "latest Bun release" },
|
||||
};
|
||||
const source: MessageWithMetadata = {
|
||||
role: "assistant",
|
||||
content: "Bun 1.3.14 is current.",
|
||||
metadata: {
|
||||
modelToolActivities: [activity],
|
||||
},
|
||||
};
|
||||
|
||||
const pending = mapPersistedMessagesToWebviewMessages([source]);
|
||||
const completed = mapPersistedMessagesToWebviewMessages([
|
||||
{
|
||||
...source,
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
...activity,
|
||||
output: "1.3.14",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(pending.at(-1)?.id).toBe("history-0");
|
||||
expect(completed.at(-1)?.id).toBe("history-0");
|
||||
});
|
||||
});
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
createConfiguredTelemetryService,
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
ensureHubWebSocketServer,
|
||||
getUserRunSpan,
|
||||
type ITelemetryService,
|
||||
Llms,
|
||||
NodeHubClient,
|
||||
type ProviderModel,
|
||||
ProviderSettingsManager,
|
||||
probeHubServer,
|
||||
projectSessionMessagesForDisplay,
|
||||
type RuntimeCapabilities,
|
||||
readHubDiscovery,
|
||||
rememberRecoverableLocalHubUrl,
|
||||
@@ -25,6 +27,8 @@ import {
|
||||
buildClineSystemPrompt,
|
||||
createClineTelemetryServiceConfig,
|
||||
createClineTelemetryServiceMetadata,
|
||||
formatDisplayUserInput,
|
||||
type MessageWithMetadata,
|
||||
} from "@cline/shared";
|
||||
import * as vscode from "vscode";
|
||||
import { displayName, version } from "../package.json";
|
||||
@@ -381,40 +385,22 @@ function readCheckpointEntriesByRunCount(
|
||||
return entries;
|
||||
}
|
||||
|
||||
type PersistedMessage = {
|
||||
id?: string;
|
||||
role?: string;
|
||||
content?:
|
||||
| string
|
||||
| Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "reasoning"; text: string; redacted?: boolean }
|
||||
| {
|
||||
type: "tool-call";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input?: unknown;
|
||||
}
|
||||
| {
|
||||
type: "tool-result";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
output?: unknown;
|
||||
isError?: boolean;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
function mapPersistedMessagesToWebviewMessages(
|
||||
messages: PersistedMessage[],
|
||||
export function mapPersistedMessagesToWebviewMessages(
|
||||
messages: MessageWithMetadata[],
|
||||
checkpointMetadata?: unknown,
|
||||
): WebviewChatMessage[] {
|
||||
const checkpointsByRunCount =
|
||||
readCheckpointEntriesByRunCount(checkpointMetadata);
|
||||
const mapped: WebviewChatMessage[] = [];
|
||||
const toolLocations = new Map<
|
||||
string,
|
||||
{ messageIndex: number; blockIndex: number }
|
||||
>();
|
||||
let userRunCount = 0;
|
||||
|
||||
return messages.flatMap((message, messageIndex) => {
|
||||
const messageKey = message.id ?? messageIndex;
|
||||
for (const entry of projectSessionMessagesForDisplay(messages)) {
|
||||
const { message, sourceIndex } = entry;
|
||||
const messageKey = message.id ?? sourceIndex;
|
||||
const textParts: string[] = [];
|
||||
const reasoningParts: string[] = [];
|
||||
let reasoningRedacted = false;
|
||||
@@ -423,6 +409,9 @@ function mapPersistedMessagesToWebviewMessages(
|
||||
string,
|
||||
NonNullable<WebviewChatMessage["toolEvents"]>[number]
|
||||
>();
|
||||
const currentToolBlockIndexes = new Map<string, number>();
|
||||
const userRunSpan = getUserRunSpan(message);
|
||||
userRunCount += userRunSpan;
|
||||
|
||||
const parts = Array.isArray(message.content)
|
||||
? message.content
|
||||
@@ -430,10 +419,16 @@ function mapPersistedMessagesToWebviewMessages(
|
||||
? [{ type: "text" as const, text: message.content.trim() }]
|
||||
: [];
|
||||
|
||||
for (const [partIndex, part] of parts.entries()) {
|
||||
switch (part.type) {
|
||||
for (const [partIndex, rawPart] of parts.entries()) {
|
||||
const part = rawPart as unknown as Record<string, unknown>;
|
||||
const type = typeof part.type === "string" ? part.type : "";
|
||||
switch (type) {
|
||||
case "text": {
|
||||
const text = part.text.trim();
|
||||
const rawText = typeof part.text === "string" ? part.text : "";
|
||||
const text =
|
||||
message.role === "user"
|
||||
? formatDisplayUserInput(rawText).trim()
|
||||
: rawText.trim();
|
||||
if (!text) break;
|
||||
textParts.push(text);
|
||||
blocks.push({
|
||||
@@ -443,66 +438,135 @@ function mapPersistedMessagesToWebviewMessages(
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "thinking":
|
||||
case "reasoning": {
|
||||
if (!part.text.trim()) break;
|
||||
reasoningParts.push(part.text);
|
||||
const reasoning =
|
||||
type === "thinking"
|
||||
? typeof part.thinking === "string"
|
||||
? part.thinking
|
||||
: ""
|
||||
: typeof part.text === "string"
|
||||
? part.text
|
||||
: "";
|
||||
if (!reasoning.trim()) break;
|
||||
reasoningParts.push(reasoning);
|
||||
blocks.push({
|
||||
id: `${messageKey}:reasoning:${partIndex}`,
|
||||
type: "reasoning",
|
||||
text: part.text,
|
||||
redacted: part.redacted,
|
||||
text: reasoning,
|
||||
redacted: part.redacted === true || undefined,
|
||||
});
|
||||
reasoningRedacted = reasoningRedacted || part.redacted === true;
|
||||
break;
|
||||
}
|
||||
case "tool-call": {
|
||||
const toolEvent = {
|
||||
id: `${messageKey}:${part.toolCallId}`,
|
||||
toolCallId: part.toolCallId,
|
||||
name: part.toolName,
|
||||
text: `Running ${part.toolName}...`,
|
||||
state: "input-available" as const,
|
||||
input: part.input,
|
||||
};
|
||||
toolEvents.set(part.toolCallId, toolEvent);
|
||||
case "redacted_thinking": {
|
||||
reasoningRedacted = true;
|
||||
blocks.push({
|
||||
id: `${messageKey}:tool:${part.toolCallId}`,
|
||||
type: "tool",
|
||||
toolEvent,
|
||||
id: `${messageKey}:reasoning:${partIndex}`,
|
||||
type: "reasoning",
|
||||
text: "[redacted]",
|
||||
redacted: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "tool-result": {
|
||||
const existing = toolEvents.get(part.toolCallId);
|
||||
case "tool_use":
|
||||
case "tool-call": {
|
||||
const toolCallId =
|
||||
(typeof part.id === "string" && part.id) ||
|
||||
(typeof part.toolCallId === "string" && part.toolCallId) ||
|
||||
`${messageKey}:${partIndex}`;
|
||||
const toolName =
|
||||
(typeof part.name === "string" && part.name) ||
|
||||
(typeof part.toolName === "string" && part.toolName) ||
|
||||
"tool";
|
||||
const toolEvent = {
|
||||
id: existing?.id ?? `${messageKey}:${part.toolCallId}`,
|
||||
toolCallId: part.toolCallId,
|
||||
name: part.toolName,
|
||||
text: part.isError
|
||||
? `${part.toolName} failed`
|
||||
: `${part.toolName} completed`,
|
||||
state: part.isError
|
||||
id: `${messageKey}:${toolCallId}`,
|
||||
toolCallId,
|
||||
name: toolName,
|
||||
text: `Running ${toolName}...`,
|
||||
state: "input-available" as const,
|
||||
input: part.input,
|
||||
};
|
||||
toolEvents.set(toolCallId, toolEvent);
|
||||
blocks.push({
|
||||
id: `${messageKey}:tool:${toolCallId}`,
|
||||
type: "tool",
|
||||
toolEvent,
|
||||
});
|
||||
currentToolBlockIndexes.set(toolCallId, blocks.length - 1);
|
||||
toolLocations.set(toolCallId, {
|
||||
messageIndex: mapped.length,
|
||||
blockIndex: blocks.length - 1,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "tool_result":
|
||||
case "tool-result": {
|
||||
const toolCallId =
|
||||
(typeof part.tool_use_id === "string" && part.tool_use_id) ||
|
||||
(typeof part.toolCallId === "string" && part.toolCallId) ||
|
||||
`${messageKey}:${partIndex}`;
|
||||
const toolName =
|
||||
(typeof part.name === "string" && part.name) ||
|
||||
(typeof part.toolName === "string" && part.toolName) ||
|
||||
"tool";
|
||||
const output = type === "tool_result" ? part.content : part.output;
|
||||
const isError = part.is_error === true || part.isError === true;
|
||||
const currentBlockIndex = currentToolBlockIndexes.get(toolCallId);
|
||||
const existingLocation = toolLocations.get(toolCallId);
|
||||
const existingBlock =
|
||||
currentBlockIndex !== undefined
|
||||
? blocks[currentBlockIndex]
|
||||
: existingLocation
|
||||
? mapped[existingLocation.messageIndex]?.blocks?.[
|
||||
existingLocation.blockIndex
|
||||
]
|
||||
: undefined;
|
||||
const existing =
|
||||
existingBlock?.type === "tool"
|
||||
? existingBlock.toolEvent
|
||||
: undefined;
|
||||
const toolEvent = {
|
||||
id: existing?.id ?? `${messageKey}:${toolCallId}`,
|
||||
toolCallId,
|
||||
name: existing?.name ?? toolName,
|
||||
text: isError
|
||||
? `${existing?.name ?? toolName} failed`
|
||||
: `${existing?.name ?? toolName} completed`,
|
||||
state: isError
|
||||
? ("output-error" as const)
|
||||
: ("output-available" as const),
|
||||
input: existing?.input,
|
||||
output: part.output,
|
||||
error: part.isError ? stringifyContent(part.output) : undefined,
|
||||
output,
|
||||
error: isError ? stringifyContent(output) : undefined,
|
||||
};
|
||||
toolEvents.set(part.toolCallId, toolEvent);
|
||||
const blockId = `${messageKey}:tool:${part.toolCallId}`;
|
||||
const existingBlockIndex = blocks.findIndex(
|
||||
(block) =>
|
||||
block.type === "tool" &&
|
||||
block.toolEvent.toolCallId === part.toolCallId,
|
||||
);
|
||||
if (existingBlockIndex >= 0) {
|
||||
blocks[existingBlockIndex] = {
|
||||
id: blockId,
|
||||
if (currentBlockIndex !== undefined) {
|
||||
blocks[currentBlockIndex] = {
|
||||
...blocks[currentBlockIndex],
|
||||
type: "tool",
|
||||
toolEvent,
|
||||
};
|
||||
toolEvents.set(toolCallId, toolEvent);
|
||||
} else if (existingLocation) {
|
||||
const target = mapped[existingLocation.messageIndex];
|
||||
const targetBlocks = target?.blocks;
|
||||
if (target && targetBlocks) {
|
||||
targetBlocks[existingLocation.blockIndex] = {
|
||||
id: `${target.id}:tool:${toolCallId}`,
|
||||
type: "tool",
|
||||
toolEvent,
|
||||
};
|
||||
target.toolEvents = (target.toolEvents ?? []).map((event) =>
|
||||
event.toolCallId === toolCallId ? toolEvent : event,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
blocks.push({ id: blockId, type: "tool", toolEvent });
|
||||
toolEvents.set(toolCallId, toolEvent);
|
||||
blocks.push({
|
||||
id: `${messageKey}:tool:${toolCallId}`,
|
||||
type: "tool",
|
||||
toolEvent,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -512,7 +576,7 @@ function mapPersistedMessagesToWebviewMessages(
|
||||
const text = textParts.join("\n");
|
||||
const toolEventList = [...toolEvents.values()];
|
||||
if (!text && reasoningParts.length === 0 && toolEventList.length === 0) {
|
||||
return [];
|
||||
continue;
|
||||
}
|
||||
const role =
|
||||
message.role === "user"
|
||||
@@ -521,22 +585,24 @@ function mapPersistedMessagesToWebviewMessages(
|
||||
? "assistant"
|
||||
: "meta";
|
||||
const checkpoint =
|
||||
role === "user" ? checkpointsByRunCount.get(++userRunCount) : undefined;
|
||||
role === "user" && userRunSpan === 1
|
||||
? checkpointsByRunCount.get(userRunCount)
|
||||
: undefined;
|
||||
|
||||
return [
|
||||
{
|
||||
id: message.id || `history-${messageIndex}`,
|
||||
role,
|
||||
text,
|
||||
reasoning:
|
||||
reasoningParts.length > 0 ? reasoningParts.join("\n") : undefined,
|
||||
reasoningRedacted: reasoningRedacted || undefined,
|
||||
checkpoint,
|
||||
toolEvents: toolEventList.length > 0 ? toolEventList : undefined,
|
||||
blocks: blocks.length > 0 ? blocks : undefined,
|
||||
},
|
||||
];
|
||||
});
|
||||
mapped.push({
|
||||
id: message.id || `history-${sourceIndex}`,
|
||||
role,
|
||||
text,
|
||||
reasoning:
|
||||
reasoningParts.length > 0 ? reasoningParts.join("\n") : undefined,
|
||||
reasoningRedacted: reasoningRedacted || undefined,
|
||||
checkpoint,
|
||||
toolEvents: toolEventList.length > 0 ? toolEventList : undefined,
|
||||
blocks: blocks.length > 0 ? blocks : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
class CoreChatWebviewController implements vscode.Disposable {
|
||||
@@ -948,9 +1014,7 @@ class CoreChatWebviewController implements vscode.Disposable {
|
||||
role: "participant",
|
||||
metadata: { source: "vscode-webview" },
|
||||
});
|
||||
const persistedMessages = (await host.readMessages(
|
||||
trimmed,
|
||||
)) as PersistedMessage[];
|
||||
const persistedMessages = await host.readMessages(trimmed);
|
||||
this.startConfig = await this.buildStartConfigFromSession(session);
|
||||
await this.post({ type: "session_started", sessionId: trimmed });
|
||||
await this.post({
|
||||
@@ -1273,7 +1337,7 @@ class CoreChatWebviewController implements vscode.Disposable {
|
||||
interactive: true,
|
||||
config: forkStartConfig,
|
||||
toolPolicies: createToolPolicies(forkStartConfig),
|
||||
initialMessages: rawMessages as import("@cline/llms").Message[],
|
||||
initialMessages: rawMessages,
|
||||
sessionMetadata: forkMetadata,
|
||||
});
|
||||
const newSessionId = response.sessionId.trim();
|
||||
@@ -1290,7 +1354,7 @@ class CoreChatWebviewController implements vscode.Disposable {
|
||||
sessionId: newSessionId,
|
||||
status: newSession?.status,
|
||||
messages: mapPersistedMessagesToWebviewMessages(
|
||||
rawMessages as PersistedMessage[],
|
||||
rawMessages,
|
||||
newSession?.metadata?.checkpoint,
|
||||
),
|
||||
});
|
||||
@@ -1388,7 +1452,7 @@ class CoreChatWebviewController implements vscode.Disposable {
|
||||
providerId: newSession?.provider,
|
||||
modelId: newSession?.model,
|
||||
messages: mapPersistedMessagesToWebviewMessages(
|
||||
(restored.messages ?? []) as PersistedMessage[],
|
||||
restored.messages ?? [],
|
||||
newSession?.metadata?.checkpoint,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "4.1.8",
|
||||
"version": "4.1.10",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.101.0"
|
||||
|
||||
@@ -429,6 +429,7 @@ message UpdateSettingsRequest {
|
||||
optional bool worktrees_enabled = 40;
|
||||
optional bool show_feature_tips = 42;
|
||||
optional string compaction_strategy = 44;
|
||||
optional bool web_search_enabled = 45;
|
||||
}
|
||||
|
||||
message UpdateTerminalConnectionTimeoutRequest {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// This allows the SdkController to reuse the classic state-building logic
|
||||
// without inheriting the entire classic Controller implementation.
|
||||
|
||||
import { readCompactionStrategyGlobally } from "@cline/core"
|
||||
import { isModelToolEnabledGlobally, readCompactionStrategyGlobally } from "@cline/core"
|
||||
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
|
||||
import type { ExtensionState, Platform } from "@shared/ExtensionMessage"
|
||||
import { ClineEnv } from "@/config"
|
||||
@@ -43,6 +43,7 @@ export async function getStateToPostToWebview(controller: {
|
||||
const mode = stateManager.getGlobalSettingsKey("mode")
|
||||
const useAutoCondense = stateManager.getGlobalSettingsKey("useAutoCondense")
|
||||
const compactionStrategy = readCompactionStrategyGlobally()
|
||||
const webSearchEnabled = isModelToolEnabledGlobally("web_search")
|
||||
const subagentsEnabled = stateManager.getGlobalSettingsKey("subagentsEnabled")
|
||||
const userInfo = stateManager.getGlobalStateKey("userInfo")
|
||||
const mcpMarketplaceEnabled = stateManager.getGlobalStateKey("mcpMarketplaceEnabled")
|
||||
@@ -120,6 +121,7 @@ export async function getStateToPostToWebview(controller: {
|
||||
mode,
|
||||
useAutoCondense,
|
||||
compactionStrategy,
|
||||
webSearchEnabled,
|
||||
subagentsEnabled,
|
||||
userInfo,
|
||||
mcpMarketplaceEnabled,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { setCompactionStrategyGlobally } from "@cline/core"
|
||||
import { setCompactionStrategyGlobally, setModelToolEnabledGlobally } from "@cline/core"
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import { PlanActMode, McpDisplayMode as ProtoMcpDisplayMode, UpdateSettingsRequest } from "@shared/proto/cline/state"
|
||||
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
|
||||
@@ -167,6 +167,11 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
controller.stateManager.setGlobalState("useAutoCondense", request.useAutoCondense)
|
||||
}
|
||||
|
||||
// Update web search setting (stored in the SDK global settings file; applied when the next session is built)
|
||||
if (request.webSearchEnabled !== undefined) {
|
||||
setModelToolEnabledGlobally("web_search", !!request.webSearchEnabled)
|
||||
}
|
||||
|
||||
if (request.compactionStrategy !== undefined) {
|
||||
const strategy = request.compactionStrategy
|
||||
if (strategy !== "basic" && strategy !== "agentic") {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CoreSessionEvent } from "@cline/core"
|
||||
import type { Message as SdkMessage } from "@cline/llms"
|
||||
import type { AgentEvent } from "@cline/shared"
|
||||
import type { AgentEvent, MessageWithMetadata } from "@cline/shared"
|
||||
import type { ClineAskUseMcpServer, ClineSayTool } from "@shared/ExtensionMessage"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getDesktopDir } from "@/utils/path"
|
||||
@@ -4000,4 +4000,38 @@ describe("tool display paths are relativized to the cwd", () => {
|
||||
expect(toolMessage).toBeDefined()
|
||||
expect(parseTool(toolMessage?.text).path).toBe("src/index.ts")
|
||||
})
|
||||
|
||||
it("renders provider model activities through the persisted local-tool path", () => {
|
||||
const messages: MessageWithMetadata[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Bun 1.3.14 is current.",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest Bun release" },
|
||||
output: "Bun 1.3.14",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const clineMessages = sdkMessagesToClineMessages(messages)
|
||||
const toolMessage = clineMessages.find((message) => message.say === "tool")
|
||||
|
||||
expect(toolMessage).toBeDefined()
|
||||
expect(parseTool(toolMessage?.text)).toMatchObject({
|
||||
tool: "webSearch",
|
||||
})
|
||||
expect(clineMessages).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "say",
|
||||
text: "Bun 1.3.14 is current.",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
// - SDK "ended" event → finalizes the session
|
||||
|
||||
import type { CoreSessionEvent } from "@cline/core"
|
||||
import { PATCH_MARKERS } from "@cline/core"
|
||||
import type { Message as SdkMessage } from "@cline/llms"
|
||||
import { PATCH_MARKERS, projectSessionMessagesForDisplay } from "@cline/core"
|
||||
import type { MessageWithMetadata as SdkMessage } from "@cline/llms"
|
||||
import { type AgentEvent, formatDisplayUserInput } from "@cline/shared"
|
||||
import { COMMAND_OUTPUT_STRING } from "@shared/combineCommandSequences"
|
||||
import type {
|
||||
@@ -2138,13 +2138,6 @@ export function translateSessionEvent(event: CoreSessionEvent, state: MessageTra
|
||||
type SdkContentBlock = Exclude<SdkMessage["content"], string>[number]
|
||||
type SdkToolUseBlock = Extract<SdkContentBlock, { type: "tool_use" }>
|
||||
type SdkMessageWithMetrics = SdkMessage & {
|
||||
metrics?: {
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheWriteTokens?: number
|
||||
cost?: number
|
||||
}
|
||||
/**
|
||||
* Plan/act mode recovered from the persisted <user_input mode="..."> wrapper before display
|
||||
* sanitization strips it (see sanitizeSdkUserMessagesForDisplay in sdk-task-history.ts).
|
||||
@@ -2334,7 +2327,8 @@ export function sdkMessagesToClineMessages(
|
||||
state.clearTurnOutcome()
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
for (const { message, sourceIndex } of projectSessionMessagesForDisplay(messages)) {
|
||||
const sourceMessage = messages[sourceIndex]
|
||||
if (message.role === "assistant") {
|
||||
flushUnmatchedToolUses()
|
||||
|
||||
@@ -2401,7 +2395,7 @@ export function sdkMessagesToClineMessages(
|
||||
// (task resumption, plan -> act auto-continue) still advance the turn/mode
|
||||
// state but never had a visible bubble live, so don't emit one here either.
|
||||
state.clearTurnOutcome()
|
||||
currentMode = message.uiMode ?? currentMode
|
||||
currentMode = sourceMessage.uiMode ?? currentMode
|
||||
if (!isSyntheticSdkUserMessage(message)) {
|
||||
clineMessages.push({
|
||||
ts: state.nextTs(),
|
||||
@@ -2418,7 +2412,7 @@ export function sdkMessagesToClineMessages(
|
||||
const userText = textContentBlocksToText(message.content)
|
||||
if (userText) {
|
||||
state.clearTurnOutcome()
|
||||
currentMode = message.uiMode ?? currentMode
|
||||
currentMode = sourceMessage.uiMode ?? currentMode
|
||||
if (!isSyntheticSdkUserMessage(message)) {
|
||||
clineMessages.push({
|
||||
ts: state.nextTs(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { existsSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import type { ClineCoreListHistoryOptions, SessionHistoryRecord } from "@cline/core"
|
||||
import type { Message as SdkMessage } from "@cline/llms"
|
||||
import type { MessageWithMetadata as SdkMessage } from "@cline/llms"
|
||||
import { formatDisplayUserInput, parseUserInputMode } from "@cline/shared"
|
||||
import { resolveSessionDataDir } from "@cline/shared/storage"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
|
||||
@@ -118,6 +118,7 @@ export interface ExtensionState {
|
||||
mcpResponsesCollapsed?: boolean
|
||||
useAutoCondense?: boolean
|
||||
compactionStrategy?: string
|
||||
webSearchEnabled?: boolean
|
||||
subagentsEnabled?: boolean
|
||||
worktreesEnabled?: ClineFeatureSetting
|
||||
favoritedModelIds: string[]
|
||||
|
||||
@@ -84,6 +84,28 @@ export function setCompactionStrategyGlobally(compactionStrategy: GlobalCompacti
|
||||
}
|
||||
}
|
||||
|
||||
export type ModelToolName = "web_search"
|
||||
|
||||
export function isModelToolEnabledGlobally(name: ModelToolName): boolean {
|
||||
try {
|
||||
const settings = JSON.parse(readFileSync(process.env.CLINE_GLOBAL_SETTINGS_PATH ?? "", "utf8"))
|
||||
return settings.tools?.[name]?.enabled === true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function setModelToolEnabledGlobally(name: ModelToolName, enabled: boolean): void {
|
||||
const filePath = process.env.CLINE_GLOBAL_SETTINGS_PATH
|
||||
if (filePath) {
|
||||
let settings: { tools?: Record<string, { enabled: boolean }> } = {}
|
||||
try {
|
||||
settings = JSON.parse(readFileSync(filePath, "utf8"))
|
||||
} catch {}
|
||||
writeFileSync(filePath, JSON.stringify({ ...settings, tools: { ...settings.tools, [name]: { enabled } } }))
|
||||
}
|
||||
}
|
||||
|
||||
export function truncateCommandOutput(output: string): string {
|
||||
return output
|
||||
}
|
||||
@@ -119,6 +141,7 @@ export { PATCH_MARKERS, PatchActionType } from "../../../../sdk/packages/core/sr
|
||||
export { createEditorExecutor } from "../../../../sdk/packages/core/src/extensions/tools/executors/editor"
|
||||
export type { EditFileInput } from "../../../../sdk/packages/core/src/extensions/tools/schemas"
|
||||
export type { ApplyPatchExecutor, EditorExecutor, ToolExecutors } from "../../../../sdk/packages/core/src/extensions/tools/types"
|
||||
export { projectSessionMessagesForDisplay } from "../../../../sdk/packages/core/src/session/display-messages"
|
||||
|
||||
// Real file-read executor (dependency-light: node:fs/node:path + @cline/shared/storage)
|
||||
// so the workspace read override and its tests exercise the actual read semantics.
|
||||
|
||||
@@ -143,6 +143,7 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
|
||||
mcpDisplayMode,
|
||||
useAutoCondense,
|
||||
compactionStrategy,
|
||||
webSearchEnabled,
|
||||
subagentsEnabled,
|
||||
worktreesEnabled,
|
||||
backgroundEditEnabled,
|
||||
@@ -202,6 +203,12 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<FeatureRow
|
||||
checked={webSearchEnabled}
|
||||
description="Let the model search the web when the selected provider and model support it. Applies to new tasks."
|
||||
label="Web Search"
|
||||
onChange={(checked) => updateSetting("webSearchEnabled", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -223,7 +230,6 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Advanced */}
|
||||
|
||||
@@ -299,6 +299,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
mcpResponsesCollapsed: false, // Default value (expanded), will be overwritten by extension state
|
||||
useAutoCondense: true,
|
||||
compactionStrategy: "basic",
|
||||
webSearchEnabled: false,
|
||||
subagentsEnabled: false,
|
||||
worktreesEnabled: { user: true, featureFlag: false },
|
||||
favoritedModelIds: [],
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@
|
||||
"code": "bun --conditions=development -F @cline/code dev",
|
||||
"clean": "bun run sdk/scripts/clean.ts",
|
||||
"types": "bun --parallel -F '*' typecheck",
|
||||
"test": "bun --parallel -F './sdk/packages/**' -F @cline/cli -F @cline/cline-hub test",
|
||||
"test:unit": "bash -lc 'set -euo pipefail; bun -F @cline/agents test & p1=$!; bun -F @cline/llms test & p2=$!; bun -F @cline/core test:unit & p3=$!; bun -F @cline/cli test:unit & p4=$!; bun -F @cline/cline-hub test & p5=$!; wait $p1; wait $p2; wait $p3; wait $p4; wait $p5'",
|
||||
"test": "bun --parallel -F './sdk/packages/**' -F @cline/cli -F @cline/cline-hub -F @cline/vscode test",
|
||||
"test:unit": "bash -lc 'set -euo pipefail; bun -F @cline/agents test & p1=$!; bun -F @cline/llms test & p2=$!; bun -F @cline/core test:unit & p3=$!; bun -F @cline/cli test:unit & p4=$!; bun -F @cline/cline-hub test & p5=$!; bun -F @cline/vscode test & p6=$!; wait $p1; wait $p2; wait $p3; wait $p4; wait $p5; wait $p6'",
|
||||
"test:e2e": "bun -F @cline/core test:e2e && bun -F @cline/cli test:e2e",
|
||||
"test:e2e:interactive": "bun -F @cline/cli test:e2e:interactive",
|
||||
"verify:routines": "zsh -lc 'cd sdk/packages/core && bunx vitest run src/cron/schedule-service.test.ts --config vitest.config.ts'",
|
||||
|
||||
@@ -404,6 +404,25 @@ Design implications:
|
||||
- avoid mixing config discovery code into runtime/plugin code
|
||||
- avoid creating thin runtime wrapper files when a helper is fundamentally projecting watcher state
|
||||
|
||||
Sandboxed plugin subprocesses are session-local but lazily recreatable. Core
|
||||
reclaims a sandbox after 30 minutes without an in-flight RPC call (configurable
|
||||
through `PluginSandboxOptions.idleTimeoutMs` or
|
||||
`CLINE_PLUGIN_IDLE_TIMEOUT_MS`), and the next plugin call starts and
|
||||
reinitializes it transparently. Pending requests are associated with the child
|
||||
generation that owns them so an old process exiting cannot reject work sent to
|
||||
its replacement. The bootstrap also exits when its parent IPC channel
|
||||
disconnects. The parent is the single authority for idle shutdown so competing
|
||||
deadlines cannot terminate a child while the parent is dispatching new work.
|
||||
|
||||
Design implications:
|
||||
|
||||
- sandbox process count scales with recently active sessions, not every session
|
||||
observed since hub startup
|
||||
- eviction never interrupts an in-flight plugin call
|
||||
- in-process plugin state is ephemeral across idle eviction; durable plugin
|
||||
state belongs in persistent storage
|
||||
- a sandbox must never outlive its owning hub process
|
||||
|
||||
## Architectural Constraints
|
||||
|
||||
### Keep `agents` Stateless
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# Cline SDK Changelog
|
||||
|
||||
## 0.0.75
|
||||
|
||||
- Added provider-executed web search. Models that support it can search the web during a turn, and the search calls and their results are persisted in session history so they replay on reload. Off by default; enable the `web_search` model tool in settings
|
||||
- Added a dedicated Cline provider for the Cline gateway, replacing the generic OpenAI-compatible path. Extended thinking budgets and other gateway options now reach the wire for both `cline` and `cline-pass`, which had silently stopped applying to `cline-pass`
|
||||
- Fixed two Cline installations on different builds shutting each other's Hub daemon down in a loop, killing every live session with an abnormal socket close. Build identity is now compared through a total order, so at most one side of a pair can ever decide to retire the other
|
||||
- A newer build no longer replaces a Hub that is serving live sessions — it attaches over the compatible wire protocol and the swap happens once the Hub is idle, instead of the sessions dying mid-handshake
|
||||
- Development builds now run their own Hub daemon per build id instead of contending for a single record; production keeps its singleton
|
||||
- Idle plugin sandbox processes are now reclaimed instead of lingering for the life of the session
|
||||
- `cline doctor fix` now reports honestly: processes that survived a kill are separated from ones that appeared while the fix ran, a live parent respawning a daemon is named, and a startup lock held by a running process is reported as held rather than leaked
|
||||
- Refreshed the model catalog, which adds Crusoe as a provider and updates model lists and per-provider default models across the board
|
||||
|
||||
## 0.0.74
|
||||
|
||||
- Fixed the Claude Code provider being unusable for agentic work: the provider now declares its own native tools instead of receiving Cline's unbridgeable tool definitions, the session is anchored on the workspace directory instead of inheriting the host's cwd, and `~/.claude` plus project settings are loaded so user-configured permission rules apply. File edits under the workspace are auto-approved; command execution stays gated by your own Claude settings
|
||||
|
||||
@@ -146,6 +146,19 @@ The setup `ctx` may include `session`, `client`, `user`, `workspaceInfo`, `autom
|
||||
|
||||
`ctx.telemetry` works in both plugin execution modes: in-process plugins receive the host telemetry service directly, and sandboxed plugins receive a bridge that forwards `capture`/`captureRequired`/`recordCounter`/`recordHistogram`/`recordGauge` calls to the host over IPC (pass only JSON-serializable properties). The host namespaces every plugin event and metric under `plugin.` and stamps it with `plugin_name`, and drops them when the user has opted out of telemetry. Always feature-detect it (`ctx.telemetry?.capture(...)`) — it is undefined when the host has no telemetry service. One sandbox caveat: the bridge is stateless, so `isEnabled()` always reports `true` and opted-out events are simply dropped host-side — do not use it to gate expensive property computation, and keep telemetry properties cheap to build. Identity setters (`setDistinctId`, `setCommonProperties`, …) are host concerns and are no-ops in the sandbox. See [`weather-metrics.ts`](./weather-metrics.ts) for usage.
|
||||
|
||||
### Sandboxed plugin lifetime
|
||||
|
||||
Discovered plugins run in a session-owned subprocess. An idle subprocess is
|
||||
reclaimed after 30 minutes without an active plugin call, and the next tool,
|
||||
hook, command, rule, or message-builder call transparently starts a fresh
|
||||
process and runs plugin setup again. Hosts can tune the idle period with
|
||||
`CLINE_PLUGIN_IDLE_TIMEOUT_MS` (milliseconds). Active calls are never evicted.
|
||||
|
||||
Treat module-level variables as a cache, not durable storage: they reset after
|
||||
idle eviction, a hub restart, or a sandbox crash. Persist state that must survive
|
||||
those boundaries to disk or another durable store. Plugin setup should remain
|
||||
safe to run again for the same session.
|
||||
|
||||
## Runtime hooks
|
||||
|
||||
Hooks are typed, in-process callbacks on the same hook layer as `@cline/agents`. They run inside the agent loop with full type information.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/agents",
|
||||
"version": "0.0.74",
|
||||
"version": "0.0.75",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -107,6 +107,52 @@ describe("AgentRuntime", () => {
|
||||
expect(model.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("streams and persists model-tool activity without local execution", async () => {
|
||||
const model = new ScriptedModel([
|
||||
() => [
|
||||
{
|
||||
type: "tool-call-delta",
|
||||
toolCallId: "search_1",
|
||||
toolName: "web_search",
|
||||
execution: "client",
|
||||
input: { query: "current weather" },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "search_1",
|
||||
toolName: "web_search",
|
||||
execution: "client",
|
||||
output: { results: [{ url: "https://example.com" }] },
|
||||
},
|
||||
{ type: "text-delta", text: "It is sunny." },
|
||||
{ type: "finish", reason: "stop" },
|
||||
],
|
||||
]);
|
||||
const runtime = new AgentRuntime({ model });
|
||||
const eventTypes: string[] = [];
|
||||
runtime.subscribe((event) => eventTypes.push(event.type));
|
||||
|
||||
const result = await runtime.run("Check the weather");
|
||||
|
||||
expect(model.requests).toHaveLength(1);
|
||||
expect(result.messages.some((message) => message.role === "tool")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(result.messages.at(-1)?.metadata).toEqual({
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search_1",
|
||||
toolName: "web_search",
|
||||
execution: "client",
|
||||
input: { query: "current weather" },
|
||||
output: { results: [{ url: "https://example.com" }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(eventTypes).toContain("tool-started");
|
||||
expect(eventTypes).toContain("tool-finished");
|
||||
});
|
||||
|
||||
it("fails a turn that hits the model output token limit before completion", async () => {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
AgentModelEvent,
|
||||
AgentModelFinishReason,
|
||||
AgentModelRequest,
|
||||
AgentModelToolActivity,
|
||||
AgentRunResult,
|
||||
AgentRuntimeEvent,
|
||||
AgentRuntimeHooks,
|
||||
@@ -970,6 +971,7 @@ export class AgentRuntime {
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
})),
|
||||
modelTools: this.config.modelTools,
|
||||
signal: this.abortController?.signal,
|
||||
options: mergeModelOptions(this.config.modelOptions, {
|
||||
metadata: modelRequestMetadata,
|
||||
@@ -1043,6 +1045,7 @@ export class AgentRuntime {
|
||||
|
||||
const content: AgentMessagePart[] = [];
|
||||
const toolAssemblies = new Map<string, PendingToolAssembly>();
|
||||
const modelToolActivities = new Map<string, AgentModelToolActivity>();
|
||||
const invalidToolCalls: InvalidToolCall[] = [];
|
||||
const sequence: Array<
|
||||
{ type: "tool"; key: string } | { type: "part"; part: AgentMessagePart }
|
||||
@@ -1105,6 +1108,29 @@ export class AgentRuntime {
|
||||
break;
|
||||
}
|
||||
case "tool-call-delta": {
|
||||
if (event.execution) {
|
||||
const toolCall: AgentToolCallPart = {
|
||||
type: "tool-call",
|
||||
toolCallId: event.toolCallId ?? createUID("model_tool"),
|
||||
toolName: event.toolName ?? "tool",
|
||||
input: event.input,
|
||||
metadata: event.metadata,
|
||||
execution: event.execution,
|
||||
};
|
||||
modelToolActivities.set(toolCall.toolCallId, {
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
execution: event.execution,
|
||||
input: toolCall.input,
|
||||
});
|
||||
await this.emit({
|
||||
type: "tool-started",
|
||||
snapshot: this.snapshot(),
|
||||
iteration: this.state.iteration,
|
||||
toolCall,
|
||||
});
|
||||
break;
|
||||
}
|
||||
const key =
|
||||
event.toolCallId ?? `tool_${event.index ?? nextToolIndex}`;
|
||||
if (event.index == null && event.toolCallId == null) {
|
||||
@@ -1142,6 +1168,43 @@ export class AgentRuntime {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool-result": {
|
||||
const existing = modelToolActivities.get(event.toolCallId);
|
||||
const activity = {
|
||||
...existing,
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
execution: event.execution,
|
||||
input: event.input === undefined ? existing?.input : event.input,
|
||||
output: event.output,
|
||||
isError: event.isError,
|
||||
};
|
||||
modelToolActivities.set(event.toolCallId, activity);
|
||||
const toolCall: AgentToolCallPart = {
|
||||
type: "tool-call",
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
input: activity.input,
|
||||
execution: event.execution,
|
||||
};
|
||||
await this.emit({
|
||||
type: "tool-finished",
|
||||
snapshot: this.snapshot(),
|
||||
iteration: this.state.iteration,
|
||||
toolCall,
|
||||
message: createMessage("tool", [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
output: event.output,
|
||||
isError: event.isError,
|
||||
execution: event.execution,
|
||||
},
|
||||
]),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "file": {
|
||||
// Model-generated file output. Preserved into the assistant
|
||||
// message so a file-only turn is not treated as empty:
|
||||
@@ -1223,10 +1286,17 @@ export class AgentRuntime {
|
||||
});
|
||||
}
|
||||
|
||||
const messageMetadata: Record<string, unknown> = {};
|
||||
if (invalidToolCalls.length > 0) {
|
||||
messageMetadata.invalidToolCalls = invalidToolCalls;
|
||||
}
|
||||
if (modelToolActivities.size > 0) {
|
||||
messageMetadata.modelToolActivities = [...modelToolActivities.values()];
|
||||
}
|
||||
const message = createMessage(
|
||||
"assistant",
|
||||
content,
|
||||
invalidToolCalls.length > 0 ? { invalidToolCalls } : undefined,
|
||||
Object.keys(messageMetadata).length > 0 ? messageMetadata : undefined,
|
||||
);
|
||||
const metrics = usageDelta(usageBeforeModel, this.state.usage);
|
||||
if (metrics) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/core",
|
||||
"description": "Cline Core SDK for Node Runtime",
|
||||
"version": "0.0.74",
|
||||
"version": "0.0.75",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -82,6 +82,62 @@ describe("ClineCore", () => {
|
||||
createRuntimeHostMock.mockReset();
|
||||
});
|
||||
|
||||
it("keeps raw reads canonical and offers an explicit display projection", async () => {
|
||||
const rawMessages = [
|
||||
{
|
||||
id: "assistant-search",
|
||||
role: "assistant" as const,
|
||||
content: "Found it",
|
||||
metadata: {
|
||||
modelToolActivities: [
|
||||
{
|
||||
toolCallId: "search-1",
|
||||
toolName: "web_search",
|
||||
execution: "provider",
|
||||
input: { query: "latest release" },
|
||||
output: "1.3.14",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
const host = {
|
||||
runtimeAddress: undefined,
|
||||
startSession: vi.fn(),
|
||||
runTurn: vi.fn(),
|
||||
restoreSession: vi.fn(),
|
||||
abort: vi.fn(),
|
||||
stopSession: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
listSessions: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
updateSession: vi.fn(),
|
||||
readSessionMessages: vi.fn(async () => rawMessages),
|
||||
dispatchHookEvent: vi.fn(),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
};
|
||||
createRuntimeHostMock.mockResolvedValue(host);
|
||||
const core = await ClineCore.create();
|
||||
|
||||
const displayMessages = await core.readDisplayMessages("session-1");
|
||||
|
||||
expect(displayMessages.map(({ message }) => message.role)).toEqual([
|
||||
"assistant",
|
||||
"user",
|
||||
"assistant",
|
||||
]);
|
||||
expect(displayMessages[0]?.message.content).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "tool_use",
|
||||
id: "search-1",
|
||||
}),
|
||||
]);
|
||||
expect(await core.readMessages("session-1")).toBe(rawMessages);
|
||||
expect(rawMessages[0]?.metadata).toHaveProperty("modelToolActivities");
|
||||
await core.dispose();
|
||||
});
|
||||
|
||||
it("compares a checkpoint to the current workspace through the public SDK API", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-core-compare-"));
|
||||
let core: ClineCore | undefined;
|
||||
|
||||
@@ -51,6 +51,10 @@ import {
|
||||
} from "./services/feature-flags";
|
||||
import { resolveCoreDistinctId } from "./services/telemetry/distinct-id";
|
||||
import { compareCheckpointToWorkspace } from "./session/checkpoint-diff";
|
||||
import {
|
||||
projectSessionMessagesForDisplay,
|
||||
type SessionDisplayMessage,
|
||||
} from "./session/display-messages";
|
||||
import type { CoreSessionEvent } from "./types/events";
|
||||
import type { SessionHistoryRecord } from "./types/sessions";
|
||||
|
||||
@@ -505,10 +509,12 @@ export class ClineCore {
|
||||
...args
|
||||
) => this.host.readSessionCompactionState(...args);
|
||||
/**
|
||||
* Reads message history for a session.
|
||||
* Reads the canonical message history for a session.
|
||||
*
|
||||
* Retrieves the full message transcript for a specific session, including all
|
||||
* user messages, agent responses, and tool interactions.
|
||||
* This is the model/replay representation used by resume, fork, and
|
||||
* compaction. Provider-owned model-tool activity remains observational
|
||||
* metadata here. Use {@link readDisplayMessages} for a UI transcript with
|
||||
* that activity projected into ordinary tool blocks.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
@@ -521,6 +527,20 @@ export class ClineCore {
|
||||
readMessages: RuntimeHost["readSessionMessages"] = (...args) =>
|
||||
this.host.readSessionMessages(...args);
|
||||
|
||||
/**
|
||||
* Reads a transcript projected for presentation. Observational model-tool
|
||||
* activity is represented with the same tool blocks as ordinary local tools.
|
||||
*
|
||||
* Use {@link readMessages} for resume, fork, compaction, or model replay.
|
||||
*/
|
||||
async readDisplayMessages(
|
||||
sessionId: string,
|
||||
): Promise<SessionDisplayMessage[]> {
|
||||
return projectSessionMessagesForDisplay(
|
||||
await this.host.readSessionMessages(sessionId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads message history for a session, preferring the live in-memory
|
||||
* conversation when the session is still resident in this host.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Message } from "@cline/llms";
|
||||
import type { MessageWithMetadata } from "@cline/llms";
|
||||
import type {
|
||||
AgentConfig,
|
||||
AutomationEventEnvelope,
|
||||
@@ -163,7 +163,7 @@ export interface RestoreInput {
|
||||
export interface RestoreResult {
|
||||
sessionId?: string;
|
||||
startResult?: StartSessionResult;
|
||||
messages?: Message[];
|
||||
messages?: MessageWithMetadata[];
|
||||
checkpoint: CheckpointEntry;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
normalizePluginManifest,
|
||||
type PluginManifest,
|
||||
} from "@cline/shared";
|
||||
import { installParentDisconnectGuard } from "../../runtime/tools/subprocess-sandbox-lifecycle";
|
||||
import { importPluginModule } from "./plugin-module-import";
|
||||
import {
|
||||
matchesPluginManifestTargeting,
|
||||
@@ -113,10 +114,7 @@ interface PluginSetupCtx {
|
||||
* setters are host concerns and are intentionally no-ops in the sandbox.
|
||||
*/
|
||||
interface PluginTelemetryBridge {
|
||||
capture(input: {
|
||||
event: string;
|
||||
properties?: Record<string, unknown>;
|
||||
}): void;
|
||||
capture(input: { event: string; properties?: Record<string, unknown> }): void;
|
||||
captureRequired(event: string, properties?: Record<string, unknown>): void;
|
||||
recordCounter(
|
||||
name: string,
|
||||
@@ -887,6 +885,8 @@ const methods: Record<string, (args: never) => Promise<unknown>> = {
|
||||
resolveRuleContent,
|
||||
};
|
||||
|
||||
installParentDisconnectGuard();
|
||||
|
||||
process.on(
|
||||
"message",
|
||||
async (message: {
|
||||
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { loadSandboxedPlugins } from "./plugin-sandbox";
|
||||
import {
|
||||
CLINE_PLUGIN_IDLE_TIMEOUT_MS_ENV,
|
||||
loadSandboxedPlugins,
|
||||
} from "./plugin-sandbox";
|
||||
|
||||
function createApiCapture() {
|
||||
const tools: AgentTool[] = [];
|
||||
@@ -651,6 +654,60 @@ describe("plugin-sandbox", () => {
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
it("reclaims an idle plugin process and reinitializes it on the next call", async () => {
|
||||
const idleDir = await mkdtemp(join(tmpdir(), "core-plugin-sandbox-idle-"));
|
||||
let sandboxed: Awaited<ReturnType<typeof loadSandboxedPlugins>> | undefined;
|
||||
try {
|
||||
const pluginPath = join(idleDir, "plugin-idle.mjs");
|
||||
await writeFile(
|
||||
pluginPath,
|
||||
[
|
||||
"export default {",
|
||||
" name: 'sandbox-idle',",
|
||||
" manifest: { capabilities: ['tools'] },",
|
||||
" setup(api) {",
|
||||
" api.registerTool({",
|
||||
" name: 'sandbox_process_id',",
|
||||
" description: 'return the sandbox process id',",
|
||||
" inputSchema: { type: 'object', properties: {}, required: [] },",
|
||||
" execute: async () => ({ pid: process.pid }),",
|
||||
" });",
|
||||
" },",
|
||||
"};",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
vi.stubEnv(CLINE_PLUGIN_IDLE_TIMEOUT_MS_ENV, "1000");
|
||||
vi.useFakeTimers();
|
||||
sandboxed = await loadSandboxedPlugins({
|
||||
pluginPaths: [pluginPath],
|
||||
importTimeoutMs: 30_000,
|
||||
});
|
||||
const { tools, api } = createApiCapture();
|
||||
await sandboxed.extensions?.[0]?.setup?.(api, {});
|
||||
const tool = tools.find((entry) => entry.name === "sandbox_process_id");
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
const context = {
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
} as AgentToolContext;
|
||||
const first = (await tool?.execute({}, context)) as { pid: number };
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
|
||||
const second = (await tool?.execute({}, context)) as { pid: number };
|
||||
expect(second.pid).not.toBe(first.pid);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
await sandboxed?.shutdown();
|
||||
await rm(idleDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it("forwards sandbox plugin events to the host", async () => {
|
||||
const extension = sharedExtensions.get("sandbox-events");
|
||||
const { tools, api } = createApiCapture();
|
||||
|
||||
@@ -15,9 +15,13 @@ import type {
|
||||
WorkspaceInfo,
|
||||
} from "@cline/shared";
|
||||
import { SubprocessSandbox } from "../../runtime/tools/subprocess-sandbox";
|
||||
import { MAX_NODE_TIMER_DELAY_MS } from "../../runtime/tools/subprocess-sandbox-lifecycle";
|
||||
import type { PluginLoadDiagnostics } from "./plugin-load-report";
|
||||
import type { PluginTargeting } from "./plugin-targeting";
|
||||
|
||||
export const CLINE_PLUGIN_IDLE_TIMEOUT_MS_ENV = "CLINE_PLUGIN_IDLE_TIMEOUT_MS";
|
||||
export const DEFAULT_PLUGIN_SANDBOX_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
export type SandboxedPluginSetupContext = Pick<
|
||||
PluginSetupContext,
|
||||
"session" | "client" | "user" | "workspaceInfo" | "logger"
|
||||
@@ -35,6 +39,12 @@ export interface PluginSandboxOptions extends PluginTargeting {
|
||||
importTimeoutMs?: number;
|
||||
hookTimeoutMs?: number;
|
||||
contributionTimeoutMs?: number;
|
||||
/**
|
||||
* Reclaim the plugin subprocess after this much time with no calls in
|
||||
* flight. Defaults to 30 minutes and can be overridden with
|
||||
* `CLINE_PLUGIN_IDLE_TIMEOUT_MS`.
|
||||
*/
|
||||
idleTimeoutMs?: number;
|
||||
onEvent?: (event: { name: string; payload?: unknown }) => void;
|
||||
/**
|
||||
* The session's working directory. Forwarded to the sandbox subprocess so
|
||||
@@ -267,7 +277,12 @@ function withTimeoutFallback(
|
||||
fallback: number,
|
||||
envVarName?: string,
|
||||
): number {
|
||||
if (typeof timeoutMs === "number" && timeoutMs > 0) {
|
||||
if (
|
||||
typeof timeoutMs === "number" &&
|
||||
Number.isInteger(timeoutMs) &&
|
||||
timeoutMs > 0 &&
|
||||
timeoutMs <= MAX_NODE_TIMER_DELAY_MS
|
||||
) {
|
||||
return timeoutMs;
|
||||
}
|
||||
if (envVarName) {
|
||||
@@ -278,7 +293,11 @@ function withTimeoutFallback(
|
||||
// malformed env value falls back to the default instead of
|
||||
// silently consuming its numeric prefix.
|
||||
const parsed = Number(raw);
|
||||
if (Number.isInteger(parsed) && parsed > 0) {
|
||||
if (
|
||||
Number.isInteger(parsed) &&
|
||||
parsed > 0 &&
|
||||
parsed <= MAX_NODE_TIMER_DELAY_MS
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
@@ -295,11 +314,17 @@ export async function loadSandboxedPlugins(
|
||||
shutdown: () => Promise<void>;
|
||||
} & PluginLoadDiagnostics
|
||||
> {
|
||||
const idleTimeoutMs = withTimeoutFallback(
|
||||
options.idleTimeoutMs,
|
||||
DEFAULT_PLUGIN_SANDBOX_IDLE_TIMEOUT_MS,
|
||||
CLINE_PLUGIN_IDLE_TIMEOUT_MS_ENV,
|
||||
);
|
||||
const sandbox = new SubprocessSandbox({
|
||||
name: "plugin-sandbox",
|
||||
...("file" in BOOTSTRAP
|
||||
? { bootstrapFile: BOOTSTRAP.file }
|
||||
: { bootstrapScript: BOOTSTRAP.script }),
|
||||
idleTimeoutMs,
|
||||
onEvent: options.onEvent,
|
||||
});
|
||||
const importTimeoutMs = withTimeoutFallback(
|
||||
@@ -468,9 +493,7 @@ function toJsonSafePayload(
|
||||
ancestors.add(value);
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(
|
||||
(entry) => toJsonSafePayload(entry, ancestors) ?? null,
|
||||
);
|
||||
return value.map((entry) => toJsonSafePayload(entry, ancestors) ?? null);
|
||||
}
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
@@ -548,8 +571,10 @@ function registerTools(
|
||||
// just as capable of smuggling a non-serializable value as the
|
||||
// context is.
|
||||
const invoke = async (payload: unknown) => {
|
||||
const { input: sandboxInput, context: sandboxContext } =
|
||||
payload as { input: unknown; context: unknown };
|
||||
const { input: sandboxInput, context: sandboxContext } = payload as {
|
||||
input: unknown;
|
||||
context: unknown;
|
||||
};
|
||||
try {
|
||||
return await sandbox.call(
|
||||
"executeTool",
|
||||
|
||||
@@ -71,4 +71,29 @@ describe("builtin tool catalog", () => {
|
||||
expect(selected.has("spawn_agent")).toBe(true);
|
||||
expect(getCoreDefaultEnabledToolIds({ mode: "act" })).toContain("teams");
|
||||
});
|
||||
|
||||
it("surfaces native web search only for supported model selections", () => {
|
||||
const anthropic = getCoreBuiltinToolCatalog({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
});
|
||||
expect(
|
||||
anthropic.find((entry) => entry.id === "web_search")?.defaultEnabled,
|
||||
).toBe(false);
|
||||
|
||||
const enabled = getCoreBuiltinToolCatalog({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
enabledModelToolIds: new Set(["web_search"]),
|
||||
});
|
||||
expect(
|
||||
enabled.find((entry) => entry.id === "web_search")?.defaultEnabled,
|
||||
).toBe(true);
|
||||
|
||||
const unsupported = getCoreBuiltinToolCatalog({
|
||||
providerId: "ollama",
|
||||
modelId: "llama3",
|
||||
});
|
||||
expect(unsupported.some((entry) => entry.id === "web_search")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { supportsModelTool } from "@cline/llms";
|
||||
import type { CoreAgentMode } from "../../types/config";
|
||||
import {
|
||||
DEFAULT_MODEL_TOOL_ROUTING_RULES,
|
||||
@@ -22,11 +23,18 @@ export interface BuiltinToolAvailabilityContext {
|
||||
enableSpawnAgent?: boolean;
|
||||
enableAgentTeams?: boolean;
|
||||
disabledToolIds?: ReadonlySet<string>;
|
||||
enabledModelToolIds?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
type RuntimeToolCatalogEntry = Omit<ToolCatalogEntry, "defaultEnabled">;
|
||||
|
||||
const BASE_TOOL_CATALOG: readonly RuntimeToolCatalogEntry[] = [
|
||||
{
|
||||
id: "web_search",
|
||||
description:
|
||||
"Search the public web using the selected model provider's native search capability.",
|
||||
headlessToolNames: ["web_search"],
|
||||
},
|
||||
{
|
||||
id: "read_files",
|
||||
description:
|
||||
@@ -166,6 +174,9 @@ function isEntryEnabledByDefault(
|
||||
if (context.disabledToolIds?.has(entryId)) {
|
||||
return false;
|
||||
}
|
||||
if (entryId === "web_search") {
|
||||
return context.enabledModelToolIds?.has(entryId) === true;
|
||||
}
|
||||
|
||||
const { flags } = resolvePresetFlags(context);
|
||||
if (entryId === "spawn_agent") {
|
||||
@@ -206,7 +217,14 @@ function buildCatalogEntry(
|
||||
export function getCoreBuiltinToolCatalog(
|
||||
context: BuiltinToolAvailabilityContext = {},
|
||||
): ToolCatalogEntry[] {
|
||||
return BASE_TOOL_CATALOG.map((entry) => buildCatalogEntry(entry, context));
|
||||
return BASE_TOOL_CATALOG.filter(
|
||||
(entry) =>
|
||||
entry.id !== "web_search" ||
|
||||
supportsModelTool(
|
||||
{ providerId: context.providerId ?? "", modelId: context.modelId },
|
||||
"web_search",
|
||||
),
|
||||
).map((entry) => buildCatalogEntry(entry, context));
|
||||
}
|
||||
|
||||
export function getCoreDefaultEnabledToolIds(
|
||||
|
||||
@@ -15,6 +15,7 @@ class MockWebSocket {
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
static instances: MockWebSocket[] = [];
|
||||
static commandPayloads = new Map<string, unknown>();
|
||||
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
readonly sentFrames: unknown[] = [];
|
||||
@@ -30,26 +31,28 @@ class MockWebSocket {
|
||||
|
||||
static reset(): void {
|
||||
MockWebSocket.instances = [];
|
||||
MockWebSocket.commandPayloads.clear();
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
const frame = JSON.parse(data) as {
|
||||
kind?: string;
|
||||
envelope?: { requestId?: string };
|
||||
envelope?: { requestId?: string; command?: string };
|
||||
};
|
||||
this.sentFrames.push(frame);
|
||||
if (frame.kind === "command" && frame.envelope?.requestId) {
|
||||
const command = frame.envelope.command ?? "client.register";
|
||||
queueMicrotask(() => {
|
||||
this.emit("message", {
|
||||
data: JSON.stringify({
|
||||
kind: "reply",
|
||||
envelope: {
|
||||
version: "v1",
|
||||
command: "client.register",
|
||||
command,
|
||||
requestId: frame.envelope?.requestId,
|
||||
ok: true,
|
||||
clientId: "hub",
|
||||
payload: {},
|
||||
payload: MockWebSocket.commandPayloads.get(command) ?? {},
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -774,6 +777,7 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
// path and fail locally whenever a stray hub daemon is listening on
|
||||
// the default port (passes in CI only because no daemon is running).
|
||||
beforeEach(() => {
|
||||
MockWebSocket.reset();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
@@ -784,6 +788,65 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
async function resolveShieldedHub(sessions: unknown[]) {
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
MockWebSocket.commandPayloads.set("session.list", { sessions });
|
||||
const discoveryPath = "/tmp/hub-discovery.json";
|
||||
const oldRecord = {
|
||||
hubId: "old-hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "old-build",
|
||||
authToken: "old-token",
|
||||
host: "127.0.0.1",
|
||||
port: 59999,
|
||||
url: "ws://127.0.0.1:59999/hub",
|
||||
pid: 12345,
|
||||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath,
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath,
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../discovery", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../discovery")>("../discovery");
|
||||
return {
|
||||
...actual,
|
||||
resolveHubBuildId: () => "current-build",
|
||||
readHubDiscovery: vi.fn(async (path: string) =>
|
||||
path === `${discoveryPath}.superseded` ? oldRecord : undefined,
|
||||
),
|
||||
probeHubServer: vi.fn(async () => oldRecord),
|
||||
};
|
||||
});
|
||||
|
||||
const { resolveCompatibleLocalHubUrl } = await import(".");
|
||||
return { oldRecord, resolved: await resolveCompatibleLocalHubUrl() };
|
||||
}
|
||||
|
||||
it("uses a shielded Hub while a participant is attached", async () => {
|
||||
const { oldRecord, resolved } = await resolveShieldedHub([
|
||||
{ status: "idle", participants: [{ clientId: "old-cli" }] },
|
||||
]);
|
||||
|
||||
expect(resolved).toBe(oldRecord.url);
|
||||
});
|
||||
|
||||
it("replaces a participant-less running Hub", async () => {
|
||||
const { resolved } = await resolveShieldedHub([
|
||||
{ status: "running", participants: [] },
|
||||
]);
|
||||
|
||||
expect(resolved).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not clear discovery on transient probe failure", async () => {
|
||||
const clearHubDiscoveryMock = vi.fn();
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
@@ -967,7 +1030,7 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
expect(readHubDiscoveryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined and keeps discovery when build metadata is missing", async () => {
|
||||
it("attaches and keeps discovery when build metadata is missing", async () => {
|
||||
const clearHubDiscoveryMock = vi.fn();
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
@@ -1011,7 +1074,13 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
|
||||
const { resolveCompatibleLocalHubUrl } = await import(".");
|
||||
|
||||
await expect(resolveCompatibleLocalHubUrl()).resolves.toBeUndefined();
|
||||
// A Hub carrying no build metadata cannot be ordered against this
|
||||
// build, so it is attached over the compatible wire protocol rather
|
||||
// than retired. Retiring an unorderable peer is what let two installs
|
||||
// shut each other's daemon down in a loop.
|
||||
await expect(resolveCompatibleLocalHubUrl()).resolves.toBe(
|
||||
"ws://127.0.0.1:59999/hub",
|
||||
);
|
||||
expect(clearHubDiscoveryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1319,3 +1388,48 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
expect(readHubDiscoveryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasActiveHubSessions", () => {
|
||||
const payload = (sessions: unknown[]) => ({ sessions });
|
||||
|
||||
it("is idle for an empty or malformed list", async () => {
|
||||
const { hasActiveHubSessions } = await import(".");
|
||||
expect(hasActiveHubSessions(payload([]))).toBe(false);
|
||||
expect(hasActiveHubSessions(undefined)).toBe(false);
|
||||
expect(hasActiveHubSessions(payload([null, "junk"]))).toBe(false);
|
||||
});
|
||||
|
||||
it("is busy while anyone is attached, whatever the status", async () => {
|
||||
const { hasActiveHubSessions } = await import(".");
|
||||
expect(
|
||||
hasActiveHubSessions(
|
||||
payload([{ status: "idle", participants: [{ clientId: "tui" }] }]),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasActiveHubSessions(
|
||||
payload([{ status: "running", participants: [{ clientId: "tui" }] }]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// The session a crashed or killed client leaves behind: a non-terminal
|
||||
// status, nobody attached. Participants are live socket subscriptions, so
|
||||
// a dead client cannot appear here - which is exactly why status must not
|
||||
// be consulted: it stays "running" forever and would pin an outdated hub
|
||||
// as busy until the machine reboots.
|
||||
it("is idle for sessions nobody is attached to, whatever the status", async () => {
|
||||
const { hasActiveHubSessions } = await import(".");
|
||||
expect(
|
||||
hasActiveHubSessions(
|
||||
payload([
|
||||
{ status: "running", participants: [] },
|
||||
{ status: "pending", participants: [] },
|
||||
{ status: "idle", participants: [] },
|
||||
{ status: "completed", participants: [] },
|
||||
{ status: "running" },
|
||||
]),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -865,15 +865,13 @@ async function probeCompatibleHubUrl(
|
||||
};
|
||||
}
|
||||
if (options?.requireCurrentBuild) {
|
||||
// Managed Hubs: reusable when the build matches or the Hub is a newer
|
||||
// build (another installation upgraded it - attach and let the
|
||||
// build-mismatch watcher prompt the user instead of downgrading it).
|
||||
// Managed Hubs: reusable unless this build is strictly newer than the
|
||||
// Hub's. A Hub that is newer or unorderable is attached over the
|
||||
// compatible wire protocol and left to the build-mismatch watcher to
|
||||
// prompt about, so two installations can never retire each other.
|
||||
const expectedBuildId = resolveHubBuildId();
|
||||
const compatibility = getManagedHubCompatibility(record, expectedBuildId);
|
||||
if (
|
||||
!compatibility.compatible &&
|
||||
!isManagedHubReusable(record, { expectedBuildId })
|
||||
) {
|
||||
if (!compatibility.compatible && !isManagedHubReusable(record)) {
|
||||
return {
|
||||
status:
|
||||
compatibility.reason === "unsupported_protocol"
|
||||
@@ -929,7 +927,20 @@ function sameNormalizedHubUrl(left: string, right: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function hasActiveHubSessions(payload: unknown): boolean {
|
||||
/**
|
||||
* Whether any client is attached to a session on the hub - the one signal
|
||||
* that cannot go stale, because participants are live socket subscriptions
|
||||
* the hub drops the moment a client's connection closes.
|
||||
*
|
||||
* Deliberately NOT based on session status: a client that dies without
|
||||
* stopping its session leaves the hub-side runtime behind in a non-terminal
|
||||
* status forever, and counting those "ghost" sessions as busy pins an
|
||||
* outdated hub as "serving sessions" until the machine reboots. The cost of
|
||||
* ignoring status is that a participant-less background run executing at the
|
||||
* exact moment of a hub swap dies with the old hub - rare, and its next
|
||||
* scheduled tick runs normally on the replacement.
|
||||
*/
|
||||
export function hasActiveHubSessions(payload: unknown): boolean {
|
||||
const sessions =
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
@@ -940,22 +951,12 @@ function hasActiveHubSessions(payload: unknown): boolean {
|
||||
if (!session || typeof session !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = session as {
|
||||
status?: unknown;
|
||||
participants?: unknown;
|
||||
};
|
||||
if (
|
||||
record.status === "running" ||
|
||||
record.status === "idle" ||
|
||||
record.status === "pending"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const record = session as { participants?: unknown };
|
||||
return Array.isArray(record.participants) && record.participants.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function localHubHasNoActiveSessions(
|
||||
export async function localHubHasNoActiveSessions(
|
||||
url: string,
|
||||
authToken?: string,
|
||||
options?: Pick<HubClientOptions, "workspaceRoot" | "cwd">,
|
||||
@@ -983,6 +984,32 @@ async function localHubHasNoActiveSessions(
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverSupersededLocalHubUrl(
|
||||
owner: HubOwnerContext,
|
||||
options: LocalHubResolutionOptions,
|
||||
): Promise<string | undefined> {
|
||||
const supersededPath = `${owner.discoveryPath}.superseded`;
|
||||
const superseded = await readHubDiscovery(supersededPath);
|
||||
if (!superseded?.url || !superseded.authToken) {
|
||||
return undefined;
|
||||
}
|
||||
const compatible = await probeCompatibleHubUrl(superseded.url, {
|
||||
authToken: superseded.authToken,
|
||||
});
|
||||
if (compatible.status !== "compatible") {
|
||||
return undefined;
|
||||
}
|
||||
const hasNoActiveSessions = await localHubHasNoActiveSessions(
|
||||
compatible.url,
|
||||
superseded.authToken,
|
||||
options,
|
||||
);
|
||||
if (hasNoActiveSessions) {
|
||||
return undefined;
|
||||
}
|
||||
return rememberRecoverableLocalHubUrl(compatible.url, superseded.authToken);
|
||||
}
|
||||
|
||||
export async function resolveCompatibleLocalHubUrl(
|
||||
options: LocalHubResolutionOptions = {},
|
||||
): Promise<string | undefined> {
|
||||
@@ -994,7 +1021,7 @@ export async function resolveCompatibleLocalHubUrl(
|
||||
const owner = resolveDefaultHubOwnerContext();
|
||||
const record = await readHubDiscovery(owner.discoveryPath);
|
||||
if (!record?.url) {
|
||||
return undefined;
|
||||
return await recoverSupersededLocalHubUrl(owner, options);
|
||||
}
|
||||
const compatible = await probeCompatibleHubUrl(record.url, {
|
||||
authToken: record.authToken,
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("checkManagedHubBuildMismatch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not prompt for an older or unordered hub build (it gets replaced instead)", async () => {
|
||||
it("reports an older hub build as outdated rather than a client update", async () => {
|
||||
vi.stubEnv("CLINE_HUB_BUILD_EPOCH_MS", "1000");
|
||||
mockDiscovery({
|
||||
record: liveRecord,
|
||||
@@ -91,10 +91,15 @@ describe("checkManagedHubBuildMismatch", () => {
|
||||
"./managed-hub-build-watcher"
|
||||
);
|
||||
|
||||
await expect(checkManagedHubBuildMismatch()).resolves.toBeUndefined();
|
||||
await expect(checkManagedHubBuildMismatch()).resolves.toMatchObject({
|
||||
reason: "outdated_hub",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not prompt for a legacy hub without build metadata", async () => {
|
||||
// A Hub carrying no ordering metadata cannot be placed relative to this
|
||||
// build, and updating this client is what supplies the missing metadata, so
|
||||
// it stays a client-update prompt.
|
||||
it("prompts to update the client for a legacy hub without build metadata", async () => {
|
||||
mockDiscovery({
|
||||
record: liveRecord,
|
||||
probe: {
|
||||
@@ -108,7 +113,9 @@ describe("checkManagedHubBuildMismatch", () => {
|
||||
"./managed-hub-build-watcher"
|
||||
);
|
||||
|
||||
await expect(checkManagedHubBuildMismatch()).resolves.toBeUndefined();
|
||||
await expect(checkManagedHubBuildMismatch()).resolves.toMatchObject({
|
||||
reason: "build_mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
it("prompts when the hub protocol is not supported by this client", async () => {
|
||||
@@ -175,6 +182,125 @@ describe("watchManagedHubBuildMismatch", () => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
/**
|
||||
* An older Hub is normally retired within moments of being seen, so
|
||||
* reporting the first sighting would flash a dialog about a Hub that is
|
||||
* already gone. Only a Hub still there on the next check was deliberately
|
||||
* left running.
|
||||
*/
|
||||
it("reports an outdated hub only once it survives a second check", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubEnv("CLINE_HUB_BUILD_EPOCH_MS", "1000");
|
||||
const probeResult: Record<string, unknown> | undefined = {
|
||||
protocolVersion: "v1",
|
||||
buildId: "old-build",
|
||||
buildEpochMs: 500,
|
||||
host: "127.0.0.1",
|
||||
port: 59999,
|
||||
url: "ws://127.0.0.1:59999/hub",
|
||||
};
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-watcher-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-watcher-discovery.json",
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../discovery", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../discovery")>("../discovery");
|
||||
return {
|
||||
...actual,
|
||||
resolveHubBuildId: () => "current-build",
|
||||
readHubDiscovery: vi.fn(async () => liveRecord),
|
||||
probeHubServer: vi.fn(async () => probeResult),
|
||||
};
|
||||
});
|
||||
const { watchManagedHubBuildMismatch } = await import(
|
||||
"./managed-hub-build-watcher"
|
||||
);
|
||||
|
||||
const onMismatch = vi.fn();
|
||||
const stop = watchManagedHubBuildMismatch({
|
||||
onMismatch,
|
||||
intervalMs: 1_000,
|
||||
});
|
||||
try {
|
||||
// First sighting is held back.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(onMismatch).not.toHaveBeenCalled();
|
||||
|
||||
// Still there on the next check: the Hub was left in place.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(onMismatch).toHaveBeenCalledTimes(1);
|
||||
expect(onMismatch).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ reason: "outdated_hub" }),
|
||||
);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("never reports an outdated hub that is replaced right after it is seen", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubEnv("CLINE_HUB_BUILD_EPOCH_MS", "1000");
|
||||
let probeResult: Record<string, unknown> | undefined = {
|
||||
protocolVersion: "v1",
|
||||
buildId: "old-build",
|
||||
buildEpochMs: 500,
|
||||
host: "127.0.0.1",
|
||||
port: 59999,
|
||||
url: "ws://127.0.0.1:59999/hub",
|
||||
};
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-watcher-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-watcher-discovery.json",
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../discovery", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../discovery")>("../discovery");
|
||||
return {
|
||||
...actual,
|
||||
resolveHubBuildId: () => "current-build",
|
||||
readHubDiscovery: vi.fn(async () => liveRecord),
|
||||
probeHubServer: vi.fn(async () => probeResult),
|
||||
};
|
||||
});
|
||||
const { watchManagedHubBuildMismatch } = await import(
|
||||
"./managed-hub-build-watcher"
|
||||
);
|
||||
|
||||
const onMismatch = vi.fn();
|
||||
const stop = watchManagedHubBuildMismatch({
|
||||
onMismatch,
|
||||
intervalMs: 1_000,
|
||||
});
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
// Replaced by this build before the next check.
|
||||
probeResult = {
|
||||
protocolVersion: "v1",
|
||||
buildId: "current-build",
|
||||
host: "127.0.0.1",
|
||||
port: 59999,
|
||||
url: "ws://127.0.0.1:59999/hub",
|
||||
};
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
expect(onMismatch).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("fires once per mismatched hub build and re-arms after recovery", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubEnv("CLINE_HUB_BUILD_EPOCH_MS", "1000");
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { isHubDaemonProcess, resolveClineBuildEnv } from "@cline/shared";
|
||||
import {
|
||||
compareHubBuilds,
|
||||
getManagedHubCompatibility,
|
||||
type HubOwnerContext,
|
||||
isManagedHubReusable,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveHubBuildId,
|
||||
resolveHubBuildIdentity,
|
||||
} from "../discovery";
|
||||
import {
|
||||
resolveProductionHubOwnerContext,
|
||||
@@ -26,9 +27,16 @@ function resolveDefaultWatchIntervalMs(): number {
|
||||
export interface ManagedHubBuildMismatchEvent {
|
||||
/** WebSocket URL of the live managed Hub that does not match this build. */
|
||||
url: string;
|
||||
/** Why this client should update: the running Hub is a newer build this
|
||||
* client stays attached to, or it speaks an unsupported protocol. */
|
||||
reason: "unsupported_protocol" | "build_mismatch";
|
||||
/**
|
||||
* Why client and Hub disagree:
|
||||
* - `build_mismatch`: the running Hub is a newer build this client stays
|
||||
* attached to, and updating this client resolves it.
|
||||
* - `unsupported_protocol`: this client cannot speak the Hub's protocol.
|
||||
* - `outdated_hub`: this client is the newer build, but the running Hub was
|
||||
* left in place because it is still serving sessions. Nothing to install;
|
||||
* the Hub is replaced once those sessions end.
|
||||
*/
|
||||
reason: "unsupported_protocol" | "build_mismatch" | "outdated_hub";
|
||||
/** Build identity reported by the running Hub, when it reports one. */
|
||||
hubBuildId?: string;
|
||||
/** Core package version reported by the running Hub. */
|
||||
@@ -86,11 +94,12 @@ export async function checkManagedHubBuildMismatch(): Promise<
|
||||
if (compatibility.compatible) {
|
||||
return undefined;
|
||||
}
|
||||
// Prompt only for mismatches that persist and that updating this client
|
||||
// resolves: a newer reusable Hub this client stays attached to, or a Hub
|
||||
// whose protocol this client cannot speak at all. Older or unordered
|
||||
// builds are retired and replaced automatically, so prompting would only
|
||||
// flash a stale dialog.
|
||||
// Prompt for mismatches that persist: a newer reusable Hub this client
|
||||
// stays attached to, a Hub whose protocol this client cannot speak, or an
|
||||
// older Hub that was left running because it is serving sessions. An older
|
||||
// idle Hub is retired and replaced automatically, so reporting it here
|
||||
// would only flash a stale dialog - the caller filters that case by
|
||||
// requiring the mismatch to survive consecutive checks.
|
||||
const report = (
|
||||
reason: ManagedHubBuildMismatchEvent["reason"],
|
||||
): ManagedHubBuildMismatchEvent => ({
|
||||
@@ -103,13 +112,15 @@ export async function checkManagedHubBuildMismatch(): Promise<
|
||||
if (compatibility.reason === "unsupported_protocol") {
|
||||
return report("unsupported_protocol");
|
||||
}
|
||||
if (
|
||||
compatibility.reason === "build_mismatch" &&
|
||||
isManagedHubReusable(healthy, { expectedBuildId })
|
||||
) {
|
||||
return report("build_mismatch");
|
||||
// Only a Hub this client is strictly newer than gets the "older Hub" copy;
|
||||
// that is the case the retire path defers while sessions are live. A newer
|
||||
// Hub - or one that carries too little metadata to order, where updating
|
||||
// this client is what supplies the missing ordering - is a client-update
|
||||
// prompt as before.
|
||||
if (compareHubBuilds(resolveHubBuildIdentity(), healthy) > 0) {
|
||||
return report("outdated_hub");
|
||||
}
|
||||
return undefined;
|
||||
return report("build_mismatch");
|
||||
}
|
||||
|
||||
export interface WatchManagedHubBuildOptions {
|
||||
@@ -140,6 +151,7 @@ export function watchManagedHubBuildMismatch(
|
||||
}
|
||||
const intervalMs = options.intervalMs ?? resolveDefaultWatchIntervalMs();
|
||||
let notifiedKey: string | undefined;
|
||||
let pendingKey: string | undefined;
|
||||
let checking = false;
|
||||
const timer = setInterval(() => {
|
||||
if (checking) {
|
||||
@@ -150,12 +162,21 @@ export function watchManagedHubBuildMismatch(
|
||||
.then((mismatch) => {
|
||||
if (!mismatch) {
|
||||
notifiedKey = undefined;
|
||||
pendingKey = undefined;
|
||||
return;
|
||||
}
|
||||
const key = `${mismatch.reason}:${mismatch.hubBuildId ?? ""}`;
|
||||
if (key === notifiedKey) {
|
||||
return;
|
||||
}
|
||||
// An older Hub is normally retired and replaced within a moment
|
||||
// of being observed. Only report one that is still there on the
|
||||
// next check, which means it was deliberately left running.
|
||||
if (mismatch.reason === "outdated_hub" && pendingKey !== key) {
|
||||
pendingKey = key;
|
||||
return;
|
||||
}
|
||||
pendingKey = undefined;
|
||||
notifiedKey = key;
|
||||
options.onMismatch(mismatch);
|
||||
})
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface HubRestoreResponse {
|
||||
manifestPath: string;
|
||||
messagesPath: string;
|
||||
};
|
||||
messages?: LlmsProviders.Message[];
|
||||
messages?: LlmsProviders.MessageWithMetadata[];
|
||||
checkpoint: CheckpointEntry;
|
||||
}
|
||||
|
||||
@@ -436,7 +436,9 @@ export class HubSessionClient {
|
||||
return extractSessionRow(reply.payload);
|
||||
}
|
||||
|
||||
async readMessages(sessionId: string): Promise<LlmsProviders.Message[]> {
|
||||
async readMessages(
|
||||
sessionId: string,
|
||||
): Promise<LlmsProviders.MessageWithMetadata[]> {
|
||||
const target = sessionId.trim();
|
||||
if (!target) {
|
||||
return [];
|
||||
@@ -451,7 +453,9 @@ export class HubSessionClient {
|
||||
throw new Error(hubReplyErrorMessage(reply, "session.messages"));
|
||||
}
|
||||
const messages = reply.payload?.messages;
|
||||
return Array.isArray(messages) ? (messages as LlmsProviders.Message[]) : [];
|
||||
return Array.isArray(messages)
|
||||
? (messages as LlmsProviders.MessageWithMetadata[])
|
||||
: [];
|
||||
}
|
||||
|
||||
async restore(input: HubRestoreRequest): Promise<HubRestoreResponse> {
|
||||
|
||||
@@ -37,6 +37,34 @@ export const hubDaemonReady = new Promise<void>((resolve, reject) => {
|
||||
// readiness promise. Keep startup failures handled by the fatal path below.
|
||||
void hubDaemonReady.catch(() => undefined);
|
||||
|
||||
const HUB_STARTUP_BIND_RETRY_WINDOW_MS = 5_000;
|
||||
const HUB_STARTUP_BIND_RETRY_DELAY_MS = 250;
|
||||
|
||||
function isAddressInUseError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error as Error & { code?: string }).code === "EADDRINUSE"
|
||||
);
|
||||
}
|
||||
|
||||
async function startHubWebSocketServerWithBindRetry(
|
||||
bindDeadline: number,
|
||||
options: Parameters<typeof startHubWebSocketServer>[0],
|
||||
): Promise<Awaited<ReturnType<typeof startHubWebSocketServer>>> {
|
||||
for (;;) {
|
||||
try {
|
||||
return await startHubWebSocketServer(options);
|
||||
} catch (error) {
|
||||
if (!isAddressInUseError(error) || Date.now() >= bindDeadline) {
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, HUB_STARTUP_BIND_RETRY_DELAY_MS),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): {
|
||||
cwd: string;
|
||||
host?: string;
|
||||
@@ -198,9 +226,14 @@ async function main(): Promise<void> {
|
||||
shutdownFatal("unhandledRejection", reason);
|
||||
});
|
||||
|
||||
// A hub being retired can keep the port bound for a couple of seconds
|
||||
// after acking shutdown (its watchdog force-exits below the 3s retire
|
||||
// poll). Spawning into that window must wait the port out instead of
|
||||
// dying with EADDRINUSE and leaving clients with no hub at all.
|
||||
const bindDeadline = Date.now() + HUB_STARTUP_BIND_RETRY_WINDOW_MS;
|
||||
let server: Awaited<ReturnType<typeof startHubWebSocketServer>>;
|
||||
try {
|
||||
server = await startHubWebSocketServer({
|
||||
server = await startHubWebSocketServerWithBindRetry(bindDeadline, {
|
||||
onShutdownRequested: () => {
|
||||
void requestOrQueueShutdown({
|
||||
reason: "authenticated HTTP shutdown request",
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
openSync,
|
||||
rememberRecoverableLocalHubUrl,
|
||||
verifyHubConnection,
|
||||
localHubHasNoActiveSessions,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
createHubServerUrl,
|
||||
@@ -28,6 +29,8 @@ const {
|
||||
openSync: vi.fn(() => 17),
|
||||
rememberRecoverableLocalHubUrl: vi.fn((url: string) => url),
|
||||
verifyHubConnection: vi.fn(),
|
||||
// Idle by default, so existing replacement cases are unaffected.
|
||||
localHubHasNoActiveSessions: vi.fn(async () => true),
|
||||
resolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
})),
|
||||
@@ -96,6 +99,7 @@ vi.mock("@cline/shared", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../client", () => ({
|
||||
localHubHasNoActiveSessions,
|
||||
rememberRecoverableLocalHubUrl,
|
||||
requestHubShutdown,
|
||||
verifyHubConnection,
|
||||
@@ -122,7 +126,11 @@ vi.mock("../discovery", () => ({
|
||||
describe("ensureDetachedHubServer", () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: true }));
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
// The retire circuit breaker is module state keyed by Hub URL, and these
|
||||
// cases all retire the same URL.
|
||||
const { __test__ } = await import(".");
|
||||
__test__.resetRetireAttempts();
|
||||
delete process.env[CLINE_RUN_AS_HUB_DAEMON_ENV];
|
||||
spawn.mockReset();
|
||||
spawn.mockImplementation(() => ({ unref: vi.fn() }));
|
||||
@@ -133,6 +141,8 @@ describe("ensureDetachedHubServer", () => {
|
||||
rememberRecoverableLocalHubUrl.mockReset();
|
||||
rememberRecoverableLocalHubUrl.mockImplementation((url: string) => url);
|
||||
verifyHubConnection.mockReset();
|
||||
localHubHasNoActiveSessions.mockReset();
|
||||
localHubHasNoActiveSessions.mockResolvedValue(true);
|
||||
clearHubDiscovery.mockReset();
|
||||
clearHubDiscovery.mockResolvedValue(undefined);
|
||||
probeHubServer.mockReset();
|
||||
@@ -400,6 +410,39 @@ describe("ensureDetachedHubServer", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("attaches to an older hub that is still serving sessions instead of retiring it", async () => {
|
||||
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
try {
|
||||
localHubHasNoActiveSessions.mockResolvedValue(false);
|
||||
readHubDiscovery.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "busy-token",
|
||||
});
|
||||
probeHubServer.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "old-build",
|
||||
pid: 12345,
|
||||
});
|
||||
// Reuse is rejected by build id before any connection check, so the
|
||||
// only verify call is the one guarding the deferred attach.
|
||||
verifyHubConnection.mockResolvedValue(true);
|
||||
|
||||
const { ensureDetachedHubServer } = await import(".");
|
||||
|
||||
await expect(ensureDetachedHubServer("/workspace")).resolves.toEqual({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "busy-token",
|
||||
});
|
||||
expect(requestHubShutdown).not.toHaveBeenCalled();
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
expect(clearHubDiscovery).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
kill.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses a healthy hub from a newer build without retiring it", async () => {
|
||||
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { closeSync, mkdirSync, openSync } from "node:fs";
|
||||
import {
|
||||
closeSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
} from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
@@ -9,6 +15,7 @@ import {
|
||||
withResolvedClineBuildEnv,
|
||||
} from "@cline/shared";
|
||||
import {
|
||||
localHubHasNoActiveSessions,
|
||||
rememberRecoverableLocalHubUrl,
|
||||
requestHubShutdown,
|
||||
verifyHubConnection,
|
||||
@@ -23,7 +30,6 @@ import {
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveClineDataDir,
|
||||
resolveHubBuildId,
|
||||
withHubStartupLock,
|
||||
writeHubDiscovery,
|
||||
} from "../discovery";
|
||||
@@ -42,6 +48,41 @@ const HUB_RETIRE_TIMEOUT_MS = 3_000;
|
||||
const HUB_RETIRE_POLL_MS = 100;
|
||||
const HUB_SPAWN_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000];
|
||||
const COMPILED_BUN_HUB_DAEMON_ARG = "--cline-hub-daemon";
|
||||
const HUB_RETIRE_ATTEMPT_LIMIT = 3;
|
||||
const HUB_RETIRE_ATTEMPT_WINDOW_MS = 60_000;
|
||||
|
||||
const retireAttemptsByUrl = new Map<
|
||||
string,
|
||||
{ count: number; windowStartedAt: number }
|
||||
>();
|
||||
|
||||
export const __test__ = {
|
||||
/** Retire attempts are module state keyed by URL; clear between cases. */
|
||||
resetRetireAttempts(): void {
|
||||
retireAttemptsByUrl.clear();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Circuit breaker on repeated retirements of the same Hub URL.
|
||||
*
|
||||
* Build ordering already guarantees that only one side of a pair can decide to
|
||||
* retire, so a healthy install retires a given URL once. Retiring the same URL
|
||||
* over and over means something upstream is wrong, and the failure mode is
|
||||
* severe: long-lived clients (sidecars, interactive CLI sessions) tear each
|
||||
* other's daemon down in a tight loop and every session dies with an abnormal
|
||||
* socket close. Backing off after a few attempts keeps a future ordering bug to
|
||||
* a stale-build prompt instead of an unusable Hub.
|
||||
*/
|
||||
function shouldAttemptRetire(url: string, now = Date.now()): boolean {
|
||||
const entry = retireAttemptsByUrl.get(url);
|
||||
if (!entry || now - entry.windowStartedAt > HUB_RETIRE_ATTEMPT_WINDOW_MS) {
|
||||
retireAttemptsByUrl.set(url, { count: 1, windowStartedAt: now });
|
||||
return true;
|
||||
}
|
||||
entry.count += 1;
|
||||
return entry.count <= HUB_RETIRE_ATTEMPT_LIMIT;
|
||||
}
|
||||
|
||||
function endpointArgs(endpoint: HubEndpointOverrides): string[] {
|
||||
return [
|
||||
@@ -70,7 +111,47 @@ function resolveDefaultHubOwnerContext() {
|
||||
}
|
||||
|
||||
function isReusableHubRecord(record: HubServerProbeRecord): boolean {
|
||||
return isManagedHubReusable(record, { expectedBuildId: resolveHubBuildId() });
|
||||
return isManagedHubReusable(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the discovery record the npm postinstall set aside (see
|
||||
* apps/cli/script/postinstall.mjs). Deliberately bypasses readHubDiscovery —
|
||||
* the file is best-effort recovery metadata, not a live record — and stays
|
||||
* synchronous so it adds no async boundary to the ensure flow.
|
||||
*
|
||||
* Exported for `cline doctor`, which must not mistake a shielded live hub for
|
||||
* a stale daemon just because its record is set aside.
|
||||
*/
|
||||
export function readSupersededHubDiscovery(
|
||||
discoveryPath: string,
|
||||
): { url?: string; authToken?: string; pid?: number } | undefined {
|
||||
try {
|
||||
const raw = JSON.parse(
|
||||
readFileSync(`${discoveryPath}.superseded`, "utf8"),
|
||||
) as { url?: unknown; authToken?: unknown; pid?: unknown };
|
||||
return {
|
||||
url: typeof raw.url === "string" ? raw.url : undefined,
|
||||
authToken: typeof raw.authToken === "string" ? raw.authToken : undefined,
|
||||
pid: typeof raw.pid === "number" ? raw.pid : undefined,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The set-aside record is one-shot recovery metadata: once an ensure completes
|
||||
* with a live, verified hub it has served its purpose, and keeping it around
|
||||
* is a hazard — its pid can be recycled by the OS and a much later launch
|
||||
* that finds no live record would SIGTERM an unrelated process with it.
|
||||
*/
|
||||
function discardSupersededHubDiscovery(discoveryPath: string): void {
|
||||
try {
|
||||
unlinkSync(`${discoveryPath}.superseded`);
|
||||
} catch {
|
||||
// Already gone or unreadable — nothing to discard.
|
||||
}
|
||||
}
|
||||
|
||||
function withMatchingDiscoveryRetirementMetadata(
|
||||
@@ -118,6 +199,9 @@ async function retireDiscoveredHub(
|
||||
record: { url: string; authToken?: string; pid?: number },
|
||||
discoveryPath: string,
|
||||
): Promise<boolean> {
|
||||
if (!shouldAttemptRetire(record.url)) {
|
||||
return false;
|
||||
}
|
||||
await requestHubShutdown(record.url, record.authToken).catch(() => false);
|
||||
if (record.pid) {
|
||||
try {
|
||||
@@ -131,14 +215,49 @@ async function retireDiscoveredHub(
|
||||
return retired;
|
||||
}
|
||||
|
||||
export type HubRetirementOutcome =
|
||||
| "reusable"
|
||||
| "retired"
|
||||
| "deferred_busy"
|
||||
| "failed";
|
||||
|
||||
/**
|
||||
* Whether the Hub is currently serving sessions, and so must not be shut down
|
||||
* under them.
|
||||
*
|
||||
* Failing open (treating an unanswerable Hub as idle) preserves the existing
|
||||
* replacement path for a Hub that is wedged or too old to answer the query;
|
||||
* only a Hub that positively reports live sessions is spared.
|
||||
*/
|
||||
async function hubHasLiveSessions(
|
||||
record: HubServerProbeRecord,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return !(await localHubHasNoActiveSessions(record.url, record.authToken));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retiring a Hub kills its established WebSockets, so a session running on it
|
||||
* dies mid-turn with an abnormal close. Defer instead while it is busy: the
|
||||
* caller attaches to the older Hub, the build-mismatch watcher tells the user a
|
||||
* newer build is waiting, and the swap happens at a boundary they choose.
|
||||
*/
|
||||
async function retireIncompatibleHub(
|
||||
record: HubServerProbeRecord,
|
||||
discoveryPath: string,
|
||||
): Promise<boolean> {
|
||||
): Promise<HubRetirementOutcome> {
|
||||
if (isReusableHubRecord(record)) {
|
||||
return true;
|
||||
return "reusable";
|
||||
}
|
||||
return retireDiscoveredHub(record, discoveryPath);
|
||||
if (await hubHasLiveSessions(record)) {
|
||||
return "deferred_busy";
|
||||
}
|
||||
return (await retireDiscoveredHub(record, discoveryPath))
|
||||
? "retired"
|
||||
: "failed";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,6 +427,13 @@ async function ensureDetachedHubServerLocked(
|
||||
};
|
||||
await retireLegacySharedHub(owner).catch(() => undefined);
|
||||
const discovered = await readHubDiscovery(owner.discoveryPath);
|
||||
// The npm package's postinstall sets the discovery record aside (same
|
||||
// ".superseded" suffix) so pre-3.0.55 updaters cannot restart a busy hub.
|
||||
// Without it, a hub displaced that way could never be retired here: the
|
||||
// port probe alone carries no auth token or pid.
|
||||
const superseded = discovered?.url
|
||||
? undefined
|
||||
: readSupersededHubDiscovery(owner.discoveryPath);
|
||||
let retiredUnusableDiscovery = false;
|
||||
if (discovered?.url) {
|
||||
const discoveredAuthToken = discovered.authToken;
|
||||
@@ -326,16 +452,30 @@ async function ensureDetachedHubServerLocked(
|
||||
authToken: discoveredAuthToken,
|
||||
}))
|
||||
) {
|
||||
discardSupersededHubDiscovery(owner.discoveryPath);
|
||||
return rememberIfManaged({
|
||||
url: healthy.url,
|
||||
authToken: discoveredAuthToken,
|
||||
});
|
||||
}
|
||||
if (healthy?.url) {
|
||||
await retireIncompatibleHub(
|
||||
const outcome = await retireIncompatibleHub(
|
||||
{ ...healthy, authToken: discoveredAuthToken },
|
||||
owner.discoveryPath,
|
||||
);
|
||||
// A busy older Hub is left running, so attach to it rather than
|
||||
// spawning a second daemon that would race it for the port.
|
||||
if (
|
||||
outcome === "deferred_busy" &&
|
||||
(await verifyHubConnection(healthy.url, {
|
||||
authToken: discoveredAuthToken,
|
||||
}))
|
||||
) {
|
||||
return rememberIfManaged({
|
||||
url: healthy.url,
|
||||
authToken: discoveredAuthToken,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
}
|
||||
@@ -345,7 +485,7 @@ async function ensureDetachedHubServerLocked(
|
||||
if (expected?.url) {
|
||||
const expectedForRetirement = withMatchingDiscoveryRetirementMetadata(
|
||||
expected,
|
||||
discovered,
|
||||
discovered ?? superseded,
|
||||
expectedUrl,
|
||||
);
|
||||
if (isReusableHubRecord(expected)) {
|
||||
@@ -355,6 +495,7 @@ async function ensureDetachedHubServerLocked(
|
||||
const candidateTokens = [
|
||||
expected.authToken,
|
||||
discovered?.authToken,
|
||||
superseded?.authToken,
|
||||
].filter(
|
||||
(token): token is string =>
|
||||
typeof token === "string" && token.trim().length > 0,
|
||||
@@ -379,7 +520,7 @@ async function ensureDetachedHubServerLocked(
|
||||
host: expected.host,
|
||||
port: expected.port,
|
||||
url: expected.url,
|
||||
pid: expected.pid ?? discovered?.pid,
|
||||
pid: expected.pid ?? discovered?.pid ?? superseded?.pid,
|
||||
startedAt: expected.startedAt ?? new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
@@ -389,6 +530,7 @@ async function ensureDetachedHubServerLocked(
|
||||
// Best-effort repair; attaching still works with the token
|
||||
// we just verified even if the discovery file is unwritable.
|
||||
}
|
||||
discardSupersededHubDiscovery(owner.discoveryPath);
|
||||
return rememberIfManaged({
|
||||
url: expected.url,
|
||||
authToken: token,
|
||||
@@ -401,12 +543,32 @@ async function ensureDetachedHubServerLocked(
|
||||
`A compatible Cline Hub is already running at ${expectedUrl}, but its discovery record is missing or unreadable and no usable auth token is available. Run 'cline doctor fix' to repair local hub discovery.${upgradeHint}`,
|
||||
);
|
||||
}
|
||||
const retiredExpected = await retireIncompatibleHub(
|
||||
const expectedOutcome = await retireIncompatibleHub(
|
||||
expectedForRetirement,
|
||||
owner.discoveryPath,
|
||||
);
|
||||
if (expectedOutcome === "deferred_busy") {
|
||||
// Same as above: the older Hub is still serving sessions, so attach
|
||||
// with whichever token verifies instead of replacing it.
|
||||
for (const token of [
|
||||
expectedForRetirement.authToken,
|
||||
discovered?.authToken,
|
||||
].filter(
|
||||
(candidate): candidate is string =>
|
||||
typeof candidate === "string" && candidate.trim().length > 0,
|
||||
)) {
|
||||
if (await verifyHubConnection(expected.url, { authToken: token })) {
|
||||
return rememberIfManaged({ url: expected.url, authToken: token });
|
||||
}
|
||||
}
|
||||
if (endpointOverrides.allowPortFallback !== true && endpoint.port !== 0) {
|
||||
throw new Error(
|
||||
`An older Cline Hub is running at ${expectedUrl} and is still serving active sessions, so it was not replaced, but no usable auth token is available to attach to it. Finish those sessions, or run 'cline doctor fix' to stop the hub.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!retiredExpected &&
|
||||
expectedOutcome === "failed" &&
|
||||
endpointOverrides.allowPortFallback !== true &&
|
||||
endpoint.port !== 0
|
||||
) {
|
||||
@@ -436,6 +598,7 @@ async function ensureDetachedHubServerLocked(
|
||||
authToken: nextDiscovery.authToken,
|
||||
}))
|
||||
) {
|
||||
discardSupersededHubDiscovery(owner.discoveryPath);
|
||||
return rememberIfManaged({
|
||||
url: healthy.url,
|
||||
authToken: nextDiscovery.authToken,
|
||||
@@ -446,15 +609,27 @@ async function ensureDetachedHubServerLocked(
|
||||
if (nextExpected?.url && !isReusableHubRecord(nextExpected)) {
|
||||
const expectedForRetirement = withMatchingDiscoveryRetirementMetadata(
|
||||
nextExpected,
|
||||
nextDiscovery,
|
||||
nextDiscovery ?? superseded,
|
||||
expectedUrl,
|
||||
);
|
||||
const retiredExpected = await retireIncompatibleHub(
|
||||
const nextOutcome = await retireIncompatibleHub(
|
||||
expectedForRetirement,
|
||||
owner.discoveryPath,
|
||||
);
|
||||
if (
|
||||
!retiredExpected &&
|
||||
nextOutcome === "deferred_busy" &&
|
||||
nextDiscovery?.authToken &&
|
||||
(await verifyHubConnection(nextExpected.url, {
|
||||
authToken: nextDiscovery.authToken,
|
||||
}))
|
||||
) {
|
||||
return rememberIfManaged({
|
||||
url: nextExpected.url,
|
||||
authToken: nextDiscovery.authToken,
|
||||
});
|
||||
}
|
||||
if (
|
||||
nextOutcome === "failed" &&
|
||||
endpointOverrides.allowPortFallback !== true &&
|
||||
endpoint.port !== 0
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compareHubBuilds,
|
||||
type HubBuildIdentity,
|
||||
isManagedHubReusable,
|
||||
} from ".";
|
||||
|
||||
/**
|
||||
* Every shape a discovered Hub record has taken across releases, including the
|
||||
* ones that caused the mutual-retire loop: pre-fingerprint daemons with no
|
||||
* build metadata at all, and fingerprinted daemons from before build epochs
|
||||
* were embedded.
|
||||
*/
|
||||
const BUILD_CORPUS: HubBuildIdentity[] = [
|
||||
{},
|
||||
{ buildId: "0.0.54" },
|
||||
{ buildId: "0.0.64" },
|
||||
{ buildId: "0.0.74" },
|
||||
{ coreVersion: "0.0.64" },
|
||||
{ coreVersion: "0.0.74" },
|
||||
{ buildId: "0.0.64", coreVersion: "0.0.64" },
|
||||
{ buildId: "0.0.74", coreVersion: "0.0.74" },
|
||||
{ buildId: "abc123", coreVersion: "0.0.74" },
|
||||
{ buildId: "def456", coreVersion: "0.0.74" },
|
||||
{ buildId: "0.0.74", buildEpochMs: 1_000 },
|
||||
{ buildId: "0.0.75", buildEpochMs: 2_000 },
|
||||
{ buildId: "0.0.75", buildEpochMs: 2_000, coreVersion: "0.0.75" },
|
||||
{ buildId: "source-0.0.74", coreVersion: "0.0.74" },
|
||||
{ buildId: "0.0.74", buildEpochMs: Number.NaN },
|
||||
{ buildId: "0.0.74", buildEpochMs: 0 },
|
||||
{ buildId: " ", coreVersion: "not-a-version" },
|
||||
{ coreVersion: "3.0.50-nightly.1785933782" },
|
||||
{ coreVersion: "3.0.50" },
|
||||
];
|
||||
|
||||
/**
|
||||
* The subset a healthy daemon actually publishes: build id, epoch, and core
|
||||
* version all present.
|
||||
*/
|
||||
const FULL_BUILD_CORPUS: HubBuildIdentity[] = [
|
||||
{ buildId: "a", buildEpochMs: 1_000, coreVersion: "0.0.64" },
|
||||
{ buildId: "b", buildEpochMs: 2_000, coreVersion: "0.0.74" },
|
||||
{ buildId: "c", buildEpochMs: 2_000, coreVersion: "0.0.75" },
|
||||
{ buildId: "d", buildEpochMs: 3_000, coreVersion: "0.0.64" },
|
||||
{ buildId: "e", buildEpochMs: 3_000, coreVersion: "0.0.64" },
|
||||
];
|
||||
|
||||
const PROTOCOL = { protocolVersion: "v1" } as const;
|
||||
|
||||
function retires(mine: HubBuildIdentity, theirs: HubBuildIdentity): boolean {
|
||||
return !isManagedHubReusable({ ...PROTOCOL, ...theirs }, { self: mine });
|
||||
}
|
||||
|
||||
describe("compareHubBuilds", () => {
|
||||
it("is reflexive over the corpus", () => {
|
||||
for (const build of BUILD_CORPUS) {
|
||||
expect(compareHubBuilds(build, build)).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("is antisymmetric over every pair in the corpus", () => {
|
||||
for (const a of BUILD_CORPUS) {
|
||||
for (const b of BUILD_CORPUS) {
|
||||
expect(
|
||||
Math.sign(compareHubBuilds(a, b)) + Math.sign(compareHubBuilds(b, a)),
|
||||
).toBe(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Transitivity only holds where every record carries the same fields: a
|
||||
* tier is skipped when either side lacks it, so partial metadata makes
|
||||
* "indistinguishable" a non-transitive relation. Antisymmetry - the
|
||||
* property that actually prevents the retire loop - holds regardless, and
|
||||
* is asserted over the full corpus above.
|
||||
*/
|
||||
it("is transitive over fully populated identities", () => {
|
||||
for (const a of FULL_BUILD_CORPUS) {
|
||||
for (const b of FULL_BUILD_CORPUS) {
|
||||
for (const c of FULL_BUILD_CORPUS) {
|
||||
if (compareHubBuilds(a, b) < 0 && compareHubBuilds(b, c) < 0) {
|
||||
expect(compareHubBuilds(a, c)).toBeLessThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("orders by build epoch before core version", () => {
|
||||
expect(
|
||||
compareHubBuilds(
|
||||
{ buildId: "a", buildEpochMs: 1_000, coreVersion: "9.9.9" },
|
||||
{ buildId: "b", buildEpochMs: 2_000, coreVersion: "0.0.1" },
|
||||
),
|
||||
).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("falls back to core version when epochs are absent or tied", () => {
|
||||
expect(
|
||||
compareHubBuilds({ coreVersion: "0.0.64" }, { coreVersion: "0.0.74" }),
|
||||
).toBeLessThan(0);
|
||||
expect(
|
||||
compareHubBuilds(
|
||||
{ buildId: "a", buildEpochMs: 5, coreVersion: "0.0.64" },
|
||||
{ buildId: "b", buildEpochMs: 5, coreVersion: "0.0.74" },
|
||||
),
|
||||
).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("treats records with no comparable metadata as indistinguishable", () => {
|
||||
expect(compareHubBuilds({}, {})).toBe(0);
|
||||
expect(compareHubBuilds({}, { buildId: "0.0.74" })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isManagedHubReusable", () => {
|
||||
/**
|
||||
* The regression guard for the mutual-retire loop: two installations, each
|
||||
* evaluating the other's Hub, must never both decide to retire.
|
||||
*/
|
||||
it("never lets two builds retire each other", () => {
|
||||
for (const a of BUILD_CORPUS) {
|
||||
for (const b of BUILD_CORPUS) {
|
||||
expect(retires(a, b) && retires(b, a)).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses a Hub built from the same build id", () => {
|
||||
const build = { buildId: "0.0.74", coreVersion: "0.0.74" };
|
||||
expect(retires(build, build)).toBe(false);
|
||||
});
|
||||
|
||||
it("retires a strictly older Hub", () => {
|
||||
expect(
|
||||
retires(
|
||||
{ buildId: "0.0.74", coreVersion: "0.0.74" },
|
||||
{ buildId: "0.0.64", coreVersion: "0.0.64" },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("attaches to a strictly newer Hub instead of downgrading it", () => {
|
||||
expect(
|
||||
retires(
|
||||
{ buildId: "0.0.64", coreVersion: "0.0.64" },
|
||||
{ buildId: "0.0.74", coreVersion: "0.0.74" },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("attaches to a Hub with no build metadata rather than retiring it", () => {
|
||||
expect(retires({ buildId: "0.0.74" }, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("still replaces a protocol-incompatible Hub", () => {
|
||||
expect(
|
||||
isManagedHubReusable(
|
||||
{ protocolVersion: "v99", buildId: "0.0.64" },
|
||||
{ self: { buildId: "0.0.74" } },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -111,18 +111,19 @@ describe("hub discovery", () => {
|
||||
expect(resolveHubBuildEpochMs()).toBe(12345);
|
||||
});
|
||||
|
||||
it("reuses managed Hubs only for the same build or a strictly newer one", () => {
|
||||
it("retires a managed Hub only when this build is strictly newer", () => {
|
||||
snapshot = captureEnv();
|
||||
delete process.env.CLINE_HUB_BUILD_EPOCH_MS;
|
||||
const reuseOptions = {
|
||||
expectedBuildId: "current-build",
|
||||
expectedBuildEpochMs: 1_000,
|
||||
const self = {
|
||||
buildId: "current-build",
|
||||
buildEpochMs: 1_000,
|
||||
coreVersion: "0.0.70",
|
||||
};
|
||||
// Same build: reusable regardless of epoch.
|
||||
expect(
|
||||
isManagedHubReusable(
|
||||
{ protocolVersion: "v1", buildId: "current-build" },
|
||||
reuseOptions,
|
||||
{ self },
|
||||
),
|
||||
).toBe(true);
|
||||
// Different build with a newer epoch: another install upgraded the Hub.
|
||||
@@ -133,24 +134,37 @@ describe("hub discovery", () => {
|
||||
buildId: "other-build",
|
||||
buildEpochMs: 2_000,
|
||||
},
|
||||
reuseOptions,
|
||||
{ self },
|
||||
),
|
||||
).toBe(true);
|
||||
// Different build that is older: retire and replace.
|
||||
expect(
|
||||
isManagedHubReusable(
|
||||
{ protocolVersion: "v1", buildId: "other-build", buildEpochMs: 500 },
|
||||
reuseOptions,
|
||||
{ self },
|
||||
),
|
||||
).toBe(false);
|
||||
// Different build with no ordering information: replace (safe default).
|
||||
// No epoch, but an older core version still orders the two builds.
|
||||
expect(
|
||||
isManagedHubReusable(
|
||||
{
|
||||
protocolVersion: "v1",
|
||||
buildId: "other-build",
|
||||
coreVersion: "0.0.64",
|
||||
},
|
||||
{ self },
|
||||
),
|
||||
).toBe(false);
|
||||
// Different build with no ordering information at all: attach rather
|
||||
// than replace. Retiring an unordered peer is what let two installs
|
||||
// shut each other's daemon down in a loop.
|
||||
expect(
|
||||
isManagedHubReusable(
|
||||
{ protocolVersion: "v1", buildId: "other-build" },
|
||||
reuseOptions,
|
||||
{ self },
|
||||
),
|
||||
).toBe(false);
|
||||
// Own epoch unknown (unbundled sources): replace.
|
||||
).toBe(true);
|
||||
// Own epoch unknown (unbundled sources): attach, never downgrade.
|
||||
expect(
|
||||
isManagedHubReusable(
|
||||
{
|
||||
@@ -158,12 +172,13 @@ describe("hub discovery", () => {
|
||||
buildId: "other-build",
|
||||
buildEpochMs: 2_000,
|
||||
},
|
||||
{ expectedBuildId: "current-build" },
|
||||
{ self: { buildId: "current-build" } },
|
||||
),
|
||||
).toBe(false);
|
||||
// Legacy hub without build metadata: replace.
|
||||
expect(isManagedHubReusable({ protocolVersion: "v1" }, reuseOptions)).toBe(
|
||||
false,
|
||||
).toBe(true);
|
||||
// Legacy hub without build metadata: attach and let the build-mismatch
|
||||
// watcher prompt instead of killing a daemon we cannot order.
|
||||
expect(isManagedHubReusable({ protocolVersion: "v1" }, { self })).toBe(
|
||||
true,
|
||||
);
|
||||
// Protocol mismatch is never reusable, newer or not.
|
||||
expect(
|
||||
@@ -173,7 +188,7 @@ describe("hub discovery", () => {
|
||||
buildId: "other-build",
|
||||
buildEpochMs: 2_000,
|
||||
},
|
||||
reuseOptions,
|
||||
{ self },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -182,41 +182,143 @@ export function getManagedHubCompatibility(
|
||||
return { compatible: true };
|
||||
}
|
||||
|
||||
export interface HubBuildIdentity {
|
||||
buildId?: string;
|
||||
buildEpochMs?: number;
|
||||
coreVersion?: string;
|
||||
}
|
||||
|
||||
function finiteEpochMs(value: number | undefined): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Numeric release components of a semver-ish version, ignoring any prerelease
|
||||
* or build suffix. Undefined when the value carries no comparable release.
|
||||
*/
|
||||
function parseReleaseComponents(
|
||||
version: string | undefined,
|
||||
): number[] | undefined {
|
||||
const release = version?.trim().split(/[-+]/, 1)[0];
|
||||
if (!release) {
|
||||
return undefined;
|
||||
}
|
||||
const components = release.split(".").map((part) => Number(part));
|
||||
if (
|
||||
components.length === 0 ||
|
||||
components.some((part) => !Number.isInteger(part) || part < 0)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return components;
|
||||
}
|
||||
|
||||
function compareReleaseComponents(a: number[], b: number[]): number {
|
||||
for (let index = 0; index < Math.max(a.length, b.length); index++) {
|
||||
const left = a[index] ?? 0;
|
||||
const right = b[index] ?? 0;
|
||||
if (left !== right) {
|
||||
return left < right ? -1 : 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total order over Hub builds: negative when `a` is older than `b`, positive
|
||||
* when newer, zero when the two are indistinguishable.
|
||||
*
|
||||
* The ordering is what keeps concurrent installations from replacing each
|
||||
* other's daemons. A one-sided "may I reuse this?" predicate lets both sides
|
||||
* answer no, and two clients then retire each other's Hub forever; a total
|
||||
* order is antisymmetric by construction, so at most one side can ever decide
|
||||
* to retire. Every tier below preserves that: a field decides only when both
|
||||
* records carry it, and the final fallback is "equal", which means reuse.
|
||||
*
|
||||
* Tiers, in order: identical build, embedded build epoch, core release
|
||||
* version, then build id as a deterministic tiebreak.
|
||||
*/
|
||||
export function compareHubBuilds(
|
||||
a: HubBuildIdentity,
|
||||
b: HubBuildIdentity,
|
||||
): number {
|
||||
const buildIdA = a.buildId?.trim();
|
||||
const buildIdB = b.buildId?.trim();
|
||||
if (buildIdA && buildIdB && buildIdA === buildIdB) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const epochA = finiteEpochMs(a.buildEpochMs);
|
||||
const epochB = finiteEpochMs(b.buildEpochMs);
|
||||
if (epochA !== undefined && epochB !== undefined && epochA !== epochB) {
|
||||
return epochA < epochB ? -1 : 1;
|
||||
}
|
||||
|
||||
const releaseA = parseReleaseComponents(a.coreVersion);
|
||||
const releaseB = parseReleaseComponents(b.coreVersion);
|
||||
if (releaseA && releaseB) {
|
||||
const release = compareReleaseComponents(releaseA, releaseB);
|
||||
if (release !== 0) {
|
||||
return release;
|
||||
}
|
||||
}
|
||||
|
||||
if (buildIdA && buildIdB && buildIdA !== buildIdB) {
|
||||
return buildIdA < buildIdB ? -1 : 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* This build's own identity, in the same shape a daemon publishes into its
|
||||
* discovery record.
|
||||
*
|
||||
* Self and peer identities must be built from the same fields. Enriching only
|
||||
* the local side - filling in a `coreVersion` the wire record can omit, say -
|
||||
* makes a build's identity depend on which role it is playing, and two
|
||||
* installations then decide the comparison on different tiers and each
|
||||
* conclude that it is the newer one. Callers that need a synthetic identity
|
||||
* pass it verbatim rather than layering overrides onto this.
|
||||
*/
|
||||
export function resolveHubBuildIdentity(): HubBuildIdentity {
|
||||
return {
|
||||
buildId: resolveHubBuildId(),
|
||||
buildEpochMs: resolveHubBuildEpochMs(),
|
||||
coreVersion: String(corePackage.version),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a client may keep using a managed local Hub instead of retiring it.
|
||||
*
|
||||
* Same build: always reusable. Different build: reusable only when the Hub
|
||||
* was produced *after* this client's own build (another installation
|
||||
* upgraded the shared Hub) - the daemon is then running newer code, so
|
||||
* replacing it would be a downgrade; the client attaches over the compatible
|
||||
* wire protocol and the build-mismatch watcher prompts the user to update.
|
||||
* A Hub that is older, unordered, or missing build metadata is not reusable
|
||||
* and gets retired and replaced, preserving the stale-daemon fix.
|
||||
* A Hub is retired only when this client's build is *strictly newer* by
|
||||
* {@link compareHubBuilds}. Same build, newer Hub, and indistinguishable
|
||||
* builds all attach over the compatible wire protocol and let the
|
||||
* build-mismatch watcher prompt the user to update. Because the decision is
|
||||
* derived from a total order, two installations can never retire each other.
|
||||
*
|
||||
* Genuine protocol incompatibility is unaffected: those Hubs cannot be spoken
|
||||
* to at all and are still replaced.
|
||||
*/
|
||||
export function isManagedHubReusable(
|
||||
record: HubProtocolMetadata & { buildId?: string; buildEpochMs?: number },
|
||||
options?: { expectedBuildId?: string; expectedBuildEpochMs?: number },
|
||||
record: HubProtocolMetadata & HubBuildIdentity,
|
||||
options?: { self?: HubBuildIdentity },
|
||||
): boolean {
|
||||
const compatibility = getManagedHubCompatibility(
|
||||
record,
|
||||
options?.expectedBuildId ?? resolveHubBuildId(),
|
||||
);
|
||||
const self = options?.self ?? resolveHubBuildIdentity();
|
||||
const compatibility = getManagedHubCompatibility(record, self.buildId ?? "");
|
||||
if (compatibility.compatible) {
|
||||
return true;
|
||||
}
|
||||
if (compatibility.reason !== "build_mismatch") {
|
||||
if (
|
||||
compatibility.reason !== "build_mismatch" &&
|
||||
compatibility.reason !== "missing_build"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const hubEpochMs = record.buildEpochMs;
|
||||
const expectedEpochMs =
|
||||
options?.expectedBuildEpochMs ?? resolveHubBuildEpochMs();
|
||||
return (
|
||||
typeof hubEpochMs === "number" &&
|
||||
Number.isFinite(hubEpochMs) &&
|
||||
typeof expectedEpochMs === "number" &&
|
||||
Number.isFinite(expectedEpochMs) &&
|
||||
hubEpochMs > expectedEpochMs
|
||||
);
|
||||
return compareHubBuilds(self, record) <= 0;
|
||||
}
|
||||
|
||||
export function resolveHubOwnerContext(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { normalizeWorkspacePath } from "../../services/workspace/workspace-manif
|
||||
import {
|
||||
type HubOwnerContext,
|
||||
resolveClineDataDir,
|
||||
resolveHubBuildId,
|
||||
resolveHubOwnerContext,
|
||||
} from ".";
|
||||
|
||||
@@ -19,10 +20,19 @@ export function resolveWorkspaceHubOwnerContext(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Development builds change on every compile, so a single shared owner record
|
||||
* puts every checkout and every rebuild in contention for one daemon - and the
|
||||
* loser of that contention gets shut down mid-session. Scoping the owner by
|
||||
* build id lets differing dev builds run their own Hub side by side instead of
|
||||
* arbitrating over one. Production keeps a single owner (see
|
||||
* {@link resolveProductionHubOwnerContext}), where the singleton matters and
|
||||
* builds are ordered.
|
||||
*/
|
||||
export function resolveSharedHubOwnerContext(
|
||||
label = DEFAULT_SHARED_HUB_OWNER_LABEL,
|
||||
): HubOwnerContext {
|
||||
return resolveHubOwnerContext(label);
|
||||
return resolveHubOwnerContext(`${label}@${resolveHubBuildId()}`);
|
||||
}
|
||||
|
||||
export function resolveProductionHubOwnerContext(): HubOwnerContext {
|
||||
|
||||
@@ -1431,7 +1431,7 @@ export class HubRuntimeHost implements RuntimeHost {
|
||||
|
||||
async readSessionMessages(
|
||||
sessionId: string,
|
||||
): Promise<import("@cline/llms").Message[]> {
|
||||
): Promise<import("@cline/llms").MessageWithMetadata[]> {
|
||||
const target = sessionId.trim();
|
||||
if (!target) {
|
||||
return [];
|
||||
@@ -1459,7 +1459,7 @@ export class HubRuntimeHost implements RuntimeHost {
|
||||
}
|
||||
const messages = reply.payload?.messages;
|
||||
return Array.isArray(messages)
|
||||
? (messages as import("@cline/llms").Message[])
|
||||
? (messages as import("@cline/llms").MessageWithMetadata[])
|
||||
: [];
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user