wydcode 0.4.3

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 (41) hide show
  1. package/LICENSE +100 -0
  2. package/NOTICE +5 -0
  3. package/README.md +434 -0
  4. package/SECURITY.md +64 -0
  5. package/SOCRA_SOURCE.md +36 -0
  6. package/TRADEMARKS.md +12 -0
  7. package/bin/wydcode.js +74 -0
  8. package/examples/behavior-preference-card.json +12 -0
  9. package/examples/exact-recurrence-card.json +11 -0
  10. package/examples/heldout-gene-evidence.json +6 -0
  11. package/examples/humaneval-ornith.md +29 -0
  12. package/examples/large_context_json_diagnostic.py +187 -0
  13. package/fixtures/failing-node-repo/package.json +9 -0
  14. package/fixtures/failing-node-repo/src/math.js +3 -0
  15. package/fixtures/failing-node-repo/test/math.test.js +8 -0
  16. package/package.json +55 -0
  17. package/pyproject.toml +24 -0
  18. package/socra_harness/__init__.py +3 -0
  19. package/socra_harness/attempts.py +76 -0
  20. package/socra_harness/central_traces.py +627 -0
  21. package/socra_harness/cli.py +265 -0
  22. package/socra_harness/eval/__init__.py +1 -0
  23. package/socra_harness/eval/humaneval.py +388 -0
  24. package/socra_harness/events.py +39 -0
  25. package/socra_harness/init.py +206 -0
  26. package/socra_harness/job/__init__.py +2 -0
  27. package/socra_harness/job/events.py +132 -0
  28. package/socra_harness/job/lock.py +90 -0
  29. package/socra_harness/job/manager.py +3785 -0
  30. package/socra_harness/job/planner.py +253 -0
  31. package/socra_harness/job/state.py +106 -0
  32. package/socra_harness/mumpix_trace_bridge.cjs +32 -0
  33. package/socra_harness/product.py +533 -0
  34. package/socra_harness/recurrence.py +263 -0
  35. package/socra_harness/scan/__init__.py +1 -0
  36. package/socra_harness/scan/repo.py +85 -0
  37. package/socra_harness/serve/__init__.py +1 -0
  38. package/socra_harness/serve/local_proxy.py +293 -0
  39. package/socra_harness/start.py +331 -0
  40. package/socra_harness/tui/__init__.py +1 -0
  41. package/socra_harness/tui/app.py +1114 -0
@@ -0,0 +1,1114 @@
1
+ from __future__ import annotations
2
+
3
+ import curses
4
+ import datetime as dt
5
+ import json
6
+ import os
7
+ import re
8
+ import shlex
9
+ import shutil
10
+ import socket
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+ import webbrowser
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from ..events import read_events
22
+
23
+
24
+ MAX_AGENT_LOOP_PASSES = 3
25
+
26
+
27
+ CHAT_SYSTEM_PROMPT = """You are Socra Harness, a local-first coding partner.
28
+ The user is vibe coding inside the selected project folder.
29
+
30
+ Rules:
31
+ - Use the project context and scanned candidates when helpful.
32
+ - Keep answers concise and practical.
33
+ - Do not include hidden reasoning.
34
+ - For normal questions, answer normally.
35
+ - When the user asks you to create or modify project files, return ONLY this JSON:
36
+ {"message":"short summary","files":[{"path":"relative/project/path","content":"complete file content"}]}
37
+ - File paths must be relative to the project root. Never use absolute paths or .. segments.
38
+ - Include complete file contents, not patches, for every file you want Socra to write.
39
+ - If the user asks for a website, include at least index.html and any CSS/JS files it needs.
40
+ - If sandbox network/install access is enabled and you need to download a repo,
41
+ fetch software, or install dependencies, include commands in the same JSON:
42
+ {"commands":[{"argv":["git","clone","https://example/repo.git"],"cwd":"."}]}
43
+ - Commands run only inside Socra's disposable sandbox, never directly in the project root.
44
+ """
45
+
46
+
47
+ def now_iso() -> str:
48
+ return dt.datetime.now(dt.UTC).isoformat()
49
+
50
+
51
+ def read_json(path: Path) -> dict[str, Any] | None:
52
+ if not path.exists():
53
+ return None
54
+ try:
55
+ return json.loads(path.read_text(encoding="utf-8"))
56
+ except Exception:
57
+ return None
58
+
59
+
60
+ def append_jsonl(path: Path, row: dict[str, Any]) -> None:
61
+ path.parent.mkdir(parents=True, exist_ok=True)
62
+ with path.open("a", encoding="utf-8") as handle:
63
+ handle.write(json.dumps(row, ensure_ascii=False) + "\n")
64
+
65
+
66
+ def read_chat(path: Path, limit: int = 10) -> list[dict[str, Any]]:
67
+ if limit <= 0:
68
+ return []
69
+ if not path.exists():
70
+ return []
71
+ rows: list[dict[str, Any]] = []
72
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
73
+ try:
74
+ row = json.loads(line)
75
+ except json.JSONDecodeError:
76
+ continue
77
+ if not isinstance(row, dict):
78
+ continue
79
+ if row.get("role") in {"user", "assistant", "system"}:
80
+ rows.append(row)
81
+ return rows[-limit:]
82
+
83
+
84
+ def pct(value: float) -> str:
85
+ return f"{value * 100:.1f}%"
86
+
87
+
88
+ def safe_addstr(stdscr: Any, y: int, x: int, text: str, attr: int = 0) -> None:
89
+ h, w = stdscr.getmaxyx()
90
+ if y < 0 or y >= h or x >= w:
91
+ return
92
+ text = text.replace("\n", " ")
93
+ try:
94
+ stdscr.addstr(y, x, text[: max(0, w - x - 1)], attr)
95
+ except curses.error:
96
+ return
97
+
98
+
99
+ def draw_bar(stdscr: Any, y: int, x: int, width: int, fraction: float, label: str) -> None:
100
+ fraction = max(0.0, min(1.0, fraction))
101
+ fill = int(width * fraction)
102
+ bar = "[" + "#" * fill + "-" * (width - fill) + "]"
103
+ safe_addstr(stdscr, y, x, f"{bar} {label}")
104
+
105
+
106
+ def latest_eval_progress(events: list[dict[str, Any]]) -> tuple[int, int, int]:
107
+ total = 0
108
+ done = 0
109
+ passed = 0
110
+ for event in events:
111
+ if event.get("event") == "eval_start":
112
+ total = int(event.get("total") or 0)
113
+ done = 0
114
+ passed = 0
115
+ elif event.get("event") == "eval_task":
116
+ done = max(done, int(event.get("index") or 0))
117
+ total = max(total, int(event.get("total") or 0))
118
+ if event.get("passed"):
119
+ passed += 1
120
+ elif event.get("event") == "eval_complete":
121
+ total = int(event.get("total") or total)
122
+ done = total
123
+ passed = int(event.get("passed") or passed)
124
+ return done, total, passed
125
+
126
+
127
+ def candidate_summary(candidates: dict[str, Any], limit: int = 6) -> str:
128
+ rows = []
129
+ for item in (candidates.get("candidates") or [])[:limit]:
130
+ rows.append(
131
+ f"- {item.get('family')}: {item.get('file_count')} files, "
132
+ f"score {item.get('score_estimate')}, samples {item.get('samples') or []}"
133
+ )
134
+ return "\n".join(rows) if rows else "No repeatable candidates found yet."
135
+
136
+
137
+ def sandbox_policy(session: dict[str, Any]) -> dict[str, Any]:
138
+ sandbox = session.get("sandbox") if isinstance(session.get("sandbox"), dict) else {}
139
+ return {
140
+ "network": bool(sandbox.get("network")),
141
+ "installs": bool(sandbox.get("installs")),
142
+ "root": str(sandbox.get("root") or ""),
143
+ }
144
+
145
+
146
+ def chat_system_prompt(project: Path, candidates: dict[str, Any], session: dict[str, Any]) -> str:
147
+ policy = sandbox_policy(session)
148
+ sandbox_text = (
149
+ f"\nSandbox command access: network={policy['network']} installs={policy['installs']} "
150
+ f"root={policy['root'] or 'not configured'}.\n"
151
+ )
152
+ if not policy["network"]:
153
+ sandbox_text += "Do not request network/download/install commands; this session has not enabled sandbox network.\n"
154
+ return (
155
+ CHAT_SYSTEM_PROMPT
156
+ + f"\nProject root: {project}\n\nScanned repeatable project patterns:\n"
157
+ + candidate_summary(candidates)
158
+ + sandbox_text
159
+ )
160
+
161
+
162
+ def chat_endpoint(server: dict[str, Any]) -> str | None:
163
+ endpoint = server.get("endpoint")
164
+ if not endpoint:
165
+ return None
166
+ return str(endpoint).rstrip("/") + "/chat/completions"
167
+
168
+
169
+ def prompt_wants_files(text: str) -> bool:
170
+ lowered = text.lower()
171
+ triggers = [
172
+ "create",
173
+ "make",
174
+ "build",
175
+ "write",
176
+ "generate",
177
+ "add",
178
+ "app",
179
+ "website",
180
+ "page",
181
+ "file",
182
+ "component",
183
+ "script",
184
+ "download",
185
+ "install",
186
+ "clone",
187
+ "repo",
188
+ "dependency",
189
+ "dependencies",
190
+ ]
191
+ return any(trigger in lowered for trigger in triggers)
192
+
193
+
194
+ def prompt_wants_website(text: str) -> bool:
195
+ lowered = text.lower()
196
+ return any(word in lowered for word in ["website", "webpage", "web page", "html", "landing page", "game"])
197
+
198
+
199
+ def post_chat(server: dict[str, Any], messages: list[dict[str, str]], json_mode: bool = False) -> str:
200
+ endpoint = chat_endpoint(server)
201
+ if not endpoint:
202
+ raise RuntimeError("Socra endpoint is not running yet.")
203
+ model = str(server.get("model_alias") or server.get("model") or "local-model")
204
+ payload = {
205
+ "model": model,
206
+ "messages": messages,
207
+ "temperature": 0.2,
208
+ "max_tokens": 4096,
209
+ }
210
+ if json_mode:
211
+ payload["response_format"] = {"type": "json_object"}
212
+ data = json.dumps(payload).encode("utf-8")
213
+ req = urllib.request.Request(endpoint, data=data, headers={"Content-Type": "application/json"}, method="POST")
214
+ try:
215
+ with urllib.request.urlopen(req, timeout=900) as res:
216
+ body = json.loads(res.read().decode("utf-8"))
217
+ except urllib.error.HTTPError as exc:
218
+ detail = exc.read().decode("utf-8", errors="replace")
219
+ raise RuntimeError(f"chat failed: HTTP {exc.code}: {detail[:500]}") from exc
220
+ choices = body.get("choices") or []
221
+ if not choices:
222
+ raise RuntimeError("chat failed: no choices returned")
223
+ message = choices[0].get("message") or {}
224
+ content = message.get("content")
225
+ if isinstance(content, str) and content.strip():
226
+ return content.strip()
227
+ raise RuntimeError("chat failed: empty assistant content")
228
+
229
+
230
+ def run_command(command: list[str], cwd: Path, timeout: int = 20) -> tuple[bool, str]:
231
+ try:
232
+ proc = subprocess.run(command, cwd=cwd, text=True, capture_output=True, timeout=timeout)
233
+ except FileNotFoundError:
234
+ return True, f"skipped; command not found: {command[0]}"
235
+ except subprocess.TimeoutExpired:
236
+ return False, f"timed out: {' '.join(command)}"
237
+ output = (proc.stdout + proc.stderr).strip()
238
+ return proc.returncode == 0, output
239
+
240
+
241
+ def command_sandbox_root(project: Path, state_dir: Path, session: dict[str, Any]) -> Path:
242
+ policy = sandbox_policy(session)
243
+ configured = str(policy.get("root") or "").strip()
244
+ if configured:
245
+ root = Path(configured).expanduser()
246
+ else:
247
+ root = state_dir / "run-sandbox" / "command-workspace"
248
+ root = root.resolve()
249
+ fallback = (state_dir / "run-sandbox" / "command-workspace").resolve()
250
+ if fallback != root and fallback not in root.parents:
251
+ root = fallback
252
+ root.mkdir(parents=True, exist_ok=True)
253
+ return root
254
+
255
+
256
+ def safe_sandbox_cwd(root: Path, relative: str) -> Path:
257
+ raw = Path(relative or ".")
258
+ if raw.is_absolute() or any(part == ".." for part in raw.parts):
259
+ raise ValueError(f"unsafe sandbox cwd: {relative}")
260
+ cwd = (root / raw).resolve()
261
+ if cwd != root and root not in cwd.parents:
262
+ raise ValueError(f"sandbox cwd escapes root: {relative}")
263
+ cwd.mkdir(parents=True, exist_ok=True)
264
+ return cwd
265
+
266
+
267
+ def command_needs_network(argv: list[str]) -> bool:
268
+ if not argv:
269
+ return False
270
+ tool = Path(argv[0]).name
271
+ if tool in {"curl", "wget"}:
272
+ return True
273
+ if tool == "git" and len(argv) > 1 and argv[1] in {"clone", "fetch", "pull", "submodule"}:
274
+ return True
275
+ if tool in {"npm", "pnpm", "yarn", "bun", "pip", "pip3"} and any(part in {"install", "add", "create", "dlx"} for part in argv[1:3]):
276
+ return True
277
+ if tool in {"npx"}:
278
+ return True
279
+ return any(part.startswith(("http://", "https://", "git@")) for part in argv)
280
+
281
+
282
+ def command_is_install(argv: list[str]) -> bool:
283
+ if not argv:
284
+ return False
285
+ tool = Path(argv[0]).name
286
+ if tool in {"npm", "pnpm", "yarn", "bun", "pip", "pip3", "npx"}:
287
+ return any(part in {"install", "add", "create", "dlx"} for part in argv[1:3]) or tool == "npx"
288
+ return False
289
+
290
+
291
+ def command_has_shell_metacharacters(argv: list[str]) -> bool:
292
+ forbidden = {";", "&&", "||", "|", ">", ">>", "<", "$(", "`"}
293
+ return any(any(mark in part for mark in forbidden) for part in argv)
294
+
295
+
296
+ def execute_sandbox_commands(
297
+ project: Path,
298
+ state_dir: Path,
299
+ session: dict[str, Any],
300
+ commands: list[dict[str, Any]],
301
+ ) -> tuple[list[dict[str, Any]], list[str]]:
302
+ policy = sandbox_policy(session)
303
+ root = command_sandbox_root(project, state_dir, session)
304
+ results: list[dict[str, Any]] = []
305
+ errors: list[str] = []
306
+ for index, command in enumerate(commands, start=1):
307
+ argv = command.get("argv")
308
+ cwd_raw = command.get("cwd", ".")
309
+ if not isinstance(argv, list) or not all(isinstance(part, str) for part in argv) or not argv:
310
+ errors.append(f"command {index}: invalid argv")
311
+ continue
312
+ if command_has_shell_metacharacters(argv):
313
+ errors.append(f"command {index}: shell metacharacters are not allowed")
314
+ continue
315
+ if command_needs_network(argv) and not policy["network"]:
316
+ errors.append(f"command {index}: blocked; sandbox network is not enabled")
317
+ continue
318
+ if command_is_install(argv) and not policy["installs"]:
319
+ errors.append(f"command {index}: blocked; sandbox installs are not enabled")
320
+ continue
321
+ try:
322
+ cwd = safe_sandbox_cwd(root, str(cwd_raw))
323
+ except ValueError as exc:
324
+ errors.append(f"command {index}: {exc}")
325
+ continue
326
+ record_agent_stage(state_dir, "sandbox", "running", command=argv, cwd=str(cwd))
327
+ ok, output = run_command(argv, cwd=cwd, timeout=180)
328
+ result = {"index": index, "argv": argv, "cwd": str(cwd), "ok": ok, "output": output[-4000:]}
329
+ results.append(result)
330
+ append_jsonl(state_dir / "sandbox-commands.jsonl", {"timestamp": now_iso(), **result})
331
+ record_agent_stage(state_dir, "sandbox", "passed" if ok else "failed", command=argv, cwd=str(cwd), output=output[-1000:])
332
+ if not ok:
333
+ errors.append(f"command {index} failed: {output[-1000:]}")
334
+ if results:
335
+ (state_dir / "last-sandbox-commands.json").write_text(json.dumps({"root": str(root), "results": results}, indent=2) + "\n", encoding="utf-8")
336
+ return results, errors
337
+
338
+
339
+ def strip_json_fence(text: str) -> str:
340
+ stripped = text.strip()
341
+ if stripped.startswith("```"):
342
+ lines = stripped.splitlines()
343
+ if lines and lines[0].startswith("```"):
344
+ lines = lines[1:]
345
+ if lines and lines[-1].startswith("```"):
346
+ lines = lines[:-1]
347
+ return "\n".join(lines).strip()
348
+ return stripped
349
+
350
+
351
+ def json_candidates(text: str) -> list[str]:
352
+ raw = text.strip()
353
+ candidates = [strip_json_fence(raw)]
354
+ for match in re.finditer(r"```(?:json)?\s*([\s\S]*?)```", raw, flags=re.IGNORECASE):
355
+ candidates.append(match.group(1).strip())
356
+ start = raw.find("{")
357
+ while start >= 0:
358
+ depth = 0
359
+ in_string = False
360
+ escaped = False
361
+ for index in range(start, len(raw)):
362
+ char = raw[index]
363
+ if escaped:
364
+ escaped = False
365
+ continue
366
+ if char == "\\":
367
+ escaped = True
368
+ continue
369
+ if char == '"':
370
+ in_string = not in_string
371
+ continue
372
+ if in_string:
373
+ continue
374
+ if char == "{":
375
+ depth += 1
376
+ elif char == "}":
377
+ depth -= 1
378
+ if depth == 0:
379
+ candidates.append(raw[start : index + 1])
380
+ break
381
+ start = raw.find("{", start + 1)
382
+ unique: list[str] = []
383
+ seen: set[str] = set()
384
+ for candidate in candidates:
385
+ if candidate and candidate not in seen:
386
+ seen.add(candidate)
387
+ unique.append(candidate)
388
+ return unique
389
+
390
+
391
+ def loads_model_json(raw: str) -> Any:
392
+ try:
393
+ return json.loads(raw)
394
+ except json.JSONDecodeError:
395
+ # Some local models emit JavaScript-style escaped apostrophes inside
396
+ # JSON strings. JSON does not allow \', but code content often needs
397
+ # the backslash preserved, so encode it as a JSON backslash instead.
398
+ repaired = raw.replace("\\'", "\\\\'")
399
+ if repaired != raw:
400
+ return json.loads(repaired)
401
+ raise
402
+
403
+
404
+ def parse_file_bundle(text: str) -> dict[str, Any] | None:
405
+ path_content_objects: list[dict[str, str]] = []
406
+ for raw in json_candidates(text):
407
+ try:
408
+ value = loads_model_json(raw)
409
+ except json.JSONDecodeError:
410
+ continue
411
+ if isinstance(value, dict) and isinstance(value.get("files"), list):
412
+ return value
413
+ if isinstance(value, dict) and isinstance(value.get("path"), str) and isinstance(value.get("content"), str):
414
+ path_content_objects.append({"path": value["path"], "content": value["content"]})
415
+ if path_content_objects:
416
+ return {"message": "Created files from the model response.", "files": path_content_objects}
417
+ return None
418
+
419
+
420
+ def parse_action_bundle(text: str) -> dict[str, Any] | None:
421
+ for raw in json_candidates(text):
422
+ try:
423
+ value = loads_model_json(raw)
424
+ except json.JSONDecodeError:
425
+ continue
426
+ if isinstance(value, dict) and (isinstance(value.get("files"), list) or isinstance(value.get("commands"), list)):
427
+ return value
428
+ file_bundle = parse_file_bundle(text)
429
+ return file_bundle
430
+
431
+
432
+ def parse_command_requests(text: str) -> list[dict[str, Any]]:
433
+ bundle = parse_action_bundle(text)
434
+ if not bundle:
435
+ return []
436
+ commands = bundle.get("commands")
437
+ if not isinstance(commands, list):
438
+ return []
439
+ parsed: list[dict[str, Any]] = []
440
+ for item in commands:
441
+ if not isinstance(item, dict):
442
+ continue
443
+ argv = item.get("argv")
444
+ cmd = item.get("cmd")
445
+ if isinstance(argv, list) and all(isinstance(part, str) for part in argv):
446
+ parts = argv
447
+ elif isinstance(cmd, str):
448
+ try:
449
+ parts = shlex.split(cmd)
450
+ except ValueError:
451
+ continue
452
+ else:
453
+ continue
454
+ if not parts:
455
+ continue
456
+ parsed.append({"argv": parts, "cwd": item.get("cwd") if isinstance(item.get("cwd"), str) else "."})
457
+ return parsed
458
+
459
+
460
+ def code_block_candidates(text: str) -> list[tuple[str, str]]:
461
+ blocks = []
462
+ for match in re.finditer(r"```(\w+)?\s*([\s\S]*?)```", text):
463
+ language = (match.group(1) or "").lower()
464
+ code = match.group(2).strip()
465
+ if code:
466
+ blocks.append((language, code))
467
+ return blocks
468
+
469
+
470
+ def synthesize_file_bundle_from_code_blocks(text: str) -> dict[str, Any] | None:
471
+ files: list[dict[str, str]] = []
472
+ counters: dict[str, int] = {}
473
+ extension_by_language = {
474
+ "html": "html",
475
+ "css": "css",
476
+ "javascript": "js",
477
+ "js": "js",
478
+ "typescript": "ts",
479
+ "ts": "ts",
480
+ "tsx": "tsx",
481
+ "jsx": "jsx",
482
+ "python": "py",
483
+ "py": "py",
484
+ "swift": "swift",
485
+ "json": "json",
486
+ "markdown": "md",
487
+ "md": "md",
488
+ }
489
+ default_name = {
490
+ "html": "index.html",
491
+ "css": "styles.css",
492
+ "javascript": "script.js",
493
+ "js": "script.js",
494
+ }
495
+ for language, code in code_block_candidates(text):
496
+ if language == "json":
497
+ continue
498
+ ext = extension_by_language.get(language)
499
+ if not ext:
500
+ if "<html" in code.lower() or "<!doctype html" in code.lower():
501
+ ext = "html"
502
+ language = "html"
503
+ else:
504
+ continue
505
+ name = default_name.get(language)
506
+ if not name:
507
+ counters[ext] = counters.get(ext, 0) + 1
508
+ name = f"generated-{counters[ext]}.{ext}"
509
+ if any(file["path"] == name for file in files):
510
+ counters[ext] = counters.get(ext, 1) + 1
511
+ stem, suffix = name.rsplit(".", 1)
512
+ name = f"{stem}-{counters[ext]}.{suffix}"
513
+ files.append({"path": name, "content": code})
514
+ if not files:
515
+ return None
516
+ return {"message": "Created files from the model response.", "files": files}
517
+
518
+
519
+ def bundle_to_text(bundle: dict[str, Any]) -> str:
520
+ return json.dumps(bundle, ensure_ascii=False)
521
+
522
+
523
+ def repair_file_bundle_with_model(server: dict[str, Any], project: Path, user_text: str, assistant_text: str) -> str | None:
524
+ repair_system = (
525
+ "You convert a coding assistant answer into Socra file JSON. "
526
+ "Return ONLY JSON with this shape: "
527
+ '{"message":"short summary","files":[{"path":"relative/project/path","content":"complete file content"}]}. '
528
+ "Use only relative paths. Do not include prose or markdown."
529
+ )
530
+ repair_user = (
531
+ f"Project root: {project}\n"
532
+ f"Original user request:\n{user_text}\n\n"
533
+ f"Assistant answer to package:\n{assistant_text}"
534
+ )
535
+ try:
536
+ repaired = post_chat(
537
+ server,
538
+ [{"role": "system", "content": repair_system}, {"role": "user", "content": repair_user}],
539
+ json_mode=True,
540
+ )
541
+ except Exception:
542
+ return None
543
+ if parse_file_bundle(repaired):
544
+ return repaired
545
+ return None
546
+
547
+
548
+ def repair_written_files_with_model(
549
+ server: dict[str, Any],
550
+ project: Path,
551
+ user_text: str,
552
+ written: list[str],
553
+ validation_errors: list[str],
554
+ ) -> str | None:
555
+ file_sections = []
556
+ for rel in written:
557
+ try:
558
+ content = safe_project_file(project, rel).read_text(encoding="utf-8")
559
+ except Exception:
560
+ continue
561
+ file_sections.append(f"--- {rel} ---\n{content}")
562
+ repair_system = (
563
+ "You repair files that were just generated by a coding agent. "
564
+ "Return ONLY Socra file JSON: "
565
+ '{"message":"short summary","files":[{"path":"relative/project/path","content":"complete file content"}]}. '
566
+ "Include complete corrected contents for every changed file. Use relative paths only."
567
+ )
568
+ repair_user = (
569
+ f"Original request:\n{user_text}\n\n"
570
+ f"Validation errors:\n" + "\n".join(f"- {error}" for error in validation_errors) + "\n\n"
571
+ f"Current files:\n\n" + "\n\n".join(file_sections)
572
+ )
573
+ try:
574
+ repaired = post_chat(
575
+ server,
576
+ [{"role": "system", "content": repair_system}, {"role": "user", "content": repair_user}],
577
+ json_mode=True,
578
+ )
579
+ except Exception:
580
+ return None
581
+ if parse_file_bundle(repaired):
582
+ return repaired
583
+ return None
584
+
585
+
586
+ def safe_project_file(project: Path, relative: str) -> Path:
587
+ raw = Path(relative)
588
+ if raw.is_absolute() or any(part == ".." for part in raw.parts):
589
+ raise ValueError(f"unsafe path: {relative}")
590
+ target = (project / raw).resolve()
591
+ project_root = project.resolve()
592
+ if target != project_root and project_root not in target.parents:
593
+ raise ValueError(f"path escapes project: {relative}")
594
+ return target
595
+
596
+
597
+ def read_file_if_exists(project: Path, relative: str) -> str:
598
+ path = safe_project_file(project, relative)
599
+ if not path.exists() or not path.is_file():
600
+ return ""
601
+ return path.read_text(encoding="utf-8", errors="replace")
602
+
603
+
604
+ def file_mentions(project: Path, html: str, relative: str) -> bool:
605
+ return relative in html or f"./{relative}" in html
606
+
607
+
608
+ def self_contain_static_site(project: Path, written: list[str], state_dir: Path) -> list[str]:
609
+ lower_written = {path.lower() for path in written}
610
+ html_files = [path for path in written if path.lower().endswith((".html", ".htm"))]
611
+ if "index.html" not in lower_written or not html_files:
612
+ return written
613
+ index = safe_project_file(project, "index.html")
614
+ html = index.read_text(encoding="utf-8", errors="replace")
615
+ changed = False
616
+ css = read_file_if_exists(project, "styles.css")
617
+ if css and file_mentions(project, html, "styles.css") and "<style>" not in html.lower():
618
+ html = re.sub(r'\s*<link[^>]+href=["\'](?:\./)?styles\.css["\'][^>]*>\s*', "\n", html, flags=re.IGNORECASE)
619
+ html = html.replace("</head>", f" <style>\n{css}\n </style>\n</head>")
620
+ changed = True
621
+ js = read_file_if_exists(project, "script.js")
622
+ if js and file_mentions(project, html, "script.js") and "<script>" not in html.lower():
623
+ html = re.sub(r'\s*<script[^>]+src=["\'](?:\./)?script\.js["\'][^>]*>\s*</script>\s*', "\n", html, flags=re.IGNORECASE)
624
+ html = html.replace("</body>", f" <script>\n{js}\n </script>\n</body>")
625
+ changed = True
626
+ if changed:
627
+ index.write_text(html, encoding="utf-8")
628
+ if "index.html" not in written:
629
+ written.append("index.html")
630
+ record = {"timestamp": now_iso(), "project": str(project.resolve()), "action": "self_contained_static_site", "files": ["index.html"]}
631
+ append_jsonl(state_dir / "validation-repairs.jsonl", record)
632
+ return written
633
+
634
+
635
+ def validate_written_files(project: Path, written: list[str], state_dir: Path) -> tuple[bool, list[str]]:
636
+ errors: list[str] = []
637
+ checks: list[dict[str, Any]] = []
638
+ for rel in written:
639
+ path = safe_project_file(project, rel)
640
+ suffix = path.suffix.lower()
641
+ ok = True
642
+ output = ""
643
+ if suffix == ".js":
644
+ ok, output = run_command(["node", "--check", str(path)], cwd=project)
645
+ elif suffix == ".json":
646
+ try:
647
+ json.loads(path.read_text(encoding="utf-8"))
648
+ output = "json ok"
649
+ except Exception as exc:
650
+ ok = False
651
+ output = str(exc)
652
+ elif suffix == ".py":
653
+ ok, output = run_command([sys.executable, "-m", "py_compile", str(path)], cwd=project)
654
+ elif suffix == ".swift":
655
+ ok, output = run_command(["swiftc", "-parse", str(path)], cwd=project)
656
+ checks.append({"file": rel, "ok": ok, "output": output[-2000:]})
657
+ if not ok:
658
+ errors.append(f"{rel}: {output[-1000:]}")
659
+ if any(path.lower().endswith((".html", ".htm")) for path in written):
660
+ for rel in [path for path in written if path.lower().endswith((".html", ".htm"))]:
661
+ html = safe_project_file(project, rel).read_text(encoding="utf-8", errors="replace")
662
+ if "<style>" not in html.lower() and ".css" not in html.lower():
663
+ errors.append(f"{rel}: no visible CSS detected")
664
+ if "<script" not in html.lower() and ".js" not in html.lower():
665
+ errors.append(f"{rel}: no script detected")
666
+ state_dir.mkdir(parents=True, exist_ok=True)
667
+ record = {"timestamp": now_iso(), "checks": checks, "errors": errors}
668
+ (state_dir / "last-validation.json").write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
669
+ append_jsonl(state_dir / "validation.jsonl", record)
670
+ return not errors, errors
671
+
672
+
673
+ def reviewer_agent_check(project: Path, state_dir: Path, user_text: str, written: list[str]) -> tuple[list[str], list[str]]:
674
+ record_agent_stage(state_dir, "reviewer", "running", files=written)
675
+ current = self_contain_static_site(project, list(written), state_dir) if prompt_wants_website(user_text) else list(written)
676
+ ok, errors = validate_written_files(project, current, state_dir)
677
+ record_agent_stage(state_dir, "reviewer", "passed" if ok else "failed", files=current, errors=errors)
678
+ return current, errors
679
+
680
+
681
+ def runner_agent_check(project: Path, state_dir: Path, user_text: str, written: list[str]) -> list[str]:
682
+ record_agent_stage(state_dir, "runner", "running", files=written)
683
+ errors: list[str] = []
684
+ if prompt_wants_website(user_text):
685
+ ok, output = run_static_preview_smoke(project, state_dir, written)
686
+ if not ok:
687
+ errors.append(output)
688
+ record_agent_stage(state_dir, "runner", "passed" if not errors else "failed", files=written, errors=errors)
689
+ return errors
690
+
691
+
692
+ def validate_and_repair_output(
693
+ server: dict[str, Any],
694
+ project: Path,
695
+ state_dir: Path,
696
+ user_text: str,
697
+ written: list[str],
698
+ ) -> tuple[list[str], list[str]]:
699
+ original = list(written)
700
+ current = list(written)
701
+ all_errors: list[str] = []
702
+ for attempt in range(1, MAX_AGENT_LOOP_PASSES + 1):
703
+ write_status(state_dir, f"Reviewer pass {attempt}/{MAX_AGENT_LOOP_PASSES}...", stage="reviewer", attempt=attempt)
704
+ current, review_errors = reviewer_agent_check(project, state_dir, user_text, current)
705
+ write_status(state_dir, f"Runner pass {attempt}/{MAX_AGENT_LOOP_PASSES}...", stage="runner", attempt=attempt)
706
+ run_errors = runner_agent_check(project, state_dir, user_text, current)
707
+ all_errors = [*review_errors, *run_errors]
708
+ if not all_errors:
709
+ record_agent_stage(state_dir, "agent_loop", "passed", attempt=attempt, files=current)
710
+ return current, []
711
+ if attempt >= MAX_AGENT_LOOP_PASSES:
712
+ break
713
+ write_status(state_dir, f"Creator repair pass {attempt + 1}/{MAX_AGENT_LOOP_PASSES}...", stage="creator_repair", attempt=attempt + 1)
714
+ record_agent_stage(state_dir, "creator", "repairing", attempt=attempt + 1, notes=all_errors)
715
+ repaired = repair_written_files_with_model(server, project, user_text, current, all_errors)
716
+ if not repaired:
717
+ record_agent_stage(state_dir, "creator", "failed", attempt=attempt + 1, notes=["model did not return repair bundle"])
718
+ break
719
+ _message, repaired_written = apply_file_bundle(project, repaired, state_dir)
720
+ current = sorted(set([*current, *repaired_written]))
721
+ record_agent_stage(state_dir, "creator", "repaired", attempt=attempt + 1, files=repaired_written)
722
+ record_agent_stage(state_dir, "agent_loop", "failed", files=current, errors=all_errors)
723
+ return original, all_errors
724
+
725
+
726
+ def write_status(state_dir: Path, message: str, **extra: Any) -> None:
727
+ state_dir.mkdir(parents=True, exist_ok=True)
728
+ payload = {"timestamp": now_iso(), "message": message, **extra}
729
+ (state_dir / "working-status.json").write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
730
+
731
+
732
+ def record_agent_stage(state_dir: Path, stage: str, status: str, **extra: Any) -> None:
733
+ row = {"timestamp": now_iso(), "stage": stage, "status": status, **extra}
734
+ append_jsonl(state_dir / "agent-loop.jsonl", row)
735
+ state_dir.mkdir(parents=True, exist_ok=True)
736
+ (state_dir / "last-agent-stage.json").write_text(json.dumps(row, indent=2) + "\n", encoding="utf-8")
737
+
738
+
739
+ def free_loopback_port() -> int:
740
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
741
+ sock.bind(("127.0.0.1", 0))
742
+ return int(sock.getsockname()[1])
743
+
744
+
745
+ def stop_previous_preview(state_dir: Path) -> None:
746
+ state = read_json(state_dir / "preview-state.json") or {}
747
+ pid = state.get("pid")
748
+ if not isinstance(pid, int):
749
+ return
750
+ try:
751
+ os.kill(pid, 0)
752
+ except OSError:
753
+ return
754
+ try:
755
+ os.kill(pid, 15)
756
+ except OSError:
757
+ return
758
+
759
+
760
+ def copy_written_files_to_sandbox(project: Path, sandbox: Path, written: list[str]) -> None:
761
+ if sandbox.exists():
762
+ shutil.rmtree(sandbox)
763
+ sandbox.mkdir(parents=True, exist_ok=True)
764
+ for rel in written:
765
+ source = safe_project_file(project, rel)
766
+ if not source.exists() or not source.is_file():
767
+ continue
768
+ target = safe_project_file(sandbox, rel)
769
+ target.parent.mkdir(parents=True, exist_ok=True)
770
+ shutil.copy2(source, target)
771
+
772
+
773
+ def prepare_static_preview_sandbox(project: Path, state_dir: Path, written: list[str]) -> tuple[Path, str] | None:
774
+ route = preview_path_for(written)
775
+ if not route:
776
+ return None
777
+ sandbox = state_dir / "run-sandbox" / "static-preview"
778
+ copy_written_files_to_sandbox(project.resolve(), sandbox, written)
779
+ return sandbox, route
780
+
781
+
782
+ def run_static_preview_smoke(project: Path, state_dir: Path, written: list[str]) -> tuple[bool, str]:
783
+ prepared = prepare_static_preview_sandbox(project, state_dir, written)
784
+ if not prepared:
785
+ return True, "no static preview target"
786
+ sandbox, route = prepared
787
+ file_url = preview_file_url(sandbox, route)
788
+ parsed = urllib.parse.urlparse(file_url)
789
+ file_path = Path(urllib.request.url2pathname(parsed.path))
790
+ if not file_path.exists():
791
+ return False, f"preview entry file missing: {file_path}"
792
+ html = file_path.read_text(encoding="utf-8", errors="replace")
793
+ if not html.strip():
794
+ return False, f"preview entry file is empty: {file_path}"
795
+ return True, f"preview smoke ok: {file_url}"
796
+
797
+
798
+ def preview_path_for(written: list[str]) -> str | None:
799
+ html = [path for path in written if path.lower().endswith((".html", ".htm"))]
800
+ if not html:
801
+ return None
802
+ if "index.html" in {Path(path).name.lower() for path in html}:
803
+ for path in html:
804
+ if Path(path).name.lower() == "index.html":
805
+ parent = str(Path(path).parent)
806
+ return "/" if parent == "." else "/" + parent.replace(os.sep, "/") + "/"
807
+ return "/" + html[0].replace(os.sep, "/")
808
+
809
+
810
+ def preview_file_url(sandbox: Path, route: str) -> str:
811
+ if route.endswith("/"):
812
+ file_path = sandbox / route.lstrip("/") / "index.html"
813
+ else:
814
+ file_path = sandbox / route.lstrip("/")
815
+ return file_path.resolve().as_uri()
816
+
817
+
818
+ def preview_server_ready(url: str, timeout_seconds: float = 2.0) -> bool:
819
+ deadline = time.time() + timeout_seconds
820
+ while time.time() < deadline:
821
+ try:
822
+ with urllib.request.urlopen(url, timeout=0.5) as res:
823
+ return 200 <= res.status < 500
824
+ except Exception:
825
+ time.sleep(0.1)
826
+ return False
827
+
828
+
829
+ def launch_static_preview(project: Path, state_dir: Path, written: list[str]) -> str | None:
830
+ prepared = prepare_static_preview_sandbox(project, state_dir, written)
831
+ if not prepared:
832
+ return None
833
+ sandbox, route = prepared
834
+ project_root = project.resolve()
835
+ write_status(state_dir, "Preparing local preview sandbox...")
836
+ stop_previous_preview(state_dir)
837
+ port = free_loopback_port()
838
+ out_log = state_dir / "preview-server.out.log"
839
+ err_log = state_dir / "preview-server.err.log"
840
+ with out_log.open("a", encoding="utf-8") as out, err_log.open("a", encoding="utf-8") as err:
841
+ proc = subprocess.Popen(
842
+ [sys.executable, "-m", "http.server", str(port), "--bind", "127.0.0.1", "--directory", str(sandbox)],
843
+ stdout=out,
844
+ stderr=err,
845
+ )
846
+ http_url = f"http://127.0.0.1:{port}{route}"
847
+ if preview_server_ready(http_url):
848
+ url = http_url
849
+ mode = "http_server"
850
+ pid: int | None = proc.pid
851
+ else:
852
+ proc.terminate()
853
+ url = preview_file_url(sandbox, route)
854
+ mode = "file_url_fallback"
855
+ pid = None
856
+ record = {
857
+ "timestamp": now_iso(),
858
+ "pid": pid,
859
+ "url": url,
860
+ "mode": mode,
861
+ "sandbox": str(sandbox),
862
+ "project": str(project_root),
863
+ "files": written,
864
+ }
865
+ (state_dir / "preview-state.json").write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
866
+ if os.environ.get("SOCRA_NO_BROWSER") != "1":
867
+ try:
868
+ webbrowser.open(url)
869
+ except Exception:
870
+ pass
871
+ return url
872
+
873
+
874
+ def apply_file_bundle(project: Path, text: str, state_dir: Path) -> tuple[str | None, list[str]]:
875
+ bundle = parse_file_bundle(text)
876
+ if not bundle:
877
+ return None, []
878
+ project_root = project.resolve()
879
+ written: list[str] = []
880
+ for item in bundle.get("files") or []:
881
+ if not isinstance(item, dict):
882
+ continue
883
+ rel = item.get("path")
884
+ content = item.get("content")
885
+ if not isinstance(rel, str) or not isinstance(content, str):
886
+ continue
887
+ target = safe_project_file(project_root, rel)
888
+ target.parent.mkdir(parents=True, exist_ok=True)
889
+ target.write_text(content, encoding="utf-8")
890
+ written.append(str(target.relative_to(project_root)))
891
+ record = {"timestamp": now_iso(), "project": str(project_root), "message": bundle.get("message"), "files": written}
892
+ state_dir.mkdir(parents=True, exist_ok=True)
893
+ (state_dir / "last-file-write.json").write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
894
+ append_jsonl(state_dir / "file-writes.jsonl", record)
895
+ return str(bundle.get("message") or "").strip() or None, written
896
+
897
+
898
+ def prompt_line(stdscr: Any, prompt: str) -> str:
899
+ h, w = stdscr.getmaxyx()
900
+ y = h - 2
901
+ stdscr.nodelay(False)
902
+ curses.echo()
903
+ safe_addstr(stdscr, y, 0, " " * max(0, w - 1))
904
+ safe_addstr(stdscr, y, 0, prompt)
905
+ stdscr.move(y, min(len(prompt), max(0, w - 2)))
906
+ try:
907
+ raw = stdscr.getstr(y, min(len(prompt), max(0, w - 2)), max(1, w - len(prompt) - 2))
908
+ finally:
909
+ curses.noecho()
910
+ stdscr.nodelay(True)
911
+ return raw.decode("utf-8", errors="replace").strip()
912
+
913
+
914
+ def project_from_state(state_dir: Path, session: dict[str, Any]) -> Path:
915
+ project = session.get("project")
916
+ if isinstance(project, str) and project:
917
+ return Path(project).expanduser().resolve()
918
+ if state_dir.name == ".socra":
919
+ return state_dir.parent.resolve()
920
+ return Path.cwd().resolve()
921
+
922
+
923
+ def run_chat_turn(stdscr: Any, state_dir: Path, user_text: str) -> None:
924
+ server = read_json(state_dir / "server-state.json") or {}
925
+ session = read_json(state_dir / "session.json") or {}
926
+ candidates = read_json(state_dir / "candidates.json") or {}
927
+ project = project_from_state(state_dir, session)
928
+ chat_path = state_dir / "chat.jsonl"
929
+ append_jsonl(chat_path, {"timestamp": now_iso(), "role": "user", "content": user_text})
930
+
931
+ history = read_chat(chat_path, limit=8)
932
+ messages = [{"role": "system", "content": chat_system_prompt(project, candidates, session)}]
933
+ for row in history:
934
+ if row.get("role") in {"user", "assistant"} and isinstance(row.get("content"), str):
935
+ messages.append({"role": row["role"], "content": row["content"]})
936
+
937
+ h, _w = stdscr.getmaxyx()
938
+ write_status(state_dir, "Model is working...", stage="model_call")
939
+ safe_addstr(stdscr, h - 1, 0, "Model is working locally...")
940
+ stdscr.refresh()
941
+ try:
942
+ wants_files = prompt_wants_files(user_text)
943
+ assistant_text = post_chat(server, messages, json_mode=wants_files)
944
+ write_status(state_dir, "Parsing response...", stage="parse_response")
945
+ command_results: list[dict[str, Any]] = []
946
+ command_errors: list[str] = []
947
+ commands = parse_command_requests(assistant_text)
948
+ bundle_message, written = apply_file_bundle(project, assistant_text, state_dir)
949
+ if commands:
950
+ write_status(state_dir, "Running requested sandbox commands...", stage="sandbox_commands")
951
+ command_results, command_errors = execute_sandbox_commands(project, state_dir, session, commands)
952
+ if wants_files and not written:
953
+ write_status(state_dir, "Repackaging model output into files...", stage="json_repair")
954
+ repaired = repair_file_bundle_with_model(server, project, user_text, assistant_text)
955
+ if repaired:
956
+ bundle_message, written = apply_file_bundle(project, repaired, state_dir)
957
+ assistant_text = repaired
958
+ repaired_commands = parse_command_requests(repaired)
959
+ if repaired_commands:
960
+ write_status(state_dir, "Running requested sandbox commands...", stage="sandbox_commands")
961
+ extra_results, extra_errors = execute_sandbox_commands(project, state_dir, session, repaired_commands)
962
+ command_results.extend(extra_results)
963
+ command_errors.extend(extra_errors)
964
+ if wants_files and not written:
965
+ synthesized = synthesize_file_bundle_from_code_blocks(assistant_text)
966
+ if synthesized:
967
+ bundle_message, written = apply_file_bundle(project, bundle_to_text(synthesized), state_dir)
968
+ display_text = bundle_message or assistant_text
969
+ if written:
970
+ record_agent_stage(state_dir, "creator", "created", files=written)
971
+ write_status(state_dir, "Validating generated files...", stage="validate")
972
+ written, validation_errors = validate_and_repair_output(server, project, state_dir, user_text, written)
973
+ write_status(state_dir, "Launching local preview sandbox...", stage="preview")
974
+ preview_url = launch_static_preview(project, state_dir, written)
975
+ if validation_errors:
976
+ display_text = f"{display_text}\nValidation warnings: {'; '.join(validation_errors[:2])}"
977
+ if preview_url:
978
+ display_text = f"{display_text}\nWrote: {', '.join(written)}\nPreview: {preview_url}"
979
+ else:
980
+ display_text = f"{display_text}\nWrote: {', '.join(written)}"
981
+ if command_results:
982
+ passed = sum(1 for result in command_results if result.get("ok"))
983
+ display_text = f"{display_text}\nSandbox commands: {passed}/{len(command_results)} passed."
984
+ if command_errors:
985
+ display_text = f"{display_text}\nSandbox warnings: {'; '.join(command_errors[:2])}"
986
+ if wants_files and not written and not command_results:
987
+ display_text = (
988
+ "No files were written because the model did not return the required Socra file JSON. "
989
+ "Try again with: create the files and return ONLY the JSON file bundle."
990
+ )
991
+ append_jsonl(chat_path, {"timestamp": now_iso(), "role": "assistant", "content": display_text, "files": written, "commands": command_results})
992
+ write_status(state_dir, "Ready.", stage="idle")
993
+ except Exception as exc:
994
+ append_jsonl(chat_path, {"timestamp": now_iso(), "role": "system", "content": f"Chat error: {exc}"})
995
+ write_status(state_dir, f"Chat error: {exc}", stage="error")
996
+
997
+
998
+ def draw_chat(stdscr: Any, y: int, state_dir: Path, max_rows: int) -> int:
999
+ safe_addstr(stdscr, y, 0, "Vibe Chat", curses.A_BOLD)
1000
+ y += 1
1001
+ chat = read_chat(state_dir / "chat.jsonl", limit=6)
1002
+ if not chat:
1003
+ safe_addstr(stdscr, y, 2, "Press Enter or c to chat with the loaded local model.")
1004
+ return y + 1
1005
+ rows_used = 0
1006
+ for row in chat[-6:]:
1007
+ if rows_used >= max_rows:
1008
+ break
1009
+ role = row.get("role", "?")
1010
+ content = str(row.get("content") or "").replace("\n", " ")
1011
+ safe_addstr(stdscr, y, 2, f"{role}: {content}")
1012
+ y += 1
1013
+ rows_used += 1
1014
+ return y
1015
+
1016
+
1017
+ def tui_loop(stdscr: Any, state_dir: Path, refresh: float) -> None:
1018
+ curses.curs_set(0)
1019
+ stdscr.nodelay(True)
1020
+ event_path = state_dir / "events.jsonl"
1021
+ while True:
1022
+ stdscr.erase()
1023
+ h, w = stdscr.getmaxyx()
1024
+ events = read_events(event_path, limit=400)
1025
+ server = read_json(state_dir / "server-state.json") or {}
1026
+ status = read_json(state_dir / "working-status.json") or {}
1027
+ agent_stage = read_json(state_dir / "last-agent-stage.json") or {}
1028
+ preview = read_json(state_dir / "preview-state.json") or {}
1029
+ candidates = read_json(state_dir / "candidates.json") or {}
1030
+ results = read_json(state_dir / "humaneval" / "results.json") or read_json(state_dir / "results.json") or {}
1031
+ done, total, passed = latest_eval_progress(events)
1032
+
1033
+ safe_addstr(stdscr, 0, 0, "Socra Harness Terminal Dashboard", curses.A_BOLD)
1034
+ safe_addstr(stdscr, 1, 0, "Enter/c chat + create files | q quit | live from .socra state")
1035
+
1036
+ y = 3
1037
+ safe_addstr(stdscr, y, 0, "Runtime", curses.A_BOLD)
1038
+ y += 1
1039
+ model_label = server.get("model_alias") or server.get("model", "not running")
1040
+ safe_addstr(stdscr, y, 2, f"Model: {model_label}")
1041
+ y += 1
1042
+ endpoint = server.get("endpoint") or (f"http://{server.get('host', '127.0.0.1')}:{server.get('port', '11439')}/v1" if server else "not running")
1043
+ safe_addstr(stdscr, y, 2, f"Endpoint: {endpoint}")
1044
+ y += 1
1045
+ safe_addstr(stdscr, y, 2, f"Backend: {server.get('backend_endpoint', 'unknown')}")
1046
+ y += 1
1047
+ safe_addstr(
1048
+ stdscr,
1049
+ y,
1050
+ 2,
1051
+ f"Harness: {server.get('harness', 'unknown')} | local-first: {server.get('local_first', 'unknown')} | hosted fallback: {server.get('hosted_fallback', 'unknown')}",
1052
+ )
1053
+ y += 2
1054
+ if status.get("message"):
1055
+ safe_addstr(stdscr, y, 2, f"Status: {status.get('message')}")
1056
+ y += 1
1057
+ if agent_stage.get("stage"):
1058
+ safe_addstr(stdscr, y, 2, f"Agent: {agent_stage.get('stage')} / {agent_stage.get('status')}")
1059
+ y += 1
1060
+ if preview.get("url"):
1061
+ safe_addstr(stdscr, y, 2, f"Preview: {preview.get('url')}")
1062
+ y += 1
1063
+ y += 1
1064
+
1065
+ safe_addstr(stdscr, y, 0, "HumanEval", curses.A_BOLD)
1066
+ y += 1
1067
+ fraction = (done / total) if total else 0
1068
+ draw_bar(stdscr, y, 2, min(42, max(10, w - 30)), fraction, f"{done}/{total} tasks, {passed} passed")
1069
+ y += 1
1070
+ if results.get("summary"):
1071
+ summary = results["summary"]
1072
+ safe_addstr(stdscr, y, 2, f"Latest: {summary.get('passed')}/{summary.get('total')} pass@1={pct(summary.get('pass_at_1', 0))}")
1073
+ y += 2
1074
+ else:
1075
+ y += 1
1076
+
1077
+ safe_addstr(stdscr, y, 0, "Repo Scan", curses.A_BOLD)
1078
+ y += 1
1079
+ safe_addstr(stdscr, y, 2, f"Source files: {candidates.get('source_files', 0)} | candidates: {candidates.get('candidate_count', 0)}")
1080
+ y += 1
1081
+ for item in (candidates.get("candidates") or [])[: min(4, max(0, h - y - 12))]:
1082
+ safe_addstr(stdscr, y, 2, f"{item['family']}: {item['file_count']} files, score {item['score_estimate']}")
1083
+ y += 1
1084
+ y += 1
1085
+
1086
+ chat_rows = max(3, min(8, h - y - 8))
1087
+ y = draw_chat(stdscr, y, state_dir, chat_rows)
1088
+ y += 1
1089
+
1090
+ safe_addstr(stdscr, y, 0, "Recent Events", curses.A_BOLD)
1091
+ y += 1
1092
+ for event in events[-min(5, max(0, h - y - 3)):]:
1093
+ label = event.get("event", "event")
1094
+ detail = event.get("task_id") or event.get("folder") or event.get("model") or ""
1095
+ safe_addstr(stdscr, y, 2, f"{label}: {detail}")
1096
+ y += 1
1097
+
1098
+ safe_addstr(stdscr, h - 1, 0, "Enter/c: chat | q: quit")
1099
+ stdscr.refresh()
1100
+ key = stdscr.getch()
1101
+ if key in {ord("q"), ord("Q")}:
1102
+ return
1103
+ if key in {10, 13, ord("c"), ord("C")}:
1104
+ user_text = prompt_line(stdscr, "You: ")
1105
+ if user_text:
1106
+ run_chat_turn(stdscr, state_dir, user_text)
1107
+ continue
1108
+ time.sleep(refresh)
1109
+
1110
+
1111
+ def tui_main(args: Any) -> int:
1112
+ args.state_dir.mkdir(parents=True, exist_ok=True)
1113
+ curses.wrapper(tui_loop, args.state_dir, args.refresh)
1114
+ return 0