@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,1131 @@
1
+ "use strict";
2
+ const electron = require("electron");
3
+ const path = require("path");
4
+ const fs = require("fs");
5
+ const os = require("os");
6
+ const module$1 = require("module");
7
+ const crypto = require("crypto");
8
+ function getShellCommand() {
9
+ if (process.platform === "win32") return "powershell.exe";
10
+ return process.env.SHELL || "/bin/zsh";
11
+ }
12
+ function getBuiltinAgents() {
13
+ return [
14
+ {
15
+ id: "shell",
16
+ name: "Shell",
17
+ command: getShellCommand(),
18
+ args: ["-l"],
19
+ // login shell to load PATH
20
+ description: "Default system shell"
21
+ },
22
+ {
23
+ id: "claude",
24
+ name: "Claude Code",
25
+ command: "claude",
26
+ args: [],
27
+ description: "Anthropic Claude Code CLI"
28
+ },
29
+ {
30
+ id: "codex",
31
+ name: "Codex CLI",
32
+ command: "codex",
33
+ args: [],
34
+ description: "OpenAI Codex CLI"
35
+ }
36
+ ];
37
+ }
38
+ function getDefaultAgent() {
39
+ return getBuiltinAgents()[0];
40
+ }
41
+ function isCodingAgent(agent) {
42
+ return agent.id !== "shell";
43
+ }
44
+ function getSettingsDir(useElectron) {
45
+ {
46
+ const { app } = require("electron");
47
+ return app.getPath("userData");
48
+ }
49
+ }
50
+ const require$1 = module$1.createRequire(require("url").pathToFileURL(__filename).href);
51
+ const pty = require$1("node-pty");
52
+ class PtyManager {
53
+ process = null;
54
+ dataCallbacks = [];
55
+ exitCallbacks = [];
56
+ constructor(agent, cwd) {
57
+ const shell = agent.command;
58
+ const args = agent.args || [];
59
+ try {
60
+ console.log(`[pty] Spawning: ${shell} ${args.join(" ")} in ${cwd}`);
61
+ } catch {
62
+ }
63
+ this.process = pty.spawn(shell, args, {
64
+ name: "xterm-256color",
65
+ cwd,
66
+ env: process.env,
67
+ cols: 80,
68
+ rows: 24
69
+ });
70
+ this.process.onData((data) => {
71
+ for (const cb of this.dataCallbacks) {
72
+ cb(data);
73
+ }
74
+ });
75
+ this.process.onExit(({ exitCode }) => {
76
+ for (const cb of this.exitCallbacks) {
77
+ cb(exitCode);
78
+ }
79
+ this.process = null;
80
+ });
81
+ }
82
+ write(data) {
83
+ this.process?.write(data);
84
+ }
85
+ resize(cols, rows) {
86
+ this.process?.resize(cols, rows);
87
+ }
88
+ kill() {
89
+ this.process?.kill();
90
+ this.process = null;
91
+ }
92
+ onData(cb) {
93
+ this.dataCallbacks.push(cb);
94
+ }
95
+ onExit(cb) {
96
+ this.exitCallbacks.push(cb);
97
+ }
98
+ }
99
+ const CTLSURF_BASE_URL = "https://app.ctlsurf.com/api";
100
+ class CtlsurfApi {
101
+ baseUrl;
102
+ apiKey = null;
103
+ constructor(baseUrl) {
104
+ this.baseUrl = baseUrl || CTLSURF_BASE_URL;
105
+ }
106
+ setApiKey(key) {
107
+ this.apiKey = key;
108
+ }
109
+ setBaseUrl(url) {
110
+ this.baseUrl = url.endsWith("/api") ? url : `${url}/api`;
111
+ }
112
+ getApiKey() {
113
+ return this.apiKey;
114
+ }
115
+ headers() {
116
+ const h = { "Content-Type": "application/json" };
117
+ if (this.apiKey) {
118
+ h["Authorization"] = `Bearer ${this.apiKey}`;
119
+ }
120
+ return h;
121
+ }
122
+ async request(method, path2, body) {
123
+ const url = `${this.baseUrl}${path2}`;
124
+ const opts = {
125
+ method,
126
+ headers: this.headers()
127
+ };
128
+ if (body) {
129
+ opts.body = JSON.stringify(body);
130
+ }
131
+ const res = await fetch(url, opts);
132
+ if (!res.ok) {
133
+ const text = await res.text();
134
+ throw new Error(`ctlsurf API ${method} ${path2}: ${res.status} ${text}`);
135
+ }
136
+ return res.json();
137
+ }
138
+ // ─── Pages ───────────────────────────────────────────
139
+ async createPage(params) {
140
+ return this.request("POST", "/pages", params);
141
+ }
142
+ async findPageByRootPath(rootPath) {
143
+ return this.request("POST", "/pages/find-by-root-path", { root_path: rootPath });
144
+ }
145
+ // ─── Blocks ──────────────────────────────────────────
146
+ async createBlock(pageId, params) {
147
+ return this.request("POST", `/blocks/page/${pageId}`, params);
148
+ }
149
+ async getBlock(blockId) {
150
+ return this.request("GET", `/blocks/${blockId}`);
151
+ }
152
+ async updateBlock(blockId, params) {
153
+ return this.request("PUT", `/blocks/${blockId}`, params);
154
+ }
155
+ // ─── Folders ────────────────────────────────────────
156
+ async createFolder(params) {
157
+ return this.request("POST", "/folders", params);
158
+ }
159
+ // ─── Workers ────────────────────────────────────────
160
+ async getAuthCode() {
161
+ return this.request("POST", "/workers/token-exchange");
162
+ }
163
+ async findFolderByPath(rootPath) {
164
+ return this.request("POST", "/folders/find-by-path", { root_path: rootPath });
165
+ }
166
+ async getFolderPages(folderId) {
167
+ const folder = await this.request("GET", `/folders/${folderId}`);
168
+ return folder?.pages || [];
169
+ }
170
+ async findFolderByGitRemote(gitRemote) {
171
+ const folders = await this.request("GET", "/folders");
172
+ return folders?.find((f) => f.git_remote === gitRemote || f.root_path === gitRemote) || null;
173
+ }
174
+ // ─── Log convenience ─────────────────────────────────
175
+ async appendLog(blockId, action, message, data) {
176
+ const block = await this.getBlock(blockId);
177
+ const props = block.props || {};
178
+ const entries = Array.isArray(props.entries) ? [...props.entries] : [];
179
+ const maxEntries = props.max_entries || 1e3;
180
+ const entry = {
181
+ _id: `log_${entries.length}`,
182
+ _timestamp: (/* @__PURE__ */ new Date()).toISOString(),
183
+ action,
184
+ message
185
+ };
186
+ if (data) {
187
+ entry.data = data;
188
+ }
189
+ entries.push(entry);
190
+ const trimmed = entries.length > maxEntries ? entries.slice(-maxEntries) : entries;
191
+ return this.updateBlock(blockId, {
192
+ props: { ...props, entries: trimmed }
193
+ });
194
+ }
195
+ }
196
+ class ConversationBridge {
197
+ api;
198
+ logBlockId = null;
199
+ pageId = null;
200
+ buffer = "";
201
+ flushTimer = null;
202
+ flushIntervalMs = 3e3;
203
+ // flush every 3 seconds
204
+ agentName = "shell";
205
+ sessionActive = false;
206
+ inputBuffer = "";
207
+ constructor(api) {
208
+ this.api = api;
209
+ }
210
+ /**
211
+ * Start a new logging session.
212
+ * Creates a log block on the given dataspace page.
213
+ */
214
+ async startSession(dataspacePageId, agentName, cwd) {
215
+ if (!this.api.getApiKey()) {
216
+ console.log("[bridge] No API key set, skipping session logging");
217
+ return;
218
+ }
219
+ this.pageId = dataspacePageId;
220
+ this.agentName = agentName;
221
+ this.buffer = "";
222
+ this.inputBuffer = "";
223
+ try {
224
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").substring(0, 19);
225
+ const block = await this.api.createBlock(dataspacePageId, {
226
+ type: "log",
227
+ title: `${agentName} — ${timestamp} — ${cwd}`,
228
+ props: {
229
+ entries: [],
230
+ max_entries: 1e3
231
+ }
232
+ });
233
+ this.logBlockId = block.id;
234
+ this.sessionActive = true;
235
+ await this.api.appendLog(this.logBlockId, "session_start", `Started ${agentName} session`, {
236
+ agent: agentName,
237
+ cwd,
238
+ timestamp
239
+ });
240
+ console.log(`[bridge] Session started, log block: ${this.logBlockId}`);
241
+ } catch (err) {
242
+ console.error(`[bridge] Failed to start session:`, err.message);
243
+ this.sessionActive = false;
244
+ }
245
+ }
246
+ /**
247
+ * Feed terminal output data into the bridge.
248
+ * Buffers and flushes periodically.
249
+ */
250
+ feedOutput(data) {
251
+ if (!this.sessionActive) return;
252
+ this.buffer += data;
253
+ if (this.flushTimer) {
254
+ clearTimeout(this.flushTimer);
255
+ }
256
+ this.flushTimer = setTimeout(() => this.flush(), this.flushIntervalMs);
257
+ }
258
+ /**
259
+ * Feed user input data into the bridge.
260
+ */
261
+ feedInput(data) {
262
+ if (!this.sessionActive) return;
263
+ this.inputBuffer += data;
264
+ if (data.includes("\r") || data.includes("\n")) {
265
+ const input = this.inputBuffer.trim();
266
+ if (input.length > 0) {
267
+ this.logEntry("user_input", input);
268
+ }
269
+ this.inputBuffer = "";
270
+ }
271
+ }
272
+ /**
273
+ * Flush buffered output to ctlsurf.
274
+ */
275
+ async flush() {
276
+ if (!this.logBlockId || this.buffer.length === 0) return;
277
+ const chunk = this.buffer;
278
+ this.buffer = "";
279
+ const cleaned = stripAnsi(chunk);
280
+ if (cleaned.trim().length === 0) return;
281
+ try {
282
+ await this.api.appendLog(this.logBlockId, "terminal_output", cleaned);
283
+ } catch (err) {
284
+ console.error(`[bridge] Failed to append log:`, err.message);
285
+ }
286
+ }
287
+ /**
288
+ * Log a specific entry immediately.
289
+ */
290
+ async logEntry(action, message, data) {
291
+ if (!this.logBlockId) return;
292
+ try {
293
+ await this.api.appendLog(this.logBlockId, action, message, data);
294
+ } catch (err) {
295
+ console.error(`[bridge] Failed to log entry:`, err.message);
296
+ }
297
+ }
298
+ /**
299
+ * End the current session.
300
+ */
301
+ async endSession(exitCode) {
302
+ if (!this.sessionActive || !this.logBlockId) return;
303
+ await this.flush();
304
+ try {
305
+ await this.api.appendLog(this.logBlockId, "session_end", `Session ended (exit code: ${exitCode ?? "unknown"})`, {
306
+ agent: this.agentName,
307
+ exitCode
308
+ });
309
+ } catch (err) {
310
+ console.error(`[bridge] Failed to log session end:`, err.message);
311
+ }
312
+ if (this.flushTimer) {
313
+ clearTimeout(this.flushTimer);
314
+ this.flushTimer = null;
315
+ }
316
+ this.sessionActive = false;
317
+ this.logBlockId = null;
318
+ this.buffer = "";
319
+ this.inputBuffer = "";
320
+ console.log("[bridge] Session ended");
321
+ }
322
+ }
323
+ function stripAnsi(str) {
324
+ return str.replace(/\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "").replace(/\x1b[^[\]](.|$)/g, "").replace(/\x1b/g, "").replace(/\r/g, "").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
325
+ }
326
+ function log$2(...args) {
327
+ try {
328
+ console.log(...args);
329
+ } catch {
330
+ }
331
+ }
332
+ const HEARTBEAT_INTERVAL_MS = 3e4;
333
+ const RECONNECT_DELAY_MS = 5e3;
334
+ const MAX_RECONNECT_DELAY_MS = 6e4;
335
+ class WorkerWsClient {
336
+ ws = null;
337
+ apiKey = null;
338
+ baseUrl;
339
+ events;
340
+ heartbeatTimer = null;
341
+ reconnectTimer = null;
342
+ reconnectDelay = RECONNECT_DELAY_MS;
343
+ registration = null;
344
+ workerId = null;
345
+ _status = "disconnected";
346
+ shouldReconnect = false;
347
+ fingerprint;
348
+ constructor(events, baseUrl) {
349
+ this.events = events;
350
+ this.baseUrl = baseUrl || "wss://app.ctlsurf.com";
351
+ this.fingerprint = this.generateFingerprint();
352
+ }
353
+ get status() {
354
+ return this._status;
355
+ }
356
+ get currentWorkerId() {
357
+ return this.workerId;
358
+ }
359
+ setApiKey(key) {
360
+ this.apiKey = key;
361
+ }
362
+ setBaseUrl(url) {
363
+ this.baseUrl = url;
364
+ }
365
+ generateFingerprint() {
366
+ const data = `${os.hostname()}:${os.userInfo().username}:${os.platform()}:${os.arch()}`;
367
+ return crypto.createHash("sha256").update(data).digest("hex").slice(0, 32);
368
+ }
369
+ setStatus(status) {
370
+ if (this._status !== status) {
371
+ this._status = status;
372
+ this.events.onStatusChange(status);
373
+ }
374
+ }
375
+ connect(registration) {
376
+ this.registration = { ...registration, fingerprint: this.fingerprint };
377
+ this.shouldReconnect = true;
378
+ this.doConnect();
379
+ }
380
+ disconnect() {
381
+ this.shouldReconnect = false;
382
+ this.clearTimers();
383
+ if (this.ws) {
384
+ const oldWs = this.ws;
385
+ this.ws = null;
386
+ oldWs.onopen = null;
387
+ oldWs.onmessage = null;
388
+ oldWs.onclose = null;
389
+ oldWs.onerror = null;
390
+ try {
391
+ oldWs.close(1e3, "client disconnect");
392
+ } catch {
393
+ }
394
+ }
395
+ this.setStatus("disconnected");
396
+ }
397
+ sendResponse(parentId, content, metadata) {
398
+ this.send({
399
+ type: "response",
400
+ parent_id: parentId,
401
+ content,
402
+ metadata
403
+ });
404
+ }
405
+ sendStatusUpdate(status) {
406
+ this.send({ type: "status_update", status });
407
+ }
408
+ sendAck(messageId) {
409
+ this.send({ type: "ack", message_id: messageId });
410
+ }
411
+ sendTerminalData(data) {
412
+ this.send({ type: "terminal_stream", data });
413
+ }
414
+ sendTerminalResize(cols, rows) {
415
+ this.send({ type: "terminal_resize", cols, rows });
416
+ }
417
+ doConnect() {
418
+ if (!this.apiKey || !this.registration) {
419
+ log$2("[worker-ws] No API key or registration, skipping connect");
420
+ return;
421
+ }
422
+ this.clearTimers();
423
+ if (this.ws) {
424
+ const oldWs = this.ws;
425
+ this.ws = null;
426
+ oldWs.onopen = null;
427
+ oldWs.onmessage = null;
428
+ oldWs.onclose = null;
429
+ oldWs.onerror = null;
430
+ try {
431
+ oldWs.close();
432
+ } catch {
433
+ }
434
+ setTimeout(() => this.doConnectNow(), 500);
435
+ return;
436
+ }
437
+ this.doConnectNow();
438
+ }
439
+ doConnectNow() {
440
+ if (!this.apiKey || !this.registration) return;
441
+ if (!this.shouldReconnect) {
442
+ log$2("[worker-ws] shouldReconnect is false, aborting connect");
443
+ return;
444
+ }
445
+ this.setStatus("connecting");
446
+ const wsBase = this.baseUrl.replace(/^http/, "ws");
447
+ const url = `${wsBase}/api/ws/worker?token=${encodeURIComponent(this.apiKey)}`;
448
+ log$2(`[worker-ws] Connecting to ${url.replace(/token=.*/, "token=***")}...`);
449
+ try {
450
+ this.ws = new WebSocket(url);
451
+ } catch (err) {
452
+ log$2("[worker-ws] Failed to create WebSocket:", err);
453
+ this.scheduleReconnect();
454
+ return;
455
+ }
456
+ this.ws.onopen = () => {
457
+ log$2("[worker-ws] Connected, sending register");
458
+ this.reconnectDelay = RECONNECT_DELAY_MS;
459
+ this.send({
460
+ type: "register",
461
+ ...this.registration
462
+ });
463
+ this.startHeartbeat();
464
+ };
465
+ this.ws.onmessage = (event) => {
466
+ try {
467
+ const data = JSON.parse(String(event.data));
468
+ this.handleMessage(data);
469
+ } catch (err) {
470
+ log$2("[worker-ws] Failed to parse message:", err);
471
+ }
472
+ };
473
+ this.ws.onclose = (event) => {
474
+ log$2(`[worker-ws] Disconnected: ${event.code} ${event.reason}`);
475
+ this.ws = null;
476
+ this.clearHeartbeat();
477
+ this.setStatus("disconnected");
478
+ if (this.shouldReconnect) {
479
+ this.scheduleReconnect();
480
+ }
481
+ };
482
+ this.ws.onerror = () => {
483
+ log$2("[worker-ws] WebSocket error");
484
+ };
485
+ }
486
+ handleMessage(data) {
487
+ const msgType = data.type;
488
+ switch (msgType) {
489
+ case "registered": {
490
+ this.workerId = data.worker_id;
491
+ const workerStatus = data.status;
492
+ console.log(`[worker-ws] Registered as ${this.workerId}, status: ${workerStatus}`);
493
+ if (workerStatus === "pending_approval") {
494
+ this.setStatus("pending_approval");
495
+ } else {
496
+ this.setStatus("connected");
497
+ }
498
+ const pendingMessages = data.pending_messages || [];
499
+ this.events.onRegistered({
500
+ worker_id: this.workerId,
501
+ folder_id: data.folder_id,
502
+ status: workerStatus,
503
+ pending_messages: pendingMessages
504
+ });
505
+ for (const msg of pendingMessages) {
506
+ this.events.onMessage(msg);
507
+ }
508
+ break;
509
+ }
510
+ case "approved": {
511
+ log$2("[worker-ws] Worker approved!");
512
+ this.setStatus("connected");
513
+ break;
514
+ }
515
+ case "message": {
516
+ const msg = data.message;
517
+ if (msg) {
518
+ console.log(`[worker-ws] Received message: ${msg.id}`);
519
+ this.events.onMessage(msg);
520
+ }
521
+ break;
522
+ }
523
+ case "terminal_input": {
524
+ const inputData = data.data;
525
+ if (inputData && this.events.onTerminalInput) {
526
+ this.events.onTerminalInput(inputData);
527
+ }
528
+ break;
529
+ }
530
+ case "heartbeat_ack":
531
+ break;
532
+ default:
533
+ console.log(`[worker-ws] Unknown message type: ${msgType}`);
534
+ }
535
+ }
536
+ send(data) {
537
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
538
+ this.ws.send(JSON.stringify(data));
539
+ }
540
+ }
541
+ startHeartbeat() {
542
+ this.clearHeartbeat();
543
+ this.heartbeatTimer = setInterval(() => {
544
+ this.send({ type: "heartbeat" });
545
+ }, HEARTBEAT_INTERVAL_MS);
546
+ }
547
+ clearHeartbeat() {
548
+ if (this.heartbeatTimer) {
549
+ clearInterval(this.heartbeatTimer);
550
+ this.heartbeatTimer = null;
551
+ }
552
+ }
553
+ scheduleReconnect() {
554
+ if (!this.shouldReconnect) return;
555
+ console.log(`[worker-ws] Reconnecting in ${this.reconnectDelay / 1e3}s...`);
556
+ this.reconnectTimer = setTimeout(() => {
557
+ this.doConnect();
558
+ }, this.reconnectDelay);
559
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY_MS);
560
+ }
561
+ clearTimers() {
562
+ this.clearHeartbeat();
563
+ if (this.reconnectTimer) {
564
+ clearTimeout(this.reconnectTimer);
565
+ this.reconnectTimer = null;
566
+ }
567
+ }
568
+ }
569
+ function log$1(...args) {
570
+ try {
571
+ console.log(...args);
572
+ } catch {
573
+ }
574
+ }
575
+ const DEFAULT_PROFILES = {
576
+ production: {
577
+ name: "Production",
578
+ apiKey: "",
579
+ baseUrl: "https://app.ctlsurf.com",
580
+ dataspacePageId: ""
581
+ }
582
+ };
583
+ const TERM_STREAM_INTERVAL_MS = 50;
584
+ class Orchestrator {
585
+ settingsDir;
586
+ events;
587
+ // Core services
588
+ ctlsurfApi = new CtlsurfApi();
589
+ bridge = new ConversationBridge(this.ctlsurfApi);
590
+ workerWs;
591
+ // State
592
+ ptyManager = null;
593
+ currentAgent = null;
594
+ currentCwd = null;
595
+ settings = {
596
+ activeProfile: "production",
597
+ profiles: { ...DEFAULT_PROFILES }
598
+ };
599
+ // Terminal stream batching
600
+ termStreamBuffer = "";
601
+ termStreamTimer = null;
602
+ constructor(settingsDir, events) {
603
+ this.settingsDir = settingsDir;
604
+ this.events = events;
605
+ this.workerWs = new WorkerWsClient({
606
+ onStatusChange: (status) => {
607
+ log$1(`[worker-ws] Status: ${status}`);
608
+ events.onWorkerStatus(status);
609
+ },
610
+ onMessage: (message) => {
611
+ log$1(`[worker-ws] Incoming message: ${message.id} (${message.type})`);
612
+ events.onWorkerMessage(message);
613
+ this.workerWs.sendAck(message.id);
614
+ if (message.type === "prompt" || message.type === "task_dispatch") {
615
+ if (this.ptyManager) {
616
+ this.ptyManager.write(message.content + "\r");
617
+ this.bridge.feedInput(message.content);
618
+ }
619
+ }
620
+ },
621
+ onRegistered: (data) => {
622
+ log$1(`[worker-ws] Registered: worker_id=${data.worker_id}, folder_id=${data.folder_id}, status=${data.status}`);
623
+ events.onWorkerRegistered(data);
624
+ if (!data.folder_id) {
625
+ events.onWorkerStatus("no_project");
626
+ }
627
+ },
628
+ onTerminalInput: (data) => {
629
+ this.ptyManager?.write(data);
630
+ }
631
+ });
632
+ }
633
+ // ─── Settings ───────────────────────────────────
634
+ getActiveProfile() {
635
+ return this.settings.profiles[this.settings.activeProfile] || this.settings.profiles.production || DEFAULT_PROFILES.production;
636
+ }
637
+ get settingsData() {
638
+ return this.settings;
639
+ }
640
+ get cwd() {
641
+ return this.currentCwd;
642
+ }
643
+ get agent() {
644
+ return this.currentAgent;
645
+ }
646
+ applyProfile(profile) {
647
+ const apiKey = profile.apiKey || process.env.CTLSURF_API_KEY || "";
648
+ if (apiKey) {
649
+ this.ctlsurfApi.setApiKey(apiKey);
650
+ this.workerWs.setApiKey(apiKey);
651
+ } else {
652
+ this.ctlsurfApi.setApiKey("");
653
+ this.workerWs.setApiKey(null);
654
+ }
655
+ const baseUrl = profile.baseUrl || process.env.CTLSURF_BASE_URL || "https://app.ctlsurf.com";
656
+ this.ctlsurfApi.setBaseUrl(baseUrl);
657
+ this.workerWs.setBaseUrl(baseUrl);
658
+ log$1(`[settings] Profile applied: ${profile.name} (${baseUrl})`);
659
+ }
660
+ loadSettings() {
661
+ try {
662
+ fs.mkdirSync(this.settingsDir, { recursive: true });
663
+ } catch {
664
+ }
665
+ const settingsPath = path.join(this.settingsDir, "settings.json");
666
+ try {
667
+ if (fs.existsSync(settingsPath)) {
668
+ const raw = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
669
+ if (!raw.profiles) {
670
+ this.settings = {
671
+ activeProfile: "production",
672
+ profiles: {
673
+ production: {
674
+ name: "Production",
675
+ apiKey: raw.ctlsurfApiKey || "",
676
+ baseUrl: raw.ctlsurfBaseUrl || "https://app.ctlsurf.com",
677
+ dataspacePageId: raw.ctlsurfDataspacePageId || ""
678
+ }
679
+ }
680
+ };
681
+ this.saveSettings();
682
+ log$1("[settings] Migrated legacy settings to profiles");
683
+ } else {
684
+ this.settings = raw;
685
+ if (!this.settings.profiles.production) {
686
+ this.settings.profiles.production = { ...DEFAULT_PROFILES.production };
687
+ }
688
+ }
689
+ }
690
+ } catch {
691
+ this.settings = {
692
+ activeProfile: "production",
693
+ profiles: { ...DEFAULT_PROFILES }
694
+ };
695
+ }
696
+ this.applyProfile(this.getActiveProfile());
697
+ }
698
+ saveSettings() {
699
+ const settingsPath = path.join(this.settingsDir, "settings.json");
700
+ try {
701
+ fs.mkdirSync(this.settingsDir, { recursive: true });
702
+ fs.writeFileSync(settingsPath, JSON.stringify(this.settings, null, 2));
703
+ } catch (err) {
704
+ log$1("[settings] Failed to save:", err.message);
705
+ }
706
+ }
707
+ overrideApiKey(key) {
708
+ this.ctlsurfApi.setApiKey(key);
709
+ this.workerWs.setApiKey(key);
710
+ }
711
+ overrideBaseUrl(url) {
712
+ this.ctlsurfApi.setBaseUrl(url);
713
+ this.workerWs.setBaseUrl(url);
714
+ }
715
+ // ─── Profile CRUD ───────────────────────────────
716
+ listProfiles() {
717
+ return {
718
+ activeProfile: this.settings.activeProfile,
719
+ profiles: Object.entries(this.settings.profiles).map(([id, p]) => ({
720
+ id,
721
+ name: p.name,
722
+ baseUrl: p.baseUrl,
723
+ hasApiKey: !!p.apiKey,
724
+ dataspacePageId: p.dataspacePageId || null
725
+ }))
726
+ };
727
+ }
728
+ getProfile(profileId) {
729
+ const p = this.settings.profiles[profileId];
730
+ if (!p) return null;
731
+ return {
732
+ id: profileId,
733
+ name: p.name,
734
+ baseUrl: p.baseUrl,
735
+ hasApiKey: !!p.apiKey,
736
+ dataspacePageId: p.dataspacePageId || ""
737
+ };
738
+ }
739
+ saveProfile(profileId, data) {
740
+ const existing = this.settings.profiles[profileId];
741
+ this.settings.profiles[profileId] = {
742
+ name: data.name,
743
+ apiKey: data.apiKey !== void 0 ? data.apiKey : existing?.apiKey || "",
744
+ baseUrl: data.baseUrl || "https://app.ctlsurf.com",
745
+ dataspacePageId: data.dataspacePageId || ""
746
+ };
747
+ this.saveSettings();
748
+ if (profileId === this.settings.activeProfile) {
749
+ this.applyProfile(this.settings.profiles[profileId]);
750
+ if (this.currentAgent && this.currentCwd) {
751
+ this.workerWs.disconnect();
752
+ this.connectWorkerWs(this.currentAgent, this.currentCwd);
753
+ }
754
+ }
755
+ }
756
+ switchProfile(profileId) {
757
+ if (!this.settings.profiles[profileId]) return { ok: false, error: "Profile not found" };
758
+ this.workerWs.disconnect();
759
+ this.settings.activeProfile = profileId;
760
+ this.saveSettings();
761
+ this.applyProfile(this.getActiveProfile());
762
+ if (this.currentAgent && this.currentCwd) {
763
+ this.connectWorkerWs(this.currentAgent, this.currentCwd);
764
+ }
765
+ return { ok: true };
766
+ }
767
+ deleteProfile(profileId) {
768
+ if (profileId === "production") return { ok: false, error: "Cannot delete Production profile" };
769
+ if (!this.settings.profiles[profileId]) return { ok: false, error: "Profile not found" };
770
+ if (this.settings.activeProfile === profileId) {
771
+ this.workerWs.disconnect();
772
+ this.settings.activeProfile = "production";
773
+ this.applyProfile(this.getActiveProfile());
774
+ if (this.currentAgent && this.currentCwd) {
775
+ this.connectWorkerWs(this.currentAgent, this.currentCwd);
776
+ }
777
+ }
778
+ delete this.settings.profiles[profileId];
779
+ this.saveSettings();
780
+ return { ok: true };
781
+ }
782
+ // ─── PTY & Agent ────────────────────────────────
783
+ async spawnAgent(agent, cwd) {
784
+ if (this.ptyManager) {
785
+ await this.bridge.endSession();
786
+ this.ptyManager.kill();
787
+ }
788
+ this.currentAgent = agent;
789
+ const prevCwd = this.currentCwd;
790
+ this.currentCwd = cwd;
791
+ if (prevCwd !== cwd) {
792
+ this.events.onCwdChanged();
793
+ }
794
+ this.ptyManager = new PtyManager(agent, cwd);
795
+ this.ptyManager.onData((data) => {
796
+ this.events.onPtyData(data);
797
+ this.bridge.feedOutput(data);
798
+ this.streamTerminalData(data);
799
+ });
800
+ const thisPtyManager = this.ptyManager;
801
+ this.ptyManager.onExit(async (exitCode) => {
802
+ this.events.onPtyExit(exitCode);
803
+ await this.bridge.endSession(exitCode);
804
+ if (thisPtyManager === this.ptyManager && this.currentAgent && isCodingAgent(this.currentAgent)) {
805
+ this.workerWs.disconnect();
806
+ }
807
+ });
808
+ const profile = this.getActiveProfile();
809
+ const dataspacePageId = profile.dataspacePageId || process.env.CTLSURF_DATASPACE_PAGE_ID || "";
810
+ if (dataspacePageId && this.ctlsurfApi.getApiKey()) {
811
+ await this.bridge.startSession(dataspacePageId, agent.name, cwd);
812
+ }
813
+ if (isCodingAgent(agent)) {
814
+ this.connectWorkerWs(agent, cwd);
815
+ } else {
816
+ this.workerWs.disconnect();
817
+ this.checkProjectStatus(cwd);
818
+ }
819
+ }
820
+ writePty(data) {
821
+ this.ptyManager?.write(data);
822
+ this.bridge.feedInput(data);
823
+ }
824
+ resizePty(cols, rows) {
825
+ this.ptyManager?.resize(cols, rows);
826
+ this.workerWs.sendTerminalResize(cols, rows);
827
+ }
828
+ async killAgent() {
829
+ await this.bridge.endSession();
830
+ this.ptyManager?.kill();
831
+ this.ptyManager = null;
832
+ if (this.currentAgent && isCodingAgent(this.currentAgent)) {
833
+ this.workerWs.disconnect();
834
+ }
835
+ }
836
+ // ─── Worker WebSocket ───────────────────────────
837
+ connectWorkerWs(agent, cwd) {
838
+ const profile = this.getActiveProfile();
839
+ const apiKey = profile.apiKey || process.env.CTLSURF_API_KEY;
840
+ if (!apiKey) {
841
+ log$1("[worker-ws] No API key, skipping WS connect");
842
+ return;
843
+ }
844
+ this.workerWs.connect({
845
+ machine: os.hostname(),
846
+ cwd,
847
+ agent: agent.name
848
+ });
849
+ }
850
+ async checkProjectStatus(cwd) {
851
+ if (!this.ctlsurfApi.getApiKey()) {
852
+ this.events.onWorkerStatus("no_project");
853
+ return;
854
+ }
855
+ try {
856
+ const folder = await this.ctlsurfApi.findFolderByPath(cwd);
857
+ if (!folder?.id) {
858
+ this.events.onWorkerStatus("no_project");
859
+ }
860
+ } catch {
861
+ this.events.onWorkerStatus("no_project");
862
+ }
863
+ }
864
+ streamTerminalData(data) {
865
+ this.termStreamBuffer += data;
866
+ if (!this.termStreamTimer) {
867
+ this.termStreamTimer = setTimeout(() => {
868
+ if (this.termStreamBuffer) {
869
+ this.workerWs.sendTerminalData(this.termStreamBuffer);
870
+ this.termStreamBuffer = "";
871
+ }
872
+ this.termStreamTimer = null;
873
+ }, TERM_STREAM_INTERVAL_MS);
874
+ }
875
+ }
876
+ // ─── Shutdown ───────────────────────────────────
877
+ async shutdown() {
878
+ await this.bridge.endSession();
879
+ this.ptyManager?.kill();
880
+ this.ptyManager = null;
881
+ this.workerWs.disconnect();
882
+ if (this.termStreamTimer) {
883
+ clearTimeout(this.termStreamTimer);
884
+ this.termStreamTimer = null;
885
+ }
886
+ }
887
+ }
888
+ process.stdout?.on?.("error", () => {
889
+ });
890
+ process.stderr?.on?.("error", () => {
891
+ });
892
+ process.on("uncaughtException", (err) => {
893
+ if (err.message === "write EPIPE") return;
894
+ try {
895
+ console.error("[uncaught]", err);
896
+ } catch {
897
+ }
898
+ });
899
+ function log(...args) {
900
+ try {
901
+ console.log(...args);
902
+ } catch {
903
+ }
904
+ }
905
+ let mainWindow = null;
906
+ const orchestrator = new Orchestrator(
907
+ getSettingsDir(),
908
+ {
909
+ onPtyData: (data) => mainWindow?.webContents.send("pty:data", data),
910
+ onPtyExit: (code) => mainWindow?.webContents.send("pty:exit", code),
911
+ onWorkerStatus: (status) => mainWindow?.webContents.send("worker:status", status),
912
+ onWorkerMessage: (message) => mainWindow?.webContents.send("worker:message", message),
913
+ onWorkerRegistered: (data) => mainWindow?.webContents.send("worker:registered", data),
914
+ onCwdChanged: () => mainWindow?.webContents.send("app:cwdChanged")
915
+ }
916
+ );
917
+ function createWindow() {
918
+ mainWindow = new electron.BrowserWindow({
919
+ width: 1400,
920
+ height: 900,
921
+ minWidth: 600,
922
+ minHeight: 400,
923
+ title: "ctlsurf-worker",
924
+ titleBarStyle: "hiddenInset",
925
+ icon: path.join(__dirname, "../../resources/icon.png"),
926
+ webPreferences: {
927
+ preload: path.join(__dirname, "../preload/index.js"),
928
+ contextIsolation: true,
929
+ nodeIntegration: false,
930
+ webviewTag: true
931
+ }
932
+ });
933
+ if (process.env.ELECTRON_RENDERER_URL) {
934
+ mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
935
+ } else {
936
+ mainWindow.loadFile(path.join(__dirname, "../renderer/index.html"));
937
+ }
938
+ mainWindow.on("closed", () => {
939
+ mainWindow = null;
940
+ });
941
+ }
942
+ electron.ipcMain.handle("pty:spawn", async (_event, agent, cwd) => {
943
+ await orchestrator.spawnAgent(agent, cwd);
944
+ return { ok: true };
945
+ });
946
+ electron.ipcMain.handle("pty:write", (_event, data) => {
947
+ orchestrator.writePty(data);
948
+ });
949
+ electron.ipcMain.handle("pty:resize", (_event, cols, rows) => {
950
+ orchestrator.resizePty(cols, rows);
951
+ });
952
+ electron.ipcMain.handle("pty:kill", async () => {
953
+ await orchestrator.killAgent();
954
+ });
955
+ electron.ipcMain.handle("agents:list", () => getBuiltinAgents());
956
+ electron.ipcMain.handle("agents:default", () => getDefaultAgent());
957
+ electron.ipcMain.handle("app:homePath", () => electron.app.getPath("home"));
958
+ electron.ipcMain.handle("app:cwd", () => process.env.CTLSURF_WORKER_CWD || process.cwd());
959
+ electron.ipcMain.handle("app:browseCwd", async () => {
960
+ if (!mainWindow) return null;
961
+ const result = await electron.dialog.showOpenDialog(mainWindow, {
962
+ properties: ["openDirectory"],
963
+ title: "Select project directory",
964
+ defaultPath: orchestrator.cwd || process.env.HOME || "/"
965
+ });
966
+ if (result.canceled || !result.filePaths[0]) return null;
967
+ return result.filePaths[0];
968
+ });
969
+ electron.ipcMain.handle("fs:readDir", async (_event, dirPath) => {
970
+ try {
971
+ const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
972
+ return entries.filter((e) => !e.name.startsWith(".")).map((e) => ({
973
+ name: e.name,
974
+ path: path.join(dirPath, e.name),
975
+ isDirectory: e.isDirectory()
976
+ })).sort((a, b) => {
977
+ if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
978
+ return a.name.localeCompare(b.name);
979
+ });
980
+ } catch {
981
+ return [];
982
+ }
983
+ });
984
+ electron.ipcMain.handle("fs:readFile", async (_event, filePath) => {
985
+ try {
986
+ const content = await fs.promises.readFile(filePath, "utf-8");
987
+ return { ok: true, content };
988
+ } catch (err) {
989
+ return { ok: false, error: err.message };
990
+ }
991
+ });
992
+ electron.ipcMain.handle("fs:writeFile", async (_event, filePath, content) => {
993
+ try {
994
+ await fs.promises.writeFile(filePath, content, "utf-8");
995
+ return { ok: true };
996
+ } catch (err) {
997
+ return { ok: false, error: err.message };
998
+ }
999
+ });
1000
+ electron.ipcMain.handle("worker:getStatus", () => orchestrator.workerWs.status);
1001
+ electron.ipcMain.handle("worker:getWorkerId", () => orchestrator.workerWs.currentWorkerId);
1002
+ electron.ipcMain.handle("worker:createProject", async () => {
1003
+ const currentCwd = orchestrator.cwd;
1004
+ if (!currentCwd || !orchestrator.ctlsurfApi.getApiKey()) {
1005
+ return { ok: false, error: "No cwd or API key" };
1006
+ }
1007
+ try {
1008
+ const folderName = currentCwd.split("/").filter(Boolean).pop() || "project";
1009
+ const folder = await orchestrator.ctlsurfApi.createFolder({
1010
+ name: folderName,
1011
+ root_path: currentCwd
1012
+ });
1013
+ await orchestrator.ctlsurfApi.createPage({
1014
+ title: folderName,
1015
+ folder_id: folder.id,
1016
+ cwd: currentCwd,
1017
+ tags: ["project"]
1018
+ });
1019
+ if (orchestrator.agent) {
1020
+ orchestrator.connectWorkerWs(orchestrator.agent, currentCwd);
1021
+ }
1022
+ log(`[worker] Created project: ${folderName} (${folder.id})`);
1023
+ return { ok: true, folder_id: folder.id };
1024
+ } catch (err) {
1025
+ log("[worker] Failed to create project:", err.message);
1026
+ return { ok: false, error: err.message };
1027
+ }
1028
+ });
1029
+ electron.ipcMain.handle("worker:getWebviewInfo", async () => {
1030
+ const profile = orchestrator.getActiveProfile();
1031
+ const baseUrl = profile.baseUrl || process.env.CTLSURF_BASE_URL || "https://app.ctlsurf.com";
1032
+ const frontendUrl = baseUrl.includes("localhost:8000") ? baseUrl.replace(":8000", ":88") : baseUrl;
1033
+ if (!orchestrator.ctlsurfApi.getApiKey()) {
1034
+ return { frontendUrl: `${frontendUrl}?embed=1`, authenticated: false };
1035
+ }
1036
+ try {
1037
+ const { code } = await orchestrator.ctlsurfApi.getAuthCode();
1038
+ let pageUrl = `${frontendUrl}?embed=1&_code=${encodeURIComponent(code)}`;
1039
+ const currentCwd = orchestrator.cwd;
1040
+ if (currentCwd) {
1041
+ try {
1042
+ let folder = null;
1043
+ try {
1044
+ folder = await orchestrator.ctlsurfApi.findFolderByPath(currentCwd);
1045
+ } catch {
1046
+ }
1047
+ if (!folder) {
1048
+ try {
1049
+ const { execSync } = require("child_process");
1050
+ const gitRemote = execSync("git remote get-url origin", { cwd: currentCwd, encoding: "utf-8" }).trim();
1051
+ if (gitRemote) {
1052
+ folder = await orchestrator.ctlsurfApi.findFolderByGitRemote(gitRemote);
1053
+ }
1054
+ } catch {
1055
+ }
1056
+ }
1057
+ if (folder?.id) {
1058
+ const pages = await orchestrator.ctlsurfApi.getFolderPages(folder.id);
1059
+ const rootPage = pages?.find((p) => !p.parent_id);
1060
+ if (rootPage?.id) {
1061
+ pageUrl = `${frontendUrl}/page/${rootPage.id}?embed=1&_code=${encodeURIComponent(code)}`;
1062
+ }
1063
+ }
1064
+ } catch {
1065
+ }
1066
+ }
1067
+ return { frontendUrl, pageUrl, authenticated: true };
1068
+ } catch (err) {
1069
+ log("[worker] Failed to exchange tokens:", err.message);
1070
+ return { frontendUrl: `${frontendUrl}?embed=1`, authenticated: false };
1071
+ }
1072
+ });
1073
+ electron.ipcMain.handle("profiles:list", () => orchestrator.listProfiles());
1074
+ electron.ipcMain.handle("profiles:get", (_event, id) => orchestrator.getProfile(id));
1075
+ electron.ipcMain.handle("profiles:save", (_event, id, data) => {
1076
+ orchestrator.saveProfile(id, data);
1077
+ return { ok: true };
1078
+ });
1079
+ electron.ipcMain.handle("profiles:switch", (_event, id) => orchestrator.switchProfile(id));
1080
+ electron.ipcMain.handle("profiles:delete", (_event, id) => orchestrator.deleteProfile(id));
1081
+ electron.ipcMain.handle("settings:get", (_event, key) => {
1082
+ const profile = orchestrator.getActiveProfile();
1083
+ if (key === "ctlsurfApiKey") return profile.apiKey ? "***configured***" : null;
1084
+ if (key === "ctlsurfBaseUrl") return profile.baseUrl || null;
1085
+ if (key === "ctlsurfDataspacePageId") return profile.dataspacePageId || null;
1086
+ return null;
1087
+ });
1088
+ electron.ipcMain.handle("settings:set", (_event, key, value) => {
1089
+ const settings = orchestrator.settingsData;
1090
+ const profile = settings.profiles[settings.activeProfile];
1091
+ if (!profile) return { ok: false };
1092
+ if (key === "ctlsurfApiKey") profile.apiKey = value;
1093
+ else if (key === "ctlsurfBaseUrl") profile.baseUrl = value;
1094
+ else if (key === "ctlsurfDataspacePageId") profile.dataspacePageId = value;
1095
+ orchestrator.saveSettings();
1096
+ orchestrator.applyProfile(profile);
1097
+ if (key === "ctlsurfApiKey" && orchestrator.agent && orchestrator.cwd) {
1098
+ orchestrator.workerWs.disconnect();
1099
+ orchestrator.connectWorkerWs(orchestrator.agent, orchestrator.cwd);
1100
+ }
1101
+ return { ok: true };
1102
+ });
1103
+ electron.ipcMain.handle("settings:getAll", () => {
1104
+ const profile = orchestrator.getActiveProfile();
1105
+ return {
1106
+ ctlsurfApiKey: profile.apiKey ? "***configured***" : null,
1107
+ ctlsurfDataspacePageId: profile.dataspacePageId || null,
1108
+ activeProfile: orchestrator.settingsData.activeProfile
1109
+ };
1110
+ });
1111
+ electron.app.whenReady().then(() => {
1112
+ if (process.platform === "darwin" && electron.app.dock) {
1113
+ const iconPath = path.join(__dirname, "../../resources/icon.png");
1114
+ try {
1115
+ const { nativeImage } = require("electron");
1116
+ electron.app.dock.setIcon(nativeImage.createFromPath(iconPath));
1117
+ } catch {
1118
+ }
1119
+ }
1120
+ orchestrator.loadSettings();
1121
+ createWindow();
1122
+ });
1123
+ electron.app.on("window-all-closed", () => {
1124
+ orchestrator.shutdown();
1125
+ electron.app.quit();
1126
+ });
1127
+ electron.app.on("activate", () => {
1128
+ if (electron.BrowserWindow.getAllWindows().length === 0) {
1129
+ createWindow();
1130
+ }
1131
+ });