Compare commits

..

88 Commits

Author SHA1 Message Date
Saoud Rizwan b43fb7777c Merge PR #13069 head (cloud models, prompt dedupe, org scope fixes) into desktop-experimental
# Conflicts:
#	apps/examples/desktop-app/sidecar/chat-session.ts
2026-08-17 18:27:06 -07:00
John Choi 8ea71e3278 fix(desktop): align cloud header status 2026-08-17 18:23:38 -07:00
John Choi f4abb7e14b feat(desktop): load Cline Cloud agent models 2026-08-17 18:16:56 -07:00
John Choi 1e08d8f277 fix(desktop): refresh cloud organization scope 2026-08-17 18:16:49 -07:00
John Choi 8bebcd1f37 Merge remote-tracking branch 'origin/main' into work/pr-13069-four-fixes
# Conflicts:
#	apps/examples/desktop-app/webview/components/agent-sidebar.tsx
2026-08-17 18:07:11 -07:00
John Choi 14f9def7ed fix(desktop): reconcile terminal cloud replies 2026-08-17 17:56:42 -07:00
John Choi 2e6c25785e fix(desktop): dedupe live cloud prompts 2026-08-17 17:41:06 -07:00
Saoud Rizwan b565e5b9ca Merge PR #13069 head (cloud provisioning/handoff fixes) into desktop-experimental
# Conflicts:
#	apps/examples/desktop-app/webview/hooks/use-chat-session.ts
2026-08-17 17:36:14 -07:00
Saoud Rizwan 0313c34b1a chore(desktop): release v0.0.14-beta.1 2026-08-17 17:29:42 -07:00
Saoud Rizwan d0f49330a3 Merge remote-tracking branch 'origin/main' into desktop-experimental
# Conflicts:
#	apps/examples/desktop-app/webview/components/agent-sidebar.tsx
#	apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx
2026-08-17 17:27:25 -07:00
John Choi db31380268 Merge remote-tracking branch 'origin/main' into work/pr-13069-four-fixes 2026-08-17 17:18:21 -07:00
Saoud Rizwan b123f2eb54 Merge remote-tracking branch 'origin/main' into desktop-experimental 2026-08-17 16:21:56 -07:00
John Choi 303722a559 fix(desktop): show prompt during cloud provisioning 2026-08-17 14:34:43 -07:00
John Choi 5fff9cbb4c fix(desktop): preserve prompt during cloud handoff 2026-08-17 12:30:16 -07:00
John Choi 70e0a3731d fix(desktop): harden cloud provisioning recovery 2026-08-17 12:18:18 -07:00
John Choi d52af3112d Merge remote-tracking branch 'origin/pr/13069' into feat/experimental-desktop-pr-bundle
# Conflicts:
#	apps/examples/desktop-app/webview/app/page.tsx
2026-08-14 19:21:03 -07:00
John Choi eccb0db669 fix(desktop): recover cloud provisioning handoff 2026-08-14 19:17:50 -07:00
John Choi a9af97a83f fix(desktop): preserve cloud session routing with SSH environments 2026-08-14 19:16:41 -07:00
John Choi 53388f5f9c Merge remote-tracking branch 'origin/pr/13069' into feat/experimental-desktop-pr-bundle
# Conflicts:
#	apps/examples/desktop-app/sidecar/cloud-sessions.test.ts
#	apps/examples/desktop-app/sidecar/commands.ts
#	apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx
#	apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx
#	apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx
#	apps/examples/desktop-app/webview/hooks/use-chat-session.ts
2026-08-14 19:00:05 -07:00
John Choi 093e9dd3d5 fix(desktop): harden cloud session creation 2026-08-14 18:54:31 -07:00
John Choi d35f4a043b fix: reconcile experimental desktop feature stack 2026-08-14 18:34:07 -07:00
John Choi bd9ee23f6c Merge origin/main into desktop cloud sessions 2026-08-14 18:32:55 -07:00
John Choi 10ff456e93 Merge remote-tracking branch 'origin/pr/13225' into feat/experimental-desktop-pr-bundle 2026-08-14 17:58:48 -07:00
John Choi cb34ba5cf9 Merge remote-tracking branch 'origin/pr/13031' into feat/experimental-desktop-pr-bundle 2026-08-14 17:58:44 -07:00
John Choi 2ed4b811be Merge remote-tracking branch 'origin/pr/12890' into feat/experimental-desktop-pr-bundle 2026-08-14 17:53:04 -07:00
John Choi 7bf542ddab Merge remote-tracking branch 'origin/pr/13069' into feat/experimental-desktop-pr-bundle
# Conflicts:
#	apps/examples/desktop-app/sidecar/commands.ts
#	apps/examples/desktop-app/sidecar/session-data/messages.test.ts
#	apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx
#	apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx
#	apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx
#	apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx
#	apps/examples/desktop-app/webview/hooks/use-chat-session.ts
2026-08-14 17:43:00 -07:00
abeatrix 1cb61ab925 Merge remote-tracking branch 'origin/main' into bee/agent-voice
# Conflicts:
#	apps/examples/desktop-app/package.json
#	apps/examples/desktop-app/sidecar/commands.ts
#	apps/examples/desktop-app/webview/app/globals.css
#	apps/examples/desktop-app/webview/app/page.tsx
#	apps/examples/desktop-app/webview/components/agent-header.tsx
#	apps/examples/desktop-app/webview/components/agent-sidebar.tsx
#	apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx
#	apps/examples/desktop-app/webview/components/views/chat/chat-messages.test.tsx
#	apps/examples/desktop-app/webview/components/views/chat/chat-messages.tsx
#	apps/examples/desktop-app/webview/components/views/chat/welcome-chat.test.tsx
#	apps/examples/desktop-app/webview/components/views/chat/welcome-chat.tsx
#	apps/examples/desktop-app/webview/components/views/chat/workspace-selector.tsx
#	apps/examples/desktop-app/webview/components/views/settings/provider-list-view.tsx
#	apps/examples/desktop-app/webview/components/views/settings/settings-view.tsx
#	apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx
#	apps/examples/desktop-app/webview/hooks/use-chat-session.ts
#	apps/examples/desktop-app/webview/lib/desktop-tray.test.ts
#	apps/examples/desktop-app/webview/lib/desktop-tray.ts
#	apps/examples/desktop-app/webview/lib/session-history.ts
#	bun.lock
#	sdk/packages/llms/package.json
#	sdk/packages/llms/src/catalog/catalog.generated.ts
#	sdk/packages/llms/src/providers/providers.generated.ts
#	sdk/packages/shared/src/llms/ai-sdk-format.ts
#	sdk/packages/ui/package.json
2026-08-13 15:54:16 -07:00
John Choi 7bf495878e fix(desktop): harden cloud session lifecycle 2026-08-13 15:52:04 -07:00
Tomás Barreiro 677a5cd915 Merge branch 'main' into add-integrations-onboarding-step 2026-08-13 19:05:59 -03:00
Tomás Barreiro 4524e884a7 Merge branch 'main' into add-integrations-onboarding-step 2026-08-13 15:42:32 -03:00
BarreiroT 75d1d6654b validate domain and fix errors on auth 2026-08-13 14:56:45 -03:00
BarreiroT 45027ee4e3 Add a GitHub integration step to the onboarding 2026-08-13 14:33:14 -03:00
John Choi 7f8fd0b62d fix(desktop): harden cloud session synchronization 2026-08-13 08:16:53 -07:00
John Choi 6df53ddf64 Merge origin/main into desktop cloud sessions 2026-08-13 08:15:59 -07:00
John Choi 243b6fe9d9 Merge remote-tracking branch 'origin/main' into work/pr-13069-four-fixes
# Conflicts:
#	apps/examples/desktop-app/webview/app/page.tsx
#	apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.test.tsx
#	apps/examples/desktop-app/webview/components/views/chat/chat-input-bar.tsx
#	apps/examples/desktop-app/webview/components/views/chat/chat-messages.tsx
#	apps/examples/desktop-app/webview/components/views/chat/welcome-chat.tsx
#	apps/examples/desktop-app/webview/components/views/chat/welcome-workspace-controls.test.tsx
#	apps/examples/desktop-app/webview/components/views/chat/welcome-workspace-controls.tsx
#	apps/examples/desktop-app/webview/hooks/use-chat-session.test.tsx
#	apps/examples/desktop-app/webview/lib/workspace-paths.ts
2026-08-12 09:11:57 -07:00
John Choi edd6e2c29b fix(desktop): close cloud session interaction gaps
Forward image attachments, allow session-scoped model changes, recover queued streaming after abort, and surface relayed run failures.
2026-08-10 16:49:03 -07:00
Saoud Rizwan 1eb28e553e test(desktop): make the pagination-recovery test deterministic
Waiting on rendered text could pass in a stale window between the search
clearing and the base reload effect running, letting the scroll fire a
stale observer whose captured request key mismatched. Drop the captured
observer callback before clearing and wait for the effect to recreate it,
which only happens after the post-clear base list applied.
2026-08-08 18:58:56 +00:00
Saoud Rizwan 42225f9219 refactor(desktop): consolidate cloud-session helpers before review
No behavior changes; review-readiness cleanup of the cloud sessions diff:

- one cloudRepositoryLabel helper in webview/lib/cloud-repositories.ts
  replaces four copies of the owner/repo label parser (sidecar placeholder
  title, repository picker, composer context label, provisioning phases)
- the cloud-provisioning- placeholder id prefix moves behind
  isCloudProvisioningSessionId, shared by the sidecar that mints the ids
  and the webview affordance gates that check them
- the two identical cloud status mappers in use-chat-session collapse into
  mapCloudRuntimeStatus in chat-session/helpers.ts
- attach() and attachExpired() share one attachResultPayload builder
  instead of duplicating the reply literal
- settings-view drops the commented-out PostHog lookup block in favor of a
  short pointer comment
- welcome-chat derives its fallback connect URL from the environment
  config instead of hardcoding production
- refresh the stale claim-set comment in create-recovery to describe the
  post-fix wait-all semantics
2026-08-08 18:58:55 +00:00
Saoud Rizwan da759853ea test(desktop): wait out the post-clear branch reload before paginating again 2026-08-08 05:37:23 +00:00
Saoud Rizwan 6fed0f0d38 style(desktop): format new test fixtures 2026-08-08 05:34:04 +00:00
Saoud Rizwan b456ce73dd fix(desktop): webview polish for cloud sessions from deep review
- humanize cloud error envelopes in the sync-failed banner and the
  rehydration fetch fallback (and scope the fallback to the active session)
- align the recovered-send status mapper with the rehydrated handler:
  cover cancelled, and leave unknown statuses alone instead of flipping a
  running turn to completed
- disable Delete on provisioning placeholders in the sidebar and sessions
  view; the sidecar always rejects it until the create settles
- surface the CLINE_CODE_CLOUD_AGENTS override in Settings when it makes
  the toggle diverge from effective behavior, and load the settings
  sections concurrently
- gate the slash-command menu to local sessions like @-mentions; the
  sandbox cannot resolve local skills/workflows
- stop the model selector from silently 'correcting' a locked cloud
  session's model when its id is missing from the local catalog
2026-08-08 05:32:53 +00:00
Saoud Rizwan 2b9d1c3e6a fix(desktop): harden cloud session lifecycle edges
- reap connections whose session vanished from a successful list (deleted
  remotely or re-scoped): they otherwise redial the dead proxy every ~5s
  forever, with a REST list per attempt, until app restart
- clear desktop-visible state synchronously at the start of dispose() so
  a manager rebuilt mid-dispose (account/credential change) cannot have
  its fresh liveSessions/pendingApprovals entries deleted from under it
- report rehydrated failed runs with the same chat_session_ended reason
  (error) the live run.failed path uses
2026-08-08 05:32:53 +00:00
Saoud Rizwan 82277128aa fix(desktop): only honor Connect GitHub URLs from Cline app origins
The cloud error envelope travels in Error.message and is authenticated by
string prefix only, so error strings a session pod controls (hub command
replies pass through verbatim) could spoof a github_not_connected envelope
whose connectUrl pointed anywhere. The webview rendered that as a trusted
looking Connect GitHub button and open_external_url validates protocol,
not origin. Drop connectUrls whose origin is not a known Cline app base
URL before they reach the action button.
2026-08-08 05:26:59 +00:00
Saoud Rizwan ee4edfeaf6 fix(desktop): keep the cloud setup snapshot fresh across org switches
The stale-selection guard compared repoUrl against a repositoryUrls
snapshot refreshed only on mount, account-id change, focus, or the
onboarding poll (which stops in ready status). An in-app org switch
refreshed none of those, so picking a repository from the new scope's
correctly filtered picker got immediately wiped against the old scope's
list. Route the picker's own loads through the same request-id-guarded
snapshot application, and re-check setup on the sidecar's
cloud_sessions_changed broadcast.
2026-08-08 05:25:00 +00:00
Saoud Rizwan 17b0ad7a76 fix(desktop): close both directions of the create-recovery claim race
Recovery previously waited only for earlier identical peers, so an
earlier create failing fast (any request_failed, including an instant
5xx) could adopt a later in-flight POST's listed session and hand two
composers the same sandbox. Branchless and branch-specific creates also
hashed to different claim keys while the branchless recovery filter
ignores branch, allowing cross-key adoption with no ordering at all.

- key in-flight peers by repo/model/org (branch excluded) so
  branchless recoveries see branch-specific peers
- settle each create's peer entry when its POST settles (never after
  recovery), then make recovery wait for every other in-flight peer in
  both directions, re-snapshotting until stable; waits cannot cycle
- gate recovery on timeout/5xx/no-status failures: a fast 4xx never
  provisioned anything, and recovering on one risks adopting an
  identical-config session created by another device on the account
2026-08-08 05:21:55 +00:00
Saoud Rizwan be8bb302a8 fix(desktop): re-key the first cloud prompt's bubble to the server session id
A cloud create returns a server-assigned session id, but the optimistic
user bubble kept the client-planned id. mergeCloudSnapshotWithLive drops
other-session messages before consulting the optimistic map, so the first
prompt's bubble silently lost its retention semantics: a lagging snapshot
could merge to a transcript with no user prompt, and a failed first send
lost its bubble on the next rehydration.

Also pins the previously untested merge behaviors: the reflected-prompt
budget (zero-budget retention and one-consumption-per-new-copy) and
error-bubble preservation on the unmatched-live drop path.
2026-08-08 05:18:45 +00:00
Saoud Rizwan f4f573c395 fix(desktop): release the branch picker's loading flag when a page fetch goes stale
loadMore reset loadingMore only when the request key still matched. Typing
a search character while a page fetch was in flight changed the key, so
the stale fetch never released the flag and pagination was dead for the
rest of the welcome screen's life (the observer effect and loadMore both
short-circuit on loadingMore). Only one page fetch can be in flight, so
the reset can be unconditional.
2026-08-08 05:16:29 +00:00
Saoud Rizwan e03b4577b4 fix(sdk): close the hub client zombie-connection race around registration
The post-registration continuation was the one mutation window not guarded
by the connect generation: a close() landing after the register reply
resolved but before the continuation ran would mark a closed client
registered. That stale flag then made a later failed registration skip
closing its socket, leaving a permanently unregistered zombie connection
that isConnected() reported healthy.

- generation-guard the continuation so a superseded attempt closes its
  socket and rejects instead of touching shared state
- drop the registered-flag condition from the connect() catch guard; the
  socket identity check alone decides ownership and cannot be poisoned
- keep a stale attempt's late timeout/error/close handlers from clobbering
  lastCloseError and sawSocketClose for a newer attempt
- stop close() from wiping the real connect failure cause when no socket
  was ever opened
2026-08-08 05:15:03 +00:00
Saoud Rizwan 16d36e53f2 Merge branch 'main' into saoudrizwan/desktop-cloud-sessions-9a9b 2026-08-07 21:50:08 -07:00
Saoud Rizwan 74a1910505 Merge branch 'main' into saoudrizwan/desktop-cloud-sessions-9a9b 2026-08-07 20:50:44 -07:00
Saoud Rizwan f3db7a0a98 fix(desktop): order concurrent identical creates so recovery cannot steal an in-flight session
A session is listed the moment the server starts provisioning it, minutes
before its successful POST returns. Timeout recovery now waits for every
earlier identical in-flight create to record its claim before adopting a
listed candidate; later peers wait on earlier ones only, so waits cannot
cycle. Regression test covers the slow-success/fast-failure overlap.
2026-08-08 03:21:07 +00:00
Saoud Rizwan 2b62f7b447 fix(desktop): webview review fixes for cloud sessions
- Emit cloud_session_provisioning_failed from the sidecar and render a
  terminal error pane in an open placeholder thread instead of an
  infinite provisioning spinner.
- Cloud-aware delete confirmations in the sidebar and sessions view (the
  action destroys the remote workspace, not just local history).
- Humanize cloud rename failures instead of showing the raw envelope.
- Preserve UI error bubbles through cloud rehydration merges; ignore
  unknown snapshot statuses instead of flipping a running turn to done.
- Clear a stale repository selection when the account can no longer
  access it so the send gate re-engages.
- Migrate cloud optimistic bookkeeping across queued-prompt re-keys,
  clear cloud refs on reset, session-scope the cloud merge, fix the
  impure provisioning-phase updater, gate rename on provisioning
  placeholder rows, and stop advertising local-only mentions/commands in
  cloud composer placeholders.
2026-08-08 03:03:15 +00:00
Saoud Rizwan 6b8b5726c3 fix(desktop): cloud session lifecycle hardening from deep review
- Reap connections whose sandbox expired (attach, sidebar poll, and
  reconnect-failure paths) so dead sessions stop reconnect-looping and
  spamming sync-failure events; sync failures now notify on transition
  only.
- Tombstone sessions mid-delete so a concurrent attach/send cannot dial a
  fresh connection that outlives the delete; treat remotely-gone sessions
  (404/410) as deletable locally.
- Guard disposed connections against resurrection by late reconnect timers
  and approval responses; purge approvals stored during failed connection
  setup.
- Leave cloud approvals pending on app shutdown instead of denying tool
  calls on pods that outlive the app.
- Use a fresh auth token (with fallback) for create-timeout recovery;
  normalize list rows so one malformed record cannot crash discovery;
  widen the recovery clock-skew window now that claims prevent
  double-adoption.
- Drop the queue-shrink 'prompt started' inference on the hub path (the
  hub emits explicit submitted events; a shrink can also mean removal).
- Reset the transcript baseline on reconnect; answer pendingPrompts with
  [] for sessions with no inner session instead of throwing.
2026-08-08 02:55:37 +00:00
Saoud Rizwan f6d8e54089 fix(desktop): cross-cutting review fixes for cloud sessions
- Use core's canonical getProviderAuthHandler("cline") for the persisted
  token fallback instead of a hand-rolled prefix heuristic that could
  corrupt unprefixed API keys; drop the dead test-only reset export.
- Reset the cloud session manager and broadcast cloud_sessions_changed
  after a cline OAuth login, and broadcast on the save_provider_settings
  (sign-out) reset, so the sidebar re-scopes immediately.
- Log cloud discovery failures instead of silently emptying the sidebar.
- Atomic write-then-rename for the desktop settings file.
- Share the repository/branch wire types between sidecar and webview.
- Add command-layer tests for the settings/flag commands; refresh the
  stale sidecar ARCHITECTURE.md; delete an orphaned comment.
2026-08-08 02:47:00 +00:00
Saoud Rizwan ef09caa3e6 fix(sdk,desktop): harden hub header resolution and onboarding poll
Review findings: bound resolveConnectionHeaders with the connect timeout so
a hung token refresh cannot pin connect() and every deduped caller forever;
record resolver failures in lastCloseError so getConnectionError() reports
the real cause; add a connect-generation token so close() during header
resolution cannot leave a doomed attempt satisfying the next connect();
stop header-auth clients from inheriting registry tokens for loopback URLs;
use the shared extractSessionId in approval.list_pending. Desktop: make the
onboarding poll read status from a ref instead of running side effects in a
state updater, and re-check GitHub connectivity when the account changes.
2026-08-08 02:42:20 +00:00
Saoud Rizwan bdb5f30f5e refactor(desktop,sdk): remove the PostHog flag plumbing cloud sessions no longer use
Reverts the SDK feature-flags service/provider changes and barrel exports to
main, and strips the desktop sidecar's PostHog-backed flag service (context
targeting, cache file, refresh/dispose lifecycle). isCloudAgentsEnabled() is
now just the env override plus the Settings toggle, and get_feature_flags
answers synchronously.
2026-08-08 01:50:57 +00:00
Saoud Rizwan 2fa0be8940 refactor(desktop,sdk): drop the code-cloud-agents flag from the SDK catalog
Cloud sessions are gated by the explicit Settings toggle now, so the SDK no
longer registers the unused PostHog flag. The Settings row is wrapped in a
visibility gate that is hard-wired on, with the future flag lookup left
commented out until the flag actually exists in PostHog.
2026-08-08 01:21:40 +00:00
Saoud Rizwan 2e5a565090 fix(desktop): harden queue snapshot validation and recovery claiming
- applyQueueSnapshot now rejects replies without a prompts array instead of
  publishing an authoritative empty queue from the pending/update/remove
  command paths.
- Timeout-recovery candidate selection and claiming now happen in one
  synchronous helper so the claim can never be separated from the check,
  and the regression test exercises truly concurrent create requests.
2026-08-08 00:24:42 +00:00
Saoud Rizwan eb95908737 fix(desktop): only treat valid pending-prompts replies as authoritative queue snapshots
An unsuccessful or malformed session.pending_prompts reply during
rehydration no longer publishes an empty queue or discards buffered queue
events; the newest buffered queue snapshot is replayed instead.
2026-08-08 00:05:44 +00:00
Saoud Rizwan b091c72e40 fix(desktop): address cloud-session review findings
- Keep the newest buffered queue snapshot when rehydration's queue fetch
  fails instead of silently dropping queued/steered prompts (Greptile P1).
- Claim recovered/created session ids per process so overlapping identical
  create requests cannot adopt the same record and orphan a sandbox
  (Greptile P1).
- Use crypto.randomUUID() for provisioning placeholder ids (CodeQL
  insecure-randomness alerts).
2026-08-07 23:44:05 +00:00
Saoud Rizwan ebf5b52e9b Merge origin/main into saoudrizwan/desktop-cloud-sessions-9a9b
Resolves conflicts with #13028 (native-feel polish and render-path
performance): keep dynamic view imports and the memoized headerDiff from
main while preserving the cloud-session behaviors from this branch (Cloud
icon import, Connect GitHub error-action button in the chat error banner,
and hiding the diff header for cloud sessions).
2026-08-07 23:36:29 +00:00
Saoud Rizwan 13d801b578 fix(desktop): allow renaming cloud sessions from the sessions view
Rename was already supported by the sidecar and offered in the sidebar and
chat header; the sessions view context menu was the odd one out. Includes
biome format fixes picked up in touched files.
2026-08-07 22:52:44 +00:00
Saoud Rizwan 642c2f643b feat(desktop): cloud sessions onboarding panel for GitHub connect flow
When the cloud composer cannot start a session yet (signed out, GitHub not
connected, or the GitHub App has no repository access) replace the composer
with an onboarding panel that explains cloud sessions, walks through the
dashboard hand-off with visual steps, and auto-detects completion via polling
and window-focus refetches. Adds a teaching hint under the ready composer.
2026-08-07 22:52:44 +00:00
Saoud Rizwan 528c6b8ff2 feat(desktop): gate cloud sessions behind an explicit settings toggle
Cloud sessions are in preview, so replace the remote rollout flag with an
opt-in toggle in Settings -> General, persisted in a desktop-owned settings
file (kept out of global-settings.json so older CLI writers cannot strip it).
The CLINE_CODE_CLOUD_AGENTS env override still wins for development. Toggling
broadcasts feature_flags_changed so open composers react without a restart.
2026-08-07 22:52:36 +00:00
Saoud Rizwan 2f1c94505a Merge origin/main into desktop cloud sessions branch 2026-08-07 22:32:38 +00:00
John Choi 5b7005b003 fix(desktop): paginate cloud branch picker 2026-08-07 08:51:45 -07:00
abeatrix e48a7c9c91 feat(code): add SSH remote environments PoC 2026-08-07 00:20:13 -07:00
John Choi 71e1c59ab8 feat(desktop): switch models in cloud sessions 2026-08-06 20:38:53 -07:00
abeatrix d7b61de54f Merge remote-tracking branch 'origin/main' into bee/agent-voice
# Conflicts:
#	apps/examples/desktop-app/sidecar/context.test.ts
#	sdk/packages/llms/src/catalog/catalog.generated.ts
#	sdk/packages/llms/src/providers/ai-sdk.ts
#	sdk/packages/llms/src/providers/builtins.ts
#	sdk/packages/llms/src/providers/provider-ids.generated.ts
#	sdk/packages/llms/src/providers/providers.generated.ts
#	sdk/packages/llms/src/providers/routing/provider-option-rules.ts
#	sdk/packages/llms/src/providers/routing/provider-options.test.ts
2026-08-06 18:15:57 -07:00
John Choi 7350294f7d fix(desktop): harden cloud session lifecycle and flags 2026-08-06 10:01:27 -07:00
John Choi fe90cbfdf1 Merge remote-tracking branch 'origin/main' into codex/desktop-cloud-agents 2026-08-06 09:49:19 -07:00
John Choi 44cf50cee7 fix(desktop): cloud sessions follow the active account scope
The sidebar now shows exactly the active scope's cloud sessions (personal
or the active organization — matching the dashboard), instead of merging
both. On account/organization switch the sidecar broadcasts
cloud_sessions_changed so the sidebar re-scopes immediately rather than
on the next poll; the cloud manager reset already discards the org cache
and connections.

The session registry is now upsert-only: a session opened under another
scope stays routable (send/abort keep working) when the server-side
active org drifts mid-run, even though it leaves the visible list.
Display truth stays lastListedSessions (active scope only).

Known behavior: after a full account/org SWITCH (manager reset), stale
threads from the previous scope report session-not-found on cold reopen —
the list no longer shows them, so this is reachable only via stale
webview state.
2026-08-06 09:43:04 -07:00
John Choi b39c7e0319 feat(desktop,sdk): cloud transcript synchronization + approval recovery
Implements the agreed convergence design (mirrors experiment/mobile-app):
subscribe → buffer → attach → snapshot (messages/status/queue) → install →
replay unreflected events → live. Single-flight with one queued rerun;
failed snapshots never become an authoritative empty transcript;
segment-scoped substring supersession in the sidecar; multiset count-delta
optimistic reconciliation with first-hydrate gating in the webview.

Review-round fixes on top of the sync implementation:
- Recovery baseline advances on delivered sends — a lost duplicate prompt
  can no longer be falsely confirmed by an earlier identical delivery.
- Prompt occurrence matching normalizes the pod's <user_input> wrapper
  (real transcripts never matched raw prompts; tests used unwrapped
  fixtures, so recovery was inert in production).
- Streamed-text trim symmetry so whitespace cannot defeat supersession
  and duplicate an entire already-persisted reply on reconnect.
- Buffered queue snapshots are dropped during replay (always older than
  the synced queue; replaying could regress it and double-bubble).
- approval.list_pending advertised in HUB_CAPABILITIES (capability-gated
  clients could never discover it) and the sidecar's approvals refresh
  never wipes observed state unless the reply provably carries the list.
- Safety tests: replay-when-not-contained, whitespace supersession,
  queue-snapshot drop, wrapped-prompt recovery, baseline advance.

Tracked follow-ups (not in this change): approval.respond and event
delivery remain unscoped hub-wide (scoping naively would break the
desktop's second approvals client); sessionId is mandatory here vs
optional on the mobile branch.
2026-08-06 09:34:16 -07:00
abeatrix 378fdf147b feat: avatar overlay 2026-08-05 22:34:31 -07:00
John Choi 9c62406da9 fix(desktop,sdk): pre-PR review round — connection lifecycle and UX hardening
From a three-lens adversarial review of the branch:

Sidecar (cloud-sessions)
- BLOCKER: remove the connections-map entry when inner-session creation
  fails — the poisoned entry returned a disposed client whose event
  subscription was gone, silently streaming nothing for every later send.
- Single-flight inner-session creation: concurrent sends could fork two
  inner sessions on the pod, permanently dropping one run's events.
- Cache the active-organization lookup (60s, successes only) — the
  sidebar poll was making two authed REST calls per tick.
- delete() now drains an in-flight connect (zombie-connection race).
- Cold-cache expiry surfaces the clean session_expired envelope on
  send/read paths, not a raw WS upgrade failure.
- A provisioned-but-connect-failed create no longer reports failure for
  a live, billed sandbox; connect happens on demand instead.
- Guard against empty 2xx create responses (raw TypeError before).

SDK (hub client)
- Socket-identity guards in every connect-attempt cleanup path: a stale
  attempt's late timeout/error/close can no longer clobber a newer
  in-flight attempt's socket or dedupe state.

Webview
- Placeholder→real swap keeps the placeholder thread when opening the
  real session fails (was: deleted it and dumped the user on a blank
  fallback thread).
- Reset executionTarget to local when the cloud flag flips off on a
  fresh thread (was: permanently stranded cloud-gated composer).
- inferStatusFromMessages preserves 'provisioning' (the hydration pass
  was clobbering the sidebar state to idle within a second).
- Humanize cloud error envelopes in the delete toast and rename failure
  (rename previously had no catch at all).

Regression tests: poisoned-connection recovery, inner-session
single-flight.
2026-08-05 22:05:22 -07:00
John Choi a988d6a6ee fix(desktop): keep cloud loading continuous through the provisioning swap
The placeholder → real-session swap mounts a fresh thread whose hydration
briefly showed the skeleton between the provisioning row and the
conversation. An empty cloud session mid-hydration now shows the same
compact row ("Opening session...") so the loading treatment never
changes shape.
2026-08-05 21:36:52 -07:00
John Choi f1ec8c897c fix(desktop): cloud session dogfood round — billing, UX, and reliability
From live dogfooding of cloud agent sessions:

Billing & sessions
- Bill the user's ACTIVE organization (server-side active flag, cached
  resolver; personal fallback) instead of always personal credits, and
  list both personal and org-scoped sessions.
- Auto-title sessions from the first prompt; support rename via REST.
- Optional branch passthrough (picker + create body + recovery match).
- Forward autoApproveTools into cloud session creation.

Provisioning experience
- Sidebar placeholder while the synchronous create provisions (REST list
  cannot see the session yet), pulsing status dot, instant list nudge.
- Unified compact loading row (shared cycling phase line) for both the
  originating thread and the placeholder pane; phases advance once and
  hold rather than looping.
- cloud_session_provisioned event swaps placeholder threads to the real
  session when the sandbox is ready.
- Opening a placeholder is benign (loading state), reads return empty,
  only mutating actions error.

Correctness
- Surface run.failed error payloads in the chat (silent-failure fix; the
  raw CLOUD_SESSION_ERROR envelope can no longer reach the screen).
- Emit chat_session_status only on real status changes (pods stream
  periodic snapshots — every visited session was marked unread forever).
- expiredAt is a TTL deadline, not an end time; display uses createdAt
  (backend bumps updatedAt on every WS connect).
- provisioning is a first-class SessionHistoryStatus (the normalizer was
  collapsing it to idle).
- The new-prompt hero requires a thread WITHOUT a history session —
  fixes every flash-of-intro-screen path for existing sessions.
- Archived-history fallback only replaces a live failure when a snapshot
  actually exists (404 = null, not empty).

Plus GitHub repository/branch pickers, org-scoped integration URLs,
thinking-effort passthrough, and feature-flag targeting by account id.
2026-08-05 21:32:51 -07:00
John Choi 0b1f083531 fix(desktop): evaluate cloud-agents flag inside ChatThreadPane
The flag sync referenced ChatThreadPane state from Home, which only
surfaced at next-build prerender (tsconfig.dev does not cover webview).
2026-08-05 18:01:13 -07:00
John Choi d79ad49de3 Merge remote-tracking branch 'origin/main' into codex/desktop-cloud-agents 2026-08-05 17:58:02 -07:00
John Choi ce78c667cf feat(desktop): cloud agent sessions behind code-cloud-agents flag
Cloud sessions via core-platform remote-session API + Hub protocol v1:
sidecar CloudSessionManager (REST lifecycle, per-session NodeHubClient with
Bearer header auth, event translation, approvals, pending-prompt bridge,
expired-session archived history), webview Local/Cloud selector with repo +
optional branch picker, and a default-off PostHog feature flag gating
creation and the selector (existing sessions always remain accessible).
SDK: NodeHubClient resolveConnectionHeaders option; close unregistered
socket on registration failure; export FeatureFlag const from @cline/shared.
2026-08-05 17:57:57 -07:00
abeatrix 2140996303 feat(desktop): avatar pet
- Add native Tauri avatar-window lifecycle, positioning, tray integration, and persisted preferences.
- Add the `/avatar-overlay` webview route and v2 spritesheet animation runtime.
- Play the standard wave animation when the avatar first appears.
- Add bundled Cline Bot and Mom spritesheets.
- Add avatar discovery, selection, visibility, and manifest-precedence support.
- Add Settings controls for enabling and selecting desktop avatars.
- Add an animated voice orb driven by microphone intensity.
- Improve realtime voice panel visibility, mute/stop controls, playback coordination, and tests.
- Reorganize chat input controls so speech, realtime voice, stop, and send actions do not compete.
- Document avatar package structure and storage.
2026-08-05 14:19:28 -07:00
abeatrix 0d1a72f009 ui update 2026-08-05 12:13:53 -07:00
abeatrix a6c0cfd5c3 move audio-player to shared ui package 2026-08-05 11:19:05 -07:00
abeatrix 17e8c0cbc9 feat(desktop): add media player 2026-08-05 11:14:57 -07:00
abeatrix e2b97792b1 feat(desktop): play generated audio artifacts 2026-08-04 22:19:09 -07:00
abeatrix e4a7c19432 feat(desktop): add generated video support 2026-08-04 22:15:50 -07:00
abeatrix 589f32d13e feat(desktop): improve realtime voice sessions 2026-08-04 22:15:21 -07:00
abeatrix 879c085fdb feat(desktop): add realtime voice and UI refinements 2026-08-04 22:15:21 -07:00
591 changed files with 41258 additions and 66640 deletions
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix auto-approve checkboxes freezing after "New Task": clear the task-scoped settings overlay when the task view is cleared or switched, so stale task settings no longer shadow global settings
+4 -4
View File
@@ -1,11 +1,11 @@
---
name: publish-desktop
description: Use when preparing, tagging, and publishing a Cline desktop app (apps/examples/desktop-app) release — stable (desktop-vX.Y.Z from main) or beta (desktop-vX.Y.Z-beta.N from desktop-experimental, shipped as the side-by-side "Cline Beta" app). Guides changelog drafting, version bumps in package.json + tauri.conf.json, tagging, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the per-channel auto-update feed.
description: Use when preparing, tagging, and publishing a Cline Code desktop app (apps/examples/desktop-app) release — stable (desktop-vX.Y.Z from main) or beta (desktop-vX.Y.Z-beta.N from desktop-experimental, shipped as the side-by-side "Cline Code Beta" app). Guides changelog drafting, version bumps in package.json + tauri.conf.json, tagging, and the desktop-publish GitHub workflow that builds, signs, notarizes, and updates the per-channel auto-update feed.
---
# Desktop App Release
Use this skill when the user asks to release the desktop app, publish the Cline desktop app, cut a desktop beta, bump the desktop version, create a `desktop-vX.Y.Z` (or `desktop-vX.Y.Z-beta.N`) tag, or trigger the desktop publish workflow.
Use this skill when the user asks to release the desktop app, publish Cline Code, cut a desktop beta, bump the desktop version, create a `desktop-vX.Y.Z` (or `desktop-vX.Y.Z-beta.N`) tag, or trigger the desktop publish workflow.
> Working directory: run every command below from the repository root.
@@ -14,8 +14,8 @@ Desktop releases are macOS-only today (a single signed + notarized universal DMG
## Release contract
- Two channels, one workflow (`channel` input on `desktop-publish.yml`):
- **stable** — tag `desktop-vX.Y.Z` (no suffix; the workflow rejects prerelease suffixes on this channel), cut from `main`, feeds the rolling `desktop-latest` release, ships as "Cline".
- **beta** — tag `desktop-vX.Y.Z-beta.N`, cut from `desktop-experimental`, feeds the rolling `desktop-beta` release, ships as "Cline Beta" (separate bundle identifier `bot.cline.app.beta`; installs side by side with stable). Built with the extra `src-tauri/tauri.beta.conf.json` overlay. Process background: `apps/examples/desktop-app/EXPERIMENTAL.md`.
- **stable** — tag `desktop-vX.Y.Z` (no suffix; the workflow rejects prerelease suffixes on this channel), cut from `main`, feeds the rolling `desktop-latest` release, ships as "Cline Code".
- **beta** — tag `desktop-vX.Y.Z-beta.N`, cut from `desktop-experimental`, feeds the rolling `desktop-beta` release, ships as "Cline Code Beta" (separate bundle identifier `bot.cline.app.beta`; installs side by side with stable). Built with the extra `src-tauri/tauri.beta.conf.json` overlay. Process background: `apps/examples/desktop-app/EXPERIMENTAL.md`.
- Version sources (must match each other and the tag): `apps/examples/desktop-app/package.json` and `apps/examples/desktop-app/src-tauri/tauri.conf.json`. (`src-tauri/Cargo.toml` has its own version but `tauri.conf.json` overrides it; no need to touch it.)
- Beta versions are prereleases of the **next** stable: stable `0.0.13` → betas `0.0.14-beta.1`, `-beta.2`, … Once a stable ≥ the beta base ships, the next beta bumps its base (`0.0.15-beta.1`).
- Release prep includes approved release notes, the version bumps, and an `apps/examples/desktop-app/CHANGELOG.md` update — committed on `main` for stable, on `desktop-experimental` for beta.
+2 -6
View File
@@ -93,9 +93,7 @@ Release prep on `main` (PR, not direct push):
```bash
gh workflow run ext-vscode-ab-package.yml --ref main \
-f version=<VERSION> -f next-ref=main -f publish=true
# (the legacy bundle always builds from the protected legacy-extension branch;
# it is deliberately not an input)
-f version=<VERSION> -f next-ref=main -f legacy-ref=legacy-extension -f publish=true
# publish=false builds an installable .vsix artifact without publishing and
# needs NO environment approval — the ungated build job uploads the artifact
# and the run completes.
@@ -158,9 +156,7 @@ For shipping a fix on the `legacy-extension` branch — or as the **structural r
# versions, e.g. combined 4.1.0 live -> hotfix is 4.1.1, not 4.0.13),
# add the matching `## [x.y.z]` entry to root CHANGELOG.md, push.
gh workflow run ext-vscode-publish-legacy.yml --ref main \
-f release-type=release
# (the branch is hardcoded to legacy-extension in the workflow; it is
# deliberately not an input)
-f release-type=release -f branch=legacy-extension
```
npm test suite runs ungated; the publish job waits on the `Publish` environment. This workflow derives + pushes the `v<version>` tag itself and creates the GitHub release — no manual tagging. Publishes to Marketplace **and** Open VSX. The branch is the npm codebase: use `npm`, never `bun`, and expect the old monolith layout (`apps/vscode/src/core/...`).
+1 -29
View File
@@ -206,8 +206,6 @@ jobs:
- name: Get Changelog Entry
id: changelog
env:
RELEASE_URL: https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }}
run: |
# Grab content between the first "## " header and the next one in apps/cli/CHANGELOG.md
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/cli/CHANGELOG.md)
@@ -215,32 +213,6 @@ jobs:
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
# Slack section blocks reject text longer than 3000 characters, and the
# Slack action logs that rejection WITHOUT failing the step - so an
# over-long changelog silently drops the release announcement while the
# run stays green (cline@3.0.50 hit this). Post a trimmed copy to Slack
# and link out to the full notes. The GitHub release body stays whole.
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
import os
content = os.environ["CONTENT"]
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
if len(content) <= 3000:
print(content, end="")
else:
budget = 3000 - len(more)
kept, used = [], 0
for line in content.splitlines(keepends=True):
if used + len(line) > budget:
break
kept.append(line)
used += len(line)
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
print(body + more, end="")
')
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
echo "SLACK_EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
@@ -276,7 +248,7 @@ jobs:
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
+9 -36
View File
@@ -111,7 +111,7 @@ jobs:
fi
ANCESTOR_REF=main
FEED=desktop-latest
PRODUCT="Cline"
PRODUCT="Cline Code"
;;
beta)
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$'; then
@@ -120,7 +120,7 @@ jobs:
fi
ANCESTOR_REF=desktop-experimental
FEED=desktop-beta
PRODUCT="Cline Beta"
PRODUCT="Cline Code Beta"
;;
*)
echo "unknown channel: ${CHANNEL}"
@@ -435,7 +435,7 @@ jobs:
OUT="dist/publish"
mkdir -p "$OUT"
# "Cline" -> Cline, "Cline Beta" -> Cline-Beta
# "Cline Code" -> Cline-Code, "Cline Code Beta" -> Cline-Code-Beta
PREFIX="${PRODUCT// /-}"
DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -print -quit)
@@ -506,33 +506,6 @@ jobs:
echo "EOF" >> $GITHUB_OUTPUT
printf "%s\n" "$CONTENT" > "$RUNNER_TEMP/release-notes.md"
# Slack section blocks reject text longer than 3000 characters, and the
# Slack action logs that rejection WITHOUT failing the step - so an
# over-long changelog silently drops the release announcement while the
# run stays green. Post a trimmed copy to Slack and link out to the full
# notes. The GitHub release body and updater manifest stay whole.
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ needs.validate.outputs.tag }}"
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
import os
content = os.environ["CONTENT"]
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
if len(content) <= 3000:
print(content, end="")
else:
budget = 3000 - len(more)
kept, used = [], 0
for line in content.splitlines(keepends=True):
if used + len(line) > budget:
break
kept.append(line)
used += len(line)
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
print(body + more, end="")
')
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
echo "SLACK_EOF" >> $GITHUB_OUTPUT
- name: Generate updater manifest
env:
VERSION: ${{ needs.validate.outputs.version }}
@@ -607,14 +580,14 @@ jobs:
if ! gh release view "$FEED" >/dev/null 2>&1; then
if [ "$CHANNEL" = "beta" ]; then
gh release create "$FEED" \
--title "Cline desktop beta (auto-update feed)" \
--title "Cline Code desktop beta (auto-update feed)" \
--notes "Rolling release backing the beta desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z-beta.N release. Only beta installs poll this feed; stable installs use desktop-latest. Do not delete." \
--latest=false \
--prerelease \
--target "$(git rev-parse HEAD)"
else
gh release create "$FEED" \
--title "Cline desktop (auto-update feed)" \
--title "Cline Code desktop (auto-update feed)" \
--notes "Rolling release backing the desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z release. Do not delete." \
--latest=false \
--target "$(git rev-parse HEAD)"
@@ -628,7 +601,7 @@ jobs:
TAG: ${{ needs.validate.outputs.tag }}
FEED: ${{ needs.validate.outputs.feed }}
run: |
echo "Published Cline desktop v${VERSION}"
echo "Published Cline Code desktop v${VERSION}"
echo "Release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
echo "Auto-update feed refreshed: https://github.com/${GITHUB_REPOSITORY}/releases/download/${FEED}/latest.json"
@@ -639,16 +612,16 @@ jobs:
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
text: "Cline Code desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "Cline desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
text: "Cline Code desktop v${{ needs.validate.outputs.version }}${{ needs.validate.outputs.channel == 'beta' && ' (beta)' || '' }}"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
+14 -44
View File
@@ -23,6 +23,11 @@ on:
required: true
default: "main"
type: string
legacy-ref:
description: "Ref to build the legacy bundle from"
required: true
default: "legacy-extension"
type: string
publish:
description: "Publish to the VS Code Marketplace and Open VSX (unchecked: just build the .vsix artifact)"
required: true
@@ -125,37 +130,31 @@ jobs:
name: Test legacy bundle
runs-on: ubuntu-latest
# The tested revision, exported so the build job builds EXACTLY what
# this suite ran against. legacy-extension is a mutable branch name and
# the build job starts later — re-resolving the name there could pick
# up commits this gate never saw.
# this suite ran against. legacy-ref is a mutable branch name and the
# build job starts later — re-resolving the name there could pick up
# commits this gate never saw.
outputs:
tested-sha: ${{ steps.rev.outputs.sha }}
defaults:
run:
working-directory: apps/vscode
steps:
# Always the protected legacy-extension branch — deliberately not
# an input. An arbitrary ref here would be built into the published
# VSIX by the environment-less build job, and the publish
# environment approver only ever sees an opaque prebuilt artifact:
# the approval would protect the marketplace PAT but not the
# shipped bytes. Hardcoding the branch makes its protection rules
# load-bearing for releases. Legacy hotfix testing has its own
# workflow (ext-vscode-publish-legacy.yml).
- uses: actions/checkout@v4
with:
ref: legacy-extension
ref: ${{ github.event.inputs.legacy-ref }}
- name: Record tested revision
id: rev
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
# Deliberately no dependency cache here: publish workflows do clean
# installs and should not restore actions caches.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
@@ -490,35 +489,6 @@ jobs:
echo "CHANGELOG_EOF"
} >> "$GITHUB_OUTPUT"
# Slack section blocks reject text longer than 3000 characters, and
# the Slack action logs that rejection WITHOUT failing the step - so
# an over-long changelog silently drops the release announcement
# while the run stays green. Post a trimmed copy to Slack and link
# out to the full notes. The GitHub release body stays whole.
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/v${{ github.event.inputs.version }}"
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
import os
content = os.environ["CONTENT"]
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
if len(content) <= 3000:
print(content, end="")
else:
budget = 3000 - len(more)
kept, used = [], 0
for line in content.splitlines(keepends=True):
if used + len(line) > budget:
break
kept.append(line)
used += len(line)
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
print(body + more, end="")
')
{
echo "slack_content<<CHANGELOG_EOF"
echo "$SLACK_CONTENT"
echo "CHANGELOG_EOF"
} >> "$GITHUB_OUTPUT"
- name: Resolve previous release tag
id: prev_tag
if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }}
@@ -574,7 +544,7 @@ jobs:
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
+20 -53
View File
@@ -21,17 +21,20 @@ on:
options:
- pre-release
- release
branch:
description: "Branch holding the legacy extension code"
required: true
default: "legacy-extension"
type: string
# Read-only by default. The publish job elevates itself to contents: write for
# the tag push and GitHub release; nothing here needs packages/checks/PR
# write. Keeping the default minimal matters doubly in this workflow because
# the test job runs BEFORE any environment approval — it must never hold a
# write token while executing checked-out code.
permissions:
contents: read
contents: write
packages: write
checks: write
pull-requests: write
concurrency:
group: ext-vscode-publish-legacy
group: ext-vscode-publish-legacy-${{ github.event.inputs.branch }}
cancel-in-progress: false
jobs:
@@ -47,23 +50,18 @@ jobs:
run:
working-directory: apps/vscode
steps:
# Always the protected legacy-extension branch — deliberately not
# an input. This job runs full npm lifecycle scripts from the
# checked-out code with no environment approval, and the publish
# job below does the same next to the marketplace PATs; an
# arbitrary ref here would hand both of them attacker-controlled
# code. Hardcoding the branch makes its protection rules
# load-bearing for releases.
- uses: actions/checkout@v4
with:
ref: legacy-extension
ref: ${{ github.event.inputs.branch }}
# Deliberately no dependency cache here: publish workflows do clean
# installs and should not restore actions caches.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
@@ -101,20 +99,16 @@ jobs:
name: Publish Legacy Extension
runs-on: ubuntu-latest
environment: publish
# For the tag push in Resolve Release Tag and the GitHub release.
permissions:
contents: write
defaults:
run:
working-directory: apps/vscode
steps:
# Check out the legacy branch (NOT main; hardcoded — see the test
# job's checkout comment). fetch-depth: 0 + tags so we can
# create/push the release tag and compute the previous tag.
# Check out the legacy branch (NOT main). fetch-depth: 0 + tags so we
# can create/push the release tag and compute the previous tag.
- uses: actions/checkout@v4
with:
ref: legacy-extension
ref: ${{ github.event.inputs.branch }}
fetch-depth: 0
fetch-tags: true
lfs: true
@@ -123,7 +117,7 @@ jobs:
id: resolve_tag
working-directory: ${{ github.workspace }}
env:
BRANCH: legacy-extension
BRANCH: ${{ github.event.inputs.branch }}
run: |
# Tag is derived from the package version on the legacy branch.
VERSION=$(node -p "require('./apps/vscode/package.json').version")
@@ -264,33 +258,6 @@ jobs:
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
# Slack section blocks reject text longer than 3000 characters, and
# the Slack action logs that rejection WITHOUT failing the step - so
# an over-long changelog silently drops the release announcement
# while the run stays green. Post a trimmed copy to Slack and link
# out to the full notes. The GitHub release body stays whole.
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ steps.resolve_tag.outputs.tag }}"
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
import os
content = os.environ["CONTENT"]
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
if len(content) <= 3000:
print(content, end="")
else:
budget = 3000 - len(more)
kept, used = [], 0
for line in content.splitlines(keepends=True):
if used + len(line) > budget:
break
kept.append(line)
used += len(line)
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
print(body + more, end="")
')
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
echo "SLACK_EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
@@ -320,7 +287,7 @@ jobs:
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
@@ -14,12 +14,9 @@ name: ext-vscode-publish-nightly
# pre-release publishes.
on:
# 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.
schedule:
# Every day at 4:00 AM PST (12:00 UTC)
- cron: "0 12 * * *"
workflow_dispatch:
inputs:
legacy-ref:
@@ -77,9 +74,8 @@ jobs:
- name: Checkout legacy source
uses: actions/checkout@v4
with:
# 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).
# NOTE: inputs are empty strings on `schedule` events, so the ||
# fallback (not the input's declared default) is what the cron uses.
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
path: legacy-src
lfs: true
@@ -234,33 +234,6 @@ jobs:
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
# Slack section blocks reject text longer than 3000 characters, and
# the Slack action logs that rejection WITHOUT failing the step - so
# an over-long changelog silently drops the release announcement
# while the run stays green. Post a trimmed copy to Slack and link
# out to the full notes. The GitHub release body stays whole.
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${{ steps.resolve_tag.outputs.tag }}"
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
import os
content = os.environ["CONTENT"]
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
if len(content) <= 3000:
print(content, end="")
else:
budget = 3000 - len(more)
kept, used = [], 0
for line in content.splitlines(keepends=True):
if used + len(line) > budget:
break
kept.append(line)
used += len(line)
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
print(body + more, end="")
')
echo "slack_content<<SLACK_EOF" >> $GITHUB_OUTPUT
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
echo "SLACK_EOF" >> $GITHUB_OUTPUT
- name: Package and Publish Extension
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
@@ -330,7 +303,7 @@ jobs:
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
+8 -9
View File
@@ -80,9 +80,8 @@ jobs:
include: ${{ fromJson(needs.matrix_prep.outputs.matrix) }}
runs-on: ${{ matrix.runner }}-latest
timeout-minutes: 20
# Nothing in this job uses OIDC, so it does not need an id-token
# permission.
permissions:
id-token: write
contents: read
defaults:
run:
@@ -94,9 +93,6 @@ jobs:
with:
bun-version: 1.3.14
# Cache keys below are exact-match only (no restore-keys prefix
# fallbacks); a miss just means a cold install, which is acceptable.
# Cache Bun's global install cache - keyed on the authoritative root bun.lock.
- name: Cache Bun install cache
uses: actions/cache@v4
@@ -104,6 +100,8 @@ jobs:
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
# Cache VS Code installation
- name: Cache VS Code
@@ -112,6 +110,8 @@ jobs:
with:
path: apps/vscode/.vscode-test
key: vscode-${{ runner.os }}-stable-${{ hashFiles('apps/vscode/.vscode-test.mjs', 'apps/vscode/package.json') }}
restore-keys: |
vscode-${{ runner.os }}-stable-
# Cache Playwright browsers
- name: Cache Playwright browsers
@@ -123,6 +123,8 @@ jobs:
~/Library/Caches/ms-playwright
~/AppData/Local/ms-playwright
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: |
playwright-browsers-${{ runner.os }}-
# Single root install resolves the entire bun workspace at once (replaces
# the per-package `npm ci` steps for apps/vscode + webview-ui).
@@ -169,12 +171,9 @@ jobs:
if: matrix.runner != 'ubuntu'
run: bun run test:e2e:optimal
# Repo-root relative: the job's `working-directory` default applies to `run`
# steps only, so an apps/vscode-relative path here silently matches nothing
# and every failing run uploads no recordings at all.
- uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: playwright-recordings-${{ matrix.runner }}
path: |
apps/vscode/test-results/
test-results/playwright/
+1 -28
View File
@@ -282,33 +282,6 @@ jobs:
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "${DELIMITER}" >> $GITHUB_OUTPUT
# Slack section blocks reject text longer than 3000 characters, and the
# Slack action logs that rejection WITHOUT failing the step - so an
# over-long changelog silently drops the release announcement while the
# run stays green. Post a trimmed copy to Slack and link out to the full
# notes. The GitHub release body stays whole.
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/sdk/sdk/v${{ steps.version.outputs.version }}"
SLACK_CONTENT=$(CONTENT="$CONTENT" RELEASE_URL="$RELEASE_URL" python3 -c '
import os
content = os.environ["CONTENT"]
more = "\n\n… <%s|Read the full release notes>" % os.environ["RELEASE_URL"]
if len(content) <= 3000:
print(content, end="")
else:
budget = 3000 - len(more)
kept, used = [], 0
for line in content.splitlines(keepends=True):
if used + len(line) > budget:
break
kept.append(line)
used += len(line)
body = "".join(kept).rstrip() if kept else content[:budget].rstrip()
print(body + more, end="")
')
echo "slack_content<<${DELIMITER}" >> $GITHUB_OUTPUT
echo "$SLACK_CONTENT" >> $GITHUB_OUTPUT
echo "${DELIMITER}" >> $GITHUB_OUTPUT
- name: Create GitHub Release
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
uses: softprops/action-gh-release@v1
@@ -360,7 +333,7 @@ jobs:
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.slack_content) }}
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
+5 -6
View File
@@ -46,12 +46,6 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
# @cline/ui imports @cline/shared/browser (generated-media), which
# resolves to dist output — build it before anything typechecks or
# builds the ui package.
- name: Build shared package
run: bun -F @cline/shared build
- name: Typecheck UI
run: bun -F @cline/ui typecheck
@@ -64,6 +58,11 @@ jobs:
- name: Build UI package
run: bun -F @cline/ui build
# The desktop chat test imports @cline/shared/browser, which resolves to
# dist output that nothing else in this job builds.
- name: Build shared package
run: bun -F @cline/shared build
- name: Test desktop chat integration
run: bun -F @cline/code test:chat-ui
-55
View File
@@ -1,60 +1,5 @@
# 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.
### Added
- Let models that support it generate images during a task. Generated images render inline in the conversation.
### Fixed
- Fix code actions failing with "command not found" on VS Code 1.134.
- Fix `@` file mentions breaking on paths that contain spaces.
- Show the diff edit view for multi-line edits in files with CRLF line endings.
- Continue the surviving session when resuming a task, instead of rebuilding it from the original task text.
- Clear the task-scoped settings overlay when the task view is cleared or switched, so one task's overrides no longer leak into the next.
- Honor the classic truncation range when migrating legacy tasks.
- Preserve LiteLLM input token limits instead of overwriting them with catalog values.
- Restore custom base URLs for Gemini, and normalize legacy host-root values so they keep working.
- Point provider signup links at each provider's API key page instead of a generic landing page.
- Load skill slash commands through the skills tool instead of pasting their instructions into your message, which previously delivered them twice.
- Stop offering image, voice, and other non-chat models in chat model pickers.
- Deliver a `PreToolUse` hook's `contextModification` to the model again, and wait for `PostToolUse` hooks so their output and `cancel` control are honored.
- Show tool activity a provider runs itself — every tool the Claude Code provider executes inside its own session — instead of dropping it from the conversation.
- Fix `run_commands` failing with ENOENT when a structured command carried a full command line with no arguments.
- Run PowerShell commands fail-fast, so a pipeline erroring per item stops at the first error instead of flooding output and still reporting success.
- Keep remote configuration in step with the SDK: coordinated refreshes, session gating, and a fail-closed opt-out.
### Changed
- Show the billed cost for Cline gateway usage.
- Refresh the model catalog, which adds AMD, Arcee, Echo, Jalapeno, Kosmik, LLM Gateway, RunInfra, and SCNet as providers and updates model lists, pricing, and per-provider default models across the board.
### Fixed (legacy bundle)
- Only treat an Anthropic `invalid_request_error` as a context-overflow when its message says so. An unrelated invalid request (bad tool schema, oversized image, unknown model id) no longer triggers context-overflow recovery.
## [4.1.10]
Everything in this release lands through the SDK bundle, so it applies to windows running that bundle and not the legacy one. The legacy bundle is unchanged from 4.1.9.
-25
View File
@@ -1,30 +1,5 @@
# 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
- Skill slash commands now load through the skills tool instead of expanding into your message. History and resume show the `/command` you typed instead of the whole skill body, and the instructions reach the model once instead of twice. Workflows still expand, as does zen mode, whose preset has no skills tool
- Image, voice, and other non-chat models are no longer offered in the onboarding and model pickers or ACP model listings, and are rejected for `--model`
- Fixed TUI dialog colors not following theme changes live
- Fixed the account dialog's selection chevron so it matches the other dialogs
- Fixed provider-executed tool activity — every tool the Claude Code provider runs inside its own session — being dropped instead of shown as a tool card
- Fixed `PreToolUse` hook `contextModification` never reaching the model, and `PostToolUse` hooks running fire-and-forget with their output and `cancel` control discarded
- Fixed `run_commands` failing with ENOENT when a structured command carried a full command line with no `args`
- PowerShell commands now fail fast on the first error instead of emitting an error record per enumerated item and still reporting success
- Fixed Gemini custom base URLs configured as a host root
- Fixed `cline schedule` commands against a remote hub, which now register a workspace client so they are authorized under the new workspace-scoped schedule rules
- Usage now displays the billed gateway cost
- Refreshed the model catalog, which adds AMD, Arcee, Echo, Jalapeno, Kosmik, LLM Gateway, RunInfra, and SCNet as providers and updates model lists, pricing, and per-provider default models across the board
## 3.0.55
- Auto-updates no longer install while a CLI is attached to the Hub. The update is recorded at startup and installed on exit, once the Hub confirms nothing else is attached, so a background update can no longer swap the package out from under a live session and kill it with `Hub connection closed (code=1006)`. `cline update` still installs immediately and now tells you the update applies on next start
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.57",
"version": "3.0.55",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+4 -21
View File
@@ -70,10 +70,6 @@ import {
sendSessionInfoUpdate,
} from "./session-updates";
const CHAT_MODEL_QUERY_OPTIONS = {
filter: "chat",
} satisfies Llms.GetModelsForProviderOptions;
interface SessionState {
id: string;
cwd: string;
@@ -189,10 +185,7 @@ export class AcpAgent implements Agent {
const providerId =
process.env.CLINE_PROVIDER ?? this.authResult?.providerId ?? "cline";
const providerModels = await Llms.getModelsForProvider(
providerId,
CHAT_MODEL_QUERY_OPTIONS,
);
const providerModels = await Llms.getModelsForProvider(providerId);
// Model ids are provider-scoped, so the default must come from the
// provider's own catalog: `cline-pass` uses `cline-pass/…` ids that mean
// nothing to `cline`, and vice versa.
@@ -263,10 +256,7 @@ export class AcpAgent implements Agent {
// provider's own catalog just like newSession.
const providerId =
process.env.CLINE_PROVIDER ?? this.authResult?.providerId ?? "cline";
const providerModels = await Llms.getModelsForProvider(
providerId,
CHAT_MODEL_QUERY_OPTIONS,
);
const providerModels = await Llms.getModelsForProvider(providerId);
session = {
id: params.sessionId,
cwd: params.cwd,
@@ -299,7 +289,6 @@ export class AcpAgent implements Agent {
const providerModels = await Llms.getModelsForProvider(
session.currentProviderId,
CHAT_MODEL_QUERY_OPTIONS,
);
const availableModels = Object.entries(providerModels).map(
([availableModelId, info]) => ({
@@ -484,10 +473,7 @@ export class AcpAgent implements Agent {
// current one when it's offered there too, otherwise fall back to the
// provider's declared default rather than whichever model happens to
// be listed first (for cline-pass that is an unrelated free model).
const providerModels = await Llms.getModelsForProvider(
value,
CHAT_MODEL_QUERY_OPTIONS,
);
const providerModels = await Llms.getModelsForProvider(value);
session.currentModelId = await resolveDefaultModelId(
value,
session.currentModelId,
@@ -921,10 +907,7 @@ async function buildAllConfigOptions(
): Promise<SessionConfigOption[]> {
const [providerOption, providerModels] = await Promise.all([
buildProviderConfigOption(session.currentProviderId),
Llms.getModelsForProvider(
session.currentProviderId,
CHAT_MODEL_QUERY_OPTIONS,
),
Llms.getModelsForProvider(session.currentProviderId),
]);
return [
providerOption,
-2
View File
@@ -17,7 +17,6 @@ const mocks = vi.hoisted(() => ({
stopConnectorsViaHub: vi.fn(async () => undefined as number | undefined),
disableConnectorAutostart: vi.fn(),
getPersistedConnectorConnection: vi.fn(),
getProcessStartToken: vi.fn(() => undefined),
getConnector: vi.fn(),
listActiveConnectors: vi.fn(),
listConnectors: vi.fn((): Array<{ name: string; description: string }> => []),
@@ -30,7 +29,6 @@ const mocks = vi.hoisted(() => ({
vi.mock("@cline/core", () => ({
disableConnectorAutostart: mocks.disableConnectorAutostart,
getPersistedConnectorConnection: mocks.getPersistedConnectorConnection,
getProcessStartToken: mocks.getProcessStartToken,
listActiveConnectors: mocks.listActiveConnectors,
persistConnectorConnection: mocks.persistConnectorConnection,
removePersistedConnectorConnection: mocks.removePersistedConnectorConnection,
-147
View File
@@ -3,20 +3,16 @@ 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",
@@ -31,10 +27,8 @@ const {
vi.mock("@cline/core", () => ({
clearHubDiscovery: mockClearHubDiscovery,
ensureDetachedHubServer: mockEnsureDetachedHubServer,
localHubHasNoActiveSessions: mockLocalHubHasNoActiveSessions,
probeHubServer: mockProbeHubServer,
readHubDiscovery: mockReadHubDiscovery,
requestHubDrain: mockRequestHubDrain,
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
@@ -101,147 +95,6 @@ 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({
+1 -123
View File
@@ -1,16 +1,14 @@
import {
clearHubDiscovery,
ensureDetachedHubServer,
localHubHasNoActiveSessions,
probeHubServer,
readHubDiscovery,
requestHubDrain,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command, InvalidArgumentError } from "commander";
import { Command } from "commander";
import { version as cliVersion } from "../../package.json";
interface HubCommandIo {
@@ -56,16 +54,6 @@ 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,
@@ -162,115 +150,5 @@ 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;
}
+70 -72
View File
@@ -4,8 +4,7 @@ import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createScheduleCommand } from "./schedule";
const mockHubClientCommand = vi.hoisted(() => vi.fn());
const mockNodeHubClientCtor = vi.hoisted(() => vi.fn());
const mockSendHubCommand = vi.hoisted(() => vi.fn());
const mockEnsureCliHubServer = vi.hoisted(() => vi.fn());
const mockProviderSettings = vi.hoisted(() => ({
lastUsed: undefined as { provider?: string; model?: string } | undefined,
@@ -17,17 +16,7 @@ vi.mock("@cline/core", async () => {
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
NodeHubClient: class {
command = mockHubClientCommand;
constructor(options: Record<string, unknown>) {
mockNodeHubClientCtor(options);
}
async connect(): Promise<void> {}
close(): void {}
},
sendHubCommand: mockSendHubCommand,
ProviderSettingsManager: class {
getLastUsedProviderSettings() {
return mockProviderSettings.lastUsed;
@@ -85,7 +74,7 @@ describe("runScheduleCommand list output", () => {
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockHubClientCommand.mockResolvedValue({
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedules: [] },
});
@@ -107,21 +96,18 @@ describe("runScheduleCommand list output", () => {
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(output).toEqual(["No schedules found."]);
// Schedule commands are workspace-scoped: the hub client must register
// with a workspace context (and the hub auth token) before commanding.
expect(mockNodeHubClientCtor).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:25463/hub",
workspaceRoot: process.cwd(),
cwd: process.cwd(),
authToken: "test-token",
}),
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
{
clientId: "cline-schedule",
command: "schedule.list",
payload: {
limit: 100,
enabled: undefined,
tags: undefined,
},
},
);
expect(mockHubClientCommand).toHaveBeenCalledWith("schedule.list", {
limit: 100,
enabled: undefined,
tags: undefined,
});
});
it("keeps JSON list output unchanged when --json is provided", async () => {
@@ -129,7 +115,7 @@ describe("runScheduleCommand list output", () => {
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockHubClientCommand.mockResolvedValue({
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedules: [] },
});
@@ -151,7 +137,7 @@ describe("runScheduleCommand list output", () => {
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(output).toEqual(["[]"]);
expect(mockHubClientCommand).toHaveBeenCalled();
expect(mockSendHubCommand).toHaveBeenCalled();
});
});
@@ -171,7 +157,7 @@ describe("runScheduleCommand create", () => {
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockHubClientCommand.mockResolvedValue({
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_123" } },
});
@@ -203,19 +189,15 @@ describe("runScheduleCommand create", () => {
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(mockNodeHubClientCtor).toHaveBeenCalledWith(
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
expect.objectContaining({
url: "ws://127.0.0.1:25463/hub",
workspaceRoot: "/tmp/workspace",
cwd: "/tmp/workspace",
authToken: "test-token",
}),
);
expect(mockHubClientCommand).toHaveBeenCalledWith(
"schedule.create",
expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
clientId: "cline-schedule",
command: "schedule.create",
payload: expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
}),
);
});
@@ -233,7 +215,7 @@ describe("runScheduleCommand create", () => {
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockHubClientCommand.mockResolvedValue({
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_123" } },
});
@@ -264,11 +246,14 @@ describe("runScheduleCommand create", () => {
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(mockHubClientCommand).toHaveBeenCalledWith(
"schedule.create",
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
command: "schedule.create",
payload: expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
}),
);
});
@@ -307,7 +292,7 @@ describe("runScheduleCommand create", () => {
expect(errors).toEqual([
'No model is configured for provider "anthropic". Pass --model or save a model for that provider before creating the schedule.',
]);
expect(mockHubClientCommand).not.toHaveBeenCalled();
expect(mockSendHubCommand).not.toHaveBeenCalled();
});
it("maps --delivery-bot to delivery.userName", async () => {
@@ -315,7 +300,7 @@ describe("runScheduleCommand create", () => {
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockHubClientCommand.mockResolvedValue({
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_delivery" } },
});
@@ -354,17 +339,21 @@ describe("runScheduleCommand create", () => {
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(output).toEqual(['{\n "scheduleId": "sched_delivery"\n}']);
expect(mockHubClientCommand).toHaveBeenCalledWith(
"schedule.create",
expect.objectContaining({
metadata: {
delivery: {
adapter: "telegram",
threadId: "telegram:123456789",
userName: "my_bot",
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
{
clientId: "cline-schedule",
command: "schedule.create",
payload: expect.objectContaining({
metadata: {
delivery: {
adapter: "telegram",
threadId: "telegram:123456789",
userName: "my_bot",
},
},
},
}),
}),
},
);
});
});
@@ -381,7 +370,7 @@ describe("runScheduleCommand import", () => {
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockHubClientCommand.mockResolvedValue({
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_123" } },
});
@@ -422,12 +411,16 @@ describe("runScheduleCommand import", () => {
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(output).toEqual(['{\n "scheduleId": "sched_123"\n}']);
expect(mockHubClientCommand).toHaveBeenCalledWith(
"schedule.create",
expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
{
clientId: "cline-schedule",
command: "schedule.create",
payload: expect.objectContaining({
provider: "anthropic",
model: "claude-sonnet-4-6",
}),
},
);
});
});
@@ -451,7 +444,7 @@ describe("runScheduleCommand export", () => {
prompt: "review status",
workspaceRoot: "/tmp/workspace",
};
mockHubClientCommand.mockResolvedValue({
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: scheduleRecord },
});
@@ -491,9 +484,14 @@ describe("runScheduleCommand export", () => {
const written = await readFile(targetPath, "utf8");
expect(written).toBe(JSON.stringify(scheduleRecord, null, 2));
expect(mockHubClientCommand).toHaveBeenCalledWith("schedule.get", {
scheduleId: "sched_abc",
});
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
{
clientId: "cline-schedule",
command: "schedule.get",
payload: { scheduleId: "sched_abc" },
},
);
} finally {
await rm(targetPath, { force: true });
}
@@ -509,7 +507,7 @@ describe("runScheduleCommand export", () => {
name: "Weekly Sync",
cronPattern: "0 9 * * 1",
};
mockHubClientCommand.mockResolvedValue({
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: scheduleRecord },
});
+26 -62
View File
@@ -2,7 +2,7 @@ import {
createLocalHubScheduleRuntimeHandlers,
HubScheduleCommandService,
HubScheduleService,
NodeHubClient,
sendHubCommand,
} from "@cline/core";
import {
ensureCliHubServer,
@@ -11,51 +11,28 @@ import {
import type { CommandIo } from "./types";
export class HubScheduleClient {
private hub: Promise<NodeHubClient> | undefined;
constructor(
private readonly url: string,
private readonly workspaceRoot: string,
private readonly authToken?: string,
private readonly endpoint: {
host?: string;
port?: number;
pathname?: string;
},
) {}
close(): void {
const hub = this.hub;
this.hub = undefined;
void hub?.then((client) => client.close()).catch(() => undefined);
}
// Schedule commands are authorized against the workspace bound to the
// connection's client registration, so all commands must share one
// registered connection instead of fire-and-forget envelopes.
private connectedHub(): Promise<NodeHubClient> {
this.hub ??= (async () => {
const client = new NodeHubClient({
url: this.url,
clientType: "cli-schedule",
displayName: "Cline CLI scheduler",
workspaceRoot: this.workspaceRoot,
cwd: this.workspaceRoot,
authToken: this.authToken,
});
try {
await client.connect();
} catch (error) {
client.close();
this.hub = undefined;
throw error;
}
return client;
})();
return this.hub;
}
close(): void {}
private async command(
command: string,
payload?: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const client = await this.connectedHub();
const reply = await client.command(command as never, payload);
const reply = await sendHubCommand(this.endpoint, {
clientId: "cline-schedule",
command: command as never,
payload,
});
if (!reply.ok) {
throw new Error(reply.error?.message ?? `hub command failed: ${command}`);
}
return (reply.payload ?? {}) as Record<string, unknown>;
}
@@ -120,7 +97,6 @@ export class LocalScheduleClient {
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
});
private readonly commands = new HubScheduleCommandService(this.service);
constructor(private readonly workspaceRoot: string) {}
close(): void {
void this.service.dispose();
@@ -130,21 +106,12 @@ export class LocalScheduleClient {
command: string,
payload?: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const reply = await this.commands.handleCommand(
{
version: "v1",
clientId: "cline-schedule-local",
command: command as never,
payload,
},
{
clientId: "cline-schedule-local",
workspaceContext: {
workspaceRoot: this.workspaceRoot,
cwd: this.workspaceRoot,
},
},
);
const reply = await this.commands.handleCommand({
version: "v1",
clientId: "cline-schedule-local",
command: command as never,
payload,
});
if (!reply.ok) {
throw new Error(reply.error?.message ?? `hub command failed: ${command}`);
}
@@ -218,27 +185,24 @@ export async function ensureSchedulerHub(
if (!address?.trim()) {
return {
ok: true,
client: new LocalScheduleClient(
workspaceRoot,
) as unknown as HubScheduleClient,
client: new LocalScheduleClient() as unknown as HubScheduleClient,
};
}
try {
const requestedEndpoint = parseHubEndpointOverride(address);
const { url: hubUrl, authToken } = await ensureCliHubServer(
const { url: hubUrl } = await ensureCliHubServer(
workspaceRoot,
requestedEndpoint,
);
const endpoint = parseHubEndpointOverride(hubUrl);
return {
ok: true,
client: new HubScheduleClient(hubUrl, workspaceRoot, authToken),
client: new HubScheduleClient(endpoint),
};
} catch (_error) {
return {
ok: true,
client: new LocalScheduleClient(
workspaceRoot,
) as unknown as HubScheduleClient,
client: new LocalScheduleClient() as unknown as HubScheduleClient,
};
}
}
+56 -6
View File
@@ -1,4 +1,4 @@
import { spawn } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import {
closeSync,
@@ -13,11 +13,7 @@ import {
} from "node:fs";
import { join } from "node:path";
import type { HubSessionClient, HubSessionRow } from "@cline/core";
import {
ensureParentDir,
getProcessStartToken,
resolveClineDataDir,
} from "@cline/core";
import { ensureParentDir, resolveClineDataDir } from "@cline/core";
import {
CLINE_RUN_AS_HUB_DAEMON_ENV,
withResolvedClineBuildEnv,
@@ -89,6 +85,60 @@ type ProcessProbe = {
getStartToken: (pid: number) => string | undefined;
};
function getProcessStartToken(pid: number): string | undefined {
if (!Number.isInteger(pid) || pid <= 0) {
return undefined;
}
try {
if (process.platform === "linux") {
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
const commandEnd = stat.lastIndexOf(")");
if (commandEnd < 0) {
return undefined;
}
// Fields after the command name begin at field 3 (state), so field
// 22 (starttime) is index 19.
const startTime = stat
.slice(commandEnd + 1)
.trim()
.split(/\s+/)[19];
const bootId = readFileSync(
"/proc/sys/kernel/random/boot_id",
"utf8",
).trim();
return startTime && bootId ? `linux:${bootId}:${startTime}` : undefined;
}
const result =
process.platform === "win32"
? spawnSync(
"powershell.exe",
[
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
`(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`,
],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
},
)
: spawnSync("ps", ["-p", String(pid), "-o", "lstart="], {
encoding: "utf8",
env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
});
const startTime = result.status === 0 ? result.stdout.trim() : "";
return startTime ? `${process.platform}:${startTime}` : undefined;
} catch {
return undefined;
}
}
const defaultProcessProbe: ProcessProbe = {
isRunning: isProcessRunning,
getStartToken: getProcessStartToken,
@@ -1028,7 +1028,6 @@ export async function handleConnectorUserTurn<
const { prompt, userImages, userFiles } = await buildUserInputMessage(
runtimeInput,
input.userInstructionService,
{ mode: startRequest.mode },
);
try {
await input.client.sendRuntimeSession(
@@ -1118,7 +1117,6 @@ async function runConnectorRuntimeTurnWithRecovery<
const { prompt, userImages, userFiles } = await buildUserInputMessage(
runtimeInput,
input.userInstructionService,
{ mode: startRequest.mode },
);
const request: ChatRunTurnRequest = {
config: startRequest,
@@ -2,7 +2,7 @@
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useCallback, useMemo, useState } from "react";
import { useDialogPalette } from "../tui/hooks/use-theme";
import { palette } from "../tui/palette";
import {
type DialogDismissKey,
isAnyKeyDismiss,
@@ -35,7 +35,6 @@ export function MigrationNoticeContent(
},
) {
const { dialogId, notice, resolve } = props;
const palette = useDialogPalette();
const subscriptionUrl = useMemo(() => getCliSubscriptionUrl(), []);
const [status, setStatus] = useState<string | undefined>();
+1 -4
View File
@@ -17,7 +17,6 @@ import {
} from "./commands/update";
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
import type { TuiStartupTarget } from "./tui/types";
import { filterChatModels } from "./utils/chat-models";
import { getCliBuildInfo } from "./utils/common";
import {
buildCliCompactionConfig,
@@ -1030,9 +1029,7 @@ export async function runCli(): Promise<void> {
`${c.dim}[model-catalog] catalog resolution failed (${message})${c.reset}`,
);
}
const knownModelIds = knownModels
? Object.keys(filterChatModels(knownModels))
: [];
const knownModelIds = knownModels ? Object.keys(knownModels) : [];
const resolvedReasoning = resolveCliReasoning({
thinking: args.thinking,
thinkingExplicitlySet: args.thinkingExplicitlySet,
+1 -26
View File
@@ -3,9 +3,7 @@ import { homedir } from "node:os";
import { basename, resolve } from "node:path";
import {
buildWorkspaceMetadata,
isSkillsToolAvailable,
mergeRulesForSystemPrompt,
readGlobalSettings,
type UserInstructionConfigService,
} from "@cline/core";
import { type AgentMode, buildClineSystemPrompt } from "@cline/shared";
@@ -81,30 +79,9 @@ function resolveMentionPath(filePath: string): string {
return resolve(filePath);
}
/**
* Whether a typed `/skill` command must be textually expanded into the
* prompt. When the session registers the runtime's `skills` tool (its
* description requires the model to invoke it on slash-command references),
* the typed command passes through and the instructions arrive as a tool
* result — keeping the persisted transcript as what the user typed. When the
* tool is unavailable (yolo preset, user toggle), expansion is the only
* delivery path.
*/
export function shouldExpandSkillSlashCommands(mode?: string): boolean {
try {
return !isSkillsToolAvailable({
mode: mode === "plan" || mode === "yolo" ? mode : "act",
disabledToolIds: new Set(readGlobalSettings().disabledTools ?? []),
});
} catch {
return true;
}
}
export async function buildUserInputMessage(
rawPrompt: string,
userInstructionService?: UserInstructionConfigService,
options?: { mode?: string },
): Promise<{
prompt: string;
userImages: string[];
@@ -113,9 +90,7 @@ export async function buildUserInputMessage(
// First, resolve slash commands if the core config service is available.
let prompt = rawPrompt;
if (userInstructionService) {
prompt = userInstructionService.resolveRuntimeSlashCommand(rawPrompt, {
expandSkillCommands: shouldExpandSkillSlashCommands(options?.mode),
});
prompt = userInstructionService.resolveRuntimeSlashCommand(rawPrompt);
}
if (!hasFileMentions(prompt)) {
+1 -3
View File
@@ -277,9 +277,7 @@ export async function runAgent(
prompt: userInput,
userImages,
userFiles,
} = await buildUserInputMessage(prompt, userInstructionService, {
mode: config.mode,
});
} = await buildUserInputMessage(prompt, userInstructionService);
const started = await sessionManager.start({
source: SessionSource.CLI,
config: {
@@ -80,6 +80,7 @@ describe("applyInteractiveModelChange", () => {
}));
const saveProviderSettings = vi.fn(() => ({
version: 1 as const,
modes: {},
providers: {},
modes: {},
}));
+1 -3
View File
@@ -621,9 +621,7 @@ export async function runInteractive(
prompt: userInput,
userImages,
userFiles,
} = await buildUserInputMessage(input, userInstructionService, {
mode,
});
} = await buildUserInputMessage(input, userInstructionService);
const mergedUserImages = [
...(attachments?.userImages ?? []),
...userImages,
+1 -5
View File
@@ -78,11 +78,7 @@ export async function runZen(
prompt: userInput,
userImages,
userFiles,
} = await buildUserInputMessage(prompt, userInstructionService, {
// Zen runs in yolo mode, whose preset has no skills tool — skill
// commands must keep expanding textually.
mode: "yolo",
});
} = await buildUserInputMessage(prompt, userInstructionService);
const startRequest: ChatStartSessionRequest = {
workspaceRoot,
-1
View File
@@ -13,7 +13,6 @@ export function getToolCatalog(
): ToolCatalogEntry[] {
const modelToolSettings = resolveModelToolSettings();
return getCoreBuiltinToolCatalog({
clientType: "cli",
disabledToolIds: resolveDisabledToolNames(),
enabledModelToolIds: new Set(
Object.entries(modelToolSettings)
@@ -169,39 +169,6 @@ describe("slash command registry", () => {
expect(expandUserCommandPrompt("/settings", registry)).toBe("/settings");
});
it("keeps skill commands typed when skill expansion is off, but still wraps workflows", () => {
const registry = buildSlashCommandRegistry({
workflowSlashCommands: [
{
name: "review",
instructions: "Review carefully",
description: "Review files",
kind: "skill",
},
{
name: "release",
instructions: "Run the release workflow",
description: "Release",
kind: "workflow",
},
],
});
const options = { expandSkillCommands: false };
// The skills tool delivers the instructions; the transcript keeps the
// typed command.
expect(
expandUserCommandPrompt("/review this file", registry, options),
).toBe("/review this file");
expect(
expandUserCommandPrompt("please /review this file", registry, options),
).toBe("please /review this file");
// Workflows are not served by the skills tool and keep expanding.
expect(expandUserCommandPrompt("/release now", registry, options)).toBe(
'<user_command slash="release">Run the release workflow</user_command> now',
);
});
it("does not expand commands omitted from a refreshed user-command registry", () => {
const staleRegistry = buildSlashCommandRegistry({
workflowSlashCommands: [
@@ -245,33 +245,19 @@ export function formatSlashCommandAutocompleteValue(
return `/${entry.name} `;
}
export interface ExpandUserCommandPromptOptions {
/**
* Whether matched skill commands are wrapped into the prompt. Pass false
* when the session registers the runtime's skills tool: the typed
* `/skill args` then goes through as-is and the model loads the
* instructions via the tool. Workflows always expand (the tool does not
* serve them). Defaults to true.
*/
expandSkillCommands?: boolean;
}
export function expandUserCommandPrompt(
input: string,
registry: SlashCommandRegistry,
options?: ExpandUserCommandPromptOptions,
): string {
if (input.includes("<user_command")) {
return input;
}
const skipCommand = (command: SlashCommandRegistryEntry): boolean =>
command.kind === "skill" && options?.expandSkillCommands === false;
const expandedSlashCommands = input.replace(
USER_COMMAND_SLASH_PATTERN,
(match, prefix: string, name: string) => {
const command = resolveSlashCommand(registry, name);
if (command?.execution !== "user-command" || skipCommand(command)) {
if (command?.execution !== "user-command") {
return match;
}
return `${prefix}${formatUserCommandBlock(command.instructions, command.name)}`;
@@ -286,11 +272,7 @@ export function expandUserCommandPrompt(
return input;
}
const command = resolveSlashCommand(registry, match[1] ?? "");
if (
!command ||
command.execution !== "user-command" ||
skipCommand(command)
) {
if (!command || command.execution !== "user-command") {
return input;
}
const rest = (match[2] ?? "").trim();
@@ -1,44 +0,0 @@
import { useRenderer } from "@opentui/react";
import { DialogContainerRenderable } from "@opentui-ui/dialog";
import { useDialogState } from "@opentui-ui/dialog/react";
import { useEffect } from "react";
import { useTheme } from "../hooks/use-theme";
import { getDialogSurface } from "../themes";
/**
* Keeps dialog panel backgrounds in sync with the active theme.
*
* The dialog library computes a panel's style once when the dialog opens
* (from the container's dialogOptions), so theme changes made while a dialog
* is open — most visibly the live preview while scrolling the theme picker —
* would leave the panel on the old surface color. This component pushes the
* theme's dialog surface into the container (for dialogs opened later) and
* onto every open dialog renderable (repainting them in place).
*
* Must be mounted inside the DialogProvider. Re-runs when a dialog opens so
* the first dialog after mount is covered too (the container is only added
* to the renderer root after this component's initial effect).
*/
export function DialogThemeSync() {
const renderer = useRenderer();
const theme = useTheme();
const dialogCount = useDialogState((state: { count: number }) => state.count);
const surface = getDialogSurface(theme);
// biome-ignore lint/correctness/useExhaustiveDependencies: dialogCount re-runs the sync when a dialog opens, covering dialogs opened before the container-level option applied (see docblock).
useEffect(() => {
const container = renderer.root
.getChildren()
.find(
(child): child is DialogContainerRenderable =>
child instanceof DialogContainerRenderable,
);
if (!container) return;
container.dialogOptions = { style: { backgroundColor: surface } };
for (const [, dialogRenderable] of container.getDialogRenderables()) {
dialogRenderable.backgroundColor = surface;
}
}, [renderer, surface, dialogCount]);
return null;
}
@@ -8,7 +8,7 @@ import {
formatClineCredits,
isClineAccountAuthErrorMessage,
} from "../../cline-account";
import { useDialogPalette } from "../../hooks/use-theme";
import { palette } from "../../palette";
export type AccountDialogAction =
| "change-model"
@@ -121,7 +121,6 @@ function AccountActionRow(props: {
selected: boolean;
onSelect: () => void;
}) {
const palette = useDialogPalette();
const fg = props.selected ? palette.textOnSelection : undefined;
return (
<box
@@ -139,7 +138,7 @@ function AccountActionRow(props: {
fg={props.selected ? palette.textOnSelection : "gray"}
flexShrink={0}
>
{props.selected ? "" : " "}
{props.selected ? ">" : " "}
</text>
<text fg={fg} flexShrink={0}>
{props.action.label}
@@ -162,7 +161,6 @@ function OrganizationRow(props: {
selected: boolean;
onSelect: () => void;
}) {
const palette = useDialogPalette();
return (
<box
flexDirection="row"
@@ -179,7 +177,7 @@ function OrganizationRow(props: {
fg={props.selected ? palette.textOnSelection : "gray"}
flexShrink={0}
>
{props.selected ? "" : " "}
{props.selected ? ">" : " "}
</text>
<text
fg={props.selected ? palette.textOnSelection : undefined}
@@ -236,7 +234,6 @@ export function AccountDialogContent(
switchAccount,
onAccountChange,
} = props;
const palette = useDialogPalette();
const [state, setState] = useState<AccountState>({
status: "loading",
message: "Loading account details...",
@@ -2,7 +2,7 @@
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useRef, useState } from "react";
import { useDialogPalette } from "../../hooks/use-theme";
import { palette } from "../../palette";
export function AskQuestionContent(
props: ChoiceContext<string | null> & {
@@ -11,7 +11,6 @@ export function AskQuestionContent(
},
) {
const { resolve, dialogId, question, options } = props;
const palette = useDialogPalette();
const [selected, setSelected] = useState(0);
const [inputKey, setInputKey] = useState(0);
const customRef = useRef("");
@@ -2,7 +2,7 @@
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useRef, useState } from "react";
import { useDialogPalette } from "../../hooks/use-theme";
import { palette } from "../../palette";
export type CheckpointRestoreMode = "chat-only" | "chat-and-workspace";
@@ -29,7 +29,6 @@ export function CheckpointConfirmContent(
},
) {
const { resolve, dismiss, dialogId, messagePreview } = props;
const palette = useDialogPalette();
const [selected, setSelected] = useState(0);
const selectedRef = useRef(0);
const selectedMode = OPTIONS[selected]?.value;
@@ -2,7 +2,7 @@
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useMemo, useRef, useState } from "react";
import { useDialogPalette } from "../../hooks/use-theme";
import { palette } from "../../palette";
export interface CheckpointPickerItem {
runCount: number;
@@ -36,7 +36,6 @@ export function CheckpointPickerContent(
},
) {
const { resolve, dismiss, dialogId, items } = props;
const palette = useDialogPalette();
const lastIndex = Math.max(0, items.length - 1);
const [selected, setSelected] = useState(lastIndex);
const selectedRef = useRef(lastIndex);
@@ -3,7 +3,7 @@ import { useTerminalDimensions } from "@opentui/react";
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useMemo, useRef, useState } from "react";
import { useDialogPalette } from "../../hooks/use-theme";
import { palette } from "../../palette";
import {
buildCommandPaletteItems,
type CommandPaletteResult,
@@ -32,7 +32,6 @@ export function CommandPaletteContent(
},
) {
const { resolve, dismiss, dialogId, canForkSession, contentWidth } = props;
const palette = useDialogPalette();
const { height } = useTerminalDimensions();
const [query, setQuery] = useState("");
const [selected, setSelected] = useState(0);
@@ -1,12 +1,12 @@
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useState } from "react";
import { useDialogPalette } from "../../hooks/use-theme";
import type {
InteractiveConfigData,
InteractiveConfigItem,
LoadInteractiveConfigDataOptions,
} from "../../interactive-config";
import { palette } from "../../palette";
import {
getExtDetailFooterText,
getExtDetailRows,
@@ -23,7 +23,6 @@ export function ExtDetailContent(
) => Promise<InteractiveConfigData | undefined>;
},
) {
const palette = useDialogPalette();
const [item, setItem] = useState(props.item);
const [toggleError, setToggleError] = useState<string | undefined>();
@@ -1,7 +1,7 @@
// @jsxImportSource @opentui/react
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useDialogPalette } from "../../hooks/use-theme";
import { palette } from "../../palette";
type HelpRow =
| { kind: "heading"; id: string; text: string }
@@ -255,7 +255,6 @@ const KEY_WIDTH = 20;
export function HelpDialogContent(props: ChoiceContext<void>) {
const { dismiss, dialogId } = props;
const palette = useDialogPalette();
useDialogKeyboard((key) => {
if (
@@ -1,7 +1,7 @@
// @jsxImportSource @opentui/react
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useDialogPalette } from "../../hooks/use-theme";
import { palette } from "../../palette";
import { resolveHubUpdateRequiredKeyAction } from "./hub-update-required-helpers";
export interface HubUpdateRequiredDetails {
@@ -12,7 +12,6 @@ export function HubUpdateRequiredContent(
props: ChoiceContext<boolean> & HubUpdateRequiredDetails,
) {
const { dialogId, dismiss, hubCoreVersion, resolve } = props;
const palette = useDialogPalette();
useDialogKeyboard((key) => {
const action = resolveHubUpdateRequiredKeyAction(key);
@@ -5,7 +5,7 @@ import {
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useState } from "react";
import { useDialogPalette } from "../../hooks/use-theme";
import { palette } from "../../palette";
export interface McpEntry {
name: string;
@@ -70,7 +70,6 @@ export function McpManagerContent(
servers: McpEntry[];
},
) {
const palette = useDialogPalette();
const [selected, setSelected] = useState(0);
const [servers, setServers] = useState(props.servers);
const [changed, setChanged] = useState(false);
@@ -22,7 +22,7 @@ import {
} from "../../../utils/codex-cli";
import open from "../../../utils/open";
import { listLocalProviders } from "../../../utils/provider-catalog";
import { useDialogPalette } from "../../hooks/use-theme";
import { palette } from "../../palette";
import {
getDefaultAwsRegion,
type ProviderConfigValues,
@@ -63,7 +63,6 @@ export function ProviderPickerContent(
props: ChoiceContext<string> & { currentProviderId: string },
) {
const { resolve, dismiss, dialogId, currentProviderId } = props;
const palette = useDialogPalette();
const [providers, setProviders] = useState<ProviderItem[]>([]);
const [search, setSearch] = useState("");
const [selected, setSelected] = useState(0);
@@ -273,7 +272,6 @@ export function UseExistingOrReconfigureContent(
},
) {
const { resolve, dismiss, dialogId, providerName, extraOptions } = props;
const palette = useDialogPalette();
const options: ExistingProviderOption[] = useMemo(
() => [
{ value: "use_existing", label: "Use existing configuration" },
@@ -353,7 +351,6 @@ function ClinePassBrowserPageContent(
url,
openedStatus,
} = props;
const palette = useDialogPalette();
const [status, setStatus] = useState("Opening browser...");
useEffect(() => {
@@ -492,7 +489,6 @@ export function ProviderConfigInputContent(
providerName,
providerSettingsManager,
} = props;
const palette = useDialogPalette();
const config = useMemo(
() => getProviderConfigFields(providerId),
@@ -660,7 +656,6 @@ export function CodexCliStatusContent(
},
) {
const { resolve, dismiss, dialogId, providerName } = props;
const palette = useDialogPalette();
const [status, setStatus] = useState<CodexCliStatus | undefined>();
const [checking, setChecking] = useState(false);
@@ -754,7 +749,6 @@ export function OAuthLoginContent(
providerName,
allowApiKeyFallback,
} = props;
const palette = useDialogPalette();
const [mode, setMode] = useState<"browser" | "device">(
providerId === "cline" ? "device" : "browser",
);
@@ -975,7 +969,6 @@ export function OAuthApiKeyInputContent(
providerName,
providerSettingsManager,
} = props;
const palette = useDialogPalette();
const [value, setValue] = useState("");
const submit = () => {
@@ -3,7 +3,7 @@ import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useMemo, useRef, useState } from "react";
import type { SlashCommandRegistryEntry } from "../../commands/slash-command-registry";
import { useDialogPalette } from "../../hooks/use-theme";
import { palette } from "../../palette";
export const SKILLS_MARKETPLACE_ACTION = "__skills_marketplace__";
export const SKILLS_MARKETPLACE_URL = "https://skills.sh/";
@@ -26,7 +26,6 @@ function matchesFilter(
export function SkillsPickerContent(props: SkillsPickerContentProps) {
const { resolve, dismiss, dialogId, commands } = props;
const palette = useDialogPalette();
const { height, width } = useTerminalDimensions();
const [filter, setFilter] = useState("");
const [selected, setSelected] = useState(0);
@@ -2,7 +2,8 @@ import { useTerminalDimensions } from "@opentui/react";
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useEffect, useRef, useState } from "react";
import { useDialogPalette, useThemeController } from "../../hooks/use-theme";
import { useThemeController } from "../../hooks/use-theme";
import { palette } from "../../palette";
import { getThemeSwatchColors, THEMES } from "../../themes";
const SWATCH_BLOCK = "\u25a0";
@@ -11,7 +12,6 @@ export function ThemePickerContent(props: ChoiceContext<string>) {
const { resolve, dismiss, dialogId } = props;
const { height } = useTerminalDimensions();
const controller = useThemeController();
const palette = useDialogPalette();
const [selected, setSelected] = useState(() => {
const index = THEMES.findIndex(
(theme) => theme.id === controller.selectedThemeId,
@@ -2,7 +2,7 @@ import type { ToolApprovalRequest } from "@cline/shared";
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import type React from "react";
import { useDialogPalette } from "../../hooks/use-theme";
import { palette } from "../../palette";
import {
buildReadFilesKeys,
parseApplyPatchInput,
@@ -139,7 +139,6 @@ export function formatApprovalParams(
export function ToolApprovalContent(
props: ChoiceContext<boolean> & { request: ToolApprovalRequest },
) {
const palette = useDialogPalette();
useDialogKeyboard((key) => {
if (key.name === "y" || key.name === "return") {
props.resolve(true);
@@ -7,8 +7,7 @@ import {
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import "opentui-spinner/react";
import { useDialogPalette } from "../../hooks/use-theme";
import type { DialogPalette } from "../../themes";
import { palette } from "../../palette";
import {
CLINE_MODEL_PICKER_TIER_LABELS,
type ClineModelPickerEntry,
@@ -25,7 +24,7 @@ export {
freeTierDescriptionFor,
} from "./cline-model-entries";
function tagColor(tag: string, palette: DialogPalette): string {
function tagColor(tag: string): string {
if (tag === "FREE") return palette.success;
if (tag === "BEST") return "magenta";
return palette.act;
@@ -59,7 +58,6 @@ export function ClineModelPicker(props: {
currentModelId?: string;
}) {
const { entries, selected, loading, currentModelId } = props;
const palette = useDialogPalette();
if (loading) {
return (
@@ -121,7 +119,7 @@ export function ClineModelPicker(props: {
{tags.map((t) => (
<text
key={t}
fg={isSel ? palette.textOnSelection : tagColor(t, palette)}
fg={isSel ? palette.textOnSelection : tagColor(t)}
flexShrink={0}
>
{t}
@@ -2,8 +2,7 @@
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useDialogPalette } from "../../hooks/use-theme";
import type { DialogPalette } from "../../themes";
import { palette } from "../../palette";
import {
CLINE_MODEL_PICKER_TIER_LABELS,
type ClineModelPickerEntry,
@@ -19,7 +18,7 @@ type ClineModelEntriesState =
| { status: "loaded"; entries: ClineModelPickerEntry[] }
| { status: "error"; message: string };
function tagColor(tag: string, palette: DialogPalette): string {
function tagColor(tag: string): string {
if (tag === "FREE") return palette.success;
if (tag === "BEST") return "magenta";
return palette.act;
@@ -40,7 +39,6 @@ export function ClineModelSelectorContent(
currentProviderName,
entries,
} = props;
const palette = useDialogPalette();
const [selected, setSelected] = useState(0);
const [onProvider, setOnProvider] = useState(false);
@@ -190,7 +188,7 @@ export function ClineModelSelectorContent(
{row.tags.map((t) => (
<text
key={t}
fg={isSel ? palette.textOnSelection : tagColor(t, palette)}
fg={isSel ? palette.textOnSelection : tagColor(t)}
flexShrink={0}
>
{t}
@@ -224,7 +222,6 @@ export function ClineModelSelectorDialogContent(
},
) {
const { dismiss, dialogId, loadEntries } = props;
const palette = useDialogPalette();
const [state, setState] = useState<ClineModelEntriesState>({
status: "loading",
message: "Loading Cline models...",
@@ -3,7 +3,7 @@ import type { Llms } from "@cline/core";
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useMemo, useState } from "react";
import { useDialogPalette } from "../../hooks/use-theme";
import { palette } from "../../palette";
import { ProviderRow } from "./provider-row";
export interface ModelOption {
@@ -65,7 +65,6 @@ export function ModelIdInputContent(
) {
const { resolve, dismiss, dialogId, currentModel, currentProviderName } =
props;
const palette = useDialogPalette();
const [modelId, setModelId] = useState(currentModel);
const [error, setError] = useState("");
const [onProvider, setOnProvider] = useState(false);
@@ -329,7 +328,6 @@ export function ThinkingLevelContent(
},
) {
const { resolve, dismiss, dialogId, modelName, currentLevel } = props;
const palette = useDialogPalette();
const [selected, setSelected] = useState(() => {
const initialLevel = currentLevel === "none" ? "medium" : currentLevel;
const idx = THINKING_LEVELS.findIndex((l) => l.value === initialLevel);
@@ -518,7 +516,6 @@ function CreateCustomModelRow(props: {
onSelect: () => void;
}) {
const { isSelected, dimmed, onSelect } = props;
const palette = useDialogPalette();
const active = isSelected && !dimmed;
const bg = active
? palette.selection
@@ -556,7 +553,6 @@ function ModelRow(props: {
onSelect: (key: string) => void;
}) {
const { model, isSelected, dimmed, isCurrent, onSelect } = props;
const palette = useDialogPalette();
const active = isSelected && !dimmed;
const bg = active
? palette.selection
@@ -1,5 +1,5 @@
// @jsxImportSource @opentui/react
import { useDialogPalette } from "../../hooks/use-theme";
import { palette } from "../../palette";
export function ProviderRow({
providerName,
@@ -8,7 +8,6 @@ export function ProviderRow({
providerName: string;
focused: boolean;
}) {
const palette = useDialogPalette();
return (
<box flexDirection="row" paddingX={1} gap={1}>
<text fg={focused ? palette.selection : "gray"} flexShrink={0}>
@@ -55,6 +55,7 @@ function BashOutput(props: { fullText: string; theme: ResolvedTheme }) {
if (!expanded) {
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse expands optional output.
<box
flexDirection="column"
paddingLeft={2}
@@ -75,6 +76,7 @@ function BashOutput(props: { fullText: string; theme: ResolvedTheme }) {
}
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse collapses output.
<box
flexDirection="column"
paddingLeft={2}
@@ -160,6 +162,7 @@ function EditOutput(props: {
const diffPalette = props.theme.diff;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse toggles the diff.
<box
flexDirection="column"
paddingLeft={2}
@@ -216,6 +219,7 @@ function ApplyPatchOutput(props: {
const diffPalette = props.theme.diff;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse toggles the diff.
<box
flexDirection="column"
paddingLeft={2}
@@ -263,6 +267,7 @@ function GenericOutput(props: { outputSummary: string; fullText?: string }) {
: displayText;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse expands long output.
<box
flexDirection="column"
paddingLeft={2}
@@ -278,6 +283,7 @@ function GenericOutput(props: { outputSummary: string; fullText?: string }) {
}
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse collapses output.
<box
flexDirection="column"
paddingLeft={2}
@@ -304,6 +310,7 @@ export function ToolOutput(props: ToolOutputProps) {
const showDetail =
errorExpanded && presentation.detail !== presentation.summary.trim();
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI box is a terminal renderable, not a DOM element; mouse toggles error details.
<box
flexDirection="column"
paddingLeft={2}
@@ -1,5 +1,4 @@
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { shouldExpandSkillSlashCommands } from "../../runtime/prompt";
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
import type { SlashCommandRegistry } from "../commands/slash-command-registry";
@@ -309,12 +308,6 @@ export function usePromptInputController(input: {
const promptForSubmit = expandUserCommandPrompt(
expandedPrompt,
slashCommandRegistry,
// Skills load through the runtime's skills tool when it is
// available for the current mode; the typed command then goes
// through as-is so the transcript keeps what the user typed.
{
expandSkillCommands: shouldExpandSkillSlashCommands(session.uiMode),
},
);
session.setHasSubmitted(true);
+2 -19
View File
@@ -1,12 +1,6 @@
import { createContext, useContext, useMemo } from "react";
import { createContext, useContext } from "react";
import type { TerminalTheme } from "../palette";
import {
AUTO_THEME_ID,
type DialogPalette,
getDialogPalette,
type ResolvedTheme,
resolveTheme,
} from "../themes";
import { AUTO_THEME_ID, type ResolvedTheme, resolveTheme } from "../themes";
export interface TerminalColors {
background: string | null;
@@ -47,17 +41,6 @@ export function useTheme(): ResolvedTheme {
return controller?.theme ?? resolveTheme(AUTO_THEME_ID, detected);
}
/**
* Theme-following colors for dialog content. Unlike the static `palette`
* constant, this re-resolves whenever the active theme changes, so open
* dialogs repaint live during theme previews (e.g. scrolling the theme
* picker).
*/
export function useDialogPalette(): DialogPalette {
const theme = useTheme();
return useMemo(() => getDialogPalette(theme), [theme]);
}
/**
* Background that adaptive colors (input field, user bubbles, rules) derive
* from: the theme's painted background when set, else the detected one.
-9
View File
@@ -169,15 +169,6 @@ export function getUserMessageBackground(terminalBg: string | null): string {
return liftedFromTerminalBg(terminalBg, BASE_LIFT, 0, 0);
}
// Dialog panels lift the theme background the same way, but a touch softer:
// they cover a large area and sit over a dimmed backdrop, so a smaller step
// already reads as "raised" without washing out the theme's hue.
const DIALOG_SURFACE_LIFT = 0.16;
export function getDialogSurfaceBackground(terminalBg: string | null): string {
return liftedFromTerminalBg(terminalBg, DIALOG_SURFACE_LIFT, 0, 0);
}
export function getModeInputForeground(
mode: string,
terminalBg: string | null,
-2
View File
@@ -23,7 +23,6 @@ import {
} from "../utils/repo-status";
import { buildCheckpointPickerItems } from "./checkpoint-picker-items";
import type { TranscriptScrollHandle } from "./components/chat-message-list";
import { DialogThemeSync } from "./components/dialog-theme-sync";
import {
CheckpointConfirmContent,
type CheckpointRestoreMode,
@@ -1039,7 +1038,6 @@ export function Root(
<TerminalColorsContext value={terminalColors}>
<ThemeProvider initialThemeId={props.initialThemeId}>
<DialogProvider size="medium">
<DialogThemeSync />
<SessionProvider
config={props.config}
initialEntries={initialEntries}
-65
View File
@@ -2,10 +2,7 @@ import { describe, expect, it } from "vitest";
import { diffPalettes, themePalette } from "./palette";
import {
AUTO_THEME_ID,
DEFAULT_DIALOG_SURFACE,
getDialogAccents,
getDialogPalette,
getDialogSurface,
getThemeDefinition,
getThemeModeAccent,
getThemeSwatchColors,
@@ -167,66 +164,4 @@ describe("theme helpers", () => {
expect(getThemeDefinition("gruvbox-dark")?.label).toBe("Gruvbox Dark");
expect(getThemeDefinition("missing")).toBeUndefined();
});
it("getDialogPalette follows the theme's dialog accents", () => {
const dracula = getDialogPalette(resolveTheme("dracula", noDetection));
expect(dracula.act).toBe("#bd93f9");
expect(dracula.selection).toBe("#bd93f9");
expect(dracula.success).toBe("#50fa7b");
expect(dracula.error).toBe("#ff5555");
expect(dracula.textOnSelection).toBe("#000000");
// Light themes fall back to dark accents (dialog surfaces stay dark).
const solarizedLight = getDialogPalette(
resolveTheme("solarized-light", noDetection),
);
expect(solarizedLight.act).toBe(themePalette.dark.act);
for (const definition of THEMES) {
const dialogPalette = getDialogPalette(
resolveTheme(definition.id, noDetection),
);
expect(dialogPalette.selection).toBe(dialogPalette.act);
expect(["#000000", "#ffffff"]).toContain(dialogPalette.textOnSelection);
}
});
it("getDialogSurface lifts dark theme backgrounds and keeps hue", () => {
// Dark themes derive the panel from their own background: a different,
// lighter color than both the background and the neutral default.
for (const id of ["tokyo-night", "dracula", "gruvbox-dark", "nord"]) {
const theme = resolveTheme(id, noDetection);
const surface = getDialogSurface(theme);
expect(surface).toMatch(/^#[0-9a-f]{6}$/i);
expect(surface).not.toBe(theme.background);
expect(surface).not.toBe(DEFAULT_DIALOG_SURFACE);
}
// Auto with no detected background has nothing to derive from.
expect(getDialogSurface(resolveTheme(AUTO_THEME_ID, noDetection))).toBe(
DEFAULT_DIALOG_SURFACE,
);
// Auto on a detected dark terminal lifts the detected background.
expect(
getDialogSurface(
resolveTheme(AUTO_THEME_ID, {
background: "#000000",
foreground: null,
}),
),
).not.toBe(DEFAULT_DIALOG_SURFACE);
// Light themes keep the neutral dark panel (dialog content still uses
// the dark accent fallback, and hardcoded light text must stay legible).
expect(getDialogSurface(resolveTheme("light", noDetection))).toBe(
DEFAULT_DIALOG_SURFACE,
);
expect(getDialogSurface(resolveTheme("solarized-light", noDetection))).toBe(
DEFAULT_DIALOG_SURFACE,
);
// The palette exposes the same surface.
const dracula = resolveTheme("dracula", noDetection);
expect(getDialogPalette(dracula).surface).toBe(getDialogSurface(dracula));
});
});
-48
View File
@@ -1,7 +1,6 @@
import {
diffPalettes,
getDefaultForeground,
getDialogSurfaceBackground,
getTerminalTheme,
hexToOklab,
oklabToHex,
@@ -568,53 +567,6 @@ export function getDialogAccents(theme: ResolvedTheme): ThemeAccents {
return theme.variant === "dark" ? theme.accents : baseAccents.dark;
}
/** Neutral dark panel used when no theme background is available to derive
* a surface from (auto theme on an undetected terminal, light themes). */
export const DEFAULT_DIALOG_SURFACE = "#262626";
/**
* Background for dialog panels. Dark themes get their own background lifted
* one perceptual step (OKLAB), so the panel keeps the theme's hue and reads
* as a raised surface of the same world. Light themes and undetectable
* backgrounds keep the neutral dark panel that matches the dark accent
* fallback in getDialogAccents.
*/
export function getDialogSurface(theme: ResolvedTheme): string {
if (theme.variant !== "dark" || !theme.background) {
return DEFAULT_DIALOG_SURFACE;
}
return getDialogSurfaceBackground(theme.background);
}
/**
* Colors for content rendered on the always-dark dialog surface, following
* the active theme's accents (see getDialogAccents). Mirrors the shape of
* the static `palette` so dialog components can consume it as a drop-in
* replacement that re-resolves on every theme change (including previews).
*/
export interface DialogPalette extends ThemeAccents {
selection: string;
textOnSelection: string;
muted: string;
/** Panel background the dialog content is rendered on. */
surface: string;
}
export function getDialogPalette(theme: ResolvedTheme): DialogPalette {
const accents = getDialogAccents(theme);
const selection = accents.act;
return {
...accents,
selection,
textOnSelection:
relativeLuminance(selection) > WHITE_TEXT_LUMINANCE_CUTOFF
? "#000000"
: "#ffffff",
muted: "gray",
surface: getDialogSurface(theme),
};
}
/** Small color strip rendered next to each entry in the theme picker. */
export function getThemeSwatchColors(definition: ThemeDefinition): string[] {
const variant = definition.variant === "auto" ? "dark" : definition.variant;
+13 -13
View File
@@ -16,8 +16,9 @@ import {
import type { CliCompactionMode, Config } from "../../utils/types";
import { getMcpManagerEntryStatus } from "../components/dialogs/mcp-manager-dialog";
import { resolveModelDisplayName } from "../components/status-bar";
import { useDialogPalette, useThemeController } from "../hooks/use-theme";
import { type DialogPalette, getThemeDefinition } from "../themes";
import { useThemeController } from "../hooks/use-theme";
import { palette } from "../palette";
import { getDialogAccents, getThemeDefinition } from "../themes";
import {
type ConfigAction,
canDeleteConfigFooterRow,
@@ -117,13 +118,11 @@ function getVisibleWindow<T>(
return { items: items.slice(start, end), startIndex: start };
}
function getCompactionModeColor(
mode: CliCompactionMode,
palette: DialogPalette,
): string {
if (mode === "agentic") return palette.success;
return mode === "basic" ? "yellow" : "gray";
}
const COMPACTION_MODE_COLORS: Record<CliCompactionMode, string> = {
agentic: palette.success,
basic: "yellow",
off: "gray",
};
export interface ConfigPanelProps extends ChoiceContext<ConfigAction> {
config: Config;
@@ -410,7 +409,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
const [toggleError, setToggleError] = useState<string | undefined>();
const [navPos, setNavPos] = useState(0);
const themeController = useThemeController();
const palette = useDialogPalette();
const dialogAccents = getDialogAccents(themeController.theme);
const currentThemeLabel =
getThemeDefinition(themeController.selectedThemeId)?.label ?? "Auto";
@@ -824,10 +823,11 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
let valueColor: string;
if (row.id === "mode") {
value = mode === "plan" ? "Plan" : "Act";
valueColor = mode === "plan" ? palette.plan : palette.act;
valueColor =
mode === "plan" ? dialogAccents.plan : dialogAccents.act;
} else if (row.id === "theme") {
value = currentThemeLabel;
valueColor = palette.act;
valueColor = dialogAccents.act;
} else if (row.id === "auto-approve") {
value = autoApprove ? "● on" : "○ off";
valueColor = autoApprove ? palette.success : "gray";
@@ -836,7 +836,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
valueColor = autoUpdateEnabled ? palette.success : "gray";
} else if (row.id === "compaction") {
value = formatCliCompactionMode(compactionMode);
valueColor = getCompactionModeColor(compactionMode, palette);
valueColor = COMPACTION_MODE_COLORS[compactionMode];
} else {
value = verbose ? "● on" : "○ off";
valueColor = verbose ? palette.success : "gray";
+1 -2
View File
@@ -15,7 +15,7 @@ import { listSessions } from "../../session/session";
import { mergeHistoryStatusRows } from "../../utils/history-format";
import { formatUsd } from "../../utils/output";
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
import { useDialogPalette } from "../hooks/use-theme";
import { palette } from "../palette";
import {
buildHistoryFooterText,
HISTORY_EXPORT_OPTIONS,
@@ -98,7 +98,6 @@ function HistoryListContent({
refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS,
registerKeyHandler,
}: HistoryListContentProps) {
const palette = useDialogPalette();
const { width } = useTerminalDimensions();
const [rows, setRows] = useState<SessionHistoryRecord[]>(
() => initialRows ?? [],
@@ -11,7 +11,6 @@ import {
} from "@cline/core";
import { isClineProvider } from "@cline/shared";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isChatProviderModel } from "../../../utils/chat-models";
import {
getCliSubscriptionUrl,
getIndividualPlanFeatures,
@@ -234,7 +233,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const ids = new Set<string>();
for (const result of results) {
if (result.status !== "fulfilled") continue;
for (const m of result.value.models.filter(isChatProviderModel)) {
for (const m of result.value.models) {
if (m.supportsReasoning) ids.add(m.id);
}
}
@@ -284,7 +283,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
providerId,
providerConfig,
);
return models.filter(isChatProviderModel).map(toModelEntry);
return models.map(toModelEntry);
})
.then((models) => {
setModelEntries(models);
@@ -128,31 +128,6 @@ describe("onboarding model helpers", () => {
]);
});
it("keeps non-chat models out of the onboarding model picker", () => {
expect(
toModelEntriesFromKnownModels({
"operation-only-whisper": {
name: "Operation-only Whisper",
operation: "transcription",
},
"whisper-large-v3": {
name: "Whisper Large V3",
modalities: { input: ["audio"], output: ["text"] },
},
"llama-chat": {
name: "Llama Chat",
modalities: { input: ["text"], output: ["text"] },
},
}),
).toEqual([
{
id: "llama-chat",
name: "Llama Chat",
supportsReasoning: false,
},
]);
});
it("formats OAuth provider labels for onboarding status views", () => {
expect(getOAuthProviderLabel("cline")).toBe("Cline");
expect(getOAuthProviderLabel("cline-pass")).toBe("ClinePass");
@@ -1,9 +1,3 @@
import type {
ChatModelModalities,
ModelModality,
ModelOperation,
} from "@cline/shared";
import { isChatProviderModel } from "../../../utils/chat-models";
import { isOpenAICodexCliProvider } from "../../../utils/codex-cli";
import { isOAuthProvider } from "../../../utils/provider-auth";
@@ -157,16 +151,11 @@ export interface ProviderModelItem {
id: string;
name?: string;
supportsReasoning?: boolean;
operation?: ModelOperation;
inputModalities?: ModelModality[];
outputModalities?: ModelModality[];
}
export interface KnownModelInfo {
name?: string;
capabilities?: string[];
operation?: ModelOperation;
modalities?: ChatModelModalities;
}
export function toProviderEntry(provider: ProviderCatalogItem): ProviderEntry {
@@ -196,13 +185,6 @@ export function toModelEntriesFromKnownModels(
): ModelEntry[] {
if (!knownModels) return [];
return Object.entries(knownModels)
.filter(([, info]) =>
isChatProviderModel({
operation: info.operation,
inputModalities: info.modalities?.input,
outputModalities: info.modalities?.output,
}),
)
.map(([id, info]) => ({
id,
name: info.name || id,
-58
View File
@@ -1,58 +0,0 @@
import { describe, expect, it } from "vitest";
import { filterChatModels, isChatProviderModel } from "./chat-models";
describe("chat model filtering", () => {
it("keeps unknown and text-capable catalog models", () => {
const models = {
legacy: { name: "Legacy" },
chat: {
name: "Chat",
modalities: { input: ["text"] as const, output: ["text"] as const },
},
mixed: {
name: "Mixed",
modalities: {
input: ["text", "image"] as const,
output: ["text", "image"] as const,
},
},
};
expect(Object.keys(filterChatModels(models))).toEqual([
"legacy",
"chat",
"mixed",
]);
});
it("removes dedicated transcription and media-generation models", () => {
const models = {
operationOnly: { operation: "speech-generation" as const },
whisper: {
modalities: { input: ["audio"] as const, output: ["text"] as const },
},
tts: {
modalities: { input: ["text"] as const, output: ["audio"] as const },
},
};
expect(filterChatModels(models)).toEqual({});
});
it("filters flattened provider model responses with the same rule", () => {
expect(isChatProviderModel({ operation: "transcription" })).toBe(false);
expect(
isChatProviderModel({
inputModalities: ["audio"],
outputModalities: ["text"],
}),
).toBe(false);
expect(
isChatProviderModel({
inputModalities: ["text", "image"],
outputModalities: ["text"],
}),
).toBe(true);
expect(isChatProviderModel({})).toBe(true);
});
});
-34
View File
@@ -1,34 +0,0 @@
import {
type ChatModelModalities,
isChatCompatibleModel,
type ModelModality,
type ModelOperation,
} from "@cline/shared";
export type ChatCatalogModel = {
readonly operation?: ModelOperation;
readonly modalities?: ChatModelModalities;
readonly [key: string]: unknown;
};
export function filterChatModels<T extends ChatCatalogModel>(
models: Readonly<Record<string, T>>,
): Record<string, T> {
return Object.fromEntries(
Object.entries(models).filter(([, model]) => isChatCompatibleModel(model)),
);
}
export function isChatProviderModel(model: {
readonly operation?: ModelOperation;
readonly inputModalities?: readonly ModelModality[];
readonly outputModalities?: readonly ModelModality[];
}): boolean {
return isChatCompatibleModel({
operation: model.operation,
modalities: {
input: model.inputModalities,
output: model.outputModalities,
},
});
}
+7 -1
View File
@@ -1,6 +1,6 @@
import { dirname, join, normalize } from "node:path";
import { fileURLToPath } from "node:url";
import { ProviderSettingsManager } from "@cline/core";
import { ClientSettingsManager, ProviderSettingsManager } from "@cline/core";
import { buildInviteUrl, resolveClineHubServerOptions } from "../options";
import type { BrowserConfig } from "./types";
@@ -19,6 +19,12 @@ export const cliIndexPath = normalize(
);
export const providerSettingsManager = new ProviderSettingsManager();
export const desktopClientSettingsManager = new ClientSettingsManager({
clientId: "desktop",
});
desktopClientSettingsManager.initializeModesIfMissing(
providerSettingsManager.read().modes,
);
export const browserConfig: BrowserConfig = {
inviteRequired: Boolean(roomSecret),
+22 -3
View File
@@ -17,19 +17,25 @@ import {
type ProviderClient,
type ProviderProtocol,
type ProviderSettings,
parseProviderModeSettings,
readGlobalSettings,
saveLocalProviderOAuthCredentials,
saveLocalProviderSettings,
saveModeSettings,
setAutoUpdateEnabledGlobally,
setTelemetryOptOutGlobally,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import { getClineEnvironmentConfig, ProviderModeSchema } from "@cline/shared";
import {
connectorChannelsPayload,
startConnectorChannel,
stopConnectorChannel,
} from "./connectors";
import { providerSettingsManager, workspaceRoot } from "./deps";
import {
desktopClientSettingsManager,
providerSettingsManager,
workspaceRoot,
} from "./deps";
import {
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
@@ -103,6 +109,7 @@ export async function handleDesktopCommand(
await ensureCustomProvidersLoaded(providerSettingsManager);
return await listLocalProviders(providerSettingsManager, {
isClinePassEnabled: true,
modeSettings: desktopClientSettingsManager.read().modes,
});
}
if (command === "list_provider_models") {
@@ -112,6 +119,18 @@ export async function handleDesktopCommand(
providerSettingsManager.getProviderConfig(provider),
);
}
if (command === "save_mode_settings") {
const mode = ProviderModeSchema.parse(args?.mode);
const settings =
args?.settings == null
? undefined
: parseProviderModeSettings(mode, args.settings);
return await saveModeSettings(
providerSettingsManager,
{ mode, settings },
desktopClientSettingsManager,
);
}
if (command === "save_provider_settings") {
return saveLocalProviderSettings(providerSettingsManager, {
...readProviderSettingsUpdate(args),
@@ -253,7 +272,7 @@ export async function handleDesktopCommand(
return path;
}
if (ROUTINE_SCHEDULE_COMMANDS.has(command)) {
return await handleRoutineScheduleCommand(command, args, workspaceRoot);
return await handleRoutineScheduleCommand(command, args);
}
if (command === "get_process_context") {
return { workspaceRoot, cwd: workspaceRoot };
+2 -11
View File
@@ -2,6 +2,7 @@ import process from "node:process";
import {
ensureCustomProvidersLoaded,
getLocalProviderModels,
isChatProviderModel,
Llms,
listLocalProviders,
loginAndSaveLocalProviderOAuthCredentials,
@@ -9,7 +10,6 @@ import {
normalizeOAuthProvider,
saveLocalProviderSettings,
} from "@cline/core";
import { isChatCompatibleModel } from "@cline/shared";
import type {
WebviewInboundMessage,
WebviewProviderModel,
@@ -88,19 +88,10 @@ export async function loadModels(
providerSettingsManager.getProviderConfig(provider),
);
const models: WebviewProviderModel[] = payload.models
.filter((model) =>
isChatCompatibleModel({
operation: model.operation,
modalities: {
input: model.inputModalities,
output: model.outputModalities,
},
}),
)
.filter(isChatProviderModel)
.map((model) => ({
id: model.id,
name: model.name,
operation: model.operation,
supportsReasoning: model.supportsReasoning,
supportsThinking: model.supportsReasoning,
inputModalities: model.inputModalities,
+17 -27
View File
@@ -27,20 +27,13 @@ function getCommands(): HubScheduleCommandService {
async function clientCommand(
hubCommand: string,
payload?: Record<string, unknown>,
workspaceRoot = process.cwd(),
): Promise<Record<string, unknown>> {
const reply = await getCommands().handleCommand(
{
version: "v1",
clientId: "cline-hub-schedules",
command: hubCommand as never,
payload,
},
{
clientId: "cline-hub-schedules",
workspaceContext: { workspaceRoot, cwd: workspaceRoot },
},
);
const reply = await getCommands().handleCommand({
version: "v1",
clientId: "cline-hub-schedules",
command: hubCommand as never,
payload,
});
if (!reply.ok) {
throw new Error(
reply.error?.message ?? `hub command failed: ${hubCommand}`,
@@ -77,17 +70,14 @@ function asTrimmedStringArray(value: unknown): string[] | undefined {
export async function handleRoutineScheduleCommand(
command: string,
args?: Record<string, unknown>,
workspaceRoot = process.cwd(),
): Promise<unknown> {
const commandHub = (hubCommand: string, payload?: Record<string, unknown>) =>
clientCommand(hubCommand, payload, workspaceRoot);
if (command === "list_routine_schedules") {
const [schedules, activeExecutions, upcomingRuns] = await Promise.all([
commandHub("schedule.list", {
clientCommand("schedule.list", {
limit: toPositiveInt(args?.limit) ?? 200,
}),
commandHub("schedule.active"),
commandHub("schedule.upcoming", { limit: 30 }),
clientCommand("schedule.active"),
clientCommand("schedule.upcoming", { limit: 30 }),
]);
const scheduleRows = Array.isArray(schedules.schedules)
? schedules.schedules
@@ -98,7 +88,7 @@ export async function handleRoutineScheduleCommand(
(schedule as Record<string, unknown>).scheduleId,
);
if (!scheduleId) return undefined;
const reply = await commandHub("schedule.list_executions", {
const reply = await clientCommand("schedule.list_executions", {
scheduleId,
limit: 1,
});
@@ -125,7 +115,7 @@ export async function handleRoutineScheduleCommand(
"createSchedule requires name, timing, prompt, and workspace_root",
);
}
const created = await commandHub("schedule.create", {
const created = await clientCommand("schedule.create", {
name,
...timing,
prompt,
@@ -159,7 +149,7 @@ export async function handleRoutineScheduleCommand(
"updateSchedule requires schedule_id, name, timing, prompt, and workspace_root",
);
}
const reply = await commandHub("schedule.update", {
const reply = await clientCommand("schedule.update", {
scheduleId,
name,
...timing,
@@ -190,25 +180,25 @@ export async function handleRoutineScheduleCommand(
return { schedule: reply.schedule ?? null };
}
if (command === "pause_routine_schedule") {
const reply = await commandHub("schedule.disable", { scheduleId });
const reply = await clientCommand("schedule.disable", { scheduleId });
return { schedule: reply.schedule ?? null };
}
if (command === "resume_routine_schedule") {
const reply = await commandHub("schedule.enable", { scheduleId });
const reply = await clientCommand("schedule.enable", { scheduleId });
return { schedule: reply.schedule ?? null };
}
if (command === "trigger_routine_schedule") {
const existing = await commandHub("schedule.get", { scheduleId });
const existing = await clientCommand("schedule.get", { scheduleId });
if (!existing.schedule)
throw new Error(`schedule not found: ${scheduleId}`);
const reply = await commandHub("schedule.trigger", {
const reply = await clientCommand("schedule.trigger", {
scheduleId,
wait: false,
});
return { execution: reply.execution ?? null };
}
if (command === "delete_routine_schedule") {
const reply = await commandHub("schedule.delete", { scheduleId });
const reply = await clientCommand("schedule.delete", { scheduleId });
return { deleted: reply.deleted === true };
}
throw new Error(`unsupported routine schedule command: ${command}`);
+1 -1
View File
@@ -88,7 +88,7 @@ function summarizeClient(client: TrackedClient): {
normalizedType === "code-sidecar-observer" ||
normalizedType === "code-sidecar-list"
) {
return { key: "code-app", label: "Cline Desktop", name: "Cline Desktop" };
return { key: "code-app", label: "Code App", name: "Code App" };
}
return {
key: client.clientId,
+1 -6
View File
@@ -15,12 +15,7 @@ export type WebviewUsage = {
export type WebviewProviderModel = Pick<
ProviderModel,
| "id"
| "name"
| "operation"
| "supportsReasoning"
| "inputModalities"
| "outputModalities"
"id" | "name" | "supportsReasoning" | "inputModalities" | "outputModalities"
> & {
supportsThinking?: boolean;
};
@@ -1,74 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildProviderModelCatalog } from "./provider-model-catalog";
import type { Provider } from "./provider-schema";
function provider(modelList: NonNullable<Provider["modelList"]>): Provider {
return {
id: "test",
name: "Test",
models: modelList.length,
color: "#000000",
letter: "T",
enabled: true,
modelList,
};
}
describe("buildProviderModelCatalog", () => {
it("keeps legacy and mixed chat models while excluding dedicated media endpoints", () => {
const catalog = buildProviderModelCatalog([
provider([
{ id: "legacy", name: "Legacy" },
{
id: "operation-only-transcription",
name: "Operation-only Transcription",
operation: "transcription",
},
{
id: "mixed",
name: "Mixed",
inputModalities: ["text", "image"],
outputModalities: ["text", "image"],
supportsReasoning: true,
},
{
id: "transcription",
name: "Transcription",
inputModalities: ["audio"],
outputModalities: ["text"],
},
{
id: "image",
name: "Image",
inputModalities: ["text"],
outputModalities: ["image"],
},
{
id: "image-operation",
name: "Image Operation",
operation: "image-generation",
},
]),
]);
expect(catalog.enabledProviderIds).toEqual(["test"]);
expect(catalog.providerModels.test).toEqual(["legacy", "mixed"]);
expect(catalog.providerReasoningModels.test).toEqual(["mixed"]);
});
it("omits enabled providers with no chat-compatible models", () => {
const catalog = buildProviderModelCatalog([
provider([
{
id: "speech",
name: "Speech",
inputModalities: ["text"],
outputModalities: ["audio"],
},
]),
]);
expect(catalog.enabledProviderIds).toEqual([]);
expect(catalog.providerModels.test).toEqual([]);
});
});
@@ -1,6 +1,5 @@
"use client";
import { isChatCompatibleModel } from "@cline/shared";
import { desktopClient } from "@/lib/desktop-client";
import type {
Provider,
@@ -18,14 +17,12 @@ export type ProviderModelCatalog = {
function toModelIds(models: ProviderModel[] | undefined): string[] {
return (models ?? [])
.filter((model) =>
isChatCompatibleModel({
operation: model.operation,
modalities: {
input: model.inputModalities,
output: model.outputModalities,
},
}),
.filter(
(model) =>
(model.inputModalities === undefined ||
model.inputModalities.includes("text")) &&
(model.outputModalities === undefined ||
model.outputModalities.includes("text")),
)
.map((model) => model.id);
}
@@ -34,14 +31,11 @@ function toReasoningModelIds(models: ProviderModel[] | undefined): string[] {
return (models ?? [])
.filter(
(model) =>
model.supportsReasoning &&
isChatCompatibleModel({
operation: model.operation,
modalities: {
input: model.inputModalities,
output: model.outputModalities,
},
}),
(model.inputModalities === undefined ||
model.inputModalities.includes("text")) &&
(model.outputModalities === undefined ||
model.outputModalities.includes("text")) &&
model.supportsReasoning,
)
.map((model) => model.id);
}
@@ -1,4 +1,4 @@
import type { ModelModality, ModelOperation } from "@cline/shared";
import type { ProviderModesSettings } from "@cline/shared/browser";
export interface ProviderModel {
id: string;
@@ -6,11 +6,12 @@ export interface ProviderModel {
supportsAttachments?: boolean;
supportsVision?: boolean;
supportsReasoning?: boolean;
operation?: ModelOperation;
inputModalities?: ModelModality[];
outputModalities?: ModelModality[];
}
export type ModelModality = "text" | "image" | "audio" | "video" | "pdf";
export type ProviderConfigFieldType =
| "text"
| "password"
@@ -68,6 +69,7 @@ export interface ProviderSettingsUpdate {
export interface ProviderCatalogResponse {
providers: Provider[];
settingsPath: string;
modes: ProviderModesSettings;
}
export interface ProviderModelsResponse {
-4
View File
@@ -8,10 +8,6 @@ export default defineConfig({
root: rootDir,
resolve: {
alias: [
{
find: /^@\/(.+)$/,
replacement: resolve(rootDir, "src/webview/src/$1"),
},
{
find: /^@cline\/core$/,
replacement: resolve(rootDir, "../../sdk/packages/core/src/index.ts"),
+8 -45
View File
@@ -1,50 +1,13 @@
# Cline Desktop Changelog
# Cline Code Desktop Changelog
## 0.0.15
## 0.0.14-beta.1
- The app is now called Cline, renamed from Cline Code. Your settings, sessions, and credentials carry over untouched — only the name and icon change
- Refreshed app icons and branding
- Reskinned the first-run onboarding, with an interactive welcome graphic
- Plugins, MCP servers, and Skills are now one Plugins hub with a dedicated Marketplace page
- The composer's model selector now leads with Recommended and Free tiers (Subscribed and Free on ClinePass), labeled by display name with descriptions, instead of an alphabetized list of raw model ids. Provider settings show the same badges and descriptions
- Agents can now create and manage durable todos and one-time or recurring schedules
- Fixed checkpoint restore wedging permanently. Sessions that were never prompted — and persistence-only updates — reported a bogus "running" status, so anything gated on an active turn stayed blocked forever
- Fixed "No sessions found" flashing while session history was still loading
- Fixed the work summary undercounting elapsed time when thinking before a tool call attached to the answer instead of the run
- Fixed the settings gear keeping its hover state while the Account screen is open
- Fixed ClinePass not being recognized as OAuth-managed in the chat credential gate, which asked for credentials it already had
- Fixed copying a user message bringing along its internal envelope
- Fixed multi-line code blocks collapsing onto a single line
- Image, voice, and other non-chat models are no longer offered in chat model pickers
- Fixed `PreToolUse` hook `contextModification` never reaching the model, and `PostToolUse` hook output and `cancel` control being discarded
- Fixed provider-executed tool activity — every tool the Claude Code provider runs inside its own session — being dropped instead of shown
- PowerShell commands now fail fast on the first error instead of flooding output and still reporting success
- Usage now displays the billed gateway cost
- Refreshed the model catalog, which adds AMD, Arcee, Echo, Jalapeno, Kosmik, LLM Gateway, RunInfra, and SCNet as providers and updates model lists, pricing, and per-provider default models across the board
## 0.0.14
- The app now posts native macOS notifications when a task finishes or needs your input, so you can leave Cline working in the background. Configure them under Settings → Notifications.
- Voice input: dictate into the composer with the microphone button and your speech is transcribed as you talk, using the provider and model you have configured.
- Commands stream their output into the transcript as they run instead of appearing all at once when the command exits. Output keeps its terminal colors, is scrollable without being yanked back to the bottom, and a long-running command can be sent to the background with "Proceed while running" so the agent moves on while it finishes.
- Models that support image generation can now produce images during a task, and they render inline in the transcript.
- Finished agent runs collapse into a single "Worked for 4m 12s and made 14 tool calls" summary you can expand, so the final answer stays in view instead of being buried under the working rows.
- Reasoning traces and tool rows now open and close with an animation instead of snapping, and respect your reduced-motion setting.
- Redesigned the question card the agent shows when it needs a decision: options are selected explicitly and submitted with a button, multiple-choice questions are supported, and there are arrow-key and AZ shortcuts. Internal request IDs, iteration counts, and timestamps no longer appear on the card.
- The Web search toggle in Settings now explains that only providers with built-in web search honor it, and shows which of your connected providers are ready to use it — or warns you, with a link to Models, when none of them are.
- Refreshed assistant markdown — chat-scaled headings, quieter code blocks with a hover copy button, and table cards — now rendered through the same pipeline as the rest of Cline, so the desktop app and the cloud dashboard finally look alike.
- Message hover actions float over the transcript instead of reserving blank space under every message, so conversations pack more tightly.
- Restyled session hover cards: they open immediately, drop the duplicated ID and updated time, and no longer animate as you move down the list.
- There is now a separate "Cline Code Beta" app that installs side by side with this one and tracks the experimental branch. It identifies itself as beta in the sidebar, Settings, window title, and tray, so you always know which build you are in.
- Fixed turns that settle through the event stream — queued prompts, and the first prompt of a fresh session — staying stuck on the streaming shimmer with no final output, healing only when you sent another message. The transcript now reconciles against the saved history as soon as the turn ends.
- Fixed sessions being given the Yolo-mode system prompt whenever auto-approve was on, even though the runtime was started in Act mode. Auto-approval is now an independent tool policy and no longer changes the advertised mode.
- `/skill` and `/workflow` commands no longer dump the whole skill body into the chat as your message. Your typed command stays as typed, the model loads the instructions through the skills tool, and sessions are no longer titled with the first line of a skill's markdown. Sessions saved before this fix render compactly too.
- Fixed command execution breaking for an entire session when a model emitted a full command line with no separate arguments — anything containing a space failed with `ENOENT`.
- Restoring a checkpoint now trims the saved transcript too, so the chat no longer keeps showing turns whose file changes were just reverted.
- Gemini custom base URLs work again, including host-root values saved before the SDK migration and proxy roots like `http://localhost:4000/gemini`, which were silently missing the API version segment and 404ing.
- LiteLLM input token limits reported by the server are preserved instead of being replaced with a 128K default.
- Fixed misaligned columns in the Usage table, and added a See More link to the full usage dashboard.
- Fixed routine dialog dropdowns not responding to mouse clicks.
- First beta release. Cline Code Beta installs side by side with the stable app so you can compare the two, and updates automatically from its own beta channel — stable installs are unaffected.
- Cloud sessions (preview): run sessions in Cline's cloud straight from the desktop app. Connect GitHub during onboarding, pick a repository and branch, and hand sessions off between devices — transcripts, approvals, and queued prompts stay in sync, and you can rename cloud sessions and switch models mid-session. Turn it on with the Cloud sessions toggle in Settings.
- Avatar overlay (preview): a floating desktop companion that reacts to what your sessions are doing.
- Onboarding now includes a GitHub integration step.
- Early proof of concept for running sessions in SSH remote environments.
- Includes everything from the upcoming stable release: microphone voice input in the composer, model-driven image generation, redesigned question prompts, animated reasoning and tool disclosures, and session list polish.
## 0.0.13
+3 -3
View File
@@ -1,7 +1,7 @@
# Desktop Experimental Branch & Beta Channel
How experimental desktop features are developed on the `desktop-experimental`
branch, shipped to users as **Cline Beta**, and graduated into `main`.
branch, shipped to users as **Cline Code Beta**, and graduated into `main`.
The release mechanics (workflow internals, secrets) live in
[`.github/workflows/desktop-publish.yml`](../../../.github/workflows/desktop-publish.yml)
and the `publish-desktop` skill
@@ -12,8 +12,8 @@ this doc is the process.
The beta is a **separate app**, not a mode of the stable app:
- Product name `Cline Beta`, bundle identifier `bot.cline.app.beta`
(stable is `Cline` / `bot.cline.app`) — set by
- Product name `Cline Code Beta`, bundle identifier `bot.cline.app.beta`
(stable is `Cline Code` / `bot.cline.app`) — set by
[`src-tauri/tauri.beta.conf.json`](./src-tauri/tauri.beta.conf.json), which
is layered over `tauri.release.conf.json` at build time.
- Both apps install and run **side by side**, so people can compare beta
+66 -4
View File
@@ -6,9 +6,8 @@ Tauri desktop shell + Bun sidecar backend + Next.js UI for running and inspectin
From `apps/examples/desktop-app/`:
- `bun run dev:headless` - Next.js UI (`http://localhost:3125`) and sidecar backend with a fresh shared approval credential
- `bun run dev:web` - Next.js UI only (approval-gated tools require `dev:headless` or the native app)
- `bun run dev:sidecar` - sidecar backend only (approval-gated tools require `dev:headless` or the native app)
- `bun run dev:web` - Next.js UI only (`http://localhost:3125`)
- `bun run dev:sidecar` - sidecar backend only
- `bun run dev` - Tauri desktop dev
- `bun run build` - build web assets
- `bun run build:sidecar` - build the Bun sidecar bundle
@@ -31,6 +30,59 @@ API keys, `JAVA_HOME`-style tool roots) are not pulled in. Set
`CLINE_SIDECAR_SKIP_SHELL_PATH=1` to disable. Implementation and details:
[`sidecar/shell-path.ts`](./sidecar/shell-path.ts).
## SSH Remote Environments (v0)
Open **Settings → Remote** to add and test an SSH host. Saving or testing a
profile does not activate it. From the welcome chat, open the environment
selector beside the workspace picker and choose the saved host; that selection
starts the SSH connection at the remote user's home directory. Choose **Add
project…** from the normal workspace selector to browse that machine and select
a project, or choose **Local** in the environment selector to disconnect. Recent
and last-used workspaces are remembered separately for each SSH host and for
the local machine.
SSH config aliases are supported. Leave **Port** blank to use the alias's SSH
configuration (including its configured port), or enter a port to override it.
The desktop keeps its webview and native integration local; only the
authenticated Cline Hub protocol is forwarded through SSH. Agent tools,
workspace discovery, Git metadata, and session persistence therefore run on the
SSH host, while approvals and live session events return to the desktop.
The desktop stores host metadata at
`~/.cline/data/settings/remote-environments.json` with mode `0600`. It stores an
identity-file path, never private-key contents. On first connect it uploads a
content-addressed, branch-matched, self-contained Hub helper under
`~/.cline/code/remote/`, binds the Hub to remote loopback, and forwards it to a
random local loopback port. Linux x64 and arm64 helpers are bundled by
`bun run build:sidecar:bin`; 32-bit Raspberry Pi operating systems are not
supported in v0. The current helper is roughly 110 MB because it includes its
own runtime. It is copied once per matching desktop build and cached, with no
`apt`, `npm`, root access,
global CLI install, or public Hub port. Disconnecting stops the desktop-owned
remote Hub but leaves the helper cached for a faster reconnect. The helper
imports the remote login-shell `PATH`, so user-installed Git, GitHub CLI, and
MCP executables remain visible.
The desktop uses its own discovery record, so an existing Cline CLI/Hub on the
same account is neither replaced nor stopped. Both Hub processes can coexist
while the desktop is connected; this isolation is intentional for the proof of
concept so a branch-matched desktop helper cannot disrupt another Cline build.
v0 intentionally leaves file attachments and opening a remote file in a local
editor disabled. Text, images, file mentions/search, Git branch operations,
session history, and remote agent tools are supported. The current desktop
provider access/API token is sent through the authenticated tunnel for the
session; reusable OAuth refresh credentials are not copied into remote provider
settings.
For a real SSH acceptance run, `scripts/verify-ssh-poc.ts` accepts
`CLINE_SSH_TEST_HOST`, `CLINE_SSH_TEST_USER`, `CLINE_SSH_TEST_KEY`,
`CLINE_SSH_TEST_WORKSPACE`, and `CLINE_SSH_TEST_HELPER`. It starts a remote
connection at the SSH user's home, starts an agent session in the test
workspace with the selected desktop provider, asks the agent to read
`REMOTE_MARKER.txt`, then verifies the session appears in remote history and
that its messages can be read back.
## Web Visual System
The framework-neutral color, typography, radius, and navigation contract lives
@@ -56,7 +108,7 @@ lost: the `desktop-latest` release/tag (its feed URL is baked into shipped
apps) and the updater private key (`TAURI_SIGNING_PRIVATE_KEY` — without it,
shipped apps can't verify new updates).
There is also a beta channel ("Cline Beta", a separate app that installs
There is also a beta channel ("Cline Code Beta", a separate app that installs
side by side with stable) cut from the `desktop-experimental` branch and
served by the rolling `desktop-beta` release — the same never-delete rule
applies to it. The experimental-branch process and beta release flow live in
@@ -142,6 +194,16 @@ Desktop transport envelope:
## Data + Storage
- Session artifacts are written under `~/.cline/data/sessions/<sessionId>/` (or `CLINE_SESSION_DATA_DIR`).
- Desktop avatar packages live under `~/.cline/avatars/<avatar-name>/`. Each package
contains a v2 `spritesheet.webp` (or PNG) and either an `avatar.json` or
`pet.json` manifest with
`id`, `displayName`, `description`, `spriteVersionNumber: 2`, and
`spritesheetPath`. If both manifests exist, `avatar.json` takes precedence.
The bundled Cline Bot avatar is selected and enabled by default, with Mom also
available as a bundled option.
Visibility and the selected installed avatar are configured independently under
**Settings → General → Desktop avatar**; both values are stored in
`~/.cline/avatars/selected.json`.
- Canonical replay/export artifact: `<sessionId>.messages.json`.
- `<sessionId>.messages.json` is expected to contain ordered messages plus assistant `modelInfo` and `metrics` (including cache token fields when provided by the model runtime).
- `<sessionId>.hooks.jsonl` is observability/debug telemetry and should not be required for normal history replay/export flows.
+9 -9
View File
@@ -1,15 +1,13 @@
{
"name": "@cline/code",
"version": "0.0.15",
"version": "0.0.14-beta.1",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
"predev:web": "bun run build:ui",
"dev:web": "next dev webview -p 3125 --turbo",
"dev:sidecar": "bun run sidecar/index.ts",
"predev:headless": "bun run build:ui",
"dev:headless": "bun run scripts/dev-headless.ts",
"dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
"dev": "tauri dev",
"prebuild": "bun run build:ui",
"build": "bun run bun.mts",
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
@@ -28,15 +26,19 @@
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
},
"dependencies": {
"@ai-sdk/gateway": "4.0.31",
"@ai-sdk/google": "4.0.44",
"@ai-sdk/openai": "4.0.41",
"@ai-sdk/react": "4.0.44",
"@base-ui/react": "^1.2.0",
"@cline/core": "workspace:*",
"@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",
@@ -68,9 +70,8 @@
"@shikijs/themes": "^4.2.0",
"@streamdown/cjk": "^1.0.3",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-notification": "^2.0.0",
"ai": "^7.0.58",
"autoprefixer": "^10.4.20",
"ansi-to-react": "^6.2.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.1.1",
@@ -80,7 +81,6 @@
"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",
@@ -92,7 +92,7 @@
"streamdown": "^2.5.0",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"zod": "^3.24.1"
"zod": "^3.25.76"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
@@ -38,22 +38,54 @@ const sidecarOutfile = (targetTriple: string): string => {
return `./src-tauri/bin/code-sidecar-${targetTriple}${extension}`;
};
const buildSidecar = async (targetTriple: string): Promise<string> => {
const outfile = sidecarOutfile(targetTriple);
const buildSidecar = async (
targetTriple: string,
outfile = sidecarOutfile(targetTriple),
entrypoint = "./sidecar/index.ts",
minify = false,
): Promise<string> => {
const bunTarget = resolveBunCompileTarget(targetTriple);
// Telemetry config must be inlined into the compiled binary: a packaged
// app launched from Finder/the Dock has no OTEL_* env at runtime, so
// without this the sidecar silently ships with telemetry disabled.
// Verify with `<binary> --telemetry-selfcheck` after building.
const defines = telemetryDefineArgs();
const optimizationArgs = minify ? ["--minify"] : [];
// A compiled Bun executable otherwise reads .env and bunfig.toml from its
// launch directory before our entrypoint runs. Remote helpers are launched
// from an SSH user's home directory, so that behavior can both make the
// helper fail on an unrelated dotenv file and leak workspace credentials
// into the Hub process. Packaged binaries must depend only on their explicit
// process environment and compiled configuration.
const runtimeIsolationArgs = [
"--no-compile-autoload-dotenv",
"--no-compile-autoload-bunfig",
];
if (bunTarget) {
await $`bun build ./sidecar/index.ts --compile --target=${bunTarget} ${defines} --outfile ${outfile}`;
await $`bun build ${entrypoint} --compile --target=${bunTarget} ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`;
} else {
await $`bun build ./sidecar/index.ts --compile ${defines} --outfile ${outfile}`;
await $`bun build ${entrypoint} --compile ${runtimeIsolationArgs} ${optimizationArgs} ${defines} --outfile ${outfile}`;
}
return outfile;
};
// SSH environments run the same Hub build as the desktop in a dedicated
// bootstrap/daemon binary. It intentionally excludes the desktop HTTP server,
// command router, and UI backend. Linux x64 and arm64 cover common SSH hosts.
const buildRemoteHelpers = async (): Promise<void> => {
for (const targetTriple of [
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
]) {
await buildSidecar(
targetTriple,
`./src-tauri/bin/remote-helpers/code-sidecar-${targetTriple}`,
"./sidecar/remote-helper.ts",
true,
);
}
};
// Tauri's universal-apple-darwin pseudo-target lipos the Rust binary itself
// but expects sidecars (externalBin) to already be fat binaries named
// `<name>-universal-apple-darwin`, so build both slices and merge them here.
@@ -68,12 +100,13 @@ const buildUniversalMacSidecar = async (): Promise<void> => {
const main = async () => {
const targetTriple = await resolveTargetTriple();
await $`mkdir -p src-tauri/bin`;
await $`mkdir -p src-tauri/bin src-tauri/bin/remote-helpers`;
if (targetTriple === "universal-apple-darwin") {
await buildUniversalMacSidecar();
return;
} else {
await buildSidecar(targetTriple);
}
await buildSidecar(targetTriple);
await buildRemoteHelpers();
};
main().catch((error: unknown) => {
@@ -1,64 +0,0 @@
import { randomUUID } from "node:crypto";
import { createServer } from "node:net";
const approvalToken = randomUUID();
const children: ReturnType<typeof Bun.spawn>[] = [];
async function reserveAvailablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("Failed to reserve a sidecar port"));
return;
}
server.close((error) =>
error ? reject(error) : resolve(address.port),
);
});
});
}
function spawn(command: string[], env: Record<string, string>) {
const child = Bun.spawn(command, {
cwd: import.meta.dir + "/..",
env: { ...process.env, ...env },
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
});
children.push(child);
return child;
}
function stopChildren(): void {
for (const child of children) {
if (!child.killed) child.kill();
}
}
process.on("SIGINT", stopChildren);
process.on("SIGTERM", stopChildren);
async function main(): Promise<void> {
const sidecarPort = await reserveAvailablePort();
const endpoint = `ws://127.0.0.1:${sidecarPort}/transport?approval_token=${approvalToken}`;
const sidecar = spawn(["bun", "run", "sidecar/index.ts"], {
CLINE_SIDECAR_APPROVAL_TOKEN: approvalToken,
CLINE_SIDECAR_PORT: String(sidecarPort),
});
const web = spawn(
["bun", "run", "next", "dev", "webview", "-p", "3125", "--turbo"],
{ NEXT_PUBLIC_SIDECAR_WS_ENDPOINT: endpoint },
);
const exitCode = await Promise.race([sidecar.exited, web.exited]);
stopChildren();
await Promise.allSettled(children.map((child) => child.exited));
process.exit(exitCode);
}
void main();
@@ -121,7 +121,7 @@ const main = () => {
const notes = notesFile
? readFileSync(notesFile, "utf8").trim()
: `Cline v${version}`;
: `Cline Code v${version}`;
const manifest = buildUpdateManifest({
version,
@@ -15,7 +15,7 @@ const BOOLEAN_FLAGS = new Set(["--allow-unsigned-mac", "--skip-build"]);
const VALUE_FLAGS = new Set(["--platform", "--target"]);
const VALID_FLAGS = [...BOOLEAN_FLAGS, ...VALUE_FLAGS];
const APP_NAME = "Cline";
const APP_NAME = "Cline Code";
const APP_ROOT = path.resolve(import.meta.dir, "..");
const BUNDLE_ROOT = path.join(
APP_ROOT,
@@ -65,6 +65,18 @@ describe("telemetryDefineArgs", () => {
expect(withKeys["process.env.ERROR_SERVICE_API_KEY"]).toBe('"ek"');
});
it("inlines the cloud-agents toggle only when set at build time", () => {
const withoutToggle = defineMap(telemetryDefineArgs({}));
expect(withoutToggle).not.toHaveProperty(
"process.env.CLINE_CODE_CLOUD_AGENTS",
);
const withToggle = defineMap(
telemetryDefineArgs({ CLINE_CODE_CLOUD_AGENTS: "1" }),
);
expect(withToggle["process.env.CLINE_CODE_CLOUD_AGENTS"]).toBe('"1"');
});
it("JSON-escapes values so headers with quotes survive the define", () => {
const defines = defineMap(
telemetryDefineArgs({
@@ -15,6 +15,9 @@ const OPTIONAL_SECRET_ENV_VARS = [
"ERROR_SERVICE_API_KEY",
] as const;
/** Optional build-time overrides for packaged dogfood builds. */
const OPTIONAL_FEATURE_ENV_VARS = ["CLINE_CODE_CLOUD_AGENTS"] as const;
/**
* Every env var `getTelemetryBuildTimeConfig` reads
* (sdk/packages/shared/src/services/telemetry-config.ts). Always inlined,
@@ -42,7 +45,10 @@ export function telemetryDefineArgs(
const define = (name: string, value: string) => {
args.push("--define", `process.env.${name}=${JSON.stringify(value)}`);
};
for (const name of OPTIONAL_SECRET_ENV_VARS) {
for (const name of [
...OPTIONAL_SECRET_ENV_VARS,
...OPTIONAL_FEATURE_ENV_VARS,
]) {
const value = env[name];
if (value) {
define(name, value);
@@ -0,0 +1,176 @@
import { mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
ClineCore,
ProviderSettingsManager,
RuntimeOAuthTokenManager,
resolveProviderApiKeyFromSettings,
SessionSource,
toProviderConfig,
} from "@cline/core";
import { RemoteEnvironmentService } from "../sidecar/remote-environments";
const required = (name: string): string => {
const value = process.env[name]?.trim();
if (!value) throw new Error(`${name} is required`);
return value;
};
async function main(): Promise<void> {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "cline-ssh-proof-"));
const service = new RemoteEnvironmentService({
profilesPath: join(temporaryDirectory, "remote-environments.json"),
helperBinaryPath: required("CLINE_SSH_TEST_HELPER"),
knownHostsPath: join(temporaryDirectory, "known_hosts"),
commandTimeoutMs: 60_000,
uploadTimeoutMs: 5 * 60_000,
});
let core: ClineCore | undefined;
try {
const helperPath = required("CLINE_SSH_TEST_HELPER");
const workspaceRoot = required("CLINE_SSH_TEST_WORKSPACE");
const profile = await service.upsert({
name: "SSH proof host",
host: required("CLINE_SSH_TEST_HOST"),
user: process.env.CLINE_SSH_TEST_USER?.trim() || undefined,
identityFile: required("CLINE_SSH_TEST_KEY"),
});
const connection = await service.connect(profile.id);
const marker = await service.run(profile.id, {
command: "sed",
args: ["-n", "1p", "REMOTE_MARKER.txt"],
cwd: workspaceRoot,
});
const providerSettings = new ProviderSettingsManager();
const stored = providerSettings.read();
const providerId = stored.lastUsedProvider;
if (!providerId)
throw new Error("No configured desktop provider is available");
const settings = providerSettings.getProviderSettings(providerId);
if (!settings)
throw new Error(`No settings found for provider ${providerId}`);
const modelId = settings.model || "meta/muse-spark-1.2";
const providerConfig = {
...toProviderConfig(
{ ...settings, model: modelId },
{ includeKnownModels: false },
),
};
delete providerConfig.refreshToken;
const oauth = await new RuntimeOAuthTokenManager({
providerSettingsManager: providerSettings,
}).resolveProviderApiKey({ providerId });
const apiKey =
oauth?.apiKey ||
resolveProviderApiKeyFromSettings(providerSettings, providerId);
if (!apiKey)
throw new Error(`No credential found for provider ${providerId}`);
core = await ClineCore.create({
clientName: "cline-code",
backendMode: "remote",
remote: {
endpoint: connection.endpoint,
authToken: connection.authToken,
workspaceRoot: connection.workspaceRoot,
cwd: connection.workspaceRoot,
clientType: "code-sidecar-ssh",
},
});
const eventNames: string[] = [];
const unsubscribe = core.subscribe((event) => {
eventNames.push(event.type);
});
const started = await core.start({
config: {
providerId,
modelId,
apiKey,
providerConfig,
workspaceRoot,
cwd: workspaceRoot,
systemPrompt: "",
mode: "act",
enableTools: true,
enableSpawnAgent: false,
enableAgentTeams: false,
},
source: SessionSource.DESKTOP,
interactive: true,
toolPolicies: { "*": { autoApprove: true } },
});
const result = await core.send({
sessionId: started.sessionId,
prompt:
"Read REMOTE_MARKER.txt from this workspace with the file-reading tool, then reply with its exact contents. Do not change any files.",
});
const sessions = await core.list(20, { hydrate: false });
const messages = await core.readMessages(started.sessionId);
unsubscribe();
await core.dispose("desktop_ssh_proof_reconnect");
core = undefined;
await service.disconnect(profile.id);
const reconnected = await service.connect(profile.id);
core = await ClineCore.create({
clientName: "cline-code",
backendMode: "remote",
remote: {
endpoint: reconnected.endpoint,
authToken: reconnected.authToken,
workspaceRoot: reconnected.workspaceRoot,
cwd: reconnected.workspaceRoot,
clientType: "code-sidecar-ssh",
},
});
const sessionsAfterReconnect = await core.list(20, { hydrate: false });
const messagesAfterReconnect = await core.readMessages(started.sessionId);
const resultText = result?.text ?? "";
const report = {
connected: true,
remote: `${connection.platform}/${connection.arch}`,
connectionRoot: connection.workspaceRoot,
workspaceRoot: started.manifest.workspace_root,
sessionId: started.sessionId,
listContainsSession: sessions.some(
(session) => session.sessionId === started.sessionId,
),
messageCount: messages.length,
reconnected: true,
reconnectListContainsSession: sessionsAfterReconnect.some(
(session) => session.sessionId === started.sessionId,
),
reconnectMessageCount: messagesAfterReconnect.length,
helperBytes: (await stat(helperPath)).size,
sshMarker: marker.stdout.trim(),
agentText: resultText,
agentObservedMarker: resultText.includes("remote workspace proof"),
eventNames: [...new Set(eventNames)],
};
if (
report.sshMarker !== "remote workspace proof" ||
!report.agentObservedMarker ||
!report.listContainsSession ||
!report.reconnectListContainsSession ||
report.messageCount < 2 ||
report.reconnectMessageCount < 2 ||
!report.eventNames.includes("agent_event")
) {
throw new Error(`SSH proof failed: ${JSON.stringify(report)}`);
}
process.stdout.write(`${JSON.stringify(report)}\n`);
} finally {
await core?.dispose("desktop_ssh_proof_complete");
await service.dispose();
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
void main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
@@ -17,7 +17,11 @@ sidecar/
├── server.ts # Bun HTTP server + WebSocket handlers
├── context.ts # SidecarContext type and factory
├── commands.ts # Command router
├── chat-session.ts # Shared-Hub chat session adapter
├── chat-session.ts # Shared-Hub chat session adapter (local + cloud routing)
├── cloud-sessions.ts # Cloud session REST client + Hub-proxy manager
├── cline-auth.ts # Refresh-aware Cline auth token resolution
├── desktop-settings.ts # Desktop-owned settings (cloud sessions opt-in)
├── feature-flags.ts # Cloud sessions gate (env override + settings toggle)
├── session-data/ # Shared discovery, messages, artifacts, search helpers
├── paths.ts # Path resolution
├── types.ts # Shared types
@@ -49,7 +53,7 @@ const sessionManager = await ClineCore.create({
workspaceRoot,
cwd: workspaceRoot,
clientType: "code-sidecar",
displayName: "Cline Desktop sidecar",
displayName: "Code App sidecar",
},
capabilities: {
requestToolApproval: async (request) => {
@@ -89,7 +93,7 @@ online:
```typescript
const pendingApprovals = new Map<string, {
resolve: (result: ToolApprovalResult) => void;
resolve: (result: ToolApprovalResult) => void | Promise<void>;
request: ToolApprovalRequest;
}>();
@@ -97,6 +101,10 @@ const pendingApprovals = new Map<string, {
// When frontend responds → resolve promise
```
Cloud sessions route approvals the same way, but the resolver forwards the
response to the sandbox Hub (`approval.respond`), which is why `resolve` may
be async.
### 3. Provider Management — Direct ProviderSettingsManager
```typescript
@@ -138,7 +146,7 @@ Supported commands:
| Command | Implementation |
|---------|---------------|
| `chat_session_command` | shared Hub through `ClineCore` |
| `chat_session_command` | shared Hub through `ClineCore`; cloud sessions route to `CloudSessionManager` |
| `list_provider_catalog` | `ProviderSettingsManager` + `listLocalProviders` |
| `list_provider_models` | `getLocalProviderModels` |
| `save_voice_input_settings` | validates and persists the selected transcription provider/model |
@@ -147,26 +155,36 @@ Supported commands:
| `save_provider_settings` | `saveLocalProviderSettings` |
| `add_provider` | `addLocalProvider` |
| `run_provider_oauth_login` | `loginLocalProvider` |
| `list_chat_sessions` | `SqliteSessionStore` + file discovery |
| `list_discovered_sessions` | Merged discovery |
| `read_session_messages` | Session data readers |
| `list_chat_sessions` | `SqliteSessionStore` + file discovery, merged with cloud sessions (2s budget) |
| `list_discovered_sessions` | Merged discovery (local + cloud) |
| `read_session_messages` | Session data readers; cloud sessions read through the sandbox Hub |
| `read_session_hooks` | Session data readers |
| `delete_chat_session` | `SqliteSessionStore.delete` + file cleanup |
| `update_chat_session_title` | `resolveSessionBackend().updateSession` |
| `delete_chat_session` | `SqliteSessionStore.delete` + file cleanup; cloud sessions also delete the sandbox |
| `update_chat_session_title` | `resolveSessionBackend().updateSession`; cloud sessions PATCH the cloud API |
| `get_feature_flags` | `isCloudAgentsEnabled()` (env override + settings toggle) |
| `get_desktop_settings` | `readDesktopSettings()` |
| `set_cloud_sessions_enabled` | `setCloudSessionsEnabled()` + `feature_flags_changed` broadcast |
| `list_cloud_repositories` | `CloudSessionManager.listRepositories()` (GitHub integration) |
| `list_cloud_branches` | `CloudSessionManager.listBranches()` (paginated) |
| `list_mcp_servers` | Direct file I/O |
| `authorize_mcp_server_oauth` | Explicit Connect action → cancellable `authorizeMcpServerOAuth` + system browser |
| `cancel_mcp_server_oauth` | Cancel the pending MCP OAuth callback wait |
| `upsert_mcp_server` | Direct file I/O |
| `delete_mcp_server` | Direct file I/O |
| `get_git_branch` | async `execFile("git", ...)` |
Realtime mode sessions expose only one browser-callable tool, `run_cline`, when
the selected realtime model supports tool calling. The webview implements that
tool by sending the request through the active Cline chat session and returning
its persisted result to the realtime provider for playback. Cline remains the
owner of workspace context, agent tools, MCP, approvals, and session history;
provider credentials remain in the sidecar.
| `list_git_branches` | async `execFile("git", ...)` |
| `checkout_git_branch` | async `execFile("git", ...)` |
| `search_workspace_files` | `getFileIndex` |
| `get_process_context` | In-memory context |
| `poll_tool_approvals` | In-memory pending map |
| `respond_tool_approval` | In-memory promise resolution |
| `poll_ask_questions` | In-memory pending map |
| `respond_ask_question` | In-memory promise resolution |
| `list_routine_schedules` | shared Hub schedule commands |
| `list_user_instruction_configs` | Direct core API |
| `pick_workspace_directory` | OS native dialog |
@@ -175,8 +193,7 @@ Supported commands:
## Dev Workflow
```bash
bun run dev:headless # Start sidecar and Next.js with a fresh shared approval credential
bun run dev:sidecar # Start only the sidecar (no browser approval surface)
bun run dev:web # Start only Next.js (no authenticated approval connection)
bun run dev:sidecar # Start sidecar on port 3126
bun run dev:web # Start Next.js on port 3125
bun run dev # Both concurrently
```
@@ -13,31 +13,53 @@ import { materializeUserFiles } from "./attachments";
import {
buildSessionConnectionUpdate,
consumeWorkspaceMetadata,
copySessionGeneratedArtifacts,
handleChatSessionCommand,
hasProviderChanged,
mergeSessionConfig,
prewarmWorkspaceMetadata,
resolveDesktopSessionMode,
rewriteDesktopTeamPrompt,
shouldUpdateSessionConnection,
WORKSPACE_METADATA_PREWARM_TTL_MS,
} from "./chat-session";
import { handleCoreSessionEvent } from "./context";
import type { SidecarContext } from "./types";
describe("resolveDesktopSessionMode", () => {
it("does not turn auto-approved Act sessions into Yolo sessions", () => {
expect(
resolveDesktopSessionMode({ mode: "act", autoApproveTools: true }),
).toBe("act");
expect(resolveDesktopSessionMode({ autoApproveTools: true })).toBe("act");
});
function localRuntimeContext(
sessionManager: Record<string, unknown>,
options: { sessionIds?: string[]; workspaceRoot?: string } = {},
) {
const workspaceRoot = options.workspaceRoot ?? "/workspace";
return {
runtimeBindings: new Map([
[
"local",
{
environmentId: "local",
kind: "local" as const,
workspaceRoot,
sessionManager,
hubClient: {
command: vi.fn(async () => undefined),
},
unsubscribeSessionEvents: () => {},
},
],
]),
sessionEnvironmentIds: new Map(
(options.sessionIds ?? []).map((sessionId) => [sessionId, "local"]),
),
activeEnvironmentId: "local",
remoteEnvironments: null,
localWorkspaceRoot: workspaceRoot,
};
}
it("preserves explicit Plan and Yolo modes", () => {
expect(resolveDesktopSessionMode({ mode: "plan" })).toBe("plan");
expect(resolveDesktopSessionMode({ mode: "yolo" })).toBe("yolo");
});
});
function localSessionManager(ctx: SidecarContext): Record<string, unknown> {
return ctx.runtimeBindings.get("local")?.sessionManager as unknown as Record<
string,
unknown
>;
}
describe("rewriteDesktopTeamPrompt", () => {
it("rewrites /team for the core runtime", () => {
@@ -213,7 +235,7 @@ describe("pathless session starts", () => {
const ctx = {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
sessionManager: { start },
...localRuntimeContext({ start }),
} as unknown as SidecarContext;
const result = (await handleChatSessionCommand(ctx, {
@@ -237,15 +259,139 @@ describe("pathless session starts", () => {
sessionId: "session-pathless",
cwd: "/home/host/.cline/data/workspaces/chat",
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
environmentId: "local",
});
expect(ctx.liveSessions.get("session-pathless")?.config).toMatchObject({
cwd: "/home/host/.cline/data/workspaces/chat",
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
});
});
it("marks sessions initiated by realtime voice with the realtime source", async () => {
const start = vi.fn(async () => ({
sessionId: "session-realtime",
manifest: {
cwd: "/workspace/project",
workspace_root: "/workspace/project",
},
manifestPath: "/tmp/session-realtime.json",
messagesPath: "/tmp/session-realtime.messages.json",
}));
const ctx = {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
...localRuntimeContext({ start }),
} as unknown as SidecarContext;
await handleChatSessionCommand(ctx, {
action: "start",
source: "realtime",
config: {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
},
});
expect(start).toHaveBeenCalledWith(
expect.objectContaining({ source: "realtime" }),
);
});
});
describe("environment-bound session attach", () => {
it("does not fall through to another host when the requested environment lacks the session", async () => {
const sessionId = "same-session-id";
const localGet = vi.fn(async () => ({
sessionId,
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/local/project",
workspaceRoot: "/local/project",
}));
const remoteGet = vi.fn(async () => undefined);
const ctx = {
liveSessions: new Map(),
sessionEnvironmentIds: new Map([[sessionId, "local"]]),
activeEnvironmentId: "local",
runtimeBindings: new Map([
[
"local",
{
environmentId: "local",
kind: "local",
workspaceRoot: "/local/project",
sessionManager: { get: localGet },
hubClient: { command: vi.fn() },
unsubscribeSessionEvents: () => {},
},
],
[
"pi-host",
{
environmentId: "pi-host",
kind: "ssh",
workspaceRoot: "/home/pi",
sessionManager: { get: remoteGet },
hubClient: { command: vi.fn() },
unsubscribeSessionEvents: () => {},
},
],
]),
} as unknown as SidecarContext;
await expect(
handleChatSessionCommand(ctx, {
action: "attach",
sessionId,
config: { environmentId: "pi-host" },
}),
).rejects.toThrow(`Session ${sessionId} not found`);
expect(remoteGet).toHaveBeenCalledWith(sessionId);
expect(localGet).not.toHaveBeenCalled();
});
});
describe("session forks", () => {
it("copies generated artifacts into the forked session", () => {
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
const sessionsDir = mkdtempSync(join(tmpdir(), "desktop-fork-artifacts-"));
const sourceSessionId = "source-session";
const targetSessionId = "forked-session";
const sourceArtifactsDir = join(sessionsDir, sourceSessionId, "artifacts");
try {
process.env.CLINE_SESSION_DATA_DIR = sessionsDir;
mkdirSync(sourceArtifactsDir, { recursive: true });
writeFileSync(join(sourceArtifactsDir, "generated.mp4"), "video");
writeFileSync(join(sourceArtifactsDir, "generated.mp3"), "audio");
copySessionGeneratedArtifacts(sourceSessionId, targetSessionId);
expect(
readFileSync(
join(sessionsDir, targetSessionId, "artifacts", "generated.mp4"),
"utf8",
),
).toBe("video");
expect(
readFileSync(
join(sessionsDir, targetSessionId, "artifacts", "generated.mp3"),
"utf8",
),
).toBe("audio");
} finally {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(sessionsDir, { recursive: true, force: true });
}
});
it("restores the selected workspace checkpoint before forking for message editing", async () => {
const sourceSessionId = `source-fork-${Date.now()}`;
const sourceMessages = [
@@ -284,29 +430,32 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
metadata: {
checkpoint: {
latest: { ref: "second", createdAt: 2, runCount: 2 },
history: [
{ ref: "first", createdAt: 1, runCount: 1 },
{ ref: "second", createdAt: 2, runCount: 2 },
],
...localRuntimeContext(
{
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
metadata: {
checkpoint: {
latest: { ref: "second", createdAt: 2, runCount: 2 },
history: [
{ ref: "first", createdAt: 1, runCount: 1 },
{ ref: "second", createdAt: 2, runCount: 2 },
],
},
},
},
})),
readMessages,
restore,
start,
},
})),
readMessages,
restore,
start,
},
{ sessionIds: [sourceSessionId] },
),
streamIndices: new Map(),
wsClients: new Set(),
} as unknown as SidecarContext;
@@ -408,26 +557,29 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
metadata: {
checkpoint: {
latest: { ref: "first", createdAt: 1, runCount: 1 },
history: [{ ref: "first", createdAt: 1, runCount: 1 }],
...localRuntimeContext(
{
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
metadata: {
checkpoint: {
latest: { ref: "first", createdAt: 1, runCount: 1 },
history: [{ ref: "first", createdAt: 1, runCount: 1 }],
},
},
},
})),
readMessages: vi.fn(async () => sourceMessages),
restore,
send,
},
})),
readMessages: vi.fn(async () => sourceMessages),
restore,
send,
},
{ sessionIds: [sourceSessionId, siblingSessionId] },
),
streamIndices: new Map(),
wsClients: new Set(),
} as unknown as SidecarContext;
@@ -489,20 +641,23 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
})),
readMessages,
restore,
start,
},
...localRuntimeContext(
{
get: vi.fn(async () => ({
sessionId: sourceSessionId,
source: "desktop",
status: "completed",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
})),
readMessages,
restore,
start,
},
{ sessionIds: [sourceSessionId] },
),
streamIndices: new Map(),
wsClients: new Set(),
} as unknown as SidecarContext;
@@ -540,7 +695,7 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: { restore },
...localRuntimeContext({ restore }, { sessionIds: [sourceSessionId] }),
} as unknown as SidecarContext;
await expect(
@@ -571,13 +726,16 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
status: "running",
})),
restore,
},
...localRuntimeContext(
{
get: vi.fn(async () => ({
sessionId: sourceSessionId,
status: "running",
})),
restore,
},
{ sessionIds: [sourceSessionId] },
),
} as unknown as SidecarContext;
await expect(
@@ -621,15 +779,18 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(),
sessionManager: {
get: vi.fn(async () => ({
sessionId: sourceSessionId,
status: "completed",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
})),
restore,
},
...localRuntimeContext(
{
get: vi.fn(async () => ({
sessionId: sourceSessionId,
status: "completed",
cwd: "/workspace/project",
workspaceRoot: "/workspace/project",
})),
restore,
},
{ sessionIds: [sourceSessionId, siblingSessionId] },
),
} as unknown as SidecarContext;
await expect(
@@ -643,81 +804,6 @@ describe("session forks", () => {
expect(ctx.restoringWorkspacePaths.size).toBe(0);
});
it("allows a workspace restore after a queued turn completes through the event stream", async () => {
const sessionId = `queued-turn-session-${Date.now()}`;
const dataDir = mkdtempSync(join(tmpdir(), "cline-queued-restore-"));
const originalDataDir = process.env.CLINE_SESSION_DATA_DIR;
process.env.CLINE_SESSION_DATA_DIR = dataDir;
try {
const restore = vi.fn(async () => ({
sessionId,
messages: [{ role: "user", content: "first prompt" }],
checkpoint: { ref: "first", createdAt: 1, runCount: 1 },
}));
const ctx = {
liveSessions: new Map([
[
sessionId,
{
config: { cwd: "/workspace/project" },
messages: [{ role: "user", content: "first prompt" }],
promptsInQueue: [],
// A drained queued turn is running: no send() RPC owns
// this turn's busy flag, only the event stream does.
busy: true,
startedAt: Date.now(),
status: "running",
},
],
]),
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
wsClients: new Set(),
sessionManager: { restore },
} as unknown as SidecarContext;
const restoreRequest = {
action: "restore_checkpoint" as const,
sessionId,
checkpointRunCount: 1,
config: {
cwd: "/workspace/project",
provider: "cline",
model: "test-model",
},
};
// While the queued turn is still running the workspace stays locked.
await expect(
handleChatSessionCommand(ctx, restoreRequest),
).rejects.toThrow("Wait for all turns in this workspace to finish");
expect(restore).not.toHaveBeenCalled();
// The queued turn settles through the event stream: the runtime
// host reports the session back at idle (there is no send() RPC
// response to clear the busy flag for event-settled turns).
handleCoreSessionEvent(ctx, {
type: "status",
payload: { sessionId, status: "idle" },
});
expect(ctx.liveSessions.get(sessionId)).toMatchObject({
busy: false,
status: "idle",
});
await expect(
handleChatSessionCommand(ctx, restoreRequest),
).resolves.toMatchObject({ sessionId });
expect(restore).toHaveBeenCalledTimes(1);
} finally {
if (originalDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = originalDataDir;
}
rmSync(dataDir, { force: true, recursive: true });
}
});
it("blocks sends from sibling sessions while their workspace is restored", async () => {
const send = vi.fn();
const sessionId = "workspace-sibling-session";
@@ -736,7 +822,7 @@ describe("session forks", () => {
],
]),
restoringWorkspacePaths: new Set(["/workspace/project"]),
sessionManager: { send },
...localRuntimeContext({ send }, { sessionIds: [sessionId] }),
} as unknown as SidecarContext;
await expect(
@@ -796,17 +882,20 @@ describe("first-send connection updates", () => {
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
wsClients: new Set(),
sessionManager: {
readMessages,
readSessionCompactionState,
send,
start,
stop,
updateSessionConnection,
pendingPrompts: {
list: vi.fn(async () => []),
...localRuntimeContext(
{
readMessages,
readSessionCompactionState,
send,
start,
stop,
updateSessionConnection,
pendingPrompts: {
list: vi.fn(async () => []),
},
},
},
{ sessionIds: [sessionId] },
),
} as unknown as SidecarContext;
return {
ctx,
@@ -930,7 +1019,7 @@ describe("first-send connection updates", () => {
attachmentCount: number;
userFiles?: string[];
}> = [];
const manager = ctx.sessionManager as unknown as {
const manager = localSessionManager(ctx) as unknown as {
send: typeof send;
pendingPrompts: {
list: (input: unknown) => Promise<unknown[]>;
@@ -1062,15 +1151,13 @@ describe("first-send connection updates", () => {
if (!session) throw new Error("missing session");
const queuedMap = new Map([["pending_1", [queuedFile]]]);
session.queuedAttachmentFiles = queuedMap;
(ctx.sessionManager as unknown as { get: unknown }).get = vi.fn(
async () => ({
status: "idle",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace",
workspaceRoot: "/workspace",
}),
);
(localSessionManager(ctx) as { get?: unknown }).get = vi.fn(async () => ({
status: "idle",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace",
workspaceRoot: "/workspace",
}));
await handleChatSessionCommand(ctx, {
action: "attach",
@@ -1383,7 +1470,97 @@ describe("first-send connection updates", () => {
});
expect(updateSessionConnection).toHaveBeenCalledTimes(1);
expect(ctx.liveSessions.get(sessionId)?.attachedViaHub).toBe(false);
});
});
describe("system prompt mode resolution", () => {
function createStartContext() {
const start = vi.fn(async (input: { config: Record<string, unknown> }) => ({
sessionId: "session-mode-test",
manifest: {
cwd: String(input.config.cwd ?? ""),
workspace_root: String(input.config.workspaceRoot ?? ""),
},
manifestPath: "/tmp/session-mode-test.json",
messagesPath: "/tmp/session-mode-test.messages.json",
}));
const ctx = {
liveSessions: new Map(),
...localRuntimeContext({ start }),
} as unknown as SidecarContext;
return { ctx, start };
}
async function startAndCaptureSystemPrompt(
config: Record<string, unknown>,
): Promise<string> {
const { ctx, start } = createStartContext();
const cwd = String(config.cwd ?? "");
// Seed the metadata cache so resolveSystemPrompt does not scan a real
// workspace during the test.
prewarmWorkspaceMetadata(cwd, async () => "test metadata");
await handleChatSessionCommand(ctx, { action: "start", config });
expect(start).toHaveBeenCalledTimes(1);
const input = start.mock.calls[0][0] as {
config: Record<string, unknown>;
};
return String(input.config.systemPrompt ?? "");
}
it("keeps the interactive act persona when autoApproveTools is enabled", async () => {
const systemPrompt = await startAndCaptureSystemPrompt({
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/tmp/cline-desktop-mode-act-auto-approve",
mode: "act",
autoApproveTools: true,
});
expect(systemPrompt).not.toContain("submit_and_exit");
expect(systemPrompt).not.toContain(
"user who you cannot communicate with directly",
);
expect(systemPrompt).toContain("assist users with various coding tasks");
});
it("defaults to act mode when mode is omitted", async () => {
const systemPrompt = await startAndCaptureSystemPrompt({
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/tmp/cline-desktop-mode-default",
autoApproveTools: true,
});
expect(systemPrompt).not.toContain("submit_and_exit");
expect(systemPrompt).toContain("assist users with various coding tasks");
});
it("appends plan-mode instructions when mode is plan", async () => {
const systemPrompt = await startAndCaptureSystemPrompt({
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/tmp/cline-desktop-mode-plan",
mode: "plan",
autoApproveTools: true,
});
expect(systemPrompt).not.toContain("submit_and_exit");
expect(systemPrompt).toContain("You are in Plan mode");
});
it("only uses the yolo persona when mode is explicitly yolo", async () => {
const systemPrompt = await startAndCaptureSystemPrompt({
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/tmp/cline-desktop-mode-yolo",
mode: "yolo",
autoApproveTools: true,
});
expect(systemPrompt).toContain("submit_and_exit");
expect(systemPrompt).toContain(
"user who you cannot communicate with directly",
);
});
});
@@ -1477,15 +1654,6 @@ describe("runtime slash command expansion on send", () => {
name: desktop-send-skill
---
Follow the desktop send skill instructions.`,
);
const workflowsDir = join(workspace, ".cline", "workflows");
mkdirSync(workflowsDir, { recursive: true });
writeFileSync(
join(workflowsDir, "desktop-send-workflow.md"),
`---
name: desktop-send-workflow
---
Follow the desktop send workflow instructions.`,
);
return workspace;
}
@@ -1514,23 +1682,25 @@ Follow the desktop send workflow instructions.`,
}),
);
const ctx = {
workspaceRoot: workspace,
liveSessions: new Map([[sessionId, session]]),
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
wsClients: new Set(),
sessionManager: {
send,
pendingPrompts: {
list: vi.fn(async () => []),
update: updatePendingPrompt,
...localRuntimeContext(
{
send,
pendingPrompts: {
list: vi.fn(async () => []),
update: updatePendingPrompt,
},
},
},
{ sessionIds: [sessionId], workspaceRoot: workspace },
),
} as unknown as SidecarContext;
return { ctx, send, session, sessionId, updatePendingPrompt };
}
it("sends a skill command through as typed for the skills tool", async () => {
it("expands a leading skill command into its instructions", async () => {
const workspace = createWorkspaceWithSkill();
const { ctx, send, session, sessionId } = createContext(workspace);
@@ -1540,59 +1710,16 @@ Follow the desktop send workflow instructions.`,
prompt: "/desktop-send-skill write the docs",
});
// Skills are not expanded into the user message: the runtime's skills
// tool loads the instructions, and the persisted transcript keeps the
// typed command.
expect(send).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "/desktop-send-skill write the docs",
}),
);
expect(session.prompt).toBe("/desktop-send-skill write the docs");
});
it("expands a skill command in yolo mode, where the skills tool is unavailable", async () => {
const workspace = createWorkspaceWithSkill();
const { ctx, send, session, sessionId } = createContext(workspace);
(session.config as Record<string, unknown>).mode = "yolo";
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "/desktop-send-skill write the docs",
});
// The yolo preset has no skills tool, so textual expansion is the only
// way the instructions reach the model.
expect(send).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "Follow the desktop send skill instructions. write the docs",
}),
);
});
it("expands a leading workflow command into its instructions", async () => {
const workspace = createWorkspaceWithSkill();
const { ctx, send, session, sessionId } = createContext(workspace);
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "/desktop-send-workflow ship it",
});
// Workflows are not served by the skills tool, so they keep textual
// expansion.
expect(send).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "Follow the desktop send workflow instructions. ship it",
}),
);
// The session's display prompt keeps the raw token.
expect(session.prompt).toBe("/desktop-send-workflow ship it");
expect(session.prompt).toBe("/desktop-send-skill write the docs");
});
it("keeps a skill command as typed when a queued prompt is edited", async () => {
it("expands a skill command when a queued prompt is edited", async () => {
const workspace = createWorkspaceWithSkill();
const { ctx, sessionId, updatePendingPrompt } = createContext(workspace);
@@ -1606,25 +1733,7 @@ Follow the desktop send workflow instructions.`,
expect(updatePendingPrompt).toHaveBeenCalledWith({
sessionId,
promptId: "queued-1",
prompt: "/desktop-send-skill later please",
});
});
it("expands a workflow command when a queued prompt is edited", async () => {
const workspace = createWorkspaceWithSkill();
const { ctx, sessionId, updatePendingPrompt } = createContext(workspace);
await handleChatSessionCommand(ctx, {
action: "update_pending_prompt",
sessionId,
promptId: "queued-2",
prompt: "/desktop-send-workflow later please",
});
expect(updatePendingPrompt).toHaveBeenCalledWith({
sessionId,
promptId: "queued-2",
prompt: "Follow the desktop send workflow instructions. later please",
prompt: "Follow the desktop send skill instructions. later please",
});
});
+419 -88
View File
@@ -1,4 +1,10 @@
import { existsSync, readFileSync } from "node:fs";
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
} from "node:fs";
import { basename, join, resolve } from "node:path";
import { isDeepStrictEqual } from "node:util";
import {
@@ -9,14 +15,17 @@ import {
createSessionCompactionState,
createUserInstructionConfigService,
getCoreBuiltinToolCatalog,
isSkillsToolAvailable,
ProviderSettingsManager,
projectSessionCompactionState,
readGlobalSettings,
resolveProviderApiKeyFromSettings,
RuntimeOAuthTokenManager,
type SessionCompactionState,
type SessionPendingPrompt,
type SessionRecord,
SessionSource,
splitCoreSessionConfig,
toProviderConfig,
trimMessagesBeforeUserRun,
} from "@cline/core";
import type { MessageWithMetadata } from "@cline/llms";
@@ -27,7 +36,15 @@ import {
materializeUserFiles,
trackQueuedAttachments,
} from "./attachments";
import { emitChunk, nowMs, sendEvent } from "./context";
import { getCloudSessionManager } from "./cloud-sessions";
import {
emitChunk,
findSessionRuntimeBinding,
getSessionRuntimeBinding,
nowMs,
sendEvent,
} from "./context";
import { isCloudAgentsEnabled } from "./feature-flags";
import { readSessionManifest, sharedSessionDataDir } from "./paths";
import { persistSessionMessages } from "./session-data/messages";
import type {
@@ -35,6 +52,7 @@ import type {
JsonRecord,
LiveSession,
PromptInQueue,
SessionRuntimeBinding,
SidecarContext,
} from "./types";
@@ -102,21 +120,15 @@ const BUILTIN_SLASH_COMMAND_NAMES = new Set(["fork", "team"]);
/**
* Expand a leading `/skill` or `/workflow` token into its configured
* instructions before dispatching the prompt. Skill commands pass through as
* typed whenever the session registers the runtime's `skills` tool (its
* description requires the model to invoke it on slash-command references),
* so the instructions reach the model as a tool result instead of being
* pasted into the user message where the transcript would render them as
* if the user had typed the whole skill body. Workflows, and skills in
* configurations without the tool (e.g. yolo mode), keep textual expansion.
* Returns the prompt unchanged when it is not a slash command, the token is
* a built-in, no command matches, or command discovery fails.
* instructions before dispatching the prompt, mirroring the CLI's
* `buildUserInputMessage`. Returns the prompt unchanged when it is not a
* slash command, the token is a built-in, no command matches, or command
* discovery fails.
*/
async function expandRuntimeSlashCommand(
ctx: SidecarContext,
workspacePath: string | undefined,
prompt: string,
mode?: unknown,
): Promise<string> {
if (!prompt.startsWith("/") || prompt.length < 2) {
return prompt;
@@ -132,12 +144,7 @@ async function expandRuntimeSlashCommand(
});
try {
await service.start();
return service.resolveRuntimeSlashCommand(prompt, {
expandSkillCommands: !isSkillsToolAvailable({
mode: mode === "plan" || mode === "yolo" ? mode : "act",
disabledToolIds: new Set(readGlobalSettings().disabledTools ?? []),
}),
});
return service.resolveRuntimeSlashCommand(prompt);
} catch (error) {
ctx.logger?.debug("Slash command expansion failed, sending raw prompt", {
error,
@@ -200,7 +207,7 @@ async function resolveDesktopRuntimePrompt(
mode?: unknown,
): Promise<string> {
return rewriteDesktopTeamPrompt(
await expandRuntimeSlashCommand(ctx, workspacePath, prompt, mode),
await expandRuntimeSlashCommand(ctx, workspacePath, prompt),
{ mode },
);
}
@@ -315,6 +322,22 @@ function readPersistedChatMessages(
}
}
export function copySessionGeneratedArtifacts(
sourceSessionId: string,
targetSessionId: string,
): void {
if (sourceSessionId === targetSessionId) return;
const sourceDir = join(sharedSessionDataDir(), sourceSessionId, "artifacts");
if (!existsSync(sourceDir)) return;
const targetDir = join(sharedSessionDataDir(), targetSessionId, "artifacts");
mkdirSync(targetDir, { recursive: true });
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
copyFileSync(join(sourceDir, entry.name), join(targetDir, entry.name));
}
}
function readSessionMetadataTitle(sessionId: string): string | undefined {
const manifest = readSessionManifest(sessionId);
const metadata =
@@ -354,6 +377,7 @@ function createLiveSession(
overrides?: Partial<LiveSession>,
): LiveSession {
return {
environmentId: overrides?.environmentId,
config,
messages: overrides?.messages ?? [],
promptsInQueue: overrides?.promptsInQueue ?? [],
@@ -421,7 +445,7 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
sessionId: config.sessionId ?? config.session_id,
providerId: config.provider ?? config.providerId ?? "",
modelId: config.model ?? config.modelId ?? "",
mode: resolveDesktopSessionMode(config),
mode: config.mode ?? "act",
apiKey: config.apiKey ?? config.api_key ?? "",
baseUrl: config.baseUrl,
headers: config.headers,
@@ -445,17 +469,6 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
};
}
/** Auto-approval is a tool policy, not a request to change the tool preset. */
export function resolveDesktopSessionMode(
config: JsonRecord,
): "act" | "plan" | "yolo" {
return config.mode === "plan"
? "plan"
: config.mode === "yolo"
? "yolo"
: "act";
}
export function buildSessionConnectionUpdate(
config: JsonRecord,
): SessionConnectionUpdate {
@@ -564,7 +577,12 @@ async function resolveSystemPrompt(config: JsonRecord): Promise<string> {
return String(config.systemPrompt ?? config.system_prompt ?? "").trim();
}
const providerId = String(config.provider ?? config.providerId ?? "").trim();
const mode = resolveDesktopSessionMode(config);
// Mode comes from config.mode only. `autoApproveTools` is a tool-approval
// policy (see resolveToolPolicies) and must NOT switch the session to the
// non-interactive "yolo" persona, whose prompt demands a `submit_and_exit`
// tool that interactive desktop sessions do not expose.
const mode =
config.mode === "plan" ? "plan" : config.mode === "yolo" ? "yolo" : "act";
const metadata = await consumeWorkspaceMetadata(cwd);
const inlineRules =
typeof config.rules === "string" && config.rules.trim().length > 0
@@ -646,9 +664,86 @@ function applyPendingPrompts(
}));
}
function getSessionManager(ctx: SidecarContext): ClineCore {
if (!ctx.sessionManager) throw new Error("Session manager not initialized");
return ctx.sessionManager;
function readEnvironmentId(config: JsonRecord | undefined): string | undefined {
const value = config?.environmentId ?? config?.environment_id;
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readExplicitSystemPrompt(config: JsonRecord): string {
const value = config.systemPrompt ?? config.system_prompt;
return typeof value === "string" ? value : "";
}
/**
* A remote Hub intentionally has its own HOME and provider settings. Send the
* selected desktop provider configuration over the authenticated SSH tunnel so
* a normal signed-in desktop session can make model requests on the host. The
* refresh token is omitted: v0 uses the current access/API token and never
* writes the desktop's reusable OAuth credential to the remote settings file.
*/
async function withRemoteProviderCredentials(
config: JsonRecord,
): Promise<JsonRecord> {
const providerId = String(config.provider ?? config.providerId ?? "").trim();
if (!providerId) return config;
const manager = new ProviderSettingsManager();
const settings = manager.getProviderSettings(providerId);
if (!settings) return config;
const modelId = String(
config.model ?? config.modelId ?? settings.model ?? "",
).trim();
const storedConfig = {
...toProviderConfig(
{
...settings,
...(modelId ? { model: modelId } : {}),
},
{ includeKnownModels: false },
),
};
delete storedConfig.refreshToken;
const explicitProviderConfig =
config.providerConfig && typeof config.providerConfig === "object"
? (config.providerConfig as JsonRecord)
: undefined;
const explicitApiKey = String(config.apiKey ?? config.api_key ?? "").trim();
const oauth = explicitApiKey
? null
: await new RuntimeOAuthTokenManager({
providerSettingsManager: manager,
}).resolveProviderApiKey({ providerId });
const apiKey =
explicitApiKey ||
oauth?.apiKey ||
resolveProviderApiKeyFromSettings(manager, providerId) ||
String(storedConfig.apiKey ?? "").trim();
return {
...config,
...(apiKey ? { apiKey } : {}),
...(!config.baseUrl && storedConfig.baseUrl
? { baseUrl: storedConfig.baseUrl }
: {}),
...(!config.headers && storedConfig.headers
? { headers: storedConfig.headers }
: {}),
providerConfig: {
...storedConfig,
...(explicitProviderConfig ?? {}),
providerId,
...(modelId ? { modelId } : {}),
},
};
}
function getSessionManager(
ctx: SidecarContext,
sessionId?: string,
config?: JsonRecord,
): ClineCore {
return getSessionRuntimeBinding(ctx, sessionId, readEnvironmentId(config))
.sessionManager;
}
// ---------------------------------------------------------------------------
@@ -660,20 +755,34 @@ async function handleStart(
request: ChatSessionCommandRequest,
): Promise<unknown> {
if (!request.config) throw new Error("config is required");
const manager = getSessionManager(ctx);
const systemPrompt = await resolveSystemPrompt(request.config);
const binding = getSessionRuntimeBinding(
ctx,
undefined,
readEnvironmentId(request.config),
);
const manager = binding.sessionManager;
const config =
binding.kind === "ssh"
? await withRemoteProviderCredentials(request.config)
: request.config;
// Workspace discovery must happen where the files live. Local desktop
// sessions keep the eager prompt path; SSH sessions leave a blank prompt for
// the remote Hub's LocalRuntimeHost bootstrap to compose from remote metadata.
const systemPrompt =
binding.kind === "ssh"
? readExplicitSystemPrompt(config)
: await resolveSystemPrompt(config);
const requestedSessionId = String(
request.config.sessionId ?? request.config.session_id ?? "",
config.sessionId ?? config.session_id ?? "",
).trim();
const initialMessages =
Array.isArray(request.config.initialMessages) &&
request.config.initialMessages.length > 0
? request.config.initialMessages
Array.isArray(config.initialMessages) && config.initialMessages.length > 0
? config.initialMessages
: requestedSessionId
? (readPersistedChatMessages(requestedSessionId) ?? undefined)
: undefined;
const coreConfig: JsonRecord = {
...buildCoreSessionConfig(request.config),
...buildCoreSessionConfig(config),
systemPrompt,
...(initialMessages ? { initialMessages } : {}),
};
@@ -689,7 +798,10 @@ async function handleStart(
});
const startResult = await manager.start({
...splitCoreSessionConfig(coreConfig as unknown as ClineCoreStartConfig),
source: SessionSource.DESKTOP,
source:
request.source === SessionSource.REALTIME
? SessionSource.REALTIME
: SessionSource.DESKTOP,
interactive: true,
...(initialMessages ? { initialMessages } : {}),
toolPolicies: resolveToolPolicies(request.config),
@@ -699,8 +811,14 @@ async function handleStart(
const cwd = startResult.manifest.cwd;
ctx.logger?.log("Desktop chat session started", { sessionId });
const session = createLiveSession(
{ ...request.config, cwd, workspaceRoot },
{
...config,
cwd,
workspaceRoot,
environmentId: binding.environmentId,
},
{
environmentId: binding.environmentId,
messages: initialMessages,
prompt: initialMessages
? derivePromptFromMessages(initialMessages)
@@ -712,7 +830,13 @@ async function handleStart(
},
);
ctx.liveSessions.set(sessionId, session);
return { sessionId, cwd, workspaceRoot };
ctx.sessionEnvironmentIds.set(sessionId, binding.environmentId);
return {
sessionId,
cwd,
workspaceRoot,
environmentId: binding.environmentId,
};
}
async function handleAttach(
@@ -724,7 +848,18 @@ async function handleAttach(
throw new Error("sessionId is required");
}
const manager = getSessionManager(ctx);
const preferredEnvironmentId = readEnvironmentId(request.config);
// An explicit environment is a hard routing boundary. Falling through to
// another connected host can attach a same-id session from the wrong machine.
const binding = preferredEnvironmentId
? getSessionRuntimeBinding(ctx, sessionId, preferredEnvironmentId)
: await findSessionRuntimeBinding(ctx, sessionId);
if (!binding) {
throw new Error(
`Session ${sessionId} not found in a connected environment`,
);
}
const manager = binding.sessionManager;
const session = await manager.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
@@ -735,13 +870,12 @@ async function handleAttach(
? (session.metadata as JsonRecord)
: undefined;
const existing = ctx.liveSessions.get(sessionId);
if (ctx.hubClient) {
await ctx.hubClient.command("session.attach", { sessionId }, sessionId);
}
const attachedConfig: JsonRecord = {
await binding.hubClient.command("session.attach", { sessionId }, sessionId);
const baseAttachedConfig: JsonRecord = {
...(existing?.config ?? {}),
...(request.config ?? {}),
sessionId,
environmentId: binding.environmentId,
provider: session.provider || existing?.config.provider || "",
model: session.model || existing?.config.model || "",
cwd:
@@ -755,9 +889,14 @@ async function handleAttach(
String(request.config?.workspaceRoot ?? "").trim() ||
String(existing?.config.workspaceRoot ?? "").trim(),
};
const attachedConfig =
binding.kind === "ssh"
? await withRemoteProviderCredentials(baseAttachedConfig)
: baseAttachedConfig;
ctx.liveSessions.set(
sessionId,
createLiveSession(attachedConfig, {
environmentId: binding.environmentId,
messages: existing?.messages ?? [],
promptsInQueue: existing?.promptsInQueue ?? [],
status: session.status,
@@ -777,9 +916,11 @@ async function handleAttach(
consumedAttachmentFiles: existing?.consumedAttachmentFiles,
}),
);
ctx.sessionEnvironmentIds.set(sessionId, binding.environmentId);
return {
sessionId,
environmentId: binding.environmentId,
status: session.status,
provider: session.provider,
model: session.model,
@@ -832,11 +973,20 @@ async function startRebuiltSession(
async function rebuildSessionForProviderChange(
ctx: SidecarContext,
manager: ClineCore,
binding: SessionRuntimeBinding,
sessionId: string,
previousConfig: JsonRecord,
nextConfig: JsonRecord,
): Promise<void> {
const manager = binding.sessionManager;
const effectivePreviousConfig =
binding.kind === "ssh"
? await withRemoteProviderCredentials(previousConfig)
: previousConfig;
const effectiveNextConfig =
binding.kind === "ssh"
? await withRemoteProviderCredentials(nextConfig)
: nextConfig;
const [messages, compactionState, previousSystemPrompt, nextSystemPrompt] =
await Promise.all([
manager.readMessages(sessionId),
@@ -848,8 +998,12 @@ async function rebuildSessionForProviderChange(
});
return undefined;
}),
resolveSystemPrompt(previousConfig),
resolveSystemPrompt(nextConfig),
binding.kind === "ssh"
? readExplicitSystemPrompt(effectivePreviousConfig)
: resolveSystemPrompt(effectivePreviousConfig),
binding.kind === "ssh"
? readExplicitSystemPrompt(effectiveNextConfig)
: resolveSystemPrompt(effectiveNextConfig),
]);
await manager.stop(sessionId);
@@ -858,7 +1012,7 @@ async function rebuildSessionForProviderChange(
await startRebuiltSession(
manager,
sessionId,
nextConfig,
effectiveNextConfig,
nextSystemPrompt,
messages,
compactionState,
@@ -869,7 +1023,7 @@ async function rebuildSessionForProviderChange(
// persistence failure cannot leave runtime and cached state diverged.
await manager.updateSessionConnection(
sessionId,
buildSessionConnectionUpdate(nextConfig),
buildSessionConnectionUpdate(effectiveNextConfig),
);
} catch (replacementError) {
try {
@@ -879,14 +1033,14 @@ async function rebuildSessionForProviderChange(
await startRebuiltSession(
manager,
sessionId,
previousConfig,
effectivePreviousConfig,
previousSystemPrompt,
messages,
compactionState,
);
await manager.updateSessionConnection(
sessionId,
buildSessionConnectionUpdate(previousConfig),
buildSessionConnectionUpdate(effectivePreviousConfig),
);
} catch (rollbackError) {
throw new AggregateError(
@@ -911,8 +1065,13 @@ async function handleSend(
if (!prompt && !hasAttachments) {
throw new Error("prompt or attachment is required");
}
const manager = getSessionManager(ctx);
const session = ctx.liveSessions.get(sessionId);
const binding = getSessionRuntimeBinding(
ctx,
sessionId,
readEnvironmentId(request.config),
);
const manager = binding.sessionManager;
const lockedWorkspaceKey = workspacePathKey(
session?.config ?? request.config,
);
@@ -929,7 +1088,8 @@ async function handleSend(
// `/command` token as the session's display prompt.
const runtimePrompt = await resolveDesktopRuntimePrompt(
ctx,
readWorkspacePath(session?.config ?? request.config) ?? ctx.workspaceRoot,
readWorkspacePath(session?.config ?? request.config) ??
ctx.localWorkspaceRoot,
prompt,
request.config?.mode ?? session?.config?.mode,
);
@@ -966,7 +1126,7 @@ async function handleSend(
if (providerChanged && session) {
await rebuildSessionForProviderChange(
ctx,
manager,
binding,
sessionId,
session.config,
nextConfig,
@@ -989,16 +1149,18 @@ async function handleSend(
}
}
if (
binding.kind === "ssh" &&
(request.attachments?.userFiles?.length ?? 0) > 0
) {
throw new Error(
"File attachments are not available in the SSH proof of concept yet. Images and text prompts are supported.",
);
}
const userFiles = materializeUserFiles(
sessionId,
request.attachments?.userFiles,
);
if (session?.attachedViaHub) {
// Once ClineCore sends a turn, its HubRuntimeHost owns the session
// subscription. Stop projecting the observer stream as well or every
// assistant/tool update (including command chunks) is emitted twice.
session.attachedViaHub = false;
}
if (delivery === "queue") {
if (session) {
session.prompt = prompt;
@@ -1068,11 +1230,19 @@ async function handleSend(
} else {
deleteMaterializedAttachments(sessionId, userFiles);
}
ctx.logger?.log("Desktop chat prompt completed", {
const completionMetadata = {
sessionId,
finishReason: result?.finishReason,
textLength: result?.text?.length ?? 0,
});
};
if (result?.finishReason === "error") {
ctx.logger?.error?.("Desktop chat prompt returned an error result", {
...completionMetadata,
failure: result.text,
});
} else {
ctx.logger?.log("Desktop chat prompt completed", completionMetadata);
}
if (session && ownsBusyState) {
session.status = "idle";
if (result?.messages) session.messages = result.messages;
@@ -1131,7 +1301,7 @@ async function handleStop(
): Promise<unknown> {
const sessionId = request.sessionId?.trim();
if (!sessionId) throw new Error("sessionId is required");
await getSessionManager(ctx).stop(sessionId);
await getSessionManager(ctx, sessionId, request.config).stop(sessionId);
const session = ctx.liveSessions.get(sessionId);
if (session) {
session.busy = false;
@@ -1146,7 +1316,10 @@ async function handleAbort(
): Promise<unknown> {
const sessionId = request.sessionId?.trim();
if (!sessionId) throw new Error("sessionId is required");
await getSessionManager(ctx).abort(sessionId, "user_abort");
await getSessionManager(ctx, sessionId, request.config).abort(
sessionId,
"user_abort",
);
const session = ctx.liveSessions.get(sessionId);
if (session) {
session.busy = false;
@@ -1168,7 +1341,7 @@ async function handleFork(
) {
throw new Error("forkBeforeRunCount must be a positive integer");
}
const manager = getSessionManager(ctx);
const manager = getSessionManager(ctx, sourceSessionId, request.config);
const liveSourceSession = ctx.liveSessions.get(sourceSessionId);
if (
forkBeforeRunCount !== undefined &&
@@ -1220,10 +1393,18 @@ async function handleForkUnlocked(
sourceSession: SessionRecord | undefined,
restoreWorkspacePath?: string,
): Promise<unknown> {
const manager = getSessionManager(ctx);
const sourceMessages =
const binding = getSessionRuntimeBinding(
ctx,
sourceSessionId,
readEnvironmentId(request.config),
);
const manager = binding.sessionManager;
let sourceMessages =
readPersistedChatMessages(sourceSessionId) ??
ctx.liveSessions.get(sourceSessionId)?.messages;
if (!sourceMessages?.length && binding.kind === "ssh") {
sourceMessages = await manager.readMessages(sourceSessionId);
}
if (!sourceMessages?.length) {
throw new Error(`No messages found for session ${sourceSessionId}`);
}
@@ -1233,10 +1414,11 @@ async function handleForkUnlocked(
? (sourceSession.metadata as JsonRecord)
: undefined) ?? readSessionMetadata(sourceSessionId);
const liveConfig = ctx.liveSessions.get(sourceSessionId)?.config;
const forkConfig: JsonRecord = {
const baseForkConfig: JsonRecord = {
...(liveConfig ?? {}),
...(request.config ?? {}),
sessionId: undefined,
environmentId: binding.environmentId,
provider:
sourceSession?.provider ||
liveConfig?.provider ||
@@ -1266,6 +1448,10 @@ async function handleForkUnlocked(
request.config?.cwd ||
"",
};
const forkConfig =
binding.kind === "ssh"
? await withRemoteProviderCredentials(baseForkConfig)
: baseForkConfig;
const checkpointMetadata =
sourceMetadata?.checkpoint !== undefined
? { checkpoints: sourceMetadata.checkpoint }
@@ -1286,7 +1472,10 @@ async function handleForkUnlocked(
...checkpointMetadata,
},
};
const systemPrompt = await resolveSystemPrompt(forkConfig);
const systemPrompt =
binding.kind === "ssh"
? readExplicitSystemPrompt(forkConfig)
: await resolveSystemPrompt(forkConfig);
const startInput = {
...splitCoreSessionConfig(
buildCoreSessionConfig({
@@ -1332,6 +1521,7 @@ async function handleForkUnlocked(
});
newSessionId = started.sessionId;
}
copySessionGeneratedArtifacts(sourceSessionId, newSessionId);
try {
const read = await manager.readMessages(newSessionId);
if (forkBeforeRunCount !== undefined || read.length > 0) {
@@ -1346,12 +1536,14 @@ async function handleForkUnlocked(
ctx.liveSessions.set(
newSessionId,
createLiveSession(forkConfig, {
environmentId: binding.environmentId,
messages: forkMessages,
prompt: derivePromptFromMessages(forkMessages),
title: readSessionMetadataTitle(sourceSessionId),
status: "idle",
}),
);
ctx.sessionEnvironmentIds.set(newSessionId, binding.environmentId);
sendPromptsInQueueSnapshot(ctx, sourceSessionId);
sendPromptsInQueueSnapshot(ctx, newSessionId);
return {
@@ -1373,10 +1565,11 @@ async function handleReset(
session?.status === "running" ||
session?.status === "stopping"
) {
await getSessionManager(ctx).stop(sessionId);
await getSessionManager(ctx, sessionId, request.config).stop(sessionId);
}
discardAllTrackedAttachments(sessionId, session);
ctx.liveSessions.delete(sessionId);
ctx.sessionEnvironmentIds.delete(sessionId);
sendPromptsInQueueSnapshot(ctx, sessionId);
}
return { sessionId: request.sessionId, ok: true };
@@ -1395,14 +1588,25 @@ async function handleRestoreCheckpoint(
runCount < 1
)
throw new Error("checkpointRunCount must be a positive integer");
const config = request.config;
if (!config) throw new Error("config is required to restore a checkpoint");
const requestedConfig = request.config;
if (!requestedConfig)
throw new Error("config is required to restore a checkpoint");
const cwd =
(typeof config.cwd === "string" && config.cwd.trim()) ||
(typeof config.workspaceRoot === "string" && config.workspaceRoot.trim()) ||
(typeof requestedConfig.cwd === "string" && requestedConfig.cwd.trim()) ||
(typeof requestedConfig.workspaceRoot === "string" &&
requestedConfig.workspaceRoot.trim()) ||
"";
if (!cwd) throw new Error("config.cwd or config.workspaceRoot is required");
const manager = getSessionManager(ctx);
const binding = getSessionRuntimeBinding(
ctx,
sourceSessionId,
readEnvironmentId(requestedConfig),
);
const manager = binding.sessionManager;
const config =
binding.kind === "ssh"
? await withRemoteProviderCredentials(requestedConfig)
: requestedConfig;
return withWorkspaceRestoreLock(ctx, cwd, async () => {
const restored = await manager.restore({
sessionId: sourceSessionId,
@@ -1413,7 +1617,10 @@ async function handleRestoreCheckpoint(
...splitCoreSessionConfig(
buildCoreSessionConfig({
...config,
systemPrompt: await resolveSystemPrompt(config),
systemPrompt:
binding.kind === "ssh"
? readExplicitSystemPrompt(config)
: await resolveSystemPrompt(config),
}) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
@@ -1426,6 +1633,7 @@ async function handleRestoreCheckpoint(
if (!sessionId || !restoredMessages) {
throw new Error("Checkpoint restore did not return a new session");
}
copySessionGeneratedArtifacts(sourceSessionId, sessionId);
discardAllTrackedAttachments(
sourceSessionId,
ctx.liveSessions.get(sourceSessionId),
@@ -1434,12 +1642,14 @@ async function handleRestoreCheckpoint(
ctx.liveSessions.set(
sessionId,
createLiveSession(config, {
environmentId: binding.environmentId,
messages: restoredMessages,
prompt: derivePromptFromMessages(restoredMessages),
title: readSessionMetadataTitle(sourceSessionId),
status: "idle",
}),
);
ctx.sessionEnvironmentIds.set(sessionId, binding.environmentId);
// A restore that reuses the source session id leaves the persisted
// transcript describing the discarded turns, and read_session_messages
// prefers that file over the live session. Write the trimmed history so
@@ -1460,9 +1670,11 @@ async function handlePendingPrompts(
): Promise<unknown> {
const sessionId = request.sessionId?.trim();
if (!sessionId) throw new Error("sessionId is required");
const prompts = await getSessionManager(ctx).pendingPrompts.list({
const prompts = await getSessionManager(
ctx,
sessionId,
});
request.config,
).pendingPrompts.list({ sessionId });
return {
sessionId,
promptsInQueue: applyPendingPrompts(ctx, sessionId, prompts),
@@ -1477,7 +1689,7 @@ async function handleSteerPrompt(
const promptId = request.promptId?.trim();
if (!sessionId || !promptId)
throw new Error("sessionId and promptId are required");
const manager = getSessionManager(ctx);
const manager = getSessionManager(ctx, sessionId, request.config);
const result = await manager.pendingPrompts.update({
sessionId,
promptId,
@@ -1509,7 +1721,7 @@ async function handleUpdatePendingPrompt(
// through handleSend, so resolve slash commands here too.
const runtimePrompt = await resolveDesktopRuntimePrompt(
ctx,
readWorkspacePath(sessionConfig) ?? ctx.workspaceRoot,
readWorkspacePath(sessionConfig) ?? ctx.localWorkspaceRoot,
prompt,
sessionConfig?.mode,
);
@@ -1535,7 +1747,7 @@ async function handleRemovePendingPrompt(
if (!sessionId || !promptId) {
throw new Error("sessionId and promptId are required");
}
const manager = getSessionManager(ctx);
const manager = getSessionManager(ctx, sessionId, request.config);
const result = await manager.pendingPrompts.delete({
sessionId,
promptId,
@@ -1578,6 +1790,125 @@ export async function handleChatSessionCommand(
ctx: SidecarContext,
request: ChatSessionCommandRequest,
): Promise<unknown> {
const executionTarget = String(request.config?.executionTarget ?? "").trim();
const sessionId = request.sessionId?.trim();
const existingCloudSession =
sessionId &&
ctx.liveSessions.get(sessionId)?.config.executionTarget === "cloud";
if (executionTarget === "cloud" || existingCloudSession) {
const cloud = getCloudSessionManager(ctx);
// The approval preference lives only client-side; keep the live session
// current so a lazily created inner session inherits the user's choice.
const requestedAutoApprove = request.config?.autoApproveTools;
if (typeof requestedAutoApprove === "boolean" && sessionId) {
const live = ctx.liveSessions.get(sessionId);
if (live) {
live.config.autoApproveTools = requestedAutoApprove;
}
}
switch (request.action) {
case "start": {
const requestedSessionId = String(
request.config?.sessionId ?? request.config?.session_id ?? "",
).trim();
// Server ids attach even with a cold registry; client-planned ids create.
if (requestedSessionId && cloud.isCloudSession(requestedSessionId)) {
return await cloud.attach(requestedSessionId);
}
// Gate new sessions only; existing cloud sessions must remain usable.
if (!isCloudAgentsEnabled()) {
throw new Error(
"Cloud sessions are not enabled for this account yet.",
);
}
const repoUrl = String(request.config?.repoUrl ?? "").trim();
const modelId = String(
request.config?.model ?? request.config?.modelId ?? "",
).trim();
if (!repoUrl || !modelId) {
throw new Error("repoUrl and model are required for a cloud session");
}
const branch = String(request.config?.branch ?? "").trim();
const initialPrompt = request.prompt?.trim();
const reasoningEffort = readReasoningEffort(
request.config?.reasoningEffort,
);
return await cloud.create({
repoUrl,
modelId,
...(initialPrompt ? { initialPrompt } : {}),
...(branch ? { branch } : {}),
...(typeof request.config?.thinking === "boolean"
? { thinking: request.config.thinking }
: {}),
...(reasoningEffort ? { reasoningEffort } : {}),
...(typeof requestedAutoApprove === "boolean"
? { autoApproveTools: requestedAutoApprove }
: {}),
});
}
case "attach":
if (!sessionId) throw new Error("sessionId is required");
return await cloud.attach(sessionId);
case "send": {
if (!sessionId) throw new Error("sessionId is required");
const prompt = request.prompt?.trim();
if (!prompt) throw new Error("prompt is required");
if (request.attachments?.userFiles?.length) {
throw new Error(
"File attachments are not supported in cloud sessions",
);
}
const live = ctx.liveSessions.get(sessionId);
const delivery = request.delivery ?? (live?.busy ? "queue" : undefined);
const modelId = String(
request.config?.model ?? request.config?.modelId ?? "",
).trim();
return await cloud.send(
sessionId,
prompt,
delivery,
modelId || undefined,
request.attachments?.userImages,
);
}
case "stop":
case "abort":
if (!sessionId) throw new Error("sessionId is required");
return await cloud.abort(sessionId);
case "pending_prompts":
if (!sessionId) throw new Error("sessionId is required");
return await cloud.pendingPrompts(sessionId);
case "steer_prompt": {
const promptId = request.promptId?.trim();
if (!sessionId || !promptId)
throw new Error("sessionId and promptId are required");
return await cloud.updatePendingPrompt(sessionId, promptId, {
delivery: "steer",
});
}
case "update_pending_prompt": {
const promptId = request.promptId?.trim();
const prompt = request.prompt?.trim();
if (!sessionId || !promptId)
throw new Error("sessionId and promptId are required");
if (!prompt) throw new Error("prompt is required");
return await cloud.updatePendingPrompt(sessionId, promptId, {
prompt,
});
}
case "remove_pending_prompt": {
const promptId = request.promptId?.trim();
if (!sessionId || !promptId)
throw new Error("sessionId and promptId are required");
return await cloud.removePendingPrompt(sessionId, promptId);
}
default:
throw new Error(
`${request.action} is not supported for cloud sessions yet`,
);
}
}
const handler = ACTION_HANDLERS[request.action];
if (!handler) throw new Error("unsupported action");
return handler(ctx, request);
@@ -0,0 +1,54 @@
import {
captureAuthRefreshSoftFailure,
getProviderAuthHandler,
type ProviderSettingsManager,
RuntimeOAuthTokenManager,
} from "@cline/core";
import type { SidecarContext } from "./types";
// Cline access tokens expire between app launches, so account requests must
// resolve through the refresh-aware OAuth manager instead of reading the
// persisted token directly. A single shared instance keeps concurrent account
// requests single-flight; the refresh token is single-use, so parallel
// refreshes would invalidate each other.
let clineOAuthTokenManager: RuntimeOAuthTokenManager | undefined;
export async function resolveFreshClineAuthToken(
manager: ProviderSettingsManager,
ctx?: SidecarContext,
): Promise<string | undefined> {
let refreshError: Error | undefined;
try {
clineOAuthTokenManager ??= new RuntimeOAuthTokenManager();
const resolution = await clineOAuthTokenManager.resolveProviderApiKey({
providerId: "cline",
});
if (resolution?.apiKey) {
return resolution.apiKey;
}
} catch (error) {
// Fall back to the persisted token; when one exists the account request
// surfaces the auth failure to the caller.
refreshError = error instanceof Error ? error : new Error(String(error));
}
// The canonical handler applies the same formatting the refresh path uses:
// OAuth access tokens gain the `workos:` prefix core-platform expects,
// while raw API keys pass through untouched.
const persisted = getProviderAuthHandler("cline")?.getApiKey(
manager.getProviderSettings("cline"),
);
// Never-signed-in resolves to undefined without a refresh attempt and is
// silent. A refresh failure with no persisted fallback means credentials
// existed but yielded nothing — that is the signal a real auth regression
// would show up as, so report exactly one event for it.
if (!persisted && refreshError && ctx) {
ctx.logger?.error?.("Cline auth token refresh failed with no fallback", {
error: refreshError,
});
captureAuthRefreshSoftFailure(ctx.telemetry, "cline", {
errorName: refreshError.name,
errorCode: "desktop_refresh_failed_no_fallback_token",
});
}
return persisted;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,6 @@ 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 () => {
@@ -22,7 +21,6 @@ vi.mock("@cline/core", async () => {
ProviderSettingsManager: class {
getProviderSettings = getProviderSettingsMock;
},
saveLocalProviderSettings: saveProviderSettingsMock,
RuntimeOAuthTokenManager: class {
resolveProviderApiKey = resolveProviderApiKeyMock;
},
@@ -52,7 +50,6 @@ beforeEach(() => {
clineAccountServiceCtorMock.mockReset();
executeClineAccountActionMock.mockReset();
getProviderSettingsMock.mockReset();
saveProviderSettingsMock.mockReset();
resolveProviderApiKeyMock.mockReset();
});
@@ -113,8 +110,10 @@ describe("cline_account command auth states", () => {
const serviceOptions = clineAccountServiceCtorMock.mock.calls[0][0] as {
getAuthToken: () => Promise<string | undefined>;
};
// Persisted OAuth tokens gain the `workos:` prefix required by
// core-platform (see cline-auth.ts).
await expect(serviceOptions.getAuthToken()).resolves.toBe(
"persisted-token",
"workos:persisted-token",
);
expect(capture).not.toHaveBeenCalled();
});
@@ -143,162 +142,3 @@ 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);
});
});
@@ -0,0 +1,280 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { isClineAccountNotAuthenticatedResult } from "../webview/lib/cline-account-state";
import {
listClineGitHubRepositories,
listClineIntegrations,
resolveGitHubInstallUrl,
} from "./commands-integrations";
import type { SidecarContext } from "./types";
const getProviderSettingsMock = vi.hoisted(() => vi.fn());
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
ProviderSettingsManager: class {
getProviderSettings = getProviderSettingsMock;
},
RuntimeOAuthTokenManager: class {
resolveProviderApiKey = resolveProviderApiKeyMock;
},
};
});
function createContext() {
const capture = vi.fn();
const ctx = {
telemetry: { capture },
logger: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as unknown as SidecarContext;
return { ctx, capture };
}
const REQUEST_OPTIONS = {
apiBaseUrl: "https://api.example.com",
appBaseUrl: "https://app.example.com",
authToken: "test-token",
} as const;
function requestOptions(fetchImpl: ReturnType<typeof vi.fn>) {
return {
...REQUEST_OPTIONS,
fetchImpl: fetchImpl as unknown as typeof fetch,
};
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
beforeEach(() => {
getProviderSettingsMock.mockReset();
resolveProviderApiKeyMock.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("listClineIntegrations", () => {
it("lists integrations through the envelope with a bearer token", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
jsonResponse({ success: true, data: [{ provider: "github" }] }),
);
const result = await listClineIntegrations(requestOptions(fetchImpl));
expect(result).toEqual([{ provider: "github" }]);
const [url, init] = fetchImpl.mock.calls[0] as [URL, RequestInit];
expect(String(url)).toBe("https://api.example.com/api/v1/integrations");
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer test-token",
);
});
});
describe("listClineGitHubRepositories", () => {
it("lists GitHub repositories from the repositories endpoint", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
jsonResponse({ success: true, data: [{ full_name: "cline/cline" }] }),
);
const result = await listClineGitHubRepositories(requestOptions(fetchImpl));
expect(result).toEqual([{ full_name: "cline/cline" }]);
expect(String(fetchImpl.mock.calls[0][0])).toBe(
"https://api.example.com/api/v1/integrations/github/repositories",
);
});
it("surfaces the API envelope error message on failures", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
jsonResponse(
{ success: false, error: "failed to list integrations" },
500,
),
);
await expect(
listClineIntegrations(requestOptions(fetchImpl)),
).rejects.toThrow("failed to list integrations");
});
});
describe("resolveGitHubInstallUrl", () => {
it("resolves the GitHub install URL from the redirect location", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: {
location: "https://github.com/apps/cline/installations/new?state=abc",
},
}),
);
const result = await resolveGitHubInstallUrl(requestOptions(fetchImpl));
expect(result).toEqual({
url: "https://github.com/apps/cline/installations/new?state=abc",
});
const [url, init] = fetchImpl.mock.calls[0] as [URL, RequestInit];
expect(url.origin + url.pathname).toBe(
"https://api.example.com/api/v1/integrations/github/install",
);
// The post-install browser hop must land on the Cline dashboard.
expect(url.searchParams.get("redirect")).toBe(
"https://app.example.com/dashboard/integrations",
);
// The redirect must be read, not followed: the Location URL is the result.
expect(init.redirect).toBe("manual");
});
it("resolves a relative redirect location against the request URL", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: "//github.com/apps/cline/installations/new" },
}),
);
const result = await resolveGitHubInstallUrl(requestOptions(fetchImpl));
// A bare relative Location would blow up later in the URL opener.
expect(result).toEqual({
url: "https://github.com/apps/cline/installations/new",
});
});
it.each([
["https://evil.example/apps/cline", "evil.example"],
["https://github.com.evil.example/apps/cline", "github.com.evil.example"],
// Subdomains are not part of the install flow, so they are not allowed
// either -- the host must be exactly github.com.
["https://gist.github.com/apps/cline", "gist.github.com"],
])("rejects a redirect to a non-GitHub host (%s)", async (location, host) => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
new Response(null, { status: 302, headers: { location } }),
);
await expect(
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
).rejects.toThrow(`unexpected host: ${host}`);
});
it("rejects a redirect that does not use https", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: "http://github.com/apps/cline" },
}),
);
await expect(
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
).rejects.toThrow("must use https");
});
it("rejects a redirect location that is not a usable URL", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: "http://" },
}),
);
await expect(
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
).rejects.toThrow("not a valid URL");
});
it("throws when the install endpoint does not answer with a redirect", async () => {
const fetchImpl = vi
.fn()
.mockResolvedValue(
jsonResponse({ error: "authentication required" }, 401),
);
await expect(
resolveGitHubInstallUrl(requestOptions(fetchImpl)),
).rejects.toThrow("authentication required");
});
});
describe("cline_integrations command auth states", () => {
it("returns a typed not-authenticated result when signed out, without calling the API", async () => {
const { ctx, capture } = createContext();
resolveProviderApiKeyMock.mockResolvedValue(null);
getProviderSettingsMock.mockReturnValue(undefined);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { handleCommand } = await import("./commands");
const result = await handleCommand(ctx, "cline_integrations", {
operation: "list",
});
expect(isClineAccountNotAuthenticatedResult(result)).toBe(true);
expect(fetchMock).not.toHaveBeenCalled();
expect(capture).not.toHaveBeenCalled();
});
it("calls the Cline API with the resolved fresh token when signed in", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({
apiKey: "fresh-token",
refreshed: true,
});
getProviderSettingsMock.mockReturnValue(undefined);
const fetchMock = vi
.fn()
.mockResolvedValue(
jsonResponse({ success: true, data: [{ provider: "github" }] }),
);
vi.stubGlobal("fetch", fetchMock);
const { handleCommand } = await import("./commands");
const result = await handleCommand(ctx, "cline_integrations", {
operation: "list",
});
expect(result).toEqual([{ provider: "github" }]);
const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit];
expect(String(url)).toContain("/api/v1/integrations");
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer fresh-token",
);
});
it("rejects unknown operations", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({
apiKey: "fresh-token",
refreshed: true,
});
getProviderSettingsMock.mockReturnValue(undefined);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { handleCommand } = await import("./commands");
await expect(
handleCommand(ctx, "cline_integrations", {
operation: "dropIntegrations",
}),
).rejects.toThrow("Unsupported Cline integrations operation");
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,183 @@
import type {
ClineGitHubRepository,
ClineIntegration,
} from "../webview/lib/cline-integrations-types";
const DEFAULT_TIMEOUT_MS = 30_000;
const GITHUB_INSTALL_HOST = "github.com";
function resolveInstallRedirect(location: string, requestUrl: URL): string {
let resolved: URL;
try {
resolved = new URL(location, requestUrl);
} catch {
throw new Error(`GitHub install redirect is not a valid URL: ${location}`);
}
if (resolved.protocol !== "https:") {
throw new Error(
`GitHub install redirect must use https, got: ${resolved.protocol}`,
);
}
if (resolved.hostname !== GITHUB_INSTALL_HOST) {
throw new Error(
`GitHub install redirect pointed at an unexpected host: ${resolved.hostname}`,
);
}
return resolved.toString();
}
export interface ClineIntegrationsRequestOptions {
apiBaseUrl: string;
/** Frontend origin the browser install flow returns to when it finishes. */
appBaseUrl: string;
authToken: string;
requestTimeoutMs?: number;
fetchImpl?: typeof fetch;
}
export async function listClineIntegrations(
options: ClineIntegrationsRequestOptions,
): Promise<ClineIntegration[]> {
const data = await requestClineApiJson("/api/v1/integrations", options);
return Array.isArray(data) ? (data as ClineIntegration[]) : [];
}
export async function listClineGitHubRepositories(
options: ClineIntegrationsRequestOptions,
): Promise<ClineGitHubRepository[]> {
const data = await requestClineApiJson(
"/api/v1/integrations/github/repositories",
options,
);
return Array.isArray(data) ? (data as ClineGitHubRepository[]) : [];
}
export async function resolveGitHubInstallUrl(
options: ClineIntegrationsRequestOptions,
): Promise<{ url: string }> {
const fetchImpl = options.fetchImpl ?? fetch;
const installUrl = new URL(
"/api/v1/integrations/github/install",
options.apiBaseUrl,
);
installUrl.searchParams.set(
"redirect",
new URL("/dashboard/integrations", options.appBaseUrl).toString(),
);
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS,
);
try {
const response = await fetchImpl(installUrl, {
method: "GET",
headers: { Authorization: `Bearer ${options.authToken}` },
redirect: "manual",
signal: controller.signal,
});
const location = response.headers.get("location");
if (response.status >= 300 && response.status < 400 && location?.trim()) {
return { url: resolveInstallRedirect(location.trim(), installUrl) };
}
const text = await response.text().catch(() => "");
let parsed: unknown;
try {
parsed = text.trim() ? JSON.parse(text) : undefined;
} catch {
parsed = undefined;
}
throw new Error(formatRequestFailure(response.status, text, parsed));
} finally {
clearTimeout(timeout);
}
}
function getEnvelopeError(parsed: unknown): string | undefined {
if (typeof parsed !== "object" || parsed === null || !("error" in parsed)) {
return undefined;
}
const error = (parsed as { error?: unknown }).error;
return typeof error === "string" && error.trim() ? error : undefined;
}
function formatRequestFailure(
status: number,
bodyText: string,
parsed: unknown,
): string {
const envelopeError = getEnvelopeError(parsed);
if (envelopeError) {
return envelopeError;
}
const body = bodyText.trim();
if (body) {
const preview = body.length > 200 ? `${body.slice(0, 200)}...` : body;
return `Cline integrations request failed with status ${status}: ${preview}`;
}
return `Cline integrations request failed with status ${status}`;
}
async function requestClineApiJson(
endpoint: string,
options: ClineIntegrationsRequestOptions,
): Promise<unknown> {
const fetchImpl = options.fetchImpl ?? fetch;
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS,
);
try {
const response = await fetchImpl(new URL(endpoint, options.apiBaseUrl), {
method: "GET",
headers: {
Authorization: `Bearer ${options.authToken}`,
"Content-Type": "application/json",
},
signal: controller.signal,
});
const text = await response.text();
let parsed: unknown;
if (text.trim()) {
try {
parsed = JSON.parse(text);
} catch {
if (!response.ok) {
throw new Error(
formatRequestFailure(response.status, text, undefined),
);
}
throw new Error("Cline integrations response was not valid JSON");
}
}
if (!response.ok) {
throw new Error(formatRequestFailure(response.status, text, parsed));
}
if (typeof parsed === "object" && parsed !== null && "success" in parsed) {
const envelope = parsed as {
success?: unknown;
error?: unknown;
data?: unknown;
};
if (typeof envelope.success === "boolean") {
if (!envelope.success) {
throw new Error(
getEnvelopeError(parsed) || "Cline integrations request failed",
);
}
return envelope.data ?? null;
}
}
return parsed ?? null;
} finally {
clearTimeout(timeout);
}
}
@@ -0,0 +1,113 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { handleCommand } from "./commands";
import type { SidecarContext } from "./types";
function createContext(): {
ctx: SidecarContext;
events: Array<{ name: string; payload: Record<string, unknown> }>;
} {
const events: Array<{ name: string; payload: Record<string, unknown> }> = [];
const ctx = {
liveSessions: new Map(),
restoringWorkspacePaths: new Set(),
streamIndices: new Map(),
wsClients: new Set([
{
send(message: string) {
const parsed = JSON.parse(message) as {
event: { name: string; payload: Record<string, unknown> };
};
events.push(parsed.event);
},
},
]),
pendingApprovals: new Map(),
pendingQuestions: new Map(),
sessionManager: null,
hubClient: null,
workspaceRoot: "/local/workspace",
unsubscribeSessionEvents: null,
cloudSessionManager: null,
} as unknown as SidecarContext;
return { ctx, events };
}
let dataDir: string;
beforeEach(() => {
dataDir = mkdtempSync(join(tmpdir(), "cline-commands-settings-"));
process.env.CLINE_DATA_DIR = dataDir;
});
afterEach(() => {
delete process.env.CLINE_CODE_CLOUD_AGENTS;
delete process.env.CLINE_DATA_DIR;
rmSync(dataDir, { recursive: true, force: true });
});
describe("desktop settings commands", () => {
it("reads default desktop settings and an off feature gate", async () => {
const { ctx } = createContext();
await expect(
handleCommand(ctx, "get_desktop_settings", {}),
).resolves.toEqual({ cloudSessionsEnabled: false });
await expect(handleCommand(ctx, "get_feature_flags", {})).resolves.toEqual({
cloudAgents: false,
});
});
it("rejects a non-boolean cloud sessions toggle value", async () => {
const { ctx, events } = createContext();
await expect(
handleCommand(ctx, "set_cloud_sessions_enabled", {
cloud_sessions_enabled: "yes",
}),
).rejects.toThrow("cloud_sessions_enabled must be a boolean");
expect(events).toEqual([]);
});
it("persists the toggle and broadcasts the new gate immediately", async () => {
const { ctx, events } = createContext();
await expect(
handleCommand(ctx, "set_cloud_sessions_enabled", {
cloud_sessions_enabled: true,
}),
).resolves.toEqual({ cloudSessionsEnabled: true });
// Open webviews re-evaluate without waiting for a restart or account
// change.
expect(events).toEqual([
{ name: "feature_flags_changed", payload: { cloudAgents: true } },
]);
await expect(handleCommand(ctx, "get_feature_flags", {})).resolves.toEqual({
cloudAgents: true,
});
await handleCommand(ctx, "set_cloud_sessions_enabled", {
cloud_sessions_enabled: false,
});
expect(events.at(-1)).toEqual({
name: "feature_flags_changed",
payload: { cloudAgents: false },
});
});
it("reports the env override through the feature gate", async () => {
const { ctx } = createContext();
process.env.CLINE_CODE_CLOUD_AGENTS = "1";
await expect(handleCommand(ctx, "get_feature_flags", {})).resolves.toEqual({
cloudAgents: true,
});
// The toggle's stored value is reported as-is; the override only
// affects the effective gate.
await expect(
handleCommand(ctx, "get_desktop_settings", {}),
).resolves.toEqual({ cloudSessionsEnabled: false });
});
});
File diff suppressed because it is too large Load Diff
+114 -510
View File
@@ -15,17 +15,17 @@ const hubGetUrlMock = vi.hoisted(() => vi.fn());
const hubIsConnectedMock = vi.hoisted(() => vi.fn());
const nodeHubClientCtorMock = vi.hoisted(() => vi.fn());
const subscribeMock = vi.hoisted(() => vi.fn());
const updateCapabilitiesMock = vi.hoisted(() => vi.fn());
vi.mock("@ai-sdk/provider-utils", () => ({
createProviderDefinedToolFactory: vi.fn(() => vi.fn()),
}));
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
ClientSettingsManager: class {
initializeModesIfMissing = vi.fn();
read = vi.fn(() => ({ modes: {} }));
getModeSettings = vi.fn(() => undefined);
},
ClineCore: {
create: createCoreMock,
},
@@ -40,7 +40,6 @@ vi.mock("@cline/core", async () => {
getUrl = hubGetUrlMock;
isConnected = hubIsConnectedMock;
subscribe = subscribeMock;
updateCapabilities = updateCapabilitiesMock;
dispose = vi.fn();
},
};
@@ -70,7 +69,6 @@ describe("Code sidecar runtime capabilities", () => {
hubIsConnectedMock.mockReset();
nodeHubClientCtorMock.mockReset();
subscribeMock.mockReset();
updateCapabilitiesMock.mockReset();
connectMock.mockResolvedValue(undefined);
ensureCompatibleLocalHubUrlMock.mockResolvedValue(
"ws://127.0.0.1:25463/hub",
@@ -80,7 +78,6 @@ describe("Code sidecar runtime capabilities", () => {
hubGetUrlMock.mockReturnValue("ws://127.0.0.1:25463/hub");
hubIsConnectedMock.mockReturnValue(true);
subscribeMock.mockReturnValue(() => {});
updateCapabilitiesMock.mockResolvedValue(undefined);
createCoreMock.mockResolvedValue({
runtimeAddress: "ws://127.0.0.1:25463/hub",
subscribe: vi.fn(() => () => {}),
@@ -88,7 +85,7 @@ describe("Code sidecar runtime capabilities", () => {
});
});
it("registers the desktop capability factory with core", async () => {
it("registers Code App capability factory with core", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
@@ -110,7 +107,7 @@ describe("Code sidecar runtime capabilities", () => {
workspaceRoot: "/workspace/project",
cwd: "/workspace/project",
clientType: "code-sidecar",
displayName: "Cline Desktop sidecar",
displayName: "Code App sidecar",
}),
}),
);
@@ -121,7 +118,7 @@ describe("Code sidecar runtime capabilities", () => {
expect.objectContaining({
url: "ws://127.0.0.1:25463/hub",
clientType: "code-sidecar-observer",
displayName: "Cline Desktop observer",
displayName: "Code App observer",
}),
);
});
@@ -194,7 +191,7 @@ describe("Code sidecar runtime capabilities", () => {
const ctx = createSidecarContext("/workspace/project");
const hubClient = await ensureSharedHubClient(ctx);
expect(hubClient).toBe(ctx.hubClient);
expect(hubClient).toBeDefined();
expect(ensureCompatibleLocalHubUrlMock).toHaveBeenCalledWith({
strategy: "require-hub",
@@ -210,78 +207,6 @@ describe("Code sidecar runtime capabilities", () => {
expect(connectMock).toHaveBeenCalledOnce();
});
it("forwards raw hub tool updates to attached desktop sessions", async () => {
const { createSidecarContext, handleHubLiveEvent } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() });
ctx.liveSessions.set("session-1", {
config: {},
messages: [],
promptsInQueue: [],
busy: true,
startedAt: Date.now(),
status: "running",
attachedViaHub: true,
});
handleHubLiveEvent(ctx, {
event: "tool.updated",
sessionId: "session-1",
payload: {
toolCallId: "call-1",
toolName: "run_commands",
update: { stream: "stdout", chunk: "live\n" },
},
});
const forwarded = readEvents(ctx).find(
(message) =>
message.event.name === "chat_event" &&
(message.event.payload as { stream?: string }).stream ===
"chat_tool_call_update",
);
expect(forwarded?.event.payload).toMatchObject({
sessionId: "session-1",
stream: "chat_tool_call_update",
});
expect(
JSON.parse(
String((forwarded?.event.payload as { chunk?: string }).chunk),
),
).toEqual({
toolCallId: "call-1",
toolName: "run_commands",
update: { stream: "stdout", chunk: "live\n" },
});
});
it("forwards proceed-while-running requests to the hub", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const { handleCommand } = await import("./commands");
hubCommandMock.mockResolvedValue({
ok: true,
payload: { detachedCount: 1 },
});
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
await expect(
handleCommand(ctx, "proceed_while_running", {
sessionId: "session-1",
toolCallId: "call-1",
}),
).resolves.toEqual({ detachedCount: 1 });
expect(hubCommandMock).toHaveBeenCalledWith(
"run.proceed_while_running",
{ sessionId: "session-1", toolCallId: "call-1" },
"session-1",
);
});
it("serializes queued image data when a queued prompt starts", async () => {
const { serializeQueuedPromptStart } = await import("./context");
@@ -302,6 +227,43 @@ describe("Code sidecar runtime capabilities", () => {
});
});
it("leaves attached-session content projection to the Core event stream", async () => {
const { createSidecarContext, handleHubLiveEvent } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() });
ctx.liveSessions.set("session-image", {
config: {},
messages: [],
promptsInQueue: [],
busy: true,
startedAt: Date.now(),
status: "running",
attachedViaHub: true,
});
for (const event of [
"assistant.delta",
"assistant.image",
"reasoning.delta",
"tool.started",
"tool.finished",
]) {
handleHubLiveEvent(ctx, {
event,
sessionId: "session-image",
payload: {
text: "one canonical copy",
toolCallId: "tool-1",
toolName: "run_commands",
},
});
}
expect(readEvents(ctx)).toEqual([]);
});
it("announces a queued prompt start once when drain emits both queue events", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
@@ -467,11 +429,7 @@ describe("Code sidecar runtime capabilities", () => {
const { handleCommand } = await import("./commands");
const ctx = createSidecarContext("/workspace/project");
const approvalClient = {
data: { canApproveTools: true },
send: vi.fn(),
};
ctx.wsClients.add(approvalClient);
ctx.wsClients.add({ send: vi.fn() });
await initializeSessionManager(ctx);
@@ -481,7 +439,6 @@ describe("Code sidecar runtime capabilities", () => {
"Which branch?",
["Keep current", "Create new"],
{
sessionId: "session-1",
agentId: "agent-1",
conversationId: "conversation-1",
iteration: 3,
@@ -493,7 +450,6 @@ describe("Code sidecar runtime capabilities", () => {
(item) => item.event.name === "ask_question_requested",
);
expect(event?.event.payload).toMatchObject({
sessionId: "session-1",
question: "Which branch?",
options: ["Keep current", "Create new"],
context: {
@@ -504,23 +460,6 @@ describe("Code sidecar runtime capabilities", () => {
});
const requestId = String(event?.event.payload.requestId ?? "");
expect(requestId.length).toBeGreaterThan(0);
expect(
await handleCommand(ctx, "poll_ask_questions", {
sessionId: "session-1",
}),
).toEqual([
expect.objectContaining({
requestId,
sessionId: "session-1",
question: "Which branch?",
options: ["Keep current", "Create new"],
}),
]);
expect(
await handleCommand(ctx, "poll_ask_questions", {
sessionId: "another-session",
}),
).toEqual([]);
await handleCommand(ctx, "respond_ask_question", {
requestId,
@@ -529,11 +468,6 @@ describe("Code sidecar runtime capabilities", () => {
await expect(answer).resolves.toBe("Create new");
expect(ctx.pendingQuestions.size).toBe(0);
expect(
await handleCommand(ctx, "poll_ask_questions", {
sessionId: "session-1",
}),
).toEqual([]);
expect(readEvents(ctx)).toContainEqual(
expect.objectContaining({
event: expect.objectContaining({
@@ -544,32 +478,6 @@ describe("Code sidecar runtime capabilities", () => {
);
});
it("rejects askQuestion requests without an owning session", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() });
await initializeSessionManager(ctx);
const capabilities = createCoreMock.mock.calls[0][0]
.capabilities as RuntimeCapabilities;
const answer = capabilities.toolExecutors?.askQuestion?.(
"Which branch?",
["Keep current", "Create new"],
{ agentId: "agent-1", iteration: 3 },
);
await expect(answer).rejects.toThrow(
"ask_question requires an active session ID",
);
expect(
readEvents(ctx).some(
(message) => message.event.name === "ask_question_requested",
),
).toBe(false);
});
it("resolves approval through websocket state", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
@@ -577,11 +485,7 @@ describe("Code sidecar runtime capabilities", () => {
const { handleCommand } = await import("./commands");
const ctx = createSidecarContext("/workspace/project");
const approvalClient = {
data: { canApproveTools: true },
send: vi.fn(),
};
ctx.wsClients.add(approvalClient);
ctx.wsClients.add({ send: vi.fn() });
await initializeSessionManager(ctx);
@@ -594,7 +498,7 @@ describe("Code sidecar runtime capabilities", () => {
hub: expect.objectContaining({
strategy: "require-hub",
clientType: "code-sidecar",
displayName: "Cline Desktop sidecar",
displayName: "Code App sidecar",
}),
}),
);
@@ -613,12 +517,9 @@ describe("Code sidecar runtime capabilities", () => {
});
expect(approval).toBeInstanceOf(Promise);
const pending = await handleCommand(
ctx,
"poll_tool_approvals",
{ sessionId: "sess-1" },
{ connection: approvalClient },
);
const pending = await handleCommand(ctx, "poll_tool_approvals", {
sessionId: "sess-1",
});
expect(pending).toEqual([
expect.objectContaining({
sessionId: "sess-1",
@@ -644,268 +545,47 @@ describe("Code sidecar runtime capabilities", () => {
);
const [{ requestId }] = pending as Array<{ requestId: string }>;
const untrustedClient = { send: vi.fn() };
ctx.wsClients.add(untrustedClient);
await expect(
handleCommand(
ctx,
"respond_tool_approval",
{ sessionId: "sess-1", requestId, approved: true },
{ connection: untrustedClient },
),
).rejects.toThrow("trusted desktop connection");
expect(ctx.pendingApprovals.size).toBe(1);
await handleCommand(
ctx,
"respond_tool_approval",
{ sessionId: "sess-1", requestId, approved: true },
{ connection: approvalClient },
);
await handleCommand(ctx, "respond_tool_approval", {
sessionId: "sess-1",
requestId,
approved: true,
});
await expect(approval).resolves.toEqual({ approved: true });
expect(
await handleCommand(
ctx,
"poll_tool_approvals",
{ sessionId: "sess-1" },
{ connection: approvalClient },
),
await handleCommand(ctx, "poll_tool_approvals", { sessionId: "sess-1" }),
).toEqual([]);
});
it("rejects and removes an approval when initial delivery fails", async () => {
const { createSidecarContext, createSidecarRuntimeCapabilities } =
await import("./context");
const ctx = createSidecarContext("/workspace/project");
const failedClient = {
data: { canApproveTools: true },
send: vi.fn(() => {
throw new Error("socket closed");
}),
};
ctx.wsClients.add(failedClient);
const approval = createSidecarRuntimeCapabilities(
ctx,
).requestToolApproval?.({
sessionId: "sess-1",
agentId: "agent-1",
conversationId: "conversation-1",
iteration: 1,
toolCallId: "tool-call-1",
toolName: "run_commands",
input: { commands: ["echo hi"] },
policy: { autoApprove: false },
});
await expect(approval).resolves.toEqual({
approved: false,
reason: "Desktop approval surface disconnected",
});
expect(ctx.pendingApprovals.size).toBe(0);
expect(ctx.wsClients.has(failedClient)).toBe(false);
});
it("rejects an owned approval when a later broadcast fails", async () => {
const {
broadcastEvent,
createSidecarContext,
createSidecarRuntimeCapabilities,
} = await import("./context");
const ctx = createSidecarContext("/workspace/project");
const approvalClient = {
data: { canApproveTools: true },
send: vi
.fn()
.mockImplementationOnce(() => undefined)
.mockImplementationOnce(() => {
throw new Error("socket closed");
}),
};
ctx.wsClients.add(approvalClient);
const approval = createSidecarRuntimeCapabilities(
ctx,
).requestToolApproval?.({
sessionId: "sess-1",
agentId: "agent-1",
conversationId: "conversation-1",
iteration: 1,
toolCallId: "tool-call-1",
toolName: "run_commands",
input: { commands: ["echo hi"] },
policy: { autoApprove: false },
});
expect(ctx.pendingApprovals.size).toBe(1);
broadcastEvent(ctx, "task.updated", { taskId: "task-1" });
await expect(approval).resolves.toEqual({
approved: false,
reason: "Desktop approval surface disconnected",
});
expect(ctx.pendingApprovals.size).toBe(0);
expect(ctx.wsClients.has(approvalClient)).toBe(false);
});
it("rejects sibling approvals when a targeted state update fails", async () => {
const { createSidecarContext, createSidecarRuntimeCapabilities } =
await import("./context");
it("keeps an approval visible when its remote acknowledgement fails", async () => {
const { createSidecarContext } = await import("./context");
const { handleCommand } = await import("./commands");
const ctx = createSidecarContext("/workspace/project");
const approvalClient = {
data: { canApproveTools: true },
send: vi
.fn()
.mockImplementationOnce(() => undefined)
.mockImplementationOnce(() => undefined)
.mockImplementationOnce(() => {
throw new Error("socket closed");
}),
};
ctx.wsClients.add(approvalClient);
const capabilities = createSidecarRuntimeCapabilities(ctx);
const request = (toolCallId: string) =>
capabilities.requestToolApproval?.({
sessionId: "sess-1",
agentId: "agent-1",
conversationId: "conversation-1",
iteration: 1,
toolCallId,
ctx.pendingApprovals.set("cloud-approval", {
item: {
requestId: "cloud-approval",
sessionId: "ses-cloud",
createdAt: new Date().toISOString(),
toolCallId: "tool-1",
toolName: "run_commands",
input: { commands: ["echo hi"] },
policy: { autoApprove: false },
});
const firstApproval = request("tool-call-1");
const siblingApproval = request("tool-call-2");
const [{ requestId }] = (await handleCommand(
ctx,
"poll_tool_approvals",
{ sessionId: "sess-1" },
{ connection: approvalClient },
)) as Array<{ requestId: string }>;
await handleCommand(
ctx,
"respond_tool_approval",
{ sessionId: "sess-1", requestId, approved: true },
{ connection: approvalClient },
);
await expect(firstApproval).resolves.toEqual({ approved: true });
await expect(siblingApproval).resolves.toEqual({
approved: false,
reason: "Desktop approval surface disconnected",
});
expect(ctx.pendingApprovals.size).toBe(0);
expect(ctx.wsClients.has(approvalClient)).toBe(false);
});
it("serializes approval readiness updates and publishes the latest state", async () => {
const {
createSidecarContext,
initializeSessionManager,
syncSidecarApprovalReadiness,
} = await import("./context");
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
updateCapabilitiesMock.mockReset();
let finishDisconnectedUpdate: (() => void) | undefined;
updateCapabilitiesMock
.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finishDisconnectedUpdate = resolve;
}),
)
.mockResolvedValue(undefined);
const disconnected = syncSidecarApprovalReadiness(ctx);
await vi.waitFor(() =>
expect(updateCapabilitiesMock).toHaveBeenCalledWith([]),
);
ctx.wsClients.add({
data: { canApproveTools: true },
send: vi.fn(),
});
const connected = syncSidecarApprovalReadiness(ctx);
expect(updateCapabilitiesMock).toHaveBeenCalledTimes(1);
finishDisconnectedUpdate?.();
await Promise.all([disconnected, connected]);
expect(updateCapabilitiesMock).toHaveBeenLastCalledWith([
expect.objectContaining({ name: "approval.respond" }),
]);
});
it("forwards Hub-owned task session approvals to the live desktop", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const { handleCommand } = await import("./commands");
let onHubEvent: ((event: Record<string, unknown>) => void) | undefined;
subscribeMock.mockImplementation((handler) => {
onHubEvent = handler;
return () => {};
});
const ctx = createSidecarContext("/workspace/project");
const approvalClient = {
data: { canApproveTools: true },
send: vi.fn(),
};
ctx.wsClients.add(approvalClient);
await initializeSessionManager(ctx);
expect(updateCapabilitiesMock).toHaveBeenCalledWith([
expect.objectContaining({ name: "approval.respond" }),
]);
onHubEvent?.({
event: "approval.requested",
sessionId: "task-session-1",
payload: {
approvalId: "hub-approval-1",
agendaTaskId: "task-1",
agentId: "task-agent-1",
conversationId: "task-conversation-1",
iteration: 2,
toolCallId: "tool-call-1",
toolName: "write_to_file",
inputJson: JSON.stringify({ path: "src/a.ts" }),
policy: { autoApprove: false },
},
resolve: async () => {
throw new Error("hub disconnected");
},
});
await vi.waitFor(() => expect(ctx.pendingApprovals.size).toBe(1));
const pendingItems = (await handleCommand(
ctx,
"poll_tool_approvals",
{ sessionId: "task-session-1" },
{ connection: approvalClient },
)) as Array<{ requestId: string }>;
const pending = pendingItems[0];
if (!pending) throw new Error("expected a pending task approval");
await handleCommand(
ctx,
"respond_tool_approval",
{
sessionId: "task-session-1",
requestId: pending.requestId,
await expect(
handleCommand(ctx, "respond_tool_approval", {
sessionId: "ses-cloud",
requestId: "cloud-approval",
approved: true,
},
{ connection: approvalClient },
);
await vi.waitFor(() =>
expect(hubCommandMock).toHaveBeenCalledWith(
"approval.respond",
{
approvalId: "hub-approval-1",
approved: true,
reason: undefined,
},
"task-session-1",
),
);
}),
).rejects.toThrow("hub disconnected");
expect(
await handleCommand(ctx, "poll_tool_approvals", {
sessionId: "ses-cloud",
}),
).toEqual([expect.objectContaining({ requestId: "cloud-approval" })]);
});
it("routes routine commands through the connected shared Hub client", async () => {
@@ -932,115 +612,6 @@ describe("Code sidecar runtime capabilities", () => {
scheduleId: "schedule-1",
});
});
it("proxies Agenda task commands through the connected shared Hub", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const { handleCommand } = await import("./commands");
const task = {
taskId: "task-1",
title: "Review the PR",
status: "pending_approval",
};
hubCommandMock.mockResolvedValue({
ok: true,
payload: { tasks: [task] },
});
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
const approvalClient = {
data: { canApproveTools: true },
send: vi.fn(),
};
await expect(
handleCommand(ctx, "task.list", {
workspaceRoot: "/workspace/project",
statuses: ["pending_approval"],
}),
).resolves.toEqual({ tasks: [task] });
expect(hubCommandMock).toHaveBeenCalledWith("task.list", {
workspaceRoot: "/workspace/project",
statuses: ["pending_approval"],
});
hubCommandMock.mockResolvedValueOnce({
ok: true,
payload: { task: { ...task, status: "in_progress", revision: 4 } },
});
await expect(
handleCommand(
ctx,
"task.run",
{
taskId: "task-1",
expectedRevision: 4,
},
{ connection: approvalClient },
),
).resolves.toEqual({
task: { ...task, status: "in_progress", revision: 4 },
});
expect(hubCommandMock).toHaveBeenCalledWith("task.run", {
taskId: "task-1",
expectedRevision: 4,
});
});
it.each([
"task.create",
"task.approve",
"task.cancel",
"task.run",
"task.automation.set",
])("rejects untrusted %s commands before they reach the shared Hub", async (command) => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const { handleCommand } = await import("./commands");
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
const untrustedClient = {
data: { canApproveTools: false },
send: vi.fn(),
};
await expect(
handleCommand(ctx, command, {}, { connection: untrustedClient }),
).rejects.toThrow("task execution requires a trusted desktop connection");
expect(hubCommandMock).not.toHaveBeenCalled();
});
it("forwards Hub task events that do not have a session", async () => {
const { createSidecarContext, handleHubLiveEvent } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() } as never);
handleHubLiveEvent(ctx, {
event: "task.created",
payload: {
taskId: "task-1",
status: "pending_approval",
},
});
expect(readEvents(ctx)).toEqual([
{
type: "event",
event: {
name: "task.created",
payload: {
taskId: "task-1",
status: "pending_approval",
},
},
},
]);
});
});
describe("disposeSidecarContext attachment cleanup", () => {
@@ -1091,4 +662,37 @@ describe("disposeSidecarContext attachment cleanup", () => {
expect(existsSync(queuedFile)).toBe(false);
expect(ctx.liveSessions.size).toBe(0);
});
it("waits for pending approval callbacks before shutdown completes", async () => {
const { createSidecarContext, disposeSidecarContext } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
let release: (() => void) | undefined;
ctx.pendingApprovals.set("approval-1", {
item: {
requestId: "approval-1",
sessionId: "session-1",
createdAt: new Date().toISOString(),
toolCallId: "tool-1",
toolName: "run_commands",
input: {},
},
resolve: async () =>
await new Promise<void>((resolve) => {
release = resolve;
}),
});
let disposed = false;
const disposing = disposeSidecarContext(ctx, "test_shutdown").then(() => {
disposed = true;
});
await vi.waitFor(() => expect(release).toBeTypeOf("function"));
expect(disposed).toBe(false);
release?.();
await disposing;
expect(disposed).toBe(true);
});
});
+340 -311
View File
@@ -15,37 +15,29 @@ import {
type ToolApprovalRequest,
type ToolApprovalResult,
} from "@cline/core";
import {
type AgentEvent,
HUB_CLIENT_TOOL_APPROVAL_CAPABILITY,
isGeneratedMedia,
} from "@cline/shared";
import { type AgentEvent, isGeneratedMedia } from "@cline/shared";
import {
discardAllTrackedAttachments,
flushConsumedAttachments,
markQueuedAttachmentsSubmitted,
reconcileQueuedAttachments,
} from "./attachments";
import {
disposeDesktopFeatureFlagsService,
getDesktopFeatureFlagsService,
} from "./feature-flags";
import { sessionLogPath } from "./paths";
import type {
LiveSession,
PendingAskQuestion,
PendingToolApproval,
PromptInQueue,
SessionRuntimeBinding,
SidecarContext,
SidecarWebSocketClient,
} from "./types";
import { LOCAL_ENVIRONMENT_ID } from "./types";
const ASK_QUESTION_TIMEOUT_MS = 5 * 60_000;
const hubClientInitialization = new WeakMap<
SidecarContext,
Promise<NodeHubClient>
>();
const approvalReadinessUpdates = new WeakMap<SidecarContext, Promise<void>>();
// ---------------------------------------------------------------------------
// Helpers — WebSocket broadcast
@@ -69,76 +61,10 @@ function sendEvent(ctx: SidecarContext, name: string, payload: unknown): void {
client.send(encoded);
} catch {
ctx.wsClients.delete(client);
cancelSidecarToolApprovalsForOwner(ctx, client);
void syncSidecarApprovalReadiness(ctx).catch((error) =>
ctx.logger?.error?.("Hub approval readiness update failed", { error }),
);
}
}
}
export function sendEventToClient(
ctx: SidecarContext,
client: SidecarWebSocketClient,
name: string,
payload: unknown,
): boolean {
try {
client.send(encodeSidecarEvent(name, payload));
return true;
} catch {
ctx.wsClients.delete(client);
cancelSidecarToolApprovalsForOwner(ctx, client);
void syncSidecarApprovalReadiness(ctx).catch((error) =>
ctx.logger?.error?.("Hub approval readiness update failed", { error }),
);
return false;
}
}
export function cancelSidecarToolApprovalsForOwner(
ctx: SidecarContext,
owner: SidecarWebSocketClient,
): void {
for (const [requestId, pending] of ctx.pendingApprovals) {
if (pending.owner !== owner) continue;
ctx.pendingApprovals.delete(requestId);
pending.resolve({
approved: false,
reason: "Desktop approval surface disconnected",
});
}
}
export function syncSidecarApprovalReadiness(
ctx: SidecarContext,
): Promise<void> {
const previous = approvalReadinessUpdates.get(ctx) ?? Promise.resolve();
const update = previous.catch(() => undefined).then(async () => {
const hubClient = ctx.hubClient;
if (!hubClient) return;
await hubClient.updateCapabilities(
[...ctx.wsClients].some(
(client) => client.data?.canApproveTools === true,
)
? [
{
name: HUB_CLIENT_TOOL_APPROVAL_CAPABILITY,
description:
"Cline Code has a live user surface for tool review.",
},
]
: [],
);
});
approvalReadinessUpdates.set(ctx, update);
return update.finally(() => {
if (approvalReadinessUpdates.get(ctx) === update) {
approvalReadinessUpdates.delete(ctx);
}
});
}
// Session log appends are chained per session so writes stay ordered, but
// they run asynchronously: a synchronous write per streamed token would stall
// the sidecar event loop (and therefore every pending UI command) under load.
@@ -241,7 +167,7 @@ export function serializeQueuedPromptStart(input: {
});
}
function sendPromptsInQueueSnapshot(
export function sendPromptsInQueueSnapshot(
ctx: SidecarContext,
sessionId: string,
): void {
@@ -438,7 +364,7 @@ function emitQueuedPromptStart(
);
}
export function handleCoreSessionEvent(
function handleCoreSessionEvent(
ctx: SidecarContext,
event: CoreSessionEvent,
): void {
@@ -577,12 +503,15 @@ export function createSidecarContext(
wsClients: new Set(),
pendingApprovals: new Map(),
pendingQuestions: new Map(),
sessionManager: null,
hubClient: null,
workspaceRoot,
runtimeBindings: new Map(),
sessionEnvironmentIds: new Map(),
activeEnvironmentId: LOCAL_ENVIRONMENT_ID,
remoteEnvironments: null,
localWorkspaceRoot: workspaceRoot,
logger: observability.logger,
telemetry: observability.telemetry,
unsubscribeSessionEvents: null,
cloudSessionManager: null,
hubBuildMismatch: null,
};
}
@@ -592,9 +521,7 @@ export async function disposeSidecarContext(
reason = "code_sidecar_shutdown",
): Promise<void> {
const cleanup: Array<Promise<unknown>> = [];
ctx.unsubscribeSessionEvents?.();
ctx.unsubscribeSessionEvents = null;
const approvalCleanup: Array<Promise<unknown>> = [];
for (const [sessionId, session] of ctx.liveSessions) {
discardAllTrackedAttachments(sessionId, session);
@@ -610,7 +537,21 @@ export async function disposeSidecarContext(
}
ctx.wsClients.clear();
for (const pending of ctx.pendingApprovals.values()) {
pending.resolve({ approved: false, reason });
// Cloud sessions outlive this app: denying their approvals on local
// shutdown would fail a tool call on a pod that keeps running and
// could otherwise be answered later (from here or another surface).
// Drop those entries locally and leave the remote approval pending.
if (ctx.cloudSessionManager?.isCloudSession(pending.item.sessionId)) {
continue;
}
try {
approvalCleanup.push(
Promise.resolve(pending.resolve({ approved: false, reason })),
);
} catch (error) {
// Keep disposing the remaining resources, then preserve the failure.
approvalCleanup.push(Promise.reject(error));
}
}
ctx.pendingApprovals.clear();
for (const pending of ctx.pendingQuestions.values()) {
@@ -618,24 +559,28 @@ export async function disposeSidecarContext(
pending.reject(new Error(reason));
}
ctx.pendingQuestions.clear();
// Approval callbacks may need the Hub/cloud clients that are disposed below.
const approvalResults = await Promise.allSettled(approvalCleanup);
const hubClient = ctx.hubClient;
ctx.hubClient = null;
if (hubClient) {
cleanup.push(hubClient.dispose());
const cloudSessionManager = ctx.cloudSessionManager;
ctx.cloudSessionManager = null;
if (cloudSessionManager) {
cleanup.push(cloudSessionManager.dispose());
}
const sessionManager = ctx.sessionManager;
ctx.sessionManager = null;
if (sessionManager) {
cleanup.push(sessionManager.dispose(reason));
for (const binding of ctx.runtimeBindings?.values() ?? []) {
binding.unsubscribeSessionEvents();
cleanup.push(binding.hubClient.dispose());
cleanup.push(binding.sessionManager.dispose(reason));
}
ctx.runtimeBindings?.clear();
ctx.sessionEnvironmentIds?.clear();
if (ctx.remoteEnvironments) {
cleanup.push(ctx.remoteEnvironments.dispose());
ctx.remoteEnvironments = null;
}
// 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 results = [...approvalResults, ...(await Promise.allSettled(cleanup))];
const firstFailure = results.find(
(result): result is PromiseRejectedResult => result.status === "rejected",
);
@@ -661,12 +606,6 @@ export function requestSidecarAskQuestion(
options: string[],
context: AgentToolContext,
): Promise<string> {
const sessionId = context.sessionId?.trim();
if (!sessionId) {
return Promise.reject(
new Error("ask_question requires an active session ID"),
);
}
const choices = options
.map((option) => option.trim())
.filter((option) => option.length > 0)
@@ -692,7 +631,6 @@ export function requestSidecarAskQuestion(
const pending: PendingAskQuestion = {
item: {
requestId,
sessionId,
createdAt: new Date().toISOString(),
question,
options: choices,
@@ -738,15 +676,6 @@ function requestSidecarToolApproval(
ctx: SidecarContext,
request: ToolApprovalRequest,
): Promise<ToolApprovalResult> {
const owner = [...ctx.wsClients].find(
(client) => client.data?.canApproveTools === true,
);
if (!owner) {
return Promise.resolve({
approved: false,
reason: "No trusted desktop approval surface is connected",
});
}
return new Promise<ToolApprovalResult>((resolve) => {
const requestId = randomUUID();
const pending: PendingToolApproval = {
@@ -761,25 +690,16 @@ function requestSidecarToolApproval(
agentId: request.agentId,
conversationId: request.conversationId,
},
owner,
resolve,
};
ctx.pendingApprovals.set(requestId, pending);
const sessionApprovals = Array.from(ctx.pendingApprovals.values())
.filter(
(approval) =>
approval.owner === owner &&
approval.item.sessionId === request.sessionId,
)
.filter((approval) => approval.item.sessionId === request.sessionId)
.map((approval) => approval.item);
if (
!sendEventToClient(ctx, owner, "tool_approval_state", {
sessionId: request.sessionId,
items: sessionApprovals,
})
) {
cancelSidecarToolApprovalsForOwner(ctx, owner);
}
sendEvent(ctx, "tool_approval_state", {
sessionId: request.sessionId,
items: sessionApprovals,
});
});
}
@@ -790,26 +710,8 @@ export function handleHubLiveEvent(
sessionId?: string;
payload?: Record<string, unknown>;
},
options: { relayRawAssistantText?: boolean } = {},
): void {
if (event.event === "approval.requested") {
if (typeof event.payload?.agendaTaskId !== "string") return;
void handleHubApprovalRequest(ctx, event).catch((error) => {
ctx.logger?.error?.("Hub task approval forwarding failed", { error });
});
return;
}
// Task lifecycle events are Hub-wide invalidations and usually do not have a
// session yet (pending and approved tasks explicitly predate their session).
// Forward them before the session-only live-chat projection below so Agenda
// surfaces stay current without polling.
if (event.event.startsWith("task.")) {
sendEvent(ctx, event.event, {
...(event.payload ?? {}),
...(event.sessionId ? { sessionId: event.sessionId } : {}),
});
return;
}
const sessionId = typeof event.sessionId === "string" ? event.sessionId : "";
if (!sessionId) {
return;
@@ -821,13 +723,23 @@ export function handleHubLiveEvent(
switch (event.event) {
case "assistant.delta": {
const text =
typeof event.payload?.text === "string" ? event.payload.text : "";
if (text) {
emitChunk(ctx, sessionId, "chat_text", text);
if (options.relayRawAssistantText) {
const text =
typeof event.payload?.text === "string" ? event.payload.text : "";
if (text) emitChunk(ctx, sessionId, "chat_text", text);
}
return;
}
case "assistant.image":
case "assistant.video":
case "assistant.audio":
case "reasoning.delta":
case "tool.started":
case "tool.finished":
// HubRuntimeHost already projects these into the canonical Core event
// stream consumed by handleCoreSessionEvent. Relaying the raw Hub copy
// too duplicates assistant output and tool activity.
return;
case "assistant.media": {
const media = event.payload?.media;
if (isGeneratedMedia(media)) {
@@ -835,80 +747,84 @@ export function handleHubLiveEvent(
}
return;
}
case "reasoning.delta": {
const text =
typeof event.payload?.text === "string" ? event.payload.text : "";
const redacted = event.payload?.redacted === true;
if (!text && !redacted) {
case "usage.updated": {
const delta =
event.payload?.delta &&
typeof event.payload.delta === "object" &&
!Array.isArray(event.payload.delta)
? (event.payload.delta as Record<string, unknown>)
: {};
const totals =
event.payload?.totals &&
typeof event.payload.totals === "object" &&
!Array.isArray(event.payload.totals)
? (event.payload.totals as Record<string, unknown>)
: {};
emitChunk(
ctx,
sessionId,
"chat_usage",
JSON.stringify({
inputTokens: delta.inputTokens,
outputTokens: delta.outputTokens,
cacheReadTokens: delta.cacheReadTokens,
cacheWriteTokens: delta.cacheWriteTokens,
cost: delta.totalCost,
totalInputTokens: totals.inputTokens,
totalOutputTokens: totals.outputTokens,
totalCost: totals.totalCost,
}),
);
return;
}
case "session.pending_prompts": {
const items = Array.isArray(event.payload?.prompts)
? (event.payload.prompts as Array<Record<string, unknown>>)
: [];
const mapped: PromptInQueue[] = items
.map((item) => ({
id: typeof item.id === "string" ? item.id : "",
prompt: typeof item.prompt === "string" ? item.prompt : "",
steer: item.delivery === "steer",
attachmentCount:
typeof item.attachmentCount === "number" ? item.attachmentCount : 0,
userImages: Array.isArray(item.userImages)
? (item.userImages as string[])
: undefined,
}))
.filter((item) => item.id && (item.prompt || item.attachmentCount > 0));
reconcileQueuedAttachments(
session,
mapped.map((item) => item.id),
);
// No "head submitted" inference here, unlike the local queue-drain
// handler: the hub emits an explicit session.pending_prompt_submitted
// for real submissions, and a snapshot can also shrink because a
// prompt was REMOVED — inferring a start would render the deleted
// prompt in the transcript as if it had been sent.
session.promptsInQueue = mapped;
sendPromptsInQueueSnapshot(ctx, sessionId);
return;
}
case "session.pending_prompt_submitted": {
const item =
event.payload?.prompt && typeof event.payload.prompt === "object"
? (event.payload.prompt as Record<string, unknown>)
: undefined;
const promptId = typeof item?.id === "string" ? item.id : "";
if (!promptId) {
return;
}
emitChunk(
ctx,
sessionId,
"chat_reasoning",
JSON.stringify({ text, redacted }),
);
return;
}
case "tool.started": {
emitChunk(
ctx,
sessionId,
"chat_tool_call_start",
JSON.stringify({
toolCallId:
typeof event.payload?.toolCallId === "string"
? event.payload.toolCallId
: undefined,
toolName:
typeof event.payload?.toolName === "string"
? event.payload.toolName
: "tool",
input: event.payload?.input,
}),
);
return;
}
case "tool.updated": {
emitChunk(
ctx,
sessionId,
"chat_tool_call_update",
JSON.stringify({
toolCallId:
typeof event.payload?.toolCallId === "string"
? event.payload.toolCallId
: undefined,
toolName:
typeof event.payload?.toolName === "string"
? event.payload.toolName
: "tool",
update: event.payload?.update,
}),
);
return;
}
case "tool.finished": {
emitChunk(
ctx,
sessionId,
"chat_tool_call_end",
JSON.stringify({
toolCallId:
typeof event.payload?.toolCallId === "string"
? event.payload.toolCallId
: undefined,
toolName:
typeof event.payload?.toolName === "string"
? event.payload.toolName
: "tool",
output: event.payload?.output,
error:
typeof event.payload?.error === "string"
? event.payload.error
: undefined,
}),
);
markQueuedAttachmentsSubmitted(session, promptId);
emitQueuedPromptStart(ctx, sessionId, session, {
promptId,
prompt: typeof item?.prompt === "string" ? item.prompt : "",
attachmentCount:
typeof item?.attachmentCount === "number" ? item.attachmentCount : 0,
userImages: Array.isArray(item?.userImages)
? (item.userImages as string[])
: undefined,
});
return;
}
case "run.started":
@@ -920,20 +836,43 @@ export function handleHubLiveEvent(
!Array.isArray(event.payload.session)
? (event.payload.session as Record<string, unknown>)
: undefined;
const status =
const runtimeStatus =
typeof payloadSession?.status === "string"
? payloadSession.status
: event.event === "run.started"
? "running"
: session.status;
// Hub "pending" means the run is blocked on approval or otherwise
// still active. Desktop has no pending status, so expose it as running
// and keep later prompts on the queue path.
const status = runtimeStatus === "pending" ? "running" : runtimeStatus;
// Pods emit periodic session.updated snapshots; re-broadcasting an
// unchanged status marks the session unread in the sidebar every time.
const statusChanged = session.status !== status;
session.status = status;
session.busy = status === "running";
sendEvent(ctx, "chat_session_status", { sessionId, status });
if (statusChanged) {
sendEvent(ctx, "chat_session_status", { sessionId, status });
}
return;
}
case "run.completed":
case "run.failed":
case "run.aborted": {
// A failed run carries its reason in payload.error — surface it, or
// the user sees a silent no-op (e.g. "Insufficient balance").
const errorMessage =
event.event === "run.failed" && typeof event.payload?.error === "string"
? event.payload.error.trim()
: "";
if (errorMessage) {
emitChunk(
ctx,
sessionId,
"chat_core_log",
JSON.stringify({ level: "error", message: errorMessage }),
);
}
const reason =
typeof event.payload?.reason === "string"
? event.payload.reason
@@ -953,72 +892,6 @@ export function handleHubLiveEvent(
}
}
async function handleHubApprovalRequest(
ctx: SidecarContext,
event: {
sessionId?: string;
payload?: Record<string, unknown>;
},
): Promise<void> {
const sessionId = event.sessionId?.trim() || "";
const approvalId =
typeof event.payload?.approvalId === "string"
? event.payload.approvalId.trim()
: "";
const toolCallId =
typeof event.payload?.toolCallId === "string"
? event.payload.toolCallId.trim()
: "";
const toolName =
typeof event.payload?.toolName === "string"
? event.payload.toolName.trim()
: "";
if (!sessionId || !approvalId || !toolCallId || !toolName) return;
let input: unknown;
try {
input =
typeof event.payload?.inputJson === "string"
? JSON.parse(event.payload.inputJson)
: undefined;
} catch {
input = undefined;
}
const result = await requestSidecarToolApproval(ctx, {
sessionId,
agentId:
typeof event.payload?.agentId === "string" ? event.payload.agentId : "",
conversationId:
typeof event.payload?.conversationId === "string"
? event.payload.conversationId
: sessionId,
iteration:
typeof event.payload?.iteration === "number"
? event.payload.iteration
: 0,
toolCallId,
toolName,
input,
policy:
event.payload?.policy &&
typeof event.payload.policy === "object" &&
!Array.isArray(event.payload.policy)
? (event.payload.policy as ToolApprovalRequest["policy"])
: { autoApprove: false },
});
const client = ctx.hubClient;
if (!client)
throw new Error("Hub client disconnected before approval response");
await client.command(
"approval.respond",
{
approvalId,
approved: result.approved,
reason: result.reason,
},
sessionId,
);
}
export async function initializeSessionManager(
ctx: SidecarContext,
): Promise<void> {
@@ -1029,16 +902,12 @@ 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,
cwd: ctx.workspaceRoot,
workspaceRoot: ctx.localWorkspaceRoot,
cwd: ctx.localWorkspaceRoot,
clientType: "code-sidecar",
displayName: "Cline Desktop sidecar",
displayName: "Code App sidecar",
},
});
@@ -1047,24 +916,186 @@ export async function initializeSessionManager(
handleCoreSessionEvent(ctx, event);
});
let hubClient: NodeHubClient;
try {
await ensureSharedHubClient(ctx, sessionManager.runtimeAddress);
hubClient = await ensureSharedHubClient(ctx, sessionManager.runtimeAddress);
} catch (error) {
unsubscribe();
await sessionManager.dispose("code_sidecar_hub_initialization_failed");
throw error;
}
ctx.sessionManager = sessionManager;
ctx.unsubscribeSessionEvents = unsubscribe;
ctx.runtimeBindings.set(LOCAL_ENVIRONMENT_ID, {
environmentId: LOCAL_ENVIRONMENT_ID,
kind: "local",
workspaceRoot: ctx.localWorkspaceRoot,
sessionManager,
hubClient,
unsubscribeSessionEvents: unsubscribe,
});
}
export function getRuntimeBinding(
ctx: SidecarContext,
environmentId = ctx.activeEnvironmentId,
): SessionRuntimeBinding {
const binding = ctx.runtimeBindings.get(environmentId);
if (!binding) {
throw new Error(`Environment ${environmentId} is not connected.`);
}
return binding;
}
export function getSessionRuntimeBinding(
ctx: SidecarContext,
sessionId?: string,
requestedEnvironmentId?: string,
): SessionRuntimeBinding {
const environmentId =
requestedEnvironmentId?.trim() ||
(sessionId ? ctx.liveSessions.get(sessionId)?.environmentId : undefined) ||
(sessionId ? ctx.sessionEnvironmentIds.get(sessionId) : undefined) ||
ctx.activeEnvironmentId;
return getRuntimeBinding(ctx, environmentId);
}
export async function findSessionRuntimeBinding(
ctx: SidecarContext,
sessionId: string,
preferredEnvironmentId?: string,
): Promise<SessionRuntimeBinding | undefined> {
const knownEnvironmentId =
preferredEnvironmentId?.trim() ||
ctx.liveSessions.get(sessionId)?.environmentId ||
ctx.sessionEnvironmentIds.get(sessionId);
const candidates = [
...(knownEnvironmentId
? [ctx.runtimeBindings.get(knownEnvironmentId)]
: []),
...ctx.runtimeBindings.values(),
].filter(
(binding, index, all): binding is SessionRuntimeBinding =>
Boolean(binding) && all.indexOf(binding) === index,
);
for (const binding of candidates) {
try {
if (await binding.sessionManager.get(sessionId)) {
ctx.sessionEnvironmentIds.set(sessionId, binding.environmentId);
return binding;
}
} catch {
// A disconnected environment must not prevent another runtime from
// resolving the session.
}
}
return undefined;
}
async function disposeRuntimeBinding(
binding: SessionRuntimeBinding,
reason: string,
): Promise<void> {
try {
binding.unsubscribeSessionEvents();
} catch {
// Continue disposing the Hub clients even if an event source has already
// torn down its subscription.
}
await Promise.allSettled([
binding.hubClient.dispose(),
binding.sessionManager.dispose(reason),
]);
}
export async function connectRemoteSessionRuntime(
ctx: SidecarContext,
connection: NonNullable<SessionRuntimeBinding["remote"]>,
): Promise<SessionRuntimeBinding> {
const environmentId = connection.profile.id;
const existing = ctx.runtimeBindings.get(environmentId);
const sessionManager = await ClineCore.create({
clientName: "cline-code",
backendMode: "remote",
capabilities: createSidecarRuntimeCapabilities(ctx),
logger: ctx.logger,
telemetry: ctx.telemetry,
remote: {
endpoint: connection.endpoint,
authToken: connection.authToken,
workspaceRoot: connection.workspaceRoot,
cwd: connection.workspaceRoot,
clientType: "code-sidecar-ssh",
displayName: `Code App (${connection.profile.name})`,
},
});
let unsubscribe: (() => void) | undefined;
let hubClient: NodeHubClient | undefined;
try {
unsubscribe = sessionManager.subscribe((event: CoreSessionEvent) => {
handleCoreSessionEvent(ctx, event);
});
hubClient = new NodeHubClient({
url: connection.endpoint,
authToken: connection.authToken,
clientType: "code-sidecar-ssh-observer",
displayName: `Code App observer (${connection.profile.name})`,
workspaceRoot: connection.workspaceRoot,
cwd: connection.workspaceRoot,
});
await hubClient.connect();
hubClient.subscribe((event) => handleHubLiveEvent(ctx, event));
} catch (error) {
try {
unsubscribe?.();
} catch {
// Best effort; the failed runtime still needs to be disposed below.
}
const disposals: Promise<unknown>[] = [
sessionManager.dispose("code_sidecar_remote_initialization_failed"),
];
if (hubClient) disposals.push(hubClient.dispose());
await Promise.allSettled(disposals);
throw error;
}
const binding: SessionRuntimeBinding = {
environmentId,
kind: "ssh",
workspaceRoot: connection.workspaceRoot,
sessionManager,
hubClient,
unsubscribeSessionEvents: unsubscribe,
remote: connection,
};
ctx.runtimeBindings.set(environmentId, binding);
ctx.activeEnvironmentId = environmentId;
if (existing) {
await disposeRuntimeBinding(existing, "code_sidecar_remote_reconnect");
}
return binding;
}
export async function disconnectRemoteSessionRuntime(
ctx: SidecarContext,
environmentId: string,
): Promise<void> {
const binding = ctx.runtimeBindings.get(environmentId);
if (binding?.kind === "ssh") {
ctx.runtimeBindings.delete(environmentId);
await disposeRuntimeBinding(binding, "code_sidecar_remote_disconnect");
}
if (ctx.activeEnvironmentId === environmentId) {
ctx.activeEnvironmentId = LOCAL_ENVIRONMENT_ID;
}
}
export async function ensureSharedHubClient(
ctx: SidecarContext,
preferredUrl?: string,
): Promise<NodeHubClient> {
if (ctx.hubClient) {
return ctx.hubClient;
const existing = ctx.runtimeBindings.get(LOCAL_ENVIRONMENT_ID)?.hubClient;
if (existing) {
return existing;
}
const pending = hubClientInitialization.get(ctx);
if (pending) {
@@ -1076,8 +1107,8 @@ export async function ensureSharedHubClient(
preferredUrl?.trim() ||
(await ensureCompatibleLocalHubUrl({
strategy: "require-hub",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
workspaceRoot: ctx.localWorkspaceRoot,
cwd: ctx.localWorkspaceRoot,
}));
if (!url) {
throw new Error("Unable to start or connect to the shared Cline Hub.");
@@ -1086,17 +1117,15 @@ export async function ensureSharedHubClient(
const client = new NodeHubClient({
url,
clientType: "code-sidecar-observer",
displayName: "Cline Desktop observer",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
displayName: "Code App observer",
workspaceRoot: ctx.localWorkspaceRoot,
cwd: ctx.localWorkspaceRoot,
});
try {
await client.connect();
client.subscribe((event) => {
handleHubLiveEvent(ctx, event);
});
ctx.hubClient = client;
await syncSidecarApprovalReadiness(ctx);
return client;
} catch (error) {
await client.dispose().catch(() => undefined);
@@ -0,0 +1,67 @@
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
readDesktopSettings,
resolveDesktopSettingsPath,
setCloudSessionsEnabled,
} from "./desktop-settings";
let dataDir: string;
beforeEach(() => {
dataDir = mkdtempSync(join(tmpdir(), "cline-desktop-settings-"));
process.env.CLINE_DATA_DIR = dataDir;
});
afterEach(() => {
delete process.env.CLINE_DATA_DIR;
rmSync(dataDir, { recursive: true, force: true });
});
describe("desktop settings", () => {
it("defaults cloud sessions to off when no settings file exists", () => {
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
});
it("persists the cloud sessions opt-in and reads it back", () => {
expect(setCloudSessionsEnabled(true)).toEqual({
cloudSessionsEnabled: true,
});
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: true });
expect(setCloudSessionsEnabled(false)).toEqual({
cloudSessionsEnabled: false,
});
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
});
it("writes into the desktop-owned settings file, not global-settings", () => {
setCloudSessionsEnabled(true);
const raw = JSON.parse(
readFileSync(resolveDesktopSettingsPath(), "utf8"),
) as Record<string, unknown>;
expect(resolveDesktopSettingsPath().endsWith("code-settings.json")).toBe(
true,
);
expect(raw.cloudSessionsEnabled).toBe(true);
});
it("treats malformed files and non-boolean values as off", () => {
mkdirSync(dirname(resolveDesktopSettingsPath()), { recursive: true });
writeFileSync(resolveDesktopSettingsPath(), "{not json", "utf8");
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
writeFileSync(
resolveDesktopSettingsPath(),
JSON.stringify({ cloudSessionsEnabled: "yes" }),
"utf8",
);
expect(readDesktopSettings()).toEqual({ cloudSessionsEnabled: false });
});
});

Some files were not shown because too many files have changed in this diff Show More