openrune 1.1.2 → 2.0.0

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 (38) hide show
  1. package/README.ko.md +5 -76
  2. package/README.md +7 -80
  3. package/bin/rune.js +18 -653
  4. package/package.json +10 -40
  5. package/.claude-plugin/marketplace.json +0 -17
  6. package/.claude-plugin/plugin.json +0 -24
  7. package/.mcp.json +0 -16
  8. package/bootstrap.js +0 -8
  9. package/channel/rune-channel.ts +0 -486
  10. package/electron-builder.yml +0 -61
  11. package/finder-extension/FinderSync.swift +0 -47
  12. package/finder-extension/RuneFinderSync.appex/Contents/Info.plist +0 -27
  13. package/finder-extension/RuneFinderSync.appex/Contents/MacOS/RuneFinderSync +0 -0
  14. package/finder-extension/main.swift +0 -5
  15. package/renderer/index.html +0 -12
  16. package/renderer/src/App.tsx +0 -44
  17. package/renderer/src/features/chat/activity-block.tsx +0 -152
  18. package/renderer/src/features/chat/chat-header.tsx +0 -58
  19. package/renderer/src/features/chat/chat-input.tsx +0 -190
  20. package/renderer/src/features/chat/chat-panel.tsx +0 -151
  21. package/renderer/src/features/chat/markdown-renderer.tsx +0 -26
  22. package/renderer/src/features/chat/message-bubble.tsx +0 -79
  23. package/renderer/src/features/chat/message-list.tsx +0 -178
  24. package/renderer/src/features/chat/types.ts +0 -32
  25. package/renderer/src/features/chat/use-chat.ts +0 -260
  26. package/renderer/src/features/terminal/terminal-panel.tsx +0 -155
  27. package/renderer/src/global.d.ts +0 -29
  28. package/renderer/src/globals.css +0 -92
  29. package/renderer/src/hooks/use-ipc.ts +0 -24
  30. package/renderer/src/lib/markdown.ts +0 -83
  31. package/renderer/src/lib/utils.ts +0 -6
  32. package/renderer/src/main.tsx +0 -10
  33. package/renderer/tsconfig.json +0 -16
  34. package/renderer/vite.config.ts +0 -23
  35. package/screenshot-chatting-ui.png +0 -0
  36. package/src/main.ts +0 -796
  37. package/src/preload.ts +0 -58
  38. package/tsconfig.json +0 -14
package/src/main.ts DELETED
@@ -1,796 +0,0 @@
1
- import { app, BrowserWindow, ipcMain, dialog } from 'electron'
2
- import * as path from 'path'
3
- import * as fs from 'fs'
4
- import * as http from 'http'
5
- import { execSync } from 'child_process'
6
- import * as pty from 'node-pty'
7
-
8
- const RUNE_DIR = path.join(require('os').homedir(), '.rune')
9
- const CHANNEL_PORT = 51234
10
-
11
- // Ensure rune config dir exists
12
- if (!fs.existsSync(RUNE_DIR)) fs.mkdirSync(RUNE_DIR, { recursive: true })
13
-
14
- // ── Window Registry ──────────────────────────────────
15
- interface RuneWindow {
16
- window: BrowserWindow
17
- filePath: string
18
- folderPath: string
19
- port: number
20
- }
21
-
22
- const windowRegistry = new Map<string, RuneWindow>()
23
- const ptyProcesses = new Map<string, pty.IPty>()
24
- const ptyOwnerWindows = new Map<string, Electron.WebContents>()
25
- let ptyIdCounter = 0
26
-
27
- // ── .rune File I/O ───────────────────────────────────
28
- interface WindowBounds {
29
- x: number
30
- y: number
31
- width: number
32
- height: number
33
- }
34
-
35
- interface RuneFile {
36
- name: string
37
- role: string
38
- icon?: string
39
- port?: number
40
- createdAt?: string
41
- windowBounds?: WindowBounds
42
- history?: { role: 'user' | 'assistant'; text: string; ts: number }[]
43
- memory?: string[]
44
- }
45
-
46
- function readRuneFile(filePath: string): RuneFile {
47
- try {
48
- return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
49
- } catch {
50
- return { name: path.basename(filePath, '.rune'), role: 'General assistant', history: [] }
51
- }
52
- }
53
-
54
- function writeRuneFile(filePath: string, data: RuneFile) {
55
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8')
56
- }
57
-
58
- function appendHistory(filePath: string, msg: { role: 'user' | 'assistant'; text: string; ts: number }) {
59
- const rune = readRuneFile(filePath)
60
- if (!rune.history) rune.history = []
61
- rune.history.push(msg)
62
- writeRuneFile(filePath, rune)
63
- }
64
-
65
- // ── Port Allocation ──────────────────────────────────
66
- function isPortInUse(port: number): Promise<boolean> {
67
- return new Promise((resolve) => {
68
- const srv = require('net').createServer()
69
- srv.once('error', () => resolve(true))
70
- srv.once('listening', () => { srv.close(); resolve(false) })
71
- srv.listen(port, '127.0.0.1')
72
- })
73
- }
74
-
75
- async function allocatePort(): Promise<number> {
76
- const usedPorts = new Set<number>()
77
- for (const [, rw] of windowRegistry) usedPorts.add(rw.port)
78
- let port = CHANNEL_PORT
79
- while (usedPorts.has(port) || await isPortInUse(port)) port++
80
- return port
81
- }
82
-
83
- // ── Channel Health Check ─────────────────────────────
84
- function checkChannelHealth(port: number): Promise<boolean> {
85
- return new Promise((resolve) => {
86
- const req = http.get(`http://127.0.0.1:${port}`, { timeout: 2000 }, (res) => {
87
- let data = ''
88
- res.on('data', (chunk: Buffer) => { data += chunk })
89
- res.on('end', () => {
90
- try {
91
- const json = JSON.parse(data)
92
- resolve(json.status === 'ok' && json.name === 'rune-channel' && json.mcpConnected !== false)
93
- } catch { resolve(false) }
94
- })
95
- })
96
- req.on('error', () => resolve(false))
97
- req.on('timeout', () => { req.destroy(); resolve(false) })
98
- })
99
- }
100
-
101
- // ── Channel Message Sender ───────────────────────────
102
- function sanitizeUnicode(s: string): string {
103
- return s.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '\uFFFD')
104
- }
105
-
106
- const activeRequests = new Map<number, http.ClientRequest>()
107
-
108
- function sendToChannel(content: string, port: number): Promise<string> {
109
- return new Promise((resolve, reject) => {
110
- const body = JSON.stringify({ type: 'chat', content: sanitizeUnicode(content) })
111
- const req = http.request({
112
- hostname: '127.0.0.1',
113
- port,
114
- path: '/',
115
- method: 'POST',
116
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
117
- }, (res) => {
118
- let data = ''
119
- res.on('data', (chunk: Buffer) => { data += chunk })
120
- res.on('end', () => { activeRequests.delete(port); resolve(data) })
121
- })
122
- req.on('error', (e) => { activeRequests.delete(port); reject(e) })
123
- activeRequests.set(port, req)
124
- req.write(body)
125
- req.end()
126
- })
127
- }
128
-
129
- function cancelChannelRequest(port: number) {
130
- const req = activeRequests.get(port)
131
- if (req) {
132
- req.destroy()
133
- activeRequests.delete(port)
134
- }
135
- }
136
-
137
- // ── SSE Push Listener ────────────────────────────────
138
- const sseConnections = new Map<number, http.IncomingMessage>()
139
- const retryTimers = new Map<number, ReturnType<typeof setInterval>>()
140
- const sseReconnectTimers = new Map<number, ReturnType<typeof setTimeout>>()
141
-
142
- function getWindowForPort(port: number): BrowserWindow | null {
143
- for (const [, rw] of windowRegistry) {
144
- if (rw.port === port && !rw.window.isDestroyed()) return rw.window
145
- }
146
- return null
147
- }
148
-
149
- function connectSSE(port: number) {
150
- if (sseConnections.has(port)) return
151
-
152
- const req = http.get(`http://127.0.0.1:${port}/sse`, (res) => {
153
- sseConnections.set(port, res)
154
- console.log(`[rune] SSE connected to :${port}`)
155
-
156
- let buf = ''
157
- res.on('data', (chunk: Buffer) => {
158
- buf += chunk
159
- const lines = buf.split('\n')
160
- buf = lines.pop() || ''
161
- for (const line of lines) {
162
- if (line.startsWith('data: ')) {
163
- try {
164
- const data = JSON.parse(line.slice(6))
165
- const win = getWindowForPort(port)
166
- if (!win) continue
167
-
168
- if (data.type === 'push') {
169
- // Save push message to .rune file history
170
- const rw = [...windowRegistry.values()].find(r => r.port === port)
171
- if (rw) appendHistory(rw.filePath, { role: 'assistant', text: data.text, ts: Date.now() })
172
- win.webContents.send('rune:pushMessage', { text: data.text, port })
173
- }
174
- if (data.type === 'tool_start' || data.type === 'tool_end') {
175
- win.webContents.send('rune:toolActivity', { port, type: data.type, tool: data.tool, args: data.args, preview: data.preview })
176
- }
177
- if (data.type === 'activity') {
178
- win.webContents.send('rune:activity', { port, activityType: data.activityType, content: data.content, tool: data.tool, args: data.args })
179
- }
180
- if (data.type === 'memory_update') {
181
- // Channel updated memory in .rune file — reload and notify renderer
182
- const rw = [...windowRegistry.values()].find(r => r.port === port)
183
- if (rw) {
184
- const rune = readRuneFile(rw.filePath)
185
- win.webContents.send('rune:memoryUpdate', { memory: rune.memory || [] })
186
- }
187
- }
188
- if (data.type === 'hook') {
189
- win.webContents.send('rune:hook', { port, ...data })
190
- }
191
- if (data.type === 'session_start') {
192
- win.webContents.send('rune:sessionStart', { port })
193
- }
194
- if (data.type === 'mcp_disconnected') {
195
- console.log(`[rune] MCP disconnected on :${port}`)
196
- win.webContents.send('rune:channelStatus', { port, connected: false })
197
- sseConnections.delete(port)
198
- startRetryPolling(port)
199
- }
200
- } catch {}
201
- }
202
- }
203
- })
204
- res.on('end', () => {
205
- sseConnections.delete(port)
206
- console.log(`[rune] SSE disconnected from :${port}, reconnecting in 3s...`)
207
- const t = setTimeout(() => connectSSE(port), 3000)
208
- sseReconnectTimers.set(port, t)
209
- })
210
- res.on('error', () => {
211
- sseConnections.delete(port)
212
- const t = setTimeout(() => connectSSE(port), 3000)
213
- sseReconnectTimers.set(port, t)
214
- })
215
- })
216
- req.on('error', () => {
217
- const t = setTimeout(() => connectSSE(port), 5000)
218
- sseReconnectTimers.set(port, t)
219
- })
220
- }
221
-
222
- function disconnectSSE(port: number) {
223
- const res = sseConnections.get(port)
224
- if (res) {
225
- res.destroy()
226
- sseConnections.delete(port)
227
- }
228
- const reconnectTimer = sseReconnectTimers.get(port)
229
- if (reconnectTimer) {
230
- clearTimeout(reconnectTimer)
231
- sseReconnectTimers.delete(port)
232
- }
233
- }
234
-
235
- async function autoConnectChannel(port: number): Promise<boolean> {
236
- const ok = await checkChannelHealth(port)
237
- if (ok) {
238
- connectSSE(port)
239
- const timer = retryTimers.get(port)
240
- if (timer) { clearInterval(timer); retryTimers.delete(port) }
241
- const win = getWindowForPort(port)
242
- win?.webContents.send('rune:channelStatus', { port, connected: true })
243
- console.log(`[rune] auto-connected to channel :${port}`)
244
- }
245
- return ok
246
- }
247
-
248
- function startRetryPolling(port: number) {
249
- if (retryTimers.has(port)) return
250
- const timer = setInterval(async () => {
251
- await autoConnectChannel(port)
252
- }, 5000)
253
- retryTimers.set(port, timer)
254
- }
255
-
256
- // ── .mcp.json Writer ─────────────────────────────────
257
- function findNodePath(): string {
258
- const cmd = process.platform === 'win32' ? 'where node' : 'which node'
259
- try { return execSync(cmd, { encoding: 'utf-8' }).trim().split('\n')[0] } catch {}
260
- const candidates = process.platform === 'win32'
261
- ? ['C:\\Program Files\\nodejs\\node.exe']
262
- : ['/usr/local/bin/node', '/opt/homebrew/bin/node']
263
- for (const p of candidates) {
264
- if (fs.existsSync(p)) return p
265
- }
266
- return 'node'
267
- }
268
-
269
- function writeMcpJson(folderPath: string, port: number, role?: string, runeFilePath?: string) {
270
- const channelJs = path.join(__dirname, 'rune-channel.js')
271
- const nodePath = findNodePath()
272
- const env: Record<string, string> = {
273
- RUNE_FOLDER_PATH: folderPath,
274
- RUNE_CHANNEL_PORT: String(port),
275
- }
276
- if (role) env.RUNE_AGENT_ROLE = role
277
- if (runeFilePath) env.RUNE_FILE_PATH = runeFilePath
278
- const mcpConfig = {
279
- mcpServers: {
280
- 'rune-channel': {
281
- command: nodePath,
282
- args: [channelJs],
283
- env,
284
- },
285
- },
286
- }
287
- try {
288
- fs.writeFileSync(path.join(folderPath, '.mcp.json'), JSON.stringify(mcpConfig, null, 2), 'utf-8')
289
- } catch {}
290
-
291
- // Auto-allow rune-channel tools so Claude doesn't prompt for permission
292
- const claudeDir = path.join(folderPath, '.claude')
293
- const settingsFile = path.join(claudeDir, 'settings.local.json')
294
- try {
295
- if (!fs.existsSync(claudeDir)) fs.mkdirSync(claudeDir, { recursive: true })
296
- let settings: any = {}
297
- if (fs.existsSync(settingsFile)) {
298
- try { settings = JSON.parse(fs.readFileSync(settingsFile, 'utf-8')) } catch {}
299
- }
300
- if (!settings.permissions) settings.permissions = {}
301
- if (!settings.permissions.allow) settings.permissions.allow = []
302
- const requiredTools = [
303
- 'mcp__rune-channel__rune_reply',
304
- 'mcp__rune-channel__rune_activity',
305
- 'mcp__rune-channel__rune_memory',
306
- ]
307
- for (const tool of requiredTools) {
308
- if (!settings.permissions.allow.includes(tool)) {
309
- settings.permissions.allow.push(tool)
310
- }
311
- }
312
- fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2), 'utf-8')
313
- } catch {}
314
- }
315
-
316
- // ── Channel Message Handler ─────────────────────────
317
- async function handleChannelMessage(content: string, runeFilePath: string, port: number) {
318
- const win = getWindowForPort(port)
319
- if (!win) return
320
-
321
- win.webContents.send('rune:streamStart', {})
322
- win.webContents.send('rune:streamStatus', { status: `Channel :${port} sending...` })
323
-
324
- try {
325
- const reply = await sendToChannel(content, port)
326
- win.webContents.send('rune:streamChunk', { text: reply })
327
- win.webContents.send('rune:streamEnd', {})
328
-
329
- // Save to .rune history
330
- appendHistory(runeFilePath, { role: 'assistant', text: reply, ts: Date.now() })
331
- } catch (e: any) {
332
- const rune = readRuneFile(runeFilePath)
333
- const folderPath = path.dirname(runeFilePath)
334
- win.webContents.send('rune:streamError', {
335
- error: `Channel :${port} error: ${e.message}\n\nStart the channel:\ncd ${folderPath} && RUNE_CHANNEL_PORT=${port} RUNE_FOLDER_PATH=${folderPath}${rune.role ? ` RUNE_AGENT_ROLE="${rune.role}"` : ''} claude --permission-mode auto --enable-auto-mode --dangerously-load-development-channels server:rune-channel`,
336
- })
337
- }
338
- }
339
-
340
- // ── Window Creation ──────────────────────────────────
341
- async function createRuneWindow(filePath: string) {
342
- // If window already exists for this file, focus it
343
- const existing = windowRegistry.get(filePath)
344
- if (existing && !existing.window.isDestroyed()) {
345
- existing.window.focus()
346
- return
347
- }
348
-
349
- const rune = readRuneFile(filePath)
350
- const folderPath = path.dirname(filePath)
351
- // Always allocate a fresh port to avoid conflicts with stale ports
352
- const port = await allocatePort()
353
-
354
- // Sync name with filename & save port
355
- const fileBaseName = path.basename(filePath, '.rune')
356
- const nameChanged = rune.name !== fileBaseName
357
- if (nameChanged) rune.name = fileBaseName
358
- if (rune.port !== port || nameChanged) {
359
- rune.port = port
360
- writeRuneFile(filePath, rune)
361
- }
362
-
363
- // Write .mcp.json
364
- writeMcpJson(folderPath, port, rune.role, filePath)
365
-
366
- const bounds = rune.windowBounds
367
- const win = new BrowserWindow({
368
- width: bounds?.width || 500,
369
- height: bounds?.height || 700,
370
- ...(bounds ? { x: bounds.x, y: bounds.y } : {}),
371
- minWidth: 300,
372
- minHeight: 400,
373
- ...(process.platform === 'darwin' ? {
374
- titleBarStyle: 'hiddenInset' as const,
375
- trafficLightPosition: { x: 12, y: 12 },
376
- } : {}),
377
- backgroundColor: '#1a1a1a',
378
- title: `${rune.name} — ${path.basename(folderPath)}`,
379
- webPreferences: {
380
- preload: path.join(__dirname, 'preload.js'),
381
- contextIsolation: true,
382
- nodeIntegration: false,
383
- },
384
- })
385
-
386
- let currentFilePath = filePath
387
-
388
- // Save window bounds on move/resize
389
- const saveBounds = () => {
390
- if (win.isDestroyed()) return
391
- const currentBounds = win.getBounds()
392
- const currentRune = readRuneFile(currentFilePath)
393
- currentRune.windowBounds = currentBounds
394
- writeRuneFile(currentFilePath, currentRune)
395
- }
396
- let boundsTimeout: ReturnType<typeof setTimeout> | null = null
397
- const debouncedSaveBounds = () => {
398
- if (boundsTimeout) clearTimeout(boundsTimeout)
399
- boundsTimeout = setTimeout(saveBounds, 500)
400
- }
401
- win.on('resize', debouncedSaveBounds)
402
- win.on('move', debouncedSaveBounds)
403
- const rw: RuneWindow = { window: win, filePath, folderPath, port }
404
- windowRegistry.set(filePath, rw)
405
- updateDockVisibility()
406
-
407
- win.loadFile(path.join(__dirname, 'renderer', 'index.html'))
408
-
409
- win.webContents.on('did-finish-load', () => {
410
- const initData = {
411
- filePath: currentFilePath,
412
- folderPath,
413
- port,
414
- name: rune.name,
415
- role: rune.role,
416
- icon: rune.icon,
417
- history: rune.history || [],
418
- }
419
- console.log('[rune] Sending init:', JSON.stringify({ filePath: currentFilePath, folderPath, port, name: rune.name }))
420
- win.webContents.send('rune:init', initData)
421
- })
422
-
423
- // Watch folder for .rune file renames
424
- const createdAt = rune.createdAt
425
- let dirWatcher: fs.FSWatcher | null = null
426
- try {
427
- dirWatcher = fs.watch(folderPath, (_eventType, filename) => {
428
- if (!filename?.endsWith('.rune')) return
429
- // Check if our file still exists
430
- if (fs.existsSync(currentFilePath)) return
431
- // Our file was renamed/deleted — scan for the new name
432
- try {
433
- const files = fs.readdirSync(folderPath).filter(f => f.endsWith('.rune'))
434
- for (const f of files) {
435
- const candidate = path.join(folderPath, f)
436
- try {
437
- const data = JSON.parse(fs.readFileSync(candidate, 'utf-8'))
438
- if (data.createdAt === createdAt && data.port === port) {
439
- console.log(`[rune] Detected rename: ${path.basename(currentFilePath)} → ${f}`)
440
- // Update registry
441
- windowRegistry.delete(currentFilePath)
442
- currentFilePath = candidate
443
- rw.filePath = candidate
444
- windowRegistry.set(candidate, rw)
445
- // Update name inside .rune file to match new filename
446
- const newName = path.basename(candidate, '.rune')
447
- data.name = newName
448
- writeRuneFile(candidate, data)
449
- // Update .mcp.json
450
- writeMcpJson(folderPath, port, data.role, candidate)
451
- // Update window title
452
- win.setTitle(`${newName} — ${path.basename(folderPath)}`)
453
- // Notify renderer
454
- win.webContents.send('rune:fileRenamed', { oldPath: currentFilePath, newPath: candidate, name: newName })
455
- break
456
- }
457
- } catch {}
458
- }
459
- } catch {}
460
- })
461
- } catch (e) {
462
- console.error('[rune] Failed to watch folder:', e)
463
- }
464
-
465
- // Start channel health check
466
- startRetryPolling(port)
467
- autoConnectChannel(port)
468
-
469
- win.on('closed', () => {
470
- windowRegistry.delete(currentFilePath)
471
- if (dirWatcher) dirWatcher.close()
472
- disconnectSSE(port)
473
- const timer = retryTimers.get(port)
474
- if (timer) { clearInterval(timer); retryTimers.delete(port) }
475
- // Kill all pty processes for this window (kills Claude Code + channel)
476
- for (const [id, p] of ptyProcesses) {
477
- try { process.kill(p.pid, 'SIGTERM') } catch {}
478
- try { p.kill() } catch {}
479
- ptyProcesses.delete(id)
480
- ptyOwnerWindows.delete(id)
481
- }
482
- // Clear port from .rune file so next session gets a fresh port
483
- try {
484
- const closeRune = readRuneFile(currentFilePath)
485
- delete closeRune.port
486
- writeRuneFile(currentFilePath, closeRune)
487
- } catch {}
488
- // Clean up .mcp.json if no other windows use this folder
489
- const folderStillUsed = [...windowRegistry.values()].some(r => r.folderPath === folderPath && !r.window.isDestroyed())
490
- if (!folderStillUsed) {
491
- try { fs.unlinkSync(path.join(folderPath, '.mcp.json')) } catch {}
492
- }
493
- updateDockVisibility()
494
- })
495
- }
496
-
497
- // ── Pending file path (before app ready) ─────────────
498
- let pendingFilePath: string | null = null
499
-
500
- app.on('will-finish-launching', () => {
501
- app.on('open-file', (event, filePath) => {
502
- event.preventDefault()
503
- console.log('[rune] open-file event:', filePath, 'ready:', app.isReady())
504
- if (app.isReady()) {
505
- createRuneWindow(filePath)
506
- } else {
507
- pendingFilePath = filePath
508
- }
509
- })
510
- })
511
-
512
- // ── IPC Setup ────────────────────────────────────────
513
- function setupIPC() {
514
- // Send message
515
- ipcMain.on('rune:sendMessage', async (event, data: { content: string; port: number }) => {
516
- const rw = [...windowRegistry.values()].find(r => r.port === data.port)
517
- if (!rw) return
518
-
519
- // Save user message to .rune history
520
- appendHistory(rw.filePath, { role: 'user', text: data.content, ts: Date.now() })
521
-
522
- await handleChannelMessage(data.content, rw.filePath, data.port)
523
- })
524
-
525
- // Cancel stream
526
- ipcMain.on('rune:cancelStream', (_event, data: { port?: number }) => {
527
- if (data?.port) {
528
- cancelChannelRequest(data.port)
529
- } else {
530
- // Cancel all active requests
531
- for (const port of activeRequests.keys()) {
532
- cancelChannelRequest(port)
533
- }
534
- }
535
- })
536
-
537
- // Connect channel
538
- ipcMain.on('rune:connectChannel', async (_event, data: { port: number }) => {
539
- await autoConnectChannel(data.port)
540
- })
541
-
542
- // Clear history
543
- ipcMain.on('rune:clearHistory', (_event, data: { port: number }) => {
544
- const rw = [...windowRegistry.values()].find(r => r.port === data.port)
545
- if (!rw) return
546
- const rune = readRuneFile(rw.filePath)
547
- rune.history = []
548
- writeRuneFile(rw.filePath, rune)
549
- })
550
-
551
- // Permission respond — send keystrokes to Claude Code's TUI prompt
552
- ipcMain.on('rune:permissionRespond', (_event, data: { ptyId: string; allow: boolean; response?: string }) => {
553
- const p = ptyProcesses.get(data.ptyId)
554
- if (!p) return
555
- const resp = data.response || (data.allow ? 'allow' : 'deny')
556
- if (resp === 'allow') {
557
- // Enter = select highlighted option 1 (Yes)
558
- p.write('\r')
559
- } else if (resp === 'always') {
560
- // Down arrow + Enter = select option 2 (Yes, and don't ask again)
561
- p.write('\x1b[B\r')
562
- } else {
563
- // Down + Down + Enter = select option 3 (No)
564
- p.write('\x1b[B\x1b[B\r')
565
- }
566
- })
567
-
568
- // Terminal spawn
569
- ipcMain.handle('terminal:spawn', (_event, data: { cwd?: string }) => {
570
- const id = `pty-${++ptyIdCounter}`
571
- const senderContents = _event.sender
572
- const shell = process.env.SHELL || '/bin/zsh'
573
- const env = { ...process.env }
574
- delete env.ELECTRON_RUN_AS_NODE
575
-
576
- // Inject RUNE_* env vars so the channel plugin inherits them
577
- const rw = [...windowRegistry.values()].find(r =>
578
- r.window.webContents === senderContents
579
- )
580
- if (rw) {
581
- env.RUNE_CHANNEL_PORT = String(rw.port)
582
- env.RUNE_FOLDER_PATH = rw.folderPath
583
- env.RUNE_FILE_PATH = rw.filePath
584
- const rune = readRuneFile(rw.filePath)
585
- if (rune.role) env.RUNE_AGENT_ROLE = rune.role
586
- }
587
- let p: any
588
- try {
589
- p = pty.spawn(shell, [], {
590
- name: 'xterm-256color',
591
- cols: 80,
592
- rows: 24,
593
- cwd: data.cwd || process.env.HOME,
594
- env: env as Record<string, string>,
595
- })
596
- } catch (err) {
597
- console.error('[rune] pty.spawn failed:', err)
598
- return { id, error: String(err) }
599
- }
600
-
601
- ptyProcesses.set(id, p)
602
- ptyOwnerWindows.set(id, senderContents)
603
-
604
- // Helper to send IPC only to the owning window
605
- const sendToOwner = (channel: string, payload: any) => {
606
- const owner = ptyOwnerWindows.get(id)
607
- if (owner && !owner.isDestroyed()) {
608
- owner.send(channel, payload)
609
- }
610
- }
611
-
612
- // Buffer recent terminal output to extract permission prompt context
613
- let recentOutput = ''
614
- let permissionDetected = false
615
- let permissionDebounce: ReturnType<typeof setTimeout> | null = null
616
-
617
- // Comprehensive ANSI/escape sequence stripper
618
- const stripAnsi = (s: string) => s
619
- .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '') // CSI sequences (incl. ?25l etc)
620
- .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC sequences
621
- .replace(/\x1b[()][A-Z0-9]/g, '') // Character set
622
- .replace(/\x1b[>=<]/g, '') // Mode set
623
- .replace(/\x1b\[\?[0-9;]*[a-z]/g, '') // Private mode sequences
624
- .replace(/\r/g, '')
625
-
626
- p.onData((data: string) => {
627
- // Accumulate recent output for context extraction
628
- recentOutput += data
629
- if (recentOutput.length > 8000) recentOutput = recentOutput.slice(-6000)
630
-
631
- // Detect ALL Claude Code permission prompts — check accumulated buffer
632
- const bufStripped = stripAnsi(recentOutput)
633
- // Match: "Do you want to proceed?", "Do you want to make this edit?",
634
- // "Do you want to run this command?", "Do you want to read..?", etc.
635
- const permissionMatch = /Do you want to [^\n]*\?/i.test(bufStripped)
636
- if (permissionMatch && !permissionDetected) {
637
- permissionDetected = true
638
- // Debounce to let full prompt text (including options) arrive
639
- if (permissionDebounce) clearTimeout(permissionDebounce)
640
- permissionDebounce = setTimeout(() => {
641
- const contextStripped = stripAnsi(recentOutput)
642
- const lines = contextStripped.split('\n').filter(l => l.trim()).slice(-25)
643
- const promptContext = lines.join('\n')
644
- console.log(`[rune] Permission prompt detected, sending to renderer (${lines.length} lines)`)
645
- sendToOwner('rune:permissionNeeded', { id, context: promptContext })
646
- // Clear buffer after detection so same prompt isn't re-detected
647
- recentOutput = ''
648
- setTimeout(() => { permissionDetected = false }, 1000)
649
- }, 500)
650
- }
651
- sendToOwner('terminal:output', { id, data })
652
- })
653
-
654
- p.onExit(({ exitCode }) => {
655
- sendToOwner('terminal:exit', { id, exitCode })
656
- ptyProcesses.delete(id)
657
- ptyOwnerWindows.delete(id)
658
- })
659
-
660
- return { id }
661
- })
662
-
663
- // Terminal input
664
- ipcMain.on('terminal:input', (_event, data: { id: string; data: string }) => {
665
- const p = ptyProcesses.get(data.id)
666
- if (p) p.write(data.data)
667
- })
668
-
669
- // Terminal resize
670
- ipcMain.on('terminal:resize', (_event, data: { id: string; cols: number; rows: number }) => {
671
- const p = ptyProcesses.get(data.id)
672
- if (p) p.resize(data.cols, data.rows)
673
- })
674
-
675
- // Terminal kill
676
- ipcMain.on('terminal:kill', (_event, data: { id: string }) => {
677
- const p = ptyProcesses.get(data.id)
678
- if (p) {
679
- p.kill()
680
- ptyProcesses.delete(data.id)
681
- ptyOwnerWindows.delete(data.id)
682
- }
683
- })
684
-
685
- // Create new .rune file
686
- ipcMain.handle('rune:createFile', async (_event, data: { folderPath: string; name: string; role?: string }) => {
687
- const fileName = `${data.name}.rune`
688
- const filePath = path.join(data.folderPath, fileName)
689
- const runeData: RuneFile = {
690
- name: data.name,
691
- role: data.role || 'General assistant',
692
- icon: 'bot',
693
- createdAt: new Date().toISOString(),
694
- history: [],
695
- }
696
- writeRuneFile(filePath, runeData)
697
- return filePath
698
- })
699
- }
700
-
701
- // ── App Lifecycle ────────────────────────────────────
702
- // Background agent app: no dock icon, no default window.
703
- // Only opens windows when .rune files are double-clicked.
704
-
705
- // Handle CLI args: `rune open /path/to/file.rune` or direct file path
706
- function getRuneFileFromArgs(argv: string[]): string | null {
707
- for (const arg of argv.slice(1)) {
708
- if (arg.endsWith('.rune')) {
709
- const resolved = path.resolve(arg)
710
- if (fs.existsSync(resolved)) return resolved
711
- }
712
- }
713
- return null
714
- }
715
-
716
- // macOS: hide dock icon until a window opens (background agent mode)
717
- if (process.platform === 'darwin') {
718
- app.dock?.hide()
719
- }
720
-
721
- app.whenReady().then(() => {
722
- setupIPC()
723
-
724
- console.log('[rune] App ready. argv:', process.argv)
725
- console.log('[rune] pendingFilePath:', pendingFilePath)
726
-
727
- // Check if launched with a .rune file argument
728
- const argFile = getRuneFileFromArgs(process.argv)
729
- // Also check env var (set by wrapper .app launcher when macOS sends Apple Event)
730
- const envFile = process.env.RUNE_OPEN_FILE || null
731
- console.log('[rune] argFile:', argFile, 'envFile:', envFile)
732
-
733
- const fileToOpen = pendingFilePath || argFile || envFile
734
- if (fileToOpen) {
735
- createRuneWindow(fileToOpen)
736
- pendingFilePath = null
737
- }
738
- // Otherwise: no window. App stays running in background, waiting for open-file events.
739
- })
740
-
741
- // Show dock icon when windows open, hide when all close
742
- function updateDockVisibility() {
743
- if (process.platform !== 'darwin') return
744
- if (BrowserWindow.getAllWindows().length > 0) {
745
- app.dock?.show()
746
- } else {
747
- app.dock?.hide()
748
- }
749
- }
750
-
751
- // ── Cleanup on quit ─────────────────────────────────
752
- function cleanupAll() {
753
- // Kill all pty processes (sends SIGTERM for proper cleanup)
754
- for (const [id, p] of ptyProcesses) {
755
- try { process.kill(p.pid, 'SIGTERM') } catch {}
756
- try { p.kill() } catch {}
757
- ptyProcesses.delete(id)
758
- ptyOwnerWindows.delete(id)
759
- }
760
- // Disconnect all SSE connections and cancel timers
761
- for (const port of sseConnections.keys()) disconnectSSE(port)
762
- for (const [port, timer] of retryTimers) {
763
- clearInterval(timer)
764
- retryTimers.delete(port)
765
- }
766
- // Cancel all active HTTP requests
767
- for (const port of activeRequests.keys()) cancelChannelRequest(port)
768
- }
769
-
770
- app.on('before-quit', () => {
771
- cleanupAll()
772
- })
773
-
774
- app.on('window-all-closed', () => {
775
- // Quit when all windows close. AppleScript launcher starts fresh on next double-click.
776
- app.quit()
777
- })
778
-
779
- // Handle second instance (Windows: file passed via argv)
780
- const gotTheLock = app.requestSingleInstanceLock()
781
- if (!gotTheLock) {
782
- app.quit()
783
- } else {
784
- app.on('second-instance', (_event, argv) => {
785
- console.log('[rune] second-instance argv:', argv)
786
- const filePath = getRuneFileFromArgs(argv)
787
- console.log('[rune] second-instance filePath:', filePath)
788
- if (filePath) {
789
- if (app.isReady()) {
790
- createRuneWindow(filePath)
791
- } else {
792
- pendingFilePath = filePath
793
- }
794
- }
795
- })
796
- }