@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,366 @@
1
+ /**
2
+ * Terminal UI (TUI) renderer
3
+ *
4
+ * Draws a title bar and status bar around the PTY output area.
5
+ * Uses ANSI escape codes and scroll regions — no external dependencies.
6
+ */
7
+
8
+ const ESC = '\x1b'
9
+ const CSI = `${ESC}[`
10
+
11
+ // Colors (Tokyo Night palette)
12
+ const BG_BAR = `${CSI}48;2;22;22;30m` // #16161e
13
+ const FG_TITLE = `${CSI}38;2;192;202;245m` // #c0caf5
14
+ const FG_DIM = `${CSI}38;2;86;95;137m` // #565f89
15
+ const FG_ACCENT = `${CSI}38;2;122;162;247m` // #7aa2f7
16
+ const FG_GREEN = `${CSI}38;2;158;206;106m` // #9ece6a
17
+ const FG_RED = `${CSI}38;2;247;118;142m` // #f7768e
18
+ const FG_YELLOW = `${CSI}38;2;224;175;104m` // #e0af68
19
+ const FG_WHITE = `${CSI}38;2;169;177;214m` // #a9b1d6
20
+ const BG_MODAL = `${CSI}48;2;31;35;53m` // #1f2335
21
+ const BG_SELECTED = `${CSI}48;2;42;43;61m` // #2a2b3d
22
+ const RESET = `${CSI}0m`
23
+
24
+ export interface TuiState {
25
+ agentName: string
26
+ cwd: string
27
+ wsStatus: string
28
+ workerId: string | null
29
+ mode: string
30
+ }
31
+
32
+ export class Tui {
33
+ private rows: number = 0
34
+ private cols: number = 0
35
+ private state: TuiState = {
36
+ agentName: '',
37
+ cwd: '',
38
+ wsStatus: 'disconnected',
39
+ workerId: null,
40
+ mode: 'terminal',
41
+ }
42
+
43
+ constructor() {
44
+ this.rows = process.stdout.rows || 24
45
+ this.cols = process.stdout.columns || 80
46
+ }
47
+
48
+ /**
49
+ * Initialize the TUI: alternate screen, hide cursor reporting, set scroll region
50
+ */
51
+ init(): void {
52
+ // Alternate screen buffer
53
+ this.write(`${CSI}?1049h`)
54
+ // Set scroll region: lines 2 to (rows - 1), leaving line 1 for title, line rows for status
55
+ this.setScrollRegion()
56
+ // Move cursor to the PTY area
57
+ this.moveToPtyArea()
58
+ // Draw chrome
59
+ this.drawTitleBar()
60
+ this.drawStatusBar()
61
+ }
62
+
63
+ /**
64
+ * Restore terminal to normal state
65
+ */
66
+ destroy(): void {
67
+ // Reset scroll region
68
+ this.write(`${CSI}r`)
69
+ // Leave alternate screen
70
+ this.write(`${CSI}?1049l`)
71
+ // Show cursor
72
+ this.write(`${CSI}?25h`)
73
+ }
74
+
75
+ /**
76
+ * Handle terminal resize
77
+ */
78
+ resize(cols: number, rows: number): void {
79
+ this.cols = cols
80
+ this.rows = rows
81
+ this.setScrollRegion()
82
+ this.drawTitleBar()
83
+ this.drawStatusBar()
84
+ this.moveToPtyArea()
85
+ }
86
+
87
+ /**
88
+ * Get the PTY dimensions (main area minus title + status bars)
89
+ */
90
+ getPtySize(): { cols: number; rows: number } {
91
+ return {
92
+ cols: this.cols,
93
+ rows: Math.max(1, this.rows - 2),
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Update state and redraw bars
99
+ */
100
+ update(partial: Partial<TuiState>): void {
101
+ Object.assign(this.state, partial)
102
+ // Save cursor, draw bars, restore cursor
103
+ this.write(`${CSI}s`) // save cursor
104
+ this.drawTitleBar()
105
+ this.drawStatusBar()
106
+ this.write(`${CSI}u`) // restore cursor
107
+ }
108
+
109
+ /**
110
+ * Write PTY output to the scroll region.
111
+ * Cursor is assumed to be in the PTY area already.
112
+ */
113
+ writePtyData(data: string): void {
114
+ // PTY data goes straight to stdout — it's already in the scroll region
115
+ this.write(data)
116
+ }
117
+
118
+ /**
119
+ * Update the terminal window/tab title via OSC escape sequence.
120
+ * Works in passthrough mode (no chrome) — doesn't conflict with the agent's TUI.
121
+ */
122
+ setTerminalTitle(title: string): void {
123
+ this.write(`${ESC}]0;${title}\x07`)
124
+ }
125
+
126
+ /**
127
+ * Build a title string from current state for the terminal tab.
128
+ */
129
+ updateTerminalTitle(): void {
130
+ const { agentName, wsStatus, cwd } = this.state
131
+ const displayCwd = this.shortenPath(cwd)
132
+ const statusIcon = {
133
+ connected: '\u25CF',
134
+ connecting: '\u25CB',
135
+ disconnected: '\u25CB',
136
+ pending_approval: '\u25CB',
137
+ no_project: '\u25CB',
138
+ }[wsStatus] || '\u25CB'
139
+ const statusLabel = {
140
+ connected: 'Connected',
141
+ connecting: 'Connecting...',
142
+ disconnected: 'Disconnected',
143
+ pending_approval: 'Pending',
144
+ no_project: 'No Project',
145
+ }[wsStatus] || wsStatus
146
+
147
+ this.setTerminalTitle(`ctlsurf \u00B7 ${agentName} \u00B7 ${statusIcon} ${statusLabel} \u00B7 ${displayCwd}`)
148
+ }
149
+
150
+ /**
151
+ * Show an interactive agent picker modal.
152
+ * Returns a promise that resolves with the selected agent index.
153
+ */
154
+ showAgentPicker(agents: { name: string; description: string }[]): Promise<number> {
155
+ return new Promise((resolve) => {
156
+ let selected = 0
157
+ const modalWidth = 44
158
+ const modalHeight = agents.length + 4 // border + title + items + border
159
+ const startCol = Math.max(1, Math.floor((this.cols - modalWidth) / 2))
160
+ const startRow = Math.max(1, Math.floor((this.rows - modalHeight) / 2))
161
+
162
+ // Enter alternate screen if not already
163
+ this.write(`${CSI}?1049h`)
164
+ // Hide cursor
165
+ this.write(`${CSI}?25l`)
166
+
167
+ const drawModal = () => {
168
+ const topBorder = '\u250c' + '\u2500'.repeat(modalWidth - 2) + '\u2510'
169
+ const botBorder = '\u2514' + '\u2500'.repeat(modalWidth - 2) + '\u2518'
170
+ const emptyLine = '\u2502' + ' '.repeat(modalWidth - 2) + '\u2502'
171
+
172
+ // Draw background fill
173
+ for (let r = 0; r < this.rows; r++) {
174
+ this.write(`${CSI}${r + 1};1H${BG_BAR}${CSI}2K${RESET}`)
175
+ }
176
+
177
+ // Draw logo/branding centered
178
+ const brand = 'ctlsurf'
179
+ const brandCol = Math.max(1, Math.floor((this.cols - brand.length) / 2))
180
+ this.write(`${CSI}${startRow - 2};${brandCol}H${FG_ACCENT}${brand}${RESET}`)
181
+
182
+ // Top border
183
+ this.write(`${CSI}${startRow};${startCol}H${BG_MODAL}${FG_DIM}${topBorder}${RESET}`)
184
+
185
+ // Title
186
+ const title = ' Select Agent'
187
+ const titlePad = ' '.repeat(Math.max(0, modalWidth - 2 - title.length))
188
+ this.write(`${CSI}${startRow + 1};${startCol}H${BG_MODAL}${FG_DIM}\u2502${RESET}${BG_MODAL}${FG_TITLE}${title}${titlePad}${FG_DIM}\u2502${RESET}`)
189
+
190
+ // Separator
191
+ const sep = '\u251c' + '\u2500'.repeat(modalWidth - 2) + '\u2524'
192
+ this.write(`${CSI}${startRow + 2};${startCol}H${BG_MODAL}${FG_DIM}${sep}${RESET}`)
193
+
194
+ // Agent items
195
+ for (let i = 0; i < agents.length; i++) {
196
+ const agent = agents[i]
197
+ const row = startRow + 3 + i
198
+ const isSelected = i === selected
199
+ const bg = isSelected ? BG_SELECTED : BG_MODAL
200
+ const pointer = isSelected ? `${FG_ACCENT}\u25B8 ` : ' '
201
+ const nameFg = isSelected ? FG_ACCENT : FG_WHITE
202
+ const descFg = FG_DIM
203
+ const nameStr = agent.name
204
+ const descStr = agent.description ? ` ${FG_DIM}— ${agent.description.slice(0, 20)}` : ''
205
+ const content = `${pointer}${nameFg}${nameStr}${descStr}`
206
+ const contentLen = (isSelected ? 2 : 2) + nameStr.length + (agent.description ? 3 + Math.min(20, agent.description.length) : 0)
207
+ const pad = ' '.repeat(Math.max(0, modalWidth - 2 - contentLen))
208
+ this.write(`${CSI}${row};${startCol}H${bg}${FG_DIM}\u2502${RESET}${bg}${content}${pad}${RESET}${BG_MODAL}${FG_DIM}\u2502${RESET}`)
209
+ }
210
+
211
+ // Bottom border
212
+ const botRow = startRow + 3 + agents.length
213
+ this.write(`${CSI}${botRow};${startCol}H${BG_MODAL}${FG_DIM}${botBorder}${RESET}`)
214
+
215
+ // Hint
216
+ const hint = '\u2191\u2193 navigate · Enter select · q quit'
217
+ const hintCol = Math.max(1, Math.floor((this.cols - hint.length) / 2))
218
+ this.write(`${CSI}${botRow + 2};${hintCol}H${FG_DIM}${hint}${RESET}`)
219
+ }
220
+
221
+ drawModal()
222
+
223
+ // Set raw mode to capture keypresses
224
+ if (process.stdin.isTTY) {
225
+ process.stdin.setRawMode(true)
226
+ }
227
+ process.stdin.resume()
228
+
229
+ const onKey = (data: Buffer) => {
230
+ const key = data.toString()
231
+
232
+ if (key === '\x1b[A' || key === 'k') {
233
+ // Up
234
+ selected = (selected - 1 + agents.length) % agents.length
235
+ drawModal()
236
+ } else if (key === '\x1b[B' || key === 'j') {
237
+ // Down
238
+ selected = (selected + 1) % agents.length
239
+ drawModal()
240
+ } else if (key === '\r' || key === '\n') {
241
+ // Enter — select
242
+ cleanup()
243
+ resolve(selected)
244
+ } else if (key === 'q' || key === '\x1b' || key === '\x03') {
245
+ // q, Escape, Ctrl+C — quit
246
+ cleanup()
247
+ this.write(`${CSI}?25h`) // show cursor
248
+ this.write(`${CSI}?1049l`) // leave alt screen
249
+ process.exit(0)
250
+ }
251
+ }
252
+
253
+ const cleanup = () => {
254
+ process.stdin.removeListener('data', onKey)
255
+ // Show cursor again
256
+ this.write(`${CSI}?25h`)
257
+ // Clear the modal (will be redrawn by init())
258
+ }
259
+
260
+ process.stdin.on('data', onKey)
261
+ })
262
+ }
263
+
264
+ // ─── Internal ───────────────────────────────────
265
+
266
+ private write(data: string): void {
267
+ try {
268
+ process.stdout.write(data)
269
+ } catch { /* EPIPE safe */ }
270
+ }
271
+
272
+ private setScrollRegion(): void {
273
+ // Scroll region from line 2 to line (rows - 1)
274
+ this.write(`${CSI}2;${this.rows - 1}r`)
275
+ }
276
+
277
+ private moveToPtyArea(): void {
278
+ // Move cursor to top of PTY area (line 2)
279
+ this.write(`${CSI}2;1H`)
280
+ }
281
+
282
+ private drawTitleBar(): void {
283
+ const { agentName, cwd, wsStatus } = this.state
284
+ // Move to line 1
285
+ this.write(`${CSI}1;1H`)
286
+ // Clear line and draw
287
+ this.write(`${BG_BAR}${CSI}2K`)
288
+
289
+ const displayCwd = this.shortenPath(cwd)
290
+
291
+ // WS status indicator
292
+ const statusColor = {
293
+ connected: FG_GREEN,
294
+ connecting: FG_YELLOW,
295
+ disconnected: FG_RED,
296
+ pending_approval: FG_YELLOW,
297
+ no_project: FG_DIM,
298
+ }[wsStatus] || FG_DIM
299
+ const statusLabel = {
300
+ connected: 'Connected',
301
+ connecting: 'Connecting...',
302
+ disconnected: 'Disconnected',
303
+ pending_approval: 'Pending',
304
+ no_project: 'No Project',
305
+ }[wsStatus] || wsStatus
306
+ const wsIndicator = `${statusColor}\u25CF ${statusLabel}${RESET}${BG_BAR}`
307
+
308
+ const left = ` ${FG_ACCENT}ctlsurf${RESET}${BG_BAR} ${FG_DIM}\u2502${RESET}${BG_BAR} ${FG_TITLE}${agentName || 'starting...'}${RESET}${BG_BAR} ${FG_DIM}\u2502${RESET}${BG_BAR} ${FG_DIM}${displayCwd}${RESET}${BG_BAR}`
309
+ const right = `${wsIndicator} ${RESET}${BG_BAR}`
310
+
311
+ this.write(left)
312
+ const pad = Math.max(0, this.cols - this.visibleLen(left) - this.visibleLen(right))
313
+ this.write(' '.repeat(pad))
314
+ this.write(right)
315
+ this.write(RESET)
316
+ }
317
+
318
+ private drawStatusBar(): void {
319
+ const { wsStatus, workerId, cwd } = this.state
320
+ // Move to last line
321
+ this.write(`${CSI}${this.rows};1H`)
322
+ // Clear line and draw
323
+ this.write(`${BG_BAR}${CSI}2K`)
324
+
325
+ const statusColor = {
326
+ connected: FG_GREEN,
327
+ connecting: FG_YELLOW,
328
+ disconnected: FG_RED,
329
+ pending_approval: FG_YELLOW,
330
+ no_project: FG_DIM,
331
+ }[wsStatus] || FG_DIM
332
+
333
+ const statusDot = `${statusColor}\u25CF${RESET}${BG_BAR}`
334
+ const statusLabel = {
335
+ connected: 'Connected',
336
+ connecting: 'Connecting...',
337
+ disconnected: 'Disconnected',
338
+ pending_approval: 'Pending Approval',
339
+ no_project: 'No Project',
340
+ }[wsStatus] || wsStatus
341
+
342
+ const displayCwd = this.shortenPath(cwd)
343
+ const left = ` ${statusDot} ${FG_DIM}${statusLabel}${RESET}${BG_BAR}`
344
+ const right = `${FG_DIM}Ctrl+\\ exit${RESET}${BG_BAR} ${FG_DIM}${displayCwd} ${RESET}${BG_BAR}`
345
+
346
+ this.write(left)
347
+ const pad = Math.max(0, this.cols - this.visibleLen(left) - this.visibleLen(right))
348
+ this.write(' '.repeat(pad))
349
+ this.write(right)
350
+ this.write(RESET)
351
+ }
352
+
353
+ private shortenPath(p: string): string {
354
+ if (!p) return ''
355
+ const home = process.env.HOME || ''
356
+ if (home && p.startsWith(home)) {
357
+ return '~' + p.slice(home.length)
358
+ }
359
+ return p
360
+ }
361
+
362
+ private visibleLen(s: string): number {
363
+ // Strip ANSI codes to get visible length
364
+ return s.replace(/\x1b\[[^m]*m/g, '').length
365
+ }
366
+ }
@@ -0,0 +1,312 @@
1
+ import os from 'os'
2
+ import crypto from 'crypto'
3
+
4
+ function log(...args: unknown[]): void {
5
+ try { console.log(...args) } catch { /* EPIPE safe */ }
6
+ }
7
+
8
+ const HEARTBEAT_INTERVAL_MS = 30_000
9
+ const RECONNECT_DELAY_MS = 5_000
10
+ const MAX_RECONNECT_DELAY_MS = 60_000
11
+
12
+ export interface WorkerRegistration {
13
+ machine: string
14
+ cwd: string
15
+ repo_url?: string
16
+ agent: string
17
+ fingerprint: string
18
+ }
19
+
20
+ export interface WorkerWsEvents {
21
+ onStatusChange: (status: WorkerWsStatus) => void
22
+ onMessage: (message: IncomingMessage) => void
23
+ onRegistered: (data: { worker_id: string; folder_id: string | null; status: string; pending_messages?: IncomingMessage[] }) => void
24
+ onTerminalInput?: (data: string) => void
25
+ }
26
+
27
+ export interface IncomingMessage {
28
+ id: string
29
+ type: string
30
+ content: string
31
+ metadata?: Record<string, unknown> | null
32
+ parent_id?: string | null
33
+ }
34
+
35
+ export type WorkerWsStatus = 'disconnected' | 'connecting' | 'connected' | 'pending_approval'
36
+
37
+ export class WorkerWsClient {
38
+ private ws: WebSocket | null = null
39
+ private apiKey: string | null = null
40
+ private baseUrl: string
41
+ private events: WorkerWsEvents
42
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null
43
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
44
+ private reconnectDelay = RECONNECT_DELAY_MS
45
+ private registration: WorkerRegistration | null = null
46
+ private workerId: string | null = null
47
+ private _status: WorkerWsStatus = 'disconnected'
48
+ private shouldReconnect = false
49
+ private fingerprint: string
50
+
51
+ constructor(events: WorkerWsEvents, baseUrl?: string) {
52
+ this.events = events
53
+ this.baseUrl = baseUrl || 'wss://app.ctlsurf.com'
54
+ // Generate a stable machine fingerprint
55
+ this.fingerprint = this.generateFingerprint()
56
+ }
57
+
58
+ get status(): WorkerWsStatus {
59
+ return this._status
60
+ }
61
+
62
+ get currentWorkerId(): string | null {
63
+ return this.workerId
64
+ }
65
+
66
+ setApiKey(key: string | null): void {
67
+ this.apiKey = key
68
+ }
69
+
70
+ setBaseUrl(url: string): void {
71
+ this.baseUrl = url
72
+ }
73
+
74
+ private generateFingerprint(): string {
75
+ const data = `${os.hostname()}:${os.userInfo().username}:${os.platform()}:${os.arch()}`
76
+ return crypto.createHash('sha256').update(data).digest('hex').slice(0, 32)
77
+ }
78
+
79
+ private setStatus(status: WorkerWsStatus): void {
80
+ if (this._status !== status) {
81
+ this._status = status
82
+ this.events.onStatusChange(status)
83
+ }
84
+ }
85
+
86
+ connect(registration: WorkerRegistration): void {
87
+ this.registration = { ...registration, fingerprint: this.fingerprint }
88
+ this.shouldReconnect = true
89
+ this.doConnect()
90
+ }
91
+
92
+ disconnect(): void {
93
+ this.shouldReconnect = false
94
+ this.clearTimers()
95
+ if (this.ws) {
96
+ const oldWs = this.ws
97
+ this.ws = null
98
+ // Remove handlers before closing to prevent stale onclose from firing
99
+ oldWs.onopen = null
100
+ oldWs.onmessage = null
101
+ oldWs.onclose = null
102
+ oldWs.onerror = null
103
+ try { oldWs.close(1000, 'client disconnect') } catch { /* ignore */ }
104
+ }
105
+ this.setStatus('disconnected')
106
+ }
107
+
108
+ sendResponse(parentId: string, content: string, metadata?: Record<string, unknown>): void {
109
+ this.send({
110
+ type: 'response',
111
+ parent_id: parentId,
112
+ content,
113
+ metadata,
114
+ })
115
+ }
116
+
117
+ sendStatusUpdate(status: string): void {
118
+ this.send({ type: 'status_update', status })
119
+ }
120
+
121
+ sendAck(messageId: string): void {
122
+ this.send({ type: 'ack', message_id: messageId })
123
+ }
124
+
125
+ sendTerminalData(data: string): void {
126
+ this.send({ type: 'terminal_stream', data })
127
+ }
128
+
129
+ sendTerminalResize(cols: number, rows: number): void {
130
+ this.send({ type: 'terminal_resize', cols, rows })
131
+ }
132
+
133
+ private doConnect(): void {
134
+ if (!this.apiKey || !this.registration) {
135
+ log('[worker-ws] No API key or registration, skipping connect')
136
+ return
137
+ }
138
+
139
+ this.clearTimers()
140
+ if (this.ws) {
141
+ const oldWs = this.ws
142
+ this.ws = null
143
+ oldWs.onopen = null
144
+ oldWs.onmessage = null
145
+ oldWs.onclose = null
146
+ oldWs.onerror = null
147
+ try { oldWs.close() } catch { /* ignore */ }
148
+ // Let the old connection fully close before opening a new one
149
+ setTimeout(() => this.doConnectNow(), 500)
150
+ return
151
+ }
152
+
153
+ this.doConnectNow()
154
+ }
155
+
156
+ private doConnectNow(): void {
157
+ if (!this.apiKey || !this.registration) return
158
+ if (!this.shouldReconnect) {
159
+ log('[worker-ws] shouldReconnect is false, aborting connect')
160
+ return
161
+ }
162
+
163
+ this.setStatus('connecting')
164
+
165
+ // Use ws:// for localhost, wss:// for remote
166
+ const wsBase = this.baseUrl.replace(/^http/, 'ws')
167
+ const url = `${wsBase}/api/ws/worker?token=${encodeURIComponent(this.apiKey)}`
168
+
169
+ log(`[worker-ws] Connecting to ${url.replace(/token=.*/, 'token=***')}...`)
170
+
171
+ try {
172
+ this.ws = new WebSocket(url)
173
+ } catch (err) {
174
+ log('[worker-ws] Failed to create WebSocket:', err)
175
+ this.scheduleReconnect()
176
+ return
177
+ }
178
+
179
+ this.ws.onopen = () => {
180
+ log('[worker-ws] Connected, sending register')
181
+ this.reconnectDelay = RECONNECT_DELAY_MS // Reset backoff
182
+ this.send({
183
+ type: 'register',
184
+ ...this.registration,
185
+ })
186
+ this.startHeartbeat()
187
+ }
188
+
189
+ this.ws.onmessage = (event) => {
190
+ try {
191
+ const data = JSON.parse(String(event.data))
192
+ this.handleMessage(data)
193
+ } catch (err) {
194
+ log('[worker-ws] Failed to parse message:', err)
195
+ }
196
+ }
197
+
198
+ this.ws.onclose = (event) => {
199
+ log(`[worker-ws] Disconnected: ${event.code} ${event.reason}`)
200
+ this.ws = null
201
+ this.clearHeartbeat()
202
+ this.setStatus('disconnected')
203
+ if (this.shouldReconnect) {
204
+ this.scheduleReconnect()
205
+ }
206
+ }
207
+
208
+ this.ws.onerror = () => {
209
+ log('[worker-ws] WebSocket error')
210
+ }
211
+ }
212
+
213
+ private handleMessage(data: Record<string, unknown>): void {
214
+ const msgType = data.type as string
215
+
216
+ switch (msgType) {
217
+ case 'registered': {
218
+ this.workerId = data.worker_id as string
219
+ const workerStatus = data.status as string
220
+ console.log(`[worker-ws] Registered as ${this.workerId}, status: ${workerStatus}`)
221
+
222
+ if (workerStatus === 'pending_approval') {
223
+ this.setStatus('pending_approval')
224
+ } else {
225
+ this.setStatus('connected')
226
+ }
227
+
228
+ const pendingMessages = (data.pending_messages || []) as IncomingMessage[]
229
+ this.events.onRegistered({
230
+ worker_id: this.workerId,
231
+ folder_id: data.folder_id as string | null,
232
+ status: workerStatus,
233
+ pending_messages: pendingMessages,
234
+ })
235
+
236
+ // Deliver pending messages
237
+ for (const msg of pendingMessages) {
238
+ this.events.onMessage(msg)
239
+ }
240
+ break
241
+ }
242
+
243
+ case 'approved': {
244
+ log('[worker-ws] Worker approved!')
245
+ this.setStatus('connected')
246
+ break
247
+ }
248
+
249
+ case 'message': {
250
+ const msg = data.message as IncomingMessage
251
+ if (msg) {
252
+ console.log(`[worker-ws] Received message: ${msg.id}`)
253
+ this.events.onMessage(msg)
254
+ }
255
+ break
256
+ }
257
+
258
+ case 'terminal_input': {
259
+ const inputData = data.data as string
260
+ if (inputData && this.events.onTerminalInput) {
261
+ this.events.onTerminalInput(inputData)
262
+ }
263
+ break
264
+ }
265
+
266
+ case 'heartbeat_ack':
267
+ break
268
+
269
+ default:
270
+ console.log(`[worker-ws] Unknown message type: ${msgType}`)
271
+ }
272
+ }
273
+
274
+ private send(data: Record<string, unknown>): void {
275
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
276
+ this.ws.send(JSON.stringify(data))
277
+ }
278
+ }
279
+
280
+
281
+ private startHeartbeat(): void {
282
+ this.clearHeartbeat()
283
+ this.heartbeatTimer = setInterval(() => {
284
+ this.send({ type: 'heartbeat' })
285
+ }, HEARTBEAT_INTERVAL_MS)
286
+ }
287
+
288
+ private clearHeartbeat(): void {
289
+ if (this.heartbeatTimer) {
290
+ clearInterval(this.heartbeatTimer)
291
+ this.heartbeatTimer = null
292
+ }
293
+ }
294
+
295
+ private scheduleReconnect(): void {
296
+ if (!this.shouldReconnect) return
297
+ console.log(`[worker-ws] Reconnecting in ${this.reconnectDelay / 1000}s...`)
298
+ this.reconnectTimer = setTimeout(() => {
299
+ this.doConnect()
300
+ }, this.reconnectDelay)
301
+ // Exponential backoff
302
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY_MS)
303
+ }
304
+
305
+ private clearTimers(): void {
306
+ this.clearHeartbeat()
307
+ if (this.reconnectTimer) {
308
+ clearTimeout(this.reconnectTimer)
309
+ this.reconnectTimer = null
310
+ }
311
+ }
312
+ }