@phenx-inc/ctlsurf 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 (133) hide show
  1. package/bin/ctlsurf-worker.js +173 -0
  2. package/electron-vite.config.ts +34 -0
  3. package/out/headless/index.mjs +1364 -0
  4. package/out/headless/index.mjs.map +7 -0
  5. package/out/main/index.js +1131 -0
  6. package/out/preload/index.js +67 -0
  7. package/out/renderer/assets/abap-D5KwWAsZ.js +1399 -0
  8. package/out/renderer/assets/apex-DVGUZ64i.js +331 -0
  9. package/out/renderer/assets/azcli-BEAhqcuE.js +69 -0
  10. package/out/renderer/assets/bat-Bqkp9Cfu.js +101 -0
  11. package/out/renderer/assets/bicep-DIlfshcM.js +110 -0
  12. package/out/renderer/assets/cameligo-CLaaYNMV.js +175 -0
  13. package/out/renderer/assets/clojure-fcgFaMHx.js +762 -0
  14. package/out/renderer/assets/codicon-ngg6Pgfi.ttf +0 -0
  15. package/out/renderer/assets/coffee-CzJ5oEdj.js +233 -0
  16. package/out/renderer/assets/cpp-CcN6f0ik.js +390 -0
  17. package/out/renderer/assets/csharp-BJeIuvde.js +327 -0
  18. package/out/renderer/assets/csp-D_3BK2Wp.js +54 -0
  19. package/out/renderer/assets/css-i3rI3_64.js +186 -0
  20. package/out/renderer/assets/css.worker-umuuUiIb.js +53567 -0
  21. package/out/renderer/assets/cssMode-DL0XItGB.js +208 -0
  22. package/out/renderer/assets/cypher-D0--_GAN.js +264 -0
  23. package/out/renderer/assets/dart-vLMHv35g.js +282 -0
  24. package/out/renderer/assets/dockerfile--oxj0cAH.js +131 -0
  25. package/out/renderer/assets/ecl-CeuUgzaZ.js +457 -0
  26. package/out/renderer/assets/editor.worker-CNgWLVu7.js +13695 -0
  27. package/out/renderer/assets/elixir-eLfY1jWH.js +570 -0
  28. package/out/renderer/assets/flow9-ZSTChSMd.js +143 -0
  29. package/out/renderer/assets/freemarker2-CrOEuDcF.js +995 -0
  30. package/out/renderer/assets/fsharp-D2uoxuLH.js +218 -0
  31. package/out/renderer/assets/go-brnMpFrj.js +219 -0
  32. package/out/renderer/assets/graphql-BeiGgjIU.js +152 -0
  33. package/out/renderer/assets/handlebars-D4QYaBof.js +414 -0
  34. package/out/renderer/assets/hcl-CrX1Es2W.js +184 -0
  35. package/out/renderer/assets/html-B2Dqk2ai.js +303 -0
  36. package/out/renderer/assets/html.worker-BT47iy49.js +29777 -0
  37. package/out/renderer/assets/htmlMode-CdZ0Prhd.js +224 -0
  38. package/out/renderer/assets/index-CJ6RsQWP.css +8108 -0
  39. package/out/renderer/assets/index-pZmE1QXB.js +211777 -0
  40. package/out/renderer/assets/ini-BcQysCTb.js +72 -0
  41. package/out/renderer/assets/java-Dt3iMn2o.js +233 -0
  42. package/out/renderer/assets/javascript-CK8zNQXj.js +72 -0
  43. package/out/renderer/assets/json.worker-D4JVmXIe.js +21424 -0
  44. package/out/renderer/assets/jsonMode-Cewaellc.js +931 -0
  45. package/out/renderer/assets/julia-Cm3ItYL_.js +512 -0
  46. package/out/renderer/assets/kotlin-Ddo1SjA5.js +253 -0
  47. package/out/renderer/assets/less-B7Qaxw-O.js +162 -0
  48. package/out/renderer/assets/lexon-C1U0m2n9.js +158 -0
  49. package/out/renderer/assets/liquid-Bd3GPNs2.js +235 -0
  50. package/out/renderer/assets/lspLanguageFeatures-DSDH7BnA.js +1841 -0
  51. package/out/renderer/assets/lua-hNsuGJkO.js +163 -0
  52. package/out/renderer/assets/m3-6ko6q9-_.js +211 -0
  53. package/out/renderer/assets/markdown-B0YTnTxW.js +230 -0
  54. package/out/renderer/assets/mdx-CCPVCrXC.js +159 -0
  55. package/out/renderer/assets/mips-CJm71dS3.js +199 -0
  56. package/out/renderer/assets/msdax-BBeIktCY.js +376 -0
  57. package/out/renderer/assets/mysql-BWiizXSn.js +879 -0
  58. package/out/renderer/assets/objective-c-B1L1C5EC.js +184 -0
  59. package/out/renderer/assets/pascal-DMQyD4Xk.js +252 -0
  60. package/out/renderer/assets/pascaligo-VA_LQ1oU.js +165 -0
  61. package/out/renderer/assets/perl-DC0Z0tlO.js +627 -0
  62. package/out/renderer/assets/pgsql-DaSGFTLp.js +852 -0
  63. package/out/renderer/assets/php-Bkx1qpkQ.js +501 -0
  64. package/out/renderer/assets/pla-DEV89yYj.js +138 -0
  65. package/out/renderer/assets/postiats-CVVurEnu.js +908 -0
  66. package/out/renderer/assets/powerquery-BQ_t1ZiQ.js +891 -0
  67. package/out/renderer/assets/powershell-BXiKvz7Z.js +240 -0
  68. package/out/renderer/assets/protobuf-CndvAUGu.js +421 -0
  69. package/out/renderer/assets/pug-BxCXwerb.js +403 -0
  70. package/out/renderer/assets/python-34jOtlcC.js +295 -0
  71. package/out/renderer/assets/qsharp-BWK6YLKm.js +302 -0
  72. package/out/renderer/assets/r-CtqYUQ6l.js +244 -0
  73. package/out/renderer/assets/razor-DXRw694z.js +545 -0
  74. package/out/renderer/assets/redis-O7gSt3oh.js +303 -0
  75. package/out/renderer/assets/redshift-CvYMMYZY.js +810 -0
  76. package/out/renderer/assets/restructuredtext-B-KQCVu_.js +175 -0
  77. package/out/renderer/assets/ruby-DCd4DmAr.js +512 -0
  78. package/out/renderer/assets/rust-B1c0VCeq.js +344 -0
  79. package/out/renderer/assets/sb-Chfc_wZF.js +116 -0
  80. package/out/renderer/assets/scala-DbVzH-3O.js +371 -0
  81. package/out/renderer/assets/scheme-D7PxodDG.js +109 -0
  82. package/out/renderer/assets/scss-B42qMyAu.js +261 -0
  83. package/out/renderer/assets/shell-vZEubQ82.js +222 -0
  84. package/out/renderer/assets/solidity-yHOxYChb.js +1368 -0
  85. package/out/renderer/assets/sophia-D7pU0Y1d.js +200 -0
  86. package/out/renderer/assets/sparql-DxuVdnRl.js +202 -0
  87. package/out/renderer/assets/sql-BAGepFCR.js +854 -0
  88. package/out/renderer/assets/st-C-b0Dh53.js +417 -0
  89. package/out/renderer/assets/swift-BmOZGynf.js +313 -0
  90. package/out/renderer/assets/systemverilog-BOC0OOdC.js +577 -0
  91. package/out/renderer/assets/tcl-Bb4GCwBr.js +233 -0
  92. package/out/renderer/assets/ts.worker-C7hW3aY-.js +225330 -0
  93. package/out/renderer/assets/tsMode-CmND5_wB.js +1265 -0
  94. package/out/renderer/assets/twig-DvgEGWAV.js +393 -0
  95. package/out/renderer/assets/typescript-BNNI0Euv.js +337 -0
  96. package/out/renderer/assets/typespec-R77Ln7Jb.js +128 -0
  97. package/out/renderer/assets/vb-Bm6ESA0Q.js +373 -0
  98. package/out/renderer/assets/wgsl-_KPae5vw.js +454 -0
  99. package/out/renderer/assets/xml-CgdndrNB.js +89 -0
  100. package/out/renderer/assets/yaml-DNWPIf1s.js +200 -0
  101. package/out/renderer/index.html +13 -0
  102. package/package.json +67 -0
  103. package/resources/icon.icns +0 -0
  104. package/resources/icon.ico +0 -0
  105. package/resources/icon.png +0 -0
  106. package/src/main/agents.ts +46 -0
  107. package/src/main/bridge.ts +180 -0
  108. package/src/main/ctlsurfApi.ts +142 -0
  109. package/src/main/detectMode.ts +17 -0
  110. package/src/main/headless.ts +182 -0
  111. package/src/main/index.ts +300 -0
  112. package/src/main/orchestrator.ts +404 -0
  113. package/src/main/pty.ts +65 -0
  114. package/src/main/settingsDir.ts +17 -0
  115. package/src/main/tui.ts +366 -0
  116. package/src/main/workerWs.ts +312 -0
  117. package/src/preload/index.ts +114 -0
  118. package/src/renderer/App.tsx +275 -0
  119. package/src/renderer/components/CtlsurfPanel.tsx +49 -0
  120. package/src/renderer/components/EditorPanel.tsx +232 -0
  121. package/src/renderer/components/MultiSplitPane.tsx +251 -0
  122. package/src/renderer/components/PaneLayout.tsx +419 -0
  123. package/src/renderer/components/SettingsDialog.tsx +204 -0
  124. package/src/renderer/components/SplitPane.tsx +82 -0
  125. package/src/renderer/components/StatusBar.tsx +73 -0
  126. package/src/renderer/components/TerminalPanel.tsx +140 -0
  127. package/src/renderer/index.html +12 -0
  128. package/src/renderer/main.tsx +10 -0
  129. package/src/renderer/styles.css +722 -0
  130. package/tsconfig.json +8 -0
  131. package/tsconfig.main.json +15 -0
  132. package/tsconfig.preload.json +14 -0
  133. package/tsconfig.renderer.json +15 -0
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * ctlsurf terminal mode (TUI)
5
+ *
6
+ * Runs the agent in a PTY with a terminal UI: title bar, status bar,
7
+ * conversation logging, and WebSocket control. No Electron required.
8
+ *
9
+ * Usage:
10
+ * ctlsurf --terminal [--agent claude] [--cwd /path] [--api-key KEY] [--base-url URL] [--profile NAME]
11
+ *
12
+ * If no --agent is given, shows an interactive agent picker.
13
+ * Press Ctrl+\ to exit at any time.
14
+ */
15
+
16
+ // Prevent EPIPE crashes
17
+ process.stdout?.on?.('error', () => {})
18
+ process.stderr?.on?.('error', () => {})
19
+ process.on('uncaughtException', (err) => {
20
+ if (err.message === 'write EPIPE') return
21
+ try { console.error('[uncaught]', err) } catch { /* ignore */ }
22
+ })
23
+
24
+ import { Orchestrator } from './orchestrator'
25
+ import { getSettingsDir } from './settingsDir'
26
+ import { getBuiltinAgents, getDefaultAgent, isCodingAgent, type AgentConfig } from './agents'
27
+ import { Tui } from './tui'
28
+
29
+ // ─── CLI arg parsing ──────────────────────────────
30
+
31
+ interface CliArgs {
32
+ agent: string | null
33
+ cwd: string
34
+ apiKey: string | null
35
+ baseUrl: string | null
36
+ profile: string | null
37
+ }
38
+
39
+ function parseArgs(argv: string[]): CliArgs {
40
+ const args: CliArgs = {
41
+ agent: null,
42
+ cwd: process.env.CTLSURF_WORKER_CWD || process.cwd(),
43
+ apiKey: null,
44
+ baseUrl: null,
45
+ profile: null,
46
+ }
47
+
48
+ for (let i = 0; i < argv.length; i++) {
49
+ const arg = argv[i]
50
+ const next = argv[i + 1]
51
+ switch (arg) {
52
+ case '--agent': args.agent = next; i++; break
53
+ case '--cwd': args.cwd = next; i++; break
54
+ case '--api-key': args.apiKey = next; i++; break
55
+ case '--base-url': args.baseUrl = next; i++; break
56
+ case '--profile': args.profile = next; i++; break
57
+ case '--terminal': break
58
+ case '--desktop': break
59
+ }
60
+ }
61
+ return args
62
+ }
63
+
64
+ // ─── Main ─────────────────────────────────────────
65
+
66
+ async function main() {
67
+ const args = parseArgs(process.argv.slice(2))
68
+ const settingsDir = getSettingsDir(false)
69
+
70
+ const tui = new Tui()
71
+ const agents = getBuiltinAgents()
72
+
73
+ // ─── Agent selection ────────────────────────────
74
+ let agent: AgentConfig
75
+
76
+ if (args.agent) {
77
+ const found = agents.find(a => a.id === args.agent)
78
+ agent = found || {
79
+ id: args.agent,
80
+ name: args.agent,
81
+ command: args.agent,
82
+ args: [],
83
+ description: `Custom agent: ${args.agent}`,
84
+ }
85
+ } else {
86
+ // Show interactive picker
87
+ const selectedIdx = await tui.showAgentPicker(agents)
88
+ agent = agents[selectedIdx]
89
+ }
90
+
91
+ // ─── Start TUI + agent ─────────────────────────
92
+
93
+ tui.update({
94
+ agentName: agent.name,
95
+ cwd: args.cwd,
96
+ mode: 'terminal',
97
+ })
98
+
99
+ tui.init()
100
+
101
+ const orchestrator = new Orchestrator(settingsDir, {
102
+ onPtyData: (data) => {
103
+ tui.writePtyData(data)
104
+ },
105
+ onPtyExit: (code) => {
106
+ tui.destroy()
107
+ console.log(`Agent exited with code ${code}`)
108
+ orchestrator.shutdown().then(() => process.exit(code))
109
+ },
110
+ onWorkerStatus: (status) => {
111
+ tui.update({ wsStatus: status })
112
+ },
113
+ onWorkerMessage: () => {},
114
+ onWorkerRegistered: () => {
115
+ tui.update({ wsStatus: 'connected' })
116
+ },
117
+ onCwdChanged: () => {
118
+ tui.update({ cwd: orchestrator.cwd || '' })
119
+ },
120
+ })
121
+
122
+ orchestrator.loadSettings()
123
+
124
+ if (args.profile) orchestrator.switchProfile(args.profile)
125
+ if (args.apiKey) orchestrator.overrideApiKey(args.apiKey)
126
+ if (args.baseUrl) orchestrator.overrideBaseUrl(args.baseUrl)
127
+
128
+ // Spawn agent with PTY sized to fit the TUI content area
129
+ const ptySize = tui.getPtySize()
130
+ await orchestrator.spawnAgent(agent, args.cwd)
131
+ orchestrator.resizePty(ptySize.cols, ptySize.rows)
132
+
133
+ // For coding agents, send an initial prompt to kick-start them
134
+ if (isCodingAgent(agent)) {
135
+ setTimeout(() => {
136
+ orchestrator.writePty('hello\r')
137
+ }, 1000)
138
+ }
139
+
140
+ // Pipe stdin to PTY, with Ctrl+\ as the exit key
141
+ if (process.stdin.isTTY) {
142
+ process.stdin.setRawMode(true)
143
+ process.stdin.resume()
144
+ process.stdin.on('data', (data) => {
145
+ const str = data.toString()
146
+ // Ctrl+\ (0x1c) = exit
147
+ if (str === '\x1c') {
148
+ shutdown()
149
+ return
150
+ }
151
+ orchestrator.writePty(str)
152
+ })
153
+ }
154
+
155
+ // Handle terminal resize
156
+ process.stdout.on('resize', () => {
157
+ const cols = process.stdout.columns || 80
158
+ const rows = process.stdout.rows || 24
159
+ tui.resize(cols, rows)
160
+ const size = tui.getPtySize()
161
+ orchestrator.resizePty(size.cols, size.rows)
162
+ })
163
+
164
+ // Graceful shutdown
165
+ const shutdown = async () => {
166
+ if (process.stdin.isTTY) {
167
+ process.stdin.setRawMode(false)
168
+ }
169
+ tui.destroy()
170
+ await orchestrator.shutdown()
171
+ process.exit(0)
172
+ }
173
+
174
+ process.on('SIGINT', shutdown)
175
+ process.on('SIGTERM', shutdown)
176
+ }
177
+
178
+ main().catch((err) => {
179
+ process.stdout.write('\x1b[?1049l')
180
+ console.error('Fatal error:', err)
181
+ process.exit(1)
182
+ })
@@ -0,0 +1,300 @@
1
+ import { app, BrowserWindow, ipcMain, dialog } from 'electron'
2
+ import path from 'path'
3
+ import fs from 'fs'
4
+
5
+ // Prevent EPIPE crashes when stdout pipe is closed
6
+ process.stdout?.on?.('error', () => {})
7
+ process.stderr?.on?.('error', () => {})
8
+ process.on('uncaughtException', (err) => {
9
+ if (err.message === 'write EPIPE') return
10
+ try { console.error('[uncaught]', err) } catch { /* ignore */ }
11
+ })
12
+ function log(...args: unknown[]): void {
13
+ try { console.log(...args) } catch { /* EPIPE safe */ }
14
+ }
15
+
16
+ import { AgentConfig, getDefaultAgent, getBuiltinAgents } from './agents'
17
+ import { getSettingsDir } from './settingsDir'
18
+ import { Orchestrator } from './orchestrator'
19
+
20
+ let mainWindow: BrowserWindow | null = null
21
+
22
+ // ─── Orchestrator (shared logic) ──────────────────
23
+
24
+ const orchestrator = new Orchestrator(
25
+ getSettingsDir(true),
26
+ {
27
+ onPtyData: (data) => mainWindow?.webContents.send('pty:data', data),
28
+ onPtyExit: (code) => mainWindow?.webContents.send('pty:exit', code),
29
+ onWorkerStatus: (status) => mainWindow?.webContents.send('worker:status', status),
30
+ onWorkerMessage: (message) => mainWindow?.webContents.send('worker:message', message),
31
+ onWorkerRegistered: (data) => mainWindow?.webContents.send('worker:registered', data),
32
+ onCwdChanged: () => mainWindow?.webContents.send('app:cwdChanged'),
33
+ }
34
+ )
35
+
36
+ // ─── Window ───────────────────────────────────────
37
+
38
+ function createWindow(): void {
39
+ mainWindow = new BrowserWindow({
40
+ width: 1400,
41
+ height: 900,
42
+ minWidth: 600,
43
+ minHeight: 400,
44
+ title: 'ctlsurf-worker',
45
+ titleBarStyle: 'hiddenInset',
46
+ icon: path.join(__dirname, '../../resources/icon.png'),
47
+ webPreferences: {
48
+ preload: path.join(__dirname, '../preload/index.js'),
49
+ contextIsolation: true,
50
+ nodeIntegration: false,
51
+ webviewTag: true
52
+ }
53
+ })
54
+
55
+ if (process.env.ELECTRON_RENDERER_URL) {
56
+ mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
57
+ } else {
58
+ mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
59
+ }
60
+
61
+ mainWindow.on('closed', () => {
62
+ mainWindow = null
63
+ })
64
+ }
65
+
66
+ // ─── IPC Handlers ──────────────────────────────────
67
+
68
+ ipcMain.handle('pty:spawn', async (_event, agent: AgentConfig, cwd: string) => {
69
+ await orchestrator.spawnAgent(agent, cwd)
70
+ return { ok: true }
71
+ })
72
+
73
+ ipcMain.handle('pty:write', (_event, data: string) => {
74
+ orchestrator.writePty(data)
75
+ })
76
+
77
+ ipcMain.handle('pty:resize', (_event, cols: number, rows: number) => {
78
+ orchestrator.resizePty(cols, rows)
79
+ })
80
+
81
+ ipcMain.handle('pty:kill', async () => {
82
+ await orchestrator.killAgent()
83
+ })
84
+
85
+ ipcMain.handle('agents:list', () => getBuiltinAgents())
86
+ ipcMain.handle('agents:default', () => getDefaultAgent())
87
+
88
+ ipcMain.handle('app:homePath', () => app.getPath('home'))
89
+ ipcMain.handle('app:cwd', () => process.env.CTLSURF_WORKER_CWD || process.cwd())
90
+
91
+ ipcMain.handle('app:browseCwd', async () => {
92
+ if (!mainWindow) return null
93
+ const result = await dialog.showOpenDialog(mainWindow, {
94
+ properties: ['openDirectory'],
95
+ title: 'Select project directory',
96
+ defaultPath: orchestrator.cwd || process.env.HOME || '/',
97
+ })
98
+ if (result.canceled || !result.filePaths[0]) return null
99
+ return result.filePaths[0]
100
+ })
101
+
102
+ // ─── Filesystem IPC ───────────────────────────────
103
+
104
+ ipcMain.handle('fs:readDir', async (_event, dirPath: string) => {
105
+ try {
106
+ const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
107
+ return entries
108
+ .filter(e => !e.name.startsWith('.'))
109
+ .map(e => ({
110
+ name: e.name,
111
+ path: path.join(dirPath, e.name),
112
+ isDirectory: e.isDirectory(),
113
+ }))
114
+ .sort((a, b) => {
115
+ if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1
116
+ return a.name.localeCompare(b.name)
117
+ })
118
+ } catch {
119
+ return []
120
+ }
121
+ })
122
+
123
+ ipcMain.handle('fs:readFile', async (_event, filePath: string) => {
124
+ try {
125
+ const content = await fs.promises.readFile(filePath, 'utf-8')
126
+ return { ok: true, content }
127
+ } catch (err: any) {
128
+ return { ok: false, error: err.message }
129
+ }
130
+ })
131
+
132
+ ipcMain.handle('fs:writeFile', async (_event, filePath: string, content: string) => {
133
+ try {
134
+ await fs.promises.writeFile(filePath, content, 'utf-8')
135
+ return { ok: true }
136
+ } catch (err: any) {
137
+ return { ok: false, error: err.message }
138
+ }
139
+ })
140
+
141
+ // ─── Worker IPC ───────────────────────────────────
142
+
143
+ ipcMain.handle('worker:getStatus', () => orchestrator.workerWs.status)
144
+ ipcMain.handle('worker:getWorkerId', () => orchestrator.workerWs.currentWorkerId)
145
+
146
+ ipcMain.handle('worker:createProject', async () => {
147
+ const currentCwd = orchestrator.cwd
148
+ if (!currentCwd || !orchestrator.ctlsurfApi.getApiKey()) {
149
+ return { ok: false, error: 'No cwd or API key' }
150
+ }
151
+
152
+ try {
153
+ const folderName = currentCwd.split('/').filter(Boolean).pop() || 'project'
154
+ const folder = await orchestrator.ctlsurfApi.createFolder({
155
+ name: folderName,
156
+ root_path: currentCwd,
157
+ })
158
+
159
+ await orchestrator.ctlsurfApi.createPage({
160
+ title: folderName,
161
+ folder_id: folder.id,
162
+ cwd: currentCwd,
163
+ tags: ['project'],
164
+ })
165
+
166
+ if (orchestrator.agent) {
167
+ orchestrator.connectWorkerWs(orchestrator.agent, currentCwd)
168
+ }
169
+
170
+ log(`[worker] Created project: ${folderName} (${folder.id})`)
171
+ return { ok: true, folder_id: folder.id }
172
+ } catch (err: any) {
173
+ log('[worker] Failed to create project:', err.message)
174
+ return { ok: false, error: err.message }
175
+ }
176
+ })
177
+
178
+ ipcMain.handle('worker:getWebviewInfo', async () => {
179
+ const profile = orchestrator.getActiveProfile()
180
+ const baseUrl = profile.baseUrl || process.env.CTLSURF_BASE_URL || 'https://app.ctlsurf.com'
181
+ const frontendUrl = baseUrl.includes('localhost:8000')
182
+ ? baseUrl.replace(':8000', ':88')
183
+ : baseUrl
184
+
185
+ if (!orchestrator.ctlsurfApi.getApiKey()) {
186
+ return { frontendUrl: `${frontendUrl}?embed=1`, authenticated: false }
187
+ }
188
+
189
+ try {
190
+ const { code } = await orchestrator.ctlsurfApi.getAuthCode()
191
+ let pageUrl = `${frontendUrl}?embed=1&_code=${encodeURIComponent(code)}`
192
+ const currentCwd = orchestrator.cwd
193
+
194
+ if (currentCwd) {
195
+ try {
196
+ let folder = null
197
+ try { folder = await orchestrator.ctlsurfApi.findFolderByPath(currentCwd) } catch { /* not found */ }
198
+
199
+ if (!folder) {
200
+ try {
201
+ const { execSync } = require('child_process')
202
+ const gitRemote = execSync('git remote get-url origin', { cwd: currentCwd, encoding: 'utf-8' }).trim()
203
+ if (gitRemote) {
204
+ folder = await orchestrator.ctlsurfApi.findFolderByGitRemote(gitRemote)
205
+ }
206
+ } catch { /* not a git repo */ }
207
+ }
208
+
209
+ if (folder?.id) {
210
+ const pages = await orchestrator.ctlsurfApi.getFolderPages(folder.id)
211
+ const rootPage = pages?.find((p: any) => !p.parent_id)
212
+ if (rootPage?.id) {
213
+ pageUrl = `${frontendUrl}/page/${rootPage.id}?embed=1&_code=${encodeURIComponent(code)}`
214
+ }
215
+ }
216
+ } catch { /* No project */ }
217
+ }
218
+
219
+ return { frontendUrl, pageUrl, authenticated: true }
220
+ } catch (err: any) {
221
+ log('[worker] Failed to exchange tokens:', err.message)
222
+ return { frontendUrl: `${frontendUrl}?embed=1`, authenticated: false }
223
+ }
224
+ })
225
+
226
+ // ─── Profile IPC ──────────────────────────────────
227
+
228
+ ipcMain.handle('profiles:list', () => orchestrator.listProfiles())
229
+ ipcMain.handle('profiles:get', (_event, id: string) => orchestrator.getProfile(id))
230
+ ipcMain.handle('profiles:save', (_event, id: string, data: any) => {
231
+ orchestrator.saveProfile(id, data)
232
+ return { ok: true }
233
+ })
234
+ ipcMain.handle('profiles:switch', (_event, id: string) => orchestrator.switchProfile(id))
235
+ ipcMain.handle('profiles:delete', (_event, id: string) => orchestrator.deleteProfile(id))
236
+
237
+ // ─── Legacy Settings IPC ──────────────────────────
238
+
239
+ ipcMain.handle('settings:get', (_event, key: string) => {
240
+ const profile = orchestrator.getActiveProfile()
241
+ if (key === 'ctlsurfApiKey') return profile.apiKey ? '***configured***' : null
242
+ if (key === 'ctlsurfBaseUrl') return profile.baseUrl || null
243
+ if (key === 'ctlsurfDataspacePageId') return profile.dataspacePageId || null
244
+ return null
245
+ })
246
+
247
+ ipcMain.handle('settings:set', (_event, key: string, value: string) => {
248
+ const settings = orchestrator.settingsData
249
+ const profile = settings.profiles[settings.activeProfile]
250
+ if (!profile) return { ok: false }
251
+
252
+ if (key === 'ctlsurfApiKey') profile.apiKey = value
253
+ else if (key === 'ctlsurfBaseUrl') profile.baseUrl = value
254
+ else if (key === 'ctlsurfDataspacePageId') profile.dataspacePageId = value
255
+
256
+ orchestrator.saveSettings()
257
+ orchestrator.applyProfile(profile)
258
+
259
+ if (key === 'ctlsurfApiKey' && orchestrator.agent && orchestrator.cwd) {
260
+ orchestrator.workerWs.disconnect()
261
+ orchestrator.connectWorkerWs(orchestrator.agent, orchestrator.cwd)
262
+ }
263
+
264
+ return { ok: true }
265
+ })
266
+
267
+ ipcMain.handle('settings:getAll', () => {
268
+ const profile = orchestrator.getActiveProfile()
269
+ return {
270
+ ctlsurfApiKey: profile.apiKey ? '***configured***' : null,
271
+ ctlsurfDataspacePageId: profile.dataspacePageId || null,
272
+ activeProfile: orchestrator.settingsData.activeProfile,
273
+ }
274
+ })
275
+
276
+ // ─── App lifecycle ─────────────────────────────────
277
+
278
+ app.whenReady().then(() => {
279
+ if (process.platform === 'darwin' && app.dock) {
280
+ const iconPath = path.join(__dirname, '../../resources/icon.png')
281
+ try {
282
+ const { nativeImage } = require('electron')
283
+ app.dock.setIcon(nativeImage.createFromPath(iconPath))
284
+ } catch { /* ignore */ }
285
+ }
286
+
287
+ orchestrator.loadSettings()
288
+ createWindow()
289
+ })
290
+
291
+ app.on('window-all-closed', () => {
292
+ orchestrator.shutdown()
293
+ app.quit()
294
+ })
295
+
296
+ app.on('activate', () => {
297
+ if (BrowserWindow.getAllWindows().length === 0) {
298
+ createWindow()
299
+ }
300
+ })