shadok-ai 0.17.35 → 0.17.37

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
@@ -254,6 +254,9 @@ Enter to skip; you can add it later from the web UI).
254
254
  with a command goes to the agent bare, without shadok's context line or
255
255
  ledger update ahead of it, since Claude Code reads a command only when `/`
256
256
  comes first.
257
+ The ✦ button beside the paperclip opens the same menu without typing, like
258
+ an emoji picker: the chosen skill goes in **front** of what you already
259
+ wrote (which becomes its argument), replacing a command already there.
257
260
  - **Files you hand an agent** — paste or drop a file into the composer, or pick
258
261
  one with the 📎 button beside it (an image, a PDF, a CSV…), and it becomes a **chip** above the text box: a
259
262
  thumbnail for an image, a named tag for anything else, each with a ✕ to
@@ -301,6 +304,14 @@ Enter to skip; you can add it later from the web UI).
301
304
  - **The detail panel.** Its numbers, where it runs (group by group: each
302
305
  agent, its schedule, its runs), the secrets agents running it get, and its
303
306
  feedback.
307
+ - **Creating.** The new-skill form asks for a **name** (what people see) and
308
+ a **technical name** (what agents call it, `/name:name`), proposed from the
309
+ name until you type your own. Choose it then: it is the skill's repository
310
+ and plugin name, so it cannot be changed afterwards. A name already taken
311
+ gets a number (`-2`, `-3`…), and the form says so before you create it.
312
+ - **Renaming.** Click a skill's name at the top of its editor to rename it
313
+ (Enter saves, Escape cancels). Only the displayed name changes: agents keep
314
+ calling it by its technical name, so attached agents are not disturbed.
304
315
  - **Sharing.** A skill is shared with **only me**, with **one or more
305
316
  groups**, or with **everyone**. Sharing keeps lists short; it is not a
306
317
  lock. "Only me" hides the skill from everyone else. A group skill is offered
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadok-ai",
3
- "version": "0.17.35",
3
+ "version": "0.17.37",
4
4
  "main": "dist/session.js",
5
5
  "scripts": {
6
6
  "test": "node --import tsx --test --test-timeout=120000 test/*.ts context/*/test/*.test.mjs",
package/public/index.html CHANGED
@@ -1086,8 +1086,28 @@
1086
1086
  .sec-meta { flex-basis: 100%; font-size: 11px; color: var(--text-dim); margin-top: 2px; }
1087
1087
  #secretAttach[data-grouped="1"] { display: none; }
1088
1088
  .sec-from { margin-left: 8px; font-size: 11px; color: var(--text-dim); font-family: var(--sans, inherit); }
1089
- .sec-ctl { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; font-size: 12px; margin-top: 4px; }
1090
- .sec-ctl label { display: inline-flex; gap: 4px; align-items: center; cursor: pointer; }
1089
+ /* Grouped mode: each own secret is a card — name + actions, one meta line, then
1090
+ the "Shared with" / "Injected into" chip rows and ONE Save. */
1091
+ #secretsList li.sec-row { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; margin-bottom: 8px; background: none; }
1092
+ #secretsList li.sec-row:nth-child(odd) { background: none; }
1093
+ .sec-ctl { flex-basis: 100%; display: flex; flex-direction: column; gap: 8px; font-size: 12px; margin-top: 10px; }
1094
+ .sec-field { display: grid; grid-template-columns: 96px 1fr; gap: 8px; align-items: start; }
1095
+ .sec-field-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; padding-top: 5px; }
1096
+ .sec-chips { display: flex; flex-wrap: wrap; gap: 6px; }
1097
+ .sec-chip { position: relative; display: inline-flex; align-items: center; gap: 5px; font-size: 12px; padding: 3px 10px; border: 1px solid var(--line); border-radius: 14px; cursor: pointer; user-select: none; }
1098
+ .sec-chip:hover { border-color: var(--amber); }
1099
+ .sec-chip input { position: absolute; opacity: 0; pointer-events: none; }
1100
+ .sec-chip:has(input:checked) { border-color: var(--amber); background: color-mix(in srgb, var(--amber) 16%, transparent); color: var(--text); }
1101
+ .sec-chip:has(input:checked)::before { content: "✓"; font-size: 11px; color: var(--amber); }
1102
+ .sec-chip:has(input:focus-visible) { outline: 2px solid var(--amber); outline-offset: 1px; }
1103
+ .sec-foot { display: flex; align-items: center; justify-content: flex-end; gap: 10px; }
1104
+ .sec-status { flex: 1; font-size: 12px; color: var(--text-dim); }
1105
+ .sec-status.err { color: #e2564a; }
1106
+ #secretsList button.sec-save { font-size: 12px; padding: 4px 14px; border-radius: 6px; border: 1px solid var(--amber); color: var(--amber); }
1107
+ #secretsList button.sec-save:hover:not(:disabled) { background: color-mix(in srgb, var(--amber) 15%, transparent); }
1108
+ #secretsList button.sec-save:disabled { opacity: .35; cursor: default; border-color: var(--line); color: var(--text-dim); }
1109
+ .sec-err { margin: 0 0 10px; padding: 8px 11px; border: 1px solid #e2564a; border-radius: 8px; color: #e2564a; background: color-mix(in srgb, #e2564a 8%, transparent); font-size: 12.5px; }
1110
+ @media (max-width: 520px) { .sec-field { grid-template-columns: 1fr; gap: 4px; } .sec-field-label { padding-top: 0; } }
1091
1111
  .group-head .gkey { flex: none; font-size: 12px; padding: 0 4px; cursor: pointer; visibility: hidden; }
1092
1112
  .group-head:hover .gkey { visibility: visible; }
1093
1113
  /* A touch screen has no hover, so the group-head ⚙/🔑 (the single-column /
@@ -1751,6 +1771,7 @@
1751
1771
  .slash-menu[hidden] { display: none; }
1752
1772
  .slash-item { display: flex; gap: 10px; align-items: baseline; width: 100%; padding: 7px 10px; border: 0; border-radius: 6px; background: transparent; color: var(--text); text-align: left; cursor: pointer; font: inherit; }
1753
1773
  .slash-item[aria-selected="true"] { background: var(--amber-soft); }
1774
+ .slash-empty { padding: 8px 10px; color: var(--text-dim); font-size: 13px; }
1754
1775
  .slash-name { font-family: var(--mono); font-size: 13px; flex: none; }
1755
1776
  .slash-desc { color: var(--text-dim); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
1756
1777
  .cmp-attach {
@@ -2469,6 +2490,11 @@
2469
2490
  .fp-col.fp-doc { padding: 22px 26px; background: var(--bg); }
2470
2491
  .fp-doc-head { display: flex; gap: 14px; align-items: center; margin-bottom: 6px; }
2471
2492
  .fp-doc-head h1 { font-size: 20px; font-weight: 650; }
2493
+ /* The skill's name, renamed in place: reads as a title until hovered/focused. */
2494
+ .fp-main .fp-doc-head input.fp-title-in { width: 100%; box-sizing: border-box; font: inherit; font-size: 20px; font-weight: 650; color: var(--text);
2495
+ background: transparent; border: 1px solid transparent; border-radius: 6px; padding: 2px 6px; margin-left: -7px; }
2496
+ .fp-main .fp-doc-head input.fp-title-in:hover { border-color: var(--line); }
2497
+ .fp-main .fp-doc-head input.fp-title-in:focus { outline: none; border-color: var(--amber); box-shadow: 0 0 0 3px var(--amber-soft); }
2472
2498
  .fp-doc-head .fp-sub { margin-top: 4px; }
2473
2499
  .fp-doc textarea { border-radius: 10px; padding: 10px 12px; background: var(--bg-raised); border: 1px solid var(--line); color: var(--text); font-family: var(--sans); }
2474
2500
  .fp-doc textarea:focus { outline: none; border-color: var(--amber); box-shadow: 0 0 0 3px var(--amber-soft); }
@@ -3017,6 +3043,7 @@
3017
3043
  </div>
3018
3044
  <div class="pin-body">
3019
3045
  <div id="secretsScope" class="sec-scope" hidden><span>Group</span><select id="secretsGroup" aria-label="Group whose secrets are shown"></select></div>
3046
+ <p id="secretsErr" class="sec-err" role="alert" hidden></p>
3020
3047
  <ul id="secretsList"></ul>
3021
3048
  <form id="secretsAdd">
3022
3049
  <div class="sec-add-row">
@@ -3731,6 +3758,11 @@
3731
3758
  <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M21 11.5l-8.6 8.6a5.5 5.5 0 0 1-7.8-7.8l8.6-8.6a3.7 3.7 0 0 1 5.2 5.2l-8.6 8.6a1.8 1.8 0 0 1-2.6-2.6l7.9-7.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
3732
3759
  </button>
3733
3760
  <input type="file" id="attachInput" multiple hidden>
3761
+ <!-- Pick a skill like an emoji: opens the `/` menu with every skill attached
3762
+ to this agent; the choice goes in at the head of the prompt. -->
3763
+ <button type="button" id="skillBtn" class="cmp-attach" title="Add a skill to this prompt" aria-label="Add a skill" aria-haspopup="listbox">
3764
+ <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z M18.5 15.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg>
3765
+ </button>
3734
3766
  <div id="slashMenu" class="slash-menu" role="listbox" aria-label="Commands" hidden></div>
3735
3767
  <textarea id="promptInput" rows="1"
3736
3768
  placeholder="Write your prompt — Enter to send, Shift+Enter for a new line"></textarea>
@@ -9931,9 +9963,19 @@
9931
9963
  slash.cache.set(sessionId, { at: Date.now(), list });
9932
9964
  return list;
9933
9965
  }
9934
- function closeSlash() { slashMenu.hidden = true; slash.list = []; }
9966
+ function closeSlash() { slashMenu.hidden = true; slash.list = []; slash.picker = false; }
9935
9967
  function paintSlash() {
9936
9968
  slashMenu.textContent = "";
9969
+ if (slash.picker && !slash.list.length) {
9970
+ // Only ATTACHED skills run (they load as a plugin at spawn), so an empty
9971
+ // menu says where to attach one rather than staying silently shut.
9972
+ const d = document.createElement("div");
9973
+ d.className = "slash-empty";
9974
+ d.textContent = "No skill attached to this agent yet. Attach one from the Skills page, then reload the agent.";
9975
+ slashMenu.appendChild(d);
9976
+ slashMenu.hidden = false;
9977
+ return;
9978
+ }
9937
9979
  slash.list.forEach((c, i) => {
9938
9980
  const b = document.createElement("button");
9939
9981
  b.type = "button";
@@ -9964,16 +10006,34 @@
9964
10006
  function pickSlash(i) {
9965
10007
  const c = slash.list[i];
9966
10008
  if (!c) return;
9967
- promptInput.value = "/" + c.name + " ";
10009
+ // From the picker the prompt may already hold text: a `/command` only runs at
10010
+ // the very start (isSlashCommand), so the skill goes in FRONT and the text
10011
+ // becomes its argument; a command already there is replaced, not stacked.
10012
+ const rest = slash.picker ? promptInput.value.replace(/^\/[A-Za-z][\w:.-]*\s*/, "") : "";
10013
+ promptInput.value = "/" + c.name + " " + rest;
9968
10014
  promptInput.setSelectionRange(promptInput.value.length, promptInput.value.length);
9969
10015
  closeSlash();
9970
10016
  promptInput.dispatchEvent(new Event("input"));
9971
10017
  promptInput.focus();
9972
10018
  }
9973
10019
  promptInput.addEventListener("input", updateSlash);
10020
+ const skillBtn = $("skillBtn");
10021
+ // mousedown would blur the textarea, whose blur handler closes the menu.
10022
+ skillBtn.addEventListener("mousedown", (e) => e.preventDefault());
10023
+ skillBtn.addEventListener("click", async () => {
10024
+ if (!active) return;
10025
+ if (!slashMenu.hidden && slash.picker) { closeSlash(); return; }
10026
+ const all = await slashCommands(active.sessionId);
10027
+ slash.list = all.slice(0, 40);
10028
+ slash.at = 0;
10029
+ slash.picker = true;
10030
+ paintSlash();
10031
+ promptInput.focus(); // arrows / Enter / Escape go through its keydown
10032
+ });
9974
10033
  promptInput.addEventListener("blur", () => setTimeout(closeSlash, 100));
9975
10034
 
9976
10035
  promptInput.addEventListener("keydown", (e) => {
10036
+ if (!slashMenu.hidden && slash.picker && !slash.list.length && e.key === "Escape") { e.preventDefault(); closeSlash(); return; }
9977
10037
  if (!slashMenu.hidden && slash.list.length) {
9978
10038
  if (e.key === "ArrowDown" || e.key === "ArrowUp") {
9979
10039
  e.preventDefault();
@@ -10504,8 +10564,11 @@
10504
10564
  actions.append(
10505
10565
  actionBtn("s-edit", "✎", "Update this secret's value / the profiles it feeds", () => startEditSecret(k)),
10506
10566
  actionBtn("s-del", "✕", "Remove from vault", async () => {
10507
- const g = secretWriteGroup();
10508
- await fetch("/secrets?name=" + encodeURIComponent(k) + (g ? "&group=" + encodeURIComponent(g) : ""), { method: "DELETE" });
10567
+ // The row's OWNER group, not the panel's write target (see secretControls).
10568
+ const g = grouped && sec.ownerGroup != null ? String(sec.ownerGroup) : secretWriteGroup();
10569
+ const r = await fetch("/secrets?name=" + encodeURIComponent(k) + (g ? "&group=" + encodeURIComponent(g) : ""), { method: "DELETE" });
10570
+ const e = await vaultError(r);
10571
+ if (e) { showVaultError("Could not remove " + k + ": " + e); return; }
10509
10572
  if (editingSecret === k) resetSecretForm();
10510
10573
  await loadVault(); renderSecrets();
10511
10574
  }),
@@ -10519,36 +10582,89 @@
10519
10582
  }
10520
10583
  return li;
10521
10584
  }
10585
+ /** Read a refused vault write's message: the server answers `{error}` JSON;
10586
+ * a proxy may answer HTML, so fall back to the status line. */
10587
+ async function vaultError(r) {
10588
+ if (r.ok) return null;
10589
+ try { const j = await r.json(); if (j && j.error) return String(j.error); } catch {}
10590
+ return "refused (HTTP " + r.status + ")";
10591
+ }
10522
10592
  /** Own secret, grouped mode: who it is lent to (PUT /secrets/share) and which
10523
- * profiles inject it (PUT /secrets/profiles). Each saves on its own button so
10524
- * a half-ticked state never reaches the server. */
10593
+ * profiles inject it (PUT /secrets/profiles), edited as chips and saved by ONE
10594
+ * button that only lights up once something changed. Both calls target the
10595
+ * secret's OWNER group, never the panel's selection: in the "Public groups"
10596
+ * view the panel's write target is the first public group, which sent every
10597
+ * row there (a 403 or a 404 for a secret living in another public group). A
10598
+ * refusal is shown in the card, not swallowed. */
10525
10599
  function secretControls(sec) {
10526
10600
  const box = document.createElement("div"); box.className = "sec-ctl";
10527
10601
  const targets = window.shareTargets ? window.shareTargets(vaultGroups, sec.ownerGroup) : [];
10528
10602
  const profs = profileCache.map((p) => p.name).filter((n) => !(window.isManagedProfile && window.isManagedProfile(n))).sort();
10529
- const picker = (label, options, current, onSave) => {
10530
- if (!options.length) return;
10531
- const wrap = document.createElement("span");
10532
- wrap.append(label + ": ");
10603
+ const same = (a, b) => a.length === b.length && a.every((x) => b.includes(x));
10604
+ const fields = [];
10605
+ const field = (label, hint, options, current) => {
10606
+ if (!options.length) return null;
10607
+ const row = document.createElement("div"); row.className = "sec-field";
10608
+ const l = document.createElement("span"); l.className = "sec-field-label"; l.textContent = label; l.title = hint;
10609
+ const chips = document.createElement("div"); chips.className = "sec-chips";
10610
+ const initial = (current || []).map(String);
10533
10611
  const cbs = options.map((o) => {
10534
- const l = document.createElement("label");
10535
- const cb = document.createElement("input"); cb.type = "checkbox"; cb.value = o.value; cb.checked = (current || []).includes(o.value);
10612
+ const lab = document.createElement("label"); lab.className = "sec-chip";
10613
+ const cb = document.createElement("input"); cb.type = "checkbox"; cb.value = o.value; cb.checked = initial.includes(o.value);
10536
10614
  const t = document.createElement("span"); t.textContent = o.label;
10537
- l.append(cb, t); wrap.appendChild(l); return cb;
10615
+ lab.append(cb, t); chips.appendChild(lab); return cb;
10538
10616
  });
10539
- wrap.appendChild(actionBtn("linkish", "save", "Save", async () => {
10540
- await onSave(cbs.filter((c) => c.checked).map((c) => c.value));
10541
- await loadVault(); renderSecrets();
10542
- }));
10543
- box.appendChild(wrap);
10617
+ row.append(l, chips); box.appendChild(row);
10618
+ const f = { cbs, initial: initial.filter((v) => options.some((o) => o.value === v)), value: () => cbs.filter((c) => c.checked).map((c) => c.value) };
10619
+ fields.push(f);
10620
+ return f;
10544
10621
  };
10545
- // A secret's own controls target its OWNER group (the section it lives in).
10546
- picker("shared read with", targets.map((g) => ({ value: String(g.id), label: g.name })), sec.sharedWith, (sharedWith) =>
10547
- fetch("/secrets/share", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(withSecretGroup({ name: sec.name, sharedWith })) }));
10548
- picker("attach to profiles", profs.map((n) => ({ value: n, label: n })), sec.profiles, (profiles) =>
10549
- fetch("/secrets/profiles", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(withSecretGroup({ name: sec.name, profiles })) }));
10622
+ const share = field("Shared with", "Groups that may READ this secret (it stays owned by its group)",
10623
+ targets.map((g) => ({ value: String(g.id), label: g.name })), sec.sharedWith);
10624
+ const inject = field("Injected into", "Profiles whose agents receive it as an environment variable at spawn",
10625
+ profs.map((n) => ({ value: n, label: n })), sec.profiles);
10626
+ if (!fields.length) return box;
10627
+ const foot = document.createElement("div"); foot.className = "sec-foot";
10628
+ const status = document.createElement("span"); status.className = "sec-status"; status.setAttribute("role", "status");
10629
+ const save = document.createElement("button"); save.type = "button"; save.className = "sec-save"; save.textContent = "Save"; save.disabled = true;
10630
+ const dirty = () => fields.some((f) => !same(f.value(), f.initial));
10631
+ box.addEventListener("change", () => {
10632
+ save.disabled = !dirty();
10633
+ if (status.classList.contains("ok")) { status.textContent = ""; status.className = "sec-status"; }
10634
+ });
10635
+ save.addEventListener("click", async () => {
10636
+ save.disabled = true; status.className = "sec-status"; status.textContent = "Saving…";
10637
+ const put = (url, body) => fetch(url, { method: "PUT", headers: { "Content-Type": "application/json" },
10638
+ body: JSON.stringify({ name: sec.name, group: String(sec.ownerGroup), ...body }) });
10639
+ const errors = [];
10640
+ try {
10641
+ if (share && !same(share.value(), share.initial)) {
10642
+ const e = await vaultError(await put("/secrets/share", { sharedWith: share.value() }));
10643
+ if (e) errors.push("Sharing not saved: " + e);
10644
+ }
10645
+ if (inject && !same(inject.value(), inject.initial)) {
10646
+ const e = await vaultError(await put("/secrets/profiles", { profiles: inject.value() }));
10647
+ if (e) errors.push("Profiles not saved: " + e);
10648
+ }
10649
+ } catch (err) { errors.push("Network error: " + (err && err.message || err)); }
10650
+ if (errors.length) {
10651
+ // Keep the user's ticks on screen so they can retry or undo — a reload
10652
+ // here would silently reset them to the server's state.
10653
+ status.className = "sec-status err"; status.textContent = errors.join(" · ");
10654
+ save.disabled = false;
10655
+ return;
10656
+ }
10657
+ await loadVault(); renderSecrets();
10658
+ });
10659
+ foot.append(status, save); box.appendChild(foot);
10550
10660
  return box;
10551
10661
  }
10662
+ /** Panel-level refusal (add / update / delete): shown above the list until the
10663
+ * next successful write or the panel is reopened. */
10664
+ function showVaultError(msg) {
10665
+ const el = $("secretsErr");
10666
+ el.textContent = msg || ""; el.hidden = !msg;
10667
+ }
10552
10668
  function renderSecrets() {
10553
10669
  const ul = $("secretsList");
10554
10670
  ul.innerHTML = "";
@@ -10689,6 +10805,7 @@
10689
10805
  await Promise.all([loadVault(), loadProfilesInto()]);
10690
10806
  renderSecrets();
10691
10807
  resetSecretForm();
10808
+ showVaultError("");
10692
10809
  $("secretsReload").hidden = true;
10693
10810
  $("secretsOverlay").hidden = false;
10694
10811
  $("secretKey").focus();
@@ -11607,8 +11724,31 @@
11607
11724
  const scroll = facEl("div", { class: "fp-scroll" });
11608
11725
  m.appendChild(scroll);
11609
11726
  const title = facEl("input", { type: "text", id: "fpTitle", placeholder: "Daily marketing summary", autocomplete: "off" });
11610
- const slugHint = facEl("div", { class: "fp-hint", text: "Agents see it under a short technical name, made from this one." });
11611
- title.addEventListener("input", () => { const sl = facSlug(title.value); slugHint.textContent = sl ? `Agents will see it as ${sl}:${sl}.` : "Agents see it under a short technical name, made from this one."; });
11727
+ // The technical name agents call it by (`/<name>:<name>`): proposed from the
11728
+ // name above until the person types their own, then left alone. Unlike the
11729
+ // displayed name it can NOT be changed later (it is the repo, the plugin and
11730
+ // every attachment's key), so it is chosen here or never.
11731
+ const slug = facEl("input", { type: "text", id: "fpSlug", placeholder: "daily-marketing-summary", autocomplete: "off", spellcheck: "false", maxlength: "40", style: "font-family:var(--mono)" });
11732
+ let slugTouched = false;
11733
+ const slugHint = facEl("div", { class: "fp-hint" });
11734
+ const paintSlug = () => {
11735
+ const sl = facSlug(slug.value);
11736
+ const taken = sl && fac.data && fac.data.skills.some((x) => x.name === sl);
11737
+ slugHint.textContent = !sl ? "Lowercase letters, digits and dashes. It cannot be changed later."
11738
+ : sl.length < 2 ? "At least 2 characters."
11739
+ : taken ? `${sl} is already used: it will be saved as ${sl}-2 (or the next free number).`
11740
+ : `Agents will call it /${sl}:${sl}. It cannot be changed later.`;
11741
+ };
11742
+ title.addEventListener("input", () => { if (!slugTouched) slug.value = facSlug(title.value); paintSlug(); });
11743
+ slug.addEventListener("input", () => {
11744
+ // Light clean-up while typing (a trailing dash must survive, "my-" is on its
11745
+ // way to "my-skill"); the full facSlug runs when it is sent.
11746
+ const v = slug.value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+/, "").replace(/--+/g, "-");
11747
+ if (v !== slug.value) slug.value = v;
11748
+ slugTouched = !!v; // emptied → follows the name again (at its next edit, or on Create)
11749
+ paintSlug();
11750
+ });
11751
+ paintSlug();
11612
11752
  const desc = facEl("textarea", { rows: "4", id: "fpDescIn",
11613
11753
  placeholder: "Writes a short summary of yesterday's marketing numbers (traffic, signups, campaigns) and posts it in #marketing. Use it every morning, or when someone asks for the marketing summary." });
11614
11754
  const tags = facEl("input", { type: "text", placeholder: "reporting, slack" });
@@ -11624,7 +11764,7 @@
11624
11764
  const v = (scroll.querySelector('input[name="fpScope"]:checked') || {}).value || "me";
11625
11765
  const scope = v === "groups" ? { groups: [...ids] } : v;
11626
11766
  try {
11627
- const out = await facCall("POST", "/factory/skills", { title: title.value.trim(), description: desc.value.trim(), scope, tags: tags.value });
11767
+ const out = await facCall("POST", "/factory/skills", { title: title.value.trim(), name: facSlug(slug.value) || facSlug(title.value) || undefined, description: desc.value.trim(), scope, tags: tags.value });
11628
11768
  await facLoad();
11629
11769
  facEdit(out.skill.id, true);
11630
11770
  } catch (e) { facMsg(e.message, true); }
@@ -11633,7 +11773,9 @@
11633
11773
  facEl("div", { class: "fp-bar" }, [facEl("button", { class: "fp-link", text: "← Skills", onclick: () => facGo("list") })]),
11634
11774
  facEl("h1", { text: "New skill" }),
11635
11775
  facEl("div", { class: "fp-sub", text: "A short set of instructions an agent follows to do one job." }),
11636
- facEl("div", { class: "fp-field" }, [facEl("label", { for: "fpTitle", text: "Name" }), title, slugHint]),
11776
+ facEl("div", { class: "fp-field" }, [facEl("label", { for: "fpTitle", text: "Name" }), title,
11777
+ facEl("div", { class: "fp-hint", text: "What people see. You can rename it any time." })]),
11778
+ facEl("div", { class: "fp-field" }, [facEl("label", { for: "fpSlug", text: "Technical name" }), slug, slugHint]),
11637
11779
  facEl("div", { class: "fp-field" }, [facEl("label", { for: "fpDescIn", text: "What does it do, and when should it be used?" }), desc,
11638
11780
  facEl("div", { class: "fp-hint", text: "An agent reads this to decide to use the skill. You can refine it later." })]),
11639
11781
  facEl("div", { class: "fp-field" }, [facEl("span", { class: "fp-label", text: "Shared with" }),
@@ -11752,7 +11894,29 @@
11752
11894
  const ed = fac.ed;
11753
11895
  const dirty = () => ed.desc !== ed.saved.desc || ed.body !== ed.saved.body;
11754
11896
  // Center content per tab
11755
- center.appendChild(facEl("div", { class: "fp-doc-head" }, [facAvatar(s, "lg"), facEl("div", {}, [facEl("h1", { text: facTitle(s) }),
11897
+ // The name is edited in place: it is only the DISPLAY title (PUT …/meta), the
11898
+ // technical name agents call it by (`<name>:<name>`) stays put, so attached
11899
+ // agents and typed `/` commands keep working after a rename.
11900
+ const nameIn = facEl("input", { type: "text", class: "fp-title-in", value: facTitle(s), maxlength: "80",
11901
+ "aria-label": "Skill name", title: `Click to rename. Agents still call it ${s.name}:${s.name}.` });
11902
+ const saveName = async () => {
11903
+ const v = nameIn.value.trim();
11904
+ if (!v) { nameIn.value = facTitle(s); return; }
11905
+ if (v === facTitle(s)) return;
11906
+ try {
11907
+ await facCall("PUT", `/factory/skills/${s.id}/meta`, { title: v });
11908
+ await facLoad();
11909
+ // A blur caused by clicking another skill in the list: that click already
11910
+ // rendered the other one — repainting here would race it.
11911
+ if (fac.view === "edit" && fac.sel && fac.sel.id === s.id) facRender();
11912
+ } catch (e) { nameIn.value = facTitle(s); facMsg(e.message, true); }
11913
+ };
11914
+ nameIn.addEventListener("keydown", (e) => {
11915
+ if (e.key === "Enter") { e.preventDefault(); nameIn.blur(); }
11916
+ if (e.key === "Escape") { nameIn.value = facTitle(s); nameIn.blur(); }
11917
+ });
11918
+ nameIn.addEventListener("blur", saveName);
11919
+ center.appendChild(facEl("div", { class: "fp-doc-head" }, [facAvatar(s, "lg"), facEl("div", { style: "flex:1;min-width:0" }, [nameIn,
11756
11920
  facEl("div", { class: "fp-sub" }, [
11757
11921
  facStatus(s).includes("draft") ? facEl("span", { class: "fp-ver draft", text: "draft changes" }) : facEl("span", { class: "fp-ver live", text: "saved" }),
11758
11922
  " ", ...facSharedLabels(s.owner).map((x) => facEl("span", { class: "fp-gp", text: x }))])])]));
@@ -12024,8 +12188,12 @@
12024
12188
  // AGENT replacing a credential silently).
12025
12189
  const keepValue = editingSecret && value === "";
12026
12190
  if (!keepValue) {
12027
- await fetch("/secrets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(withSecretGroup({ name, value, overwrite: true })) });
12191
+ const r = await fetch("/secrets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(withSecretGroup({ name, value, overwrite: true })) });
12192
+ const err = await vaultError(r);
12193
+ // Refused: say so and keep the form filled, rather than clearing it as if saved.
12194
+ if (err) { showVaultError("Could not save " + name + ": " + err); return; }
12028
12195
  }
12196
+ showVaultError("");
12029
12197
  // Grouped mode binds profiles per row (PUT /secrets/profiles) and hides the
12030
12198
  // ticks: syncing here would read an empty selection and detach everything.
12031
12199
  if (window.secretsPanelMode(vaultGroups) === "flat") await syncSecretToProfiles(name, wanted); // refreshes profileCache