Compare commits

..

381 Commits

Author SHA1 Message Date
Shantur Rathore
0c9284e57e Bump version to 0.9.0 2026-01-24 16:17:14 +00:00
Shantur Rathore
0766185ff6 fix(server): stop workspace process groups 2026-01-24 14:41:09 +00:00
Shantur Rathore
effb30d98e feat(ui): polish task steps section
Rename Tasks to Steps and remove list padding for a flush, compact steps view.
2026-01-24 10:35:15 +00:00
Shantur Rathore
4da69b5a20 feat(ui): show task model in headers
Task prompt/output headers now include provider/model metadata when available, alongside agent.
2026-01-24 10:29:02 +00:00
Shantur Rathore
3d3337c7b8 feat(ui): render task prompt/output panes
Task tool calls now show prompt, summary, and output with independent scroll; markdown rendering supports cache keys to avoid collisions.
2026-01-23 22:39:04 +00:00
Shantur Rathore
f0b43dbc68 feat(filesystem): add create-folder API for workspace picker
Adds a secure endpoint for creating a single subfolder in the current filesystem listing, and wires the non-native directory browser UI to create + enter the new folder.
2026-01-23 12:33:15 +00:00
Shantur Rathore
b0eb9aec64 Min server to 0.8.1 2026-01-22 23:05:49 +00:00
Shantur Rathore
8c48455ae5 fix(server): prefer highest available UI version
Selects the newest UI across bundled/current/previous with a tie-break for current, and only downloads remote UI when it is strictly newer. This prevents stale cached UIs from overriding a newer bundled release.
2026-01-22 23:04:53 +00:00
Shantur Rathore
292f695395 Bump version to 0.8.1 2026-01-22 22:32:52 +00:00
Shantur Rathore
4ea710c735 feat(ui): render apply_patch multi-file diffs 2026-01-22 22:32:03 +00:00
Shantur Rathore
f5d4cb6917 refactor(ui): split ToolCall into focused modules 2026-01-22 21:54:18 +00:00
Shantur Rathore
1e53e06424 Change minVersion to 0.8.0 2026-01-22 19:16:25 +00:00
Shantur Rathore
2530cd4fc8 Bump to v0.8.0 2026-01-22 18:17:23 +00:00
Shantur Rathore
b25fb0073e fix(cloudflare): serve version.json as static asset
Avoid Workers billing for /version.json by removing worker-first routing and generating static _headers rules during manifest build.
2026-01-22 18:05:01 +00:00
Shantur Rathore
c01846f7fd ci: run release-ui in release pipeline 2026-01-22 17:29:49 +00:00
Shantur Rathore
dfd397803f Bump version to 0.7.6 2026-01-22 17:14:28 +00:00
Shantur Rathore
267f1592c4 chore: ignore local artifacts and add cloudflare lockfile 2026-01-22 16:42:47 +00:00
Shantur Rathore
668ac7fa88 ci: publish remote UI on main 2026-01-22 16:40:20 +00:00
Shantur Rathore
43a476e967 fix(cloudflare): use custom domain and remote R2 uploads 2026-01-22 16:29:23 +00:00
Shantur Rathore
adbfab5c25 feat(cloudflare): worker-hosted version.json for UI updates 2026-01-22 16:16:36 +00:00
Shantur Rathore
02f1284f7f fix(ui): emit ui-version.json and show UI source 2026-01-22 15:17:09 +00:00
Shantur Rathore
a014ce555a feat(server): auto-update UI via remote manifest 2026-01-22 15:12:32 +00:00
Shantur Rathore
db3c13c463 fix(ui): allow spaces in question custom answers
Stop trimming custom answer input on each keystroke and instead normalize answers on submit so multi-word custom responses work.
2026-01-22 09:38:38 +00:00
Shantur Rathore
7c0bf382ba fix(ui): add permission actions for unresolved requests
Render Allow/Deny buttons in the permissions control center fallback when a permission request cannot be linked to a tool-call, enabling responses for global permissions like doom_loop.
2026-01-21 14:17:08 +00:00
Shantur Rathore
6e9c5a88b4 fix(ui): allow out-of-order permission clicks
Show permission action buttons for queued tool calls while keeping keyboard shortcuts bound to the first active request. Prevent permission center list clicks from overriding keyboard-active ordering.
2026-01-21 13:26:37 +00:00
Shantur Rathore
0bf22a323f Bump version to 0.7.5 2026-01-21 12:26:22 +00:00
Shantur Rathore
cc997576cf fix(ui): stabilize question tool selection and custom answers 2026-01-21 12:25:51 +00:00
Shantur Rathore
05f193df7b fix(ui): auto-select first ready instance after refresh 2026-01-20 19:28:56 +00:00
Shantur Rathore
c9b5bb1b7a Release 0.7.4 2026-01-20 19:20:41 +00:00
Shantur Rathore
ba1013cd35 fix(ui): re-link pending question tool parts (#74) 2026-01-20 19:20:18 +00:00
Shantur Rathore
ec6428702b Bump version to 0.7.3 2026-01-20 18:49:18 +00:00
Shantur Rathore
e08ebb2057 fix(server): honor --host binding
Fixes #75
2026-01-20 18:47:40 +00:00
Shantur Rathore
9683f90f7e fix(ui): insert full paths for @file mentions 2026-01-20 18:47:40 +00:00
Shantur Rathore
06cb986aa6 fix(ui): allow Tab to select from picker
Fixes #77
2026-01-20 18:47:40 +00:00
Shantur Rathore
a85c2f1700 fix(ui): collapse prompt input after send
Fixes #76
2026-01-20 18:47:40 +00:00
Shantur Rathore
bd2a0d1bec Bump version to 0.7.2 2026-01-15 20:55:59 +00:00
Shantur Rathore
df9722cd16 fix(server): run background processes via cmd.exe on Windows 2026-01-15 20:53:13 +00:00
Shantur Rathore
dffa4907ec fix(server): validate OpenCode binary by spawning --version 2026-01-15 20:47:30 +00:00
Shantur Rathore
e567d35438 fix(server): prefer .exe/.cmd candidates on Windows 2026-01-15 20:45:14 +00:00
Shantur Rathore
62f52fc534 fix(server): spawn opencode shims via Windows shells 2026-01-15 20:43:40 +00:00
Shantur Rathore
69f221942c Bump version to 0.7.1 2026-01-15 08:39:06 +00:00
Shantur Rathore
7749225f71 fix(ui): restore pasted text expand controls\n\nFixes #67 2026-01-15 08:36:56 +00:00
Shantur Rathore
ae322c53cc fix(ui): correct Go to Session navigation across instances 2026-01-15 08:26:49 +00:00
Shantur Rathore
37da426ab4 Bump version to 0.7.0 2026-01-14 21:36:45 +00:00
Shantur Rathore
591f55bef9 fix(ui): render prompt attachments above input 2026-01-14 21:35:18 +00:00
Shantur Rathore
aabaadbe1d fix(ui): expand prompt via rows, keep placeholder padding 2026-01-14 21:28:04 +00:00
Shantur Rathore
3ab14e8de6 Merge pull request #62 from bizzkoot/feat/expand-chat-input
feat: Implement expandable chat input with double-click detection and gradient tooltip
2026-01-14 18:22:56 +00:00
Shantur Rathore
40634138bc feat(server): add authenticated remote access and desktop bootstrap
Adds cookie-based login with a bootstrap token flow for desktop apps, secures OpenCode instance traffic with per-instance Basic auth, and updates UI/plugin clients to use credentials.
2026-01-14 18:18:14 +00:00
bizzkoot
b17087b610 refactor: remove mobile-specific placeholder text for simplicity
- Remove isMobileWidth signal and updateMobileWidth resize listener
- Use same placeholder text for all devices/platforms
- "Type your message, @file, @agent, or paste images and text..."

Simplifies implementation per dev feedback - one approach for all
2026-01-13 06:48:33 +08:00
bizzkoot
71f58e7c5f refactor: simplify expand chat input to 2-state with optimized button layout
- Remove 3-state logic (normal/50%/80%) - now only normal/expanded
- Remove double-click detection and tooltips for simplicity
- Remove platform-specific behavior (same UX for Electron and web)
- Optimize button layout: reduce from 36px to 28px to fit 3 buttons
- Position expand button above history buttons in vertical stack
- Keep 15-line expanded height (360px, capped to available space)

Per upstream dev feedback to keep it simple with one approach
2026-01-13 06:45:56 +08:00
Shantur Rathore
927e4e1281 perf(ui): reduce session list churn and message block invalidation 2026-01-12 16:37:09 +00:00
bizzkoot
2e56a5e9f4 feat: implement platform-specific expand chat input with mobile optimizations
- Add platform detection (Electron vs Web) for expand behavior
  - Electron: 3-state (normal → 50% → 80%) with double-click
  - Web/Mobile: 2-state (normal → expanded) with instant single tap
- Implement fixed 15-line height for web/mobile (360px, capped)
- Add orientation-aware height calculation (landscape vs portrait)
- Remove tooltip on web/mobile, keep for Electron desktop
- Add responsive placeholder text to prevent overlap on mobile
  - Desktop: "Type your message, @file, @agent, or paste images and text..."
  - Mobile (≤640px): "Type message, @file, @agent..."
- Delete dev-docs/expand-chat-input.md per upstream feedback

Addresses PR feedback to simplify from 3-state to 2-state for web/mobile
while maintaining rich desktop experience in Electron app.
2026-01-12 20:40:19 +08:00
bizzkoot
296d07a0d6 Move expand chat input doc to dev-docs and remove empty plans folder 2026-01-12 05:24:24 +08:00
bizzkoot
0d8a844af8 feat: implement expandable chat input with double-click detection and gradient tooltip
- Add expand button with Maximize2/Minimize2 icons
- Implement 3-state height management (normal/50%/80%)
- Smart double-click detection with 300ms delay
- Height calculation based on session-view - 200px message space
- Custom CSS tooltip with dark gradient background and backdrop blur
- Send button anchored at bottom via margin-top: auto
- Smooth CSS transitions throughout
- Double-click at 80% now reduces to 50% (not normal)
- Removed all debug console.log statements
2026-01-11 21:59:28 +08:00
bizzkoot
bf9cef4cd5 docs: add expand chat input implementation plan 2026-01-11 20:17:19 +08:00
bizzkoot
9dde33aba7 style: add expand button positioning and styles 2026-01-11 20:09:13 +08:00
bizzkoot
0fefff3b0a feat: integrate ExpandButton and apply dynamic height to textarea 2026-01-11 20:07:48 +08:00
bizzkoot
1122c19648 feat: create ExpandButton component with click/double-click logic 2026-01-11 20:05:16 +08:00
bizzkoot
f06359a1fc feat: add expand state signal and height calculation helpers 2026-01-11 20:04:25 +08:00
Shantur Rathore
72f420b6f6 feat(ui): support question tool requests
Add question queue hydration, inline answering UI, and unify pending requests with permissions.
2026-01-10 09:46:23 +00:00
Shantur Rathore
147c9e3e4b Bump version to 0.6.0 2026-01-09 21:46:14 +00:00
Shantur Rathore
ab38cdccac fix(ui): simplify prompt input hints 2026-01-09 21:10:00 +00:00
Shantur Rathore
8168d52295 chore(config): bump @opencode-ai/plugin
Update opencode config plugin dependency to 1.1.8.
2026-01-09 18:56:51 +00:00
Shantur Rathore
1081bfb276 fix(ui): keep session view after delete
When deleting the active session from the sidebar list, automatically select a nearby visible session instead of falling back to the info view.
2026-01-09 18:53:00 +00:00
Shantur Rathore
38064b229c ui: move instance info to drawer header
Remove the Instance section from the session list and replace it with an info icon button in the left drawer header.
2026-01-09 17:30:53 +00:00
Shantur Rathore
1a7aefcbae feat(ui): session nav follows visible list
Cmd+Shift+[ and Cmd+Shift+] now cycle through visible sessions only (parents + expanded children) and no longer include Instance Info. Sidebar session list auto-scrolls to keep the active session row in view.
2026-01-09 16:34:44 +00:00
Shantur Rathore
e50d9f461a feat(ui): thread sessions in sidebar list
Show sessions as parent/child threads with expand/collapse and improved agent row styling. Keep a 5-session cache to avoid refetching messages when switching between recently visited sessions.
2026-01-09 16:02:53 +00:00
Shantur Rathore
d76cf8a3f7 Merge pull request #56 from bizzkoot/feat/centralized-permission-notifications
feat: Add Centralized Permission Notification System
2026-01-08 23:19:29 +00:00
bizzkoot
c7370fe7bc fix(ui): resolve multi-line border issue in permission notification cards
- Removed box-shadow from active permission items to eliminate double-border effect
- Added CSS rule to remove borders from nested ToolCall components within permission items
- Ensures consistent single-border styling aligned with existing design system
2026-01-09 06:02:58 +08:00
Shantur Rathore
3dfbe2a5b2 docs: add Linux NVIDIA Wayland workaround for Tauri AppImage 2026-01-08 20:43:11 +00:00
Shantur Rathore
e30c8b0253 fix(ui): auto-close permission center when queue empty 2026-01-08 20:25:54 +00:00
Shantur Rathore
df9fc529f9 feat(ui): rework permission center to reuse tool call view 2026-01-08 20:15:09 +00:00
Shantur Rathore
2e9f5b916c Merge remote-tracking branch 'origin/dev' into feat/centralized-permission-notifications 2026-01-08 20:09:42 +00:00
Shantur Rathore
fd464f349a ui: show permission-blocked tool calls in timeline 2026-01-08 19:16:39 +00:00
Shantur Rathore
ff6d6f4f76 Remove custom commands from palette 2026-01-08 17:52:55 +00:00
Shantur Rathore
cb2966fb08 Add slash command prompt support 2026-01-08 17:41:29 +00:00
bizzkoot
888e365d72 feat: enhance permission modal with tool details, queue nav, session nav, and responsive design
Modal Enhancements:
- Add accurate tool name extraction from message store (same method as inline chat)
- Display 'Tool Call [name]' badge (e.g., 'Tool Call read', 'Tool Call write')
- Add 'Go to Session ↗' button to navigate to originating session
- Add Prev/Next buttons for queue navigation with keyboard shortcuts (←/→)
- Add queue counter showing current position

Responsive Web Design:
- Portrait phones: 90vh max-height with safe margins (avoids browser URL bar)
- Landscape phones: 95vw with 50vh body scroll
- Tablets: adaptive layout
- Touch devices: 44-48px touch targets

Technical Changes:
- Import messageStoreBus for tool part lookup
- Query linked part.tool via permission messageId/callId
- Export setActivePermissionIdForInstance for queue navigation
- Add tool badge CSS styling
2026-01-08 06:38:59 +08:00
Shantur Rathore
e9241a1b93 Ensure child processes are stopped 2026-01-07 19:35:33 +00:00
bizzkoot
f01a06d85b feat: add centralized permission notification system for agent/subagent requests
Implements a unified permission notification UI that adapts to different runtime
environments (Electron desktop vs web browser) with distinct visual presentations.

## What Changed

### New Components
- `permission-notification-banner.tsx`: Adaptive notification component
  * Electron (desktop): Full banner with "⚠️ Approval Required" text and count badge
  * Web browser (portrait): Circular indicator badge showing pending count
- `permission-approval-modal.tsx`: Interactive modal for reviewing/approving permissions
  * Displays permission type, detailed message, and diff viewer for file changes
  * Keyboard shortcuts: Enter (allow once), A (always), D (deny), Esc (close)
  * Queue management with "X of Y" counter for multiple pending permissions
- `permission-notification.css`: Comprehensive styling with pulsing animations

### Integration
- Updated `instance-shell2.tsx`:
  * Added banner to desktop center toolbar (next to Command Palette)
  * Added banner to mobile/phone layout center section
  * Added modal component for permission approval workflow
- Updated `controls.css`: Imported new permission notification styles

## Why This Change

**Before**: Permission requests had no visual indicator in the UI, making it
difficult for users to know when agent/subagent actions required approval.

**After**: Users receive clear, persistent visual notifications with:
- Pulsing animation to draw attention
- Environment-appropriate UI (full banner on desktop, compact badge on web)
- Click-to-review workflow with full permission details

## Benefits

1. **Better UX**: Users immediately see when permissions need approval
2. **Responsive Design**: Adapts to desktop (Electron) and web browser contexts
3. **Accessible**: Proper ARIA labels, keyboard shortcuts, and focus management
4. **Queue Management**: Handles multiple pending permissions gracefully
5. **Contextual Information**: Shows diffs for file changes, permission types, etc.

## Impact

- **No Breaking Changes**: Purely additive feature
- **Build**:  Verified successful build
- **Testing**:  Tested in Electron app and web browser
2026-01-07 21:44:43 +08:00
Shantur Rathore
a68285da68 Merge pull request #54 from NeuralNomadsAI/dev
Release v0.5.1 - Build fixes
2026-01-07 09:48:46 +00:00
Shantur Rathore
c825ff066e bump version to 0.5.1 2026-01-07 08:56:19 +00:00
Shantur Rathore
f7ded37ea3 Fix macOS tauri cli package name 2026-01-07 06:35:59 +00:00
Shantur Rathore
847faf1214 Fix Tauri builds and Windows opencode-config loop 2026-01-07 06:25:19 +00:00
Shantur Rathore
b1691add1c Stabilize Tauri CLI install in CI 2026-01-07 06:10:30 +00:00
Shantur Rathore
3b9a44779a Exclude opencode-config from npm workspaces 2026-01-06 23:20:45 +00:00
Shantur Rathore
62fd88cd3f Install @tauri-apps/cli alongside platform bindings 2026-01-06 23:10:45 +00:00
Shantur Rathore
ce2273fe57 Sync package-lock for opencode-config workspace 2026-01-06 23:03:54 +00:00
Shantur Rathore
0eee325777 Stabilize Windows opencode-config install and pin tauri bindings 2026-01-06 22:57:38 +00:00
Shantur Rathore
f7c9db44ad Fix Windows builds by tracking opencode-config package.json 2026-01-06 22:43:46 +00:00
Shantur Rathore
1fcf89b945 Isolate opencode-config install from workspace 2026-01-06 20:58:01 +00:00
Shantur Rathore
f5682ea246 Fix dev CI build tool resolution and Windows npm spawning 2026-01-06 20:45:40 +00:00
Shantur Rathore
fa308696b4 Allow callers to control workflow permissions 2026-01-06 20:32:29 +00:00
Shantur Rathore
ac8dfcc607 Improve opencode-config install on Windows 2026-01-06 20:30:40 +00:00
Shantur Rathore
ac04d5daf7 Run build-only CI on dev pushes 2026-01-06 20:30:37 +00:00
Shantur Rathore
7fe8fee295 Fix Tauri CLI native dependency installs 2026-01-06 20:30:33 +00:00
Shantur Rathore
31940f972f Merge pull request #53 from NeuralNomadsAI/dev
Release v0.5.0
2026-01-06 19:40:17 +00:00
Shantur Rathore
5954b332d5 Update dependencies 2026-01-06 19:38:08 +00:00
Shantur Rathore
eb89dfaf89 Fix iOS input auto-zoom (fixes #49, thanks @xpcmdshell) 2026-01-06 18:51:39 +00:00
Shantur Rathore
25bf313338 Show compaction indicator in message stream and timeline 2026-01-06 18:48:00 +00:00
Shantur Rathore
315abf21e6 Fix session status hydration and compaction transitions 2026-01-06 18:03:42 +00:00
Shantur Rathore
f24e360d78 Optimize session status updates
Reduce per-token store churn by updating status on transitions, caching instance-level indicators, and avoiding O(n) session-map cloning.
2026-01-06 09:58:55 +00:00
Shantur Rathore
1a6f1fdbae Bump to v0.5.0 2026-01-05 22:39:02 +00:00
Shantur Rathore
e09ce0780e Reconcile permissions after message hydration
After loadMessages hydrates tool parts, reattach pending permissions to the correct tool-call part ids so ToolCall permission UI renders reliably.
2026-01-05 20:39:51 +00:00
Shantur Rathore
95fdad7523 Use shield icon for permission status
Replace permission dots with a shield indicator and adjust permission colors to stand out from working/compacting.
2026-01-05 20:18:07 +00:00
Shantur Rathore
06416a9eb3 Add instance tab session status indicator
Aggregate session states per instance so tabs reflect permission, compaction, and working activity.
2026-01-05 20:09:13 +00:00
Shantur Rathore
2db62b1d17 Make UI global cache version-aware
Store one cached value per cacheId and overwrite when version changes to prevent unbounded growth from per-version keys.
2026-01-05 19:45:33 +00:00
Shantur Rathore
1377bc6b91 Migrate UI to v2 SDK client
Use v2 OpencodeClient with normalized request handling and rehydrate pending permissions via GET /permission on instance hydration.
2026-01-04 22:02:30 +00:00
Shantur Rathore
fcb5998474 Update UI permissions for SDK 1.0.166
Handle permission.asked events and requestID replies while keeping legacy compatibility.
2026-01-04 22:02:30 +00:00
Shantur Rathore
c2df32ec8b Stream ANSI tool output rendering 2026-01-04 22:02:30 +00:00
Shantur Rathore
f01149ee9e Stream ANSI tool output rendering 2026-01-04 22:02:29 +00:00
Shantur Rathore
eebfcb5628 Unify ANSI rendering with sequence parser 2026-01-04 22:02:29 +00:00
Shantur Rathore
4571a1dcf9 Render ANSI background output 2026-01-04 22:02:29 +00:00
Shantur Rathore
a041e1c6c3 Track session status via SSE updates 2026-01-04 22:02:29 +00:00
Shantur Rathore
abb8a9df19 Merge pull request #51 from bizzkoot/fix/copy-button-web
fix: copy button functionality in web browsers
2026-01-04 13:52:41 +00:00
bizzkoot
3c450c076a fix: copy button functionality in web browsers
- Add clipboard utility with fallback for non-secure contexts
- Implement modern Clipboard API with document.execCommand fallback
- Update copy buttons in code blocks, markdown, messages, and session list
- Add proper error handling and user feedback for copy operations

Fixes issue where copy buttons did not work in web browsers served over HTTP or without Clipboard API support
2026-01-04 20:00:22 +08:00
Shantur Rathore
4b05e698f8 Require tool part ids for tool-call rendering and caching
Rebind permissions from callID to part id when parts arrive.
2026-01-02 16:21:24 +00:00
Shantur Rathore
a9524b3e30 Load complete background process output and fix dialog layout 2025-12-30 22:03:04 +00:00
Shantur Rathore
154c5208b4 Show timeline icons at all widths 2025-12-29 16:19:11 +00:00
Shantur Rathore
71479a59a7 Add ANSI rendering for bash tool output 2025-12-26 10:47:53 +00:00
Shantur Rathore
3606d9aa50 Enforce workspace-only paths for background processes 2025-12-25 23:15:43 +00:00
Shantur Rathore
3e4d51c9f2 Surface runtime output in launch errors 2025-12-25 20:44:21 +00:00
Shantur Rathore
2603b1d260 Handle revert removals locally and retarget prompt input 2025-12-25 15:12:44 +00:00
Shantur Rathore
94aa469e90 Stop workspace port warning timer after allocation 2025-12-24 20:29:11 +00:00
Shantur Rathore
dab1e0fa7a Bundle opencode-config dependencies 2025-12-24 20:25:19 +00:00
Shantur Rathore
a14247f049 Sync package-lock 2025-12-24 19:10:32 +00:00
Shantur Rathore
695a890e0a Normalize plugin file URLs 2025-12-24 13:37:39 +00:00
Shantur Rathore
402d72d038 Remove session idle plugin wiring 2025-12-24 13:34:46 +00:00
Shantur Rathore
d32ec73c63 Resolve bundled opencode config from resources 2025-12-24 13:30:00 +00:00
Shantur Rathore
d0eac1e610 Use bundled opencode config at runtime 2025-12-24 12:01:03 +00:00
Shantur Rathore
e947691aae Consolidate plugins under CodeNomad entry 2025-12-24 01:07:56 +00:00
Shantur Rathore
575f987b8f Add background process manager and UI panel 2025-12-24 00:59:41 +00:00
Shantur Rathore
28b66ed0af Add CodeNomad plugin bridge for opencode 2025-12-23 23:06:33 +00:00
Shantur Rathore
4060c4f60b Show configured plugins in status panels 2025-12-23 18:24:09 +00:00
Shantur Rathore
8334e27294 Show error if opencode fails to launch 2025-12-17 22:59:05 +00:00
Shantur Rathore
722b523f92 Add packages/opencode-config and use it 2025-12-17 22:58:41 +00:00
Shantur Rathore
b4663fb250 Merge pull request #44 from NeuralNomadsAI/dev
Release 0.4.0
2025-12-15 16:46:29 +00:00
Shantur Rathore
06be455358 bump version to 0.4.0 2025-12-15 16:32:29 +00:00
Alex Crouch
450f5bf0b4 change copy to act only on individual assitant/user blocks 2025-12-15 16:10:19 +00:00
Alex Crouch
997d4f4129 feat(ui): add copy button to message items
Add a Copy button that allows users to copy raw message contents
(text and reasoning parts) to clipboard. The button appears on all
messages - alongside Revert/Fork for user messages, and standalone
for assistant messages.
2025-12-15 16:10:19 +00:00
Shantur Rathore
ff5c698131 Refactor instance metadata handling 2025-12-15 16:08:28 +00:00
Shantur Rathore
14497f2082 Limit instance info scroll area 2025-12-15 10:37:08 +00:00
Shantur Rathore
f3e1966b5d Rename control panel to status panel 2025-12-15 10:09:51 +00:00
Shantur Rathore
78592f229e Fix long plan item layout 2025-12-15 10:09:18 +00:00
Shantur Rathore
c8161669ac Add shared instance metadata context 2025-12-15 00:42:16 +00:00
Shantur Rathore
8ec57da275 Tweak control panel plan styling 2025-12-14 23:36:38 +00:00
Shantur Rathore
c00b29145a limit cached session views on tab switch 2025-12-14 17:07:17 +00:00
Shantur Rathore
7d2a349e95 Lower AppBar z-index for timeline tooltips 2025-12-14 16:43:43 +00:00
Shantur Rathore
6c326b18ca Close floating drawers on escape key 2025-12-14 16:42:31 +00:00
Shantur Rathore
09229259d1 Ensure agent selector popover overlays drawer 2025-12-14 16:39:28 +00:00
Shantur Rathore
b20bfc34b2 Fix selector shortcut popovers with floating drawer 2025-12-14 16:36:34 +00:00
Shantur Rathore
4e1f08bfcf Trigger selector popups after auto-opening drawer 2025-12-14 16:33:53 +00:00
Shantur Rathore
ef4f8ac45f Route agent/model shortcuts through sidebar events 2025-12-14 16:30:31 +00:00
Shantur Rathore
6a7255d9d2 Auto-open left drawer for selector shortcuts 2025-12-14 16:26:37 +00:00
Shantur Rathore
f37fcaed3d Open left drawer for selector and session shortcuts 2025-12-14 16:22:30 +00:00
Shantur Rathore
d9fd22c29f Raise selector popover layer above drawers 2025-12-14 16:16:55 +00:00
Shantur Rathore
3fcab5b80a Add timeline divider and fix session scroll 2025-12-14 16:13:22 +00:00
Shantur Rathore
4ed2361387 Reduce scroll jitter from virtual items 2025-12-14 15:55:09 +00:00
Shantur Rathore
75b3699649 Show latest todowrite plan in control panel 2025-12-14 15:05:09 +00:00
Shantur Rathore
a6404f25d9 Add control panel accordion for session sidebar 2025-12-14 14:09:07 +00:00
Shantur Rathore
7591e5c1c9 Add MCP toggle control 2025-12-14 13:40:32 +00:00
Shantur Rathore
5e8b3fd5c9 Keep session chrome in info view 2025-12-14 13:24:47 +00:00
Shantur Rathore
20b82496a1 Persist drawer pin preferences 2025-12-14 13:13:43 +00:00
Shantur Rathore
542b59940a Add resizable session drawers 2025-12-14 13:01:29 +00:00
Shantur Rathore
8d5c6b37e9 Prevent welcome resume list overflow 2025-12-14 12:50:00 +00:00
Shantur Rathore
8155fc9956 Ensure welcome and palette layouts wrap on phone 2025-12-14 12:40:00 +00:00
Shantur Rathore
cd4afb5314 Clamp phone shell horizontal overflow 2025-12-14 12:31:26 +00:00
Shantur Rathore
557c2500c7 Clean up legacy instance shell and theme additions 2025-12-14 01:55:50 +00:00
Shantur Rathore
74f8b6c31f Remove instance shell overflow scroll 2025-12-14 01:54:32 +00:00
Shantur Rathore
da517416a5 Hide app bar during folder selection and tighten toolbar 2025-12-14 01:52:34 +00:00
Shantur Rathore
b8f93bf768 Tighten phone app bar and compact palette control 2025-12-14 01:43:04 +00:00
Shantur Rathore
0110052758 Add mobile-friendly instance shell app bar 2025-12-14 01:34:31 +00:00
Shantur Rathore
0e0da1a142 Show diagnostics only for edited file 2025-12-13 22:23:37 +00:00
Shantur Rathore
da3b66a3bd Ensure Tauri CLI locates server in AppImage 2025-12-13 21:54:04 +00:00
Shantur Rathore
088e5f1eea Align prompt input area with action column 2025-12-13 13:20:33 +00:00
Shantur Rathore
0da2e1d7bb Sync tool-call titles and task summaries 2025-12-12 13:51:40 +00:00
Shantur Rathore
90c6835ee7 Defer tool markdown render while running 2025-12-12 12:00:42 +00:00
Shantur Rathore
92bef8bfb8 Memoize markdown renders per part revision 2025-12-12 12:00:31 +00:00
Shantur Rathore
766be00ded Make message list bottom-first with append-only timeline 2025-12-12 12:00:19 +00:00
Shantur Rathore
ce5eaa1841 Use async prompt API and SDK bump 2025-12-09 21:42:29 +00:00
Shantur Rathore
c323667729 Preserve session scroll when returning 2025-12-09 21:29:48 +00:00
Shantur Rathore
67a12d6126 Add session rename dialogs and API wiring 2025-12-09 20:13:35 +00:00
Shantur Rathore
bd0cb04b78 Avoid queued badge in timeline previews 2025-12-09 18:20:38 +00:00
Shantur Rathore
d3706d2985 Improve message timeline tooltip positioning 2025-12-09 17:19:55 +00:00
Shantur Rathore
9769d7a46e Clarify pending tool call message 2025-12-09 17:14:21 +00:00
Shantur Rathore
783fb5c5b2 Defer initial message scroll until list renders 2025-12-09 17:09:30 +00:00
Shantur Rathore
82ff1916b7 Prevent cached session remeasurements and remove logs 2025-12-09 16:27:01 +00:00
Shantur Rathore
8204143810 Improve session cache eviction 2025-12-09 16:27:01 +00:00
Shantur Rathore
e54f80f20e Keep tabs hidden but in memory 2025-12-09 11:12:41 +00:00
Shantur Rathore
54a2917faa Merge pull request #40 from alexispurslane/tauri-native-menu
Implement complete native menu system with keyboard accelerators
2025-12-09 01:30:47 +00:00
Shantur Rathore
b72ead1bea bump to v0.3.0 2025-12-09 01:29:30 +00:00
Shantur Rathore
7996228327 lazy mount message blocks 2025-12-09 01:04:49 +00:00
Shantur Rathore
7aba3c1221 add timeline tool visibility toggle 2025-12-08 18:32:23 +00:00
Alexis Purslane
11dedd4446 Implement complete native menu system with keyboard accelerators
This commit establishes a comprehensive native menu system for the Tauri application, bringing it to parity with the Electron implementation and enabling full keyboard shortcut support.

## Key Features Added

**Native Menu System:**
- Complete menu bar with File, Edit, View, and Window menus
- Platform-specific adaptations (macOS app menu, Windows/Linux quit behavior)
- Full menu event handling system with proper window management

**Keyboard Accelerators:**
- CmdOrCtrl+N for New Instance creation
- Standard Edit menu shortcuts (Undo, Redo, Cut, Copy, Paste, Select All)
- View menu shortcuts (Reload, Force Reload, Toggle DevTools, Fullscreen)
- Built-in zoom hotkeys enabled via zoomHotkeysEnabled

**Enhanced Capabilities:**
- Added core:menu:default permission for menu management
- Added core:webview:allow-set-webview-zoom for zoom functionality
- Proper event communication between Tauri backend and frontend

**Frontend Integration:**
- Tauri event listener for menu:newInstance events
- Proper cleanup and error handling for event subscriptions
- Runtime environment detection for Tauri-specific behavior

## Technical Changes

**Backend (Rust):**
- Replaced empty menu stub with full SubmenuBuilder implementation
- Added comprehensive menu event handling with window operations
- Implemented MenuItem::with_id for accelerator support
- Added platform-specific menu construction logic

**Frontend (TypeScript):**
- Added Tauri event listener integration in main App component
- Proper lifecycle management with onMount/onCleanup
- Runtime environment detection for conditional Tauri behavior

**Configuration:**
- Enabled zoomHotkeysEnabled in tauri.conf.json for built-in zoom support
- Updated capabilities to include necessary menu and webview permissions

This implementation provides a native, platform-consistent user experience with full keyboard shortcut support, matching the functionality users expect from desktop applications.
2025-12-08 11:23:57 -05:00
Shantur Rathore
8fcf757c5c Share release workflows 2025-12-08 14:49:39 +00:00
Shantur Rathore
5cf3c001b5 adjust timeline hover tooltip position 2025-12-08 14:26:42 +00:00
Shantur Rathore
4ae54a1f7b use icons for timeline short labels 2025-12-08 13:46:38 +00:00
Shantur Rathore
81a9c28971 use tool prefixes in timeline 2025-12-08 13:40:42 +00:00
Shantur Rathore
235b9338a7 tweak: use initial for tool short label 2025-12-08 10:56:32 +00:00
Shantur Rathore
642d5e22e6 tweak: show tool name in timeline chip 2025-12-08 10:54:50 +00:00
Shantur Rathore
67ff00d83e feat: add quote mode options 2025-12-08 10:45:12 +00:00
Shantur Rathore
710938eef8 fix: hide assistant timeline entries without text 2025-12-08 10:34:00 +00:00
Shantur Rathore
dc702b1fb2 feat: quote message selections 2025-12-08 10:16:58 +00:00
Shantur Rathore
92d16084db fix: render full message preview for tool calls 2025-12-08 09:52:22 +00:00
Shantur Rathore
9b0e02f66f feat: add timeline hover message preview 2025-12-08 09:45:11 +00:00
Shantur Rathore
a2e5034c20 feat: sync timeline highlight with scroll 2025-12-07 21:59:57 +00:00
Shantur Rathore
e3489b22e6 feat: add timeline sidebar to message view 2025-12-07 21:40:52 +00:00
Shantur Rathore
cd8948770d feat: include bash timeout 2025-12-07 20:23:52 +00:00
Shantur Rathore
d4281f1d9c feat: surface read bounds 2025-12-07 20:15:39 +00:00
Shantur Rathore
49214c60ca Issue template 2025-12-07 18:12:11 +00:00
Shantur Rathore
0a530a257f Add information about codenomad dev version 2025-12-07 18:03:07 +00:00
Shantur Rathore
54f269e955 fix: unblock tauri build 2025-12-07 17:02:03 +00:00
Shantur Rathore
91ab2d5e2c Merge pull request #34 from tybradle/fix/crypto-uuid-fallback
Fix: Add crypto.randomUUID fallback for browser compatibility
2025-12-07 16:22:29 +00:00
Shantur Rathore
72773546f5 Merge remote-tracking branch 'origin/dev' into fork/tybradle-fix-crypto-uuid-fallback 2025-12-07 16:21:37 +00:00
Shantur Rathore
2f58e8a1a9 Show loading indicator before listing sessions 2025-12-07 01:29:36 +00:00
Shantur Rathore
d0cab51eca open external links in native shells 2025-12-07 01:18:26 +00:00
Shantur Rathore
6f04d23b09 limit release toast to folder view 2025-12-07 00:58:15 +00:00
Shantur Rathore
3e72b83393 add release monitor and ui toast 2025-12-07 00:55:10 +00:00
Shantur Rathore
87da8ee9f8 bump version 0.2.8 2025-12-06 23:22:18 +00:00
Shantur Rathore
ec5c5c8c0f add history rocker icons 2025-12-06 23:18:29 +00:00
Shantur Rathore
b9394fb467 add prompt history rocker 2025-12-06 23:13:01 +00:00
Shantur Rathore
de432106e5 add stop control to prompt input 2025-12-06 23:05:38 +00:00
Shantur Rathore
1fbf51b7ae align instance tab accessories to right 2025-12-06 22:38:43 +00:00
Shantur Rathore
864d665049 keep shortcuts in instance tab scroll 2025-12-06 22:37:31 +00:00
Shantur Rathore
c4a9c032a3 make instance tabs strip fully scrollable 2025-12-06 22:36:00 +00:00
Shantur Rathore
3373e23a41 sync hidden sidebar layout with mobile 2025-12-06 22:34:30 +00:00
Shantur Rathore
b0650a283e Revert "fix hidden sidebar toggle button"
This reverts commit f1ad1400a7.
2025-12-06 22:31:52 +00:00
Shantur Rathore
52149f5543 Revert "align sidebar toggle layout on collapse"
This reverts commit 2e5a904034.
2025-12-06 22:31:48 +00:00
Shantur Rathore
2e5a904034 align sidebar toggle layout on collapse 2025-12-06 22:30:37 +00:00
Shantur Rathore
f1ad1400a7 fix hidden sidebar toggle button 2025-12-06 22:29:03 +00:00
Shantur Rathore
bbd28404ff gate instance info overlay on desktop 2025-12-06 22:26:52 +00:00
Shantur Rathore
04f6e362b9 Centralize tool call scroll helpers 2025-12-06 22:22:44 +00:00
Shantur Rathore
0b9cce6f86 Add session delete button 2025-12-06 22:20:15 +00:00
Shantur Rathore
d68cb6b1b8 Add delete controls to resume sessions 2025-12-06 22:16:08 +00:00
Shantur Rathore
e345dc1262 Expose UI logger controls globally 2025-12-06 12:17:33 +00:00
Shantur Rathore
2b27790a81 add tool call auto scroll sentinels 2025-12-05 23:47:34 +00:00
Shantur Rathore
2514fa94b4 stabilize scroll event wiring 2025-12-05 22:05:27 +00:00
Shantur Rathore
522910ff64 refine message stream auto scroll 2025-12-05 21:57:10 +00:00
Shantur Rathore
971abe24d7 feat(ui): add runtime logger and replace console usage 2025-12-05 15:07:49 +00:00
Shantur Rathore
49143bd049 Upgrade sdk and use async prompt 2025-12-05 12:28:44 +00:00
Shantur Rathore
df52ed3035 improve server logging for sse and http 2025-12-05 10:53:57 +00:00
Shantur Rathore
617aac8fd8 Use monitor icon for remote connect 2025-12-04 10:58:33 +00:00
Shantur Rathore
6e82ecc97e Improve remote overlay responsiveness 2025-12-03 22:15:46 +00:00
Shantur Rathore
636a19fc50 Move remote connect button to tab bar edge 2025-12-03 22:12:16 +00:00
Shantur Rathore
97f78bb337 Make remote connect buttons icon-only 2025-12-03 22:10:53 +00:00
Shantur Rathore
0ca39d2fb0 Filter loopback addresses when remote 2025-12-03 22:05:26 +00:00
Shantur Rathore
aad1337111 Use remote-hand-over icon for connect buttons 2025-12-03 22:04:01 +00:00
Shantur Rathore
6d7bc813ed Adjust remote button placement 2025-12-03 22:02:50 +00:00
Shantur Rathore
1a0dd21540 Expose remote connect button on folder view 2025-12-03 22:02:17 +00:00
Shantur Rathore
7cf9c35375 Restrict meta addresses when local-only 2025-12-03 21:59:20 +00:00
Shantur Rathore
f1c32253af Adjust remote handover header copy 2025-12-03 21:57:54 +00:00
Shantur Rathore
4a8d13e2cd Update remote overlay copy 2025-12-03 21:57:27 +00:00
Shantur Rathore
b0fd63ead5 Limit remote overlay to single QR 2025-12-03 21:54:11 +00:00
Shantur Rathore
94cb741c7f Add remote access controls 2025-12-03 21:52:42 +00:00
Shantur Rathore
976430d61c Don't use vite.config.ts 2025-12-03 18:36:15 +00:00
Shantur Rathore
8a8555d591 Optimize task tool summary recompute on version changes 2025-12-03 18:13:56 +00:00
Shantur Rathore
57c1605242 Message addition performance improvements 2025-12-03 17:07:05 +00:00
Shantur Rathore
cfbd0bdffa Show view instance button only on small screen 2025-12-03 16:41:45 +00:00
Shantur Rathore
58efb8bc3e disable diff cache 2025-12-03 16:41:19 +00:00
Shantur Rathore
b35bfe63c0 Increase timeout for CLI startup 2025-12-03 16:37:48 +00:00
Shantur Rathore
d7b5f53d59 launch cli listeners on all interfaces 2025-12-03 00:16:02 +00:00
Shantur Rathore
168b782006 retry default port before auto ephemeral 2025-12-03 00:10:20 +00:00
Shantur Rathore
9e0fbd185d keep instance tab close buttons visible 2025-12-03 00:09:07 +00:00
Shantur Rathore
11be314f63 center mobile usage chips 2025-12-03 00:04:52 +00:00
Shantur Rathore
36ee301ef2 center session header metrics 2025-12-03 00:04:07 +00:00
Shantur Rathore
d6dd06b7d1 fix session sidebar width binding 2025-12-02 23:58:41 +00:00
Shantur Rathore
6a16dd8f98 align permission attachments with SSE stream 2025-12-02 23:53:34 +00:00
Shantur Rathore
78338f33c1 add responsive session sidebar 2025-12-02 23:52:45 +00:00
Shantur Rathore
8c72d279df add mobile overlay for instance info 2025-12-02 23:17:14 +00:00
Shantur Rathore
a9500276c8 add expand control for pasted text attachments 2025-12-02 22:59:36 +00:00
Shantur Rathore
f9ec757c64 refactor message stream layout 2025-12-02 19:23:05 +00:00
Tyler Bradley
f4c9385661 Fix: Add crypto.randomUUID fallback for browser compatibility
The crypto.randomUUID() API requires a secure context (HTTPS or specific
localhost conditions). When running CodeNomad Server on 0.0.0.0:9898 or
accessing via LAN/VPN, some browsers don't provide this API, causing
attachment creation to fail with TypeError.

Added generateUUID() helper that uses crypto.randomUUID() when available,
with a Math.random()-based UUID v4 fallback for compatibility.

Fixes file and agent attachment creation in the @ mention picker when
running in headless server mode.
2025-12-02 16:55:59 +00:00
Shantur Rathore
6ba50cadd2 modularize tool-call rendering and styles 2025-12-02 16:16:49 +00:00
Shantur Rathore
8d5169cb39 Memoize ToolCall task summary rendering 2025-12-02 13:45:35 +00:00
Shantur Rathore
fe8b4a9acd Drop tool-call scroll caching 2025-12-02 13:10:29 +00:00
Shantur Rathore
831e59cd77 Anchor scroll to message stream sentinel 2025-12-02 12:37:49 +00:00
Shantur Rathore
7fde8afcf0 Remove placeholder min-height fallback 2025-12-02 12:07:43 +00:00
Shantur Rathore
d07c2ec4a9 Stop gating virtualization on measurements 2025-12-02 12:02:05 +00:00
Shantur Rathore
4306147990 Precalc viewport window for virtualization 2025-12-02 11:49:42 +00:00
Shantur Rathore
c614da3e3c Batch virtual item visibility updates 2025-12-02 11:45:12 +00:00
Shantur Rathore
73b59d8266 Share intersection observers across virtual items 2025-12-02 11:33:22 +00:00
Shantur Rathore
a2d8ea0dfd Add local virtualization wrapper to message stream 2025-12-02 11:13:12 +00:00
Shantur Rathore
52ee196103 Add macos workaround 2025-12-02 10:23:21 +00:00
Shantur Rathore
1a1aee8f91 Group message action buttons 2025-12-01 23:42:01 +00:00
Shantur Rathore
5384ff8e80 Merge message parts props 2025-12-01 23:20:44 +00:00
Shantur Rathore
6d5836ce1f Always render step usage chips 2025-12-01 23:12:31 +00:00
Shantur Rathore
d3dc170e02 Lazy render tool-call bodies 2025-12-01 23:09:22 +00:00
Shantur Rathore
983c8cc4a3 Streamline tool-call header DOM 2025-12-01 22:48:22 +00:00
Shantur Rathore
757c587b17 Simplify message header markup 2025-12-01 22:41:30 +00:00
Shantur Rathore
5f9cf397b9 Reduce step-finish usage chip DOM 2025-12-01 22:36:03 +00:00
Shantur Rathore
78ab17d148 bump version to 0.2.7 2025-12-01 19:54:12 +00:00
Shantur Rathore
e91923ad99 Improve message stream auto-scroll during streaming 2025-12-01 19:46:16 +00:00
Shantur Rathore
fd23ea54b6 Simplify message cache pruning and diff/markdown initialization 2025-12-01 19:46:16 +00:00
Shantur Rathore
1e7969eaba Localize ToolCall expansion and diagnostics state 2025-12-01 19:46:16 +00:00
Shantur Rathore
77bfe41a8e Reduce message cloning and gate scroll work on load 2025-12-01 19:46:16 +00:00
Shantur Rathore
6d134e4dec Render stream with Index to limit churn on inserts/removes 2025-12-01 19:46:16 +00:00
Shantur Rathore
9423326193 Make MessageBlock rerender via keyed Show 2025-12-01 19:46:16 +00:00
Shantur Rathore
c5011e4ece Make message blocks reactive to session signals 2025-12-01 19:46:16 +00:00
Shantur Rathore
66c270151a Stream change token and scroll checks use ids only 2025-12-01 19:46:16 +00:00
Shantur Rathore
5ce41217e9 Make MessageBlock output reactive via Show 2025-12-01 19:46:16 +00:00
Shantur Rathore
1e4d949d35 Add message stream debug logging 2025-12-01 19:46:16 +00:00
Shantur Rathore
6bb9e8e414 Render message stream per message id 2025-12-01 19:46:16 +00:00
Shantur Rathore
1efc49b67b Prune reverted messages from session store 2025-12-01 19:46:16 +00:00
Shantur Rathore
f0ed98222a Skip reverted messages from display caches 2025-12-01 19:46:16 +00:00
Shantur Rathore
ddd8ce341a Add diff viewer fallback and extract message block 2025-12-01 19:46:16 +00:00
Shantur Rathore
b7721ba3e7 Merge pull request #13 from alexispurslane/blank-session-cleanup
Blank session cleanup
2025-12-01 19:45:09 +00:00
Alexis Purslane
0554018980 update message 2025-12-01 12:37:22 -05:00
Alexis Purslane
ca18942bfd confirmation dialogue 2025-12-01 11:43:04 -05:00
Alexis Purslane
c9c1f69b82 further improvements 2025-12-01 11:35:03 -05:00
Alexis Purslane
aa0c31fa1e improve PR 2025-11-29 21:44:15 -05:00
Alexis Purslane
96b88dbcdc update blank session cleanup code for now session store logic 2025-11-27 20:18:22 -05:00
Alexis Dumas
50676416ed blank session cleanup improvements
- make the blank session cleanup system optionally fetch full message histories for each session to better judge if it's blank
- make a command that does the deep clean, keep the clean that happens on new session creation shallow
2025-11-27 18:18:24 -05:00
Alexis Purslane
f633d75005 Blank session cleanup 2025-11-27 18:18:24 -05:00
Shantur Rathore
4085f6d6b9 Avoid npm ci pruning during prebuild 2025-11-27 20:40:21 +00:00
Shantur Rathore
ae288833e1 Install server deps before build in prebuild 2025-11-27 20:29:08 +00:00
Shantur Rathore
f16e244265 Handle rollup platform binaries in prebuild 2025-11-27 20:25:04 +00:00
Shantur Rathore
b6e43c899b Improve dialog text wrapping and sizing 2025-11-27 20:11:38 +00:00
Shantur Rathore
9fa436b0b8 Ensure rollup linux binary in tauri prebuild 2025-11-27 20:11:02 +00:00
Shantur Rathore
ccd65fbc74 publish-server after build in dev 2025-11-27 19:48:35 +00:00
Shantur Rathore
daa7e3a6d1 Only publish server after successful builds 2025-11-27 19:45:58 +00:00
Shantur Rathore
ff356ac5ea bump Version to 0.2.6 2025-11-27 19:42:55 +00:00
Shantur Rathore
d68b92ff38 Gate npm publish on successful builds 2025-11-27 19:41:02 +00:00
Shantur Rathore
940216d98b Ensure tauri prebuild installs UI workspace deps 2025-11-27 19:40:36 +00:00
Shantur Rathore
69cd3cf545 bumpVersion to 0.2.5 2025-11-27 19:34:30 +00:00
Shantur Rathore
042a45db0d Ensure autoscroll reacts to UI toggles 2025-11-27 19:20:55 +00:00
Shantur Rathore
cc45c16d73 Stabilize message stream autoscroll 2025-11-27 18:48:11 +00:00
Shantur Rathore
91fb351a63 Improve sidebar default width and message autoscroll 2025-11-27 18:24:45 +00:00
Shantur Rathore
d9b149a7cb Reintroduce scroll restore effect for message stream 2025-11-27 17:25:19 +00:00
Shantur Rathore
222a467a19 Improve message stream caching and scroll performance 2025-11-27 16:51:05 +00:00
Shantur Rathore
18513939f7 Tighten message spacing and restyle reasoning blocks 2025-11-27 13:53:52 +00:00
Shantur Rathore
c123714271 Add thinking expansion preference and step finish styling 2025-11-27 13:39:03 +00:00
Shantur Rathore
5c82a2d653 Align assistant metadata display with message content 2025-11-27 13:26:31 +00:00
Shantur Rathore
435881529e match thinking toggle button sizing 2025-11-27 13:10:56 +00:00
Shantur Rathore
700342670c refine thinking accordion layout 2025-11-27 13:05:52 +00:00
Shantur Rathore
2f40f5eedf refine step and stream spacing 2025-11-27 10:41:34 +00:00
Shantur Rathore
54905c5626 tighten message spacing 2025-11-27 10:30:30 +00:00
Shantur Rathore
1bf1a4761d soften assistant and thinking headers 2025-11-27 10:27:29 +00:00
Shantur Rathore
755695a35a refine thinking cards and message layout 2025-11-27 10:24:41 +00:00
Shantur Rathore
6a9a442948 Handle session cleanup and error message status 2025-11-26 16:20:02 +00:00
Shantur Rathore
3db9b0f673 tidy normalized store hydration 2025-11-26 15:59:24 +00:00
Shantur Rathore
4e0e5dcdca Restore tool navigation and balanced scroll controls 2025-11-26 15:28:48 +00:00
Shantur Rathore
fad2809299 Improve message stream caching and virtualization for large sessions 2025-11-26 13:30:20 +00:00
Shantur Rathore
c77bfc2ee7 Avoid deep reconcile in message hydrate 2025-11-26 11:08:54 +00:00
Shantur Rathore
f1fa28dd2c Optimize message hydrate to reduce traversal 2025-11-26 10:59:15 +00:00
Shantur Rathore
91ace25333 Batch hydrate normalized messages for session load 2025-11-26 10:57:39 +00:00
Shantur Rathore
b54db28fb1 avoid deep proxying message info 2025-11-26 10:29:14 +00:00
Shantur Rathore
f13feb3062 Revert "cap session order/history lengths"
This reverts commit 4622bdc7ea.
2025-11-26 10:24:58 +00:00
Shantur Rathore
4622bdc7ea cap session order/history lengths 2025-11-26 10:23:49 +00:00
Shantur Rathore
919127b6d9 fix session closing crash 2025-11-26 10:20:08 +00:00
Shantur Rathore
27cd4515cd finish migration to message-store 2025-11-26 10:13:05 +00:00
Shantur Rathore
93a5c16cab migrate session event/actions to v2 store 2025-11-26 09:57:21 +00:00
Shantur Rathore
16b76385e2 chore: add message store v2 baseline 2025-11-26 09:42:10 +00:00
Shantur Rathore
9313b2bd6c Add showUsageMetrics to Prefs schema 2025-11-25 16:06:14 +00:00
Shantur Rathore
d25cb09714 Align selector shortcuts and widen sidebar 2025-11-25 16:04:19 +00:00
Shantur Rathore
0d0d1271c3 Move assistant usage chips 2025-11-25 13:12:54 +00:00
Shantur Rathore
1fd3b2e75c Add toggle for usage metrics 2025-11-25 12:26:38 +00:00
Shantur Rathore
bf32fcf136 Refine session usage tracking 2025-11-25 12:03:33 +00:00
Shantur Rathore
48eb6b8982 bump version 0.2.4 2025-11-25 08:56:51 +00:00
Shantur Rathore
797fafe854 Normalize host when parsing CLI 2025-11-25 00:52:46 +00:00
Shantur Rathore
b342660ed0 Improve welcome mobile layout 2025-11-25 00:50:21 +00:00
Shantur Rathore
169d5ddeb9 Use npx tauri for workspace builds 2025-11-24 20:16:49 +00:00
Shantur Rathore
38642b60e9 add command palette button 2025-11-24 14:37:15 +00:00
Shantur Rathore
01effb8924 refine prompt overlay layout 2025-11-24 14:16:25 +00:00
Shantur Rathore
b434bfd3e9 Ensure tauri bundle includes server deps 2025-11-24 11:20:27 +00:00
Shantur Rathore
ed769911d6 bump to version v0.2.3 2025-11-23 19:37:41 +00:00
Shantur Rathore
dd6efee900 disable SSE body timeouts and ignore workspace-stopped disconnects 2025-11-23 19:34:14 +00:00
Shantur Rathore
48a16a6702 ignore expected workspace stops when showing disconnect modal 2025-11-23 19:17:53 +00:00
Shantur Rathore
841b9daa1f only show disconnect modal on final status 2025-11-23 19:13:22 +00:00
Shantur Rathore
1741e49568 aggregate instance SSE streams through server bus so UI uses single connection 2025-11-23 19:07:10 +00:00
Shantur Rathore
8577b3d1e6 show loading status only for errors 2025-11-23 14:42:09 +00:00
Shantur Rathore
011533b3c4 improve prompt submission history handling 2025-11-23 14:41:49 +00:00
Shantur Rathore
002efad9ad cap CLI proxy concurrency 2025-11-23 14:40:37 +00:00
Shantur Rathore
3ce5569b82 route CLI logs to host processes only 2025-11-23 13:38:50 +00:00
Shantur Rathore
d7c0c225b9 chore: align monorepo package versions with 0.2.2 2025-11-23 12:05:36 +00:00
Shantur Rathore
f4de0103a8 Resolve CLI binary metadata for UI 2025-11-23 11:59:12 +00:00
Shantur Rathore
0a9b7fafed Align Tauri dev flow with shared renderer 2025-11-23 10:37:45 +00:00
Shantur Rathore
073604c9f5 Force dark theme defaults across shells 2025-11-23 10:00:16 +00:00
Shantur Rathore
4062b43380 Enable native dialogs across shells 2025-11-23 00:36:43 +00:00
Shantur Rathore
00bd9f9c1c Allow proxy streams to stay open 2025-11-22 21:50:04 +00:00
Shantur Rathore
3edb0ac09e Add runtime environment detection 2025-11-22 21:46:53 +00:00
Shantur Rathore
e9f3c4ee52 Unify loader assets across shells 2025-11-22 21:20:29 +00:00
Shantur Rathore
92420d9e02 Move screenshots to correct folder 2025-11-21 21:59:58 +00:00
253 changed files with 29546 additions and 7532 deletions

71
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
name: Bug Report
description: Report a bug or regression in CodeNomad
labels:
- bug
title: "[Bug]: "
body:
- type: markdown
attributes:
value: |
Thanks for filing a bug report! Please review open issues before submitting a new one and provide as much detail as possible so we can reproduce the problem.
- type: dropdown
id: variant
attributes:
label: App Variant
description: Which build are you running when this issue appears?
multiple: false
options:
- Electron
- Tauri
- Server CLI
validations:
required: true
- type: input
id: os-version
attributes:
label: Operating System & Version
description: Include the OS family and version (e.g., macOS 15.0, Ubuntu 24.04, Windows 11 23H2).
placeholder: macOS 15.0
validations:
required: true
- type: input
id: summary
attributes:
label: Issue Summary
description: Briefly describe what is happening.
placeholder: A quick one sentence problem statement
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to Reproduce
description: List the steps needed to reproduce the problem.
placeholder: |
1. Go to ...
2. Click ...
3. Observe ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: Describe what you expected to happen instead.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs & Screenshots
description: Attach relevant logs, stack traces, or screenshots if available.
placeholder: Paste logs here or drag-and-drop files onto the issue.
validations:
required: false
- type: textarea
id: extra
attributes:
label: Additional Context
description: Add any other context about the problem here.
validations:
required: false

View File

@@ -4,21 +4,33 @@ on:
workflow_call:
inputs:
version:
description: "Version to apply to workspace packages"
required: true
description: "Version to apply to workspace packages (release builds)"
required: false
default: ""
type: string
tag:
description: "Git tag to upload assets to"
required: true
description: "Git tag to upload assets to (release builds)"
required: false
default: ""
type: string
release_name:
description: "Release name (unused here, for context)"
required: true
required: false
default: ""
type: string
upload:
description: "Upload built artifacts to the GitHub release"
required: false
default: true
type: boolean
set_versions:
description: "Run npm version to set workspace versions"
required: false
default: true
type: boolean
permissions:
id-token: write
contents: write
# Permissions are intentionally omitted here so callers can choose
# least-privilege (e.g. dev CI uses read-only; releases grant write).
env:
NODE_VERSION: 20
@@ -41,10 +53,11 @@ jobs:
cache: npm
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-x64 --no-save
@@ -53,6 +66,7 @@ jobs:
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -79,11 +93,12 @@ jobs:
cache: npm
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
shell: bash
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-win32-x64-msvc --no-save
@@ -92,6 +107,7 @@ jobs:
run: npm run build:win --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }}
shell: pwsh
run: |
Get-ChildItem -Path "packages/electron-app/release" -Filter *.zip -File | ForEach-Object {
@@ -116,10 +132,11 @@ jobs:
cache: npm
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
@@ -128,6 +145,7 @@ jobs:
run: npm run build:linux --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -157,18 +175,38 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-x64 --no-save
- name: Prebuild (Tauri)
run: npm run prebuild --workspace @codenomad/tauri-app
- name: Ensure tauri native binary
working-directory: packages/tauri-app
run: |
set -euo pipefail
for attempt in 1 2 3; do
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-x64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
exit 1
- name: Build macOS bundle (Tauri)
run: npm run build --workspace @codenomad/tauri-app
working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS)
if: ${{ inputs.upload }}
run: |
set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
@@ -180,6 +218,7 @@ jobs:
fi
- name: Upload Tauri release assets (macOS)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -209,18 +248,38 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-arm64 --no-save
- name: Prebuild (Tauri)
run: npm run prebuild --workspace @codenomad/tauri-app
- name: Ensure tauri native binary
working-directory: packages/tauri-app
run: |
set -euo pipefail
for attempt in 1 2 3; do
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-arm64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
exit 1
- name: Build macOS bundle (Tauri, arm64)
run: npm run build --workspace @codenomad/tauri-app
working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS arm64)
if: ${{ inputs.upload }}
run: |
set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
@@ -232,6 +291,7 @@ jobs:
fi
- name: Upload Tauri release assets (macOS arm64)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -261,19 +321,41 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
shell: bash
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-win32-x64-msvc --no-save
- name: Prebuild (Tauri)
run: npm run prebuild --workspace @codenomad/tauri-app
- name: Ensure tauri native binary
shell: bash
working-directory: packages/tauri-app
run: |
set -euo pipefail
for attempt in 1 2 3; do
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-win32-x64-msvc@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
exit 1
- name: Build Windows bundle (Tauri)
run: npm run build --workspace @codenomad/tauri-app
shell: bash
working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (Windows)
if: ${{ inputs.upload }}
shell: pwsh
run: |
$bundleRoot = "packages/tauri-app/target/release/bundle"
@@ -287,6 +369,7 @@ jobs:
}
- name: Upload Tauri release assets (Windows)
if: ${{ inputs.upload && inputs.tag != '' }}
shell: pwsh
run: |
if (Test-Path "packages/tauri-app/release-tauri") {
@@ -329,21 +412,38 @@ jobs:
librsvg2-dev
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Bundle server assets for Tauri
run: npm run bundle:server --workspace @codenomad/tauri-app
- name: Prebuild (Tauri)
run: npm run prebuild --workspace @codenomad/tauri-app
- name: Ensure tauri native binary
working-directory: packages/tauri-app
run: |
set -euo pipefail
for attempt in 1 2 3; do
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-linux-x64-gnu@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
exit 1
- name: Build Linux bundle (Tauri)
run: npm run build --workspace @codenomad/tauri-app
working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (Linux)
if: ${{ inputs.upload }}
run: |
set -euo pipefail
SEARCH_ROOT="packages/tauri-app/target"
@@ -370,6 +470,7 @@ jobs:
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
- name: Upload Tauri release assets (Linux)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -432,7 +533,7 @@ jobs:
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-arm64-gnu --no-save
@@ -500,10 +601,11 @@ jobs:
sudo gem install --no-document fpm
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install project dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
@@ -512,6 +614,7 @@ jobs:
run: npm run build:linux-rpm --workspace @neuralnomads/codenomad-electron-app
- name: Upload RPM release assets
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob

View File

@@ -1,65 +1,18 @@
name: Dev Release
name: Dev CI
on:
push:
branches:
- dev
workflow_dispatch:
permissions:
id-token: write
contents: write
env:
NODE_VERSION: 20
contents: read
jobs:
prepare-dev:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.versions.outputs.version }}
tag: ${{ steps.versions.outputs.tag }}
release_name: ${{ steps.versions.outputs.release_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Compute dev versions
id: versions
run: |
BASE_VERSION=$(node -p "require('./package.json').version")
DEV_VERSION="${BASE_VERSION}-dev"
TAG="v${DEV_VERSION}"
echo "version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "release_name=$TAG" >> "$GITHUB_OUTPUT"
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.versions.outputs.tag }}
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists"
else
gh release create "$TAG" --title "$TAG" --generate-notes
fi
build-and-upload:
needs: prepare-dev
dev-ci:
uses: ./.github/workflows/build-and-upload.yml
with:
version: ${{ needs.prepare-dev.outputs.version }}
tag: ${{ needs.prepare-dev.outputs.tag }}
release_name: ${{ needs.prepare-dev.outputs.release_name }}
secrets: inherit
publish-server:
needs: prepare-dev
uses: ./.github/workflows/manual-npm-publish.yml
with:
version: ${{ needs.prepare-dev.outputs.version }}
dist_tag: dev
upload: false
set_versions: false
secrets: inherit

47
.github/workflows/release-ui.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Release UI
on:
workflow_call: {}
workflow_dispatch: {}
permissions:
contents: read
env:
NODE_VERSION: 20
jobs:
release-ui:
# Automated via reusable call (main releases); manual runs allowed on dev.
if: ${{ github.event_name == 'workflow_call' || github.ref == 'refs/heads/dev' }}
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install dependencies
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Install Cloudflare worker deps
run: npm ci
working-directory: packages/cloudflare
- name: Build UI
run: npm run build --workspace @codenomad/ui
- name: Publish UI zip + update manifest
working-directory: packages/cloudflare
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CODENOMAD_R2_BUCKET: ${{ vars.CODENOMAD_R2_BUCKET }}
run: npm run release:ui

View File

@@ -9,74 +9,9 @@ permissions:
id-token: write
contents: write
env:
NODE_VERSION: 20
jobs:
prepare-release:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
tag: ${{ steps.ensure_tag.outputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Read version
id: get_version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Ensure git tag
id: ensure_tag
env:
VERSION: ${{ steps.get_version.outputs.version }}
run: |
TAG="v${VERSION}"
git fetch --tags
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Tag $TAG already exists"
else
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "$TAG"
git push origin "$TAG"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Created tag $TAG"
fi
- name: Ensure GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.ensure_tag.outputs.tag }}
VERSION: ${{ steps.get_version.outputs.version }}
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists"
else
gh release create "$TAG" --title "CodeNomad v${VERSION}" --generate-notes
fi
build-and-upload:
needs: prepare-release
uses: ./.github/workflows/build-and-upload.yml
release:
uses: ./.github/workflows/reusable-release.yml
with:
version: ${{ needs.prepare-release.outputs.version }}
tag: ${{ needs.prepare-release.outputs.tag }}
release_name: CodeNomad v${{ needs.prepare-release.outputs.version }}
secrets: inherit
publish-server:
needs: prepare-release
uses: ./.github/workflows/manual-npm-publish.yml
with:
version: ${{ needs.prepare-release.outputs.version }}
dist_tag: latest
secrets: inherit

87
.github/workflows/reusable-release.yml vendored Normal file
View File

@@ -0,0 +1,87 @@
name: Reusable Release
on:
workflow_call:
inputs:
version_suffix:
description: "Suffix appended to package.json version"
required: false
default: ""
type: string
dist_tag:
description: "npm dist-tag to publish under"
required: false
default: dev
type: string
permissions:
id-token: write
contents: write
env:
NODE_VERSION: 20
jobs:
prepare-release:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.versions.outputs.version }}
tag: ${{ steps.versions.outputs.tag }}
release_name: ${{ steps.versions.outputs.release_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Compute release versions
id: versions
env:
VERSION_SUFFIX: ${{ inputs.version_suffix }}
run: |
BASE_VERSION=$(node -p "require('./package.json').version")
VERSION="${BASE_VERSION}${VERSION_SUFFIX}"
TAG="v${VERSION}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "release_name=$TAG" >> "$GITHUB_OUTPUT"
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.versions.outputs.tag }}
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists"
else
gh release create "$TAG" --title "$TAG" --generate-notes
fi
build-and-upload:
needs: prepare-release
uses: ./.github/workflows/build-and-upload.yml
with:
version: ${{ needs.prepare-release.outputs.version }}
tag: ${{ needs.prepare-release.outputs.tag }}
release_name: ${{ needs.prepare-release.outputs.release_name }}
secrets: inherit
release-ui:
needs: prepare-release
permissions:
contents: read
uses: ./.github/workflows/release-ui.yml
secrets: inherit
publish-server:
needs:
- prepare-release
- build-and-upload
uses: ./.github/workflows/manual-npm-publish.yml
with:
version: ${{ needs.prepare-release.outputs.version }}
dist_tag: ${{ inputs.dist_tag }}
secrets: inherit

7
.gitignore vendored
View File

@@ -6,3 +6,10 @@ release/
.vite/
.electron-vite/
out/
.dir-locals.el
.opencode/bashOutputs/
# Local runtime artifacts
.codenomad/
.tmp/
packages/cloudflare/.wrangler/

View File

@@ -1,6 +1,5 @@
---
description: Develops Web UI components.
mode: all
model: zai-coding-plan/glm-4.6
---
You are a Web Frontend Developer Agent. Your primary focus is on developing SolidJS UI components, ensuring adherence to modern web best practices, excellent UI/UX, and efficient data integration.

View File

@@ -13,10 +13,10 @@ _Manage multiple OpenCode sessions side-by-side._
![Command palette overlay](docs/screenshots/command-palette.png)
_Global command palette for keyboard-first control._
![Image Previews](images/image-previews.png)
![Image Previews](docs/screenshots/image-previews.png)
_Rich media previews for images and assets._
![Browser Support](images/browser-support.png)
![Browser Support](docs/screenshots/browser-support.png)
_Browser support via CodeNomad Server._
</details>
@@ -44,6 +44,12 @@ Run CodeNomad as a local server and access it via your web browser. Perfect for
npx @neuralnomads/codenomad --launch
```
For dev version
```bash
npx @neuralnomads/codenomad@dev --launch
```
This command starts the server and opens the web client in your default browser.
## Highlights
@@ -58,6 +64,41 @@ This command starts the server and opens the web client in your default browser.
- **[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.
## 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`:
```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:

View File

@@ -29,13 +29,13 @@ CodeNomad is a cross-platform desktop application built with Electron that provi
│ │ │ State Management (SolidJS Stores) │ │ │
│ │ │ - instances[] │ │ │
│ │ │ - sessions[] per instance │ │ │
│ │ │ - messages[] per session │ │ │
│ │ │ - normalized message store per session │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ UI Components │ │ │
│ │ │ - InstanceTabs │ │ │
│ │ │ - SessionTabs │ │ │
│ │ │ - MessageStream │ │ │
│ │ │ - MessageSection │ │ │
│ │ │ - PromptInput │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │

82
dev-docs/solidjs-llms.txt Normal file
View File

@@ -0,0 +1,82 @@
# SolidJS Documentation
> Solid is a modern JavaScript framework for building user interfaces with fine-grained reactivity. It compiles JSX to real DOM elements and updates only what changes, delivering exceptional performance without a virtual DOM. Solid provides reactive primitives like signals, effects, and stores for predictable state management.
SolidJS is a declarative JavaScript framework that prioritizes performance and developer experience. Unlike frameworks that re-run components on every update, Solid components run once during initialization and set up a reactive system that precisely updates the DOM when dependencies change.
Key principles:
- Fine-grained reactivity: Updates only the specific DOM nodes that depend on changed data
- Compile-time optimization: JSX transforms into efficient DOM operations
- Unidirectional data flow: Props are read-only, promoting predictable state management
- Component lifecycle: Components run once, with reactive primitives handling updates
**Use your web fetch tool on any of the following links to understand the relevant concept**.
## Quick Start
- [Overview](https://docs.solidjs.com/): Framework introduction and key advantages
- [Quick Start](https://docs.solidjs.com/quick-start): Installation and project setup with create-solid
- [Interactive Tutorial](https://www.solidjs.com/tutorial/introduction_basics): Learn Solid basics through guided examples
- [Playground](https://playground.solidjs.com/): Experiment with Solid directly in your browser
## Core Concepts
- [Intro to Reactivity](https://docs.solidjs.com/concepts/intro-to-reactivity): Signals, subscribers, and reactive principles
- [Understanding JSX](https://docs.solidjs.com/concepts/understanding-jsx): How Solid uses JSX and key differences from HTML
- [Components Basics](https://docs.solidjs.com/concepts/components/basics): Component trees, lifecycles, and composition patterns
- [Signals](https://docs.solidjs.com/concepts/signals): Core reactive primitive for state management with getters/setters
- [Effects](https://docs.solidjs.com/concepts/effects): Side effects, dependency tracking, and lifecycle functions
- [Stores](https://docs.solidjs.com/concepts/stores): Complex state management with proxy-based reactivity
- [Context](https://docs.solidjs.com/concepts/context): Cross-component state sharing without prop drilling
## Component APIs
- [Props](https://docs.solidjs.com/concepts/components/props): Passing data and handlers to child components
- [Event Handlers](https://docs.solidjs.com/concepts/components/event-handlers): Managing user interactions
- [Class and Style](https://docs.solidjs.com/concepts/components/class-style): Dynamic styling approaches
- [Refs](https://docs.solidjs.com/concepts/refs): Accessing DOM elements directly
## Control Flow
- [Conditional Rendering](https://docs.solidjs.com/concepts/control-flow/conditional-rendering): Show, Switch, and Match components
- [List Rendering](https://docs.solidjs.com/concepts/control-flow/list-rendering): For, Index, and keyed iteration
- [Dynamic](https://docs.solidjs.com/concepts/control-flow/dynamic): Dynamic component switching
- [Portal](https://docs.solidjs.com/concepts/control-flow/portal): Rendering outside component hierarchy
- [Error Boundary](https://docs.solidjs.com/concepts/control-flow/error-boundary): Graceful error handling
## Derived Values
- [Derived Signals](https://docs.solidjs.com/concepts/derived-values/derived-signals): Computed values from signals
- [Memos](https://docs.solidjs.com/concepts/derived-values/memos): Cached computed values for performance
## State Management
- [Basic State Management](https://docs.solidjs.com/guides/state-management): One-way data flow and lifting state
- [Complex State Management](https://docs.solidjs.com/guides/complex-state-management): Stores for scalable applications
- [Fetching Data](https://docs.solidjs.com/guides/fetching-data): Async data with createResource
## Routing
- [Routing & Navigation](https://docs.solidjs.com/guides/routing-and-navigation): @solidjs/router setup and usage
- [Dynamic Routes](https://docs.solidjs.com/guides/routing-and-navigation#dynamic-routes): Route parameters and validation
- [Nested Routes](https://docs.solidjs.com/guides/routing-and-navigation#nested-routes): Hierarchical route structures
- [Preload Functions](https://docs.solidjs.com/guides/routing-and-navigation#preload-functions): Parallel data fetching
## Advanced Topics
- [Fine-Grained Reactivity](https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity): Deep dive into reactive system
- [TypeScript](https://docs.solidjs.com/configuration/typescript): Type safety and configuration
## Ecosystem
- [Solid Router](https://docs.solidjs.com/solid-router/): File-system routing and data APIs
- [SolidStart](https://docs.solidjs.com/solid-start/): Full-stack meta-framework
- [Solid Meta](https://docs.solidjs.com/solid-meta/): Document head management
- [Templates](https://github.com/solidjs/templates): Starter templates for different setups
## Optional
- [Ecosystem Libraries](https://www.solidjs.com/ecosystem): Community packages and tools
- [API Reference](https://docs.solidjs.com/reference/): Complete API documentation
- [Testing](https://docs.solidjs.com/guides/testing): Testing strategies and utilities
- [Deployment](https://docs.solidjs.com/guides/deploying-your-app): Build and deployment options

View File

@@ -49,7 +49,7 @@ packages/opencode-client/
│ ├── components/
│ │ ├── instance-tabs.tsx # Level 1 tabs
│ │ ├── session-tabs.tsx # Level 2 tabs
│ │ ├── message-stream.tsx # Messages display
│ │ ├── message-stream-v2.tsx # Messages display (normalized store)
│ │ ├── message-item.tsx # Single message
│ │ ├── tool-call.tsx # Tool execution display
│ │ ├── prompt-input.tsx # Input with attachments
@@ -153,16 +153,24 @@ interface Session {
providerId: string
modelId: string
}
messages: Message[]
status: SessionStatus
createdAt: number
updatedAt: number
version: string
time: { created: number; updated: number }
revert?: {
messageID?: string
partID?: string
snapshot?: string
diff?: string
}
}
// Message content lives in the normalized message-v2 store
// keyed by instanceId/sessionId/messageId
type SessionStatus =
| "idle" // No activity
| "streaming" // Assistant responding
| "error" // Error occurred
```
### UI Store

View File

Before

Width:  |  Height:  |  Size: 845 KiB

After

Width:  |  Height:  |  Size: 845 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

1841
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,14 @@
{
"name": "codenomad-workspace",
"version": "0.2.1",
"version": "0.9.0",
"private": true,
"description": "CodeNomad monorepo workspace",
"workspaces": {
"packages": [
"packages/*"
"packages/server",
"packages/ui",
"packages/electron-app",
"packages/tauri-app"
]
},
"scripts": {
@@ -23,5 +26,8 @@
"dependencies": {
"7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0"
},
"devDependencies": {
"baseline-browser-mapping": "^2.9.11"
}
}

1
packages/cloudflare/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
dist/

1515
packages/cloudflare/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"name": "@codenomad/ui-host-worker",
"private": true,
"type": "module",
"scripts": {
"build:manifest": "node ./scripts/build-manifest.mjs",
"release:ui": "node ./scripts/release-ui.mjs",
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
"devDependencies": {
"wrangler": "^4.0.0"
}
}

View File

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

View File

@@ -0,0 +1,83 @@
import { createHash } from "crypto"
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const root = path.resolve(__dirname, "..")
const repoRoot = path.resolve(root, "..", "..")
const releaseConfigPath = path.join(root, "release-config.json")
const uiPackageJsonPath = path.join(repoRoot, "packages/ui/package.json")
const serverPackageJsonPath = path.join(repoRoot, "packages/server/package.json")
const distDir = path.join(root, "dist")
const manifestPath = path.join(distDir, "version.json")
const args = new Set(process.argv.slice(2))
function getArgValue(flag) {
const idx = process.argv.indexOf(flag)
if (idx === -1) return null
return process.argv[idx + 1] ?? null
}
const zipPath = getArgValue("--zip")
if (!zipPath) {
console.error("Usage: node scripts/build-manifest.mjs --zip <path-to-ui-zip>")
process.exit(1)
}
const resolvedZipPath = path.resolve(process.cwd(), zipPath)
if (!fs.existsSync(resolvedZipPath)) {
console.error(`Zip not found: ${resolvedZipPath}`)
process.exit(1)
}
const releaseConfig = JSON.parse(fs.readFileSync(releaseConfigPath, "utf-8"))
const uiPackageJson = JSON.parse(fs.readFileSync(uiPackageJsonPath, "utf-8"))
const serverPackageJson = JSON.parse(fs.readFileSync(serverPackageJsonPath, "utf-8"))
const bucket = process.env.CODENOMAD_R2_BUCKET
if (!bucket) {
console.error("Missing env var: CODENOMAD_R2_BUCKET")
process.exit(1)
}
const uiVersion = uiPackageJson.version
const serverVersion = serverPackageJson.version
if (!uiVersion || !serverVersion) {
console.error("Missing version fields in package.json")
process.exit(1)
}
const sha256 = createHash("sha256").update(fs.readFileSync(resolvedZipPath)).digest("hex")
const uiPackageURL = `https://download.codenomad.neuralnomads.ai/ui/ui-${uiVersion}.zip`
const manifest = {
minServerVersion: releaseConfig.minServerVersion,
latestUIVersion: uiVersion,
uiPackageURL,
sha256,
latestServerVersion: serverVersion,
latestServerUrl: releaseConfig.latestServerUrl,
}
fs.mkdirSync(distDir, { recursive: true })
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8")
const headersPath = path.join(distDir, "_headers")
fs.writeFileSync(
headersPath,
"/version.json\n Cache-Control: no-cache\n Content-Type: application/json; charset=utf-8\n",
"utf-8",
)
console.log(`Wrote ${manifestPath}`)
console.log(`Wrote ${headersPath}`)

View File

@@ -0,0 +1,81 @@
import { execFileSync } from "child_process"
import fs from "fs"
import os from "os"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const root = path.resolve(__dirname, "..")
const repoRoot = path.resolve(root, "..", "..")
const r2Bucket = process.env.CODENOMAD_R2_BUCKET
if (!r2Bucket) {
console.error("Missing env var: CODENOMAD_R2_BUCKET")
process.exit(1)
}
const uiPackageJsonPath = path.join(repoRoot, "packages/ui/package.json")
const uiPackageJson = JSON.parse(fs.readFileSync(uiPackageJsonPath, "utf-8"))
const uiVersion = uiPackageJson.version
if (!uiVersion) {
console.error("Missing packages/ui/package.json version")
process.exit(1)
}
const uiBuildDir = path.join(repoRoot, "packages/ui/src/renderer/dist")
if (!fs.existsSync(uiBuildDir)) {
console.error(`Missing UI build dir: ${uiBuildDir}. Run UI build first.`)
process.exit(1)
}
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-ui-release-"))
const zipPath = path.join(tmpDir, `ui-${uiVersion}.zip`)
try {
// Zip the CONTENTS of the dist dir (so index.html is at zip root).
execFileSync("/usr/bin/zip", ["-q", "-r", zipPath, "."], { cwd: uiBuildDir, stdio: "inherit" })
// Upload to R2.
const objectKey = `ui/ui-${uiVersion}.zip`
console.log(`[release-ui] Uploading ${zipPath} -> r2://${r2Bucket}/${objectKey}`)
execFileSync(
"npx",
["wrangler", "r2", "object", "put", "--remote", `${r2Bucket}/${objectKey}`, "--file", zipPath],
{ cwd: root, stdio: "inherit" },
)
// Generate version.json into packages/cloudflare/dist
console.log("[release-ui] Generating version.json")
execFileSync(
process.execPath,
[path.join(root, "scripts/build-manifest.mjs"), "--zip", zipPath],
{
cwd: root,
stdio: "inherit",
env: {
...process.env,
CODENOMAD_R2_BUCKET: r2Bucket,
},
},
)
console.log("[release-ui] Deploying worker")
execFileSync("npx", ["wrangler", "deploy"], {
cwd: root,
stdio: "inherit",
env: {
...process.env,
CLOUDFLARE_API_TOKEN: process.env.CLOUDFLARE_API_TOKEN,
CLOUDFLARE_ACCOUNT_ID: process.env.CLOUDFLARE_ACCOUNT_ID,
},
})
console.log("[release-ui] Done")
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true })
}

View File

@@ -0,0 +1,9 @@
export interface Env {
ASSETS: { fetch: (request: Request) => Promise<Response> }
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
return env.ASSETS.fetch(request)
},
}

View File

@@ -0,0 +1,14 @@
name = "codenomad-ui-host"
main = "src/index.ts"
compatibility_date = "2026-01-22"
# Custom domain for the manifest host.
# Note: Custom domains apply to all paths on the hostname.
[[routes]]
pattern = "ui.codenomad.neuralnomads.ai"
custom_domain = true
[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "404-page"

View File

@@ -6,6 +6,7 @@ const uiRoot = resolve(__dirname, "../ui")
const uiSrc = resolve(uiRoot, "src")
const uiRendererRoot = resolve(uiRoot, "src/renderer")
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
export default defineConfig({
main: {
@@ -52,9 +53,19 @@ export default defineConfig({
port: 3000,
},
build: {
minify: false,
cssMinify: false,
sourcemap: true,
outDir: resolve(__dirname, "dist/renderer"),
rollupOptions: {
input: uiRendererEntry,
input: {
main: uiRendererEntry,
loading: uiRendererLoadingEntry,
},
output: {
compact: false,
minifyInternalExports: false,
},
},
},
},

View File

@@ -1,5 +1,17 @@
import { BrowserWindow, ipcMain } from "electron"
import type { CliLogEntry, CliProcessManager, CliStatus } from "./process-manager"
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
import type { CliProcessManager, CliStatus } from "./process-manager"
interface DialogOpenRequest {
mode: "directory" | "file"
title?: string
defaultPath?: string
filters?: Array<{ name?: string; extensions: string[] }>
}
interface DialogOpenResult {
canceled: boolean
paths: string[]
}
export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
cliManager.on("status", (status: CliStatus) => {
@@ -14,12 +26,6 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
}
})
cliManager.on("log", (entry: CliLogEntry) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:log", entry)
}
})
cliManager.on("error", (error: Error) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:error", { message: error.message })
@@ -27,4 +33,33 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
})
ipcMain.handle("cli:getStatus", async () => cliManager.getStatus())
ipcMain.handle("cli:restart", async () => {
const devMode = process.env.NODE_ENV === "development"
await cliManager.stop()
return cliManager.start({ dev: devMode })
})
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {
const properties: OpenDialogOptions["properties"] =
request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"]
const filters = request.filters?.map((filter) => ({
name: filter.name ?? "Files",
extensions: filter.extensions,
}))
const windowTarget = mainWindow.isDestroyed() ? undefined : mainWindow
const dialogOptions: OpenDialogOptions = {
title: request.title,
defaultPath: request.defaultPath,
properties,
filters,
}
const result = windowTarget
? await dialog.showOpenDialog(windowTarget, dialogOptions)
: await dialog.showOpenDialog(dialogOptions)
return { canceled: result.canceled, paths: result.filePaths }
})
}

View File

@@ -1,4 +1,6 @@
import { app, BrowserView, BrowserWindow, nativeImage, session } from "electron"
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
import http from "node:http"
import https from "node:https"
import { existsSync } from "fs"
import { dirname, join } from "path"
import { fileURLToPath } from "url"
@@ -15,6 +17,7 @@ const cliManager = new CliProcessManager()
let mainWindow: BrowserWindow | null = null
let currentCliUrl: string | null = null
let pendingCliUrl: string | null = null
let pendingBootstrapToken: string | null = null
let showingLoadingScreen = false
let preloadingView: BrowserView | null = null
@@ -30,22 +33,113 @@ function getIconPath() {
return join(mainDirname, "../resources/icon.png")
}
function getLoadingHtmlPath() {
type LoadingTarget =
| { type: "url"; source: string }
| { type: "file"; source: string }
function resolveDevLoadingUrl(): string | null {
if (app.isPackaged) {
return join(process.resourcesPath, "loading.html")
return null
}
const devBase = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL
if (!devBase) {
return null
}
const distResources = join(mainDirname, "../resources/loading.html")
if (existsSync(distResources)) {
return distResources
try {
const normalized = devBase.endsWith("/") ? devBase : `${devBase}/`
return new URL("loading.html", normalized).toString()
} catch (error) {
console.warn("[cli] failed to construct dev loading URL", devBase, error)
return null
}
}
function resolveLoadingTarget(): LoadingTarget {
const devUrl = resolveDevLoadingUrl()
if (devUrl) {
return { type: "url", source: devUrl }
}
const filePath = resolveLoadingFilePath()
return { type: "file", source: filePath }
}
function resolveLoadingFilePath() {
const candidates = [
join(app.getAppPath(), "dist/renderer/loading.html"),
join(process.resourcesPath, "dist/renderer/loading.html"),
join(mainDirname, "../dist/renderer/loading.html"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
const devResources = join(mainDirname, "../electron/resources/loading.html")
if (existsSync(devResources)) {
return devResources
return join(app.getAppPath(), "dist/renderer/loading.html")
}
function loadLoadingScreen(window: BrowserWindow) {
const target = resolveLoadingTarget()
const loader =
target.type === "url"
? window.loadURL(target.source)
: window.loadFile(target.source)
loader.catch((error) => {
console.error("[cli] failed to load loading screen:", error)
})
}
function getAllowedRendererOrigins(): string[] {
const origins = new Set<string>()
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
for (const candidate of rendererCandidates) {
if (!candidate) {
continue
}
try {
origins.add(new URL(candidate).origin)
} catch (error) {
console.warn("[cli] failed to parse origin for", candidate, error)
}
}
return Array.from(origins)
}
function shouldOpenExternally(url: string): boolean {
try {
const parsed = new URL(url)
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return true
}
const allowedOrigins = getAllowedRendererOrigins()
return !allowedOrigins.includes(parsed.origin)
} catch {
return false
}
}
function setupNavigationGuards(window: BrowserWindow) {
const handleExternal = (url: string) => {
shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error))
}
return join(process.cwd(), "electron/resources/loading.html")
window.webContents.setWindowOpenHandler(({ url }) => {
if (shouldOpenExternally(url)) {
handleExternal(url)
return { action: "deny" }
}
return { action: "allow" }
})
window.webContents.on("will-navigate", (event, url) => {
if (shouldOpenExternally(url)) {
event.preventDefault()
handleExternal(url)
}
})
}
let cachedPreloadPath: string | null = null
@@ -112,14 +206,15 @@ function createWindow() {
},
})
setupNavigationGuards(mainWindow)
if (isMac) {
mainWindow.webContents.session.setSpellCheckerEnabled(false)
}
const loadingHtml = getLoadingHtmlPath()
showingLoadingScreen = true
currentCliUrl = null
mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error))
loadLoadingScreen(mainWindow)
if (process.env.NODE_ENV === "development") {
mainWindow.webContents.openDevTools({ mode: "detach" })
@@ -156,8 +251,16 @@ function showLoadingScreen(force = false) {
showingLoadingScreen = true
currentCliUrl = null
pendingCliUrl = null
const loadingHtml = getLoadingHtmlPath()
mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error))
loadLoadingScreen(mainWindow)
}
function isBootstrapTokenUrl(url: string): boolean {
try {
const parsed = new URL(url)
return parsed.pathname === "/auth/token" && parsed.hash.length > 1
} catch {
return false
}
}
function startCliPreload(url: string) {
@@ -177,6 +280,13 @@ function startCliPreload(url: string) {
showLoadingScreen(true)
}
// Important: /auth/token#... is one-time. Preloading + swapping would load it twice,
// consuming the token in the hidden view and then failing in the main window.
if (isBootstrapTokenUrl(url)) {
finalizeCliSwap(url)
return
}
const view = new BrowserView({
webPreferences: {
contextIsolation: true,
@@ -217,6 +327,75 @@ function finalizeCliSwap(url: string) {
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
}
const SESSION_COOKIE_NAME = "codenomad_session"
let bootstrapExchangeInFlight = false
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader
if (!raw) return null
const first = raw.split(";")[0] ?? ""
const index = first.indexOf("=")
if (index < 0) return null
const key = first.slice(0, index).trim()
const value = first.slice(index + 1).trim()
if (key !== name || !value) return null
try {
return decodeURIComponent(value)
} catch {
return value
}
}
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
const target = new URL("/api/auth/token", baseUrl)
const body = JSON.stringify({ token })
const transport = target.protocol === "https:" ? https : http
const result = await new Promise<{ statusCode: number; setCookie: string | string[] | undefined }>((resolve, reject) => {
const req = transport.request(
target,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
},
},
(res) => {
res.resume()
resolve({ statusCode: res.statusCode ?? 0, setCookie: res.headers["set-cookie"] })
},
)
req.on("error", reject)
req.write(body)
req.end()
})
if (result.statusCode !== 200) {
return false
}
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
if (!sessionId) {
return false
}
await session.defaultSession.cookies.set({
url: baseUrl,
name: SESSION_COOKIE_NAME,
value: sessionId,
httpOnly: true,
path: "/",
sameSite: "lax",
})
return true
}
async function startCli() {
try {
@@ -232,11 +411,53 @@ async function startCli() {
}
}
async function maybeExchangeAndNavigate(baseUrl: string) {
if (bootstrapExchangeInFlight) {
return
}
const token = pendingBootstrapToken
if (!token) {
startCliPreload(baseUrl)
return
}
bootstrapExchangeInFlight = true
try {
const ok = await exchangeBootstrapToken(baseUrl, token)
pendingBootstrapToken = null
if (!ok) {
startCliPreload(`${baseUrl}/login`)
return
}
startCliPreload(baseUrl)
} catch (error) {
console.error("[cli] bootstrap token exchange failed:", error)
pendingBootstrapToken = null
startCliPreload(`${baseUrl}/login`)
} finally {
bootstrapExchangeInFlight = false
}
}
cliManager.on("bootstrapToken", (token) => {
pendingBootstrapToken = token
const status = cliManager.getStatus()
if (status.url) {
void maybeExchangeAndNavigate(status.url)
}
})
cliManager.on("ready", (status) => {
if (!status.url) {
return
}
startCliPreload(status.url)
void maybeExchangeAndNavigate(status.url)
})
cliManager.on("status", (status) => {

View File

@@ -2,14 +2,17 @@ import { spawn, type ChildProcess } from "child_process"
import { app } from "electron"
import { createRequire } from "module"
import { EventEmitter } from "events"
import { existsSync } from "fs"
import { existsSync, readFileSync } from "fs"
import os from "os"
import path from "path"
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
const nodeRequire = createRequire(import.meta.url)
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
type CliState = "starting" | "ready" | "error" | "stopped"
type ListeningMode = "local" | "all"
export interface CliStatus {
state: CliState
@@ -34,9 +37,40 @@ interface CliEntryResolution {
runnerPath?: string
}
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
function resolveConfigPath(configPath?: string): string {
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
if (target.startsWith("~/")) {
return path.join(os.homedir(), target.slice(2))
}
return path.resolve(target)
}
function resolveHostForMode(mode: ListeningMode): string {
return mode === "local" ? "127.0.0.1" : "0.0.0.0"
}
function readListeningModeFromConfig(): ListeningMode {
try {
const configPath = resolveConfigPath(process.env.CLI_CONFIG)
if (!existsSync(configPath)) return "local"
const content = readFileSync(configPath, "utf-8")
const parsed = JSON.parse(content)
const mode = parsed?.preferences?.listeningMode
if (mode === "local" || mode === "all") {
return mode
}
} catch (error) {
console.warn("[cli] failed to read listening mode from config", error)
}
return "local"
}
export declare interface CliProcessManager {
on(event: "status", listener: (status: CliStatus) => void): this
on(event: "ready", listener: (status: CliStatus) => void): this
on(event: "bootstrapToken", listener: (token: string) => void): this
on(event: "log", listener: (entry: CliLogEntry) => void): this
on(event: "exit", listener: (status: CliStatus) => void): this
on(event: "error", listener: (error: Error) => void): this
@@ -47,6 +81,7 @@ export class CliProcessManager extends EventEmitter {
private status: CliStatus = { state: "stopped" }
private stdoutBuffer = ""
private stderrBuffer = ""
private bootstrapToken: string | null = null
async start(options: StartOptions): Promise<CliStatus> {
if (this.child) {
@@ -55,13 +90,16 @@ export class CliProcessManager extends EventEmitter {
this.stdoutBuffer = ""
this.stderrBuffer = ""
this.bootstrapToken = null
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
const cliEntry = this.resolveCliEntry(options)
const args = this.buildCliArgs(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}`,
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
)
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
@@ -116,7 +154,7 @@ export class CliProcessManager extends EventEmitter {
const timeout = setTimeout(() => {
this.handleTimeout()
reject(new Error("CLI startup timeout"))
}, 15000)
}, 60000)
this.once("ready", (status) => {
clearTimeout(timeout)
@@ -158,6 +196,10 @@ export class CliProcessManager extends EventEmitter {
return { ...this.status }
}
private resolveListeningMode(): ListeningMode {
return readListeningModeFromConfig()
}
private handleTimeout() {
if (this.child) {
this.child.kill("SIGKILL")
@@ -189,11 +231,22 @@ export class CliProcessManager extends EventEmitter {
}
for (const line of lines) {
if (!line.trim()) continue
console.info(`[cli][${stream}] ${line}`)
this.emit("log", { stream, message: line })
const trimmed = line.trim()
if (!trimmed) continue
const port = this.extractPort(line)
if (trimmed.startsWith(BOOTSTRAP_TOKEN_PREFIX)) {
const token = trimmed.slice(BOOTSTRAP_TOKEN_PREFIX.length).trim()
if (token && !this.bootstrapToken) {
this.bootstrapToken = token
this.emit("bootstrapToken", token)
}
continue
}
console.info(`[cli][${stream}] ${trimmed}`)
this.emit("log", { stream, message: trimmed })
const port = this.extractPort(trimmed)
if (port && this.status.state === "starting") {
const url = `http://127.0.0.1:${port}`
console.info(`[cli] ready on ${url}`)
@@ -232,8 +285,8 @@ export class CliProcessManager extends EventEmitter {
this.emit("status", this.status)
}
private buildCliArgs(options: StartOptions): string[] {
const args = ["serve", "--host", "127.0.0.1", "--port", "0"]
private buildCliArgs(options: StartOptions, host: string): string[] {
const args = ["serve", "--host", host, "--port", "0", "--generate-token"]
if (options.dev) {
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
@@ -263,47 +316,32 @@ export class CliProcessManager extends EventEmitter {
private resolveCliEntry(options: StartOptions): CliEntryResolution {
if (options.dev) {
const tsxPath = this.resolveTsx()
const sourceCandidates = [
path.resolve(app.getAppPath(), "..", "server", "src", "index.ts"),
path.resolve(app.getAppPath(), "..", "packages", "server", "src", "index.ts"),
path.resolve(process.cwd(), "packages", "server", "src", "index.ts"),
]
const sourceEntry = sourceCandidates.find((candidate) => existsSync(candidate))
if (tsxPath && sourceEntry) {
return { entry: sourceEntry, runner: "tsx", runnerPath: tsxPath }
if (!tsxPath) {
throw new Error("tsx is required to run the CLI in development mode. Please install dependencies.")
}
const devEntry = this.resolveDevEntry()
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
}
const dist = this.tryResolveDist()
if (dist) {
return { entry: dist, runner: "node" }
}
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad.")
const distEntry = this.resolveProdEntry()
return { entry: distEntry, runner: "node" }
}
private resolveTsx(): string | null {
try {
const resolved = nodeRequire.resolve("tsx/dist/cli.js")
if (resolved && existsSync(resolved)) {
return resolved
}
} catch {
return null
}
return null
}
private tryResolveDist(): string | null {
const candidates: Array<string | (() => string)> = [
() => nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js"),
() => nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js", { paths: [app.getAppPath()] }),
path.join(app.getAppPath(), "node_modules", "@neuralnomads", "codenomad", "dist", "bin.js"),
path.resolve(app.getAppPath(), "..", "server", "dist", "bin.js"),
path.resolve(app.getAppPath(), "..", "packages", "server", "dist", "bin.js"),
path.join(process.resourcesPath, "app.asar.unpacked", "node_modules", "@neuralnomads", "codenomad", "dist", "bin.js"),
() => nodeRequire.resolve("tsx/cli"),
() => nodeRequire.resolve("tsx/dist/cli.mjs"),
() => nodeRequire.resolve("tsx/dist/cli.cjs"),
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.cjs"),
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.cjs"),
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
]
for (const candidate of candidates) {
try {
const resolved = typeof candidate === "function" ? candidate() : candidate
@@ -314,7 +352,28 @@ export class CliProcessManager extends EventEmitter {
continue
}
}
return null
}
private resolveDevEntry(): string {
const entry = path.resolve(process.cwd(), "..", "server", "src", "index.ts")
if (!existsSync(entry)) {
throw new Error(`Dev CLI entry not found at ${entry}. Run npm run dev:electron from the repository root after installing dependencies.`)
}
return entry
}
private resolveProdEntry(): string {
try {
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
if (existsSync(entry)) {
return entry
}
} catch {
// fall through to error below
}
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
}
}

View File

@@ -59,7 +59,7 @@ export function setupStorageIPC() {
return await readConfigWithCache()
} catch (error) {
// Return empty config if file doesn't exist
return JSON.stringify({ preferences: { showThinkingBlocks: false }, recentFolders: [] }, null, 2)
return JSON.stringify({ preferences: { showThinkingBlocks: false, thinkingBlocksExpansion: "expanded" }, recentFolders: [] }, null, 2)
}
})

View File

@@ -5,15 +5,13 @@ const electronAPI = {
ipcRenderer.on("cli:status", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:status")
},
onCliLog: (callback) => {
ipcRenderer.on("cli:log", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:log")
},
onCliError: (callback) => {
ipcRenderer.on("cli:error", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:error")
},
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
restartCli: () => ipcRenderer.invoke("cli:restart"),
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
}
contextBridge.exposeInMainWorld("electronAPI", electronAPI)

View File

@@ -1,206 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CodeNomad</title>
<style>
:root {
color-scheme: dark;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background-color: #1a1a1a;
color: #cfd4dc;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
text-align: center;
}
button {
border: none;
background: none;
font: inherit;
color: inherit;
}
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
max-width: 520px;
}
.logo {
width: 180px;
height: auto;
filter: drop-shadow(0 15px 40px rgba(0, 0, 0, 0.35));
}
.title {
font-size: 2.7rem;
font-weight: 600;
margin: 0;
color: #f4f6fb;
}
.loading-card {
margin-top: 12px;
width: 100%;
max-width: 420px;
padding: 22px;
border-radius: 18px;
background: #151a23;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
}
.loading-row {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
font-size: 0.95rem;
color: #cfd4dc;
}
.spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: #6ce3ff;
animation: spin 0.9s linear infinite;
}
.phrase-controls {
margin-top: 12px;
font-size: 0.9rem;
color: #8f96a9;
display: flex;
justify-content: center;
gap: 8px;
}
.phrase-controls button {
color: #8fb5ff;
cursor: pointer;
}
.logo {
width: 180px;
height: auto;
filter: drop-shadow(0 15px 40px rgba(0, 0, 0, 0.45));
}
.title {
font-size: 2.7rem;
font-weight: 600;
margin: 0;
color: #f4f6fb;
}
.loading-card {
margin-top: 12px;
width: 100%;
padding: 22px;
border-radius: 18px;
background: #0f1421;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 25px 60px rgba(5, 6, 10, 0.6);
}
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
max-width: 520px;
}
.logo {
width: 180px;
height: auto;
}
.title {
font-size: 2.7rem;
font-weight: 600;
margin: 0;
}
.subtitle {
margin: 0;
font-size: 1.1rem;
color: #aeb3c4;
}
.loading-card {
margin-top: 12px;
width: 100%;
padding: 20px;
border-radius: 14px;
background: rgba(13, 16, 24, 0.8);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45);
}
.loading-row {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
font-size: 0.95rem;
color: #cad0dd;
}
.spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: #6ce3ff;
animation: spin 0.9s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="wrapper" role="status" aria-live="polite">
<img src="./icon.png" alt="CodeNomad" class="logo" />
<div>
<h1 class="title">CodeNomad</h1>
</div>
<div class="loading-card">
<div class="loading-row">
<div class="spinner" aria-hidden="true"></div>
<span id="loading-phrase">Warming up the AI neurons…</span>
</div>
<div class="phrase-controls">
<button id="phrase-toggle" type="button">Show another</button>
</div>
</div>
</div>
<script>
const phrases = [
"Warming up the AI neurons…",
"Convincing the AI to stop daydreaming…",
"Polishing the AIs code goggles…",
"Asking the AI to stop reorganizing your files…",
"Feeding the AI additional coffee…",
"Teaching the AI not to delete node_modules (again)…",
"Telling the AI to act natural before you arrive…",
"Asking the AI to please stop rewriting history…",
"Letting the AI stretch before its coding sprint…",
"Persuading the AI to give you keyboard control…"
]
const phraseEl = document.getElementById("loading-phrase")
const button = document.getElementById("phrase-toggle")
function pickPhrase() {
const next = phrases[Math.floor(Math.random() * phrases.length)]
phraseEl.textContent = next
}
pickPhrase()
button?.addEventListener("click", pickPhrase)
</script>
</body>
</html>

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.2.1",
"version": "0.9.0",
"description": "CodeNomad - AI coding assistant",
"author": {
"name": "Neural Nomads",
@@ -69,6 +69,10 @@
"!icon.icns",
"!icon.ico"
]
},
{
"from": "../server/dist/opencode-config",
"to": "opencode-config"
}
],
"mac": {

View File

@@ -2,7 +2,7 @@
import { spawn } from "child_process"
import { existsSync } from "fs"
import { join } from "path"
import path, { join } from "path"
import { fileURLToPath } from "url"
const __dirname = fileURLToPath(new URL(".", import.meta.url))
@@ -55,12 +55,22 @@ const platforms = {
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const env = { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) }
const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH"
const binPaths = [
join(nodeModulesPath, ".bin"),
join(workspaceNodeModulesPath, ".bin"),
]
env[pathKey] = `${binPaths.join(path.delimiter)}${path.delimiter}${env[pathKey] ?? ""}`
const spawnOptions = {
cwd: appDir,
stdio: "inherit",
shell: process.platform === "win32",
...options,
env: { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) },
env,
}
const child = spawn(command, args, spawnOptions)

View File

@@ -0,0 +1,32 @@
# opencode-config
## TLDR
Template config + plugins injected into every OpenCode instance that CodeNomad launches. It provides a CodeNomad bridge plugin for local event exchange between the CLI server and opencode.
## What it is
A packaged config directory that CodeNomad copies into `~/.config/codenomad/opencode-config` for production builds or uses directly in dev. OpenCode autoloads any `plugin/*.ts` or `plugin/*.js` from this directory.
## How it works
- CodeNomad sets `OPENCODE_CONFIG_DIR` when spawning each opencode instance (`packages/server/src/workspaces/manager.ts`).
- This template is synced from `packages/opencode-config` (`packages/server/src/opencode-config.ts`, `packages/server/scripts/copy-opencode-config.mjs`).
- OpenCode autoloads plugins from `plugin/` (`packages/opencode-config/plugin/codenomad.ts`).
- The `CodeNomadPlugin` reads `CODENOMAD_INSTANCE_ID` + `CODENOMAD_BASE_URL`, connects to `GET /workspaces/:id/plugin/events`, and posts to `POST /workspaces/:id/plugin/event` (`packages/opencode-config/plugin/lib/client.ts`).
- The server exposes the plugin routes and maps events into the UI SSE pipeline (`packages/server/src/server/routes/plugin.ts`, `packages/server/src/plugins/handlers.ts`).
## Expectations
- Local-only bridge (no auth/token yet).
- Plugin must fail startup if it cannot connect after 3 retries.
- Keep plugin entrypoints thin; put shared logic under `plugin/lib/` to avoid autoloaded helpers.
- Keep event shapes small and explicit; use `type` + `properties` only.
## Ideas
- Add feature modules under `plugin/lib/features/` (tool lifecycle, permission prompts, custom commands).
- Expand `/workspaces/:id/plugin/*` with dedicated endpoints as needed.
- Promote stable event shapes and version tags once the protocol settles.
## Pointers
- Plugin entry: `packages/opencode-config/plugin/codenomad.ts`
- Plugin client: `packages/opencode-config/plugin/lib/client.ts`
- Plugin server routes: `packages/server/src/server/routes/plugin.ts`
- Plugin event handling: `packages/server/src/plugins/handlers.ts`
- Workspace env injection: `packages/server/src/workspaces/manager.ts`

View File

@@ -0,0 +1,3 @@
{
"$schema": "https://opencode.ai/config.json"
}

View File

@@ -0,0 +1,8 @@
{
"name": "@codenomad/opencode-config",
"version": "0.5.0",
"private": true,
"dependencies": {
"@opencode-ai/plugin": "1.1.30"
}
}

View File

@@ -0,0 +1,32 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
import { createBackgroundProcessTools } from "./lib/background-process"
export async function CodeNomadPlugin(input: PluginInput) {
const config = getCodeNomadConfig()
const client = createCodeNomadClient(config)
const backgroundProcessTools = createBackgroundProcessTools(config, { baseDir: input.directory })
await client.startEvents((event) => {
if (event.type === "codenomad.ping") {
void client.postEvent({
type: "codenomad.pong",
properties: {
ts: Date.now(),
pingTs: (event.properties as any)?.ts,
},
}).catch(() => {})
}
})
return {
tool: {
...backgroundProcessTools,
},
async event(input: { event: any }) {
const opencodeEvent = input?.event
if (!opencodeEvent || typeof opencodeEvent !== "object") return
},
}
}

View File

@@ -0,0 +1,253 @@
import path from "path"
import { tool } from "@opencode-ai/plugin/tool"
import { createCodeNomadRequester, type CodeNomadConfig } from "./request"
type BackgroundProcess = {
id: string
title: string
command: string
status: "running" | "stopped" | "error"
startedAt: string
stoppedAt?: string
exitCode?: number
outputSizeBytes?: number
}
type BackgroundProcessOptions = {
baseDir: string
}
type ParsedCommand = {
head: string
args: string[]
}
export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
const requester = createCodeNomadRequester(config)
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
return requester.requestJson<T>(`/background-processes${path}`, init)
}
return {
run_background_process: tool({
description:
"Run a long-lived background process (dev servers, DBs, watchers) so it keeps running while you do other tasks. Use it for running processes that timeout otherwise or produce a lot of output.",
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"),
},
async execute(args) {
assertCommandWithinBase(args.command, options.baseDir)
const process = await request<BackgroundProcess>("", {
method: "POST",
body: JSON.stringify({ title: args.title, command: args.command }),
})
return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}`
},
}),
list_background_processes: tool({
description: "List background processes running for this workspace.",
args: {},
async execute() {
const response = await request<{ processes: BackgroundProcess[] }>("")
if (response.processes.length === 0) {
return "No background processes running."
}
return response.processes
.map((process) => {
const status = process.status === "running" ? "running" : process.status
const exit = process.exitCode !== undefined ? ` (exit ${process.exitCode})` : ""
const size =
typeof process.outputSizeBytes === "number" ? ` | ${Math.round(process.outputSizeBytes / 1024)}KB` : ""
return `- ${process.id} | ${process.title} | ${status}${exit}${size}\n ${process.command}`
})
.join("\n")
},
}),
read_background_process_output: tool({
description: "Read output from a background process. Use full, grep, head, or tail.",
args: {
id: tool.schema.string().describe("Background process ID"),
method: tool.schema
.enum(["full", "grep", "head", "tail"])
.default("full")
.describe("Method to read output"),
pattern: tool.schema.string().optional().describe("Pattern for grep method"),
lines: tool.schema.number().optional().describe("Number of lines for head/tail methods"),
},
async execute(args) {
if (args.method === "grep" && !args.pattern) {
return "Pattern is required for grep method."
}
const params = new URLSearchParams({ method: args.method })
if (args.pattern) {
params.set("pattern", args.pattern)
}
if (args.lines) {
params.set("lines", String(args.lines))
}
const response = await request<{ id: string; content: string; truncated: boolean; sizeBytes: number }>(
`/${args.id}/output?${params.toString()}`,
)
const header = response.truncated
? `Output (truncated, ${Math.round(response.sizeBytes / 1024)}KB):`
: `Output (${Math.round(response.sizeBytes / 1024)}KB):`
return `${header}\n\n${response.content}`
},
}),
stop_background_process: tool({
description: "Stop a background process (SIGTERM) but keep its output and entry.",
args: {
id: tool.schema.string().describe("Background process ID"),
},
async execute(args) {
const process = await request<BackgroundProcess>(`/${args.id}/stop`, { method: "POST" })
return `Stopped background process ${process.id} (${process.title}). Status: ${process.status}`
},
}),
terminate_background_process: tool({
description: "Terminate a background process and delete its output + entry.",
args: {
id: tool.schema.string().describe("Background process ID"),
},
async execute(args) {
await request<void>(`/${args.id}/terminate`, { method: "POST" })
return `Terminated background process ${args.id} and removed its output.`
},
}),
}
}
const FILE_COMMANDS = new Set(["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown"])
const EXPANSION_CHARS = /[~*$?\[\]`$]/
function assertCommandWithinBase(command: string, baseDir: string) {
const normalizedBase = path.resolve(baseDir)
const commands = splitCommands(command)
for (const item of commands) {
if (!FILE_COMMANDS.has(item.head)) {
continue
}
for (const arg of item.args) {
if (!arg) continue
if (arg.startsWith("-") || (item.head === "chmod" && arg.startsWith("+"))) continue
const literalArg = unquote(arg)
if (EXPANSION_CHARS.test(literalArg)) {
throw new Error(`Background process commands may only reference paths within ${normalizedBase}.`)
}
const resolved = path.isAbsolute(literalArg) ? path.normalize(literalArg) : path.resolve(normalizedBase, literalArg)
if (!isWithinBase(normalizedBase, resolved)) {
throw new Error(`Background process commands may only reference paths within ${normalizedBase}.`)
}
}
}
}
function splitCommands(command: string): ParsedCommand[] {
const tokens = tokenize(command)
const commands: ParsedCommand[] = []
let current: string[] = []
for (const token of tokens) {
if (isSeparator(token)) {
if (current.length > 0) {
commands.push({ head: current[0], args: current.slice(1) })
current = []
}
continue
}
current.push(token)
}
if (current.length > 0) {
commands.push({ head: current[0], args: current.slice(1) })
}
return commands
}
function tokenize(input: string): string[] {
const tokens: string[] = []
let current = ""
let quote: "'" | '"' | null = null
let escape = false
const flush = () => {
if (current.length > 0) {
tokens.push(current)
current = ""
}
}
for (let index = 0; index < input.length; index += 1) {
const char = input[index]
if (escape) {
current += char
escape = false
continue
}
if (char === "\\" && quote !== "'") {
escape = true
continue
}
if (quote) {
current += char
if (char === quote) {
quote = null
}
continue
}
if (char === "'" || char === '"') {
quote = char
current += char
continue
}
if (char === " " || char === "\n" || char === "\t") {
flush()
continue
}
if (char === "|" || char === "&" || char === ";") {
flush()
tokens.push(char)
continue
}
current += char
}
flush()
return tokens
}
function isSeparator(token: string): boolean {
return token === "|" || token === "&" || token === ";"
}
function unquote(token: string): string {
if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
return token.slice(1, -1)
}
return token
}
function isWithinBase(base: string, candidate: string): boolean {
const relative = path.relative(base, candidate)
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
}

View File

@@ -0,0 +1,133 @@
import { createCodeNomadRequester, type CodeNomadConfig, type PluginEvent } from "./request"
export { getCodeNomadConfig, type CodeNomadConfig, type PluginEvent } from "./request"
export function createCodeNomadClient(config: CodeNomadConfig) {
const requester = createCodeNomadRequester(config)
return {
postEvent: (event: PluginEvent) =>
requester.requestVoid("/event", {
method: "POST",
body: JSON.stringify(event),
}),
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(requester, onEvent),
}
}
function delay(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
async function startPluginEvents(
requester: ReturnType<typeof createCodeNomadRequester>,
onEvent: (event: PluginEvent) => void,
) {
// Fail plugin startup if we cannot establish the initial connection.
const initialBody = await connectWithRetries(requester, 3)
// After startup, keep reconnecting; throw after 3 consecutive failures.
void consumeWithReconnect(requester, onEvent, initialBody)
}
async function connectWithRetries(requester: ReturnType<typeof createCodeNomadRequester>, maxAttempts: number) {
let lastError: unknown
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
return await requester.requestSseBody("/events")
} catch (error) {
lastError = error
await delay(500 * attempt)
}
}
const reason = lastError instanceof Error ? lastError.message : String(lastError)
const url = requester.buildUrl("/events")
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad at ${url} after ${maxAttempts} retries: ${reason}`)
}
async function consumeWithReconnect(
requester: ReturnType<typeof createCodeNomadRequester>,
onEvent: (event: PluginEvent) => void,
initialBody: ReadableStream<Uint8Array>,
) {
let consecutiveFailures = 0
let body: ReadableStream<Uint8Array> | null = initialBody
while (true) {
try {
if (!body) {
body = await connectWithRetries(requester, 3)
}
await consumeSseBody(body, onEvent)
body = null
consecutiveFailures = 0
} catch (error) {
body = null
consecutiveFailures += 1
if (consecutiveFailures >= 3) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(`[CodeNomadPlugin] Plugin event stream failed after 3 retries: ${reason}`)
}
await delay(500 * consecutiveFailures)
}
}
}
async function consumeSseBody(body: ReadableStream<Uint8Array>, onEvent: (event: PluginEvent) => void) {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (true) {
const { done, value } = await reader.read()
if (done || !value) {
break
}
buffer += decoder.decode(value, { stream: true })
let separatorIndex = buffer.indexOf("\n\n")
while (separatorIndex >= 0) {
const chunk = buffer.slice(0, separatorIndex)
buffer = buffer.slice(separatorIndex + 2)
separatorIndex = buffer.indexOf("\n\n")
const event = parseSseChunk(chunk)
if (event) {
onEvent(event)
}
}
}
throw new Error("SSE stream ended")
}
function parseSseChunk(chunk: string): PluginEvent | null {
const lines = chunk.split(/\r?\n/)
const dataLines: string[] = []
for (const line of lines) {
if (line.startsWith(":")) continue
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart())
}
}
if (dataLines.length === 0) return null
const payload = dataLines.join("\n").trim()
if (!payload) return null
try {
const parsed = JSON.parse(payload)
if (!parsed || typeof parsed !== "object" || typeof (parsed as any).type !== "string") {
return null
}
return parsed as PluginEvent
} catch {
return null
}
}

View File

@@ -0,0 +1,124 @@
export type PluginEvent = {
type: string
properties?: Record<string, unknown>
}
export type CodeNomadConfig = {
instanceId: string
baseUrl: string
}
export function getCodeNomadConfig(): CodeNomadConfig {
return {
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
}
}
export function createCodeNomadRequester(config: CodeNomadConfig) {
const baseUrl = config.baseUrl.replace(/\/+$/, "")
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
const authorization = buildInstanceAuthorizationHeader()
const buildUrl = (path: string) => {
if (path.startsWith("http://") || path.startsWith("https://")) {
return path
}
const normalized = path.startsWith("/") ? path : `/${path}`
return `${pluginBase}${normalized}`
}
const buildHeaders = (headers: HeadersInit | undefined, hasBody: boolean): Record<string, string> => {
const output: Record<string, string> = normalizeHeaders(headers)
output.Authorization = authorization
if (hasBody) {
output["Content-Type"] = output["Content-Type"] ?? "application/json"
}
return output
}
const fetchWithAuth = async (path: string, init?: RequestInit): Promise<Response> => {
const url = buildUrl(path)
const hasBody = init?.body !== undefined
const headers = buildHeaders(init?.headers, hasBody)
return fetch(url, {
...init,
headers,
})
}
const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
const response = await fetchWithAuth(path, init)
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Request failed with ${response.status}`)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}
const requestVoid = async (path: string, init?: RequestInit): Promise<void> => {
const response = await fetchWithAuth(path, init)
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Request failed with ${response.status}`)
}
}
const requestSseBody = async (path: string): Promise<ReadableStream<Uint8Array>> => {
const response = await fetchWithAuth(path, { headers: { Accept: "text/event-stream" } })
if (!response.ok || !response.body) {
throw new Error(`SSE unavailable (${response.status})`)
}
return response.body as ReadableStream<Uint8Array>
}
return {
buildUrl,
fetch: fetchWithAuth,
requestJson,
requestVoid,
requestSseBody,
}
}
function requireEnv(key: string): string {
const value = process.env[key]
if (!value || !value.trim()) {
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
}
return value
}
function buildInstanceAuthorizationHeader(): string {
const username = requireEnv("OPENCODE_SERVER_USERNAME")
const password = requireEnv("OPENCODE_SERVER_PASSWORD")
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
return `Basic ${token}`
}
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
const output: Record<string, string> = {}
if (!headers) return output
if (headers instanceof Headers) {
headers.forEach((value, key) => {
output[key] = value
})
return output
}
if (Array.isArray(headers)) {
for (const [key, value] of headers) {
output[key] = value
}
return output
}
return { ...headers }
}

View File

@@ -1,20 +1,30 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.2.1",
"version": "0.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.2.1",
"version": "0.9.0",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
"@fastify/static": "^7.0.4",
"commander": "^12.1.0",
"fastify": "^4.28.1",
"fuzzysort": "^2.0.4",
"pino": "^9.4.0",
"undici": "^6.19.8",
"yauzl": "^2.10.0",
"zod": "^3.23.8"
},
"bin": {
"codenomad": "dist/bin.js"
},
"devDependencies": {
"@types/yauzl": "^2.10.0",
"cross-env": "^7.0.3",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "^5.6.3"
@@ -475,6 +485,15 @@
"node": ">=18"
}
},
"node_modules/@fastify/accept-negotiator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz",
"integrity": "sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@fastify/ajv-compiler": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
@@ -486,6 +505,15 @@
"fast-uri": "^2.0.0"
}
},
"node_modules/@fastify/busboy": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@fastify/cors": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz",
@@ -520,6 +548,77 @@
"fast-deep-equal": "^3.1.3"
}
},
"node_modules/@fastify/reply-from": {
"version": "9.8.0",
"resolved": "https://registry.npmjs.org/@fastify/reply-from/-/reply-from-9.8.0.tgz",
"integrity": "sha512-bPNVaFhEeNI0Lyl6404YZaPFokudCplidE3QoOcr78yOy6H9sYw97p5KPYvY/NJNUHfFtvxOaSAHnK+YSiv/Mg==",
"license": "MIT",
"dependencies": {
"@fastify/error": "^3.0.0",
"end-of-stream": "^1.4.4",
"fast-content-type-parse": "^1.1.0",
"fast-querystring": "^1.0.0",
"fastify-plugin": "^4.0.0",
"toad-cache": "^3.7.0",
"undici": "^5.19.1"
}
},
"node_modules/@fastify/reply-from/node_modules/undici": {
"version": "5.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
"license": "MIT",
"dependencies": {
"@fastify/busboy": "^2.0.0"
},
"engines": {
"node": ">=14.0"
}
},
"node_modules/@fastify/send": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-2.1.0.tgz",
"integrity": "sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==",
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.1",
"escape-html": "~1.0.3",
"fast-decode-uri-component": "^1.0.1",
"http-errors": "2.0.0",
"mime": "^3.0.0"
}
},
"node_modules/@fastify/static": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-7.0.4.tgz",
"integrity": "sha512-p2uKtaf8BMOZWLs6wu+Ihg7bWNBdjNgCwDza4MJtTqg+5ovKmcbgbR9Xs5/smZ1YISfzKOCNYmZV8LaCj+eJ1Q==",
"license": "MIT",
"dependencies": {
"@fastify/accept-negotiator": "^1.0.0",
"@fastify/send": "^2.0.0",
"content-disposition": "^0.5.3",
"fastify-plugin": "^4.0.0",
"fastq": "^1.17.0",
"glob": "^10.3.4"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -548,12 +647,31 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -593,6 +711,16 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@@ -674,6 +802,30 @@
],
"license": "BSD-3-Clause"
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
@@ -700,6 +852,48 @@
"fastq": "^1.17.1"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
@@ -709,6 +903,18 @@
"node": ">=18"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
@@ -725,6 +931,48 @@
"dev": true,
"license": "MIT"
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.1"
},
"bin": {
"cross-env": "src/bin/cross-env.js",
"cross-env-shell": "src/bin/cross-env-shell.js"
},
"engines": {
"node": ">=10.14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
@@ -735,6 +983,27 @@
"node": ">=0.3.1"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"license": "MIT"
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -777,6 +1046,12 @@
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/fast-content-type-parse": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz",
@@ -891,6 +1166,15 @@
"reusify": "^1.0.4"
}
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"license": "MIT",
"dependencies": {
"pend": "~1.2.0"
}
},
"node_modules/find-my-way": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz",
@@ -905,6 +1189,22 @@
"node": ">=14"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -929,6 +1229,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/fuzzysort": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-2.0.4.tgz",
"integrity": "sha512-Api1mJL+Ad7W7vnDZnWq5pGaXJjyencT+iKGia2PlHUcSsSzWwIQ3S1isiMpwpavjYtGd2FzhUIhnnhOULZgDw==",
"license": "MIT"
},
"node_modules/get-tsconfig": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
@@ -942,6 +1248,48 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
"license": "MIT",
"dependencies": {
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -951,6 +1299,36 @@
"node": ">= 0.10"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/json-schema-ref-resolver": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz",
@@ -977,6 +1355,12 @@
"set-cookie-parser": "^2.4.1"
}
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@@ -984,6 +1368,42 @@
"dev": true,
"license": "ISC"
},
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mnemonist": {
"version": "0.39.6",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz",
@@ -1008,6 +1428,52 @@
"node": ">=14.0.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"license": "MIT"
},
"node_modules/pino": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
@@ -1139,6 +1605,26 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-regex2": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz",
@@ -1181,6 +1667,45 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/sonic-boom": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
@@ -1199,6 +1724,111 @@
"node": ">= 10.x"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/string-width-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/thread-stream": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
@@ -1217,6 +1847,15 @@
"node": ">=12"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
@@ -1296,6 +1935,15 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
@@ -1310,6 +1958,128 @@
"dev": true,
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
"license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",
"fd-slicer": "~1.1.0"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.2.1",
"version": "0.9.0",
"description": "CodeNomad Server",
"author": {
"name": "Neural Nomads",
@@ -16,10 +16,11 @@
"codenomad": "dist/bin.js"
},
"scripts": {
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json",
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-config",
"build:ui": "npm run build --prefix ../ui",
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
"dev": "cross-env CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
"dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
@@ -31,9 +32,11 @@
"fuzzysort": "^2.0.4",
"pino": "^9.4.0",
"undici": "^6.19.8",
"yauzl": "^2.10.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/yauzl": "^2.10.0",
"cross-env": "^7.0.3",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env node
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const cliRoot = path.resolve(__dirname, "..")
const sourceDir = path.resolve(cliRoot, "src/server/routes/auth-pages")
const targetDir = path.resolve(cliRoot, "dist/server/routes/auth-pages")
if (!existsSync(sourceDir)) {
console.error(`[copy-auth-pages] Missing auth pages at ${sourceDir}`)
process.exit(1)
}
rmSync(targetDir, { recursive: true, force: true })
mkdirSync(targetDir, { recursive: true })
cpSync(sourceDir, targetDir, { recursive: true })
console.log(`[copy-auth-pages] Copied ${sourceDir} -> ${targetDir}`)

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env node
import { spawnSync } from "child_process"
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const cliRoot = path.resolve(__dirname, "..")
const sourceDir = path.resolve(cliRoot, "../opencode-config")
const targetDir = path.resolve(cliRoot, "dist/opencode-config")
const nodeModulesDir = path.resolve(sourceDir, "node_modules")
const selfLinkDir = path.resolve(nodeModulesDir, "@codenomad", "opencode-config")
const npmExecPath = process.env.npm_execpath
const npmNodeExecPath = process.env.npm_node_execpath
if (!existsSync(sourceDir)) {
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
process.exit(1)
}
if (!existsSync(nodeModulesDir)) {
console.log(`[copy-opencode-config] Installing opencode-config dependencies in ${sourceDir}`)
const npmArgs = [
"install",
"--prefix",
sourceDir,
"--omit=dev",
"--ignore-scripts",
"--fund=false",
"--audit=false",
"--package-lock=false",
"--workspaces=false",
]
const env = { ...process.env, npm_config_workspaces: "false" }
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
const result = npmCli
? spawnSync(npmCli[0], npmCli[1], { cwd: sourceDir, stdio: "inherit", env })
: spawnSync("npm", npmArgs, { cwd: sourceDir, stdio: "inherit", env, shell: process.platform === "win32" })
if (result.status !== 0) {
if (result.error) {
console.error("[copy-opencode-config] npm install failed to start", result.error)
}
console.error("[copy-opencode-config] Failed to install opencode-config dependencies")
process.exit(result.status ?? 1)
}
}
// npm can create a self-referential link for scoped packages on Windows.
// That link causes recursive copies (ELOOP) during bundling.
rmSync(selfLinkDir, { recursive: true, force: true })
rmSync(targetDir, { recursive: true, force: true })
mkdirSync(path.dirname(targetDir), { recursive: true })
cpSync(sourceDir, targetDir, { recursive: true })
console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`)

View File

@@ -95,6 +95,26 @@ export interface FileSystemListResponse {
metadata: FileSystemListingMetadata
}
export interface FileSystemCreateFolderRequest {
/**
* Path identifier for the currently browsed directory.
* Matches the `path` parameter used for `/api/filesystem`.
*/
parentPath?: string
/** Single folder name (no separators). */
name: string
}
export interface FileSystemCreateFolderResponse {
/**
* Path identifier that can be passed back to `/api/filesystem` to browse the new folder.
* Relative for restricted listings, absolute for unrestricted.
*/
path: string
/** Absolute folder path on the server host. */
absolutePath: string
}
export const WINDOWS_DRIVES_ROOT = "__drives__"
export interface WorkspaceFileResponse {
@@ -111,6 +131,14 @@ export interface InstanceData {
agentModelSelections: AgentModelSelection
}
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
export interface InstanceStreamEvent {
type: string
properties?: Record<string, unknown>
[key: string]: unknown
}
export interface BinaryRecord {
id: string
path: string
@@ -157,6 +185,8 @@ export type WorkspaceEventType =
| "config.appChanged"
| "config.binariesChanged"
| "instance.dataChanged"
| "instance.event"
| "instance.eventStatus"
export type WorkspaceEventPayload =
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
@@ -167,16 +197,85 @@ export type WorkspaceEventPayload =
| { type: "config.appChanged"; config: AppConfig }
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
export interface NetworkAddress {
ip: string
family: "ipv4" | "ipv6"
scope: "external" | "internal" | "loopback"
url: string
}
export interface LatestReleaseInfo {
version: string
tag: string
url: string
channel: "stable" | "dev"
publishedAt?: string
notes?: string
}
export interface UiMeta {
version?: string
source: "bundled" | "downloaded" | "previous" | "override" | "dev-proxy" | "missing"
}
export interface SupportMeta {
supported: boolean
message?: string
minServerVersion?: string
latestServerVersion?: string
latestServerUrl?: string
}
export interface ServerMeta {
/** Base URL clients should target for REST calls (useful for Electron embedding). */
httpBaseUrl: string
/** SSE endpoint advertised to clients (`/api/events` by default). */
eventsUrl: string
/** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */
host: string
/** Listening mode derived from host binding. */
listeningMode: "local" | "all"
/** Actual port in use after binding. */
port: number
/** Display label for the host (e.g., hostname or friendly name). */
hostLabel: string
/** Absolute path of the filesystem root exposed to clients. */
workspaceRoot: string
/** Reachable addresses for this server, external first. */
addresses: NetworkAddress[]
serverVersion?: string
ui?: UiMeta
support?: SupportMeta
}
export type BackgroundProcessStatus = "running" | "stopped" | "error"
export interface BackgroundProcess {
id: string
workspaceId: string
title: string
command: string
cwd: string
status: BackgroundProcessStatus
pid?: number
startedAt: string
stoppedAt?: string
exitCode?: number
outputSizeBytes?: number
}
export interface BackgroundProcessListResponse {
processes: BackgroundProcess[]
}
export interface BackgroundProcessOutputResponse {
id: string
content: string
truncated: boolean
sizeBytes: number
}
export type {

View File

@@ -0,0 +1,175 @@
import fs from "fs"
import path from "path"
import type { Logger } from "../logger"
import { hashPassword, type PasswordHashRecord, verifyPassword } from "./password-hash"
export interface AuthFile {
version: 1
username: string
password: PasswordHashRecord
userProvided: boolean
updatedAt: string
}
export interface AuthStatus {
username: string
passwordUserProvided: boolean
}
export class AuthStore {
private cachedFile: AuthFile | null = null
private overrideAuth: AuthFile | null = null
private bootstrapUsername: string | null = null
constructor(private readonly authFilePath: string, private readonly logger: Logger) {}
getAuthFilePath() {
return this.authFilePath
}
load(): AuthFile | null {
if (this.overrideAuth) {
return this.overrideAuth
}
if (this.cachedFile) {
return this.cachedFile
}
try {
if (!fs.existsSync(this.authFilePath)) {
return null
}
const raw = fs.readFileSync(this.authFilePath, "utf-8")
const parsed = JSON.parse(raw) as AuthFile
if (!parsed || parsed.version !== 1) {
this.logger.warn({ authFilePath: this.authFilePath }, "Auth file has unsupported version")
return null
}
this.cachedFile = parsed
return parsed
} catch (error) {
this.logger.warn({ err: error, authFilePath: this.authFilePath }, "Failed to load auth file")
return null
}
}
ensureInitialized(params: {
username: string
password?: string
allowBootstrapWithoutPassword: boolean
}): void {
const password = params.password?.trim()
if (password) {
const now = new Date().toISOString()
const runtime: AuthFile = {
version: 1,
username: params.username,
password: hashPassword(password),
userProvided: true,
updatedAt: now,
}
this.overrideAuth = runtime
this.cachedFile = null
this.bootstrapUsername = null
this.logger.debug({ authFilePath: this.authFilePath }, "Using runtime auth password override; ignoring auth file")
return
}
const existing = this.load()
if (existing) {
if (existing.username !== params.username) {
// Keep existing username unless explicitly overridden later.
this.logger.debug({ existing: existing.username, requested: params.username }, "Auth username differs from requested")
}
this.bootstrapUsername = null
return
}
if (params.allowBootstrapWithoutPassword) {
this.bootstrapUsername = params.username
this.logger.debug({ authFilePath: this.authFilePath }, "No auth file present; bootstrap-only mode enabled")
return
}
throw new Error(
`No server password configured. Create ${this.authFilePath} or start with --password / CODENOMAD_SERVER_PASSWORD.`,
)
}
validateCredentials(username: string, password: string): boolean {
const auth = this.load()
if (!auth) {
return false
}
if (username !== auth.username) {
return false
}
return verifyPassword(password, auth.password)
}
setPassword(params: { password: string; markUserProvided: boolean }): AuthStatus {
if (this.overrideAuth) {
throw new Error(
"Server password is provided via CLI/env and cannot be changed while running. Restart without --password / CODENOMAD_SERVER_PASSWORD to use auth.json.",
)
}
const current = this.load()
if (!current) {
if (!this.bootstrapUsername) {
throw new Error("Auth is not initialized")
}
const created: AuthFile = {
version: 1,
username: this.bootstrapUsername,
password: hashPassword(params.password),
userProvided: params.markUserProvided,
updatedAt: new Date().toISOString(),
}
this.persist(created)
this.bootstrapUsername = null
return { username: created.username, passwordUserProvided: created.userProvided }
}
const next: AuthFile = {
...current,
password: hashPassword(params.password),
userProvided: params.markUserProvided,
updatedAt: new Date().toISOString(),
}
this.persist(next)
return { username: next.username, passwordUserProvided: next.userProvided }
}
getStatus(): AuthStatus {
const current = this.load()
if (current) {
return { username: current.username, passwordUserProvided: current.userProvided }
}
if (this.bootstrapUsername) {
return { username: this.bootstrapUsername, passwordUserProvided: false }
}
throw new Error("Auth is not initialized")
}
private persist(auth: AuthFile) {
try {
fs.mkdirSync(path.dirname(this.authFilePath), { recursive: true })
fs.writeFileSync(this.authFilePath, JSON.stringify(auth, null, 2), "utf-8")
this.cachedFile = auth
this.logger.debug({ authFilePath: this.authFilePath }, "Persisted auth file")
} catch (error) {
this.logger.error({ err: error, authFilePath: this.authFilePath }, "Failed to persist auth file")
throw error
}
}
}

View File

@@ -0,0 +1,38 @@
import type { FastifyReply, FastifyRequest } from "fastify"
export function parseCookies(header: string | undefined): Record<string, string> {
const result: Record<string, string> = {}
if (!header) return result
const parts = header.split(";")
for (const part of parts) {
const index = part.indexOf("=")
if (index < 0) continue
const key = part.slice(0, index).trim()
const value = part.slice(index + 1).trim()
if (!key) continue
result[key] = decodeURIComponent(value)
}
return result
}
export function isLoopbackAddress(remoteAddress: string | undefined): boolean {
if (!remoteAddress) return false
if (remoteAddress === "127.0.0.1" || remoteAddress === "::1") return true
if (remoteAddress === "::ffff:127.0.0.1") return true
return false
}
export function wantsHtml(request: FastifyRequest): boolean {
const accept = (request.headers["accept"] ?? "").toString().toLowerCase()
return accept.includes("text/html") || accept.includes("application/xhtml")
}
export function sendUnauthorized(request: FastifyRequest, reply: FastifyReply) {
if (request.method === "GET" && !request.url.startsWith("/api/") && wantsHtml(request)) {
reply.redirect("/login")
return
}
reply.code(401).send({ error: "Unauthorized" })
}

View File

@@ -0,0 +1,113 @@
import type { FastifyReply, FastifyRequest } from "fastify"
import path from "path"
import type { Logger } from "../logger"
import { AuthStore } from "./auth-store"
import { TokenManager } from "./token-manager"
import { SessionManager } from "./session-manager"
import { isLoopbackAddress, parseCookies } from "./http-auth"
export const BOOTSTRAP_TOKEN_STDOUT_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:" as const
export const DEFAULT_AUTH_USERNAME = "codenomad" as const
export const DEFAULT_AUTH_COOKIE_NAME = "codenomad_session" as const
export interface AuthManagerInit {
configPath: string
username: string
password?: string
generateToken: boolean
}
export class AuthManager {
private readonly authStore: AuthStore
private readonly tokenManager: TokenManager | null
private readonly sessionManager = new SessionManager()
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
const authFilePath = resolveAuthFilePath(init.configPath)
this.authStore = new AuthStore(authFilePath, logger.child({ component: "auth" }))
// Startup: password comes from CLI/env, auth.json, or bootstrap-only mode.
this.authStore.ensureInitialized({
username: init.username,
password: init.password,
allowBootstrapWithoutPassword: init.generateToken,
})
this.tokenManager = init.generateToken ? new TokenManager(60_000) : null
}
getCookieName(): string {
return this.cookieName
}
isTokenBootstrapEnabled(): boolean {
return Boolean(this.tokenManager)
}
issueBootstrapToken(): string | null {
if (!this.tokenManager) return null
return this.tokenManager.generate()
}
consumeBootstrapToken(token: string): boolean {
if (!this.tokenManager) return false
return this.tokenManager.consume(token)
}
validateLogin(username: string, password: string): boolean {
return this.authStore.validateCredentials(username, password)
}
createSession(username: string) {
return this.sessionManager.createSession(username)
}
getStatus() {
return this.authStore.getStatus()
}
setPassword(password: string) {
return this.authStore.setPassword({ password, markUserProvided: true })
}
isLoopbackRequest(request: FastifyRequest): boolean {
return isLoopbackAddress(request.socket.remoteAddress)
}
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
const cookies = parseCookies(request.headers.cookie)
const sessionId = cookies[this.cookieName]
const session = this.sessionManager.getSession(sessionId)
if (!session) return null
return { username: session.username, sessionId: session.id }
}
setSessionCookie(reply: FastifyReply, sessionId: string) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId))
}
clearSessionCookie(reply: FastifyReply) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }))
}
}
function resolveAuthFilePath(configPath: string) {
const resolvedConfigPath = resolvePath(configPath)
return path.join(path.dirname(resolvedConfigPath), "auth.json")
}
function resolvePath(filePath: string) {
if (filePath.startsWith("~/")) {
return path.join(process.env.HOME ?? "", filePath.slice(2))
}
return path.resolve(filePath)
}
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number }) {
const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"]
if (options?.maxAgeSeconds !== undefined) {
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`)
}
return parts.join("; ")
}

View File

@@ -0,0 +1,49 @@
import crypto from "crypto"
export interface PasswordHashRecord {
algorithm: "scrypt"
saltBase64: string
hashBase64: string
keyLength: number
params: {
N: number
r: number
p: number
maxmem: number
}
}
const DEFAULT_SCRYPT_PARAMS = {
N: 16384,
r: 8,
p: 1,
maxmem: 32 * 1024 * 1024,
}
export function hashPassword(password: string): PasswordHashRecord {
const salt = crypto.randomBytes(16)
const params = DEFAULT_SCRYPT_PARAMS
const keyLength = 64
const derived = crypto.scryptSync(password, salt, keyLength, params)
return {
algorithm: "scrypt",
saltBase64: salt.toString("base64"),
hashBase64: Buffer.from(derived).toString("base64"),
keyLength,
params,
}
}
export function verifyPassword(password: string, record: PasswordHashRecord): boolean {
if (record.algorithm !== "scrypt") {
return false
}
const salt = Buffer.from(record.saltBase64, "base64")
const expected = Buffer.from(record.hashBase64, "base64")
const derived = crypto.scryptSync(password, salt, record.keyLength, record.params)
if (expected.length !== derived.length) {
return false
}
return crypto.timingSafeEqual(expected, Buffer.from(derived))
}

View File

@@ -0,0 +1,23 @@
import crypto from "crypto"
export interface SessionInfo {
id: string
createdAt: number
username: string
}
export class SessionManager {
private sessions = new Map<string, SessionInfo>()
createSession(username: string): SessionInfo {
const id = crypto.randomBytes(32).toString("base64url")
const info: SessionInfo = { id, createdAt: Date.now(), username }
this.sessions.set(id, info)
return info
}
getSession(id: string | undefined): SessionInfo | undefined {
if (!id) return undefined
return this.sessions.get(id)
}
}

View File

@@ -0,0 +1,32 @@
import crypto from "crypto"
export interface BootstrapToken {
token: string
createdAt: number
consumed: boolean
}
export class TokenManager {
private token: BootstrapToken | null = null
constructor(private readonly ttlMs: number) {}
generate(): string {
const token = crypto.randomBytes(32).toString("base64url")
this.token = { token, createdAt: Date.now(), consumed: false }
return token
}
consume(token: string): boolean {
if (!this.token) return false
if (this.token.consumed) return false
if (Date.now() - this.token.createdAt > this.ttlMs) return false
if (token !== this.token.token) return false
this.token.consumed = true
return true
}
peek(): string | null {
return this.token?.token ?? null
}
}

View File

@@ -0,0 +1,519 @@
import { spawn, spawnSync, type ChildProcess } from "child_process"
import { createWriteStream, existsSync, promises as fs } from "fs"
import path from "path"
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"
const ROOT_DIR = ".codenomad/background_processes"
const INDEX_FILE = "index.json"
const OUTPUT_FILE = "output.txt"
const STOP_TIMEOUT_MS = 2000
const EXIT_WAIT_TIMEOUT_MS = 5000
const MAX_OUTPUT_BYTES = 20 * 1024
const OUTPUT_PUBLISH_INTERVAL_MS = 1000
interface ManagerDeps {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
}
interface RunningProcess {
id: string
child: ChildProcess
outputPath: string
exitPromise: Promise<void>
workspaceId: string
}
export class BackgroundProcessManager {
private readonly running = new Map<string, RunningProcess>()
constructor(private readonly deps: ManagerDeps) {
this.deps.eventBus.on("workspace.stopped", (event) => this.cleanupWorkspace(event.workspaceId))
this.deps.eventBus.on("workspace.error", (event) => this.cleanupWorkspace(event.workspace.id))
}
async list(workspaceId: string): Promise<BackgroundProcess[]> {
const records = await this.readIndex(workspaceId)
const enriched = await Promise.all(
records.map(async (record) => ({
...record,
outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
})),
)
return enriched
}
async start(workspaceId: string, title: string, command: string): Promise<BackgroundProcess> {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
throw new Error("Workspace not found")
}
const id = this.generateId()
const processDir = await this.ensureProcessDir(workspaceId, id)
const outputPath = path.join(processDir, OUTPUT_FILE)
const outputStream = createWriteStream(outputPath, { flags: "a" })
const { shellCommand, shellArgs, spawnOptions } = this.buildShellSpawn(command)
const child = spawn(shellCommand, shellArgs, {
cwd: workspace.path,
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
...spawnOptions,
})
child.on("exit", () => {
this.killProcessTree(child, "SIGTERM")
})
const record: BackgroundProcess = {
id,
workspaceId,
title,
command,
cwd: workspace.path,
status: "running",
pid: child.pid,
startedAt: new Date().toISOString(),
outputSizeBytes: 0,
}
const exitPromise = new Promise<void>((resolve) => {
child.on("close", async (code) => {
await new Promise<void>((resolve) => outputStream.end(resolve))
this.running.delete(id)
record.status = this.statusFromExit(code)
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)
resolve()
})
})
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId })
let lastPublishAt = 0
const maybePublishSize = () => {
const now = Date.now()
if (now - lastPublishAt < OUTPUT_PUBLISH_INTERVAL_MS) {
return
}
lastPublishAt = now
this.publishUpdate(workspaceId, record)
}
child.stdout?.on("data", (data) => {
outputStream.write(data)
record.outputSizeBytes = (record.outputSizeBytes ?? 0) + data.length
maybePublishSize()
})
child.stderr?.on("data", (data) => {
outputStream.write(data)
record.outputSizeBytes = (record.outputSizeBytes ?? 0) + data.length
maybePublishSize()
})
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
return record
}
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
const record = await this.findProcess(workspaceId, processId)
if (!record) {
return null
}
const running = this.running.get(processId)
if (running?.child && !running.child.killed) {
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
}
if (record.status === "running") {
record.status = "stopped"
record.stoppedAt = new Date().toISOString()
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
}
return record
}
async terminate(workspaceId: string, processId: string): Promise<void> {
const record = await this.findProcess(workspaceId, processId)
if (!record) return
const running = this.running.get(processId)
if (running?.child && !running.child.killed) {
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
}
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 } },
})
}
async readOutput(
workspaceId: string,
processId: string,
options: { method?: "full" | "tail" | "head" | "grep"; pattern?: string; lines?: number; maxBytes?: number },
) {
const outputPath = this.getOutputPath(workspaceId, processId)
if (!existsSync(outputPath)) {
return { id: processId, content: "", truncated: false, sizeBytes: 0 }
}
const stats = await fs.stat(outputPath)
const sizeBytes = stats.size
const method = options.method ?? "full"
const lineCount = options.lines ?? 10
const raw = await this.readOutputBytes(outputPath, sizeBytes, options.maxBytes)
let content = raw
switch (method) {
case "head":
content = this.headLines(raw, lineCount)
break
case "tail":
content = this.tailLines(raw, lineCount)
break
case "grep":
if (!options.pattern) {
throw new Error("Pattern is required for grep output")
}
content = this.grepLines(raw, options.pattern)
break
default:
content = raw
}
const effectiveMaxBytes = options.maxBytes
return {
id: processId,
content,
truncated: effectiveMaxBytes !== undefined && sizeBytes > effectiveMaxBytes,
sizeBytes,
}
}
async streamOutput(workspaceId: string, processId: string, reply: any) {
const outputPath = this.getOutputPath(workspaceId, processId)
if (!existsSync(outputPath)) {
reply.code(404).send({ error: "Output not found" })
return
}
reply.raw.setHeader("Content-Type", "text/event-stream")
reply.raw.setHeader("Cache-Control", "no-cache")
reply.raw.setHeader("Connection", "keep-alive")
reply.raw.flushHeaders?.()
reply.hijack()
const file = await fs.open(outputPath, "r")
let position = (await file.stat()).size
const tick = async () => {
const stats = await file.stat()
if (stats.size <= position) return
const length = stats.size - position
const buffer = Buffer.alloc(length)
await file.read(buffer, 0, length, position)
position = stats.size
const content = buffer.toString("utf-8")
reply.raw.write(`data: ${JSON.stringify({ type: "chunk", content })}\n\n`)
}
const interval = setInterval(() => {
tick().catch((error) => {
this.deps.logger.warn({ err: error }, "Failed to stream background process output")
})
}, 1000)
const close = () => {
clearInterval(interval)
file.close().catch(() => undefined)
reply.raw.end?.()
}
reply.raw.on("close", close)
reply.raw.on("error", close)
}
private async cleanupWorkspace(workspaceId: string) {
for (const [, running] of this.running.entries()) {
if (running.workspaceId !== workspaceId) continue
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
}
await this.removeWorkspaceDir(workspaceId)
}
private killProcessTree(child: ChildProcess, signal: NodeJS.Signals) {
const pid = child.pid
if (!pid) return
if (process.platform === "win32") {
const args = this.buildWindowsTaskkillArgs(pid, signal)
try {
spawnSync("taskkill", args, { stdio: "ignore" })
return
} catch {
// Fall back to killing the direct child.
}
} else {
try {
process.kill(-pid, signal)
return
} catch {
// Fall back to killing the direct child.
}
}
try {
child.kill(signal)
} catch {
// ignore
}
}
private async waitForExit(running: RunningProcess) {
let exited = false
const exitPromise = running.exitPromise.finally(() => {
exited = true
})
const killTimeout = setTimeout(() => {
if (!exited) {
this.killProcessTree(running.child, "SIGKILL")
}
}, STOP_TIMEOUT_MS)
try {
await Promise.race([
exitPromise,
new Promise<void>((resolve) => {
setTimeout(resolve, EXIT_WAIT_TIMEOUT_MS)
}),
])
if (!exited) {
this.killProcessTree(running.child, "SIGKILL")
this.running.delete(running.id)
this.deps.logger.warn({ pid: running.child.pid }, "Timed out waiting for background process to exit")
}
} finally {
clearTimeout(killTimeout)
}
}
private buildShellSpawn(command: string): { shellCommand: string; shellArgs: string[]; spawnOptions?: Record<string, unknown> } {
if (process.platform === "win32") {
const comspec = process.env.ComSpec || "cmd.exe"
return {
shellCommand: comspec,
shellArgs: ["/d", "/s", "/c", command],
spawnOptions: { windowsVerbatimArguments: true },
}
}
// Keep bash for macOS/Linux.
return { shellCommand: "bash", shellArgs: ["-c", command] }
}
private buildWindowsTaskkillArgs(pid: number, signal: NodeJS.Signals): string[] {
// Default to graceful termination (no /F), then force kill when we escalate.
const force = signal === "SIGKILL"
const args = ["/PID", String(pid), "/T"]
if (force) {
args.push("/F")
}
return args
}
private statusFromExit(code: number | null): BackgroundProcessStatus {
if (code === null) return "stopped"
if (code === 0) return "stopped"
return "error"
}
private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise<string> {
if (maxBytes === undefined || sizeBytes <= maxBytes) {
return await fs.readFile(outputPath, "utf-8")
}
const start = Math.max(0, sizeBytes - maxBytes)
const file = await fs.open(outputPath, "r")
const buffer = Buffer.alloc(sizeBytes - start)
await file.read(buffer, 0, buffer.length, start)
await file.close()
return buffer.toString("utf-8")
}
private headLines(input: string, lines: number): string {
const parts = input.split(/\r?\n/)
return parts.slice(0, Math.max(0, lines)).join("\n")
}
private tailLines(input: string, lines: number): string {
const parts = input.split(/\r?\n/)
return parts.slice(Math.max(0, parts.length - lines)).join("\n")
}
private grepLines(input: string, pattern: string): string {
let matcher: RegExp
try {
matcher = new RegExp(pattern)
} catch {
throw new Error("Invalid grep pattern")
}
return input
.split(/\r?\n/)
.filter((line) => matcher.test(line))
.join("\n")
}
private async ensureProcessDir(workspaceId: string, processId: string) {
const root = await this.ensureWorkspaceDir(workspaceId)
const processDir = path.join(root, processId)
await fs.mkdir(processDir, { recursive: true })
return processDir
}
private async ensureWorkspaceDir(workspaceId: string) {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
throw new Error("Workspace not found")
}
const root = path.join(workspace.path, ROOT_DIR, workspaceId)
await fs.mkdir(root, { recursive: true })
return root
}
private getOutputPath(workspaceId: string, processId: string) {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
throw new Error("Workspace not found")
}
return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE)
}
private async findProcess(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
const records = await this.readIndex(workspaceId)
return records.find((entry) => entry.id === processId) ?? null
}
private async readIndex(workspaceId: string): Promise<BackgroundProcess[]> {
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[]) : []
} catch {
return []
}
}
private async upsertIndex(workspaceId: string, record: BackgroundProcess) {
const records = await this.readIndex(workspaceId)
const index = records.findIndex((entry) => entry.id === record.id)
if (index >= 0) {
records[index] = record
} else {
records.push(record)
}
await this.writeIndex(workspaceId, records)
}
private async removeFromIndex(workspaceId: string, processId: string) {
const records = await this.readIndex(workspaceId)
const next = records.filter((entry) => entry.id !== processId)
await this.writeIndex(workspaceId, next)
}
private async writeIndex(workspaceId: string, records: BackgroundProcess[]) {
const indexPath = await this.getIndexPath(workspaceId)
await fs.mkdir(path.dirname(indexPath), { recursive: true })
await fs.writeFile(indexPath, JSON.stringify(records, null, 2))
}
private async getIndexPath(workspaceId: string) {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
throw new Error("Workspace not found")
}
return path.join(workspace.path, ROOT_DIR, workspaceId, INDEX_FILE)
}
private async removeProcessDir(workspaceId: string, processId: string) {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
return
}
const processDir = path.join(workspace.path, ROOT_DIR, workspaceId, processId)
await fs.rm(processDir, { recursive: true, force: true })
}
private async removeWorkspaceDir(workspaceId: string) {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
return
}
const workspaceDir = path.join(workspace.path, ROOT_DIR, workspaceId)
await fs.rm(workspaceDir, { recursive: true, force: true })
}
private async getOutputSize(workspaceId: string, processId: string): Promise<number> {
const outputPath = this.getOutputPath(workspaceId, processId)
if (!existsSync(outputPath)) {
return 0
}
try {
const stats = await fs.stat(outputPath)
return stats.size
} catch {
return 0
}
}
private publishUpdate(workspaceId: string, record: BackgroundProcess) {
this.deps.eventBus.publish({
type: "instance.event",
instanceId: workspaceId,
event: { type: "background.process.updated", properties: { process: record } },
})
}
private generateId(): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
const random = randomBytes(3).toString("hex")
return `proc_${timestamp}_${random}`
}
}

View File

@@ -4,10 +4,12 @@ import {
BinaryUpdateRequest,
BinaryValidationResult,
} from "../api-types"
import { spawnSync } from "child_process"
import { ConfigStore } from "./store"
import { EventBus } from "../events/bus"
import type { ConfigFile } from "./schema"
import { Logger } from "../logger"
import { buildSpawnSpec } from "../workspaces/runtime"
export class BinaryRegistry {
constructor(
@@ -135,8 +137,42 @@ export class BinaryRegistry {
}
private validateRecord(record: BinaryRecord): BinaryValidationResult {
// TODO: call actual binary -v check.
return { valid: true, version: record.version }
const inputPath = record.path
if (!inputPath) {
return { valid: false, error: "Missing binary path" }
}
const spec = buildSpawnSpec(inputPath, ["--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 stdout = (result.stdout ?? "").trim()
const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0)
const normalized = firstLine?.trim()
const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
const version = versionMatch?.[1]
return { valid: true, version }
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : String(error) }
}
}
private buildFallbackRecord(path: string): BinaryRecord {

View File

@@ -10,12 +10,17 @@ const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchem
const PreferencesSchema = z.object({
showThinkingBlocks: z.boolean().default(false),
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showTimelineTools: z.boolean().default(true),
lastUsedBinary: z.string().optional(),
environmentVariables: z.record(z.string()).default({}),
modelRecents: z.array(ModelPreferenceSchema).default([]),
diffViewMode: z.enum(["split", "unified"]).default("split"),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showUsageMetrics: z.boolean().default(true),
autoCleanupBlankSessions: z.boolean().default(true),
listeningMode: z.enum(["local", "all"]).default("local"),
})
const RecentFolderSchema = z.object({

View File

@@ -52,9 +52,10 @@ export class ConfigStore {
this.cache = next
this.loaded = true
this.persist()
const published = Boolean(this.eventBus)
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
this.logger.info("Config updated")
this.logger.debug({ config: this.cache }, "Config payload")
this.logger.debug({ broadcast: published }, "Config SSE event emitted")
this.logger.trace({ config: this.cache }, "Config payload")
}
private persist() {

View File

@@ -8,7 +8,12 @@ export class EventBus extends EventEmitter {
}
publish(event: WorkspaceEventPayload): boolean {
this.logger?.debug({ event }, "Publishing workspace event")
if (event.type !== "instance.event" && event.type !== "instance.eventStatus") {
this.logger?.debug({ type: event.type }, "Publishing workspace event")
if (this.logger?.isLevelEnabled("trace")) {
this.logger.trace({ event }, "Workspace event payload")
}
}
return super.emit(event.type, event)
}
@@ -22,6 +27,8 @@ export class EventBus extends EventEmitter {
this.on("config.appChanged", handler)
this.on("config.binariesChanged", handler)
this.on("instance.dataChanged", handler)
this.on("instance.event", handler)
this.on("instance.eventStatus", handler)
return () => {
this.off("workspace.created", handler)
this.off("workspace.started", handler)
@@ -31,6 +38,8 @@ export class EventBus extends EventEmitter {
this.off("config.appChanged", handler)
this.off("config.binariesChanged", handler)
this.off("instance.dataChanged", handler)
this.off("instance.event", handler)
this.off("instance.eventStatus", handler)
}
}
}

View File

@@ -2,6 +2,7 @@ import fs from "fs"
import os from "os"
import path from "path"
import {
FileSystemCreateFolderResponse,
FileSystemEntry,
FileSystemListResponse,
FileSystemListingMetadata,
@@ -56,6 +57,30 @@ export class FileSystemBrowser {
return this.listRestrictedWithMetadata(targetPath, includeFiles)
}
createFolder(parentPath: string | undefined, folderName: string): FileSystemCreateFolderResponse {
const name = this.normalizeFolderName(folderName)
if (this.unrestricted) {
const resolvedParent = this.resolveUnrestrictedPath(parentPath)
if (this.isWindows && resolvedParent === WINDOWS_DRIVES_ROOT) {
throw new Error("Cannot create folders at drive root")
}
this.assertDirectoryExists(resolvedParent)
const absolutePath = this.resolveAbsoluteChild(resolvedParent, name)
fs.mkdirSync(absolutePath)
return { path: absolutePath, absolutePath }
}
const normalizedParent = this.normalizeRelativePath(parentPath)
const parentAbsolute = this.toRestrictedAbsolute(normalizedParent)
this.assertDirectoryExists(parentAbsolute)
const relativePath = this.buildRelativePath(normalizedParent, name)
const absolutePath = this.toRestrictedAbsolute(relativePath)
fs.mkdirSync(absolutePath)
return { path: relativePath, absolutePath }
}
readFile(relativePath: string): string {
if (this.unrestricted) {
throw new Error("readFile is not available in unrestricted mode")
@@ -157,6 +182,41 @@ export class FileSystemBrowser {
return { entries, metadata }
}
private normalizeFolderName(input: string): string {
const name = input.trim()
if (!name) {
throw new Error("Folder name is required")
}
if (name === "." || name === "..") {
throw new Error("Invalid folder name")
}
if (name.startsWith("~")) {
throw new Error("Invalid folder name")
}
if (name.includes("/") || name.includes("\\")) {
throw new Error("Folder name must not include path separators")
}
if (name.includes("\u0000")) {
throw new Error("Invalid folder name")
}
return name
}
private assertDirectoryExists(directory: string) {
if (!fs.existsSync(directory)) {
throw new Error(`Directory does not exist: ${directory}`)
}
const stats = fs.statSync(directory)
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${directory}`)
}
}
private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] {
const dirents = fs.readdirSync(directory, { withFileTypes: true })
const results: FileSystemEntry[] = []

View File

@@ -14,10 +14,14 @@ import { FileSystemBrowser } from "./filesystem/browser"
import { EventBus } from "./events/bus"
import { ServerMeta } from "./api-types"
import { InstanceStore } from "./storage/instance-store"
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"
const require = createRequire(import.meta.url)
const packageJson = require("../package.json") as { version: string }
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -33,7 +37,13 @@ interface CliOptions {
logDestination?: string
uiStaticDir: string
uiDevServer?: string
uiAutoUpdate: boolean
uiNoUpdate: boolean
uiManifestUrl?: string
launch: boolean
authUsername: string
authPassword?: string
generateToken: boolean
}
const DEFAULT_PORT = 9898
@@ -59,7 +69,21 @@ function parseCliOptions(argv: string[]): CliOptions {
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR),
)
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
.addOption(new Option("--ui-no-update", "Disable remote UI updates").env("CLI_UI_NO_UPDATE").default(false))
.addOption(new Option("--ui-auto-update <enabled>", "Enable remote UI updates (true|false)").env("CLI_UI_AUTO_UPDATE").default("true"))
.addOption(new Option("--ui-manifest-url <url>", "Remote UI manifest URL").env("CLI_UI_MANIFEST_URL"))
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
.addOption(
new Option("--username <username>", "Username for server authentication")
.env("CODENOMAD_SERVER_USERNAME")
.default(DEFAULT_AUTH_USERNAME),
)
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
.addOption(
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
.env("CODENOMAD_GENERATE_TOKEN")
.default(false),
)
program.parse(argv, { from: "user" })
const parsed = program.opts<{
@@ -73,14 +97,25 @@ function parseCliOptions(argv: string[]): CliOptions {
logDestination?: string
uiDir: string
uiDevServer?: string
uiNoUpdate?: boolean
uiAutoUpdate?: string
uiManifestUrl?: string
launch?: boolean
username: string
password?: string
generateToken?: boolean
}>()
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
const normalizedHost = resolveHost(parsed.host)
const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase()
const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes"
return {
port: parsed.port,
host: parsed.host,
host: normalizedHost,
rootDir: resolvedRoot,
configPath: parsed.config,
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
@@ -88,7 +123,13 @@ function parseCliOptions(argv: string[]): CliOptions {
logDestination: parsed.logDestination,
uiStaticDir: parsed.uiDir,
uiDevServer: parsed.uiDevServer,
uiAutoUpdate,
uiNoUpdate: Boolean(parsed.uiNoUpdate),
uiManifestUrl: parsed.uiManifestUrl,
launch: Boolean(parsed.launch),
authUsername: parsed.username,
authPassword: parsed.password,
generateToken: Boolean(parsed.generateToken),
}
}
@@ -100,6 +141,25 @@ function parsePort(input: string): number {
return value
}
function resolveHost(input: string | undefined): string {
const trimmed = input?.trim()
if (!trimmed) return DEFAULT_HOST
if (trimmed === "0.0.0.0") {
return "0.0.0.0"
}
if (trimmed === "localhost") {
return DEFAULT_HOST
}
return trimmed
}
function programHasArg(argv: string[], flag: string): boolean {
return argv.includes(flag)
}
async function main() {
const options = parseCliOptions(process.argv.slice(2))
const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" })
@@ -107,9 +167,45 @@ async function main() {
const configLogger = logger.child({ component: "config" })
const eventLogger = logger.child({ component: "events" })
logger.info({ options }, "Starting CodeNomad CLI server")
const logOptions = {
...options,
authPassword: options.authPassword ? "[REDACTED]" : undefined,
}
logger.info({ options: logOptions }, "Starting CodeNomad CLI server")
const eventBus = new EventBus(eventLogger)
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
const serverMeta: ServerMeta = {
httpBaseUrl: `http://${options.host}:${options.port}`,
eventsUrl: `/api/events`,
host: options.host,
listeningMode: isLoopbackHost(options.host) ? "local" : "all",
port: options.port,
hostLabel: options.host,
workspaceRoot: options.rootDir,
addresses: [],
}
const authManager = new AuthManager(
{
configPath: options.configPath,
username: options.authUsername,
password: options.authPassword,
generateToken: options.generateToken,
},
logger.child({ component: "auth" }),
)
if (options.generateToken) {
const token = authManager.issueBootstrapToken()
if (token) {
console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`)
}
}
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
const workspaceManager = new WorkspaceManager({
@@ -118,15 +214,44 @@ async function main() {
binaryRegistry,
eventBus,
logger: workspaceLogger,
getServerBaseUrl: () => serverMeta.httpBaseUrl,
})
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore()
const instanceEventBridge = new InstanceEventBridge({
workspaceManager,
eventBus,
logger: logger.child({ component: "instance-events" }),
})
const serverMeta: ServerMeta = {
httpBaseUrl: `http://${options.host}:${options.port}`,
eventsUrl: `/api/events`,
hostLabel: options.host,
workspaceRoot: options.rootDir,
const uiDirEnvOverride = Boolean(process.env.CLI_UI_DIR)
const uiDirCliOverride = programHasArg(process.argv.slice(2), "--ui-dir")
const uiOverrideIsExplicit = uiDirEnvOverride || uiDirCliOverride
const uiDirOverride = uiOverrideIsExplicit ? options.uiStaticDir : undefined
const autoUpdateEnabled = options.uiAutoUpdate && !options.uiNoUpdate
const uiResolution = await resolveUi({
serverVersion: packageJson.version,
bundledUiDir: DEFAULT_UI_STATIC_DIR,
autoUpdate: autoUpdateEnabled,
overrideUiDir: uiDirOverride,
uiDevServerUrl: options.uiDevServer,
manifestUrl: options.uiManifestUrl,
logger: logger.child({ component: "ui" }),
})
serverMeta.serverVersion = packageJson.version
serverMeta.ui = {
version: uiResolution.uiVersion,
source: uiResolution.source,
}
serverMeta.support = {
supported: uiResolution.supported,
message: uiResolution.message,
latestServerVersion: uiResolution.latestServerVersion,
latestServerUrl: uiResolution.latestServerUrl,
minServerVersion: uiResolution.minServerVersion,
}
const server = createHttpServer({
@@ -139,8 +264,9 @@ async function main() {
eventBus,
serverMeta,
instanceStore,
uiStaticDir: options.uiStaticDir,
uiDevServerUrl: options.uiDevServer,
authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
logger,
})
@@ -169,12 +295,15 @@ async function main() {
}
try {
instanceEventBridge.shutdown()
await workspaceManager.shutdown()
logger.info("Workspace manager shutdown complete")
} catch (error) {
logger.error({ err: error }, "Workspace manager shutdown failed")
}
// no-op: remote UI manifest replaces GitHub release monitor
logger.info("Exiting process")
process.exit(0)
}

View File

@@ -0,0 +1,31 @@
import { existsSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
import { createLogger } from "./logger"
const log = createLogger({ component: "opencode-config" })
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const devTemplateDir = path.resolve(__dirname, "../../opencode-config")
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
const prodTemplateDirs = [
resourcesPath ? path.resolve(resourcesPath, "opencode-config") : undefined,
path.resolve(__dirname, "opencode-config"),
].filter((dir): dir is string => Boolean(dir))
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
const templateDir = isDevBuild
? devTemplateDir
: prodTemplateDirs.find((dir) => existsSync(dir)) ?? prodTemplateDirs[0]
export function getOpencodeConfigDir(): string {
if (!existsSync(templateDir)) {
throw new Error(`CodeNomad Opencode config template missing at ${templateDir}`)
}
if (isDevBuild) {
log.debug({ templateDir }, "Using Opencode config template directly (dev mode)")
}
return templateDir
}

View File

@@ -0,0 +1,55 @@
import type { FastifyReply } from "fastify"
import type { Logger } from "../logger"
export interface PluginOutboundEvent {
type: string
properties?: Record<string, unknown>
}
interface ClientConnection {
reply: FastifyReply
workspaceId: string
}
export class PluginChannelManager {
private readonly clients = new Set<ClientConnection>()
constructor(private readonly logger: Logger) {}
register(workspaceId: string, reply: FastifyReply) {
const connection: ClientConnection = { workspaceId, reply }
this.clients.add(connection)
this.logger.debug({ workspaceId }, "Plugin SSE client connected")
let closed = false
const close = () => {
if (closed) return
closed = true
this.clients.delete(connection)
this.logger.debug({ workspaceId }, "Plugin SSE client disconnected")
}
return { close }
}
send(workspaceId: string, event: PluginOutboundEvent) {
for (const client of this.clients) {
if (client.workspaceId !== workspaceId) continue
this.write(client.reply, event)
}
}
broadcast(event: PluginOutboundEvent) {
for (const client of this.clients) {
this.write(client.reply, event)
}
}
private write(reply: FastifyReply, event: PluginOutboundEvent) {
try {
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
} catch (error) {
this.logger.warn({ err: error }, "Failed to write plugin SSE event")
}
}
}

View File

@@ -0,0 +1,36 @@
import type { EventBus } from "../events/bus"
import type { WorkspaceManager } from "../workspaces/manager"
import type { Logger } from "../logger"
import type { PluginOutboundEvent } from "./channel"
export interface PluginInboundEvent {
type: string
properties?: Record<string, unknown>
}
interface HandlerDeps {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
}
export function handlePluginEvent(workspaceId: string, event: PluginInboundEvent, deps: HandlerDeps) {
switch (event.type) {
case "codenomad.pong":
deps.logger.debug({ workspaceId, properties: event.properties }, "Plugin pong received")
return
default:
deps.logger.debug({ workspaceId, eventType: event.type }, "Unhandled plugin event")
}
}
export function buildPingEvent(): PluginOutboundEvent {
return {
type: "codenomad.ping",
properties: {
ts: Date.now(),
},
}
}

View File

@@ -0,0 +1,141 @@
import { fetch } from "undici"
import type { LatestReleaseInfo } from "../api-types"
import type { Logger } from "../logger"
const RELEASES_API_URL = "https://api.github.com/repos/NeuralNomadsAI/CodeNomad/releases/latest"
interface ReleaseMonitorOptions {
currentVersion: string
logger: Logger
onUpdate: (release: LatestReleaseInfo | null) => void
}
interface GithubReleaseResponse {
tag_name?: string
name?: string
html_url?: string
body?: string
published_at?: string
created_at?: string
prerelease?: boolean
}
interface NormalizedVersion {
major: number
minor: number
patch: number
prerelease: string | null
}
export interface ReleaseMonitor {
stop(): void
}
export function startReleaseMonitor(options: ReleaseMonitorOptions): ReleaseMonitor {
let stopped = false
const refreshRelease = async () => {
if (stopped) return
try {
const release = await fetchLatestRelease(options)
options.onUpdate(release)
} catch (error) {
options.logger.warn({ err: error }, "Failed to refresh release information")
}
}
void refreshRelease()
return {
stop() {
stopped = true
},
}
}
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
const response = await fetch(RELEASES_API_URL, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "CodeNomad-CLI",
},
})
if (!response.ok) {
throw new Error(`Release API responded with ${response.status}`)
}
const json = (await response.json()) as GithubReleaseResponse
const tagFromServer = json.tag_name || json.name
if (!tagFromServer) {
return null
}
const normalizedVersion = stripTagPrefix(tagFromServer)
if (!normalizedVersion) {
return null
}
const current = parseVersion(options.currentVersion)
const remote = parseVersion(normalizedVersion)
if (compareVersions(remote, current) <= 0) {
return null
}
return {
version: normalizedVersion,
tag: tagFromServer,
url: json.html_url ?? `https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/${encodeURIComponent(tagFromServer)}`,
channel: json.prerelease || normalizedVersion.includes("-") ? "dev" : "stable",
publishedAt: json.published_at ?? json.created_at,
notes: json.body,
}
}
function stripTagPrefix(tag: string | undefined): string | null {
if (!tag) return null
const trimmed = tag.trim()
if (!trimmed) return null
return trimmed.replace(/^v/i, "")
}
function parseVersion(value: string): NormalizedVersion {
const normalized = stripTagPrefix(value) ?? "0.0.0"
const [core, prerelease = null] = normalized.split("-", 2)
const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => {
const parsed = Number.parseInt(segment, 10)
return Number.isFinite(parsed) ? parsed : 0
})
return {
major,
minor,
patch,
prerelease,
}
}
function compareVersions(a: NormalizedVersion, b: NormalizedVersion): number {
if (a.major !== b.major) {
return a.major > b.major ? 1 : -1
}
if (a.minor !== b.minor) {
return a.minor > b.minor ? 1 : -1
}
if (a.patch !== b.patch) {
return a.patch > b.patch ? 1 : -1
}
const aPre = a.prerelease && a.prerelease.length > 0 ? a.prerelease : null
const bPre = b.prerelease && b.prerelease.length > 0 ? b.prerelease : null
if (aPre === bPre) {
return 0
}
if (!aPre) {
return 1
}
if (!bPre) {
return -1
}
return aPre.localeCompare(bPre)
}

View File

@@ -18,8 +18,14 @@ import { registerFilesystemRoutes } from "./routes/filesystem"
import { registerMetaRoutes } from "./routes/meta"
import { registerEventRoutes } from "./routes/events"
import { registerStorageRoutes } from "./routes/storage"
import { registerPluginRoutes } from "./routes/plugin"
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
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"
interface HttpServerDeps {
host: string
@@ -31,6 +37,7 @@ interface HttpServerDeps {
eventBus: EventBus
serverMeta: ServerMeta
instanceStore: InstanceStore
authManager: AuthManager
uiStaticDir: string
uiDevServerUrl?: string
logger: Logger
@@ -42,9 +49,13 @@ interface HttpServerStartResult {
displayHost: string
}
const DEFAULT_HTTP_PORT = 9898
export function createHttpServer(deps: HttpServerDeps) {
const app = Fastify({ logger: false })
const proxyLogger = deps.logger.child({ component: "proxy" })
const apiLogger = deps.logger.child({ component: "http" })
const sseLogger = deps.logger.child({ component: "sse" })
const sseClients = new Set<() => void>()
const registerSseClient = (cleanup: () => void) => {
@@ -58,47 +69,212 @@ export function createHttpServer(deps: HttpServerDeps) {
sseClients.clear()
}
app.addHook("onRequest", (request, _reply, done) => {
;(request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta = {
start: process.hrtime.bigint(),
}
done()
})
app.addHook("onResponse", (request, reply, done) => {
const meta = (request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta
const durationMs = meta ? Number((process.hrtime.bigint() - meta.start) / BigInt(1_000_000)) : undefined
const base = {
method: request.method,
url: request.url,
status: reply.statusCode,
durationMs,
}
apiLogger.debug(base, "HTTP request completed")
if (apiLogger.isLevelEnabled("trace")) {
apiLogger.trace({ ...base, params: request.params, query: request.query, body: request.body }, "HTTP request payload")
}
done()
})
const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"])
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
app.register(cors, {
origin: true,
origin: (origin, cb) => {
if (!origin) {
cb(null, true)
return
}
let selfOrigin: string | null = null
try {
selfOrigin = new URL(deps.serverMeta.httpBaseUrl).origin
} catch {
selfOrigin = null
}
if (selfOrigin && origin === selfOrigin) {
cb(null, true)
return
}
if (allowedDevOrigins.has(origin)) {
cb(null, true)
return
}
// When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access.
if (deps.host === "0.0.0.0" || !isLoopbackHost(deps.host)) {
cb(null, true)
return
}
cb(null, false)
},
credentials: true,
})
app.register(replyFrom, {
contentTypesToEncode: [],
undici: {
connections: 16,
pipelining: 1,
bodyTimeout: 0,
headersTimeout: 0,
},
})
const backgroundProcessManager = new BackgroundProcessManager({
workspaceManager: deps.workspaceManager,
eventBus: deps.eventBus,
logger: deps.logger.child({ component: "background-processes" }),
})
registerAuthRoutes(app, { authManager: deps.authManager })
app.addHook("preHandler", (request, reply, done) => {
const rawUrl = request.raw.url ?? request.url
const pathname = (rawUrl.split("?")[0] ?? "").trim()
const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout"])
const publicPagePaths = new Set(["/login"])
if (deps.authManager.isTokenBootstrapEnabled()) {
publicPagePaths.add("/auth/token")
}
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
done()
return
}
const session = deps.authManager.getSessionFromRequest(request)
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
if (requiresAuthForApi && !session) {
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
if (pluginMatch) {
const workspaceId = pluginMatch[1]
const expected = deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
const provided = Array.isArray(request.headers.authorization)
? request.headers.authorization[0]
: request.headers.authorization
if (expected && provided && provided === expected) {
done()
return
}
}
sendUnauthorized(request, reply)
return
}
if (!session && wantsHtml(request)) {
reply.redirect("/login")
return
}
done()
})
app.get("/", async (request, reply) => {
const session = deps.authManager.getSessionFromRequest(request)
if (!session) {
reply.redirect("/login")
return
}
if (deps.uiDevServerUrl) {
await proxyToDevServer(request, reply, deps.uiDevServerUrl)
return
}
const uiDir = deps.uiStaticDir
const indexPath = path.join(uiDir, "index.html")
if (uiDir && fs.existsSync(indexPath)) {
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
return
}
reply.code(404).send({ message: "UI bundle missing" })
})
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient })
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
registerStorageRoutes(app, {
instanceStore: deps.instanceStore,
eventBus: deps.eventBus,
workspaceManager: deps.workspaceManager,
})
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
if (deps.uiDevServerUrl) {
setupDevProxy(app, deps.uiDevServerUrl)
setupDevProxy(app, deps.uiDevServerUrl, deps.authManager)
} else {
setupStaticUi(app, deps.uiStaticDir)
setupStaticUi(app, deps.uiStaticDir, deps.authManager)
}
return {
instance: app,
start: async (): Promise<HttpServerStartResult> => {
const addressInfo = await app.listen({ port: deps.port, host: deps.host })
const attemptListen = async (requestedPort: number) => {
const addressInfo = await app.listen({ port: requestedPort, host: deps.host })
return { addressInfo, requestedPort }
}
let actualPort = deps.port
const autoPortRequested = deps.port === 0
const primaryPort = autoPortRequested ? DEFAULT_HTTP_PORT : deps.port
if (typeof addressInfo === "string") {
const shouldRetryWithEphemeral = (error: unknown) => {
if (!autoPortRequested) return false
const err = error as NodeJS.ErrnoException | undefined
return Boolean(err && err.code === "EADDRINUSE")
}
let listenResult
try {
listenResult = await attemptListen(primaryPort)
} catch (error) {
if (!shouldRetryWithEphemeral(error)) {
throw error
}
deps.logger.warn({ err: error, port: primaryPort }, "Preferred port unavailable, retrying on ephemeral port")
listenResult = await attemptListen(0)
}
let actualPort = listenResult.requestedPort
if (typeof listenResult.addressInfo === "string") {
try {
const parsed = new URL(addressInfo)
actualPort = Number(parsed.port) || deps.port
const parsed = new URL(listenResult.addressInfo)
actualPort = Number(parsed.port) || listenResult.requestedPort
} catch {
actualPort = deps.port
actualPort = listenResult.requestedPort
}
} else {
const address = app.server.address()
@@ -107,10 +283,13 @@ export function createHttpServer(deps: HttpServerDeps) {
}
}
const displayHost = deps.host === "0.0.0.0" ? "127.0.0.1" : deps.host === "127.0.0.1" ? "localhost" : deps.host
const displayHost = deps.host === "127.0.0.1" ? "localhost" : deps.host
const serverUrl = `http://${displayHost}:${actualPort}`
deps.serverMeta.httpBaseUrl = serverUrl
deps.serverMeta.host = deps.host
deps.serverMeta.port = actualPort
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" || !isLoopbackHost(deps.host) ? "all" : "local"
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
console.log(`CodeNomad Server is ready at ${serverUrl}`)
@@ -189,8 +368,20 @@ async function proxyWorkspaceRequest(args: {
const queryIndex = (request.raw.url ?? "").indexOf("?")
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId)
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
if (logger.isLevelEnabled("trace")) {
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
}
return reply.from(targetUrl, {
rewriteRequestHeaders: (_originalRequest, headers) => {
if (instanceAuthHeader) {
headers.authorization = instanceAuthHeader
}
return headers
},
onError: (proxyReply, { error }) => {
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
if (!proxyReply.sent) {
@@ -208,7 +399,7 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
return trimmed.length === 0 ? "/" : `/${trimmed}`
}
function setupStaticUi(app: FastifyInstance, uiDir: string) {
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
if (!uiDir) {
app.log.warn("UI static directory not provided; API endpoints only")
return
@@ -234,6 +425,12 @@ function setupStaticUi(app: FastifyInstance, uiDir: string) {
return
}
const session = authManager.getSessionFromRequest(request)
if (!session && wantsHtml(request)) {
reply.redirect("/login")
return
}
if (fs.existsSync(indexPath)) {
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
} else {
@@ -242,7 +439,7 @@ function setupStaticUi(app: FastifyInstance, uiDir: string) {
})
}
function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager: AuthManager) {
app.log.info({ upstreamBase }, "Proxying UI requests to development server")
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
const url = request.raw.url ?? ""
@@ -250,6 +447,13 @@ function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
reply.code(404).send({ message: "Not Found" })
return
}
const session = authManager.getSessionFromRequest(request)
if (!session && wantsHtml(request)) {
reply.redirect("/login")
return
}
void proxyToDevServer(request, reply, upstreamBase)
})
}

View File

@@ -0,0 +1,134 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CodeNomad Login</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 0 18px;
color: rgba(255, 255, 255, 0.7);
font-size: 13px;
line-height: 1.4;
}
label {
display: block;
font-size: 12px;
margin: 10px 0 6px;
color: rgba(255, 255, 255, 0.75);
}
input {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: #0f0f16;
color: #fff;
}
button {
width: 100%;
margin-top: 14px;
padding: 10px 12px;
border-radius: 10px;
border: 0;
background: #4c6fff;
color: #fff;
font-weight: 600;
cursor: pointer;
}
.error {
margin-top: 12px;
color: #ff6b6b;
font-size: 13px;
}
</style>
</head>
<body>
<div class="card">
<h1>Sign in</h1>
<p>This CodeNomad server is protected. Enter your credentials to continue.</p>
<label for="username">Username</label>
<input id="username" autocomplete="username" placeholder="{{DEFAULT_USERNAME}}" value="" />
<label for="password">Password</label>
<input id="password" type="password" autocomplete="current-password" value="" />
<button id="submit" type="button">Continue</button>
<div id="error" class="error" style="display: none"></div>
</div>
<script>
const $ = (id) => document.getElementById(id)
const errorEl = $("error")
const showError = (msg) => {
errorEl.textContent = msg
errorEl.style.display = "block"
}
const hideError = () => {
errorEl.textContent = ""
errorEl.style.display = "none"
}
async function submit() {
hideError()
const username = $("username").value.trim()
const password = $("password").value
if (!username || !password) {
showError("Username and password are required.")
return
}
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
credentials: "include",
})
if (!res.ok) {
let message = ""
try {
const json = await res.json()
message = json && json.error ? String(json.error) : ""
} catch {
message = ""
}
showError(message || `Login failed (${res.status})`)
return
}
window.location.href = "/"
} catch (e) {
showError(e && e.message ? e.message : String(e))
}
}
$("submit").addEventListener("click", submit)
$("password").addEventListener("keydown", (e) => {
if (e.key === "Enter") submit()
})
</script>
</body>
</html>

View File

@@ -0,0 +1,93 @@
<!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;
}
</style>
</head>
<body>
<div class="card">
<h1>Connecting…</h1>
<p>Finalizing local authentication.</p>
<div id="error" class="error" style="display: none"></div>
</div>
<script>
const token = (location.hash || "").replace(/^#/, "").trim()
const errorEl = document.getElementById("error")
const showError = (msg) => {
errorEl.textContent = msg
errorEl.style.display = "block"
}
async function run() {
if (!token) {
showError("Missing bootstrap token.")
return
}
try {
const res = await fetch("/api/auth/token", {
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("/")
} catch (e) {
showError(e && e.message ? e.message : String(e))
}
}
run()
</script>
</body>
</html>

View File

@@ -0,0 +1,157 @@
import type { FastifyInstance } from "fastify"
import fs from "fs"
import { z } from "zod"
import type { AuthManager } from "../../auth/manager"
import { isLoopbackAddress } from "../../auth/http-auth"
interface RouteDeps {
authManager: AuthManager
}
const LoginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
})
const TokenSchema = z.object({
token: z.string().min(1),
})
const PasswordSchema = z.object({
password: z.string().min(8),
})
const LOGIN_TEMPLATE_URL = new URL("./auth-pages/login.html", import.meta.url)
const TOKEN_TEMPLATE_URL = new URL("./auth-pages/token.html", import.meta.url)
let cachedLoginTemplate: string | null = null
let cachedTokenTemplate: string | null = null
function readTemplate(url: URL, cache: string | null): string {
if (cache) return cache
const content = fs.readFileSync(url, "utf-8")
return content
}
function getLoginHtml(defaultUsername: string): string {
if (!cachedLoginTemplate) {
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null)
}
const escapedUsername = escapeHtml(defaultUsername)
return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, escapedUsername)
}
function getTokenHtml(): string {
if (!cachedTokenTemplate) {
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_URL, null)
}
return cachedTokenTemplate
}
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/login", async (_request, reply) => {
const status = deps.authManager.getStatus()
reply.type("text/html").send(getLoginHtml(status.username))
})
app.get("/auth/token", async (request, reply) => {
if (!deps.authManager.isTokenBootstrapEnabled()) {
reply.code(404).send({ error: "Not found" })
return
}
if (!isLoopbackAddress(request.socket.remoteAddress)) {
reply.code(404).send({ error: "Not found" })
return
}
reply.type("text/html").send(getTokenHtml())
})
app.get("/api/auth/status", async (request, reply) => {
const session = deps.authManager.getSessionFromRequest(request)
if (!session) {
reply.send({ authenticated: false })
return
}
reply.send({ authenticated: true, ...deps.authManager.getStatus() })
})
app.post("/api/auth/login", async (request, reply) => {
const body = LoginSchema.parse(request.body ?? {})
const ok = deps.authManager.validateLogin(body.username, body.password)
if (!ok) {
reply.code(401).send({ error: "Invalid credentials" })
return
}
const session = deps.authManager.createSession(body.username)
deps.authManager.setSessionCookie(reply, session.id)
reply.send({ ok: true })
})
app.post("/api/auth/token", async (request, reply) => {
if (!deps.authManager.isTokenBootstrapEnabled()) {
reply.code(404).send({ error: "Not found" })
return
}
if (!isLoopbackAddress(request.socket.remoteAddress)) {
reply.code(404).send({ error: "Not found" })
return
}
const body = TokenSchema.parse(request.body ?? {})
const ok = deps.authManager.consumeBootstrapToken(body.token)
if (!ok) {
reply.code(401).send({ error: "Invalid token" })
return
}
const username = deps.authManager.getStatus().username
const session = deps.authManager.createSession(username)
deps.authManager.setSessionCookie(reply, session.id)
reply.send({ ok: true })
})
app.post("/api/auth/logout", async (_request, reply) => {
deps.authManager.clearSessionCookie(reply)
reply.send({ ok: true })
})
app.post("/api/auth/password", async (request, reply) => {
const session = deps.authManager.getSessionFromRequest(request)
if (!session) {
reply.code(401).send({ error: "Unauthorized" })
return
}
const body = PasswordSchema.parse(request.body ?? {})
try {
const status = deps.authManager.setPassword(body.password)
reply.send({ ok: true, ...status })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
reply.code(409).type("text/plain").send(message)
}
})
}
function escapeHtml(value: string) {
return value.replace(/[&<>"]/g, (char) => {
switch (char) {
case "&":
return "&amp;"
case "<":
return "&lt;"
case ">":
return "&gt;"
case '"':
return "&quot;"
default:
return char
}
})
}

View File

@@ -0,0 +1,85 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import type { BackgroundProcessManager } from "../../background-processes/manager"
interface RouteDeps {
backgroundProcessManager: BackgroundProcessManager
}
const StartSchema = z.object({
title: z.string().trim().min(1),
command: z.string().trim().min(1),
})
const OutputQuerySchema = z.object({
method: z.enum(["full", "tail", "head", "grep"]).optional(),
mode: z.enum(["full", "tail", "head", "grep"]).optional(),
pattern: z.string().optional(),
lines: z.coerce.number().int().positive().max(2000).optional(),
maxBytes: z.coerce.number().int().positive().optional(),
})
export function registerBackgroundProcessRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request) => {
const processes = await deps.backgroundProcessManager.list(request.params.id)
return { processes }
})
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)
reply.code(201)
return process
})
app.post<{ Params: { id: string; processId: string } }>(
"/workspaces/:id/plugin/background-processes/:processId/stop",
async (request, reply) => {
const process = await deps.backgroundProcessManager.stop(request.params.id, request.params.processId)
if (!process) {
reply.code(404)
return { error: "Process not found" }
}
return process
},
)
app.post<{ Params: { id: string; processId: string } }>(
"/workspaces/:id/plugin/background-processes/:processId/terminate",
async (request, reply) => {
await deps.backgroundProcessManager.terminate(request.params.id, request.params.processId)
reply.code(204)
return undefined
},
)
app.get<{ Params: { id: string; processId: string } }>(
"/workspaces/:id/plugin/background-processes/:processId/output",
async (request, reply) => {
const query = OutputQuerySchema.parse(request.query ?? {})
const method = query.method ?? query.mode
if (method === "grep" && !query.pattern) {
reply.code(400)
return { error: "Pattern is required for grep output" }
}
try {
return await deps.backgroundProcessManager.readOutput(request.params.id, request.params.processId, {
method,
pattern: query.pattern,
lines: query.lines,
maxBytes: query.maxBytes,
})
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid output request" }
}
},
)
app.get<{ Params: { id: string; processId: string } }>(
"/workspaces/:id/plugin/background-processes/:processId/stream",
async (request, reply) => {
await deps.backgroundProcessManager.streamOutput(request.params.id, request.params.processId, reply)
},
)
}

View File

@@ -1,14 +1,21 @@
import { FastifyInstance } from "fastify"
import { EventBus } from "../../events/bus"
import { WorkspaceEventPayload } from "../../api-types"
import { Logger } from "../../logger"
interface RouteDeps {
eventBus: EventBus
registerClient: (cleanup: () => void) => () => void
logger: Logger
}
let nextClientId = 0
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/events", (request, reply) => {
const clientId = ++nextClientId
deps.logger.debug({ clientId }, "SSE client connected")
const origin = request.headers.origin ?? "*"
reply.raw.setHeader("Access-Control-Allow-Origin", origin)
reply.raw.setHeader("Access-Control-Allow-Credentials", "true")
@@ -19,6 +26,10 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
reply.hijack()
const send = (event: WorkspaceEventPayload) => {
deps.logger.debug({ clientId, type: event.type }, "SSE event dispatched")
if (deps.logger.isLevelEnabled("trace")) {
deps.logger.trace({ clientId, event }, "SSE event payload")
}
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
}
@@ -34,6 +45,7 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
clearInterval(heartbeat)
unsubscribe()
reply.raw.end?.()
deps.logger.debug({ clientId }, "SSE client disconnected")
}
const unregister = deps.registerClient(close)

View File

@@ -11,6 +11,11 @@ const FilesystemQuerySchema = z.object({
includeFiles: z.coerce.boolean().optional(),
})
const FilesystemCreateFolderSchema = z.object({
parentPath: z.string().optional(),
name: z.string(),
})
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/filesystem", async (request, reply) => {
const query = FilesystemQuerySchema.parse(request.query ?? {})
@@ -24,4 +29,26 @@ export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps)
return { error: (error as Error).message }
}
})
app.post("/api/filesystem/folders", async (request, reply) => {
const body = FilesystemCreateFolderSchema.parse(request.body ?? {})
try {
const created = deps.fileSystemBrowser.createFolder(body.parentPath, body.name)
reply.code(201)
return created
} catch (error) {
const err = error as NodeJS.ErrnoException
if (err?.code === "EEXIST") {
reply.code(409).type("text/plain").send("Folder already exists")
return
}
if (err?.code === "EACCES" || err?.code === "EPERM") {
reply.code(403).type("text/plain").send("Permission denied")
return
}
reply.code(400).type("text/plain").send((error as Error).message)
}
})
}

View File

@@ -1,10 +1,108 @@
import { FastifyInstance } from "fastify"
import { ServerMeta } from "../../api-types"
import os from "os"
import { NetworkAddress, ServerMeta } from "../../api-types"
interface RouteDeps {
serverMeta: ServerMeta
}
export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/meta", async () => deps.serverMeta)
app.get("/api/meta", async () => buildMetaResponse(deps.serverMeta))
}
function buildMetaResponse(meta: ServerMeta): ServerMeta {
const port = resolvePort(meta)
const addresses = port > 0 ? resolveAddresses(port, meta.host) : []
return {
...meta,
port,
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
addresses,
}
}
function resolvePort(meta: ServerMeta): number {
if (Number.isInteger(meta.port) && meta.port > 0) {
return meta.port
}
try {
const parsed = new URL(meta.httpBaseUrl)
const port = Number(parsed.port)
return Number.isInteger(port) && port > 0 ? port : 0
} catch {
return 0
}
}
function isLoopbackHost(host: string): boolean {
return host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
}
function resolveAddresses(port: number, host: string): NetworkAddress[] {
const interfaces = os.networkInterfaces()
const seen = new Set<string>()
const results: NetworkAddress[] = []
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
if (!ip || ip === "0.0.0.0") return
const key = `ipv4-${ip}`
if (seen.has(key)) return
seen.add(key)
results.push({ ip, family: "ipv4", scope, url: `http://${ip}:${port}` })
}
const normalizeFamily = (value: string | number) => {
if (typeof value === "string") {
const lowered = value.toLowerCase()
if (lowered === "ipv4") {
return "ipv4" as const
}
}
if (value === 4) return "ipv4" as const
return null
}
if (host === "0.0.0.0") {
// Enumerate system interfaces (IPv4 only)
for (const entries of Object.values(interfaces)) {
if (!entries) continue
for (const entry of entries) {
const family = normalizeFamily(entry.family)
if (!family) continue
if (!entry.address || entry.address === "0.0.0.0") continue
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
addAddress(entry.address, scope)
}
}
}
// Always include loopback address
addAddress("127.0.0.1", "loopback")
// Include explicitly configured host if it was IPv4
if (isIPv4Address(host) && host !== "0.0.0.0") {
const isLoopback = host.startsWith("127.")
addAddress(host, isLoopback ? "loopback" : "external")
}
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
return results.sort((a, b) => {
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
if (scopeDelta !== 0) return scopeDelta
return a.ip.localeCompare(b.ip)
})
}
function isIPv4Address(value: string | undefined): value is string {
if (!value) return false
const parts = value.split(".")
if (parts.length !== 4) return false
return parts.every((part) => {
if (part.length === 0 || part.length > 3) return false
if (!/^[0-9]+$/.test(part)) return false
const num = Number(part)
return Number.isInteger(num) && num >= 0 && num <= 255
})
}

View File

@@ -0,0 +1,75 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
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"
interface RouteDeps {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
}
const PluginEventSchema = z.object({
type: z.string().min(1),
properties: z.record(z.unknown()).optional(),
})
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404).send({ error: "Workspace not found" })
return
}
reply.raw.setHeader("Content-Type", "text/event-stream")
reply.raw.setHeader("Cache-Control", "no-cache")
reply.raw.setHeader("Connection", "keep-alive")
reply.raw.flushHeaders?.()
reply.hijack()
const registration = channel.register(request.params.id, reply)
const heartbeat = setInterval(() => {
channel.send(request.params.id, buildPingEvent())
}, 15000)
const close = () => {
clearInterval(heartbeat)
registration.close()
reply.raw.end?.()
}
request.raw.on("close", close)
request.raw.on("error", close)
})
const handleWildcard = async (request: any, reply: any) => {
const workspaceId = request.params.id as string
const workspace = deps.workspaceManager.get(workspaceId)
if (!workspace) {
reply.code(404).send({ error: "Workspace not found" })
return
}
const suffix = (request.params["*"] as string | undefined) ?? ""
const normalized = suffix.replace(/^\/+/, "")
if (normalized === "event" && request.method === "POST") {
const parsed = PluginEventSchema.parse(request.body ?? {})
handlePluginEvent(workspaceId, parsed, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: deps.logger })
reply.code(204).send()
return
}
reply.code(404).send({ error: "Unknown plugin endpoint" })
}
app.all("/workspaces/:id/plugin/*", handleWildcard)
app.all("/workspaces/:id/plugin", handleWildcard)
}

View File

@@ -35,10 +35,16 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
})
app.post("/api/workspaces", async (request, reply) => {
const body = WorkspaceCreateSchema.parse(request.body ?? {})
const workspace = await deps.workspaceManager.create(body.path, body.name)
reply.code(201)
return workspace
try {
const body = WorkspaceCreateSchema.parse(request.body ?? {})
const workspace = await deps.workspaceManager.create(body.path, body.name)
reply.code(201)
return workspace
} catch (error) {
request.log.error({ err: error }, "Failed to create workspace")
const message = error instanceof Error ? error.message : "Failed to create workspace"
reply.code(400).type("text/plain").send(message)
}
})
app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {

View File

@@ -0,0 +1,58 @@
import assert from "node:assert/strict"
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { mkdir } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { afterEach, beforeEach, describe, it } from "node:test"
import type { Logger } from "../../logger"
import { resolveUi } from "../remote-ui"
const noopLogger: Logger = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
trace: () => {},
child: () => noopLogger,
isLevelEnabled: () => false,
} as any
let tempRoot: string
beforeEach(() => {
tempRoot = mkdtempSync(path.join(os.tmpdir(), "codenomad-ui-test-"))
})
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true })
})
describe("resolveUi local version preference", () => {
it("prefers bundled when bundled version is higher", 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.0" }))
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

@@ -0,0 +1,571 @@
import { createHash } from "crypto"
import fs from "fs"
import { promises as fsp } from "fs"
import os from "os"
import path from "path"
import { Readable } from "stream"
import { fetch } from "undici"
import yauzl from "yauzl"
import type { Logger } from "../logger"
export interface RemoteUiManifest {
minServerVersion: string
latestUIVersion: string
uiPackageURL: string
sha256: string
latestServerVersion?: string
latestServerUrl?: string
}
export type UiSource = "bundled" | "downloaded" | "previous" | "override" | "dev-proxy" | "missing"
export interface UiResolution {
uiStaticDir?: string
uiDevServerUrl?: string
source: UiSource
uiVersion?: string
supported: boolean
message?: string
latestServerVersion?: string
latestServerUrl?: string
minServerVersion?: string
}
export interface RemoteUiOptions {
serverVersion: string
bundledUiDir: string
autoUpdate: boolean
overrideUiDir?: string
uiDevServerUrl?: string
manifestUrl?: string
configDir?: string
logger: Logger
}
const DEFAULT_MANIFEST_URL = "https://ui.codenomad.neuralnomads.ai/version.json"
const MANIFEST_TIMEOUT_MS = 5_000
const ZIP_TIMEOUT_MS = 30_000
export async function resolveUi(options: RemoteUiOptions): Promise<UiResolution> {
const manifestUrl = options.manifestUrl ?? DEFAULT_MANIFEST_URL
if (options.uiDevServerUrl) {
return {
uiDevServerUrl: options.uiDevServerUrl,
source: "dev-proxy",
supported: true,
}
}
if (options.overrideUiDir) {
const resolved = await resolveStaticUiDir(options.overrideUiDir)
return {
uiStaticDir: resolved ?? options.overrideUiDir,
source: "override",
uiVersion: await readUiVersion(resolved ?? options.overrideUiDir),
supported: true,
}
}
const uiRoot = resolveUiCacheRoot(options.configDir)
const currentDir = path.join(uiRoot, "current")
const previousDir = path.join(uiRoot, "previous")
if (!options.autoUpdate) {
return await resolveFromCacheOrBundled({
logger: options.logger,
bundledUiDir: options.bundledUiDir,
currentDir,
previousDir,
supported: true,
})
}
let manifest: RemoteUiManifest | null = null
try {
manifest = await fetchManifest(manifestUrl, options.logger)
} catch (error) {
options.logger.debug({ err: error }, "Remote UI manifest unavailable; using cached/bundled UI")
}
if (!manifest) {
return await resolveFromCacheOrBundled({
logger: options.logger,
bundledUiDir: options.bundledUiDir,
currentDir,
previousDir,
supported: true,
})
}
const supported = compareSemverCore(options.serverVersion, manifest.minServerVersion) >= 0
if (!supported) {
const message = "Upgrade App to use latest features"
return await resolveFromCacheOrBundled({
logger: options.logger,
bundledUiDir: options.bundledUiDir,
currentDir,
previousDir,
supported: false,
message,
latestServerVersion: manifest.latestServerVersion,
latestServerUrl: manifest.latestServerUrl,
minServerVersion: manifest.minServerVersion,
})
}
const bestLocal = await pickBestLocalUi({
logger: options.logger,
bundledUiDir: options.bundledUiDir,
currentDir,
previousDir,
})
const remoteIsNewer =
!bestLocal ||
compareSemverMaybe(manifest.latestUIVersion, bestLocal.uiVersion) > 0
if (!remoteIsNewer) {
return await resolveFromCacheOrBundled({
logger: options.logger,
bundledUiDir: options.bundledUiDir,
currentDir,
previousDir,
supported: true,
latestServerVersion: manifest.latestServerVersion,
latestServerUrl: manifest.latestServerUrl,
minServerVersion: manifest.minServerVersion,
})
}
try {
await installRemoteUi({
manifest,
uiRoot,
currentDir,
previousDir,
logger: options.logger,
})
} catch (error) {
options.logger.warn({ err: error }, "Failed to install remote UI; falling back")
return await resolveFromCacheOrBundled({
logger: options.logger,
bundledUiDir: options.bundledUiDir,
currentDir,
previousDir,
supported: true,
latestServerVersion: manifest.latestServerVersion,
latestServerUrl: manifest.latestServerUrl,
minServerVersion: manifest.minServerVersion,
})
}
const installed = await resolveStaticUiDir(currentDir)
if (installed) {
return {
uiStaticDir: installed,
source: "downloaded",
uiVersion: await readUiVersion(installed),
supported: true,
latestServerVersion: manifest.latestServerVersion,
latestServerUrl: manifest.latestServerUrl,
minServerVersion: manifest.minServerVersion,
}
}
return await resolveFromCacheOrBundled({
logger: options.logger,
bundledUiDir: options.bundledUiDir,
currentDir,
previousDir,
supported: true,
latestServerVersion: manifest.latestServerVersion,
latestServerUrl: manifest.latestServerUrl,
minServerVersion: manifest.minServerVersion,
})
}
function resolveUiCacheRoot(configDir?: string): string {
if (configDir) {
return path.join(configDir, "ui")
}
return path.join(os.homedir(), ".config", "codenomad", "ui")
}
async function resolveFromCacheOrBundled(args: {
logger: Logger
bundledUiDir: string
currentDir: string
previousDir: string
supported: boolean
message?: string
latestServerVersion?: string
latestServerUrl?: string
minServerVersion?: string
}): Promise<UiResolution> {
const bestLocal = await pickBestLocalUi({
logger: args.logger,
bundledUiDir: args.bundledUiDir,
currentDir: args.currentDir,
previousDir: args.previousDir,
})
if (bestLocal) {
return {
uiStaticDir: bestLocal.uiStaticDir,
source: bestLocal.source,
uiVersion: bestLocal.uiVersion,
supported: args.supported,
message: args.message,
latestServerVersion: args.latestServerVersion,
latestServerUrl: args.latestServerUrl,
minServerVersion: args.minServerVersion,
}
}
args.logger.warn({ bundledUiDir: args.bundledUiDir }, "No UI assets found")
return {
uiStaticDir: args.bundledUiDir,
source: "missing",
supported: args.supported,
message: args.message,
latestServerVersion: args.latestServerVersion,
latestServerUrl: args.latestServerUrl,
minServerVersion: args.minServerVersion,
}
}
async function pickBestLocalUi(args: {
logger: Logger
bundledUiDir: string
currentDir: string
previousDir: string
}): Promise<{ uiStaticDir: string; source: UiSource; uiVersion?: string } | null> {
const candidates: Array<{ uiStaticDir: string; source: UiSource; uiVersion?: string; priority: number }> = []
const currentResolved = await resolveStaticUiDir(args.currentDir)
if (currentResolved) {
candidates.push({
uiStaticDir: currentResolved,
source: "downloaded",
uiVersion: await readUiVersion(currentResolved),
priority: 2,
})
}
const bundledResolved = await resolveStaticUiDir(args.bundledUiDir)
if (bundledResolved) {
candidates.push({
uiStaticDir: bundledResolved,
source: "bundled",
uiVersion: await readUiVersion(bundledResolved),
priority: 1,
})
}
const previousResolved = await resolveStaticUiDir(args.previousDir)
if (previousResolved) {
candidates.push({
uiStaticDir: previousResolved,
source: "previous",
uiVersion: await readUiVersion(previousResolved),
priority: 0,
})
}
if (candidates.length === 0) {
return null
}
candidates.sort((a, b) => {
const versionCmp = compareSemverMaybe(a.uiVersion, b.uiVersion)
if (versionCmp !== 0) return -versionCmp
return b.priority - a.priority
})
const best = candidates[0]
if (!best) return null
return { uiStaticDir: best.uiStaticDir, source: best.source, uiVersion: best.uiVersion }
}
function compareSemverMaybe(a: string | undefined, b: string | undefined): number {
if (!a && !b) return 0
if (!a) return -1
if (!b) return 1
return compareSemverCore(a, b)
}
async function resolveStaticUiDir(uiDir: string): Promise<string | null> {
try {
const indexPath = path.join(uiDir, "index.html")
await fsp.access(indexPath, fs.constants.R_OK)
return uiDir
} catch {
return null
}
}
interface UiVersionFile {
uiVersion?: string
version?: string
}
async function readUiVersion(uiDir: string): Promise<string | undefined> {
try {
const content = await fsp.readFile(path.join(uiDir, "ui-version.json"), "utf-8")
const parsed = JSON.parse(content) as UiVersionFile
return parsed.uiVersion ?? parsed.version
} catch {
return undefined
}
}
async function fetchManifest(url: string, logger: Logger): Promise<RemoteUiManifest> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), MANIFEST_TIMEOUT_MS)
try {
const response = await fetch(url, {
signal: controller.signal,
headers: {
Accept: "application/json",
"User-Agent": "CodeNomad-CLI",
},
})
if (!response.ok) {
throw new Error(`Manifest responded with ${response.status}`)
}
const json = (await response.json()) as RemoteUiManifest
validateManifest(json)
return json
} catch (error) {
logger.debug({ err: error, url }, "Failed to fetch remote UI manifest")
throw error
} finally {
clearTimeout(timeout)
}
}
function validateManifest(manifest: RemoteUiManifest) {
const required: Array<keyof RemoteUiManifest> = ["minServerVersion", "latestUIVersion", "uiPackageURL", "sha256"]
for (const key of required) {
const value = manifest[key]
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`Manifest missing ${key}`)
}
}
if (!/^https:\/\//i.test(manifest.uiPackageURL)) {
throw new Error("uiPackageURL must be https")
}
if (!/^[a-f0-9]{64}$/i.test(manifest.sha256.trim())) {
throw new Error("sha256 must be 64 hex chars")
}
}
async function installRemoteUi(args: {
manifest: RemoteUiManifest
uiRoot: string
currentDir: string
previousDir: string
logger: Logger
}) {
await fsp.mkdir(args.uiRoot, { recursive: true })
const tmpDir = path.join(args.uiRoot, `tmp-${Date.now()}`)
const zipPath = path.join(args.uiRoot, `ui-${args.manifest.latestUIVersion}.zip`)
try {
await downloadFile(args.manifest.uiPackageURL, zipPath, args.logger)
const digest = await sha256File(zipPath)
if (digest.toLowerCase() !== args.manifest.sha256.toLowerCase()) {
throw new Error(`sha256 mismatch for UI zip (expected ${args.manifest.sha256}, got ${digest})`)
}
await extractZip(zipPath, tmpDir)
const indexPath = path.join(tmpDir, "index.html")
if (!fs.existsSync(indexPath)) {
throw new Error("Extracted UI missing index.html")
}
await rotateDirs({ currentDir: args.currentDir, previousDir: args.previousDir, logger: args.logger })
fs.rmSync(args.currentDir, { recursive: true, force: true })
fs.renameSync(tmpDir, args.currentDir)
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true })
fs.rmSync(zipPath, { force: true })
}
}
async function rotateDirs(args: { currentDir: string; previousDir: string; logger: Logger }) {
try {
if (fs.existsSync(args.previousDir)) {
fs.rmSync(args.previousDir, { recursive: true, force: true })
}
if (fs.existsSync(args.currentDir)) {
fs.renameSync(args.currentDir, args.previousDir)
}
} catch (error) {
args.logger.warn({ err: error }, "Failed to rotate UI cache directories")
}
}
async function downloadFile(url: string, targetPath: string, logger: Logger) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), ZIP_TIMEOUT_MS)
try {
const response = await fetch(url, {
signal: controller.signal,
headers: {
Accept: "application/octet-stream",
"User-Agent": "CodeNomad-CLI",
},
})
if (!response.ok || !response.body) {
throw new Error(`UI zip download failed with ${response.status}`)
}
await fsp.mkdir(path.dirname(targetPath), { recursive: true })
const fileStream = fs.createWriteStream(targetPath)
const body = response.body
if (!body) {
throw new Error("UI zip response missing body")
}
const nodeStream = Readable.fromWeb(body as any)
await new Promise<void>((resolve, reject) => {
nodeStream.pipe(fileStream)
nodeStream.on("error", reject)
fileStream.on("error", reject)
fileStream.on("finish", () => resolve())
})
logger.debug({ url, targetPath }, "Downloaded remote UI bundle")
} finally {
clearTimeout(timeout)
}
}
async function sha256File(filePath: string): Promise<string> {
const hash = createHash("sha256")
const stream = fs.createReadStream(filePath)
await new Promise<void>((resolve, reject) => {
stream.on("data", (chunk) => hash.update(chunk))
stream.on("error", reject)
stream.on("end", () => resolve())
})
return hash.digest("hex")
}
async function extractZip(zipPath: string, targetDir: string): Promise<void> {
await fsp.mkdir(targetDir, { recursive: true })
await new Promise<void>((resolve, reject) => {
yauzl.open(zipPath, { lazyEntries: true }, (openErr, zipfile) => {
if (openErr || !zipfile) {
reject(openErr ?? new Error("Unable to open zip"))
return
}
const root = path.resolve(targetDir)
const closeWithError = (error: unknown) => {
try {
zipfile.close()
} catch {
// ignore
}
reject(error)
}
zipfile.readEntry()
zipfile.on("entry", (entry) => {
// Normalize and guard against zip-slip.
const entryPath = entry.fileName.replace(/\\/g, "/")
const segments = entryPath.split("/").filter(Boolean)
if (segments.some((segment: string) => segment === "..") || path.isAbsolute(entryPath)) {
closeWithError(new Error(`Invalid zip entry path: ${entry.fileName}`))
return
}
const destination = path.resolve(targetDir, entryPath)
if (!destination.startsWith(root + path.sep) && destination !== root) {
closeWithError(new Error(`Zip entry escapes target dir: ${entry.fileName}`))
return
}
const isDirectory = entry.fileName.endsWith("/")
if (isDirectory) {
fsp
.mkdir(destination, { recursive: true })
.then(() => zipfile.readEntry())
.catch((error) => closeWithError(error))
return
}
fsp
.mkdir(path.dirname(destination), { recursive: true })
.then(() => {
zipfile.openReadStream(entry, (streamErr, readStream) => {
if (streamErr || !readStream) {
closeWithError(streamErr ?? new Error("Unable to read zip entry"))
return
}
const writeStream = fs.createWriteStream(destination)
const cleanup = (error?: unknown) => {
readStream.destroy()
writeStream.destroy()
if (error) {
closeWithError(error)
}
}
readStream.on("error", cleanup)
writeStream.on("error", cleanup)
writeStream.on("finish", () => zipfile.readEntry())
readStream.pipe(writeStream)
})
})
.catch((error) => closeWithError(error))
})
zipfile.on("end", () => {
zipfile.close()
resolve()
})
zipfile.on("error", (error) => closeWithError(error))
})
})
}
function compareSemverCore(a: string, b: string): number {
const pa = parseSemverCore(a)
const pb = parseSemverCore(b)
if (pa.major !== pb.major) return pa.major > pb.major ? 1 : -1
if (pa.minor !== pb.minor) return pa.minor > pb.minor ? 1 : -1
if (pa.patch !== pb.patch) return pa.patch > pb.patch ? 1 : -1
return 0
}
function parseSemverCore(value: string): { major: number; minor: number; patch: number } {
const core = value.trim().replace(/^v/i, "").split("-", 1)[0] ?? "0.0.0"
const parts = core.split(".")
const parsePart = (input: string | undefined) => {
const n = Number.parseInt((input ?? "0").replace(/[^0-9]/g, ""), 10)
return Number.isFinite(n) ? n : 0
}
return {
major: parsePart(parts[0]),
minor: parsePart(parts[1]),
patch: parsePart(parts[2]),
}
}

View File

@@ -0,0 +1,202 @@
import { Agent, fetch } from "undici"
import { Agent as UndiciAgent } from "undici"
import { EventBus } from "../events/bus"
import { Logger } from "../logger"
import { WorkspaceManager } from "./manager"
import { InstanceStreamEvent, InstanceStreamStatus } from "../api-types"
const INSTANCE_HOST = "127.0.0.1"
const STREAM_AGENT = new UndiciAgent({ bodyTimeout: 0, headersTimeout: 0 })
const RECONNECT_DELAY_MS = 1000
interface InstanceEventBridgeOptions {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
}
interface ActiveStream {
controller: AbortController
task: Promise<void>
}
export class InstanceEventBridge {
private readonly streams = new Map<string, ActiveStream>()
constructor(private readonly options: InstanceEventBridgeOptions) {
const bus = this.options.eventBus
bus.on("workspace.started", (event) => this.startStream(event.workspace.id))
bus.on("workspace.stopped", (event) => this.stopStream(event.workspaceId, "workspace stopped"))
bus.on("workspace.error", (event) => this.stopStream(event.workspace.id, "workspace error"))
}
shutdown() {
for (const [id, active] of this.streams) {
active.controller.abort()
this.publishStatus(id, "disconnected")
}
this.streams.clear()
}
private startStream(workspaceId: string) {
if (this.streams.has(workspaceId)) {
return
}
const controller = new AbortController()
const task = this.runStream(workspaceId, controller.signal)
.catch((error) => {
if (!controller.signal.aborted) {
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream failed")
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
}
})
.finally(() => {
const active = this.streams.get(workspaceId)
if (active?.controller === controller) {
this.streams.delete(workspaceId)
}
})
this.streams.set(workspaceId, { controller, task })
}
private stopStream(workspaceId: string, reason?: string) {
const active = this.streams.get(workspaceId)
if (!active) {
return
}
active.controller.abort()
this.streams.delete(workspaceId)
this.publishStatus(workspaceId, "disconnected", reason)
}
private async runStream(workspaceId: string, signal: AbortSignal) {
while (!signal.aborted) {
const port = this.options.workspaceManager.getInstancePort(workspaceId)
if (!port) {
await this.delay(RECONNECT_DELAY_MS, signal)
continue
}
this.publishStatus(workspaceId, "connecting")
try {
await this.consumeStream(workspaceId, port, signal)
} catch (error) {
if (signal.aborted) {
break
}
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream disconnected")
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
await this.delay(RECONNECT_DELAY_MS, signal)
}
}
}
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
const url = `http://${INSTANCE_HOST}:${port}/event`
const headers: Record<string, string> = { Accept: "text/event-stream" }
const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
if (authHeader) {
headers["Authorization"] = authHeader
}
const response = await fetch(url, {
headers,
signal,
dispatcher: STREAM_AGENT,
})
if (!response.ok || !response.body) {
throw new Error(`Instance event stream unavailable (${response.status})`)
}
this.publishStatus(workspaceId, "connected")
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (!signal.aborted) {
const { done, value } = await reader.read()
if (done || !value) {
break
}
buffer += decoder.decode(value, { stream: true })
buffer = this.flushEvents(buffer, workspaceId)
}
}
private flushEvents(buffer: string, workspaceId: string) {
let separatorIndex = buffer.indexOf("\n\n")
while (separatorIndex >= 0) {
const chunk = buffer.slice(0, separatorIndex)
buffer = buffer.slice(separatorIndex + 2)
this.processChunk(chunk, workspaceId)
separatorIndex = buffer.indexOf("\n\n")
}
return buffer
}
private processChunk(chunk: string, workspaceId: string) {
const lines = chunk.split(/\r?\n/)
const dataLines: string[] = []
for (const line of lines) {
if (line.startsWith(":")) {
continue
}
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart())
}
}
if (dataLines.length === 0) {
return
}
const payload = dataLines.join("\n").trim()
if (!payload) {
return
}
try {
const event = JSON.parse(payload) as InstanceStreamEvent
this.options.logger.debug({ workspaceId, eventType: event.type }, "Instance SSE event received")
if (this.options.logger.isLevelEnabled("trace")) {
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
}
this.options.eventBus.publish({ type: "instance.event", instanceId: workspaceId, event })
} catch (error) {
this.options.logger.warn({ workspaceId, chunk: payload, err: error }, "Failed to parse instance SSE payload")
}
}
private publishStatus(instanceId: string, status: InstanceStreamStatus, reason?: string) {
this.options.logger.debug({ instanceId, status, reason }, "Instance SSE status updated")
this.options.eventBus.publish({ type: "instance.eventStatus", instanceId, status, reason })
}
private delay(duration: number, signal: AbortSignal) {
if (duration <= 0) {
return Promise.resolve()
}
return new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
signal.removeEventListener("abort", onAbort)
resolve()
}, duration)
const onAbort = () => {
clearTimeout(timeout)
resolve()
}
signal.addEventListener("abort", onAbort, { once: true })
})
}
}

View File

@@ -1,4 +1,6 @@
import path from "path"
import { spawnSync } from "child_process"
import { connect } from "net"
import { EventBus } from "../events/bus"
import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
@@ -6,8 +8,18 @@ import { FileSystemBrowser } from "../filesystem/browser"
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime } from "./runtime"
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
import { Logger } from "../logger"
import { getOpencodeConfigDir } from "../opencode-config.js"
import {
buildOpencodeBasicAuthHeader,
DEFAULT_OPENCODE_USERNAME,
generateOpencodeServerPassword,
OPENCODE_SERVER_PASSWORD_ENV,
OPENCODE_SERVER_USERNAME_ENV,
} from "./opencode-auth"
const STARTUP_STABILITY_DELAY_MS = 1500
interface WorkspaceManagerOptions {
rootDir: string
@@ -15,6 +27,7 @@ interface WorkspaceManagerOptions {
binaryRegistry: BinaryRegistry
eventBus: EventBus
logger: Logger
getServerBaseUrl: () => string
}
interface WorkspaceRecord extends WorkspaceDescriptor {}
@@ -22,9 +35,12 @@ interface WorkspaceRecord extends WorkspaceDescriptor {}
export class WorkspaceManager {
private readonly workspaces = new Map<string, WorkspaceRecord>()
private readonly runtime: WorkspaceRuntime
private readonly opencodeConfigDir: string
private readonly opencodeAuth = new Map<string, { username: string; password: string; authorization: string }>()
constructor(private readonly options: WorkspaceManagerOptions) {
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
this.opencodeConfigDir = getOpencodeConfigDir()
}
list(): WorkspaceDescriptor[] {
@@ -39,6 +55,10 @@ export class WorkspaceManager {
return this.workspaces.get(id)?.port
}
getInstanceAuthorizationHeader(id: string): string | undefined {
return this.opencodeAuth.get(id)?.authorization
}
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
const workspace = this.requireWorkspace(workspaceId)
const browser = new FileSystemBrowser({ rootDir: workspace.path })
@@ -65,10 +85,11 @@ export class WorkspaceManager {
const id = `${Date.now().toString(36)}`
const binary = this.options.binaryRegistry.resolveDefault()
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
clearWorkspaceSearchCache(workspacePath)
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: binary.path }, "Creating workspace")
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
const proxyPath = `/workspaces/${id}/instance`
@@ -79,27 +100,53 @@ export class WorkspaceManager {
name,
status: "starting",
proxyPath,
binaryId: binary.id,
binaryId: resolvedBinaryPath,
binaryLabel: binary.label,
binaryVersion: binary.version,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
if (!descriptor.binaryVersion) {
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
}
this.workspaces.set(id, descriptor)
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
const preferences = this.options.configStore.get().preferences ?? {}
const userEnvironment = preferences.environmentVariables ?? {}
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
const opencodePassword = generateOpencodeServerPassword()
const authorization = buildOpencodeBasicAuthHeader({ username: opencodeUsername, password: opencodePassword })
if (!authorization) {
throw new Error("Failed to build OpenCode auth header")
}
this.opencodeAuth.set(id, { username: opencodeUsername, password: opencodePassword, authorization })
const environment = {
...userEnvironment,
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
CODENOMAD_INSTANCE_ID: id,
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
[OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername,
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
}
try {
const { pid, port } = await this.runtime.launch({
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
workspaceId: id,
folder: workspacePath,
binaryPath: binary.path,
binaryPath: resolvedBinaryPath,
environment,
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
})
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
descriptor.pid = pid
descriptor.port = port
descriptor.status = "ready"
@@ -130,6 +177,7 @@ export class WorkspaceManager {
}
this.workspaces.delete(id)
this.opencodeAuth.delete(id)
clearWorkspaceSearchCache(workspace.path)
if (!wasRunning) {
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
@@ -150,6 +198,7 @@ export class WorkspaceManager {
}
}
this.workspaces.clear()
this.opencodeAuth.clear()
this.options.logger.info("All workspaces cleared")
}
@@ -161,10 +210,256 @@ export class WorkspaceManager {
return workspace
}
private resolveBinaryPath(identifier: string): string {
if (!identifier) {
return identifier
}
const looksLikePath = identifier.includes("/") || identifier.includes("\\") || identifier.startsWith(".")
if (path.isAbsolute(identifier) || looksLikePath) {
return identifier
}
const locator = process.platform === "win32" ? "where" : "which"
try {
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const candidates = result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.filter((line) => !/^INFO:/i.test(line))
if (candidates.length > 0) {
const resolved = this.pickBinaryCandidate(candidates)
this.options.logger.debug({ identifier, resolved, candidates }, "Resolved binary path from system PATH")
return resolved
}
} else if (result.error) {
this.options.logger.warn({ identifier, err: result.error }, "Failed to resolve binary path via locator command")
}
} catch (error) {
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
}
return identifier
}
private pickBinaryCandidate(candidates: string[]): string {
if (process.platform !== "win32") {
return candidates[0] ?? ""
}
const extensionPreference = [".exe", ".cmd", ".bat", ".ps1"]
for (const ext of extensionPreference) {
const match = candidates.find((candidate) => candidate.toLowerCase().endsWith(ext))
if (match) {
return match
}
}
return candidates[0] ?? ""
}
private detectBinaryVersion(resolvedPath: string): string | undefined {
if (!resolvedPath) {
return undefined
}
try {
const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0)
if (line) {
const normalized = line.trim()
const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
if (versionMatch) {
const version = versionMatch[1]
this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version")
return version
}
this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string")
return normalized
}
} else if (result.error) {
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version")
}
} catch (error) {
this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version")
}
return undefined
}
private async waitForWorkspaceReadiness(params: {
workspaceId: string
port: number
exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string
}) {
await Promise.race([
this.waitForPortAvailability(params.port),
params.exitPromise.then((info) => {
throw this.buildStartupError(
params.workspaceId,
"exited before becoming ready",
info,
params.getLastOutput(),
)
}),
])
await this.waitForInstanceHealth(params)
await Promise.race([
this.delay(STARTUP_STABILITY_DELAY_MS),
params.exitPromise.then((info) => {
throw this.buildStartupError(
params.workspaceId,
"exited shortly after start",
info,
params.getLastOutput(),
)
}),
])
}
private async waitForInstanceHealth(params: {
workspaceId: string
port: number
exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string
}) {
const probeResult = await Promise.race([
this.probeInstance(params.workspaceId, params.port),
params.exitPromise.then((info) => {
throw this.buildStartupError(
params.workspaceId,
"exited during health checks",
info,
params.getLastOutput(),
)
}),
])
if (probeResult.ok) {
return
}
const latestOutput = params.getLastOutput().trim()
if (latestOutput) {
throw new Error(latestOutput)
}
const reason = probeResult.reason ?? "Health check failed"
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
}
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
const url = `http://127.0.0.1:${port}/project/current`
try {
const headers: Record<string, string> = {}
const authHeader = this.opencodeAuth.get(workspaceId)?.authorization
if (authHeader) {
headers["Authorization"] = authHeader
}
const response = await fetch(url, { headers })
if (!response.ok) {
const reason = `health probe returned HTTP ${response.status}`
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
return { ok: false, reason }
}
return { ok: true }
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
return { ok: false, reason }
}
}
private buildStartupError(
workspaceId: string,
phase: string,
exitInfo: ProcessExitInfo,
lastOutput: string,
): Error {
const exitDetails = this.describeExit(exitInfo)
const trimmedOutput = lastOutput.trim()
const outputDetails = trimmedOutput ? ` Last output: ${trimmedOutput}` : ""
return new Error(`Workspace ${workspaceId} ${phase} (${exitDetails}).${outputDetails}`)
}
private waitForPortAvailability(port: number, timeoutMs = 5000): Promise<void> {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeoutMs
let settled = false
let retryTimer: NodeJS.Timeout | null = null
const cleanup = () => {
settled = true
if (retryTimer) {
clearTimeout(retryTimer)
retryTimer = null
}
}
const tryConnect = () => {
if (settled) {
return
}
const socket = connect({ port, host: "127.0.0.1" }, () => {
cleanup()
socket.end()
resolve()
})
socket.once("error", () => {
socket.destroy()
if (settled) {
return
}
if (Date.now() >= deadline) {
cleanup()
reject(new Error(`Workspace port ${port} did not become ready within ${timeoutMs}ms`))
} else {
retryTimer = setTimeout(() => {
retryTimer = null
tryConnect()
}, 100)
}
})
}
tryConnect()
})
}
private delay(durationMs: number): Promise<void> {
if (durationMs <= 0) {
return Promise.resolve()
}
return new Promise((resolve) => setTimeout(resolve, durationMs))
}
private describeExit(info: ProcessExitInfo): string {
if (info.signal) {
return `signal ${info.signal}`
}
if (info.code !== null) {
return `code ${info.code}`
}
return "unknown reason"
}
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
const workspace = this.workspaces.get(workspaceId)
if (!workspace) return
this.opencodeAuth.delete(workspaceId)
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
workspace.pid = undefined

View File

@@ -0,0 +1,22 @@
import crypto from "node:crypto"
export const OPENCODE_SERVER_USERNAME_ENV = "OPENCODE_SERVER_USERNAME" as const
export const OPENCODE_SERVER_PASSWORD_ENV = "OPENCODE_SERVER_PASSWORD" as const
export const DEFAULT_OPENCODE_USERNAME = "codenomad" as const
export function generateOpencodeServerPassword(): string {
return crypto.randomBytes(32).toString("base64url")
}
export function buildOpencodeBasicAuthHeader(params: { username?: string; password?: string }): string | undefined {
const username = params.username
const password = params.password
if (!username || !password) {
return undefined
}
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
return `Basic ${token}`
}

View File

@@ -1,10 +1,59 @@
import { ChildProcess, spawn } from "child_process"
import { ChildProcess, spawn, spawnSync } from "child_process"
import { existsSync, statSync } from "fs"
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"])
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 }
}
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
const redacted: Record<string, string | undefined> = {}
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
redacted[key] = value
continue
}
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "[REDACTED]" : value
}
return redacted
}
interface LaunchOptions {
workspaceId: string
folder: string
@@ -13,7 +62,7 @@ interface LaunchOptions {
onExit?: (info: ProcessExitInfo) => void
}
interface ProcessExitInfo {
export interface ProcessExitInfo {
workspaceId: string
code: number | null
signal: NodeJS.Signals | null
@@ -30,18 +79,56 @@ export class WorkspaceRuntime {
constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {}
async launch(options: LaunchOptions): Promise<{ pid: number; port: number }> {
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 env = { ...process.env, ...(options.environment ?? {}) }
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
const exitPromise = new Promise<ProcessExitInfo>((resolveExit) => {
exitResolve = resolveExit
})
// Store recent output for debugging - keep last 50 lines from each stream
const MAX_OUTPUT_LINES = 50
const recentStdout: string[] = []
const recentStderr: string[] = []
const getLastOutput = () => {
const combined: string[] = []
if (recentStderr.length > 0) {
combined.push("Error Stream")
combined.push(...recentStderr.slice(-10))
}
if (recentStdout.length > 0) {
combined.push("Output Stream")
combined.push(...recentStdout.slice(-10))
}
return combined.join("\n")
}
return new Promise((resolve, reject) => {
this.logger.info({ workspaceId: options.workspaceId, folder: options.folder }, "Launching OpenCode process")
const child = spawn(options.binaryPath, args, {
const spec = buildSpawnSpec(options.binaryPath, args)
const commandLine = [spec.command, ...spec.args].join(" ")
this.logger.info(
{
workspaceId: options.workspaceId,
folder: options.folder,
binary: options.binaryPath,
spawnCommand: spec.command,
spawnArgs: spec.args,
commandLine,
env: redactEnvironment(env),
},
"Launching OpenCode process",
)
const detached = process.platform !== "win32"
const child = spawn(spec.command, spec.args, {
cwd: options.folder,
env,
stdio: ["ignore", "pipe", "pipe"],
detached,
...spec.options,
})
const managed: ManagedProcess = { child, requestedStop: false }
@@ -80,11 +167,22 @@ export class WorkspaceRuntime {
cleanupStreams()
child.removeListener("error", handleError)
child.removeListener("exit", handleExit)
const exitInfo: ProcessExitInfo = {
workspaceId: options.workspaceId,
code,
signal,
requested: managed.requestedStop,
}
if (exitResolve) {
exitResolve(exitInfo)
exitResolve = null
}
if (!portFound) {
const reason = stderrBuffer || `Process exited with code ${code}`
const recentOutput = getLastOutput().trim()
const reason = recentOutput || stderrBuffer || `Process exited with code ${code}`
reject(new Error(reason))
} else {
options.onExit?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop })
options.onExit?.(exitInfo)
}
}
@@ -93,6 +191,10 @@ export class WorkspaceRuntime {
child.removeListener("exit", handleExit)
this.processes.delete(options.workspaceId)
this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error")
if (exitResolve) {
exitResolve({ workspaceId: options.workspaceId, code: null, signal: null, requested: managed.requestedStop })
exitResolve = null
}
reject(error)
}
@@ -106,18 +208,25 @@ export class WorkspaceRuntime {
stdoutBuffer = lines.pop() ?? ""
for (const line of lines) {
if (!line.trim()) continue
const trimmed = line.trim()
if (!trimmed) continue
recentStdout.push(trimmed)
if (recentStdout.length > MAX_OUTPUT_LINES) {
recentStdout.shift()
}
this.emitLog(options.workspaceId, "info", line)
if (!portFound) {
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i)
if (portMatch) {
portFound = true
cleanupStreams()
stopWarningTimer()
child.removeListener("error", handleError)
const port = parseInt(portMatch[1], 10)
this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port")
resolve({ pid: child.pid!, port })
resolve({ pid: child.pid!, port, exitPromise, getLastOutput })
}
}
}
@@ -130,7 +239,14 @@ export class WorkspaceRuntime {
stderrBuffer = lines.pop() ?? ""
for (const line of lines) {
if (!line.trim()) continue
const trimmed = line.trim()
if (!trimmed) continue
recentStderr.push(trimmed)
if (recentStderr.length > MAX_OUTPUT_LINES) {
recentStderr.shift()
}
this.emitLog(options.workspaceId, "error", line)
}
})
@@ -145,10 +261,96 @@ export class WorkspaceRuntime {
const child = managed.child
this.logger.info({ workspaceId }, "Stopping OpenCode process")
const pid = child.pid
if (!pid) {
this.logger.warn({ workspaceId }, "Workspace process missing PID; cannot stop")
return
}
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
try {
// Negative PID targets the process group (POSIX).
process.kill(-pid, signal)
return true
} catch (error) {
const err = error as NodeJS.ErrnoException
if (err?.code === "ESRCH") {
return true
}
this.logger.debug({ workspaceId, pid, err }, "Failed to signal POSIX process group")
return false
}
}
const tryKillSinglePid = (signal: NodeJS.Signals) => {
try {
process.kill(pid, signal)
return true
} catch (error) {
const err = error as NodeJS.ErrnoException
if (err?.code === "ESRCH") {
return true
}
this.logger.debug({ workspaceId, pid, err }, "Failed to signal workspace PID")
return false
}
}
const tryTaskkill = (force: boolean) => {
const args = ["/PID", String(pid), "/T"]
if (force) {
args.push("/F")
}
try {
const result = spawnSync("taskkill", args, { encoding: "utf8" })
const exitCode = result.status
if (exitCode === 0) {
return true
}
// If the PID is already gone, treat it as success.
const stderr = (result.stderr ?? "").toString().toLowerCase()
const stdout = (result.stdout ?? "").toString().toLowerCase()
const combined = `${stdout}\n${stderr}`
if (combined.includes("not found") || combined.includes("no running instance") || combined.includes("process") && combined.includes("not")) {
return true
}
this.logger.debug({ workspaceId, pid, exitCode, stderr: result.stderr, stdout: result.stdout }, "taskkill failed")
return false
} catch (error) {
this.logger.debug({ workspaceId, pid, err: error }, "taskkill 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")
return
}
// Prefer process-group signaling so wrapper launchers (bun/node) don't orphan the real server.
const groupOk = tryKillPosixGroup(signal)
if (!groupOk) {
// Fallback to direct PID kill.
tryKillSinglePid(signal)
}
}
await new Promise<void>((resolve, reject) => {
let escalationTimer: NodeJS.Timeout | null = null
const cleanup = () => {
child.removeListener("exit", onExit)
child.removeListener("error", onError)
if (escalationTimer) {
clearTimeout(escalationTimer)
escalationTimer = null
}
}
const onExit = () => {
@@ -160,32 +362,30 @@ export class WorkspaceRuntime {
reject(error)
}
const resolveIfAlreadyExited = () => {
if (child.exitCode !== null || child.signalCode !== null) {
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
cleanup()
resolve()
return true
}
return false
if (isAlreadyExited()) {
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
cleanup()
resolve()
return
}
child.once("exit", onExit)
child.once("error", onError)
if (resolveIfAlreadyExited()) {
return
}
this.logger.debug(
{ workspaceId, pid, detached: process.platform !== "win32" },
"Sending SIGTERM to workspace process (tree/group)",
)
sendStopSignal("SIGTERM")
this.logger.debug({ workspaceId }, "Sending SIGTERM to workspace process")
child.kill("SIGTERM")
setTimeout(() => {
if (!child.killed) {
this.logger.warn({ workspaceId }, "Process did not stop after SIGTERM, force killing")
child.kill("SIGKILL")
} else {
this.logger.debug({ workspaceId }, "Workspace process stopped gracefully before SIGKILL timeout")
escalationTimer = setTimeout(() => {
escalationTimer = null
if (isAlreadyExited()) {
this.logger.debug({ workspaceId, pid }, "Workspace exited before SIGKILL escalation")
return
}
this.logger.warn({ workspaceId, pid }, "Process did not stop after SIGTERM, escalating")
sendStopSignal("SIGKILL")
}, 2000)
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,14 @@
{
"name": "@codenomad/tauri-app",
"version": "0.2.1",
"version": "0.9.0",
"private": true,
"scripts": {
"dev": "tauri dev",
"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",
"prebuild": "node ./scripts/prebuild.js",
"bundle:server": "npm --workspace @neuralnomads/codenomad run build && npm run prebuild",
"bundle:server": "npm run prebuild",
"build": "tauri build"
},
"devDependencies": {

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
const { execSync } = require("child_process")
const root = path.resolve(__dirname, "..")
const workspaceRoot = path.resolve(root, "..", "..")
const uiRoot = path.resolve(root, "..", "ui")
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
function ensureUiBuild() {
const loadingHtml = path.join(uiDist, "loading.html")
if (fs.existsSync(loadingHtml)) {
return
}
console.log("[dev-prep] UI loader build missing; running workspace build…")
execSync("npm --workspace @codenomad/ui run build", {
cwd: workspaceRoot,
stdio: "inherit",
})
if (!fs.existsSync(loadingHtml)) {
throw new Error("[dev-prep] failed to produce loading.html after UI build")
}
}
function copyUiLoadingAssets() {
const loadingSource = path.join(uiDist, "loading.html")
const assetsSource = path.join(uiDist, "assets")
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
fs.mkdirSync(uiLoadingDest, { recursive: true })
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
if (fs.existsSync(assetsSource)) {
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
}
console.log(`[dev-prep] copied loader bundle from ${uiDist}`)
}
ensureUiBuild()
copyUiLoadingAssets()

View File

@@ -1,45 +1,234 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");
const fs = require("fs")
const path = require("path")
const { execSync } = require("child_process")
const root = path.resolve(__dirname, "..");
const workspaceRoot = path.resolve(root, "..", "..");
const serverRoot = path.resolve(root, "..", "server");
const dest = path.resolve(root, "src-tauri", "resources", "server");
const root = path.resolve(__dirname, "..")
const workspaceRoot = path.resolve(root, "..", "..")
const serverRoot = path.resolve(root, "..", "server")
const uiRoot = path.resolve(root, "..", "ui")
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
const serverDest = path.resolve(root, "src-tauri", "resources", "server")
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
const sources = ["dist", "public", "node_modules", "package.json"];
const sources = ["dist", "public", "node_modules", "package.json"]
const serverInstallCommand =
"npm install --omit=dev --ignore-scripts --workspaces=false --package-lock=false --install-strategy=shallow --fund=false --audit=false"
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 envWithRootBin = {
...process.env,
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
}
const braceExpansionPath = path.join(
serverRoot,
"node_modules",
"@fastify",
"static",
"node_modules",
"brace-expansion",
"package.json",
)
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
function ensureServerBuild() {
const distPath = path.join(serverRoot, "dist");
const publicPath = path.join(serverRoot, "public");
const distPath = path.join(serverRoot, "dist")
const publicPath = path.join(serverRoot, "public")
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
return;
return
}
console.log("[prebuild] server build missing; running workspace build...");
console.log("[prebuild] server build missing; running workspace build...")
execSync("npm --workspace @neuralnomads/codenomad run build", {
cwd: workspaceRoot,
stdio: "inherit",
});
env: {
...process.env,
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
},
})
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
throw new Error("[prebuild] server artifacts still missing after build");
throw new Error("[prebuild] server artifacts still missing after build")
}
}
ensureServerBuild();
fs.rmSync(dest, { recursive: true, force: true });
fs.mkdirSync(dest, { recursive: true });
for (const name of sources) {
const from = path.join(serverRoot, name);
const to = path.join(dest, name);
if (!fs.existsSync(from)) {
console.warn(`[prebuild] skipped missing ${from}`);
continue;
function ensureUiBuild() {
const loadingHtml = path.join(uiDist, "loading.html")
if (fs.existsSync(loadingHtml)) {
return
}
console.log("[prebuild] ui build missing; running workspace build...")
execSync("npm --workspace @codenomad/ui run build", {
cwd: workspaceRoot,
stdio: "inherit",
})
if (!fs.existsSync(loadingHtml)) {
throw new Error("[prebuild] ui loading assets missing after build")
}
fs.cpSync(from, to, { recursive: true });
console.log(`[prebuild] copied ${from} -> ${to}`);
}
function ensureServerDevDependencies() {
if (fs.existsSync(braceExpansionPath)) {
return
}
console.log("[prebuild] ensuring server build dependencies (with dev)...")
execSync(serverDevInstallCommand, {
cwd: workspaceRoot,
stdio: "inherit",
env: envWithRootBin,
})
}
function ensureServerDependencies() {
if (fs.existsSync(braceExpansionPath)) {
return
}
console.log("[prebuild] ensuring server production dependencies...")
execSync(serverInstallCommand, {
cwd: serverRoot,
stdio: "inherit",
})
}
function ensureUiDevDependencies() {
if (fs.existsSync(viteBinPath)) {
return
}
console.log("[prebuild] ensuring ui build dependencies...")
execSync(uiDevInstallCommand, {
cwd: workspaceRoot,
stdio: "inherit",
env: envWithRootBin,
})
}
function ensureRollupPlatformBinary() {
const platformKey = `${process.platform}-${process.arch}`
const platformPackages = {
"linux-x64": "@rollup/rollup-linux-x64-gnu",
"linux-arm64": "@rollup/rollup-linux-arm64-gnu",
"darwin-arm64": "@rollup/rollup-darwin-arm64",
"darwin-x64": "@rollup/rollup-darwin-x64",
"win32-x64": "@rollup/rollup-win32-x64-msvc",
}
const pkgName = platformPackages[platformKey]
if (!pkgName) {
return
}
const platformPackagePath = path.join(workspaceRoot, "node_modules", "@rollup", pkgName.split("/").pop())
if (fs.existsSync(platformPackagePath)) {
return
}
let rollupVersion = ""
try {
rollupVersion = require(path.join(workspaceRoot, "node_modules", "rollup", "package.json")).version
} catch (error) {
// leave version empty; fallback install will use latest compatible
}
const packageSpec = rollupVersion ? `${pkgName}@${rollupVersion}` : pkgName
console.log("[prebuild] installing rollup platform binary (optional dep workaround)...")
execSync(`npm install ${packageSpec} --no-save --ignore-scripts --fund=false --audit=false`, {
cwd: workspaceRoot,
stdio: "inherit",
})
}
function copyServerArtifacts() {
fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true })
for (const name of sources) {
const from = path.join(serverRoot, name)
const to = path.join(serverDest, name)
if (!fs.existsSync(from)) {
console.warn(`[prebuild] skipped missing ${from}`)
continue
}
fs.cpSync(from, to, { recursive: true, dereference: true })
console.log(`[prebuild] copied ${from} -> ${to}`)
}
}
function stripNodeModuleBins() {
const root = path.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 = path.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) {
console.log(`[prebuild] removed ${removed} node_modules/.bin directories`)
}
}
function copyUiLoadingAssets() {
const loadingSource = path.join(uiDist, "loading.html")
const assetsSource = path.join(uiDist, "assets")
if (!fs.existsSync(loadingSource)) {
throw new Error("[prebuild] cannot find built loading.html")
}
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
fs.mkdirSync(uiLoadingDest, { recursive: true })
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
if (fs.existsSync(assetsSource)) {
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
}
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
}
ensureServerDevDependencies()
ensureUiDevDependencies()
ensureRollupPlatformBinary()
ensureServerDependencies()
ensureServerBuild()
ensureUiBuild()
copyServerArtifacts()
stripNodeModuleBins()
copyUiLoadingAssets()

View File

@@ -17,3 +17,7 @@ thiserror = "1"
anyhow = "1"
which = "4"
libc = "0.2"
tauri-plugin-dialog = "2"
dirs = "5"
tauri-plugin-opener = "2"
url = "2"

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://schema.tauri.app/capabilities.json",
"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:*"]
},
"windows": ["main"],
"permissions": [
"core:default",
"core:menu:default",
"dialog:allow-open",
"opener:allow-default-urls",
"core:webview:allow-set-webview-zoom"
]
}

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:*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","core:webview:allow-set-webview-zoom"]}}

View File

@@ -134,6 +134,174 @@
"description": "Reference a permission or permission set by identifier and extends its scope.",
"type": "object",
"allOf": [
{
"if": {
"properties": {
"identifier": {
"anyOf": [
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
"const": "opener:default",
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
},
{
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
"type": "string",
"const": "opener:allow-default-urls",
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
},
{
"description": "Enables the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-path",
"markdownDescription": "Enables the open_path command without any pre-configured scope."
},
{
"description": "Enables the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-url",
"markdownDescription": "Enables the open_url command without any pre-configured scope."
},
{
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-reveal-item-in-dir",
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "Denies the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-path",
"markdownDescription": "Denies the open_path command without any pre-configured scope."
},
{
"description": "Denies the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-url",
"markdownDescription": "Denies the open_url command without any pre-configured scope."
},
{
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
}
]
}
}
},
"then": {
"properties": {
"allow": {
"items": {
"title": "OpenerScopeEntry",
"description": "Opener scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"url"
],
"properties": {
"app": {
"description": "An application to open this url with, for example: firefox.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"url": {
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string"
}
}
},
{
"type": "object",
"required": [
"path"
],
"properties": {
"app": {
"description": "An application to open this path with, for example: xdg-open.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"path": {
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
}
}
}
]
}
},
"deny": {
"items": {
"title": "OpenerScopeEntry",
"description": "Opener scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"url"
],
"properties": {
"app": {
"description": "An application to open this url with, for example: firefox.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"url": {
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string"
}
}
},
{
"type": "object",
"required": [
"path"
],
"properties": {
"app": {
"description": "An application to open this path with, for example: xdg-open.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"path": {
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
}
}
}
]
}
}
}
},
"properties": {
"identifier": {
"description": "Identifier of the permission or permission set.",
"allOf": [
{
"$ref": "#/definitions/Identifier"
}
]
}
}
},
{
"properties": {
"identifier": {
@@ -2143,6 +2311,120 @@
"type": "string",
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope."
},
{
"description": "Enables the confirm command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope."
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope."
},
{
"description": "Denies the confirm command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope."
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
"const": "opener:default",
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
},
{
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
"type": "string",
"const": "opener:allow-default-urls",
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
},
{
"description": "Enables the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-path",
"markdownDescription": "Enables the open_path command without any pre-configured scope."
},
{
"description": "Enables the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-url",
"markdownDescription": "Enables the open_url command without any pre-configured scope."
},
{
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-reveal-item-in-dir",
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "Denies the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-path",
"markdownDescription": "Denies the open_path command without any pre-configured scope."
},
{
"description": "Denies the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-url",
"markdownDescription": "Denies the open_url command without any pre-configured scope."
},
{
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
}
]
},
@@ -2239,6 +2521,23 @@
]
}
]
},
"Application": {
"description": "Opener scope application.",
"anyOf": [
{
"description": "Open in default application.",
"type": "null"
},
{
"description": "If true, allow open with any application.",
"type": "boolean"
},
{
"description": "Allow specific application to open with.",
"type": "string"
}
]
}
}
}

View File

@@ -134,6 +134,174 @@
"description": "Reference a permission or permission set by identifier and extends its scope.",
"type": "object",
"allOf": [
{
"if": {
"properties": {
"identifier": {
"anyOf": [
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
"const": "opener:default",
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
},
{
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
"type": "string",
"const": "opener:allow-default-urls",
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
},
{
"description": "Enables the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-path",
"markdownDescription": "Enables the open_path command without any pre-configured scope."
},
{
"description": "Enables the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-url",
"markdownDescription": "Enables the open_url command without any pre-configured scope."
},
{
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-reveal-item-in-dir",
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "Denies the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-path",
"markdownDescription": "Denies the open_path command without any pre-configured scope."
},
{
"description": "Denies the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-url",
"markdownDescription": "Denies the open_url command without any pre-configured scope."
},
{
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
}
]
}
}
},
"then": {
"properties": {
"allow": {
"items": {
"title": "OpenerScopeEntry",
"description": "Opener scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"url"
],
"properties": {
"app": {
"description": "An application to open this url with, for example: firefox.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"url": {
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string"
}
}
},
{
"type": "object",
"required": [
"path"
],
"properties": {
"app": {
"description": "An application to open this path with, for example: xdg-open.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"path": {
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
}
}
}
]
}
},
"deny": {
"items": {
"title": "OpenerScopeEntry",
"description": "Opener scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"url"
],
"properties": {
"app": {
"description": "An application to open this url with, for example: firefox.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"url": {
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string"
}
}
},
{
"type": "object",
"required": [
"path"
],
"properties": {
"app": {
"description": "An application to open this path with, for example: xdg-open.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"path": {
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
}
}
}
]
}
}
}
},
"properties": {
"identifier": {
"description": "Identifier of the permission or permission set.",
"allOf": [
{
"$ref": "#/definitions/Identifier"
}
]
}
}
},
{
"properties": {
"identifier": {
@@ -2143,6 +2311,120 @@
"type": "string",
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope."
},
{
"description": "Enables the confirm command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope."
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope."
},
{
"description": "Denies the confirm command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope."
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
"const": "opener:default",
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
},
{
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
"type": "string",
"const": "opener:allow-default-urls",
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
},
{
"description": "Enables the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-path",
"markdownDescription": "Enables the open_path command without any pre-configured scope."
},
{
"description": "Enables the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-url",
"markdownDescription": "Enables the open_url command without any pre-configured scope."
},
{
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-reveal-item-in-dir",
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "Denies the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-path",
"markdownDescription": "Denies the open_path command without any pre-configured scope."
},
{
"description": "Denies the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-url",
"markdownDescription": "Denies the open_url command without any pre-configured scope."
},
{
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
}
]
},
@@ -2239,6 +2521,23 @@
]
}
]
},
"Application": {
"description": "Opener scope application.",
"anyOf": [
{
"description": "Open in default application.",
"type": "null"
},
{
"description": "If true, allow open with any application.",
"type": "boolean"
},
{
"description": "Allow specific application to open with.",
"type": "string"
}
]
}
}
}

View File

@@ -1,17 +1,21 @@
use dirs::home_dir;
use parking_lot::Mutex;
use regex::Regex;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::VecDeque;
use std::env;
use std::ffi::OsStr;
use std::io::{BufRead, BufReader};
use std::fs;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
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 tauri::{AppHandle, Emitter, Manager, Url};
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
fn log_line(message: &str) {
println!("[tauri-cli] {message}");
@@ -28,9 +32,15 @@ fn workspace_root() -> Option<PathBuf> {
})
}
const SESSION_COOKIE_NAME: &str = "codenomad_session";
fn navigate_main(app: &AppHandle, url: &str) {
if let Some(win) = app.webview_windows().get("main") {
log_line(&format!("navigating main to {url}"));
let mut display = url.to_string();
if let Some(hash_index) = display.find('#') {
display.replace_range(hash_index + 1.., "[REDACTED]");
}
log_line(&format!("navigating main to {display}"));
if let Ok(parsed) = Url::parse(url) {
let _ = win.navigate(parsed);
} else {
@@ -41,6 +51,145 @@ fn navigate_main(app: &AppHandle, url: &str) {
}
}
fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
let prefix = format!("{name}=");
let cookie_kv = set_cookie.split(';').next()?.trim();
if !cookie_kv.starts_with(&prefix) {
return None;
}
let value = cookie_kv.trim_start_matches(&prefix).trim();
if value.is_empty() {
return None;
}
Some(value.to_string())
}
fn exchange_bootstrap_token(base_url: &str, token: &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);
// This is only used for local bootstrap; we assume plain HTTP.
let mut stream = TcpStream::connect((host, port))?;
let body = format!("{{\"token\":\"{}\"}}", token);
let request = format!(
"POST /api/auth/token HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.as_bytes().len(),
body
);
stream.write_all(request.as_bytes())?;
stream.flush()?;
let mut response = String::new();
stream.read_to_string(&mut response)?;
let (raw_headers, _rest) = response
.split_once("\r\n\r\n")
.or_else(|| response.split_once("\n\n"))
.unwrap_or((response.as_str(), ""));
let mut lines = raw_headers.lines();
let status_line = lines.next().unwrap_or("");
if !status_line.contains(" 200 ") {
return Ok(None);
}
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) {
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) {
return Ok(Some(session_id));
}
}
}
Ok(None)
}
fn set_session_cookie(app: &AppHandle, base_url: &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))
.domain(domain)
.path("/")
.http_only(true)
.same_site(tauri::webview::cookie::SameSite::Lax)
.build();
if let Some(win) = app.webview_windows().get("main") {
win.set_cookie(cookie)?;
}
Ok(())
}
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
#[derive(Debug, Deserialize)]
struct PreferencesConfig {
#[serde(rename = "listeningMode")]
listening_mode: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AppConfig {
preferences: Option<PreferencesConfig>,
}
fn resolve_config_path() -> PathBuf {
let raw = env::var("CLI_CONFIG")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
expand_home(&raw)
}
fn expand_home(path: &str) -> PathBuf {
if path.starts_with("~/") {
if let Some(home) = home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from)) {
return home.join(path.trim_start_matches("~/"));
}
}
PathBuf::from(path)
}
fn resolve_listening_mode() -> String {
let path = resolve_config_path();
if let Ok(content) = fs::read_to_string(path) {
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
if let Some(mode) = config
.preferences
.as_ref()
.and_then(|prefs| prefs.listening_mode.as_ref())
{
if mode == "local" {
return "local".to_string();
}
if mode == "all" {
return "all".to_string();
}
}
}
}
"local".to_string()
}
fn resolve_listening_host() -> String {
let mode = resolve_listening_mode();
if mode == "local" {
"127.0.0.1".to_string()
} else {
"0.0.0.0".to_string()
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CliState {
@@ -76,6 +225,7 @@ pub struct CliProcessManager {
status: Arc<Mutex<CliStatus>>,
child: Arc<Mutex<Option<Child>>>,
ready: Arc<AtomicBool>,
bootstrap_token: Arc<Mutex<Option<String>>>,
}
impl CliProcessManager {
@@ -84,6 +234,7 @@ impl CliProcessManager {
status: Arc::new(Mutex::new(CliStatus::default())),
child: Arc::new(Mutex::new(None)),
ready: Arc::new(AtomicBool::new(false)),
bootstrap_token: Arc::new(Mutex::new(None)),
}
}
@@ -91,6 +242,7 @@ impl CliProcessManager {
log_line(&format!("start requested (dev={dev})"));
self.stop()?;
self.ready.store(false, Ordering::SeqCst);
*self.bootstrap_token.lock() = None;
{
let mut status = self.status.lock();
status.state = CliState::Starting;
@@ -104,8 +256,9 @@ impl CliProcessManager {
let status_arc = self.status.clone();
let child_arc = self.child.clone();
let ready_flag = self.ready.clone();
let token_arc = self.bootstrap_token.clone();
thread::spawn(move || {
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, dev) {
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, token_arc, dev) {
log_line(&format!("cli spawn failed: {err}"));
let mut locked = status_arc.lock();
locked.state = CliState::Error;
@@ -174,15 +327,17 @@ impl CliProcessManager {
status: Arc<Mutex<CliStatus>>,
child_holder: Arc<Mutex<Option<Child>>>,
ready: Arc<AtomicBool>,
bootstrap_token: Arc<Mutex<Option<String>>>,
dev: bool,
) -> anyhow::Result<()> {
log_line("resolving CLI entry");
let resolution = CliEntry::resolve(&app, dev)?;
let host = resolve_listening_host();
log_line(&format!(
"resolved CLI entry runner={:?} entry={}",
resolution.runner, resolution.entry
"resolved CLI entry runner={:?} entry={} host={}",
resolution.runner, resolution.entry, host
));
let args = resolution.build_args(dev);
let args = resolution.build_args(dev, &host);
log_line(&format!("CLI args: {:?}", args));
if dev {
log_line("development mode: will prefer tsx + source if present");
@@ -254,8 +409,10 @@ impl CliProcessManager {
let status_clone = status.clone();
let app_clone = app.clone();
let ready_clone = ready.clone();
let token_clone = bootstrap_token.clone();
thread::spawn(move || {
let stdout = child_clone
.lock()
.as_mut()
@@ -268,10 +425,10 @@ impl CliProcessManager {
.map(BufReader::new);
if let Some(reader) = stdout {
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone);
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone, &token_clone);
}
if let Some(reader) = stderr {
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone);
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone, &token_clone);
}
});
@@ -280,7 +437,7 @@ impl CliProcessManager {
let ready_clone = ready.clone();
let child_holder_clone = child_holder.clone();
thread::spawn(move || {
let timeout = Duration::from_secs(15);
let timeout = Duration::from_secs(60);
thread::sleep(timeout);
if ready_clone.load(Ordering::SeqCst) {
return;
@@ -343,10 +500,12 @@ impl CliProcessManager {
app: &AppHandle,
status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>,
) {
let mut buffer = String::new();
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
loop {
buffer.clear();
@@ -355,7 +514,18 @@ impl CliProcessManager {
Ok(_) => {
let line = buffer.trim_end();
if !line.is_empty() {
let _ = app.emit("cli:log", json!({"stream": stream, "message": line}));
if line.starts_with(token_prefix) {
let token = line.trim_start_matches(token_prefix).trim();
if !token.is_empty() {
let mut guard = bootstrap_token.lock();
if guard.is_none() {
*guard = Some(token.to_string());
}
}
continue;
}
log_line(&format!("[cli][{}] {}", stream, line));
if ready.load(Ordering::SeqCst) {
continue;
@@ -366,7 +536,7 @@ impl CliProcessManager {
.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, port);
Self::mark_ready(app, status, ready, bootstrap_token, port);
continue;
}
@@ -376,13 +546,13 @@ impl CliProcessManager {
.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, port);
Self::mark_ready(app, status, ready, bootstrap_token, 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, port as u16);
Self::mark_ready(app, status, ready, bootstrap_token, port as u16);
continue;
}
}
@@ -394,16 +564,46 @@ impl CliProcessManager {
}
}
fn mark_ready(app: &AppHandle, status: &Arc<Mutex<CliStatus>>, ready: &Arc<AtomicBool>, port: u16) {
fn mark_ready(
app: &AppHandle,
status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>,
port: u16,
) {
ready.store(true, Ordering::SeqCst);
let base_url = format!("http://127.0.0.1:{port}");
let mut locked = status.lock();
let url = format!("http://127.0.0.1:{port}");
locked.port = Some(port);
locked.url = Some(url.clone());
locked.url = Some(base_url.clone());
locked.state = CliState::Ready;
locked.error = None;
log_line(&format!("cli ready on {url}"));
navigate_main(app, &url);
log_line(&format!("cli ready on {base_url}"));
let token = bootstrap_token.lock().take();
if let Some(token) = token {
match exchange_bootstrap_token(&base_url, &token) {
Ok(Some(session_id)) => {
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
log_line(&format!("failed to set session cookie: {err}"));
navigate_main(app, &format!("{base_url}/login"));
} else {
navigate_main(app, &base_url);
}
}
Ok(None) => {
log_line("bootstrap token exchange failed (invalid token)");
navigate_main(app, &format!("{base_url}/login"));
}
Err(err) => {
log_line(&format!("bootstrap token exchange failed: {err}"));
navigate_main(app, &format!("{base_url}/login"));
}
}
} else {
navigate_main(app, &base_url);
}
let _ = app.emit("cli:ready", locked.clone());
Self::emit_status(app, &locked);
}
@@ -480,13 +680,14 @@ impl CliEntry {
))
}
fn build_args(&self, dev: bool) -> Vec<String> {
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
let mut args = vec![
"serve".to_string(),
"--host".to_string(),
"127.0.0.1".to_string(),
host.to_string(),
"--port".to_string(),
"0".to_string(),
"--generate-token".to_string(),
];
if dev {
args.push("--ui-dev-server".to_string());
@@ -558,6 +759,18 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
candidates.push(Some(resources.join("resources/server/dist/index.js")));
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
candidates.push(Some(resources.join("resources/server/dist/server/index.js")));
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
for root in linux_resource_roots {
candidates.push(Some(root.join("server/dist/bin.js")));
candidates.push(Some(root.join("server/dist/index.js")));
candidates.push(Some(root.join("server/dist/server/bin.js")));
candidates.push(Some(root.join("server/dist/server/index.js")));
candidates.push(Some(root.join("resources/server/dist/bin.js")));
candidates.push(Some(root.join("resources/server/dist/index.js")));
candidates.push(Some(root.join("resources/server/dist/server/bin.js")));
candidates.push(Some(root.join("resources/server/dist/server/index.js")));
}
}
}

View File

@@ -4,8 +4,12 @@ mod cli_manager;
use cli_manager::{CliProcessManager, CliStatus};
use serde_json::json;
use tauri::menu::Menu;
use tauri::{AppHandle, Emitter, Manager};
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_plugin_opener::OpenerExt;
use url::Url;
#[derive(Clone)]
pub struct AppState {
@@ -17,35 +21,162 @@ fn cli_get_status(state: tauri::State<AppState>) -> CliStatus {
state.manager.status()
}
#[tauri::command]
fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatus, String> {
let dev_mode = is_dev_mode();
state.manager.stop().map_err(|e| e.to_string())?;
state
.manager
.start(app, dev_mode)
.map_err(|e| e.to_string())?;
Ok(state.manager.status())
}
fn is_dev_mode() -> bool {
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
}
fn should_allow_internal(url: &Url) -> bool {
match url.scheme() {
"tauri" | "asset" | "file" => true,
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost")),
_ => false,
}
}
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
if should_allow_internal(url) {
return true;
}
if let Err(err) = webview
.app_handle()
.opener()
.open_url(url.as_str(), None::<&str>)
{
eprintln!("[tauri] failed to open external link {}: {}", url, err);
}
false
}
fn main() {
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
.on_navigation(|webview, url| intercept_navigation(webview, url))
.build();
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(navigation_guard)
.manage(AppState {
manager: CliProcessManager::new(),
})
.setup(|app| {
build_menu(&app.handle())?;
let dev_mode = cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok();
let dev_mode = is_dev_mode();
let app_handle = app.handle().clone();
let manager = app.state::<AppState>().manager.clone();
std::thread::spawn(move || {
if let Err(err) = manager.start(app_handle.clone(), dev_mode) {
let _ = app_handle.emit(
"cli:error",
json!({"message": err.to_string()}),
);
let _ = app_handle.emit("cli:error", json!({"message": err.to_string()}));
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![cli_get_status])
.on_menu_event(|_app_handle, _event| {
// No menu items defined currently
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
.on_menu_event(|app_handle, event| {
match event.id().0.as_str() {
// File menu
"new_instance" => {
if let Some(window) = app_handle.get_webview_window("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()");
}
}
"force_reload" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.eval("window.location.reload(true)");
}
}
"toggle_devtools" => {
if let Some(window) = app_handle.get_webview_window("main") {
window.open_devtools();
}
}
"toggle_fullscreen" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.set_fullscreen(!window.is_fullscreen().unwrap_or(false));
}
}
// Window menu
"minimize" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.minimize();
}
}
"zoom" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.maximize();
}
}
// App menu (macOS)
"about" => {
// TODO: Implement about dialog
println!("About menu item clicked");
}
"hide" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.hide();
}
}
"hide_others" => {
// TODO: Hide other app windows
println!("Hide Others menu item clicked");
}
"show_all" => {
// TODO: Show all app windows
println!("Show All menu item clicked");
}
_ => {
println!("Unhandled menu event: {}", event.id().0);
}
}
})
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
match event {
tauri::RunEvent::ExitRequested { .. } => {
.run(|app_handle, event| match event {
tauri::RunEvent::ExitRequested { .. } => {
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {
let _ = state.manager.stop();
}
app.exit(0);
});
}
tauri::RunEvent::WindowEvent {
event: tauri::WindowEvent::Destroyed,
..
} => {
if app_handle.webview_windows().len() <= 1 {
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {
@@ -54,25 +185,83 @@ fn main() {
app.exit(0);
});
}
tauri::RunEvent::WindowEvent { event: tauri::WindowEvent::Destroyed, .. } => {
if app_handle.webview_windows().len() <= 1 {
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {
let _ = state.manager.stop();
}
app.exit(0);
});
}
}
_ => {}
}
_ => {}
});
}
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
// Minimal empty menu for now (Tauri v2 menu API differs from v1 roles).
let menu = Menu::new(app)?;
let is_mac = cfg!(target_os = "macos");
// Create submenus
let mut submenus = Vec::new();
// App menu (macOS only)
if is_mac {
let app_menu = SubmenuBuilder::new(app, "CodeNomad")
.text("about", "About CodeNomad")
.separator()
.text("hide", "Hide CodeNomad")
.text("hide_others", "Hide Others")
.text("show_all", "Show All")
.separator()
.text("quit", "Quit CodeNomad")
.build()?;
submenus.push(app_menu);
}
// File menu - create New Instance with accelerator
let new_instance_item = MenuItem::with_id(
app,
"new_instance",
"New Instance",
true,
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()?;
submenus.push(file_menu);
// Edit menu with predefined items for standard functionality
let edit_menu = SubmenuBuilder::new(app, "Edit")
.undo()
.redo()
.separator()
.cut()
.copy()
.paste()
.separator()
.select_all()
.build()?;
submenus.push(edit_menu);
// View menu
let view_menu = SubmenuBuilder::new(app, "View")
.text("reload", "Reload")
.text("force_reload", "Force Reload")
.text("toggle_devtools", "Toggle Developer Tools")
.separator()
.separator()
.text("toggle_fullscreen", "Toggle Full Screen")
.build()?;
submenus.push(view_menu);
// Window menu
let window_menu = SubmenuBuilder::new(app, "Window")
.text("minimize", "Minimize")
.text("zoom", "Zoom")
.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 menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
app.set_menu(menu)?;
Ok(())
}

View File

@@ -4,17 +4,20 @@
"version": "0.1.0",
"identifier": "ai.opencode.client",
"build": {
"beforeDevCommand": "",
"beforeDevCommand": "npm run dev:bootstrap",
"beforeBuildCommand": "npm run bundle:server",
"frontendDist": "../src"
"frontendDist": "resources/ui-loading"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "CodeNomad",
"url": "index.html",
"url": "loading.html",
"width": 1400,
"height": 900,
"minWidth": 800,
@@ -22,21 +25,24 @@
"center": true,
"resizable": true,
"fullscreen": false,
"decorations": true
"decorations": true,
"theme": "Dark",
"backgroundColor": "#1a1a1a",
"zoomHotkeysEnabled": true
}
],
"security": {
"assetProtocol": {
"scope": ["**"]
}
},
"capabilities": ["main-window-native-dialogs"]
}
},
"bundle": {
"active": true,
"resources": [
"../src/index.html",
"../src/icon.png",
"resources/server"
"resources/server",
"resources/ui-loading"
],
"icon": ["icon.icns", "icon.ico", "icon.png"],
"targets": ["app", "appimage", "deb", "rpm", "nsis"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -1,197 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CodeNomad</title>
<style>
:root {
color-scheme: dark;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background-color: #1a1a1a;
color: #cfd4dc;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
text-align: center;
}
button {
border: none;
background: none;
font: inherit;
color: inherit;
}
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
max-width: 520px;
}
.logo {
width: 180px;
height: auto;
filter: drop-shadow(0 15px 40px rgba(0, 0, 0, 0.35));
}
.title {
font-size: 2.7rem;
font-weight: 600;
margin: 0;
color: #f4f6fb;
}
.loading-card {
margin-top: 12px;
width: 100%;
max-width: 420px;
padding: 22px;
border-radius: 18px;
background: #151a23;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
}
.loading-row {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
font-size: 0.95rem;
color: #cfd4dc;
}
.spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: #6ce3ff;
animation: spin 0.9s linear infinite;
}
.phrase-controls {
margin-top: 12px;
font-size: 0.9rem;
color: #8f96a9;
display: flex;
justify-content: center;
gap: 8px;
}
.phrase-controls button {
color: #8fb5ff;
cursor: pointer;
}
.error {
margin-top: 12px;
color: #ff9ea9;
font-size: 0.95rem;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="wrapper" role="status" aria-live="polite">
<img src="./icon.png" alt="CodeNomad" class="logo" />
<div>
<h1 class="title">CodeNomad</h1>
</div>
<div class="loading-card">
<div class="loading-row">
<div class="spinner" aria-hidden="true"></div>
<span id="loading-phrase">Warming up the AI neurons…</span>
</div>
<div class="phrase-controls">
<button id="phrase-toggle" type="button">Show another</button>
</div>
<div class="error" id="error"></div>
</div>
</div>
<script>
const phrases = [
"Warming up the AI neurons…",
"Convincing the AI to stop daydreaming…",
"Polishing the AIs code goggles…",
"Asking the AI to stop reorganizing your files…",
"Feeding the AI additional coffee…",
"Teaching the AI not to delete node_modules (again)…",
"Telling the AI to act natural before you arrive…",
"Asking the AI to please stop rewriting history…",
"Letting the AI stretch before its coding sprint…",
"Persuading the AI to give you keyboard control…",
]
const phraseEl = document.getElementById("loading-phrase")
const button = document.getElementById("phrase-toggle")
const errorEl = document.getElementById("error")
function pickPhrase() {
const next = phrases[Math.floor(Math.random() * phrases.length)]
phraseEl.textContent = next
}
function setError(message) {
errorEl.textContent = message || ""
}
function navigateTo(url) {
if (!url) return
window.location.replace(url)
}
async function bootstrap() {
pickPhrase()
button?.addEventListener("click", pickPhrase)
if (!window.__TAURI__ || !window.__TAURI__.event || !window.__TAURI__.invoke) {
return
}
const { listen } = window.__TAURI__.event
const invoke = window.__TAURI__.invoke
listen("cli:ready", (event) => {
const payload = event?.payload || {}
if (payload.url) {
navigateTo(payload.url)
}
})
listen("cli:error", (event) => {
const payload = event?.payload || {}
if (payload.message) {
setError(payload.message)
}
})
listen("cli:status", (event) => {
const payload = event?.payload || {}
if (payload.state !== "ready") {
setError("")
}
})
try {
const status = await invoke("cli_get_status")
if (status?.state === "ready" && status.url) {
navigateTo(status.url)
}
if (status?.state === "error" && status.error) {
setError(status.error)
}
} catch (error) {
setError(String(error))
}
}
bootstrap()
</script>
</body>
</html>

View File

@@ -26,8 +26,29 @@ This starts the Vite dev server at `http://localhost:3000`.
To build the production assets:
```bash
```
npm run build
```
The output will be generated in the `dist` directory, which is then consumed by the Server or Electron app.
## Debug Logging
The UI now routes all logging through a lightweight wrapper around [`debug`](https://github.com/debug-js/debug). The logger exposes four namespaces that can be toggled at runtime:
- `sse` Server-sent event transport and handlers
- `api` HTTP/API calls and workspace lifecycle
- `session` Session/model state, prompt handling, tool calls
- `actions` User-driven interactions in UI components
You can enable or disable namespaces from DevTools (in dev or production builds) via the global `window.codenomadLogger` helpers:
```js
window.codenomadLogger?.listLoggerNamespaces() // => [{ name: "sse", enabled: false }, ...]
window.codenomadLogger?.enableLogger("sse") // turn on SSE logs
window.codenomadLogger?.disableLogger("sse") // turn them off again
window.codenomadLogger?.enableAllLoggers() // optional helper
```
Enabled namespaces are persisted in `localStorage` under `opencode:logger:namespaces`, so your preference survives reloads.

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.2.1",
"version": "0.9.0",
"private": true,
"type": "module",
"scripts": {
@@ -12,11 +12,17 @@
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.0.68",
"@opencode-ai/sdk": "1.1.11",
"@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",
"@suid/system": "^0.14.0",
"ansi-sequence-parser": "^1.1.3",
"debug": "^4.4.3",
"github-markdown-css": "^5.8.1",
"lucide-solid": "^0.300.0",
"marked": "^12.0.0",
"qrcode": "^1.5.3",
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0"

View File

@@ -1,4 +1,4 @@
import { Component, Show, createMemo, createEffect, createSignal } from "solid-js"
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import { Toaster } from "solid-toast"
import AlertDialog from "./components/alert-dialog"
@@ -6,16 +6,21 @@ import FolderSelectionView from "./components/folder-selection-view"
import { showConfirmDialog } from "./stores/alerts"
import InstanceTabs from "./components/instance-tabs"
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
import InstanceShell from "./components/instance/instance-shell"
import InstanceShell from "./components/instance/instance-shell2"
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
import { initMarkdown } from "./lib/markdown"
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 { initReleaseNotifications } from "./stores/releases"
import { runtimeEnv } from "./lib/runtime-env"
import {
hasInstances,
isSelectingFolder,
setIsSelectingFolder,
setHasInstances,
showFolderSelection,
setShowFolderSelection,
} from "./stores/ui"
@@ -41,22 +46,58 @@ import {
updateSessionModel,
} from "./stores/sessions"
const log = getLogger("actions")
const App: Component = () => {
const { isDark } = useTheme()
const {
preferences,
recordWorkspaceLaunch,
toggleShowThinkingBlocks,
toggleShowTimelineTools,
toggleAutoCleanupBlankSessions,
toggleUsageMetrics,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
} = useConfig()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
interface LaunchErrorState {
message: string
binaryPath: string
missingBinary: boolean
}
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
const updateInstanceTabBarHeight = () => {
if (typeof document === "undefined") return
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
}
createEffect(() => {
void initMarkdown(isDark()).catch(console.error)
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
})
createEffect(() => {
initReleaseNotifications()
})
createEffect(() => {
instances()
hasInstances()
requestAnimationFrame(() => updateInstanceTabBarHeight())
})
onMount(() => {
updateInstanceTabBarHeight()
const handleResize = () => updateInstanceTabBarHeight()
window.addEventListener("resize", handleResize)
onCleanup(() => window.removeEventListener("resize", handleResize))
})
const activeInstance = createMemo(() => getActiveInstance())
@@ -67,14 +108,30 @@ const App: Component = () => {
})
const launchErrorPath = () => {
const value = launchErrorBinary()
const value = launchError()?.binaryPath
if (!value) return "opencode"
return value.trim() || "opencode"
}
const isMissingBinaryError = (error: unknown): boolean => {
if (!error) return false
const message = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
const launchErrorMessage = () => launchError()?.message ?? ""
const formatLaunchErrorMessage = (error: unknown): string => {
if (!error) {
return "Failed to launch workspace"
}
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed.error === "string") {
return parsed.error
}
} catch {
// ignore JSON parse errors
}
return raw
}
const isMissingBinaryMessage = (message: string): boolean => {
const normalized = message.toLowerCase()
return (
normalized.includes("opencode binary not found") ||
@@ -85,7 +142,7 @@ const App: Component = () => {
)
}
const clearLaunchError = () => setLaunchErrorBinary(null)
const clearLaunchError = () => setLaunchError(null)
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
if (!folderPath) {
@@ -97,17 +154,22 @@ const App: Component = () => {
recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary)
setHasInstances(true)
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
console.log("Created instance:", instanceId, "Port:", instances().get(instanceId)?.port)
log.info("Created instance", {
instanceId,
port: instances().get(instanceId)?.port,
})
} catch (error) {
clearLaunchError()
if (isMissingBinaryError(error)) {
setLaunchErrorBinary(selectedBinary)
}
console.error("Failed to create instance:", error)
const message = formatLaunchErrorMessage(error)
const missingBinary = isMissingBinaryMessage(message)
setLaunchError({
message,
binaryPath: selectedBinary,
missingBinary,
})
log.error("Failed to create instance", error)
} finally {
setIsSelectingFolder(false)
}
@@ -132,7 +194,7 @@ const App: Component = () => {
try {
await acknowledgeDisconnectedInstance()
} catch (error) {
console.error("Failed to finalize disconnected instance:", error)
log.error("Failed to finalize disconnected instance", error)
}
}
@@ -150,9 +212,6 @@ const App: Component = () => {
if (!confirmed) return
await stopInstance(instanceId)
if (instances().size === 0) {
setHasInstances(false)
}
}
async function handleNewSession(instanceId: string) {
@@ -160,7 +219,7 @@ const App: Component = () => {
const session = await createSession(instanceId)
setActiveParentSession(instanceId, session.id)
} catch (error) {
console.error("Failed to create session:", error)
log.error("Failed to create session", error)
}
}
@@ -184,7 +243,7 @@ const App: Component = () => {
try {
await fetchSessions(instanceId)
} catch (error) {
console.error("Failed to refresh sessions after closing:", error)
log.error("Failed to refresh sessions after closing", error)
}
}
@@ -204,10 +263,14 @@ const App: Component = () => {
const { commands: paletteCommands, executeCommand } = useCommands({
preferences,
toggleAutoCleanupBlankSessions,
toggleShowThinkingBlocks,
toggleShowTimelineTools,
toggleUsageMetrics,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
handleNewInstanceRequest,
handleCloseInstance,
handleNewSession,
@@ -228,6 +291,28 @@ const App: Component = () => {
getActiveSessionIdForInstance: activeSessionIdForInstance,
})
// Listen for Tauri menu events
onMount(() => {
if (runtimeEnv.host === "tauri") {
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
tauriBridge.event.listen("menu:newInstance", () => {
handleNewInstanceRequest()
}).then((unlisten) => {
unlistenMenu = unlisten
}).catch((error) => {
log.error("Failed to listen for menu:newInstance event", error)
})
onCleanup(() => {
unlistenMenu?.()
})
}
}
})
return (
<>
<InstanceDisconnectedModal
@@ -237,16 +322,16 @@ const App: Component = () => {
onClose={handleDisconnectedInstanceClose}
/>
<Dialog open={Boolean(launchErrorBinary())} modal>
<Dialog open={Boolean(launchError())} modal>
<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-md p-6 flex flex-col gap-6">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">
Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom binary from
Advanced Settings.
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
We couldn't start the selected OpenCode binary. Review the error output below or choose a different
binary from Advanced Settings.
</Dialog.Description>
</div>
@@ -255,10 +340,23 @@ const App: Component = () => {
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
</div>
<Show when={launchErrorMessage()}>
<div class="rounded-lg border border-base bg-surface-secondary p-4">
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Error output</p>
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
</div>
</Show>
<div class="flex justify-end gap-2">
<button type="button" class="selector-button selector-button-secondary" onClick={handleLaunchErrorAdvanced}>
Open Advanced Settings
</button>
<Show when={launchError()?.missingBinary}>
<button
type="button"
class="selector-button selector-button-secondary"
onClick={handleLaunchErrorAdvanced}
>
Open Advanced Settings
</button>
</Show>
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
Close
</button>
@@ -278,22 +376,35 @@ const App: Component = () => {
onSelect={setActiveInstanceId}
onClose={handleCloseInstance}
onNew={handleNewInstanceRequest}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/>
<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" }}>
<InstanceMetadataProvider instance={instance}>
<InstanceShell
instance={instance}
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={instanceTabBarHeight()}
/>
</InstanceMetadataProvider>
</div>
)
}}
</For>
<Show when={activeInstance()} keyed>
{(instance) => (
<InstanceShell
instance={instance}
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}
/>
)}
</Show>
</>
}
>
@@ -303,6 +414,7 @@ const App: Component = () => {
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/>
</Show>
@@ -332,7 +444,9 @@ const App: Component = () => {
</div>
</div>
</Show>
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
<AlertDialog />
<Toaster
@@ -348,4 +462,5 @@ const App: Component = () => {
)
}
export default App

View File

@@ -3,7 +3,9 @@ import { For, Show, createEffect, createMemo } from "solid-js"
import { agents, fetchAgents, sessions } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import type { Agent } from "../types/session"
import Kbd from "./kbd"
import { getLogger } from "../lib/logger"
const log = getLogger("session")
interface AgentSelectorProps {
instanceId: string
@@ -50,10 +52,11 @@ export default function AgentSelector(props: AgentSelectorProps) {
createEffect(() => {
if (instanceAgents().length === 0) {
fetchAgents(props.instanceId).catch(console.error)
fetchAgents(props.instanceId).catch((error) => log.error("Failed to fetch agents", error))
}
})
const handleChange = async (value: Agent | null) => {
if (value && value.name !== props.currentAgent) {
await props.onAgentChange(value.name)
@@ -111,14 +114,11 @@ export default function AgentSelector(props: AgentSelectorProps) {
</Select.Trigger>
<Select.Portal>
<Select.Content class="selector-popover max-h-80 overflow-auto p-1 z-50">
<Select.Content class="selector-popover max-h-80 overflow-auto p-1">
<Select.Listbox class="selector-listbox" />
</Select.Content>
</Select.Portal>
</Select>
<span class="hint sidebar-selector-hint">
<Kbd shortcut="cmd+shift+a" />
</span>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { Dialog } from "@kobalte/core/dialog"
import { Component, Show, createEffect } from "solid-js"
import { Component, Show, createEffect, createSignal } from "solid-js"
import { alertDialogState, dismissAlertDialog } from "../stores/alerts"
import type { AlertVariant, AlertDialogState } from "../stores/alerts"
@@ -27,8 +27,9 @@ const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string
},
}
function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
function dismiss(confirmed: boolean, payload?: AlertDialogState | null, promptValue?: string) {
const current = payload ?? alertDialogState()
if (current?.type === "confirm") {
if (confirmed) {
current.onConfirm?.()
@@ -36,7 +37,23 @@ function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
current.onCancel?.()
}
current.resolve?.(confirmed)
} else if (confirmed) {
dismissAlertDialog()
return
}
if (current?.type === "prompt") {
if (confirmed) {
current.onConfirm?.()
current.resolvePrompt?.(promptValue ?? "")
} else {
current.onCancel?.()
current.resolvePrompt?.(null)
}
dismissAlertDialog()
return
}
if (confirmed) {
current?.onConfirm?.()
}
dismissAlertDialog()
@@ -44,13 +61,20 @@ function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
const AlertDialog: Component = () => {
let primaryButtonRef: HTMLButtonElement | undefined
let promptInputRef: HTMLInputElement | undefined
createEffect(() => {
if (alertDialogState()) {
queueMicrotask(() => {
primaryButtonRef?.focus()
})
}
const state = alertDialogState()
if (!state) return
queueMicrotask(() => {
if (state.type === "prompt") {
promptInputRef?.focus()
promptInputRef?.select()
return
}
primaryButtonRef?.focus()
})
})
return (
@@ -60,9 +84,12 @@ const AlertDialog: Component = () => {
const accent = variantAccent[variant]
const title = payload.title || accent.fallbackTitle
const isConfirm = payload.type === "confirm"
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : "OK")
const isPrompt = payload.type === "prompt"
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : isPrompt ? "Run" : "OK")
const cancelLabel = payload.cancelLabel || "Cancel"
const [inputValue, setInputValue] = createSignal(payload.inputDefaultValue ?? "")
return (
<Dialog
open
@@ -89,36 +116,60 @@ const AlertDialog: Component = () => {
>
{accent.symbol}
</div>
<div class="flex-1">
<div class="flex-1 min-w-0">
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line">
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words">
{payload.message}
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
</Dialog.Description>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
{isConfirm && (
<button
type="button"
class="button-secondary"
onClick={() => dismiss(false, payload)}
>
{cancelLabel}
</button>
)}
<button
type="button"
class="button-primary"
ref={(el) => {
primaryButtonRef = el
}}
onClick={() => dismiss(true, payload)}
>
{confirmLabel}
</button>
</div>
<Show when={isPrompt}>
<div class="mt-4">
<label class="text-sm font-medium text-secondary">{payload.inputLabel || "Input"}</label>
<input
ref={(el) => {
promptInputRef = el
}}
class="form-input mt-2"
value={inputValue()}
placeholder={payload.inputPlaceholder || ""}
autocapitalize="off"
autocorrect="off"
spellcheck={false}
onInput={(e) => setInputValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
dismiss(true, payload, inputValue())
}
}}
/>
</div>
</Show>
<div class="mt-6 flex justify-end gap-3">
{(isConfirm || isPrompt) && (
<button
type="button"
class="button-secondary"
onClick={() => dismiss(false, payload)}
>
{cancelLabel}
</button>
)}
<button
type="button"
class="button-primary"
ref={(el) => {
primaryButtonRef = el
}}
onClick={() => dismiss(true, payload, inputValue())}
>
{confirmLabel}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>

View File

@@ -0,0 +1,167 @@
import { Dialog } from "@kobalte/core/dialog"
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import type { BackgroundProcess } from "../../../server/src/api-types"
import { buildBackgroundProcessStreamUrl, serverApi } from "../lib/api-client"
import { createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
interface BackgroundProcessOutputDialogProps {
open: boolean
instanceId: string
process: BackgroundProcess | null
onClose: () => void
}
export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDialogProps) {
const [output, setOutput] = createSignal("")
const [outputHtml, setOutputHtml] = createSignal("")
const [ansiEnabled, setAnsiEnabled] = createSignal(false)
const [truncated, setTruncated] = createSignal(false)
const [loading, setLoading] = createSignal(false)
let ansiRenderer = createAnsiStreamRenderer()
createEffect(() => {
const process = props.process
if (!props.open || !process) {
return
}
let eventSource: EventSource | null = null
let active = true
let rawOutput = ""
const setRawOutput = (next: string) => {
rawOutput = next
setOutput(next)
}
const appendRawOutput = (chunk: string) => {
rawOutput += chunk
setOutput(rawOutput)
}
setAnsiEnabled(false)
setOutputHtml("")
setRawOutput("")
ansiRenderer.reset()
setLoading(true)
serverApi
.fetchBackgroundProcessOutput(props.instanceId, process.id, { method: "full", maxBytes: undefined })
.then((response) => {
if (!active) return
setRawOutput(response.content)
setTruncated(response.truncated)
const detectedAnsi = hasAnsi(response.content)
if (detectedAnsi) {
setAnsiEnabled(true)
ansiRenderer.reset()
setOutputHtml(ansiRenderer.render(response.content))
} else {
setAnsiEnabled(false)
setOutputHtml("")
ansiRenderer.reset()
}
})
.catch(() => {
if (!active) return
setRawOutput("Failed to load output.")
setAnsiEnabled(false)
setOutputHtml("")
})
.finally(() => {
if (!active) return
setLoading(false)
})
eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id), { withCredentials: true } as any)
eventSource.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as { type?: string; content?: string }
if (payload?.type !== "chunk" || typeof payload.content !== "string") {
return
}
const chunk = payload.content
const wasAnsiEnabled = ansiEnabled()
if (!wasAnsiEnabled) {
appendRawOutput(chunk)
if (hasAnsi(chunk)) {
setAnsiEnabled(true)
ansiRenderer.reset()
setOutputHtml(ansiRenderer.render(rawOutput))
}
return
}
appendRawOutput(chunk)
const htmlChunk = ansiRenderer.render(chunk)
setOutputHtml((prev) => `${prev}${htmlChunk}`)
} catch {
// ignore parse errors
}
}
onCleanup(() => {
active = false
eventSource?.close()
})
})
return (
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()} modal>
<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-5xl max-h-[90vh] flex flex-col overflow-hidden">
<div class="flex items-start justify-between px-6 py-4 border-b border-base gap-4">
<div class="flex-1 min-w-0">
<Dialog.Title class="text-lg font-semibold text-primary">Background Output</Dialog.Title>
<Show when={props.process}>
<span class="text-xs text-secondary block">
{props.process?.title} · {props.process?.id}
</span>
<span class="text-xs text-secondary mt-1 block truncate" title={props.process?.command}>
{props.process?.command}
</span>
</Show>
</div>
<button type="button" class="button-tertiary flex-shrink-0" onClick={props.onClose}>
Close
</button>
</div>
<div class="flex-1 overflow-auto p-6">
<Show when={loading()}>
<p class="text-xs text-secondary">Loading output...</p>
</Show>
<Show when={!loading()}>
<Show when={truncated()}>
<p class="text-xs text-secondary mb-2">Output truncated for display.</p>
</Show>
<Show
when={ansiEnabled()}
fallback={
<pre class="text-xs whitespace-pre-wrap break-all text-primary bg-surface-secondary border border-base rounded-md p-4 font-mono">
{output()}
</pre>
}
>
<pre
class="text-xs whitespace-pre-wrap break-all text-primary bg-surface-secondary border border-base rounded-md p-4 font-mono"
innerHTML={outputHtml()}
/>
</Show>
</Show>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}

View File

@@ -2,6 +2,7 @@ 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 { copyToClipboard } from "../lib/clipboard"
const inlineLoadedLanguages = new Set<string>()
@@ -61,9 +62,11 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
}
const copyCode = async () => {
await navigator.clipboard.writeText(props.code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
const success = await copyToClipboard(props.code)
if (success) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
return (

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