Compare commits

...

179 Commits

Author SHA1 Message Date
Shantur Rathore
68551f6731 fix(ui): unify apply_patch diagnostics matching 2026-04-20 21:08:33 +01:00
Shantur Rathore
662a6b94b0 fix(ui): remove delete shortcuts from recent lists 2026-04-20 20:51:36 +01:00
Pascal André
77df40169a Fix WSL UNC OpenCode binaries on Windows (#341)
## Summary
- support Windows validation and launch of OpenCode binaries stored
under WSL UNC paths like \\wsl.localhost\...
- harden the existing manual directory browser so absolute, UNC, and WSL
paths can be pasted and navigated reliably
- harden WSL env/path propagation, UNC workspace handling, runtime
shutdown, and add targeted tests

Partially addresses #5.

## Testing
- node --test --import tsx src/workspaces/__tests__/spawn.test.ts
- npm run typecheck --workspace @neuralnomads/codenomad
- npm run typecheck --workspace @codenomad/ui
2026-04-20 20:29:08 +01:00
Shantur Rathore
3b411e2e73 fix(ui): gate desktop privileges by host and window context (#347)
Don't let remote server windows use local features like local file browser etc
2026-04-20 20:28:11 +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
0dc5867fb3 fix(speech): surface streaming playback compatibility 2026-03-26 22:59:30 +00:00
Shantur Rathore
d13ecba322 feat(speech): add configurable TTS playback modes 2026-03-26 20:46:49 +00:00
Shantur Rathore
740f37db86 refactor(ui): use stop-square icon for speech playback 2026-03-26 19:39:37 +00:00
Shantur Rathore
d447b05821 feat(ui): add message text-to-speech controls 2026-03-26 18:29:45 +00:00
Shantur Rathore
1233121a13 feat(speech): add prompt voice input (#249)
## Summary
- add server-backed speech capabilities and transcription endpoints plus
UI settings for speech configuration
- add push-to-talk prompt voice input with microphone controls,
transcription insertion, and browser capability gating
- keep prompt controls aligned by restoring right-side nav placement and
moving the mic beside the expand control
2026-03-25 14:08:11 +00:00
Pascal André
a950d47df0 fix(tauri): force Windows process tree shutdown (#240)
## Summary
- force the Windows CLI process tree shutdown path during normal app
close
- avoid leaving child server processes alive when the direct wrapper
process exits first
- keep the change limited to the Windows shutdown path in cli_manager

## Testing
- cargo check --manifest-path packages/tauri-app/src-tauri/Cargo.toml
2026-03-24 21:12:43 +00:00
MusiCode1
1c68f5d288 feat(i18n): Hebrew locale + full RTL support (#243)
# feat(i18n): Hebrew locale + full RTL support

## Summary

This PR adds full Hebrew (he) locale support to the UI, including a
complete translation of all user-facing strings and comprehensive RTL
layout support across all components.

## What was done

### Hebrew translation
- Full translation of all i18n message files for the `he` locale (17
translation files)
- Registered the language in the i18n system and the language picker

### RTL support
- Automatic direction detection (`dir="rtl"`) when Hebrew is selected
- Replaced physical CSS properties (`left`/`right`) with logical
equivalents (`inline-start`/`inline-end`) across the project
- Fixed resize direction, file path alignment, and textarea padding
- Fixed navigation button positioning in textarea for RTL
- Fixed scrollbar direction in RTL
- Fixed code block direction and selector alignment
- Fixed Monaco editor direction in the file viewer
- Auto-detect text direction in reasoning block (`dir="auto"` +
`unicode-bidi: plaintext`)

### Adapted components
- `session-layout` — sidebar and resize handle
- `prompt-input` — text direction and buttons
- `message-base` — message blocks and reasoning
- `message-timeline` — timeline bar
- `right-panel` — right side panel
- `tool-call` — tool call display
- `settings-screen` — settings page
- `selector` — selection component
- `instance-shell` — main shell

## New files

```
packages/ui/src/lib/i18n/messages/he/
  advancedSettings.ts
  app.ts
  commands.ts
  dialogs.ts
  filesystem.ts
  folderSelection.ts
  index.ts
  instance.ts
  loadingScreen.ts
  logs.ts
  markdown.ts
  messaging.ts
  remoteAccess.ts
  session.ts
  settings.ts
  time.ts
  toolCall.ts
```

## Suggested testing
- Switch language to Hebrew and verify all strings are translated
- Verify RTL layout is correct across all screens (session, settings,
file viewer)
- Verify that English text inside a reasoning block is displayed LTR
- Switch back to English and verify everything returns to LTR

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Shantur Rathore <i@shantur.com>
2026-03-24 21:09:52 +00:00
Pascal André
3bad0afd7d perf(ui): defer locale and overlay bundles (#238)
## Summary
- defer locale and overlay loading work away from the first critical
render path
- seed locale state from the bootstrap preload so the first render can
use the preloaded language immediately
- keep bootstrap cache and locale fallback behavior consistent on
subsequent launches

## Testing
- npm run build --workspace @codenomad/ui
2026-03-23 15:12:28 +00:00
Pascal André
8567d49178 perf(ui): split right panel and secondary viewer chunks (#239)
## Summary
- split the right panel, picker, and tool call secondary viewers into
smaller deferred chunks
- release hidden right-panel file buffers and stop tracking static
tool-call scrollers when they are not needed
- keep this branch focused on the remaining secondary viewer chunking
work now that the Monaco-specific chunking moved into PR 215

## Testing
- npm run build --workspace @codenomad/ui
2026-03-23 08:47:03 +00:00
MusiCode1
09284ee2ce feat(ui): add RTL support for Hebrew/Arabic text (#229)
## What and why

CodeNomad had no RTL (right-to-left) support, so users writing in Hebrew
or Arabic would see their messages displayed left-to-right — misaligned
text, broken reading flow, wrong punctuation placement.

This PR adds automatic direction detection to all elements that display
user or model text. The browser detects direction from the first strong
character in each text block: Hebrew/Arabic → RTL, Latin/code → LTR. No
configuration needed — it just works per message, per paragraph.

## Technical notes

The natural fix is `dir="auto"` on the containing elements. However,
Chromium does not propagate direction detection from a parent `<div>`
into its `<p>` children — so Hebrew inside `<p>` rendered via
`innerHTML` (as markdown is) was still detected as LTR. The fix is to
apply `unicode-bidi: plaintext` via CSS directly on the block-level
elements (`p`, `li`, headings, etc.), which has the same auto-detection
semantics but applies per element.

## Summary

- Add `dir="auto"` to all elements containing user-generated or
model-generated text (message content, prompt input, session names, tool
outputs) so the browser auto-detects text direction
- Add `unicode-bidi: plaintext` via CSS to markdown block elements (`p`,
`li`, headings, `blockquote`, `td`/`th`) to fix per-paragraph RTL
detection in Chromium (where `dir="auto"` on a parent div does not
recurse into block children)
- Convert physical CSS properties to logical equivalents in
`markdown.css`: `border-left` → `border-inline-start`, `padding-left` →
`padding-inline-start`, `text-align: left` → `text-align: start`,
`margin-left` → `margin-inline-start`

## Affected components

- `markdown.tsx` — main markdown renderer
- `message-part.tsx` — text part wrapper and plain-text fallback
- `message-item.tsx` — message body and error blocks
- `prompt-input.tsx` — user input textarea
- `session-list.tsx` — session titles in sidebar
- `session-rename-dialog.tsx` — session rename input
- `instance-welcome-view.tsx` — Resume Session dialog
- `tool-call/markdown-render.tsx` — tool output markdown fallback
- `tool-call/ansi-render.tsx` — ANSI output
- `tool-call/diagnostics-section.tsx` — diagnostic messages

## Test plan

- [ ] Send a Hebrew-only message → text right-aligned
- [ ] Send a mixed Hebrew + English message → correct per-paragraph
direction
- [ ] Message containing a code block → code stays LTR
- [ ] Type Hebrew in the prompt textarea → input flows right-to-left
- [ ] Hebrew session name in sidebar → right-aligned
- [ ] Hebrew session name in Resume Session dialog → right-aligned

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 20:18:24 +00:00
Pascal André
a2e30f1b54 fix(tauri): restore desktop menu controls and fullscreen shortcut (#226)
## Summary
- restore the missing desktop View and Window menu controls
- use native reload and window actions where supported instead of
brittle webview-only behavior
- restore the working fullscreen keyboard shortcut while keeping the
zoom menu labels aligned with the intended desktop behavior

## Testing
- cargo check --manifest-path packages/tauri-app/src-tauri/Cargo.toml
2026-03-22 20:13:29 +00:00
Shantur Rathore
a4af811de3 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-03-22 20:09:24 +00:00
Shantur Rathore
c5aa59ca75 fix(ui): keep reasoning streams pinned to bottom 2026-03-22 20:04:45 +00:00
Shantur Rathore
b8e0714b68 fix(ui): reduce message stream follow threshold 2026-03-22 19:54:28 +00:00
Shantur Rathore
3f890e5de1 fix(ui): restore spacing between virtualized message parts 2026-03-22 19:46:44 +00:00
Shantur Rathore
935926d875 ci: skip draft PR builds until ready 2026-03-22 19:41:48 +00:00
Pascal André
74f753abf4 perf(ui): lazy-load markdown and defer diff rendering (#215)
## Summary
- lazy-load the markdown and diff render paths so they stop inflating
initial UI startup work
- move shared text rendering helpers out of the markdown path and keep
diff rendering on the deferred path
- defer the Monaco secondary viewers so the markdown and diff path no
longer keeps that work in the main bundle

## Follow-ups
- related fork follow-up: Pagecran/CodeNomad#1
- that follow-up is now independent on dev and only keeps the remaining
right panel, picker, and tool-call secondary chunking work

## Testing
- npm run typecheck --workspace @codenomad/ui
- npm run build --workspace @codenomad/ui
2026-03-22 11:54:05 +00:00
codenomadbot[bot]
d15340a4b8 fix(ui): unwrap pasted placeholders in slash commands (#235)
## What
Fix slash command execution so `[pasted #N]` placeholders are resolved
before calling `session.command`, matching normal prompt send behavior.

## Why
When pasting long text into a slash command (e.g. `/some-command [pasted
#1]`), the UI previously bypassed `resolvePastedPlaceholders(...)` for
known slash commands and sent the literal placeholder text as command
arguments.

## Changes
- Resolve pasted placeholders (and other prompt placeholders handled by
`resolvePastedPlaceholders`) in slash-command arguments before
`executeCustomCommand(...)`.
- Remove *consumed* pasted-text attachments (those referenced by
placeholders in the slash-command args) so they don’t linger for the
next prompt.

Fixes #234.

## Notes
- I attempted `npm run typecheck --workspace @codenomad/ui` locally but
the workspace dependencies aren’t installed in this bot environment, so
it fails with missing-module errors. CI should validate with a full
install.

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

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-03-20 22:49:24 +00:00
Pascal André
108cad82d0 fix(tauri): restore external links in the folder picker (#225)
## Summary
- restore the GitHub and Discord links on the folder picker in the Tauri
app
- open those links through the desktop opener bridge instead of relying
on browser-only navigation behavior
- include the capability/schema updates needed for the opener path

## Testing
- npm run typecheck --workspace @codenomad/ui
- cargo check --manifest-path packages/tauri-app/src-tauri/Cargo.toml
2026-03-20 22:48:29 +00:00
codenomadbot[bot]
823dd2d687 Suppress OS notifications for subagent (child) sessions (#236)
This PR prevents OS notification spam from spawned subagent sessions by
skipping OS-level notifications for any session that is a child thread
(`parentId !== null`).

What changed
- `packages/ui/src/stores/session-events.ts`
- Added `isChildSession(...)` +
`shouldSendOsNotificationForSession(...)`
  - Applied the check to OS notifications emitted from:
    - `handleSessionIdle(...)`
    - `handlePermissionUpdated(...)`
    - `handleQuestionAsked(...)`
- If a session is not yet hydrated in the client store, we
conservatively *do not* emit an OS notification (avoids early subagent
spam).

Why
- Subagent sessions are represented as child sessions in the UI thread
model; OS notifications were previously emitted for all sessions
indiscriminately.

Testing
- Not run here: `bun run typecheck` fails in this environment due to
missing installed deps/types (e.g. `solid-js`).

Closes #228

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

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-03-20 22:48:06 +00:00
Mateusz Popielarz
313e82880b feat(lazy loading): Implement virtual list with virtua (#241)
### Summary of Improvements

This PR replaces the custom `IntersectionObserver`-based virtualization
with the `virtua` library to significantly improve rendering performance
and UI responsiveness.

### 🚀 Performance Results

Verified using `session-performance.test.ts`:
- **Rendering**: 2000 messages rendered in **16.90ms**.
- **Huge Conversation**: 10,000 messages processed in **0.80ms**.
- **Session Switching**: Average switch time reduced to **0.58ms**
(virtually zero lag).

### 🛠️ Key Changes

- **Virtualized Message Stream**: Integrated `virtua/solid` for
efficient windowing and automatic scroll compensation.
- **Floating Scroll Controls**: Applied `position: absolute` and
`pointer-events: none` to the list controls to ensure
scroll-to-top/bottom buttons float correctly over the message area
without blocking interactions.
- **Package Synchronization**: Updated `virtua` and SDK dependencies,
with a fully synchronized `package-lock.json` for stable builds.

### 🎥 UI Verification


https://github.com/user-attachments/assets/24e483a3-8be6-4ac4-a431-d719f2015f4e


- **Smooth Scrolling**: Verified that rendering gaps are eliminated
during fast scrolls.
- **Position Retention**: Scroll positions are preserved when switching
between sessions.

> [!NOTE]
> Detailed performance gains and layout fixes are isolated to the
`virtua` implementation and core package updates, following the
requested cleanup.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-03-20 22:46:05 +00:00
Shantur Rathore
68407a01a4 ci: post PR artifact comments per build 2026-03-20 18:00:18 +00:00
Shantur Rathore
0283493f2a ci: prefer latest PR build run for artifact comments 2026-03-20 17:56:31 +00:00
Shantur Rathore
e989795de3 ci: move PR artifact comments to trusted workflow 2026-03-20 09:24:27 +00:00
Shantur Rathore
103d2bf1a8 ci: comment PR artifacts from validation run 2026-03-20 07:40:59 +00:00
Shantur Rathore
0ce7a47e03 ci: read PR number from workflow run 2026-03-20 07:22:56 +00:00
Shantur Rathore
5df8809c82 ci: resolve artifact comments by PR head branch 2026-03-20 07:13:04 +00:00
Shantur Rathore
6e22614648 ci: resolve PR number for artifact comment 2026-03-19 21:15:48 +00:00
Shantur Rathore
5d87e1e563 ci: upload PR build artifacts and comment link 2026-03-19 20:52:14 +00:00
Shantur Rathore
d735b189f5 refactor(tauri): use imported event and dialog APIs 2026-03-19 19:38:43 +00:00
Shantur Rathore
3d575f4f68 fix(tauri): align wake lock bridge with v2 API 2026-03-19 19:20:18 +00:00
Shantur Rathore
b58728dc0e add PR branch authorization workflows
Restrict non-dev pull requests to an allowlisted set of actors and skip cross-platform PR builds unless that authorization check passes. Keep dev open for general contributions while guiding other PRs back to the dev branch.
2026-03-19 15:01:36 +00:00
Shantur Rathore
672177f570 add PR build validation workflow
Run the full cross-platform build matrix on pull request creation and updates so build regressions are caught before merge without publishing release artifacts.
2026-03-19 14:52:48 +00:00
Shantur Rathore
6961efde0b Merge pull request #224 from Pagecran/upstream/tauri-prebuild-sync
fix(tauri): sync server UI bundle during prebuild
2026-03-18 20:39:22 +00:00
Shantur Rathore
b3e0233f4b Merge pull request #232 from NeuralNomadsAI/codenomad/issue-231
fix(tauri): stop CLI process group on exit
2026-03-18 20:33:55 +00:00
Pascal André
fcebcb0174 fix(tauri): sync server UI bundle during prebuild
Ensure the Tauri prebuild step refreshes packages/server/public from the current UI renderer bundle so the packaged desktop app does not serve a stale folder-selection UI.
2026-03-18 20:45:08 +01:00
Shantur Rathore
eaab5e2e9f fix(tauri): stop CLI process group on exit 2026-03-18 19:43:41 +00:00
Shantur Rathore
b12825f923 Merge pull request #227 from Pagecran/upstream/tauri-windows-runtime
fix(tauri): improve Windows desktop runtime behavior
2026-03-18 19:37:31 +00:00
Pascal André
8245f474b8 fix(tauri): avoid non-Windows spawn warning 2026-03-18 20:21:40 +01:00
Pascal André
3a15b311a8 fix(tauri): hide taskkill during Windows cleanup 2026-03-18 20:19:10 +01:00
Pascal André
6cb6c0af32 fix(tauri): align desktop bundle identifier 2026-03-18 20:19:10 +01:00
Pascal André
7f631611fd fix(tauri): hide Windows CLI console window
Set CREATE_NO_WINDOW on the spawned local CLI process so the packaged Windows Tauri app does not flash an extra console window when it launches Node.
2026-03-18 20:19:10 +01:00
Pascal André
9d91ecc649 fix(tauri): kill Windows CLI process trees on shutdown
Use taskkill /T /F for the local server process on Windows so child Node/Bun processes do not survive app shutdown or startup timeouts.
2026-03-18 20:19:10 +01:00
Pascal André
87afb06d34 fix(tauri): restore Windows app identity
Set the same explicit AppUserModelID that the legacy Electron app used so Windows taskbar grouping and notification attribution stay consistent in the Tauri desktop build.
2026-03-18 20:18:59 +01:00
Pascal André
4402d9afb0 fix(tauri): align desktop version metadata
Match the Tauri package, Cargo, and bundle version metadata to the current legacy desktop version so About dialogs and installer artifacts stop reporting 0.1.0.
2026-03-18 20:18:07 +01: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
7c3f808d69 Minium server 0.12.3 2026-03-13 20:06:41 +00:00
Shantur Rathore
a59e929b12 Release v0.12.3 2026-03-13 20:04:20 +00:00
Shantur Rathore
8ff4019839 fix(ui): stabilize prompt async optimistic messages
Reconcile optimistic user messages by replacing the oldest synthetic pending message when the server-backed message arrives. Stop sending prompt part ids and rely on message-level replacement so v1.2.25 validation passes without duplicating optimistic content.
2026-03-13 19:17:55 +00:00
Shantur Rathore
d9068ac8c6 fix(ui): tighten settings content padding
Reduce the Settings scroll area gutter while keeping a consistent inset on all sides.
2026-03-11 11:01:04 +00:00
Shantur Rathore
51f8eff3f7 fix(ui): remove settings rounded corners
Make the Settings screen use square corners across panels, cards, and embedded controls.
2026-03-11 10:55:51 +00:00
Shantur Rathore
627ff2d42b feat(ui): centralize interaction preferences
Expose interaction defaults in Settings and reuse the same registry for command palette actions.
2026-03-11 10:53:28 +00:00
Shantur Rathore
0d9da40102 feat(ui): add unified settings screen 2026-03-11 10:10:58 +00:00
Shantur Rathore
ff94c9714e fix(tauri): align dev CLI args with electron 2026-03-10 22:23:38 +00:00
Shantur Rathore
429825f434 feat(desktop): unify folder drag-and-drop across runtimes 2026-03-10 22:12:23 +00:00
Shantur Rathore
d836d2e62d fix(tauri): remove Windows-only CLI dependency 2026-03-10 20:15:16 +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
312 changed files with 29178 additions and 5843 deletions

View File

@@ -28,6 +28,21 @@ on:
required: false
default: true
type: boolean
upload_actions_artifacts:
description: "Upload built artifacts to GitHub Actions run artifacts"
required: false
default: false
type: boolean
actions_artifacts_retention_days:
description: "Retention (days) for GitHub Actions artifacts"
required: false
default: 7
type: number
actions_artifacts_name_prefix:
description: "Optional prefix for Actions artifact names"
required: false
default: ""
type: string
set_versions:
description: "Run npm version to set workspace versions"
required: false
@@ -197,12 +212,21 @@ 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
done
- name: Upload Actions artifacts (Electron macOS)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-macos
path: packages/electron-app/release/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error
build-windows:
runs-on: windows-2025
env:
@@ -244,6 +268,15 @@ jobs:
gh release upload $env:TAG $_.FullName --clobber
}
- name: Upload Actions artifacts (Electron Windows)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-windows
path: packages/electron-app/release/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error
build-linux:
runs-on: ubuntu-24.04
env:
@@ -280,12 +313,23 @@ 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
done
- name: Upload Actions artifacts (Electron Linux)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
path: |
packages/electron-app/release/*.zip
packages/electron-app/release/*.AppImage
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error
build-tauri-macos:
runs-on: macos-15-intel
env:
@@ -339,7 +383,7 @@ jobs:
run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS)
if: ${{ inputs.upload }}
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
run: |
set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
@@ -350,6 +394,15 @@ jobs:
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
fi
- name: Upload Actions artifacts (Tauri macOS)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos
path: packages/tauri-app/release-tauri/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: warn
- name: Upload Tauri release assets (macOS)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
@@ -414,7 +467,7 @@ jobs:
run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS arm64)
if: ${{ inputs.upload }}
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
run: |
set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
@@ -425,6 +478,15 @@ jobs:
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
fi
- name: Upload Actions artifacts (Tauri macOS arm64)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos-arm64
path: packages/tauri-app/release-tauri/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: warn
- name: Upload Tauri release assets (macOS arm64)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
@@ -492,7 +554,7 @@ jobs:
run: npm exec -- tauri build
- name: Package Tauri artifacts (Windows)
if: ${{ inputs.upload }}
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
shell: pwsh
run: |
$bundleRoot = "packages/tauri-app/target/release/bundle"
@@ -505,6 +567,15 @@ jobs:
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
}
- name: Upload Actions artifacts (Tauri Windows)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-windows
path: packages/tauri-app/release-tauri/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: warn
- name: Upload Tauri release assets (Windows)
if: ${{ inputs.upload && inputs.tag != '' }}
shell: pwsh
@@ -582,7 +653,7 @@ jobs:
run: npm exec -- tauri build
- name: Package Tauri artifacts (Linux)
if: ${{ inputs.upload }}
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
run: |
set -euo pipefail
SEARCH_ROOT="packages/tauri-app/target"
@@ -608,6 +679,15 @@ jobs:
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
- name: Upload Actions artifacts (Tauri Linux)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-linux
path: packages/tauri-app/release-tauri/*
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: warn
- name: Upload Tauri release assets (Linux)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
@@ -766,3 +846,12 @@ jobs:
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done
- name: Upload Actions artifacts (Electron Linux RPM)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux-rpm
path: packages/electron-app/release/*.rpm
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error

View File

@@ -0,0 +1,122 @@
name: Comment PR Artifacts
on:
pull_request_target:
types:
- opened
- edited
- synchronize
- reopened
- ready_for_review
permissions:
actions: read
contents: read
issues: write
pull-requests: write
jobs:
comment:
runs-on: ubuntu-latest
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
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 }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
RETENTION_DAYS: 7
steps:
- name: Check PR authorization
id: auth
shell: bash
run: |
set -euo pipefail
if [ "$BASE_REF" = "dev" ]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
else
echo "allowed=false" >> "$GITHUB_OUTPUT"
fi
- name: Wait for PR build and comment
if: ${{ steps.auth.outputs.allowed == 'true' && env.IS_DRAFT != 'true' }}
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = Number(process.env.PR_NUMBER);
const headSha = process.env.HEAD_SHA;
const retentionDays = Number(process.env.RETENTION_DAYS || '7');
const marker = '<!-- codenomad-pr-artifacts -->';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let matchedRun = null;
for (let attempt = 1; attempt <= 30; attempt += 1) {
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
owner,
repo,
workflow_id: 'pr-build.yml',
event: 'pull_request',
per_page: 100,
});
const matchingRuns = runs
.filter((run) => run.head_sha === headSha)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
matchedRun = matchingRuns[0] || null;
if (matchedRun && matchedRun.status === 'completed') {
break;
}
core.info(`Waiting for PR Build Validation run for ${headSha} (attempt ${attempt}/30)`);
await sleep(10000);
}
if (!matchedRun) {
core.setFailed(`Could not find PR Build Validation run for ${headSha}.`);
return;
}
if (matchedRun.status !== 'completed') {
core.setFailed(`PR Build Validation run ${matchedRun.id} did not complete in time.`);
return;
}
const artifacts = await github.paginate(
github.rest.actions.listWorkflowRunArtifacts,
{ owner, repo, run_id: matchedRun.id, per_page: 100 }
);
const active = artifacts.filter((artifact) => !artifact.expired);
const runUrl = matchedRun.html_url;
const artifactsBlock = active.length
? ['Artifacts:', ...active.map((artifact) => `- ${artifact.name}`)].join('\n')
: 'Artifacts: (none found on this run)';
const body = [
marker,
'PR builds are available as GitHub Actions artifacts:',
'',
runUrl,
'',
`Artifacts expire in ${retentionDays} days.`,
artifactsBlock,
].join('\n');
const created = await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body,
});
core.info(`Created artifacts comment: ${created.data.html_url}`);

58
.github/workflows/pr-build.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
name: PR Build Validation
on:
pull_request:
types:
- opened
- edited
- synchronize
- reopened
- ready_for_review
permissions:
contents: read
actions: write
concurrency:
group: pr-build-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
authorize:
runs-on: ubuntu-latest
outputs:
allowed: ${{ steps.auth.outputs.allowed }}
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
steps:
- name: Check PR authorization
id: auth
shell: bash
run: |
set -euo pipefail
if [ "$BASE_REF" = "dev" ]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
else
echo "allowed=false" >> "$GITHUB_OUTPUT"
echo "Skipping builds for PR by unauthorized author targeting $BASE_REF" >&2
fi
build:
needs: authorize
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
uses: ./.github/workflows/build-and-upload.yml
with:
ref: ${{ github.event.pull_request.head.sha }}
upload: false
upload_actions_artifacts: true
actions_artifacts_retention_days: 7
actions_artifacts_name_prefix: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}-
set_versions: false

View File

@@ -0,0 +1,55 @@
name: Restrict Non-Dev PRs
on:
pull_request_target:
types:
- opened
- edited
- reopened
- synchronize
permissions:
contents: read
pull-requests: write
jobs:
restrict-non-dev-prs:
if: ${{ github.event.pull_request.base.ref != 'dev' }}
runs-on: ubuntu-latest
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
steps:
- name: Check allowed actor
id: auth
shell: bash
run: |
set -euo pipefail
normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${PR_AUTHOR},"* ]]; then
echo "authorized=true" >> "$GITHUB_OUTPUT"
else
echo "authorized=false" >> "$GITHUB_OUTPUT"
fi
- name: Comment on unauthorized PR
if: ${{ steps.auth.outputs.authorized != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr comment "$PR_NUMBER" --body "Thanks for the contribution. PRs need to target \`dev\` branch. Please retarget this PR to the dev branch"
- name: Close unauthorized PR
if: ${{ steps.auth.outputs.authorized != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr close "$PR_NUMBER"
- name: Fail unauthorized PR
if: ${{ steps.auth.outputs.authorized != 'true' }}
run: |
echo "PR author $PR_AUTHOR is not allowed to open PRs targeting $BASE_REF" >&2
exit 1

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

394
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "codenomad-workspace",
"version": "0.12.2",
"version": "0.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
"version": "0.12.2",
"version": "0.14.0",
"license": "MIT",
"dependencies": {
"7zip-bin": "^5.2.0",
@@ -15,6 +15,14 @@
"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"
},
"workspaces": {
"packages": [
"packages/server",
@@ -2931,16 +2939,304 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz",
"integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz",
"integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz",
"integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.52.5",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz",
"integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz",
"integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz",
"integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz",
"integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz",
"integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz",
"integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz",
"integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz",
"integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz",
"integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz",
"integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz",
"integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz",
"integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz",
"integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz",
"integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz",
"integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz",
"integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz",
"integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz",
"integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@shikijs/core": {
@@ -3253,9 +3549,9 @@
}
},
"node_modules/@tauri-apps/api": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
@@ -3314,6 +3610,7 @@
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
@@ -3321,6 +3618,15 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
@@ -8230,6 +8536,27 @@
"regex-recursion": "^6.0.2"
}
},
"node_modules/openai": {
"version": "6.27.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.27.0.tgz",
"integrity": "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -10234,14 +10561,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/tauri-plugin-keepawake-api": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/tauri-plugin-keepawake-api/-/tauri-plugin-keepawake-api-0.1.0.tgz",
"integrity": "sha512-XPUl66zUYiB7kCRxsTdmCoNjFM/++NWCJ4kdTo2NUOgBUa8UVYfayDWnnTzGIQbhT7qNAHs+jgKSjhqSKs/QHA==",
"dependencies": {
"@tauri-apps/api": ">=2.0.0-beta.6"
}
},
"node_modules/temp-dir": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
@@ -10982,6 +11301,36 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/virtua": {
"version": "0.48.8",
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.48.8.tgz",
"integrity": "sha512-jpsxOw5V4B6hg44JePRLo9DL0TV7N1lBEVtPjKpAJebXyhI2s9lfiXJESaLapNtr3vtiSk/pWHiLf7B2a6UcgQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.14.0",
"react-dom": ">=16.14.0",
"solid-js": ">=1.0",
"svelte": ">=5.0",
"vue": ">=3.2"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"solid-js": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/vite": {
"version": "5.4.21",
"dev": true,
@@ -11987,6 +12336,7 @@
"node_modules/zod": {
"version": "3.25.76",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -12001,7 +12351,7 @@
},
"packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.12.2",
"version": "0.14.0",
"license": "MIT",
"dependencies": {
"@codenomad/ui": "file:../ui",
@@ -12038,7 +12388,7 @@
},
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.12.2",
"version": "0.14.0",
"license": "MIT",
"dependencies": {
"@fastify/cors": "^8.5.0",
@@ -12048,6 +12398,7 @@
"fastify": "^4.28.1",
"fuzzysort": "^2.0.4",
"node-forge": "^1.3.3",
"openai": "^6.27.0",
"pino": "^9.4.0",
"undici": "^6.19.8",
"yaml": "^2.4.2",
@@ -12079,16 +12430,15 @@
},
"packages/tauri-app": {
"name": "@codenomad/tauri-app",
"version": "0.12.2",
"version": "0.14.0",
"license": "MIT",
"devDependencies": {
"@tauri-apps/cli": "^2.9.4",
"@tauri-apps/cli-win32-x64-msvc": "^2.9.4"
"@tauri-apps/cli": "^2.9.4"
}
},
"packages/ui": {
"name": "@codenomad/ui",
"version": "0.12.2",
"version": "0.14.0",
"license": "MIT",
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
@@ -12098,6 +12448,8 @@
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",
"@suid/system": "^0.14.0",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2.5.3",
"ansi-sequence-parser": "^1.1.3",
@@ -12110,7 +12462,7 @@
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0",
"tauri-plugin-keepawake-api": "^0.1.0",
"virtua": "^0.48.8",
"yaml": "^2.4.2"
},
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.12.2",
"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.11.4",
"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,4 +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
@@ -65,6 +67,24 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
return { canceled: result.canceled, paths: result.filePaths }
})
ipcMain.handle("filesystem:getDirectoryPaths", async (_event, paths: unknown): Promise<string[]> => {
if (!Array.isArray(paths)) {
return []
}
const directories = paths.filter((value): value is string => {
if (typeof value !== "string" || value.trim().length === 0) {
return false
}
try {
return fs.statSync(value).isDirectory()
} catch {
return false
}
})
return directories
})
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
const next = Boolean(enabled)
if (next) {
@@ -92,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")
@@ -92,8 +120,13 @@ function loadLoadingScreen(window: BrowserWindow) {
})
}
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 +141,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 +160,7 @@ function setupNavigationGuards(window: BrowserWindow) {
}
window.webContents.setWindowOpenHandler(({ url }) => {
if (shouldOpenExternally(url)) {
if (shouldOpenExternally(url, window)) {
handleExternal(url)
return { action: "deny" }
}
@@ -135,13 +168,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)) {
@@ -203,28 +277,34 @@ function createWindow() {
contextIsolation: true,
nodeIntegration: false,
spellcheck: !isMac,
additionalArguments: ["--codenomad-window-context=local"],
},
})
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)
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
@@ -321,13 +401,69 @@ 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,
additionalArguments: ["--codenomad-window-context=remote"],
},
})
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 +486,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 +517,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: "/",
@@ -489,6 +626,7 @@ app.whenReady().then(() => {
if (isMac) {
session.defaultSession.setSpellCheckerEnabled(false)
configureMediaPermissionHandlers(getAllowedRendererOrigins)
app.on("browser-window-created", (_, window) => {
window.webContents.session.setSpellCheckerEnabled(false)
})
@@ -502,6 +640,17 @@ app.whenReady().then(() => {
}
createWindow()
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
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"
@@ -38,6 +42,9 @@ interface CliEntryResolution {
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,67 @@ 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)
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)) {
const runtimePath = this.resolveShellNodeCommand()
const entryPath = this.resolveBundledProdEntry()
const supervisorPath = this.resolveCliSupervisorPath()
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
const shellCommand = buildUserShellCommand(`exec ${this.buildExecutableCommand(runtimePath, [entryPath, ...args])}`)
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 node at ${runtimePath} (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: shellEnv,
stdio: "pipe",
serviceName: "CodeNomad CLI Supervisor",
})
this.childLaunchMode = "utility"
} else {
const cliEntry = this.resolveCliEntry(options)
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 }
env.ELECTRON_RUN_AS_NODE = "1"
const spawnDetails = supportsUserShell()
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${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 +216,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 +284,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 +375,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 +395,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 +450,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 +539,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.
@@ -449,6 +572,10 @@ 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 === "tsx") {
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
@@ -519,4 +646,58 @@ export class CliProcessManager extends EventEmitter {
}
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
}
private shouldUsePackagedShellSupervisor(options: StartOptions): boolean {
return !options.dev && app.isPackaged && process.platform === "darwin"
}
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 resolveShellNodeCommand(): string {
const configured = process.env.NODE_BINARY?.trim()
return configured && configured.length > 0 ? configured : "node"
}
private resolveBundledProdEntry(): string {
const candidates = [
path.join(process.resourcesPath, "server", "dist", "bin.js"),
path.join(mainDirname, "../resources/server/dist/bin.js"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
throw new Error("Unable to locate bundled CodeNomad CLI build in app resources.")
}
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

@@ -1,6 +1,19 @@
const { contextBridge, ipcRenderer } = require("electron")
const { contextBridge, ipcRenderer, webUtils } = require("electron")
const electronAPI = {
function resolveWindowContext() {
const prefix = "--codenomad-window-context="
const arg = process.argv.find((value) => typeof value === "string" && value.startsWith(prefix))
const context = arg ? arg.slice(prefix.length) : "local"
return context === "remote" ? "remote" : "local"
}
function resolveRuntimeHost(windowContext) {
return "electron"
}
const windowContext = resolveWindowContext()
const localElectronAPI = {
onCliStatus: (callback) => {
ipcRenderer.on("cli:status", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:status")
@@ -12,8 +25,29 @@ const electronAPI = {
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
restartCli: () => ipcRenderer.invoke("cli:restart"),
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths),
getPathForFile: (file) => {
try {
return webUtils.getPathForFile(file)
} catch {
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)
const remoteElectronAPI = {
requestMicrophoneAccess: localElectronAPI.requestMicrophoneAccess,
setWakeLock: localElectronAPI.setWakeLock,
showNotification: localElectronAPI.showNotification,
}
contextBridge.exposeInMainWorld(
"electronAPI",
windowContext === "local" ? localElectronAPI : remoteElectronAPI,
)
contextBridge.exposeInMainWorld("__CODENOMAD_WINDOW_CONTEXT__", windowContext)
contextBridge.exposeInMainWorld("__CODENOMAD_RUNTIME_HOST__", resolveRuntimeHost(windowContext))

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.2",
"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

@@ -111,6 +111,12 @@ async function build(platform) {
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,132 @@
#!/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")
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 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 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()
ensureServerDependencies()
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.3.7"
}
}

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.2",
"version": "0.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.12.2",
"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.2",
"version": "0.14.0",
"description": "CodeNomad Server",
"license": "MIT",
"author": {
@@ -32,6 +32,7 @@
"fastify": "^4.28.1",
"fuzzysort": "^2.0.4",
"node-forge": "^1.3.3",
"openai": "^6.27.0",
"pino": "^9.4.0",
"undici": "^6.19.8",
"yaml": "^2.4.2",

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
@@ -207,12 +274,87 @@ export interface BinaryValidationResult {
error?: string
}
export interface SpeechSegment {
startMs: number
endMs: number
text: string
}
export interface SpeechCapabilitiesResponse {
available: boolean
configured: boolean
provider: string
supportsStt: boolean
supportsTts: boolean
supportsStreamingTts: boolean
baseUrl?: string
sttModel: string
ttsModel: string
ttsVoice: string
ttsFormats: string[]
streamingTtsFormats: string[]
}
export interface SpeechTranscriptionResponse {
text: string
language?: string
durationMs?: number
segments?: SpeechSegment[]
}
export interface SpeechSynthesisResponse {
audioBase64: string
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"
@@ -225,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 }
@@ -291,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
@@ -303,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,10 +19,16 @@ 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"
const require = createRequire(import.meta.url)
@@ -54,6 +60,7 @@ interface CliOptions {
launch: boolean
authUsername: string
authPassword?: string
authCookieName: string
generateToken: boolean
dangerouslySkipAuth: boolean
}
@@ -99,6 +106,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")
@@ -138,6 +150,7 @@ function parseCliOptions(argv: string[]): CliOptions {
launch?: boolean
username: string
password?: string
authCookieName: string
generateToken?: boolean
dangerouslySkipAuth?: boolean
}>()
@@ -184,6 +197,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),
}
@@ -265,6 +279,7 @@ async function main() {
configPath: configLocation.configYamlPath,
username: options.authUsername,
password: options.authPassword,
cookieName: options.authCookieName,
generateToken: options.generateToken,
dangerouslySkipAuth: options.dangerouslySkipAuth,
},
@@ -304,6 +319,12 @@ 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,
@@ -355,12 +376,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)
@@ -388,7 +418,13 @@ async function main() {
eventBus,
serverMeta,
instanceStore,
speechService,
sidecarManager,
authManager,
clientConnectionManager,
pluginChannel,
voiceModeManager,
remoteProxySessionManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
logger,
@@ -408,7 +444,13 @@ async function main() {
eventBus,
serverMeta,
instanceStore,
speechService,
sidecarManager,
authManager,
clientConnectionManager,
pluginChannel,
voiceModeManager,
remoteProxySessionManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined,
logger,
@@ -438,18 +480,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
@@ -460,7 +506,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 = []
}
@@ -468,6 +516,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) {
@@ -491,6 +549,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

@@ -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,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,14 @@ 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 { 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"
@@ -21,12 +24,22 @@ import { registerStorageRoutes } from "./routes/storage"
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"
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
@@ -41,7 +54,13 @@ interface HttpServerDeps {
eventBus: EventBus
serverMeta: ServerMeta
instanceStore: InstanceStore
speechService: SpeechService
sidecarManager: SideCarManager
authManager: AuthManager
clientConnectionManager: ClientConnectionManager
pluginChannel: PluginChannelManager
voiceModeManager: VoiceModeManager
remoteProxySessionManager: RemoteProxySessionManager
uiStaticDir: string
uiDevServerUrl?: string
logger: Logger
@@ -183,14 +202,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(?:\/|$)/)
@@ -245,14 +269,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,
})
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
registerRemoteServerRoutes(app, { logger: apiLogger })
registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager })
registerSpeechRoutes(app, { speechService: deps.speechService })
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 })
@@ -327,6 +372,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()
@@ -663,52 +770,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")
@@ -811,3 +872,281 @@ function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, s
}
return result
}
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

@@ -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

@@ -1,8 +1,9 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { probeBinaryVersion } from "../../workspaces/runtime"
import { probeBinaryVersion } from "../../workspaces/spawn"
import type { SettingsService } from "../../settings/service"
import type { Logger } from "../../logger"
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
interface RouteDeps {
settings: SettingsService
@@ -20,10 +21,10 @@ function validateBinaryPath(binaryPath: string): { valid: boolean; version?: str
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
// Full-document access
app.get("/api/storage/config", async () => deps.settings.getDoc("config"))
app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config")))
app.patch("/api/storage/config", async (request, reply) => {
try {
return deps.settings.mergePatchDoc("config", request.body ?? {})
return sanitizeConfigDoc(deps.settings.mergePatchDoc("config", request.body ?? {}))
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" }
@@ -31,12 +32,15 @@ export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
})
app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => {
return deps.settings.getOwner("config", request.params.owner)
return sanitizeConfigOwner(request.params.owner, deps.settings.getOwner("config", request.params.owner))
})
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
try {
return deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {})
return sanitizeConfigOwner(
request.params.owner,
deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}),
)
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" }

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

@@ -0,0 +1,74 @@
import type { FastifyInstance } from "fastify"
import { z } from "zod"
import type { SpeechService } from "../../speech/service"
interface RouteDeps {
speechService: SpeechService
}
const TranscribeBodySchema = z.object({
audioBase64: z.string().min(1, "Audio payload is required"),
mimeType: z.string().min(1, "Audio MIME type is required"),
filename: z.string().optional(),
language: z.string().optional(),
prompt: z.string().optional(),
})
const SynthesizeBodySchema = z.object({
text: z.string().trim().min(1, "Text is required"),
format: z.enum(["mp3", "wav", "opus", "aac"]).optional(),
})
function getSpeechErrorStatus(error: unknown): number {
if (error instanceof z.ZodError) {
return 400
}
if (error instanceof Error && /not configured/i.test(error.message)) {
return 503
}
return 502
}
function getSpeechErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback
}
export function registerSpeechRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/speech/capabilities", async () => deps.speechService.getCapabilities())
app.post("/api/speech/transcribe", async (request, reply) => {
try {
const body = TranscribeBodySchema.parse(request.body ?? {})
return await deps.speechService.transcribe(body)
} catch (error) {
request.log.error({ err: error }, "Failed to transcribe audio")
reply.code(getSpeechErrorStatus(error))
return { error: getSpeechErrorMessage(error, "Failed to transcribe audio") }
}
})
app.post("/api/speech/synthesize", async (request, reply) => {
try {
const body = SynthesizeBodySchema.parse(request.body ?? {})
return await deps.speechService.synthesize(body)
} catch (error) {
request.log.error({ err: error }, "Failed to synthesize audio")
reply.code(getSpeechErrorStatus(error))
return { error: getSpeechErrorMessage(error, "Failed to synthesize audio") }
}
})
app.post("/api/speech/synthesize/stream", async (request, reply) => {
try {
const body = SynthesizeBodySchema.parse(request.body ?? {})
const result = await deps.speechService.synthesizeStream(body)
reply.header("Content-Type", result.mimeType)
reply.header("Cache-Control", "no-store")
return reply.send(result.stream)
} catch (error) {
request.log.error({ err: error }, "Failed to stream synthesized audio")
reply.code(getSpeechErrorStatus(error))
return { error: getSpeechErrorMessage(error, "Failed to stream synthesized audio") }
}
})
}

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

@@ -0,0 +1,40 @@
import type { SettingsDoc } from "./yaml-doc-store"
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function sanitizeServerOwner(value: SettingsDoc): SettingsDoc {
const next: SettingsDoc = { ...value }
const speech = isPlainObject(next.speech) ? { ...next.speech } : null
if (!speech) {
return next
}
const rawApiKey = typeof speech.apiKey === "string" ? speech.apiKey.trim() : ""
if (rawApiKey) {
delete speech.apiKey
speech.hasApiKey = true
} else if (!("hasApiKey" in speech)) {
speech.hasApiKey = false
}
next.speech = speech
return next
}
export function sanitizeConfigOwner(owner: string, value: SettingsDoc): SettingsDoc {
if (owner !== "server") {
return value
}
return sanitizeServerOwner(value)
}
export function sanitizeConfigDoc(value: SettingsDoc): SettingsDoc {
const next: SettingsDoc = { ...value }
if (isPlainObject(next.server)) {
next.server = sanitizeServerOwner(next.server)
}
return next
}

View File

@@ -1,12 +1,62 @@
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"
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
@@ -22,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
}
@@ -45,10 +117,11 @@ export class SettingsService {
private publish(kind: DocKind, owner: string, value?: SettingsDoc) {
if (!this.eventBus) return
const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged"
const nextValue = value ?? this.getOwner(kind, owner)
const payload: WorkspaceEventPayload = {
type,
owner,
value: value ?? this.getOwner(kind, owner),
value: kind === "config" ? sanitizeConfigOwner(owner, nextValue) : nextValue,
} as any
this.eventBus.publish(payload)
}

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

@@ -0,0 +1,234 @@
import { Readable } from "node:stream"
import OpenAI from "openai"
import { toFile } from "openai/uploads"
import type { SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../../api-types"
import type { Logger } from "../../logger"
import type { NormalizedSpeechSettings, SpeechSynthesisStreamResponse, SynthesizeSpeechInput, TranscribeAudioInput } from "../service"
interface OpenAICompatibleSpeechProviderOptions {
settings: NormalizedSpeechSettings
logger: Logger
}
export class OpenAICompatibleSpeechProvider {
constructor(private readonly options: OpenAICompatibleSpeechProviderOptions) {}
getCapabilities() {
const { settings } = this.options
return {
available: true,
configured: Boolean(settings.apiKey),
provider: settings.provider,
supportsStt: true,
supportsTts: true,
supportsStreamingTts: true,
baseUrl: settings.baseUrl,
sttModel: settings.sttModel,
ttsModel: settings.ttsModel,
ttsVoice: settings.ttsVoice,
ttsFormats: ["mp3", "wav", "opus", "aac"],
streamingTtsFormats: ["mp3", "wav", "opus", "aac"],
}
}
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
const client = this.createClient()
const startedAt = Date.now()
const extension = extensionForMime(input.mimeType)
const buffer = Buffer.from(input.audioBase64, "base64")
const filename = input.filename?.trim() || `prompt-input.${extension}`
this.options.logger.info(
{
mimeType: input.mimeType,
bytes: buffer.byteLength,
language: input.language,
model: this.options.settings.sttModel,
},
"speech.transcribe",
)
const response = await this.requestTranscription(client, buffer, filename, input)
return {
text: typeof response?.text === "string" ? response.text : "",
language: typeof response?.language === "string" ? response.language : input.language,
durationMs: Number.isFinite(response?.duration) ? Math.round(Number(response.duration) * 1000) : Date.now() - startedAt,
segments: Array.isArray(response?.segments)
? response.segments
.filter((segment: any) => typeof segment?.text === "string")
.map((segment: any) => ({
startMs: Math.max(0, Math.round(Number(segment.start ?? 0) * 1000)),
endMs: Math.max(0, Math.round(Number(segment.end ?? 0) * 1000)),
text: String(segment.text),
}))
: undefined,
}
}
private async requestTranscription(
client: OpenAI,
buffer: Buffer,
filename: string,
input: TranscribeAudioInput,
): Promise<any> {
const baseRequest = {
model: this.options.settings.sttModel,
...(input.language ? { language: input.language } : {}),
...(input.prompt ? { prompt: input.prompt } : {}),
}
try {
const file = await toFile(buffer, filename, { type: input.mimeType })
return (await client.audio.transcriptions.create({
...baseRequest,
file,
response_format: "verbose_json" as any,
} as any)) as any
} catch (error) {
this.options.logger.warn({ err: error }, "speech.transcribe verbose_json failed; retrying default format")
const retryFile = await toFile(buffer, filename, { type: input.mimeType })
return (await client.audio.transcriptions.create({
...baseRequest,
file: retryFile,
} as any)) as any
}
}
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
const format = input.format ?? this.options.settings.ttsFormat
this.options.logger.info(
{
model: this.options.settings.ttsModel,
voice: this.options.settings.ttsVoice,
format,
},
"speech.synthesize",
)
const response = await this.requestSpeechAudio(input.text, format)
const mimeType = response.headers.get("content-type") || mimeTypeForFormat(format)
const audioBuffer = Buffer.from(await response.arrayBuffer())
return {
audioBase64: audioBuffer.toString("base64"),
mimeType,
}
}
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
const format = input.format ?? this.options.settings.ttsFormat
this.options.logger.info(
{
model: this.options.settings.ttsModel,
voice: this.options.settings.ttsVoice,
format,
},
"speech.synthesize.stream",
)
const response = await this.requestSpeechAudio(input.text, format)
if (!response.body) {
throw new Error("Speech provider did not return a stream.")
}
return {
stream: Readable.fromWeb(response.body as any),
mimeType: response.headers.get("content-type") || mimeTypeForFormat(format),
}
}
private async requestSpeechAudio(text: string, format: "mp3" | "wav" | "opus" | "aac"): Promise<Response> {
const { settings } = this.options
if (!settings.apiKey) {
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
}
const endpoint = new URL("audio/speech", ensureTrailingSlash(settings.baseUrl ?? "https://api.openai.com/v1"))
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()
throw new Error(detail || `Speech synthesis failed with ${response.status}`)
}
return response
}
private createClient(): OpenAI {
const { settings } = this.options
if (!settings.apiKey) {
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
}
return new OpenAI({
apiKey: settings.apiKey,
baseURL: settings.baseUrl,
})
}
}
function extensionForMime(mimeType: string): string {
const normalized = mimeType.toLowerCase()
if (normalized.includes("webm")) return "webm"
if (normalized.includes("ogg")) return "ogg"
if (normalized.includes("wav")) return "wav"
if (normalized.includes("mpeg") || normalized.includes("mp3")) return "mp3"
if (normalized.includes("mp4") || normalized.includes("aac")) return "m4a"
return "webm"
}
function mimeTypeForFormat(format: "mp3" | "wav" | "opus" | "aac"): string {
if (format === "wav") return "audio/wav"
if (format === "opus") return 'audio/ogg; codecs="opus"'
if (format === "aac") return "audio/aac"
return "audio/mpeg"
}
function ensureTrailingSlash(value: string): string {
return value.endsWith("/") ? value : `${value}/`
}

View File

@@ -0,0 +1,106 @@
import { z } from "zod"
import type { Readable } from "node:stream"
import type { Logger } from "../logger"
import type { SettingsService } from "../settings/service"
import type { SpeechCapabilitiesResponse, SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../api-types"
import { OpenAICompatibleSpeechProvider } from "./providers/openai-compatible"
const ServerSpeechSettingsSchema = z.object({
speech: z
.object({
provider: z.string().optional(),
apiKey: z.string().optional(),
baseUrl: z.string().optional(),
sttModel: z.string().optional(),
ttsModel: z.string().optional(),
ttsVoice: z.string().optional(),
ttsFormat: z.enum(["mp3", "wav", "opus", "aac"]).optional(),
})
.optional(),
})
export interface TranscribeAudioInput {
audioBase64: string
mimeType: string
filename?: string
language?: string
prompt?: string
}
export interface SynthesizeSpeechInput {
text: string
format?: "mp3" | "wav" | "opus" | "aac"
}
export interface SpeechSynthesisStreamResponse {
stream: Readable
mimeType: string
}
export interface SpeechProvider {
getCapabilities(): SpeechCapabilitiesResponse
transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse>
synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse>
synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse>
}
export interface NormalizedSpeechSettings {
provider: string
apiKey?: string
baseUrl?: string
sttModel: string
ttsModel: string
ttsVoice: string
ttsFormat: "mp3" | "wav" | "opus" | "aac"
}
const DEFAULT_PROVIDER = "openai-compatible"
const DEFAULT_STT_MODEL = "gpt-4o-mini-transcribe"
const DEFAULT_TTS_MODEL = "gpt-4o-mini-tts"
const DEFAULT_TTS_VOICE = "alloy"
const DEFAULT_TTS_FORMAT = "mp3"
export class SpeechService {
constructor(
private readonly settings: SettingsService,
private readonly logger: Logger,
) {}
getCapabilities(): SpeechCapabilitiesResponse {
return this.createProvider().getCapabilities()
}
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
return this.createProvider().transcribe(input)
}
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
return this.createProvider().synthesize(input)
}
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
return this.createProvider().synthesizeStream(input)
}
private createProvider(): SpeechProvider {
const settings = this.resolveSettings()
return new OpenAICompatibleSpeechProvider({
settings,
logger: this.logger.child({ provider: settings.provider }),
})
}
private resolveSettings(): NormalizedSpeechSettings {
const parsed = ServerSpeechSettingsSchema.parse(this.settings.getOwner("config", "server") ?? {})
const speech = parsed.speech ?? {}
return {
provider: speech.provider?.trim() || DEFAULT_PROVIDER,
apiKey: speech.apiKey?.trim() || process.env.OPENAI_API_KEY,
baseUrl: speech.baseUrl?.trim() || process.env.OPENAI_BASE_URL || undefined,
sttModel: speech.sttModel?.trim() || DEFAULT_STT_MODEL,
ttsModel: speech.ttsModel?.trim() || DEFAULT_TTS_MODEL,
ttsVoice: speech.ttsVoice?.trim() || DEFAULT_TTS_VOICE,
ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT,
}
}
}

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,193 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { buildWindowsSpawnSpec, buildWslSignalSpec, parseWslUncPath, resolveWslWorkingDirectory } from "../spawn"
describe("parseWslUncPath", () => {
it("parses WSL UNC paths into distro and linux path", () => {
assert.deepEqual(parseWslUncPath(String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`), {
distro: "Ubuntu",
linuxPath: "/home/dev/.opencode/bin/opencode",
})
})
it("supports the legacy wsl$ UNC prefix", () => {
assert.deepEqual(parseWslUncPath(String.raw`\\wsl$\Ubuntu\home\dev`), {
distro: "Ubuntu",
linuxPath: "/home/dev",
})
})
})
describe("resolveWslWorkingDirectory", () => {
it("keeps WSL workspace folders in the same distro", () => {
assert.equal(
JSON.stringify(resolveWslWorkingDirectory(String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`, "Ubuntu")),
JSON.stringify({ kind: "linux", path: "/home/dev/workspace" }),
)
})
it("keeps Windows drive paths so WSL can resolve them with wslpath", () => {
assert.equal(
JSON.stringify(resolveWslWorkingDirectory(String.raw`C:\Users\dev\workspace`, "Ubuntu")),
JSON.stringify({ kind: "windows", path: String.raw`C:\Users\dev\workspace` }),
)
})
it("keeps UNC network paths so WSL can resolve them with wslpath", () => {
assert.equal(
JSON.stringify(resolveWslWorkingDirectory(String.raw`\\server\share\workspace`, "Ubuntu")),
JSON.stringify({ kind: "windows", path: String.raw`\\server\share\workspace` }),
)
})
it("rejects WSL workspace folders from a different distro", () => {
assert.equal(resolveWslWorkingDirectory(String.raw`\\wsl.localhost\Debian\home\dev\workspace`, "Ubuntu"), null)
})
})
describe("buildWindowsSpawnSpec", () => {
it("wraps WSL binaries with wsl.exe and propagates required env vars", () => {
const spec = buildWindowsSpawnSpec(
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
["serve", "--port", "0"],
{
cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`,
env: {
OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
CODENOMAD_INSTANCE_ID: "workspace-123",
OPENCODE_SERVER_PASSWORD: "secret",
},
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID", "OPENCODE_SERVER_PASSWORD"],
},
)
assert.equal(spec.command, "wsl.exe")
assert.deepEqual(spec.args, [
"--distribution",
"Ubuntu",
"--cd",
"/home/dev/workspace",
"--exec",
"/home/dev/.opencode/bin/opencode",
"serve",
"--port",
"0",
])
assert.equal(spec.cwd, undefined)
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID:OPENCODE_SERVER_PASSWORD")
})
it("upgrades existing WSLENV path entries to include /p", () => {
const spec = buildWindowsSpawnSpec(
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
["serve"],
{
env: {
OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
WSLENV: "OPENCODE_CONFIG_DIR:CODENOMAD_INSTANCE_ID/u",
},
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID"],
},
)
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID/u")
})
it("propagates inherited known path variables even when they are not explicitly requested", () => {
const spec = buildWindowsSpawnSpec(
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
["serve"],
{
env: {
NODE_EXTRA_CA_CERTS: String.raw`C:\certs\root.pem`,
},
},
)
assert.equal(spec.env?.WSLENV, "NODE_EXTRA_CA_CERTS/p")
})
it("uses wslpath for Windows workspace folders instead of assuming /mnt", () => {
const spec = buildWindowsSpawnSpec(
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
["serve", "--port", "0"],
{
cwd: String.raw`C:\Users\dev\workspace`,
},
)
assert.equal(spec.command, "wsl.exe")
assert.deepEqual(spec.args, [
"--distribution",
"Ubuntu",
"--exec",
"sh",
"-lc",
'cd "$(wslpath -au "$1")" && shift && exec "$@"',
"codenomad-wsl-launch",
String.raw`C:\Users\dev\workspace`,
"/home/dev/.opencode/bin/opencode",
"serve",
"--port",
"0",
])
})
it("uses wslpath for UNC network workspace folders", () => {
const spec = buildWindowsSpawnSpec(
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
["serve"],
{
cwd: String.raw`\\server\share\workspace`,
},
)
assert.equal(spec.command, "wsl.exe")
assert.deepEqual(spec.args, [
"--distribution",
"Ubuntu",
"--exec",
"sh",
"-lc",
'cd "$(wslpath -au "$1")" && shift && exec "$@"',
"codenomad-wsl-launch",
String.raw`\\server\share\workspace`,
"/home/dev/.opencode/bin/opencode",
"serve",
])
})
it("can wrap WSL launches to emit the Linux PID marker", () => {
const spec = buildWindowsSpawnSpec(
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
["serve"],
{
cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`,
wslPidMarker: "__CODENOMAD_WSL_PID__:",
},
)
assert.equal(spec.command, "wsl.exe")
assert.deepEqual(spec.args, [
"--distribution",
"Ubuntu",
"--exec",
"sh",
"-lc",
`printf '%s%s\\n' '__CODENOMAD_WSL_PID__:' "$$" && cd "$1" && shift && exec "$@"`,
"codenomad-wsl-launch",
"/home/dev/workspace",
"/home/dev/.opencode/bin/opencode",
"serve",
])
assert.equal(spec.wsl?.pidMarker, "__CODENOMAD_WSL_PID__:")
})
it("builds the WSL kill command for tracked Linux PIDs", () => {
const spec = buildWslSignalSpec("Ubuntu", 4321, "SIGTERM")
assert.equal(spec.command, "wsl.exe")
assert.deepEqual(spec.args, ["--distribution", "Ubuntu", "--exec", "kill", "-TERM", "4321"])
})
})

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

@@ -83,6 +83,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 +142,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),
})

View File

@@ -4,100 +4,10 @@ import path from "path"
import { EventBus } from "../events/bus"
import { LogLevel, WorkspaceLogEntry } from "../api-types"
import { Logger } from "../logger"
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
export function buildSpawnSpec(binaryPath: string, args: string[]) {
if (process.platform !== "win32") {
return { command: binaryPath, args, options: {} as const }
}
const extension = path.extname(binaryPath).toLowerCase()
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
const comspec = process.env.ComSpec || "cmd.exe"
// cmd.exe requires the full command as a single string.
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
return {
command: comspec,
args: ["/d", "/s", "/c", commandLine],
options: { windowsVerbatimArguments: true } as const,
}
}
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
// powershell.exe ships with Windows. (pwsh may not.)
return {
command: "powershell.exe",
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
options: {} as const,
}
}
return { command: binaryPath, args, options: {} as const }
}
export function probeBinaryVersion(binaryPath: string): {
valid: boolean
version?: string
reported?: string
error?: string
} {
if (!binaryPath) {
return { valid: false, error: "Missing binary path" }
}
const spec = buildSpawnSpec(binaryPath, ["--version"])
try {
const result = spawnSync(spec.command, spec.args, {
encoding: "utf8",
windowsVerbatimArguments: Boolean(
(spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments,
),
})
if (result.error) {
return { valid: false, error: result.error.message }
}
if (result.status !== 0) {
const stderr = result.stderr?.trim()
const stdout = result.stdout?.trim()
const combined = stderr || stdout
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
return { valid: false, error }
}
const stdoutLines = String(result.stdout ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
const stderrLines = String(result.stderr ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
// Prefer stdout; fall back to stderr (some tools report version there).
const reported = stdoutLines[0] ?? stderrLines[0]
if (!reported) {
return { valid: true }
}
const versionMatch = reported.match(VERSION_REGEX)
const version = versionMatch?.[1]
return { valid: true, version, reported }
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : String(error) }
}
}
import { buildSpawnSpec, buildWslSignalSpec } from "./spawn"
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
const WSL_PID_MARKER = "__CODENOMAD_WSL_PID__:"
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
const redacted: Record<string, string | undefined> = {}
@@ -116,6 +26,7 @@ interface LaunchOptions {
folder: string
binaryPath: string
environment?: Record<string, string>
logLevel?: string
onExit?: (info: ProcessExitInfo) => void
}
@@ -129,6 +40,10 @@ export interface ProcessExitInfo {
interface ManagedProcess {
child: ChildProcess
requestedStop: boolean
wsl?: {
distro: string
linuxPid: number | null
}
}
export class WorkspaceRuntime {
@@ -139,7 +54,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
@@ -165,7 +81,13 @@ export class WorkspaceRuntime {
}
return new Promise((resolve, reject) => {
const spec = buildSpawnSpec(options.binaryPath, args)
const propagatedEnvKeys = Object.keys(options.environment ?? {})
const spec = buildSpawnSpec(options.binaryPath, args, {
cwd: options.folder,
env,
propagateEnvKeys: propagatedEnvKeys,
wslPidMarker: WSL_PID_MARKER,
})
const commandLine = [spec.command, ...spec.args].join(" ")
this.logger.info(
{
@@ -195,14 +117,18 @@ export class WorkspaceRuntime {
)
const detached = process.platform !== "win32"
const child = spawn(spec.command, spec.args, {
cwd: options.folder,
env,
cwd: spec.cwd,
env: spec.env,
stdio: ["ignore", "pipe", "pipe"],
detached,
...spec.options,
})
const managed: ManagedProcess = { child, requestedStop: false }
const managed: ManagedProcess = {
child,
requestedStop: false,
...(spec.wsl ? { wsl: { distro: spec.wsl.distro, linuxPid: null } } : {}),
}
this.processes.set(options.workspaceId, managed)
let stdoutBuffer = ""
@@ -282,6 +208,15 @@ export class WorkspaceRuntime {
const trimmed = line.trim()
if (!trimmed) continue
if (managed.wsl && trimmed.startsWith(WSL_PID_MARKER)) {
const linuxPid = Number.parseInt(trimmed.slice(WSL_PID_MARKER.length), 10)
if (Number.isFinite(linuxPid) && linuxPid > 0) {
managed.wsl.linuxPid = linuxPid
this.logger.debug({ workspaceId: options.workspaceId, linuxPid }, "Captured WSL OpenCode PID")
}
continue
}
recentStdout.push(trimmed)
if (recentStdout.length > MAX_OUTPUT_LINES) {
recentStdout.shift()
@@ -396,11 +331,44 @@ export class WorkspaceRuntime {
}
}
const trySignalWslProcess = (signal: NodeJS.Signals) => {
if (process.platform !== "win32" || !managed.wsl?.linuxPid) {
return false
}
try {
const spec = buildWslSignalSpec(managed.wsl.distro, managed.wsl.linuxPid, signal)
const result = spawnSync(spec.command, spec.args, { encoding: "utf8" })
const exitCode = result.status
if (exitCode === 0) {
return true
}
const stderr = (result.stderr ?? "").toString().toLowerCase()
const stdout = (result.stdout ?? "").toString().toLowerCase()
const combined = `${stdout}\n${stderr}`
if (combined.includes("no such process") || combined.includes("not found")) {
return true
}
this.logger.debug(
{ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, exitCode, stderr: result.stderr, stdout: result.stdout },
"WSL kill failed",
)
return false
} catch (error) {
this.logger.debug({ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, err: error }, "WSL kill failed to execute")
return false
}
}
const sendStopSignal = (signal: NodeJS.Signals) => {
if (process.platform === "win32") {
// Best-effort: terminate the whole process tree rooted at pid.
// Use /F only for escalation.
tryTaskkill(signal === "SIGKILL")
// WSL-backed launches need a Linux signal first because the tracked Windows PID belongs to wsl.exe.
if (!trySignalWslProcess(signal)) {
// Fallback to the Windows process tree rooted at pid. Use /F only for escalation.
tryTaskkill(signal === "SIGKILL")
}
return
}

View File

@@ -0,0 +1,307 @@
import { spawnSync } from "child_process"
import path from "path"
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
const WSL_UNC_PATH_REGEX = /^\\\\wsl(?:\.localhost|\$)\\([^\\/]+)(?:[\\/](.*))?$/i
const WSL_PATH_ENV_KEYS = new Set(["OPENCODE_CONFIG_DIR", "NODE_EXTRA_CA_CERTS"])
export interface SpawnSpec {
command: string
args: string[]
options: {
windowsVerbatimArguments?: boolean
}
cwd?: string
env?: NodeJS.ProcessEnv
wsl?: {
distro: string
pidMarker?: string
}
}
interface BuildSpawnSpecOptions {
cwd?: string
env?: NodeJS.ProcessEnv
propagateEnvKeys?: string[]
wslPidMarker?: string
}
interface WslPath {
distro: string
linuxPath: string
}
export type WslWorkingDirectory =
| { kind: "linux"; path: string }
| { kind: "windows"; path: string }
export function parseWslUncPath(input: string): WslPath | null {
const normalized = input.trim().replace(/\//g, "\\")
const match = normalized.match(WSL_UNC_PATH_REGEX)
if (!match) {
return null
}
const distro = match[1] ?? ""
const remainder = match[2] ?? ""
const segments = remainder.split(/\\+/).filter((segment) => segment.length > 0)
return {
distro,
linuxPath: segments.length > 0 ? `/${segments.join("/")}` : "/",
}
}
export function resolveWslWorkingDirectory(folder: string, distro: string): WslWorkingDirectory | null {
const wslFolder = parseWslUncPath(folder)
if (wslFolder) {
return wslFolder.distro.toLowerCase() === distro.toLowerCase() ? { kind: "linux", path: wslFolder.linuxPath } : null
}
const windowsFolder = normalizeWindowsPath(folder)
return windowsFolder ? { kind: "windows", path: windowsFolder } : null
}
export function buildWindowsSpawnSpec(binaryPath: string, args: string[], options: BuildSpawnSpecOptions = {}): SpawnSpec {
const wslPath = parseWslUncPath(binaryPath)
if (wslPath) {
return buildWslSpawnSpec(wslPath, args, options)
}
const extension = path.extname(binaryPath).toLowerCase()
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
const comspec = process.env.ComSpec || "cmd.exe"
// cmd.exe requires the full command as a single string.
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
return {
command: comspec,
args: ["/d", "/s", "/c", commandLine],
options: { windowsVerbatimArguments: true },
cwd: options.cwd,
env: options.env,
}
}
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
// powershell.exe ships with Windows. (pwsh may not.)
return {
command: "powershell.exe",
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
options: {},
cwd: options.cwd,
env: options.env,
}
}
return {
command: binaryPath,
args,
options: {},
cwd: options.cwd,
env: options.env,
}
}
export function buildSpawnSpec(binaryPath: string, args: string[], options: BuildSpawnSpecOptions = {}): SpawnSpec {
if (process.platform !== "win32") {
return {
command: binaryPath,
args,
options: {},
cwd: options.cwd,
env: options.env,
}
}
return buildWindowsSpawnSpec(binaryPath, args, options)
}
export function buildWslSignalSpec(distro: string, linuxPid: number, signal: NodeJS.Signals): SpawnSpec {
return {
command: "wsl.exe",
args: ["--distribution", distro, "--exec", "kill", signal === "SIGKILL" ? "-KILL" : "-TERM", String(linuxPid)],
options: {},
wsl: { distro },
}
}
export function probeBinaryVersion(binaryPath: string): {
valid: boolean
version?: string
reported?: string
error?: string
} {
if (!binaryPath) {
return { valid: false, error: "Missing binary path" }
}
try {
const spec = buildSpawnSpec(binaryPath, ["--version"])
const result = spawnSync(spec.command, spec.args, {
encoding: "utf8",
cwd: spec.cwd,
env: spec.env,
windowsVerbatimArguments: Boolean(spec.options.windowsVerbatimArguments),
})
if (result.error) {
return { valid: false, error: result.error.message }
}
if (result.status !== 0) {
const stderr = result.stderr?.trim()
const stdout = result.stdout?.trim()
const combined = stderr || stdout
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
return { valid: false, error }
}
const stdoutLines = String(result.stdout ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
const stderrLines = String(result.stderr ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
// Prefer stdout; fall back to stderr (some tools report version there).
const reported = stdoutLines[0] ?? stderrLines[0]
if (!reported) {
return { valid: true }
}
const versionMatch = reported.match(VERSION_REGEX)
const version = versionMatch?.[1]
return { valid: true, version, reported }
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : String(error) }
}
}
function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawnSpecOptions): SpawnSpec {
const workingDirectory = options.cwd ? resolveWslWorkingDirectory(options.cwd, wslPath.distro) : undefined
if (options.cwd && !workingDirectory) {
throw new Error(
`Unable to translate workspace folder for WSL binary in distro "${wslPath.distro}": ${options.cwd}`,
)
}
const wslArgs = ["--distribution", wslPath.distro]
const shouldWrapWithShell = Boolean(options.wslPidMarker) || workingDirectory?.kind === "windows"
if (!shouldWrapWithShell && workingDirectory?.kind === "linux") {
wslArgs.push("--cd", workingDirectory.path)
}
if (shouldWrapWithShell) {
const launchScript = buildWslLaunchScript(workingDirectory ?? undefined, options.wslPidMarker)
wslArgs.push(
"--exec",
"sh",
"-lc",
launchScript,
"codenomad-wsl-launch",
)
if (workingDirectory) {
wslArgs.push(workingDirectory.path)
}
wslArgs.push(
wslPath.linuxPath,
...args,
)
} else {
wslArgs.push("--exec", wslPath.linuxPath, ...args)
}
return {
command: "wsl.exe",
args: wslArgs,
options: {},
env: buildWslEnvironment(options.env, options.propagateEnvKeys),
wsl: { distro: wslPath.distro, pidMarker: options.wslPidMarker },
}
}
function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined, pidMarker: string | undefined): string {
const steps: string[] = []
if (pidMarker) {
steps.push(`printf '%s%s\\n' '${pidMarker}' "$$"`)
}
if (workingDirectory?.kind === "linux") {
steps.push('cd "$1"')
steps.push("shift")
} else if (workingDirectory?.kind === "windows") {
steps.push('cd "$(wslpath -au "$1")"')
steps.push("shift")
}
steps.push('exec "$@"')
return steps.join(" && ")
}
function normalizeWindowsPath(input: string): string | null {
const normalized = path.win32.normalize(input.trim().replace(/\//g, "\\"))
if (!normalized) {
return null
}
if (/^[A-Za-z]:/.test(normalized) || normalized.startsWith("\\\\")) {
return normalized
}
return null
}
function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKeys: string[] | undefined): NodeJS.ProcessEnv | undefined {
if (!env) {
return env
}
const keysToPropagate = Array.from(
new Set([
...(propagateEnvKeys ?? []).filter((key) => env[key] !== undefined),
...Array.from(WSL_PATH_ENV_KEYS).filter((key) => env[key] !== undefined),
]),
)
if (keysToPropagate.length === 0) {
return env
}
const next = { ...env }
const entries = (next.WSLENV ?? "").split(":").filter((entry) => entry.length > 0)
const byName = new Map(entries.map((entry) => [entry.split("/")[0] ?? entry, entry]))
for (const key of keysToPropagate) {
const existingEntry = byName.get(key)
if (existingEntry) {
byName.set(key, ensureWslenvEntry(existingEntry, WSL_PATH_ENV_KEYS.has(key)))
continue
}
byName.set(key, WSL_PATH_ENV_KEYS.has(key) ? `${key}/p` : key)
}
next.WSLENV = Array.from(byName.values()).join(":")
return next
}
function ensureWslenvEntry(entry: string, requiresPathTranslation: boolean): string {
if (!requiresPathTranslation) {
return entry
}
const [name, rawFlags = ""] = entry.split("/")
if (rawFlags.includes("p")) {
return entry
}
return rawFlags.length > 0 ? `${name}/${rawFlags}p` : `${name}/p`
}

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
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.12.2",
"version": "0.14.0",
"private": true,
"license": "MIT",
"scripts": {
@@ -8,12 +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-win32-x64-msvc": "^2.9.4"
"@tauri-apps/cli": "^2.9.4"
}
}

View File

@@ -20,6 +20,7 @@ const serverDevInstallCommand =
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
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 envWithRootBin = {
...process.env,
@@ -36,6 +37,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() {
@@ -55,11 +62,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",
@@ -91,8 +94,17 @@ function ensureUiBuild() {
}
}
function syncServerUiBundle() {
console.log("[prebuild] syncing server public UI bundle...")
execSync(serverPrepareUiCommand, {
cwd: workspaceRoot,
stdio: "inherit",
env: envWithRootBin,
})
}
function ensureServerDevDependencies() {
if (fs.existsSync(braceExpansionPath)) {
if (serverBuildDependencyPaths.every((filePath) => fs.existsSync(filePath))) {
return
}
@@ -136,6 +148,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",
}
@@ -246,6 +259,7 @@ function copyUiLoadingAssets() {
ensureServerDependencies()
ensureServerBuild()
ensureUiBuild()
syncServerUiBundle()
copyServerArtifacts()
stripNodeModuleBins()
copyUiLoadingAssets()

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,6 +1,6 @@
[package]
name = "codenomad-tauri"
version = "0.1.0"
version = "0.14.0"
edition = "2021"
license = "MIT"
@@ -12,16 +12,24 @@ tauri = { version = "2.5.2", 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"
keepawake = "0.6"
tauri-plugin-dialog = "2"
dirs = "5"
tauri-plugin-opener = "2"
tauri-plugin-global-shortcut = "2"
url = "2"
tauri-plugin-keepawake = "0.1.1"
tauri-plugin-notification = "2"
[target.'cfg(windows)'.dependencies]
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

@@ -11,6 +11,7 @@
"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",

File diff suppressed because one or more lines are too long

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","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

@@ -2379,34 +2379,70 @@
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
"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": "keepawake:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
"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 start command without any pre-configured scope.",
"description": "Enables the is_registered command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-start",
"markdownDescription": "Enables the start command without any pre-configured scope."
"const": "global-shortcut:allow-is-registered",
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
},
{
"description": "Enables the stop command without any pre-configured scope.",
"description": "Enables the register command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-stop",
"markdownDescription": "Enables the stop command without any pre-configured scope."
"const": "global-shortcut:allow-register",
"markdownDescription": "Enables the register command without any pre-configured scope."
},
{
"description": "Denies the start command without any pre-configured scope.",
"description": "Enables the register_all command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-start",
"markdownDescription": "Denies the start command without any pre-configured scope."
"const": "global-shortcut:allow-register-all",
"markdownDescription": "Enables the register_all command without any pre-configured scope."
},
{
"description": "Denies the stop command without any pre-configured scope.",
"description": "Enables the unregister command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-stop",
"markdownDescription": "Denies the stop command without any pre-configured scope."
"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`",

View File

@@ -2379,34 +2379,70 @@
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
"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": "keepawake:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
"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 start command without any pre-configured scope.",
"description": "Enables the is_registered command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-start",
"markdownDescription": "Enables the start command without any pre-configured scope."
"const": "global-shortcut:allow-is-registered",
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
},
{
"description": "Enables the stop command without any pre-configured scope.",
"description": "Enables the register command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-stop",
"markdownDescription": "Enables the stop command without any pre-configured scope."
"const": "global-shortcut:allow-register",
"markdownDescription": "Enables the register command without any pre-configured scope."
},
{
"description": "Denies the start command without any pre-configured scope.",
"description": "Enables the register_all command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-start",
"markdownDescription": "Denies the start command without any pre-configured scope."
"const": "global-shortcut:allow-register-all",
"markdownDescription": "Enables the register_all command without any pre-configured scope."
},
{
"description": "Denies the stop command without any pre-configured scope.",
"description": "Enables the unregister command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-stop",
"markdownDescription": "Denies the stop command without any pre-configured scope."
"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`",

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,22 +5,126 @@ 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;
use std::path::PathBuf;
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}");
}
#[cfg(windows)]
fn configure_spawn(command: &mut Command) {
command.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
fn configure_spawn(_command: &mut Command) {}
fn workspace_root() -> Option<PathBuf> {
std::env::current_dir().ok().and_then(|mut dir| {
for _ in 0..3 {
@@ -32,10 +136,52 @@ fn workspace_root() -> Option<PathBuf> {
})
}
const SESSION_COOKIE_NAME: &str = "codenomad_session";
const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session";
const CLI_STOP_GRACE_SECS: u64 = 30;
#[cfg(windows)]
const CLI_WINDOWS_FORCE_GRACE_MS: u64 = 2_000;
#[cfg(unix)]
fn configure_posix_process_group(command: &mut Command) {
// Ensure the CLI runs in its own process group so we can terminate wrapper
// processes (login shell/tsx) without leaving the server orphaned.
unsafe {
command.pre_exec(|| {
if libc::setpgid(0, 0) != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
}
#[cfg(windows)]
fn kill_process_tree_windows(pid: u32, force: bool) -> bool {
let mut args = vec!["/PID".to_string(), pid.to_string(), "/T".to_string()];
if force {
args.push("/F".to_string());
}
let mut command = Command::new("taskkill");
command.args(&args);
configure_spawn(&mut command);
match command.output() {
Ok(output) => {
if output.status.success() {
return true;
}
// If the PID is already gone, treat it as success.
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
let combined = format!("{stdout}\n{stderr}");
combined.contains("not found") || combined.contains("no running instance")
}
Err(_) => false,
}
}
fn navigate_main(app: &AppHandle, url: &str) {
if let Some(win) = app.webview_windows().get("main") {
let mut display = url.to_string();
@@ -66,7 +212,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);
@@ -101,11 +251,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));
}
}
@@ -114,11 +264,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)
@@ -132,6 +287,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)]
@@ -286,6 +451,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>>>,
}
@@ -295,6 +462,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)),
}
@@ -317,6 +486,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 || {
@@ -324,6 +495,8 @@ impl CliProcessManager {
app.clone(),
status_arc.clone(),
child_arc,
#[cfg(windows)]
job_arc,
ready_flag,
token_arc,
dev,
@@ -343,16 +516,25 @@ 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(unix)]
unsafe {
libc::kill(child.id() as i32, libc::SIGTERM);
let pid = child.id() as i32;
// Prefer signaling the process group to avoid orphaning children
// when the CLI was launched via a wrapper shell.
let group_res = libc::kill(-pid, libc::SIGTERM);
if group_res != 0 {
let _ = libc::kill(pid, libc::SIGTERM);
}
}
#[cfg(windows)]
{
let _ = child.kill();
let _ = kill_process_tree_windows(child.id(), false);
}
let start = Instant::now();
@@ -360,6 +542,19 @@ impl CliProcessManager {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
#[cfg(windows)]
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()
));
if !kill_process_tree_windows(child.id(), true) {
let _ = child.kill();
}
break;
}
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
log_line(&format!(
"stop timed out after {}s; sending SIGKILL pid={}",
@@ -368,11 +563,17 @@ impl CliProcessManager {
));
#[cfg(unix)]
unsafe {
libc::kill(child.id() as i32, libc::SIGKILL);
let pid = child.id() as i32;
let group_res = libc::kill(-pid, libc::SIGKILL);
if group_res != 0 {
let _ = libc::kill(pid, libc::SIGKILL);
}
}
#[cfg(windows)]
{
let _ = child.kill();
if !kill_process_tree_windows(child.id(), true) {
let _ = child.kill();
}
}
break;
}
@@ -381,6 +582,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();
@@ -401,6 +605,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,
@@ -412,7 +617,8 @@ 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");
@@ -423,7 +629,16 @@ impl CliProcessManager {
log_line(&format!("using cwd={}", c.display()));
}
let command_info = if supports_user_shell() {
let use_user_shell = supports_user_shell();
if !use_user_shell && which::which(&resolution.node_binary).is_err() {
return Err(anyhow::anyhow!(
"Node binary '{}' not found. CodeNomad desktop currently 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 {
@@ -434,25 +649,22 @@ impl CliProcessManager {
})
};
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());
configure_spawn(&mut c);
if let Some(ref cwd) = cwd {
c.current_dir(cwd);
}
#[cfg(unix)]
configure_posix_process_group(&mut c);
c.spawn()?
}
ShellCommandType::Direct(cmd) => {
@@ -462,15 +674,34 @@ impl CliProcessManager {
.env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
configure_spawn(&mut c);
if let Some(ref cwd) = cwd {
c.current_dir(cwd);
}
#[cfg(unix)]
configure_posix_process_group(&mut c);
c.spawn()?
}
};
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);
@@ -487,6 +718,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
@@ -501,24 +733,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(),
);
});
}
});
@@ -526,6 +775,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);
@@ -537,7 +788,24 @@ impl CliProcessManager {
locked.error = Some("CLI did not start in time".to_string());
log_line("timeout waiting for CLI readiness");
if let Some(child) = child_holder_clone.lock().as_mut() {
let _ = child.kill();
#[cfg(unix)]
unsafe {
let pid = child.id() as i32;
let group_res = libc::kill(-pid, libc::SIGKILL);
if group_res != 0 {
let _ = libc::kill(pid, libc::SIGKILL);
}
}
#[cfg(windows)]
{
if !kill_process_tree_windows(child.id(), true) {
let _ = child.kill();
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = child.kill();
}
}
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
Self::emit_status(&app_clone, &locked);
@@ -563,6 +831,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,
@@ -617,10 +889,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 {
@@ -647,44 +920,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 desktop currently 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,
@@ -697,6 +958,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);
@@ -720,9 +982,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 {
@@ -818,24 +1082,45 @@ impl CliEntry {
))
}
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())
.or_else(|| {
std::env::var("ELECTRON_RENDERER_URL")
.ok()
.filter(|value| !value.trim().is_empty())
})
.unwrap_or_else(|| "http://localhost:3000".to_string());
let log_level = std::env::var("CLI_LOG_LEVEL")
.ok()
.map(|value| value.trim().to_lowercase())
.filter(|value| !value.is_empty())
.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());
args.push("0".to_string());
args.push("--ui-dev-server".to_string());
args.push("http://localhost:3000".to_string());
args.push(ui_dev_server);
args.push("--log-level".to_string());
args.push("debug".to_string());
args.push(log_level);
} else {
// Prod desktop: always keep loopback HTTP enabled.
args.push("--https".to_string());
@@ -862,27 +1147,58 @@ 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)
@@ -944,7 +1260,13 @@ fn build_shell_command_string(
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 command = 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 args = build_shell_args(&shell, &command);
log_line(&format!("user shell command: {} {:?}", shell, args));
Ok(ShellCommand { shell, args })
@@ -984,8 +1306,8 @@ fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
.unwrap_or("")
.to_lowercase();
if shell_name.contains("zsh") {
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
if shell_name.contains("zsh") || shell_name.contains("bash") {
vec!["-i".into(), "-l".into(), "-c".into(), command.into()]
} else {
vec!["-l".into(), "-c".into(), command.into()]
}

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,22 +1,126 @@
#![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, Wry};
use tauri::{
AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
};
use tauri_plugin_global_shortcut::{
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
};
use tauri_plugin_opener::OpenerExt;
use url::Url;
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
#[cfg(windows)]
use std::ffi::OsStr;
#[cfg(windows)]
use std::iter;
#[cfg(windows)]
use std::os::windows::ffi::OsStrExt;
#[cfg(windows)]
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.1;
const MIN_ZOOM_LEVEL: f64 = 0.2;
const MAX_ZOOM_LEVEL: f64 = 5.0;
const LOCAL_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'local';";
const REMOTE_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'remote';";
#[cfg(windows)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
#[derive(Clone)]
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)]
#[serde(default, rename_all = "camelCase")]
struct WakeLockConfig {
display: bool,
idle: bool,
sleep: bool,
}
#[tauri::command]
@@ -35,6 +139,38 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
Ok(state.manager.status())
}
#[tauri::command]
fn wake_lock_start(
state: tauri::State<AppState>,
config: Option<WakeLockConfig>,
) -> Result<(), String> {
let config = config.unwrap_or(WakeLockConfig {
display: true,
idle: false,
sleep: false,
});
let mut builder = keepawake::Builder::default();
builder
.display(config.display)
.idle(config.idle)
.sleep(config.sleep)
.reason("CodeNomad active session")
.app_name("CodeNomad")
.app_reverse_domain("ai.neuralnomads.codenomad.client");
let wake_lock = builder.create().map_err(|err| err.to_string())?;
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
*state_lock = Some(wake_lock);
Ok(())
}
#[tauri::command]
fn wake_lock_stop(state: tauri::State<AppState>) -> Result<(), String> {
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
state_lock.take();
Ok(())
}
fn is_dev_mode() -> bool {
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
@@ -42,20 +178,44 @@ 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.
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "tauri.localhost")),
"http" | "https" => matches!(
url.host_str(),
Some("127.0.0.1" | "localhost" | "tauri.localhost")
),
_ => false,
}
}
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()
@@ -66,7 +226,291 @@ 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()))
.initialization_script(REMOTE_WINDOW_CONTEXT_SCRIPT)
.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()
.filter_map(|path| match std::fs::metadata(path) {
Ok(metadata) if metadata.is_dir() => Some(path.to_string_lossy().to_string()),
_ => None,
})
.collect()
}
fn emit_window_event(app_handle: &AppHandle, window_label: &str, event_name: &str) {
if let Some(window) = app_handle.get_webview_window(window_label) {
let _ = window.emit(event_name, ());
}
}
fn emit_folder_drop_event(
app_handle: &AppHandle,
window_label: &str,
event_name: &str,
paths: &[std::path::PathBuf],
) {
let directories = collect_directory_paths(paths);
if directories.is_empty() {
return;
}
if let Some(window) = app_handle.get_webview_window(window_label) {
let _ = window.emit(event_name, json!({ "paths": directories }));
}
}
fn clamp_zoom_level(value: f64) -> f64 {
value.clamp(MIN_ZOOM_LEVEL, MAX_ZOOM_LEVEL)
}
fn set_main_window_zoom(app_handle: &AppHandle, next_zoom: f64) {
if let Some(window) = app_handle.get_webview_window("main") {
let normalized = clamp_zoom_level(next_zoom);
if window.set_zoom(normalized).is_ok() {
if let Ok(mut zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
*zoom_level = normalized;
}
}
}
}
fn reload_main_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.reload();
}
}
fn force_reload_main_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
if let Ok(mut url) = window.url() {
if should_allow_internal(&url) {
let reload_token = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.to_string();
let existing_pairs: Vec<(String, String)> = url
.query_pairs()
.into_owned()
.filter(|(key, _)| key != "__codenomad_force_reload")
.collect();
{
let mut pairs = url.query_pairs_mut();
pairs.clear();
for (key, value) in existing_pairs {
pairs.append_pair(&key, &value);
}
pairs.append_pair("__codenomad_force_reload", &reload_token);
}
let _ = window.navigate(url);
return;
}
}
let _ = window.reload();
}
}
fn toggle_fullscreen_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
let next_fullscreen = !window.is_fullscreen().unwrap_or(false);
let _ = window.set_fullscreen(next_fullscreen);
if cfg!(not(target_os = "macos")) {
if next_fullscreen {
let _ = window.hide_menu();
} else {
let _ = window.show_menu();
}
}
}
}
fn fullscreen_shortcut() -> Option<Shortcut> {
if cfg!(target_os = "macos") {
None
} else {
Some(Shortcut::new(None, ShortcutCode::F11))
}
}
#[cfg(windows)]
fn set_windows_app_user_model_id() {
let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID)
.encode_wide()
.chain(iter::once(0))
.collect();
let result = unsafe { SetCurrentProcessExplicitAppUserModelID(app_id.as_ptr()) };
if result < 0 {
eprintln!("[tauri] failed to set AppUserModelID: {result}");
}
}
#[cfg(not(windows))]
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();
@@ -74,14 +518,55 @@ fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_keepawake::init())
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, shortcut, event| {
if event.state() != ShortcutState::Pressed {
return;
}
if fullscreen_shortcut().as_ref() == Some(shortcut) {
toggle_fullscreen_window(app);
}
})
.build(),
)
.plugin(tauri_plugin_notification::init())
.plugin(navigation_guard)
.manage(AppState {
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();
build_menu(&app.handle())?;
if let Some(window) = app.get_webview_window("main") {
let _ = window.eval(LOCAL_WINDOW_CONTEXT_SCRIPT);
}
if let Some(shortcut) = fullscreen_shortcut() {
let shortcut_manager = app.handle().global_shortcut();
let _ = shortcut_manager.register(shortcut.clone());
if let Some(window) = app.get_webview_window("main") {
let app_handle = app.handle().clone();
window.on_window_event(move |event| {
if let WindowEvent::Focused(focused) = event {
let shortcut_manager = app_handle.global_shortcut();
if *focused {
let _ = shortcut_manager.register(shortcut.clone());
} else {
let _ = shortcut_manager.unregister(shortcut.clone());
}
}
});
}
}
let dev_mode = is_dev_mode();
let app_handle = app.handle().clone();
let manager = app.state::<AppState>().manager.clone();
@@ -92,7 +577,14 @@ fn main() {
});
Ok(())
})
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
.invoke_handler(tauri::generate_handler![
cli_get_status,
cli_restart,
wake_lock_start,
wake_lock_stop,
needs_local_certificate_install,
open_remote_window
])
.on_menu_event(|app_handle, event| {
match event.id().0.as_str() {
// File menu
@@ -101,36 +593,42 @@ fn main() {
let _ = window.emit("menu:newInstance", ());
}
}
"close" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.close();
}
}
"quit" => {
app_handle.exit(0);
}
// View menu
"reload" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.eval("window.location.reload()");
}
reload_main_window(app_handle);
}
"force_reload" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.eval("window.location.reload(true)");
}
force_reload_main_window(app_handle);
}
"toggle_devtools" => {
if let Some(window) = app_handle.get_webview_window("main") {
window.open_devtools();
if window.is_devtools_open() {
window.close_devtools();
} else {
window.open_devtools();
}
}
}
"reset_zoom" => {
set_main_window_zoom(app_handle, DEFAULT_ZOOM_LEVEL);
}
"zoom_in" => {
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
set_main_window_zoom(app_handle, *zoom_level + ZOOM_STEP);
}
}
"zoom_out" => {
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
set_main_window_zoom(app_handle, *zoom_level - ZOOM_STEP);
}
}
"toggle_fullscreen" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.set_fullscreen(!window.is_fullscreen().unwrap_or(false));
}
toggle_fullscreen_window(app_handle);
}
// Window menu
@@ -144,6 +642,11 @@ fn main() {
let _ = window.maximize();
}
}
"close_window" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.close();
}
}
// App menu (macOS)
"about" => {
@@ -187,15 +690,49 @@ fn main() {
app.exit(0);
});
}
tauri::RunEvent::WindowEvent {
label,
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Enter { paths, .. }),
..
} => {
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drag-enter", &paths);
}
tauri::RunEvent::WindowEvent {
label,
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }),
..
} => {
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drop", &paths);
}
tauri::RunEvent::WindowEvent {
label,
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Leave),
..
} => {
emit_window_event(&app_handle, &label, "desktop:folder-drag-leave");
}
tauri::RunEvent::WindowEvent {
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>() {
@@ -210,6 +747,7 @@ fn main() {
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
let is_mac = cfg!(target_os = "macos");
let is_linux = cfg!(target_os = "linux");
// Create submenus
let mut submenus = Vec::new();
@@ -234,16 +772,77 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
"new_instance",
"New Instance",
true,
Some("CmdOrCtrl+N")
Some("CmdOrCtrl+N"),
)?;
let file_menu = SubmenuBuilder::new(app, "File")
.item(&new_instance_item)
.separator()
.text(if is_mac { "close" } else { "quit" }, if is_mac { "Close" } else { "Quit" })
.build()?;
let file_menu = if is_mac {
SubmenuBuilder::new(app, "File")
.item(&new_instance_item)
.separator()
.close_window()
.build()?
} else {
SubmenuBuilder::new(app, "File")
.item(&new_instance_item)
.separator()
.text("quit", "Quit")
.build()?
};
submenus.push(file_menu);
let reload_item = MenuItem::with_id(app, "reload", "Reload", true, Some("CmdOrCtrl+R"))?;
let force_reload_item = MenuItem::with_id(
app,
"force_reload",
"Force Reload",
true,
Some("CmdOrCtrl+Shift+R"),
)?;
let toggle_devtools_item = MenuItem::with_id(
app,
"toggle_devtools",
"Toggle Developer Tools",
true,
Some("Alt+CmdOrCtrl+I"),
)?;
let reset_zoom_item =
MenuItem::with_id(app, "reset_zoom", "Actual Size", true, Some("CmdOrCtrl+0"))?;
let zoom_in_item = MenuItem::with_id(
app,
"zoom_in",
if is_mac { "Zoom In" } else { "Zoom In\tCtrl++" },
true,
None::<&str>,
)?;
let zoom_out_item = MenuItem::with_id(
app,
"zoom_out",
if is_mac {
"Zoom Out"
} else {
"Zoom Out\tCtrl+-"
},
true,
None::<&str>,
)?;
let toggle_fullscreen_item = MenuItem::with_id(
app,
"toggle_fullscreen",
if is_mac {
"Toggle Full Screen"
} else {
"Toggle Full Screen\tF11"
},
true,
if is_mac {
Some("Ctrl+Cmd+F")
} else {
None::<&str>
},
)?;
let close_window_item =
MenuItem::with_id(app, "close_window", "Close", true, Some("CmdOrCtrl+W"))?;
// Edit menu with predefined items for standard functionality
let edit_menu = SubmenuBuilder::new(app, "Edit")
.undo()
@@ -259,27 +858,48 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
// View menu
let view_menu = SubmenuBuilder::new(app, "View")
.text("reload", "Reload")
.text("force_reload", "Force Reload")
.text("toggle_devtools", "Toggle Developer Tools")
.item(&reload_item)
.item(&force_reload_item)
.item(&toggle_devtools_item)
.separator()
.item(&reset_zoom_item)
.item(&zoom_in_item)
.item(&zoom_out_item)
.separator()
.text("toggle_fullscreen", "Toggle Full Screen")
.item(&toggle_fullscreen_item)
.build()?;
submenus.push(view_menu);
// Window menu
let window_menu = SubmenuBuilder::new(app, "Window")
.text("minimize", "Minimize")
.text("zoom", "Zoom")
.build()?;
let window_menu = if is_linux {
SubmenuBuilder::new(app, "Window")
.text("minimize", "Minimize")
.text("zoom", "Zoom")
.separator()
.item(&close_window_item)
.build()?
} else if is_mac {
SubmenuBuilder::new(app, "Window")
.minimize()
.maximize()
.build()?
} else {
SubmenuBuilder::new(app, "Window")
.minimize()
.maximize()
.separator()
.close_window()
.build()?
};
submenus.push(window_menu);
// Build the main menu with all submenus
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus.iter().map(|s| s as &dyn tauri::menu::IsMenuItem<_>).collect();
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus
.iter()
.map(|s| s as &dyn tauri::menu::IsMenuItem<_>)
.collect();
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
app.set_menu(menu)?;
Ok(())
}

View File

@@ -1,17 +1,15 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CodeNomad",
"version": "0.1.0",
"identifier": "ai.opencode.client",
"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,61 @@
],
"security": {
"assetProtocol": {
"scope": ["**"]
"scope": [
"**"
]
},
"capabilities": ["main-window-native-dialogs"]
"capabilities": [
"main-window-native-dialogs"
]
}
},
"bundle": {
"active": true,
"linux": {
"appimage": {
"files": {
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop"
}
},
"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.2",
"version": "0.14.0",
"private": true,
"license": "MIT",
"type": "module",
@@ -18,8 +18,10 @@
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",
"@suid/system": "^0.14.0",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2.5.3",
"ansi-sequence-parser": "^1.1.3",
"debug": "^4.4.3",
"github-markdown-css": "^5.8.1",
@@ -30,7 +32,7 @@
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0",
"tauri-plugin-keepawake-api": "^0.1.0",
"virtua": "^0.48.8",
"yaml": "^2.4.2"
},
"devDependencies": {

View File

@@ -9,23 +9,23 @@ import { showConfirmDialog } from "./stores/alerts"
import InstanceTabs from "./components/instance-tabs"
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
import InstanceShell from "./components/instance/instance-shell2"
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
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 { initMarkdown } from "./lib/markdown"
import { showAlertDialog } from "./stores/alerts"
import { initGithubStars } from "./stores/github-stars"
import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands"
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
import { getLogger } from "./lib/logger"
import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors"
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors"
import { initReleaseNotifications } from "./stores/releases"
import { runtimeEnv } from "./lib/runtime-env"
import { isTauriHost, isWebHost, runtimeEnv } from "./lib/runtime-env"
import { useI18n } from "./lib/i18n"
import { setWakeLockDesired } from "./lib/native/wake-lock"
import {
hasInstances,
isSelectingFolder,
setIsSelectingFolder,
showFolderSelection,
@@ -35,10 +35,7 @@ import { useConfig } from "./stores/preferences"
import {
createInstance,
instances,
activeInstanceId,
setActiveInstanceId,
stopInstance,
getActiveInstance,
disconnectedInstance,
acknowledgeDisconnectedInstance,
} from "./stores/instances"
@@ -54,11 +51,27 @@ import {
} from "./stores/sessions"
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")
const App: Component = () => {
const { isDark } = useTheme()
const { t } = useI18n()
const {
preferences,
@@ -70,6 +83,7 @@ const App: Component = () => {
toggleAutoCleanupBlankSessions,
toggleUsageMetrics,
togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
@@ -77,9 +91,8 @@ const App: Component = () => {
setToolInputsVisibility,
} = useConfig()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
const [sidecarPickerOpen, setSidecarPickerOpen] = createSignal(false)
const phoneQuery = useMediaQuery("(max-width: 767px)")
const isPhoneLayout = createMemo(() => phoneQuery())
@@ -124,7 +137,7 @@ const App: Component = () => {
createEffect(() => {
if (typeof document === "undefined") return
const shouldShow =
runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
!isWebHost() && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
})
@@ -184,10 +197,6 @@ const App: Component = () => {
}
})
createEffect(() => {
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
})
createEffect(() => {
initReleaseNotifications()
})
@@ -213,8 +222,7 @@ const App: Component = () => {
})
createEffect(() => {
instances()
hasInstances()
appTabs()
requestAnimationFrame(() => updateInstanceTabBarHeight())
})
@@ -226,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
@@ -251,8 +267,8 @@ const App: Component = () => {
recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary)
selectInstanceTab(instanceId)
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
log.info("Created instance", {
instanceId,
@@ -274,12 +290,31 @@ const App: Component = () => {
function handleLaunchErrorAdvanced() {
clearLaunchError()
setIsAdvancedSettingsOpen(true)
openSettings("opencode")
}
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)
}
}
@@ -340,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)
@@ -362,12 +414,14 @@ const App: Component = () => {
toggleShowTimelineTools,
toggleUsageMetrics,
togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
setToolInputsVisibility,
handleNewInstanceRequest,
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
handleCloseInstance,
handleNewSession,
handleCloseSession,
@@ -378,6 +432,7 @@ const App: Component = () => {
useAppLifecycle({
setEscapeInDebounce,
handleNewInstanceRequest,
handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""),
handleCloseInstance,
handleNewSession,
handleCloseSession,
@@ -389,7 +444,7 @@ const App: Component = () => {
// Listen for Tauri menu events
onMount(() => {
if (runtimeEnv.host === "tauri") {
if (isTauriHost()) {
const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__
if (tauriBridge?.event) {
let unlistenMenu: (() => void) | null = null
@@ -477,53 +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}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/>
</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>
@@ -533,10 +595,7 @@ const App: Component = () => {
<FolderSelectionView
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
onOpenSidecar={handleOpenSidecarPicker}
/>
</Show>
@@ -546,12 +605,9 @@ const App: Component = () => {
<FolderSelectionView
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
onOpenSidecar={handleOpenSidecarPicker}
onClose={() => {
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
clearLaunchError()
}}
/>
@@ -559,7 +615,8 @@ const App: Component = () => {
</div>
</Show>
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
<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,7 +1,8 @@
import { createSignal, onMount, Show, createEffect } from "solid-js"
import type { Highlighter } from "shiki/bundle/full"
import { useTheme } from "../lib/theme"
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
import { getSharedHighlighter } from "../lib/markdown"
import { escapeHtml } from "../lib/text-render-utils"
import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n"

View File

@@ -1,9 +1,10 @@
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"
import type { DiffHighlighterLang } from "@git-diff-view/core"
import { ErrorBoundary } from "solid-js"
import { getLanguageFromPath } from "../lib/markdown"
import { getLanguageFromPath } from "../lib/text-render-utils"
import { normalizeDiffText } from "../lib/diff-utils"
import { setCacheEntry } from "../lib/global-cache"
import type { CacheEntryParams } from "../lib/global-cache"
@@ -19,6 +20,7 @@ interface ToolCallDiffViewerProps {
filePath?: string
theme: "light" | "dark"
mode: DiffViewMode
wrap?: boolean
onRendered?: () => void
cachedHtml?: string
cacheEntryParams?: CacheEntryParams
@@ -30,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) {
@@ -66,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?.()
@@ -82,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
@@ -94,6 +272,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
html: markup,
theme: props.theme,
mode: props.mode,
wrap: props.wrap,
})
}
props.onRendered?.()
@@ -121,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>
@@ -130,8 +309,8 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
</div>
}
>
<div innerHTML={props.cachedHtml} />
<div ref={diffContainerRef} innerHTML={props.cachedHtml} />
</Show>
</div>
)
}
}

View File

@@ -58,6 +58,16 @@ function resolveAbsolutePath(root: string, relativePath: string) {
return `${trimmedRoot}${normalized}`
}
function getAbsolutePathFromMetadata(metadata: FileSystemListingMetadata | null) {
if (!metadata || metadata.pathKind === "drives") {
return ""
}
if (metadata.pathKind === "relative") {
return resolveAbsolutePath(metadata.rootPath, metadata.currentPath)
}
return metadata.displayPath
}
type FolderRow =
| { type: "up"; path: string }
| { type: "folder"; entry: FileSystemEntry }
@@ -67,6 +77,8 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
const [rootPath, setRootPath] = createSignal("")
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const [pathInput, setPathInput] = createSignal("")
const [pathInputDirty, setPathInputDirty] = createSignal(false)
const [creatingFolder, setCreatingFolder] = createSignal(false)
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
@@ -75,12 +87,16 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
const metadataCache = new Map<string, FileSystemListingMetadata>()
const inFlightRequests = new Map<string, Promise<FileSystemListingMetadata>>()
let latestNavigationId = 0
function resetState() {
setRootPath("")
setDirectoryChildren(new Map<string, FileSystemEntry[]>())
setLoadingPaths(new Set<string>())
setCurrentPathKey(null)
setCurrentMetadata(null)
setPathInput("")
setPathInputDirty(false)
metadataCache.clear()
inFlightRequests.clear()
setError(null)
@@ -109,11 +125,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
async function initialize() {
setLoading(true)
try {
const metadata = await loadDirectory()
applyMetadata(metadata)
} catch (err) {
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
setError(message)
await navigateTo()
} finally {
setLoading(false)
}
@@ -197,13 +209,22 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
}
async function navigateTo(path?: string) {
const navigationId = ++latestNavigationId
setError(null)
try {
const metadata = await loadDirectory(path)
if (navigationId !== latestNavigationId) {
return null
}
applyMetadata(metadata)
return metadata
} catch (err) {
if (navigationId !== latestNavigationId) {
return null
}
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
setError(message)
return null
}
}
@@ -225,31 +246,58 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
})
function handleNavigateTo(path: string) {
setPathInputDirty(false)
void navigateTo(path)
}
function handleNavigateUp() {
const parent = currentMetadata()?.parentPath
if (parent) {
setPathInputDirty(false)
void navigateTo(parent)
}
}
const currentAbsolutePath = createMemo(() => {
const metadata = currentMetadata()
if (!metadata) {
return ""
return getAbsolutePathFromMetadata(currentMetadata())
})
createEffect(() => {
const absolutePath = currentAbsolutePath()
if (!pathInputDirty()) {
setPathInput(absolutePath)
}
if (metadata.pathKind === "drives") {
return ""
}
if (metadata.pathKind === "relative") {
return resolveAbsolutePath(metadata.rootPath, metadata.currentPath)
}
return metadata.displayPath
})
const canSelectCurrent = createMemo(() => Boolean(currentAbsolutePath()))
const canSubmitPath = createMemo(() => pathInput().trim().length > 0)
async function handlePathSubmit() {
const target = pathInput().trim()
if (!target) {
return
}
const metadata = await navigateTo(target)
if (!metadata) {
return
}
setPathInputDirty(false)
setPathInput(getAbsolutePathFromMetadata(metadata))
}
async function handleSelectCurrent() {
const target = pathInput().trim()
const metadata = target && target !== currentAbsolutePath() ? await navigateTo(target) : currentMetadata()
if (!metadata) {
return
}
setPathInputDirty(false)
const absolute = getAbsolutePathFromMetadata(metadata)
if (absolute) {
setPathInput(absolute)
props.onSelect(absolute)
}
}
function handleEntrySelect(entry: FileSystemEntry) {
const absolutePath = entry.absolutePath
@@ -262,10 +310,13 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
async function handleCreateFolder() {
if (creatingFolder()) return
const metadata = currentMetadata()
const target = pathInput().trim()
const metadata = target && target !== currentAbsolutePath() ? await navigateTo(target) : currentMetadata()
if (!metadata || metadata.pathKind === "drives") {
return
}
setPathInputDirty(false)
setPathInput(getAbsolutePathFromMetadata(metadata))
const name =
(await showPromptDialog(t("directoryBrowser.createFolder.promptMessage"), {
@@ -338,19 +389,29 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
<div class="directory-browser-current">
<div class="directory-browser-current-meta">
<span class="directory-browser-current-label">{t("directoryBrowser.currentFolder")}</span>
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
<input
type="text"
value={pathInput()}
onInput={(event) => {
setPathInput(event.currentTarget.value)
setPathInputDirty(true)
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
void handlePathSubmit()
}
}}
spellcheck={false}
class="selector-input directory-browser-current-path"
/>
</div>
<div class="directory-browser-current-actions">
<button
type="button"
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
disabled={!canSelectCurrent() || creatingFolder()}
onClick={() => {
const absolute = currentAbsolutePath()
if (absolute) {
props.onSelect(absolute)
}
}}
disabled={(!canSelectCurrent() && !canSubmitPath()) || creatingFolder()}
onClick={() => void handleSelectCurrent()}
>
{t("directoryBrowser.selectCurrent")}
</button>

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