Compare commits

...

259 Commits

Author SHA1 Message Date
Simos Mikelatos
98f6596b9d Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	src/components/main-content/view/MainContent.tsx
2026-06-29 07:11:00 +00:00
Simos Mikelatos
261690935f Merge pull request #891 from siteboon/electron-app 2026-06-29 09:07:57 +02:00
Simos Mikelatos
0168da7bcd Remove duplicate command grouping from PR 929 2026-06-29 07:05:30 +00:00
Haileyesus
3bcb541560 fix(chat): stop orphaned tool results rendering as raw text during pagination
Sessions load in pages from the bottom up, so a loaded page often contains
a tool_result whose tool_use sits in an older, not-yet-loaded page. That
result wasn't recognized as attached, so it was pushed as a standalone
assistant message and its raw output rendered as unstyled Markdown. It only
"fixed itself" once the user scrolled up far enough to load the page with
the matching tool_use.

Skip results that have a toolId but no matching tool_use in the loaded set —
they attach and render correctly (inside their command row/group) once the
older page loads. Results with no toolId still render as before.
2026-06-28 18:45:22 +03:00
Haileyesus
fcc469b55c feat(chat): group shell commands interleaved within thinking mode 2026-06-28 18:34:15 +03:00
Haileyesus
2afe0955ed fix(chat): open file references in editor instead of new browser window
Clicking a file reference in a chat message (e.g. `useShellTerminal.ts`)
opened a new browser window because it was rendered as a plain anchor with
target="_blank" and an empty/relative href.

The markdown link renderer now intercepts file-path links — using the href,
or the link text when the href is empty — strips any `:line:col` suffix, and
opens the file in the in-app editor side panel while keeping the Chat tab
active (matching the inline edit view).

- useFileOpenResolver: resolves bare/partial references to real project
  files via the cached project file tree
- PaletteOpsContext: add `openFileInEditor` op that opens the editor without
  switching tabs
2026-06-28 18:30:29 +03:00
Haileyesus
c88baaf8dc feat(chat): render shell commands as collapsible Codex-style rows
Show Bash tool calls as a compact, single-line command with a chevron
that expands to reveal the output inline, instead of hiding successful
output and popping a separate red box on error.

- Add BashCommandDisplay: command row with $ prompt, status/spinner,
  line-count hint, copy button, and an inline output panel (errors
  auto-expand and tint red).
- Add CommandRunGroup: collapse 2+ consecutive commands under one
  "Ran N commands" header; expanding reveals each command, which stays
  independently expandable. Collapsed by default; opens on error.
- Group consecutive Bash runs in ChatMessagesPane and route single Bash
  calls through BashCommandDisplay in ToolRenderer.
- Suppress the duplicate generic result section for Bash in
  MessageComponent since output now lives in the command row.
- Theme-integrated surfaces (no hard black boxes), emerald accent,
  subtle motion, and clean focus states for a modern, uncluttered look.
2026-06-28 18:19:57 +03:00
Haileyesus
f8430dc886 fix(sidebar): remove horizontal scroll in conversation search view 2026-06-28 17:48:56 +03:00
Haileyesus
98a3a3a1f4 fix(sidebar): make sessions list hyperlinks 2026-06-28 17:33:17 +03:00
Haileyesus
2c08060f65 fix: enlarge language selector in quick settings panel 2026-06-28 17:22:02 +03:00
Haileyesus
75bbafb438 fix: minimize clutter in /models 2026-06-28 17:20:15 +03:00
Haileyesus
7c8928c66d fix: show provider icon 2026-06-28 17:19:53 +03:00
turato
ed4ae3114a fix(chat): prevent chat interface crash on malformed AskUserQuestion payload (#920)
* fix(chat): prevent chat interface crash when AskUserQuestion payload is malformed

Loading a session that contains an AskUserQuestion tool call could crash the
entire chat interface with "TypeError: e.map is not a function".

The AskUserQuestion tool is configured with `defaultOpen: true`, so
QuestionAnswerContent renders as soon as the session loads. Its array guard
(`!questions || questions.length === 0`) only checked for truthiness, and
`q.options` was mapped/iterated with no guard at all. When `questions` or
`options` arrive from the session transcript as a non-array value, the
`.map()` / `.some()` calls throw and take down the whole chat view via the
error boundary.

Guard both with `Array.isArray()` so a single malformed message can no longer
crash the interface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(chat): cover QuestionAnswerContent against malformed AskUserQuestion payloads

Adds the first frontend regression test, guarding the crash fixed in the
previous commit: a non-array `questions` value or a question missing its
`options` array must render gracefully instead of throwing
"e.map is not a function" and taking down the whole chat interface.

Follows the repo's existing test convention (node:test + tsx); uses
react-dom/server renderToStaticMarkup so no DOM/jsdom is required.
Run with: npx tsx --test src/**/QuestionAnswerContent.test.tsx

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): harden QuestionAnswerContent against malformed question entries

Addresses review feedback: even with the array guards, a malformed transcript
could still crash before the options fallback ran —

- a `questions` entry that is null/non-object threw on `q.question` access
- a non-string `answers[q.question]` threw on `answer.split(', ')`

Skip entries that aren't a proper question object with a string prompt, and
only call string methods on the answer when it is actually a string. Extends
the regression test to cover both vectors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): guard malformed question options

---------

Co-authored-by: hustuhao <hustuhao@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-06-26 16:47:24 +02:00
Simos Mikelatos
46ba8e56b4 Potential fix for pull request finding 'Useless assignment to local variable'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-06-26 16:14:44 +02:00
Simos Mikelatos
a0899a252e Merge branch 'main' into electron-app 2026-06-26 16:09:19 +02:00
Haile
591e8e7642 fix: voice tts format settings (#919)
* feat(voice): add optional speech-to-text input and read-aloud TTS

Adds a push-to-talk mic button in the composer and a read-aloud button on
assistant messages. Both are opt-in and hidden unless a voice backend is
configured via VOICE_SIDECAR_URL.

The auth-gated /api/voice proxy forwards to a configurable backend exposing
/transcribe and /tts (provider-agnostic); the frontend probes /api/voice/health
and hides the controls when disabled. Adds i18n keys and docs/voice.md.

Includes a local, no-API-key reference backend in voice-sidecar/ (faster-whisper
for STT, Kokoro-82M for TTS, both CPU-capable).

* refactor(voice): provider-agnostic backend and in-app config

Switches the voice proxy to the OpenAI audio API (/v1/audio/transcriptions and
/v1/audio/speech) so it works with OpenAI, Groq, or a local server. Adds a
Settings -> Voice tab (base URL, API key, models, voice) plus a Quick Settings
toggle, and removes the bundled Python sidecar.

Review fixes: stop mic tracks on unmount, clear the global TTS stop handler and
revoke leaked blob URLs, add fetch timeouts in the proxy, surface mic errors in
the button, trim before appending transcripts, and drop the repo-wide wav ignore.

* fix(voice): relax backend timeout and surface timeout errors

Bumps the proxy timeout to 5 minutes (VOICE_TIMEOUT_MS) since local TTS can
synthesize long messages at roughly real-time, and returns a clear timed-out
message (504) instead of failing silently. The read-aloud button now shows
backend errors.

* fix(voice): play read-aloud through an app-level player to stop cutoffs

Read-aloud now runs in a single module-level player outside the React tree instead
of per-message component state. Switching chats or re-rendering a message no longer
revokes the blob URL mid-play (the 'Invalid URI' cutoff). Adds content-keyed caching so
re-listening doesn't regenerate, and reuses one audio element (also unlocks iOS once).

* fix(voice): address review (SSRF guard, auth mapping, client timeout)

Validates the user-supplied backend URL (http/https only, blocks the link-local
metadata range) to prevent SSRF; remaps upstream 401/403 so a bad voice API key
isn't read as the app's own auth failing; adds a client-side AbortController timeout
on the read-aloud request so the button can't sit in loading if a request stalls.

* docs(voice): provider-agnostic wording and jsdoc on proxy functions

drop leftover sidecar/faster-whisper references now that the backend is any
openai-compatible voice api, and add jsdoc to the voice-proxy functions so the
docstring coverage check passes.

* fix(voice): harden timeout parsing, tts input check, and player abort

- fall back to the default when VOICE_TIMEOUT_MS is non-numeric or <= 0, so a
  bad override can't make the abort fire immediately
- type-check the tts `text` before calling .trim() so a non-string body returns
  400 instead of throwing
- abort the in-flight TTS fetch on stop() and on a superseding play, so tapping
  read-aloud repeatedly doesn't leave orphaned requests generating audio

* feat(voice): send transcript with the main send button while recording

while dictating, the main send button stops recording, transcribes, and sends
in one tap, matching the codex-style flow. the mic button still stops and drops
the transcript into the input box to edit before sending. voice recording state
is lifted into the composer so both buttons share it, and the send button is
enabled (not grayed) while recording. also fix a pre-existing type error: the
quick-settings preferences map was missing voiceEnabled.

* fix(voice): make stop() idempotent so a double tap can't throw

guard on the recorder's own state instead of react state, so a double tap or
the mic and send buttons both firing won't call stop() on an already-inactive
MediaRecorder.

* fix(voice): expose TTS format in user settings

* fix(voice): harden recording and backend behavior

Redirects could bypass the backend URL guard, and TTS playback waited for full buffering.

Recording could overlap or finish after teardown. Controls also ignored backend readiness.

Explicit formats and config-aware cache keys prevent stale audio after settings change.

* fix(voice): validate config and request boundaries

Malformed stored settings could break voice requests instead of using safe defaults.

Health results could outlive auth changes. URL checks also did not guard the fetch sink.

Remove constant recorder branches so lifecycle cancellation stays clear.

* fix(voice): separate client and server backends

User-selected backend URLs must remain usable without letting clients control server requests.

Call custom providers from the browser while keeping the server proxy bound to its configured host.

This restores voice controls for frontend settings without reopening the SSRF path.

* fix: hide voice options until enabled

---------

Co-authored-by: newsbubbles <nathaniel.gibson@gmail.com>
Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-06-26 16:06:40 +02:00
Simos Mikelatos
3bc2c777a3 fix: await desktop auth token lookup 2026-06-26 10:33:10 +00:00
Simos Mikelatos
63f3c3941d feat: add desktop notifications and skills updates 2026-06-26 10:25:47 +00:00
Simos Mikelatos
e6c6f89dda Merge branch 'main' into electron-app 2026-06-26 10:02:48 +02:00
Haile
c947eaaee5 feat: play sound for pending tool requests (#918) 2026-06-25 14:57:10 +02:00
Simos Mikelatos
6f712269e8 fix: remove invalid windows build options 2026-06-24 21:05:59 +00:00
Simos Mikelatos
52244404a3 chore: remove windows icon generator 2026-06-24 20:50:45 +00:00
Simos Mikelatos
8ad18f8587 fix: improve desktop chat performance 2026-06-24 20:49:24 +00:00
Simos Mikelatos
fe116a7138 ci: restore notarized macOS branch builds 2026-06-24 20:25:53 +00:00
Simos Mikelatos
490e66ebdb fix: stabilize desktop environment auth navigation 2026-06-24 20:09:41 +00:00
Simos Mikelatos
81eb966904 ci: skip notarization for macOS branch builds 2026-06-24 20:05:52 +00:00
Simos Mikelatos
0d68dc2cd0 fix: add Electron tab diagnostics 2026-06-24 20:00:45 +00:00
Haile
4a503b1dc8 fix(shell): prioritize user npm binaries (#913)
Interactive shells could resolve bundled or system CLIs before user-installed npm binaries.

Move existing user npm global directories to the front of PATH while preserving all other entries.

Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-06-24 20:15:52 +02:00
Koya Kikuchi
f6326c8082 feat(version): warn when the server was updated but not restarted (#898)
When the package is updated on disk but the long-lived server process is
not restarted, the new frontend bundle (served from disk) talks to the
old running backend. New DB-backed features then fail silently — e.g.
deleting/archiving a session appears to do nothing — because the new
schema/routes only take effect on restart.

Nothing currently detects this skew: useVersionCheck only compares the
frontend's build-time version against the latest GitHub release.

This exposes the running server's version (captured once at startup) via
/health, compares it to the frontend's build-time version in
useVersionCheck, and shows a "restart required" banner in the sidebar
(and a small indicator in the collapsed sidebar) when they differ.

- server: add `version` (RUNNING_VERSION, read once at startup) to /health
- useVersionCheck: return `restartRequired` / `runningVersion`
- SidebarFooter / SidebarCollapsed: surface a restart-required banner
- i18n: add `version.restartRequired` to all 10 sidebar locales

Verified with `tsc --noEmit` (client + server) and eslint.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-06-22 22:49:57 +02:00
Haile
c5fe127958 feat(skills): add provider skill management (#909)
* feat(skills): add provider skill management

Users need one settings surface to discover and install skills without manually navigating provider-specific directories.

Add provider-backed global skill installation for Claude, Codex, Gemini, and Cursor, while keeping OpenCode read-only because it reuses other providers' skill locations.

Add a responsive Skills settings tab with scoped discovery, search, refresh controls, markdown and folder uploads, upload feedback, and overflow-safe layouts.

Validate bundled skill files and paths before writing them, preserve scripts and assets, and cover provider discovery and installation behavior with tests.

* fix(skills): preserve uploaded skill folders

Folder drops discarded supporting scripts and assets.

Keep relative paths and upload every file from the selected skill folder.

Use the selected folder name for installation and cover it in provider tests.

* fix(skills): restrict standalone skill uploads

Only show Markdown files when selecting standalone skills.

Normalize browser file paths so SKILL.md is not mistaken for a folder named dot.

* fix(skills): validate installs before writing

Preserve bundled files and normalize fallback names across skill installation paths.

Validate complete batches before writing and reject existing targets to avoid partial installs.

Keep project metadata and make folder selection tolerant of casing and cancelled dialogs.

* fix(skills): overwrite existing installations

Replace an existing skill directory instead of rejecting a duplicate installation.

Remove stale supporting files so the installed directory exactly matches the new upload.
2026-06-22 22:45:27 +02:00
Simos Mikelatos
bb630ef739 fix: hide computer use menus 2026-06-20 01:50:02 +00:00
Simos Mikelatos
1c05fe0905 fix: stabilize cloud computer use mcp 2026-06-19 20:47:53 +00:00
chenxiccc
4712431be8 fix(chat): prevent normalizeInlineCodeFences from breaking adjacent fenced code blocks (#903) 2026-06-19 18:40:26 +02:00
Simos Mikelatos
077baee5f2 fix: authenticate desktop agent websocket 2026-06-19 15:52:49 +00:00
coderabbitai[bot]
f150fa6b09 fix: apply CodeRabbit auto-fixes
Fixed 1 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-06-19 15:22:43 +00:00
Simos Mikelatos
9f8cee8919 fix: restore macos semantic helper cast 2026-06-19 15:05:47 +00:00
Simos Mikelatos
bb323fc566 fix: respect cloud computer use setting 2026-06-19 15:02:07 +00:00
Simos Mikelatos
5ef40be2d3 fix: macos release 2026-06-19 14:46:58 +00:00
Simos Mikelatos
cf4b28273e fix: compile macos semantic helper 2026-06-19 14:22:47 +00:00
Simos Mikelatos
f4c68942a5 fix: repair desktop launcher local view 2026-06-19 14:20:23 +00:00
Simos Mikelatos
4d70a2588c feat: improve Computer Use linking status 2026-06-19 13:47:16 +00:00
Simos Mikelatos
218e8e2e38 chore: update Codex SDK to latest 2026-06-19 13:12:53 +00:00
Simos Mikelatos
53c3c4c27a Fix long-running desktop resource leaks 2026-06-19 13:07:08 +00:00
Simos Mikelatos
901c6fc956 chore: simplify desktop release artifacts 2026-06-19 13:04:53 +00:00
Simos Mikelatos
278fe4f7b1 Fix semantic review issues and release action runtime 2026-06-19 12:46:40 +00:00
Simos Mikelatos
d7f4d4c342 Fix desktop release review findings 2026-06-19 12:29:46 +00:00
Simos Mikelatos
d1930fecdb fix: build semantic helpers on macos and windows 2026-06-19 12:17:32 +00:00
Simos Mikelatos
1726705459 feat: add CloudCLI computer use semantics, desktop helper packaging, and permission onboarding 2026-06-19 12:09:55 +00:00
Simos Mikelatos
a35200f340 Harden computer use MCP handling 2026-06-19 08:06:26 +00:00
Simos Mikelatos
06c9745489 Update src/i18n/locales/zh-CN/settings.json
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-19 09:56:21 +02:00
Simos Mikelatos
0dd22db2bb Update src/i18n/locales/zh-TW/settings.json
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-19 09:56:01 +02:00
Simos Mikelatos
e7aa72c41e Update src/i18n/locales/tr/settings.json
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-19 09:55:45 +02:00
Simos Mikelatos
9f24f80f33 Fix computer use session error status 2026-06-19 07:47:56 +00:00
Simos Mikelatos
25ab273b05 Publish branch server bundles for thin desktop builds 2026-06-19 07:08:19 +00:00
Simos Mikelatos
5be100ea1b Keep branch desktop artifacts thin 2026-06-19 06:49:28 +00:00
Simos Mikelatos
2af3d38afe Harden desktop workflows and computer use handling 2026-06-19 06:21:13 +00:00
Simos Mikelatos
531833bc87 Merge branch 'main' into electron-app 2026-06-19 08:19:36 +02:00
Simos Mikelatos
b2333e7d93 Fix launcher CodeQL unused helpers 2026-06-18 21:17:09 +00:00
Simos Mikelatos
f75ae385dd Add on-demand desktop server bundle 2026-06-18 21:08:29 +00:00
Koya Kikuchi
7ca355651f fix(i18n): add missing sidebar message keys to all locales (#896)
The sidebar `messages` namespace was missing six keys that are referenced
in `useSidebarController.ts`:

- messages.updateProjectError (rename / star-toggle failure)
- messages.refreshError (project list refresh failure)
- messages.restoreProjectFailed / restoreProjectError
- messages.restoreSessionFailed / restoreSessionError

`updateProjectError` and `refreshError` are called via `t()` without an
inline default, so on failure users see the raw key string
"messages.updateProjectError" / "messages.refreshError" instead of a
message. The four restore.* keys have inline English defaults in the code,
so they previously fell back to English even in non-English UIs.

Adds all six keys to every locale (de, en, fr, it, ja, ko, ru, tr,
zh-CN, zh-TW), matching the existing wording/style of the neighbouring
delete/create messages in each file.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:12:38 +03:00
Karel Bourgois
a12ca8eed3 fix(claude-sync): skip subagent transcripts to prevent main session corruption (#854)
The session indexer scans ~/.claude/projects recursively via
findFilesRecursivelyCreatedAfter, which descends into per-session
subagents/ directories. Claude writes subagent transcripts at:

  ~/.claude/projects/<encoded-cwd>/<session-id>/subagents/agent-<id>.jsonl

These files repeat the parent session's sessionId. When indexed as
standalone sessions they upsert over the parent row and overwrite its
jsonl_path with the subagent path, corrupting the main session record
(the sidebar then points at, and renders, the subagent transcript).

Add a single isSubagentTranscript() guard (path segment named
"subagents") and apply it in both the recursive scan and the
single-file watcher path.

Co-authored-by: Haile <118998054+blackmammoth@users.noreply.github.com>
2026-06-18 15:37:37 +03:00
Simos Mikelatos
7786763dd1 Fix desktop settings modal behavior 2026-06-18 06:15:17 +00:00
Simos Mikelatos
1dbf545fd9 Authenticate ripgrep install in desktop workflows 2026-06-17 22:29:55 +00:00
Simos Mikelatos
ac37213269 Run desktop branch builds on electron app pushes 2026-06-17 22:26:47 +00:00
Simos Mikelatos
65fdc38f2e Add desktop app packaging and settings updates 2026-06-17 22:15:36 +00:00
Simos Mikelatos
6c2652aee6 Merge remote-tracking branch 'origin/main' into electron-app
# Conflicts:
#	package-lock.json
#	package.json
#	server/index.js
#	src/components/main-content/types/types.ts
#	src/components/main-content/view/MainContent.tsx
#	src/components/main-content/view/subcomponents/MainContentHeader.tsx
#	src/components/main-content/view/subcomponents/MainContentTabSwitcher.tsx
#	src/components/main-content/view/subcomponents/MainContentTitle.tsx
#	src/components/settings/hooks/useSettingsController.ts
#	src/components/settings/types/types.ts
#	src/components/settings/view/Settings.tsx
#	src/components/settings/view/SettingsSidebar.tsx
#	src/hooks/useProjectsState.ts
#	src/i18n/locales/de/common.json
#	src/i18n/locales/en/common.json
#	src/i18n/locales/en/settings.json
#	src/i18n/locales/it/common.json
#	src/i18n/locales/ja/common.json
#	src/i18n/locales/ko/common.json
#	src/i18n/locales/ru/common.json
#	src/i18n/locales/tr/common.json
#	src/i18n/locales/zh-CN/common.json
#	src/i18n/locales/zh-TW/common.json
#	src/types/app.ts
2026-06-17 20:18:09 +00:00
Simos Mikelatos
bf50d29c20 Merge remote-tracking branch 'origin/browser-use' into electron-app
# Conflicts:
#	src/i18n/locales/en/settings.json
2026-06-17 20:17:38 +00:00
Simos Mikelatos
e88539170e Add browser use as MCP to providers (#889) 2026-06-17 22:06:17 +02:00
Simos Mikelatos
ffc0cd7501 Improve Browser settings load and managed MCP display 2026-06-17 20:04:44 +00:00
Simos Mikelatos
59194d1502 Refine Browser naming and managed MCP UX
- Rename Browser Use surfaces to Browser
- Register Browser MCP under the new server name
- Mark CloudCLI-managed MCP servers read-only
- Adjust MCP stdio framing and sidebar footer sizing
2026-06-17 19:18:23 +00:00
Simos Mikelatos
7e6028b113 feat: add desktop computer use runtime 2026-06-17 19:01:15 +00:00
Simos Mikelatos
9881e5e366 feat(browser-use): improve mobile monitoring ux 2026-06-17 18:19:12 +00:00
Simos Mikelatos
496a895e8a feat(browser-use): refine monitoring panel ux 2026-06-17 17:39:55 +00:00
Simos Mikelatos
086df034b4 feat(browser-use): simplify agent session monitoring 2026-06-17 17:04:11 +00:00
Simos Mikelatos
fc71fc7d2b Merge branch 'pr889-fixes' into electron-app
# Conflicts:
#	server/index.js
2026-06-17 15:45:07 +00:00
Simos Mikelatos
a0d56429a7 fix browser use 2026-06-17 15:43:21 +00:00
Simos Mikelatos
6af4afe6f2 Merge branch 'main' into browser-use 2026-06-16 19:02:36 +02:00
Simos Mikelatos
c03ddb25fe Merge pull request #887 from siteboon/feat/unify-websocket-2
Refactor chat activity indicator and unify session lifecycle handling
2026-06-16 19:01:25 +02:00
Haileyesus
d7a38a567a chore: move tests to appropriate folder 2026-06-16 17:54:48 +03:00
Haileyesus
fec91d3deb Merge branch 'feat/unify-websocket-2' of https://github.com/siteboon/claudecodeui into feat/unify-websocket-2 2026-06-16 17:48:08 +03:00
Haileyesus
c6c153e7f2 chore: move tests to appropriate folder 2026-06-16 17:47:52 +03:00
Haile
4758ccf36e Merge branch 'main' into feat/unify-websocket-2 2026-06-16 17:39:10 +03:00
Haileyesus
e23e6af06a docs: update session activity guard comment 2026-06-16 17:27:54 +03:00
Haileyesus
56b2e14059 fix: recover pending permission requests 2026-06-16 17:20:40 +03:00
Haileyesus
39b0473e38 fix: keep running-session polling active
Keep the running-session poller active even when the local
processing set is empty so runs started from another tab or
client can still be discovered.
2026-06-16 16:52:12 +03:00
Simos Mikelatos
7aeca52669 Merge branch 'browser-use' into electron-app
# Conflicts:
#	src/components/main-content/view/subcomponents/MainContentTabSwitcher.tsx
2026-06-16 06:51:35 +00:00
Simos Mikelatos
56532af33a feat: add browser use guide links 2026-06-15 21:22:49 +00:00
Simos Mikelatos
9438a365f2 feat: improve browser use session controls 2026-06-15 21:14:10 +00:00
Simos Mikelatos
e5c6e5e596 fix: hide browser use runtime mode 2026-06-15 20:20:44 +00:00
Simos Mikelatos
0426522406 feat: expose browser use to agents via MCP 2026-06-15 19:47:58 +00:00
Simos Mikelatos
6e7e2ff4c1 feat: make browser use opt-in 2026-06-15 18:12:27 +00:00
Simos Mikelatos
e6263dbd1f refactor: store browser use settings in database 2026-06-15 17:57:00 +00:00
Simos Mikelatos
260070bae0 feat: add browser use runtime setup settings 2026-06-15 17:52:27 +00:00
Simos Mikelatos
daac6e3fd3 ci: add macos desktop release workflow 2026-06-15 17:26:53 +00:00
Simos Mikelatos
861cfecbaa feat: add electron app support 2026-06-15 16:21:05 +00:00
Simos Mikelatos
a182765e10 Merge branch 'browser-use' into electron-app 2026-06-15 16:15:03 +00:00
Simos Mikelatos
828d1a2302 Merge remote-tracking branch 'origin/feat/unify-websocket-2' into browser-use-independent 2026-06-15 16:12:10 +00:00
Aurélien
f319d2cf8d feat(i18n): add French (fr) locale (#878)
Complete French translation for all 7 locale files:
auth, chat, codeEditor, common, settings, sidebar, tasks.

Also fixes a bug in languages.js where the Turkish and Italian
entries shared the same object (missing closing brace), causing
Italian to be silently dropped from the supported languages list.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 15:02:50 +03:00
Haileyesus
9fb2d91b26 fix: resolve session provider on backend reads
Session history and token usage reads already have a stable app session id.
Passing provider and project hints from the frontend kept those reads coupled
with provider-specific state that the backend can resolve from the session row.

Resolve token usage provider server-side and narrow the session store read API
to session id plus pagination. This keeps provider-specific storage decisions
behind the backend boundary and makes reconnect, pagination, and load-all use
the same session-owned contract.
2026-06-15 14:04:50 +03:00
Haileyesus
9cb2afd67e fix: upgrade gemini logo 2026-06-15 13:47:28 +03:00
Haileyesus
d0adddbbda fix: normalize project session payloads
The sidebar had to understand cursorSessions, codexSessions,
and other provider buckets because /api/projects exposed
provider-shaped arrays.

That leaked backend adapter storage into project state and made
frontend behavior drift each time a provider needed another bucket
or exception.

Return one sessions list with provider metadata instead. Project
state, search, and running-session filtering now share one contract,
while provider-specific storage remains behind the backend boundary.
2026-06-15 13:43:18 +03:00
Haileyesus
2abb45636b fix: remove provider specific token usage calculator 2026-06-15 13:36:57 +03:00
Haileyesus
677d330981 fix: create one unified function for frontend session processing 2026-06-15 13:36:35 +03:00
Simos Mikelatos
d427004bd7 Merge browser use branch 2026-06-14 20:34:36 +00:00
Simos Mikelatos
243e6cecd5 Add browser use workspace panel 2026-06-14 20:34:16 +00:00
Haileyesus
1b336e9aa9 fix(sidebar): align session status controls across layouts 2026-06-13 00:09:59 +03:00
Haileyesus
7bed675ad5 fix: changes provider logos to svg for fast load 2026-06-13 00:04:56 +03:00
Haileyesus
5b9adbbdee fix(opencode): bind watcher sessions to app rows early 2026-06-12 23:22:11 +03:00
Haileyesus
416a737d76 fix(opencode): pass workspace dir explicitly
The remote environment could start OpenCode runs under /opt/claudecodeui.

That happened even when the selected project path was correct.

The integration relied on child-process cwd alone.

OpenCode run resolves its workspace through the explicit --dir contract.

Pass --dir with the resolved working directory.

Assert in the CLI test that launch args include the workspace dir.
2026-06-12 22:45:22 +03:00
Haileyesus
3bbb42c233 fix(sessions): canonicalize sidebar ids and timestamps
The sidebar could keep a provider-native id after backend remapping.

That left a duplicate non-working session visible until refresh.

Fresh sessions could also appear hours old.

SQLite CURRENT_TIMESTAMP is UTC without a timezone suffix.

Browser parsing then treated those values like local time.

Broadcast a canonical session_upserted event when the provider id is mapped.

Collapse provider-id aliases onto the stable app session id in the client.

Normalize session-row timestamps to ISO UTC when reading from the repository.
2026-06-12 20:52:18 +03:00
Haileyesus
123ae31020 fix(chat): sort messages appropriately 2026-06-11 21:48:46 +03:00
Haileyesus
89f05247ed fix(shell): use correct session id 2026-06-11 21:04:31 +03:00
Haileyesus
00e526b6e9 chore: remove a log 2026-06-11 20:22:07 +03:00
Haileyesus
591b18e9e3 feat(sidebar): improve running session state tracking
Add a running-session view to the sidebar, including header controls, running counts, empty states, and row-level processing indicators so active provider work is visible outside the current chat.

Hydrate running state after refresh through a status-only /api/providers/sessions/running endpoint backed by chatRunRegistry.listRunningRuns, then sync and poll the frontend processingSessions map from AppContent without attaching to chat streams or replaying messages.

Preserve fresh local processing entries during sync so newly sent messages are not cleared before the backend registry catches up, and clear completed sessions once the status endpoint no longer reports them.

Thread active session state through sidebar project/session components, show rotating loaders for processing sessions, and keep the running search mode expanded and filterable.

Fix optimistic local user-message dedupe so repeated prompts are only collapsed when a matching server echo appears from the same send window, preventing sent messages from disappearing until assistant completion.

Add registry test coverage for listing currently running app sessions.

Tests: npx eslint on changed files; npx tsc --noEmit -p tsconfig.json; npx tsc --noEmit -p server/tsconfig.json; npx tsx --tsconfig server/tsconfig.json --test server/modules/websocket/tests/chat-run-registry.test.ts.
2026-06-11 20:04:38 +03:00
Haileyesus
881e72d4a0 fix: correct notification session id 2026-06-11 19:31:13 +03:00
Haileyesus
f5eac2ec12 feat(chat): unify session gateway with stable IDs and a single WS protocol
The frontend previously juggled placeholder IDs, provider-native IDs, and session_created handoffs, which caused race conditions and provider-specific branching. This introduces app-allocated session IDs, a chat run registry with event replay, delta sidebar updates, and one kind-based websocket contract so the UI can treat every provider the same while JSONL remains the source of truth.
2026-06-11 18:47:19 +03:00
Haileyesus
3d948217ef chore: upgrade gemini models 2026-06-11 18:38:02 +03:00
Simos Mikelatos
86f64797b0 Merge pull request #867 from siteboon/chore/add-github-issues-board-plugin 2026-06-11 13:29:44 +02:00
Haileyesus
21b0f14e7a chore: add github issues board plugin 2026-06-11 14:00:41 +03:00
Simos Mikelatos
f12af8a61b Merge pull request #864 from siteboon/chore/add-plugins
chore: add plugins
2026-06-11 09:51:07 +02:00
Haileyesus
afc717e69e feat(chat): derive activity indicator from per-session state and unify provider lifecycle events
Replace the chat processing banner with a minimal activity indicator and
rebuild the state model underneath it. The old banner was driven by five
overlapping pieces of state (isLoading, canAbortSession, claudeStatus in the
chat, plus two app-level Sets updated in lockstep through four callbacks)
that had to be kept in sync imperatively. Because completion and status
events mutated the *viewed* session's flags regardless of which session they
belonged to, a background session finishing could hide the indicator for a
still-running session, returning to a finished session could briefly show a
stale banner, and a late status reply could override a newer request.

The fix is structural rather than patch-by-patch: a single
Map<sessionId, {statusText, canInterrupt, startedAt}> in useSessionProtection
is now the only source of truth for "this session is working". The indicator,
stop button, composer streaming state, and session protection are all derived
from the viewed session's entry on render, so there is no stale local copy to
restore or reset when switching sessions. A PENDING_SESSION_ID sentinel
covers the window before a new conversation receives its real session id.
Terminal events delete the entry atomically, which is why the indicator
disappears the instant the final chunk arrives. Stale check-session-status
replies are discarded via an ifStartedBefore guard (an idle reply older than
the entry's startedAt describes a previous request, not the current one).

The second half unifies the provider lifecycle contract, because the frontend
could not be made race-free while each provider terminated differently:

- cursor emitted complete twice per run (result line + process close), which
  double-played the completion sound and let a late close-complete clear a
  newer request's indicator
- aborts produced two completes (the abort-session reply plus the provider's
  own non-aborted one), so cancelling a run played the celebration sound
- codex omitted exitCode; others attached ad-hoc fields (resultText, isError,
  isNewSession) the client had to know about
- claude/codex failures ended with only an error event while gemini/cursor
  also emit kind:'error' for mid-run stderr noise, so 'error' was ambiguous
  between "the run died" and "a process wrote to stderr"

Every run now ends with exactly one complete built by createCompleteMessage()
({sessionId, actualSessionId, exitCode, success, aborted}); abort-session
sends it on behalf of cancelled runs and providers detect the abort and skip
their own. error is demoted to an informational row, so stderr noise no
longer kills the indicator mid-run, and the client celebrates only
success: true completes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:39:04 +03:00
Haileyesus
f549bd99e7 docs: update available plugin readmes 2026-06-10 16:57:40 +03:00
Haileyesus
bc34085af9 chore: add more plugins list 2026-06-10 16:49:38 +03:00
Simos Mikelatos
6a53c31e90 feat: render changelog as markdown in version upgrade modal 2026-06-09 20:53:15 +00:00
Simos Mikelatos
92de0ed613 chore: remove unused modelConstants from the project 2026-06-09 20:51:19 +00:00
viper151
b6a45b3183 chore(release): v1.34.0 2026-06-09 20:34:48 +00:00
Simos Mikelatos
ce327b6fa9 feat: adding Fable 5 in claude code 2026-06-09 20:33:00 +00:00
viper151
276639099b chore(release): v1.33.3 2026-06-09 16:18:59 +00:00
Simos Mikelatos
f4f88318c2 Merge pull request #853 from siteboon/feature/chat-completion-notifications
feat: signal when chat runs complete
2026-06-09 18:14:53 +02:00
Simos Mikelatos
029d159592 Merge branch 'main' into feature/chat-completion-notifications 2026-06-09 18:13:42 +02:00
Simos Mikelatos
7c9ec8fa12 Merge pull request #859 from jakeefr/fix/editor-toolbar-offscreen-796
fix: keep editor toolbar in view on long unwrapped lines
2026-06-09 18:10:37 +02:00
Simos Mikelatos
1b4d4b7278 Merge branch 'main' into fix/editor-toolbar-offscreen-796 2026-06-09 18:10:28 +02:00
Simos Mikelatos
b1a0afe9e0 Merge pull request #856 from bourgois/fix/chat-initial-scroll-reanchor
fix(chat): re-anchor initial scroll across lazy content reflow
2026-06-09 18:06:17 +02:00
Simos Mikelatos
88eb2009bb Merge branch 'main' into fix/chat-initial-scroll-reanchor 2026-06-09 18:05:41 +02:00
Simos Mikelatos
602e6ad4ac fix: address notification review feedback 2026-06-09 16:04:15 +00:00
Simos Mikelatos
4a2453fe32 Merge pull request #848 from siteboon/chore/add-prism-plugin
chore: add prism plugin
2026-06-09 17:46:56 +02:00
Simos Mikelatos
f439a8a3d5 Merge branch 'main' into chore/add-prism-plugin 2026-06-09 17:46:47 +02:00
Simos Mikelatos
23210bc40e Merge branch 'main' into feature/chat-completion-notifications 2026-06-09 17:39:56 +02:00
Jake
beae8c6513 fix: keep editor toolbar in view on long unwrapped lines 2026-06-09 10:38:27 -05:00
ShockStruck
33a4e72ca4 fix(chat): re-anchor initial scroll across lazy content reflow
The previous initial-scroll behavior fired one scrollToBottom() at
+200ms after the session load and cleared the pending flag. When
markdown, syntax highlighting, or images finished rendering after
that window, scrollHeight grew but nothing re-anchored the viewport.
The chat tab appeared "scrolled way up" with the latest assistant
message off-screen until the user manually scrolled or sent a new
message.

This replaces the setTimeout with a requestAnimationFrame loop that
re-scrolls every frame while scrollHeight is still growing, capped
at ~1s (60 frames) or 3 consecutive stable frames. The loop cancels
cleanly on session change via the existing pendingInitialScrollRef
flag, and the cleanup function cancels any in-flight rAF on unmount.

No behavior change for sessions whose content layout is already stable
at the first frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 15:39:26 +02:00
szmidtpiotr
f7c0024fe1 fix: slash command suggestions trigger at any / in input, not only at start (#843)
Previously the regex ^\/(\S*)$ only matched when the entire text before
the cursor was a bare /command. Typing a slash mid-sentence (e.g.
"please run /he") produced no suggestions.

Changed pattern to (?:^|\s)(\/\S*)$  which matches / at the start of
input or after any whitespace. Also compute slashPos from match.index
instead of hardcoding 0, so insertCommandIntoInput replaces the correct
slice of the input when the command is mid-sentence.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 15:56:31 +03:00
Haileyesus
ca8fd0ee23 fix: align prism plugin name and id with manifest.json 2026-06-09 15:44:42 +03:00
Simos Mikelatos
b7e6bca2e3 Merge pull request #851 from jakeefr/fix/windows-plugin-env
Pass Windows-essential env vars to plugin subprocesses
2026-06-09 14:41:27 +02:00
Simos Mikelatos
84c166c4cb Merge pull request #847 from siteboon/feature/file-tree-upload-ux
feat: add file tree upload progress
2026-06-09 14:26:31 +02:00
Simos Mikelatos
231ed04002 Merge branch 'main' into feature/file-tree-upload-ux 2026-06-09 14:25:57 +02:00
Haileyesus
d70dc077bf feat: signal when chat runs complete
Users can miss chat completions while the app is in the background.

They can also miss completions when their attention is elsewhere.

Add opt-out sound notifications and a temporary title marker.

This makes completion noticeable without external audio assets or persistent browser notifications.
2026-06-09 13:51:51 +03:00
Jakob Michael Werner
1faa1a6a00 Pass Windows-essential env vars to plugin subprocesses
Plugin servers are started with a deliberately minimal env (PATH, HOME,
NODE_ENV, PLUGIN_NAME). On Windows that drops system variables that child
processes need to bootstrap. The one that bit me: without APPDATA, CPython
cannot find the per-user site-packages, so a plugin that shells out to a
pip install --user CLI launches the tool but it dies with ModuleNotFoundError.
SystemRoot, PATHEXT and TEMP cause similar failures for other tools.

On win32, pass through a small allowlist of non-secret system variables
(SystemRoot, windir, SystemDrive, USERPROFILE, APPDATA, LOCALAPPDATA, TEMP,
TMP, PATHEXT) when they are set. No change off Windows, and no host secrets
are exposed.
2026-06-08 17:13:10 -05:00
Haileyesus
3cd89956ba fix: update naming convention 2026-06-08 16:10:24 +03:00
Haileyesus
01dbe2a8bf chore: add prism plugin 2026-06-08 15:55:40 +03:00
Noah
f4a1614a0a fix(sandbox): prevent server SIGHUP on sbx exec exit (#792)
Replace bare background operator with nohup+disown so the cloudcli
server process survives after the sbx exec session terminates.
Also redirects stdout/stderr to /tmp/cloudcli-ui.log for debugging
via `cloudcli sandbox logs`.

Fixes #791

Co-authored-by: NoahHahm <noah@naverz-corp.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Haile <118998054+blackmammoth@users.noreply.github.com>
2026-06-08 15:28:35 +03:00
Haileyesus
c235b05e1d feat: add file tree upload progress
Users need a visible upload path from the explorer itself, not only drag and
 drop behavior with no progress feedback. Routing picker and drop uploads
 through one XHR-backed hook keeps progress, validation, refresh, and success
 counts consistent for every upload source.

The 200MB limit is mirrored in the client, multer, and nginx template so large
 uploads fail predictably instead of being blocked by whichever layer sees the
 request first. The server also returns explicit requested and uploaded counts
 so partial or multi-file batches can render accurate status text.
2026-06-08 14:52:09 +03:00
viper151
dd77649053 chore(release): v1.33.2 2026-06-08 05:58:08 +00:00
Simos Mikelatos
af3a28abc7 Remove fast project display names option 2026-06-07 08:55:16 +00:00
Simos Mikelatos
371ff034e4 Add fast project display names option 2026-06-07 08:46:31 +00:00
Simos Mikelatos
3b4d6885aa Add lightweight projects query options 2026-06-07 08:10:28 +00:00
Simos Mikelatos
bc9d2dd830 Merge pull request #839 from siteboon/fix/claude-token-cache-usage 2026-06-05 21:48:44 +02:00
妖怪不丸
c21a9f4561 feat(i18n): add Traditional Chinese (zh-TW) locale (#773)
* feat(i18n): add Traditional Chinese (zh-TW) locale

* fix(i18n): localize remaining English strings and fix README fence (zh-TW)

* fix(i18n): track zh-TW tasks.json (was excluded by .gitignore)

---------

Co-authored-by: Haile <118998054+blackmammoth@users.noreply.github.com>
2026-06-05 21:45:49 +03:00
Haileyesus
ed9cdf0114 fix: include Claude cache tokens in usage 2026-06-05 21:38:05 +03:00
Simos Mikelatos
b39997c429 Merge pull request #838 from siteboon/fix/do-not-show-model-description-in-chat-view 2026-06-05 20:31:55 +02:00
Haileyesus
d638a8982c fix: do not show model description in chat view 2026-06-05 21:28:08 +03:00
Simos Mikelatos
f238050b85 feat(chat): open cost modal from token usage 2026-06-05 17:33:22 +00:00
viper151
beaa2d2533 chore(release): v1.33.1 2026-06-05 16:56:27 +00:00
Simos Mikelatos
c90b34108e chore: update package-lock.json 2026-06-05 16:54:22 +00:00
Simos Mikelatos
323357384e Merge pull request #837 from siteboon/fix/tool-result-error-rendering 2026-06-05 17:40:54 +02:00
Simos Mikelatos
d509aa635b Merge pull request #834 from siteboon/chore/update-claude-fallback-models 2026-06-05 17:36:22 +02:00
Haile
2149b8776b fix: remove thinking mode (#835) 2026-06-05 17:35:39 +02:00
Haile
2b416f2dcb Merge branch 'main' into fix/tool-result-error-rendering 2026-06-05 16:32:51 +03:00
Haileyesus
bb8db5815c fix: show Claude tool result errors
Claude stores some tool failures as errored tool_result rows. The UI either
attached those rows to hidden tool output or dropped them when no matching tool
call was rendered, which made validation failures disappear from chat history.

Render unattached errored tool results, unwrap Claude tool_use_error content,
and keep tool errors visible even for tools whose successful output is hidden.
Also remove the permission-grant recovery controls from rendered error history
so denied tool use stays a plain error message.
2026-06-05 16:16:34 +03:00
Haile
b3d0f9037d Merge branch 'main' into chore/update-claude-fallback-models 2026-06-05 15:58:05 +03:00
Haile
3ec76b5bb1 docs: add nginx subpath deployment template (#820)
Users deploying behind a reverse proxy need a config they can adapt.

The template documents each proxy block and centralizes upstream/subpath values.

It also notes that Nginx location matchers still require literal subpath edits.
2026-06-05 14:24:26 +02:00
Haile
14ddbc7c57 fix: redact websocket auth token in logs (#827) 2026-06-05 14:23:27 +02:00
Haile
ebb0e59e80 fix: file tree concurrency (#828)
* perf(file-tree): parallelize directory traversal and widen default ignore list

The project file-tree endpoint walked children sequentially with
`await fsPromises.stat()` inside a for-loop plus a separate
`fsPromises.access()` probe before recursing. On high-latency
filesystems (NFS/SMB) every one of those round-trips was serialized,
so a 120k-file SMB-mounted project took ~2 minutes to load.

This change:
* Runs stat() and recursive getFileTree() calls in parallel via
  `Promise.all` — pipelines round-trips and lets subtree traversals
  overlap.
* Drops the redundant access() probe; any EACCES now surfaces from
  readdir's own try/catch in the recursive call, saving one RTT per
  directory.
* Extracts the hardcoded skip list into an IGNORED_DIRS Set and
  extends it to cover common Python / Rust / JVM / IDE build
  artefacts (.next, __pycache__, .pytest_cache, .tox, .venv,
  target, .gradle, .idea, coverage, etc).

No API shape change; existing consumers get the same tree structure,
only much faster on large or remote-mounted projects.

* fix(file-tree): bound filesystem traversal concurrency

Prevent large file-tree scans from launching unbounded stat and readdir work.

Keep the parallel traversal benefit on high-latency mounts with a bounded queue.

Ignore skipped names only for directories so same-named files stay visible.

* fix(file-tree): inspect entries with lstat

Use lstat for file-tree metadata so symlink entries are identified without following targets.

---------

Co-authored-by: leonkong via Claude <leonkong.claude@users.noreply.github.com>
2026-06-05 14:21:30 +02:00
Haile
957f53fb99 Merge branch 'main' into chore/update-claude-fallback-models 2026-06-05 15:19:13 +03:00
Haile
ef2fd48b46 fix(shell): disconnect and restart buttons (#831)
Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-06-05 14:17:20 +02:00
Haileyesus
cdcac182d4 fix: load claude models directly from provider
Claude's model catalog changes quickly enough that a shared three-day cache can
leave users selecting stale defaults or missing newly available model aliases.
Route Claude model lookups through the provider every time so the UI and slash
commands reflect the current provider result instead of an old disk snapshot.

Keep the static fallback catalog aligned with the latest Claude defaults so the
provider still has a sensible response when live discovery is unavailable.
2026-06-05 15:14:32 +03:00
Haileyesus
94785bfa57 chore: update Claude fallback models 2026-06-05 15:02:25 +03:00
Haile
9e608b8426 Fixes/minor fixes (#832)
* chore: update claude agent sdk to latest version

* fix: show CTRL+K correctly in chatview
2026-06-05 13:56:34 +02:00
Simos Mikelatos
c667b6a179 Update model version in OPTIONS description 2026-06-04 23:43:48 +02:00
Reza Moghaddam
fa9eaf5573 feat(chat): auto-detect text direction for RTL languages (#729)
Add dir="auto" to chat message content and composer textarea so
Persian and Arabic text automatically renders right-to-left
while English and other LTR text remains unaffected.

Co-authored-by: Haile <118998054+blackmammoth@users.noreply.github.com>
2026-06-04 22:24:07 +03:00
Vojtech
2edfef2e3f fix(websocket): add 30s server-side heartbeat to prevent proxy idle disconnects (#770)
The WebSocket gateway never sent ping frames, so any reverse proxy with
an idle timeout (Cloudflare Tunnel ~100s, AWS ALB 60s, nginx 60s, etc.)
would silently tear down /shell, /ws and /plugin-ws/* connections after
the idle window. The UI reconnects automatically but users see a
"Connecting to shell" toast every 1–3 minutes during normal use and any
in-flight PTY/chat traffic can race the reconnect.

Schedule a 30s ws.ping() per connection at the gateway level, cleared on
close/error. ping/pong counts as protocol activity for all proxies that
implement WebSocket correctly, so this single change covers every
deployment topology without per-proxy tuning.

Fixes #769

Co-authored-by: Haile <118998054+blackmammoth@users.noreply.github.com>
2026-06-04 22:07:59 +03:00
ehsanmim
96b16b42e4 fix(vite): proxy /plugin-ws WebSocket requests to the backend in dev (#757)
Plugin WebSocket connections (e.g. the official Terminal plugin) hang
in `npm run dev` because Vite proxies /api, /ws, and /shell but not
/plugin-ws/*. Production is unaffected because the same Express server
serves both the frontend and the WS gateway.

Co-authored-by: Haile <118998054+blackmammoth@users.noreply.github.com>
2026-06-04 20:57:24 +03:00
Peter Buchegger
f082cdc63b fix(websocket): reset unmountedRef on each effect re-run so token refresh reconnects (#721)
The effect cleanup sets unmountedRef.current = true to prevent reconnects after
the provider unmounts. Without an inverse reset at the start of the effect,
re-running the effect (e.g. when the auth token rotates) leaves the ref true,
and connect() short-circuits at its unmounted guard. The socket then stays
permanently disconnected for the lifetime of the provider.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Haile <118998054+blackmammoth@users.noreply.github.com>
2026-06-04 20:50:02 +03:00
Haile
d9e9df183f fix: plugin svg icon sanitization (#817)
* fix(security)(components): unsanitized svg content injected via `dangerouslys

The plugin icon renderer fetches SVG text from `/api/plugins/.../assets/...` and injects it directly into the DOM using `dangerouslySetInnerHTML` after only checking that the payload starts with `<svg`. This does not remove malicious attributes/elements (e.g., event handlers, scriptable SVG payloads), enabling DOM-based XSS if a plugin asset is malicious or compromised.

Affected files: PluginIcon.tsx

Signed-off-by: tuanaiseo <221258316+tuanaiseo@users.noreply.github.com>

* fix: sanitize plugin svg icons

---------

Signed-off-by: tuanaiseo <221258316+tuanaiseo@users.noreply.github.com>
Co-authored-by: tuanaiseo <tuanaiseo@gmail.com>
Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-06-02 13:24:38 +02:00
Haile
43c33d5cb1 fix: recognize claude auth token env (#818) 2026-06-02 13:23:30 +02:00
viper151
b988e0da51 chore(release): v1.33.0 2026-06-01 20:57:51 +00:00
Haile
f132a21cd7 Fix/router basename root prefix (#815)
* fix: harden router basename detection

* fix: broaden icon basename detection

* fix: ignore cross-origin basename hints

* fix: keep root deployments from inheriting asset basenames

Router basename detection must support root hosting and path-prefix hosting at runtime.

The icon fallback used /icons/icon-192x192.png as a basename on root deployments.

After login, React Router mounted at /icons while the current URL was /.

That mismatch made authenticated root deployments render a blank page.

Strip known asset directories even when they are the only path segment.

Root icon URLs now keep basename ''. Prefixed /ai/icons/... URLs still resolve to /ai.

---------

Co-authored-by: JohnGenri <myname945@gmail.com>
Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-06-01 22:45:57 +02:00
CoderLuii
36b860e322 fix: preserve WebSocket frame type in plugin proxy (#594)
* fix: preserve WebSocket frame type in plugin proxy

The plugin WebSocket proxy relays all messages as binary frames
regardless of the original frame type. This causes text-based ready
messages to be forwarded as binary, so the browser never processes
them and plugin UIs (like web-terminal) show a spinner indefinitely.

Pass the isBinary flag through in both relay directions so the
original frame type is preserved.

Fixes CoderLuii/HolyClaude#11

* fix(plugins): preserve websocket frame type in proxy

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 20:56:51 +03:00
CoderLuii
1e125f3db5 fix: refresh Claude auth status after login flow (#617)
* fix: refresh Claude auth status after login flow

* fix: rely on refreshed auth status after login

---------

Co-authored-by: HolyCode User <noreply@holycode.local>
2026-05-31 14:17:27 +03:00
Peter Buchegger
dbc41dc91d fix(chat): prevent double send on mobile by removing redundant submit handlers (#719)
PromptInputSubmit already has type="submit" via the parent form, so the
button's click triggers handleSubmit through the form's onSubmit path.
The added onMouseDown/onTouchStart handlers created two extra paths that
both invoked handleSubmit; on iOS Safari a single tap could fire both
touchstart and a synthetic mousedown before isLoading state propagated,
producing two messages and two image-upload roundtrips.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 15:50:45 +03:00
Haile
38bf21ddf5 fix: refine token usage reporting (#807) 2026-05-30 10:09:27 +02:00
Haile
86948097aa fix - group plugin settings by source (#808) 2026-05-30 10:09:06 +02:00
Alex Navarro
951f58751c fix(sidebar): keep session rename input visible while editing (#781)
The rename input shares a parent div that uses `group-hover:opacity-100`,
so moving the cursor off the row visually hid the input mid-edit.

While editing, force the action panel to `opacity-100` and dismiss it
via an outside-click listener instead of mouseleave. Also hide the
relative-time badge so it does not overlap the input.
2026-05-29 18:04:35 +03:00
Alex Navarro
27e509a9b8 feat(sidebar): tooltip for the active-session indicator dot (#782)
The pulsing green dot next to a session row signals that the session
had activity in the last 10 minutes, but the meaning was undocumented.
Hovering it now shows a translated tooltip, and an aria-label exposes
the same text to screen readers.

Uses the existing shared Tooltip component (portal-positioned, so it
is not clipped by the sidebar overflow). Translation key added to all
eight sidebar locale files (en, de, it, ja, ko, ru, tr, zh-CN).
2026-05-29 18:02:20 +03:00
Tim McNulty
295bad9c00 style: fix project star button location by replacing folder icon (#793) 2026-05-29 17:54:56 +03:00
Haile
3b79aab958 Fix/use fallback models for claude (#806)
* fix: remove the hide cursor on windows logic

* feat(cursor): update fallback models

* fix(claude): force fallback models and disable supportedModels lookup
2026-05-29 13:33:13 +02:00
Haile
997cf9fd1a Feature/update cursor model (#804)
* fix: remove the hide cursor on windows logic

* feat(cursor): update fallback models
2026-05-28 20:23:01 +02:00
Haile
374e9de719 feat: add opencode support (#762)
* feat: add opencode support

* fix: stabilize opencode session startup

* fix: /models

* fix: improveUI for commands

* fix: format commands.js

* feat: load models through provider adapters

Provider model selection had outgrown a single hardcoded service.

The old service mixed shared caching with provider catalogs and CLI lookup details.

That made stale model lists more likely as providers changed on separate schedules.

Move model discovery behind each provider so lookup lives next to the integration.

The shared service now focuses on provider resolution, caching, persistence, and dedupe.

Return cache metadata and add bypassCache because model availability changes outside the app.

The UI and /models command can show freshness and let users force a provider refresh.

Surface model descriptions while keeping fallback catalogs for unavailable CLIs or SDKs.

* feat(models): resolve active session models through provider adapters

The model inventory command was showing a mix of catalog defaults and
composer-local state instead of the model that is actually active for a
real provider session. That made /models, /cost, and /status
misleading once a session had already started, especially for providers
whose effective runtime model can differ from the optimistic model value
held in the UI.

Introduce an explicit getCurrentActiveModel() contract on
IProviderModels so model resolution lives next to each provider's
catalog logic and uses the provider-native source of truth:

- Claude reads the init event from a resumed stream-json run
- Codex reads model from ~/.codex/config.toml
- Cursor reads lastUsedModel from the chat store.db
- OpenCode reads the persisted session model from opencode.db
- Gemini intentionally returns its default because the CLI does not
  provide a reliable active-session lookup

Keep the returned shape intentionally minimal ({ model }). The goal is
to expose only what downstream command consumers need and avoid leaking
provider-specific metadata into a shared transport shape that would
create extra UI coupling and future cleanup cost.

Also make command behavior session-aware: when there is no concrete
session id, do not spawn provider processes or inspect provider session
storage just to answer /models, /cost, or /status. In a new-session
view the correct answer is simply the provider default, and doing more
work there adds latency and unnecessary side effects for no user value.

As part of this, centralize two supporting concerns:

- add a shared helper for building the default current-model result from
  a provider catalog so fallbacks stay aligned with DEFAULT
- move leaf-directory validation into shared utils so Cursor session
  readers and model lookup code enforce the same path-safety rule

Tests were expanded to cover both the new service delegation path and
the sessionless command behavior, while keeping cache-sensitive tests
isolated from persisted host cache state.

Why this change:
- command output should reflect the model actually driving a session
- new-session views should stay fast and side-effect free
- provider-specific active-model lookup should not be scattered across
  routes or UI code
- fallback behavior should be explicit, consistent, and limited to the
  provider default when no true active model can be resolved

* feat: support session-scoped model overrides

Model selection was acting like a provider-level preference.

That made resumed sessions drift back to a default or request-time model.

Users expect /models changes made inside a conversation to affect that session.

Store explicit session choices in app-owned ~/.cloudcli state.

This avoids editing provider transcripts or native provider config.

Resolve the effective model before launching each provider runtime.

Claude, Cursor, Codex, Gemini, and OpenCode now honor stored resume choices.

Expose a backend active-model change endpoint for existing sessions.

The models modal can now distinguish default changes from session overrides.

It also shows when a selected model will apply on the next response.

For Claude, stop probing active model state by resuming with a dummy prompt.

Read the indexed JSONL transcript from the end instead.

This preserves provider history while honoring /model stdout or model fields.

Add service tests for adapter delegation and resume-model precedence.

The tests keep cache state, override state, and requested fallback separate.

* feat: make command modal more compact

* fix: preserve opencode session creation events

OpenCode emits the real session id asynchronously on its first JSON output. The runner
registered that id from a helper that could not see the spawned process because
the process reference was scoped inside the model-resolution callback. That
ReferenceError was swallowed by the generic JSON parse fallback, so the client
never received session_created. Without that event, a new OpenCode chat stayed
on / and the assistant stream was not attached to the new session view.

Keep the process reference in the outer spawn scope so registration can update
the active-process map and websocket writer as soon as OpenCode announces the
session id. Split JSON parsing from event processing so malformed non-JSON
output can still stream as raw text, while registration or adapter failures are
surfaced as real errors instead of being hidden as assistant content.

Add a fake opencode executable regression test to lock in the expected lifecycle
ordering: session_created must be sent before live assistant messages, and the
same session id must carry through stream_end and complete.

* fix: clarify model refresh and onboarding providers

OpenCode is now a supported chat provider, but first-run onboarding still only offered
Claude, Cursor, Codex, and Gemini. That made OpenCode harder to discover and
forced users to finish setup before finding the provider in settings or chat.
Adding it to onboarding keeps first-run setup aligned with the providers the
application already supports elsewhere.

The model refresh control was also doing too much visual work. In the new chat
model picker, the previous Hard Refresh label looked like the dialog heading,
which made the primary task unclear. Users open that dialog to choose a model;
refreshing catalogs is only a secondary maintenance action for stale cached
provider model lists.

Rename and reposition the refresh affordance so the model picker reads as a
model picker first. The copy now explains why catalogs are cached, when a refresh
is useful, and that the refresh checks every provider. The /models modal gets the
same clarification so both model-selection surfaces describe the cache behavior
consistently.

* fix: format opencode model catalog labels

OpenCode returns provider-prefixed ids directly from the CLI. Passing those ids through as
labels made the model picker hard to scan: users saw values like
anthropic/claude-3-5-sonnet-20241022 or lowercased, hyphen-split text instead
of readable model names.

Keep the exact OpenCode id as the option value because that is what the CLI
expects, but derive a presentation label for the frontend. The formatter is
intentionally generic rather than a catalog of known providers. It handles common
identifier structure such as provider/model, hyphen-delimited words, v-prefixed
versions, adjacent numeric version tokens, and 8-digit date suffixes.

This keeps OpenCode usable as its model list expands across many upstream
providers without requiring code changes for every new provider or model family.
The description keeps the raw provider-prefixed id visible so users can still
confirm the precise model being selected.

* feat: add more fallback models for cursor

* docs: move model catalog out of shared

The model catalog is no longer a frontend/backend runtime contract.

Keeping it under shared made ownership misleading. It implied the catalog was
application code shared by runtime consumers, even though it now only supports
README links and public API documentation.

Move the catalog into public so it lives beside the docs surfaces that need it.
This gives the API docs a stable, served module and gives README readers a
linkable source without suggesting frontend or backend runtime dependency.

Render the API docs model list from the exported provider registry instead of a
hardcoded Claude/Cursor/Codex subset. That keeps Gemini and OpenCode visible and
makes future provider documentation changes flow through one docs-specific file.

Update README links, provider maintenance notes, and package files so published
artifacts include the standalone docs page and model catalog without relying on
the old shared path.

* fix: simplify empty-state model selector

Keep the provider empty state focused on the setup action users need there:

choosing a model.

The refresh control, cache timestamp, and refresh explanation made the dialog feel

like a cache-management surface.

That extra action is out of place in the empty state, where the goal is to start

a chat with the selected provider and model.

Remove the refresh-specific UI from ProviderSelectionEmptyState and drop the

now-unused refresh/cache props from the ChatMessagesPane pass-through.

Refresh behavior remains available in the dedicated command result flow.
2026-05-28 10:50:41 +02:00
viper151
10f721cf14 chore(release): v1.32.0 2026-05-13 12:02:26 +00:00
Haile
631695ef73 Surface provider skills in the slash command menu (#759)
* feat(providers): surface skills in slash command menu

Provider skills were hidden behind provider-specific filesystem rules.

That made the backend and UI unable to offer one discovery path for skills.

Add a normalized skills contract, provider service, and provider skills API.

Keep provider-specific lookup rules inside adapters so routes and UI stay generic.

Claude needs plugin handling because enabled plugins resolve through installed_plugins.json.

Plugin folders can expose commands or skills, so Claude scans both forms.

Claude plugin commands are namespaced to avoid collisions with user and project skills.

Codex, Gemini, and Cursor adapters map their expected skill roots into the same contract.

The slash menu now shows skills beside built-in and custom commands for discovery.

The menu avoids mid-message activation, duplicate rows, loose namespace matches, and input overlap.

Provider tests cover discovery locations and Claude plugin edge cases.

* fix(providers): guard invalid skill command namespaces

Claude plugin ids come from local settings and installed plugin metadata.

Invalid ids such as empty strings or @ should not become command namespaces.

Skip plugin folders when no safe plugin name can be derived.

This prevents malformed slash commands like /:command from reaching the UI.

Add regression coverage for empty and @ plugin ids.

Keyboard selection in the slash menu should match mouse selection.

Only skills are inserted into the composer because they are provider invocations.

Built-in and custom commands execute directly and close the menu on success or failure.

* fix(security): centralize safe frontmatter parsing

Move frontmatter parsing into server/shared/frontmatter.ts so every backend caller
uses the same gray-matter configuration instead of importing gray-matter directly.

The goal is to keep executable JS and JSON frontmatter engines disabled for
all markdown discovered from the filesystem, not only command routes.

Provider skills and shared skill metadata now go through parseFrontMatter too.
That closes the gap where plugin or provider markdown could regain default
gray-matter behavior simply because it lived outside the original command path.

Classify the new parser in backend boundaries so modules can depend on the
safe shared API without reaching into legacy utility paths.

* feat(providers): add comprehensive guide for provider module setup and usage
2026-05-12 21:33:12 +03:00
Haile
039696c2de Fix/websocket streaming issues (#748) 2026-05-08 22:51:03 +03:00
Haile
beb0a50413 fix: enhance regex to correctly parse wrapper file paths for claude.exe (#741) 2026-05-04 17:54:02 +02:00
Haile
e89d2da5df Fix New session issues and websocket issues (#738)
* fix: reset-state-on-new-session-click

* fix(chat): preserve continuity while session ids settle

New conversations were crossing a short but important consistency gap.
The route could already point at a newly created session id while the
projects payload had not refreshed yet, and realtime/optimistic messages
could still be keyed under a provisional id. In that window the UI could
stop reading the active session store, briefly render the conversation as
missing, and then repopulate it a moment later.

That same gap also made duplication more likely. Optimistic local user
messages could survive long enough to appear beside the persisted copy,
and finalized assistant streaming rows could sit directly next to the
server-backed assistant message with the same content before realtime state
was cleared. The result was a chat view that felt unstable exactly when a
new session was being created.

This commit makes session-id reconciliation a first-class part of the chat
flow instead of assuming every layer will agree immediately. The session
store now understands canonical session aliases and can migrate one
conversation from a provisional id to the real id without dropping its
in-memory state. The route navigation path can replace the provisional URL
entry instead of stacking it in history, and the project/session selection
logic keeps a synthetic selected session alive long enough for the sidebar
and project payloads to catch up.

The practical goal is to keep one visible conversation throughout the whole
creation lifecycle: no dead window between websocket events and project
refresh, no stale provisional URL after the real id is known, and no extra
optimistic/local bubbles when server history catches up.

* fix(cli): resolve executable path for Claude CLI on Windows

* fix(session-synchronizer): improve session name extraction for Claude and Codex
2026-05-04 13:54:07 +03:00
simosmik
392c73b693 fix: add clarification on auto mode 2026-04-30 15:28:00 +00:00
viper151
5e7c4c5f8c chore(release): v1.31.5 2026-04-30 14:12:48 +00:00
simosmik
3f71d4932b feat: add auto mode to claude code 2026-04-30 14:09:51 +00:00
viper151
80561ee9e9 chore(release): v1.31.4 2026-04-30 12:47:35 +00:00
simosmik
658421c1c4 fix: bump codex sdk to latest version 2026-04-30 12:45:30 +00:00
viper151
881465aa71 chore(release): v1.31.3 2026-04-30 11:50:31 +00:00
Simos Mikelatos
9f2afebc66 Create command palette and add new features for search and actions (#728)
* refactor(ui): replace in-repo Command primitive with cmdk wrapper

* feat(command-palette): add global Cmd+K palette with v1 actions

* feat(command-palette): add session, file, and commit search sources

* refactor: add provider names to model constants

* feat(command-palette): add settings, navigation, message search, and ⌘K hints

* feat(command-palette): add git fetch/pull/push and branch switch actions

* refactor(command-palette): consolidate fetch source hooks behind useApiSource

* refactor(command-palette): extract useCommandKey and SETTINGS_MAIN_TABS metadata

* refactor(command-palette): extract groups into declarative registry

* refactor(command-palette): wire openFile through PaletteOpsContext

* refactor: migrate openSettings and refreshProjects from window.* to PaletteOpsContext

* refactor(command-palette): inline groups and delete registry indirection

* refactor(command-palette): return items array directly from source hooks

* refactor(palette-ops): flatten Handle wrapper into ref-based registry

* refactor: inline useCommandKey as MOD_KEY constant in two call sites

* feat: introduce pages and fix bug on branch switching

* fix: small labels

* fix: coderabbit issues

* fix: coderabbit comments

* Update src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-04-30 14:48:48 +03:00
viper151
df3d5de8c1 chore(release): v1.31.2 2026-04-30 07:02:47 +00:00
Simos Mikelatos
b44c93d884 Bump version from 1.31.0 to 1.31.1 2026-04-30 09:01:23 +02:00
Simos Mikelatos
a1c6d667a4 Bump version from 1.31.0 to 1.31.1 2026-04-30 09:01:01 +02:00
simosmik
0753c04783 fix: migrations for new sqlite schema 2026-04-30 06:56:43 +00:00
Simos Mikelatos
e1275e6d3c Add CloudCLI Scheduler plugin description to README 2026-04-30 08:32:54 +02:00
viper151
ccb8b83692 chore(release): v1.31.0 2026-04-30 06:31:49 +00:00
Simos Mikelatos
641731b3ef Update modelConstants.js 2026-04-30 08:23:35 +02:00
Simos Mikelatos
d4bdc667cc Reorder sonnet model in CLAUDE_MODELS 2026-04-30 08:23:21 +02:00
Simos Mikelatos
ce724e6e3f Add GPT-5.5 model to CODEX_MODELS 2026-04-30 08:22:26 +02:00
Rkkooo
b4a39c7297 fix(/status): use CLAUDE_MODELS.DEFAULT instead of stale 'claude-sonnet-4.5' fallback (#723)
The /status slash command's response object falls back to a hardcoded
'claude-sonnet-4.5' when no session model is set in context. Two issues:

1. 'claude-sonnet-4.5' is not in CLAUDE_MODELS.OPTIONS (the dropdown), so
   the displayed model can be a value the user can't actually select.
2. CLAUDE_MODELS.DEFAULT is currently "opus" — meaning a fresh session
   started with no model picked actually runs on opus, not sonnet-4.5.
   The /status command therefore mis-reports the running model.

Tracking the centralized DEFAULT keeps the displayed value coherent with
what claude-sdk.js actually spawns (line 208 already uses
`options.model || CLAUDE_MODELS.DEFAULT`).

Co-authored-by: Enrico Masella <enrico.masella@gmail.com>
Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-04-30 08:20:14 +02:00
Haile
44edf94f3a Refactor provider/session architecture to be DB-driven, modular, and sessionId-first across backend and frontend (#715)
* refactor: remove unused exports

* refactor: remove unused fields from project and session objects

* refactor: rename session_names table and related code to sessions for clarity and consistency

* refactor(database): move db into typescript

- Implemented githubTokensDb for managing GitHub tokens with CRUD operations.
- Created
otificationPreferencesDb to handle user notification preferences.
- Added projectsDb for project path management and related operations.
- Introduced pushSubscriptionsDb for managing browser push subscriptions.
- Developed scanStateDb to track the last scanned timestamp.
- Established sessionsDb for session management with CRUD functionalities.
- Created userDb for user management, including authentication and onboarding.
- Implemented apidKeysDb for storing and managing VAPID keys.

feat(database): define schema for new database tables

- Added SQL schema definitions for users, API keys, user credentials, notification preferences, VAPID keys, push subscriptions, projects, sessions, scan state, and app configuration.
- Included necessary indexes for performance optimization.

refactor(shared): enhance type definitions and utility functions

- Updated shared types and interfaces for improved clarity and consistency.
- Added new types for credential management and provider-specific operations.
- Refined utility functions for better error handling and message normalization.

* feat: added session indexer logic

* perf(projects): lazy-load TaskMaster metadata per selected project

Why:

- /api/projects is a hot path (initial load, sidebar refresh, websocket sync).

- Scanning .taskmaster for every project on each call added avoidable fs I/O and payload size.

- TaskMaster metadata is only needed after selecting a specific project.

- Moving it to a project-scoped endpoint makes loading cost match user intent.

- The UI now hydrates TaskMaster state on selection and keeps it across refresh events.

- This prevents status flicker/regression while still removing global scan overhead.

- Selection fetches are sequence-guarded to block stale async responses on fast switching.

- isManuallyAdded was removed from responses to keep the public project contract minimal.

- Project dumps now use incrementing snapshot files to preserve history for debugging.

What changed:

- Added GET /api/projects/:projectName/taskmaster and getProjectTaskMaster().

- Removed TaskMaster detection from bulk getProjects().

- Added api.projectTaskmaster(...) plus selection-time hydration in frontend contexts.

- Merged cached taskmaster values into refreshed project lists for continuity.

- Removed isManuallyAdded from manual project payloads.

* refactor: update import paths for database modules and remove legacy db.js and schema.js files

* refactor(projects): identify projects by DB projectId instead of folder-derived name

GET /api/projects used to scan ~/.claude/projects/ on every request, derive
each project's identity from the encoded folder name, and re-parse JSONL
files to build session lists. Using the folder-derived name as the project
identifier leaked the Claude CLI's on-disk encoding into every API route,
forced every downstream endpoint to re-resolve a real path via JSONL
'cwd' inspection, and made the project list endpoint O(projects x sessions)
on disk I/O.

This change switches the entire API surface to identify projects by the
stable primary key from the 'projects' table and drives the listing
straight from the DB:

- Add projectsDb.getProjectPathById as the canonical projectId -> path
  resolver so routes no longer need to touch the filesystem to figure out
  where a project lives.

- Rewrite getProjects so it reads the project list from the 'projects'
  table and the per-project session list from the 'sessions' table (one
  SELECT per project). No filesystem scanning happens for this endpoint
  anymore, which removes the dependency on ~/.claude/projects existing,
  on Cursor's MD5-hashed chat folders being discoverable, and on Codex's
  JSONL history being on disk. Per the migration spec each session now
  exposes 'summary' sourced from sessions.custom_name, 'messageCount' = 0
  (message counting is not implemented), and sessionMeta.hasMore is
  pinned to false since this endpoint doesn't drive session pagination.

- Introduce id-based wrappers (getSessionsById, renameProjectById,
  deleteSessionById, deleteProjectById, getProjectTaskMasterById) so
  every caller can pass projectId and resolve the real path through the
  DB. renameProjectById also writes to projects.custom_project_name so
  the DB-driven getProjects response reflects renames immediately; it
  keeps project-config.json in sync for any legacy reader that still
  consults the JSON file.

- Migrate every /api/projects/:projectName route in server/index.js,
  server/routes/taskmaster.js, and server/routes/messages.js to
  :projectId, and change server/routes/git.js so the 'project'
  query/body parameter carries a projectId that is resolved through the
  DB before any git command runs. TaskMaster WebSocket broadcasts emit
  'projectId' for the same reason so the frontend can match
  notifications against its current selection without another lookup.

- Delete helpers that existed only to feed the old getProjects path
  (getCursorSessions, getGeminiCliSessions, getProjectTaskMaster) along
  with their unused imports (better-sqlite3's Database,
  applyCustomSessionNames). The legacy folder-name helpers (getSessions,
  renameProject, deleteSession, deleteProject, extractProjectDirectory)
  are kept as internal implementation details of the id-based wrappers
  and of destructive cleanup / conversation search, but they are no
  longer re-exported.

- searchConversations still walks JSONL to produce match snippets (that
  data doesn't live in the DB), but it now includes the resolved
  projectId in each result so the sidebar can cross-reference hits with
  its already loaded project list without a second round-trip.

Frontend migration:

- Project.name is replaced by Project.projectId in src/types/app.ts, and
  ProjectSession.__projectName becomes __projectId so session tagging
  and sidebar state keys stay aligned with the backend identifier.
  Settings continues to use SettingsProject.name for legacy consumers,
  but it is populated from projectId by normalizeProjectForSettings.

- All places that previously indexed per-project state by project.name
  (sidebar expanded/starred/loading/deletingProjects sets,
  additionalSessions map, projectHasMoreOverrides, starredProjects
  localStorage, command history and draft-input localStorage,
  TaskMaster caches) now key on projectId so state survives
  display-name edits and is consistent across the app.

- src/utils/api.js renames every endpoint parameter to projectId, the
  unified messages endpoint takes projectId in its query string, and
  useSessionStore forwards projectId on fetchFromServer / fetchMore /
  refreshFromServer. Git panel, file tree, code editor, PRD editor,
  plugins context, MCP server flows and TaskMaster hooks are all
  updated to pass projectId.

- DEFAULT_PROJECT_FOR_EMPTY_SHELL is updated to carry a 'default'
  projectId sentinel so the empty-shell placeholder still satisfies the
  Project contract.

Bug fix bundled in:

- sessionsDb.setName no longer bumps updated_at when a row already
  exists. Renaming is a label change, not activity, so there is no
  reason for it to reset 'last activity' in the sidebar. It also no
  longer relies on SQLite's CURRENT_TIMESTAMP, which stores a naive
  'YYYY-MM-DD HH:MM:SS' value that JavaScript parses as local time and
  caused renamed sessions to appear shifted backwards by the client's
  UTC offset. When an INSERT actually happens it now writes ISO-8601
  UTC with a 'Z' suffix.

- buildSessionsByProviderFromDb normalizes any legacy naive timestamps
  in the sessions table to ISO-8601 UTC on the way out so rows written
  before this change also render correctly on the client.

Other cleanup:

- Removed the filesystem-first project-discovery comment block at the
  top of server/projects.js and replaced it with a short note that
  describes the new DB-driven flow and lists the few remaining
  filesystem-dependent helpers (message reads, search, destructive
  delete, manual project registration).

- server/modules/providers/index.ts is added as a small barrel so the
  providers module exposes a stable public surface.

Made-with: Cursor

* refactor(projects): reorganize project-related logic into dedicated modules

* refactor(projects): rename getProjects with getProjectsWithSessions

* refactor: update import path for getProjectsWithSessions to include file extension

* refactor: use updated session watcher
In addition, for projects_updated websocket response, send the sessionId instead

* refactor(websocket): move websocket logic to its own module

* refactor(sessions-watcher): remove redundant logging after session sync completion

* refactor(index.js): reorganize code structure

* refactor(index.js): fix import order

* refactor: remove unnecessary GitHub cloning logic from create-workspace endpoint

* refactor: modularize project services, and wizard create/clone flow

Restructure project creation, listing, GitHub clone progress, and TaskMaster
details behind a dedicated TypeScript module under server/modules/projects/,
and align the client wizard with a single path-based flow.

Server / routing
- Remove server/routes/projects.js and mount server/modules/projects/
  projects.routes.ts at /api/projects (still behind authenticateToken).
- Drop duplicate handlers from server/index.js for GET /api/projects and
  GET /api/projects/:projectId/taskmaster; those live on the new router.
- Import WORKSPACES_ROOT and validateWorkspacePath from shared utils in
  index.js instead of the deleted projects route module.

Projects router (projects.routes.ts)
- GET /: list projects with sessions (existing snapshot behavior).
- POST /create-project: validate body, reject legacy workspaceType and
  mixed clone fields, delegate to createProject service, return distinct
  success copy when an archived path is reactivated.
- GET /clone-progress: Server-Sent Events for clone progress/complete/error;
  requires authenticated user id for token resolution; wires startCloneProject.
- GET /:projectId/taskmaster: delegates to getProjectTaskMaster.

Services (new)
- project-management.service.ts: path validation, workspace directory
  creation, persistence via projectsDb.createProjectPath, mapping to API
  project shape; surfaces AppError for validation, conflict, and not-found
  cases; optional dependency injection for tests.
- project-clone.service.ts: validates workspace, resolves GitHub auth
  (stored token or inline token), runs git clone with progress callbacks,
  registers project via createProject on success; sanitizes errors and
  supports cancellation; injectable dependencies for tests.
- projects-has-taskmaster.service.ts: moves TaskMaster detection and
  normalization out of server/projects.js; resolve-by-id and public
  getProjectTaskMaster with structured AppError responses.

Persistence and shared types
- projectsDb.createProjectPath now returns CreateProjectPathResult
  (created | reactivated_archived | active_conflict) using INSERT … ON
  CONFLICT with selective update when the row is archived; normalizes
  display name from path or custom name; repository row typing moves to
  shared ProjectRepositoryRow.
- getProjectPaths() returns only non-archived rows (isArchived = 0).
- shared/types.ts: ProjectRepositoryRow, CreateProjectPathResult/outcome,
  WorkspacePathValidationResult.
- shared/utils.ts: WORKSPACES_ROOT, forbidden path lists, validateWorkspacePath,
  asyncHandler for Express async routes.

Legacy cleanup
- server/projects.js: remove detectTaskMasterFolder, normalizeTaskMasterInfo,
  and getProjectTaskMasterById (logic lives in the new service).
- server/routes/agent.js: register external API project paths with
  projectsDb.createProjectPath instead of addProjectManually try/catch;
  treat active_conflict as an existing registration and continue.

Tests
- Add Node test suites for project-management, project-clone, and
  projects-has-taskmaster services; update projects.service test import
  for renamed projects-with-sessions-fetch.service.ts.

Rename
- projects.service.ts → projects-with-sessions-fetch.service.ts;
  re-export from modules/projects/index.ts.

Client (project creation wizard)
- Remove StepTypeSelection and workspaceType from form state and types;
  wizard is two steps (configure path/GitHub auth, then review).
- createWorkspaceRequest → createProjectRequest; clone vs create-only
  inferred from githubUrl (pathUtils / isCloneWorkflow).
- Adjust step indices, WizardProgress, StepConfiguration/Review,
  WorkspacePathField, and src/utils/api.js as needed for the new API.

Docs
- Minor websocket README touch-up.

Net: ~1.6k insertions / ~0.9k deletions across 29 files; behavior is
centralized in typed services with explicit HTTP errors and test seams.

* refactor: remove loading sessions logic from sidebar

* refactor: move project rename to module

* refactor: move project deletion to module

* refactor: move project star state from localStorage to backend

* refactor: implement optimistic UI for project star state management

* feat: optimistic update for session watcher

* fix(projects-state): stop websocket message reprocessing loop

The websocket projects effect in useProjectsState could re-handle the same
latestMessage after local state writes triggered re-renders.

Under bursty websocket traffic, this created an update feedback cycle
that surfaced as 'Maximum update depth exceeded', often from Sidebar.

What changed:
- Added lastHandledMessageRef so each latestMessage object is handled once.
- Added an early return guard when the current message was already handled.
- Made projects updates idempotent by comparing previous and merged payloads
  before calling setProjects.

Result:
- Breaks the effect -> state update -> effect re-entry cycle.
- Reduces redundant renders during rapid projects_updated traffic while
  preserving normal project/session synchronization.

* refactor: optimize project auto-expand logic

* refactor: move projects provider specific logic into respective session providers

* refactor: move rename and delete sessions to modules

* refactor: move fetching messages to module

* fix: remove unused var

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* Potential fix for pull request finding 'Useless assignment to local variable'

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* refactor(projects/sidebar): remove temp snapshot side-effects and simplify session metadata UX

Why this change was needed:
- Project listing had an implicit side effect: every fetch wrote a debug snapshot under `.tmp/project-dumps`.
  That added unnecessary disk I/O to a hot path, introduced hidden runtime behavior, and created maintenance
  overhead for code that was not part of product functionality.
- Keeping snapshot-specific exports/tests around made the projects module API broader than needed and coupled
  tests to temporary/debug behavior instead of user-visible behavior.
- Codex sessions could remain stuck with a placeholder name (`Untitled Codex Session`) even after a real title
  became available from newer sync data, which degraded session discoverability in the UI.
- Sidebar session rows showed duplicated provider branding and long-form relative times, which added visual noise
  and reduced scan speed when many sessions are listed.

What changed:
- Removed temporary projects snapshot dumping from `projects-with-sessions-fetch.service.ts`:
  - deleted snapshot types/helpers and file-write flow
  - removed the write call from `getProjectsWithSessions`
- Removed snapshot-related surface area from `projects/index.ts`.
- Removed the snapshot-focused test `projects.service.test.ts` that only validated removed debug behavior.
- Updated `codex-session-synchronizer.provider.ts` to upgrade session names when an existing session still has
  the placeholder title but a real parsed name is now available.
- Updated `SidebarSessionItem.tsx`:
  - removed duplicate provider logo rendering in each session row
  - moved age indicator to the right side
  - made age indicator fade on hover to prioritize action controls
  - switched to compact relative time format (`<1m`, `Xm`, `Xhr`, `Xd`) for faster list scanning

Outcome:
- Lower overhead and fewer hidden side effects in project fetches.
- Cleaner module boundaries in projects.
- Better Codex session naming consistency after sync.
- Cleaner sidebar density and clearer hover/action behavior.

* refactor: implement pagination for project sessions loading

* refactor: move search to module

* fix: search performance

* refactor: add handling for internal Codex metadata in conversation search

* fix(migrations,projects,clone): normalize legacy schema before writes and harden conflict detection

Why

- Legacy installs can have a sessions table shape that predates provider/custom_name columns. Running migrateLegacySessionNames first caused its INSERT OR REPLACE INTO sessions (...) to target columns that may not exist and fail during startup migration.

- Some upgraded databases had projects.project_id as plain TEXT instead of a real PRIMARY KEY. That breaks assumptions used by id-based lookups and can allow invalid/duplicate identity semantics over time.

- projectsDb.createProjectPath inferred outcomes from 
ow.isArchived, but the upsert path always returns the post-update row with isArchived=0, so archived-reactivation and fresh-create could be misclassified.

- git clone accepted user-controlled URLs directly in argv position, so inputs beginning with - could be interpreted as options instead of a repository argument.

What

- Added 
ebuildProjectsTableWithPrimaryKeySchema in migrations: detect table shape via getTableInfo('projects'), verify project_id has pk=1, and rebuild when missing.

- Rebuild flow now creates a canonical projects__new table (project_id TEXT PRIMARY KEY), copies rows with transformation, backfills empty ids via SQLITE_UUID_SQL, deduplicates conflicting ids/paths, then swaps tables inside a transaction.

- Replaced the prior ddColumnToTableIfNotExists(...) + UPDATE project_id sequence with PK-aware detection/rebuild logic so legacy DBs converge to the required schema.

- Reordered migration sequence to run 
ebuildSessionsTableWithProjectSchema before migrateLegacySessionNames, ensuring sessions is normalized before legacy session_names merge writes execute.

- Updated projectsDb.createProjectPath to generate an ttemptedId before insert, pass it into the prepared statement, and classify outcomes by comparing returned 
ow.project_id to ttemptedId (created vs 
eactivated_archived), with no-row remaining ctive_conflict.

- Hardened clone execution by inserting -- before clone URL in git argv and rejecting normalized GitHub URLs that start with - in startCloneProject.

Tests

- Added integration coverage for projectsDb.createProjectPath branches: fresh insert, archived reactivation, and active conflict.

- Added clone service test for option-prefixed githubUrl rejection (INVALID_GITHUB_URL).

* refactor(session-synchronizer): update last scanned timestamp based on synchronization results

* refactor: improve session limit and offset validation in provider routes

* refactor: normalize project paths across database and service modules

* refactor(database): make session id the primary key in sessions table

* fix(codex): preserve reasoning entries as thinking blocks

Codex history normalization was downgrading reasoning into plain assistant text
because of branch ordering, not because the raw data was missing.

Why this mattered:
- Codex reasoning JSONL entries are intentionally mapped to history items with
  type thinking, but they also carry message.role assistant.
- normalizeHistoryEntry evaluated the assistant-role branch before the
  thinking branch.
- As a result, reasoning content matched the assistant-text path first and was
  emitted as kind text instead of kind thinking.
- This collapses semantic intent, so UI and downstream features that rely on
  thinking blocks (separate rendering, filtering, and interpretation of model
  thought process vs final answer) receive the wrong message kind.

What changed:
- Prioritized thinking detection (raw.type === thinking or raw.isReasoning)
  before role-based assistant normalization.
- Kept a non-empty content guard for thinking payloads to avoid emitting empty
  artifacts.

Impact:
- Reasoning entries from persisted Codex JSONL now remain thinking blocks
  end-to-end.
- Regular assistant text normalization behavior remains unchanged.

* refactor: remove dead code

* refactor: directly use getProjectPathById from projectsDb

* refactor: add gemini jsonl session support
2026-04-30 08:19:26 +02:00
viper151
f6200e3e95 chore(release): v1.30.0 2026-04-21 16:43:54 +00:00
simosmik
fa5a23897c chore: add docker sandbox action 2026-04-21 16:41:40 +00:00
Simos Mikelatos
c5e55adc89 feat: introduce opus 4.7 (#682)
* feat: introduce opus 4.7
- Bump claude-agent-sdk from 0.2.59 to 0.2.116
- Forward process.env to SDK subprocess so ANTHROPIC_BASE_URL and other env vars work
- Add claude-opus-4-6 as a distinct mode

* feat: add "claude" as fallback in the cli path

* fix: base url config
2026-04-21 18:26:25 +02:00
Menny Even Danan
09dd407648 fix missing curly (#683) 2026-04-21 16:45:29 +02:00
Mahsum Aktaş
89b754d186 feat(i18n): add Turkish (tr) language support (#678)
* feat(i18n): add Turkish (tr) language support

Add comprehensive Turkish localization for the UI, following the
existing i18n pattern established by Japanese (#384), Russian (#514),
and German (#525) language support.

Changes:
- Add Turkish translation files for all 7 namespaces
  (auth, chat, codeEditor, common, settings, sidebar, tasks)
- Register Turkish locale in config.js with all resources
- Add Turkish entry to languages.js (value: tr, nativeName: Türkçe)
- Update .gitignore to allow src/i18n/locales/tr/tasks.json
  (matches existing en/ja/ru/de exceptions)

Translation details:
- 934 total strings translated (100% coverage, matches en.json key count)
- Translated by a native Turkish speaker with software engineering
  background; terminology reviewed against conventional Turkish
  tech community usage.
- Technical terms kept in English per Turkish dev community norms:
  Claude, Cursor, Codex, Gemini, CLI, MCP, PRD, JSON, YAML, stdio,
  http, commit, branch, token, prompt, minimap, sandbox, YOLO.
- Informal second-person singular (\"sen\") used throughout — fits the
  developer-facing nature of the UI.
- All interpolation placeholders preserved exactly (e.g. {{count}},
  {{projectName}}, {{email}}).
- i18next plural keys (_one/_other) kept intact.

Verification:
- Key structure parity with en.json confirmed (jq paths diff empty)
- All 38 unique interpolation variables preserved
- npm run build passes cleanly

* docs(readme): add Turkish README and language switcher links

Add README.tr.md — full Turkish translation of the main README,
following the structure of existing README.de.md / README.ja.md /
README.ko.md / README.ru.md / README.zh-CN.md.

Update the language switcher row in all 6 existing README variants
to include a Turkish link (matches the pattern used by #534 for the
German language link addition).

---------

Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-04-21 14:42:37 +02:00
Pereira Ricardo
86b6545c35 feat(i18n): add Italian language support (#677)
Add complete Italian (it) translations for all 7 namespaces:
common, auth, settings, sidebar, chat, codeEditor, and tasks.

Register Italian in languages.js and i18n config.
Add .gitignore exception for it/tasks.json.

Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-04-21 14:40:05 +02:00
Haile
49dd3cfb23 Refactor provider runtimes for sessions, auth, and MCP management (#666)
* feat: implement MCP provider registry and service

- Add provider registry to manage LLM providers (Claude, Codex, Cursor, Gemini).
- Create provider routes for MCP server operations (list, upsert, delete, run).
- Implement MCP service for handling server operations and validations.
- Introduce abstract provider class and MCP provider base for shared functionality.
- Add tests for MCP server operations across different providers and scopes.
- Define shared interfaces and types for MCP functionality.
- Implement utility functions for handling JSON config files and API responses.

* chore: remove dead code related to MCP server

* refactor: put /api/providers in index.js and remove /providers prefix from provider.routes.ts

* refactor(settings): move MCP server management into provider module

Extract MCP server settings out of the settings controller and agents tab into a
dedicated frontend MCP module. The settings UI now delegates MCP rendering and
behavior to a single module that only needs the selected provider and current
projects.

Changes:
- Add `src/components/mcp` as the single frontend MCP module
- Move MCP server list rendering into `McpServers`
- Move MCP add/edit modal into `McpServerFormModal`
- Move MCP API/state logic into `useMcpServers`
- Move MCP form state/validation logic into `useMcpServerForm`
- Add provider-specific MCP constants, types, and formatting helpers
- Use the unified `/api/providers/:provider/mcp/servers` API for all providers
- Support MCP management for Claude, Cursor, Codex, and Gemini
- Remove old settings-owned Claude/Codex MCP modal components
- Remove old provider-specific `McpServersContent` branching from settings
- Strip MCP server state, fetch, save, delete, and modal ownership from
  `useSettingsController`
- Simplify agents settings props so MCP only receives `selectedProvider` and
  `currentProjects`
- Keep Claude working-directory unsupported while preserving cwd support for
  Cursor, Codex, and Gemini
- Add progressive MCP loading:
  - render user/global scope first
  - load project/local scopes in the background
  - append project results as they resolve
  - cache MCP lists briefly to avoid slow tab-switch refetches
  - ignore stale async responses after provider switches

Verification:
- `npx eslint src/components/mcp`
- `npm run typecheck`
- `npm run build:client`

* fix(mcp): form with multiline text handling for args, env, headers, and envVars

* feat(mcp): add global MCP server creation flow

Add a separate global MCP add path in the settings MCP module so users can create
one shared MCP server configuration across Claude, Cursor, Codex, and Gemini from
the same screen.

The provider-specific add flow is still kept next to it because these two actions
have different intent. A global MCP server must be constrained to the subset of
configuration that every provider can accept, while a provider-specific server can
still use that provider's own supported scopes, transports, and fields. Naming the
buttons as "Add Global MCP Server" and "Add <Provider> MCP Server" makes that
distinction explicit without forcing users to infer it from the selected tab.

This also moves the explanatory copy to button hover text to keep the MCP toolbar
compact while still documenting the difference between global and provider-only
adds at the point of action.

Implementation details:
- Add global MCP form mode with shared user/project scopes and stdio/http transports.
- Submit global creates through `/api/providers/mcp/servers/global`.
- Reuse the existing MCP form modal with configurable scopes, transports, labels,
  and descriptions instead of duplicating form logic.
- Disable provider-only fields for the global flow because those fields cannot be
  safely written to every provider.
- Clear the MCP server cache globally after a global add because every provider tab
  may have changed.
- Surface partial global add failures with provider-specific error messages.

Validation:
- npx eslint src/components/mcp/view/McpServers.tsx
- npm run typecheck
- npm run build:client

* feat: implement platform-specific provider visibility for cursor agent

* refactor(providers): centralize message handling in provider module

Move provider-specific normalizeMessage and fetchHistory logic out of the legacy
server/providers adapters and into the refactored provider classes so callers can
depend on the main provider contract instead of parallel adapter plumbing.

Add a providers service to resolve concrete providers through the registry and
delegate message normalization/history loading from realtime handlers and the
unified messages route. Add shared TypeScript message/history types and normalized
message helpers so provider implementations and callers use the same contract.

Remove the old adapter registry/files now that Claude, Codex, Cursor, and Gemini
implement the required behavior directly.

* refactor(providers): move auth status checks into provider runtimes

Move provider authentication status logic out of the CLI auth route so auth checks
live with the provider implementations that understand each provider's install
and credential model.

Add provider-specific auth runtime classes for Claude, Codex, Cursor, and Gemini,
and expose them through the shared provider contract as `provider.auth`. Add a
provider auth service that resolves providers through the registry and delegates
status checks via `auth.getStatus()`.

Keep the existing `/api/cli/<provider>/status` endpoints, but make them thin route
adapters over the new provider auth service. This removes duplicated route-local
credential parsing and makes auth status a first-class provider capability beside
MCP and message handling.

* refactor(providers): clarify provider auth and MCP naming

Rename provider auth/MCP contracts to remove the overloaded Runtime suffix so
the shared interfaces read as stable provider capabilities instead of execution
implementation details.

Add a consistent provider-first auth class naming convention by renaming
ClaudeAuthProvider, CodexAuthProvider, CursorAuthProvider, and GeminiAuthProvider
to ClaudeProviderAuth, CodexProviderAuth, CursorProviderAuth, and
GeminiProviderAuth.

This keeps the provider module API easier to scan and aligns auth naming with
the main provider ownership model.

* refactor(providers): move session message delegation into sessions service

Move provider-backed session history and message normalization calls out of the
generic providers service so the service name reflects the behavior it owns.

Add a dedicated sessions service for listing session-capable providers,
normalizing live provider events, and fetching persisted session history through
the provider registry. Update realtime handlers and the unified messages route to
depend on `sessionsService` instead of `providersService`.

This separates session message operations from other provider concerns such as
auth and MCP, keeping the provider services easier to navigate as the module
grows.

* refactor(providers): move auth status routes under provider API

Move provider authentication status endpoints out of the legacy `/api/cli` route
namespace so auth status is exposed through the same provider module that owns
provider auth and MCP behavior.

Add `GET /api/providers/:provider/auth/status` to the provider router and route
it through the provider auth service. Remove the old `cli-auth` route file and
`/api/cli` mount now that provider auth status is handled by the unified provider
API.

Update the frontend provider auth endpoint map to call the new provider-scoped
routes and rename the endpoint constant to reflect that it is no longer CLI
specific.

* chore(api): remove unused backend endpoints after MCP audit

Remove legacy backend routes that no longer have frontend or internal
callers, including the old Claude/Codex MCP APIs, unused Cursor and Codex
helper endpoints, stale TaskMaster detection/next/initialize routes,
and unused command/project helpers.

This reduces duplicated MCP behavior now handled by the provider-based
MCP API, shrinks the exposed backend surface, and removes probe/service
code that only existed for deleted endpoints.

Add an MCP settings API audit document to capture the route-usage
analysis and explain why the legacy MCP endpoints were considered safe
to remove.

* refactor(providers): remove debug logging from Claude authentication status checks

* refactor(cursor): lazy-load better-sqlite3 and remove unused type definitions

* refactor(cursor): remove SSE from CursorMcpProvider constructor and error message

* refactor(auth): standardize API response structure and remove unused error handling

* refactor: make providers use dedicated session handling classes

* refactor: remove legacy provider selection UI and logic

* fix(server/providers): harden and correct session history normalization/pagination

Address correctness and safety issues in provider session adapters while
preserving existing normalized message shapes.

Claude sessions:
- Ensure user text content parts generate unique normalized message ids.
- Replace duplicate `${baseId}_text` ids with index-suffixed ids to avoid
  collisions when one user message contains multiple text segments.

Cursor sessions:
- Add session id sanitization before constructing SQLite paths to prevent
  path traversal via crafted session ids.
- Enforce containment by resolving the computed DB path and asserting it stays
  under ~/.cursor/chats/<cwdId>.
- Refactor blob parsing to a two-pass flow: first build blobMap and collect
  JSON blobs, then parse binary parent refs against the fully populated map.
- Fix pagination semantics so limit=0 returns an empty page instead of full
  history, with consistent total/hasMore/offset/limit metadata.

Gemini sessions:
- Honor FetchHistoryOptions pagination by reading limit/offset and slicing
  normalized history accordingly.
- Return consistent hasMore/offset/limit metadata for paged responses.

Validation:
- eslint passed for touched files.
- server TypeScript check passed (tsc --noEmit -p server/tsconfig.json).

---------
2026-04-21 14:38:51 +02:00
simosmik
457ca0daab fix: reduce size of permission mode button tap target and provider selector on mobile 2026-04-21 12:32:09 +00:00
simosmik
09dcea05fb fix: precise Claude SDK denial message detection in deriveToolStatus 2026-04-20 15:52:09 +00:00
simosmik
3969135bd4 fix: iOS scrolling main chat area 2026-04-20 15:47:19 +00:00
simosmik
25820ed995 fix: small mobile respnosive fixes 2026-04-20 15:41:37 +00:00
simosmik
fc3504eaed fix: migrate PlanDisplay raw params from native details to Collapsible primitive 2026-04-20 15:36:02 +00:00
simosmik
ec0ff974cb refactor: queue primitive, tool status badges, and tool display cleanup
- Add Queue/QueueItem/QueueItemIndicator/QueueItemContent primitive
- Rewrite TodoList using Queue (clean list, no bordered cards, no priority badges)
- Add ToolStatusBadge component (Running/Completed/Error/Denied)
- Migrate CollapsibleSection from native <details> to Collapsible primitive
- Add badge prop threading through CollapsibleDisplay and CollapsibleSection
- Add status badges to OneLineDisplay and CollapsibleDisplay via ToolRenderer
- Update SubagentContainer: theme tokens + Collapsible for tool history
- Replace hardcoded gray-* colors with theme tokens throughout tool displays
2026-04-20 15:30:16 +00:00
simosmik
c471b5d3fa fix: small mobile respnosive fixes 2026-04-20 15:05:08 +00:00
simosmik
5758bee8a0 refactor: chat composer new design 2026-04-20 14:47:49 +00:00
simosmik
7763e60fb3 refactor: add primitives, plan mode display, and new session model selector 2026-04-20 12:47:55 +00:00
viper151
25b00b58de chore(release): v1.29.5 2026-04-16 11:02:31 +00:00
simosmik
6a13e1773b fix: update node-pty to latest version 2026-04-16 10:52:55 +00:00
viper151
6102b74455 chore(release): v1.29.4 2026-04-16 10:33:45 +00:00
Simos Mikelatos
9ef1ab533d Refactor CLI authentication module location (#660)
* refactor: move cli-auth.js to the providers folder

* fix: expired oauth token returns no error message
2026-04-16 12:32:25 +02:00
simosmik
e9c7a5041c feat: deleting from sidebar will now ask whether to remove all data as well 2026-04-16 09:05:56 +00:00
simosmik
289520814c refactor: remove the sqlite3 dependency 2026-04-16 08:37:59 +00:00
simosmik
09486016e6 chore: upgrade commit lint to 20.5.0 2026-04-16 08:08:36 +00:00
simosmik
4c106a5083 fix: pass pathToClaudeCodeExecutable to SDK when CLAUDE_CLI_PATH is set
Closes #468
2026-04-16 07:58:32 +00:00
Simos Mikelatos
63e996bb77 refactor(server): extract URL detection and color utils from index.js (#657)
No behavioral changes — 1:1 code move with imports replacing inline definitions.
2026-04-16 09:46:09 +02:00
viper151
fbad3a90f8 chore(release): v1.29.3 2026-04-15 12:02:27 +00:00
Haile
96463df8da Feature/backend ts support andunification of auth settings on frontend (#654)
* fix: remove project dependency from settings controller and onboarding

* fix(settings): remove onClose prop from useSettingsController args

* chore: tailwind classes order

* refactor: move provider auth status management to custom hook

* refactor: rename SessionProvider to LLMProvider

* feat(frontend): support for @ alias based imports)

* fix: replace init.sql with schema.js

* fix: refactor database initialization to use schema.js for SQL statements

* feat(server): add a real backend TypeScript build and enforce module boundaries

The backend had started to grow beyond what the frontend-only tooling setup could
support safely. We were still running server code directly from /server, linting
mainly the client, and relying on path assumptions such as "../.." that only
worked in the source layout. That created three problems:

- backend alias imports were hard to resolve consistently in the editor, ESLint,
  and the runtime
- server code had no enforced module boundary rules, so cross-module deep imports
  could bypass intended public entry points
- building the backend into a separate output directory would break repo-level
  lookups for package.json, .env, dist, and public assets because those paths
  were derived from source-only relative assumptions

This change makes the backend tooling explicit and runtime-safe.

A dedicated backend TypeScript config now lives in server/tsconfig.json, with
tsconfig.server.json reduced to a compatibility shim. This gives the language
service and backend tooling a canonical project rooted in /server while still
preserving top-level compatibility for any existing references. The backend alias
mapping now resolves relative to /server, which avoids colliding with the
frontend's "@/..." -> "src/*" mapping.

The package scripts were updated so development runs through tsx with the backend
tsconfig, build now produces a compiled backend in dist-server, and typecheck/lint
cover both client and server. A new build-server.mjs script runs TypeScript and
tsc-alias and cleans dist-server first, which prevents stale compiled files from
shadowing current source files after refactors.

To make the compiled backend behave the same as the source backend, runtime path
resolution was centralized in server/utils/runtime-paths.js. Instead of assuming
fixed relative paths from each module, server entry points now resolve the actual
app root and server root at runtime. That keeps package.json, .env, dist, public,
and default database paths stable whether code is executed from /server or from
/dist-server/server.

ESLint was expanded from a frontend-only setup into a backend-aware one. The
backend now uses import resolution tied to the backend tsconfig so aliased imports
resolve correctly in linting, import ordering matches the frontend style, and
unused/duplicate imports are surfaced consistently.

Most importantly, eslint-plugin-boundaries now enforces server module boundaries.
Files under server/modules can no longer import another module's internals
directly. Cross-module imports must go through that module's barrel file
(index.ts/index.js). boundaries/no-unknown was also enabled so alias-resolution
gaps cannot silently bypass the rule.

Together, these changes make the backend buildable, keep runtime path resolution
stable after compilation, align server tooling with the client where appropriate,
and enforce a stricter modular architecture for server code.

* fix: update package.json to include dist-server in files and remove tsconfig.server.json

* refactor: remove build-server.mjs and inline its logic into package.json scripts

* fix: update paths in package.json and bin.js to use dist-server directory

* feat(eslint): add backend shared types and enforce compile-time contract for imports

* fix(eslint): update shared types pattern

---------

Co-authored-by: Haileyesus <something@gmail.com>
2026-04-15 13:26:12 +02:00
simosmik
31f28a2c18 chore: remove unused route (migrated to providers already) 2026-04-14 21:58:53 +00:00
Simos Mikelatos
8ff5f35c05 Update model constants for Opus and Gemini versions 2026-04-14 23:06:43 +02:00
Haile
641304242d fix(version-upgrade-modal): implement reload countdown and update UI messages (#655)
Co-authored-by: Haileyesus <something@gmail.com>
2026-04-14 23:02:20 +02:00
viper151
c3599cd2c4 chore(release): v1.29.2 2026-04-14 18:16:20 +00:00
simosmik
9b11c034d9 fix(sandbox): use backgrounded sbx run to keep sandbox alive 2026-04-14 18:14:58 +00:00
viper151
b6d19201b6 chore(release): v1.29.1 2026-04-14 17:38:53 +00:00
simosmik
4a569725da fix: add latest tag to docker npx command and change the detach mode to work without spawn 2026-04-14 17:37:20 +00:00
viper151
6ce3306947 chore(release): v1.29.0 2026-04-14 15:20:18 +00:00
Haile
d0dd007d0f Feature/restart server on update (#652)
* feat: support restart server on update for platform

* feat: add update platform script to package.json

* feat: optimize platform update command by omitting dev dependencies

* feat: simplify update commands for platform

---------

Co-authored-by: Haileyesus <something@gmail.com>
Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
2026-04-14 17:18:47 +02:00
simosmik
13e97e2c71 feat: adding docker sandbox environments 2026-04-14 15:18:02 +00:00
Haile
c7a5baf147 fix(thinking-mode): fix dropdown positioning (#646) 2026-04-13 11:44:31 +02:00
simosmik
e2459cb0f8 chore: update release flow node version 2026-04-10 14:56:33 +00:00
513 changed files with 71384 additions and 17738 deletions

View File

@@ -0,0 +1,109 @@
name: Desktop macOS Branch Build
on:
workflow_dispatch:
push:
branches:
- electron-app
jobs:
build-macos:
name: Build macOS desktop artifact
runs-on: macos-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Typecheck
run: npm run typecheck
- name: Resolve artifact metadata
id: artifact
run: |
SAFE_REF="$(printf '%s' "${GITHUB_REF_NAME}" | tr -c 'A-Za-z0-9._-' '-')"
echo "name=CloudCLI-macOS-${SAFE_REF}-${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT"
echo "server_bundle_tag=cloudcli-local-server-${SAFE_REF}" >> "$GITHUB_OUTPUT"
- name: Configure branch server bundle source
run: printf '{"releaseTag":"%s"}\n' "${{ steps.artifact.outputs.server_bundle_tag }}" > electron/server-bundle-config.json
- name: Verify signing secrets are configured
run: |
test -n "$CSC_LINK"
test -n "$CSC_KEY_PASSWORD"
test -n "$APPLE_ID"
test -n "$APPLE_APP_SPECIFIC_PASSWORD"
test -n "$APPLE_TEAM_ID"
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Build signed and notarized macOS artifacts
run: npm run desktop:dist:mac -- --publish never
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Build branch server bundle
run: node scripts/release/build-server-bundle.js
- name: Verify branch server runtime artifacts
run: |
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
- name: Publish branch server bundle
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ steps.artifact.outputs.server_bundle_tag }}
name: CloudCLI Desktop Local Runtime (${{ github.ref_name }})
body: |
This prerelease is used by CloudCLI Desktop branch builds to run Local mode.
To test this branch, download the desktop app from this workflow run's artifacts. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
You do not need to download these runtime files manually.
prerelease: true
fail_on_unmatched_files: false
overwrite_files: true
files: |
release/local-server/*
- name: Verify macOS artifacts
run: |
test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)"
shasum -a 256 release/desktop/*.dmg > release/SHASUMS256.txt
cat release/SHASUMS256.txt
- name: Upload branch build artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: ${{ steps.artifact.outputs.name }}
path: |
release/desktop/*.dmg
release/SHASUMS256.txt
if-no-files-found: error
retention-days: 14

View File

@@ -0,0 +1,151 @@
name: Desktop macOS Release
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to create or update (defaults to v<package version>)'
required: false
type: string
release_name:
description: 'Release name (defaults to "CloudCLI Desktop macOS <tag>")'
required: false
type: string
prerelease:
description: 'Mark the GitHub release as a prerelease'
required: true
default: false
type: boolean
jobs:
build-macos:
name: Build signed macOS desktop app
runs-on: macos-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Resolve release metadata
id: release
env:
TAG_INPUT: ${{ inputs.tag }}
RELEASE_NAME_INPUT: ${{ inputs.release_name }}
run: |
VERSION="$(node -p "require('./package.json').version")"
TAG="$TAG_INPUT"
if [ -z "$TAG" ]; then
TAG="v${VERSION}"
fi
TAG="$(printf '%s' "$TAG" | tr -d '\r\n' | sed 's/[^A-Za-z0-9._-]/-/g')"
if [ -z "$TAG" ]; then
echo "Resolved release tag is empty after normalization." >&2
exit 1
fi
RELEASE_NAME="$RELEASE_NAME_INPUT"
if [ -z "$RELEASE_NAME" ]; then
RELEASE_NAME="CloudCLI Desktop macOS ${TAG}"
fi
RELEASE_NAME_DELIMITER="release_name_$(uuidgen)"
{
echo "tag=$TAG"
echo "release_name<<$RELEASE_NAME_DELIMITER"
printf '%s\n' "$RELEASE_NAME"
echo "$RELEASE_NAME_DELIMITER"
echo "server_bundle_tag=cloudcli-local-server-${TAG}"
} >> "$GITHUB_OUTPUT"
- name: Configure release server bundle source
env:
SERVER_BUNDLE_TAG: ${{ steps.release.outputs.server_bundle_tag }}
run: printf '{"releaseTag":"%s"}\n' "$SERVER_BUNDLE_TAG" > electron/server-bundle-config.json
- name: Verify signing secrets are configured
run: |
test -n "$CSC_LINK"
test -n "$CSC_KEY_PASSWORD"
test -n "$APPLE_ID"
test -n "$APPLE_APP_SPECIFIC_PASSWORD"
test -n "$APPLE_TEAM_ID"
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Build signed and notarized macOS artifacts
run: npm run desktop:dist:mac -- --publish never
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Build local server bundle
run: node scripts/release/build-server-bundle.js
- name: Verify local server runtime artifacts
run: |
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
- name: Publish local server runtime assets
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ steps.release.outputs.server_bundle_tag }}
target_commitish: ${{ github.sha }}
name: CloudCLI Local Server Runtime (${{ steps.release.outputs.tag }})
body: |
This prerelease contains the Local mode runtime for CloudCLI Desktop.
Download CloudCLI Desktop from the main ${{ steps.release.outputs.tag }} release. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
You do not need to download these runtime files manually.
prerelease: true
fail_on_unmatched_files: false
overwrite_files: true
files: |
release/local-server/*
- name: Verify macOS artifacts
run: |
test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)"
shasum -a 256 release/desktop/*.dmg > release/SHASUMS256.txt
cat release/SHASUMS256.txt
- name: Publish GitHub release assets
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ steps.release.outputs.tag }}
target_commitish: ${{ github.sha }}
name: ${{ steps.release.outputs.release_name }}
body: |
Download the CloudCLI Desktop installer for your Mac.
The local server runtime used by local mode is installed automatically by the desktop app. You do not need to download any server bundle manually.
prerelease: ${{ inputs.prerelease }}
fail_on_unmatched_files: false
files: |
release/desktop/*.dmg
release/SHASUMS256.txt

View File

@@ -0,0 +1,95 @@
name: Desktop Windows Branch Build
on:
workflow_dispatch:
push:
branches:
- electron-app
jobs:
build-windows:
name: Build unsigned Windows desktop artifact
runs-on: windows-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Typecheck
run: npm run typecheck
- name: Resolve artifact metadata
id: artifact
shell: bash
run: |
SAFE_REF="$(printf '%s' "${GITHUB_REF_NAME}" | tr -c 'A-Za-z0-9._-' '-')"
echo "name=CloudCLI-windows-${SAFE_REF}-${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT"
echo "server_bundle_tag=cloudcli-local-server-${SAFE_REF}" >> "$GITHUB_OUTPUT"
- name: Configure branch server bundle source
shell: bash
run: printf '{"releaseTag":"%s"}\n' "${{ steps.artifact.outputs.server_bundle_tag }}" > electron/server-bundle-config.json
- name: Build unsigned Windows artifacts
run: npm run desktop:dist:win -- --publish never
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Build branch server bundle
run: node scripts/release/build-server-bundle.js
- name: Verify branch server runtime artifacts
shell: bash
run: |
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
- name: Publish branch server bundle
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ steps.artifact.outputs.server_bundle_tag }}
name: CloudCLI Desktop Local Runtime (${{ github.ref_name }})
body: |
This prerelease is used by CloudCLI Desktop branch builds to run Local mode.
To test this branch, download the desktop app from this workflow run's artifacts. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
You do not need to download these runtime files manually.
prerelease: true
fail_on_unmatched_files: false
overwrite_files: true
files: |
release/local-server/*
- name: Verify Windows artifacts
shell: bash
run: |
test -n "$(find release/desktop -maxdepth 1 -name '*.exe' -print -quit)"
sha256sum release/desktop/*.exe > release/SHASUMS256.txt
cat release/SHASUMS256.txt
- name: Upload branch build artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: ${{ steps.artifact.outputs.name }}
path: |
release/desktop/*.exe
release/SHASUMS256.txt
if-no-files-found: error
retention-days: 14

51
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: Docker
on:
workflow_dispatch:
inputs:
extra_tag:
description: 'Additional tag to push alongside the template tag (e.g. v1.2.3, leave empty for none)'
required: false
type: string
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
template: [claude-code, codex, gemini]
steps:
- uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute tags
id: tags
run: |
TAGS="docker.io/cloudcliai/sandbox:${{ matrix.template }}"
if [ -n "${{ inputs.extra_tag }}" ]; then
TAGS="$TAGS,docker.io/cloudcliai/sandbox:${{ matrix.template }}-${{ inputs.extra_tag }}"
fi
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ./docker
file: ./docker/${{ matrix.template }}/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.tags.outputs.tags }}
cache-from: type=gha,scope=${{ matrix.template }}
cache-to: type=gha,mode=max,scope=${{ matrix.template }}

View File

@@ -13,21 +13,102 @@ on:
required: false
type: string
permissions:
contents: read
# This workflow publishes releases with write credentials, so actions are pinned
# to immutable commit SHAs. The trailing comments keep the original major tag
# visible for maintenance context.
jobs:
build-macos-semantic-helper:
strategy:
fail-fast: false
matrix:
include:
- runs_on: macos-15
target_dir: darwin-arm64
- runs_on: macos-15-intel
target_dir: darwin-x64
runs-on: ${{ matrix.runs_on }}
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
- name: Build macOS semantic helper
run: node scripts/build-computer-semantics.mjs
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
- name: Verify macOS semantic helper target
run: test -x "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics"
- name: Stage macOS semantic helper artifact
run: |
mkdir -p "semantic-helper-artifact/${{ matrix.target_dir }}"
cp "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics" "semantic-helper-artifact/${{ matrix.target_dir }}/"
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: semantic-helper-${{ matrix.target_dir }}
path: semantic-helper-artifact/*
if-no-files-found: error
build-windows-semantic-helper:
strategy:
fail-fast: false
matrix:
include:
- runs_on: windows-2025
target_dir: win32-x64
- runs_on: windows-11-arm
target_dir: win32-arm64
runs-on: ${{ matrix.runs_on }}
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
- name: Build Windows semantic helper
run: node scripts/build-computer-semantics.mjs
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
- name: Verify Windows semantic helper target
shell: bash
run: test -f "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics.exe"
- name: Stage Windows semantic helper artifact
shell: bash
run: |
mkdir -p "semantic-helper-artifact/${{ matrix.target_dir }}"
cp "server/modules/computer-use/semantics/bin/${{ matrix.target_dir }}/CloudCLISemantics.exe" "semantic-helper-artifact/${{ matrix.target_dir }}/"
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: semantic-helper-${{ matrix.target_dir }}
path: semantic-helper-artifact/*
if-no-files-found: error
release:
needs:
- build-macos-semantic-helper
- build-windows-semantic-helper
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_PAT }}
- uses: actions/setup-node@v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 20
node-version: 22
registry-url: https://registry.npmjs.org
- name: git config
@@ -37,6 +118,20 @@ jobs:
- run: npm ci
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
pattern: semantic-helper-*
path: server/modules/computer-use/semantics/bin
merge-multiple: true
- name: Verify bundled semantic helpers
run: |
test -x server/modules/computer-use/semantics/bin/darwin-arm64/CloudCLISemantics
test -x server/modules/computer-use/semantics/bin/darwin-x64/CloudCLISemantics
test -f server/modules/computer-use/semantics/bin/win32-x64/CloudCLISemantics.exe
test -f server/modules/computer-use/semantics/bin/win32-arm64/CloudCLISemantics.exe
find server/modules/computer-use/semantics/bin -maxdepth 2 -type f -print
- name: Release
run: |
ARGS="--ci --increment=${{ inputs.increment }}"

14
.gitignore vendored
View File

@@ -8,6 +8,7 @@ lerna-debug.log*
# Build outputs
dist/
dist-server/
dist-ssr/
build/
out/
@@ -133,9 +134,20 @@ tasks/
# Translations
!src/i18n/locales/en/tasks.json
!src/i18n/locales/fr/tasks.json
!src/i18n/locales/ja/tasks.json
!src/i18n/locales/ru/tasks.json
!src/i18n/locales/de/tasks.json
!src/i18n/locales/tr/tasks.json
!src/i18n/locales/it/tasks.json
# Git worktrees
.worktrees/
.worktrees/
# Local desktop packaging artifacts
/.desktop-build/
/release/
/electron/server-bundle-config.json
cloudcli-sidebar-app-source.tar.gz
cloudcli-sidebar.html
electron/*.tar.gz

View File

@@ -3,6 +3,214 @@
All notable changes to CloudCLI UI will be documented in this file.
## [](https://github.com/siteboon/claudecodeui/compare/v1.33.3...vnull) (2026-06-09)
### New Features
* adding Fable 5 in claude code ([ce327b6](https://github.com/siteboon/claudecodeui/commit/ce327b6fa9329aa3e9a3a1da7225ca01d3b06ac5))
## [1.33.3](https://github.com/siteboon/claudecodeui/compare/v1.33.2...v1.33.3) (2026-06-09)
### New Features
* add file tree upload progress ([c235b05](https://github.com/siteboon/claudecodeui/commit/c235b05e1d3b626667dba4043b685512e3cd3d5d))
* signal when chat runs complete ([d70dc07](https://github.com/siteboon/claudecodeui/commit/d70dc077bfbbfcf2ff4fa5514fabf7b4485861fa))
### Bug Fixes
* address notification review feedback ([602e6ad](https://github.com/siteboon/claudecodeui/commit/602e6ad4acba612a7ea66fb3bc7485054f5675ee))
* align prism plugin name and id with manifest.json ([ca8fd0e](https://github.com/siteboon/claudecodeui/commit/ca8fd0ee235b6a3210157bd0d9af83024d4a2248))
* **chat:** re-anchor initial scroll across lazy content reflow ([33a4e72](https://github.com/siteboon/claudecodeui/commit/33a4e72ca4f84df60aadfc4ff3f3467d6f5ae948))
* keep editor toolbar in view on long unwrapped lines ([beae8c6](https://github.com/siteboon/claudecodeui/commit/beae8c6513daa7518b9de40d8bfde3bf08e7bc87))
* **sandbox:** prevent server SIGHUP on sbx exec exit ([#792](https://github.com/siteboon/claudecodeui/issues/792)) ([f4a1614](https://github.com/siteboon/claudecodeui/commit/f4a1614a0a4ab4b65e8368d5e4221f015cb7555d)), closes [#791](https://github.com/siteboon/claudecodeui/issues/791)
* slash command suggestions trigger at any / in input, not only at start ([#843](https://github.com/siteboon/claudecodeui/issues/843)) ([f7c0024](https://github.com/siteboon/claudecodeui/commit/f7c0024fe15057ad049c71e15e88adb482a4497f))
* update naming convention ([3cd8995](https://github.com/siteboon/claudecodeui/commit/3cd89956ba06f0fc3e17d349b0c50baab4012658))
### Maintenance
* add prism plugin ([01dbe2a](https://github.com/siteboon/claudecodeui/commit/01dbe2a8bfcb3b265995f01f905b218d5f576f7b))
## [1.33.2](https://github.com/siteboon/claudecodeui/compare/v1.33.1...v1.33.2) (2026-06-08)
### New Features
* **chat:** open cost modal from token usage ([f238050](https://github.com/siteboon/claudecodeui/commit/f238050b85c3b99a702a8635059735e1a3b3a4f4))
* **i18n:** add Traditional Chinese (zh-TW) locale ([#773](https://github.com/siteboon/claudecodeui/issues/773)) ([c21a9f4](https://github.com/siteboon/claudecodeui/commit/c21a9f45610eb1eeb650d8e6cf8650e798f77f6f))
### Bug Fixes
* do not show model description in chat view ([d638a89](https://github.com/siteboon/claudecodeui/commit/d638a8982c7f75b08fc7f65f01d6d54989c790d1))
* include Claude cache tokens in usage ([ed9cdf0](https://github.com/siteboon/claudecodeui/commit/ed9cdf01145fa0d063580bb76d30cfa7ee67af86))
## [1.33.1](https://github.com/siteboon/claudecodeui/compare/v1.33.0...v1.33.1) (2026-06-05)
### New Features
* **chat:** auto-detect text direction for RTL languages ([#729](https://github.com/siteboon/claudecodeui/issues/729)) ([fa9eaf5](https://github.com/siteboon/claudecodeui/commit/fa9eaf5573a6f870a19fb62ab430ffd87c466582))
### Bug Fixes
* file tree concurrency ([#828](https://github.com/siteboon/claudecodeui/issues/828)) ([ebb0e59](https://github.com/siteboon/claudecodeui/commit/ebb0e59e8023c0a8040d168a5adffb7102e80561))
* load claude models directly from provider ([cdcac18](https://github.com/siteboon/claudecodeui/commit/cdcac182d458a24908777568979c8e756f94428c))
* plugin svg icon sanitization ([#817](https://github.com/siteboon/claudecodeui/issues/817)) ([d9e9df1](https://github.com/siteboon/claudecodeui/commit/d9e9df183f462c88c3b60975eb8254faa9168717))
* recognize claude auth token env ([#818](https://github.com/siteboon/claudecodeui/issues/818)) ([43c33d5](https://github.com/siteboon/claudecodeui/commit/43c33d5cb1b41835dfe3bccd450c5a9c2441509b))
* redact websocket auth token in logs ([#827](https://github.com/siteboon/claudecodeui/issues/827)) ([14ddbc7](https://github.com/siteboon/claudecodeui/commit/14ddbc7c57a01da9fb65fd87d8588532b11833fa))
* remove thinking mode ([#835](https://github.com/siteboon/claudecodeui/issues/835)) ([2149b87](https://github.com/siteboon/claudecodeui/commit/2149b8776b7ebfec0eace413f4fc527ccb2324c0))
* **shell:** disconnect and restart buttons ([#831](https://github.com/siteboon/claudecodeui/issues/831)) ([ef2fd48](https://github.com/siteboon/claudecodeui/commit/ef2fd48b46452d4b9e2bf1f5e3c30fafe19f27f2))
* show Claude tool result errors ([bb8db58](https://github.com/siteboon/claudecodeui/commit/bb8db5815c2d20ee4fbfa02d14c886a56ef352e0))
* **vite:** proxy /plugin-ws WebSocket requests to the backend in dev ([#757](https://github.com/siteboon/claudecodeui/issues/757)) ([96b16b4](https://github.com/siteboon/claudecodeui/commit/96b16b42e4f807d04ec743a5a4117a37a3f5e0d9))
* **websocket:** add 30s server-side heartbeat to prevent proxy idle disconnects ([#770](https://github.com/siteboon/claudecodeui/issues/770)) ([2edfef2](https://github.com/siteboon/claudecodeui/commit/2edfef2e3f4271c29ae8670df9dd382a9eef7c3c)), closes [#769](https://github.com/siteboon/claudecodeui/issues/769)
* **websocket:** reset unmountedRef on each effect re-run so token refresh reconnects ([#721](https://github.com/siteboon/claudecodeui/issues/721)) ([f082cdc](https://github.com/siteboon/claudecodeui/commit/f082cdc63bd0de90f8b3da1df6071e91ab545831))
### Documentation
* add nginx subpath deployment template ([#820](https://github.com/siteboon/claudecodeui/issues/820)) ([3ec76b5](https://github.com/siteboon/claudecodeui/commit/3ec76b5bb15a13cec41056f4c9b9c425195022fa))
### Maintenance
* update Claude fallback models ([94785bf](https://github.com/siteboon/claudecodeui/commit/94785bfa579d1f39a2bee0f9dd0f09fd0243bc79))
* update package-lock.json ([c90b341](https://github.com/siteboon/claudecodeui/commit/c90b34108e86a3effdb5c6979ea7b1692d2b9da0))
## [](https://github.com/siteboon/claudecodeui/compare/v1.32.0...vnull) (2026-06-01)
### New Features
* add opencode support ([#762](https://github.com/siteboon/claudecodeui/issues/762)) ([374e9de](https://github.com/siteboon/claudecodeui/commit/374e9de71934c41ce2c19c796e35a19234b240ec))
* **sidebar:** tooltip for the active-session indicator dot ([#782](https://github.com/siteboon/claudecodeui/issues/782)) ([27e509a](https://github.com/siteboon/claudecodeui/commit/27e509a9b8bb25c35ae0abbda44c536e15c332c8))
### Bug Fixes
* **chat:** prevent double send on mobile by removing redundant submit handlers ([#719](https://github.com/siteboon/claudecodeui/issues/719)) ([dbc41dc](https://github.com/siteboon/claudecodeui/commit/dbc41dc91dbf1fb54f92f5536d64646b4e924f31))
* preserve WebSocket frame type in plugin proxy ([#594](https://github.com/siteboon/claudecodeui/issues/594)) ([36b860e](https://github.com/siteboon/claudecodeui/commit/36b860e322454df62ebf5309018590b596e6b913)), closes [CoderLuii/HolyClaude#11](https://github.com/CoderLuii/HolyClaude/issues/11)
* refine token usage reporting ([#807](https://github.com/siteboon/claudecodeui/issues/807)) ([38bf21d](https://github.com/siteboon/claudecodeui/commit/38bf21ddf554ed28676d86b5221c25adf6f07afd))
* refresh Claude auth status after login flow ([#617](https://github.com/siteboon/claudecodeui/issues/617)) ([1e125f3](https://github.com/siteboon/claudecodeui/commit/1e125f3db5248399cd50dc3d40b1f8f44cf7ccb6))
* **sidebar:** keep session rename input visible while editing ([#781](https://github.com/siteboon/claudecodeui/issues/781)) ([951f587](https://github.com/siteboon/claudecodeui/commit/951f58751c152fbbb3f8b3ce3c814c06c061de18))
### Styling
* fix project star button location by replacing folder icon ([#793](https://github.com/siteboon/claudecodeui/issues/793)) ([295bad9](https://github.com/siteboon/claudecodeui/commit/295bad9c006b669878cbf52940794f29f7370178))
## [1.32.0](https://github.com/siteboon/claudecodeui/compare/v1.31.5...v1.32.0) (2026-05-13)
### Bug Fixes
* add clarification on auto mode ([392c73b](https://github.com/siteboon/claudecodeui/commit/392c73b6933600ea8a589c5d4eff5f7b830f99c5))
* enhance regex to correctly parse wrapper file paths for claude.exe ([#741](https://github.com/siteboon/claudecodeui/issues/741)) ([beb0a50](https://github.com/siteboon/claudecodeui/commit/beb0a50413beddfb16f6b49103e1b6b80567cb90))
## [1.31.5](https://github.com/siteboon/claudecodeui/compare/v1.31.4...v1.31.5) (2026-04-30)
### New Features
* add auto mode to claude code ([3f71d49](https://github.com/siteboon/claudecodeui/commit/3f71d4932b05dfedcdf816e2a3d7d0cd69c4f566))
## [1.31.4](https://github.com/siteboon/claudecodeui/compare/v1.31.3...v1.31.4) (2026-04-30)
### Bug Fixes
* bump codex sdk to latest version ([658421c](https://github.com/siteboon/claudecodeui/commit/658421c1c44ec4eb58b69ec7b1844a9fba11a3f3))
## [1.31.3](https://github.com/siteboon/claudecodeui/compare/v1.31.2...v1.31.3) (2026-04-30)
## [1.31.2](https://github.com/siteboon/claudecodeui/compare/v1.31.0...v1.31.2) (2026-04-30)
### Bug Fixes
* migrations for new sqlite schema ([0753c04](https://github.com/siteboon/claudecodeui/commit/0753c047837dab17b86ae4453027e30b465870f8))
## [1.31.0](https://github.com/siteboon/claudecodeui/compare/v1.30.0...v1.31.0) (2026-04-30)
### Bug Fixes
* **/status:** use CLAUDE_MODELS.DEFAULT instead of stale 'claude-sonnet-4.5' fallback ([#723](https://github.com/siteboon/claudecodeui/issues/723)) ([b4a39c7](https://github.com/siteboon/claudecodeui/commit/b4a39c729710a6294c62eb742e99e05f3e3914e9))
## [1.30.0](https://github.com/siteboon/claudecodeui/compare/v1.29.5...v1.30.0) (2026-04-21)
### New Features
* **i18n:** add Italian language support ([#677](https://github.com/siteboon/claudecodeui/issues/677)) ([86b6545](https://github.com/siteboon/claudecodeui/commit/86b6545c3505475ac2de0cec75cc8f86ab22aceb))
* **i18n:** add Turkish (tr) language support ([#678](https://github.com/siteboon/claudecodeui/issues/678)) ([89b754d](https://github.com/siteboon/claudecodeui/commit/89b754d186b68f3df8aa439a2d535644406066f0)), closes [#384](https://github.com/siteboon/claudecodeui/issues/384) [#514](https://github.com/siteboon/claudecodeui/issues/514) [#525](https://github.com/siteboon/claudecodeui/issues/525) [#534](https://github.com/siteboon/claudecodeui/issues/534)
* introduce opus 4.7 ([#682](https://github.com/siteboon/claudecodeui/issues/682)) ([c5e55ad](https://github.com/siteboon/claudecodeui/commit/c5e55adc89d0316675f90a927aa40d115958ae9f))
### Bug Fixes
* iOS scrolling main chat area ([3969135](https://github.com/siteboon/claudecodeui/commit/3969135bd427fbf48f29bb3dbfedb47791ca78dc))
* migrate PlanDisplay raw params from native details to Collapsible primitive ([fc3504e](https://github.com/siteboon/claudecodeui/commit/fc3504eaed8ca7ed9214838d148ea385b8352c31))
* precise Claude SDK denial message detection in deriveToolStatus ([09dcea0](https://github.com/siteboon/claudecodeui/commit/09dcea05fbc8c208d931aa1f08618f0e8087392f))
* reduce size of permission mode button tap target and provider selector on mobile ([457ca0d](https://github.com/siteboon/claudecodeui/commit/457ca0daabcaa8397f4375ee8aa2671336b648ff))
* small mobile respnosive fixes ([25820ed](https://github.com/siteboon/claudecodeui/commit/25820ed995c1b813b1f9ed073097b08eb1d902ec))
* small mobile respnosive fixes ([c471b5d](https://github.com/siteboon/claudecodeui/commit/c471b5d3fa6ce1968adb4cf87a15ac0e18febd20))
### Refactoring
* add primitives, plan mode display, and new session model selector ([7763e60](https://github.com/siteboon/claudecodeui/commit/7763e60fb32e34742058c055c57664a503a34d1d))
* chat composer new design ([5758bee](https://github.com/siteboon/claudecodeui/commit/5758bee8a038ed50073dba882108617959dda82c))
* queue primitive, tool status badges, and tool display cleanup ([ec0ff97](https://github.com/siteboon/claudecodeui/commit/ec0ff974cba213a1100b2a071b8ba533e812fe82))
### Maintenance
* add docker sandbox action ([fa5a238](https://github.com/siteboon/claudecodeui/commit/fa5a23897c086bcacf1cf5d926c650f98a0f2222))
## [1.29.5](https://github.com/siteboon/claudecodeui/compare/v1.29.4...v1.29.5) (2026-04-16)
### Bug Fixes
* update node-pty to latest version ([6a13e17](https://github.com/siteboon/claudecodeui/commit/6a13e1773b145049ade512aa6e5cac21c2e5c4de))
## [1.29.4](https://github.com/siteboon/claudecodeui/compare/v1.29.3...v1.29.4) (2026-04-16)
### New Features
* deleting from sidebar will now ask whether to remove all data as well ([e9c7a50](https://github.com/siteboon/claudecodeui/commit/e9c7a5041c31a6f7b2032f06abe19c52d3d4cd8c))
### Bug Fixes
* pass pathToClaudeCodeExecutable to SDK when CLAUDE_CLI_PATH is set ([4c106a5](https://github.com/siteboon/claudecodeui/commit/4c106a5083d90989bbeedaefdbb68f5b3fa6fd58)), closes [#468](https://github.com/siteboon/claudecodeui/issues/468)
### Refactoring
* remove the sqlite3 dependency ([2895208](https://github.com/siteboon/claudecodeui/commit/289520814cf3ca36403056739ef22021f78c6033))
* **server:** extract URL detection and color utils from index.js ([#657](https://github.com/siteboon/claudecodeui/issues/657)) ([63e996b](https://github.com/siteboon/claudecodeui/commit/63e996bb77cfa97b1f55f6bdccc50161a75a3eee))
### Maintenance
* upgrade commit lint to 20.5.0 ([0948601](https://github.com/siteboon/claudecodeui/commit/09486016e67d97358c228ebc6eb4502ccb0012e4))
## [1.29.3](https://github.com/siteboon/claudecodeui/compare/v1.29.2...v1.29.3) (2026-04-15)
### Bug Fixes
* **version-upgrade-modal:** implement reload countdown and update UI messages ([#655](https://github.com/siteboon/claudecodeui/issues/655)) ([6413042](https://github.com/siteboon/claudecodeui/commit/641304242d7705b54aab65faa4a7673438c92c60))
### Maintenance
* remove unused route (migrated to providers already) ([31f28a2](https://github.com/siteboon/claudecodeui/commit/31f28a2c183f6ead50941027632d7ab64b7bb2d4))
## [1.29.2](https://github.com/siteboon/claudecodeui/compare/v1.29.1...v1.29.2) (2026-04-14)
### Bug Fixes
* **sandbox:** use backgrounded sbx run to keep sandbox alive ([9b11c03](https://github.com/siteboon/claudecodeui/commit/9b11c034d9a19710a23b56c62dcf07c21a17bd97))
## [1.29.1](https://github.com/siteboon/claudecodeui/compare/v1.29.0...v1.29.1) (2026-04-14)
### Bug Fixes
* add latest tag to docker npx command and change the detach mode to work without spawn ([4a56972](https://github.com/siteboon/claudecodeui/commit/4a569725dae320a505753359d8edfd8ca79f0fd7))
## [1.29.0](https://github.com/siteboon/claudecodeui/compare/v1.28.1...v1.29.0) (2026-04-14)
### New Features
* adding docker sandbox environments ([13e97e2](https://github.com/siteboon/claudecodeui/commit/13e97e2c71254de7a60afb5495b21064c4bc4241))
### Bug Fixes
* **thinking-mode:** fix dropdown positioning ([#646](https://github.com/siteboon/claudecodeui/issues/646)) ([c7a5baf](https://github.com/siteboon/claudecodeui/commit/c7a5baf1479404bd40e23aa58bd9f677df9a04c6))
### Maintenance
* update release flow node version ([e2459cb](https://github.com/siteboon/claudecodeui/commit/e2459cb0f8b35f54827778a7b444e6c3ca326506))
## [1.28.1](https://github.com/siteboon/claudecodeui/compare/v1.28.0...v1.28.1) (2026-04-10)
### New Features

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -62,7 +62,7 @@
- **Sitzungsverwaltung** Gespräche fortsetzen, mehrere Sitzungen verwalten und Verlauf nachverfolgen
- **Plugin-System** CloudCLI mit eigenen Plugins erweitern neue Tabs, Backend-Dienste und Integrationen hinzufügen. [Eigenes Plugin erstellen →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI Integration** *(Optional)* Erweitertes Projektmanagement mit KI-gestützter Aufgabenplanung, PRD-Parsing und Workflow-Automatisierung
- **Modell-Kompatibilität** Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle in [`shared/modelConstants.js`](shared/modelConstants.js))
- **Modell-Kompatibilität** Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle zur Laufzeit über `GET /api/providers/:provider/models`)
## Schnellstart
@@ -76,6 +76,8 @@ Der schnellste Einstieg keine lokale Einrichtung erforderlich. Erhalte eine
### Self-Hosted (Open Source)
#### npm
CloudCLI UI sofort mit **npx** ausprobieren (erfordert **Node.js** v22+):
```bash
@@ -93,6 +95,15 @@ cloudcli
Die **[Dokumentation →](https://cloudcli.ai/docs)** enthält weitere Konfigurationsoptionen, PM2, Remote-Server-Einrichtung und mehr.
#### Docker Sandboxes (Experimentell)
Agents in isolierten Sandboxes mit Hypervisor-Isolation ausführen. Standardmäßig wird Claude Code gestartet. Erfordert die [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/).
```
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
Unterstützt Claude Code, Codex und Gemini CLI. Weitere Details in der [Sandbox-Dokumentation](docker/).
---
@@ -153,6 +164,14 @@ CloudCLI verfügt über ein Plugin-System, mit dem benutzerdefinierte Tabs mit e
| Plugin | Beschreibung |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Zeigt Dateianzahl, Codezeilen, Dateityp-Aufschlüsselung, größte Dateien und zuletzt geänderte Dateien des aktuellen Projekts |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Vollwertiges xterm.js-Terminal mit Multi-Tab-Unterstützung |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Überwacht lange laufende Claude-Code-Sitzungen auf Hänger und stellt Prozesssteuerungen bereit |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Erstellt arbeitsbereichsbezogene geplante Prompts und führt sie über eine lokale CLI wie Codex, Claude Code oder Gemini CLI aus |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Sitzungsintelligenz für Claude Code in CloudCLI, inklusive Sichtbarkeit des Token-Verbrauchs |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Aktive Claude-Code-Sitzungen anzeigen, verwalten und beenden |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | API-Kosten anhand von Modellpreisen und Token-Nutzung berechnen, mit Unterstützung für Preisvorlagen |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Task-Queue-Dashboard zum Anzeigen, Filtern und Starten von Agent-Aufgaben |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban-Board für GitHub Issues mit bidirektionaler TaskMaster-Synchronisierung und automatischer Installation des /github-task CLI-Skills |
### Eigenes Plugin erstellen

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <b>日本語</b></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <b>日本語</b> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -72,6 +72,8 @@
### セルフホスト(オープンソース)
#### npm
**npx** で今すぐ CloudCLI UI を試せます(**Node.js** v22+ が必要):
```bash
@@ -89,6 +91,15 @@ cloudcli
より詳細な設定オプション、PM2、リモートサーバー設定などについては **[ドキュメントはこちら →](https://cloudcli.ai/docs)** を参照してください。
#### Docker Sandboxes実験的
ハイパーバイザーレベルの分離でエージェントをサンドボックスで実行します。デフォルトでは Claude Code が起動します。[`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/) が必要です。
```
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
Claude Code、Codex、Gemini CLI に対応。詳細は[サンドボックスのドキュメント](docker/)をご覧ください。
---
@@ -147,6 +158,14 @@ CloudCLI にはプラグインシステムがあり、独自のフロントエ
| プラグイン | 説明 |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 現在のプロジェクトについて、ファイル数、コード行数、ファイル種別の内訳、最大ファイル、最近変更されたファイルを表示 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 複数タブに対応した本格的な xterm.js ターミナル |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 長時間実行中の Claude Code セッションのハングを監視し、プロセス操作を提供 |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | ワークスペース単位のスケジュール済みプロンプトを作成し、Codex、Claude Code、Gemini CLI などのローカル CLI で実行 |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI 内で Claude Code のセッション分析を行い、トークン消費の可視化も提供 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | アクティブな Claude Code セッションを表示、管理、終了 |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | モデル価格とトークン使用量から API コストを計算し、モデル価格プリセットにも対応 |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | エージェントタスクを表示、フィルタリング、起動するためのタスクキューダッシュボード |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues 用の Kanban ボード。TaskMaster との双方向同期と /github-task CLI スキルの自動インストールに対応 |
### 自作する

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -60,7 +60,7 @@
- **세션 관리** - 대화를 재개하고, 여러 세션을 관리하며 기록을 추적
- **플러그인 시스템** - 커스텀 탭, 백엔드 서비스, 통합을 추가하여 CloudCLI 확장. [직접 빌드 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI 통합** *(선택사항)* - AI 중심의 작업 계획, PRD 파싱, 워크플로 자동화를 통한 고급 프로젝트 관리
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`shared/modelConstants.js`에서 전체 지원 모델 확인)
- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`GET /api/providers/:provider/models` API에서 전체 지원 모델 확인)
## 빠른 시작
@@ -72,6 +72,8 @@
### 셀프 호스트 (오픈 소스)
#### npm
**npx**로 즉시 CloudCLI UI를 실행하세요 (Node.js v22+ 필요):
```bash
@@ -87,7 +89,17 @@ cloudcli
`http://localhost:3001`을 열면 기존 세션이 자동으로 발견됩니다.
자세한 구성 옵션, PM2, 원격 서버 설정 등은 **[문서 →](https://cloudcli.ai/docs)**를 참고하세요
자세한 구성 옵션, PM2, 원격 서버 설정 등은 **[문서 →](https://cloudcli.ai/docs)**를 참고하세요.
#### Docker Sandboxes (실험적)
하이퍼바이저 수준 격리로 에이전트를 샌드박스에서 실행합니다. 기본 에이전트는 Claude Code입니다. [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/)가 필요합니다.
```
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
Claude Code, Codex, Gemini CLI를 지원합니다. 자세한 내용은 [샌드박스 문서](docker/)를 참고하세요.
---
@@ -146,6 +158,14 @@ CloudCLI는 커스텀 탭과 선택적 Node.js 백엔드가 포함된 플러그
| 플러그인 | 설명 |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 현재 프로젝트의 파일 수, 코드 줄 수, 파일 유형 분포, 가장 큰 파일, 최근 수정 파일을 표시 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 다중 탭을 지원하는 전체 xterm.js 터미널 |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 장시간 실행 중인 Claude Code 세션의 중단 상태를 감시하고 프로세스 제어를 제공 |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 워크스페이스 범위 예약 프롬프트를 만들고 Codex, Claude Code, Gemini CLI 같은 로컬 CLI로 실행 |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI 안에서 Claude Code 세션 인텔리전스와 토큰 소모 가시성을 제공 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 활성 Claude Code 세션을 보고, 관리하고, 종료 |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 모델 가격과 토큰 사용량으로 API 비용을 계산하고 모델 가격 프리셋을 지원 |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 에이전트 작업을 보고, 필터링하고, 실행하는 작업 큐 대시보드 |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues용 Kanban 보드. TaskMaster 양방향 동기화와 /github-task CLI 스킬 자동 설치 지원 |
### 직접 만들기

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -59,10 +59,11 @@
- **Integrated Shell Terminal** - Direct access to the Agents CLI through built-in shell functionality
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
- **Browser Use** - Open browser sessions for web research, testing, and agent-driven browser tasks
- **Session Management** - Resume conversations, manage multiple sessions, and track history
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`shared/modelConstants.js`](shared/modelConstants.js) for the full list of supported models)
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`)
## Quick Start
@@ -73,9 +74,16 @@ The fastest way to get started — no local setup required. Get a fully managed,
**[Get started with CloudCLI Cloud](https://cloudcli.ai)**
### Desktop App
Download the latest macOS or Windows desktop app from the **[GitHub Releases](https://github.com/siteboon/claudecodeui/releases)** page.
Use the desktop app to open CloudCLI Cloud environments, switch between local and remote workspaces, copy mobile/browser URLs, and keep Local CloudCLI available from your menu bar or tray. To work locally, choose **Local CloudCLI** in the desktop app; it will use your running local server or start one for you.
### Self-Hosted (Open source)
#### npm
Try CloudCLI UI instantly with **npx** (requires **Node.js** v22+):
```
@@ -91,33 +99,41 @@ cloudcli
Open `http://localhost:3001` — all your existing sessions are discovered automatically.
Visit the **[documentation →](https://cloudcli.ai/docs)** for more full configuration options, PM2, remote server setup and more
Visit the **[documentation →](https://cloudcli.ai/docs)** for full configuration options, PM2, remote server setup and more.
#### Docker Sandboxes (Experimental)
Run agents in isolated sandboxes with hypervisor-level isolation. Starts Claude Code by default. Requires the [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/).
```
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
Supports Claude Code, Codex, and Gemini CLI. See the [sandbox docs](docker/) for setup and advanced options.
---
## Which option is right for you?
CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self-host it on your own machine, or use CloudCLI Cloud which builds on top of it with a full managed cloud environment, team features, and deeper integrations.
CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self-host it on your own machine, run it in a Docker sandbox for isolation, or use CloudCLI Cloud for a fully managed environment.
| | CloudCLI UI (Self-hosted) | CloudCLI Cloud |
|---|---|---|
| **Best for** | Developers who want a full UI for local agent sessions on their own machine | Teams and developers who want agents running in the cloud, accessible from anywhere |
| **How you access it** | Browser via `[yourip]:port` | Browser, any IDE, REST API, n8n |
| **Setup** | `npx @cloudcli-ai/cloudcli` | No setup required |
| **Machine needs to stay on** | Yes | No |
| **Mobile access** | Any browser on your network | Any device, native app coming |
| **Sessions available** | All sessions auto-discovered from `~/.claude` | All sessions within your cloud environment |
| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
| **File explorer and Git** | Yes, built into the UI | Yes, built into the UI |
| **MCP configuration** | Managed via UI, synced with your local `~/.claude` config | Managed via UI |
| **IDE access** | Your local IDE | Any IDE connected to your cloud environment |
| **REST API** | Yes | Yes |
| **n8n node** | No | Yes |
| **Team sharing** | No | Yes |
| **Platform cost** | Free, open source | Starts at $7/month |
| | Self-Hosted (npm) | Self-Hosted (Docker Sandbox) *(Experimental)* | CloudCLI Cloud |
|---|---|---|---|
| **Best for** | Local agent sessions on your own machine | Isolated agents with web/mobile IDE | Teams who want agents in the cloud |
| **How you access it** | Browser via `[yourip]:port` | Browser via `localhost:port` | Browser, any IDE, REST API, n8n |
| **Setup** | `npx @cloudcli-ai/cloudcli` | `npx @cloudcli-ai/cloudcli@latest sandbox ~/project` | No setup required |
| **Isolation** | Runs on your host | Hypervisor-level sandbox (microVM) | Full cloud isolation |
| **Machine needs to stay on** | Yes | Yes | No |
| **Mobile access** | Any browser on your network | Any browser on your network | Any device, native app coming |
| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
| **File explorer and Git** | Yes | Yes | Yes |
| **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI |
| **REST API** | Yes | Yes | Yes |
| **Team sharing** | No | No | Yes |
| **Platform cost** | Free, open source | Free, open source | Starts at $7/month |
> Both options use your own AI subscriptions (Claude, Cursor, etc.) — CloudCLI provides the environment, not the AI.
> All options use your own AI subscriptions (Claude, Cursor, etc.) — CloudCLI provides the environment, not the AI.
---
@@ -153,7 +169,14 @@ CloudCLI has a plugin system that lets you add custom tabs with their own fronte
| Plugin | Description |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Shows file counts, lines of code, file-type breakdown, largest files, and recently modified files for your current project |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Full xterm.js terminal with multi-tab support|
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Full xterm.js terminal with multi-tab support |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Watches long-running Claude Code sessions for hangs and exposes process controls |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Create workspace-scoped scheduled prompts and execute them through a local CLI such as Codex, Claude Code, or Gemini CLI |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Session intelligence for Claude Code inside CloudCLI, including token burn visibility |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | View, manage, and kill active Claude Code sessions |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Calculate API costs from model prices and token usage, with preset model pricing support |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Task queue dashboard to view, filter, and launch agent tasks |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban board for GitHub Issues with bidirectional TaskMaster sync and /github-task CLI skill auto-install |
### Build Your Own

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -62,7 +62,7 @@
- **Управление сессиями** - возобновляйте диалоги, управляйте несколькими сессиями и отслеживайте историю
- **Система плагинов** - расширяйте CloudCLI кастомными плагинами — добавляйте новые вкладки, бэкенд-сервисы и интеграции. [Создать свой →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **Интеграция с TaskMaster AI** *(опционально)* - продвинутое управление проектами с планированием задач на базе AI, разбором PRD и автоматизацией workflow
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (см. [`shared/modelConstants.js`](shared/modelConstants.js) для полного списка поддерживаемых моделей)
- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (полный список поддерживаемых моделей доступен через `GET /api/providers/:provider/models`)
## Быстрый старт
@@ -76,6 +76,8 @@
### Self-Hosted (Open source)
#### npm
Попробовать CloudCLI UI можно сразу через **npx** (требуется **Node.js** v22+):
```bash
@@ -91,8 +93,17 @@ cloudcli
Откройте `http://localhost:3001` — все ваши существующие сессии будут обнаружены автоматически.
Посетите **[документацию →](https://cloudcli.ai/docs)**, чтобы узнать про дополнительные варианты конфигурации, PM2, настройку удалённого сервера и многое другое
Посетите **[документацию →](https://cloudcli.ai/docs)**, чтобы узнать про дополнительные варианты конфигурации, PM2, настройку удалённого сервера и многое другое.
#### Docker Sandboxes (Экспериментально)
Запускайте агентов в изолированных песочницах с гипервизорной изоляцией. По умолчанию запускается Claude Code. Требуется [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/).
```
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
Поддерживаются Claude Code, Codex и Gemini CLI. Подробнее в [документации sandbox](docker/).
---
@@ -153,6 +164,14 @@ CloudCLI UI — это open source UI-слой, на котором постро
| Плагин | Описание |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Показывает количество файлов, строки кода, разбивку по типам файлов, самые большие файлы и недавно изменённые файлы для текущего проекта |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Полноценный терминал xterm.js с поддержкой нескольких вкладок |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Отслеживает зависания долгих сессий Claude Code и предоставляет управление процессами |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Создаёт запланированные промпты для рабочей области и запускает их через локальную CLI, например Codex, Claude Code или Gemini CLI |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | Аналитика сессий Claude Code внутри CloudCLI, включая видимость расхода токенов |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Просмотр, управление и завершение активных сессий Claude Code |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Расчёт стоимости API по ценам моделей и использованию токенов, с поддержкой пресетов цен |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Дашборд очереди задач для просмотра, фильтрации и запуска агентских задач |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | Kanban-доска для GitHub Issues с двусторонней синхронизацией TaskMaster и автоустановкой CLI-навыка /github-task |
### Создать свой

259
README.tr.md Normal file
View File

@@ -0,0 +1,259 @@
<div align="center">
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<h1>Cloud CLI (Claude Code UI olarak da bilinir)</h1>
<p><a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, <a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>, <a href="https://developers.openai.com/codex">Codex</a> ve <a href="https://geminicli.com/">Gemini-CLI</a> için masaüstü ve mobil arayüz.<br>Yerel ya da uzaktan kullanarak aktif projelerine ve oturumlarına her yerden erişebilirsin.</p>
</div>
<p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">Dokümantasyon</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Sorun Bildir</a> · <a href="CONTRIBUTING.md">Katkıda Bulun</a>
</p>
<p align="center">
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Hemen_Dene-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Toplulu%C4%9Fa%20Kat%C4%B1l-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord'a Katıl"></a>
<br><br>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <b>Türkçe</b></i></div>
---
## Ekran Görüntüleri
<div align="center">
<table>
<tr>
<td align="center">
<h3>Masaüstü Görünümü</h3>
<img src="public/screenshots/desktop-main.png" alt="Masaüstü Arayüzü" width="400">
<br>
<em>Proje genel bakışı ve sohbeti gösteren ana arayüz</em>
</td>
<td align="center">
<h3>Mobil Deneyim</h3>
<img src="public/screenshots/mobile-chat.png" alt="Mobil Arayüz" width="250">
<br>
<em>Dokunma gezinmesiyle duyarlı mobil tasarım</em>
</td>
</tr>
<tr>
<td align="center" colspan="2">
<h3>CLI Seçimi</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI Seçimi" width="400">
<br>
<em>Claude Code, Gemini, Cursor CLI ve Codex arasında seçim yap</em>
</td>
</tr>
</table>
</div>
## Özellikler
- **Duyarlı Tasarım** — Masaüstü, tablet ve mobilde sorunsuz çalışır; böylece ajanlarını telefondan da kullanabilirsin
- **Etkileşimli Sohbet Arayüzü** — Ajanlarla akıcı iletişim için dahili sohbet arayüzü
- **Entegre Shell Terminali** — Yerleşik shell özelliği üzerinden ajan CLI'larına doğrudan erişim
- **Dosya Gezgini** — Sözdizimi vurgulama ve canlı düzenleme ile etkileşimli dosya ağacı
- **Git Gezgini** — Değişikliklerini görüntüle, staging'e ekle ve commit'le. Dallar arası geçiş de yapabilirsin
- **Oturum Yönetimi** — Konuşmalara devam et, birden fazla oturumu yönet ve geçmişi takip et
- **Eklenti Sistemi** — CloudCLI'ı özel eklentilerle genişlet: yeni sekmeler, arka uç servisleri ve entegrasyonlar ekle. [Kendi eklentini yaz →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI Entegrasyonu** *(İsteğe Bağlı)* — AI destekli görev planlama, PRD ayrıştırma ve iş akışı otomasyonu ile gelişmiş proje yönetimi
- **Model Uyumluluğu** — Claude, GPT ve Gemini model aileleriyle çalışır (desteklenen tüm modeller için `GET /api/providers/:provider/models` API'sine bak)
## Hızlı Başlangıç
### CloudCLI Cloud (Önerilen)
Başlamanın en hızlı yolu — yerel kurulum yok. Web, mobil uygulama, API veya favori IDE'nden erişilebilen, tam yönetilen, konteyner tabanlı bir geliştirme ortamına sahip ol.
**[CloudCLI Cloud ile başla](https://cloudcli.ai)**
### Kendin Barındır (Açık Kaynak)
#### npm
CloudCLI UI'yi **npx** ile anında dene (**Node.js** v22+ gerekir):
```
npx @cloudcli-ai/cloudcli
```
Veya düzenli kullanım için **genel olarak** kur:
```
npm install -g @cloudcli-ai/cloudcli
cloudcli
```
`http://localhost:3001` adresini aç — mevcut tüm oturumların otomatik olarak keşfedilir.
Tam yapılandırma seçenekleri, PM2, uzak sunucu kurulumu ve daha fazlası için **[dokümantasyonu ziyaret et →](https://cloudcli.ai/docs)**.
#### Docker Sandbox'lar (Deneysel)
Ajanları hipervizör seviyesinde izolasyonlu sandbox'larda çalıştır. Varsayılan olarak Claude Code başlar. [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/) gerekir.
```
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
Claude Code, Codex ve Gemini CLI destekler. Kurulum ve gelişmiş seçenekler için [sandbox dokümantasyonuna](docker/) bak.
---
## Hangi seçenek sana uygun?
CloudCLI UI, CloudCLI Cloud'u güçlendiren açık kaynak arayüz katmanıdır. Kendi makinende barındırabilir, izolasyon için Docker sandbox'ta çalıştırabilir veya tam yönetilen ortam için CloudCLI Cloud kullanabilirsin.
| | Kendin Barındır (npm) | Kendin Barındır (Docker Sandbox) *(Deneysel)* | CloudCLI Cloud |
|---|---|---|---|
| **En iyi şunun için** | Kendi makinende yerel ajan oturumları | Web/mobil IDE ile izole ajanlar | Ajanlarını bulutta isteyen ekipler |
| **Nasıl erişilir** | `[yourip]:port` üzerinden tarayıcıda | `localhost:port` üzerinden tarayıcıda | Tarayıcı, herhangi bir IDE, REST API, n8n |
| **Kurulum** | `npx @cloudcli-ai/cloudcli` | `npx @cloudcli-ai/cloudcli@latest sandbox ~/project` | Kurulum gerekmez |
| **İzolasyon** | Kendi host'unda çalışır | Hipervizör seviyesi sandbox (microVM) | Tam bulut izolasyonu |
| **Makinenin açık kalması gerek** | Evet | Evet | Hayır |
| **Mobil erişim** | Ağındaki herhangi bir tarayıcı | Ağındaki herhangi bir tarayıcı | Herhangi bir cihaz, native uygulama yolda |
| **Desteklenen ajanlar** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
| **Dosya gezgini ve Git** | Evet | Evet | Evet |
| **MCP yapılandırması** | `~/.claude` ile senkron | UI üzerinden yönetilir | UI üzerinden yönetilir |
| **REST API** | Evet | Evet | Evet |
| **Ekip paylaşımı** | Hayır | Hayır | Evet |
| **Platform maliyeti** | Ücretsiz, açık kaynak | Ücretsiz, açık kaynak | Aylık 7 $'dan başlar |
> Tüm seçenekler kendi AI aboneliklerini (Claude, Cursor, vb.) kullanır — CloudCLI AI'ı değil, ortamı sağlar.
---
## Güvenlik ve Araç Yapılandırması
**🔒 Önemli Uyarı**: Tüm Claude Code araçları **varsayılan olarak devre dışıdır**. Bu, potansiyel olarak zararlı işlemlerin otomatik çalışmasını önler.
### Araçları Etkinleştirme
Claude Code'un tam işlevselliğinden yararlanmak için araçları manuel olarak etkinleştirmen gerekir:
1. **Araç Ayarlarını Aç** — Kenar çubuğundaki dişli simgesine tıkla
2. **Seçerek Etkinleştir** — Yalnızca ihtiyacın olan araçları
3. **Ayarları Uygula** — Tercihlerin yerel olarak kaydedilir
<div align="center">
![Araç Ayarları Modalı](public/screenshots/tools-modal.png)
*Araç Ayarları arayüzü — yalnızca ihtiyacın olanı etkinleştir*
</div>
**Önerilen yaklaşım**: Temel araçlarla başla ve gerektikçe daha fazlasını ekle. Bu ayarları sonra her zaman değiştirebilirsin.
---
## Eklentiler
CloudCLI, kendi frontend UI'sı ve isteğe bağlı Node.js arka ucu olan özel sekmeler eklemeni sağlayan bir eklenti sistemine sahiptir. Git depolarından eklentileri doğrudan **Ayarlar > Eklentiler**'den yükleyebilir veya kendi eklentini yazabilirsin.
### Mevcut Eklentiler
| Eklenti | Açıklama |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Mevcut projen için dosya sayıları, kod satırları, dosya türü dağılımı, en büyük dosyalar ve son değiştirilen dosyaları gösterir |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Çoklu sekme destekli tam xterm.js terminali |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | Uzun süren Claude Code oturumlarını takılmalara karşı izler ve süreç kontrolleri sunar |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | Çalışma alanı kapsamlı zamanlanmış prompt'lar oluşturur ve bunları Codex, Claude Code veya Gemini CLI gibi yerel CLI'larla çalıştırır |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | CloudCLI içinde Claude Code oturum zekası ve token tüketimi görünürlüğü sağlar |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | Aktif Claude Code oturumlarını görüntülemeni, yönetmeni ve sonlandırmanı sağlar |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | Model fiyatları ve token kullanımından API maliyetlerini hesaplar; model fiyatı hazır ayarlarını destekler |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | Ajan görevlerini görüntülemek, filtrelemek ve başlatmak için görev kuyruğu paneli |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | GitHub Issues için Kanban panosu; çift yönlü TaskMaster senkronizasyonu ve /github-task CLI becerisi otomatik kurulumu içerir |
### Kendi Eklentini Yaz
**[Plugin Starter Şablonu →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — kendi eklentini oluşturmak için bu repo'yu fork'la. Frontend render, canlı bağlam güncellemeleri ve arka uç sunucusuyla RPC iletişimi içeren çalışan bir örnek içerir.
**[Plugin Dokümantasyonu →](https://cloudcli.ai/docs/plugin-overview)** — plugin API'sı, manifest formatı, güvenlik modeli ve daha fazlası için tam rehber.
---
## Sık Sorulan Sorular
<details>
<summary>Bu Claude Code Remote Control'dan nasıl farklı?</summary>
Claude Code Remote Control, yerel terminalinde zaten çalışan bir oturuma mesaj göndermeni sağlar. Makinen açık kalmak zorunda, terminalin açık kalmak zorunda ve ağ bağlantısı olmadan yaklaşık 10 dakika sonra oturumlar zaman aşımına uğrar.
CloudCLI UI ve CloudCLI Cloud, Claude Code'un yanında değil içinde çalışır — MCP sunucuların, izinlerin, ayarların ve oturumların, Claude Code'un yerel olarak kullandığının birebir aynısıdır. Hiçbir şey çoğaltılmaz veya ayrı yönetilmez.
Pratikte bu ne demek:
- **Tek oturum değil, tüm oturumların** — CloudCLI UI, `~/.claude` klasöründeki her oturumu otomatik keşfeder. Remote Control yalnızca tek aktif oturumu Claude mobil uygulamasına açar.
- **Ayarların sana ait** — UI'da değiştirdiğin MCP sunucuları, araç izinleri ve proje yapılandırması doğrudan Claude Code yapılandırmana yazılır ve anında etkili olur; tersi de geçerli.
- **Daha fazla ajanla çalışır** — Sadece Claude Code değil; Cursor CLI, Codex ve Gemini CLI de.
- **Sadece sohbet penceresi değil, tam UI** — dosya gezgini, Git entegrasyonu, MCP yönetimi ve shell terminali hepsi yerleşik.
- **CloudCLI Cloud bulutta çalışır** — laptop'unu kapat, ajan çalışmaya devam eder. Beklemen gereken terminal yok, uyanık tutman gereken makine yok.
</details>
<details>
<summary>AI aboneliği için ayrıca ödeme yapmam gerekiyor mu?</summary>
Evet. CloudCLI AI'yi değil, ortamı sağlar. Kendi Claude, Cursor, Codex veya Gemini aboneliğini getirirsin. CloudCLI Cloud, barındırılan ortam için aylık 7 $'dan başlar — bunun üzerine eklenir.
</details>
<details>
<summary>CloudCLI UI'yi telefonumda kullanabilir miyim?</summary>
Evet. Kendin barındırdığında, sunucuyu makinende çalıştır ve ağındaki herhangi bir tarayıcıda `[yourip]:port` adresini aç. CloudCLI Cloud için, herhangi bir cihazdan aç — VPN yok, port yönlendirme yok, kurulum yok. Native bir uygulama da hazırlanıyor.
</details>
<details>
<summary>UI'da yaptığım değişiklikler yerel Claude Code kurulumumu etkiler mi?</summary>
Evet, kendin barındırdığında. CloudCLI UI, Claude Code'un yerel olarak kullandığı aynı `~/.claude` yapılandırmasından okur ve ona yazar. UI üzerinden eklediğin MCP sunucuları Claude Code'da anında görünür; tersi de geçerli.
</details>
---
## Topluluk ve Destek
- **[Dokümantasyon](https://cloudcli.ai/docs)** — kurulum, yapılandırma, özellikler ve sorun giderme
- **[Discord](https://discord.gg/buxwujPNRE)** — yardım al ve diğer kullanıcılarla tanış
- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — hata raporları ve özellik istekleri
- **[Katkı Rehberi](CONTRIBUTING.md)** — projeye nasıl katkıda bulunulur
## Lisans
GNU Affero General Public License v3.0 veya sonrası (AGPL-3.0-or-later) — tam metin ve Bölüm 7 altındaki ek şartlar için [LICENSE](LICENSE) dosyasına bak.
Bu proje açık kaynaklıdır ve AGPL-3.0-or-later lisansı altında özgürce kullanılabilir, değiştirilebilir ve dağıtılabilir. Bu yazılımı değiştirir ve bir ağ servisi olarak çalıştırırsan, değiştirilmiş kaynak kodunu o servisin kullanıcılarına sunmak zorundasın.
CloudCLI UI — (https://cloudcli.ai).
## Teşekkürler
### Kullanılan Teknolojiler
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** — Anthropic'in resmi CLI'ı
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** — Cursor'un resmi CLI'ı
- **[Codex](https://developers.openai.com/codex)** — OpenAI Codex
- **[Gemini-CLI](https://geminicli.com/)** — Google Gemini CLI
- **[React](https://react.dev/)** — Kullanıcı arayüzü kütüphanesi
- **[Vite](https://vitejs.dev/)** — Hızlı derleme aracı ve geliştirme sunucusu
- **[Tailwind CSS](https://tailwindcss.com/)** — Utility-first CSS framework
- **[CodeMirror](https://codemirror.net/)** — Gelişmiş kod editörü
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(İsteğe Bağlı)* — AI destekli proje yönetimi ve görev planlama
### Sponsorlar
- [Siteboon — AI destekli web sitesi oluşturucu](https://siteboon.ai)
---
<div align="center">
<strong>Claude Code, Cursor ve Codex topluluğu için özenle yapıldı.</strong>
</div>

View File

@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>中文</b> · <a href="./README.ja.md">日本語</a></i></div>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>简体中文</b> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
@@ -60,7 +60,7 @@
- **会话管理** - 恢复对话、管理多个会话并跟踪历史记录
- **插件系统** - 通过自定义选项卡、后端服务与集成扩展 CloudCLI。 [开始构建 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI 集成** *(可选)* - 结合 AI 任务规划、PRD 分析与工作流自动化,实现高级项目管理
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表见 [`shared/modelConstants.js`](shared/modelConstants.js)
- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族(完整支持列表可通过 `GET /api/providers/:provider/models` 接口获取
## 快速开始
@@ -72,6 +72,8 @@
### 自托管(开源)
#### npm
启动 CloudCLI UI只需一行 `npx`(需要 Node.js v22+
```bash
@@ -87,7 +89,17 @@ cloudcli
打开 `http://localhost:3001`,系统会自动发现所有现有会话。
更多配置选项、PM2、远程服务器设置等请参阅 **[文档 →](https://cloudcli.ai/docs)**
更多配置选项、PM2、远程服务器设置等请参阅 **[文档 →](https://cloudcli.ai/docs)**
#### Docker Sandboxes实验性
在隔离的沙箱中运行代理,具有虚拟机管理程序级别的隔离。默认启动 Claude Code。需要 [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/)。
```
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
支持 Claude Code、Codex 和 Gemini CLI。详情请参阅 [沙箱文档](docker/)。
---
@@ -146,6 +158,14 @@ CloudCLI 配备插件系统,允许你添加带自定义前端 UI 和可选 Nod
| 插件 | 描述 |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示当前项目的文件数、代码行数、文件类型分布、最大文件以及最近修改的文件 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 支持多标签页的完整 xterm.js 终端 |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 监控长时间运行的 Claude Code 会话是否卡住,并提供进程控制 |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 创建工作区范围的定时提示词,并通过 Codex、Claude Code 或 Gemini CLI 等本地 CLI 执行 |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | 在 CloudCLI 中提供 Claude Code 会话智能分析,包括 token 消耗可视化 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 查看、管理并终止活动的 Claude Code 会话 |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 根据模型价格和 token 用量计算 API 成本,并支持模型价格预设 |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 用于查看、筛选和启动代理任务的任务队列仪表板 |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | 用于 GitHub Issues 的看板,支持 TaskMaster 双向同步和 /github-task CLI 技能自动安装 |
### 自行构建

250
README.zh-TW.md Normal file
View File

@@ -0,0 +1,250 @@
<div align="center">
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<h1>Cloud CLI又名 Claude Code UI</h1>
<p><a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a><a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a><a href="https://developers.openai.com/codex">Codex</a><a href="https://geminicli.com/">Gemini-CLI</a> 的桌面和行動裝置 UI。可在本機或遠端使用從任何地方查看您的專案與工作階段。</p>
</div>
<p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">文件</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug 回報</a> · <a href="CONTRIBUTING.md">貢獻指南</a>
</p>
<p align="center">
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="加入 Discord 社群"></a>
<br><br>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <b>繁體中文</b> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
---
## 截圖
<div align="center">
<table>
<tr>
<td align="center">
<h3>桌面檢視</h3>
<img src="public/screenshots/desktop-main.png" alt="桌面介面" width="400">
<br>
<em>顯示專案總覽和聊天的主介面</em>
</td>
<td align="center">
<h3>行動裝置體驗</h3>
<img src="public/screenshots/mobile-chat.png" alt="行動裝置介面" width="250">
<br>
<em>具有觸控導覽的響應式行動裝置設計</em>
</td>
</tr>
<tr>
<td align="center" colspan="2">
<h3>CLI 選擇</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI 選擇" width="400">
<br>
<em>在 Claude Code、Gemini、Cursor CLI 與 Codex 之間進行選擇</em>
</td>
</tr>
</table>
</div>
## 功能
- **響應式設計** — 在桌面、平板和行動裝置上無縫運作,讓您隨時隨地使用 Agents
- **互動聊天介面** — 內建聊天 UI輕鬆與 Agents 交流
- **整合 Shell 終端機** — 透過內建 shell 功能直接存取 Agents CLI
- **檔案瀏覽器** — 互動式檔案樹,支援語法醒目提示與即時編輯
- **Git 瀏覽器** — 檢視、暫存並提交變更,還可切換分支
- **工作階段管理** — 恢復對話、管理多個工作階段並追蹤歷史紀錄
- **外掛系統** — 透過自訂分頁、後端服務與整合來擴充 CloudCLI。[開始建構 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI 整合** *(選用)* — 結合 AI 任務規劃、PRD 分析與工作流程自動化,實現進階專案管理
- **模型相容性** — 支援 Claude、GPT、Gemini 模型家族(完整支援列表可透過 `GET /api/providers/:provider/models` 介面取得)
## 快速開始
### CloudCLI Cloud推薦
無需本機設定即可快速啟動。提供可透過網路瀏覽器、行動應用程式、API 或慣用的 IDE 存取的完全容器化託管開發環境。
**[立即開始 CloudCLI Cloud](https://cloudcli.ai)**
### 自架(開源)
#### npm
啟動 CloudCLI UI只需一行 `npx`(需要 Node.js v22+
```bash
npx @cloudcli-ai/cloudcli
```
或進行全域安裝,便於日常使用:
```bash
npm install -g @cloudcli-ai/cloudcli
cloudcli
```
開啟 `http://localhost:3001`,系統會自動發現所有現有工作階段。
更多設定選項、PM2、遠端伺服器設定等請參閱 **[文件 →](https://cloudcli.ai/docs)**。
#### Docker Sandboxes實驗性
在隔離的沙箱中執行代理,具有虛擬機管理程式等級的隔離。預設啟動 Claude Code。需要 [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/)。
```bash
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
支援 Claude Code、Codex 和 Gemini CLI。詳情請參閱[沙箱文件](docker/)。
---
## 哪個選項更適合你?
CloudCLI UI 是 CloudCLI Cloud 的開源 UI 層。你可以在本機上自架它,也可以使用提供團隊功能與深入整合的 CloudCLI Cloud。
| | CloudCLI UI自架 | CloudCLI Cloud |
|---|---|---|
| **適合對象** | 需要為本機代理工作階段提供完整 UI 的開發者 | 需要部署在雲端,隨時從任何地方存取代理的團隊與開發者 |
| **存取方式** | 透過 `[yourip]:port` 在瀏覽器中存取 | 瀏覽器、任意 IDE、REST API、n8n |
| **設定** | `npx @cloudcli-ai/cloudcli` | 無需設定 |
| **機器需保持開機嗎** | 是 | 否 |
| **行動裝置存取** | 網路內任意瀏覽器 | 任意裝置(原生應用程式即將推出) |
| **可用工作階段** | 自動發現 `~/.claude` 中的所有工作階段 | 雲端環境內的工作階段 |
| **支援的 Agents** | Claude Code、Cursor CLI、Codex、Gemini CLI | Claude Code、Cursor CLI、Codex、Gemini CLI |
| **檔案瀏覽與 Git** | 內建於 UI | 內建於 UI |
| **MCP 設定** | UI 管理,與本機 `~/.claude` 設定同步 | UI 管理 |
| **IDE 存取** | 本機 IDE | 任何連線到雲端環境的 IDE |
| **REST API** | 是 | 是 |
| **n8n 節點** | 否 | 是 |
| **團隊共享** | 否 | 是 |
| **平台費用** | 免費開源 | 起價 $7/月 |
> 兩種方式都使用你自己的 AI 訂閱Claude、Cursor 等)— CloudCLI 提供環境,而非 AI。
---
## 安全與工具設定
**🔒 重要提示**:所有 Claude Code 工具預設**停用**,可防止潛在的有害操作自動執行。
### 啟用工具
1. **開啟工具設定** — 點擊側邊欄齒輪圖示
2. **選擇性啟用** — 僅啟用所需工具
3. **套用設定** — 偏好設定儲存在本機
<div align="center">
![工具設定彈出視窗](public/screenshots/tools-modal.png)
*工具設定介面 — 只啟用你需要的內容*
</div>
**建議做法**:先啟用基礎工具,再根據需要新增其他工具。隨時可以調整。
---
## 外掛
CloudCLI 配備外掛系統,允許你新增帶有自訂前端 UI 和選用 Node.js 後端的分頁。在 Settings > Plugins 中直接從 Git 儲存庫安裝外掛,或自行開發。
### 可用外掛
| 外掛 | 描述 |
|---|---|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示目前專案的檔案數、程式碼行數、檔案類型分佈、最大檔案以及最近修改的檔案 |
| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | 支援多分頁的完整 xterm.js 終端機 |
| **[Claude Watch](https://github.com/satsuki19980613/cloudcli-claude-watch)** | 監控長時間執行的 Claude Code 工作階段是否卡住,並提供程序控制 |
| **[CloudCLI Scheduler](https://github.com/grostim/cloudcli-cron)** | 建立工作區範圍的排程提示詞,並透過 Codex、Claude Code 或 Gemini CLI 等本機 CLI 執行 |
| **[PRISM CloudCLI](https://github.com/jakeefr/cloudcli-plugin-prism)** | 在 CloudCLI 中提供 Claude Code 工作階段智慧分析,包括 token 消耗可視化 |
| **[Sessions](https://github.com/strykereye2/cloudcli-plugin-session-manager)** | 檢視、管理並終止作用中的 Claude Code 工作階段 |
| **[Token Cost Calculator](https://github.com/NightmareAway/cloudcli-plugin-token-cost-calculator)** | 根據模型價格與 token 用量計算 API 成本,並支援模型價格預設 |
| **[Task Queue](https://github.com/TadMSTR/cloudcli-plugin-task-queue)** | 用於檢視、篩選和啟動代理任務的任務佇列儀表板 |
| **[GitHub Issues Board](https://github.com/szmidtpiotr/claude-github-issue)** | 用於 GitHub Issues 的看板,支援 TaskMaster 雙向同步和 /github-task CLI 技能自動安裝 |
### 自行建構
**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — Fork 該儲存庫以建構自己的外掛。範例包括前端渲染、即時上下文更新和 RPC 通訊。
**[外掛文件 →](https://cloudcli.ai/docs/plugin-overview)** — 提供外掛 API、清單格式、安全模型等完整指南。
---
## 常見問題
<details>
<summary>與 Claude Code Remote Control 有何不同?</summary>
Claude Code Remote Control 讓你傳送訊息到本機終端機中已經執行的工作階段。該方式要求你的機器保持開機,終端機保持開啟,中斷網路後約 10 分鐘工作階段會逾時。
CloudCLI UI 與 CloudCLI Cloud 是對 Claude Code 的擴充,而非旁觀 — MCP 伺服器、權限、設定、工作階段與 Claude Code 完全一致。
- **涵蓋全部工作階段** — CloudCLI UI 會自動掃描 `~/.claude` 資料夾中的每個工作階段。Remote Control 只暴露目前活動的工作階段。
- **設定統一** — 在 CloudCLI UI 中修改的 MCP、工具權限等設定會立即寫入 Claude Code。
- **支援更多 Agents** — Claude Code、Cursor CLI、Codex、Gemini CLI。
- **完整 UI** — 除了聊天介面還包括檔案瀏覽器、Git 整合、MCP 管理和 Shell 終端機。
- **CloudCLI Cloud 持續運作於雲端** — 關閉本機裝置也不會中斷代理執行,無需監控終端機。
</details>
<details>
<summary>需要額外購買 AI 訂閱嗎?</summary>
需要。CloudCLI 只提供環境。你仍需自行取得 Claude、Cursor、Codex 或 Gemini 訂閱。CloudCLI Cloud 從 $7/月起提供託管環境。
</details>
<details>
<summary>能在手機上使用 CloudCLI UI 嗎?</summary>
可以。自架時,在你的裝置上執行伺服器,然後在網路中的任意瀏覽器開啟 `[yourip]:port`。CloudCLI Cloud 可從任意裝置存取,內建原生應用程式也在開發中。
</details>
<details>
<summary>UI 中的變更會影響本機 Claude Code 設定嗎?</summary>
會的。自架模式下CloudCLI UI 讀取並寫入 Claude Code 使用的 `~/.claude` 設定。透過 UI 新增的 MCP 伺服器會立即在 Claude Code 中可見。
</details>
---
## 社群與支援
- **[文件](https://cloudcli.ai/docs)** — 安裝、設定、功能與疑難排解指南
- **[Discord](https://discord.gg/buxwujPNRE)** — 取得協助並與社群交流
- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — 回報 Bug 與建議功能
- **[貢獻指南](CONTRIBUTING.md)** — 如何參與專案貢獻
## 授權條款
GNU 通用公共授權條款 v3.0 — 詳見 [LICENSE](LICENSE) 檔案。
該專案為開源軟體,在 GPL v3 授權條款下可自由使用、修改與散布。
## 致謝
### 使用技術
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** — Anthropic 官方 CLI
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** — Cursor 官方 CLI
- **[Codex](https://developers.openai.com/codex)** — OpenAI Codex
- **[Gemini-CLI](https://geminicli.com/)** — Google Gemini CLI
- **[React](https://react.dev/)** — 使用者介面函式庫
- **[Vite](https://vitejs.dev/)** — 快速建構工具與開發伺服器
- **[Tailwind CSS](https://tailwindcss.com/)** — 實用優先 CSS 框架
- **[CodeMirror](https://codemirror.net/)** — 進階程式碼編輯器
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(選用)* — AI 驅動的專案管理與任務規劃
### 贊助商
- [Siteboon - AI powered website builder](https://siteboon.ai)
---
<div align="center">
<strong>為 Claude Code、Cursor 和 Codex 社群精心打造。</strong>
</div>

View File

@@ -1,89 +1,160 @@
# CloudCLI — Docker Sandbox Templates
<!-- Docker Hub short description (100 chars max): -->
<!-- Sandbox templates for running AI coding agents with a web & mobile IDE (Claude Code, Codex, Gemini) -->
Run AI coding agents with a full web IDE inside [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/).
# Sandboxed coding agents with a web & mobile IDE (CloudCLI)
Instead of a terminal-only experience, get a browser-based interface with chat, file explorer, git panel, shell, and MCP configuration — all running safely inside an isolated sandbox.
[Docker Sandbox](https://docs.docker.com/ai/sandboxes/) templates that add [CloudCLI](https://cloudcli.ai) on top of Claude Code, Codex, and Gemini CLI. You get a full web and mobile IDE accessible from any browser on any device.
## Available Templates
## Get started
| Template | Base Image | Agent |
|----------|-----------|-------|
| `cloudcli-ai/sandbox:claude-code` | `docker/sandbox-templates:claude-code` | Claude Code |
| `cloudcli-ai/sandbox:codex` | `docker/sandbox-templates:codex` | OpenAI Codex |
| `cloudcli-ai/sandbox:gemini` | `docker/sandbox-templates:gemini` | Gemini CLI |
### 1. Install the sbx CLI
## Quick Start
Docker Sandboxes run agents in isolated microVMs. Install the `sbx` CLI:
### 1. Start a sandbox with the template
- **macOS**: `brew install docker/tap/sbx`
- **Windows**: `winget install -h Docker.sbx`
- **Linux**: `sudo apt-get install docker-sbx`
Full instructions: [docs.docker.com/ai/sandboxes/get-started](https://docs.docker.com/ai/sandboxes/get-started/)
### 2. Store your API key
`sbx` manages credentials securely — your API key never enters the sandbox. Store it once:
```bash
sbx run --template docker.io/cloudcli-ai/sandbox:claude-code claude ~/my-project
sbx login
sbx secret set -g anthropic
```
### 2. Forward the UI port
### 3. Launch Claude Code
```bash
sbx ports <sandbox-name> --publish 3001:3001
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
```
### 3. Open the browser
Open **http://localhost:3001**. Set a password on first visit. Start building.
```
http://localhost:3001
### Using a different agent
Store the matching API key and pass `--agent`:
```bash
# OpenAI Codex
sbx secret set -g openai
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project --agent codex
# Gemini CLI
sbx secret set -g google
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project --agent gemini
```
On first visit you'll set a password — this protects the UI if the port is ever exposed beyond localhost.
### Available templates
## What You Get
| Agent | Template |
|-------|----------|
| **Claude Code** (default) | `docker.io/cloudcliai/sandbox:claude-code` |
| OpenAI Codex | `docker.io/cloudcliai/sandbox:codex` |
| Gemini CLI | `docker.io/cloudcliai/sandbox:gemini` |
- **Chat** — Rich conversation UI with markdown rendering, code blocks, and message history
- **Files** — Visual file tree with syntax-highlighted editor
- **Git** — Diff viewer, staging, branch switching, and commit — all visual
These are used with `--template` when running `sbx` directly (see [Advanced usage](#advanced-usage)).
## Managing sandboxes
```bash
sbx ls # List all sandboxes
sbx stop my-project # Stop (preserves state)
sbx start my-project # Restart a stopped sandbox
sbx rm my-project # Remove everything
sbx exec my-project bash # Open a shell inside the sandbox
```
If you install CloudCLI globally (`npm install -g @cloudcli-ai/cloudcli`), you can also use:
```bash
cloudcli sandbox ls
cloudcli sandbox start my-project # Restart and re-launch web UI
cloudcli sandbox logs my-project # View server logs
```
## What you get
- **Chat** — Markdown rendering, code blocks, message history
- **Files** — File tree with syntax-highlighted editor
- **Git** — Diff viewer, staging, branch switching, commits
- **Shell** — Built-in terminal emulator
- **MCP** — Configure Model Context Protocol servers through the UI
- **MCP** — Configure Model Context Protocol servers visually
- **Mobile** — Works on tablet and phone browsers
## Building Locally
All Dockerfiles share scripts from `shared/`. Build with the `docker/` directory as context:
```bash
# Claude Code variant
docker build -f docker/claude-code/Dockerfile -t cloudcli-sandbox:claude-code docker/
# Codex variant
docker build -f docker/codex/Dockerfile -t cloudcli-sandbox:codex docker/
# Gemini variant
docker build -f docker/gemini/Dockerfile -t cloudcli-sandbox:gemini docker/
```
## How It Works
Each template extends Docker's official sandbox base image and adds:
1. **Node.js 22** — Runtime for CloudCLI
2. **CloudCLI** — Installed globally via `npm install -g @cloudcli-ai/cloudcli`
3. **Auto-start** — The UI server starts in the background when the sandbox shell opens (port 3001)
The agent (Claude Code, Codex, or Gemini) comes from the base image. CloudCLI connects to it and provides the web interface on top.
Your project directory is mounted bidirectionally — edits propagate in real time, both ways.
## Configuration
| Environment Variable | Default | Description |
|---------------------|---------|-------------|
| `SERVER_PORT` | `3001` | Port for the web UI |
| `HOST` | `0.0.0.0` | Bind address |
| `DATABASE_PATH` | `~/.cloudcli/auth.db` | SQLite database location |
## Network Policies
If your sandbox uses restricted network policies, allow the UI port:
Set variables at creation time with `--env`:
```bash
sbx policy allow network "localhost:3001"
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project --env SERVER_PORT=8080
```
Or inside a running sandbox:
```bash
sbx exec my-project bash -c 'echo "export SERVER_PORT=8080" >> /etc/sandbox-persistent.sh'
```
Restart CloudCLI for changes to take effect:
```bash
sbx exec my-project bash -c 'pkill -f "server/index.js"'
sbx exec -d my-project cloudcli start --port 3001
```
| Variable | Default | Description |
|----------|---------|-------------|
| `SERVER_PORT` | `3001` | Web UI port |
| `HOST` | `0.0.0.0` | Bind address (must be `0.0.0.0` for `sbx ports`) |
| `DATABASE_PATH` | `~/.cloudcli/auth.db` | SQLite database location |
## Advanced usage
For branch mode, multiple workspaces, memory limits, or the terminal agent experience, use `sbx` with the template:
```bash
# Terminal agent + web UI
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/my-project --name my-project
sbx ports my-project --publish 3001:3001
# Branch mode (Git worktree isolation)
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/my-project --branch my-feature
# Multiple workspaces
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/project ~/shared-libs:ro
# Pass a prompt directly
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/my-project -- "Fix the auth bug"
```
CloudCLI auto-starts via `.bashrc` when using `sbx run`.
Full options in the [Docker Sandboxes usage guide](https://docs.docker.com/ai/sandboxes/usage/).
## Network policies
Sandboxes restrict outbound access by default. To reach host services from inside the sandbox:
```bash
sbx policy allow network localhost:11434
# Inside the sandbox: curl http://host.docker.internal:11434
```
The web UI itself doesn't need a policy — access it via `sbx ports`.
## Links
- [CloudCLI Cloud](https://cloudcli.ai) — fully managed, no setup required
- [Documentation](https://cloudcli.ai/docs) — full configuration guide
- [Discord](https://discord.gg/buxwujPNRE) — community support
- [GitHub](https://github.com/siteboon/claudecodeui) — source code and issues
## License
These templates are free and open-source under the same license as CloudCLI (AGPL-3.0-or-later).
AGPL-3.0-or-later

View File

@@ -5,7 +5,7 @@ COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh
RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh
USER agent
RUN npm install -g @cloudcli-ai/cloudcli
RUN npm install -g @cloudcli-ai/cloudcli && cloudcli --version
COPY shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
COPY --chown=agent:agent shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc

View File

@@ -5,7 +5,7 @@ COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh
RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh
USER agent
RUN npm install -g @cloudcli-ai/cloudcli
RUN npm install -g @cloudcli-ai/cloudcli && cloudcli --version
COPY shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
COPY --chown=agent:agent shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc

View File

@@ -5,7 +5,7 @@ COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh
RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh
USER agent
RUN npm install -g @cloudcli-ai/cloudcli
RUN npm install -g @cloudcli-ai/cloudcli && cloudcli --version
COPY shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
COPY --chown=agent:agent shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc

View File

@@ -1,13 +1,10 @@
#!/bin/bash
set -e
# Install Node.js 22 LTS
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
# Install Node.js + build tools needed for native modules (node-pty, better-sqlite3, bcrypt)
# Node.js + build tools for native modules + common dev tools
apt-get install -y --no-install-recommends \
nodejs build-essential python3 python3-setuptools \
# Install build tools needed for native modules (node-pty, better-sqlite3, bcrypt)
# Node.js is already provided by the sandbox base image
apt-get update && apt-get install -y --no-install-recommends \
build-essential python3 python3-setuptools \
jq ripgrep sqlite3 zip unzip tree vim-tiny
# Clean up apt cache to reduce image size

View File

@@ -4,19 +4,14 @@
# This script is sourced from ~/.bashrc on sandbox shell open.
if ! pgrep -f "server/index.js" > /dev/null 2>&1; then
# Start the pre-installed version immediately
nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 &
disown
# Check for updates in the background (non-blocking)
nohup npm update -g @cloudcli-ai/cloudcli > /tmp/cloudcli-update.log 2>&1 &
disown
echo ""
echo " CloudCLI is starting on port 3001..."
echo ""
echo " To access the web UI, forward the port:"
echo " sbx ports \$(hostname) --publish 3001:3001"
echo " Forward the port from another terminal:"
echo " sbx ports <sandbox-name> --publish 3001:3001"
echo ""
echo " Then open: http://localhost:3001"
echo ""

View File

@@ -0,0 +1,218 @@
# CloudCLI UI Nginx subpath deployment template.
#
# Purpose:
# Serve CloudCLI UI from a path prefix such as:
# http://localhost/ai/
# https://example.com/ai/
#
# CloudCLI itself still runs at the root of its own HTTP server, for example:
# http://127.0.0.1:3001/
#
# Nginx receives public requests under /ai, strips that prefix, and forwards the
# remaining path to CloudCLI. For example:
# /ai/ -> /
# /ai/session/abc -> /session/abc
# /ai/assets/index.js -> /assets/index.js
#
# Important Nginx limitation:
# Nginx does not allow variables in `location` matchers or `rewrite` regexes.
# The configurable variables below are still useful for proxy/filter values,
# but if you change /ai to a different subpath, also update every line marked:
# [SUBPATH LITERAL]
#
# To use a different subpath, replace these literal matchers:
# location = /ai
# location ^~ /ai/
# rewrite ^/ai(?<cloudcli_path>/.*)$ ...
#
# Recommended deployment shape:
# CloudCLI is the only app using /ai, while root paths /api, /ws, and /shell
# are also proxied because the current frontend still calls those endpoints
# with root-relative URLs.
worker_processes 1;
events {
# Maximum simultaneous connections handled by each worker process.
# The default is enough for local testing and small self-hosted deployments.
worker_connections 1024;
}
http {
# WebSocket requests include an Upgrade header. Normal HTTP requests do not.
# This map gives us the right Connection header for both cases:
# Upgrade present -> "upgrade"
# Upgrade absent -> "close"
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
# For HTTPS deployments, replace this with `listen 443 ssl http2;` and
# add ssl_certificate / ssl_certificate_key lines.
listen 80 default_server;
# Use your real hostname in production, for example:
# server_name cloudcli.example.com;
server_name localhost 127.0.0.1;
# ---- User settings -------------------------------------------------
#
# Public path prefix where users access CloudCLI.
# Do not add a trailing slash.
#
# This variable can be used in redirects and response rewrites. It
# cannot be used in `location` matchers, so update the [SUBPATH LITERAL]
# lines too if you change it.
set $cloudcli_subpath /ai;
# Private upstream URL where the CloudCLI server is listening.
# For a default local server this is usually http://127.0.0.1:3001.
set $cloudcli_upstream http://127.0.0.1:3001;
# Allow larger file uploads through the code editor/project file APIs.
client_max_body_size 200m;
# Redirect /ai to /ai/ so relative browser URL resolution is stable.
# [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath.
location = /ai {
return 301 $cloudcli_subpath/;
}
# Main prefixed CloudCLI UI route.
#
# [SUBPATH LITERAL] Change `/ai/` and the `^/ai` rewrite if you change
# $cloudcli_subpath.
location ^~ /ai/ {
# Strip the public subpath before proxying. CloudCLI expects to see
# root paths such as /, /session/:id, /assets/..., /manifest.json.
rewrite ^/ai(?<cloudcli_path>/.*)$ $cloudcli_path break;
# Forward the rewritten request to the private CloudCLI server.
proxy_pass $cloudcli_upstream;
# Use HTTP/1.1 so WebSocket upgrade requests can pass through if a
# browser reaches a socket endpoint under the subpath.
proxy_http_version 1.1;
# Preserve useful request metadata for logs and future app support.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
# WebSocket upgrade headers. Harmless for normal HTTP requests.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Long-running agent and terminal sessions can stay open for a long
# time, so avoid closing idle proxied connections too aggressively.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# Disable gzip from the upstream response so sub_filter can inspect
# and rewrite HTML/JSON/JS response bodies.
proxy_set_header Accept-Encoding "";
# Rewrite browser-visible root-relative URLs so the runtime can
# discover that the app is mounted under the subpath.
#
# Examples:
# href="/manifest.json" -> href="/ai/manifest.json"
# src="/assets/app.js" -> src="/ai/assets/app.js"
#
# These rewrites are important for React Router basename detection.
sub_filter_once off;
sub_filter_types
application/json
application/manifest+json
application/javascript
text/javascript;
sub_filter 'href="/' 'href="$cloudcli_subpath/';
sub_filter 'src="/' 'src="$cloudcli_subpath/';
# The production HTML and JS register the service worker at /sw.js.
# Rewrite that registration so the worker is served from /ai/sw.js.
sub_filter "register('/sw.js')" "register('$cloudcli_subpath/sw.js')";
sub_filter 'register("/sw.js")' 'register("$cloudcli_subpath/sw.js")';
# The manifest and service worker contain root-relative paths too.
# Rewriting them keeps PWA metadata and cached manifest requests
# under the same public subpath.
sub_filter '"start_url": "/"' '"start_url": "$cloudcli_subpath/"';
sub_filter '"scope": "/"' '"scope": "$cloudcli_subpath/"';
sub_filter '"src": "/' '"src": "$cloudcli_subpath/';
sub_filter "'/manifest.json'" "'$cloudcli_subpath/manifest.json'";
sub_filter '"/manifest.json"' '"$cloudcli_subpath/manifest.json"';
}
# Root API proxy.
#
# The current CloudCLI frontend calls APIs with root-relative URLs such
# as /api/auth/login. Keep this location unless the frontend becomes
# fully prefix-aware for API requests.
location ^~ /api/ {
proxy_pass $cloudcli_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Main app WebSocket proxy.
#
# The frontend opens /ws for realtime chat/session/task updates.
location /ws {
proxy_pass $cloudcli_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Shell WebSocket proxy.
#
# The browser terminal uses /shell. It requires the same WebSocket
# upgrade handling as /ws.
location /shell {
proxy_pass $cloudcli_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Optional health endpoint proxy used by the frontend version checker.
location = /health {
proxy_pass $cloudcli_upstream;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
}
}
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

260
electron/cloud.js Normal file
View File

@@ -0,0 +1,260 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { safeStorage } from 'electron';
const CLOUD_API_TIMEOUT_MS = 15000;
function encryptSecret(secret) {
if (!safeStorage.isEncryptionAvailable()) {
return { encrypted: false, value: secret };
}
return {
encrypted: true,
value: safeStorage.encryptString(secret).toString('base64'),
};
}
function decryptSecret(record) {
if (!record?.value) return null;
if (!record.encrypted) return record.value;
try {
return safeStorage.decryptString(Buffer.from(record.value, 'base64'));
} catch {
return null;
}
}
export class CloudController {
constructor({ storePath, controlPlaneUrl, callbackUrl, onChange }) {
this.storePath = storePath;
this.controlPlaneUrl = controlPlaneUrl;
this.callbackUrl = callbackUrl;
this.onChange = onChange;
this.cloudAccount = null;
this.cloudEnvironments = [];
this.authState = 'logged_out';
}
getAccount() {
return this.cloudAccount;
}
getAuthState() {
return this.authState;
}
getEnvironments() {
return this.cloudEnvironments;
}
getEnvironmentUrl(environment) {
return environment.access_url || `https://${environment.subdomain}.cloudcli.ai`;
}
async getEnvironmentLaunchUrl(environment) {
if (!environment?.id) {
return this.getEnvironmentUrl(environment);
}
const data = await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/launch`, {
method: 'POST',
});
return data.launch_url || data.environment_url || this.getEnvironmentUrl(environment);
}
findEnvironment(environmentId) {
return this.cloudEnvironments.find((item) => item.id === environmentId) || null;
}
async loadCloudAccount() {
try {
const raw = await fs.readFile(this.storePath, 'utf8');
const stored = JSON.parse(raw);
const apiKey = decryptSecret(stored.apiKey);
this.cloudAccount = {
deviceId: stored.deviceId || crypto.randomUUID(),
email: stored.email || null,
apiKey: apiKey || null,
};
this.authState = apiKey ? 'connected' : (stored.email ? 'expired' : 'logged_out');
return this.cloudAccount;
} catch {
this.cloudAccount = {
deviceId: crypto.randomUUID(),
email: null,
apiKey: null,
};
this.authState = 'logged_out';
return this.cloudAccount;
}
}
async saveCloudAccount(account) {
const payload = {
deviceId: account.deviceId || crypto.randomUUID(),
email: account.email || null,
apiKey: account.apiKey ? encryptSecret(account.apiKey) : null,
};
await fs.mkdir(path.dirname(this.storePath), { recursive: true });
await fs.writeFile(this.storePath, JSON.stringify(payload, null, 2), 'utf8');
this.cloudAccount = {
deviceId: payload.deviceId,
email: payload.email,
apiKey: account.apiKey || null,
};
this.authState = account.apiKey ? 'connected' : 'logged_out';
this.onChange?.();
return this.cloudAccount;
}
async clearCloudAccount() {
this.cloudAccount = {
deviceId: crypto.randomUUID(),
email: null,
apiKey: null,
};
this.cloudEnvironments = [];
this.authState = 'logged_out';
await fs.rm(this.storePath, { force: true });
this.onChange?.();
}
async invalidateCloudAccount() {
this.cloudEnvironments = [];
if (!this.cloudAccount) {
this.cloudAccount = {
deviceId: crypto.randomUUID(),
email: null,
apiKey: null,
};
} else {
this.cloudAccount = {
...this.cloudAccount,
apiKey: null,
};
}
this.authState = this.cloudAccount.email ? 'expired' : 'logged_out';
const payload = {
deviceId: this.cloudAccount.deviceId,
email: this.cloudAccount.email || null,
apiKey: null,
};
await fs.mkdir(path.dirname(this.storePath), { recursive: true });
await fs.writeFile(this.storePath, JSON.stringify(payload, null, 2), 'utf8');
this.onChange?.();
}
async cloudApi(pathname, options = {}) {
if (!this.cloudAccount?.apiKey) {
throw new Error('Connect your CloudCLI account first.');
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), CLOUD_API_TIMEOUT_MS);
let response;
try {
response = await fetch(`${this.controlPlaneUrl}${pathname}`, {
...options,
signal: options.signal || controller.signal,
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.cloudAccount.apiKey,
...(options.headers || {}),
},
});
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`CloudCLI API request timed out after ${Math.round(CLOUD_API_TIMEOUT_MS / 1000)} seconds.`);
}
throw error;
} finally {
clearTimeout(timeout);
}
const body = await response.json().catch(() => ({}));
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
await this.invalidateCloudAccount();
}
throw new Error(body.error || `CloudCLI API request failed: ${response.status}`);
}
return body;
}
async refreshCloudEnvironments() {
if (!this.cloudAccount?.apiKey) {
this.cloudEnvironments = [];
this.onChange?.();
return [];
}
const data = await this.cloudApi('/api/v1/environments');
this.cloudEnvironments = data.environments || [];
this.onChange?.();
return this.cloudEnvironments;
}
async startEnvironment(environment) {
await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/start`, {
method: 'POST',
});
}
async stopEnvironment(environment) {
await this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/stop`, {
method: 'POST',
});
}
async getEnvironmentCredentials(environment) {
return this.cloudApi(`/api/v1/environments/${encodeURIComponent(environment.id)}/credentials`);
}
async startEnvironmentAndWait(environment, timeoutMs) {
await this.startEnvironment(environment);
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const environments = await this.refreshCloudEnvironments();
const current = environments.find((env) => env.id === environment.id);
if (current?.status === 'running') {
return current;
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
throw new Error(`${environment.name} did not become ready in time.`);
}
buildConnectUrl() {
if (!this.cloudAccount?.deviceId) {
this.cloudAccount = {
deviceId: crypto.randomUUID(),
email: null,
apiKey: null,
};
}
const connectUrl = new URL('/auth/app-connect', this.controlPlaneUrl);
connectUrl.searchParams.set('device_id', this.cloudAccount.deviceId);
connectUrl.searchParams.set('callback_url', this.callbackUrl);
connectUrl.searchParams.set('app_surface', 'cloudcli_desktop');
connectUrl.searchParams.set('client_platform', 'desktop');
return connectUrl.toString();
}
async saveFromCallback({ apiKey, email }) {
await this.saveCloudAccount({
deviceId: this.cloudAccount?.deviceId || crypto.randomUUID(),
email,
apiKey,
});
return this.cloudAccount;
}
}

290
electron/computerAgent.js Normal file
View File

@@ -0,0 +1,290 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
const IPC_PREFIX = '@@CUAGENT@@';
const TARGET_STATUS_TIMEOUT_MS = 5000;
function getDesktopPath() {
const currentPath = process.env.PATH || '';
const commonPaths = process.platform === 'win32'
? []
: ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
return [...commonPaths, currentPath].filter(Boolean).join(path.delimiter);
}
function getNodeRuntime(isPackaged) {
if (isPackaged && process.versions.electron) {
return { command: process.execPath, env: { ELECTRON_RUN_AS_NODE: '1' } };
}
if (process.env.npm_node_execpath) {
return { command: process.env.npm_node_execpath, env: {} };
}
return { command: 'node', env: {} };
}
function toAgentWsUrl(httpUrl) {
try {
const parsed = new URL(httpUrl);
parsed.protocol = parsed.protocol === 'http:' ? 'ws:' : 'wss:';
parsed.pathname = '/desktop-agent';
parsed.search = '';
parsed.hash = '';
return parsed.toString();
} catch {
return null;
}
}
async function isComputerUseEnabledTarget(httpUrl, apiKey) {
let statusUrl;
try {
statusUrl = new URL('/api/computer-use/status', httpUrl).toString();
} catch {
return false;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TARGET_STATUS_TIMEOUT_MS);
try {
const response = await fetch(statusUrl, {
signal: controller.signal,
headers: apiKey ? { 'X-API-Key': apiKey } : undefined,
});
const body = await response.json().catch(() => null);
return response.ok && body?.success !== false && body?.data?.enabled === true;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
async function filterEnabledComputerUseTargets(targets, apiKey) {
const checks = await Promise.all(targets.map(async (target) => ({
target,
enabled: await isComputerUseEnabledTarget(target, apiKey),
})));
return checks.filter((item) => item.enabled).map((item) => item.target);
}
/**
* Keeps a Computer Use desktop agent connected to running cloud environments
* while desktop access is enabled.
*/
export class ComputerAgentController {
constructor({ appRoot, settingsPath, isPackaged = false, getRunningEnvironmentUrls, getApiKey, promptConsent, onChange }) {
this.appRoot = appRoot;
this.settingsPath = settingsPath;
this.isPackaged = isPackaged;
this.getRunningEnvironmentUrls = getRunningEnvironmentUrls;
this.getApiKey = getApiKey;
this.promptConsent = promptConsent;
this.onChange = onChange;
this.settings = { enabled: false, consentMode: 'ask' };
this.child = null;
this.connectedUrls = new Set();
this.currentTargets = [];
this.stdoutBuffer = '';
this.lastEvent = null;
this.lastError = null;
}
getSettings() {
return { ...this.settings };
}
getState() {
return {
enabled: this.settings.enabled,
consentMode: this.settings.consentMode,
running: Boolean(this.child),
connectedCount: this.connectedUrls.size,
targetCount: this.currentTargets.length,
targetUrls: [...this.currentTargets],
lastEvent: this.lastEvent,
lastError: this.lastError,
};
}
async loadSettings() {
try {
const raw = await fs.readFile(this.settingsPath, 'utf8');
const stored = JSON.parse(raw);
this.settings = {
enabled: Boolean(stored.enabled),
consentMode: stored.consentMode === 'auto' ? 'auto' : 'ask',
};
} catch {
this.settings = { enabled: false, consentMode: 'ask' };
}
return this.settings;
}
async saveSettings(next) {
this.settings = {
enabled: Boolean(next.enabled),
consentMode: next.consentMode === 'auto' ? 'auto' : 'ask',
};
await fs.mkdir(path.dirname(this.settingsPath), { recursive: true });
await fs.writeFile(this.settingsPath, JSON.stringify(this.settings, null, 2), 'utf8');
await this.sync();
this.onChange?.();
return this.settings;
}
async sync() {
const targets = this.settings.enabled ? (this.getRunningEnvironmentUrls?.() || []) : [];
const enabledTargets = this.settings.enabled ? await filterEnabledComputerUseTargets(targets, this.getApiKey?.() || '') : [];
const wsTargets = enabledTargets.map(toAgentWsUrl).filter(Boolean);
const sameTargets =
wsTargets.length === this.currentTargets.length &&
wsTargets.every((url) => this.currentTargets.includes(url));
if (!this.settings.enabled || wsTargets.length === 0) {
this.stop();
this.currentTargets = [];
this.lastEvent = this.settings.enabled ? 'no-targets' : 'disabled';
return;
}
if (this.child && sameTargets) {
return;
}
this.currentTargets = wsTargets;
this.lastEvent = 'restarting';
this.lastError = null;
this.restart(wsTargets);
}
restart(wsTargets) {
this.stop();
const agentEntry = process.env.CLOUDCLI_COMPUTER_AGENT_ENTRY
|| path.join(this.appRoot, 'dist-server', 'server', 'computer-use-agent.js');
const runtime = getNodeRuntime(this.isPackaged);
this.child = spawn(runtime.command, [agentEntry], {
cwd: this.appRoot,
env: {
...process.env,
...runtime.env,
PATH: getDesktopPath(),
CLOUDCLI_DESKTOP_AGENT_URLS: wsTargets.join(','),
CLOUDCLI_DESKTOP_AGENT_API_KEY: this.getApiKey?.() || '',
CLOUDCLI_COMPUTER_USE_CONSENT_MODE: this.settings.consentMode,
},
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
this.connectedUrls = new Set();
this.child.once('error', (error) => {
console.error('[ComputerAgent] failed to start:', error.message);
this.lastEvent = 'start-error';
this.lastError = error.message;
this.child = null;
this.onChange?.();
});
this.child.stdout?.on('data', (chunk) => this.handleStdout(String(chunk)));
this.child.stderr?.on('data', (chunk) => {
for (const line of String(chunk).split(/\r?\n/)) {
if (line.trim()) {
this.lastError = line.trim();
console.error('[ComputerAgent]', line);
}
}
});
this.child.once('exit', (code) => {
console.log(`[ComputerAgent] exited (code ${code ?? 'null'})`);
this.lastEvent = `exit:${code ?? 'null'}`;
this.child = null;
this.connectedUrls = new Set();
this.onChange?.();
});
this.onChange?.();
}
handleStdout(chunk) {
this.stdoutBuffer += chunk;
const lines = this.stdoutBuffer.split('\n');
this.stdoutBuffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith(IPC_PREFIX)) {
if (trimmed) console.log('[ComputerAgent]', trimmed);
continue;
}
let payload;
try {
payload = JSON.parse(trimmed.slice(IPC_PREFIX.length).trim());
} catch {
continue;
}
void this.handleAgentEvent(payload);
}
}
async handleAgentEvent(payload) {
switch (payload.type) {
case 'connected':
this.connectedUrls.add(payload.url);
this.lastEvent = 'connected';
this.lastError = null;
this.onChange?.();
break;
case 'disconnected':
this.connectedUrls.delete(payload.url);
this.lastEvent = 'disconnected';
this.onChange?.();
if (payload.reason && /computer use.*disabled/i.test(payload.reason)) {
void this.sync().catch((error) => {
this.lastError = error instanceof Error ? error.message : 'Failed to sync Computer Use targets.';
this.onChange?.();
});
}
break;
case 'starting':
this.lastEvent = 'starting';
this.lastError = null;
this.onChange?.();
break;
case 'error':
this.lastEvent = 'error';
this.lastError = payload.message || 'Computer agent error.';
this.onChange?.();
break;
case 'consent-request': {
const allow = await this.promptConsent?.(payload.sessionId);
this.sendToChild({ type: 'consent-response', sessionId: payload.sessionId, allow: Boolean(allow) });
break;
}
default:
break;
}
}
sendToChild(message) {
if (this.child?.stdin?.writable) {
this.child.stdin.write(`${IPC_PREFIX} ${JSON.stringify(message)}\n`);
}
}
revokeSession(sessionId) {
this.sendToChild({ type: 'revoke-session', sessionId });
}
stop() {
if (!this.child) return;
const child = this.child;
this.child = null;
this.connectedUrls = new Set();
try { child.kill('SIGTERM'); } catch { /* noop */ }
}
}

View File

@@ -0,0 +1,378 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { Notification } from 'electron';
import WebSocket from 'ws';
const RECONNECT_MIN_MS = 1000;
const RECONNECT_MAX_MS = 30000;
const TARGET_REGISTER_TIMEOUT_MS = 8000;
function toNotificationsWsUrl(httpUrl) {
try {
const parsed = new URL(httpUrl);
parsed.protocol = parsed.protocol === 'http:' ? 'ws:' : 'wss:';
parsed.pathname = '/desktop-notifications';
parsed.search = '';
parsed.hash = '';
return parsed.toString();
} catch {
return null;
}
}
function readJsonMessage(raw) {
try {
return JSON.parse(String(raw));
} catch {
return null;
}
}
async function requestJson(url, { method = 'POST', body = null, headers = {} } = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TARGET_REGISTER_TIMEOUT_MS);
try {
const response = await fetch(url, {
method,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...headers,
},
...(body == null ? {} : { body: JSON.stringify(body) }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error || `Request failed with status ${response.status}`);
}
return payload;
} finally {
clearTimeout(timeout);
}
}
export class DesktopNotificationsController {
constructor({
settingsPath,
appVersion,
appName,
getDeviceId,
getAccountEmail,
getRunningEnvironmentUrls,
getApiKey,
getAuthToken,
getIconPath,
openNotificationTarget,
onChange,
}) {
this.settingsPath = settingsPath;
this.appVersion = appVersion;
this.appName = appName;
this.getDeviceId = getDeviceId;
this.getAccountEmail = getAccountEmail;
this.getRunningEnvironmentUrls = getRunningEnvironmentUrls;
this.getApiKey = getApiKey;
this.getAuthToken = getAuthToken;
this.getIconPath = getIconPath;
this.openNotificationTarget = openNotificationTarget;
this.onChange = onChange;
this.settings = { enabled: false };
this.connections = new Map();
this.lastEvent = null;
this.lastError = null;
}
getState() {
const connectedTargets = [];
for (const [url, connection] of this.connections.entries()) {
if (connection.ws?.readyState === WebSocket.OPEN) {
connectedTargets.push(url);
}
}
return {
enabled: this.settings.enabled,
supported: Notification.isSupported(),
targetCount: this.connections.size,
connectedCount: connectedTargets.length,
connectedTargets,
lastEvent: this.lastEvent,
lastError: this.lastError,
};
}
async loadSettings() {
try {
const raw = await fs.readFile(this.settingsPath, 'utf8');
const stored = JSON.parse(raw);
this.settings = { enabled: Boolean(stored.enabled) };
} catch {
this.settings = { enabled: false };
}
return this.settings;
}
async saveSettings(next) {
const enabled = Boolean(next?.enabled);
if (!enabled && this.settings.enabled) {
await this.disableCurrentTargets();
}
this.settings = { enabled };
await fs.mkdir(path.dirname(this.settingsPath), { recursive: true });
await fs.writeFile(this.settingsPath, JSON.stringify(this.settings, null, 2), 'utf8');
await this.sync();
this.onChange?.();
return this.settings;
}
async sync() {
if (!this.settings.enabled) {
this.stop();
this.lastEvent = 'disabled';
this.onChange?.();
return;
}
if (!Notification.isSupported()) {
this.stop();
this.lastEvent = 'unsupported';
this.lastError = 'Native notifications are not supported on this system.';
this.onChange?.();
return;
}
const deviceId = this.getDeviceId?.();
if (!deviceId) {
this.stop();
this.lastEvent = 'missing-device';
this.lastError = 'Connect a CloudCLI account before enabling desktop notifications.';
this.onChange?.();
return;
}
const targets = (this.getRunningEnvironmentUrls?.() || [])
.map((httpUrl) => ({
httpUrl,
wsUrl: toNotificationsWsUrl(httpUrl),
}))
.filter((target) => target.wsUrl);
const nextWsUrls = new Set(targets.map((target) => target.wsUrl));
for (const [wsUrl, connection] of this.connections.entries()) {
if (!nextWsUrls.has(wsUrl)) {
this.closeConnection(connection);
this.connections.delete(wsUrl);
}
}
for (const target of targets) {
if (!this.connections.has(target.wsUrl)) {
void this.connect(target).catch((error) => {
this.lastEvent = 'connect-error';
this.lastError = error instanceof Error ? error.message : String(error);
this.onChange?.();
});
}
}
this.lastEvent = targets.length ? 'sync' : 'no-targets';
this.onChange?.();
}
async connect(target, attempt = 0) {
const existing = this.connections.get(target.wsUrl);
if (existing?.ws && [WebSocket.CONNECTING, WebSocket.OPEN].includes(existing.ws.readyState)) {
return;
}
const connection = {
...target,
ws: null,
reconnectTimer: null,
closed: false,
attempt,
};
this.connections.set(target.wsUrl, connection);
const headers = await this.getTargetAuthHeaders(target.httpUrl);
if (connection.closed || this.connections.get(target.wsUrl) !== connection) {
return;
}
const ws = new WebSocket(target.wsUrl, { headers: Object.keys(headers).length ? headers : undefined });
connection.ws = ws;
ws.on('open', async () => {
try {
await this.registerTarget(target.httpUrl);
ws.send(JSON.stringify({
type: 'register',
deviceId: this.getDeviceId?.(),
label: this.getAccountEmail?.() || this.appName,
platform: process.platform,
appVersion: this.appVersion,
}));
connection.attempt = 0;
this.lastEvent = 'connected';
this.lastError = null;
this.onChange?.();
} catch (error) {
this.lastEvent = 'register-error';
this.lastError = error instanceof Error ? error.message : String(error);
this.onChange?.();
try { ws.close(); } catch {}
}
});
ws.on('message', (raw) => this.handleMessage(target, ws, raw));
ws.on('close', () => this.scheduleReconnect(target.wsUrl));
ws.on('error', (error) => {
this.lastEvent = 'socket-error';
this.lastError = error instanceof Error ? error.message : String(error);
this.onChange?.();
});
}
async registerTarget(httpUrl) {
const url = new URL('/api/notifications/endpoints/current', httpUrl).toString();
await requestJson(url, {
method: 'POST',
headers: await this.getTargetAuthHeaders(httpUrl),
body: {
channel: 'desktop',
endpointId: this.getDeviceId?.(),
label: this.getAccountEmail?.() || this.appName,
metadata: {
platform: process.platform,
appVersion: this.appVersion,
},
enabled: true,
},
});
}
async disableCurrentTargets() {
const deviceId = this.getDeviceId?.();
if (!deviceId) return;
const targets = new Set([
...[...this.connections.values()].map((connection) => connection.httpUrl).filter(Boolean),
...(this.getRunningEnvironmentUrls?.() || []),
]);
const results = await Promise.allSettled([...targets].map(async (httpUrl) => {
const url = new URL(`/api/notifications/endpoints/desktop/${encodeURIComponent(deviceId)}`, httpUrl).toString();
await requestJson(url, {
method: 'PATCH',
headers: await this.getTargetAuthHeaders(httpUrl),
body: { enabled: false },
});
}));
const rejected = results.find((result) => result.status === 'rejected');
if (rejected) {
this.lastEvent = 'disable-endpoint-error';
this.lastError = rejected.reason instanceof Error ? rejected.reason.message : String(rejected.reason);
}
}
async getTargetAuthHeaders(httpUrl) {
const headers = {};
const apiKey = this.getApiKey?.();
if (apiKey) {
headers['X-API-Key'] = apiKey;
}
const authToken = await Promise.resolve(this.getAuthToken?.(httpUrl)).catch(() => null);
if (authToken) {
headers.Authorization = `Bearer ${authToken}`;
}
return headers;
}
handleMessage(target, ws, raw) {
const message = readJsonMessage(raw);
if (!message || message.type !== 'notification' || !message.payload) {
return;
}
const shown = this.showNativeNotification(target, message.payload);
if (shown && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'notification_ack',
id: message.id || message.payload?.data?.tag || null,
action: 'shown',
}));
}
}
showNativeNotification(target, payload) {
if (!Notification.isSupported()) return false;
const notification = new Notification({
title: payload.title || this.appName,
body: payload.body || '',
icon: this.getIconPath?.(),
silent: false,
});
notification.on('click', () => {
void this.openNotificationTarget?.({
environmentUrl: target.httpUrl,
sessionId: payload.data?.sessionId || null,
provider: payload.data?.provider || null,
}).catch((error) => {
this.lastEvent = 'click-error';
this.lastError = error instanceof Error ? error.message : String(error);
this.onChange?.();
});
});
notification.show();
this.lastEvent = 'notification-shown';
this.lastError = null;
this.onChange?.();
return true;
}
scheduleReconnect(wsUrl) {
const connection = this.connections.get(wsUrl);
if (!connection || connection.closed || !this.settings.enabled) {
return;
}
const attempt = connection.attempt + 1;
connection.attempt = attempt;
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * (2 ** Math.min(attempt, 5)));
connection.reconnectTimer = setTimeout(() => {
if (!this.connections.has(wsUrl) || !this.settings.enabled) return;
void this.connect({
httpUrl: connection.httpUrl,
wsUrl: connection.wsUrl,
}, attempt).catch((error) => {
this.lastEvent = 'connect-error';
this.lastError = error instanceof Error ? error.message : String(error);
this.onChange?.();
});
}, delay);
this.lastEvent = 'reconnecting';
this.onChange?.();
}
closeConnection(connection) {
connection.closed = true;
if (connection.reconnectTimer) {
clearTimeout(connection.reconnectTimer);
connection.reconnectTimer = null;
}
try { connection.ws?.close(); } catch {}
}
stop() {
for (const connection of this.connections.values()) {
this.closeConnection(connection);
}
this.connections.clear();
this.onChange?.();
}
}

781
electron/desktopWindow.js Normal file
View File

@@ -0,0 +1,781 @@
import { BrowserWindow, Menu, Tray, clipboard, nativeImage, nativeTheme, session, webContents as electronWebContents } from 'electron';
import { ViewHost } from './viewHost.js';
const TITLEBAR_HEIGHT = 44;
const AUTH_TOKEN_STORAGE_KEY = 'auth-token';
// TODO: Re-enable Computer Use menus after fixing the MCP server connection
// between the desktop app and the web UI.
const COMPUTER_USE_MENUS_ENABLED = false;
function isAllowedPermissionOrigin(sourceUrl, controlPlaneUrl) {
try {
const source = new URL(sourceUrl);
if ((source.hostname === '127.0.0.1' || source.hostname === 'localhost') && source.protocol === 'http:') {
return true;
}
if (source.protocol !== 'https:') {
return false;
}
const controlPlane = new URL(controlPlaneUrl);
return source.origin === controlPlane.origin || source.hostname.endsWith('.cloudcli.ai');
} catch {
return false;
}
}
function getWebContentsProcessId(contents) {
return {
osProcessId: typeof contents.getOSProcessId === 'function' ? contents.getOSProcessId() : null,
processId: typeof contents.getProcessId === 'function' ? contents.getProcessId() : null,
};
}
export class DesktopWindowManager {
constructor({
appName,
getWindowIconPath,
getLauncherPath,
getPreloadPath,
openExternalUrl,
getDesktopState,
getDisplayTargetName,
getRemoteEnvironmentMenuItems,
getCloudState,
getLocalState,
actions,
tabs,
}) {
this.appName = appName;
this.getWindowIconPath = getWindowIconPath;
this.getLauncherPath = getLauncherPath;
this.getPreloadPath = getPreloadPath;
this.openExternalUrl = openExternalUrl;
this.getDesktopState = getDesktopState;
this.getDisplayTargetName = getDisplayTargetName;
this.getRemoteEnvironmentMenuItems = getRemoteEnvironmentMenuItems;
this.getCloudState = getCloudState;
this.getLocalState = getLocalState;
this.actions = actions;
this.tabs = tabs;
this.mainWindow = null;
this.settingsWindow = null;
this.tray = null;
this.launcherLoaded = false;
this.viewHost = new ViewHost({
appName: this.appName,
getMainWindow: () => this.mainWindow,
getContentViewBounds: () => this.getContentViewBounds(),
getPreloadPath: this.getPreloadPath,
openExternalUrl: this.openExternalUrl,
showError: this.actions.showError,
});
}
getMainWindow() {
return this.mainWindow;
}
getTrayImage() {
const image = nativeImage.createFromPath(this.getWindowIconPath());
return image.resize({ width: 18, height: 18 });
}
getContentViewBounds() {
if (!this.mainWindow) return { x: 0, y: TITLEBAR_HEIGHT, width: 0, height: 0 };
const [width, height] = this.mainWindow.getContentSize();
return {
x: 0,
y: TITLEBAR_HEIGHT,
width,
height: Math.max(0, height - TITLEBAR_HEIGHT),
};
}
detachActiveContentView() {
this.viewHost.detachAll();
}
async showTabPlaceholder(target, message) {
const tabId = this.tabs.getTabIdForTarget(target);
await this.viewHost.showTabPlaceholder(tabId, target, message);
}
async showLocalStartupTarget(target, logs) {
const tabId = this.tabs.getTabIdForTarget(target);
await this.viewHost.showLocalStartupTarget(tabId, target, logs);
}
async showContentTarget(target) {
const tabId = this.tabs.getTabIdForTarget(target);
await this.viewHost.showContentTarget(tabId, target);
}
destroyTabView(tabId) {
this.viewHost.destroyTabView(tabId);
}
emitDesktopState() {
const state = this.getDesktopState();
if (this.mainWindow && !this.mainWindow.webContents.isDestroyed()) {
this.mainWindow.webContents.send('cloudcli-desktop:state-updated', state);
}
if (this.settingsWindow && !this.settingsWindow.webContents.isDestroyed()) {
this.settingsWindow.webContents.send('cloudcli-desktop:state-updated', state);
}
}
emitLauncherCommand(command) {
if (!this.mainWindow || this.mainWindow.webContents.isDestroyed()) return;
this.mainWindow.webContents.send('cloudcli-desktop:launcher-command', command);
}
emitSettingsCommand(command) {
if (!this.settingsWindow || this.settingsWindow.webContents.isDestroyed()) return;
this.settingsWindow.webContents.send('cloudcli-desktop:launcher-command', command);
}
syncSettingsWindowBounds() {
if (!this.mainWindow || !this.settingsWindow || this.settingsWindow.isDestroyed()) return;
this.settingsWindow.setBounds(this.mainWindow.getBounds());
}
async ensureSettingsWindow(sheet = 'desktop-settings') {
if (!this.mainWindow) return null;
if (this.settingsWindow && !this.settingsWindow.isDestroyed()) {
this.syncSettingsWindowBounds();
this.emitSettingsCommand({ type: 'open-sheet', sheet });
this.settingsWindow.focus();
return this.settingsWindow;
}
this.settingsWindow = new BrowserWindow({
parent: this.mainWindow,
show: false,
frame: false,
transparent: true,
hasShadow: false,
resizable: false,
minimizable: false,
maximizable: false,
fullscreenable: false,
movable: false,
skipTaskbar: true,
backgroundColor: '#00000000',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
preload: this.getPreloadPath(),
},
});
this.syncSettingsWindowBounds();
this.viewHost.configureChildWebContents(this.settingsWindow.webContents);
this.settingsWindow.once('ready-to-show', () => this.settingsWindow?.show());
this.settingsWindow.on('closed', () => {
this.settingsWindow = null;
});
await this.settingsWindow.loadFile(this.getLauncherPath(), {
query: { modal: '1', sheet },
});
return this.settingsWindow;
}
closeSettingsWindow() {
if (!this.settingsWindow || this.settingsWindow.isDestroyed()) return;
this.settingsWindow.close();
}
async showTarget(target, { trackTab = true } = {}) {
if (!this.mainWindow) return;
if (trackTab) {
this.tabs.upsertTarget(target);
}
this.actions.setActiveTarget(target);
this.buildAppMenu();
this.mainWindow.setTitle(`${this.appName} - ${target.name}`);
const finalUrl = await this.showContentTarget(target);
this.emitDesktopState();
return finalUrl;
}
async showLauncher() {
if (!this.mainWindow) return;
const target = { kind: 'launcher', name: this.appName, url: null };
this.tabs.upsertTarget(target);
this.actions.setActiveTarget(target);
this.detachActiveContentView();
this.buildAppMenu();
this.mainWindow.setTitle(this.appName);
this.mainWindow.webContents.focus();
if (!this.launcherLoaded) {
await this.mainWindow.loadFile(this.getLauncherPath());
this.launcherLoaded = true;
} else {
this.emitDesktopState();
}
}
async switchDesktopTab(tabId) {
const tab = this.tabs.activate(tabId);
if (!tab || !this.mainWindow) return this.getDesktopState();
if (tab.id === 'home' || tab.kind === 'launcher') {
await this.showLauncher();
return this.getDesktopState();
}
if (!tab.target?.url) {
throw new Error('This tab does not have a target URL.');
}
await this.showTarget(tab.target, { trackTab: false });
return this.getDesktopState();
}
async reloadActiveTab() {
const activeTab = this.tabs.getActiveTab();
if (!activeTab || activeTab.id === 'home' || activeTab.kind === 'launcher') {
this.emitDesktopState();
return this.getDesktopState();
}
const reloaded = this.viewHost.reloadTab(activeTab.id);
if (!reloaded && activeTab.target?.url) {
await this.showTarget(activeTab.target, { trackTab: false });
}
this.emitDesktopState();
return this.getDesktopState();
}
async navigateActiveView(url) {
const navigated = await this.viewHost.navigateActiveView(url);
this.emitDesktopState();
return navigated;
}
async readAuthTokenForTarget(url) {
return this.viewHost.readLocalStorageValueForOrigin(url, AUTH_TOKEN_STORAGE_KEY);
}
openActiveTabDevTools() {
if (this.viewHost.openActiveViewDevTools()) return;
void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before opening active tab DevTools.'));
}
reloadActiveBrowserViewForDiagnostics() {
if (this.viewHost.reloadActiveView()) return;
void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before reloading the active BrowserView.'));
}
detachActiveBrowserViewForDiagnostics() {
if (this.viewHost.detachActiveView()) return;
void this.actions.showError('No active BrowserView', new Error('Switch to a non-launcher tab before detaching the active BrowserView.'));
}
copyWebContentsDiagnostics() {
const tabViewDiagnostics = this.viewHost.getTabViewDiagnostics();
const tabViewByContentsId = new Map(
tabViewDiagnostics
.filter((item) => item.webContentsId != null)
.map((item) => [item.webContentsId, item])
);
const rows = electronWebContents.getAllWebContents().map((contents) => {
const destroyed = contents.isDestroyed();
const processIds = destroyed ? { osProcessId: null, processId: null } : getWebContentsProcessId(contents);
const tabView = tabViewByContentsId.get(contents.id);
let owner = 'unknown';
if (this.mainWindow?.webContents?.id === contents.id) {
owner = 'main-window';
} else if (this.settingsWindow?.webContents?.id === contents.id) {
owner = 'settings-window';
} else if (tabView) {
owner = `browser-view:${tabView.tabId}`;
}
return {
id: contents.id,
owner,
osProcessId: processIds.osProcessId,
processId: processIds.processId,
url: destroyed ? null : contents.getURL(),
title: destroyed ? null : contents.getTitle(),
destroyed,
focused: destroyed || typeof contents.isFocused !== 'function' ? false : contents.isFocused(),
attached: tabView ? tabView.attached : null,
active: tabView ? tabView.active : null,
};
});
const activeTab = this.tabs.getActiveTab();
const diagnostics = {
generatedAt: new Date().toISOString(),
activeTabId: this.tabs.activeTabId,
activeTab: activeTab
? {
id: activeTab.id,
title: activeTab.title,
kind: activeTab.kind,
targetUrl: activeTab.target?.url || null,
}
: null,
tabViews: tabViewDiagnostics,
webContents: rows,
};
clipboard.writeText(JSON.stringify(diagnostics, null, 2));
}
async closeDesktopTab(tabId) {
const tab = this.tabs.remove(tabId);
if (!tab) return this.getDesktopState();
this.destroyTabView(tabId);
if (this.tabs.activeTabId === 'home') {
await this.showLauncher();
} else {
this.emitDesktopState();
}
return this.getDesktopState();
}
buildEnvironmentActionsSubmenu(environment) {
const items = [];
const statusSuffix = environment.status === 'running' ? '' : ` (${environment.status})`;
items.push({
label: 'Open Environment',
click: () => void this.actions.openEnvironmentInDesktop(environment)
.catch((error) => this.actions.showError(`Could not open ${environment.name || environment.subdomain}${statusSuffix}`, error)),
});
items.push({
label: 'Open in Browser',
click: () => void this.actions.openEnvironmentInBrowser(environment)
.catch((error) => this.actions.showError('Could not open environment in browser', error)),
});
items.push({
label: 'Open in VS Code',
click: () => void this.actions.openEnvironmentInIde(environment, 'vscode')
.catch((error) => this.actions.showError('Could not open environment in VS Code', error)),
});
items.push({
label: 'Open in Cursor',
click: () => void this.actions.openEnvironmentInIde(environment, 'cursor')
.catch((error) => this.actions.showError('Could not open environment in Cursor', error)),
});
items.push({
label: 'Open SSH Terminal',
click: () => void this.actions.openEnvironmentInSsh(environment)
.catch((error) => this.actions.showError('Could not open SSH terminal', error)),
});
items.push({
label: 'Copy Mobile/Web URL',
click: () => this.actions.copyText(this.actions.getEnvironmentUrl(environment)),
});
if (environment.status !== 'running') {
items.unshift({
label: environment.status === 'paused' ? 'Resume' : 'Start',
click: () => void this.actions.startEnvironment(environment)
.catch((error) => this.actions.showError('Could not start environment', error)),
});
}
if (environment.status === 'running') {
items.push({
label: 'Stop',
click: () => void this.actions.stopEnvironment(environment)
.catch((error) => this.actions.showError('Could not stop environment', error)),
});
}
return items;
}
buildTrayEnvironmentSection() {
const cloudState = this.getCloudState();
if (!cloudState.account?.apiKey) {
return [
{
label: cloudState.account?.email ? `Reconnect ${cloudState.account.email}` : 'Login',
click: () => void this.actions.connectCloudAccount()
.catch((error) => this.actions.showError('Could not connect CloudCLI account', error)),
},
];
}
if (!cloudState.environments.length) {
return [{ label: 'No environments found', enabled: false }];
}
return cloudState.environments.map((environment) => ({
label: `${environment.name || environment.subdomain} - ${environment.status}`,
submenu: this.buildEnvironmentActionsSubmenu(environment),
}));
}
buildAppMenu() {
if (!this.mainWindow) return;
const cloudState = this.getCloudState();
const localState = this.getLocalState();
const remoteItems = this.getRemoteEnvironmentMenuItems();
const cloudAccountLabel = cloudState.account?.apiKey
? (cloudState.account?.email ? `Connected: ${cloudState.account.email}` : 'CloudCLI Connected')
: (cloudState.account?.email ? `Reconnect: ${cloudState.account.email}` : 'Connect CloudCLI Account...');
const template = [
{
label: this.appName,
submenu: [
{ label: `About ${this.appName}`, role: 'about' },
{ type: 'separator' },
{
label: 'Show Launcher',
accelerator: 'CmdOrCtrl+Shift+L',
click: () => void this.showLauncher().catch((error) => this.actions.showError('Could not show launcher', error)),
},
{
label: 'Switch Environment',
accelerator: 'CmdOrCtrl+Shift+E',
click: () => void this.actions.showEnvironmentPicker().catch((error) => this.actions.showError('Could not switch environment', error)),
},
{ type: 'separator' },
{
label: 'Services',
visible: COMPUTER_USE_MENUS_ENABLED,
submenu: [
{
label: 'Computer Use',
click: () => void this.showDesktopSettings(),
},
],
},
{
label: 'Diagnostics',
submenu: [
{
label: 'Copy Diagnostics',
click: () => void this.actions.copyDiagnostics(),
},
],
},
{ type: 'separator' },
{
label: process.platform === 'darwin' ? `Hide ${this.appName}` : 'Hide',
role: 'hide',
visible: process.platform === 'darwin',
},
{ label: 'Hide Others', role: 'hideOthers', visible: process.platform === 'darwin' },
{ label: 'Show All', role: 'unhide', visible: process.platform === 'darwin' },
{ type: 'separator', visible: process.platform === 'darwin' },
{ label: `Quit ${this.appName}`, accelerator: 'CmdOrCtrl+Q', role: 'quit' },
],
},
{
label: 'Environment',
submenu: [
{
label: 'Show Launcher',
accelerator: 'CmdOrCtrl+Shift+L',
click: () => void this.showLauncher().catch((error) => this.actions.showError('Could not show launcher', error)),
},
{
label: 'Switch Environment',
accelerator: 'CmdOrCtrl+Shift+E',
click: () => void this.actions.showEnvironmentPicker().catch((error) => this.actions.showError('Could not switch environment', error)),
},
{ type: 'separator' },
{
label: 'Open Local CloudCLI',
accelerator: 'CmdOrCtrl+L',
click: () => void this.actions.openLocalInDesktop().catch((error) => this.actions.showError('Could not open local CloudCLI', error)),
},
{
label: 'Open Local Web UI in Browser',
accelerator: 'CmdOrCtrl+Shift+W',
click: () => void this.actions.openLocalWebUi().catch((error) => this.actions.showError('Could not open local web UI', error)),
},
{
label: 'Copy Local Web URL',
accelerator: 'CmdOrCtrl+Shift+U',
click: () => void this.actions.copyLocalWebUrl().catch((error) => this.actions.showError('Could not copy local web URL', error)),
},
{ type: 'separator' },
{
label: 'Keep Local Server Running After Quit',
type: 'checkbox',
checked: localState.desktopSettings.keepLocalServerRunning,
click: (menuItem) => void this.actions.updateDesktopSetting('keepLocalServerRunning', menuItem.checked)
.catch((error) => this.actions.showError('Could not update desktop setting', error)),
},
{
label: 'Allow LAN Access to Local Server',
type: 'checkbox',
checked: localState.desktopSettings.exposeLocalServerOnNetwork,
click: (menuItem) => void this.actions.updateDesktopSetting('exposeLocalServerOnNetwork', menuItem.checked)
.catch((error) => this.actions.showError('Could not update desktop setting', error)),
},
],
},
{
label: 'Cloud',
submenu: [
{
label: cloudAccountLabel,
accelerator: 'CmdOrCtrl+Shift+C',
click: () => void this.actions.connectCloudAccount().catch((error) => this.actions.showError('Could not connect CloudCLI account', error)),
},
{
label: 'Refresh Cloud Environments',
click: () => void this.actions.refreshCloudEnvironments().catch((error) => this.actions.showError('Could not load CloudCLI environments', error)),
enabled: Boolean(cloudState.account?.apiKey),
},
{
label: 'Logout CloudCLI Account',
click: () => void this.actions.clearCloudAccount().catch((error) => this.actions.showError('Could not logout', error)),
enabled: Boolean(cloudState.account?.apiKey),
},
{ type: 'separator' },
{
label: 'Remote Environments',
submenu: remoteItems,
},
],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{
label: 'Open Active Tab DevTools',
click: () => this.openActiveTabDevTools(),
},
{
label: 'Copy WebContents Diagnostics',
click: () => this.copyWebContentsDiagnostics(),
},
{
label: 'Reload Active BrowserView',
click: () => this.reloadActiveBrowserViewForDiagnostics(),
},
{
label: 'Detach Active BrowserView',
click: () => this.detachActiveBrowserViewForDiagnostics(),
},
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
...(process.platform === 'darwin' ? [{ type: 'separator' }, { role: 'front' }] : []),
],
},
{
label: 'Help',
submenu: [
{
label: 'Open cloudcli.ai',
click: () => void this.actions.openCloudDashboard(),
},
{
label: 'Copy Diagnostics',
click: () => void this.actions.copyDiagnostics(),
},
],
},
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
this.buildTrayMenu();
}
buildTrayMenu() {
if (!this.tray) return;
const cloudState = this.getCloudState();
const localState = this.getLocalState();
const template = [
{
label: 'Local',
submenu: [
{
label: localState.localServerRunning ? 'Open Local in CloudCLI' : 'Start Local in CloudCLI',
click: () => void this.actions.openLocalInDesktop().catch((error) => this.actions.showError('Could not open local CloudCLI', error)),
},
{
label: 'Open Local in Browser',
click: () => void this.actions.openLocalWebUi().catch((error) => this.actions.showError('Could not open local web UI', error)),
},
{
label: 'Copy Local URL',
click: () => void this.actions.copyLocalWebUrl().catch((error) => this.actions.showError('Could not copy local web URL', error)),
},
],
},
{
label: 'Cloud Environments',
submenu: this.buildTrayEnvironmentSection(),
},
{ type: 'separator' },
{
label: cloudState.account?.email ? `Connected: ${cloudState.account.email}` : 'Login',
click: () => void this.actions.connectCloudAccount().catch((error) => this.actions.showError('Could not connect CloudCLI account', error)),
},
{
label: 'Logout CloudCLI Account',
click: () => void this.actions.clearCloudAccount().catch((error) => this.actions.showError('Could not logout', error)),
enabled: Boolean(cloudState.account?.apiKey),
},
{ type: 'separator' },
{
label: `Quit ${this.appName}`,
role: 'quit',
},
];
this.tray.setToolTip(`${this.appName}${this.actions.getActiveTarget()?.name ? ` - ${this.actions.getActiveTarget().name}` : ''}`);
this.tray.setContextMenu(Menu.buildFromTemplate(template));
}
async showDesktopSettings() {
if (!this.mainWindow) return this.getDesktopState();
await this.ensureSettingsWindow('desktop-settings');
return this.getDesktopState();
}
async showLocalSettings() {
if (!this.mainWindow) return this.getDesktopState();
await this.ensureSettingsWindow('local-settings');
return this.getDesktopState();
}
async showActiveEnvironmentActionsMenu() {
if (!this.mainWindow) return this.getDesktopState();
const activeTarget = this.actions.getActiveTarget();
if (activeTarget?.kind !== 'remote') return this.getDesktopState();
const environment = this.getCloudState().environments.find((item) => item.id === activeTarget.id);
if (!environment) return this.getDesktopState();
const menu = Menu.buildFromTemplate(this.buildEnvironmentActionsSubmenu(environment));
menu.popup({ window: this.mainWindow });
return this.getDesktopState();
}
async showEnvironmentActionsMenu(environmentId) {
if (!this.mainWindow) return this.getDesktopState();
const environment = this.getCloudState().environments.find((item) => item.id === environmentId);
if (!environment) return this.getDesktopState();
const menu = Menu.buildFromTemplate(this.buildEnvironmentActionsSubmenu(environment));
menu.popup({ window: this.mainWindow });
return this.getDesktopState();
}
configurePermissions() {
const isAllowedPermission = (webContents, permission) => {
const sourceUrl = webContents.getURL();
const allowedPermissions = new Set(['clipboard-read', 'media', 'notifications']);
return isAllowedPermissionOrigin(sourceUrl, this.getCloudState().controlPlaneUrl) && allowedPermissions.has(permission);
};
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
callback(isAllowedPermission(webContents, permission));
});
session.defaultSession.setPermissionCheckHandler((webContents, permission) => {
if (!webContents) return false;
return isAllowedPermission(webContents, permission);
});
}
createTray() {
if (this.tray) return;
this.tray = new Tray(this.getTrayImage());
this.tray.on('click', () => {
if (!this.mainWindow) return;
if (this.mainWindow.isVisible()) {
this.mainWindow.focus();
} else {
this.mainWindow.show();
}
});
this.buildTrayMenu();
}
async createWindow() {
this.mainWindow = new BrowserWindow({
width: 1440,
height: 960,
minWidth: 1024,
minHeight: 720,
show: false,
backgroundColor: '#0f172a',
title: this.appName,
icon: this.getWindowIconPath(),
titleBarStyle: 'hidden',
...(process.platform === 'darwin'
? { trafficLightPosition: { x: 18, y: 14 } }
: {
titleBarOverlay: {
color: nativeTheme.shouldUseDarkColors ? '#111111' : '#f7f8fa',
symbolColor: nativeTheme.shouldUseDarkColors ? '#a1a1a1' : '#5b6470',
height: 44,
},
}),
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
preload: this.getPreloadPath(),
},
});
this.mainWindow.once('ready-to-show', () => {
this.mainWindow?.show();
});
this.mainWindow.webContents.setWindowOpenHandler(({ url }) => {
void this.openExternalUrl(url).catch((error) => this.actions.showError('Could not open external link', error));
return { action: 'deny' };
});
this.mainWindow.on('resize', () => {
this.viewHost.resizeActiveView();
this.syncSettingsWindowBounds();
});
this.mainWindow.on('move', () => {
this.syncSettingsWindowBounds();
});
this.mainWindow.on('closed', () => {
this.viewHost.clear();
this.settingsWindow = null;
this.mainWindow = null;
this.launcherLoaded = false;
});
this.buildAppMenu();
await this.showLauncher();
}
}

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' data:; connect-src *; img-src 'self' data:" />
<title>CloudCLI Desktop</title>
<link rel="stylesheet" href="./launcher.css" />
</head>
<body>
<div id="app"></div>
<script src="./launcher.js"></script>
</body>
</html>

View File

@@ -0,0 +1,801 @@
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
}
html.cc-modal-window,
body.cc-modal-window {
background: transparent;
}
:root {
--bg: #111315;
--s1: #171a1d;
--s2: #1e2328;
--s3: #262d34;
--b-subtle: #28303a;
--b: #313b46;
--b-strong: #42505f;
--tx: #f5f7fa;
--tx2: #adb8c5;
--tx3: #7f8b98;
--brand: #0a66d9;
--brand-2: #5fa5ff;
--brand-faint: rgba(10, 102, 217, 0.14);
--ok: #2aa775;
--warn: #d48b20;
--err: #d65252;
--tab-hover-bg: rgba(255, 255, 255, 0.08);
--tab-active-bg: rgba(255, 255, 255, 0.14);
--mono: "SF Mono", "Geist Mono", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, sans-serif;
color-scheme: dark;
}
:root[data-theme="light"] {
--bg: #f3f5f8;
--s1: #ffffff;
--s2: #f7f9fb;
--s3: #edf1f5;
--b-subtle: #e5eaf0;
--b: #d8dee6;
--b-strong: #c3ccd6;
--tx: #11151a;
--tx2: #566171;
--tx3: #7f8b98;
--brand: #0a66d9;
--brand-2: #0f5fc6;
--brand-faint: rgba(10, 102, 217, 0.09);
--ok: #1f8e61;
--warn: #b67515;
--err: #c24747;
--tab-hover-bg: rgba(15, 23, 42, 0.05);
--tab-active-bg: rgba(15, 23, 42, 0.08);
color-scheme: light;
}
body {
background: var(--bg);
color: var(--tx);
font-family: var(--sans);
font-size: 14px;
-webkit-font-smoothing: antialiased;
overflow: hidden;
user-select: none;
}
input {
font: inherit;
user-select: text;
}
button {
font: inherit;
color: inherit;
cursor: pointer;
border: 0;
background: none;
}
#app {
height: 100vh;
display: flex;
flex-direction: column;
min-height: 0;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--b);
border-radius: 6px;
border: 2px solid transparent;
background-clip: content-box;
}
svg {
display: block;
}
.mono {
font-family: var(--mono);
}
.lbl {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 1.1px;
text-transform: uppercase;
color: var(--tx3);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--tx3);
flex: 0 0 auto;
display: inline-block;
}
.titlebar {
-webkit-app-region: drag;
display: flex;
align-items: center;
gap: 12px;
height: 44px;
padding: 0 12px;
border-bottom: 1px solid var(--b-subtle);
background: color-mix(in srgb, var(--s1) 90%, transparent);
flex: 0 0 auto;
}
.titlebar button,
.titlebar input,
.titlebar .no-drag {
-webkit-app-region: no-drag;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.brand .mk {
width: 22px;
height: 22px;
display: block;
flex: 0 0 auto;
object-fit: contain;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-width: 0;
height: 32px;
padding: 0 13px;
border-radius: 9px;
border: 1px solid var(--b);
background: var(--s1);
color: var(--tx);
font-weight: 500;
transition: border-color 0.12s, background 0.12s, filter 0.12s;
}
.btn:hover {
border-color: var(--b-strong);
background: var(--s2);
}
.btn.pri {
background: var(--brand);
border-color: var(--brand);
color: #fff;
}
.btn.pri:hover {
filter: brightness(1.05);
}
.btn.sm {
height: 28px;
padding: 0 10px;
font-size: 12px;
}
.btn:disabled {
opacity: 0.55;
cursor: default;
}
.icon-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 9px;
border: 1px solid transparent;
color: var(--tx2);
}
.icon-btn:hover {
background: var(--s2);
border-color: var(--b);
color: var(--tx);
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 22px;
padding: 0 9px;
border-radius: 999px;
font-size: 11px;
background: var(--s2);
color: var(--tx2);
border: 1px solid var(--b-subtle);
white-space: nowrap;
}
.badge.ok {
color: var(--ok);
}
.badge.warn {
color: var(--warn);
}
.badge.idle {
color: var(--tx3);
}
.cc-body {
flex: 1;
min-height: 0;
overflow: auto;
position: relative;
}
.statusbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 12px;
height: 27px;
padding: 0 12px;
border-top: 1px solid var(--b-subtle);
background: var(--s1);
font-size: 11px;
color: var(--tx2);
font-family: var(--mono);
}
.statusbar .sep {
opacity: 0.4;
}
.status-msg.progress {
color: var(--brand-2);
}
.status-msg.error {
color: var(--err);
}
.cc-overlay {
position: fixed;
inset: 0;
background: rgba(6, 8, 11, 0.28);
display: none;
z-index: 50;
align-items: center;
justify-content: center;
padding: 24px;
}
.cc-overlay.open {
display: flex;
}
.cc-sheet {
width: 620px;
max-width: min(92vw, 620px);
max-height: min(720px, 82vh);
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: 14px;
border: 1px solid var(--b);
background: color-mix(in srgb, var(--s1) 98%, transparent);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.34);
}
.cc-sheet-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 20px 20px 18px;
border-bottom: 1px solid var(--b-subtle);
}
.cc-sheet-copy {
min-width: 0;
}
.cc-sheet-title {
font-size: 20px;
font-weight: 600;
line-height: 1.2;
}
.cc-sheet-subtitle {
margin-top: 6px;
color: var(--tx2);
line-height: 1.45;
}
.cc-sheet-close {
flex: 0 0 auto;
}
.cc-sheet-body {
overflow: auto;
padding: 16px 20px 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.cc-sheet-footer {
padding: 14px 20px 18px;
border-top: 1px solid var(--b-subtle);
}
.cc-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.cc-section-head {
display: flex;
flex-direction: column;
gap: 2px;
}
.cc-section-title {
font-size: 14px;
font-weight: 600;
color: var(--tx);
}
.cc-section-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.cc-surface {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border: 1px solid var(--b-subtle);
border-radius: 14px;
background: linear-gradient(180deg, color-mix(in srgb, var(--s2) 86%, transparent), var(--s1));
}
.cc-row2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.cc-meta {
color: var(--tx2);
font-size: 12px;
line-height: 1.45;
}
.cc-toggle,
.cc-choice {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
gap: 12px;
align-items: start;
color: var(--tx2);
font-size: 13px;
line-height: 1.45;
}
.cc-toggle input,
.cc-choice input {
width: 16px;
height: 16px;
margin-top: 2px;
accent-color: var(--brand);
}
.cc-toggle b,
.cc-choice b {
color: var(--tx);
font-weight: 600;
}
.cc-choice-group {
gap: 12px;
}
.cc-permissions {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid var(--b-subtle);
border-radius: 12px;
background: var(--s1);
}
.cc-note {
color: var(--tx2);
font-size: 12px;
line-height: 1.45;
}
.cc-permission-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding-top: 10px;
border-top: 1px solid var(--b-subtle);
}
.cc-permission-title {
color: var(--tx);
font-size: 13px;
font-weight: 600;
}
.cc-permission-detail {
margin-top: 2px;
color: var(--tx2);
font-size: 12px;
line-height: 1.4;
}
.cc-permission-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.cc-kv {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 2px 0;
color: var(--tx2);
}
.cc-kv span:last-child {
color: var(--tx);
font-weight: 500;
}
.cc-actions-inline {
display: flex;
gap: 8px;
flex-wrap: wrap;
padding-top: 4px;
}
.cc-status-badge {
display: inline-flex;
align-items: center;
align-self: flex-start;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid var(--b-subtle);
background: var(--s2);
font-size: 12px;
font-weight: 600;
}
.cc-status-badge.ok {
color: var(--ok);
}
.cc-status-badge.warn {
color: var(--warn);
}
.cc-status-badge.idle {
color: var(--tx3);
}
.v-sidebar {
display: grid;
grid-template-columns: 248px 1fr;
overflow: hidden;
}
.sb {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 12px;
border-right: 1px solid var(--b-subtle);
background: var(--s1);
overflow: auto;
}
.sb-grp {
display: flex;
flex-direction: column;
gap: 3px;
}
.sb-grp .lbl {
padding: 6px 8px;
}
.sb-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 10px;
color: var(--tx2);
text-align: left;
}
.sb-item > span:nth-child(2) {
flex: 1;
}
.sb-item .sb-meta {
font-size: 11px;
color: var(--tx3);
font-family: var(--mono);
}
.sb-item:hover {
background: var(--s2);
}
.sb-item.active {
background: var(--brand-faint);
color: var(--tx);
}
.sb-item.active svg {
color: var(--brand-2);
}
.sb-main {
overflow: auto;
padding: 24px;
min-width: 0;
}
.pane-h {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.pane-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.pane-sub {
margin: 4px 0 0;
color: var(--tx2);
font-size: 13px;
}
.card {
border: 1px solid var(--b);
border-radius: 14px;
background: color-mix(in srgb, var(--s1) 94%, transparent);
padding: 18px;
display: flex;
flex-direction: column;
gap: 16px;
max-width: 620px;
margin-bottom: 12px;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.08);
}
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.card-tools {
display: flex;
align-items: center;
gap: 8px;
}
.card-t {
font-size: 15px;
font-weight: 600;
}
.card-sub {
margin-top: 4px;
color: var(--tx2);
font-size: 13px;
line-height: 1.45;
}
.card-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.env {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 12px 14px;
border: 1px solid var(--b);
border-radius: 12px;
background: var(--s1);
margin-bottom: 8px;
}
.env:hover {
border-color: var(--b-strong);
}
.env-i {
flex: 1;
min-width: 0;
}
.env-n {
font-weight: 500;
}
.env-u {
font-size: 12px;
color: var(--tx3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.env-tags {
display: flex;
gap: 6px;
}
.tag {
font-family: var(--mono);
font-size: 11px;
color: var(--tx2);
background: var(--s2);
border: 1px solid var(--b-subtle);
border-radius: 999px;
padding: 2px 7px;
white-space: nowrap;
}
.empty {
border: 1px dashed var(--b);
border-radius: 12px;
padding: 28px;
text-align: center;
color: var(--tx2);
max-width: 560px;
}
body.mac .titlebar {
padding-left: 92px;
padding-right: 12px;
}
body.win .titlebar {
padding-right: 150px;
}
.titlebar .brand {
margin-right: 6px;
}
.tb-tabs {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
overflow: hidden;
}
.tb-tab {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 112px;
max-width: 232px;
flex: 0 0 auto;
height: 30px;
padding: 0 7px 0 12px;
border: 1px solid transparent;
border-radius: 8px;
color: var(--tx2);
font-size: 12px;
background: transparent;
transition: background 0.12s, color 0.12s;
}
.tb-tab:hover {
background: var(--tab-hover-bg);
}
.tb-tab.active {
background: var(--tab-active-bg);
color: var(--tx);
}
.tb-tab span:first-child {
flex: 1;
min-width: 0;
max-width: 20ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tb-close {
display: grid;
width: 20px;
height: 20px;
margin-left: 8px;
place-items: center;
border-radius: 6px;
color: var(--tx3);
font-size: 14px;
line-height: 1;
flex: 0 0 auto;
}
.tb-close:hover {
background: var(--tab-hover-bg);
color: var(--tx);
}
.tb-action {
flex: 0 0 auto;
}
@media (max-width: 760px) {
.v-sidebar {
grid-template-columns: 1fr;
}
.sb {
flex-direction: row;
align-items: center;
overflow: auto;
}
.env-tags {
display: none;
}
.cc-sheet {
max-width: 100%;
}
.cc-row2 {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,829 @@
window.__APP_VERSION__ = '1.34.0';
window.__MOCK_STATE__ = {
account: { connected: true, email: 'you@cloudcli.ai' },
activeTarget: { kind: 'launcher', name: 'Launcher', url: null },
cloudLoading: false,
desktopSettings: { keepLocalServerRunning: false, exposeLocalServerOnNetwork: false, themeMode: 'system' },
localWebUrl: 'http://localhost:3001',
shareableWebUrl: 'http://localhost:3001',
localServerRunning: false,
localStartupLogs: [],
computerUse: { enabled: false, consentMode: 'ask', running: false, connectedCount: 0, targetCount: 0 },
computerUsePermissions: {
platform: 'darwin',
supported: true,
accessibility: 'not_granted',
screenRecording: 'not_determined',
message: 'macOS requires Accessibility and Screen Recording for Computer Use.',
},
environments: [
{ id: 'env-api', name: 'api-gateway', subdomain: 'api-gateway', access_url: 'https://api-gateway.cloudcli.ai', status: 'running', region: 'fra1', agent: 'Claude Code' },
{ id: 'env-web', name: 'web-frontend', subdomain: 'web-frontend', access_url: 'https://web-frontend.cloudcli.ai', status: 'stopped', region: 'sfo1', agent: 'Codex' },
{ id: 'env-data', name: 'data-pipeline', subdomain: 'data-pipeline', access_url: 'https://data-pipeline.cloudcli.ai', status: 'stopped', region: 'fra1', agent: 'Cursor' },
{ id: 'env-ml', name: 'ml-trainer', subdomain: 'ml-trainer', access_url: 'https://ml-trainer.cloudcli.ai', status: 'paused', region: 'iad1', agent: 'Gemini' },
],
};
(function cloudCliLauncher() {
var MOCK = window.__MOCK_STATE__ || {};
var VERSION = window.__APP_VERSION__ || '';
var LOGO_URL = new URL('../../public/logo-32.png', window.location.href).toString();
var SEARCH = new URLSearchParams(window.location.search || '');
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
var mockState = clone(MOCK);
var mockBridge = {
getState: function () { return Promise.resolve(clone(mockState)); },
openLocal: function () {
mockState.localServerRunning = true;
mockState.activeTarget = { kind: 'local', name: 'Local CloudCLI', url: mockState.localWebUrl };
return Promise.resolve(clone(mockState));
},
openLocalWebUi: function () {
mockState.localServerRunning = true;
return Promise.resolve(clone(mockState));
},
copyLocalWebUrl: function () { return Promise.resolve(clone(mockState)); },
connectCloud: function () {
mockState.account = { connected: true, email: 'you@cloudcli.ai' };
return Promise.resolve(clone(mockState));
},
disconnectCloud: function () {
mockState.account = { connected: false, email: null };
mockState.environments = [];
mockState.tabs = (mockState.tabs || []).filter(function (tab) { return tab.kind !== 'remote'; });
mockState.activeTabId = 'home';
mockState.activeTarget = { kind: 'launcher', name: 'Launcher', url: null };
return Promise.resolve(clone(mockState));
},
refreshEnvironments: function () { return Promise.resolve(clone(mockState)); },
refreshActiveTab: function () { return Promise.resolve(clone(mockState)); },
copyDiagnostics: function () { return Promise.resolve(clone(mockState)); },
showComputerAccess: function () { return Promise.resolve(clone(mockState)); },
showEnvironmentPicker: function () { return Promise.resolve(clone(mockState)); },
showLauncher: function () { return Promise.resolve(clone(mockState)); },
showLocalSettings: function () { return Promise.resolve(clone(mockState)); },
showDesktopSettings: function () { return Promise.resolve(clone(mockState)); },
closeSettingsWindow: function () { return Promise.resolve(clone(mockState)); },
showActiveEnvironmentActionsMenu: function () { return Promise.resolve(clone(mockState)); },
openCloudDashboard: function () { return Promise.resolve(clone(mockState)); },
runActiveEnvironmentAction: function () { return Promise.resolve(clone(mockState)); },
switchTab: function (id) { mockState.activeTabId = id; return Promise.resolve(clone(mockState)); },
closeTab: function (id) {
mockState.tabs = (mockState.tabs || []).filter(function (tab) { return tab.id === 'home' || tab.id !== id; });
if (mockState.activeTabId === id) mockState.activeTabId = 'home';
return Promise.resolve(clone(mockState));
},
updateSetting: function (key, value) {
mockState.desktopSettings = mockState.desktopSettings || {};
mockState.desktopSettings[key] = key === 'themeMode' ? value : !!value;
return Promise.resolve(clone(mockState));
},
updateComputerUse: function (settings) {
mockState.computerUse = mockState.computerUse || { enabled: false, consentMode: 'ask', running: false, connectedCount: 0, targetCount: 0 };
if (typeof settings.enabled === 'boolean') mockState.computerUse.enabled = settings.enabled;
if (settings.consentMode === 'auto' || settings.consentMode === 'ask') mockState.computerUse.consentMode = settings.consentMode;
mockState.computerUse.running = mockState.computerUse.enabled;
return Promise.resolve(clone(mockState));
},
requestComputerUsePermission: function (permission) {
mockState.computerUsePermissions = mockState.computerUsePermissions || {};
if (permission === 'accessibility') mockState.computerUsePermissions.accessibility = 'granted';
if (permission === 'screen') mockState.computerUsePermissions.screenRecording = 'granted';
if (permission === 'all') {
mockState.computerUsePermissions.accessibility = 'granted';
mockState.computerUsePermissions.screenRecording = 'granted';
}
return Promise.resolve(clone(mockState));
},
openEnvironment: function (id) {
var env = (mockState.environments || []).filter(function (item) { return item.id === id; })[0];
if (env) {
env.status = 'starting';
setTimeout(function () {
env.status = 'running';
mockState.activeTarget = { kind: 'remote', id: id, name: env.name, url: env.access_url };
}, 1700);
}
return Promise.resolve(clone(mockState));
},
};
var bridge = window.cloudcliDesktop || mockBridge;
var ICONS = {
terminal: '<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>',
cloud: '<path d="M17.5 19a4.5 4.5 0 0 0 .5-8.97A6 6 0 0 0 6.34 9 4 4 0 0 0 7 19z"/>',
refresh: '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
settings: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>',
gear: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6l-.03.08a2 2 0 1 1-3.94 0L10 20a1.7 1.7 0 0 0-1-.6 1.7 1.7 0 0 0-1.88.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1l-.08-.03a2 2 0 1 1 0-3.94L4 10a1.7 1.7 0 0 0 .6-1 1.7 1.7 0 0 0-.34-1.88l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6l.03-.08a2 2 0 1 1 3.94 0L14 4a1.7 1.7 0 0 0 1 .6 1.7 1.7 0 0 0 1.88-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.2.36.4.7.6 1l.08.03a2 2 0 1 1 0 3.94L20 14a1.7 1.7 0 0 0-.6 1z"/>',
play: '<polygon points="6 4 20 12 6 20 6 4"/>',
arrow: '<line x1="7" y1="17" x2="17" y2="7"/><polyline points="8 7 17 7 17 16"/>',
copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
cloudPlus: '<path d="M17.5 19a4.5 4.5 0 0 0 .5-8.97A6 6 0 0 0 6.34 9 4 4 0 0 0 7 19z"/><line x1="12" y1="9" x2="12" y2="15"/><line x1="9" y1="12" x2="15" y2="12"/>',
monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
phone: '<rect x="7" y="2" width="10" height="20" rx="2"/><line x1="11" y1="18" x2="13" y2="18"/>',
x: '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
logOut: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
};
var FILLED = { play: true };
function icon(name, size) {
size = size || 16;
return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="' + (FILLED[name] ? 'currentColor' : 'none') + '" stroke="' + (FILLED[name] ? 'none' : 'currentColor') + '" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' + (ICONS[name] || '') + '</svg>';
}
function esc(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function statusMeta(status) {
var map = {
running: { label: 'Running', cls: 'ok', dot: '#10b981', verb: 'Opening', open: 'Open' },
starting: { label: 'Starting', cls: 'warn', dot: '#f59e0b', verb: 'Starting', open: 'Open', busy: true },
stopped: { label: 'Stopped', cls: 'idle', dot: '#6b7280', verb: 'Starting', open: 'Start & open' },
paused: { label: 'Paused', cls: 'warn', dot: '#f59e0b', verb: 'Resuming', open: 'Resume' },
};
return map[status] || { label: status || 'Unknown', cls: 'idle', dot: '#6b7280', verb: 'Starting', open: 'Start & open' };
}
function connected(state) {
return !!(state && state.account && state.account.connected);
}
function authState(state) {
return state && state.account ? (state.account.authState || (state.account.connected ? 'connected' : 'logged_out')) : 'logged_out';
}
function accountLabel(state) {
if (authState(state) === 'expired') return 'Reconnect';
if (state && state.account && state.account.email) return state.account.email;
if (connected(state)) return 'Connected';
return 'Log in';
}
function localUrl(state) {
return (state && (state.shareableWebUrl || state.localWebUrl)) || '';
}
function envCount(state) {
var count = state && state.environments ? state.environments.length : 0;
return count + ' environment' + (count === 1 ? '' : 's');
}
function errMsg(error) {
return error && error.message ? error.message : String(error);
}
function resolveTheme(state) {
var settings = state && state.desktopSettings ? state.desktopSettings : {};
var mode = settings.themeMode || 'system';
if (mode === 'light' || mode === 'dark') return mode;
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function computerUseStatus(state) {
var computerUse = state && state.computerUse ? state.computerUse : {};
var connectedCount = computerUse.connectedCount || 0;
var environmentLabel = connectedCount + ' environment' + (connectedCount === 1 ? '' : 's');
if (!computerUse.enabled) {
return { label: 'Disabled', tone: 'idle', detail: 'CloudCLI cannot use this computer.' };
}
if (!connectedCount) {
return { label: 'Not connected', tone: 'warn', detail: 'No environment connected.' };
}
if (computerUse.consentMode === 'auto') {
return { label: 'Connected', tone: 'warn', detail: environmentLabel + ' connected. Unattended access is on.' };
}
return { label: 'Connected', tone: 'ok', detail: environmentLabel + ' connected.' };
}
var CC = {
icon: icon,
esc: esc,
statusMeta: statusMeta,
connected: connected,
authState: authState,
accountLabel: accountLabel,
localUrl: localUrl,
envCount: envCount,
computerUseStatus: computerUseStatus,
version: VERSION,
logoUrl: LOGO_URL,
platform: 'win',
state: clone(MOCK),
ui: {},
_busyEnv: null,
_status: { msg: '', tone: '' },
_reg: {},
_wired: false,
_poll: null,
modalMode: SEARCH.get('modal') === '1',
};
window.CC = CC;
var app;
var overlay;
CC.setState = function (state) {
var currentSheet = CC.ui.openSheet || (CC.modalMode ? (CC.ui.initialSheet || 'desktop-settings') : null);
var sheetBody = overlay ? overlay.querySelector('.cc-sheet-body') : null;
var scrollTop = sheetBody ? sheetBody.scrollTop : 0;
if (state && typeof state === 'object') CC.state = state;
CC.applyTheme(CC.state);
CC.render(CC.state);
if (currentSheet) {
CC.openSheet(currentSheet, { scrollTop: scrollTop });
}
};
CC.applyTheme = function (state) {
var settings = state && state.desktopSettings ? state.desktopSettings : {};
var themeMode = settings.themeMode || 'system';
var resolvedTheme = resolveTheme(state);
document.documentElement.setAttribute('data-theme', resolvedTheme);
document.documentElement.setAttribute('data-theme-mode', themeMode);
};
CC.refresh = function () {
return Promise.resolve(bridge.getState()).then(function (state) {
CC.setState(state);
return state;
});
};
CC.run = function (label, fn) {
CC._status = { msg: label, tone: 'progress' };
CC.render(CC.state);
return Promise.resolve()
.then(fn)
.then(function (state) {
if (state && state.environments) CC.state = state;
return CC.refresh();
})
.then(function () {
CC._status = { msg: '', tone: '' };
CC.render(CC.state);
})
.catch(function (error) {
CC._status = { msg: errMsg(error), tone: 'error' };
CC.render(CC.state);
});
};
CC.startPolling = function () {
if (CC._poll) return;
var ticks = 0;
CC._poll = setInterval(function () {
ticks += 1;
Promise.resolve(bridge.getState()).then(function (state) {
CC.setState(state);
var anyStarting = (state.environments || []).some(function (environment) { return environment.status === 'starting'; });
if (!anyStarting || ticks > 16) {
clearInterval(CC._poll);
CC._poll = null;
if (!anyStarting) {
CC._status = { msg: '', tone: '' };
CC.render(CC.state);
}
}
});
}, 1500);
};
CC.openEnv = function (id) {
var env = (CC.state.environments || []).filter(function (environment) { return environment.id === id; })[0];
var meta = statusMeta(env ? env.status : '');
CC._busyEnv = id;
CC._status = { msg: (meta.verb || 'Opening') + ' ' + ((env && (env.name || env.subdomain)) || 'environment') + '...', tone: 'progress' };
if (env) {
var tabId = 'remote:' + env.id;
var tabs = CC.state.tabs && CC.state.tabs.length ? CC.state.tabs : [{ id: 'home', title: 'Launcher', kind: 'launcher', closable: false }];
tabs = tabs.map(function (tab) {
tab.active = false;
return tab;
});
var existing = tabs.filter(function (tab) { return tab.id === tabId; })[0];
if (existing) {
existing.active = true;
existing.title = env.name || env.subdomain;
} else {
tabs.push({ id: tabId, title: env.name || env.subdomain, kind: 'remote', closable: true, active: true });
}
CC.state.tabs = tabs;
CC.state.activeTabId = tabId;
}
if (env && env.status !== 'running') env.status = 'starting';
CC.render(CC.state);
return Promise.resolve(bridge.openEnvironment(id)).then(function (state) {
if (state && state.environments) CC.setState(state);
CC.startPolling();
}).catch(function (error) {
CC._busyEnv = null;
if (env) env.status = 'stopped';
CC._status = { msg: errMsg(error), tone: 'error' };
CC.render(CC.state);
});
};
CC.act = function (name, node) {
switch (name) {
case 'local':
return CC.run('Starting Local CloudCLI...', function () { return bridge.openLocal(); });
case 'connect':
return CC.run('Opening cloudcli.ai to connect your account...', function () { return bridge.connectCloud(); });
case 'logout':
return CC.run('Logging out...', function () { return bridge.disconnectCloud(); });
case 'open-web':
return CC.run('Opening local web UI in your browser...', function () { return bridge.openLocalWebUi(); });
case 'copy-web':
return CC.run('Copied local URL to clipboard', function () { return bridge.copyLocalWebUrl(); });
case 'diagnostics':
return CC.run('Copied diagnostics to clipboard', function () { return bridge.copyDiagnostics(); });
case 'set-setting':
return CC.run('Saved', function () { return bridge.updateSetting(node.key, node.value); });
case 'set-theme-mode':
return CC.run('Saved', function () { return bridge.updateSetting('themeMode', node.value); });
case 'set-computer-mode':
CC.state.computerUse = {
...((CC.state && CC.state.computerUse) || {}),
enabled: true,
consentMode: node.value === 'auto' ? 'auto' : 'ask',
};
return CC.run('Saved', function () {
return bridge.updateComputerUse({
enabled: true,
consentMode: node.value,
});
});
case 'set-computer-enabled':
CC.state.computerUse = {
...((CC.state && CC.state.computerUse) || {}),
enabled: !!node.value,
};
return CC.run('Saved', function () {
var current = (CC.state && CC.state.computerUse) || { consentMode: 'ask' };
return bridge.updateComputerUse({
enabled: !!node.value,
consentMode: current.consentMode === 'auto' ? 'auto' : 'ask',
});
});
case 'computer-permission':
return CC.run('Opening permission settings...', function () {
return bridge.requestComputerUsePermission(node.getAttribute('data-cc-computer-permission'));
});
case 'settings-toggle':
return CC.run('Opening desktop settings...', function () { return bridge.showDesktopSettings(); });
case 'desktop-settings-toggle':
return CC.run('Opening desktop settings...', function () { return bridge.showDesktopSettings(); });
case 'local-settings-toggle':
return CC.run('Opening local settings...', function () { return bridge.showLocalSettings(); });
case 'computer-settings-toggle':
return CC.run('Opening desktop settings...', function () { return bridge.showDesktopSettings(); });
case 'settings-close':
return CC.closeSheet();
case 'dashboard':
return CC.run('Opening CloudCLI dashboard...', function () { return bridge.openCloudDashboard(); });
case 'refresh-environments':
return CC.run('Refreshing cloud environments...', function () { return bridge.refreshEnvironments(); });
case 'refresh-tab':
return CC.run('Refreshing tab...', function () { return bridge.refreshActiveTab(); });
case 'env-action':
return CC.run('Opening environment...', function () { return bridge.runActiveEnvironmentAction(node.getAttribute('data-cc-env-action')); });
case 'env-menu':
return CC.run('Opening environment actions...', function () { return bridge.showActiveEnvironmentActionsMenu(); });
case 'env-row-menu':
return CC.run('Opening environment actions...', function () { return bridge.showEnvironmentActionsMenu(node.getAttribute('data-cc-environment-id')); });
default:
return;
}
};
function renderTabs(state) {
var tabs = state.tabs && state.tabs.length ? state.tabs : [{ id: 'home', title: 'Home', closable: false, active: true }];
return tabs.map(function (tab) {
var title = tab.title || '';
var visibleChars = Math.min(title.length, 20);
var tabWidth = Math.max(112, Math.min(232, (visibleChars * 8) + (tab.closable ? 56 : 38)));
return '<button class="tb-tab no-drag' + (tab.active ? ' active' : '') + '" data-cc-tab="' + esc(tab.id) + '" title="' + esc(title) + '" style="width:' + tabWidth + 'px;flex-basis:' + tabWidth + 'px">' +
'<span>' + esc(title) + '</span>' +
(tab.closable ? '<span class="tb-close" data-cc-close-tab="' + esc(tab.id) + '" title="Close tab">&times;</span>' : '') +
'</button>';
}).join('');
}
CC.titlebar = function (state) {
var conn = connected(state);
var activeTab = (state.tabs || []).filter(function (tab) { return tab.active; })[0] || null;
var activeEnvironmentId = state.activeTarget && state.activeTarget.kind === 'remote' ? state.activeTarget.id : null;
if (!activeEnvironmentId && activeTab && /^remote:/.test(activeTab.id || '')) {
activeEnvironmentId = activeTab.id.replace(/^remote:/, '');
}
var activeRefreshable = (state.activeTarget && (state.activeTarget.kind === 'remote' || state.activeTarget.kind === 'local')) ||
(activeTab && activeTab.id !== 'home');
var envActions = activeEnvironmentId ? '<button class="btn sm tb-action no-drag" data-cc-action="env-row-menu" data-cc-environment-id="' + esc(activeEnvironmentId) + '" title="Open environment actions">Open environment in...</button>' : '';
var refreshAction = activeRefreshable ? '<button class="icon-btn tb-action no-drag" data-cc-action="refresh-tab" title="Refresh tab">' + icon('refresh', 16) + '</button>' : '';
var logoutAction = (conn || authState(state) === 'expired') ? '<button class="icon-btn tb-action no-drag" data-cc-action="logout" title="Logout">' + icon('logOut', 16) + '</button>' : '';
return '<div class="titlebar">' +
'<div class="brand"><img class="mk" src="' + esc(LOGO_URL) + '" alt=""><span>CloudCLI</span></div>' +
'<div class="tb-tabs no-drag">' + renderTabs(state) + '</div>' +
'<span style="flex:1"></span>' +
refreshAction +
envActions +
'<button class="btn sm tb-action no-drag" data-cc-action="connect" title="' + esc(authState(state) === 'expired' ? 'Reconnect your CloudCLI account' : accountLabel(state)) + '"><span class="dot" style="background:' + (conn ? 'var(--ok)' : (authState(state) === 'expired' ? 'var(--warn)' : 'var(--tx3)')) + '"></span>' + esc(accountLabel(state)) + '</button>' +
logoutAction +
'<button class="icon-btn tb-action no-drag" data-cc-action="settings-toggle" title="Settings">' + icon('settings', 16) + '</button>' +
'</div>';
};
CC.statusbar = function (state) {
var status = CC._status || {};
var running = !!state.localServerRunning;
return '<div class="statusbar">' +
'<span><span class="dot" style="width:7px;height:7px;background:' + (running ? 'var(--ok)' : 'var(--tx3)') + '"></span> local ' + (running ? 'running · ' + esc(localUrl(state)) : 'idle') + '</span>' +
'<span class="sep">·</span><span>' + esc(envCount(state)) + '</span>' +
'<span class="sep">·</span><span>' + (authState(state) === 'expired' ? 'session expired' : (connected(state) ? esc(accountLabel(state)) : 'not connected')) + '</span>' +
'<span style="flex:1"></span>' +
(status.msg ? '<span class="status-msg ' + esc(status.tone) + '">' + esc(status.msg) + '</span><span class="sep">·</span>' : '') +
'<span>v' + esc(VERSION) + '</span>' +
'</div>';
};
CC.renderSheet = function (title, subtitle, sections, footer) {
overlay.innerHTML =
'<div class="cc-sheet cc-modal">' +
'<div class="cc-sheet-header">' +
'<div class="cc-sheet-copy"><div class="cc-sheet-title">' + esc(title) + '</div><div class="cc-sheet-subtitle">' + esc(subtitle || '') + '</div></div>' +
'<button class="icon-btn cc-sheet-close" data-cc-action="settings-close" title="Close">' + icon('x', 16) + '</button>' +
'</div>' +
'<div class="cc-sheet-body">' + sections.join('') + '</div>' +
(footer ? '<div class="cc-sheet-footer">' + footer + '</div>' : '') +
'</div>';
};
CC.renderSection = function (eyebrow, title, body) {
return '<section class="cc-section">' +
'<div class="cc-section-head">' +
'<div class="lbl">' + esc(eyebrow) + '</div>' +
'<div class="cc-section-title">' + esc(title) + '</div>' +
'</div>' +
'<div class="cc-section-body">' + body + '</div>' +
'</section>';
};
CC.renderRadioOption = function (name, value, checked, title, description) {
return '<label class="cc-choice">' +
'<input type="radio" name="' + esc(name) + '" value="' + esc(value) + '"' + (checked ? ' checked' : '') + '>' +
'<span><b>' + esc(title) + '</b><br>' + esc(description) + '</span>' +
'</label>';
};
CC.openSheet = function (sheet, options) {
options = options || {};
if (sheet === 'desktop-settings') {
CC.renderDesktopSettings();
} else {
CC.renderLocalSettings();
}
CC.ui.openSheet = sheet;
overlay.classList.add('open');
if (typeof options.scrollTop === 'number') {
var body = overlay.querySelector('.cc-sheet-body');
if (body) body.scrollTop = options.scrollTop;
}
};
CC.closeSheet = function () {
if (CC.modalMode && bridge.closeSettingsWindow) {
CC.ui.openSheet = null;
return bridge.closeSettingsWindow();
}
CC.ui.openSheet = null;
overlay.classList.remove('open');
};
CC.buildLocalServerSection = function (state, options) {
options = options || {};
var settings = state.desktopSettings || {};
var url = localUrl(state) || 'starts on demand';
var body = '<div class="cc-surface">' +
'<div class="cc-meta mono">' + esc(url) + '</div>' +
'<div class="cc-row2"><button class="btn sm" data-cc-action="open-web">' + icon('arrow', 14) + 'Open in browser</button><button class="btn sm" data-cc-action="copy-web">' + icon('copy', 14) + 'Copy URL</button></div>';
if (options.includePreferences) {
body +=
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="keepLocalServerRunning"' + (settings.keepLocalServerRunning ? ' checked' : '') + '><span><b>Keep server running</b><br>Leave Local CloudCLI available after you quit the app.</span></label>' +
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="exposeLocalServerOnNetwork"' + (settings.exposeLocalServerOnNetwork ? ' checked' : '') + '><span><b>Allow LAN access</b><br>Use the copied URL from another device on this network.</span></label>';
}
body += '</div>';
return CC.renderSection(
options.eyebrow || 'LOCAL SERVER',
options.title || 'Run Local CloudCLI on this machine',
body
);
};
CC.buildThemeSection = function (state) {
var settings = state.desktopSettings || {};
return CC.renderSection('APPEARANCE', 'Desktop theme', '' +
'<div class="cc-surface cc-choice-group">' +
CC.renderRadioOption('desktop-theme', 'system', settings.themeMode === 'system', 'System', 'Follow the operating system appearance.') +
CC.renderRadioOption('desktop-theme', 'light', settings.themeMode === 'light', 'Light', 'Use the light interface appearance.') +
CC.renderRadioOption('desktop-theme', 'dark', settings.themeMode === 'dark', 'Dark', 'Use the dark interface appearance.') +
'</div>'
);
};
function permissionLabel(value) {
if (value === 'granted') return 'Granted';
if (value === 'denied' || value === 'restricted') return 'Needs attention';
if (value === 'not_applicable') return 'Not required';
return 'Not granted';
}
function permissionTone(value) {
if (value === 'granted' || value === 'not_applicable') return 'ok';
if (value === 'denied' || value === 'restricted') return 'warn';
return 'idle';
}
// TODO: Re-enable Computer Use menus after fixing the MCP server connection
// between the desktop app and the web UI.
var COMPUTER_USE_MENUS_ENABLED = false;
function renderComputerPermissionRow(key, label, detail, status) {
return '<div class="cc-permission-row">' +
'<div><div class="cc-permission-title">' + CC.esc(label) + '</div><div class="cc-permission-detail">' + CC.esc(detail) + '</div></div>' +
'<div class="cc-permission-actions"><span class="badge ' + permissionTone(status) + '">' + CC.esc(permissionLabel(status)) + '</span>' +
(status === 'granted' || status === 'not_applicable'
? ''
: '<button class="btn sm" data-cc-action="computer-permission" data-cc-computer-permission="' + CC.esc(key) + '">Open settings</button>') +
'</div>' +
'</div>';
}
function renderComputerPermissions(state) {
var permissions = state.computerUsePermissions || {};
if (!permissions.supported) {
return '<div class="cc-note">' + CC.esc(permissions.message || 'No additional OS permission setup is required from CloudCLI on this platform.') + '</div>';
}
return '<div class="cc-note">' + CC.esc(permissions.message || 'Grant the required OS permissions before approving agent control.') + '</div>' +
renderComputerPermissionRow('accessibility', 'Accessibility', 'Allows CloudCLI to click, type, and use accessibility actions.', permissions.accessibility) +
renderComputerPermissionRow('screen', 'Screen Recording', 'Allows CloudCLI to capture screenshots for agent observation.', permissions.screenRecording);
}
CC.buildComputerUseSection = function (state) {
var computerUse = state.computerUse || {};
var status = computerUseStatus(state);
var body =
'<div class="cc-surface">' +
'<label class="cc-toggle"><input type="checkbox" data-cc-computer-enabled="true"' + (computerUse.enabled ? ' checked' : '') + '><span><b>Enable Computer Use</b><br>Let CloudCLI use the computer. Agents cannot act until you approve a session.</span></label>' +
'<div class="cc-row2"><span class="badge ' + CC.esc(status.tone) + '">' + CC.esc(status.label) + '</span><span class="cc-meta">' + CC.esc(status.detail) + '</span><button class="btn sm" data-cc-action="refresh-environments">' + CC.icon('refresh', 14) + 'Refresh / relink</button></div>';
if (computerUse.enabled) {
body += '<div class="cc-permissions">' + renderComputerPermissions(state) + '</div>';
body += '<div class="cc-choice-group">' +
CC.renderRadioOption('computer-access-mode', 'ask', computerUse.consentMode !== 'auto', 'Ask before each session', 'Agents can request control, but you approve every session.') +
CC.renderRadioOption('computer-access-mode', 'auto', computerUse.consentMode === 'auto', 'Unattended access', 'Trusted agents can use this computer without a local approval prompt.') +
'</div>';
}
body += '</div>';
return CC.renderSection('COMPUTER USE', 'Control how agents can use this computer', body);
};
CC.renderLocalSettings = function () {
var state = CC.state || {};
var sections = [
CC.buildLocalServerSection(state, { includePreferences: false }),
CC.renderSection('PREFERENCES', 'How the local service behaves', '' +
'<div class="cc-surface">' +
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="keepLocalServerRunning"' + ((state.desktopSettings || {}).keepLocalServerRunning ? ' checked' : '') + '><span><b>Keep server running</b><br>Leave Local CloudCLI available after you quit the app.</span></label>' +
'<label class="cc-toggle"><input type="checkbox" data-cc-setting="exposeLocalServerOnNetwork"' + ((state.desktopSettings || {}).exposeLocalServerOnNetwork ? ' checked' : '') + '><span><b>Allow LAN access</b><br>Use the copied URL from another device on this network.</span></label>' +
'</div>'
),
];
CC.renderSheet('Local Settings', 'Manage how Local CloudCLI runs on this computer.', sections);
};
CC.renderDesktopSettings = function () {
var state = CC.state || {};
var sections = [
CC.buildThemeSection(state),
];
if (COMPUTER_USE_MENUS_ENABLED) {
sections.push(CC.buildComputerUseSection(state));
}
CC.renderSheet('Desktop Settings', 'Manage the desktop app appearance.', sections);
};
CC.render = function (state) {
state = state || CC.state;
var titlebar = (CC._reg.titlebar || CC.titlebar)(state);
var statusbar = (CC._reg.statusbar || CC.statusbar)(state);
var body = CC._reg.renderBody ? CC._reg.renderBody(state) : '';
if (CC.modalMode) {
app.innerHTML = '';
} else {
app.innerHTML = titlebar + '<div class="cc-body ' + (CC._reg.bodyClass || '') + '">' + body + '</div>' + statusbar;
}
if (CC._reg.afterRender) CC._reg.afterRender(state);
};
function wireEvents() {
if (CC._wired) return;
CC._wired = true;
document.addEventListener('click', function (event) {
if (CC._reg.onClick && CC._reg.onClick(event)) return;
var closeTab = event.target.closest('[data-cc-close-tab]');
if (closeTab) {
event.stopPropagation();
CC.run('Closing tab...', function () { return bridge.closeTab(closeTab.getAttribute('data-cc-close-tab')); });
return;
}
var tab = event.target.closest('[data-cc-tab]');
if (tab) {
CC.run('Switching tab...', function () { return bridge.switchTab(tab.getAttribute('data-cc-tab')); });
return;
}
var action = event.target.closest('[data-cc-action]');
if (action) {
CC.act(action.getAttribute('data-cc-action'), action);
return;
}
var env = event.target.closest('[data-cc-env]');
if (env) {
CC.openEnv(env.getAttribute('data-cc-env'));
return;
}
if (overlay.classList.contains('open') && !event.target.closest('.cc-sheet')) {
CC.closeSheet();
}
});
document.addEventListener('change', function (event) {
var setting = event.target.closest('[data-cc-setting]');
if (setting) {
CC.act('set-setting', {
key: setting.getAttribute('data-cc-setting'),
value: setting.checked,
});
return;
}
var theme = event.target.closest('[name="desktop-theme"]');
if (theme) {
CC.act('set-theme-mode', { value: theme.value });
return;
}
var computerMode = event.target.closest('[name="computer-access-mode"]');
if (computerMode) {
CC.act('set-computer-mode', { value: computerMode.value });
return;
}
var computerEnabled = event.target.closest('[data-cc-computer-enabled]');
if (computerEnabled) {
CC.act('set-computer-enabled', { value: computerEnabled.checked });
}
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && overlay.classList.contains('open')) {
CC.closeSheet();
return;
}
if ((event.metaKey || event.ctrlKey) && event.key === ',') {
event.preventDefault();
CC.act('settings-toggle');
return;
}
if (overlay.classList.contains('open')) return;
if (CC._reg.onKey) CC._reg.onKey(event, CC.state);
});
}
function boot() {
app = document.getElementById('app');
overlay = document.createElement('div');
overlay.id = 'cc-overlay';
overlay.className = 'cc-overlay';
document.body.appendChild(overlay);
var isMac = /Mac/i.test(navigator.platform) || /Mac OS X/i.test(navigator.userAgent);
var isWin = /Win/i.test(navigator.platform);
CC.platform = isMac ? 'mac' : (isWin ? 'win' : 'linux');
document.body.classList.add(CC.platform);
CC.ui.initialSheet = SEARCH.get('sheet') || 'desktop-settings';
if (CC.modalMode) {
document.documentElement.classList.add('cc-modal-window');
document.body.classList.add('cc-modal-window');
}
wireEvents();
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
CC.applyTheme(CC.state);
});
}
if (bridge.onStateUpdated) {
bridge.onStateUpdated(function (state) { CC.setState(state); });
}
if (bridge.onLauncherCommand) {
bridge.onLauncherCommand(function (command) {
if (command && command.type === 'open-sheet') {
CC.ui.initialSheet = command.sheet || CC.ui.initialSheet || 'desktop-settings';
CC.openSheet(command.sheet);
}
});
}
CC.refresh().catch(function (error) {
CC._status = { msg: errMsg(error), tone: 'error' };
CC.render(CC.state);
});
}
CC.register = function (registry) {
CC._reg = registry || {};
};
CC.start = function () {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
};
})();
(function sidebarApp() {
var CC = window.CC;
function navItem(id, iconName, label, meta, selected) {
return '<button class="sb-item' + (selected === id ? ' active' : '') + '" data-cc-nav="' + id + '">' +
CC.icon(iconName, 16) + '<span>' + label + '</span><span class="sb-meta">' + CC.esc(meta) + '</span></button>';
}
function localPane(state) {
return '<div class="pane-h"><div><h2 class="pane-title">Local servers</h2><p class="pane-sub">Manage Local CloudCLI on this machine. No account required.</p></div></div>' +
'<div class="card"><div class="card-head"><div><div class="card-t">Local server</div><div class="card-sub mono">' + CC.esc(CC.localUrl(state) || 'Starts on demand') + '</div></div><div class="card-tools"><span class="dot" style="background:' + (state.localServerRunning ? 'var(--ok)' : 'var(--tx3)') + '"></span><button class="icon-btn" data-cc-action="local-settings-toggle" title="Local settings">' + CC.icon('gear', 16) + '</button></div></div>' +
'<div class="card-actions"><button class="btn pri" data-cc-action="local">' + CC.icon('play', 15) + 'Open Local CloudCLI</button><button class="btn" data-cc-action="open-web">' + CC.icon('arrow', 14) + 'Open in browser</button><button class="btn" data-cc-action="copy-web">' + CC.icon('copy', 14) + 'Copy URL</button></div></div>';
}
function envRow(environment) {
var meta = CC.statusMeta(environment.status);
var tags = (environment.agent ? '<span class="tag">' + CC.esc(environment.agent) + '</span>' : '') + (environment.region ? '<span class="tag">' + CC.esc(environment.region) + '</span>' : '');
return '<div class="env" data-cc-env="' + environment.id + '"><span class="dot" style="background:' + meta.dot + '"></span>' +
'<div class="env-i"><div class="env-n">' + CC.esc(environment.name || environment.subdomain) + '</div><div class="env-u mono">' + CC.esc(environment.access_url || '') + '</div></div>' +
'<div class="env-tags">' + tags + '</div>' +
'<span class="badge ' + meta.cls + '">' + meta.label + '</span>' +
'<button class="btn sm" data-cc-action="env-row-menu" data-cc-environment-id="' + environment.id + '">Open environment in...</button>' +
'<button class="btn sm ' + (environment.status === 'running' ? 'pri' : '') + '">' + CC.icon(meta.busy ? 'refresh' : (environment.status === 'running' ? 'arrow' : 'play'), 14) + meta.open + '</button></div>';
}
function cloudPane(state) {
var header = '<div class="pane-h"><div><h2 class="pane-title">Environments</h2><p class="pane-sub">' + CC.esc(CC.envCount(state)) + '</p></div><button class="btn sm" data-cc-action="dashboard">' + CC.icon('arrow', 14) + 'Dashboard</button></div>';
if (CC.authState(state) === 'expired') {
return header + '<div class="empty">Your CloudCLI session expired.<div style="margin-top:14px"><button class="btn pri" data-cc-action="connect">' + CC.icon('cloudPlus', 15) + 'Reconnect account</button></div></div>';
}
if (!CC.connected(state)) {
return header + '<div class="empty">Connect your CloudCLI account to list hosted environments.<div style="margin-top:14px"><button class="btn pri" data-cc-action="connect">' + CC.icon('cloudPlus', 15) + 'Connect account</button></div></div>';
}
if (state.cloudLoading && !(state.environments || []).length) {
return header + '<div class="empty">Loading your CloudCLI environments...</div>';
}
var list = (state.environments || []).map(envRow).join('');
if (!list) list = '<div class="empty">No hosted environments yet.</div>';
return header + list;
}
function renderBody(state) {
var section = CC.ui.section || ((CC.connected(state) || CC.authState(state) === 'expired') ? 'cloud' : 'local');
CC.ui.section = section;
var nav = '<div class="sb"><div class="sb-grp"><div class="lbl">Launcher</div>' +
navItem('local', 'terminal', 'Local servers', state.localServerRunning ? 'on' : 'idle', section) +
navItem('cloud', 'cloud', 'Cloud environments', (state.environments || []).length, section) +
'</div></div>';
return nav + '<div class="sb-main">' + (section === 'local' ? localPane(state) : cloudPane(state)) + '</div>';
}
function onClick(event) {
var nav = event.target.closest('[data-cc-nav]');
if (!nav) return false;
CC.ui.section = nav.getAttribute('data-cc-nav');
CC.render(CC.state);
return true;
}
CC.register({
bodyClass: 'v-sidebar',
renderBody: renderBody,
onClick: onClick,
});
CC.start();
})();

550
electron/localServer.js Normal file
View File

@@ -0,0 +1,550 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import http from 'node:http';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { ServerInstaller } from './serverInstaller.js';
const DEFAULT_PORT = 3001;
const HOST = '127.0.0.1';
const DISPLAY_HOST = 'localhost';
const HEALTH_TIMEOUT_MS = 1000;
const SERVER_START_TIMEOUT_MS = 30000;
const MAX_STARTUP_LOG_LINES = 300;
const SERVER_MARKER_PATH = path.join(os.homedir(), '.cloudcli', 'local-server.json');
const LOCAL_SERVER_URL_ENV_KEYS = [
'CLOUDCLI_DESKTOP_LOCAL_SERVER_URL',
'CLOUDCLI_LOCAL_SERVER_URL',
'ELECTRON_LOCAL_SERVER_URL',
];
const LOCAL_SERVER_PORT_ENV_KEYS = [
'CLOUDCLI_DESKTOP_LOCAL_SERVER_PORT',
'CLOUDCLI_SERVER_PORT',
'SERVER_PORT',
'PORT',
];
function requestJson(url, timeoutMs = HEALTH_TIMEOUT_MS) {
return new Promise((resolve) => {
const req = http.get(url, { timeout: timeoutMs }, (res) => {
let body = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => {
try {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
json: JSON.parse(body),
});
} catch {
resolve({ ok: false, json: null });
}
});
});
req.on('timeout', () => {
req.destroy();
resolve({ ok: false, json: null });
});
req.on('error', () => resolve({ ok: false, json: null }));
});
}
async function isCloudCliServer(baseUrl) {
const response = await requestJson(`${baseUrl}/health`);
return response.ok
&& response.json?.status === 'ok'
&& typeof response.json?.installMode === 'string';
}
function isPortAvailable(port, host = HOST) {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => resolve(false));
server.once('listening', () => {
server.close(() => resolve(true));
});
server.listen(port, host);
});
}
function getFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.once('listening', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : DEFAULT_PORT;
server.close(() => resolve(port));
});
server.listen(0, HOST);
});
}
async function chooseServerPort(host) {
if (await isPortAvailable(DEFAULT_PORT, host)) {
return DEFAULT_PORT;
}
return getFreePort();
}
function getDesktopPath() {
const currentPath = process.env.PATH || '';
const commonPaths = process.platform === 'win32'
? []
: ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
return [...commonPaths, currentPath].filter(Boolean).join(path.delimiter);
}
function getNodeRuntime(usePackagedElectronRuntime) {
if (process.env.ELECTRON_NODE_PATH) {
return { command: process.env.ELECTRON_NODE_PATH, env: {}, label: 'ELECTRON_NODE_PATH' };
}
if (usePackagedElectronRuntime && process.versions.electron) {
return {
command: process.execPath,
env: { ELECTRON_RUN_AS_NODE: '1' },
label: `Electron ${process.versions.electron} Node ${process.versions.node}`,
};
}
if (process.env.npm_node_execpath) {
return { command: process.env.npm_node_execpath, env: {}, label: 'npm_node_execpath' };
}
return { command: 'node', env: {}, label: 'PATH node' };
}
function stripTrailingSlash(value) {
return value.endsWith('/') ? value.slice(0, -1) : value;
}
function addCandidateUrl(urls, rawUrl) {
if (!rawUrl) return;
try {
const parsed = new URL(String(rawUrl));
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
parsed.hash = '';
parsed.search = '';
const normalized = stripTrailingSlash(parsed.toString());
if (!urls.includes(normalized)) urls.push(normalized);
} catch {
// Ignore invalid user-provided discovery values.
}
}
function addCandidatePort(urls, rawPort) {
const port = Number.parseInt(String(rawPort || ''), 10);
if (!Number.isInteger(port) || port < 1 || port > 65535) return;
addCandidateUrl(urls, `http://${HOST}:${port}`);
}
function getPortFromUrl(baseUrl) {
try {
const parsed = new URL(baseUrl);
if (parsed.port) return Number.parseInt(parsed.port, 10);
return parsed.protocol === 'https:' ? 443 : 80;
} catch {
return null;
}
}
function getDisplayUrl(baseUrl) {
try {
const parsed = new URL(baseUrl);
if (parsed.hostname === HOST) {
parsed.hostname = DISPLAY_HOST;
}
return stripTrailingSlash(parsed.toString());
} catch {
return baseUrl;
}
}
async function pathExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function readServerBundleConfig(appRoot) {
try {
const raw = await fs.readFile(path.join(appRoot, 'electron', 'server-bundle-config.json'), 'utf8');
const config = JSON.parse(raw);
return {
releaseTag: typeof config.releaseTag === 'string' && config.releaseTag.trim()
? config.releaseTag.trim()
: '',
};
} catch {
return { releaseTag: '' };
}
}
function getServerCwd(appRoot, serverEntry) {
const normalizedEntry = path.resolve(serverEntry);
const bundledEntry = path.resolve(appRoot, 'dist-server', 'server', 'index.js');
if (normalizedEntry === bundledEntry) {
return appRoot;
}
// Installed server entries are laid out as <root>/dist-server/server/index.js.
return path.resolve(path.dirname(normalizedEntry), '..', '..');
}
async function readServerMarkerUrl() {
try {
const raw = await fs.readFile(SERVER_MARKER_PATH, 'utf8');
const marker = JSON.parse(raw);
return marker.url || (marker.port ? `http://${marker.host || HOST}:${marker.port}` : null);
} catch {
return null;
}
}
async function getExistingServerCandidateUrls(defaultUrl) {
const urls = [];
for (const key of LOCAL_SERVER_URL_ENV_KEYS) {
addCandidateUrl(urls, process.env[key]);
}
addCandidateUrl(urls, await readServerMarkerUrl());
for (const key of LOCAL_SERVER_PORT_ENV_KEYS) {
addCandidatePort(urls, process.env[key]);
}
addCandidateUrl(urls, defaultUrl);
return urls;
}
async function waitForCloudCliServer(baseUrl, timeoutMs) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await isCloudCliServer(baseUrl)) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 300));
}
return false;
}
export class LocalServerController {
constructor({ appRoot, settingsPath, isPackaged = false, appVersion, onChange }) {
this.appRoot = appRoot;
this.settingsPath = settingsPath;
this.isPackaged = isPackaged;
this.appVersion = appVersion;
this.onChange = onChange;
this.localServerUrl = null;
this.localServerPort = null;
this.ownedServerProcess = null;
this.startupLogs = [];
this.desktopSettings = {
keepLocalServerRunning: false,
exposeLocalServerOnNetwork: false,
themeMode: 'system',
};
}
getSettings() {
return this.desktopSettings;
}
getLocalServerUrl() {
return this.localServerUrl;
}
getHealthCheckUrl() {
if (!this.localServerPort) return this.localServerUrl;
return `http://${HOST}:${this.localServerPort}`;
}
appendStartupLog(line) {
const text = String(line || '').trimEnd();
if (!text) return;
const timestamp = new Date().toLocaleTimeString();
this.startupLogs.push(`[${timestamp}] ${text}`);
if (this.startupLogs.length > MAX_STARTUP_LOG_LINES) {
this.startupLogs.splice(0, this.startupLogs.length - MAX_STARTUP_LOG_LINES);
}
this.onChange?.();
}
getStartupLogs() {
return [...this.startupLogs];
}
getPendingTarget() {
return {
kind: 'local',
name: 'Local CloudCLI',
url: this.localServerUrl || `http://${DISPLAY_HOST}:${this.localServerPort || DEFAULT_PORT}`,
};
}
getLanAddress() {
const interfaces = os.networkInterfaces();
for (const entries of Object.values(interfaces)) {
for (const entry of entries || []) {
if (entry.family === 'IPv4' && !entry.internal) {
return entry.address;
}
}
}
return null;
}
getShareableWebUrl() {
if (!this.localServerUrl || !this.localServerPort) return null;
if (this.desktopSettings.exposeLocalServerOnNetwork) {
const lanAddress = this.getLanAddress();
if (lanAddress) {
return `http://${lanAddress}:${this.localServerPort}`;
}
}
return this.getLocalServerUrl();
}
getServerBindHost() {
return this.desktopSettings.exposeLocalServerOnNetwork ? '0.0.0.0' : HOST;
}
async loadDesktopSettings() {
try {
const raw = await fs.readFile(this.settingsPath, 'utf8');
const stored = JSON.parse(raw);
this.desktopSettings = {
keepLocalServerRunning: Boolean(stored.keepLocalServerRunning),
exposeLocalServerOnNetwork: Boolean(stored.exposeLocalServerOnNetwork),
themeMode: stored.themeMode === 'light' || stored.themeMode === 'dark' ? stored.themeMode : 'system',
};
} catch {
this.desktopSettings = {
keepLocalServerRunning: false,
exposeLocalServerOnNetwork: false,
themeMode: 'system',
};
}
}
async saveDesktopSettings(nextSettings = this.desktopSettings) {
this.desktopSettings = {
keepLocalServerRunning: Boolean(nextSettings.keepLocalServerRunning),
exposeLocalServerOnNetwork: Boolean(nextSettings.exposeLocalServerOnNetwork),
themeMode: nextSettings.themeMode === 'light' || nextSettings.themeMode === 'dark' ? nextSettings.themeMode : 'system',
};
await fs.mkdir(path.dirname(this.settingsPath), { recursive: true });
await fs.writeFile(this.settingsPath, JSON.stringify(this.desktopSettings, null, 2), 'utf8');
this.onChange?.();
}
async updateDesktopSetting(key, value) {
if (!Object.prototype.hasOwnProperty.call(this.desktopSettings, key)) {
throw new Error(`Unknown desktop setting: ${key}`);
}
const wasExposeSetting = key === 'exposeLocalServerOnNetwork';
const wasLocalRunning = Boolean(this.localServerUrl);
const nextValue = key === 'themeMode' ? value : Boolean(value);
await this.saveDesktopSettings({ ...this.desktopSettings, [key]: nextValue });
return {
desktopSettings: this.desktopSettings,
requiresRestartNotice: wasExposeSetting && wasLocalRunning,
};
}
/** Resolves the local server entry, installing the matching runtime if needed. */
async resolveServerEntry() {
if (process.env.ELECTRON_SERVER_ENTRY) {
return process.env.ELECTRON_SERVER_ENTRY;
}
const bundledEntry = path.join(this.appRoot, 'dist-server', 'server', 'index.js');
if (process.env.CLOUDCLI_USE_INSTALLED_SERVER !== '1' && await pathExists(bundledEntry)) {
return bundledEntry;
}
if (!this.appVersion) {
throw new Error('Cannot install local server: app version is unknown.');
}
const bundleConfig = await readServerBundleConfig(this.appRoot);
const installer = new ServerInstaller({
version: this.appVersion,
bundleReleaseTag: bundleConfig.releaseTag,
onLog: (line) => this.appendStartupLog(line),
});
return installer.ensureInstalled();
}
startBundledServer(port, serverEntry) {
const bindHost = this.getServerBindHost();
const runtime = getNodeRuntime(this.isPackaged);
const serverCwd = getServerCwd(this.appRoot, serverEntry);
const command = `${runtime.command} ${serverEntry}`;
this.appendStartupLog(`$ ${command}`);
this.appendStartupLog(`runtime: ${runtime.label}`);
this.appendStartupLog(`cwd: ${serverCwd}`);
this.appendStartupLog(`HOST=${bindHost} SERVER_PORT=${port} NODE_ENV=production`);
this.ownedServerProcess = spawn(runtime.command, [serverEntry], {
cwd: serverCwd,
detached: true,
env: {
...process.env,
...runtime.env,
HOST: bindHost,
SERVER_PORT: String(port),
NODE_ENV: 'production',
PATH: getDesktopPath(),
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
this.ownedServerProcess.once('error', (error) => {
this.appendStartupLog(`failed to start process: ${error.message}`);
this.ownedServerProcess = null;
});
this.ownedServerProcess.stdout?.on('data', (chunk) => {
for (const line of String(chunk).split(/\r?\n/)) {
this.appendStartupLog(line);
}
});
this.ownedServerProcess.stderr?.on('data', (chunk) => {
for (const line of String(chunk).split(/\r?\n/)) {
this.appendStartupLog(`stderr: ${line}`);
}
});
this.ownedServerProcess.once('exit', (code, signal) => {
this.appendStartupLog(`process exited with code ${code ?? 'null'} and signal ${signal ?? 'null'}`);
if (this.ownedServerProcess) {
console.error(`CloudCLI desktop server exited with code ${code ?? 'null'} and signal ${signal ?? 'null'}`);
}
this.ownedServerProcess = null;
});
}
async resolveLocalServerUrl() {
const defaultUrl = `http://${HOST}:${DEFAULT_PORT}`;
const defaultDisplayUrl = `http://${DISPLAY_HOST}:${DEFAULT_PORT}`;
const devUrl = process.env.ELECTRON_DEV_URL;
const forceOwnServer = process.env.ELECTRON_FORCE_OWN_SERVER === '1';
if (devUrl) {
const ready = await waitForCloudCliServer(defaultUrl, SERVER_START_TIMEOUT_MS);
if (!ready) {
throw new Error(`Development backend did not become ready at ${defaultDisplayUrl}`);
}
this.localServerPort = DEFAULT_PORT;
return devUrl;
}
if (!forceOwnServer) {
const candidateUrls = await getExistingServerCandidateUrls(defaultUrl);
for (const candidateUrl of candidateUrls) {
if (await isCloudCliServer(candidateUrl)) {
const displayUrl = getDisplayUrl(candidateUrl);
this.localServerPort = getPortFromUrl(candidateUrl);
this.appendStartupLog(`Using existing Local CloudCLI at ${displayUrl}`);
return displayUrl;
}
}
}
const serverEntry = await this.resolveServerEntry();
const port = await chooseServerPort(this.getServerBindHost());
const serverUrl = `http://${HOST}:${port}`;
const displayUrl = `http://${DISPLAY_HOST}:${port}`;
this.localServerPort = port;
this.startBundledServer(port, serverEntry);
const ready = await waitForCloudCliServer(serverUrl, SERVER_START_TIMEOUT_MS);
if (!ready) {
const recentLogs = this.getStartupLogs().slice(-20).join('\n');
await this.shutdownOwnedServer();
this.localServerPort = null;
throw new Error([
`Bundled backend did not become ready at ${displayUrl}.`,
recentLogs ? `Recent startup output:\n${recentLogs}` : 'No startup output was captured.',
].join('\n\n'));
}
this.appendStartupLog(`Local CloudCLI ready at ${displayUrl}`);
this.localServerUrl = displayUrl;
return displayUrl;
}
async ensureLocalServer() {
if (!this.localServerUrl) {
this.localServerUrl = await this.resolveLocalServerUrl();
}
return this.localServerUrl;
}
async getResolvedTarget() {
await this.ensureLocalServer();
return {
kind: 'local',
name: 'Local CloudCLI',
url: this.localServerUrl,
};
}
async loadLocalTarget() {
return {
pendingTarget: this.getPendingTarget(),
target: await this.getResolvedTarget(),
};
}
hasOwnedServer() {
return Boolean(this.ownedServerProcess);
}
detachOwnedServer() {
if (!this.ownedServerProcess) return;
this.ownedServerProcess.unref();
this.ownedServerProcess = null;
}
async shutdownOwnedServer() {
if (!this.ownedServerProcess) return;
const child = this.ownedServerProcess;
this.ownedServerProcess = null;
child.kill('SIGTERM');
await new Promise((resolve) => {
const timeout = setTimeout(resolve, 3000);
child.once('exit', () => {
clearTimeout(timeout);
resolve();
});
});
}
}
export { DEFAULT_PORT, HOST };

1060
electron/main.js Normal file

File diff suppressed because it is too large Load Diff

63
electron/preload.cjs Normal file
View File

@@ -0,0 +1,63 @@
const { contextBridge, ipcRenderer } = require('electron');
function isCloudCliAppOrigin(location) {
if (location.protocol === 'file:') return true;
if (location.protocol === 'http:') {
return location.hostname === '127.0.0.1' || location.hostname === 'localhost';
}
return location.protocol === 'https:' && (
location.hostname === 'cloudcli.ai' || location.hostname.endsWith('.cloudcli.ai')
);
}
function onDesktopStateUpdated(callback) {
const listener = (_event, state) => callback(state);
ipcRenderer.on('cloudcli-desktop:state-updated', listener);
return () => {
ipcRenderer.removeListener('cloudcli-desktop:state-updated', listener);
};
}
if (isCloudCliAppOrigin(window.location)) {
contextBridge.exposeInMainWorld('cloudcliDesktopNotifications', {
getState: () => ipcRenderer.invoke('cloudcli-desktop:get-state'),
update: (settings) => ipcRenderer.invoke('cloudcli-desktop:update-desktop-notifications', settings),
onStateUpdated: onDesktopStateUpdated,
});
}
if (window.location.protocol === 'file:') {
contextBridge.exposeInMainWorld('cloudcliDesktop', {
connectCloud: () => ipcRenderer.invoke('cloudcli-desktop:connect-cloud'),
disconnectCloud: () => ipcRenderer.invoke('cloudcli-desktop:disconnect-cloud'),
copyDiagnostics: () => ipcRenderer.invoke('cloudcli-desktop:copy-diagnostics'),
copyLocalWebUrl: () => ipcRenderer.invoke('cloudcli-desktop:copy-local-web-url'),
getState: () => ipcRenderer.invoke('cloudcli-desktop:get-state'),
openCloudDashboard: () => ipcRenderer.invoke('cloudcli-desktop:open-cloud-dashboard'),
openEnvironment: (environmentId) => ipcRenderer.invoke('cloudcli-desktop:open-environment', environmentId),
runActiveEnvironmentAction: (action) => ipcRenderer.invoke('cloudcli-desktop:run-active-environment-action', action),
openLocal: () => ipcRenderer.invoke('cloudcli-desktop:open-local'),
openLocalWebUi: () => ipcRenderer.invoke('cloudcli-desktop:open-local-web-ui'),
refreshEnvironments: () => ipcRenderer.invoke('cloudcli-desktop:refresh-environments'),
refreshActiveTab: () => ipcRenderer.invoke('cloudcli-desktop:reload-active-tab'),
showEnvironmentPicker: () => ipcRenderer.invoke('cloudcli-desktop:show-environment-picker'),
showLauncher: () => ipcRenderer.invoke('cloudcli-desktop:show-launcher'),
showComputerAccess: () => ipcRenderer.invoke('cloudcli-desktop:show-computer-access'),
showLocalSettings: () => ipcRenderer.invoke('cloudcli-desktop:show-local-settings'),
updateComputerUse: (settings) => ipcRenderer.invoke('cloudcli-desktop:update-computer-use', settings),
requestComputerUsePermission: (permission) => ipcRenderer.invoke('cloudcli-desktop:request-computer-use-permission', permission),
showDesktopSettings: () => ipcRenderer.invoke('cloudcli-desktop:show-desktop-settings'),
closeSettingsWindow: () => ipcRenderer.invoke('cloudcli-desktop:close-settings-window'),
showActiveEnvironmentActionsMenu: () => ipcRenderer.invoke('cloudcli-desktop:show-active-environment-actions-menu'),
showEnvironmentActionsMenu: (environmentId) => ipcRenderer.invoke('cloudcli-desktop:show-environment-actions-menu', environmentId),
switchTab: (tabId) => ipcRenderer.invoke('cloudcli-desktop:switch-tab', tabId),
closeTab: (tabId) => ipcRenderer.invoke('cloudcli-desktop:close-tab', tabId),
updateSetting: (key, value) => ipcRenderer.invoke('cloudcli-desktop:update-setting', key, value),
onStateUpdated: onDesktopStateUpdated,
onLauncherCommand: (callback) => {
ipcRenderer.on('cloudcli-desktop:launcher-command', (_event, command) => callback(command));
},
});
}

View File

@@ -0,0 +1,62 @@
import fs from 'node:fs/promises';
import sharp from 'sharp';
const size = 1024;
const assetsDir = 'electron/assets';
const iconPath = 'electron/assets/logo-macos.png';
const icnsPath = 'electron/assets/logo-macos.icns';
function renderSvg(entrySize) {
const scale = entrySize / 32;
return `
<svg xmlns="http://www.w3.org/2000/svg" width="${entrySize}" height="${entrySize}" viewBox="0 0 ${entrySize} ${entrySize}">
<rect width="${entrySize}" height="${entrySize}" fill="#2563eb"/>
<path
d="M${8 * scale} ${9 * scale}C${8 * scale} ${8.44772 * scale} ${8.44772 * scale} ${8 * scale} ${9 * scale} ${8 * scale}H${23 * scale}C${23.5523 * scale} ${8 * scale} ${24 * scale} ${8.44772 * scale} ${24 * scale} ${9 * scale}V${18 * scale}C${24 * scale} ${18.5523 * scale} ${23.5523 * scale} ${19 * scale} ${23 * scale} ${19 * scale}H${12 * scale}L${8 * scale} ${23 * scale}V${9 * scale}Z"
stroke="white"
stroke-width="${2 * scale}"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>`;
}
async function renderPng(entrySize) {
return sharp(Buffer.from(renderSvg(entrySize)))
.png()
.toBuffer();
}
await fs.mkdir(assetsDir, { recursive: true });
await fs.writeFile(iconPath, await renderPng(size));
const icnsEntries = [
['icp4', 16],
['icp5', 32],
['icp6', 64],
['ic07', 128],
['ic08', 256],
['ic09', 512],
['ic10', 1024],
['ic11', 32],
['ic12', 64],
['ic13', 256],
['ic14', 512],
];
const blocks = await Promise.all(icnsEntries.map(async ([type, entrySize]) => {
const png = await renderPng(entrySize);
const block = Buffer.alloc(8 + png.length);
block.write(type, 0, 4, 'ascii');
block.writeUInt32BE(block.length, 4);
png.copy(block, 8);
return block;
}));
const totalLength = 8 + blocks.reduce((sum, block) => sum + block.length, 0);
const header = Buffer.alloc(8);
header.write('icns', 0, 4, 'ascii');
header.writeUInt32BE(totalLength, 4);
await fs.writeFile(icnsPath, Buffer.concat([header, ...blocks], totalLength));

277
electron/serverInstaller.js Normal file
View File

@@ -0,0 +1,277 @@
import { spawn } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import https from 'node:https';
import os from 'node:os';
import path from 'node:path';
/**
* Installs the versioned local server runtime used by CloudCLI Desktop.
*
* Server bundles are cached under:
* ~/.cloudcli/server/<version>/dist-server/server/index.js
*/
const DEFAULT_INSTALL_ROOT = path.join(os.homedir(), '.cloudcli', 'server');
const DEFAULT_BUNDLE_BASE_URL = 'https://github.com/siteboon/claudecodeui/releases/download';
const MAX_REDIRECTS = 5;
const LOCAL_DOWNLOAD_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
function mapArch(arch = process.arch) {
return arch === 'arm64' ? 'arm64' : 'x64';
}
function mapPlatform(platform = process.platform) {
if (platform === 'darwin') return 'mac';
if (platform === 'win32') return 'win';
return 'linux';
}
export class ServerInstaller {
constructor({
version,
platform = process.platform,
arch = process.arch,
installRoot = process.env.CLOUDCLI_SERVER_DIR || DEFAULT_INSTALL_ROOT,
bundleBaseUrl = process.env.CLOUDCLI_SERVER_BUNDLE_URL || DEFAULT_BUNDLE_BASE_URL,
bundleReleaseTag = process.env.CLOUDCLI_SERVER_BUNDLE_RELEASE_TAG || '',
onLog,
} = {}) {
if (!version) throw new Error('ServerInstaller requires the app version');
this.version = version;
this.platform = mapPlatform(platform);
this.arch = mapArch(arch);
this.installRoot = installRoot;
this.bundleBaseUrl = bundleBaseUrl.replace(/\/+$/, '');
this.bundleReleaseTag = bundleReleaseTag || `v${this.version}`;
this.onLog = typeof onLog === 'function' ? onLog : () => {};
}
/** Directory the current version's server is (or will be) installed in. */
getVersionDir() {
return path.join(this.installRoot, this.version);
}
/** Absolute path to the server entry once installed. */
getServerEntry() {
return path.join(this.getVersionDir(), 'dist-server', 'server', 'index.js');
}
getBundleName() {
return `cloudcli-local-server-${this.version}-${this.platform}-${this.arch}.tar.gz`;
}
getBundleUrl() {
const url = new URL(`${this.bundleBaseUrl}/${this.bundleReleaseTag}/${this.getBundleName()}`);
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOCAL_DOWNLOAD_HOSTS.has(url.hostname))) {
throw new Error(`Refusing unsupported server bundle URL: ${url.toString()}`);
}
return url.toString();
}
log(line) {
this.onLog(String(line));
}
async isInstalled() {
try {
const marker = JSON.parse(
await fs.readFile(path.join(this.getVersionDir(), '.installed.json'), 'utf8'),
);
if (marker.version !== this.version) return false;
await fs.access(this.getServerEntry());
return true;
} catch {
return false;
}
}
/**
* Ensures the server for this version is installed, downloading + extracting
* it if needed. Returns the resolved server entry path.
*/
async ensureInstalled() {
if (await this.isInstalled()) {
this.log(`Local server ${this.version} already installed.`);
return this.getServerEntry();
}
const versionDir = this.getVersionDir();
const tmpDir = path.join(this.installRoot, `.tmp-${this.version}-${process.pid}`);
const archivePath = path.join(tmpDir, this.getBundleName());
await fs.mkdir(tmpDir, { recursive: true });
try {
const url = this.getBundleUrl();
this.log(`Downloading local server bundle…`);
this.log(url);
await this.#download(url, archivePath);
await this.#verifyChecksum(url, archivePath);
this.log('Extracting local server…');
await fs.rm(versionDir, { recursive: true, force: true });
await fs.mkdir(versionDir, { recursive: true });
await this.#validateArchive(archivePath);
await this.#extract(archivePath, versionDir);
const entry = this.getServerEntry();
await fs.access(entry);
await fs.writeFile(
path.join(versionDir, '.installed.json'),
JSON.stringify({ version: this.version, installedAt: new Date().toISOString() }, null, 2),
'utf8',
);
this.log(`Local server ${this.version} installed.`);
return entry;
} catch (error) {
await fs.rm(versionDir, { recursive: true, force: true }).catch(() => {});
throw new Error(`Failed to install local server: ${error.message}`);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}
#download(url, destPath, redirectsLeft = MAX_REDIRECTS) {
return new Promise((resolve, reject) => {
const req = https.get(url, (res) => {
const { statusCode, headers } = res;
if (statusCode >= 300 && statusCode < 400 && headers.location) {
res.resume();
if (redirectsLeft <= 0) {
reject(new Error('Too many redirects'));
return;
}
const next = new URL(headers.location, url).toString();
resolve(this.#download(next, destPath, redirectsLeft - 1));
return;
}
if (statusCode !== 200) {
res.resume();
reject(new Error(`Download failed with HTTP ${statusCode}`));
return;
}
const total = Number(headers['content-length']) || 0;
let received = 0;
let lastPct = -1;
const out = createWriteStream(destPath);
res.on('data', (chunk) => {
received += chunk.length;
if (total) {
const pct = Math.floor((received / total) * 100);
if (pct !== lastPct && pct % 10 === 0) {
lastPct = pct;
this.log(`Downloading… ${pct}%`);
}
}
});
res.pipe(out);
out.on('finish', () => out.close(resolve));
out.on('error', reject);
res.on('error', reject);
});
req.on('error', reject);
});
}
async #verifyChecksum(url, archivePath) {
let expected;
try {
expected = (await this.#fetchText(`${url}.sha256`)).trim().split(/\s+/)[0];
} catch (error) {
throw new Error(`Could not verify server bundle checksum: ${error.message}`);
}
const actual = await this.#sha256(archivePath);
if (expected.toLowerCase() !== actual.toLowerCase()) {
throw new Error('Checksum mismatch — refusing to install');
}
this.log('Checksum verified.');
}
#fetchText(url, redirectsLeft = MAX_REDIRECTS) {
return new Promise((resolve, reject) => {
https
.get(url, (res) => {
const { statusCode, headers } = res;
if (statusCode >= 300 && statusCode < 400 && headers.location) {
res.resume();
if (redirectsLeft <= 0) return reject(new Error('Too many redirects'));
return resolve(this.#fetchText(new URL(headers.location, url).toString(), redirectsLeft - 1));
}
if (statusCode !== 200) {
res.resume();
return reject(new Error(`HTTP ${statusCode}`));
}
let body = '';
res.setEncoding('utf8');
res.on('data', (c) => (body += c));
res.on('end', () => resolve(body));
res.on('error', reject);
})
.on('error', reject);
});
}
#sha256(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = createReadStream(filePath);
stream.on('data', (c) => hash.update(c));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
#extract(archivePath, destDir) {
return new Promise((resolve, reject) => {
const child = spawn('tar', ['-xzf', archivePath, '-C', destDir], {
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true,
});
let stderr = '';
child.stderr?.on('data', (c) => (stderr += c));
child.once('error', reject);
child.once('exit', (code) => {
if (code === 0) resolve();
else reject(new Error(`tar exited with code ${code}: ${stderr.trim()}`));
});
});
}
#validateArchive(archivePath) {
return new Promise((resolve, reject) => {
const child = spawn('tar', ['-tzf', archivePath], {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (c) => { stdout += c; });
child.stderr?.on('data', (c) => { stderr += c; });
child.once('error', reject);
child.once('exit', (code) => {
if (code !== 0) {
reject(new Error(`tar list exited with code ${code}: ${stderr.trim()}`));
return;
}
for (const entry of stdout.split(/\r?\n/).filter(Boolean)) {
const normalized = entry.replace(/\\/g, '/');
if (
path.isAbsolute(normalized)
|| /^[a-zA-Z]:\//.test(normalized)
|| normalized.split('/').includes('..')
) {
reject(new Error(`Refusing unsafe archive entry: ${entry}`));
return;
}
}
resolve();
});
});
}
}

87
electron/tabs.js Normal file
View File

@@ -0,0 +1,87 @@
export class TabsController {
constructor() {
this.activeTabId = 'home';
this.tabs = [
{
id: 'home',
title: 'Launcher',
kind: 'launcher',
closable: false,
},
];
}
getTabIdForTarget(target) {
if (target.kind === 'launcher') return 'home';
if (target.kind === 'remote' && target.id) return `remote:${target.id}`;
return target.kind;
}
upsertTarget(target) {
const tabId = this.getTabIdForTarget(target);
const existingTab = this.tabs.find((tab) => tab.id === tabId);
const nextTab = {
id: tabId,
title: target.kind === 'launcher' ? 'Launcher' : target.name,
kind: target.kind,
target,
closable: tabId !== 'home',
};
if (existingTab) {
Object.assign(existingTab, nextTab);
} else {
this.tabs.push(nextTab);
}
this.activeTabId = tabId;
return nextTab;
}
activate(tabId) {
const tab = this.tabs.find((item) => item.id === tabId);
if (!tab) return null;
this.activeTabId = tab.id;
return tab;
}
remove(tabId) {
const tab = this.tabs.find((item) => item.id === tabId);
if (!tab || !tab.closable) return null;
this.tabs = this.tabs.filter((item) => item.id !== tabId);
if (this.activeTabId === tabId) {
this.activeTabId = 'home';
}
return tab;
}
removeByKind(kind) {
const removed = this.tabs.filter((tab) => tab.kind === kind && tab.closable);
if (!removed.length) return [];
const removedIds = new Set(removed.map((tab) => tab.id));
this.tabs = this.tabs.filter((tab) => !removedIds.has(tab.id));
if (removedIds.has(this.activeTabId)) {
this.activeTabId = 'home';
}
return removed;
}
getActiveTab() {
return this.getTab(this.activeTabId);
}
getTab(tabId) {
return this.tabs.find((item) => item.id === tabId) || null;
}
getSerializableTabs() {
return this.tabs.map((tab) => ({
id: tab.id,
title: tab.title,
kind: tab.kind,
closable: tab.closable,
active: tab.id === this.activeTabId,
}));
}
}

331
electron/viewHost.js Normal file
View File

@@ -0,0 +1,331 @@
import { BrowserView } from 'electron';
const TARGET_LOAD_TIMEOUT_MS = 20000;
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function buildPlaceholderHtml(title, message, logs = []) {
const logHtml = logs.length
? `<pre>${logs.map(escapeHtml).join('\n')}</pre>`
: '<pre>Waiting for process output...</pre>';
return [
'<!doctype html><meta charset="utf-8">',
'<style>',
'html,body{margin:0;height:100%;background:#0a0a0a;color:#fafafa;font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}',
'body{padding:28px;overflow:hidden}',
'.shell{height:100%;display:flex;flex-direction:column;gap:16px}',
'.box{display:flex;align-items:center;gap:10px;color:#d4d4d4;flex:0 0 auto}',
'.dot{width:8px;height:8px;border-radius:50%;background:#0b60ea;box-shadow:0 0 0 6px rgba(11,96,234,.15)}',
'pre{margin:0;flex:1;overflow:auto;border:1px solid #262626;border-radius:10px;background:#050505;color:#d4d4d4;padding:14px;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;white-space:pre-wrap;user-select:text}',
'</style>',
'<div class="shell">',
`<div class="box"><span class="dot"></span><span>${escapeHtml(message || `Opening ${title}...`)}</span></div>`,
logHtml,
'</div>',
].join('');
}
function isHttpUrl(url) {
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
async function loadUrlWithTimeout(webContents, url, timeoutMs = TARGET_LOAD_TIMEOUT_MS) {
let timedOut = false;
let timeout = null;
const loadPromise = webContents.loadURL(url);
const timeoutPromise = new Promise((_, reject) => {
timeout = setTimeout(() => {
timedOut = true;
try {
webContents.stop();
} catch {
// Ignore teardown races while reporting the original timeout.
}
reject(new Error(`Timed out loading ${url} after ${Math.round(timeoutMs / 1000)} seconds.`));
}, timeoutMs);
});
try {
await Promise.race([loadPromise, timeoutPromise]);
} catch (error) {
if (timedOut) {
loadPromise.catch(() => {});
}
throw error;
} finally {
if (timeout) clearTimeout(timeout);
}
}
export class ViewHost {
constructor({ appName, getMainWindow, getContentViewBounds, getPreloadPath, openExternalUrl, showError }) {
this.appName = appName;
this.getMainWindow = getMainWindow;
this.getContentViewBounds = getContentViewBounds;
this.getPreloadPath = getPreloadPath;
this.openExternalUrl = openExternalUrl;
this.showError = showError;
this.activeContentView = null;
this.tabViews = new Map();
}
configureChildWebContents(webContents) {
webContents.setWindowOpenHandler(({ url }) => {
void this.openExternalUrl(url).catch((error) => this.showError('Could not open external link', error));
return { action: 'deny' };
});
}
detachAll() {
const mainWindow = this.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
try {
for (const view of mainWindow.getBrowserViews()) {
mainWindow.removeBrowserView(view);
}
} catch {
// BrowserViews may already be gone during BrowserWindow teardown.
}
this.activeContentView = null;
}
detachActiveView() {
const mainWindow = this.getMainWindow();
const view = this.activeContentView;
if (!mainWindow || mainWindow.isDestroyed() || !view) return false;
try {
if (mainWindow.getBrowserViews().includes(view)) {
mainWindow.removeBrowserView(view);
}
} catch {
return false;
}
this.activeContentView = null;
return true;
}
getActiveView() {
const view = this.activeContentView;
if (!view || view.webContents.isDestroyed()) return null;
return view;
}
openActiveViewDevTools() {
const view = this.getActiveView();
if (!view) return false;
view.webContents.openDevTools({ mode: 'detach' });
return true;
}
reloadActiveView() {
const view = this.getActiveView();
if (!view) return false;
view.webContents.reloadIgnoringCache();
return true;
}
async readLocalStorageValueForOrigin(originUrl, key) {
let targetOrigin;
try {
targetOrigin = new URL(originUrl).origin;
} catch {
return null;
}
for (const view of this.tabViews.values()) {
if (!view || view.webContents.isDestroyed()) continue;
let viewOrigin;
try {
viewOrigin = new URL(view.webContents.getURL()).origin;
} catch {
continue;
}
if (viewOrigin !== targetOrigin) continue;
try {
const value = await view.webContents.executeJavaScript(
`window.localStorage.getItem(${JSON.stringify(key)})`,
true
);
return typeof value === 'string' && value ? value : null;
} catch {
return null;
}
}
return null;
}
getTabViewDiagnostics() {
const mainWindow = this.getMainWindow();
const attachedViews = new Set();
if (mainWindow && !mainWindow.isDestroyed()) {
try {
for (const view of mainWindow.getBrowserViews()) {
attachedViews.add(view);
}
} catch {
// Ignore teardown races while gathering best-effort diagnostics.
}
}
return Array.from(this.tabViews.entries()).map(([tabId, view]) => {
const { webContents } = view;
const destroyed = webContents.isDestroyed();
return {
tabId,
webContentsId: destroyed ? null : webContents.id,
url: destroyed ? null : webContents.getURL(),
title: destroyed ? null : webContents.getTitle(),
osProcessId: destroyed || typeof webContents.getOSProcessId !== 'function' ? null : webContents.getOSProcessId(),
processId: destroyed || typeof webContents.getProcessId !== 'function' ? null : webContents.getProcessId(),
attached: attachedViews.has(view),
active: this.activeContentView === view,
destroyed,
};
});
}
getOrCreateTabView(tabId) {
let view = this.tabViews.get(tabId);
if (view) return view;
view = new BrowserView({
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
preload: this.getPreloadPath(),
},
});
this.configureChildWebContents(view.webContents);
this.tabViews.set(tabId, view);
return view;
}
attach(view) {
const mainWindow = this.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
if (this.activeContentView && this.activeContentView !== view) {
this.detachAll();
}
this.activeContentView = view;
try {
if (!mainWindow.getBrowserViews().includes(view)) {
mainWindow.addBrowserView(view);
}
} catch {
return;
}
view.setBounds(this.getContentViewBounds());
view.setAutoResize({ width: true, height: true });
}
resizeActiveView() {
if (this.activeContentView) {
this.activeContentView.setBounds(this.getContentViewBounds());
}
}
async showTabPlaceholder(tabId, target, message) {
const view = this.getOrCreateTabView(tabId);
this.attach(view);
const html = buildPlaceholderHtml(target.name || this.appName, message);
await view.webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
view.__cloudcliStartupHtml = html;
view.__cloudcliLoadedUrl = null;
}
async showLocalStartupTarget(tabId, target, logs) {
const view = this.getOrCreateTabView(tabId);
if (view.__cloudcliLoadingUrl) return;
this.attach(view);
const html = buildPlaceholderHtml(target.name || this.appName, 'Starting Local CloudCLI...', logs);
if (view.__cloudcliStartupHtml === html) return;
await view.webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
view.__cloudcliStartupHtml = html;
view.__cloudcliLoadedUrl = null;
}
async showContentTarget(tabId, target) {
const loadUrl = target.loadUrl || target.url;
if (!isHttpUrl(loadUrl)) {
throw new Error(`Refusing to load unsupported app URL: ${loadUrl}`);
}
const view = this.getOrCreateTabView(tabId);
this.attach(view);
if (target.forceLoad || view.__cloudcliLoadedUrl !== target.url) {
view.__cloudcliLoadingUrl = loadUrl;
try {
await loadUrlWithTimeout(view.webContents, loadUrl);
view.__cloudcliLoadedUrl = target.url;
view.__cloudcliStartupHtml = null;
delete target.loadUrl;
delete target.forceLoad;
} finally {
if (view.__cloudcliLoadingUrl === loadUrl) {
view.__cloudcliLoadingUrl = null;
}
}
}
return view.webContents.getURL();
}
reloadTab(tabId) {
const view = this.tabViews.get(tabId);
if (!view || view.webContents.isDestroyed()) return false;
view.webContents.reloadIgnoringCache();
return true;
}
async navigateActiveView(url) {
const view = this.getActiveView();
if (!view) return false;
await loadUrlWithTimeout(view.webContents, url);
view.__cloudcliLoadedUrl = url;
view.__cloudcliStartupHtml = null;
return true;
}
destroyTabView(tabId) {
const view = this.tabViews.get(tabId);
if (!view) return;
const mainWindow = this.getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
try {
if (mainWindow.getBrowserViews().includes(view)) {
mainWindow.removeBrowserView(view);
}
} catch {
// Ignore teardown races; Electron owns final destruction during quit.
}
}
if (this.activeContentView === view) {
this.activeContentView = null;
}
try {
if (!view.webContents.isDestroyed()) {
view.webContents.destroy();
}
} catch {
// The view may already be destroyed by its parent BrowserWindow.
}
this.tabViews.delete(tabId);
}
clear() {
this.tabViews.clear();
this.activeContentView = null;
}
}

View File

@@ -3,7 +3,9 @@ import tseslint from "typescript-eslint";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import importX from "eslint-plugin-import-x";
import { createNodeResolver, importX } from "eslint-plugin-import-x";
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
import boundaries from "eslint-plugin-boundaries";
import tailwindcss from "eslint-plugin-tailwindcss";
import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
@@ -82,7 +84,7 @@ export default tseslint.config(
"sibling",
"index",
],
"newlines-between": "never",
"newlines-between": "always",
},
],
@@ -98,5 +100,152 @@ export default tseslint.config(
"no-control-regex": "off",
"no-useless-escape": "off",
},
},
{
files: ["server/**/*.{js,ts}"], // apply this block only to backend source files
ignores: ["server/**/*.d.ts"], // skip generated declaration files in backend linting
plugins: {
boundaries, // enforce backend architecture boundaries (module-to-module contracts)
"import-x": importX, // keep import hygiene rules (duplicates, unresolved paths, etc.)
"unused-imports": unusedImports, // remove dead imports/variables from backend files
},
languageOptions: {
parser: tseslint.parser, // parse both JS and TS syntax in backend files
parserOptions: {
ecmaVersion: "latest", // support modern ECMAScript syntax in backend code
sourceType: "module", // treat backend files as ESM modules
},
globals: {
...globals.node, // expose Node.js globals such as process, Buffer, and __dirname equivalents
},
},
settings: {
"boundaries/include": ["server/**/*.{js,ts}"], // only analyze dependency boundaries inside backend files
"import/resolver": {
// boundaries resolves imports through eslint-module-utils, which reads the classic
// import/resolver setting instead of import-x/resolver-next.
typescript: {
project: ["server/tsconfig.json"], // resolve backend aliases using the canonical backend tsconfig
alwaysTryTypes: true, // keep normal TS package/type resolution working alongside aliases
},
node: {
extensions: [".mjs", ".cjs", ".js", ".json", ".node", ".ts", ".tsx"], // preserve Node-style fallback resolution for plain files
},
},
"import-x/resolver-next": [
// ESLint's import plugin does not read tsconfig path aliases on its own.
// This resolver teaches import-x how to understand the backend-only "@/*"
// mapping defined in server/tsconfig.json, which fixes false no-unresolved errors in editors.
createTypeScriptImportResolver({
project: ["server/tsconfig.json"], // point the resolver at the canonical backend tsconfig instead of the frontend one
alwaysTryTypes: true, // keep standard TypeScript package resolution working while backend aliases are enabled
}),
// Keep Node-style resolution available for normal package imports and plain relative JS files.
// The TypeScript resolver handles aliases, while the Node resolver preserves the expected fallback behavior.
createNodeResolver({
extensions: [".mjs", ".cjs", ".js", ".json", ".node", ".ts", ".tsx"],
}),
],
"boundaries/elements": [
{
type: "backend-shared-type-contract", // shared backend type/interface contracts that modules may consume without creating runtime coupling
pattern: [
"server/shared/types.{js,ts}",
"server/shared/interfaces.{js,ts}",
], // keep backend modules on explicit shared contract files for erased imports only
mode: "file", // treat each shared contract file itself as the boundary element instead of the whole folder
},
{
type: "backend-shared-utils", // shared backend runtime helpers that modules may import directly
pattern: [
"server/shared/utils.{js,ts}",
"server/shared/frontmatter.ts",
"server/shared/claude-cli-path.ts",
], // classify shared utility files so modules can depend on them explicitly
mode: "file",
},
{
type: "backend-legacy-runtime", // legacy runtime persistence modules used while providers migrate into server/modules
pattern: [
"server/projects.js",
"server/sessionManager.js",
"server/utils/runtime-paths.js",
], // provider history loading still resolves session data through these legacy runtime files
mode: "file",
},
{
type: "backend-module", // logical element name used by boundaries rules below
pattern: "server/modules/*", // each direct folder in server/modules is treated as one module boundary
mode: "folder", // classify dependencies at folder-module level (not per individual file)
capture: ["moduleName"], // capture the module folder name for messages/debugging/template use
},
],
},
rules: {
// --- Unused imports/vars (backend) ---
"unused-imports/no-unused-imports": "warn", // warn when imports are not used so they can be cleaned up
"unused-imports/no-unused-vars": "off", // keep backend signal focused on dead imports instead of local unused variables
// --- Import hygiene (backend) ---
"import-x/no-duplicates": "warn", // prevent duplicate import lines from the same module
"import-x/order": [
"warn", // keep backend import grouping/order consistent with the frontend config
{
groups: [
"builtin", // Node built-ins such as fs, path, and url come first
"external", // third-party packages come after built-ins
"internal", // aliased internal imports such as @/... come next
"parent", // ../ imports come after aliased internal imports
"sibling", // ./foo imports come after parent imports
"index", // bare ./ imports stay last
],
"newlines-between": "always", // require a blank line between import groups in backend files too
},
],
"import-x/no-unresolved": "error", // fail when an import path cannot be resolved
"import-x/no-useless-path-segments": "warn", // prefer cleaner paths (remove redundant ./ and ../ segments)
"import-x/no-absolute-path": "error", // disallow absolute filesystem imports in backend files
// --- General safety/style (backend) ---
eqeqeq: ["warn", "always", { null: "ignore" }], // avoid accidental coercion while still allowing x == null checks
// --- Architecture boundaries (backend modules) ---
"boundaries/dependencies": [
"error", // treat architecture violations as lint errors
{
default: "allow", // allow normal imports unless a rule below explicitly disallows them
checkInternals: false, // do not apply these cross-module rules to imports inside the same module
rules: [
{
from: { type: "backend-module" }, // modules may depend on shared type/interface contracts only as erased type-only imports
to: { type: "backend-shared-type-contract" },
disallow: {
dependency: { kind: ["value", "typeof"] },
}, // block runtime imports so shared contracts stay compile-time only instead of becoming hidden shared modules
message:
"Backend modules may only use `import type` when importing from server/shared/types.ts or server/shared/interfaces.ts.",
},
{
to: { type: "backend-module" }, // when importing anything that belongs to another backend module
disallow: { to: { internalPath: "**" } }, // block all direct/deep imports into module internals by default
message:
"Cross-module imports must go through that module's barrel file (server/modules/<module>/index.ts or index.js).", // explicit error message for architecture violations
},
{
to: { type: "backend-module" }, // same target scope as the disallow rule above
allow: {
to: {
internalPath: [
"index", // allow extensionless barrel imports resolved as module root index
"index.{js,mjs,cjs,ts,tsx}", // allow explicit index.* barrel file imports
],
},
}, // re-allow only public module entry points (barrel files)
},
],
},
],
"boundaries/no-unknown": "error", // fail fast if boundaries cannot classify a dependency, which prevents silent rule bypasses
},
}
);

8049
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,20 @@
{
"name": "@cloudcli-ai/cloudcli",
"version": "1.28.1",
"version": "1.34.0",
"productName": "CloudCLI",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",
"main": "dist-server/server/index.js",
"bin": {
"cloudcli": "server/cli.js"
"cloudcli": "dist-server/server/cli.js"
},
"files": [
"electron/",
"server/",
"shared/",
"public/api-docs.html",
"dist/",
"dist-server/",
"scripts/",
"README.md"
],
@@ -23,19 +27,98 @@
"url": "https://github.com/siteboon/claudecodeui/issues"
},
"scripts": {
"dev": "concurrently --kill-others \"npm run server\" \"npm run client\"",
"server": "node server/index.js",
"dev": "concurrently --kill-others \"npm run server:dev\" \"npm run client\"",
"server": "node dist-server/server/index.js",
"preserver:dev": "npm run build:semantics",
"server:dev": "tsx --tsconfig server/tsconfig.json server/index.js",
"preserver:dev-watch": "npm run build:semantics",
"server:dev-watch": "tsx watch --tsconfig server/tsconfig.json server/index.js",
"client": "vite",
"build": "vite build",
"desktop": "electron electron/main.js",
"predesktop:dev": "npm run build:semantics",
"desktop:dev": "cross-env ELECTRON_DEV_URL=http://127.0.0.1:5173 electron electron/main.js",
"desktop:stage": "node scripts/release/prepare-desktop-app.js",
"desktop:pack": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --dir",
"desktop:dist:mac": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --mac dmg",
"desktop:dist:win": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --win nsis",
"server:bundle": "npm run build && node scripts/release/build-server-bundle.js",
"desktop:icon:mac": "node electron/scripts/generate-macos-icon.js",
"build": "npm run build:semantics && npm run build:client && npm run build:server",
"build:client": "vite build",
"build:semantics": "node scripts/build-computer-semantics.mjs",
"prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"",
"build:server": "tsc -p server/tsconfig.json && tsc-alias -p server/tsconfig.json",
"postbuild:server": "node scripts/copy-computer-semantics-bin.mjs",
"preview": "vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p server/tsconfig.json",
"lint": "eslint src/ server/",
"lint:fix": "eslint src/ server/ --fix",
"start": "npm run build && npm run server",
"release": "./release.sh",
"prepublishOnly": "npm run build",
"postinstall": "node scripts/fix-node-pty.js",
"prepare": "husky"
"postinstall": "node scripts/fix-node-pty.js && npm run build:semantics",
"prepare": "husky",
"update:platform": "./update-platform.sh"
},
"build": {
"appId": "ai.cloudcli.desktop",
"productName": "CloudCLI",
"asar": false,
"artifactName": "cloudcli-desktop-${version}-${os}-${arch}.${ext}",
"directories": {
"output": "release/desktop"
},
"extraMetadata": {
"main": "electron/main.js"
},
"files": [
"electron/",
"public/",
"dist/",
"dist-server/",
"shared/",
"server/",
"package.json",
"!**/node_modules/@anthropic-ai/claude-agent-sdk-{darwin,linux,win32}-*/**"
],
"protocols": [
{
"name": "CloudCLI",
"schemes": [
"cloudcli"
]
}
],
"mac": {
"category": "public.app-category.developer-tools",
"icon": "electron/assets/logo-macos.icns",
"notarize": true,
"target": [
"dmg"
],
"extendInfo": {
"CFBundleName": "CloudCLI",
"CFBundleDisplayName": "CloudCLI",
"CFBundleURLTypes": [
{
"CFBundleURLName": "CloudCLI",
"CFBundleURLSchemes": [
"cloudcli"
]
}
]
}
},
"win": {
"icon": "electron/assets/logo-windows.ico",
"target": [
"nsis"
]
},
"nsis": {
"installerIcon": "electron/assets/logo-windows.ico",
"uninstallerIcon": "electron/assets/logo-windows.ico"
}
},
"keywords": [
"claude code",
@@ -58,7 +141,7 @@
"author": "CloudCLI UI Contributors",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.59",
"@anthropic-ai/claude-agent-sdk": "^0.3.165",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
@@ -69,10 +152,11 @@
"@codemirror/theme-one-dark": "^6.1.2",
"@iarna/toml": "^2.2.5",
"@octokit/rest": "^22.0.0",
"@openai/codex-sdk": "^0.101.0",
"@openai/codex-sdk": "^0.141.0",
"@replit/codemirror-minimap": "^0.5.2",
"@tailwindcss/typography": "^0.5.16",
"@uiw/react-codemirror": "^4.23.13",
"@vscode/ripgrep": "^1.17.1",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
@@ -83,8 +167,10 @@
"chokidar": "^4.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cors": "^2.8.5",
"cross-spawn": "^7.0.3",
"dompurify": "^3.4.7",
"express": "^4.18.2",
"fuse.js": "^7.0.0",
"gray-matter": "^4.0.3",
@@ -97,7 +183,7 @@
"mime-types": "^3.0.1",
"multer": "^2.0.1",
"node-fetch": "^2.7.0",
"node-pty": "^1.1.0-beta34",
"node-pty": "^1.2.0-beta.12",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
@@ -110,25 +196,32 @@
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1",
"web-push": "^3.6.7",
"ws": "^8.14.2"
},
"devDependencies": {
"@commitlint/cli": "^20.4.3",
"@commitlint/config-conventional": "^20.4.3",
"@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.5.0",
"@eslint/js": "^9.39.3",
"@release-it/conventional-changelog": "^10.0.5",
"@types/better-sqlite3": "^7.6.13",
"@types/cross-spawn": "^6.0.6",
"@types/express": "^5.0.6",
"@types/node": "^22.19.7",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.6.0",
"auto-changelog": "^2.5.0",
"autoprefixer": "^10.4.16",
"concurrently": "^8.2.2",
"cross-env": "^10.1.0",
"electron": "^38.0.0",
"electron-builder": "^26.15.3",
"eslint": "^9.39.3",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-boundaries": "^6.0.2",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
@@ -143,11 +236,18 @@
"release-it": "^19.0.5",
"sharp": "^0.34.2",
"tailwindcss": "^3.4.0",
"tsc-alias": "^1.8.16",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.0.4"
},
"lint-staged": {
"src/**/*.{ts,tsx,js,jsx}": "eslint"
"src/**/*.{ts,tsx,js,jsx}": "eslint",
"server/**/*.{js,ts}": "eslint"
},
"optionalDependencies": {
"@nut-tree-fork/nut-js": "^4.2.6",
"screenshot-desktop": "^1.15.4"
}
}

View File

@@ -820,32 +820,49 @@ data: {"type":"done"}</code></pre>
</div>
</div>
<script type="module">
// Import model constants
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '/shared/modelConstants.js';
<script>
// Dynamic URL replacement
const apiUrl = window.location.origin;
document.querySelectorAll('.api-url').forEach(el => {
el.textContent = apiUrl;
});
// Dynamically populate model documentation
window.addEventListener('DOMContentLoaded', () => {
const modelCell = document.getElementById('model-options-cell');
if (modelCell) {
const claudeModels = CLAUDE_MODELS.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
const cursorModels = CURSOR_MODELS.OPTIONS.slice(0, 8).map(m => `<code>${m.value}</code>`).join(', ');
const codexModels = CODEX_MODELS.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
// Populate model documentation from the live provider API
const PROVIDER_ORDER = [
{ id: 'claude', name: 'Anthropic' },
{ id: 'codex', name: 'OpenAI' },
{ id: 'gemini', name: 'Google' },
{ id: 'cursor', name: 'Cursor' },
{ id: 'opencode', name: 'OpenCode' },
];
modelCell.innerHTML = `
Model identifier for the AI provider:<br><br>
<strong>Claude:</strong> ${claudeModels} (default: <code>${CLAUDE_MODELS.DEFAULT}</code>)<br><br>
<strong>Cursor:</strong> ${cursorModels}, and more (default: <code>${CURSOR_MODELS.DEFAULT}</code>)<br><br>
<strong>Codex:</strong> ${codexModels} (default: <code>${CODEX_MODELS.DEFAULT}</code>)
`;
}
});
async function populateModels() {
const modelCell = document.getElementById('model-options-cell');
if (!modelCell) return;
const token = localStorage.getItem('auth-token');
const headers = token ? { Authorization: `Bearer ${token}` } : {};
const results = await Promise.allSettled(
PROVIDER_ORDER.map(({ id }) =>
fetch(`/api/providers/${id}/models`, { headers }).then(r => r.json())
)
);
const providerModels = results.map((result, i) => {
const { name } = PROVIDER_ORDER[i];
if (result.status === 'rejected' || !result.value?.data?.models) {
return `<strong>${name}:</strong> <em>unavailable</em>`;
}
const { OPTIONS, DEFAULT } = result.value.data.models;
const models = OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');
return `<strong>${name}:</strong> ${models} (default: <code>${DEFAULT}</code>)`;
}).join('<br><br>');
modelCell.innerHTML = `Model identifier for the AI provider:<br><br>${providerModels}`;
}
document.addEventListener('DOMContentLoaded', populateModels);
// Tab switching
window.showTab = function(tabName) {

View File

@@ -75,7 +75,7 @@
- **Session Management** - Resume conversations, manage multiple sessions, and track history
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`shared/modelConstants.js`](https://github.com/siteboon/claudecodeui/blob/main/shared/modelConstants.js) for the full list of supported models)
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (the full list of supported models is available at runtime via `GET /api/providers/:provider/models`)
## Quick Start

View File

@@ -1,2 +1,2 @@
#!/usr/bin/env node
import('@cloudcli-ai/cloudcli/server/cli.js');
import('@cloudcli-ai/cloudcli/dist-server/server/cli.js');

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..');
const platform = process.env.CLOUDCLI_SEMANTICS_PLATFORM || process.platform;
const arch = process.env.CLOUDCLI_SEMANTICS_ARCH || process.arch;
const platformArch = `${platform}-${arch}`;
const semanticsRoot = path.join(rootDir, 'server', 'modules', 'computer-use', 'semantics');
const outDir = path.join(semanticsRoot, 'bin', platformArch);
const requireBuild = process.env.CLOUDCLI_SEMANTICS_BUILD_REQUIRED === '1';
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: 'inherit',
shell: process.platform === 'win32',
...options,
});
child.once('error', reject);
child.once('exit', (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`));
});
});
}
function commandExists(command) {
return new Promise((resolve) => {
const child = spawn(command, ['--version'], {
stdio: 'ignore',
shell: process.platform === 'win32',
});
child.once('error', () => resolve(false));
child.once('exit', (code) => resolve(code === 0));
});
}
async function pathExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function isUpToDate(output, inputs) {
if (!(await pathExists(output))) {
return false;
}
const outputStat = await fs.stat(output);
for (const input of inputs) {
const inputStat = await fs.stat(input);
if (inputStat.mtimeMs > outputStat.mtimeMs) {
return false;
}
}
return true;
}
async function ensureCommand(command, helpText) {
if (await commandExists(command)) {
return true;
}
const message = `${command} was not found. ${helpText}`;
if (requireBuild) {
throw new Error(message);
}
console.log(`Skipping semantic helper build: ${message}`);
return false;
}
if (platform === 'darwin') {
const source = path.join(semanticsRoot, 'helpers', 'macos', 'CloudCLISemantics.swift');
const output = path.join(outDir, 'CloudCLISemantics');
if (!(await ensureCommand('swiftc', 'Install Xcode Command Line Tools to compile the macOS helper.'))) {
process.exit(0);
}
if (await isUpToDate(output, [source])) {
console.log(`Semantic helper is up to date: ${path.relative(rootDir, output)}`);
process.exit(0);
}
await fs.mkdir(outDir, { recursive: true });
await run('swiftc', [
source,
'-o',
output,
'-framework',
'AppKit',
'-framework',
'ApplicationServices',
]);
await fs.chmod(output, 0o755);
console.log(`Built ${path.relative(rootDir, output)}`);
} else if (platform === 'win32') {
const project = path.join(semanticsRoot, 'helpers', 'windows', 'CloudCLISemantics.csproj');
const source = path.join(semanticsRoot, 'helpers', 'windows', 'Program.cs');
const output = path.join(outDir, 'CloudCLISemantics.exe');
if (!(await ensureCommand('dotnet', '.NET SDK is required to compile the Windows helper.'))) {
process.exit(0);
}
if (await isUpToDate(output, [project, source])) {
console.log(`Semantic helper is up to date: ${path.relative(rootDir, output)}`);
process.exit(0);
}
await fs.mkdir(outDir, { recursive: true });
await run('dotnet', [
'publish',
project,
'-c',
'Release',
'-r',
arch === 'arm64' ? 'win-arm64' : 'win-x64',
'--self-contained',
'false',
'-p:PublishSingleFile=true',
'-o',
outDir,
]);
console.log(`Built ${path.relative(rootDir, output)}`);
} else {
console.log(`Semantic helper build is not supported for ${platform}-${arch}.`);
}

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..');
const sourceDir = path.join(rootDir, 'server', 'modules', 'computer-use', 'semantics', 'bin');
const targetDir = path.join(rootDir, 'dist-server', 'server', 'modules', 'computer-use', 'semantics', 'bin');
async function pathExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
if (await pathExists(sourceDir)) {
await fs.mkdir(path.dirname(targetDir), { recursive: true });
await fs.cp(sourceDir, targetDir, { recursive: true });
console.log(`Copied Computer Use semantic helpers to ${path.relative(rootDir, targetDir)}`);
}

View File

@@ -0,0 +1,176 @@
#!/usr/bin/env node
import crypto from 'node:crypto';
import { createReadStream, readFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..', '..');
const packageJson = JSON.parse(
await fs.readFile(path.join(rootDir, 'package.json'), 'utf8'),
);
function getElectronVersion() {
try {
return JSON.parse(
readFileSync(path.join(rootDir, 'node_modules', 'electron', 'package.json'), 'utf8'),
).version;
} catch {
try {
return JSON.parse(
readFileSync(path.join(rootDir, 'package-lock.json'), 'utf8'),
).packages['node_modules/electron'].version;
} catch {
throw new Error('Could not resolve an exact Electron version for server native rebuild.');
}
}
}
function mapArch(arch = process.arch) {
return arch === 'arm64' ? 'arm64' : 'x64';
}
function mapPlatform(platform = process.platform) {
if (platform === 'darwin') return 'mac';
if (platform === 'win32') return 'win';
return 'linux';
}
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: 'inherit',
shell: process.platform === 'win32',
...options,
});
child.once('error', reject);
child.once('exit', (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`));
});
});
}
async function pathExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function copyRequired(stageDir, relativePath) {
const from = path.join(rootDir, relativePath);
if (!(await pathExists(from))) {
throw new Error(`Required server bundle input is missing: ${relativePath}`);
}
await fs.cp(from, path.join(stageDir, relativePath), { recursive: true });
}
async function copyIfExists(stageDir, relativePath) {
const from = path.join(rootDir, relativePath);
if (!(await pathExists(from))) return;
await fs.cp(from, path.join(stageDir, relativePath), { recursive: true });
}
async function writeServerPackageJson(stageDir) {
const stagedPackageJson = {
...packageJson,
scripts: {
...(packageJson.scripts || {}),
},
};
// The bundle stage is not a git checkout with dev dependencies, so lifecycle
// scripts such as Husky prepare must not run there. Dependency install scripts
// still run; native modules need them before the Electron ABI rebuild below.
delete stagedPackageJson.scripts.postinstall;
delete stagedPackageJson.scripts.prepare;
delete stagedPackageJson.scripts.prepublishOnly;
await fs.writeFile(
path.join(stageDir, 'package.json'),
`${JSON.stringify(stagedPackageJson, null, 2)}\n`,
'utf8',
);
}
function sha256(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
const platform = mapPlatform(process.env.CLOUDCLI_BUNDLE_PLATFORM || process.platform);
const arch = mapArch(process.env.CLOUDCLI_BUNDLE_ARCH || process.arch);
const version = packageJson.version;
const bundleName = `cloudcli-local-server-${version}-${platform}-${arch}.tar.gz`;
const bundleRoot = path.join(rootDir, 'release', 'local-server');
const stageDir = path.join(bundleRoot, `.stage-${version}-${platform}-${arch}`);
const archivePath = path.join(bundleRoot, bundleName);
await fs.rm(stageDir, { recursive: true, force: true });
await fs.mkdir(stageDir, { recursive: true });
await fs.mkdir(bundleRoot, { recursive: true });
await copyRequired(stageDir, 'dist');
await copyRequired(stageDir, 'dist-server');
await copyRequired(stageDir, 'public');
await copyRequired(stageDir, 'shared');
await copyRequired(stageDir, 'package-lock.json');
await copyIfExists(stageDir, 'scripts/fix-node-pty.js');
await writeServerPackageJson(stageDir);
console.log('Installing production server dependencies into bundle stage...');
await run('npm', ['ci', '--omit=dev'], {
cwd: stageDir,
env: {
...process.env,
npm_config_audit: 'false',
npm_config_fund: 'false',
},
});
const electronVersion = getElectronVersion();
const electronRebuild = process.platform === 'win32'
? path.join(rootDir, 'node_modules', '.bin', 'electron-rebuild.cmd')
: path.join(rootDir, 'node_modules', '.bin', 'electron-rebuild');
console.log(`Rebuilding native server dependencies for Electron ${electronVersion} (${arch})...`);
await run(electronRebuild, ['--version', electronVersion, '--module-dir', stageDir, '--arch', arch, '--force'], {
cwd: rootDir,
env: {
...process.env,
npm_config_audit: 'false',
npm_config_fund: 'false',
},
});
if (await pathExists(path.join(stageDir, 'scripts', 'fix-node-pty.js'))) {
await run(process.execPath, ['scripts/fix-node-pty.js'], { cwd: stageDir });
}
await fs.writeFile(
path.join(stageDir, '.installed.json'),
JSON.stringify({ version, platform, arch, builtAt: new Date().toISOString() }, null, 2),
'utf8',
);
await fs.rm(archivePath, { force: true });
const tarArgs = process.platform === 'win32'
? ['-czf', archivePath, '-C', stageDir, '.']
: ['-czf', archivePath, '-C', stageDir, '.'];
await run('tar', tarArgs);
const digest = await sha256(archivePath);
const checksumPath = `${archivePath}.sha256`;
await fs.writeFile(checksumPath, `${digest} ${bundleName}\n`, 'utf8');
await fs.rm(stageDir, { recursive: true, force: true });
const size = (await fs.stat(archivePath)).size / 1024 / 1024;
console.log(`Wrote ${path.relative(rootDir, archivePath)} (${size.toFixed(1)} MB)`);
console.log(`Wrote ${path.relative(rootDir, checksumPath)}`);

View File

@@ -0,0 +1,158 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..', '..');
const stageDir = path.join(rootDir, '.desktop-build', 'desktop-app');
const packageJson = JSON.parse(
await fs.readFile(path.join(rootDir, 'package.json'), 'utf8'),
);
function getElectronVersion() {
try {
return JSON.parse(
readFileSync(path.join(rootDir, 'node_modules', 'electron', 'package.json'), 'utf8'),
).version;
} catch {
try {
return JSON.parse(
readFileSync(path.join(rootDir, 'package-lock.json'), 'utf8'),
).packages['node_modules/electron'].version;
} catch {
throw new Error('Could not resolve an exact Electron version for desktop packaging.');
}
}
}
async function pathExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function copyRequired(relativePath) {
const from = path.join(rootDir, relativePath);
const to = path.join(stageDir, relativePath);
if (!(await pathExists(from))) {
throw new Error(`Required desktop build input is missing: ${relativePath}`);
}
await fs.cp(from, to, { recursive: true });
}
async function copyIfExists(relativePath) {
const from = path.join(rootDir, relativePath);
if (!(await pathExists(from))) return false;
await fs.cp(from, path.join(stageDir, relativePath), { recursive: true });
return true;
}
async function copyNodeModule(packageName) {
const parts = packageName.split('/');
const source = path.join(rootDir, 'node_modules', ...parts);
if (!(await pathExists(source))) return false;
const target = path.join(stageDir, 'node_modules', ...parts);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.cp(source, target, { recursive: true });
return true;
}
function buildDesktopPackageJson(copiedOptionalDependencies) {
return {
name: `${packageJson.name}-desktop`,
version: packageJson.version,
productName: packageJson.productName,
description: `${packageJson.productName} desktop shell`,
author: packageJson.author,
license: packageJson.license,
type: 'module',
main: 'electron/main.js',
dependencies: {
ws: packageJson.dependencies.ws,
},
optionalDependencies: copiedOptionalDependencies,
build: {
appId: packageJson.build.appId,
productName: packageJson.build.productName,
asar: packageJson.build.asar,
artifactName: packageJson.build.artifactName,
electronVersion: getElectronVersion(),
directories: {
output: '../../release/desktop',
},
extraMetadata: {
main: 'electron/main.js',
},
files: [
'electron/**',
'public/**',
'dist/**',
'dist-server/**',
'node_modules/**',
'package.json',
],
protocols: packageJson.build.protocols,
mac: packageJson.build.mac,
win: packageJson.build.win,
nsis: packageJson.build.nsis,
},
};
}
await fs.rm(stageDir, { recursive: true, force: true });
await fs.mkdir(stageDir, { recursive: true });
await copyRequired('electron');
await copyRequired('dist');
await copyRequired('public');
// The desktop app still ships the standalone Computer Use desktop agent, but
// not the full local server. Local CloudCLI is downloaded on demand.
await copyRequired('dist-server/server/computer-use-agent.js');
await copyIfExists('dist-server/server/computer-use-agent.js.map');
await copyRequired('dist-server/server/modules/computer-use');
const copiedRuntimeDependencies = [];
if (await copyNodeModule('ws')) {
copiedRuntimeDependencies.push('ws');
} else {
throw new Error('Required desktop dependency is missing from node_modules: ws');
}
const copiedOptionalDependencies = {};
for (const [name, version] of Object.entries(packageJson.optionalDependencies || {})) {
if (await copyNodeModule(name)) {
copiedOptionalDependencies[name] = version;
}
}
for (const name of [
'@nut-tree-fork/default-clipboard-provider',
'@nut-tree-fork/libnut',
'@nut-tree-fork/provider-interfaces',
'@nut-tree-fork/shared',
'jimp',
'node-abort-controller',
'temp',
]) {
await copyNodeModule(name);
}
await fs.writeFile(
path.join(stageDir, 'package.json'),
`${JSON.stringify(buildDesktopPackageJson(copiedOptionalDependencies), null, 2)}\n`,
'utf8',
);
console.log(`Prepared thin desktop app at ${path.relative(rootDir, stageDir)}`);
console.log(`Runtime dependencies: ${copiedRuntimeDependencies.join(', ')}`);
if (Object.keys(copiedOptionalDependencies).length) {
console.log(`Optional dependencies: ${Object.keys(copiedOptionalDependencies).join(', ')}`);
}

384
server/browser-use-mcp.ts Normal file
View File

@@ -0,0 +1,384 @@
#!/usr/bin/env node
import './load-env.js';
type JsonRpcRequest = {
jsonrpc: '2.0';
id?: string | number | null;
method: string;
params?: Record<string, unknown>;
};
type ToolDefinition = {
name: string;
description: string;
inputSchema: Record<string, unknown>;
};
const textResponse = (text: string) => ({
content: [{ type: 'text', text }],
});
const jsonResponse = (value: unknown) => textResponse(JSON.stringify(value, null, 2));
const readString = (value: unknown, name: string): string => {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`${name} is required.`);
}
return value.trim();
};
const readOptionalString = (value: unknown): string | undefined =>
typeof value === 'string' && value.trim() ? value.trim() : undefined;
const readNumber = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
const apiUrl = (process.env.CLOUDCLI_BROWSER_USE_API_URL || 'http://127.0.0.1:3001/api/browser-use-mcp').replace(/\/$/, '');
const apiToken = process.env.CLOUDCLI_BROWSER_USE_MCP_TOKEN || '';
const API_TIMEOUT_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_API_TIMEOUT_MS || '60000', 10);
async function callBrowserUseApi(toolName: string, input: Record<string, unknown>) {
if (!apiToken) {
throw new Error('CLOUDCLI_BROWSER_USE_MCP_TOKEN is not configured.');
}
const response = await fetch(`${apiUrl}/tools/${encodeURIComponent(toolName)}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
signal: AbortSignal.timeout(API_TIMEOUT_MS),
});
const data = await response.json() as { success?: boolean; data?: unknown; error?: string };
if (!response.ok || data.success === false) {
throw new Error(data.error || `Browser API request failed (${response.status})`);
}
return data.data;
}
const sessionIdSchema = {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Browser session id.' },
},
required: ['sessionId'],
};
const tools: ToolDefinition[] = [
{
name: 'browser_create_session',
description: 'Create a temporary Browser session that the agent can control. Optionally provide a background profileName to reuse cookies and storage.',
inputSchema: {
type: 'object',
properties: {
profileName: { type: 'string', description: 'Optional background profile name for persistent browser storage.' },
},
},
},
{
name: 'browser_list_sessions',
description: 'List Browser sessions currently available to agents.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'browser_snapshot',
description: 'Capture current page metadata, screenshot data URL, and visible body text for a Browser session.',
inputSchema: sessionIdSchema,
},
{
name: 'browser_take_screenshot',
description: 'Capture the latest screenshot for a Browser session.',
inputSchema: sessionIdSchema,
},
{
name: 'browser_navigate',
description: 'Navigate a Browser session to an HTTP or HTTPS URL.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
url: { type: 'string' },
},
required: ['sessionId', 'url'],
},
},
{
name: 'browser_click',
description: 'Click an element by CSS selector, visible text, or x/y coordinates.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
selector: { type: 'string' },
text: { type: 'string' },
x: { type: 'number' },
y: { type: 'number' },
},
required: ['sessionId'],
},
},
{
name: 'browser_type',
description: 'Type text into the focused page or fill a CSS selector. Set submit to press Enter after typing.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
selector: { type: 'string' },
text: { type: 'string' },
submit: { type: 'boolean' },
},
required: ['sessionId', 'text'],
},
},
{
name: 'browser_fill_form',
description: 'Fill multiple form fields using CSS selectors.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
fields: {
type: 'array',
items: {
type: 'object',
properties: {
selector: { type: 'string' },
value: { type: 'string' },
},
required: ['selector', 'value'],
},
},
},
required: ['sessionId', 'fields'],
},
},
{
name: 'browser_press_key',
description: 'Press a keyboard key, for example Enter, Escape, Tab, or Control+A.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
key: { type: 'string' },
},
required: ['sessionId', 'key'],
},
},
{
name: 'browser_select_option',
description: 'Select option values in a select element found by CSS selector.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
selector: { type: 'string' },
values: { type: 'array', items: { type: 'string' } },
},
required: ['sessionId', 'selector', 'values'],
},
},
{
name: 'browser_wait_for',
description: 'Wait for visible text, a URL pattern, or a short timeout.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
text: { type: 'string' },
url: { type: 'string' },
timeoutMs: { type: 'number' },
},
required: ['sessionId'],
},
},
{
name: 'browser_tabs',
description: 'List, open, select, or close tabs in a Browser session.',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
action: { type: 'string', enum: ['list', 'new', 'select', 'close'] },
index: { type: 'number' },
url: { type: 'string' },
},
required: ['sessionId'],
},
},
{
name: 'browser_close_session',
description: 'Stop a Browser session controlled by agents.',
inputSchema: sessionIdSchema,
},
];
async function callTool(name: string, args: Record<string, unknown>) {
switch (name) {
case 'browser_create_session':
return jsonResponse(await callBrowserUseApi(name, {
profileName: readOptionalString(args.profileName),
}));
case 'browser_list_sessions':
return jsonResponse(await callBrowserUseApi(name, {}));
case 'browser_snapshot':
return jsonResponse(await callBrowserUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') }));
case 'browser_take_screenshot': {
return jsonResponse(await callBrowserUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') }));
}
case 'browser_navigate':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
url: readString(args.url, 'url'),
}));
case 'browser_click':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
selector: readOptionalString(args.selector),
text: readOptionalString(args.text),
x: readNumber(args.x),
y: readNumber(args.y),
}));
case 'browser_type':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
selector: readOptionalString(args.selector),
text: readString(args.text, 'text'),
submit: args.submit === true,
}));
case 'browser_fill_form': {
const fields = Array.isArray(args.fields)
? args.fields.map((field) => {
const record = field as Record<string, unknown>;
return {
selector: readString(record.selector, 'field.selector'),
value: readString(record.value, 'field.value'),
};
})
: [];
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
fields,
}));
}
case 'browser_press_key':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
key: readString(args.key, 'key'),
}));
case 'browser_select_option':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
selector: readString(args.selector, 'selector'),
values: Array.isArray(args.values) ? args.values.filter((value): value is string => typeof value === 'string') : [],
}));
case 'browser_wait_for':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
text: readOptionalString(args.text),
url: readOptionalString(args.url),
timeoutMs: readNumber(args.timeoutMs),
}));
case 'browser_tabs':
return jsonResponse(await callBrowserUseApi(name, {
sessionId: readString(args.sessionId, 'sessionId'),
action: args.action === 'new' || args.action === 'select' || args.action === 'close' || args.action === 'list'
? args.action
: undefined,
index: readNumber(args.index),
url: readOptionalString(args.url),
}));
case 'browser_close_session':
return jsonResponse(await callBrowserUseApi(name, { sessionId: readString(args.sessionId, 'sessionId') }));
default:
throw new Error(`Unknown tool: ${name}`);
}
}
async function handleMessage(message: JsonRpcRequest) {
if (message.method === 'initialize') {
return {
protocolVersion: '2024-11-05',
capabilities: { tools: {} },
serverInfo: { name: 'cloudcli-browser', version: '1.0.0' },
};
}
if (message.method === 'tools/list') {
return { tools };
}
if (message.method === 'tools/call') {
const params = message.params || {};
const name = readString(params.name, 'name');
const args = (params.arguments && typeof params.arguments === 'object'
? params.arguments
: {}) as Record<string, unknown>;
return callTool(name, args);
}
if (message.method.startsWith('notifications/')) {
return undefined;
}
throw new Error(`Unsupported method: ${message.method}`);
}
function writeMessage(message: Record<string, unknown>) {
// MCP stdio transport uses newline-delimited JSON (one JSON-RPC message per line,
// no embedded newlines). This is NOT the LSP Content-Length framing.
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function sendResult(id: string | number | null | undefined, result: unknown) {
if (id === undefined) {
return;
}
writeMessage({ jsonrpc: '2.0', id, result });
}
function sendError(id: string | number | null | undefined, error: unknown) {
if (id === undefined) {
return;
}
writeMessage({
jsonrpc: '2.0',
id,
error: {
code: -32000,
message: error instanceof Error ? error.message : String(error),
},
});
}
let buffer = '';
process.stdin.on('data', (chunk) => {
buffer += chunk.toString('utf8');
let newlineIndex: number;
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
const rawMessage = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!rawMessage) {
continue;
}
void (async () => {
let request: JsonRpcRequest;
try {
request = JSON.parse(rawMessage) as JsonRpcRequest;
} catch (error) {
sendError(null, error);
return;
}
try {
const result = await handleMessage(request);
sendResult(request.id, result);
} catch (error) {
sendError(request.id, error);
}
})();
}
});

View File

@@ -17,22 +17,29 @@ import crypto from 'crypto';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
import { CLAUDE_FALLBACK_MODELS } from './modules/providers/list/claude/claude-models.provider.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
import {
createNotificationEvent,
notifyRunFailed,
notifyRunStopped,
notifyUserIfEnabled
} from './services/notification-orchestrator.js';
import { claudeAdapter } from './providers/claude/adapter.js';
import { createNormalizedMessage } from './providers/types.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
const activeSessions = new Map();
const pendingToolApprovals = new Map();
// Sessions cancelled via abort-session. The abort handler already sent the
// terminal `complete` (aborted: true) to the client, so the run loop must not
// emit a second one when its generator winds down.
const abortedSessionIds = new Set();
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion']);
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);
function createRequestId() {
if (typeof crypto.randomUUID === 'function') {
@@ -148,6 +155,14 @@ function mapCliOptionsToSDK(options = {}) {
const sdkOptions = {};
// Forward all host env vars (e.g. ANTHROPIC_BASE_URL) to the subprocess.
// Since SDK 0.2.113, options.env replaces process.env instead of overlaying it.
sdkOptions.env = { ...process.env };
// Resolve the executable eagerly on Windows because the SDK uses raw child_process.spawn,
// which does not reliably follow npm's shell wrappers like cross-spawn does.
sdkOptions.pathToClaudeCodeExecutable = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH);
// Map working directory
if (cwd) {
sdkOptions.cwd = cwd;
@@ -193,8 +208,8 @@ function mapCliOptionsToSDK(options = {}) {
sdkOptions.disallowedTools = settings.disallowedTools || [];
// Map model (default to sonnet)
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m], fable
sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT;
// Model logged at query start below
// Map system prompt configuration
@@ -274,43 +289,75 @@ function transformMessage(sdkMessage) {
return sdkMessage;
}
function readNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
/**
* Extracts token usage from SDK result messages
* @param {Object} resultMessage - SDK result message
* Extracts token usage from SDK messages.
* Prefers per-step `message.usage` (Claude message payload), then falls back
* to result-level usage/modelUsage for compatibility across SDK versions.
* @param {Object} sdkMessage - SDK stream message
* @returns {Object|null} Token budget object or null
*/
function extractTokenBudget(resultMessage) {
if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
function extractTokenBudget(sdkMessage) {
if (!sdkMessage || typeof sdkMessage !== 'object') {
return null;
}
// Get the first model's usage data
const modelKey = Object.keys(resultMessage.modelUsage)[0];
const modelData = resultMessage.modelUsage[modelKey];
const messageUsage = sdkMessage.message?.usage || sdkMessage.usage;
if (messageUsage && typeof messageUsage === 'object') {
const directInputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
const cacheCreationTokens = readNumber(messageUsage.cache_creation_input_tokens ?? messageUsage.cacheCreationInputTokens ?? messageUsage.cacheCreationTokens);
const cacheReadTokens = readNumber(messageUsage.cache_read_input_tokens ?? messageUsage.cacheReadInputTokens ?? messageUsage.cacheReadTokens);
const cacheTokens = cacheCreationTokens + cacheReadTokens;
const inputTokens = directInputTokens + cacheTokens;
const outputTokens = readNumber(messageUsage.output_tokens ?? messageUsage.outputTokens);
const totalUsed = inputTokens + outputTokens;
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
if (!modelData) {
return {
used: totalUsed,
total: contextWindow,
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
cacheTokens,
breakdown: {
input: inputTokens,
output: outputTokens,
},
};
}
if (!sdkMessage.modelUsage || typeof sdkMessage.modelUsage !== 'object') {
return null;
}
// Use cumulative tokens if available (tracks total for the session)
// Otherwise fall back to per-request tokens
const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0;
const outputTokens = modelData.cumulativeOutputTokens || modelData.outputTokens || 0;
const cacheReadTokens = modelData.cumulativeCacheReadInputTokens || modelData.cacheReadInputTokens || 0;
const cacheCreationTokens = modelData.cumulativeCacheCreationInputTokens || modelData.cacheCreationInputTokens || 0;
// Fallback for older SDK messages with only modelUsage
const modelKey = Object.keys(sdkMessage.modelUsage)[0];
const modelData = sdkMessage.modelUsage[modelKey];
// Total used = input + output + cache tokens
const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
if (!modelData || typeof modelData !== 'object') {
return null;
}
// Use configured context window budget from environment (default 160000)
// This is the user's budget limit, not the model's context window
const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
// Token calc logged via token-budget WS event
const inputTokens = readNumber(modelData.cumulativeInputTokens ?? modelData.inputTokens);
const outputTokens = readNumber(modelData.cumulativeOutputTokens ?? modelData.outputTokens);
const totalUsed = inputTokens + outputTokens;
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
return {
used: totalUsed,
total: contextWindow
total: contextWindow,
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens,
},
};
}
@@ -481,8 +528,17 @@ async function queryClaudeSDK(command, options = {}, ws) {
};
try {
const resolvedModel = await providerModelsService.resolveResumeModel(
'claude',
sessionId,
options.model,
);
// Map CLI options to SDK format
const sdkOptions = mapCliOptionsToSDK(options);
const sdkOptions = mapCliOptionsToSDK({
...options,
model: resolvedModel || options.model,
});
// Load MCP configuration
const mcpServers = await loadMcpConfig(options.cwd);
@@ -516,6 +572,12 @@ async function queryClaudeSDK(command, options = {}, ws) {
}]
};
// Caveat: in 'auto' and 'bypassPermissions' modes the SDK resolves approval
// at the permission-mode step and skips this callback, so interactive tools
// (AskUserQuestion, ExitPlanMode) won't reach the UI — the classifier/bypass
// auto-approves them and the model acts on a generated answer. Move these
// tools to a PreToolUse hook (runs before the mode check) if we need them
// to work in those modes.
sdkOptions.canUseTool = async (toolName, input, context) => {
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
@@ -649,7 +711,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
const sid = capturedSessionId || sessionId || null;
// Use adapter to normalize SDK events into NormalizedMessage[]
const normalized = claudeAdapter.normalizeMessage(transformedMessage, sid);
const normalized = sessionsService.normalizeMessage('claude', transformedMessage, sid);
for (const msg of normalized) {
// Preserve parentToolUseId from SDK wrapper for subagent tool grouping
if (transformedMessage.parentToolUseId && !msg.parentToolUseId) {
@@ -658,16 +720,10 @@ async function queryClaudeSDK(command, options = {}, ws) {
ws.send(msg);
}
// Extract and send token budget updates from result messages
if (message.type === 'result') {
const models = Object.keys(message.modelUsage || {});
if (models.length > 0) {
// Model info available in result message
}
const tokenBudgetData = extractTokenBudget(message);
if (tokenBudgetData) {
ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
}
// Extract and send token budget updates from assistant/result usage payloads
const tokenBudgetData = extractTokenBudget(message);
if (tokenBudgetData) {
ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
}
}
@@ -679,14 +735,18 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Clean up temporary image files
await cleanupTempFiles(tempImagePaths, tempDir);
// Send completion event
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' }));
// Send the terminal completion event — skipped for aborted runs, whose
// terminal `complete` (aborted: true) was already sent by abort-session.
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
if (!wasAborted) {
ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 0 }));
}
notifyRunStopped({
userId: ws?.userId || null,
provider: 'claude',
sessionId: capturedSessionId || sessionId || null,
sessionName: sessionSummary,
stopReason: 'completed'
stopReason: wasAborted ? 'aborted' : 'completed'
});
// Complete
@@ -701,8 +761,22 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Clean up temporary image files on error
await cleanupTempFiles(tempImagePaths, tempDir);
// Send error to WebSocket
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
const wasAborted = capturedSessionId ? abortedSessionIds.delete(capturedSessionId) : false;
if (wasAborted) {
// The abort already produced the terminal complete; a generator throw
// caused by interrupt() is expected noise, not a user-facing error.
return;
}
// Check if Claude CLI is installed for a clearer error message
const installed = await providerAuthService.isProviderInstalled('claude');
const errorContent = !installed
? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
: error.message;
// Send error to WebSocket, then the terminal complete
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
ws.send(createCompleteMessage({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
notifyRunFailed({
userId: ws?.userId || null,
provider: 'claude',
@@ -710,8 +784,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
sessionName: sessionSummary,
error
});
throw error;
}
}
@@ -731,6 +803,10 @@ async function abortClaudeSDKSession(sessionId) {
try {
console.log(`Aborting SDK session: ${sessionId}`);
// Mark before interrupting so the run loop knows not to emit its own
// terminal complete (the abort handler sends the aborted one).
abortedSessionIds.add(sessionId);
// Call interrupt() on the query instance
await session.instance.interrupt();
@@ -746,6 +822,8 @@ async function abortClaudeSDKSession(sessionId) {
return true;
} catch (error) {
console.error(`Error aborting session ${sessionId}:`, error);
// The run keeps going; let it emit its own terminal complete.
abortedSessionIds.delete(sessionId);
return false;
}
}

View File

@@ -7,6 +7,8 @@
* Commands:
* (no args) - Start the server (default)
* start - Start the server
* sandbox - Manage Docker sandbox environments
* browser-use-mcp - Run Browser MCP stdio server
* status - Show configuration and data locations
* help - Show help information
* version - Show version information
@@ -15,11 +17,12 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const __dirname = getModuleDir(import.meta.url);
// The CLI is compiled into dist-server/server, but it still needs to read the top-level
// package.json and .env file. Resolving the app root once keeps those lookups stable.
const APP_ROOT = findAppRoot(__dirname);
// ANSI color codes for terminal output
const colors = {
@@ -49,13 +52,16 @@ const c = {
};
// Load package.json for version info
const packageJsonPath = path.join(__dirname, '../package.json');
const packageJsonPath = path.join(APP_ROOT, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
// Match the runtime fallback in load-env.js so "cloudcli status" reports the same default
// database location that the backend will actually use when no DATABASE_PATH is configured.
const DEFAULT_DATABASE_PATH = path.join(os.homedir(), '.cloudcli', 'auth.db');
// Load environment variables from .env file if it exists
function loadEnvFile() {
try {
const envPath = path.join(__dirname, '../.env');
const envPath = path.join(APP_ROOT, '.env');
const envFile = fs.readFileSync(envPath, 'utf8');
envFile.split('\n').forEach(line => {
const trimmedLine = line.trim();
@@ -74,12 +80,12 @@ function loadEnvFile() {
// Get the database path (same logic as db.js)
function getDatabasePath() {
loadEnvFile();
return process.env.DATABASE_PATH || path.join(__dirname, 'database', 'auth.db');
return process.env.DATABASE_PATH || DEFAULT_DATABASE_PATH;
}
// Get the installation directory
function getInstallDir() {
return path.join(__dirname, '..');
return APP_ROOT;
}
// Show status command
@@ -123,7 +129,7 @@ function showStatus() {
console.log(` Status: ${projectsExists ? c.ok('[OK] Exists') : c.warn('[WARN] Not found')}`);
// Config file location
const envFilePath = path.join(__dirname, '../.env');
const envFilePath = path.join(APP_ROOT, '.env');
const envExists = fs.existsSync(envFilePath);
console.log(`\n${c.info('[INFO]')} Configuration File:`);
console.log(` ${c.dim(envFilePath)}`);
@@ -149,11 +155,13 @@ Usage:
cloudcli [command] [options]
Commands:
start Start the CloudCLI server (default)
status Show configuration and data locations
update Update to the latest version
help Show this help information
version Show version information
start Start the CloudCLI server (default)
sandbox Manage Docker sandbox environments
browser-use-mcp Run the Browser MCP stdio server
status Show configuration and data locations
update Update to the latest version
help Show this help information
version Show version information
Options:
-p, --port <port> Set server port (default: 3001)
@@ -164,8 +172,7 @@ Options:
Examples:
$ cloudcli # Start with defaults
$ cloudcli --port 8080 # Start on port 8080
$ cloudcli -p 3000 # Short form for port
$ cloudcli start --port 4000 # Explicit start command
$ cloudcli sandbox ~/my-project # Run in a Docker sandbox
$ cloudcli status # Show configuration
Environment Variables:
@@ -244,6 +251,353 @@ async function updatePackage() {
}
}
// ── Sandbox command ─────────────────────────────────────────
const SANDBOX_TEMPLATES = {
claude: 'docker.io/cloudcliai/sandbox:claude-code',
codex: 'docker.io/cloudcliai/sandbox:codex',
gemini: 'docker.io/cloudcliai/sandbox:gemini',
};
const SANDBOX_SECRETS = {
claude: 'anthropic',
codex: 'openai',
gemini: 'google',
};
function parseSandboxArgs(args) {
const result = {
subcommand: null,
workspace: null,
agent: 'claude',
name: null,
port: 3001,
template: null,
env: [],
};
const subcommands = ['ls', 'stop', 'start', 'rm', 'logs', 'help'];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (i === 0 && subcommands.includes(arg)) {
result.subcommand = arg;
} else if (arg === '--agent' || arg === '-a') {
result.agent = args[++i];
} else if (arg === '--name' || arg === '-n') {
result.name = args[++i];
} else if (arg === '--port') {
result.port = parseInt(args[++i], 10);
} else if (arg === '--template' || arg === '-t') {
result.template = args[++i];
} else if (arg === '--env' || arg === '-e') {
result.env.push(args[++i]);
} else if (!arg.startsWith('-')) {
if (!result.subcommand) {
result.workspace = arg;
} else {
result.name = arg; // for stop/start/rm/logs <name>
}
}
}
// Default subcommand based on what we got
if (!result.subcommand) {
result.subcommand = 'create';
}
// Derive name from workspace path if not set
if (!result.name && result.workspace) {
result.name = path.basename(path.resolve(result.workspace.replace(/^~/, os.homedir())));
}
// Default template from agent
if (!result.template) {
result.template = SANDBOX_TEMPLATES[result.agent] || SANDBOX_TEMPLATES.claude;
}
return result;
}
function showSandboxHelp() {
console.log(`
${c.bright('CloudCLI Sandbox')} — Run CloudCLI inside Docker Sandboxes
Usage:
cloudcli sandbox <workspace> Create and start a sandbox
cloudcli sandbox <subcommand> [name] Manage sandboxes
Subcommands:
${c.bright('(default)')} Create a sandbox and start the web UI
${c.bright('ls')} List all sandboxes
${c.bright('start')} Restart a stopped sandbox and re-launch the web UI
${c.bright('stop')} Stop a sandbox (preserves state)
${c.bright('rm')} Remove a sandbox
${c.bright('logs')} Show CloudCLI server logs
${c.bright('help')} Show this help
Options:
-a, --agent <agent> Agent to use: claude, codex, gemini (default: claude)
-n, --name <name> Sandbox name (default: derived from workspace folder)
-t, --template <image> Custom template image
-e, --env <KEY=VALUE> Set environment variable (repeatable)
--port <port> Host port for the web UI (default: 3001)
Examples:
$ cloudcli sandbox ~/my-project
$ cloudcli sandbox ~/my-project --agent codex --port 8080
$ cloudcli sandbox ~/my-project --env SERVER_PORT=8080 --env HOST=0.0.0.0
$ cloudcli sandbox ls
$ cloudcli sandbox stop my-project
$ cloudcli sandbox start my-project
$ cloudcli sandbox rm my-project
Prerequisites:
1. Install sbx CLI: https://docs.docker.com/ai/sandboxes/get-started/
2. Authenticate and store your API key:
sbx login
sbx secret set -g anthropic # for Claude
sbx secret set -g openai # for Codex
sbx secret set -g google # for Gemini
Advanced usage:
For branch mode, multiple workspaces, memory limits, network policies,
or passing prompts to the agent, use sbx directly with the template:
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/my-project --branch my-feature
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/project ~/libs:ro --memory 8g
Full Docker Sandboxes docs: https://docs.docker.com/ai/sandboxes/usage/
`);
}
async function sandboxCommand(args) {
const { execFileSync, spawn: spawnProcess } = await import('child_process');
// Safe execution — uses execFileSync (no shell) to prevent injection
const sbx = (subcmd, opts = {}) => {
const result = execFileSync('sbx', subcmd, {
encoding: 'utf8',
stdio: opts.inherit ? 'inherit' : 'pipe',
});
return result || '';
};
const opts = parseSandboxArgs(args);
if (opts.subcommand === 'help') {
showSandboxHelp();
return;
}
// Validate name (alphanumeric, hyphens, underscores only)
if (opts.name && !/^[\w-]+$/.test(opts.name)) {
console.error(`\n${c.error('❌')} Invalid sandbox name: ${opts.name}`);
console.log(` Names may only contain letters, numbers, hyphens, and underscores.\n`);
process.exit(1);
}
// Check sbx is installed
try {
sbx(['version']);
} catch {
console.error(`\n${c.error('❌')} ${c.bright('sbx')} CLI not found.\n`);
console.log(` Install it from: ${c.info('https://docs.docker.com/ai/sandboxes/get-started/')}`);
console.log(` Then run: ${c.bright('sbx login')}`);
console.log(` And store your API key: ${c.bright('sbx secret set -g anthropic')}\n`);
process.exit(1);
}
switch (opts.subcommand) {
case 'ls':
sbx(['ls'], { inherit: true });
break;
case 'stop':
if (!opts.name) {
console.error(`\n${c.error('❌')} Sandbox name required: cloudcli sandbox stop <name>\n`);
process.exit(1);
}
sbx(['stop', opts.name], { inherit: true });
break;
case 'rm':
if (!opts.name) {
console.error(`\n${c.error('❌')} Sandbox name required: cloudcli sandbox rm <name>\n`);
process.exit(1);
}
sbx(['rm', opts.name], { inherit: true });
break;
case 'logs':
if (!opts.name) {
console.error(`\n${c.error('❌')} Sandbox name required: cloudcli sandbox logs <name>\n`);
process.exit(1);
}
try {
sbx(['exec', opts.name, 'bash', '-c', 'cat /tmp/cloudcli-ui.log'], { inherit: true });
} catch (e) {
console.error(`\n${c.error('❌')} Could not read logs: ${e.message || 'Is the sandbox running?'}\n`);
}
break;
case 'start': {
if (!opts.name) {
console.error(`\n${c.error('❌')} Sandbox name required: cloudcli sandbox start <name>\n`);
process.exit(1);
}
console.log(`\n${c.info('▶')} Starting sandbox ${c.bright(opts.name)}...`);
const restartRun = spawnProcess('sbx', ['run', opts.name], {
detached: true,
stdio: ['ignore', 'ignore', 'ignore'],
});
restartRun.unref();
await new Promise(resolve => setTimeout(resolve, 5000));
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
try {
sbx(['ports', opts.name, '--publish', `${opts.port}:3001`]);
} catch (e) {
const msg = e.stdout || e.stderr || e.message || '';
if (msg.includes('address already in use')) {
const altPort = opts.port + 1;
console.log(`${c.warn('⚠')} Port ${opts.port} in use, trying ${altPort}...`);
try {
sbx(['ports', opts.name, '--publish', `${altPort}:3001`]);
opts.port = altPort;
} catch {
console.error(`${c.error('❌')} Ports ${opts.port} and ${altPort} both in use. Use --port to specify a free port.`);
process.exit(1);
}
} else {
throw e;
}
}
console.log(`\n${c.ok('✔')} ${c.bright('CloudCLI is ready!')}`);
console.log(` ${c.info('→')} ${c.bright(`http://localhost:${opts.port}`)}\n`);
break;
}
case 'create': {
if (!opts.workspace) {
console.error(`\n${c.error('❌')} Workspace path required: cloudcli sandbox <path>\n`);
console.log(` Example: ${c.bright('cloudcli sandbox ~/my-project')}\n`);
process.exit(1);
}
const workspace = opts.workspace.startsWith('~')
? opts.workspace.replace(/^~/, os.homedir())
: path.resolve(opts.workspace);
if (!fs.existsSync(workspace)) {
console.error(`\n${c.error('❌')} Workspace path not found: ${c.dim(workspace)}\n`);
process.exit(1);
}
const secret = SANDBOX_SECRETS[opts.agent] || 'anthropic';
// Check if the required secret is stored
try {
const secretList = sbx(['secret', 'ls']);
if (!secretList.includes(secret)) {
console.error(`\n${c.error('❌')} No ${c.bright(secret)} API key found.\n`);
console.log(` Run: ${c.bright(`sbx secret set -g ${secret}`)}\n`);
process.exit(1);
}
} catch { /* sbx secret ls not available, skip check */ }
console.log(`\n${c.bright('CloudCLI Sandbox')}`);
console.log(c.dim('─'.repeat(50)));
console.log(` Agent: ${c.info(opts.agent)} ${c.dim(`(${secret} credentials)`)}`);
console.log(` Workspace: ${c.dim(workspace)}`);
console.log(` Name: ${c.dim(opts.name)}`);
console.log(` Template: ${c.dim(opts.template)}`);
console.log(` Port: ${c.dim(String(opts.port))}`);
if (opts.env.length > 0) {
console.log(` Env: ${c.dim(opts.env.join(', '))}`);
}
console.log(c.dim('─'.repeat(50)));
// Step 1: Launch sandbox with sbx run in background.
// sbx run creates the sandbox (or reconnects) AND holds an active session,
// which prevents the sandbox from auto-stopping.
console.log(`\n${c.info('▶')} Creating sandbox ${c.bright(opts.name)}...`);
const bgRun = spawnProcess('sbx', [
'run', '--template', opts.template, '--name', opts.name, opts.agent, workspace,
], {
detached: true,
stdio: ['ignore', 'ignore', 'ignore'],
});
bgRun.unref();
// Wait for sandbox to be ready
await new Promise(resolve => setTimeout(resolve, 5000));
// Step 2: Inject environment variables
if (opts.env.length > 0) {
console.log(`${c.info('▶')} Setting environment variables...`);
const exports = opts.env
.filter(e => /^\w+=.+$/.test(e))
.map(e => `export ${e}`)
.join('\n');
if (exports) {
sbx(['exec', opts.name, 'bash', '-c', `echo '${exports}' >> /etc/sandbox-persistent.sh`]);
}
const invalid = opts.env.filter(e => !/^\w+=.+$/.test(e));
if (invalid.length > 0) {
console.log(`${c.warn('⚠')} Skipped invalid env vars: ${invalid.join(', ')} (expected KEY=VALUE)`);
}
}
// Step 3: Start CloudCLI inside the sandbox
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
// Step 4: Forward port
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
try {
sbx(['ports', opts.name, '--publish', `${opts.port}:3001`]);
} catch (e) {
const msg = e.stdout || e.stderr || e.message || '';
if (msg.includes('address already in use')) {
const altPort = opts.port + 1;
console.log(`${c.warn('⚠')} Port ${opts.port} in use, trying ${altPort}...`);
try {
sbx(['ports', opts.name, '--publish', `${altPort}:3001`]);
opts.port = altPort;
} catch {
console.error(`${c.error('❌')} Ports ${opts.port} and ${altPort} both in use. Use --port to specify a free port.`);
process.exit(1);
}
} else {
throw e;
}
}
// Done
console.log(`\n${c.ok('✔')} ${c.bright('CloudCLI is ready!')}`);
console.log(` ${c.info('→')} Open ${c.bright(`http://localhost:${opts.port}`)}`);
console.log(`\n${c.dim(' Manage with:')}`);
console.log(` ${c.dim('$')} sbx ls`);
console.log(` ${c.dim('$')} sbx stop ${opts.name}`);
console.log(` ${c.dim('$')} sbx start ${opts.name}`);
console.log(` ${c.dim('$')} sbx rm ${opts.name}`);
console.log(`\n${c.dim(' Or install globally:')} npm install -g @cloudcli-ai/cloudcli\n`);
break;
}
default:
showSandboxHelp();
}
}
// ── Server ──────────────────────────────────────────────────
// Start the server
async function startServer() {
// Check for updates silently on startup
@@ -253,6 +607,10 @@ async function startServer() {
await import('./index.js');
}
async function startBrowserUseMcp() {
await import('./browser-use-mcp.js');
}
// Parse CLI arguments
function parseArgs(args) {
const parsed = { command: 'start', options: {} };
@@ -274,6 +632,10 @@ function parseArgs(args) {
parsed.command = 'version';
} else if (!arg.startsWith('-')) {
parsed.command = arg;
if (arg === 'sandbox') {
parsed.remainingArgs = args.slice(i + 1);
break;
}
}
}
@@ -283,7 +645,7 @@ function parseArgs(args) {
// Main CLI handler
async function main() {
const args = process.argv.slice(2);
const { command, options } = parseArgs(args);
const { command, options, remainingArgs } = parseArgs(args);
// Apply CLI options to environment variables
if (options.serverPort) {
@@ -299,6 +661,12 @@ async function main() {
case 'start':
await startServer();
break;
case 'sandbox':
await sandboxCommand(remainingArgs || []);
break;
case 'browser-use-mcp':
await startBrowserUseMcp();
break;
case 'status':
case 'info':
showStatus();

View File

@@ -0,0 +1,279 @@
#!/usr/bin/env node
/**
* CloudCLI Computer Use — Desktop Agent.
*
* Standalone executor for the cloud relay. The Electron desktop app spawns this
* process (via ELECTRON_RUN_AS_NODE) whenever Computer Use is enabled and the
* user has running cloud environments. It opens an outbound websocket to each
* environment's `/desktop-agent` endpoint and executes the `computer_*` actions
* the hosted server relays, returning a fresh screenshot each time.
*
* It is fully self-contained: it reuses the shared nut-js executor module and
* does NOT depend on the local CloudCLI server. Consent is enforced here (the
* controlled machine is the authority): in `ask` mode the agent asks the parent
* Electron process for a per-session decision before the first action runs.
*/
import readline from 'node:readline';
import { WebSocket } from 'ws';
import {
getRuntimeReadiness,
type Point,
type ClickButton,
type ScrollDirection,
} from './modules/computer-use/computer-executor.js';
import { runRawComputerAction } from './modules/computer-use/actions/raw-action-dispatcher.js';
import type { RawActionTarget, RawComputerAction } from './modules/computer-use/actions/raw-action-types.js';
import { computerSemanticsService } from './modules/computer-use/computer-semantics.service.js';
type ConsentMode = 'ask' | 'auto';
type RelayMessage = {
kind?: string;
type?: string;
id?: string;
params?: Record<string, unknown>;
};
const IPC_PREFIX = '@@CUAGENT@@';
const RECONNECT_BASE_MS = 2000;
const RECONNECT_MAX_MS = 30_000;
const consentMode: ConsentMode = process.env.CLOUDCLI_COMPUTER_USE_CONSENT_MODE === 'auto' ? 'auto' : 'ask';
const agentLabel = process.env.CLOUDCLI_DESKTOP_AGENT_LABEL || 'cloudcli-desktop';
const desktopAgentApiKey = process.env.CLOUDCLI_DESKTOP_AGENT_API_KEY || '';
function parseTargets(): string[] {
const raw =
process.env.CLOUDCLI_DESKTOP_AGENT_URLS ||
process.env.CLOUDCLI_DESKTOP_AGENT_URL ||
'';
return raw
.split(',')
.map((value) => value.trim())
.filter(Boolean);
}
// --- Parent (Electron) IPC over stdout/stdin -------------------------------
function emitToParent(message: Record<string, unknown>): void {
process.stdout.write(`${IPC_PREFIX} ${JSON.stringify(message)}\n`);
}
/** Per-session consent decisions, and resolvers awaiting a parent reply. */
const sessionConsent = new Map<string, 'granted' | 'denied'>();
const pendingConsent = new Map<string, Array<(allow: boolean) => void>>();
const stdinReader = readline.createInterface({ input: process.stdin });
stdinReader.on('line', (line) => {
const trimmed = line.trim();
if (!trimmed.startsWith(IPC_PREFIX)) {
return;
}
try {
const payload = JSON.parse(trimmed.slice(IPC_PREFIX.length).trim()) as Record<string, unknown>;
if (payload.type === 'consent-response' && typeof payload.sessionId === 'string') {
const allow = payload.allow === true;
sessionConsent.set(payload.sessionId, allow ? 'granted' : 'denied');
const waiters = pendingConsent.get(payload.sessionId) || [];
pendingConsent.delete(payload.sessionId);
for (const resolve of waiters) {
resolve(allow);
}
} else if (payload.type === 'revoke-session' && typeof payload.sessionId === 'string') {
sessionConsent.delete(payload.sessionId);
}
} catch {
// ignore malformed control lines
}
});
async function ensureConsent(sessionId: string): Promise<boolean> {
if (consentMode === 'auto') {
return true;
}
const existing = sessionConsent.get(sessionId);
if (existing === 'granted') return true;
if (existing === 'denied') return false;
// Ask the parent (Electron) to prompt the user, and wait for the decision.
return new Promise<boolean>((resolve) => {
const waiters = pendingConsent.get(sessionId) || [];
waiters.push(resolve);
pendingConsent.set(sessionId, waiters);
emitToParent({ type: 'consent-request', sessionId });
});
}
// --- Action execution ------------------------------------------------------
function asPoint(value: unknown): Point | undefined {
if (value && typeof value === 'object') {
const point = value as Record<string, unknown>;
if (typeof point.x === 'number' && typeof point.y === 'number') {
return { x: point.x, y: point.y };
}
}
return undefined;
}
function rawActionFromRelay(type: string, params: Record<string, unknown>): RawComputerAction {
const point = asPoint(params.point);
switch (type) {
case 'screenshot':
return { type: 'screenshot' };
case 'cursor_position':
return { type: 'cursor_position' };
case 'mouse_move':
if (!point) {
throw new Error('mouse_move requires a valid point.');
}
return { type: 'mouse_move', point };
case 'click':
return {
type: 'click',
button: (params.button as ClickButton) || 'left',
point,
double: params.double === true,
};
case 'drag': {
const from = asPoint(params.from);
const to = asPoint(params.to);
if (!from || !to) {
throw new Error('drag requires valid from and to points.');
}
return { type: 'drag', from, to, button: (params.button as ClickButton) || 'left' };
}
case 'type':
return { type: 'type', text: String(params.text ?? '') };
case 'key':
return { type: 'key', key: String(params.key ?? '') };
case 'scroll':
return {
type: 'scroll',
direction: (params.direction as ScrollDirection) || 'down',
amount: typeof params.amount === 'number' ? params.amount : 3,
point,
};
case 'wait':
return { type: 'wait', ms: typeof params.ms === 'number' ? params.ms : undefined };
default:
throw new Error(`Unsupported computer action: ${type}`);
}
}
async function runAction(type: string, params: Record<string, unknown>): Promise<Record<string, unknown>> {
if (type === 'semantic_tool') {
const toolName = typeof params.toolName === 'string' ? params.toolName : '';
const args = params.arguments && typeof params.arguments === 'object'
? params.arguments as Record<string, unknown>
: {};
const sessionId = typeof params.sessionId === 'string' ? params.sessionId : 'default';
if (!toolName) {
throw new Error('semantic_tool requires toolName.');
}
return await computerSemanticsService.callTool(toolName, { ...args, sessionId }) as Record<string, unknown>;
}
const readiness = getRuntimeReadiness();
if (!readiness.nutInstalled || !readiness.screenshotInstalled) {
throw new Error('Computer Use runtime is not installed on the desktop agent.');
}
const target: RawActionTarget = {
displaySize: (params.displaySize as RawActionTarget['displaySize']) ?? null,
};
return await runRawComputerAction(rawActionFromRelay(type, params), target) as Record<string, unknown>;
}
// --- Relay connection ------------------------------------------------------
function connect(url: string): void {
let reconnectMs = RECONNECT_BASE_MS;
let socket: WebSocket | null = null;
const open = () => {
socket = new WebSocket(url, {
headers: desktopAgentApiKey ? { 'X-API-Key': desktopAgentApiKey } : undefined,
});
socket.on('open', () => {
reconnectMs = RECONNECT_BASE_MS;
emitToParent({ type: 'connected', url });
socket?.send(JSON.stringify({ kind: 'register', label: agentLabel, consentMode }));
});
socket.on('message', async (raw) => {
let message: RelayMessage;
try {
message = JSON.parse(String(raw)) as RelayMessage;
} catch {
return;
}
const kind = message.kind || message.type;
if (kind !== 'computer_relay' || typeof message.id !== 'string') {
return;
}
const id = message.id;
const type = String(message.type || (message.params?.type as string) || '');
const params = message.params || {};
const sessionId = typeof params.sessionId === 'string' ? params.sessionId : 'default';
if (type === 'stop_session') {
sessionConsent.delete(sessionId);
socket?.send(JSON.stringify({ kind: 'computer_relay_result', id, result: { ok: true } }));
return;
}
try {
const allowed = await ensureConsent(sessionId);
if (!allowed) {
socket?.send(JSON.stringify({ kind: 'computer_relay_result', id, error: 'The user denied desktop control for this session.' }));
return;
}
const result = await runAction(type, params);
socket?.send(JSON.stringify({ kind: 'computer_relay_result', id, result }));
} catch (error) {
socket?.send(JSON.stringify({
kind: 'computer_relay_result',
id,
error: error instanceof Error ? error.message : 'Desktop agent action failed.',
}));
}
});
const scheduleReconnect = (code?: number, reason?: Buffer) => {
const reasonText = reason?.toString() || '';
emitToParent({ type: 'disconnected', url, code, reason: reasonText });
if (code === 1008 && /computer use.*disabled/i.test(reasonText)) {
return;
}
setTimeout(open, reconnectMs);
reconnectMs = Math.min(reconnectMs * 2, RECONNECT_MAX_MS);
};
socket.on('close', scheduleReconnect);
socket.on('error', () => {
try { socket?.close(); } catch { /* noop */ }
});
};
open();
}
function main(): void {
const targets = parseTargets();
if (targets.length === 0) {
emitToParent({ type: 'error', message: 'No desktop-agent target URLs provided.' });
return;
}
emitToParent({ type: 'starting', targets, consentMode });
for (const url of targets) {
connect(url);
}
}
main();

574
server/computer-use-mcp.ts Normal file
View File

@@ -0,0 +1,574 @@
#!/usr/bin/env node
import './load-env.js';
type JsonRpcRequest = {
jsonrpc: '2.0';
id?: string | number | null;
method: string;
params?: Record<string, unknown>;
};
type ToolDefinition = {
name: string;
description: string;
inputSchema: Record<string, unknown>;
};
const readString = (value: unknown, name: string): string => {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`${name} is required.`);
}
return value.trim();
};
const readOptionalString = (value: unknown): string | undefined =>
typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
const readNumber = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
const readMouseButton = (value: unknown): 'left' | 'right' | 'middle' =>
value === 'right' || value === 'middle' ? value : 'left';
const apiUrl = (process.env.CLOUDCLI_COMPUTER_USE_API_URL || 'http://127.0.0.1:3001/api/computer-use-mcp').replace(/\/$/, '');
const apiToken = process.env.CLOUDCLI_COMPUTER_USE_MCP_TOKEN || '';
const computerUseInstructions = `
CloudCLI Computer Use lets you operate the user's real desktop through guarded sessions. Use it deliberately: observe first, act second, then verify.
Recommended app workflow:
1. If you do not know the target app name, call computer_list_apps.
2. Call computer_get_app_state for the target app before app-scoped actions. It returns a screenshot, accessibility elements, and a stateId.
3. Prefer semantic element actions using stateId + element_index from the latest computer_get_app_state result. Do not guess element indexes or reuse them after large UI changes without refreshing state.
4. Use x/y coordinates from the returned screenshot only when no suitable element_index is available.
5. After every action, inspect the returned screenshot/state before deciding the next action.
Use app-scoped tools when the target app is known: computer_list_apps, computer_get_app_state, computer_click_element, computer_perform_secondary_action, computer_set_value, computer_type_text, computer_press_key, computer_scroll_element, and computer_app_drag.
Use raw desktop tools only when you need full-screen coordinate control, cursor position, or current-focus input: computer_screenshot, computer_cursor_position, computer_mouse_move, computer_click, computer_drag, computer_type, computer_key, computer_scroll, computer_wait, and computer_close_session. Raw coordinates are screenshot pixels, so call computer_screenshot first when you need a coordinate frame.
Most tools can use or create the active agent session automatically when sessionId is omitted. In local mode, input actions require the user to grant control in the Computer tab before they work. In cloud mode, approval is handled by the linked CloudCLI desktop app.
If a tool reports missing permission, denied control, or no available desktop session, stop retrying and ask the user to fix access. For local mode, ask them to open CloudCLI Desktop, go to the Computer tab, enable Computer Use, grant the requested OS permissions, and allow the session. On macOS this usually means Accessibility and Screen Recording. For cloud mode, ask them to keep the linked CloudCLI Desktop app running and approve the cloud agent's Computer Use request there.
Ask before sending, deleting, purchasing, approving, uploading, publishing, changing account settings, or making other externally visible or destructive changes. Do not inspect unrelated private content unless the user explicitly asked for that task.
`.trim();
async function callComputerUseApi(toolName: string, input: Record<string, unknown>) {
if (!apiToken) {
throw new Error('CLOUDCLI_COMPUTER_USE_MCP_TOKEN is not configured.');
}
const response = await fetch(`${apiUrl}/tools/${encodeURIComponent(toolName)}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
});
const data = await response.json() as { success?: boolean; data?: unknown; error?: string };
if (!response.ok || data.success === false) {
throw new Error(data.error || `Computer Use API request failed (${response.status})`);
}
return data.data;
}
/** Pulls the most recent screenshot data URL out of an API result, if present. */
function findScreenshot(value: unknown): string | null {
if (!value || typeof value !== 'object') {
return null;
}
const record = value as Record<string, unknown>;
if (typeof record.screenshotDataUrl === 'string') {
return record.screenshotDataUrl;
}
if (record.session && typeof record.session === 'object') {
const session = record.session as Record<string, unknown>;
if (typeof session.screenshotDataUrl === 'string') {
return session.screenshotDataUrl;
}
}
return null;
}
/** Removes the large data URL from JSON so the text block stays small. */
function stripScreenshot(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(stripScreenshot);
}
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
if (key === 'screenshotDataUrl' && typeof val === 'string') {
out.screenshot = '[returned as image]';
continue;
}
out[key] = stripScreenshot(val);
}
return out;
}
return value;
}
/**
* Builds an MCP tool result. Screenshots are returned as an `image` content block so
* vision-capable models actually see the desktop — a JSON data-URL string would not work.
*/
function toolResult(value: unknown) {
const content: Array<Record<string, unknown>> = [
{ type: 'text', text: JSON.stringify(stripScreenshot(value), null, 2) },
];
const screenshot = findScreenshot(value);
const match = screenshot ? /^data:(image\/[a-z]+);base64,(.+)$/i.exec(screenshot) : null;
if (match) {
content.push({ type: 'image', data: match[2], mimeType: match[1] });
}
return { content };
}
const sessionIdSchema = {
type: 'object',
properties: {
sessionId: { type: 'string', description: 'Optional. Omit to use or create the active agent session automatically.' },
},
};
const optionalSessionProperty = sessionIdSchema.properties.sessionId;
const withOptionalSession = (properties: Record<string, unknown> = {}) => ({
sessionId: optionalSessionProperty,
...properties,
});
const optionalSessionInput = (args: Record<string, unknown>, extra: Record<string, unknown> = {}) => ({
sessionId: readOptionalString(args.sessionId),
...extra,
});
const stateIdProperty = {
type: 'string',
description: 'State id returned by the latest computer_get_app_state call for this app. Send it with element_index so the runtime can resolve the cached element.',
};
const elementIndexProperty = {
type: 'string',
description: 'Element index from the latest computer_get_app_state result for this app. Use with stateId when possible.',
};
const tools: ToolDefinition[] = [
{
name: 'computer_list_apps',
description: 'Discover app names, bundle identifiers, process names, and window titles that can be used as the app target for app-scoped Computer Use tools. Call this first when you are unsure which app string to pass to computer_get_app_state.',
inputSchema: { type: 'object', properties: withOptionalSession() },
},
{
name: 'computer_get_app_state',
description: 'Inspect a target app and return its current screenshot, accessibility elements, and stateId. Call this before element-targeted actions, after navigation, and whenever the UI may have changed enough that old element indexes could be stale.',
inputSchema: {
type: 'object',
properties: withOptionalSession({
app: { type: 'string', description: 'App name, process name, bundle identifier, or window title from computer_list_apps or the user request.' },
}),
required: ['app'],
},
},
{
name: 'computer_click_element',
description: 'Click a target inside an app. Prefer stateId + element_index from computer_get_app_state; use x/y screenshot coordinates only when the target is not represented in the accessibility elements.',
inputSchema: {
type: 'object',
properties: withOptionalSession({
app: { type: 'string', description: 'Target app name, process name, bundle identifier, or window title.' },
stateId: stateIdProperty,
element_index: elementIndexProperty,
x: { type: 'number', description: 'X coordinate in screenshot pixel coordinates from computer_get_app_state.' },
y: { type: 'number', description: 'Y coordinate in screenshot pixel coordinates from computer_get_app_state.' },
click_count: { type: 'integer', description: 'Number of clicks, usually 1. Defaults to 1 and is capped by the runtime.' },
mouse_button: { type: 'string', enum: ['left', 'right', 'middle'], description: 'Button for the click; omitted means left.' },
}),
required: ['app'],
},
},
{
name: 'computer_perform_secondary_action',
description: 'Open the secondary action for a target inside an app, typically a context menu. Prefer stateId + element_index; if native secondary actions are unavailable, the runtime falls back to a right-click at the resolved point.',
inputSchema: {
type: 'object',
properties: withOptionalSession({
app: { type: 'string', description: 'Target app name, process name, bundle identifier, or window title.' },
stateId: stateIdProperty,
element_index: elementIndexProperty,
x: { type: 'number', description: 'X coordinate in screenshot pixel coordinates from computer_get_app_state.' },
y: { type: 'number', description: 'Y coordinate in screenshot pixel coordinates from computer_get_app_state.' },
}),
required: ['app'],
},
},
{
name: 'computer_set_value',
description: 'Set the value of a specific editable element in an app. Prefer stateId + element_index for a settable accessibility element; coordinate fallback focuses the resolved point and replaces the current value, so do not call this unless the target is resolved.',
inputSchema: {
type: 'object',
properties: withOptionalSession({
app: { type: 'string', description: 'Target app name, process name, bundle identifier, or window title.' },
stateId: stateIdProperty,
element_index: elementIndexProperty,
x: { type: 'number', description: 'X coordinate in screenshot pixel coordinates from computer_get_app_state.' },
y: { type: 'number', description: 'Y coordinate in screenshot pixel coordinates from computer_get_app_state.' },
value: { type: 'string', description: 'Exact value to put into the target element.' },
}),
required: ['app', 'value'],
},
},
{
name: 'computer_type_text',
description: 'Type literal text into the target app using keyboard input. Use after you have focused the intended field with computer_click_element or verified the correct focus in computer_get_app_state.',
inputSchema: {
type: 'object',
properties: withOptionalSession({
app: { type: 'string', description: 'Target app name, process name, bundle identifier, or window title.' },
text: { type: 'string', description: 'Text to enter exactly as provided.' },
}),
required: ['app', 'text'],
},
},
{
name: 'computer_press_key',
description: 'Press a key or key combination in the target app. Use for navigation, shortcuts, and confirmation keys after verifying the intended app/focus.',
inputSchema: {
type: 'object',
properties: withOptionalSession({
app: { type: 'string', description: 'Target app name, process name, bundle identifier, or window title.' },
key: { type: 'string', description: 'Key or chord, using names such as Return, Escape, Tab, ctrl+s, cmd+a, Up, or Page_Down.' },
}),
required: ['app', 'key'],
},
},
{
name: 'computer_scroll_element',
description: 'Scroll a target area inside an app. Prefer stateId + element_index for scrollable elements; use x/y screenshot coordinates only when the scroll target is visible but not represented as an element.',
inputSchema: {
type: 'object',
properties: withOptionalSession({
app: { type: 'string', description: 'Target app name, process name, bundle identifier, or window title.' },
stateId: stateIdProperty,
element_index: elementIndexProperty,
x: { type: 'number', description: 'X coordinate in screenshot pixel coordinates from computer_get_app_state.' },
y: { type: 'number', description: 'Y coordinate in screenshot pixel coordinates from computer_get_app_state.' },
direction: { type: 'string', enum: ['up', 'down', 'left', 'right'], description: 'Direction to scroll the target.' },
pages: { type: 'number', description: 'How far to scroll, measured in page units. Fractional values are allowed; default is 1.' },
}),
required: ['app', 'direction'],
},
},
{
name: 'computer_app_drag',
description: 'Drag inside a target app from one screenshot coordinate to another. Use for sliders, selections, map/canvas gestures, or drag-and-drop when no semantic element action is available.',
inputSchema: {
type: 'object',
properties: withOptionalSession({
app: { type: 'string', description: 'Target app name, process name, bundle identifier, or window title.' },
from_x: { type: 'number', description: 'Start X coordinate in screenshot pixels.' },
from_y: { type: 'number', description: 'Start Y coordinate in screenshot pixels.' },
to_x: { type: 'number', description: 'End X coordinate in screenshot pixels.' },
to_y: { type: 'number', description: 'End Y coordinate in screenshot pixels.' },
}),
required: ['app', 'from_x', 'from_y', 'to_x', 'to_y'],
},
},
{
name: 'computer_screenshot',
description: 'Capture the full desktop screenshot and current display size. Use this before raw coordinate actions when an app-specific accessibility state is unavailable or the task spans multiple apps.',
inputSchema: sessionIdSchema,
},
{
name: 'computer_cursor_position',
description: 'Get the current mouse cursor position in desktop screenshot pixel coordinates. Useful after a raw action misses or when coordinating pointer-relative steps.',
inputSchema: sessionIdSchema,
},
{
name: 'computer_mouse_move',
description: 'Move the mouse cursor to an exact full-desktop screenshot coordinate. Call computer_screenshot first if you do not already have a current coordinate frame.',
inputSchema: {
type: 'object',
properties: {
sessionId: optionalSessionProperty,
x: { type: 'number', description: 'X coordinate in full-desktop screenshot pixels.' },
y: { type: 'number', description: 'Y coordinate in full-desktop screenshot pixels.' },
},
required: ['x', 'y'],
},
},
{
name: 'computer_click',
description: 'Raw desktop click at the current cursor or at optional full-desktop screenshot coordinates. Prefer computer_click_element when the target app and element are known.',
inputSchema: {
type: 'object',
properties: {
sessionId: optionalSessionProperty,
x: { type: 'number', description: 'Optional X coordinate in full-desktop screenshot pixels.' },
y: { type: 'number', description: 'Optional Y coordinate in full-desktop screenshot pixels.' },
mouseButton: { type: 'string', enum: ['left', 'right', 'middle'], description: 'Button for the click; omitted means left.' },
clickCount: { type: 'integer', description: 'How many times to click; omitted means 1.' },
},
},
},
{
name: 'computer_drag',
description: 'Raw desktop drag from start coordinates to end coordinates in full-desktop screenshot pixels. Prefer computer_app_drag for app-scoped drags when the target app is known.',
inputSchema: {
type: 'object',
properties: {
sessionId: optionalSessionProperty,
startX: { type: 'number', description: 'Start X coordinate in full-desktop screenshot pixels.' },
startY: { type: 'number', description: 'Start Y coordinate in full-desktop screenshot pixels.' },
endX: { type: 'number', description: 'End X coordinate in full-desktop screenshot pixels.' },
endY: { type: 'number', description: 'End Y coordinate in full-desktop screenshot pixels.' },
mouseButton: { type: 'string', enum: ['left', 'right', 'middle'], description: 'Button to hold during the drag; omitted means left.' },
},
required: ['startX', 'startY', 'endX', 'endY'],
},
},
{
name: 'computer_type',
description: 'Type literal text at the current desktop focus. This is not app-scoped; use only after verifying the intended field is focused.',
inputSchema: {
type: 'object',
properties: { sessionId: optionalSessionProperty, text: { type: 'string', description: 'Text to enter exactly as provided at current focus.' } },
required: ['text'],
},
},
{
name: 'computer_key',
description: 'Press a key or key chord at the current desktop focus. This is not app-scoped; use computer_press_key when the target app is known.',
inputSchema: {
type: 'object',
properties: { sessionId: optionalSessionProperty, key: { type: 'string', description: 'Key or chord, using names such as Return, Escape, Tab, ctrl+s, cmd+a, Up, or Page_Down.' } },
required: ['key'],
},
},
{
name: 'computer_scroll',
description: 'Raw desktop scroll at the current cursor or optional full-desktop screenshot coordinates. Prefer computer_scroll_element when the target app/element is known.',
inputSchema: {
type: 'object',
properties: {
sessionId: optionalSessionProperty,
direction: { type: 'string', enum: ['up', 'down', 'left', 'right'], description: 'Direction to scroll the desktop target.' },
amount: { type: 'number', description: 'Scroll amount in wheel/page-like units. Defaults are runtime-defined.' },
x: { type: 'number', description: 'Optional X coordinate in full-desktop screenshot pixels.' },
y: { type: 'number', description: 'Optional Y coordinate in full-desktop screenshot pixels.' },
},
required: ['direction'],
},
},
{
name: 'computer_wait',
description: 'Wait briefly, up to 10000 ms, then return an updated desktop screenshot. Use after actions that trigger loading, animation, or delayed UI changes.',
inputSchema: {
type: 'object',
properties: { sessionId: optionalSessionProperty, timeoutMs: { type: 'number', description: 'Milliseconds to wait. The runtime caps long waits.' } },
},
},
{
name: 'computer_close_session',
description: 'Stop the active auto-created Computer Use session, or the specified session, and revoke agent input control for that session.',
inputSchema: sessionIdSchema,
},
];
async function callTool(name: string, args: Record<string, unknown>) {
switch (name) {
case 'computer_app_drag':
case 'computer_click_element':
case 'computer_get_app_state':
case 'computer_list_apps':
case 'computer_perform_secondary_action':
case 'computer_press_key':
case 'computer_scroll_element':
case 'computer_set_value':
case 'computer_type_text':
return toolResult(await callComputerUseApi(name, args));
case 'computer_screenshot':
case 'computer_cursor_position':
case 'computer_close_session':
return toolResult(await callComputerUseApi(name, optionalSessionInput(args)));
case 'computer_mouse_move':
return toolResult(await callComputerUseApi(name, optionalSessionInput(args, {
x: readNumber(args.x),
y: readNumber(args.y),
})));
case 'computer_click':
return toolResult(await callComputerUseApi(name, optionalSessionInput(args, {
x: readNumber(args.x),
y: readNumber(args.y),
mouseButton: readMouseButton(args.mouseButton ?? args.mouse_button ?? args.button),
clickCount: readNumber(args.clickCount ?? args.click_count),
})));
case 'computer_drag':
return toolResult(await callComputerUseApi(name, optionalSessionInput(args, {
startX: readNumber(args.startX),
startY: readNumber(args.startY),
endX: readNumber(args.endX),
endY: readNumber(args.endY),
mouseButton: readMouseButton(args.mouseButton ?? args.mouse_button ?? args.button),
})));
case 'computer_type':
return toolResult(await callComputerUseApi(name, optionalSessionInput(args, {
text: readString(args.text, 'text'),
})));
case 'computer_key':
return toolResult(await callComputerUseApi(name, optionalSessionInput(args, {
key: readString(args.key, 'key'),
})));
case 'computer_scroll':
return toolResult(await callComputerUseApi(name, optionalSessionInput(args, {
direction: typeof args.direction === 'string' ? args.direction : 'up',
amount: readNumber(args.amount),
x: readNumber(args.x),
y: readNumber(args.y),
})));
case 'computer_wait':
return toolResult(await callComputerUseApi(name, optionalSessionInput(args, {
timeoutMs: readNumber(args.timeoutMs),
})));
default:
throw new Error(`Unknown tool: ${name}`);
}
}
async function handleMessage(message: JsonRpcRequest) {
if (message.method === 'initialize') {
return {
protocolVersion: '2024-11-05',
capabilities: { tools: {} },
serverInfo: { name: 'cloudcli-computer-use', version: '1.0.0' },
instructions: computerUseInstructions,
};
}
if (message.method === 'tools/list') {
return { tools };
}
if (message.method === 'tools/call') {
const params = message.params || {};
const name = readString(params.name, 'name');
const args = (params.arguments && typeof params.arguments === 'object'
? params.arguments
: {}) as Record<string, unknown>;
return callTool(name, args);
}
if (message.method.startsWith('notifications/')) {
return undefined;
}
throw new Error(`Unsupported method: ${message.method}`);
}
type MessageFraming = 'content-length' | 'line';
function writeMessage(message: Record<string, unknown>, framing: MessageFraming) {
const payload = JSON.stringify(message);
if (framing === 'line') {
process.stdout.write(`${payload}\n`);
return;
}
process.stdout.write(`Content-Length: ${Buffer.byteLength(payload, 'utf8')}\r\n\r\n${payload}`);
}
function sendResult(id: string | number | null | undefined, result: unknown, framing: MessageFraming) {
if (id === undefined) {
return;
}
writeMessage({ jsonrpc: '2.0', id, result }, framing);
}
function sendError(id: string | number | null | undefined, error: unknown, framing: MessageFraming) {
if (id === undefined) {
return;
}
writeMessage({
jsonrpc: '2.0',
id,
error: {
code: -32000,
message: error instanceof Error ? error.message : String(error),
},
}, framing);
}
let buffer = Buffer.alloc(0);
function handleRawMessage(rawMessage: string, framing: MessageFraming) {
void (async () => {
let request: JsonRpcRequest | null = null;
try {
request = JSON.parse(rawMessage) as JsonRpcRequest;
const result = await handleMessage(request);
sendResult(request.id, result, framing);
} catch (error) {
sendError(request?.id ?? null, error, framing);
}
})();
}
function findHeaderEnd(input: Buffer): { index: number; length: number } | null {
const crlf = input.indexOf('\r\n\r\n');
if (crlf !== -1) {
return { index: crlf, length: 4 };
}
const lf = input.indexOf('\n\n');
if (lf !== -1) {
return { index: lf, length: 2 };
}
return null;
}
process.stdin.on('data', (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
while (true) {
const headerEnd = findHeaderEnd(buffer);
if (!headerEnd) {
if (/^Content-Length:/i.test(buffer.toString('utf8', 0, Math.min(buffer.length, 32)))) {
return;
}
const newline = buffer.indexOf('\n');
if (newline === -1) {
return;
}
const rawLine = buffer.slice(0, newline).toString('utf8').trim();
buffer = buffer.slice(newline + 1);
if (!rawLine) {
continue;
}
handleRawMessage(rawLine, 'line');
continue;
}
const header = buffer.slice(0, headerEnd.index).toString('utf8');
const lengthMatch = /Content-Length:\s*(\d+)/i.exec(header);
if (!lengthMatch) {
buffer = buffer.slice(headerEnd.index + headerEnd.length);
continue;
}
const length = Number.parseInt(lengthMatch[1], 10);
const messageStart = headerEnd.index + headerEnd.length;
const messageEnd = messageStart + length;
if (buffer.length < messageEnd) {
return;
}
const rawMessage = buffer.slice(messageStart, messageEnd).toString('utf8');
buffer = buffer.slice(messageEnd);
handleRawMessage(rawMessage, 'content-length');
}
});

View File

@@ -1,8 +1,10 @@
import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { cursorAdapter } from './providers/cursor/adapter.js';
import { createNormalizedMessage } from './providers/types.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
// Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
@@ -27,10 +29,15 @@ function isWorkspaceTrustPrompt(text = '') {
async function spawnCursor(command, options = {}, ws) {
return new Promise(async (resolve, reject) => {
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, sessionSummary } = options;
const resolvedModel = await providerModelsService.resolveResumeModel('cursor', sessionId, model);
let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event
let hasRetriedWithTrust = false;
let settled = false;
// The unified lifecycle contract requires exactly one terminal `complete`
// per run. Cursor surfaces completion twice (the `result` JSON line and
// the process close), so the first emission wins.
let completeSent = false;
// Use tools settings passed from frontend, or defaults
const settings = toolsSettings || {
@@ -51,9 +58,10 @@ async function spawnCursor(command, options = {}, ws) {
// Provide a prompt (works for both new and resumed sessions)
baseArgs.push('-p', command);
// Add model flag if specified (only meaningful for new sessions; harmless on resume)
if (!sessionId && model) {
baseArgs.push('--model', model);
// Model overrides are applied to both new and resumed sessions so a
// session-scoped change request can take effect on the next turn.
if (resolvedModel) {
baseArgs.push('--model', resolvedModel);
}
// Request streaming JSON when we are providing a prompt
@@ -149,7 +157,6 @@ async function spawnCursor(command, options = {}, ws) {
try {
const response = JSON.parse(line);
console.log('Parsed JSON response:', response);
// Handle different message types
switch (response.type) {
@@ -158,7 +165,6 @@ async function spawnCursor(command, options = {}, ws) {
// Capture session ID
if (response.session_id && !capturedSessionId) {
capturedSessionId = response.session_id;
console.log('Captured session ID:', capturedSessionId);
// Update process key with captured session ID
if (processKey !== capturedSessionId) {
@@ -189,22 +195,21 @@ async function spawnCursor(command, options = {}, ws) {
case 'assistant':
// Accumulate assistant message chunks
if (response.message && response.message.content && response.message.content.length > 0) {
const normalized = cursorAdapter.normalizeMessage(response, capturedSessionId || sessionId || null);
const normalized = sessionsService.normalizeMessage('cursor', response, capturedSessionId || sessionId || null);
for (const msg of normalized) ws.send(msg);
}
break;
case 'result': {
// Session complete — send stream end + lifecycle complete with result payload
console.log('Cursor session result:', response);
const resultText = typeof response.result === 'string' ? response.result : '';
ws.send(createNormalizedMessage({
kind: 'complete',
exitCode: response.subtype === 'success' ? 0 : 1,
resultText,
isError: response.subtype !== 'success',
sessionId: capturedSessionId || sessionId, provider: 'cursor',
}));
// Session complete — terminal lifecycle event for this run
if (!completeSent) {
completeSent = true;
ws.send(createCompleteMessage({
provider: 'cursor',
sessionId: capturedSessionId || sessionId || null,
exitCode: response.subtype === 'success' ? 0 : 1,
}));
}
break;
}
@@ -212,14 +217,12 @@ async function spawnCursor(command, options = {}, ws) {
// Unknown message types — ignore.
}
} catch (parseError) {
console.log('Non-JSON response:', line);
if (shouldSuppressForTrustRetry(line)) {
return;
}
// If not JSON, send as stream delta via adapter
const normalized = cursorAdapter.normalizeMessage(line, capturedSessionId || sessionId || null);
const normalized = sessionsService.normalizeMessage('cursor', line, capturedSessionId || sessionId || null);
for (const msg of normalized) ws.send(msg);
}
};
@@ -227,7 +230,6 @@ async function spawnCursor(command, options = {}, ws) {
// Handle stdout (streaming JSON responses)
cursorProcess.stdout.on('data', (data) => {
const rawOutput = data.toString();
console.log('Cursor CLI stdout:', rawOutput);
// Stream chunks can split JSON objects across packets; keep trailing partial line.
stdoutLineBuffer += rawOutput;
@@ -253,8 +255,6 @@ async function spawnCursor(command, options = {}, ws) {
// Handle process completion
cursorProcess.on('close', async (code) => {
console.log(`Cursor CLI process exited with code ${code}`);
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
@@ -275,7 +275,12 @@ async function spawnCursor(command, options = {}, ws) {
return;
}
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'cursor' }));
// Terminal complete — unless the `result` line already sent it, or the
// run was aborted (abort-session sent the aborted complete).
if (!completeSent && !cursorProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'cursor', sessionId: finalSessionId, exitCode: code }));
}
if (code === 0) {
notifyTerminalState({ code });
@@ -287,14 +292,24 @@ async function spawnCursor(command, options = {}, ws) {
});
// Handle process errors
cursorProcess.on('error', (error) => {
cursorProcess.on('error', async (error) => {
console.error('Cursor CLI process error:', error);
// Clean up process reference on error
const finalSessionId = capturedSessionId || sessionId || processKey;
activeCursorProcesses.delete(finalSessionId);
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
// Check if Cursor CLI is installed for a clearer error message
const installed = await providerAuthService.isProviderInstalled('cursor');
const errorContent = !installed
? 'Cursor CLI is not installed. Please install it from https://cursor.com'
: error.message;
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));
if (!completeSent && !cursorProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'cursor', sessionId: capturedSessionId || sessionId || null, exitCode: 1 }));
}
notifyTerminalState({ error });
settleOnce(() => reject(error));
@@ -312,6 +327,9 @@ function abortCursorSession(sessionId) {
const process = activeCursorProcesses.get(sessionId);
if (process) {
console.log(`Aborting Cursor session: ${sessionId}`);
// The abort handler sends the terminal complete (aborted: true); flag the
// process so its close handler does not emit a second one.
process.aborted = true;
process.kill('SIGTERM');
activeCursorProcesses.delete(sessionId);
return true;

View File

@@ -1,630 +0,0 @@
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// ANSI color codes for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
cyan: '\x1b[36m',
dim: '\x1b[2m',
};
const c = {
info: (text) => `${colors.cyan}${text}${colors.reset}`,
bright: (text) => `${colors.bright}${text}${colors.reset}`,
dim: (text) => `${colors.dim}${text}${colors.reset}`,
};
// Use DATABASE_PATH environment variable if set, otherwise use default location
const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, 'auth.db');
const INIT_SQL_PATH = path.join(__dirname, 'init.sql');
// Ensure database directory exists if custom path is provided
if (process.env.DATABASE_PATH) {
const dbDir = path.dirname(DB_PATH);
try {
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
console.log(`Created database directory: ${dbDir}`);
}
} catch (error) {
console.error(`Failed to create database directory ${dbDir}:`, error.message);
throw error;
}
}
// As part of 1.19.2 we are introducing a new location for auth.db. The below handles exisitng moving legacy database from install directory to new location
const LEGACY_DB_PATH = path.join(__dirname, 'auth.db');
if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGACY_DB_PATH)) {
try {
fs.copyFileSync(LEGACY_DB_PATH, DB_PATH);
console.log(`[MIGRATION] Copied database from ${LEGACY_DB_PATH} to ${DB_PATH}`);
for (const suffix of ['-wal', '-shm']) {
if (fs.existsSync(LEGACY_DB_PATH + suffix)) {
fs.copyFileSync(LEGACY_DB_PATH + suffix, DB_PATH + suffix);
}
}
} catch (err) {
console.warn(`[MIGRATION] Could not copy legacy database: ${err.message}`);
}
}
// Create database connection
const db = new Database(DB_PATH);
// app_config must exist before any other module imports (auth.js reads the JWT secret at load time).
// runMigrations() also creates this table, but it runs too late for existing installations
// where auth.js is imported before initializeDatabase() is called.
db.exec(`CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Show app installation path prominently
const appInstallPath = path.join(__dirname, '../..');
console.log('');
console.log(c.dim('═'.repeat(60)));
console.log(`${c.info('[INFO]')} App Installation: ${c.bright(appInstallPath)}`);
console.log(`${c.info('[INFO]')} Database: ${c.dim(path.relative(appInstallPath, DB_PATH))}`);
if (process.env.DATABASE_PATH) {
console.log(` ${c.dim('(Using custom DATABASE_PATH from environment)')}`);
}
console.log(c.dim('═'.repeat(60)));
console.log('');
const runMigrations = () => {
try {
const tableInfo = db.prepare("PRAGMA table_info(users)").all();
const columnNames = tableInfo.map(col => col.name);
if (!columnNames.includes('git_name')) {
console.log('Running migration: Adding git_name column');
db.exec('ALTER TABLE users ADD COLUMN git_name TEXT');
}
if (!columnNames.includes('git_email')) {
console.log('Running migration: Adding git_email column');
db.exec('ALTER TABLE users ADD COLUMN git_email TEXT');
}
if (!columnNames.includes('has_completed_onboarding')) {
console.log('Running migration: Adding has_completed_onboarding column');
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
}
db.exec(`
CREATE TABLE IF NOT EXISTS user_notification_preferences (
user_id INTEGER PRIMARY KEY,
preferences_json TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS vapid_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
keys_p256dh TEXT NOT NULL,
keys_auth TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Create app_config table if it doesn't exist (for existing installations)
db.exec(`CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Create session_names table if it doesn't exist (for existing installations)
db.exec(`CREATE TABLE IF NOT EXISTS session_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'claude',
custom_name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(session_id, provider)
)`);
db.exec('CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider)');
console.log('Database migrations completed successfully');
} catch (error) {
console.error('Error running migrations:', error.message);
throw error;
}
};
// Initialize database with schema
const initializeDatabase = async () => {
try {
const initSQL = fs.readFileSync(INIT_SQL_PATH, 'utf8');
db.exec(initSQL);
console.log('Database initialized successfully');
runMigrations();
} catch (error) {
console.error('Error initializing database:', error.message);
throw error;
}
};
// User database operations
const userDb = {
// Check if any users exist
hasUsers: () => {
try {
const row = db.prepare('SELECT COUNT(*) as count FROM users').get();
return row.count > 0;
} catch (err) {
throw err;
}
},
// Create a new user
createUser: (username, passwordHash) => {
try {
const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
const result = stmt.run(username, passwordHash);
return { id: result.lastInsertRowid, username };
} catch (err) {
throw err;
}
},
// Get user by username
getUserByUsername: (username) => {
try {
const row = db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username);
return row;
} catch (err) {
throw err;
}
},
// Update last login time (non-fatal — logged but not thrown)
updateLastLogin: (userId) => {
try {
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
} catch (err) {
console.warn('Failed to update last login:', err.message);
}
},
// Get user by ID
getUserById: (userId) => {
try {
const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1').get(userId);
return row;
} catch (err) {
throw err;
}
},
getFirstUser: () => {
try {
const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE is_active = 1 LIMIT 1').get();
return row;
} catch (err) {
throw err;
}
},
updateGitConfig: (userId, gitName, gitEmail) => {
try {
const stmt = db.prepare('UPDATE users SET git_name = ?, git_email = ? WHERE id = ?');
stmt.run(gitName, gitEmail, userId);
} catch (err) {
throw err;
}
},
getGitConfig: (userId) => {
try {
const row = db.prepare('SELECT git_name, git_email FROM users WHERE id = ?').get(userId);
return row;
} catch (err) {
throw err;
}
},
completeOnboarding: (userId) => {
try {
const stmt = db.prepare('UPDATE users SET has_completed_onboarding = 1 WHERE id = ?');
stmt.run(userId);
} catch (err) {
throw err;
}
},
hasCompletedOnboarding: (userId) => {
try {
const row = db.prepare('SELECT has_completed_onboarding FROM users WHERE id = ?').get(userId);
return row?.has_completed_onboarding === 1;
} catch (err) {
throw err;
}
}
};
// API Keys database operations
const apiKeysDb = {
// Generate a new API key
generateApiKey: () => {
return 'ck_' + crypto.randomBytes(32).toString('hex');
},
// Create a new API key
createApiKey: (userId, keyName) => {
try {
const apiKey = apiKeysDb.generateApiKey();
const stmt = db.prepare('INSERT INTO api_keys (user_id, key_name, api_key) VALUES (?, ?, ?)');
const result = stmt.run(userId, keyName, apiKey);
return { id: result.lastInsertRowid, keyName, apiKey };
} catch (err) {
throw err;
}
},
// Get all API keys for a user
getApiKeys: (userId) => {
try {
const rows = db.prepare('SELECT id, key_name, api_key, created_at, last_used, is_active FROM api_keys WHERE user_id = ? ORDER BY created_at DESC').all(userId);
return rows;
} catch (err) {
throw err;
}
},
// Validate API key and get user
validateApiKey: (apiKey) => {
try {
const row = db.prepare(`
SELECT u.id, u.username, ak.id as api_key_id
FROM api_keys ak
JOIN users u ON ak.user_id = u.id
WHERE ak.api_key = ? AND ak.is_active = 1 AND u.is_active = 1
`).get(apiKey);
if (row) {
// Update last_used timestamp
db.prepare('UPDATE api_keys SET last_used = CURRENT_TIMESTAMP WHERE id = ?').run(row.api_key_id);
}
return row;
} catch (err) {
throw err;
}
},
// Delete an API key
deleteApiKey: (userId, apiKeyId) => {
try {
const stmt = db.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?');
const result = stmt.run(apiKeyId, userId);
return result.changes > 0;
} catch (err) {
throw err;
}
},
// Toggle API key active status
toggleApiKey: (userId, apiKeyId, isActive) => {
try {
const stmt = db.prepare('UPDATE api_keys SET is_active = ? WHERE id = ? AND user_id = ?');
const result = stmt.run(isActive ? 1 : 0, apiKeyId, userId);
return result.changes > 0;
} catch (err) {
throw err;
}
}
};
// User credentials database operations (for GitHub tokens, GitLab tokens, etc.)
const credentialsDb = {
// Create a new credential
createCredential: (userId, credentialName, credentialType, credentialValue, description = null) => {
try {
const stmt = db.prepare('INSERT INTO user_credentials (user_id, credential_name, credential_type, credential_value, description) VALUES (?, ?, ?, ?, ?)');
const result = stmt.run(userId, credentialName, credentialType, credentialValue, description);
return { id: result.lastInsertRowid, credentialName, credentialType };
} catch (err) {
throw err;
}
},
// Get all credentials for a user, optionally filtered by type
getCredentials: (userId, credentialType = null) => {
try {
let query = 'SELECT id, credential_name, credential_type, description, created_at, is_active FROM user_credentials WHERE user_id = ?';
const params = [userId];
if (credentialType) {
query += ' AND credential_type = ?';
params.push(credentialType);
}
query += ' ORDER BY created_at DESC';
const rows = db.prepare(query).all(...params);
return rows;
} catch (err) {
throw err;
}
},
// Get active credential value for a user by type (returns most recent active)
getActiveCredential: (userId, credentialType) => {
try {
const row = db.prepare('SELECT credential_value FROM user_credentials WHERE user_id = ? AND credential_type = ? AND is_active = 1 ORDER BY created_at DESC LIMIT 1').get(userId, credentialType);
return row?.credential_value || null;
} catch (err) {
throw err;
}
},
// Delete a credential
deleteCredential: (userId, credentialId) => {
try {
const stmt = db.prepare('DELETE FROM user_credentials WHERE id = ? AND user_id = ?');
const result = stmt.run(credentialId, userId);
return result.changes > 0;
} catch (err) {
throw err;
}
},
// Toggle credential active status
toggleCredential: (userId, credentialId, isActive) => {
try {
const stmt = db.prepare('UPDATE user_credentials SET is_active = ? WHERE id = ? AND user_id = ?');
const result = stmt.run(isActive ? 1 : 0, credentialId, userId);
return result.changes > 0;
} catch (err) {
throw err;
}
}
};
const DEFAULT_NOTIFICATION_PREFERENCES = {
channels: {
inApp: false,
webPush: false
},
events: {
actionRequired: true,
stop: true,
error: true
}
};
const normalizeNotificationPreferences = (value) => {
const source = value && typeof value === 'object' ? value : {};
return {
channels: {
inApp: source.channels?.inApp === true,
webPush: source.channels?.webPush === true
},
events: {
actionRequired: source.events?.actionRequired !== false,
stop: source.events?.stop !== false,
error: source.events?.error !== false
}
};
};
const notificationPreferencesDb = {
getPreferences: (userId) => {
try {
const row = db.prepare('SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?').get(userId);
if (!row) {
const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
db.prepare(
'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)'
).run(userId, JSON.stringify(defaults));
return defaults;
}
let parsed;
try {
parsed = JSON.parse(row.preferences_json);
} catch {
parsed = DEFAULT_NOTIFICATION_PREFERENCES;
}
return normalizeNotificationPreferences(parsed);
} catch (err) {
throw err;
}
},
updatePreferences: (userId, preferences) => {
try {
const normalized = normalizeNotificationPreferences(preferences);
db.prepare(
`INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
preferences_json = excluded.preferences_json,
updated_at = CURRENT_TIMESTAMP`
).run(userId, JSON.stringify(normalized));
return normalized;
} catch (err) {
throw err;
}
}
};
const pushSubscriptionsDb = {
saveSubscription: (userId, endpoint, keysP256dh, keysAuth) => {
try {
db.prepare(
`INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth)
VALUES (?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET
user_id = excluded.user_id,
keys_p256dh = excluded.keys_p256dh,
keys_auth = excluded.keys_auth`
).run(userId, endpoint, keysP256dh, keysAuth);
} catch (err) {
throw err;
}
},
getSubscriptions: (userId) => {
try {
return db.prepare('SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?').all(userId);
} catch (err) {
throw err;
}
},
removeSubscription: (endpoint) => {
try {
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);
} catch (err) {
throw err;
}
},
removeAllForUser: (userId) => {
try {
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);
} catch (err) {
throw err;
}
}
};
// Session custom names database operations
const sessionNamesDb = {
// Set (insert or update) a custom session name
setName: (sessionId, provider, customName) => {
db.prepare(`
INSERT INTO session_names (session_id, provider, custom_name)
VALUES (?, ?, ?)
ON CONFLICT(session_id, provider)
DO UPDATE SET custom_name = excluded.custom_name, updated_at = CURRENT_TIMESTAMP
`).run(sessionId, provider, customName);
},
// Get a single custom session name
getName: (sessionId, provider) => {
const row = db.prepare(
'SELECT custom_name FROM session_names WHERE session_id = ? AND provider = ?'
).get(sessionId, provider);
return row?.custom_name || null;
},
// Batch lookup — returns Map<sessionId, customName>
getNames: (sessionIds, provider) => {
if (!sessionIds.length) return new Map();
const placeholders = sessionIds.map(() => '?').join(',');
const rows = db.prepare(
`SELECT session_id, custom_name FROM session_names
WHERE session_id IN (${placeholders}) AND provider = ?`
).all(...sessionIds, provider);
return new Map(rows.map(r => [r.session_id, r.custom_name]));
},
// Delete a custom session name
deleteName: (sessionId, provider) => {
return db.prepare(
'DELETE FROM session_names WHERE session_id = ? AND provider = ?'
).run(sessionId, provider).changes > 0;
},
};
// Apply custom session names from the database (overrides CLI-generated summaries)
function applyCustomSessionNames(sessions, provider) {
if (!sessions?.length) return;
try {
const ids = sessions.map(s => s.id);
const customNames = sessionNamesDb.getNames(ids, provider);
for (const session of sessions) {
const custom = customNames.get(session.id);
if (custom) session.summary = custom;
}
} catch (error) {
console.warn(`[DB] Failed to apply custom session names for ${provider}:`, error.message);
}
}
// App config database operations
const appConfigDb = {
get: (key) => {
try {
const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key);
return row?.value || null;
} catch (err) {
return null;
}
},
set: (key, value) => {
db.prepare(
'INSERT INTO app_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run(key, value);
},
getOrCreateJwtSecret: () => {
let secret = appConfigDb.get('jwt_secret');
if (!secret) {
secret = crypto.randomBytes(64).toString('hex');
appConfigDb.set('jwt_secret', secret);
}
return secret;
}
};
// Backward compatibility - keep old names pointing to new system
const githubTokensDb = {
createGithubToken: (userId, tokenName, githubToken, description = null) => {
return credentialsDb.createCredential(userId, tokenName, 'github_token', githubToken, description);
},
getGithubTokens: (userId) => {
return credentialsDb.getCredentials(userId, 'github_token');
},
getActiveGithubToken: (userId) => {
return credentialsDb.getActiveCredential(userId, 'github_token');
},
deleteGithubToken: (userId, tokenId) => {
return credentialsDb.deleteCredential(userId, tokenId);
},
toggleGithubToken: (userId, tokenId, isActive) => {
return credentialsDb.toggleCredential(userId, tokenId, isActive);
}
};
export {
db,
initializeDatabase,
userDb,
apiKeysDb,
credentialsDb,
notificationPreferencesDb,
pushSubscriptionsDb,
sessionNamesDb,
applyCustomSessionNames,
appConfigDb,
githubTokensDb // Backward compatibility
};

View File

@@ -1,99 +0,0 @@
-- Initialize authentication database
PRAGMA foreign_keys = ON;
-- Users table (single user system)
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
is_active BOOLEAN DEFAULT 1,
git_name TEXT,
git_email TEXT,
has_completed_onboarding BOOLEAN DEFAULT 0
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
-- API Keys table for external API access
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
key_name TEXT NOT NULL,
api_key TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used DATETIME,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(api_key);
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active);
-- User credentials table for storing various tokens/credentials (GitHub, GitLab, etc.)
CREATE TABLE IF NOT EXISTS user_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
credential_name TEXT NOT NULL,
credential_type TEXT NOT NULL, -- 'github_token', 'gitlab_token', 'bitbucket_token', etc.
credential_value TEXT NOT NULL,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
-- User notification preferences (backend-owned, provider-agnostic)
CREATE TABLE IF NOT EXISTS user_notification_preferences (
user_id INTEGER PRIMARY KEY,
preferences_json TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- VAPID key pair for Web Push notifications
CREATE TABLE IF NOT EXISTS vapid_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Browser push subscriptions
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
keys_p256dh TEXT NOT NULL,
keys_auth TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Session custom names (provider-agnostic display name overrides)
CREATE TABLE IF NOT EXISTS session_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'claude',
custom_name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(session_id, provider)
);
CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);
-- App configuration table (auto-generated secrets, settings, etc.)
CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -1,23 +1,137 @@
import { spawn } from 'child_process';
import { promises as fs } from 'fs';
import os from 'os';
import path from 'path';
import crossSpawn from 'cross-spawn';
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import sessionManager from './sessionManager.js';
import GeminiResponseHandler from './gemini-response-handler.js';
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
import { createNormalizedMessage } from './providers/types.js';
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
import { createCompleteMessage, createNormalizedMessage } from './shared/utils.js';
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
let activeGeminiProcesses = new Map(); // Track active processes by session ID
function mapGeminiExitCodeToMessage(exitCode) {
switch (exitCode) {
case 42:
return 'Gemini rejected the request input (exit code 42).';
case 44:
return 'Gemini sandbox error (exit code 44). Check local sandbox/container settings.';
case 52:
return 'Gemini configuration error (exit code 52). Check your Gemini settings files for invalid JSON/config.';
case 53:
return 'Gemini conversation turn limit reached (exit code 53). Start a new Gemini session.';
default:
return null;
}
}
const GEMINI_AUTH_ENV_KEYS = [
'GEMINI_API_KEY',
'GOOGLE_API_KEY',
'GOOGLE_CLOUD_PROJECT',
'GOOGLE_CLOUD_PROJECT_ID',
'GOOGLE_CLOUD_LOCATION',
'GOOGLE_APPLICATION_CREDENTIALS'
];
function parseEnvFileContent(content) {
const parsed = {};
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const exportPrefix = 'export ';
const normalizedLine = line.startsWith(exportPrefix) ? line.slice(exportPrefix.length).trim() : line;
const separatorIndex = normalizedLine.indexOf('=');
if (separatorIndex <= 0) {
continue;
}
const key = normalizedLine.slice(0, separatorIndex).trim();
if (!key) {
continue;
}
let value = normalizedLine.slice(separatorIndex + 1).trim();
const hasDoubleQuotes = value.startsWith('"') && value.endsWith('"');
const hasSingleQuotes = value.startsWith('\'') && value.endsWith('\'');
if (hasDoubleQuotes || hasSingleQuotes) {
value = value.slice(1, -1);
} else {
// Support inline comments in unquoted values: KEY=value # comment
value = value.replace(/\s+#.*$/, '').trim();
}
parsed[key] = value;
}
return parsed;
}
async function loadGeminiUserLevelEnv() {
const geminiCliHome = (process.env.GEMINI_CLI_HOME || '').trim() || os.homedir();
const envCandidates = [
path.join(geminiCliHome, '.gemini', '.env'),
path.join(geminiCliHome, '.env')
];
for (const envPath of envCandidates) {
try {
await fs.access(envPath);
const content = await fs.readFile(envPath, 'utf8');
return parseEnvFileContent(content);
} catch {
// Keep scanning for the next candidate.
}
}
return {};
}
async function buildGeminiProcessEnv() {
const processEnv = { ...process.env };
if (processEnv.GEMINI_API_KEY || processEnv.GOOGLE_API_KEY || processEnv.GOOGLE_APPLICATION_CREDENTIALS) {
return processEnv;
}
// Gemini CLI docs recommend ~/.gemini/.env for persistent headless auth settings.
// When the server process was launched without shell profile variables, we still
// want the spawned CLI process to inherit those user-level credentials.
const userEnv = await loadGeminiUserLevelEnv();
for (const key of GEMINI_AUTH_ENV_KEYS) {
if (!processEnv[key] && userEnv[key]) {
processEnv[key] = userEnv[key];
}
}
return processEnv;
}
async function spawnGemini(command, options = {}, ws) {
const { sessionId, projectPath, cwd, toolsSettings, permissionMode, images, sessionSummary } = options;
const resolvedModel = await providerModelsService.resolveResumeModel(
'gemini',
sessionId,
options.model
);
let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event
let assistantBlocks = []; // Accumulate the full response blocks including tools
// Unified lifecycle contract: exactly one terminal `complete` per run
// (close and error handlers can both fire for spawn failures).
let completeSent = false;
// Use tools settings passed from frontend, or defaults
const settings = toolsSettings || {
@@ -99,6 +213,11 @@ async function spawnGemini(command, options = {}, ws) {
args.push('--debug');
}
// This integration runs Gemini in headless mode and cannot answer trust prompts.
// Skip folder-trust interactivity so authenticated runs don't fail with
// FatalUntrustedWorkspaceError in previously unseen directories.
args.push('--skip-trust');
// Add MCP config flag only if MCP servers are configured
try {
const geminiConfigPath = path.join(os.homedir(), '.gemini.json');
@@ -134,7 +253,7 @@ async function spawnGemini(command, options = {}, ws) {
}
// Add model for all sessions (both new and resumed)
let modelToUse = options.model || 'gemini-2.5-flash';
let modelToUse = resolvedModel || 'gemini-2.5-flash';
args.push('--model', modelToUse);
args.push('--output-format', 'stream-json');
@@ -153,9 +272,6 @@ async function spawnGemini(command, options = {}, ws) {
// Try to find gemini in PATH first, then fall back to environment variable
const geminiPath = process.env.GEMINI_PATH || 'gemini';
console.log('Spawning Gemini CLI:', geminiPath, args.join(' '));
console.log('Working directory:', workingDir);
let spawnCmd = geminiPath;
let spawnArgs = args;
@@ -167,11 +283,13 @@ async function spawnGemini(command, options = {}, ws) {
spawnArgs = ['-c', 'exec "$0" "$@"', geminiPath, ...args];
}
const spawnEnv = await buildGeminiProcessEnv();
return new Promise((resolve, reject) => {
const geminiProcess = spawnFunction(spawnCmd, spawnArgs, {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env } // Inherit all environment variables
env: spawnEnv
});
let terminalNotificationSent = false;
let terminalFailureReason = null;
@@ -275,12 +393,43 @@ async function spawnGemini(command, options = {}, ws) {
}
},
onInit: (event) => {
if (capturedSessionId) {
const sess = sessionManager.getSession(capturedSessionId);
if (sess && !sess.cliSessionId) {
sess.cliSessionId = event.session_id;
sessionManager.saveSession(capturedSessionId);
const discoveredSessionId = event?.session_id;
if (!discoveredSessionId) {
return;
}
// New Gemini sessions announce their canonical ID asynchronously via the
// initial `init` stream event. Avoid synthetic IDs and only register
// the session once that real ID is known (same model used by Claude/Codex).
if (!capturedSessionId) {
capturedSessionId = discoveredSessionId;
sessionManager.createSession(capturedSessionId, cwd || process.cwd());
if (command) {
sessionManager.addMessage(capturedSessionId, 'user', command);
}
if (processKey !== capturedSessionId) {
activeGeminiProcesses.delete(processKey);
activeGeminiProcesses.set(capturedSessionId, geminiProcess);
}
geminiProcess.sessionId = capturedSessionId;
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
ws.setSessionId(capturedSessionId);
}
if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true;
ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'gemini' }));
}
}
const sess = sessionManager.getSession(capturedSessionId);
if (sess && !sess.cliSessionId) {
sess.cliSessionId = discoveredSessionId;
sessionManager.saveSession(capturedSessionId);
}
}
});
@@ -291,30 +440,6 @@ async function spawnGemini(command, options = {}, ws) {
const rawOutput = data.toString();
startTimeout(); // Re-arm the timeout
// For new sessions, create a session ID FIRST
if (!sessionId && !sessionCreatedSent && !capturedSessionId) {
capturedSessionId = `gemini_${Date.now()}`;
sessionCreatedSent = true;
// Create session in session manager
sessionManager.createSession(capturedSessionId, cwd || process.cwd());
// Save the user message now that we have a session ID
if (command) {
sessionManager.addMessage(capturedSessionId, 'user', command);
}
// Update process key with captured session ID
if (processKey !== capturedSessionId) {
activeGeminiProcesses.delete(processKey);
activeGeminiProcesses.set(capturedSessionId, geminiProcess);
}
ws.setSessionId && typeof ws.setSessionId === 'function' && ws.setSessionId(capturedSessionId);
ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'gemini' }));
}
if (responseHandler) {
responseHandler.processData(rawOutput);
} else if (rawOutput) {
@@ -364,7 +489,12 @@ async function spawnGemini(command, options = {}, ws) {
sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);
}
ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'gemini' }));
// Terminal complete — skipped for aborted runs (abort-session
// already sent the aborted complete on this run's behalf).
if (!completeSent && !geminiProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'gemini', sessionId: finalSessionId, exitCode: code }));
}
// Clean up temporary image files if any
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
@@ -380,22 +510,74 @@ async function spawnGemini(command, options = {}, ws) {
notifyTerminalState({ code });
resolve();
} else {
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
// code 127 = shell "command not found" - check installation
if (code === 127) {
const installed = await providerAuthService.isProviderInstalled('gemini');
if (!installed) {
terminalFailureReason = 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli';
ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' }));
}
} else if (code === 41) {
// Gemini CLI documents exit code 41 as FatalAuthenticationError.
// Surface an actionable auth error instead of a generic exit-code message.
let authErrorSuffix = '';
try {
const authStatus = await providerAuthService.getProviderAuthStatus('gemini');
if (!authStatus?.authenticated && authStatus?.error) {
authErrorSuffix = ` Details: ${authStatus.error}`;
}
} catch {
// Keep base remediation text when auth status lookup fails.
}
terminalFailureReason =
'Gemini authentication failed (exit code 41). '
+ 'Run `gemini` in a terminal to choose an auth method, or configure a valid `GEMINI_API_KEY`.'
+ authErrorSuffix;
ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' }));
} else {
const mappedError = mapGeminiExitCodeToMessage(code);
if (mappedError) {
terminalFailureReason = mappedError;
ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' }));
}
}
notifyTerminalState({
code,
error: code === null ? 'Gemini CLI process was terminated or timed out' : null
});
reject(new Error(code === null ? 'Gemini CLI process was terminated or timed out' : `Gemini CLI exited with code ${code}`));
reject(
new Error(
terminalFailureReason
|| (code === null
? 'Gemini CLI process was terminated or timed out'
: `Gemini CLI exited with code ${code}`)
)
);
}
});
// Handle process errors
geminiProcess.on('error', (error) => {
geminiProcess.on('error', async (error) => {
// Clean up process reference on error
const finalSessionId = capturedSessionId || sessionId || processKey;
activeGeminiProcesses.delete(finalSessionId);
// Check if Gemini CLI is installed for a clearer error message
const installed = await providerAuthService.isProviderInstalled('gemini');
const errorContent = !installed
? 'Gemini CLI is not installed. Please install it first: https://github.com/google-gemini/gemini-cli'
: error.message;
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: errorSessionId, provider: 'gemini' }));
ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: errorSessionId, provider: 'gemini' }));
if (!completeSent && !geminiProcess.aborted) {
completeSent = true;
ws.send(createCompleteMessage({ provider: 'gemini', sessionId: errorSessionId, exitCode: 1 }));
}
notifyTerminalState({ error });
reject(error);
@@ -420,6 +602,9 @@ function abortGeminiSession(sessionId) {
if (geminiProc) {
try {
// The abort handler sends the terminal complete (aborted: true);
// flag the process so its close handler does not emit a second one.
geminiProc.aborted = true;
geminiProc.kill('SIGTERM');
setTimeout(() => {
if (activeGeminiProcesses.has(processKey)) {

View File

@@ -1,5 +1,32 @@
// Gemini Response Handler - JSON Stream processing
import { geminiAdapter } from './providers/gemini/adapter.js';
import { sessionsService } from './modules/providers/services/sessions.service.js';
import { createNormalizedMessage } from './shared/utils.js';
function buildGeminiTokenBudget(tokens) {
if (!tokens || typeof tokens !== 'object') {
return null;
}
const parsedInputTokens = Number(tokens.input);
const parsedOutputTokens = Number(tokens.output);
const inputTokens = Number.isFinite(parsedInputTokens) ? parsedInputTokens : 0;
const outputTokens = Number.isFinite(parsedOutputTokens) ? parsedOutputTokens : 0;
const parsedUsed = Number(tokens.total);
const used = Number.isFinite(parsedUsed) ? parsedUsed : inputTokens + outputTokens;
if (!Number.isFinite(used) || used <= 0) {
return null;
}
return {
used,
inputTokens,
outputTokens,
breakdown: {
input: inputTokens,
output: outputTokens,
},
};
}
class GeminiResponseHandler {
constructor(ws, options = {}) {
@@ -56,10 +83,21 @@ class GeminiResponseHandler {
}
// Normalize via adapter and send all resulting messages
const normalized = geminiAdapter.normalizeMessage(event, sid);
const normalized = sessionsService.normalizeMessage('gemini', event, sid);
for (const msg of normalized) {
this.ws.send(msg);
}
const tokenBudget = buildGeminiTokenBudget(event.tokens);
if (tokenBudget) {
this.ws.send(createNormalizedMessage({
kind: 'status',
text: 'token_budget',
tokenBudget,
sessionId: sid,
provider: 'gemini',
}));
}
}
forceFlush() {

File diff suppressed because it is too large Load Diff

View File

@@ -2,14 +2,15 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const __dirname = getModuleDir(import.meta.url);
// Resolve the repo/app root via the nearest /server folder so this file keeps finding the
// same top-level .env file from both /server/load-env.js and /dist-server/server/load-env.js.
const APP_ROOT = findAppRoot(__dirname);
try {
const envPath = path.join(__dirname, '../.env');
const envPath = path.join(APP_ROOT, '.env');
const envFile = fs.readFileSync(envPath, 'utf8');
envFile.split('\n').forEach(line => {
const trimmedLine = line.trim();
@@ -21,9 +22,13 @@ try {
}
});
} catch (e) {
console.log('No .env file found or error reading it:', e.message);
console.error('No .env file found or error reading it:', e.message);
}
// Keep the default database in a stable user-level location so rebuilding dist-server
// never changes where the backend stores auth.db when DATABASE_PATH is not set explicitly.
const DEFAULT_DATABASE_PATH = path.join(os.homedir(), '.cloudcli', 'auth.db');
if (!process.env.DATABASE_PATH) {
process.env.DATABASE_PATH = path.join(os.homedir(), '.cloudcli', 'auth.db');
process.env.DATABASE_PATH = DEFAULT_DATABASE_PATH;
}

View File

@@ -1,5 +1,5 @@
import jwt from 'jsonwebtoken';
import { userDb, appConfigDb } from '../database/db.js';
import { userDb, appConfigDb } from '../modules/database/index.js';
import { IS_PLATFORM } from '../constants/config.js';
// Use env var if set, otherwise auto-generate a unique secret per installation

View File

@@ -0,0 +1,120 @@
import express from 'express';
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
const router = express.Router();
function readBearerToken(header: unknown): string | null {
if (typeof header !== 'string') {
return null;
}
const match = /^Bearer\s+(\S.*)$/i.exec(header.trim());
return match?.[1]?.trim() || null;
}
router.use((req, res, next) => {
const expected = browserUseService.getMcpToken();
const token = readBearerToken(req.headers.authorization) || String(req.headers['x-browser-use-mcp-token'] || '');
if (!token || token !== expected) {
res.status(401).json({ success: false, error: 'Invalid Browser MCP token.' });
return;
}
next();
});
router.post('/tools/:toolName', async (req, res) => {
try {
const input = (req.body && typeof req.body === 'object' ? req.body : {}) as Record<string, unknown>;
const sessionId = typeof input.sessionId === 'string' ? input.sessionId : '';
const toolName = req.params.toolName;
let result: unknown;
switch (toolName) {
case 'browser_create_session':
result = await browserUseService.createAgentSession({
profileName: typeof input.profileName === 'string' ? input.profileName : null,
});
break;
case 'browser_list_sessions':
result = await browserUseService.listAgentSessions();
break;
case 'browser_snapshot':
case 'browser_take_screenshot':
result = await browserUseService.agentSnapshot(sessionId);
break;
case 'browser_navigate':
result = await browserUseService.agentNavigate(sessionId, String(input.url || ''));
break;
case 'browser_click':
result = await browserUseService.agentClick(sessionId, {
selector: typeof input.selector === 'string' ? input.selector : undefined,
text: typeof input.text === 'string' ? input.text : undefined,
x: typeof input.x === 'number' ? input.x : undefined,
y: typeof input.y === 'number' ? input.y : undefined,
});
break;
case 'browser_type':
result = await browserUseService.agentType(sessionId, {
selector: typeof input.selector === 'string' ? input.selector : undefined,
text: String(input.text || ''),
submit: input.submit === true,
});
break;
case 'browser_fill_form':
result = await browserUseService.agentFillForm(
sessionId,
Array.isArray(input.fields)
? input.fields.map((field) => {
const record = field as Record<string, unknown>;
return {
selector: String(record.selector || ''),
value: String(record.value || ''),
};
})
: [],
);
break;
case 'browser_press_key':
result = await browserUseService.agentPressKey(sessionId, String(input.key || ''));
break;
case 'browser_select_option':
result = await browserUseService.agentSelectOption(
sessionId,
String(input.selector || ''),
Array.isArray(input.values) ? input.values.filter((value): value is string => typeof value === 'string') : [],
);
break;
case 'browser_wait_for':
result = await browserUseService.agentWaitFor(sessionId, {
text: typeof input.text === 'string' ? input.text : undefined,
url: typeof input.url === 'string' ? input.url : undefined,
timeoutMs: typeof input.timeoutMs === 'number' ? input.timeoutMs : undefined,
});
break;
case 'browser_tabs':
result = await browserUseService.agentTabs(sessionId, {
action: input.action === 'new' || input.action === 'select' || input.action === 'close' || input.action === 'list'
? input.action
: undefined,
index: typeof input.index === 'number' ? input.index : undefined,
url: typeof input.url === 'string' ? input.url : undefined,
});
break;
case 'browser_close_session':
result = await browserUseService.agentStopSession(sessionId);
break;
default:
res.status(404).json({ success: false, error: `Unknown Browser MCP tool "${toolName}".` });
return;
}
res.json({ success: true, data: result });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Browser MCP tool failed.',
});
}
});
export default router;

View File

@@ -0,0 +1,96 @@
import express from 'express';
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
const router = express.Router();
function readParam(value: string | string[] | undefined): string {
return Array.isArray(value) ? value[0] || '' : value || '';
}
router.get('/status', async (_req, res) => {
try {
res.json({ success: true, data: await browserUseService.getStatus() });
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to load Browser status.',
});
}
});
router.get('/settings', async (_req, res) => {
try {
res.json({ success: true, data: { settings: await browserUseService.getSettings() } });
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to load Browser settings.',
});
}
});
router.put('/settings', async (req, res) => {
try {
const settings = await browserUseService.updateSettings(req.body || {});
res.json({ success: true, data: { settings } });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to save Browser settings.',
});
}
});
router.post('/runtime/install', async (_req, res) => {
try {
const result = await browserUseService.installRuntime();
res.status(result.success ? 200 : 500).json({
success: result.success,
data: result,
error: result.success ? undefined : result.message,
});
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to install Browser runtime.',
});
}
});
router.get('/sessions', async (_req, res) => {
try {
res.json({ success: true, data: { sessions: await browserUseService.listSessions() } });
} catch (error) {
res.status(401).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to list browser sessions.',
});
}
});
router.post('/sessions/:sessionId/stop', async (req, res) => {
try {
const result = await browserUseService.stopSession(readParam(req.params.sessionId));
res.json({ success: true, data: result });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to stop browser session.',
});
}
});
router.delete('/sessions/:sessionId', async (req, res) => {
try {
const result = await browserUseService.deleteSession(readParam(req.params.sessionId));
res.json({ success: true, data: result });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to delete browser session.',
});
}
});
export default router;

View File

@@ -0,0 +1,836 @@
import { createRequire } from 'node:module';
import { randomBytes, randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { appConfigDb } from '@/modules/database/index.js';
import { providerMcpService } from '@/modules/providers/index.js';
import { getModuleDir } from '@/utils/runtime-paths.js';
const require = createRequire(import.meta.url);
const __dirname = getModuleDir(import.meta.url);
const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
const MAX_SESSIONS_PER_OWNER = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_MAX_SESSIONS_PER_OWNER || '3', 10);
const SESSION_TTL_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_SESSION_TTL_MS || String(30 * 60 * 1000), 10);
const BROWSER_USE_SETTINGS_KEY = 'browser_use_settings';
const BROWSER_USE_MCP_TOKEN_KEY = 'browser_use_mcp_token';
type BrowserUseRuntime = 'cloud' | 'local';
type BrowserUseSessionStatus = 'ready' | 'stopped' | 'unavailable';
type BrowserUseSession = {
id: string;
ownerId: string;
createdBy: 'agent';
runtime: BrowserUseRuntime;
status: BrowserUseSessionStatus;
url: string | null;
title: string | null;
screenshotDataUrl: string | null;
createdAt: string;
updatedAt: string;
lastAction: string | null;
message: string | null;
profileName: string | null;
viewport: {
width: number;
height: number;
} | null;
cursor: {
x: number;
y: number;
actor: 'agent';
} | null;
};
type PublicBrowserUseSession = Omit<BrowserUseSession, 'ownerId'>;
type RuntimeHandle = {
browser?: any;
context?: any;
page?: any;
};
type BrowserUseSettings = {
enabled: boolean;
};
type RuntimeReadiness = {
playwright: any | null;
playwrightInstalled: boolean;
chromiumInstalled: boolean;
chromiumExecutablePath: string | null;
installInProgress: boolean;
installMessage: string | null;
};
type RuntimeProbe = Omit<RuntimeReadiness, 'installInProgress' | 'installMessage'>;
const sessions = new Map<string, BrowserUseSession>();
const handles = new Map<string, RuntimeHandle>();
let installPromise: Promise<{ success: boolean; message: string }> | null = null;
let lastInstallMessage: string | null = null;
let runtimeProbeCache: { value: RuntimeProbe; updatedAt: number } | null = null;
const DEFAULT_SETTINGS: BrowserUseSettings = {
enabled: false,
};
const AGENT_OWNER_ID = 'agent';
const PROFILE_ROOT = path.join(os.homedir(), '.cloudcli', 'browser-use', 'profiles');
const MCP_SERVER_NAME = 'cloudcli-browser';
const LEGACY_MCP_SERVER_NAMES = ['cloudcli-browser-use'];
const RUNTIME_READINESS_CACHE_TTL_MS = 30_000;
function getRuntime(): BrowserUseRuntime {
return IS_PLATFORM ? 'cloud' : 'local';
}
function readSettings(): BrowserUseSettings {
try {
const raw = appConfigDb.get(BROWSER_USE_SETTINGS_KEY);
if (!raw) {
return DEFAULT_SETTINGS;
}
const parsed = JSON.parse(raw) as Partial<BrowserUseSettings>;
return {
enabled: parsed.enabled === true,
};
} catch (error: any) {
console.warn('[Browser] Failed to read settings:', error?.message || error);
return DEFAULT_SETTINGS;
}
}
function writeSettings(settings: BrowserUseSettings): BrowserUseSettings {
const normalized = {
enabled: settings.enabled === true,
};
appConfigDb.set(BROWSER_USE_SETTINGS_KEY, JSON.stringify(normalized));
return normalized;
}
function getOrCreateMcpToken(): string {
const existing = appConfigDb.get(BROWSER_USE_MCP_TOKEN_KEY);
if (existing) {
return existing;
}
const token = randomBytes(32).toString('hex');
appConfigDb.set(BROWSER_USE_MCP_TOKEN_KEY, token);
return token;
}
function getSetupMessage(settings: BrowserUseSettings, readiness: RuntimeReadiness): string {
if (!settings.enabled) {
return 'Browser is disabled in settings.';
}
if (!readiness.playwrightInstalled) {
return 'Install Playwright and Chromium to use browser sessions.';
}
if (!readiness.chromiumInstalled) {
return 'Playwright is installed, but Chromium is missing. Install the Chromium runtime to continue.';
}
return readiness.installMessage || 'Browser runtime is not ready.';
}
function getPlaywright(): any | null {
try {
return require('playwright');
} catch {
return null;
}
}
function getMcpCommand(): { command: string; args: string[] } {
const serverDir = path.resolve(__dirname, '..', '..');
const mcpScriptPath = path.join(serverDir, 'browser-use-mcp.js');
if (fs.existsSync(mcpScriptPath)) {
return {
command: process.execPath,
args: [mcpScriptPath],
};
}
return {
command: 'cloudcli',
args: ['browser-use-mcp'],
};
}
function getMcpApiUrl(): string {
const port = process.env.SERVER_PORT || process.env.PORT || '3001';
return `http://127.0.0.1:${port}/api/browser-use-mcp`;
}
async function removeMcpServerFromAllProviders(name: string) {
const results = await providerMcpService.removeMcpServerFromAllProviders({
name,
scope: 'user',
});
return results.map((result) => ({ ...result, name }));
}
function normalizeProfileName(profileName?: string | null): string | null {
const normalized = String(profileName || '').trim();
if (!normalized) {
return null;
}
return normalized.slice(0, 80);
}
function getProfilePath(profileName: string): string {
const safeName = profileName
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'default';
return path.join(PROFILE_ROOT, safeName);
}
function probeRuntime(): RuntimeProbe {
const playwright = getPlaywright();
const readiness: RuntimeProbe = {
playwright,
playwrightInstalled: Boolean(playwright),
chromiumInstalled: false,
chromiumExecutablePath: null,
};
if (!playwright) {
return readiness;
}
try {
const executablePath = playwright.chromium.executablePath();
readiness.chromiumExecutablePath = executablePath;
readiness.chromiumInstalled = Boolean(executablePath && fs.existsSync(executablePath));
} catch {
readiness.chromiumInstalled = false;
}
return readiness;
}
function getRuntimeReadiness(options: { force?: boolean } = {}): RuntimeReadiness {
const now = Date.now();
const cachedProbe = runtimeProbeCache;
const canUseCache = !options.force
&& !installPromise
&& cachedProbe
&& now - cachedProbe.updatedAt < RUNTIME_READINESS_CACHE_TTL_MS;
const probe = canUseCache ? cachedProbe.value : probeRuntime();
if (!canUseCache && !installPromise) {
runtimeProbeCache = { value: probe, updatedAt: now };
}
return {
...probe,
installInProgress: Boolean(installPromise),
installMessage: lastInstallMessage,
};
}
const INSTALL_COMMAND_TIMEOUT_MS = Number.parseInt(
process.env.CLOUDCLI_BROWSER_USE_INSTALL_TIMEOUT_MS || String(10 * 60 * 1000),
10,
);
function runCommand(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: process.cwd(),
env: process.env,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
});
const output: string[] = [];
let settled = false;
const finish = (fn: () => void) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
fn();
};
const timer = setTimeout(() => {
child.kill('SIGKILL');
finish(() => reject(new Error(
`${command} ${args.join(' ')} timed out after ${INSTALL_COMMAND_TIMEOUT_MS}ms.`,
)));
}, INSTALL_COMMAND_TIMEOUT_MS);
timer.unref?.();
child.stdout.on('data', (chunk) => output.push(String(chunk)));
child.stderr.on('data', (chunk) => output.push(String(chunk)));
child.on('error', (error) => finish(() => reject(error)));
child.on('close', (code) => finish(() => {
if (code === 0) {
resolve();
return;
}
reject(new Error(output.join('').trim() || `${command} ${args.join(' ')} exited with code ${code}`));
}));
});
}
function formatInstallError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('sudo') && message.includes('password')) {
return 'Installing Chromium system dependencies requires administrator privileges. Run `npx playwright install-deps chromium` on the machine where CloudCLI runs, then try again.';
}
return message || 'Failed to install Browser runtime.';
}
async function installRuntime(): Promise<{ success: boolean; message: string }> {
if (installPromise) {
return installPromise;
}
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
runtimeProbeCache = null;
installPromise = (async () => {
try {
lastInstallMessage = 'Installing Playwright package...';
await runCommand(npmCommand, ['install', '--no-save', '--no-package-lock', 'playwright']);
if (process.platform === 'linux') {
lastInstallMessage = 'Installing Chromium system dependencies...';
await runCommand(npmCommand, ['exec', '--', 'playwright', 'install-deps', 'chromium']);
}
lastInstallMessage = 'Installing Chromium runtime...';
await runCommand(npmCommand, ['exec', '--', 'playwright', 'install', 'chromium']);
lastInstallMessage = 'Browser runtime installed.';
return { success: true, message: lastInstallMessage };
} catch (error) {
lastInstallMessage = formatInstallError(error);
return { success: false, message: lastInstallMessage };
}
})();
try {
return await installPromise;
} finally {
installPromise = null;
runtimeProbeCache = null;
}
}
function normalizeUrl(rawUrl: string): string {
const trimmed = rawUrl.trim();
if (!trimmed) {
throw new Error('URL is required.');
}
const withProtocol = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(trimmed)
? trimmed
: `https://${trimmed}`;
const parsed = new URL(withProtocol);
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error('Only http and https URLs are supported.');
}
return parsed.toString();
}
function publicSession(session: BrowserUseSession): PublicBrowserUseSession {
const { ownerId: _ownerId, ...publicFields } = session;
return publicFields;
}
function ownerSessions(ownerId: string): BrowserUseSession[] {
return [...sessions.values()].filter((session) => session.ownerId === ownerId);
}
async function closeHandle(sessionId: string): Promise<void> {
const handle = handles.get(sessionId);
handles.delete(sessionId);
await handle?.context?.close?.().catch(() => undefined);
await handle?.browser?.close().catch(() => undefined);
}
async function expireStaleSessions(now = Date.now()): Promise<void> {
await Promise.all([...sessions.values()].map(async (session) => {
if (session.status !== 'ready') {
return;
}
const updatedAt = Date.parse(session.updatedAt);
if (!Number.isFinite(updatedAt) || now - updatedAt <= SESSION_TTL_MS) {
return;
}
await closeHandle(session.id);
session.status = 'stopped';
session.updatedAt = new Date(now).toISOString();
session.lastAction = 'expire';
session.message = 'Browser session expired after inactivity.';
}));
}
async function captureSession(session: BrowserUseSession, page: any): Promise<void> {
const screenshot = await page.screenshot({ type: 'jpeg', quality: 72, fullPage: false });
session.screenshotDataUrl = `data:image/jpeg;base64,${Buffer.from(screenshot).toString('base64')}`;
session.title = await page.title().catch(() => null);
session.url = page.url() || session.url;
session.viewport = page.viewportSize?.() || session.viewport;
session.updatedAt = new Date().toISOString();
}
async function getActionPoint(page: any, input: { selector?: string; text?: string; x?: number; y?: number }) {
if (typeof input.x === 'number' && typeof input.y === 'number') {
return { x: input.x, y: input.y };
}
const locator = input.selector
? page.locator(input.selector).first()
: input.text
? page.getByText(input.text, { exact: false }).first()
: null;
if (!locator) {
return null;
}
const box = await locator.boundingBox().catch(() => null);
if (!box) {
return null;
}
return {
x: Math.round(box.x + box.width / 2),
y: Math.round(box.y + box.height / 2),
};
}
export const browserUseService = {
async getSettings() {
return readSettings();
},
async updateSettings(settings: Partial<BrowserUseSettings>) {
const current = readSettings();
const nextSettings = {
enabled: typeof settings.enabled === 'boolean' ? settings.enabled : current.enabled,
};
const next = writeSettings(nextSettings);
if (next.enabled) {
await this.registerAgentMcp();
} else if (current.enabled) {
await this.unregisterAgentMcp();
await this.stopAllSessions();
}
return next;
},
async getStatus() {
const settings = readSettings();
const readiness = getRuntimeReadiness();
const available = settings.enabled && readiness.playwrightInstalled && readiness.chromiumInstalled;
return {
enabled: settings.enabled,
runtime: getRuntime(),
available,
playwrightInstalled: readiness.playwrightInstalled,
chromiumInstalled: readiness.chromiumInstalled,
installInProgress: readiness.installInProgress,
sessionCount: sessions.size,
message: available
? 'Browser runtime is available.'
: getSetupMessage(settings, readiness),
};
},
async registerAgentMcp() {
const { command, args } = getMcpCommand();
await Promise.all(LEGACY_MCP_SERVER_NAMES.map((name) => removeMcpServerFromAllProviders(name)));
const results = await providerMcpService.addMcpServerToAllProviders({
name: MCP_SERVER_NAME,
scope: 'user',
transport: 'stdio',
command,
args,
env: {
CLOUDCLI_BROWSER_USE_MCP_TOKEN: getOrCreateMcpToken(),
CLOUDCLI_BROWSER_USE_API_URL: getMcpApiUrl(),
},
});
return { name: MCP_SERVER_NAME, command, args, results };
},
getMcpToken() {
return getOrCreateMcpToken();
},
async unregisterAgentMcp() {
const results = (await Promise.all(
[MCP_SERVER_NAME, ...LEGACY_MCP_SERVER_NAMES].map((name) => removeMcpServerFromAllProviders(name)),
)).flat();
return { name: MCP_SERVER_NAME, results };
},
async installRuntime() {
const result = await installRuntime();
return {
...result,
status: await this.getStatus(),
};
},
async listSessions() {
await expireStaleSessions();
return [...sessions.values()]
.filter((session) => session.ownerId === AGENT_OWNER_ID)
.map(publicSession);
},
async createAgentSession(options?: { profileName?: string | null }) {
const settings = readSettings();
if (!settings.enabled) {
throw new Error('Browser agent tools are disabled.');
}
await expireStaleSessions();
const profileName = normalizeProfileName(options?.profileName);
const now = new Date().toISOString();
const session: BrowserUseSession = {
id: randomUUID(),
ownerId: AGENT_OWNER_ID,
createdBy: 'agent',
runtime: getRuntime(),
status: 'unavailable',
url: null,
title: null,
screenshotDataUrl: null,
createdAt: now,
updatedAt: now,
lastAction: 'create',
message: null,
profileName,
viewport: { width: 1440, height: 900 },
cursor: null,
};
const activeOwnerSessions = ownerSessions(AGENT_OWNER_ID).filter((item) => item.status === 'ready');
if (activeOwnerSessions.length >= MAX_SESSIONS_PER_OWNER) {
throw new Error(`Browser is limited to ${MAX_SESSIONS_PER_OWNER} active agent sessions.`);
}
const readiness = getRuntimeReadiness();
if (!settings.enabled || !readiness.playwrightInstalled || !readiness.chromiumInstalled || !readiness.playwright) {
session.message = getSetupMessage(settings, readiness);
sessions.set(session.id, session);
return publicSession(session);
}
let browser: any | undefined;
let context: any | undefined;
let page: any;
const launchOptions = {
headless: true,
args: ['--disable-dev-shm-usage'],
};
const contextOptions = {
viewport: { width: 1440, height: 900 },
serviceWorkers: 'block',
};
if (profileName) {
fs.mkdirSync(PROFILE_ROOT, { recursive: true });
context = await readiness.playwright.chromium.launchPersistentContext(getProfilePath(profileName), {
...launchOptions,
...contextOptions,
});
page = context.pages()[0] || await context.newPage();
} else {
browser = await readiness.playwright.chromium.launch(launchOptions);
context = await browser.newContext(contextOptions);
page = await context.newPage();
}
session.status = 'ready';
session.message = 'Browser session is ready.';
sessions.set(session.id, session);
handles.set(session.id, { browser, context, page });
await captureSession(session, page);
return publicSession(session);
},
async listAgentSessions() {
const settings = readSettings();
if (!settings.enabled) {
return [];
}
await expireStaleSessions();
return [...sessions.values()]
.filter((session) => session.ownerId === AGENT_OWNER_ID)
.map(publicSession);
},
async getAgentSession(sessionId: string) {
const settings = readSettings();
if (!settings.enabled) {
throw new Error('Browser agent tools are disabled.');
}
const session = sessions.get(sessionId);
if (!session || session.ownerId !== AGENT_OWNER_ID) {
throw new Error('Browser session not found.');
}
return session;
},
async agentNavigate(sessionId: string, rawUrl: string) {
await this.getAgentSession(sessionId);
await expireStaleSessions();
const session = sessions.get(sessionId);
if (!session || session.ownerId !== AGENT_OWNER_ID) {
throw new Error('Browser session not found.');
}
if (session.status !== 'ready') {
throw new Error(session.message || 'Browser session is not available.');
}
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
const url = normalizeUrl(rawUrl);
await handle.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
session.lastAction = `navigate:${url}`;
session.cursor = null;
await captureSession(session, handle.page);
return publicSession(session);
},
async agentSnapshot(sessionId: string) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
await captureSession(session, handle.page);
const text = await handle.page.locator('body').innerText({ timeout: 5_000 }).catch(() => '');
return {
session: publicSession(session),
text: text.slice(0, 30_000),
};
},
async agentClick(sessionId: string, input: { selector?: string; text?: string; x?: number; y?: number }) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
const point = await getActionPoint(handle.page, input);
if (input.selector) {
await handle.page.locator(input.selector).first().click({ timeout: 10_000 });
} else if (input.text) {
await handle.page.getByText(input.text, { exact: false }).first().click({ timeout: 10_000 });
} else if (typeof input.x === 'number' && typeof input.y === 'number') {
await handle.page.mouse.click(input.x, input.y);
} else {
throw new Error('Provide selector, text, or x/y coordinates.');
}
session.lastAction = 'click';
session.cursor = point ? { ...point, actor: 'agent' } : null;
await captureSession(session, handle.page);
return publicSession(session);
},
async agentType(sessionId: string, input: { selector?: string; text: string; submit?: boolean }) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
if (input.selector) {
await handle.page.locator(input.selector).first().fill(input.text, { timeout: 10_000 });
session.cursor = await getActionPoint(handle.page, input).then((point) => (
point ? { ...point, actor: 'agent' as const } : null
));
} else {
await handle.page.keyboard.type(input.text);
}
if (input.submit) {
await handle.page.keyboard.press('Enter');
}
session.lastAction = 'type';
await captureSession(session, handle.page);
return publicSession(session);
},
async agentFillForm(sessionId: string, fields: Array<{ selector: string; value: string }>) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
for (const field of fields) {
await handle.page.locator(field.selector).first().fill(field.value, { timeout: 10_000 });
}
session.lastAction = 'fill_form';
if (fields[0]) {
session.cursor = await getActionPoint(handle.page, { selector: fields[0].selector }).then((point) => (
point ? { ...point, actor: 'agent' as const } : null
));
}
await captureSession(session, handle.page);
return publicSession(session);
},
async agentPressKey(sessionId: string, key: string) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
await handle.page.keyboard.press(key);
session.lastAction = `press_key:${key}`;
await captureSession(session, handle.page);
return publicSession(session);
},
async agentSelectOption(sessionId: string, selector: string, values: string[]) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
await handle.page.locator(selector).first().selectOption(values, { timeout: 10_000 });
session.lastAction = 'select_option';
session.cursor = await getActionPoint(handle.page, { selector }).then((point) => (
point ? { ...point, actor: 'agent' as const } : null
));
await captureSession(session, handle.page);
return publicSession(session);
},
async agentWaitFor(sessionId: string, input: { text?: string; url?: string; timeoutMs?: number }) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
const timeout = Math.max(250, Math.min(input.timeoutMs || 5_000, 30_000));
if (input.text) {
await handle.page.getByText(input.text, { exact: false }).first().waitFor({ timeout });
} else if (input.url) {
await handle.page.waitForURL(input.url, { timeout });
} else {
await handle.page.waitForTimeout(timeout);
}
session.lastAction = 'wait_for';
await captureSession(session, handle.page);
return publicSession(session);
},
async agentTabs(sessionId: string, input: { action?: 'list' | 'new' | 'select' | 'close'; index?: number; url?: string }) {
const session = await this.getAgentSession(sessionId);
const handle = handles.get(sessionId);
if (!handle?.context || !handle?.page) {
throw new Error('Browser runtime handle is not available.');
}
const action = input.action || 'list';
if (action === 'new') {
const page = await handle.context.newPage();
handles.set(sessionId, { ...handle, page });
if (input.url) {
await this.agentNavigate(sessionId, input.url);
}
} else if (action === 'select') {
const page = handle.context.pages()[input.index || 0];
if (!page) {
throw new Error('Tab not found.');
}
handles.set(sessionId, { ...handle, page });
} else if (action === 'close') {
const pages = handle.context.pages();
const page = pages[input.index ?? pages.indexOf(handle.page)];
if (!page) {
throw new Error('Tab not found.');
}
await page.close();
handles.set(sessionId, { ...handle, page: handle.context.pages()[0] || await handle.context.newPage() });
}
const updatedHandle = handles.get(sessionId);
await captureSession(session, updatedHandle?.page || handle.page);
return {
session: publicSession(session),
tabs: handle.context.pages().map((page: any, index: number) => ({
index,
url: page.url(),
active: page === (updatedHandle?.page || handle.page),
})),
};
},
async stopSession(sessionId: string) {
const session = sessions.get(sessionId);
if (!session || session.ownerId !== AGENT_OWNER_ID) {
return { stopped: false };
}
await closeHandle(sessionId);
session.status = 'stopped';
session.updatedAt = new Date().toISOString();
session.lastAction = 'stop';
session.message = 'Browser session stopped. Create a new session to continue browsing.';
return { stopped: true, session: publicSession(session) };
},
async deleteSession(sessionId: string) {
const session = sessions.get(sessionId);
if (!session || session.ownerId !== AGENT_OWNER_ID) {
return { deleted: false };
}
await closeHandle(sessionId);
sessions.delete(sessionId);
return { deleted: true, sessionId };
},
async agentStopSession(sessionId: string) {
await this.getAgentSession(sessionId);
return this.stopSession(sessionId);
},
async stopAllSessions() {
await Promise.all([...sessions.keys()].map(async (sessionId) => {
await closeHandle(sessionId);
const session = sessions.get(sessionId);
if (session) {
session.status = 'stopped';
session.updatedAt = new Date().toISOString();
session.lastAction = 'shutdown';
session.message = 'Browser session stopped during server shutdown.';
}
}));
},
};
process.once('beforeExit', () => {
void browserUseService.stopAllSessions();
});

View File

@@ -0,0 +1,10 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
test('browser monitor list starts empty without agent sessions', async () => {
const sessions = await browserUseService.listSessions();
assert.deepEqual(sessions, []);
});

View File

@@ -0,0 +1,67 @@
import {
captureScreenshot,
executor,
type ExecutorTarget,
} from '@/modules/computer-use/computer-executor.js';
import type { RawActionResult, RawComputerAction, RawActionTarget } from '@/modules/computer-use/actions/raw-action-types.js';
const DEFAULT_WAIT_MS = 1000;
const MAX_WAIT_MS = 10_000;
function normalizeWaitMs(ms: number | undefined): number {
if (ms === undefined) {
return DEFAULT_WAIT_MS;
}
if (!Number.isFinite(ms)) {
throw new Error('Computer Use wait duration must be a finite number.');
}
return Math.trunc(Math.max(0, Math.min(ms, MAX_WAIT_MS)));
}
async function snapshot(target: RawActionTarget): Promise<RawActionResult> {
const { dataUrl, size } = await captureScreenshot();
return { screenshotDataUrl: dataUrl, displaySize: size || target.displaySize };
}
export async function runRawComputerAction(
action: RawComputerAction,
target: RawActionTarget,
): Promise<RawActionResult> {
const executorTarget: ExecutorTarget = {
displaySize: target.displaySize,
};
switch (action.type) {
case 'screenshot':
return snapshot(target);
case 'cursor_position': {
const position = await executor.cursorPosition(executorTarget);
return { ...(await snapshot(target)), position, cursor: position };
}
case 'mouse_move':
await executor.moveTo(executorTarget, action.point);
return { ...(await snapshot(target)), cursor: action.point };
case 'click':
await executor.click(executorTarget, action.button, action.point, action.double === true);
return { ...(await snapshot(target)), cursor: action.point ?? null };
case 'drag':
await executor.drag(executorTarget, action.from, action.to, action.button ?? 'left');
return { ...(await snapshot(target)), cursor: action.to };
case 'type':
await executor.type(action.text);
return snapshot(target);
case 'key':
await executor.pressChord(action.key);
return snapshot(target);
case 'scroll':
await executor.scroll(executorTarget, action.direction, action.amount ?? 3, action.point);
return { ...(await snapshot(target)), cursor: action.point ?? null };
case 'wait':
await new Promise((resolve) => setTimeout(resolve, normalizeWaitMs(action.ms)));
return snapshot(target);
default: {
const exhaustive: never = action;
throw new Error(`Unsupported computer action: ${(exhaustive as { type?: string }).type || 'unknown'}`);
}
}
}

View File

@@ -0,0 +1,28 @@
import type {
ClickButton,
DisplaySize,
Point,
ScrollDirection,
} from '@/modules/computer-use/computer-executor.js';
export type RawComputerAction =
| { type: 'screenshot' }
| { type: 'cursor_position' }
| { type: 'mouse_move'; point: Point }
| { type: 'click'; button: ClickButton; point?: Point; double?: boolean }
| { type: 'drag'; from: Point; to: Point; button?: ClickButton }
| { type: 'type'; text: string }
| { type: 'key'; key: string }
| { type: 'scroll'; direction: ScrollDirection; amount?: number; point?: Point }
| { type: 'wait'; ms?: number };
export type RawActionTarget = {
displaySize: DisplaySize | null;
};
export type RawActionResult = {
screenshotDataUrl?: string | null;
displaySize?: DisplaySize | null;
cursor?: Point | null;
position?: Point | null;
};

View File

@@ -0,0 +1,242 @@
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
export type Point = { x: number; y: number };
export type ClickButton = 'left' | 'right' | 'middle';
export type ScrollDirection = 'up' | 'down' | 'left' | 'right';
export type DisplaySize = { width: number; height: number };
export type RuntimeReadiness = {
nut: any | null;
screenshot: any | null;
nutInstalled: boolean;
screenshotInstalled: boolean;
};
/**
* Coordinate space the executor reports/accepts. The screenshot pixel space is
* the canonical space agents and users address; it is mapped to the nut-js
* logical mouse space before any action runs.
*/
export type ExecutorTarget = {
displaySize: DisplaySize | null;
};
export function getNut(): any | null {
try {
return require('@nut-tree-fork/nut-js');
} catch {
return null;
}
}
export function getScreenshot(): any | null {
try {
const mod = require('screenshot-desktop');
return mod?.default || mod;
} catch {
return null;
}
}
export function getRuntimeReadiness(): RuntimeReadiness {
const nut = getNut();
const screenshot = getScreenshot();
return {
nut,
screenshot,
nutInstalled: Boolean(nut),
screenshotInstalled: typeof screenshot === 'function',
};
}
/** Reads the pixel dimensions from a PNG/JPEG buffer header without decoding it. */
export function readImageSize(buffer: Buffer): DisplaySize | null {
// PNG: 8-byte signature, then IHDR chunk with width/height as big-endian uint32.
if (buffer.length >= 24 && buffer[0] === 0x89 && buffer[1] === 0x50) {
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
}
// JPEG: scan for a Start-Of-Frame marker (0xFFC0..0xFFCF, excluding C4/C8/CC).
if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8) {
let offset = 2;
while (offset + 9 < buffer.length) {
if (buffer[offset] !== 0xff) {
offset += 1;
continue;
}
const marker = buffer[offset + 1];
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) };
}
offset += 2 + buffer.readUInt16BE(offset + 2);
}
}
return null;
}
export async function captureScreenshot(): Promise<{ dataUrl: string; size: DisplaySize | null }> {
const screenshot = getScreenshot();
if (typeof screenshot !== 'function') {
throw new Error('Computer Use runtime is not available.');
}
const buffer: Buffer = await screenshot({ format: 'png' });
return {
dataUrl: `data:image/png;base64,${buffer.toString('base64')}`,
size: readImageSize(buffer),
};
}
/** Returns the mouse coordinate space size (logical screen pixels). */
export async function getMouseSpaceSize(): Promise<DisplaySize> {
const nut = getNut();
if (!nut) {
throw new Error('Computer Use runtime is not available.');
}
const width = await nut.screen.width();
const height = await nut.screen.height();
return { width, height };
}
/** Maps a point from screenshot/image space to the mouse coordinate space. */
export async function toMouseSpace(target: ExecutorTarget, point: Point): Promise<Point> {
const mouseSize = await getMouseSpaceSize();
const image = target.displaySize || mouseSize;
const scaleX = image.width ? mouseSize.width / image.width : 1;
const scaleY = image.height ? mouseSize.height / image.height : 1;
return {
x: Math.round(point.x * scaleX),
y: Math.round(point.y * scaleY),
};
}
/** Maps a point from the mouse coordinate space back to screenshot/image space. */
export function toImageSpace(target: ExecutorTarget, point: Point, mouseSize: DisplaySize): Point {
const image = target.displaySize || mouseSize;
const scaleX = mouseSize.width ? image.width / mouseSize.width : 1;
const scaleY = mouseSize.height ? image.height / mouseSize.height : 1;
return {
x: Math.round(point.x * scaleX),
y: Math.round(point.y * scaleY),
};
}
function nutButton(nut: any, button: ClickButton) {
if (button === 'right') return nut.Button.RIGHT;
if (button === 'middle') return nut.Button.MIDDLE;
return nut.Button.LEFT;
}
/** Maps a key name (xdotool-style, as Anthropic's computer tool emits) to a nut-js Key. */
function nutKey(nut: any, token: string): any {
const map: Record<string, string> = {
return: 'Enter', enter: 'Enter', esc: 'Escape', escape: 'Escape', tab: 'Tab',
space: 'Space', backspace: 'Backspace', delete: 'Delete', del: 'Delete', insert: 'Insert',
up: 'Up', down: 'Down', left: 'Left', right: 'Right',
home: 'Home', end: 'End', pageup: 'PageUp', page_up: 'PageUp', pagedown: 'PageDown', page_down: 'PageDown',
ctrl: 'LeftControl', control: 'LeftControl', alt: 'LeftAlt', shift: 'LeftShift',
meta: 'LeftSuper', super: 'LeftSuper', cmd: 'LeftSuper', win: 'LeftSuper',
capslock: 'CapsLock',
};
const lower = token.toLowerCase();
if (map[lower]) {
return nut.Key[map[lower]];
}
if (/^f([1-9]|1[0-9]|2[0-4])$/.test(lower)) {
return nut.Key[`F${lower.slice(1)}`];
}
if (token.length === 1) {
const upper = token.toUpperCase();
if (nut.Key[upper] !== undefined) {
return nut.Key[upper];
}
if (nut.Key[`Num${token}`] !== undefined && /[0-9]/.test(token)) {
return nut.Key[`Num${token}`];
}
}
throw new Error(`Unsupported key: ${token}`);
}
/**
* The cross-platform OS executor. It is intentionally free of any server,
* database, or session dependencies so it can run both inside the local server
* process (OSS mode) and inside the standalone desktop agent (cloud relay).
*/
export const executor = {
async configure() {
const nut = getNut();
if (nut) {
// Make actions responsive; the agent loop already paces itself with screenshots.
nut.mouse.config.autoDelayMs = 2;
nut.keyboard.config.autoDelayMs = 2;
}
return nut;
},
async cursorPosition(target: ExecutorTarget): Promise<Point> {
const nut = await this.configure();
const mouseSize = await getMouseSpaceSize();
const pos = await nut.mouse.getPosition();
return toImageSpace(target, { x: pos.x, y: pos.y }, mouseSize);
},
async moveTo(target: ExecutorTarget, point: Point): Promise<void> {
const nut = await this.configure();
const dest = await toMouseSpace(target, point);
await nut.mouse.setPosition(new nut.Point(dest.x, dest.y));
},
async click(target: ExecutorTarget, button: ClickButton, point?: Point, doubleClick = false): Promise<void> {
const nut = await this.configure();
if (point) {
await this.moveTo(target, point);
}
if (doubleClick) {
await nut.mouse.doubleClick(nutButton(nut, button));
} else {
await nut.mouse.click(nutButton(nut, button));
}
},
async drag(target: ExecutorTarget, from: Point, to: Point, button: ClickButton = 'left'): Promise<void> {
const nut = await this.configure();
const start = await toMouseSpace(target, from);
const end = await toMouseSpace(target, to);
await nut.mouse.setPosition(new nut.Point(start.x, start.y));
await nut.mouse.pressButton(nutButton(nut, button));
await nut.mouse.setPosition(new nut.Point(end.x, end.y));
await nut.mouse.releaseButton(nutButton(nut, button));
},
async type(text: string): Promise<void> {
const nut = await this.configure();
await nut.keyboard.type(text);
},
async pressChord(chord: string): Promise<void> {
const nut = await this.configure();
const tokens = chord.split('+').map((token) => token.trim()).filter(Boolean);
if (tokens.length === 0) {
return;
}
const keys = tokens.map((token) => nutKey(nut, token));
for (const key of keys) {
await nut.keyboard.pressKey(key);
}
for (const key of [...keys].reverse()) {
await nut.keyboard.releaseKey(key);
}
},
async scroll(target: ExecutorTarget, direction: ScrollDirection, amount: number, point?: Point): Promise<void> {
const nut = await this.configure();
if (point) {
await this.moveTo(target, point);
}
const steps = Math.max(1, Math.round(amount));
if (direction === 'up') await nut.mouse.scrollUp(steps);
else if (direction === 'down') await nut.mouse.scrollDown(steps);
else if (direction === 'left') await nut.mouse.scrollLeft(steps);
else await nut.mouse.scrollRight(steps);
},
};

View File

@@ -0,0 +1,460 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import {
captureScreenshot,
executor,
type ClickButton,
type ExecutorTarget,
type Point,
type ScrollDirection,
} from '@/modules/computer-use/computer-executor.js';
import type { SemanticAdapter } from '@/modules/computer-use/semantics/adapters/semantic-adapter.js';
import { createMacOsSemanticAdapter } from '@/modules/computer-use/semantics/adapters/macos/macos-semantic-adapter.js';
import { createWindowsSemanticAdapter } from '@/modules/computer-use/semantics/adapters/windows/windows-semantic-adapter.js';
import { resolveSemanticHelper } from '@/modules/computer-use/semantics/helpers/semantic-helper-resolver.js';
import { semanticSessionStore } from '@/modules/computer-use/semantics/semantic-session-store.js';
import type { SemanticAppState, SemanticElement } from '@/modules/computer-use/semantics/semantic-types.js';
const execFileAsync = promisify(execFile);
const MAX_APP_STATE_ELEMENTS = 250;
let helperAdapter: SemanticAdapter | null | undefined;
function readString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function requireApp(input: Record<string, unknown>): string {
const app = readString(input.app);
if (!app) {
throw new Error('app is required.');
}
return app;
}
function readNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function readButton(value: unknown): ClickButton {
return value === 'right' || value === 'middle' ? value : 'left';
}
function readClickCount(value: unknown): number {
const count = readNumber(value);
if (count === undefined) {
return 1;
}
return Math.max(1, Math.min(5, Math.trunc(count)));
}
function readDirection(value: unknown): ScrollDirection {
return value === 'up' || value === 'left' || value === 'right' ? value : 'down';
}
function readSessionId(input: Record<string, unknown>): string {
return readString(input.sessionId) || 'default';
}
function centerOf(element: SemanticElement): Point | null {
const bounds = element.bounds;
if (!bounds) {
return null;
}
return {
x: Math.round(bounds.x + bounds.width / 2),
y: Math.round(bounds.y + bounds.height / 2),
};
}
function getCachedElement(sessionId: string, app: string, index: string, stateId?: string): SemanticElement | null {
return semanticSessionStore.getElement(sessionId, app, index, stateId);
}
function getPoint(input: Record<string, unknown>, sessionId: string, app: string): Point | undefined {
const x = readNumber(input.x);
const y = readNumber(input.y);
if (x !== undefined && y !== undefined) {
return { x, y };
}
const elementIndex = readString(input.element_index);
if (!elementIndex) {
return undefined;
}
const element = getCachedElement(sessionId, app, elementIndex, readString(input.stateId) || undefined);
return element ? centerOf(element) || undefined : undefined;
}
function getHelperAdapter(): SemanticAdapter | null {
if (helperAdapter !== undefined) {
return helperAdapter;
}
if (process.platform !== 'darwin' && process.platform !== 'win32') {
helperAdapter = null;
return helperAdapter;
}
const resolution = resolveSemanticHelper();
if (!resolution.available) {
helperAdapter = null;
return helperAdapter;
}
helperAdapter = process.platform === 'darwin'
? createMacOsSemanticAdapter()
: createWindowsSemanticAdapter();
return helperAdapter;
}
function shouldFallbackFromHelper(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /not implemented|unavailable|not found|does not exist|timed out|not running|exited with code|failed to start/i.test(message);
}
async function withHelperState(
sessionId: string,
operation: (adapter: SemanticAdapter) => Promise<SemanticAppState>,
): Promise<SemanticAppState | null> {
const adapter = getHelperAdapter();
if (!adapter) {
return null;
}
try {
return semanticSessionStore.save(sessionId, await operation(adapter));
} catch (error) {
if (shouldFallbackFromHelper(error)) {
console.warn('[ComputerSemantics] Falling back from helper:', error instanceof Error ? error.message : String(error));
return null;
}
throw error;
}
}
async function run(command: string, args: string[], timeout = 5000): Promise<string> {
const { stdout } = await execFileAsync(command, args, {
timeout,
windowsHide: true,
maxBuffer: 1024 * 1024 * 4,
});
return stdout;
}
async function listMacApps(): Promise<Array<Record<string, unknown>>> {
const script = [
'tell application "System Events"',
'set appRows to {}',
'repeat with p in (application processes whose background only is false)',
'set end of appRows to (name of p as text)',
'end repeat',
'return appRows',
'end tell',
].join('\n');
const output = await run('osascript', ['-e', script]);
return output.split(', ')
.map((name) => name.trim())
.filter(Boolean)
.map((name) => ({ name, running: true }));
}
async function listWindowsApps(): Promise<Array<Record<string, unknown>>> {
const script = [
'Get-Process | Where-Object { $_.MainWindowTitle } |',
'Select-Object ProcessName, Id, MainWindowTitle | ConvertTo-Json -Depth 3',
].join(' ');
const output = await run('powershell.exe', ['-NoProfile', '-Command', script]);
const parsed = JSON.parse(output || '[]');
const rows = Array.isArray(parsed) ? parsed : [parsed];
return rows.map((row) => ({
name: row.ProcessName,
pid: row.Id,
windowTitle: row.MainWindowTitle,
running: true,
}));
}
async function listLinuxApps(): Promise<Array<Record<string, unknown>>> {
try {
const output = await run('wmctrl', ['-lx']);
return output.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const parts = line.split(/\s+/);
return {
windowId: parts[0],
desktop: parts[1],
host: parts[2],
className: parts[3],
windowTitle: parts.slice(4).join(' '),
running: true,
};
});
} catch {
const output = await run('ps', ['-eo', 'comm=']);
return [...new Set(output.split(/\r?\n/).map((name) => name.trim()).filter(Boolean))]
.slice(0, 200)
.map((name) => ({ name, running: true }));
}
}
async function listApps(): Promise<Array<Record<string, unknown>>> {
if (process.platform === 'darwin') {
return listMacApps();
}
if (process.platform === 'win32') {
return listWindowsApps();
}
return listLinuxApps();
}
async function macAccessibilityTree(app: string): Promise<SemanticElement[]> {
const escapedApp = app.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const script = `
on safeText(v)
try
return v as text
on error
return ""
end try
end safeText
on emitElement(e, depth, maxDepth, counter)
if depth > maxDepth then return {}
set rows to {}
try
set roleText to my safeText(role of e)
on error
set roleText to "element"
end try
try
set titleText to my safeText(title of e)
on error
set titleText to ""
end try
try
set valueText to my safeText(value of e)
on error
set valueText to ""
end try
try
set posValue to position of e
set sizeValue to size of e
set boundsText to ((item 1 of posValue) as text) & "," & ((item 2 of posValue) as text) & "," & ((item 1 of sizeValue) as text) & "," & ((item 2 of sizeValue) as text)
on error
set boundsText to ""
end try
set end of rows to ((counter as text) & tab & roleText & tab & titleText & tab & valueText & tab & boundsText)
if counter > ${MAX_APP_STATE_ELEMENTS} then return rows
try
repeat with childElement in UI elements of e
set childRows to my emitElement(childElement, depth + 1, maxDepth, counter + (count of rows))
set rows to rows & childRows
if (count of rows) > ${MAX_APP_STATE_ELEMENTS} then return rows
end repeat
end try
return rows
end emitElement
tell application "System Events"
if not (exists process "${escapedApp}") then error "App is not running: ${escapedApp}"
tell process "${escapedApp}"
set rows to {}
repeat with w in windows
set rows to rows & my emitElement(w, 0, 4, (count of rows) + 1)
if (count of rows) > ${MAX_APP_STATE_ELEMENTS} then exit repeat
end repeat
return rows
end tell
end tell
`;
const output = await run('osascript', ['-e', script], 10000);
return output.split(/\r?\n|, /)
.map((line) => line.trim())
.filter(Boolean)
.map((line, index) => {
const [rawIndex, role, title, value, boundsText] = line.split('\t');
const boundsParts = (boundsText || '').split(',').map((part) => Number.parseFloat(part));
const hasBounds = boundsParts.length === 4 && boundsParts.every(Number.isFinite);
return {
index: rawIndex || String(index + 1),
role: role || 'element',
title: title || undefined,
value: value || undefined,
bounds: hasBounds
? { x: boundsParts[0], y: boundsParts[1], width: boundsParts[2], height: boundsParts[3] }
: undefined,
};
});
}
async function getAccessibilityTree(app: string): Promise<{ elements: SemanticElement[]; message?: string }> {
if (process.platform === 'darwin') {
try {
return { elements: await macAccessibilityTree(app) };
} catch (error) {
return { elements: [], message: error instanceof Error ? error.message : String(error) };
}
}
return {
elements: [],
message: 'Native accessibility tree capture is not implemented for this platform yet.',
};
}
async function getAppState(sessionId: string, app: string): Promise<SemanticAppState> {
if (!app) {
throw new Error('app is required.');
}
const helperState = await withHelperState(sessionId, (adapter) => adapter.getAppState({ sessionId, app }));
if (helperState) {
return helperState;
}
const screenshot = await captureScreenshot();
const tree = await getAccessibilityTree(app);
const state: SemanticAppState = {
stateId: semanticSessionStore.createStateId(),
app,
platform: process.platform,
screenshotDataUrl: screenshot.dataUrl,
displaySize: screenshot.size,
elements: tree.elements,
accessibilityTree: tree.elements,
message: tree.message,
};
return semanticSessionStore.save(sessionId, state);
}
async function targetFor(sessionId: string, app: string, stateId?: string): Promise<ExecutorTarget> {
const cached = semanticSessionStore.getState(sessionId, app, stateId);
return { displaySize: cached?.displaySize || (await captureScreenshot()).size };
}
export const computerSemanticsService = {
async callTool(name: string, input: Record<string, unknown>): Promise<unknown> {
const sessionId = readSessionId(input);
switch (name) {
case 'list_apps': {
const adapter = getHelperAdapter();
if (adapter) {
try {
return { apps: await adapter.listApps(), platform: process.platform };
} catch (error) {
if (!shouldFallbackFromHelper(error)) {
throw error;
}
console.warn('[ComputerSemantics] Falling back from helper:', error instanceof Error ? error.message : String(error));
}
}
return { apps: await listApps(), platform: process.platform };
}
case 'get_app_state':
return getAppState(sessionId, readString(input.app));
case 'click':
case 'click_element': {
const app = requireApp(input);
const helperState = await withHelperState(sessionId, (adapter) => adapter.clickElement({ ...input, sessionId, app }));
if (helperState) {
return helperState;
}
const stateId = readString(input.stateId) || undefined;
const point = getPoint(input, sessionId, app);
if (!point) {
throw new Error('click requires x/y or an element_index from computer_get_app_state.');
}
const target = await targetFor(sessionId, app, stateId);
const button = readButton(input.mouse_button ?? input.mouseButton);
const clickCount = readClickCount(input.click_count ?? input.clickCount);
for (let index = 0; index < clickCount; index += 1) {
await executor.click(target, button, point, false);
}
return getAppState(sessionId, app);
}
case 'drag': {
const app = requireApp(input);
const helperState = await withHelperState(sessionId, (adapter) => adapter.drag({ ...input, sessionId, app }));
if (helperState) {
return helperState;
}
const stateId = readString(input.stateId) || undefined;
const fromX = readNumber(input.from_x);
const fromY = readNumber(input.from_y);
const toX = readNumber(input.to_x);
const toY = readNumber(input.to_y);
if (fromX === undefined || fromY === undefined || toX === undefined || toY === undefined) {
throw new Error('drag requires from_x/from_y/to_x/to_y.');
}
await executor.drag(await targetFor(sessionId, app, stateId), { x: fromX, y: fromY }, { x: toX, y: toY }, readButton(input.mouse_button ?? input.mouseButton));
return getAppState(sessionId, app);
}
case 'scroll':
case 'scroll_element': {
const app = requireApp(input);
const helperState = await withHelperState(sessionId, (adapter) => adapter.scrollElement({ ...input, sessionId, app }));
if (helperState) {
return helperState;
}
const stateId = readString(input.stateId) || undefined;
const point = getPoint(input, sessionId, app);
if (!point) {
throw new Error('scroll requires x/y or an element_index from computer_get_app_state.');
}
await executor.scroll(await targetFor(sessionId, app, stateId), readDirection(input.direction), readNumber(input.pages) ?? 1, point);
return getAppState(sessionId, app);
}
case 'type_text': {
const app = requireApp(input);
const helperState = await withHelperState(sessionId, (adapter) => adapter.typeText({ ...input, sessionId, app }));
if (helperState) {
return helperState;
}
await executor.type(readString(input.text));
return getAppState(sessionId, app);
}
case 'press_key': {
const app = requireApp(input);
const helperState = await withHelperState(sessionId, (adapter) => adapter.pressKey({ ...input, sessionId, app }));
if (helperState) {
return helperState;
}
await executor.pressChord(readString(input.key));
return getAppState(sessionId, app);
}
case 'set_value': {
const app = requireApp(input);
const helperState = await withHelperState(sessionId, (adapter) => adapter.setValue({ ...input, sessionId, app }));
if (helperState) {
return helperState;
}
const stateId = readString(input.stateId) || undefined;
const point = getPoint(input, sessionId, app);
if (!point) {
throw new Error('set_value requires x/y or an element_index from computer_get_app_state.');
}
await executor.click(await targetFor(sessionId, app, stateId), 'left', point, false);
await executor.pressChord(process.platform === 'darwin' ? 'cmd+a' : 'ctrl+a');
await executor.type(readString(input.value));
return getAppState(sessionId, app);
}
case 'perform_secondary_action': {
const app = requireApp(input);
const helperState = await withHelperState(sessionId, (adapter) => adapter.performSecondaryAction({ ...input, sessionId, app }));
if (helperState) {
return helperState;
}
const stateId = readString(input.stateId) || undefined;
const point = getPoint(input, sessionId, app);
if (!point) {
throw new Error('perform_secondary_action requires x/y or an element_index from computer_get_app_state.');
}
await executor.click(await targetFor(sessionId, app, stateId), 'right', point, false);
return getAppState(sessionId, app);
}
default:
throw new Error(`Unknown semantic Computer Use tool: ${name}`);
}
},
};

View File

@@ -0,0 +1,141 @@
import express from 'express';
import { computerUseService } from '@/modules/computer-use/computer-use.service.js';
import { semanticOperationForMcpTool } from '@/modules/computer-use/semantics/semantic-tool-dispatcher.js';
const router = express.Router();
function readBearerToken(header: unknown): string | null {
if (typeof header !== 'string') {
return null;
}
const trimmed = header.trim();
const scheme = 'Bearer';
if (trimmed.slice(0, scheme.length).toLowerCase() !== scheme.toLowerCase()) {
return null;
}
const separator = trimmed[scheme.length];
if (separator !== ' ' && separator !== '\t') {
return null;
}
return trimmed.slice(scheme.length + 1).trimStart() || null;
}
function toButton(value: unknown): 'left' | 'right' | 'middle' {
return value === 'right' || value === 'middle' ? value : 'left';
}
function toScrollDirection(value: unknown): 'up' | 'down' | 'left' | 'right' {
return value === 'down' || value === 'left' || value === 'right' ? value : 'up';
}
function point(input: Record<string, unknown>): { x: number; y: number } | undefined {
return typeof input.x === 'number' && typeof input.y === 'number'
? { x: input.x, y: input.y }
: undefined;
}
function requireNumber(input: Record<string, unknown>, name: string): number {
const value = input[name];
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new Error(`${name} is required and must be a finite number.`);
}
return value;
}
function requirePoint(input: Record<string, unknown>): { x: number; y: number } {
return { x: requireNumber(input, 'x'), y: requireNumber(input, 'y') };
}
function requireNamedPoint(input: Record<string, unknown>, xName: string, yName: string): { x: number; y: number } {
return { x: requireNumber(input, xName), y: requireNumber(input, yName) };
}
router.use((req, res, next) => {
const expected = computerUseService.getMcpToken();
const token = readBearerToken(req.headers.authorization) || String(req.headers['x-computer-use-mcp-token'] || '');
if (!token || token !== expected) {
res.status(401).json({ success: false, error: 'Invalid Computer Use MCP token.' });
return;
}
next();
});
router.post('/tools/:toolName', async (req, res) => {
try {
const input = (req.body && typeof req.body === 'object' ? req.body : {}) as Record<string, unknown>;
const sessionId = typeof input.sessionId === 'string' ? input.sessionId : undefined;
const toolName = req.params.toolName;
const semanticOperation = semanticOperationForMcpTool(toolName);
let result: unknown;
if (semanticOperation) {
result = await computerUseService.callSemanticTool(semanticOperation, input);
res.json({ success: true, data: result });
return;
}
switch (toolName) {
case 'computer_screenshot':
result = await computerUseService.agentScreenshot(sessionId);
break;
case 'computer_cursor_position':
result = await computerUseService.agentCursorPosition(sessionId);
break;
case 'computer_mouse_move':
result = await computerUseService.agentMouseMove(sessionId, requirePoint(input));
break;
case 'computer_click':
result = await computerUseService.agentUnifiedClick(sessionId, {
button: toButton(input.mouseButton ?? input.mouse_button ?? input.button),
point: point(input),
clickCount: typeof input.clickCount === 'number'
? input.clickCount
: typeof input.click_count === 'number'
? input.click_count
: 1,
});
break;
case 'computer_drag': {
const from = requireNamedPoint(input, 'startX', 'startY');
const to = requireNamedPoint(input, 'endX', 'endY');
result = await computerUseService.agentDrag(sessionId, from, to, toButton(input.mouseButton ?? input.mouse_button ?? input.button));
break;
}
case 'computer_type':
result = await computerUseService.agentType(sessionId, String(input.text || ''));
break;
case 'computer_key':
result = await computerUseService.agentKey(sessionId, String(input.key || ''));
break;
case 'computer_scroll':
result = await computerUseService.agentScroll(sessionId, {
direction: toScrollDirection(input.direction),
amount: typeof input.amount === 'number' ? input.amount : undefined,
x: typeof input.x === 'number' ? input.x : undefined,
y: typeof input.y === 'number' ? input.y : undefined,
});
break;
case 'computer_wait':
result = await computerUseService.agentWait(sessionId, typeof input.timeoutMs === 'number' ? input.timeoutMs : undefined);
break;
case 'computer_close_session':
result = await computerUseService.agentStopSession(sessionId);
break;
default:
res.status(404).json({ success: false, error: `Unknown Computer Use MCP tool "${toolName}".` });
return;
}
res.json({ success: true, data: result });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Computer Use MCP tool failed.',
});
}
});
export default router;

View File

@@ -0,0 +1,211 @@
import express from 'express';
import { computerUseService } from '@/modules/computer-use/computer-use.service.js';
import { AppError } from '@/shared/utils.js';
const router = express.Router();
type AuthenticatedRequest = express.Request & {
user?: {
id?: string | number;
};
};
function requireUser(req: AuthenticatedRequest): { id: string | number } {
const userId = req.user?.id;
if (userId === undefined || userId === null || String(userId).trim() === '') {
throw new AppError('Authenticated user is required.', {
code: 'AUTHENTICATED_USER_REQUIRED',
statusCode: 401,
});
}
return { id: userId };
}
function getErrorStatusCode(error: unknown, fallbackStatusCode: number): number {
if (error instanceof AppError) {
return error.statusCode;
}
if (error && typeof error === 'object') {
const statusCode = 'statusCode' in error ? error.statusCode : 'status' in error ? error.status : undefined;
if (typeof statusCode === 'number' && Number.isInteger(statusCode) && statusCode >= 400 && statusCode <= 599) {
return statusCode;
}
}
return fallbackStatusCode;
}
function readParam(value: string | string[] | undefined): string {
return Array.isArray(value) ? value[0] || '' : value || '';
}
function toButton(value: unknown): 'left' | 'right' | 'middle' {
return value === 'right' || value === 'middle' ? value : 'left';
}
router.get('/status', async (_req, res) => {
try {
res.json({ success: true, data: await computerUseService.getStatus() });
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to load Computer Use status.',
});
}
});
router.get('/settings', async (req: AuthenticatedRequest, res) => {
try {
requireUser(req);
res.json({ success: true, data: { settings: await computerUseService.getSettings() } });
} catch (error) {
res.status(getErrorStatusCode(error, 500)).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to load Computer Use settings.',
});
}
});
router.put('/settings', async (req: AuthenticatedRequest, res) => {
try {
requireUser(req);
const settings = await computerUseService.updateSettings(req.body || {});
res.json({ success: true, data: { settings } });
} catch (error) {
res.status(getErrorStatusCode(error, 400)).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to save Computer Use settings.',
});
}
});
router.post('/runtime/install', async (req: AuthenticatedRequest, res) => {
try {
requireUser(req);
const result = await computerUseService.installRuntime();
res.status(result.success ? 200 : 500).json({
success: result.success,
data: result,
error: result.success ? undefined : result.message,
});
} catch (error) {
res.status(getErrorStatusCode(error, 500)).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to install Computer Use runtime.',
});
}
});
router.get('/sessions', async (req: AuthenticatedRequest, res) => {
try {
res.json({ success: true, data: { sessions: await computerUseService.listSessions(requireUser(req)) } });
} catch (error) {
res.status(getErrorStatusCode(error, 500)).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to list Computer Use sessions.',
});
}
});
router.post('/sessions/:sessionId/screenshot', async (req: AuthenticatedRequest, res) => {
try {
const session = await computerUseService.userScreenshot(requireUser(req), readParam(req.params.sessionId));
res.json({ success: true, data: { session } });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to capture the screen.',
});
}
});
router.post('/sessions/:sessionId/click', async (req: AuthenticatedRequest, res) => {
try {
const x = Number(req.body?.x);
const y = Number(req.body?.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
res.status(400).json({
success: false,
error: 'Valid numeric coordinates are required.',
});
return;
}
const session = await computerUseService.userClick(requireUser(req), readParam(req.params.sessionId), {
x,
y,
button: toButton(req.body?.button),
double: req.body?.double === true,
});
res.json({ success: true, data: { session } });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to click.',
});
}
});
router.post('/sessions/:sessionId/press-key', async (req: AuthenticatedRequest, res) => {
try {
const session = await computerUseService.userPressKey(requireUser(req), readParam(req.params.sessionId), String(req.body?.key || ''));
res.json({ success: true, data: { session } });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to send key input.',
});
}
});
router.post('/sessions/:sessionId/consent/grant', async (req: AuthenticatedRequest, res) => {
try {
const session = await computerUseService.grantAgentAccess(requireUser(req), readParam(req.params.sessionId));
res.json({ success: true, data: { session } });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to grant control.',
});
}
});
router.post('/sessions/:sessionId/consent/revoke', async (req: AuthenticatedRequest, res) => {
try {
const session = await computerUseService.revokeAgentAccess(requireUser(req), readParam(req.params.sessionId));
res.json({ success: true, data: { session } });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to revoke control.',
});
}
});
router.post('/sessions/:sessionId/stop', async (req: AuthenticatedRequest, res) => {
try {
const result = await computerUseService.stopSession(requireUser(req), readParam(req.params.sessionId));
res.json({ success: true, data: result });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to stop Computer Use session.',
});
}
});
router.delete('/sessions/:sessionId', async (req: AuthenticatedRequest, res) => {
try {
const result = await computerUseService.deleteSession(requireUser(req), readParam(req.params.sessionId));
res.json({ success: true, data: result });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to delete Computer Use session.',
});
}
});
export default router;

View File

@@ -0,0 +1,920 @@
import { randomBytes, randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { appConfigDb } from '@/modules/database/index.js';
import { providerMcpService } from '@/modules/providers/index.js';
import { getModuleDir } from '@/utils/runtime-paths.js';
import {
getRuntimeReadiness as getExecutorReadiness,
type Point,
type ClickButton,
type ScrollDirection,
} from '@/modules/computer-use/computer-executor.js';
import { runRawComputerAction } from '@/modules/computer-use/actions/raw-action-dispatcher.js';
import type { RawComputerAction } from '@/modules/computer-use/actions/raw-action-types.js';
import { desktopAgentRelay } from '@/modules/computer-use/desktop-agent-relay.service.js';
import { computerSemanticsService } from '@/modules/computer-use/computer-semantics.service.js';
import { semanticOperationNames } from '@/modules/computer-use/semantics/semantic-tool-dispatcher.js';
const __dirname = getModuleDir(import.meta.url);
const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
const MAX_SESSIONS_PER_OWNER = Number.parseInt(process.env.CLOUDCLI_COMPUTER_USE_MAX_SESSIONS_PER_OWNER || '1', 10);
const SESSION_TTL_MS = Number.parseInt(process.env.CLOUDCLI_COMPUTER_USE_SESSION_TTL_MS || String(30 * 60 * 1000), 10);
const STOPPED_SESSION_RETENTION_MS = Number.parseInt(process.env.CLOUDCLI_COMPUTER_USE_STOPPED_SESSION_RETENTION_MS || String(30 * 60 * 1000), 10);
const MAX_STORED_SESSIONS = Number.parseInt(process.env.CLOUDCLI_COMPUTER_USE_MAX_STORED_SESSIONS || '100', 10);
const COMPUTER_USE_SETTINGS_KEY = 'computer_use_settings';
const COMPUTER_USE_MCP_TOKEN_KEY = 'computer_use_mcp_token';
type ComputerUseRuntime = 'cloud' | 'local';
type ComputerUseSessionStatus = 'ready' | 'stopped' | 'unavailable';
type ComputerUseSession = {
id: string;
ownerId: string;
createdBy: 'user' | 'agent';
runtime: ComputerUseRuntime;
status: ComputerUseSessionStatus;
screenshotDataUrl: string | null;
createdAt: string;
updatedAt: string;
lastAction: string | null;
message: string | null;
/** Per-session consent: agents may act only while this is true. */
agentAccessEnabled: boolean;
/** Size of the captured screenshot in pixels — the coordinate space agents/users use. */
displaySize: {
width: number;
height: number;
} | null;
cursor: {
x: number;
y: number;
actor: 'agent' | 'user';
} | null;
};
type PublicComputerUseSession = Omit<ComputerUseSession, 'ownerId'>;
type ComputerUseOwner = {
id: string | number;
};
type ComputerUseSettings = {
enabled: boolean;
};
type RuntimeReadiness = {
nut: any | null;
screenshot: any | null;
nutInstalled: boolean;
screenshotInstalled: boolean;
installInProgress: boolean;
installMessage: string | null;
};
const sessions = new Map<string, ComputerUseSession>();
let installPromise: Promise<{ success: boolean; message: string }> | null = null;
let lastInstallMessage: string | null = null;
const DEFAULT_SETTINGS: ComputerUseSettings = {
enabled: false,
};
const AGENT_OWNER_ID = 'agent';
const MCP_SERVER_NAME = 'cloudcli-computer-use';
const MCP_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini', 'opencode'];
function getRuntime(): ComputerUseRuntime {
return IS_PLATFORM ? 'cloud' : 'local';
}
function readSettings(): ComputerUseSettings {
try {
const raw = appConfigDb.get(COMPUTER_USE_SETTINGS_KEY);
if (!raw) {
return DEFAULT_SETTINGS;
}
const parsed = JSON.parse(raw) as Partial<ComputerUseSettings>;
return {
enabled: parsed.enabled === true,
};
} catch (error: any) {
console.warn('[Computer Use] Failed to read settings:', error?.message || error);
return DEFAULT_SETTINGS;
}
}
function writeSettings(settings: ComputerUseSettings): ComputerUseSettings {
const normalized = {
enabled: settings.enabled === true,
};
appConfigDb.set(COMPUTER_USE_SETTINGS_KEY, JSON.stringify(normalized));
return normalized;
}
function getOrCreateMcpToken(): string {
const existing = appConfigDb.get(COMPUTER_USE_MCP_TOKEN_KEY);
if (existing) {
return existing;
}
const token = randomBytes(32).toString('hex');
appConfigDb.set(COMPUTER_USE_MCP_TOKEN_KEY, token);
return token;
}
function getSetupMessage(settings: ComputerUseSettings, readiness: RuntimeReadiness): string {
if (!settings.enabled) {
return 'Computer Use is disabled in settings.';
}
if (getRuntime() === 'cloud') {
return 'Open CloudCLI Desktop on this computer, connect the same account, and enable Computer Use.';
}
if (!readiness.nutInstalled || !readiness.screenshotInstalled) {
return 'Install the desktop control runtime to capture the screen and drive the mouse and keyboard.';
}
return readiness.installMessage || 'Computer Use runtime is not ready.';
}
function getMcpCommand(): { command: string; args: string[] } {
const serverDir = path.resolve(__dirname, '..', '..');
const mcpScriptPath = path.join(serverDir, 'computer-use-mcp.js');
if (fs.existsSync(mcpScriptPath)) {
return {
command: process.execPath,
args: [mcpScriptPath],
};
}
return {
command: 'cloudcli',
args: ['computer-use-mcp'],
};
}
function getMcpApiUrl(): string {
const port = process.env.SERVER_PORT || process.env.PORT || '3001';
return `http://127.0.0.1:${port}/api/computer-use-mcp`;
}
function getRuntimeReadiness(): RuntimeReadiness {
const base = getExecutorReadiness();
return {
...base,
installInProgress: Boolean(installPromise),
installMessage: lastInstallMessage,
};
}
function runCommand(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: process.cwd(),
env: process.env,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
});
const output: string[] = [];
child.stdout.on('data', (chunk) => output.push(String(chunk)));
child.stderr.on('data', (chunk) => output.push(String(chunk)));
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(output.join('').trim() || `${command} ${args.join(' ')} exited with code ${code}`));
});
});
}
function formatInstallError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (process.platform === 'linux' && /libxtst|x11|xtst|libpng|imagemagick|scrot/i.test(message)) {
return [
'Installing the desktop control runtime needs system packages.',
'On Debian/Ubuntu run: sudo apt-get install -y libxtst-dev libpng-dev imagemagick',
'then try again.',
].join(' ');
}
return message || 'Failed to install the Computer Use runtime.';
}
function isPackagedElectronNodeRuntime(): boolean {
return process.env.ELECTRON_RUN_AS_NODE === '1' && Boolean(process.versions.electron);
}
async function installRuntime(): Promise<{ success: boolean; message: string }> {
if (installPromise) {
return installPromise;
}
const readiness = getExecutorReadiness();
if (readiness.nutInstalled && readiness.screenshotInstalled) {
lastInstallMessage = 'Computer Use runtime is available.';
return { success: true, message: lastInstallMessage };
}
if (isPackagedElectronNodeRuntime()) {
lastInstallMessage = 'Computer Use runtime was not bundled with this desktop build.';
return { success: false, message: lastInstallMessage };
}
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
installPromise = (async () => {
try {
lastInstallMessage = 'Installing desktop control runtime…';
await runCommand(npmCommand, [
'install',
'--no-save',
'--no-package-lock',
'@nut-tree-fork/nut-js',
'screenshot-desktop',
]);
lastInstallMessage = 'Computer Use runtime installed.';
return { success: true, message: lastInstallMessage };
} catch (error) {
lastInstallMessage = formatInstallError(error);
return { success: false, message: lastInstallMessage };
}
})();
try {
return await installPromise;
} finally {
installPromise = null;
}
}
function getOwnerId(owner: ComputerUseOwner): string {
if (owner.id === undefined || owner.id === null || String(owner.id).trim() === '') {
throw new Error('Authenticated user is required.');
}
return String(owner.id);
}
function publicSession(session: ComputerUseSession): PublicComputerUseSession {
const { ownerId: _ownerId, ...publicFields } = session;
return publicFields;
}
function ownerSessions(ownerId: string): ComputerUseSession[] {
return [...sessions.values()].filter((session) => session.ownerId === ownerId);
}
function canAccessSession(ownerId: string, session: ComputerUseSession): boolean {
return session.ownerId === ownerId || session.ownerId === AGENT_OWNER_ID;
}
function normalizeSessionId(sessionId?: string | null): string | null {
if (typeof sessionId !== 'string') {
return null;
}
const trimmed = sessionId.trim();
return trimmed ? trimmed : null;
}
function findActiveAgentSession(): ComputerUseSession | null {
return ownerSessions(AGENT_OWNER_ID)
.filter((session) => session.status === 'ready')
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))[0] || null;
}
function positiveDuration(value: number, fallback: number): number {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
async function expireStaleSessions(now = Date.now()): Promise<void> {
const sessionTtl = positiveDuration(SESSION_TTL_MS, 30 * 60 * 1000);
const stoppedRetention = positiveDuration(STOPPED_SESSION_RETENTION_MS, sessionTtl);
for (const [sessionId, session] of sessions.entries()) {
const updatedAt = Date.parse(session.updatedAt);
if (!Number.isFinite(updatedAt)) {
continue;
}
if (session.status === 'ready') {
if (now - updatedAt <= sessionTtl) {
continue;
}
session.status = 'stopped';
session.agentAccessEnabled = false;
session.updatedAt = new Date(now).toISOString();
session.lastAction = 'expire';
session.message = 'Computer Use session expired after inactivity.';
continue;
}
if (now - updatedAt > stoppedRetention) {
sessions.delete(sessionId);
}
}
const maxStoredSessions = Number.isFinite(MAX_STORED_SESSIONS) && MAX_STORED_SESSIONS > 0
? MAX_STORED_SESSIONS
: 100;
if (sessions.size <= maxStoredSessions) {
return;
}
const removable = [...sessions.values()]
.filter((session) => session.status !== 'ready')
.sort((a, b) => Date.parse(a.updatedAt) - Date.parse(b.updatedAt));
for (const session of removable) {
if (sessions.size <= maxStoredSessions) {
break;
}
sessions.delete(session.id);
}
}
// --- Action layer: local executor (OSS) or cloud relay to the desktop agent --
//
// Every desktop interaction goes through `performAction` / `getCursorPosition`.
// In local mode it drives the in-process nut-js executor (computer-executor.ts);
// in cloud mode it forwards the action to the linked desktop agent over
// `desktopAgentRelay` and applies the returned screenshot. The local server
// itself never touches the OS in cloud mode.
/** Shape the desktop agent returns for any relayed action. */
type RelayResult = {
screenshotDataUrl?: string | null;
displaySize?: { width: number; height: number } | null;
cursor?: { x: number; y: number } | null;
position?: Point | null;
};
function applyRelayResult(session: ComputerUseSession, result: RelayResult): void {
if (typeof result.screenshotDataUrl === 'string') {
session.screenshotDataUrl = result.screenshotDataUrl;
}
if (result.displaySize) {
session.displaySize = result.displaySize;
}
if (result.cursor) {
session.cursor = { x: result.cursor.x, y: result.cursor.y, actor: session.cursor?.actor ?? 'agent' };
}
session.updatedAt = new Date().toISOString();
}
function stripSessionArgs(args: Record<string, unknown>): Record<string, unknown> {
const { sessionId: _sessionId, ...toolArgs } = args;
return toolArgs;
}
async function refreshScreenshot(session: ComputerUseSession): Promise<void> {
if (getRuntime() === 'cloud') {
const result = (await desktopAgentRelay.relay('screenshot', { sessionId: session.id })) as RelayResult;
applyRelayResult(session, result);
return;
}
applyRelayResult(session, await runRawComputerAction({ type: 'screenshot' }, session));
}
/** Runs one action and refreshes the session screenshot afterwards. */
async function performAction(session: ComputerUseSession, action: RawComputerAction): Promise<void> {
if (getRuntime() === 'cloud') {
const result = (await desktopAgentRelay.relay(action.type, {
...action,
sessionId: session.id,
displaySize: session.displaySize,
})) as RelayResult;
applyRelayResult(session, result);
return;
}
applyRelayResult(session, await runRawComputerAction(action, session));
}
/** Reads the current cursor position in screenshot-pixel space. */
async function getCursorPosition(session: ComputerUseSession): Promise<Point> {
if (getRuntime() === 'cloud') {
const result = (await desktopAgentRelay.relay('cursor_position', {
sessionId: session.id,
displaySize: session.displaySize,
})) as RelayResult;
applyRelayResult(session, result);
if (result.position) {
return result.position;
}
return session.cursor ? { x: session.cursor.x, y: session.cursor.y } : { x: 0, y: 0 };
}
const result = await runRawComputerAction({ type: 'cursor_position' }, session);
applyRelayResult(session, result);
return result.position || session.cursor || { x: 0, y: 0 };
}
function assertReady(session: ComputerUseSession): void {
if (session.status !== 'ready') {
throw new Error(session.message || 'Computer Use session is not available.');
}
}
function agentToolsAvailable(): boolean {
const settings = readSettings();
if (!settings.enabled) {
return false;
}
if (getRuntime() === 'cloud') {
return desktopAgentRelay.isConnected();
}
return true;
}
function assertAgentToolsAvailable(): void {
if (agentToolsAvailable()) {
return;
}
const settings = readSettings();
if (!settings.enabled) {
throw new Error('Computer Use agent tools are disabled.');
}
throw new Error(
getRuntime() === 'cloud'
? 'No desktop is linked. Open CloudCLI Desktop on this computer, connect the same account, and enable Computer Use.'
: 'Computer Use agent tools are disabled.'
);
}
function stopSessions(lastAction: string, message: string): void {
for (const session of sessions.values()) {
session.status = 'stopped';
session.agentAccessEnabled = false;
session.updatedAt = new Date().toISOString();
session.lastAction = lastAction;
session.message = message;
}
}
export const computerUseService = {
async getSettings() {
return readSettings();
},
async updateSettings(settings: Partial<ComputerUseSettings>) {
const current = readSettings();
const enabled = typeof settings.enabled === 'boolean' ? settings.enabled : current.enabled;
const next = writeSettings({ enabled });
if (next.enabled) {
await this.registerAgentMcp();
} else {
await this.unregisterAgentMcp();
desktopAgentRelay.disconnectAll('Computer Use was disabled in this environment.');
stopSessions('settings:disabled', 'Computer Use was disabled in settings.');
}
return next;
},
async getStatus() {
const settings = readSettings();
const readiness = getRuntimeReadiness();
const isCloud = getRuntime() === 'cloud';
const runtimeReady = readiness.nutInstalled && readiness.screenshotInstalled;
// Cloud mode still respects the saved feature setting. When enabled, cloud
// availability comes from a linked desktop agent because the hosted server
// has no screen of its own.
const desktopAgentConnected = desktopAgentRelay.isConnected();
const available = settings.enabled && (isCloud
? desktopAgentConnected
: runtimeReady);
return {
enabled: settings.enabled,
runtime: getRuntime(),
available,
desktopAgentConnected,
desktopAgentCount: desktopAgentRelay.connectedCount(),
nutInstalled: readiness.nutInstalled,
screenshotInstalled: readiness.screenshotInstalled,
installInProgress: readiness.installInProgress,
sessionCount: sessions.size,
message: available ? 'Computer Use runtime is available.' : getSetupMessage(settings, readiness),
};
},
async registerAgentMcp() {
const { command, args } = getMcpCommand();
const results = await providerMcpService.addMcpServerToAllProviders({
name: MCP_SERVER_NAME,
scope: 'user',
transport: 'stdio',
command,
args,
env: {
CLOUDCLI_COMPUTER_USE_MCP_TOKEN: getOrCreateMcpToken(),
CLOUDCLI_COMPUTER_USE_API_URL: getMcpApiUrl(),
},
});
return { name: MCP_SERVER_NAME, command, args, results };
},
getMcpToken() {
return getOrCreateMcpToken();
},
async unregisterAgentMcp() {
const results = await Promise.all(MCP_PROVIDERS.map(async (provider) => {
try {
const result = await providerMcpService.removeProviderMcpServer(provider, {
name: MCP_SERVER_NAME,
scope: 'user',
});
return { provider, removed: result.removed };
} catch (error) {
return {
provider,
removed: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}));
return { name: MCP_SERVER_NAME, results };
},
async installRuntime() {
const result = await installRuntime();
return {
...result,
status: await this.getStatus(),
};
},
async listSessions(owner: ComputerUseOwner) {
const ownerId = getOwnerId(owner);
await expireStaleSessions();
return [...sessions.values()]
.filter((session) => canAccessSession(ownerId, session))
.map(publicSession);
},
async createSession(owner: ComputerUseOwner, options?: { createdBy?: 'user' | 'agent' }) {
const ownerId = getOwnerId(owner);
await expireStaleSessions();
const createdBy = options?.createdBy ?? 'user';
const now = new Date().toISOString();
const session: ComputerUseSession = {
id: randomUUID(),
ownerId,
createdBy,
runtime: getRuntime(),
status: 'unavailable',
screenshotDataUrl: null,
createdAt: now,
updatedAt: now,
lastAction: 'create',
// Consent is always OFF at creation — the user must explicitly grant control,
// even for agent-initiated sessions controlling the full desktop.
agentAccessEnabled: false,
displaySize: null,
message: null,
cursor: null,
};
const activeOwnerSessions = ownerSessions(ownerId).filter((item) => item.status === 'ready');
if (activeOwnerSessions.length >= MAX_SESSIONS_PER_OWNER) {
throw new Error(`Computer Use is limited to ${MAX_SESSIONS_PER_OWNER} active session(s).`);
}
const settings = readSettings();
const readiness = getRuntimeReadiness();
const isCloud = getRuntime() === 'cloud';
const runtimeReady = readiness.nutInstalled && readiness.screenshotInstalled;
const ready = settings.enabled && (isCloud
? desktopAgentRelay.isConnected()
: runtimeReady);
if (!ready) {
session.message = getSetupMessage(settings, readiness);
sessions.set(session.id, session);
return publicSession(session);
}
// In cloud mode the linked desktop agent is the consent authority and prompts
// the user per its own consent mode, so the relay is allowed to act. In local
// mode the user must still grant control from the panel.
if (isCloud) {
session.agentAccessEnabled = true;
}
session.status = 'ready';
session.message = isCloud
? 'Computer Use session is ready on the linked desktop.'
: 'Computer Use session is ready. Grant control to let agents act.';
sessions.set(session.id, session);
try {
await refreshScreenshot(session);
} catch (error) {
session.status = 'unavailable';
session.message = error instanceof Error ? error.message : 'Failed to capture the screen.';
}
return publicSession(session);
},
async grantAgentAccess(owner: ComputerUseOwner, sessionId: string) {
const ownerId = getOwnerId(owner);
const session = sessions.get(sessionId);
if (!session || !canAccessSession(ownerId, session)) {
throw new Error('Computer Use session not found.');
}
session.agentAccessEnabled = true;
session.updatedAt = new Date().toISOString();
session.lastAction = 'consent:grant';
return publicSession(session);
},
async revokeAgentAccess(owner: ComputerUseOwner, sessionId: string) {
const ownerId = getOwnerId(owner);
const session = sessions.get(sessionId);
if (!session || !canAccessSession(ownerId, session)) {
throw new Error('Computer Use session not found.');
}
session.agentAccessEnabled = false;
session.updatedAt = new Date().toISOString();
session.lastAction = 'consent:revoke';
return publicSession(session);
},
async stopSession(owner: ComputerUseOwner, sessionId: string) {
const ownerId = getOwnerId(owner);
const session = sessions.get(sessionId);
if (!session || !canAccessSession(ownerId, session)) {
return { stopped: false };
}
session.status = 'stopped';
session.agentAccessEnabled = false;
session.updatedAt = new Date().toISOString();
session.lastAction = 'stop';
session.message = 'Computer Use session stopped. Agent control is revoked.';
if (getRuntime() === 'cloud' && desktopAgentRelay.isConnected()) {
// Best-effort: tell the desktop agent to forget this session's consent.
void desktopAgentRelay.relay('stop_session', { sessionId }).catch(() => undefined);
}
return { stopped: true, session: publicSession(session) };
},
async deleteSession(owner: ComputerUseOwner, sessionId: string) {
const ownerId = getOwnerId(owner);
const session = sessions.get(sessionId);
if (!session || !canAccessSession(ownerId, session)) {
return { deleted: false };
}
sessions.delete(sessionId);
return { deleted: true, sessionId };
},
// --- User-initiated actions (from the panel) -------------------------------
async userScreenshot(owner: ComputerUseOwner, sessionId: string) {
const ownerId = getOwnerId(owner);
const session = sessions.get(sessionId);
if (!session || !canAccessSession(ownerId, session)) {
throw new Error('Computer Use session not found.');
}
assertReady(session);
await refreshScreenshot(session);
session.lastAction = 'screenshot';
return publicSession(session);
},
async userClick(owner: ComputerUseOwner, sessionId: string, input: { x: number; y: number; button?: ClickButton; double?: boolean }) {
const ownerId = getOwnerId(owner);
const session = sessions.get(sessionId);
if (!session || !canAccessSession(ownerId, session)) {
throw new Error('Computer Use session not found.');
}
assertReady(session);
await performAction(session, {
type: 'click',
button: input.button || 'left',
point: { x: input.x, y: input.y },
double: input.double === true,
});
session.cursor = { x: input.x, y: input.y, actor: 'user' };
session.lastAction = input.double ? 'double_click' : 'click';
return publicSession(session);
},
async userPressKey(owner: ComputerUseOwner, sessionId: string, key: string) {
const ownerId = getOwnerId(owner);
const session = sessions.get(sessionId);
if (!session || !canAccessSession(ownerId, session)) {
throw new Error('Computer Use session not found.');
}
assertReady(session);
await performAction(session, { type: 'key', key });
session.lastAction = `key:${key}`;
return publicSession(session);
},
// --- Agent-initiated actions (via MCP) ------------------------------------
/**
* Resolves a session the agent is allowed to act on. In local mode this
* enforces the in-process per-session consent flag. In cloud mode the linked
* desktop agent is the consent authority (it prompts the user per its own
* consent mode), so this only requires the relay to be connected.
*/
async getOrCreateAgentSession(): Promise<ComputerUseSession> {
assertAgentToolsAvailable();
await expireStaleSessions();
const existing = findActiveAgentSession();
if (existing) {
return existing;
}
const created = await this.createSession({ id: AGENT_OWNER_ID }, { createdBy: 'agent' });
const session = sessions.get(created.id);
if (!session) {
throw new Error('Computer Use session could not be created.');
}
return session;
},
async getConsentedSession(sessionId?: string): Promise<ComputerUseSession> {
assertAgentToolsAvailable();
const normalizedSessionId = normalizeSessionId(sessionId);
const session = normalizedSessionId
? sessions.get(normalizedSessionId)
: await this.getOrCreateAgentSession();
if (!session) {
throw new Error('Computer Use session not found.');
}
if (getRuntime() !== 'cloud' && !session.agentAccessEnabled) {
throw new Error(`Computer Use session ${session.id} is awaiting user consent. Ask the user to grant control in the Computer panel.`);
}
assertReady(session);
return session;
},
async agentScreenshot(sessionId?: string) {
const session = await this.getConsentedSession(sessionId);
await refreshScreenshot(session);
session.lastAction = 'screenshot';
return publicSession(session);
},
async agentCursorPosition(sessionId?: string) {
const session = await this.getConsentedSession(sessionId);
const point = await getCursorPosition(session);
session.cursor = { ...point, actor: 'agent' };
session.lastAction = 'cursor_position';
return { session: publicSession(session), position: point };
},
async agentMouseMove(sessionId: string | undefined, point: Point) {
const session = await this.getConsentedSession(sessionId);
await performAction(session, { type: 'mouse_move', point });
session.cursor = { ...point, actor: 'agent' };
session.lastAction = 'mouse_move';
return publicSession(session);
},
async agentUnifiedClick(sessionId: string | undefined, input: { button?: ClickButton; point?: Point; clickCount?: number }) {
const session = await this.getConsentedSession(sessionId);
const button = input.button || 'left';
const clickCount = Math.max(1, Math.min(Math.trunc(input.clickCount || 1), 5));
for (let index = 0; index < clickCount; index += 1) {
await performAction(session, { type: 'click', button, point: input.point, double: false });
}
if (input.point) {
session.cursor = { ...input.point, actor: 'agent' };
}
session.lastAction = clickCount > 1 ? `${button}_click:${clickCount}` : `${button}_click`;
return publicSession(session);
},
async agentDrag(sessionId: string | undefined, from: Point, to: Point, button: ClickButton = 'left') {
const session = await this.getConsentedSession(sessionId);
await performAction(session, { type: 'drag', from, to, button });
session.cursor = { ...to, actor: 'agent' };
session.lastAction = `${button}_drag`;
return publicSession(session);
},
async agentType(sessionId: string | undefined, text: string) {
const session = await this.getConsentedSession(sessionId);
await performAction(session, { type: 'type', text });
session.lastAction = 'type';
return publicSession(session);
},
async agentKey(sessionId: string | undefined, key: string) {
const session = await this.getConsentedSession(sessionId);
await performAction(session, { type: 'key', key });
session.lastAction = `key:${key}`;
return publicSession(session);
},
async agentScroll(sessionId: string | undefined, input: { direction: ScrollDirection; amount?: number; x?: number; y?: number }) {
const session = await this.getConsentedSession(sessionId);
const point = typeof input.x === 'number' && typeof input.y === 'number' ? { x: input.x, y: input.y } : undefined;
await performAction(session, { type: 'scroll', direction: input.direction, amount: input.amount, point });
if (point) {
session.cursor = { ...point, actor: 'agent' };
}
session.lastAction = `scroll:${input.direction}`;
return publicSession(session);
},
async agentWait(sessionId?: string, timeoutMs?: number) {
const session = await this.getConsentedSession(sessionId);
await performAction(session, { type: 'wait', ms: timeoutMs });
session.lastAction = 'wait';
return publicSession(session);
},
async agentStopSession(sessionId?: string) {
assertAgentToolsAvailable();
const normalizedSessionId = normalizeSessionId(sessionId);
if (normalizedSessionId) {
return this.stopSession({ id: AGENT_OWNER_ID }, normalizedSessionId);
}
await expireStaleSessions();
const existing = findActiveAgentSession();
if (!existing) {
return { stopped: false };
}
return this.stopSession({ id: AGENT_OWNER_ID }, existing.id);
},
async callSemanticTool(toolName: string, args: Record<string, unknown>) {
if (!semanticOperationNames.has(toolName)) {
throw new Error(`Unsupported semantic Computer Use tool: ${toolName}`);
}
const sessionId = typeof args.sessionId === 'string' ? args.sessionId : undefined;
const session = await this.getConsentedSession(normalizeSessionId(sessionId) ?? undefined);
const toolArgs = { ...stripSessionArgs(args), sessionId: session.id };
const semanticResult = getRuntime() === 'cloud'
? await desktopAgentRelay.relay('semantic_tool', {
sessionId: session.id,
displaySize: session.displaySize,
toolName,
arguments: toolArgs,
})
: await computerSemanticsService.callTool(toolName, toolArgs);
applyRelayResult(session, semanticResult as RelayResult);
session.lastAction = `semantic:${toolName}`;
return { session: publicSession(session), result: semanticResult };
},
/**
* Cloud only: when a desktop agent links to this hosted environment, expose
* the computer_* MCP tools only if the user enabled Computer Use in settings.
*/
async onDesktopAgentConnected() {
if (getRuntime() !== 'cloud') {
return;
}
if (!readSettings().enabled) {
return;
}
try {
await this.registerAgentMcp();
} catch (error) {
console.warn('[Computer Use] Failed to register MCP for linked desktop agent:', error instanceof Error ? error.message : error);
}
},
/** Cloud only: tear down sessions when the last desktop agent disconnects. */
async onDesktopAgentDisconnected() {
if (getRuntime() !== 'cloud' || desktopAgentRelay.isConnected()) {
return;
}
for (const session of sessions.values()) {
if (session.status === 'ready') {
session.status = 'stopped';
session.agentAccessEnabled = false;
session.updatedAt = new Date().toISOString();
session.lastAction = 'agent-disconnected';
session.message = 'The linked desktop agent disconnected.';
}
}
},
async stopAllSessions() {
stopSessions('shutdown', 'Computer Use session stopped during server shutdown.');
},
};
// Drive cloud MCP exposure + session teardown off desktop-agent connectivity.
desktopAgentRelay.setHooks({
canAcceptConnection: () => getRuntime() === 'cloud' && readSettings().enabled,
onFirstConnect: () => computerUseService.onDesktopAgentConnected(),
onLastDisconnect: () => computerUseService.onDesktopAgentDisconnected(),
});
process.once('beforeExit', () => {
void computerUseService.stopAllSessions();
});

View File

@@ -0,0 +1,158 @@
import { randomUUID } from 'node:crypto';
import type { WebSocket } from 'ws';
const RELAY_TIMEOUT_MS = Number.parseInt(process.env.CLOUDCLI_COMPUTER_USE_RELAY_TIMEOUT_MS || '60000', 10);
const WS_OPEN = 1;
type PendingRelay = {
resolve: (value: unknown) => void;
reject: (reason: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
type ConnectedAgent = {
ws: WebSocket;
label: string;
registeredAt: string;
};
type RelayLifecycleHooks = {
canAcceptConnection?: () => boolean;
onFirstConnect?: () => void | Promise<void>;
onLastDisconnect?: () => void | Promise<void>;
};
const agents = new Map<WebSocket, ConnectedAgent>();
const pending = new Map<string, PendingRelay>();
let hooks: RelayLifecycleHooks = {};
function rejectAllPending(reason: string): void {
for (const [callId, call] of pending.entries()) {
clearTimeout(call.timer);
call.reject(new Error(reason));
pending.delete(callId);
}
}
function pickAgent(): ConnectedAgent | undefined {
for (const agent of agents.values()) {
if (agent.ws.readyState === WS_OPEN) {
return agent;
}
}
return undefined;
}
/**
* Cloud-side registry of linked desktop agents and the request/response relay
* used to drive the user's real desktop. The hosted server never touches the OS
* itself — it only forwards `computer_*` actions to a connected desktop agent
* and awaits the screenshot it returns.
*/
export const desktopAgentRelay = {
setHooks(next: RelayLifecycleHooks): void {
hooks = next;
},
register(ws: WebSocket, label = 'desktop-agent'): boolean {
if (hooks.canAcceptConnection && !hooks.canAcceptConnection()) {
console.log(`[DesktopAgent] Rejected (${label}); Computer Use is disabled.`);
try {
ws.close(1008, 'Computer Use is disabled in this environment.');
} catch {
// ignore close failures
}
return false;
}
const wasEmpty = pickAgent() === undefined;
agents.set(ws, { ws, label, registeredAt: new Date().toISOString() });
console.log(`[DesktopAgent] Registered (${label}); ${agents.size} connected.`);
ws.on('close', () => {
const wasRegistered = agents.delete(ws);
console.log(`[DesktopAgent] Disconnected (${label}); ${agents.size} remain.`);
if (wasRegistered && pickAgent() === undefined) {
rejectAllPending('Desktop agent disconnected.');
void hooks.onLastDisconnect?.();
}
});
if (wasEmpty) {
void hooks.onFirstConnect?.();
}
return true;
},
disconnectAll(reason = 'Desktop agent disconnected.'): void {
const hadAgent = pickAgent() !== undefined;
const sockets = [...agents.keys()];
agents.clear();
for (const ws of sockets) {
try {
ws.close(1008, reason);
} catch {
// ignore close failures
}
}
rejectAllPending(reason);
if (hadAgent) {
void hooks.onLastDisconnect?.();
}
},
/** Resolves a pending relay call with the desktop agent's reply. */
handleResult(id: string, result: unknown, error?: string): void {
const call = pending.get(id);
if (!call) {
return;
}
clearTimeout(call.timer);
pending.delete(id);
if (error) {
call.reject(new Error(error));
} else {
call.resolve(result);
}
},
isConnected(): boolean {
return pickAgent() !== undefined;
},
connectedCount(): number {
let count = 0;
for (const agent of agents.values()) {
if (agent.ws.readyState === WS_OPEN) {
count++;
}
}
return count;
},
async relay(type: string, params: Record<string, unknown>): Promise<unknown> {
const agent = pickAgent();
if (!agent) {
throw new Error(
'No desktop is linked. Open CloudCLI Desktop on this computer, connect the same account, and enable Computer Use.'
);
}
const id = randomUUID();
return new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error('Desktop agent did not respond in time.'));
}, RELAY_TIMEOUT_MS);
pending.set(id, { resolve, reject, timer });
try {
agent.ws.send(JSON.stringify({ kind: 'computer_relay', id, type, params }));
} catch (error) {
clearTimeout(timer);
pending.delete(id);
reject(error instanceof Error ? error : new Error('Failed to send to desktop agent.'));
}
});
},
};

View File

@@ -0,0 +1,2 @@
export { computerUseService } from '@/modules/computer-use/computer-use.service.js';
export { desktopAgentRelay } from '@/modules/computer-use/desktop-agent-relay.service.js';

View File

@@ -0,0 +1,82 @@
import { SemanticHelperProcess } from '@/modules/computer-use/semantics/helpers/semantic-helper-process.js';
import { resolveSemanticHelper } from '@/modules/computer-use/semantics/helpers/semantic-helper-resolver.js';
import type { SemanticAdapter, SemanticAdapterCapabilities } from '@/modules/computer-use/semantics/adapters/semantic-adapter.js';
import type { SemanticApp, SemanticAppState, SemanticToolInput } from '@/modules/computer-use/semantics/semantic-types.js';
type HelperMethod =
| 'list_apps'
| 'get_app_state'
| 'click_element'
| 'perform_secondary_action'
| 'set_value'
| 'type_text'
| 'press_key'
| 'scroll_element'
| 'drag';
export class HelperSemanticAdapter implements SemanticAdapter {
private helper: SemanticHelperProcess | null = null;
constructor(
private readonly platform: NodeJS.Platform,
private readonly arch: NodeJS.Architecture = process.arch,
) {}
capabilities(): SemanticAdapterCapabilities {
return {
platform: this.platform,
appDiscovery: true,
accessibilityTree: true,
nativeElementActions: true,
nativeValueSetting: true,
targetedInput: true,
};
}
async listApps(): Promise<SemanticApp[]> {
return await this.request('list_apps', {}) as SemanticApp[];
}
async getAppState(input: SemanticToolInput): Promise<SemanticAppState> {
return await this.request('get_app_state', input) as SemanticAppState;
}
async clickElement(input: SemanticToolInput): Promise<SemanticAppState> {
return await this.request('click_element', input) as SemanticAppState;
}
async performSecondaryAction(input: SemanticToolInput): Promise<SemanticAppState> {
return await this.request('perform_secondary_action', input) as SemanticAppState;
}
async setValue(input: SemanticToolInput): Promise<SemanticAppState> {
return await this.request('set_value', input) as SemanticAppState;
}
async typeText(input: SemanticToolInput): Promise<SemanticAppState> {
return await this.request('type_text', input) as SemanticAppState;
}
async pressKey(input: SemanticToolInput): Promise<SemanticAppState> {
return await this.request('press_key', input) as SemanticAppState;
}
async scrollElement(input: SemanticToolInput): Promise<SemanticAppState> {
return await this.request('scroll_element', input) as SemanticAppState;
}
async drag(input: SemanticToolInput): Promise<SemanticAppState> {
return await this.request('drag', input) as SemanticAppState;
}
private async request(method: HelperMethod, params: Record<string, unknown>): Promise<unknown> {
if (!this.helper) {
const resolution = resolveSemanticHelper(this.platform, this.arch);
if (!resolution.available || !resolution.path) {
throw new Error(resolution.reason || `Semantic helper is unavailable for ${this.platform}-${this.arch}.`);
}
this.helper = new SemanticHelperProcess(resolution.path);
}
return this.helper.request(method, params);
}
}

View File

@@ -0,0 +1,5 @@
import { HelperSemanticAdapter } from '@/modules/computer-use/semantics/adapters/helper-semantic-adapter.js';
export function createMacOsSemanticAdapter(): HelperSemanticAdapter {
return new HelperSemanticAdapter('darwin');
}

View File

@@ -0,0 +1,23 @@
import type { SemanticApp, SemanticAppState, SemanticToolInput } from '@/modules/computer-use/semantics/semantic-types.js';
export type SemanticAdapterCapabilities = {
platform: NodeJS.Platform;
appDiscovery: boolean;
accessibilityTree: boolean;
nativeElementActions: boolean;
nativeValueSetting: boolean;
targetedInput: boolean;
};
export type SemanticAdapter = {
capabilities(): SemanticAdapterCapabilities;
listApps(): Promise<SemanticApp[]>;
getAppState(input: SemanticToolInput): Promise<SemanticAppState>;
clickElement(input: SemanticToolInput): Promise<SemanticAppState>;
performSecondaryAction(input: SemanticToolInput): Promise<SemanticAppState>;
setValue(input: SemanticToolInput): Promise<SemanticAppState>;
typeText(input: SemanticToolInput): Promise<SemanticAppState>;
pressKey(input: SemanticToolInput): Promise<SemanticAppState>;
scrollElement(input: SemanticToolInput): Promise<SemanticAppState>;
drag(input: SemanticToolInput): Promise<SemanticAppState>;
};

View File

@@ -0,0 +1,5 @@
import { HelperSemanticAdapter } from '@/modules/computer-use/semantics/adapters/helper-semantic-adapter.js';
export function createWindowsSemanticAdapter(): HelperSemanticAdapter {
return new HelperSemanticAdapter('win32');
}

View File

@@ -0,0 +1,467 @@
import AppKit
import ApplicationServices
import Foundation
typealias JSON = [String: Any]
struct ElementRecord {
let index: String
let role: String
let title: String?
let value: String?
let bounds: [String: Double]?
let actions: [String]
}
var stateElements: [String: [ElementRecord]] = [:]
var stateAxElements: [String: [String: AXUIElement]] = [:]
var stateOrder: [String] = []
let maxStoredStates = 100
func jsonLine(_ object: Any) {
guard JSONSerialization.isValidJSONObject(object),
let data = try? JSONSerialization.data(withJSONObject: object),
let text = String(data: data, encoding: .utf8)
else {
print("{\"error\":\"Failed to encode JSON\"}")
fflush(stdout)
return
}
print(text)
fflush(stdout)
}
func respond(id: Any?, result: Any) {
jsonLine(["id": id ?? NSNull(), "result": result])
}
func respondError(id: Any?, _ message: String) {
jsonLine(["id": id ?? NSNull(), "error": message])
}
func stringAttr(_ element: AXUIElement, _ attr: CFString) -> String? {
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(element, attr, &value) == .success else { return nil }
return value as? String
}
func boolAttr(_ element: AXUIElement, _ attr: CFString) -> Bool? {
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(element, attr, &value) == .success else { return nil }
return value as? Bool
}
func arrayAttr(_ element: AXUIElement, _ attr: CFString) -> [AXUIElement] {
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(element, attr, &value) == .success else { return [] }
return value as? [AXUIElement] ?? []
}
func actions(_ element: AXUIElement) -> [String] {
var names: CFArray?
guard AXUIElementCopyActionNames(element, &names) == .success else { return [] }
return names as? [String] ?? []
}
func bounds(_ element: AXUIElement) -> [String: Double]? {
var positionRef: CFTypeRef?
var sizeRef: CFTypeRef?
guard AXUIElementCopyAttributeValue(element, kAXPositionAttribute as CFString, &positionRef) == .success,
AXUIElementCopyAttributeValue(element, kAXSizeAttribute as CFString, &sizeRef) == .success,
let positionValue = positionRef,
let sizeValue = sizeRef
else { return nil }
var point = CGPoint.zero
var size = CGSize.zero
guard CFGetTypeID(positionValue) == AXValueGetTypeID(),
CFGetTypeID(sizeValue) == AXValueGetTypeID()
else { return nil }
let positionAxValue = positionValue as! AXValue
let sizeAxValue = sizeValue as! AXValue
guard AXValueGetValue(positionAxValue, .cgPoint, &point),
AXValueGetValue(sizeAxValue, .cgSize, &size)
else { return nil }
return [
"x": Double(point.x),
"y": Double(point.y),
"width": Double(size.width),
"height": Double(size.height),
]
}
func record(_ element: AXUIElement, index: String) -> ElementRecord {
ElementRecord(
index: index,
role: stringAttr(element, kAXRoleAttribute as CFString) ?? "AXUnknown",
title: stringAttr(element, kAXTitleAttribute as CFString) ?? stringAttr(element, kAXDescriptionAttribute as CFString),
value: stringAttr(element, kAXValueAttribute as CFString),
bounds: bounds(element),
actions: actions(element)
)
}
func cachedElement(_ params: JSON) -> AXUIElement? {
guard let stateId = params["stateId"] as? String,
let elementIndex = params["element_index"] as? String
else {
return nil
}
return stateAxElements[stateId]?[elementIndex]
}
func dictionary(_ record: ElementRecord) -> JSON {
var output: JSON = [
"index": record.index,
"role": record.role,
"actions": record.actions,
]
if let title = record.title { output["title"] = title }
if let value = record.value { output["value"] = value }
if let bounds = record.bounds { output["bounds"] = bounds }
return output
}
func pruneStoredStates() {
while stateOrder.count > maxStoredStates {
let evicted = stateOrder.removeFirst()
stateElements.removeValue(forKey: evicted)
stateAxElements.removeValue(forKey: evicted)
}
}
func resolveApp(_ query: String) throws -> NSRunningApplication {
let normalized = query.lowercased()
let apps = NSWorkspace.shared.runningApplications.filter { app in
app.activationPolicy == .regular
}
if let app = apps.first(where: { $0.bundleIdentifier?.lowercased() == normalized }) {
return app
}
if let app = apps.first(where: { ($0.localizedName ?? "").lowercased() == normalized }) {
return app
}
if let app = apps.first(where: { ($0.localizedName ?? "").lowercased().contains(normalized) }) {
return app
}
throw NSError(domain: "CloudCLISemantics", code: 404, userInfo: [NSLocalizedDescriptionKey: "App is not running: \(query)"])
}
func listApps() -> [[String: Any]] {
NSWorkspace.shared.runningApplications
.filter { $0.activationPolicy == .regular }
.map { app in
[
"id": app.bundleIdentifier ?? app.localizedName ?? "\(app.processIdentifier)",
"name": app.localizedName ?? app.bundleIdentifier ?? "Unknown",
"bundleIdentifier": app.bundleIdentifier ?? "",
"pid": Int(app.processIdentifier),
"running": true,
]
}
}
func walk(_ element: AXUIElement, depth: Int, maxDepth: Int, records: inout [ElementRecord], axRecords: inout [String: AXUIElement], limit: Int) {
if depth > maxDepth || records.count >= limit { return }
let index = "\(records.count + 1)"
records.append(record(element, index: index))
axRecords[index] = element
for child in arrayAttr(element, kAXChildrenAttribute as CFString) {
walk(child, depth: depth + 1, maxDepth: maxDepth, records: &records, axRecords: &axRecords, limit: limit)
if records.count >= limit { return }
}
}
func pngDataUrlForMainDisplay() -> String? {
let fileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("cloudcli-semantics-\(UUID().uuidString).png")
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
process.arguments = ["-x", "-t", "png", fileURL.path]
do {
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else { return nil }
let png = try Data(contentsOf: fileURL)
try? FileManager.default.removeItem(at: fileURL)
return png.isEmpty ? nil : "data:image/png;base64,\(png.base64EncodedString())"
} catch {
try? FileManager.default.removeItem(at: fileURL)
return nil
}
}
func getAppState(_ params: JSON) throws -> JSON {
let appName = params["app"] as? String ?? ""
let app = try resolveApp(appName)
let axApp = AXUIElementCreateApplication(app.processIdentifier)
let windows = arrayAttr(axApp, kAXWindowsAttribute as CFString)
let root = windows.first ?? axApp
var records: [ElementRecord] = []
var axRecords: [String: AXUIElement] = [:]
walk(root, depth: 0, maxDepth: 5, records: &records, axRecords: &axRecords, limit: 300)
let stateId = "state_\(UUID().uuidString)"
stateElements[stateId] = records
stateAxElements[stateId] = axRecords
stateOrder.append(stateId)
pruneStoredStates()
let elements = records.map(dictionary)
return [
"stateId": stateId,
"app": app.localizedName ?? app.bundleIdentifier ?? appName,
"platform": "darwin",
"screenshotDataUrl": pngDataUrlForMainDisplay() ?? NSNull(),
"displaySize": [
"width": Int(CGDisplayPixelsWide(CGMainDisplayID())),
"height": Int(CGDisplayPixelsHigh(CGMainDisplayID())),
],
"elements": elements,
"accessibilityTree": elements,
"treeText": elements.map { "\($0["index"] ?? "") \($0["role"] ?? "") \($0["title"] ?? "")" }.joined(separator: "\n"),
]
}
func cgMouseButton(_ value: Any?) -> CGMouseButton {
guard let button = value as? String else { return .left }
switch button {
case "right": return .right
case "middle": return .center
default: return .left
}
}
func mouseEventTypes(_ button: CGMouseButton) -> (CGEventType, CGEventType) {
switch button {
case .right: return (.rightMouseDown, .rightMouseUp)
case .center: return (.otherMouseDown, .otherMouseUp)
default: return (.leftMouseDown, .leftMouseUp)
}
}
func postMouseClick(point: CGPoint, button: CGMouseButton, clickCount: Int = 1) throws {
guard let source = CGEventSource(stateID: .combinedSessionState) else {
throw NSError(domain: "CloudCLISemantics", code: 500, userInfo: [NSLocalizedDescriptionKey: "Failed to create CGEventSource"])
}
let eventTypes = mouseEventTypes(button)
for _ in 0..<max(1, clickCount) {
let down = CGEvent(mouseEventSource: source, mouseType: eventTypes.0, mouseCursorPosition: point, mouseButton: button)
let up = CGEvent(mouseEventSource: source, mouseType: eventTypes.1, mouseCursorPosition: point, mouseButton: button)
down?.post(tap: .cghidEventTap)
up?.post(tap: .cghidEventTap)
usleep(80_000)
}
}
func postDrag(from: CGPoint, to: CGPoint, button: CGMouseButton) throws {
guard let source = CGEventSource(stateID: .combinedSessionState) else {
throw NSError(domain: "CloudCLISemantics", code: 500, userInfo: [NSLocalizedDescriptionKey: "Failed to create CGEventSource"])
}
let eventTypes = mouseEventTypes(button)
CGEvent(mouseEventSource: source, mouseType: eventTypes.0, mouseCursorPosition: from, mouseButton: button)?.post(tap: .cghidEventTap)
usleep(80_000)
CGEvent(mouseEventSource: source, mouseType: .leftMouseDragged, mouseCursorPosition: to, mouseButton: button)?.post(tap: .cghidEventTap)
usleep(80_000)
CGEvent(mouseEventSource: source, mouseType: eventTypes.1, mouseCursorPosition: to, mouseButton: button)?.post(tap: .cghidEventTap)
}
func runAppleScript(_ script: String) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
process.arguments = ["-e", script]
process.standardOutput = Pipe()
let stderr = Pipe()
process.standardError = stderr
try process.run()
process.waitUntilExit()
if process.terminationStatus != 0 {
let data = stderr.fileHandleForReading.readDataToEndOfFile()
let message = String(data: data, encoding: .utf8) ?? "AppleScript failed."
throw NSError(domain: "CloudCLISemantics", code: Int(process.terminationStatus), userInfo: [NSLocalizedDescriptionKey: message])
}
}
func escapedAppleScriptString(_ value: String) -> String {
value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
}
func pointForElement(_ params: JSON) -> CGPoint? {
if let x = params["x"] as? Double, let y = params["y"] as? Double {
return CGPoint(x: x, y: y)
}
guard let stateId = params["stateId"] as? String,
let elementIndex = params["element_index"] as? String,
let element = stateElements[stateId]?.first(where: { $0.index == elementIndex }),
let b = element.bounds,
let x = b["x"], let y = b["y"], let width = b["width"], let height = b["height"]
else {
return nil
}
return CGPoint(x: x + width / 2, y: y + height / 2)
}
func click(_ params: JSON) throws -> JSON {
if let element = cachedElement(params),
cgMouseButton(params["mouse_button"]) == .left,
(params["click_count"] as? Int ?? 1) == 1,
actions(element).contains(kAXPressAction as String),
AXUIElementPerformAction(element, kAXPressAction as CFString) == .success {
return try getAppState(params)
}
guard let point = pointForElement(params) else {
throw NSError(domain: "CloudCLISemantics", code: 400, userInfo: [NSLocalizedDescriptionKey: "click_element requires x/y or stateId + element_index"])
}
let clickCount = params["click_count"] as? Int ?? 1
try postMouseClick(point: point, button: cgMouseButton(params["mouse_button"]), clickCount: clickCount)
return try getAppState(params)
}
func performSecondaryAction(_ params: JSON) throws -> JSON {
if let element = cachedElement(params),
actions(element).contains(kAXShowMenuAction as String),
AXUIElementPerformAction(element, kAXShowMenuAction as CFString) == .success {
return try getAppState(params)
}
guard let point = pointForElement(params) else {
throw NSError(domain: "CloudCLISemantics", code: 400, userInfo: [NSLocalizedDescriptionKey: "perform_secondary_action requires x/y or stateId + element_index"])
}
try postMouseClick(point: point, button: .right)
return try getAppState(params)
}
func setValue(_ params: JSON) throws -> JSON {
guard let value = params["value"] as? String else {
throw NSError(domain: "CloudCLISemantics", code: 400, userInfo: [NSLocalizedDescriptionKey: "set_value requires value"])
}
if let element = cachedElement(params),
AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, value as CFTypeRef) == .success {
return try getAppState(params)
}
guard let point = pointForElement(params) else {
throw NSError(domain: "CloudCLISemantics", code: 400, userInfo: [NSLocalizedDescriptionKey: "set_value requires x/y or stateId + element_index"])
}
try postMouseClick(point: point, button: .left)
try runAppleScript("tell application \"System Events\" to keystroke \"a\" using command down")
try runAppleScript("tell application \"System Events\" to keystroke \"\(escapedAppleScriptString(value))\"")
return try getAppState(params)
}
func typeText(_ params: JSON) throws -> JSON {
let text = params["text"] as? String ?? ""
try runAppleScript("tell application \"System Events\" to keystroke \"\(escapedAppleScriptString(text))\"")
return try getAppState(params)
}
func appleScriptModifiers(_ parts: [String]) -> String {
let modifiers = parts.compactMap { part -> String? in
switch part.lowercased() {
case "cmd", "command", "meta": return "command down"
case "ctrl", "control": return "control down"
case "alt", "option": return "option down"
case "shift": return "shift down"
default: return nil
}
}
return modifiers.isEmpty ? "" : " using {\(modifiers.joined(separator: ", "))}"
}
func appleScriptKeyCode(_ key: String) -> Int? {
switch key.lowercased() {
case "return", "enter": return 36
case "tab": return 48
case "space": return 49
case "delete", "backspace": return 51
case "escape", "esc": return 53
case "left": return 123
case "right": return 124
case "down": return 125
case "up": return 126
default: return nil
}
}
func pressKey(_ params: JSON) throws -> JSON {
let raw = params["key"] as? String ?? ""
let parts = raw.split(separator: "+").map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
let key = parts.last ?? raw
let modifiers = appleScriptModifiers(Array(parts.dropLast()))
if let keyCode = appleScriptKeyCode(key) {
try runAppleScript("tell application \"System Events\" to key code \(keyCode)\(modifiers)")
} else {
try runAppleScript("tell application \"System Events\" to keystroke \"\(escapedAppleScriptString(key))\"\(modifiers)")
}
return try getAppState(params)
}
func scrollElement(_ params: JSON) throws -> JSON {
guard let point = pointForElement(params) else {
throw NSError(domain: "CloudCLISemantics", code: 400, userInfo: [NSLocalizedDescriptionKey: "scroll_element requires x/y or stateId + element_index"])
}
CGWarpMouseCursorPosition(point)
let direction = params["direction"] as? String ?? "down"
let pages = params["pages"] as? Double ?? 1.0
let amount = Int32(max(1.0, abs(pages) * 8.0))
let vertical = direction == "up" ? amount : direction == "down" ? -amount : 0
let horizontal = direction == "left" ? amount : direction == "right" ? -amount : 0
CGEvent(scrollWheelEvent2Source: nil, units: .line, wheelCount: 2, wheel1: vertical, wheel2: horizontal, wheel3: 0)?.post(tap: .cghidEventTap)
return try getAppState(params)
}
func drag(_ params: JSON) throws -> JSON {
guard let fromX = params["from_x"] as? Double,
let fromY = params["from_y"] as? Double,
let toX = params["to_x"] as? Double,
let toY = params["to_y"] as? Double
else {
throw NSError(domain: "CloudCLISemantics", code: 400, userInfo: [NSLocalizedDescriptionKey: "drag requires from_x/from_y/to_x/to_y"])
}
try postDrag(from: CGPoint(x: fromX, y: fromY), to: CGPoint(x: toX, y: toY), button: cgMouseButton(params["mouse_button"]))
return try getAppState(params)
}
func handle(_ request: JSON) {
let id = request["id"]
let method = request["method"] as? String ?? ""
let params = request["params"] as? JSON ?? [:]
do {
switch method {
case "list_apps":
respond(id: id, result: listApps())
case "get_app_state":
respond(id: id, result: try getAppState(params))
case "click_element":
respond(id: id, result: try click(params))
case "perform_secondary_action":
respond(id: id, result: try performSecondaryAction(params))
case "set_value":
respond(id: id, result: try setValue(params))
case "type_text":
respond(id: id, result: try typeText(params))
case "press_key":
respond(id: id, result: try pressKey(params))
case "scroll_element":
respond(id: id, result: try scrollElement(params))
case "drag":
respond(id: id, result: try drag(params))
default:
respondError(id: id, "Method is not implemented yet: \(method)")
}
} catch {
respondError(id: id, error.localizedDescription)
}
}
while let line = readLine() {
guard let data = line.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data),
let request = object as? JSON
else {
respondError(id: nil, "Invalid JSON request")
continue
}
handle(request)
}

View File

@@ -0,0 +1,124 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import readline from 'node:readline';
type JsonRecord = Record<string, unknown>;
type PendingRequest = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
const DEFAULT_TIMEOUT_MS = Number.parseInt(process.env.CLOUDCLI_SEMANTICS_HELPER_TIMEOUT_MS || '60000', 10);
function timeoutMs(): number {
return Number.isFinite(DEFAULT_TIMEOUT_MS) && DEFAULT_TIMEOUT_MS > 0 ? DEFAULT_TIMEOUT_MS : 60000;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export class SemanticHelperProcess {
private child: ChildProcessWithoutNullStreams | null = null;
private reader: readline.Interface | null = null;
private nextId = 1;
private pending = new Map<number, PendingRequest>();
constructor(private readonly executablePath: string) {}
async request(method: string, params: JsonRecord): Promise<unknown> {
this.ensureStarted();
const child = this.child;
if (!child?.stdin.writable) {
throw new Error('Semantic helper process is not running.');
}
const id = this.nextId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Semantic helper request timed out: ${method}`));
}, timeoutMs());
this.pending.set(id, { resolve, reject, timer });
child.stdin.write(`${JSON.stringify({ id, method, params })}\n`);
});
}
stop(): void {
const child = this.child;
this.child = null;
this.reader?.close();
this.reader = null;
this.rejectAll('Semantic helper stopped.');
if (child) {
try { child.kill('SIGTERM'); } catch { /* noop */ }
}
}
private ensureStarted(): void {
if (this.child) {
return;
}
this.child = spawn(this.executablePath, [], {
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
this.reader = readline.createInterface({ input: this.child.stdout });
this.reader.on('line', (line) => this.handleLine(line));
this.child.stderr.on('data', (chunk) => {
const text = String(chunk).trim();
if (text) {
console.error('[SemanticHelper]', text);
}
});
this.child.once('error', (error) => {
this.child = null;
this.rejectAll(`Failed to start semantic helper: ${error.message}`);
});
this.child.once('exit', (code) => {
this.child = null;
this.rejectAll(`Semantic helper exited with code ${code ?? 'null'}.`);
});
}
private handleLine(line: string): void {
let message: JsonRecord;
try {
message = JSON.parse(line) as JsonRecord;
} catch (error) {
console.error('[SemanticHelper] Invalid JSON response:', errorMessage(error));
return;
}
const id = typeof message.id === 'number' ? message.id : null;
if (id === null) {
return;
}
const pending = this.pending.get(id);
if (!pending) {
return;
}
clearTimeout(pending.timer);
this.pending.delete(id);
if (message.error) {
pending.reject(new Error(typeof message.error === 'string' ? message.error : 'Semantic helper request failed.'));
return;
}
pending.resolve(message.result);
}
private rejectAll(reason: string): void {
for (const [id, request] of this.pending.entries()) {
clearTimeout(request.timer);
request.reject(new Error(reason));
this.pending.delete(id);
}
}
}

View File

@@ -0,0 +1,97 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export type SemanticHelperPlatform = 'darwin' | 'win32';
export type SemanticHelperResolution = {
available: boolean;
path: string | null;
source: 'bundled' | 'dev' | 'missing';
platform: NodeJS.Platform;
arch: NodeJS.Architecture;
reason?: string;
};
function helperExecutableName(platform: NodeJS.Platform): string | null {
if (platform === 'darwin') {
return 'CloudCLISemantics';
}
if (platform === 'win32') {
return 'CloudCLISemantics.exe';
}
return null;
}
function pathExists(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.X_OK);
return true;
} catch {
try {
fs.accessSync(filePath, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
}
function candidatePaths(platform: NodeJS.Platform, arch: NodeJS.Architecture): Array<{ source: 'bundled' | 'dev'; path: string }> {
const executable = helperExecutableName(platform);
if (!executable) {
return [];
}
const platformArch = `${platform}-${arch}`;
return [
{
source: 'bundled',
path: path.resolve(__dirname, '..', 'bin', platformArch, executable),
},
{
source: 'dev',
path: path.resolve(process.cwd(), 'server', 'modules', 'computer-use', 'semantics', 'bin', platformArch, executable),
},
];
}
export function resolveSemanticHelper(
platform: NodeJS.Platform = process.platform,
arch: NodeJS.Architecture = process.arch,
): SemanticHelperResolution {
const executable = helperExecutableName(platform);
if (!executable) {
return {
available: false,
path: null,
source: 'missing',
platform,
arch,
reason: `Semantic Computer Use helper is not supported on ${platform}.`,
};
}
for (const candidate of candidatePaths(platform, arch)) {
if (pathExists(candidate.path)) {
return {
available: true,
path: candidate.path,
source: candidate.source,
platform,
arch,
};
}
}
return {
available: false,
path: null,
source: 'missing',
platform,
arch,
reason: `Bundled semantic helper was not found for ${platform}-${arch} (${executable}).`,
};
}

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWindowsForms>true</UseWindowsForms>
<UseWPF>true</UseWPF>
<AssemblyName>CloudCLISemantics</AssemblyName>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,534 @@
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Windows.Automation;
static class Program
{
private const int MaxStoredStates = 100;
private static readonly Dictionary<string, List<ElementRecord>> StateElements = new();
private static readonly Dictionary<string, Dictionary<string, AutomationElement>> StateAutomationElements = new();
private static readonly Queue<string> StateOrder = new();
public static void Main()
{
string? line;
while ((line = Console.ReadLine()) != null)
{
try
{
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
var id = root.TryGetProperty("id", out var idValue) ? idValue.Clone() : default;
var method = root.TryGetProperty("method", out var methodValue) ? methodValue.GetString() ?? "" : "";
var parameters = root.TryGetProperty("params", out var paramsValue) && paramsValue.ValueKind == JsonValueKind.Object
? paramsValue.Clone()
: JsonDocument.Parse("{}").RootElement.Clone();
try
{
object result = method switch
{
"list_apps" => ListApps(),
"get_app_state" => GetAppState(parameters),
"click_element" => ClickElement(parameters),
"perform_secondary_action" => PerformSecondaryAction(parameters),
"set_value" => SetValue(parameters),
"type_text" => TypeText(parameters),
"press_key" => PressKey(parameters),
"scroll_element" => ScrollElement(parameters),
"drag" => Drag(parameters),
_ => throw new InvalidOperationException($"Method is not implemented yet: {method}")
};
Write(new Dictionary<string, object?> { ["id"] = JsonValue(id), ["result"] = result });
}
catch (Exception ex)
{
Write(new Dictionary<string, object?> { ["id"] = JsonValue(id), ["error"] = ex.Message });
}
}
catch (Exception ex)
{
Write(new Dictionary<string, object?> { ["id"] = null, ["error"] = $"Invalid JSON request: {ex.Message}" });
}
}
}
private static object? JsonValue(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.Number => element.TryGetInt64(out var number) ? number : element.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => null
};
}
private static void Write(object value)
{
Console.WriteLine(JsonSerializer.Serialize(value));
Console.Out.Flush();
}
private static List<Dictionary<string, object?>> ListApps()
{
return Process.GetProcesses()
.Where(process => process.MainWindowHandle != IntPtr.Zero)
.OrderBy(process => process.ProcessName)
.Select(process => new Dictionary<string, object?>
{
["id"] = process.Id.ToString(),
["name"] = process.ProcessName,
["processName"] = process.ProcessName,
["pid"] = process.Id,
["running"] = true,
["windowTitle"] = process.MainWindowTitle
})
.ToList();
}
private static Process ResolveProcess(string query)
{
var normalized = query.Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
throw new InvalidOperationException("app is required.");
}
var processes = Process.GetProcesses()
.Where(process => process.MainWindowHandle != IntPtr.Zero)
.ToList();
return processes.FirstOrDefault(process => process.ProcessName.Equals(normalized, StringComparison.OrdinalIgnoreCase))
?? processes.FirstOrDefault(process => process.MainWindowTitle.Equals(normalized, StringComparison.OrdinalIgnoreCase))
?? processes.FirstOrDefault(process => process.MainWindowTitle.Contains(normalized, StringComparison.OrdinalIgnoreCase))
?? throw new InvalidOperationException($"App is not running: {query}");
}
private static Dictionary<string, object?> GetAppState(JsonElement parameters)
{
var appQuery = ReadString(parameters, "app");
var process = ResolveProcess(appQuery);
var root = AutomationElement.FromHandle(process.MainWindowHandle)
?? throw new InvalidOperationException("No UI Automation root window is available.");
var records = new List<ElementRecord>();
var automationElements = new Dictionary<string, AutomationElement>();
Walk(root, records, automationElements, 0, 5, 300);
var stateId = $"state_{Guid.NewGuid()}";
StateElements[stateId] = records;
StateAutomationElements[stateId] = automationElements;
StateOrder.Enqueue(stateId);
PruneStoredStates();
var elements = records.Select(record => record.ToDictionary()).ToList();
var bounds = root.Current.BoundingRectangle;
return new Dictionary<string, object?>
{
["stateId"] = stateId,
["app"] = process.ProcessName,
["platform"] = "win32",
["screenshotDataUrl"] = CaptureScreen(),
["displaySize"] = new Dictionary<string, object?>
{
["width"] = (int)System.Windows.Forms.Screen.PrimaryScreen!.Bounds.Width,
["height"] = (int)System.Windows.Forms.Screen.PrimaryScreen!.Bounds.Height
},
["window"] = new Dictionary<string, object?>
{
["title"] = process.MainWindowTitle,
["bounds"] = BoundsDictionary(bounds)
},
["elements"] = elements,
["accessibilityTree"] = elements,
["treeText"] = string.Join("\n", elements.Select(element => $"{element["index"]} {element["role"]} {element.GetValueOrDefault("title")}"))
};
}
private static Dictionary<string, object?> ClickElement(JsonElement parameters)
{
var mouseButton = ReadString(parameters, "mouse_button");
if ((mouseButton == "" || mouseButton == "left") && ReadInt(parameters, "click_count", 1) == 1)
{
var element = AutomationElementFor(parameters);
if (element != null && TryInvoke(element))
{
return GetAppState(parameters);
}
}
var point = PointFor(parameters);
if (point == null)
{
throw new InvalidOperationException("click_element requires x/y or stateId + element_index.");
}
SendMouseClick(point.Value.X, point.Value.Y, ReadString(parameters, "mouse_button"), ReadInt(parameters, "click_count", 1));
return GetAppState(parameters);
}
private static Dictionary<string, object?> PerformSecondaryAction(JsonElement parameters)
{
var point = PointFor(parameters);
if (point == null)
{
throw new InvalidOperationException("perform_secondary_action requires x/y or stateId + element_index.");
}
SendMouseClick(point.Value.X, point.Value.Y, "right", 1);
return GetAppState(parameters);
}
private static Dictionary<string, object?> SetValue(JsonElement parameters)
{
var value = ReadString(parameters, "value");
var element = AutomationElementFor(parameters);
var focused = false;
if (element != null)
{
if (element.TryGetCurrentPattern(ValuePattern.Pattern, out var valuePattern))
{
((ValuePattern)valuePattern).SetValue(value);
return GetAppState(parameters);
}
try
{
element.SetFocus();
focused = true;
}
catch
{
// Fall through to coordinate focus below.
}
}
var point = PointFor(parameters);
if (point != null)
{
SendMouseClick(point.Value.X, point.Value.Y, "left", 1);
focused = true;
}
else if (!focused && element == null)
{
throw new InvalidOperationException("set_value requires x/y or stateId + element_index.");
}
else if (!focused)
{
throw new InvalidOperationException("set_value could not focus the requested element.");
}
System.Windows.Forms.SendKeys.SendWait("^a");
System.Windows.Forms.SendKeys.SendWait(EscapeSendKeys(value));
return GetAppState(parameters);
}
private static Dictionary<string, object?> TypeText(JsonElement parameters)
{
var text = ReadString(parameters, "text");
System.Windows.Forms.SendKeys.SendWait(EscapeSendKeys(text));
return GetAppState(parameters);
}
private static Dictionary<string, object?> PressKey(JsonElement parameters)
{
var key = ReadString(parameters, "key");
System.Windows.Forms.SendKeys.SendWait(ToSendKeysChord(key));
return GetAppState(parameters);
}
private static Dictionary<string, object?> ScrollElement(JsonElement parameters)
{
var element = AutomationElementFor(parameters);
var direction = ReadString(parameters, "direction");
var pages = ReadDouble(parameters, "pages", 1);
if (element != null && element.TryGetCurrentPattern(ScrollPattern.Pattern, out var scrollPatternValue))
{
var scrollPattern = (ScrollPattern)scrollPatternValue;
var vertical = direction == "up" ? ScrollAmount.LargeDecrement : direction == "down" ? ScrollAmount.LargeIncrement : ScrollAmount.NoAmount;
var horizontal = direction == "left" ? ScrollAmount.LargeDecrement : direction == "right" ? ScrollAmount.LargeIncrement : ScrollAmount.NoAmount;
scrollPattern.Scroll(horizontal, vertical);
return GetAppState(parameters);
}
var point = PointFor(parameters);
if (point == null)
{
throw new InvalidOperationException("scroll_element requires x/y or stateId + element_index.");
}
SetCursorPos(point.Value.X, point.Value.Y);
var wheel = (int)Math.Round(Math.Max(1, pages) * 120);
if (direction == "down") wheel = -wheel;
mouse_event(0x0800, 0, 0, unchecked((uint)wheel), UIntPtr.Zero);
return GetAppState(parameters);
}
private static void PruneStoredStates()
{
while (StateOrder.Count > MaxStoredStates)
{
var evicted = StateOrder.Dequeue();
StateElements.Remove(evicted);
StateAutomationElements.Remove(evicted);
}
}
private static Dictionary<string, object?> Drag(JsonElement parameters)
{
var fromX = ReadDouble(parameters, "from_x", double.NaN);
var fromY = ReadDouble(parameters, "from_y", double.NaN);
var toX = ReadDouble(parameters, "to_x", double.NaN);
var toY = ReadDouble(parameters, "to_y", double.NaN);
if (double.IsNaN(fromX) || double.IsNaN(fromY) || double.IsNaN(toX) || double.IsNaN(toY))
{
throw new InvalidOperationException("drag requires from_x/from_y/to_x/to_y.");
}
SetCursorPos((int)Math.Round(fromX), (int)Math.Round(fromY));
mouse_event(0x0002, 0, 0, 0, UIntPtr.Zero);
Thread.Sleep(80);
SetCursorPos((int)Math.Round(toX), (int)Math.Round(toY));
Thread.Sleep(80);
mouse_event(0x0004, 0, 0, 0, UIntPtr.Zero);
return GetAppState(parameters);
}
private static void Walk(AutomationElement element, List<ElementRecord> records, Dictionary<string, AutomationElement> automationElements, int depth, int maxDepth, int limit)
{
if (depth > maxDepth || records.Count >= limit) return;
var index = (records.Count + 1).ToString();
records.Add(ElementRecord.From(element, index));
automationElements[index] = element;
var children = element.FindAll(TreeScope.Children, Condition.TrueCondition);
foreach (AutomationElement child in children)
{
Walk(child, records, automationElements, depth + 1, maxDepth, limit);
if (records.Count >= limit) return;
}
}
private static string ReadString(JsonElement element, string property)
{
return element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String
? value.GetString() ?? ""
: "";
}
private static int ReadInt(JsonElement element, string property, int defaultValue)
{
return element.TryGetProperty(property, out var value) && value.TryGetInt32(out var number)
? number
: defaultValue;
}
private static double ReadDouble(JsonElement element, string property, double defaultValue)
{
return element.TryGetProperty(property, out var value) && value.TryGetDouble(out var number)
? number
: defaultValue;
}
private static AutomationElement? AutomationElementFor(JsonElement parameters)
{
var stateId = ReadString(parameters, "stateId");
var elementIndex = ReadString(parameters, "element_index");
return !string.IsNullOrWhiteSpace(stateId)
&& !string.IsNullOrWhiteSpace(elementIndex)
&& StateAutomationElements.TryGetValue(stateId, out var elements)
&& elements.TryGetValue(elementIndex, out var element)
? element
: null;
}
private static System.Drawing.Point? PointFor(JsonElement parameters)
{
if (parameters.TryGetProperty("x", out var xValue) && parameters.TryGetProperty("y", out var yValue)
&& xValue.TryGetDouble(out var x) && yValue.TryGetDouble(out var y))
{
return new System.Drawing.Point((int)Math.Round(x), (int)Math.Round(y));
}
var stateId = ReadString(parameters, "stateId");
var elementIndex = ReadString(parameters, "element_index");
if (string.IsNullOrWhiteSpace(stateId) || string.IsNullOrWhiteSpace(elementIndex)) return null;
if (!StateElements.TryGetValue(stateId, out var elements)) return null;
var element = elements.FirstOrDefault(item => item.Index == elementIndex);
if (element?.Bounds == null) return null;
return new System.Drawing.Point(
(int)Math.Round(element.Bounds.Value.Left + element.Bounds.Value.Width / 2),
(int)Math.Round(element.Bounds.Value.Top + element.Bounds.Value.Height / 2)
);
}
private static string CaptureScreen()
{
var bounds = System.Windows.Forms.Screen.PrimaryScreen!.Bounds;
using var bitmap = new Bitmap(bounds.Width, bounds.Height);
using var graphics = Graphics.FromImage(bitmap);
graphics.CopyFromScreen(bounds.Left, bounds.Top, 0, 0, bounds.Size);
using var stream = new MemoryStream();
bitmap.Save(stream, ImageFormat.Png);
return $"data:image/png;base64,{Convert.ToBase64String(stream.ToArray())}";
}
private static Dictionary<string, object?> BoundsDictionary(System.Windows.Rect rect)
{
return new Dictionary<string, object?>
{
["x"] = rect.X,
["y"] = rect.Y,
["width"] = rect.Width,
["height"] = rect.Height
};
}
[DllImport("user32.dll")]
private static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")]
private static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo);
private static void SendMouseClick(int x, int y, string button, int clickCount)
{
var (down, up) = button switch
{
"right" => (0x0008u, 0x0010u),
"middle" => (0x0020u, 0x0040u),
_ => (0x0002u, 0x0004u)
};
SetCursorPos(x, y);
for (var i = 0; i < Math.Max(1, clickCount); i++)
{
mouse_event(down, 0, 0, 0, UIntPtr.Zero);
mouse_event(up, 0, 0, 0, UIntPtr.Zero);
Thread.Sleep(80);
}
}
private static bool TryInvoke(AutomationElement element)
{
try
{
if (!element.TryGetCurrentPattern(InvokePattern.Pattern, out var pattern)) return false;
((InvokePattern)pattern).Invoke();
return true;
}
catch
{
return false;
}
}
private static string EscapeSendKeys(string value)
{
return value
.Replace("{", "{{}")
.Replace("}", "{}}")
.Replace("+", "{+}")
.Replace("^", "{^}")
.Replace("%", "{%}")
.Replace("~", "{~}")
.Replace("(", "{(}")
.Replace(")", "{)}")
.Replace("[", "{[}")
.Replace("]", "{]}");
}
private static string ToSendKeysChord(string key)
{
var normalized = key.Trim();
if (normalized.Contains('+'))
{
var parts = normalized.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var modifiers = "";
var last = parts.LastOrDefault() ?? "";
foreach (var part in parts.Take(parts.Length - 1))
{
modifiers += part.ToLowerInvariant() switch
{
"ctrl" or "control" => "^",
"alt" => "%",
"shift" => "+",
"cmd" or "win" or "windows" => "^",
_ => ""
};
}
return modifiers + SendKeyName(last);
}
return SendKeyName(normalized);
}
private static string SendKeyName(string key)
{
return key.ToLowerInvariant() switch
{
"return" or "enter" => "{ENTER}",
"escape" or "esc" => "{ESC}",
"tab" => "{TAB}",
"backspace" => "{BACKSPACE}",
"delete" or "del" => "{DELETE}",
"left" => "{LEFT}",
"right" => "{RIGHT}",
"up" => "{UP}",
"down" => "{DOWN}",
"space" => " ",
_ => key.Length == 1 ? EscapeSendKeys(key) : $"{{{key.ToUpperInvariant()}}}"
};
}
private sealed record ElementRecord(
string Index,
string Role,
string? Title,
string? Value,
System.Windows.Rect? Bounds,
List<string> Actions)
{
public static ElementRecord From(AutomationElement element, string index)
{
var patterns = element.GetSupportedPatterns().Select(pattern => pattern.ProgrammaticName).ToList();
return new ElementRecord(
index,
element.Current.ControlType.ProgrammaticName.Replace("ControlType.", ""),
element.Current.Name,
TryValue(element),
element.Current.BoundingRectangle,
patterns
);
}
public Dictionary<string, object?> ToDictionary()
{
var output = new Dictionary<string, object?>
{
["index"] = Index,
["role"] = Role,
["actions"] = Actions
};
if (!string.IsNullOrEmpty(Title)) output["title"] = Title;
if (!string.IsNullOrEmpty(Value)) output["value"] = Value;
if (Bounds != null) output["bounds"] = BoundsDictionary(Bounds.Value);
return output;
}
private static string? TryValue(AutomationElement element)
{
try
{
if (element.TryGetCurrentPattern(ValuePattern.Pattern, out var pattern))
{
return ((ValuePattern)pattern).Current.Value;
}
}
catch
{
return null;
}
return null;
}
}
}

View File

@@ -0,0 +1,87 @@
import { randomUUID } from 'node:crypto';
import type { SemanticAppState, SemanticElement } from '@/modules/computer-use/semantics/semantic-types.js';
const DEFAULT_STATE_TTL_MS = Number.parseInt(process.env.CLOUDCLI_COMPUTER_SEMANTIC_STATE_TTL_MS || String(10 * 60 * 1000), 10);
type StoredState = {
sessionId: string;
appKey: string;
state: SemanticAppState;
updatedAt: number;
};
function normalizeAppKey(app: string): string {
return app.trim().toLowerCase();
}
export class SemanticSessionStore {
private states = new Map<string, StoredState>();
private latestBySessionApp = new Map<string, string>();
createStateId(): string {
return `state_${randomUUID()}`;
}
save(sessionId: string, state: SemanticAppState): SemanticAppState {
const appKey = normalizeAppKey(state.app);
const nextState = {
...state,
stateId: state.stateId || this.createStateId(),
};
this.states.set(nextState.stateId, {
sessionId,
appKey,
state: nextState,
updatedAt: Date.now(),
});
this.latestBySessionApp.set(this.latestKey(sessionId, appKey), nextState.stateId);
return nextState;
}
getState(sessionId: string, app: string, stateId?: string): SemanticAppState | null {
this.expire();
if (stateId) {
const entry = this.states.get(stateId);
const appKey = normalizeAppKey(app);
return entry && entry.sessionId === sessionId && entry.appKey === appKey ? entry.state : null;
}
const latestStateId = this.latestBySessionApp.get(this.latestKey(sessionId, normalizeAppKey(app)));
return latestStateId ? this.states.get(latestStateId)?.state || null : null;
}
getElement(sessionId: string, app: string, elementIndex: string, stateId?: string): SemanticElement | null {
const state = this.getState(sessionId, app, stateId);
return state?.elements.find((element) => element.index === elementIndex) || null;
}
clearSession(sessionId: string): void {
for (const [stateId, entry] of this.states.entries()) {
if (entry.sessionId === sessionId) {
this.states.delete(stateId);
this.latestBySessionApp.delete(this.latestKey(entry.sessionId, entry.appKey));
}
}
}
expire(now = Date.now()): void {
const ttl = Number.isFinite(DEFAULT_STATE_TTL_MS) && DEFAULT_STATE_TTL_MS > 0
? DEFAULT_STATE_TTL_MS
: 10 * 60 * 1000;
for (const [stateId, entry] of this.states.entries()) {
if (now - entry.updatedAt > ttl) {
this.states.delete(stateId);
const key = this.latestKey(entry.sessionId, entry.appKey);
if (this.latestBySessionApp.get(key) === stateId) {
this.latestBySessionApp.delete(key);
}
}
}
}
private latestKey(sessionId: string, appKey: string): string {
return `${sessionId}:${appKey}`;
}
}
export const semanticSessionStore = new SemanticSessionStore();

View File

@@ -0,0 +1,17 @@
export const semanticMcpToolMap: Record<string, string> = {
computer_app_drag: 'drag',
computer_click_element: 'click_element',
computer_get_app_state: 'get_app_state',
computer_list_apps: 'list_apps',
computer_perform_secondary_action: 'perform_secondary_action',
computer_press_key: 'press_key',
computer_scroll_element: 'scroll_element',
computer_set_value: 'set_value',
computer_type_text: 'type_text',
};
export const semanticOperationNames = new Set(Object.values(semanticMcpToolMap));
export function semanticOperationForMcpTool(toolName: string): string | null {
return semanticMcpToolMap[toolName] || null;
}

View File

@@ -0,0 +1,58 @@
import type { DisplaySize, Point } from '@/modules/computer-use/computer-executor.js';
export type SemanticBounds = {
x: number;
y: number;
width: number;
height: number;
};
export type SemanticApp = {
id?: string;
name: string;
bundleIdentifier?: string;
processName?: string;
pid?: number;
running: boolean;
windowTitle?: string;
};
export type SemanticElement = {
index: string;
role: string;
title?: string;
value?: string;
description?: string;
enabled?: boolean;
focused?: boolean;
selected?: boolean;
bounds?: SemanticBounds;
actions?: string[];
settableValue?: boolean;
};
export type SemanticAppState = {
stateId: string;
app: string;
platform: NodeJS.Platform;
screenshotDataUrl: string | null;
displaySize: DisplaySize | null;
elements: SemanticElement[];
accessibilityTree: SemanticElement[];
treeText?: string;
message?: string;
};
export type SemanticToolInput = Record<string, unknown> & {
sessionId?: string;
app?: string;
stateId?: string;
element_index?: string;
};
export type SemanticToolResult = SemanticAppState | {
apps: SemanticApp[];
platform: NodeJS.Platform;
};
export type SemanticActionPoint = Point;

View File

@@ -0,0 +1,143 @@
/**
* Database connection management.
*
* Owns the single SQLite connection used across all repositories.
* Handles path resolution, directory creation, legacy database migration,
* and eager app_config bootstrap so the auth middleware can read the
* JWT secret before the full schema is applied.
*
* Consumers should never create their own Database instance — they use
* `getConnection()` to obtain the shared singleton.
*/
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { APP_CONFIG_TABLE_SCHEMA_SQL } from '@/modules/database/schema.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ---------------------------------------------------------------------------
// Path resolution
// ---------------------------------------------------------------------------
/**
* Resolves the database file path from environment or falls back
* to the legacy location inside the server/database/ folder.
*
* Priority:
* 1. DATABASE_PATH environment variable (set by cli.js or load-env-vars.js)
* 2. Legacy path: server/database/auth.db
*/
function resolveDatabasePath(): string {
// process.env.DATABASE_PATH is set by load-env-vars.js to either the .env value or a default(~/.cloudcli/auth.db) in the user's home directory.
return process.env.DATABASE_PATH || resolveLegacyDatabasePath();
}
/**
* Resolves the legacy database path (always inside server/database/).
* Used for the one-time migration to the new external location.
*/
function resolveLegacyDatabasePath(): string {
const serverDir = path.resolve(__dirname, '..', '..', '..');
return path.join(serverDir, 'database', 'auth.db');
}
// ---------------------------------------------------------------------------
// Directory & migration helpers
// ---------------------------------------------------------------------------
function ensureDatabaseDirectory(dbPath: string): void {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
console.log('Created database directory:', dir);
}
}
/**
* If the database was moved to an external location (e.g. ~/.cloudcli/)
* but the user still has a legacy auth.db inside the install directory,
* copy it to the new location as a one-time migration.
*/
function migrateLegacyDatabase(targetPath: string): void {
const legacyPath = resolveLegacyDatabasePath();
if (targetPath === legacyPath) return;
if (fs.existsSync(targetPath)) return;
if (!fs.existsSync(legacyPath)) return;
try {
fs.copyFileSync(legacyPath, targetPath);
console.log('Migrated legacy database', { from: legacyPath, to: targetPath });
// copy the write-ahead log and shared memory files (auth.db-wal, auth.db-shm) if they exist, to preserve any uncommitted transactions
for (const suffix of ['-wal', '-shm']) {
const src = legacyPath + suffix;
if (fs.existsSync(src)) {
fs.copyFileSync(src, targetPath + suffix);
}
}
} catch (err: any) {
console.error('Could not migrate legacy database', { error: err.message });
}
}
// ---------------------------------------------------------------------------
// Singleton connection
// ---------------------------------------------------------------------------
let instance: Database.Database | null = null;
/**
* Returns the shared database connection, creating it on first call.
*
* The first invocation:
* 1. Resolves the target database path
* 2. Ensures the parent directory exists
* 3. Migrates from the legacy install-directory path if needed
* 4. Opens the SQLite connection
* 5. Eagerly creates the app_config table (auth reads JWT secret at import time)
* 6. Logs the database location
*/
export function getConnection(): Database.Database {
if (instance) return instance;
const dbPath = resolveDatabasePath();
ensureDatabaseDirectory(dbPath);
migrateLegacyDatabase(dbPath);
instance = new Database(dbPath);
// app_config must exist immediately — the auth middleware reads
// the JWT secret at module-load time, before initializeDatabase() runs.
instance.exec(APP_CONFIG_TABLE_SCHEMA_SQL);
return instance;
}
/**
* Returns the resolved database file path without opening a connection.
* Useful for diagnostics and CLI status commands.
*/
export function getDatabasePath(): string {
return resolveDatabasePath();
}
/**
* Closes the database connection and clears the singleton.
* Primarily used for graceful shutdown or testing.
*/
export function closeConnection(): void {
if (instance) {
instance.close();
instance = null;
console.log('Database connection closed');
}
}

View File

@@ -0,0 +1,14 @@
export { initializeDatabase } from '@/modules/database/init-db.js';
export { closeConnection, getConnection, getDatabasePath } from '@/modules/database/connection.js';
export { apiKeysDb } from '@/modules/database/repositories/api-keys.js';
export { appConfigDb } from '@/modules/database/repositories/app-config.js';
export { credentialsDb } from '@/modules/database/repositories/credentials.js';
export { githubTokensDb } from '@/modules/database/repositories/github-tokens.js';
export { notificationChannelEndpointsDb } from '@/modules/database/repositories/notification-channel-endpoints.js';
export { notificationPreferencesDb } from '@/modules/database/repositories/notification-preferences.js';
export { projectsDb } from '@/modules/database/repositories/projects.db.js';
export { pushSubscriptionsDb } from '@/modules/database/repositories/push-subscriptions.js';
export { scanStateDb } from '@/modules/database/repositories/scan-state.db.js';
export { sessionsDb } from '@/modules/database/repositories/sessions.db.js';
export { userDb } from '@/modules/database/repositories/users.js';
export { vapidKeysDb } from '@/modules/database/repositories/vapid-keys.js';

View File

@@ -0,0 +1,17 @@
import { getConnection } from "@/modules/database/connection.js";
import { runMigrations } from "@/modules/database/migrations.js";
import { INIT_SCHEMA_SQL } from "@/modules/database/schema.js";
// Initialize database with schema
export const initializeDatabase = async () => {
try {
const db = getConnection();
db.exec(INIT_SCHEMA_SQL);
console.log('Database schema applied');
runMigrations(db);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.log('Database initialization failed', { error: message });
throw err;
}
};

View File

@@ -0,0 +1,480 @@
import { Database } from 'better-sqlite3';
import {
APP_CONFIG_TABLE_SCHEMA_SQL,
LAST_SCANNED_AT_SQL,
NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL,
PROJECTS_TABLE_SCHEMA_SQL,
PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL,
SESSIONS_TABLE_SCHEMA_SQL,
USER_NOTIFICATION_PREFERENCES_TABLE_SCHEMA_SQL,
VAPID_KEYS_TABLE_SCHEMA_SQL,
} from '@/modules/database/schema.js';
const SQLITE_UUID_SQL = `
lower(hex(randomblob(4))) || '-' ||
lower(hex(randomblob(2))) || '-' ||
lower(hex(randomblob(2))) || '-' ||
lower(hex(randomblob(2))) || '-' ||
lower(hex(randomblob(6)))
`;
type TableInfoRow = {
name: string;
pk: number;
};
const addColumnToTableIfNotExists = (
db: Database,
tableName: string,
columnNames: string[],
columnName: string,
columnType: string
) => {
if (!columnNames.includes(columnName)) {
console.log(`Running migration: Adding ${columnName} column to ${tableName} table`);
db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnType}`);
}
};
const tableExists = (db: Database, tableName: string): boolean =>
Boolean(
db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(tableName)
);
const getTableInfo = (db: Database, tableName: string): TableInfoRow[] =>
db.prepare(`PRAGMA table_info(${tableName})`).all() as TableInfoRow[];
const migrateLegacySessionNames = (db: Database): void => {
const hasLegacySessionNamesTable = tableExists(db, 'session_names');
const hasSessionsTable = tableExists(db, 'sessions');
if (!hasLegacySessionNamesTable) {
return;
}
if (hasSessionsTable) {
console.log('Running migration: Merging session_names into sessions');
db.exec(`
INSERT INTO sessions (session_id, provider, custom_name, created_at, updated_at)
SELECT
session_id,
COALESCE(provider, 'claude'),
custom_name,
COALESCE(created_at, CURRENT_TIMESTAMP),
COALESCE(updated_at, CURRENT_TIMESTAMP)
FROM session_names
WHERE true
ON CONFLICT(session_id) DO UPDATE SET
provider = excluded.provider,
custom_name = COALESCE(excluded.custom_name, sessions.custom_name),
created_at = COALESCE(sessions.created_at, excluded.created_at),
updated_at = COALESCE(excluded.updated_at, sessions.updated_at)
`);
db.exec('DROP TABLE session_names');
return;
}
console.log('Running migration: Renaming session_names table to sessions');
db.exec('ALTER TABLE session_names RENAME TO sessions');
};
const migrateLegacyWorkspaceTableIntoProjects = (db: Database): void => {
db.exec(PROJECTS_TABLE_SCHEMA_SQL);
if (!tableExists(db, 'workspace_original_paths')) {
return;
}
console.log('Running migration: Migrating workspace_original_paths data into projects');
db.exec(`
INSERT INTO projects (project_id, project_path, custom_project_name, isStarred, isArchived)
SELECT
CASE
WHEN workspace_id IS NULL OR trim(workspace_id) = ''
THEN ${SQLITE_UUID_SQL}
ELSE workspace_id
END,
workspace_path,
custom_workspace_name,
COALESCE(isStarred, 0),
0
FROM workspace_original_paths
WHERE workspace_path IS NOT NULL AND trim(workspace_path) <> ''
ON CONFLICT(project_path) DO UPDATE SET
custom_project_name = COALESCE(projects.custom_project_name, excluded.custom_project_name),
isStarred = COALESCE(projects.isStarred, excluded.isStarred)
`);
};
const rebuildProjectsTableWithPrimaryKeySchema = (db: Database): void => {
const hasProjectsTable = tableExists(db, 'projects');
if (!hasProjectsTable) {
db.exec(PROJECTS_TABLE_SCHEMA_SQL);
return;
}
const projectsTableInfo = getTableInfo(db, 'projects');
const columnNames = projectsTableInfo.map((column) => column.name);
const hasProjectIdPrimaryKey = projectsTableInfo.some(
(column) => column.name === 'project_id' && column.pk === 1,
);
if (hasProjectIdPrimaryKey) {
addColumnToTableIfNotExists(db, 'projects', columnNames, 'custom_project_name', 'TEXT DEFAULT NULL');
addColumnToTableIfNotExists(db, 'projects', columnNames, 'isStarred', 'BOOLEAN DEFAULT 0');
addColumnToTableIfNotExists(db, 'projects', columnNames, 'isArchived', 'BOOLEAN DEFAULT 0');
db.exec(`
UPDATE projects
SET project_id = ${SQLITE_UUID_SQL}
WHERE project_id IS NULL OR trim(project_id) = ''
`);
return;
}
console.log('Running migration: Rebuilding projects table to enforce project_id primary key');
const projectPathExpression = columnNames.includes('project_path')
? 'project_path'
: columnNames.includes('workspace_path')
? 'workspace_path'
: 'NULL';
const customProjectNameExpression = columnNames.includes('custom_project_name')
? 'custom_project_name'
: columnNames.includes('custom_workspace_name')
? 'custom_workspace_name'
: 'NULL';
const isStarredExpression = columnNames.includes('isStarred') ? 'COALESCE(isStarred, 0)' : '0';
const isArchivedExpression = columnNames.includes('isArchived') ? 'COALESCE(isArchived, 0)' : '0';
const projectIdExpression = columnNames.includes('project_id')
? `CASE
WHEN project_id IS NULL OR trim(project_id) = ''
THEN ${SQLITE_UUID_SQL}
ELSE project_id
END`
: SQLITE_UUID_SQL;
db.exec('PRAGMA foreign_keys = OFF');
try {
db.exec('BEGIN TRANSACTION');
db.exec('DROP TABLE IF EXISTS projects__new');
db.exec(`
CREATE TABLE projects__new (
project_id TEXT PRIMARY KEY NOT NULL,
project_path TEXT NOT NULL UNIQUE,
custom_project_name TEXT DEFAULT NULL,
isStarred BOOLEAN DEFAULT 0,
isArchived BOOLEAN DEFAULT 0
)
`);
db.exec(`
WITH source_rows AS (
SELECT
${projectPathExpression} AS project_path,
${customProjectNameExpression} AS custom_project_name,
${isStarredExpression} AS isStarred,
${isArchivedExpression} AS isArchived,
${projectIdExpression} AS candidate_project_id,
rowid AS source_rowid
FROM projects
WHERE ${projectPathExpression} IS NOT NULL AND trim(${projectPathExpression}) <> ''
),
deduped_paths AS (
SELECT
project_path,
custom_project_name,
isStarred,
isArchived,
candidate_project_id,
source_rowid,
ROW_NUMBER() OVER (PARTITION BY project_path ORDER BY source_rowid) AS project_path_rank
FROM source_rows
),
prepared_rows AS (
SELECT
CASE
WHEN ROW_NUMBER() OVER (PARTITION BY candidate_project_id ORDER BY source_rowid) = 1
THEN candidate_project_id
ELSE ${SQLITE_UUID_SQL}
END AS project_id,
project_path,
custom_project_name,
isStarred,
isArchived
FROM deduped_paths
WHERE project_path_rank = 1
)
INSERT INTO projects__new (
project_id,
project_path,
custom_project_name,
isStarred,
isArchived
)
SELECT
project_id,
project_path,
custom_project_name,
isStarred,
isArchived
FROM prepared_rows
`);
db.exec('DROP TABLE projects');
db.exec('ALTER TABLE projects__new RENAME TO projects');
db.exec('COMMIT');
} catch (migrationError) {
db.exec('ROLLBACK');
throw migrationError;
} finally {
db.exec('PRAGMA foreign_keys = ON');
}
};
const rebuildSessionsTableWithProjectSchema = (db: Database): void => {
const hasSessions = tableExists(db, 'sessions');
if (!hasSessions) {
db.exec(SESSIONS_TABLE_SCHEMA_SQL);
return;
}
const sessionsTableInfo = getTableInfo(db, 'sessions');
const columnNames = sessionsTableInfo.map((column) => column.name);
const primaryKeyColumns = sessionsTableInfo
.filter((column) => column.pk > 0)
.sort((a, b) => a.pk - b.pk)
.map((column) => column.name);
const shouldRebuild =
!columnNames.includes('project_path') ||
primaryKeyColumns.length !== 1 ||
primaryKeyColumns[0] !== 'session_id' ||
!columnNames.includes('provider');
if (!shouldRebuild) {
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'jsonl_path', 'TEXT');
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'isArchived', 'BOOLEAN DEFAULT 0');
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'created_at', 'DATETIME');
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'updated_at', 'DATETIME');
db.exec('UPDATE sessions SET isArchived = COALESCE(isArchived, 0)');
db.exec('UPDATE sessions SET created_at = COALESCE(created_at, CURRENT_TIMESTAMP)');
db.exec('UPDATE sessions SET updated_at = COALESCE(updated_at, CURRENT_TIMESTAMP)');
return;
}
console.log('Running migration: Rebuilding sessions table to project-based schema');
const projectPathExpression = columnNames.includes('project_path')
? 'project_path'
: columnNames.includes('workspace_path')
? 'workspace_path'
: 'NULL';
const providerExpression = columnNames.includes('provider')
? "COALESCE(provider, 'claude')"
: "'claude'";
const customNameExpression = columnNames.includes('custom_name')
? 'custom_name'
: 'NULL';
const jsonlPathExpression = columnNames.includes('jsonl_path')
? 'jsonl_path'
: 'NULL';
const isArchivedExpression = columnNames.includes('isArchived')
? 'COALESCE(isArchived, 0)'
: '0';
const createdAtExpression = columnNames.includes('created_at')
? 'COALESCE(created_at, CURRENT_TIMESTAMP)'
: 'CURRENT_TIMESTAMP';
const updatedAtExpression = columnNames.includes('updated_at')
? 'COALESCE(updated_at, CURRENT_TIMESTAMP)'
: 'CURRENT_TIMESTAMP';
db.exec('PRAGMA foreign_keys = OFF');
try {
db.exec('BEGIN TRANSACTION');
db.exec('DROP TABLE IF EXISTS sessions__new');
db.exec(`
CREATE TABLE sessions__new (
session_id TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'claude',
custom_name TEXT,
project_path TEXT,
jsonl_path TEXT,
isArchived BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (session_id),
FOREIGN KEY (project_path) REFERENCES projects(project_path)
ON DELETE SET NULL
ON UPDATE CASCADE
)
`);
db.exec(`
WITH source_rows AS (
SELECT
session_id,
${providerExpression} AS provider,
${customNameExpression} AS custom_name,
${projectPathExpression} AS project_path,
${jsonlPathExpression} AS jsonl_path,
${isArchivedExpression} AS isArchived,
${createdAtExpression} AS created_at,
${updatedAtExpression} AS updated_at,
rowid AS source_rowid
FROM sessions
WHERE session_id IS NOT NULL AND trim(session_id) <> ''
),
ranked_rows AS (
SELECT
session_id,
provider,
custom_name,
project_path,
jsonl_path,
isArchived,
created_at,
updated_at,
ROW_NUMBER() OVER (
PARTITION BY session_id
ORDER BY datetime(COALESCE(updated_at, created_at)) DESC, source_rowid DESC
) AS session_rank
FROM source_rows
)
INSERT INTO sessions__new (
session_id,
provider,
custom_name,
project_path,
jsonl_path,
isArchived,
created_at,
updated_at
)
SELECT
session_id,
provider,
custom_name,
project_path,
jsonl_path,
isArchived,
created_at,
updated_at
FROM ranked_rows
WHERE session_rank = 1
`);
db.exec('DROP TABLE sessions');
db.exec('ALTER TABLE sessions__new RENAME TO sessions');
db.exec('COMMIT');
} catch (migrationError) {
db.exec('ROLLBACK');
throw migrationError;
} finally {
db.exec('PRAGMA foreign_keys = ON');
}
};
/**
* Adds the `provider_session_id` mapping column used by the session gateway.
*
* Rows that existed before this migration were always keyed directly by the
* provider-native session id, so backfilling `provider_session_id` with
* `session_id` keeps every legacy row resolvable through the new mapping.
*/
const addProviderSessionIdMapping = (db: Database): void => {
const sessionsTableInfo = getTableInfo(db, 'sessions');
const columnNames = sessionsTableInfo.map((column) => column.name);
addColumnToTableIfNotExists(db, 'sessions', columnNames, 'provider_session_id', 'TEXT');
db.exec(`
UPDATE sessions
SET provider_session_id = session_id
WHERE provider_session_id IS NULL
`);
};
const ensureProjectsForSessionPaths = (db: Database): void => {
if (!tableExists(db, 'sessions')) {
return;
}
db.exec(`
INSERT INTO projects (project_id, project_path, custom_project_name, isStarred, isArchived)
SELECT
${SQLITE_UUID_SQL},
project_path,
NULL,
0,
0
FROM sessions
WHERE project_path IS NOT NULL AND trim(project_path) <> ''
ON CONFLICT(project_path) DO NOTHING
`);
};
export const runMigrations = (db: Database) => {
try {
const usersTableInfo = db.prepare('PRAGMA table_info(users)').all() as { name: string }[];
const userColumnNames = usersTableInfo.map((column) => column.name);
addColumnToTableIfNotExists(db, 'users', userColumnNames, 'git_name', 'TEXT');
addColumnToTableIfNotExists(db, 'users', userColumnNames, 'git_email', 'TEXT');
addColumnToTableIfNotExists(
db,
'users',
userColumnNames,
'has_completed_onboarding',
'BOOLEAN DEFAULT 0'
);
db.exec(APP_CONFIG_TABLE_SCHEMA_SQL);
db.exec(USER_NOTIFICATION_PREFERENCES_TABLE_SCHEMA_SQL);
db.exec(VAPID_KEYS_TABLE_SCHEMA_SQL);
db.exec(PUSH_SUBSCRIPTIONS_TABLE_SCHEMA_SQL);
db.exec('CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id)');
db.exec(NOTIFICATION_CHANNEL_ENDPOINTS_TABLE_SCHEMA_SQL);
db.exec('CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_user_channel ON notification_channel_endpoints(user_id, channel)');
db.exec('CREATE INDEX IF NOT EXISTS idx_notification_channel_endpoints_enabled ON notification_channel_endpoints(enabled)');
db.exec(PROJECTS_TABLE_SCHEMA_SQL);
rebuildProjectsTableWithPrimaryKeySchema(db);
migrateLegacyWorkspaceTableIntoProjects(db);
rebuildSessionsTableWithProjectSchema(db);
migrateLegacySessionNames(db);
addProviderSessionIdMapping(db);
ensureProjectsForSessionPaths(db);
db.exec('CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_provider_session_id ON sessions(provider_session_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path)');
db.exec('CREATE INDEX IF NOT EXISTS idx_sessions_is_archived ON sessions(isArchived)');
db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_starred ON projects(isStarred)');
db.exec('CREATE INDEX IF NOT EXISTS idx_projects_is_archived ON projects(isArchived)');
db.exec('DROP INDEX IF EXISTS idx_session_names_lookup');
db.exec('DROP INDEX IF EXISTS idx_sessions_workspace_path');
db.exec('DROP INDEX IF EXISTS idx_workspace_original_paths_is_starred');
db.exec('DROP INDEX IF EXISTS idx_workspace_original_paths_workspace_id');
if (tableExists(db, 'workspace_original_paths')) {
console.log('Running migration: Dropping legacy workspace_original_paths table');
db.exec('DROP TABLE workspace_original_paths');
}
db.exec(LAST_SCANNED_AT_SQL);
console.log('Database migrations completed successfully');
} catch (error: any) {
console.error('Error running migrations:', error.message);
throw error;
}
};

View File

@@ -0,0 +1,119 @@
/**
* API keys repository.
*
* Manages API keys used for external/programmatic access to the backend.
* Keys are prefixed with `ck_` and tied to a user via foreign key.
*/
import crypto from 'crypto';
import { getConnection } from '@/modules/database/connection.js';
type ApiKeyRow = {
id: number;
key_name: string;
api_key: string;
created_at: string;
last_used: string | null;
is_active: number;
};
type CreateApiKeyResult = {
id: number | bigint;
keyName: string;
apiKey: string;
};
type ValidatedApiKeyUser = {
id: number;
username: string;
api_key_id: number;
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Generates a cryptographically random API key with the `ck_` prefix. */
function generateApiKey(): string {
return 'ck_' + crypto.randomBytes(32).toString('hex');
}
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
export const apiKeysDb = {
generateApiKey,
/** Creates a new API key for the given user and returns it for one-time display. */
createApiKey(userId: number, keyName: string): CreateApiKeyResult {
const db = getConnection();
const apiKey = generateApiKey();
const result = db
.prepare(
'INSERT INTO api_keys (user_id, key_name, api_key) VALUES (?, ?, ?)'
)
.run(userId, keyName, apiKey);
return { id: result.lastInsertRowid, keyName, apiKey };
},
/** Lists all API keys for a user, most recent first. */
getApiKeys(userId: number): ApiKeyRow[] {
const db = getConnection();
return db
.prepare(
'SELECT id, key_name, api_key, created_at, last_used, is_active FROM api_keys WHERE user_id = ? ORDER BY created_at DESC'
)
.all(userId) as ApiKeyRow[];
},
/**
* Validates an API key and resolves the owning user.
* If the key is valid, its `last_used` timestamp is updated as a side effect.
* Returns undefined when the key is invalid or the user is inactive.
*/
validateApiKey(apiKey: string): ValidatedApiKeyUser | undefined {
const db = getConnection();
const row = db
.prepare(
`SELECT u.id, u.username, ak.id as api_key_id
FROM api_keys ak
JOIN users u ON ak.user_id = u.id
WHERE ak.api_key = ? AND ak.is_active = 1 AND u.is_active = 1`
)
.get(apiKey) as ValidatedApiKeyUser | undefined;
if (row) {
db.prepare(
'UPDATE api_keys SET last_used = CURRENT_TIMESTAMP WHERE id = ?'
).run(row.api_key_id);
}
return row;
},
/** Permanently removes an API key. Returns true if a row was deleted. */
deleteApiKey(userId: number, apiKeyId: number): boolean {
const db = getConnection();
const result = db
.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?')
.run(apiKeyId, userId);
return result.changes > 0;
},
/** Enables or disables an API key without deleting it. */
toggleApiKey(
userId: number,
apiKeyId: number,
isActive: boolean
): boolean {
const db = getConnection();
const result = db
.prepare(
'UPDATE api_keys SET is_active = ? WHERE id = ? AND user_id = ?'
)
.run(isActive ? 1 : 0, apiKeyId, userId);
return result.changes > 0;
},
};

View File

@@ -0,0 +1,53 @@
/**
* App config repository.
*
* Key-value store for application-level configuration that persists
* across restarts (JWT secret, feature flags, etc.). Values are always
* stored as strings; callers handle parsing.
*/
import crypto from 'crypto';
import { getConnection } from '@/modules/database/connection.js';
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
export const appConfigDb = {
/** Returns the stored value for a config key, or null if missing. */
get(key: string): string | null {
try {
const db = getConnection();
const row = db
.prepare('SELECT value FROM app_config WHERE key = ?')
.get(key) as { value: string } | undefined;
return row?.value ?? null;
} catch {
// Swallow errors so early-startup reads (e.g. JWT secret) do not crash.
return null;
}
},
/** Inserts or updates a config key (upsert). */
set(key: string, value: string): void {
const db = getConnection();
db.prepare(
'INSERT INTO app_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run(key, value);
},
/**
* Returns the JWT signing secret, generating and persisting one
* if it does not already exist. This ensures the secret survives
* server restarts while being created automatically on first boot.
*/
getOrCreateJwtSecret(): string {
let secret = appConfigDb.get('jwt_secret');
if (!secret) {
secret = crypto.randomBytes(64).toString('hex');
appConfigDb.set('jwt_secret', secret);
}
return secret;
},
};

View File

@@ -0,0 +1,106 @@
/**
* User credentials repository.
*
* Manages external service tokens (GitHub, GitLab, Bitbucket, etc.)
* stored per-user. Each credential has a type discriminator so multiple
* credential kinds can coexist in the same table.
*/
import { getConnection } from '@/modules/database/connection.js';
import type {
CreateCredentialResult,
CredentialPublicRow,
} from '@/shared/types.js';
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
export const credentialsDb = {
/** Stores a new credential and returns a safe (no raw value) result. */
createCredential(
userId: number,
credentialName: string,
credentialType: string,
credentialValue: string,
description: string | null = null
): CreateCredentialResult {
const db = getConnection();
const result = db
.prepare(
'INSERT INTO user_credentials (user_id, credential_name, credential_type, credential_value, description) VALUES (?, ?, ?, ?, ?)'
)
.run(userId, credentialName, credentialType, credentialValue, description);
return {
id: result.lastInsertRowid,
credentialName,
credentialType,
};
},
/**
* Lists credentials for a user (excluding raw values).
* Optionally filters by credential type (e.g. 'github_token').
*/
getCredentials(
userId: number,
credentialType: string | null = null
): CredentialPublicRow[] {
const db = getConnection();
if (credentialType) {
return db
.prepare(
'SELECT id, credential_name, credential_type, description, created_at, is_active FROM user_credentials WHERE user_id = ? AND credential_type = ? ORDER BY created_at DESC'
)
.all(userId, credentialType) as CredentialPublicRow[];
}
return db
.prepare(
'SELECT id, credential_name, credential_type, description, created_at, is_active FROM user_credentials WHERE user_id = ? ORDER BY created_at DESC'
)
.all(userId) as CredentialPublicRow[];
},
/**
* Returns the raw credential value for the most recent active
* credential of the given type, or null if none exists.
*/
getActiveCredential(
userId: number,
credentialType: string
): string | null {
const db = getConnection();
const row = db
.prepare(
'SELECT credential_value FROM user_credentials WHERE user_id = ? AND credential_type = ? AND is_active = 1 ORDER BY created_at DESC LIMIT 1'
)
.get(userId, credentialType) as { credential_value: string } | undefined;
return row?.credential_value ?? null;
},
/** Permanently removes a credential. Returns true if a row was deleted. */
deleteCredential(userId: number, credentialId: number): boolean {
const db = getConnection();
const result = db
.prepare('DELETE FROM user_credentials WHERE id = ? AND user_id = ?')
.run(credentialId, userId);
return result.changes > 0;
},
/** Enables or disables a credential without deleting it. */
toggleCredential(
userId: number,
credentialId: number,
isActive: boolean
): boolean {
const db = getConnection();
const result = db
.prepare(
'UPDATE user_credentials SET is_active = ? WHERE id = ? AND user_id = ?'
)
.run(isActive ? 1 : 0, credentialId, userId);
return result.changes > 0;
},
};

View File

@@ -0,0 +1,100 @@
/**
* GitHub tokens repository.
*
* Backward-compatible helper layer over generic credentials storage.
* Tokens are stored in `user_credentials` with `credential_type = 'github_token'`.
*/
import { getConnection } from '@/modules/database/connection.js';
import { credentialsDb } from '@/modules/database/repositories/credentials.js';
import type {
CredentialPublicRow,
CreateCredentialResult,
} from '@/shared/types.js';
const GITHUB_TOKEN_TYPE = 'github_token';
type CredentialRow = {
id: number;
user_id: number;
credential_name: string;
credential_type: string;
credential_value: string;
description: string | null;
created_at: string;
is_active: number;
};
type GithubTokenLookup = CredentialRow & {
github_token: string;
};
export const githubTokensDb = {
/** Creates a GitHub token credential entry. */
createGithubToken(
userId: number,
tokenName: string,
githubToken: string,
description: string | null = null
): CreateCredentialResult {
return credentialsDb.createCredential(
userId,
tokenName,
GITHUB_TOKEN_TYPE,
githubToken,
description
);
},
/** Returns all GitHub tokens (safe shape: no credential value). */
getGithubTokens(userId: number): CredentialPublicRow[] {
return credentialsDb.getCredentials(userId, GITHUB_TOKEN_TYPE);
},
/** Returns the most recent active GitHub token value for a user. */
getActiveGithubToken(userId: number): string | null {
return credentialsDb.getActiveCredential(userId, GITHUB_TOKEN_TYPE);
},
/**
* Returns a specific active GitHub token row by id/user, including
* a `github_token` compatibility field.
*/
getGithubTokenById(userId: number, tokenId: number): GithubTokenLookup | null {
const db = getConnection();
const row = db
.prepare(
`SELECT *
FROM user_credentials
WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1`
)
.get(tokenId, userId, GITHUB_TOKEN_TYPE) as CredentialRow | undefined;
if (!row) return null;
return {
...row,
github_token: row.credential_value,
};
},
/** Updates active state for a GitHub token. */
updateGithubToken(
userId: number,
tokenId: number,
isActive: boolean
): boolean {
return credentialsDb.toggleCredential(userId, tokenId, isActive);
},
/** Deletes a GitHub token. */
deleteGithubToken(userId: number, tokenId: number): boolean {
return credentialsDb.deleteCredential(userId, tokenId);
},
// Legacy alias used by existing routes
toggleGithubToken(userId: number, tokenId: number, isActive: boolean): boolean {
return githubTokensDb.updateGithubToken(userId, tokenId, isActive);
},
};

View File

@@ -0,0 +1,153 @@
import { getConnection } from '@/modules/database/connection.js';
type NotificationChannelEndpointRow = {
id: number;
user_id: number;
channel: string;
endpoint_id: string;
label: string | null;
metadata_json: string | null;
enabled: number;
last_seen_at: string;
created_at: string;
updated_at: string;
};
type UpsertNotificationChannelEndpointInput = {
userId: number;
channel: string;
endpointId: string;
label?: string | null;
metadata?: Record<string, unknown> | null;
enabled?: boolean;
};
function normalizeRequiredText(value: unknown): string {
if (typeof value !== 'string') return '';
return value.trim();
}
function normalizeNullableText(value: unknown): string | null {
if (typeof value !== 'string') return null;
const normalized = value.trim();
return normalized || null;
}
function serializeMetadata(metadata: Record<string, unknown> | null | undefined): string | null {
if (!metadata || typeof metadata !== 'object') return null;
return JSON.stringify(metadata);
}
function parseMetadata(metadataJson: string | null): Record<string, unknown> {
if (!metadataJson) return {};
try {
const parsed = JSON.parse(metadataJson);
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : {};
} catch {
return {};
}
}
export const notificationChannelEndpointsDb = {
upsertEndpoint(input: UpsertNotificationChannelEndpointInput): NotificationChannelEndpointRow {
const channel = normalizeRequiredText(input.channel);
const endpointId = normalizeRequiredText(input.endpointId);
if (!channel) throw new Error('channel is required');
if (!endpointId) throw new Error('endpointId is required');
const enabled = input.enabled === false ? 0 : 1;
const db = getConnection();
db.prepare(
`INSERT INTO notification_channel_endpoints (
user_id,
channel,
endpoint_id,
label,
metadata_json,
enabled,
last_seen_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, channel, endpoint_id) DO UPDATE SET
label = excluded.label,
metadata_json = excluded.metadata_json,
enabled = excluded.enabled,
last_seen_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP`
).run(
input.userId,
channel,
endpointId,
normalizeNullableText(input.label),
serializeMetadata(input.metadata),
enabled
);
return notificationChannelEndpointsDb.getEndpoint(input.userId, channel, endpointId)!;
},
getEndpoint(userId: number, channel: string, endpointId: string): NotificationChannelEndpointRow | null {
const db = getConnection();
const row = db.prepare(
`SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at
FROM notification_channel_endpoints
WHERE user_id = ? AND channel = ? AND endpoint_id = ?`
).get(
userId,
normalizeRequiredText(channel),
normalizeRequiredText(endpointId)
) as NotificationChannelEndpointRow | undefined;
return row || null;
},
getEndpoints(userId: number, channel: string): NotificationChannelEndpointRow[] {
const db = getConnection();
return db.prepare(
`SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at
FROM notification_channel_endpoints
WHERE user_id = ? AND channel = ?
ORDER BY last_seen_at DESC`
).all(userId, normalizeRequiredText(channel)) as NotificationChannelEndpointRow[];
},
getEnabledEndpoints(userId: number, channel: string): NotificationChannelEndpointRow[] {
const db = getConnection();
return db.prepare(
`SELECT id, user_id, channel, endpoint_id, label, metadata_json, enabled, last_seen_at, created_at, updated_at
FROM notification_channel_endpoints
WHERE user_id = ? AND channel = ? AND enabled = 1
ORDER BY last_seen_at DESC`
).all(userId, normalizeRequiredText(channel)) as NotificationChannelEndpointRow[];
},
setEndpointEnabled(userId: number, channel: string, endpointId: string, enabled: boolean): boolean {
const db = getConnection();
const result = db.prepare(
`UPDATE notification_channel_endpoints
SET enabled = ?, updated_at = CURRENT_TIMESTAMP
WHERE user_id = ? AND channel = ? AND endpoint_id = ?`
).run(enabled ? 1 : 0, userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId));
return result.changes > 0;
},
touchEndpoint(userId: number, channel: string, endpointId: string): boolean {
const db = getConnection();
const result = db.prepare(
`UPDATE notification_channel_endpoints
SET last_seen_at = CURRENT_TIMESTAMP
WHERE user_id = ? AND channel = ? AND endpoint_id = ?`
).run(userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId));
return result.changes > 0;
},
removeEndpoint(userId: number, channel: string, endpointId: string): boolean {
const db = getConnection();
const result = db.prepare(
'DELETE FROM notification_channel_endpoints WHERE user_id = ? AND channel = ? AND endpoint_id = ?'
).run(userId, normalizeRequiredText(channel), normalizeRequiredText(endpointId));
return result.changes > 0;
},
parseMetadata,
};

View File

@@ -0,0 +1,117 @@
/**
* Notification preferences repository.
*
* Stores per-user notification channel/event preferences as JSON.
*/
import { getConnection } from '@/modules/database/connection.js';
type NotificationPreferences = {
channels: {
inApp: boolean;
webPush: boolean;
desktop: boolean;
sound: boolean;
[key: string]: boolean;
};
events: {
actionRequired: boolean;
stop: boolean;
error: boolean;
};
};
const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
channels: {
inApp: false,
webPush: false,
desktop: false,
sound: true,
},
events: {
actionRequired: true,
stop: true,
error: true,
},
};
function normalizeNotificationPreferences(value: unknown): NotificationPreferences {
const source = value && typeof value === 'object' ? (value as Record<string, any>) : {};
const sourceChannels = source.channels && typeof source.channels === 'object'
? source.channels as Record<string, unknown>
: {};
const extraChannels = Object.fromEntries(
Object.entries(sourceChannels)
.filter(([key, channelValue]) => !['inApp', 'webPush', 'desktop', 'sound'].includes(key) && typeof channelValue === 'boolean')
) as Record<string, boolean>;
return {
channels: {
...extraChannels,
inApp: source.channels?.inApp === true,
webPush: source.channels?.webPush === true,
desktop: source.channels?.desktop === true,
sound: source.channels?.sound !== false,
},
events: {
actionRequired: source.events?.actionRequired !== false,
stop: source.events?.stop !== false,
error: source.events?.error !== false,
},
};
}
export const notificationPreferencesDb = {
/** Returns the normalized preferences for a user, creating defaults on first read. */
getNotificationPreferences(userId: number): NotificationPreferences {
const db = getConnection();
const row = db
.prepare(
'SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?'
)
.get(userId) as { preferences_json: string } | undefined;
if (!row) {
const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
db.prepare(
'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)'
).run(userId, JSON.stringify(defaults));
return defaults;
}
let parsed: unknown;
try {
parsed = JSON.parse(row.preferences_json);
} catch {
parsed = DEFAULT_NOTIFICATION_PREFERENCES;
}
return normalizeNotificationPreferences(parsed);
},
/** Upserts normalized preferences for a user and returns the stored value. */
updateNotificationPreferences(
userId: number,
preferences: unknown
): NotificationPreferences {
const normalized = normalizeNotificationPreferences(preferences);
const db = getConnection();
db.prepare(
`INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
preferences_json = excluded.preferences_json,
updated_at = CURRENT_TIMESTAMP`
).run(userId, JSON.stringify(normalized));
return normalized;
},
// Legacy aliases used by existing services/routes
getPreferences(userId: number): NotificationPreferences {
return notificationPreferencesDb.getNotificationPreferences(userId);
},
updatePreferences(userId: number, preferences: unknown): NotificationPreferences {
return notificationPreferencesDb.updateNotificationPreferences(userId, preferences);
},
};

View File

@@ -0,0 +1,196 @@
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { getConnection } from '@/modules/database/connection.js';
import type { CreateProjectPathResult, ProjectRepositoryRow } from '@/shared/types.js';
import { normalizeProjectPath } from '@/shared/utils.js';
function normalizeProjectDisplayName(projectPath: string, customProjectName: string | null): string {
const trimmedCustomName = typeof customProjectName === 'string' ? customProjectName.trim() : '';
if (trimmedCustomName.length > 0) {
return trimmedCustomName;
}
const directoryName = path.basename(projectPath);
return directoryName || projectPath;
}
export const projectsDb = {
createProjectPath(projectPath: string, customProjectName: string | null = null): CreateProjectPathResult {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
const normalizedProjectName = normalizeProjectDisplayName(normalizedProjectPath, customProjectName);
const attemptedId = randomUUID();
const row = db.prepare(`
INSERT INTO projects (project_id, project_path, custom_project_name, isArchived)
VALUES (?, ?, ?, 0)
ON CONFLICT(project_path) DO UPDATE SET
isArchived = 0
WHERE projects.isArchived = 1
RETURNING project_id, project_path, custom_project_name, isStarred, isArchived
`).get(attemptedId, normalizedProjectPath, normalizedProjectName) as ProjectRepositoryRow | undefined;
if (row) {
return {
outcome: row.project_id === attemptedId ? 'created' : 'reactivated_archived',
project: row,
};
}
const existingProject = projectsDb.getProjectPath(normalizedProjectPath);
return {
outcome: 'active_conflict',
project: existingProject,
};
},
getProjectPath(projectPath: string): ProjectRepositoryRow | null {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
const row = db.prepare(`
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
FROM projects
WHERE project_path = ?
`).get(normalizedProjectPath) as ProjectRepositoryRow | undefined;
return row ?? null;
},
getProjectById(projectId: string): ProjectRepositoryRow | null {
const db = getConnection();
const row = db.prepare(`
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
FROM projects
WHERE project_id = ?
`).get(projectId) as ProjectRepositoryRow | undefined;
return row ?? null;
},
/**
* Resolve the absolute project directory from a database project_id.
*
* This is the canonical lookup used after the projectName → projectId migration:
* API routes receive the DB-assigned `projectId` and must resolve the real folder
* path through this helper before touching the filesystem. Returns `null` when the
* project row does not exist so callers can respond with a 404.
*/
getProjectPathById(projectId: string): string | null {
const db = getConnection();
const row = db.prepare(`
SELECT project_path
FROM projects
WHERE project_id = ?
`).get(projectId) as Pick<ProjectRepositoryRow, 'project_path'> | undefined;
return row?.project_path ?? null;
},
getProjectPaths(): ProjectRepositoryRow[] {
const db = getConnection();
return db.prepare(`
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
FROM projects
WHERE isArchived = 0
`).all() as ProjectRepositoryRow[];
},
/**
* Archived rows are queried separately so archive-focused UIs can present
* hidden workspaces without reintroducing them into the active sidebar list.
*/
getArchivedProjectPaths(): ProjectRepositoryRow[] {
const db = getConnection();
return db.prepare(`
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
FROM projects
WHERE isArchived = 1
`).all() as ProjectRepositoryRow[];
},
getCustomProjectName(projectPath: string): string | null {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
const row = db.prepare(`
SELECT custom_project_name
FROM projects
WHERE project_path = ?
`).get(normalizedProjectPath) as Pick<ProjectRepositoryRow, 'custom_project_name'> | undefined;
return row?.custom_project_name ?? null;
},
updateCustomProjectName(projectPath: string, customProjectName: string | null): void {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
db.prepare(`
INSERT INTO projects (project_id, project_path, custom_project_name)
VALUES (?, ?, ?)
ON CONFLICT(project_path) DO UPDATE SET custom_project_name = excluded.custom_project_name
`).run(randomUUID(), normalizedProjectPath, customProjectName);
},
updateCustomProjectNameById(projectId: string, customProjectName: string | null): void {
const db = getConnection();
db.prepare(`
UPDATE projects
SET custom_project_name = ?
WHERE project_id = ?
`).run(customProjectName, projectId);
},
updateProjectIsStarred(projectPath: string, isStarred: boolean): void {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
db.prepare(`
UPDATE projects
SET isStarred = ?
WHERE project_path = ?
`).run(isStarred ? 1 : 0, normalizedProjectPath);
},
updateProjectIsStarredById(projectId: string, isStarred: boolean): void {
const db = getConnection();
db.prepare(`
UPDATE projects
SET isStarred = ?
WHERE project_id = ?
`).run(isStarred ? 1 : 0, projectId);
},
updateProjectIsArchived(projectPath: string, isArchived: boolean): void {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
db.prepare(`
UPDATE projects
SET isArchived = ?
WHERE project_path = ?
`).run(isArchived ? 1 : 0, normalizedProjectPath);
},
updateProjectIsArchivedById(projectId: string, isArchived: boolean): void {
const db = getConnection();
db.prepare(`
UPDATE projects
SET isArchived = ?
WHERE project_id = ?
`).run(isArchived ? 1 : 0, projectId);
},
deleteProjectPath(projectPath: string): void {
const db = getConnection();
const normalizedProjectPath = normalizeProjectPath(projectPath);
db.prepare(`
DELETE FROM projects
WHERE project_path = ?
`).run(normalizedProjectPath);
},
deleteProjectById(projectId: string): void {
const db = getConnection();
db.prepare(`
DELETE FROM projects
WHERE project_id = ?
`).run(projectId);
},
};

View File

@@ -0,0 +1,80 @@
/**
* Push subscriptions repository.
*
* Persists browser push subscription endpoints and keys per user.
*/
import { getConnection } from '@/modules/database/connection.js';
type PushSubscriptionLookupRow = {
endpoint: string;
keys_p256dh: string;
keys_auth: string;
};
export const pushSubscriptionsDb = {
/** Upserts a push subscription endpoint for a user. */
createPushSubscription(
userId: number,
endpoint: string,
keysP256dh: string,
keysAuth: string
): void {
const db = getConnection();
db.prepare(
`INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth)
VALUES (?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET
user_id = excluded.user_id,
keys_p256dh = excluded.keys_p256dh,
keys_auth = excluded.keys_auth`
).run(userId, endpoint, keysP256dh, keysAuth);
},
/** Returns all subscriptions for a user. */
getPushSubscriptions(userId: number): PushSubscriptionLookupRow[] {
const db = getConnection();
return db
.prepare(
'SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?'
)
.all(userId) as PushSubscriptionLookupRow[];
},
/** Deletes one subscription by endpoint. */
deletePushSubscription(endpoint: string): void {
const db = getConnection();
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);
},
/** Deletes all subscriptions for a user. */
deletePushSubscriptionsForUser(userId: number): void {
const db = getConnection();
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);
},
// Legacy aliases used by existing services/routes
saveSubscription(
userId: number,
endpoint: string,
keysP256dh: string,
keysAuth: string
): void {
pushSubscriptionsDb.createPushSubscription(
userId,
endpoint,
keysP256dh,
keysAuth
);
},
getSubscriptions(userId: number): PushSubscriptionLookupRow[] {
return pushSubscriptionsDb.getPushSubscriptions(userId);
},
removeSubscription(endpoint: string): void {
pushSubscriptionsDb.deletePushSubscription(endpoint);
},
removeAllForUser(userId: number): void {
pushSubscriptionsDb.deletePushSubscriptionsForUser(userId);
},
};

View File

@@ -0,0 +1,42 @@
import { getConnection } from '@/modules/database/connection.js';
type ScanStateRow = {
last_scanned_at: string;
};
export const scanStateDb = {
getLastScannedAt() {
const db = getConnection();
const row = db
.prepare(`SELECT last_scanned_at FROM scan_state WHERE id = 1`)
.get() as ScanStateRow;
if (!row) {
return null; // Before any scan, the row is undefined.
}
let lastScannedDate: Date | null = null;
const lastScannedStr = row.last_scanned_at;
if (lastScannedStr) {
// SQLite CURRENT_TIMESTAMP returns UTC in "YYYY-MM-DD HH:MM:SS" format.
// Replace space with 'T' and append 'Z' to parse reliably in JS across all platforms.
lastScannedDate = new Date(lastScannedStr.replace(' ', 'T') + 'Z');
}
return lastScannedDate;
},
updateLastScannedAt(scannedAt: Date = new Date()) {
const db = getConnection();
const sqliteTimestamp = scannedAt.toISOString().slice(0, 19).replace('T', ' ');
db.prepare(`
INSERT INTO scan_state (id, last_scanned_at)
VALUES (1, ?)
ON CONFLICT (id)
DO UPDATE SET last_scanned_at = excluded.last_scanned_at
`).run(sqliteTimestamp);
}
};

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