@ninemind/agentgem 0.1.1 → 0.3.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.
@@ -2,6 +2,11 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" />
5
+ <!-- Same-origin by default. Inline script/style are required by this single-file UI;
6
+ no eval is used, so 'unsafe-eval' is omitted (this clears Electron's insecure-CSP
7
+ warning). Google Fonts (stylesheet + font files) and the data: SVG favicon are
8
+ allowlisted; API calls are same-origin. -->
9
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; base-uri 'self'; object-src 'none'" />
5
10
  <title>agentgem — Gem Builder</title>
6
11
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 40' fill='none'><path d='M20 2 L36 14 L20 38 L4 14 Z' fill='%239a3324'/><path d='M20 2 L36 14 L20 16 Z' fill='%23bb4a38'/><path d='M20 2 L4 14 L20 16 Z' fill='%237d2a1e'/><path d='M4 14 L20 16 L20 38 Z' fill='%238b2e21'/><path d='M36 14 L20 16 L20 38 Z' fill='%23a8392a'/><path d='M4 14 L36 14 L20 16 Z' fill='%23c8543f'/></svg>" />
7
12
  <link rel="preconnect" href="https://fonts.googleapis.com">
@@ -152,6 +157,13 @@
152
157
  .prow .pm{margin-left:auto;color:var(--muted);font-size:12px;white-space:nowrap;font-family:var(--mono)}
153
158
  .redtag{color:var(--accent);font-size:11px}
154
159
  pre.json{background:var(--card);border:1px solid var(--line);border-radius:var(--r);padding:12px;font:12px/1.6 var(--mono);white-space:pre-wrap;word-break:break-word;margin:0}
160
+ /* ── channel picker ── */
161
+ #channelPicker{border:1px solid var(--line);border-radius:var(--r);padding:10px 13px;margin-bottom:12px;background:var(--card)}
162
+ #channelPicker legend{font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:.14em;color:var(--muted);padding:0 4px}
163
+ #channelPicker legend .d{color:var(--muted);font-size:10px;font-weight:400;text-transform:none;letter-spacing:0}
164
+ #channelPicker .ch-labels{display:flex;flex-wrap:wrap;gap:6px 14px;margin-top:6px}
165
+ #channelPicker label{display:flex;align-items:center;gap:5px;font-size:13px;cursor:pointer}
166
+ #channelPicker input[type=checkbox]{accent-color:var(--accent);margin:0}
155
167
  /* ── filters ── */
156
168
  #filterPanel{border:1px solid var(--line);border-radius:var(--r);padding:11px 13px;margin-bottom:12px;background:var(--accent-soft)}
157
169
  #filterPanel[hidden]{display:none}
@@ -202,7 +214,7 @@
202
214
  </nav>
203
215
  <main>
204
216
  <section class="pane left">
205
- <div class="bar" id="nameBar" style="display:none"><input id="name" type="text" placeholder="gem name" value="gem" style="flex:1" /></div>
217
+ <div class="bar" id="nameBar" style="display:none"><label for="name" class="d" style="white-space:nowrap">Gem name</label><input id="name" type="text" placeholder="gem name" value="gem" style="flex:1" /></div>
206
218
  <div class="bar" id="wsBar" style="display:none">
207
219
  <select id="wsSelect" title="open a saved workspace" style="flex:1"><option value="">— no workspace —</option></select>
208
220
  <button id="wsNew" class="ghost" title="save the current selection as a new workspace">New workspace…</button>
@@ -237,8 +249,19 @@
237
249
  <div class="bar" id="typeBar"></div>
238
250
  </div>
239
251
  <div class="bar" id="selbar"><strong style="flex:1;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)">Selection</strong><button id="all" class="ghost">Select all shown</button><button id="none" class="ghost">Clear</button></div>
240
- <div class="bar"><button id="importBtn" class="ghost">⬇ Import from machine…</button><span class="d" id="importStatus" style="margin-left:8px"></span></div>
252
+ <div class="bar"><button id="importBtn" class="ghost">⬇ Import from machine…</button><button id="getBtn" class="ghost">🔗 Get gems…</button><span class="d" id="importStatus" style="margin-left:8px"></span></div>
241
253
  <div id="inventory">Loading…</div>
254
+ <fieldset id="channelPicker" class="pgroup">
255
+ <legend>Channels <span class="d">(how this Gem is reached)</span></legend>
256
+ <div class="ch-labels">
257
+ <label><input type="checkbox" class="ch" value="slack"> Slack</label>
258
+ <label><input type="checkbox" class="ch" value="telegram"> Telegram</label>
259
+ <label><input type="checkbox" class="ch" value="discord"> Discord</label>
260
+ <label><input type="checkbox" class="ch" value="teams"> Teams</label>
261
+ <label><input type="checkbox" class="ch" value="twilio"> Twilio</label>
262
+ <label><input type="checkbox" class="ch" value="github"> GitHub</label>
263
+ </div>
264
+ </fieldset>
242
265
  <div id="testdrive" class="testdrive" hidden>
243
266
  <div class="hd">▶ Test-drive this agent <span class="pill" id="tdPill">Claude Code</span></div>
244
267
  <div class="cmd"><span class="dollar">$</span> <code id="tdCmd"></code> <button id="tdCopy" class="ghost">Copy</button></div>
@@ -246,7 +269,7 @@
246
269
  </div>
247
270
  </section>
248
271
  <section class="pane right">
249
- <div class="bar"><strong style="flex:1">Gem (live)</strong><select id="target" title="materialize target" style="margin-left:auto"><option value="claude">Claude</option><option value="codex">Codex</option><option value="agents">Agents</option><option value="hermes">Hermes</option><option value="eve">Eve</option><option value="flue">Flue</option><option value="openai-sandbox">OpenAI Sandbox</option><option value="agentcore">AgentCore</option></select><span class="seg" id="preview-modes"><button type="button" data-pmode="summary">Summary</button><button type="button" data-pmode="json">JSON</button><button type="button" data-pmode="materialize">Materialize</button><button type="button" data-pmode="managed">Managed Agents</button></span><span class="exportwrap"><button id="exportBtn">Export ▾</button><div id="exportMenu" class="exportmenu" hidden><button id="dltar" class="menuitem" title="download the gem archive as a .tar.gz">⬇ Download .tar.gz</button><button id="save" class="menuitem" title="write the gem archive (manifest + lock + files) to a folder">💾 Save to folder…</button><button id="dl" class="menuitem">⬇ Download JSON</button><button id="copy" class="menuitem">⧉ Copy JSON</button></div></span><span class="d" id="archiveStatus" style="margin-left:6px"></span></div>
272
+ <div class="bar"><strong style="flex:1">Gem (live)</strong><select id="target" title="materialize target" style="margin-left:auto"><option value="claude">Claude</option><option value="codex">Codex</option><option value="agents">Agents</option><option value="hermes">Hermes</option><option value="eve">Eve</option><option value="flue">Flue</option><option value="openai-sandbox">OpenAI Sandbox</option><option value="agentcore">AgentCore</option><option value="a2a">A2A</option></select><span class="seg" id="preview-modes"><button type="button" data-pmode="summary">Summary</button><button type="button" data-pmode="json">JSON</button><button type="button" data-pmode="materialize">Materialize</button><button type="button" data-pmode="run">Run</button><button type="button" data-pmode="managed">Managed Agents</button></span><span class="exportwrap"><button id="exportBtn">Export ▾</button><div id="exportMenu" class="exportmenu" hidden><button id="dlgem" class="menuitem" title="download one portable, self-verifying .gem file to share">🔗 Download .gem (share)</button><button id="dltar" class="menuitem" title="download the gem archive as a .tar.gz">⬇ Download .tar.gz</button><button id="save" class="menuitem" title="write the gem archive (manifest + lock + files) to a folder">💾 Save to folder…</button><button id="dl" class="menuitem">⬇ Download JSON</button><button id="copy" class="menuitem">⧉ Copy JSON</button></div></span><span class="d" id="archiveStatus" style="margin-left:6px"></span></div>
250
273
  <div id="preview"></div>
251
274
  <p class="note">MCP server &amp; hook config secrets are shown as <code>&lt;redacted&gt;</code> and never exported. Skill, CLAUDE.md, rules bodies &amp; hook commands are bundled as written — don't keep secrets in them.</p>
252
275
  <div id="checksPanel" class="group" style="margin-top:14px">
@@ -261,6 +284,23 @@
261
284
  <pre class="modal-body" id="modal-body"></pre>
262
285
  </div>
263
286
  </div>
287
+ <div id="getModal" class="modal-bg" hidden>
288
+ <div class="modal">
289
+ <div class="modal-h"><strong class="t">Get gems</strong><span class="src">install a shared .gem, or search the registry</span><button id="getClose" class="ghost" style="margin-left:auto">✕ Close</button></div>
290
+ <div style="padding:14px;display:flex;flex-direction:column;gap:14px">
291
+ <div>
292
+ <div class="bar"><strong style="flex:1;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)">Install a .gem</strong></div>
293
+ <div class="bar"><input id="installSrc" type="text" placeholder="https://…/foo.gem or /path/to/foo.gem" style="flex:1" /><select id="installTarget" title="materialize target"></select><button id="installBtn">Install</button></div>
294
+ <span class="d" id="installStatus"></span>
295
+ </div>
296
+ <div>
297
+ <div class="bar"><strong style="flex:1;font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)">Search the registry</strong></div>
298
+ <div class="bar"><input id="regSearch" type="text" placeholder="search names, tags, descriptions…" style="flex:1" /><button id="regSearchBtn" class="ghost">Search</button></div>
299
+ <div id="regResults"></div>
300
+ </div>
301
+ </div>
302
+ </div>
303
+ </div>
264
304
  <div id="importModal" class="modal-bg" hidden>
265
305
  <div class="modal">
266
306
  <div class="modal-h"><strong class="t">Import from machine</strong><span class="src">~/.claude · ~/.agents · ~/.codex · ~/.hermes</span><button id="importClose" class="ghost" style="margin-left:auto">✕ Close</button></div>
@@ -275,6 +315,8 @@
275
315
  <div class="modal">
276
316
  <div class="modal-h"><strong class="t">Open a testbed</strong><button id="recentClose" class="ghost" style="margin-left:auto">✕ Close</button></div>
277
317
  <div class="modal-body" style="padding:14px">
318
+ <div id="anView" hidden></div>
319
+ <div id="anPicker">
278
320
  <div id="tbCwd" hidden style="padding:12px;border:1px solid var(--line);border-radius:8px;margin-bottom:14px">
279
321
  <div style="margin-bottom:6px">This folder looks like a <span id="tbCwdFlavor"></span> project</div>
280
322
  <div class="d" id="tbCwdPath" style="margin-bottom:8px;word-break:break-all"></div>
@@ -289,8 +331,9 @@
289
331
  <div id="recentList">Loading…</div>
290
332
  <div class="src" style="margin:14px 0 6px">Discovered <span class="d">(projects from your Claude / Codex history)</span></div>
291
333
  <div id="discoveredList">Loading…</div>
334
+ </div>
292
335
  </div>
293
- <div class="modal-h" style="border-top:1px solid var(--line);border-bottom:0">
336
+ <div class="modal-h" id="anFooter" style="border-top:1px solid var(--line);border-bottom:0">
294
337
  <span class="d">Not this folder? Pick a recent one above, or browse.</span>
295
338
  <button id="recentBrowse" style="margin-left:auto">Browse folder…</button>
296
339
  </div>
@@ -328,6 +371,7 @@ let candidate = null; // { path, flavor } currently shown in the top block
328
371
 
329
372
  async function openOrCreateTestbed(){
330
373
  document.getElementById("recentModal").hidden = false;
374
+ showPicker(); // always open on the project list, not a stale recommendation view
331
375
  try {
332
376
  const s = await (await fetch("/api/testbed/suggestion")).json();
333
377
  if (s.looksLikeProject) renderCandidate(s.cwd, s.flavor, s.name);
@@ -381,11 +425,15 @@ async function renderRecents(){
381
425
  const fl = esc((FLAVORS[p.flavor]||FLAVORS.claude).label);
382
426
  const when = p.lastUsed ? esc(p.lastUsed.slice(0,10)) : "";
383
427
  const stale = p.exists ? "" : ` <span class="d" title="path no longer exists">· missing</span>`;
384
- return `<label class="row recent" data-i="${i}"${p.exists?"":' style="opacity:.5"'}><span><b>${short}</b> <span class="src">${fl}</span> <span class="d">${esc(p.path)}</span>${stale}</span><span class="d" style="margin-left:auto">${when}</span></label>`;
428
+ const an = p.exists ? `<button type="button" class="ghost anBtn" data-i="${i}" title="recommend a Gem from this project's session history">Analyze</button>` : "";
429
+ return `<label class="row recent" data-i="${i}"${p.exists?"":' style="opacity:.5"'}><span><b>${short}</b> <span class="src">${fl}</span> <span class="d">${esc(p.path)}</span>${stale}</span><span class="d" style="margin-left:auto">${when}</span>${an}</label>`;
385
430
  }).join("");
386
431
  list.querySelectorAll("label.recent").forEach(row=>{
387
432
  row.onclick = ()=>{ const p = recents[+row.dataset.i]; if(!p.exists) return; document.getElementById("recentModal").hidden = true; useTestbed(p.path, p.flavor, p.name); };
388
433
  });
434
+ list.querySelectorAll("button.anBtn").forEach(btn=>{
435
+ btn.onclick = (e)=>{ e.preventDefault(); e.stopPropagation(); const p = recents[+btn.dataset.i]; analyzeWorkflow(p.path, p.flavor, p.name); };
436
+ });
389
437
  }
390
438
 
391
439
  // Cross-repo projects from Claude/Codex session history, minus anything already in Recent.
@@ -404,11 +452,15 @@ async function renderDiscovered(){
404
452
  const fl = esc((FLAVORS[p.flavor]||FLAVORS.claude).label);
405
453
  const when = p.lastUsed ? esc(p.lastUsed.slice(0,10)) : "";
406
454
  const stale = p.exists ? "" : ` <span class="d" title="path no longer exists">· missing</span>`;
407
- return `<label class="row disc" data-i="${i}"${p.exists?"":' style="opacity:.5"'}><span><b>${short}</b> <span class="src">${fl}</span> <span class="d">${esc(p.path)}</span>${stale}</span><span class="d" style="margin-left:auto">${when}</span></label>`;
455
+ const an = p.exists ? `<button type="button" class="ghost anBtn" data-i="${i}" title="recommend a Gem from this project's session history">Analyze</button>` : "";
456
+ return `<label class="row disc" data-i="${i}"${p.exists?"":' style="opacity:.5"'}><span><b>${short}</b> <span class="src">${fl}</span> <span class="d">${esc(p.path)}</span>${stale}</span><span class="d" style="margin-left:auto">${when}</span>${an}</label>`;
408
457
  }).join("");
409
458
  list.querySelectorAll("label.disc").forEach(row=>{
410
459
  row.onclick = ()=>{ const p = projects[+row.dataset.i]; if(!p.exists) return; document.getElementById("recentModal").hidden = true; useTestbed(p.path, p.flavor, p.path.replace(/^.*\//, "")); };
411
460
  });
461
+ list.querySelectorAll("button.anBtn").forEach(btn=>{
462
+ btn.onclick = (e)=>{ e.preventDefault(); e.stopPropagation(); const p = projects[+btn.dataset.i]; analyzeWorkflow(p.path, p.flavor, p.path.replace(/^.*\//, "")); };
463
+ });
412
464
  }
413
465
 
414
466
  // Adopt a known project+flavor: scaffold is idempotent (writeIfAbsent) and records a recent.
@@ -419,12 +471,237 @@ async function useTestbed(path, flavor, name){
419
471
  setTestbed(path);
420
472
  nameEdited = false; // picking a project is explicit — default the gem name to it
421
473
  document.getElementById("name").value = name;
422
- load();
474
+ await load(); // awaited so callers (e.g. Analyze) can apply once the inventory is rendered
475
+ }
476
+
477
+ // Swap the picker for a focused recommendation view (same modal, no stacking).
478
+ function anModalTitle(t){ const el = document.querySelector("#recentModal .modal-h .t"); if (el) el.textContent = t; }
479
+ // NB: #anFooter has class `modal-h` whose CSS `display:flex` overrides the
480
+ // `hidden` attribute, so toggle its inline display directly.
481
+ function showAnView(){ document.getElementById("anPicker").hidden = true; document.getElementById("anFooter").style.display = "none"; document.getElementById("anView").hidden = false; anModalTitle("Workflow recommendation"); }
482
+ function showPicker(){ document.getElementById("anView").hidden = true; document.getElementById("anPicker").hidden = false; document.getElementById("anFooter").style.display = ""; anModalTitle("Open a testbed"); }
483
+
484
+ // Recommend a Gem for `path` from its session history — IN PLACE, without adopting
485
+ // it as the active testbed. Renders a focused view inside the same modal. One at a
486
+ // time: the focused view replaces the list, so analyses can't overlap from the UI.
487
+ function analyzeWorkflow(path, flavor, name, fresh){
488
+ const view = document.getElementById("anView");
489
+ let es = null, acc = "", finished = false;
490
+ view.innerHTML =
491
+ `<button type="button" class="ghost anBack">← back to projects</button>`
492
+ + `<div style="margin-top:10px"><b>${esc(name)}</b></div>`
493
+ + `<div class="d" id="anPhase" style="margin-top:8px">Starting…</div>`
494
+ + `<pre id="anStream" style="margin-top:8px;max-height:140px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-size:11px;color:var(--muted);background:rgba(127,127,127,.06);padding:8px;border-radius:6px;display:none"></pre>`;
495
+ view.querySelector(".anBack").onclick = (e) => { e.preventDefault(); finished = true; if (es) es.close(); showPicker(); };
496
+ showAnView();
497
+ const phaseEl = view.querySelector("#anPhase");
498
+ const streamEl = view.querySelector("#anStream");
499
+
500
+ es = new EventSource(`/api/workflow/analyze/stream?root=${encodeURIComponent(path)}${fresh ? "&fresh=1" : ""}`);
501
+ es.addEventListener("phase", (ev) => {
502
+ const d = JSON.parse(ev.data);
503
+ if (d.phase === "scanning") phaseEl.textContent = "Scanning session history…";
504
+ else if (d.phase === "scanned") phaseEl.textContent = `Scanned ${d.transcripts} transcript(s) · ${d.sessions} session(s). Asking Claude…`;
505
+ else if (d.phase === "thinking") phaseEl.textContent = "Claude is clustering a Gem from your usage…";
506
+ else if (d.phase === "validating") phaseEl.textContent = "Validating against your inventory…";
507
+ });
508
+ es.addEventListener("delta", (ev) => {
509
+ acc += JSON.parse(ev.data).text;
510
+ streamEl.style.display = "block";
511
+ streamEl.textContent = acc.slice(-1200); // tail of the agent's live output
512
+ streamEl.scrollTop = streamEl.scrollHeight;
513
+ });
514
+ es.addEventListener("done", (ev) => { finished = true; es.close(); renderAnView(view, path, flavor, name, JSON.parse(ev.data)); });
515
+ es.addEventListener("failed", (ev) => { finished = true; es.close(); renderAnError(view, esc(JSON.parse(ev.data).message || "unknown error")); });
516
+ es.onerror = () => { if (finished) return; finished = true; es.close(); renderAnError(view, "connection lost"); };
517
+ }
518
+
519
+ function renderAnError(view, msg){
520
+ view.innerHTML = `<button type="button" class="ghost anBack">← back to projects</button><div class="d" style="margin-top:10px">Analysis failed: ${msg}</div>`;
521
+ view.querySelector(".anBack").onclick = (e) => { e.preventDefault(); showPicker(); };
522
+ }
523
+
524
+ // Read-only view of the candidate Gems. Each card adopts the project AND applies
525
+ // just that candidate's selection.
526
+ let anCandidates = []; // stash so a card's button can grab its candidate
527
+ let anDistilled = []; // stash distilled drafts for the accept buttons
528
+ let acceptedDrafts = []; // distilled drafts the user folded into the build
529
+ function renderAnView(view, path, flavor, name, res){
530
+ anCandidates = res.candidates || [];
531
+ anDistilled = res.distilled || [];
532
+ const degraded = res.degraded ? ` <span class="d">(usage frequency — agent unavailable)</span>` : "";
533
+ const gaps = (res.gaps || []).length ? `<div class="d" style="margin:8px 0">Used but not in inventory: ${esc(res.gaps.join(", "))}</div>` : "";
534
+ const li = (n, t, reason, root) => {
535
+ const scope = root === null ? ` <span class="src" title="global / plugin artifact">global</span>` : "";
536
+ return `<li style="margin-bottom:4px"><b>${esc(n)}</b> <span class="d">${esc(t)}</span>${scope}${reason ? ` — ${esc(reason)}` : ""}</li>`;
537
+ };
538
+
539
+ const cards = anCandidates.map((c, i) => {
540
+ const items = c.include.map(it => li(it.name, it.type, it.reason, it.root)).join("");
541
+ const instr = c.includeInstructions ? li("CLAUDE.md", "instructions", "loaded every session", c.root) : "";
542
+ return `<div style="border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin-bottom:10px">`
543
+ + `<div style="font-size:14px"><b>${esc(c.name)}</b> <span class="d">· ${c.confidence}</span></div>`
544
+ + `<div class="d" style="margin:3px 0 8px">${esc(c.description)}</div>`
545
+ + (items || instr ? `<ul style="margin:0 0 8px 18px;padding:0">${items}${instr}</ul>` : `<div class="d">No project-scoped artifacts.</div>`)
546
+ + `<button type="button" class="anApply" data-i="${i}" style="margin-top:4px">Switch &amp; apply this Gem ▸</button>`
547
+ + `</div>`;
548
+ }).join("");
549
+
550
+ // Distilled draft skills: recurring procedures the analyzer captured from the
551
+ // builtin work (Bash/Edit/test/commit) that aren't yet a skill. Accepting writes
552
+ // a reviewable SKILL.md draft — it is NOT installed.
553
+ const drafts = anDistilled.map((d, i) => {
554
+ const trig = (d.triggers || []).map(t => `<span class="src">${esc(t)}</span>`).join(" ");
555
+ const mut = d.mutating ? ` <span class="src" title="this workflow writes/executes">mutating</span>` : "";
556
+ return `<div style="border:1px dashed var(--line);border-radius:8px;padding:10px 12px;margin-bottom:10px">`
557
+ + `<div style="font-size:14px"><b>${esc(d.name)}</b> <span class="d">· distilled · ${d.confidence}</span>${mut}</div>`
558
+ + `<div class="d" style="margin:3px 0 6px">${esc(d.description)}</div>`
559
+ + (trig ? `<div style="margin-bottom:6px">${trig}</div>` : "")
560
+ + `<div class="d" style="margin-bottom:6px">tools: ${esc((d.tools || []).join(", ") || "—")} · seen in ${d.evidence?.sessions ?? "?"} session(s)</div>`
561
+ + `<details style="margin-bottom:8px"><summary class="d" style="cursor:pointer">view workflow body</summary>`
562
+ + `<pre style="white-space:pre-wrap;font-size:12px;background:var(--bg2,#0001);padding:8px;border-radius:6px;overflow:auto">${esc(d.body || "")}</pre></details>`
563
+ + `<button type="button" class="anFold" data-i="${i}" style="margin-top:2px">Add to build ▸</button>`
564
+ + ` <button type="button" class="ghost anAccept" data-i="${i}" style="margin-top:2px">write SKILL.md only</button>`
565
+ + ` <span class="anAcceptMsg d" data-i="${i}"></span>`
566
+ + `</div>`;
567
+ }).join("");
568
+ const draftsSection = anDistilled.length
569
+ ? `<div style="margin-top:14px;font-size:15px"><b>${anDistilled.length} distilled skill draft(s)</b> <span class="d">· captured from repeated workflows</span></div>`
570
+ + `<div class="d" style="margin:2px 0 10px">Reviewed drafts are written to <code>.agentgem/distilled/</code> — edit and promote them yourself; they are not installed.</div>`
571
+ + drafts
572
+ : "";
573
+
574
+ const heading = anCandidates.length > 1 ? `${anCandidates.length} candidate Gems` : (anCandidates.length === 1 ? "Recommended Gem" : "No recurring flows found");
575
+ const cached = res.cached ? ` <span class="src" title="cached — re-analyze to refresh">cached</span>` : "";
576
+ view.innerHTML =
577
+ `<div style="display:flex;align-items:center;gap:8px"><button type="button" class="ghost anBack">← back to projects</button>`
578
+ + `<button type="button" class="ghost anFresh" style="margin-left:auto" title="re-run the analysis ignoring the cache">↻ Re-analyze</button></div>`
579
+ + `<div style="margin-top:10px;font-size:15px"><b>${esc(heading)}</b>${degraded}${cached} <span class="d">· ${res.signalSummary.sessionsScanned} session(s)</span></div>`
580
+ + `<div class="d" style="margin:4px 0 10px;word-break:break-all">${esc(path)}</div>`
581
+ + (cards || `<div class="d">Nothing to recommend from this project's usage.</div>`)
582
+ + gaps
583
+ + draftsSection;
584
+ view.querySelector(".anBack").onclick = (e) => { e.preventDefault(); showPicker(); };
585
+ view.querySelector(".anFresh").onclick = (e) => { e.preventDefault(); analyzeWorkflow(path, flavor, name, true); };
586
+ view.querySelectorAll(".anApply").forEach(btn => {
587
+ btn.onclick = (e) => { e.preventDefault(); switchAndApply(path, flavor, anCandidates[+btn.dataset.i]); };
588
+ });
589
+ view.querySelectorAll(".anAccept").forEach(btn => {
590
+ btn.onclick = (e) => { e.preventDefault(); acceptDraft(view, +btn.dataset.i, btn); };
591
+ });
592
+ view.querySelectorAll(".anFold").forEach(btn => {
593
+ btn.onclick = (e) => { e.preventDefault(); addDraftToBuild(+btn.dataset.i); };
594
+ });
595
+ }
596
+
597
+ // Fold a distilled draft into the active build: adopt the analyzed project as the
598
+ // testbed, inject the draft as a project skill (so it renders + selects like any
599
+ // other), and stash it so buildSelectionBody ships it as `distilledDrafts` — the
600
+ // server stages it into the inventory before resolving the selection.
601
+ async function addDraftToBuild(i){
602
+ const skill = anDistilled[i];
603
+ const root = skill.evidence.root;
604
+ document.getElementById("recentModal").hidden = true; showPicker();
605
+ await useTestbed(root, "claude", skill.name); // adopts + loads + renders the project's inventory
606
+ let p = (inv.projects || []).find(x => x.root === root);
607
+ if (!p) { p = { root, name: root.replace(/^.*\//, ""), skills: [], mcpServers: [], instructions: [], hooks: [] }; (inv.projects = inv.projects || []).push(p); }
608
+ if (!p.skills.some(s => s.name === skill.name)) p.skills.push({ type: "skill", name: skill.name, description: skill.description, source: "distilled-draft", content: skill.body });
609
+ if (!acceptedDrafts.some(d => d.name === skill.name)) acceptedDrafts.push(skill);
610
+ projSel(root).skills.add(skill.name);
611
+ fetch("/api/workflow/draft", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(skill) }).catch(() => {}); // also persist a reviewable SKILL.md (best-effort)
612
+ renderInventoryPane(); // re-render so the injected draft shows as a checked skill
613
+ restoreChecks(); refresh();
614
+ ensureAnalyzeBanner().innerHTML = `<b>${esc(skill.name)}</b> <span class="d">(distilled draft)</span> added to the build — it's bundled into the Gem and a SKILL.md draft is written under <code>.agentgem/distilled/</code> on build.`;
615
+ }
616
+
617
+ // Accept a distilled draft: POST it; the server writes the SKILL.md draft and
618
+ // returns the path. The draft is NOT installed — the user promotes it by hand.
619
+ async function acceptDraft(view, i, btn){
620
+ const skill = anDistilled[i];
621
+ const msg = view.querySelector(`.anAcceptMsg[data-i="${i}"]`);
622
+ btn.disabled = true; if (msg) msg.textContent = "writing…";
623
+ try {
624
+ const res = await fetch("/api/workflow/draft", { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify(skill) });
625
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
626
+ const { path } = await res.json();
627
+ btn.textContent = "✓ draft written";
628
+ if (msg) msg.innerHTML = ` → <code>${esc(path)}</code>`;
629
+ } catch (err) {
630
+ btn.disabled = false;
631
+ if (msg) msg.textContent = `failed: ${err.message}`;
632
+ }
633
+ }
634
+
635
+ // The explicit switch: adopt the project as the active testbed, then pre-check
636
+ // the chosen candidate's selection.
637
+ async function switchAndApply(path, flavor, candidate){
638
+ document.getElementById("recentModal").hidden = true;
639
+ showPicker(); // reset modal for next open
640
+ await useTestbed(path, flavor, candidate.name); // adopt + render this project's inventory
641
+ applyCandidate(path, candidate); // pre-check this candidate's boxes + banner
642
+ }
643
+
644
+ function ensureAnalyzeBanner(){
645
+ let b = document.getElementById("analyzeBanner");
646
+ if (!b) { b = document.createElement("div"); b.id = "analyzeBanner"; b.className = "note"; b.style.margin = "0 0 12px";
647
+ const inv = document.getElementById("inventory"); inv.insertBefore(b, inv.firstChild); }
648
+ return b;
649
+ }
650
+
651
+ // Mutate the shared `sel` model (same shape onToggle maintains) from one
652
+ // candidate's selection — global artifacts go top-level, project ones under
653
+ // projects[root] — then re-sync the checkboxes and the build preview.
654
+ function applyCandidate(root, candidate){
655
+ const s = candidate.selection;
656
+ // global (top-level) artifacts
657
+ (s.skills || []).forEach(n => sel.skills.add(n));
658
+ (s.mcpServers || []).forEach(n => sel.mcpServers.add(n));
659
+ (s.hooks || []).forEach(n => sel.hooks.add(n));
660
+ if (s.includeInstructions) sel.includeInstructions = true;
661
+ // project artifacts
662
+ const ps = (s.projects || {})[root] || {};
663
+ const ts = projSel(root);
664
+ (ps.skills || []).forEach(n => ts.skills.add(n));
665
+ (ps.mcpServers || []).forEach(n => ts.mcpServers.add(n));
666
+ (ps.hooks || []).forEach(n => ts.hooks.add(n));
667
+ if (ps.includeInstructions) ts.includeInstructions = true;
668
+
669
+ renderGlobalGroup(candidate); // surface the global picks as checkboxes
670
+ restoreChecks(); refresh();
671
+
672
+ const reasons = candidate.include.map(i => `${esc(i.name)}: ${esc(i.reason)}`).join(" · ");
673
+ ensureAnalyzeBanner().innerHTML = `<b>${esc(candidate.name)}</b> — ${esc(candidate.description)}<br>${reasons}`;
674
+ }
675
+
676
+ // The testbed inventory pane normally shows only project artifacts (globals are
677
+ // reached via Import). When a candidate bundles globals, render them as a
678
+ // dedicated checkbox group so they're visible and reviewable. Their data-kind is
679
+ // the top-level kind, so onToggle/restoreChecks/buildSelectionBody treat them as
680
+ // the global selection.
681
+ function renderGlobalGroup(candidate){
682
+ document.getElementById("globalGroup")?.remove();
683
+ const globals = (candidate.include || []).filter(i => i.root === null);
684
+ if (!globals.length) return;
685
+ const kindOf = t => t === "skill" ? "skills" : t === "mcp_server" ? "mcpServers" : "hooks";
686
+ const rows = globals.map(i =>
687
+ `<label class="row"><input type="checkbox" data-kind="${kindOf(i.type)}" data-name="${esc(i.name)}"> <span>${esc(i.name)} <span class="src">global</span> <span class="d">— ${esc(i.type)}</span></span></label>`).join("");
688
+ document.getElementById("inventory").insertAdjacentHTML("afterbegin",
689
+ `<div class="group" id="globalGroup"><h2>Global / plugin (recommended)</h2>${rows}</div>`);
690
+ document.querySelectorAll("#globalGroup input[type=checkbox]").forEach(cb => cb.addEventListener("change", onToggle));
691
+ }
692
+
693
+ // Prefer the Electron-native folder dialog when running in the desktop app;
694
+ // fall back to the local REST picker in a plain browser. Both return { path }.
695
+ async function pickFolderPath() {
696
+ if (window.agentgem && window.agentgem.pickFolder) {
697
+ return await window.agentgem.pickFolder();
698
+ }
699
+ return await (await fetch("/api/pick-folder")).json();
423
700
  }
424
701
 
425
702
  // Browse routes the picked folder back through the same confirm block (no prompts).
426
703
  async function browseForTestbed(){
427
- const pick = await (await fetch("/api/pick-folder")).json();
704
+ const pick = await pickFolderPath();
428
705
  if(!pick.path) return;
429
706
  const flavor = (await (await fetch(`/api/testbed/detect?root=${encodeURIComponent(pick.path)}`)).json()).flavor;
430
707
  renderCandidate(pick.path, flavor, pick.path.replace(/^.*\//, ""));
@@ -467,6 +744,12 @@ async function load() {
467
744
  const qs = `?projects=${encodeURIComponent(JSON.stringify(projects))}`;
468
745
  inv = await (await fetch("/api/inventory" + qs)).json();
469
746
  inv.projects = inv.projects || [];
747
+ renderInventoryPane();
748
+ }
749
+
750
+ // Render the active testbed's project groups from the current `inv` (re-runnable so
751
+ // an injected distilled draft skill can appear without a re-fetch).
752
+ function renderInventoryPane(){
470
753
  // Render ONLY the active testbed's project groups (global groups are reached via Import).
471
754
  const proj = inv.projects.find(p => p.root === activeTestbed) || { root: activeTestbed, name: activeTestbed.replace(/^.*\//, ""), skills: [], mcpServers: [], instructions: [], hooks: [] };
472
755
  let html = group("Skills", proj.skills, "projectSkills", proj.root)
@@ -616,8 +899,14 @@ function buildSelectionBody(){
616
899
  const selection = { skills, mcpServers, includeInstructions: sel.includeInstructions };
617
900
  if (hooks.length) selection.hooks = hooks;
618
901
  if (Object.keys(projectsSel).length) selection.projects = projectsSel;
902
+ const channels = Array.from(document.querySelectorAll("#channelPicker .ch:checked")).map((el) => ({ platform: el.value }));
619
903
  const reqBody = { selection, name: document.getElementById("name").value || "gem" };
904
+ if (channels.length) reqBody.channels = channels;
620
905
  if (projects.length) reqBody.projects = projects;
906
+ // Ship the distilled drafts that are actually selected so the server can stage
907
+ // them into the inventory before resolving the selection (proposal §7b).
908
+ const drafts = acceptedDrafts.filter(d => (projectsSel[d.evidence.root]?.skills || []).includes(d.name));
909
+ if (drafts.length) reqBody.distilledDrafts = drafts;
621
910
  return reqBody;
622
911
  }
623
912
  async function build(){
@@ -645,6 +934,9 @@ document.getElementById("suggestChecks").addEventListener("click", async () => {
645
934
  renderChecks(); build();
646
935
  });
647
936
  let previewMode = "summary";
937
+ // "Run" preview mode: drive a locally-installed ACP coding agent against the live Gem.
938
+ let __gemRun = { task: "", agent: "claude", expect: "" };
939
+ let __gemRunES = null; // active EventSource for the streaming run (one at a time)
648
940
  function fmtSize(n){ return n < 1024 ? `${n} B` : `${(n / 1024).toFixed(n < 10240 ? 1 : 0)} KB`; }
649
941
  function artSize(a){ return (a.type === "mcp_server" || a.type === "hook") ? JSON.stringify(a.config || {}).length : (a.content || "").length; }
650
942
  const GEM_MARK_SVG = `<svg class="gemmark" viewBox="0 0 64 64" fill="none" aria-hidden="true"><path d="M32 4 58 24 32 60 6 24 Z" fill="#9a3324"/><path d="M32 4 58 24 32 26 Z" fill="#bb4a38"/><path d="M32 4 6 24 32 26 Z" fill="#7d2a1e"/><path d="M6 24 32 26 32 60 Z" fill="#8b2e21"/><path d="M58 24 32 26 32 60 Z" fill="#a8392a"/><path d="M6 24 58 24 32 26 Z" fill="#c8543f"/><path class="glint" d="M32 4 58 24 32 26 Z" fill="#ffd9a0"/></svg>`;
@@ -690,6 +982,7 @@ function renderPreview(){
690
982
  if (typeof renderRail === "function") renderRail();
691
983
  if (previewMode === "materialize") { renderMaterialize(); document.querySelectorAll("#preview-modes button").forEach(b => b.classList.toggle("on", b.dataset.pmode === previewMode)); return; }
692
984
  if (previewMode === "managed") { renderPublish(); document.querySelectorAll("#preview-modes button").forEach(b => b.classList.toggle("on", b.dataset.pmode === previewMode)); return; }
985
+ if (previewMode === "run") { renderRun(); document.querySelectorAll("#preview-modes button").forEach(b => b.classList.toggle("on", b.dataset.pmode === previewMode)); return; }
693
986
  const p = window.__gem;
694
987
  if (!p) { el.textContent = ""; }
695
988
  else if (p.error || previewMode === "json") {
@@ -700,21 +993,99 @@ function renderPreview(){
700
993
  }
701
994
  document.querySelectorAll("#preview-modes button").forEach(b => b.classList.toggle("on", b.dataset.pmode === previewMode));
702
995
  }
996
+ let a2aServerMode = false; // a2a target: false = Agent Card only (default), true = also emit the runnable server
703
997
  async function renderMaterialize(){
704
998
  const el = document.getElementById("preview");
705
999
  const reqBody = buildSelectionBody();
706
1000
  reqBody.target = document.getElementById("target").value;
1001
+ if (reqBody.target === "a2a" && a2aServerMode) reqBody.a2aServer = true;
707
1002
  const m = await (await fetch("/api/materialize", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(reqBody) })).json();
708
1003
  window.__materialize = m;
709
1004
  if (m.error) { el.innerHTML = ""; const pre = document.createElement("pre"); pre.className = "json"; pre.textContent = JSON.stringify(m, null, 2); el.appendChild(pre); return; }
710
1005
  const paths = Object.keys(m.files || {});
711
1006
  const compat = Object.entries(m.compatibility || {}).map(([t, c]) => `${t} ${c.skipped ? c.skipped + " skipped" : "✓"}`).join(" · ");
712
1007
  let h = `<div class="psummary"><div class="phead"><strong>${esc(m.target)}</strong> <span class="d">· ${paths.length} file${paths.length === 1 ? "" : "s"}</span></div>`;
1008
+ if (reqBody.target === "a2a") h += `<div class="pgroup"><label style="display:flex;align-items:center;gap:6px"><input type="checkbox" id="a2aServerToggle" ${a2aServerMode ? "checked" : ""}> Emit runnable server (AI SDK v7) — otherwise just the Agent Card</label></div>`;
713
1009
  h += `<div class="pgroup"><h3>Files</h3>` + (paths.length ? paths.map(p => `<button type="button" class="prow" data-mpath="${esc(p)}"><span class="pn">${esc(p)}</span></button>`).join("") : `<p class="d">No files — select artifacts on the left.</p>`) + `</div>`;
714
1010
  if ((m.skipped || []).length) h += `<div class="pgroup"><h3>Skipped (${m.skipped.length})</h3>` + m.skipped.map(s => `<div class="prow"><span class="pn">${esc(s.artifact)}</span> <span class="pm">${esc(s.reason)}</span></div>`).join("") + `</div>`;
715
1011
  h += `<p class="note">Compatibility: ${esc(compat)}</p></div>`;
716
1012
  el.innerHTML = h;
717
1013
  }
1014
+ // "Run" preview mode: test-run the live Gem with a locally-installed ACP coding agent.
1015
+ // Materializes the selected Gem into a testbed, drives the agent against a task, and
1016
+ // (when "expect tools" is given) shows a verification verdict — the trust primitive.
1017
+ function renderRun(){
1018
+ const el = document.getElementById("preview");
1019
+ const r = window.__gemRunResult;
1020
+ let h = `<div class="psummary"><div class="phead"><strong>Run with a local agent</strong> <span class="d">· drives a locally-installed coding agent over ACP against this Gem</span></div>`;
1021
+ h += `<div class="pgroup"><h3>Task</h3><textarea id="gemRunTask" rows="3" placeholder="Describe what the agent should do with this Gem…" style="width:100%;box-sizing:border-box">${esc(__gemRun.task)}</textarea></div>`;
1022
+ h += `<div class="pgroup" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap">`
1023
+ + `<label>Agent <select id="gemRunAgent"><option value="claude"${__gemRun.agent === "claude" ? " selected" : ""}>Claude</option><option value="codex"${__gemRun.agent === "codex" ? " selected" : ""}>Codex (unvalidated)</option></select></label>`
1024
+ + `<label style="flex:1;min-width:160px">Expect tools <span class="d">(optional, comma-separated)</span><input id="gemRunExpect" placeholder="e.g. Write, qa" value="${esc(__gemRun.expect)}" style="width:100%;box-sizing:border-box"></label>`
1025
+ + `<button id="gemRunBtn">Run ▶</button>`
1026
+ + `<span class="d" id="gemRunStatus"></span></div>`;
1027
+ h += `<div id="gemRunOut" class="pgroup">${r ? runResultHtml(r) : '<p class="d">No run yet — select artifacts on the left, enter a task, and Run.</p>'}</div></div>`;
1028
+ el.innerHTML = h;
1029
+ document.getElementById("gemRunBtn").onclick = runGem;
1030
+ }
1031
+ function runResultHtml(res){
1032
+ if (!res || res.error) return `<pre class="json">${esc(JSON.stringify(res, null, 2))}</pre>`;
1033
+ const run = res.run || {}, rr = run.result || {}, v = res.verification;
1034
+ let h = "";
1035
+ if (v){
1036
+ h += `<div class="prow"><span style="display:inline-block;padding:2px 9px;border-radius:10px;font-weight:600;color:#fff;background:${v.passed ? "#1f7a33" : "#a3301f"}">${v.passed ? "✓ Verified" : "✗ Not verified"}</span></div>`;
1037
+ h += (v.checks || []).map(c => `<div class="prow"><span class="pn">${c.passed ? "✓" : "✗"} ${esc(c.name)}</span> <span class="pm">${esc(c.detail)}</span></div>`).join("");
1038
+ }
1039
+ if (run.ok === false) h += `<div class="prow"><span class="pm">${esc(run.error || "run failed")}</span></div>`;
1040
+ if ((rr.toolCalls || []).length) h += `<h3>Tools</h3><div>` + rr.toolCalls.map(t => `<span style="display:inline-block;padding:2px 8px;margin:2px;border-radius:6px;background:rgba(33,28,21,.08);font-size:12px">${esc(t.title)}${t.status ? " · " + esc(t.status) : ""}</span>`).join("") + `</div>`;
1041
+ if (rr.text) h += `<h3>Agent</h3><div style="white-space:pre-wrap;background:rgba(33,28,21,.04);padding:8px;border-radius:6px">${esc(rr.text)}</div>`;
1042
+ h += `<p class="note">Ran <strong>${esc(res.agent || "")}</strong> · materialized ${(res.materialized && res.materialized.written || []).length} artifact(s) into <code>${esc(res.dir || "")}</code></p>`;
1043
+ return h;
1044
+ }
1045
+ // Two-step streaming flow: POST /prepare materializes the Gem (carries the full
1046
+ // selection) and returns an opaque runId; then EventSource streams the agent run
1047
+ // (tool calls + token deltas) live, mirroring the workflow-analyze stream.
1048
+ async function runGem(){
1049
+ const taskEl = document.getElementById("gemRunTask");
1050
+ __gemRun.task = taskEl.value.trim();
1051
+ __gemRun.agent = document.getElementById("gemRunAgent").value;
1052
+ __gemRun.expect = document.getElementById("gemRunExpect").value;
1053
+ if (!__gemRun.task){ taskEl.focus(); return; }
1054
+ const btn = document.getElementById("gemRunBtn"), status = document.getElementById("gemRunStatus"), out = document.getElementById("gemRunOut");
1055
+ if (__gemRunES){ __gemRunES.close(); __gemRunES = null; }
1056
+ btn.disabled = true;
1057
+ const t0 = Date.now();
1058
+ const timer = setInterval(() => { status.textContent = "Running… " + ((Date.now() - t0) / 1000).toFixed(0) + "s"; }, 250);
1059
+ const finish = (label) => { clearInterval(timer); status.textContent = label; btn.disabled = false; };
1060
+ // Live result, accumulated as events stream in.
1061
+ const live = { agent: __gemRun.agent, run: { result: { text: "", toolCalls: [] } } };
1062
+ window.__gemRunResult = live;
1063
+ try {
1064
+ status.textContent = "Materializing…";
1065
+ const prep = await (await fetch("/api/gem/run/prepare", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...buildSelectionBody(), agent: __gemRun.agent }) })).json();
1066
+ if (prep.error || !prep.runId){ window.__gemRunResult = prep; out.innerHTML = runResultHtml(prep); finish("Failed"); return; }
1067
+ live.dir = prep.runDir; live.materialized = prep.materialized;
1068
+ out.innerHTML = runResultHtml(live);
1069
+ status.textContent = "Running…";
1070
+ const tools = __gemRun.expect.split(",").map(s => s.trim()).filter(Boolean);
1071
+ const qs = new URLSearchParams({ runId: prep.runId, task: __gemRun.task });
1072
+ if (tools.length) qs.set("expectTools", tools.join(","));
1073
+ const es = new EventSource(`/api/gem/run/stream?${qs}`); __gemRunES = es;
1074
+ es.addEventListener("tool", e => { live.run.result.toolCalls.push(JSON.parse(e.data)); out.innerHTML = runResultHtml(live); });
1075
+ es.addEventListener("delta", e => { live.run.result.text += JSON.parse(e.data).text; out.innerHTML = runResultHtml(live); });
1076
+ es.addEventListener("done", e => {
1077
+ const d = JSON.parse(e.data);
1078
+ window.__gemRunResult = { ...live, run: d.run, verification: d.verification, agent: d.agent };
1079
+ out.innerHTML = runResultHtml(window.__gemRunResult);
1080
+ es.close(); __gemRunES = null;
1081
+ finish(d.run && d.run.ok ? "Done in " + ((Date.now() - t0) / 1000).toFixed(1) + "s" : "Failed");
1082
+ });
1083
+ es.addEventListener("failed", e => { out.innerHTML = `<p class="pm">${esc(JSON.parse(e.data).message || "failed")}</p>`; es.close(); __gemRunES = null; finish("Failed"); });
1084
+ es.onerror = () => { es.close(); __gemRunES = null; finish("Connection lost"); };
1085
+ } catch (e) {
1086
+ out.innerHTML = `<p class="pm">${esc(String(e))}</p>`; finish("Error");
1087
+ }
1088
+ }
718
1089
  // Managed Agents publish: offline preview of the agent payload + a gated Publish action.
719
1090
  let __publishReady = null, __publishRequestId = null, __publishFingerprint = null, __deployTargets = null;
720
1091
  // The default deploy backend for each materialize target/harness. Selecting a harness in the
@@ -1018,6 +1389,10 @@ document.getElementById("target").addEventListener("change", (e) => {
1018
1389
  if (mapped) deployBackend = mapped;
1019
1390
  renderPreview();
1020
1391
  });
1392
+ // a2a server toggle (rendered inside the materialize preview): re-materialize with/without the server.
1393
+ document.getElementById("preview").addEventListener("change", (e) => {
1394
+ if (e.target.id === "a2aServerToggle") { a2aServerMode = e.target.checked; renderMaterialize(); }
1395
+ });
1021
1396
  document.getElementById("preview").addEventListener("click", e => {
1022
1397
  if (e.target.id === "publishBtn") { doPublish(); return; }
1023
1398
  const mp = e.target.closest("[data-mpath]");
@@ -1040,6 +1415,7 @@ document.getElementById("none").addEventListener("click", () => {
1040
1415
  });
1041
1416
  });
1042
1417
  document.getElementById("name").addEventListener("input", () => { nameEdited = true; refresh(); });
1418
+ document.getElementById("channelPicker").addEventListener("change", () => refresh());
1043
1419
  document.getElementById("dl").addEventListener("click", () => {
1044
1420
  const blob = new Blob([JSON.stringify(window.__gem || {}, null, 2)], { type: "application/json" });
1045
1421
  const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = (document.getElementById("name").value || "gem") + ".json"; a.click();
@@ -1049,13 +1425,24 @@ document.getElementById("copy").addEventListener("click", () => navigator.clipbo
1049
1425
  document.getElementById("save").addEventListener("click", async () => {
1050
1426
  const status = document.getElementById("archiveStatus");
1051
1427
  status.textContent = "Choose a folder…";
1052
- const picked = await (await fetch("/api/pick-folder")).json();
1428
+ const picked = await pickFolderPath();
1053
1429
  if (!picked.path) { status.textContent = ""; return; }
1054
1430
  status.textContent = "Saving…";
1055
1431
  const r = await (await fetch("/api/archive", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...buildSelectionBody(), outDir: picked.path }) })).json();
1056
1432
  const n = Object.keys(r.files || {}).length, sk = (r.skipped || []).length;
1057
1433
  status.textContent = `Saved ${n} files to ${r.path}` + (sk ? ` · ${sk} skipped` : "");
1058
1434
  });
1435
+ // Download one portable, self-verifying .gem (gzipped tar) to hand to someone else.
1436
+ document.getElementById("dlgem").addEventListener("click", async () => {
1437
+ const status = document.getElementById("archiveStatus");
1438
+ status.textContent = "Building .gem…";
1439
+ const r = await (await fetch("/api/archive", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...buildSelectionBody(), tar: true }) })).json();
1440
+ if (!r.tarGz) { status.textContent = "No archive — select artifacts on the left."; return; }
1441
+ const bytes = Uint8Array.from(atob(r.tarGz), c => c.charCodeAt(0));
1442
+ const blob = new Blob([bytes], { type: "application/gzip" });
1443
+ const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = (document.getElementById("name").value || "gem") + ".gem"; a.click();
1444
+ status.textContent = `Downloaded ${a.download} — share it; install it with “Get gems”.`;
1445
+ });
1059
1446
  // Download the gem archive as a single .tar.gz (the transport/shipping form).
1060
1447
  document.getElementById("dltar").addEventListener("click", async () => {
1061
1448
  const status = document.getElementById("archiveStatus");
@@ -1067,6 +1454,52 @@ document.getElementById("dltar").addEventListener("click", async () => {
1067
1454
  const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = (document.getElementById("name").value || "gem") + ".gem.tar.gz"; a.click();
1068
1455
  status.textContent = `Downloaded ${a.download}`;
1069
1456
  });
1457
+ // Get gems: install a shared .gem by URL/path, or search the configured registry.
1458
+ (function(){
1459
+ const modal = document.getElementById("getModal");
1460
+ const open = () => {
1461
+ document.getElementById("installTarget").innerHTML = document.getElementById("target").innerHTML;
1462
+ modal.hidden = false;
1463
+ };
1464
+ document.getElementById("getBtn").addEventListener("click", open);
1465
+ document.getElementById("getClose").addEventListener("click", () => { modal.hidden = true; });
1466
+ modal.addEventListener("click", e => { if (e.target === modal) modal.hidden = true; });
1467
+
1468
+ document.getElementById("installBtn").addEventListener("click", async () => {
1469
+ const src = document.getElementById("installSrc").value.trim();
1470
+ const status = document.getElementById("installStatus");
1471
+ if (!src) { status.textContent = "Enter a .gem URL or local path."; return; }
1472
+ const body = { target: document.getElementById("installTarget").value };
1473
+ if (/^https?:\/\//i.test(src)) body.gemUrl = src; else body.gemPath = src;
1474
+ status.textContent = "Installing…";
1475
+ try {
1476
+ const m = await (await fetch("/api/materialize", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })).json();
1477
+ if (m.error) { status.textContent = "Failed: " + m.error; return; }
1478
+ window.__materialize = m;
1479
+ status.textContent = `Installed — ${Object.keys(m.files || {}).length} files for ${m.target}. Switch the preview to Materialize to inspect.`;
1480
+ } catch (e) { status.textContent = "Failed: " + (e && e.message || e); }
1481
+ });
1482
+
1483
+ async function runSearch(){
1484
+ const el = document.getElementById("regResults");
1485
+ const q = document.getElementById("regSearch").value.trim();
1486
+ el.innerHTML = '<p class="note">Searching…</p>';
1487
+ try {
1488
+ const ready = await (await fetch("/api/registry/ready")).json();
1489
+ if (!ready.ready) { el.innerHTML = '<p class="note">No registry configured (set <code>AGENTGEM_REGISTRY_REPO</code>). You can still install a .gem by URL or path above.</p>'; return; }
1490
+ const r = await (await fetch("/api/registry/search?q=" + encodeURIComponent(q) + "&limit=25")).json();
1491
+ const hits = r.results || [];
1492
+ if (!hits.length) { el.innerHTML = '<p class="note">No matching gems.</p>'; return; }
1493
+ el.innerHTML = hits.map(h => {
1494
+ const kinds = (h.artifactKinds || []).join(", ");
1495
+ return `<button type="button" class="prow" data-ref="${esc(h.key)}"><span class="pn">${esc(h.key)}</span> <span class="pm">${esc(h.latest)}${kinds ? " · " + esc(kinds) : ""}</span></button>` +
1496
+ (h.description ? `<div class="note" style="margin:0 0 6px 8px">${esc(h.description)}</div>` : "");
1497
+ }).join("");
1498
+ } catch (e) { el.innerHTML = '<p class="note">Search failed: ' + esc(String(e && e.message || e)) + "</p>"; }
1499
+ }
1500
+ document.getElementById("regSearchBtn").addEventListener("click", runSearch);
1501
+ document.getElementById("regSearch").addEventListener("keydown", e => { if (e.key === "Enter") runSearch(); });
1502
+ })();
1070
1503
  // Export ▾ dropdown: toggle, close on outside click / Escape, and close after any item runs.
1071
1504
  (function(){
1072
1505
  const btn = document.getElementById("exportBtn"), menu = document.getElementById("exportMenu");
@@ -1226,7 +1659,8 @@ async function wsRender(target){
1226
1659
  tree.innerHTML = `<p class="d">Rendering ${esc(target)}…</p>`;
1227
1660
  let r;
1228
1661
  try {
1229
- const res = await fetch("/api/workspace/render", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: wsCurrent, target }) });
1662
+ const body = { name: wsCurrent, target, ...(target === "a2a" && a2aServerMode ? { a2aServer: true } : {}) };
1663
+ const res = await fetch("/api/workspace/render", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
1230
1664
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
1231
1665
  r = await res.json();
1232
1666
  } catch {