localant 1.0.2 → 1.1.1

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 (192) hide show
  1. package/README.ja.md +185 -0
  2. package/README.md +146 -29
  3. package/SECURITY.md +63 -8
  4. package/assets/hero.png +0 -0
  5. package/assets/localant-icon.png +0 -0
  6. package/examples/skills/article-publisher/README.md +41 -0
  7. package/examples/skills/article-publisher/package.json +9 -0
  8. package/examples/skills/article-publisher/skill.json +134 -0
  9. package/examples/skills/article-publisher/src/index.ts +186 -0
  10. package/examples/skills/article-publisher/tests/skill.test.ts +72 -0
  11. package/package.json +14 -4
  12. package/packages/cli/dist/autostart.d.ts +20 -0
  13. package/packages/cli/dist/autostart.d.ts.map +1 -0
  14. package/packages/cli/dist/autostart.js +124 -0
  15. package/packages/cli/dist/autostart.js.map +1 -0
  16. package/packages/cli/dist/bin.js +288 -26
  17. package/packages/cli/dist/bin.js.map +1 -1
  18. package/packages/cli/dist/runtime.d.ts.map +1 -1
  19. package/packages/cli/dist/runtime.js +56 -8
  20. package/packages/cli/dist/runtime.js.map +1 -1
  21. package/packages/cli/dist/serveo-setup.d.ts +37 -0
  22. package/packages/cli/dist/serveo-setup.d.ts.map +1 -0
  23. package/packages/cli/dist/serveo-setup.js +168 -0
  24. package/packages/cli/dist/serveo-setup.js.map +1 -0
  25. package/packages/cli/dist/util.d.ts +6 -0
  26. package/packages/cli/dist/util.d.ts.map +1 -1
  27. package/packages/cli/dist/util.js +20 -0
  28. package/packages/cli/dist/util.js.map +1 -1
  29. package/packages/cli/package.json +1 -1
  30. package/packages/dashboard/dist/index.d.ts +5 -4
  31. package/packages/dashboard/dist/index.d.ts.map +1 -1
  32. package/packages/dashboard/dist/index.js +754 -48
  33. package/packages/dashboard/dist/index.js.map +1 -1
  34. package/packages/gateway/dist/gateway.d.ts +14 -3
  35. package/packages/gateway/dist/gateway.d.ts.map +1 -1
  36. package/packages/gateway/dist/gateway.js +60 -10
  37. package/packages/gateway/dist/gateway.js.map +1 -1
  38. package/packages/gateway/dist/index.d.ts +3 -0
  39. package/packages/gateway/dist/index.d.ts.map +1 -1
  40. package/packages/gateway/dist/index.js +3 -0
  41. package/packages/gateway/dist/index.js.map +1 -1
  42. package/packages/gateway/dist/managers/coding-agent-manager.d.ts +21 -9
  43. package/packages/gateway/dist/managers/coding-agent-manager.d.ts.map +1 -1
  44. package/packages/gateway/dist/managers/coding-agent-manager.js +38 -31
  45. package/packages/gateway/dist/managers/coding-agent-manager.js.map +1 -1
  46. package/packages/gateway/dist/managers/fs-manager.d.ts +73 -0
  47. package/packages/gateway/dist/managers/fs-manager.d.ts.map +1 -1
  48. package/packages/gateway/dist/managers/fs-manager.js +290 -6
  49. package/packages/gateway/dist/managers/fs-manager.js.map +1 -1
  50. package/packages/gateway/dist/managers/git-manager.d.ts +6 -0
  51. package/packages/gateway/dist/managers/git-manager.d.ts.map +1 -1
  52. package/packages/gateway/dist/managers/git-manager.js +24 -0
  53. package/packages/gateway/dist/managers/git-manager.js.map +1 -1
  54. package/packages/gateway/dist/managers/lsp-service.d.ts +88 -0
  55. package/packages/gateway/dist/managers/lsp-service.d.ts.map +1 -0
  56. package/packages/gateway/dist/managers/lsp-service.js +249 -0
  57. package/packages/gateway/dist/managers/lsp-service.js.map +1 -0
  58. package/packages/gateway/dist/managers/mcp-bridge.d.ts +2 -1
  59. package/packages/gateway/dist/managers/mcp-bridge.d.ts.map +1 -1
  60. package/packages/gateway/dist/managers/mcp-bridge.js +23 -2
  61. package/packages/gateway/dist/managers/mcp-bridge.js.map +1 -1
  62. package/packages/gateway/dist/managers/shell-manager.d.ts +19 -0
  63. package/packages/gateway/dist/managers/shell-manager.d.ts.map +1 -1
  64. package/packages/gateway/dist/managers/shell-manager.js +28 -0
  65. package/packages/gateway/dist/managers/shell-manager.js.map +1 -1
  66. package/packages/gateway/dist/managers/skill-runtime.d.ts +8 -0
  67. package/packages/gateway/dist/managers/skill-runtime.d.ts.map +1 -1
  68. package/packages/gateway/dist/managers/skill-runtime.js +15 -0
  69. package/packages/gateway/dist/managers/skill-runtime.js.map +1 -1
  70. package/packages/gateway/dist/managers/tunnel-manager.d.ts +19 -1
  71. package/packages/gateway/dist/managers/tunnel-manager.d.ts.map +1 -1
  72. package/packages/gateway/dist/managers/tunnel-manager.js +289 -8
  73. package/packages/gateway/dist/managers/tunnel-manager.js.map +1 -1
  74. package/packages/gateway/dist/security/command-guard.d.ts +3 -0
  75. package/packages/gateway/dist/security/command-guard.d.ts.map +1 -1
  76. package/packages/gateway/dist/security/command-guard.js +15 -7
  77. package/packages/gateway/dist/security/command-guard.js.map +1 -1
  78. package/packages/gateway/dist/security/path-guard.d.ts +3 -0
  79. package/packages/gateway/dist/security/path-guard.d.ts.map +1 -1
  80. package/packages/gateway/dist/security/path-guard.js +8 -2
  81. package/packages/gateway/dist/security/path-guard.js.map +1 -1
  82. package/packages/gateway/dist/stores/config-store.d.ts +10 -0
  83. package/packages/gateway/dist/stores/config-store.d.ts.map +1 -1
  84. package/packages/gateway/dist/stores/config-store.js +47 -3
  85. package/packages/gateway/dist/stores/config-store.js.map +1 -1
  86. package/packages/gateway/dist/stores/secret-vault.d.ts +19 -3
  87. package/packages/gateway/dist/stores/secret-vault.d.ts.map +1 -1
  88. package/packages/gateway/dist/stores/secret-vault.js +47 -6
  89. package/packages/gateway/dist/stores/secret-vault.js.map +1 -1
  90. package/packages/gateway/dist/tools/adapters.d.ts.map +1 -1
  91. package/packages/gateway/dist/tools/adapters.js +198 -7
  92. package/packages/gateway/dist/tools/adapters.js.map +1 -1
  93. package/packages/gateway/dist/tools/adb.d.ts.map +1 -1
  94. package/packages/gateway/dist/tools/adb.js +42 -0
  95. package/packages/gateway/dist/tools/adb.js.map +1 -1
  96. package/packages/gateway/dist/tools/agent.d.ts +10 -0
  97. package/packages/gateway/dist/tools/agent.d.ts.map +1 -0
  98. package/packages/gateway/dist/tools/agent.js +35 -0
  99. package/packages/gateway/dist/tools/agent.js.map +1 -0
  100. package/packages/gateway/dist/tools/aliases.d.ts +7 -0
  101. package/packages/gateway/dist/tools/aliases.d.ts.map +1 -0
  102. package/packages/gateway/dist/tools/aliases.js +64 -0
  103. package/packages/gateway/dist/tools/aliases.js.map +1 -0
  104. package/packages/gateway/dist/tools/bash.d.ts +10 -0
  105. package/packages/gateway/dist/tools/bash.d.ts.map +1 -0
  106. package/packages/gateway/dist/tools/bash.js +67 -0
  107. package/packages/gateway/dist/tools/bash.js.map +1 -0
  108. package/packages/gateway/dist/tools/browser.d.ts.map +1 -1
  109. package/packages/gateway/dist/tools/browser.js +10 -1
  110. package/packages/gateway/dist/tools/browser.js.map +1 -1
  111. package/packages/gateway/dist/tools/coding-agent.js +10 -10
  112. package/packages/gateway/dist/tools/coding-agent.js.map +1 -1
  113. package/packages/gateway/dist/tools/control.d.ts +8 -0
  114. package/packages/gateway/dist/tools/control.d.ts.map +1 -0
  115. package/packages/gateway/dist/tools/control.js +134 -0
  116. package/packages/gateway/dist/tools/control.js.map +1 -0
  117. package/packages/gateway/dist/tools/editing.d.ts +8 -0
  118. package/packages/gateway/dist/tools/editing.d.ts.map +1 -0
  119. package/packages/gateway/dist/tools/editing.js +102 -0
  120. package/packages/gateway/dist/tools/editing.js.map +1 -0
  121. package/packages/gateway/dist/tools/filesystem.d.ts.map +1 -1
  122. package/packages/gateway/dist/tools/filesystem.js +14 -2
  123. package/packages/gateway/dist/tools/filesystem.js.map +1 -1
  124. package/packages/gateway/dist/tools/git.d.ts.map +1 -1
  125. package/packages/gateway/dist/tools/git.js +79 -17
  126. package/packages/gateway/dist/tools/git.js.map +1 -1
  127. package/packages/gateway/dist/tools/index.d.ts.map +1 -1
  128. package/packages/gateway/dist/tools/index.js +17 -4
  129. package/packages/gateway/dist/tools/index.js.map +1 -1
  130. package/packages/gateway/dist/tools/lsp.d.ts +10 -0
  131. package/packages/gateway/dist/tools/lsp.d.ts.map +1 -0
  132. package/packages/gateway/dist/tools/lsp.js +110 -0
  133. package/packages/gateway/dist/tools/lsp.js.map +1 -0
  134. package/packages/gateway/dist/tools/question.d.ts +10 -0
  135. package/packages/gateway/dist/tools/question.d.ts.map +1 -0
  136. package/packages/gateway/dist/tools/question.js +30 -0
  137. package/packages/gateway/dist/tools/question.js.map +1 -0
  138. package/packages/gateway/dist/tools/shell.d.ts +1 -1
  139. package/packages/gateway/dist/tools/shell.d.ts.map +1 -1
  140. package/packages/gateway/dist/tools/shell.js +15 -0
  141. package/packages/gateway/dist/tools/shell.js.map +1 -1
  142. package/packages/gateway/dist/tools/skill.d.ts.map +1 -1
  143. package/packages/gateway/dist/tools/skill.js +2 -7
  144. package/packages/gateway/dist/tools/skill.js.map +1 -1
  145. package/packages/gateway/dist/tools/system.js +2 -2
  146. package/packages/gateway/dist/tools/system.js.map +1 -1
  147. package/packages/gateway/dist/tools/validation.d.ts +3 -0
  148. package/packages/gateway/dist/tools/validation.d.ts.map +1 -0
  149. package/packages/gateway/dist/tools/validation.js +110 -0
  150. package/packages/gateway/dist/tools/validation.js.map +1 -0
  151. package/packages/mcp/dist/http-server.d.ts +1 -1
  152. package/packages/mcp/dist/http-server.d.ts.map +1 -1
  153. package/packages/mcp/dist/http-server.js +530 -21
  154. package/packages/mcp/dist/http-server.js.map +1 -1
  155. package/packages/mcp/dist/mcp-server.d.ts.map +1 -1
  156. package/packages/mcp/dist/mcp-server.js +5 -1
  157. package/packages/mcp/dist/mcp-server.js.map +1 -1
  158. package/packages/shared/dist/config.d.ts +146 -16
  159. package/packages/shared/dist/config.d.ts.map +1 -1
  160. package/packages/shared/dist/config.js +93 -7
  161. package/packages/shared/dist/config.js.map +1 -1
  162. package/packages/shared/dist/index.d.ts +2 -0
  163. package/packages/shared/dist/index.d.ts.map +1 -1
  164. package/packages/shared/dist/index.js +2 -0
  165. package/packages/shared/dist/index.js.map +1 -1
  166. package/packages/shared/dist/paths.d.ts +19 -2
  167. package/packages/shared/dist/paths.d.ts.map +1 -1
  168. package/packages/shared/dist/paths.js +50 -3
  169. package/packages/shared/dist/paths.js.map +1 -1
  170. package/packages/shared/dist/tool-profiles.d.ts +34 -0
  171. package/packages/shared/dist/tool-profiles.d.ts.map +1 -0
  172. package/packages/shared/dist/tool-profiles.js +182 -0
  173. package/packages/shared/dist/tool-profiles.js.map +1 -0
  174. package/packages/shared/dist/types.d.ts +1 -12
  175. package/packages/shared/dist/types.d.ts.map +1 -1
  176. package/packages/shared/dist/version.d.ts +2 -0
  177. package/packages/shared/dist/version.d.ts.map +1 -0
  178. package/packages/shared/dist/version.js +35 -0
  179. package/packages/shared/dist/version.js.map +1 -0
  180. package/assets/icon.svg +0 -25
  181. package/packages/gateway/dist/managers/project-registry.d.ts +0 -17
  182. package/packages/gateway/dist/managers/project-registry.d.ts.map +0 -1
  183. package/packages/gateway/dist/managers/project-registry.js +0 -90
  184. package/packages/gateway/dist/managers/project-registry.js.map +0 -1
  185. package/packages/gateway/dist/tools/article.d.ts +0 -3
  186. package/packages/gateway/dist/tools/article.d.ts.map +0 -1
  187. package/packages/gateway/dist/tools/article.js +0 -230
  188. package/packages/gateway/dist/tools/article.js.map +0 -1
  189. package/packages/gateway/dist/tools/project.d.ts +0 -3
  190. package/packages/gateway/dist/tools/project.d.ts.map +0 -1
  191. package/packages/gateway/dist/tools/project.js +0 -86
  192. package/packages/gateway/dist/tools/project.js.map +0 -1
@@ -2,22 +2,25 @@
2
2
  * Self-contained local dashboard. Returns a single HTML document that talks to
3
3
  * the gateway's /api/* endpoints on the same origin. Local-only by default.
4
4
  *
5
- * (A full React/Vite/Tailwind build lives under a future `web/` workspace; this
6
- * dependency-free version ships in v1.0 so the dashboard works with zero build
7
- * steps and no CDN requirement.)
5
+ * Dependency-free and build-free by design: this ships as one HTML string so
6
+ * the dashboard works with zero build steps and no CDN requirement. The inner
7
+ * <script> uses string concatenation (no template literals) so it can live
8
+ * inside this outer template literal without escaping collisions.
8
9
  */
9
- export function dashboardHtml() {
10
+ export function dashboardHtml(token = "") {
10
11
  return `<!doctype html>
11
12
  <html lang="en">
12
13
  <head>
13
14
  <meta charset="utf-8" />
14
15
  <meta name="viewport" content="width=device-width, initial-scale=1" />
16
+ <link rel="icon" type="image/png" href="/favicon.png" />
15
17
  <title>LocalAnt — Dashboard</title>
16
18
  <style>
17
19
  :root { --bg:#0b0f17; --panel:#131a26; --panel2:#1b2433; --text:#e6edf3; --muted:#8b98a9; --accent:#4f8cff; --danger:#ff5f56; --ok:#3fb950; --warn:#d29922; --border:#243049; }
18
20
  * { box-sizing:border-box; }
19
21
  body { margin:0; font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--text); }
20
- header { padding:16px 24px; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:12px; }
22
+ header { padding:14px 24px; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:12px; }
23
+ header .logo { display:flex; align-items:center; }
21
24
  header h1 { font-size:16px; margin:0; font-weight:600; }
22
25
  .pill { font-size:11px; padding:2px 8px; border-radius:999px; background:var(--panel2); color:var(--muted); }
23
26
  .layout { display:flex; min-height:calc(100vh - 53px); }
@@ -27,25 +30,58 @@ export function dashboardHtml() {
27
30
  main { flex:1; padding:24px; overflow:auto; }
28
31
  .card { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px; margin-bottom:16px; }
29
32
  .card h2 { margin:0 0 12px; font-size:14px; }
33
+ .card h3 { margin:16px 0 8px; font-size:13px; }
30
34
  .row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
31
35
  code, pre { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
32
36
  pre { background:var(--panel2); padding:12px; border-radius:8px; overflow:auto; font-size:12px; max-height:380px; }
33
37
  button.btn { background:var(--accent); color:#fff; border:none; padding:8px 14px; border-radius:8px; cursor:pointer; font-size:13px; }
38
+ button.btn:disabled { opacity:.5; cursor:not-allowed; }
34
39
  button.btn.ghost { background:var(--panel2); color:var(--text); }
35
40
  button.btn.danger { background:var(--danger); }
36
41
  button.btn.ok { background:var(--ok); }
42
+ button.btn.sm { padding:3px 9px; font-size:11px; }
37
43
  table { width:100%; border-collapse:collapse; font-size:13px; }
38
44
  th,td { text-align:left; padding:8px; border-bottom:1px solid var(--border); vertical-align:top; }
39
45
  th { color:var(--muted); font-weight:500; }
40
- input,textarea { background:var(--panel2); border:1px solid var(--border); color:var(--text); border-radius:8px; padding:8px; font-size:13px; }
46
+ input,textarea,select { background:var(--panel2); border:1px solid var(--border); color:var(--text); border-radius:8px; padding:8px; font-size:13px; font-family:inherit; }
47
+ textarea { width:100%; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
48
+ label { font-size:13px; }
41
49
  .muted { color:var(--muted); }
42
50
  .tag { font-size:11px; padding:2px 6px; border-radius:6px; background:var(--panel2); }
51
+ .tag.core { background:rgba(210,153,34,.18); color:var(--warn); }
43
52
  .risk0{color:var(--ok)} .risk1{color:#7fd} .risk2{color:var(--warn)} .risk3{color:#ff9} .risk4{color:var(--danger)}
44
- .warnbox { background:rgba(210,153,34,.12); border:1px solid var(--warn); color:#f0d590; padding:10px 12px; border-radius:8px; font-size:13px; }
53
+ .warnbox { background:rgba(210,153,34,.12); border:1px solid var(--warn); color:#f0d590; padding:10px 12px; border-radius:8px; font-size:13px; margin-bottom:16px; }
54
+ #toast { position:fixed; top:16px; right:16px; max-width:420px; z-index:50; display:flex; flex-direction:column; gap:8px; }
55
+ .toast { padding:10px 14px; border-radius:8px; font-size:13px; box-shadow:0 4px 16px rgba(0,0,0,.4); }
56
+ .toast.err { background:#3a1416; border:1px solid var(--danger); color:#ffb3ae; }
57
+ .toast.ok { background:#10271a; border:1px solid var(--ok); color:#9be6ad; }
58
+ .field { margin-bottom:14px; }
59
+ .field label { display:block; margin-bottom:6px; font-weight:600; }
60
+ .field input, .field select { width:100%; }
61
+ .spin { display:inline-block; width:12px; height:12px; border:2px solid rgba(255,255,255,.3); border-top-color:#fff; border-radius:50%; animation:sp .7s linear infinite; vertical-align:middle; }
62
+ @keyframes sp { to { transform:rotate(360deg); } }
63
+ .navbadge { background:var(--danger); color:#fff; border-radius:999px; padding:0 6px; font-size:11px; margin-left:6px; }
64
+ #modalOverlay { position:fixed; inset:0; background:rgba(0,0,0,.6); display:none; align-items:center; justify-content:center; z-index:60; padding:24px; }
65
+ #modalOverlay.show { display:flex; }
66
+ .modal { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:20px; max-width:560px; width:100%; max-height:82vh; overflow:auto; }
67
+ .modal h2 { margin:0 0 12px; font-size:15px; }
68
+ .modal .close { float:right; cursor:pointer; color:var(--muted); background:none; border:none; font-size:20px; line-height:1; }
69
+ .modal table td { font-size:12px; }
70
+ .modal table td:first-child { color:var(--muted); white-space:nowrap; width:1%; padding-right:16px; }
71
+ @media (max-width:720px){
72
+ .layout { flex-direction:column; }
73
+ nav { width:100%; border-right:none; border-bottom:1px solid var(--border); display:flex; flex-wrap:wrap; gap:4px; }
74
+ nav button { width:auto; flex:0 0 auto; }
75
+ main { padding:16px; }
76
+ header { padding:12px 16px; }
77
+ }
45
78
  </style>
46
79
  </head>
47
80
  <body>
81
+ <div id="toast"></div>
82
+ <div id="modalOverlay"><div class="modal" id="modalBox"></div></div>
48
83
  <header>
84
+ <span class="logo"><img src="/hero.png" height="26" alt="LocalAnt" /></span>
49
85
  <h1>LocalAnt</h1>
50
86
  <span class="pill" id="statusPill">connecting…</span>
51
87
  <span class="pill" id="tunnelPill"></span>
@@ -55,54 +91,229 @@ export function dashboardHtml() {
55
91
  <main id="main"></main>
56
92
  </div>
57
93
  <script>
58
- const TABS = ["Home","Security","Approvals","Audit","Skills","Projects","Secrets","Agents","Settings"];
94
+ const TABS = ["Home","Tools","Security","Approvals","Audit","Secrets","Agents","Settings"];
59
95
  let current = "Home";
60
- const api = (p, opts) => fetch("/api/"+p, opts).then(r => r.json());
61
- const el = (h) => { const d=document.createElement('div'); d.innerHTML=h; return d.firstElementChild; };
96
+ let toolSub = "tools";
97
+ let pendingApprovals = 0;
98
+ let logTimers = [];
99
+ function clearLogTimers(){ logTimers.forEach(clearInterval); logTimers=[]; }
100
+ const DASH_TOKEN = ${JSON.stringify(token)};
101
+
102
+ function openModal(title, html){
103
+ document.getElementById('modalBox').innerHTML='<button class="close" id="modalClose">×</button><h2>'+esc(title)+'</h2>'+html;
104
+ document.getElementById('modalOverlay').classList.add('show');
105
+ document.getElementById('modalClose').onclick=closeModal;
106
+ }
107
+ function closeModal(){ document.getElementById('modalOverlay').classList.remove('show'); }
108
+
109
+ // Wire a Show/Hide button to toggle a password input's visibility.
110
+ function wirePw(inputId, btnId){
111
+ const inp=document.getElementById(inputId), btn=document.getElementById(btnId);
112
+ if(!inp||!btn) return;
113
+ btn.onclick=()=>{ const show=inp.type==='password'; inp.type=show?'text':'password'; btn.textContent=show?'Hide':'Show'; };
114
+ }
115
+
116
+ function toast(msg, kind){
117
+ const wrap=document.getElementById('toast');
118
+ const t=document.createElement('div');
119
+ t.className='toast '+(kind||'ok');
120
+ t.textContent=msg;
121
+ wrap.appendChild(t);
122
+ setTimeout(()=>{ t.style.opacity='0'; t.style.transition='opacity .4s'; setTimeout(()=>t.remove(),400); }, kind==='err'?6000:3000);
123
+ }
124
+
125
+ // api() surfaces errors instead of swallowing them: non-2xx or a JSON {error}
126
+ // both throw, so every caller can show the failure in a toast.
127
+ async function api(p, opts){
128
+ const o = Object.assign({}, opts);
129
+ o.headers = Object.assign({}, o.headers, { "x-dashboard-token": DASH_TOKEN });
130
+ const r = await fetch("/api/"+p, o);
131
+ let body = null;
132
+ const text = await r.text();
133
+ if(text){ try { body = JSON.parse(text); } catch(e){ body = { raw:text }; } }
134
+ if(!r.ok){ throw new Error((body && body.error) || ("HTTP "+r.status)); }
135
+ if(body && body.error){ throw new Error(body.error); }
136
+ return body;
137
+ }
138
+
139
+ // Wrap an async click handler so failures always toast and the button can't be
140
+ // double-fired while in flight.
141
+ function action(btn, fn, busyLabel){
142
+ return async function(){
143
+ const orig = btn.innerHTML;
144
+ btn.disabled = true;
145
+ if(busyLabel) btn.innerHTML = '<span class="spin"></span> '+busyLabel;
146
+ try { await fn(); }
147
+ catch(e){ toast(e.message || String(e), 'err'); }
148
+ finally { btn.disabled = false; btn.innerHTML = orig; }
149
+ };
150
+ }
151
+
152
+ const el = (h) => {
153
+ const d=document.createElement('div');
154
+ const t=h.trim();
155
+ if(t.startsWith('<tr') || t.startsWith('<td')){
156
+ const tbl=document.createElement('table');
157
+ tbl.innerHTML=h;
158
+ return tbl.querySelector('tr') || tbl.querySelector('td') || d;
159
+ }
160
+ d.innerHTML=h;
161
+ return d.firstElementChild;
162
+ };
62
163
  function riskClass(r){ return "risk"+r; }
63
- function esc(s){ return String(s??"").replace(/[&<>]/g, c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c])); }
164
+ function esc(s){ return String(s==null?"":s).replace(/[&<>]/g, c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c])); }
64
165
 
65
166
  function renderNav(){
66
167
  const nav=document.getElementById('nav'); nav.innerHTML='';
67
- for(const t of TABS){ const b=el('<button>'+t+'</button>'); if(t===current)b.className='active'; b.onclick=()=>{current=t;renderNav();render();}; nav.appendChild(b); }
168
+ for(const t of TABS){
169
+ const label = (t==='Approvals' && pendingApprovals>0) ? (t+'<span class="navbadge">'+pendingApprovals+'</span>') : t;
170
+ const b=el('<button>'+label+'</button>');
171
+ if(t===current)b.className='active';
172
+ b.onclick=()=>{current=t;renderNav();render();};
173
+ nav.appendChild(b);
174
+ }
68
175
  }
69
176
 
70
177
  async function render(){
71
- const m=document.getElementById('main'); m.innerHTML='<p class="muted">Loading…</p>';
178
+ clearLogTimers();
179
+ const m=document.getElementById('main');
180
+ const hash=window.location.hash;
181
+ if(hash.startsWith('#oauth/approve')){
182
+ const nav=document.getElementById('nav'); if(nav)nav.style.display='none';
183
+ await renderOAuthApprove(m);
184
+ return;
185
+ }
186
+ const nav=document.getElementById('nav'); if(nav)nav.style.display='block';
187
+ m.innerHTML='<p class="muted">Loading…</p>';
72
188
  try { await VIEWS[current](m); } catch(e){ m.innerHTML='<div class="card">Error: '+esc(e.message)+'</div>'; }
73
189
  }
74
190
 
191
+ async function renderOAuthApprove(m){
192
+ const hash = window.location.hash;
193
+ const qIdx = hash.indexOf('?');
194
+ const params = new URLSearchParams(qIdx !== -1 ? hash.slice(qIdx) : '');
195
+ const state = params.get('state') || '';
196
+ const redirectUri = params.get('redirect_uri') || '';
197
+
198
+ m.innerHTML = '<div class="card" style="max-width:500px;margin:40px auto;padding:24px;">'
199
+ +'<h2 style="margin-top:0;">Approve ChatGPT Connection</h2>'
200
+ +'<p>ChatGPT wants to connect to your LocalAnt instance.</p>'
201
+ +'<p class="muted" style="word-break:break-all;font-size:12px;background:var(--panel2);padding:10px;border-radius:8px;">Redirect URI: <code>'+esc(redirectUri)+'</code></p>'
202
+ +'<div class="row" style="margin-top:24px;gap:12px;display:flex;">'
203
+ +'<button class="btn ok" id="oauthApproveBtn" style="flex:1;padding:12px;">Approve &amp; Connect</button>'
204
+ +'<button class="btn danger" id="oauthDenyBtn" style="flex:1;padding:12px;background:none;border:1px solid var(--danger);color:var(--danger)">Deny</button>'
205
+ +'</div>'
206
+ +'<div id="oauthErr" class="muted" style="margin-top:12px;color:var(--danger)"></div>'
207
+ +'</div>';
208
+
209
+ document.getElementById('oauthApproveBtn').onclick = async () => {
210
+ try {
211
+ const res = await api('oauth/approve', {
212
+ method: 'POST',
213
+ headers: {'content-type':'application/json'},
214
+ body: JSON.stringify({ redirect_uri: redirectUri })
215
+ });
216
+ const target = redirectUri + (redirectUri.includes('?') ? '&' : '?') + 'code=' + res.code + '&state=' + encodeURIComponent(state);
217
+ window.location.href = target;
218
+ } catch(e) {
219
+ document.getElementById('oauthErr').textContent = e.message;
220
+ }
221
+ };
222
+
223
+ document.getElementById('oauthDenyBtn').onclick = () => {
224
+ window.location.href = redirectUri + (redirectUri.includes('?') ? '&' : '?') + 'error=access_denied&state=' + encodeURIComponent(state);
225
+ };
226
+ }
227
+
228
+ function tunnelControls(t){
229
+ let regLink = '';
230
+ let errMsg = t.error || '';
231
+ if (t.error && t.error.indexOf('https://console.serveo.net') !== -1) {
232
+ const m = t.error.match(/https:\\/\\/console\\.serveo\\.net\\/ssh\\/keys\\?add=[^\\s]+/i);
233
+ if (m) {
234
+ const url = m[0];
235
+ regLink = '<div class="warnbox" style="border-color:var(--accent);background:rgba(79,140,255,0.1);color:#cce0ff">'
236
+ + '🔑 <b>Action Required</b>: SSH key registration is required to use the serveo tunnel.<br>'
237
+ + '<a href="' + esc(url) + '" target="_blank" class="btn sm" style="display:inline-block;margin-top:8px;text-decoration:none;background:var(--accent);color:#fff">Register Key on Serveo</a>'
238
+ + '</div>';
239
+ errMsg = t.error.replace(url, '').replace(/Please register here:\\s*$/, '');
240
+ }
241
+ }
242
+
243
+ const card=el('<div class="card"><h2>Tunnel</h2>'
244
+ + (regLink || '')
245
+ + '<p>Provider: <code>'+esc(t.provider)+'</code> · Status: <code>'+esc(t.status)+'</code></p>'
246
+ + (t.url?'<p>URL: <code>'+esc(t.url)+'</code></p>':'<p class="muted">No public URL.</p>')
247
+ + (errMsg?'<p class="risk4">'+esc(errMsg)+'</p>':'')
248
+ + '<div class="row"><button class="btn" id="tunStart">Start</button>'
249
+ + '<button class="btn ghost" id="tunRestart">Restart</button>'
250
+ + '<button class="btn danger" id="tunStop">Stop</button></div></div>');
251
+ card.querySelector('#tunStart').onclick=action(card.querySelector('#tunStart'),async()=>{ await api('tunnel/start',{method:'POST'}); toast('Tunnel starting'); render(); },'Starting');
252
+ card.querySelector('#tunRestart').onclick=action(card.querySelector('#tunRestart'),async()=>{ await api('tunnel/restart',{method:'POST'}); toast('Tunnel restarted'); render(); },'Restarting');
253
+ card.querySelector('#tunStop').onclick=action(card.querySelector('#tunStop'),async()=>{ await api('tunnel/stop',{method:'POST'}); toast('Tunnel stopped'); render(); });
254
+ return card;
255
+ }
256
+
75
257
  const VIEWS = {
76
258
  async Home(m){
77
259
  const s=await api('status');
78
260
  const mcp=await api('mcp-endpoint');
79
261
  m.innerHTML='';
80
- const endpoint = mcp.endpoint || '(tunnel not running — start it from the CLI)';
262
+ const t=s.tunnel||{};
263
+ const endpoint = mcp.endpoint || '(tunnel not running — start it below or from the CLI)';
81
264
  m.appendChild(el('<div class="card"><h2>Gateway</h2>'
82
265
  +'<div class="row"><span class="tag">'+esc(s.platform)+'</span><span class="tag">node '+esc(s.node)+'</span><span class="tag">pid '+s.pid+'</span></div>'
83
266
  +'<p class="muted">Started '+esc(s.startedAt)+'</p>'
84
267
  +'<p>Gateway: <code>'+esc(s.gateway)+'</code></p>'
85
268
  +'<p>Dashboard: <code>'+esc(s.dashboard||'')+'</code></p></div>'));
269
+
270
+ if(t.url && /trycloudflare\\.com/.test(t.url)){
271
+ m.appendChild(el('<div class="warnbox">⚠️ This is a temporary Quick Tunnel URL — it <b>changes every restart</b>, so you would have to recreate the ChatGPT connector each time. To get a permanent URL (and never rebuild the connector), set a fixed tunnel in <b>Settings</b> (ngrok static domain, a custom subdomain, or your own domain). The auth token is persistent, so a fixed URL means no re-auth.</div>'));
272
+ }
273
+
86
274
  const card=el('<div class="card"><h2>ChatGPT MCP endpoint</h2>'
87
275
  +'<pre id="ep">'+esc(endpoint)+'</pre>'
88
- +'<div class="row"><button class="btn" id="copyEp">Copy</button></div>'
89
- +'<ol class="muted"><li>ChatGPT Settings Apps &amp; Connectors</li><li>Advanced settings → Developer Mode ON</li><li>Connectors → Create</li><li>Paste the URL above, name it LocalAnt</li><li>Ask ChatGPT: "Run health check on my local app"</li></ol></div>');
276
+ +'<div class="row"><button class="btn" id="copyEp">Copy</button><button class="btn ghost" id="testEp">Test connection</button><span id="testOut" class="muted" style="font-size:12px"></span></div>'
277
+ +'<ol class="muted"><li>Open <a href="https://chatgpt.com/#settings/Connectors" target="_blank" style="color:var(--accent);text-decoration:none;font-weight:600;">ChatGPT Connectors Settings</a></li><li><a href="https://chatgpt.com/#settings/Connectors/Advanced" target="_blank" style="color:var(--accent);text-decoration:none;font-weight:600;">Advanced settings</a> → Developer Mode ON</li><li>Connectors → Create</li><li>Paste the URL above, name it LocalAnt</li><li>Set Authentication to "None" (the token is embedded in the URL)</li><li>Ask ChatGPT: "Run health check on my local app"</li></ol></div>');
90
278
  m.appendChild(card);
91
- document.getElementById('copyEp').onclick=()=>navigator.clipboard.writeText(endpoint);
279
+ document.getElementById('copyEp').onclick=()=>{ navigator.clipboard.writeText(endpoint); toast('Copied endpoint'); };
280
+ const testBtn=document.getElementById('testEp');
281
+ testBtn.onclick=action(testBtn,async()=>{
282
+ const out=document.getElementById('testOut');
283
+ out.textContent='';
284
+ const r=await api('tunnel/test',{method:'POST'});
285
+ if(r.reachable){ out.innerHTML='<span class="risk0">✓ reachable ('+r.status+', '+r.ms+'ms) — ChatGPT can reach your gateway.</span>'; }
286
+ else { out.innerHTML='<span class="risk4">✗ not reachable'+(r.reason?': '+esc(r.reason):'')+'</span>'; }
287
+ },'Testing');
288
+
289
+ m.appendChild(tunnelControls(t));
290
+
92
291
  const hc=el('<div class="card"><h2>Health check</h2><button class="btn ghost" id="hcBtn">Run</button><pre id="hcOut" style="display:none"></pre></div>');
93
292
  m.appendChild(hc);
94
- document.getElementById('hcBtn').onclick=async()=>{ const o=document.getElementById('hcOut'); o.style.display='block'; o.textContent=JSON.stringify(await api('health'),null,2); };
293
+ document.getElementById('hcBtn').onclick=action(document.getElementById('hcBtn'),async()=>{ const o=document.getElementById('hcOut'); o.style.display='block'; o.textContent=JSON.stringify(await api('health'),null,2); });
294
+
295
+ const env=el('<div class="card"><h2>Environment</h2><div id="envOut" class="muted">Checking…</div></div>');
296
+ m.appendChild(env);
297
+ try {
298
+ const d=await api('doctor');
299
+ const chips=d.tools.map(function(t){ return '<span class="tag" style="margin:2px;'+(t.available?'':'opacity:.5')+'">'+(t.available?'✓':'✗')+' '+esc(t.name)+'</span>'; }).join(' ');
300
+ document.getElementById('envOut').innerHTML='<p>node '+esc(d.node)+' · '+esc(d.platform)+(d.skillExecOk?'':' <span class="risk2">(Node 22+ recommended for skills)</span>')+'</p><div class="row">'+chips+'</div><p class="muted" style="font-size:12px;margin-top:8px">✓ = on PATH. Install <code>cloudflared</code>/<code>ngrok</code> for tunnels, <code>claude</code>/<code>codex</code>/<code>openclaw</code>/<code>agy</code>/<code>hermes</code>/<code>opencode</code> for agents.</p>';
301
+ } catch(e){ document.getElementById('envOut').textContent='Could not load environment.'; }
95
302
  },
303
+
96
304
  async Security(m){
97
305
  const c=await api('config');
98
306
  m.innerHTML='';
99
307
  const t=c.tunnel||{};
100
308
  if(t.provider && t.provider!=='none'){ m.appendChild(el('<div class="warnbox">⚠️ A public tunnel exposes this gateway to the internet. Anyone with the URL + token can reach your tools. Keep the token secret and stop the tunnel when not in use.</div>')); }
101
- m.appendChild(el('<div class="card"><h2>Allowed directories</h2><pre>'+esc(JSON.stringify(c.security.allowedDirectories,null,2))+'</pre></div>'));
102
- m.appendChild(el('<div class="card"><h2>Allowed commands</h2><pre>'+esc(JSON.stringify(c.security.allowedCommands,null,2))+'</pre></div>'));
103
- m.appendChild(el('<div class="card"><h2>Blocked command tokens</h2><pre>'+esc(JSON.stringify(c.security.blockedCommandTokens,null,2))+'</pre></div>'));
309
+ const modeNote = { strict:'allow-list: only allowed dirs/commands, per-risk approval', open:'deny-list: everything except the blocklist; only risk-4 needs approval', yolo:'deny-list with no approval gates at all' };
310
+ m.appendChild(el('<div class="card"><h2>Security mode</h2><p><code>'+esc(c.security.mode)+'</code> — '+esc(modeNote[c.security.mode]||'')+'</p><p class="muted">Change it in Settings.</p></div>'));
311
+ m.appendChild(el('<div class="card"><h2>Allowed directories <span class="muted">(strict mode only)</span></h2><pre>'+esc(JSON.stringify(c.security.allowedDirectories,null,2))+'</pre></div>'));
312
+ m.appendChild(el('<div class="card"><h2>Allowed commands <span class="muted">(strict mode only)</span></h2><pre>'+esc(JSON.stringify(c.security.allowedCommands,null,2))+'</pre></div>'));
313
+ m.appendChild(el('<div class="card"><h2>Blocked command tokens <span class="muted">(always enforced)</span></h2><pre>'+esc(JSON.stringify(c.security.blockedCommandTokens,null,2))+'</pre></div>'));
104
314
  m.appendChild(el('<div class="card"><h2>Risk policy</h2><p class="muted">risk 0 read-only · 1 draft · 2 file-mod · 3 shell/agent · 4 destructive/publish</p><p>approveRisk1: <code>'+c.security.approveRisk1+'</code></p></div>'));
105
315
  },
316
+
106
317
  async Approvals(m){
107
318
  const list=await api('approvals');
108
319
  m.innerHTML='<div class="card"><h2>Pending approvals</h2><div id="ap"></div></div>';
@@ -113,62 +324,557 @@ const VIEWS = {
113
324
  +'<p class="muted">'+esc(a.summary)+'</p><p class="muted">'+esc(a.reason)+'</p>'
114
325
  +'<div class="row"><button class="btn ok">Approve once</button><button class="btn">Approve for session</button><button class="btn danger">Deny</button></div></div>');
115
326
  const [once,sess,deny]=d.querySelectorAll('button');
116
- once.onclick=async()=>{await api('approvals/'+a.id+'/approve',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({scope:'once'})});render();};
117
- sess.onclick=async()=>{await api('approvals/'+a.id+'/approve',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({scope:'session'})});render();};
118
- deny.onclick=async()=>{await api('approvals/'+a.id+'/deny',{method:'POST'});render();};
327
+ once.onclick=action(once,async()=>{await api('approvals/'+a.id+'/approve',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({scope:'once'})});render();});
328
+ sess.onclick=action(sess,async()=>{await api('approvals/'+a.id+'/approve',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({scope:'session'})});render();});
329
+ deny.onclick=action(deny,async()=>{await api('approvals/'+a.id+'/deny',{method:'POST'});render();});
119
330
  ap.appendChild(d);
120
331
  }
121
332
  },
333
+
122
334
  async Audit(m){
123
- const logs=await api('audit');
124
- m.innerHTML='<div class="card"><h2>Audit log</h2><table><thead><tr><th>Time</th><th>Tool</th><th>Risk</th><th>Approval</th><th>ms</th><th>In</th></tr></thead><tbody id="lg"></tbody></table></div>';
335
+ m.innerHTML='<div class="card"><h2>Audit log</h2>'
336
+ +'<div class="row" style="margin-bottom:12px;gap:8px"><input type="text" id="auditQ" placeholder="Search tool / input / output…" style="flex:1;min-width:200px" /><button class="btn ghost" id="auditSearch">Search</button><button class="btn ghost" id="auditClear">Clear</button></div>'
337
+ +'<p class="muted" style="margin-top:0;font-size:12px">Click a row for the full entry.</p>'
338
+ +'<table><thead><tr><th>Time</th><th>Tool</th><th>Risk</th><th>Approval</th><th>ms</th><th>In</th></tr></thead><tbody id="lg"></tbody></table></div>';
125
339
  const tb=document.getElementById('lg');
126
- for(const e of logs){ tb.appendChild(el('<tr><td class="muted">'+esc(e.timestamp.replace("T"," ").slice(0,19))+'</td><td>'+esc(e.tool)+'</td><td class="'+riskClass(e.risk)+'">'+e.risk+'</td><td>'+esc(e.approval)+(e.error?' <span class="risk4">err</span>':'')+'</td><td>'+e.durationMs+'</td><td class="muted">'+esc(e.inputSummary).slice(0,80)+'</td></tr>')); }
340
+ const load=async(q)=>{
341
+ tb.innerHTML='';
342
+ const logs=await api('audit'+(q?('?q='+encodeURIComponent(q)):''));
343
+ if(!logs.length){ tb.appendChild(el('<tr><td colspan=6 class="muted">'+(q?'No matches.':'No audit entries yet.')+'</td></tr>')); return; }
344
+ for(const e of logs){
345
+ const tr=el('<tr style="cursor:pointer"><td class="muted">'+esc(e.timestamp.replace("T"," ").slice(0,19))+'</td><td>'+esc(e.tool)+'</td><td class="'+riskClass(e.risk)+'">'+e.risk+'</td><td>'+esc(e.approval)+(e.error?' <span class="risk4">err</span>':'')+'</td><td>'+e.durationMs+'</td><td class="muted">'+esc(String(e.inputSummary).slice(0,80))+'</td></tr>');
346
+ tr.onclick=()=>showAuditDetail(e.id);
347
+ tb.appendChild(tr);
348
+ }
349
+ };
350
+ document.getElementById('auditSearch').onclick=action(document.getElementById('auditSearch'),async()=>{ await load(document.getElementById('auditQ').value.trim()); });
351
+ document.getElementById('auditClear').onclick=async()=>{ document.getElementById('auditQ').value=''; await load(''); };
352
+ document.getElementById('auditQ').addEventListener('keydown',(ev)=>{ if(ev.key==='Enter') document.getElementById('auditSearch').click(); });
353
+ await load('');
127
354
  },
355
+
128
356
  async Skills(m){
129
- const skills=await api('skills');
130
- m.innerHTML='<div class="card"><h2>Skills</h2><table><thead><tr><th>Name</th><th>Ver</th><th>Risk</th><th>State</th><th>Tools</th><th></th></tr></thead><tbody id="sk"></tbody></table></div>';
357
+ const data=await api('skills');
358
+ const skills=data.skills||[];
359
+ m.innerHTML='<div class="card"><h2>Skills</h2>'
360
+ +'<p class="muted">Drop skills into <code>'+esc(data.skillsDir||'')+'</code> or create one below. Generated skills start disabled.</p>'
361
+ +'<table><thead><tr><th>Name</th><th>Ver</th><th>Risk</th><th>State</th><th>Tools</th><th></th></tr></thead><tbody id="sk"></tbody></table></div>'
362
+ +'<div class="card"><h2>Create skill</h2>'
363
+ +'<div class="row" style="gap:12px;">'
364
+ +'<input type="text" id="skName" placeholder="name (kebab-case)" style="width:200px" />'
365
+ +'<input type="text" id="skDesc" placeholder="description" style="flex:1;min-width:200px" />'
366
+ +'<select id="skRisk" style="width:90px"><option value="0">risk 0</option><option value="1" selected>risk 1</option><option value="2">risk 2</option><option value="3">risk 3</option><option value="4">risk 4</option></select>'
367
+ +'<button class="btn" id="skCreate">Create</button>'
368
+ +'</div><p class="muted" style="margin-top:8px;">The generated skill is saved <b>disabled</b>. Review its permissions, then enable it.</p></div>'
369
+ +'<div class="card"><h2>Install from Git</h2>'
370
+ +'<div class="row" style="gap:12px;">'
371
+ +'<input type="text" id="skUrl" placeholder="https://github.com/user/my-skill.git" style="flex:1;min-width:240px" />'
372
+ +'<button class="btn" id="skInstall">Clone</button>'
373
+ +'</div><p class="muted" style="margin-top:8px;">Clones the repo into the skills directory <b>disabled</b>. Only install skills you trust — review permissions before enabling.</p></div>';
131
374
  const tb=document.getElementById('sk');
375
+ if(!skills.length){ tb.appendChild(el('<tr><td colspan=6 class="muted">No skills found.</td></tr>')); }
132
376
  for(const s of skills){
133
- const tr=el('<tr><td><b>'+esc(s.name)+'</b>'+(s.generated?' <span class="tag">generated</span>':'')+'<br><span class="muted">'+esc(s.description)+'</span></td><td>'+esc(s.version)+'</td><td class="'+riskClass(s.riskLevel)+'">'+s.riskLevel+'</td><td>'+(s.enabled?'<span class="risk0">enabled</span>':'<span class="muted">disabled</span>')+(s.valid?'':' <span class="risk4">invalid</span>')+'</td><td class="muted">'+esc((s.tools||[]).join(", "))+'</td><td></td></tr>');
134
- const btn=el('<button class="btn ghost">'+(s.enabled?'Disable':'Enable')+'</button>');
135
- btn.onclick=async()=>{await api('skills/'+s.name+'/'+(s.enabled?'disable':'enable'),{method:'POST'});render();};
136
- tr.lastElementChild.appendChild(btn);
377
+ const tr=el('<tr><td><b>'+esc(s.name)+'</b>'+(s.generated?' <span class="tag">generated</span>':'')+(s.bundled?' <span class="tag">bundled</span>':'')+'<br><span class="muted">'+esc(s.description)+'</span></td><td>'+esc(s.version)+'</td><td class="'+riskClass(s.riskLevel)+'">'+s.riskLevel+'</td><td>'+(s.enabled?'<span class="risk0">enabled</span>':'<span class="muted">disabled</span>')+(s.valid?'':' <span class="risk4">invalid</span>')+'</td><td class="muted">'+esc((s.tools||[]).join(", "))+'</td><td></td></tr>');
378
+ const cell=tr.lastElementChild;
379
+ const toggle=el('<button class="btn ghost sm">'+(s.enabled?'Disable':'Enable')+'</button>');
380
+ toggle.onclick=action(toggle,async()=>{await api('skills/'+encodeURIComponent(s.name)+'/'+(s.enabled?'disable':'enable'),{method:'POST'});toast(s.name+(s.enabled?' disabled':' enabled'));render();});
381
+ cell.appendChild(toggle);
382
+ const info=el('<button class="btn ghost sm" style="margin-left:6px">Details</button>');
383
+ info.onclick=action(info,async()=>{ await showSkillDetail(s.name); });
384
+ cell.appendChild(info);
385
+ if(!s.bundled){
386
+ const un=el('<button class="btn danger sm" style="margin-left:6px;background:none;border:1px solid var(--danger);color:var(--danger)">Uninstall</button>');
387
+ un.onclick=action(un,async()=>{ if(confirm('Uninstall skill "'+s.name+'"? This deletes its files.')){ await api('skills/'+encodeURIComponent(s.name),{method:'DELETE'}); toast(s.name+' uninstalled'); render(); } });
388
+ cell.appendChild(un);
389
+ }
137
390
  tb.appendChild(tr);
138
391
  }
392
+ document.getElementById('skCreate').onclick=action(document.getElementById('skCreate'),async()=>{
393
+ const name=document.getElementById('skName').value.trim();
394
+ const description=document.getElementById('skDesc').value.trim();
395
+ const riskLevel=parseInt(document.getElementById('skRisk').value,10);
396
+ if(!name||!description){ toast('Name and description are required.','err'); return; }
397
+ await api('skills',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name,description,riskLevel})});
398
+ toast('Skill "'+name+'" created (disabled)');
399
+ render();
400
+ },'Creating');
401
+ document.getElementById('skInstall').onclick=action(document.getElementById('skInstall'),async()=>{
402
+ const url=document.getElementById('skUrl').value.trim();
403
+ if(!url){ toast('Git URL is required.','err'); return; }
404
+ const r=await api('skills/install',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({url})});
405
+ toast('Installed "'+r.installed+'" (disabled)');
406
+ render();
407
+ },'Cloning');
139
408
  },
140
- async Projects(m){
141
- const ps=await api('projects');
142
- m.innerHTML='<div class="card"><h2>Projects</h2><table><thead><tr><th>Name</th><th>Path</th><th>Stack</th></tr></thead><tbody id="pj"></tbody></table></div>';
143
- const tb=document.getElementById('pj');
144
- for(const p of ps){ tb.appendChild(el('<tr><td><b>'+esc(p.name)+'</b></td><td class="muted">'+esc(p.path)+'</td><td>'+esc((p.stack||[]).join(", "))+'</td></tr>')); }
145
- if(!ps.length) tb.appendChild(el('<tr><td colspan=3 class="muted">No projects registered.</td></tr>'));
146
- },
409
+
147
410
  async Secrets(m){
148
411
  const s=await api('secrets');
149
- m.innerHTML='<div class="card"><h2>Secrets</h2><p class="muted">Names only — values are never displayed.</p><ul id="sl"></ul></div>';
412
+ m.innerHTML='<div class="card"><h2>Secrets</h2><p class="muted">Names only — values are never displayed.</p>'
413
+ +'<ul id="sl" style="padding-left:20px;margin-bottom:24px;"></ul>'
414
+ +'<h3>Add secret</h3>'
415
+ +'<div class="row" style="margin-top:12px;gap:12px;">'
416
+ +'<input type="text" id="secName" placeholder="Secret name (e.g. QIITA_TOKEN)" style="width:250px" />'
417
+ +'<input type="password" id="secVal" placeholder="Secret value" style="width:220px" />'
418
+ +'<button class="btn ghost sm" id="secValShow">Show</button>'
419
+ +'<button class="btn" id="addSecBtn">Add secret</button>'
420
+ +'</div></div>';
421
+ wirePw('secVal','secValShow');
150
422
  const ul=document.getElementById('sl');
151
- for(const n of s.names){ ul.appendChild(el('<li><code>'+esc(n)+'</code></li>')); }
152
423
  if(!s.names.length) ul.appendChild(el('<li class="muted">No secrets stored.</li>'));
424
+ for(const n of s.names){
425
+ const li=el('<li style="margin-bottom:8px;display:flex;align-items:center;gap:12px;"><code>'+esc(n)+'</code></li>');
426
+ const rm=el('<button class="btn danger sm" style="background:none;border:1px solid var(--danger);color:var(--danger)">Remove</button>');
427
+ rm.onclick=action(rm,async()=>{ if(confirm('Remove secret "'+n+'"?')){ await api('secrets/'+encodeURIComponent(n),{method:'DELETE'}); toast('Removed '+n); render(); } });
428
+ li.appendChild(rm);
429
+ ul.appendChild(li);
430
+ }
431
+ document.getElementById('addSecBtn').onclick=action(document.getElementById('addSecBtn'),async()=>{
432
+ const name=document.getElementById('secName').value.trim();
433
+ const value=document.getElementById('secVal').value.trim();
434
+ if(!name||!value){ toast('Both name and value are required.','err'); return; }
435
+ await api('secrets',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name,value})});
436
+ toast('Secret added');
437
+ render();
438
+ });
153
439
  },
440
+
154
441
  async Agents(m){
155
442
  const a=await api('agents');
156
- m.innerHTML='<div class="card"><h2>Coding agents</h2><table><thead><tr><th>Agent</th><th>Enabled</th><th>CLI available</th><th>Command</th></tr></thead><tbody id="ag"></tbody></table></div>';
443
+ const tasks=await api('agents/tasks');
444
+ m.innerHTML='<div class="card"><h2>Coding agents</h2><table><thead><tr><th>Agent</th><th>CLI available</th><th>Command</th><th>Enabled</th></tr></thead><tbody id="ag"></tbody></table></div>';
157
445
  const tb=document.getElementById('ag');
158
- for(const x of a){ tb.appendChild(el('<tr><td><b>'+esc(x.agent)+'</b></td><td>'+(x.enabled?'yes':'no')+'</td><td>'+(x.available?'<span class="risk0">yes</span>':'<span class="muted">no</span>')+'</td><td class="muted"><code>'+esc(x.command)+'</code></td></tr>')); }
446
+ for(const x of a){
447
+ const avail = x.available ? '<span class="risk0">yes</span>' : '<span class="risk4">not on PATH</span>';
448
+ const tr=el('<tr><td><b>'+esc(x.agent)+'</b></td><td>'+avail+'</td><td class="muted"><code>'+esc(x.command)+'</code></td><td></td></tr>');
449
+ const toggle=el('<button class="btn '+(x.enabled?'ok':'ghost')+' sm">'+(x.enabled?'Enabled':'Disabled')+'</button>');
450
+ toggle.onclick=action(toggle,async()=>{ await api('agents/'+encodeURIComponent(x.agent)+'/'+(x.enabled?'disable':'enable'),{method:'POST'}); toast(x.agent+(x.enabled?' disabled':' enabled')); render(); });
451
+ tr.lastElementChild.appendChild(toggle);
452
+ if(!x.available){ tr.lastElementChild.appendChild(el('<span class="muted" style="margin-left:8px;font-size:11px">install <code>'+esc(x.command)+'</code> to run it</span>')); }
453
+ tb.appendChild(tr);
454
+ }
455
+
456
+ // Launcher: only agents that are enabled AND on PATH can actually run.
457
+ const runnable=a.filter(function(x){return x.enabled && x.available;});
458
+ const runCard=el('<div class="card"><h2>Run a task</h2></div>');
459
+ if(!runnable.length){
460
+ runCard.appendChild(el('<p class="muted">Enable an agent above whose CLI is installed to run tasks from here.</p>'));
461
+ } else {
462
+ const agentOpts=runnable.map(function(x){return '<option value="'+esc(x.agent)+'">'+esc(x.agent)+'</option>';}).join('');
463
+ runCard.appendChild(el('<div class="row" style="gap:12px;margin-bottom:12px">'
464
+ +'<select id="runAgent">'+agentOpts+'</select>'
465
+ +'<input type="text" id="runCwd" placeholder="working directory (absolute path)" style="flex:1;min-width:240px" />'
466
+ +'<select id="runMode"><option value="plan">plan (read-only)</option><option value="execute">execute (creates a branch)</option></select>'
467
+ +'</div>'));
468
+ runCard.appendChild(el('<textarea id="runTask" rows="3" placeholder="Describe the task…"></textarea>'));
469
+ const runBtn=el('<div class="row" style="margin-top:8px"><button class="btn" id="runBtn">Run</button></div>');
470
+ runCard.appendChild(runBtn);
471
+ runBtn.querySelector('#runBtn').onclick=action(runBtn.querySelector('#runBtn'),async()=>{
472
+ const agent=document.getElementById('runAgent').value;
473
+ const cwd=document.getElementById('runCwd').value.trim();
474
+ const mode=document.getElementById('runMode').value;
475
+ const task=document.getElementById('runTask').value.trim();
476
+ if(!cwd){ toast('Working directory is required.','err'); return; }
477
+ if(!task){ toast('Describe the task first.','err'); return; }
478
+ await api('agents/run',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({agent,cwd,task,mode})});
479
+ toast('Task '+mode+' started');
480
+ render();
481
+ },'Running');
482
+ }
483
+ m.appendChild(runCard);
484
+
485
+ const taskCard=el('<div class="card"><h2>Agent tasks</h2><table><thead><tr><th>Created</th><th>Agent</th><th>Mode</th><th>Status</th><th>Branch</th><th></th></tr></thead><tbody id="tk"></tbody></table></div>');
486
+ m.appendChild(taskCard);
487
+ const tkb=taskCard.querySelector('#tk');
488
+ if(!tasks.length){ tkb.appendChild(el('<tr><td colspan=6 class="muted">No agent tasks yet.</td></tr>')); }
489
+ for(const t of tasks){
490
+ const stCls = t.status==='completed'?'risk0':(t.status==='failed'?'risk4':(t.status==='running'?'risk2':''));
491
+ const tr=el('<tr><td class="muted">'+esc(String(t.createdAt).replace("T"," ").slice(0,19))+'</td><td>'+esc(t.agent)+'</td><td>'+esc(t.mode)+'</td><td class="'+stCls+'">'+esc(t.status)+'</td><td class="muted">'+esc(t.branch||'')+'</td><td></td></tr>');
492
+ const cell=tr.lastElementChild;
493
+ const logs=el('<button class="btn ghost sm">Logs</button>');
494
+ logs.onclick=action(logs,async()=>{
495
+ const ex=tr.nextElementSibling;
496
+ if(ex&&ex.classList.contains('logrow')){ ex.remove(); return; }
497
+ const pre=el('<pre></pre>');
498
+ const fetchLogs=async()=>{ try{ const r=await api('agents/tasks/'+encodeURIComponent(t.id)+'/logs'); pre.textContent=r.logs||'(no output)'; pre.scrollTop=pre.scrollHeight; }catch(e){} };
499
+ await fetchLogs();
500
+ const lr=el('<tr class="logrow"><td colspan=6></td></tr>');
501
+ lr.firstElementChild.appendChild(pre);
502
+ tr.after(lr);
503
+ // Live-tail a running task's output until it finishes or the row closes.
504
+ if(t.status==='running'){ const tm=setInterval(()=>{ if(!document.body.contains(pre)){ clearInterval(tm); return; } fetchLogs(); }, 2000); logTimers.push(tm); }
505
+ });
506
+ cell.appendChild(logs);
507
+ if(t.status==='running'){
508
+ const stop=el('<button class="btn danger sm" style="margin-left:6px">Stop</button>');
509
+ stop.onclick=action(stop,async()=>{ await api('agents/tasks/'+encodeURIComponent(t.id)+'/stop',{method:'POST'}); toast('Stopped'); render(); });
510
+ cell.appendChild(stop);
511
+ }
512
+ tkb.appendChild(tr);
513
+ }
514
+
515
+ // If a task is running, refresh the view when its status changes (e.g.
516
+ // running → completed) — but never while the launcher textarea has focus,
517
+ // so we don't interrupt typing.
518
+ if(tasks.some(function(t){return t.status==='running';})){
519
+ const sig=tasks.map(function(t){return t.id+':'+t.status;}).join(',');
520
+ const tm=setInterval(async()=>{
521
+ const ta=document.getElementById('runTask');
522
+ if(ta && document.activeElement===ta) return;
523
+ if(document.getElementById('modalOverlay').classList.contains('show')) return;
524
+ try{
525
+ const now=await api('agents/tasks');
526
+ if(now.map(function(t){return t.id+':'+t.status;}).join(',')!==sig && current==='Agents'){ render(); }
527
+ }catch(e){}
528
+ }, 3000);
529
+ logTimers.push(tm);
530
+ }
531
+ },
532
+
533
+ async MCP(m){
534
+ m.innerHTML='<div class="card"><h2>Bridged MCP servers</h2>'
535
+ +'<p class="muted" style="margin-top:0">Connect downstream <b>stdio</b> MCP servers to LocalAnt. Their tools are proxied through the gateway (approval + audit pipeline). '
536
+ +'ChatGPT uses them via <code>mcp_server_list_tools</code> to discover and <code>mcp_server_run_tool</code> to invoke — these are available even in the <b>minimal</b> tool profile.</p>'
537
+ +'<table><thead><tr><th>Name</th><th>Command</th><th>Enabled</th><th></th></tr></thead><tbody id="mcpList"></tbody></table>'
538
+ +'<h3>Add server</h3>'
539
+ +'<div class="row" style="gap:12px">'
540
+ +'<input type="text" id="mcpName" placeholder="name (e.g. filesystem)" style="width:160px" />'
541
+ +'<input type="text" id="mcpCmd" placeholder="command (e.g. npx)" style="width:150px" />'
542
+ +'<input type="text" id="mcpArgs" placeholder="args (space-separated)" style="flex:1;min-width:180px" />'
543
+ +'<button class="btn" id="mcpAdd">Add</button>'
544
+ +'</div>'
545
+ +'<p class="muted" style="margin-top:8px;font-size:12px">Example — official filesystem server: command <code>npx</code>, args <code>-y @modelcontextprotocol/server-filesystem /path/to/dir</code>. Use <b>Test</b> to verify the connection and list its tools.</p>'
546
+ +'</div>';
547
+
548
+ const mcpServers=await api('mcp-servers');
549
+ const mcpList=document.getElementById('mcpList');
550
+ if(!mcpServers.length) mcpList.appendChild(el('<tr><td colspan=4 class="muted">No MCP servers configured. Add one below.</td></tr>'));
551
+ for(const sv of mcpServers){
552
+ const tr=el('<tr><td><b>'+esc(sv.name)+'</b></td><td class="muted"><code>'+esc(sv.command)+' '+esc((sv.args||[]).join(" "))+'</code></td><td>'+(sv.enabled?'<span class="risk0">yes</span>':'<span class="muted">no</span>')+'</td><td></td></tr>');
553
+ const cell=tr.lastElementChild;
554
+ const tog=el('<button class="btn ghost sm">'+(sv.enabled?'Disable':'Enable')+'</button>');
555
+ tog.onclick=action(tog,async()=>{ await api('mcp-servers/'+encodeURIComponent(sv.name)+'/'+(sv.enabled?'disable':'enable'),{method:'POST'}); render(); });
556
+ cell.appendChild(tog);
557
+ const test=el('<button class="btn ghost sm" style="margin-left:6px">Test</button>');
558
+ test.onclick=action(test,async()=>{ const r=await api('mcp-servers/'+encodeURIComponent(sv.name)+'/test',{method:'POST'}); if(r.ok){ toast(sv.name+': '+r.tools.length+' tools ('+r.tools.slice(0,5).join(', ')+')'); } else { toast(sv.name+': '+r.reason,'err'); } },'Testing');
559
+ cell.appendChild(test);
560
+ const rm=el('<button class="btn danger sm" style="margin-left:6px;background:none;border:1px solid var(--danger);color:var(--danger)">Remove</button>');
561
+ rm.onclick=action(rm,async()=>{ if(confirm('Remove MCP server "'+sv.name+'"?')){ await api('mcp-servers/'+encodeURIComponent(sv.name),{method:'DELETE'}); render(); } });
562
+ cell.appendChild(rm);
563
+ mcpList.appendChild(tr);
564
+ }
565
+ document.getElementById('mcpAdd').onclick=action(document.getElementById('mcpAdd'),async()=>{
566
+ const name=document.getElementById('mcpName').value.trim();
567
+ const command=document.getElementById('mcpCmd').value.trim();
568
+ const args=document.getElementById('mcpArgs').value.trim();
569
+ if(!name||!command){ toast('Name and command are required.','err'); return; }
570
+ await api('mcp-servers',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name,command,args,enabled:true})});
571
+ toast('MCP server added');
572
+ render();
573
+ });
159
574
  },
575
+
160
576
  async Settings(m){
161
577
  const c=await api('config');
162
- m.innerHTML='<div class="card"><h2>Configuration</h2><pre>'+esc(JSON.stringify(c,null,2))+'</pre><p class="muted">Edit config via the CLI or update_config tool.</p></div>';
578
+ const sec=c.security||{};
579
+ const mode=sec.mode||'open';
580
+ const allowedDirs=sec.allowedDirectories||[];
581
+ const allowedCmds=sec.allowedCommands||[];
582
+ const blockedTokens=sec.blockedCommandTokens||[];
583
+ const CORE=["sudo","su","mkfs","mkfs.ext4","dd","fdisk","diskutil","shutdown","reboot"];
584
+
585
+ const tun=c.tunnel||{};
586
+
587
+ m.innerHTML='<div class="card"><h2>Security settings</h2>'
588
+ +'<div class="field"><label>Security mode</label>'
589
+ +'<select id="secMode" style="width:160px">'
590
+ +'<option value="open"'+(mode==='open'?' selected':'')+'>open (default)</option>'
591
+ +'<option value="strict"'+(mode==='strict'?' selected':'')+'>strict</option>'
592
+ +'<option value="yolo"'+(mode==='yolo'?' selected':'')+'>yolo</option>'
593
+ +'</select>'
594
+ +'<p class="muted" style="margin-top:6px;font-size:12px;"><b>open</b>: deny-list for personal use — anything except the blocklist; only risk-4 needs approval. <b>strict</b>: allow-list + per-risk approval (shared machines). <b>yolo</b>: no approval gates at all.</p>'
595
+ +'</div>'
596
+ +'<div class="field"><label style="display:flex;align-items:center;gap:8px;cursor:pointer;"><input type="checkbox" id="approveRisk1"'+(sec.approveRisk1?' checked':'')+' style="width:auto"/> Require approval for Risk 1 (draft) actions</label></div>'
597
+ +'<div class="field"><label>Tool profile</label>'
598
+ +'<select id="toolProfile" style="width:160px">'
599
+ +'<option value="minimal"'+(((c.tools&&c.tools.profile)||'minimal')==='minimal'?' selected':'')+'>minimal (default)</option>'
600
+ +'<option value="coding"'+((c.tools&&c.tools.profile)==='coding'?' selected':'')+'>coding</option>'
601
+ +'<option value="full"'+((c.tools&&c.tools.profile)==='full'?' selected':'')+'>full</option>'
602
+ +'</select>'
603
+ +'<p class="muted" style="margin-top:6px;font-size:12px;"><b>minimal</b>: advertise only the core surface to ChatGPT — Shell, coding Agent, Skill, read-only files, and the control plane. <b>coding</b>: use ChatGPT as a local coding agent — read/edit/apply_patch, grep/glob, bash, git, project validation, todo/plan, agent delegation. <b>full</b>: advertise every tool (browser, adb, git, publishers, file writes, …).</p>'
604
+ +'</div>'
605
+ +'</div>'
606
+
607
+ +'<div class="card"><h2>Auth token</h2>'
608
+ +'<p class="muted" style="margin-top:0">The token authenticates ChatGPT (it is embedded in the MCP URL). Keep it secret.</p>'
609
+ +'<div class="row" style="gap:8px"><input type="password" id="authTok" value="" placeholder="•••••••• (click Reveal)" readonly style="flex:1" /><button class="btn ghost sm" id="authReveal">Reveal</button><button class="btn ghost sm" id="authCopy">Copy</button></div>'
610
+ +'<p class="muted" style="margin-top:8px;font-size:12px">Rotating the token immediately invalidates the old MCP URL — you must update the URL in the ChatGPT connector (or just re-paste the new one shown on Home). Existing stored secrets are unaffected.</p>'
611
+ +'<button class="btn danger" id="authRotate" style="margin-top:4px">Rotate token</button>'
612
+ +'</div>'
613
+
614
+ +'<div class="card"><h2>Tunnel — fixed URL</h2>'
615
+ +'<p class="muted" style="margin-top:0">A fixed URL means you never recreate the ChatGPT connector. Pick a provider and fill the matching field, then Save &amp; restart.</p>'
616
+ +'<div class="field"><label>Provider</label>'
617
+ +'<select id="tunProvider" style="width:160px">'
618
+ +['cloudflared','ngrok','localtunnel','serveo','none'].map(function(p){return '<option value="'+p+'"'+((tun.provider||'cloudflared')===p?' selected':'')+'>'+p+'</option>';}).join('')
619
+ +'</select></div>'
620
+ +'<div class="field"><label>Custom subdomain (localtunnel: no signup · serveo: one-time SSH key registration for a fixed URL)</label><input type="text" id="tunSubdomain" value="'+esc(tun.subdomain||'')+'" placeholder="e.g. my-localant-mcp" /></div>'
621
+ +'<div class="field"><label>Token / authtoken (cloudflared tunnel token / ngrok authtoken)</label><div class="row" style="gap:8px"><input type="password" id="tunToken" value="'+esc(tun.token||'')+'" placeholder="Cloudflare Tunnel token or ngrok authtoken" style="flex:1" /><button class="btn ghost sm" id="tunTokenShow">Show</button></div></div>'
622
+ +'<div class="field"><label>Custom domain (ngrok static domain)</label><input type="text" id="tunDomain" value="'+esc(tun.domain||'')+'" placeholder="e.g. my-app.ngrok-free.app" /></div>'
623
+ +'<div class="field"><label>Public URL (override — used as-is)</label><input type="text" id="tunUrl" value="'+esc(tun.publicUrl||'')+'" placeholder="e.g. https://my-domain.com" /></div>'
624
+ +'<div class="row"><button class="btn" id="saveTunBtn">Save tunnel settings</button><button class="btn ghost" id="saveRestartBtn">Save &amp; restart tunnel</button></div>'
625
+ +'</div>'
626
+
627
+ +'<div class="card"><h2>Ports</h2>'
628
+ +'<div class="row" style="gap:12px;">'
629
+ +'<div class="field" style="margin:0"><label>Gateway port</label><input type="number" id="gwPort" value="'+(c.gateway&&c.gateway.port||8787)+'" style="width:120px"/></div>'
630
+ +'<div class="field" style="margin:0"><label>Dashboard port</label><input type="number" id="dashPort" value="'+(c.dashboard&&c.dashboard.port||8788)+'" style="width:120px"/></div>'
631
+ +'<button class="btn" id="savePorts" style="align-self:flex-end">Save ports</button>'
632
+ +'</div><p class="muted" style="margin-top:8px">Takes effect after restarting the gateway process.</p></div>'
633
+
634
+ +'<div class="card"><h2>Allowed directories <span class="muted">(strict mode)</span></h2><ul id="dirList" style="padding-left:20px;margin-bottom:16px;"></ul>'
635
+ +'<div class="row" style="gap:12px;"><input type="text" id="newDir" placeholder="Absolute directory path" style="flex:1;" /><button class="btn" id="addDirBtn">Add</button></div></div>'
636
+
637
+ +'<div class="card"><h2>Allowed commands <span class="muted">(strict mode)</span></h2><ul id="cmdList" style="padding-left:20px;margin-bottom:16px;"></ul>'
638
+ +'<div class="row" style="gap:12px;"><input type="text" id="newCmd" placeholder="Command prefix (e.g. npm run)" style="flex:1;" /><button class="btn" id="addCmdBtn">Add</button></div></div>'
639
+
640
+ +'<div class="card"><h2>Blocked command tokens <span class="muted">(always enforced)</span></h2><ul id="tokList" style="padding-left:20px;margin-bottom:16px;"></ul>'
641
+ +'<div class="row" style="gap:12px;"><input type="text" id="newTok" placeholder="Token (e.g. nc)" style="flex:1;" /><button class="btn" id="addTokBtn">Add</button></div></div>'
642
+
643
+ +'<div class="card"><h2>Raw config (advanced)</h2><p class="muted" style="margin-top:0">Edit the full JSON and save. Invalid config is rejected with the validation error.</p>'
644
+ +'<textarea id="rawCfg" rows="16">'+esc(JSON.stringify(c,null,2))+'</textarea>'
645
+ +'<div class="row" style="margin-top:8px"><button class="btn" id="saveRaw">Validate &amp; save</button></div></div>';
646
+
647
+ wirePw('tunToken','tunTokenShow');
648
+ const saveSec = async (update) => { await api('config',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({security:update})}); };
649
+ const saveTun = async (update) => { await api('config',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({tunnel:update})}); };
650
+
651
+ document.getElementById('secMode').onchange=async(e)=>{ try{ await saveSec({mode:e.target.value}); toast('Mode → '+e.target.value); render(); }catch(err){ toast(err.message,'err'); } };
652
+ document.getElementById('approveRisk1').onchange=async(e)=>{ try{ await saveSec({approveRisk1:e.target.checked}); toast('Saved'); }catch(err){ toast(err.message,'err'); } };
653
+ document.getElementById('toolProfile').onchange=async(e)=>{ try{ await api('config',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({tools:{profile:e.target.value}})}); toast('Tool profile → '+e.target.value); render(); }catch(err){ toast(err.message,'err'); } };
654
+
655
+ const authTok=document.getElementById('authTok');
656
+ document.getElementById('authReveal').onclick=action(document.getElementById('authReveal'),async()=>{
657
+ if(authTok.type==='text' && authTok.value){ authTok.type='password'; document.getElementById('authReveal').textContent='Reveal'; return; }
658
+ const r=await api('token'); authTok.value=r.token; authTok.type='text'; document.getElementById('authReveal').textContent='Hide';
659
+ });
660
+ document.getElementById('authCopy').onclick=action(document.getElementById('authCopy'),async()=>{ const r=await api('token'); navigator.clipboard.writeText(r.token); toast('Token copied'); });
661
+ document.getElementById('authRotate').onclick=action(document.getElementById('authRotate'),async()=>{
662
+ if(!confirm('Rotate the auth token? The current MCP URL stops working until you update it in ChatGPT.')) return;
663
+ const r=await api('token/rotate',{method:'POST'});
664
+ authTok.value=r.token; authTok.type='text'; document.getElementById('authReveal').textContent='Hide';
665
+ toast('Token rotated — update the connector URL (see Home)');
666
+ });
667
+
668
+ document.getElementById('tunProvider').onchange=async(e)=>{ try{ await saveTun({provider:e.target.value}); toast('Provider → '+e.target.value); }catch(err){ toast(err.message,'err'); } };
669
+
670
+ function tunPayload(){
671
+ return {
672
+ token: document.getElementById('tunToken').value.trim(),
673
+ domain: document.getElementById('tunDomain').value.trim(),
674
+ publicUrl: document.getElementById('tunUrl').value.trim(),
675
+ subdomain: document.getElementById('tunSubdomain').value.trim(),
676
+ provider: document.getElementById('tunProvider').value
677
+ };
678
+ }
679
+ document.getElementById('saveTunBtn').onclick=action(document.getElementById('saveTunBtn'),async()=>{ await saveTun(tunPayload()); toast('Tunnel settings saved'); });
680
+ document.getElementById('saveRestartBtn').onclick=action(document.getElementById('saveRestartBtn'),async()=>{ await saveTun(tunPayload()); const info=await api('tunnel/restart',{method:'POST'}); toast(info.url?('Tunnel up: '+info.url):('Tunnel '+info.status)); },'Restarting');
681
+
682
+ document.getElementById('savePorts').onclick=action(document.getElementById('savePorts'),async()=>{
683
+ const gp=parseInt(document.getElementById('gwPort').value,10);
684
+ const dp=parseInt(document.getElementById('dashPort').value,10);
685
+ await api('config',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({gateway:{host:(c.gateway&&c.gateway.host)||'127.0.0.1',port:gp},dashboard:{enabled:(c.dashboard?c.dashboard.enabled:true),port:dp}})});
686
+ toast('Ports saved — restart the gateway to apply');
687
+ });
688
+
689
+ const listEditor = (ulId, items, key, opts) => {
690
+ opts=opts||{};
691
+ const ul=document.getElementById(ulId);
692
+ if(!items.length) ul.innerHTML='<li class="muted">None.</li>';
693
+ items.forEach(function(v){
694
+ const isCore = opts.core && opts.core.indexOf(v)!==-1;
695
+ const li=el('<li style="margin-bottom:8px;display:flex;align-items:center;gap:12px;"><code>'+esc(v)+'</code>'+(isCore?' <span class="tag core">core</span>':'')+'</li>');
696
+ if(!isCore){
697
+ const rm=el('<button class="btn danger sm" style="background:none;border:1px solid var(--danger);color:var(--danger)">Remove</button>');
698
+ rm.onclick=action(rm,async()=>{ await saveSec({[key]: items.filter(x=>x!==v)}); render(); });
699
+ li.appendChild(rm);
700
+ }
701
+ ul.appendChild(li);
702
+ });
703
+ };
704
+ listEditor('dirList', allowedDirs, 'allowedDirectories');
705
+ listEditor('cmdList', allowedCmds, 'allowedCommands');
706
+ listEditor('tokList', blockedTokens, 'blockedCommandTokens', { core: CORE });
707
+
708
+ document.getElementById('addDirBtn').onclick=action(document.getElementById('addDirBtn'),async()=>{ const v=document.getElementById('newDir').value.trim(); if(v){ await saveSec({allowedDirectories:[...allowedDirs,v]}); render(); } });
709
+ document.getElementById('addCmdBtn').onclick=action(document.getElementById('addCmdBtn'),async()=>{ const v=document.getElementById('newCmd').value.trim(); if(v){ await saveSec({allowedCommands:[...allowedCmds,v]}); render(); } });
710
+ document.getElementById('addTokBtn').onclick=action(document.getElementById('addTokBtn'),async()=>{ const v=document.getElementById('newTok').value.trim(); if(v){ await saveSec({blockedCommandTokens:[...blockedTokens,v]}); render(); } });
711
+
712
+ document.getElementById('saveRaw').onclick=action(document.getElementById('saveRaw'),async()=>{
713
+ let parsed;
714
+ try { parsed=JSON.parse(document.getElementById('rawCfg').value); }
715
+ catch(e){ toast('Invalid JSON: '+e.message,'err'); return; }
716
+ await api('config',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(parsed)});
717
+ toast('Config saved');
718
+ render();
719
+ });
720
+ },
721
+
722
+ // One sidebar entry. Opening it shows three sub-tabs — built-in Tools, Skills,
723
+ // and MCP — each rendered into the shared pane under the sub-tab bar. The
724
+ // active sub-tab persists across re-renders (toolSub) so toggling a skill or
725
+ // server keeps you in place.
726
+ async Tools(m){
727
+ m.innerHTML = '<div class="card" style="padding:10px 14px"><div class="row" id="toolSubtabs" style="gap:8px"></div></div><div id="toolPane"></div>';
728
+ const bar = document.getElementById('toolSubtabs');
729
+ const pane = document.getElementById('toolPane');
730
+ const btns = {};
731
+ const renderSub = async function(key){
732
+ toolSub = key;
733
+ for(const k in btns){ btns[k].className = 'btn '+(k===key?'':'ghost')+' sm'; }
734
+ pane.innerHTML = '';
735
+ if(key==='skills') await VIEWS.Skills(pane);
736
+ else if(key==='mcp') await VIEWS.MCP(pane);
737
+ else await builtinToolsView(pane);
738
+ };
739
+ for(const pair of [['tools','Tools'],['skills','Skills'],['mcp','MCP']]){
740
+ const b = el('<button class="btn ghost sm">'+pair[1]+'</button>');
741
+ b.onclick = function(){ renderSub(pair[0]); };
742
+ btns[pair[0]] = b; bar.appendChild(b);
743
+ }
744
+ await renderSub(toolSub || 'tools');
163
745
  },
164
746
  };
165
747
 
748
+ // Built-in tools ChatGPT can call right now (the active profile surface only).
749
+ async function builtinToolsView(m){
750
+ const [tools, cfg] = await Promise.all([api('tools'), api('config')]);
751
+ const profile = (cfg.tools && cfg.tools.profile) || 'minimal';
752
+ const active = tools.filter(function(t){ return t.active; });
753
+ const hidden = tools.length - active.length;
754
+ m.innerHTML = '<div class="card"><h2>Built-in tools</h2>'
755
+ + '<p class="muted" style="margin-top:0">Built-in tools ChatGPT can call right now — profile <code>'+esc(profile)+'</code> exposes <b>'+active.length+'</b>'+(hidden?' ('+hidden+' inactive tools hidden — switch to the <code>full</code> profile in Settings to expose them)':'')+'.</p>'
756
+ + '<div class="row" style="gap:8px;margin-bottom:10px"><input type="text" id="toolSearch" placeholder="Search tools…" style="flex:1;min-width:200px" /></div>'
757
+ + '<table><thead><tr><th>Name</th><th>Risk</th><th>Description</th><th>Parameters</th></tr></thead><tbody id="tl"></tbody></table>'
758
+ + '<p class="muted" id="toolEmpty" style="display:none">No matching tools.</p>'
759
+ + '</div>';
760
+ const tb = document.getElementById('tl');
761
+ const rowEls = [];
762
+ if(!active.length){ tb.appendChild(el('<tr><td colspan=4 class="muted">No active tools in this profile.</td></tr>')); }
763
+ for(const t of active){
764
+ const params = Object.entries(t.inputSchema || {}).map(function(item){
765
+ const info = item[1];
766
+ const d = info.description ? (' - ' + esc(info.description)) : '';
767
+ return '<code>' + esc(item[0]) + '</code>: <span class="muted">' + esc(info.type) + '</span>' + d;
768
+ }).join('<br>') || '<span class="muted">none</span>';
769
+ const tr = el('<tr><td><b>'+esc(t.name)+'</b></td><td class="'+riskClass(t.risk)+'">risk '+t.risk+'</td><td>'+esc(t.description)+'</td><td>'+params+'</td></tr>');
770
+ tr._text = (t.name + ' ' + (t.description||'')).toLowerCase();
771
+ rowEls.push(tr); tb.appendChild(tr);
772
+ }
773
+ document.getElementById('toolSearch').oninput=function(){
774
+ const q=(this.value||'').toLowerCase().trim();
775
+ let shown=0;
776
+ for(const tr of rowEls){ const ok=!q||tr._text.indexOf(q)>=0; tr.style.display=ok?'':'none'; if(ok) shown++; }
777
+ document.getElementById('toolEmpty').style.display=shown?'none':'';
778
+ };
779
+ }
780
+
781
+ async function showAuditDetail(id){
782
+ try {
783
+ const e=await api('audit/'+encodeURIComponent(id));
784
+ const row=(k,val)=>'<tr><td>'+esc(k)+'</td><td>'+val+'</td></tr>';
785
+ const html='<table>'
786
+ +row('Time',esc(e.timestamp))
787
+ +row('Tool','<b>'+esc(e.tool)+'</b>')
788
+ +row('Caller',esc(e.caller))
789
+ +row('Risk','<span class="'+riskClass(e.risk)+'">'+e.risk+'</span>')
790
+ +row('Approval',esc(e.approval))
791
+ +row('Duration',e.durationMs+' ms')
792
+ +(e.error?row('Error','<span class="risk4">'+esc(e.error)+'</span>'):'')
793
+ +row('Input','<pre style="margin:0">'+esc(e.inputSummary)+'</pre>')
794
+ +row('Output','<pre style="margin:0">'+esc(e.outputSummary)+'</pre>')
795
+ +'</table>';
796
+ openModal('Audit entry', html);
797
+ } catch(e){ toast(e.message,'err'); }
798
+ }
799
+
800
+ async function showSkillDetail(name){
801
+ try {
802
+ const s=await api('skills/'+encodeURIComponent(name));
803
+ const perms=(s.manifest&&s.manifest.permissions)||{};
804
+ const v=s.validation||{valid:true,errors:[]};
805
+ const row=(k,val)=>'<tr><td>'+esc(k)+'</td><td>'+val+'</td></tr>';
806
+ const tools=(s.manifest.tools||[]).map(function(t){return '<code>'+esc(t.name)+'</code>';}).join(' ')||'<span class="muted">none</span>';
807
+ const validHtml = v.valid ? '<span class="risk0">valid</span>' : '<span class="risk4">'+esc((v.errors||[]).join('; '))+'</span>';
808
+ const html='<table>'
809
+ +row('Name','<b>'+esc(s.manifest.name)+'</b>')
810
+ +row('Version',esc(s.manifest.version))
811
+ +row('Risk','<span class="'+riskClass(s.manifest.riskLevel)+'">'+s.manifest.riskLevel+'</span>')
812
+ +row('State',(s.enabled?'<span class="risk0">enabled</span>':'<span class="muted">disabled</span>')+(s.bundled?' · bundled':''))
813
+ +row('Directory','<code style="word-break:break-all">'+esc(s.dir)+'</code>')
814
+ +row('Tools',tools)
815
+ +row('Permissions','<pre style="margin:0">'+esc(JSON.stringify(perms,null,2))+'</pre>')
816
+ +row('Validation',validHtml)
817
+ +'</table>';
818
+ let runHtml='';
819
+ if(s.enabled && (s.manifest.tools||[]).length){
820
+ const toolOpts=(s.manifest.tools||[]).map(function(t){return '<option value="'+esc(t.name)+'">'+esc(t.name)+'</option>';}).join('');
821
+ runHtml='<h3 style="margin-top:16px;font-size:13px">Run a tool</h3>'
822
+ +'<div class="row" style="gap:8px;margin-bottom:8px"><select id="skRunTool">'+toolOpts+'</select><button class="btn sm" id="skRunBtn">Run</button></div>'
823
+ +'<textarea id="skRunInput" rows="3" placeholder="JSON input (e.g. an object)">{}</textarea>'
824
+ +'<pre id="skRunOut" style="display:none;margin-top:8px"></pre>';
825
+ } else if(!s.enabled){
826
+ runHtml='<p class="muted" style="margin-top:12px;font-size:12px">Enable the skill to run its tools.</p>';
827
+ }
828
+ openModal('Skill: '+s.manifest.name, html+runHtml);
829
+ const runBtn=document.getElementById('skRunBtn');
830
+ if(runBtn){
831
+ runBtn.onclick=action(runBtn,async()=>{
832
+ const tool=document.getElementById('skRunTool').value;
833
+ let input;
834
+ try { input=JSON.parse(document.getElementById('skRunInput').value||'{}'); }
835
+ catch(e){ toast('Input must be valid JSON: '+e.message,'err'); return; }
836
+ const out=document.getElementById('skRunOut');
837
+ const r=await api('skills/'+encodeURIComponent(s.manifest.name)+'/run',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({tool,input})});
838
+ out.style.display='block';
839
+ out.textContent=JSON.stringify(r.result,null,2);
840
+ },'Running');
841
+ }
842
+ } catch(e){ toast(e.message,'err'); }
843
+ }
844
+
845
+ function setStatus(online, s){
846
+ const pill=document.getElementById('statusPill');
847
+ pill.textContent = online ? '● online' : '● offline';
848
+ pill.style.color = online ? 'var(--ok)' : 'var(--danger)';
849
+ const t=(s&&s.tunnel)||{};
850
+ document.getElementById('tunnelPill').textContent = online ? (t.url ? ('tunnel: '+t.provider) : 'tunnel: off') : '';
851
+ }
852
+
853
+ // Poll status + pending approvals so the header and the Approvals badge stay
854
+ // live — a risk-gated action triggered by ChatGPT shows up without a manual
855
+ // refresh. Only the badge/pills update unless you're on the Approvals tab.
856
+ async function poll(){
857
+ try{
858
+ const s=await api('status');
859
+ setStatus(true, s);
860
+ const ap=await api('approvals');
861
+ const prev=pendingApprovals;
862
+ pendingApprovals=ap.length;
863
+ if(pendingApprovals!==prev){
864
+ renderNav();
865
+ if(current==='Approvals' && !document.getElementById('modalOverlay').classList.contains('show')) render();
866
+ }
867
+ } catch(e){ setStatus(false); }
868
+ }
869
+
166
870
  async function boot(){
871
+ window.addEventListener('hashchange', render);
872
+ document.getElementById('modalOverlay').addEventListener('click', (e)=>{ if(e.target.id==='modalOverlay') closeModal(); });
873
+ document.addEventListener('keydown', (e)=>{ if(e.key==='Escape') closeModal(); });
167
874
  renderNav();
168
- try{ const s=await api('status'); document.getElementById('statusPill').textContent='● online'; document.getElementById('statusPill').style.color='var(--ok)';
169
- const t=s.tunnel||{}; document.getElementById('tunnelPill').textContent = t.url? ('tunnel: '+t.provider) : 'tunnel: off'; }
170
- catch{ document.getElementById('statusPill').textContent='● offline'; }
875
+ await poll();
171
876
  render();
877
+ setInterval(poll, 5000);
172
878
  }
173
879
  boot();
174
880
  </script>