agent-vx 0.1.0__py3-none-any.whl

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.
agentaix/gui.py ADDED
@@ -0,0 +1,1321 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import queue
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ import threading
10
+ import time
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ import tkinter as tk
14
+ from tkinter import scrolledtext
15
+
16
+ from playwright.async_api import (
17
+ Page,
18
+ TimeoutError as PlaywrightTimeoutError,
19
+ async_playwright,
20
+ )
21
+
22
+
23
+ # ----------------------------------------------------------------------
24
+ # Core constants (unchanged)
25
+ # ----------------------------------------------------------------------
26
+
27
+ CHATGPT_URL = "https://chatgpt.com/"
28
+ PROFILE_DIR = Path.home() / ".chatgpt_terminal_agent"
29
+ OUTPUT_DIR = Path.cwd() / "agent_output"
30
+
31
+ DEFAULT_TIMEOUT = 30_000
32
+ RESPONSE_TIMEOUT = 600_000
33
+ NAVIGATION_TIMEOUT = 120_000
34
+ MAX_RETRIES = 3
35
+
36
+ # Non-markdown markers (*** would be eaten by bold+italic rendering).
37
+ START_MARKER = "@@@START@@@"
38
+ END_MARKER = "@@@END@@@"
39
+
40
+
41
+ SYSTEM_PROMPT = r"""
42
+ You are a production-grade Python coding agent.
43
+
44
+ The user will give you a programming request.
45
+
46
+ You MUST return exactly ONE complete Python file that fulfils the request.
47
+
48
+ STRICT RULES:
49
+
50
+ 1. No introduction.
51
+ 2. No explanation.
52
+ 3. No conclusion.
53
+ 4. No conversational text.
54
+ 5. No "Sure", "Here is", or similar phrases.
55
+ 6. Return COMPLETE working code.
56
+ 7. Never return pseudocode.
57
+ 8. Never use TODO placeholders.
58
+ 9. Include every required import.
59
+ 10. NEVER return multiple files, markdown tables, or prose.
60
+ 11. The output must be a SINGLE self-contained Python file.
61
+ 12. If third-party packages are needed, the Python file MUST
62
+ install them itself at runtime using pip (via subprocess with
63
+ sys.executable -m pip) and only install what is missing.
64
+ 13. Do NOT emit a separate INSTALL block - the Python file handles
65
+ installation on its own.
66
+ 14. If the task requires OS-level setup on Windows (registry
67
+ changes, service install, scheduled task, persistent
68
+ environment variable, firewall rule, etc.), the Python file
69
+ MUST contain the PowerShell script as a Python string, WRITE
70
+ it to a temporary .ps1 file, run it via subprocess, then
71
+ delete the .ps1 file afterwards.
72
+ 15. Do not hard-code secrets.
73
+ 16. Use secure and practical defaults.
74
+ 17. Make the Python file runnable as `python the_file.py` with no
75
+ arguments required.
76
+ 18. If arguments are useful, provide sensible defaults via
77
+ argparse.
78
+ 19. Never wrap the code in anything other than a single python
79
+ fenced block.
80
+
81
+ RESPONSE FORMAT (very important).
82
+
83
+ Your reply must have exactly this shape, in this order, and nothing
84
+ else. Do NOT write out the words in square brackets literally -
85
+ produce the actual characters they describe.
86
+
87
+ - Line 1: three at-sign characters, then the word START, then three
88
+ at-sign characters. (In other words: the character @ appears three
89
+ times, immediately followed by START, immediately followed by @
90
+ three more times. No spaces. Nothing before it.)
91
+
92
+ - Line 2: three backticks, then the word python (this is the opening
93
+ of a fenced code block).
94
+
95
+ - Then: the complete, real, working Python source code of the single
96
+ file the user asked for. Write ACTUAL code. Do NOT write any
97
+ placeholder text such as "full python file here" or similar.
98
+
99
+ - Then: three backticks on their own line (this closes the fenced
100
+ code block).
101
+
102
+ - Final line: three at-sign characters, then the word END, then three
103
+ at-sign characters. (Same pattern as the first line but with END.)
104
+
105
+ Your response is INVALID if it contains anything before the first
106
+ line or after the final line, or if it contains any explanation,
107
+ heading, greeting, or prose.
108
+ """
109
+
110
+
111
+ # ----------------------------------------------------------------------
112
+ # UI palette (red-on-black techy)
113
+ # ----------------------------------------------------------------------
114
+
115
+ UI_BG = "#0a0000" # window base
116
+ UI_BG_DEEP = "#050000" # log / entry background
117
+ UI_FG = "#ff1a1a" # primary red text
118
+ UI_FG_DIM = "#8a1010" # dim red
119
+ UI_FG_BRIGHT = "#ff4444" # bright red
120
+ UI_ACCENT = "#ff0033" # accent / cursor
121
+ UI_BORDER = "#3a0000" # divider / outline
122
+ UI_OK = "#00ff66" # success
123
+ UI_WARN = "#ff8800" # status / progress
124
+ UI_ERR = "#ff0033" # error
125
+ UI_OUT = "#cc4444" # program output
126
+ UI_USER = "#ffaaaa" # user input echo
127
+
128
+ UI_FONT = ("Consolas", 10)
129
+ UI_FONT_BOLD = ("Consolas", 10, "bold")
130
+ UI_FONT_SMALL = ("Consolas", 9)
131
+ UI_FONT_TITLE = ("Consolas", 22, "bold")
132
+ UI_FONT_GLYPH = ("Consolas", 12, "bold")
133
+
134
+
135
+ # ----------------------------------------------------------------------
136
+ # CLI helpers (unchanged)
137
+ # ----------------------------------------------------------------------
138
+
139
+ def parse_args():
140
+ parser = argparse.ArgumentParser(
141
+ description="Terminal ChatGPT coding agent"
142
+ )
143
+
144
+ parser.add_argument(
145
+ "prompt",
146
+ nargs="*",
147
+ help="Programming request (one-shot mode)"
148
+ )
149
+
150
+ parser.add_argument(
151
+ "-f",
152
+ "--file",
153
+ help="Read request from a text file (one-shot mode)"
154
+ )
155
+
156
+ parser.add_argument(
157
+ "-o",
158
+ "--output",
159
+ default=str(OUTPUT_DIR / "response.txt"),
160
+ help="Save full response here (one-shot mode)"
161
+ )
162
+
163
+ parser.add_argument(
164
+ "--code-output",
165
+ default=str(OUTPUT_DIR / "code.txt"),
166
+ help="Save extracted code here (one-shot mode)"
167
+ )
168
+
169
+ parser.add_argument(
170
+ "--browser",
171
+ choices=("chromium", "chrome", "msedge"),
172
+ default="chromium"
173
+ )
174
+
175
+ parser.add_argument(
176
+ "--new-session",
177
+ action="store_true"
178
+ )
179
+
180
+ parser.add_argument(
181
+ "--timeout",
182
+ type=int,
183
+ default=DEFAULT_TIMEOUT
184
+ )
185
+
186
+ parser.add_argument(
187
+ "--response-timeout",
188
+ type=int,
189
+ default=RESPONSE_TIMEOUT
190
+ )
191
+
192
+ return parser.parse_args()
193
+
194
+
195
+ def get_one_shot_request(args) -> str:
196
+ if args.file:
197
+ path = Path(args.file)
198
+
199
+ if not path.exists():
200
+ raise FileNotFoundError(
201
+ f"Request file not found: {path}"
202
+ )
203
+
204
+ request = path.read_text(
205
+ encoding="utf-8"
206
+ ).strip()
207
+
208
+ if not request:
209
+ raise ValueError(
210
+ "Request file is empty."
211
+ )
212
+
213
+ return request
214
+
215
+ request = " ".join(args.prompt).strip()
216
+
217
+ if not request:
218
+ raise ValueError(
219
+ "No request supplied."
220
+ )
221
+
222
+ return request
223
+
224
+
225
+ def get_interactive_request() -> str:
226
+ print()
227
+ print("Enter your request (single line).")
228
+ print("Type 'exit' or 'quit' to stop.")
229
+ print()
230
+
231
+ try:
232
+ line = input("request> ")
233
+ except EOFError:
234
+ return ""
235
+
236
+ line = line.strip()
237
+
238
+ if line.lower() in ("exit", "quit", ""):
239
+ return ""
240
+
241
+ return line
242
+
243
+
244
+ def build_prompt(request: str) -> str:
245
+ return (
246
+ SYSTEM_PROMPT
247
+ + "\n\n"
248
+ + "USER REQUEST:\n"
249
+ + request
250
+ )
251
+
252
+
253
+ # ----------------------------------------------------------------------
254
+ # Input-box discovery (used by both navigation and sending)
255
+ # ----------------------------------------------------------------------
256
+
257
+ MESSAGE_BOX_SELECTORS = [
258
+ 'textarea[data-testid="textbox"]',
259
+ 'textarea[placeholder*="Message"]',
260
+ 'textarea[placeholder*="message"]',
261
+ 'textarea',
262
+ '[contenteditable="true"][role="textbox"]',
263
+ '[contenteditable="true"]',
264
+ ]
265
+
266
+
267
+ async def visible_locator(
268
+ page: Page,
269
+ selectors: list[str],
270
+ timeout: int = 1500
271
+ ):
272
+ for selector in selectors:
273
+ try:
274
+ locator = page.locator(selector).first
275
+
276
+ if await locator.is_visible(timeout=timeout):
277
+ return locator
278
+
279
+ except Exception:
280
+ pass
281
+
282
+ return None
283
+
284
+
285
+ async def find_message_box(
286
+ page: Page,
287
+ timeout: int
288
+ ):
289
+ deadline = time.monotonic() + timeout / 1000
290
+
291
+ while time.monotonic() < deadline:
292
+ box = await visible_locator(page, MESSAGE_BOX_SELECTORS)
293
+
294
+ if box:
295
+ return box
296
+
297
+ await page.wait_for_timeout(500)
298
+
299
+ return None
300
+
301
+
302
+ async def find_send_button(page: Page):
303
+ selectors = [
304
+ 'button[data-testid="send-button"]',
305
+ 'button[aria-label="Send prompt"]',
306
+ 'button[aria-label*="Send"]',
307
+ 'button:has-text("Send")',
308
+ ]
309
+
310
+ return await visible_locator(page, selectors)
311
+
312
+
313
+ async def detect_blocked_page(page: Page):
314
+ try:
315
+ body = (
316
+ await page.locator("body").inner_text(timeout=3000)
317
+ ).lower()
318
+ except Exception:
319
+ body = ""
320
+
321
+ captcha = (
322
+ "captcha",
323
+ "verify you are human",
324
+ "verify you're human",
325
+ "security check",
326
+ )
327
+
328
+ blocked = (
329
+ "access denied",
330
+ "request blocked",
331
+ "unusual activity",
332
+ )
333
+
334
+ login = (
335
+ "log in to chatgpt",
336
+ "sign in to chatgpt",
337
+ "create an account",
338
+ )
339
+
340
+ if any(x in body for x in captcha):
341
+ return "CAPTCHA/security verification detected."
342
+
343
+ if any(x in body for x in blocked):
344
+ return "Access/security block detected."
345
+
346
+ if any(x in body for x in login):
347
+ box = await find_message_box(page, 1000)
348
+
349
+ if box is None:
350
+ return (
351
+ "ChatGPT requires authentication "
352
+ "or logged-out chat is unavailable."
353
+ )
354
+
355
+ return None
356
+
357
+
358
+ async def launch_context(playwright, args):
359
+ # Not headless - but launched minimized so it stays out of the way.
360
+ launch_options = {
361
+ "headless": False,
362
+ "args": [
363
+ "--start-minimized",
364
+ "--window-position=-32000,-32000",
365
+ "--window-size=200,200",
366
+ ],
367
+ }
368
+
369
+ if args.browser == "chrome":
370
+ launch_options["channel"] = "chrome"
371
+
372
+ elif args.browser == "msedge":
373
+ launch_options["channel"] = "msedge"
374
+
375
+ if args.new_session:
376
+ browser = await playwright.chromium.launch(**launch_options)
377
+
378
+ context = await browser.new_context(
379
+ viewport={
380
+ "width": 1440,
381
+ "height": 900,
382
+ },
383
+ locale="en-US",
384
+ )
385
+
386
+ return browser, context
387
+
388
+ PROFILE_DIR.mkdir(parents=True, exist_ok=True)
389
+
390
+ context = (
391
+ await playwright.chromium.launch_persistent_context(
392
+ user_data_dir=str(PROFILE_DIR),
393
+ viewport={
394
+ "width": 1440,
395
+ "height": 900,
396
+ },
397
+ locale="en-US",
398
+ **launch_options,
399
+ )
400
+ )
401
+
402
+ return None, context
403
+
404
+
405
+ # ----------------------------------------------------------------------
406
+ # Screen text helpers
407
+ # ----------------------------------------------------------------------
408
+
409
+ async def get_screen_text(page: Page) -> str:
410
+ """Return all visible text on the page as a single string."""
411
+ try:
412
+ return await page.locator("body").inner_text(timeout=5000)
413
+ except Exception:
414
+ return ""
415
+
416
+
417
+ def count_marker(text: str, marker: str) -> int:
418
+ return text.count(marker)
419
+
420
+
421
+ def extract_between_markers(text: str) -> str:
422
+ """
423
+ Return the text between the LAST @@@START@@@ and the LAST @@@END@@@.
424
+ """
425
+ if not text:
426
+ return ""
427
+
428
+ start = text.rfind(START_MARKER)
429
+ end = text.rfind(END_MARKER)
430
+
431
+ if start == -1 or end == -1 or end <= start:
432
+ return ""
433
+
434
+ return text[start + len(START_MARKER):end].strip()
435
+
436
+
437
+ # ----------------------------------------------------------------------
438
+ # Send
439
+ # ----------------------------------------------------------------------
440
+
441
+ async def send_prompt(page: Page, prompt: str, timeout: int):
442
+ box = await find_message_box(page, timeout)
443
+
444
+ if box is None:
445
+ try:
446
+ await page.screenshot(path="debug_no_input.png", full_page=True)
447
+ print("[!] Screenshot saved: debug_no_input.png")
448
+ except Exception:
449
+ pass
450
+ raise RuntimeError(
451
+ f"Could not find ChatGPT input. URL={page.url}"
452
+ )
453
+
454
+ await box.click()
455
+ await page.wait_for_timeout(150)
456
+
457
+ try:
458
+ await page.keyboard.insert_text(prompt)
459
+ except Exception:
460
+ try:
461
+ await box.fill(prompt)
462
+ except Exception:
463
+ await page.keyboard.type(prompt, delay=1)
464
+
465
+ await page.wait_for_timeout(400)
466
+
467
+ await box.press("Enter")
468
+
469
+ await page.wait_for_timeout(600)
470
+
471
+
472
+ # ----------------------------------------------------------------------
473
+ # Navigate to a fresh chat (new per iteration)
474
+ # ----------------------------------------------------------------------
475
+
476
+ async def open_new_chat(page: Page, timeout_ms: int):
477
+ """
478
+ Navigate to the ChatGPT root and wait until the input box is
479
+ actually present. Returns the input box locator.
480
+ """
481
+ try:
482
+ await page.goto(
483
+ CHATGPT_URL,
484
+ wait_until="domcontentloaded",
485
+ timeout=NAVIGATION_TIMEOUT,
486
+ )
487
+ except PlaywrightTimeoutError:
488
+ pass
489
+
490
+ # Wait for the React app to hydrate and render the input box.
491
+ box = await find_message_box(page, timeout_ms)
492
+
493
+ if box is None:
494
+ try:
495
+ await page.screenshot(
496
+ path="debug_no_input.png", full_page=True
497
+ )
498
+ print("[!] Screenshot saved: debug_no_input.png")
499
+ except Exception:
500
+ pass
501
+ raise RuntimeError(
502
+ f"Input box never appeared after navigation. URL={page.url}"
503
+ )
504
+
505
+ return box
506
+
507
+
508
+ # ----------------------------------------------------------------------
509
+ # Wait for response
510
+ # ----------------------------------------------------------------------
511
+
512
+ async def wait_for_response(
513
+ page: Page,
514
+ timeout_ms: int,
515
+ baseline_end_count: int,
516
+ ):
517
+ """
518
+ Wait until at least ONE new @@@END@@@ marker appears on screen
519
+ (from the AI reply), then extract the text between the last
520
+ @@@START@@@ and the last @@@END@@@.
521
+ """
522
+ start = time.monotonic()
523
+ last_debug = -1
524
+ target = baseline_end_count + 1
525
+
526
+ print(
527
+ f"[*] Waiting for reply (baseline ENDs={baseline_end_count}, "
528
+ f"target={target})..."
529
+ )
530
+
531
+ while True:
532
+ elapsed = (time.monotonic() - start) * 1000
533
+
534
+ if elapsed > timeout_ms:
535
+ try:
536
+ await page.screenshot(
537
+ path="debug_timeout.png", full_page=True
538
+ )
539
+ print("[!] Screenshot saved: debug_timeout.png")
540
+ except Exception:
541
+ pass
542
+ raise TimeoutError(
543
+ "Timed out waiting for the reply. "
544
+ "See debug_timeout.png."
545
+ )
546
+
547
+ text = await get_screen_text(page)
548
+ end_count = count_marker(text, END_MARKER)
549
+
550
+ if end_count >= target:
551
+ extracted = extract_between_markers(text)
552
+ if extracted:
553
+ print(
554
+ f"[✓] Reply captured "
555
+ f"(ENDs={end_count}, chars={len(extracted)})."
556
+ )
557
+ return extracted
558
+
559
+ sec = int(elapsed / 1000)
560
+ if sec != last_debug and sec % 5 == 0:
561
+ last_debug = sec
562
+ print(
563
+ f"[debug] elapsed={sec}s "
564
+ f"ends={end_count}/{target} "
565
+ f"screen_chars={len(text)}"
566
+ )
567
+
568
+ await page.wait_for_timeout(500)
569
+
570
+
571
+ def extract_code(response: str) -> str:
572
+ blocks = re.findall(
573
+ r"```(?:[a-zA-Z0-9_+#.-]+)?\s*\n(.*?)```",
574
+ response,
575
+ re.DOTALL,
576
+ )
577
+
578
+ if blocks:
579
+ return "\n\n".join(
580
+ block.strip()
581
+ for block in blocks
582
+ if block.strip()
583
+ )
584
+
585
+ return response.strip()
586
+
587
+
588
+ def save_output(response: str, output_path: str, code_path: str):
589
+ output = Path(output_path)
590
+ code = Path(code_path)
591
+
592
+ output.parent.mkdir(parents=True, exist_ok=True)
593
+ code.parent.mkdir(parents=True, exist_ok=True)
594
+
595
+ output.write_text(response, encoding="utf-8")
596
+ code.write_text(extract_code(response), encoding="utf-8")
597
+
598
+
599
+ def build_unique_paths(prefix: str) -> tuple[Path, Path, Path]:
600
+ """
601
+ Build a unique set of output paths for one iteration.
602
+ Returns (response_txt, code_txt, run_py).
603
+ """
604
+ stamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
605
+
606
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
607
+
608
+ response_path = OUTPUT_DIR / f"{prefix}_response_{stamp}.txt"
609
+ code_path = OUTPUT_DIR / f"{prefix}_code_{stamp}.txt"
610
+ py_path = OUTPUT_DIR / f"{prefix}_generated_{stamp}.py"
611
+
612
+ return response_path, code_path, py_path
613
+
614
+
615
+ def run_generated_code(py_path: Path) -> int:
616
+ """
617
+ Execute the generated .py with the same interpreter, in a
618
+ subprocess, and stream its output live.
619
+ Returns the exit code.
620
+ """
621
+ print()
622
+ print("=" * 70)
623
+ print(f"[*] Running: {py_path}")
624
+ print("=" * 70)
625
+
626
+ try:
627
+ result = subprocess.run(
628
+ [sys.executable, str(py_path)],
629
+ check=False,
630
+ )
631
+ print("=" * 70)
632
+ print(f"[*] Exit code: {result.returncode}")
633
+ print("=" * 70)
634
+ return result.returncode
635
+
636
+ except KeyboardInterrupt:
637
+ print("\n[!] Interrupted.")
638
+ return -1
639
+
640
+ except Exception as exc:
641
+ print(f"[!] Failed to run: {exc}")
642
+ return -1
643
+
644
+
645
+ async def do_one_iteration(
646
+ page: Page,
647
+ request: str,
648
+ args,
649
+ label_prefix: str,
650
+ ):
651
+ """
652
+ One full round: new chat -> send -> capture -> save -> run.
653
+ """
654
+ print()
655
+ print("#" * 70)
656
+ print(
657
+ f"[*] New chat. Request: {request[:80]}"
658
+ + ("..." if len(request) > 80 else "")
659
+ )
660
+ print("#" * 70)
661
+
662
+ # ------------------------------------------------------------
663
+ # Open a fresh chat each iteration and wait for input box.
664
+ # ------------------------------------------------------------
665
+ await open_new_chat(page, args.timeout)
666
+
667
+ blocked = await detect_blocked_page(page)
668
+ if blocked:
669
+ raise RuntimeError(blocked)
670
+
671
+ baseline_text = await get_screen_text(page)
672
+ baseline_end_count = count_marker(baseline_text, END_MARKER)
673
+
674
+ print(f"[*] Baseline END markers on screen: {baseline_end_count}")
675
+ print("[*] Sending request...")
676
+
677
+ await send_prompt(page, build_prompt(request), args.timeout)
678
+
679
+ print("[*] Waiting for response...")
680
+
681
+ response = await wait_for_response(
682
+ page,
683
+ args.response_timeout,
684
+ baseline_end_count,
685
+ )
686
+
687
+ if not response.strip():
688
+ raise RuntimeError("Empty ChatGPT response.")
689
+
690
+ # ------------------------------------------------------------
691
+ # Save outputs.
692
+ # ------------------------------------------------------------
693
+ response_path, code_path, py_path = build_unique_paths(label_prefix)
694
+
695
+ # NOTE: argument order must match the signature
696
+ # save_output(response, output_path, code_path).
697
+ save_output(response, str(response_path), str(code_path))
698
+
699
+ # Save the extracted python code as a runnable .py file.
700
+ code_body = extract_code(response)
701
+ py_path.write_text(code_body, encoding="utf-8")
702
+
703
+ print()
704
+ print("=" * 70)
705
+ print(response)
706
+ print("=" * 70)
707
+ print()
708
+ print(f"Response saved to: {response_path.resolve()}")
709
+ print(f"Code saved to: {code_path.resolve()}")
710
+ print(f"Python file: {py_path.resolve()}")
711
+
712
+ # ------------------------------------------------------------
713
+ # Run the generated code.
714
+ # ------------------------------------------------------------
715
+ run_generated_code(py_path)
716
+
717
+
718
+ async def main_async(args):
719
+ one_shot = bool(args.prompt) or bool(args.file)
720
+
721
+ if one_shot:
722
+ request = get_one_shot_request(args)
723
+ label_prefix = "oneshot"
724
+ else:
725
+ label_prefix = "run"
726
+
727
+ browser = None
728
+ context = None
729
+
730
+ async with async_playwright() as playwright:
731
+ try:
732
+ browser, context = await launch_context(playwright, args)
733
+
734
+ context.set_default_timeout(args.timeout)
735
+
736
+ pages = context.pages
737
+
738
+ if pages:
739
+ page = pages[0]
740
+ else:
741
+ page = await context.new_page()
742
+
743
+ page.set_default_navigation_timeout(NAVIGATION_TIMEOUT)
744
+
745
+ print("[*] Opening ChatGPT...")
746
+
747
+ # Initial load; wait for input box to be sure we're logged in.
748
+ try:
749
+ await open_new_chat(page, args.timeout)
750
+ except Exception as exc:
751
+ print(f"[!] Initial load failed: {exc}")
752
+ raise
753
+
754
+ # ----------------------------------------------------
755
+ # One-shot mode.
756
+ # ----------------------------------------------------
757
+ if one_shot:
758
+ for attempt in range(1, MAX_RETRIES + 1):
759
+ try:
760
+ print(
761
+ f"[*] Attempt {attempt}/{MAX_RETRIES}"
762
+ )
763
+
764
+ await do_one_iteration(
765
+ page, request, args, label_prefix
766
+ )
767
+ return
768
+
769
+ except KeyboardInterrupt:
770
+ raise
771
+
772
+ except Exception as exc:
773
+ print(
774
+ f"[!] Attempt failed: {exc}",
775
+ file=sys.stderr,
776
+ )
777
+
778
+ if attempt < MAX_RETRIES:
779
+ delay = attempt * 3
780
+ print(
781
+ f"[*] Retrying in {delay}s..."
782
+ )
783
+ await asyncio.sleep(delay)
784
+
785
+ raise RuntimeError(
786
+ f"Agent failed after {MAX_RETRIES} attempts."
787
+ )
788
+
789
+ # ----------------------------------------------------
790
+ # Interactive loop mode.
791
+ # ----------------------------------------------------
792
+ iteration = 0
793
+
794
+ while True:
795
+ request = get_interactive_request()
796
+
797
+ if not request:
798
+ print("[*] Exiting.")
799
+ break
800
+
801
+ iteration += 1
802
+ prefix = f"run{iteration:03d}"
803
+
804
+ try:
805
+ await do_one_iteration(
806
+ page, request, args, prefix
807
+ )
808
+
809
+ except KeyboardInterrupt:
810
+ print("\n[*] Interrupted, exiting.")
811
+ break
812
+
813
+ except Exception as exc:
814
+ print(
815
+ f"[!] Iteration failed: {exc}",
816
+ file=sys.stderr,
817
+ )
818
+
819
+ return
820
+
821
+ finally:
822
+ if context:
823
+ try:
824
+ await context.close()
825
+ except Exception:
826
+ pass
827
+
828
+ if browser:
829
+ try:
830
+ await browser.close()
831
+ except Exception:
832
+ pass
833
+
834
+
835
+ # ======================================================================
836
+ # GUI AGENT - AgentAnon
837
+ # ======================================================================
838
+
839
+ def _build_gui_args():
840
+ """Lightweight namespace that mirrors the CLI args used by the
841
+ existing core logic (browser / new_session / timeout ...)."""
842
+
843
+ class _Args:
844
+ browser = "chromium"
845
+ new_session = False
846
+ timeout = DEFAULT_TIMEOUT
847
+ response_timeout = RESPONSE_TIMEOUT
848
+
849
+ return _Args()
850
+
851
+
852
+ class AgentAnonApp:
853
+ """
854
+ Red-on-black techy GUI wrapper around the existing async core.
855
+ All heavy lifting still happens through the same helpers
856
+ (launch_context, open_new_chat, send_prompt, wait_for_response,
857
+ extract_code, save_output, ...).
858
+ """
859
+
860
+ def __init__(self, root: tk.Tk):
861
+ self.root = root
862
+
863
+ # Thread-safe channels between GUI thread and worker thread.
864
+ self.out_queue: queue.Queue = queue.Queue()
865
+ self.request_queue: queue.Queue = queue.Queue()
866
+
867
+ self.running = True
868
+ self.busy = False
869
+ self.ready = False
870
+
871
+ self._setup_ui()
872
+ self._start_worker()
873
+
874
+ self.root.after(50, self._poll)
875
+ self.root.protocol("WM_DELETE_WINDOW", self._on_close)
876
+
877
+ # ------------------------------------------------------------------
878
+ # UI construction
879
+ # ------------------------------------------------------------------
880
+
881
+ def _setup_ui(self):
882
+ self.root.title("AgentAnon")
883
+ self.root.configure(bg=UI_BG)
884
+ self.root.geometry("960x680")
885
+ self.root.minsize(720, 520)
886
+
887
+ # ---- Header ------------------------------------------------
888
+ header = tk.Frame(self.root, bg=UI_BG)
889
+ header.pack(fill="x", padx=14, pady=(12, 0))
890
+
891
+ title = tk.Label(
892
+ header,
893
+ text="\u25e2 AGENTANON \u25e3",
894
+ bg=UI_BG,
895
+ fg=UI_FG,
896
+ font=UI_FONT_TITLE,
897
+ )
898
+ title.pack(side="left")
899
+
900
+ subtitle = tk.Label(
901
+ header,
902
+ text="// autonomous code synthesis unit",
903
+ bg=UI_BG,
904
+ fg=UI_FG_DIM,
905
+ font=UI_FONT_SMALL,
906
+ )
907
+ subtitle.pack(side="left", padx=(12, 0), pady=(10, 0))
908
+
909
+ self._divider(pady=(8, 8))
910
+
911
+ # ---- Log / chat area --------------------------------------
912
+ log_frame = tk.Frame(self.root, bg=UI_BG)
913
+ log_frame.pack(fill="both", expand=True, padx=14)
914
+
915
+ self.log_area = scrolledtext.ScrolledText(
916
+ log_frame,
917
+ bg=UI_BG_DEEP,
918
+ fg=UI_FG,
919
+ insertbackground=UI_ACCENT,
920
+ font=UI_FONT,
921
+ relief="flat",
922
+ borderwidth=0,
923
+ highlightthickness=1,
924
+ highlightbackground=UI_BORDER,
925
+ highlightcolor=UI_BORDER,
926
+ wrap="word",
927
+ state="disabled",
928
+ padx=10,
929
+ pady=8,
930
+ )
931
+ self.log_area.pack(fill="both", expand=True)
932
+
933
+ self.log_area.tag_configure("info", foreground=UI_FG)
934
+ self.log_area.tag_configure("status", foreground=UI_WARN)
935
+ self.log_area.tag_configure("success", foreground=UI_OK)
936
+ self.log_area.tag_configure("error", foreground=UI_ERR)
937
+ self.log_area.tag_configure("output", foreground=UI_OUT)
938
+ self.log_area.tag_configure("user", foreground=UI_USER,
939
+ font=UI_FONT_BOLD)
940
+ self.log_area.tag_configure("heading", foreground=UI_FG_BRIGHT,
941
+ font=UI_FONT_BOLD)
942
+ self.log_area.tag_configure("system", foreground=UI_FG_DIM,
943
+ font=UI_FONT_SMALL)
944
+
945
+ self._divider(pady=(8, 8))
946
+
947
+ # ---- Input row --------------------------------------------
948
+ input_frame = tk.Frame(self.root, bg=UI_BG)
949
+ input_frame.pack(fill="x", padx=14, pady=(0, 6))
950
+
951
+ prompt_glyph = tk.Label(
952
+ input_frame,
953
+ text="\u25b8",
954
+ bg=UI_BG,
955
+ fg=UI_FG,
956
+ font=UI_FONT_GLYPH,
957
+ )
958
+ prompt_glyph.pack(side="left", padx=(0, 6))
959
+
960
+ self.entry = tk.Entry(
961
+ input_frame,
962
+ bg=UI_BG_DEEP,
963
+ fg=UI_FG,
964
+ insertbackground=UI_ACCENT,
965
+ font=UI_FONT,
966
+ relief="flat",
967
+ highlightthickness=1,
968
+ highlightbackground=UI_BORDER,
969
+ highlightcolor=UI_ACCENT,
970
+ disabledbackground=UI_BG_DEEP,
971
+ disabledforeground=UI_FG_DIM,
972
+ )
973
+ self.entry.pack(
974
+ side="left", fill="x", expand=True, padx=(0, 10), ipady=6
975
+ )
976
+ self.entry.bind("<Return>", self._submit_event)
977
+
978
+ self.send_btn = tk.Button(
979
+ input_frame,
980
+ text="[ TRANSMIT ]",
981
+ bg=UI_BG,
982
+ fg=UI_FG,
983
+ activebackground=UI_ACCENT,
984
+ activeforeground="#000000",
985
+ font=UI_FONT_BOLD,
986
+ relief="flat",
987
+ borderwidth=0,
988
+ padx=14,
989
+ pady=4,
990
+ cursor="hand2",
991
+ command=self._submit_event,
992
+ )
993
+ self.send_btn.pack(side="right")
994
+
995
+ # ---- Status bar -------------------------------------------
996
+ status_frame = tk.Frame(self.root, bg=UI_BG)
997
+ status_frame.pack(fill="x", padx=14, pady=(0, 12))
998
+
999
+ self.status_label = tk.Label(
1000
+ status_frame,
1001
+ text="\u25cf initializing",
1002
+ bg=UI_BG,
1003
+ fg=UI_WARN,
1004
+ font=UI_FONT_SMALL,
1005
+ anchor="w",
1006
+ )
1007
+ self.status_label.pack(side="left")
1008
+
1009
+ self.hint_label = tk.Label(
1010
+ status_frame,
1011
+ text="enter \u2192 transmit | esc \u2192 clear",
1012
+ bg=UI_BG,
1013
+ fg=UI_FG_DIM,
1014
+ font=UI_FONT_SMALL,
1015
+ anchor="e",
1016
+ )
1017
+ self.hint_label.pack(side="right")
1018
+
1019
+ self.root.bind("<Escape>", lambda e: self.entry.delete(0, "end"))
1020
+
1021
+ # Intro banner
1022
+ self._append("\u2500" * 68, "system")
1023
+ self._append(" AGENTANON :: autonomous code synthesis unit", "heading")
1024
+ self._append(" channel: secure | mode: interactive | status: warming up", "system")
1025
+ self._append("\u2500" * 68, "system")
1026
+ self.entry.focus_set()
1027
+
1028
+ def _divider(self, pady=(0, 0)):
1029
+ sep = tk.Frame(self.root, bg=UI_BORDER, height=1)
1030
+ sep.pack(fill="x", padx=14, pady=pady)
1031
+
1032
+ # ------------------------------------------------------------------
1033
+ # Threading / worker
1034
+ # ------------------------------------------------------------------
1035
+
1036
+ def _start_worker(self):
1037
+ t = threading.Thread(
1038
+ target=self._worker_thread,
1039
+ name="AgentAnonWorker",
1040
+ daemon=True,
1041
+ )
1042
+ t.start()
1043
+
1044
+ def _worker_thread(self):
1045
+ try:
1046
+ asyncio.run(self._worker_main())
1047
+ except Exception as exc:
1048
+ self._log(f"worker crashed: {exc}", "error")
1049
+ self._status("halted", UI_ERR)
1050
+
1051
+ async def _worker_main(self):
1052
+ args = _build_gui_args()
1053
+
1054
+ async with async_playwright() as playwright:
1055
+ browser = None
1056
+ context = None
1057
+ try:
1058
+ self._log("booting headless runtime...", "system")
1059
+ self._status("booting", UI_WARN)
1060
+
1061
+ browser, context = await launch_context(playwright, args)
1062
+ context.set_default_timeout(args.timeout)
1063
+
1064
+ pages = context.pages
1065
+ page = pages[0] if pages else await context.new_page()
1066
+ page.set_default_navigation_timeout(NAVIGATION_TIMEOUT)
1067
+
1068
+ self._log("opening secure channel...", "system")
1069
+ await open_new_chat(page, args.timeout)
1070
+
1071
+ self.ready = True
1072
+ self._log("channel established.", "success")
1073
+ self._status("ready", UI_OK)
1074
+ self._log("awaiting instruction.", "system")
1075
+
1076
+ loop = asyncio.get_event_loop()
1077
+
1078
+ while self.running:
1079
+ try:
1080
+ request = await loop.run_in_executor(
1081
+ None, self.request_queue.get
1082
+ )
1083
+ except RuntimeError:
1084
+ break
1085
+
1086
+ if request is None:
1087
+ break
1088
+
1089
+ self._status("working", UI_WARN)
1090
+ self._log("", "info")
1091
+ self._log("\u25b8 working on:", "heading")
1092
+ self._log(f" {request}", "user")
1093
+
1094
+ try:
1095
+ py_path = await self._run_iteration(
1096
+ page, request, args
1097
+ )
1098
+ await self._run_generated_async(py_path)
1099
+ except Exception as exc:
1100
+ self._log(f" \u2717 {exc}", "error")
1101
+
1102
+ self._status("ready", UI_OK)
1103
+ self._done()
1104
+
1105
+ finally:
1106
+ if context:
1107
+ try:
1108
+ await context.close()
1109
+ except Exception:
1110
+ pass
1111
+ if browser:
1112
+ try:
1113
+ await browser.close()
1114
+ except Exception:
1115
+ pass
1116
+
1117
+ # ------------------------------------------------------------------
1118
+ # GUI-flavoured version of do_one_iteration (same core logic,
1119
+ # only prints replaced with queue messages).
1120
+ # ------------------------------------------------------------------
1121
+
1122
+ async def _run_iteration(self, page: Page, request: str, args):
1123
+ await open_new_chat(page, args.timeout)
1124
+
1125
+ blocked = await detect_blocked_page(page)
1126
+ if blocked:
1127
+ raise RuntimeError(blocked)
1128
+
1129
+ baseline_text = await get_screen_text(page)
1130
+ baseline_end_count = count_marker(baseline_text, END_MARKER)
1131
+
1132
+ self._log(" \u00b7 transmitting payload...", "status")
1133
+ await send_prompt(page, build_prompt(request), args.timeout)
1134
+
1135
+ self._log(" \u00b7 synthesizing response...", "status")
1136
+ response = await self._wait_for_response(
1137
+ page, args.response_timeout, baseline_end_count
1138
+ )
1139
+
1140
+ if not response.strip():
1141
+ raise RuntimeError("empty response payload.")
1142
+
1143
+ response_path, code_path, py_path = build_unique_paths("run")
1144
+ save_output(response, str(response_path), str(code_path))
1145
+
1146
+ code_body = extract_code(response)
1147
+ py_path.write_text(code_body, encoding="utf-8")
1148
+
1149
+ self._log(f" \u2713 artifact: {py_path.name}", "success")
1150
+ self._log(f" \u2192 {py_path.resolve()}", "system")
1151
+
1152
+ return py_path
1153
+
1154
+ async def _wait_for_response(
1155
+ self,
1156
+ page: Page,
1157
+ timeout_ms: int,
1158
+ baseline_end_count: int,
1159
+ ):
1160
+ start = time.monotonic()
1161
+ target = baseline_end_count + 1
1162
+ last_tick = -1
1163
+
1164
+ while True:
1165
+ elapsed_ms = (time.monotonic() - start) * 1000
1166
+
1167
+ if elapsed_ms > timeout_ms:
1168
+ raise TimeoutError("response window expired.")
1169
+
1170
+ text = await get_screen_text(page)
1171
+ end_count = count_marker(text, END_MARKER)
1172
+
1173
+ if end_count >= target:
1174
+ extracted = extract_between_markers(text)
1175
+ if extracted:
1176
+ return extracted
1177
+
1178
+ sec = int(elapsed_ms / 1000)
1179
+ if sec != last_tick and sec % 5 == 0 and sec > 0:
1180
+ last_tick = sec
1181
+ self._log(f" \u00b7 still synthesizing ({sec}s)", "status")
1182
+
1183
+ await page.wait_for_timeout(500)
1184
+
1185
+ async def _run_generated_async(self, py_path: Path):
1186
+ self._log(" \u00b7 executing artifact...", "status")
1187
+
1188
+ try:
1189
+ proc = await asyncio.create_subprocess_exec(
1190
+ sys.executable, str(py_path),
1191
+ stdout=asyncio.subprocess.PIPE,
1192
+ stderr=asyncio.subprocess.STDOUT,
1193
+ )
1194
+
1195
+ if proc.stdout is not None:
1196
+ async for raw in proc.stdout:
1197
+ text = raw.decode(errors="replace").rstrip()
1198
+ if text:
1199
+ self._log(f" | {text}", "output")
1200
+
1201
+ await proc.wait()
1202
+
1203
+ if proc.returncode == 0:
1204
+ self._log(" \u2713 execution complete (exit 0)", "success")
1205
+ else:
1206
+ self._log(
1207
+ f" \u2717 execution finished (exit {proc.returncode})",
1208
+ "error",
1209
+ )
1210
+ except Exception as exc:
1211
+ self._log(f" \u2717 execution failed: {exc}", "error")
1212
+
1213
+ # ------------------------------------------------------------------
1214
+ # Cross-thread messaging
1215
+ # ------------------------------------------------------------------
1216
+
1217
+ def _log(self, text: str, tag: str = "info"):
1218
+ self.out_queue.put(("log", text, tag))
1219
+
1220
+ def _status(self, text: str, color: str = UI_FG):
1221
+ self.out_queue.put(("status", text, color))
1222
+
1223
+ def _done(self):
1224
+ self.out_queue.put(("done",))
1225
+
1226
+ def _poll(self):
1227
+ try:
1228
+ while True:
1229
+ item = self.out_queue.get_nowait()
1230
+ kind = item[0]
1231
+
1232
+ if kind == "log":
1233
+ self._append(item[1], item[2])
1234
+ elif kind == "status":
1235
+ self.status_label.configure(
1236
+ text=f"\u25cf {item[1]}", fg=item[2]
1237
+ )
1238
+ elif kind == "done":
1239
+ self.busy = False
1240
+ self.send_btn.configure(state="normal")
1241
+ self.entry.focus_set()
1242
+ except queue.Empty:
1243
+ pass
1244
+
1245
+ if self.running:
1246
+ self.root.after(50, self._poll)
1247
+
1248
+ def _append(self, text: str, tag: str = "info"):
1249
+ self.log_area.configure(state="normal")
1250
+ self.log_area.insert("end", text + "\n", tag)
1251
+ self.log_area.see("end")
1252
+ self.log_area.configure(state="disabled")
1253
+
1254
+ # ------------------------------------------------------------------
1255
+ # User actions
1256
+ # ------------------------------------------------------------------
1257
+
1258
+ def _submit_event(self, event=None):
1259
+ text = self.entry.get().strip()
1260
+ if not text:
1261
+ return
1262
+
1263
+ if not self.ready:
1264
+ self._append("[ waiting ] channel not ready yet", "status")
1265
+ return
1266
+
1267
+ if self.busy:
1268
+ self._append("[ busy ] previous task still running", "status")
1269
+ return
1270
+
1271
+ self.entry.delete(0, "end")
1272
+ self._append("", "info")
1273
+ self._append(f"\u25b8 {text}", "user")
1274
+
1275
+ self.busy = True
1276
+ self.send_btn.configure(state="disabled")
1277
+ self.request_queue.put(text)
1278
+
1279
+ def _on_close(self):
1280
+ self.running = False
1281
+ try:
1282
+ self.request_queue.put(None)
1283
+ except Exception:
1284
+ pass
1285
+ try:
1286
+ self.root.destroy()
1287
+ except Exception:
1288
+ pass
1289
+
1290
+
1291
+ def launch_gui():
1292
+ root = tk.Tk()
1293
+ AgentAnonApp(root)
1294
+ root.mainloop()
1295
+
1296
+
1297
+ # ======================================================================
1298
+ # Entry point
1299
+ # ======================================================================
1300
+
1301
+ def main():
1302
+ # If CLI arguments are given, keep the original terminal behaviour.
1303
+ # Otherwise, launch the AgentAnon GUI.
1304
+ if len(sys.argv) > 1:
1305
+ args = parse_args()
1306
+
1307
+ try:
1308
+ asyncio.run(main_async(args))
1309
+
1310
+ except KeyboardInterrupt:
1311
+ print("\nStopped.")
1312
+
1313
+ except Exception as exc:
1314
+ print(f"\nERROR: {exc}", file=sys.stderr)
1315
+ sys.exit(1)
1316
+ else:
1317
+ launch_gui()
1318
+
1319
+
1320
+ if __name__ == "__main__":
1321
+ main()