shadok-ai 0.11.5 → 0.11.7

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
@@ -92,11 +92,13 @@ Enter to skip; you can add it later from the web UI).
92
92
  and three go-to-market roles (paid, organic, support). Each one is yours to
93
93
  edit, and a role you delete stays deleted.
94
94
  - **Secret vault** — stored under `~/.shadok-ai`, never in your repo, injected
95
- as env vars into the agents that need them. An agent that **obtains** a
96
- credential (a CLI login, a key it just provisioned) can add it to the vault
97
- itself so the next agent doesn't start from nothing — write-only, the value
98
- piped in rather than typed as an argument, and never overwriting an existing
99
- name without you saying so.
95
+ as env vars into the agents that need them. Add or **update** a secret from the
96
+ panel, tick the **profiles** it should feed right there, and reload the running
97
+ agents on those profiles in one click so a rotated value takes effect now. An
98
+ agent that **obtains** a credential (a CLI login, a key it just provisioned)
99
+ can add it to the vault itself so the next agent doesn't start from nothing —
100
+ write-only, the value piped in rather than typed as an argument, and never
101
+ overwriting an existing name without you saying so.
100
102
  - **Scheduled prompts** — give a channel a recurring prompt (every N minutes, or
101
103
  daily at HH:MM in a time zone you choose) for monitoring and reporting. Each
102
104
  schedule can carry a **deterministic guard command** that runs *without the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadok-ai",
3
- "version": "0.11.5",
3
+ "version": "0.11.7",
4
4
  "main": "dist/session.js",
5
5
  "scripts": {
6
6
  "test": "node --import tsx --test test/*.ts context/*/test/*.test.mjs",
package/public/index.html CHANGED
@@ -759,6 +759,27 @@
759
759
  }
760
760
  .turn { display: flex; flex-direction: column; gap: 3px; max-width: 82%; margin-top: 8px; }
761
761
  .turn:first-child { margin-top: 0; }
762
+ /* "Jump to the latest": floats bottom-right of the chat, above the composer
763
+ (its `bottom` is set in JS to the composer's height). Hidden at the bottom. */
764
+ #jumpBottom {
765
+ position: absolute; right: 18px; z-index: 6;
766
+ display: inline-flex; align-items: center; gap: 5px;
767
+ font: inherit; font-size: 12px; line-height: 1; padding: 7px 12px;
768
+ border-radius: 16px; cursor: pointer;
769
+ background: var(--bg-raised); color: var(--text); border: 1px solid var(--line);
770
+ box-shadow: 0 3px 12px rgba(0,0,0,.45);
771
+ }
772
+ #jumpBottom:hover { border-color: var(--amber); color: var(--amber); }
773
+ #jumpBottom svg { display: block; }
774
+ /* Divider between messages already seen and the ones that arrived since. */
775
+ .unread-sep {
776
+ display: flex; align-items: center; gap: 10px; margin: 10px 2px 4px;
777
+ color: var(--amber); font-size: 10px; letter-spacing: .16em; text-transform: uppercase;
778
+ }
779
+ .unread-sep::before, .unread-sep::after {
780
+ content: ""; flex: 1; height: 1px;
781
+ background: color-mix(in srgb, var(--amber) 45%, transparent);
782
+ }
762
783
  /* Copy: always visible, never on hover — a phone has no hover, and a button
763
784
  you cannot reveal does not exist. */
764
785
  .turn .copy {
@@ -1476,7 +1497,11 @@
1476
1497
  #secretsList li { display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; border-radius: 6px; }
1477
1498
  #secretsList li:nth-child(odd) { background: rgba(255,255,255,.04); }
1478
1499
  #secretsList code { font-size: 13px; }
1479
- #secretsList button { background: none; border: none; color: #e26; cursor: pointer; font-size: 14px; }
1500
+ #secretsList .s-actions { display: flex; gap: 2px; flex: none; }
1501
+ #secretsList button { background: none; border: none; cursor: pointer; font-size: 14px; padding: 0 4px; }
1502
+ #secretsList .s-del { color: #e26; }
1503
+ #secretsList .s-edit { color: var(--text-dim); }
1504
+ #secretsList .s-edit:hover { color: var(--amber); }
1480
1505
  /* Le panneau Users n'avait AUCUN style : d'où les puces brutes et le nom, le
1481
1506
  rôle et les boutons empilés les uns sous les autres. Même traitement que
1482
1507
  #profilesList, dont il est le frère. */
@@ -1497,9 +1522,21 @@
1497
1522
  #inviteBox .ib-url { margin-top: 6px; font-family: var(--mono); font-size: 12px; line-height: 1.45; word-break: break-all; user-select: all; }
1498
1523
  #inviteBox button { background: none; border: 1px solid rgba(255,255,255,.15); border-radius: 5px; color: inherit; cursor: pointer; font-size: 12px; padding: 2px 8px; flex: none; }
1499
1524
  #inviteBox button:hover { border-color: var(--amber); color: var(--amber); }
1500
- #secretsAdd { display: flex; gap: 8px; }
1501
- #secretsAdd input { flex: 1; min-width: 0; padding: 6px 8px; border-radius: 6px; border: 1px solid rgba(255,255,255,.15); background: rgba(0,0,0,.25); color: inherit; }
1502
- #secretsAdd button { padding: 6px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,.15); background: rgba(255,255,255,.08); color: inherit; cursor: pointer; }
1525
+ #secretsAdd { display: flex; flex-direction: column; gap: 8px; }
1526
+ #secretsAdd .sec-add-row { display: flex; gap: 8px; }
1527
+ #secretsAdd input { flex: 1; min-width: 0; padding: 6px 8px; border-radius: 6px; border: 1px solid rgba(255,255,255,.15); background: rgba(0,0,0,.25); color: inherit; font-size: 16px; }
1528
+ #secretsAdd #secretSubmit { flex: none; padding: 6px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,.15); background: rgba(255,255,255,.08); color: inherit; cursor: pointer; }
1529
+ #secretKey[readonly] { opacity: .65; }
1530
+ .sec-attach { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; }
1531
+ .sec-attach-label { font-size: 12px; color: var(--text-dim); }
1532
+ .sec-profiles { display: flex; flex-wrap: wrap; gap: 6px; }
1533
+ .sec-profiles label { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; padding: 3px 9px; border: 1px solid var(--line); border-radius: 14px; cursor: pointer; }
1534
+ .sec-profiles label:hover { border-color: var(--amber); }
1535
+ .sec-profiles input { accent-color: var(--amber); margin: 0; }
1536
+ .sec-reload { margin-top: 10px; border: 1px solid var(--amber); border-radius: 8px; padding: 9px 11px; background: color-mix(in srgb, var(--amber) 8%, transparent); font-size: 12.5px; display: flex; align-items: center; gap: 10px; }
1537
+ .sec-reload span { flex: 1; }
1538
+ .sec-reload button { flex: none; padding: 5px 11px; border-radius: 6px; border: 1px solid var(--amber); background: none; color: var(--amber); cursor: pointer; font: inherit; font-size: 12px; }
1539
+ .sec-reload button:hover { background: color-mix(in srgb, var(--amber) 15%, transparent); }
1503
1540
  </style>
1504
1541
  </head>
1505
1542
  <body>
@@ -1609,10 +1646,18 @@
1609
1646
  <div class="pin-body">
1610
1647
  <ul id="secretsList"></ul>
1611
1648
  <form id="secretsAdd">
1612
- <input id="secretKey" placeholder="NAME" autocomplete="off" spellcheck="false" />
1613
- <input id="secretVal" placeholder="value" autocomplete="off" spellcheck="false" />
1614
- <button type="submit">Add</button>
1649
+ <div class="sec-add-row">
1650
+ <input id="secretKey" placeholder="NAME" autocomplete="off" spellcheck="false" />
1651
+ <input id="secretVal" placeholder="value" autocomplete="off" spellcheck="false" />
1652
+ <button type="submit" id="secretSubmit">Add</button>
1653
+ </div>
1654
+ <div id="secretAttach" class="sec-attach" hidden>
1655
+ <span class="sec-attach-label">Inject into profiles:</span>
1656
+ <div id="secretProfiles" class="sec-profiles"></div>
1657
+ <button type="button" id="secretEditCancel" class="linkish" hidden>cancel edit</button>
1658
+ </div>
1615
1659
  </form>
1660
+ <div id="secretsReload" class="sec-reload" hidden></div>
1616
1661
  </div>
1617
1662
  </div>
1618
1663
  </div>
@@ -1979,6 +2024,13 @@
1979
2024
  with the favicon, `shadokInner`); it pumps via CSS. -->
1980
2025
  <div id="thinking"><span class="tw-text">Shadok is pumping</span><span class="tw-shadok" aria-hidden="true"></span></div>
1981
2026
 
2027
+ <!-- Floating "jump to the latest": shown only when the active channel is
2028
+ scrolled up. Its bottom offset tracks the composer's height (JS). -->
2029
+ <button id="jumpBottom" type="button" hidden aria-label="Jump to the latest">
2030
+ <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path d="M12 5v13M6 12l6 6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
2031
+ Latest
2032
+ </button>
2033
+
1982
2034
  <form id="composer" hidden>
1983
2035
  <textarea id="promptInput" rows="1"
1984
2036
  placeholder="Write your prompt — Enter to send, Shift+Enter for a new line"></textarea>
@@ -2253,7 +2305,12 @@
2253
2305
  // (no more stolen scroll during live streaming). See pinBottom().
2254
2306
  el._stickBottom = true;
2255
2307
  el.addEventListener("scroll", () => {
2256
- el._stickBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
2308
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
2309
+ el._stickBottom = atBottom;
2310
+ if (tab === active) {
2311
+ if (atBottom) markCaughtUp(tab); // scrolled down to the end → all seen
2312
+ updateJumpButton();
2313
+ }
2257
2314
  });
2258
2315
 
2259
2316
  const btn = document.createElement("div");
@@ -2331,6 +2388,7 @@
2331
2388
  contextPct: null,
2332
2389
  muted: false, // no favicon pip, no title badge/blink, no chime
2333
2390
  transcriptEl: el,
2391
+ unreadSep: null, // the "new messages" separator element, when present
2334
2392
  profile: null, // the agent's "type": the profile applied at spawn
2335
2393
  crons: [], // labels of the scheduled prompts (the ⏰ on the tab)
2336
2394
  btnEl: btn, ledEl: led, nameEl: name, timerEl: timer,
@@ -3304,7 +3362,17 @@
3304
3362
  // Experimental terminal follows the active channel (re-seeds on switch).
3305
3363
  if (typeof syncTerm === "function") syncTerm();
3306
3364
  if (tab.status !== "setup") {
3307
- tab.transcriptEl.scrollTop = tab.transcriptEl.scrollHeight;
3365
+ // Resume at the "new" separator when this channel has unread messages
3366
+ // (you left off there), otherwise at the latest. Either way the composer
3367
+ // clears the mood on activate; the separator lingers until you read down.
3368
+ if (tab.unreadSep) {
3369
+ tab.unreadSep.scrollIntoView({ block: "start" });
3370
+ tab.transcriptEl._stickBottom = false;
3371
+ } else {
3372
+ tab.transcriptEl.scrollTop = tab.transcriptEl.scrollHeight;
3373
+ tab.transcriptEl._stickBottom = true;
3374
+ }
3375
+ updateJumpButton();
3308
3376
  promptInput.focus();
3309
3377
  }
3310
3378
  }
@@ -3796,6 +3864,53 @@
3796
3864
  if (tab === active && el._stickBottom !== false) el.scrollTop = el.scrollHeight;
3797
3865
  }
3798
3866
 
3867
+ // ── "Jump to the latest" + the unread separator ─────────────────────────
3868
+ // A channel is "caught up" when you're viewing it AND at its bottom. Anything
3869
+ // that arrives otherwise (another channel in the background, or this one while
3870
+ // you scrolled up) is unread: a separator is dropped before the first such
3871
+ // message, and the floating button offers a one-click way down.
3872
+ const jumpBottom = $("jumpBottom");
3873
+ function isCaughtUp(tab) {
3874
+ return tab === active && tab.transcriptEl._stickBottom !== false;
3875
+ }
3876
+ /** Drop a "new" separator before `turnEl` when the channel isn't caught up and
3877
+ * doesn't already carry one. */
3878
+ function markUnreadBoundary(tab, turnEl) {
3879
+ if (tab.unreadSep || isCaughtUp(tab) || !turnEl) return;
3880
+ const sep = document.createElement("div");
3881
+ sep.className = "unread-sep";
3882
+ sep.setAttribute("role", "separator");
3883
+ const label = document.createElement("span");
3884
+ label.textContent = "new";
3885
+ sep.appendChild(label);
3886
+ tab.transcriptEl.insertBefore(sep, turnEl);
3887
+ tab.unreadSep = sep;
3888
+ }
3889
+ /** Reached the bottom of a channel → its new messages are now seen. */
3890
+ function markCaughtUp(tab) {
3891
+ if (tab.unreadSep) { tab.unreadSep.remove(); tab.unreadSep = null; }
3892
+ if (tab.btnEl.classList.contains("unread")) setTabMood(tab, null);
3893
+ }
3894
+ /** Show the button only while the active channel is scrolled up; keep it clear
3895
+ * of the composer. */
3896
+ function updateJumpButton() {
3897
+ const el = active && active.transcriptEl;
3898
+ const composerH = $("composer").hidden ? 12 : $("composer").offsetHeight + 12;
3899
+ jumpBottom.style.bottom = composerH + "px";
3900
+ const up = !!el && !$("composer").hidden && el._stickBottom === false && el.scrollHeight - el.clientHeight > 60;
3901
+ jumpBottom.hidden = !up;
3902
+ }
3903
+ function goToLatest() {
3904
+ if (!active) return;
3905
+ const el = active.transcriptEl;
3906
+ el.scrollTop = el.scrollHeight;
3907
+ el._stickBottom = true;
3908
+ markCaughtUp(active);
3909
+ updateJumpButton();
3910
+ }
3911
+ jumpBottom.addEventListener("click", goToLatest);
3912
+ window.addEventListener("resize", updateJumpButton);
3913
+
3799
3914
  /* A message's date/time. Today → the time alone ("21:57"). Another day → date
3800
3915
  + time ("3 August, 21:57"), with the year when it differs — otherwise a
3801
3916
  resumed history spanning several days just showed "21:57", which misleads.
@@ -4817,6 +4932,7 @@
4817
4932
  // reconnect (laptop wake / dropped link) rebuilds the transcript instead
4818
4933
  // of appending a second copy of the whole history.
4819
4934
  t.transcriptEl.innerHTML = "";
4935
+ t.unreadSep = null; // the separator element went with the wipe
4820
4936
  addTurn(t, "system", "", "— resumed session history —");
4821
4937
  for (const h of msg.turns) {
4822
4938
  if (h.role === "user") {
@@ -4924,7 +5040,11 @@
4924
5040
  // who typed). Falling back to the origin still beats "elsewhere": a
4925
5041
  // scheduled run answering on its own has an explanation this way.
4926
5042
  closeActivity(t);
4927
- addTurn(t, "user", echoAuthorLabel(msg), msg.text, null, Date.now());
5043
+ {
5044
+ const b = addTurn(t, "user", echoAuthorLabel(msg), msg.text, null, Date.now());
5045
+ markUnreadBoundary(t, b.parentElement);
5046
+ }
5047
+ if (t === active) updateJumpButton();
4928
5048
  break;
4929
5049
  case "profile":
4930
5050
  // The desired / in-force pair. Broadcast to EVERY client: changing the
@@ -4972,7 +5092,9 @@
4972
5092
  // gluing the two bubbles into one wordless run (the CSS group rule
4973
5093
  // spares `.after-gap`).
4974
5094
  if (msg.afterInternal) b.parentElement.classList.add("after-gap");
5095
+ markUnreadBoundary(t, b.parentElement);
4975
5096
  }
5097
+ if (t === active) updateJumpButton();
4976
5098
  break;
4977
5099
  case "stream-tool":
4978
5100
  // A tool call, grouped into the open activity block.
@@ -5422,6 +5544,7 @@
5422
5544
  function autosizePrompt() {
5423
5545
  promptInput.style.height = "auto";
5424
5546
  promptInput.style.height = Math.min(promptInput.scrollHeight, 160) + "px";
5547
+ updateJumpButton(); // the composer grew/shrank — keep the button above it
5425
5548
  }
5426
5549
  promptInput.addEventListener("input", () => {
5427
5550
  autosizePrompt();
@@ -5743,24 +5866,122 @@
5743
5866
  }
5744
5867
  code.appendChild(tag);
5745
5868
  }
5869
+ const actions = document.createElement("div");
5870
+ actions.className = "s-actions";
5871
+ const edit = document.createElement("button");
5872
+ edit.type = "button"; edit.className = "s-edit"; edit.textContent = "✎";
5873
+ edit.title = "Update this secret's value / the profiles it feeds";
5874
+ edit.addEventListener("click", () => startEditSecret(k));
5746
5875
  const del = document.createElement("button");
5747
- del.textContent = "✕"; del.title = "Remove from vault";
5876
+ del.type = "button"; del.className = "s-del"; del.textContent = "✕"; del.title = "Remove from vault";
5748
5877
  del.addEventListener("click", async () => {
5749
5878
  await fetch("/secrets?name=" + encodeURIComponent(k), { method: "DELETE" });
5879
+ if (editingSecret === k) resetSecretForm();
5750
5880
  await loadVault(); renderSecrets();
5751
5881
  });
5752
- li.append(code, del);
5882
+ actions.append(edit, del);
5883
+ li.append(code, actions);
5753
5884
  ul.appendChild(li);
5754
5885
  }
5755
5886
  }
5887
+
5888
+ // ── Add / update a secret, and wire it to profiles ──────────────────────
5889
+ // The vault add form doubles as an editor: ✎ on a row loads the name (locked)
5890
+ // and the profiles it already feeds, so you can rotate the value or change
5891
+ // which profiles get it in one place.
5892
+ let editingSecret = null;
5893
+ /** Paint a checkbox per (non-managed) profile; `checked` is the set to tick. */
5894
+ function renderSecretProfiles(checked) {
5895
+ const box = $("secretProfiles");
5896
+ box.replaceChildren();
5897
+ const names = profileCache
5898
+ .map((p) => p.name)
5899
+ .filter((n) => !(window.isManagedProfile && window.isManagedProfile(n)))
5900
+ .sort();
5901
+ $("secretAttach").hidden = names.length === 0;
5902
+ for (const n of names) {
5903
+ const label = document.createElement("label");
5904
+ const cb = document.createElement("input");
5905
+ cb.type = "checkbox"; cb.value = n; cb.checked = checked.has(n);
5906
+ const span = document.createElement("span");
5907
+ span.textContent = n;
5908
+ label.append(cb, span);
5909
+ box.appendChild(label);
5910
+ }
5911
+ }
5912
+ function checkedProfiles() {
5913
+ return [...$("secretProfiles").querySelectorAll("input:checked")].map((c) => c.value);
5914
+ }
5915
+ function resetSecretForm() {
5916
+ editingSecret = null;
5917
+ $("secretKey").value = ""; $("secretKey").readOnly = false;
5918
+ $("secretVal").value = ""; $("secretVal").placeholder = "value";
5919
+ $("secretSubmit").textContent = "Add";
5920
+ $("secretEditCancel").hidden = true;
5921
+ renderSecretProfiles(new Set());
5922
+ }
5923
+ function startEditSecret(name) {
5924
+ editingSecret = name;
5925
+ $("secretKey").value = name; $("secretKey").readOnly = true;
5926
+ $("secretVal").value = ""; $("secretVal").placeholder = "new value (blank = keep)";
5927
+ $("secretSubmit").textContent = "Update";
5928
+ $("secretEditCancel").hidden = false;
5929
+ const users = window.secretUsers ? window.secretUsers(profileCache, name) : [];
5930
+ renderSecretProfiles(new Set(users));
5931
+ $("secretVal").focus();
5932
+ }
5933
+ /** Write the checkbox choices back onto every affected profile (add where now
5934
+ * ticked, remove where now unticked), preserving each profile's other fields —
5935
+ * crucially its systemPrompt, or omitting it would silently un-edit the role. */
5936
+ async function syncSecretToProfiles(name, wanted) {
5937
+ const want = new Set(wanted);
5938
+ const changed = [];
5939
+ for (const p of profileCache) {
5940
+ if (window.isManagedProfile && window.isManagedProfile(p.name)) continue;
5941
+ const has = (p.secrets || []).includes(name);
5942
+ if (want.has(p.name) === has) continue; // no change for this profile
5943
+ const secrets = want.has(p.name)
5944
+ ? [...(p.secrets || []), name]
5945
+ : (p.secrets || []).filter((s) => s !== name);
5946
+ const body = { name: p.name, deny: p.deny, allow: p.allow, model: p.model, secrets };
5947
+ if (typeof p.systemPrompt === "string") body.systemPrompt = p.systemPrompt; // keep an edited prompt
5948
+ await fetch("/profiles", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
5949
+ changed.push(p.name);
5950
+ }
5951
+ if (changed.length) await loadProfilesInto();
5952
+ return changed;
5953
+ }
5954
+ /** Offer to reload the LIVE agents whose profile now feeds this secret — the
5955
+ * value only reaches a process at spawn, so a running one needs a respawn. */
5956
+ function offerReload(secretName) {
5957
+ const users = new Set(window.secretUsers ? window.secretUsers(profileCache, secretName) : []);
5958
+ // Only LIVE agents: a stopped one will get the value when it next launches.
5959
+ const agents = tabs.filter((t) => users.has(t.profile) && t.ws && t.ws.readyState === WebSocket.OPEN);
5960
+ const box = $("secretsReload");
5961
+ box.replaceChildren();
5962
+ if (!agents.length) { box.hidden = true; return; }
5963
+ const txt = document.createElement("span");
5964
+ txt.textContent =
5965
+ agents.length === 1
5966
+ ? `1 running agent (${agents[0].nameEl.textContent}) uses “${secretName}”. Reload it to pick up the new value?`
5967
+ : `${agents.length} running agents use “${secretName}”. Reload them to pick up the new value?`;
5968
+ const go = document.createElement("button");
5969
+ go.type = "button"; go.textContent = "Reload " + (agents.length === 1 ? "it" : "all");
5970
+ go.addEventListener("click", () => { agents.forEach(reloadTab); box.hidden = true; });
5971
+ box.append(txt, go);
5972
+ box.hidden = false;
5973
+ }
5756
5974
  // profileCache feeds the "no profile uses it" warning: it must be fresh when
5757
5975
  // the panel opens, or we would show a stale diagnosis.
5758
5976
  $("secretsBtn").addEventListener("click", async () => {
5759
5977
  await Promise.all([loadVault(), loadProfilesInto()]);
5760
5978
  renderSecrets();
5979
+ resetSecretForm();
5980
+ $("secretsReload").hidden = true;
5761
5981
  $("secretsOverlay").hidden = false;
5762
5982
  $("secretKey").focus();
5763
5983
  });
5984
+ $("secretEditCancel").addEventListener("click", resetSecretForm);
5764
5985
  // The missing link: from the secrets, go and attach them to a profile.
5765
5986
  $("secretsToProfiles").addEventListener("click", () => {
5766
5987
  $("secretsOverlay").hidden = true;
@@ -5825,13 +6046,21 @@
5825
6046
  e.preventDefault();
5826
6047
  const name = $("secretKey").value.trim();
5827
6048
  const value = $("secretVal").value;
6049
+ const wanted = checkedProfiles();
5828
6050
  if (!name) return;
5829
- // `overwrite` because a person reading the vault's list and clicking Save is
5830
- // acting deliberately. The flag exists to stop an AGENT from replacing a
5831
- // credential silently — not to slow the human down.
5832
- await fetch("/secrets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, value, overwrite: true }) });
5833
- $("secretKey").value = ""; $("secretVal").value = "";
6051
+ // Editing an existing secret with the value left BLANK means "keep the value,
6052
+ // just change the profiles" — the vault is write-only, so we must not clobber
6053
+ // it with "". A new secret, or a value typed, is written (overwrite: true
6054
+ // because a human at the vault is acting deliberately; the flag only stops an
6055
+ // AGENT replacing a credential silently).
6056
+ const keepValue = editingSecret && value === "";
6057
+ if (!keepValue) {
6058
+ await fetch("/secrets", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, value, overwrite: true }) });
6059
+ }
6060
+ await syncSecretToProfiles(name, wanted); // refreshes profileCache
5834
6061
  await loadVault(); renderSecrets();
6062
+ offerReload(name); // propose reloading the live agents that now feed on it
6063
+ resetSecretForm();
5835
6064
  $("secretKey").focus();
5836
6065
  });
5837
6066
  // Parse a pasted .env-style block → [{name, value}]. Handles `export