@bytespell/shella 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/bundled-plugins/agent/AGENT_SPEC.md +611 -0
  2. package/bundled-plugins/agent/README.md +7 -0
  3. package/bundled-plugins/agent/components.json +24 -0
  4. package/bundled-plugins/agent/eslint.config.js +23 -0
  5. package/bundled-plugins/agent/index.html +13 -0
  6. package/bundled-plugins/agent/package-lock.json +12140 -0
  7. package/bundled-plugins/agent/package.json +62 -0
  8. package/bundled-plugins/agent/public/vite.svg +1 -0
  9. package/bundled-plugins/agent/server.js +631 -0
  10. package/bundled-plugins/agent/src/App.tsx +755 -0
  11. package/bundled-plugins/agent/src/assets/react.svg +1 -0
  12. package/bundled-plugins/agent/src/components/ui/alert-dialog.tsx +182 -0
  13. package/bundled-plugins/agent/src/components/ui/badge.tsx +45 -0
  14. package/bundled-plugins/agent/src/components/ui/button.tsx +60 -0
  15. package/bundled-plugins/agent/src/components/ui/card.tsx +94 -0
  16. package/bundled-plugins/agent/src/components/ui/combobox.tsx +294 -0
  17. package/bundled-plugins/agent/src/components/ui/dropdown-menu.tsx +253 -0
  18. package/bundled-plugins/agent/src/components/ui/field.tsx +225 -0
  19. package/bundled-plugins/agent/src/components/ui/input-group.tsx +147 -0
  20. package/bundled-plugins/agent/src/components/ui/input.tsx +19 -0
  21. package/bundled-plugins/agent/src/components/ui/label.tsx +24 -0
  22. package/bundled-plugins/agent/src/components/ui/select.tsx +185 -0
  23. package/bundled-plugins/agent/src/components/ui/separator.tsx +26 -0
  24. package/bundled-plugins/agent/src/components/ui/switch.tsx +31 -0
  25. package/bundled-plugins/agent/src/components/ui/textarea.tsx +18 -0
  26. package/bundled-plugins/agent/src/index.css +131 -0
  27. package/bundled-plugins/agent/src/lib/utils.ts +6 -0
  28. package/bundled-plugins/agent/src/main.tsx +11 -0
  29. package/bundled-plugins/agent/src/reducer.test.ts +359 -0
  30. package/bundled-plugins/agent/src/reducer.ts +255 -0
  31. package/bundled-plugins/agent/src/store.ts +379 -0
  32. package/bundled-plugins/agent/src/types.ts +98 -0
  33. package/bundled-plugins/agent/src/utils.test.ts +393 -0
  34. package/bundled-plugins/agent/src/utils.ts +158 -0
  35. package/bundled-plugins/agent/tsconfig.app.json +32 -0
  36. package/bundled-plugins/agent/tsconfig.json +13 -0
  37. package/bundled-plugins/agent/tsconfig.node.json +26 -0
  38. package/bundled-plugins/agent/vite.config.ts +14 -0
  39. package/bundled-plugins/agent/vitest.config.ts +17 -0
  40. package/bundled-plugins/terminal/README.md +7 -0
  41. package/bundled-plugins/terminal/index.html +24 -0
  42. package/bundled-plugins/terminal/package-lock.json +3346 -0
  43. package/bundled-plugins/terminal/package.json +38 -0
  44. package/bundled-plugins/terminal/server.ts +265 -0
  45. package/bundled-plugins/terminal/src/App.tsx +153 -0
  46. package/bundled-plugins/terminal/src/TERMINAL_SPEC.md +404 -0
  47. package/bundled-plugins/terminal/src/main.tsx +9 -0
  48. package/bundled-plugins/terminal/src/store.ts +114 -0
  49. package/bundled-plugins/terminal/tsconfig.json +22 -0
  50. package/bundled-plugins/terminal/vite.config.ts +10 -0
  51. package/dist/src/plugin-manager.js +1 -1
  52. package/dist/src/plugin-manager.js.map +1 -1
  53. package/package.json +1 -1
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@shella/terminal",
3
+ "private": true,
4
+ "version": "0.2.6",
5
+ "type": "module",
6
+ "main": "server.ts",
7
+ "shella": {
8
+ "displayName": "Terminal",
9
+ "devCommand": "tsx server.ts"
10
+ },
11
+ "scripts": {
12
+ "dev": "tsx server.ts",
13
+ "build": "vite build",
14
+ "preview": "vite preview"
15
+ },
16
+ "dependencies": {
17
+ "@xterm/addon-fit": "^0.10.0",
18
+ "@xterm/addon-web-links": "^0.11.0",
19
+ "@xterm/xterm": "^5.5.0",
20
+ "express": "^5.0.0",
21
+ "node-pty": "^1.2.0-beta.7",
22
+ "react": "^19.2.0",
23
+ "react-dom": "^19.2.0",
24
+ "ws": "^8.18.0",
25
+ "zustand": "^5.0.10"
26
+ },
27
+ "devDependencies": {
28
+ "@types/express": "^5.0.0",
29
+ "@types/node": "^22.0.0",
30
+ "@types/react": "^19.2.0",
31
+ "@types/react-dom": "^19.2.0",
32
+ "@types/ws": "^8.5.0",
33
+ "@vitejs/plugin-react": "^4.3.0",
34
+ "tsx": "^4.19.0",
35
+ "typescript": "^5.6.0",
36
+ "vite": "^6.0.0"
37
+ }
38
+ }
@@ -0,0 +1,265 @@
1
+ import express from 'express'
2
+ import { createServer } from 'http'
3
+ import { WebSocketServer, WebSocket } from 'ws'
4
+ import * as pty from 'node-pty'
5
+ import path from 'path'
6
+ import fs from 'fs'
7
+ import { fileURLToPath } from 'url'
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
10
+ const app = express()
11
+ const PORT = process.env.PORT
12
+ const INSTANCE_ID = process.env.INSTANCE_ID
13
+ const HOME = process.env.HOME || '/tmp'
14
+
15
+ // State persistence paths (following XDG spec)
16
+ const XDG_STATE_HOME = process.env.XDG_STATE_HOME || path.join(HOME, '.local', 'state')
17
+ const STATE_DIR = path.join(XDG_STATE_HOME, 'shella', 'plugins', 'terminal')
18
+ const STATE_FILE = INSTANCE_ID ? path.join(STATE_DIR, `${INSTANCE_ID}.json`) : null
19
+
20
+ interface PersistedState {
21
+ scrollback: string[]
22
+ cwd?: string
23
+ }
24
+
25
+ function loadPersistedState(): PersistedState | null {
26
+ if (!STATE_FILE) return null
27
+ try {
28
+ if (fs.existsSync(STATE_FILE)) {
29
+ const content = fs.readFileSync(STATE_FILE, 'utf-8')
30
+ const state = JSON.parse(content) as PersistedState
31
+ console.log(`[terminal] Loaded persisted state for instance ${INSTANCE_ID}`)
32
+ return state
33
+ }
34
+ } catch (err) {
35
+ console.error('[terminal] Failed to load persisted state:', err)
36
+ }
37
+ return null
38
+ }
39
+
40
+ function savePersistedState(state: PersistedState): void {
41
+ if (!STATE_FILE) return
42
+ try {
43
+ fs.mkdirSync(STATE_DIR, { recursive: true })
44
+ fs.writeFileSync(STATE_FILE, JSON.stringify(state), 'utf-8')
45
+ console.log(`[terminal] Saved state for instance ${INSTANCE_ID}`)
46
+ } catch (err) {
47
+ console.error('[terminal] Failed to save state:', err)
48
+ }
49
+ }
50
+
51
+ // Check if we're in dev mode (no dist folder)
52
+ const isDev = !fs.existsSync(path.join(__dirname, 'dist', 'index.html'))
53
+
54
+ // Allow embedding in iframes (for shella-web)
55
+ app.use((_req, res, next) => {
56
+ res.removeHeader('X-Frame-Options')
57
+ res.setHeader('Content-Security-Policy', 'frame-ancestors *')
58
+ next()
59
+ })
60
+
61
+ app.use(express.json())
62
+
63
+ // Health check
64
+ app.get('/api/health', (_req, res) => {
65
+ res.json({ status: 'ok', shell: process.env.SHELL || '/bin/bash' })
66
+ })
67
+
68
+ async function startServer() {
69
+ if (isDev) {
70
+ // Dev mode: use Vite middleware for HMR
71
+ const { createServer: createViteServer } = await import('vite')
72
+ const vite = await createViteServer({
73
+ server: { middlewareMode: true, hmr: { port: Number(PORT) + 1000 } },
74
+ appType: 'spa',
75
+ })
76
+ app.use(vite.middlewares)
77
+ } else {
78
+ // Production: serve built files
79
+ app.use(express.static(path.join(__dirname, 'dist')))
80
+ app.get('/{*splat}', (_req, res) => {
81
+ res.sendFile(path.join(__dirname, 'dist', 'index.html'))
82
+ })
83
+ }
84
+
85
+ const server = createServer(app)
86
+
87
+ // PTY is spawned lazily on first client connection with correct size
88
+ const shell = process.env.SHELL || '/bin/bash'
89
+ const home = process.env.HOME || '/tmp'
90
+
91
+ let ptyProcess: pty.IPty | null = null
92
+
93
+ // Track connected clients
94
+ const clients = new Set<WebSocket>()
95
+
96
+ // Load persisted state if available (for daemon recovery)
97
+ const persistedState = loadPersistedState()
98
+
99
+ // Buffer recent output for new clients (scrollback)
100
+ // Initialize with persisted scrollback if recovering
101
+ const outputBuffer: string[] = persistedState?.scrollback ?? []
102
+ const MAX_BUFFER_LINES = 1000
103
+
104
+ // Track if we have unsaved changes
105
+ let hasUnsavedChanges = false
106
+
107
+ // Chunk size for sending buffered output (256KB - well under iOS's 1MB WebSocket limit)
108
+ const MAX_MESSAGE_SIZE = 256 * 1024
109
+
110
+ function sendChunked(ws: WebSocket, data: string) {
111
+ for (let i = 0; i < data.length; i += MAX_MESSAGE_SIZE) {
112
+ const chunk = data.slice(i, i + MAX_MESSAGE_SIZE)
113
+ ws.send(JSON.stringify({ type: 'output', data: chunk }))
114
+ }
115
+ }
116
+
117
+ // Broadcast master resize to all connected clients
118
+ function broadcastMasterResize(cols: number, rows: number) {
119
+ const message = JSON.stringify({ type: 'master_resize', cols, rows })
120
+ for (const client of clients) {
121
+ if (client.readyState === WebSocket.OPEN) {
122
+ client.send(message)
123
+ }
124
+ }
125
+ }
126
+
127
+ function spawnPty(cols: number, rows: number) {
128
+ if (ptyProcess) return // Already spawned
129
+
130
+ ptyProcess = pty.spawn(shell, [], {
131
+ name: 'xterm-256color',
132
+ cols,
133
+ rows,
134
+ cwd: home,
135
+ env: {
136
+ ...process.env as Record<string, string>,
137
+ // Suppress zsh's partial line marker (%) on fresh terminal
138
+ PROMPT_EOL_MARK: '',
139
+ },
140
+ })
141
+
142
+ console.log(`[terminal] PTY spawned: pid=${ptyProcess.pid}, shell=${shell}, size=${cols}x${rows}`)
143
+
144
+ // PTY -> all connected clients
145
+ ptyProcess.onData((data: string) => {
146
+ // Buffer output for new clients
147
+ outputBuffer.push(data)
148
+ if (outputBuffer.length > MAX_BUFFER_LINES) {
149
+ outputBuffer.shift()
150
+ }
151
+ hasUnsavedChanges = true
152
+
153
+ // Broadcast to all connected clients
154
+ const message = JSON.stringify({ type: 'output', data })
155
+ for (const client of clients) {
156
+ if (client.readyState === WebSocket.OPEN) {
157
+ client.send(message)
158
+ }
159
+ }
160
+ })
161
+
162
+ ptyProcess.onExit(({ exitCode, signal }) => {
163
+ console.log(`[terminal] PTY exited: code=${exitCode}, signal=${signal}`)
164
+ const message = JSON.stringify({ type: 'exit', exitCode, signal })
165
+ for (const client of clients) {
166
+ if (client.readyState === WebSocket.OPEN) {
167
+ client.send(message)
168
+ client.close()
169
+ }
170
+ }
171
+ // PTY died, shut down the instance
172
+ process.exit(exitCode ?? 0)
173
+ })
174
+ }
175
+
176
+ // WebSocket server for client connections
177
+ const wss = new WebSocketServer({ server, path: '/ws' })
178
+
179
+ wss.on('connection', (ws: WebSocket) => {
180
+ clients.add(ws)
181
+
182
+ // Send current master size if PTY exists
183
+ if (ptyProcess) {
184
+ ws.send(JSON.stringify({
185
+ type: 'master_resize',
186
+ cols: ptyProcess.cols,
187
+ rows: ptyProcess.rows
188
+ }))
189
+ }
190
+
191
+ // Send buffered output to new client so they see history
192
+ // This works for both active PTY output AND recovered scrollback from previous session
193
+ // Use chunked sending to avoid exceeding iOS's 1MB WebSocket message limit
194
+ if (outputBuffer.length > 0) {
195
+ sendChunked(ws, outputBuffer.join(''))
196
+ }
197
+
198
+ // Client -> PTY
199
+ ws.on('message', (message: Buffer) => {
200
+ try {
201
+ const msg = JSON.parse(message.toString())
202
+
203
+ switch (msg.type) {
204
+ case 'input':
205
+ // Only forward input - no resize from input messages
206
+ ptyProcess?.write(msg.data)
207
+ break
208
+ case 'resize':
209
+ if (msg.cols && msg.rows) {
210
+ if (!ptyProcess) {
211
+ // First resize spawns the PTY with correct size
212
+ spawnPty(msg.cols, msg.rows)
213
+ // Broadcast the initial master size to all clients (including the one that just connected)
214
+ broadcastMasterResize(msg.cols, msg.rows)
215
+ } else {
216
+ ptyProcess.resize(msg.cols, msg.rows)
217
+ // Broadcast the new master size to all clients
218
+ broadcastMasterResize(msg.cols, msg.rows)
219
+ }
220
+ }
221
+ break
222
+ }
223
+ } catch (err) {
224
+ console.error('[terminal] Invalid message:', err)
225
+ }
226
+ })
227
+
228
+ ws.on('close', () => {
229
+ clients.delete(ws)
230
+ })
231
+
232
+ ws.on('error', (err) => {
233
+ console.error('[terminal] WebSocket error:', err)
234
+ clients.delete(ws)
235
+ })
236
+ })
237
+
238
+ server.listen(PORT, () => {
239
+ console.log(`[terminal] Running on port ${PORT}${isDev ? ' (dev mode with HMR)' : ''}`)
240
+ if (persistedState) {
241
+ console.log(`[terminal] Restored ${persistedState.scrollback.length} lines of scrollback`)
242
+ }
243
+ })
244
+
245
+ // Periodic save (every 30 seconds if there are changes)
246
+ const saveInterval = setInterval(() => {
247
+ if (hasUnsavedChanges) {
248
+ savePersistedState({ scrollback: outputBuffer })
249
+ hasUnsavedChanges = false
250
+ }
251
+ }, 30000)
252
+
253
+ // Graceful shutdown - save state and kill PTY when instance stops
254
+ process.on('SIGTERM', () => {
255
+ console.log('[terminal] SIGTERM received, shutting down')
256
+ clearInterval(saveInterval)
257
+ // Save final state before exiting
258
+ savePersistedState({ scrollback: outputBuffer })
259
+ ptyProcess?.kill()
260
+ wss.clients.forEach((client) => client.close())
261
+ server.close(() => process.exit(0))
262
+ })
263
+ }
264
+
265
+ startServer()
@@ -0,0 +1,153 @@
1
+ import { useEffect, useRef, useCallback } from 'react'
2
+ import { Terminal } from '@xterm/xterm'
3
+ import { WebLinksAddon } from '@xterm/addon-web-links'
4
+ import { FitAddon } from '@xterm/addon-fit'
5
+ import '@xterm/xterm/css/xterm.css'
6
+ import { useTerminalStore } from './store'
7
+
8
+ export default function App() {
9
+ const containerRef = useRef<HTMLDivElement>(null)
10
+ const terminalRef = useRef<HTMLDivElement>(null)
11
+ const fitAddonRef = useRef<FitAddon | null>(null)
12
+ const initialResizeSentRef = useRef(false)
13
+
14
+ const { status, connect, setTerminal, sendInput, sendResize } = useTerminalStore()
15
+
16
+ // Calculate optimal size for current viewport (used only for initial resize)
17
+ const calculateOptimalSize = useCallback(() => {
18
+ const container = containerRef.current
19
+ const terminal = useTerminalStore.getState().terminal
20
+ if (!container || !terminal) return null
21
+
22
+ const core = (terminal as unknown as { _core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } } })._core
23
+ const cellDims = core?._renderService?.dimensions?.css?.cell
24
+ if (!cellDims) return null
25
+
26
+ const padding = 4
27
+ const availableWidth = container.clientWidth - padding * 2
28
+ const availableHeight = container.clientHeight - padding * 2
29
+
30
+ const cols = Math.floor(availableWidth / cellDims.width)
31
+ const rows = Math.floor(availableHeight / cellDims.height)
32
+
33
+ if (cols <= 0 || rows <= 0) return null
34
+ return { cols, rows }
35
+ }, [])
36
+
37
+ // Send initial resize to spawn PTY (only once)
38
+ const sendInitialResize = useCallback(() => {
39
+ if (initialResizeSentRef.current) return
40
+
41
+ const optimal = calculateOptimalSize()
42
+ if (!optimal) return
43
+
44
+ initialResizeSentRef.current = true
45
+ sendResize(optimal.cols, optimal.rows)
46
+ }, [calculateOptimalSize, sendResize])
47
+
48
+ // Initialize terminal (once)
49
+ useEffect(() => {
50
+ if (!terminalRef.current) return
51
+
52
+ // Check if already initialized
53
+ const existingTerminal = useTerminalStore.getState().terminal
54
+ if (existingTerminal) return
55
+
56
+ const term = new Terminal({
57
+ cursorBlink: true,
58
+ fontSize: 14,
59
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
60
+ theme: {
61
+ background: '#1a1a1a',
62
+ foreground: '#e0e0e0',
63
+ cursor: '#e0e0e0',
64
+ cursorAccent: '#1a1a1a',
65
+ selectionBackground: '#4a4a4a',
66
+ black: '#1a1a1a',
67
+ red: '#ff5555',
68
+ green: '#50fa7b',
69
+ yellow: '#f1fa8c',
70
+ blue: '#6272a4',
71
+ magenta: '#ff79c6',
72
+ cyan: '#8be9fd',
73
+ white: '#e0e0e0',
74
+ brightBlack: '#4a4a4a',
75
+ brightRed: '#ff6e6e',
76
+ brightGreen: '#69ff94',
77
+ brightYellow: '#ffffa5',
78
+ brightBlue: '#d6acff',
79
+ brightMagenta: '#ff92df',
80
+ brightCyan: '#a4ffff',
81
+ brightWhite: '#ffffff',
82
+ },
83
+ })
84
+
85
+ // Add addons
86
+ const webLinksAddon = new WebLinksAddon()
87
+ const fitAddon = new FitAddon()
88
+ term.loadAddon(webLinksAddon)
89
+ term.loadAddon(fitAddon)
90
+ fitAddonRef.current = fitAddon
91
+
92
+ // Open terminal
93
+ term.open(terminalRef.current)
94
+
95
+ // Set up input handler
96
+ term.onData((data) => {
97
+ sendInput(data)
98
+ })
99
+
100
+ // Store terminal and connect
101
+ setTerminal(term)
102
+ connect()
103
+
104
+ // Initial resize after a tick (to let xterm measure)
105
+ setTimeout(() => {
106
+ sendInitialResize()
107
+ }, 50)
108
+
109
+ // No cleanup - terminal persists in store
110
+ }, [connect, setTerminal, sendInput, sendInitialResize])
111
+
112
+ return (
113
+ <div
114
+ ref={containerRef}
115
+ style={{
116
+ width: '100%',
117
+ height: '100%',
118
+ backgroundColor: '#1a1a1a',
119
+ display: 'flex',
120
+ flexDirection: 'column',
121
+ overflow: 'hidden',
122
+ position: 'relative',
123
+ }}
124
+ >
125
+ {status !== 'connected' && (
126
+ <div
127
+ style={{
128
+ padding: '8px 12px',
129
+ backgroundColor: status === 'error' ? '#ff5555' : '#4a4a4a',
130
+ color: '#fff',
131
+ fontSize: '12px',
132
+ }}
133
+ >
134
+ {status === 'connecting' && 'Connecting...'}
135
+ {status === 'disconnected' && 'Disconnected'}
136
+ {status === 'error' && 'Connection error'}
137
+ </div>
138
+ )}
139
+ <div
140
+ style={{
141
+ flex: 1,
142
+ display: 'flex',
143
+ alignItems: 'flex-start',
144
+ justifyContent: 'flex-start',
145
+ padding: '4px',
146
+ overflow: 'hidden',
147
+ }}
148
+ >
149
+ <div ref={terminalRef} />
150
+ </div>
151
+ </div>
152
+ )
153
+ }