Compare commits

..
Author SHA1 Message Date
Saoud Rizwan 4f836ae7d0 test(sdk): give windows-sensitive suites realistic timeouts
Four consecutive SDK publish runs failed on windows-latest, each on a
different test, all of them plain timeouts: two @cline/shared SQLite
tests at the 5s vitest default, core's bash executor at 10s, and the hub
singleton endpoint test at 10s. The 2-core Windows runner spawns forks
and takes SQLite locks slowly enough to blow those budgets under load.

These timeouts guard against hangs; they are not timing assertions (the
one suite that does assert elapsed time, shutdown.e2e, was fixed by
removing file-level parallelism instead). Raise core to 20s and give
@cline/shared an explicit 15s in place of the inherited 5s default.
2026-08-22 16:39:19 -07:00
Saoud Rizwan 6cb653a362 chore(desktop): release v0.0.16 2026-08-22 16:34:23 -07:00
Saoud Rizwan 5077fe8697 fix(core): run hub e2e files serially so daemon timing budgets survive CI contention
singleton.e2e.test.ts (added in #13468) spawns real daemons and runs for
~15s. Vitest's default file parallelism let it run alongside
shutdown.e2e.test.ts, whose assertions are wall-clock bound: discovery
within 10s, exit within 5s, and a 2s shutdown watchdog. On the 2-core
windows-latest runner that contention alone broke those budgets, failing
the shutdown test two different ways across runs — once never observing
discovery, once with the daemon forced to exit before its HTTP 202
flushed (socket hang up). The test passed on Windows before #13468 and
has failed every SDK publish run since.
2026-08-22 15:18:04 -07:00
Saoud Rizwan 2266fe8cf4 chore(cli): release v3.0.57 2026-08-22 13:25:40 -07:00
Saoud Rizwan 21cb8d2525 chore(sdk): release v0.0.78 2026-08-22 13:03:18 -07:00
Saoud Rizwan 68ad354b52 chore(vscode): prepare 4.1.13 release 2026-08-22 12:50:54 -07:00
Saoud RizwanandSaoud Rizwan e098a8ed0d fix(core): stop stored capability lists from silently revoking tool calling for custom models (#13476)
* fix(core): seed tools capability when custom model capabilities are synthesized from boolean flags

For a models.json entry with no explicit capabilities list, toStoredModelInfo
synthesized a capability array purely from boolean convenience flags (e.g.
supportsReasoning: true -> ["reasoning"]). modelSupportsToolCalling fails open
only for a missing or empty list, so the synthesized non-empty list read as an
authoritative denial and silently stripped every tool definition from requests
to custom OpenAI-compatible models (#13463).

Seed "tools" whenever the list was not explicitly authored and the boolean
projections made it non-empty, preserving the fail-open contract. Explicitly
authored capability lists remain authoritative and can still disable tools.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* test(core): cover stale catalog capability overrides

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix: treat stored capability lists as non-authoritative for tool calling

The hasExplicitCapabilities guard still let two producers of tool-less
lists through:

- The VS Code legacy-override migration (legacyModelInfoToOverrides)
  persists explicit partial lists like ["prompt-cache"] into models.json
  for custom OpenAI-compatible models, which then read as an authoritative
  "cannot call tools" and drop every tool - same symptom as #13463.
- Any hand- or UI-authored partial list on a non-catalog model.

Stored entries and user-authored provider metadata have no way to declare
"cannot call tools" (there is no supportsTools field, and every writer
that authors a full list includes "tools"), so seed "tools" into any
non-empty list for a language model. Only generated catalog capabilities
remain authoritative - a genuine no-tools catalog model stays that way -
and non-language models (e.g. image generation) never gain a tools claim.

Also make legacyModelInfoToOverrides write "tools" into the arrays it
fabricates, matching the providers.json migration, so models.json stops
being poisoned for older readers.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-22 12:42:09 -07:00
1de61b178a feat(hub): add drain and upgrade commands with replay support (#13468)
* feat(hub): add drain and upgrade commands with replay support

* handles disconnection

* feat(hub): wire bot profiles, drain, and durable event/run-queue into the live transport

Completes the wiring the previous commits' primitives needed:
HubServerTransport gains isDraining(), hub.drain/hub.status/profile.get
command handling, and replayEventsAfter() (backed by the durable event
log), plus the sequence/sinceSequence wire types they depend on in
shared/hub.ts. run-queue-handlers.ts reads the active bot profile's
plugin roots when executing durable runs.

Also adds hub/profiles/: profile.json (identity/rules/plugins) ->
system prompt composition, --profile / CLINE_HUB_BOT_PROFILE
resolution, and the bundled cline-dad profile with its
cline_hub_support read-only diagnostics tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Revert "feat(hub): wire bot profiles, drain, and durable event/run-queue into the live transport"

This reverts commit 6696d5d202.

* fix(hub): dedupe replayed events by eventId, not just sequence

HubEventLogStore.append() returns a new envelope stamped with a
sequence rather than mutating the input, so a pending approval
re-issued sequence-less by subscribe() (it predates any durable-log
append) and its later sequence-stamped copy from the durable log are
two different objects carrying the same eventId. The replay-then-live
buffer in browser-websocket.ts only deduped by sequence, so the
sequence-less copy's guard never tripped and it was delivered a second
time when the buffer flushed after replay.

Track delivered eventIds alongside the sequence cursor; eventId
survives the append/stamp round-trip unchanged, so this dedupes the
exact-same logical event regardless of which copy arrives first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(hub): wire drain, durable event log, and run queue into the live transport

CI on this branch failed bun run build:sdk: browser-websocket.ts,
client/index.ts, and hub-websocket-server.ts (already on this branch)
reference sequence/sinceSequence, HubServerTransport.isDraining(), and
the "hub.drain" command — but the commit that reverted bot profiles
out of this branch also reverted this wiring, since it shared a commit
with the profiles work. That wiring is a hub concern, not a
bot-profiles one; split it back out.

- shared/hub.ts: sequence/sinceSequence types, run.enqueue/run.list/
  hub.drain/hub.status/stream.replay capability, command, and event
  names. profile.get intentionally excluded — stays bot-profiles-only.
- context.ts: isDraining() on HubTransportContext. botProfile field
  intentionally excluded.
- hub-server-transport.ts: eventLog/runQueue fields and start/stop
  lifecycle, publish() appends to the durable log, handleCommand cases
  for run.enqueue/run.list/hub.drain/hub.status, drain-refusal check,
  replayEventsAfter()/lastEventSequence(). startBotProfile()/
  startHubSupportTool() and the profile.get case intentionally
  excluded.
- run-queue-handlers.ts: added without handleProfileGet (needs
  ctx.botProfile, which doesn't exist here).
- hub-upgrades.test.ts: added without its two bot-profile-injection
  tests (they need a resolved bot profile to assert against).

Verified bun run build:sdk exits 0 (the exact CI command) and
bunx vitest run src/hub passes (311/312; the one failure is the
same pre-existing environment-timing flake already present before
this change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(hub): export instance-lock, event-log, and run-queue from the hub barrel

These landed as internal modules only; hub-server-transport.ts and
hub-websocket-server.ts import them by direct path, but nothing
re-exported them from the public @cline/core/hub surface the way
sibling discovery/server modules already are.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(hub): wire the instance lock into the daemon entry point

The singleton lock (discovery/instance-lock.ts) and its consumption in
startHubWebSocketServer/ensureHubWebSocketServer were already on this
branch, but the daemon entry point's own half was not: retrying a bind
when a retiring predecessor still holds the lock, and exiting with a
distinct code (3) instead of the generic fatal path when a live Hub
already owns the data directory. Without this, a daemon racing a
retiring predecessor could fail outright instead of waiting the lock
out, and losing the singleton race looked identical to a crash.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(hub): address drain/upgrade review findings (#13478)

- cline hub upgrade: check idleness at least once (--wait 0 works), reject
  non-numeric --wait, and un-drain on every abort path so an aborted
  upgrade can never leave the hub refusing new work
- add cline hub drain --off and the off query param to requestHubDrain so
  POST /drain?off is reachable from shipped code
- HubEventLogStore/HubRunQueue: WAL journal mode + busy_timeout, and stamp
  sequences from lastInsertRowid instead of SELECT MAX(sequence)
- HubInstanceLock.acquire: degrade to an unheld lock when SQLite is
  unavailable instead of refusing hub startup; only BUSY/LOCKED still
  raises HubLockHeldError
- ensureHubWebSocketServer: retire an unusable discovered hub through the
  shared retireDiscoveredHub (busy hubs are attached to, drain precedes
  shutdown, discovery cleared only when the hub actually retired)
- replay adapter: advance the cursor past eventId-deduped events, cap
  replay pages, stop when the cursor stalls, and drop the dedupe set after
  the buffered flush so it cannot grow for the socket lifetime

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* fix(hub): derive the singleton e2e challenger cwd portably

The challenger's working directory was derived by round-tripping the
discovery path through a file: URL and stripping the last pathname
segment. On Windows that yields a POSIX-style '/C:/...' path, which is
not a valid spawn cwd, so the spawn fails ENOENT before the singleton
lock is ever contested and the Windows SDK test job goes red.

The data dir is simply the discovery file's parent: use dirname().

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-21 21:49:21 -07:00
Saoud Rizwan e7ed29109b ci(vscode): make combined nightly manual-dispatch only
The PublishNightly environment gained required reviewers, so each cron
run parked on approval, held the workflow's concurrency group, and
silently cancelled every scheduled run queued behind it. 20 consecutive
scheduled nightlies died this way between 2026-07-31 and 2026-08-21;
the only nightlies that shipped in that window were manual dispatches.

Drop the cron rather than leave a trigger that cannot succeed unattended.
2026-08-21 17:01:56 -07:00
BeeandCursor Agent 9316de6bb5 fix: propagate Langfuse session telemetry (#13473)
* fix telemetry session propagation

* feat telemetry client version metadata

* fix(core): address Langfuse review feedback — hub client identity + delegated agent session grouping (#13475)

* fix(core): rebuild hub session client identity from request headers

Hub-backed sessions do not transport extensionContext (it is local-only),
so the daemon's runtime built traces without the clientName/clientVersion
metadata even though the hub client bakes X-CLIENT-TYPE / X-CLIENT-VERSION
into the session's provider headers. Reconstruct extensionContext.client
from those headers during local runtime bootstrap so hub-backed Langfuse
traces carry the same client identity as local runtimes, and the daemon's
header re-resolution stops clobbering the original X-CLIENT-TYPE.

* fix(core): propagate parent distinctId/sessionId to delegated agents

Delegated agents (spawned sub-agents, configured agents, teammates) were
built without distinctId and sessionId, so their Langfuse traces had no
userId or sessionId and did not group with the parent user or session.
Thread the host-resolved distinctId through RuntimeBuilderInput and the
root sessionId through the delegated-agent config provider, and copy both
onto the delegated AgentConfig.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-21 16:34:21 -07:00
Tomás Barreiro fb58e340a2 Add feature flags to the desktop app (#13289)
* Add feature flags to the app

* React to account updates

* Address comments

* use a per-app file
2026-08-22 00:25:30 +02:00
Saoud Rizwan db6d18a98a chore(vscode): prepare 4.1.12 release 2026-08-21 13:53:56 -07:00
2ea460fa46 Treat an empty preserved capability list as unspecified when seeding tools (#13465)
* Treat an empty preserved capability list as unspecified when seeding tools

toSdkModelInfo guarded the tools seeding with a strict
preservedCapabilities === undefined check, but modelHasCapability —
the runtime's own reader — treats undefined AND length === 0 as
"unspecified". A custom OpenAI-Compatible model whose stored
capabilities field is a defined-but-empty array (a config carried over
from before the field existed, or one round-tripped through a boundary
that defaults it to []) skipped the seeding; the first boolean
projection to run afterwards (e.g. supportsReasoning) then populated
the array, the runtime gate read the non-empty, tool-less list as
authoritative, and every tool definition was silently dropped from the
session (#13463).

The guard now covers the empty array too, matching the reader's
unspecified semantics.

* test: satisfy the store's isModelInfo gate so the empty-capabilities case actually reaches knownModels

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: yzxcj797 <yzxcj797@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-21 13:42:01 -07:00
Saoud RizwanandSaoud Rizwan 7d366ce7d4 fix(vscode): remote config MCP settings (#13466)
* fix(vscode): enforce enterprise MCP controls on the Customize marketplace

The unified Customize marketplace replaced the old MCP marketplace
without carrying over enterprise remote-config enforcement: the catalog
RPC returned every MCP entry and installs were never policy-checked,
so orgs with mcpMarketplaceEnabled=false or an allowedMCPServers
allowlist saw (and could install) all marketplace MCP servers.

- Filter MCP entries out of getMarketplaceCatalog when the marketplace
  is disabled, and restrict entries to the allowlist when configured
  (matching entry id, display name, installed server name, or source
  repo URL, mirroring legacy GitHub-URL allowlist ids)
- Reject installMarketplaceEntry requests that violate the policy
- Map the published catalog's repo/homepage fields onto
  sourceUrl/homepageUrl so URL-based allowlists can match
- Update the enterprise MCP server controls docs

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* refactor: simplify MCP marketplace policy enforcement

Fold the policy check into marketplace-helpers, drop the dedicated
test suite, and trim the docs edit to the strictly necessary line.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-08-21 12:40:01 -07:00
85 changed files with 7101 additions and 1019 deletions
@@ -14,9 +14,12 @@ name: ext-vscode-publish-nightly
# pre-release publishes.
on:
schedule:
# Every day at 4:00 AM PST (12:00 UTC)
- cron: "0 12 * * *"
# Manual dispatch only. The nightly cron was removed deliberately: the
# PublishNightly environment gained required reviewers, and an unattended
# cron run would just sit `waiting` on that approval, hold this workflow's
# concurrency group, and silently cancel every later scheduled run behind it
# (that is exactly what happened between 2026-07-31 and 2026-08-21, killing
# 20 consecutive nightlies). Cut a nightly by dispatching this workflow.
workflow_dispatch:
inputs:
legacy-ref:
@@ -74,8 +77,9 @@ jobs:
- name: Checkout legacy source
uses: actions/checkout@v4
with:
# NOTE: inputs are empty strings on `schedule` events, so the ||
# fallback (not the input's declared default) is what the cron uses.
# NOTE: the || fallback is retained so this stays correct if a
# non-dispatch trigger is ever added back (inputs are empty strings
# on e.g. `schedule` events, where the declared default does not apply).
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
path: legacy-src
lfs: true
+19
View File
@@ -1,5 +1,24 @@
# Changelog
## [4.1.13]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Fixed
- Restore tool calling for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`. The inferred list read as an authoritative denial and stripped every tool from the request; an explicitly authored capability list still decides.
- Keep Hub-backed sessions intact across a Hub restart or upgrade. Clients replay the events they missed while disconnected, and the same event is no longer delivered twice when the replay and live streams overlap.
- Carry session and client identity into Langfuse traces for Hub-backed and delegated-agent runs, which previously arrived without their session grouping or client version.
## [4.1.12]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Fixed
- Enforce enterprise MCP controls on the Customize marketplace. MCP entries are now hidden when remote config disables the marketplace, and limited to `allowedMCPServers` when an allowlist is configured.
- Restore tool calling for custom OpenAI-Compatible models whose stored capability list was empty.
## [4.1.11]
Everything here lands through the SDK bundle, so it applies to windows running that bundle — except the last section, which is a legacy-bundle fix.
+9
View File
@@ -1,5 +1,14 @@
# Cline CLI Changelog
## 3.0.57
- Added `cline hub drain`, which stops a hub from accepting new mutating work while it finishes what it is already running, and `cline hub drain --off` to lift it
- Added `cline hub upgrade`, which drains the hub, waits for it to go idle, stops it, and starts a fresh one on the current build. An aborted upgrade lifts the drain again, so the hub is never left refusing work
- Sessions now survive a hub restart. A reconnecting client replays the events it missed while disconnected, deduped by event id so nothing is delivered twice
- Fixed tool calling being silently disabled for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`. The inferred list read as an authoritative denial and stripped every tool from the request
- Langfuse traces now carry session and client identity for hub-backed and delegated-agent runs, instead of arriving without their session grouping or client version
- Refreshed the model catalog, which updates model lists and pricing across providers and changes the resolved default model for several of them (DeepSeek, Crof, CrossModel, Eden AI, Kilo, and NanoGPT)
## 3.0.56
- Models that support image generation can now produce media during a turn. The TUI saves each generated file to a temporary path and prints it so you can open it with your usual tools, HTML session exports embed images inline, and ACP clients receive generated images as image content
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.56",
"version": "3.0.57",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+147
View File
@@ -3,16 +3,20 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const {
mockClearHubDiscovery,
mockEnsureDetachedHubServer,
mockLocalHubHasNoActiveSessions,
mockProbeHubServer,
mockReadHubDiscovery,
mockRequestHubDrain,
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockStopLocalHubServerGracefully,
} = vi.hoisted(() => ({
mockClearHubDiscovery: vi.fn(),
mockEnsureDetachedHubServer: vi.fn(),
mockLocalHubHasNoActiveSessions: vi.fn(),
mockProbeHubServer: vi.fn(),
mockReadHubDiscovery: vi.fn(),
mockRequestHubDrain: vi.fn(),
mockResolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "hub-production",
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
@@ -27,8 +31,10 @@ const {
vi.mock("@cline/core", () => ({
clearHubDiscovery: mockClearHubDiscovery,
ensureDetachedHubServer: mockEnsureDetachedHubServer,
localHubHasNoActiveSessions: mockLocalHubHasNoActiveSessions,
probeHubServer: mockProbeHubServer,
readHubDiscovery: mockReadHubDiscovery,
requestHubDrain: mockRequestHubDrain,
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
@@ -95,6 +101,147 @@ describe("createHubCommand", () => {
});
});
function createCommand() {
const output: string[] = [];
const errors: string[] = [];
let exitCode = 0;
const cmd = createHubCommand(
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: (text) => {
errors.push(text);
},
},
(code) => {
exitCode = code;
},
);
return {
cmd,
output,
errors,
exitCode: () => exitCode,
};
}
it("sends an un-drain request with drain --off", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
mockRequestHubDrain.mockResolvedValue(true);
const { cmd, output, exitCode } = createCommand();
await cmd.parseAsync(["drain", "--off"], { from: "user" });
expect(exitCode()).toBe(0);
expect(mockRequestHubDrain).toHaveBeenCalledWith(
"ws://127.0.0.1:25463/hub",
"token",
"cline hub drain --off",
{ off: true },
);
expect(JSON.parse(output[0] || "")).toEqual({
draining: false,
url: "ws://127.0.0.1:25463/hub",
});
});
it("drains without the off flag by default", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
mockRequestHubDrain.mockResolvedValue(true);
const { cmd, output, exitCode } = createCommand();
await cmd.parseAsync(["drain"], { from: "user" });
expect(exitCode()).toBe(0);
expect(mockRequestHubDrain).toHaveBeenCalledWith(
"ws://127.0.0.1:25463/hub",
"token",
"cline hub drain",
{ off: false },
);
expect(JSON.parse(output[0] || "")).toEqual({
draining: true,
url: "ws://127.0.0.1:25463/hub",
});
});
it("replaces an idle hub with upgrade --wait 0 instead of skipping the idle check", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
mockRequestHubDrain.mockResolvedValue(true);
mockLocalHubHasNoActiveSessions.mockResolvedValue(true);
mockStopLocalHubServerGracefully.mockResolvedValue(true);
mockEnsureDetachedHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "new-token",
});
const { cmd, output, errors, exitCode } = createCommand();
await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" });
expect(errors).toEqual([]);
expect(exitCode()).toBe(0);
expect(mockLocalHubHasNoActiveSessions).toHaveBeenCalled();
expect(mockStopLocalHubServerGracefully).toHaveBeenCalled();
expect(mockEnsureDetachedHubServer).toHaveBeenCalled();
// The drain was never lifted manually: the drained hub was replaced.
expect(mockRequestHubDrain).toHaveBeenCalledTimes(1);
expect(JSON.parse(output[0] || "")).toEqual({
upgraded: true,
url: "ws://127.0.0.1:25463/hub",
});
});
it("un-drains the hub when upgrade aborts because sessions are still active", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
mockRequestHubDrain.mockResolvedValue(true);
mockLocalHubHasNoActiveSessions.mockResolvedValue(false);
const { cmd, errors, exitCode } = createCommand();
await cmd.parseAsync(["upgrade", "--wait", "0"], { from: "user" });
expect(exitCode()).toBe(1);
expect(errors[0]).toContain("still serving sessions");
expect(mockStopLocalHubServerGracefully).not.toHaveBeenCalled();
expect(mockEnsureDetachedHubServer).not.toHaveBeenCalled();
expect(mockRequestHubDrain).toHaveBeenCalledTimes(2);
expect(mockRequestHubDrain).toHaveBeenLastCalledWith(
"ws://127.0.0.1:25463/hub",
"token",
"cline hub upgrade aborted",
{ off: true },
);
});
it("rejects a non-numeric upgrade --wait instead of treating it as an expired deadline", async () => {
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "token",
});
const { cmd } = createCommand();
cmd.configureOutput({ writeErr: () => {} });
for (const sub of cmd.commands) {
sub.configureOutput({ writeErr: () => {} });
}
await expect(
cmd.parseAsync(["upgrade", "--wait", "soon"], { from: "user" }),
).rejects.toThrow("--wait requires a non-negative number of seconds.");
expect(mockRequestHubDrain).not.toHaveBeenCalled();
});
it("passes the selected owner to graceful stop", async () => {
process.env.CLINE_BUILD_ENV = "development";
mockReadHubDiscovery.mockResolvedValue({
+123 -1
View File
@@ -1,14 +1,16 @@
import {
clearHubDiscovery,
ensureDetachedHubServer,
localHubHasNoActiveSessions,
probeHubServer,
readHubDiscovery,
requestHubDrain,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
import { Command, InvalidArgumentError } from "commander";
import { version as cliVersion } from "../../package.json";
interface HubCommandIo {
@@ -54,6 +56,16 @@ function resolveCliHubOwnerContext() {
: resolveSharedHubOwnerContext();
}
function parseWaitSeconds(value: string): number {
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed) || parsed < 0) {
throw new InvalidArgumentError(
"--wait requires a non-negative number of seconds.",
);
}
return parsed;
}
export function createHubCommand(
io: HubCommandIo,
setExitCode: (code: number) => void,
@@ -150,5 +162,115 @@ export function createHubCommand(
}),
);
hub
.command("drain")
.description("Refuse new mutating work while accepted runs finish")
.option("--reason <text>", "Why the hub is draining")
.option("--off", "Lift the drain and accept new mutating work again")
.action(
action(async (cmdOptions: { reason?: string; off?: boolean }) => {
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (!discovery?.url) {
io.writeErr("No hub is running.");
fail();
return;
}
const draining = cmdOptions.off !== true;
const ok = await requestHubDrain(
discovery.url,
discovery.authToken,
cmdOptions.reason ??
(draining ? "cline hub drain" : "cline hub drain --off"),
{ off: !draining },
);
if (!ok) {
io.writeErr(
draining ? "Hub drain request failed." : "Hub un-drain request failed.",
);
fail();
return;
}
io.writeln(JSON.stringify({ draining, url: discovery.url }));
}),
);
hub
.command("upgrade")
.description(
"Drain, wait for the hub to go idle, stop it, and start a fresh one",
)
.option(
"--wait <seconds>",
"How long to wait for the hub to go idle",
parseWaitSeconds,
120,
)
.action(
action(async (cmdOptions: { wait: number }) => {
const opts = hub.opts<{
cwd: string;
host?: string;
port?: number;
pathname?: string;
}>();
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (discovery?.url) {
const drained = await requestHubDrain(
discovery.url,
discovery.authToken,
"cline hub upgrade",
).catch(() => false);
// An aborted upgrade must hand the hub back: leaving it
// draining refuses all new mutating work until a restart.
const undrain = async (): Promise<void> => {
if (!drained) {
return;
}
await requestHubDrain(
discovery.url,
discovery.authToken,
"cline hub upgrade aborted",
{ off: true },
).catch(() => false);
};
try {
const deadline = Date.now() + cmdOptions.wait * 1_000;
let idle = false;
// Check at least once so --wait 0 still observes an idle hub.
for (;;) {
idle = await localHubHasNoActiveSessions(
discovery.url,
discovery.authToken,
).catch(() => true);
if (idle || Date.now() >= deadline) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 1_000));
}
if (!idle) {
await undrain();
io.writeErr(
"Hub is still serving sessions after the wait window; not replacing it. Re-run with a longer --wait, or finish the sessions first.",
);
fail();
return;
}
await stopHubServer(opts.cwd);
} catch (error) {
await undrain();
throw error;
}
}
const { url } = await ensureDetachedHubServer(opts.cwd, {
host: opts.host,
port: opts.port,
pathname: opts.pathname,
});
io.writeln(JSON.stringify({ upgraded: true, url }));
}),
);
return hub;
}
+7
View File
@@ -1,5 +1,12 @@
# Cline Desktop Changelog
## 0.0.16
- The agent can now be handed off between Hub instances without losing work: a Hub that is restarting refuses new work while it finishes what it is running, and the app replays anything it missed while disconnected instead of dropping it
- Fixed tool calling being silently disabled for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`
- Refreshed the model catalog, which updates model lists and pricing across providers and changes the resolved default model for several of them (DeepSeek, Crof, CrossModel, Eden AI, Kilo, and NanoGPT)
- The app now honors server-side feature flags, refreshing them when your account changes
## 0.0.15
- The app is now called Cline, renamed from Cline Code. Your settings, sessions, and credentials carry over untouched — only the name and icon change
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.15",
"version": "0.0.16",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
@@ -33,10 +33,10 @@
"@cline/llms": "workspace:*",
"@cline/shared": "workspace:*",
"@cline/ui": "workspace:*",
"@pierre/diffs": "^1.3.0",
"@fontsource-variable/geist-mono": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8",
"@hookform/resolvers": "^3.9.1",
"@pierre/diffs": "^1.3.0",
"@radix-ui/react-accordion": "1.2.12",
"@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-aspect-ratio": "1.1.8",
@@ -80,6 +80,7 @@
"next": "16.2.11",
"next-themes": "^0.4.6",
"pino": "^10.3.1",
"posthog-node": "^5.8.0",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-day-picker": "9.13.2",
@@ -5,6 +5,7 @@ import type { SidecarContext } from "./types";
const clineAccountServiceCtorMock = vi.hoisted(() => vi.fn());
const executeClineAccountActionMock = vi.hoisted(() => vi.fn());
const getProviderSettingsMock = vi.hoisted(() => vi.fn());
const saveProviderSettingsMock = vi.hoisted(() => vi.fn());
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", async () => {
@@ -21,6 +22,7 @@ vi.mock("@cline/core", async () => {
ProviderSettingsManager: class {
getProviderSettings = getProviderSettingsMock;
},
saveLocalProviderSettings: saveProviderSettingsMock,
RuntimeOAuthTokenManager: class {
resolveProviderApiKey = resolveProviderApiKeyMock;
},
@@ -50,6 +52,7 @@ beforeEach(() => {
clineAccountServiceCtorMock.mockReset();
executeClineAccountActionMock.mockReset();
getProviderSettingsMock.mockReset();
saveProviderSettingsMock.mockReset();
resolveProviderApiKeyMock.mockReset();
});
@@ -140,3 +143,162 @@ describe("cline_account command auth states", () => {
});
});
});
/**
* Feature-flag identity is otherwise resolved once at sidecar startup, so these
* cover the mid-session transitions that would otherwise keep evaluating flags
* against a stale account (or the device).
*/
describe("cline_account keeps feature-flag identity in sync", () => {
async function currentFlagsUserId(): Promise<string | undefined> {
const { getDesktopFeatureFlagsContext } = await import("./feature-flags");
return getDesktopFeatureFlagsContext().userId ?? undefined;
}
async function runOperation(ctx: SidecarContext, operation: string) {
const { handleCommand } = await import("./commands");
return handleCommand(ctx, "cline_account", {
action: "clineAccount",
operation,
});
}
beforeEach(async () => {
const { resetDesktopFeatureFlagsForTesting } = await import(
"./feature-flags"
);
resetDesktopFeatureFlagsForTesting();
});
it("adopts the account identity on login", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({
id: "acct-1",
email: "dev@example.com",
});
await runOperation(ctx, "fetchMe");
expect(await currentFlagsUserId()).toBe("acct-1");
});
it("leaves the signed-in identity intact across an organization switch", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
await runOperation(ctx, "fetchMe");
expect(await currentFlagsUserId()).toBe("acct-1");
executeClineAccountActionMock.mockResolvedValue(undefined);
getProviderSettingsMock.mockReturnValue({
auth: { accountId: "stale-acct" },
});
await runOperation(ctx, "switchAccount");
expect(await currentFlagsUserId()).toBe("acct-1");
});
it("adopts the identity from the refetch that follows a switch", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
await runOperation(ctx, "fetchMe");
executeClineAccountActionMock.mockResolvedValue(undefined);
await runOperation(ctx, "switchAccount");
executeClineAccountActionMock.mockResolvedValue({ id: "acct-2" });
await runOperation(ctx, "fetchMe");
expect(await currentFlagsUserId()).toBe("acct-2");
});
it("clears the account identity on logout", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
await runOperation(ctx, "fetchMe");
expect(await currentFlagsUserId()).toBe("acct-1");
// Signed out: no token resolves.
resolveProviderApiKeyMock.mockResolvedValue(null);
getProviderSettingsMock.mockReturnValue(undefined);
await runOperation(ctx, "fetchMe");
expect(await currentFlagsUserId()).toBeUndefined();
});
it("clears the identity when sign-out blanks the cline auth settings", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
await runOperation(ctx, "fetchMe");
expect(await currentFlagsUserId()).toBe("acct-1");
// What the Sign Out button actually sends: a settings write that blanks
// the auth block. No account command is involved.
getProviderSettingsMock.mockReturnValue({ auth: { accountId: "" } });
saveProviderSettingsMock.mockReturnValue({
providerId: "cline",
enabled: true,
settingsPath: "/tmp/settings.json",
});
const { handleCommand } = await import("./commands");
await handleCommand(ctx, "save_provider_settings", {
provider: "cline",
api_key: "",
settings: { auth: { accessToken: "", refreshToken: "", accountId: "" } },
});
expect(await currentFlagsUserId()).toBeUndefined();
});
it("ignores settings writes for other providers", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
await runOperation(ctx, "fetchMe");
saveProviderSettingsMock.mockReturnValue({
providerId: "anthropic",
enabled: true,
settingsPath: "/tmp/settings.json",
});
const { handleCommand } = await import("./commands");
await handleCommand(ctx, "save_provider_settings", {
provider: "anthropic",
api_key: "sk-test",
});
// Saving an unrelated provider must not disturb the Cline identity.
expect(await currentFlagsUserId()).toBe("acct-1");
});
it("falls back to the device distinct ID after logout", async () => {
const { ctx } = createContext();
const { getDesktopFeatureFlagsContext } = await import("./feature-flags");
const deviceId = getDesktopFeatureFlagsContext().distinctId;
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
getProviderSettingsMock.mockReturnValue({});
executeClineAccountActionMock.mockResolvedValue({ id: "acct-1" });
await runOperation(ctx, "fetchMe");
expect(getDesktopFeatureFlagsContext().distinctId).toBe("acct-1");
resolveProviderApiKeyMock.mockResolvedValue(null);
getProviderSettingsMock.mockReturnValue(undefined);
await runOperation(ctx, "fetchMe");
// Not left on the previous account's ID.
expect(getDesktopFeatureFlagsContext().distinctId).toBe(deviceId);
});
});
+63 -2
View File
@@ -70,6 +70,10 @@ import {
resolveSidecarAskQuestion,
sendEventToClient,
} from "./context";
import {
identifyDesktopFeatureFlagsAccount,
refreshDesktopFeatureFlags,
} from "./feature-flags";
import {
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
@@ -294,6 +298,33 @@ function removePathIfExists(
// refreshes would invalidate each other.
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
function syncFeatureFlagsAccountFromResult(
ctx: SidecarContext,
operation: string,
result: unknown,
): void {
if (operation === "fetchMe") {
const user = result as { id?: string; email?: string } | undefined;
if (user?.id) {
void identifyDesktopFeatureFlagsAccount(
{ id: user.id, email: user.email },
{ logger: ctx.logger, telemetry: ctx.telemetry },
);
}
return;
}
}
function syncFeatureFlagsAccountFromSettings(
ctx: SidecarContext,
manager: ProviderSettingsManager,
): void {
void identifyDesktopFeatureFlagsAccount(
{ id: manager.getProviderSettings("cline")?.auth?.accountId },
{ logger: ctx.logger, telemetry: ctx.telemetry },
);
}
async function resolveFreshClineAuthToken(
ctx: SidecarContext,
manager: ProviderSettingsManager,
@@ -1548,6 +1579,14 @@ export async function handleCommand(
// would be captured as error telemetry and shown raw to the user.
const authToken = await resolveFreshClineAuthToken(ctx, manager);
if (!authToken) {
// Backstop for credentials that go away without a settings write —
// an expired or server-revoked token. Explicit sign-out is handled
// at its source in `save_provider_settings`; this catches the rest
// so a stale account never keeps serving its rollout cohort.
void identifyDesktopFeatureFlagsAccount(
{},
{ logger: ctx.logger, telemetry: ctx.telemetry },
);
return CLINE_ACCOUNT_NOT_AUTHENTICATED_RESULT;
}
const settings = manager.getProviderSettings("cline");
@@ -1556,10 +1595,12 @@ export async function handleCommand(
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
getAuthToken: async () => authToken,
});
return await executeClineAccountAction(
const result = await executeClineAccountAction(
args as ClineAccountActionRequest,
accountService,
);
syncFeatureFlagsAccountFromResult(ctx, operation, result);
return result;
}
// ── Provider management ────────────────────────────────────────────
@@ -1718,13 +1759,21 @@ export async function handleCommand(
}
if (command === "save_provider_settings") {
const manager = new ProviderSettingsManager();
return saveLocalProviderSettings(manager, {
const saved = saveLocalProviderSettings(manager, {
...readProviderSettingsUpdate(args),
providerId: String(args?.provider ?? ""),
enabled: typeof args?.enabled === "boolean" ? args.enabled : undefined,
apiKey: typeof args?.api_key === "string" ? args.api_key : undefined,
baseUrl: typeof args?.base_url === "string" ? args.base_url : undefined,
});
// Sign-out is a `save_provider_settings` that blanks the cline auth block
// (see signOut in webview settings/account-view.tsx), so this is the
// authoritative signal — it fires the moment credentials are cleared
// rather than waiting for the next account fetch.
if (saved.providerId === "cline" || saved.providerId === "cline-pass") {
syncFeatureFlagsAccountFromSettings(ctx, manager);
}
return saved;
}
if (command === "add_provider") {
const manager = new ProviderSettingsManager();
@@ -1827,6 +1876,18 @@ export async function handleCommand(
return readGlobalSettings();
}
// ── Feature flags ──────────────────────────────────────────────────
// Flags are evaluated here, not in the webview: the sidecar already has
// the PostHog key inlined at build time and evaluates against the same
// distinct ID it reports telemetry with. The client just reads the
// resolved values.
if (command === "get_feature_flags") {
return await refreshDesktopFeatureFlags({
logger: ctx.logger,
telemetry: ctx.telemetry,
});
}
// ── Connector channels ─────────────────────────────────────────────
if (command === "list_connector_channels") {
return connectorChannelsPayload();
@@ -26,6 +26,10 @@ import {
markQueuedAttachmentsSubmitted,
reconcileQueuedAttachments,
} from "./attachments";
import {
disposeDesktopFeatureFlagsService,
getDesktopFeatureFlagsService,
} from "./feature-flags";
import { sessionLogPath } from "./paths";
import type {
LiveSession,
@@ -627,6 +631,10 @@ export async function disposeSidecarContext(
cleanup.push(sessionManager.dispose(reason));
}
// Shuts down the PostHog client the feature flags service owns, flushing
// any pending $feature_flag_called events.
cleanup.push(disposeDesktopFeatureFlagsService());
const results = await Promise.allSettled(cleanup);
const firstFailure = results.find(
(result): result is PromiseRejectedResult => result.status === "rejected",
@@ -1021,6 +1029,10 @@ export async function initializeSessionManager(
capabilities: createSidecarRuntimeCapabilities(ctx),
logger: ctx.logger,
telemetry: ctx.telemetry,
featureFlags: getDesktopFeatureFlagsService({
logger: ctx.logger,
telemetry: ctx.telemetry,
}),
hub: {
strategy: "require-hub",
workspaceRoot: ctx.workspaceRoot,
@@ -0,0 +1,241 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
buildClinePostHogClient: vi.fn(() => ({ kind: "posthog-client" })),
PostHogFeatureFlagsProvider: vi.fn(function PostHogFeatureFlagsProvider(
this: Record<string, unknown>,
options: unknown,
) {
this.kind = "posthog";
this.options = options;
}),
NoOpFeatureFlagsProvider: vi.fn(function NoOpFeatureFlagsProvider(
this: Record<string, unknown>,
) {
this.kind = "noop";
}),
resolveCoreDistinctId: vi.fn(() => "machine-distinct-id"),
poll: vi.fn(async () => {}),
dispose: vi.fn(async () => {}),
setContext: vi.fn(),
getFlagPayload: vi.fn((_flag: unknown): unknown => undefined),
}));
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
// Two known flags keep the snapshot assertions meaningful even as the
// real registry changes.
FEATURE_FLAGS: ["ext-cline-pass", "ext-demo-flag"],
NoOpFeatureFlagsProvider: mocks.NoOpFeatureFlagsProvider,
resolveCoreDistinctId: mocks.resolveCoreDistinctId,
FeatureFlagsService: class {
options: Record<string, unknown>;
constructor(options: Record<string, unknown>) {
this.options = options;
}
poll = mocks.poll;
dispose = mocks.dispose;
setContext = mocks.setContext;
getFlagPayload = mocks.getFlagPayload;
},
};
});
vi.mock("@cline/core/services/feature-flags/posthog", () => ({
buildClinePostHogClient: mocks.buildClinePostHogClient,
PostHogFeatureFlagsProvider: mocks.PostHogFeatureFlagsProvider,
}));
import {
buildFeatureFlagsSnapshot,
disposeDesktopFeatureFlagsService,
getDesktopFeatureFlagsContext,
getDesktopFeatureFlagsService,
refreshDesktopFeatureFlags,
resetDesktopFeatureFlagsForTesting,
setDesktopFeatureFlagsAccountContext,
} from "./feature-flags";
const originalApiKey = process.env.TELEMETRY_SERVICE_API_KEY;
const originalIsTest = process.env.IS_TEST;
beforeEach(() => {
vi.clearAllMocks();
resetDesktopFeatureFlagsForTesting();
delete process.env.IS_TEST;
delete process.env.E2E_TEST;
});
afterEach(() => {
if (originalApiKey === undefined) {
delete process.env.TELEMETRY_SERVICE_API_KEY;
} else {
process.env.TELEMETRY_SERVICE_API_KEY = originalApiKey;
}
if (originalIsTest === undefined) {
delete process.env.IS_TEST;
} else {
process.env.IS_TEST = originalIsTest;
}
});
describe("getDesktopFeatureFlagsService", () => {
it("uses PostHog when the build-time key is inlined", () => {
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
getDesktopFeatureFlagsService();
expect(mocks.PostHogFeatureFlagsProvider).toHaveBeenCalledTimes(1);
expect(mocks.buildClinePostHogClient).toHaveBeenCalledWith("phc_key");
expect(mocks.NoOpFeatureFlagsProvider).not.toHaveBeenCalled();
});
it("falls back to the no-op provider when no key was inlined", () => {
delete process.env.TELEMETRY_SERVICE_API_KEY;
getDesktopFeatureFlagsService();
expect(mocks.NoOpFeatureFlagsProvider).toHaveBeenCalledTimes(1);
expect(mocks.PostHogFeatureFlagsProvider).not.toHaveBeenCalled();
});
it("never calls PostHog under IS_TEST even with a key present", () => {
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
process.env.IS_TEST = "true";
getDesktopFeatureFlagsService();
expect(mocks.NoOpFeatureFlagsProvider).toHaveBeenCalledTimes(1);
expect(mocks.PostHogFeatureFlagsProvider).not.toHaveBeenCalled();
});
it("returns one shared instance so the core and the webview agree", () => {
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
expect(getDesktopFeatureFlagsService()).toBe(
getDesktopFeatureFlagsService(),
);
expect(mocks.PostHogFeatureFlagsProvider).toHaveBeenCalledTimes(1);
});
});
describe("feature flags context", () => {
it("defaults to the machine distinct ID under the cline-code client name", () => {
const context = getDesktopFeatureFlagsContext();
expect(context.clientName).toBe("cline-code");
expect(context.distinctId).toBe("machine-distinct-id");
});
it("switches to the account ID once signed in, and pushes it to the service", () => {
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
getDesktopFeatureFlagsService();
setDesktopFeatureFlagsAccountContext({
id: "acct-1",
email: "dev@example.com",
});
const context = getDesktopFeatureFlagsContext();
expect(context.distinctId).toBe("acct-1");
expect(context.userId).toBe("acct-1");
expect(mocks.setContext).toHaveBeenCalledTimes(1);
});
it("keeps the device identity when the account ID is blank", () => {
setDesktopFeatureFlagsAccountContext({ id: " " });
expect(getDesktopFeatureFlagsContext().distinctId).toBe(
"machine-distinct-id",
);
});
it("clears the account identity on sign-out and falls back to the device", () => {
setDesktopFeatureFlagsAccountContext({ id: "acct-1" });
expect(getDesktopFeatureFlagsContext().userId).toBe("acct-1");
expect(setDesktopFeatureFlagsAccountContext({})).toBe(true);
const context = getDesktopFeatureFlagsContext();
expect(context.userId).toBeUndefined();
// Must not be left on the signed-out account's ID.
expect(context.distinctId).toBe("machine-distinct-id");
});
it("reports no change when the same account is re-confirmed", () => {
expect(setDesktopFeatureFlagsAccountContext({ id: "acct-1" })).toBe(true);
expect(setDesktopFeatureFlagsAccountContext({ id: "acct-1" })).toBe(false);
});
it("reports no change when signed out twice", () => {
expect(setDesktopFeatureFlagsAccountContext({})).toBe(false);
});
it("re-points at the new account when switching accounts", () => {
setDesktopFeatureFlagsAccountContext({ id: "acct-1" });
expect(setDesktopFeatureFlagsAccountContext({ id: "acct-2" })).toBe(true);
const context = getDesktopFeatureFlagsContext();
expect(context.userId).toBe("acct-2");
expect(context.distinctId).toBe("acct-2");
});
});
describe("buildFeatureFlagsSnapshot", () => {
it("resolves every known flag so the client needs no defaults", () => {
mocks.getFlagPayload.mockImplementation((flag: unknown) =>
flag === "ext-cline-pass" ? true : undefined,
);
const snapshot = buildFeatureFlagsSnapshot(
getDesktopFeatureFlagsService() as never,
);
expect(snapshot.flags).toEqual({
"ext-cline-pass": true,
// Unreturned flags resolve to false rather than being absent.
"ext-demo-flag": false,
});
});
it("passes non-boolean payloads through untouched", () => {
mocks.getFlagPayload.mockImplementation((flag: unknown) =>
flag === "ext-cline-pass" ? { variant: "b", limit: 3 } : false,
);
const snapshot = buildFeatureFlagsSnapshot(
getDesktopFeatureFlagsService() as never,
);
expect(snapshot.flags["ext-cline-pass"]).toEqual({
variant: "b",
limit: 3,
});
});
});
describe("refreshDesktopFeatureFlags", () => {
it("polls before returning the snapshot", async () => {
mocks.getFlagPayload.mockReturnValue(true);
const snapshot = await refreshDesktopFeatureFlags();
expect(mocks.poll).toHaveBeenCalledTimes(1);
expect(snapshot.flags["ext-cline-pass"]).toBe(true);
});
it("still returns cached values when the poll fails", async () => {
mocks.poll.mockRejectedValueOnce(new Error("offline"));
mocks.getFlagPayload.mockReturnValue(false);
const logger = { error: vi.fn(), log: vi.fn(), debug: vi.fn() };
const snapshot = await refreshDesktopFeatureFlags({ logger });
expect(snapshot.flags["ext-cline-pass"]).toBe(false);
expect(logger.error).toHaveBeenCalled();
});
});
describe("disposeDesktopFeatureFlagsService", () => {
it("disposes the live service and clears it", async () => {
process.env.TELEMETRY_SERVICE_API_KEY = "phc_key";
getDesktopFeatureFlagsService();
await disposeDesktopFeatureFlagsService();
expect(mocks.dispose).toHaveBeenCalledTimes(1);
// A later call builds a fresh service rather than reusing a disposed one.
getDesktopFeatureFlagsService();
expect(mocks.PostHogFeatureFlagsProvider).toHaveBeenCalledTimes(2);
});
it("is a no-op when nothing was created", async () => {
await expect(disposeDesktopFeatureFlagsService()).resolves.toBeUndefined();
expect(mocks.dispose).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,171 @@
import { join } from "node:path";
import {
type BasicLogger,
FEATURE_FLAGS,
type FeatureFlagPayload,
type FeatureFlagsContext,
FeatureFlagsService,
type ITelemetryService,
NoOpFeatureFlagsProvider,
resolveCoreDistinctId,
} from "@cline/core";
import {
buildClinePostHogClient,
PostHogFeatureFlagsProvider,
} from "@cline/core/services/feature-flags/posthog";
import { resolveClineDataDir } from "@cline/shared/storage";
const DESKTOP_FEATURE_FLAGS_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
let desktopFeatureFlagsContext: FeatureFlagsContext = {
clientName: "cline-code",
};
let desktopFeatureFlagsService: FeatureFlagsService | undefined;
function resolveDesktopFeatureFlagsCachePath(): string {
return join(resolveClineDataDir(), "cache", "feature-flags.cline-code.json");
}
function ensureDesktopDistinctId(): string {
const distinctId = desktopFeatureFlagsContext.distinctId?.trim();
if (distinctId) {
return distinctId;
}
const resolved = resolveCoreDistinctId();
desktopFeatureFlagsContext.distinctId = resolved;
return resolved;
}
export function getDesktopFeatureFlagsContext(): FeatureFlagsContext {
ensureDesktopDistinctId();
return { ...desktopFeatureFlagsContext };
}
export function getDesktopFeatureFlagsService(options?: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
}): FeatureFlagsService {
if (!desktopFeatureFlagsService) {
const apiKey = process.env.TELEMETRY_SERVICE_API_KEY;
const provider =
apiKey &&
process.env.IS_TEST !== "true" &&
process.env.E2E_TEST !== "true"
? new PostHogFeatureFlagsProvider({
client: buildClinePostHogClient(apiKey),
config: {
logger: options?.logger,
},
})
: new NoOpFeatureFlagsProvider();
desktopFeatureFlagsService = new FeatureFlagsService({
provider,
telemetry: options?.telemetry,
logger: options?.logger,
context: getDesktopFeatureFlagsContext(),
cacheFilePath: resolveDesktopFeatureFlagsCachePath(),
persistentCacheMaxAgeMs: DESKTOP_FEATURE_FLAGS_CACHE_MAX_AGE_MS,
});
}
return desktopFeatureFlagsService;
}
export async function disposeDesktopFeatureFlagsService(): Promise<void> {
if (!desktopFeatureFlagsService) {
return;
}
const current = desktopFeatureFlagsService;
desktopFeatureFlagsService = undefined;
await current.dispose();
}
export function setDesktopFeatureFlagsAccountContext(account: {
id?: string;
email?: string;
}): boolean {
const accountId = account.id?.trim();
const previousUserId = desktopFeatureFlagsContext.userId ?? undefined;
if (previousUserId === (accountId || undefined)) {
return false;
}
if (accountId) {
desktopFeatureFlagsContext = {
...desktopFeatureFlagsContext,
distinctId: accountId,
userId: accountId,
};
} else {
// Drop both identifiers; ensureDesktopDistinctId re-resolves the device
// ID on the next read rather than leaving the old account's ID behind.
const {
distinctId: _distinctId,
userId: _userId,
...rest
} = desktopFeatureFlagsContext;
desktopFeatureFlagsContext = rest;
}
desktopFeatureFlagsService?.setContext(getDesktopFeatureFlagsContext());
return true;
}
export type FeatureFlagsSnapshot = {
flags: Record<string, FeatureFlagPayload>;
};
export function buildFeatureFlagsSnapshot(
service: FeatureFlagsService,
): FeatureFlagsSnapshot {
const flags: Record<string, FeatureFlagPayload> = {};
for (const flag of FEATURE_FLAGS) {
flags[flag] = service.getFlagPayload(flag) ?? false;
}
return { flags };
}
/**
* Refresh flags from PostHog, then hand back the resolved snapshot.
*
* Polling is cheap to call repeatedly.
*/
export async function refreshDesktopFeatureFlags(options?: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
}): Promise<FeatureFlagsSnapshot> {
const service = getDesktopFeatureFlagsService(options);
try {
await service.poll();
} catch (error) {
options?.logger?.error?.("Error refreshing desktop feature flags", {
error,
});
}
return buildFeatureFlagsSnapshot(service);
}
export async function identifyDesktopFeatureFlagsAccount(
account: { id?: string; email?: string },
options?: { logger?: BasicLogger; telemetry?: ITelemetryService },
): Promise<void> {
if (
!setDesktopFeatureFlagsAccountContext(account) ||
!desktopFeatureFlagsService
) {
return;
}
try {
await desktopFeatureFlagsService.poll();
} catch (error) {
options?.logger?.error?.("Error polling desktop feature flags", { error });
}
}
export function resetDesktopFeatureFlagsForTesting(): void {
desktopFeatureFlagsService = undefined;
desktopFeatureFlagsContext = { clientName: "cline-code" };
}
@@ -9,6 +9,7 @@ import {
setSdkLogger,
} from "@cline/core";
import { version } from "../package.json";
import { setDesktopFeatureFlagsAccountContext } from "./feature-flags";
import {
createDesktopLoggerAdapter,
type DesktopLoggerAdapter,
@@ -45,6 +46,7 @@ export function createDesktopObservability(): DesktopObservability {
id: auth.accountId,
provider: "cline",
});
setDesktopFeatureFlagsAccountContext({ id: auth.accountId });
}
captureExtensionActivated(telemetry);
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cline",
"version": "0.0.15",
"version": "0.0.16",
"identifier": "bot.cline.app",
"build": {
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
@@ -0,0 +1,54 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { desktopClient } from "@/lib/desktop-client";
export type FeatureFlagValue =
| string
| number
| boolean
| null
| { [key: string]: FeatureFlagValue }
| FeatureFlagValue[];
type FeatureFlagsSnapshot = {
flags: Record<string, FeatureFlagValue>;
};
export type FeatureFlagsState = {
flags: Record<string, FeatureFlagValue>;
loaded: boolean;
refresh: () => Promise<void>;
};
export function useFeatureFlags(): FeatureFlagsState {
const [flags, setFlags] = useState<Record<string, FeatureFlagValue>>({});
const [loaded, setLoaded] = useState(false);
const load = useCallback(async () => {
try {
const snapshot =
await desktopClient.invoke<FeatureFlagsSnapshot>("get_feature_flags");
setFlags(snapshot?.flags ?? {});
} catch {
// Sidecar down or still starting. Leave the previous values in place;
// every consumer falls back to `false`, matching the registry default
// for a flag nobody has been opted into.
} finally {
setLoaded(true);
}
}, []);
useEffect(() => {
void load();
}, [load]);
return { flags, loaded, refresh: load };
}
export function isFeatureEnabled(
flags: Record<string, FeatureFlagValue>,
flag: string,
): boolean {
return flags[flag] === true;
}
+1 -1
View File
@@ -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.11",
"version": "4.1.13",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.101.0"
@@ -6,6 +6,7 @@ import type { Controller } from "../../index"
const installMarketplaceEntryFromCatalogStub: sinon.SinonStub = sinon.stub()
const marketplaceHelpersMock = () => ({
installMarketplaceEntryFromCatalog: installMarketplaceEntryFromCatalogStub,
isMcpEntryAllowedByPolicy: () => true,
})
mock.module("../marketplace-helpers", marketplaceHelpersMock)
@@ -23,6 +24,7 @@ describe("installMarketplaceEntry", () => {
const controller = {
mcpHub: { reconcileMcpServersFromSettingsRPC },
invalidateUserInstructionService,
stateManager: { getRemoteConfigSettings: () => ({}) },
} as unknown as Controller
installMarketplaceEntryFromCatalogStub.resolves({
id: "chrome-devtools",
@@ -1,8 +1,11 @@
import type { EmptyRequest } from "@shared/proto/cline/common"
import type { MarketplaceCatalog } from "@shared/proto/cline/marketplace"
import type { Controller } from "../index"
import { fetchMarketplaceCatalog } from "./marketplace-helpers"
import { fetchMarketplaceCatalog, isMcpEntryAllowedByPolicy } from "./marketplace-helpers"
export async function getMarketplaceCatalog(_controller: Controller, _request: EmptyRequest): Promise<MarketplaceCatalog> {
return fetchMarketplaceCatalog()
export async function getMarketplaceCatalog(controller: Controller, _request: EmptyRequest): Promise<MarketplaceCatalog> {
const catalog = await fetchMarketplaceCatalog()
// Filter out MCP entries blocked by enterprise remote config so they never reach the webview.
const policy = controller.stateManager.getRemoteConfigSettings()
return { ...catalog, entries: catalog.entries.filter((entry) => isMcpEntryAllowedByPolicy(entry, policy)) }
}
@@ -1,6 +1,6 @@
import { type MarketplaceEntryRequest, MarketplaceInstallResult } from "@shared/proto/cline/marketplace"
import type { Controller } from "../index"
import { installMarketplaceEntryFromCatalog } from "./marketplace-helpers"
import { installMarketplaceEntryFromCatalog, isMcpEntryAllowedByPolicy } from "./marketplace-helpers"
export async function installMarketplaceEntry(
controller: Controller,
@@ -9,6 +9,11 @@ export async function installMarketplaceEntry(
if (!request.entry) {
throw new Error("Marketplace entry is required.")
}
if (!isMcpEntryAllowedByPolicy(request.entry, controller.stateManager.getRemoteConfigSettings())) {
throw new Error(
`Installing "${request.entry.name || request.entry.id}" is blocked by your organization's MCP server policy.`,
)
}
const result = await installMarketplaceEntryFromCatalog(request.entry)
if (request.entry.type === "mcp") {
await controller.mcpHub?.reconcileMcpServersFromSettingsRPC()
@@ -82,8 +82,17 @@ function sanitizeEntry(raw: unknown): MarketplaceEntry | undefined {
description: typeof record.description === "string" ? record.description : undefined,
tags: asStringArray(record.tags),
author: typeof record.author === "string" ? record.author : undefined,
sourceUrl: typeof record.sourceUrl === "string" ? record.sourceUrl : undefined,
homepageUrl: typeof record.homepageUrl === "string" ? record.homepageUrl : undefined,
// The published catalog uses "repo"/"homepage"; older entries may use
// "sourceUrl"/"homepageUrl". Accept both so URL-based enterprise
// allowlist ids can be matched against the entry.
sourceUrl:
typeof record.sourceUrl === "string" ? record.sourceUrl : typeof record.repo === "string" ? record.repo : undefined,
homepageUrl:
typeof record.homepageUrl === "string"
? record.homepageUrl
: typeof record.homepage === "string"
? record.homepage
: undefined,
install: install
? {
args: asStringArray(install.args),
@@ -134,6 +143,31 @@ function marketplaceKey(entry: MarketplaceEntry): string {
return `${entry.type}:${entry.id}`
}
/** Normalizes an allowlist id or entry identifier; legacy allowlist ids may be GitHub repo URLs. */
function normalizePolicyValue(value: string | undefined): string {
return normalizeMatchValue((value ?? "").replace(/^https?:\/\//i, "").replace(/\/+$/, ""))
}
/**
* Enterprise remote config can disable the MCP marketplace (`mcpMarketplaceEnabled: false`)
* or restrict it to an allowlist (`allowedMCPServers`). Non-MCP entries are not governed
* by these controls. Allowlist ids match the entry id, display name, installed server
* name, or source/homepage URL.
*/
export function isMcpEntryAllowedByPolicy(
entry: MarketplaceEntry,
policy: { mcpMarketplaceEnabled?: boolean; allowedMCPServers?: Array<{ id: string }> },
): boolean {
if (entry.type !== "mcp") return true
if (policy.mcpMarketplaceEnabled === false) return false
if (!policy.allowedMCPServers?.length) return true
const candidates = new Set(
[entry.id, entry.name, getEntryArgs(entry)[0], entry.sourceUrl, entry.homepageUrl].map(normalizePolicyValue),
)
candidates.delete("")
return policy.allowedMCPServers.some((server) => candidates.has(normalizePolicyValue(server.id)))
}
function getEntryArgs(entry: MarketplaceEntry): string[] {
return entry.install?.args ?? []
}
@@ -908,6 +908,35 @@ describe("buildSessionConfig", () => {
expect(knownModel.modalities).toEqual({ input: ["text", "image"], output: ["text", "image"] })
})
it("defaults tool-calling on when the preserved capability list is defined but empty", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "openrouter",
actModeOpenRouterModelId: "mock/empty-capabilities-model",
openRouterApiKey: "openrouter-key",
// A capabilities field that round-tripped through a boundary
// defaulting the missing array to [] — same "no signal" state as
// an absent one (modelHasCapability treats both as unspecified).
// Before the fix, the strict `=== undefined` guard skipped the
// tools seeding, supportsReasoning populated the array, and the
// runtime gate silently dropped every tool definition (#13463).
actModeOpenRouterModelInfo: {
name: "Empty Capabilities Model",
contextWindow: 16_000,
// Required by the store's isModelInfo gate: without a boolean
// supportsPromptCache the state snapshot is rejected and the
// model never reaches knownModels at all.
supportsPromptCache: false,
supportsReasoning: true,
capabilities: [],
},
} as any)
const config = await buildSessionConfig({ cwd: "/tmp/workspace" })
const knownModel = (config.providerConfig as any).knownModels["mock/empty-capabilities-model"]
expect(knownModel.capabilities).toEqual(expect.arrayContaining(["reasoning", "tools"]))
})
it("keeps legacy supportsTools=false authoritative for dynamic-list models", async () => {
mocks.stateManager.getApiConfiguration.mockReturnValue({
actModeApiProvider: "openrouter",
+9 -1
View File
@@ -258,13 +258,21 @@ function toSdkModelInfo(selection: ResolvedModelSelection): SdkModelInfo {
setCapability("prompt-cache", modelInfo.supportsPromptCache)
if (modelInfo.supportsReasoning !== undefined) setCapability("reasoning", modelInfo.supportsReasoning)
if (selection.overrides?.supportsAttachments !== undefined) setCapability("files", selection.overrides.supportsAttachments)
if (preservedCapabilities === undefined) {
if (preservedCapabilities === undefined || preservedCapabilities.length === 0) {
// No authoritative SDK list survived to here (dynamic-list snapshot,
// fallback metadata, or a custom model). The array we are rebuilding
// from booleans must still carry a definitive tool-calling signal,
// because a non-empty capabilities array without "tools" reads as
// "cannot call tools" to the SDK runtime. Legacy metadata only models
// tool support for OpenAI-compatible entries via `supportsTools`.
//
// An EMPTY array is the same "no signal" state as an absent one —
// modelHasCapability treats both as unspecified — and configs carried
// over from before the field existed (or round-tripped through a
// boundary that defaults it to []) land exactly here. Guarding only
// `undefined` let those custom models keep a non-empty, tool-less
// array once any boolean projection (e.g. reasoning) populated it,
// silently disabling tool calling at the runtime gate (#13463).
const supportsTools = (modelInfo as { supportsTools?: boolean }).supportsTools
setCapability("tools", supportsTools !== false)
}
@@ -490,7 +490,11 @@ describe("createProviderConfigStore", () => {
name: "Legacy Custom",
maxTokens: 4_096,
contextWindow: 64_000,
capabilities: ["prompt-cache"],
// "tools" must always ride along: legacy ModelInfo carries no
// tool-calling boolean, and a persisted capability list without
// "tools" reads as authoritative "cannot call tools" to the SDK
// runtime (#13463).
capabilities: ["tools", "prompt-cache"],
supportsVision: false,
supportsReasoning: true,
inputPrice: 1,
+7 -1
View File
@@ -731,7 +731,13 @@ function legacyModelInfoToOverrides(modelInfo: ModelInfo, fallback: ModelInfo):
if (Boolean(modelInfo.supportsReasoning) !== Boolean(fallback.supportsReasoning))
overrides.supportsReasoning = Boolean(modelInfo.supportsReasoning)
if (modelInfo.supportsPromptCache !== fallback.supportsPromptCache) {
const capabilities: string[] = []
// Legacy ModelInfo has no tool-calling boolean, so this projection
// carries no "no tools" signal. Persisting the list without "tools"
// would read as an authoritative tool-less capability list to the SDK
// runtime once stored in models.json, silently disabling tool calling
// (#13463). Match the providers.json migration, which always writes
// "tools" for OpenAI-compatible custom models.
const capabilities: string[] = ["tools"]
if (supportsVision) capabilities.push("images")
if (modelInfo.supportsPromptCache) capabilities.push("prompt-cache")
overrides.capabilities = capabilities
+8 -6
View File
@@ -19,7 +19,7 @@
},
"apps/cli": {
"name": "@cline/cli",
"version": "3.0.56",
"version": "3.0.57",
"bin": {
"cline": "src/index.ts",
},
@@ -219,6 +219,7 @@
"next": "16.2.11",
"next-themes": "^0.4.6",
"pino": "^10.3.1",
"posthog-node": "^5.8.0",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-day-picker": "9.13.2",
@@ -635,7 +636,7 @@
},
"sdk/packages/agents": {
"name": "@cline/agents",
"version": "0.0.77",
"version": "0.0.78",
"dependencies": {
"@cline/llms": "workspace:*",
"@cline/shared": "workspace:*",
@@ -644,7 +645,7 @@
},
"sdk/packages/core": {
"name": "@cline/core",
"version": "0.0.77",
"version": "0.0.78",
"dependencies": {
"@cline/agents": "workspace:*",
"@cline/llms": "workspace:*",
@@ -682,7 +683,7 @@
},
"sdk/packages/llms": {
"name": "@cline/llms",
"version": "0.0.77",
"version": "0.0.78",
"dependencies": {
"@ai-sdk/amazon-bedrock": "^5.0.50",
"@ai-sdk/anthropic": "^4.0.36",
@@ -698,6 +699,7 @@
"@aws-sdk/credential-providers": "^3.922.0",
"@cline/shared": "workspace:*",
"@jerome-benoit/sap-ai-provider": "4.8.0",
"@langfuse/core": "5.10.1",
"@langfuse/otel": "5.10.1",
"@langfuse/vercel-ai-sdk": "5.9.1",
"@openrouter/ai-sdk-provider": "^3",
@@ -728,14 +730,14 @@
},
"sdk/packages/sdk": {
"name": "@cline/sdk",
"version": "0.0.77",
"version": "0.0.78",
"dependencies": {
"@cline/core": "workspace:*",
},
},
"sdk/packages/shared": {
"name": "@cline/shared",
"version": "0.0.77",
"version": "0.0.78",
"dependencies": {
"aws4fetch": "^1.0.20",
"jsonrepair": "^3.13.2",
@@ -4,7 +4,7 @@ sidebarTitle: "MCP Server Controls"
description: "Enterprise controls for MCP server allowlisting and remote MCP server management"
---
Cline no longer exposes an MCP Marketplace browse/install surface in the SDK-backed VS Code extension. For Enterprise administrators, the legacy remote configuration field names are still supported for compatibility and now govern MCP server access: local server availability, local server allowlisting, organization-managed remote MCP servers, and blocking personal remote MCP servers.
For Enterprise administrators, the legacy remote configuration field names are still supported for compatibility and govern MCP access: the MCP marketplace in the Customize view, local server availability, local server allowlisting, organization-managed remote MCP servers, and blocking personal remote MCP servers. `mcpMarketplaceEnabled: false` also hides the MCP marketplace, and `allowedMCPServers` also restricts which marketplace entries are visible and installable.
## Overview
+7
View File
@@ -1,5 +1,12 @@
# Cline SDK Changelog
## 0.0.78
- The hub can now be drained and upgraded without losing work. A draining hub refuses new mutating work while it finishes what it is running, a durable event log lets a reconnecting client replay everything it missed, and durable runs are queued rather than dropped. Replayed events are deduped by event id, so an event that arrives on both the replay and the live stream is delivered once
- Fixed tool calling being silently disabled for custom OpenAI-Compatible models whose capability list was synthesized from convenience flags like `supportsReasoning`. An inferred list read as an authoritative denial and stripped every tool from the request; explicitly authored capability lists still decide
- Langfuse traces now carry session and client identity for hub-backed and delegated-agent runs. Hub sessions rebuild client name and version from request headers, and delegated agents group under their parent session instead of appearing as unattributed traces
- Refreshed the model catalog, which updates model lists and pricing across providers and changes the resolved default model for several of them (DeepSeek, Crof, CrossModel, Eden AI, Kilo, and NanoGPT). If you use one of those providers without pinning a model, you will get a different default
## 0.0.77
- The `tasks` tool (durable todos and one-time or recurring agent schedules) is now scoped to the clients that can service it. Hosts declare their client type and the core tool catalog resolves availability centrally, so CLI and VS Code sessions no longer register a tool they cannot act on; hub sessions resolve the same way from the requesting client's metadata
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/agents",
"version": "0.0.77",
"version": "0.0.78",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline",
+3
View File
@@ -1026,6 +1026,9 @@ export class AgentRuntime {
const usageBeforeModel = cloneUsage(this.state.usage);
const modelRequestMetadata = omitUndefinedValues({
distinctId: trimNonEmpty(this.config.distinctId),
clientName: trimNonEmpty(this.config.clientName),
clientVersion: trimNonEmpty(this.config.clientVersion),
clineCoreVersion: trimNonEmpty(this.config.clineCoreVersion),
sessionId: trimNonEmpty(this.config.sessionId),
agentId: this.state.agentId,
conversationId: trimNonEmpty(this.config.conversationId),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/core",
"description": "Cline Core SDK for Node Runtime",
"version": "0.0.77",
"version": "0.0.78",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline",
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import {
buildDelegatedAgentConfig,
createDelegatedAgentConfigProvider,
} from "./delegated-agent";
describe("buildDelegatedAgentConfig", () => {
it("inherits the parent distinctId and sessionId for telemetry grouping", () => {
const configProvider = createDelegatedAgentConfigProvider({
providerId: "anthropic",
modelId: "claude-sonnet-4-5",
distinctId: "user-123",
sessionId: "sess-parent",
});
const config = buildDelegatedAgentConfig({
kind: "subagent",
prompt: "review the diff",
tools: [],
configProvider,
parentAgentId: "agent-lead",
});
expect(config.distinctId).toBe("user-123");
expect(config.sessionId).toBe("sess-parent");
expect(config.parentAgentId).toBe("agent-lead");
});
it("leaves identity fields undefined when the parent has none", () => {
const configProvider = createDelegatedAgentConfigProvider({
providerId: "anthropic",
modelId: "claude-sonnet-4-5",
});
const config = buildDelegatedAgentConfig({
kind: "subagent",
prompt: "review the diff",
tools: [],
configProvider,
});
expect(config.distinctId).toBeUndefined();
expect(config.sessionId).toBeUndefined();
});
});
@@ -46,6 +46,16 @@ export interface DelegatedAgentRuntimeConfig
logger?: BasicLogger;
telemetry?: ITelemetryService;
workspaceMetadata?: string;
/**
* Stable end-user identity inherited from the parent session so
* delegated-agent telemetry (Langfuse `userId`) groups with the user.
*/
distinctId?: string;
/**
* Root core session id inherited from the parent session so
* delegated-agent telemetry (Langfuse `sessionId`) groups with it.
*/
sessionId?: string;
}
export interface DelegatedAgentConfigProvider {
@@ -118,6 +128,8 @@ export function buildDelegatedAgentConfig(
return {
...options.configProvider.getConnectionConfig(),
distinctId: runtimeConfig.distinctId,
sessionId: runtimeConfig.sessionId,
systemPrompt,
tools: options.tools,
maxIterations: options.maxIterations ?? runtimeConfig.maxIterations,
@@ -1433,3 +1433,42 @@ describe("hasActiveHubSessions", () => {
).toBe(false);
});
});
describe("requestHubDrain", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("posts /drain with the reason and no off param by default", async () => {
const fetchMock = vi.fn(async (_input: unknown, _init?: unknown) => ({
ok: true,
}));
vi.stubGlobal("fetch", fetchMock);
const { requestHubDrain } = await import(".");
await expect(
requestHubDrain("ws://127.0.0.1:25463/hub", "token", "upgrade"),
).resolves.toBe(true);
const requested = new URL(String(fetchMock.mock.calls[0]?.[0]));
expect(requested.pathname).toBe("/drain");
expect(requested.searchParams.get("reason")).toBe("upgrade");
expect(requested.searchParams.get("off")).toBeNull();
});
it("sets the off param so a drain can be lifted", async () => {
const fetchMock = vi.fn(async (_input: unknown, _init?: unknown) => ({
ok: true,
}));
vi.stubGlobal("fetch", fetchMock);
const { requestHubDrain } = await import(".");
await expect(
requestHubDrain("ws://127.0.0.1:25463/hub", "token", "upgrade aborted", {
off: true,
}),
).resolves.toBe(true);
const requested = new URL(String(fetchMock.mock.calls[0]?.[0]));
expect(requested.pathname).toBe("/drain");
expect(requested.searchParams.get("off")).toBe("1");
});
});
+70 -1
View File
@@ -300,6 +300,14 @@ export class NodeHubClient {
private readonly pendingReplies = new Map<string, PendingReply>();
private readonly listeners = new Set<SubscriptionEntry>();
private readonly subscriptionCounts = new Map<string, number>();
/**
* Highest durable event sequence observed per subscription key. Sent as
* `sinceSequence` when a subscription frame is (re)issued, so a hub with a
* durable event log replays exactly what this client missed while
* disconnected. Hubs without a log ignore the cursor (live-only, the
* legacy behavior).
*/
private readonly lastEventSequenceByKey = new Map<string, number>();
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
private reconnectAttempt = 0;
private closedByClient = false;
@@ -745,10 +753,17 @@ export class NodeHubClient {
kind: "stream.subscribe" | "stream.unsubscribe",
sessionId?: string,
): void {
const sinceSequence =
kind === "stream.subscribe"
? this.lastEventSequenceByKey.get(
this.subscriptionKeyForSessionId(sessionId),
)
: undefined;
this.sendFrame({
kind,
clientId: this.clientId,
...(sessionId ? { sessionId } : {}),
...(sinceSequence !== undefined ? { sinceSequence } : {}),
});
}
@@ -797,7 +812,20 @@ export class NodeHubClient {
pending.resolve(frame.envelope);
return;
}
case "event":
case "event": {
const sequence = frame.envelope.sequence;
if (typeof sequence === "number") {
const eventSessionKey = frame.envelope.sessionId?.trim();
for (const key of [GLOBAL_SUBSCRIPTION_KEY, eventSessionKey]) {
if (!key || !this.subscriptionCounts.has(key)) {
continue;
}
const previous = this.lastEventSequenceByKey.get(key) ?? 0;
if (sequence > previous) {
this.lastEventSequenceByKey.set(key, sequence);
}
}
}
for (const entry of this.listeners) {
if (
entry.sessionId &&
@@ -808,6 +836,7 @@ export class NodeHubClient {
entry.listener(frame.envelope);
}
return;
}
case "command":
case "stream.subscribe":
case "stream.unsubscribe":
@@ -1076,6 +1105,46 @@ export async function ensureCompatibleLocalHubUrl(
}
}
/**
* Ask a hub to stop admitting new mutating work (sessions, runs) while its
* accepted work finishes. Best-effort: pre-drain hubs answer 404 and the
* caller proceeds without it.
*
* Pass `{ off: true }` to lift a drain (`POST /drain?off`): an aborted
* upgrade must be able to hand the hub back instead of leaving it refusing
* work until a restart.
*/
export async function requestHubDrain(
url: string,
authToken?: string,
reason?: string,
options?: { off?: boolean },
): Promise<boolean> {
const parsed = new URL(url);
const resolvedAuthToken =
authToken?.trim() || resolveLocalHubAuthToken(parsed);
if (parsed.protocol === "ws:") {
parsed.protocol = "http:";
} else if (parsed.protocol === "wss:") {
parsed.protocol = "https:";
}
parsed.pathname = "/drain";
parsed.hash = "";
if (reason) {
parsed.searchParams.set("reason", reason);
}
if (options?.off) {
parsed.searchParams.set("off", "1");
}
const response = await fetch(parsed, {
method: "POST",
headers: resolvedAuthToken
? { authorization: `Bearer ${resolvedAuthToken}` }
: undefined,
});
return response.ok;
}
export async function requestHubShutdown(
url: string,
authToken?: string,
@@ -0,0 +1,75 @@
/**
* E2E fixture for the OS-backed singleton lock: starts a minimal hub server
* for the owner context named by the environment and, like the production
* daemon entry, exits with code 3 when a live Hub already holds the lock
* never touching the incumbent.
*/
import { join } from "node:path";
import type { HubScheduleRuntimeHandlers } from "../../../cron/service/schedule-service";
import type { RuntimeHost } from "../../../runtime/host/runtime-host";
import {
HUB_LOCK_HELD_EXIT_CODE,
isHubLockHeldError,
} from "../../discovery/instance-lock";
import { startHubWebSocketServer } from "../../server";
const discoveryPath = process.env.CLINE_HUB_DISCOVERY_PATH?.trim();
const dataDir = process.env.CLINE_DATA_DIR?.trim();
const port = Number(process.env.CLINE_HUB_TEST_PORT);
if (!discoveryPath || !dataDir || !Number.isInteger(port) || port < 0) {
throw new Error("Invalid singleton daemon fixture environment");
}
const unusedRuntimeMethod = async (): Promise<never> => {
throw new Error("The singleton fixture does not execute sessions");
};
const sessionHost = {
subscribe: () => () => undefined,
dispose: async () => undefined,
startSession: unusedRuntimeMethod,
runTurn: unusedRuntimeMethod,
restoreSession: unusedRuntimeMethod,
abort: unusedRuntimeMethod,
stopSession: unusedRuntimeMethod,
getSession: unusedRuntimeMethod,
listSessions: unusedRuntimeMethod,
deleteSession: unusedRuntimeMethod,
updateSession: unusedRuntimeMethod,
updateSessionCompactionState: unusedRuntimeMethod,
readSessionCompactionState: unusedRuntimeMethod,
readSessionMessages: unusedRuntimeMethod,
dispatchHookEvent: unusedRuntimeMethod,
} as unknown as RuntimeHost;
const runtimeHandlers: HubScheduleRuntimeHandlers = {
startSession: unusedRuntimeMethod,
sendSession: unusedRuntimeMethod,
abortSession: unusedRuntimeMethod,
stopSession: unusedRuntimeMethod,
};
try {
await startHubWebSocketServer({
host: "127.0.0.1",
port,
pathname: "/hub",
owner: { ownerId: "singleton-e2e-fixture", discoveryPath },
sessionHost,
runtimeHandlers,
scheduleOptions: { dbPath: join(dataDir, "schedules.db") },
eventLog: { dbPath: join(dataDir, "hub-events.db") },
runQueue: { dbPath: join(dataDir, "hub-runs.db") },
});
process.stderr.write("[singleton-fixture] serving\n");
} catch (error) {
if (isHubLockHeldError(error)) {
process.stderr.write("[singleton-fixture] lock held by a live hub\n");
process.exit(HUB_LOCK_HELD_EXIT_CODE);
}
throw error;
}
await new Promise<void>(() => undefined);
+22 -1
View File
@@ -8,6 +8,10 @@ import {
import { reconnectDaemonConnectors } from "../../services/connectors/daemon-connector-reconnect";
import { createLocalHubScheduleRuntimeHandlers } from "../daemon/runtime-handlers";
import { resolveHubEndpointOptions } from "../discovery/defaults";
import {
HUB_LOCK_HELD_EXIT_CODE,
isHubLockHeldError,
} from "../discovery/instance-lock";
import {
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
@@ -55,7 +59,15 @@ async function startHubWebSocketServerWithBindRetry(
try {
return await startHubWebSocketServer(options);
} catch (error) {
if (!isAddressInUseError(error) || Date.now() >= bindDeadline) {
// A retiring predecessor can hold the port — or the instance lock —
// for a couple of seconds after acking shutdown. Wait either out
// within the deadline; a lock still held past it means a live Hub
// owns this context, and the rule is connect or diagnose, never
// replace (exit code 3, below).
if (
(!isAddressInUseError(error) && !isHubLockHeldError(error)) ||
Date.now() >= bindDeadline
) {
throw error;
}
await new Promise((resolve) =>
@@ -346,6 +358,15 @@ async function main(): Promise<void> {
void main().catch((error) => {
rejectHubDaemonReady(error);
if (isHubLockHeldError(error)) {
// A live Hub owns this context. Losing the singleton race is a
// diagnosis, not a failure to fight: exit distinctly and leave the
// running Hub alone.
process.stderr.write(
`[hub-daemon] another live Hub owns this data directory: ${error.message}\n`,
);
process.exit(HUB_LOCK_HELD_EXIT_CODE);
}
const message =
error instanceof Error ? error.stack || error.message : String(error);
process.stderr.write(`[hub-daemon] fatal: ${message}\n`);
+24 -5
View File
@@ -8,6 +8,7 @@ const {
rememberRecoverableLocalHubUrl,
verifyHubConnection,
localHubHasNoActiveSessions,
requestHubDrain,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
createHubServerUrl,
@@ -31,6 +32,7 @@ const {
verifyHubConnection: vi.fn(),
// Idle by default, so existing replacement cases are unaffected.
localHubHasNoActiveSessions: vi.fn(async () => true),
requestHubDrain: vi.fn(async () => true),
resolveProductionHubOwnerContext: vi.fn(() => ({
discoveryPath: "/tmp/hub-discovery.json",
})),
@@ -101,6 +103,7 @@ vi.mock("@cline/shared", () => ({
vi.mock("../client", () => ({
localHubHasNoActiveSessions,
rememberRecoverableLocalHubUrl,
requestHubDrain,
requestHubShutdown,
verifyHubConnection,
}));
@@ -148,6 +151,8 @@ describe("ensureDetachedHubServer", () => {
probeHubServer.mockReset();
requestHubShutdown.mockReset();
requestHubShutdown.mockResolvedValue(true);
requestHubDrain.mockReset();
requestHubDrain.mockResolvedValue(true);
readHubDiscovery.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
@@ -401,7 +406,17 @@ describe("ensureDetachedHubServer", () => {
"ws://127.0.0.1:25463/hub",
"old-token",
);
expect(kill).toHaveBeenCalledWith(12345, "SIGTERM");
// Retirement is drain-first: the hub refuses new work before it is
// asked to shut down.
expect(requestHubDrain).toHaveBeenCalledWith(
"ws://127.0.0.1:25463/hub",
"old-token",
"retired by newer install",
);
expect(requestHubDrain.mock.invocationCallOrder[0]).toBeLessThan(
requestHubShutdown.mock.invocationCallOrder[0] ?? 0,
);
expect(kill).not.toHaveBeenCalledWith(12345, "SIGTERM");
expect(clearHubDiscovery).toHaveBeenCalledWith("/tmp/hub-discovery.json");
expect(spawn).toHaveBeenCalledOnce();
expect(verifyHubConnection).toHaveBeenCalledOnce();
@@ -509,7 +524,7 @@ describe("ensureDetachedHubServer", () => {
"ws://127.0.0.1:25463/hub",
"",
);
expect(kill).toHaveBeenCalledWith(12345, "SIGTERM");
expect(kill).not.toHaveBeenCalledWith(12345, "SIGTERM");
expect(clearHubDiscovery).toHaveBeenCalledWith("/tmp/hub-discovery.json");
expect(spawn).toHaveBeenCalledOnce();
} finally {
@@ -537,6 +552,10 @@ describe("ensureDetachedHubServer", () => {
await pending;
expect(spawn).not.toHaveBeenCalled();
// The hub survived the retirement attempt, so its discovery record
// must not be cleared — clearing it would leave the live daemon
// undiscoverable.
expect(clearHubDiscovery).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
@@ -580,7 +599,7 @@ describe("ensureDetachedHubServer", () => {
"ws://127.0.0.1:39121/hub",
"legacy-token",
);
expect(kill).toHaveBeenCalledWith(222, "SIGTERM");
expect(kill).not.toHaveBeenCalledWith(222, "SIGTERM");
expect(clearHubDiscovery).toHaveBeenCalledWith(
"/tmp/legacy-hub-discovery.json",
);
@@ -679,7 +698,7 @@ describe("ensureDetachedHubServer", () => {
"ws://127.0.0.1:25463/hub",
"old-token",
);
expect(kill).toHaveBeenCalledWith(12345, "SIGTERM");
expect(kill).not.toHaveBeenCalledWith(12345, "SIGTERM");
} finally {
kill.mockRestore();
}
@@ -740,7 +759,7 @@ describe("ensureDetachedHubServer", () => {
expect(clearHubDiscovery.mock.invocationCallOrder[0]).toBeGreaterThan(
probeHubServer.mock.invocationCallOrder[2],
);
expect(kill).toHaveBeenCalledWith(12345, "SIGTERM");
expect(kill).not.toHaveBeenCalledWith(12345, "SIGTERM");
expect(spawn).toHaveBeenCalledOnce();
expect(verifyHubConnection).toHaveBeenCalledOnce();
} finally {
+37 -6
View File
@@ -17,6 +17,7 @@ import {
import {
localHubHasNoActiveSessions,
rememberRecoverableLocalHubUrl,
requestHubDrain,
requestHubShutdown,
verifyHubConnection,
} from "../client";
@@ -195,26 +196,56 @@ async function waitForHubToRetire(
return false;
}
async function retireDiscoveredHub(
/**
* Gracefully retire a discovered hub. Shared by every replacement path
* (detached ensure, in-process ensure) so retirement always means the same
* thing: drain first, then an authenticated shutdown, SIGTERM only as a
* last resort, and discovery cleared only once the hub is actually gone.
*/
export async function retireDiscoveredHub(
record: { url: string; authToken?: string; pid?: number },
discoveryPath: string,
): Promise<boolean> {
if (!shouldAttemptRetire(record.url)) {
return false;
}
// Graceful handover, in order of increasing force: drain (refuse new
// work), then an authenticated shutdown, then SIGTERM only as a fallback
// and only at a pid we can positively observe alive right now — a recorded
// pid may have been recycled by the OS onto an unrelated process.
await requestHubDrain(
record.url,
record.authToken,
"retired by newer install",
).catch(() => false);
await requestHubShutdown(record.url, record.authToken).catch(() => false);
if (record.pid) {
let retired = await waitForHubToRetire(record.url, HUB_RETIRE_TIMEOUT_MS);
if (!retired && record.pid && isPidAlive(record.pid)) {
try {
process.kill(record.pid, "SIGTERM");
} catch {
// Best-effort cleanup only. A compatible hub may still start on a fallback port.
}
retired = await waitForHubToRetire(record.url, HUB_RETIRE_TIMEOUT_MS);
}
// Only the successful retirement may clear discovery: clearing the record
// of a hub that survived leaves a live daemon undiscoverable, recoverable
// only through the expected-URL probe/repair path.
if (retired) {
await clearHubDiscovery(discoveryPath).catch(() => undefined);
}
const retired = await waitForHubToRetire(record.url, HUB_RETIRE_TIMEOUT_MS);
await clearHubDiscovery(discoveryPath).catch(() => undefined);
return retired;
}
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as { code?: string })?.code === "EPERM";
}
}
export type HubRetirementOutcome =
| "reusable"
| "retired"
@@ -229,8 +260,8 @@ export type HubRetirementOutcome =
* 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,
export async function hubHasLiveSessions(
record: Pick<HubServerProbeRecord, "url" | "authToken">,
): Promise<boolean> {
try {
return !(await localHubHasNoActiveSessions(record.url, record.authToken));
@@ -0,0 +1,236 @@
/**
* Real-process proof of lock-enforced singleton ownership:
*
* - A second daemon for the same owner context exits with code 3 and the
* incumbent keeps serving, untouched no kill, no port fight, no loop.
* - A crashed holder leaks nothing: the kernel releases the lock with the
* process, and a successor acquires it immediately.
*/
import { type ChildProcess, spawn } from "node:child_process";
import { once } from "node:events";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, delimiter, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
interface ReadyDaemon {
child: ChildProcess;
discoveryPath: string;
discovery: { authToken: string; url: string; pid?: number };
exit: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
stderr: () => string;
}
const tempDirs = new Set<string>();
const children = new Set<ChildProcess>();
function resolveBunExecutable(): string {
const currentExecutable = basename(process.execPath).toLowerCase();
if (
(process.versions as { bun?: string }).bun ||
currentExecutable === "bun" ||
currentExecutable === "bun.exe"
) {
return process.execPath;
}
const configured = process.env.BUN_EXEC_PATH?.trim();
if (configured) {
return configured;
}
const installed = process.env.BUN_INSTALL?.trim();
const executableName = process.platform === "win32" ? "bun.exe" : "bun";
if (installed) {
const candidate = join(installed, "bin", executableName);
if (existsSync(candidate)) {
return candidate;
}
}
for (const directory of process.env.PATH?.split(delimiter) ?? []) {
const candidate = join(directory, executableName);
if (existsSync(candidate)) {
return candidate;
}
}
throw new Error(`Bun executable (${executableName}) was not found on PATH`);
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function spawnFixture(
dataDir: string,
discoveryPath: string,
): {
child: ChildProcess;
exit: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
stderr: () => string;
} {
const entryPath = fileURLToPath(
new URL("./__fixtures__/singleton-daemon.ts", import.meta.url),
);
const child = spawn(
resolveBunExecutable(),
["--conditions=development", entryPath],
{
cwd: dataDir,
env: {
...process.env,
CLINE_BUILD_ENV: "development",
CLINE_DATA_DIR: dataDir,
CLINE_HUB_DISCOVERY_PATH: discoveryPath,
CLINE_HUB_TEST_PORT: "0",
CLINE_NO_INTERACTIVE: "1",
NO_COLOR: "1",
},
stdio: ["ignore", "ignore", "pipe"],
},
);
children.add(child);
let stderr = "";
child.stderr?.setEncoding("utf8");
child.stderr?.on("data", (chunk: string) => {
stderr += chunk;
});
const exit = once(child, "exit").then(([code, signal]) => {
children.delete(child);
return {
code: code as number | null,
signal: signal as NodeJS.Signals | null,
};
});
return { child, exit, stderr: () => stderr };
}
async function waitForDiscovery(
discoveryPath: string,
childExit: Promise<{ code: number | null; signal: NodeJS.Signals | null }>,
readStderr: () => string,
/** A SIGKILLed predecessor leaves its record behind; skip that pid. */
notPid?: number,
): Promise<ReadyDaemon["discovery"]> {
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
const parsed = JSON.parse(
await readFile(discoveryPath, "utf8"),
) as Partial<ReadyDaemon["discovery"]>;
if (
typeof parsed.url === "string" &&
typeof parsed.authToken === "string" &&
(notPid === undefined || parsed.pid !== notPid)
) {
return {
url: parsed.url,
authToken: parsed.authToken,
pid: typeof parsed.pid === "number" ? parsed.pid : undefined,
};
}
} catch {
// Startup has not published a complete atomic record yet.
}
const earlyExit = await Promise.race([
childExit.then((result) => ({ result })),
delay(25).then(() => undefined),
]);
if (earlyExit) {
throw new Error(
`Hub daemon exited before readiness (${JSON.stringify(earlyExit.result)}): ${readStderr()}`,
);
}
}
throw new Error(`Timed out waiting for daemon discovery: ${readStderr()}`);
}
async function startDaemon(existing?: {
dataDir: string;
discoveryPath: string;
notPid?: number;
}): Promise<ReadyDaemon> {
const dataDir =
existing?.dataDir ??
(await mkdtemp(join(tmpdir(), "cline-hub-singleton-e2e-")));
tempDirs.add(dataDir);
const discoveryPath =
existing?.discoveryPath ?? join(dataDir, "hub-discovery.json");
const { child, exit, stderr } = spawnFixture(dataDir, discoveryPath);
const discovery = await waitForDiscovery(
discoveryPath,
exit,
stderr,
existing?.notPid,
);
return { child, discoveryPath, discovery, exit, stderr };
}
function toHealthUrl(webSocketUrl: string): URL {
const url = new URL(webSocketUrl);
url.protocol = url.protocol === "wss:" ? "https:" : "http:";
url.pathname = "/health";
url.search = "";
return url;
}
afterEach(async () => {
for (const child of children) {
child.kill("SIGKILL");
await Promise.race([once(child, "exit"), delay(5_000)]).catch(
() => undefined,
);
}
children.clear();
for (const dataDir of tempDirs) {
await rm(dataDir, { recursive: true, force: true });
}
tempDirs.clear();
});
describe("hub singleton lock (real processes)", () => {
it("a second daemon exits code 3 and the incumbent keeps serving", async () => {
const incumbent = await startDaemon();
const challenger = spawnFixture(
// Same owner context: same data dir + discovery path. The data dir is
// the discovery file's parent; deriving it through a file: URL breaks
// on Windows (`/C:/...` is not a valid spawn cwd, so spawn fails
// ENOENT before the singleton lock is ever contested).
dirname(incumbent.discoveryPath),
incumbent.discoveryPath,
);
const challengerExit = await Promise.race([
challenger.exit,
delay(15_000).then(() => undefined),
]);
expect(challengerExit?.code).toBe(3);
expect(challenger.stderr()).toContain("lock held by a live hub");
// The incumbent is untouched: still serving, discovery still its own.
const health = await fetch(toHealthUrl(incumbent.discovery.url));
expect(health.status).toBe(200);
const record = JSON.parse(
await readFile(incumbent.discoveryPath, "utf8"),
) as { url?: string };
expect(record.url).toBe(incumbent.discovery.url);
expect(incumbent.child.exitCode).toBeNull();
}, 30_000);
it("a crashed holder leaks nothing: a successor acquires immediately", async () => {
const first = await startDaemon();
const dataDir = [...tempDirs][tempDirs.size - 1] as string;
// SIGKILL: no cleanup code runs; only the kernel releases the lock.
first.child.kill("SIGKILL");
await first.exit;
const successor = await startDaemon({
dataDir,
discoveryPath: first.discoveryPath,
notPid: first.child.pid,
});
expect(successor.discovery.url).not.toBe("");
const health = await fetch(toHealthUrl(successor.discovery.url));
expect(health.status).toBe(200);
}, 30_000);
});
@@ -0,0 +1,72 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
const { loadSqliteDb } = vi.hoisted(() => ({
loadSqliteDb: vi.fn<(path: string) => unknown>(() => {
throw new Error("SQLite is unavailable in this runtime");
}),
}));
// Simulates runtimes with a broken or missing SQLite backend (e.g.
// node:sqlite unavailable) without touching a real database.
vi.mock("@cline/shared/db", () => ({ loadSqliteDb }));
import {
HubInstanceLock,
HubLockHeldError,
resolveHubInstanceLockPath,
} from "./instance-lock";
describe("HubInstanceLock without a usable SQLite backend", () => {
function tempLockFile(): string {
return resolveHubInstanceLockPath(
join(mkdtempSync(join(tmpdir(), "cline-hub-lock-")), "discovery.json"),
);
}
it("degrades to an unheld lock when the lock database cannot be loaded", () => {
loadSqliteDb.mockImplementationOnce(() => {
throw new Error("SQLite is unavailable in this runtime");
});
const lockFile = tempLockFile();
const lock = HubInstanceLock.acquire(lockFile);
expect(lock.held).toBe(false);
expect(lock.lockFile).toBe(lockFile);
expect(() => lock.release()).not.toThrow();
});
it("degrades on a non-busy failure instead of propagating it", () => {
const close = vi.fn();
loadSqliteDb.mockImplementationOnce(() => ({
exec: (sql: string) => {
if (sql.includes("BEGIN EXCLUSIVE")) {
throw new Error("SQLITE_IOERR: disk I/O error");
}
},
prepare: vi.fn(),
close,
}));
const lock = HubInstanceLock.acquire(tempLockFile());
expect(lock.held).toBe(false);
expect(close).toHaveBeenCalled();
});
it("still surfaces a held lock as HubLockHeldError", () => {
loadSqliteDb.mockImplementationOnce(() => ({
exec: (sql: string) => {
if (sql.includes("BEGIN EXCLUSIVE")) {
throw Object.assign(new Error("database is locked"), {
code: "SQLITE_BUSY",
});
}
},
prepare: vi.fn(),
close: vi.fn(),
}));
expect(() => HubInstanceLock.acquire(tempLockFile())).toThrow(
HubLockHeldError,
);
});
});
@@ -0,0 +1,70 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
HubInstanceLock,
HubLockHeldError,
isHubLockHeldError,
resolveHubInstanceLockPath,
} from "./instance-lock";
describe("HubInstanceLock", () => {
function tempLockFile(): string {
return resolveHubInstanceLockPath(
join(mkdtempSync(join(tmpdir(), "cline-hub-lock-")), "discovery.json"),
);
}
it("grants exclusive ownership and refuses a second acquirer", () => {
const lockFile = tempLockFile();
const first = HubInstanceLock.acquire(lockFile);
expect(first.held).toBe(true);
try {
expect(() => HubInstanceLock.acquire(lockFile)).toThrow(HubLockHeldError);
} finally {
first.release();
}
});
it("frees ownership on release so a successor can acquire", () => {
const lockFile = tempLockFile();
const first = HubInstanceLock.acquire(lockFile);
first.release();
expect(first.held).toBe(false);
const second = HubInstanceLock.acquire(lockFile);
expect(second.held).toBe(true);
second.release();
});
it("release is idempotent", () => {
const lockFile = tempLockFile();
const lock = HubInstanceLock.acquire(lockFile);
lock.release();
expect(() => lock.release()).not.toThrow();
});
it("identifies its own error type", () => {
const lockFile = tempLockFile();
const lock = HubInstanceLock.acquire(lockFile);
try {
HubInstanceLock.acquire(lockFile);
expect.unreachable("second acquire must throw");
} catch (error) {
expect(isHubLockHeldError(error)).toBe(true);
expect((error as HubLockHeldError).lockFile).toBe(lockFile);
} finally {
lock.release();
}
expect(isHubLockHeldError(new Error("nope"))).toBe(false);
});
it("scopes locks per discovery path", () => {
const first = HubInstanceLock.acquire(tempLockFile());
const second = HubInstanceLock.acquire(tempLockFile());
expect(first.held).toBe(true);
expect(second.held).toBe(true);
first.release();
second.release();
});
});
@@ -0,0 +1,134 @@
/**
* OS-backed exclusive Hub instance lock.
*
* Authority over a Hub owner context (the discovery path) is an
* operating-system exclusive lock, not a PID file, not a heartbeat, and not
* build arbitration: the lock is a SQLite database held inside a
* never-committed `BEGIN EXCLUSIVE` transaction, which SQLite maps onto OS
* file locks. The kernel releases the lock the instant the holding process
* dies crashed holders cannot leak ownership, and a live holder cannot be
* displaced by deleting a file.
*
* A process that fails to acquire the lock must connect to the running Hub
* or diagnose never kill it and never bind another endpoint on its own.
* This makes the historical mutual-retire loop (two installs SIGTERMing each
* other's daemon, #13145/#13230) structurally impossible: at most one live
* Hub can exist per owner context, enforced by the OS before either process
* gets a chance to disagree.
*
* The startup-lock directory (`<discoveryPath>.lock`, see `withHubLock`)
* remains a short-lived mutex around discovery reads/writes; this lock is a
* different thing it is held for the entire lifetime of the serving Hub.
*/
import { chmodSync, existsSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { loadSqliteDb, type SqliteDb } from "@cline/shared/db";
/** Exit code for a daemon that lost the singleton race: diagnose, never replace. */
export const HUB_LOCK_HELD_EXIT_CODE = 3;
/** The lock is held by another live Hub process (or connection). */
export class HubLockHeldError extends Error {
readonly lockFile: string;
constructor(lockFile: string) {
super(
`Hub instance lock is held by a live Hub: ${lockFile}. ` +
"Connect to the running Hub or diagnose it; never replace it.",
);
this.name = "HubLockHeldError";
this.lockFile = lockFile;
}
}
export function isHubLockHeldError(error: unknown): error is HubLockHeldError {
return error instanceof Error && error.name === "HubLockHeldError";
}
/** The lock file that guards one owner context (derived from its discovery path). */
export function resolveHubInstanceLockPath(discoveryPath: string): string {
return `${discoveryPath}.instance.lock`;
}
export class HubInstanceLock {
private db: SqliteDb | undefined;
readonly lockFile: string;
private constructor(lockFile: string, db: SqliteDb | undefined) {
this.lockFile = lockFile;
this.db = db;
}
get held(): boolean {
return this.db !== undefined;
}
/** Release the lock (process exit releases it too, via the OS). */
release(): void {
const db = this.db;
if (!db) {
return;
}
this.db = undefined;
try {
db.exec("ROLLBACK;");
} catch {
// Already rolled back or the handle is gone; closing suffices.
}
db.close?.();
}
/**
* Attempt to take exclusive ownership of a Hub owner context. Throws
* `HubLockHeldError` when another live process holds it.
*
* Only a positively held lock (SQLITE_BUSY/LOCKED) refuses startup. Any
* other failure SQLite unavailable in this runtime, an unwritable lock
* directory degrades to an unheld lock (`held === false`) and the Hub
* starts without singleton enforcement, matching how the event log and
* run queue already degrade rather than making SQLite a hard requirement.
*/
static acquire(lockFile: string): HubInstanceLock {
let existed = false;
let db: SqliteDb;
try {
mkdirSync(dirname(lockFile), { recursive: true });
existed = existsSync(lockFile);
db = loadSqliteDb(lockFile);
} catch {
return new HubInstanceLock(lockFile, undefined);
}
try {
// Fail immediately instead of queueing behind the current holder.
db.exec("PRAGMA busy_timeout = 0;");
db.exec("BEGIN EXCLUSIVE;");
} catch (error) {
db.close?.();
if (isBusy(error)) {
throw new HubLockHeldError(lockFile);
}
return new HubInstanceLock(lockFile, undefined);
}
if (!existed) {
try {
chmodSync(lockFile, 0o600);
} catch {
// Permission tightening is best-effort on exotic filesystems.
}
}
return new HubInstanceLock(lockFile, db);
}
}
function isBusy(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
const code = (error as { code?: unknown })?.code;
return (
code === "SQLITE_BUSY" ||
code === "SQLITE_LOCKED" ||
message.includes("SQLITE_BUSY") ||
message.includes("SQLITE_LOCKED") ||
message.includes("database is locked")
);
}
+3
View File
@@ -49,8 +49,11 @@ export * from "./daemon/runtime-handlers";
export * from "./daemon/start-shared-server";
export * from "./discovery";
export * from "./discovery/defaults";
export * from "./discovery/instance-lock";
export * from "./discovery/workspace";
export * from "./server";
export * from "./server/browser-websocket";
export * from "./server/command-transport";
export * from "./server/hub-event-log";
export * from "./server/hub-run-queue";
export * from "./server/native-transport";
@@ -534,6 +534,61 @@ describe("HubServerTransport boundaries", () => {
}
});
it("replays a pending approval to a client that (re)subscribes after it was raised", async () => {
const transport = createTransport();
const ctx = getContext(transport);
ensureSessionState(ctx, "session-1", "client-1", "creator", {
interactive: true,
});
// No subscriber is attached yet: the approval is raised into the void.
const resultPromise = requestToolApproval(ctx, {
sessionId: "session-1",
agentId: "agent-1",
conversationId: "conversation-1",
iteration: 1,
toolCallId: "call-1",
toolName: "run_commands",
input: { commands: ["echo hi"] },
policy: { autoApprove: false },
});
// Let the request actually publish (it awaits ctx.sessionHost.getSession
// first) before anyone subscribes, so this exercises replay-on-subscribe
// rather than catching a live broadcast in that async gap.
for (let i = 0; i < 50 && ctx.pendingApprovals.size === 0; i += 1) {
await Promise.resolve();
}
expect(ctx.pendingApprovals.size).toBe(1);
// A client subscribing after the fact must still see the request.
const events: HubEventEnvelope[] = [];
transport.subscribe("late-client", (event) => events.push(event));
await Promise.resolve();
await Promise.resolve();
const requested = events.find(
(event) => event.event === "approval.requested",
);
expect(requested?.payload).toMatchObject({
sessionId: "session-1",
conversationId: "conversation-1",
toolCallId: "call-1",
});
const approvalId = requested?.payload?.approvalId as string;
await handleApprovalRespond(ctx, {
version: "v1",
requestId: "req-late",
command: "approval.respond",
payload: { approvalId, approved: true },
});
await expect(resultPromise).resolves.toEqual({
approved: true,
reason: undefined,
});
});
it("rejects pending tool approvals when a run is aborted", async () => {
const abort = vi.fn().mockResolvedValue(undefined);
const transport = createTransport({
@@ -1,5 +1,9 @@
import { resolve } from "node:path";
import type { HubCommandEnvelope, HubReplyEnvelope } from "@cline/shared";
import type {
HubCommandEnvelope,
HubEventEnvelope,
HubReplyEnvelope,
} from "@cline/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import { BrowserWebSocketHubAdapter } from "./browser-websocket";
import type { HubConnectionAuthority } from "./command-transport";
@@ -256,14 +260,14 @@ describe("BrowserWebSocketHubAdapter", () => {
payload:
command === "client.register"
? {
clientId: "client-1",
clientType: "test",
transport: "websocket",
workspaceContext: {
workspaceRoot: "/second-workspace",
cwd: "/second-workspace/project",
},
}
clientId: "client-1",
clientType: "test",
transport: "websocket",
workspaceContext: {
workspaceRoot: "/second-workspace",
cwd: "/second-workspace/project",
},
}
: undefined,
},
}),
@@ -414,4 +418,207 @@ describe("BrowserWebSocketHubAdapter", () => {
},
});
});
it("does not duplicate a pending approval replayed via both the live gate and the durable log", async () => {
// Mirrors HubServerTransport.subscribe(): a pending approval predates
// any durable-log append, so it's re-issued sequence-less through the
// live listener (queued as a microtask, same as the real reissue).
const pendingApproval: HubEventEnvelope = {
version: "v1",
event: "approval.requested",
eventId: "hevt_pending_approval",
sessionId: "session-1",
timestamp: Date.now(),
payload: { approvalId: "approval_1" },
};
// HubEventLogStore.append() returns a *new* object stamped with a
// sequence rather than mutating the original — same eventId, though.
const stampedApproval: HubEventEnvelope = {
...pendingApproval,
sequence: 1,
};
let replayCalls = 0;
const transport = {
command: vi.fn(),
subscribe: vi.fn(
(_clientId: string, listener: (event: HubEventEnvelope) => void) => {
queueMicrotask(() => listener(pendingApproval));
return () => {};
},
),
replayEventsAfter: vi.fn(() => {
replayCalls += 1;
return replayCalls === 1 ? [stampedApproval] : [];
}),
};
const socket = createSocket();
const adapter = new BrowserWebSocketHubAdapter(transport);
adapter.attach(socket);
socket.emitMessage(
JSON.stringify({
kind: "stream.subscribe",
clientId: "late-reader",
sessionId: "session-1",
sinceSequence: 0,
}),
);
const deadline = Date.now() + 2_000;
while (transport.replayEventsAfter.mock.calls.length < 2 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 5));
}
// Let the buffered-flush finally-block run past the last replay page.
await new Promise((r) => setTimeout(r, 25));
const delivered = socket.sent
.map((entry) => JSON.parse(entry))
.filter(
(frame) =>
frame.kind === "event" &&
frame.envelope.eventId === "hevt_pending_approval",
);
expect(delivered).toHaveLength(1);
});
it("advances the replay cursor past events skipped by eventId dedupe", async () => {
// The same eventId appended twice to the durable log (e.g. a pending
// approval re-issued and re-logged) used to wedge replay: the skipped
// duplicate never advanced lastDelivered, so the same page was
// refetched forever.
const duplicate = (sequence: number): HubEventEnvelope => ({
version: "v1",
event: "approval.requested",
eventId: "hevt_duplicated",
sessionId: "session-1",
timestamp: Date.now(),
sequence,
});
const transport = {
command: vi.fn(),
subscribe: vi.fn(() => () => {}),
replayEventsAfter: vi.fn((sinceSequence: number) => {
if (sinceSequence === 0) return [duplicate(1)];
if (sinceSequence === 1) return [duplicate(2)];
return [];
}),
};
const socket = createSocket();
new BrowserWebSocketHubAdapter(transport).attach(socket);
socket.emitMessage(
JSON.stringify({
kind: "stream.subscribe",
clientId: "resumer",
sessionId: "session-1",
sinceSequence: 0,
}),
);
await vi.waitFor(() =>
expect(transport.replayEventsAfter).toHaveBeenCalledWith(2, {
sessionId: "session-1",
limit: 200,
}),
);
expect(transport.replayEventsAfter).toHaveBeenCalledTimes(3);
const delivered = socket.sent
.map((entry) => JSON.parse(entry))
.filter((frame) => frame.kind === "event");
expect(delivered).toHaveLength(1);
expect(delivered[0]?.envelope.sequence).toBe(1);
});
it("stops replay when a misbehaving source never advances the cursor", async () => {
const stuck: HubEventEnvelope = {
version: "v1",
event: "assistant.delta",
eventId: "hevt_stuck",
sessionId: "session-1",
timestamp: Date.now(),
sequence: 1,
};
const transport = {
command: vi.fn(),
subscribe: vi.fn(() => () => {}),
// Always returns the same non-empty page regardless of the cursor.
replayEventsAfter: vi.fn(() => [stuck]),
};
const socket = createSocket();
new BrowserWebSocketHubAdapter(transport).attach(socket);
socket.emitMessage(
JSON.stringify({
kind: "stream.subscribe",
clientId: "resumer",
sessionId: "session-1",
sinceSequence: 0,
}),
);
await vi.waitFor(() =>
expect(transport.replayEventsAfter).toHaveBeenCalledTimes(2),
);
// Give a would-be third iteration time to run; the guard must break out.
await new Promise((resolve) => setTimeout(resolve, 25));
expect(transport.replayEventsAfter).toHaveBeenCalledTimes(2);
const delivered = socket.sent
.map((entry) => JSON.parse(entry))
.filter((frame) => frame.kind === "event");
expect(delivered).toHaveLength(1);
});
it("drops replay dedupe state once the buffered flush completes", async () => {
const stamped: HubEventEnvelope = {
version: "v1",
event: "approval.requested",
eventId: "hevt_reissued",
sessionId: "session-1",
timestamp: Date.now(),
sequence: 1,
};
let liveListener: ((event: HubEventEnvelope) => void) | undefined;
const transport = {
command: vi.fn(),
subscribe: vi.fn(
(_clientId: string, listener: (event: HubEventEnvelope) => void) => {
liveListener = listener;
return () => {};
},
),
replayEventsAfter: vi.fn((sinceSequence: number) =>
sinceSequence === 0 ? [stamped] : [],
),
};
const socket = createSocket();
new BrowserWebSocketHubAdapter(transport).attach(socket);
socket.emitMessage(
JSON.stringify({
kind: "stream.subscribe",
clientId: "late-reader",
sessionId: "session-1",
sinceSequence: 0,
}),
);
await vi.waitFor(() =>
expect(transport.replayEventsAfter).toHaveBeenCalledTimes(2),
);
// Let the buffered-flush finally-block complete.
await new Promise((resolve) => setTimeout(resolve, 25));
// A live re-issue after replay (sequence-less, e.g. a pending approval
// re-raised much later) follows the live-only contract: it is delivered,
// not swallowed by replay-era dedupe state.
liveListener?.({ ...stamped, sequence: undefined });
const delivered = socket.sent
.map((entry) => JSON.parse(entry))
.filter(
(frame) =>
frame.kind === "event" &&
frame.envelope.eventId === "hevt_reissued",
);
expect(delivered).toHaveLength(2);
});
});
@@ -20,6 +20,15 @@ import { logHubMessage } from "./hub-server-logging";
type HubCommandFrame = HubTransportFrame & { kind: "command" };
const HUB_EVENT_REPLAY_PAGE_SIZE = 200;
/**
* Hard ceiling on replay pages per subscribe. At the default page size this
* covers the event log's full retention cap; a replay source that still has
* more after this is misbehaving, and live delivery takes over from wherever
* the cursor reached.
*/
const HUB_EVENT_REPLAY_MAX_PAGES = 1_000;
export interface BrowserHubSocketLike {
send(data: string): void;
addEventListener(
@@ -309,12 +318,108 @@ export class BrowserWebSocketHubAdapter {
if (subscriptions.has(key)) {
break;
}
const sinceSequence =
typeof frame.sinceSequence === "number" &&
Number.isFinite(frame.sinceSequence) &&
frame.sinceSequence >= 0
? Math.floor(frame.sinceSequence)
: undefined;
if (
sinceSequence === undefined ||
typeof this.transport.replayEventsAfter !== "function"
) {
// Live-only delivery: the legacy contract, byte-for-byte.
const unsubscribe = await this.transport.subscribe(
frame.clientId,
onEvent,
{ sessionId: frame.sessionId },
);
subscriptions.set(key, unsubscribe);
break;
}
// Replay-then-live: subscribe first and buffer live events while
// durable pages stream out, then flush the buffer past the last
// replayed sequence — no gap, no duplicates, resumable by cursor.
let replayDone = false;
let lastDelivered = sinceSequence;
const buffered: HubEventEnvelope[] = [];
// A pending approval buffered from the live gate (re-issued by
// subscribe() sequence-less, since it predates any durable-log
// append) and its durable-log replay copy (sequence-stamped by
// HubEventLogStore.append, which returns a new object rather than
// mutating the original) share the same eventId. The sequence
// cursor alone can't catch that: dedupe by eventId too, or the
// buffer flush below re-delivers it after replay already did.
// The set exists only for that replay/flush window — it is
// dropped once the flush completes so it cannot grow for the
// lifetime of the socket.
let deliveredEventIds: Set<string> | undefined = new Set<string>();
const deliver = (envelope: HubEventEnvelope): void => {
if (typeof envelope.sequence === "number") {
if (envelope.sequence <= lastDelivered) {
return;
}
// Advance the cursor before any eventId dedupe: a skipped
// duplicate must still move replay forward, or the next
// page refetches it forever.
lastDelivered = envelope.sequence;
}
if (envelope.eventId && deliveredEventIds) {
if (deliveredEventIds.has(envelope.eventId)) {
return;
}
deliveredEventIds.add(envelope.eventId);
}
onEvent(envelope);
};
const gate = (envelope: HubEventEnvelope): void => {
if (!replayDone) {
buffered.push(envelope);
return;
}
deliver(envelope);
};
const unsubscribe = await this.transport.subscribe(
frame.clientId,
onEvent,
gate,
{ sessionId: frame.sessionId },
);
subscriptions.set(key, unsubscribe);
try {
let pages = 0;
while (!closed && pages < HUB_EVENT_REPLAY_MAX_PAGES) {
const pageCursor = lastDelivered;
const page = this.transport.replayEventsAfter(lastDelivered, {
sessionId: frame.sessionId,
limit: HUB_EVENT_REPLAY_PAGE_SIZE,
});
if (page.length === 0) {
break;
}
for (const envelope of page) {
deliver(envelope);
}
if (lastDelivered <= pageCursor) {
// The cursor did not move, so the next fetch would return
// this same page again. Stop instead of spinning.
break;
}
pages += 1;
// Yield between pages so replay never starves the socket.
await new Promise<void>((resolveYield) =>
setTimeout(resolveYield, 0),
);
}
} finally {
replayDone = true;
for (const envelope of buffered) {
deliver(envelope);
}
buffered.length = 0;
// Replay/flush dedupe is over; from here the subscription is
// live-only and must not accumulate per-event state.
deliveredEventIds = undefined;
}
break;
}
case "stream.unsubscribe": {
@@ -22,4 +22,13 @@ export interface HubCommandTransport {
listener: (event: HubEventEnvelope) => void,
options?: { sessionId?: string },
): Promise<() => void> | (() => void);
/**
* Durable events with `sequence > sinceSequence` (scoped when a sessionId
* is given), oldest first, bounded by `limit`. Absent on transports
* without a durable event log; callers must treat replay as best-effort.
*/
replayEventsAfter?(
sinceSequence: number,
options: { sessionId?: string; limit: number },
): HubEventEnvelope[];
}
@@ -1,5 +1,6 @@
import type {
HubCommandEnvelope,
HubEventEnvelope,
HubReplyEnvelope,
ToolApprovalRequest,
} from "@cline/shared";
@@ -33,31 +34,54 @@ export async function requestToolApproval(
? session.metadata.agendaTaskId
: undefined;
return await new Promise((resolve) => {
const requestedEvent = ctx.buildEvent(
"approval.requested",
{
approvalId,
sessionId: request.sessionId,
agentId: request.agentId,
conversationId: request.conversationId,
iteration: request.iteration,
toolCallId: request.toolCallId,
toolName: request.toolName,
inputJson: JSON.stringify(request.input ?? null),
policy: request.policy,
agendaTaskId,
},
sessionId,
);
ctx.pendingApprovals.set(approvalId, {
sessionId,
resolve,
requestedEvent,
});
ctx.publish(
ctx.buildEvent(
"approval.requested",
{
approvalId,
sessionId: request.sessionId,
agentId: request.agentId,
conversationId: request.conversationId,
iteration: request.iteration,
toolCallId: request.toolCallId,
toolName: request.toolName,
inputJson: JSON.stringify(request.input ?? null),
policy: request.policy,
agendaTaskId,
},
sessionId,
),
);
ctx.publish(requestedEvent);
});
}
/**
* Pending `approval.requested` events, optionally scoped to one session.
* Re-issued to a (re)subscribing client so an approval raised while nobody
* was connected or while this client was disconnected is neither lost
* nor implicitly answered.
*/
export function pendingApprovalEvents(
ctx: HubTransportContext,
sessionId?: string,
): HubEventEnvelope[] {
const events: HubEventEnvelope[] = [];
for (const pending of ctx.pendingApprovals.values()) {
if (!pending.requestedEvent) {
continue;
}
if (sessionId && pending.sessionId !== sessionId) {
continue;
}
events.push(pending.requestedEvent);
}
return events;
}
export function resolvePendingApproval(
ctx: HubTransportContext,
approvalId: string,
@@ -30,6 +30,12 @@ import {
export type PendingApproval = {
sessionId: string;
resolve: (result: { approved: boolean; reason?: string }) => void;
/**
* The `approval.requested` event as originally published. Pending
* approvals survive client disconnects, so a (re)subscribing client is
* re-issued this event instead of being left with a silently parked turn.
*/
requestedEvent?: HubEventEnvelope;
};
export type PendingCapabilityRequest = {
@@ -76,6 +82,13 @@ export interface HubTransportContext {
SessionUsageRuntimeService &
SessionConnectionRuntimeService
>;
/**
* While draining, new mutating work (session.create, run.*) is refused
* with the retryable `hub_draining` error so the Hub can be replaced at a
* boundary an operator chose instead of being ambushed mid-turn.
* Optional: absent contexts (test fixtures) are never draining.
*/
isDraining?(): boolean;
publish(event: HubEventEnvelope): void;
buildEvent(
event: HubEventEnvelope["event"],
@@ -0,0 +1,232 @@
/**
* Queue-backed run admission (`run.enqueue`), queue introspection
* (`run.list`), and drain lifecycle (`hub.drain`, `hub.status`).
*
* `run.start` keeps its historical synchronous-reply contract untouched;
* `run.enqueue` is the additive path with app-server semantics: durable FIFO
* admission, an immediate `{runId, acceptedAt, queuePosition}` ack, and
* execution that never depends on the requesting socket staying alive.
*/
import type { HubCommandEnvelope, HubReplyEnvelope } from "@cline/shared";
import {
HubRunAdmissionRejectedError,
type HubRunQueue,
type HubRunRecord,
} from "../hub-run-queue";
import { logHubMessage } from "../hub-server-logging";
import {
errorReply,
extractSessionId,
type HubTransportContext,
okReply,
} from "./context";
import { handleSessionInput } from "./run-handlers";
export const HUB_DRAINING_ERROR_CODE = "hub_draining";
/** Commands refused while the Hub is draining (all of them admit new work). */
const DRAIN_REFUSED_COMMANDS = new Set<string>([
"session.create",
"session.restore",
"session.fork",
"run.start",
"session.send_input",
"run.enqueue",
]);
export function isDrainRefusedCommand(command: string): boolean {
return DRAIN_REFUSED_COMMANDS.has(command);
}
export function drainingReply(envelope: HubCommandEnvelope): HubReplyEnvelope {
return {
version: envelope.version,
requestId: envelope.requestId,
ok: false,
error: {
code: HUB_DRAINING_ERROR_CODE,
message:
"Hub is draining and refuses new mutating work; retry once a hub is serving again.",
details: { retryable: true },
},
};
}
/**
* Serial per-session executor over the durable queue. Admission and
* execution are decoupled: `enqueue` acks immediately, `pump` runs turns one
* at a time per session through the same `handleSessionInput` path (and thus
* the same event projection) as `run.start`.
*/
export class HubRunExecutor {
private readonly activeSessions = new Set<string>();
constructor(
private readonly ctx: HubTransportContext,
private readonly queue: HubRunQueue,
) {}
/** Start (or continue) draining the session's queue in the background. */
pump(sessionId: string): void {
if (this.activeSessions.has(sessionId)) {
return;
}
this.activeSessions.add(sessionId);
void this.drainSession(sessionId)
.catch((error) => {
logHubMessage("error", "run.queue.pump_failed", { sessionId, error });
})
.finally(() => {
this.activeSessions.delete(sessionId);
// New work admitted while the finally raced the last check.
if (this.queue.nextQueued(sessionId)) {
this.pump(sessionId);
}
});
}
private async drainSession(sessionId: string): Promise<void> {
for (;;) {
const run = this.queue.nextQueued(sessionId);
if (!run) {
return;
}
this.queue.markRunning(run.runId);
await this.execute(run);
}
}
private async execute(run: HubRunRecord): Promise<void> {
const envelope: HubCommandEnvelope = {
version: "v1",
command: "run.start",
requestId: run.runId,
clientId: run.clientId,
sessionId: run.sessionId,
payload: { ...run.input, sessionId: run.sessionId },
};
try {
const reply = await handleSessionInput(this.ctx, envelope);
if (reply.ok) {
const finishReason = (
reply.payload?.result as { finishReason?: string } | undefined
)?.finishReason;
this.queue.markTerminal(
run.runId,
finishReason === "aborted"
? "aborted"
: finishReason === "error" || finishReason === "failed"
? "failed"
: "completed",
finishReason === "error" ? "Run finished with an error." : undefined,
);
} else {
this.queue.markTerminal(
run.runId,
"failed",
reply.error?.message ?? "Run failed.",
);
}
} catch (error) {
this.queue.markTerminal(
run.runId,
"failed",
error instanceof Error ? error.message : String(error),
);
}
}
}
export function handleRunEnqueue(
ctx: HubTransportContext,
envelope: HubCommandEnvelope,
queue: HubRunQueue,
executor: HubRunExecutor,
): HubReplyEnvelope {
const sessionId = extractSessionId(envelope);
if (!sessionId) {
return errorReply(
envelope,
"invalid_session_id",
"run.enqueue requires a sessionId",
);
}
const payload =
envelope.payload && typeof envelope.payload === "object"
? envelope.payload
: {};
const prompt =
typeof payload.prompt === "string"
? payload.prompt
: typeof payload.input === "string"
? payload.input
: "";
if (!prompt.trim()) {
return errorReply(
envelope,
"invalid_session_input",
"run.enqueue requires a prompt string",
);
}
let accepted: ReturnType<HubRunQueue["admit"]>;
try {
accepted = queue.admit(
sessionId,
payload as Record<string, unknown>,
envelope.clientId?.trim() || undefined,
);
} catch (error) {
if (error instanceof HubRunAdmissionRejectedError) {
return {
version: envelope.version,
requestId: envelope.requestId,
ok: false,
error: {
code: "run_admission_rejected",
message: error.message,
details: { retryable: true },
},
};
}
throw error;
}
ctx.publish(
ctx.buildEvent(
"run.enqueued",
{
runId: accepted.runId,
acceptedAt: accepted.acceptedAt,
queuePosition: accepted.queuePosition,
...(envelope.clientId ? { clientId: envelope.clientId } : {}),
},
sessionId,
),
);
executor.pump(sessionId);
return okReply(envelope, { ...accepted });
}
export function handleRunList(
envelope: HubCommandEnvelope,
queue: HubRunQueue,
): HubReplyEnvelope {
const sessionId = extractSessionId(envelope) || undefined;
const limit =
typeof envelope.payload?.limit === "number" &&
Number.isFinite(envelope.payload.limit) &&
envelope.payload.limit > 0
? Math.floor(envelope.payload.limit)
: undefined;
return okReply(envelope, {
runs: queue.list({ sessionId, limit }).map((run) => ({
runId: run.runId,
sessionId: run.sessionId,
state: run.state,
acceptedAt: run.acceptedAt,
startedAt: run.startedAt,
endedAt: run.endedAt,
error: run.error,
})),
});
}
@@ -0,0 +1,119 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { HubEventEnvelope } from "@cline/shared";
import { loadSqliteDb } from "@cline/shared/db";
import { describe, expect, it } from "vitest";
import { HubEventLogStore } from "./hub-event-log";
function envelope(
event: HubEventEnvelope["event"],
sessionId?: string,
payload?: Record<string, unknown>,
): HubEventEnvelope {
return {
version: "v1",
event,
eventId: `hevt_${event}_${sessionId ?? "global"}`,
sessionId,
timestamp: Date.now(),
payload,
};
}
describe("HubEventLogStore", () => {
it("opens its database in WAL mode", () => {
const dbPath = join(
mkdtempSync(join(tmpdir(), "cline-hub-events-")),
"hub-events.db",
);
const log = new HubEventLogStore({ dbPath });
log.append(envelope("run.started", "s1"));
log.close();
// WAL is persistent: a fresh connection observes the configured mode.
const db = loadSqliteDb(dbPath);
try {
expect(
String(db.prepare("PRAGMA journal_mode;").get()?.journal_mode),
).toBe("wal");
} finally {
db.close?.();
}
});
it("stamps a monotonically increasing global sequence", () => {
const log = new HubEventLogStore({ dbPath: ":memory:" });
const first = log.append(envelope("run.started", "s1"));
const second = log.append(envelope("assistant.delta", "s1"));
expect(first.sequence).toBe(1);
expect(second.sequence).toBe(2);
expect(log.lastSequence()).toBe(2);
log.close();
});
it("replays events after a cursor, oldest first", () => {
const log = new HubEventLogStore({ dbPath: ":memory:" });
log.append(envelope("run.started", "s1"));
log.append(envelope("assistant.delta", "s1", { text: "a" }));
log.append(envelope("run.completed", "s1"));
const replay = log.listAfter(1, {}, 10);
expect(replay.map((event) => event.event)).toEqual([
"assistant.delta",
"run.completed",
]);
expect(replay.map((event) => event.sequence)).toEqual([2, 3]);
expect(replay[0]?.payload).toEqual({ text: "a" });
log.close();
});
it("scopes replay to one session while keeping global order", () => {
const log = new HubEventLogStore({ dbPath: ":memory:" });
log.append(envelope("run.started", "s1"));
log.append(envelope("run.started", "s2"));
log.append(envelope("run.completed", "s1"));
const replay = log.listAfter(0, { sessionId: "s1" }, 10);
expect(replay.map((event) => [event.event, event.sequence])).toEqual([
["run.started", 1],
["run.completed", 3],
]);
log.close();
});
it("bounds replay pages by limit", () => {
const log = new HubEventLogStore({ dbPath: ":memory:" });
for (let index = 0; index < 5; index += 1) {
log.append(envelope("assistant.delta", "s1"));
}
expect(log.listAfter(0, {}, 2)).toHaveLength(2);
expect(log.listAfter(2, {}, 2).map((event) => event.sequence)).toEqual([
3, 4,
]);
log.close();
});
it("prunes by retention and row cap", () => {
const log = new HubEventLogStore({
dbPath: ":memory:",
retentionMs: 1_000_000,
maxRows: 2,
});
log.append(envelope("run.started", "s1"));
log.append(envelope("assistant.delta", "s1"));
log.append(envelope("run.completed", "s1"));
log.prune();
const rows = log.listAfter(0, {}, 10);
expect(rows.map((event) => event.sequence)).toEqual([2, 3]);
// Sequences stay monotonic after pruning — cursors never rewind.
expect(log.append(envelope("run.started", "s2")).sequence).toBe(4);
log.close();
});
it("is inert after close", () => {
const log = new HubEventLogStore({ dbPath: ":memory:" });
log.append(envelope("run.started", "s1"));
log.close();
expect(log.lastSequence()).toBe(0);
expect(log.listAfter(0, {}, 10)).toEqual([]);
expect(() => log.append(envelope("run.completed", "s1"))).not.toThrow();
});
});
@@ -0,0 +1,184 @@
/**
* Durable, cursor-addressed Hub event log.
*
* Every event the Hub publishes is appended here with a monotonically
* increasing global sequence before it is fanned out to live sockets. A
* client that reconnects can resume exactly where it left off by passing
* `sinceSequence` on `stream.subscribe`: the adapter replays pages from this
* log, then live-tails. Nothing about delivery depends on who was watching
* when the event happened disconnect never implies data loss.
*
* The log is a projection aid, not the source of truth for conversation
* history (session messages remain canonical on disk); it is bounded by a
* retention sweep so it can run forever.
*/
import { join } from "node:path";
import type { HubEventEnvelope } from "@cline/shared";
import { loadSqliteDb, type SqliteDb } from "@cline/shared/db";
import { resolveDbDataDir } from "@cline/shared/storage";
const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
const DEFAULT_MAX_ROWS = 200_000;
export interface HubEventLogOptions {
/** Database file. Defaults to an owner-scoped `<data>/db/hub-events-*.db`; use ":memory:" in tests. */
dbPath?: string;
/** Scopes the default `dbPath`; ignored when `dbPath` is given. */
ownerId?: string;
/** Events older than this are pruned. Defaults to 7 days. */
retentionMs?: number;
/** Hard cap on rows kept, oldest pruned first. Defaults to 200k. */
maxRows?: number;
}
export interface HubEventLogScope {
sessionId?: string;
}
/**
* Default log location, scoped per hub owner context so coexisting hubs
* (production and a dev shared hub) never interleave one log's sequences.
*/
export function resolveHubEventLogPath(ownerId?: string): string {
const scope = ownerId?.replace(/[^a-zA-Z0-9_-]/g, "-");
return join(
resolveDbDataDir(),
scope ? `hub-events-${scope}.db` : "hub-events.db",
);
}
export class HubEventLogStore {
private readonly db: SqliteDb;
private readonly retentionMs: number;
private readonly maxRows: number;
private closed = false;
constructor(options: HubEventLogOptions = {}) {
this.db = loadSqliteDb(
options.dbPath ?? resolveHubEventLogPath(options.ownerId),
);
this.retentionMs = options.retentionMs ?? DEFAULT_RETENTION_MS;
this.maxRows = options.maxRows ?? DEFAULT_MAX_ROWS;
// Every streaming chunk lands here as an INSERT; WAL keeps those
// appends from serializing against replay reads, and the busy timeout
// matches the other SQLite stores instead of failing fast on contention.
this.db.exec("PRAGMA journal_mode = WAL;");
this.db.exec("PRAGMA busy_timeout = 5000;");
this.db.exec(`
CREATE TABLE IF NOT EXISTS hub_events (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
event TEXT NOT NULL,
session_id TEXT,
envelope_json TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_hub_events_session
ON hub_events(session_id, sequence);
CREATE INDEX IF NOT EXISTS idx_hub_events_created
ON hub_events(created_at);
`);
}
/**
* Append a durable event and return it stamped with its global sequence.
* The returned envelope (not the input) is what must be fanned out so
* live listeners and replaying clients observe identical frames.
*/
append(envelope: HubEventEnvelope): HubEventEnvelope {
if (this.closed) {
return envelope;
}
const createdAt = envelope.timestamp ?? Date.now();
const inserted = this.db
.prepare(
`INSERT INTO hub_events (event, session_id, envelope_json, created_at)
VALUES (?, ?, ?, ?);`,
)
.run(
envelope.event,
envelope.sessionId ?? null,
// Stored without `sequence`; stamped from the rowid on read/return.
JSON.stringify(envelope),
createdAt,
);
// The AUTOINCREMENT primary key IS the sequence, so the insert's own
// rowid stamps it without a second round-trip per streaming chunk.
const rowid = inserted?.lastInsertRowid;
const sequence =
typeof rowid === "number" || typeof rowid === "bigint"
? Number(rowid)
: this.lastSequence();
return { ...envelope, sequence };
}
/** Events after `sequence`, oldest first, optionally scoped to a session. */
listAfter(
sequence: number,
scope: HubEventLogScope,
limit: number,
): HubEventEnvelope[] {
if (this.closed) {
return [];
}
const clauses = ["sequence > ?"];
const params: unknown[] = [sequence];
if (scope.sessionId) {
clauses.push("session_id = ?");
params.push(scope.sessionId);
}
params.push(limit);
return this.db
.prepare(
`SELECT sequence, envelope_json FROM hub_events
WHERE ${clauses.join(" AND ")} ORDER BY sequence LIMIT ?;`,
)
.all(...params)
.flatMap((row) => {
try {
const envelope = JSON.parse(
String(row.envelope_json),
) as HubEventEnvelope;
return [{ ...envelope, sequence: Number(row.sequence) }];
} catch {
return [];
}
});
}
lastSequence(): number {
if (this.closed) {
return 0;
}
const row = this.db
.prepare("SELECT MAX(sequence) AS sequence FROM hub_events;")
.get();
return Number(row?.sequence ?? 0);
}
/** Bound the log: drop rows past retention, then enforce the row cap. */
prune(now = Date.now()): void {
if (this.closed) {
return;
}
this.db
.prepare("DELETE FROM hub_events WHERE created_at < ?;")
.run(now - this.retentionMs);
this.db
.prepare(
`DELETE FROM hub_events WHERE sequence <= (
SELECT sequence FROM hub_events ORDER BY sequence DESC
LIMIT 1 OFFSET ?
);`,
)
.run(this.maxRows);
}
close(): void {
if (this.closed) {
return;
}
this.closed = true;
this.db.close?.();
}
}
@@ -0,0 +1,105 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadSqliteDb } from "@cline/shared/db";
import { describe, expect, it } from "vitest";
import { HubRunAdmissionRejectedError, HubRunQueue } from "./hub-run-queue";
describe("HubRunQueue", () => {
it("opens its database in WAL mode", () => {
const dbPath = join(
mkdtempSync(join(tmpdir(), "cline-hub-runs-")),
"hub-runs.db",
);
const queue = new HubRunQueue({ dbPath });
queue.admit("s1", { prompt: "one" });
queue.close();
// WAL is persistent: a fresh connection observes the configured mode.
const db = loadSqliteDb(dbPath);
try {
expect(
String(db.prepare("PRAGMA journal_mode;").get()?.journal_mode),
).toBe("wal");
} finally {
db.close?.();
}
});
it("acks admission immediately with runId, acceptedAt, and queue position", () => {
const queue = new HubRunQueue({ dbPath: ":memory:" });
const first = queue.admit("s1", { prompt: "one" }, "client-a");
const second = queue.admit("s1", { prompt: "two" });
expect(first.runId).toMatch(/^hrun_/);
expect(first.queuePosition).toBe(0);
expect(second.queuePosition).toBe(1);
expect(first.acceptedAt).toBeGreaterThan(0);
queue.close();
});
it("dequeues in FIFO admission order per session", () => {
const queue = new HubRunQueue({ dbPath: ":memory:" });
const first = queue.admit("s1", { prompt: "one" });
queue.admit("s2", { prompt: "other-session" });
const second = queue.admit("s1", { prompt: "two" });
expect(queue.nextQueued("s1")?.runId).toBe(first.runId);
queue.markRunning(first.runId);
expect(queue.nextQueued("s1")?.runId).toBe(second.runId);
expect(queue.hasRunning("s1")).toBe(true);
queue.markTerminal(first.runId, "completed");
expect(queue.hasRunning("s1")).toBe(false);
queue.close();
});
it("applies backpressure with a retryable admission rejection", () => {
const queue = new HubRunQueue({
dbPath: ":memory:",
maxPendingPerSession: 2,
});
queue.admit("s1", { prompt: "one" });
queue.admit("s1", { prompt: "two" });
try {
queue.admit("s1", { prompt: "three" });
expect.unreachable("third admission must reject");
} catch (error) {
expect(error).toBeInstanceOf(HubRunAdmissionRejectedError);
expect((error as HubRunAdmissionRejectedError).retryable).toBe(true);
}
// A different session is unaffected by s1's full queue.
expect(queue.admit("s2", { prompt: "ok" }).queuePosition).toBe(0);
queue.close();
});
it("recovers on startup: running becomes interrupted, queued re-admits FIFO", () => {
const queue = new HubRunQueue({ dbPath: ":memory:" });
const crashed = queue.admit("s1", { prompt: "crashed mid-turn" });
queue.markRunning(crashed.runId);
const queuedA = queue.admit("s1", { prompt: "queued a" });
const queuedB = queue.admit("s2", { prompt: "queued b" });
const recovered = queue.recoverOnStartup();
expect(recovered.interrupted.map((run) => run.runId)).toEqual([
crashed.runId,
]);
expect(recovered.requeued.map((run) => run.runId)).toEqual([
queuedA.runId,
queuedB.runId,
]);
expect(queue.get(crashed.runId)?.state).toBe("interrupted");
expect(queue.get(crashed.runId)?.error).toContain("exited");
expect(queue.nextQueued("s1")?.runId).toBe(queuedA.runId);
queue.close();
});
it("records terminal states with errors and lists newest first", () => {
const queue = new HubRunQueue({ dbPath: ":memory:" });
const first = queue.admit("s1", { prompt: "one" });
const second = queue.admit("s1", { prompt: "two" });
queue.markRunning(first.runId);
queue.markTerminal(first.runId, "failed", "provider exploded");
const listed = queue.list({ sessionId: "s1" });
expect(listed.map((run) => run.runId)).toEqual([second.runId, first.runId]);
expect(listed[1]?.state).toBe("failed");
expect(listed[1]?.error).toBe("provider exploded");
queue.close();
});
});
@@ -0,0 +1,312 @@
/**
* Durable FIFO run queue with immediate acknowledgement.
*
* `run.enqueue` admits a prompt into this queue and acks immediately with
* `{runId, acceptedAt, queuePosition}` acceptance is decoupled from
* execution, so the reply never blocks on the turn and never dies with the
* socket. One run executes at a time per session, in admission order.
*
* Runs are durable: a daemon crash leaves `queued` rows to re-admit in FIFO
* order at the next startup, and `running` rows are marked `interrupted`
* never silently resumed, never left dangling as ghost "running" state.
*
* Admission applies backpressure: a full per-session queue rejects with a
* retryable `run_admission_rejected` instead of accepting unbounded work.
*/
import { join } from "node:path";
import { createSessionId } from "@cline/shared";
import { loadSqliteDb, type SqliteDb } from "@cline/shared/db";
import { resolveDbDataDir } from "@cline/shared/storage";
const DEFAULT_MAX_PENDING_PER_SESSION = 32;
const TERMINAL_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
export type HubRunState =
| "queued"
| "running"
| "completed"
| "failed"
| "aborted"
| "interrupted";
export interface HubRunRecord {
runId: string;
sessionId: string;
state: HubRunState;
/** The `run.start`-shaped payload to execute (prompt, mode, attachments, ...). */
input: Record<string, unknown>;
clientId?: string;
acceptedAt: number;
startedAt?: number;
endedAt?: number;
error?: string;
}
export interface HubRunAccepted {
runId: string;
acceptedAt: number;
queuePosition: number;
}
export class HubRunAdmissionRejectedError extends Error {
readonly retryable = true;
constructor(sessionId: string, pending: number, limit: number) {
super(
`Session ${sessionId} queue is full (${pending} pending runs, limit ${limit}); retry later.`,
);
this.name = "HubRunAdmissionRejectedError";
}
}
export interface HubRunQueueOptions {
/** Database file. Defaults to an owner-scoped `<data>/db/hub-runs-*.db`; use ":memory:" in tests. */
dbPath?: string;
/** Scopes the default `dbPath`; ignored when `dbPath` is given. */
ownerId?: string;
maxPendingPerSession?: number;
}
/**
* Default queue location, scoped per hub owner context: startup recovery
* marks orphaned `running` rows interrupted, and a coexisting hub (dev
* shared next to production) must never reap another hub's live runs.
*/
export function resolveHubRunQueuePath(ownerId?: string): string {
const scope = ownerId?.replace(/[^a-zA-Z0-9_-]/g, "-");
return join(
resolveDbDataDir(),
scope ? `hub-runs-${scope}.db` : "hub-runs.db",
);
}
export class HubRunQueue {
private readonly db: SqliteDb;
private readonly maxPendingPerSession: number;
private closed = false;
constructor(options: HubRunQueueOptions = {}) {
this.db = loadSqliteDb(
options.dbPath ?? resolveHubRunQueuePath(options.ownerId),
);
this.maxPendingPerSession =
options.maxPendingPerSession ?? DEFAULT_MAX_PENDING_PER_SESSION;
// WAL + a busy timeout, matching the other SQLite stores: admissions
// and state transitions must not serialize against run.list readers or
// fail fast when another handle briefly holds the write lock.
this.db.exec("PRAGMA journal_mode = WAL;");
this.db.exec("PRAGMA busy_timeout = 5000;");
this.db.exec(`
CREATE TABLE IF NOT EXISTS hub_runs (
accepted_seq INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT NOT NULL UNIQUE,
session_id TEXT NOT NULL,
state TEXT NOT NULL,
input_json TEXT NOT NULL,
client_id TEXT,
accepted_at INTEGER NOT NULL,
started_at INTEGER,
ended_at INTEGER,
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_hub_runs_session
ON hub_runs(session_id, accepted_seq);
CREATE INDEX IF NOT EXISTS idx_hub_runs_state
ON hub_runs(state, accepted_seq);
`);
}
/** Durable FIFO admission + immediate acknowledgement. */
admit(
sessionId: string,
input: Record<string, unknown>,
clientId?: string,
): HubRunAccepted {
const pendingAhead = this.countPendingBySession(sessionId);
if (pendingAhead >= this.maxPendingPerSession) {
throw new HubRunAdmissionRejectedError(
sessionId,
pendingAhead,
this.maxPendingPerSession,
);
}
const runId = createSessionId("hrun_");
const acceptedAt = Date.now();
this.db
.prepare(
`INSERT INTO hub_runs (run_id, session_id, state, input_json, client_id, accepted_at)
VALUES (?, ?, 'queued', ?, ?, ?);`,
)
.run(
runId,
sessionId,
JSON.stringify(input),
clientId ?? null,
acceptedAt,
);
return { runId, acceptedAt, queuePosition: pendingAhead };
}
get(runId: string): HubRunRecord | undefined {
const row = this.db
.prepare("SELECT * FROM hub_runs WHERE run_id = ?;")
.get(runId);
return row ? toRecord(row) : undefined;
}
/** Oldest queued run for the session, if any. */
nextQueued(sessionId: string): HubRunRecord | undefined {
const row = this.db
.prepare(
`SELECT * FROM hub_runs WHERE session_id = ? AND state = 'queued'
ORDER BY accepted_seq LIMIT 1;`,
)
.get(sessionId);
return row ? toRecord(row) : undefined;
}
/** Whether a run is currently marked running for the session. */
hasRunning(sessionId: string): boolean {
const row = this.db
.prepare(
"SELECT COUNT(*) AS n FROM hub_runs WHERE session_id = ? AND state = 'running';",
)
.get(sessionId);
return Number(row?.n ?? 0) > 0;
}
countPendingBySession(sessionId: string): number {
const row = this.db
.prepare(
`SELECT COUNT(*) AS n FROM hub_runs
WHERE session_id = ? AND state IN ('queued', 'running');`,
)
.get(sessionId);
return Number(row?.n ?? 0);
}
countPending(): number {
const row = this.db
.prepare(
"SELECT COUNT(*) AS n FROM hub_runs WHERE state IN ('queued', 'running');",
)
.get();
return Number(row?.n ?? 0);
}
markRunning(runId: string): void {
this.db
.prepare(
"UPDATE hub_runs SET state = 'running', started_at = ? WHERE run_id = ? AND state = 'queued';",
)
.run(Date.now(), runId);
}
markTerminal(
runId: string,
state: Extract<
HubRunState,
"completed" | "failed" | "aborted" | "interrupted"
>,
error?: string,
): void {
this.db
.prepare(
"UPDATE hub_runs SET state = ?, ended_at = ?, error = ? WHERE run_id = ?;",
)
.run(state, Date.now(), error ?? null, runId);
}
list(options: { sessionId?: string; limit?: number } = {}): HubRunRecord[] {
const clauses: string[] = [];
const params: unknown[] = [];
if (options.sessionId) {
clauses.push("session_id = ?");
params.push(options.sessionId);
}
params.push(options.limit ?? 100);
return this.db
.prepare(
`SELECT * FROM hub_runs
${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""}
ORDER BY accepted_seq DESC LIMIT ?;`,
)
.all(...params)
.map(toRecord);
}
/**
* Crash recovery, run once at daemon startup, before any new admission:
* runs left `running` by a dead daemon are marked `interrupted` (never
* auto-resumed a resumed half-turn is worse than an honest interrupt),
* and committed `queued` runs are returned for FIFO re-admission.
*/
recoverOnStartup(): {
interrupted: HubRunRecord[];
requeued: HubRunRecord[];
} {
const interrupted = this.db
.prepare(
"SELECT * FROM hub_runs WHERE state = 'running' ORDER BY accepted_seq;",
)
.all()
.map(toRecord);
this.db
.prepare(
`UPDATE hub_runs SET state = 'interrupted', ended_at = ?,
error = 'Hub daemon exited before the run finished.'
WHERE state = 'running';`,
)
.run(Date.now());
const requeued = this.db
.prepare(
"SELECT * FROM hub_runs WHERE state = 'queued' ORDER BY accepted_seq;",
)
.all()
.map(toRecord);
this.db
.prepare(
"DELETE FROM hub_runs WHERE ended_at IS NOT NULL AND ended_at < ?;",
)
.run(Date.now() - TERMINAL_RETENTION_MS);
return {
interrupted: interrupted.map((run) => ({
...run,
state: "interrupted" as const,
})),
requeued,
};
}
close(): void {
if (this.closed) {
return;
}
this.closed = true;
this.db.close?.();
}
}
function toRecord(row: Record<string, unknown>): HubRunRecord {
let input: Record<string, unknown> = {};
try {
const parsed = JSON.parse(String(row.input_json));
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
input = parsed as Record<string, unknown>;
}
} catch {
// A corrupt input row still surfaces as a record; execution will fail it.
}
return {
runId: String(row.run_id),
sessionId: String(row.session_id),
state: String(row.state) as HubRunState,
input,
clientId: typeof row.client_id === "string" ? row.client_id : undefined,
acceptedAt: Number(row.accepted_at),
startedAt: row.started_at === null ? undefined : Number(row.started_at),
endedAt: row.ended_at === null ? undefined : Number(row.ended_at),
error: typeof row.error === "string" ? row.error : undefined,
};
}
@@ -12,6 +12,8 @@ import type {
import type { CoreSettingsService } from "../../settings";
import type { AgendaTaskManagerOptions } from "../../tasks";
import type { HubOwnerContext } from "../discovery";
import type { HubEventLogOptions } from "./hub-event-log";
import type { HubRunQueueOptions } from "./hub-run-queue";
export interface HubWebSocketServerOptions {
/** Workspace authority assigned by the Hub to authenticated clients. */
@@ -61,6 +63,14 @@ export interface HubWebSocketServerOptions {
* signals, and fatal errors through one shutdown coordinator.
*/
onShutdownRequested?: () => void | Promise<void>;
/**
* Durable event log configuration. Pass `false` to disable persistence
* (events become fire-and-forget, the pre-log behavior; `stream.subscribe`
* replay cursors are then best-effort no-ops).
*/
eventLog?: HubEventLogOptions | false;
/** Durable run queue configuration (`run.enqueue`). */
runQueue?: HubRunQueueOptions | false;
}
export interface HubWebSocketServer {
@@ -46,6 +46,7 @@ import type { CoreSessionEvent } from "../../types/events";
import type { HubConnectionAuthority } from "./command-transport";
import {
handleApprovalRespond,
pendingApprovalEvents,
requestToolApproval as requestToolApprovalHandler,
resolvePendingApproval,
} from "./handlers/approval-handlers";
@@ -76,6 +77,13 @@ import {
handleSessionHook,
handleSessionInput,
} from "./handlers/run-handlers";
import {
drainingReply,
HubRunExecutor,
handleRunEnqueue,
handleRunList,
isDrainRefusedCommand,
} from "./handlers/run-queue-handlers";
import { projectSessionEvent } from "./handlers/session-event-projector";
import {
handleSessionAttach,
@@ -94,8 +102,10 @@ import {
handleSessionUpdateConnection,
handleSessionUpdatePendingPrompt,
} from "./handlers/session-handlers";
import { HubEventLogStore } from "./hub-event-log";
import { HubRunQueue } from "./hub-run-queue";
import { eventNameForScheduleCommand } from "./hub-schedule-events";
import { logHubBoundaryError } from "./hub-server-logging";
import { logHubBoundaryError, logHubMessage } from "./hub-server-logging";
import type { HubWebSocketServerOptions } from "./hub-server-options";
import type { HubSessionState } from "./hub-session-records";
import type { NativeHubTransport } from "./native-transport";
@@ -214,6 +224,13 @@ export class HubServerTransport implements NativeHubTransport {
Partial<PendingPromptsRuntimeService & CommandExecutionRuntimeService>;
private readonly hubId = createSessionId("hub_");
private readonly ctx: HubTransportContext;
/** Durable event log; created on start(), absent in never-started tests. */
private eventLog?: HubEventLogStore;
private eventLogPruneTimer?: ReturnType<typeof setInterval>;
/** Durable run queue + serial per-session executor (run.enqueue). */
private runQueue?: HubRunQueue;
private runExecutor?: HubRunExecutor;
private draining = false;
constructor(readonly options: HubWebSocketServerOptions) {
this.sessionHost =
@@ -225,6 +242,7 @@ export class HubServerTransport implements NativeHubTransport {
telemetry: options.telemetry,
});
this.ctx = {
isDraining: () => this.draining,
clients: this.clients,
sessionState: this.sessionState,
pendingApprovals: this.pendingApprovals,
@@ -544,9 +562,83 @@ export class HubServerTransport implements NativeHubTransport {
console.error("[hub] cron service start failed", err);
}
}
this.startEventLog();
this.startRunQueue();
}
private startEventLog(): void {
if (this.options.eventLog === false || this.eventLog) {
return;
}
try {
const eventLog = new HubEventLogStore({
ownerId: this.options.owner?.ownerId,
...(this.options.eventLog ?? {}),
});
eventLog.prune();
this.eventLog = eventLog;
this.eventLogPruneTimer = setInterval(
() => {
try {
eventLog.prune();
} catch {
// A failed sweep retries on the next interval.
}
},
60 * 60 * 1000,
);
this.eventLogPruneTimer.unref?.();
} catch (error) {
// Degrade to live-only fan-out (the pre-log behavior) rather than
// refusing to serve; replay cursors are then best-effort no-ops.
logHubMessage("error", "event_log.start_failed", { error });
}
}
private startRunQueue(): void {
if (this.options.runQueue === false || this.runQueue) {
return;
}
try {
this.runQueue = new HubRunQueue({
ownerId: this.options.owner?.ownerId,
...(this.options.runQueue ?? {}),
});
this.runExecutor = new HubRunExecutor(this.ctx, this.runQueue);
const recovered = this.runQueue.recoverOnStartup();
for (const run of recovered.interrupted) {
this.publish(
buildHubEvent(
"run.interrupted",
{
runId: run.runId,
error: run.error,
reason: "hub_restart",
},
run.sessionId,
),
);
}
const sessions = new Set(recovered.requeued.map((run) => run.sessionId));
for (const sessionId of sessions) {
this.runExecutor.pump(sessionId);
}
if (recovered.interrupted.length > 0 || recovered.requeued.length > 0) {
logHubMessage("info", "run.queue.recovered", {
interrupted: recovered.interrupted.length,
requeued: recovered.requeued.length,
});
}
} catch (error) {
logHubMessage("error", "run.queue.start_failed", { error });
}
}
async stop(): Promise<void> {
if (this.eventLogPruneTimer) {
clearInterval(this.eventLogPruneTimer);
this.eventLogPruneTimer = undefined;
}
for (const approvalId of this.pendingApprovals.keys()) {
resolvePendingApproval(this.ctx, approvalId, {
approved: false,
@@ -568,6 +660,11 @@ export class HubServerTransport implements NativeHubTransport {
console.error("[hub] cron service stop failed", err);
}
}
this.eventLog?.close();
this.eventLog = undefined;
this.runQueue?.close();
this.runQueue = undefined;
this.runExecutor = undefined;
}
async handleCommand(
@@ -611,6 +708,9 @@ export class HubServerTransport implements NativeHubTransport {
envelope: HubCommandEnvelope,
authority?: HubConnectionAuthority,
): Promise<HubReplyEnvelope> {
if (this.draining && isDrainRefusedCommand(envelope.command)) {
return drainingReply(envelope);
}
if (isAgendaTaskCommand(envelope.command)) {
return await this.taskCommands.handleCommand(envelope, authority);
}
@@ -680,6 +780,36 @@ export class HubServerTransport implements NativeHubTransport {
case "run.start":
case "session.send_input":
return await handleSessionInput(this.ctx, envelope);
case "run.enqueue": {
if (!this.runQueue || !this.runExecutor) {
return {
version: envelope.version,
requestId: envelope.requestId,
ok: false,
error: {
code: "run_queue_unavailable",
message:
"This hub has no durable run queue; use run.start instead.",
},
};
}
return handleRunEnqueue(
this.ctx,
envelope,
this.runQueue,
this.runExecutor,
);
}
case "run.list": {
if (!this.runQueue) {
return okReply(envelope, { runs: [] });
}
return handleRunList(envelope, this.runQueue);
}
case "hub.drain":
return this.handleHubDrain(envelope);
case "hub.status":
return this.handleHubStatus(envelope);
case "run.abort":
return await handleRunAbort(this.ctx, envelope);
case "run.proceed_while_running":
@@ -833,6 +963,79 @@ export class HubServerTransport implements NativeHubTransport {
}
}
/**
* Explicit drain: refuse new mutating work while accepted runs finish.
* This is the graceful half of an upgrade replacement happens at a
* boundary an operator chose, never as an ambush under a live turn.
*/
private handleHubDrain(envelope: HubCommandEnvelope): HubReplyEnvelope {
const requested = envelope.payload?.draining !== false;
const reason =
typeof envelope.payload?.reason === "string"
? envelope.payload.reason
: undefined;
if (this.draining !== requested) {
this.draining = requested;
this.publish(
buildHubEvent("hub.drain_changed", {
draining: this.draining,
...(reason ? { reason } : {}),
}),
);
logHubMessage("info", "hub.drain_changed", {
draining: this.draining,
reason,
});
}
return okReply(envelope, this.describeStatus());
}
private handleHubStatus(envelope: HubCommandEnvelope): HubReplyEnvelope {
return okReply(envelope, this.describeStatus());
}
private describeStatus(): Record<string, unknown> {
let activeRpcTurns = 0;
for (const count of this.activeRpcTurnCountBySession.values()) {
activeRpcTurns += count;
}
return {
hubId: this.hubId,
draining: this.draining,
activeRpcTurns,
pendingRuns: this.runQueue?.countPending() ?? 0,
eventLog: this.eventLog
? { lastSequence: this.eventLog.lastSequence() }
: undefined,
// Idle = safe to stop: nothing executing and nothing accepted-but-unstarted.
idle: activeRpcTurns === 0 && (this.runQueue?.countPending() ?? 0) === 0,
};
}
/** Whether the hub is currently draining (exposed for the HTTP status). */
isDraining(): boolean {
return this.draining;
}
/** Durable events after a cursor — the adapter's replay source. */
replayEventsAfter(
sinceSequence: number,
options: { sessionId?: string; limit: number },
): HubEventEnvelope[] {
if (!this.eventLog) {
return [];
}
return this.eventLog.listAfter(
sinceSequence,
{ sessionId: options.sessionId },
options.limit,
);
}
lastEventSequence(): number {
return this.eventLog?.lastSequence() ?? 0;
}
subscribe(
clientId: string,
listener: (event: HubEventEnvelope) => void,
@@ -842,6 +1045,27 @@ export class HubServerTransport implements NativeHubTransport {
const entry = { sessionId: options?.sessionId, listener };
current.add(entry);
this.listeners.set(clientId, current);
// Re-issue pending approvals so a (re)connecting client can answer a
// request raised while it was away instead of leaving the turn parked.
const pending = pendingApprovalEvents(this.ctx, options?.sessionId);
if (pending.length > 0) {
queueMicrotask(() => {
const listeners = this.listeners.get(clientId);
if (!listeners?.has(entry)) {
return;
}
for (const event of pending) {
try {
entry.listener(event);
} catch (error) {
logHubBoundaryError(
"listener threw while re-issuing pending approval",
error,
);
}
}
});
}
return () => {
const listeners = this.listeners.get(clientId);
if (!listeners) {
@@ -872,6 +1096,19 @@ export class HubServerTransport implements NativeHubTransport {
}
private publish(event: HubEventEnvelope): void {
// Durability before delivery: append to the event log and fan out the
// sequence-stamped envelope, so live listeners and replaying clients
// observe identical frames and the cursor is always meaningful.
if (this.eventLog) {
try {
event = this.eventLog.append(event);
} catch (error) {
logHubBoundaryError(
`event log append failed for ${event.event}`,
error,
);
}
}
for (const entries of this.listeners.values()) {
for (const entry of entries) {
if (entry.sessionId && entry.sessionId !== event.sessionId) {
@@ -0,0 +1,383 @@
/**
* Vertical-slice coverage for the Hub's app-server upgrades: durable
* sequence-stamped events with cursor replay, queue-backed run admission
* with an immediate ack, the drain lifecycle, and pending-approval re-issue
* on (re)subscribe.
*/
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { HubEventEnvelope } from "@cline/shared";
import { describe, expect, it, vi } from "vitest";
vi.mock("@ai-sdk/provider-utils", () => ({
createProviderDefinedToolFactory: vi.fn(() => vi.fn()),
}));
import type {
StartSessionInput,
StartSessionResult,
} from "../../runtime/host/runtime-host";
import type { HubTransportContext } from "./handlers/context";
import { HubServerTransport } from "./hub-server-transport";
function createStartedTransportOptions() {
const root = mkdtempSync(join(tmpdir(), "cline-hub-upgrades-"));
const sessions = new Map<string, Record<string, unknown>>();
const capturedStarts: StartSessionInput[] = [];
const startSession = vi.fn(
async (input: StartSessionInput): Promise<StartSessionResult> => {
capturedStarts.push(input);
const sessionId = input.config.sessionId ?? "session-x";
sessions.set(sessionId, {
sessionId,
source: "core",
status: "running",
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
interactive: input.interactive === true,
provider: input.config.providerId,
model: input.config.modelId,
cwd: input.config.cwd ?? root,
workspaceRoot: input.config.workspaceRoot ?? root,
});
return {
sessionId,
manifest: {
version: 1,
session_id: sessionId,
source: "core",
pid: 1,
started_at: new Date().toISOString(),
status: "running",
interactive: input.interactive === true,
provider: input.config.providerId,
model: input.config.modelId,
cwd: input.config.cwd ?? root,
workspace_root: input.config.workspaceRoot ?? root,
enable_tools: true,
enable_spawn: true,
enable_teams: true,
},
manifestPath: "",
messagesPath: "",
};
},
);
const runTurn = vi.fn(async () => ({
text: "done",
finishReason: "completed" as const,
toolCalls: [],
}));
return {
root,
sessions,
capturedStarts,
startSession,
runTurn,
options: {
workspaceRoot: root,
runtimeHandlers: {
startSession: vi.fn(),
sendSession: vi.fn(),
abortSession: vi.fn(),
stopSession: vi.fn(),
},
scheduleOptions: { dbPath: ":memory:" },
taskOptions: {
dbPath: join(root, "tasks.db"),
globalSpecsDir: join(root, "specs"),
watchFiles: false,
},
eventLog: { dbPath: ":memory:" },
runQueue: { dbPath: ":memory:" },
sessionHost: {
subscribe: vi.fn(() => () => {}),
startSession,
runTurn,
stopSession: vi.fn(async () => {}),
abort: vi.fn(async () => {}),
dispose: vi.fn(async () => {}),
getSession: vi.fn(async (sessionId: string) => sessions.get(sessionId)),
getAccumulatedUsage: vi.fn(async () => undefined),
listSessions: vi.fn(async () => [...sessions.values()]),
deleteSession: vi.fn(async () => false),
updateSession: vi.fn(async () => ({ updated: false })),
updateSessionCompactionState: vi.fn(async () => ({ updated: false })),
readSessionCompactionState: vi.fn(async () => undefined),
readSessionMessages: vi.fn(async () => []),
dispatchHookEvent: vi.fn(async () => {}),
restoreSession: vi.fn(),
} as never,
},
};
}
async function waitFor(
predicate: () => boolean | Promise<boolean>,
timeoutMs = 2_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!(await predicate())) {
if (Date.now() > deadline) {
throw new Error("condition not reached in time");
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
describe("Hub app-server upgrades", () => {
it("stamps published events with durable sequences and replays from a cursor", async () => {
const { options } = createStartedTransportOptions();
const transport = new HubServerTransport(options as never);
await transport.start();
try {
const seen: HubEventEnvelope[] = [];
transport.subscribe("observer", (event) => seen.push(event));
await transport.handleCommand({
version: "v1",
command: "session.create",
clientId: "creator",
payload: { sessionConfig: { sessionId: "replay-session" } },
});
await transport.handleCommand({
version: "v1",
command: "run.start",
clientId: "creator",
sessionId: "replay-session",
payload: { prompt: "hello" },
});
expect(seen.length).toBeGreaterThan(0);
for (const event of seen) {
expect(typeof event.sequence).toBe("number");
}
const sequences = seen.map((event) => event.sequence ?? 0);
expect([...sequences].sort((a, b) => a - b)).toEqual(sequences);
// Replay from the middle of the stream returns exactly the tail.
const cursor = sequences[0] ?? 0;
const replay = transport.replayEventsAfter(cursor, { limit: 100 });
expect(replay.map((event) => event.sequence)).toEqual(sequences.slice(1));
expect(transport.lastEventSequence()).toBe(sequences.at(-1));
} finally {
await transport.stop();
}
});
it("acks run.enqueue immediately and executes the run through the queue", async () => {
const { options, runTurn } = createStartedTransportOptions();
const transport = new HubServerTransport(options as never);
await transport.start();
try {
const events: HubEventEnvelope[] = [];
transport.subscribe("observer", (event) => events.push(event));
await transport.handleCommand({
version: "v1",
command: "session.create",
clientId: "creator",
payload: { sessionConfig: { sessionId: "queued-session" } },
});
const reply = await transport.handleCommand({
version: "v1",
command: "run.enqueue",
clientId: "creator",
sessionId: "queued-session",
payload: { prompt: "queued work" },
});
expect(reply.ok).toBe(true);
expect(reply.payload?.runId).toMatch(/^hrun_/);
expect(reply.payload?.queuePosition).toBe(0);
expect(typeof reply.payload?.acceptedAt).toBe("number");
await waitFor(() =>
events.some((event) => event.event === "run.completed"),
);
expect(runTurn).toHaveBeenCalledOnce();
expect(events.some((event) => event.event === "run.enqueued")).toBe(true);
const listed = await transport.handleCommand({
version: "v1",
command: "run.list",
sessionId: "queued-session",
});
expect(listed.ok).toBe(true);
const runs = listed.payload?.runs as { state: string }[];
expect(runs).toHaveLength(1);
expect(runs[0]?.state).toBe("completed");
} finally {
await transport.stop();
}
});
it("refuses new mutating work while draining, with a retryable error", async () => {
const { options } = createStartedTransportOptions();
const transport = new HubServerTransport(options as never);
await transport.start();
try {
const drainReply = await transport.handleCommand({
version: "v1",
command: "hub.drain",
payload: { reason: "test upgrade" },
});
expect(drainReply.ok).toBe(true);
expect(drainReply.payload?.draining).toBe(true);
expect(transport.isDraining()).toBe(true);
const refused = await transport.handleCommand({
version: "v1",
command: "session.create",
clientId: "creator",
payload: {},
});
expect(refused.ok).toBe(false);
expect(refused.error?.code).toBe("hub_draining");
expect(refused.error?.details?.retryable).toBe(true);
// Reads still work while draining.
const status = await transport.handleCommand({
version: "v1",
command: "hub.status",
});
expect(status.ok).toBe(true);
expect(status.payload?.draining).toBe(true);
const undrain = await transport.handleCommand({
version: "v1",
command: "hub.drain",
payload: { draining: false },
});
expect(undrain.payload?.draining).toBe(false);
const allowed = await transport.handleCommand({
version: "v1",
command: "session.create",
clientId: "creator",
payload: { sessionConfig: { sessionId: "post-drain" } },
});
expect(allowed.ok).toBe(true);
} finally {
await transport.stop();
}
});
it("re-issues pending approval requests to a (re)subscribing client", async () => {
const { options } = createStartedTransportOptions();
const transport = new HubServerTransport(options as never);
await transport.start();
try {
await transport.handleCommand({
version: "v1",
command: "session.create",
clientId: "creator",
payload: {
sessionConfig: { sessionId: "approval-session" },
metadata: { interactive: true },
},
});
const ctx = (
transport as unknown as {
ctx: import("./handlers/context").HubTransportContext;
}
).ctx;
const { requestToolApproval } = await import(
"./handlers/approval-handlers"
);
// Raise an approval while nobody is subscribed — the old Hub lost
// this event forever and parked the turn.
const approvalPromise = requestToolApproval(ctx, {
sessionId: "approval-session",
agentId: "agent-1",
conversationId: "conv-1",
iteration: 1,
toolCallId: "call-1",
toolName: "write_file",
input: {},
policy: "ask",
} as never);
const seen: HubEventEnvelope[] = [];
transport.subscribe("late-client", (event) => seen.push(event), {
sessionId: "approval-session",
});
await waitFor(() =>
seen.some((event) => event.event === "approval.requested"),
);
const requested = seen.find(
(event) => event.event === "approval.requested",
);
const approvalId = requested?.payload?.approvalId as string;
const respond = await transport.handleCommand({
version: "v1",
command: "approval.respond",
clientId: "late-client",
payload: { approvalId, approved: true },
});
expect(respond.ok).toBe(true);
await expect(approvalPromise).resolves.toEqual({
approved: true,
reason: undefined,
});
} finally {
await transport.stop();
}
});
it("recovers queued runs and interrupts orphaned running runs across a restart", async () => {
const { options } = createStartedTransportOptions();
const root = mkdtempSync(join(tmpdir(), "cline-hub-recovery-"));
const runsDb = join(root, "hub-runs.db");
// First hub generation: admit one run and crash before executing it.
const { HubRunQueue } = await import("./hub-run-queue");
const preCrash = new HubRunQueue({ dbPath: runsDb });
const orphan = preCrash.admit("lost-session", { prompt: "was running" });
preCrash.markRunning(orphan.runId);
const queued = preCrash.admit("lost-session", { prompt: "still queued" });
preCrash.close();
// Second generation recovers on start: the orphan is interrupted, the
// queued run re-admits (and fails cleanly because the session is gone).
const transport = new HubServerTransport({
...options,
runQueue: { dbPath: runsDb },
} as never);
const events: HubEventEnvelope[] = [];
transport.subscribe("observer", (event) => events.push(event));
await transport.start();
try {
expect(
events.some(
(event) =>
event.event === "run.interrupted" &&
event.payload?.runId === orphan.runId,
),
).toBe(true);
const listed = await transport.handleCommand({
version: "v1",
command: "run.list",
sessionId: "lost-session",
});
const runs = listed.payload?.runs as {
runId: string;
state: string;
}[];
expect(runs.find((run) => run.runId === orphan.runId)?.state).toBe(
"interrupted",
);
// The re-admitted run settles terminally (its session no longer
// exists), never dangling as "queued" or ghost-"running".
await waitFor(async () => {
const relisted = await transport.handleCommand({
version: "v1",
command: "run.list",
sessionId: "lost-session",
});
const state = (
relisted.payload?.runs as { runId: string; state: string }[]
).find((run) => run.runId === queued.runId)?.state;
return state === "failed" || state === "completed";
});
} finally {
await transport.stop();
}
});
});
@@ -0,0 +1,209 @@
/**
* ensureHubWebSocketServer replacement rules for a live-but-unusable
* discovered hub. They must agree with the detached-daemon ensure path:
* a hub serving sessions is attached to (never ambushed), and retirement
* goes through the shared retireDiscoveredHub (drain first, discovery
* cleared only when the hub actually went away).
*/
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@ai-sdk/provider-utils", () => ({
createProviderDefinedToolFactory: vi.fn(() => vi.fn()),
}));
const {
hubHasLiveSessions,
retireDiscoveredHub,
verifyHubConnection,
probeHubServer,
readHubDiscovery,
clearHubDiscovery,
isManagedHubReusable,
} = vi.hoisted(() => ({
hubHasLiveSessions: vi.fn(),
retireDiscoveredHub: vi.fn(),
verifyHubConnection: vi.fn(),
probeHubServer: vi.fn(),
readHubDiscovery: vi.fn(),
clearHubDiscovery: vi.fn(async () => undefined),
isManagedHubReusable: vi.fn(() => false),
}));
vi.mock("../daemon", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
hubHasLiveSessions,
retireDiscoveredHub,
}));
vi.mock("../client", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
verifyHubConnection,
}));
vi.mock("../discovery", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
probeHubServer,
readHubDiscovery,
clearHubDiscovery,
isManagedHubReusable,
}));
import type { HubWebSocketServer } from "./hub-server-options";
import { ensureHubWebSocketServer } from "./hub-websocket-server";
const STALE_URL = "ws://127.0.0.1:39999/hub";
function createOwner() {
const root = mkdtempSync(join(tmpdir(), "cline-hub-ensure-retire-"));
return {
ownerId: "hub-ensure-retire-test",
discoveryPath: join(root, "discovery.json"),
};
}
function stubSessionHost() {
return {
subscribe: vi.fn(() => () => {}),
startSession: vi.fn(),
runTurn: vi.fn(),
stopSession: vi.fn(async () => {}),
abort: vi.fn(async () => {}),
dispose: vi.fn(async () => {}),
getSession: vi.fn(async () => undefined),
getAccumulatedUsage: vi.fn(async () => undefined),
listSessions: vi.fn(async () => []),
deleteSession: vi.fn(async () => false),
updateSession: vi.fn(async () => ({ updated: false })),
updateSessionCompactionState: vi.fn(async () => ({ updated: false })),
readSessionCompactionState: vi.fn(async () => undefined),
readSessionMessages: vi.fn(async () => []),
dispatchHookEvent: vi.fn(async () => {}),
restoreSession: vi.fn(),
} as never;
}
function ensureOptions(owner: ReturnType<typeof createOwner>) {
const root = mkdtempSync(join(tmpdir(), "cline-hub-ensure-ws-"));
return {
owner,
host: "127.0.0.1",
port: 0,
pathname: "/hub",
workspaceRoot: root,
runtimeHandlers: {
startSession: vi.fn(),
sendSession: vi.fn(),
abortSession: vi.fn(),
stopSession: vi.fn(),
},
scheduleOptions: { dbPath: ":memory:" },
taskOptions: {
dbPath: join(root, "tasks.db"),
globalSpecsDir: join(root, "specs"),
watchFiles: false,
},
eventLog: { dbPath: ":memory:" },
runQueue: { dbPath: ":memory:" },
sessionHost: stubSessionHost(),
} as never;
}
describe("ensureHubWebSocketServer retire path", () => {
const servers = new Set<HubWebSocketServer>();
afterEach(async () => {
for (const server of servers) {
await server.close().catch(() => undefined);
}
servers.clear();
vi.clearAllMocks();
clearHubDiscovery.mockResolvedValue(undefined);
isManagedHubReusable.mockReturnValue(false);
});
it("attaches to a busy unusable hub instead of retiring it", async () => {
const owner = createOwner();
readHubDiscovery.mockResolvedValue({
url: STALE_URL,
authToken: "busy-token",
pid: 4242,
});
probeHubServer.mockResolvedValue({
url: STALE_URL,
protocolVersion: "v1",
buildId: "old-build",
pid: 4242,
});
hubHasLiveSessions.mockResolvedValue(true);
verifyHubConnection.mockResolvedValue(true);
const result = await ensureHubWebSocketServer(ensureOptions(owner));
expect(result).toMatchObject({
url: STALE_URL,
authToken: "busy-token",
action: "reuse",
});
expect(hubHasLiveSessions).toHaveBeenCalledWith({
url: STALE_URL,
authToken: "busy-token",
pid: 4242,
});
expect(retireDiscoveredHub).not.toHaveBeenCalled();
expect(clearHubDiscovery).not.toHaveBeenCalled();
});
it("retires an idle unusable hub through the shared drain-first retirement", async () => {
const owner = createOwner();
readHubDiscovery.mockResolvedValue({
url: STALE_URL,
authToken: "old-token",
pid: 4242,
});
probeHubServer.mockResolvedValue({
url: STALE_URL,
protocolVersion: "v1",
buildId: "old-build",
pid: 4242,
});
hubHasLiveSessions.mockResolvedValue(false);
retireDiscoveredHub.mockResolvedValue(true);
const result = await ensureHubWebSocketServer(ensureOptions(owner));
if (result.server) {
servers.add(result.server);
}
expect(retireDiscoveredHub).toHaveBeenCalledWith(
{ url: STALE_URL, authToken: "old-token", pid: 4242 },
owner.discoveryPath,
);
// Discovery is retireDiscoveredHub's responsibility (cleared only when
// the hub actually retired); the ensure path must not clear it itself.
expect(clearHubDiscovery).not.toHaveBeenCalled();
expect(result.action).toBe("started");
expect(result.url).not.toBe(STALE_URL);
});
it("clears discovery for a stale record whose endpoint is gone", async () => {
const owner = createOwner();
readHubDiscovery.mockResolvedValue({
url: STALE_URL,
authToken: "gone-token",
});
probeHubServer.mockResolvedValue(undefined);
const result = await ensureHubWebSocketServer(ensureOptions(owner));
if (result.server) {
servers.add(result.server);
}
expect(clearHubDiscovery).toHaveBeenCalledWith(owner.discoveryPath);
expect(retireDiscoveredHub).not.toHaveBeenCalled();
expect(result.action).toBe("started");
});
});
@@ -9,7 +9,11 @@ import {
} from "@cline/shared";
import { WebSocketServer } from "ws";
import corePackage from "../../../package.json";
import { rememberRecoverableLocalHubUrl, verifyHubConnection } from "../client";
import {
rememberRecoverableLocalHubUrl,
verifyHubConnection,
} from "../client";
import { hubHasLiveSessions, retireDiscoveredHub } from "../daemon";
import {
clearHubDiscovery,
clearHubDiscoveryIfOwned,
@@ -26,7 +30,13 @@ import {
writeHubDiscovery,
} from "../discovery";
import { resolveDefaultHubPort } from "../discovery/defaults";
import {
HubInstanceLock,
isHubLockHeldError,
resolveHubInstanceLockPath,
} from "../discovery/instance-lock";
import { BrowserWebSocketHubAdapter } from "./browser-websocket";
import { logHubMessage } from "./hub-server-logging";
import type {
EnsuredHubWebSocketServerResult,
EnsureHubWebSocketServerOptions,
@@ -237,6 +247,9 @@ const SHARED_SERVERS = new Map<string, SharedHubServerEntry>();
const HUB_AUTH_PROTOCOL_PREFIX = "cline-hub-auth.";
const HUB_SOCKET_HEARTBEAT_INTERVAL_MS = 30_000;
const HUB_STARTUP_ROLLBACK_TIMEOUT_MS = 2_000;
/** How long ensure waits for a retiring predecessor's endpoint and lock. */
const ENSURE_RETIRE_WAIT_MS = 3_000;
const ENSURE_RETIRE_POLL_MS = 100;
async function settlesWithin(
promise: Promise<unknown>,
@@ -343,8 +356,32 @@ export async function startHubWebSocketServer(
const buildId = resolveHubBuildId();
const buildEpochMs = resolveHubBuildEpochMs();
const authToken = createHubAuthToken();
const transport = new HubServerTransport(options);
await transport.start();
// Singleton authority is an OS-backed exclusive lock scoped to the owner
// context, acquired before any resource is created. A process that cannot
// take it must connect to the running Hub or diagnose — never replace it.
// This removes kill-based build arbitration as the ownership mechanism:
// two live daemons for one owner are now structurally impossible.
const instanceLock = HubInstanceLock.acquire(
resolveHubInstanceLockPath(owner.discoveryPath),
);
if (!instanceLock.held) {
// SQLite is unavailable in this runtime, so singleton enforcement is
// off; the Hub still serves (the event log and run queue degrade the
// same way) rather than refusing to start over a missing lock backend.
logHubMessage("warn", "instance_lock.unavailable", {
lockFile: instanceLock.lockFile,
});
}
let transport: HubServerTransport;
try {
// The resolved owner context flows into the transport so its durable
// stores (event log, run queue) default to owner-scoped files.
transport = new HubServerTransport({ ...options, owner });
await transport.start();
} catch (error) {
instanceLock.release();
throw error;
}
const hubId = transport.getHubId();
const adapter = new BrowserWebSocketHubAdapter(
new NativeHubTransportAdapter(transport),
@@ -425,6 +462,9 @@ export async function startHubWebSocketServer(
if (shared?.server === exposedServer) {
SHARED_SERVERS.delete(owner.discoveryPath);
}
// Release singleton ownership last: the successor may take the lock
// only once the endpoint, transport, and discovery are all retired.
instanceLock.release();
});
closeHandle = { transportStopped, closed };
@@ -463,6 +503,7 @@ export async function startHubWebSocketServer(
coreVersion: versionPayload.coreVersion,
buildId: versionPayload.buildId,
buildEpochMs: versionPayload.buildEpochMs,
draining: transport.isDraining(),
host,
port,
url,
@@ -504,6 +545,42 @@ export async function startHubWebSocketServer(
return;
}
const requestUrl = new URL(req.url ?? "/", `http://${host}:${port}`);
if (requestUrl.pathname === "/drain" && req.method === "POST") {
if (
!isValidHubAuthToken(
readBearerToken(req.headers.authorization),
authToken,
)
) {
res.statusCode = 401;
res.end("Unauthorized");
return;
}
const draining = requestUrl.searchParams.get("off") === null;
void transport
.handleCommand({
version: CURRENT_HUB_PROTOCOL_VERSION,
command: "hub.drain",
payload: {
draining,
reason:
requestUrl.searchParams.get("reason") ??
"authenticated HTTP drain request",
},
})
.then(
(reply) => {
res.statusCode = reply.ok ? 200 : 500;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(reply.payload ?? { ok: reply.ok }));
},
() => {
res.statusCode = 500;
res.end("Drain failed");
},
);
return;
}
if (requestUrl.pathname === "/shutdown" && req.method === "POST") {
if (
!isValidHubAuthToken(
@@ -652,6 +729,7 @@ export async function startHubWebSocketServer(
Promise.resolve().then(() => transport.stop()),
HUB_STARTUP_ROLLBACK_TIMEOUT_MS,
);
instanceLock.release();
throw error;
}
@@ -790,8 +868,45 @@ export async function ensureHubWebSocketServer(
);
}
// A discovered endpoint that cannot be authenticated/verified is stale.
await clearHubDiscovery(owner.discoveryPath);
// A live hub that cannot be reused must be retired before a
// successor can exist: singleton ownership is lock-enforced, so
// starting a replacement while it lives would (correctly) fail
// with the instance lock held. Retirement follows the same rules
// as the detached-daemon ensure path (retireDiscoveredHub): never
// ambush a hub that is still serving sessions, drain before the
// shutdown request, and clear discovery only once the hub is
// actually gone — clearing the record of a survivor would leave a
// live daemon undiscoverable.
if (healthy?.url) {
const retirementRecord = {
url: healthy.url,
authToken: discovered.authToken,
pid: healthy.pid ?? discovered.pid,
};
if (await hubHasLiveSessions(retirementRecord)) {
// Busy: attach to the older hub instead of replacing it,
// mirroring the daemon's deferred_busy handling. If it
// cannot be attached either, leave it running — starting
// below surfaces the instance-lock conflict instead of
// tearing down live sessions.
if (
await verifyHubConnection(healthy.url, {
authToken: discovered.authToken,
})
) {
return rememberIfManaged({
url: healthy.url,
authToken: discovered.authToken,
action: "reuse",
});
}
} else {
await retireDiscoveredHub(retirementRecord, owner.discoveryPath);
}
} else {
// A discovered endpoint that cannot even be probed is stale.
await clearHubDiscovery(owner.discoveryPath);
}
}
const start = async (
@@ -821,13 +936,24 @@ export async function ensureHubWebSocketServer(
}
};
try {
return await start(options);
} catch (error) {
if (!options.allowPortFallback || !isAddressInUseError(error)) {
throw error;
// The predecessor's lock release trails its HTTP close slightly, so a
// lock-held failure inside the wait window retries instead of failing.
const lockDeadline = Date.now() + ENSURE_RETIRE_WAIT_MS;
for (;;) {
try {
return await start(options);
} catch (error) {
if (isHubLockHeldError(error) && Date.now() < lockDeadline) {
await new Promise((resolve) =>
setTimeout(resolve, ENSURE_RETIRE_POLL_MS),
);
continue;
}
if (!options.allowPortFallback || !isAddressInUseError(error)) {
throw error;
}
return await start({ ...options, port: 0 });
}
return await start({ ...options, port: 0 });
}
});
}
@@ -18,6 +18,11 @@ export interface NativeHubTransport {
listener: (event: HubEventEnvelope) => void,
options?: { sessionId?: string },
): () => void;
/** See {@link HubCommandTransport.replayEventsAfter}. */
replayEventsAfter?(
sinceSequence: number,
options: { sessionId?: string; limit: number },
): HubEventEnvelope[];
}
export class NativeHubTransportAdapter implements HubCommandTransport {
@@ -37,4 +42,11 @@ export class NativeHubTransportAdapter implements HubCommandTransport {
): () => void {
return this.transport.subscribe(clientId, listener, options);
}
replayEventsAfter(
sinceSequence: number,
options: { sessionId?: string; limit: number },
): HubEventEnvelope[] {
return this.transport.replayEventsAfter?.(sinceSequence, options) ?? [];
}
}
@@ -0,0 +1,247 @@
/**
* Wire-level proof of the durable-event upgrade: a client that was never
* connected while a run streamed can subscribe later with a cursor and
* receive the whole history over a real WebSocket the Hub no longer
* requires a witness for events to survive.
*/
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { HubEventEnvelope, HubTransportFrame } from "@cline/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WebSocket } from "ws";
vi.mock("@ai-sdk/provider-utils", () => ({
createProviderDefinedToolFactory: vi.fn(() => vi.fn()),
}));
import { createLocalHubScheduleRuntimeHandlers } from "./../daemon/runtime-handlers";
import { createInMemoryHubOwnerContext } from "../discovery";
import type { HubWebSocketServer } from "./hub-server-options";
import { startHubWebSocketServer } from "./hub-websocket-server";
const servers = new Set<HubWebSocketServer>();
const sockets = new Set<WebSocket>();
afterEach(async () => {
for (const socket of sockets) {
try {
socket.close();
} catch {
// already closed
}
}
sockets.clear();
for (const server of servers) {
await server.close().catch(() => undefined);
}
servers.clear();
});
function stubSessionHost() {
const root = mkdtempSync(join(tmpdir(), "cline-hub-replay-wire-"));
const sessions = new Map<string, Record<string, unknown>>();
return {
root,
host: {
subscribe: vi.fn(() => () => {}),
startSession: vi.fn(async (input: { config: { sessionId?: string } }) => {
const sessionId = input.config.sessionId ?? "wire-session";
sessions.set(sessionId, {
sessionId,
source: "core",
status: "running",
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
interactive: true,
cwd: root,
workspaceRoot: root,
});
return {
sessionId,
manifest: {
version: 1,
session_id: sessionId,
source: "core",
pid: 1,
started_at: new Date().toISOString(),
status: "running",
interactive: true,
cwd: root,
workspace_root: root,
enable_tools: true,
enable_spawn: true,
enable_teams: true,
},
manifestPath: "",
messagesPath: "",
};
}),
runTurn: vi.fn(async () => ({
text: "done",
finishReason: "completed" as const,
toolCalls: [],
})),
stopSession: vi.fn(async () => {}),
abort: vi.fn(async () => {}),
dispose: vi.fn(async () => {}),
getSession: vi.fn(async (sessionId: string) => sessions.get(sessionId)),
getAccumulatedUsage: vi.fn(async () => undefined),
listSessions: vi.fn(async () => [...sessions.values()]),
deleteSession: vi.fn(async () => false),
updateSession: vi.fn(async () => ({ updated: false })),
updateSessionCompactionState: vi.fn(async () => ({ updated: false })),
readSessionCompactionState: vi.fn(async () => undefined),
readSessionMessages: vi.fn(async () => []),
dispatchHookEvent: vi.fn(async () => {}),
restoreSession: vi.fn(),
} as never,
};
}
async function openSocket(url: string, authToken: string): Promise<WebSocket> {
const socket = new WebSocket(url, `cline-hub-auth.${authToken}`);
sockets.add(socket);
await new Promise<void>((resolve, reject) => {
socket.once("open", () => resolve());
socket.once("error", reject);
});
return socket;
}
function sendFrame(socket: WebSocket, frame: HubTransportFrame): void {
socket.send(JSON.stringify(frame));
}
async function commandOverSocket(
socket: WebSocket,
events: HubEventEnvelope[],
envelope: {
command: string;
requestId: string;
clientId?: string;
sessionId?: string;
payload?: Record<string, unknown>;
},
): Promise<Record<string, unknown> | undefined> {
return await new Promise((resolve, reject) => {
const onMessage = (raw: Buffer | string) => {
const frame = JSON.parse(String(raw)) as HubTransportFrame;
if (frame.kind === "event") {
events.push(frame.envelope);
return;
}
if (
frame.kind === "reply" &&
frame.envelope.requestId === envelope.requestId
) {
socket.off("message", onMessage);
if (frame.envelope.ok) {
resolve(frame.envelope.payload);
} else {
reject(new Error(frame.envelope.error?.message ?? "command failed"));
}
}
};
socket.on("message", onMessage);
sendFrame(socket, {
kind: "command",
envelope: { version: "v1", ...envelope },
} as HubTransportFrame);
});
}
describe("hub event replay over the wire", () => {
it("replays a full run to a client that connected after the fact", async () => {
const { root, host } = stubSessionHost();
const server = await startHubWebSocketServer({
owner: createInMemoryHubOwnerContext("hub-replay-wire"),
host: "127.0.0.1",
port: 0,
pathname: "/hub",
workspaceRoot: root,
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
scheduleOptions: { dbPath: ":memory:" },
taskOptions: {
dbPath: join(root, "tasks.db"),
globalSpecsDir: join(root, "specs"),
watchFiles: false,
},
eventLog: { dbPath: ":memory:" },
runQueue: { dbPath: ":memory:" },
sessionHost: host,
});
servers.add(server);
// Writer connection: create a session and run a turn with NO event
// subscription anywhere — the pre-upgrade Hub dropped these events.
const writer = await openSocket(server.url, server.authToken);
const writerEvents: HubEventEnvelope[] = [];
await commandOverSocket(writer, writerEvents, {
command: "session.create",
requestId: "req-create",
clientId: "writer",
payload: { sessionConfig: { sessionId: "wire-session" } },
});
await commandOverSocket(writer, writerEvents, {
command: "run.start",
requestId: "req-run",
clientId: "writer",
sessionId: "wire-session",
payload: { prompt: "do the thing" },
});
writer.close();
// Late reader: was never connected during the run; a cursor subscribe
// replays the entire durable history in order.
const reader = await openSocket(server.url, server.authToken);
const replayed: HubEventEnvelope[] = [];
reader.on("message", (raw) => {
const frame = JSON.parse(String(raw)) as HubTransportFrame;
if (frame.kind === "event") {
replayed.push(frame.envelope);
}
});
sendFrame(reader, {
kind: "stream.subscribe",
clientId: "late-reader",
sessionId: "wire-session",
sinceSequence: 0,
} as HubTransportFrame);
const deadline = Date.now() + 5_000;
while (
!replayed.some((event) => event.event === "run.completed") &&
Date.now() < deadline
) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
const names = replayed.map((event) => event.event);
expect(names).toContain("session.created");
expect(names).toContain("run.started");
expect(names).toContain("run.completed");
const sequences = replayed.map((event) => event.sequence ?? 0);
expect([...sequences].sort((a, b) => a - b)).toEqual(sequences);
expect(new Set(sequences).size).toBe(sequences.length);
// A live-only subscriber (legacy frame, no cursor) gets nothing from
// history — the legacy contract is untouched.
const legacy = await openSocket(server.url, server.authToken);
const legacyEvents: HubEventEnvelope[] = [];
legacy.on("message", (raw) => {
const frame = JSON.parse(String(raw)) as HubTransportFrame;
if (frame.kind === "event") {
legacyEvents.push(frame.envelope);
}
});
sendFrame(legacy, {
kind: "stream.subscribe",
clientId: "legacy-reader",
sessionId: "wire-session",
} as HubTransportFrame);
await new Promise((resolve) => setTimeout(resolve, 250));
expect(legacyEvents).toEqual([]);
}, 15_000);
});
@@ -13,6 +13,7 @@ import type {
ITelemetryService,
} from "@cline/shared";
import { describe, expect, it, vi } from "vitest";
import { version as clineCoreVersion } from "../../../package.json";
import {
buildMessageModelInfo,
buildModelOptions,
@@ -192,6 +193,32 @@ describe("createAgentRuntimeConfig", () => {
);
});
it("maps telemetry identity fields from AgentConfig", () => {
const runtimeConfig = createAgentRuntimeConfig({
agentConfig: makeAgentConfig({
distinctId: "user-123",
extensionContext: {
client: { name: "cline-cli", version: "3.0.38" },
},
}),
agentId: "a",
model: nullModel,
});
expect(runtimeConfig.distinctId).toBe("user-123");
expect(runtimeConfig.clientName).toBe("cline-cli");
expect(runtimeConfig.clientVersion).toBe("3.0.38");
expect(runtimeConfig.clineCoreVersion).toBe(clineCoreVersion);
});
it("falls back to AgentConfig.sessionId when the input has none", () => {
const runtimeConfig = createAgentRuntimeConfig({
agentConfig: makeAgentConfig({ sessionId: "sess-parent" }),
agentId: "a",
model: nullModel,
});
expect(runtimeConfig.sessionId).toBe("sess-parent");
});
it("uses the override systemPrompt when provided", () => {
const runtimeConfig = createAgentRuntimeConfig({
agentConfig: makeAgentConfig({ systemPrompt: "default" }),
@@ -25,6 +25,7 @@ import type {
BasicLogger,
ITelemetryService,
} from "@cline/shared";
import { version as clineCoreVersion } from "../../../package.json";
/**
* Inputs required to assemble an `AgentRuntimeConfig`. Distinct from
@@ -96,6 +97,10 @@ export function createAgentRuntimeConfig(
const toolExecution = resolveToolExecution(agentConfig.maxParallelToolCalls);
const config: AgentRuntimeConfig = {
distinctId: agentConfig.distinctId,
clientName: agentConfig.extensionContext?.client?.name,
clientVersion: agentConfig.extensionContext?.client?.version,
clineCoreVersion,
sessionId: input.sessionId ?? agentConfig.sessionId,
agentId: input.agentId,
conversationId: input.conversationId,
@@ -264,6 +264,7 @@ export class LocalRuntimeHost implements RuntimeHost {
private readonly providerSettingsManager: ProviderSettingsManager;
private readonly oauthTokenManager: RuntimeOAuthTokenManager;
private readonly defaultTelemetry?: ITelemetryService;
private readonly distinctId: string;
private readonly defaultLogger?: BasicLogger;
private readonly defaultFetch?: typeof fetch;
private readonly events = new RuntimeHostEventBus();
@@ -286,6 +287,7 @@ export class LocalRuntimeHost implements RuntimeHost {
const homeDir = homedir();
if (homeDir) setHomeDirIfUnset(homeDir);
const distinctId = resolveCoreDistinctId(options.distinctId);
this.distinctId = distinctId;
this.sessionService = options.sessionService;
this.runtimeBuilder = options.runtimeBuilder ?? new DefaultRuntimeBuilder();
this.createAgentInstance =
@@ -612,6 +614,7 @@ export class LocalRuntimeHost implements RuntimeHost {
if (!resumedArtifacts) manifest.metadata = initialSessionMetadata;
const runtime = await this.runtimeBuilder.build({
...bootstrap.runtimeBuilderInput,
distinctId: this.distinctId,
runCommandExecutionController: this.runCommandExecutionController,
});
const configWithProvider = bootstrap.config;
@@ -712,6 +715,7 @@ export class LocalRuntimeHost implements RuntimeHost {
});
const agentConfig = {
distinctId: this.distinctId,
sessionId,
providerId: providerConfig.providerId,
modelId: providerConfig.modelId,
@@ -539,6 +539,8 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
const delegatedAgentConfigProvider = createDelegatedAgentConfigProvider({
providerId: config.providerId,
modelId: config.modelId,
distinctId: input.distinctId,
sessionId: config.sessionId,
cwd: config.cwd,
apiKey: config.apiKey ?? "",
baseUrl: config.baseUrl,
@@ -56,6 +56,11 @@ export interface BuiltRuntime {
export interface RuntimeBuilderInput {
config: CoreSessionConfig;
/**
* Host-resolved stable end-user identity, forwarded so delegated agents
* (sub-agents / teammates) emit the same telemetry `userId` as the lead.
*/
distinctId?: string;
hooks?: AgentHooks;
extensions?: AgentConfig["extensions"];
onTeamEvent?: (event: TeamEvent) => void;
@@ -474,6 +474,82 @@ describe("prepareLocalRuntimeBootstrap", () => {
});
});
it("rebuilds extensionContext.client from hub-baked request headers", async () => {
const { prepareLocalRuntimeBootstrap } = await import(
"./local-runtime-bootstrap"
);
const input = createStartInput();
const config = input.config as typeof input.config & {
headers: Record<string, string>;
};
config.headers = {
"X-CLIENT-TYPE": "cline-cli",
"X-CLIENT-VERSION": "3.0.38",
};
const bootstrap = await prepareLocalRuntimeBootstrap({
input,
sessionId: "sess-hub-client",
providerSettingsManager: createProviderSettingsManager() as never,
defaultTelemetry: undefined,
defaultToolPolicies: undefined,
onPluginEvent: () => {},
onTeamEvent: () => {},
createSpawnTool,
readSessionMetadata: async () => undefined,
writeSessionMetadata: async () => {},
});
expect(bootstrap.config.extensionContext?.client).toEqual({
name: "cline-cli",
version: "3.0.38",
});
expect(bootstrap.providerConfig.headers).toMatchObject({
"User-Agent": "Cline/3.0.38",
"X-CLIENT-TYPE": "cline-cli",
"X-CLIENT-VERSION": "3.0.38",
});
});
it("prefers configured extensionContext.client over header-derived identity", async () => {
const { prepareLocalRuntimeBootstrap } = await import(
"./local-runtime-bootstrap"
);
const input = createStartInput();
const config = input.config as typeof input.config & {
headers: Record<string, string>;
};
config.headers = {
"X-CLIENT-TYPE": "header-client",
"X-CLIENT-VERSION": "0.0.1",
};
const bootstrap = await prepareLocalRuntimeBootstrap({
input,
localRuntime: {
extensionContext: {
client: { name: "cline-vscode", version: "9.9.9" },
},
},
sessionId: "sess-local-client",
providerSettingsManager: createProviderSettingsManager() as never,
defaultTelemetry: undefined,
defaultToolPolicies: undefined,
onPluginEvent: () => {},
onTeamEvent: () => {},
createSpawnTool,
readSessionMetadata: async () => undefined,
writeSessionMetadata: async () => {},
});
expect(bootstrap.config.extensionContext?.client).toEqual({
name: "cline-vscode",
version: "9.9.9",
});
});
it("uses host request headers for Cline providers on core sessions", async () => {
const { prepareLocalRuntimeBootstrap } = await import(
"./local-runtime-bootstrap"
@@ -5,6 +5,7 @@ import type {
AgentHooks,
AgentTool,
BasicLogger,
ClientContext,
ExtensionContext,
ITelemetryService,
RuntimeConfigExtensionKind,
@@ -93,6 +94,25 @@ function logPluginDiagnostics(
}
}
/**
* Recover client identity from the Cline request headers baked into the
* session config. Hub-backed sessions do not transport `extensionContext`
* (it is local-only), but the hub client resolves `X-CLIENT-TYPE` /
* `X-CLIENT-VERSION` headers before `session.create`, so the daemon can
* rebuild `extensionContext.client` from them and keep trace metadata
* (Langfuse `clientName` / `clientVersion`) consistent with local runtimes.
*/
function resolveClientContextFromHeaders(
headers: Record<string, string> | undefined,
): ClientContext | undefined {
const name = headers?.["X-CLIENT-TYPE"]?.trim();
if (!name) {
return undefined;
}
const version = headers?.["X-CLIENT-VERSION"]?.trim();
return { name, ...(version ? { version } : {}) };
}
function resolveReasoningSettings(
config: CoreSessionConfig,
storedReasoning: ProviderSettings["reasoning"],
@@ -287,8 +307,12 @@ export async function prepareLocalRuntimeBootstrap(
initError,
} = await buildWorkspaceMetadataWithInfo(workspacePath);
const configuredExtensionContext = localConfig?.extensionContext;
const headerClientContext = configuredExtensionContext?.client
? undefined
: resolveClientContextFromHeaders(input.config.headers);
const extensionContext: ExtensionContext = {
...(configuredExtensionContext ?? {}),
...(headerClientContext ? { client: headerClientContext } : {}),
workspace: {
...workspaceInfo,
...(configuredExtensionContext?.workspace ?? {}),
@@ -325,6 +325,7 @@ function toStoredModelInfo(
modelId: string,
model: StoredModelEntry | undefined,
fallbackCapabilities?: ModelInfo["capabilities"],
capabilitiesAreAuthoritative = false,
): ModelInfo {
const capabilities = new Set<ModelCapability>(
model?.capabilities ?? fallbackCapabilities ?? [],
@@ -341,6 +342,24 @@ function toStoredModelInfo(
if (model.supportsReasoning) capabilities.add("reasoning");
else capabilities.delete("reasoning");
}
// An unspecified capability list fails open for tool calling
// (modelSupportsToolCalling), but any populated list is treated as
// authoritative — a partial one without "tools" silently revokes every
// tool definition (#13463). Stored entries and user-authored provider
// metadata cannot declare "cannot call tools" (there is no supportsTools
// field, and no writer intentionally omits "tools"): their lists are
// partial overlays, not authoritative catalogs. Seed "tools" into any
// non-empty list for a language model unless the list is anchored on
// generated catalog metadata, which IS authoritative (a genuine no-tools
// catalog model must stay that way). An empty set stays absent so every
// gate keeps its own fail-open default.
if (
!capabilitiesAreAuthoritative &&
capabilities.size > 0 &&
(model?.operation === undefined || model.operation === "language")
) {
capabilities.add("tools");
}
const apiFormat = model?.apiFormat;
const hasPricing =
@@ -395,15 +414,35 @@ function registerCustomModels(
providerId: string,
models: StoredProviderEntry["models"] | undefined,
): void {
const generatedModels = getGeneratedModelsForProvider(providerId);
for (const [modelKey, model] of Object.entries(models ?? {})) {
const modelId = model.id?.trim() || modelKey.trim();
if (!modelId) {
continue;
}
const generatedCapabilities = generatedModels[modelId]?.capabilities;
const storedModel =
generatedCapabilities && model.capabilities
? {
...model,
// Stored capability lists are additive overrides for catalog
// models. Preserve generated capabilities such as "tools"
// when loading metadata written by older clients that only
// persisted their boolean projections.
capabilities: [
...new Set([...generatedCapabilities, ...model.capabilities]),
],
}
: model;
LlmsModels.registerModel(
providerId,
modelId,
toStoredModelInfo(modelId, model),
toStoredModelInfo(
modelId,
storedModel,
generatedCapabilities,
generatedCapabilities !== undefined,
),
);
}
}
@@ -186,6 +186,108 @@ describe("models registry parsing", () => {
expect(model).not.toHaveProperty("temperature");
});
it("seeds tool calling when capabilities are synthesized purely from boolean flags", async () => {
const parsed = parseModelsFile({
version: 1,
providers: {
"boolean-only-provider": {
provider: {
name: "Boolean Only Provider",
baseUrl: "https://boolean-only.example.invalid/v1",
},
models: {
// No explicit capabilities list: the entry only carries the
// boolean convenience flag. The synthesized list must include
// "tools", otherwise a non-empty list without it reads as an
// authoritative denial to modelSupportsToolCalling (#13463).
reasoner: {
contextWindow: 16000,
supportsReasoning: true,
},
// No flags at all: the capability list must stay absent so the
// runtime keeps its fail-open behavior.
bare: {
contextWindow: 16000,
},
// Explicit partial list on a non-catalog model: nothing can
// author a "no tools" stored entry (the VS Code legacy
// migration writes exactly this shape), so "tools" must be
// seeded here too.
"partial-list": {
capabilities: ["prompt-cache"],
},
// Non-language models must not gain a tools claim.
"image-gen": {
operation: "image-generation",
capabilities: ["images"],
},
},
},
},
});
const entry = parsed.providers["boolean-only-provider"];
if (!entry) {
throw new Error("expected boolean-only provider entry");
}
registerCustomProvider("boolean-only-provider", entry);
const models = await LlmsModels.getModelsForProvider(
"boolean-only-provider",
);
expect(models.reasoner?.capabilities).toEqual(
expect.arrayContaining(["reasoning", "tools"]),
);
expect(models.bare).not.toHaveProperty("capabilities");
expect(models["partial-list"]?.capabilities).toEqual(
expect.arrayContaining(["prompt-cache", "tools"]),
);
expect(models["image-gen"]?.capabilities).not.toContain("tools");
});
it("keeps generated tool support when stale OpenCode Go metadata shadows a catalog model", async () => {
const generatedModel =
LlmsModels.getGeneratedModelsForProvider("opencode-go")["glm-5.3"];
expect(generatedModel?.capabilities).toContain("tools");
const parsed = parseModelsFile({
version: 1,
providers: {
"opencode-go": {
models: {
"glm-5.3": {
// Older clients persisted only capability projections they
// understood. Once v4.1.11 began gating tools, this partial
// list shadowed the catalog's "tools" capability and disabled
// every edit/read tool for the model.
capabilities: ["reasoning", "prompt-cache"],
},
},
},
},
});
const entry = parsed.providers["opencode-go"];
if (!entry) {
throw new Error("expected OpenCode Go provider entry");
}
registerCustomProvider("opencode-go", entry);
const model = (await LlmsModels.getModelsForProvider("opencode-go"))[
"glm-5.3"
];
expect(model?.capabilities).toEqual(
expect.arrayContaining([
"tools",
"reasoning",
"prompt-cache",
"structured_output",
]),
);
});
it("skips malformed provider entries while preserving valid providers", () => {
expect(
parseModelsFile({
+6 -2
View File
@@ -9,8 +9,12 @@ export default defineConfig({
// pure unit tests. Windows hosted runners regularly exceed Vitest's 5s
// default while starting those processes, so retain a bounded but realistic
// budget and reduce Windows CI contention.
testTimeout: 10_000,
hookTimeout: 15_000,
// windows-latest runners are 2-core and spawn forks slowly; the hub
// suites additionally start real servers and take SQLite locks. These
// budgets guard against hangs, they are not timing assertions, so give
// them room rather than failing publishes on runner speed.
testTimeout: 20_000,
hookTimeout: 25_000,
pool: "forks",
...(process.env.CI && process.platform === "win32"
? {
+9
View File
@@ -6,5 +6,14 @@ export default defineConfig({
include: ["src/**/*.e2e.test.ts"],
testTimeout: 30_000,
hookTimeout: 30_000,
// These e2e files spawn real daemon processes and assert on wall-clock
// budgets (discovery within 10s, exit within 5s, a 2s shutdown
// watchdog). Run them one file at a time: in parallel they compete for
// the runner's cores, and on a 2-core Windows runner the contention
// alone blew those budgets — singleton.e2e.test.ts holds daemons up for
// ~15s, which is long enough to starve shutdown.e2e.test.ts into either
// missing discovery or being forced to exit before its HTTP response
// flushed.
fileParallelism: false,
},
});
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/llms",
"version": "0.0.77",
"version": "0.0.78",
"description": "Config-driven SDK for selecting, extending, and instantiating LLM providers and models",
"repository": {
"type": "git",
@@ -64,6 +64,7 @@
"@aws-sdk/credential-providers": "^3.922.0",
"@cline/shared": "workspace:*",
"@jerome-benoit/sap-ai-provider": "4.8.0",
"@langfuse/core": "5.10.1",
"@langfuse/otel": "5.10.1",
"@langfuse/vercel-ai-sdk": "5.9.1",
"@openrouter/ai-sdk-provider": "^3",
File diff suppressed because it is too large Load Diff
+125 -64
View File
@@ -599,6 +599,50 @@ async function ensureGatewayLangfuseTelemetry(
}
}
async function withAiSdkLangfuseTraceContext<T>(
enabled: boolean,
request: GatewayStreamRequest,
callback: () => T | Promise<T>,
): Promise<T> {
const metadata =
request.metadata && typeof request.metadata === "object"
? request.metadata
: {};
const tags = Array.isArray(metadata.tags)
? metadata.tags.filter(
(value): value is string =>
typeof value === "string" && value.trim().length > 0,
)
: undefined;
const distinctId =
typeof metadata.distinctId === "string" ? metadata.distinctId : undefined;
const sessionId =
typeof metadata.sessionId === "string" ? metadata.sessionId : undefined;
if (!enabled || (!distinctId && !sessionId && !tags?.length)) {
return await callback();
}
const runtime = await import("../services/langfuse-telemetry");
return await runtime.withLangfuseTraceAttributes(
true,
{
...(distinctId ? { userId: distinctId } : {}),
...(sessionId ? { sessionId } : {}),
...(tags?.length ? { tags } : {}),
metadata: {
...(typeof metadata.conversationId === "string"
? { conversationId: metadata.conversationId }
: {}),
...(typeof metadata.runId === "string"
? { runId: metadata.runId }
: {}),
},
},
callback,
);
}
function buildAiSdkRuntimeContext(
request: GatewayStreamRequest,
context: GatewayProviderContext,
@@ -625,6 +669,15 @@ function buildAiSdkRuntimeContext(
...(typeof metadata.sessionId === "string"
? { sessionId: metadata.sessionId }
: {}),
...(typeof metadata.clientName === "string"
? { clientName: metadata.clientName }
: {}),
...(typeof metadata.clientVersion === "string"
? { clientVersion: metadata.clientVersion }
: {}),
...(typeof metadata.clineCoreVersion === "string"
? { clineCoreVersion: metadata.clineCoreVersion }
: {}),
...(tags && tags.length > 0 ? { tags } : {}),
// Keep Cline correlation fields available even when the integration
// does not promote them to first-class Langfuse fields.
@@ -2152,71 +2205,79 @@ function createAiSdkProvider(kind: ProviderModuleKind): GatewayProviderFactory {
...(portableReasoning ? { reasoning: portableReasoning } : {}),
},
});
stream = streamText({
model: withEmptyResponseRetry(
provider.operations.language(context.model.id),
provider.retryEmptyResponses,
context.logger,
) as never,
messages: messages as never,
...(useSystemOption ? { system: systemPrompt } : {}),
...(tools ? { tools } : {}),
abortSignal: request.signal,
experimental_repairToolCall: repairMalformedToolCall as never,
experimental_telemetry: {
isEnabled: langfuse,
functionId: "cline-agent-turn",
includeRuntimeContext: {
distinctId: true,
userId: true,
sessionId: true,
tags: true,
conversationId: true,
runId: true,
iteration: true,
providerId: true,
modelId: true,
resolvedModelId: true,
},
},
runtimeContext: buildAiSdkRuntimeContext(request, context),
providerOptions: providerOptions as never,
...(provider.executesModelTools && activeModelTools.length
? { stopWhen: stepCountIs(8) }
: {}),
...requestConfig,
...(portableReasoning ? { reasoning: portableReasoning } : {}),
onError: ({ error: streamError }) => {
const captured = captureStreamError(streamError);
const msg = captured.message;
capturedError.current = captured;
if (log?.error) {
log.error("[ai-sdk] stream error", {
providerId: request.providerId,
error: streamError,
severity: "error",
});
} else if (log) {
log.log(`[ai-sdk] stream error: ${msg}`, {
providerId: request.providerId,
severity: "error",
});
}
captured.reported = captureSdkError(context.telemetry, {
component: "llms",
operation: "provider.stream",
error: streamError,
errorMessage: msg,
severity: "error",
handled: true,
context: {
providerId: request.providerId,
modelId: request.modelId,
providerKind: kind,
stream = await withAiSdkLangfuseTraceContext(
langfuse,
request,
() =>
streamText({
model: withEmptyResponseRetry(
provider.operations.language(context.model.id),
provider.retryEmptyResponses,
context.logger,
) as never,
messages: messages as never,
...(useSystemOption ? { system: systemPrompt } : {}),
...(tools ? { tools } : {}),
abortSignal: request.signal,
experimental_repairToolCall: repairMalformedToolCall as never,
experimental_telemetry: {
isEnabled: langfuse,
functionId: "cline-agent-turn",
includeRuntimeContext: {
distinctId: true,
userId: true,
sessionId: true,
clientName: true,
clientVersion: true,
clineCoreVersion: true,
tags: true,
conversationId: true,
runId: true,
iteration: true,
providerId: true,
modelId: true,
resolvedModelId: true,
},
},
});
},
}) as unknown as AiSdkStreamResult;
runtimeContext: buildAiSdkRuntimeContext(request, context),
providerOptions: providerOptions as never,
...(provider.executesModelTools && activeModelTools.length
? { stopWhen: stepCountIs(8) }
: {}),
...requestConfig,
...(portableReasoning ? { reasoning: portableReasoning } : {}),
onError: ({ error: streamError }) => {
const captured = captureStreamError(streamError);
const msg = captured.message;
capturedError.current = captured;
if (log?.error) {
log.error("[ai-sdk] stream error", {
providerId: request.providerId,
error: streamError,
severity: "error",
});
} else if (log) {
log.log(`[ai-sdk] stream error: ${msg}`, {
providerId: request.providerId,
severity: "error",
});
}
captured.reported = captureSdkError(context.telemetry, {
component: "llms",
operation: "provider.stream",
error: streamError,
errorMessage: msg,
severity: "error",
handled: true,
context: {
providerId: request.providerId,
modelId: request.modelId,
providerKind: kind,
},
});
},
}) as unknown as AiSdkStreamResult,
);
// Suppress dangling promise rejections (finishReason, totalUsage, steps, etc.)
// BEFORE iterating. The AI SDK rejects these DelayedPromises inside the stream's
@@ -499,7 +499,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "crof",
defaultModelId: "deepseek-v4-flash-0731",
defaultModelId: "qwen3.8-27b",
apiKeyEnv: ["CROF_API_KEY"],
docsUrl: "https://crof.ai/docs",
defaults: {
@@ -513,7 +513,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "crossmodel",
defaultModelId: "z-ai/glm-5.3",
defaultModelId: "deepseek/deepseek-v4-flash-vision-exp",
apiKeyEnv: ["CROSSMODEL_API_KEY"],
docsUrl: "https://www.crossmodel.ai/docs",
defaults: {
@@ -570,7 +570,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "deepseek",
defaultModelId: "deepseek-v4-pro",
defaultModelId: "deepseek-v4-flash-vision-exp",
apiKeyEnv: ["DEEPSEEK_API_KEY"],
docsUrl: "https://api-docs.deepseek.com/quick_start/pricing",
defaults: {
@@ -655,7 +655,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "edenai",
defaultModelId: "qwen/qwen3.8-27b",
defaultModelId: "deepseek/deepseek-v4-flash-vision-exp",
apiKeyEnv: ["EDENAI_API_KEY"],
docsUrl: "https://docs.edenai.co",
defaults: {
@@ -1064,7 +1064,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "kilo",
defaultModelId: "stealth/ox-alpha",
defaultModelId: "deepseek/deepseek-v4-flash-vision-exp",
apiKeyEnv: ["KILO_API_KEY"],
docsUrl: "https://kilo.ai",
defaults: {
@@ -1468,9 +1468,9 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
name: "NanoGPT",
description: "NanoGPT model provider from models.dev",
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
capabilities: ["tools", "prompt-cache", "reasoning"],
modelsProviderId: "nano-gpt",
defaultModelId: "ornith-ai/ornith-1.5-35b-a3b",
defaultModelId: "google/gemma-4-26b-a4b-uncensored",
apiKeyEnv: ["NANO_GPT_API_KEY"],
docsUrl: "https://docs.nano-gpt.com",
defaults: {
@@ -1582,7 +1582,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "ofox",
defaultModelId: "z-ai/glm-5.3",
defaultModelId: "deepseek/deepseek-v4-flash-vision-exp",
apiKeyEnv: ["OFOX_API_KEY"],
docsUrl: "https://ofox.ai/docs",
defaults: {
@@ -1635,7 +1635,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "opencode-go",
defaultModelId: "glm-5.3",
defaultModelId: "deepseek-v4-flash-vision-exp",
apiKeyEnv: ["OPENCODE_API_KEY"],
docsUrl: "https://opencode.ai/docs/zen",
defaults: {
@@ -1649,7 +1649,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "openrouter",
defaultModelId: "stealth/ox-alpha",
defaultModelId: "deepseek/deepseek-v4-flash-vision-exp",
apiKeyEnv: ["OPENROUTER_API_KEY"],
docsUrl: "https://openrouter.ai/models",
defaults: {
@@ -1883,9 +1883,9 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
name: "Scaleway",
description: "Scaleway model provider from models.dev",
family: "openai-compatible",
capabilities: ["tools", "reasoning"],
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "scaleway",
defaultModelId: "glm-5.2",
defaultModelId: "deepseek-v4-flash-0731",
apiKeyEnv: ["SCALEWAY_API_KEY"],
docsUrl: "https://www.scaleway.com/en/docs/generative-apis/",
defaults: {
@@ -2280,7 +2280,7 @@ export const GENERATED_PROVIDER_SPECS: readonly BuiltinSpec[] = [
family: "openai-compatible",
capabilities: ["tools", "reasoning", "prompt-cache"],
modelsProviderId: "vercel-ai-gateway",
defaultModelId: "alibaba/qwen3.8-27b",
defaultModelId: "deepseek/deepseek-v4-flash-vision-exp",
apiKeyEnv: ["AI_GATEWAY_API_KEY"],
docsUrl:
"https://github.com/vercel/ai/tree/5eb85cc45a259553501f535b8ac79a77d0e79223/packages/gateway",
@@ -11,6 +11,32 @@ type LangfuseTelemetryConfig = {
secretKey: string;
};
export type LangfuseTraceAttributes = {
userId?: string;
sessionId?: string;
tags?: string[];
metadata?: Record<string, string>;
traceName?: string;
};
/**
* Set Langfuse trace-level attributes for the duration of an SDK operation.
* Runtime context is useful observation metadata, but Langfuse's Sessions and
* Users views are indexed from propagated trace attributes instead.
*/
export async function withLangfuseTraceAttributes<T>(
enabled: boolean,
attributes: LangfuseTraceAttributes,
callback: () => T | Promise<T>,
): Promise<T> {
if (!enabled) {
return await callback();
}
const { propagateAttributes } = await import("@langfuse/core");
return await propagateAttributes(attributes, callback);
}
const LANGFUSE_DEBUG_ENV = "CLINE_DEBUG_LANGFUSE";
let langfuseTelemetryReady: boolean | undefined;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/sdk",
"description": "Cline SDK - user-facing alias for @cline/core",
"version": "0.0.77",
"version": "0.0.78",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/shared",
"version": "0.0.77",
"version": "0.0.78",
"description": "Shared utilities, types, and schemas for Cline packages",
"repository": {
"type": "git",
+6
View File
@@ -481,6 +481,12 @@ export interface AgentRuntimeConfig {
* This is intentionally separate from the host-owned session id.
*/
distinctId?: string;
/** Calling client surface, for example `cline-vscode` or `cline-sdk`. */
clientName?: string;
/** Calling client version, such as the VS Code extension version. */
clientVersion?: string;
/** Version of the Cline Core SDK executing the runtime. */
clineCoreVersion?: string;
/**
* Core/hub runtime session identifier.
*
+3
View File
@@ -684,6 +684,8 @@ export const AgentResultSchema = z.object({
* Configuration for creating an Agent
*/
export interface AgentConfig {
/** Stable end-user identity used for provider and observability metadata. */
distinctId?: string;
/**
* Core/hub runtime session identifier.
*
@@ -911,6 +913,7 @@ export interface AgentConfig {
}
export const AgentConfigSchema = z.object({
distinctId: z.string().optional(),
sessionId: z.string().optional(),
// Provider Settings
providerId: z.string(),
+4 -1
View File
@@ -4,7 +4,10 @@ import { dirname } from "node:path";
import { getErrorCode, getErrorMessage } from "../parse/error";
export type SqliteStatement = {
run: (...params: unknown[]) => { changes?: number };
run: (...params: unknown[]) => {
changes?: number;
lastInsertRowid?: number | bigint;
};
get: (...params: unknown[]) => Record<string, unknown> | null;
all: (...params: unknown[]) => Record<string, unknown>[];
};
+36 -2
View File
@@ -40,7 +40,12 @@ export type HubCapabilityName =
| "settings.set"
| "connector.start"
| "connector.stop"
| "connector.supervised";
| "connector.supervised"
| "run.enqueue"
| "run.list"
| "hub.drain"
| "hub.status"
| "stream.replay";
export const HUB_CAPABILITIES: readonly HubCapabilityName[] = [
"client.register",
@@ -66,6 +71,11 @@ export const HUB_CAPABILITIES: readonly HubCapabilityName[] = [
"connector.start",
"connector.stop",
"connector.supervised",
"run.enqueue",
"run.list",
"hub.drain",
"hub.status",
"stream.replay",
];
export interface HubProtocolMetadata {
@@ -518,8 +528,12 @@ export type HubCommandName =
| "session.hook"
| "run.start"
| "session.send_input"
| "run.enqueue"
| "run.list"
| "run.abort"
| "run.proceed_while_running"
| "hub.drain"
| "hub.status"
| "approval.request"
| "approval.respond"
| "capability.request"
@@ -628,6 +642,9 @@ export type HubEventName =
| "run.aborted"
| "run.completed"
| "run.failed"
| "run.enqueued"
| "run.interrupted"
| "hub.drain_changed"
| "iteration.started"
| "iteration.finished"
| "assistant.delta"
@@ -676,6 +693,13 @@ export interface HubEventEnvelope {
version: HubProtocolVersion;
event: HubEventName;
eventId?: string;
/**
* Monotonic global sequence assigned by the Hub's durable event log.
* A client can resume delivery exactly where it left off by passing the
* last observed sequence as `sinceSequence` on `stream.subscribe`.
* Absent on hubs (or events) without a durable log.
*/
sequence?: number;
sessionId?: string;
clientId?: string;
sourceHubId?: string;
@@ -929,7 +953,17 @@ export interface HubStateSnapshot {
export type HubTransportFrame =
| { kind: "command"; envelope: HubCommandEnvelope }
| { kind: "reply"; envelope: HubReplyEnvelope }
| { kind: "stream.subscribe"; clientId: string; sessionId?: string }
| {
kind: "stream.subscribe";
clientId: string;
sessionId?: string;
/**
* Replay cursor: when set, the Hub first replays durable events with
* `sequence > sinceSequence` (scoped to `sessionId` when given), then
* live-tails. Omit for live-only delivery (legacy behavior).
*/
sinceSequence?: number;
}
| { kind: "stream.unsubscribe"; clientId: string; sessionId?: string }
| { kind: "event"; envelope: HubEventEnvelope };
+4
View File
@@ -4,6 +4,10 @@ export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
// SQLite-backed tests routinely exceed vitest's 5s default on the
// 2-core windows-latest runner. A hang guard, not a timing assertion.
testTimeout: 15_000,
hookTimeout: 15_000,
exclude: ["src/**/*.e2e.test.ts"],
},
});