Compare commits

...

138 Commits

Author SHA1 Message Date
Shantur Rathore
105714778b fix(ci): pin npm for publish workflow 2026-04-21 10:06:29 +01:00
Shantur Rathore
c9eea8c003 fix(tauri): require standalone server in desktop bundles 2026-04-21 08:33:53 +01:00
Shantur Rathore
25512e8dc1 fix(ci): install xdg-utils for Linux Tauri bundling 2026-04-21 07:55:32 +01:00
Shantur Rathore
f56d63d166 fix(tauri): strip native config addons from bundles 2026-04-21 07:51:53 +01:00
Shantur Rathore
8173030b1a fix(ci): log Linux Tauri bundle diagnostics 2026-04-21 07:35:22 +01:00
Shantur Rathore
73a97e64ba fix(tauri): let CI control platform CLI binaries 2026-04-20 23:35:13 +01:00
Shantur Rathore
a5f38ee625 fix(tauri): align packaged CLI with Linux bundler 2026-04-20 23:25:39 +01:00
Shantur Rathore
ca880451e7 fix(tauri): prune Bun from Linux app bundle 2026-04-20 23:14:16 +01:00
Shantur Rathore
4af8cc08b9 fix(ci): restore dev Linux Tauri bundling env 2026-04-20 23:04:32 +01:00
Shantur Rathore
b60d86116a fix(tauri): fall back to Node server on Linux 2026-04-20 22:54:21 +01:00
Shantur Rathore
76f14e2189 fix(ci): pin Linux Tauri CLI to known-good version 2026-04-20 21:12:56 +01:00
Shantur Rathore
9ecd5131a6 fix(ci): stabilize Linux Tauri AppImage bundling 2026-04-20 21:00:19 +01:00
Shantur Rathore
95f47ebbe4 fix(tauri): avoid AppImage linuxdeploy desktop alias conflict 2026-04-20 20:35:26 +01:00
Shantur Rathore
6c50564df6 fix(ci): align Tauri CLI with packaged desktop builds 2026-04-20 14:45:32 +01:00
Shantur Rathore
166edd2e30 fix(ci): align Node and Tauri versions for desktop builds 2026-04-20 14:30:29 +01:00
Shantur Rathore
79dbbd4cb4 fix(server): preserve streamed proxy bodies and strip hop headers 2026-04-20 14:13:05 +01:00
Shantur Rathore
1c2ec1558e fix(build): use bundled Bun for standalone server builds 2026-04-20 13:16:23 +01:00
Shantur Rathore
3b08bc3262 fix(desktop): align standalone server startup and proxy workspace instances 2026-04-20 12:58:56 +01:00
Shantur Rathore
016c7bda4a fix(tauri): use in-app certificate install confirmation 2026-04-20 08:49:50 +01:00
Pascal André
04fc28c492 feat(tauri): support self-signed remote HTTPS via server-backed proxy (#333)
## Summary

- add a server-backed HTTPS proxy flow for Tauri remote windows so
self-signed remote HTTPS works with the local CLI TLS assets and desktop
auth/cookie handling
- manage remote proxy sessions through `packages/server` with
per-session bootstrap, local-only cleanup, and explicit session
lifecycle handling
- support the Tauri desktop flow across environments, including packaged
Windows builds, `tauri dev`, and updated Linux/macOS handling for the
new local HTTPS proxy path

## Testing

- `npm run build --workspace @neuralnomads/codenomad`
- `cargo check`
- `npm run build --workspace @codenomad/tauri-app`
- Windows smoke test for concurrent remote proxy bootstrap sessions
- Windows manual validation of packaged Tauri remote connection flow

## Notes

- Windows was validated end-to-end.
- Linux and macOS code paths were updated for the new proxy flow, but
runtime validation on those platforms is still pending.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-19 23:26:55 +01:00
Shantur Rathore
623a09fd7e fix(ui): stabilize long reply hold during streaming 2026-04-19 19:56:48 +01:00
Shantur Rathore
b00aa7ef84 fix(build): add Windows ARM64 Rollup native package 2026-04-19 08:49:23 +01:00
Pascal André
acfa265595 fix(build): align Rollup native packages with supported platforms (#337)
Fixes #324

## Summary
- declare root Rollup optional dependencies for the repo's current
supported build matrix: macOS x64/arm64, Linux x64/arm64, and Windows
x64
- pin those root platform packages to the same Rollup version already
used by the repo
- keep the existing workflow/manual-install fallback steps in place for
now

## Validation
- regenerated `package-lock.json` with `npm install --package-lock-only
--ignore-scripts`
- verified the root package entry now records the supported platform
packages under `optionalDependencies`
- kept the change scoped to the platforms currently represented in
workflows and `packages/tauri-app/scripts/prebuild.js`
2026-04-19 08:40:49 +01:00
Pascal André
35b171764e fix(desktop): align Electron package and runtime app ids (#342)
Follow-up from #334

## Summary
- align the Electron package `build.appId` with the runtime identifier
already used in `app.setAppUserModelId(...)`
- remove the mismatch between packaged desktop identity and runtime
desktop identity
- keep the change narrowly scoped to identifier consistency only

## Validation
- verified the previous mismatch in `packages/electron-app/package.json`
vs `packages/electron-app/electron/main/main.ts`
- updated the packaging id to match the runtime id exactly
2026-04-18 23:56:58 +01:00
Pascal André
6b53ab2d73 fix(ui): prevent session status labels from being retranslated (#339)
Fixes #273

## Summary
- mark the session list header label as non-translatable
- mark compact session status badges as non-translatable
- prevent browser/page translation from duplicating already localized
labels like the repeated idle badge shown in #273

## Validation
- `npm run build --workspace @codenomad/ui`
2026-04-18 23:49:38 +01:00
Pascal André
1b829094ef fix(desktop): improve Linux desktop icon integration (#334)
Refs #330

## Summary
- add standard Linux hicolor icon sizes to the Tauri package outputs
- enable the GTK app id on Linux and ship a matching reverse-DNS desktop
entry alias for shell association
- mark the alias desktop entry `NoDisplay=true` so it does not surface
as a duplicate launcher in desktop menus
- include the same alias desktop entry for AppImage so the fix is not
limited to deb/rpm packages

## Validation
- confirmed in the Linux VM that the desktop-integrated launch no longer
shows the generic taskbar icon
- verified the alias desktop entry is now hidden from app menus via
`NoDisplay=true`
- attempted a fresh `tauri build --bundles deb`; the build still hits
the known optional `@tauri-apps/cli` native-binding issue in this
workspace after prebuild, not a code/config error from this PR
2026-04-18 23:46:03 +01:00
Pascal André
e28e9f5879 fix(desktop): show explicit missing Node errors (#336)
Fixes #294

## Summary
- detect missing desktop Node runtimes before spawning the bundled CLI
- return a clear error message that tells users to install Node.js or
set `NODE_BINARY`
- handle both direct spawns and desktop-shell launches consistently

## Validation
- `npm run bundle:server --workspace @codenomad/tauri-app && cargo build
--manifest-path packages/tauri-app/src-tauri/Cargo.toml`
- exercised the missing-runtime path in the Linux VM by launching with
an invalid `NODE_BINARY`
2026-04-18 23:39:39 +01:00
Pascal André
cb84547c88 fix(desktop): source shell rc before launching CLI (#332)
Fixes #326

## Summary
- source the user's bash or zsh rc before launching the bundled CLI from
Tauri
- use `-l -i -c` for zsh so shell-managed Node runtimes are available in
launcher-started sessions
- fixes the reproduced Linux launcher case where the app exits with `CLI
exited early: exit status: 127` while terminal launches work

## Validation
- reproduced the failure with the released Tauri `v0.14.0` Linux binary
- verified the patched binary succeeds under the same launcher-like
environment
- ran `cargo build` on the dev-based PR branch
2026-04-18 23:34:49 +01:00
VooDisss
e022a158eb improve delete worktree failure diagnostics (#302)
## Summary
- move delete-worktree failures out of transient toast-only UX and keep
them inline in the delete modal
- add parsed diagnostics for common failure modes, including a short
summary, likely cause, and suggested next step
- make the raw error easier to review and share with raw and sanitized
copy actions

Closes #301.

## BEFORE:

<img width="1127" height="860" alt="image"
src="https://github.com/user-attachments/assets/dd09ba1e-be8c-450c-a1dd-f1cde2a48802"
/>

## AFTER: 

<img width="1384" height="835" alt="image"
src="https://github.com/user-attachments/assets/6b0d1459-21fa-4264-9e54-45540f584538"
/>

## Problem
Before this change, delete-worktree failures were difficult to work
with:

1. The failure message was effectively raw backend or git output.
2. Users had to infer the meaning of the error themselves.
3. The UI did not explain what likely went wrong or what to do next.
4. Sharing the error for debugging was awkward when it included
machine-local absolute paths.
5. The confirmation modal was not being used as the primary diagnostic
surface for a destructive action that frequently fails for
understandable reasons.

This was especially frustrating for common cases such as:
- modified or untracked files in the worktree
- a process still using the worktree directory
- permission errors on Windows
- missing worktree directories or stale worktree records

## What changed

### Modal failure UX
- keep delete failures inline inside
`packages/ui/src/components/worktree-selector.tsx`
- clear modal-local error state when opening or closing the dialog
- keep the success toast on successful deletion, but use the modal
itself for failure presentation

### Human-readable diagnostics
- parse JSON-shaped backend error payloads such as `{"error":"..."}`
before classification
- classify common delete failure patterns into:
  - `localChanges`
  - `inUse`
  - `notFound`
  - `permissionDenied`
  - `unknown`
- render three user-facing lines above the raw error:
  - summary
  - likely cause
  - suggested next step

### Copy flows
- add `Copy error` for the original failure text
- add `Copy sanitized` to redact common absolute path and username
patterns before copying

### Modal content and sizing
- present the target worktree in a simpler two-line summary block
- update the delete description text to plain English: `Deletes this
branch worktree and its local folder.`
- size the delete modal deliberately for desktop use while allowing
vertical expansion to the viewport limit before scrolling

### i18n coverage
- add the new delete diagnostic strings across all currently supported
locales touched by this area:
  - `en`
  - `es`
  - `fr`
  - `he`
  - `ja`
  - `ru`
  - `zh-Hans`

## Why this approach
- It keeps the backend contract unchanged and solves the UX problem
where it occurs.
- It preserves access to the raw failure text instead of hiding
implementation detail entirely.
- It gives users immediate guidance without forcing them to translate
git errors into next actions.
- It improves bug reporting without requiring a separate logging or
export workflow.

## Not included
- server-side preflight guards that block delete when the worktree is
still assigned or in use
- process-aware worktree locking detection
- automatic retry or force-delete-and-retry flows

Those are useful follow-ups, but this PR is intentionally scoped to
failure presentation and debuggability.

## Files changed
- `packages/ui/src/components/worktree-selector.tsx`
- `packages/ui/src/lib/i18n/messages/en/instance.ts`
- `packages/ui/src/lib/i18n/messages/es/instance.ts`
- `packages/ui/src/lib/i18n/messages/fr/instance.ts`
- `packages/ui/src/lib/i18n/messages/he/instance.ts`
- `packages/ui/src/lib/i18n/messages/ja/instance.ts`
- `packages/ui/src/lib/i18n/messages/ru/instance.ts`
- `packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts`

## Validation
- `npm run typecheck --workspace @codenomad/ui`
- `npm run build --workspace @codenomad/ui`
- `npm run typecheck --workspace @neuralnomads/codenomad-electron-app`

## Notes for reviewers
- The error classifier is intentionally heuristic and string-based. It
is meant to improve the common cases without increasing backend
coupling.
- The sanitized copy flow is conservative and focused on path and
username redaction, not full structured log scrubbing.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-17 17:12:17 +01:00
VooDisss
9d9a6a79ec Git diff monaco redesign (#304)
## Summary

Fixes #303.

This PR redesigns the Git Changes Monaco diff gutter so unified and
split view both use a more intentional, space-efficient Monaco
presentation while preserving Monaco's performance on large diffs.

The final behavior includes:

- `Compact` and `Normal` gutter modes for Git Changes
- dynamic gutter sizing based on actual line-number digit counts
- independent original/modified number-column sizing where needed
- split-view fixes for both wasted left inset and line-number/sign
overlap
- persisted gutter-mode selection
- localized user-facing labels for the control

## Visual comparison

### Unified view before

<img width="465" height="353" alt="Unified view before"
src="https://github.com/user-attachments/assets/0c061f25-f20a-4127-a85d-aee1161611c7"
/>

### Unified view after

<img width="634" height="240" alt="Unified view after"
src="https://github.com/user-attachments/assets/f2dfd952-89ed-4fdd-83db-a05f19f023b2"
/>

### Split view before

<img width="596" height="335" alt="Split view before"
src="https://github.com/user-attachments/assets/09bfbe41-9438-4801-b181-49a9d19d5bb8"
/>

### Split view after

<img width="640" height="338" alt="Split view after"
src="https://github.com/user-attachments/assets/fc3618ef-474f-4217-bb21-5ffd53eb4e01"
/>

<!-- If you want to replace these screenshots later, keep the four
sections above and swap the image URLs. -->

## What changed

### Unified view

- added two Git Changes Monaco gutter presentations:
  - `Compact`
  - `Normal`
- kept compact as the tighter single-column-feel unified gutter
- kept normal as the wider Monaco-style unified gutter
- made unified gutter sizing respond to actual line-number digit counts
instead of fixed assumptions
- made normal mode size the visible number columns independently when
one side needs more width than the other

### Split view

- added dynamic split gutter sizing derived from actual before/after
line counts
- made split original and modified number columns size independently
- fixed the modified-pane overlap where larger line numbers could
collide with the `+` lane
- fixed the original-pane wasted left inset caused by Monaco reserving
an empty original-side glyph-margin lane

### Persistence and UI

- persisted the selected gutter mode in preferences so it survives
reloads
- moved the gutter-mode control out of the Git Changes toolbar and into
Appearance settings
- renamed the visible settings options to `Compact` and `Normal`

### i18n

- removed hardcoded user-facing gutter toggle strings
- added localized keys for the gutter control labels and titles used by
the Git Changes surface

## Implementation notes

- Monaco remains the active Git Changes renderer throughout
- gutter sizing logic is centralized in
`packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx`
- CSS is used only for narrow presentation adjustments such as the 4px
left inset and the split original-pane glyph-margin correction
- the persisted gutter-mode preference is the source of truth for the
selected presentation

## Review focus

- unified `Compact` mode should feel tight without clipping or overlap
- unified `Normal` mode should remain wider and readable
- 3-digit and 4-digit line numbers should not collide with the sign lane
- split original pane should no longer show wasted left inset before the
first visible number column
- split modified pane should not leave conspicuous dead space or collide
with the `+` lane as digit counts grow
- selected gutter mode should persist after reload

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-17 17:04:10 +01:00
Shantur Rathore
82a7c95dba fix(ui): separate prompt composer action columns
Keep the textarea width independent from the prompt controls so wrapping matches the visible layout. Split secondary controls from the primary stop/send rail to preserve the original action column width and add a matching divider.
2026-04-17 16:12:48 +01:00
Shantur Rathore
313a0e579e fix(ui): hold streaming replies once top leaves view 2026-04-17 15:20:48 +01:00
Pascal André
a795869064 fix(ui): stabilize timeline follow scroll from bottom (#327)
## Summary
- fix the sticky-bottom state where dragging the scrollbar to the bottom
makes `PageUp` jump to the previous timeline block and then snap
immediately back down
- keep the change scoped to `virtual-follow-list.tsx`, where follow
mode, scroll intent, and bottom pinning are coordinated

## Root Cause
The list only disabled follow mode when it saw an explicit local "user
intent" signal. After reaching the bottom through the native scrollbar,
`PageUp` could move the viewport without tripping that path, so the next
render notification re-enabled the bottom snap immediately.

## Validation
- `npx tsc --noEmit --project packages/ui/tsconfig.json`
- `npm run build --prefix packages/ui`
- manual desktop test: `PageUp` works again from the bottom sticky state
2026-04-17 06:36:00 +01:00
VooDisss
9bf4d351de Refactor Git Changes workflow and diff handling (#311)
# Git Changes PR Review Context

Fixes: #310 

## Purpose of this document

This document is intended to give a PR reviewer or gatekeeper enough
neutral context to review the Git Changes feature series accurately.

## BEFORE/AFTER SNAPSHOT:

<img width="835" height="1163" alt="image"
src="https://github.com/user-attachments/assets/463d6f8c-1a6b-4cf0-8ab8-44a92c534ca5"
/>


It distinguishes:

1. the intended scope of the work
2. implementation choices that were deliberate
3. behaviors that were explicitly tested and accepted during development
4. remaining follow-up areas that were not part of the required intent

It should not be treated as a request to approve the PR automatically.
It exists to reduce false-positive review findings caused by missing
context.

---

## High-level scope

The work in this series refactors and extends the existing `Git Changes`
tab in the right panel.

The intended feature scope includes:

1. grouped staged / unstaged change presentation
2. correct section-aware diff loading
3. per-file stage / unstage controls
4. commit message compose box and commit action for staged changes
5. prompt-context insertion from the Git diff viewer
6. auto-refresh behavior that reduces dependence on the manual refresh
button

This work is intentionally implemented inside the existing Git Changes
vertical slice rather than as a new SCM subsystem.

---

## Files and areas intentionally changed

### Server / API surface

The following server areas were intentionally extended:

1. `packages/server/src/api-types.ts`
2. `packages/server/src/events/bus.ts`
3. `packages/server/src/server/http-server.ts`
4. `packages/server/src/server/routes/workspaces.ts`
5. `packages/server/src/workspaces/git-status.ts`
6. `packages/server/src/workspaces/git-mutations.ts`
7. `packages/server/src/workspaces/worktree-directory.ts`
8. `packages/server/src/workspaces/instance-events.ts`

### UI surface

The following UI areas were intentionally extended:

1. `packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx`
2. `packages/ui/src/components/instance/instance-shell2.tsx`
3.
`packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx`
4.
`packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts`
5.
`packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx`
6. `packages/ui/src/components/instance/shell/right-panel/types.ts`
7. `packages/ui/src/components/instance/shell/storage.ts`
8. `packages/ui/src/components/prompt-input.tsx`
9. `packages/ui/src/components/prompt-input/types.ts`
10. `packages/ui/src/components/session/session-view.tsx`
11. `packages/ui/src/lib/api-client.ts`
12. `packages/ui/src/lib/i18n/messages/*/instance.ts`
13. `packages/ui/src/styles/panels/right-panel.css`

---

## Intentional product and architecture decisions

The following outcomes were deliberate and should not be flagged as
issues merely because they exist.

### Git status / diff architecture

1. The UI does not rely only on the proxied OpenCode `file.status()`
payload.
2. CodeNomad adds server-backed worktree Git status and diff endpoints
to expose staged / unstaged semantics correctly.
3. Server-backed worktree mutation endpoints were added for:
   - stage
   - unstage
   - commit
4. The existing event bus / SSE channel is reused for Git invalidation,
instead of adding a bespoke invalidation route.

### Git Changes UI structure

1. The file list is grouped into:
   - `Staged Changes`
   - `Changes`
2. Both sections are collapsible.
3. Section open state is persisted.
4. The same file may appear in both sections when Git state genuinely
requires that.
5. Rows are filename-first, with parent path as secondary text.
6. Rows are intentionally compact compared to the original flat list.

### Diff behavior

1. Diff loading is section-aware.
2. Deleted files are supported in grouped mode.
3. Binary files are treated as non-line-oriented in the diff viewer.
4. Binary diffs suppress line-based prompt-context affordances.

### Stage / unstage / commit workflow

1. Stage and unstage are per-file row actions.
2. Bulk stage-all / unstage-all was intentionally not added.
3. The commit compose box is intentionally rendered inside the `Staged
Changes` section.
4. The commit button is intentionally overlaid inside the commit input
area.
5. The current commit compose flow is minimal by design:
   - no push
   - no amend flow
   - no branch management

### Prompt-context insertion

1. Prompt insertion is intentionally an HTML comment marker, not a full
diff payload.
2. The expected inserted form is:

   `<!-- Git change context: <path> lines X-Y -->`

3. The trigger UI is intentionally a seam/gutter action in the Monaco
diff viewer, not a toolbar button.

### Row action reveal behavior

1. Stage / unstage row actions are intentionally hover-revealed on
hover-capable layouts.
2. The row action reveal intentionally uses:
   - delayed hide
   - slight stats fade/shift
   - compact idle width
3. On non-hover layouts, the action remains visible for reliability.

### Auto-refresh behavior

The accepted refresh model is intentionally hybrid:

1. refresh on Git Changes tab activation
2. 20-second polling only while the Git Changes tab is active
3. immediate invalidation from completed raw tool events for:
   - `write`
   - `edit`
   - `apply_patch`

This hybrid model is intentional. Polling remains as a fallback even
after tool-event invalidation.

---

## Behaviors explicitly tested during development

The following behaviors were explicitly exercised during development and
used to guide fixes.

### Grouped staged / unstaged behavior

1. files appear in the correct staged / unstaged sections
2. section collapse / expand works
3. collapse state persists
4. line counts are section-specific

### Diff behavior

1. staged diff loads differently from unstaged diff
2. deleted-file handling was verified and corrected
3. binary-file rendering was corrected to avoid line-oriented behavior
4. untracked binary files no longer report fake text line counts

### Mutation behavior

1. per-file stage works from `Changes`
2. per-file unstage works from `Staged Changes`
3. stage / unstage selection remapping was exercised and corrected
4. unborn-repo unstage behavior was explicitly hardened

### Prompt-context behavior

1. selected line / range insertion was tested
2. button placement in the Monaco seam/gutter was iterated and verified

### Auto-refresh behavior

1. tab-activation refresh was tested
2. 20-second active-tab polling was tested
3. raw completed tool invalidation was tested in the running UI for:
   - `write`
   - `edit`
   - `apply_patch`
4. stale async overwrite and stale selection restoration bugs were found
and fixed through review/testing

---

## Review findings that were investigated and are no longer intended
blocker topics

The following areas were previously raised by strict reviews and then
either fixed or determined to be acceptable within scope.

### Fixed in the current series

1. duplicate stage / unstage firing
2. stale diff response overwriting newer selection
3. passive refresh restoring a stale selection
4. instance-wide invalidation overreach
5. selected diff staying stale after tool invalidation
6. worktree-switch status races
7. unhandled rejection risk from async invalidation publication
8. queued invalidation intent being lost during in-flight refresh
9. `git-diff` path traversal / absolute path boundary issue

### Investigated and considered non-blocking within current intent

1. split add/delete presentation for tracked rename behavior
   - this was compared against VS Code behavior during manual testing
   - no stage/unstage corruption was observed in the tested flow
- this is currently treated as a representation tradeoff, not a proven
blocker

---

## Remaining non-blocker follow-up areas

The following are still reasonable follow-up topics, but they were not
part of the required blocker-fix scope.

1. normalize directory-to-worktree matching more aggressively on Windows
so tool invalidation works more reliably from nested directories or
path-format variations
2. improve keyboard discoverability of hover-revealed stage / unstage
actions
3. reserve textarea space for the overlaid commit button if the overlay
tradeoff is reconsidered
4. reduce size/complexity in:
   - `RightPanel.tsx`
   - `right-panel.css`
5. tighten raw SSE tool-event parsing into a more explicit helper if
that event bridge grows further

These follow-ups should not be interpreted as evidence that the core
implementation is incomplete unless a reviewer finds a new concrete
failure.

---

## Suggested review focus

If a gatekeeper or reviewer is evaluating this PR, the most useful focus
areas are:

1. whether staged / unstaged behavior is correct for normal Git
workflows
2. whether the new server worktree Git endpoints remain narrowly scoped
3. whether auto-refresh remains bounded to the active Git Changes
context
4. whether the explicit fixes for stale async behavior and invalidation
races are sufficient
5. whether any unintentional server boundary broadening or state
corruption remains

Less useful review topics, unless tied to a concrete failure, are:

1. preference disagreements with accepted prompt insertion format
2. preference disagreements with the overlaid commit button placement
3. preference disagreements with keeping polling fallback alongside tool
invalidation
4. objections to server-backed Git endpoints purely because they add
surface area

---

## Summary

This series intentionally evolves the existing Git Changes tab into a
more complete source-control workflow for:

1. grouped staged / unstaged inspection
2. section-aware diffs
3. per-file staging and unstaging
4. commit composition for staged changes
5. prompt-context insertion from Git diffs
6. bounded auto-refresh for both passive viewing and agent-driven file
mutations

The intended review standard is to find concrete correctness, layering,
or maintenance problems that remain after this series — not to re-argue
the already accepted product choices listed above.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-16 23:11:48 +01:00
Shantur Rathore
657e78da6a feat(electron): publish linux AppImage artifacts 2026-04-16 11:28:39 +01:00
Shantur Rathore
dee356558f docs: add SideCars README section 2026-04-16 09:59:53 +01:00
Shantur Rathore
03ed3d3b2c Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-04-16 08:43:33 +01:00
Shantur Rathore
a111de1af8 Minimum version to 0.14.0 2026-04-16 08:43:16 +01:00
Shantur Rathore
8a3b162be9 Bump version to 0.14.0 2026-04-16 08:42:33 +01:00
Shantur Rathore
c62cb3ce4a fix(server): share voice mode state across listeners 2026-04-13 21:36:49 +01:00
Shantur Rathore
d9811e735d fix(server): reject stale voice mode enables 2026-04-13 20:37:31 +01:00
Pascal André
1ce58b9dd9 fix(tauri): own Windows CLI subtree with a job object (#320)
## Summary
- Follow-up to #240 to make Windows desktop shutdown reliable this time,
even when the tracked CLI wrapper PID exits before its descendants
- Attach the spawned CLI process to a Windows Job Object with
`KILL_ON_JOB_CLOSE`, so the desktop app owns the whole subtree instead
of relying only on `taskkill /PID <wrapper> /T`
- Keep the current graceful-then-force shutdown path, but add a robust
OS-level fallback that reaps orphaned workspace processes when the
wrapper is already gone

## Root Cause
The previous Windows shutdown logic still depended on the PID tracked by
Tauri. In practice that PID can be a short-lived Node wrapper. Once that
wrapper exits, `taskkill` can report success or PID-not-found while
descendants remain alive, and the desktop app no longer has a reliable
handle to reap them.

## Validation
- `cargo check --manifest-path packages/tauri-app/src-tauri/Cargo.toml`
- `cargo build --release --manifest-path
packages/tauri-app/src-tauri/Cargo.toml`
- Manual local test: orphaned processes are cleaned up after desktop
shutdown
2026-04-12 21:10:15 +01:00
Pascal André
1907a4da03 perf(ui): virtualize message timeline rendering, #274 follow-up ( BIG SPEED IMPROVEMENT ) (#291)
## Summary
- virtualize MessageTimeline so large session histories stop rendering
the full timeline sidebar at once.
- keep the existing full render path in selection mode so xray/selection
behavior stays intact.
- route active-segment scrolling through the virtualizer so timeline
navigation still follows the selected message.

## Benefit
- prompt field was very laggy in cession with big history and timeline
had many bugs, this is fixed.
- the session with big history now load as fast as a new session .
2026-04-11 22:52:00 +01:00
Shantur Rathore
abf4c67fcc fix(ui): separate dictated prompt text 2026-04-11 20:34:53 +01:00
Shantur Rathore
bc130ceb5b fix(ui): portal timeline preview tooltip 2026-04-11 19:53:25 +01:00
Shantur Rathore
8505a43b16 fix(ui): add toggle for holding long assistant replies 2026-04-11 19:47:57 +01:00
Shantur Rathore
2a3329b5ed fix(ui): hold auto-follow on oversized assistant replies 2026-04-11 19:28:27 +01:00
VooDisss
c9c1cf21f0 fix(ui): stop forced auto-follow during streaming (#309)
# PR Draft: Fix sticky auto-scroll during streaming chat responses

Fixes #308

## Summary

This change makes chat auto-scroll easier to escape while assistant
output is still streaming.

The goal is to stop the viewport from repeatedly pulling the user back
toward the bottom once they begin scrolling upward to inspect earlier
content.

## Why

Before this change, streaming updates could keep reasserting
bottom-follow behavior during active rendering. That made auto-scroll
feel sticky and forced users to scroll repeatedly or forcefully just to
review earlier parts of an in-progress response.

The intended behavior is simpler: once the user scrolls upward to leave
follow mode, the UI should respect that decision instead of fighting it
during subsequent stream updates.

## What Changed

1. Removed render-time force-bottom behavior from the shared
follow-scroll helper path.
2. Updated streamed reasoning output to restore scroll without forcing
the viewport back to the bottom.
3. Updated streamed tool-call output to use the same non-forcing restore
behavior.

## Scope Boundaries

Included:

- Sticky auto-scroll behavior during streamed chat output
- Shared follow-scroll behavior used by streamed nested panes
- Reasoning and tool-call streaming paths that reused the same forced
follow behavior

Not included:

- A full rewrite of the virtualized message list follow model
- Broader scroll UX changes outside the streaming follow/escape behavior
- Unrelated UI or plugin configuration changes in the worktree

## Technical Notes

The core problem was not basic auto-scroll itself, but a render-time
path that could keep forcing bottom-follow behavior while new streamed
content was arriving.

That meant a user's attempt to scroll upward could be overridden
repeatedly by subsequent stream updates, which is why the auto-scroll
felt sticky. The fix removes that override and keeps render-time
restoration dependent on the current follow state instead.

## Files Changed

- `packages/ui/src/lib/follow-scroll.tsx`
- `packages/ui/src/components/message-block.tsx`
- `packages/ui/src/components/tool-call.tsx`

## Verification

Performed:

1. Reproduced the sticky auto-scroll behavior with a long multi-line
streaming response.
2. Verified that scrolling upward during streaming now disengages follow
more naturally in the affected streamed panes.
3. Ran `npm run typecheck --workspace @codenomad/ui`.
4. Ran `npm run build --workspace @codenomad/ui`.

Build note:

- The UI typecheck passes.
- The UI build succeeds.
- The build still emits existing third-party and chunk-size warnings
unrelated to this change.

## Risks and Follow-up

1. The broader scroll-follow model is still more heuristic-heavy than
ideal, so there may be future follow-up work to simplify it further.
2. This PR intentionally applies the smallest targeted fix to the known
snap-back path instead of rewriting the full chat scroll system.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-10 16:26:33 +01:00
Shantur Rathore
c7d4f99e48 fix(ui): prevent settings modal overflow on phones 2026-04-09 21:00:17 +01:00
Shantur Rathore
d50c00afb4 revert: remove debouncing and transparent window from zoom fix
Reverted debouncing logic and transparent window mode that were causing issues.
Kept the zoom step reduction from 0.2 to 0.1 for finer control.
2026-04-09 16:23:45 +01:00
Shantur Rathore
0ef57df3bc fix(ui): show token stats and simplify context window calculation
- Track messageInfoVersion in cache signature to rebuild when tokens arrive via SSE
- Read tokens from step-finish part directly (embedded in SSE events)
- Simplify available tokens to show full context window when no explicit input limit
2026-04-08 22:19:10 +01:00
Shantur Rathore
0739ec857c Reapply "fix(ui): support unified diff patch format in session changes viewer"
This reverts commit af6429162f.
2026-04-08 20:57:23 +01:00
Shantur Rathore
b060ab45ff Revert "feat(tauri): add zip bundle target for macOS and Windows"
This reverts commit 197898c01c.
2026-04-08 20:57:23 +01:00
Shantur Rathore
af6429162f Revert "fix(ui): support unified diff patch format in session changes viewer"
This reverts commit 2e9ee2cde6.
2026-04-08 20:57:12 +01:00
Shantur Rathore
2e9ee2cde6 fix(ui): support unified diff patch format in session changes viewer
Session diffs now use a compact patch field instead of storing full
before/after content. Added parsePatchToBeforeAfter utility to extract
before/after from unified diff format, and updated MonacoDiffViewer to
accept patch prop as alternative to before/after strings.
2026-04-08 20:48:13 +01:00
Shantur Rathore
d45c0b9367 fix(tauri): prevent Windows zoom freeze with debouncing and transparent window
- Add 50ms debounce to zoom operations to prevent WebView2 IPC bottleneck
- Enable transparent window mode for better Windows resize/zoom performance
- Reduce zoom step from 0.2 to 0.1 for finer control
2026-04-08 20:47:49 +01:00
Shantur Rathore
197898c01c feat(tauri): add zip bundle target for macOS and Windows
- Add build scripts for platform-specific builds with zip bundles
- Update CI workflow to use --bundles flag for explicit target selection
- macOS: use app,zip (removed dmg)
- Windows: use nsis,zip
- Linux: use appimage,deb,rpm
2026-04-08 20:34:08 +01:00
Shantur Rathore
0c0cfd2d22 fix(ui): keep speech input chained and scrolled to bottom 2026-04-08 19:02:06 +01:00
Shantur Rathore
5107ac207e feat(ui): show background process notify state 2026-04-08 16:09:17 +01:00
Shantur Rathore
1130066a33 feat(background-process): notify sessions when tasks end
Send synthetic session notifications when background processes finish, fail, stop, or terminate so the originating agent can react without polling. Hide synthetic text-only prompts from the UI stream so operational notifications stay out of the visible transcript.
2026-04-08 15:48:50 +01:00
Shantur Rathore
403a3ff189 Scroll fixes - Improve scroll to bottom handling for reasoning, bash and task tools (#288)
Fixes #286 and more
2026-04-04 15:11:45 +01:00
codenomadbot[bot]
7996e514c4 fix(ui): preserve prompt text when dismissing mention picker (#285)
## Summary
- preserve the current prompt text when dismissing the `@` mention/file
picker with `Esc`
- let `Enter` fall back to normal prompt submission when the mention
picker is open but there is no selectable result

## Verification
- source inspection of the prompt input and picker flow
- local `npm run typecheck --workspace @codenomad/ui` is blocked in this
environment because workspace dependencies are not installed

--
Yours,
[CodeNomadBot](https://github.com/NeuralNomadsAI/CodeNomad)

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-04 00:48:37 +01:00
Pascal André
141be2cde0 perf(ui): fix O(n²) reactive subscriptions in timeline effects (HUGE SPEED IMPROVEMENT) (#274)
## Summary

- Wraps store-proxied array iteration in `untrack()` in two
`createEffect` blocks and one `createMemo` in `message-section.tsx` to
prevent SolidJS from creating O(n) per-element reactive subscriptions on
every run
- Replaces `ids.includes()` with `Set.has()` for O(1) cleanup lookups in
the part-count tracking effect

## Problem

Two `createEffect` blocks in `message-section.tsx` iterate the
`messageIds()` store proxy array inside a tracked reactive context. This
causes SolidJS to create **O(n) per-element subscriptions** on every
run. When any element changes, all n subscriptions fire, re-running the
entire effect — resulting in **O(n²) total work**.

Additionally, the cleanup loop in the part-count tracking effect uses
`ids.includes(trackedId)` which is O(n) per tracked ID, compounding to
O(n²).

For long-running sessions with large message history (e.g. 7569
messages), this caused **~4.8 seconds of input latency** when sending a
new prompt.

## Fix

1. **Timeline sync effect (~line 738):** Wrap entire body in
`untrack()`, replace `ids.slice()` with `[...ids]` to snapshot without
proxy tracking
2. **Part-count tracking effect (~line 891):** Wrap iteration in
`untrack()`, replace `ids.includes()` with `new Set(ids).has()` for O(1)
lookups
3. **`lastAssistantIndex` memo:** Read message records via `untrack()`
to avoid O(n) subscriptions on part-level updates

## Result

On a 7569-message session: prompt input latency reduced from **~4.8s to
~42ms** (114x improvement).
2026-04-03 23:01:13 +01:00
codenomadbot[bot]
259d457209 fix(desktop): launch server with unrestricted root (#283)
## Summary
- launch the Electron-managed server with `--unrestricted-root` by
default
- launch the Tauri-managed server with `--unrestricted-root` by default
- stop relying on the server's `process.cwd()` fallback for desktop
filesystem browsing

--
Yours,
[CodeNomadBot](https://github.com/NeuralNomadsAI/CodeNomad)

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-03 16:47:34 +01:00
Shantur Rathore
d0a0325d7e feat(sidecars): add proxied sidecar tabs (#279)
## Summary
- add SideCar support across the server and UI, including proxied tabs,
picker/settings flows, and websocket-aware proxying
- unify top-level tab handling so workspace instances and SideCars share
the same tab model and navigation flows
- limit SideCars to port-based services only, removing server-managed
process control from the final API and UI

---------

Co-authored-by: Shantur <shantur@Mac.home>
Co-authored-by: Shantur <shantur@Shanturs-MacBook-Pro-M5.local>
2026-04-02 23:00:17 +01:00
Shantur Rathore
19a4c3df16 add remote server launcher flow (#277)
## Summary
- add a remote CodeNomad server launcher flow in the home screen,
including saved server profiles, probe-before-connect behavior, and
desktop bridge APIs for opening remote windows
- add Electron support for remote server windows with per-window origin
handling and self-signed certificate bypass, plus Tauri support for
remote windows with clearer self-signed guidance
- fix Tauri dev server resolution and window shutdown behavior so dev
mode prefers the source server entry and the app only exits after the
last window closes
2026-04-02 21:29:19 +01:00
Shantur Rathore
10506920ac fix electron remote tls exception scoping 2026-04-02 18:46:16 +01:00
Shantur Rathore
92c029d744 fix remote server keyboard and reconnect flows 2026-04-02 18:20:17 +01:00
Shantur Rathore
6eb3246d37 update tauri self-signed guidance 2026-04-02 17:18:23 +01:00
Shantur Rathore
5c90de84de fix tauri window shutdown behavior 2026-04-02 17:15:25 +01:00
Shantur Rathore
455a59f693 fix tauri dev server resolution 2026-04-02 17:10:10 +01:00
Shantur Rathore
a89da02d6b fix(tauri): stabilize dev CLI shell startup 2026-04-02 17:01:10 +01:00
Shantur Rathore
69d9e95bee add remote server launcher flow 2026-04-02 16:08:54 +01:00
bluelovers
893d5f9296 Add log level configuration support (#272)
Add log level configuration support via config.yaml and UI settings.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-02 11:12:33 +01:00
Shantur Rathore
e82e529a8f Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-04-01 23:16:33 +01:00
VooDisss
4f236ce36f Implement shared compact split and unified tool-call diff layout (#270)
# PR Title

Implement shared compact split and unified tool-call diff layout

---
Fixes #268 
# PR Description

## Summary

This PR makes tool-call diffs more compact in both `Unified` and `Split`
views by reducing wasted horizontal space in line-number gutters and
content indentation.

## What changed

- introduced a shared compact-diff framework for tool-call diffs
- kept mobile-specific policy limited to:
  - forcing unified mode below the breakpoint
  - enabling wrap only in mobile unified mode
- added mode-specific compact applicators in the diff viewer:
  - unified applicator
  - split applicator
- reduced gutter width waste by measuring rendered line-number text and
tightening column width around it
- removed unnecessary right-side content padding
- aligned `+` / `-` markers closer to the left edge across both views
- simplified cleanup after gatekeeper review by removing extra plumbing
and residue

## Screenshots

### Before

<img width="581" height="341" alt="image"
src="https://github.com/user-attachments/assets/ec47b256-749a-4afc-8879-aaf33f0b46b6"
/>

### After

<img width="470" height="586" alt="image"
src="https://github.com/user-attachments/assets/7258a5a2-47c4-408d-84bc-1b497761c7ad"
/>

## Architectural approach

This change intentionally uses:

- shared policy in
`packages/ui/src/components/tool-call/diff-render.tsx`
- shared helper/measurement logic in
`packages/ui/src/components/diff-viewer.tsx`
- mode-specific applicators where unified and split DOM differ
- CSS for shared visual spacing and alignment cleanup

The goal was to keep the implementation architecturally clean and avoid
building separate duplicated compact-diff features for:

- mobile vs desktop
- unified vs split

Instead, the feature shares one compact-diff concept and only diverges
where the upstream diff DOM requires separate handling.

## Files changed

- `packages/ui/src/components/tool-call/diff-render.tsx`
- `packages/ui/src/components/diff-viewer.tsx`
- `packages/ui/src/styles/messaging/tool-call.css`
- `packages/ui/src/types/message.ts`

## Validation

Manual validation was performed in the running UI.

Verified manually:

- compact unified gutters on mobile
- compact unified gutters on desktop
- compact split gutters on desktop
- tighter operator alignment in both modes

Also verified:

- `npm run typecheck` passes

## Notes

- This PR is intended to address the compact diff layout problem
described in the related issue.
- Diff-specific CSS still lives in `tool-call.css`; future extraction
into a smaller dedicated stylesheet is possible but not required for
this change.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-01 23:13:32 +01:00
Shantur Rathore
2ffeb45a9c fix(workflows): recheck non-dev PR authorization by author 2026-04-01 23:11:25 +01:00
Shantur Rathore
df16b64a95 Merge remote-tracking branch 'origin/main' into dev 2026-04-01 22:13:57 +01:00
VooDisss
f3c54df283 fix(server): show sane remote URLs for 0.0.0.0 binds (#262)
Closes #261

## Summary

- improve startup remote URL selection when the server binds to
`0.0.0.0`
- print additional reachable remote URLs instead of advertising only the
first external address
- add targeted tests for address ordering and advertisability behavior

## Problem

When CodeNomad was started with `--host 0.0.0.0`, the CLI chose the
first external IPv4 address it discovered and displayed only that one as
the remote URL.

On Windows machines with WSL, Hyper-V, Docker, or other virtual
adapters, that often surfaced a virtual `172.x.x.x` address even though
a more useful LAN address such as `192.168.x.x` was also reachable and
usable from other devices.

That made remote access look broken or confusing even though the server
itself was accessible.

## What changed

- reuse the resolved network-address list for both:
  - primary remote URL selection
  - startup logging of additional reachable URLs
- choose the primary remote URL from the **advertisable** external
addresses instead of any external address
- print `Other Accessible URLs` when multiple useful remote URLs are
available
- avoid hard-coding a preference like `192.168 > 10 > 172`
- suppress link-local `169.254.*` addresses from user-facing advertised
URLs
- add tests covering:
  - stable ordering across RFC1918 address ranges
  - link-local addresses being non-advertisable
  - link-local-first discovery not stealing the primary LAN URL

## Why this approach

This keeps address derivation in the network-address resolver layer and
limits `index.ts` to startup wiring and presentation.

It also fixes the misleading terminal output without redesigning binding
behavior, TLS behavior, or the server API contract.

## Validation

- `npm run typecheck --workspace @neuralnomads/codenomad`
- `npx tsx --test
'.\\src\\server\\__tests__\\network-addresses.test.ts'`

## Notes

- this change is intentionally focused on selection and presentation of
reachable addresses
- it does not attempt a broader virtual-adapter classification policy
beyond suppressing clearly low-value link-local addresses in user-facing
output

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-01 22:12:28 +01:00
Shantur Rathore
5658a9f62d Merge remote-tracking branch 'origin/main' into dev 2026-04-01 21:35:09 +01:00
Shantur Rathore
9d6a5bcdc0 Refresh README with modern marketing-focused layout 2026-03-31 22:53:58 +01:00
Shantur Rathore
514b187b00 Update Worker caching 2026-03-31 22:51:04 +01:00
Shantur Rathore
240acb7729 Update README 2026-03-31 22:50:40 +01:00
Shantur Rathore
278b563c1a Release 0.13.3 - Voice conversation mode, File editing, YOLO mode (#264)
## Thanks for contributions
- PR #252 “feat: Enable file editing and saving” by @jchadwick
- PR #256 “feat(ui): add session yolo mode controls” by @pascalandr
- PR #257 “fix(tauri): sync native app version with package releases” by
@pascalandr
- PR #258 “fix(tauri): stop stale UI assets from shadowing desktop
builds” by @pascalandr
- PR #260 “fix(ui): escape raw HTML in user prompt messages” by
@app/codenomadbot

## Highlights
- **Edit and save files directly in CodeNomad**: Update workspace files
in the built-in editor, save them without leaving the app, and get safer
handling for unsaved changes or edit conflicts.
- **More control over session automation**: Turn on per-session YOLO
mode from the Status tab, keep it visible with a clear badge, and let
long-running sessions continue auto-accepting prompts as expected.
- **Better voice conversation options**: Use spoken summary mode for
replies and keep conversation speech settings isolated per client, so
one device’s voice preferences do not unexpectedly affect another.
- **Faster session recovery**: Reload a session transcript from the
sidebar and see when a session is retrying, including live status
feedback.

## What’s Improved
- **Smoother desktop setup**: Desktop builds now bundle the right CLI
resources and handle microphone access more cleanly.
- **More reliable cross-platform desktop behavior**: Windows process
handling and npm invocation are safer, reducing environment-specific
issues.
- **Clearer session status visibility**: Retrying sessions now show more
useful state in the sidebar and header, so it is easier to tell what is
happening.
- **Cleaner in-app feedback**: Long toast messages wrap properly, GitHub
star counts display more cleanly, and message/code rendering behaves
more predictably.

## Fixes
- **Safer prompt rendering**: Raw HTML in user prompts is escaped so
messages display safely instead of being interpreted.
- **More reliable code previews**: Incomplete syntax highlighting
results are no longer cached, which helps prevent broken-looking file
views.
- **Better voice handoff**: Conversation playback stops when voice input
starts, avoiding overlapping speech.
- **More dependable desktop releases**: Native app versions now stay
aligned with package releases, and stale UI assets no longer shadow new
desktop builds.

### Contributors
- @jchadwick
- @pascalandr
2026-03-31 20:33:43 +01:00
Shantur Rathore
0af79002ed Min version 0.13.3 2026-03-31 20:16:35 +01:00
Shantur Rathore
f3981a1cce Bump version to 0.13.3 2026-03-31 20:15:25 +01:00
Shantur Rathore
031e8d5717 Fix bumpVersion script for both npm and tauri 2026-03-31 20:15:16 +01:00
Shantur
995fb3b6a3 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-03-31 19:35:28 +01:00
Shantur
aeb0ff11b3 fix(ui): stop conversation speech when voice input starts 2026-03-31 18:59:52 +01:00
Shantur
b61cfbd9f9 fix(ui): refine GitHub stars display 2026-03-31 18:51:53 +01:00
Shantur
481dd1a88a fix(ui): wrap long toast messages
Constrain toast titles and bodies so long retry and error messages wrap inside the notification card instead of overflowing past the container.
2026-03-31 18:41:32 +01:00
Shantur
3f6cdd36f3 feat(ui): surface retrying session status
Preserve retry metadata from session.status events so the session list and header can show a live retry countdown with context. Notify users when a session enters retry and reuse the existing error styling so retrying feels actionable without losing the current badge layout.
2026-03-31 18:38:54 +01:00
Shantur
fe932c8307 fix(ui): avoid caching incomplete code highlighting
Only cache markdown HTML after Shiki has the required fence languages loaded so virtualized assistant messages can re-render with syntax highlighting when remounted.
2026-03-31 15:18:44 +01:00
Pascal André
64ac885157 feat(ui): add session yolo mode controls (#256)
## Summary
- add a per-session Yolo mode toggle for permission prompts and persist
its state
- move the control into the Status tab with clearer copy, an info
tooltip, and a visible header badge when it is enabled
- auto-accept queued permissions for any yolo-enabled session in the
instance, not only the currently focused session

## Why
- keeps this risky mode explicit and easy to audit from the session
status area
- matches the expected multi-session desktop behavior when several
sessions stay active in parallel

## Testing
- npm run typecheck --workspace @codenomad/ui
- npm run build --workspace @codenomad/ui

Closes #18
2026-03-31 14:46:20 +01:00
Shantur
1d953dfe64 feat(ui): add session reload action
Let users refresh a session transcript from the sidebar without reopening it. Reuse the existing forced message loading path so the reload behavior stays aligned with normal session hydration.
2026-03-31 14:32:45 +01:00
Shantur
42589464e5 feat(voice): support per-client conversation mode state 2026-03-31 12:39:29 +01:00
Shantur
197dee2aea Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-03-31 00:22:32 +01:00
Shantur
045d8da8b2 feat(voice): add spoken summary mode for conversation replies 2026-03-31 00:20:26 +01:00
Pascal André
c9bd4b7395 fix(tauri): stop stale UI assets from shadowing desktop builds (#258)
## Summary
- prefer the bundled desktop UI over the downloaded cache when both
report the same version, so rebuilt installers do not keep serving stale
frontend assets
- rebuild the server workspace during the Tauri prebuild step on every
desktop package build, matching Electron's correctness boundary for
fresh UI/server assets
- add a regression test covering the equal-version bundled-vs-downloaded
UI selection path

## Why
- local desktop rebuilds should reflect the latest server and UI code
without requiring users to manually clear cached assets
- packaged updates should keep favoring the freshly bundled frontend
when the cached copy is not actually newer

## Testing
- node --import tsx --test
packages/server/src/ui/__tests__/remote-ui.test.ts
- npm run build:tauri
2026-03-30 20:54:29 +01:00
Pascal André
41a5026331 fix(tauri): sync native app version with package releases (#257)
## Summary
- sync the Tauri native version metadata from
`packages/tauri-app/package.json` so release builds pick up workspace
version bumps like `0.13.1`
- update the checked-in Tauri `Cargo.toml` and `tauri.conf.json`
versions from `0.12.3` to `0.13.1`
- document the prebuild sync behavior in `BUILD.md`

## Testing
- `node packages/tauri-app/scripts/sync-tauri-version.js`
2026-03-30 20:52:37 +01:00
codenomadbot[bot]
d1a27ac31b fix(ui): escape raw HTML in user prompt messages (#260)
## Summary
- escape raw HTML when rendering user message markdown so prompt input
is shown as text instead of injected HTML
- keep assistant and tool markdown behavior unchanged by scoping the
escape behavior to user messages
- update markdown cache keys so escaped and non-escaped render output do
not collide

## Verification
- `npm run typecheck --workspace @codenomad/ui` *(fails in this
workspace because frontend dependencies are not installed)*
- `npm run build --workspace @codenomad/ui` *(fails in this workspace
because `vite` is not installed)*

--
Yours,
[CodeNomadBot](https://github.com/NeuralNomadsAI/CodeNomad)

Co-authored-by: Shantur <shantur@Mac.home>
2026-03-30 08:48:52 +01:00
Jess Chadwick
37b3f85e61 feat: Enable file editing and saving (#252)
## Summary
- Adds file writing capability to Monaco editor in the file viewer
- Implements writeFile API on the server for workspace files
- Integrates save functionality into the file viewer UI with proper
state management

## Bug Fixes (Review Feedback)
- Fixed failed save discarding edits when switching files - now checks
save result and only proceeds if successful
- Fixed refresh overwriting dirty editor state - now prompts for
confirmation before discarding edits
- Fixed save button unable to save empty files - changed check from `if
(content)` to `if (content !== undefined && content !== null)`
- Added agent edit conflict detection - when agent edits file while user
has unsaved changes, shows conflict dialog with Overwrite/Cancel options
- Fixed dialog appearing behind unpinned sidebar - increased alert
dialog z-index to z-100

## Related Issues
- Closes #251

---------

Co-authored-by: Jess Chadwick <jchadwick@gmail.com>
2026-03-29 22:41:11 +01:00
Shantur
55a6479c0e fix(electron): use safe npm invocation on windows 2026-03-29 09:47:58 +01:00
Shantur Rathore
f88064af06 fix(desktop): bundle CLI resources and request mic access 2026-03-28 15:30:14 +00:00
Shantur Rathore
27bccb8d6b Release v0.13.1 - Voice mode, Super speedy streaming, and a lot more (#255)
## Thanks for contributions

- PR [#249](https://github.com/NeuralNomadsAI/CodeNomad/pull/249)
"feat(speech): add prompt voice input" by
[@shantur](https://github.com/shantur)
- PR [#243](https://github.com/NeuralNomadsAI/CodeNomad/pull/243)
"feat(i18n): Hebrew locale + full RTL support" by
[@MusiCode1](https://github.com/MusiCode1)
- PR [#241](https://github.com/NeuralNomadsAI/CodeNomad/pull/241)
"feat(lazy loading): Implement virtual list with virtua" by
[@pixellos](https://github.com/pixellos)
- PR [#240](https://github.com/NeuralNomadsAI/CodeNomad/pull/240)
"fix(tauri): force Windows process tree shutdown" by
[@pascalandr](https://github.com/pascalandr)
- PR [#239](https://github.com/NeuralNomadsAI/CodeNomad/pull/239)
"perf(ui): split right panel and secondary viewer chunks" by
[@pascalandr](https://github.com/pascalandr)
- PR [#238](https://github.com/NeuralNomadsAI/CodeNomad/pull/238)
"perf(ui): defer locale and overlay bundles" by
[@pascalandr](https://github.com/pascalandr)
- PR [#236](https://github.com/NeuralNomadsAI/CodeNomad/pull/236)
"Suppress OS notifications for subagent (child) sessions" by
`@app/codenomadbot`
- PR [#235](https://github.com/NeuralNomadsAI/CodeNomad/pull/235)
"fix(ui): unwrap pasted placeholders in slash commands" by
`@app/codenomadbot`
- PR [#232](https://github.com/NeuralNomadsAI/CodeNomad/pull/232)
"fix(tauri): stop CLI process group on exit" by `@app/codenomadbot`
- PR [#229](https://github.com/NeuralNomadsAI/CodeNomad/pull/229)
"feat(ui): add RTL support for Hebrew/Arabic text" by
[@MusiCode1](https://github.com/MusiCode1)
- PR [#227](https://github.com/NeuralNomadsAI/CodeNomad/pull/227)
"fix(tauri): improve Windows desktop runtime behavior" by
[@pascalandr](https://github.com/pascalandr)
- PR [#226](https://github.com/NeuralNomadsAI/CodeNomad/pull/226)
"fix(tauri): restore desktop menu controls and fullscreen shortcut" by
[@pascalandr](https://github.com/pascalandr)
- PR [#225](https://github.com/NeuralNomadsAI/CodeNomad/pull/225)
"fix(tauri): restore external links in the folder picker" by
[@pascalandr](https://github.com/pascalandr)
- PR [#224](https://github.com/NeuralNomadsAI/CodeNomad/pull/224)
"fix(tauri): sync server UI bundle during prebuild" by
[@pascalandr](https://github.com/pascalandr)
- PR [#215](https://github.com/NeuralNomadsAI/CodeNomad/pull/215)
"perf(ui): lazy-load markdown and defer diff rendering" by
[@pascalandr](https://github.com/pascalandr)

## Highlights

- **Voice-first conversations**: Start prompts with voice input,
configure speech behavior from settings, and listen back to assistant
responses with message playback and conversation playback controls.
- **A complete Hebrew + RTL experience**: CodeNomad now ships with a
full Hebrew locale and much broader right-to-left support, making the
app feel natural for Hebrew users while improving Arabic text rendering
too.
- **A much faster experience in long chats**: The new virtualized
message list, deferred markdown and diff rendering, and more selective
loading for heavy UI surfaces make large sessions feel noticeably
smoother.

## What's Improved

- **More flexible speech controls**: Speech settings and playback modes
now adapt better to different browsers and platform capabilities.
- **Cleaner prompt workflow**: The prompt includes a quick clear action,
a simpler recording indicator, and a more polished mic control layout.
- **Faster startup and lighter heavy views**: Locale bundles, overlays,
right-panel viewers, picker flows, markdown, and diff surfaces all load
more lazily to reduce upfront UI work.
- **Less notification spam**: Subagent sessions no longer fire OS
notifications, so important interruptions are easier to notice.
- **Better RTL behavior across the whole interface**: Session names,
tool outputs, markdown blocks, file views, selectors, and layout
controls behave more consistently in right-to-left contexts.

## Fixes

- **More reliable Windows desktop behavior**: Process cleanup is
stronger during app shutdown, background CLI process trees are
terminated more reliably, desktop identity/metadata is aligned more
cleanly, and stray console windows are hidden during startup and exit.
- **Cleaner shutdown on macOS and Linux**: Desktop quit/close now stops
the spawned CLI process group more reliably, reducing leftover
background processes after exit.
- **Restored desktop actions**: External links in the folder picker work
again, and the desktop View/Window controls plus the fullscreen shortcut
are back.
- **More stable streaming and scrolling**: Reasoning streams stay pinned
more consistently, follow behavior is less jumpy, spacing is cleaner in
virtualized conversations, and session switching retains position more
smoothly.
- **Safer slash command pasting**: Pasted placeholders are resolved
correctly before slash commands run, so long pasted inputs behave like
normal prompts.
- **More dependable desktop packaging**: Tauri prebuild now refreshes
the server UI bundle correctly, which avoids packaged desktop builds
picking up stale UI assets.
- **Clearer speech compatibility handling**: Streaming playback
limitations are surfaced more cleanly instead of failing in a confusing
way.

### Contributors

- [@pascalandr](https://github.com/pascalandr)
- [@MusiCode1](https://github.com/MusiCode1)
- [@pixellos](https://github.com/pixellos)
2026-03-27 19:58:35 +00:00
Shantur Rathore
1b4eff9419 Min version 0.13.1 2026-03-27 19:46:54 +00:00
Shantur Rathore
6c1febf50e Bump to v0.13.1 2026-03-27 19:46:12 +00:00
Shantur Rathore
75622ef366 refactor(ui): simplify prompt recording indicator 2026-03-27 19:45:56 +00:00
Shantur Rathore
864f913e3e feat(ui): add assistant conversation playback mode 2026-03-27 19:17:25 +00:00
Shantur Rathore
b7d4f8f869 feat(ui): add clear action to prompt input 2026-03-26 23:10:02 +00:00
Shantur Rathore
153065d025 Merge pull request #214 from Pagecran/ready/tauri-auth-cookie-isolation
fix(tauri): isolate desktop auth cookies per app
2026-03-15 17:53:06 +00:00
Pascal André
2abda0e6b4 fix(desktop): isolate Electron auth cookies per app
Make the legacy Electron desktop client generate and pass a per-launch auth cookie name too, so parallel desktop instances stop clobbering each other's localhost session cookie just like the Tauri client.
2026-03-15 09:38:00 +01:00
Pascal André
800133361d fix(tauri): remove stray perf emission from auth cookie PR
Drop the startup instrumentation call that leaked into the auth-cookie isolation branch. The helper is not defined on this PR branch, and the PR does not need to serialize the generated cookie name to fix the multi-instance auth collision.
2026-03-15 01:10:05 +01:00
Pascal André
034cb5dea9 fix(tauri): isolate desktop auth cookies per app 2026-03-14 23:31:46 +01:00
Shantur Rathore
d7ab84f245 Merge pull request #213 from NeuralNomadsAI/dev
Release v0.12.3
2026-03-13 21:27:30 +00:00
Shantur Rathore
201988b97c Merge pull request #205 from NeuralNomadsAI/dev
Release v0.12.1 - Histogram, bulk delete, snappier long sessions and more
2026-03-04 10:42:43 +00:00
Shantur Rathore
6a6fcff2c8 Merge pull request #195 from NeuralNomadsAI/dev
Release v0.11.4 - Mobile Fullscreen mode and lots of improvements
2026-02-22 17:15:22 +00:00
Shantur Rathore
f29f197b9a Merge pull request #177 from NeuralNomadsAI/dev
v0.11.1 Release - Latest OC Support, Improved file/folder picker, Dev Releases and lot more
2026-02-16 16:31:17 +00:00
Shantur Rathore
dbde403b3e Merge pull request #150 from NeuralNomadsAI/dev
Release v0.10.3 - Viewer for Changes, Git Diff and workspace files along with UX fixes
2026-02-11 16:09:49 +00:00
Shantur Rathore
230c981cc2 Merge pull request #134 from NeuralNomadsAI/dev
Release v0.10.2
2026-02-09 01:08:06 +00:00
Shantur Rathore
34978c87fb Merge pull request #125 from NeuralNomadsAI/dev
Release v0.10.1 - Worktrees, HTTPS, PWA and more
2026-02-08 18:07:08 +00:00
Shantur Rathore
3e6d0a402c Merge pull request #116 from NeuralNomadsAI/dev
Release v0.9.4 - Context manipulation, Session search, Themes and more
2026-02-03 20:26:17 +00:00
Shantur Rathore
e81c5f6443 Merge pull request #105 from NeuralNomadsAI/dev
Release v0.9.3 -  Tauri fixes, Skip Auth, Better Question tool and more
2026-01-30 09:18:20 +00:00
Shantur Rathore
b0d27bd127 Merge pull request #99 from NeuralNomadsAI/dev
Release v0.9.2 - Model Favourites and Multi-Lang UI
2026-01-26 21:02:29 +00:00
Shantur Rathore
7576470295 Merge pull request #96 from NeuralNomadsAI/dev
Release v0.9.1 - Thinking variant, Robust process cleanup
2026-01-25 18:08:18 +00:00
Shantur Rathore
6d32e09db0 Merge pull request #94 from NeuralNomadsAI/dev
Release 0.9.0
2026-01-24 16:47:37 +00:00
Shantur Rathore
503cb3a02e Merge pull request #91 from NeuralNomadsAI/dev
Release v0.8.1 - Support apply_patch tool
2026-01-22 23:07:37 +00:00
Shantur Rathore
0250c6350f Merge pull request #89 from NeuralNomadsAI/dev
Change minVersion to 0.8.0
2026-01-22 19:17:20 +00:00
Shantur Rathore
24cc8fe939 Merge pull request #88 from NeuralNomadsAI/dev
Release v0.8.0 - Auto update UI and more fixes
2026-01-22 18:58:51 +00:00
Shantur Rathore
282b234a7c Merge pull request #87 from NeuralNomadsAI/dev
Release 0.7.6 - Question tool fixes + Split test
2026-01-22 17:20:19 +00:00
Shantur Rathore
4ba088a876 Merge pull request #82 from NeuralNomadsAI/dev
Release 0.7.5
2026-01-21 12:27:47 +00:00
Shantur Rathore
7b1817d606 Merge pull request #80 from NeuralNomadsAI/dev
Release 0.7.4
2026-01-20 19:30:19 +00:00
Shantur Rathore
5bc3c23ec5 Merge pull request #79 from NeuralNomadsAI/dev
Release 0.7.3 - Bug fixes and minor improvements
2026-01-20 18:53:39 +00:00
Shantur Rathore
127a51e3c3 Merge pull request #72 from NeuralNomadsAI/dev
Release v0.7.2 - Test1
2026-01-15 20:59:06 +00:00
Shantur Rathore
daa22b6d8c Merge pull request #68 from NeuralNomadsAI/dev
Release v0.7.1
2026-01-15 08:42:55 +00:00
Shantur Rathore
23f2de2d7e Merge pull request #66 from NeuralNomadsAI/dev
Actually Release 0.7.0
2026-01-14 21:56:13 +00:00
Shantur Rathore
80c9b76709 Merge pull request #65 from NeuralNomadsAI/dev
Release v0.7.0
2026-01-14 21:46:38 +00:00
Shantur Rathore
a29b77d60b Merge pull request #59 from NeuralNomadsAI/dev
v0.6.0 Release
2026-01-09 21:55:50 +00:00
243 changed files with 20077 additions and 2495 deletions

View File

@@ -53,7 +53,7 @@ on:
# least-privilege (e.g. dev CI uses read-only; releases grant write).
env:
NODE_VERSION: 20
NODE_VERSION: 22
jobs:
build-macos:
@@ -212,7 +212,7 @@ jobs:
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/electron-app/release/*.zip; do
for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
@@ -313,7 +313,7 @@ jobs:
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/electron-app/release/*.zip; do
for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
@@ -324,7 +324,9 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
path: packages/electron-app/release/*.zip
path: |
packages/electron-app/release/*.zip
packages/electron-app/release/*.AppImage
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error
@@ -370,7 +372,7 @@ jobs:
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-x64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
npm install @tauri-apps/cli@2.10.1 @tauri-apps/cli-darwin-x64@2.10.1 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
@@ -454,7 +456,7 @@ jobs:
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-arm64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
npm install @tauri-apps/cli@2.10.1 @tauri-apps/cli-darwin-arm64@2.10.1 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
@@ -540,7 +542,7 @@ jobs:
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-win32-x64-msvc@2.9.4 --no-save --no-audit --no-fund --workspaces=false
npm install @tauri-apps/cli@2.10.1 @tauri-apps/cli-win32-x64-msvc@2.10.1 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
@@ -612,6 +614,7 @@ jobs:
sudo apt-get install -y \
build-essential \
pkg-config \
xdg-utils \
libgtk-3-dev \
libglib2.0-dev \
libwebkit2gtk-4.1-dev \
@@ -640,6 +643,7 @@ jobs:
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
# Tauri CLI 2.10.1 regresses Linux AppImage bundling in CI; keep Linux on the last known-good CLI.
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-linux-x64-gnu@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
@@ -739,6 +743,7 @@ jobs:
sudo apt-get install -y \
build-essential \
pkg-config \
xdg-utils \
gcc-aarch64-linux-gnu \
g++-aarch64-linux-gnu \
libgtk-3-dev:arm64 \

View File

@@ -4,6 +4,7 @@ on:
pull_request_target:
types:
- opened
- edited
- synchronize
- reopened
- ready_for_review
@@ -19,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
ACTOR: ${{ github.actor }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
IS_DRAFT: ${{ github.event.pull_request.draft }}
PR_NUMBER: ${{ github.event.pull_request.number }}
@@ -37,7 +38,7 @@ jobs:
fi
normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${ACTOR},"* ]]; then
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
else
echo "allowed=false" >> "$GITHUB_OUTPUT"

View File

@@ -46,7 +46,8 @@ jobs:
publish:
runs-on: ubuntu-latest
env:
NODE_VERSION: 20
NODE_VERSION: 22
PUBLISH_NPM_VERSION: 11.5.1
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -59,8 +60,15 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
registry-url: https://registry.npmjs.org
- name: Ensure npm >=11.5.1
run: npm install -g npm@latest
- name: Prepare pinned npm CLI
shell: bash
run: |
set -euo pipefail
tool_dir="$RUNNER_TEMP/publish-npm"
mkdir -p "$tool_dir"
npm install --prefix "$tool_dir" "npm@${PUBLISH_NPM_VERSION}" --no-audit --no-fund
echo "$tool_dir/node_modules/npm/bin" >> "$GITHUB_PATH"
"$tool_dir/node_modules/npm/bin/npm-cli.js" --version
- name: Install dependencies
run: npm ci --workspaces

View File

@@ -4,6 +4,7 @@ on:
pull_request:
types:
- opened
- edited
- synchronize
- reopened
- ready_for_review
@@ -23,7 +24,7 @@ jobs:
allowed: ${{ steps.auth.outputs.allowed }}
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
ACTOR: ${{ github.actor }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
steps:
- name: Check PR authorization
@@ -37,11 +38,11 @@ jobs:
fi
normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${ACTOR},"* ]]; then
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
else
echo "allowed=false" >> "$GITHUB_OUTPUT"
echo "Skipping builds for unauthorized PR targeting $BASE_REF" >&2
echo "Skipping builds for PR by unauthorized author targeting $BASE_REF" >&2
fi
build:

View File

@@ -14,7 +14,7 @@ permissions:
contents: read
env:
NODE_VERSION: 20
NODE_VERSION: 22
jobs:
release-ui:

View File

@@ -4,6 +4,7 @@ on:
pull_request_target:
types:
- opened
- edited
- reopened
- synchronize
@@ -17,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
ACTOR: ${{ github.actor }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
steps:
@@ -27,7 +28,7 @@ jobs:
run: |
set -euo pipefail
normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${ACTOR},"* ]]; then
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
echo "authorized=true" >> "$GITHUB_OUTPUT"
else
echo "authorized=false" >> "$GITHUB_OUTPUT"
@@ -50,5 +51,5 @@ jobs:
- name: Fail unauthorized PR
if: ${{ steps.auth.outputs.authorized != 'true' }}
run: |
echo "Actor $ACTOR is not allowed to open PRs targeting $BASE_REF" >&2
echo "PR author $PR_AUTHOR is not allowed to open PRs targeting $BASE_REF" >&2
exit 1

View File

@@ -39,7 +39,7 @@ permissions:
contents: write
env:
NODE_VERSION: 20
NODE_VERSION: 22
jobs:
prepare-release:

224
README.md
View File

@@ -1,128 +1,182 @@
# CodeNomad
## A fast, multi-instance workspace for running OpenCode sessions.
## The AI Coding Cockpit for OpenCode
CodeNomad is built for people who live inside OpenCode for hours on end and need a cockpit, not a kiosk. It delivers a premium, low-latency workspace that favors speed, clarity, and direct control.
CodeNomad transforms OpenCode from a terminal tool into a **premium desktop workspace** — built for developers who live inside AI coding sessions for hours and need control, speed, and clarity.
> OpenCode gives you the engine. CodeNomad gives you the cockpit.
![Multi-instance workspace](docs/screenshots/newSession.png)
_Manage multiple OpenCode sessions side-by-side._
<details>
<summary>📸 More Screenshots</summary>
---
![Command palette overlay](docs/screenshots/command-palette.png)
_Global command palette for keyboard-first control._
## Features
![Image Previews](docs/screenshots/image-previews.png)
_Rich media previews for images and assets._
- **🚀 Multi-Instance Workspace**
- **🌐 Remote Access**
- **🧠 Session Management**
- **🎙️ Voice Input & Speech**
- **🌳 Git Worktrees**
- **💬 Rich Message Experience**
- **🧩 SideCars**
- **⌨️ Command Palette**
- **📁 File System Browser**
- **🔐 Authentication & Security**
- **🔔 Notifications**
- **🎨 Theming**
- **🌍 Internationalization**
![Browser Support](docs/screenshots/browser-support.png)
_Browser support via CodeNomad Server._
</details>
---
## Getting Started
Choose the way that fits your workflow:
### 🖥️ Desktop App
### 🖥️ Desktop App (Recommended)
The best experience. A native application (Electron-based) with global shortcuts, deeper system integration, and a dedicated window.
Available as both Electron and Tauri builds — choose based on your preference.
- **Download**: Grab the latest installer for macOS, Windows, or Linux from the [Releases Page](https://github.com/shantur/CodeNomad/releases).
- **Run**: Install and launch like any other app.
Download the latest installer for your platform from [Releases](https://github.com/shantur/CodeNomad/releases).
### 🦀 Tauri App (Experimental)
We are also working on a lightweight, high-performance version built with [Tauri](https://tauri.app). It is currently in active development.
- **Download**: Experimental builds are available on the [Releases Page](https://github.com/shantur/CodeNomad/releases).
- **Source**: Check out `packages/tauri-app` if you're interested in contributing.
| Platform | Formats |
|----------|---------|
| macOS | DMG, ZIP (Universal: Intel + Apple Silicon) |
| Windows | NSIS Installer, ZIP (x64, ARM64) |
| Linux | AppImage, deb, tar.gz (x64, ARM64) |
### 💻 CodeNomad Server
Run CodeNomad as a local server and access it via your web browser. Perfect for remote development (SSH/VPN) or running as a service.
Run as a local server and access via browser. Perfect for remote development.
```bash
npx @neuralnomads/codenomad --launch
```
Full server/CLI documentation (flags + env vars, TLS, auth, remote access):
- [packages/server/README.md](packages/server/README.md)
To see all available options:
```bash
npx @neuralnomads/codenomad --help
```
See [Server Documentation](packages/server/README.md) for flags, TLS, auth, and remote access.
### 🧪 Dev Releases
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
Bleeding-edge builds from the `dev` branch:
```bash
npx @neuralnomads/codenomad-dev --launch
```
## Highlights
---
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
- **Long-Session Native**: Scroll through massive transcripts without hitches.
- **Command Palette**: A single global palette to jump tabs, launch tools, and control everything.
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing flow.
## SideCars
SideCars let you open local web tools inside CodeNomad as tabs.
<details>
<summary><strong>Configuration</strong></summary>
- **Name**: Display name used in CodeNomad
- **Port**: Local HTTP or HTTPS service running on `127.0.0.1:<port>`
- **Base path**: Mounted under `/sidecars/:id`
- **Prefix mode**:
- **Preserve prefix** forwards the full `/sidecars/:id/...` path upstream
- **Strip prefix** removes `/sidecars/:id` before forwarding the request upstream
</details>
<details>
<summary><strong>VSCode (OpenVSCode Server)</strong></summary>
Run with Docker:
```bash
docker run -it --init -p 8000:3000 -v "${HOME}:${HOME}:cached" -e HOME=${HOME} gitpod/openvscode-server --server-base-path /sidecars/vscode
```
Add SideCar as:
- **Name**: `VSCode`
- **Port**: `http://127.0.0.1:8000`
- **Base path**: `/sidecars/vscode`
- **Prefix mode**: `Preserve prefix`
</details>
<details>
<summary><strong>Terminal (ttyd)</strong></summary>
Run with:
```bash
ttyd --writable zsh
```
Add SideCar as:
- **Name**: `Terminal`
- **Port**: `http://127.0.0.1:7681`
- **Base path**: `/sidecars/terminal`
- **Prefix mode**: `Strip prefix`
</details>
---
## Requirements
- **[OpenCode CLI](https://opencode.ai)**: Must be installed and available in your `PATH`.
- **Node.js 18+**: Required if running the CLI server or building from source.
- **[OpenCode CLI](https://opencode.ai)** — must be installed and in your `PATH`
- **Node.js 18+** — for server mode or building from source
## Troubleshooting
---
### macOS says the app is damaged
If macOS reports that "CodeNomad.app is damaged and can't be opened," Gatekeeper flagged the download because the app is not yet notarized. You can clear the quarantine flag after moving CodeNomad into `/Applications`:
## Development
```bash
xattr -l /Applications/CodeNomad.app
xattr -dr com.apple.quarantine /Applications/CodeNomad.app
```
After removing the quarantine attribute, launch the app normally. On Intel Macs you may also need to approve CodeNomad from **System Settings → Privacy & Security** the first time you run it.
### Linux (Wayland + NVIDIA): Tauri AppImage closes immediately
On some Wayland compositor + NVIDIA driver setups, WebKitGTK can fail to initialize its DMA-BUF/GBM path and the Tauri build may exit right away.
Try running with one of these environment variables:
```bash
# Most reliable workaround (can reduce rendering performance)
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
# Alternative for some Wayland setups
__NV_DISABLE_EXPLICIT_SYNC=1 codenomad
```
If you're running the Tauri AppImage and want the workaround applied every time, create a tiny wrapper script on your `PATH`:
```bash
#!/bin/bash
export WEBKIT_DISABLE_DMABUF_RENDERER=1
exec ~/.local/share/bauh/appimage/installed/codenomad/CodeNomad-Tauri-0.4.0-linux-x64.AppImage "$@"
```
Upstream tracking: https://github.com/tauri-apps/tauri/issues/10702
## Architecture & Development
CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:
CodeNomad is a monorepo built with:
| Package | Description |
|---------|-------------|
| **[packages/electron-app](packages/electron-app/README.md)** | The native desktop application shell. Wraps the UI and Server. |
| **[packages/server](packages/server/README.md)** | The core logic and CLI. Manages workspaces, proxies OpenCode, and serves the API. |
| **[packages/ui](packages/ui/README.md)** | The SolidJS-based frontend. Fast, reactive, and beautiful. |
| **[packages/server](packages/server/README.md)** | Core logic & CLI — workspaces, OpenCode proxy, API, auth, speech |
| **[packages/ui](packages/ui/README.md)** | SolidJS frontend — reactive, fast, beautiful |
| **[packages/electron-app](packages/electron-app/README.md)** | Desktop shell — process management, IPC, native dialogs |
| **[packages/tauri-app](packages/tauri-app)** | Tauri desktop shell (experimental) |
### Quick Build
To build the Desktop App from source:
### Quick Start
1. Clone the repo.
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
```bash
git clone https://github.com/NeuralNomadsAI/CodeNomad.git
cd CodeNomad
npm install
npm run dev
```
[![Star History Chart](https://api.star-history.com/svg?repos=NeuralNomadsAI/CodeNomad&type=Date)](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
---
## Troubleshooting
<details>
<summary><strong>macOS: "CodeNomad.app is damaged and can't be opened"</strong></summary>
Gatekeeper flag due to missing notarization. Clear the quarantine attribute:
```bash
xattr -dr com.apple.quarantine /Applications/CodeNomad.app
```
On Intel Macs, also check **System Settings → Privacy & Security** on first launch.
</details>
<details>
<summary><strong>Linux (Wayland + NVIDIA): Tauri App closes immediately</strong></summary>
WebKitGTK DMA-BUF/GBM issue. Run with:
```bash
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
```
See full workaround in the original README.
</details>
---
## Community
[![Star History](https://api.star-history.com/svg?repos=NeuralNomadsAI/CodeNomad&type=Date)](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
---
**Built with ♥ by [Neural Nomads](https://github.com/NeuralNomadsAI)** · [MIT License](LICENSE)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 845 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 835 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 966 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

1526
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.12.3",
"version": "0.14.0",
"private": true,
"description": "CodeNomad monorepo workspace",
"license": "MIT",
@@ -22,7 +22,7 @@
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version"
"bumpVersion": "node ./scripts/bump-version.js"
},
"dependencies": {
"7zip-bin": "^5.2.0",
@@ -30,5 +30,13 @@
},
"devDependencies": {
"baseline-browser-mapping": "^2.9.11"
},
"optionalDependencies": {
"@rollup/rollup-darwin-arm64": "4.52.5",
"@rollup/rollup-darwin-x64": "4.52.5",
"@rollup/rollup-linux-arm64-gnu": "4.52.5",
"@rollup/rollup-linux-x64-gnu": "4.52.5",
"@rollup/rollup-win32-arm64-msvc": "4.52.5",
"@rollup/rollup-win32-x64-msvc": "4.52.5"
}
}
}

View File

@@ -1,4 +1,4 @@
{
"minServerVersion": "0.12.3",
"minServerVersion": "0.14.0",
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
}

View File

@@ -4,6 +4,23 @@ export interface Env {
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
if (url.pathname === "/version.json") {
const response = await env.ASSETS.fetch(request)
const newHeaders = new Headers(response.headers)
newHeaders.set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate")
newHeaders.set("Pragma", "no-cache")
newHeaders.set("Expires", "0")
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
})
}
return env.ASSETS.fetch(request)
},
}

View File

@@ -2,3 +2,4 @@ node_modules/
dist/
release/
.vite/
electron/resources/server/

View File

@@ -1,5 +1,6 @@
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
import fs from "fs"
import { requestMicrophoneAccess } from "./permissions"
import type { CliProcessManager, CliStatus } from "./process-manager"
let wakeLockId: number | null = null
@@ -111,6 +112,33 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
return { enabled: false }
})
ipcMain.handle(
"media:requestMicrophoneAccess",
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
)
ipcMain.handle(
"remote:openWindow",
async (
_event,
payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean },
): Promise<{ ok: boolean }> => {
const opener = (mainWindow as BrowserWindow & {
__codenomadOpenRemoteWindow?: (payload: {
id: string
name: string
baseUrl: string
skipTlsVerify: boolean
}) => Promise<void>
}).__codenomadOpenRemoteWindow
if (!opener) {
throw new Error("Remote window opening is not available")
}
await opener(payload)
return { ok: true }
},
)
ipcMain.handle(
"notifications:show",
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {

View File

@@ -1,11 +1,12 @@
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
import http from "node:http"
import https from "node:https"
import { existsSync } from "fs"
import { existsSync, mkdirSync } from "fs"
import { dirname, join } from "path"
import { fileURLToPath } from "url"
import { createApplicationMenu } from "./menu"
import { setupCliIPC } from "./ipc"
import { configureMediaPermissionHandlers } from "./permissions"
import { CliProcessManager } from "./process-manager"
const mainFilename = fileURLToPath(import.meta.url)
@@ -13,6 +14,31 @@ const mainDirname = dirname(mainFilename)
const isMac = process.platform === "darwin"
function configureDevStoragePaths() {
if (app.isPackaged) {
return
}
const appName = "CodeNomad"
try {
app.setName(appName)
const userDataPath = join(app.getPath("appData"), appName)
const sessionDataPath = join(userDataPath, "session-data")
mkdirSync(userDataPath, { recursive: true })
mkdirSync(sessionDataPath, { recursive: true })
app.setPath("userData", userDataPath)
app.setPath("sessionData", sessionDataPath)
} catch (error) {
console.warn("[cli] failed to configure dev storage paths", error)
}
}
configureDevStoragePaths()
const cliManager = new CliProcessManager()
let mainWindow: BrowserWindow | null = null
let currentCliUrl: string | null = null
@@ -20,6 +46,8 @@ let pendingCliUrl: string | null = null
let pendingBootstrapToken: string | null = null
let showingLoadingScreen = false
let preloadingView: BrowserView | null = null
const remoteWindowOrigins = new Map<number, Set<string>>()
const insecureWindowOrigins = new Map<number, Set<string>>()
if (isMac) {
app.commandLine.appendSwitch("disable-spell-checking")
@@ -90,10 +118,17 @@ function loadLoadingScreen(window: BrowserWindow) {
loader.catch((error) => {
console.error("[cli] failed to load loading screen:", error)
})
return loader
}
function getAllowedRendererOrigins(): string[] {
function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
const origins = new Set<string>()
if (window) {
for (const origin of remoteWindowOrigins.get(window.id) ?? []) {
origins.add(origin)
}
}
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
for (const candidate of rendererCandidates) {
if (!candidate) {
@@ -108,13 +143,13 @@ function getAllowedRendererOrigins(): string[] {
return Array.from(origins)
}
function shouldOpenExternally(url: string): boolean {
function shouldOpenExternally(url: string, window?: BrowserWindow | null): boolean {
try {
const parsed = new URL(url)
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return true
}
const allowedOrigins = getAllowedRendererOrigins()
const allowedOrigins = getAllowedRendererOrigins(window)
return !allowedOrigins.includes(parsed.origin)
} catch {
return false
@@ -127,7 +162,7 @@ function setupNavigationGuards(window: BrowserWindow) {
}
window.webContents.setWindowOpenHandler(({ url }) => {
if (shouldOpenExternally(url)) {
if (shouldOpenExternally(url, window)) {
handleExternal(url)
return { action: "deny" }
}
@@ -135,13 +170,54 @@ function setupNavigationGuards(window: BrowserWindow) {
})
window.webContents.on("will-navigate", (event, url) => {
if (shouldOpenExternally(url)) {
if (shouldOpenExternally(url, window)) {
event.preventDefault()
handleExternal(url)
}
})
}
function setWindowAllowedOrigin(window: BrowserWindow, url: string) {
try {
const origin = new URL(url).origin
remoteWindowOrigins.set(window.id, new Set([origin]))
} catch (error) {
console.warn("[cli] failed to store allowed origin", url, error)
}
}
function clearWindowAllowedOrigin(window: BrowserWindow) {
remoteWindowOrigins.delete(window.id)
}
function addWindowInsecureOrigin(window: BrowserWindow, url: string) {
try {
const origin = new URL(url).origin
insecureWindowOrigins.set(window.id, new Set([origin]))
} catch (error) {
console.warn("[cli] failed to store insecure origin", url, error)
}
}
function clearWindowInsecureOrigin(window: BrowserWindow) {
insecureWindowOrigins.delete(window.id)
}
function isInsecureOriginAllowed(url: string) {
try {
const targetOrigin = new URL(url).origin
for (const origins of insecureWindowOrigins.values()) {
if (origins.has(targetOrigin)) {
return true
}
}
} catch {
return false
}
return false
}
let cachedPreloadPath: string | null = null
function getPreloadPath() {
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
@@ -206,36 +282,37 @@ function createWindow() {
},
})
setupNavigationGuards(mainWindow)
const window = mainWindow
setupNavigationGuards(window)
if (isMac) {
mainWindow.webContents.session.setSpellCheckerEnabled(false)
window.webContents.session.setSpellCheckerEnabled(false)
}
showingLoadingScreen = true
currentCliUrl = null
loadLoadingScreen(mainWindow)
clearWindowAllowedOrigin(window)
const loadingReady = loadLoadingScreen(window)
if (process.env.NODE_ENV === "development") {
mainWindow.webContents.openDevTools({ mode: "detach" })
window.webContents.openDevTools({ mode: "detach" })
}
createApplicationMenu(mainWindow)
setupCliIPC(mainWindow, cliManager)
createApplicationMenu(window)
setupCliIPC(window, cliManager)
mainWindow.on("closed", () => {
window.on("closed", () => {
destroyPreloadingView()
clearWindowAllowedOrigin(window)
clearWindowInsecureOrigin(window)
mainWindow = null
currentCliUrl = null
pendingCliUrl = null
showingLoadingScreen = false
})
if (pendingCliUrl) {
const url = pendingCliUrl
pendingCliUrl = null
startCliPreload(url)
}
return loadingReady
}
function showLoadingScreen(force = false) {
@@ -321,13 +398,68 @@ function finalizeCliSwap(url: string) {
return
}
const window = mainWindow
showingLoadingScreen = false
currentCliUrl = url
setWindowAllowedOrigin(window, url)
pendingCliUrl = null
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
window.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
}
function buildRemoteWindowTitle(name: string, baseUrl: string) {
try {
const parsed = new URL(baseUrl)
return `${name} - ${parsed.host}`
} catch {
return `${name} - ${baseUrl}`
}
}
function buildRemoteErrorHtml(name: string, baseUrl: string, message: string) {
const escapedName = name.replace(/[&<>"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[char] ?? char))
const escapedUrl = baseUrl.replace(/[&<>"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[char] ?? char))
const escapedMessage = message.replace(/[&<>"]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[char] ?? char))
return `<!doctype html><html><head><meta charset="utf-8" /><title>${escapedName}</title><style>body{margin:0;background:#111827;color:#f9fafb;font-family:Inter,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px}main{max-width:560px;width:100%;background:rgba(17,24,39,.88);border:1px solid rgba(255,255,255,.08);border-radius:20px;padding:28px;box-shadow:0 25px 60px rgba(0,0,0,.45)}h1{margin:0 0 10px;font-size:1.5rem}p{margin:0 0 10px;color:#cbd5e1;line-height:1.5}code{display:block;margin-top:16px;padding:12px 14px;border-radius:12px;background:#0f172a;color:#bfdbfe;overflow:auto}</style></head><body><main><h1>${escapedName}</h1><p>Could not connect to the remote server.</p><p>${escapedMessage}</p><code>${escapedUrl}</code></main></body></html>`
}
async function openRemoteWindow(payload: { id: string; name: string; baseUrl: string; skipTlsVerify: boolean }) {
const targetUrl = new URL(payload.baseUrl)
const title = buildRemoteWindowTitle(payload.name, payload.baseUrl)
const window = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
backgroundColor: "#1a1a1a",
icon: getIconPath(),
title,
webPreferences: {
preload: getPreloadPath(),
contextIsolation: true,
nodeIntegration: false,
spellcheck: !isMac,
},
})
setWindowAllowedOrigin(window, targetUrl.toString())
if (payload.skipTlsVerify) {
addWindowInsecureOrigin(window, targetUrl.toString())
}
setupNavigationGuards(window)
window.on("closed", () => {
clearWindowAllowedOrigin(window)
clearWindowInsecureOrigin(window)
})
try {
await window.loadURL(targetUrl.toString())
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(buildRemoteErrorHtml(payload.name, payload.baseUrl, message))}`)
}
}
const SESSION_COOKIE_NAME = "codenomad_session"
let bootstrapExchangeInFlight = false
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
@@ -350,6 +482,7 @@ function extractCookieValue(setCookieHeader: string | string[] | undefined, name
}
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
const sessionCookieName = cliManager.getAuthCookieName()
const target = new URL("/api/auth/token", baseUrl)
const body = JSON.stringify({ token })
@@ -380,14 +513,14 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<b
return false
}
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
const sessionId = extractCookieValue(result.setCookie, sessionCookieName)
if (!sessionId) {
return false
}
await session.defaultSession.cookies.set({
url: baseUrl,
name: SESSION_COOKIE_NAME,
name: sessionCookieName,
value: sessionId,
httpOnly: true,
path: "/",
@@ -485,10 +618,12 @@ app.whenReady().then(() => {
// ignore
}
startCli()
const loadingReady = createWindow()
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
if (isMac) {
session.defaultSession.setSpellCheckerEnabled(false)
configureMediaPermissionHandlers(getAllowedRendererOrigins)
app.on("browser-window-created", (_, window) => {
window.webContents.session.setSpellCheckerEnabled(false)
})
@@ -501,7 +636,21 @@ app.whenReady().then(() => {
}
}
createWindow()
void loadingReady.finally(() => {
setTimeout(() => {
void startCli()
}, 0)
})
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
if (isInsecureOriginAllowed(url)) {
event.preventDefault()
console.warn("[cli] allowing insecure remote certificate for", url, error)
callback(true)
return
}
callback(false)
})
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {

View File

@@ -0,0 +1,58 @@
import { session, systemPreferences } from "electron"
const isMac = process.platform === "darwin"
export function isAllowedRendererOrigin(origin: string | undefined | null, allowedOrigins: string[]): boolean {
if (!origin) {
return false
}
try {
const normalized = new URL(origin).origin
return allowedOrigins.includes(normalized)
} catch {
return false
}
}
export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[]) {
const isAudioMediaRequest = (permission: string, details?: unknown) => {
if (permission !== "media") {
return false
}
const mediaTypes = (details as { mediaTypes?: string[] } | undefined)?.mediaTypes ?? []
return mediaTypes.length === 0 || mediaTypes.includes("audio")
}
session.defaultSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => {
if (!isAudioMediaRequest(permission, details)) {
return false
}
return isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins())
})
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
if (!isAudioMediaRequest(permission, details)) {
callback(false)
return
}
const requestingOrigin = (details as { requestingOrigin?: string } | undefined)?.requestingOrigin || webContents.getURL()
callback(isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins()))
})
}
export async function requestMicrophoneAccess(): Promise<boolean> {
if (!isMac) {
return true
}
const status = systemPreferences.getMediaAccessStatus("microphone")
if (status === "granted") {
return true
}
return systemPreferences.askForMediaAccess("microphone")
}

View File

@@ -1,16 +1,20 @@
import { spawn, spawnSync, type ChildProcess } from "child_process"
import { app } from "electron"
import { app, utilityProcess, type UtilityProcess } from "electron"
import { createRequire } from "module"
import { EventEmitter } from "events"
import { existsSync, readFileSync } from "fs"
import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import { parse as parseYaml } from "yaml"
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
const nodeRequire = createRequire(import.meta.url)
const mainFilename = fileURLToPath(import.meta.url)
const mainDirname = path.dirname(mainFilename)
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
const SESSION_COOKIE_NAME_PREFIX = "codenomad_session"
type CliState = "starting" | "ready" | "error" | "stopped"
type ListeningMode = "local" | "all"
@@ -34,10 +38,13 @@ interface StartOptions {
interface CliEntryResolution {
entry: string
runner: "node" | "tsx"
runner: "node" | "tsx" | "standalone"
runnerPath?: string
}
type ManagedChild = ChildProcess | UtilityProcess
type ChildLaunchMode = "spawn" | "utility"
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
function isYamlPath(filePath: string): boolean {
@@ -117,11 +124,13 @@ export declare interface CliProcessManager {
}
export class CliProcessManager extends EventEmitter {
private child?: ChildProcess
private child?: ManagedChild
private childLaunchMode: ChildLaunchMode = "spawn"
private status: CliStatus = { state: "stopped" }
private stdoutBuffer = ""
private stderrBuffer = ""
private bootstrapToken: string | null = null
private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
private requestedStop = false
async start(options: StartOptions): Promise<CliStatus> {
@@ -132,36 +141,72 @@ export class CliProcessManager extends EventEmitter {
this.stdoutBuffer = ""
this.stderrBuffer = ""
this.bootstrapToken = null
this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
this.requestedStop = false
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
const cliEntry = this.resolveCliEntry(options)
const listeningMode = this.resolveListeningMode()
const host = resolveHostForMode(listeningMode)
const args = this.buildCliArgs(options, host)
const cliEntry = this.resolveCliEntry(options)
console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
)
let child: ManagedChild
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
env.ELECTRON_RUN_AS_NODE = "1"
if (this.shouldUsePackagedShellSupervisor(options, cliEntry)) {
const supervisorPath = this.resolveCliSupervisorPath()
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
const shellTarget = cliEntry.runner === "standalone" ? this.buildExecutableCommand(cliEntry.entry, args) : this.buildCommand(cliEntry, args)
const shellCommand = buildUserShellCommand(`exec ${shellTarget}`)
const supervisorPayload = JSON.stringify({
command: shellCommand.command,
args: shellCommand.args,
cwd: process.cwd(),
})
const spawnDetails = supportsUserShell()
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
: this.buildDirectSpawn(cliEntry, args)
console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
)
console.info(`[cli] utility supervisor: ${supervisorPath}`)
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
const detached = process.platform !== "win32"
const child = spawn(spawnDetails.command, spawnDetails.args, {
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
detached,
})
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
env: cliEntry.runner === "standalone" ? shellEnv : { ...shellEnv, ELECTRON_RUN_AS_NODE: "1" },
stdio: "pipe",
serviceName: "CodeNomad CLI Supervisor",
})
this.childLaunchMode = "utility"
} else {
console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
)
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
if (!child.pid) {
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
if (cliEntry.runner !== "standalone") {
env.ELECTRON_RUN_AS_NODE = "1"
}
const spawnDetails = supportsUserShell()
? buildUserShellCommand(
`${cliEntry.runner === "standalone" ? "" : "ELECTRON_RUN_AS_NODE=1 "}exec ${
cliEntry.runner === "standalone" ? this.buildExecutableCommand(cliEntry.entry, args) : this.buildCommand(cliEntry, args)
}`,
)
: this.buildDirectSpawn(cliEntry, args)
const detached = process.platform !== "win32"
child = spawn(spawnDetails.command, spawnDetails.args, {
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
detached,
})
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
this.childLaunchMode = "spawn"
}
if (this.childLaunchMode === "spawn" && !child.pid) {
console.error("[cli] spawn failed: no pid")
}
@@ -176,23 +221,48 @@ export class CliProcessManager extends EventEmitter {
this.handleStream(data.toString(), "stderr")
})
child.on("error", (error) => {
console.error("[cli] failed to start CLI:", error)
this.updateStatus({ state: "error", error: error.message })
this.emit("error", error)
})
if (this.childLaunchMode === "utility") {
const utilityChild = child as UtilityProcess
child.on("exit", (code, signal) => {
const failed = this.status.state !== "ready"
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
this.updateStatus({ state: failed ? "error" : "stopped", error })
if (failed && error) {
this.emit("error", new Error(error))
}
this.emit("exit", this.status)
this.child = undefined
})
utilityChild.on("error", (error) => {
const message = this.describeUtilityProcessError(error)
console.error("[cli] utility supervisor failed:", error)
this.updateStatus({ state: "error", error: message })
this.emit("error", new Error(message))
})
utilityChild.on("exit", (code) => {
const failed = this.status.state !== "ready"
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}` : undefined
console.info(`[cli] exit (code=${code ?? ""})${error ? ` error=${error}` : ""}`)
this.updateStatus({ state: failed ? "error" : "stopped", error })
if (failed && error) {
this.emit("error", new Error(error))
}
this.emit("exit", this.status)
this.child = undefined
})
} else {
const spawnedChild = child as ChildProcess
spawnedChild.on("error", (error) => {
console.error("[cli] failed to start CLI:", error)
this.updateStatus({ state: "error", error: error.message })
this.emit("error", error)
})
spawnedChild.on("exit", (code, signal) => {
const failed = this.status.state !== "ready"
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
this.updateStatus({ state: failed ? "error" : "stopped", error })
if (failed && error) {
this.emit("error", new Error(error))
}
this.emit("exit", this.status)
this.child = undefined
})
}
return new Promise<CliStatus>((resolve, reject) => {
const timeout = setTimeout(() => {
@@ -219,16 +289,22 @@ export class CliProcessManager extends EventEmitter {
return
}
if (this.childLaunchMode === "utility") {
return this.stopUtilityChild(child as UtilityProcess)
}
const spawnedChild = child as ChildProcess
this.requestedStop = true
const pid = child.pid
const pid = spawnedChild.pid
if (!pid) {
this.child = undefined
this.updateStatus({ state: "stopped" })
return
}
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
try {
@@ -304,7 +380,7 @@ export class CliProcessManager extends EventEmitter {
sendStopSignal("SIGKILL")
}, 30000)
child.on("exit", () => {
spawnedChild.on("exit", () => {
clearTimeout(killTimeout)
this.child = undefined
console.info("[cli] CLI process exited")
@@ -324,10 +400,54 @@ export class CliProcessManager extends EventEmitter {
})
}
private stopUtilityChild(child: UtilityProcess): Promise<void> {
this.requestedStop = true
const pid = child.pid
if (!pid) {
this.child = undefined
this.updateStatus({ state: "stopped" })
return Promise.resolve()
}
return new Promise((resolve) => {
const killTimeout = setTimeout(() => {
console.warn(`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${pid})`)
try {
process.kill(pid, "SIGKILL")
} catch {
// no-op
}
}, 30000)
child.once("exit", () => {
clearTimeout(killTimeout)
this.child = undefined
console.info("[cli] CLI process exited")
this.updateStatus({ state: "stopped" })
resolve()
})
if (child.pid === undefined) {
clearTimeout(killTimeout)
this.child = undefined
this.updateStatus({ state: "stopped" })
resolve()
return
}
child.kill()
})
}
getStatus(): CliStatus {
return { ...this.status }
}
getAuthCookieName(): string {
return this.authCookieName
}
private resolveListeningMode(): ListeningMode {
return readListeningModeFromConfig()
}
@@ -335,14 +455,22 @@ export class CliProcessManager extends EventEmitter {
private handleTimeout() {
if (this.child) {
const pid = this.child.pid
if (pid && process.platform !== "win32") {
if (this.childLaunchMode === "utility") {
if (pid) {
try {
process.kill(pid, "SIGKILL")
} catch {
// no-op
}
}
} else if (pid && process.platform !== "win32") {
try {
process.kill(-pid, "SIGKILL")
} catch {
this.child.kill("SIGKILL")
;(this.child as ChildProcess).kill("SIGKILL")
}
} else {
this.child.kill("SIGKILL")
;(this.child as ChildProcess).kill("SIGKILL")
}
this.child = undefined
}
@@ -416,7 +544,7 @@ export class CliProcessManager extends EventEmitter {
}
private buildCliArgs(options: StartOptions, host: string): string[] {
const args = ["serve", "--host", host, "--generate-token"]
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName, "--unrestricted-root"]
if (options.dev) {
// Dev: run plain HTTP + Vite dev server proxy.
@@ -440,6 +568,10 @@ export class CliProcessManager extends EventEmitter {
}
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
if (cliEntry.runner === "standalone") {
return this.buildExecutableCommand(cliEntry.entry, args)
}
const parts = [JSON.stringify(process.execPath)]
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
parts.push(JSON.stringify(cliEntry.runnerPath))
@@ -449,7 +581,15 @@ export class CliProcessManager extends EventEmitter {
return parts.join(" ")
}
private buildExecutableCommand(command: string, args: string[]): string {
return [JSON.stringify(command), ...args.map((arg) => JSON.stringify(arg))].join(" ")
}
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
if (cliEntry.runner === "standalone") {
return { command: cliEntry.entry, args }
}
if (cliEntry.runner === "tsx") {
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
}
@@ -466,9 +606,8 @@ export class CliProcessManager extends EventEmitter {
const devEntry = this.resolveDevEntry()
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
}
const distEntry = this.resolveProdEntry()
return { entry: distEntry, runner: "node" }
return { entry: this.resolveStandaloneProdEntry(), runner: "standalone" }
}
private resolveTsx(): string | null {
@@ -508,15 +647,54 @@ export class CliProcessManager extends EventEmitter {
return entry
}
private resolveProdEntry(): string {
try {
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
if (existsSync(entry)) {
return entry
private resolveStandaloneProdEntry(): string {
const executableName = process.platform === "win32" ? "codenomad-server.exe" : "codenomad-server"
const candidates = [
path.join(process.resourcesPath, "server", "dist", executableName),
path.join(mainDirname, "../resources/server/dist", executableName),
path.resolve(process.cwd(), "..", "server", "dist", executableName),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
} catch {
// fall through to error below
}
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
throw new Error(`Unable to locate standalone CodeNomad server executable (${executableName}). Run npm run build:standalone --workspace @neuralnomads/codenomad.`)
}
private shouldUsePackagedShellSupervisor(options: StartOptions, cliEntry: CliEntryResolution): boolean {
return !options.dev && app.isPackaged && process.platform === "darwin" && cliEntry.runner !== "standalone"
}
private resolveCliSupervisorPath(): string {
const candidates = [
path.join(process.resourcesPath, "cli-supervisor.cjs"),
path.join(mainDirname, "../resources/cli-supervisor.cjs"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
throw new Error("Unable to locate CodeNomad CLI supervisor script.")
}
private describeUtilityProcessError(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message
}
if (error && typeof error === "object") {
const typed = error as { type?: unknown; location?: unknown }
if (typeof typed.type === "string") {
return typeof typed.location === "string" ? `${typed.type} at ${typed.location}` : typed.type
}
}
return String(error)
}
}

View File

@@ -20,8 +20,10 @@ const electronAPI = {
return null
}
},
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
}
contextBridge.exposeInMainWorld("electronAPI", electronAPI)

View File

@@ -0,0 +1,131 @@
#!/usr/bin/env node
const { spawn } = require("child_process")
const SHUTDOWN_GRACE_MS = 30_000
let child = null
let shutdownTimer = null
function log(message, error) {
if (error) {
console.error(`[cli-supervisor] ${message}`, error)
return
}
console.log(`[cli-supervisor] ${message}`)
}
function clearShutdownTimer() {
if (shutdownTimer) {
clearTimeout(shutdownTimer)
shutdownTimer = null
}
}
function forwardStream(stream, target) {
if (!stream) return
stream.on("data", (chunk) => {
target.write(chunk)
})
}
function terminateChild(force) {
if (!child || child.exitCode !== null || child.signalCode !== null) {
return
}
try {
child.kill(force ? "SIGKILL" : "SIGTERM")
} catch {
// no-op
}
}
function requestShutdown(force = false) {
if (!child) {
process.exit(force ? 1 : 0)
return
}
terminateChild(force)
if (force) {
process.exit(1)
return
}
clearShutdownTimer()
shutdownTimer = setTimeout(() => {
log(`shutdown timed out after ${SHUTDOWN_GRACE_MS}ms; forcing child termination`)
terminateChild(true)
}, SHUTDOWN_GRACE_MS)
shutdownTimer.unref()
}
function installShutdownHandlers() {
process.on("SIGTERM", () => requestShutdown(false))
process.on("SIGINT", () => requestShutdown(false))
process.on("disconnect", () => requestShutdown(false))
process.on("uncaughtException", (error) => {
log("uncaught exception", error)
requestShutdown(true)
})
process.on("unhandledRejection", (error) => {
log("unhandled rejection", error)
requestShutdown(true)
})
}
function parsePayload() {
const raw = process.argv[2]
if (!raw) {
throw new Error("Supervisor payload is required")
}
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== "object") {
throw new Error("Supervisor payload must be an object")
}
if (typeof parsed.command !== "string" || parsed.command.trim().length === 0) {
throw new Error("Supervisor payload command is required")
}
if (!Array.isArray(parsed.args) || !parsed.args.every((value) => typeof value === "string")) {
throw new Error("Supervisor payload args must be a string array")
}
return {
command: parsed.command,
args: parsed.args,
cwd: typeof parsed.cwd === "string" && parsed.cwd.trim().length > 0 ? parsed.cwd : process.cwd(),
}
}
function main() {
installShutdownHandlers()
const payload = parsePayload()
log(`launching shell command: ${payload.command} ${payload.args.join(" ")}`)
child = spawn(payload.command, payload.args, {
cwd: payload.cwd,
env: process.env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
})
forwardStream(child.stdout, process.stdout)
forwardStream(child.stderr, process.stderr)
child.on("error", (error) => {
log("failed to spawn shell command", error)
process.exit(1)
})
child.on("exit", (code, signal) => {
clearShutdownTimer()
log(`child exited code=${code ?? ""} signal=${signal ?? ""}`)
process.exitCode = typeof code === "number" ? code : signal ? 1 : 0
process.exit()
})
}
main()

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.12.3",
"version": "0.14.0",
"description": "CodeNomad - AI coding assistant",
"license": "MIT",
"author": {
@@ -20,6 +20,8 @@
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
"prepare:resources": "node scripts/prepare-resources.js",
"prebuild": "npm run prepare:resources",
"build": "electron-vite build",
"typecheck": "tsc --noEmit -p tsconfig.json",
"preview": "electron-vite preview",
@@ -33,8 +35,11 @@
"build:linux-arm64": "node scripts/build.js linux-arm64",
"build:linux-rpm": "node scripts/build.js linux-rpm",
"build:all": "node scripts/build.js all",
"prepackage:mac": "npm run prepare:resources",
"package:mac": "electron-builder --mac",
"prepackage:win": "npm run prepare:resources",
"package:win": "electron-builder --win",
"prepackage:linux": "npm run prepare:resources",
"package:linux": "electron-builder --linux"
},
"dependencies": {
@@ -57,7 +62,7 @@
"vite-plugin-solid": "^2.10.0"
},
"build": {
"appId": "ai.opencode.client",
"appId": "ai.neuralnomads.codenomad.client",
"productName": "CodeNomad",
"directories": {
"output": "release",
@@ -82,6 +87,12 @@
}
],
"mac": {
"entitlements": "electron/resources/entitlements.mac.plist",
"entitlementsInherit": "electron/resources/entitlements.mac.plist",
"extendInfo": {
"NSMicrophoneUsageDescription": "CodeNomad needs microphone access for speech-to-text prompt input.",
"NSLocalNetworkUsageDescription": "CodeNomad needs local network access to connect to locally hosted AI and speech services."
},
"category": "public.app-category.developer-tools",
"target": [
{
@@ -136,6 +147,13 @@
"x64",
"arm64"
]
},
{
"target": "AppImage",
"arch": [
"x64",
"arm64"
]
}
],
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env node
import { spawn } from "child_process"
import { existsSync } from "fs"
import { existsSync, readFileSync } from "fs"
import path, { join } from "path"
import { fileURLToPath } from "url"
@@ -14,6 +14,46 @@ const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"
const nodeModulesPath = join(appDir, "node_modules")
const workspaceNodeModulesPath = join(workspaceRoot, "node_modules")
function getPlatformEsbuildPackage() {
const platformKey = `${process.platform}-${process.arch}`
const platformPackages = {
"linux-x64": "@esbuild/linux-x64",
"linux-arm64": "@esbuild/linux-arm64",
"darwin-arm64": "@esbuild/darwin-arm64",
"darwin-x64": "@esbuild/darwin-x64",
"win32-arm64": "@esbuild/win32-arm64",
"win32-x64": "@esbuild/win32-x64",
}
return platformPackages[platformKey] ?? null
}
async function ensureEsbuildPlatformBinary() {
const pkgName = getPlatformEsbuildPackage()
if (!pkgName) {
return
}
const platformPackagePath = join(workspaceNodeModulesPath, ...pkgName.split("/"))
if (existsSync(platformPackagePath)) {
return
}
let esbuildVersion = ""
try {
esbuildVersion = JSON.parse(readFileSync(join(workspaceNodeModulesPath, "esbuild", "package.json"), "utf-8")).version ?? ""
} catch {
// leave version empty; fallback install will use latest compatible
}
const packageSpec = esbuildVersion ? `${pkgName}@${esbuildVersion}` : pkgName
console.log("📦 Step 0/3: Restoring esbuild platform binary...\n")
await run(npmCmd, ["install", packageSpec, "--no-save", "--ignore-scripts", "--fund=false", "--audit=false"], {
cwd: workspaceRoot,
env: { NODE_PATH: workspaceNodeModulesPath },
})
}
const platforms = {
mac: {
args: ["--mac", "--x64", "--arm64"],
@@ -105,12 +145,20 @@ async function build(platform) {
console.log(`\n🔨 Building for: ${config.description}\n`)
try {
await ensureEsbuildPlatformBinary()
console.log("📦 Step 1/3: Building CLI dependency...\n")
await run(npmCmd, ["run", "build", "--workspace", "@neuralnomads/codenomad"], {
cwd: workspaceRoot,
env: { NODE_PATH: workspaceNodeModulesPath },
})
console.log("\n📦 Step 1.5/3: Preparing packaged server resources...\n")
await run(process.execPath, [join(appDir, "scripts", "prepare-resources.js")], {
cwd: workspaceRoot,
env: { NODE_PATH: workspaceNodeModulesPath },
})
console.log("\n📦 Step 2/3: Building Electron app...\n")
await run(npmCmd, ["run", "build"])

View File

@@ -0,0 +1,208 @@
#!/usr/bin/env node
import fs from "fs"
import path, { join } from "path"
import { spawnSync } from "child_process"
import { fileURLToPath } from "url"
const __dirname = fileURLToPath(new URL(".", import.meta.url))
const appDir = join(__dirname, "..")
const workspaceRoot = join(appDir, "..", "..")
const serverRoot = join(appDir, "..", "server")
const resourcesRoot = join(appDir, "electron", "resources")
const serverDest = join(resourcesRoot, "server")
const npmExecPath = process.env.npm_execpath
const npmNodeExecPath = process.env.npm_node_execpath
const serverSources = ["dist", "public", "node_modules", "package.json"]
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json")
const standaloneMarker = join(serverRoot, "dist", process.platform === "win32" ? "codenomad-server.exe" : "codenomad-server")
function log(message) {
console.log(`[prepare-resources] ${message}`)
}
function ensureServerBuild() {
const distPath = join(serverRoot, "dist")
const publicPath = join(serverRoot, "public")
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
throw new Error("Server build artifacts are missing. Run the server build before packaging Electron.")
}
}
function ensureStandaloneServerBuild() {
log("building standalone server executable")
const result = spawnSync(
"npm",
["run", "build:standalone", "--workspace", "@neuralnomads/codenomad"],
{
cwd: workspaceRoot,
stdio: "inherit",
env: {
...process.env,
PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
},
shell: process.platform === "win32",
},
)
if (result.status !== 0) {
if (result.error) {
throw result.error
}
throw new Error(`standalone server build exited with code ${result.status ?? 1}`)
}
if (!fs.existsSync(standaloneMarker)) {
throw new Error(`Standalone server executable missing after build: ${standaloneMarker}`)
}
}
function ensureServerDependencies() {
if (fs.existsSync(serverDepsMarker)) {
return
}
log("installing production server dependencies")
const npmArgs = [
"install",
"--omit=dev",
"--ignore-scripts",
"--workspaces=false",
"--package-lock=false",
"--install-strategy=shallow",
"--fund=false",
"--audit=false",
]
const env = {
...process.env,
PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
npm_config_workspaces: "false",
}
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
const result = npmCli
? spawnSync(npmCli[0], npmCli[1], { cwd: serverRoot, stdio: "inherit", env })
: spawnSync("npm", npmArgs, { cwd: serverRoot, stdio: "inherit", env, shell: process.platform === "win32" })
if (result.status !== 0) {
if (result.error) {
throw result.error
}
throw new Error(`npm install exited with code ${result.status ?? 1}`)
}
}
function ensureEsbuildPlatformBinary() {
const platformKey = `${process.platform}-${process.arch}`
const platformPackages = {
"linux-x64": "@esbuild/linux-x64",
"linux-arm64": "@esbuild/linux-arm64",
"darwin-arm64": "@esbuild/darwin-arm64",
"darwin-x64": "@esbuild/darwin-x64",
"win32-arm64": "@esbuild/win32-arm64",
"win32-x64": "@esbuild/win32-x64",
}
const pkgName = platformPackages[platformKey]
if (!pkgName) {
return
}
const platformPackagePath = join(workspaceRoot, "node_modules", ...pkgName.split("/"))
if (fs.existsSync(platformPackagePath)) {
return
}
let esbuildVersion = ""
try {
esbuildVersion = JSON.parse(fs.readFileSync(join(workspaceRoot, "node_modules", "esbuild", "package.json"), "utf-8")).version ?? ""
} catch {
// leave version empty; fallback install will use latest compatible
}
const packageSpec = esbuildVersion ? `${pkgName}@${esbuildVersion}` : pkgName
log("installing esbuild platform binary (optional dep workaround)")
const result = spawnSync("npm", ["install", packageSpec, "--no-save", "--ignore-scripts", "--fund=false", "--audit=false"], {
cwd: workspaceRoot,
stdio: "inherit",
shell: process.platform === "win32",
})
if (result.status !== 0) {
if (result.error) {
throw result.error
}
throw new Error(`esbuild platform install exited with code ${result.status ?? 1}`)
}
}
function copyServerArtifacts() {
fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true })
for (const name of serverSources) {
const from = join(serverRoot, name)
const to = join(serverDest, name)
if (!fs.existsSync(from)) {
throw new Error(`Missing required server artifact: ${from}`)
}
fs.cpSync(from, to, { recursive: true, dereference: true })
log(`copied ${name} to Electron resources`)
}
}
function stripNodeModuleBins() {
const root = join(serverDest, "node_modules")
if (!fs.existsSync(root)) {
return
}
const stack = [root]
let removed = 0
while (stack.length > 0) {
const current = stack.pop()
if (!current) break
let entries
try {
entries = fs.readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const full = join(current, entry.name)
if (entry.name === ".bin") {
fs.rmSync(full, { recursive: true, force: true })
removed += 1
continue
}
if (entry.isDirectory()) {
stack.push(full)
}
}
}
if (removed > 0) {
log(`removed ${removed} node_modules/.bin directories`)
}
}
async function main() {
ensureServerBuild()
ensureStandaloneServerBuild()
ensureServerDependencies()
ensureEsbuildPlatformBinary()
copyServerArtifacts()
stripNodeModuleBins()
}
main().catch((error) => {
console.error("[prepare-resources] failed:", error)
process.exit(1)
})

View File

@@ -14,5 +14,5 @@
"noEmit": true
},
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "electron/resources/server"]
}

View File

@@ -4,6 +4,6 @@
"private": true,
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.2.14"
"@opencode-ai/plugin": "1.14.19"
}
}
}

View File

@@ -2,6 +2,8 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
import { createBackgroundProcessTools } from "./lib/background-process"
let voiceModeEnabled = false
export async function CodeNomadPlugin(input: PluginInput) {
const config = getCodeNomadConfig()
const client = createCodeNomadClient(config)
@@ -16,6 +18,11 @@ export async function CodeNomadPlugin(input: PluginInput) {
pingTs: (event.properties as any)?.ts,
},
}).catch(() => {})
return
}
if (event.type === "codenomad.voiceMode") {
voiceModeEnabled = Boolean((event.properties as { enabled?: unknown } | undefined)?.enabled)
}
})
@@ -23,6 +30,13 @@ export async function CodeNomadPlugin(input: PluginInput) {
tool: {
...backgroundProcessTools,
},
async "chat.message"(_input: { sessionID: string }, output: { message: { system?: string } }) {
if (!voiceModeEnabled) {
return
}
output.message.system = [output.message.system, buildVoiceModePrompt()].filter(Boolean).join("\n\n")
},
async event(input: { event: any }) {
const opencodeEvent = input?.event
if (!opencodeEvent || typeof opencodeEvent !== "object") return
@@ -30,3 +44,19 @@ export async function CodeNomadPlugin(input: PluginInput) {
},
}
}
function buildVoiceModePrompt(): string {
return [
"Voice conversation mode is enabled.",
"Prepend your reply with a fenced code block using language `spoken`.",
"The `spoken` block should be the natural conversational reply you would say out loud to the user. It should be a concise spoken gist of the full response in 2 to 4 natural sentences.",
"In the spoken block, summarize the main outcome, recommendation, or next step. Sound conversational and natural, not like a document summary.",
"Do not include code, bullet lists, markdown formatting, or long technical detail in the spoken block.",
"Do not add generic phrases about whether the user should read more.",
"Only mention additional written detail when there is something specific that may matter for the user's next response, such as a tradeoff, caveat, risk, open question, exact diff, or test result.",
"When referring to that written detail, say `below` or `in the message` rather than `detailed section`.",
"After the `spoken` block, continue with your normal detailed response.",
"Example:",
"```spoken\nI implemented the relay-based voice-mode flow and it works with the current plugin bridge. The reconnect caveat is explained below.\n```",
].join("\n\n")
}

View File

@@ -13,6 +13,11 @@ type BackgroundProcess = {
outputSizeBytes?: number
}
type BackgroundProcessNotificationRequest = {
sessionID: string
directory: string
}
type BackgroundProcessOptions = {
baseDir: string
}
@@ -36,12 +41,19 @@ export function createBackgroundProcessTools(config: CodeNomadConfig, options: B
args: {
title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"),
command: tool.schema.string().describe("Shell command to run in the workspace"),
notify: tool.schema.boolean().optional().describe("Notify the current session when the process ends"),
},
async execute(args) {
async execute(args, context) {
assertCommandWithinBase(args.command, options.baseDir)
const notification: BackgroundProcessNotificationRequest | undefined = args.notify
? {
sessionID: context.sessionID,
directory: context.directory,
}
: undefined
const process = await request<BackgroundProcess>("", {
method: "POST",
body: JSON.stringify({ title: args.title, command: args.command }),
body: JSON.stringify({ title: args.title, command: args.command, notify: args.notify, notification }),
})
return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}`

View File

@@ -1,12 +1,12 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.12.3",
"version": "0.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.12.3",
"version": "0.14.0",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.12.3",
"version": "0.14.0",
"description": "CodeNomad Server",
"license": "MIT",
"author": {
@@ -18,6 +18,7 @@
},
"scripts": {
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-config",
"build:standalone": "node ./scripts/build-standalone.mjs",
"build:ui": "npm run build --prefix ../ui",
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
@@ -25,16 +26,16 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
"@fastify/static": "^7.0.4",
"@fastify/cors": "^11.2.0",
"@fastify/reply-from": "^12.6.2",
"@fastify/static": "^9.1.1",
"commander": "^12.1.0",
"fastify": "^4.28.1",
"fastify": "^5.8.5",
"fuzzysort": "^2.0.4",
"node-forge": "^1.3.3",
"openai": "^6.27.0",
"pino": "^9.4.0",
"undici": "^6.19.8",
"undici": "^8.1.0",
"yaml": "^2.4.2",
"yauzl": "^2.10.0",
"zod": "^3.23.8"
@@ -42,9 +43,10 @@
"devDependencies": {
"@types/node-forge": "^1.3.14",
"@types/yauzl": "^2.10.0",
"bun": "^1.3.13",
"cross-env": "^7.0.3",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "^5.6.3"
}
}
}

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env node
import fs from "fs"
import path from "path"
import { spawnSync } from "child_process"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const cliRoot = path.resolve(__dirname, "..")
const distDir = path.join(cliRoot, "dist")
const publicDir = path.join(cliRoot, "public")
const authPagesSourceDir = path.join(distDir, "server", "routes", "auth-pages")
const authPagesTargetDir = path.join(distDir, "auth-pages")
const explicitTarget = process.env.CODENOMAD_STANDALONE_TARGET?.trim()
const outputName = (explicitTarget?.includes("windows") || process.platform === "win32") ? "codenomad-server.exe" : "codenomad-server"
const outputPath = path.join(distDir, outputName)
const packageJsonPath = path.join(cliRoot, "package.json")
function resolveBunCommand() {
const executableName = process.platform === "win32" ? "bun.exe" : "bun"
const localBinName = process.platform === "win32" ? "bun.cmd" : "bun"
const candidates = [
path.join(cliRoot, "node_modules", ".bin", localBinName),
path.join(cliRoot, "..", "..", "node_modules", ".bin", localBinName),
path.join(cliRoot, "node_modules", "bun", "bin", executableName),
path.join(cliRoot, "..", "..", "node_modules", "bun", "bin", executableName),
]
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate
}
}
return "bun"
}
function fail(message) {
console.error(`[build-standalone] ${message}`)
process.exit(1)
}
function ensureArtifacts() {
const requiredPaths = [distDir, publicDir, authPagesSourceDir, packageJsonPath]
const missing = requiredPaths.filter((filePath) => !fs.existsSync(filePath))
if (missing.length > 0) {
fail(`Missing required build artifacts: ${missing.join(", ")}. Run npm run build first.`)
}
const bunResult = spawnSync(resolveBunCommand(), ["-v"], { cwd: cliRoot, encoding: "utf-8", shell: process.platform === "win32" })
if (bunResult.status !== 0) {
fail("Bun is required to build the standalone server executable. Install dependencies so the local Bun binary is available.")
}
}
function syncStandaloneAuthPages() {
fs.rmSync(authPagesTargetDir, { recursive: true, force: true })
fs.mkdirSync(path.dirname(authPagesTargetDir), { recursive: true })
fs.cpSync(authPagesSourceDir, authPagesTargetDir, { recursive: true })
}
function buildStandaloneExecutable() {
fs.rmSync(outputPath, { force: true })
const bunCommand = resolveBunCommand()
const args = ["build", "--compile"]
if (explicitTarget) {
args.push(`--target=${explicitTarget}`)
}
args.push(path.join(cliRoot, "src", "index.ts"), "--outfile", outputPath)
const result = spawnSync(bunCommand, args, {
cwd: cliRoot,
stdio: "inherit",
shell: process.platform === "win32",
})
if (result.status !== 0) {
if (result.error) {
throw result.error
}
throw new Error(`bun build --compile exited with code ${result.status ?? 1}`)
}
}
function main() {
ensureArtifacts()
syncStandaloneAuthPages()
buildStandaloneExecutable()
console.log(`[build-standalone] built ${outputPath}`)
}
try {
main()
} catch (error) {
console.error("[build-standalone] failed:", error)
process.exit(1)
}

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { spawnSync } from "child_process"
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
@@ -14,6 +14,67 @@ const selfLinkDir = path.resolve(nodeModulesDir, "@codenomad", "opencode-config"
const npmExecPath = process.env.npm_execpath
const npmNodeExecPath = process.env.npm_node_execpath
function stripNodeModuleBins(rootDir) {
const root = path.join(rootDir, "node_modules")
if (!existsSync(root)) {
return 0
}
const stack = [root]
let removed = 0
while (stack.length > 0) {
const current = stack.pop()
if (!current) break
let entries
try {
entries = readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const full = path.join(current, entry.name)
if (entry.name === ".bin") {
rmSync(full, { recursive: true, force: true })
removed += 1
continue
}
if (entry.isDirectory()) {
stack.push(full)
}
}
}
return removed
}
function stripOptionalNativeAddons(rootDir) {
const nodeModulesRoot = path.join(rootDir, "node_modules")
if (!existsSync(nodeModulesRoot)) {
return 0
}
const removablePaths = [
path.join(nodeModulesRoot, "@msgpackr-extract"),
path.join(nodeModulesRoot, "msgpackr-extract"),
]
let removed = 0
for (const targetPath of removablePaths) {
if (!existsSync(targetPath)) {
continue
}
rmSync(targetPath, { recursive: true, force: true })
removed += 1
}
return removed
}
if (!existsSync(sourceDir)) {
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
process.exit(1)
@@ -58,4 +119,14 @@ rmSync(targetDir, { recursive: true, force: true })
mkdirSync(path.dirname(targetDir), { recursive: true })
cpSync(sourceDir, targetDir, { recursive: true })
const removedBins = stripNodeModuleBins(targetDir)
if (removedBins > 0) {
console.log(`[copy-opencode-config] Removed ${removedBins} node_modules/.bin directories`)
}
const removedNativeAddons = stripOptionalNativeAddons(targetDir)
if (removedNativeAddons > 0) {
console.log(`[copy-opencode-config] Removed ${removedNativeAddons} optional native addon package paths`)
}
console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`)

View File

@@ -81,6 +81,55 @@ export interface WorktreeMap {
parentSessionWorktreeSlug: Record<string, string>
}
export type GitChangeKind = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | "unmerged"
export interface WorktreeGitStatusEntry {
path: string
originalPath?: string | null
stagedStatus: GitChangeKind | null
stagedAdditions: number
stagedDeletions: number
unstagedStatus: GitChangeKind | null
unstagedAdditions: number
unstagedDeletions: number
}
export type WorktreeGitStatusResponse = WorktreeGitStatusEntry[]
export type WorktreeGitDiffScope = "staged" | "unstaged"
export interface WorktreeGitPathsRequest {
paths: string[]
}
export interface WorktreeGitMutationResponse {
ok: true
}
export interface WorktreeGitCommitRequest {
message: string
}
export interface WorktreeGitCommitResponse {
ok: true
commitSha?: string
}
export interface WorktreeGitDiffResponse {
path: string
originalPath?: string | null
scope: WorktreeGitDiffScope
before: string
after: string
isBinary?: boolean
}
export interface WorktreeGitDiffRequest {
path: string
originalPath?: string | null
scope: WorktreeGitDiffScope
}
export type LogLevel = "debug" | "info" | "warn" | "error"
export interface WorkspaceLogEntry {
@@ -170,6 +219,24 @@ export interface InstanceStreamEvent {
[key: string]: unknown
}
export type SideCarKind = "port"
export type SideCarPrefixMode = "strip" | "preserve"
export type SideCarStatus = "running" | "stopped"
export interface SideCar {
id: string
kind: SideCarKind
name: string
port: number
insecure: boolean
prefixMode: SideCarPrefixMode
status: SideCarStatus
createdAt: string
updatedAt: string
}
export interface BinaryRecord {
id: string
path: string
@@ -240,12 +307,54 @@ export interface SpeechSynthesisResponse {
mimeType: string
}
export interface VoiceModeStateResponse {
enabled: boolean
}
export interface RemoteServerProfile {
id: string
name: string
baseUrl: string
skipTlsVerify: boolean
createdAt: string
updatedAt: string
lastConnectedAt?: string
}
export interface RemoteServerProbeRequest {
baseUrl: string
skipTlsVerify?: boolean
}
export interface RemoteServerProbeResponse {
ok: boolean
reachable: boolean
normalizedUrl: string
skipTlsVerify: boolean
requiresAuth: boolean
authenticated: boolean
error?: string
errorCode?: string
}
export interface RemoteProxySessionCreateRequest {
baseUrl: string
skipTlsVerify?: boolean
}
export interface RemoteProxySessionCreateResponse {
sessionId: string
windowUrl: string
}
export type WorkspaceEventType =
| "workspace.created"
| "workspace.started"
| "workspace.error"
| "workspace.stopped"
| "workspace.log"
| "sidecar.updated"
| "sidecar.removed"
| "storage.configChanged"
| "storage.stateChanged"
| "instance.dataChanged"
@@ -258,6 +367,8 @@ export type WorkspaceEventPayload =
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
| { type: "workspace.stopped"; workspaceId: string }
| { type: "workspace.log"; entry: WorkspaceLogEntry }
| { type: "sidecar.updated"; sidecar: SideCar }
| { type: "sidecar.removed"; sidecarId: string }
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
@@ -324,6 +435,8 @@ export interface ServerMeta {
export type BackgroundProcessStatus = "running" | "stopped" | "error"
export type BackgroundProcessTerminalReason = "finished" | "failed" | "user_stopped" | "user_terminated"
export interface BackgroundProcess {
id: string
workspaceId: string
@@ -336,6 +449,8 @@ export interface BackgroundProcess {
stoppedAt?: string
exitCode?: number
outputSizeBytes?: number
terminalReason?: BackgroundProcessTerminalReason
notifyEnabled?: boolean
}
export interface BackgroundProcessListResponse {

View File

@@ -16,16 +16,18 @@ export interface AuthManagerInit {
password?: string
generateToken: boolean
dangerouslySkipAuth?: boolean
cookieName?: string
}
export class AuthManager {
private readonly authStore: AuthStore | null
private readonly tokenManager: TokenManager | null
private readonly sessionManager = new SessionManager()
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
private readonly cookieName: string
private readonly authEnabled: boolean
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
this.cookieName = sanitizeCookieName(init.cookieName)
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
if (!this.authEnabled) {
@@ -102,13 +104,18 @@ export class AuthManager {
}
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
return this.getSessionFromHeaders(request.headers)
}
getSessionFromHeaders(headers: { cookie?: string | string[] | undefined }): { username: string; sessionId: string } | null {
if (!this.authEnabled) {
// When auth is disabled, treat all requests as authenticated.
// We still return a stable username so callers can display it.
return { username: this.init.username, sessionId: "auth-disabled" }
}
const cookies = parseCookies(request.headers.cookie)
const cookieHeader = Array.isArray(headers.cookie) ? headers.cookie.join("; ") : headers.cookie
const cookies = parseCookies(cookieHeader)
const sessionId = cookies[this.cookieName]
const session = this.sessionManager.getSession(sessionId)
if (!session) return null
@@ -139,6 +146,16 @@ export class AuthManager {
}
}
function sanitizeCookieName(value: string | undefined): string {
const trimmed = value?.trim()
if (!trimmed) {
return DEFAULT_AUTH_COOKIE_NAME
}
const sanitized = trimmed.replace(/[^A-Za-z0-9_-]/g, "_")
return sanitized.length > 0 ? sanitized : DEFAULT_AUTH_COOKIE_NAME
}
function resolveAuthFilePath(configPath: string) {
const resolvedConfigPath = resolvePath(configPath)
return path.join(path.dirname(resolvedConfigPath), "auth.json")

View File

@@ -5,7 +5,7 @@ import { randomBytes } from "crypto"
import type { EventBus } from "../events/bus"
import type { WorkspaceManager } from "../workspaces/manager"
import type { Logger } from "../logger"
import type { BackgroundProcess, BackgroundProcessStatus } from "../api-types"
import type { BackgroundProcess, BackgroundProcessStatus, BackgroundProcessTerminalReason } from "../api-types"
const ROOT_DIR = ".codenomad/background_processes"
const INDEX_FILE = "index.json"
@@ -27,6 +27,31 @@ interface RunningProcess {
outputPath: string
exitPromise: Promise<void>
workspaceId: string
completion?: ProcessCompletion
}
interface ProcessCompletion {
reason: BackgroundProcessTerminalReason
endContext: "normal" | "workspace_cleanup"
removeAfterFinalize?: boolean
}
interface BackgroundProcessNotificationState {
sessionID: string
directory: string
sentAt?: string
}
interface PersistedBackgroundProcess extends BackgroundProcess {
notify?: BackgroundProcessNotificationState
}
interface StartOptions {
notify?: boolean
notification?: {
sessionID: string
directory: string
}
}
export class BackgroundProcessManager {
@@ -41,14 +66,14 @@ export class BackgroundProcessManager {
const records = await this.readIndex(workspaceId)
const enriched = await Promise.all(
records.map(async (record) => ({
...record,
...this.toPublicProcess(record),
outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
})),
)
return enriched
}
async start(workspaceId: string, title: string, command: string): Promise<BackgroundProcess> {
async start(workspaceId: string, title: string, command: string, options: StartOptions = {}): Promise<BackgroundProcess> {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
throw new Error("Workspace not found")
@@ -73,8 +98,7 @@ export class BackgroundProcessManager {
this.killProcessTree(child, "SIGTERM")
})
const record: BackgroundProcess = {
const record: PersistedBackgroundProcess = {
id,
workspaceId,
title,
@@ -84,6 +108,20 @@ export class BackgroundProcessManager {
pid: child.pid,
startedAt: new Date().toISOString(),
outputSizeBytes: 0,
notify: options.notify && options.notification
? {
sessionID: options.notification.sessionID,
directory: options.notification.directory,
}
: undefined,
}
const runningState: RunningProcess = {
id,
child,
outputPath,
exitPromise: Promise.resolve(),
workspaceId,
}
const exitPromise = new Promise<void>((resolve) => {
@@ -91,18 +129,21 @@ export class BackgroundProcessManager {
await new Promise<void>((resolve) => outputStream.end(resolve))
this.running.delete(id)
record.status = this.statusFromExit(code)
const completion = runningState.completion ?? this.completionFromExit(code)
record.terminalReason = completion.reason
record.status = this.statusFromReason(completion.reason)
record.exitCode = code === null ? undefined : code
record.stoppedAt = new Date().toISOString()
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
await this.finalizeRecord(workspaceId, record, completion)
resolve()
})
})
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId })
runningState.exitPromise = exitPromise
this.running.set(id, runningState)
let lastPublishAt = 0
const maybePublishSize = () => {
@@ -128,7 +169,7 @@ export class BackgroundProcessManager {
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
return record
return this.toPublicProcess(record)
}
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
@@ -139,19 +180,21 @@ export class BackgroundProcessManager {
const running = this.running.get(processId)
if (running?.child && !running.child.killed) {
running.completion = { reason: "user_stopped", endContext: "normal" }
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
const updated = await this.findProcess(workspaceId, processId)
return updated ? this.toPublicProcess(updated) : this.toPublicProcess(record)
}
if (record.status === "running") {
record.status = "stopped"
record.terminalReason = "user_stopped"
record.stoppedAt = new Date().toISOString()
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
await this.finalizeRecord(workspaceId, record, { reason: "user_stopped", endContext: "normal" })
}
return record
return this.toPublicProcess(record)
}
async terminate(workspaceId: string, processId: string): Promise<void> {
@@ -160,17 +203,19 @@ export class BackgroundProcessManager {
const running = this.running.get(processId)
if (running?.child && !running.child.killed) {
running.completion = { reason: "user_terminated", endContext: "normal", removeAfterFinalize: true }
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
return
}
await this.removeFromIndex(workspaceId, processId)
await this.removeProcessDir(workspaceId, processId)
this.deps.eventBus.publish({
type: "instance.event",
instanceId: workspaceId,
event: { type: "background.process.removed", properties: { processId } },
record.status = "stopped"
record.terminalReason = "user_terminated"
record.stoppedAt = new Date().toISOString()
await this.finalizeRecord(workspaceId, record, {
reason: "user_terminated",
endContext: "normal",
removeAfterFinalize: true,
})
}
@@ -266,6 +311,11 @@ export class BackgroundProcessManager {
private async cleanupWorkspace(workspaceId: string) {
for (const [, running] of this.running.entries()) {
if (running.workspaceId !== workspaceId) continue
running.completion = {
reason: "user_terminated",
endContext: "workspace_cleanup",
removeAfterFinalize: true,
}
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
}
@@ -356,10 +406,17 @@ export class BackgroundProcessManager {
return args
}
private statusFromExit(code: number | null): BackgroundProcessStatus {
if (code === null) return "stopped"
if (code === 0) return "stopped"
return "error"
private completionFromExit(code: number | null): ProcessCompletion {
if (code === 0) {
return { reason: "finished", endContext: "normal" }
}
return { reason: "failed", endContext: "normal" }
}
private statusFromReason(reason: BackgroundProcessTerminalReason): BackgroundProcessStatus {
if (reason === "failed") return "error"
return "stopped"
}
private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise<string> {
@@ -423,25 +480,25 @@ export class BackgroundProcessManager {
return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE)
}
private async findProcess(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
private async findProcess(workspaceId: string, processId: string): Promise<PersistedBackgroundProcess | null> {
const records = await this.readIndex(workspaceId)
return records.find((entry) => entry.id === processId) ?? null
}
private async readIndex(workspaceId: string): Promise<BackgroundProcess[]> {
private async readIndex(workspaceId: string): Promise<PersistedBackgroundProcess[]> {
const indexPath = await this.getIndexPath(workspaceId)
if (!existsSync(indexPath)) return []
try {
const raw = await fs.readFile(indexPath, "utf-8")
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as BackgroundProcess[]) : []
return Array.isArray(parsed) ? (parsed as PersistedBackgroundProcess[]) : []
} catch {
return []
}
}
private async upsertIndex(workspaceId: string, record: BackgroundProcess) {
private async upsertIndex(workspaceId: string, record: PersistedBackgroundProcess) {
const records = await this.readIndex(workspaceId)
const index = records.findIndex((entry) => entry.id === record.id)
if (index >= 0) {
@@ -458,7 +515,7 @@ export class BackgroundProcessManager {
await this.writeIndex(workspaceId, next)
}
private async writeIndex(workspaceId: string, records: BackgroundProcess[]) {
private async writeIndex(workspaceId: string, records: PersistedBackgroundProcess[]) {
const indexPath = await this.getIndexPath(workspaceId)
await fs.mkdir(path.dirname(indexPath), { recursive: true })
await fs.writeFile(indexPath, JSON.stringify(records, null, 2))
@@ -503,14 +560,139 @@ export class BackgroundProcessManager {
}
}
private publishUpdate(workspaceId: string, record: BackgroundProcess) {
private publishUpdate(workspaceId: string, record: PersistedBackgroundProcess) {
this.deps.eventBus.publish({
type: "instance.event",
instanceId: workspaceId,
event: { type: "background.process.updated", properties: { process: record } },
event: { type: "background.process.updated", properties: { process: this.toPublicProcess(record) } },
})
}
private toPublicProcess(record: PersistedBackgroundProcess): BackgroundProcess {
return {
id: record.id,
workspaceId: record.workspaceId,
title: record.title,
command: record.command,
cwd: record.cwd,
status: record.status,
pid: record.pid,
startedAt: record.startedAt,
stoppedAt: record.stoppedAt,
exitCode: record.exitCode,
outputSizeBytes: record.outputSizeBytes,
terminalReason: record.terminalReason,
notifyEnabled: Boolean(record.notify),
}
}
private async finalizeRecord(workspaceId: string, record: PersistedBackgroundProcess, completion: ProcessCompletion) {
if (this.shouldSendCompletionPrompt(record, completion)) {
try {
await this.sendCompletionPrompt(workspaceId, record)
if (record.notify) {
record.notify.sentAt = new Date().toISOString()
}
} catch (error) {
this.deps.logger.warn({ err: error, workspaceId, processId: record.id }, "Failed to send background process completion prompt")
}
}
if (completion.removeAfterFinalize) {
await this.removeFromIndex(workspaceId, record.id)
await this.removeProcessDir(workspaceId, record.id)
this.deps.eventBus.publish({
type: "instance.event",
instanceId: workspaceId,
event: { type: "background.process.removed", properties: { processId: record.id } },
})
return
}
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
}
private shouldSendCompletionPrompt(record: PersistedBackgroundProcess, completion: ProcessCompletion) {
if (completion.endContext === "workspace_cleanup") return false
if (!record.notify) return false
return !record.notify.sentAt
}
private async sendCompletionPrompt(workspaceId: string, record: PersistedBackgroundProcess) {
const notify = record.notify
if (!notify || !record.terminalReason) return
if (!this.deps.workspaceManager.get(workspaceId)) {
throw new Error("Workspace not found")
}
const port = this.deps.workspaceManager.getInstancePort(workspaceId)
if (!port) {
throw new Error("Workspace instance is not ready")
}
const targetUrl = `http://127.0.0.1:${port}/session/${encodeURIComponent(notify.sessionID)}/prompt_async`
const headers: Record<string, string> = {
"content-type": "application/json",
"x-opencode-directory": /[^\x00-\x7F]/.test(notify.directory) ? encodeURIComponent(notify.directory) : notify.directory,
}
const authorization = this.deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
if (authorization) {
headers.authorization = authorization
}
const response = await fetch(targetUrl, {
method: "POST",
headers,
body: JSON.stringify({
parts: [
{
type: "text",
text: this.buildSyntheticCompletionPrompt(record),
synthetic: true,
},
],
}),
})
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Prompt request failed with ${response.status}`)
}
}
private buildCompletionPrompt(record: PersistedBackgroundProcess): string {
const ref = `Background process "${record.title}" (${record.id})`
switch (record.terminalReason) {
case "finished":
return `${ref} finished successfully.`
case "failed":
return record.exitCode === undefined ? `${ref} failed.` : `${ref} failed with exit code ${record.exitCode}.`
case "user_stopped":
return `${ref} was stopped by user.`
case "user_terminated":
return `${ref} was terminated by user.`
}
return `${ref} ended.`
}
private buildSyntheticCompletionPrompt(record: PersistedBackgroundProcess): string {
return `<system-message>${this.escapeTaggedText(this.buildCompletionPrompt(record))}</system-message>`
}
private escapeTaggedText(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
}
private generateId(): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
const random = randomBytes(3).toString("hex")

View File

@@ -0,0 +1,128 @@
import type { Logger } from "../logger"
const STALE_CONNECTION_TIMEOUT_MS = 45000
const STALE_SWEEP_INTERVAL_MS = 5000
export interface ClientConnectionRef {
clientId: string
connectionId: string
}
export interface ClientConnectionRecord extends ClientConnectionRef {
key: string
connectedAt: number
lastSeenAt: number
}
type ConnectionChangeEvent = {
type: "connected" | "disconnected"
connection: ClientConnectionRecord
reason?: string
}
interface RegisteredConnection extends ClientConnectionRecord {
close: () => void
}
export class ClientConnectionManager {
private readonly connections = new Map<string, RegisteredConnection>()
private readonly subscribers = new Set<(event: ConnectionChangeEvent) => void>()
private readonly sweepTimer: NodeJS.Timeout
constructor(private readonly logger: Logger) {
this.sweepTimer = setInterval(() => this.sweepStaleConnections(), STALE_SWEEP_INTERVAL_MS)
this.sweepTimer.unref?.()
}
shutdown(): void {
clearInterval(this.sweepTimer)
for (const connection of Array.from(this.connections.values())) {
this.disconnect(connection.key, "shutdown", false)
}
}
subscribe(listener: (event: ConnectionChangeEvent) => void): () => void {
this.subscribers.add(listener)
return () => this.subscribers.delete(listener)
}
register(input: ClientConnectionRef & { close: () => void }): () => void {
const key = getConnectionKey(input)
const now = Date.now()
const existing = this.connections.get(key)
if (existing) {
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Replacing existing client connection")
this.disconnect(key, "replaced")
}
const connection: RegisteredConnection = {
key,
clientId: input.clientId,
connectionId: input.connectionId,
connectedAt: now,
lastSeenAt: now,
close: input.close,
}
this.connections.set(key, connection)
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Client connected")
this.notify({ type: "connected", connection })
return () => this.disconnect(key, "closed")
}
pong(input: ClientConnectionRef): boolean {
const key = getConnectionKey(input)
const connection = this.connections.get(key)
if (!connection) {
this.logger.debug({ clientId: input.clientId, connectionId: input.connectionId }, "Ignoring pong for unknown client connection")
return false
}
connection.lastSeenAt = Date.now()
return true
}
isConnected(input: ClientConnectionRef): boolean {
return this.connections.has(getConnectionKey(input))
}
private sweepStaleConnections(): void {
const cutoff = Date.now() - STALE_CONNECTION_TIMEOUT_MS
for (const connection of Array.from(this.connections.values())) {
if (connection.lastSeenAt > cutoff) continue
this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId }, "Client connection timed out")
this.disconnect(connection.key, "timeout")
}
}
private disconnect(key: string, reason: string, invokeClose = true): void {
const connection = this.connections.get(key)
if (!connection) return
this.connections.delete(key)
this.logger.debug({ clientId: connection.clientId, connectionId: connection.connectionId, reason }, "Client disconnected")
if (invokeClose) {
try {
connection.close()
} catch (error) {
this.logger.warn({ err: error, clientId: connection.clientId, connectionId: connection.connectionId }, "Failed to close stale client connection")
}
}
this.notify({ type: "disconnected", connection, reason })
}
private notify(event: ConnectionChangeEvent): void {
for (const subscriber of this.subscribers) {
try {
subscriber(event)
} catch (error) {
this.logger.warn({ err: error, eventType: event.type }, "Client connection subscriber failed")
}
}
}
}
function getConnectionKey(input: ClientConnectionRef): string {
return `${input.clientId}:${input.connectionId}`
}

View File

@@ -26,6 +26,7 @@ const PreferencesSchema = z
showUsageMetrics: z.boolean().default(true),
autoCleanupBlankSessions: z.boolean().default(true),
listeningMode: z.enum(["local", "all"]).default("local"),
logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"),
// OS notifications
osNotificationsEnabled: z.boolean().default(false),

View File

@@ -24,6 +24,8 @@ export class EventBus extends EventEmitter {
this.on("workspace.error", handler)
this.on("workspace.stopped", handler)
this.on("workspace.log", handler)
this.on("sidecar.updated", handler)
this.on("sidecar.removed", handler)
this.on("storage.configChanged", handler)
this.on("storage.stateChanged", handler)
this.on("instance.dataChanged", handler)
@@ -35,6 +37,8 @@ export class EventBus extends EventEmitter {
this.off("workspace.error", handler)
this.off("workspace.stopped", handler)
this.off("workspace.log", handler)
this.off("sidecar.updated", handler)
this.off("sidecar.removed", handler)
this.off("storage.configChanged", handler)
this.off("storage.stateChanged", handler)
this.off("instance.dataChanged", handler)

View File

@@ -81,6 +81,14 @@ export class FileSystemBrowser {
return { path: relativePath, absolutePath }
}
writeFile(relativePath: string, contents: string): void {
if (this.unrestricted) {
throw new Error("writeFile is not available in unrestricted mode")
}
const resolved = this.toRestrictedAbsolute(relativePath)
fs.writeFileSync(resolved, contents, "utf-8")
}
readFile(relativePath: string): string {
if (this.unrestricted) {
throw new Error("readFile is not available in unrestricted mode")

View File

@@ -19,18 +19,24 @@ import { InstanceEventBridge } from "./workspaces/instance-events"
import { createLogger } from "./logger"
import { launchInBrowser } from "./launcher"
import { resolveUi } from "./ui/remote-ui"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
import { resolveHttpsOptions } from "./server/tls"
import { resolveNetworkAddresses } from "./server/network-addresses"
import { RemoteProxySessionManager } from "./server/remote-proxy"
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
import { SpeechService } from "./speech/service"
import { SideCarManager } from "./sidecars/manager"
import { ClientConnectionManager } from "./clients/connection-manager"
import { PluginChannelManager } from "./plugins/channel"
import { VoiceModeManager } from "./plugins/voice-mode"
import { readServerPackageVersion, resolveServerPublicDir } from "./runtime-paths"
const require = createRequire(import.meta.url)
const packageJson = require("../package.json") as { version: string }
const packageJson = { version: readServerPackageVersion(import.meta.url) }
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
const DEFAULT_UI_STATIC_DIR = resolveServerPublicDir(import.meta.url)
interface CliOptions {
host: string
@@ -55,6 +61,7 @@ interface CliOptions {
launch: boolean
authUsername: string
authPassword?: string
authCookieName: string
generateToken: boolean
dangerouslySkipAuth: boolean
}
@@ -100,6 +107,11 @@ function parseCliOptions(argv: string[]): CliOptions {
.default(DEFAULT_AUTH_USERNAME),
)
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
.addOption(
new Option("--auth-cookie-name <name>", "Cookie name for server authentication")
.env("CODENOMAD_AUTH_COOKIE_NAME")
.default(DEFAULT_AUTH_COOKIE_NAME),
)
.addOption(
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
.env("CODENOMAD_GENERATE_TOKEN")
@@ -139,6 +151,7 @@ function parseCliOptions(argv: string[]): CliOptions {
launch?: boolean
username: string
password?: string
authCookieName: string
generateToken?: boolean
dangerouslySkipAuth?: boolean
}>()
@@ -185,6 +198,7 @@ function parseCliOptions(argv: string[]): CliOptions {
launch: Boolean(parsed.launch),
authUsername: parsed.username,
authPassword: parsed.password,
authCookieName: parsed.authCookieName,
generateToken: Boolean(parsed.generateToken),
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
}
@@ -266,6 +280,7 @@ async function main() {
configPath: configLocation.configYamlPath,
username: options.authUsername,
password: options.authPassword,
cookieName: options.authCookieName,
generateToken: options.generateToken,
dangerouslySkipAuth: options.dangerouslySkipAuth,
},
@@ -306,6 +321,11 @@ async function main() {
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore(configLocation.instancesDir)
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
const sidecarManager = new SideCarManager({
settings,
eventBus,
logger: logger.child({ component: "sidecars" }),
})
const instanceEventBridge = new InstanceEventBridge({
workspaceManager,
eventBus,
@@ -357,12 +377,21 @@ async function main() {
})
: null
if (uiResolution.uiDevServerUrl && options.https) {
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
}
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }))
const pluginChannel = new PluginChannelManager(logger.child({ component: "plugin-channel" }))
const remoteProxySessionManager = new RemoteProxySessionManager({
authManager,
logger: logger.child({ component: "remote-proxy" }),
httpsOptions: tlsResolution?.httpsOptions,
})
const voiceModeManager = new VoiceModeManager({
connections: clientConnectionManager,
channel: pluginChannel,
logger: logger.child({ component: "voice-mode" }),
})
const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT)
const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT)
@@ -391,7 +420,12 @@ async function main() {
serverMeta,
instanceStore,
speechService,
sidecarManager,
authManager,
clientConnectionManager,
pluginChannel,
voiceModeManager,
remoteProxySessionManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
logger,
@@ -412,7 +446,12 @@ async function main() {
serverMeta,
instanceStore,
speechService,
sidecarManager,
authManager,
clientConnectionManager,
pluginChannel,
voiceModeManager,
remoteProxySessionManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined,
logger,
@@ -442,18 +481,22 @@ async function main() {
// which can lead clients to talk to the wrong process.
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
let remoteUrl: string | undefined
let remoteAddresses = [] as ReturnType<typeof resolveNetworkAddresses>
if (remoteStart) {
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
let remoteHost = options.host
if (wantsAll) {
if (options.host === "0.0.0.0") {
const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
const resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
remoteAddresses = resolved.userVisible
remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://localhost:${remoteStart.port}`
}
} else {
remoteHost = "localhost"
}
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
if (!remoteUrl) {
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
}
}
serverMeta.localUrl = localUrl
@@ -464,7 +507,9 @@ async function main() {
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
if (serverMeta.remotePort && remoteUrl) {
serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
serverMeta.addresses = remoteAddresses.length
? remoteAddresses
: resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
} else {
serverMeta.addresses = []
}
@@ -472,6 +517,16 @@ async function main() {
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
if (serverMeta.remoteUrl) {
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
const additionalRemoteUrls = serverMeta.addresses
.map((addr) => addr.remoteUrl)
.filter((url) => url !== serverMeta.remoteUrl)
if (additionalRemoteUrls.length > 0) {
console.log("Other Accessible URLs:")
for (const url of additionalRemoteUrls) {
console.log(` - ${url}`)
}
}
}
if (options.launch) {
@@ -495,6 +550,18 @@ async function main() {
logger.warn({ err: error }, "Instance event bridge shutdown failed")
}
try {
await sidecarManager.shutdown()
} catch (error) {
logger.error({ err: error }, "SideCar manager shutdown failed")
}
try {
clientConnectionManager.shutdown()
} catch (error) {
logger.warn({ err: error }, "Client connection manager shutdown failed")
}
try {
await workspaceManager.shutdown()
logger.info("Workspace manager shutdown complete")

View File

@@ -1,22 +1,11 @@
import { existsSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
import { createLogger } from "./logger"
import { resolveOpencodeTemplateDir } from "./runtime-paths"
const log = createLogger({ component: "opencode-config" })
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const devTemplateDir = path.resolve(__dirname, "../../opencode-config")
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
const prodTemplateDirs = [
resourcesPath ? path.resolve(resourcesPath, "opencode-config") : undefined,
path.resolve(__dirname, "opencode-config"),
].filter((dir): dir is string => Boolean(dir))
const templateDir = resolveOpencodeTemplateDir(import.meta.url)
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
const templateDir = isDevBuild
? devTemplateDir
: prodTemplateDirs.find((dir) => existsSync(dir)) ?? prodTemplateDirs[0]
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER)
export function getOpencodeConfigDir(): string {
if (!existsSync(templateDir)) {

View File

@@ -0,0 +1,100 @@
import type { Logger } from "../logger"
import type { ClientConnectionManager, ClientConnectionRef } from "../clients/connection-manager"
import type { PluginChannelManager } from "./channel"
interface VoiceModeManagerOptions {
connections: ClientConnectionManager
channel: PluginChannelManager
logger: Logger
}
export class VoiceModeManager {
private readonly enabledConnectionsByInstance = new Map<string, Set<string>>()
private readonly aggregateByInstance = new Map<string, boolean>()
constructor(private readonly options: VoiceModeManagerOptions) {
this.options.connections.subscribe((event) => {
if (event.type !== "disconnected") return
this.clearConnection(event.connection)
})
}
setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): boolean {
if (enabled && !this.options.connections.isConnected(connection)) {
this.options.logger.debug(
{ instanceId, clientId: connection.clientId, connectionId: connection.connectionId },
"Ignoring voice mode enable for disconnected client connection",
)
return false
}
const key = getConnectionKey(connection)
const current = this.enabledConnectionsByInstance.get(instanceId) ?? new Set<string>()
if (enabled) {
current.add(key)
this.enabledConnectionsByInstance.set(instanceId, current)
} else if (current.delete(key)) {
if (current.size === 0) {
this.enabledConnectionsByInstance.delete(instanceId)
} else {
this.enabledConnectionsByInstance.set(instanceId, current)
}
}
this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection")
this.publishIfChanged(instanceId)
return true
}
syncInstance(instanceId: string): void {
this.options.channel.send(instanceId, buildVoiceModeEvent(this.isEnabled(instanceId)))
}
isEnabled(instanceId: string): boolean {
return this.aggregateByInstance.get(instanceId) === true
}
private clearConnection(connection: ClientConnectionRef): void {
const key = getConnectionKey(connection)
for (const [instanceId, enabledConnections] of Array.from(this.enabledConnectionsByInstance.entries())) {
if (!enabledConnections.delete(key)) continue
if (enabledConnections.size === 0) {
this.enabledConnectionsByInstance.delete(instanceId)
}
this.publishIfChanged(instanceId)
}
}
private publishIfChanged(instanceId: string): void {
const enabled = (this.enabledConnectionsByInstance.get(instanceId)?.size ?? 0) > 0
const previous = this.aggregateByInstance.get(instanceId) === true
if (enabled === previous) return
if (enabled) {
this.aggregateByInstance.set(instanceId, true)
} else {
this.aggregateByInstance.delete(instanceId)
}
this.options.logger.debug(
{ instanceId, enabled },
"Broadcasting aggregate voice mode",
)
this.options.channel.send(instanceId, buildVoiceModeEvent(enabled))
}
}
function buildVoiceModeEvent(enabled: boolean) {
return {
type: "codenomad.voiceMode",
properties: {
enabled,
formatVersion: "v1",
},
}
}
function getConnectionKey(connection: ClientConnectionRef): string {
return `${connection.clientId}:${connection.connectionId}`
}

View File

@@ -0,0 +1,79 @@
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
function safeModuleDir(importMetaUrl: string): string | null {
try {
return path.dirname(fileURLToPath(importMetaUrl))
} catch {
return null
}
}
function firstExistingPath(candidates: Array<string | null | undefined>, predicate: (value: string) => boolean): string | null {
for (const candidate of candidates) {
if (!candidate) continue
if (predicate(candidate)) {
return candidate
}
}
return null
}
export function getPackagedDistDir(): string {
return path.dirname(process.execPath)
}
export function resolveServerPackageRoot(importMetaUrl: string): string {
const moduleDir = safeModuleDir(importMetaUrl)
const configuredRoot = process.env.CODENOMAD_SERVER_ROOT?.trim()
const candidates = [
configuredRoot ? path.resolve(configuredRoot) : null,
moduleDir ? path.resolve(moduleDir, "..") : null,
path.resolve(getPackagedDistDir(), ".."),
]
return (
firstExistingPath(candidates, (value) => fs.existsSync(path.join(value, "package.json"))) ??
candidates.find((value): value is string => Boolean(value)) ??
process.cwd()
)
}
export function resolveServerPublicDir(importMetaUrl: string): string {
const moduleDir = safeModuleDir(importMetaUrl)
const candidates = [moduleDir ? path.resolve(moduleDir, "../public") : null, path.join(resolveServerPackageRoot(importMetaUrl), "public")]
return firstExistingPath(candidates, (value) => fs.existsSync(value)) ?? candidates[candidates.length - 1]!
}
export function resolveAuthTemplatePath(importMetaUrl: string, fileName: string): string {
const moduleDir = safeModuleDir(importMetaUrl)
const distDir = getPackagedDistDir()
const candidates = [
moduleDir ? path.join(moduleDir, "auth-pages", fileName) : null,
path.join(distDir, "auth-pages", fileName),
path.join(distDir, "server", "routes", "auth-pages", fileName),
]
return firstExistingPath(candidates, (value) => fs.existsSync(value)) ?? candidates[0]!
}
export function resolveOpencodeTemplateDir(importMetaUrl: string): string {
const moduleDir = safeModuleDir(importMetaUrl)
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
const candidates = [
moduleDir ? path.resolve(moduleDir, "../../opencode-config") : null,
resourcesPath ? path.resolve(resourcesPath, "opencode-config") : null,
moduleDir ? path.resolve(moduleDir, "opencode-config") : null,
path.join(getPackagedDistDir(), "opencode-config"),
]
return firstExistingPath(candidates, (value) => fs.existsSync(value)) ?? candidates[candidates.length - 1]!
}
export function readServerPackageVersion(importMetaUrl: string): string {
const packageJsonPath = path.join(resolveServerPackageRoot(importMetaUrl), "package.json")
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as { version?: unknown }
return typeof parsed.version === "string" && parsed.version.trim().length > 0 ? parsed.version : "0.0.0"
}

View File

@@ -0,0 +1,94 @@
import assert from "node:assert/strict"
import os from "node:os"
import { describe, it } from "node:test"
import { resolveNetworkAddresses, resolveRemoteAddresses } from "../network-addresses"
describe("resolveNetworkAddresses", () => {
it("preserves interface order among external addresses", () => {
const addresses = [
{ address: "172.24.0.1", family: "IPv4", internal: false },
{ address: "192.168.1.128", family: "IPv4", internal: false },
{ address: "10.0.0.8", family: 4, internal: false },
{ address: "127.0.0.1", family: "IPv4", internal: true },
{ address: "169.254.10.20", family: "IPv4", internal: false },
]
usingMockedNetworkInterfaces(addresses, () => {
const result = resolveNetworkAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
assert.deepEqual(
result.map((entry) => entry.ip),
["172.24.0.1", "192.168.1.128", "10.0.0.8", "169.254.10.20", "127.0.0.1"],
)
})
})
})
describe("resolveRemoteAddresses", () => {
it("keeps all external addresses user-visible while preferring non-link-local addresses for the primary URL", () => {
const addresses = [
{ address: "169.254.10.20", family: "IPv4", internal: false },
{ address: "192.168.1.128", family: "IPv4", internal: false },
{ address: "172.24.0.1", family: "IPv4", internal: false },
]
usingMockedNetworkInterfaces(addresses, () => {
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
assert.deepEqual(
result.userVisible.map((entry) => entry.ip),
["192.168.1.128", "172.24.0.1", "169.254.10.20"],
)
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
})
})
it("prefers private LAN addresses over public addresses", () => {
const addresses = [
{ address: "203.0.113.40", family: "IPv4", internal: false },
{ address: "192.168.1.128", family: "IPv4", internal: false },
{ address: "8.8.8.8", family: "IPv4", internal: false },
]
usingMockedNetworkInterfaces(addresses, () => {
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
assert.deepEqual(
result.userVisible.map((entry) => entry.ip),
["192.168.1.128", "203.0.113.40", "8.8.8.8"],
)
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
})
})
it("uses a public address when no private LAN address is available", () => {
const addresses = [
{ address: "169.254.10.20", family: "IPv4", internal: false },
{ address: "203.0.113.40", family: "IPv4", internal: false },
]
usingMockedNetworkInterfaces(addresses, () => {
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
assert.deepEqual(result.userVisible.map((entry) => entry.ip), ["203.0.113.40", "169.254.10.20"])
assert.equal(result.primaryRemoteUrl, "https://203.0.113.40:9898")
})
})
})
function usingMockedNetworkInterfaces(
addresses: Array<{ address: string; family: string | number; internal: boolean }>,
callback: () => void,
) {
const original = os.networkInterfaces
os.networkInterfaces = (() => ({
ethernet0: addresses as unknown as ReturnType<typeof os.networkInterfaces>[string],
})) as typeof os.networkInterfaces
try {
callback()
} finally {
os.networkInterfaces = original
}
}

View File

@@ -0,0 +1,248 @@
import assert from "node:assert/strict"
import { after, afterEach, describe, it } from "node:test"
import fs from "node:fs"
import http, { type IncomingMessage, type ServerResponse } from "node:http"
import os from "node:os"
import path from "node:path"
import { Agent, fetch } from "undici"
import type { AuthManager } from "../../auth/manager"
import type { Logger } from "../../logger"
import { RemoteProxySessionManager } from "../remote-proxy"
import { resolveHttpsOptions } from "../tls"
const sharedTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-remote-proxy-test-"))
const sharedTls = resolveHttpsOptions({
enabled: true,
configDir: sharedTempDir,
host: "127.0.0.1",
logger: createStubLogger(),
})
if (!sharedTls) {
throw new Error("Failed to generate HTTPS options for remote proxy tests")
}
const sharedHttpsOptions = sharedTls.httpsOptions
const httpsDispatcher = new Agent({ connect: { rejectUnauthorized: false } })
const managers = new Set<RemoteProxySessionManager>()
afterEach(async () => {
for (const manager of managers) {
await disposeManager(manager)
}
managers.clear()
})
after(() => {
fs.rmSync(sharedTempDir, { recursive: true, force: true })
httpsDispatcher.close().catch(() => {})
})
describe("RemoteProxySessionManager", () => {
it("blocks proxying before activation and keeps bootstrap tokens scoped per session", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const session1 = await createSession(manager, `${upstreamBaseUrl}/base`)
const session2 = await createSession(manager, `${upstreamBaseUrl}/base`)
const blocked = await proxyFetch(`${session1.proxyOrigin}/status`)
assert.equal(blocked.status, 403)
const wrongTokenResponse = await proxyFetch(`${session1.proxyOrigin}/__codenomad/api/auth/token`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token: session2.token }),
})
assert.equal(wrongTokenResponse.status, 401)
assert.equal(await activateSession(session1), true)
assert.equal(await activateSession(session2), true)
}, (req, res) => {
res.writeHead(200, { "content-type": "text/plain" })
res.end(req.url ?? "")
})
})
it("preserves remote base paths and rewrites same-origin redirects to the local proxy origin", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
await activateSession(session)
const apiResponse = await proxyFetch(`${session.proxyOrigin}/api/auth/status?foo=bar`)
assert.equal(apiResponse.status, 200)
assert.equal(await apiResponse.text(), "/base/api/auth/status?foo=bar")
const redirectResponse = await proxyFetch(`${session.proxyOrigin}/redirect`, { redirect: "manual" })
assert.equal(redirectResponse.status, 302)
assert.equal(redirectResponse.headers.get("location"), `${session.proxyOrigin}/base/after?ok=1`)
}, (req, res) => {
const requestUrl = req.url ?? ""
if (requestUrl === "/base/redirect") {
res.writeHead(302, { location: "/base/after?ok=1" })
res.end()
return
}
res.writeHead(200, { "content-type": "text/plain" })
res.end(requestUrl)
})
})
it("rewrites set-cookie names for the proxy and restores cookie names on proxied requests", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
await activateSession(session)
const loginResponse = await proxyFetch(`${session.proxyOrigin}/login`)
assert.equal(loginResponse.status, 200)
const setCookie = getSetCookie(loginResponse)[0]
assert.match(setCookie, /^cnrp_[0-9a-f]+_session=abc123/i)
assert.doesNotMatch(setCookie, /domain=/i)
const cookieHeader = setCookie.split(";", 1)[0]
const whoamiResponse = await proxyFetch(`${session.proxyOrigin}/whoami`, {
headers: { cookie: cookieHeader },
})
assert.equal(await whoamiResponse.text(), "session=abc123")
}, (req, res) => {
const requestUrl = req.url ?? ""
if (requestUrl === "/base/login") {
res.writeHead(200, {
"content-type": "text/plain",
"set-cookie": "session=abc123; Path=/; Secure; HttpOnly; Domain=127.0.0.1",
})
res.end("ok")
return
}
if (requestUrl === "/base/whoami") {
res.writeHead(200, { "content-type": "text/plain" })
res.end(req.headers.cookie ?? "")
return
}
res.writeHead(404, { "content-type": "text/plain" })
res.end(requestUrl)
})
})
it("supports explicit deletion and idle cleanup of sessions", async () => {
await withUpstreamServer(async (upstreamBaseUrl) => {
const manager = createSessionManager()
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
assert.equal(await manager.deleteSession(session.sessionId), true)
assert.equal(await manager.deleteSession(session.sessionId), false)
const session3 = await createSession(manager, `${upstreamBaseUrl}/base`)
const internalSessions = (manager as any).sessions as Map<string, { lastAccessAt: number }>
const internalCleanup = (manager as any).cleanupExpiredSessions as () => Promise<void>
internalSessions.get(session3.sessionId)!.lastAccessAt = Date.now() - 31 * 60_000
await internalCleanup.call(manager)
assert.equal(internalSessions.has(session3.sessionId), false)
assert.equal(await manager.deleteSession(session3.sessionId), false)
}, (_req, res) => {
res.writeHead(200, { "content-type": "text/plain" })
res.end("ok")
})
})
})
function createSessionManager() {
const manager = new RemoteProxySessionManager({
authManager: {
isLoopbackRequest: () => true,
} as unknown as AuthManager,
logger: createStubLogger(),
httpsOptions: sharedHttpsOptions,
})
managers.add(manager)
return manager
}
async function createSession(manager: RemoteProxySessionManager, baseUrl: string) {
const created = await manager.createSession(baseUrl, false)
const windowUrl = new URL(created.windowUrl)
return {
sessionId: created.sessionId,
windowUrl,
proxyOrigin: windowUrl.origin,
token: decodeURIComponent(windowUrl.hash.replace(/^#/, "")),
}
}
async function activateSession(session: { proxyOrigin: string; token: string }) {
const response = await proxyFetch(`${session.proxyOrigin}/__codenomad/api/auth/token`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token: session.token }),
})
if (!response.ok) {
return false
}
const body = (await response.json()) as { ok?: boolean }
return body.ok === true
}
function getSetCookie(response: Awaited<ReturnType<typeof fetch>>): string[] {
const values = (response.headers as any).getSetCookie?.() as string[] | undefined
if (Array.isArray(values) && values.length > 0) {
return values
}
const fallback = response.headers.get("set-cookie")
return fallback ? [fallback] : []
}
async function proxyFetch(url: string, init?: Parameters<typeof fetch>[1]) {
return fetch(url, { dispatcher: httpsDispatcher, ...init })
}
async function disposeManager(manager: RemoteProxySessionManager) {
const sessions = Array.from(((manager as any).sessions as Map<string, unknown>).keys())
for (const sessionId of sessions) {
await manager.deleteSession(sessionId)
}
clearInterval((manager as any).cleanupTimer as NodeJS.Timeout)
}
async function withUpstreamServer(
callback: (baseUrl: string) => Promise<void>,
handler: (req: IncomingMessage, res: ServerResponse<IncomingMessage>) => void,
) {
const server = http.createServer(handler)
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()))
try {
const address = server.address()
if (!address || typeof address === "string") {
throw new Error("Failed to resolve upstream server address")
}
await callback(`http://127.0.0.1:${address.port}`)
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
}
function createStubLogger(): Logger {
const logger = {
info() {},
warn() {},
error() {},
child() {
return logger
},
}
return logger as unknown as Logger
}

View File

@@ -3,11 +3,16 @@ import cors from "@fastify/cors"
import fastifyStatic from "@fastify/static"
import replyFrom from "@fastify/reply-from"
import fs from "fs"
import { connect as connectTcp, type Socket } from "net"
import path from "path"
import { Readable } from "stream"
import { pipeline } from "stream/promises"
import { connect as connectTls, type TLSSocket } from "tls"
import { fetch } from "undici"
import type { Logger } from "../logger"
import { WorkspaceManager } from "../workspaces/manager"
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
import { resolveWorktreeDirectory } from "../workspaces/worktree-directory"
import type { SettingsService } from "../settings/service"
import { FileSystemBrowser } from "../filesystem/browser"
@@ -22,6 +27,9 @@ import { registerPluginRoutes } from "./routes/plugin"
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { registerWorktreeRoutes } from "./routes/worktrees"
import { registerSpeechRoutes } from "./routes/speech"
import { registerRemoteServerRoutes } from "./routes/remote-servers"
import { registerRemoteProxyRoutes } from "./routes/remote-proxy"
import { registerSideCarRoutes } from "./routes/sidecars"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager"
@@ -29,6 +37,11 @@ import type { AuthManager } from "../auth/manager"
import { registerAuthRoutes } from "./routes/auth"
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
import type { SpeechService } from "../speech/service"
import { ClientConnectionManager } from "../clients/connection-manager"
import { PluginChannelManager } from "../plugins/channel"
import { VoiceModeManager } from "../plugins/voice-mode"
import type { SideCarManager } from "../sidecars/manager"
import type { RemoteProxySessionManager } from "./remote-proxy"
interface HttpServerDeps {
bindHost: string
@@ -44,7 +57,12 @@ interface HttpServerDeps {
serverMeta: ServerMeta
instanceStore: InstanceStore
speechService: SpeechService
sidecarManager: SideCarManager
authManager: AuthManager
clientConnectionManager: ClientConnectionManager
pluginChannel: PluginChannelManager
voiceModeManager: VoiceModeManager
remoteProxySessionManager: RemoteProxySessionManager
uiStaticDir: string
uiDevServerUrl?: string
logger: Logger
@@ -186,14 +204,19 @@ export function createHttpServer(deps: HttpServerDeps) {
publicPagePaths.add("/auth/token")
}
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
const isLoopbackRemoteProxyDelete =
request.method === "DELETE" &&
pathname.startsWith("/api/remote-proxy/sessions/") &&
deps.authManager.isLoopbackRequest(request)
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname) || isLoopbackRemoteProxyDelete) {
done()
return
}
const session = deps.authManager.getSessionFromRequest(request)
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/")
if (requiresAuthForApi && !session) {
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
@@ -248,15 +271,35 @@ export function createHttpServer(deps: HttpServerDeps) {
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
registerEventRoutes(app, {
eventBus: deps.eventBus,
registerClient: registerSseClient,
logger: sseLogger,
connectionManager: deps.clientConnectionManager,
})
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
registerStorageRoutes(app, {
instanceStore: deps.instanceStore,
eventBus: deps.eventBus,
workspaceManager: deps.workspaceManager,
})
registerRemoteServerRoutes(app, { logger: apiLogger })
registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager })
registerSpeechRoutes(app, { speechService: deps.speechService })
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
setupSideCarWebSocketProxy(app, {
sidecarManager: deps.sidecarManager,
authManager: deps.authManager,
logger: proxyLogger,
})
registerPluginRoutes(app, {
workspaceManager: deps.workspaceManager,
eventBus: deps.eventBus,
logger: proxyLogger,
channel: deps.pluginChannel,
voiceModeManager: deps.voiceModeManager,
})
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
@@ -331,6 +374,68 @@ interface InstanceProxyDeps {
logger: Logger
}
interface SideCarProxyDeps {
sidecarManager: SideCarManager
logger: Logger
}
interface SideCarWebSocketProxyDeps extends SideCarProxyDeps {
authManager: AuthManager
}
function registerSideCarProxyRoutes(app: FastifyInstance, deps: SideCarProxyDeps) {
const proxyBaseHandler = async (
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) => {
await proxySideCarRequest({
request,
reply,
sidecarManager: deps.sidecarManager,
logger: deps.logger,
pathSuffix: "",
})
}
const proxyWildcardHandler = async (
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
reply: FastifyReply,
) => {
await proxySideCarRequest({
request,
reply,
sidecarManager: deps.sidecarManager,
logger: deps.logger,
pathSuffix: request.params["*"] ?? "",
})
}
app.all("/sidecars/:id", proxyBaseHandler)
app.all("/sidecars/:id/*", proxyWildcardHandler)
}
function setupSideCarWebSocketProxy(app: FastifyInstance, deps: SideCarWebSocketProxyDeps) {
app.server.on("upgrade", (request, socket, head) => {
const rawUrl = request.url ?? "/"
const parsed = parseSideCarUpgradePath(rawUrl)
if (!parsed) {
return
}
void proxySideCarWebSocketUpgrade({
request,
socket: socket as Socket,
head,
sidecarId: parsed.sidecarId,
incomingPath: parsed.pathname,
search: parsed.search,
sidecarManager: deps.sidecarManager,
authManager: deps.authManager,
logger: deps.logger,
})
})
}
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
app.register(async (instance) => {
instance.removeAllContentTypeParsers()
@@ -523,57 +628,57 @@ async function proxyWorkspaceRequest(args: {
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
}
return reply.from(targetUrl, {
rewriteRequestHeaders: (_originalRequest, headers) => {
if (instanceAuthHeader) {
headers.authorization = instanceAuthHeader
}
const headers = buildWorkspaceInstanceProxyHeaders(request.headers, instanceAuthHeader, directory)
// OpenCode expects the *full* path; we send it via header to avoid query tampering.
const isNonASCII = /[^\x00-\x7F]/.test(directory)
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory
if (logger.isLevelEnabled("trace")) {
logger.trace(
{
workspaceId,
method: request.method,
targetUrl,
worktreeSlug,
directory,
contentType: request.headers["content-type"],
body: bodyToJson(request.body),
headers: redactProxyHeadersForLogs(headers),
},
"Proxy -> OpenCode request",
)
}
// Overwrite any client-provided value (case-insensitive headers are normalized by Node).
;(headers as Record<string, unknown>)["x-opencode-directory"] = encodedDirectory
const init: any = {
method: request.method,
headers,
redirect: "manual",
}
if (logger.isLevelEnabled("trace")) {
const outgoing: Record<string, unknown> = {}
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
outgoing[key] = value
}
if (request.method !== "GET" && request.method !== "HEAD") {
const body = toProxyRequestBody(request.body)
if (body !== undefined) {
init.body = body
init.duplex = "half"
}
}
// Redact sensitive headers.
for (const key of Object.keys(outgoing)) {
const lower = key.toLowerCase()
if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
outgoing[key] = "<redacted>"
}
}
try {
const response = await fetch(targetUrl, init)
reply.code(response.status)
applyInstanceProxyResponseHeaders(reply, response)
logger.trace(
{
workspaceId,
method: request.method,
targetUrl,
worktreeSlug,
directory,
contentType: request.headers["content-type"],
body: bodyToJson(request.body),
headers: outgoing,
},
"Proxy -> OpenCode request",
)
}
if (!response.body || request.method === "HEAD") {
reply.send()
return
}
return headers
},
onError: (proxyReply, { error }) => {
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
if (!proxyReply.sent) {
proxyReply.code(502).send({ error: "Workspace instance proxy failed" })
}
},
})
reply.hijack()
reply.raw.writeHead(reply.statusCode, toOutgoingHeaders(reply.getHeaders()))
await pipeline(Readable.fromWeb(response.body as any), reply.raw)
} catch (error) {
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
if (!reply.sent) {
reply.code(502).send({ error: "Workspace instance proxy failed" })
}
}
}
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
@@ -667,52 +772,6 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
return trimmed.length === 0 ? "/" : `/${trimmed}`
}
type WorktreeCacheEntry = {
expiresAt: number
repoRoot: string
worktrees: Array<{ slug: string; directory: string }>
}
const WORKTREE_CACHE_TTL_MS = 2000
const worktreeCache = new Map<string, WorktreeCacheEntry>()
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger: Logger }) {
const cached = worktreeCache.get(params.workspaceId)
const now = Date.now()
if (cached && cached.expiresAt > now) {
return cached
}
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
const entry: WorktreeCacheEntry = {
expiresAt: now + WORKTREE_CACHE_TTL_MS,
repoRoot,
worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })),
}
worktreeCache.set(params.workspaceId, entry)
return entry
}
async function resolveWorktreeDirectory(params: {
workspaceId: string
workspacePath: string
worktreeSlug: string
logger: Logger
}): Promise<string | null> {
const { worktreeSlug } = params
const cached = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug)
if (match) {
return match.directory
}
// If the slug is new (e.g., created moments ago), refresh once.
worktreeCache.delete(params.workspaceId)
const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null
}
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
if (!uiDir) {
app.log.warn("UI static directory not provided; API endpoints only")
@@ -810,8 +869,364 @@ function isApiRequest(rawUrl: string | null | undefined) {
function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, string> {
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(headers ?? {})) {
if (!value || key.toLowerCase() === "host") continue
const lower = key.toLowerCase()
if (!value || lower === "host" || isHopByHopHeader(lower)) continue
result[key] = Array.isArray(value) ? value.join(",") : value
}
return result
}
function toProxyRequestBody(body: unknown): any {
if (body == null) {
return undefined
}
if (typeof (body as { pipe?: unknown }).pipe === "function") {
return body
}
if (typeof (body as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === "function") {
return body
}
if (Buffer.isBuffer(body) || typeof body === "string" || body instanceof Uint8Array) {
return body
}
return JSON.stringify(body)
}
function buildWorkspaceInstanceProxyHeaders(
headers: FastifyRequest["headers"],
instanceAuthHeader: string | undefined,
directory: string,
): Record<string, string> {
const next = buildProxyHeaders(headers)
if (instanceAuthHeader) {
next.authorization = instanceAuthHeader
}
const isNonASCII = /[^\x00-\x7F]/.test(directory)
next["x-opencode-directory"] = isNonASCII ? encodeURIComponent(directory) : directory
return next
}
function redactProxyHeadersForLogs(headers: Record<string, string>): Record<string, string> {
const outgoing = { ...headers }
for (const key of Object.keys(outgoing)) {
const lower = key.toLowerCase()
if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
outgoing[key] = "<redacted>"
}
}
return outgoing
}
function applyInstanceProxyResponseHeaders(reply: FastifyReply, response: any) {
response.headers.forEach((value: string, key: string) => {
const lower = key.toLowerCase()
if (isHopByHopHeader(lower) || lower === "content-length" || lower === "content-encoding") {
return
}
reply.header(key, value)
})
}
function toOutgoingHeaders(headers: ReturnType<FastifyReply["getHeaders"]>): Record<string, string | string[]> {
const next: Record<string, string | string[]> = {}
for (const [key, value] of Object.entries(headers)) {
if (value === undefined) {
continue
}
next[key] = Array.isArray(value) ? value.map(String) : String(value)
}
return next
}
function isHopByHopHeader(name: string): boolean {
return new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]).has(name)
}
async function proxySideCarRequest(args: {
request: FastifyRequest
reply: FastifyReply
sidecarManager: SideCarManager
logger: Logger
pathSuffix?: string
}) {
const sidecarId = (args.request.params as { id?: string }).id ?? ""
const sidecar = await args.sidecarManager.get(sidecarId)
if (!sidecar) {
args.reply.code(404).send({ error: "SideCar not found" })
return
}
const pathname = (args.request.raw.url ?? args.request.url ?? "").split("?")[0] ?? ""
const queryIndex = (args.request.raw.url ?? args.request.url ?? "").indexOf("?")
const search = queryIndex >= 0 ? (args.request.raw.url ?? args.request.url ?? "").slice(queryIndex) : ""
const pathSuffix = args.pathSuffix ?? ""
const requestPath = pathSuffix ? `${args.sidecarManager.buildProxyBasePath(sidecarId)}/${pathSuffix.replace(/^\/+/, "")}` : args.sidecarManager.buildProxyBasePath(sidecarId)
const targetPath = args.sidecarManager.buildTargetPath(sidecarId, requestPath, search)
const targetOrigin = args.sidecarManager.buildTargetOrigin(sidecar)
const targetUrl = `${targetOrigin}${targetPath}`
args.logger.debug({ sidecarId: sidecar.id, targetUrl, pathname, prefixMode: sidecar.prefixMode }, "Proxying request to SideCar")
await args.reply.from(targetUrl, {
rewriteRequestHeaders: (_originalRequest, headers) =>
sanitizeSideCarProxyRequestHeaders(headers as Record<string, string | string[] | undefined>, targetOrigin),
rewriteHeaders: (headers) => rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, sidecar.prefixMode),
onError: (reply, { error }) => {
args.logger.error({ sidecarId: sidecar.id, err: error, targetUrl }, "Failed to proxy SideCar request")
if (!reply.sent) {
reply.code(502).send({ error: "SideCar proxy failed" })
}
},
})
}
function parseSideCarUpgradePath(rawUrl: string): { sidecarId: string; pathname: string; search: string } | null {
let parsed: URL
try {
parsed = new URL(rawUrl, "http://localhost")
} catch {
return null
}
const match = parsed.pathname.match(/^\/sidecars\/([^/]+)(?:\/.*)?$/)
if (!match) {
return null
}
try {
return {
sidecarId: decodeURIComponent(match[1] ?? ""),
pathname: parsed.pathname,
search: parsed.search,
}
} catch {
return null
}
}
async function proxySideCarWebSocketUpgrade(args: {
request: import("http").IncomingMessage
socket: Socket
head: Buffer
sidecarId: string
incomingPath: string
search: string
sidecarManager: SideCarManager
authManager: AuthManager
logger: Logger
}) {
const { request, socket, head, sidecarId, incomingPath, search, sidecarManager, authManager, logger } = args
if (!isWebSocketUpgradeRequest(request)) {
rejectUpgrade(socket, 400, "Bad Request")
return
}
const session = authManager.getSessionFromHeaders(request.headers)
if (!session) {
rejectUpgrade(socket, 401, "Unauthorized")
return
}
const sidecar = await sidecarManager.get(sidecarId)
if (!sidecar) {
rejectUpgrade(socket, 404, "Not Found")
return
}
const targetOrigin = sidecarManager.buildTargetOrigin(sidecar)
const targetPath = sidecarManager.buildTargetPath(sidecarId, incomingPath, search)
const targetUrl = new URL(`${targetOrigin}${targetPath}`)
logger.debug({ sidecarId, targetUrl: targetUrl.toString(), prefixMode: sidecar.prefixMode }, "Proxying websocket to SideCar")
const { socket: upstream, readyEvent } = createSideCarUpstreamSocket(targetUrl)
const closeBoth = () => {
if (!socket.destroyed) {
socket.destroy()
}
if (!upstream.destroyed) {
upstream.destroy()
}
}
upstream.once("error", (error) => {
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to proxy SideCar websocket")
rejectUpgrade(socket, 502, "Bad Gateway")
if (!upstream.destroyed) {
upstream.destroy()
}
})
socket.once("error", (error) => {
logger.debug({ sidecarId, err: error }, "SideCar websocket client socket errored")
if (!upstream.destroyed) {
upstream.destroy()
}
})
upstream.once(readyEvent, () => {
try {
upstream.write(buildSideCarWebSocketRequest(request, targetUrl))
if (head.length > 0) {
upstream.write(head)
}
upstream.pipe(socket)
socket.pipe(upstream)
} catch (error) {
logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to forward SideCar websocket upgrade")
closeBoth()
}
})
upstream.once("close", () => {
if (!socket.destroyed) {
socket.end()
}
})
socket.once("close", () => {
if (!upstream.destroyed) {
upstream.end()
}
})
}
function createSideCarUpstreamSocket(targetUrl: URL): { socket: Socket | TLSSocket; readyEvent: "connect" | "secureConnect" } {
const port = Number(targetUrl.port || (targetUrl.protocol === "https:" ? 443 : 80))
if (targetUrl.protocol === "https:") {
return {
socket: connectTls({
host: targetUrl.hostname,
port,
servername: targetUrl.hostname,
}),
readyEvent: "secureConnect",
}
}
return {
socket: connectTcp(port, targetUrl.hostname),
readyEvent: "connect",
}
}
function buildSideCarWebSocketRequest(request: import("http").IncomingMessage, targetUrl: URL): string {
const pathWithQuery = `${targetUrl.pathname}${targetUrl.search}`
const requestLine = `${request.method ?? "GET"} ${pathWithQuery} HTTP/${request.httpVersion}\r\n`
const headerLines: string[] = []
const rawHeaders = request.rawHeaders ?? []
const blockedHeaders = getBlockedSideCarRequestHeaders()
for (let index = 0; index < rawHeaders.length; index += 2) {
const key = rawHeaders[index]
const value = rawHeaders[index + 1]
if (!key || value === undefined) continue
const lower = key.toLowerCase()
if (blockedHeaders.has(lower)) continue
if (lower === "origin") {
headerLines.push(`Origin: ${targetUrl.origin}\r\n`)
continue
}
headerLines.push(`${key}: ${value}\r\n`)
}
const hostValue = targetUrl.port ? `${targetUrl.hostname}:${targetUrl.port}` : targetUrl.hostname
headerLines.push(`Host: ${hostValue}\r\n`)
headerLines.push("\r\n")
return requestLine + headerLines.join("")
}
function isWebSocketUpgradeRequest(request: import("http").IncomingMessage): boolean {
const upgrade = request.headers.upgrade
if (typeof upgrade !== "string" || upgrade.toLowerCase() !== "websocket") {
return false
}
const connection = request.headers.connection
const connectionValue = Array.isArray(connection) ? connection.join(",") : connection ?? ""
return connectionValue.toLowerCase().split(",").map((part) => part.trim()).includes("upgrade")
}
function rejectUpgrade(socket: Socket, statusCode: number, statusText: string) {
if (socket.destroyed) {
return
}
socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
socket.destroy()
}
function rewriteSideCarResponseHeaders(
headers: Record<string, string | string[] | undefined>,
sidecarId: string,
targetOrigin: string,
prefixMode: "strip" | "preserve",
) {
if (prefixMode === "preserve") {
return headers
}
const next = { ...headers }
const locationHeader = next.location
const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader
if (!location) {
return next
}
const publicBase = `/sidecars/${encodeURIComponent(sidecarId)}`
if (location.startsWith("/")) {
next.location = `${publicBase}${location}`
return next
}
try {
const parsed = new URL(location)
if (parsed.origin === targetOrigin) {
next.location = `${publicBase}${parsed.pathname}${parsed.search}${parsed.hash}`
}
} catch {
// Relative redirects should continue to resolve against the public sidecar path.
}
return next
}
function sanitizeSideCarProxyRequestHeaders(
headers: Record<string, string | string[] | undefined>,
targetOrigin: string,
): Record<string, string | string[] | undefined> {
const blockedHeaders = getBlockedSideCarRequestHeaders()
const next: Record<string, string | string[] | undefined> = {}
for (const [key, value] of Object.entries(headers)) {
if (!value) continue
if (blockedHeaders.has(key.toLowerCase())) continue
next[key] = value
}
next.origin = targetOrigin
return next
}
function getBlockedSideCarRequestHeaders(): Set<string> {
return new Set([
"host",
"authorization",
"proxy-authorization",
"forwarded",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-port",
"x-forwarded-proto",
])
}

View File

@@ -1,6 +1,12 @@
import os from "os"
import type { NetworkAddress } from "../api-types"
export interface ResolvedRemoteAddresses {
all: NetworkAddress[]
userVisible: NetworkAddress[]
primaryRemoteUrl?: string
}
export function resolveNetworkAddresses(args: {
host: string
protocol: "http" | "https"
@@ -58,10 +64,57 @@ export function resolveNetworkAddresses(args: {
return results.sort((a, b) => {
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
if (scopeDelta !== 0) return scopeDelta
return a.ip.localeCompare(b.ip)
return 0
})
}
export function resolveRemoteAddresses(args: {
host: string
protocol: "http" | "https"
port: number
}): ResolvedRemoteAddresses {
const all = resolveNetworkAddresses(args)
const userVisible = sortUserVisibleAddresses(all.filter((address) => address.scope === "external"))
return {
all,
userVisible,
primaryRemoteUrl: userVisible[0]?.remoteUrl,
}
}
function sortUserVisibleAddresses(addresses: NetworkAddress[]): NetworkAddress[] {
return [...addresses].sort((left, right) => getUserVisiblePriority(left.ip) - getUserVisiblePriority(right.ip))
}
function getUserVisiblePriority(ip: string): number {
if (isPrivateIPv4(ip)) return 0
if (isLinkLocalIPv4(ip)) return 2
return 1
}
function isLinkLocalIPv4(ip: string): boolean {
const octets = parseIPv4(ip)
if (!octets) return false
const [first, second] = octets
return first === 169 && second === 254
}
function isPrivateIPv4(ip: string): boolean {
const octets = parseIPv4(ip)
if (!octets) return false
const [first, second] = octets
if (first === 10) return true
if (first === 192 && second === 168) return true
return first === 172 && second >= 16 && second <= 31
}
function parseIPv4(value: string): number[] | null {
if (!isIPv4Address(value)) return null
return value.split(".").map((part) => Number(part))
}
function isIPv4Address(value: string | undefined): value is string {
if (!value) return false
const parts = value.split(".")

View File

@@ -0,0 +1,566 @@
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
import { randomBytes, randomUUID } from "crypto"
import { Readable } from "stream"
import { pipeline } from "stream/promises"
import { Agent, fetch } from "undici"
import type { AuthManager } from "../auth/manager"
import type { Logger } from "../logger"
const LOOPBACK_HOST = "127.0.0.1"
const BOOTSTRAP_PAGE_PATH = "/__codenomad/auth/token"
const BOOTSTRAP_EXCHANGE_PATH = "/__codenomad/api/auth/token"
const SESSION_IDLE_TTL_MS = 30 * 60_000
interface RemoteProxySession {
id: string
bootstrapToken: string
targetBaseUrl: URL
skipTlsVerify: boolean
localBaseUrl: URL
entryUrl: URL
bootstrapUrl: string
activated: boolean
cookiePrefix: string
app: FastifyInstance
dispatcher?: Agent
createdAt: number
lastAccessAt: number
}
export interface RemoteProxySessionManagerOptions {
authManager: AuthManager
logger: Logger
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
}
export interface RemoteProxySessionCreateResult {
sessionId: string
windowUrl: string
}
export class RemoteProxySessionManager {
private readonly sessions = new Map<string, RemoteProxySession>()
private readonly cleanupTimer: NodeJS.Timeout
constructor(private readonly options: RemoteProxySessionManagerOptions) {
this.cleanupTimer = setInterval(() => {
void this.cleanupExpiredSessions()
}, 60_000)
this.cleanupTimer.unref()
}
async createSession(baseUrl: string, skipTlsVerify: boolean): Promise<RemoteProxySessionCreateResult> {
if (!this.options.httpsOptions) {
throw new Error("Local HTTPS is required for remote proxy sessions")
}
const targetBaseUrl = normalizeBaseUrl(baseUrl)
const sessionId = randomUUID()
const bootstrapToken = randomBytes(32).toString("base64url")
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
const app = Fastify({ logger: false, https: this.options.httpsOptions })
let session: RemoteProxySession | null = null
app.removeAllContentTypeParsers()
// Preserve raw request bodies for proxying while still letting token JSON parse from Buffer.
app.addContentTypeParser("*", { parseAs: "buffer" }, (_req, body, done) => done(null, body))
app.get(BOOTSTRAP_PAGE_PATH, async (request, reply) => {
if (!this.options.authManager.isLoopbackRequest(request)) {
reply.code(404).send({ error: "Not found" })
return
}
reply.header("Cache-Control", "no-store")
reply.header("Pragma", "no-cache")
reply.header("Expires", "0")
reply.type("text/html").send(buildBootstrapPageHtml())
})
app.post(BOOTSTRAP_EXCHANGE_PATH, async (request, reply) => {
if (!this.options.authManager.isLoopbackRequest(request)) {
reply.code(404).send({ error: "Not found" })
return
}
if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" })
return
}
const body = parseTokenBody(request.body)
if (body.token !== session.bootstrapToken) {
reply.code(401).send({ error: "Invalid token" })
return
}
session.activated = true
session.lastAccessAt = Date.now()
reply.send({ ok: true })
})
app.all("/*", async (request, reply) => {
if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" })
return
}
if (!session.activated) {
reply.code(403).send({ error: "Remote proxy session is not activated" })
return
}
session.lastAccessAt = Date.now()
await proxyRequest({ request, reply, session, logger: this.options.logger })
})
app.setNotFoundHandler(async (request, reply) => {
if (!session) {
reply.code(503).send({ error: "Remote proxy session is unavailable" })
return
}
if (!session.activated) {
reply.code(403).send({ error: "Remote proxy session is not activated" })
return
}
session.lastAccessAt = Date.now()
await proxyRequest({ request, reply, session, logger: this.options.logger })
})
const addressInfo = await app.listen({ host: LOOPBACK_HOST, port: 0 })
const address = new URL(addressInfo)
const localBaseUrl = new URL(`https://${LOOPBACK_HOST}:${address.port}`)
const entryUrl = new URL(targetBaseUrl.pathname || "/", localBaseUrl)
const returnTo = buildReturnToTarget(entryUrl)
session = {
id: sessionId,
bootstrapToken,
targetBaseUrl,
skipTlsVerify,
localBaseUrl,
entryUrl,
bootstrapUrl: `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(bootstrapToken)}`,
activated: false,
cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`,
app,
dispatcher,
createdAt: Date.now(),
lastAccessAt: Date.now(),
}
this.sessions.set(sessionId, session)
this.options.logger.info(
{ sessionId, targetBaseUrl: targetBaseUrl.toString(), localBaseUrl: localBaseUrl.toString() },
"Created remote proxy session",
)
return { sessionId, windowUrl: session.bootstrapUrl }
}
async deleteSession(sessionId: string): Promise<boolean> {
return this.disposeSession(sessionId)
}
private async cleanupExpiredSessions() {
const now = Date.now()
for (const session of Array.from(this.sessions.values())) {
if (now - session.lastAccessAt <= SESSION_IDLE_TTL_MS) {
continue
}
await this.disposeSession(session.id)
}
}
private async disposeSession(sessionId: string): Promise<boolean> {
const session = this.sessions.get(sessionId)
if (!session) {
return false
}
this.sessions.delete(sessionId)
session.dispatcher?.close().catch(() => {})
await session.app.close().catch(() => {})
this.options.logger.info({ sessionId }, "Disposed remote proxy session")
return true
}
}
function normalizeBaseUrl(input: string): URL {
const parsed = new URL(input.trim())
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Server URL must use http:// or https://")
}
parsed.hash = ""
parsed.search = ""
parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/"
return parsed
}
function buildReturnToTarget(entryUrl: URL): string {
const query = entryUrl.search ? entryUrl.search : ""
return `${entryUrl.pathname || "/"}${query}`
}
function buildBootstrapPageHtml(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CodeNomad</title>
<style>
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; background: #0b0b0f; color: #fff; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
.card { width: 420px; max-width: calc(100vw - 32px); background: #14141c; border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 24px; }
h1 { font-size: 18px; margin: 0 0 12px; }
p { margin: 0; color: rgba(255,255,255,0.7); font-size: 13px; line-height: 1.4; }
.error { margin-top: 12px; color: #ff6b6b; font-size: 13px; display: none; }
</style>
</head>
<body>
<div class="card">
<h1>Connecting...</h1>
<p>Finalizing local authentication.</p>
<div id="error" class="error"></div>
</div>
<script>
const token = decodeURIComponent((location.hash || "").replace(/^#/, "").trim())
const params = new URLSearchParams(location.search)
const returnTo = sanitizeReturnTo(params.get("returnTo"))
const errorEl = document.getElementById("error")
function sanitizeReturnTo(value) {
if (!value || typeof value !== "string") return "/"
if (!value.startsWith("/")) return "/"
if (value.startsWith("//")) return "/"
return value
}
function showError(message) {
errorEl.textContent = message
errorEl.style.display = "block"
}
async function run() {
if (!token) {
showError("Missing bootstrap token.")
return
}
try {
const res = await fetch("${BOOTSTRAP_EXCHANGE_PATH}", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
credentials: "include",
})
if (!res.ok) {
let message = ""
try {
const json = await res.json()
message = json && json.error ? String(json.error) : ""
} catch {
message = ""
}
showError(message || "Token exchange failed (" + res.status + ")")
return
}
window.location.replace(returnTo)
} catch (error) {
showError(error && error.message ? error.message : String(error))
}
}
run()
</script>
</body>
</html>`
}
function parseTokenBody(body: unknown): { token: string } {
const value = normalizeJsonBody(body) as { token?: unknown } | null | undefined
const token = typeof value?.token === "string" ? value.token.trim() : ""
if (!token) {
throw new Error("Missing bootstrap token")
}
return { token }
}
function normalizeJsonBody(body: unknown): unknown {
if (Buffer.isBuffer(body)) {
return JSON.parse(body.toString("utf-8"))
}
if (typeof body === "string") {
return JSON.parse(body)
}
return body
}
function toRequestBody(body: unknown): any {
if (body == null) {
return undefined
}
if (Buffer.isBuffer(body) || typeof body === "string" || body instanceof Uint8Array) {
return body
}
return JSON.stringify(body)
}
async function proxyRequest(args: {
request: FastifyRequest
reply: FastifyReply
session: RemoteProxySession
logger: Logger
}) {
const { request, reply, session, logger } = args
const upstreamUrl = buildUpstreamUrl(session.targetBaseUrl, request.raw.url ?? request.url)
const headers = filterRequestHeaders(request.headers, session)
const init: any = {
method: request.method,
headers,
dispatcher: session.dispatcher,
redirect: "manual",
}
if (request.method !== "GET" && request.method !== "HEAD") {
const body = toRequestBody(request.body)
if (body !== undefined) {
init.body = body
init.duplex = "half"
}
}
try {
const response = await fetch(upstreamUrl, init as any)
reply.code(response.status)
applyResponseHeaders(reply, response, session)
if (!response.body || request.method === "HEAD") {
reply.send()
return
}
reply.hijack()
reply.raw.writeHead(reply.statusCode, toOutgoingHeaders(reply.getHeaders()))
await pipeline(Readable.fromWeb(response.body as any), reply.raw)
} catch (error) {
logger.error({ err: error, upstreamUrl }, "Failed to proxy remote session request")
if (!reply.sent) {
reply.code(502).send({ error: "Remote proxy request failed" })
}
}
}
function buildUpstreamUrl(baseUrl: URL, rawUrl: string): string {
const parsed = new URL(rawUrl, "https://localhost")
const url = new URL(baseUrl.toString())
url.pathname = rewriteRequestPath(baseUrl, parsed.pathname)
url.search = stripInternalQuery(parsed.search)
url.hash = ""
return url.toString()
}
function rewriteRequestPath(baseUrl: URL, requestPath: string): string {
const basePath = normalizedBasePath(baseUrl)
if (basePath === "/") {
return requestPath
}
if (requestPath === "/") {
return basePath
}
if (pathHasBasePrefix(basePath, requestPath)) {
return requestPath
}
return `${basePath}${requestPath}`
}
function normalizedBasePath(baseUrl: URL): string {
return baseUrl.pathname || "/"
}
function pathHasBasePrefix(basePath: string, requestPath: string): boolean {
return requestPath === basePath || requestPath.startsWith(`${basePath}/`)
}
function stripInternalQuery(search: string): string {
if (!search || search === "?") {
return ""
}
return search
}
function filterRequestHeaders(
headers: FastifyRequest["headers"],
session: RemoteProxySession,
): Record<string, string> {
const next: Record<string, string> = {}
for (const [key, value] of Object.entries(headers ?? {})) {
if (!value) continue
const lower = key.toLowerCase()
if (
isHopByHopHeader(lower) ||
lower === "host" ||
lower === "content-length" ||
lower === "accept-encoding"
) {
continue
}
if (lower === "origin") {
next[key] = session.targetBaseUrl.origin
continue
}
if (lower === "referer") {
const rewritten = rewriteRefererHeader(Array.isArray(value) ? value[0] : value, session.targetBaseUrl)
if (rewritten) {
next[key] = rewritten
}
continue
}
if (lower === "cookie") {
const rewritten = rewriteRequestCookieHeader(Array.isArray(value) ? value.join("; ") : value, session.cookiePrefix)
if (rewritten) {
next[key] = rewritten
}
continue
}
next[key] = Array.isArray(value) ? value.join(",") : value
}
next.host = session.targetBaseUrl.port ? `${session.targetBaseUrl.hostname}:${session.targetBaseUrl.port}` : session.targetBaseUrl.hostname
if (!next.origin) {
next.origin = session.targetBaseUrl.origin
}
return next
}
function rewriteRefererHeader(referer: string | undefined, targetBaseUrl: URL): string | null {
if (!referer) {
return null
}
try {
const parsed = new URL(referer)
const rewritten = new URL(targetBaseUrl.toString())
rewritten.pathname = rewriteRequestPath(targetBaseUrl, parsed.pathname)
rewritten.search = parsed.search
rewritten.hash = parsed.hash
return rewritten.toString()
} catch {
return null
}
}
function applyResponseHeaders(reply: FastifyReply, response: any, session: RemoteProxySession) {
const setCookie = (response.headers as any).getSetCookie?.() as string[] | undefined
if (Array.isArray(setCookie)) {
for (const cookie of setCookie) {
reply.header("set-cookie", rewriteSetCookie(cookie, session.cookiePrefix))
}
}
response.headers.forEach((value: string, key: string) => {
const lower = key.toLowerCase()
if (
isHopByHopHeader(lower) ||
lower === "set-cookie" ||
lower === "content-length" ||
lower === "content-encoding"
) {
return
}
if (lower === "location") {
reply.header(key, rewriteLocation(value, session.targetBaseUrl, session.localBaseUrl))
return
}
reply.header(key, value)
})
}
function toOutgoingHeaders(headers: ReturnType<FastifyReply["getHeaders"]>): Record<string, string | string[]> {
const next: Record<string, string | string[]> = {}
for (const [key, value] of Object.entries(headers)) {
if (value === undefined) {
continue
}
next[key] = Array.isArray(value) ? value.map(String) : String(value)
}
return next
}
function rewriteSetCookie(cookie: string, cookiePrefix: string): string {
const parts = cookie.split(";").map((part) => part.trim())
const first = parts.shift() ?? ""
const separator = first.indexOf("=")
if (separator <= 0) {
return cookie
}
const name = first.slice(0, separator).trim()
const value = first.slice(separator + 1)
const rewritten = [`${cookiePrefix}${name}=${value}`]
for (const part of parts) {
if (part.slice(0, 7).toLowerCase().startsWith("domain=")) {
continue
}
rewritten.push(part)
}
return rewritten.join("; ")
}
function rewriteRequestCookieHeader(cookieHeader: string, cookiePrefix: string): string {
const next: string[] = []
for (const rawPart of cookieHeader.split(";")) {
const part = rawPart.trim()
if (!part) continue
const separator = part.indexOf("=")
if (separator <= 0) continue
const name = part.slice(0, separator).trim()
const value = part.slice(separator + 1)
if (!name.startsWith(cookiePrefix)) {
continue
}
next.push(`${name.slice(cookiePrefix.length)}=${value}`)
}
return next.join("; ")
}
function rewriteLocation(location: string, targetBaseUrl: URL, localBaseUrl: URL): string {
try {
const parsed = new URL(location, targetBaseUrl)
if (parsed.origin !== targetBaseUrl.origin) {
return location
}
const rewritten = new URL(localBaseUrl.toString())
rewritten.pathname = parsed.pathname
rewritten.search = parsed.search
rewritten.hash = parsed.hash
return rewritten.toString()
} catch {
return location
}
}
function isHopByHopHeader(name: string): boolean {
return new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]).has(name)
}

View File

@@ -3,6 +3,7 @@ import fs from "fs"
import { z } from "zod"
import type { AuthManager } from "../../auth/manager"
import { isLoopbackAddress } from "../../auth/http-auth"
import { resolveAuthTemplatePath } from "../../runtime-paths"
interface RouteDeps {
authManager: AuthManager
@@ -21,21 +22,21 @@ const PasswordSchema = z.object({
password: z.string().min(8),
})
const LOGIN_TEMPLATE_URL = new URL("./auth-pages/login.html", import.meta.url)
const TOKEN_TEMPLATE_URL = new URL("./auth-pages/token.html", import.meta.url)
const LOGIN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "login.html")
const TOKEN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "token.html")
let cachedLoginTemplate: string | null = null
let cachedTokenTemplate: string | null = null
function readTemplate(url: URL, cache: string | null): string {
function readTemplate(filePath: string, cache: string | null): string {
if (cache) return cache
const content = fs.readFileSync(url, "utf-8")
const content = fs.readFileSync(filePath, "utf-8")
return content
}
function getLoginHtml(defaultUsername: string): string {
if (!cachedLoginTemplate) {
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null)
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_PATH, null)
}
const escapedUsername = escapeHtml(defaultUsername)
@@ -44,7 +45,7 @@ function getLoginHtml(defaultUsername: string): string {
function getTokenHtml(): string {
if (!cachedTokenTemplate) {
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_URL, null)
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_PATH, null)
}
return cachedTokenTemplate

View File

@@ -9,6 +9,21 @@ interface RouteDeps {
const StartSchema = z.object({
title: z.string().trim().min(1),
command: z.string().trim().min(1),
notify: z.boolean().optional(),
notification: z
.object({
sessionID: z.string().trim().min(1),
directory: z.string().trim().min(1),
})
.optional(),
}).superRefine((value, ctx) => {
if (value.notify && !value.notification) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Notification metadata is required when notify is enabled",
path: ["notification"],
})
}
})
const OutputQuerySchema = z.object({
@@ -27,7 +42,10 @@ export function registerBackgroundProcessRoutes(app: FastifyInstance, deps: Rout
app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => {
const payload = StartSchema.parse(request.body ?? {})
const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command)
const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command, {
notify: payload.notify,
notification: payload.notification,
})
reply.code(201)
return process
})

View File

@@ -1,19 +1,32 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { EventBus } from "../../events/bus"
import { WorkspaceEventPayload } from "../../api-types"
import type { ClientConnectionManager } from "../../clients/connection-manager"
import { Logger } from "../../logger"
interface RouteDeps {
eventBus: EventBus
registerClient: (cleanup: () => void) => () => void
logger: Logger
connectionManager: ClientConnectionManager
}
let nextClientId = 0
const ConnectionQuerySchema = z.object({
clientId: z.string().trim().min(1),
connectionId: z.string().trim().min(1),
})
const PongBodySchema = ConnectionQuerySchema.extend({
pingTs: z.number().optional(),
})
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/events", (request, reply) => {
const clientId = ++nextClientId
const connection = ConnectionQuerySchema.parse(request.query ?? {})
deps.logger.debug({ clientId }, "SSE client connected")
const origin = request.headers.origin ?? "*"
@@ -35,7 +48,8 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
const unsubscribe = deps.eventBus.onEvent(send)
const heartbeat = setInterval(() => {
reply.raw.write(`:hb ${Date.now()}\n\n`)
const ping = { ts: Date.now() }
reply.raw.write(`event: codenomad.client.ping\ndata: ${JSON.stringify(ping)}\n\n`)
}, 15000)
let closed = false
@@ -49,13 +63,27 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
}
const unregister = deps.registerClient(close)
const unregisterConnection = deps.connectionManager.register({
...connection,
close,
})
const handleClose = () => {
close()
unregister()
unregisterConnection()
}
request.raw.on("close", handleClose)
request.raw.on("error", handleClose)
})
app.post("/api/client-connections/pong", (request, reply) => {
const body = PongBodySchema.parse(request.body ?? {})
if (!deps.connectionManager.pong(body)) {
reply.code(404).send({ error: "Client connection not found" })
return
}
reply.code(204).send()
})
}

View File

@@ -1,6 +1,6 @@
import { FastifyInstance } from "fastify"
import { ServerMeta } from "../../api-types"
import { resolveNetworkAddresses } from "../network-addresses"
interface RouteDeps {
serverMeta: ServerMeta
@@ -13,14 +13,12 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
function buildMetaResponse(meta: ServerMeta): ServerMeta {
const localPort = resolveLocalPort(meta)
const remote = resolveRemote(meta)
const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : []
return {
...meta,
localPort,
remotePort: remote?.port,
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
addresses,
}
}

View File

@@ -1,15 +1,19 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import type { VoiceModeStateResponse } from "../../api-types"
import type { WorkspaceManager } from "../../workspaces/manager"
import type { EventBus } from "../../events/bus"
import type { Logger } from "../../logger"
import { PluginChannelManager } from "../../plugins/channel"
import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers"
import { VoiceModeManager } from "../../plugins/voice-mode"
interface RouteDeps {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
channel: PluginChannelManager
voiceModeManager: VoiceModeManager
}
const PluginEventSchema = z.object({
@@ -17,9 +21,13 @@ const PluginEventSchema = z.object({
properties: z.record(z.unknown()).optional(),
})
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
const VoiceModeStateSchema = z.object({
enabled: z.boolean(),
clientId: z.string().trim().min(1),
connectionId: z.string().trim().min(1),
})
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
@@ -33,10 +41,11 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
reply.raw.flushHeaders?.()
reply.hijack()
const registration = channel.register(request.params.id, reply)
const registration = deps.channel.register(request.params.id, reply)
deps.voiceModeManager.syncInstance(request.params.id)
const heartbeat = setInterval(() => {
channel.send(request.params.id, buildPingEvent())
deps.channel.send(request.params.id, buildPingEvent())
}, 15000)
const close = () => {
@@ -49,6 +58,28 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
request.raw.on("error", close)
})
app.post<{ Params: { id: string }; Body: VoiceModeStateResponse }>("/workspaces/:id/plugin/voice-mode", (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404).send({ error: "Workspace not found" })
return
}
const payload = VoiceModeStateSchema.parse(request.body ?? {})
const applied = deps.voiceModeManager.setEnabled(
request.params.id,
{ clientId: payload.clientId, connectionId: payload.connectionId },
payload.enabled,
)
if (payload.enabled && !applied) {
reply.code(409).send({ error: "Client connection not active for voice mode enable" })
return
}
return { enabled: payload.enabled }
})
const handleWildcard = async (request: any, reply: any) => {
const workspaceId = request.params.id as string
const workspace = deps.workspaceManager.get(workspaceId)

View File

@@ -0,0 +1,54 @@
import type { FastifyInstance } from "fastify"
import { z } from "zod"
import type { RemoteProxySessionCreateResponse } from "../../api-types"
import { isLoopbackAddress } from "../../auth/http-auth"
import type { Logger } from "../../logger"
import type { RemoteProxySessionManager } from "../remote-proxy"
interface RouteDeps {
logger: Logger
sessionManager: RemoteProxySessionManager
}
const CreateSessionSchema = z.object({
baseUrl: z.string().min(1),
skipTlsVerify: z.boolean().optional(),
})
const SessionParamsSchema = z.object({
id: z.string().uuid(),
})
export function registerRemoteProxyRoutes(app: FastifyInstance, deps: RouteDeps) {
app.post("/api/remote-proxy/sessions", async (request, reply): Promise<RemoteProxySessionCreateResponse | { error: string }> => {
try {
const body = CreateSessionSchema.parse(request.body ?? {})
return await deps.sessionManager.createSession(body.baseUrl, Boolean(body.skipTlsVerify))
} catch (error) {
deps.logger.warn({ err: error }, "Failed to create remote proxy session")
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to create remote proxy session" }
}
})
app.delete("/api/remote-proxy/sessions/:id", async (request, reply): Promise<{ ok: boolean } | { error: string }> => {
if (!isLoopbackAddress(request.socket.remoteAddress)) {
reply.code(404)
return { error: "Not found" }
}
try {
const params = SessionParamsSchema.parse(request.params ?? {})
const deleted = await deps.sessionManager.deleteSession(params.id)
if (!deleted) {
reply.code(404)
return { error: "Remote proxy session not found" }
}
return { ok: true }
} catch (error) {
deps.logger.warn({ err: error }, "Failed to delete remote proxy session")
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to delete remote proxy session" }
}
})
}

View File

@@ -0,0 +1,166 @@
import { Agent, fetch } from "undici"
import type { FastifyInstance } from "fastify"
import { z } from "zod"
import type { Logger } from "../../logger"
import type { RemoteServerProbeResponse } from "../../api-types"
interface RouteDeps {
logger: Logger
}
const ProbeSchema = z.object({
baseUrl: z.string().min(1),
skipTlsVerify: z.boolean().optional(),
})
const PROBE_TIMEOUT_MS = 8_000
export function registerRemoteServerRoutes(app: FastifyInstance, deps: RouteDeps) {
app.post("/api/remote-servers/probe", async (request, reply) => {
try {
const body = ProbeSchema.parse(request.body ?? {})
return await probeRemoteServer(body.baseUrl, Boolean(body.skipTlsVerify))
} catch (error) {
deps.logger.warn({ err: error }, "Failed to probe remote server")
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid request" }
}
})
}
async function probeRemoteServer(baseUrl: string, skipTlsVerify: boolean): Promise<RemoteServerProbeResponse> {
const normalizedUrl = normalizeBaseUrl(baseUrl)
const probeUrl = new URL("./api/auth/status", `${normalizedUrl}/`)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
try {
const response = await fetch(probeUrl, {
method: "GET",
dispatcher,
signal: controller.signal,
headers: {
Accept: "application/json",
},
})
if (!response.ok) {
return {
ok: false,
reachable: true,
normalizedUrl,
skipTlsVerify,
requiresAuth: false,
authenticated: false,
error: `Remote server returned HTTP ${response.status}`,
errorCode: "http_error",
}
}
const payload = (await response.json()) as { authenticated?: unknown }
if (typeof payload?.authenticated !== "boolean") {
return {
ok: false,
reachable: true,
normalizedUrl,
skipTlsVerify,
requiresAuth: false,
authenticated: false,
error: "Remote server did not return a valid CodeNomad auth response",
errorCode: "invalid_server",
}
}
return {
ok: true,
reachable: true,
normalizedUrl,
skipTlsVerify,
requiresAuth: !payload.authenticated,
authenticated: payload.authenticated,
}
} catch (error) {
const message = describeProbeError(error)
return {
ok: false,
reachable: false,
normalizedUrl,
skipTlsVerify,
requiresAuth: false,
authenticated: false,
error: message.message,
errorCode: message.code,
}
} finally {
clearTimeout(timeout)
await dispatcher?.close().catch(() => {})
}
}
function normalizeBaseUrl(input: string): string {
const parsed = new URL(input.trim())
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Server URL must use http:// or https://")
}
parsed.hash = ""
parsed.search = ""
parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/"
const value = parsed.toString()
return parsed.pathname === "/" ? value.replace(/\/$/, "") : value.replace(/\/$/, "")
}
function describeProbeError(error: unknown): { code: string; message: string } {
const chain = unwrapErrorChain(error)
const detailed =
chain.find((entry) => {
const code = (entry?.code ?? "").toString()
return Boolean(code) && code !== "UND_ERR_RESPONSE_STATUS_CODE"
}) ?? chain[0]
const code = (detailed?.code ?? "").toString()
const exactMessage = detailed?.message?.trim() || chain.find((entry) => entry.message?.trim())?.message?.trim()
if (code === "DEPTH_ZERO_SELF_SIGNED_CERT" || code === "SELF_SIGNED_CERT_IN_CHAIN" || code === "CERT_HAS_EXPIRED") {
return {
code: "tls_error",
message: "Certificate check failed while connecting to the remote server.",
}
}
return {
code:
code === "ERR_INVALID_URL"
? "invalid_url"
: code === "ECONNREFUSED"
? "connection_refused"
: code === "ENOTFOUND"
? "dns_error"
: code === "UND_ERR_CONNECT_TIMEOUT" || code === "ABORT_ERR"
? "timeout"
: code
? code.toLowerCase()
: "probe_failed",
message: exactMessage || "Failed to connect to the remote server.",
}
}
function unwrapErrorChain(error: unknown): Array<{ code?: unknown; message?: string }> {
const results: Array<{ code?: unknown; message?: string }> = []
let current: unknown = error
const seen = new Set<unknown>()
while (current && typeof current === "object" && !seen.has(current)) {
seen.add(current)
const entry = current as { code?: unknown; message?: string; cause?: unknown }
results.push({ code: entry.code, message: entry.message })
current = entry.cause
}
if (results.length === 0 && error instanceof Error) {
results.push({ message: error.message })
}
return results
}

View File

@@ -0,0 +1,56 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import type { SideCarManager } from "../../sidecars/manager"
interface RouteDeps {
sidecarManager: SideCarManager
}
const SideCarCreateSchema = z.object({
kind: z.literal("port").default("port"),
name: z.string().trim().min(1),
port: z.number().int().min(1).max(65535),
insecure: z.boolean().default(false),
prefixMode: z.enum(["strip", "preserve"]).default("strip"),
})
const SideCarUpdateSchema = SideCarCreateSchema.omit({ kind: true }).partial().refine((value) => Object.keys(value).length > 0, {
message: "At least one field is required",
})
export function registerSideCarRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/sidecars", async () => {
return { sidecars: await deps.sidecarManager.list() }
})
app.post("/api/sidecars", async (request, reply) => {
try {
const body = SideCarCreateSchema.parse(request.body ?? {})
const sidecar = await deps.sidecarManager.create(body)
reply.code(201)
return sidecar
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to create SideCar" }
}
})
app.put<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => {
try {
const body = SideCarUpdateSchema.parse(request.body ?? {})
return await deps.sidecarManager.update(request.params.id, body)
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to update SideCar" }
}
})
app.delete<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => {
const removed = await deps.sidecarManager.delete(request.params.id)
if (!removed) {
reply.code(404)
return { error: "SideCar not found" }
}
reply.code(204)
})
}

View File

@@ -1,6 +1,10 @@
import { FastifyInstance, FastifyReply } from "fastify"
import { z } from "zod"
import { WorkspaceManager } from "../../workspaces/manager"
import { getWorktreeGitDiff, getWorktreeGitStatus } from "../../workspaces/git-status"
import { commitWorktreeChanges, isGitMutationError, stageWorktreePaths, unstageWorktreePaths } from "../../workspaces/git-mutations"
import { isGitAvailable, resolveRepoRoot } from "../../workspaces/git-worktrees"
import { resolveWorktreeDirectory } from "../../workspaces/worktree-directory"
interface RouteDeps {
workspaceManager: WorkspaceManager
@@ -19,6 +23,24 @@ const WorkspaceFileContentQuerySchema = z.object({
path: z.string(),
})
const WorkspaceFileContentBodySchema = z.object({
contents: z.string(),
})
const WorktreeGitDiffQuerySchema = z.object({
path: z.string().trim().min(1, "Path is required"),
originalPath: z.string().trim().optional(),
scope: z.enum(["staged", "unstaged"]),
})
const WorktreeGitPathsBodySchema = z.object({
paths: z.array(z.string().trim().min(1, "Path is required")).min(1, "At least one path is required"),
})
const WorktreeGitCommitBodySchema = z.object({
message: z.string().trim().min(1, "Commit message is required"),
})
const WorkspaceFileSearchQuerySchema = z.object({
q: z.string().trim().min(1, "Query is required"),
limit: z.coerce.number().int().positive().max(200).optional(),
@@ -100,10 +122,152 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
return handleWorkspaceError(error, reply)
}
})
app.put<{
Params: { id: string }
Querystring: { path?: string }
}>("/api/workspaces/:id/files/content", async (request, reply) => {
try {
const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {})
const body = WorkspaceFileContentBodySchema.parse(request.body ?? {})
deps.workspaceManager.writeFile(request.params.id, query.path, body.contents)
reply.code(204)
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.get<{
Params: { id: string; slug: string }
}>("/api/workspaces/:id/worktrees/:slug/git-status", async (request, reply) => {
try {
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
if (!directory) return
return await getWorktreeGitStatus({ workspaceFolder: directory, logger: request.log })
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.get<{
Params: { id: string; slug: string }
Querystring: { path: string; originalPath?: string; scope: "staged" | "unstaged" }
}>("/api/workspaces/:id/worktrees/:slug/git-diff", async (request, reply) => {
try {
const query = WorktreeGitDiffQuerySchema.parse(request.query ?? {})
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
if (!directory) return
return await getWorktreeGitDiff({
workspaceFolder: directory,
path: query.path,
originalPath: query.originalPath,
scope: query.scope,
})
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.post<{
Params: { id: string; slug: string }
Body: { paths: string[] }
}>("/api/workspaces/:id/worktrees/:slug/git-stage", async (request, reply) => {
try {
const body = WorktreeGitPathsBodySchema.parse(request.body ?? {})
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
if (!directory) return
await stageWorktreePaths({ workspaceFolder: directory, paths: body.paths })
return { ok: true as const }
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.post<{
Params: { id: string; slug: string }
Body: { paths: string[] }
}>("/api/workspaces/:id/worktrees/:slug/git-unstage", async (request, reply) => {
try {
const body = WorktreeGitPathsBodySchema.parse(request.body ?? {})
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
if (!directory) return
await unstageWorktreePaths({ workspaceFolder: directory, paths: body.paths })
return { ok: true as const }
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.post<{
Params: { id: string; slug: string }
Body: { message: string }
}>("/api/workspaces/:id/worktrees/:slug/git-commit", async (request, reply) => {
try {
const body = WorktreeGitCommitBodySchema.parse(request.body ?? {})
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
if (!directory) return
const result = await commitWorktreeChanges({ workspaceFolder: directory, message: body.message })
return { ok: true as const, ...result }
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
}
async function resolveGitWorktreeDirectory(
workspaceManager: WorkspaceManager,
workspaceId: string,
worktreeSlug: string,
logger: { debug?: (obj: any, msg?: string) => void; warn?: (obj: any, msg?: string) => void },
reply: FastifyReply,
): Promise<string | null> {
const workspace = workspaceManager.get(workspaceId)
if (!workspace) {
reply.code(404)
reply.send({ error: "Workspace not found" })
return null
}
const gitAvailable = await isGitAvailable(workspace.path)
if (!gitAvailable) {
reply.code(503)
reply.send({ error: "Git is not installed or not available in PATH" })
return null
}
const { isGitRepo } = await resolveRepoRoot(workspace.path, logger)
if (!isGitRepo) {
reply.code(400)
reply.send({ error: "Workspace is not a Git repository" })
return null
}
const directory = await resolveWorktreeDirectory({
workspaceId: workspace.id,
workspacePath: workspace.path,
worktreeSlug,
logger,
})
if (!directory) {
reply.code(404)
reply.send({ error: "Worktree not found" })
return null
}
return directory
}
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
if (isGitMutationError(error)) {
reply.code(error.statusCode)
return { error: error.message }
}
if (error instanceof Error && error.message === "Workspace not found") {
reply.code(404)
return { error: "Workspace not found" }

View File

@@ -107,6 +107,10 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co
if (typeof listeningMode === "string") {
serverConfig.listeningMode = listeningMode
}
const logLevel = preferences.logLevel
if (typeof logLevel === "string") {
serverConfig.logLevel = logLevel
}
const lastUsedBinary = preferences.lastUsedBinary
if (typeof lastUsedBinary === "string") {
serverConfig.opencodeBinary = lastUsedBinary
@@ -135,6 +139,7 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co
const moved = new Set([
"environmentVariables",
"listeningMode",
"logLevel",
"lastUsedBinary",
"modelRecents",
"modelFavorites",

View File

@@ -1,6 +1,7 @@
import type { Logger } from "../logger"
import type { EventBus } from "../events/bus"
import type { ConfigLocation } from "../config/location"
import { z } from "zod"
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
import { migrateSettingsLayout } from "./migrate"
import type { WorkspaceEventPayload } from "../api-types"
@@ -8,6 +9,54 @@ import { sanitizeConfigOwner } from "./public-config"
export type DocKind = "config" | "state"
const CanonicalLogLevelSchema = z.preprocess(
(value) => (typeof value === "string" ? value.trim().toUpperCase() : value),
z.enum(["DEBUG", "INFO", "WARN", "ERROR"]),
)
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function isDeepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true
try {
return JSON.stringify(a) === JSON.stringify(b)
} catch {
return false
}
}
function normalizeServerConfigOwner(value: SettingsDoc): SettingsDoc {
if (!isPlainObject(value)) {
return {}
}
const next: SettingsDoc = { ...value }
const parsedLogLevel = CanonicalLogLevelSchema.safeParse(next.logLevel)
if (parsedLogLevel.success) {
next.logLevel = parsedLogLevel.data
} else if (next.logLevel !== undefined) {
next.logLevel = "DEBUG"
}
return next
}
function normalizeConfigDoc(doc: SettingsDoc): SettingsDoc {
if (!isPlainObject(doc)) {
return {}
}
if (!isPlainObject(doc.server)) {
return doc
}
return {
...doc,
server: normalizeServerConfigOwner(doc.server as SettingsDoc),
}
}
export class SettingsService {
private readonly configStore: YamlDocStore
private readonly stateStore: YamlDocStore
@@ -23,22 +72,44 @@ export class SettingsService {
}
getDoc(kind: DocKind): SettingsDoc {
return kind === "config" ? this.configStore.get() : this.stateStore.get()
if (kind !== "config") {
return this.stateStore.get()
}
const current = this.configStore.get()
const normalized = normalizeConfigDoc(current)
if (!isDeepEqual(current, normalized)) {
this.configStore.replace(normalized)
}
return normalized
}
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch)
const updated =
kind === "config"
? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch)))
: this.stateStore.mergePatch(patch)
this.publish(kind, "*")
return updated
}
getOwner(kind: DocKind, owner: string): SettingsDoc {
return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner)
if (kind !== "config") {
return this.stateStore.getOwner(owner)
}
return owner === "server"
? normalizeServerConfigOwner(this.getDoc("config").server as SettingsDoc)
: this.getDoc("config")[owner] as SettingsDoc
}
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
const updated =
kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch)
kind === "config"
? owner === "server"
? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch)))
: this.configStore.mergePatchOwner(owner, patch)
: this.stateStore.mergePatchOwner(owner, patch)
this.publish(kind, owner, updated)
return updated
}

View File

@@ -0,0 +1,256 @@
import { connect } from "net"
import type { EventBus } from "../events/bus"
import type { Logger } from "../logger"
import type { SettingsService } from "../settings/service"
import type { SideCar, SideCarKind, SideCarPrefixMode, SideCarStatus } from "../api-types"
interface SideCarManagerOptions {
settings: SettingsService
eventBus: EventBus
logger: Logger
}
interface SideCarConfigRecord {
id: string
kind: SideCarKind
name: string
port: number
insecure: boolean
prefixMode: SideCarPrefixMode
createdAt: string
updatedAt: string
}
interface SideCarRuntimeRecord {
status: SideCarStatus
}
export class SideCarManager {
private readonly configs = new Map<string, SideCarConfigRecord>()
private readonly runtime = new Map<string, SideCarRuntimeRecord>()
constructor(private readonly options: SideCarManagerOptions) {
for (const record of this.loadConfiguredSideCars()) {
this.configs.set(record.id, record)
this.runtime.set(record.id, { status: "stopped" })
}
queueMicrotask(() => {
for (const record of this.configs.values()) {
void this.refreshPortSideCar(record.id).catch((error) => {
this.options.logger.warn({ sidecarId: record.id, err: error }, "Failed to probe sidecar port")
})
}
})
}
async list(): Promise<SideCar[]> {
await this.refreshPortStatuses()
return Array.from(this.configs.values()).map((record) => this.toSideCar(record))
}
async get(id: string): Promise<SideCar | undefined> {
if (!this.configs.has(id)) return undefined
await this.refreshPortSideCar(id)
return this.toSideCar(this.requireConfig(id))
}
async create(input: {
kind: SideCarKind
name: string
port: number
insecure: boolean
prefixMode: SideCarPrefixMode
}): Promise<SideCar> {
const normalizedName = input.name.trim()
const id = this.buildSideCarId(normalizedName)
if (this.configs.has(id)) {
throw new Error(`SideCar '${id}' already exists`)
}
const now = new Date().toISOString()
const record: SideCarConfigRecord = {
id,
kind: input.kind,
name: normalizedName,
port: input.port,
insecure: input.insecure,
prefixMode: input.prefixMode,
createdAt: now,
updatedAt: now,
}
this.configs.set(record.id, record)
this.runtime.set(record.id, { status: "stopped" })
this.persistConfigs()
await this.refreshPortSideCar(record.id)
return this.toSideCar(record)
}
async update(
id: string,
input: Partial<{
name: string
port: number
insecure: boolean
prefixMode: SideCarPrefixMode
}>,
): Promise<SideCar> {
const record = this.requireConfig(id)
record.name = typeof input.name === "string" ? input.name.trim() : record.name
record.port = typeof input.port === "number" ? input.port : record.port
record.insecure = typeof input.insecure === "boolean" ? input.insecure : record.insecure
record.prefixMode = typeof input.prefixMode === "string" ? input.prefixMode : record.prefixMode
record.updatedAt = new Date().toISOString()
this.persistConfigs()
await this.refreshPortSideCar(id)
return this.toSideCar(record)
}
async delete(id: string): Promise<boolean> {
const record = this.configs.get(id)
if (!record) return false
this.configs.delete(id)
this.runtime.delete(id)
this.persistConfigs()
this.options.eventBus.publish({ type: "sidecar.removed", sidecarId: id })
return true
}
async shutdown() {
return
}
buildTargetOrigin(sidecar: Pick<SideCar, "port" | "insecure">): string {
const protocol = sidecar.insecure ? "http" : "https"
return `${protocol}://127.0.0.1:${sidecar.port}`
}
buildProxyBasePath(id: string): string {
return `/sidecars/${encodeURIComponent(id)}`
}
buildTargetPath(id: string, incomingPath: string, search = ""): string {
const record = this.requireConfig(id)
const publicBase = this.buildProxyBasePath(id)
const normalizedPath = incomingPath || publicBase
if (record.prefixMode === "preserve") {
return `${normalizedPath}${search}`
}
let stripped = normalizedPath.startsWith(publicBase) ? normalizedPath.slice(publicBase.length) : normalizedPath
if (!stripped || stripped === "/") {
stripped = "/"
} else if (!stripped.startsWith("/")) {
stripped = `/${stripped}`
}
return `${stripped}${search}`
}
private async refreshPortStatuses() {
await Promise.all(Array.from(this.configs.values()).map((record) => this.refreshPortSideCar(record.id)))
}
private async refreshPortSideCar(id: string) {
const record = this.configs.get(id)
if (!record) return
const isAvailable = await this.isPortAvailable(record.port)
const current = this.runtime.get(id)
const nextStatus: SideCarStatus = isAvailable ? "running" : "stopped"
if (current?.status === nextStatus) {
return
}
this.runtime.set(id, { status: nextStatus })
record.updatedAt = new Date().toISOString()
this.publish(id)
}
private publish(id: string) {
const record = this.configs.get(id)
if (!record) return
this.options.eventBus.publish({ type: "sidecar.updated", sidecar: this.toSideCar(record) })
}
private toSideCar(record: SideCarConfigRecord): SideCar {
const runtime = this.runtime.get(record.id)
return {
id: record.id,
kind: record.kind,
name: record.name,
port: record.port,
insecure: record.insecure,
prefixMode: record.prefixMode,
status: runtime?.status ?? "stopped",
createdAt: record.createdAt,
updatedAt: record.updatedAt,
}
}
private requireConfig(id: string): SideCarConfigRecord {
const record = this.configs.get(id)
if (!record) {
throw new Error("SideCar not found")
}
return record
}
private persistConfigs() {
const sidecars = Array.from(this.configs.values()).map((record) => ({ ...record }))
this.options.settings.mergePatchOwner("config", "server", { sidecars })
}
private loadConfiguredSideCars(): SideCarConfigRecord[] {
const serverConfig = this.options.settings.getOwner("config", "server") as { sidecars?: unknown }
const list = Array.isArray(serverConfig?.sidecars) ? serverConfig.sidecars : []
const records: SideCarConfigRecord[] = []
for (const item of list) {
if (!item || typeof item !== "object") continue
const record = item as Record<string, unknown>
const kind = record.kind === "port" ? "port" : null
const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : null
const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : null
const port = typeof record.port === "number" && Number.isInteger(record.port) ? record.port : null
if (!kind || !id || !name || !port) continue
const insecure = record.insecure === true
const prefixMode = record.prefixMode === "preserve" ? "preserve" : "strip"
const createdAt = typeof record.createdAt === "string" && record.createdAt ? record.createdAt : new Date().toISOString()
const updatedAt = typeof record.updatedAt === "string" && record.updatedAt ? record.updatedAt : createdAt
records.push({ id, kind, name, port, insecure, prefixMode, createdAt, updatedAt })
}
return records
}
private isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = connect({ port, host: "127.0.0.1" }, () => {
socket.end()
resolve(true)
})
socket.once("error", () => {
socket.destroy()
resolve(false)
})
})
}
private buildSideCarId(name: string): string {
const normalized = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-|-$/g, "")
if (!normalized) {
throw new Error("SideCar name must include letters or numbers")
}
return normalized
}
}

View File

@@ -147,19 +147,49 @@ export class OpenAICompatibleSpeechProvider {
}
const endpoint = new URL("audio/speech", ensureTrailingSlash(settings.baseUrl ?? "https://api.openai.com/v1"))
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${settings.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: settings.ttsModel,
voice: settings.ttsVoice,
input: text,
response_format: format,
}),
})
let response: Response
try {
response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${settings.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: settings.ttsModel,
voice: settings.ttsVoice,
input: text,
response_format: format,
}),
})
} catch (error) {
const detailedError = error as Error & {
cause?: unknown
code?: string
errno?: number | string
syscall?: string
address?: string
port?: number
}
this.options.logger.error(
{
err: error,
endpoint: endpoint.toString(),
baseUrl: settings.baseUrl,
model: settings.ttsModel,
voice: settings.ttsVoice,
format,
cause: detailedError.cause,
code: detailedError.code,
errno: detailedError.errno,
syscall: detailedError.syscall,
address: detailedError.address,
port: detailedError.port,
},
"speech.synthesize fetch failed",
)
throw error
}
if (!response.ok) {
const detail = await response.text()

View File

@@ -55,4 +55,31 @@ describe("resolveUi local version preference", () => {
assert.equal(result.uiStaticDir, bundledDir)
assert.equal(result.uiVersion, "0.8.1")
})
it("prefers bundled when bundled and downloaded versions are equal", async () => {
const bundledDir = path.join(tempRoot, "bundled")
const configDir = path.join(tempRoot, "config")
const currentDir = path.join(configDir, "ui", "current")
await mkdir(bundledDir, { recursive: true })
await mkdir(currentDir, { recursive: true })
writeFileSync(path.join(bundledDir, "index.html"), "<html>bundled</html>")
writeFileSync(path.join(bundledDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
writeFileSync(path.join(currentDir, "index.html"), "<html>current</html>")
writeFileSync(path.join(currentDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
const result = await resolveUi({
serverVersion: "0.8.1",
bundledUiDir: bundledDir,
autoUpdate: false,
configDir,
logger: noopLogger,
})
assert.equal(result.source, "bundled")
assert.equal(result.uiStaticDir, bundledDir)
assert.equal(result.uiVersion, "0.8.1")
})
})

View File

@@ -250,7 +250,7 @@ async function pickBestLocalUi(args: {
uiStaticDir: currentResolved,
source: "downloaded",
uiVersion: await readUiVersion(currentResolved),
priority: 2,
priority: 1,
})
}
@@ -260,7 +260,7 @@ async function pickBestLocalUi(args: {
uiStaticDir: bundledResolved,
source: "bundled",
uiVersion: await readUiVersion(bundledResolved),
priority: 1,
priority: 2,
})
}

View File

@@ -0,0 +1,121 @@
import { spawn } from "child_process"
import path from "path"
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
class GitMutationError extends Error {
statusCode: number
constructor(message: string, statusCode = 400) {
super(message)
this.name = "GitMutationError"
this.statusCode = statusCode
}
}
function runGit(args: string[], cwd: string): Promise<GitResult> {
return new Promise((resolve) => {
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
let stdout = ""
let stderr = ""
child.stdout?.on("data", (chunk) => {
stdout += chunk.toString()
})
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString()
})
child.once("error", (error) => {
resolve({ ok: false, error, stdout, stderr })
})
child.once("close", (code) => {
if (code === 0) {
resolve({ ok: true, stdout })
} else {
const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`)
resolve({ ok: false, error, stdout, stderr })
}
})
})
}
export function normalizeGitWorktreeRelativePath(input: string): string {
const normalized = input.trim().replace(/\\+/g, "/").replace(/^\.\//, "")
if (!normalized) {
throw new GitMutationError("Path is required", 400)
}
if (path.posix.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) {
throw new GitMutationError(`Absolute paths are not allowed: ${input}`, 400)
}
if (normalized === "." || normalized === "..") {
throw new GitMutationError(`Invalid path: ${input}`, 400)
}
if (normalized.startsWith("../") || normalized.includes("/../") || normalized.endsWith("/..")) {
throw new GitMutationError(`Path traversal is not allowed: ${input}`, 400)
}
return normalized
}
function normalizeGitMutationPaths(paths: string[]): string[] {
const deduped = new Set<string>()
for (const rawPath of paths) {
deduped.add(normalizeGitWorktreeRelativePath(rawPath))
}
const normalized = Array.from(deduped)
if (normalized.length === 0) {
throw new GitMutationError("At least one path is required", 400)
}
return normalized
}
async function ensureGitCommandSucceeded(resultPromise: Promise<GitResult>, fallbackMessage: string): Promise<string> {
const result = await resultPromise
if (!result.ok) {
const message = result.stderr?.trim() || result.error.message || fallbackMessage
throw new GitMutationError(message, 409)
}
return result.stdout
}
export function isGitMutationError(error: unknown): error is GitMutationError {
return error instanceof GitMutationError
}
export async function stageWorktreePaths(params: { workspaceFolder: string; paths: string[] }): Promise<void> {
const paths = normalizeGitMutationPaths(params.paths)
await ensureGitCommandSucceeded(runGit(["add", "--", ...paths], params.workspaceFolder), "Failed to stage files")
}
export async function unstageWorktreePaths(params: { workspaceFolder: string; paths: string[] }): Promise<void> {
const paths = normalizeGitMutationPaths(params.paths)
const headResult = await runGit(["rev-parse", "--verify", "HEAD"], params.workspaceFolder)
if (headResult.ok) {
await ensureGitCommandSucceeded(
runGit(["restore", "--staged", "--", ...paths], params.workspaceFolder),
"Failed to unstage files",
)
return
}
await ensureGitCommandSucceeded(
runGit(["rm", "--cached", "--quiet", "--", ...paths], params.workspaceFolder),
"Failed to unstage files",
)
}
export async function commitWorktreeChanges(params: { workspaceFolder: string; message: string }): Promise<{ commitSha?: string }> {
const message = params.message.trim()
if (!message) {
throw new GitMutationError("Commit message is required", 400)
}
await ensureGitCommandSucceeded(runGit(["commit", "-m", message], params.workspaceFolder), "Failed to create commit")
const shaResult = await runGit(["rev-parse", "HEAD"], params.workspaceFolder)
if (!shaResult.ok) {
return {}
}
const commitSha = shaResult.stdout.trim()
return commitSha ? { commitSha } : {}
}

View File

@@ -0,0 +1,385 @@
import { spawn } from "child_process"
import { readFile } from "fs/promises"
import path from "path"
import type { GitChangeKind, WorktreeGitDiffResponse, WorktreeGitDiffScope, WorktreeGitStatusEntry } from "../api-types"
import type { LogLike } from "./git-worktrees"
import { normalizeGitWorktreeRelativePath } from "./git-mutations"
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
type GitSuccessResult = Extract<GitResult, { ok: true }>
async function readFileAsDiffText(filePath: string): Promise<string> {
return readFile(filePath, "utf-8")
}
async function readGitBlobAsDiffText(resultPromise: Promise<GitResult>, missingOk = false): Promise<string> {
const result = await resultPromise
if (!result.ok) {
return decodeGitShowResult(result, missingOk)
}
return result.stdout
}
function runGit(args: string[], cwd: string, acceptedExitCodes: number[] = [0]): Promise<GitResult> {
return new Promise((resolve) => {
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
let stdout = ""
let stderr = ""
child.stdout?.on("data", (chunk) => {
stdout += chunk.toString()
})
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString()
})
child.once("error", (error) => {
resolve({ ok: false, error, stdout, stderr })
})
child.once("close", (code) => {
if (acceptedExitCodes.includes(code ?? 0)) {
resolve({ ok: true, stdout })
} else {
const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`)
resolve({ ok: false, error, stdout, stderr })
}
})
})
}
function ensureEntry(map: Map<string, WorktreeGitStatusEntry>, path: string): WorktreeGitStatusEntry {
const existing = map.get(path)
if (existing) return existing
const next: WorktreeGitStatusEntry = {
path,
originalPath: null,
stagedStatus: null,
stagedAdditions: 0,
stagedDeletions: 0,
unstagedStatus: null,
unstagedAdditions: 0,
unstagedDeletions: 0,
}
map.set(path, next)
return next
}
function normalizeGitStatusPath(value: string): string {
return value.trim().replace(/\\+/g, "/")
}
function parseGitChangeKind(code: string): GitChangeKind | null {
const normalized = code.trim().toUpperCase()
if (!normalized) return null
if (normalized === "A") return "added"
if (normalized === "M") return "modified"
if (normalized === "D") return "deleted"
if (normalized.startsWith("R")) return "renamed"
if (normalized.startsWith("C")) return "copied"
if (normalized === "U") return "unmerged"
return null
}
function applyNameStatusOutput(
map: Map<string, WorktreeGitStatusEntry>,
output: string,
target: "stagedStatus" | "unstagedStatus",
) {
const tokens = output.split("\0")
let index = 0
while (index < tokens.length) {
const record = tokens[index++] ?? ""
if (!record) continue
const parts = record.split("\t")
const statusCode = parseGitChangeKind(parts[0] ?? "")
if (!statusCode) continue
const inlinePath = parts.slice(1).join("\t")
const firstPath = inlinePath || tokens[index++] || ""
const secondPath = statusCode === "renamed" || statusCode === "copied" ? tokens[index++] || "" : ""
const path = statusCode === "renamed" || statusCode === "copied" ? secondPath || firstPath : firstPath
const normalizedPath = normalizeGitStatusPath(path)
if (!normalizedPath) continue
const entry = ensureEntry(map, normalizedPath)
entry[target] = statusCode
if (statusCode === "renamed" || statusCode === "copied") {
const originalPath = normalizeGitStatusPath(firstPath)
entry.originalPath = originalPath || entry.originalPath || null
}
}
}
function applyUntrackedOutput(map: Map<string, WorktreeGitStatusEntry>, output: string) {
for (const rawLine of output.split(/\r?\n/)) {
const path = normalizeGitStatusPath(rawLine)
if (!path) continue
ensureEntry(map, path).unstagedStatus = "untracked"
}
}
function parseSingleNumstat(output: string): { additions: number; deletions: number; isBinary: boolean; found: boolean } {
for (const rawLine of output.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line) continue
const parts = rawLine.split("\t")
const isBinary = parts[0] === "-" || parts[1] === "-"
return {
additions: isBinary ? 0 : Number.parseInt(parts[0] ?? "0", 10) || 0,
deletions: isBinary ? 0 : Number.parseInt(parts[1] ?? "0", 10) || 0,
isBinary,
found: true,
}
}
return { additions: 0, deletions: 0, isBinary: false, found: false }
}
async function getUntrackedFileNumstat(workspaceFolder: string, relativePath: string): Promise<{ additions: number; deletions: number }> {
const absolutePath = path.join(workspaceFolder, relativePath)
const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], workspaceFolder, [0, 1])
if (!result.ok) {
throw result.error
}
const parsed = parseSingleNumstat(result.stdout)
return { additions: parsed.additions, deletions: parsed.deletions }
}
async function applyUntrackedFileStats(map: Map<string, WorktreeGitStatusEntry>, workspaceFolder: string) {
const pending = Array.from(map.values())
.filter((entry) => entry.unstagedStatus === "untracked")
.map(async (entry) => {
try {
const stats = await getUntrackedFileNumstat(workspaceFolder, entry.path)
entry.unstagedAdditions = stats.additions
entry.unstagedDeletions = stats.deletions
} catch {
entry.unstagedAdditions = 0
entry.unstagedDeletions = 0
}
})
await Promise.all(pending)
}
function applyNumstatOutput(
map: Map<string, WorktreeGitStatusEntry>,
output: string,
target: "staged" | "unstaged",
) {
const tokens = output.split("\0")
let index = 0
while (index < tokens.length) {
const record = tokens[index++] ?? ""
if (!record) continue
const parts = record.split("\t")
if (parts.length < 3) continue
const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] ?? "0", 10)
const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] ?? "0", 10)
const inlinePath = parts.slice(2).join("\t")
const isRenameLike = inlinePath === ""
const originalPath = isRenameLike ? normalizeGitStatusPath(tokens[index++] ?? "") : null
const normalizedPath = normalizeGitStatusPath(isRenameLike ? tokens[index++] ?? "" : inlinePath)
if (!normalizedPath) continue
const entry = ensureEntry(map, normalizedPath)
if (originalPath) {
entry.originalPath = originalPath
}
if (target === "staged") {
entry.stagedAdditions = Number.isFinite(additions) ? additions : 0
entry.stagedDeletions = Number.isFinite(deletions) ? deletions : 0
} else {
entry.unstagedAdditions = Number.isFinite(additions) ? additions : 0
entry.unstagedDeletions = Number.isFinite(deletions) ? deletions : 0
}
}
}
export async function getWorktreeGitStatus(params: {
workspaceFolder: string
logger?: LogLike
}): Promise<WorktreeGitStatusEntry[]> {
const { workspaceFolder, logger } = params
const [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult] = await Promise.all([
runGit(["diff", "--name-status", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder),
runGit(["diff", "--name-status", "-z", "--find-renames", "--find-copies"], workspaceFolder),
runGit(["ls-files", "--others", "--exclude-standard"], workspaceFolder),
runGit(["diff", "--numstat", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder),
runGit(["diff", "--numstat", "-z", "--find-renames", "--find-copies"], workspaceFolder),
])
for (const result of [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult]) {
if (!result.ok) {
logger?.warn?.({ workspaceFolder, err: result.error }, "Failed to read git status for worktree")
throw result.error
}
}
const stagedOutput = (stagedResult as GitSuccessResult).stdout
const unstagedOutput = (unstagedResult as GitSuccessResult).stdout
const untrackedOutput = (untrackedResult as GitSuccessResult).stdout
const stagedNumstatOutput = (stagedNumstatResult as GitSuccessResult).stdout
const unstagedNumstatOutput = (unstagedNumstatResult as GitSuccessResult).stdout
const entries = new Map<string, WorktreeGitStatusEntry>()
applyNameStatusOutput(entries, stagedOutput, "stagedStatus")
applyNameStatusOutput(entries, unstagedOutput, "unstagedStatus")
applyUntrackedOutput(entries, untrackedOutput)
applyNumstatOutput(entries, stagedNumstatOutput, "staged")
applyNumstatOutput(entries, unstagedNumstatOutput, "unstaged")
await applyUntrackedFileStats(entries, workspaceFolder)
return Array.from(entries.values()).sort((a, b) => a.path.localeCompare(b.path))
}
function decodeGitShowResult(result: GitResult, missingOk = false): string {
if (result.ok) return result.stdout
const message = result.stderr?.trim() || result.error.message || ""
if (
missingOk &&
(message.includes("exists on disk, but not in") ||
message.includes("Path '") ||
message.includes("does not exist") ||
message.includes("unknown revision or path not in the working tree"))
) {
return ""
}
throw result.error
}
async function readGitIndexBlob(workspaceFolder: string, normalizedPath: string): Promise<GitResult> {
return runGit(["cat-file", "-p", `:${normalizedPath}`], workspaceFolder)
}
async function getTrackedDiffMetadata(params: {
workspaceFolder: string
scope: WorktreeGitDiffScope
normalizedPath: string
normalizedOriginalPath: string | null
}): Promise<{ isBinary: boolean; found: boolean }> {
const args = ["diff", "--numstat"]
if (params.scope === "staged") {
args.push("--cached")
}
args.push("--find-renames", "--find-copies", "--")
args.push(params.normalizedPath)
if (params.normalizedOriginalPath && params.normalizedOriginalPath !== params.normalizedPath) {
args.push(params.normalizedOriginalPath)
}
const result = await runGit(args, params.workspaceFolder)
if (!result.ok) {
throw result.error
}
const parsed = parseSingleNumstat(result.stdout)
return { isBinary: parsed.isBinary, found: parsed.found }
}
async function getUntrackedDiffMetadata(params: {
workspaceFolder: string
normalizedPath: string
}): Promise<{ isBinary: boolean }> {
const absolutePath = path.join(params.workspaceFolder, params.normalizedPath)
const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], params.workspaceFolder, [0, 1])
if (!result.ok) {
throw result.error
}
return { isBinary: parseSingleNumstat(result.stdout).isBinary }
}
async function resolveUnstagedBeforePath(params: {
workspaceFolder: string
normalizedPath: string
normalizedOriginalPath: string | null
}): Promise<GitResult> {
const currentPathResult = await readGitIndexBlob(params.workspaceFolder, params.normalizedPath)
if (currentPathResult.ok || !params.normalizedOriginalPath || params.normalizedOriginalPath === params.normalizedPath) {
return currentPathResult
}
return readGitIndexBlob(params.workspaceFolder, params.normalizedOriginalPath)
}
export async function getWorktreeGitDiff(params: {
workspaceFolder: string
path: string
originalPath?: string | null
scope: WorktreeGitDiffScope
}): Promise<WorktreeGitDiffResponse> {
const normalizedPath = normalizeGitWorktreeRelativePath(params.path)
const normalizedOriginalPath = params.originalPath ? normalizeGitWorktreeRelativePath(params.originalPath) : null
const trackedMetadata = await getTrackedDiffMetadata({
workspaceFolder: params.workspaceFolder,
scope: params.scope,
normalizedPath,
normalizedOriginalPath,
})
const diffMetadata =
params.scope === "unstaged" && !trackedMetadata.found
? await getUntrackedDiffMetadata({
workspaceFolder: params.workspaceFolder,
normalizedPath,
})
: trackedMetadata
if (diffMetadata.isBinary) {
return {
path: normalizedPath,
originalPath: normalizedOriginalPath,
scope: params.scope,
before: "",
after: "",
isBinary: true,
}
}
if (params.scope === "staged") {
const [beforeResult, afterResult] = await Promise.all([
readGitBlobAsDiffText(runGit(["show", `HEAD:${normalizedOriginalPath ?? normalizedPath}`], params.workspaceFolder), true),
readGitBlobAsDiffText(readGitIndexBlob(params.workspaceFolder, normalizedPath), true),
])
return {
path: normalizedPath,
originalPath: normalizedOriginalPath,
scope: params.scope,
before: beforeResult,
after: afterResult,
isBinary: false,
}
}
const indexResult = await resolveUnstagedBeforePath({
workspaceFolder: params.workspaceFolder,
normalizedPath,
normalizedOriginalPath,
})
const beforeResult = await readGitBlobAsDiffText(Promise.resolve(indexResult), true)
let after = beforeResult
const fsPath = path.join(params.workspaceFolder, normalizedPath)
try {
after = await readFileAsDiffText(fsPath)
} catch {
after = ""
}
return {
path: normalizedPath,
originalPath: normalizedOriginalPath,
scope: params.scope,
before: beforeResult,
after,
isBinary: false,
}
}

View File

@@ -10,6 +10,10 @@ export interface LogLike {
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
function isGitUnavailableResult(result: GitResult): boolean {
return !result.ok && (result.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"
}
function runGit(args: string[], cwd: string): Promise<GitResult> {
return new Promise((resolve) => {
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
@@ -38,6 +42,9 @@ function runGit(args: string[], cwd: string): Promise<GitResult> {
export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> {
const result = await runGit(["rev-parse", "--show-toplevel"], folder)
if (isGitUnavailableResult(result)) {
throw new Error("Git is not installed or not available in PATH")
}
if (!result.ok) {
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
return { repoRoot: folder, isGitRepo: false }
@@ -49,6 +56,11 @@ export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise
return { repoRoot, isGitRepo: true }
}
export async function isGitAvailable(folder: string): Promise<boolean> {
const result = await runGit(["--version"], folder)
return result.ok || !isGitUnavailableResult(result)
}
function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> {
const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = []
const lines = output.split(/\r?\n/)
@@ -90,15 +102,22 @@ export async function listWorktrees(params: {
logger?: LogLike
}): Promise<WorktreeDescriptor[]> {
const { repoRoot, workspaceFolder, logger } = params
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
if (!result.ok) {
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only")
return [rootDescriptor]
}
const records = parseWorktreePorcelain(result.stdout)
const rootRecord = records.find((record) => path.resolve(record.worktree) === path.resolve(repoRoot))
const rootDescriptor: WorktreeDescriptor = {
slug: "root",
directory: repoRoot,
kind: "root",
branch: rootRecord?.branch,
}
const worktrees: WorktreeDescriptor[] = [rootDescriptor]
const seen = new Set<string>(["root"])

View File

@@ -21,6 +21,70 @@ import {
const STARTUP_STABILITY_DELAY_MS = 1500
function defaultShellPath(): string {
const configured = process.env.SHELL?.trim()
if (configured) {
return configured
}
return process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"
}
function shellEscape(input: string): string {
if (!input) return "''"
return `'${input.replace(/'/g, `'\\''`)}'`
}
function wrapCommandForShell(command: string, shellPath: string): string {
const shellName = path.basename(shellPath).toLowerCase()
if (shellName.includes("bash")) {
return `if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; ${command}`
}
if (shellName.includes("zsh")) {
return `if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; ${command}`
}
return command
}
function buildShellArgs(shellPath: string, command: string): string[] {
const shellName = path.basename(shellPath).toLowerCase()
if (shellName.includes("zsh")) {
return ["-l", "-i", "-c", command]
}
return ["-l", "-c", command]
}
function resolveBinaryPathFromUserShell(identifier: string): string | null {
if (process.platform === "win32") {
return null
}
const shellPath = defaultShellPath()
const lookupCommand = wrapCommandForShell(`command -v ${shellEscape(identifier)}`, shellPath)
const result = spawnSync(shellPath, buildShellArgs(shellPath, lookupCommand), {
encoding: "utf8",
env: {
...process.env,
npm_config_prefix: undefined,
NPM_CONFIG_PREFIX: undefined,
},
})
if (result.status !== 0) {
return null
}
const resolved = String(result.stdout ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0)
return resolved ?? null
}
interface WorkspaceManagerOptions {
rootDir: string
settings: SettingsService
@@ -83,6 +147,12 @@ export class WorkspaceManager {
}
}
writeFile(workspaceId: string, relativePath: string, contents: string): void {
const workspace = this.requireWorkspace(workspaceId)
const browser = new FileSystemBrowser({ rootDir: workspace.path })
browser.writeFile(relativePath, contents)
}
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
const id = `${Date.now().toString(36)}`
@@ -136,12 +206,15 @@ export class WorkspaceManager {
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
}
const logLevel = (serverConfig as any)?.logLevel
try {
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
workspaceId: id,
folder: workspacePath,
binaryPath: resolvedBinaryPath,
environment,
logLevel,
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
})
@@ -257,6 +330,12 @@ export class WorkspaceManager {
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
}
const shellResolved = resolveBinaryPathFromUserShell(identifier)
if (shellResolved) {
this.options.logger.debug({ identifier, resolved: shellResolved }, "Resolved binary path from user shell")
return shellResolved
}
return identifier
}

View File

@@ -116,6 +116,7 @@ interface LaunchOptions {
folder: string
binaryPath: string
environment?: Record<string, string>
logLevel?: string
onExit?: (info: ProcessExitInfo) => void
}
@@ -139,7 +140,8 @@ export class WorkspaceRuntime {
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
this.validateFolder(options.folder)
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
const logLevel = typeof options.logLevel === "string" ? options.logLevel.toUpperCase() : "DEBUG"
const args = ["serve", "--port", "0", "--print-logs", "--log-level", logLevel]
const env = { ...process.env, ...(options.environment ?? {}) }
let exitResolve: ((info: ProcessExitInfo) => void) | null = null

View File

@@ -0,0 +1,99 @@
import { realpath } from "fs/promises"
import type { LogLike } from "./git-worktrees"
import { listWorktrees, resolveRepoRoot } from "./git-worktrees"
type WorktreeCacheEntry = {
expiresAt: number
repoRoot: string
worktrees: Array<{ slug: string; directory: string; normalizedDirectory: string }>
}
const WORKTREE_CACHE_TTL_MS = 2000
const worktreeCache = new Map<string, WorktreeCacheEntry>()
async function normalizeDirectoryPath(directory: string): Promise<string> {
const trimmed = (directory ?? "").trim()
if (!trimmed) return ""
try {
return await realpath(trimmed)
} catch {
return trimmed
}
}
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger?: LogLike }) {
const cached = worktreeCache.get(params.workspaceId)
const now = Date.now()
if (cached && cached.expiresAt > now) {
return cached
}
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
const entry: WorktreeCacheEntry = {
expiresAt: now + WORKTREE_CACHE_TTL_MS,
repoRoot,
worktrees: await Promise.all(
worktrees.map(async (wt) => ({
slug: wt.slug,
directory: wt.directory,
normalizedDirectory: await normalizeDirectoryPath(wt.directory),
})),
),
}
worktreeCache.set(params.workspaceId, entry)
return entry
}
export async function resolveWorktreeDirectory(params: {
workspaceId: string
workspacePath: string
worktreeSlug: string
logger?: LogLike
}): Promise<string | null> {
const cached = await getCachedWorktrees({
workspaceId: params.workspaceId,
workspacePath: params.workspacePath,
logger: params.logger,
})
const match = cached.worktrees.find((wt) => wt.slug === params.worktreeSlug)
if (match) {
return match.directory
}
worktreeCache.delete(params.workspaceId)
const refreshed = await getCachedWorktrees({
workspaceId: params.workspaceId,
workspacePath: params.workspacePath,
logger: params.logger,
})
return refreshed.worktrees.find((wt) => wt.slug === params.worktreeSlug)?.directory ?? null
}
export async function resolveWorktreeSlugForDirectory(params: {
workspaceId: string
workspacePath: string
directory: string
logger?: LogLike
}): Promise<string | null> {
const target = await normalizeDirectoryPath(params.directory ?? "")
if (!target) return null
const cached = await getCachedWorktrees({
workspaceId: params.workspaceId,
workspacePath: params.workspacePath,
logger: params.logger,
})
const match = cached.worktrees.find((wt) => wt.normalizedDirectory === target)
if (match) {
return match.slug
}
worktreeCache.delete(params.workspaceId)
const refreshed = await getCachedWorktrees({
workspaceId: params.workspaceId,
workspacePath: params.workspacePath,
logger: params.logger,
})
return refreshed.worktrees.find((wt) => wt.normalizedDirectory === target)?.slug ?? null
}

View File

@@ -213,6 +213,28 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "base64"
version = "0.21.7"
@@ -408,6 +430,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -444,6 +468,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
@@ -456,17 +486,28 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "codenomad-tauri"
version = "0.12.3"
version = "0.14.0"
dependencies = [
"anyhow",
"base64 0.22.1",
"dirs 5.0.1",
"keepawake",
"libc",
"once_cell",
"parking_lot",
"regex",
"reqwest 0.12.28",
"rustls",
"serde",
"serde_json",
"serde_yaml",
@@ -476,8 +517,8 @@ dependencies = [
"tauri-plugin-global-shortcut",
"tauri-plugin-notification",
"tauri-plugin-opener",
"thiserror 1.0.69",
"url",
"webkit2gtk",
"which",
"windows-sys 0.59.0",
]
@@ -969,6 +1010,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -1139,6 +1189,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futf"
version = "0.1.5"
@@ -1379,8 +1435,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -1390,9 +1448,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1574,6 +1634,25 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.13.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1699,6 +1778,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1710,6 +1790,23 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -1999,6 +2096,16 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.91"
@@ -2157,6 +2264,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
@@ -2995,6 +3108,61 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -3212,6 +3380,50 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
[[package]]
name = "reqwest"
version = "0.13.2"
@@ -3242,7 +3454,7 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"wasm-streams 0.5.0",
"web-sys",
]
@@ -3270,6 +3482,20 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -3311,6 +3537,44 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -3531,6 +3795,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.18.0"
@@ -3792,6 +4068,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -3943,7 +4225,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"serde_repr",
@@ -4367,6 +4649,21 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.50.0"
@@ -4381,6 +4678,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -4691,6 +4998,12 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -4902,6 +5215,19 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
@@ -4937,6 +5263,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.3"
@@ -4993,6 +5329,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -5286,6 +5631,15 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -5927,6 +6281,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.3"

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.12.3",
"version": "0.14.0",
"private": true,
"license": "MIT",
"scripts": {
@@ -8,11 +8,12 @@
"dev:ui": "npm run dev --workspace @codenomad/ui",
"dev:prep": "node ./scripts/dev-prep.js",
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
"sync:version": "node ./scripts/sync-tauri-version.js",
"prebuild": "node ./scripts/prebuild.js",
"bundle:server": "npm run prebuild",
"build": "tauri build"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
"@tauri-apps/cli": "^2.10.1"
}
}

View File

@@ -21,6 +21,7 @@ const serverDevInstallCommand =
const uiDevInstallCommand =
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
const serverStandaloneBuildCommand = "npm run build:standalone --workspace @neuralnomads/codenomad"
const envWithRootBin = {
...process.env,
@@ -37,6 +38,12 @@ const braceExpansionPath = path.join(
"package.json",
)
const serverBuildDependencyPaths = [
path.join(serverRoot, "node_modules", "typescript", "package.json"),
path.join(serverRoot, "node_modules", "@types", "node-forge", "package.json"),
path.join(serverRoot, "node_modules", "@types", "yauzl", "package.json"),
]
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
async function ensureMonacoAssets() {
@@ -56,11 +63,7 @@ async function ensureMonacoAssets() {
function ensureServerBuild() {
const distPath = path.join(serverRoot, "dist")
const publicPath = path.join(serverRoot, "public")
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
return
}
console.log("[prebuild] server build missing; running workspace build...")
console.log("[prebuild] rebuilding server workspace for desktop packaging...")
execSync("npm --workspace @neuralnomads/codenomad run build", {
cwd: workspaceRoot,
stdio: "inherit",
@@ -75,6 +78,15 @@ function ensureServerBuild() {
}
}
function ensureStandaloneServerBuild() {
console.log("[prebuild] building standalone server executable...")
execSync(serverStandaloneBuildCommand, {
cwd: workspaceRoot,
stdio: "inherit",
env: envWithRootBin,
})
}
function ensureUiBuild() {
const loadingHtml = path.join(uiDist, "loading.html")
if (fs.existsSync(loadingHtml)) {
@@ -102,7 +114,7 @@ function syncServerUiBundle() {
}
function ensureServerDevDependencies() {
if (fs.existsSync(braceExpansionPath)) {
if (serverBuildDependencyPaths.every((filePath) => fs.existsSync(filePath))) {
return
}
@@ -115,15 +127,19 @@ function ensureServerDevDependencies() {
}
function ensureServerDependencies() {
if (fs.existsSync(braceExpansionPath)) {
return
}
console.log("[prebuild] ensuring server production dependencies...")
execSync(serverInstallCommand, {
console.log("[prebuild] pruning server to production dependencies...")
execSync("npm prune --omit=dev --ignore-scripts --workspaces=false --fund=false --audit=false", {
cwd: serverRoot,
stdio: "inherit",
})
if (!fs.existsSync(braceExpansionPath)) {
console.log("[prebuild] restoring missing server production dependencies...")
execSync(serverInstallCommand, {
cwd: serverRoot,
stdio: "inherit",
})
}
}
function ensureUiDevDependencies() {
@@ -146,6 +162,7 @@ function ensureRollupPlatformBinary() {
"linux-arm64": "@rollup/rollup-linux-arm64-gnu",
"darwin-arm64": "@rollup/rollup-darwin-arm64",
"darwin-x64": "@rollup/rollup-darwin-x64",
"win32-arm64": "@rollup/rollup-win32-arm64-msvc",
"win32-x64": "@rollup/rollup-win32-x64-msvc",
}
@@ -175,6 +192,47 @@ function ensureRollupPlatformBinary() {
})
}
function ensureEsbuildPlatformBinary() {
const platformKey = `${process.platform}-${process.arch}`
const platformPackages = {
"linux-x64": "@esbuild/linux-x64",
"linux-arm64": "@esbuild/linux-arm64",
"darwin-arm64": "@esbuild/darwin-arm64",
"darwin-x64": "@esbuild/darwin-x64",
"win32-arm64": "@esbuild/win32-arm64",
"win32-x64": "@esbuild/win32-x64",
}
const pkgName = platformPackages[platformKey]
if (!pkgName) {
return
}
const platformPackagePath = path.join(workspaceRoot, "node_modules", ...pkgName.split("/"))
if (fs.existsSync(platformPackagePath)) {
return
}
let esbuildVersion = ""
try {
esbuildVersion = require(path.join(workspaceRoot, "node_modules", "esbuild", "package.json")).version
} catch {
try {
esbuildVersion = require(path.join(workspaceRoot, "node_modules", "vite", "node_modules", "esbuild", "package.json")).version
} catch {
// leave version empty; fallback install will use latest compatible
}
}
const packageSpec = esbuildVersion ? `${pkgName}@${esbuildVersion}` : pkgName
console.log("[prebuild] installing esbuild platform binary (optional dep workaround)...")
execSync(`npm install ${packageSpec} --no-save --ignore-scripts --fund=false --audit=false`, {
cwd: workspaceRoot,
stdio: "inherit",
})
}
function copyServerArtifacts() {
fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true })
@@ -253,8 +311,10 @@ function copyUiLoadingAssets() {
ensureUiDevDependencies()
await ensureMonacoAssets()
ensureRollupPlatformBinary()
ensureServerDependencies()
ensureEsbuildPlatformBinary()
ensureServerBuild()
ensureStandaloneServerBuild()
ensureServerDependencies()
ensureUiBuild()
syncServerUiBundle()
copyServerArtifacts()

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
const root = path.resolve(__dirname, "..")
const packageJsonPath = path.join(root, "package.json")
const cargoTomlPath = path.join(root, "src-tauri", "Cargo.toml")
const cargoLockPath = path.join(root, "Cargo.lock")
const tauriConfigPath = path.join(root, "src-tauri", "tauri.conf.json")
function readPackageVersion() {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error("Missing version in packages/tauri-app/package.json")
}
return packageJson.version
}
function syncCargoToml(version) {
const current = fs.readFileSync(cargoTomlPath, "utf8")
const packageVersionPattern = /(\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/m
const match = current.match(packageVersionPattern)
if (!match) {
throw new Error("Unable to find [package] version in packages/tauri-app/src-tauri/Cargo.toml")
}
if (match[2] === version) {
return false
}
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
fs.writeFileSync(cargoTomlPath, updated)
return true
}
function syncCargoLock(version) {
if (!fs.existsSync(cargoLockPath)) {
return false
}
const current = fs.readFileSync(cargoLockPath, "utf8")
const packageVersionPattern = /(\[\[package\]\]\r?\nname = "codenomad-tauri"\r?\nversion = ")([^"]+)(")/
const match = current.match(packageVersionPattern)
if (!match) {
throw new Error("Unable to find codenomad-tauri version in packages/tauri-app/Cargo.lock")
}
if (match[2] === version) {
return false
}
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
fs.writeFileSync(cargoLockPath, updated)
return true
}
function syncTauriConfig(version) {
const current = fs.readFileSync(tauriConfigPath, "utf8")
const config = JSON.parse(current)
if (config.version === version) {
return false
}
config.version = version
fs.writeFileSync(tauriConfigPath, `${JSON.stringify(config, null, 2)}\n`)
return true
}
function main() {
const version = readPackageVersion()
const changed = []
if (syncCargoToml(version)) {
changed.push(path.relative(root, cargoTomlPath))
}
if (syncCargoLock(version)) {
changed.push(path.relative(root, cargoLockPath))
}
if (syncTauriConfig(version)) {
changed.push(path.relative(root, tauriConfigPath))
}
if (changed.length === 0) {
console.log(`[sync-tauri-version] already aligned to ${version}`)
return
}
console.log(`[sync-tauri-version] synced ${version} -> ${changed.join(", ")}`)
}
try {
main()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`[sync-tauri-version] failed: ${message}`)
process.exit(1)
}

View File

@@ -1,21 +1,22 @@
[package]
name = "codenomad-tauri"
version = "0.12.3"
version = "0.14.0"
edition = "2021"
license = "MIT"
[build-dependencies]
tauri-build = { version = "2.5.2", features = [] }
tauri-build = { version = "2.5.6", features = [] }
[dependencies]
tauri = { version = "2.5.2", features = [ "devtools"] }
tauri = { version = "2.10.1", features = [ "devtools"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
base64 = "0.22"
rustls = { version = "0.23", features = ["ring"] }
reqwest = { version = "0.12", default-features = false, features = ["http2", "charset", "json", "stream", "rustls-tls"] }
regex = "1"
once_cell = "1"
parking_lot = "0.12"
thiserror = "1"
anyhow = "1"
which = "4"
libc = "0.2"
@@ -28,4 +29,7 @@ url = "2"
tauri-plugin-notification = "2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }
[target.'cfg(target_os = "linux")'.dependencies]
webkit2gtk = "2.0.2"

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>CodeNomad needs microphone access for speech-to-text prompt input.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>CodeNomad needs local network access to connect to locally hosted AI and speech services.</string>
</dict>
</plist>

View File

@@ -1 +1 @@
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}

View File

@@ -2378,6 +2378,72 @@
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
"type": "string",
"const": "global-shortcut:default",
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
},
{
"description": "Enables the is_registered command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-is-registered",
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
},
{
"description": "Enables the register command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-register",
"markdownDescription": "Enables the register command without any pre-configured scope."
},
{
"description": "Enables the register_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-register-all",
"markdownDescription": "Enables the register_all command without any pre-configured scope."
},
{
"description": "Enables the unregister command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-unregister",
"markdownDescription": "Enables the unregister command without any pre-configured scope."
},
{
"description": "Enables the unregister_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-unregister-all",
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
},
{
"description": "Denies the is_registered command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-is-registered",
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
},
{
"description": "Denies the register command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-register",
"markdownDescription": "Denies the register command without any pre-configured scope."
},
{
"description": "Denies the register_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-register-all",
"markdownDescription": "Denies the register_all command without any pre-configured scope."
},
{
"description": "Denies the unregister command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-unregister",
"markdownDescription": "Denies the unregister command without any pre-configured scope."
},
{
"description": "Denies the unregister_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-unregister-all",
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -0,0 +1,9 @@
[Desktop Entry]
Categories=
Exec=codenomad-tauri
StartupWMClass=codenomad-tauri
Icon=codenomad-tauri
Name=CodeNomad
NoDisplay=true
Terminal=false
Type=Application

View File

@@ -0,0 +1,449 @@
use base64::Engine;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
const TLS_DIR_NAME: &str = "tls";
const CA_CERT_FILE: &str = "ca-cert.pem";
const SERVER_CERT_FILE: &str = "server-cert.pem";
const SERVER_KEY_FILE: &str = "server-key.pem";
const TRUSTED_MARKER: &str = "server-ca.trusted";
#[cfg(windows)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
/// Holds the PEM-encoded certificate/key pair used by the local HTTPS proxy,
/// plus the CA certificate DER used for trust-store installation.
pub struct LocalCert {
pub cert_pem: String,
pub key_pem: String,
pub ca_cert_der: Vec<u8>,
}
struct TlsAssetPaths {
cert_path: PathBuf,
key_path: PathBuf,
trust_path: PathBuf,
append_ca_to_cert: bool,
}
/// Loads the TLS assets already managed by `packages/server`.
pub fn ensure_local_cert() -> Result<LocalCert, String> {
let assets = resolve_tls_asset_paths()?;
let mut cert_pem = read_pem_file(&assets.cert_path)?;
let key_pem = read_pem_file(&assets.key_path)?;
let trust_pem = read_pem_file(&assets.trust_path)?;
if assets.append_ca_to_cert {
cert_pem = format!("{}\n{}\n", cert_pem.trim(), trust_pem.trim());
}
let ca_cert_der = pem_to_der(&trust_pem)?;
Ok(LocalCert {
cert_pem,
key_pem,
ca_cert_der,
})
}
fn read_pem_file(path: &Path) -> Result<String, String> {
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))
}
fn server_tls_dir() -> Result<PathBuf, String> {
Ok(resolve_server_config_base_dir()?.join(TLS_DIR_NAME))
}
fn resolve_tls_asset_paths() -> Result<TlsAssetPaths, String> {
let tls_key_path = env::var("CLI_TLS_KEY")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| resolve_path_like_server(&value))
.transpose()?;
let tls_cert_path = env::var("CLI_TLS_CERT")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| resolve_path_like_server(&value))
.transpose()?;
let tls_ca_path = env::var("CLI_TLS_CA")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| resolve_path_like_server(&value))
.transpose()?;
match (tls_key_path, tls_cert_path) {
(Some(key_path), Some(cert_path)) => {
let append_ca_to_cert = tls_ca_path.is_some();
let trust_path = tls_ca_path.unwrap_or_else(|| cert_path.clone());
Ok(TlsAssetPaths {
cert_path,
key_path,
trust_path,
append_ca_to_cert,
})
}
(Some(_), None) | (None, Some(_)) => Err(
"CLI_TLS_KEY and CLI_TLS_CERT must both be set when using custom TLS files"
.to_string(),
),
(None, None) => {
let tls_dir = server_tls_dir()?;
Ok(TlsAssetPaths {
cert_path: tls_dir.join(SERVER_CERT_FILE),
key_path: tls_dir.join(SERVER_KEY_FILE),
trust_path: tls_dir.join(CA_CERT_FILE),
append_ca_to_cert: true,
})
}
}
}
fn resolve_server_config_base_dir() -> Result<PathBuf, String> {
let raw = env::var("CLI_CONFIG")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
let expanded = resolve_path_like_server(&raw)?;
let lower = raw.trim().to_lowercase();
if lower.ends_with(".yaml") || lower.ends_with(".yml") || lower.ends_with(".json") {
return expanded
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| format!("Failed to determine config base dir from {}", expanded.display()));
}
Ok(expanded)
}
fn resolve_path_like_server(path: &str) -> Result<PathBuf, String> {
if path.starts_with("~/") {
let home = dirs::home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from));
let home = home.ok_or_else(|| "Cannot determine home directory".to_string())?;
return Ok(home.join(path.trim_start_matches("~/")));
}
let path = PathBuf::from(path);
if path.is_absolute() {
return Ok(path);
}
let cwd = env::current_dir().map_err(|e| format!("Failed to read current dir: {e}"))?;
Ok(cwd.join(path))
}
fn trusted_marker_path() -> Result<PathBuf, String> {
let base = dirs::data_local_dir()
.ok_or_else(|| "Cannot determine local app data directory".to_string())?;
#[cfg(windows)]
{
return Ok(base.join(WINDOWS_APP_USER_MODEL_ID).join(TRUSTED_MARKER));
}
#[cfg(not(windows))]
{
Ok(base.join("codenomad").join(TRUSTED_MARKER))
}
}
fn trusted_marker_value(cert_der: &[u8]) -> String {
cert_der.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn trusted_marker_file_suffix(cert_der: &[u8]) -> String {
trusted_marker_value(cert_der).chars().take(16).collect()
}
fn has_matching_trusted_marker(cert_der: &[u8]) -> bool {
trusted_marker_path()
.ok()
.and_then(|path| fs::read_to_string(path).ok())
.map(|value| value.trim() == trusted_marker_value(cert_der))
.unwrap_or(false)
}
fn write_trusted_marker(cert_der: &[u8]) -> Result<(), String> {
let path = trusted_marker_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create trust state dir {}: {e}", parent.display()))?;
}
fs::write(path, trusted_marker_value(cert_der))
.map_err(|e| format!("Failed to write trust marker: {e}"))
}
#[cfg(windows)]
pub fn needs_trust_in_store(cert_der: &[u8]) -> Result<bool, String> {
Ok(!windows_cert_is_trusted(cert_der)?)
}
#[cfg(windows)]
pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
use windows_sys::Win32::Security::Cryptography::{
CertAddEncodedCertificateToStore, CertCloseStore, CertOpenSystemStoreW,
CERT_STORE_ADD_REPLACE_EXISTING, PKCS_7_ASN_ENCODING, X509_ASN_ENCODING,
};
if !needs_trust_in_store(cert_der)? {
return Ok(());
}
let store_name: Vec<u16> = "Root\0".encode_utf16().collect();
unsafe {
let store = CertOpenSystemStoreW(0, store_name.as_ptr());
if store.is_null() {
return Err("Failed to open CurrentUser\\Root certificate store".into());
}
let encoding = X509_ASN_ENCODING | PKCS_7_ASN_ENCODING;
let result = CertAddEncodedCertificateToStore(
store,
encoding,
cert_der.as_ptr(),
cert_der.len() as u32,
CERT_STORE_ADD_REPLACE_EXISTING,
std::ptr::null_mut(),
);
CertCloseStore(store, 0);
if result == 0 {
return Err(
"Failed to add certificate to trust store. The user may have declined the security dialog."
.into(),
);
}
}
write_trusted_marker(cert_der)?;
Ok(())
}
#[cfg(target_os = "macos")]
pub fn needs_trust_in_store(cert_der: &[u8]) -> Result<bool, String> {
Ok(!(has_matching_trusted_marker(cert_der) && macos_cert_is_trusted(cert_der)?))
}
#[cfg(target_os = "macos")]
pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
use std::process::Command;
if !needs_trust_in_store(cert_der)? {
return Ok(());
}
let temp_path = env::temp_dir().join(format!(
"codenomad-server-ca-{}.cer",
trusted_marker_file_suffix(cert_der)
));
fs::write(&temp_path, cert_der)
.map_err(|e| format!("Failed to write temporary certificate {}: {e}", temp_path.display()))?;
let keychain_path = resolve_macos_user_keychain()?;
let mut command = Command::new("/usr/bin/security");
command.args(["add-trusted-cert", "-r", "trustRoot", "-k"]);
command.arg(&keychain_path);
let output = command.arg(&temp_path).output().map_err(|e| {
format!(
"Failed to launch macOS security tool to trust the local CA certificate: {e}"
)
})?;
let _ = fs::remove_file(&temp_path);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let detail = if stderr.is_empty() {
format!("security exited with status {}", output.status)
} else {
stderr
};
return Err(format!(
"Failed to add the local CodeNomad CA certificate to the macOS trust settings: {detail}"
));
}
if !macos_cert_is_trusted(cert_der)? {
return Err(format!(
"Added the local CodeNomad CA certificate to {} but could not verify that macOS trusts it",
keychain_path.display()
));
}
write_trusted_marker(cert_der)?;
Ok(())
}
#[cfg(windows)]
fn windows_cert_is_trusted(cert_der: &[u8]) -> Result<bool, String> {
use windows_sys::Win32::Security::Cryptography::{
CertCloseStore, CertEnumCertificatesInStore, CertOpenSystemStoreW,
};
let store_name: Vec<u16> = "Root\0".encode_utf16().collect();
unsafe {
let store = CertOpenSystemStoreW(0, store_name.as_ptr());
if store.is_null() {
return Err("Failed to open CurrentUser\\Root certificate store".into());
}
let mut context = CertEnumCertificatesInStore(store, std::ptr::null());
while !context.is_null() {
let encoded = std::slice::from_raw_parts(
(*context).pbCertEncoded,
(*context).cbCertEncoded as usize,
);
if encoded == cert_der {
CertCloseStore(store, 0);
return Ok(true);
}
context = CertEnumCertificatesInStore(store, context);
}
CertCloseStore(store, 0);
Ok(false)
}
}
#[cfg(target_os = "macos")]
fn resolve_macos_user_keychain() -> Result<PathBuf, String> {
let output = std::process::Command::new("/usr/bin/security")
.args(["default-keychain", "-d", "user"])
.output()
.map_err(|e| format!("Failed to resolve macOS default user keychain: {e}"))?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let trimmed = stdout.trim().trim_matches('"');
if !trimmed.is_empty() {
return Ok(PathBuf::from(trimmed));
}
}
let home = dirs::home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from));
let home = home.ok_or_else(|| "Cannot determine home directory for macOS keychain lookup".to_string())?;
Ok(home.join("Library/Keychains/login.keychain-db"))
}
#[cfg(target_os = "macos")]
fn macos_cert_is_trusted(cert_der: &[u8]) -> Result<bool, String> {
use std::process::Command;
let temp_path = env::temp_dir().join(format!(
"codenomad-server-ca-verify-{}.cer",
trusted_marker_file_suffix(cert_der)
));
fs::write(&temp_path, cert_der)
.map_err(|e| format!("Failed to write temporary certificate {}: {e}", temp_path.display()))?;
let keychain_path = resolve_macos_user_keychain()?;
let fingerprint = macos_cert_sha256(&temp_path)?;
let find_output = Command::new("/usr/bin/security")
.args(["find-certificate", "-a", "-Z", "-c", "CodeNomad Local CA"])
.arg(&keychain_path)
.output()
.map_err(|e| format!("Failed to query macOS keychain certificates: {e}"))?;
if !find_output.status.success() {
let _ = fs::remove_file(&temp_path);
let stderr = String::from_utf8_lossy(&find_output.stderr).trim().to_string();
let detail = if stderr.is_empty() {
format!("security exited with status {}", find_output.status)
} else {
stderr
};
return Err(format!(
"Failed to inspect the macOS keychain for the local CodeNomad CA certificate: {detail}"
));
}
let stdout = String::from_utf8_lossy(&find_output.stdout);
if !stdout.to_ascii_uppercase().contains(&fingerprint) {
let _ = fs::remove_file(&temp_path);
return Ok(false);
}
let verify_output = Command::new("/usr/bin/security")
.args(["verify-cert", "-q", "-L", "-l", "-p", "basic", "-c"])
.arg(&temp_path)
.args(["-k"])
.arg(&keychain_path)
.output()
.map_err(|e| format!("Failed to verify macOS trust for the local CodeNomad CA certificate: {e}"))?;
let _ = fs::remove_file(&temp_path);
Ok(verify_output.status.success())
}
#[cfg(target_os = "macos")]
fn macos_cert_sha256(cert_path: &Path) -> Result<String, String> {
let output = std::process::Command::new("/usr/bin/shasum")
.args(["-a", "256"])
.arg(cert_path)
.output()
.map_err(|e| format!("Failed to compute SHA-256 for {}: {e}", cert_path.display()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let detail = if stderr.is_empty() {
format!("shasum exited with status {}", output.status)
} else {
stderr
};
return Err(format!(
"Failed to compute SHA-256 for {}: {detail}",
cert_path.display()
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let hash = stdout
.split_whitespace()
.next()
.ok_or_else(|| format!("Failed to parse SHA-256 output for {}", cert_path.display()))?;
Ok(hash.to_ascii_uppercase())
}
#[cfg(all(not(windows), not(target_os = "macos")))]
pub fn needs_trust_in_store(_cert_der: &[u8]) -> Result<bool, String> {
Ok(false)
}
#[cfg(all(not(windows), not(target_os = "macos")))]
pub fn trust_cert_in_store(_cert_der: &[u8]) -> Result<(), String> {
// Non-Windows platforms use native webview-specific handling instead of OS trust-store writes.
Ok(())
}
fn pem_to_der(pem: &str) -> Result<Vec<u8>, String> {
let mut body = String::new();
let mut in_block = false;
for line in pem.lines() {
if line.starts_with("-----BEGIN CERTIFICATE-----") {
in_block = true;
continue;
}
if line.starts_with("-----END CERTIFICATE-----") {
break;
}
if in_block {
body.push_str(line.trim());
}
}
if body.is_empty() {
return Err("No certificate found in PEM file".to_string());
}
base64::engine::general_purpose::STANDARD
.decode(body)
.map_err(|e| format!("Failed to decode certificate PEM: {e}"))
}

View File

@@ -5,9 +5,13 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::VecDeque;
use std::env;
#[cfg(windows)]
use std::ffi::c_void;
use std::ffi::OsStr;
use std::fs;
use std::io::{BufRead, BufReader, Read, Write};
#[cfg(windows)]
use std::mem::{size_of, zeroed};
use std::net::TcpStream;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
@@ -16,14 +20,98 @@ use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
#[cfg(windows)]
use std::os::windows::io::AsRawHandle;
#[cfg(windows)]
use std::os::windows::process::CommandExt;
#[cfg(windows)]
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
#[cfg(windows)]
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
const MISSING_NODE_PREFIX: &str = "CODENOMAD_MISSING_NODE:";
#[cfg(windows)]
#[derive(Debug)]
struct WindowsJobObject {
// The desktop wrapper may observe only a short-lived Node wrapper PID while the real
// server and workspace descendants continue running below it. KILL_ON_JOB_CLOSE gives
// Tauri an OS-owned handle for the whole subtree instead of relying on a single PID.
handle: HANDLE,
}
#[cfg(windows)]
impl WindowsJobObject {
fn create() -> anyhow::Result<Self> {
let handle = unsafe { CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()) };
if handle.is_null() {
return Err(anyhow::anyhow!(
"CreateJobObjectW failed: {}",
std::io::Error::last_os_error()
));
}
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let ok = unsafe {
SetInformationJobObject(
handle,
JobObjectExtendedLimitInformation,
&mut info as *mut _ as *mut c_void,
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
};
if ok == 0 {
let err = std::io::Error::last_os_error();
unsafe {
CloseHandle(handle);
}
return Err(anyhow::anyhow!("SetInformationJobObject failed: {}", err));
}
Ok(Self { handle })
}
fn assign_child(&self, child: &Child) -> anyhow::Result<()> {
let process_handle = child.as_raw_handle() as HANDLE;
let ok = unsafe { AssignProcessToJobObject(self.handle, process_handle) };
if ok == 0 {
return Err(anyhow::anyhow!(
"AssignProcessToJobObject failed: {}",
std::io::Error::last_os_error()
));
}
Ok(())
}
}
#[cfg(windows)]
impl Drop for WindowsJobObject {
fn drop(&mut self) {
if !self.handle.is_null() {
unsafe {
CloseHandle(self.handle);
}
}
}
}
#[cfg(windows)]
unsafe impl Send for WindowsJobObject {}
#[cfg(windows)]
unsafe impl Sync for WindowsJobObject {}
fn log_line(message: &str) {
println!("[tauri-cli] {message}");
@@ -48,7 +136,11 @@ fn workspace_root() -> Option<PathBuf> {
})
}
const SESSION_COOKIE_NAME: &str = "codenomad_session";
fn launch_cwd() -> Option<PathBuf> {
std::env::current_dir().ok()
}
const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session";
const CLI_STOP_GRACE_SECS: u64 = 30;
#[cfg(windows)]
@@ -124,7 +216,11 @@ fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
Some(value.to_string())
}
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
fn exchange_bootstrap_token(
base_url: &str,
token: &str,
cookie_name: &str,
) -> anyhow::Result<Option<String>> {
let parsed = Url::parse(base_url)?;
let host = parsed.host_str().unwrap_or("127.0.0.1");
let port = parsed.port_or_known_default().unwrap_or(80);
@@ -159,11 +255,11 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
for line in lines {
// handle case-insensitive header name
if let Some(value) = line.strip_prefix("Set-Cookie:") {
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
return Ok(Some(session_id));
}
} else if let Some(value) = line.strip_prefix("set-cookie:") {
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
return Ok(Some(session_id));
}
}
@@ -172,11 +268,16 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
Ok(None)
}
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
fn set_session_cookie(
app: &AppHandle,
base_url: &str,
cookie_name: &str,
session_id: &str,
) -> anyhow::Result<()> {
let parsed = Url::parse(base_url)?;
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
let cookie = Cookie::build((cookie_name.to_string(), session_id.to_string()))
.domain(domain)
.path("/")
.http_only(true)
@@ -190,6 +291,16 @@ fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyh
Ok(())
}
fn generate_auth_cookie_name() -> String {
let pid = std::process::id();
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}")
}
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
#[derive(Debug, Deserialize)]
@@ -344,6 +455,8 @@ impl Default for CliStatus {
pub struct CliProcessManager {
status: Arc<Mutex<CliStatus>>,
child: Arc<Mutex<Option<Child>>>,
#[cfg(windows)]
job: Arc<Mutex<Option<WindowsJobObject>>>,
ready: Arc<AtomicBool>,
bootstrap_token: Arc<Mutex<Option<String>>>,
}
@@ -353,6 +466,8 @@ impl CliProcessManager {
Self {
status: Arc::new(Mutex::new(CliStatus::default())),
child: Arc::new(Mutex::new(None)),
#[cfg(windows)]
job: Arc::new(Mutex::new(None)),
ready: Arc::new(AtomicBool::new(false)),
bootstrap_token: Arc::new(Mutex::new(None)),
}
@@ -375,6 +490,8 @@ impl CliProcessManager {
let status_arc = self.status.clone();
let child_arc = self.child.clone();
#[cfg(windows)]
let job_arc = self.job.clone();
let ready_flag = self.ready.clone();
let token_arc = self.bootstrap_token.clone();
thread::spawn(move || {
@@ -382,6 +499,8 @@ impl CliProcessManager {
app.clone(),
status_arc.clone(),
child_arc,
#[cfg(windows)]
job_arc,
ready_flag,
token_arc,
dev,
@@ -401,11 +520,12 @@ impl CliProcessManager {
}
pub fn stop(&self) -> anyhow::Result<()> {
#[cfg(windows)]
let _job = self.job.lock().take();
let mut child_opt = self.child.lock();
if let Some(mut child) = child_opt.take() {
log_line(&format!("stopping CLI pid={}", child.id()));
#[cfg(windows)]
let mut forced_tree_shutdown = false;
#[cfg(unix)]
unsafe {
let pid = child.id() as i32;
@@ -427,18 +547,16 @@ impl CliProcessManager {
Ok(Some(_)) => break,
Ok(None) => {
#[cfg(windows)]
if !forced_tree_shutdown
&& start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS)
{
if start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS) {
log_line(&format!(
"regular Windows shutdown still running after {}ms; escalating pid={}",
CLI_WINDOWS_FORCE_GRACE_MS,
child.id()
));
forced_tree_shutdown = true;
if !kill_process_tree_windows(child.id(), true) {
let _ = child.kill();
}
break;
}
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
@@ -457,11 +575,7 @@ impl CliProcessManager {
}
#[cfg(windows)]
{
if !forced_tree_shutdown
&& !kill_process_tree_windows(child.id(), true)
{
let _ = child.kill();
} else if forced_tree_shutdown {
if !kill_process_tree_windows(child.id(), true) {
let _ = child.kill();
}
}
@@ -472,6 +586,9 @@ impl CliProcessManager {
Err(_) => break,
}
}
} else {
#[cfg(windows)]
log_line("tracked CLI process already exited; dropping Windows job object to reap descendants");
}
let mut status = self.status.lock();
@@ -492,6 +609,7 @@ impl CliProcessManager {
app: AppHandle,
status: Arc<Mutex<CliStatus>>,
child_holder: Arc<Mutex<Option<Child>>>,
#[cfg(windows)] job_holder: Arc<Mutex<Option<WindowsJobObject>>>,
ready: Arc<AtomicBool>,
bootstrap_token: Arc<Mutex<Option<String>>>,
dev: bool,
@@ -503,44 +621,61 @@ impl CliProcessManager {
"resolved CLI entry runner={:?} entry={} host={}",
resolution.runner, resolution.entry, host
));
let args = resolution.build_args(dev, &host);
let auth_cookie_name = Arc::new(generate_auth_cookie_name());
let args = resolution.build_args(dev, &host, auth_cookie_name.as_str());
log_line(&format!("CLI args: {:?}", args));
if dev {
log_line("development mode: will prefer tsx + source if present");
}
let cwd = workspace_root();
let cwd = launch_cwd();
if let Some(ref c) = cwd {
log_line(&format!("using cwd={}", c.display()));
}
let command_info = if supports_user_shell() {
let use_user_shell = supports_user_shell();
if resolution.runner == Runner::Tsx
&& !use_user_shell
&& which::which(&resolution.node_binary).is_err()
{
return Err(anyhow::anyhow!(
"Node binary '{}' not found. CodeNomad development mode requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
resolution.node_binary
));
}
let command_info = if use_user_shell {
log_line("spawning via user shell");
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
} else {
log_line("spawning directly with node");
log_line(if resolution.runner == Runner::Standalone {
"spawning directly with standalone executable"
} else {
"spawning directly with node"
});
ShellCommandType::Direct(DirectCommand {
program: resolution.node_binary.clone(),
program: if resolution.runner == Runner::Standalone {
resolution.entry.clone()
} else {
resolution.node_binary.clone()
},
args: resolution.runner_args(&args),
})
};
if !supports_user_shell() {
if which::which(&resolution.node_binary).is_err() {
return Err(anyhow::anyhow!(
"Node binary not found. Make sure Node.js is installed."
));
}
}
let child = match &command_info {
ShellCommandType::UserShell(cmd) => {
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
let mut c = Command::new(&cmd.shell);
c.args(&cmd.args)
.env("ELECTRON_RUN_AS_NODE", "1")
.env_remove("npm_config_prefix")
.env_remove("NPM_CONFIG_PREFIX")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if resolution.runner != Runner::Standalone {
c.env("ELECTRON_RUN_AS_NODE", "1");
}
configure_spawn(&mut c);
if let Some(ref cwd) = cwd {
c.current_dir(cwd);
@@ -553,9 +688,11 @@ impl CliProcessManager {
log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args));
let mut c = Command::new(&cmd.program);
c.args(&cmd.args)
.env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if resolution.runner != Runner::Standalone {
c.env("ELECTRON_RUN_AS_NODE", "1");
}
configure_spawn(&mut c);
if let Some(ref cwd) = cwd {
c.current_dir(cwd);
@@ -568,6 +705,22 @@ impl CliProcessManager {
let pid = child.id();
log_line(&format!("spawned pid={pid}"));
#[cfg(windows)]
match WindowsJobObject::create().and_then(|job| {
job.assign_child(&child)?;
Ok(job)
}) {
Ok(job) => {
log_line(&format!("attached pid={pid} to Windows job object"));
*job_holder.lock() = Some(job);
}
Err(err) => {
log_line(&format!(
"failed to attach pid={pid} to Windows job object; falling back to taskkill-only cleanup: {err}"
));
}
}
{
let mut locked = status.lock();
locked.pid = Some(pid);
@@ -584,6 +737,7 @@ impl CliProcessManager {
let app_clone = app.clone();
let ready_clone = ready.clone();
let token_clone = bootstrap_token.clone();
let auth_cookie_name_clone = auth_cookie_name.clone();
thread::spawn(move || {
let stdout = child_clone
@@ -598,24 +752,41 @@ impl CliProcessManager {
.map(BufReader::new);
if let Some(reader) = stdout {
Self::process_stream(
reader,
"stdout",
&app_clone,
&status_clone,
&ready_clone,
&token_clone,
);
let app = app_clone.clone();
let status = status_clone.clone();
let ready = ready_clone.clone();
let token = token_clone.clone();
let auth_cookie_name = auth_cookie_name_clone.clone();
thread::spawn(move || {
Self::process_stream(
reader,
"stdout",
&app,
&status,
&ready,
&token,
auth_cookie_name.as_str(),
);
});
}
if let Some(reader) = stderr {
Self::process_stream(
reader,
"stderr",
&app_clone,
&status_clone,
&ready_clone,
&token_clone,
);
let app = app_clone.clone();
let status = status_clone.clone();
let ready = ready_clone.clone();
let token = token_clone.clone();
let auth_cookie_name = auth_cookie_name_clone.clone();
thread::spawn(move || {
Self::process_stream(
reader,
"stderr",
&app,
&status,
&ready,
&token,
auth_cookie_name.as_str(),
);
});
}
});
@@ -623,6 +794,8 @@ impl CliProcessManager {
let status_clone = status.clone();
let ready_clone = ready.clone();
let child_holder_clone = child_holder.clone();
#[cfg(windows)]
let job_holder_clone = job_holder.clone();
thread::spawn(move || {
let timeout = Duration::from_secs(60);
thread::sleep(timeout);
@@ -677,6 +850,10 @@ impl CliProcessManager {
// Drop the handle after the process exits so other callers
// don't attempt to stop/kill a finished process.
*guard = None;
#[cfg(windows)]
{
let _ = job_holder_clone.lock().take();
}
Some(status)
}
None => None,
@@ -731,10 +908,11 @@ impl CliProcessManager {
status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>,
auth_cookie_name: &str,
) {
let mut buffer = String::new();
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
let local_url_regex =
Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok();
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
loop {
@@ -761,44 +939,32 @@ impl CliProcessManager {
continue;
}
if let Some(node_binary) = line.strip_prefix(MISSING_NODE_PREFIX) {
let mut locked = status.lock();
if locked.error.is_none() {
locked.error = Some(format!(
"Node binary '{}' not found in the desktop shell environment. CodeNomad development mode requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
node_binary.trim()
));
}
continue;
}
if let Some(url) = local_url_regex
.as_ref()
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.map(|m| m.as_str().to_string())
{
Self::mark_ready(app, status, ready, bootstrap_token, url);
Self::mark_ready(
app,
status,
ready,
bootstrap_token,
auth_cookie_name,
url,
);
continue;
}
if line.to_lowercase().contains("http server listening") {
if let Some(port) = http_regex
.as_ref()
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok())
{
Self::mark_ready(
app,
status,
ready,
bootstrap_token,
format!("http://localhost:{port}"),
);
continue;
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
Self::mark_ready(
app,
status,
ready,
bootstrap_token,
format!("http://localhost:{}", port),
);
continue;
}
}
}
}
}
Err(_) => break,
@@ -811,6 +977,7 @@ impl CliProcessManager {
status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>,
auth_cookie_name: &str,
base_url: String,
) {
ready.store(true, Ordering::SeqCst);
@@ -834,9 +1001,11 @@ impl CliProcessManager {
if scheme.as_deref() != Some("http") {
navigate_main(app, &base_url);
} else {
match exchange_bootstrap_token(&base_url, &token) {
match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) {
Ok(Some(session_id)) => {
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
if let Err(err) =
set_session_cookie(app, &base_url, &auth_cookie_name, &session_id)
{
log_line(&format!("failed to set session cookie: {err}"));
navigate_main(app, &format!("{base_url}/login"));
} else {
@@ -897,7 +1066,7 @@ struct CliEntry {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Runner {
Node,
Standalone,
Tsx,
}
@@ -918,30 +1087,34 @@ impl CliEntry {
}
}
if let Some(entry) = resolve_dist_entry(app) {
if let Some(entry) = resolve_standalone_entry(app) {
return Ok(Self {
entry,
runner: Runner::Node,
runner: Runner::Standalone,
runner_path: None,
node_binary,
node_binary: String::new(),
});
}
Err(anyhow::anyhow!(
"Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad."
"Unable to locate the packaged CodeNomad standalone server. Please rebuild the desktop bundle."
))
}
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
fn build_args(&self, dev: bool, host: &str, auth_cookie_name: &str) -> Vec<String> {
let mut args = vec![
"serve".to_string(),
"--host".to_string(),
host.to_string(),
"--auth-cookie-name".to_string(),
auth_cookie_name.to_string(),
"--generate-token".to_string(),
"--unrestricted-root".to_string(),
];
if dev {
// Dev: plain HTTP + Vite dev server proxy.
// Dev: keep loopback HTTP for the Vite proxy, but also enable HTTPS so
// remote proxy sessions can still spin up secure local windows.
let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL")
.ok()
.filter(|value| !value.trim().is_empty())
@@ -958,7 +1131,7 @@ impl CliEntry {
.unwrap_or_else(|| "info".to_string());
args.push("--https".to_string());
args.push("false".to_string());
args.push("true".to_string());
args.push("--http".to_string());
args.push("true".to_string());
args.push("--http-port".to_string());
@@ -978,6 +1151,10 @@ impl CliEntry {
}
fn runner_args(&self, cli_args: &[String]) -> Vec<String> {
if self.runner == Runner::Standalone {
return cli_args.to_vec();
}
let mut args = VecDeque::new();
if self.runner == Runner::Tsx {
if let Some(path) = &self.runner_path {
@@ -993,71 +1170,94 @@ impl CliEntry {
}
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
let candidates = vec![
std::env::current_dir()
.ok()
let cwd = std::env::current_dir().ok();
let workspace = workspace_root();
let mut candidates = vec![
cwd.as_ref()
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
cwd.as_ref()
.map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")),
cwd.as_ref()
.map(|p| p.join("../node_modules/tsx/dist/cli.mjs")),
cwd.as_ref()
.map(|p| p.join("../node_modules/tsx/dist/cli.cjs")),
cwd.as_ref()
.map(|p| p.join("../node_modules/tsx/dist/cli.js")),
cwd.as_ref()
.map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")),
cwd.as_ref()
.map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")),
cwd.as_ref()
.map(|p| p.join("../../node_modules/tsx/dist/cli.js")),
workspace
.as_ref()
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
workspace
.as_ref()
.map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
workspace
.as_ref()
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
std::env::current_exe().ok().and_then(|ex| {
ex.parent()
.map(|p| p.join("../node_modules/tsx/dist/cli.js"))
}),
];
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.mjs")));
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.cjs")));
candidates.push(Some(dir.join("../node_modules/tsx/dist/cli.js")));
}
}
first_existing(candidates)
}
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
let cwd = std::env::current_dir().ok();
let workspace = workspace_root();
let candidates = vec![
std::env::current_dir()
.ok()
workspace
.as_ref()
.map(|p| p.join("packages/server/src/index.ts")),
std::env::current_dir()
.ok()
.map(|p| p.join("../server/src/index.ts")),
cwd.as_ref().map(|p| p.join("packages/server/src/index.ts")),
cwd.as_ref().map(|p| p.join("../server/src/index.ts")),
cwd.as_ref().map(|p| p.join("../../server/src/index.ts")),
];
first_existing(candidates)
}
fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
fn resolve_standalone_entry(_app: &AppHandle) -> Option<String> {
let executable_name = if cfg!(windows) {
"codenomad-server.exe"
} else {
"codenomad-server"
};
let base = workspace_root();
let mut candidates: Vec<Option<PathBuf>> = vec![
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
base.as_ref()
.map(|p| p.join("packages/server/dist/index.js")),
base.as_ref().map(|p| p.join("server/dist/bin.js")),
base.as_ref().map(|p| p.join("server/dist/index.js")),
];
let mut candidates = vec![base
.as_ref()
.map(|p| p.join("packages/server/dist").join(executable_name))];
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
candidates.push(Some(dir.join("resources/server/dist/bin.js")));
candidates.push(Some(dir.join("resources/server/dist/index.js")));
candidates.push(Some(dir.join("resources/server/dist/server/bin.js")));
candidates.push(Some(dir.join("resources/server/dist/server/index.js")));
candidates.push(Some(
dir.join("resources/server/dist").join(executable_name),
));
let resources = dir.join("../Resources");
candidates.push(Some(resources.join("server/dist/bin.js")));
candidates.push(Some(resources.join("server/dist/index.js")));
candidates.push(Some(resources.join("server/dist/server/bin.js")));
candidates.push(Some(resources.join("server/dist/server/index.js")));
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
candidates.push(Some(resources.join("resources/server/dist/index.js")));
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
candidates.push(Some(resources.join("server/dist").join(executable_name)));
candidates.push(Some(
resources.join("resources/server/dist/server/index.js"),
resources
.join("resources/server/dist")
.join(executable_name),
));
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
for root in linux_resource_roots {
candidates.push(Some(root.join("server/dist/bin.js")));
candidates.push(Some(root.join("server/dist/index.js")));
candidates.push(Some(root.join("server/dist/server/bin.js")));
candidates.push(Some(root.join("server/dist/server/index.js")));
candidates.push(Some(root.join("resources/server/dist/bin.js")));
candidates.push(Some(root.join("resources/server/dist/index.js")));
candidates.push(Some(root.join("resources/server/dist/server/bin.js")));
candidates.push(Some(root.join("resources/server/dist/server/index.js")));
candidates.push(Some(root.join("server/dist").join(executable_name)));
candidates.push(Some(
root.join("resources/server/dist").join(executable_name),
));
}
}
}
@@ -1071,16 +1271,55 @@ fn build_shell_command_string(
) -> anyhow::Result<ShellCommand> {
let shell = default_shell();
let mut quoted: Vec<String> = Vec::new();
quoted.push(shell_escape(&entry.node_binary));
for arg in entry.runner_args(cli_args) {
quoted.push(shell_escape(&arg));
}
let command = format!("ELECTRON_RUN_AS_NODE=1 exec {}", quoted.join(" "));
let args = build_shell_args(&shell, &command);
let command = if entry.runner == Runner::Standalone {
quoted.push(shell_escape(&entry.entry));
for arg in cli_args {
quoted.push(shell_escape(arg));
}
format!("exec {}", quoted.join(" "))
} else {
quoted.push(shell_escape(&entry.node_binary));
for arg in entry.runner_args(cli_args) {
quoted.push(shell_escape(&arg));
}
format!(
"if command -v {} >/dev/null 2>&1; then ELECTRON_RUN_AS_NODE=1 exec {}; else printf '%s%s\\n' '{}' {} >&2; exit 127; fi",
shell_escape(&entry.node_binary),
quoted.join(" "),
MISSING_NODE_PREFIX,
shell_escape(&entry.node_binary),
)
};
let wrapped_command = wrap_command_for_shell(&command, &shell);
let args = build_shell_args(&shell, &wrapped_command);
log_line(&format!("user shell command: {} {:?}", shell, args));
Ok(ShellCommand { shell, args })
}
fn wrap_command_for_shell(command: &str, shell: &str) -> String {
let shell_name = std::path::Path::new(shell)
.file_name()
.and_then(OsStr::to_str)
.unwrap_or("")
.to_lowercase();
if shell_name.contains("bash") {
return format!(
"if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; {}",
command
);
}
if shell_name.contains("zsh") {
return format!(
"if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; {}",
command
);
}
command.to_string()
}
fn default_shell() -> String {
if let Ok(shell) = std::env::var("SHELL") {
if !shell.trim().is_empty() {

View File

@@ -0,0 +1,88 @@
use crate::AppState;
use tauri::{AppHandle, Manager, WebviewWindow};
use url::Url;
use webkit2gtk::{WebContextExt, WebView, WebViewExt};
pub fn should_bootstrap_tls_navigation(target_url: &Url, allow_tls_certificate: bool) -> bool {
allow_tls_certificate && target_url.scheme() == "https"
}
pub fn ensure_remote_window_tls_handler(
window: &WebviewWindow,
app_handle: &AppHandle,
window_label: &str,
) -> Result<(), String> {
{
let state = app_handle.state::<AppState>();
let mut handlers = state
.remote_tls_handlers
.lock()
.map_err(|err| err.to_string())?;
if !handlers.insert(window_label.to_string()) {
return Ok(());
}
}
let app_handle = app_handle.clone();
let window_label = window_label.to_string();
window
.with_webview(move |platform_webview| {
let webview = platform_webview.inner();
let app_handle = app_handle.clone();
let window_label = window_label.clone();
webview.connect_load_failed_with_tls_errors(move |view, failing_uri, certificate, _| {
allow_remote_tls_certificate(
&app_handle,
&window_label,
view,
failing_uri,
certificate,
)
});
})
.map_err(|err| err.to_string())
}
fn allow_remote_tls_certificate(
app_handle: &AppHandle,
window_label: &str,
view: &WebView,
failing_uri: &str,
certificate: &webkit2gtk::gio::TlsCertificate,
) -> bool {
let Ok(parsed_uri) = Url::parse(failing_uri) else {
return false;
};
let Some(host) = parsed_uri.host_str() else {
return false;
};
let state = app_handle.state::<AppState>();
let skip_tls_verify = state
.remote_skip_tls_verify
.lock()
.ok()
.and_then(|values| values.get(window_label).copied())
.unwrap_or(false);
if !skip_tls_verify {
return false;
}
let expected_origin = state
.remote_origins
.lock()
.ok()
.and_then(|origins| origins.get(window_label).cloned());
let parsed_origin = parsed_uri.origin().ascii_serialization();
if expected_origin.as_deref() != Some(parsed_origin.as_str()) {
return false;
}
let Some(context) = view.context() else {
return false;
};
context.allow_tls_certificate_for_host(certificate, host);
view.load_uri(failing_uri);
true
}

View File

@@ -1,18 +1,25 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[allow(dead_code)]
mod cert_manager;
mod cli_manager;
#[cfg(target_os = "linux")]
mod linux_tls;
use cli_manager::{CliProcessManager, CliStatus};
use keepawake::KeepAwake;
use serde::Deserialize;
use serde_json::json;
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::webview::Webview;
use tauri::{AppHandle, Emitter, Manager, Runtime, WindowEvent, Wry};
use tauri::{
AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
};
use tauri_plugin_global_shortcut::{
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
};
@@ -30,7 +37,7 @@ use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
const ZOOM_STEP: f64 = 0.2;
const ZOOM_STEP: f64 = 0.1;
const MIN_ZOOM_LEVEL: f64 = 0.2;
const MAX_ZOOM_LEVEL: f64 = 5.0;
@@ -41,6 +48,69 @@ pub struct AppState {
pub manager: CliProcessManager,
pub wake_lock: Mutex<Option<KeepAwake>>,
pub zoom_level: Mutex<f64>,
pub remote_origins: Mutex<HashMap<String, String>>,
pub remote_proxy_sessions: Mutex<HashMap<String, String>>,
pub remote_skip_tls_verify: Mutex<HashMap<String, bool>>,
pub remote_tls_handlers: Mutex<HashSet<String>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RemoteWindowPayload {
id: String,
name: String,
base_url: String,
entry_url: Option<String>,
proxy_session_id: Option<String>,
#[allow(dead_code)]
skip_tls_verify: bool,
}
fn schedule_remote_proxy_session_cleanup(app: AppHandle, session_id: String) {
tauri::async_runtime::spawn(async move {
if let Err(err) = cleanup_remote_proxy_session(&app, &session_id).await {
eprintln!(
"[tauri] failed to clean up remote proxy session {}: {}",
session_id, err
);
}
});
}
async fn cleanup_remote_proxy_session(app: &AppHandle, session_id: &str) -> Result<(), String> {
let status = app.state::<AppState>().manager.status();
let Some(base_url) = status.url else {
return Ok(());
};
let mut cleanup_url = Url::parse(&base_url).map_err(|err| err.to_string())?;
cleanup_url.set_path(&format!("/api/remote-proxy/sessions/{session_id}"));
cleanup_url.set_query(None);
cleanup_url.set_fragment(None);
let client = if cleanup_url.scheme() == "https" {
let local_cert = cert_manager::ensure_local_cert()?;
let ca_cert = reqwest::Certificate::from_der(&local_cert.ca_cert_der)
.map_err(|err| err.to_string())?;
reqwest::Client::builder()
.add_root_certificate(ca_cert)
.build()
.map_err(|err| err.to_string())?
} else {
reqwest::Client::new()
};
let response = client
.delete(cleanup_url.as_str())
.send()
.await
.map_err(|err| err.to_string())?;
if response.status().is_success() || response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(());
}
Err(format!("unexpected status {}", response.status()))
}
#[derive(Debug, Default, Deserialize)]
@@ -106,7 +176,7 @@ fn is_dev_mode() -> bool {
fn should_allow_internal(url: &Url) -> bool {
match url.scheme() {
"tauri" | "asset" | "file" => true,
"tauri" | "asset" | "file" | "about" => true,
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
// This must be treated as an internal origin or the navigation guard will
// redirect it to the system browser and the app will appear blank.
@@ -118,11 +188,32 @@ fn should_allow_internal(url: &Url) -> bool {
}
}
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
fn should_allow_window_origin<R: Runtime>(
app_handle: &AppHandle<R>,
window_label: &str,
url: &Url,
) -> bool {
if should_allow_internal(url) {
return true;
}
let state = app_handle.state::<AppState>();
let Ok(allowed) = state.remote_origins.lock() else {
return false;
};
if let Some(origin) = allowed.get(window_label) {
return origin == &url.origin().ascii_serialization();
}
false
}
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
let window_label = webview.label().to_string();
if should_allow_window_origin(&webview.app_handle(), &window_label, url) {
return true;
}
if let Err(err) = webview
.app_handle()
.opener()
@@ -133,6 +224,161 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
false
}
async fn open_remote_window_impl(
app: AppHandle,
payload: RemoteWindowPayload,
) -> Result<(), String> {
let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str());
let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?;
let label = format!("remote-{}", payload.id);
let title = format!(
"{} - {}",
payload.name,
Url::parse(&payload.base_url)
.ok()
.and_then(|url| url.host_str().map(str::to_string))
.unwrap_or_else(|| payload.base_url.clone())
);
let window_url = parsed.clone();
let allow_linux_tls_certificate =
parsed.scheme() == "https" && (payload.proxy_session_id.is_some() || payload.skip_tls_verify);
app.state::<AppState>()
.remote_origins
.lock()
.map_err(|err| err.to_string())?
.insert(label.clone(), window_url.origin().ascii_serialization());
app.state::<AppState>()
.remote_skip_tls_verify
.lock()
.map_err(|err| err.to_string())?
.insert(label.clone(), allow_linux_tls_certificate);
let replaced_session = {
let state = app.state::<AppState>();
let mut sessions = state
.remote_proxy_sessions
.lock()
.map_err(|err| err.to_string())?;
match payload.proxy_session_id.clone() {
Some(session_id) => sessions.insert(label.clone(), session_id),
None => sessions.remove(&label),
}
};
if let Some(previous) = replaced_session {
if payload.proxy_session_id.as_deref() != Some(previous.as_str()) {
schedule_remote_proxy_session_cleanup(app.clone(), previous);
}
}
if let Some(existing) = app.get_webview_window(&label) {
#[cfg(target_os = "linux")]
linux_tls::ensure_remote_window_tls_handler(&existing, &app, &label)?;
let _ = existing.navigate(window_url.clone());
let _ = existing.set_title(&title);
let _ = existing.show();
let _ = existing.unminimize();
let _ = existing.set_focus();
return Ok(());
}
#[cfg(target_os = "linux")]
let initial_url = if linux_tls::should_bootstrap_tls_navigation(
&window_url,
allow_linux_tls_certificate,
) {
Url::parse("about:blank").map_err(|err| err.to_string())?
} else {
window_url.clone()
};
#[cfg(not(target_os = "linux"))]
let initial_url = window_url.clone();
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone()))
.title(title)
.inner_size(1400.0, 900.0)
.min_inner_size(800.0, 600.0)
.build()
.map_err(|err| err.to_string())?;
#[cfg(target_os = "linux")]
{
linux_tls::ensure_remote_window_tls_handler(&window, &app, &label)?;
if initial_url != window_url {
let _ = window.navigate(window_url.clone());
}
}
let app_handle = app.clone();
let label_for_cleanup = label.clone();
window.on_window_event(move |event| {
if let WindowEvent::Destroyed = event {
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
origins.remove(&label_for_cleanup);
}
if let Ok(mut sessions) = app_handle.state::<AppState>().remote_proxy_sessions.lock() {
if let Some(session_id) = sessions.remove(&label_for_cleanup) {
schedule_remote_proxy_session_cleanup(app_handle.clone(), session_id);
}
}
if let Ok(mut values) = app_handle.state::<AppState>().remote_skip_tls_verify.lock() {
values.remove(&label_for_cleanup);
}
if let Ok(mut handlers) = app_handle.state::<AppState>().remote_tls_handlers.lock() {
handlers.remove(&label_for_cleanup);
}
}
});
Ok(())
}
#[tauri::command]
fn needs_local_certificate_install() -> Result<bool, String> {
#[cfg(not(target_os = "linux"))]
{
let local_cert = cert_manager::ensure_local_cert().map_err(|err| {
format!("Failed to load the local HTTPS certificate for the remote proxy window: {err}")
})?;
return cert_manager::needs_trust_in_store(&local_cert.ca_cert_der).map_err(|err| {
format!("Failed to inspect the local CodeNomad certificate trust state: {err}")
});
}
#[cfg(target_os = "linux")]
{
Ok(false)
}
}
#[tauri::command]
async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
#[cfg(not(target_os = "linux"))]
{
let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str());
let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?;
if payload.proxy_session_id.is_some() && parsed.scheme() == "https" {
let local_cert = cert_manager::ensure_local_cert().map_err(|err| {
format!(
"Failed to load the local HTTPS certificate for the remote proxy window: {err}"
)
})?;
if let Err(err) = cert_manager::trust_cert_in_store(&local_cert.ca_cert_der) {
return Err(format!(
"Failed to trust the local CodeNomad CA certificate. Accept the certificate installation prompt and try again: {err}"
));
}
}
}
open_remote_window_impl(app, payload).await
}
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
paths
.iter()
@@ -260,6 +506,8 @@ fn set_windows_app_user_model_id() {
fn set_windows_app_user_model_id() {}
fn main() {
let _ = rustls::crypto::ring::default_provider().install_default();
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
.on_navigation(|webview, url| intercept_navigation(webview, url))
.build();
@@ -286,6 +534,10 @@ fn main() {
manager: CliProcessManager::new(),
wake_lock: Mutex::new(None),
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
remote_origins: Mutex::new(HashMap::new()),
remote_proxy_sessions: Mutex::new(HashMap::new()),
remote_skip_tls_verify: Mutex::new(HashMap::new()),
remote_tls_handlers: Mutex::new(HashSet::new()),
})
.setup(|app| {
set_windows_app_user_model_id();
@@ -323,7 +575,9 @@ fn main() {
cli_get_status,
cli_restart,
wake_lock_start,
wake_lock_stop
wake_lock_stop,
needs_local_certificate_install,
open_remote_window
])
.on_menu_event(|app_handle, event| {
match event.id().0.as_str() {
@@ -455,11 +709,24 @@ fn main() {
event: tauri::WindowEvent::CloseRequested { api, .. },
..
} => {
// Ensure we have time to stop the CLI process before the app exits.
// Let windows close normally. App shutdown is handled only after the
// last window is actually gone so remote windows can outlive `main`.
let _ = api;
}
tauri::RunEvent::WindowEvent {
event: tauri::WindowEvent::Destroyed,
..
} => {
if !app_handle.webview_windows().is_empty() {
return;
}
// Stop the CLI only when the final window is gone and the app is
// truly exiting.
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
return;
}
api.prevent_close();
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {

View File

@@ -1,17 +1,15 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CodeNomad",
"version": "0.12.3",
"version": "0.14.0",
"identifier": "ai.neuralnomads.codenomad.client",
"build": {
"beforeDevCommand": "npm run dev:bootstrap",
"beforeBuildCommand": "npm run bundle:server",
"frontendDist": "resources/ui-loading"
},
"app": {
"enableGTKAppId": true,
"withGlobalTauri": true,
"windows": [
{
@@ -33,18 +31,56 @@
],
"security": {
"assetProtocol": {
"scope": ["**"]
"scope": [
"**"
]
},
"capabilities": ["main-window-native-dialogs"]
"capabilities": [
"main-window-native-dialogs"
]
}
},
"bundle": {
"active": true,
"linux": {
"deb": {
"files": {
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop",
"/usr/share/icons/hicolor/32x32/apps/codenomad-tauri.png": "icons/linux/32x32.png",
"/usr/share/icons/hicolor/48x48/apps/codenomad-tauri.png": "icons/linux/48x48.png",
"/usr/share/icons/hicolor/64x64/apps/codenomad-tauri.png": "icons/linux/64x64.png",
"/usr/share/icons/hicolor/128x128/apps/codenomad-tauri.png": "icons/linux/128x128.png",
"/usr/share/icons/hicolor/256x256/apps/codenomad-tauri.png": "icons/linux/256x256.png",
"/usr/share/icons/hicolor/512x512/apps/codenomad-tauri.png": "icons/linux/512x512.png"
}
},
"rpm": {
"files": {
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop",
"/usr/share/icons/hicolor/32x32/apps/codenomad-tauri.png": "icons/linux/32x32.png",
"/usr/share/icons/hicolor/48x48/apps/codenomad-tauri.png": "icons/linux/48x48.png",
"/usr/share/icons/hicolor/64x64/apps/codenomad-tauri.png": "icons/linux/64x64.png",
"/usr/share/icons/hicolor/128x128/apps/codenomad-tauri.png": "icons/linux/128x128.png",
"/usr/share/icons/hicolor/256x256/apps/codenomad-tauri.png": "icons/linux/256x256.png",
"/usr/share/icons/hicolor/512x512/apps/codenomad-tauri.png": "icons/linux/512x512.png"
}
}
},
"resources": [
"resources/server",
"resources/ui-loading"
],
"icon": ["icon.icns", "icon.ico", "icon.png"],
"targets": ["app", "appimage", "deb", "rpm", "nsis"]
"icon": [
"icon.icns",
"icon.ico",
"icon.png"
],
"targets": [
"app",
"appimage",
"deb",
"rpm",
"nsis"
]
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.12.3",
"version": "0.14.0",
"private": true,
"license": "MIT",
"type": "module",
@@ -45,4 +45,4 @@
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-solid": "^2.10.0"
}
}
}

View File

@@ -10,7 +10,10 @@ import InstanceTabs from "./components/instance-tabs"
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
import InstanceShell from "./components/instance/instance-shell2"
import { SettingsScreen } from "./components/settings-screen"
import { SideCarPickerDialog } from "./components/sidecar-picker-dialog"
import { SideCarView } from "./components/sidecar-view"
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
import { showAlertDialog } from "./stores/alerts"
import { initGithubStars } from "./stores/github-stars"
import { useCommands } from "./lib/hooks/use-commands"
@@ -23,7 +26,6 @@ import { runtimeEnv } from "./lib/runtime-env"
import { useI18n } from "./lib/i18n"
import { setWakeLockDesired } from "./lib/native/wake-lock"
import {
hasInstances,
isSelectingFolder,
setIsSelectingFolder,
showFolderSelection,
@@ -33,10 +35,7 @@ import { useConfig } from "./stores/preferences"
import {
createInstance,
instances,
activeInstanceId,
setActiveInstanceId,
stopInstance,
getActiveInstance,
disconnectedInstance,
acknowledgeDisconnectedInstance,
} from "./stores/instances"
@@ -53,6 +52,22 @@ import {
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
import { openSettings } from "./stores/settings-screen"
import {
closeSidecarTab,
ensureSidecarsLoaded,
openSidecarTab,
} from "./stores/sidecars"
import {
activeAppTab,
activeAppTabId,
appTabs,
ensureActiveAppTab,
getAdjacentAppTabId,
getAppTabById,
selectAppTab,
selectInstanceTab,
selectSidecarTab,
} from "./stores/app-tabs"
const log = getLogger("actions")
@@ -77,6 +92,7 @@ const App: Component = () => {
} = useConfig()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
const [sidecarPickerOpen, setSidecarPickerOpen] = createSignal(false)
const phoneQuery = useMediaQuery("(max-width: 767px)")
const isPhoneLayout = createMemo(() => phoneQuery())
@@ -206,8 +222,7 @@ const App: Component = () => {
})
createEffect(() => {
instances()
hasInstances()
appTabs()
requestAnimationFrame(() => updateInstanceTabBarHeight())
})
@@ -219,7 +234,15 @@ const App: Component = () => {
onCleanup(() => window.removeEventListener("resize", handleResize))
})
const activeInstance = createMemo(() => getActiveInstance())
createEffect(() => {
appTabs()
ensureActiveAppTab()
})
const activeInstance = createMemo(() => {
const tab = activeAppTab()
return tab?.kind === "instance" ? tab.instance : null
})
const activeSessionIdForInstance = createMemo(() => {
const instance = activeInstance()
if (!instance) return null
@@ -244,6 +267,7 @@ const App: Component = () => {
recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary)
selectInstanceTab(instanceId)
setShowFolderSelection(false)
log.info("Created instance", {
@@ -270,8 +294,27 @@ const App: Component = () => {
}
function handleNewInstanceRequest() {
if (hasInstances()) {
setShowFolderSelection(true)
setShowFolderSelection(true)
}
function handleOpenSidecarPicker() {
setSidecarPickerOpen(true)
void ensureSidecarsLoaded()
}
async function handleOpenSidecar(sidecarId: string) {
try {
const tab = await openSidecarTab(sidecarId)
selectSidecarTab(tab.token)
setShowFolderSelection(false)
setSidecarPickerOpen(false)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
showAlertDialog(message, {
variant: "error",
title: t("sidecars.open.errorTitle"),
})
log.error("Failed to open SideCar", error)
}
}
@@ -332,6 +375,23 @@ const App: Component = () => {
}
}
async function handleCloseAppTab(tabId: string) {
const tab = getAppTabById(tabId)
if (!tab) return
const fallbackTabId = activeAppTabId() === tabId ? getAdjacentAppTabId(tabId) : activeAppTabId()
if (tab.kind === "instance") {
await handleCloseInstance(tab.instance.id)
} else {
closeSidecarTab(tab.sidecarTab.token)
}
if (!getAppTabById(tabId)) {
ensureActiveAppTab(fallbackTabId)
}
}
const handleSidebarAgentChange = async (instanceId: string, sessionId: string, agent: string) => {
if (!instanceId || !sessionId || sessionId === "info") return
await updateSessionAgent(instanceId, sessionId, agent)
@@ -361,6 +421,7 @@ const App: Component = () => {
setThinkingBlocksExpansion,
setToolInputsVisibility,
handleNewInstanceRequest,
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
handleCloseInstance,
handleNewSession,
handleCloseSession,
@@ -371,6 +432,7 @@ const App: Component = () => {
useAppLifecycle({
setEscapeInDebounce,
handleNewInstanceRequest,
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
handleCloseInstance,
handleNewSession,
handleCloseSession,
@@ -470,52 +532,60 @@ const App: Component = () => {
</div>
</Show>
<Show
when={!hasInstances()}
when={appTabs().length === 0}
fallback={
<>
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
<InstanceTabs
instances={instances()}
activeInstanceId={activeInstanceId()}
onSelect={setActiveInstanceId}
onClose={handleCloseInstance}
tabs={appTabs()}
activeTabId={activeAppTabId()}
onSelect={selectAppTab}
onClose={(tabId) => void handleCloseAppTab(tabId)}
onNew={handleNewInstanceRequest}
/>
</Show>
<For each={Array.from(instances().values())}>
{(instance) => {
const isActiveInstance = () => activeInstanceId() === instance.id
const isVisible = () => isActiveInstance() && !showFolderSelection()
return (
<div
class="flex-1 min-h-0 overflow-hidden"
style={{ display: isVisible() ? "flex" : "none" }}
data-instance-id={instance.id}
data-instance-active={isActiveInstance() ? "true" : "false"}
data-instance-visible={isVisible() ? "true" : "false"}
>
<InstanceMetadataProvider instance={instance}>
<InstanceShell
instance={instance}
isActiveInstance={isActiveInstance()}
escapeInDebounce={escapeInDebounce()}
paletteCommands={paletteCommands}
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
onNewSession={() => handleNewSession(instance.id)}
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
onExecuteCommand={executeCommand}
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
onExitMobileFullscreen={() => void exitMobileFullscreen()}
/>
</InstanceMetadataProvider>
</div>
)
<For each={appTabs()}>
{(tab) => {
const isVisible = () => activeAppTabId() === tab.id && !showFolderSelection()
return tab.kind === "instance" ? (
<div
class="flex-1 min-h-0 overflow-hidden"
style={{ display: isVisible() ? "flex" : "none" }}
data-instance-id={tab.instance.id}
data-tab-id={tab.id}
data-tab-kind={tab.kind}
data-tab-visible={isVisible() ? "true" : "false"}
>
<InstanceMetadataProvider instance={tab.instance}>
<InstanceShell
instance={tab.instance}
isActiveInstance={isVisible()}
escapeInDebounce={escapeInDebounce()}
paletteCommands={paletteCommands}
onCloseSession={(sessionId) => handleCloseSession(tab.instance.id, sessionId)}
onNewSession={() => handleNewSession(tab.instance.id)}
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(tab.instance.id, sessionId, agent)}
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(tab.instance.id, sessionId, model)}
onExecuteCommand={executeCommand}
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
onExitMobileFullscreen={() => void exitMobileFullscreen()}
/>
</InstanceMetadataProvider>
</div>
) : (
<div
class="flex-1 min-h-0 overflow-hidden"
style={{ display: isVisible() ? "flex" : "none" }}
data-tab-id={tab.id}
data-tab-kind={tab.kind}
data-tab-visible={isVisible() ? "true" : "false"}
>
<SideCarView tab={tab.sidecarTab} />
</div>
)
}}
</For>
@@ -525,6 +595,7 @@ const App: Component = () => {
<FolderSelectionView
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
onOpenSidecar={handleOpenSidecarPicker}
/>
</Show>
@@ -534,6 +605,7 @@ const App: Component = () => {
<FolderSelectionView
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
onOpenSidecar={handleOpenSidecarPicker}
onClose={() => {
setShowFolderSelection(false)
clearLaunchError()
@@ -544,6 +616,7 @@ const App: Component = () => {
</Show>
<SettingsScreen />
<SideCarPickerDialog open={sidecarPickerOpen()} onClose={() => setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} />
<AlertDialog />

View File

@@ -108,15 +108,15 @@ const AlertDialog: Component = () => {
open
modal
onOpenChange={(open) => {
if (!open) {
// Only handle dismiss if dialog is dismissible (default: true)
if (!open && payload.dismissible !== false) {
dismiss(false, payload)
}
}}
>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
<Dialog.Overlay class="modal-overlay z-[60]" />
<Dialog.Content class="modal-surface fixed left-1/2 top-1/2 z-[1310] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 p-6 border border-base shadow-2xl" tabIndex={-1}>
<div class="flex items-start gap-3">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
@@ -140,10 +140,11 @@ const AlertDialog: Component = () => {
<Show when={isPrompt}>
<div class="mt-4">
<label class="text-sm font-medium text-secondary">
<label for="prompt-input" class="text-sm font-medium text-secondary">
{payload.inputLabel || t("alertDialog.prompt.inputLabel")}
</label>
<input
id="prompt-input"
ref={(el) => {
promptInputRef = el
}}
@@ -184,11 +185,10 @@ const AlertDialog: Component = () => {
>
{confirmLabel}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
)
}}
</Show>

View File

@@ -1,4 +1,4 @@
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
import { createMemo, Show, createEffect } from "solid-js"
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
import "@git-diff-view/solid/styles/diff-view-pure.css"
import { disableCache } from "@git-diff-view/core"
@@ -20,6 +20,7 @@ interface ToolCallDiffViewerProps {
filePath?: string
theme: "light" | "dark"
mode: DiffViewMode
wrap?: boolean
onRendered?: () => void
cachedHtml?: string
cacheEntryParams?: CacheEntryParams
@@ -31,11 +32,183 @@ type DiffData = {
hunks: string[]
}
type CaptureContext = {
theme: ToolCallDiffViewerProps["theme"]
mode: DiffViewMode
diffText: string
cacheEntryParams?: CacheEntryParams
function measureTextWidth(container: HTMLElement, text: string, source: HTMLElement) {
const computed = window.getComputedStyle(source)
const probe = document.createElement("span")
probe.textContent = text || ""
probe.style.position = "absolute"
probe.style.visibility = "hidden"
probe.style.pointerEvents = "none"
probe.style.display = "inline-block"
probe.style.width = "auto"
probe.style.maxWidth = "none"
probe.style.whiteSpace = "nowrap"
probe.style.fontFamily = computed.fontFamily
probe.style.fontSize = computed.fontSize
probe.style.fontWeight = computed.fontWeight
probe.style.fontStyle = computed.fontStyle
probe.style.letterSpacing = computed.letterSpacing
probe.style.fontVariant = computed.fontVariant
probe.style.textTransform = computed.textTransform
probe.style.lineHeight = computed.lineHeight
container.appendChild(probe)
const width = Math.ceil(probe.getBoundingClientRect().width)
probe.remove()
return width
}
function computeCompactWidth(
container: HTMLElement,
entries: Array<{ text: string; source: HTMLElement }>,
maxWidthPx = 40,
) {
const measuredLabelWidthPx = entries.reduce((max, entry) => {
return Math.max(max, measureTextWidth(container, entry.text, entry.source))
}, 0)
const fallbackTextLength = entries.reduce((max, entry) => Math.max(max, entry.text.length), 1)
const fallbackWidthPx = Math.round(fallbackTextLength * 7 + 4)
return Math.max(2, Math.min(maxWidthPx, measuredLabelWidthPx > 0 ? measuredLabelWidthPx + 2 : fallbackWidthPx))
}
function applyCompactUnifiedGutter(container: HTMLElement, wrap: boolean) {
const tableWrapper = container.querySelector<HTMLElement>(".unified-diff-table-wrapper")
const table = container.querySelector<HTMLTableElement>(".unified-diff-table")
const numberCol = container.querySelector<HTMLTableColElement>(".unified-diff-table-num-col")
const gutterRows = container.querySelectorAll<HTMLElement>(".diff-line-num")
const hunkGutters = container.querySelectorAll<HTMLElement>(".diff-line-hunk-action, .diff-line-widget-wrapper, .diff-line-extend-wrapper")
const entries: Array<{ gutter: HTMLElement; label: HTMLElement; text: string }> = []
if (table) {
if (wrap) {
table.classList.add("table-fixed")
table.style.tableLayout = "fixed"
table.style.width = "100%"
table.style.minWidth = "100%"
} else {
table.classList.remove("table-fixed")
table.style.tableLayout = "auto"
table.style.width = "max-content"
table.style.minWidth = "100%"
}
}
gutterRows.forEach((gutter) => {
const oldSpan = gutter.querySelector<HTMLElement>("[data-line-old-num]")
const newSpan = gutter.querySelector<HTMLElement>("[data-line-new-num]")
const spacer = gutter.querySelector<HTMLElement>(".shrink-0")
const flexWrapper = gutter.querySelector<HTMLElement>(":scope > .flex")
const currentLabel = gutter.querySelector<HTMLElement>(":scope > .tool-call-diff-compact-line-number")
const oldText = oldSpan?.textContent?.trim() ?? ""
const newText = newSpan?.textContent?.trim() ?? ""
const hasUsableNew = newText.length > 0 && newText !== "0"
const hasUsableOld = oldText.length > 0 && oldText !== "0"
const visibleText = hasUsableNew ? newText : hasUsableOld ? oldText : newText || oldText
if (flexWrapper) flexWrapper.style.display = "none"
if (spacer) spacer.style.display = "none"
if (oldSpan) { oldSpan.style.display = "none"; oldSpan.style.width = "auto" }
if (newSpan) { newSpan.style.display = "none"; newSpan.style.width = "auto" }
gutter.style.paddingLeft = "1px"
gutter.style.paddingRight = "1px"
gutter.style.textAlign = "left"
const label = currentLabel ?? document.createElement("span")
label.className = "tool-call-diff-compact-line-number"
label.textContent = visibleText
label.setAttribute("aria-hidden", visibleText ? "false" : "true")
if (!currentLabel) gutter.appendChild(label)
entries.push({ gutter, label, text: visibleText })
})
const gutterWidthPx = computeCompactWidth(container, entries.map((entry) => ({ text: entry.text, source: entry.label })))
const gutterWidth = `${gutterWidthPx}px`
const compactAsideWidth = `${Math.max(8, gutterWidthPx - 10)}px`
if (tableWrapper) {
tableWrapper.style.setProperty("--diff-aside-width", compactAsideWidth)
tableWrapper.style.setProperty("--diff-aside-width--", compactAsideWidth)
}
if (numberCol) {
numberCol.style.width = gutterWidth
}
entries.forEach(({ gutter, label }) => {
gutter.style.width = gutterWidth
gutter.style.minWidth = gutterWidth
gutter.style.maxWidth = gutterWidth
label.style.width = "auto"
label.style.maxWidth = "none"
})
hunkGutters.forEach((gutter) => {
gutter.style.width = gutterWidth
gutter.style.minWidth = gutterWidth
gutter.style.maxWidth = gutterWidth
gutter.style.paddingLeft = "0"
gutter.style.paddingRight = "0"
})
}
function applyCompactSplitGutter(container: HTMLElement) {
const oldWrapper = container.querySelector<HTMLElement>(".old-diff-table-wrapper")
const newWrapper = container.querySelector<HTMLElement>(".new-diff-table-wrapper")
const numberCells = Array.from(container.querySelectorAll<HTMLElement>(".diff-line-old-num, .diff-line-new-num"))
const hunkActions = Array.from(container.querySelectorAll<HTMLElement>(".diff-line-hunk-action, .diff-line-widget-wrapper, .diff-line-extend-wrapper"))
const numberSpans = numberCells
.map((cell) => ({ cell, span: cell.querySelector<HTMLElement>("[data-line-num]") }))
.filter((entry): entry is { cell: HTMLElement; span: HTMLElement } => Boolean(entry.span))
const gutterWidthPx = computeCompactWidth(
container,
numberSpans.map(({ span }) => ({ text: span.textContent?.trim() ?? "", source: span })),
64,
)
const gutterWidth = `${gutterWidthPx}px`
;[oldWrapper, newWrapper].forEach((wrapper) => {
if (wrapper) {
wrapper.style.setProperty("--diff-aside-width", gutterWidth)
}
})
numberCells.forEach((cell) => {
cell.style.width = gutterWidth
cell.style.minWidth = gutterWidth
cell.style.maxWidth = gutterWidth
cell.style.paddingLeft = "2px"
cell.style.paddingRight = "2px"
cell.style.textAlign = "left"
cell.style.whiteSpace = "nowrap"
cell.style.overflowWrap = "normal"
cell.style.wordBreak = "normal"
})
numberSpans.forEach(({ span }) => {
span.style.whiteSpace = "nowrap"
span.style.overflowWrap = "normal"
span.style.wordBreak = "normal"
})
hunkActions.forEach((cell) => {
cell.style.width = gutterWidth
cell.style.minWidth = gutterWidth
cell.style.maxWidth = gutterWidth
cell.style.paddingLeft = "0"
cell.style.paddingRight = "0"
})
}
function applyCompactDiffLayout(container: HTMLElement, mode: DiffViewMode, wrap = false) {
if (mode === "unified") {
applyCompactUnifiedGutter(container, wrap)
return
}
if (mode === "split") {
applyCompactSplitGutter(container)
}
}
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
@@ -67,12 +240,15 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
const contextKey = createMemo(() => {
const data = diffData()
if (!data) return ""
return `${props.theme}|${props.mode}|${props.diffText}`
return `${props.theme}|${props.mode}|${props.wrap ? "wrap" : "nowrap"}|${props.diffText}`
})
createEffect(() => {
const cachedHtml = props.cachedHtml
if (cachedHtml) {
if (diffContainerRef) {
applyCompactDiffLayout(diffContainerRef, props.mode, Boolean(props.wrap))
}
// When we are given cached HTML, we rely on the caller's cache
// and simply notify once rendered.
props.onRendered?.()
@@ -83,9 +259,10 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
if (!key) return
if (!diffContainerRef) return
if (lastCapturedKey === key) return
requestAnimationFrame(() => {
if (!diffContainerRef) return
applyCompactDiffLayout(diffContainerRef, props.mode, Boolean(props.wrap))
const markup = diffContainerRef.innerHTML
if (!markup) return
lastCapturedKey = key
@@ -95,6 +272,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
html: markup,
theme: props.theme,
mode: props.mode,
wrap: props.wrap,
})
}
props.onRendered?.()
@@ -122,7 +300,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
diffViewTheme={props.theme}
diffViewHighlight
diffViewWrap={false}
diffViewWrap={Boolean(props.wrap)}
diffViewFontSize={13}
/>
</ErrorBoundary>
@@ -131,7 +309,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
</div>
}
>
<div innerHTML={props.cachedHtml} />
<div ref={diffContainerRef} innerHTML={props.cachedHtml} />
</Show>
</div>
)

View File

@@ -1,18 +1,69 @@
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
import { Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { loadMonaco } from "../../lib/monaco/setup"
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
import { inferMonacoLanguageId } from "../../lib/monaco/language"
import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
import { useTheme } from "../../lib/theme"
import { parsePatchToBeforeAfter } from "../../lib/diff-utils"
interface MonacoDiffViewerProps {
scopeKey: string
path: string
before: string
after: string
patch?: string
before?: string
after?: string
viewMode?: "split" | "unified"
contextMode?: "expanded" | "collapsed"
wordWrap?: "on" | "off"
onRequestInsertContext?: (selection: { startLine: number; endLine: number }) => void
insertContextLabel?: string
}
function getLineCount(value: string): number {
if (!value) return 1
return value.split("\n").length
}
function getDigitCount(value: number): number {
return String(Math.max(1, value)).length
}
function getUnifiedGutterSizing(options: { before: string; after: string }) {
const beforeLineCount = getLineCount(options.before)
const afterLineCount = getLineCount(options.after)
const beforeDigitCount = getDigitCount(beforeLineCount)
const afterDigitCount = getDigitCount(afterLineCount)
const maxDigitCount = Math.max(beforeDigitCount, afterDigitCount)
const extraDigits = Math.max(0, maxDigitCount - 2)
const beforeNumberChars = Math.max(2, beforeDigitCount)
const afterNumberChars = Math.max(2, afterDigitCount)
const fourDigitPenalty = Math.max(0, maxDigitCount - 3)
return {
diffEditorLineNumbersMinChars: Math.max(beforeNumberChars, afterNumberChars),
originalLineNumbersMinChars: beforeNumberChars,
modifiedLineNumbersMinChars: afterNumberChars,
lineDecorationsWidth: 6 + extraDigits * 2 + fourDigitPenalty * 2,
}
}
function getSplitGutterSizing(options: { before: string; after: string }) {
const beforeLineCount = getLineCount(options.before)
const afterLineCount = getLineCount(options.after)
const beforeDigitCount = getDigitCount(beforeLineCount)
const afterDigitCount = getDigitCount(afterLineCount)
const maxDigitCount = Math.max(beforeDigitCount, afterDigitCount)
const extraDigits = Math.max(0, maxDigitCount - 2)
const beforeNumberChars = Math.max(2, beforeDigitCount)
const afterNumberChars = Math.max(2, afterDigitCount)
const fourDigitPenalty = Math.max(0, maxDigitCount - 3)
return {
diffEditorLineNumbersMinChars: Math.max(beforeNumberChars, afterNumberChars),
originalLineNumbersMinChars: beforeNumberChars,
modifiedLineNumbersMinChars: afterNumberChars,
lineDecorationsWidth: 8 + extraDigits * 2 + fourDigitPenalty,
}
}
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
@@ -21,7 +72,22 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
let diffEditor: any = null
let monaco: any = null
let splitLayoutFrame: number | null = null
const [ready, setReady] = createSignal(false)
const [hoveredLine, setHoveredLine] = createSignal<number | null>(null)
const [selectedRange, setSelectedRange] = createSignal<{ startLine: number; endLine: number } | null>(null)
const [widgetHovered, setWidgetHovered] = createSignal(false)
const [widgetPosition, setWidgetPosition] = createSignal<{ top: number; left: number } | null>(null)
const resolvedContent = createMemo(() => {
if (props.patch !== undefined && props.patch !== null) {
return parsePatchToBeforeAfter(props.patch)
}
return {
before: props.before ?? "",
after: props.after ?? "",
}
})
const disposeEditor = () => {
try {
@@ -37,6 +103,90 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
diffEditor = null
}
const clearSplitLayoutVariables = () => {
if (!host) return
host.style.removeProperty("--split-original-line-number-width")
host.style.removeProperty("--split-original-delete-sign-left")
host.style.removeProperty("--split-original-gutter-width")
}
const syncSplitLayoutVariables = (options: {
viewMode: "split" | "unified"
originalLineNumbersMinChars: number
lineDecorationsWidth: number
}) => {
if (!host) return
if (splitLayoutFrame !== null && typeof window !== "undefined") {
window.cancelAnimationFrame(splitLayoutFrame)
splitLayoutFrame = null
}
if (options.viewMode !== "split" || typeof window === "undefined") {
clearSplitLayoutVariables()
return
}
splitLayoutFrame = window.requestAnimationFrame(() => {
splitLayoutFrame = null
if (!host) return
const originalLineNumbers = host.querySelector<HTMLElement>(".editor.original .line-numbers")
const measuredWidth = originalLineNumbers?.getBoundingClientRect().width ?? 0
const lineNumberWidth =
measuredWidth > 0 ? measuredWidth : Math.max(12, options.originalLineNumbersMinChars * 6)
host.style.setProperty("--split-original-line-number-width", `${lineNumberWidth}px`)
host.style.setProperty("--split-original-delete-sign-left", `${lineNumberWidth}px`)
host.style.setProperty(
"--split-original-gutter-width",
`${lineNumberWidth + options.lineDecorationsWidth}px`,
)
})
}
const getModifiedEditor = () => diffEditor?.getModifiedEditor?.() ?? null
const getActiveInsertRange = () => {
const selection = selectedRange()
if (selection) return selection
if (widgetHovered() && hoveredLine()) {
return { startLine: hoveredLine() as number, endLine: hoveredLine() as number }
}
const line = hoveredLine()
if (!line) return null
return { startLine: line, endLine: line }
}
const layoutInsertWidget = () => {
const modifiedEditor = getModifiedEditor()
const container = host
if (!modifiedEditor || !container) return
const activeRange = getActiveInsertRange()
if (!activeRange) {
setWidgetPosition(null)
return
}
try {
const modifiedDom = modifiedEditor.getDomNode?.() as HTMLElement | null
if (!modifiedDom) {
setWidgetPosition(null)
return
}
const margin = modifiedDom.querySelector<HTMLElement>(".margin")
const scrollable = modifiedDom.querySelector<HTMLElement>(".monaco-scrollable-element.editor-scrollable")
const lineTop = modifiedEditor.getTopForLineNumber?.(activeRange.startLine) ?? 0
const scrollTop = modifiedEditor.getScrollTop?.() ?? 0
const lineHeight = Number(modifiedEditor.getOption?.(monaco.editor.EditorOption.lineHeight) ?? 18)
const modifiedRect = modifiedDom.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
const seamLeft = modifiedRect.left - containerRect.left + (margin?.offsetWidth ?? scrollable?.offsetLeft ?? 0)
const centerTop = modifiedRect.top - containerRect.top + (lineTop - scrollTop) + lineHeight / 2
setWidgetPosition({ top: centerTop, left: seamLeft })
} catch {
setWidgetPosition(null)
}
}
onMount(() => {
let cancelled = false
void (async () => {
@@ -69,10 +219,17 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
})
setReady(true)
layoutInsertWidget()
})()
onCleanup(() => {
cancelled = true
if (splitLayoutFrame !== null && typeof window !== "undefined") {
window.cancelAnimationFrame(splitLayoutFrame)
splitLayoutFrame = null
}
clearSplitLayoutVariables()
setReady(false)
disposeEditor()
})
@@ -83,15 +240,101 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
})
createEffect(() => {
if (!host) return
host.dataset.viewMode = props.viewMode === "split" ? "split" : "unified"
})
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const modifiedEditor = diffEditor.getModifiedEditor?.()
if (!modifiedEditor?.onDidChangeCursorSelection) return
const disposable = modifiedEditor.onDidChangeCursorSelection((event: any) => {
const selection = event?.selection
if (!selection || selection.isEmpty?.()) {
setSelectedRange(null)
layoutInsertWidget()
return
}
setSelectedRange({
startLine: Math.min(selection.startLineNumber, selection.endLineNumber),
endLine: Math.max(selection.startLineNumber, selection.endLineNumber),
})
layoutInsertWidget()
})
onCleanup(() => {
try {
disposable?.dispose?.()
} catch {
// ignore
}
})
})
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const modifiedEditor = getModifiedEditor()
if (!modifiedEditor?.onMouseMove || !modifiedEditor?.onMouseLeave || !modifiedEditor?.onMouseDown) return
const moveDisposable = modifiedEditor.onMouseMove((event: any) => {
const lineNumber = event?.target?.position?.lineNumber
setHoveredLine(typeof lineNumber === "number" ? lineNumber : null)
layoutInsertWidget()
})
const leaveDisposable = modifiedEditor.onMouseLeave(() => {
if (!widgetHovered()) {
setHoveredLine(null)
}
layoutInsertWidget()
})
const scrollDisposable = modifiedEditor.onDidScrollChange?.(() => {
layoutInsertWidget()
})
onCleanup(() => {
try {
moveDisposable?.dispose?.()
leaveDisposable?.dispose?.()
scrollDisposable?.dispose?.()
} catch {
// ignore
}
})
})
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const activeRange = getActiveInsertRange()
if (!activeRange) setWidgetPosition(null)
layoutInsertWidget()
})
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const viewMode = props.viewMode === "unified" ? "unified" : "split"
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
const wordWrap = props.wordWrap === "on" ? "on" : "off"
const { before, after } = resolvedContent()
const sizing =
viewMode === "unified"
? getUnifiedGutterSizing({ before, after })
: getSplitGutterSizing({ before, after })
const {
diffEditorLineNumbersMinChars,
originalLineNumbersMinChars,
modifiedLineNumbersMinChars,
lineDecorationsWidth,
} = sizing
diffEditor.updateOptions({
renderSideBySide: viewMode === "split",
renderSideBySideInlineBreakpoint: 0,
renderIndicators: true,
lineNumbersMinChars: diffEditorLineNumbersMinChars,
lineDecorationsWidth,
hideUnchangedRegions:
contextMode === "collapsed"
? { enabled: true }
@@ -100,26 +343,41 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
})
try {
diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap })
diffEditor.getOriginalEditor?.()?.updateOptions?.({
wordWrap,
lineNumbersMinChars: originalLineNumbersMinChars,
lineDecorationsWidth,
})
} catch {
// ignore
}
try {
diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap })
diffEditor.getModifiedEditor?.()?.updateOptions?.({
wordWrap,
lineNumbersMinChars: modifiedLineNumbersMinChars,
lineDecorationsWidth,
})
} catch {
// ignore
}
syncSplitLayoutVariables({
viewMode,
originalLineNumbersMinChars,
lineDecorationsWidth,
})
})
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const languageId = inferMonacoLanguageId(monaco, props.path)
const { before, after } = resolvedContent()
const beforeKey = `${props.scopeKey}:diff:${props.path}:before`
const afterKey = `${props.scopeKey}:diff:${props.path}:after`
const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: props.before, languageId })
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: props.after, languageId })
const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: before, languageId })
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: after, languageId })
diffEditor.setModel({ original, modified })
void ensureMonacoLanguageLoaded(languageId).then(() => {
@@ -132,5 +390,46 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
})
})
return <div class="monaco-viewer" ref={host} />
return (
<div class="monaco-viewer" ref={host}>
<div class="git-change-context-overlay">
<Show when={widgetPosition()}>
{(position: () => { top: number; left: number }) => (
<div
class="git-change-context-widget-host"
style={{ top: `${position().top}px`, left: `${position().left}px` }}
onMouseEnter={() => {
setWidgetHovered(true)
layoutInsertWidget()
}}
onMouseLeave={() => {
setWidgetHovered(false)
layoutInsertWidget()
}}
>
<button
type="button"
class="git-change-context-widget"
aria-label={props.insertContextLabel ?? "Add git change context to prompt"}
title={props.insertContextLabel ?? "Add git change context to prompt"}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
const activeRange = getActiveInsertRange()
if (!activeRange) return
props.onRequestInsertContext?.(activeRange)
}}
>
+
</button>
</div>
)}
</Show>
</div>
</div>
)
}

View File

@@ -9,6 +9,8 @@ interface MonacoFileViewerProps {
scopeKey: string
path: string
content: string
onSave?: (content: string) => void
onContentChange?: (content: string) => void
}
export function MonacoFileViewer(props: MonacoFileViewerProps) {
@@ -33,6 +35,11 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
editor = null
}
const saveContent = () => {
if (!editor || !props.onSave) return
props.onSave(editor.getValue())
}
onMount(() => {
let cancelled = false
void (async () => {
@@ -44,7 +51,7 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
editor = monaco.editor.create(host, {
value: "",
language: "plaintext",
readOnly: true,
readOnly: false,
automaticLayout: true,
lineNumbers: "on",
minimap: { enabled: false },
@@ -54,6 +61,14 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
fontSize: 13,
})
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, saveContent)
editor.onDidChangeModelContent(() => {
if (props.onContentChange) {
props.onContentChange(editor.getValue())
}
})
setReady(true)
})()

View File

@@ -1,6 +1,7 @@
import { Dialog } from "@kobalte/core/dialog"
import { Select } from "@kobalte/core/select"
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2 } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
@@ -14,25 +15,49 @@ import { useI18n, type Locale } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts"
import { openSettings, settingsOpen } from "../stores/settings-screen"
import { openExternalUrl } from "../lib/external-url"
import { serverApi } from "../lib/api-client"
import { runtimeEnv } from "../lib/runtime-env"
import { openRemoteServerWindow } from "../lib/native/remote-window"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
type HomeTab = "local" | "servers"
interface FolderSelectionViewProps {
onSelectFolder: (folder: string, binaryPath?: string) => void
onOpenSidecar?: () => void
isLoading?: boolean
onClose?: () => void
}
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = useConfig()
const {
recentFolders,
removeRecentFolder,
preferences,
updatePreferences,
serverSettings,
remoteServers,
saveRemoteServerProfile,
markRemoteServerConnected,
removeRemoteServerProfile,
} = useConfig()
const { t, locale } = useI18n()
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
const [activeTab, setActiveTab] = createSignal<HomeTab>("local")
const [isServerDialogOpen, setIsServerDialogOpen] = createSignal(false)
const [serverName, setServerName] = createSignal("")
const [serverUrl, setServerUrl] = createSignal("")
const [skipTlsVerify, setSkipTlsVerify] = createSignal(false)
const [serverDialogError, setServerDialogError] = createSignal<string | null>(null)
const [isSavingServer, setIsSavingServer] = createSignal(false)
const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null)
const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined
@@ -49,10 +74,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
]
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
const folders = () => recentFolders()
const serverList = () => remoteServers()
const isLoading = () => Boolean(props.isLoading)
function getActiveListLength() {
return activeTab() === "local" ? folders().length : serverList().length
}
// Update selected binary when preferences change
createEffect(() => {
const lastUsed = serverSettings().opencodeBinary
@@ -64,7 +94,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
function scrollToIndex(index: number) {
const container = recentListRef
if (!container) return
const element = container.querySelector(`[data-folder-index="${index}"]`) as HTMLElement | null
const element = container.querySelector(`[data-list-index="${index}"]`) as HTMLElement | null
if (!element) return
const containerRect = container.getBoundingClientRect()
@@ -113,19 +143,18 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
return
}
const folderList = folders()
if (isBrowseShortcut) {
e.preventDefault()
void handleBrowse()
return
}
if (folderList.length === 0) return
const listLength = getActiveListLength()
if (listLength === 0) return
if (e.key === "ArrowDown") {
e.preventDefault()
const newIndex = Math.min(selectedIndex() + 1, folderList.length - 1)
const newIndex = Math.min(selectedIndex() + 1, listLength - 1)
setSelectedIndex(newIndex)
setFocusMode("recent")
scrollToIndex(newIndex)
@@ -138,7 +167,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
} else if (e.key === "PageDown") {
e.preventDefault()
const pageSize = 5
const newIndex = Math.min(selectedIndex() + pageSize, folderList.length - 1)
const newIndex = Math.min(selectedIndex() + pageSize, listLength - 1)
setSelectedIndex(newIndex)
setFocusMode("recent")
scrollToIndex(newIndex)
@@ -156,7 +185,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
scrollToIndex(0)
} else if (e.key === "End") {
e.preventDefault()
const newIndex = folderList.length - 1
const newIndex = listLength - 1
setSelectedIndex(newIndex)
setFocusMode("recent")
scrollToIndex(newIndex)
@@ -165,10 +194,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
handleEnterKey()
} else if (e.key === "Backspace" || e.key === "Delete") {
e.preventDefault()
if (folderList.length > 0 && focusMode() === "recent") {
const folder = folderList[selectedIndex()]
if (folder) {
handleRemove(folder.path)
if (listLength > 0 && focusMode() === "recent") {
if (activeTab() === "local") {
const folder = folders()[selectedIndex()]
if (folder) {
handleRemove(folder.path)
}
} else {
const server = serverList()[selectedIndex()]
if (server) {
removeRemoteServerProfile(server.id)
}
}
}
}
@@ -177,15 +213,40 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
function handleEnterKey() {
if (isLoading()) return
const folderList = folders()
const index = selectedIndex()
const folder = folderList[index]
if (folder) {
handleFolderSelect(folder.path)
if (activeTab() === "local") {
const folder = folders()[index]
if (folder) {
handleFolderSelect(folder.path)
}
return
}
const server = serverList()[index]
if (server) {
void handleConnectSavedServer(server.id)
}
}
createEffect(() => {
activeTab()
setSelectedIndex(0)
setFocusMode("recent")
})
createEffect(() => {
const length = getActiveListLength()
if (length === 0) {
setSelectedIndex(0)
return
}
if (selectedIndex() >= length) {
setSelectedIndex(length - 1)
}
})
onMount(() => {
window.addEventListener("keydown", handleKeyDown)
@@ -236,6 +297,103 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
props.onSelectFolder(path, selectedBinary())
}
function resetServerDialog() {
setServerName("")
setServerUrl("")
setSkipTlsVerify(false)
setServerDialogError(null)
}
function openServerDialog() {
resetServerDialog()
setIsServerDialogOpen(true)
}
async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) {
const trimmedName = input.name.trim()
const trimmedUrl = input.baseUrl.trim()
if (!trimmedName || !trimmedUrl) {
throw new Error(t("folderSelection.servers.dialog.errorRequired"))
}
const probe = await serverApi.probeRemoteServer({
baseUrl: trimmedUrl,
skipTlsVerify: input.skipTlsVerify,
})
if (!probe.ok) {
throw new Error(probe.error || t("folderSelection.servers.dialog.errorConnect"))
}
const profile = await saveRemoteServerProfile({
id: input.id,
name: trimmedName,
baseUrl: probe.normalizedUrl,
skipTlsVerify: input.skipTlsVerify,
})
if (openWindow) {
const remoteProxySession =
runtimeEnv.host === "tauri" && profile.skipTlsVerify && profile.baseUrl.startsWith("https://")
? await serverApi.createRemoteProxySession({
baseUrl: profile.baseUrl,
skipTlsVerify: profile.skipTlsVerify,
})
: undefined
try {
await openRemoteServerWindow(profile, remoteProxySession?.windowUrl, remoteProxySession?.sessionId)
} catch (error) {
if (remoteProxySession) {
void serverApi.deleteRemoteProxySession(remoteProxySession.sessionId).catch(() => {})
}
throw error
}
await markRemoteServerConnected(profile.id)
}
return profile
}
async function handleSaveServer(openWindow: boolean) {
if (isSavingServer()) return
setIsSavingServer(true)
setServerDialogError(null)
try {
await probeAndOpenServer(
{
name: serverName(),
baseUrl: serverUrl(),
skipTlsVerify: skipTlsVerify(),
},
openWindow,
)
setIsServerDialogOpen(false)
resetServerDialog()
} catch (error) {
setServerDialogError(error instanceof Error ? error.message : String(error))
} finally {
setIsSavingServer(false)
}
}
async function handleConnectSavedServer(id: string) {
const target = remoteServers().find((entry) => entry.id === id)
if (!target || connectingServerId()) return
setConnectingServerId(id)
try {
await probeAndOpenServer(target, true)
} catch (error) {
showAlertDialog(error instanceof Error ? error.message : String(error), {
title: t("folderSelection.servers.errorTitle"),
variant: "warning",
})
} finally {
setConnectingServerId(null)
}
}
async function handleBrowse() {
if (isLoading()) return
setFocusMode("new")
@@ -443,7 +601,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
rel="noreferrer"
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
aria-label={t("folderSelection.links.githubStars")}
title={t("folderSelection.links.githubStars")}
title={githubStars() !== null ? `${t("folderSelection.links.githubStars")}: ${githubStars()!.toLocaleString()}` : t("folderSelection.links.githubStars")}
onClick={(event) => {
event.preventDefault()
void openExternalUrl(GITHUB_URL, "folder-selection")
@@ -476,90 +634,223 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
{/* Right column: recent folders */}
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
<Show
when={folders().length > 0}
fallback={
<div class="panel panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Clock class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
</div>
}
>
<div class="panel flex flex-col flex-1 min-h-0">
<div class="panel-header">
<h2 class="panel-title">{t("folderSelection.recent.title")}</h2>
<p class="panel-subtitle">
{t(
folders().length === 1
? "folderSelection.recent.subtitle.one"
: "folderSelection.recent.subtitle.other",
{ count: folders().length },
)}
</p>
</div>
<div
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
ref={(el) => (recentListRef = el)}
>
<For each={folders()}>
{(folder, index) => (
<div class="panel-header !gap-0 !p-0">
<div class="grid grid-cols-2 gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none">
<button
type="button"
class="border-r border-base px-4 py-3 text-left transition-colors"
classList={{
"text-primary": activeTab() === "local",
"text-muted hover:text-secondary": activeTab() !== "local",
}}
style={{
"background-color": "var(--surface-secondary)",
}}
onClick={() => setActiveTab("local")}
>
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
"panel-list-item-disabled": isLoading(),
class="panel-title text-base"
style={{
color: activeTab() === "local" ? "var(--text-primary)" : "var(--text-secondary)",
}}
>
<div class="flex items-center gap-2 w-full px-1">
{t("folderSelection.recent.title")}
</div>
<p
class="panel-subtitle mt-1"
style={{
color: activeTab() === "local" ? "var(--text-muted)" : "var(--text-secondary)",
}}
>
{t(
folders().length === 1
? "folderSelection.recent.subtitle.one"
: "folderSelection.recent.subtitle.other",
{ count: folders().length },
)}
</p>
</button>
<button
type="button"
class="px-4 py-3 text-left transition-colors"
classList={{
"text-primary": activeTab() === "servers",
"text-muted hover:text-secondary": activeTab() !== "servers",
}}
style={{
"background-color": "var(--surface-secondary)",
}}
onClick={() => setActiveTab("servers")}
>
<div
class="panel-title text-base"
style={{
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
}}
>
{t("folderSelection.tabs.servers")}
</div>
<p
class="panel-subtitle mt-1"
style={{
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
}}
>
{t("folderSelection.servers.count", { count: remoteServers().length })}
</p>
</button>
</div>
</div>
<Show
when={activeTab() === "local"}
fallback={
<Show
when={remoteServers().length > 0}
fallback={
<div class="panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Globe class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p>
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
<button
data-folder-index={index()}
class="panel-list-item-content flex-1"
disabled={isLoading()}
onClick={() => handleFolderSelect(folder.path)}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
setSelectedIndex(index())
}}
type="button"
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
onClick={openServerDialog}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{splitFolderPath(folder.path).baseName}
</span>
</div>
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
<span class="font-mono truncate-start flex-1 min-w-0">
{getDisplayPath(folder.path)}
</span>
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
</div>
</div>
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
<kbd class="kbd"></kbd>
</Show>
</div>
</button>
<button
onClick={(e) => handleRemove(folder.path, e)}
disabled={isLoading()}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title={t("folderSelection.recent.remove")}
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
<Globe class="w-4 h-4" />
<span>{t("folderSelection.actions.connectButton")}</span>
</button>
</div>
}
>
<div
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
ref={(el) => (recentListRef = el)}
>
<For each={remoteServers()}>
{(server, index) => (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
}}
>
<div class="flex items-center gap-2 w-full px-1">
<button
data-list-index={index()}
class="panel-list-item-content flex-1"
onClick={() => void handleConnectSavedServer(server.id)}
onMouseEnter={() => {
setFocusMode("recent")
setSelectedIndex(index())
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0 text-left">
<div class="flex items-center gap-2 mb-1">
<Globe class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">{server.name}</span>
</div>
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
<span class="font-mono truncate-start flex-1 min-w-0">{server.baseUrl}</span>
</div>
</div>
<Show when={connectingServerId() === server.id} fallback={<Show when={focusMode() === "recent" && selectedIndex() === index()}><kbd class="kbd"></kbd></Show>}>
<Loader2 class="w-4 h-4 animate-spin icon-muted" />
</Show>
</div>
</button>
<button
onClick={() => removeRemoteServerProfile(server.id)}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title={t("folderSelection.servers.remove")}
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button>
</div>
</div>
)}
</For>
</div>
)}
</For>
</div>
</Show>
}
>
<Show
when={folders().length > 0}
fallback={
<div class="panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Clock class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
</div>
}
>
<div
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
ref={(el) => (recentListRef = el)}
>
<For each={folders()}>
{(folder, index) => (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
"panel-list-item-disabled": isLoading(),
}}
>
<div class="flex items-center gap-2 w-full px-1">
<button
data-list-index={index()}
class="panel-list-item-content flex-1"
disabled={isLoading()}
onClick={() => handleFolderSelect(folder.path)}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
setSelectedIndex(index())
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{splitFolderPath(folder.path).baseName}
</span>
</div>
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
<span class="font-mono truncate-start flex-1 min-w-0">
{getDisplayPath(folder.path)}
</span>
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
</div>
</div>
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
<kbd class="kbd"></kbd>
</Show>
</div>
</button>
<button
onClick={(e) => handleRemove(folder.path, e)}
disabled={isLoading()}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title={t("folderSelection.recent.remove")}
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button>
</div>
</div>
)}
</For>
</div>
</Show>
</Show>
</div>
</Show>
</div>
@@ -567,11 +858,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
<div class="panel shrink-0">
<div class="panel-header hidden sm:block">
<h2 class="panel-title">{t("folderSelection.browse.title")}</h2>
<p class="panel-subtitle">{t("folderSelection.browse.subtitle")}</p>
<h2 class="panel-title">{t("folderSelection.actions.title")}</h2>
<p class="panel-subtitle">{t("folderSelection.actions.subtitle")}</p>
</div>
<div class="panel-body">
<div class="panel-body flex flex-col gap-3">
<button
onClick={() => void handleBrowse()}
disabled={props.isLoading}
@@ -588,6 +879,27 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div>
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
</button>
<button
type="button"
onClick={() => props.onOpenSidecar?.()}
class="button-primary mt-3 w-full flex items-center justify-center text-sm"
>
<div class="flex items-center gap-2">
<MonitorUp class="w-4 h-4" />
<span>{t("folderSelection.sidecars.button")}</span>
</div>
</button>
<button
onClick={openServerDialog}
class="button-primary w-full flex items-center justify-center text-sm"
>
<div class="flex items-center gap-2">
<Globe class="w-4 h-4" />
<span>{t("folderSelection.actions.connectButton")}</span>
</div>
</button>
</div>
{/* OpenCode settings section */}
@@ -663,6 +975,82 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
onClose={() => setIsFolderBrowserOpen(false)}
onSelect={handleBrowserSelect}
/>
<Dialog open={isServerDialogOpen()} onOpenChange={(open) => !open && setIsServerDialogOpen(false)}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-[1300] flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-lg p-6 flex flex-col gap-5" tabIndex={-1}>
<div>
<Dialog.Title class="text-xl font-semibold text-primary">
{t("folderSelection.servers.dialog.title")}
</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">
{t("folderSelection.servers.dialog.description")}
</Dialog.Description>
</div>
<label class="flex flex-col gap-2 text-sm text-secondary">
<span>{t("folderSelection.servers.dialog.name")}</span>
<input
class="selector-input w-full"
value={serverName()}
onInput={(event) => setServerName(event.currentTarget.value)}
placeholder={t("folderSelection.servers.dialog.namePlaceholder")}
/>
</label>
<label class="flex flex-col gap-2 text-sm text-secondary">
<span>{t("folderSelection.servers.dialog.url")}</span>
<input
class="selector-input w-full"
value={serverUrl()}
onInput={(event) => setServerUrl(event.currentTarget.value)}
placeholder={t("folderSelection.servers.dialog.urlPlaceholder")}
/>
</label>
<label class="flex items-start gap-3 text-sm text-secondary">
<input
type="checkbox"
checked={skipTlsVerify()}
onChange={(event) => setSkipTlsVerify(event.currentTarget.checked)}
/>
<span>{t("folderSelection.servers.dialog.skipTls")}</span>
</label>
<Show when={serverDialogError()}>
{(message) => <p class="text-sm text-red-500 break-words">{message()}</p>}
</Show>
<div class="flex items-center justify-end gap-3">
<button class="selector-button selector-button-secondary w-auto px-4" onClick={() => setIsServerDialogOpen(false)}>
{t("folderSelection.servers.dialog.cancel")}
</button>
<button
class="selector-button selector-button-secondary w-auto px-4"
disabled={isSavingServer()}
onClick={() => void handleSaveServer(false)}
>
{t("folderSelection.servers.dialog.save")}
</button>
<button
class="selector-button selector-button-secondary w-auto px-4"
disabled={isSavingServer()}
onClick={() => void handleSaveServer(true)}
>
<Show when={isSavingServer()} fallback={<span>{t("folderSelection.servers.dialog.connect")}</span>}>
<span class="inline-flex items-center gap-2">
<Loader2 class="w-4 h-4 animate-spin" />
{t("folderSelection.servers.dialog.connecting")}
</span>
</Show>
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
</>
)
}

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