Compare commits

..

79 Commits

Author SHA1 Message Date
Saoud Rizwan b496c5a1f3 chore(cli): release v3.0.47 2026-07-28 15:36:26 -07:00
Bee 2fc06ce446 feat(desktop): add pagination to session history view (#12618)
* fix(desktop): order sessions by last activity and unify status dot colors

* feat(desktop): add pagination to session history view

Display sessions in ten-item pages and fetch older history only after reaching the final local page. Add coverage for pagination, session opening, and compact token formatting.

* page numbers

* Support adding a session to favorite list

* apply feedback

* fix
2026-07-28 15:30:43 -07:00
Bee 64c50380f3 feat(chat): refine tool and reasoning message disclosures (#12616)
* feat(chat): refine tool and reasoning message disclosures

Add tool-specific icons with a fallback, display elapsed reasoning time, and restyle reasoning disclosures. Position hidden message actions outside the layout and update attachment sizing to valid Tailwind utilities.

* fix(desktop): improve chat message actions and scrolling

Refine action positioning, sizing, timestamps, and visibility for chat messages. Remove nested overflow constraints so scrolling remains controlled by the conversation viewport, and tighten tool disclosure spacing.

* tools icon mapping

* fix(desktop): align chat timestamps and tool icons
2026-07-28 15:30:33 -07:00
Saoud Rizwan e36197d911 chore(sdk): release v0.0.66 2026-07-28 15:22:13 -07:00
Bee 52d187578e feat(desktop): show subagent/teammates execution history (#12615)
* feat(desktop): expose session agent execution history

Add a list_session_agents sidecar command to retrieve agent and team run details from child sessions and tool messages. Include comprehensive tests for agent discovery, message parsing, status handling, and result normalization.

* apply feedback

* add test

* feedback fix

* fix

* fix p1
2026-07-28 15:09:27 -07:00
Tomás Barreiro a6239c420c Update generated files (#12649) 2026-07-28 13:56:05 -07:00
Bee c7c5e6518b fix(desktop): order sessions by last activity and unify status dot colors (#12617) 2026-07-28 12:06:59 -07:00
Bee 98c0717302 feat(desktop): system tray session status (#12659)
* feat(desktop): add system tray session status support

Enable Tauri tray icon and PNG image features for desktop tray integration. Expose the running session count in process context so the tray can reflect active work, with test coverage for running and idle sessions.

* fix(desktop): buffer tray actions and show app status
2026-07-28 20:59:36 +02:00
Dominic Cooney b4aed24ff8 fix(vscode): compact tasks opened from history (#12002)
* fix(vscode): compact tasks opened from history

The compact button only worked while a session was actively running.
Opening a task from history and clicking compact errored with "There is
no active task to compact."

Compaction is defined over a session transcript, so rather than grow a
second implementation for displayed tasks, resume a displayed history
task on an isolated session host and compact it through the existing
path. The coordinator owns and disposes that host, so task navigation
cannot make cleanup stop a replacement active session.

Follow-up resume and both compaction paths (idle active session and
displayed task) acquire the same session-rebuild boundary around
transcript read, session start, and persistence. Task and session
object identity are rechecked across awaits; cleanup targets only the
exact host and session started by the operation. A follow-up abandoned
by task navigation settles the streaming turn phase it pre-set, so the
newly displayed task never shows a stuck Thinking/Cancel footer.

The resume-start preparation shared by follow-up and compaction is
extracted into prepareTaskResumeStartInput, including legacy task
conversion, so the two callers cannot drift apart.

The compaction divider UX and context-meter shrink remain owned by the
already-merged webview compaction change.

* fix(vscode): deliver follow-ups across a same-task proxy reload

Follow-up targeting checks compared the displayed TaskProxy by object
identity, but showTaskWithId allocates a fresh proxy for the same task
id, so reloading the task mid-resume silently dropped the message.
Compare targeting by taskId; cleanup keeps object identity.
2026-07-28 10:52:13 -07:00
Saoud Rizwan e9ec82d2ef docs: remove stray double space in README CLI example (#12594)
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 09:45:51 -07:00
Saoud Rizwan e3ff875e09 fix(cli): persist /settings general toggles (mode, auto-approve, compaction) across restarts (#12614)
* feat(core): persist plan/act mode, tool auto-approve, and compaction mode in global settings

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

* fix(cli): restore /settings general toggles across restarts

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

* fix(core): make global settings updates cross-process safe

Targeted setters previously did unlocked read-modify-write cycles over the
shared global-settings.json, so concurrent hosts (two CLIs, or CLI + VS Code)
could silently discard each other's changes. Route all setters through a new
updateGlobalSettings(mutate) helper that re-reads the latest on-disk state
under a short-lived lock file (with stale-lock reclaim and a bounded wait)
and replaces the file atomically via temp-file rename so readers never see
torn writes.

* Revert "fix(core): make global settings updates cross-process safe"

This reverts commit 198c1c831b.

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-28 08:56:55 -07:00
Tomás Barreiro dc175c73a8 Cline free ux (#12593)
* Introduce the concept of free models that have the cline-free ID

* Add (free) to explicitly free models

* Render (free) in free models name

* Add pricing to the free model info

* Fix free model pricing

* fix tests

* SEt cline-free model pricing to 0

* revert pricing changes

* Add free limit error handling

* Include the reset time in the message

* Add button to switch model in VSCode

* add model not found error

* remove problematic tests

* fix review messages

* Fix model promotion ended

* Revert "revert pricing changes"

This reverts commit 7e5b2a34fd.
2026-07-28 06:20:28 +02:00
Tomás Barreiro d91f1ce166 Add support for cline-free models. (#12591)
* Introduce the concept of free models that have the cline-free ID

* Add (free) to explicitly free models

* Render (free) in free models name

* Add pricing to the free model info

* Fix free model pricing

* fix tests

* SEt cline-free model pricing to 0
2026-07-28 05:50:15 +02:00
Saoud Rizwan bc5a2e85a4 chore(desktop): release v0.0.6 2026-07-27 19:01:44 -07:00
Saoud Rizwan 9c56a726a7 Add persistent sidebar update indicator for staged desktop app updates (#12611)
* Add persistent sidebar update indicator for staged app updates

* Surface restart failures from the update indicator and reset its pending state
2026-07-27 18:37:51 -07:00
Saoud Rizwan 72c9bbfcaa Clarify desktop app auto-update setting: it governs the CLI, not the app (#12601)
* Reword desktop auto-update setting description to drop CLI mention

* Clarify desktop settings copy: auto-update toggle governs the CLI, not the app
2026-07-27 18:14:39 -07:00
Bee 53a7c3e80e fix(desktop): align startup appearance and session context (#12608)
* fix(desktop): align startup appearance and session context

* update cline logo size

* display full workspace name

* fix: fits in narrow screen size

* header in narrow screen

* Transient failures no longer replace a valid branch with no-git.

* header alignments

* account settings button row

* fix(desktop): improve collapsed sidebar settings layout

Use a compact overlay-friendly width and left-align navigation controls in collapsed settings. Adjust header padding, stack account details, anchor the expand button, and add layout regression tests.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-27 18:13:55 -07:00
John Choi 8751daaba9 refactor(ui): extract desktop session status (#12596) 2026-07-27 17:16:04 -07:00
Bee aa1fffbe80 fix(ui): use solid primary color for checked switch (#12600)
Update the switch's checked state background from translucent to solid primary for clearer visual feedback and improved contrast.
2026-07-27 16:55:43 -07:00
Saoud Rizwan 283c3ba937 ci: grant pull-requests write so promo-comment deletion works on PRs (#12606) 2026-07-27 16:51:00 -07:00
Saoud Rizwan 69c9a9ac28 ci: delete coding-agent promo comments on PRs (#12604) 2026-07-27 16:44:48 -07:00
Bee 4d238558d0 feat(deaktop): queue ui update (#12534)
* feat(deaktop): queue ui update

* fix(desktop): address queue review feedback

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-27 16:06:16 -07:00
Bee 51e5daf8eb feat(core): persist and restore connector sessions (#12125)
* feat(core): persist and restore connector sessions

Persist successful connector runs for autostart and disable persistence when connectors are stopped. Reconnect saved connector channels during CLI daemon or hub startup so previously connected adapters are restored after restarts without blocking startup.

* fix(core): harden connector autostart recovery

* fix(core): address connector autostart edge cases

* fix(core): address connector reconnect review feedback

* fix(core): address remaining connector review feedback

* fix(core): address connector persistence review feedback

* fix(core): address connector lifecycle review feedback

* fix(core): restart surviving connectors after hub restart

* fix(core): refresh Telegram identity after token rotation

* fix(hub): restart active connectors on start

* fix(connectors): address lifecycle review regressions

* fix(connectors): make reconnects instance-safe

* fix(connectors): harden restart failure handling
2026-07-27 15:59:23 -07:00
John Choi 28ef954258 feat(ui): add host-safe theme and Markdown exports (#12439)
* feat(ui): add host-safe theme and package contract

* fix(ui): preserve scoped theme contract

* refactor(ui): establish generated theme contract

* chore(ui): refresh committed build

* fix(ui): isolate markdown presentation

* chore(ui): prepare next package preview

* fix(ui): harden preview package contract

* fix(ui): enforce clean package builds in CI

* refactor(ui): rely on npm package builds

* test(ui): make package smoke failures actionable

* ci(sdk): restrict pull requests to main

* test(ui): verify the published package contract

* docs(ui): document the React types floor

* chore(ui): keep package checks out of shared workflows

* fix(ui): preserve standalone markdown cascade

* fix(ui): reject stale generated theme builds

* refactor(ui): narrow foundation to adoption needs

* test(ui): verify packed CSS exports exist

* fix(ui): restore publish contract safeguards

* refactor(ui): keep foundation adoption-focused

* test(ui): verify new packed CSS exports
2026-07-27 15:26:27 -07:00
Saoud Rizwan 9c4841ea07 chore(desktop): release v0.0.5 2026-07-27 15:21:49 -07:00
Saoud Rizwan 1bd200e906 ci: strip cloud-agent promo wrappers from PR bodies (#12588)
* ci: strip cloud-agent promo wrappers from PR bodies

* ci: make PR body strip vendor-agnostic, pin github-script to SHA
2026-07-27 15:10:05 -07:00
Saoud Rizwan fabbc144d6 perf(desktop): make the app feel snappy end-to-end (#12568)
* perf(desktop): make the app feel snappy end-to-end

Fixes several compounding sources of UI jank that made every click and
keystroke feel seconds-slow:

- Aurora background: drop per-frame 46-64px CSS blur re-rasterization;
  bake softness into gradients + a static mask and animate only
  opacity/transform (compositor-only). Onboarding/home idle went from
  ~10fps to a locked 60fps under 4x CPU throttling.
- Hide the app shell while the opaque onboarding overlay is up so a
  second aurora + hero animations are not composited underneath.
- Hero verb animation: opacity/transform only (no text blur filter).
- Composer: keystroke state now lives inside ChatInputBar (versioned
  promptDraft injections for quick actions/undo/resets), and mention/
  slash detection is derived instead of effect-synced; typing went from
  245/246 keystrokes over 50ms to 3/240.
- Chat streaming: coalesce per-token text/reasoning deltas into ~48ms
  flushes; memoize MessageBubble/ToolMessageBlock with stable callbacks
  so finished messages skip re-rendering during streams.
- Session history: only surface isLoadingHistory before the first load;
  background refreshes no longer re-render the whole app twice each.
- Provider catalog (~700KB): dedupe concurrent fetches with a short TTL
  so app boot issues one round-trip instead of three.
- Sidecar: session-log appends are now ordered async writes instead of
  writeFileSync per streamed token; git/folder-picker/editor discovery
  use async execFile so the native picker no longer freezes every
  pending command; editor discovery results cached for 60s.

* fix(desktop): address Bugbot review findings

- Invalidate the shared provider-catalog cache after any provider
  mutation (onboarding connect paths, account sign-in/out, settings
  save, add provider) so post-save reloads never see a pre-save copy.
- Clear the injected composer draft on send so a composer remount
  cannot repopulate the previous prompt.
- Mark the hidden app shell inert + aria-hidden while the onboarding
  overlay covers it, keeping covered controls out of the tab order.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-27 15:04:49 -07:00
Amy Duquette 372f029343 docs: add Poolside provider setup guide (#12586) 2026-07-27 21:38:35 +02:00
Tomás Barreiro 9d9b7aa6f8 Add data dirs when running specific environments (#12585) 2026-07-27 21:19:07 +02:00
Dominic Cooney 8c7095b805 test(cli): avoid hard-coded dialog background color (#12579)
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-07-27 11:07:54 -07:00
Dominic Cooney 05535e844c chore(vscode): remove unused OpenTelemetry dependencies (#12573)
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-07-27 10:29:13 -07:00
Tran Binh Minh c3dc4a95bc fix(cli): use unique keys for read_files rows (#12144)
read_files rendered file rows keyed by raw path, so the same path listed twice (e.g. find-skills reading a SKILL.md repeatedly) produced duplicate React keys and the two-children-with-the-same-key warning. Build index-namespaced keys instead, at both render sites.

Fixes #9784

Signed-off-by: Minhkunn <minh.12072k6@gmail.com>
Co-authored-by: Minhkunn <minh.12072k6@gmail.com>
2026-07-26 13:41:30 -07:00
Sufiyan Khan 9466fcc018 docs: fix typos, incorrect slash command, and hooks vs plugins mismatch (#12154)
* docs: fix typos and incorrect slash command reference

- Fix double period in MiniMax provider description
- Remove duplicate 'through' in kanban install description
- Fix /new -> /newtask (correct slash command name)

* docs(hooks): fix description to reference SDK Plugins, not SDK Hooks

The description said 'SDK Hooks page' but the content links to the
SDK Plugins page (/sdk/plugins). Align the description with the
actual destination.
2026-07-26 13:13:39 -07:00
Saoud Rizwan dd7a1c5fa6 fix(desktop): onboarding Cline API key path, stuck "Agent is working..." composer, OAuth sign-in cancel (#12564)
* fix(desktop): clear busy status when queued turns finish; add Cline API key onboarding path

* feat(desktop): allow cancelling a pending Cline browser sign-in during onboarding

* fix(desktop): address review findings on OAuth cancel, API key verification, and queued-turn status

* fix(desktop): cancel pending OAuth logins when the initiating transport connection closes

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-26 02:10:15 -07:00
Saoud Rizwan fe75eb6271 fix(vscode/core): agentic compaction silently fell back to basic, and manual /compact never reached the model (#12563)
* fix(vscode): resolve base URL and knownModels for compaction summarizer

The agentic compaction summarizer creates its LLM handler from the
session's ProviderConfig alone. For the OpenAI Compatible provider
stored under its SDK spelling (openai-compatible), resolveBaseUrl had
no mapping, so ProviderConfig was built without a baseUrl and the
summarizer silently hit the provider default endpoint (api.openai.com),
failed auth, and fell back to basic compaction - the UI still showed
'Context compacted' with no hint that agentic summarization never ran.

- resolveBaseUrl: accept the SDK spelling of the OpenAI Compatible
  provider, and fall back to the providers.json base URL (mirroring
  resolveApiKey) when legacy state has none.
- buildSessionConfig: expose knownModels at the top level of
  CoreSessionConfig, so manual compaction (sdk-compaction.ts) budgets
  against the real model context window instead of the 64k fallback.

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

* fix(core): project compaction sidecar even when auto-compaction is disabled

Manual /compact persists a compaction sidecar and promises the next turn
will use the compacted working context, but the runtime host only wired
the compaction-state-aware prepareTurn when compaction was enabled. With
Auto Compact off (the VS Code extension default), a manual /compact was
a silent no-op for the model: the sidecar was saved and the UI showed
'Context compacted', yet every subsequent request still sent the full
canonical transcript.

createCompactionStateAwarePrepareTurn already supports an undefined
compact fn (project existing state, never re-compact), so wire it
unconditionally; sessions without a sidecar are unaffected. Also keep a
resumed/initial sidecar instead of dropping it when compaction is
disabled.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-26 01:20:08 -07:00
Saoud Rizwan e4091c3686 Stop the task at the mistake limit like the CLI and remove the max-mistakes setting (#12561)
* fix(vscode): stop the task at the mistake limit like the CLI, drop the max-mistakes setting

When the SDK's consecutive-mistake limit is hit, the extension used to
block on an ask (Proceed Anyways / Start New Task) while the agent loop
kept running against the provider — reproduced 2,100+ consecutive API
requests behind the unanswered prompt.

Replicate the CLI's non-interactive resolver instead: show an error row
and resolve the decision as an immediate stop. The run aborts cleanly at
the turn boundary, the turn phase becomes awaiting_followup, and the
user continues whenever they want by sending a new message (which also
resets the SDK's mistake tracking on the next productive turn).

Also remove the extension's maxConsecutiveMistakes setting (state key,
settings RPC, webview state, proto fields now reserved). It was never
wired into the SDK session config — the SDK's own default governs — so
the setting was dead weight. Legacy mistake_limit_reached asks from
persisted conversations still render via the existing webview paths.

* fix(proto): reserve retired Settings field 139 (max_consecutive_mistakes)

The original removal added 'reserved 139' but the proto generator at the
branch base had no reserved-statement support and silently dropped it on
regeneration. Main (b4c640733) taught generate-state-proto.mjs to
preserve reserved statements, so after the merge the reservation now
survives. Also reserve the field name, mirroring the custom_prompt
removal pattern.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-26 00:53:41 -07:00
Saoud Rizwan bcf8c1d03e fix(vscode): relative read_files paths and diff-preview stalls no longer fail read/edit tools (#12558)
* fix(core): resolve relative read_files paths against the session cwd

The built-in FileReadExecutor resolved relative paths against process.cwd(),
which in a VS Code extension host is typically '/' rather than the workspace.
Every relative-path read failed with ENOENT, so models fell back to reading
files through the terminal. Resolve relative paths against the tool's
configured cwd in createReadFilesTool before invoking the executor.

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

* fix(vscode): never let a stalled diff preview open fail or delay an edit

Thrown errors from opening the edit diff preview were already swallowed, but
a hung vscode.diff call was unbounded: on auto-approve it burned the editor
tool's 30s execution timeout (failing the whole edit), and on manual approval
it delayed the approval ask indefinitely. Bound the preview open with a 5s
timeout; on timeout the edit proceeds without a preview and the late-opening
tab is closed once the open settles.

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

* style(core): order node:path import first for biome

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

* refactor(vscode): flatten the preview-open timeout into a plain race

Replace the custom timeout error class, the race helper with timer
bookkeeping, and the two-branch cleanup with a single Promise.race and one
settle-then-close line. Same behavior: a rejected or stalled preview open
never blocks the approval ask or fails the edit, and any late-appearing tab
is closed once the open settles.

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

* refactor(vscode): move the read_files cwd fix out of the SDK into the host

Revert the SDK change and instead override the read_files executor in the
extension, alongside the existing editor/apply_patch/askQuestion overrides.
The override resolves relative paths against the workspace root before
delegating to the SDK's built-in reader, since the extension host's
process.cwd() is usually '/' and every relative-path read failed with ENOENT,
pushing the model into terminal fallbacks. The SDK is left untouched.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-26 00:22:29 -07:00
Saoud Rizwan 5099eed5ee fix(desktop): consistent MCP server cards and single uninstall affordance (#12555)
* fix(desktop): consistent installed cards and single uninstall in marketplace views

* fix(desktop): surface marketplace setup guidance on matched installed cards

* fix(desktop): show setup guidance for all matched marketplace entries, not just first match

* fix(desktop): unambiguous entry-to-item matching and no stale installed card flash

* fix(desktop): drop orphaned installed keys optimistically instead of hiding cards during recheck

* fix(desktop): guard recheck races and avoid duplicate uninstall for ambiguous matches

* fix(desktop): keep uninstall action on ambiguous fallback marketplace cards

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-26 00:12:28 -07:00
Saoud Rizwan b4c640733b Remove dead "Use compact prompt" toggle from LM Studio provider settings (#12551)
* Remove dead 'Use compact prompt' toggle from LM Studio settings

The compact system prompt option was never wired up in the SDK-based
extension: the customPrompt value was stored in state and echoed back
to the webview, but nothing in the session factory or SDK ever read it
to alter the system prompt. Remove the checkbox (only shown for the
LM Studio provider) and all the dead state/proto plumbing behind it.

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

* Add changeset for compact prompt toggle removal

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

* Reserve removed custom_prompt field number/name in Settings proto

Teach generate-state-proto.mjs to preserve reserved statements in the
generated Secrets/Settings messages so removed fields keep their wire
identity reserved across regenerations, and reserve field 150 and the
custom_prompt name (plus the name in UpdateSettingsRequest).

Addresses Greptile review feedback on #12551.

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

* Never assign reserved proto field numbers to new Settings fields

If the highest-numbered field was removed and reserved, the generator
would hand that same number to the next new field, emitting both a
reserved statement and a live field at the same number. Parse reserved
numbers (including ranges) from the existing message, skip them when
assigning new numbers, and fail fast if an active field collides with
a reservation.

Addresses Bugbot review feedback on #12551.

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

* Format generate-state-proto.mjs

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-25 22:57:10 -07:00
Saoud Rizwan 40ba9d25eb fix(webview): use VS Code theme selection colors in dropdown menus (#12554)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-25 22:54:45 -07:00
Saoud Rizwan adb8ee0c4d fix(vscode): model ID under chat field stale / resets to gpt-4o for OpenAI Compatible (#12552)
* fix(vscode): fold SDK openai-compatible provider id to legacy openai spelling

The settings provider dropdown sources ids from the SDK catalog, so picking
OpenAI Compatible stored 'openai-compatible' into plan/actModeApiProvider.
Every provider-keyed code path (webview model label, planModeOpenAiModelId
slots, session factory) expects the legacy 'openai' spelling, so the model
id under the chat field went stale after Done and fell back to the catalog
default (gpt-4o).

- parseProviderId + toLegacyApiProvider now alias openai-compatible -> openai
- state-keys load transform migrates already-stored SDK spellings
- convertProtoToApiProvider normalizes provider ids written from the webview
- commitModelSelection writes the legacy spelling and posts state to the
  webview so model-only commits refresh the chat model label immediately
- session factory normalizes provider ids from state and providers.json

* fix(vscode): make toLegacyApiProvider alias lookup case-insensitive

parseProviderId lowercases before its alias lookup, but toLegacyApiProvider
(used directly by convertProtoToApiProvider and the state-keys load
transform) matched aliases case-sensitively, so a mixed-case
'OpenAI-Compatible' would not fold. Fall back to a lowercased lookup while
preserving original casing for unknown ids.

* fix(vscode): treat spelling-only provider differences as the same provider

Addresses the Bugbot finding on PR #12552: stale snapshots can still hold
the SDK spelling (openai-compatible) while new writes use the legacy
spelling (openai). Normalize both sides of the provider comparisons in
SdkProviderChangeCoordinator.providerForMode and
SdkController.isSelectionForActiveModeProvider so a spelling-only
difference neither restarts the active session nor skips the lightweight
in-session model update.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-25 22:53:58 -07:00
Saoud Rizwan 01617f9a05 chore: remove orphaned legacy auto-retry UI and dead onRetryAttempt plumbing (#12557)
The pre-SDK extension auto-retried failed API requests and surfaced
'Auto-retrying in X seconds' rows (say:'error_retry') plus a retryStatus
header on api_req_started. The SDK-based extension never emits either:
errors map straight to an api_req_failed ask with a manual Retry button,
and retrying is handled silently by the AI SDK / auth-refresh retry.

Remove the orphaned webview rendering (ChatRow error_retry case,
ErrorBlockTitle, combineErrorRetryMessages, isRequestInProgress chain,
stories), the unused say types and proto enum values (reserved), the
retryStatus field, and the never-invoked onRetryAttempt callback from
ApiHandlerOptions, sdk-api-handler, and @cline/llms provider config.

Legacy transcripts may still contain error_retry / api_req_retried rows;
readUiMessages now drops them so old tasks don't render raw JSON.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-25 22:52:11 -07:00
Saoud Rizwan b72d7f1a6f fix(vscode): keep webview alive when moved between primary and secondary sidebars (#12553)
* fix(vscode): keep webview alive when moved between sidebars

Moving the Cline view between the primary and secondary sidebars made the
view go blank with 'this.unsubscribeHostTelemetrySettings is not a function'.

Two fixes:
- The vscode host bridge streaming client returned the async IIFE's Promise
  instead of the cancel function its contract declares, so callers invoking
  the stored unsubscribe function threw a TypeError. It now returns a
  synchronous wrapper that resolves the real cancel function in the background.
- VscodeWebviewProvider disposed the whole Controller on WebviewView
  onDidDispose. VS Code destroys and re-resolves the view when it is moved
  between sidebars, so the re-resolved view was served by a dead controller
  (postStateToWebview no-ops after dispose) and rendered blank. onDidDispose
  now only releases view-scoped resources; the controller is disposed on
  extension deactivation via WebviewProvider.disposeAllInstances.

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

* fix(vscode): address review — don't clear active task on re-resolve, guard stale view dispose

- resolveWebviewView no longer calls clearTask on re-resolves (moving the
  view between sidebars must not terminate a running task); it only clears
  stale task state on the first resolve after activation.
- onDidDispose now only tears down view resources if the disposed view is
  still the active one, so a stale dispose event arriving after a newer view
  resolved cannot clobber the active view's listeners. resolveWebviewView
  also releases the previous view's resources up front.

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-25 22:47:40 -07:00
Saoud Rizwan d5b966d0a8 fix: center-align sign-in verification code box in chat (#12533)
The 'Enter this code in your browser' box shown after clicking
'Sign in to Cline' was left-aligned while the surrounding logged-out
message and button are centered. Center the label and the code/copy row.

Fixes #12531

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
2026-07-25 18:48:45 -07:00
Sufiyan Khan 1bb0833287 fix(cli): isolate hub daemon abort handling (#12500) 2026-07-24 18:44:51 -07:00
Saoud Rizwan 2c64c4ce5b fix: OpenAI Compatible model list fails when base URL has a trailing slash (#12532)
* fix: normalize trailing slash in OpenAI Compatible base URL for model list fetch

* fix: construct OpenAiModelsRequest via proto create in refreshOpenAiModels test

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-24 15:28:51 -07:00
Bee 0bdf2e3468 fix(desktop): restore window dragging (#12529) 2026-07-24 22:07:34 +02:00
Dominic Cooney 98024c4243 fix(cli): patch @opentui-ui/dialog for opentui 0.4.x remove() contract (#12516)
@opentui-ui/dialog@0.1.2 is built against @opentui/core ^0.1.69, whose
Renderable.remove(id) took a string id. Core 0.4.x renamed it to
remove(child) and throws when handed anything but a renderable, so the
dialog package's removeDialog()/provider teardown aborted before
detaching the panel: the React portal content unmounted but the
imperative grey box stayed on screen over the chat after every dialog
close (model picker, help, command palette, ...).

The upstream package is abandoned at 0.1.2, so pin the fix with a bun
patch that passes the renderable object on all three bindings (react,
solid, core container). A tui-test opens and dismisses the help dialog
and asserts the panel's #262626 background is fully gone, not just its
text.

Fixes #12506

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-07-24 15:18:15 +09:00
Saoud Rizwan 359e771ab3 Set up Cloud dev environment (CLI, VS Code extension, desktop app) (#12515)
* docs: add Cursor Cloud dev environment setup notes (AGENTS.md)

* docs: document VS Code extension + desktop app dev setup (AGENTS.md)

* docs: trim AGENTS.md cloud agent instructions

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-07-23 19:26:10 -07:00
Saoud Rizwan a21b21de84 chore(desktop): release v0.0.4 2026-07-23 18:50:17 -07:00
Bee ca6f6a6c23 feat(desktop): use the shared Cline Hub runtime (#12508)
* feat(desktop): use the shared Cline Hub runtime

* fix(hub): group code-sidecar-observer clients under Code App

The desktop observer client type was renamed from code-sidecar-approvals
to code-sidecar-observer, but the Code App grouping matchers in the hub
dashboard and menubar sidecar still only matched the old type. Since the
observer now registers on the shared Hub, it showed up as a separate
ungrouped client. Keep the old type matched for older desktop builds.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-23 18:21:39 -07:00
Bee 3e06abc366 feat(desktop): support chat without workspaces (#12412)
* feat(core): support pathless sessions with temporary workspaces

* fix(desktop): mark editor icons as decorative

* fix(core): omit absent auth request IDs

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-23 18:09:03 -07:00
Saoud Rizwan 6518b05b6c feat(desktop): first-run onboarding flow (#12495)
* feat(desktop): add replay new-user-experience setting

Adds an onboarding state module (localStorage-backed, with a reset event
the app shell can subscribe to) and a 'New user experience' row in
Settings -> General with a Replay button so beta testers and designers
can re-run the first-run flow. The flow itself ships in the stacked
follow-up PR.

* feat(desktop): first-run onboarding flow

Full-screen first-run experience shown until completed once: a welcome
step (3D glass logo over the aurora background), a connect step offering
Cline sign-in (recommended) or bring-your-own API key against the
provider catalog, and a done step that drops the user into a fresh
thread. Completion is tracked by the onboarding state module from the
previous PR; the Settings replay row now re-enters the flow in place via
the reset event, so its toast is gone. Skipping is always available so
nobody gets trapped; the connected provider (and its default model when
known) is remembered so the chat composer opens pointed at it.

* fix(desktop): address greptile review on onboarding flow

- Filter the bring-your-own-key picker to providers a lone API key can
  fully configure: providers with structured config fields (Vertex gcp.*,
  Bedrock aws.*) or no API-key field at all (Claude Code) no longer appear,
  since connecting them here would report success without working.
- Record Cline as the active provider when a signed-in user hits Continue,
  so replaying onboarding doesn't leave the chat pointed at a previously
  selected provider.

* feat(desktop): accent color themes and switchable app icon (#12496)

* feat(desktop): accent color themes and switchable app icon

Settings -> General grows an appearance cluster next to Dark mode:

- Accent color: six palettes from the Figma exploration (violet default,
  graphite, cyan, pink, espresso, ember). Non-default accents re-anchor
  --primary/--primary-foreground/--primary-emphasis/--ring per light and
  dark mode via html[data-cline-accent] overrides in globals.css, tuned in
  OKLCH to mirror the brand token relationships; chart and sidebar tokens
  alias var(--primary) so they follow. Persisted in localStorage and
  applied at boot alongside the dark-mode sync.

- App icon: the four Figma variants (Classic, Sunrise, Steel, Midnight).
  The webview persists the choice, swaps the favicon in browser mode, and
  in the Tauri shell calls the new set_app_icon native command, which
  loads the matching bundled resource (icons/dock/*.png) and applies it
  via NSApplication.applicationIconImage on the main thread. macOS resets
  the dock icon every launch, so the shell re-applies the stored choice at
  boot; classic is also loaded from a resource because the objc2 binding
  warns against passing nil to restore the bundled icon. Other platforms
  no-op (Ok(false)).

* fix(desktop): don't let a stale app-icon failure roll back a newer selection
2026-07-23 16:59:50 -07:00
Bee f9f492319e feat(desktop): redesign channel setup as expandable cards (#12490)
* feat(desktop): redesign channel setup as expandable cards

Replace the add-channel dialog with inline expandable configuration forms, including per-channel validation and error handling.

Add comprehensive tests for channel configuration, conditional fields, security options, and connection workflows.

* Connect

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-23 16:54:29 -07:00
Saoud Rizwan c3ab557194 polish(desktop): cleaner chat markdown + external links that actually open (#12497)
* polish(desktop): cleaner chat markdown + external links that actually open

Links in chat markdown never opened in the packaged app: the confirm
dialog's window.open(_blank) is silently dropped by the Tauri shell.
Route opens through openExternalUrl (open_external_url sidecar command)
and only keep the confirmation dialog for deceptive links whose visible
text reads as a URL on a different host than the real destination —
ordinary external links now open directly in the default browser.

Visual pass on Streamdown output for the chat pane: collapse the
double-boxed code block card and drop the language header row, reveal
the copy button on hover only, turn off line numbers, single-box tables,
chat-scale the heading ramp (h1 was text-3xl next to 14px body), outside
list markers, and tighter block rhythm.

* fix(desktop): harden deceptive-link detection per review

Recurse into element children when extracting link label text so inline
formatting (e.g. a bolded hostname) can't dodge the deception check, and
compare port and (when the label states one) scheme in addition to
hostname so same-host links to an unexpected scheme or port still get
the confirmation dialog. An unparseable destination behind URL-shaped
label text is now treated as deceptive rather than waved through.

* fix(desktop): treat trailing-dot FQDN labels like their plain hostname

Browsers resolve 'github.com.' identically to 'github.com', but the
URL-shaped-label pattern rejected the trailing dot, so a deceptive label
like [github.com.](https://evil.example) skipped the deception check and
opened directly. Accept one trailing dot in the pattern and strip
trailing dots during hostname normalization on both sides, so the FQDN
form is deceptive exactly when the plain form is.

* fix(desktop): treat protocol-relative labels like their https form

A label spelled '//github.com' reads as a URL but failed the URL-shaped
pattern (which only tolerated an https?:// prefix), so it skipped the
deception check and opened an unrelated destination directly. Accept a
protocol-relative prefix in the pattern, and parse '//'-prefixed values
as https-relative in parseLinkParts — prepending 'https://' to them
produced an empty hostname and made the comparison a no-op.
2026-07-23 16:48:50 -07:00
Bee 9fa37a9128 feat(desktop): display image attachments in chat (#12502)
* feat(desktop): drag and drop files to attach them to the chat

The Tauri webview swallows OS file drags by default (dragDropEnabled),
so HTML5 drop events never fire. Disable it on the main window per the
Tauri v2 docs, then handle standard dragenter/dragover/dragleave/drop on
the chat pane: dropped files feed the same dedupe-and-append pipeline as
the paperclip picker, with a depth-counted 'Drop to attach' overlay while
files are dragged over. Image drops become data-URL images via the
existing serializeAttachments path.

* feat(desktop): display image attachments in chat

* 225x225

* fix(desktop): preserve queued attachments

* fix(desktop): distinguish queued image turns

* fix pending

* fixed

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-23 16:46:36 -07:00
Bee 9cc7796bbe feat(desktop): add custom overlay title bar navigation (#12504)
* feat(desktop): add custom overlay title bar navigation

Configure Tauri to use a hidden overlay title bar and host back/forward navigation in the draggable sidebar header. Preserve agent title width during editing to prevent layout shifts, with tests covering both behaviors.

* fix(desktop): reconcile deleted navigation entries

* fix(desktop): dedupe session deletion events

* fix(desktop): serialize session deletion state

* fix(desktop): use exported DesktopAppView type in page.tsx

AppView is a non-exported type local to agent-sidebar.tsx, so referencing
it in page.tsx was a TS2304 error hidden by the typecheck script's webview
exclusion and next's ignoreBuildErrors.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-23 16:46:07 -07:00
Saoud Rizwan 439baee17c feat(desktop): add replay new-user-experience setting (#12494)
Adds an onboarding state module (localStorage-backed, with a reset event
the app shell can subscribe to) and a 'New user experience' row in
Settings -> General with a Replay button so beta testers and designers
can re-run the first-run flow. The flow itself ships in the stacked
follow-up PR.
2026-07-23 16:43:33 -07:00
Bee 469debdb30 fix(schedules): default headless routines to yolo (#12489)
* fix(schedules): default headless routines to yolo

Centralize the Cline default model ID in @cline/shared while preserving the @cline/llms export. Keep explicit modes stable and disable ask_question for unattended scheduled runs.

* autoapprove

* fix unit test

* fix(schedules): harden headless routine execution

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-23 14:53:03 -07:00
John Choi 79589c8417 test(cli): relax cold history dispatch timeout (#12511) 2026-07-23 14:36:33 -07:00
Saoud Rizwan 8e5471de9c feat(vscode): show compaction progress and results in the webview (#12487)
* feat(vscode): show compaction progress and results in the webview

Port the CLI's compaction UX to the VS Code extension:

- Translate the SDK's compaction status notices into a say:'compaction'
  divider row with a spinner while running, updated in place (same ts) to
  'Context compacted · x → y tokens · n → m messages' when done, matching
  the CLI's divider. Dangling dividers finalize as failed/cancelled when
  the turn errors or ends mid-compaction.
- Manual /compact (button or slash command) drives the same divider from
  the compaction coordinator instead of plain info lines, capturing token
  counters from the SDK's status notices.
- Drop raw status-notice slugs ('auto-compacting') that previously
  rendered as info rows.
- Context window bar now reads the compacted size (tokensAfter) from a
  compaction row newer than the last API request, so it drops immediately
  after compaction instead of waiting for the next turn.
- Fix the Auto Compact Strategy selector showing 'basic' when unset; the
  effective default is agentic (core defaults strategy ?? 'agentic').

* fix(vscode): apply compaction shrink as a ratio to the context meter

Address review feedback from #12487:

- getLastApiReqTotalTokens: instead of substituting the compaction
  notice's tokensAfter (an SDK estimate on a different scale than
  provider-reported usage, which made the bar re-snap when the next
  request's real usage landed), scale the last provider-reported request
  total by the compaction's tokensAfter/tokensBefore ratio. Both
  counters come from the same estimator, so the ratio is scale-free.
  Multiple compactions since the last request compound. A completed
  divider without token counters leaves the total unscaled.
- Suppress only the known-internal status notices explicitly
  (compaction-budget-adjusted); an unrecognized status notice now falls
  through to an info row so future notices surface instead of silently
  vanishing.
- Cross-reference the two compaction-divider finalization paths (auto:
  translator finalizeDanglingCompaction; manual: coordinator catch) so
  terminal-state rule changes touch both.
- Post state to the webview before re-throwing in the coordinator's
  failure path, consistent with the other terminal branches.
2026-07-23 10:07:57 -07:00
Saoud Rizwan cd553d2343 feat(desktop): drag and drop files to attach them to the chat (#12498)
The Tauri webview swallows OS file drags by default (dragDropEnabled),
so HTML5 drop events never fire. Disable it on the main window per the
Tauri v2 docs, then handle standard dragenter/dragover/dragleave/drop on
the chat pane: dropped files feed the same dedupe-and-append pipeline as
the paperclip picker, with a depth-counted 'Drop to attach' overlay while
files are dragged over. Image drops become data-URL images via the
existing serializeAttachments path.
2026-07-23 10:06:16 -07:00
Mikołaj Kondratek 7b776225b9 fix(telemetry): report host identity on SDK-pipeline events (#12503)
* fix(telemetry): report host identity on SDK-pipeline events

On JetBrains standalone cline-core, SDK-pipeline events (task lifecycle,
token usage, tool usage, provider failures) reported the hardcoded
cline_type "VSCode Extension", platform "VS Code", and
platform_version "unknown", unlike the classic TelemetryService which
resolves these from HostProvider.env.getHostVersion().

Extend the host_plugin_version resolution in VscodeTelemetryPolicyService
to apply the full host identity (cline_type, platform, platform_version)
with the same mapping the classic pipeline uses, before the telemetry
gate opens. Fields the host does not report keep the construction-time
fallbacks.

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

* fix(telemetry): fall back to unknown host identity, not VSCode labels

A failed getHostVersion lookup previously left the hardcoded VSCode
identity in place, hiding the failure as a plausible-looking row.
"unknown" makes the failure visible and matches the classic
TelemetryService's || "unknown" semantics.

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

* fix(telemetry): defer provider_created until host identity is applied

telemetry.provider_created was captured synchronously inside the core
factory, before VscodeTelemetryPolicyService resolves getHostVersion —
so that one event always carried the construction-time fallback identity
(pre-existing: it reported the hardcoded VSCode identity on JetBrains
and never had host_plugin_version).

Add an opt-in deferProviderCreatedEvent to the core telemetry factories
that skips the construction-time capture and exposes it as
ConfiguredTelemetryHandle.emitProviderCreated; the policy service emits
it right after applying the resolved host metadata. Other handle
consumers (CLI, hub daemon, examples) keep immediate emission.

Also close the subscription race on the same guarantee: a host setting
flip arriving while getHostVersion is still resolving now waits for the
metadata to be applied before opening the gate, and a slow initial
settings fetch no longer overwrites a newer subscription update.

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

* fix(telemetry): emit deferred provider_created on early dispose

If the policy service is disposed while the host-version lookup is
still pending, the deferred provider_created would never be captured —
the undeferred event was always emitted (with construction-time
identity) and exported by the shutdown flush. Emit-once semantics:
dispose fires the event with the fallback identity before shutting the
handle down, and the late metadata continuation cannot double-emit.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:25:53 +09:00
Bee ae033761ab feat: auto generate built-in provider list (#12204)
* feat: auto generate built-in provider list

- Generate `providers.generated.ts` and `provider-ids.generated.ts` from `models.dev/api.json` alongside the model catalog
- Merge generated provider specs with handwritten built-in overrides for Cline, Codex, local/OAuth providers, routing metadata, and product defaults
- Include additional `models.dev` providers only when they are OpenAI-compatible for now
- Keep lightweight provider ID utilities from importing the full generated provider spec catalog

* Removed redundant handwritten definitions for providers that are fully described by generated metadata

* update unit test
2026-07-23 14:27:59 +02:00
Mikołaj Kondratek c961ae7730 feat(telemetry): emit host_plugin_version metadata on all events (#12478)
* feat(telemetry): emit host_plugin_version metadata on all events

The host already reports its Cline distribution version over the
hostbridge (getHostVersion.clineVersion — the JetBrains plugin version
on JetBrains, the extension version on VSCode), but telemetry never
attached it: extension_version is always the cline-core bundle version,
so JetBrains events could not be tied to a plugin release.

Attach it as a new optional host_plugin_version metadata field, omitted
when the host does not report one.

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

* test(telemetry): loop over host version cases instead of interleaving stubs

Review feedback: the two host_plugin_version cases were interleaved via
onFirstCall/onSecondCall stubs across two service instances. Run one
mock-assert-reset cycle per case so the only differences between them —
the host version response and the expected reported value — are visible
in the case table.

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

* test(telemetry): guarantee stub cleanup in host_plugin_version test

Review feedback: the loop installed process-global stubs and only
restored them on the happy path — a rejected create() or failed
assertion would leak exhausted stubs into subsequent tests and leave
the service undisposed. Use a sinon sandbox restored in finally, and
dispose the service there too.

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

* feat(telemetry): carry host_plugin_version on SDK-pipeline events too

Review feedback: main's production controller emits task lifecycle,
token usage, tool usage, and provider-failure events through a separate
SDK telemetry service whose metadata is built independently, so those
events still omitted the plugin version.

Add the optional host_plugin_version field to the shared SDK
TelemetryMetadata contract and resolve it from the authoritative
getHostVersion response during the policy service's init. The metadata
update is sequenced before the host telemetry setting is applied, and
events stay gated until that setting lands, so no event can be emitted
without the field in place. A failed host-version lookup degrades to
the previous behavior (field absent, telemetry still enabled).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:42:37 +09:00
Dominic Cooney e940b6a335 fix(vscode): preserve edit preview focus (#12491)
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-07-23 16:23:18 +09:00
David Knaack 5a0780a6f9 fix(llms): set metering header and use fetch adapter for sap ai core (#12337) 2026-07-23 00:05:27 -07:00
Bee 847276f4a5 feat(desktop): add one-time routines and run navigation (#12477)
* feat(schedules): add one-time routines and run navigation

* fix schedule review concerns

* fix repository lint errors

* fix optional auth request ID telemetry

* fix one-time schedule lifecycle

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-07-22 16:56:49 -07:00
Dominic Cooney 8b4d1973b5 fix(vscode): reclaim unobserved fallback terminals (#12352)
Classify unobservable terminal outcomes so cleanup and reporting share one source of truth. Reclaim managed sendText fallbacks at the disclosed next-acquisition boundary while preserving markerless, continued, detached, and uncertain-error terminals. Make cleanup, CWD reservations, process listeners, and detached logs failure-safe.
2026-07-22 16:22:11 -07:00
Dominic Cooney a5211a3a5c fix(vscode): make 'Proceed While Running' sticky to the command batch (#12359)
Register every foreground command before terminal acquisition so a parallel batch observes one Proceed While Running decision. If a command is still acquiring a terminal, settle its tool result immediately and transfer the approved command to an owned detached lifecycle that logs acquisition, output, completion, and failure. Abort before startup unregisters the handle and prevents the command from starting later.
2026-07-23 06:39:38 +09:00
Saoud Rizwan fb1333fdc2 fix(desktop): route external link opens through sidecar so they work in Tauri (#12481)
* fix(desktop): route external link opens through sidecar so they work in Tauri

The markdown 'Open external link?' dialog confirmed via window.open, which
the Tauri webview silently drops (no window opener configured), so clicking
'Open link' did nothing (ENG-2302). Route confirmation through
openExternalUrl, which invokes the open_external_url sidecar command inside
the Tauri shell and falls back to window.open in plain web mode.

Also fixes the marketplace 'Get value' env-var link, which relied on the
same dead target=_blank behavior.

* fix(desktop): open mailto/tel links and middle-clicked marketplace links

Greptile review fixes:
- open_external_url now allows mailto: and tel: alongside http(s) — the
  platform openers already dispatch any scheme to the OS protocol handler,
  the gate is just the allowlist. Streamdown's harden step blocks every
  other scheme before it reaches SafeMarkdownLink (test added to guard
  that assumption, since the sidecar allowlist relies on it).
- Protocol-relative URLs pass streamdown but fail the sidecar's new URL()
  parse; pin them to https before handing off.
- The marketplace 'Get value' link now intercepts middle clicks (auxclick)
  too, which bypassed the onClick handler and fell into the dead
  target=_blank path.
2026-07-22 14:27:01 -07:00
Renee Huang 59113c309c docs: update ClinePass wording to '2-5x the usage on popular open coding models compared to standard API rate' (#12479)
* docs: update ClinePass wording from '2-5x API rate limits' to '2-5x the usage on popular open coding models compared to standard API rate'

* docs: update ClinePass wording in cline-provider.mdx for consistency

* nit
2026-07-22 11:44:55 -07:00
TheRealSpencer 48d0c38f52 fix(security): bump axios to 1.18.0 to resolve multiple CVEs in axios (#12473)
* fix(security): bump axios to 1.18.0 to resolve multiple CVEs in axios

* fix(security): bump axios to 1.18.0 in docs project
2026-07-22 12:38:45 -05:00
aikido-autofix[bot] c7ab9ff839 fix(security): update js-yaml from 4.1.1 to 4.3.0 (#12456)
Co-authored-by: aikido-autofix[bot] <119856028+aikido-autofix[bot]@users.noreply.github.com>
2026-07-22 09:49:42 -05:00
Tomás Barreiro 045518d19f Add Request-ID to auth events (#12444)
* Add the X-Request-ID to the ClineOAuthTokenError

* Add request id to events

* address comments
2026-07-22 13:20:30 +09:00
Saoud Rizwan 099c6179e4 fix(desktop): resolve login shell PATH so agent can find gh and other CLI tools (#12429)
* fix(desktop): resolve login shell PATH so agent can find gh and other CLI tools

When the Tauri app is launched from Finder/the Dock on macOS it inherits
launchd's minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin), so the sidecar and
every process it spawns for the agent (bash tool, MCP servers) can't find
tools installed via shell profiles, e.g. Homebrew's gh in /opt/homebrew/bin.
The same task works from the CLI because a terminal runs with the full
login-shell PATH.

At sidecar startup, ask the user's login+interactive shell for its PATH
(sentinel markers isolate it from profile noise, 5s timeout, kill on hang)
and merge it into process.env.PATH: shell entries first, current-only
entries preserved. No-op on Windows; CLINE_SIDECAR_SKIP_SHELL_PATH=1 is the
escape hatch. Failures never block startup.

Fixes CLINE-2740

* fix(desktop): address greptile review on shell PATH resolution

- Don't let shell resolution eat the Tauri endpoint-readiness window: kick
  it off first so it overlaps sidecar startup (awaited before the session
  manager exists, which is what spawns children), drop the shell timeout
  5s -> 2s, and give the fallback attempt half the budget so the combined
  worst case (3s) stays inside the 5s readiness poll.
- Handle non-POSIX login shells: run the marker printf inside /bin/sh so
  $PATH expansion never depends on the outer shell's rules (fish would
  space-join it), pass -i/-l/-c as separate flags, give csh/tcsh only -c
  (their -l is valid only as the sole flag), and retry with the platform
  default shell when $SHELL can't produce a PATH.
- Don't log the resolved PATH: the applied result now carries an entry
  count instead of the merged PATH string.

* fix(desktop): read login shell from the account database, document PATH resolution

$SHELL is set by a parent shell, so a GUI-launched process may not have it.
Use os.userInfo().shell (getpwuid — DirectoryServices on macOS, same source
as dscl UserShell; NSS/etc/passwd on Linux) as the authoritative source,
with $SHELL and the platform default as fallbacks. Also documents the whole
mechanism in the app README.

* fix(desktop): widen endpoint readiness poll, source csh login profile

- The 5s get_desktop_backend_endpoint poll was already tight for
  session-manager init on slow machines; shell PATH resolution (bounded 3s
  worst case) made it tighter. Poll 15s instead — it returns as soon as the
  ready line arrives, so only genuine failure waits longer.
- csh/tcsh can't take -l alongside -c, so mark them as login shells via the
  argv[0] dash convention (argv0: "-tcsh") to get ~/.login sourced on top
  of the always-read rc file.

* fix(desktop): never spawn a second sidecar while one is alive

ensure_desktop_backend_started treated a live child with a pending
endpoint as absent and fell through to spawn a duplicate, orphaning the
first process. Hold the process lock across the whole check-and-spawn
(concurrent callers serialize), return early for any live child, fail
the endpoint poll fast when the child exits instead of respawning, and
stop a stale stdout-reader from wiping a successor's endpoint. The spawn
is injectable so regression tests cover repeated and concurrent startup
checks (exactly one spawn while pending) and dead-child replacement.

* style(desktop): tighten mergePaths and csh comment per review

* docs(desktop): codify backend state lock ordering
2026-07-21 18:11:55 -07:00
Bee 26037b17ac feat(core): default to agentic compaction (#12317)
* feat(core): default to agentic compaction

Use agentic compaction when no valid strategy is configured while preserving explicit basic selection. Add a session compaction CLI and package script for testing and comparing compaction strategies.

* createHandlerMock

* fix(core): let the agentic compaction cut land on assistant boundaries

Agentic auto-compaction only accepted typed user messages (turn starts)
as cut boundaries. The canonical host transcript — one typed task
followed by a long assistant tool_use / user tool_result loop — has no
turn start past index 0, so findCutIndex snapped to 0 and
runAgenticCompaction returned undefined: the UI showed "auto-compacting"
then "auto-compaction-skipped" on every turn while the context kept
growing. Re-compaction had the same failure permanently, because the
projected transcript starts with a compaction summary message, which is
excluded from turn starts.

Assistant messages are equally safe boundaries: an assistant's tool_use
keeps its result in the user message that follows it, so a cut there
never orphans half of a tool pair. Typed-user protection is preserved —
when a typed turn exists past index 0 the cut still stays at or before
it, so the latest typed prompt is never folded into the summary.

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

* add compaction fixtures for testing

* basic compaction improvement

* feat: attach metadata to the merged compaction message

* fix(core): address review comments on compact-session script

- add cline provider to the API key env defaults (CLINE_API_KEY)
- accept legacy string-content messages in readMessages
- print usage instead of a stack trace when --provider/--model are missing

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

* fix(core): preserve basic compaction across restores

## Summary

- keep tool-result message IDs stable across restore/persist round-trips
- preserve concluding assistant responses as real messages during basic compaction
- freeze prior compaction output so later passes only fold newly added history
- accumulate removed-message and usage metadata across repeated compactions
- update the basic compaction fixture and regression coverage

## Problem

Tool-result IDs were re-suffixed every time persisted messages were converted
back into agent messages. Because compaction state hashes the source message
prefix, restoring a session changed that hash and invalidated an otherwise
successful compaction, causing the full transcript to be sent again.

Basic compaction also reprocessed its own output on subsequent passes. This
could stack duplicate system notices, discard assistant conclusions retained by
the previous pass, and replace cumulative compaction statistics with values
from only the latest pass.

## Solution

Only add tool-result suffixes when splitting a mixed message, leaving already
split and single-result message IDs unchanged. Mark non-user compaction
survivors as preserved, carry those messages through future passes verbatim,
and budget older turns' final assistant answers as first-class messages.
Compaction metadata now adds prior removed-message and usage totals to the work
performed by the current pass.

## Validation

- 66 focused codec and compaction tests pass
- @cline/core typecheck and smoke typecheck pass
- Biome checks pass for all changed TypeScript files
- git diff --check passes

* fix unit test

* fix compaction defaults and fallback

* fix basic compaction credential lookup

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 17:41:58 -07:00
Saoud Rizwan 1adce5e56d chore(desktop): release v0.0.3 2026-07-21 17:31:29 -07:00
Saoud Rizwan 73583ea178 polish(ui): reasoning trigger reads just 'Thinking' — drop status text and brain icon (#12460)
The collapsed reasoning block header showed 'Thought process · Complete'
with a brain icon; PM feedback is that the status text and icon read as
noise. The trigger is now just the label + disclosure chevron, with
'Thinking' as the default label in both streaming and complete states.
Removes the now-unused cline-chat-reasoning-status style and BrainIcon.
2026-07-21 17:28:25 -07:00
473 changed files with 126575 additions and 33255 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Remove the non-functional "Use compact prompt" toggle from LM Studio provider settings
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: strip trailing slashes from the OpenAI Compatible base URL when fetching the model list, so `/models` is queried correctly and the model dropdown populates
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: center-align the sign-in verification code box shown after clicking "Sign in to Cline"
@@ -0,0 +1,60 @@
# Some coding-agent GitHub Apps advertise themselves by auto-commenting on every
# new PR ("<Tool> Agent can help with this pull request. Just @<tool> ..."). The
# app needs pull_requests:write for its real job (pushing branches, opening PRs),
# and GitHub offers no per-behavior control over an installed App, so the ad
# cannot be disabled at the source. This deletes those promo comments as they
# appear. Genuine agent output comments (work results, reviews) don't match the
# promo pattern and are left alone.
#
# No checkout, API-calls-only — comment text is only ever handled as data inside
# the script, never interpolated into the workflow definition.
name: repo-delete-agent-promo-comments
on:
issue_comment:
types: [created]
jobs:
delete:
runs-on: ubuntu-latest
timeout-minutes: 2
# Prefilter so a runner only spins up for bot comments that look like the
# ad; the script re-verifies before deleting.
if: >-
github.event.issue.pull_request &&
endsWith(github.event.comment.user.login, '[bot]') &&
contains(github.event.comment.body, 'can help with this pull request')
# Comment deletion goes through the issues API, but GitHub gates the
# endpoint by where the comment lives: issue comments need `issues`,
# PR-conversation comments need `pull-requests`. The prefilter restricts
# this job to PR comments, so pull-requests is the one that matters;
# issues is kept in case the prefilter is ever widened.
permissions:
issues: write
pull-requests: write
steps:
# Pinned to a commit SHA (not the mutable v7 tag) because this job holds
# write permissions and fires on attacker-postable events.
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const comment = context.payload.comment
// Belt and suspenders on top of the job-level prefilter: only
// delete when the author is a real GitHub App bot AND the body
// matches the self-promotion shape ("... can help with this
// pull request. Just @<handle> ..."). A human quoting the ad
// text is not a Bot; a bot posting real work output doesn't
// match the promo shape.
const isBot = comment.user.type === "Bot"
const isPromo = /\bcan help with this pull request\b[\s\S]*@\w/i.test(comment.body || "")
if (!isBot || !isPromo) {
core.info("not an agent promo comment, leaving it alone")
return
}
await github.rest.issues.deleteComment({
...context.repo,
comment_id: comment.id,
})
core.info(`deleted promo comment ${comment.id} by ${comment.user.login} on #${context.payload.issue.number}`)
@@ -0,0 +1,65 @@
# Cloud coding agents append promotional badge blocks to PR bodies after the
# agent's final turn, wrapped around <!-- <VENDOR>_AGENT_PR_BODY_BEGIN/END -->
# marker comments. The agent itself never sees that content, so no repo rule or
# agent instruction can prevent it. This strips it from the PR description on
# open/edit, keeping only the agent-authored content between the markers.
#
# Uses pull_request_target so the token has write access on PRs from forks. That
# trigger is only unsafe when a job checks out and executes PR code — this one
# never checks out the repository, it only calls the REST API.
name: repo-strip-agent-badges
on:
pull_request_target:
types: [opened, edited]
concurrency:
group: strip-agent-badges-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
strip:
runs-on: ubuntu-latest
if: contains(github.event.pull_request.body, '_AGENT_PR_BODY')
permissions:
pull-requests: write
steps:
# Pinned to a commit SHA (not the mutable v7 tag) because this job holds
# write permissions under pull_request_target.
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
// Re-fetch instead of trusting the event payload: the body may have
// been edited again between the event firing and this run (agent
// harnesses edit PR bodies post-open), and updating from the stale
// snapshot would clobber the newer content.
const { data: pr } = await github.rest.pulls.get({
...context.repo,
pull_number: context.payload.pull_request.number,
})
const body = pr.body || ""
// The BEGIN/END comments wrap the agent-authored content; everything
// outside them (vendor promo badges, "open in <tool>" links) is
// appended by the harness. Keep only what's between the markers.
// The backreference requires BEGIN and END to name the same vendor.
// No markers -> no match -> body passes through unchanged.
const cleaned = body
.replace(
/^[\s\S]*?<!--\s*([A-Z][A-Z0-9_]*?)_AGENT_PR_BODY_BEGIN\s*-->\r?\n?([\s\S]*?)<!--\s*\1_AGENT_PR_BODY_END\s*-->[\s\S]*$/,
"$2",
)
.trimEnd()
// No change means a previous run already cleaned this body. Returning
// without an update is what stops `edited` from retriggering forever.
if (cleaned === body) {
core.info("nothing to strip")
return
}
await github.rest.pulls.update({
...context.repo,
pull_number: pr.number,
body: cleaned,
})
core.info(`stripped ${body.length - cleaned.length} chars from PR #${pr.number}`)
+4 -2
View File
@@ -51,7 +51,8 @@
"env": {
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
"CLINE_ENVIRONMENT": "staging"
"CLINE_ENVIRONMENT": "staging",
"CLINE_DIR": "${userHome}/.cline_staging"
}
},
{
@@ -75,7 +76,8 @@
"env": {
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
"CLINE_ENVIRONMENT": "local"
"CLINE_ENVIRONMENT": "local",
"CLINE_DIR": "${userHome}/.cline_local"
}
},
{
+32
View File
@@ -0,0 +1,32 @@
This is the **Cline** monorepo. Toolchain is **Bun 1.3.13** (package manager + task runner) with **Node >=22** as the runtime. Do not use npm/yarn/pnpm.
## Cloud Agent Instructions
### Cline CLI
- Run from source: `bun run cli` (interactive: `bun run cli -i`; one-shot: append a prompt). This resolves to `apps/cli` and **auto-spawns the `@cline/cline-hub` daemon** — you do not start the hub separately.
- Inspect local health with `bun run cli doctor`; `bun run cli version` prints the version.
- An actual agent turn requires an **LLM provider credential**. With no credentials the default `cline` provider fails fast with an `Unauthorized` error and the interactive TUI shows a provider sign-in screen. Configure via `cline auth` or provider env vars (e.g. `ANTHROPIC_API_KEY`, `CLINE_API_KEY`, `OPENROUTER_API_KEY`); see `apps/cli/README.md`.
### Build / Lint / test
- SDK packages (`@cline/shared|llms|agents|core|sdk`) resolve each other through compiled `dist/` (their `exports` point only at `dist/`, with no `development` source condition). You **must** run `bun run build:sdk` after changing SDK dependencies/source before running the CLI or SDK tests, otherwise imports fail with missing `@cline/*` / missing `dist/` errors. Running processes do **not** hot-reload SDK source changes — rebuild and restart.\
- Known cloud-env test artifact: `@cline/core` test `src/services/workspace/workspace-manifest.test.ts > readGitWorkspaceState > prefers origin and returns the current branch` fails because cloud VMs configure git `insteadOf` rules that rewrite GitHub remotes to `https://x-access-token:...@github.com/...`. This is an environment artifact, not a code bug.
- Some `@cline/cli` e2e assertions (`bun -F @cline/cli test:e2e`) may fail on exact tool-listing string formats; treat as pre-existing test drift, not an environment problem.
### GUI display
- A virtual X display is live at **`DISPLAY=:1`** (the same desktop used for screenshots). GUI apps (VS Code, the Tauri desktop window) launched with `DISPLAY=:1` render there and can be screenshotted — no need to start your own `xvfb`. Prefer starting long-running GUI/dev processes in a `tmux` session (see the tmux guidance) so they survive.
### VS Code extension (`apps/vscode`, package `claude-dev`)
Toolchain is pre-installed and persisted in the VM: generated gRPC/proto code, the bundled `ripgrep` binaries (`apps/vscode/bin/`), the built webview (`webview-ui/build`), the esbuild bundle (`dist/extension.js`), VS Code itself (`/usr/bin/code`), and the GUI system libraries its tests need.
- **Codegen prerequisite:** `bun run protos` (from `apps/vscode`) regenerates `src/generated/*` and the webview grpc client. The `dev`, `build:webview`, and `check-types` scripts already run it, so proto changes are picked up by those commands; run it manually only if you edit `.proto` files without a full build.
- **Build:** `bun run build:webview` (webview UI, ~15s) then `bun esbuild.mjs` (extension bundle). `bun run package` does the full production build.
- **Run it (dev host):** `DISPLAY=:1 code --no-sandbox --user-data-dir=/tmp/vscode-userdata --extensionDevelopmentPath=/workspace/apps/vscode <some-folder>`, then click the Cline icon in the Activity Bar to open the webview. (`--no-sandbox` is required in this container.)
- **Test:** `bun run test:unit` (bun-based, ~984 tests, no VS Code host needed). `bun run test:integration` (`@vscode/test-electron`, downloads a VS Code build, runs under the GUI libs) and `bun run test:e2e` (Playwright) exercise a real extension host — heavier, and the GUI libs for them are already installed.
- One-time deps (already installed, listed here in case they must be recreated): ripgrep via `bun run download-ripgrep`; VS Code test GUI libs per `CONTRIBUTING.md` (`libnss3`, `libatk*`, `libgbm1`, `xvfb`, etc.).
### Desktop app (`apps/examples/desktop-app`, package `@cline/code`)
A Tauri v2 (Rust) shell + Next.js webview + a Bun "sidecar" backend. Rust and the Tauri Linux system libs are pre-installed and persisted.
- **Headless (no Rust/window):** run the backend and UI separately — `bun run dev:sidecar` (Bun backend on `127.0.0.1:3126`, serves `ws://.../transport`) and `bun run dev:web` (Next.js UI on `http://localhost:3125`).
- **Native window:** `bun run dev` (`tauri dev`) — its `beforeDevCommand` builds the sidecar binary and starts `dev:web` (`:3125`), then Rust `main.rs` spawns the sidecar; so free ports `3125`/`3126` first. Launch with `DISPLAY=:1` to see the window. A `libEGL: DRI3 error` warning is benign (software rendering) — the WebKitGTK window still renders.
- **Rust version caveat:** the crate graph needs Cargo's `edition2024` feature, so **Rust ≥1.85** is required (the VM's base 1.83 fails with "feature `edition2024` is required"). The toolchain here was updated via `rustup default stable` (currently 1.97). First `cargo` build downloads/compiles the full Tauri crate graph (a few minutes); subsequent builds are cached.
- **System libs (already installed):** `libwebkit2gtk-4.1-dev`, `libgtk-3-dev`, `libayatana-appindicator3-dev`, `librsvg2-dev`, `libxdo-dev`, `libssl-dev`, `build-essential`.
- **Test/typecheck:** `bun run typecheck`, `bun run test:chat-ui` (Vitest). Both trigger `build:ui` first.
+1 -1
View File
@@ -226,7 +226,7 @@ Run Cline with zero interaction for scripting and automation. Pipe input, get JS
```bash
cline "Run tests and fix any failures"
git diff origin/main | cline "Review these changes for issues"
git diff origin/main | cline "Review these changes for issues"
cline --json "List all TODO comments" | jq -r 'select(.type == "agent_event" and .event.text) | .event.text'
```
+14
View File
@@ -1,5 +1,19 @@
# Cline CLI Changelog
## 3.0.47
- Free Cline models are now supported end to end: free models show as "(free)", and hitting the free limit renders a dedicated card with the reset time (from SDK v0.0.66)
- `/settings` general toggles (plan/act mode, tool auto-approve, compaction mode) now persist across restarts
- Upgraded the TUI stack from opentui 0.1.102 to 0.4.3
- Fixed a grey panel left behind on screen after closing a dialog (model picker, help, command palette) — a leftover from the opentui upgrade
- Fixed a React duplicate-key warning when `read_files` listed the same path more than once
- Aborting a task no longer risks killing the shared hub daemon
- Connector status delivery failures are no longer fatal to the turn
- Agentic compaction is now the default context-compaction strategy, with fixes for it silently falling back to basic compaction and for tool-heavy transcripts that could never find a cut point (from SDK v0.0.66)
- Editor edits preserve a file's existing line endings, fixing failed exact-match edits on CRLF files (from SDK v0.0.66)
- Broader built-in provider coverage, now generated from models.dev (from SDK v0.0.66)
- Updated the bundled model catalog (from SDK v0.0.66)
## 3.0.46
- Fixed out-of-credits detection so the CLI reliably recognizes the Cline API's real `insufficient_credits` (402) error and shows the "add credits" card instead of a generic error
+1 -1
View File
@@ -257,7 +257,7 @@ Schedules can route results back to chat surfaces with `--delivery-adapter`, `--
| `--hooks-dir <path>` | Additional hooks directory hint for runtime hook injection |
| `--acp` | ACP (Agent Client Protocol) mode |
| `--thinking [none\|low\|medium\|high\|xhigh]` | Model thinking level when supported. Defaults to `medium` when the flag is provided without a level; thinking is off when the flag is omitted. |
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `basic`; use `agentic` for LLM compaction or `off` to disable. |
| `--compaction <agentic\|basic\|off>` | Context compaction mode. Defaults to `agentic`; use `basic` for local truncation or `off` to disable. |
| `--retries <count>` | Maximum consecutive mistakes (retries) before halting (default: `3`) |
| `--json` | Output NDJSON instead of styled text |
| `--data-dir <path>` | Use isolated local state at `<path>` instead of `~/.cline` (enables sandbox mode automatically) |
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.46",
"version": "3.0.47",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+494
View File
@@ -0,0 +1,494 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
CLINE_CONNECTOR_DETACHED_CHILD_ENV,
CONNECT_ALREADY_RUNNING_EXIT_CODE,
} from "../connectors/common";
import type { ConnectIo, ConnectRunContext } from "../connectors/types";
import {
runConnectAdapter,
runRestartConnector,
runStopAllConnectors,
stopAllConnectors,
} from "./connect";
const mocks = vi.hoisted(() => ({
disableConnectorAutostart: vi.fn(),
getPersistedConnectorConnection: vi.fn(),
getConnector: vi.fn(),
listActiveConnectors: vi.fn(),
listConnectors: vi.fn((): Array<{ name: string; description: string }> => []),
persistConnectorConnection: vi.fn(),
removePersistedConnectorConnection: vi.fn(),
run: vi.fn(),
validate: vi.fn(),
}));
vi.mock("@cline/core", () => ({
disableConnectorAutostart: mocks.disableConnectorAutostart,
getPersistedConnectorConnection: mocks.getPersistedConnectorConnection,
listActiveConnectors: mocks.listActiveConnectors,
persistConnectorConnection: mocks.persistConnectorConnection,
removePersistedConnectorConnection: mocks.removePersistedConnectorConnection,
}));
vi.mock("../connectors/registry", () => ({
getConnector: mocks.getConnector,
listConnectors: mocks.listConnectors,
}));
describe("runConnectAdapter", () => {
const previousDetachedChild = process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV];
const io: ConnectIo = {
writeln: vi.fn(),
writeErr: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mocks.listConnectors.mockReturnValue([]);
mocks.listActiveConnectors.mockReturnValue([]);
mocks.run.mockImplementation(
async (_args: string[], _io: ConnectIo, context: ConnectRunContext) => {
context.setPersistenceInstanceId("cline_bot");
return 0;
},
);
mocks.validate.mockResolvedValue(0);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
});
});
afterEach(() => {
if (previousDetachedChild === undefined) {
delete process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV];
} else {
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] = previousDetachedChild;
}
});
it("persists a successful detached connector start", async () => {
await expect(
runConnectAdapter("telegram", ["-k", "token"], io),
).resolves.toBe(0);
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
"telegram",
"cline_bot",
["-k", "token"],
);
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("persists a successful env-only connector start", async () => {
await expect(runConnectAdapter("telegram", [], io)).resolves.toBe(0);
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
"telegram",
"cline_bot",
[],
);
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("persists connector-resolved launch arguments", async () => {
mocks.run.mockImplementation(
async (_args: string[], _io: ConnectIo, context: ConnectRunContext) => {
context.setPersistenceInstanceId("resolved_bot");
context.setPersistenceArgs([
"--bot-token",
"token",
"--bot-username",
"resolved_bot",
]);
return 0;
},
);
await expect(
runConnectAdapter("telegram", ["--bot-token", "token"], io),
).resolves.toBe(0);
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
"telegram",
"resolved_bot",
["--bot-token", "token", "--bot-username", "resolved_bot"],
);
});
it("does not rewrite persistence when a connector is already running", async () => {
mocks.run.mockResolvedValue(CONNECT_ALREADY_RUNNING_EXIT_CODE);
await expect(
runConnectAdapter("telegram", ["-k", "token"], io),
).resolves.toBe(0);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it.each([
"-i",
"--interactive",
])("disables autostart after a successful %s foreground run exits", async (interactiveFlag) => {
await expect(
runConnectAdapter("telegram", ["-k", "token", interactiveFlag], io),
).resolves.toBe(0);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).toHaveBeenCalledWith(
"telegram",
"cline_bot",
);
});
it("does not change persistence after a failed foreground run", async () => {
mocks.run.mockResolvedValue(1);
await expect(
runConnectAdapter("telegram", ["-k", "token", "-i"], io),
).resolves.toBe(1);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("does not persist a failed detached launch", async () => {
mocks.run.mockResolvedValue(1);
await expect(
runConnectAdapter("telegram", ["-k", "token"], io),
).resolves.toBe(1);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("leaves persistence unchanged when an internal detached child exits", async () => {
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] = "1";
await expect(
runConnectAdapter("telegram", ["-k", "token", "-i"], io),
).resolves.toBe(0);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("does not persist help invocations", async () => {
await expect(runConnectAdapter("telegram", ["--help"], io)).resolves.toBe(
0,
);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("leaves autostart unchanged during shared process cleanup", async () => {
const stopAll = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 2,
});
mocks.listConnectors.mockReturnValue([
{ name: "telegram", description: "Telegram" },
]);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
showHelp: vi.fn(),
stopAll,
});
await expect(stopAllConnectors(io)).resolves.toEqual({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 2,
executed: 1,
});
expect(stopAll).toHaveBeenCalledWith(io);
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
});
it("disables autostart for an explicit stop-all command", async () => {
const stopAll = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 2,
});
mocks.listConnectors.mockReturnValue([
{ name: "telegram", description: "Telegram" },
]);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
showHelp: vi.fn(),
stopAll,
});
await expect(runStopAllConnectors(io)).resolves.toBe(0);
expect(stopAll).toHaveBeenCalledWith(io);
expect(mocks.disableConnectorAutostart).toHaveBeenCalledWith();
});
it("validates a replacement before stopping the active instance", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 0,
});
mocks.validate.mockResolvedValue(1);
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runRestartConnector("telegram", ["-k", "bad-token"], io),
).resolves.toBe(1);
expect(mocks.validate).toHaveBeenCalledWith(["-k", "bad-token"], io);
expect(stopInstance).not.toHaveBeenCalled();
expect(mocks.run).not.toHaveBeenCalled();
});
it("shows restart help without stopping an active instance", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 0,
});
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(runRestartConnector("telegram", ["--help"], io)).resolves.toBe(
0,
);
expect(mocks.run).toHaveBeenCalledWith(["--help"], io, expect.any(Object));
expect(mocks.validate).not.toHaveBeenCalled();
expect(stopInstance).not.toHaveBeenCalled();
});
it("restores the last successful launch when a replacement fails", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 0,
});
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getPersistedConnectorConnection.mockReturnValue({
channel: "telegram",
instanceId: "cline_bot",
connectArgs: ["-k", "new-token"],
lastSuccessfulArgs: ["-k", "old-token"],
enabled: true,
updatedAt: "2026-07-25T00:00:00.000Z",
lastConnectedAt: "2026-07-25T00:00:00.000Z",
});
mocks.run
.mockResolvedValueOnce(1)
.mockImplementationOnce(
async (_args: string[], _io: ConnectIo, context: ConnectRunContext) => {
context.setPersistenceInstanceId("cline_bot");
return 0;
},
);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runRestartConnector("telegram", ["-k", "new-token"], io),
).resolves.toBe(1);
expect(stopInstance).toHaveBeenCalledWith("cline_bot", io);
expect(mocks.run).toHaveBeenNthCalledWith(
1,
["-k", "new-token"],
io,
expect.any(Object),
);
expect(mocks.run).toHaveBeenNthCalledWith(
2,
["-k", "old-token"],
io,
expect.any(Object),
);
expect(mocks.disableConnectorAutostart).not.toHaveBeenCalled();
expect(mocks.persistConnectorConnection).toHaveBeenCalledWith(
"telegram",
"cline_bot",
["-k", "old-token"],
);
});
it("restarts an active instance without persisted rollback arguments", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 0,
});
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getPersistedConnectorConnection.mockReturnValue(undefined);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runRestartConnector("telegram", ["-k", "new-token"], io),
).resolves.toBe(0);
expect(stopInstance).toHaveBeenCalledWith("cline_bot", io);
expect(mocks.run).toHaveBeenCalledWith(
["-k", "new-token"],
io,
expect.any(Object),
);
});
it("does not start a replacement when the active process cannot be stopped", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 0,
failedProcesses: 1,
stoppedSessions: 0,
});
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runRestartConnector("telegram", ["-k", "new-token"], io),
).resolves.toBe(1);
expect(stopInstance).toHaveBeenCalledWith("cline_bot", io);
expect(mocks.run).not.toHaveBeenCalled();
});
it("does not count an already-running instance as a successful replacement", async () => {
const stopInstance = vi.fn().mockResolvedValue({
stoppedProcesses: 1,
failedProcesses: 0,
stoppedSessions: 0,
});
mocks.listActiveConnectors.mockReturnValue([
{
id: "telegram:cline_bot",
type: "telegram",
instanceId: "cline_bot",
pid: 123,
hubUrl: "ws://127.0.0.1:4317",
botUsername: "cline_bot",
},
]);
mocks.getPersistedConnectorConnection.mockReturnValue({
channel: "telegram",
instanceId: "cline_bot",
connectArgs: ["-k", "new-token"],
lastSuccessfulArgs: ["-k", "old-token"],
enabled: true,
updatedAt: "2026-07-25T00:00:00.000Z",
lastConnectedAt: "2026-07-25T00:00:00.000Z",
});
mocks.run.mockResolvedValue(CONNECT_ALREADY_RUNNING_EXIT_CODE);
mocks.getConnector.mockResolvedValue({
name: "telegram",
description: "Telegram",
run: mocks.run,
validate: mocks.validate,
showHelp: vi.fn(),
stopInstance,
});
await expect(
runRestartConnector("telegram", ["-k", "new-token"], io),
).resolves.toBe(1);
expect(mocks.run).toHaveBeenCalledTimes(1);
expect(io.writeErr).toHaveBeenCalledWith(
"[connect] replacement was not started because telegram instance cline_bot is still running",
);
expect(mocks.persistConnectorConnection).not.toHaveBeenCalled();
});
});
+203 -15
View File
@@ -1,10 +1,29 @@
import {
disableConnectorAutostart,
getPersistedConnectorConnection,
listActiveConnectors,
persistConnectorConnection,
removePersistedConnectorConnection,
} from "@cline/core";
import {
CLINE_CONNECTOR_DETACHED_CHILD_ENV,
CONNECT_ALREADY_RUNNING_EXIT_CODE,
} from "../connectors/common";
import { getConnector, listConnectors } from "../connectors/registry";
import type { ConnectIo, ConnectStopResult } from "../connectors/types";
import type {
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../connectors/types";
const HELP_FLAGS = new Set(["-h", "--help"]);
const INTERACTIVE_FLAGS = new Set(["-i", "--interactive"]);
export async function stopAllConnectors(
io: ConnectIo,
): Promise<ConnectStopResult & { executed: number }> {
let stoppedProcesses = 0;
let failedProcesses = 0;
let stoppedSessions = 0;
let executed = 0;
for (const entry of listConnectors()) {
@@ -18,42 +37,209 @@ export async function stopAllConnectors(
executed += 1;
const result = await connector.stopAll(io);
stoppedProcesses += result.stoppedProcesses;
failedProcesses += result.failedProcesses;
stoppedSessions += result.stoppedSessions;
}
return { stoppedProcesses, stoppedSessions, executed };
return { stoppedProcesses, failedProcesses, stoppedSessions, executed };
}
export async function runStopAllConnectors(io: ConnectIo): Promise<number> {
const { stoppedProcesses, stoppedSessions, executed } =
const { stoppedProcesses, failedProcesses, stoppedSessions, executed } =
await stopAllConnectors(io);
if (executed === 0) {
io.writeln("[connect] no adapters support stop yet");
return 0;
}
disableConnectorAutostart();
io.writeln(
`[connect] stopped processes=${stoppedProcesses} sessions=${stoppedSessions}`,
`[connect] stopped processes=${stoppedProcesses} failed=${failedProcesses} sessions=${stoppedSessions}`,
);
return 0;
return failedProcesses === 0 ? 0 : 1;
}
export async function runStopConnector(
adapterName: string,
io: ConnectIo,
options: {
autostart: "disable" | "preserve";
instanceId?: string;
} = {
autostart: "disable",
},
): Promise<number> {
const connector = await getConnector(adapterName);
if (!connector) {
io.writeErr(`unknown connect adapter "${adapterName}"`);
return 1;
}
if (!connector.stopAll) {
const stop = options.instanceId
? connector.stopInstance
? () => connector.stopInstance?.(options.instanceId ?? "", io)
: undefined
: connector.stopAll
? () => connector.stopAll?.(io)
: undefined;
if (!stop) {
io.writeErr(`connect adapter "${adapterName}" does not support stop`);
return 1;
}
const result: ConnectStopResult = await connector.stopAll(io);
const result = await stop();
if (!result) {
io.writeErr(`connect adapter "${adapterName}" does not support stop`);
return 1;
}
if (options.autostart === "disable") {
disableConnectorAutostart(connector.name, options.instanceId);
}
io.writeln(
`[connect] ${connector.name} stopped processes=${result.stoppedProcesses} sessions=${result.stoppedSessions}`,
`[connect] ${connector.name}${options.instanceId ? ` instance=${options.instanceId}` : ""} stopped processes=${result.stoppedProcesses} failed=${result.failedProcesses} sessions=${result.stoppedSessions}`,
);
return 0;
return result.failedProcesses === 0 ? 0 : 1;
}
export async function runRestartConnector(
adapterName: string,
passthroughArgs: string[],
io: ConnectIo,
requestedInstanceId?: string,
): Promise<number> {
if (passthroughArgs.some((arg) => HELP_FLAGS.has(arg))) {
return await runConnectAdapter(adapterName, passthroughArgs, io);
}
const connector = await getConnector(adapterName);
if (!connector) {
io.writeErr(`unknown connect adapter "${adapterName}"`);
return 1;
}
const activeInstances = listActiveConnectors().filter(
(record) => record.type === adapterName,
);
if (!requestedInstanceId && activeInstances.length > 1) {
io.writeErr(
`cannot safely restart ${adapterName}: ${activeInstances.length} instances are active; specify an instance`,
);
return 1;
}
const instanceId = requestedInstanceId ?? activeInstances[0]?.instanceId;
const targetIsActive =
instanceId !== undefined &&
activeInstances.some((record) => record.instanceId === instanceId);
if (!targetIsActive || !instanceId) {
return await runConnectAdapter(adapterName, passthroughArgs, io);
}
const validationExitCode = await connector.validate(passthroughArgs, io);
if (validationExitCode !== 0) {
return validationExitCode;
}
const previousConnection = getPersistedConnectorConnection(
adapterName,
instanceId,
);
const stopExitCode = await runStopConnector(adapterName, io, {
autostart: "preserve",
instanceId,
});
if (stopExitCode !== 0) {
return stopExitCode;
}
const replacement = await runConnectAdapterWithResult(
adapterName,
passthroughArgs,
io,
);
if (replacement.exitCode === 0) {
if (replacement.instanceId && replacement.instanceId !== instanceId) {
removePersistedConnectorConnection(adapterName, instanceId);
}
return 0;
}
if (replacement.exitCode === CONNECT_ALREADY_RUNNING_EXIT_CODE) {
io.writeErr(
`[connect] replacement was not started because ${adapterName} instance ${instanceId} is still running`,
);
return 1;
}
if (!previousConnection) {
io.writeErr(
`[connect] replacement failed and ${adapterName} instance ${instanceId} has no successful launch arguments for rollback`,
);
return replacement.exitCode;
}
io.writeErr(
`[connect] replacement failed; restoring ${adapterName} instance ${instanceId}`,
);
const rollback = await runConnectAdapterWithResult(
adapterName,
previousConnection.lastSuccessfulArgs,
io,
);
if (rollback.exitCode === 0) {
io.writeln(`[connect] restored ${adapterName} instance ${instanceId}`);
} else {
io.writeErr(
`[connect] failed to restore ${adapterName} instance ${instanceId}`,
);
}
return replacement.exitCode;
}
interface ConnectAdapterResult {
exitCode: number;
instanceId?: string;
}
async function runConnectAdapterWithResult(
adapterName: string,
passthroughArgs: string[],
io: ConnectIo,
): Promise<ConnectAdapterResult> {
const connector = await getConnector(adapterName);
if (!connector) {
io.writeErr(`unknown connect adapter "${adapterName}"`);
return { exitCode: 1 };
}
let persistenceArgs = passthroughArgs;
let persistenceInstanceId: string | undefined;
const context: ConnectRunContext = {
setPersistenceArgs: (args) => {
persistenceArgs = [...args];
},
setPersistenceInstanceId: (instanceId) => {
persistenceInstanceId = instanceId;
},
};
const exitCode = await connector.run(passthroughArgs, io, context);
if (exitCode === CONNECT_ALREADY_RUNNING_EXIT_CODE) {
return { exitCode, instanceId: persistenceInstanceId };
}
const isHelpInvocation = passthroughArgs.some((arg) => HELP_FLAGS.has(arg));
const isInteractiveInvocation = passthroughArgs.some((arg) =>
INTERACTIVE_FLAGS.has(arg),
);
const isDetachedChild =
process.env[CLINE_CONNECTOR_DETACHED_CHILD_ENV] === "1";
if (
exitCode === 0 &&
!isHelpInvocation &&
!isDetachedChild &&
isInteractiveInvocation
) {
disableConnectorAutostart(connector.name, persistenceInstanceId);
} else if (
exitCode === 0 &&
!isHelpInvocation &&
!isDetachedChild &&
persistenceInstanceId
) {
persistConnectorConnection(
connector.name,
persistenceInstanceId,
persistenceArgs,
);
}
return { exitCode, instanceId: persistenceInstanceId };
}
export async function runConnectAdapter(
@@ -61,12 +247,14 @@ export async function runConnectAdapter(
passthroughArgs: string[],
io: ConnectIo,
): Promise<number> {
const connector = await getConnector(adapterName);
if (!connector) {
io.writeErr(`unknown connect adapter "${adapterName}"`);
return 1;
}
return connector.run(passthroughArgs, io);
const result = await runConnectAdapterWithResult(
adapterName,
passthroughArgs,
io,
);
return result.exitCode === CONNECT_ALREADY_RUNNING_EXIT_CODE
? 0
: result.exitCode;
}
export function formatAdapterList(): string {
+6
View File
@@ -22,6 +22,7 @@ const {
mockClearHubDiscovery,
mockStopLocalHubServerGracefully,
mockEnsureFileExists,
mockListActiveConnectors,
mockStopAllConnectors,
} = vi.hoisted(() => ({
mockSpawnSync: vi.fn(),
@@ -50,8 +51,10 @@ const {
mockClearHubDiscovery: vi.fn(),
mockStopLocalHubServerGracefully: vi.fn(async () => false),
mockEnsureFileExists: vi.fn(),
mockListActiveConnectors: vi.fn(() => []),
mockStopAllConnectors: vi.fn(async () => ({
stoppedProcesses: 0,
failedProcesses: 0,
stoppedSessions: 0,
executed: 0,
})),
@@ -70,6 +73,7 @@ vi.mock("@cline/core", () => ({
readHubDiscovery: mockReadHubDiscovery,
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
ensureFileExists: mockEnsureFileExists,
listActiveConnectors: mockListActiveConnectors,
}));
vi.mock("../connectors/common", () => ({
@@ -100,6 +104,7 @@ describe("runDoctorCommand", () => {
mockStopLocalHubServerGracefully.mockResolvedValue(false);
mockStopAllConnectors.mockResolvedValue({
stoppedProcesses: 0,
failedProcesses: 0,
stoppedSessions: 0,
executed: 0,
});
@@ -283,6 +288,7 @@ describe("runDoctorCommand", () => {
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
mockStopAllConnectors.mockResolvedValue({
stoppedProcesses: 2,
failedProcesses: 0,
stoppedSessions: 5,
executed: 3,
});
+6 -5
View File
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
import {
clearHubDiscovery,
ensureFileExists,
listActiveConnectors,
probeHubServer,
readHubDiscovery,
resolveClineDataDir,
@@ -11,15 +12,15 @@ import {
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import {
type ActiveConnectorRecord,
formatUptime,
resolveClineBuildEnv,
} from "@cline/shared";
import { Command } from "commander";
import open from "open";
import { version as cliVersion } from "../../package.json";
import { isProcessRunning } from "../connectors/common";
import {
type ActiveConnectorRecord,
listActiveConnectors,
} from "../connectors/status";
import { getCliBuildInfo } from "../utils/common";
import { c, writeln } from "../utils/output";
import { stopAllConnectors } from "./connect";
+1
View File
@@ -136,6 +136,7 @@ export function commanderToParsedArgs(program: Command): ParsedArgs {
interactive: !!opts.tui,
outputMode: opts.json ? "json" : "text",
mode: opts.plan ? "plan" : opts.yolo ? "yolo" : opts.zen ? "zen" : "act",
modeExplicitlySet: !!(opts.plan || opts.act || opts.yolo || opts.zen),
sandbox: !!opts.dataDir,
acpMode: !!opts.acp,
thinking: false,
+4 -2
View File
@@ -148,8 +148,10 @@ export function isJsonPath(path: string): boolean {
return path.toLowerCase().endsWith(".json");
}
export function parseMode(raw: string | undefined): "act" | "plan" | undefined {
if (raw === "act" || raw === "plan") {
export function parseMode(
raw: string | undefined,
): "act" | "plan" | "yolo" | undefined {
if (raw === "act" || raw === "plan" || raw === "yolo") {
return raw;
}
return undefined;
+5 -3
View File
@@ -1,3 +1,4 @@
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import type { Command } from "commander";
import { ensureSchedulerHub } from "./client";
import {
@@ -9,6 +10,7 @@ import {
mergeScheduleMetadata,
parseJsonObjectFlag,
parseList,
parseMode,
resolveAddress,
toPositiveInt,
} from "./common";
@@ -63,8 +65,8 @@ export function registerScheduleCommands(
.option("--disabled", "Create in disabled state")
.option("--max-parallel <n>", "Max parallel executions", "1")
.option("--metadata-json <json>", "Metadata as JSON object")
.option("--mode <act|plan>", "Execution mode")
.option("--model <model>", "Model to use", "openai/gpt-5.3-codex")
.option("--mode <act|plan|yolo>", "Execution mode", "yolo")
.option("--model <model>", "Model to use", CLINE_DEFAULT_MODEL_ID)
.option("--provider <id>", "Provider ID", "cline")
.option("--system-prompt <text>", "System prompt override")
.option("--tags <list>", "Comma-separated tags")
@@ -96,7 +98,7 @@ export function registerScheduleCommands(
prompt: opts.prompt,
provider: opts.provider,
model: opts.model,
mode: opts.mode === "plan" ? "plan" : "act",
mode: parseMode(opts.mode) ?? "yolo",
workspaceRoot: opts.workspace,
cwd: opts.cwd,
systemPrompt: opts.systemPrompt,
@@ -1,5 +1,6 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve } from "node:path";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import type { Command } from "commander";
import { ensureSchedulerHub } from "./client";
import {
@@ -39,7 +40,7 @@ function resolveImportedModelSelection(parsed: Record<string, unknown>): {
modelSelection?.modelId ??
parsed.modelId ??
parsed.model ??
"openai/gpt-5.3-codex",
CLINE_DEFAULT_MODEL_ID,
).trim();
return { provider, model };
}
@@ -165,7 +166,10 @@ export function registerScheduleImportCommand(
prompt: String(parsed.prompt ?? "").trim(),
provider,
model,
mode: parsed.mode === "plan" ? "plan" : "act",
mode:
parseMode(
typeof parsed.mode === "string" ? parsed.mode : undefined,
) ?? "yolo",
workspaceRoot,
cwd: String(parsed.cwd ?? "").trim() || undefined,
systemPrompt:
@@ -229,7 +233,7 @@ export function registerScheduleUpdateCommand(
.option("--enabled", "Enable the schedule")
.option("--max-parallel <n>", "New max parallel executions")
.option("--metadata-json <json>", "New metadata as JSON object")
.option("--mode <act|plan>", "New execution mode")
.option("--mode <act|plan|yolo>", "New execution mode")
.option("--model <model>", "New model")
.option("--name <name>", "New name")
.option("--pause", "Pause the schedule")
+39 -22
View File
@@ -59,6 +59,7 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import {
@@ -937,9 +938,18 @@ class DiscordConnector extends ConnectorBase<
);
}
protected override async runWithOptions(
override async stopInstance(
instanceId: string,
io: ConnectIo,
): Promise<ConnectStopResult> {
return await this.stopDiscordConnectorInstance(
this.resolveConnectorStatePath(instanceId),
io,
);
}
protected override async validateOptions(
options: ConnectDiscordOptions,
rawArgs: string[],
io: ConnectIo,
): Promise<number> {
if (!options.applicationId) {
@@ -960,7 +970,16 @@ class DiscordConnector extends ConnectorBase<
);
return 1;
}
return 0;
}
protected override async runWithOptions(
options: ConnectDiscordOptions,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
context.setPersistenceInstanceId(options.applicationId);
const statePath = this.resolveConnectorStatePath(options.applicationId);
const bindingsPath = this.resolveBindingsPath(options.applicationId);
const staleState = this.removeStaleState(
@@ -971,26 +990,24 @@ class DiscordConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<DiscordThreadState>(bindingsPath);
}
if (
await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_DISCORD_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[discord] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[discord] starting background connector pid=${pid} application=${options.applicationId}`,
foregroundHint:
"[discord] use `cline connect discord -i ...` to run in the foreground",
launchFailureMessage:
"failed to launch Discord connector in background",
})
) {
return 0;
const backgroundExitCode = await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_DISCORD_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[discord] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[discord] starting background connector pid=${pid} application=${options.applicationId}`,
foregroundHint:
"[discord] use `cline connect discord -i ...` to run in the foreground",
launchFailureMessage: "failed to launch Discord connector in background",
});
if (backgroundExitCode !== undefined) {
return backgroundExitCode;
}
const loggerAdapter = createCliLoggerAdapter({
+76 -52
View File
@@ -55,6 +55,7 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import {
@@ -409,11 +410,66 @@ class GoogleChatConnector extends ConnectorBase<
);
}
override async stopInstance(
instanceId: string,
io: ConnectIo,
): Promise<ConnectStopResult> {
return await this.stopGoogleChatConnectorInstance(
this.resolveConnectorStatePath(instanceId),
io,
);
}
private parseCredentials(
options: ConnectGoogleChatOptions,
):
| { client_email: string; private_key: string; project_id?: string }
| undefined {
if (!options.credentialsJson) {
return undefined;
}
const parsed = JSON.parse(options.credentialsJson) as Record<
string,
unknown
>;
if (
typeof parsed.client_email !== "string" ||
typeof parsed.private_key !== "string"
) {
throw new Error(
"credentials JSON must include string client_email and private_key fields",
);
}
return {
client_email: parsed.client_email,
private_key: parsed.private_key,
project_id:
typeof parsed.project_id === "string" ? parsed.project_id : undefined,
};
}
protected override async validateOptions(
options: ConnectGoogleChatOptions,
io: ConnectIo,
): Promise<number> {
try {
this.parseCredentials(options);
return 0;
} catch (error) {
io.writeErr(
`invalid GOOGLE_CHAT_CREDENTIALS JSON: ${error instanceof Error ? error.message : String(error)}`,
);
return 1;
}
}
protected override async runWithOptions(
options: ConnectGoogleChatOptions,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
context.setPersistenceInstanceId(options.userName);
const statePath = this.resolveConnectorStatePath(options.userName);
const bindingsPath = this.resolveBindingsPath(options.userName);
const staleState = this.removeStaleState(
@@ -424,26 +480,25 @@ class GoogleChatConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<GoogleChatThreadState>(bindingsPath);
}
if (
await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_GCHAT_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[gchat] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[gchat] starting background connector pid=${pid} user=${options.userName}`,
foregroundHint:
"[gchat] use `cline connect gchat -i ...` to run in the foreground",
launchFailureMessage:
"failed to launch Google Chat connector in background",
})
) {
return 0;
const backgroundExitCode = await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_GCHAT_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[gchat] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[gchat] starting background connector pid=${pid} user=${options.userName}`,
foregroundHint:
"[gchat] use `cline connect gchat -i ...` to run in the foreground",
launchFailureMessage:
"failed to launch Google Chat connector in background",
});
if (backgroundExitCode !== undefined) {
return backgroundExitCode;
}
const loggerAdapter = createCliLoggerAdapter({
@@ -452,38 +507,7 @@ class GoogleChatConnector extends ConnectorBase<
});
const logger = createChatSdkLogger(loggerAdapter);
const consoleLogger = new ConsoleLogger("info", "gchat-connect");
let parsedCredentials:
| { client_email: string; private_key: string; project_id?: string }
| undefined;
if (options.credentialsJson) {
try {
const parsed = JSON.parse(options.credentialsJson) as Record<
string,
unknown
>;
if (
typeof parsed.client_email !== "string" ||
typeof parsed.private_key !== "string"
) {
throw new Error(
"credentials JSON must include string client_email and private_key fields",
);
}
parsedCredentials = {
client_email: parsed.client_email,
private_key: parsed.private_key,
project_id:
typeof parsed.project_id === "string"
? parsed.project_id
: undefined,
};
} catch (error) {
io.writeErr(
`invalid GOOGLE_CHAT_CREDENTIALS JSON: ${error instanceof Error ? error.message : String(error)}`,
);
return 1;
}
}
const parsedCredentials = this.parseCredentials(options);
const endpointUrl = `${options.baseUrl.replace(/\/$/, "")}/api/webhooks/gchat`;
const gchat = createGoogleChatAdapter(
parsedCredentials
+31 -19
View File
@@ -51,6 +51,7 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import { getConnectorSystemPrompt } from "./prompts";
@@ -477,11 +478,23 @@ class LinearConnector extends ConnectorBase<
);
}
override async stopInstance(
instanceId: string,
io: ConnectIo,
): Promise<ConnectStopResult> {
return await this.stopLinearConnectorInstance(
this.resolveConnectorStatePath(instanceId),
io,
);
}
protected override async runWithOptions(
options: ConnectLinearOptions,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
context.setPersistenceInstanceId(options.userName);
const statePath = this.resolveConnectorStatePath(options.userName);
const bindingsPath = this.resolveBindingsPath(options.userName);
const staleState = this.removeStaleState(
@@ -492,25 +505,24 @@ class LinearConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<LinearThreadState>(bindingsPath);
}
if (
await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_LINEAR_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[linear] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[linear] starting background connector pid=${pid} user=${options.userName}`,
foregroundHint:
"[linear] use `cline connect linear -i ...` to run in the foreground",
launchFailureMessage: "failed to launch Linear connector in background",
})
) {
return 0;
const backgroundExitCode = await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_LINEAR_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[linear] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[linear] starting background connector pid=${pid} user=${options.userName}`,
foregroundHint:
"[linear] use `cline connect linear -i ...` to run in the foreground",
launchFailureMessage: "failed to launch Linear connector in background",
});
if (backgroundExitCode !== undefined) {
return backgroundExitCode;
}
const loggerAdapter = createCliLoggerAdapter({
+33 -21
View File
@@ -61,6 +61,7 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import {
@@ -668,11 +669,23 @@ class SlackConnector extends ConnectorBase<
);
}
override async stopInstance(
instanceId: string,
io: ConnectIo,
): Promise<ConnectStopResult> {
return await this.stopSlackConnectorInstance(
this.resolveConnectorStatePath(instanceId),
io,
);
}
protected override async runWithOptions(
options: ConnectSlackOptions,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
context.setPersistenceInstanceId(options.userName);
const statePath = this.resolveConnectorStatePath(options.userName);
const bindingsPath = this.resolveBindingsPath(options.userName);
const stateStorePath = this.resolveStateStorePath(options.userName);
@@ -684,27 +697,26 @@ class SlackConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<SlackThreadState>(bindingsPath);
}
if (
await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_SLACK_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
state.connectionMode === "socket"
? `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} mode=socket`
: `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[slack] starting background connector pid=${pid} user=${options.userName} mode=${options.connectionMode}`,
foregroundHint:
"[slack] use `cline connect slack -i ...` to run in the foreground",
launchFailureMessage: "failed to launch Slack connector in background",
})
) {
return 0;
const backgroundExitCode = await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_SLACK_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
state.connectionMode === "socket"
? `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} mode=socket`
: `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[slack] starting background connector pid=${pid} user=${options.userName} mode=${options.connectionMode}`,
foregroundHint:
"[slack] use `cline connect slack -i ...` to run in the foreground",
launchFailureMessage: "failed to launch Slack connector in background",
});
if (backgroundExitCode !== undefined) {
return backgroundExitCode;
}
const loggerAdapter = createCliLoggerAdapter({
@@ -2,9 +2,19 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ConnectTelegramOptions } from "@cline/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CONNECT_ALREADY_RUNNING_EXIT_CODE } from "../common";
import { __test__, telegramConnector } from "./telegram";
const mocks = vi.hoisted(() => ({
spawnDetachedConnector: vi.fn(),
}));
vi.mock("../common", async (importOriginal) => ({
...(await importOriginal<typeof import("../common")>()),
spawnDetachedConnector: mocks.spawnDetachedConnector,
}));
const parseTelegramArgs = (rawArgs: string[]): ConnectTelegramOptions =>
(
telegramConnector as unknown as {
@@ -15,6 +25,11 @@ const parseTelegramArgs = (rawArgs: string[]): ConnectTelegramOptions =>
const originalClineDataDir = process.env.CLINE_DATA_DIR;
const tempDataDirs: string[] = [];
beforeEach(() => {
vi.clearAllMocks();
mocks.spawnDetachedConnector.mockReturnValue(42);
});
function useTempClineDataDir(): string {
const dataDir = mkdtempSync(join(tmpdir(), "cline-telegram-test-"));
tempDataDirs.push(dataDir);
@@ -153,7 +168,7 @@ describe("telegramConnector", () => {
expect(options.botUsername).toBe("test_bot");
});
it("does not call getMe when the token-only connector is already running", async () => {
it("validates a token before reporting its connector as already running", async () => {
const dataDir = useTempClineDataDir();
const connectorDir = join(dataDir, "connectors", "telegram");
mkdirSync(connectorDir, { recursive: true });
@@ -167,26 +182,87 @@ describe("telegramConnector", () => {
startedAt: new Date().toISOString(),
}),
);
const fetchImpl = vi.fn(async () => {
throw new Error("unexpected getMe call");
});
const fetchImpl = vi.fn(async () =>
Response.json({
ok: true,
result: { username: "resolved_bot" },
}),
);
vi.stubGlobal("fetch", fetchImpl);
const output: string[] = [];
const errors: string[] = [];
await expect(
telegramConnector.run(["--bot-token", "123:test", "--cwd", "/tmp/work"], {
writeln: (text = "") => output.push(text),
writeErr: (text) => errors.push(text),
}),
).resolves.toBe(0);
telegramConnector.run(
["--bot-token", "123:test", "--cwd", "/tmp/work"],
{
writeln: (text = "") => output.push(text),
writeErr: (text) => errors.push(text),
},
{
setPersistenceArgs: vi.fn(),
setPersistenceInstanceId: vi.fn(),
},
),
).resolves.toBe(CONNECT_ALREADY_RUNNING_EXIT_CODE);
expect(fetchImpl).not.toHaveBeenCalled();
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(errors).toEqual([]);
expect(output).toEqual([
`[telegram] connector already running pid=${process.pid} rpc=127.0.0.1:54321`,
]);
});
it("reports the resolved bot username in persistence args", async () => {
const dataDir = useTempClineDataDir();
vi.stubGlobal(
"fetch",
vi.fn(async () => {
return new Response(
JSON.stringify({
ok: true,
result: { username: "resolved_bot" },
}),
);
}),
);
const setPersistenceArgs = vi.fn();
const setPersistenceInstanceId = vi.fn();
mocks.spawnDetachedConnector.mockImplementation(() => {
const connectorDir = join(dataDir, "connectors", "telegram");
mkdirSync(connectorDir, { recursive: true });
writeFileSync(
join(connectorDir, "resolved_bot.json"),
JSON.stringify({
botUsername: "resolved_bot",
pid: process.pid,
}),
);
return process.pid;
});
await expect(
telegramConnector.run(
["--bot-token", "123:test", "--cwd", "/tmp/work"],
{
writeln: () => {},
writeErr: () => {},
},
{ setPersistenceArgs, setPersistenceInstanceId },
),
).resolves.toBe(0);
expect(setPersistenceArgs).toHaveBeenCalledWith([
"--bot-token",
"123:test",
"--cwd",
"/tmp/work",
"--bot-username",
"resolved_bot",
]);
expect(setPersistenceInstanceId).toHaveBeenCalledWith("resolved_bot");
expect(mocks.spawnDetachedConnector).toHaveBeenCalled();
});
});
describe("telegram bot username resolution", () => {
+50 -22
View File
@@ -20,7 +20,7 @@ import {
import { createWorkspaceChatCommandHost } from "../../utils/plugin-chat-commands";
import { ConnectorBase } from "../base";
import { createChatSdkLogger, enqueueThreadTurn } from "../chat-runtime";
import { isProcessRunning } from "../common";
import { CONNECT_ALREADY_RUNNING_EXIT_CODE, isProcessRunning } from "../common";
import {
type ActiveConnectorTurn,
handleConnectorUserTurn,
@@ -51,6 +51,7 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import {
@@ -604,10 +605,37 @@ class TelegramConnector extends ConnectorBase<
);
}
override async stopInstance(
instanceId: string,
io: ConnectIo,
): Promise<ConnectStopResult> {
return await this.stopTelegramConnectorInstance(
this.resolveConnectorStatePath(instanceId),
io,
);
}
protected override async validateOptions(
options: ConnectTelegramOptions,
io: ConnectIo,
): Promise<number> {
try {
await resolveTelegramBotUsername({
...options,
botUsername: undefined,
});
return 0;
} catch (error) {
io.writeErr(error instanceof Error ? error.message : String(error));
return 1;
}
}
protected override async runWithOptions(
inputOptions: ConnectTelegramOptions,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
if (
!inputOptions.botUsername &&
@@ -621,7 +649,7 @@ class TelegramConnector extends ConnectorBase<
io.writeln(
`[telegram] connector already running pid=${runningState.pid} rpc=${runningState.rpcAddress}`,
);
return 0;
return CONNECT_ALREADY_RUNNING_EXIT_CODE;
}
}
let resolvedBotUsername: string;
@@ -638,6 +666,8 @@ class TelegramConnector extends ConnectorBase<
const backgroundArgs = inputOptions.botUsername
? rawArgs
: [...rawArgs, "--bot-username", resolvedBotUsername];
context.setPersistenceArgs(backgroundArgs);
context.setPersistenceInstanceId(options.botUsername);
const statePath = this.resolveConnectorStatePath(options.botUsername);
const bindingsPath = this.resolveBindingsPath(options.botUsername);
const staleState = this.removeStaleState(
@@ -648,26 +678,24 @@ class TelegramConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<TelegramThreadState>(bindingsPath);
}
if (
await this.maybeRunInBackground({
rawArgs: backgroundArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_TELEGRAM_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[telegram] connector already running pid=${state.pid} rpc=${state.rpcAddress}`,
formatBackgroundStartMessage: (pid) =>
`[telegram] starting background connector pid=${pid} bot=@${options.botUsername}`,
foregroundHint:
"[telegram] use `cline connect telegram -i ...` to run in the foreground",
launchFailureMessage:
"failed to launch Telegram connector in background",
})
) {
return 0;
const backgroundExitCode = await this.maybeRunInBackground({
rawArgs: backgroundArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_TELEGRAM_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[telegram] connector already running pid=${state.pid} rpc=${state.rpcAddress}`,
formatBackgroundStartMessage: (pid) =>
`[telegram] starting background connector pid=${pid} bot=@${options.botUsername}`,
foregroundHint:
"[telegram] use `cline connect telegram -i ...` to run in the foreground",
launchFailureMessage: "failed to launch Telegram connector in background",
});
if (backgroundExitCode !== undefined) {
return backgroundExitCode;
}
const loggerAdapter = createCliLoggerAdapter({
+31 -20
View File
@@ -55,6 +55,7 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "../types";
import {
@@ -444,15 +445,27 @@ class WhatsAppConnector extends ConnectorBase<
);
}
override async stopInstance(
instanceId: string,
io: ConnectIo,
): Promise<ConnectStopResult> {
return await this.stopWhatsAppConnectorInstance(
this.resolveConnectorStatePath(instanceId),
io,
);
}
protected override async runWithOptions(
options: ConnectWhatsAppOptions,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
const instanceKey = resolveInstanceKey({
phoneNumberId: options.phoneNumberId,
userName: options.userName,
});
context.setPersistenceInstanceId(instanceKey);
const statePath = this.resolveConnectorStatePath(instanceKey);
const bindingsPath = this.resolveBindingsPath(instanceKey);
const staleState = this.removeStaleState(
@@ -463,26 +476,24 @@ class WhatsAppConnector extends ConnectorBase<
if (staleState) {
clearBindingSessionIds<WhatsAppThreadState>(bindingsPath);
}
if (
await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_WHATSAPP_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[whatsapp] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[whatsapp] starting background connector pid=${pid} user=${options.userName}`,
foregroundHint:
"[whatsapp] use `cline connect whatsapp -i ...` to run in the foreground",
launchFailureMessage:
"failed to launch WhatsApp connector in background",
})
) {
return 0;
const backgroundExitCode = await this.maybeRunInBackground({
rawArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_WHATSAPP_CONNECT_CHILD",
statePath,
readState: (path) => this.readConnectorState(path),
isRunning: (state) => isProcessRunning(state.pid),
formatAlreadyRunningMessage: (state) =>
`[whatsapp] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
formatBackgroundStartMessage: (pid) =>
`[whatsapp] starting background connector pid=${pid} user=${options.userName}`,
foregroundHint:
"[whatsapp] use `cline connect whatsapp -i ...` to run in the foreground",
launchFailureMessage: "failed to launch WhatsApp connector in background",
});
if (backgroundExitCode !== undefined) {
return backgroundExitCode;
}
const loggerAdapter = createCliLoggerAdapter({
+208
View File
@@ -0,0 +1,208 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ConnectorBase } from "./base";
import { CONNECT_ALREADY_RUNNING_EXIT_CODE } from "./common";
import type { ConnectIo } from "./types";
const mocks = vi.hoisted(() => ({
isProcessRunning: vi.fn(),
spawnDetachedConnector: vi.fn(),
terminateProcess: vi.fn(),
}));
vi.mock("./common", async (importOriginal) => ({
...(await importOriginal<typeof import("./common")>()),
isProcessRunning: mocks.isProcessRunning,
spawnDetachedConnector: mocks.spawnDetachedConnector,
terminateProcess: mocks.terminateProcess,
}));
class TestConnector extends ConnectorBase<
Record<string, never>,
{ pid: number }
> {
constructor() {
super("test", "Test connector");
}
protected readOptions(): Record<string, never> {
return {};
}
protected async runWithOptions(): Promise<number> {
return 0;
}
runBackground(
io: ConnectIo,
options?: {
readState?: () => { pid: number } | undefined;
isRunning?: (state: { pid: number }) => boolean;
startupTimeoutMs?: number;
},
): Promise<number | undefined> {
return this.maybeRunInBackground({
rawArgs: ["--token", "secret"],
io,
interactive: false,
childEnvVar: "CLINE_TEST_CONNECT_CHILD",
statePath: "/tmp/test-connector.json",
readState: options?.readState ?? (() => undefined),
isRunning: options?.isRunning ?? (() => false),
formatAlreadyRunningMessage: () => "already running",
formatBackgroundStartMessage: (pid) => `started ${pid}`,
foregroundHint: "foreground hint",
launchFailureMessage: "launch failed",
startupTimeoutMs: options?.startupTimeoutMs,
});
}
stopProcess(
io: ConnectIo,
options: {
statePath: string;
readState: (path: string) => { pid: number } | undefined;
stopSessions?: (state: { pid: number }) => Promise<number>;
clearBindings?: (state: { pid: number }) => void;
},
) {
return this.stopManagedProcess({
io,
statePath: options.statePath,
readState: options.readState,
describeStoppedProcess: (state) => `stopped pid=${state.pid}`,
getPid: (state) => state.pid,
stopSessions: options.stopSessions ?? (async () => 0),
clearBindings: options.clearBindings,
});
}
}
describe("ConnectorBase background launch", () => {
const io: ConnectIo = {
writeln: vi.fn(),
writeErr: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mocks.isProcessRunning.mockReturnValue(true);
mocks.terminateProcess.mockResolvedValue(true);
});
it("returns a failure exit code when the detached process is not created", async () => {
mocks.spawnDetachedConnector.mockReturnValue(0);
await expect(new TestConnector().runBackground(io)).resolves.toBe(1);
expect(io.writeErr).toHaveBeenCalledWith("launch failed");
});
it("returns success only after a detached process receives a pid", async () => {
mocks.spawnDetachedConnector.mockReturnValue(42);
let reads = 0;
await expect(
new TestConnector().runBackground(io, {
readState: () => (++reads > 1 ? { pid: 42 } : undefined),
isRunning: () => true,
}),
).resolves.toBe(0);
expect(io.writeln).toHaveBeenCalledWith("started 42");
});
it("fails when the detached child exits before becoming ready", async () => {
mocks.spawnDetachedConnector.mockReturnValue(42);
mocks.isProcessRunning.mockReturnValue(false);
await expect(new TestConnector().runBackground(io)).resolves.toBe(1);
expect(io.writeErr).toHaveBeenCalledWith(
"launch failed: child exited before becoming ready",
);
});
it("terminates a detached child that never becomes ready", async () => {
mocks.spawnDetachedConnector.mockReturnValue(42);
await expect(
new TestConnector().runBackground(io, { startupTimeoutMs: 0 }),
).resolves.toBe(1);
expect(mocks.terminateProcess).toHaveBeenCalledWith(42);
expect(io.writeErr).toHaveBeenCalledWith(
"launch failed: timed out after 0ms",
);
});
it("returns a distinct result when a connector is already running", async () => {
await expect(
new TestConnector().runBackground(io, {
readState: () => ({ pid: 99 }),
isRunning: () => true,
}),
).resolves.toBe(CONNECT_ALREADY_RUNNING_EXIT_CODE);
expect(io.writeln).toHaveBeenCalledWith("already running");
expect(mocks.spawnDetachedConnector).not.toHaveBeenCalled();
});
it("keeps state and reports failure when the process survives termination", async () => {
const connector = new TestConnector();
const removeStateFile = vi.spyOn(
connector as unknown as { removeStateFile: (path: string) => void },
"removeStateFile",
);
const stopSessions = vi.fn(async () => 1);
const clearBindings = vi.fn();
mocks.terminateProcess.mockResolvedValue(false);
mocks.isProcessRunning.mockReturnValue(true);
await expect(
connector.stopProcess(io, {
statePath: "/tmp/test-connector.json",
readState: () => ({ pid: 42 }),
stopSessions,
clearBindings,
}),
).resolves.toEqual({
stoppedProcesses: 0,
failedProcesses: 1,
stoppedSessions: 0,
});
expect(removeStateFile).not.toHaveBeenCalled();
expect(stopSessions).not.toHaveBeenCalled();
expect(clearBindings).not.toHaveBeenCalled();
expect(io.writeErr).toHaveBeenCalledWith(
"[connect] failed to stop connector process pid=42",
);
});
it("cleans stale state after confirming the process is already gone", async () => {
const connector = new TestConnector();
const removeStateFile = vi.spyOn(
connector as unknown as { removeStateFile: (path: string) => void },
"removeStateFile",
);
const stopSessions = vi.fn(async () => 1);
const clearBindings = vi.fn();
mocks.terminateProcess.mockResolvedValue(false);
mocks.isProcessRunning.mockReturnValue(false);
await expect(
connector.stopProcess(io, {
statePath: "/tmp/test-connector.json",
readState: () => ({ pid: 42 }),
stopSessions,
clearBindings,
}),
).resolves.toEqual({
stoppedProcesses: 0,
failedProcesses: 0,
stoppedSessions: 1,
});
expect(removeStateFile).toHaveBeenCalledWith("/tmp/test-connector.json");
expect(stopSessions).toHaveBeenCalledWith({ pid: 42 });
expect(clearBindings).toHaveBeenCalledWith({ pid: 42 });
});
});
+86 -11
View File
@@ -3,6 +3,7 @@ import { join } from "node:path";
import { resolveClineDataDir } from "@cline/core";
import { Command, CommanderError } from "commander";
import {
CONNECT_ALREADY_RUNNING_EXIT_CODE,
isProcessRunning,
readJsonFile,
removeFile,
@@ -13,15 +14,19 @@ import {
import type {
ConnectCommandDefinition,
ConnectIo,
ConnectRunContext,
ConnectStopResult,
} from "./types";
const SHOW_HELP_ERROR = "__SHOW_HELP__";
const CONNECTOR_STARTUP_TIMEOUT_MS = 15_000;
const CONNECTOR_STARTUP_POLL_MS = 100;
export abstract class ConnectorBase<Options, State>
implements ConnectCommandDefinition
{
stopAll?(io: ConnectIo): Promise<ConnectStopResult>;
stopInstance?(instanceId: string, io: ConnectIo): Promise<ConnectStopResult>;
constructor(
public readonly name: string,
@@ -41,8 +46,16 @@ export abstract class ConnectorBase<Options, State>
options: Options,
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number>;
protected async validateOptions(
_options: Options,
_io: ConnectIo,
): Promise<number> {
return 0;
}
showHelp(io: ConnectIo): void {
const output = this.createCommand().helpInformation().trimEnd();
for (const line of output.split("\n")) {
@@ -50,7 +63,11 @@ export abstract class ConnectorBase<Options, State>
}
}
async run(rawArgs: string[], io: ConnectIo): Promise<number> {
async run(
rawArgs: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number> {
let options: Options;
try {
options = this.parseArgs(rawArgs);
@@ -63,7 +80,27 @@ export abstract class ConnectorBase<Options, State>
io.writeErr(message);
return 1;
}
return this.runWithOptions(options, rawArgs, io);
const validationExitCode = await this.validateOptions(options, io);
if (validationExitCode !== 0) {
return validationExitCode;
}
return this.runWithOptions(options, rawArgs, io, context);
}
async validate(rawArgs: string[], io: ConnectIo): Promise<number> {
let options: Options;
try {
options = this.parseArgs(rawArgs);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message === SHOW_HELP_ERROR) {
this.showHelp(io);
return 0;
}
io.writeErr(message);
return 1;
}
return await this.validateOptions(options, io);
}
protected parseArgs(rawArgs: string[]): Options {
@@ -145,14 +182,15 @@ export abstract class ConnectorBase<Options, State>
formatBackgroundStartMessage: (pid: number) => string;
foregroundHint: string;
launchFailureMessage: string;
}): Promise<boolean> {
startupTimeoutMs?: number;
}): Promise<number | undefined> {
if (input.interactive || process.env[input.childEnvVar] === "1") {
return false;
return undefined;
}
const runningState = input.readState(input.statePath);
if (runningState && input.isRunning(runningState)) {
input.io.writeln(input.formatAlreadyRunningMessage(runningState));
return true;
return CONNECT_ALREADY_RUNNING_EXIT_CODE;
}
const pid = spawnDetachedConnector(
["connect", this.name],
@@ -161,11 +199,32 @@ export abstract class ConnectorBase<Options, State>
);
if (!pid) {
input.io.writeErr(input.launchFailureMessage);
return true;
return 1;
}
input.io.writeln(input.formatBackgroundStartMessage(pid));
input.io.writeln(input.foregroundHint);
return true;
const startedAt = Date.now();
const timeoutMs = input.startupTimeoutMs ?? CONNECTOR_STARTUP_TIMEOUT_MS;
while (Date.now() - startedAt < timeoutMs) {
const state = input.readState(input.statePath);
if (state && input.isRunning(state)) {
return 0;
}
if (!isProcessRunning(pid)) {
input.io.writeErr(
`${input.launchFailureMessage}: child exited before becoming ready`,
);
return 1;
}
await new Promise((resolve) =>
setTimeout(resolve, CONNECTOR_STARTUP_POLL_MS),
);
}
await terminateProcess(pid);
input.io.writeErr(
`${input.launchFailureMessage}: timed out after ${timeoutMs}ms`,
);
return 1;
}
protected async stopAllFromStatePaths(
@@ -177,13 +236,15 @@ export abstract class ConnectorBase<Options, State>
) => Promise<ConnectStopResult>,
): Promise<ConnectStopResult> {
let stoppedProcesses = 0;
let failedProcesses = 0;
let stoppedSessions = 0;
for (const statePath of statePaths) {
const result = await stopInstance(statePath, io);
stoppedProcesses += result.stoppedProcesses;
failedProcesses += result.failedProcesses;
stoppedSessions += result.stoppedSessions;
}
return { stoppedProcesses, stoppedSessions };
return { stoppedProcesses, failedProcesses, stoppedSessions };
}
protected async stopManagedProcess(input: {
@@ -198,17 +259,31 @@ export abstract class ConnectorBase<Options, State>
const state = input.readState(input.statePath);
if (!state) {
this.removeStateFile(input.statePath);
return { stoppedProcesses: 0, stoppedSessions: 0 };
return {
stoppedProcesses: 0,
failedProcesses: 0,
stoppedSessions: 0,
};
}
const pid = input.getPid(state);
let stoppedProcesses = 0;
if (await terminateProcess(input.getPid(state))) {
if (await terminateProcess(pid)) {
stoppedProcesses = 1;
input.io.writeln(input.describeStoppedProcess(state));
} else if (isProcessRunning(pid)) {
input.io.writeErr(
`[connect] failed to stop connector process pid=${pid}`,
);
return {
stoppedProcesses: 0,
failedProcesses: 1,
stoppedSessions: 0,
};
}
const stoppedSessions = await input.stopSessions(state);
input.clearBindings?.(state);
this.removeStateFile(input.statePath);
return { stoppedProcesses, stoppedSessions };
return { stoppedProcesses, failedProcesses: 0, stoppedSessions };
}
protected parseOptionalInteger(
+18
View File
@@ -83,6 +83,24 @@ describe("spawnDetachedConnector", () => {
],
});
});
it("marks detached children and removes the hub-daemon-only environment flag", () => {
const env = {
CLINE_BUILD_ENV: "production",
CLINE_RUN_AS_HUB_DAEMON: "1",
UNCHANGED: "value",
};
expect(
__test__.buildDetachedConnectorEnv("CLINE_TELEGRAM_CONNECT_CHILD", env),
).toEqual({
CLINE_BUILD_ENV: "production",
CLINE_CONNECTOR_DETACHED_CHILD: "1",
CLINE_TELEGRAM_CONNECT_CHILD: "1",
UNCHANGED: "value",
});
expect(env.CLINE_RUN_AS_HUB_DAEMON).toBe("1");
});
});
describe("readSessionReplyText", () => {
+29 -5
View File
@@ -10,11 +10,24 @@ import {
import { join } from "node:path";
import type { HubSessionClient, HubSessionRow } from "@cline/core";
import { ensureParentDir, resolveClineDataDir } from "@cline/core";
import { withResolvedClineBuildEnv } from "@cline/shared";
import {
CLINE_RUN_AS_HUB_DAEMON_ENV,
withResolvedClineBuildEnv,
} from "@cline/shared";
import { createCliLoggerAdapter } from "../logging/adapter";
import { logSpawnedProcess } from "../logging/process";
import { resolveCliLaunchSpec } from "../utils/internal-launch";
export const CLINE_CONNECTOR_DETACHED_CHILD_ENV =
"CLINE_CONNECTOR_DETACHED_CHILD";
/**
* Internal success from a detached connect when an instance is already running.
* `runConnectAdapter` maps this to exit 0 without changing persisted autostart
* state.
*/
export const CONNECT_ALREADY_RUNNING_EXIT_CODE = 75;
export function parseBooleanFlag(rawArgs: string[], flag: string): boolean {
return rawArgs.includes(flag);
}
@@ -123,6 +136,19 @@ function buildDetachedConnectorCommand(
};
}
function buildDetachedConnectorEnv(
childEnvKey: string,
env: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
const childEnv = {
...withResolvedClineBuildEnv(env),
[childEnvKey]: "1",
[CLINE_CONNECTOR_DETACHED_CHILD_ENV]: "1",
};
delete childEnv[CLINE_RUN_AS_HUB_DAEMON_ENV];
return childEnv;
}
export function resolveConnectorDebugLogPath(
adapterName: string,
instanceKey: string,
@@ -190,10 +216,7 @@ export function spawnDetachedConnector(
detachedLogFd === undefined
? "ignore"
: ["ignore", detachedLogFd, detachedLogFd],
env: {
...withResolvedClineBuildEnv(process.env),
[childEnvKey]: "1",
},
env: buildDetachedConnectorEnv(childEnvKey),
// Prevent a console window from appearing on Windows; detached
// processes otherwise allocate a new visible console.
windowsHide: true,
@@ -245,6 +268,7 @@ export function spawnDetachedConnector(
export const __test__ = {
buildDetachedConnectorArgs,
buildDetachedConnectorCommand,
buildDetachedConnectorEnv,
};
export function readJsonFile<T>(path: string, fallback: T): T {
+13 -1
View File
@@ -5,13 +5,25 @@ export type ConnectIo = {
export type ConnectStopResult = {
stoppedProcesses: number;
failedProcesses: number;
stoppedSessions: number;
};
export type ConnectRunContext = {
setPersistenceArgs: (args: string[]) => void;
setPersistenceInstanceId: (instanceId: string) => void;
};
export interface ConnectCommandDefinition {
name: string;
description: string;
run(args: string[], io: ConnectIo): Promise<number>;
run(
args: string[],
io: ConnectIo,
context: ConnectRunContext,
): Promise<number>;
validate(args: string[], io: ConnectIo): Promise<number>;
showHelp(io: ConnectIo): void;
stopAll?(io: ConnectIo): Promise<ConnectStopResult>;
stopInstance?(instanceId: string, io: ConnectIo): Promise<ConnectStopResult>;
}
+20 -6
View File
@@ -1,13 +1,19 @@
#!/usr/bin/env bun
import { isMainThread } from "node:worker_threads";
import { disposeAll, initVcr, isHubDaemonProcess } from "@cline/shared";
import {
disposeAll,
initVcr,
isHubDaemonProcess,
setConnectorCliLaunchSpec,
} from "@cline/shared";
import { logCliProcessError } from "./logging/errors";
import {
abortActiveRuntime,
cleanupActiveRuntime,
isAbortInProgress,
} from "./runtime/active-runtime";
import { resolveCliLaunchSpec } from "./utils/internal-launch";
import { writeErr } from "./utils/output";
// Initialize VCR before any HTTP requests are made.
@@ -16,7 +22,20 @@ initVcr(process.env.CLINE_VCR);
if (!isMainThread) {
// Worker imports of the bundled CLI entrypoint should not start the CLI.
} else if (isHubDaemonProcess()) {
// The hub daemon owns its process-level abort handling. Installing the CLI's
// fatal rejection handler first would make expected abort rejections exit it.
void import("@cline/core/hub/daemon-entry");
} else {
const cliLaunchSpec = resolveCliLaunchSpec({ debugRole: "connector" });
if (cliLaunchSpec) {
setConnectorCliLaunchSpec({
launcher: cliLaunchSpec.launcher,
connectArgsPrefix: [...cliLaunchSpec.childArgsPrefix, "connect"],
cwd: process.cwd(),
});
}
let shuttingDown = false;
let handlingFatalProcessError = false;
const forwardSignalToRuntime = () => {
@@ -57,11 +76,6 @@ if (!isMainThread) {
});
void (async () => {
if (isHubDaemonProcess()) {
await import("@cline/core/hub/daemon-entry");
return;
}
let exitCode = 0;
try {
const { runCli } = await import("./main");
+257 -4
View File
@@ -1,4 +1,6 @@
import { fstatSync } from "node:fs";
import { fstatSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
CliMigrationNotice,
@@ -18,6 +20,7 @@ vi.mock("node:fs", async () => {
const originalArgv = [...process.argv];
const originalStdinIsTTY = process.stdin.isTTY;
const originalStdoutIsTTY = process.stdout.isTTY;
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
const mockState = vi.hoisted(() => ({
runAgentImports: 0,
runInteractiveImports: 0,
@@ -61,6 +64,13 @@ const kanbanMocks = vi.hoisted(() => ({
const dashboardMocks = vi.hoisted(() => ({
runDashboardCommand: vi.fn(),
}));
const connectMocks = vi.hoisted(() => ({
formatAdapterList: vi.fn(() => ""),
runConnectAdapter: vi.fn(async () => 0),
runRestartConnector: vi.fn(async () => 0),
runStopAllConnectors: vi.fn(async () => 0),
runStopConnector: vi.fn(async () => 0),
}));
const migrationNoticeMocks = vi.hoisted(() => ({
getClineCliMigrationNotice: vi.fn<
(
@@ -198,6 +208,7 @@ vi.mock("./runtime/prompt", () => ({
}));
vi.mock("./commands/kanban", () => kanbanMocks);
vi.mock("./commands/dashboard", () => dashboardMocks);
vi.mock("./commands/connect", () => connectMocks);
vi.mock("./kanban-migration/notice", () => migrationNoticeMocks);
vi.mock("./commands/update", () => updateMocks);
vi.mock("./commands/history", () => historyMocks);
@@ -208,8 +219,17 @@ vi.mock("./utils/telemetry", () => telemetryMocks);
vi.mock("./utils/worktree", () => worktreeMocks);
describe("runCli lightweight command dispatch", () => {
let globalSettingsRoot: string | undefined;
beforeEach(() => {
process.exitCode = undefined;
// Startup now reads persisted general settings; point the resolver at a
// fresh temp file so the developer's real settings cannot leak in.
globalSettingsRoot = mkdtempSync(join(tmpdir(), "cline-cli-main-test-"));
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
globalSettingsRoot,
"global-settings.json",
);
mockState.runAgentImports = 0;
mockState.runInteractiveImports = 0;
mockState.runAgentCalls = 0;
@@ -273,6 +293,16 @@ describe("runCli lightweight command dispatch", () => {
kanbanMocks.launchKanban.mockResolvedValue(0);
dashboardMocks.runDashboardCommand.mockReset();
dashboardMocks.runDashboardCommand.mockResolvedValue(0);
connectMocks.formatAdapterList.mockReset();
connectMocks.formatAdapterList.mockReturnValue("");
connectMocks.runConnectAdapter.mockReset();
connectMocks.runConnectAdapter.mockResolvedValue(0);
connectMocks.runRestartConnector.mockReset();
connectMocks.runRestartConnector.mockResolvedValue(0);
connectMocks.runStopAllConnectors.mockReset();
connectMocks.runStopAllConnectors.mockResolvedValue(0);
connectMocks.runStopConnector.mockReset();
connectMocks.runStopConnector.mockResolvedValue(0);
migrationNoticeMocks.getClineCliMigrationNotice.mockReset();
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(undefined);
migrationNoticeMocks.markClineCliMigrationNoticeShown.mockReset();
@@ -299,6 +329,16 @@ describe("runCli lightweight command dispatch", () => {
afterEach(() => {
process.exitCode = undefined;
if (originalGlobalSettingsPath === undefined) {
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
} else {
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
}
if (globalSettingsRoot) {
rmSync(globalSettingsRoot, { recursive: true, force: true });
globalSettingsRoot = undefined;
}
process.argv = [...originalArgv];
Object.defineProperty(process.stdin, "isTTY", {
value: originalStdinIsTTY,
@@ -333,6 +373,55 @@ describe("runCli lightweight command dispatch", () => {
expect(historyListCalls[0]?.[0]).not.toHaveProperty("workspaceRoot");
expect(mockState.runAgentImports).toBe(0);
expect(mockState.runInteractiveImports).toBe(0);
}, 30_000);
it("routes connector restart arguments through the restart lifecycle", async () => {
process.argv = [
"bun",
"src/index.ts",
"connect",
"--restart",
"telegram",
"-k",
"token",
];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(0);
expect(connectMocks.runRestartConnector).toHaveBeenCalledWith(
"telegram",
["-k", "token"],
expect.any(Object),
undefined,
);
expect(connectMocks.runConnectAdapter).not.toHaveBeenCalled();
expect(connectMocks.runStopConnector).not.toHaveBeenCalled();
});
it("routes a targeted connector restart to one instance", async () => {
process.argv = [
"bun",
"src/index.ts",
"connect",
"--restart-instance",
"cline_bot",
"telegram",
"-k",
"token",
];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(0);
expect(connectMocks.runRestartConnector).toHaveBeenCalledWith(
"telegram",
["-k", "token"],
expect.any(Object),
"cline_bot",
);
});
it("does not load runtime modules for root update", async () => {
@@ -848,6 +937,172 @@ describe("runCli lightweight command dispatch", () => {
);
});
describe("persisted general settings at startup", () => {
function writePersistedSettings(settings: Record<string, unknown>) {
const path = process.env.CLINE_GLOBAL_SETTINGS_PATH;
if (!path) {
throw new Error("CLINE_GLOBAL_SETTINGS_PATH is not set");
}
writeFileSync(path, JSON.stringify(settings));
}
it("restores the persisted plan mode when no mode flag is provided", async () => {
writePersistedSettings({ planActMode: "plan" });
promptMocks.resolveSystemPrompt.mockClear();
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({ mode: "plan" }),
expect.anything(),
undefined,
expect.any(Object),
);
expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith(
expect.objectContaining({ mode: "plan" }),
);
});
it("prefers an explicit --act flag over the persisted plan mode", async () => {
writePersistedSettings({ planActMode: "plan" });
promptMocks.resolveSystemPrompt.mockClear();
process.argv = ["bun", "src/index.ts", "--act"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({ mode: "act" }),
expect.anything(),
undefined,
expect.any(Object),
);
expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith(
expect.objectContaining({ mode: "act" }),
);
});
it("restores the persisted auto-approve setting as a runtime policy", async () => {
writePersistedSettings({ toolAutoApprove: false });
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({
defaultToolAutoApprove: true,
toolPolicies: {
"*": { autoApprove: false },
},
}),
expect.anything(),
undefined,
expect.any(Object),
);
});
it("prefers an explicit --auto-approve flag over the persisted setting", async () => {
writePersistedSettings({ toolAutoApprove: false });
process.argv = ["bun", "src/index.ts", "--auto-approve", "true"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({
toolPolicies: {
"*": { autoApprove: true },
},
}),
expect.anything(),
undefined,
expect.any(Object),
);
});
it("restores disabled compaction across restarts", async () => {
writePersistedSettings({
compactionEnabled: false,
compactionStrategy: "basic",
});
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({
compaction: { enabled: false },
}),
expect.anything(),
undefined,
expect.any(Object),
);
});
it("restores the persisted compaction strategy across restarts", async () => {
writePersistedSettings({
compactionEnabled: true,
compactionStrategy: "basic",
});
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({
compaction: { enabled: true, strategy: "basic" },
}),
expect.anything(),
undefined,
expect.any(Object),
);
});
it("prefers an explicit --compaction flag over the persisted mode", async () => {
writePersistedSettings({ compactionEnabled: false });
process.argv = ["bun", "src/index.ts", "--compaction", "agentic"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({
compaction: { enabled: true, strategy: "agentic" },
}),
expect.anything(),
undefined,
expect.any(Object),
);
});
it("applies persisted settings to single-prompt runs as well", async () => {
writePersistedSettings({
compactionEnabled: true,
compactionStrategy: "basic",
planActMode: "plan",
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
expect.objectContaining({
compaction: { enabled: true, strategy: "basic" },
mode: "plan",
}),
expect.anything(),
);
});
});
it("forces chat view when resuming a session", async () => {
process.argv = ["bun", "src/index.ts", "--id", "sess_123"];
@@ -1302,7 +1557,6 @@ describe("runCli lightweight command dispatch", () => {
expect.objectContaining({
compaction: {
enabled: true,
strategy: "basic",
},
thinking: true,
reasoningEffort: "medium",
@@ -1389,7 +1643,7 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("enables truncation compaction by default for prompt runs", async () => {
it("uses Core's agentic compaction default for prompt runs", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
@@ -1404,7 +1658,6 @@ describe("runCli lightweight command dispatch", () => {
expect.objectContaining({
compaction: {
enabled: true,
strategy: "basic",
},
}),
expect.anything(),
+51 -12
View File
@@ -43,6 +43,11 @@ import {
normalizeProviderId,
} from "./utils/provider-auth";
import { resolveCliReasoning } from "./utils/reasoning";
import {
resolveStartupCompactionMode,
resolveStartupMode,
resolveStartupToolAutoApprove,
} from "./utils/startup-settings";
import { rewriteTeamPrompt, TEAM_COMMAND_USAGE } from "./utils/team-command";
import {
captureCliExtensionActivated,
@@ -362,6 +367,11 @@ export async function runCli(): Promise<void> {
.description("Connect to an external channel")
.argument("[channel]", "Channel to connect Cline CLI to")
.option("--stop", "Kill all current channel connections")
.option("--restart", "Restart a channel connection")
.option(
"--restart-instance <id>",
"Restart one connector instance (used by daemon recovery)",
)
.allowUnknownOption()
.passThroughOptions()
.addHelpText(
@@ -372,16 +382,32 @@ export async function runCli(): Promise<void> {
const {
formatAdapterList,
runConnectAdapter,
runRestartConnector,
runStopAllConnectors,
runStopConnector,
} = await import("./commands/connect");
const opts = connectCmd.opts();
if (opts.stop) {
if (opts.stop && (opts.restart || opts.restartInstance)) {
io.writeErr("connect accepts only one of --stop or --restart");
ctx.exitCode = 1;
} else if (opts.stop) {
if (adapter) {
ctx.exitCode = await runStopConnector(adapter, io);
} else {
ctx.exitCode = await runStopAllConnectors(io);
}
} else if (opts.restart || opts.restartInstance) {
if (!adapter) {
io.writeErr("connect --restart requires a channel");
ctx.exitCode = 1;
} else {
ctx.exitCode = await runRestartConnector(
adapter,
connectCmd.args.slice(1),
io,
opts.restartInstance,
);
}
} else if (adapter) {
// connectCmd.args = [adapter, ...passthroughFlags]. Pass only the
// connector-specific flags (everything after the adapter name).
@@ -844,14 +870,6 @@ export async function runCli(): Promise<void> {
}
}
setCurrentOutputMode(args.outputMode);
const defaultToolAutoApprove = true;
const effectiveToolAutoApprove =
args.autoApproveOverride ?? defaultToolAutoApprove;
const toolPolicies: Record<string, ToolPolicy> = {
"*": {
autoApprove: effectiveToolAutoApprove,
},
};
if (args.outputMode === "json" && (args.interactive || !args.prompt)) {
writeErr(
@@ -928,6 +946,27 @@ export async function runCli(): Promise<void> {
runAgent,
} = await loadCliRuntimeModules();
// General settings toggled in the TUI /settings panel persist to the
// global settings file; explicit CLI flags take precedence over the
// persisted values, which in turn override the built-in defaults.
const persistedGlobalSettings = coreServer.readGlobalSettings();
const defaultToolAutoApprove = true;
const effectiveToolAutoApprove = resolveStartupToolAutoApprove(
args,
persistedGlobalSettings,
defaultToolAutoApprove,
);
const toolPolicies: Record<string, ToolPolicy> = {
"*": {
autoApprove: effectiveToolAutoApprove,
},
};
const effectiveMode = resolveStartupMode(args, persistedGlobalSettings);
const effectiveCompactionMode = resolveStartupCompactionMode(
args,
persistedGlobalSettings,
);
// Register the SDK early logger as early as possible — before any
// provider settings reads — so the full startup sequence is captured.
// These components operate before/outside ClineCore sessions, so the
@@ -1086,13 +1125,13 @@ export async function runCli(): Promise<void> {
cwd,
explicitSystemPrompt: args.systemPrompt,
providerId: provider,
mode: args.mode ?? "act",
mode: effectiveMode,
}),
execution: {
maxConsecutiveMistakes: args.retries ?? 3,
},
checkpoint: CLI_DEFAULT_CHECKPOINT_CONFIG,
compaction: buildCliCompactionConfig(args.compactionMode),
compaction: buildCliCompactionConfig(effectiveCompactionMode),
timeoutSeconds: args.timeoutSeconds,
sandbox: sandboxEnabled,
sandboxDataDir,
@@ -1100,7 +1139,7 @@ export async function runCli(): Promise<void> {
thinking: resolvedReasoning.thinking,
reasoningEffort: resolvedReasoning.reasoningEffort,
outputMode: args.outputMode,
mode: args.mode,
mode: effectiveMode,
logger: loggerAdapter.core,
loggerConfig: loggerAdapter.runtimeConfig,
telemetry: getCliTelemetryService(loggerAdapter.core),
+5 -8
View File
@@ -2,7 +2,7 @@ let activeRuntimeAbort: (() => void) | undefined;
let activeRuntimeCleanup: (() => void) | undefined;
let abortGraceTimer: ReturnType<typeof setTimeout> | undefined;
let abortInProgress = false;
let savedRejectionListeners: Function[] | undefined;
let savedRejectionListeners: Array<(...args: unknown[]) => void> | undefined;
export function setActiveRuntimeAbort(abortFn: (() => void) | undefined): void {
activeRuntimeAbort = abortFn;
@@ -49,9 +49,9 @@ export function markAbortInProgress(): void {
// rejections in the LLM streaming layer that reach every registered
// listener (including OpenTUI's error overlay). Swapping the listeners
// is the only way to prevent them from surfacing to the user.
savedRejectionListeners = process.rawListeners(
"unhandledRejection",
) as Function[];
savedRejectionListeners = process.rawListeners("unhandledRejection") as Array<
(...args: unknown[]) => void
>;
process.removeAllListeners("unhandledRejection");
process.on("unhandledRejection", (_reason, promise) => {
promise.catch(() => {});
@@ -68,10 +68,7 @@ export function clearAbortInProgress(): void {
if (savedRejectionListeners) {
process.removeAllListeners("unhandledRejection");
for (const listener of savedRejectionListeners) {
process.on(
"unhandledRejection",
listener as (...args: unknown[]) => void,
);
process.on("unhandledRejection", listener);
}
savedRejectionListeners = undefined;
}
@@ -12,6 +12,25 @@ import {
resolveCompactionProviderConfig,
} from "./compaction";
const createHandlerMock = vi.fn();
// Core defaults to the agentic compaction strategy, which summarizes via a
// real LLM handler. Stub only `createHandlerAsync` so no network call (or API
// key) is needed; every other `@cline/llms` export stays real because
// `@cline/core` re-exports them.
vi.mock("@cline/llms", async (importOriginal) => ({
...(await importOriginal<typeof import("@cline/llms")>()),
createHandlerAsync: (config: unknown) => createHandlerMock(config),
}));
async function* streamChunks(
chunks: Array<Record<string, unknown>>,
): AsyncGenerator<Record<string, unknown>> {
for (const chunk of chunks) {
yield chunk;
}
}
function createConfig(): Config {
return {
providerId: "anthropic",
@@ -46,6 +65,7 @@ function createProviderSettingsManager(): ProviderSettingsManager {
}
afterEach(() => {
createHandlerMock.mockReset();
for (const tempDir of providerSettingsTempDirs.splice(0)) {
rmSync(tempDir, { force: true, recursive: true });
}
@@ -163,6 +183,15 @@ describe("compactInteractiveMessages", () => {
});
it("uses a useful target budget for manual compaction", async () => {
const mockSummary = "## Goal\nMocked agentic compaction summary";
createHandlerMock.mockReturnValue({
createMessage: vi.fn(() =>
streamChunks([
{ type: "text", id: "summary-1", text: mockSummary },
{ type: "done", id: "summary-1", success: true },
]),
),
});
const longText = "x".repeat(16_000);
const messages = Array.from({ length: 10 }, (_, index) => ({
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
@@ -189,6 +218,17 @@ describe("compactInteractiveMessages", () => {
expect(compactedMessages.length).toBeGreaterThan(1);
expect(compactedMessages.length).toBeLessThan(messages.length);
expect(compactedTextLength).toBeGreaterThan(1_000);
// The agentic strategy folds older messages into a summary message
// built from the (mocked) summarizer output.
expect(createHandlerMock).toHaveBeenCalledTimes(1);
const [summaryMessage] = compactedMessages;
const summaryText = Array.isArray(summaryMessage?.content)
? summaryMessage.content
.map((block) => ("text" in block ? block.text : ""))
.join("\n")
: String(summaryMessage?.content ?? "");
expect(summaryText).toContain(mockSummary);
});
it("reports compaction when core returns changed messages with the same count", async () => {
+38 -48
View File
@@ -55,54 +55,44 @@ const CLI_CLINE_PASS_LIMIT_MESSAGE = [
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass";
vi.mock("@cline/core", () => ({
getClineOrgIndividualInferenceSubscriptionMessage: () =>
CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE,
getClinePassSubscriptionUrl: () => CLINE_PASS_SUBSCRIPTION_URL,
isClineNotSubscribedError: (error: unknown) =>
error instanceof Error && error.name === "ClineNotSubscribedError",
isClineNotSubscribedMessage: (text: string) =>
text
.toLowerCase()
.includes("the user is not subscribed to required model plan"),
isClineOrgIndividualInferenceSubscriptionError: (error: unknown) =>
error instanceof Error &&
error.name === "ClineOrgIndividualInferenceSubscriptionError",
isClineOrgIndividualInferenceSubscriptionMessage: (text: string) =>
text
.toLowerCase()
.includes(
"organization accounts cannot use individual model inference subscriptions",
),
isClinePassLimitError: (error: unknown) =>
error instanceof Error && error.name === "ClinePassLimitError",
extractClinePassLimitMessage: (text: string) => {
const normalized = text.toLowerCase();
const prefix = "you have reached your";
const suffix = "please try again later.";
const start = normalized.indexOf(prefix);
if (start === -1) return undefined;
const suffixStart = normalized.indexOf(suffix, start);
if (suffixStart === -1) return undefined;
const end = suffixStart + suffix.length;
if (!normalized.slice(start, end).includes("clinepass limit")) {
return undefined;
}
return text.slice(start, end);
},
isClinePassLimitMessage: (text: string) => {
const normalized = text.toLowerCase();
return (
normalized.includes("you have reached your") &&
normalized.includes("clinepass limit") &&
normalized.includes("please try again later.")
);
},
prewarmFileIndex: vi.fn(async () => undefined),
SessionSource: {
CLI: "cli",
},
}));
vi.mock(
"@cline/core",
async (importActual: () => Promise<typeof import("@cline/core")>) => ({
...(await importActual()),
getClineOrgIndividualInferenceSubscriptionMessage: () =>
CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE,
getClinePassSubscriptionUrl: () => CLINE_PASS_SUBSCRIPTION_URL,
isClineNotSubscribedError: (error: unknown) =>
error instanceof Error && error.name === "ClineNotSubscribedError",
isClineNotSubscribedMessage: (text: string) =>
text
.toLowerCase()
.includes("the user is not subscribed to required model plan"),
isClineOrgIndividualInferenceSubscriptionError: (error: unknown) =>
error instanceof Error &&
error.name === "ClineOrgIndividualInferenceSubscriptionError",
isClineOrgIndividualInferenceSubscriptionMessage: (text: string) =>
text
.toLowerCase()
.includes(
"organization accounts cannot use individual model inference subscriptions",
),
isClinePassLimitError: (error: unknown) =>
error instanceof Error && error.name === "ClinePassLimitError",
isClinePassLimitMessage: (text: string) => {
const normalized = text.toLowerCase();
return (
normalized.includes("you have reached your") &&
normalized.includes("clinepass limit") &&
normalized.includes("please try again later.")
);
},
prewarmFileIndex: vi.fn(async () => undefined),
SessionSource: {
CLI: "cli",
},
}),
);
vi.mock("../utils/approval", () => ({
askQuestionInTerminal: vi.fn(),
+7 -3
View File
@@ -205,7 +205,9 @@ export async function runAgent(
event.error.message.trim()
) {
displayedErrorMessages.add(
formatCliErrorMessage(event.error.message).trim(),
formatCliErrorMessage(event.error.message, {
modelId: config.modelId,
}).trim(),
);
}
handleEvent(event, config);
@@ -390,7 +392,9 @@ export async function runAgent(
}
if (result.finishReason !== "completed") {
const errorText = formatCliErrorMessage(result.text).trim();
const errorText = formatCliErrorMessage(result.text, {
modelId: config.modelId,
}).trim();
if (
errorText &&
(config.outputMode === "json" || !displayedErrorMessages.has(errorText))
@@ -411,7 +415,7 @@ export async function runAgent(
);
process.exitCode = 0;
} catch (err) {
const message = formatCliErrorMessage(err);
const message = formatCliErrorMessage(err, { modelId: config.modelId });
logCliError(config.logger, "CLI task run failed", { error: err });
writeErr(message);
process.exitCode = 1;
+8
View File
@@ -2,6 +2,9 @@ import {
getCurrentContextSize,
type ProviderSettings,
ProviderSettingsManager,
setCompactionModeGlobally,
setPlanActModeGlobally,
setToolAutoApproveGlobally,
type UserInstructionConfigService,
} from "@cline/core";
import { formatModeSwitchNotice } from "@cline/shared";
@@ -712,15 +715,20 @@ export async function runInteractive(
onTurnErrorReported: () => {},
onAutoApproveChange: (enabled) => {
setInteractiveAutoApprove(enabled);
setToolAutoApproveGlobally(enabled);
void refreshInteractiveSessionPolicies();
},
onCompactionModeChange: async (mode) => {
await sessionRuntime.ensureReady();
applyCliCompactionMode(config, mode);
setCompactionModeGlobally(mode);
await sessionRuntime.restartWithCurrentMessages();
},
onModeChange: async (mode) => {
if (!isInteractiveMode(mode)) return;
// Persist the user's choice immediately, even when the switch is
// deferred until the current turn aborts, so it survives restarts.
setPlanActModeGlobally(mode);
if (isRunning) {
pendingModeChange.current = mode;
pendingModeChange.source = "ui";
+117 -2
View File
@@ -17,14 +17,19 @@
// - Auto-approve all (Shift+Tab)
// ---------------------------------------------------------------------------
import { test } from "@microsoft/tui-test";
import { expect, test } from "@microsoft/tui-test";
import type { Terminal } from "@microsoft/tui-test/lib/terminal/term";
import { CLINE_BIN, TERMINAL_WIDE } from "../helpers/constants.js";
import { clineEnv } from "../helpers/env.js";
import {
toggleAutoApproveAll,
waitForChatReady,
} from "../helpers/page-objects/chat.js";
import { expectVisible } from "../helpers/terminal.js";
import {
expectNotVisible,
expectVisible,
typeAndSubmit,
} from "../helpers/terminal.js";
test.describe("cline (authenticated) - shows chat view", () => {
test.use({
@@ -53,3 +58,113 @@ test.describe("Auto-approve all - Shift+Tab toggle", () => {
await toggleAutoApproveAll(terminal);
});
});
test.describe("Dialog dismissal - panel is fully removed", () => {
test.use({
program: { file: CLINE_BIN, args: [] },
...TERMINAL_WIDE,
env: clineEnv("default"),
});
type Background = {
mode: number | undefined;
color: number | undefined;
};
type TerminalSnapshot = ReturnType<Terminal["serialize"]> & {
baseY: number;
};
const backgroundsEqual = (
left: Background | undefined,
right: Background | undefined,
): boolean => left?.mode === right?.mode && left?.color === right?.color;
const snapshotTerminal = (terminal: Terminal): TerminalSnapshot => ({
...terminal.serialize(),
baseY: terminal.getCursor().baseY,
});
const findTextPosition = (
terminal: Terminal,
text: string,
): { x: number; y: number } => {
const lines = terminal.getViewableBuffer();
for (let y = 0; y < lines.length; y++) {
const x = lines[y].join("").indexOf(text);
if (x !== -1) {
return { x, y };
}
}
throw new Error(`Unable to locate visible text: ${text}`);
};
const getCellBackground = (
snapshot: TerminalSnapshot,
position: { x: number; y: number },
): Background => {
const targetRow = snapshot.baseY + position.y;
let background: Background = { mode: undefined, color: undefined };
for (let y = snapshot.baseY; y <= targetRow; y++) {
for (let x = 0; x < TERMINAL_WIDE.columns; x++) {
const shift = snapshot.shifts.get(`${x},${y}`);
if (shift?.bgColorMode !== undefined) {
background = { mode: shift.bgColorMode, color: shift.bgColor };
}
if (x === position.x && y === targetRow) {
return background;
}
}
}
throw new Error(
`Cell is outside the visible terminal: ${position.x},${position.y}`,
);
};
// @opentui-ui/dialog is built against @opentui/core ^0.1.69, whose
// Renderable.remove(id) took an id. Core 0.4.x renamed it to
// remove(child) and throws on a non-renderable argument, so the
// package's removeDialog() aborted before detaching its panel — the React
// portal content unmounted, but the imperative grey box stayed on screen
// over the chat. Asserting on the panel's background (not its text) is what
// distinguishes a leaked box from a clean teardown.
test("closing the help dialog removes its grey panel", async ({
terminal,
}) => {
await waitForChatReady(terminal);
const terminalBeforeDialog = snapshotTerminal(terminal);
await typeAndSubmit(terminal, "/help");
await expectVisible(terminal, "Keyboard Shortcuts");
const dialogPosition = findTextPosition(terminal, "Keyboard Shortcuts");
const backgroundAtDialogPosition = getCellBackground(
terminalBeforeDialog,
dialogPosition,
);
const dialogBackground = getCellBackground(
snapshotTerminal(terminal),
dialogPosition,
);
expect(dialogBackground).not.toEqual(backgroundAtDialogPosition);
terminal.keyEscape();
await expectNotVisible(terminal, "Keyboard Shortcuts");
// The panel unmounts a frame after its content. Poll the title's former
// position until the background captured from the visible panel is gone.
const deadline = Date.now() + 10_000;
let backgroundAfterDialog = getCellBackground(
snapshotTerminal(terminal),
dialogPosition,
);
while (
!backgroundsEqual(backgroundAfterDialog, backgroundAtDialogPosition) &&
Date.now() < deadline
) {
await new Promise((resolve) => setTimeout(resolve, 100));
backgroundAfterDialog = getCellBackground(
snapshotTerminal(terminal),
dialogPosition,
);
}
expect(backgroundAfterDialog).toEqual(backgroundAtDialogPosition);
});
});
+85 -10
View File
@@ -1,4 +1,7 @@
import type { ClineSubscriptionPlan } from "@cline/core";
import {
type ClineSubscriptionPlan,
extractClineFreeModelLimitResetTime,
} from "@cline/core";
import { useTerminalDimensions } from "@opentui/react";
import type React from "react";
import { useEffect, useState } from "react";
@@ -8,6 +11,8 @@ import {
getClinePassLimitDetailMessage,
getCliSubscriptionUrl,
getIndividualPlanFeatures,
isClineFreeModelLimitErrorMessage,
isClineFreePromotionEndedErrorMessage,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassLimitErrorMessage,
isClinePassSubscriptionError,
@@ -29,6 +34,7 @@ import { formatCompactionDividerLabel } from "../utils/compaction-status";
import { getSyntaxStyle, type SyntaxAccentMode } from "../utils/syntax-style";
import { isWarningToolError } from "../utils/tool-errors";
import {
buildReadFilesKeys,
parseApplyPatchInput,
parseAskQuestionInput,
parseEditorInput,
@@ -129,12 +135,13 @@ function formatToolParams(
case "read_files": {
const info = parseReadFilesInput(rawInput);
if (!info?.files.length) return fallback;
const keys = buildReadFilesKeys(info.files);
return info.files.map((f, i) => {
const sl = f.startLine != null ? String(f.startLine) : "undefined";
const el = f.endLine != null ? String(f.endLine) : "undefined";
const sep = i > 0 ? "; " : "";
return (
<span key={`${i}:${f.path}`}>
<span key={keys[i]}>
{sep}
{shortenPath(f.path)}
<span fg="gray">
@@ -478,14 +485,6 @@ function ClinePassLimitErrorView(props: {
selectable
content="Switch to Cline usage-based billing and retry with the Cline provider."
/>
<box flexDirection="row">
<text fg="gray">Interactive CLI: </text>
<text
fg={props.defaultFg}
selectable
content="type /model, press tab to change provider, choose Cline, then retry."
/>
</box>
<box flexDirection="row">
<text fg="gray">Headless CLI: </text>
<text fg={props.defaultFg} selectable content="rerun with " />
@@ -502,6 +501,71 @@ function ClinePassLimitErrorView(props: {
);
}
function ClineFreeModelLimitErrorView(props: {
message: string;
defaultFg?: string;
}) {
const resetTime = extractClineFreeModelLimitResetTime(props.message);
return (
<box flexDirection="row">
<text fg={palette.act} content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={palette.act}
paddingX={1}
>
<text fg="red">Daily free model limit reached</text>
<text
fg={props.defaultFg}
selectable
content="You've reached today's free usage limit for this model."
/>
<text
fg={props.defaultFg}
selectable
content={
resetTime
? `Try again in ${resetTime} or select another model.`
: "Try again later or select another model."
}
/>
<text fg="gray">Open the model selector with /model.</text>
</box>
</box>
);
}
function ClineFreePromotionEndedErrorView(props: { defaultFg?: string }) {
return (
<box flexDirection="row">
<text fg={palette.act} content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={palette.act}
paddingX={1}
>
<text fg="red">Free model promotion ended</text>
<text
fg={props.defaultFg}
selectable
content="The free promotion for this model has ended and it is no longer available."
/>
<text
fg={props.defaultFg}
selectable
content="Select another model to continue."
/>
<text fg="gray">Open the model selector with /model.</text>
</box>
</box>
);
}
export function ChatEntryView(props: {
entry: ChatEntry;
accent?: string;
@@ -626,6 +690,17 @@ export function ChatEntryView(props: {
/>
);
}
if (isClineFreeModelLimitErrorMessage(entry.text)) {
return (
<ClineFreeModelLimitErrorView
defaultFg={defaultFg}
message={entry.text}
/>
);
}
if (isClineFreePromotionEndedErrorMessage(entry.text)) {
return <ClineFreePromotionEndedErrorView defaultFg={defaultFg} />;
}
return (
<box flexDirection="row">
<text fg="red" content="* " />
@@ -4,6 +4,7 @@ import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import type React from "react";
import { palette } from "../../palette";
import {
buildReadFilesKeys,
parseApplyPatchInput,
parseEditorInput,
parseReadFilesInput,
@@ -22,13 +23,14 @@ export function formatApprovalParams(
case "read_files": {
const info = parseReadFilesInput(rawInput);
if (!info?.files.length) break;
const keys = buildReadFilesKeys(info.files);
return info.files.map((f, i) => {
const range =
f.startLine != null
? ` lines ${f.startLine}-${f.endLine ?? "end"}`
: "";
return (
<text key={f.path} fg="gray" selectable>
<text key={keys[i]} fg="gray" selectable>
{" "}
{shortenPath(f.path, 60)}
{range && <span fg="gray">{range}</span>}
+4 -1
View File
@@ -30,6 +30,7 @@ interface AgentEventDeps {
}) => void;
onTurnErrorReported: TuiProps["onTurnErrorReported"];
verbose: boolean;
modelId?: string;
}
export function useAgentEventHandlers(deps: AgentEventDeps) {
@@ -45,6 +46,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
addUsageDelta,
onTurnErrorReported,
verbose,
modelId,
} = deps;
// Compaction dividers that arrived while an assistant message was still
@@ -224,7 +226,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
if (!event.recoverable || verbose) {
appendEntry({
kind: "error",
text: formatCliErrorMessage(event.error),
text: formatCliErrorMessage(event.error, { modelId }),
});
}
break;
@@ -289,6 +291,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
addUsageDelta,
onTurnErrorReported,
verbose,
modelId,
closeToolEntry,
finalizeDanglingCompactionEntry,
flushPendingCompactionEntries,
@@ -38,6 +38,7 @@ export function usePromptInputController(input: {
onSubmit: TuiProps["onSubmit"];
initialPrompt?: string;
providerId: string;
modelId?: string;
configVerbose: boolean;
refreshRepoStatus: () => void;
setAppView: (view: AppView) => void;
@@ -50,6 +51,7 @@ export function usePromptInputController(input: {
onSubmit,
initialPrompt,
providerId,
modelId,
configVerbose,
refreshRepoStatus,
setAppView,
@@ -377,7 +379,7 @@ export function usePromptInputController(input: {
if (!turnErrorReportedRef.current) {
session.appendEntry({
kind: "error",
text: formatCliErrorMessage(error),
text: formatCliErrorMessage(error, { modelId }),
});
}
} finally {
@@ -393,6 +395,7 @@ export function usePromptInputController(input: {
clearPasteAttachments,
configVerbose,
inputHistory,
modelId,
onSubmit,
providerId,
refreshRepoStatus,
+2
View File
@@ -724,6 +724,7 @@ function App(props: TuiProps) {
addUsageDelta: session.addUsageDelta,
onTurnErrorReported: props.onTurnErrorReported,
verbose: props.config.verbose ?? false,
modelId: props.config.modelId,
});
const promptInput = usePromptInputController({
@@ -733,6 +734,7 @@ function App(props: TuiProps) {
onSubmit: props.onSubmit,
initialPrompt: props.initialPrompt,
providerId: props.config.providerId,
modelId: props.config.modelId,
configVerbose: props.config.verbose ?? false,
refreshRepoStatus,
setAppView,
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { buildReadFilesKeys, parseReadFilesInput } from "./tool-parsing";
describe("buildReadFilesKeys", () => {
it("produces unique keys when the same path is read twice", () => {
const info = parseReadFilesInput({
files: [{ path: "/a/SKILL.md" }, { path: "/a/SKILL.md" }],
});
const keys = buildReadFilesKeys(info?.files ?? []);
expect(keys).toHaveLength(2);
expect(new Set(keys).size).toBe(keys.length);
});
it("produces unique keys for duplicate paths from the file_paths shape", () => {
const info = parseReadFilesInput({
file_paths: ["/a/SKILL.md", "/a/SKILL.md", "/b/SKILL.md"],
});
const keys = buildReadFilesKeys(info?.files ?? []);
expect(keys).toHaveLength(3);
expect(new Set(keys).size).toBe(keys.length);
});
it("keeps distinct paths in unique keys", () => {
const keys = buildReadFilesKeys([{ path: "/a.ts" }, { path: "/b.ts" }]);
expect(new Set(keys).size).toBe(2);
});
it("returns no keys for an empty list", () => {
expect(buildReadFilesKeys([])).toEqual([]);
});
});
+6
View File
@@ -91,6 +91,12 @@ export function parseReadFilesInput(input: unknown): ReadFilesInfo | undefined {
return undefined;
}
// A read_files call can list the same path more than once, so the raw path is
// not a unique React key. Prefix the array index to keep keys unique per row.
export function buildReadFilesKeys(files: { path: string }[]): string[] {
return files.map((f, i) => `${i}:${f.path}`);
}
export interface RunCommandsInfo {
commands: string[];
}
@@ -1,11 +1,14 @@
import { describe, expect, it } from "vitest";
import {
formatCliErrorMessage,
getCliClineFreeModelLimitMessage,
getCliClinePassLimitMessage,
getCliNotSubscribedMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassLimitDetailMessage,
getCliSubscriptionUrl,
isClineFreeModelLimitErrorMessage,
isClineFreePromotionEndedErrorMessage,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassLimitErrorMessage,
isClinePassSubscriptionError,
@@ -48,6 +51,9 @@ describe("cline-pass-errors", () => {
),
).toBe(true);
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
expect(formatCliErrorMessage(new Error(raw))).not.toContain(
"deepseek-v4-flash",
);
});
it("recognizes and formats ClinePass period limit errors with usage-billing guidance", () => {
@@ -67,4 +73,44 @@ describe("cline-pass-errors", () => {
);
expect(formatCliErrorMessage(new Error(raw))).toContain("--provider cline");
});
it("recognizes and formats daily free model limits without usage-billing guidance", () => {
const raw =
"Error: Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 23h 59m";
expect(isClineFreeModelLimitErrorMessage(raw)).toBe(true);
expect(isClineFreeModelLimitErrorMessage(new Error(raw))).toBe(true);
expect(formatCliErrorMessage(new Error(raw))).toBe(
getCliClineFreeModelLimitMessage(raw),
);
expect(formatCliErrorMessage(new Error(raw))).not.toContain("Error 429");
expect(formatCliErrorMessage(new Error(raw))).toContain(
"Try again in 23h 59m",
);
expect(formatCliErrorMessage(new Error(raw))).toContain(
"select another model",
);
expect(formatCliErrorMessage(new Error(raw))).not.toContain(
"usage-based billing",
);
expect(
isClineFreeModelLimitErrorMessage(getCliClineFreeModelLimitMessage(raw)),
).toBe(true);
});
it("formats model-not-found errors for removed free models", () => {
const raw = new Error("Error 404: model not found");
expect(
formatCliErrorMessage(raw, { modelId: "cline-free/retired-model" }),
).toContain("Free model promotion ended");
expect(
isClineFreePromotionEndedErrorMessage(
formatCliErrorMessage(raw, { modelId: "cline-free/retired-model" }),
),
).toBe(true);
expect(
formatCliErrorMessage(raw, { modelId: "vendor/retired-model" }),
).toBe(raw.message);
});
});
+86 -1
View File
@@ -1,7 +1,11 @@
import {
type ClineSubscriptionPlan,
extractClineFreeModelLimitResetTime,
extractClinePassLimitMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
isClineFreeModelLimitError,
isClineFreeModelLimitMessage,
isClineModelNotFoundMessage,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionError,
@@ -39,6 +43,31 @@ export function getCliClinePassLimitMessage(message: string): string {
return lines.filter((line) => line.trim().length > 0).join("\n");
}
const CLINE_FREE_MODEL_PREFIX = "cline-free/";
const CLINE_FREE_PROMOTION_ENDED_HEADER = "Free model promotion ended";
const CLINE_FREE_MODEL_LIMIT_HEADER = "Daily free model limit reached";
export function getCliClineFreePromotionEndedMessage(): string {
return [
CLINE_FREE_PROMOTION_ENDED_HEADER,
"The free promotion for this model has ended and it is no longer available.",
"Select another model to continue.",
"Open the model selector with /model.",
].join("\n");
}
export function getCliClineFreeModelLimitMessage(message: string): string {
const resetTime = extractClineFreeModelLimitResetTime(message);
return [
CLINE_FREE_MODEL_LIMIT_HEADER,
"You've reached today's free usage limit for this model.",
resetTime
? `Try again in ${resetTime} or select another model.`
: "Try again later or select another model.",
"Open the model selector with /model.",
].join("\n");
}
export function getIndividualPlanFeatures(
plans: ClineSubscriptionPlan[],
): string[] {
@@ -114,7 +143,55 @@ export function isClinePassLimitErrorMessage(error: unknown): boolean {
return typeof error === "string" && isClinePassLimitMessage(error);
}
export function formatCliErrorMessage(error: unknown): string {
// Detects that a deleted free model was requested: the backend answers "model
// not found" once a free promotion ends and the cline-free/ model is removed.
// The modelId gate keeps regular model-not-found errors on their generic path.
export function isClineFreePromotionEndedErrorMessage(
error: unknown,
modelId?: string,
): boolean {
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "";
if (
message
.toLowerCase()
.includes(CLINE_FREE_PROMOTION_ENDED_HEADER.toLowerCase())
) {
return true;
}
if (!modelId?.startsWith(CLINE_FREE_MODEL_PREFIX)) {
return false;
}
return isClineModelNotFoundMessage(message);
}
export function isClineFreeModelLimitErrorMessage(error: unknown): boolean {
if (isClineFreeModelLimitError(error)) {
return true;
}
if (error instanceof Error) {
return (
error.name === "ClineFreeModelLimitError" ||
isClineFreeModelLimitMessage(error.message)
);
}
return (
typeof error === "string" &&
(error
.toLowerCase()
.includes(CLINE_FREE_MODEL_LIMIT_HEADER.toLowerCase()) ||
isClineFreeModelLimitMessage(error))
);
}
export function formatCliErrorMessage(
error: unknown,
options?: { modelId?: string },
): string {
if (isClinePassSubscriptionError(error)) {
return getCliNotSubscribedMessage();
}
@@ -126,6 +203,14 @@ export function formatCliErrorMessage(error: unknown): string {
error instanceof Error ? error.message : String(error),
);
}
if (isClineFreeModelLimitErrorMessage(error)) {
return getCliClineFreeModelLimitMessage(
error instanceof Error ? error.message : String(error),
);
}
if (isClineFreePromotionEndedErrorMessage(error, options?.modelId)) {
return getCliClineFreePromotionEndedMessage();
}
if (error instanceof Error) {
return error.message;
}
+3 -6
View File
@@ -15,14 +15,12 @@ function createConfig(compaction?: Config["compaction"]): Config {
}
describe("CLI compaction mode helpers", () => {
it("defaults enabled compaction to basic truncation", () => {
expect(DEFAULT_CLI_COMPACTION_MODE).toBe("basic");
it("defaults enabled compaction to agentic summarization", () => {
expect(DEFAULT_CLI_COMPACTION_MODE).toBe("agentic");
expect(getCliCompactionMode(createConfig())).toBe(
DEFAULT_CLI_COMPACTION_MODE,
);
expect(formatCliCompactionMode(DEFAULT_CLI_COMPACTION_MODE)).toBe(
"Truncation",
);
expect(formatCliCompactionMode(DEFAULT_CLI_COMPACTION_MODE)).toBe("LLM");
});
it("maps basic and off modes to core compaction config", () => {
@@ -47,7 +45,6 @@ describe("CLI compaction mode helpers", () => {
it("builds default and explicit core compaction config", () => {
expect(buildCliCompactionConfig()).toEqual({
enabled: true,
strategy: "basic",
});
expect(buildCliCompactionConfig("agentic")).toEqual({
enabled: true,
+7 -6
View File
@@ -5,7 +5,7 @@ export const CLI_COMPACTION_MODES = ["basic", "agentic", "off"] as const;
export const DEFAULT_CLI_COMPACTION_MODE: Extract<
CliCompactionMode,
"agentic" | "basic"
> = "basic";
> = "agentic";
const CLI_COMPACTION_MODE_ALIASES: Record<string, CliCompactionMode> = {
agentic: "agentic",
@@ -20,7 +20,7 @@ const CLI_COMPACTION_MODE_LABELS = {
} as const satisfies Record<CliCompactionMode, string>;
export const CLI_COMPACTION_MODE_OPTION_DESCRIPTION =
"Context compaction mode: agentic|basic|off (default: basic)";
"Context compaction mode: agentic|basic|off (default: agentic)";
export const CLI_COMPACTION_MODE_EXPECTED_TEXT = '"agentic", "basic", or "off"';
@@ -31,8 +31,11 @@ export function parseCliCompactionMode(
}
export function buildCliCompactionConfig(
mode: CliCompactionMode | undefined = DEFAULT_CLI_COMPACTION_MODE,
mode?: CliCompactionMode,
): NonNullable<Config["compaction"]> {
if (mode === undefined) {
return { enabled: true };
}
if (mode === "off") {
return { enabled: false };
}
@@ -43,9 +46,7 @@ export function getCliCompactionMode(config: Config): CliCompactionMode {
if (config.compaction?.enabled === false) {
return "off";
}
return config.compaction?.strategy === "agentic"
? "agentic"
: DEFAULT_CLI_COMPACTION_MODE;
return config.compaction?.strategy ?? DEFAULT_CLI_COMPACTION_MODE;
}
export function applyCliCompactionMode(
+6 -1
View File
@@ -141,13 +141,18 @@ function captureRemoteConfigInitialized(bundle: RemoteConfigBundle): void {
export async function prepareCliEnterpriseIntegration(
input: ClineCoreStartInput,
) {
const workspacePath =
input.config.workspaceRoot?.trim() || input.config.cwd?.trim();
if (!workspacePath) {
return undefined;
}
const bundle = await loadCliRemoteConfigBundle();
if (!bundle) {
return undefined;
}
captureRemoteConfigInitialized(bundle);
return prepareRemoteConfigCoreIntegration({
workspacePath: input.config.workspaceRoot ?? input.config.cwd,
workspacePath,
pluginName: "enterprise",
controlPlane: {
name: "cline-account",
+31
View File
@@ -222,6 +222,37 @@ describe("handleEvent text formatting", () => {
expect(errorOutput).toContain("--provider cline");
});
it("formats daily free model limit agent errors before writing to stderr", () => {
handleEvent(
{
type: "error",
error: new Error(
"Error: Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 23h 59m",
),
recoverable: false,
} as unknown as AgentEvent,
{} as Config,
);
expect(errorOutput).toContain("Daily free model limit reached");
expect(errorOutput).toContain("select another model");
expect(errorOutput).not.toContain("usage-based billing");
});
it("formats removed free model errors using the configured model id", () => {
handleEvent(
{
type: "error",
error: new Error("Error 404: model not found"),
recoverable: false,
} as unknown as AgentEvent,
{ modelId: "cline-free/retired-model" } as Config,
);
expect(errorOutput).toContain("Free model promotion ended");
expect(errorOutput).toContain("Select another model");
});
it("suppresses heartbeat-only team progress messages", () => {
handleTeamEvent({
type: "run_progress",
+3 -1
View File
@@ -204,7 +204,9 @@ export function handleEvent(event: AgentEvent, config: Config): void {
case "error":
closeInlineStreamIfNeeded();
if (!event.recoverable || config.verbose) {
writeErr(formatCliErrorMessage(event.error));
writeErr(
formatCliErrorMessage(event.error, { modelId: config.modelId }),
);
}
break;
case "notice":
@@ -39,6 +39,36 @@ describe("shouldZeroClineFreeModelCost", () => {
);
});
it("matches cline-free model ids from the free endpoint bucket exactly", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
return new Response(
JSON.stringify({
free: [{ id: "cline-free/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}),
);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "cline-free/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(true);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline-pass",
modelId: "deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(false);
});
it("does not zero non-Cline providers", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
+10
View File
@@ -46,6 +46,7 @@ describe("parseArgs", () => {
interactive: false,
outputMode: "text",
mode: "act",
modeExplicitlySet: false,
sandbox: false,
acpMode: false,
thinking: false,
@@ -220,6 +221,15 @@ describe("parseArgs", () => {
expect(parsedYolo.autoApproveOverride).toBe(true);
});
it("marks explicit mode flags so persisted settings do not override them", () => {
expect(parseArgs([]).modeExplicitlySet).toBe(false);
expect(parseArgs(["Audit the repo"]).modeExplicitlySet).toBe(false);
expect(parseArgs(["--plan"]).modeExplicitlySet).toBe(true);
expect(parseArgs(["--act"]).modeExplicitlySet).toBe(true);
expect(parseArgs(["--yolo"]).modeExplicitlySet).toBe(true);
expect(parseArgs(["--zen", "do it"]).modeExplicitlySet).toBe(true);
});
it("parses --zen flag for background hub dispatch", () => {
const parsedLong = parseArgs(["--zen", "do it"]);
expect(parsedLong.mode).toBe("zen");
+116
View File
@@ -0,0 +1,116 @@
import type { GlobalSettings } from "@cline/core";
import { describe, expect, it } from "vitest";
import {
resolveStartupCompactionMode,
resolveStartupMode,
resolveStartupToolAutoApprove,
} from "./startup-settings";
function makeSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
return {
autoUpdateEnabled: true,
telemetryOptOut: false,
...overrides,
};
}
describe("resolveStartupMode", () => {
it("uses the parsed default when nothing is persisted", () => {
expect(
resolveStartupMode(
{ mode: "act", modeExplicitlySet: false },
makeSettings(),
),
).toBe("act");
});
it("restores the persisted plan/act mode when no mode flag is provided", () => {
expect(
resolveStartupMode(
{ mode: "act", modeExplicitlySet: false },
makeSettings({ planActMode: "plan" }),
),
).toBe("plan");
});
it("prefers an explicit mode flag over the persisted mode", () => {
expect(
resolveStartupMode(
{ mode: "act", modeExplicitlySet: true },
makeSettings({ planActMode: "plan" }),
),
).toBe("act");
expect(
resolveStartupMode(
{ mode: "yolo", modeExplicitlySet: true },
makeSettings({ planActMode: "plan" }),
),
).toBe("yolo");
});
});
describe("resolveStartupToolAutoApprove", () => {
it("falls back to the built-in default when nothing is persisted", () => {
expect(resolveStartupToolAutoApprove({}, makeSettings(), true)).toBe(true);
});
it("restores the persisted auto-approve setting", () => {
expect(
resolveStartupToolAutoApprove(
{},
makeSettings({ toolAutoApprove: false }),
true,
),
).toBe(false);
});
it("prefers an explicit --auto-approve flag over the persisted setting", () => {
expect(
resolveStartupToolAutoApprove(
{ autoApproveOverride: true },
makeSettings({ toolAutoApprove: false }),
true,
),
).toBe(true);
expect(
resolveStartupToolAutoApprove(
{ autoApproveOverride: false },
makeSettings({ toolAutoApprove: true }),
true,
),
).toBe(false);
});
});
describe("resolveStartupCompactionMode", () => {
it("returns undefined so Core's default applies when nothing is persisted", () => {
expect(resolveStartupCompactionMode({}, makeSettings())).toBeUndefined();
});
it("restores the persisted strategy", () => {
expect(
resolveStartupCompactionMode(
{},
makeSettings({ compactionEnabled: true, compactionStrategy: "basic" }),
),
).toBe("basic");
});
it("restores the off state even when a strategy is retained on disk", () => {
expect(
resolveStartupCompactionMode(
{},
makeSettings({ compactionEnabled: false, compactionStrategy: "basic" }),
),
).toBe("off");
});
it("prefers an explicit --compaction flag over the persisted mode", () => {
expect(
resolveStartupCompactionMode(
{ compactionMode: "agentic" },
makeSettings({ compactionEnabled: false }),
),
).toBe("agentic");
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { GlobalSettings } from "@cline/core";
import type { CliAgentMode, CliCompactionMode, ParsedArgs } from "./types";
/**
* Resolves general settings at CLI startup with the precedence
* explicit CLI flag -> persisted global setting -> built-in default,
* so choices made in the TUI /settings panel survive restarts (see
* https://github.com/cline/cline/issues/12158).
*/
export function resolveStartupMode(
args: Pick<ParsedArgs, "mode" | "modeExplicitlySet">,
settings: GlobalSettings,
): CliAgentMode {
if (args.modeExplicitlySet) {
return args.mode;
}
return settings.planActMode ?? args.mode;
}
export function resolveStartupToolAutoApprove(
args: Pick<ParsedArgs, "autoApproveOverride">,
settings: GlobalSettings,
defaultToolAutoApprove: boolean,
): boolean {
return (
args.autoApproveOverride ??
settings.toolAutoApprove ??
defaultToolAutoApprove
);
}
/**
* Returns undefined when neither a flag nor a persisted value exists, so
* callers fall through to Core's compaction default.
*/
export function resolveStartupCompactionMode(
args: Pick<ParsedArgs, "compactionMode">,
settings: GlobalSettings,
): CliCompactionMode | undefined {
if (args.compactionMode) {
return args.compactionMode;
}
if (settings.compactionEnabled === false) {
return "off";
}
return settings.compactionStrategy;
}
+2
View File
@@ -71,6 +71,8 @@ export interface ParsedArgs {
interactive: boolean;
outputMode: CliOutputMode;
mode: CliAgentMode;
/** Whether a mode flag (--plan/--act/--yolo/--zen) was explicitly provided */
modeExplicitlySet?: boolean;
timeoutSeconds?: number;
invalidTimeoutSeconds?: string;
thinking: boolean;
+5 -3
View File
@@ -1,4 +1,5 @@
import * as p from "@clack/prompts";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared";
import {
ensureSchedulerHub,
type HubScheduleClient,
@@ -135,10 +136,11 @@ async function actionCreate(client: HubScheduleClient): Promise<void> {
const mode = await p.select({
message: "Agent mode",
options: [
{ value: "yolo", label: "Yolo", hint: "execute without approvals" },
{ value: "act", label: "Act", hint: "execute tasks" },
{ value: "plan", label: "Plan", hint: "plan only" },
],
initialValue: "act",
initialValue: "yolo",
});
if (isCancel(mode)) return;
@@ -214,8 +216,8 @@ async function actionCreate(client: HubScheduleClient): Promise<void> {
cronPattern,
prompt: (prompt as string).trim(),
provider: provider ?? "cline",
model: model ?? "openai/gpt-5.3-codex",
mode: (mode as string) === "plan" ? "plan" : "act",
model: model ?? CLINE_DEFAULT_MODEL_ID,
mode: mode as "act" | "plan" | "yolo",
workspaceRoot: (workspace as string).trim(),
systemPrompt,
maxIterations,
+71 -2
View File
@@ -1,7 +1,33 @@
import { describe, expect, it } from "vitest";
import { __test__ } from "./connectors";
import {
CLINE_CONNECTOR_CLI_LAUNCH_ENV,
readConnectorCliLaunchSpec,
} from "@cline/shared";
import { afterEach, describe, expect, it } from "vitest";
import { __test__, configureConnectorCliLaunch } from "./connectors";
describe("connector launch command", () => {
const originalLaunchSpec = process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV];
afterEach(() => {
if (originalLaunchSpec === undefined) {
delete process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV];
} else {
process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV] = originalLaunchSpec;
}
});
it("registers the CLI connect command for the detached daemon", () => {
const expected = __test__.buildCliConnectCommand([]);
configureConnectorCliLaunch();
expect(readConnectorCliLaunchSpec()).toEqual({
launcher: expected.launcher,
connectArgsPrefix: expected.childArgs,
cwd: expect.any(String),
});
});
it("uses Bun conditions when launching the source CLI from Bun", () => {
expect(
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
@@ -94,4 +120,47 @@ describe("connector launch command", () => {
"Telegram rejected this bot token. Copy the token from @BotFather and try again.",
);
});
it("builds connector start arguments through the shared platform definition", () => {
expect(
__test__.buildConnectorStartArgs({
channel: "telegram",
values: { "-k": " 123456:token " },
security: {
enabled: true,
values: { userId: " 987654321 " },
},
}),
).toEqual([
"telegram",
"-k",
"123456:token",
"--allowed-user-id",
"987654321",
]);
});
it("uses the atomic restart command for an active connector", () => {
expect(
__test__.buildConnectorLaunchArgs(["telegram", "-k", "token"], "restart"),
).toEqual(["--restart", "telegram", "-k", "token"]);
});
it("starts an inactive connector directly", () => {
expect(
__test__.buildConnectorLaunchArgs(["telegram", "-k", "token"], "start"),
).toEqual(["telegram", "-k", "token"]);
});
it("rejects a channel-wide restart when multiple instances are active", () => {
expect(() => __test__.resolveConnectorLaunchMode("telegram", 2)).toThrow(
"cannot safely restart telegram: 2 instances are active",
);
});
it("rejects when connector readiness times out", async () => {
await expect(
__test__.waitForConnectorState(() => false, 0),
).rejects.toThrow("connector did not reach expected state within 0ms");
});
});
+67 -34
View File
@@ -2,13 +2,14 @@ import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { basename } from "node:path";
import process from "node:process";
import { withResolvedClineBuildEnv } from "@cline/shared";
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
import { listActiveConnectors } from "../../../cli/src/connectors/status";
import { listActiveConnectors } from "@cline/core";
import {
PLATFORMS,
shouldIncludeField,
} from "../../../cli/src/wizards/connect/platforms";
buildConnectorConnectArgs,
CONNECTOR_PLATFORMS,
listConnectorCatalog,
setConnectorCliLaunchSpec,
withResolvedClineBuildEnv,
} from "@cline/shared";
import type {
WebviewConnectorChannel,
WebviewConnectorChannelsResponse,
@@ -78,12 +79,21 @@ function buildCliConnectCommand(
return { launcher, childArgs };
}
export function configureConnectorCliLaunch(): void {
const command = buildCliConnectCommand([]);
setConnectorCliLaunchSpec({
launcher: command.launcher,
connectArgsPrefix: command.childArgs,
cwd: workspaceRoot,
});
}
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
);
const available: WebviewConnectorChannel[] = PLATFORMS.filter((platform) =>
supported.has(platform.id),
const available: WebviewConnectorChannel[] = CONNECTOR_PLATFORMS.filter(
(platform) => supported.has(platform.id),
).map((platform) => ({
id: platform.id,
name: platform.name,
@@ -154,12 +164,15 @@ async function waitForConnectorState(
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(
`connector did not reach expected state within ${timeoutMs}ms`,
);
}
function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
const channel = asString(args?.channel);
if (!channel) throw new Error("channel is required");
const platform = PLATFORMS.find((entry) => entry.id === channel);
const platform = CONNECTOR_PLATFORMS.find((entry) => entry.id === channel);
if (!platform) throw new Error(`unknown connector channel: ${channel}`);
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
@@ -177,32 +190,40 @@ function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
fieldValues[field.flag] = field.initialValue;
}
}
const cliArgs = [channel];
for (const field of platform.fields) {
if (!shouldIncludeField(field, fieldValues)) {
continue;
const securityInput = asRecord(args?.security);
const rawSecurityValues = asRecord(securityInput?.values) ?? {};
const securityValues: Record<string, string> = {};
for (const [key, value] of Object.entries(rawSecurityValues)) {
if (typeof value === "string") {
securityValues[key] = value.trim();
}
const value = fieldValues[field.flag];
if (!value) {
if (field.required) throw new Error(`${field.label} is required`);
continue;
}
cliArgs.push(field.flag, value);
}
const security = asRecord(args?.security);
if (security?.enabled === true && platform.security) {
const securityValues = asRecord(security.values) ?? {};
const hookValues: Record<string, string> = {};
for (const field of platform.security.fields) {
const value = asString(securityValues[field.key]);
if (!value) throw new Error(field.requiredMessage);
const validationError = field.validate?.(value);
if (validationError) throw new Error(validationError);
hookValues[field.key] = value;
}
cliArgs.push(...platform.security.buildArgs(hookValues));
return [
channel,
...buildConnectorConnectArgs(platform, fieldValues, {
enabled: securityInput?.enabled === true,
values: securityValues,
}),
];
}
function buildConnectorLaunchArgs(
cliArgs: string[],
mode: "start" | "restart",
): string[] {
return mode === "restart" ? ["--restart", ...cliArgs] : cliArgs;
}
function resolveConnectorLaunchMode(
channel: string,
activeCount: number,
): "start" | "restart" {
if (activeCount > 1) {
throw new Error(
`cannot safely restart ${channel}: ${activeCount} instances are active; stop the intended instances explicitly first`,
);
}
return cliArgs;
return activeCount === 1 ? "restart" : "start";
}
export async function startConnectorChannel(
@@ -210,7 +231,13 @@ export async function startConnectorChannel(
): Promise<WebviewConnectorChannelsResponse> {
const cliArgs = buildConnectorStartArgs(args);
const channel = cliArgs[0] ?? "";
const result = await runCliConnectCommand(cliArgs);
const activeCount = listActiveConnectors().filter(
(connector) => connector.type === channel,
).length;
const mode = resolveConnectorLaunchMode(channel, activeCount);
const result = await runCliConnectCommand(
buildConnectorLaunchArgs(cliArgs, mode),
);
if (result.code !== 0) {
throw new Error(
normalizeConnectorError(
@@ -227,7 +254,11 @@ export async function startConnectorChannel(
export const __test__ = {
buildCliConnectCommand,
buildConnectorLaunchArgs,
buildConnectorStartArgs,
normalizeConnectorError,
resolveConnectorLaunchMode,
waitForConnectorState,
};
export async function stopConnectorChannel(
@@ -241,7 +272,9 @@ export async function stopConnectorChannel(
if (!supported.has(channel)) {
throw new Error(`unknown connector channel: ${channel}`);
}
const result = await runCliConnectCommand([channel, "--stop"]);
// `--stop` must precede the channel: the connect command uses
// passThroughOptions, so flags after the channel go to the adapter.
const result = await runCliConnectCommand(["--stop", channel]);
if (result.code !== 0) {
throw new Error(
normalizeConnectorError(
+2
View File
@@ -12,6 +12,7 @@ import {
rejectAllPendingApprovals,
requestToolApprovalFromWebview,
} from "./approvals";
import { configureConnectorCliLaunch } from "./connectors";
import { workspaceRoot } from "./deps";
import {
formatClientName,
@@ -90,6 +91,7 @@ export async function syncHubClientsAndSessions(
}
export async function attachHub(ctx: HubContext): Promise<void> {
configureConnectorCliLaunch();
const hub = await ensureDetachedHubServer(workspaceRoot);
ctx.hubUrl = hub.url;
ctx.hubAuthToken = hub.authToken;
+46 -23
View File
@@ -3,6 +3,12 @@ import {
HubScheduleCommandService,
HubScheduleService,
} from "@cline/core";
import {
CLINE_DEFAULT_MODEL_ID,
ONE_TIME_SCHEDULE_CRON_PATTERN,
ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY,
readHubScheduleMode,
} from "@cline/shared";
import { asTrimmedString, toPositiveInt } from "./utils";
let scheduleService: HubScheduleService | undefined;
@@ -36,6 +42,31 @@ async function clientCommand(
return (reply.payload ?? {}) as Record<string, unknown>;
}
function routineScheduleTiming(
args?: Record<string, unknown>,
): { cronPattern: string; metadata?: Record<string, number> } | undefined {
if (args?.schedule_type === "once") {
const runAt =
typeof args.run_at === "number" ? args.run_at : Number(args?.run_at);
return Number.isFinite(runAt)
? {
cronPattern: ONE_TIME_SCHEDULE_CRON_PATTERN,
metadata: { [ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY]: runAt },
}
: undefined;
}
const cronPattern = asTrimmedString(args?.cron_pattern);
return cronPattern ? { cronPattern } : undefined;
}
function asTrimmedStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const values = value
.map((item) => asTrimmedString(item))
.filter((item): item is string => item !== undefined);
return values.length > 0 ? values : undefined;
}
export async function handleRoutineScheduleCommand(
command: string,
args?: Record<string, unknown>,
@@ -76,23 +107,23 @@ export async function handleRoutineScheduleCommand(
if (command === "create_routine_schedule") {
const name = asTrimmedString(args?.name);
const cronPattern = asTrimmedString(args?.cron_pattern);
const timing = routineScheduleTiming(args);
const prompt = asTrimmedString(args?.prompt);
const routineWorkspaceRoot = asTrimmedString(args?.workspace_root);
if (!name || !cronPattern || !prompt || !routineWorkspaceRoot) {
if (!name || !timing || !prompt || !routineWorkspaceRoot) {
throw new Error(
"createSchedule requires name, cron_pattern, prompt, and workspace_root",
"createSchedule requires name, timing, prompt, and workspace_root",
);
}
const created = await clientCommand("schedule.create", {
name,
cronPattern,
...timing,
prompt,
modelSelection: {
providerId: asTrimmedString(args?.provider) ?? "cline",
modelId: asTrimmedString(args?.model) ?? "openai/gpt-5.3-codex",
modelId: asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID,
},
mode: args?.mode === "plan" ? "plan" : "act",
mode: readHubScheduleMode(args, "yolo"),
workspaceRoot: routineWorkspaceRoot,
cwd: asTrimmedString(args?.cwd),
systemPrompt: asTrimmedString(args?.system_prompt),
@@ -100,12 +131,7 @@ export async function handleRoutineScheduleCommand(
timeoutSeconds: toPositiveInt(args?.timeout_seconds),
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
enabled: args?.enabled !== false,
tags:
Array.isArray(args?.tags) && args.tags.length > 0
? (args.tags as string[])
.map((v) => v.trim())
.filter((v) => v.length > 0)
: undefined,
tags: asTrimmedStringArray(args?.tags),
});
return { schedule: created.schedule ?? null };
}
@@ -113,25 +139,26 @@ export async function handleRoutineScheduleCommand(
const scheduleId = asTrimmedString(args?.schedule_id);
if (!scheduleId) throw new Error(`${command} requires schedule_id`);
if (command === "update_routine_schedule") {
const mode = readHubScheduleMode(args);
const name = asTrimmedString(args?.name);
const cronPattern = asTrimmedString(args?.cron_pattern);
const timing = routineScheduleTiming(args);
const prompt = asTrimmedString(args?.prompt);
const routineWorkspaceRoot = asTrimmedString(args?.workspace_root);
if (!name || !cronPattern || !prompt || !routineWorkspaceRoot) {
if (!name || !timing || !prompt || !routineWorkspaceRoot) {
throw new Error(
"updateSchedule requires schedule_id, name, cron_pattern, prompt, and workspace_root",
"updateSchedule requires schedule_id, name, timing, prompt, and workspace_root",
);
}
const reply = await clientCommand("schedule.update", {
scheduleId,
name,
cronPattern,
...timing,
prompt,
modelSelection: {
providerId: asTrimmedString(args?.provider) ?? "cline",
modelId: asTrimmedString(args?.model) ?? "openai/gpt-5.3-codex",
modelId: asTrimmedString(args?.model) ?? CLINE_DEFAULT_MODEL_ID,
},
mode: args?.mode === "plan" ? "plan" : "act",
...(mode === undefined ? {} : { mode }),
workspaceRoot: routineWorkspaceRoot,
cwd: asTrimmedString(args?.cwd) ?? null,
systemPrompt:
@@ -148,11 +175,7 @@ export async function handleRoutineScheduleCommand(
: toPositiveInt(args?.timeout_seconds),
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
enabled: args?.enabled !== false,
tags: Array.isArray(args?.tags)
? (args.tags as string[])
.map((v) => v.trim())
.filter((v) => v.length > 0)
: [],
tags: asTrimmedStringArray(args?.tags) ?? [],
});
return { schedule: reply.schedule ?? null };
}
@@ -79,6 +79,7 @@ function summarizeClient(client: TrackedClient): {
if (
normalizedType === "code-sidecar" ||
normalizedType === "code-sidecar-approvals" ||
normalizedType === "code-sidecar-observer" ||
normalizedType === "code-sidecar-list"
) {
return { key: "code-app", label: "Code App", name: "Code App" };
+1 -1
View File
@@ -1,4 +1,4 @@
import { listActiveConnectors } from "../../../cli/src/connectors/status";
import { listActiveConnectors } from "@cline/core";
import type { WebviewHubState } from "../webview-protocol";
import {
clientSummariesPayload,
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@cline/shared": "workspace:*",
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@rive-app/react-webgl2": "^4.27.2",
@@ -1,5 +1,10 @@
"use client";
import {
CLINE_DEFAULT_MODEL_ID,
ONE_TIME_SCHEDULE_CRON_PATTERN,
ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY,
} from "@cline/shared";
import {
Circle,
Eye,
@@ -72,6 +77,7 @@ interface RoutineSchedule {
scheduleId: string;
name: string;
cronPattern: string;
metadata?: Record<string, unknown>;
prompt: string;
provider?: string;
model?: string;
@@ -145,7 +151,7 @@ interface ProcessContext {
}
const FALLBACK_PROVIDER_MODELS: Record<string, string[]> = {
cline: ["anthropic/claude-sonnet-4.6"],
cline: [CLINE_DEFAULT_MODEL_ID],
anthropic: ["claude-sonnet-4-6"],
"openai-native": ["gpt-5.3-codex"],
openrouter: ["anthropic/claude-sonnet-4.6"],
@@ -170,13 +176,9 @@ interface RoutineFormState {
prompt: string;
provider: string;
model: string;
mode: "act" | "plan";
workspaceRoot: string;
cwd: string;
systemPrompt: string;
maxIterations: string;
timeoutSeconds: string;
maxParallel: string;
tags: string;
enabled: boolean;
}
@@ -203,6 +205,15 @@ function formatDateTime(value?: DateTimeValue | null): string {
return parsed.toLocaleString();
}
function getOneTimeScheduleRunAt(
schedule: RoutineSchedule,
): number | undefined {
const runAt = schedule.metadata?.[ONE_TIME_SCHEDULE_RUN_AT_METADATA_KEY];
return typeof runAt === "number" && Number.isFinite(runAt)
? runAt
: undefined;
}
function formatScheduleModel(schedule: RoutineSchedule): string {
const provider =
schedule.modelSelection?.providerId?.trim() || schedule.provider?.trim();
@@ -226,7 +237,7 @@ function getScheduleProviderModel(schedule: RoutineSchedule): {
model:
schedule.modelSelection?.modelId?.trim() ||
schedule.model?.trim() ||
"openai/gpt-5.3-codex",
CLINE_DEFAULT_MODEL_ID,
};
}
@@ -241,20 +252,27 @@ function formatExecutionResult(execution?: RoutineExecution): string {
return when === "-" ? status : `${status} at ${when}`;
}
function parseOptionalPositiveInt(text: string): number | undefined {
const trimmed = text.trim();
function asTrimmedFormString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function parseOptionalPositiveInt(value: unknown): number | undefined {
const trimmed = asTrimmedFormString(value);
if (!trimmed) {
return undefined;
}
const value = Number.parseInt(trimmed, 10);
if (!Number.isFinite(value) || value <= 0) {
const parsedValue = Number.parseInt(trimmed, 10);
if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
return undefined;
}
return value;
return parsedValue;
}
function parseTags(text: string): string[] | undefined {
const tags = text
function parseTags(value: unknown): string[] | undefined {
if (typeof value !== "string") {
return undefined;
}
const tags = value
.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0);
@@ -382,14 +400,10 @@ export function RoutineSchedulesContent() {
scheduleDays: ["MON", "TUE", "WED", "THU", "FRI"],
prompt: "Review PRs opened yesterday and summarize issues.",
provider: "cline",
model: "openai/gpt-5.3-codex",
mode: "act",
model: CLINE_DEFAULT_MODEL_ID,
workspaceRoot: "",
cwd: "",
systemPrompt: "",
maxIterations: "",
timeoutSeconds: "",
maxParallel: "1",
tags: "",
enabled: true,
});
@@ -702,13 +716,9 @@ export function RoutineSchedulesContent() {
prompt: "Review PRs opened yesterday and summarize issues.",
provider: preferredProvider,
model: preferredModel,
mode: "act",
workspaceRoot: context.workspaceRoot || context.cwd,
cwd: context.cwd || "",
systemPrompt: "",
maxIterations: "",
timeoutSeconds: "",
maxParallel: "1",
tags: "",
enabled: true,
});
@@ -716,6 +726,9 @@ export function RoutineSchedulesContent() {
};
const openEditDialog = (schedule: RoutineSchedule) => {
if (schedule.cronPattern === ONE_TIME_SCHEDULE_CRON_PATTERN) {
return;
}
const { provider, model } = getScheduleProviderModel(schedule);
const parsedCron = parseCronPattern(schedule.cronPattern);
setEditingSchedule(schedule);
@@ -738,22 +751,12 @@ export function RoutineSchedulesContent() {
prompt: schedule.prompt,
provider,
model,
mode: schedule.mode === "plan" ? "plan" : "act",
workspaceRoot: schedule.workspaceRoot ?? "",
cwd: schedule.cwd ?? "",
systemPrompt: schedule.systemPrompt ?? "",
maxIterations:
typeof schedule.maxIterations === "number"
? String(schedule.maxIterations)
: "",
timeoutSeconds:
typeof schedule.timeoutSeconds === "number"
? String(schedule.timeoutSeconds)
: "",
maxParallel:
typeof schedule.maxParallel === "number"
? String(schedule.maxParallel)
: "1",
tags: schedule.tags?.join(",") ?? "",
enabled: schedule.enabled,
});
@@ -761,7 +764,7 @@ export function RoutineSchedulesContent() {
};
const submitCreateForm = async () => {
const name = createForm.name.trim();
const name = asTrimmedFormString(createForm.name);
if (!name) {
setCreateFormError("Routine name is required.");
return;
@@ -775,12 +778,12 @@ export function RoutineSchedulesContent() {
setCreateFormError("Select at least one day and a valid time.");
return;
}
const prompt = createForm.prompt.trim();
const prompt = asTrimmedFormString(createForm.prompt);
if (!prompt) {
setCreateFormError("Prompt is required.");
return;
}
const workspaceRoot = createForm.workspaceRoot.trim();
const workspaceRoot = asTrimmedFormString(createForm.workspaceRoot);
if (!workspaceRoot) {
setCreateFormError("Workspace root is required.");
return;
@@ -789,18 +792,17 @@ export function RoutineSchedulesContent() {
setIsCreating(true);
try {
const provider =
normalizeProviderId(createForm.provider) ||
normalizeProviderId(asTrimmedFormString(createForm.provider)) ||
availableProviders[0] ||
"cline";
const model =
createForm.model.trim() ||
asTrimmedFormString(createForm.model) ||
(visibleProviderModels[provider] ?? [])[0] ||
"openai/gpt-5.3-codex";
const maxIterations = parseOptionalPositiveInt(createForm.maxIterations);
CLINE_DEFAULT_MODEL_ID;
const systemPrompt = asTrimmedFormString(createForm.systemPrompt);
const timeoutSeconds = parseOptionalPositiveInt(
createForm.timeoutSeconds,
);
const maxParallel = parseOptionalPositiveInt(createForm.maxParallel) ?? 1;
const tags = parseTags(createForm.tags);
const command = editingSchedule
? "update_routine_schedule"
@@ -814,19 +816,16 @@ export function RoutineSchedulesContent() {
prompt,
provider,
model,
mode: createForm.mode,
mode: editingSchedule?.mode ?? "yolo", // New routines must default to yolo mode.
workspace_root: workspaceRoot,
cwd: createForm.cwd.trim() || undefined,
cwd: editingSchedule ? (editingSchedule.cwd ?? null) : workspaceRoot,
system_prompt: editingSchedule
? createForm.systemPrompt.trim() || null
: createForm.systemPrompt.trim() || undefined,
max_iterations: editingSchedule
? (maxIterations ?? null)
: maxIterations,
? systemPrompt || null
: systemPrompt || undefined,
timeout_seconds: editingSchedule
? (timeoutSeconds ?? null)
: timeoutSeconds,
max_parallel: maxParallel,
max_parallel: 1,
enabled: createForm.enabled,
tags: tags ?? [],
});
@@ -948,7 +947,9 @@ export function RoutineSchedulesContent() {
{schedule.mode}
</span>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{schedule.cronPattern}
{schedule.cronPattern === ONE_TIME_SCHEDULE_CRON_PATTERN
? `Once · ${formatDateTime(getOneTimeScheduleRunAt(schedule))}`
: schedule.cronPattern}
</span>
<div className="flex-1" />
<div className="flex items-center gap-1">
@@ -967,7 +968,10 @@ export function RoutineSchedulesContent() {
size="icon-sm"
aria-label={`Edit ${schedule.name}`}
onClick={() => openEditDialog(schedule)}
disabled={isBusy}
disabled={
isBusy ||
schedule.cronPattern === ONE_TIME_SCHEDULE_CRON_PATTERN
}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
@@ -1373,27 +1377,6 @@ export function RoutineSchedulesContent() {
</Combobox>
</div>
<div>
<Label>Mode</Label>
<Select
value={createForm.mode}
onValueChange={(value) =>
setCreateForm((prev) => ({
...prev,
mode: value === "plan" ? "plan" : "act",
}))
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="act">act</SelectItem>
<SelectItem value="plan">plan</SelectItem>
</SelectContent>
</Select>
</div>
<div className="sm:col-span-2">
<Label htmlFor="routine-workspace">Workspace root</Label>
<Input
@@ -1408,20 +1391,6 @@ export function RoutineSchedulesContent() {
/>
</div>
<div className="sm:col-span-2">
<Label htmlFor="routine-cwd">CWD (optional)</Label>
<Input
id="routine-cwd"
value={createForm.cwd}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
cwd: event.target.value,
}))
}
/>
</div>
<div className="sm:col-span-2">
<Label htmlFor="routine-system-prompt">
System prompt (optional)
@@ -1439,23 +1408,6 @@ export function RoutineSchedulesContent() {
/>
</div>
<div>
<Label htmlFor="routine-max-iterations">
Max iterations (optional)
</Label>
<Input
id="routine-max-iterations"
value={createForm.maxIterations}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
maxIterations: event.target.value,
}))
}
placeholder="50"
/>
</div>
<div>
<Label htmlFor="routine-timeout">
Timeout seconds (optional)
@@ -1473,21 +1425,6 @@ export function RoutineSchedulesContent() {
/>
</div>
<div>
<Label htmlFor="routine-max-parallel">Max parallel</Label>
<Input
id="routine-max-parallel"
value={createForm.maxParallel}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
maxParallel: event.target.value,
}))
}
placeholder="1"
/>
</div>
<div>
<Label htmlFor="routine-tags">
Tags (comma-separated, optional)
+40
View File
@@ -1,5 +1,45 @@
# Cline Code Desktop Changelog
## 0.0.6
- Queued messages now appear in a collapsible list above the composer with a count — expand it to edit, send-now, or delete individual queued turns.
- New sidebar update indicator: once an update has been downloaded, an accent-colored icon stays in the sidebar showing the new version with a one-click restart, so the update is still reachable after you dismiss the toast (and restart failures are now surfaced instead of silently doing nothing).
- No more appearance flash on launch — the app paints in your saved (or system) light/dark theme before the first frame.
- The header and sidebar now show the full workspace name and git branch, and lay out correctly on narrow windows; a transient git lookup no longer wipes a valid branch name back to "no git".
- Cleaner collapsed-sidebar settings layout: compact width, left-aligned navigation, stacked account details.
- Clarified the auto-update setting — it's now "Keep CLI up to date" and explains that it governs the `cline` terminal command, not the app itself (the app updates separately).
- Toggle switches now use a solid accent color when on, for clearer contrast.
## 0.0.5
- Major performance overhaul: the app now feels snappy end-to-end. The animated background renders at a locked 60fps instead of ~10fps, typing in the composer no longer stutters (245 slow keystrokes → 3), streaming responses coalesce updates instead of re-rendering the whole chat per token, and app boot fetches the provider catalog once instead of three times.
- The native folder picker and command execution no longer freeze the app while the sidecar writes session logs or discovers your editor.
- Fixed the composer getting stuck on "Agent is working..." after queued turns finished.
- Added a Cline API key path to onboarding, and you can now cancel a pending browser sign-in instead of being stuck waiting for it.
- Fixed window dragging.
- MCP server cards are now consistent across marketplace views, with a single uninstall action and setup guidance shown on installed servers.
- Fixed agentic compaction silently falling back to basic compaction for OpenAI-Compatible providers, and manual /compact never actually reaching the model when auto-compaction was off.
## 0.0.4
- Start chatting without opening a project folder — the app now supports workspace-free chat sessions.
- New first-run onboarding flow to get you set up on launch.
- Drag and drop files directly onto the chat to attach them.
- Image attachments now display inline in the chat transcript.
- Schedule one-time routines (not just recurring ones), with navigation to jump to a routine's run.
- New custom overlay title bar with in-app navigation.
- Redesigned channel setup as expandable cards.
- Added a setting to replay the new-user experience.
- Cleaner chat markdown rendering, and external links now open correctly in your browser.
- Agent sessions now use agentic compaction by default, keeping long conversations within context more intelligently.
- Fixed the agent not finding `gh` and other CLI tools by resolving your login shell's PATH.
- Headless routines now default to YOLO mode so they can run unattended.
- Fixed request metering for the SAP AI Core provider.
## 0.0.3
- The reasoning section in the chat transcript now reads simply "Thinking" — dropped the redundant status text and brain icon.
## 0.0.2
- First public release of Cline Code for macOS: a desktop app for running and inspecting Cline agent sessions, signed and notarized for Apple Silicon and Intel.
+22 -4
View File
@@ -16,6 +16,20 @@ From `apps/examples/desktop-app/`:
- `bun run package:desktop` - package the current OS desktop app into `dist/desktop/`
- `bun run typecheck` - TypeScript check
## Login Shell PATH Resolution
Apps launched from Finder/the Dock inherit launchd's minimal `PATH`
(`/usr/bin:/bin:/usr/sbin:/sbin`), not the one your shell profiles build, so
agent-run commands would miss Homebrew-installed tools like `gh` even though
they work fine from a terminal. At startup the sidecar asks the user's login
shell — read from the account database via `getpwuid`, falling back to
`$SHELL` — for its `PATH` and merges it into `process.env.PATH`, which every
agent-spawned child (run_commands, MCP servers) inherits. Only `PATH` is
imported, deliberately; other login-environment variables (`SSH_AUTH_SOCK`,
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).
## Web Visual System
The framework-neutral color, typography, radius, and navigation contract lives
@@ -86,7 +100,9 @@ Do not remove `src-tauri/entitlements.plist` or the `bundle.macOS.entitlements`
Startup flow:
1. Tauri starts a persistent local desktop backend and keeps only native window/file-picker/open-path responsibilities.
2. The desktop backend starts the Bun sidecar and exposes one websocket transport (`/transport`) for commands, queries, and pushed events.
2. The desktop backend starts the Bun sidecar, which discovers or starts the
canonical shared Cline Hub and exposes one websocket transport (`/transport`)
for desktop commands, queries, and pushed events.
3. The React app uses `lib/desktop-client.ts` and no longer imports `@tauri-apps/api/core` directly in feature code.
4. Tool approval updates are pushed from the backend instead of polled from the UI.
5. Session process context resolves `workspaceRoot` from git root and uses that same path as default `cwd` for chat runtime and git operations unless explicitly overridden.
@@ -107,8 +123,8 @@ Desktop transport envelope:
## Key Files
- [`src-tauri/src/main.rs`](./src-tauri/src/main.rs) - Tauri shell lifecycle, backend launch, and native-only commands
- [`sidecar/index.ts`](./sidecar/index.ts) - persistent Bun sidecar backend
- [`sidecar/chat-session.ts`](./sidecar/chat-session.ts) - in-process chat session runtime
- [`sidecar/index.ts`](./sidecar/index.ts) - persistent Bun sidecar and Hub-daemon entry dispatch
- [`sidecar/chat-session.ts`](./sidecar/chat-session.ts) - shared-Hub chat session adapter
- [`webview/lib/desktop-client.ts`](./webview/lib/desktop-client.ts) - typed desktop websocket client
- [`webview/hooks/use-chat-session.ts`](./webview/hooks/use-chat-session.ts) - UI chat session state + backend subscriptions
- [`webview/lib/chat-schema.ts`](./webview/lib/chat-schema.ts) - chat message schema used by the UI
@@ -143,5 +159,7 @@ Logging can be configured with the same environment variables as the CLI:
- Tauri restarts the desktop backend if the sidecar process exits and kills it on app teardown.
- Chat sends now preflight provider credentials. If a provider that requires API-key auth is selected without a key, the UI blocks the turn with a clear error message instead of starting a hanging session.
- If a turn completes with `finishReason=error` before any assistant content is produced, the UI now adds an explicit error chat message so failed turns are visible in the transcript.
- If package changes are not reflected, rebuild SDK packages (`bun run build:sdk`). The next `cline rpc ensure` call should attach to the current build's sidecar automatically.
- If package changes are not reflected, rebuild SDK packages (`bun run build:sdk`).
The next desktop or CLI Hub connection will reuse a compatible running Hub or
replace an incompatible one through the shared discovery path.
- Provider settings updates are patch-style: only fields you edit are changed. Unset fields are preserved instead of being cleared.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.2",
"version": "0.0.6",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
@@ -2,9 +2,12 @@
## Overview
The sidecar is a single Bun process that handles the desktop backend runtime directly.
The sidecar is a Bun process that adapts the desktop UI and native operations to
the shared Cline Hub.
It imports `@cline/core` directly and serves the Next.js frontend over HTTP + WebSocket.
It imports `@cline/core`, discovers or starts the canonical shared Hub, registers
as a Hub client, and serves the Next.js frontend over HTTP + WebSocket. The
sidecar does not own a private agent runtime Hub.
## Directory Structure
@@ -14,7 +17,7 @@ sidecar/
├── server.ts # Bun HTTP server + WebSocket handlers
├── context.ts # SidecarContext type and factory
├── commands.ts # Command router
├── chat-session.ts # In-process chat session management
├── chat-session.ts # Shared-Hub chat session adapter
├── session-data/ # Shared discovery, messages, artifacts, search helpers
├── paths.ts # Path resolution
├── types.ts # Shared types
@@ -31,15 +34,23 @@ Event: { "type": "event", "event": { "name": string, "payload": unknown } }
## Key Design Decisions
### 1. Chat Sessions — In-Process via LocalRuntimeHost
### 1. Chat Sessions — Shared Hub Client
Instead of spawning a separate runtime bridge process, we use `LocalRuntimeHost` directly:
`ClineCore` uses Hub mode without an explicit endpoint. Core therefore reuses
the same compatible Hub discovered by the CLI or starts the canonical detached
Hub when the desktop is the first client:
```typescript
import { LocalRuntimeHost } from "@cline/core";
const sessionManager = await ClineCore.create({
clientName: "cline-code",
backendMode: "hub",
hub: {
strategy: "require-hub",
workspaceRoot,
cwd: workspaceRoot,
clientType: "code-sidecar",
displayName: "Code App sidecar",
},
capabilities: {
requestToolApproval: async (request) => {
// Push approval request to frontend via WebSocket event
@@ -66,9 +77,15 @@ sessionManager.subscribe((event) => {
});
```
### 2. Tool Approval — In-Memory Promise Resolution
The compiled sidecar also recognizes Core's Hub-daemon launch mode. This lets
the desktop start the same detached Hub when no CLI process has started it yet.
Startup discovery and locking ensure concurrent clients converge on one Hub.
No more file-system watchers. Tool approvals use in-memory promise maps:
### 2. Tool Approval — Client-Owned Promise Resolution
The shared Hub routes approval requests back to the client that created the
session. Desktop approvals use in-memory promise maps while the webview is
online:
```typescript
const pendingApprovals = new Map<string, {
@@ -96,12 +113,11 @@ const store = new SqliteSessionStore();
### 5. Routine Schedules — Direct Hub Commands
Routine operations now ensure the local hub server in-process and issue hub schedule commands directly. They are still called in-process, not via child script:
Routine operations use the same connected Hub client as chat session
observation. They never start a second in-process Hub:
```typescript
import { ensureHubServer, sendHubCommand } from "@cline/core";
await ensureHubServer({ runtimeHandlers: createLocalHubScheduleRuntimeHandlers() });
await sendHubCommand({}, { command: "schedule.list", payload: { limit: 200 } });
await ctx.hubClient.command("schedule.list", { limit: 200 });
```
### 6. Native Commands
@@ -122,7 +138,7 @@ Supported commands:
| Command | Implementation |
|---------|---------------|
| `chat_session_command` | `LocalRuntimeHost` in-process |
| `chat_session_command` | shared Hub through `ClineCore` |
| `list_provider_catalog` | `ProviderSettingsManager` + `listLocalProviders` |
| `list_provider_models` | `getLocalProviderModels` |
| `save_provider_settings` | `saveLocalProviderSettings` |
@@ -137,14 +153,14 @@ Supported commands:
| `list_mcp_servers` | Direct file I/O |
| `upsert_mcp_server` | Direct file I/O |
| `delete_mcp_server` | Direct file I/O |
| `get_git_branch` | `execFileSync("git", ...)` |
| `list_git_branches` | `execFileSync("git", ...)` |
| `checkout_git_branch` | `execFileSync("git", ...)` |
| `get_git_branch` | async `execFile("git", ...)` |
| `list_git_branches` | async `execFile("git", ...)` |
| `checkout_git_branch` | async `execFile("git", ...)` |
| `search_workspace_files` | `getFileIndex` |
| `get_process_context` | In-memory context |
| `poll_tool_approvals` | In-memory pending map |
| `respond_tool_approval` | In-memory promise resolution |
| `list_routine_schedules` | local hub schedule commands |
| `list_routine_schedules` | shared Hub schedule commands |
| `list_user_instruction_configs` | Direct core API |
| `pick_workspace_directory` | OS native dialog |
| `open_mcp_settings_file` | OS `open` command |
@@ -0,0 +1,158 @@
import { existsSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
deleteMaterializedAttachments,
discardAllTrackedAttachments,
flushConsumedAttachments,
markQueuedAttachmentsSubmitted,
materializeUserFiles,
reconcileQueuedAttachments,
sessionAttachmentsDir,
trackQueuedAttachments,
} from "./attachments";
import type { LiveSession } from "./types";
const sessionId = "attachment-test-session";
let previousSessionDataDir: string | undefined;
let testSessionDataDir: string;
function createSession(): LiveSession {
return {
config: {},
messages: [],
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
};
}
beforeEach(() => {
previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
testSessionDataDir = join(
tmpdir(),
`cline-desktop-attachment-lifecycle-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
});
afterEach(() => {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
});
describe("materialized attachment lifecycle", () => {
it("only deletes files inside the session attachments dir", () => {
const [staged] = materializeUserFiles(sessionId, [
{ name: "notes.txt", content: "hello" },
]) as string[];
const outside = join(testSessionDataDir, "outside.txt");
writeFileSync(outside, "keep me", "utf8");
deleteMaterializedAttachments(sessionId, [staged, outside]);
expect(existsSync(staged)).toBe(false);
expect(existsSync(outside)).toBe(true);
});
it("deletes consumed files when the turn ends", () => {
const session = createSession();
const files = materializeUserFiles(sessionId, [
{ name: "a.txt", content: "a" },
]) as string[];
trackQueuedAttachments(
session,
[
{
id: "pending_1",
prompt: "p",
delivery: "queue",
attachmentCount: 1,
userFiles: files,
},
],
files,
);
markQueuedAttachmentsSubmitted(session, "pending_1");
expect(existsSync(files[0] as string)).toBe(true);
flushConsumedAttachments(sessionId, session);
expect(existsSync(files[0] as string)).toBe(false);
expect(session.consumedAttachmentFiles?.size ?? 0).toBe(0);
});
it("keeps files for a submitted prompt that gets requeued", () => {
const session = createSession();
const files = materializeUserFiles(sessionId, [
{ name: "a.txt", content: "a" },
]) as string[];
trackQueuedAttachments(
session,
[
{
id: "pending_1",
prompt: "p",
delivery: "queue",
attachmentCount: 1,
userFiles: files,
},
],
files,
);
markQueuedAttachmentsSubmitted(session, "pending_1");
// Drain send failed → prompt is back in the queue snapshot.
reconcileQueuedAttachments(session, ["pending_1"]);
flushConsumedAttachments(sessionId, session);
expect(existsSync(files[0] as string)).toBe(true);
expect(session.queuedAttachmentFiles?.get("pending_1")).toEqual(files);
});
it("tracks files as consumed when the prompt is no longer queued", () => {
const session = createSession();
const files = materializeUserFiles(sessionId, [
{ name: "a.txt", content: "a" },
]) as string[];
trackQueuedAttachments(session, [], files);
expect(session.queuedAttachmentFiles?.size ?? 0).toBe(0);
expect(session.consumedAttachmentFiles?.size).toBe(1);
});
it("discards all tracked files on session end", () => {
const session = createSession();
const queued = materializeUserFiles(sessionId, [
{ name: "queued.txt", content: "q" },
]) as string[];
const consumed = materializeUserFiles(sessionId, [
{ name: "consumed.txt", content: "c" },
]) as string[];
trackQueuedAttachments(
session,
[
{
id: "pending_1",
prompt: "p",
delivery: "queue",
attachmentCount: 1,
userFiles: queued,
},
],
queued,
);
trackQueuedAttachments(session, [], consumed);
discardAllTrackedAttachments(sessionId, session);
expect(existsSync(queued[0] as string)).toBe(false);
expect(existsSync(consumed[0] as string)).toBe(false);
expect(existsSync(sessionAttachmentsDir(sessionId))).toBe(true);
});
});
@@ -0,0 +1,157 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { basename, join, resolve, sep } from "node:path";
import { isDeepStrictEqual } from "node:util";
import type { SessionPendingPrompt } from "@cline/core";
import { sharedSessionDataDir } from "./paths";
import type { ChatTurnAttachments, LiveSession } from "./types";
function queuedFilesMap(session: LiveSession): Map<string, string[]> {
if (!session.queuedAttachmentFiles) {
session.queuedAttachmentFiles = new Map();
}
return session.queuedAttachmentFiles;
}
function consumedFilesMap(session: LiveSession): Map<string, string[]> {
if (!session.consumedAttachmentFiles) {
session.consumedAttachmentFiles = new Map();
}
return session.consumedAttachmentFiles;
}
// ---------------------------------------------------------------------------
// Materialized user-attachment lifecycle
//
// Non-image attachments arrive from the webview as inline content and are
// written to `<session-data>/<sessionId>/user-attachments/` so the SDK can
// load them by path at turn start. The sidecar owns these files and must
// delete them once consumed (turn completed) or discarded (queued prompt
// removed / session ended) — otherwise user data accumulates on disk.
// ---------------------------------------------------------------------------
export function sessionAttachmentsDir(sessionId: string): string {
return join(sharedSessionDataDir(), sessionId, "user-attachments");
}
export function materializeUserFiles(
sessionId: string,
files: ChatTurnAttachments["userFiles"],
): string[] | undefined {
if (!files?.length) {
return undefined;
}
const attachmentDir = sessionAttachmentsDir(sessionId);
mkdirSync(attachmentDir, { recursive: true });
return files.map((file) => {
const requestedName = basename(file.name.trim());
const safeName =
requestedName && requestedName !== "." && requestedName !== ".."
? requestedName
: "attachment.txt";
const path = join(attachmentDir, `${randomUUID()}-${safeName}`);
writeFileSync(path, file.content, "utf8");
return path;
});
}
/**
* Delete materialized attachment files. Only paths inside the session's
* user-attachments directory are removed, so files referenced from elsewhere
* (e.g. `@`-mentions) are never touched.
*/
export function deleteMaterializedAttachments(
sessionId: string,
paths: string[] | undefined,
): void {
if (!paths?.length) return;
const attachmentDir = resolve(sessionAttachmentsDir(sessionId)) + sep;
for (const path of paths) {
if (!resolve(path).startsWith(attachmentDir)) continue;
try {
rmSync(path, { force: true });
} catch {
// Best-effort cleanup; leftover files are removed with the session dir.
}
}
}
/**
* Track files staged for a queued/steered prompt so they can be deleted once
* the prompt is consumed or discarded. If the prompt is no longer in the
* queue (already submitted), the files are tracked as consumed and deleted
* when the running turn finishes.
*/
export function trackQueuedAttachments(
session: LiveSession | undefined,
prompts: SessionPendingPrompt[],
userFiles: string[] | undefined,
): void {
if (!session || !userFiles?.length) return;
const match = prompts.find((prompt) =>
isDeepStrictEqual(prompt.userFiles, userFiles),
);
if (match) {
queuedFilesMap(session).set(match.id, userFiles);
} else {
// Not in the queue → already being consumed by the running turn. Key by a
// fresh id so it never collides with a prompt-id key used elsewhere in the
// consumed bucket.
consumedFilesMap(session).set(randomUUID(), userFiles);
}
}
/** Move a submitted queued prompt's files into the consumed bucket. */
export function markQueuedAttachmentsSubmitted(
session: LiveSession | undefined,
promptId: string,
): void {
const files = session?.queuedAttachmentFiles?.get(promptId);
if (!session || !files) return;
session.queuedAttachmentFiles?.delete(promptId);
consumedFilesMap(session).set(promptId, files);
}
/**
* A prompt id reappearing in the queue means a submitted prompt was requeued
* (e.g. the drain send failed) move its files back to the queued bucket so
* the turn-end flush does not delete files still pending consumption.
*/
export function reconcileQueuedAttachments(
session: LiveSession | undefined,
queuedPromptIds: string[],
): void {
if (!session?.consumedAttachmentFiles?.size) return;
for (const id of queuedPromptIds) {
const files = session.consumedAttachmentFiles.get(id);
if (!files) continue;
session.consumedAttachmentFiles.delete(id);
queuedFilesMap(session).set(id, files);
}
}
/** Delete files for prompts whose turn has finished. */
export function flushConsumedAttachments(
sessionId: string,
session: LiveSession | undefined,
): void {
if (!session?.consumedAttachmentFiles?.size) return;
for (const files of session.consumedAttachmentFiles.values()) {
deleteMaterializedAttachments(sessionId, files);
}
session.consumedAttachmentFiles.clear();
}
/** Delete every tracked file for a session (queued prompts are discarded). */
export function discardAllTrackedAttachments(
sessionId: string,
session: LiveSession | undefined,
): void {
if (!session) return;
flushConsumedAttachments(sessionId, session);
if (!session.queuedAttachmentFiles?.size) return;
for (const files of session.queuedAttachmentFiles.values()) {
deleteMaterializedAttachments(sessionId, files);
}
session.queuedAttachmentFiles.clear();
}
@@ -1,7 +1,8 @@
import { rmSync } from "node:fs";
import { existsSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { materializeUserFiles } from "./attachments";
import {
buildSessionConnectionUpdate,
consumeWorkspaceMetadata,
@@ -128,6 +129,51 @@ describe("hasProviderChanged", () => {
});
});
describe("pathless session starts", () => {
it("omits workspace paths and returns the SDK-resolved chat workspace", async () => {
const start = vi.fn(async (input: { config: Record<string, unknown> }) => {
expect(input.config).not.toHaveProperty("cwd");
expect(input.config).not.toHaveProperty("workspaceRoot");
return {
sessionId: "session-pathless",
manifest: {
cwd: "/home/host/.cline/data/workspaces/chat",
workspace_root: "/home/host/.cline/data/workspaces/chat",
},
manifestPath: "/tmp/session-pathless.json",
messagesPath: "/tmp/session-pathless.messages.json",
};
});
const ctx = {
liveSessions: new Map(),
sessionManager: { start },
} as unknown as SidecarContext;
const result = (await handleChatSessionCommand(ctx, {
action: "start",
config: {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
enableTools: true,
},
})) as {
sessionId: string;
cwd: string;
workspaceRoot: string;
};
expect(result).toEqual({
sessionId: "session-pathless",
cwd: "/home/host/.cline/data/workspaces/chat",
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
});
expect(ctx.liveSessions.get("session-pathless")?.config).toMatchObject({
cwd: "/home/host/.cline/data/workspaces/chat",
workspaceRoot: "/home/host/.cline/data/workspaces/chat",
});
});
});
describe("first-send connection updates", () => {
const baseConfig = {
provider: "cline",
@@ -141,7 +187,7 @@ describe("first-send connection updates", () => {
config?: Record<string, unknown>;
}) {
const updateSessionConnection = vi.fn(async () => undefined);
const send = vi.fn(async () => ({
const send = vi.fn(async (_input?: unknown) => ({
text: "done",
finishReason: "completed",
messages: [],
@@ -208,6 +254,266 @@ describe("first-send connection updates", () => {
expect(send).toHaveBeenCalledTimes(1);
});
it("allows an image-only user turn", async () => {
const { ctx, send, sessionId } = createContext();
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "",
attachments: {
userImages: ["data:image/png;base64,aGVsbG8="],
userFiles: [],
},
});
expect(send).toHaveBeenCalledWith({
sessionId,
prompt: "",
delivery: undefined,
userImages: ["data:image/png;base64,aGVsbG8="],
});
});
it.each([
undefined,
"queue",
] as const)("forwards file attachments for %s delivery", async (delivery) => {
const { ctx, send, sessionId } = createContext();
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
const testSessionDataDir = join(
tmpdir(),
`cline-desktop-attachments-${Date.now()}-${delivery ?? "immediate"}`,
);
let sentFileContent: string | undefined;
send.mockImplementation(async (input?: unknown) => {
const files = (input as { userFiles?: string[] } | undefined)?.userFiles;
if (files?.[0]) {
sentFileContent = readFileSync(files[0], "utf8");
}
return { text: "done", finishReason: "completed", messages: [] };
});
try {
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "",
delivery,
attachments: {
userFiles: [{ name: "notes.txt", content: "hello" }],
},
});
const input = send.mock.calls[0]?.[0] as
| { userFiles?: string[] }
| undefined;
expect(send).toHaveBeenCalledWith({
sessionId,
prompt: "",
delivery,
userImages: undefined,
userFiles: [expect.stringMatching(/notes\.txt$/)],
});
expect(sentFileContent).toBe("hello");
if (delivery === "queue") {
// Queued attachments stay on disk until the prompt is consumed.
expect(existsSync(input?.userFiles?.[0] ?? "")).toBe(true);
} else {
// Immediate turns delete the materialized file once the send resolves.
expect(existsSync(input?.userFiles?.[0] ?? "")).toBe(false);
}
} finally {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
}
});
it("deletes materialized attachments when a queued prompt is removed", async () => {
const { ctx, send, sessionId } = createContext();
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
const testSessionDataDir = join(
tmpdir(),
`cline-desktop-attachments-remove-${Date.now()}`,
);
try {
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
const queue: Array<{
id: string;
prompt: string;
delivery: "queue";
attachmentCount: number;
userFiles?: string[];
}> = [];
const manager = ctx.sessionManager as unknown as {
send: typeof send;
pendingPrompts: {
list: (input: unknown) => Promise<unknown[]>;
delete: (input: {
sessionId: string;
promptId: string;
}) => Promise<unknown>;
};
};
manager.send = vi.fn(async (input?: unknown) => {
const { prompt, userFiles } = input as {
prompt: string;
userFiles?: string[];
};
queue.push({
id: "pending_1",
prompt,
delivery: "queue",
attachmentCount: userFiles?.length ?? 0,
userFiles,
});
return undefined;
}) as unknown as typeof send;
manager.pendingPrompts = {
list: vi.fn(async () => [...queue]),
delete: vi.fn(async ({ promptId }) => {
const index = queue.findIndex((entry) => entry.id === promptId);
const [removed] = index >= 0 ? queue.splice(index, 1) : [];
return {
sessionId,
prompts: [...queue],
prompt: removed,
removed: index >= 0,
};
}),
};
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "queued with file",
delivery: "queue",
attachments: {
userFiles: [{ name: "notes.txt", content: "hello" }],
},
});
const filePath = queue[0]?.userFiles?.[0] ?? "";
expect(existsSync(filePath)).toBe(true);
expect(
ctx.liveSessions
.get(sessionId)
?.queuedAttachmentFiles?.get("pending_1"),
).toEqual([filePath]);
await handleChatSessionCommand(ctx, {
action: "remove_pending_prompt",
sessionId,
promptId: "pending_1",
});
expect(existsSync(filePath)).toBe(false);
expect(
ctx.liveSessions.get(sessionId)?.queuedAttachmentFiles?.size ?? 0,
).toBe(0);
} finally {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
}
});
it("deletes tracked attachments when a session is reset", async () => {
const { ctx, sessionId } = createContext();
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
const testSessionDataDir = join(
tmpdir(),
`cline-desktop-attachments-reset-${Date.now()}`,
);
try {
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
const [queuedFile] = materializeUserFiles(sessionId, [
{ name: "queued.txt", content: "q" },
]) as string[];
const [consumedFile] = materializeUserFiles(sessionId, [
{ name: "consumed.txt", content: "c" },
]) as string[];
const session = ctx.liveSessions.get(sessionId);
if (!session) throw new Error("missing session");
session.queuedAttachmentFiles = new Map([["pending_1", [queuedFile]]]);
session.consumedAttachmentFiles = new Map([
["pending_2", [consumedFile]],
]);
await handleChatSessionCommand(ctx, {
action: "reset",
sessionId,
});
expect(existsSync(queuedFile)).toBe(false);
expect(existsSync(consumedFile)).toBe(false);
expect(ctx.liveSessions.has(sessionId)).toBe(false);
} finally {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
}
});
it("preserves tracked attachments across re-attach", async () => {
const { ctx, sessionId } = createContext();
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
const testSessionDataDir = join(
tmpdir(),
`cline-desktop-attachments-attach-${Date.now()}`,
);
try {
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
const [queuedFile] = materializeUserFiles(sessionId, [
{ name: "queued.txt", content: "q" },
]) as string[];
const session = ctx.liveSessions.get(sessionId);
if (!session) throw new Error("missing session");
const queuedMap = new Map([["pending_1", [queuedFile]]]);
session.queuedAttachmentFiles = queuedMap;
(ctx.sessionManager as unknown as { get: unknown }).get = vi.fn(
async () => ({
status: "idle",
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
cwd: "/workspace",
workspaceRoot: "/workspace",
}),
);
await handleChatSessionCommand(ctx, {
action: "attach",
sessionId,
});
expect(existsSync(queuedFile)).toBe(true);
expect(
ctx.liveSessions
.get(sessionId)
?.queuedAttachmentFiles?.get("pending_1"),
).toEqual([queuedFile]);
} finally {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
}
});
it("updates a changed connection before sending", async () => {
const { ctx, send, sessionId, updateSessionConnection } = createContext({
config: { ...baseConfig, reasoningEffort: "low" },
+108 -28
View File
@@ -5,7 +5,7 @@ import {
buildConnectionUpdate,
buildWorkspaceMetadata,
type ClineCore,
type CoreSessionConfig,
type ClineCoreStartConfig,
createSessionCompactionState,
projectSessionCompactionState,
type SessionCompactionState,
@@ -15,6 +15,12 @@ import {
} from "@cline/core";
import type { Message } from "@cline/llms";
import { buildClineSystemPrompt } from "@cline/shared";
import {
deleteMaterializedAttachments,
discardAllTrackedAttachments,
materializeUserFiles,
trackQueuedAttachments,
} from "./attachments";
import { emitChunk, nowMs, sendEvent } from "./context";
import { readSessionManifest, sharedSessionDataDir } from "./paths";
import type {
@@ -162,6 +168,8 @@ function createLiveSession(
prompt: overrides?.prompt,
title: overrides?.title,
attachedViaHub: overrides?.attachedViaHub ?? false,
queuedAttachmentFiles: overrides?.queuedAttachmentFiles,
consumedAttachmentFiles: overrides?.consumedAttachmentFiles,
};
}
@@ -197,6 +205,11 @@ function readPositiveInteger(value: unknown): number | undefined {
}
function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
const rawWorkspaceRoot = config.workspaceRoot ?? config.workspace_root;
const workspaceRoot =
typeof rawWorkspaceRoot === "string" ? rawWorkspaceRoot.trim() : "";
const cwd =
(typeof config.cwd === "string" ? config.cwd.trim() : "") || workspaceRoot;
const thinking =
typeof config.thinking === "boolean" ? config.thinking : undefined;
const reasoningEffort =
@@ -218,8 +231,8 @@ function buildCoreSessionConfig(config: JsonRecord): JsonRecord {
baseUrl: config.baseUrl,
headers: config.headers,
providerConfig: config.providerConfig,
workspaceRoot: config.workspaceRoot ?? config.workspace_root ?? "",
cwd: config.cwd ?? config.workspaceRoot ?? config.workspace_root ?? "",
...(workspaceRoot ? { workspaceRoot } : {}),
...(cwd ? { cwd } : {}),
systemPrompt: config.systemPrompt ?? config.system_prompt ?? "",
maxIterations: config.maxIterations ?? config.max_iterations,
enableTools: config.enableTools ?? config.enable_tools ?? true,
@@ -402,7 +415,13 @@ function sendPromptsInQueueSnapshot(
const session = ctx.liveSessions.get(sessionId);
sendEvent(ctx, "prompts_in_queue_state", {
sessionId,
items: session?.promptsInQueue ?? [],
items:
session?.promptsInQueue.map(({ id, prompt, steer, attachmentCount }) => ({
id,
prompt,
steer,
attachmentCount,
})) ?? [],
});
}
@@ -412,6 +431,7 @@ function mapPendingPrompt(item: SessionPendingPrompt): PromptInQueue {
prompt: item.prompt,
steer: item.delivery === "steer",
attachmentCount: item.attachmentCount,
userImages: item.userImages,
};
}
@@ -426,7 +446,12 @@ function applyPendingPrompts(
session.promptsInQueue = mapped;
}
sendPromptsInQueueSnapshot(ctx, sessionId);
return mapped;
return mapped.map(({ id, prompt, steer, attachmentCount }) => ({
id,
prompt,
steer,
attachmentCount,
}));
}
function getSessionManager(ctx: SidecarContext): ClineCore {
@@ -471,7 +496,7 @@ async function handleStart(
modelId: String(coreConfig.modelId ?? ""),
});
const startResult = await manager.start({
...splitCoreSessionConfig(coreConfig as unknown as CoreSessionConfig),
...splitCoreSessionConfig(coreConfig as unknown as ClineCoreStartConfig),
source: SessionSource.DESKTOP,
interactive: true,
...(initialMessages
@@ -480,19 +505,24 @@ async function handleStart(
toolPolicies: resolveToolPolicies(request.config),
});
const sessionId = startResult.sessionId;
const workspaceRoot = startResult.manifest.workspace_root;
const cwd = startResult.manifest.cwd;
ctx.logger?.log("Desktop chat session started", { sessionId });
const session = createLiveSession(request.config, {
messages: initialMessages,
prompt: initialMessages
? derivePromptFromMessages(initialMessages)
: undefined,
title: requestedSessionId
? readSessionMetadataTitle(requestedSessionId)
: undefined,
status: "idle",
});
const session = createLiveSession(
{ ...request.config, cwd, workspaceRoot },
{
messages: initialMessages,
prompt: initialMessages
? derivePromptFromMessages(initialMessages)
: undefined,
title: requestedSessionId
? readSessionMetadataTitle(requestedSessionId)
: undefined,
status: "idle",
},
);
ctx.liveSessions.set(sessionId, session);
return { sessionId };
return { sessionId, cwd, workspaceRoot };
}
async function handleAttach(
@@ -550,6 +580,11 @@ async function handleAttach(
existing?.title,
endedAt: isoTimestampToMs(session.endedAt),
attachedViaHub: true,
// Preserve tracked attachment files so re-attach (called on every
// webview hydrate) does not orphan materialized files still awaiting
// cleanup.
queuedAttachmentFiles: existing?.queuedAttachmentFiles,
consumedAttachmentFiles: existing?.consumedAttachmentFiles,
}),
);
@@ -582,7 +617,7 @@ async function startRebuiltSession(
...config,
sessionId,
systemPrompt,
}) as unknown as CoreSessionConfig,
}) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
@@ -679,8 +714,13 @@ async function handleSend(
): Promise<unknown> {
const sessionId = request.sessionId?.trim();
if (!sessionId) throw new Error("sessionId is required");
const prompt = request.prompt?.trim();
if (!prompt) throw new Error("prompt is required");
const prompt = request.prompt?.trim() ?? "";
const hasAttachments =
(request.attachments?.userImages?.length ?? 0) > 0 ||
(request.attachments?.userFiles?.length ?? 0) > 0;
if (!prompt && !hasAttachments) {
throw new Error("prompt or attachment is required");
}
const manager = getSessionManager(ctx);
const session = ctx.liveSessions.get(sessionId);
if (session?.transitioningProvider) {
@@ -742,6 +782,10 @@ async function handleSend(
}
}
const userFiles = materializeUserFiles(
sessionId,
request.attachments?.userFiles,
);
if (delivery === "queue") {
if (session) {
session.prompt = prompt;
@@ -751,8 +795,10 @@ async function handleSend(
prompt,
delivery: "queue",
userImages: request.attachments?.userImages,
userFiles,
});
const prompts = await manager.pendingPrompts.list({ sessionId });
trackQueuedAttachments(session, prompts, userFiles);
return {
sessionId,
ok: true,
@@ -766,12 +812,33 @@ async function handleSend(
promptLength: prompt.length,
delivery,
});
const result = await manager.send({
sessionId,
prompt,
delivery,
userImages: request.attachments?.userImages,
});
let result: Awaited<ReturnType<ClineCore["send"]>>;
try {
result = await manager.send({
sessionId,
prompt,
delivery,
userImages: request.attachments?.userImages,
userFiles,
});
} catch (error) {
deleteMaterializedAttachments(sessionId, userFiles);
throw error;
}
if (result === undefined) {
// The runtime queued or steered the prompt instead of running it
// (busy interactive session / steer delivery) — track the files so
// they are deleted once the prompt is consumed or discarded.
if (userFiles?.length) {
trackQueuedAttachments(
session,
await manager.pendingPrompts.list({ sessionId }),
userFiles,
);
}
} else {
deleteMaterializedAttachments(sessionId, userFiles);
}
ctx.logger?.log("Desktop chat prompt completed", {
sessionId,
finishReason: result?.finishReason,
@@ -932,7 +999,7 @@ async function handleFork(
...forkConfig,
systemPrompt,
initialMessages: sourceMessages,
}) as unknown as CoreSessionConfig,
}) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
@@ -941,6 +1008,10 @@ async function handleFork(
toolPolicies: resolveToolPolicies(forkConfig),
});
const newSessionId = startResult.sessionId;
discardAllTrackedAttachments(
sourceSessionId,
ctx.liveSessions.get(sourceSessionId),
);
ctx.liveSessions.delete(sourceSessionId);
ctx.liveSessions.set(
newSessionId,
@@ -980,6 +1051,7 @@ async function handleReset(
) {
await getSessionManager(ctx).stop(sessionId);
}
discardAllTrackedAttachments(sessionId, session);
ctx.liveSessions.delete(sessionId);
sendPromptsInQueueSnapshot(ctx, sessionId);
}
@@ -1018,7 +1090,7 @@ async function handleRestoreCheckpoint(
buildCoreSessionConfig({
...request.config,
systemPrompt: await resolveSystemPrompt(request.config),
}) as unknown as CoreSessionConfig,
}) as unknown as ClineCoreStartConfig,
),
source: SessionSource.DESKTOP,
interactive: true,
@@ -1030,6 +1102,10 @@ async function handleRestoreCheckpoint(
if (!sessionId || !restoredMessages) {
throw new Error("Checkpoint restore did not return a new session");
}
discardAllTrackedAttachments(
sourceSessionId,
ctx.liveSessions.get(sourceSessionId),
);
ctx.liveSessions.delete(sourceSessionId);
ctx.liveSessions.set(
sessionId,
@@ -1131,6 +1207,10 @@ async function handleRemovePendingPrompt(
sessionId,
promptId,
});
if (result.removed === true) {
deleteMaterializedAttachments(sessionId, result.prompt?.userFiles);
ctx.liveSessions.get(sessionId)?.queuedAttachmentFiles?.delete(promptId);
}
return {
sessionId,
removed: result.removed === true,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
import {
CLINE_CONNECTOR_CLI_LAUNCH_ENV,
readConnectorCliLaunchSpec,
} from "@cline/shared";
import { afterEach, describe, expect, it } from "vitest";
import { __test__, configureConnectorCliLaunch } from "./connectors";
describe("desktop connector lifecycle", () => {
const originalLaunchSpec = process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV];
afterEach(() => {
if (originalLaunchSpec === undefined) {
delete process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV];
} else {
process.env[CLINE_CONNECTOR_CLI_LAUNCH_ENV] = originalLaunchSpec;
}
});
it("registers the CLI connect command for a desktop-started daemon", () => {
const workspaceRoot = "/repo";
const expected = __test__.buildCliConnectCommand(workspaceRoot, []);
configureConnectorCliLaunch(workspaceRoot);
expect(readConnectorCliLaunchSpec()).toEqual({
launcher: expected.launcher,
connectArgsPrefix: expected.childArgs,
cwd: workspaceRoot,
});
});
it("uses the atomic restart command for an active channel", () => {
expect(
__test__.buildConnectorLaunchArgs(["telegram", "-k", "token"], true),
).toEqual(["--restart", "telegram", "-k", "token"]);
});
it("starts an inactive channel directly", () => {
expect(
__test__.buildConnectorLaunchArgs(["telegram", "-k", "token"], false),
).toEqual(["telegram", "-k", "token"]);
});
it("rejects a channel-wide restart when multiple instances are active", () => {
expect(() => __test__.shouldRestartConnector("telegram", 2)).toThrow(
"cannot safely restart telegram: 2 instances are active",
);
});
});
+72 -45
View File
@@ -2,13 +2,14 @@ import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { basename, join, normalize } from "node:path";
import process from "node:process";
import { withResolvedClineBuildEnv } from "@cline/shared";
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
import { listActiveConnectors } from "../../../cli/src/connectors/status";
import { listActiveConnectors } from "@cline/core";
import {
PLATFORMS,
shouldIncludeField,
} from "../../../cli/src/wizards/connect/platforms";
buildConnectorConnectArgs,
CONNECTOR_PLATFORMS,
listConnectorCatalog,
setConnectorCliLaunchSpec,
withResolvedClineBuildEnv,
} from "@cline/shared";
import type { JsonRecord } from "./types";
type ConnectorField = {
@@ -75,6 +76,16 @@ function asString(value: unknown): string | undefined {
return typeof value === "string" ? value.trim() || undefined : undefined;
}
function asStringRecord(value: unknown): Record<string, string> {
const record = asRecord(value);
if (!record) return {};
return Object.fromEntries(
Object.entries(record).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
}
function stripAnsi(value: string): string {
return value.replace(ANSI_ESCAPE_PATTERN, "");
}
@@ -125,12 +136,21 @@ function buildCliConnectCommand(
return { launcher, childArgs };
}
export function configureConnectorCliLaunch(workspaceRoot: string): void {
const command = buildCliConnectCommand(workspaceRoot, []);
setConnectorCliLaunchSpec({
launcher: command.launcher,
connectArgsPrefix: command.childArgs,
cwd: workspaceRoot,
});
}
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
);
const available: WebviewConnectorChannel[] = PLATFORMS.filter((platform) =>
supported.has(platform.id),
const available: WebviewConnectorChannel[] = CONNECTOR_PLATFORMS.filter(
(platform) => supported.has(platform.id),
).map((platform) => ({
id: platform.id,
name: platform.name,
@@ -211,7 +231,7 @@ async function waitForConnectorState(
function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
const channel = asString(args?.channel);
if (!channel) throw new Error("channel is required");
const platform = PLATFORMS.find((entry) => entry.id === channel);
const platform = CONNECTOR_PLATFORMS.find((entry) => entry.id === channel);
if (!platform) throw new Error(`unknown connector channel: ${channel}`);
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
@@ -219,42 +239,36 @@ function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
if (!supported.has(platform.id)) {
throw new Error(`connector channel is not available: ${channel}`);
}
const values = asRecord(args?.values) ?? {};
const fieldValues: Record<string, string> = {};
for (const field of platform.fields) {
const rawValue = values[field.flag];
if (typeof rawValue === "string") {
fieldValues[field.flag] = rawValue.trim();
} else if (field.initialValue) {
fieldValues[field.flag] = field.initialValue;
}
}
const cliArgs = [channel];
for (const field of platform.fields) {
if (!shouldIncludeField(field, fieldValues)) {
continue;
}
const value = fieldValues[field.flag];
if (!value) {
if (field.required) throw new Error(`${field.label} is required`);
continue;
}
cliArgs.push(field.flag, value);
}
const security = asRecord(args?.security);
if (security?.enabled === true && platform.security) {
const securityValues = asRecord(security.values) ?? {};
const hookValues: Record<string, string> = {};
for (const field of platform.security.fields) {
const value = asString(securityValues[field.key]);
if (!value) throw new Error(field.requiredMessage);
const validationError = field.validate?.(value);
if (validationError) throw new Error(validationError);
hookValues[field.key] = value;
}
cliArgs.push(...platform.security.buildArgs(hookValues));
return [
channel,
...buildConnectorConnectArgs(
platform,
asStringRecord(args?.values),
security
? {
enabled: security.enabled === true,
values: asStringRecord(security.values),
}
: undefined,
),
];
}
function buildConnectorLaunchArgs(
cliArgs: string[],
isRestart: boolean,
): string[] {
return isRestart ? ["--restart", ...cliArgs] : cliArgs;
}
function shouldRestartConnector(channel: string, activeCount: number): boolean {
if (activeCount > 1) {
throw new Error(
`cannot safely restart ${channel}: ${activeCount} instances are active; stop the intended instances explicitly first`,
);
}
return cliArgs;
return activeCount === 1;
}
export async function startConnectorChannel(
@@ -263,7 +277,14 @@ export async function startConnectorChannel(
): Promise<WebviewConnectorChannelsResponse> {
const cliArgs = buildConnectorStartArgs(args);
const channel = cliArgs[0] ?? "";
const result = await runCliConnectCommand(workspaceRoot, cliArgs);
const activeCount = listActiveConnectors().filter(
(connector) => connector.type === channel,
).length;
const isRestart = shouldRestartConnector(channel, activeCount);
const result = await runCliConnectCommand(
workspaceRoot,
buildConnectorLaunchArgs(cliArgs, isRestart),
);
if (result.code !== 0) {
throw new Error(
normalizeConnectorError(
@@ -278,6 +299,12 @@ export async function startConnectorChannel(
return connectorChannelsPayload();
}
export const __test__ = {
buildCliConnectCommand,
buildConnectorLaunchArgs,
shouldRestartConnector,
};
export async function stopConnectorChannel(
workspaceRoot: string,
args?: Record<string, unknown>,
@@ -290,7 +317,7 @@ export async function stopConnectorChannel(
if (!supported.has(channel)) {
throw new Error(`unknown connector channel: ${channel}`);
}
const result = await runCliConnectCommand(workspaceRoot, [channel, "--stop"]);
const result = await runCliConnectCommand(workspaceRoot, ["--stop", channel]);
if (result.code !== 0) {
throw new Error(
normalizeConnectorError(
+191 -42
View File
@@ -1,12 +1,19 @@
import { existsSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { RuntimeCapabilities } from "@cline/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SidecarContext } from "./types";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { materializeUserFiles } from "./attachments";
import type { LiveSession, SidecarContext } from "./types";
const createCoreMock = vi.hoisted(() => vi.fn());
const connectMock = vi.hoisted(() => vi.fn());
const ensureCompatibleLocalHubUrlMock = vi.hoisted(() => vi.fn());
const hubCommandMock = vi.hoisted(() => vi.fn());
const hubGetConnectionErrorMock = vi.hoisted(() => vi.fn());
const hubGetUrlMock = vi.hoisted(() => vi.fn());
const hubIsConnectedMock = vi.hoisted(() => vi.fn());
const nodeHubClientCtorMock = vi.hoisted(() => vi.fn());
const resolveHubOwnerContextMock = vi.hoisted(() => vi.fn());
const startHubWebSocketServerMock = vi.hoisted(() => vi.fn());
const subscribeMock = vi.hoisted(() => vi.fn());
vi.mock("@cline/core", async () => {
@@ -17,19 +24,16 @@ vi.mock("@cline/core", async () => {
ClineCore: {
create: createCoreMock,
},
createLocalHubScheduleRuntimeHandlers: vi.fn(() => ({
startSession: vi.fn(),
sendSession: vi.fn(),
abortSession: vi.fn(),
stopSession: vi.fn(),
})),
resolveHubOwnerContext: resolveHubOwnerContextMock,
startHubWebSocketServer: startHubWebSocketServerMock,
ensureCompatibleLocalHubUrl: ensureCompatibleLocalHubUrlMock,
NodeHubClient: class {
constructor(options: unknown) {
nodeHubClientCtorMock(options);
}
connect = connectMock;
command = hubCommandMock;
getConnectionError = hubGetConnectionErrorMock;
getUrl = hubGetUrlMock;
isConnected = hubIsConnectedMock;
subscribe = subscribeMock;
dispose = vi.fn();
},
@@ -53,20 +57,21 @@ describe("Code sidecar runtime capabilities", () => {
beforeEach(() => {
createCoreMock.mockReset();
connectMock.mockReset();
ensureCompatibleLocalHubUrlMock.mockReset();
hubCommandMock.mockReset();
hubGetConnectionErrorMock.mockReset();
hubGetUrlMock.mockReset();
hubIsConnectedMock.mockReset();
nodeHubClientCtorMock.mockReset();
resolveHubOwnerContextMock.mockReset();
startHubWebSocketServerMock.mockReset();
subscribeMock.mockReset();
connectMock.mockResolvedValue(undefined);
resolveHubOwnerContextMock.mockReturnValue({
ownerId: "code-sidecar-test",
discoveryPath: "/tmp/code-sidecar-test.json",
});
startHubWebSocketServerMock.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
close: vi.fn(),
});
ensureCompatibleLocalHubUrlMock.mockResolvedValue(
"ws://127.0.0.1:25463/hub",
);
hubCommandMock.mockResolvedValue({ ok: true, payload: {} });
hubGetConnectionErrorMock.mockReturnValue(null);
hubGetUrlMock.mockReturnValue("ws://127.0.0.1:25463/hub");
hubIsConnectedMock.mockReturnValue(true);
subscribeMock.mockReturnValue(() => {});
createCoreMock.mockResolvedValue({
runtimeAddress: "ws://127.0.0.1:25463/hub",
@@ -83,15 +88,6 @@ describe("Code sidecar runtime capabilities", () => {
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
expect(startHubWebSocketServerMock).toHaveBeenCalledWith(
expect.objectContaining({
port: 0,
owner: {
ownerId: "code-sidecar-test",
discoveryPath: "/tmp/code-sidecar-test.json",
},
}),
);
expect(createCoreMock).toHaveBeenCalledWith(
expect.objectContaining({
backendMode: "hub",
@@ -102,23 +98,27 @@ describe("Code sidecar runtime capabilities", () => {
requestToolApproval: expect.any(Function),
}),
hub: expect.objectContaining({
endpoint: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
strategy: "require-hub",
workspaceRoot: "/workspace/project",
cwd: "/workspace/project",
clientType: "code-sidecar",
displayName: "Code App sidecar",
}),
}),
);
const hubOptions = createCoreMock.mock.calls[0][0].hub;
expect(hubOptions).not.toHaveProperty("endpoint");
expect(hubOptions).not.toHaveProperty("authToken");
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
clientType: "code-sidecar-approvals",
clientType: "code-sidecar-observer",
displayName: "Code App observer",
}),
);
});
it("wires the desktop logger and telemetry through the client and embedded hub", async () => {
it("wires the desktop logger and telemetry through the shared Hub client", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
@@ -135,9 +135,6 @@ describe("Code sidecar runtime capabilities", () => {
await initializeSessionManager(ctx);
expect(startHubWebSocketServerMock).toHaveBeenCalledWith(
expect.objectContaining({ logger, telemetry }),
);
expect(createCoreMock).toHaveBeenCalledWith(
expect.objectContaining({
clientName: "cline-code",
@@ -147,6 +144,84 @@ describe("Code sidecar runtime capabilities", () => {
);
});
it("reports the connected shared Hub endpoint in process context", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const { handleCommand } = await import("./commands");
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
const session = {
config: {},
messages: [],
promptsInQueue: [],
busy: true,
startedAt: Date.now(),
status: "running",
} satisfies LiveSession;
ctx.liveSessions.set("running-session", session);
ctx.liveSessions.set("idle-session", {
...session,
busy: false,
status: "idle",
});
await expect(handleCommand(ctx, "get_process_context")).resolves.toEqual(
expect.objectContaining({
runningSessionCount: 1,
hub: {
status: "connected",
url: "ws://127.0.0.1:25463/hub",
error: null,
},
}),
);
});
it("starts or reuses the shared Hub when a command needs a client", async () => {
const { createSidecarContext, ensureSharedHubClient } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
const hubClient = await ensureSharedHubClient(ctx);
expect(hubClient).toBe(ctx.hubClient);
expect(ensureCompatibleLocalHubUrlMock).toHaveBeenCalledWith({
strategy: "require-hub",
workspaceRoot: "/workspace/project",
cwd: "/workspace/project",
});
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:25463/hub",
clientType: "code-sidecar-observer",
}),
);
expect(connectMock).toHaveBeenCalledOnce();
});
it("serializes queued image data when a queued prompt starts", async () => {
const { serializeQueuedPromptStart } = await import("./context");
expect(
JSON.parse(
serializeQueuedPromptStart({
promptId: "queued-prompt-1",
prompt: "Describe this",
attachmentCount: 1,
userImages: ["data:image/png;base64,AQID"],
}),
),
).toEqual({
promptId: "queued-prompt-1",
prompt: "Describe this",
attachmentCount: 1,
userImages: ["data:image/png;base64,AQID"],
});
});
it("resolves askQuestion through the websocket request/response protocol", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
@@ -221,8 +296,7 @@ describe("Code sidecar runtime capabilities", () => {
requestToolApproval: expect.any(Function),
}),
hub: expect.objectContaining({
endpoint: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
strategy: "require-hub",
clientType: "code-sidecar",
displayName: "Code App sidecar",
}),
@@ -282,4 +356,79 @@ describe("Code sidecar runtime capabilities", () => {
await handleCommand(ctx, "poll_tool_approvals", { sessionId: "sess-1" }),
).toEqual([]);
});
it("routes routine commands through the connected shared Hub client", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const { handleCommand } = await import("./commands");
hubCommandMock.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "schedule-1", enabled: false } },
});
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
await expect(
handleCommand(ctx, "pause_routine_schedule", {
schedule_id: "schedule-1",
}),
).resolves.toEqual({
schedule: { scheduleId: "schedule-1", enabled: false },
});
expect(hubCommandMock).toHaveBeenCalledWith("schedule.disable", {
scheduleId: "schedule-1",
});
});
});
describe("disposeSidecarContext attachment cleanup", () => {
let previousSessionDataDir: string | undefined;
let testSessionDataDir: string;
beforeEach(() => {
previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
testSessionDataDir = join(
tmpdir(),
`cline-desktop-dispose-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
});
afterEach(() => {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
});
it("deletes tracked attachments for all live sessions on shutdown", async () => {
const { createSidecarContext, disposeSidecarContext } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
const sessionId = "dispose-session";
const [queuedFile] = materializeUserFiles(sessionId, [
{ name: "queued.txt", content: "q" },
]) as string[];
const session: LiveSession = {
config: {},
messages: [],
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
queuedAttachmentFiles: new Map([["pending_1", [queuedFile]]]),
};
ctx.liveSessions.set(sessionId, session);
await disposeSidecarContext(ctx, "test_shutdown");
expect(existsSync(queuedFile)).toBe(false);
expect(ctx.liveSessions.size).toBe(0);
});
});
+139 -47
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import { appendFile, mkdir } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname } from "node:path";
import {
@@ -7,17 +7,21 @@ import {
type BasicLogger,
ClineCore,
type CoreSessionEvent,
createLocalHubScheduleRuntimeHandlers,
ensureCompatibleLocalHubUrl,
type ITelemetryService,
NodeHubClient,
type RuntimeCapabilities,
resolveHubOwnerContext,
setHomeDirIfUnset,
startHubWebSocketServer,
type ToolApprovalRequest,
type ToolApprovalResult,
} from "@cline/core";
import type { AgentEvent } from "@cline/shared";
import {
discardAllTrackedAttachments,
flushConsumedAttachments,
markQueuedAttachmentsSubmitted,
reconcileQueuedAttachments,
} from "./attachments";
import { sessionLogPath } from "./paths";
import type {
LiveSession,
@@ -28,6 +32,10 @@ import type {
} from "./types";
const ASK_QUESTION_TIMEOUT_MS = 5 * 60_000;
const hubClientInitialization = new WeakMap<
SidecarContext,
Promise<NodeHubClient>
>();
// ---------------------------------------------------------------------------
// Helpers — WebSocket broadcast
@@ -51,6 +59,11 @@ function sendEvent(ctx: SidecarContext, name: string, payload: unknown): void {
}
}
// Session log appends are chained per session so writes stay ordered, but
// they run asynchronously: a synchronous write per streamed token would stall
// the sidecar event loop (and therefore every pending UI command) under load.
const sessionLogWriteTails = new Map<string, Promise<void>>();
function appendSessionChunk(
sessionId: string,
stream: string,
@@ -58,9 +71,21 @@ function appendSessionChunk(
ts: number,
): void {
const path = sessionLogPath(sessionId);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify({ ts, stream, chunk })}\n`, {
flag: "a",
const line = `${JSON.stringify({ ts, stream, chunk })}\n`;
const tail = sessionLogWriteTails.get(sessionId) ?? Promise.resolve();
const next = tail
.then(async () => {
await mkdir(dirname(path), { recursive: true });
await appendFile(path, line);
})
.catch(() => {
// Session logs are best-effort diagnostics; never fail the stream.
});
sessionLogWriteTails.set(sessionId, next);
void next.finally(() => {
if (sessionLogWriteTails.get(sessionId) === next) {
sessionLogWriteTails.delete(sessionId);
}
});
}
@@ -111,7 +136,29 @@ export function broadcastChunk(
// ---------------------------------------------------------------------------
function getPromptsInQueue(session: LiveSession): PromptInQueue[] {
return session.promptsInQueue;
return session.promptsInQueue.map(
({ id, prompt, steer, attachmentCount, userImages }) => ({
id,
prompt,
steer,
attachmentCount,
userImages,
}),
);
}
export function serializeQueuedPromptStart(input: {
promptId: string;
prompt: string;
attachmentCount?: number;
userImages?: string[];
}): string {
return JSON.stringify({
promptId: input.promptId,
prompt: input.prompt,
attachmentCount: input.attachmentCount ?? 0,
userImages: input.userImages,
});
}
function sendPromptsInQueueSnapshot(
@@ -303,9 +350,16 @@ function handleCoreSessionEvent(
prompt: item.prompt ?? "",
steer: item.delivery === "steer",
attachmentCount: item.attachmentCount ?? 0,
userImages: item.userImages,
}))
.filter((item) => item.id && item.prompt);
.filter(
(item) => item.id && (item.prompt || (item.attachmentCount ?? 0) > 0),
);
if (session) {
reconcileQueuedAttachments(
session,
mapped.map((item) => item.id),
);
const previous = session.promptsInQueue;
session.promptsInQueue = mapped;
if (
@@ -317,9 +371,11 @@ function handleCoreSessionEvent(
ctx,
sessionId,
"chat_queued_prompt_start",
JSON.stringify({
serializeQueuedPromptStart({
promptId: previous[0].id,
prompt: previous[0].prompt,
attachmentCount: previous[0].attachmentCount ?? 0,
userImages: previous[0].userImages,
}),
);
}
@@ -328,14 +384,18 @@ function handleCoreSessionEvent(
break;
}
case "pending_prompt_submitted": {
const { sessionId, prompt, attachmentCount } = event.payload;
const { sessionId, id, prompt, attachmentCount, userImages } =
event.payload;
markQueuedAttachmentsSubmitted(ctx.liveSessions.get(sessionId), id);
emitChunk(
ctx,
sessionId,
"chat_queued_prompt_start",
JSON.stringify({
serializeQueuedPromptStart({
promptId: id,
prompt,
attachmentCount: attachmentCount ?? 0,
userImages,
}),
);
break;
@@ -348,6 +408,7 @@ function handleCoreSessionEvent(
session.endedAt = nowMs();
session.status = reason || "ended";
}
discardAllTrackedAttachments(sessionId, session);
sendEvent(ctx, "chat_session_ended", { sessionId, reason });
break;
}
@@ -367,6 +428,10 @@ function handleCoreSessionEvent(
if (session) {
session.status = status;
session.busy = status === "running";
if (status !== "running") {
// The turn that consumed submitted attachments has finished.
flushConsumedAttachments(sessionId, session);
}
}
sendEvent(ctx, "chat_session_status", { sessionId, status });
break;
@@ -397,7 +462,6 @@ export function createSidecarContext(
pendingQuestions: new Map(),
sessionManager: null,
hubClient: null,
hubServer: null,
workspaceRoot,
logger: observability.logger,
telemetry: observability.telemetry,
@@ -414,6 +478,11 @@ export async function disposeSidecarContext(
ctx.unsubscribeSessionEvents?.();
ctx.unsubscribeSessionEvents = null;
for (const [sessionId, session] of ctx.liveSessions) {
discardAllTrackedAttachments(sessionId, session);
}
ctx.liveSessions.clear();
for (const client of ctx.wsClients) {
try {
client.close?.();
@@ -444,12 +513,6 @@ export async function disposeSidecarContext(
cleanup.push(sessionManager.dispose(reason));
}
const hubServer = ctx.hubServer;
ctx.hubServer = null;
if (hubServer) {
cleanup.push(hubServer.close());
}
const results = await Promise.allSettled(cleanup);
const firstFailure = results.find(
(result): result is PromiseRejectedResult => result.status === "rejected",
@@ -702,15 +765,6 @@ export async function initializeSessionManager(
ctx: SidecarContext,
): Promise<void> {
setHomeDirIfUnset(homedir());
const hubServer = await startHubWebSocketServer({
port: 0,
owner: resolveHubOwnerContext(
`code-sidecar:${process.pid}:${randomUUID()}`,
),
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
logger: ctx.logger,
telemetry: ctx.telemetry,
});
const sessionManager = await ClineCore.create({
clientName: "cline-code",
backendMode: "hub",
@@ -718,8 +772,7 @@ export async function initializeSessionManager(
logger: ctx.logger,
telemetry: ctx.telemetry,
hub: {
endpoint: hubServer.url,
authToken: hubServer.authToken,
strategy: "require-hub",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
clientType: "code-sidecar",
@@ -732,25 +785,64 @@ export async function initializeSessionManager(
handleCoreSessionEvent(ctx, event);
});
const runtimeAddress = sessionManager.runtimeAddress?.trim();
let hubClient: NodeHubClient | null = null;
if (runtimeAddress) {
hubClient = new NodeHubClient({
url: runtimeAddress,
authToken: hubServer.authToken,
clientType: "code-sidecar-approvals",
displayName: "Code App approvals",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
});
await hubClient.connect();
hubClient.subscribe((event) => {
handleHubLiveEvent(ctx, event);
});
try {
await ensureSharedHubClient(ctx, sessionManager.runtimeAddress);
} catch (error) {
unsubscribe();
await sessionManager.dispose("code_sidecar_hub_initialization_failed");
throw error;
}
ctx.sessionManager = sessionManager;
ctx.hubClient = hubClient;
ctx.hubServer = hubServer;
ctx.unsubscribeSessionEvents = unsubscribe;
}
export async function ensureSharedHubClient(
ctx: SidecarContext,
preferredUrl?: string,
): Promise<NodeHubClient> {
if (ctx.hubClient) {
return ctx.hubClient;
}
const pending = hubClientInitialization.get(ctx);
if (pending) {
return await pending;
}
const initialization = (async () => {
const url =
preferredUrl?.trim() ||
(await ensureCompatibleLocalHubUrl({
strategy: "require-hub",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
}));
if (!url) {
throw new Error("Unable to start or connect to the shared Cline Hub.");
}
const client = new NodeHubClient({
url,
clientType: "code-sidecar-observer",
displayName: "Code App observer",
workspaceRoot: ctx.workspaceRoot,
cwd: ctx.workspaceRoot,
});
try {
await client.connect();
client.subscribe((event) => {
handleHubLiveEvent(ctx, event);
});
ctx.hubClient = client;
return client;
} catch (error) {
await client.dispose().catch(() => undefined);
throw error;
}
})().finally(() => {
hubClientInitialization.delete(ctx);
});
hubClientInitialization.set(ctx, initialization);
return await initialization;
}
+24 -1
View File
@@ -1,6 +1,8 @@
import { homedir } from "node:os";
import { setHomeDirIfUnset } from "@cline/core";
import { isHubDaemonProcess } from "@cline/shared";
import { prewarmWorkspaceMetadata } from "./chat-session";
import { configureConnectorCliLaunch } from "./connectors";
import {
createSidecarContext,
disposeSidecarContext,
@@ -9,6 +11,7 @@ import {
import { createDesktopObservability } from "./observability";
import { resolveWorkspaceRoot } from "./paths";
import { startServer } from "./server";
import { ensureLoginShellPath } from "./shell-path";
import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types";
const SHUTDOWN_TIMEOUT_MS = 5_000;
@@ -38,8 +41,16 @@ async function main() {
throw new Error("sidecar must be run with Bun");
}
// When launched from Finder/the Dock the app inherits launchd's minimal
// PATH, so agent-spawned processes can't find shell-profile-installed
// tools like `gh`. Kick resolution off first so it overlaps the rest of
// startup, but await it before the session manager exists — that's what
// spawns children (agent sessions, MCP servers, scheduled runs).
const shellPathPromise = ensureLoginShellPath();
const workspaceRoot = resolveWorkspaceRoot(process.cwd());
setHomeDirIfUnset(homedir());
configureConnectorCliLaunch(workspaceRoot);
const observability = createDesktopObservability();
activeObservability = observability;
const ctx = createSidecarContext(workspaceRoot, observability);
@@ -49,6 +60,10 @@ async function main() {
});
prewarmWorkspaceMetadata(workspaceRoot);
observability.logger.log(
"Login shell PATH resolution",
await shellPathPromise,
);
await initializeSessionManager(ctx);
let shuttingDown = false;
@@ -121,7 +136,15 @@ async function main() {
);
}
main().catch(async (error) => {
async function runEntrypoint(): Promise<void> {
if (isHubDaemonProcess()) {
await import("@cline/core/hub/daemon-entry");
return;
}
await main();
}
runEntrypoint().catch(async (error) => {
const message = error instanceof Error ? error.message : String(error);
activeObservability?.logger.error?.("Desktop sidecar process failed", {
error,
@@ -0,0 +1,164 @@
import type { ProviderSettingsManager } from "@cline/core";
import { describe, expect, it, vi } from "vitest";
import {
cancelProviderOAuthLogin,
cancelProviderOAuthLoginsForOwner,
OAuthLoginCancelledError,
runCancellableProviderOAuthLogin,
} from "./oauth-login";
type Credentials = { accessToken: string };
function makeManager(): ProviderSettingsManager {
return {
getProviderSettings: () => undefined,
} as unknown as ProviderSettingsManager;
}
function makeDependencies(overrides: {
login: () => Promise<Credentials>;
save?: ReturnType<typeof vi.fn>;
}) {
const save =
overrides.save ??
vi.fn(() => ({
provider: "cline",
auth: { accessToken: "saved-token" },
}));
return {
dependencies: {
login: overrides.login as never,
save: save as never,
markEnabled: vi.fn() as never,
},
save,
};
}
describe("runCancellableProviderOAuthLogin", () => {
it("saves credentials and returns the access token on success", async () => {
const { dependencies, save } = makeDependencies({
login: async () => ({ accessToken: "fresh-token" }),
});
const result = await runCancellableProviderOAuthLogin(
makeManager(),
"cline",
() => undefined,
{},
dependencies,
);
expect(save).toHaveBeenCalledTimes(1);
expect(result).toEqual({ provider: "cline", accessToken: "saved-token" });
});
it("rejects promptly on cancel and never persists a late completion", async () => {
let resolveLogin: (credentials: Credentials) => void = () => undefined;
const { dependencies, save } = makeDependencies({
login: () =>
new Promise<Credentials>((resolve) => {
resolveLogin = resolve;
}),
});
const pending = runCancellableProviderOAuthLogin(
makeManager(),
"cline",
() => undefined,
{},
dependencies,
);
// Cancellation must reject the pending login right away, without
// waiting for the browser round-trip to finish.
expect(cancelProviderOAuthLogin("cline")).toBe(true);
await expect(pending).rejects.toBeInstanceOf(OAuthLoginCancelledError);
// The user completes the abandoned browser flow afterwards: the
// credentials must be discarded, not saved.
resolveLogin({ accessToken: "late-token" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(save).not.toHaveBeenCalled();
});
it("reports when there is no pending login to cancel", () => {
expect(cancelProviderOAuthLogin("cline")).toBe(false);
});
it("cancels a dangling attempt when a new login starts for the provider", async () => {
let resolveFirstLogin: (credentials: Credentials) => void = () => undefined;
const firstSave = vi.fn(() => ({
provider: "cline",
auth: { accessToken: "first-token" },
}));
const first = runCancellableProviderOAuthLogin(
makeManager(),
"cline",
() => undefined,
{},
makeDependencies({
login: () =>
new Promise<Credentials>((resolve) => {
resolveFirstLogin = resolve;
}),
save: firstSave,
}).dependencies,
);
const { dependencies: secondDependencies, save: secondSave } =
makeDependencies({
login: async () => ({ accessToken: "second-token" }),
});
const second = runCancellableProviderOAuthLogin(
makeManager(),
"cline",
() => undefined,
{},
secondDependencies,
);
await expect(first).rejects.toBeInstanceOf(OAuthLoginCancelledError);
await expect(second).resolves.toEqual({
provider: "cline",
accessToken: "saved-token",
});
// The first attempt's late completion is discarded.
resolveFirstLogin({ accessToken: "first-token" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(firstSave).not.toHaveBeenCalled();
expect(secondSave).toHaveBeenCalledTimes(1);
});
it("cancels pending logins when their transport connection closes", async () => {
let resolveLogin: (credentials: Credentials) => void = () => undefined;
const { dependencies, save } = makeDependencies({
login: () =>
new Promise<Credentials>((resolve) => {
resolveLogin = resolve;
}),
});
const connection = {};
const pending = runCancellableProviderOAuthLogin(
makeManager(),
"cline",
() => undefined,
{ owner: connection },
dependencies,
);
// A different connection closing must not cancel this login.
expect(cancelProviderOAuthLoginsForOwner({})).toBe(0);
// The initiating connection closing cancels it, so a lost cancel
// command (transport drop, webview reload) cannot leave an abandoned
// flow that persists credentials later.
expect(cancelProviderOAuthLoginsForOwner(connection)).toBe(1);
await expect(pending).rejects.toBeInstanceOf(OAuthLoginCancelledError);
resolveLogin({ accessToken: "late-token" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(save).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,129 @@
import type { ProviderSettingsManager } from "@cline/core";
import {
getProviderAuthStorageId,
loginLocalProvider,
markLocalProviderEnabled,
saveLocalProviderOAuthCredentials,
} from "@cline/core";
export class OAuthLoginCancelledError extends Error {
constructor(providerId: string) {
super(`Sign-in was cancelled for provider "${providerId}"`);
this.name = "OAuthLoginCancelledError";
}
}
type PendingOAuthLogin = {
cancelled: boolean;
cancel: () => void;
/** Transport connection that initiated the login, when known. */
owner?: object;
};
// One pending browser round-trip per provider. Starting a new login for the
// same provider cancels the previous dangling attempt so an abandoned browser
// tab can never race a fresh sign-in.
const pendingOAuthLoginsByProvider = new Map<string, PendingOAuthLogin>();
export type OAuthLoginDependencies = {
login: typeof loginLocalProvider;
save: typeof saveLocalProviderOAuthCredentials;
markEnabled: typeof markLocalProviderEnabled;
};
const defaultDependencies: OAuthLoginDependencies = {
login: loginLocalProvider,
save: saveLocalProviderOAuthCredentials,
markEnabled: markLocalProviderEnabled,
};
/**
* Runs a provider OAuth login that can be cancelled while the browser
* round-trip is pending. Cancellation rejects the returned promise right away
* AND guarantees the credentials of a late-completing browser flow are never
* persisted, so the UI's signed-out state cannot diverge from disk.
*/
export async function runCancellableProviderOAuthLogin(
manager: ProviderSettingsManager,
providerId: string,
openUrl: (url: string) => void,
options: { owner?: object } = {},
dependencies: OAuthLoginDependencies = defaultDependencies,
): Promise<{ provider: string; accessToken: string }> {
const storageProviderId = getProviderAuthStorageId(providerId) ?? providerId;
const existing = manager.getProviderSettings(storageProviderId);
pendingOAuthLoginsByProvider.get(providerId)?.cancel();
let rejectOnCancel: (error: Error) => void = () => undefined;
const cancellation = new Promise<never>((_, reject) => {
rejectOnCancel = reject;
});
const entry: PendingOAuthLogin = {
cancelled: false,
cancel: () => {
entry.cancelled = true;
rejectOnCancel(new OAuthLoginCancelledError(providerId));
},
owner: options.owner,
};
pendingOAuthLoginsByProvider.set(providerId, entry);
try {
// Promise.race subscribes to the login promise, so a late rejection
// after cancellation is observed and cannot become an unhandled
// rejection that kills the sidecar.
const credentials = await Promise.race([
dependencies.login(providerId, existing, openUrl),
cancellation,
]);
if (entry.cancelled) {
throw new OAuthLoginCancelledError(providerId);
}
const saved = dependencies.save(manager, providerId, existing, credentials);
if (saved.provider !== providerId) {
dependencies.markEnabled(manager, providerId, { tokenSource: "oauth" });
}
return {
provider: providerId,
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
};
} finally {
if (pendingOAuthLoginsByProvider.get(providerId) === entry) {
pendingOAuthLoginsByProvider.delete(providerId);
}
}
}
/**
* Cancels the pending OAuth login for a provider, if any. Returns whether a
* pending login existed. The cancelled attempt's credentials are discarded
* even if the user later completes the already-open browser flow.
*/
export function cancelProviderOAuthLogin(providerId: string): boolean {
const entry = pendingOAuthLoginsByProvider.get(providerId);
if (!entry) {
return false;
}
entry.cancel();
pendingOAuthLoginsByProvider.delete(providerId);
return true;
}
/**
* Cancels every pending OAuth login initiated by a transport connection.
* Called when that connection closes so a lost or undeliverable cancel
* command (e.g. the webview reloaded or the transport dropped) can never
* leave an abandoned browser flow that persists credentials later.
*/
export function cancelProviderOAuthLoginsForOwner(owner: object): number {
let cancelled = 0;
for (const [providerId, entry] of pendingOAuthLoginsByProvider) {
if (entry.owner === owner) {
entry.cancel();
pendingOAuthLoginsByProvider.delete(providerId);
cancelled += 1;
}
}
return cancelled;
}
+8 -1
View File
@@ -2,6 +2,7 @@ import type { DesktopTransportRequest } from "../webview/lib/desktop-transport";
import { handleCommand } from "./commands";
import { sendEvent } from "./context";
import { fetchMarketplaceCatalog } from "./marketplace";
import { cancelProviderOAuthLoginsForOwner } from "./oauth-login";
import {
BunRuntime,
SIDECAR_HOST,
@@ -237,7 +238,9 @@ function createWebSocketHandler(ctx: SidecarContext) {
return;
}
try {
const result = await handleCommand(ctx, request.command, request.args);
const result = await handleCommand(ctx, request.command, request.args, {
connection: ws,
});
ws.send(jsonResponse(request.id, true, result));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -246,6 +249,10 @@ function createWebSocketHandler(ctx: SidecarContext) {
},
close(ws: SidecarWebSocketClient) {
ctx.wsClients.delete(ws);
// OAuth logins are interactive: if the connection that started one
// goes away (webview reload, transport drop), cancel it so the
// abandoned browser flow can never persist credentials later.
cancelProviderOAuthLoginsForOwner(ws);
},
};
}
@@ -0,0 +1,298 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type StoreRecord = {
sessionId: string;
agentId?: string;
parentAgentId?: string;
parentSessionId?: string;
status: string;
prompt?: string;
teamName?: string;
provider: string;
model: string;
startedAt: string;
endedAt?: string | null;
messagesPath?: string;
};
const records = new Map<string, StoreRecord>();
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
SqliteSessionStore: class {
listChildren(parentSessionId: string, limit = 200) {
return [...records.values()]
.filter((record) => record.parentSessionId === parentSessionId)
.sort((a, b) => a.startedAt.localeCompare(b.startedAt))
.slice(0, limit);
}
get(sessionId: string) {
return records.get(sessionId);
}
},
};
});
import { listSessionAgents, readChildSessionMessages } from "./agents";
let dir: string;
function writeMessages(name: string, messages: unknown[]): string {
const path = join(dir, name);
writeFileSync(path, JSON.stringify({ messages }), "utf8");
return path;
}
const ROOT = "root1";
function record(overrides: Partial<StoreRecord> & { sessionId: string }) {
return {
agentId: "agent1",
parentSessionId: ROOT,
status: "completed",
provider: "anthropic",
model: "claude-opus-5",
startedAt: "2026-07-27T00:00:00.000Z",
...overrides,
} satisfies StoreRecord;
}
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "agents-test-"));
records.clear();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe("listSessionAgents", () => {
it("returns nothing for a blank session id", () => {
expect(listSessionAgents("")).toEqual([]);
expect(listSessionAgents(" ")).toEqual([]);
});
it("lists only children of the requested root", () => {
records.set("root1__a", record({ sessionId: "root1__a", agentId: "a" }));
records.set(
"other__b",
record({
sessionId: "other__b",
agentId: "b",
parentSessionId: "other",
}),
);
const agents = listSessionAgents(ROOT);
expect(agents.map((agent) => agent.agentId)).toEqual(["a"]);
});
it("classifies team-task children apart from subagents", () => {
records.set("root1__a", record({ sessionId: "root1__a", agentId: "a" }));
records.set(
"root1__teamtask__b__x1",
record({
sessionId: "root1__teamtask__b__x1",
agentId: "b",
teamName: "platform",
startedAt: "2026-07-27T00:01:00.000Z",
}),
);
const agents = listSessionAgents(ROOT);
expect(agents.map((agent) => agent.kind)).toEqual(["subagent", "teamtask"]);
expect(agents[1]?.teamName).toBe("platform");
});
it("skips rows with no agent id", () => {
records.set(
"root1__a",
record({ sessionId: "root1__a", agentId: undefined }),
);
expect(listSessionAgents(ROOT)).toEqual([]);
});
it("reports whether a transcript exists yet", () => {
records.set(
"root1__written",
record({
sessionId: "root1__written",
agentId: "written",
messagesPath: writeMessages("written.json", [
{ role: "assistant", content: "done" },
]),
}),
);
records.set(
"root1__empty",
record({
sessionId: "root1__empty",
agentId: "empty",
status: "running",
messagesPath: writeMessages("empty.json", []),
startedAt: "2026-07-27T00:01:00.000Z",
}),
);
records.set(
"root1__missing",
record({
sessionId: "root1__missing",
agentId: "missing",
status: "running",
messagesPath: join(dir, "nope.json"),
startedAt: "2026-07-27T00:02:00.000Z",
}),
);
const agents = listSessionAgents(ROOT);
expect(agents.map((agent) => [agent.agentId, agent.hasMessages])).toEqual([
["written", true],
["empty", false],
["missing", false],
]);
});
it("preserves the persisted status", () => {
records.set(
"root1__a",
record({ sessionId: "root1__a", agentId: "a", status: "failed" }),
);
expect(listSessionAgents(ROOT)[0]?.status).toBe("failed");
});
});
describe("readChildSessionMessages", () => {
it("reads a child transcript from the path recorded on its row", () => {
records.set(
"root1__a",
record({
sessionId: "root1__a",
agentId: "a",
messagesPath: writeMessages("a.json", [
{ role: "user", content: "do the thing" },
{ role: "assistant", content: "did the thing" },
]),
}),
);
expect(JSON.stringify(readChildSessionMessages("root1__a"))).toContain(
"did the thing",
);
});
it("returns null for a root session so ordinary reads are untouched", () => {
records.set(
"root1",
record({ sessionId: "root1", parentSessionId: undefined }),
);
expect(readChildSessionMessages("root1")).toBeNull();
});
it("returns null for an unknown or blank session", () => {
expect(readChildSessionMessages("root1__ghost")).toBeNull();
expect(readChildSessionMessages(" ")).toBeNull();
});
it("returns null when the recorded transcript has not been written yet", () => {
records.set(
"root1__a",
record({
sessionId: "root1__a",
agentId: "a",
status: "running",
messagesPath: join(dir, "not-yet.json"),
}),
);
expect(readChildSessionMessages("root1__a")).toBeNull();
});
});
describe("listSessionAgents last action", () => {
it("reports the most recent tool call", () => {
records.set(
"root1__a",
record({
sessionId: "root1__a",
agentId: "a",
status: "running",
messagesPath: writeMessages("a.json", [
{ role: "user", content: "investigate" },
{
role: "assistant",
content: [
{ type: "text", text: "Let me look around." },
{ type: "tool_use", name: "read_files", input: {} },
],
},
]),
}),
);
expect(listSessionAgents(ROOT)[0]?.lastAction).toBe("Running read_files");
});
it("falls back to the latest assistant text", () => {
records.set(
"root1__a",
record({
sessionId: "root1__a",
agentId: "a",
messagesPath: writeMessages("a.json", [
{ role: "assistant", content: [{ type: "tool_use", name: "grep" }] },
{ role: "user", content: [{ type: "tool_result", content: "hits" }] },
{ role: "assistant", content: "Found three call sites." },
]),
}),
);
expect(listSessionAgents(ROOT)[0]?.lastAction).toBe(
"Found three call sites.",
);
});
it("skips reasoning blocks, which are intent rather than action", () => {
records.set(
"root1__a",
record({
sessionId: "root1__a",
agentId: "a",
messagesPath: writeMessages("a.json", [
{
role: "assistant",
content: [
{ type: "text", text: "Checked the parser." },
{ type: "thinking", thinking: "I should double check" },
],
},
]),
}),
);
expect(listSessionAgents(ROOT)[0]?.lastAction).toBe("Checked the parser.");
});
it("collapses whitespace and truncates a long action", () => {
records.set(
"root1__a",
record({
sessionId: "root1__a",
agentId: "a",
messagesPath: writeMessages("a.json", [
{ role: "assistant", content: `line one\n\n${"x".repeat(400)}` },
]),
}),
);
const lastAction = listSessionAgents(ROOT)[0]?.lastAction ?? "";
expect(lastAction.endsWith("...")).toBe(true);
expect(lastAction.length).toBeLessThanOrEqual(163);
expect(lastAction).not.toContain("\n");
});
it("leaves the action undefined when no transcript exists", () => {
records.set(
"root1__a",
record({ sessionId: "root1__a", agentId: "a", status: "running" }),
);
expect(listSessionAgents(ROOT)[0]?.lastAction).toBeUndefined();
});
});
@@ -0,0 +1,183 @@
import { existsSync, readFileSync } from "node:fs";
import { SqliteSessionStore } from "@cline/core";
/**
* Child agents of a chat session: `spawn_agent` subagent runs and team-task
* teammate runs. Both are persisted as sessions parented to the root session
* (see the SDK's team-child-session-manager), which is the only place their
* real status lives the tool events forwarded to the webview carry no agent
* attribution at all.
*/
export type SessionAgentRecord = {
sessionId: string;
agentId: string;
parentAgentId?: string;
/** "subagent" for spawn_agent runs, "teamtask" for team-task runs. */
kind: "subagent" | "teamtask";
status: string;
/** The task the agent was given — its first prompt. */
prompt?: string;
/** Most recent thing the agent did, for an at-a-glance progress line. */
lastAction?: string;
teamName?: string;
provider?: string;
model?: string;
startedAt: string;
endedAt?: string;
/** Whether a transcript exists to open yet. */
hasMessages: boolean;
};
const TEAM_TASK_MARKER = "__teamtask__";
const LAST_ACTION_LIMIT = 160;
function readMessagesFile(path: string): unknown[] | null {
if (!path || !existsSync(path)) {
return null;
}
try {
const parsed = JSON.parse(readFileSync(path, "utf8")) as
| { messages?: unknown[] }
| unknown[];
if (Array.isArray(parsed)) {
return parsed;
}
return Array.isArray(parsed.messages) ? parsed.messages : [];
} catch {
return null;
}
}
/**
* A child row records where its transcript was written. Deriving the path here
* instead would mean duplicating the SDK's artifact layout, which nests child
* transcripts under the root session directory keyed by agent id.
*/
function resolveChildMessagesPath(record: {
messagesPath?: string;
}): string | undefined {
return record.messagesPath?.trim() || undefined;
}
/**
* Transcript of a child agent session, or null when the id is not a child or
* nothing has been written yet. Lets the ordinary session-reading path open a
* subagent session without knowing where child artifacts live.
*/
export function readChildSessionMessages(sessionId: string): unknown[] | null {
const trimmed = sessionId.trim();
if (!trimmed) {
return null;
}
let record: { parentSessionId?: string; messagesPath?: string } | undefined;
try {
record = new SqliteSessionStore().get(trimmed);
} catch {
// No session database on this host; nothing to resolve.
return null;
}
if (!record?.parentSessionId) {
return null;
}
const messagesPath = resolveChildMessagesPath(record);
return messagesPath ? readMessagesFile(messagesPath) : null;
}
function truncate(value: string, limit = LAST_ACTION_LIMIT): string {
const collapsed = value.replace(/\s+/g, " ").trim();
return collapsed.length > limit
? `${collapsed.slice(0, limit)}...`
: collapsed;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
/**
* Summarize the agent's most recent step from the tail of its transcript.
*
* A tool call names itself usefully ("Running read_files"), so it wins over
* prose; assistant text is the fallback. Reasoning blocks are skipped they
* describe intent rather than an action taken.
*/
function deriveLastAction(messages: unknown[]): string | undefined {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = asRecord(messages[index]);
if (!message || message.role !== "assistant") {
continue;
}
const content = message.content;
if (typeof content === "string") {
const text = truncate(content);
if (text) {
return text;
}
continue;
}
if (!Array.isArray(content)) {
continue;
}
for (
let blockIndex = content.length - 1;
blockIndex >= 0;
blockIndex -= 1
) {
const block = asRecord(content[blockIndex]);
if (!block) {
continue;
}
if (block.type === "tool_use" && typeof block.name === "string") {
return `Running ${block.name}`;
}
if (block.type === "text" && typeof block.text === "string") {
const text = truncate(block.text);
if (text) {
return text;
}
}
}
}
return undefined;
}
export function listSessionAgents(
rootSessionId: string,
limit = 200,
): SessionAgentRecord[] {
const trimmedRootId = rootSessionId.trim();
if (!trimmedRootId) {
return [];
}
const store = new SqliteSessionStore();
const out: SessionAgentRecord[] = [];
for (const record of store.listChildren(trimmedRootId, limit)) {
const agentId = record.agentId?.trim();
if (!agentId) {
continue;
}
const messagesPath = resolveChildMessagesPath(record);
const messages = messagesPath ? readMessagesFile(messagesPath) : null;
out.push({
sessionId: record.sessionId,
agentId,
parentAgentId: record.parentAgentId,
kind: record.sessionId.includes(TEAM_TASK_MARKER)
? "teamtask"
: "subagent",
status: record.status,
prompt: record.prompt,
lastAction: messages?.length ? deriveLastAction(messages) : undefined,
teamName: record.teamName,
provider: record.provider || undefined,
model: record.model || undefined,
startedAt: record.startedAt,
endedAt: record.endedAt ?? undefined,
hasMessages: Boolean(messages && messages.length > 0),
});
}
return out;
}
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import { readSessionMessages } from "./messages";
describe("readSessionMessages", () => {
it("preserves each stored message timestamp across projected blocks", async () => {
const sessionId = `timestamp-projection-${Date.now()}`;
const userTimestamp = 1_781_041_621_282;
const assistantTimestamp = 1_781_041_621_946;
const liveSessions = new Map([
[
sessionId,
{
messages: [
{
id: "user-message",
role: "user",
content: [{ type: "text", text: "Question" }],
ts: userTimestamp,
},
{
id: "assistant-message",
role: "assistant",
content: [
{ type: "thinking", thinking: "Consider it" },
{ type: "text", text: "Answer" },
{
type: "tool_use",
id: "tool-use",
name: "read_files",
input: { paths: ["a.ts"] },
},
],
ts: assistantTimestamp,
},
],
},
],
]);
await expect(
readSessionMessages(
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
sessionId,
),
).resolves.toEqual([
expect.objectContaining({
id: "user-message_text_0",
createdAt: userTimestamp,
}),
expect.objectContaining({
id: "assistant-message_text_0",
createdAt: assistantTimestamp,
reasoning: "Consider it",
}),
expect.objectContaining({
id: "assistant-message_tool_use_2",
createdAt: assistantTimestamp,
}),
]);
});
it("projects image content blocks without replacing them with placeholder text", async () => {
const sessionId = `image-projection-${Date.now()}`;
const liveSessions = new Map([
[
sessionId,
{
messages: [
{
id: "user-image",
role: "user",
content: [
{ type: "text", text: "Describe this" },
{
type: "image",
mediaType: "image/png",
data: "aGVsbG8=",
},
],
},
],
},
],
]);
await expect(
readSessionMessages(
{ liveSessions } as Parameters<typeof readSessionMessages>[0],
sessionId,
),
).resolves.toEqual([
expect.objectContaining({
role: "user",
content: "Describe this",
images: [
{
id: "user-image_image_1",
mediaType: "image/png",
data: "aGVsbG8=",
},
],
}),
]);
});
});
@@ -1,11 +1,13 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { validateImageMedia } from "@cline/shared";
import {
readSessionManifest,
sharedSessionMessagesPath,
sharedSessionMessagesWritePath,
} from "../paths";
import type { JsonRecord, SidecarContext } from "../types";
import { readChildSessionMessages } from "./agents";
import {
parseF64Value,
parseU64Value,
@@ -27,6 +29,17 @@ type ChatTurnResult = {
const nowMs = () => Date.now();
function resolveMessageCreatedAt(
message: JsonRecord,
fallbackCreatedAt: number,
): number {
return (
parseU64Value(message.ts) ??
parseU64Value(message.createdAt) ??
fallbackCreatedAt
);
}
function readMessageMetadata(message: JsonRecord): JsonRecord | undefined {
return message.metadata && typeof message.metadata === "object"
? (message.metadata as JsonRecord)
@@ -121,6 +134,20 @@ function trimNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function extractImageBlock(
record: JsonRecord,
): { mediaType: string; data: string } | undefined {
const mediaType = trimNonEmptyString(record.mediaType);
const data = trimNonEmptyString(record.data);
if (!data) {
return undefined;
}
const validation = validateImageMedia(mediaType, data);
return validation.ok
? { mediaType: validation.mediaType, data: validation.base64 }
: undefined;
}
export function readPersistedChatMessages(sessionId: string): unknown[] | null {
const path = sharedSessionMessagesPath(sessionId);
if (!existsSync(path)) {
@@ -301,7 +328,12 @@ export async function readSessionMessages(
sessionId: string,
maxMessages = 800,
): Promise<unknown[]> {
const persisted = readPersistedChatMessages(sessionId);
const persisted =
readPersistedChatMessages(sessionId) ??
// A child agent's transcript is not stored under its own session
// directory — it lives beside the root session's artifacts — so opening a
// subagent session has to resolve the path recorded on its row.
readChildSessionMessages(sessionId);
const messages =
persisted && persisted.length > 0
? persisted
@@ -312,7 +344,6 @@ export async function readSessionMessages(
const out: JsonRecord[] = [];
const checkpointsByRunCount = readCheckpointEntriesByRunCount(sessionId);
const pendingToolMessages = new Map<string, [number, string, unknown]>();
let nextCreatedAt = baseTs;
let userRunCount = 0;
for (let idx = start; idx < messages.length; idx += 1) {
@@ -321,6 +352,7 @@ export async function readSessionMessages(
continue;
}
const message = rawMessage as JsonRecord;
const createdAt = resolveMessageCreatedAt(message, baseTs + idx);
let textMeta = extractMessageUsageMeta(message);
const storedMeta = extractStoredMessageMeta(message);
if (storedMeta) {
@@ -360,13 +392,14 @@ export async function readSessionMessages(
sessionId,
role,
content,
createdAt: nextCreatedAt++,
createdAt,
meta: textMeta,
});
continue;
}
const textParts: string[] = [];
const images: Array<{ id: string; mediaType: string; data: string }> = [];
const reasoningParts: string[] = [];
let reasoningRedacted = false;
let textSegmentIndex = 0;
@@ -385,7 +418,7 @@ export async function readSessionMessages(
sessionId,
role,
content: joined,
createdAt: nextCreatedAt++,
createdAt,
meta: textMeta,
});
textSegmentIndex += 1;
@@ -415,7 +448,7 @@ export async function readSessionMessages(
sessionId,
role: "tool",
content: buildToolPayloadJson(toolName, input, null, false),
createdAt: nextCreatedAt++,
createdAt,
meta: {
toolName,
hookEventName: "history_tool_use",
@@ -455,7 +488,7 @@ export async function readSessionMessages(
sessionId,
role: "tool",
content: buildToolPayloadJson("tool_result", null, result, isError),
createdAt: nextCreatedAt++,
createdAt,
meta: {
toolName: "tool_result",
hookEventName: "history_tool_result",
@@ -476,6 +509,16 @@ export async function readSessionMessages(
reasoningRedacted = true;
continue;
}
if (blockType === "image") {
const image = extractImageBlock(record);
if (image) {
images.push({
id: `${messageIdBase}_image_${blockIdx}`,
...image,
});
}
continue;
}
const line = stringifyMessageContent(block);
if (line.trim()) {
textParts.push(line);
@@ -483,6 +526,25 @@ export async function readSessionMessages(
}
flushTextParts();
if (images.length > 0) {
const target = out
.slice(outStartIndex)
.find((item) => item.role === role);
if (target) {
target.images = images;
} else {
out.push({
id: `${messageIdBase}_images`,
sessionId,
role,
content: "",
images,
createdAt,
meta: textMeta,
});
textMeta = undefined;
}
}
if (reasoningParts.length > 0 || reasoningRedacted) {
const reasoning = reasoningParts.join("\n").trim();
const target = out
@@ -503,7 +565,7 @@ export async function readSessionMessages(
content: "",
reasoning: reasoning || undefined,
reasoningRedacted: reasoningRedacted || undefined,
createdAt: nextCreatedAt++,
createdAt,
meta: textMeta,
});
textMeta = undefined;
@@ -0,0 +1,250 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
defaultShellFor,
ensureLoginShellPath,
extractMarkedPath,
loginShellFor,
mergePaths,
resolveLoginShellPath,
shellInvocation,
} from "./shell-path";
const MARKER_START = "__CLINE_SIDECAR_PATH_START__";
const MARKER_END = "__CLINE_SIDECAR_PATH_END__";
let tempDirs: string[] = [];
/**
* Fake login shell: a /bin/sh script invoked as `fake-shell -i -l -c <cmd>`,
* so the command to run arrives as $4. The default body mimics a login shell
* whose profile prepends Homebrew before running the command.
*/
function writeFakeShell(
script = 'PATH="/opt/homebrew/bin:/usr/bin"; eval "$4"',
name = "fake-shell",
): string {
const dir = mkdtempSync(join(tmpdir(), "cline-shell-path-"));
tempDirs.push(dir);
const shellPath = join(dir, name);
writeFileSync(shellPath, `#!/bin/sh\n${script}\n`);
chmodSync(shellPath, 0o755);
return shellPath;
}
afterEach(() => {
for (const dir of tempDirs) {
rmSync(dir, { recursive: true, force: true });
}
tempDirs = [];
});
describe("extractMarkedPath", () => {
it("extracts the PATH between markers", () => {
expect(
extractMarkedPath(
`${MARKER_START}/opt/homebrew/bin:/usr/bin${MARKER_END}`,
),
).toBe("/opt/homebrew/bin:/usr/bin");
});
it("ignores shell profile noise around the markers", () => {
const output = `Welcome!\nsome banner\n${MARKER_START}/usr/local/bin${MARKER_END}\ntrailing noise`;
expect(extractMarkedPath(output)).toBe("/usr/local/bin");
});
it("returns undefined when markers are missing or empty", () => {
expect(extractMarkedPath("no markers here")).toBeUndefined();
expect(extractMarkedPath(`${MARKER_START}${MARKER_END}`)).toBeUndefined();
expect(extractMarkedPath(`${MARKER_START}/usr/bin`)).toBeUndefined();
});
});
describe("mergePaths", () => {
it("puts shell entries first and keeps current-only entries", () => {
expect(
mergePaths(
"/opt/homebrew/bin:/usr/bin:/bin",
"/usr/bin:/bin:/custom/bin",
),
).toBe("/opt/homebrew/bin:/usr/bin:/bin:/custom/bin");
});
it("drops duplicate and empty entries", () => {
expect(mergePaths("/a::/b:/a", "/b:/c:")).toBe("/a:/b:/c");
});
});
describe("defaultShellFor", () => {
it("uses zsh on macOS and bash elsewhere", () => {
expect(defaultShellFor("darwin")).toBe("/bin/zsh");
expect(defaultShellFor("linux")).toBe("/bin/bash");
});
});
describe("loginShellFor", () => {
it("returns the passwd-database shell when one exists", () => {
// The test runner's uid has a passwd entry, so $SHELL must lose.
const shell = loginShellFor(process.platform, {
SHELL: "/env/should-not-win",
});
expect(shell.startsWith("/")).toBe(true);
expect(shell).not.toBe("/env/should-not-win");
});
});
describe("shellInvocation", () => {
it("uses separate login+interactive flags for posix-style shells", () => {
expect(shellInvocation("/bin/zsh", "cmd")).toEqual({
args: ["-i", "-l", "-c", "cmd"],
});
expect(shellInvocation("/opt/homebrew/bin/fish", "cmd")).toEqual({
args: ["-i", "-l", "-c", "cmd"],
});
});
it("marks csh-family shells as login via argv0 (-l must be their sole flag)", () => {
expect(shellInvocation("/bin/tcsh", "cmd")).toEqual({
args: ["-c", "cmd"],
argv0: "-tcsh",
});
expect(shellInvocation("/bin/csh", "cmd")).toEqual({
args: ["-c", "cmd"],
argv0: "-csh",
});
});
});
describe("resolveLoginShellPath", () => {
it("captures PATH from the shell", async () => {
const shell = writeFakeShell();
await expect(resolveLoginShellPath(shell)).resolves.toBe(
"/opt/homebrew/bin:/usr/bin",
);
});
it("reads PATH from the environment, not the shell's own expansion", async () => {
// Mimics fish: its "$PATH" expansion would space-join the entries,
// but the printf runs inside /bin/sh, which reads the exported
// colon-delimited PATH env var — so the shell's expansion rules
// never apply. This fake shell never evals the command text; it
// only exports PATH and runs the command via sh, like fish would.
const shell = writeFakeShell(
'PATH="/opt/homebrew/bin:/usr/bin"; export PATH; /bin/sh -c "$4"',
);
await expect(resolveLoginShellPath(shell)).resolves.toBe(
"/opt/homebrew/bin:/usr/bin",
);
});
it("resolves undefined when the shell prints garbage", async () => {
const shell = writeFakeShell('echo "no markers"');
await expect(resolveLoginShellPath(shell)).resolves.toBeUndefined();
});
it("resolves undefined when the shell is missing", async () => {
await expect(
resolveLoginShellPath("/nonexistent/shell"),
).resolves.toBeUndefined();
});
it("times out hung shells without rejecting", async () => {
const shell = writeFakeShell("sleep 60");
await expect(resolveLoginShellPath(shell, 200)).resolves.toBeUndefined();
});
it("invokes csh-family shells without login/interactive flags", async () => {
// A csh stand-in that rejects any first flag other than -c.
const shell = writeFakeShell(
'[ "$1" = "-c" ] || exit 64; PATH="/opt/homebrew/bin:/usr/bin"; eval "$2"',
"tcsh",
);
await expect(resolveLoginShellPath(shell)).resolves.toBe(
"/opt/homebrew/bin:/usr/bin",
);
});
});
describe("ensureLoginShellPath", () => {
it("merges the login shell PATH into env.PATH", async () => {
const shell = writeFakeShell();
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin:/bin" };
const result = await ensureLoginShellPath({
platform: "darwin",
env,
userShell: shell,
});
expect(result).toEqual({
status: "applied",
pathEntries: 3,
shell,
});
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/bin");
});
it("falls back to the default shell when $SHELL can't resolve", async () => {
const fallbackShell = writeFakeShell();
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
const result = await ensureLoginShellPath({
platform: "darwin",
env,
userShell: "/nonexistent/shell",
fallbackShell,
});
expect(result.status).toBe("applied");
expect(result).toMatchObject({ shell: fallbackShell });
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
});
it("leaves PATH untouched when every shell fails", async () => {
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
const result = await ensureLoginShellPath({
platform: "darwin",
env,
userShell: "/nonexistent/shell",
fallbackShell: "/nonexistent/other-shell",
});
expect(result).toEqual({ status: "failed", shell: "/nonexistent/shell" });
expect(env.PATH).toBe("/usr/bin");
});
it("skips on windows", async () => {
const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows" };
const result = await ensureLoginShellPath({ platform: "win32", env });
expect(result).toEqual({ status: "skipped", reason: "windows" });
});
it("skips when the escape hatch is set", async () => {
const env: NodeJS.ProcessEnv = {
PATH: "/usr/bin",
CLINE_SIDECAR_SKIP_SHELL_PATH: "1",
};
const result = await ensureLoginShellPath({ platform: "darwin", env });
expect(result.status).toBe("skipped");
expect(env.PATH).toBe("/usr/bin");
});
it("never exposes the resolved PATH in its result", async () => {
const shell = writeFakeShell();
const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
const result = await ensureLoginShellPath({
platform: "darwin",
env,
userShell: shell,
});
expect(JSON.stringify(result)).not.toContain("/opt/homebrew/bin");
});
it("resolves against a real shell end to end", async () => {
const env: NodeJS.ProcessEnv = { PATH: "/bin" };
const result = await ensureLoginShellPath({
platform: "linux",
env,
userShell: "/bin/sh",
});
expect(result.status).toBe("applied");
expect(env.PATH).toContain("/bin");
});
});
@@ -0,0 +1,239 @@
/**
* Login-shell PATH resolution for the desktop sidecar.
*
* When the Tauri app is launched from Finder/the Dock on macOS, it inherits
* launchd's minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin) instead of the
* user's shell PATH. The sidecar and every process it spawns for the agent
* (bash tool, MCP servers) then can't find tools like `gh` that live in
* /opt/homebrew/bin or other shell-profile-added directories, even though
* the same task works from the CLI in a terminal.
*
* At startup we ask the user's login shell for its PATH and merge it into
* process.env.PATH, so child processes see the same PATH a terminal would.
*/
import { spawn } from "node:child_process";
import { userInfo } from "node:os";
import { basename, delimiter } from "node:path";
const PATH_MARKER_START = "__CLINE_SIDECAR_PATH_START__";
const PATH_MARKER_END = "__CLINE_SIDECAR_PATH_END__";
/**
* Kept well under the Tauri shell's 5s endpoint-readiness poll: this
* resolution overlaps sidecar startup but is awaited before the server
* starts, so a pathological shell profile must not eat the whole window.
*/
const SHELL_TIMEOUT_MS = 2_000;
/**
* The command every shell is asked to run. $PATH expansion happens inside
* POSIX sh not the user's shell so shells with different expansion rules
* (fish would space-join "$PATH") still produce a colon-delimited value; sh
* reads the PATH environment variable the login shell exported.
*/
const PRINT_PATH_COMMAND = `/bin/sh -c 'printf "%s%s%s" "${PATH_MARKER_START}" "$PATH" "${PATH_MARKER_END}"'`;
/**
* Escape hatch: set CLINE_SIDECAR_SKIP_SHELL_PATH=1 to leave PATH untouched
* (e.g. if a broken shell profile makes resolution misbehave).
*/
const SKIP_ENV_VAR = "CLINE_SIDECAR_SKIP_SHELL_PATH";
export function defaultShellFor(platform: NodeJS.Platform): string {
return platform === "darwin" ? "/bin/zsh" : "/bin/bash";
}
/**
* The user's configured login shell. The account database is authoritative:
* a GUI-launched process has no parent shell, so $SHELL may be unset there.
* userInfo() reads getpwuid(), which on macOS goes through DirectoryServices
* the same source `dscl . -read /Users/$USER UserShell` reports and on
* Linux resolves via NSS (/etc/passwd et al.). $SHELL and the platform
* default are fallbacks for environments with no passwd entry.
*/
export function loginShellFor(
platform: NodeJS.Platform,
env: NodeJS.ProcessEnv,
): string {
try {
const shell = userInfo().shell?.trim();
if (shell) {
return shell;
}
} catch {
// No passwd entry for the current uid (some containers) — fall through.
}
return env.SHELL?.trim() || defaultShellFor(platform);
}
export interface ShellInvocation {
args: string[];
/**
* argv[0] the shell should see. A leading dash is the historical "you
* are a login shell" signal, used where -l can't be passed as a flag.
*/
argv0?: string;
}
/**
* How to invoke a shell so it sources its profiles and runs a command.
* csh/tcsh accept -l only as the sole flag, so they're marked login via the
* argv[0] dash convention instead (sources ~/.login on top of the always-read
* ~/.cshrc or ~/.tcshrc); everything else gets login (-l, ~/.zprofile
* Homebrew's shellenv) plus interactive (-i, ~/.zshrc nvm-style version
* managers) as separate flags.
*/
export function shellInvocation(
shell: string,
command: string,
): ShellInvocation {
const kind = basename(shell);
if (kind === "csh" || kind === "tcsh") {
return { args: ["-c", command], argv0: `-${kind}` };
}
return { args: ["-i", "-l", "-c", command] };
}
/**
* Extract the PATH value printed between the sentinel markers, ignoring any
* noise a shell profile writes to stdout around it.
*/
export function extractMarkedPath(output: string): string | undefined {
const start = output.indexOf(PATH_MARKER_START);
if (start === -1) {
return undefined;
}
const end = output.indexOf(PATH_MARKER_END, start);
if (end === -1) {
return undefined;
}
const value = output.slice(start + PATH_MARKER_START.length, end).trim();
return value.length > 0 ? value : undefined;
}
/**
* Merge the login shell's PATH with the current one: shell entries first (so
* profile-managed dirs like /opt/homebrew/bin win), then any current entries
* the shell PATH doesn't already contain (so explicitly-injected dirs from
* the launching environment aren't lost). Duplicates are dropped.
*/
export function mergePaths(shellPath: string, currentPath: string): string {
const entries = [
...shellPath.split(delimiter),
...currentPath.split(delimiter),
]
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
return Array.from(new Set(entries)).join(delimiter);
}
/**
* Run the user's shell with its profiles sourced and capture its PATH.
* Resolves to undefined on any failure (missing shell, timeout, profile
* error) callers should treat that as "keep the current PATH".
*/
export function resolveLoginShellPath(
shell: string,
timeoutMs = SHELL_TIMEOUT_MS,
): Promise<string | undefined> {
return new Promise((resolve) => {
const invocation = shellInvocation(shell, PRINT_PATH_COMMAND);
const child = spawn(shell, invocation.args, {
argv0: invocation.argv0,
stdio: ["ignore", "pipe", "ignore"],
detached: true,
});
let output = "";
let settled = false;
const settle = (value: string | undefined) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolve(value);
};
const timeout = setTimeout(() => {
try {
if (child.pid) {
process.kill(-child.pid, "SIGKILL");
}
} catch {
child.kill("SIGKILL");
}
settle(undefined);
}, timeoutMs);
child.stdout?.on("data", (data: Buffer) => {
output += data.toString("utf8");
});
child.on("error", () => settle(undefined));
child.on("close", () => settle(extractMarkedPath(output)));
});
}
/**
* Resolve the login shell's PATH and merge it into process.env.PATH. The
* shell comes from the account database (see loginShellFor); if it can't
* produce a PATH (exotic shell, broken profile), retry once with the
* platform default shell before giving up.
*
* No-op on Windows (the GUI PATH comes from the registry there) and when
* CLINE_SIDECAR_SKIP_SHELL_PATH is set. Failures are reported via the
* returned status but never block startup. The result never contains the
* resolved PATH itself so it is safe to log verbatim.
*/
export async function ensureLoginShellPath(options?: {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
/** Test seam: overrides passwd/$SHELL discovery of the user's shell. */
userShell?: string;
/** Test seam: overrides the platform-default fallback shell. */
fallbackShell?: string;
}): Promise<
| { status: "applied"; pathEntries: number; shell: string }
| { status: "skipped"; reason: string }
| { status: "failed"; shell: string }
> {
const platform = options?.platform ?? process.platform;
const env = options?.env ?? process.env;
if (platform === "win32") {
return { status: "skipped", reason: "windows" };
}
if (env[SKIP_ENV_VAR]?.trim()) {
return { status: "skipped", reason: SKIP_ENV_VAR };
}
const userShell = options?.userShell ?? loginShellFor(platform, env);
const fallbackShell = options?.fallbackShell ?? defaultShellFor(platform);
const baseTimeoutMs = options?.timeoutMs ?? SHELL_TIMEOUT_MS;
// The fallback gets half the budget so the combined worst case stays
// bounded even when both shells hang (see SHELL_TIMEOUT_MS).
const attempts: Array<[shell: string, timeoutMs: number]> =
userShell === fallbackShell
? [[userShell, baseTimeoutMs]]
: [
[userShell, baseTimeoutMs],
[fallbackShell, baseTimeoutMs / 2],
];
for (const [shell, timeoutMs] of attempts) {
const shellPath = await resolveLoginShellPath(shell, timeoutMs);
if (!shellPath) {
continue;
}
const merged = mergePaths(shellPath, env.PATH ?? "");
env.PATH = merged;
return {
status: "applied",
pathEntries: merged.split(delimiter).length,
shell,
};
}
return { status: "failed", shell: userShell };
}
+5 -2
View File
@@ -2,7 +2,6 @@ import type {
AgentToolContext,
BasicLogger,
ClineCore,
HubServer,
ITelemetryService,
NodeHubClient,
ToolApprovalResult,
@@ -43,6 +42,7 @@ export type PromptInQueue = {
prompt: string;
steer: boolean;
attachmentCount?: number;
userImages?: string[];
};
export type LiveSession = {
@@ -57,6 +57,10 @@ export type LiveSession = {
prompt?: string;
title?: string;
attachedViaHub?: boolean;
/** Materialized attachment files for prompts still waiting in the queue. */
queuedAttachmentFiles?: Map<string, string[]>;
/** Materialized attachment files whose prompt was submitted; deleted when the turn ends. */
consumedAttachmentFiles?: Map<string, string[]>;
};
export type ToolApprovalRequestItem = {
@@ -107,7 +111,6 @@ export type SidecarContext = {
pendingQuestions: Map<string, PendingAskQuestion>;
sessionManager: ClineCore | null;
hubClient: NodeHubClient | null;
hubServer: HubServer | null;
workspaceRoot: string;
logger?: BasicLogger;
telemetry?: ITelemetryService;
+399
View File
@@ -47,6 +47,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "ashpd"
version = "0.11.1"
@@ -369,6 +378,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -504,11 +519,16 @@ dependencies = [
name = "cline-app"
version = "0.1.0"
dependencies = [
"objc2",
"objc2-app-kit",
"objc2-foundation",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-updater",
"tokio",
]
[[package]]
@@ -718,6 +738,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -1017,6 +1048,16 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1579,6 +1620,21 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -1750,6 +1806,19 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.1",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2016,6 +2085,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2037,6 +2112,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "muda"
version = "0.19.1"
@@ -2143,9 +2228,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation",
"objc2-quartz-core",
]
[[package]]
@@ -2165,6 +2258,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-foundation",
]
@@ -2225,6 +2319,19 @@ dependencies = [
"objc2-core-graphics",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -2248,6 +2355,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2263,6 +2371,18 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2326,6 +2446,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2342,6 +2468,20 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2645,6 +2785,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -2796,15 +2942,20 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -2840,6 +2991,20 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -2868,6 +3033,79 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -2883,6 +3121,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -2946,6 +3193,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.11.0",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.36.1"
@@ -3277,6 +3547,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -3393,6 +3669,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -3416,6 +3703,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"image",
"jni",
"libc",
"log",
@@ -3512,6 +3800,55 @@ dependencies = [
"tauri-utils",
]
[[package]]
name = "tauri-plugin"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]]
name = "tauri-runtime"
version = "2.11.1"
@@ -3730,6 +4067,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -4025,6 +4372,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -4387,6 +4740,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -4617,6 +4979,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -5035,6 +5406,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.1"
@@ -5160,6 +5541,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.3"
@@ -5193,6 +5580,18 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.13.0",
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
@@ -9,11 +9,16 @@ tauri-build = { version = "2.0.0", features = [] }
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2.11.1", features = [] }
tauri = { version = "2.11.1", features = ["image-png", "tray-icon"] }
tauri-plugin-updater = "2"
tokio = { version = "1", features = ["time"] }
rfd = "0.15"
[target."cfg(target_os = \"macos\")".dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSResponder"] }
objc2-foundation = { version = "0.3", features = ["NSString"] }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-window",
"description": "Permissions required by the main desktop window.",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-set-title",
"core:window:allow-start-dragging"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

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