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.
- package/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +24 -0
- package/LICENSE +21 -0
- package/README.md +257 -0
- package/bin/rune.js +718 -0
- package/bootstrap.js +4 -0
- package/channel/rune-channel.ts +467 -0
- package/electron-builder.yml +61 -0
- package/finder-extension/FinderSync.swift +47 -0
- package/finder-extension/RuneFinderSync.appex/Contents/Info.plist +27 -0
- package/finder-extension/RuneFinderSync.appex/Contents/MacOS/RuneFinderSync +0 -0
- package/finder-extension/main.swift +5 -0
- package/package.json +53 -0
- package/renderer/index.html +12 -0
- package/renderer/src/App.tsx +43 -0
- package/renderer/src/features/chat/activity-block.tsx +152 -0
- package/renderer/src/features/chat/chat-header.tsx +58 -0
- package/renderer/src/features/chat/chat-input.tsx +190 -0
- package/renderer/src/features/chat/chat-panel.tsx +150 -0
- package/renderer/src/features/chat/markdown-renderer.tsx +26 -0
- package/renderer/src/features/chat/message-bubble.tsx +79 -0
- package/renderer/src/features/chat/message-list.tsx +178 -0
- package/renderer/src/features/chat/types.ts +32 -0
- package/renderer/src/features/chat/use-chat.ts +251 -0
- package/renderer/src/features/terminal/terminal-panel.tsx +132 -0
- package/renderer/src/global.d.ts +29 -0
- package/renderer/src/globals.css +92 -0
- package/renderer/src/hooks/use-ipc.ts +24 -0
- package/renderer/src/lib/markdown.ts +83 -0
- package/renderer/src/lib/utils.ts +6 -0
- package/renderer/src/main.tsx +10 -0
- package/renderer/tsconfig.json +16 -0
- package/renderer/vite.config.ts +23 -0
- package/src/main.ts +782 -0
- package/src/preload.ts +58 -0
- 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
|
+
}
|