anyagent-bridge 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,6 +39,8 @@ before exposing the bridge beyond localhost, and
39
39
  - Persistent sessions that survive reconnects, with automatic PTY respawn and backoff.
40
40
  - Heartbeat + dead-connection detection so stale viewers get cleaned up.
41
41
  - File management API: browse, read, write, rename, move, delete, upload, download — all behind a path whitelist.
42
+ - Add projects by **browsing folders** in the UI (the 📁 Projects button) — no typing full paths.
43
+ - Save API keys / tokens into a project's `.env.local` from the UI (the 🔑 Secrets button) — never paste them into the chat or hand-edit files.
42
44
  - Crash guards (uncaught exceptions, signals) so the server stays up.
43
45
  - Constant-time token comparison and basic rate limiting.
44
46
  - Optional **login**: Google/GitHub OAuth, TOTP 2FA, and signed expiring sessions on top of the token (Stage 3).
package/client/index.html CHANGED
@@ -146,6 +146,59 @@
146
146
  #onboard .warn { color: var(--yellow); }
147
147
  #onboard .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 6px; }
148
148
  #onboard .muted { color: var(--muted); font-size: 12px; }
149
+
150
+ /* Projects — folder-browser picker */
151
+ #projModal { position: fixed; inset: 0; z-index: 60; background: rgba(13,17,23,.96); display: none; align-items: center; justify-content: center; padding: 18px; }
152
+ #projModal.open { display: flex; }
153
+ #projModal .card { position: relative; background: var(--bar); border: 1px solid var(--border); border-radius: 12px; width: 100%; max-width: 480px; max-height: 90vh; overflow-y: auto; padding: 20px 22px; box-shadow: 0 12px 40px rgba(0,0,0,.5); }
154
+ #projModal h2 { margin: 0 0 2px; font-size: 17px; }
155
+ #projModal .sub { margin: 0 0 12px; color: var(--muted); font-size: 12.5px; line-height: 1.5; }
156
+ #projModal .pm-close { position: absolute; top: 12px; right: 14px; background: transparent; border: none; color: var(--muted); font-size: 22px; line-height: 1; cursor: pointer; padding: 2px 6px; }
157
+ #projModal .pm-close:hover { color: var(--fg); }
158
+ #projModal .pm-list { list-style: none; margin: 0 0 12px; padding: 0; }
159
+ #projModal .pm-list li { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border: 1px solid var(--border); border-radius: 7px; margin-bottom: 6px; }
160
+ #projModal .pm-nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
161
+ #projModal .pm-pa { font-size: 11px; color: var(--muted); word-break: break-all; flex: 1; }
162
+ #projModal .pm-del { background: transparent; border: none; color: var(--red); cursor: pointer; font-size: 14px; padding: 0 4px; }
163
+ #projModal .pm-empty { color: var(--muted); font-size: 12.5px; margin: 0 0 12px; }
164
+ #projModal .pm-add { border-top: 1px solid var(--border); padding-top: 12px; }
165
+ #projModal .pm-add h3 { margin: 0 0 8px; font-size: 13px; }
166
+ #projModal .pm-crumb { font-size: 12px; color: var(--accent); word-break: break-all; margin: 0 0 6px; }
167
+ #projModal .pm-browser { border: 1px solid var(--border); border-radius: 7px; max-height: 190px; overflow-y: auto; margin-bottom: 8px; background: #0d1117; }
168
+ #projModal .pm-row { display: flex; align-items: center; gap: 8px; padding: 7px 10px; cursor: pointer; font-size: 13px; border-bottom: 1px solid rgba(48,54,61,.5); }
169
+ #projModal .pm-row:last-child { border-bottom: none; }
170
+ #projModal .pm-row:hover { background: #161b22; }
171
+ #projModal .pm-row.up { color: var(--muted); }
172
+ #projModal .pm-sel { font-size: 12px; color: var(--fg); margin: 0 0 8px; word-break: break-all; }
173
+ #projModal .pm-sel b { color: var(--green); }
174
+ #projModal .pm-fields { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
175
+ #projModal .pm-fields input { flex: 1; min-width: 150px; }
176
+ #projModal .pm-err { color: var(--red); font-size: 12px; min-height: 15px; margin: 6px 0 0; }
177
+
178
+ /* Secrets — per-project .env.local editor */
179
+ #secModal { position: fixed; inset: 0; z-index: 60; background: rgba(13,17,23,.96); display: none; align-items: center; justify-content: center; padding: 18px; }
180
+ #secModal.open { display: flex; }
181
+ #secModal .card { position: relative; background: var(--bar); border: 1px solid var(--border); border-radius: 12px; width: 100%; max-width: 480px; max-height: 90vh; overflow-y: auto; padding: 20px 22px; box-shadow: 0 12px 40px rgba(0,0,0,.5); }
182
+ #secModal h2 { margin: 0 0 2px; font-size: 17px; }
183
+ #secModal h2 .dim { color: var(--muted); font-weight: 400; font-size: 14px; }
184
+ #secModal .sub { margin: 0 0 12px; color: var(--muted); font-size: 12.5px; line-height: 1.5; }
185
+ #secModal .pm-close { position: absolute; top: 12px; right: 14px; background: transparent; border: none; color: var(--muted); font-size: 22px; line-height: 1; cursor: pointer; padding: 2px 6px; }
186
+ #secModal .pm-close:hover { color: var(--fg); }
187
+ #secModal .pm-empty { font-size: 12.5px; color: var(--muted); margin: 0 0 10px; line-height: 1.5; }
188
+ #secModal .sec-field { margin-bottom: 8px; }
189
+ #secModal .sec-field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
190
+ #secModal .sec-field select { width: 100%; }
191
+ #secModal .sec-target { font-size: 12px; color: var(--muted); margin: 0 0 10px; word-break: break-all; }
192
+ #secModal .sec-list { list-style: none; margin: 0 0 10px; padding: 0; }
193
+ #secModal .sec-list li { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border: 1px solid var(--border); border-radius: 7px; margin-bottom: 6px; }
194
+ #secModal .sec-k { font-size: 12.5px; font-weight: 600; word-break: break-all; }
195
+ #secModal .sec-v { font-size: 12px; color: var(--muted); flex: 1; font-family: Menlo, Monaco, monospace; word-break: break-all; }
196
+ #secModal .sec-eye, #secModal .sec-del { background: transparent; border: none; cursor: pointer; font-size: 13px; padding: 0 3px; }
197
+ #secModal .sec-del { color: var(--red); }
198
+ #secModal .sec-add { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
199
+ #secModal .sec-add input { flex: 1; min-width: 130px; }
200
+ #secModal .pm-err { color: var(--red); font-size: 12px; min-height: 15px; margin: 6px 0 0; }
201
+ #secModal .sec-note { font-size: 11px; color: var(--muted); margin: 8px 0 0; line-height: 1.5; }
149
202
  </style>
150
203
  </head>
151
204
  <body>
@@ -156,6 +209,8 @@
156
209
  <button id="startBtn">Start</button>
157
210
  <button id="connectBtn" title="Open on a phone or another device">📱 Connect a device</button>
158
211
  <select id="projectSel" title="Project" style="display:none"></select>
212
+ <button id="projBtn" title="Add or manage projects by browsing folders">📁 Projects</button>
213
+ <button id="secBtn" title="Save API keys / tokens into a project's .env.local">🔑 Secrets</button>
159
214
  <span class="spacer"></span>
160
215
  <span id="status" class="disconnected"><span class="led"></span><span id="statusText">disconnected</span></span>
161
216
  </div>
@@ -208,6 +263,52 @@
208
263
  </div>
209
264
  </div>
210
265
 
266
+ <div id="projModal">
267
+ <div class="card">
268
+ <button class="pm-close" id="pmClose" aria-label="Close">×</button>
269
+ <h2>Projects</h2>
270
+ <p class="sub">A project scopes an agent session to a folder. Add one by browsing — no typing the full path.</p>
271
+ <ul class="pm-list" id="pmList"></ul>
272
+ <p class="pm-empty" id="pmEmpty" style="display:none">No projects yet.</p>
273
+ <div class="pm-add">
274
+ <h3>Add a project</h3>
275
+ <p class="pm-crumb" id="pmCrumb">…</p>
276
+ <div class="pm-browser" id="pmBrowser"></div>
277
+ <p class="pm-sel">Selected folder: <b id="pmSel">…</b></p>
278
+ <div class="pm-fields">
279
+ <input id="pmName" type="text" placeholder="project name" autocomplete="off" autocapitalize="off" spellcheck="false" />
280
+ <button class="primary" id="pmAdd">Add project</button>
281
+ </div>
282
+ <p class="pm-err" id="pmErr"></p>
283
+ </div>
284
+ </div>
285
+ </div>
286
+
287
+ <div id="secModal">
288
+ <div class="card">
289
+ <button class="pm-close" id="secClose" aria-label="Close">×</button>
290
+ <h2>Secrets <span class="dim">(.env.local)</span></h2>
291
+ <p class="sub">Save API keys and tokens into a project's <code>.env.local</code> — the agent you launch there reads them. They go straight to a file, never through the chat or terminal.</p>
292
+ <p class="pm-empty" id="secNoProj" style="display:none">Add a project first with the <b>📁 Projects</b> button — secrets are saved into a project folder.</p>
293
+ <div id="secEditor">
294
+ <div class="sec-field">
295
+ <label for="secProj">Project folder</label>
296
+ <select id="secProj"></select>
297
+ </div>
298
+ <p class="sec-target">Writes to <code id="secTarget">…</code></p>
299
+ <ul class="sec-list" id="secList"></ul>
300
+ <p class="pm-empty" id="secEmpty" style="display:none">No keys yet.</p>
301
+ <div class="sec-add">
302
+ <input id="secKey" type="text" placeholder="KEY (e.g. OPENAI_API_KEY)" autocomplete="off" autocapitalize="characters" spellcheck="false" />
303
+ <input id="secVal" type="password" placeholder="value" autocomplete="off" spellcheck="false" />
304
+ <button class="primary" id="secAdd">Save</button>
305
+ </div>
306
+ <p class="pm-err" id="secErr"></p>
307
+ <p class="sec-note">Stored unencrypted in the project folder, like any <code>.env</code> — anyone who can read that folder can read these.</p>
308
+ </div>
309
+ </div>
310
+ </div>
311
+
211
312
  <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
212
313
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
213
314
  <script>
@@ -649,6 +750,252 @@
649
750
  onboard.addEventListener("click", (e) => { if (e.target === onboard) closeOnboard(); });
650
751
  document.addEventListener("keydown", (e) => { if (e.key === "Escape" && onboard.classList.contains("open")) closeOnboard(); });
651
752
 
753
+ // ---------- Projects: folder-browser picker (reuses GET /api/browse) ----------
754
+ const projBtn = $("projBtn"), projModal = $("projModal"), pmClose = $("pmClose");
755
+ let pmCurrent = null;
756
+ function pmBasename(p) { return p.replace(/[\/\\]+$/, "").split(/[\/\\]/).pop() || p; }
757
+ async function pmBrowse(p) {
758
+ const browser = $("pmBrowser"), err = $("pmErr");
759
+ err.textContent = "";
760
+ // Raw fetch (not api()) so the server's own message survives a 403 — e.g. a
761
+ // folder outside the configured allowedPaths returns "Access denied", which is
762
+ // far more useful than a generic "unauthorized".
763
+ let res, data;
764
+ try {
765
+ res = await fetch("/api/browse" + (p ? "?path=" + encodeURIComponent(p) : ""),
766
+ { credentials: "include", headers: token ? { Authorization: "Bearer " + token } : {} });
767
+ data = await res.json().catch(() => ({}));
768
+ } catch (e) { err.textContent = "Could not reach the server."; return; }
769
+ if (res.status === 401) { err.textContent = "Session expired — reload to log in again."; return; }
770
+ if (!res.ok || data.error) { err.textContent = data.error || ("Could not open that folder (HTTP " + res.status + ")."); return; }
771
+ pmCurrent = data.current;
772
+ $("pmCrumb").textContent = data.current;
773
+ $("pmSel").textContent = data.current;
774
+ // keep the name field in sync with the folder until the user types their own
775
+ if ($("pmName").dataset.auto !== "0") {
776
+ $("pmName").value = pmBasename(data.current);
777
+ $("pmName").dataset.auto = "1";
778
+ }
779
+ browser.innerHTML = "";
780
+ if (data.parent) {
781
+ const up = document.createElement("div");
782
+ up.className = "pm-row up"; up.textContent = "⬆ ..";
783
+ up.addEventListener("click", () => pmBrowse(data.parent));
784
+ browser.appendChild(up);
785
+ }
786
+ if (!data.folders.length) {
787
+ const none = document.createElement("div");
788
+ none.className = "pm-row"; none.style.cursor = "default"; none.textContent = "(no subfolders here)";
789
+ browser.appendChild(none);
790
+ }
791
+ for (const f of data.folders) {
792
+ const row = document.createElement("div");
793
+ row.className = "pm-row"; row.textContent = "📁 " + f.name;
794
+ row.addEventListener("click", () => pmBrowse(f.path));
795
+ browser.appendChild(row);
796
+ }
797
+ }
798
+ async function pmLoadList() {
799
+ const ul = $("pmList");
800
+ ul.innerHTML = "";
801
+ let list = [];
802
+ try { const d = await api("/api/projects"); list = d.projects || []; } catch (_) {}
803
+ $("pmEmpty").style.display = list.length ? "none" : "";
804
+ for (const p of list) {
805
+ const li = document.createElement("li");
806
+ const nm = document.createElement("span"); nm.className = "pm-nm"; nm.textContent = p.name;
807
+ const pa = document.createElement("span"); pa.className = "pm-pa"; pa.textContent = p.path;
808
+ const del = document.createElement("button"); del.className = "pm-del"; del.textContent = "✕"; del.title = "Remove";
809
+ del.addEventListener("click", () => pmDelete(p.name));
810
+ li.appendChild(nm); li.appendChild(pa); li.appendChild(del);
811
+ ul.appendChild(li);
812
+ }
813
+ }
814
+ async function pmAddProject() {
815
+ const err = $("pmErr"), name = $("pmName").value.trim();
816
+ if (!pmCurrent) { err.textContent = "Pick a folder first."; return; }
817
+ if (!name) { err.textContent = "Give the project a name."; return; }
818
+ err.textContent = "";
819
+ let res, data;
820
+ try {
821
+ res = await fetch("/api/projects", { method: "POST", credentials: "include",
822
+ headers: Object.assign({ "Content-Type": "application/json" }, token ? { Authorization: "Bearer " + token } : {}),
823
+ body: JSON.stringify({ name: name, path: pmCurrent }) });
824
+ data = await res.json().catch(() => ({}));
825
+ } catch (e) { err.textContent = "Network error."; return; }
826
+ if (!res.ok || data.error) { err.textContent = data.error || ("HTTP " + res.status); return; }
827
+ $("pmName").value = ""; $("pmName").dataset.auto = "1";
828
+ await pmLoadList();
829
+ try { await loadProjects(); } catch (_) {} // refresh the toolbar dropdown
830
+ }
831
+ async function pmDelete(name) {
832
+ try {
833
+ await fetch("/api/projects/" + encodeURIComponent(name), { method: "DELETE", credentials: "include",
834
+ headers: token ? { Authorization: "Bearer " + token } : {} });
835
+ } catch (_) {}
836
+ await pmLoadList();
837
+ try { await loadProjects(); } catch (_) {}
838
+ }
839
+ function openProjects() {
840
+ projModal.classList.add("open");
841
+ $("pmErr").textContent = "";
842
+ $("pmName").value = ""; $("pmName").dataset.auto = "1";
843
+ pmLoadList();
844
+ pmBrowse(null); // start at the server's home / first allowed base
845
+ }
846
+ function closeProjects() { projModal.classList.remove("open"); }
847
+ $("pmName").addEventListener("input", () => { $("pmName").dataset.auto = "0"; });
848
+ projBtn.addEventListener("click", openProjects);
849
+ pmClose.addEventListener("click", closeProjects);
850
+ projModal.addEventListener("click", (e) => { if (e.target === projModal) closeProjects(); });
851
+ $("pmAdd").addEventListener("click", pmAddProject);
852
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape" && projModal.classList.contains("open")) closeProjects(); });
853
+
854
+ // ---------- Secrets: write API keys into a project's .env.local ----------
855
+ const secBtn = $("secBtn"), secModal = $("secModal"), secClose = $("secClose");
856
+ let secRaw = ""; // current raw .env.local text for the selected project
857
+ let secPath = null; // absolute path of that .env.local
858
+ let secLoadFailed = false; // true when the existing file couldn't be read → block writes so we never clobber it
859
+ const VALID_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
860
+
861
+ function secStripQuotes(v) {
862
+ v = v.trim();
863
+ if (v.length >= 2 && ((v[0] === '"' && v.endsWith('"')) || (v[0] === "'" && v.endsWith("'")))) return v.slice(1, -1);
864
+ return v;
865
+ }
866
+ function secParse(raw) {
867
+ const out = [];
868
+ for (const line of raw.split(/\r?\n/)) {
869
+ const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
870
+ if (m) out.push({ key: m[1], value: secStripQuotes(m[2]) });
871
+ }
872
+ return out;
873
+ }
874
+ function secFormatValue(v) {
875
+ // bare when safe, else double-quote with escaping — so a value can never inject a new line/var
876
+ return /^[A-Za-z0-9_./:@+-]*$/.test(v) ? v : '"' + v.replace(/(["\\])/g, "\\$1") + '"';
877
+ }
878
+ function secSetLine(raw, key, value) {
879
+ const lines = raw.split(/\r?\n/);
880
+ const re = new RegExp("^\\s*" + key + "\\s*="); // key is VALID_KEY-checked → no regex injection
881
+ const newLine = key + "=" + secFormatValue(value);
882
+ let replaced = false;
883
+ for (let i = 0; i < lines.length; i++) { if (re.test(lines[i])) { lines[i] = newLine; replaced = true; break; } }
884
+ if (!replaced) {
885
+ if (lines.length && lines[lines.length - 1].trim() === "") lines[lines.length - 1] = newLine;
886
+ else lines.push(newLine);
887
+ }
888
+ return lines.join("\n").replace(/\n*$/, "\n");
889
+ }
890
+ function secDelLine(raw, key) {
891
+ const re = new RegExp("^\\s*" + key + "\\s*=");
892
+ return raw.split(/\r?\n/).filter((l) => !re.test(l)).join("\n").replace(/\n*$/, "\n");
893
+ }
894
+
895
+ async function secLoadProjects() {
896
+ const sel = $("secProj");
897
+ sel.innerHTML = "";
898
+ let list = [];
899
+ try { const d = await api("/api/projects"); list = d.projects || []; } catch (_) {}
900
+ if (!list.length) { $("secNoProj").style.display = ""; $("secEditor").style.display = "none"; return; }
901
+ $("secNoProj").style.display = "none"; $("secEditor").style.display = "";
902
+ for (const p of list) {
903
+ const o = document.createElement("option");
904
+ o.value = p.path; o.textContent = p.name + " (" + p.path + ")";
905
+ sel.appendChild(o);
906
+ }
907
+ if (projectSel && projectSel.value && [...sel.options].some((o) => o.value === projectSel.value)) sel.value = projectSel.value;
908
+ await secLoadEnv();
909
+ }
910
+ function secSetEnabled(on) {
911
+ $("secKey").disabled = !on; $("secVal").disabled = !on; $("secAdd").disabled = !on;
912
+ }
913
+ async function secLoadEnv() {
914
+ secPath = $("secProj").value.replace(/[\/\\]+$/, "") + "/.env.local";
915
+ $("secTarget").textContent = secPath;
916
+ $("secErr").textContent = "";
917
+ secRaw = "";
918
+ secLoadFailed = false;
919
+ // Distinguish "no file yet" (404 → a fresh file is fine) from a real read failure.
920
+ // On any other error we do NOT know the current contents, so we block editing —
921
+ // otherwise the next Save would write only the new key and wipe the rest.
922
+ let res;
923
+ try {
924
+ res = await fetch("/api/explorer/read?path=" + encodeURIComponent(secPath),
925
+ { credentials: "include", headers: token ? { Authorization: "Bearer " + token } : {} });
926
+ } catch (e) {
927
+ secLoadFailed = true;
928
+ $("secErr").textContent = "Couldn't reach the server — editing disabled so existing keys stay safe.";
929
+ secSetEnabled(false); secRender(); return;
930
+ }
931
+ if (res.status === 404) {
932
+ secRaw = ""; // no .env.local yet
933
+ } else if (res.ok) {
934
+ const data = await res.json().catch(() => ({}));
935
+ secRaw = typeof data.content === "string" ? data.content : "";
936
+ } else {
937
+ secLoadFailed = true;
938
+ $("secErr").textContent = res.status === 401
939
+ ? "Session expired — reload to log in again."
940
+ : "Couldn't read this folder's .env.local — editing disabled so existing keys aren't overwritten.";
941
+ secSetEnabled(false); secRender(); return;
942
+ }
943
+ secSetEnabled(true);
944
+ secRender();
945
+ }
946
+ function secRender() {
947
+ const ul = $("secList");
948
+ ul.innerHTML = "";
949
+ const entries = secParse(secRaw);
950
+ $("secEmpty").style.display = entries.length ? "none" : "";
951
+ for (const e of entries) {
952
+ const li = document.createElement("li");
953
+ const k = document.createElement("span"); k.className = "sec-k"; k.textContent = e.key;
954
+ const v = document.createElement("span"); v.className = "sec-v"; v.textContent = "••••••••"; v.dataset.real = e.value; v.dataset.shown = "0";
955
+ const eye = document.createElement("button"); eye.className = "sec-eye"; eye.textContent = "👁"; eye.title = "Show / hide";
956
+ eye.addEventListener("click", () => { const sh = v.dataset.shown === "1"; v.textContent = sh ? "••••••••" : (v.dataset.real || "(empty)"); v.dataset.shown = sh ? "0" : "1"; });
957
+ const del = document.createElement("button"); del.className = "sec-del"; del.textContent = "✕"; del.title = "Remove";
958
+ del.addEventListener("click", () => secRemove(e.key));
959
+ li.appendChild(k); li.appendChild(v); li.appendChild(eye); li.appendChild(del);
960
+ ul.appendChild(li);
961
+ }
962
+ }
963
+ async function secWrite() {
964
+ const res = await fetch("/api/explorer/write", { method: "POST", credentials: "include",
965
+ headers: Object.assign({ "Content-Type": "application/json" }, token ? { Authorization: "Bearer " + token } : {}),
966
+ body: JSON.stringify({ path: secPath, content: secRaw }) });
967
+ if (res.status === 401) { closeSecrets(); showGate("Session expired — please log in again."); throw new Error("session expired"); }
968
+ const data = await res.json().catch(() => ({}));
969
+ if (!res.ok || data.error) throw new Error(data.error || ("HTTP " + res.status));
970
+ }
971
+ async function secAdd() {
972
+ const err = $("secErr"), key = $("secKey").value.trim();
973
+ if (secLoadFailed) { err.textContent = "Can't save — the current .env.local couldn't be read. Re-open Secrets and try again."; return; }
974
+ if (!VALID_KEY.test(key)) { err.textContent = "Key must be letters/digits/underscores, e.g. OPENAI_API_KEY."; return; }
975
+ const val = $("secVal").value.replace(/[\r\n]+/g, ""); // a secret is single-line
976
+ err.textContent = "";
977
+ const prev = secRaw;
978
+ secRaw = secSetLine(secRaw, key, val);
979
+ try { await secWrite(); } catch (e) { secRaw = prev; err.textContent = "Could not save: " + e.message; return; }
980
+ $("secKey").value = ""; $("secVal").value = "";
981
+ secRender();
982
+ }
983
+ async function secRemove(key) {
984
+ const prev = secRaw;
985
+ secRaw = secDelLine(secRaw, key);
986
+ try { await secWrite(); } catch (e) { secRaw = prev; $("secErr").textContent = "Could not save: " + e.message; return; }
987
+ secRender();
988
+ }
989
+ function openSecrets() { secModal.classList.add("open"); $("secErr").textContent = ""; $("secKey").value = ""; $("secVal").value = ""; secLoadProjects(); }
990
+ function closeSecrets() { secModal.classList.remove("open"); }
991
+ secBtn.addEventListener("click", openSecrets);
992
+ secClose.addEventListener("click", closeSecrets);
993
+ secModal.addEventListener("click", (e) => { if (e.target === secModal) closeSecrets(); });
994
+ $("secProj").addEventListener("change", secLoadEnv);
995
+ $("secAdd").addEventListener("click", secAdd);
996
+ $("secVal").addEventListener("keydown", (e) => { if (e.key === "Enter") secAdd(); });
997
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape" && secModal.classList.contains("open")) closeSecrets(); });
998
+
652
999
  // ---------- Bootstrap ----------
653
1000
  (async function boot() {
654
1001
  const params = new URLSearchParams(location.search);
@@ -82,6 +82,14 @@ dropdown in the top bar (e.g. **Claude Code**) and click **Start** — it runs i
82
82
  the session, streamed live to your browser. Detaching the browser keeps it alive;
83
83
  reconnect and you're back with full scrollback.
84
84
 
85
+ To run the agent inside a specific **project folder**, click **📁 Projects** in the
86
+ top bar and *browse* to the folder — no typing the full path. It's saved and shows
87
+ up in the toolbar's project dropdown; pick one before launching.
88
+
89
+ If the agent needs API keys (e.g. `OPENAI_API_KEY`), click **🔑 Secrets**, pick the
90
+ project, and add `KEY` + value — it's written to that project's `.env.local` for you,
91
+ so you never paste keys into the chat or hand-edit dotfiles.
92
+
85
93
  ## If something goes wrong
86
94
 
87
95
  - **`node -v` says command not found** — install Node.js (above), then reopen the
@@ -46,6 +46,10 @@ as you would in your own terminal — streamed live to the browser. Type prompts
46
46
  send keys, and watch output in real time. Detaching the browser keeps the session
47
47
  alive; reconnecting reattaches with full scrollback.
48
48
 
49
+ To scope a session to a folder, click **📁 Projects** in the top bar and *browse* to
50
+ it — no typing the path. Saved projects appear in the toolbar dropdown; pick one
51
+ before you launch the agent.
52
+
49
53
  ![Claude Code running inside a bridge session](screenshots/03-agent-running.png)
50
54
 
51
55
  ## 4. Browse and edit files
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anyagent-bridge",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Control your local terminal and any CLI AI coding agent from a browser, anywhere.",
5
5
  "license": "MIT",
6
6
  "author": "elon-choo",