openrune 0.1.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 (36) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/.claude-plugin/plugin.json +24 -0
  3. package/LICENSE +21 -0
  4. package/README.md +257 -0
  5. package/bin/rune.js +718 -0
  6. package/bootstrap.js +4 -0
  7. package/channel/rune-channel.ts +467 -0
  8. package/electron-builder.yml +61 -0
  9. package/finder-extension/FinderSync.swift +47 -0
  10. package/finder-extension/RuneFinderSync.appex/Contents/Info.plist +27 -0
  11. package/finder-extension/RuneFinderSync.appex/Contents/MacOS/RuneFinderSync +0 -0
  12. package/finder-extension/main.swift +5 -0
  13. package/package.json +53 -0
  14. package/renderer/index.html +12 -0
  15. package/renderer/src/App.tsx +43 -0
  16. package/renderer/src/features/chat/activity-block.tsx +152 -0
  17. package/renderer/src/features/chat/chat-header.tsx +58 -0
  18. package/renderer/src/features/chat/chat-input.tsx +190 -0
  19. package/renderer/src/features/chat/chat-panel.tsx +150 -0
  20. package/renderer/src/features/chat/markdown-renderer.tsx +26 -0
  21. package/renderer/src/features/chat/message-bubble.tsx +79 -0
  22. package/renderer/src/features/chat/message-list.tsx +178 -0
  23. package/renderer/src/features/chat/types.ts +32 -0
  24. package/renderer/src/features/chat/use-chat.ts +251 -0
  25. package/renderer/src/features/terminal/terminal-panel.tsx +132 -0
  26. package/renderer/src/global.d.ts +29 -0
  27. package/renderer/src/globals.css +92 -0
  28. package/renderer/src/hooks/use-ipc.ts +24 -0
  29. package/renderer/src/lib/markdown.ts +83 -0
  30. package/renderer/src/lib/utils.ts +6 -0
  31. package/renderer/src/main.tsx +10 -0
  32. package/renderer/tsconfig.json +16 -0
  33. package/renderer/vite.config.ts +23 -0
  34. package/src/main.ts +782 -0
  35. package/src/preload.ts +58 -0
  36. package/tsconfig.json +14 -0
package/src/main.ts ADDED
@@ -0,0 +1,782 @@
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
+ try { return execSync('which node', { encoding: 'utf-8' }).trim() } catch {}
259
+ for (const p of ['/usr/local/bin/node', '/opt/homebrew/bin/node']) {
260
+ if (fs.existsSync(p)) return p
261
+ }
262
+ return 'node'
263
+ }
264
+
265
+ function writeMcpJson(folderPath: string, port: number, role?: string, runeFilePath?: string) {
266
+ const channelJs = path.join(__dirname, 'rune-channel.js')
267
+ const nodePath = findNodePath()
268
+ const env: Record<string, string> = {
269
+ RUNE_FOLDER_PATH: folderPath,
270
+ RUNE_CHANNEL_PORT: String(port),
271
+ }
272
+ if (role) env.RUNE_AGENT_ROLE = role
273
+ if (runeFilePath) env.RUNE_FILE_PATH = runeFilePath
274
+ const mcpConfig = {
275
+ mcpServers: {
276
+ 'rune-channel': {
277
+ command: nodePath,
278
+ args: [channelJs],
279
+ env,
280
+ },
281
+ },
282
+ }
283
+ try {
284
+ fs.writeFileSync(path.join(folderPath, '.mcp.json'), JSON.stringify(mcpConfig, null, 2), 'utf-8')
285
+ } catch {}
286
+
287
+ // Auto-allow rune-channel tools so Claude doesn't prompt for permission
288
+ const claudeDir = path.join(folderPath, '.claude')
289
+ const settingsFile = path.join(claudeDir, 'settings.local.json')
290
+ try {
291
+ if (!fs.existsSync(claudeDir)) fs.mkdirSync(claudeDir, { recursive: true })
292
+ let settings: any = {}
293
+ if (fs.existsSync(settingsFile)) {
294
+ try { settings = JSON.parse(fs.readFileSync(settingsFile, 'utf-8')) } catch {}
295
+ }
296
+ if (!settings.permissions) settings.permissions = {}
297
+ if (!settings.permissions.allow) settings.permissions.allow = []
298
+ const requiredTools = [
299
+ 'mcp__rune-channel__rune_reply',
300
+ 'mcp__rune-channel__rune_activity',
301
+ 'mcp__rune-channel__rune_memory',
302
+ ]
303
+ for (const tool of requiredTools) {
304
+ if (!settings.permissions.allow.includes(tool)) {
305
+ settings.permissions.allow.push(tool)
306
+ }
307
+ }
308
+ fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2), 'utf-8')
309
+ } catch {}
310
+ }
311
+
312
+ // ── Channel Message Handler ─────────────────────────
313
+ async function handleChannelMessage(content: string, runeFilePath: string, port: number) {
314
+ const win = getWindowForPort(port)
315
+ if (!win) return
316
+
317
+ win.webContents.send('rune:streamStart', {})
318
+ win.webContents.send('rune:streamStatus', { status: `Channel :${port} sending...` })
319
+
320
+ try {
321
+ const reply = await sendToChannel(content, port)
322
+ win.webContents.send('rune:streamChunk', { text: reply })
323
+ win.webContents.send('rune:streamEnd', {})
324
+
325
+ // Save to .rune history
326
+ appendHistory(runeFilePath, { role: 'assistant', text: reply, ts: Date.now() })
327
+ } catch (e: any) {
328
+ const rune = readRuneFile(runeFilePath)
329
+ const folderPath = path.dirname(runeFilePath)
330
+ win.webContents.send('rune:streamError', {
331
+ 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`,
332
+ })
333
+ }
334
+ }
335
+
336
+ // ── Window Creation ──────────────────────────────────
337
+ async function createRuneWindow(filePath: string) {
338
+ // If window already exists for this file, focus it
339
+ const existing = windowRegistry.get(filePath)
340
+ if (existing && !existing.window.isDestroyed()) {
341
+ existing.window.focus()
342
+ return
343
+ }
344
+
345
+ const rune = readRuneFile(filePath)
346
+ const folderPath = path.dirname(filePath)
347
+ let port = rune.port
348
+ if (port && await isPortInUse(port)) port = 0
349
+ if (!port) port = await allocatePort()
350
+
351
+ // Sync name with filename & save port
352
+ const fileBaseName = path.basename(filePath, '.rune')
353
+ const nameChanged = rune.name !== fileBaseName
354
+ if (nameChanged) rune.name = fileBaseName
355
+ if (rune.port !== port || nameChanged) {
356
+ rune.port = port
357
+ writeRuneFile(filePath, rune)
358
+ }
359
+
360
+ // Write .mcp.json
361
+ writeMcpJson(folderPath, port, rune.role, filePath)
362
+
363
+ const bounds = rune.windowBounds
364
+ const win = new BrowserWindow({
365
+ width: bounds?.width || 500,
366
+ height: bounds?.height || 700,
367
+ ...(bounds ? { x: bounds.x, y: bounds.y } : {}),
368
+ minWidth: 300,
369
+ minHeight: 400,
370
+ ...(process.platform === 'darwin' ? {
371
+ titleBarStyle: 'hiddenInset' as const,
372
+ trafficLightPosition: { x: 12, y: 12 },
373
+ } : {}),
374
+ backgroundColor: '#1a1a1a',
375
+ title: `${rune.name} — ${path.basename(folderPath)}`,
376
+ webPreferences: {
377
+ preload: path.join(__dirname, 'preload.js'),
378
+ contextIsolation: true,
379
+ nodeIntegration: false,
380
+ },
381
+ })
382
+
383
+ let currentFilePath = filePath
384
+
385
+ // Save window bounds on move/resize
386
+ const saveBounds = () => {
387
+ if (win.isDestroyed()) return
388
+ const currentBounds = win.getBounds()
389
+ const currentRune = readRuneFile(currentFilePath)
390
+ currentRune.windowBounds = currentBounds
391
+ writeRuneFile(currentFilePath, currentRune)
392
+ }
393
+ let boundsTimeout: ReturnType<typeof setTimeout> | null = null
394
+ const debouncedSaveBounds = () => {
395
+ if (boundsTimeout) clearTimeout(boundsTimeout)
396
+ boundsTimeout = setTimeout(saveBounds, 500)
397
+ }
398
+ win.on('resize', debouncedSaveBounds)
399
+ win.on('move', debouncedSaveBounds)
400
+ const rw: RuneWindow = { window: win, filePath, folderPath, port }
401
+ windowRegistry.set(filePath, rw)
402
+ updateDockVisibility()
403
+
404
+ win.loadFile(path.join(__dirname, 'renderer', 'index.html'))
405
+
406
+ win.webContents.on('did-finish-load', () => {
407
+ const initData = {
408
+ filePath: currentFilePath,
409
+ folderPath,
410
+ port,
411
+ name: rune.name,
412
+ role: rune.role,
413
+ icon: rune.icon,
414
+ history: rune.history || [],
415
+ }
416
+ console.log('[rune] Sending init:', JSON.stringify({ filePath: currentFilePath, folderPath, port, name: rune.name }))
417
+ win.webContents.send('rune:init', initData)
418
+ })
419
+
420
+ // Watch folder for .rune file renames
421
+ const createdAt = rune.createdAt
422
+ let dirWatcher: fs.FSWatcher | null = null
423
+ try {
424
+ dirWatcher = fs.watch(folderPath, (_eventType, filename) => {
425
+ if (!filename?.endsWith('.rune')) return
426
+ // Check if our file still exists
427
+ if (fs.existsSync(currentFilePath)) return
428
+ // Our file was renamed/deleted — scan for the new name
429
+ try {
430
+ const files = fs.readdirSync(folderPath).filter(f => f.endsWith('.rune'))
431
+ for (const f of files) {
432
+ const candidate = path.join(folderPath, f)
433
+ try {
434
+ const data = JSON.parse(fs.readFileSync(candidate, 'utf-8'))
435
+ if (data.createdAt === createdAt && data.port === port) {
436
+ console.log(`[rune] Detected rename: ${path.basename(currentFilePath)} → ${f}`)
437
+ // Update registry
438
+ windowRegistry.delete(currentFilePath)
439
+ currentFilePath = candidate
440
+ rw.filePath = candidate
441
+ windowRegistry.set(candidate, rw)
442
+ // Update name inside .rune file to match new filename
443
+ const newName = path.basename(candidate, '.rune')
444
+ data.name = newName
445
+ writeRuneFile(candidate, data)
446
+ // Update .mcp.json
447
+ writeMcpJson(folderPath, port, data.role, candidate)
448
+ // Update window title
449
+ win.setTitle(`${newName} — ${path.basename(folderPath)}`)
450
+ // Notify renderer
451
+ win.webContents.send('rune:fileRenamed', { oldPath: currentFilePath, newPath: candidate, name: newName })
452
+ break
453
+ }
454
+ } catch {}
455
+ }
456
+ } catch {}
457
+ })
458
+ } catch (e) {
459
+ console.error('[rune] Failed to watch folder:', e)
460
+ }
461
+
462
+ // Start channel health check
463
+ startRetryPolling(port)
464
+ autoConnectChannel(port)
465
+
466
+ win.on('closed', () => {
467
+ windowRegistry.delete(currentFilePath)
468
+ if (dirWatcher) dirWatcher.close()
469
+ disconnectSSE(port)
470
+ const timer = retryTimers.get(port)
471
+ if (timer) { clearInterval(timer); retryTimers.delete(port) }
472
+ // Kill all pty processes for this window (kills Claude Code + channel)
473
+ for (const [id, p] of ptyProcesses) {
474
+ try { process.kill(p.pid, 'SIGTERM') } catch {}
475
+ try { p.kill() } catch {}
476
+ ptyProcesses.delete(id)
477
+ ptyOwnerWindows.delete(id)
478
+ }
479
+ updateDockVisibility()
480
+ })
481
+ }
482
+
483
+ // ── Pending file path (before app ready) ─────────────
484
+ let pendingFilePath: string | null = null
485
+
486
+ app.on('will-finish-launching', () => {
487
+ app.on('open-file', (event, filePath) => {
488
+ event.preventDefault()
489
+ console.log('[rune] open-file event:', filePath, 'ready:', app.isReady())
490
+ if (app.isReady()) {
491
+ createRuneWindow(filePath)
492
+ } else {
493
+ pendingFilePath = filePath
494
+ }
495
+ })
496
+ })
497
+
498
+ // ── IPC Setup ────────────────────────────────────────
499
+ function setupIPC() {
500
+ // Send message
501
+ ipcMain.on('rune:sendMessage', async (event, data: { content: string; port: number }) => {
502
+ const rw = [...windowRegistry.values()].find(r => r.port === data.port)
503
+ if (!rw) return
504
+
505
+ // Save user message to .rune history
506
+ appendHistory(rw.filePath, { role: 'user', text: data.content, ts: Date.now() })
507
+
508
+ await handleChannelMessage(data.content, rw.filePath, data.port)
509
+ })
510
+
511
+ // Cancel stream
512
+ ipcMain.on('rune:cancelStream', (_event, data: { port?: number }) => {
513
+ if (data?.port) {
514
+ cancelChannelRequest(data.port)
515
+ } else {
516
+ // Cancel all active requests
517
+ for (const port of activeRequests.keys()) {
518
+ cancelChannelRequest(port)
519
+ }
520
+ }
521
+ })
522
+
523
+ // Connect channel
524
+ ipcMain.on('rune:connectChannel', async (_event, data: { port: number }) => {
525
+ await autoConnectChannel(data.port)
526
+ })
527
+
528
+ // Clear history
529
+ ipcMain.on('rune:clearHistory', (_event, data: { port: number }) => {
530
+ const rw = [...windowRegistry.values()].find(r => r.port === data.port)
531
+ if (!rw) return
532
+ const rune = readRuneFile(rw.filePath)
533
+ rune.history = []
534
+ writeRuneFile(rw.filePath, rune)
535
+ })
536
+
537
+ // Permission respond — send keystrokes to Claude Code's TUI prompt
538
+ ipcMain.on('rune:permissionRespond', (_event, data: { ptyId: string; allow: boolean; response?: string }) => {
539
+ const p = ptyProcesses.get(data.ptyId)
540
+ if (!p) return
541
+ const resp = data.response || (data.allow ? 'allow' : 'deny')
542
+ if (resp === 'allow') {
543
+ // Enter = select highlighted option 1 (Yes)
544
+ p.write('\r')
545
+ } else if (resp === 'always') {
546
+ // Down arrow + Enter = select option 2 (Yes, and don't ask again)
547
+ p.write('\x1b[B\r')
548
+ } else {
549
+ // Down + Down + Enter = select option 3 (No)
550
+ p.write('\x1b[B\x1b[B\r')
551
+ }
552
+ })
553
+
554
+ // Terminal spawn
555
+ ipcMain.handle('terminal:spawn', (_event, data: { cwd?: string }) => {
556
+ const id = `pty-${++ptyIdCounter}`
557
+ const senderContents = _event.sender
558
+ const shell = process.env.SHELL || '/bin/zsh'
559
+ const env = { ...process.env }
560
+ delete env.ELECTRON_RUN_AS_NODE
561
+
562
+ // Inject RUNE_* env vars so the channel plugin inherits them
563
+ const rw = [...windowRegistry.values()].find(r =>
564
+ r.window.webContents === senderContents
565
+ )
566
+ if (rw) {
567
+ env.RUNE_CHANNEL_PORT = String(rw.port)
568
+ env.RUNE_FOLDER_PATH = rw.folderPath
569
+ env.RUNE_FILE_PATH = rw.filePath
570
+ const rune = readRuneFile(rw.filePath)
571
+ if (rune.role) env.RUNE_AGENT_ROLE = rune.role
572
+ }
573
+ let p: any
574
+ try {
575
+ p = pty.spawn(shell, [], {
576
+ name: 'xterm-256color',
577
+ cols: 80,
578
+ rows: 24,
579
+ cwd: data.cwd || process.env.HOME,
580
+ env: env as Record<string, string>,
581
+ })
582
+ } catch (err) {
583
+ console.error('[rune] pty.spawn failed:', err)
584
+ return { id, error: String(err) }
585
+ }
586
+
587
+ ptyProcesses.set(id, p)
588
+ ptyOwnerWindows.set(id, senderContents)
589
+
590
+ // Helper to send IPC only to the owning window
591
+ const sendToOwner = (channel: string, payload: any) => {
592
+ const owner = ptyOwnerWindows.get(id)
593
+ if (owner && !owner.isDestroyed()) {
594
+ owner.send(channel, payload)
595
+ }
596
+ }
597
+
598
+ // Buffer recent terminal output to extract permission prompt context
599
+ let recentOutput = ''
600
+ let permissionDetected = false
601
+ let permissionDebounce: ReturnType<typeof setTimeout> | null = null
602
+
603
+ // Comprehensive ANSI/escape sequence stripper
604
+ const stripAnsi = (s: string) => s
605
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '') // CSI sequences (incl. ?25l etc)
606
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC sequences
607
+ .replace(/\x1b[()][A-Z0-9]/g, '') // Character set
608
+ .replace(/\x1b[>=<]/g, '') // Mode set
609
+ .replace(/\x1b\[\?[0-9;]*[a-z]/g, '') // Private mode sequences
610
+ .replace(/\r/g, '')
611
+
612
+ p.onData((data: string) => {
613
+ // Accumulate recent output for context extraction
614
+ recentOutput += data
615
+ if (recentOutput.length > 8000) recentOutput = recentOutput.slice(-6000)
616
+
617
+ // Detect ALL Claude Code permission prompts — check accumulated buffer
618
+ const bufStripped = stripAnsi(recentOutput)
619
+ // Match: "Do you want to proceed?", "Do you want to make this edit?",
620
+ // "Do you want to run this command?", "Do you want to read..?", etc.
621
+ const permissionMatch = /Do you want to [^\n]*\?/i.test(bufStripped)
622
+ if (permissionMatch && !permissionDetected) {
623
+ permissionDetected = true
624
+ // Debounce to let full prompt text (including options) arrive
625
+ if (permissionDebounce) clearTimeout(permissionDebounce)
626
+ permissionDebounce = setTimeout(() => {
627
+ const contextStripped = stripAnsi(recentOutput)
628
+ const lines = contextStripped.split('\n').filter(l => l.trim()).slice(-25)
629
+ const promptContext = lines.join('\n')
630
+ console.log(`[rune] Permission prompt detected, sending to renderer (${lines.length} lines)`)
631
+ sendToOwner('rune:permissionNeeded', { id, context: promptContext })
632
+ // Clear buffer after detection so same prompt isn't re-detected
633
+ recentOutput = ''
634
+ setTimeout(() => { permissionDetected = false }, 1000)
635
+ }, 500)
636
+ }
637
+ sendToOwner('terminal:output', { id, data })
638
+ })
639
+
640
+ p.onExit(({ exitCode }) => {
641
+ sendToOwner('terminal:exit', { id, exitCode })
642
+ ptyProcesses.delete(id)
643
+ ptyOwnerWindows.delete(id)
644
+ })
645
+
646
+ return { id }
647
+ })
648
+
649
+ // Terminal input
650
+ ipcMain.on('terminal:input', (_event, data: { id: string; data: string }) => {
651
+ const p = ptyProcesses.get(data.id)
652
+ if (p) p.write(data.data)
653
+ })
654
+
655
+ // Terminal resize
656
+ ipcMain.on('terminal:resize', (_event, data: { id: string; cols: number; rows: number }) => {
657
+ const p = ptyProcesses.get(data.id)
658
+ if (p) p.resize(data.cols, data.rows)
659
+ })
660
+
661
+ // Terminal kill
662
+ ipcMain.on('terminal:kill', (_event, data: { id: string }) => {
663
+ const p = ptyProcesses.get(data.id)
664
+ if (p) {
665
+ p.kill()
666
+ ptyProcesses.delete(data.id)
667
+ ptyOwnerWindows.delete(data.id)
668
+ }
669
+ })
670
+
671
+ // Create new .rune file
672
+ ipcMain.handle('rune:createFile', async (_event, data: { folderPath: string; name: string; role?: string }) => {
673
+ const fileName = `${data.name}.rune`
674
+ const filePath = path.join(data.folderPath, fileName)
675
+ const runeData: RuneFile = {
676
+ name: data.name,
677
+ role: data.role || 'General assistant',
678
+ icon: 'bot',
679
+ createdAt: new Date().toISOString(),
680
+ history: [],
681
+ }
682
+ writeRuneFile(filePath, runeData)
683
+ return filePath
684
+ })
685
+ }
686
+
687
+ // ── App Lifecycle ────────────────────────────────────
688
+ // Background agent app: no dock icon, no default window.
689
+ // Only opens windows when .rune files are double-clicked.
690
+
691
+ // Handle CLI args: `rune open /path/to/file.rune` or direct file path
692
+ function getRuneFileFromArgs(argv: string[]): string | null {
693
+ for (const arg of argv.slice(1)) {
694
+ if (arg.endsWith('.rune')) {
695
+ const resolved = path.resolve(arg)
696
+ if (fs.existsSync(resolved)) return resolved
697
+ }
698
+ }
699
+ return null
700
+ }
701
+
702
+ // macOS: hide dock icon until a window opens (background agent mode)
703
+ if (process.platform === 'darwin') {
704
+ app.dock?.hide()
705
+ }
706
+
707
+ app.whenReady().then(() => {
708
+ setupIPC()
709
+
710
+ console.log('[rune] App ready. argv:', process.argv)
711
+ console.log('[rune] pendingFilePath:', pendingFilePath)
712
+
713
+ // Check if launched with a .rune file argument
714
+ const argFile = getRuneFileFromArgs(process.argv)
715
+ // Also check env var (set by wrapper .app launcher when macOS sends Apple Event)
716
+ const envFile = process.env.RUNE_OPEN_FILE || null
717
+ console.log('[rune] argFile:', argFile, 'envFile:', envFile)
718
+
719
+ const fileToOpen = pendingFilePath || argFile || envFile
720
+ if (fileToOpen) {
721
+ createRuneWindow(fileToOpen)
722
+ pendingFilePath = null
723
+ }
724
+ // Otherwise: no window. App stays running in background, waiting for open-file events.
725
+ })
726
+
727
+ // Show dock icon when windows open, hide when all close
728
+ function updateDockVisibility() {
729
+ if (process.platform !== 'darwin') return
730
+ if (BrowserWindow.getAllWindows().length > 0) {
731
+ app.dock?.show()
732
+ } else {
733
+ app.dock?.hide()
734
+ }
735
+ }
736
+
737
+ // ── Cleanup on quit ─────────────────────────────────
738
+ function cleanupAll() {
739
+ // Kill all pty processes (sends SIGTERM for proper cleanup)
740
+ for (const [id, p] of ptyProcesses) {
741
+ try { process.kill(p.pid, 'SIGTERM') } catch {}
742
+ try { p.kill() } catch {}
743
+ ptyProcesses.delete(id)
744
+ ptyOwnerWindows.delete(id)
745
+ }
746
+ // Disconnect all SSE connections and cancel timers
747
+ for (const port of sseConnections.keys()) disconnectSSE(port)
748
+ for (const [port, timer] of retryTimers) {
749
+ clearInterval(timer)
750
+ retryTimers.delete(port)
751
+ }
752
+ // Cancel all active HTTP requests
753
+ for (const port of activeRequests.keys()) cancelChannelRequest(port)
754
+ }
755
+
756
+ app.on('before-quit', () => {
757
+ cleanupAll()
758
+ })
759
+
760
+ app.on('window-all-closed', () => {
761
+ // Quit when all windows close. AppleScript launcher starts fresh on next double-click.
762
+ app.quit()
763
+ })
764
+
765
+ // Handle second instance (Windows: file passed via argv)
766
+ const gotTheLock = app.requestSingleInstanceLock()
767
+ if (!gotTheLock) {
768
+ app.quit()
769
+ } else {
770
+ app.on('second-instance', (_event, argv) => {
771
+ console.log('[rune] second-instance argv:', argv)
772
+ const filePath = getRuneFileFromArgs(argv)
773
+ console.log('[rune] second-instance filePath:', filePath)
774
+ if (filePath) {
775
+ if (app.isReady()) {
776
+ createRuneWindow(filePath)
777
+ } else {
778
+ pendingFilePath = filePath
779
+ }
780
+ }
781
+ })
782
+ }