@songsid/agend 2.1.6-beta.6 → 2.1.6-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/backend/claude-code.d.ts +25 -0
  2. package/dist/backend/claude-code.js +103 -0
  3. package/dist/backend/claude-code.js.map +1 -1
  4. package/dist/backend/types.d.ts +27 -0
  5. package/dist/backend/types.js.map +1 -1
  6. package/dist/channel/adapters/discord.d.ts +14 -0
  7. package/dist/channel/adapters/discord.js +59 -1
  8. package/dist/channel/adapters/discord.js.map +1 -1
  9. package/dist/channel/adapters/telegram.d.ts +6 -0
  10. package/dist/channel/adapters/telegram.js +36 -0
  11. package/dist/channel/adapters/telegram.js.map +1 -1
  12. package/dist/channel/tool-router.js +20 -1
  13. package/dist/channel/tool-router.js.map +1 -1
  14. package/dist/channel/types.d.ts +18 -0
  15. package/dist/config-validator.js +3 -0
  16. package/dist/config-validator.js.map +1 -1
  17. package/dist/connection-secrets.d.ts +96 -0
  18. package/dist/connection-secrets.js +25 -0
  19. package/dist/connection-secrets.js.map +1 -0
  20. package/dist/daemon.d.ts +38 -0
  21. package/dist/daemon.js +246 -80
  22. package/dist/daemon.js.map +1 -1
  23. package/dist/fleet-manager.d.ts +171 -0
  24. package/dist/fleet-manager.js +954 -5
  25. package/dist/fleet-manager.js.map +1 -1
  26. package/dist/outbound-schemas.js +1 -1
  27. package/dist/outbound-schemas.js.map +1 -1
  28. package/dist/provider-probe.d.ts +3 -0
  29. package/dist/provider-probe.js +12 -2
  30. package/dist/provider-probe.js.map +1 -1
  31. package/dist/provider-secret-registry.d.ts +98 -0
  32. package/dist/provider-secret-registry.js +334 -0
  33. package/dist/provider-secret-registry.js.map +1 -0
  34. package/dist/quickstart-api.d.ts +1 -1
  35. package/dist/quickstart-api.js +14 -4
  36. package/dist/quickstart-api.js.map +1 -1
  37. package/dist/secret-store.d.ts +25 -0
  38. package/dist/secret-store.js +165 -0
  39. package/dist/secret-store.js.map +1 -0
  40. package/dist/settings-api.d.ts +101 -0
  41. package/dist/settings-api.js +404 -4
  42. package/dist/settings-api.js.map +1 -1
  43. package/dist/types.d.ts +2 -0
  44. package/dist/ui/settings.html +171 -6
  45. package/dist/usage/providers.d.ts +8 -6
  46. package/dist/usage/providers.js +83 -9
  47. package/dist/usage/providers.js.map +1 -1
  48. package/dist/usage/usage-api.d.ts +12 -0
  49. package/dist/usage/usage-api.js +55 -0
  50. package/dist/usage/usage-api.js.map +1 -1
  51. package/package.json +1 -1
@@ -49,7 +49,7 @@
49
49
  button.danger:hover { background: #fef2f2; }
50
50
  button.sm { padding: 4px 9px; font-size: 12px; }
51
51
  label { display: block; font-size: 12px; font-weight: 500; color: var(--muted); margin: 10px 0 4px; }
52
- input[type=text], input[type=number], select { width: 100%; font-family: inherit; font-size: 13px; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: #fff; color: var(--fg); }
52
+ input[type=text], input[type=password], input[type=number], select { width: 100%; font-family: inherit; font-size: 13px; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: #fff; color: var(--fg); }
53
53
  input:focus, select:focus { outline: 2px solid #bfdbfe; border-color: var(--accent); }
54
54
  .edit { padding: 14px 16px; background: #fff; border-top: 1px solid var(--border); }
55
55
  .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
@@ -193,6 +193,7 @@
193
193
  <div class="sec-head"><h2>🔌 Connections &amp; Bots</h2><div class="grow"></div><button class="sm" id="newBotBtn" data-i18n="add">+ Add</button></div>
194
194
  <div id="newBot"></div>
195
195
  <div class="card" id="bots"></div>
196
+ <div class="card" id="providerSecrets" style="margin-top:12px"></div>
196
197
  </section>
197
198
 
198
199
  <section>
@@ -417,7 +418,7 @@
417
418
  }
418
419
 
419
420
  // ── State ──
420
- const state = { schema: { impacts: {}, order: ["now", "instance", "fleet"] }, fleet: {}, classic: {}, classicStatus: {}, classicLive: {}, live: {}, fleetUp: false, advFmt: "yaml", advEdit: false, collapsedGroups: new Set(), version: "", newAgentOpen: false, newBotOpen: false, pending: new Map() };
421
+ const state = { schema: { impacts: {}, order: ["now", "instance", "fleet"] }, fleet: {}, classic: {}, classicStatus: {}, classicLive: {}, live: {}, connections: [], providerSecrets: [], fleetUp: false, advFmt: "yaml", advEdit: false, collapsedGroups: new Set(), version: "", newAgentOpen: false, newBotOpen: false, pending: new Map() };
421
422
  const channels = () => state.fleet.channels || (state.fleet.channel ? [state.fleet.channel] : []);
422
423
  const channelIds = () => channels().map(c => c.id || c.type).filter(Boolean);
423
424
  const chLabel = (i) => i === 0 ? "primary" : "persona";
@@ -707,8 +708,10 @@
707
708
  catch (error) { result = { ok: false, body: { error: error.message } }; }
708
709
  if (!result?.ok) {
709
710
  showBanner(`${change.label}: ${result?.body?.error || t("applyFailed")}`, true);
711
+ change.cleanup?.();
710
712
  button.disabled = false; return;
711
713
  }
714
+ change.cleanup?.();
712
715
  state.pending.delete(key); updatePendingBar();
713
716
  }
714
717
  // The key is generated here, before the first attempt, and reused by every
@@ -1156,6 +1159,7 @@
1156
1159
  ? (ch.access.allowed_users).slice(0, 3).join(", ") + ((ch.access.allowed_users).length > 3 ? ` +${(ch.access.allowed_users).length - 3}` : "")
1157
1160
  : t("noAllowedUsers")),
1158
1161
  el("span", { class: "spacer" }),
1162
+ el("span", { class: "tag", title: "secret status" }, state.connections.find(c => (c.id || c.type) === (ch.id || ch.type))?.token_present ? "Token configured" : "Token missing"),
1159
1163
  el("span", { style: `color:${state.fleetUp ? "var(--green)" : "var(--red)"};font-size:13px` }, state.fleetUp ? "Connected" : "Problem"),
1160
1164
  el("button", { class: "sm ghost", onclick: () => {
1161
1165
  const form = botEditForm(ch, i);
@@ -1166,6 +1170,125 @@
1166
1170
  });
1167
1171
  }
1168
1172
 
1173
+ // Provider API keys use a separate registry endpoint. Unsupported providers
1174
+ // are intentionally rendered without an input: a key that cannot be
1175
+ // positively verified must never be accepted as an "unverified" write.
1176
+ function providerSecretInputAllowed(spec) { return spec?.verifier === "available"; }
1177
+ function renderProviderSecrets() {
1178
+ const host = $("providerSecrets"); if (!host) return;
1179
+ host.innerHTML = "";
1180
+ const rows = Array.isArray(state.providerSecrets) ? state.providerSecrets : [];
1181
+ if (!rows.length) { host.className = "card empty"; host.textContent = "No provider API-key verifiers available."; return; }
1182
+ host.className = "card";
1183
+ rows.forEach(spec => {
1184
+ const row = el("div", { class: "item" });
1185
+ const feedback = el("div", { class: "feedback" });
1186
+ const present = spec.token_present ? "Key configured" : "Not configured";
1187
+ if (!providerSecretInputAllowed(spec)) {
1188
+ row.append(el("div", { class: "item-row" }, el("span", { class: "name" }, spec.display_name), el("span", { class: "tag" }, "Unsupported verifier")), el("div", { class: "hint" }, "This provider cannot be safely verified here; no key input is offered."));
1189
+ host.append(row); return;
1190
+ }
1191
+ const input = el("input", { type: "password", autocomplete: "new-password", placeholder: present });
1192
+ const button = el("button", { class: "sm primary" }, "Verify & apply");
1193
+ button.onclick = async () => {
1194
+ const secret = input.value;
1195
+ if (!secret) { feedback.className = "feedback error"; feedback.textContent = "Enter a key first."; return; }
1196
+ button.disabled = true; feedback.className = "feedback"; feedback.textContent = "Verifying…";
1197
+ const key = crypto.randomUUID ? crypto.randomUUID() : `provider-${Date.now()}-${Math.random().toString(36).slice(2)}`;
1198
+ try {
1199
+ const verified = await api(`/api/settings/secrets/${encodeURIComponent(spec.id)}/verify`, { method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify({ secret, idempotency_key: key }) });
1200
+ if (!verified.ok) { feedback.className = "feedback error"; feedback.textContent = `✗ ${verified.body?.error || "verification failed"}`; return; }
1201
+ feedback.textContent = "Applying…";
1202
+ let result = await api(`/api/settings/secrets/${encodeURIComponent(spec.id)}/apply`, { method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify({ verification_id: verified.body?.verification_id, idempotency_key: key }) });
1203
+ let body = result.body || {};
1204
+ for (let n = 0; result.ok && body.job_id && body.result === "applying" && n < 60; n++) {
1205
+ await new Promise(resolve => setTimeout(resolve, 250));
1206
+ const status = await api(`/api/settings/secrets/${encodeURIComponent(spec.id)}/apply/${encodeURIComponent(body.job_id)}`);
1207
+ if (status.ok) body = status.body || body;
1208
+ }
1209
+ const labels = { applied_next_use: "✓ Saved — new processes will use it", reloaded: "✓ Applied", rolled_back: "↩ Rolled back", rollback_failed: "✗ Rollback failed" };
1210
+ const ok = body.result === "applied_next_use" || body.result === "reloaded";
1211
+ feedback.className = `feedback ${ok ? "" : "error"}`;
1212
+ feedback.textContent = labels[body.result] || `✗ ${body.error || "Apply failed"}`;
1213
+ if (ok) { input.value = ""; await reload(); }
1214
+ } finally { input.value = ""; button.disabled = false; }
1215
+ };
1216
+ row.append(el("div", { class: "item-row" }, el("span", { class: "name" }, spec.display_name), el("span", { class: "tag" }, present), el("span", { class: "spacer" }, input), button), feedback);
1217
+ host.append(row);
1218
+ });
1219
+ }
1220
+
1221
+ // Secret rotation is staged through the same Apply bar as every other
1222
+ // Settings mutation. The token is held only in this short-lived closure and
1223
+ // is cleared by the caller after the request settles; it never enters
1224
+ // localStorage, the URL, a label, or the staged change's display text.
1225
+ async function applyConnectionSecret(connectionId, secret, feedback) {
1226
+ const key = crypto.randomUUID ? crypto.randomUUID() : `secret-${Date.now()}-${Math.random().toString(36).slice(2)}`;
1227
+ feedback.className = "feedback"; feedback.textContent = "Verifying…";
1228
+ const verified = await api(`/api/settings/connections/${encodeURIComponent(connectionId)}/secret/verify`, {
1229
+ method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify({ secret, idempotency_key: key }),
1230
+ });
1231
+ if (!verified.ok) { feedback.className = "feedback error"; feedback.textContent = `✗ ${verified.body?.error || "verification failed"}`; return verified; }
1232
+ feedback.textContent = "Applying…";
1233
+ const applied = await api(`/api/settings/connections/${encodeURIComponent(connectionId)}/secret/apply`, {
1234
+ method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify({ verification_id: verified.body?.verification_id, idempotency_key: key }),
1235
+ });
1236
+ if (!applied.ok && applied.status !== 409) { feedback.className = "feedback error"; feedback.textContent = `✗ ${applied.body?.error || "apply failed"}`; return applied; }
1237
+ let result = applied.body || {};
1238
+ if (result.job_id && result.result === "applying") {
1239
+ for (let n = 0; n < 60; n++) {
1240
+ await new Promise(resolve => setTimeout(resolve, 250));
1241
+ const status = await api(`/api/settings/connections/${encodeURIComponent(connectionId)}/secret/apply/${encodeURIComponent(result.job_id)}`);
1242
+ if (status.ok) { result = status.body || result; if (result.result !== "applying") break; }
1243
+ }
1244
+ }
1245
+ const labels = { applying: "…Still applying — check status", applied: "✓ Applied", restart_required: "↻ Saved — restart required", rolled_back: "↩ Rolled back", rollback_failed: "✗ Rollback failed" };
1246
+ feedback.className = `feedback ${result.result === "applied" || result.result === "restart_required" ? "" : "error"}`;
1247
+ feedback.textContent = labels[result.result] || "✗ Unknown apply result";
1248
+ return { ok: result.result === "applied" || result.result === "restart_required", status: result.result === "applied" ? 200 : 409, body: result };
1249
+ }
1250
+
1251
+ // Binding changes use a separate verified challenge. They never go through
1252
+ // PUT /fleet/channels: the provider probe proves the target first, and the
1253
+ // apply job updates only group/general coordinates after the replacement
1254
+ // adapter is ready.
1255
+ async function verifyAndApplyConnectionBinding(connectionId, groupId, generalChannelId, feedback) {
1256
+ const key = crypto.randomUUID ? crypto.randomUUID() : `binding-${Date.now()}-${Math.random().toString(36).slice(2)}`;
1257
+ const binding = { group_id: String(groupId).trim(), general_channel_id: String(generalChannelId || "").trim() || null };
1258
+ feedback.className = "feedback"; feedback.textContent = "Verifying target…";
1259
+ const verified = await api(`/api/settings/connections/${encodeURIComponent(connectionId)}/binding/verify`, {
1260
+ method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify({ ...binding, idempotency_key: key }),
1261
+ });
1262
+ if (!verified.ok) { feedback.className = "feedback error"; feedback.textContent = `✗ ${verified.body?.error || "binding verification failed"}`; return verified; }
1263
+ const probe = verified.body?.probe || {};
1264
+ const target = [probe.group_name || binding.group_id, probe.channel_name || probe.channel_id].filter(Boolean).join(" / ");
1265
+ const perms = [probe.can_view ? "view" : null, probe.can_send ? "send" : null, probe.can_manage_topics ? "topics" : null].filter(Boolean).join(", ");
1266
+ feedback.textContent = `✓ Verified ${target}${perms ? ` (${perms})` : ""}`;
1267
+ if (!window.confirm(`Rebind this connection to ${target || binding.group_id}?`)) {
1268
+ feedback.textContent = "Verified; not applied.";
1269
+ return { ok: false, status: 409, body: { result: "verified" } };
1270
+ }
1271
+ feedback.textContent = "Applying binding…";
1272
+ const applied = await api(`/api/settings/connections/${encodeURIComponent(connectionId)}/binding/apply`, {
1273
+ method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify({ verification_id: verified.body?.verification_id, idempotency_key: key }),
1274
+ });
1275
+ if (!applied.ok && applied.status !== 409) { feedback.className = "feedback error"; feedback.textContent = `✗ ${applied.body?.error || "binding apply failed"}`; return applied; }
1276
+ let result = applied.body || {};
1277
+ if (result.job_id && result.result === "applying") {
1278
+ for (let n = 0; n < 60; n++) {
1279
+ await new Promise(resolve => setTimeout(resolve, 250));
1280
+ const status = await api(`/api/settings/connections/${encodeURIComponent(connectionId)}/binding/apply/${encodeURIComponent(result.job_id)}`);
1281
+ if (status.ok) { result = status.body || result; if (result.result !== "applying") break; }
1282
+ }
1283
+ }
1284
+ const labels = { applied: "✓ Binding applied", rolled_back: "↩ Binding rolled back", rollback_failed: "✗ Binding rollback failed", applying: "…Still applying — check status" };
1285
+ const ok = result.result === "applied";
1286
+ feedback.className = `feedback ${ok ? "" : "error"}`;
1287
+ feedback.textContent = labels[result.result] || "✗ Unknown binding result";
1288
+ if (ok) await reload();
1289
+ return { ok, status: ok ? 200 : 409, body: result };
1290
+ }
1291
+
1169
1292
  function botEditForm(ch, i) {
1170
1293
  ch.access = ch.access || { mode: "locked", allowed_users: [] };
1171
1294
  ch.access.allowed_users = ch.access.allowed_users || [];
@@ -1175,18 +1298,54 @@
1175
1298
  const fMode = select(ch.access.mode || "locked", ["open", "locked", "pairing"]);
1176
1299
  const users = [...ch.access.allowed_users];
1177
1300
  const accessFeedback = el("div", { class: "feedback" });
1301
+ const tokenInput = el("input", { type: "password", autocomplete: "new-password", placeholder: "Paste a new bot token to rotate it" });
1302
+ const secretFeedback = el("div", { class: "feedback" });
1303
+ const bindingFeedback = el("div", { class: "feedback" });
1304
+ const fGroup = el("input", { type: "text", value: ch.group_id != null ? String(ch.group_id) : "", autocomplete: "off" });
1305
+ const fGeneral = el("input", { type: "text", value: ch.options?.general_channel_id != null ? String(ch.options.general_channel_id) : "", autocomplete: "off", placeholder: type === "discord" ? "General channel id (optional)" : "Forum topic id (optional)" });
1306
+ const bindingVerify = el("button", { class: "sm primary", onclick: async () => {
1307
+ if (!fGroup.value.trim()) { bindingFeedback.className = "feedback error"; bindingFeedback.textContent = "✗ group/guild id required"; return; }
1308
+ await verifyAndApplyConnectionBinding(connectionId, fGroup.value, fGeneral.value, bindingFeedback);
1309
+ } }, "Verify new binding → Confirm rebind");
1310
+ const connectionId = ch.id || ch.type || chLabel(i);
1311
+ let pendingSecret = "";
1312
+ const stageSecret = () => {
1313
+ const secret = tokenInput.value;
1314
+ if (!secret) { secretFeedback.className = "feedback error"; secretFeedback.textContent = "✗ token required"; return; }
1315
+ pendingSecret = secret;
1316
+ tokenInput.value = "";
1317
+ stageChange(`secret:${connectionId}`, {
1318
+ // Secret rotation is an immediate adapter operation. Use the server's
1319
+ // published impact ordering rather than duplicating an impact literal
1320
+ // in this page (hot/cold parity guards reject such copies).
1321
+ label: `Rotate ${type} token`, impact: state.schema.order[0],
1322
+ apply: async () => {
1323
+ const value = pendingSecret; pendingSecret = "";
1324
+ try { return await applyConnectionSecret(connectionId, value, secretFeedback); }
1325
+ finally { pendingSecret = ""; }
1326
+ },
1327
+ cleanup: () => { pendingSecret = ""; },
1328
+ });
1329
+ secretFeedback.className = "feedback"; secretFeedback.textContent = "Queued — click Apply changes.";
1330
+ };
1178
1331
  const validateAccess = () => setValidation(fMode, accessFeedback, "", fMode.value === "locked" && users.length === 0 ? t("accessLockedEmpty") : "");
1179
1332
  fMode.addEventListener("change", validateAccess);
1180
1333
  box.append(
1181
1334
  el("div", { class: "grid2" },
1182
1335
  el("div", {}, el("label", {}, t("accessMode"), impact("fleet.channel.access.mode")), fMode, accessFeedback),
1183
1336
  el("div", {}, el("label", {}, t("allowedUsers"), impact("fleet.channel.access.allowed_users")), chipList(users, validateAccess, "user id"))),
1184
- // Only the field that platform actually has, the way quickstart asks.
1185
1337
  el("div", { class: "field" },
1186
1338
  el("label", {}, type === "discord" ? t("guildIdField") : t("groupIdField"), impact("fleet.channels")),
1187
- el("div", { class: "sub" }, ch.group_id ? String(ch.group_id) : t("notSet"))),
1339
+ fGroup,
1340
+ el("label", {}, type === "discord" ? "General channel id" : "Forum topic id"), fGeneral,
1341
+ el("div", { class: "hint" }, "The target is checked with the existing bot token before any binding is changed."),
1342
+ el("div", { class: "actions" }, bindingVerify, bindingFeedback)),
1188
1343
  drawer(t("advancedSection"),
1189
1344
  el("div", { class: "field" }, el("label", {}, t("tokenEnv")), el("div", { class: "sub" }, ch.bot_token_env || "(no token env)")),
1345
+ el("div", { class: "field" },
1346
+ el("label", {}, "Bot token (never stored in the browser)"), tokenInput,
1347
+ el("div", { class: "hint" }, "The token is verified by AgEnD, then applied by replacing the running adapter. It is never echoed."),
1348
+ el("div", { class: "actions" }, el("button", { class: "sm primary", onclick: stageSecret }, "Verify & apply token"), secretFeedback)),
1190
1349
  el("div", { class: "actions" },
1191
1350
  el("button", { class: "sm danger", onclick: () => { closeModal(); delBot(i); } }, t("deleteBot")))),
1192
1351
  );
@@ -1639,7 +1798,7 @@
1639
1798
  }
1640
1799
 
1641
1800
  // ── Load / render ──
1642
- function renderAll() { renderAgents(); renderClassic(); renderBots(); renderGeneral(); renderAdvanced(); applyStaticI18n(); }
1801
+ function renderAll() { renderAgents(); renderClassic(); renderBots(); renderProviderSecrets(); renderGeneral(); renderAdvanced(); applyStaticI18n(); }
1643
1802
  async function reloadLive() {
1644
1803
  const f = await api("/api/fleet");
1645
1804
  state.fleetUp = f.ok;
@@ -1671,6 +1830,12 @@
1671
1830
  if (!f.ok) { $("banner").style.display = "block"; $("banner").style.background = "#fef2f2"; $("banner").style.color = "var(--red)"; $("banner").textContent = t("configLoadFailed"); return; }
1672
1831
  state.fleet = f.body || {};
1673
1832
  const c = await api("/api/settings/classic"); state.classic = c.body || {};
1833
+ const connections = await api("/api/settings/connections");
1834
+ state.connections = connections.ok && Array.isArray(connections.body) ? connections.body : [];
1835
+ const providerSecrets = await api("/api/settings/provider-secrets");
1836
+ const providerHost = $("providerSecrets");
1837
+ if (providerHost) providerHost.style.display = providerSecrets.ok ? "" : "none";
1838
+ state.providerSecrets = providerSecrets.ok && Array.isArray(providerSecrets.body) ? providerSecrets.body : [];
1674
1839
  await reloadLive();
1675
1840
  renderAll();
1676
1841
  }
@@ -1692,7 +1857,7 @@
1692
1857
  document.addEventListener("keydown", e => { if (e.key === "Escape" && modalState) closeModal(); });
1693
1858
  $("wizardBtn").onclick = () => openWizard();
1694
1859
  $("applyChanges").onclick = applyPendingChanges;
1695
- $("discardChanges").onclick = async () => { state.pending.clear(); updatePendingBar(); await reload(); };
1860
+ $("discardChanges").onclick = async () => { for (const change of state.pending.values()) change.cleanup?.(); state.pending.clear(); updatePendingBar(); await reload(); };
1696
1861
 
1697
1862
  reload();
1698
1863
  })();
@@ -140,16 +140,18 @@ type UsageProvider = {
140
140
  name: string;
141
141
  fetch: (storeHome?: string) => Promise<Omit<ProviderUsage, "id" | "name">>;
142
142
  };
143
+ type UsageFleetConfig = Parameters<typeof listConfiguredProfiles>[0];
143
144
  /**
144
145
  * One row per subscription, not one row per backend.
145
146
  *
146
- * A fleet running two kiro logins has two quotas, and a single row could only
147
- * ever show one of them — or worse, add them together and show a number that is
148
- * true of neither. Each configured credential profile reads its own store and
149
- * gets its own row; a backend with no profiles is left exactly as it was, one
150
- * row reading the shared login.
147
+ * A fleet running two kiro/codex logins has two quotas, and a single row could
148
+ * only ever show one of them — or worse, add them together and show a number
149
+ * that is true of neither. Each effective credential profile reads its own
150
+ * store and gets its own row; a mixed fleet also keeps one row for effective
151
+ * legacy shared bindings. Instance-private homes are never scanned: their
152
+ * auth files point at these same logical sources.
151
153
  */
152
- export declare function providersForConfig(config: Parameters<typeof listConfiguredProfiles>[0], base?: UsageProvider[]): UsageProvider[];
154
+ export declare function providersForConfig(config: UsageFleetConfig, base?: UsageProvider[]): UsageProvider[];
153
155
  /** Test seam: a provider that hangs cannot be simulated with real vendors. */
154
156
  export declare function setUsageProvidersForTests(providers: UsageProvider[] | null): void;
155
157
  /**
@@ -23,7 +23,7 @@ import { readFileSync } from "node:fs";
23
23
  import { join } from "node:path";
24
24
  import { getAgendHome } from "../paths.js";
25
25
  import { loadFleetConfig } from "../config.js";
26
- import { credentialHomeSpec, credentialProfileStoreHome, listConfiguredProfiles, } from "../backend/credential-profile.js";
26
+ import { credentialHomeSpec, credentialProfileStoreHome, instanceCredentialProfile, } from "../backend/credential-profile.js";
27
27
  import { readKiroAuthTokens } from "../backend/kiro-auth-store.js";
28
28
  import { homedir } from "node:os";
29
29
  import { getProviderRateLimit } from "./provider-alerts.js";
@@ -1099,27 +1099,101 @@ const DEFAULT_PROVIDERS = [
1099
1099
  /**
1100
1100
  * One row per subscription, not one row per backend.
1101
1101
  *
1102
- * A fleet running two kiro logins has two quotas, and a single row could only
1103
- * ever show one of them — or worse, add them together and show a number that is
1104
- * true of neither. Each configured credential profile reads its own store and
1105
- * gets its own row; a backend with no profiles is left exactly as it was, one
1106
- * row reading the shared login.
1102
+ * A fleet running two kiro/codex logins has two quotas, and a single row could
1103
+ * only ever show one of them — or worse, add them together and show a number
1104
+ * that is true of neither. Each effective credential profile reads its own
1105
+ * store and gets its own row; a mixed fleet also keeps one row for effective
1106
+ * legacy shared bindings. Instance-private homes are never scanned: their
1107
+ * auth files point at these same logical sources.
1107
1108
  */
1108
1109
  export function providersForConfig(config, base = DEFAULT_PROVIDERS) {
1109
1110
  return base.flatMap(provider => {
1110
1111
  const backendName = usageBackendForProviderId(provider.id);
1111
1112
  if (!backendName || !credentialHomeSpec(backendName))
1112
1113
  return [provider];
1113
- const profiles = listConfiguredProfiles(config, backendName);
1114
+ const profiles = effectiveCredentialProfiles(config, backendName);
1114
1115
  if (profiles.length === 0)
1115
1116
  return [provider];
1116
- return profiles.map(profile => ({
1117
+ // A fleet can mix profiled and legacy instances. The old expansion
1118
+ // returned only profile rows whenever *any* profile existed, which hid the
1119
+ // shared subscription from a running legacy instance. Include the shared
1120
+ // source only when an effective binding actually uses it; active-provider
1121
+ // filtering then removes sources that are stopped or otherwise unused.
1122
+ const rows = [];
1123
+ // Classic channels are stored separately from fleet.yaml and always use
1124
+ // the shared Codex login today. Keep the shared Codex row available so
1125
+ // active-provider filtering can retain that binding; unlike filesystem
1126
+ // scanning this row is harmless when no classic/shared instance is live.
1127
+ if (provider.id === "codex" || hasEffectiveSharedBinding(config, backendName)) {
1128
+ rows.push({
1129
+ id: provider.id,
1130
+ name: provider.id === "codex" ? `${provider.name} (default)` : provider.name,
1131
+ fetch: () => provider.fetch(),
1132
+ });
1133
+ }
1134
+ rows.push(...profiles.map(profile => ({
1117
1135
  id: `${provider.id}:${profile}`,
1118
1136
  name: `${provider.name} (${profile})`,
1119
1137
  fetch: () => provider.fetch(credentialProfileStoreHome(getAgendHome(), backendName, profile)),
1120
- }));
1138
+ })));
1139
+ return rows;
1121
1140
  });
1122
1141
  }
1142
+ /**
1143
+ * Whether at least one effective fleet binding uses the legacy shared login.
1144
+ *
1145
+ * This deliberately follows effective backend/profile inheritance instead of
1146
+ * looking at directories on disk. Instance-private Codex homes are config
1147
+ * homes whose auth file points at this same shared/profile source; scanning
1148
+ * them would duplicate one quota and make usage depend on stale instance
1149
+ * directories. A missing/unknown config keeps the legacy shared row for
1150
+ * backwards compatibility.
1151
+ */
1152
+ function hasEffectiveSharedBinding(config, backendName) {
1153
+ if (!config)
1154
+ return true;
1155
+ const defaults = config.defaults;
1156
+ const instances = config.instances ?? {};
1157
+ let sawEffectiveBinding = false;
1158
+ for (const instance of Object.values(instances)) {
1159
+ const backend = instance.backend ?? defaults?.backend;
1160
+ if (backend !== backendName)
1161
+ continue;
1162
+ sawEffectiveBinding = true;
1163
+ if (!instanceCredentialProfile(instance, defaults, backendName))
1164
+ return true;
1165
+ }
1166
+ // A defaults-only fleet has one effective shared binding when the default
1167
+ // backend is this provider and no profile was configured. With no known
1168
+ // binding at all, preserve the pre-profile shared-provider behaviour.
1169
+ if (!sawEffectiveBinding) {
1170
+ if (defaults?.backend === backendName && !instanceCredentialProfile(undefined, defaults, backendName))
1171
+ return true;
1172
+ return Object.keys(instances).length === 0;
1173
+ }
1174
+ return false;
1175
+ }
1176
+ /** Profiles on effective bindings only (not dormant backend_options on another backend). */
1177
+ function effectiveCredentialProfiles(config, backendName) {
1178
+ if (!config)
1179
+ return [];
1180
+ const found = new Set();
1181
+ const defaults = config.defaults;
1182
+ for (const instance of Object.values(config.instances ?? {})) {
1183
+ const backend = instance.backend ?? defaults?.backend;
1184
+ if (backend !== backendName)
1185
+ continue;
1186
+ const profile = instanceCredentialProfile(instance, defaults, backendName);
1187
+ if (profile)
1188
+ found.add(profile);
1189
+ }
1190
+ if (Object.keys(config.instances ?? {}).length === 0 && defaults?.backend === backendName) {
1191
+ const profile = instanceCredentialProfile(undefined, defaults, backendName);
1192
+ if (profile)
1193
+ found.add(profile);
1194
+ }
1195
+ return [...found].sort();
1196
+ }
1123
1197
  /**
1124
1198
  * The fleet configuration, for working out which subscriptions exist.
1125
1199
  *