Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
# Changelog

## 7.8.0

### Open the workspace in VS Code

- **One button, local or remote.** A project row and the file viewer now hand the
workspace to your desktop editor. A remote SSH project opens through **VS Code
Remote-SSH**, already at the right path — no reconnecting by hand, no hunting for
the folder.

- **It opens on the machine you are sitting at.** The studio launches the editor
itself when it can find a `code` binary on the host; when it cannot — inside
Docker, on a headless server, on Windows — it hands your browser a `vscode://`
link instead, and the desktop handler takes it from there. One rule, no
platform-sniffing, and it lands correctly whether the studio runs on your laptop
or three time zones away.

- **Not only VS Code.** Insiders, VSCodium, Cursor and Windsurf are selectable in
Settings → *UI*. All four are VS Code forks, so the remote-SSH link means the same
thing in each; only the scheme and the binary change.

- **A remote workspace on a non-standard port carries it.** Omitting the port would
not be the cautious choice — the connection would simply be attempted against 22
and fail.

- **Refusals say what is wrong.** A remote project recorded with a `~/project`
workdir cannot be expressed as a URI (Remote-SSH would look for a directory
literally named `~`), so it is refused by name rather than opening the wrong
folder. Per-file opening stays local-only on purpose: VS Code's own URL handler
opens a remote *file* link as a folder, so the button comes off rather than doing
the wrong thing — the project row still opens the remote workspace.

## 7.7.0

Three things that were true only in one place become true everywhere: the file
Expand Down
49 changes: 47 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ docker compose up -d
docker compose logs -f claude-chat
```

No linting and no build step configured. `npm test` chains 63 test files under `test/`: 18 DOM-less render/UI-logic tests (`test/render/*.test.mjs`, run through `node --test`) plus 45 plain-`node` suites in `test/` covering the overload detector, env load order, multi-agent results, terminals, bots, telegram, updates, kanban scheduling, i18n completeness, the config precedence resolver plus its secret masking, usage-limit detection, the filesystem path guard (including the SVG sandbox header and the symlink rule on the `@`-mention search endpoints) plus the tunnel-blocks-terminal rule, WS session re-subscription, the SSH remote CLI-session import, the live engine pane / interactive-prompt watchdog, the cross-project global workspace aggregation, the rule that an SSH credential never leaves the server process, the Windows command-quoting oracle, the auth token lifecycle, the multi-agent dependency scheduler (waves, plan sanitising, and the rule that a failure warning must survive dep-context truncation), the SSH stream parser's three guards, the remote non-interactive shell environment (`remote-env.test.js`, which runs the generated prelude through real `bash -lc`: it must parse, print nothing on stdout, and never end on a false test — the caller chains `&& claude …` behind it), the remote CLI-list framing parser, the bot inbox's SQL seam (`bot-inbox.test.js` pins that `from_bot AS "from"` keeps the exact key `planInboxDelivery` reads — rename one without the other and every letter is silently retired as malformed), the one-time config/.env migration onto CCS_CONFIG_PATH and the mid-task clarification delivery contract on the subscription engine (`interrupt-delivery.test.js` — pins that the tmux injection block sits BEFORE the poll loop's completion `break`, that draining does not imply delivery, that a failed paste is re-queued and warns non-terminally, and that the task runner passes the same callbacks the chat path does), the CLAUDE.md / AGENTS.md discovery rules (`agents-md.test.js`, which also pins that AGENTS.md reaches the subprocess as `--append-system-prompt` and never as `--system-prompt`), and the remote file browser's three guard layers (`remote-files.test.js` runs the generated POSIX script through a real `/bin/sh` against a temp tree that contains symlinks OUT of the project; `remote-files-api.test.js` boots a server against a fake remote via `CCS_REMOTE_EXEC_HOOK` and drives `/api/files` the way the SPA does), and the new-chat defaults chain (`chat-defaults.test.js` pins the pure resolver — the built-ins are asserted to be exactly what the SPA hardcoded before #58, and the choice lists to be exactly the toolbar's `data-v` sets and `MODEL_MAP`'s aliases; `chat-defaults-api.test.js` boots a real server in a throwaway `APP_DIR` and pins that a project writes back a SPARSE override object — a five-key snapshot passes every other assertion in that file and still breaks the feature). On the render side, `tables.test.mjs` also pins the ReDoS bound in renderMd step 3.4, `xss.test.mjs` runs 24 adversarial payloads end-to-end, and `forged-tokens.test.mjs` covers the case where user text contains the renderer's own placeholder control bytes, and `pane-font.test.mjs` pins the clamp DIRECTION of `_fitEnginePaneFont` (a wide engine pane may only shrink; a narrow split pane must be allowed to grow). `script-scope.test.mjs` pins which `<script>` block a helper is declared in — declarations hoist only within their own block, so a helper used by `loadSess()` must not live in the terminal block at the bottom of the file. Note the glob: a file under `test/render/` whose name does not end in `.test.mjs` is NEVER run — `_load.selftest.mjs` sat there unexecuted until it was renamed to `loader.test.mjs`. It runs serially and aborts on the first failing file. `.github/workflows/ci.yml` runs it on every push and PR to `main` (tmux installed, so the four tmux-dependent suites do not self-skip).
No linting and no build step configured. `npm test` chains 65 test files under `test/`: 18 DOM-less render/UI-logic tests (`test/render/*.test.mjs`, run through `node --test`) plus 47 plain-`node` suites in `test/` covering the overload detector, env load order, multi-agent results, terminals, bots, telegram, updates, kanban scheduling, i18n completeness, the config precedence resolver plus its secret masking, usage-limit detection, the filesystem path guard (including the SVG sandbox header and the symlink rule on the `@`-mention search endpoints) plus the tunnel-blocks-terminal rule, WS session re-subscription, the SSH remote CLI-session import, the live engine pane / interactive-prompt watchdog, the cross-project global workspace aggregation, the rule that an SSH credential never leaves the server process, the Windows command-quoting oracle, the auth token lifecycle, the multi-agent dependency scheduler (waves, plan sanitising, and the rule that a failure warning must survive dep-context truncation), the SSH stream parser's three guards, the remote non-interactive shell environment (`remote-env.test.js`, which runs the generated prelude through real `bash -lc`: it must parse, print nothing on stdout, and never end on a false test — the caller chains `&& claude …` behind it), the remote CLI-list framing parser, the bot inbox's SQL seam (`bot-inbox.test.js` pins that `from_bot AS "from"` keeps the exact key `planInboxDelivery` reads — rename one without the other and every letter is silently retired as malformed), the one-time config/.env migration onto CCS_CONFIG_PATH and the mid-task clarification delivery contract on the subscription engine (`interrupt-delivery.test.js` — pins that the tmux injection block sits BEFORE the poll loop's completion `break`, that draining does not imply delivery, that a failed paste is re-queued and warns non-terminally, and that the task runner passes the same callbacks the chat path does), the CLAUDE.md / AGENTS.md discovery rules (`agents-md.test.js`, which also pins that AGENTS.md reaches the subprocess as `--append-system-prompt` and never as `--system-prompt`), and the remote file browser's three guard layers (`remote-files.test.js` runs the generated POSIX script through a real `/bin/sh` against a temp tree that contains symlinks OUT of the project; `remote-files-api.test.js` boots a server against a fake remote via `CCS_REMOTE_EXEC_HOOK` and drives `/api/files` the way the SPA does), the editor deep links (`editor-links.test.js` pins the two URI shapes literally — the browser link puts `vscode-remote` in the AUTHORITY and the CLI argument puts it in the SCHEME, and collapsing the two silently breaks one path; `editor-open-api.test.js` boots a real server with `PATH` pointed at an EMPTY directory, which both makes the `opened:'client'` fallback deterministic and guarantees the suite never launches an editor window on a developer's desktop), and the new-chat defaults chain (`chat-defaults.test.js` pins the pure resolver — the built-ins are asserted to be exactly what the SPA hardcoded before #58, and the choice lists to be exactly the toolbar's `data-v` sets and `MODEL_MAP`'s aliases; `chat-defaults-api.test.js` boots a real server in a throwaway `APP_DIR` and pins that a project writes back a SPARSE override object — a five-key snapshot passes every other assertion in that file and still breaks the feature). On the render side, `tables.test.mjs` also pins the ReDoS bound in renderMd step 3.4, `xss.test.mjs` runs 24 adversarial payloads end-to-end, and `forged-tokens.test.mjs` covers the case where user text contains the renderer's own placeholder control bytes, and `pane-font.test.mjs` pins the clamp DIRECTION of `_fitEnginePaneFont` (a wide engine pane may only shrink; a narrow split pane must be allowed to grow). `script-scope.test.mjs` pins which `<script>` block a helper is declared in — declarations hoist only within their own block, so a helper used by `loadSess()` must not live in the terminal block at the bottom of the file. Note the glob: a file under `test/render/` whose name does not end in `.test.mjs` is NEVER run — `_load.selftest.mjs` sat there unexecuted until it was renamed to `loader.test.mjs`. It runs serially and aborts on the first failing file. `.github/workflows/ci.yml` runs it on every push and PR to `main` (tmux installed, so the four tmux-dependent suites do not self-skip).

## Architecture

Expand Down Expand Up @@ -362,6 +362,51 @@ is testable without booting anything:
they fire only when a caller passes nothing at all, which is a different concern from
a channel policy. Pinned by `test/chat-defaults-api.test.js`.

### Open in VS Code (issue #63)

`editor-links.js` builds the links; `POST /api/editor/open` decides which of the two
ways to launch is right for THIS deployment. Both exist because the studio is not
always running on the machine the browser is on.

- **The two URI shapes are not the same string.** The browser deep link is
`vscode://vscode-remote/ssh-remote+user@host/srv/app` — the editor's scheme, with
`vscode-remote` as the URI AUTHORITY, which is how the desktop URL handler routes
it. The CLI argument is `--folder-uri vscode-remote://ssh-remote+user@host/srv/app`
— there `vscode-remote` IS the scheme, because the editor is already running.
Collapse the two and one path silently stops working.
- **The server prefers its own CLI, and falls back to the deep link.** A resolvable
`code` binary means the server is a workstation, so it opens the window itself and
can report failure honestly. No binary means Docker, a headless host or Windows —
and there the browser follows `vscode://`, which lands on the machine with the
screen. That is one rule, no `process.platform` test, correct in every deployment.
- **Nothing about the deep-link outcome is detectable.** No browser reports whether a
protocol handler exists or ran. So the SPA states the condition every time it takes
that path ("if nothing opened, …") instead of claiming an error it cannot observe.
- **`$PATH` is walked in-process, never through `which`/`where`.** A subprocess would
need a shell on Windows, and the value being launched is a user-chosen filesystem
path — the BatBadBut re-parse `delegate-terminal.js` already had to work around.
`_EXEC_EXTS` therefore excludes `.CMD`: VS Code puts `code.cmd` on PATH and Node
refuses to spawn one without `shell: true`, so accepting it would turn every Windows
launch into a throw instead of the deep-link fallback that works there.
- **Authorisation is the file browser's resolver, not a second guard.**
`resolveFilesWorkdir()` gives "you may open in an editor whatever you may browse",
plus the default-`WORKDIR` fallback and — critically — `isRemote` from the PROJECT
RECORD. Inferring remoteness from the path would hand a local POSIX-looking workdir
to Remote-SSH.
- **A `~/project` remote workdir is refused by name.** It is legal everywhere else in
this app because the remote *shell* expands it; a URI has no shell, and Remote-SSH
would look for a directory literally called `~`. Resolving it would need an SSH
round trip, which is not something a link builder should do.
- **Per-FILE open is local-only, and not for the #57 reason.** The remote folder link
works; VS Code's URL handler opens a remote FILE uri as a folder
(microsoft/vscode-remote-release#4333). So `fpvEditorBtn` hides for a remote file
while the project row keeps opening the remote workspace.
- **The editor is a fixed catalog, not a binary name field.** The value becomes both a
URI scheme and an `argv[0]`. `EDITORS` covers VS Code, Insiders, VSCodium, Cursor and
Windsurf — all VS Code forks, so `vscode-remote://` means the same thing in each —
and the settings row (`config-resolve.js`, `section: 'ui'`) reads its choices from
there rather than restating them.

### Markdown Rendering in SPA
- During streaming: `renderStreaming()` handles unclosed code fences
- On `done` event: re-render with full `renderMd()` for proper final formatting
Expand All @@ -371,7 +416,7 @@ is testable without booting anything:

## How to Verify Changes

`npm test` runs 63 test files under `test/` (18 `test/render/*.test.mjs` + 45 `test/*.test.js`), and `.github/workflows/ci.yml` runs the same command on every push and PR to `main`. Nothing covers the live browser/WebSocket path, so also verify that manually:
`npm test` runs 65 test files under `test/` (18 `test/render/*.test.mjs` + 47 `test/*.test.js`), and `.github/workflows/ci.yml` runs the same command on every push and PR to `main`. Nothing covers the live browser/WebSocket path, so also verify that manually:

```bash
# 1. Start server
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,8 @@ The active engine is always visible at a glance: a **⚡ Max** badge appears in

**SSH** — add remote servers, create projects pointing to directories on them. Claude works there as if local. Type `#` in chat for quick multi-server attachment. Screenshots and files auto-upload via SFTP.

**Open in VS Code** — the ⏻-style button on a project row hands the workspace to your desktop editor, local or over SSH. A remote project opens through **VS Code Remote-SSH** at the right path, without reconnecting by hand. The studio launches the editor itself when it can find a `code` binary; when it cannot — inside Docker, on a headless host, on Windows — it hands your browser a `vscode://` link instead, so the window opens on the machine you are actually sitting at. Pick **VS Code, Insiders, VSCodium, Cursor or Windsurf** in Settings → *UI*. The file viewer gets the same button for the file you are reading.

**Remote file browser** — the file tree, the file viewer and `@`-mention search now work on SSH projects as well, not only local ones. Read-only by design: list a directory, open a file. Download, share and image/PDF preview stay hidden for remote files instead of failing under your finger. Listings are capped at 2000 entries and files at 2 MB — both enforced on the remote host and announced in the UI, so an 8 GB log never crosses the link. A symlink pointing outside the project root is refused, not followed.

**Remote Access** — one click: cloudflared (no signup) or ngrok. Public HTTPS URL in seconds. Works behind NAT, firewalls, corporate VPNs. URL sent to Telegram automatically.
Expand Down Expand Up @@ -490,6 +492,7 @@ npx github:Lexus2016/claude-code-studio # launch as usual
| **Skills** | 30 built-in, auto-classification, plugin discovery, custom `.md` files |
| **Commands** | 10 built-in slash commands, custom commands |
| **Remote** | SSH servers, SFTP upload, `#` quick-attach, cloudflared/ngrok tunnels, remote CLI session import, read-only remote file browser (tree + viewer + `@`-mention search over SSH, capped and symlink-guarded) |
| **Editor** | Open the active workspace in VS Code / Insiders / VSCodium / Cursor / Windsurf — locally or through Remote-SSH; launched by the server when it has the CLI, by the browser via `vscode://` when it does not |
| **Mobile** | Native-feel UI, bottom sheet, scroll-snap Kanban, iOS-safe, touch-optimized |
| **Dashboard** | Activity heatmap, tool usage, model distribution, Automation Index, peak hours |
| **Reliability** | security-hardened Telegram uploads, self-healing sessions, crash protection, atomic writes, instant stop, rate limit auto-wait, concurrency safety (session lock + busy_timeout), orphaned session lock auto-cleanup |
Expand Down
9 changes: 9 additions & 0 deletions config-resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
'use strict';

const chatDefaults = require('./chat-defaults');
const editorLinks = require('./editor-links');

// Sources, most-significant first. Ordering here IS the precedence contract;
// resolve() walks candidates in the order it builds them, not in this order,
Expand Down Expand Up @@ -198,6 +199,14 @@ const SETTINGS = [
choices: ['uk', 'en', 'ru', 'fr', 'he'], def: 'en', falsyFallsThrough: true,
src: 'server.js:loadMergedConfig' },

// Which desktop editor the "Open in workspace" action targets (issue #63). A fixed
// list rather than a free-text binary name: the value becomes both a URI scheme and
// an argv[0], and neither is somewhere to accept arbitrary text. editor-links.js
// owns the catalog, so the choices are not restated here.
{ key: 'editor', section: 'ui', backing: 'config', merge: 'merged', path: 'editor', type: 'enum',
choices: editorLinks.EDITOR_IDS, def: editorLinks.DEFAULT_EDITOR, falsyFallsThrough: true,
src: 'editor-links.js:editorFor' },

// ── Advanced ──────────────────────────────────────────────────────────────
{ key: 'LOG_LEVEL', section: 'advanced', backing: 'env', type: 'enum',
choices: ['error', 'warn', 'info', 'debug'], def: 'info', restart: true, src: 'server.js:_logLevel' },
Expand Down
Loading
Loading