Chris4K commited on
Commit
dbe7d35
Β·
verified Β·
1 Parent(s): 2b40cbb

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +449 -4
main.py CHANGED
@@ -57,8 +57,59 @@ ALLOWED_BINS = {
57
  "ls": ["ls"],
58
  "find": ["find"],
59
  }
60
- EXEC_TIMEOUT = int(os.environ.get("VAULT_EXEC_TIMEOUT", "30"))
61
- MAX_OUTPUT = 64 * 1024 # 64KB output cap
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
  # ── Meta / stats ──────────────────────────────────────────────────
64
  def load_meta():
@@ -387,7 +438,24 @@ async def exec_code(request: Request):
387
  env = data.get("env",{})
388
  timeout = int(data.get("timeout", EXEC_TIMEOUT))
389
  if not code.strip(): raise HTTPException(400, "code required")
390
- result = await exec_command(runtime, code, cwd, env, min(timeout, 60))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  return jresp(result)
392
 
393
  @app.get("/api/exec/stream")
@@ -442,6 +510,175 @@ async def stats():
442
  "total_writes":META["total_writes"],"total_execs":META["total_execs"],
443
  "by_ext":dict(sorted(by_ext.items(),key=lambda x:-x[1])[:10])})
444
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
  # ── MCP ───────────────────────────────────────────────────────────
446
  MCP_TOOLS = [
447
  {"name":"vault_read","description":"Read a file from the workspace",
@@ -476,6 +713,14 @@ MCP_TOOLS = [
476
  "src":{"type":"string"},"dst":{"type":"string"}}}},
477
  {"name":"vault_stats","description":"Get workspace statistics",
478
  "inputSchema":{"type":"object","properties":{}}},
 
 
 
 
 
 
 
 
479
  ]
480
 
481
  async def mcp_call(name, args):
@@ -529,6 +774,36 @@ async def mcp_call(name, args):
529
  if p.is_file(): total_files+=1; total_size+=p.stat().st_size
530
  return json.dumps({"total_files":total_files,"total_size":total_size,
531
  "total_writes":META["total_writes"],"total_execs":META["total_execs"]})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
  return json.dumps({"error": f"unknown tool: {name}"})
533
 
534
  @app.get("/mcp/sse")
@@ -871,6 +1146,7 @@ body::after{content:'';position:fixed;inset:0;pointer-events:none;
871
  <div class="tab-item" id="tab-editor" >&#9998; Editor</div>
872
  <div class="tab-item" id="tab-terminal">&#62; Terminal</div>
873
  <div class="tab-item" id="tab-exec-log">&#9881; Exec Log</div>
 
874
  </div>
875
 
876
  <!-- BROWSER -->
@@ -924,6 +1200,74 @@ body::after{content:'';position:fixed;inset:0;pointer-events:none;
924
  <!-- EXEC LOG -->
925
  <div id="exec-log-view" style="display:none;flex:1;overflow-y:auto;padding:.6rem .9rem;"></div>
926
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
927
  <!-- VERSIONS PANEL -->
928
  <div id="versions-panel">
929
  <div id="vp-hdr">
@@ -1308,17 +1652,118 @@ function showTab(t){
1308
  document.getElementById('tab-editor').className='tab-item'+(t==='editor'?' on':'');
1309
  document.getElementById('tab-terminal').className='tab-item'+(t==='terminal'?' on':'');
1310
  document.getElementById('tab-exec-log').className='tab-item'+(t==='exec-log'?' on':'');
 
1311
  document.getElementById('browser-view').style.display=t==='browser'?'block':'none';
1312
  document.getElementById('editor-wrap').style.display=t==='editor'?'flex':'none';
1313
  document.getElementById('terminal-wrap').style.display=t==='terminal'?'flex':'none';
1314
  document.getElementById('exec-log-view').style.display=t==='exec-log'?'block':'none';
 
1315
  if(t==='exec-log') loadExecLog();
 
1316
  }
1317
  document.getElementById('tab-browser').addEventListener('click',function(){showTab('browser');});
1318
  document.getElementById('tab-editor').addEventListener('click',function(){showTab('editor');});
1319
  document.getElementById('tab-terminal').addEventListener('click',function(){showTab('terminal');});
1320
  document.getElementById('tab-exec-log').addEventListener('click',function(){showTab('exec-log');});
1321
  document.getElementById('btn-open-term').addEventListener('click',function(){showTab('terminal');});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1322
 
1323
  // ── View toggle ───────────────────────────────────────────────────
1324
  document.getElementById('vt-list').addEventListener('click',function(){
@@ -1427,4 +1872,4 @@ loadTree();
1427
  loadBrowser('');
1428
  </script>
1429
  </body>
1430
- </html>"""
 
57
  "ls": ["ls"],
58
  "find": ["find"],
59
  }
60
+ EXEC_TIMEOUT = int(os.environ.get("VAULT_EXEC_TIMEOUT", "30"))
61
+ MAX_OUTPUT = 64 * 1024 # 64KB output cap
62
+
63
+ # ── Sprint 6 β€” security + AI integrations ─────────────────────────
64
+ APPROVE_URL = os.environ.get("APPROVE_URL", "")
65
+ HARNESS_URL = os.environ.get("HARNESS_URL", "")
66
+ ANTHROPIC_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
67
+ GH_TOKEN = os.environ.get("GH_TOKEN", "")
68
+ VAULT_KEY = os.environ.get("VAULT_KEY", "")
69
+
70
+ # Patterns that require human approval before exec
71
+ RISKY_EXEC_PATTERNS = [
72
+ "rm -rf", "rm -f /", "dd if=", "mkfs", "> /dev/",
73
+ ":(){ :|:& };:", "chmod -R 777", "wget.*| bash", "curl.*| sh",
74
+ ]
75
+ RISKY_GIT_PATTERNS = ["push --force", "push -f", "reset --hard", "clean -fd"]
76
+
77
+ import httpx as _httpx # sync httpx for gate helpers (called from sync context)
78
+
79
+ def _approve_sync(tool: str, args: dict, risk: str = "high") -> tuple[bool, str]:
80
+ """Synchronous approval gate β€” polls up to 90s."""
81
+ if not APPROVE_URL:
82
+ return True, "approve_url_missing"
83
+ try:
84
+ r = _httpx.post(f"{APPROVE_URL}/api/approval/request",
85
+ json={"agent": "vault", "tool": tool, "args": args,
86
+ "risk": risk, "auto_timeout": 120}, timeout=6)
87
+ if r.status_code == 200:
88
+ aid = r.json().get("id")
89
+ for _ in range(18):
90
+ import time as _t; _t.sleep(5)
91
+ pr = _httpx.get(f"{APPROVE_URL}/api/approval/{aid}", timeout=4)
92
+ if pr.status_code == 200:
93
+ st = pr.json().get("status")
94
+ if st == "approved": return True, "human_approved"
95
+ if st in ("rejected","expired"): return False, st
96
+ return False, "timeout"
97
+ except Exception as e:
98
+ return False, f"approve_error: {e}"
99
+
100
+ def _harness_scan(tool: str, content: str) -> str:
101
+ """Scan exec output through harness before returning. Pass-through on failure."""
102
+ if not HARNESS_URL:
103
+ return content
104
+ try:
105
+ r = _httpx.post(f"{HARNESS_URL}/api/scan/output",
106
+ json={"agent": "vault", "tool": tool, "content": content},
107
+ timeout=4)
108
+ if r.status_code == 200:
109
+ return r.json().get("sanitised", content)
110
+ except Exception:
111
+ pass
112
+ return content
113
 
114
  # ── Meta / stats ──────────────────────────────────────────────────
115
  def load_meta():
 
438
  env = data.get("env",{})
439
  timeout = int(data.get("timeout", EXEC_TIMEOUT))
440
  if not code.strip(): raise HTTPException(400, "code required")
441
+
442
+ # Approval gate: risky bash/shell patterns
443
+ if runtime in ("bash","sh"):
444
+ if any(p in code for p in RISKY_EXEC_PATTERNS):
445
+ ok, reason = _approve_sync("vault_exec",
446
+ {"runtime": runtime, "code": code[:300], "cwd": cwd},
447
+ risk="critical")
448
+ if not ok:
449
+ return jresp({"ok": False, "exit_code": -1,
450
+ "output": f"[VAULT] BLOCKED by approval gate: {reason}",
451
+ "ms": 0})
452
+
453
+ result = await exec_command(runtime, code, cwd, env, min(timeout, 60))
454
+
455
+ # Harness: scan output before returning
456
+ if result.get("output"):
457
+ result["output"] = _harness_scan("vault_exec", result["output"])
458
+
459
  return jresp(result)
460
 
461
  @app.get("/api/exec/stream")
 
510
  "total_writes":META["total_writes"],"total_execs":META["total_execs"],
511
  "by_ext":dict(sorted(by_ext.items(),key=lambda x:-x[1])[:10])})
512
 
513
+ # ── Git API (Sprint 6) ────────────────────────────────────────────
514
+
515
+ @app.get("/api/git/status")
516
+ async def git_status(cwd: str = "code"):
517
+ result = await exec_command("git", "status --short", cwd)
518
+ staged = await exec_command("git", "diff --cached --name-only", cwd)
519
+ log = await exec_command("git", "log --oneline -5", cwd)
520
+ return jresp({
521
+ "status": result.get("output","").strip(),
522
+ "staged": staged.get("output","").strip(),
523
+ "log": log.get("output","").strip(),
524
+ "ok": result.get("ok", False),
525
+ })
526
+
527
+ @app.get("/api/git/log")
528
+ async def git_log(cwd: str = "code", limit: int = 20):
529
+ result = await exec_command("git",
530
+ f"log --oneline --graph --decorate -n {min(limit,50)}", cwd)
531
+ return jresp({"log": result.get("output","").strip(), "ok": result.get("ok")})
532
+
533
+ @app.post("/api/git/init")
534
+ async def git_init(request: Request):
535
+ data = await request.json()
536
+ cwd = data.get("cwd","code")
537
+ result = await exec_command("git", "init", cwd)
538
+ if result.get("ok"):
539
+ await exec_command("git", 'config user.email "vault@ki-fusion-labs.de"', cwd)
540
+ await exec_command("git", 'config user.name "FORGE Vault"', cwd)
541
+ return jresp(result)
542
+
543
+ @app.post("/api/git/commit")
544
+ async def git_commit(request: Request):
545
+ data = await request.json()
546
+ message = data.get("message","auto: vault commit").strip() or "auto: vault commit"
547
+ cwd = data.get("cwd","code")
548
+ files = data.get("files", []) # empty = git add -A
549
+
550
+ # Stage files
551
+ if files:
552
+ for f in files:
553
+ await exec_command("git", f"add {f}", cwd)
554
+ else:
555
+ await exec_command("git", "add -A", cwd)
556
+
557
+ # Commit
558
+ result = await exec_command("git", f'commit -m "{message}"', cwd)
559
+ return jresp({
560
+ "ok": result.get("ok"),
561
+ "output": result.get("output","").strip(),
562
+ "ms": result.get("ms",0),
563
+ })
564
+
565
+ @app.post("/api/git/push")
566
+ async def git_push(request: Request):
567
+ data = await request.json()
568
+ remote = data.get("remote","origin")
569
+ branch = data.get("branch","main")
570
+ force = bool(data.get("force", False))
571
+ cwd = data.get("cwd","code")
572
+
573
+ # Always require approval for git push
574
+ ok, reason = _approve_sync(
575
+ "vault_git_push",
576
+ {"remote": remote, "branch": branch, "force": force, "cwd": cwd},
577
+ risk="high" if not force else "critical",
578
+ )
579
+ if not ok:
580
+ return jresp({"ok": False, "output": f"[VAULT] git push BLOCKED: {reason}", "ms": 0})
581
+
582
+ force_flag = "--force" if force else ""
583
+ env_extra = {"GH_TOKEN": GH_TOKEN} if GH_TOKEN else {}
584
+ cmd = f"push {remote} {branch} {force_flag}".strip()
585
+ result = await exec_command("git", cmd, cwd, env_extra)
586
+ return jresp({
587
+ "ok": result.get("ok"),
588
+ "output": result.get("output","").strip(),
589
+ "ms": result.get("ms",0),
590
+ })
591
+
592
+ @app.post("/api/git/clone")
593
+ async def git_clone(request: Request):
594
+ data = await request.json()
595
+ url = data.get("url","")
596
+ dest = data.get("dest","code")
597
+ if not url: raise HTTPException(400,"url required")
598
+ env_extra = {"GH_TOKEN": GH_TOKEN} if GH_TOKEN else {}
599
+ result = await exec_command("bash", f"git clone {url} {WS_ROOT}/{dest}/repo",
600
+ "", env_extra, timeout=60)
601
+ return jresp(result)
602
+
603
+
604
+ # ── Anthropic API proxy (OpenClaw skill, Sprint 6) ────────────────
605
+ # Streams Claude responses. Agents call this directly for AI-assisted
606
+ # code review, documentation, analysis within the vault context.
607
+
608
+ @app.post("/api/anthropic")
609
+ async def anthropic_proxy(request: Request):
610
+ """
611
+ Anthropic API proxy with vault context injection.
612
+ Body: { model, max_tokens, messages, system, vault_context_path }
613
+ If vault_context_path set, injects file content into system prompt.
614
+ Streams raw SSE from Anthropic back to caller.
615
+ """
616
+ if not ANTHROPIC_KEY:
617
+ raise HTTPException(503, "ANTHROPIC_API_KEY not configured")
618
+
619
+ data = await request.json()
620
+ model = data.get("model","claude-sonnet-4-20250514")
621
+ max_tokens = int(data.get("max_tokens", 4096))
622
+ messages = data.get("messages", [])
623
+ system = data.get("system","You are a helpful AI assistant with access to the FORGE vault workspace.")
624
+ stream = bool(data.get("stream", True))
625
+ ctx_path = data.get("vault_context_path","")
626
+
627
+ # Inject vault file context if requested
628
+ if ctx_path:
629
+ try:
630
+ p = safe_path(ctx_path)
631
+ ctx_content = p.read_text(errors="replace")[:8000]
632
+ system = system + f"\n\nVAULT FILE CONTEXT ({ctx_path}):\n```\n{ctx_content}\n```"
633
+ except Exception as e:
634
+ system = system + f"\n\n[vault_context_path error: {e}]"
635
+
636
+ payload = {
637
+ "model": model,
638
+ "max_tokens": max_tokens,
639
+ "system": system,
640
+ "messages": messages,
641
+ "stream": stream,
642
+ }
643
+
644
+ async def _stream_anthropic():
645
+ async with _httpx.AsyncClient(timeout=120) as c:
646
+ async with c.stream(
647
+ "POST",
648
+ "https://api.anthropic.com/v1/messages",
649
+ json=payload,
650
+ headers={
651
+ "x-api-key": ANTHROPIC_KEY,
652
+ "anthropic-version": "2023-06-01",
653
+ "content-type": "application/json",
654
+ }
655
+ ) as resp:
656
+ async for chunk in resp.aiter_text():
657
+ if chunk:
658
+ yield chunk
659
+
660
+ if stream:
661
+ return StreamingResponse(
662
+ _stream_anthropic(),
663
+ media_type="text/event-stream",
664
+ headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no"},
665
+ )
666
+
667
+ # Non-stream: collect and return
668
+ async with _httpx.AsyncClient(timeout=120) as c:
669
+ resp = await c.post(
670
+ "https://api.anthropic.com/v1/messages",
671
+ json={**payload, "stream": False},
672
+ headers={
673
+ "x-api-key": ANTHROPIC_KEY,
674
+ "anthropic-version": "2023-06-01",
675
+ "content-type": "application/json",
676
+ }
677
+ )
678
+ return JSONResponse(resp.json())
679
+
680
+
681
+
682
  # ── MCP ───────────────────────────────────────────────────────────
683
  MCP_TOOLS = [
684
  {"name":"vault_read","description":"Read a file from the workspace",
 
713
  "src":{"type":"string"},"dst":{"type":"string"}}}},
714
  {"name":"vault_stats","description":"Get workspace statistics",
715
  "inputSchema":{"type":"object","properties":{}}},
716
+ {"name":"vault_git_status","description":"Git status, staged files and recent log.",
717
+ "inputSchema":{"type":"object","properties":{"cwd":{"type":"string"}}}},
718
+ {"name":"vault_git_commit","description":"Stage all and commit.",
719
+ "inputSchema":{"type":"object","properties":{"message":{"type":"string"},"cwd":{"type":"string"},"files":{"type":"array","items":{"type":"string"}}}}},
720
+ {"name":"vault_git_push","description":"Push to remote (requires human approval).",
721
+ "inputSchema":{"type":"object","properties":{"remote":{"type":"string"},"branch":{"type":"string"},"force":{"type":"boolean"},"cwd":{"type":"string"}}}},
722
+ {"name":"vault_anthropic","description":"Call Claude API with optional vault file context injection.",
723
+ "inputSchema":{"type":"object","properties":{"messages":{"type":"array"},"system":{"type":"string"},"vault_context_path":{"type":"string"},"max_tokens":{"type":"integer"}}}},
724
  ]
725
 
726
  async def mcp_call(name, args):
 
774
  if p.is_file(): total_files+=1; total_size+=p.stat().st_size
775
  return json.dumps({"total_files":total_files,"total_size":total_size,
776
  "total_writes":META["total_writes"],"total_execs":META["total_execs"]})
777
+ if name == "vault_git_status":
778
+ import asyncio as _aio
779
+ r = _aio.get_event_loop().run_until_complete(exec_command("git","status --short",a.get("cwd","code")))
780
+ lg = _aio.get_event_loop().run_until_complete(exec_command("git","log --oneline -5",a.get("cwd","code")))
781
+ return json.dumps({"status":r.get("output","").strip(),"log":lg.get("output","").strip()})
782
+ if name == "vault_git_commit":
783
+ import urllib.request as _ur
784
+ body = json.dumps({"message":a.get("message","auto commit"),"cwd":a.get("cwd","code"),"files":a.get("files",[])}).encode()
785
+ req = _ur.Request("http://localhost:7860/api/git/commit",data=body,headers={"Content-Type":"application/json"},method="POST")
786
+ try:
787
+ with _ur.urlopen(req,timeout=30) as resp: d=json.loads(resp.read())
788
+ except Exception as e: d={"ok":False,"output":str(e)}
789
+ return json.dumps(d)
790
+ if name == "vault_git_push":
791
+ import urllib.request as _ur
792
+ body = json.dumps({"remote":a.get("remote","origin"),"branch":a.get("branch","main"),"force":bool(a.get("force",False)),"cwd":a.get("cwd","code")}).encode()
793
+ req = _ur.Request("http://localhost:7860/api/git/push",data=body,headers={"Content-Type":"application/json"},method="POST")
794
+ try:
795
+ with _ur.urlopen(req,timeout=120) as resp: d=json.loads(resp.read())
796
+ except Exception as e: d={"ok":False,"output":str(e)}
797
+ return json.dumps(d)
798
+ if name == "vault_anthropic":
799
+ import urllib.request as _ur
800
+ body = json.dumps({**a,"stream":False}).encode()
801
+ req = _ur.Request("http://localhost:7860/api/anthropic",data=body,headers={"Content-Type":"application/json"},method="POST")
802
+ try:
803
+ with _ur.urlopen(req,timeout=120) as resp: d=json.loads(resp.read())
804
+ text = d.get("content",[{}])[0].get("text","") if isinstance(d.get("content"),list) else str(d)
805
+ except Exception as e: text=str(e)
806
+ return json.dumps({"text":text[:2000]})
807
  return json.dumps({"error": f"unknown tool: {name}"})
808
 
809
  @app.get("/mcp/sse")
 
1146
  <div class="tab-item" id="tab-editor" >&#9998; Editor</div>
1147
  <div class="tab-item" id="tab-terminal">&#62; Terminal</div>
1148
  <div class="tab-item" id="tab-exec-log">&#9881; Exec Log</div>
1149
+ <div class="tab-item" id="tab-git">&#128279; Git</div>
1150
  </div>
1151
 
1152
  <!-- BROWSER -->
 
1200
  <!-- EXEC LOG -->
1201
  <div id="exec-log-view" style="display:none;flex:1;overflow-y:auto;padding:.6rem .9rem;"></div>
1202
 
1203
+ <!-- GIT PANEL -->
1204
+ <div id="git-view" style="display:none;flex:1;overflow-y:auto;padding:.9rem;font-family:'DM Mono',monospace">
1205
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
1206
+ <div>
1207
+ <div style="font-size:.68rem;color:var(--muted);text-transform:uppercase;letter-spacing:.12em;margin-bottom:.4rem">Working Directory</div>
1208
+ <select id="git-cwd" style="background:var(--surface2);border:1px solid var(--border);color:var(--text);padding:.35rem .6rem;border-radius:4px;font-family:'DM Mono',monospace;font-size:.78rem;width:100%">
1209
+ <option value="code">code/</option>
1210
+ <option value="reports">reports/</option>
1211
+ <option value="scratch">scratch/</option>
1212
+ <option value="shared">shared/</option>
1213
+ </select>
1214
+ </div>
1215
+ <div style="display:flex;align-items:flex-end;gap:.5rem">
1216
+ <button id="git-refresh-btn" class="btn btn-secondary" style="font-size:.75rem;padding:.35rem .8rem">&#8635; Status</button>
1217
+ <button id="git-init-btn" class="btn btn-secondary" style="font-size:.75rem;padding:.35rem .8rem">&#9881; Init</button>
1218
+ </div>
1219
+ </div>
1220
+
1221
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
1222
+ <div>
1223
+ <div style="font-size:.68rem;color:var(--muted);text-transform:uppercase;letter-spacing:.12em;margin-bottom:.4rem">Status</div>
1224
+ <pre id="git-status-out" style="background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:.6rem;font-size:.75rem;line-height:1.5;min-height:80px;overflow-x:auto;white-space:pre-wrap;color:var(--green)">β€”</pre>
1225
+ </div>
1226
+ <div>
1227
+ <div style="font-size:.68rem;color:var(--muted);text-transform:uppercase;letter-spacing:.12em;margin-bottom:.4rem">Recent Log</div>
1228
+ <pre id="git-log-out" style="background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:.6rem;font-size:.75rem;line-height:1.5;min-height:80px;overflow-x:auto;white-space:pre-wrap;color:var(--cyan)">β€”</pre>
1229
+ </div>
1230
+ </div>
1231
+
1232
+ <div style="background:var(--surface2);border:1px solid var(--border);border-radius:6px;padding:1rem;margin-bottom:1rem">
1233
+ <div style="font-size:.72rem;color:var(--accent);font-weight:600;margin-bottom:.75rem">&#10003; Commit</div>
1234
+ <input id="git-commit-msg" class="search-input" placeholder="Commit message&#8230;" style="width:100%;margin-bottom:.6rem;font-family:'DM Mono',monospace;font-size:.8rem">
1235
+ <button id="git-commit-btn" class="btn btn-primary" style="font-size:.78rem">&#9997; Commit (stage all)</button>
1236
+ <span id="git-commit-result" style="margin-left:.75rem;font-size:.75rem;color:var(--muted)"></span>
1237
+ </div>
1238
+
1239
+ <div style="background:var(--surface2);border:1px solid var(--border);border-radius:6px;padding:1rem;margin-bottom:1rem">
1240
+ <div style="font-size:.72rem;color:var(--accent);font-weight:600;margin-bottom:.75rem">&#128640; Push (requires approval)</div>
1241
+ <div style="display:grid;grid-template-columns:1fr 1fr auto;gap:.5rem;align-items:end">
1242
+ <div>
1243
+ <div style="font-size:.65rem;color:var(--muted);margin-bottom:.25rem">Remote</div>
1244
+ <input id="git-remote" class="search-input" value="origin" style="width:100%;font-size:.78rem">
1245
+ </div>
1246
+ <div>
1247
+ <div style="font-size:.65rem;color:var(--muted);margin-bottom:.25rem">Branch</div>
1248
+ <input id="git-branch" class="search-input" value="main" style="width:100%;font-size:.78rem">
1249
+ </div>
1250
+ <button id="git-push-btn" class="btn btn-primary" style="font-size:.78rem;background:var(--purple)">&#8679; Push</button>
1251
+ </div>
1252
+ <label style="display:flex;align-items:center;gap:.4rem;margin-top:.6rem;font-size:.72rem;color:var(--muted);cursor:pointer">
1253
+ <input type="checkbox" id="git-force" style="accent-color:var(--red)"> Force push (--force)
1254
+ </label>
1255
+ <div id="git-push-result" style="margin-top:.5rem;font-size:.75rem;color:var(--muted)"></div>
1256
+ </div>
1257
+
1258
+ <!-- Claude assistant -->
1259
+ <div style="background:var(--surface2);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:6px;padding:1rem">
1260
+ <div style="font-size:.72rem;color:var(--accent);font-weight:600;margin-bottom:.75rem">&#129302; Claude Code Review</div>
1261
+ <div style="font-size:.7rem;color:var(--muted);margin-bottom:.5rem">Paste code or leave blank to use the open editor file</div>
1262
+ <textarea id="claude-input" class="search-input" rows="4" placeholder="Code to review, or press Run to use the open file&#8230;" style="width:100%;font-family:'DM Mono',monospace;font-size:.78rem;resize:vertical;min-height:80px;margin-bottom:.5rem"></textarea>
1263
+ <div style="display:flex;gap:.5rem;align-items:center">
1264
+ <button id="claude-run-btn" class="btn btn-primary" style="font-size:.78rem">&#9654; Ask Claude</button>
1265
+ <span style="font-size:.7rem;color:var(--muted)">streams via /api/anthropic</span>
1266
+ </div>
1267
+ <pre id="claude-out" style="margin-top:.75rem;background:var(--surface);border:1px solid var(--border);border-radius:4px;padding:.75rem;font-size:.75rem;line-height:1.6;min-height:60px;overflow-x:auto;white-space:pre-wrap;color:var(--text);display:none"></pre>
1268
+ </div>
1269
+ </div>
1270
+
1271
  <!-- VERSIONS PANEL -->
1272
  <div id="versions-panel">
1273
  <div id="vp-hdr">
 
1652
  document.getElementById('tab-editor').className='tab-item'+(t==='editor'?' on':'');
1653
  document.getElementById('tab-terminal').className='tab-item'+(t==='terminal'?' on':'');
1654
  document.getElementById('tab-exec-log').className='tab-item'+(t==='exec-log'?' on':'');
1655
+ document.getElementById('tab-git').className='tab-item'+(t==='git'?' on':'');
1656
  document.getElementById('browser-view').style.display=t==='browser'?'block':'none';
1657
  document.getElementById('editor-wrap').style.display=t==='editor'?'flex':'none';
1658
  document.getElementById('terminal-wrap').style.display=t==='terminal'?'flex':'none';
1659
  document.getElementById('exec-log-view').style.display=t==='exec-log'?'block':'none';
1660
+ document.getElementById('git-view').style.display=t==='git'?'flex':'none';
1661
  if(t==='exec-log') loadExecLog();
1662
+ if(t==='git') loadGitStatus();
1663
  }
1664
  document.getElementById('tab-browser').addEventListener('click',function(){showTab('browser');});
1665
  document.getElementById('tab-editor').addEventListener('click',function(){showTab('editor');});
1666
  document.getElementById('tab-terminal').addEventListener('click',function(){showTab('terminal');});
1667
  document.getElementById('tab-exec-log').addEventListener('click',function(){showTab('exec-log');});
1668
  document.getElementById('btn-open-term').addEventListener('click',function(){showTab('terminal');});
1669
+ document.getElementById('tab-git').addEventListener('click',function(){showTab('git');});
1670
+
1671
+ // ── Git tab JS ─────────────────────────────────────────────────────
1672
+ function gitCwd(){ return document.getElementById('git-cwd').value; }
1673
+
1674
+ function loadGitStatus(){
1675
+ fetch('/api/git/status?cwd='+encodeURIComponent(gitCwd()))
1676
+ .then(function(r){return r.json();}).then(function(d){
1677
+ document.getElementById('git-status-out').textContent = d.status || '(clean)';
1678
+ document.getElementById('git-log-out').textContent = d.log || '(no commits)';
1679
+ }).catch(function(e){ document.getElementById('git-status-out').textContent='Error: '+e; });
1680
+ }
1681
+
1682
+ document.getElementById('git-refresh-btn').addEventListener('click', loadGitStatus);
1683
+ document.getElementById('git-init-btn').addEventListener('click', function(){
1684
+ fetch('/api/git/init',{method:'POST',headers:{'Content-Type':'application/json'},
1685
+ body:JSON.stringify({cwd:gitCwd()})})
1686
+ .then(function(r){return r.json();}).then(function(d){
1687
+ toast(d.ok?'Git repo initialized':'Init failed: '+(d.output||'?'), d.ok?'ok':'err');
1688
+ loadGitStatus();
1689
+ });
1690
+ });
1691
+
1692
+ document.getElementById('git-commit-btn').addEventListener('click', function(){
1693
+ var msg = document.getElementById('git-commit-msg').value.trim() || 'auto: vault commit';
1694
+ var btn = document.getElementById('git-commit-btn');
1695
+ btn.disabled=true; btn.textContent='Committing&#8230;';
1696
+ fetch('/api/git/commit',{method:'POST',headers:{'Content-Type':'application/json'},
1697
+ body:JSON.stringify({message:msg,cwd:gitCwd(),files:[]})})
1698
+ .then(function(r){return r.json();}).then(function(d){
1699
+ var el = document.getElementById('git-commit-result');
1700
+ el.style.color = d.ok ? 'var(--green)' : 'var(--red)';
1701
+ el.textContent = d.ok ? '&#10003; Committed' : '&#10007; '+(d.output||'failed').slice(0,80);
1702
+ if(d.ok){ document.getElementById('git-commit-msg').value=''; loadGitStatus(); }
1703
+ btn.disabled=false; btn.textContent='&#9997; Commit (stage all)';
1704
+ });
1705
+ });
1706
+
1707
+ document.getElementById('git-push-btn').addEventListener('click', function(){
1708
+ var btn = document.getElementById('git-push-btn');
1709
+ btn.disabled=true; btn.textContent='Waiting approval&#8230;';
1710
+ document.getElementById('git-push-result').textContent = 'Awaiting Telegram approval from Christof...';
1711
+ fetch('/api/git/push',{method:'POST',headers:{'Content-Type':'application/json'},
1712
+ body:JSON.stringify({
1713
+ remote: document.getElementById('git-remote').value||'origin',
1714
+ branch: document.getElementById('git-branch').value||'main',
1715
+ force: document.getElementById('git-force').checked,
1716
+ cwd: gitCwd()
1717
+ })})
1718
+ .then(function(r){return r.json();}).then(function(d){
1719
+ var el = document.getElementById('git-push-result');
1720
+ el.style.color = d.ok ? 'var(--green)' : 'var(--red)';
1721
+ el.textContent = d.ok ? '&#10003; Pushed: '+(d.output||'').slice(0,100) : '&#10007; '+(d.output||'blocked').slice(0,120);
1722
+ btn.disabled=false; btn.textContent='&#8679; Push';
1723
+ if(d.ok) loadGitStatus();
1724
+ });
1725
+ });
1726
+
1727
+ // Claude code review
1728
+ document.getElementById('claude-run-btn').addEventListener('click', function(){
1729
+ var code = document.getElementById('claude-input').value.trim();
1730
+ var out = document.getElementById('claude-out');
1731
+ out.style.display='block'; out.textContent='Calling Claude&#8230;';
1732
+ var btn = document.getElementById('claude-run-btn');
1733
+ btn.disabled=true;
1734
+ // If no code pasted, include open file path for context
1735
+ var ctxPath = (!code && OPEN_FILE) ? OPEN_FILE : '';
1736
+ var userMsg = code ? 'Please review this code and suggest improvements:\n\n```\n'+code+'\n```'
1737
+ : 'Please review the file at the vault context path and suggest improvements.';
1738
+ fetch('/api/anthropic',{method:'POST',headers:{'Content-Type':'application/json'},
1739
+ body:JSON.stringify({
1740
+ messages:[{role:'user',content:userMsg}],
1741
+ system:'You are a senior software engineer doing code review. Be concise and specific.',
1742
+ vault_context_path: ctxPath,
1743
+ max_tokens:1024,
1744
+ stream:true
1745
+ })})
1746
+ .then(function(resp){
1747
+ var reader=resp.body.getReader(); var decoder=new TextDecoder(); out.textContent='';
1748
+ function read(){
1749
+ reader.read().then(function(a){
1750
+ if(a.done){btn.disabled=false;return;}
1751
+ var chunk=decoder.decode(a.value);
1752
+ // Parse SSE data lines for text delta
1753
+ chunk.split('\n').forEach(function(line){
1754
+ if(!line.startsWith('data: ')) return;
1755
+ try{
1756
+ var ev=JSON.parse(line.slice(6));
1757
+ if(ev.type==='content_block_delta'&&ev.delta&&ev.delta.text)
1758
+ out.textContent+=ev.delta.text;
1759
+ }catch(e){}
1760
+ });
1761
+ read();
1762
+ });
1763
+ }
1764
+ read();
1765
+ }).catch(function(e){ out.textContent='Error: '+e; btn.disabled=false; });
1766
+ });
1767
 
1768
  // ── View toggle ───────────────────────────────────────────────────
1769
  document.getElementById('vt-list').addEventListener('click',function(){
 
1872
  loadBrowser('');
1873
  </script>
1874
  </body>
1875
+ </html>"""