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
808 changed files with 46630 additions and 104168 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
+1 -1
View File
@@ -7,7 +7,7 @@ description: Use when preparing, tagging, and publishing an apps/cli npm release
Use this skill when the user asks to release the CLI, publish `cline`, bump the CLI version, draft release notes, create a `cli-vX.Y.Z` tag, or trigger the CLI publish workflow.
The CLI is npm-only. Do not add alternate distribution channels. Windows binaries are Authenticode-signed automatically by the publish workflow via Azure Trusted Signing (see the `.github/actions/sign-windows-cli` composite action and "Windows code signing" in `apps/cli/DISTRIBUTION.md`); if the signing secrets are not configured the workflow warns and publishes unsigned binaries. Local publishes (`bun release cli`) do not sign — prefer the GitHub Actions publish path for releases users run on Windows.
The CLI is npm-only. Do not add alternate distribution or signing steps.
> Working directory: run every command below from the repository root. Paths and scripts (e.g. `apps/cli/package.json`, `sdk/packages/`, `bun release cli`, `bun run version`) are written relative to the repo root.
+8 -8
View File
@@ -1,27 +1,27 @@
---
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.
Desktop releases ship two platforms, built entirely in GitHub Actions — there is no local publish path. macOS: a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel. Windows: an Authenticode-signed NSIS installer (`<Product>_<version>_x64-setup.exe`), signed via Azure Trusted Signing in the `build-windows` job (jsign through Tauri's `signCommand`, see `apps/examples/desktop-app/scripts/tauri-sign-windows.ps1`; requires the repo-level `AZURE_*` secrets including `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP`, plus a `PublishDesktop`-environment federated credential on the `cline-cli-signing` Entra app). Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user **on that channel**.
Desktop releases are macOS-only today (a single signed + notarized universal DMG that runs natively on both Apple Silicon and Intel) and are built entirely in GitHub Actions — there is no local publish path. Installed apps discover new releases automatically through the Tauri updater, so publishing a release is what ships the update to every existing user **on that channel**.
## 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.
- Publish path: `.github/workflows/desktop-publish.yml` (workflow_dispatch, requires the tag to exist, point at the checked-out commit, and be reachable from the channel's branch — `origin/main` for stable, `origin/desktop-experimental` for beta).
- **Both channels dispatch from `main`.** This is a security invariant, not a convenience: the run executes `main`'s workflow copy and only the checkout points at the tag, so the signing-secret gates (the `github.ref == main` check and the PublishDesktop environment's main-only deployment-branch policy) hold for beta too. Never add `desktop-experimental` to the PublishDesktop deployment-branch policy.
- The workflow creates the tag's GitHub release (universal DMG + macOS updater artifact + Windows NSIS installer with its updater signature + `latest.json`; marked prerelease for beta) and refreshes the channel's rolling feed release, which is the static auto-update feed every installed app on that channel polls. Never delete the `desktop-latest` or `desktop-beta` release or tag.
- The workflow creates the tag's GitHub release (universal DMG + updater artifact + `latest.json`; marked prerelease for beta) and refreshes the channel's rolling feed release, which is the static auto-update feed every installed app on that channel polls. Never delete the `desktop-latest` or `desktop-beta` release or tag.
- The changelog's `## <version>` section (exact-match, not "topmost") is extracted verbatim into the GitHub release body, the Slack announcement, and the updater manifest notes.
- Always ask before pushing commits or tags.
@@ -120,7 +120,7 @@ gh api repos/cline/cline/actions/runs/<run-id>/pending_deployments \
Nothing after `validate` runs — and no signing key is readable — until then.
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`; beta adds the `tauri.beta.conf.json` overlay), verifies every Mach-O in the bundle carries both slices and that the compiled binary embeds exactly its own channel's feed URL, signs with the Developer ID certificate, notarizes with the App Store Connect API key, and signs the updater artifact with the Tauri updater key. In parallel, `build-windows` builds the x64 NSIS installer on a Windows runner, Authenticode-signs every binary via Azure Trusted Signing (Tauri `signCommand` -> `scripts/tauri-sign-windows.ps1`), runs the same feed-endpoint and telemetry guardrails, and verifies the shipped installer with `Get-AuthenticodeSignature`. The release job then creates the GitHub release (prerelease for beta), refreshes the channel's feed (`desktop-latest/latest.json` or `desktop-beta/latest.json`), and posts to Slack. Notarization typically adds 210 minutes.
The workflow builds one universal macOS bundle (`tauri build --target universal-apple-darwin` lipos the aarch64 + x86_64 Rust binaries; the Bun sidecar is lipo'd by `build-sidecar-bin.ts`; beta adds the `tauri.beta.conf.json` overlay), verifies every Mach-O in the bundle carries both slices and that the compiled binary embeds exactly its own channel's feed URL, signs with the Developer ID certificate, notarizes with the App Store Connect API key, signs the updater artifact with the Tauri updater key, creates the GitHub release (prerelease for beta), refreshes the channel's feed (`desktop-latest/latest.json` or `desktop-beta/latest.json`), and posts to Slack. Notarization typically adds 210 minutes.
If the workflow fails on missing credentials, see "Publish secrets (one-time setup)" below.
@@ -131,7 +131,7 @@ curl -sL https://github.com/cline/cline/releases/download/desktop-latest/latest.
curl -sL https://github.com/cline/cline/releases/download/desktop-beta/latest.json | head -30 # beta
```
The `version` field must be the new release; both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new universal `.app.tar.gz` asset under the release tag (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact), and the `windows-x86_64` entry must point at the new `*_x64-setup.exe` asset. Installed apps on that channel — including older per-arch installs — pick the update up on next launch or within 2 hours.
The `version` field must be the new release and both `darwin-aarch64` and `darwin-x86_64` entries must point at the same new universal `.app.tar.gz` asset under the release tag (each slice of the fat binary requests its own arch key at runtime, so both keys serve the one artifact). Installed apps on that channel — including older per-arch installs — pick the update up on next launch or within 2 hours.
After a **beta** publish, also confirm the stable feed was not touched: `desktop-latest/latest.json` must still serve the previous stable version. (The workflow guards this fail-closed, but it is cheap to verify and catastrophic to miss — the updater comparator is a plain semver "newer than", so a beta manifest on `desktop-latest` would auto-update every stable install onto the 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/...`).
+3 -7
View File
@@ -15,8 +15,6 @@ body:
- VSCode Extension
- JetBrains Plugin
- CLI
- Desktop App
- Cloud Platform
default: 0
validations:
required: true
@@ -64,15 +62,13 @@ body:
- type: textarea
id: ide-diagnostics
attributes:
label: Diagnostics
label: IDE / CLI Diagnostics
description: |
Paste the diagnostics for your Cline surface. This captures the build, runtime, and host details we need.
Paste the "About" diagnostics for your Cline surface. This captures the IDE build, runtime, and host details we need.
- VSCode Extension: open `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS), then copy the info.
- JetBrains Plugin: open `Help → About` (Windows/Linux) or `<IDE name> → About` (macOS), then click `Copy` to grab build, runtime, OS, memory, and cores.
- CLI: there is no About dialog. Run `cline --version` and paste the output.
- Desktop App: paste the app version from the Settings view.
- Cloud Platform: paste your browser name and version, plus the page URL where the issue occurred.
placeholder: Paste the copied About info, `cline --version` output, or browser/app details here.
placeholder: Paste the copied About info or `cline --version` output here.
validations:
required: false
- type: textarea
-155
View File
@@ -1,155 +0,0 @@
name: Sign Windows CLI binaries
description: >
Authenticode-signs the compiled Windows CLI executables with Azure Trusted
Signing (via jsign, so it runs on Linux runners) and verifies the resulting
signatures. If the Azure Trusted Signing secrets are not configured, the
action logs a warning and exits successfully so releases keep working while
signing infrastructure is being provisioned.
inputs:
azure-client-id:
description: Client ID of the Entra app with the Trusted Signing Certificate Profile Signer role (OIDC federated credential, no client secret).
required: false
default: ""
azure-tenant-id:
description: Entra tenant ID.
required: false
default: ""
azure-subscription-id:
description: Azure subscription ID containing the Trusted Signing account.
required: false
default: ""
endpoint:
description: Trusted Signing account endpoint, for example https://eus.codesigning.azure.net.
required: false
default: ""
account:
description: Trusted Signing account name.
required: false
default: ""
certificate-profile:
description: Trusted Signing certificate profile name.
required: false
default: ""
files:
description: Newline-separated list of PE files to sign.
required: true
runs:
using: composite
steps:
- name: Check signing configuration
id: check
shell: bash
env:
AZURE_CLIENT_ID: ${{ inputs.azure-client-id }}
AZURE_TENANT_ID: ${{ inputs.azure-tenant-id }}
AZURE_SUBSCRIPTION_ID: ${{ inputs.azure-subscription-id }}
SIGNING_ENDPOINT: ${{ inputs.endpoint }}
SIGNING_ACCOUNT: ${{ inputs.account }}
SIGNING_PROFILE: ${{ inputs.certificate-profile }}
run: |
missing=()
set_count=0
for var in AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID SIGNING_ENDPOINT SIGNING_ACCOUNT SIGNING_PROFILE; do
if [ -z "${!var}" ]; then
missing+=("$var")
else
set_count=$((set_count + 1))
fi
done
if [ "${#missing[@]}" -eq 0 ]; then
echo "Azure Trusted Signing is configured; Windows binaries will be signed."
echo "enabled=true" >> "$GITHUB_OUTPUT"
elif [ "$set_count" -eq 0 ]; then
echo "::warning::Azure Trusted Signing is not configured; publishing UNSIGNED Windows binaries. Set the AZURE_* and AZURE_TRUSTED_SIGNING_* repository secrets to enable signing."
echo "enabled=false" >> "$GITHUB_OUTPUT"
else
# Partial configuration is almost certainly a typo'd or renamed
# secret. Fail loudly instead of silently publishing unsigned.
echo "::error::Azure Trusted Signing is PARTIALLY configured; refusing to publish. Missing: ${missing[*]}"
exit 1
fi
- name: Azure login (OIDC)
if: steps.check.outputs.enabled == 'true'
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
with:
client-id: ${{ inputs.azure-client-id }}
tenant-id: ${{ inputs.azure-tenant-id }}
subscription-id: ${{ inputs.azure-subscription-id }}
- name: Sign Windows binaries
if: steps.check.outputs.enabled == 'true'
shell: bash
env:
SIGNING_ENDPOINT: ${{ inputs.endpoint }}
SIGNING_ACCOUNT: ${{ inputs.account }}
SIGNING_PROFILE: ${{ inputs.certificate-profile }}
FILES: ${{ inputs.files }}
JSIGN_VERSION: "7.5"
JSIGN_SHA256: "602a51c3545a6dc4fb99bd2ea7152b26d1345916d0c93ddfbd5936cb735af91c"
run: |
set -euo pipefail
JSIGN_JAR="${RUNNER_TEMP}/jsign-${JSIGN_VERSION}.jar"
curl -fsSL -o "$JSIGN_JAR" "https://github.com/ebourg/jsign/releases/download/${JSIGN_VERSION}/jsign-${JSIGN_VERSION}.jar"
echo "${JSIGN_SHA256} ${JSIGN_JAR}" | sha256sum --check --strict
JSIGN_STOREPASS=$(az account get-access-token --resource https://codesigning.azure.net --query accessToken --output tsv)
echo "::add-mask::${JSIGN_STOREPASS}"
export JSIGN_STOREPASS
# jsign expects the endpoint host, not the URL. Tolerate both the
# portal's display form (trailing slash) and the bare form.
KEYSTORE="${SIGNING_ENDPOINT#https://}"
KEYSTORE="${KEYSTORE%/}"
while IFS= read -r file; do
[ -z "$file" ] && continue
echo "Signing ${file}"
java -jar "$JSIGN_JAR" \
--storetype TRUSTEDSIGNING \
--keystore "$KEYSTORE" \
--storepass env:JSIGN_STOREPASS \
--alias "${SIGNING_ACCOUNT}/${SIGNING_PROFILE}" \
--alg SHA-256 \
--tsaurl http://timestamp.acs.microsoft.com \
--tsmode RFC3161 \
--replace \
"$file"
done <<< "$FILES"
- name: Verify signatures
if: steps.check.outputs.enabled == 'true'
shell: bash
env:
FILES: ${{ inputs.files }}
# Authenticode chains anchor to the Microsoft Identity Verification
# Root CA 2020, which is not in the Mozilla TLS bundle, so fetch it
# explicitly (pinned) for osslsigncode chain validation.
MS_ROOT_URL: "https://www.microsoft.com/pkiops/certs/Microsoft%20Identity%20Verification%20Root%20Certificate%20Authority%202020.crt"
MS_ROOT_SHA256: "5367f20c7ade0e2bca790915056d086b720c33c1fa2a2661acf787e3292e1270"
run: |
set -euo pipefail
if ! command -v osslsigncode >/dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq osslsigncode
fi
MS_ROOT_DER="${RUNNER_TEMP}/ms-identity-root-2020.crt"
MS_ROOT_PEM="${RUNNER_TEMP}/ms-identity-root-2020.pem"
curl -fsSL -o "$MS_ROOT_DER" "$MS_ROOT_URL"
echo "${MS_ROOT_SHA256} ${MS_ROOT_DER}" | sha256sum --check --strict
openssl x509 -inform DER -in "$MS_ROOT_DER" -out "$MS_ROOT_PEM"
while IFS= read -r file; do
[ -z "$file" ] && continue
echo "Verifying signature on ${file}"
# Timestamp countersignature chain is checked separately by Windows;
# -ignore-timestamp only skips TSA chain validation here, not the
# Authenticode chain itself.
osslsigncode verify -in "$file" -CAfile "$MS_ROOT_PEM" -ignore-timestamp
done <<< "$FILES"
+1 -56
View File
@@ -190,19 +190,6 @@ jobs:
ls -lh "$dir/bin/"
done
- name: Sign Windows binaries
uses: ./.github/actions/sign-windows-cli
with:
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
account: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_CLI }}
files: |
apps/cli/dist/cli-windows-x64/bin/cline.exe
apps/cli/dist/cli-windows-arm64/bin/cline.exe
- name: Publish to NPM with latest tag
env:
NPM_CONFIG_PROVENANCE: "true"
@@ -219,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)
@@ -228,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:
@@ -289,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"
@@ -460,20 +419,6 @@ jobs:
ls -lh "$dir/bin/"
done
- name: Sign Windows binaries
if: steps.check_commits.outputs.skip != 'true'
uses: ./.github/actions/sign-windows-cli
with:
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
account: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_CLI }}
files: |
apps/cli/dist/cli-windows-x64/bin/cline.exe
apps/cli/dist/cli-windows-arm64/bin/cline.exe
- name: Publish to NPM with nightly tag
if: steps.check_commits.outputs.skip != 'true'
env:
+10 -310
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)
@@ -462,282 +462,9 @@ jobs:
path: apps/examples/desktop-app/dist/publish/*
if-no-files-found: error
build-windows:
name: Build Windows (x64)
needs: validate
# Same gate rationale as the macOS build job above. This job additionally
# needs id-token: write for Azure OIDC: Windows binaries are
# Authenticode-signed with Azure Trusted Signing, authenticated through the
# PublishDesktop-environment federated credential on the cline-cli-signing
# Entra app (subject repo:cline/cline:environment:PublishDesktop).
if: github.ref == 'refs/heads/main'
environment: PublishDesktop
runs-on: windows-latest
timeout-minutes: 90
permissions:
contents: read
id-token: write
steps:
# All-or-nothing: an unsigned Windows desktop build is never acceptable
# (Smart App Control / WDAC block unsigned exes and SmartScreen flags
# unsigned installers), and Tauri would skip updater-artifact signing
# silently if the updater key were missing. Unlike the CLI pipeline
# there is no unsigned fallback here.
- name: Verify signing secrets are present
shell: bash
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
missing=()
for name in AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID \
AZURE_TRUSTED_SIGNING_ENDPOINT AZURE_TRUSTED_SIGNING_ACCOUNT_NAME \
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP \
TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
[ -n "${!name}" ] || missing+=("$name")
done
if [ ${#missing[@]} -gt 0 ]; then
echo "Missing signing secrets for the Windows desktop build:"
printf ' - %s\n' "${missing[@]}"
echo
echo "The AZURE_* names are repository secrets; the TAURI_* names"
echo "live in the PublishDesktop environment. Refusing to build an"
echo "unsigned Windows desktop release."
exit 1
fi
echo "All Windows signing secrets are present."
# Every action in this job is SHA-pinned (unlike elsewhere in this
# file): they run with id-token: write and the updater signing key in
# scope, so a hijacked upstream tag must not be able to reach the
# signing identity or tamper with what gets signed and uploaded.
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ needs.validate.outputs.tag }}
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3.13"
- name: Setup Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable branch
with:
# With a SHA-pinned action the toolchain no longer comes from the
# ref name, so it must be set explicitly.
toolchain: stable
# No Rust build cache, mirroring the macOS job: this job holds the
# updater signing key and an Azure signing session, and a restored cache
# archive is attacker-controlled if the Actions cache is poisoned.
- name: Install dependencies
run: bun install
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build SDK packages
run: bun run build:sdk
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
- name: Azure login (OIDC)
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# Tauri invokes signCommand once per staged binary (main exe, sidecar,
# NSIS uninstaller, and the installer itself). The overlay is generated
# here rather than committed because signCommand needs an absolute path
# to the signing script on this runner.
- name: Write signing config overlay
shell: bash
run: |
SCRIPT_PATH="${GITHUB_WORKSPACE//\\//}/apps/examples/desktop-app/scripts/tauri-sign-windows.ps1"
SIGN_CONF="${RUNNER_TEMP//\\//}/tauri-windows-sign.conf.json"
cat > "$SIGN_CONF" <<EOF
{
"\$schema": "https://schema.tauri.app/config/2",
"bundle": {
"windows": {
"signCommand": "pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ${SCRIPT_PATH} %1"
}
}
}
EOF
cat "$SIGN_CONF"
echo "SIGN_CONF=${SIGN_CONF}" >> "$GITHUB_ENV"
- name: Build and sign desktop bundle
shell: bash
working-directory: apps/examples/desktop-app
# NSIS only: the MSI (WiX) target adds nothing for direct-download
# distribution and the updater uses the NSIS artifact. $CONFIG_ARGS is
# deliberately unquoted: it must word-split into separate flags.
run: bunx tauri build --bundles nsis $CONFIG_ARGS --config "$SIGN_CONF"
env:
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
# Telemetry inlined into the sidecar at compile time, same as macOS.
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
# Authenticode signing via scripts/tauri-sign-windows.ps1 (jsign +
# Azure Trusted Signing; the token comes from the azure/login session)
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_DESKTOP }}
# Updater artifact signing (minisign keypair, same key as macOS)
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# Same guardrail as the macOS job: assert the compiled binary embeds
# this channel's updater feed URL and not the other channel's. Checked
# on the unbundled main exe because NSIS compresses the installer
# contents, which defeats a string search on the installer itself.
- name: Verify updater feed endpoint
shell: bash
working-directory: apps/examples/desktop-app
env:
CHANNEL: ${{ needs.validate.outputs.channel }}
run: |
case "$CHANNEL" in
stable)
WANT="releases/download/desktop-latest/latest.json"
FORBID="releases/download/desktop-beta/latest.json"
;;
beta)
WANT="releases/download/desktop-beta/latest.json"
FORBID="releases/download/desktop-latest/latest.json"
;;
*)
echo "unknown channel: ${CHANNEL}"
exit 1
;;
esac
found=0
for bin in src-tauri/target/release/*.exe; do
if grep -a "$FORBID" "$bin" >/dev/null; then
echo "$bin embeds the other channel's feed URL (${FORBID})"
exit 1
fi
if grep -a "$WANT" "$bin" >/dev/null; then
found=1
fi
done
if [ "$found" -ne 1 ]; then
echo "No exe in src-tauri/target/release embeds ${WANT}."
echo "The updater endpoint overlay did not apply; check the"
echo "--config flags on the build step and tauri.beta.conf.json."
exit 1
fi
echo "Updater endpoint verified: ${WANT}"
# Same guardrail as the macOS job, run natively on the Windows sidecar.
- name: Verify sidecar telemetry config was inlined
shell: bash
working-directory: apps/examples/desktop-app
run: |
SELFCHECK=$(./src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe --telemetry-selfcheck)
echo "$SELFCHECK"
if ! printf '%s' "$SELFCHECK" | grep -q '"enabled":true'; then
echo "Packaged sidecar reports telemetry disabled."
echo "Check the OTEL_* / TELEMETRY_SERVICE_API_KEY env on the"
echo "'Build and sign desktop bundle' step and the --define"
echo "inlining in scripts/build-sidecar-bin.ts."
exit 1
fi
if printf '%s' "$SELFCHECK" | grep -Eq '"otlp_endpoint_host":"(invalid-endpoint-url)?"'; then
echo "Packaged sidecar reports telemetry enabled but its OTLP"
echo "endpoint is missing, unparseable, or not an http(s) URL."
echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret."
exit 1
fi
- name: Collect artifacts
shell: bash
working-directory: apps/examples/desktop-app
env:
VERSION: ${{ needs.validate.outputs.version }}
PRODUCT: ${{ needs.validate.outputs.product }}
run: |
BUNDLE_DIR="src-tauri/target/release/bundle"
OUT="dist/publish"
mkdir -p "$OUT"
# "Cline" -> Cline, "Cline Beta" -> Cline-Beta
PREFIX="${PRODUCT// /-}"
SETUP=$(find "$BUNDLE_DIR/nsis" -name '*-setup.exe' -print -quit)
if [ -z "$SETUP" ]; then
echo "no NSIS installer produced under $BUNDLE_DIR/nsis"
exit 1
fi
# The .sig is the updater (minisign) signature; without it the
# manifest generator cannot publish a windows-x86_64 entry.
if [ ! -f "${SETUP}.sig" ]; then
echo "updater signature missing next to $SETUP"
exit 1
fi
cp "$SETUP" "$OUT/${PREFIX}_${VERSION}_x64-setup.exe"
cp "${SETUP}.sig" "$OUT/${PREFIX}_${VERSION}_x64-setup.exe.sig"
ls -lh "$OUT"
# Independent Authenticode gate on the exact artifact users download.
# The signing script already verifies each file it signs, but this step
# would still catch an installer that skipped signCommand entirely.
- name: Verify Authenticode signatures
shell: pwsh
working-directory: apps/examples/desktop-app
run: |
# The Tauri bundler signs the sidecar in place, so check it here too;
# a WDAC-locked machine blocks the app at runtime if the sidecar it
# spawns is unsigned, even when the installer itself is fine.
$files = @(Get-ChildItem dist/publish/*.exe) + @(Get-Item src-tauri/bin/code-sidecar-x86_64-pc-windows-msvc.exe)
if ($files.Count -lt 2) { throw "expected at least the installer and the sidecar to verify" }
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file.FullName
if ($sig.Status -ne "Valid") {
throw "Invalid Authenticode signature for $($file.Name): $($sig.Status) - $($sig.StatusMessage)"
}
Write-Host "$($file.Name): Valid ($($sig.SignerCertificate.Subject))"
}
- name: Upload artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-windows-x64
path: apps/examples/desktop-app/dist/publish/*
if-no-files-found: error
release:
name: Create GitHub release
needs: [validate, build, build-windows]
needs: [validate, build]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -779,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 }}
@@ -880,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)"
@@ -901,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"
@@ -912,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"
-50
View File
@@ -1,50 +0,0 @@
name: desktop-test
on:
push:
branches:
- main
- desktop-experimental
paths:
- "apps/examples/desktop-app/package.json"
- "apps/examples/desktop-app/scripts/dmg-background.ts"
- "apps/examples/desktop-app/scripts/dmg-background.test.ts"
- "apps/examples/desktop-app/src-tauri/dmg/background.png"
- "apps/examples/desktop-app/src-tauri/dmg/background@2x.png"
- ".github/workflows/desktop-test.yml"
pull_request:
branches:
- main
- desktop-experimental
paths:
- "apps/examples/desktop-app/package.json"
- "apps/examples/desktop-app/scripts/dmg-background.ts"
- "apps/examples/desktop-app/scripts/dmg-background.test.ts"
- "apps/examples/desktop-app/src-tauri/dmg/background.png"
- "apps/examples/desktop-app/src-tauri/dmg/background@2x.png"
- ".github/workflows/desktop-test.yml"
workflow_dispatch:
permissions:
contents: read
jobs:
dmg-background:
name: Test DMG background tooling
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/examples/desktop-app
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
# The suite only uses Bun/Node built-ins and committed artwork, so it does
# not need a workspace dependency install or macOS runner.
- name: Test DMG background tooling
run: bun run test:dmg-background
+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
-1
View File
@@ -88,7 +88,6 @@ apps/vscode/tsconfig.test.generated.json
.next/dev/static
**/src-tauri/target/debug/.fingerprint
apps/examples/desktop-app/src-tauri/target
apps/examples/desktop-app/src-tauri/dmg/background.gen.tiff
apps/examples/desktop-app/webview/.next
# Next.js generated type shim (churns between dev and build)
-94
View File
@@ -1,99 +1,5 @@
# Changelog
## [4.1.16]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Fixed
- Cost estimates are no longer shown for providers billed by a flat-rate subscription (ClinePass, ChatGPT via Codex, and Claude Code). The task header and model pricing rows rendered API-rate dollar figures that read as real charges on top of the subscription, including a flash of them on every chat-view mount while provider listings were loading.
- Signing back in no longer moves your last-used provider off ClinePass on credential refresh.
- Hooks now resolve their workspace from the VS Code window instead of shared global state in `~/.cline`. With a second window open on another project, a workspace's `.clinerules/hooks` scripts were never discovered, and hook cwd and the workspace paths passed to hook scripts resolved against whatever project some other or older Cline instance last recorded.
- New files are now created with your platform's native line endings.
- Fixed the codebase search tool crashing on files containing a single enormous line.
- Credentials embedded in git remote URLs are now redacted from the workspace information sent to the model, which also now carries richer workspace metadata.
- Installing an MCP server from the marketplace no longer misreads the catalog's `--` separator as part of the server command.
- The hub's event log can no longer grow until it fills your disk.
### Changed
- The per-tool MCP auto-approve checkboxes are hidden. MCP auto-approval is governed solely by the global "Use MCP servers" toggle — the per-tool checkboxes were no-ops that implied granularity the approval path does not have.
## [4.1.15]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Fixed
- Auto-approve every MCP tool call while the "Use MCP servers" toggle is on. The toggle only took effect on tools that had also been opted in individually, so turning it on appeared to do nothing; it now governs all MCP tools on its own.
## [4.1.14]
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
### Added
- Refresh the built-in model catalog. New entries include Claude Fable 5, Grok 4.6 on Vertex, several DeepSeek V4 Flash variants (including the vision preview), MiMo v2.5, Qwen3.8 27B, Gemma 4 26B, LongCat 2.0, Nemotron 3.5 Lightning, and Thinking Machines' Inkling models.
### Fixed
- Restore task completion telemetry for interactive sessions. A share of interactive stops routed through a teardown path that never reported completion after 4.1.11 changed how session status is tracked; every session now reports it exactly once.
## [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.
-62
View File
@@ -1,67 +1,5 @@
# Cline CLI Changelog
## 3.0.61
- Cline now handles a running Hub that is older than your CLI. Instead of quietly talking to a hub executing stale code, you get a prompt showing how many active sessions a replacement would interrupt, with enter-to-replace or escape-to-keep. The replacement drains the Hub first so in-flight turns finish, and a hub too old or wedged to accept the drain is left alone rather than killed
- Windows binaries are now Authenticode-signed via Azure Trusted Signing, and a launch blocked by application-control policy now prints an actionable error instead of failing bare
- Fixed the CLI dying when an enabled remote (SSE/streamable HTTP) MCP server is unreachable. The connect now has a 10s budget, so an offline server no longer stalls session startup past the Hub's deadline and tears the session down — previously the interactive TUI exited and one-shot runs failed
- Fixed tool calling being silently disabled for Dify, SAP AI Core, opencode, and Codex CLI models. Their catalog entries declare no capabilities, and the empty list was read as an authoritative denial that stripped every tool from the request
- Fixed images being dropped from file reads on models whose capability list is empty
- Langfuse tracing now works in released builds. Detection identified the OpenTelemetry provider by class name, which minification renames, so tracing silently initialized as not-ready in every published binary while working in dev
- Restoring a checkpoint now refuses to run when you have made commits after it, instead of silently knocking them off the branch where only the reflog could recover them. Chat-only restore is unaffected
- `apply_patch` now preserves a file's existing CRLF line endings
- Global rules are now also read from `~/Cline/Rules`, which is where the VS Code Rules tab writes them on WSL and headless installs
- Signing in to OpenAI Codex (ChatGPT subscription) now fails with a clear "port in use" error when 1455 is occupied, instead of opening a browser to a flow that can never complete
- A transient network failure while refreshing Codex or OpenAI-compatible-account tokens no longer logs you out
- Aborting a session now also cancels the delegated subagents and teammates it spawned, instead of leaving their work running
- Agent-created schedules now live in `~/.cline/schedules` instead of inheriting whichever chat folder they were created in. Schedules you create with `--workspace` are unchanged
- Fixed scheduled tasks disappearing after a hub restart
- Fixed markdown flashing as it settled at the end of a streamed response
- The message the model sees when you reject a tool call now names the tool and reads as your decision rather than an error
- Cline provider models now come from the live catalog, so newly published models show up without a CLI update
- Refreshed the model catalog. Adds ten providers (Bothub, OpenReason, SenseNova (China), TokenGo, TokenRouter, Vancine, Volcengine Ark, Volcengine Ark Coding Plan, above.dev, and klokintegration.se) and updates model lists and pricing across providers. This is an unusually wide refresh: the resolved default model changes for 57 providers. Most consequentially, Anthropic now resolves to Claude Fable 5.1 instead of Claude Opus 5, and Amazon Bedrock, Vertex, OpenRouter, Vercel AI Gateway, Kilo Gateway, LLM Gateway, DevPass, DigitalOcean, CrossModel, Eden AI, and NanoGPT follow it to Fable 5.1. If you use any provider without pinning a model, expect a different default
## 3.0.60
- Fixed the background hub process ballooning in memory during long sessions — session status updates were broadcasting a full copy of the conversation transcript to every connected client, which on a large task could grow the process to tens of gigabytes. Upgrading retires the running hub so the fix takes effect on the next command
- New files are now created with your platform's native line endings
- Fixed the codebase search tool crashing on files that contain a single enormous line
- Cost estimates are no longer shown for Claude Code. Its usage is typically covered by a Claude Pro/Max subscription, but its models reuse Anthropic API pricing, so Cline was showing charges you were not being billed
- Credentials embedded in git remote URLs are now redacted from the workspace information sent to the model
- Installing an MCP server no longer misreads a `--` separator in the install arguments as part of the server command
- Refreshed the model catalog. Adds seven providers (Agnes AI, Aixy, IteraCompute, LLM Tech, NeoSmith, Pendra, and Standard Compute) and updates model lists and pricing across providers. The resolved default model changes for ClinePass (now GLM 5.3), Z.ai, Hugging Face, evroc, LLM Gateway, NanoGPT, and Weights & Biases, so if you use one of those without pinning a model you will get a different default
## 3.0.58
- The first-launch "Try ClinePass" dialog no longer advertises the $4.99 first-month promo, which is ending
- The hub's event log is now capped at 64 MiB on disk. Events carrying full session snapshots could previously grow the log to tens of gigabytes on a long-running hub, since deleting rows never shrinks the file. Oldest events are dropped first and the space is returned, and pruning runs on volume as well as on a timer
- Refreshed the model catalog. Adds two providers (AgentRouter and Opper) and updates model lists and pricing across providers. The resolved default model changes for Aki.io and NanoGPT, so if you use one of those without pinning a model you will get a different default
## 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
-3
View File
@@ -270,9 +270,6 @@ The postinstall script runs in diverse environments (CI, Docker, restricted perm
### Windows
Windows binaries are `.exe` files. The build script appends `.exe` to the output filename on Windows targets. The resolver handles this. npm on Windows generates `.cmd` shims for bin entries automatically.
### Windows code signing
Windows application control (Smart App Control, WDAC, AppLocker) blocks unsigned executables at launch, regardless of how they were installed — npm distribution gets no exemption ([#12934](https://github.com/cline/cline/issues/12934)). The publish workflow Authenticode-signs `cli-windows-x64/bin/cline.exe` and `cli-windows-arm64/bin/cline.exe` with Azure Trusted Signing before publishing, via the `.github/actions/sign-windows-cli` composite action. Signing runs on the Linux publish runner using [jsign](https://ebourg.github.io/jsign/) (`--storetype TRUSTEDSIGNING`) with an OIDC-federated Entra app, then verifies the signature chain with `osslsigncode` against the Microsoft Identity Verification Root CA 2020. If all `AZURE_*` / `AZURE_TRUSTED_SIGNING_*` repository secrets are absent, the action logs a warning and the release ships unsigned rather than failing; if only some resolve (a typo'd or renamed secret), the release fails loudly instead. The certificate profile secret is suffixed `_CLI` because the desktop app will later get its own profile; the other five secrets are shared. Note that signing bun-compiled executables requires Bun >= 1.2.23 (earlier versions located the embedded bundle relative to the end of the file, which signing corrupts).
### File permissions
Compiled binaries need to be executable (`chmod 755`). The build script sets this after copying. The postinstall also sets permissions on the cached binary. Some npm packaging steps can strip permissions, so both handle this defensively.
-23
View File
@@ -72,29 +72,6 @@ function run(target) {
});
if (result.error) {
console.error(result.error.message);
// Windows application control (Smart App Control, WDAC, AppLocker)
// blocks the child exe at launch, which Node surfaces only as an
// opaque "spawnSync ... UNKNOWN" error. Point users at the real cause.
const code = result.error.code;
if (
os.platform() === "win32" &&
(code === "UNKNOWN" || code === "EACCES" || code === "EPERM")
) {
console.error(
"\nWindows refused to start the Cline binary:\n " +
target +
"\n\n" +
"This usually means an application control policy (Smart App Control,\n" +
"WDAC, or AppLocker) or antivirus blocked the executable. To confirm,\n" +
"run the path above directly in a terminal and check the error Windows\n" +
"reports, or inspect its signature with:\n\n" +
' Get-AuthenticodeSignature "' +
target +
'"\n\n' +
"If it was blocked by policy, allow the file or ask your administrator\n" +
"to trust it. See https://github.com/cline/cline/issues for known issues.",
);
}
process.exit(1);
}
if (typeof result.status === "number") {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.61",
"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 -125
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,117 +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>();
@@ -71,6 +70,7 @@ export function MigrationNoticeContent(
latest open-weight coding models with enough quota for day-to-day
work, at a much lower cost than paying API costs directly.
</text>
<text selectable>Try it now with a limited-time promo for $4.99.</text>
</box>
<box flexDirection="row">
<text fg={palette.act} selectable>
+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,
+4 -53
View File
@@ -1,17 +1,8 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { buildUserInputMessage, resolveSystemPrompt } from "./prompt";
const workspaceDirectories: string[] = [];
afterEach(() => {
for (const directory of workspaceDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { buildUserInputMessage } from "./prompt";
describe("buildUserInputMessage", () => {
it("extracts image mentions into userImages", async () => {
@@ -52,43 +43,3 @@ describe("buildUserInputMessage", () => {
expect(result.userFiles).toEqual([filePath]);
});
});
describe("resolveSystemPrompt workspace metadata", () => {
it("includes git remotes and the latest commit for Cline requests", async () => {
const cwd = mkdtempSync(join(tmpdir(), "cline-prompt-"));
workspaceDirectories.push(cwd);
execFileSync("git", ["init"], { cwd });
execFileSync("git", ["config", "user.email", "test@cline.bot"], { cwd });
execFileSync("git", ["config", "user.name", "Cline Test"], { cwd });
writeFileSync(join(cwd, "README.md"), "test\n");
execFileSync("git", ["add", "README.md"], { cwd });
execFileSync("git", ["commit", "-m", "initial"], { cwd });
execFileSync(
"git",
["remote", "add", "origin", "https://example.com/cline/repo.git"],
{ cwd },
);
const commit = execFileSync("git", ["rev-parse", "HEAD"], {
cwd,
encoding: "utf8",
}).trim();
const prompt = await resolveSystemPrompt({ cwd, providerId: "cline" });
expect(prompt).toContain("origin: https://example.com/cline/repo.git");
expect(prompt).toContain(commit);
});
it("includes parseable metadata outside a project", async () => {
const cwd = mkdtempSync(join(tmpdir(), "cline-prompt-"));
workspaceDirectories.push(cwd);
const prompt = await resolveSystemPrompt({ cwd, providerId: "cline" });
expect(prompt).toContain("# Workspace Configuration");
expect(prompt).toContain(JSON.stringify(cwd));
expect(prompt).toContain(`"hint": "${basename(cwd)}"`);
expect(prompt).not.toContain("associatedRemoteUrls");
expect(prompt).not.toContain("latestGitCommitHash");
});
});
+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 -8
View File
@@ -657,18 +657,11 @@ export function ChatEntryView(props: {
* token identity, so settled content never re-renders.
* tableOptions preserves the bordered table style that coalesced
* mode used by default (top-level defaults to borderless columns).
*
* streaming stays true even after the entry settles: flipping the
* prop makes MarkdownRenderable rebuild every block from scratch
* (updateBlocks(true) skips all reuse paths), so the finished
* message flashes back to unhighlighted text while tree-sitter
* re-highlights. opencode's TUI keeps streaming={true} for the
* same reason. entry.streaming still drives the spinner glyph.
*/}
<markdown
content={content}
syntaxStyle={getSyntaxStyle(theme, mode)}
streaming={true}
streaming={entry.streaming}
internalBlockMode="top-level"
tableOptions={{ style: "grid" }}
fg={defaultFg}
@@ -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 (
@@ -14,27 +14,6 @@ export function resolveHubUpdateRequiredKeyAction(
return "ignore";
}
/**
* Human phrase for the live work an outdated Hub is serving, used by the
* "Hub update required" dialog. Falls back to an unquantified phrase when the
* Hub could not answer the activity query.
*/
export function describeOutdatedHubSessions(counts: {
activeSessionCount?: number;
participantClientCount?: number;
}): string {
const sessions = counts.activeSessionCount;
if (typeof sessions !== "number" || sessions <= 0) {
return "active sessions from other Cline clients";
}
const sessionsPhrase = `${sessions} active session${sessions === 1 ? "" : "s"}`;
const clients = counts.participantClientCount;
if (typeof clients !== "number" || clients <= 0) {
return sessionsPhrase;
}
return `${sessionsPhrase} from ${clients} connected Cline client${clients === 1 ? "" : "s"}`;
}
/**
* Yolo and sandbox sessions force the local backend and never attach to the
* shared managed Hub (see the forceLocalBackend condition in the interactive
@@ -1,27 +1,17 @@
// @jsxImportSource @opentui/react
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useDialogPalette } from "../../hooks/use-theme";
import {
describeOutdatedHubSessions,
resolveHubUpdateRequiredKeyAction,
} from "./hub-update-required-helpers";
import { palette } from "../../palette";
import { resolveHubUpdateRequiredKeyAction } from "./hub-update-required-helpers";
export interface HubUpdateRequiredDetails {
hubCoreVersion?: string;
}
/**
* Shown only for `unsupported_protocol`: the running Hub speaks a protocol
* this CLI cannot, so nothing hub-backed works until the CLI updates. The
* softer `build_mismatch` case (newer Hub, compatible protocol) is a toast
* in root.tsx, not this modal.
*/
export function HubUpdateRequiredContent(
props: ChoiceContext<boolean> & HubUpdateRequiredDetails,
) {
const { dialogId, dismiss, hubCoreVersion, resolve } = props;
const palette = useDialogPalette();
useDialogKeyboard((key) => {
const action = resolveHubUpdateRequiredKeyAction(key);
@@ -38,12 +28,13 @@ export function HubUpdateRequiredContent(
<text fg="yellow">Cline Hub was updated</text>
<box flexDirection="column">
<text selectable>
Another Cline installation updated the shared Cline Hub
{hubCoreVersion ? ` (core ${hubCoreVersion})` : ""} to a version this
CLI cannot talk to.
Another Cline installation restarted the shared Cline Hub
{hubCoreVersion ? ` (core ${hubCoreVersion})` : ""}, and it no longer
matches this CLI.
</text>
<text selectable>
Update and restart Cline to reconnect to the running Hub.
Update and restart Cline so this CLI and the Hub run the same version
again.
</text>
</box>
<box flexDirection="row">
@@ -57,64 +48,3 @@ export function HubUpdateRequiredContent(
</box>
);
}
export interface HubOutdatedDetails {
hubCoreVersion?: string;
activeSessionCount?: number;
participantClientCount?: number;
}
/**
* Shown when this CLI is the newer build and the shared Hub was left running
* an older one because it is still serving other clients' sessions. Enter
* replaces the Hub now (interrupting that work); Esc keeps it running.
*/
export function HubOutdatedContent(
props: ChoiceContext<boolean> & HubOutdatedDetails,
) {
const {
activeSessionCount,
dialogId,
dismiss,
participantClientCount,
resolve,
} = props;
const palette = useDialogPalette();
useDialogKeyboard((key) => {
const action = resolveHubUpdateRequiredKeyAction(key);
if (action === "ignore") return;
if (action === "update") {
resolve(true);
return;
}
dismiss();
}, dialogId);
return (
<box flexDirection="column" paddingX={1} gap={1}>
<text fg="yellow">Cline Hub update required</text>
<box flexDirection="column">
<text selectable>
This CLI needs a newer Cline Hub, but the running one is still serving{" "}
{describeOutdatedHubSessions({
activeSessionCount,
participantClientCount,
})}
.
</text>
<text selectable>
Updating stops that Hub and interrupts its sessions.
</text>
</box>
<box flexDirection="row">
<box paddingX={1} backgroundColor={palette.act}>
<text fg={palette.textOnSelection}>Update Now</text>
</box>
</box>
<text fg={palette.muted}>
Press Enter to update now, Esc to keep the Hub running
</text>
</box>
);
}
@@ -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);
@@ -3,6 +3,7 @@ import {
type ProviderSettingsManager,
saveLocalProviderSettings,
} from "@cline/core";
import { CLI_PROMO_CODE } from "../../../utils/cline-pass-errors";
import {
type DialogDismissKey,
isAnyKeyDismiss,
@@ -76,5 +77,8 @@ export function buildClinePassSubscriptionPageUrl(
appBaseUrl || DEFAULT_APP_BASE_URL,
);
url.searchParams.set("personal", "true");
if (CLI_PROMO_CODE) {
url.searchParams.set("code", CLI_PROMO_CODE);
}
return url.toString();
}
@@ -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);
@@ -1,9 +1,5 @@
import type { AgentMode } from "@cline/core";
import {
type ToolApprovalRequest,
type ToolApprovalResult,
USER_REJECTED_TOOL_REASON,
} from "@cline/shared";
import type { ToolApprovalRequest, ToolApprovalResult } from "@cline/shared";
import { useCallback, useEffect, useRef, useState } from "react";
import type { RuntimeToolInteraction, TuiProps } from "../types";
@@ -40,16 +36,16 @@ function toRuntimeToolInteraction(
};
}
function deniedToolResult(): ToolApprovalResult {
function deniedToolResult(request: ToolApprovalRequest): ToolApprovalResult {
return {
approved: false,
reason: USER_REJECTED_TOOL_REASON,
reason: `Tool "${request.toolName}" was denied by user`,
};
}
function dismissPendingInteraction(pending: PendingRuntimeToolInteraction) {
if (pending.kind === "tool_approval") {
pending.resolve(deniedToolResult());
pending.resolve(deniedToolResult(pending.request));
return;
}
pending.resolve("[User dismissed the question]");
@@ -115,7 +111,9 @@ export function useRuntimeDialogBridge(input: {
if (!pending || pending.id !== id || pending.kind !== "tool_approval") {
return;
}
pending.resolve(approved ? { approved: true } : deniedToolResult());
pending.resolve(
approved ? { approved: true } : deniedToolResult(pending.request),
);
const hasNext = finishActive(id);
if (!hasNext) {
refocusTextarea();
+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,
+10 -84
View File
@@ -2,7 +2,6 @@ import {
getCurrentContextSize,
type ManagedHubBuildMismatchEvent,
summarizeUsageFromMessages,
upgradeManagedHub,
watchManagedHubBuildMismatch,
} from "@cline/core";
import { formatDisplayUserInput } from "@cline/shared";
@@ -24,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,
@@ -41,10 +39,7 @@ import {
buildCommandPaletteItems,
findCommandPaletteShortcut,
} from "./components/dialogs/command-palette-items";
import {
HubOutdatedContent,
HubUpdateRequiredContent,
} from "./components/dialogs/hub-update-required";
import { HubUpdateRequiredContent } from "./components/dialogs/hub-update-required";
import { shouldWatchManagedHubBuild } from "./components/dialogs/hub-update-required-helpers";
import {
SKILLS_MARKETPLACE_ACTION,
@@ -590,85 +585,17 @@ function App(props: TuiProps) {
setHubBuildMismatch(null);
const hubCoreVersion = hubBuildMismatch.hubCoreVersion;
if (hubBuildMismatch.reason === "outdated_hub") {
// This CLI is already the newer build; the Hub is behind only because
// retiring it would kill the sessions it is serving. Left alone it
// would stay behind for as long as those sessions run, so put the
// choice to the user: replace it now (interrupting that work), or
// keep it running and update later. This session itself is safe
// either way - a CLI that could not attach to the outdated Hub is
// running on the local backend.
const details = {
hubCoreVersion,
activeSessionCount: hubBuildMismatch.activeSessionCount,
participantClientCount: hubBuildMismatch.participantClientCount,
};
void dialog
.choice<boolean>({
content: (ctx: ChoiceContext<boolean>) => (
<HubOutdatedContent {...ctx} {...details} />
),
})
.then(async (update) => {
if (!update) {
// choice() resolves undefined on Esc; it does not reject.
showToast(
"The running Cline Hub stays on the older version. Run 'cline hub upgrade' once its sessions finish.",
"info",
);
refocusTextareaRef.current();
return;
}
showToast("Updating the Cline Hub…", "info");
try {
const result = await upgradeManagedHub({
force: true,
reason: "cline TUI hub update",
});
if (result.outcome === "still_busy") {
showToast(
"The Hub picked up new sessions before it could be replaced. Try again in a moment.",
"info",
);
} else {
showToast(
result.outcome === "replaced" || result.outcome === "started"
? "Cline Hub updated."
: "Cline Hub is already up to date.",
"success",
);
}
} catch (error) {
showToast(
error instanceof Error && error.message
? error.message
: "Updating the Cline Hub failed. Run 'cline doctor fix' and try again.",
"error",
);
}
refocusTextareaRef.current();
})
.catch(() => {
refocusTextareaRef.current();
});
// This CLI is already the newer build. The Hub is behind only because
// retiring it would kill the sessions it is serving, and it is
// replaced on its own at the next launch. Nothing is wrong, nothing is
// asked, and nothing the user can act on differs - so say nothing, the
// same conclusion the desktop surface reached.
//
// The classification still earns its keep here: it is what stops the
// update-and-restart prompt below from firing at someone who has
// nothing to update.
return;
}
if (hubBuildMismatch.reason === "build_mismatch") {
// The Hub is newer but still speaks this CLI's protocol, so the
// session keeps working and parity is advisable rather than urgent.
// A modal mid-session is too heavy for advice; a toast (once per
// observed Hub build, the watcher dedupes) says what changed and
// how to catch up without stealing focus.
showToast(
`The shared Cline Hub was updated${
hubCoreVersion ? ` (core ${hubCoreVersion})` : ""
}. Run 'cline update' and restart when convenient.`,
"info",
);
return;
}
// unsupported_protocol: this CLI cannot speak the running Hub's
// protocol at all, so nothing hub-backed can work until it updates.
// That is worth a blocking prompt.
void dialog
.choice<boolean>({
content: (ctx: ChoiceContext<boolean>) => (
@@ -1111,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;
@@ -1,4 +1,5 @@
import { isProviderSettingsUsable, ProviderSettingsManager } from "@cline/core";
import { ProviderSettingsManager } from "@cline/core";
import { isProviderSettingsUsable } from "../../utils/provider-readiness";
import type { TuiProps } from "../types";
export function isProviderConfigured(config: TuiProps["config"]): boolean {
+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,
+2 -6
View File
@@ -1,9 +1,5 @@
import { createInterface } from "node:readline";
import {
type ToolApprovalRequest,
type ToolApprovalResult,
USER_REJECTED_TOOL_REASON,
} from "@cline/shared";
import type { ToolApprovalRequest, ToolApprovalResult } from "@cline/shared";
import { truncate } from "./helpers";
import { c, getActiveCliSession, write } from "./output";
@@ -95,7 +91,7 @@ async function requestTerminalToolApproval(
}
return {
approved: false,
reason: USER_REJECTED_TOOL_REASON,
reason: `Tool "${request.toolName}" was denied by user`,
};
}
-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,
},
});
}
+12 -3
View File
@@ -18,11 +18,20 @@ import { getClineEnvironmentConfig } from "@cline/shared";
export { getClineOrgIndividualInferenceSubscriptionMessage };
export const CLI_PROMO_CODE = "";
export function getCliSubscriptionUrl(): string {
return new URL(
`/dashboard/subscription?personal=true`,
if (!CLI_PROMO_CODE) {
return new URL(
`/dashboard/subscription?personal=true`,
getClineEnvironmentConfig().appBaseUrl,
).toString();
}
return `${new URL(
`/promo?code=${CLI_PROMO_CODE}&personal=true`,
getClineEnvironmentConfig().appBaseUrl,
).toString();
).toString()}`;
}
export function getCliNotSubscribedMessage(): string {
@@ -1,5 +1,5 @@
import type { ProviderSettings } from "@cline/core";
import { describe, expect, it } from "vitest";
import type { ProviderSettings } from "../llms/provider-settings";
import { isProviderSettingsUsable } from "./provider-readiness";
describe("provider readiness", () => {
@@ -167,24 +167,4 @@ describe("provider readiness", () => {
} satisfies ProviderSettings),
).toBe(false);
});
it("rejects migrated placeholder entries that only hold a default model", () => {
// The legacy VS Code migration can seed entries with a catalog-default
// model and no credentials (e.g. qwen-code from a plan/act provider
// selection, sapaicore from a persisted orchestration-mode default).
// These must not read as configured.
expect(
isProviderSettingsUsable("qwen-code", {
provider: "qwen-code",
model: "qwen3-coder-plus",
} satisfies ProviderSettings),
).toBe(false);
expect(
isProviderSettingsUsable("sapaicore", {
provider: "sapaicore",
model: "anthropic--claude-4-sonnet",
sap: { useOrchestrationMode: true },
} satisfies ProviderSettings),
).toBe(false);
});
});
@@ -1,13 +1,13 @@
import * as LlmsModels from "@cline/llms";
import {
getProviderConfigFields,
type ProviderConfig,
type ProviderSettings,
} from "@cline/core";
import {
getPersistedProviderApiKey,
isOAuthProvider,
} from "../../auth/provider-auth-registry";
import type {
ProviderConfig,
ProviderSettings,
} from "../llms/provider-settings";
import { getProviderConfigFields } from "./provider-config-fields";
normalizeProviderId,
} from "./provider-auth";
function hasText(value: string | undefined): boolean {
return typeof value === "string" && value.trim().length > 0;
@@ -34,7 +34,7 @@ function hasAwsRegion(settings: ProviderSettings): boolean {
function hasGcpCredentials(settings: ProviderSettings): boolean {
const gcp = settings.gcp;
// Vertex defaults to us-central1 at runtime when no region is stored, so keep
// existing project-only configs usable while new saves include a region.
// existing project-only configs usable while new CLI saves include a region.
return hasText(gcp?.projectId);
}
@@ -52,14 +52,6 @@ function hasSapCredentials(settings: ProviderSettings): boolean {
);
}
/**
* Whether persisted provider settings hold enough real credentials or
* endpoint configuration for a turn to plausibly succeed. Unlike the mere
* existence of a settings entry (which migrations and empty "connect" saves
* can create), this requires provider-appropriate evidence: an API key or
* OAuth token, cloud credentials (AWS/GCP/Azure/SAP), a local-auth CLI, or a
* resolvable endpoint + model for keyless local providers.
*/
export function isProviderSettingsUsable(
providerId: string,
settings: ProviderSettings | undefined,
@@ -68,10 +60,8 @@ export function isProviderSettingsUsable(
if (!settings) {
return false;
}
const normalizedProviderId = LlmsModels.normalizeProviderId(providerId);
if (
LlmsModels.normalizeProviderId(settings.provider) !== normalizedProviderId
) {
const normalizedProviderId = normalizeProviderId(providerId);
if (normalizeProviderId(settings.provider) !== normalizedProviderId) {
return false;
}
if (normalizedProviderId === "bedrock") {
+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 -16
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 };
@@ -264,19 +283,6 @@ export async function handleDesktopCommand(
) {
return [...ctx.sessions.values()].map(toWebviewSessionSummary);
}
if (command === "search_sessions") {
if (!ctx.uiClient) throw new Error("Hub is not connected");
const query = String(args?.query ?? "").trim();
if (!query) return [];
return await ctx.uiClient.searchSessions({
query,
limit: typeof args?.limit === "number" ? args.limit : 50,
workspaceRoot:
typeof args?.workspaceRoot === "string"
? args.workspaceRoot
: undefined,
});
}
if (command === "read_session_hooks") {
return [];
}
+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 -1
View File
@@ -29,7 +29,7 @@
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.577.0",
"media-chrome": "^4.18.1",
"mermaid": "11.16.1",
"mermaid": "11.16.0",
"motion": "^12.38.0",
"nanoid": "^5.1.7",
"next-themes": "^0.4.6",
-87
View File
@@ -15,7 +15,6 @@ import {
PlugIcon,
RotateCcwIcon,
RssIcon,
SearchIcon,
ServerIcon,
SettingsIcon,
Trash2Icon,
@@ -64,7 +63,6 @@ import type {
import { PageFrame, PageHeader } from "./components/views/page-layout";
import type { CustomizationSection } from "./components/views/settings/extensions-view";
import type { SettingsSection } from "./components/views/settings/settings-view";
import { desktopClient } from "./lib/desktop-client";
import { syncHubTheme } from "./lib/theme";
import { postToHost } from "./vscode";
@@ -742,18 +740,7 @@ function SessionsView({
onRenameSession: (sessionId: string, title: string) => Promise<void> | void;
sessions: WebviewSessionSummary[];
}) {
type SearchHit = {
sessionId: string;
documentId: string;
title: string;
workspaceRoot: string;
role: string;
snippet: string;
};
const [sessionFilters, setSessionFilters] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [searchHits, setSearchHits] = useState<SearchHit[]>([]);
const [searching, setSearching] = useState(false);
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState("");
const [deleteSessionCandidate, setDeleteSessionCandidate] =
@@ -785,34 +772,6 @@ function SessionsView({
});
}, [sessions, sessionFilters, sortDirection]);
useEffect(() => {
const query = searchQuery.trim();
if (!query) {
setSearchHits([]);
setSearching(false);
return;
}
let cancelled = false;
setSearching(true);
const timer = setTimeout(() => {
void desktopClient
.invoke<SearchHit[]>("search_sessions", { query, limit: 50 })
.then((hits) => {
if (!cancelled) setSearchHits(hits);
})
.catch(() => {
if (!cancelled) setSearchHits([]);
})
.finally(() => {
if (!cancelled) setSearching(false);
});
}, 200);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [searchQuery]);
const startRenameSession = (session: WebviewSessionSummary) => {
setEditingSessionId(session.sessionId);
setEditingTitle(session.title || shortId(session.sessionId));
@@ -935,52 +894,6 @@ function SessionsView({
</>
}
/>
<div className="relative mb-3">
<SearchIcon className="pointer-events-none absolute left-3 top-2.5 size-4 text-muted-foreground" />
<Input
aria-label="Search all session history"
className="pl-9"
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search messages, commands, errors, and file paths across all sessions…"
value={searchQuery}
/>
</div>
{searchQuery.trim() ? (
<section className="mb-4 overflow-hidden rounded-lg border bg-card">
{searching ? (
<p className="px-4 py-5 text-sm text-muted-foreground">
Searching
</p>
) : searchHits.length === 0 ? (
<p className="px-4 py-5 text-sm text-muted-foreground">
No matching session history.
</p>
) : (
searchHits.map((hit) => (
<button
className="block w-full border-b px-4 py-3 text-left last:border-b-0 hover:bg-accent/40"
key={hit.documentId}
onClick={() => onOpenSession(hit.sessionId)}
type="button"
>
<div className="flex items-center gap-2 text-sm font-medium">
<span className="truncate">{hit.title}</span>
<span className="text-xs font-normal text-muted-foreground">
{hit.role}
</span>
</div>
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{hit.snippet}
</p>
<p className="mt-1 truncate text-xs text-muted-foreground">
{hit.workspaceRoot}
</p>
</button>
))
)}
</section>
) : null}
<section className="w-full min-w-0 overflow-x-auto">
<div className="grid w-full min-w-[56rem] grid-cols-[minmax(12rem,1.35fr)_minmax(7rem,0.85fr)_minmax(10rem,1.1fr)_5rem_5rem_4.5rem_5.5rem_2rem] gap-x-4 bg-muted/40 px-4 py-3 text-[15px] font-medium text-muted-foreground">
+2 -5
View File
@@ -1,9 +1,6 @@
"use client";
import {
type GeneratedMedia,
USER_REJECTED_TOOL_REASON,
} from "@cline/shared/browser";
import type { GeneratedMedia } from "@cline/shared/browser";
import { GeneratedMediaContent } from "@cline/ui";
import {
CheckIcon,
@@ -1185,7 +1182,7 @@ export default function Chat({
type: "approval_response",
approvalId,
approved,
reason: approved ? "Approved in Cline Hub." : USER_REJECTED_TOOL_REASON,
reason: approved ? "Approved in Cline Hub." : "Rejected in Cline Hub.",
});
setStatus(approved ? "Approval sent." : "Rejection sent.");
};
@@ -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 -128
View File
@@ -1,133 +1,13 @@
# Cline Desktop Changelog
# Cline Code Desktop Changelog
## 0.0.21
## 0.0.14-beta.1
- Marketplace is now a two-pane explorer: a browsable list on the left and full catalog metadata for the selected item on the right, with category tag filters that collapse behind a "more" toggle
- Stopping a session now actually stops everything it started. Stop stays available while child agents are running, and an abort propagates to delegated subagents and to teammates instead of leaving orphaned work running in the background; cancelled teammate tasks now persist as cancelled
- Fixed the ask-a-question tool's option text overflowing instead of wrapping
- You can now drop file attachments anywhere over the chat input, not just on the small attach target
- Cline provider models now refresh from the live catalog, so newly released models show up without waiting for an app update
- Provider 401/403 responses are now classified as authentication errors rather than generic request failures, so a bad or missing API key is distinguishable from a real provider outage
- Fixed Langfuse tracing never initializing in release builds — the minified bundle broke tracer detection, so telemetry worked in dev and silently did nothing in the shipped app. Also updated for AI SDK 7's telemetry API
- Refreshed the model catalog. Adds TokenGo and Volcengine Ark, and updates model lists, pricing, and the resolved default model for ~36 providers (including Hugging Face, Mistral, OpenRouter, Together, NanoGPT, Requesty, Baseten, Cloudflare Workers AI, and DigitalOcean) — if you use one of those without pinning a model, you will get a different default
## 0.0.20
- Cline Desktop now ships on Windows: releases include a code-signed x64 installer, and installed apps auto-update on the same feed macOS does
- Windows shell fixes: background processes (the sidecar, git) no longer pop visible console windows; updates now download in the background and install when you restart the app; the MCP settings path falls back to `USERPROFILE` when `HOME` is unset
- Tool results that return images — screenshots from browser or MCP tools — now render as inline images you can click to expand, with a carousel for stepping through multiple images, instead of raw base64 text
- Session search now covers your full indexed history. The sidebar search icon opens the command bar (Cmd/Ctrl+P) with server-ranked results, instead of a sidebar-local dialog that first loaded every session into memory
- Onboarding has a new GitHub integration step
- Fixed scheduled tasks disappearing after the app updated — hub-managed schedules were being wiped by cron reconciliation on restart
- Agent-created schedules now live in one user-level home (`~/.cline/schedules`) instead of being scattered across whichever chat folder created them, and they now appear on the Schedules page
- A finished scheduled session now surfaces its final answer: the completing step auto-expands, is labeled "Scheduled task completed" (or failed), and its summary renders as markdown
- Suggested routine templates now ask for a specific final report, so a scheduled run ends with something readable
- Providers no longer show as "Configured" on the strength of a leftover settings entry with no real credentials, and the badge now updates live after connecting or saving credentials instead of waiting for a remount
- Fixed OpenAI Codex (ChatGPT subscription) sign-in silently dead-ending when callback port 1455 was already in use — it now fails immediately with an actionable error, and OAuth redirect errors surface instead of a confusing "Missing authorization code"
- Codex and OCA sign-ins are no longer dropped when a token refresh hits a transient network failure or server error
- Checkpoint restore now refuses to reset your workspace when commits were made after the checkpoint, instead of silently knocking them off the branch
- Fixed an enabled-but-offline remote MCP server stalling session startup until the session was torn down
- Global rules stored at `~/Cline/Rules` are now discovered (previously only `~/Documents/Cline/Rules`), fixing rules that never reached the model on WSL and headless installs
- `apply_patch` now preserves a file's own CRLF line endings
- The window title bar stays draggable across every view
- Voice input's Live and After recording badges now have tooltips explaining them
- Removed the box shadow from the chat message actions row
- The hub no longer watches agenda spec directories while the todo tool is disabled, dropping an OS watch handle per known workspace
## 0.0.19
- Fixed the background Cline process ballooning in memory during long sessions — session status updates were carrying a full copy of the conversation transcript to every connected client, which on a multi-megabyte task could grow the process to tens of gigabytes. Status updates now carry only state (status, usage, model, workspace, checkpoint); the transcript is fetched on demand
- Refreshed the model catalog. Adds seven providers (Agnes AI, Aixy, IteraCompute, LLM Tech, NeoSmith, Pendra, and Standard Compute) and updates model lists and pricing across providers. The resolved default model changes for ClinePass (now GLM 5.3), Z.ai, Hugging Face, evroc, LLM Gateway, NanoGPT, and Weights & Biases, so if you use one of those without pinning a model you will get a different default
## 0.0.18
- The sidebar is time-sorted again by default, with collapsible Pinned / Scheduled / Tasks sections and a one-click toggle to switch to project grouping (the old dropdown is gone). Scheduled sessions are marked with a clock icon, and the list starts taller and grows to fill the sidebar instead of stranding rows over empty space
- Session rows now show a trash button on hover for quick deletion, with the same confirmation the row's context menu uses
- Customize is now your installed inventory only. Browsing moved to a dedicated Marketplace page — one list across plugins, MCP servers, and skills with type-filter and tag chips — and the two pages link to each other from their headers and from sidebar sub-tabs
- Schedule cards are now click targets: clicking a card anywhere outside its controls opens its details, the redundant eye button is gone, and the edit / run / pause / delete buttons are large enough to hit
- Schedule details are one scrollable view instead of Overview/Runs tabs, showing the meta grid, the configuration, and the most recent runs with a "Show all N runs" expander
- "Run now" now hands you into the session it starts
- Scheduled and automation runs no longer render their internal `[SYSTEM]` steering messages as if you had typed them — a finished scheduled session reads as prompt, work summary, answer
- Fixed opening a scheduled session while it runs leaving it stuck on the thinking shimmer until you switched away and back
- Fixed installing plugins and MCP servers from the Marketplace failing with `Executable not found in $PATH: "cline"` — installs now run in-process and no longer require a Cline CLI on your machine
- Fixed quitting the app beach-balling for several seconds
- Cost estimates are no longer shown for subscription-billed providers (ClinePass, ChatGPT via Codex, and Claude Code), where an API-rate dollar figure read as a real charge on top of your subscription
- Fixed hover cards flashing closed and reopening when clicked
- The macOS DMG install window now has custom Cline artwork and layout
- Credentials embedded in git remote URLs are now redacted from the workspace information sent to the model
## 0.0.17
- Plugins, MCP, Skills, Rules, Hooks, and Tools are now one Customize hub with tabbed sections and live counts. Catalog-backed tabs show what you have installed followed by an inline Browse section, so installing something from the catalog immediately appears above — the separate Marketplace page is gone
- Redesigned the Models page: providers are grouped into Connected, Popular, and All with their auth kind and configuration status instead of per-row toggles. OAuth providers now offer a browser sign-in rather than an API key field, with a collapsed manual-key escape hatch where supported, and explicit Connect / Disconnect / Sign out actions
- Voice input moved to its own Settings → Voice page that only offers connected transcription-capable providers and preselects a default model. The composer's microphone button now appears only once a voice model is configured
- Sidebar sessions are always grouped by project, with pinned sessions leading each group and scheduled sessions marked by an inline clock. The Favorite action is now called Pin
- New, Schedule, and Customize each got their own labeled row below the logo. New starts a fresh task and puts your cursor straight in the composer
- Session search moved into a dialog behind the search icon in the logo row, and it now searches your full history instead of only the sessions already loaded in the sidebar
- Added suggested schedule templates to the Schedule page
- Add Provider opens a dialog instead of swapping out the page
- Desktop notifications are now a single section under General, so the Event/Notify/Sound matrix no longer reads as a peer of settings like Dark mode
- The agent's todo tool and the Agenda panel have been removed; scheduled tasks are unaffected
- Fixed the provider list being unscrollable while a provider detail panel was open
- Fixed a failed settings save leaving the Models page claiming a provider configuration that was never written to disk
- Fixed Uninstall buttons collapsing to a broken square next to Install
- Fixed unreadable selected text inside input fields
- New files are now created with your platform's native line endings
- Fixed the codebase search tool crashing the app on files containing a single enormous line
- The hub's event log can no longer grow until it fills your disk
## 0.0.16
- The agent can now be handed off between Hub instances without losing work: a Hub that is restarting refuses new work while it finishes what it is running, and the app replays anything it missed while disconnected instead of dropping it
- Fixed tool calling being silently disabled for custom OpenAI-Compatible models whose capability list was inferred from convenience flags like `supportsReasoning`
- Refreshed the model catalog, which updates model lists and pricing across providers and changes the resolved default model for several of them (DeepSeek, Crof, CrossModel, Eden AI, Kilo, and NanoGPT)
- The app now honors server-side feature flags, refreshing them when your account changes
## 0.0.15
- The app is now called Cline, renamed from Cline Code. Your settings, sessions, and credentials carry over untouched — only the name and icon change
- 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 -36
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
@@ -17,38 +16,6 @@ From `apps/examples/desktop-app/`:
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
- `bun run typecheck` - TypeScript check
## Customizing the macOS Install Window
The drag-to-Applications window is configured by `bundle.macOS.dmg` in
[`src-tauri/tauri.conf.json`](./src-tauri/tauri.conf.json). Its artwork comes
from the PNG sources in [`src-tauri/dmg/`](./src-tauri/dmg/); the
`background.gen.tiff` Finder actually renders is a gitignored build artifact
regenerated from them on every build.
1. The current source artwork is `640x400`. Export `background.png` at 1x and
`background@2x.png` at 2x.
2. Currently the app icons are centered at `(140, 200)` and
the Applications folder centered at `(500, 200)`. If updating artwork, update `appPosition`
and `applicationFolderPosition` to reposition the app icons.
3. Build with `bun run build:binary`. Before compiling, the build validates
both PNG dimensions, combines them with `tiffutil` into the Retina-aware
`src-tauri/dmg/background.gen.tiff`, and verifies the TIFF contains the
expected 1x and 2x representations. Run `bun run dmg:background` to do just
that step, e.g. to sanity-check new artwork without a full build. The DMG
is written beneath `src-tauri/target/release/bundle/dmg/`.
Run `bun run test:dmg-background` for the cross-platform checks covering the
committed PNG dimensions and TIFF validation logic.
The configured `640x432` Finder window is intentionally 32 points taller than
the `640x400` background. That extra height matches the Finder chrome in the
currently verified packaged layout; re-check it after material macOS or Finder
changes. The project deliberately uses a multi-resolution TIFF even though
Tauri's documented background formats are PNG, JPG, and GIF: Finder renders
both the 1x and 2x representations from a single background file. Re-check the
packaged DMG after upgrading Tauri in case its background validation changes.
## Login Shell PATH Resolution
Apps launched from Finder/the Dock inherit launchd's minimal `PATH`
@@ -63,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
@@ -88,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
@@ -174,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 -11
View File
@@ -1,22 +1,18 @@
{
"name": "@cline/code",
"version": "0.0.21",
"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",
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
"build:binary": "tauri build",
"dmg:background": "bun run scripts/dmg-background.ts",
"test:dmg-background": "bun test scripts/dmg-background.test.ts",
"package": "bun run package:desktop",
"package:desktop": "bun run scripts/package-desktop.ts",
"package:desktop:mac": "bun run scripts/package-desktop.ts --platform mac",
@@ -30,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",
@@ -70,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",
@@ -82,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",
@@ -94,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) => {

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