@nanocollective/roster 0.1.0-alpha.27 → 0.1.0-alpha.28

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/dist/cli.js CHANGED
@@ -6608,15 +6608,19 @@ async function portalCommand(argv) {
6608
6608
  return;
6609
6609
  }
6610
6610
  const agentId = String(org.agent?.id ?? org.agent ?? "claude-code-action");
6611
- const [plan2, orgSecret] = await Promise.all([
6611
+ const [plan2, orgSecret, onRepos] = await Promise.all([
6612
6612
  planCredential(org.org, name, brains),
6613
- readOrgSecret(org.org, name)
6613
+ readOrgSecret(org.org, name),
6614
+ Promise.all(brains.map((b) => api(`repos/${b}/actions/secrets/${name}`).then((r) => r.ok)))
6614
6615
  ]);
6616
+ const repoSecrets = brains.filter((_, i) => onRepos[i]);
6615
6617
  json(res, {
6616
6618
  name,
6617
6619
  brains,
6618
6620
  plan: plan2,
6619
6621
  orgSecret,
6622
+ repoSecrets,
6623
+ stored: Boolean(orgSecret) || brains.length > 0 && repoSecrets.length === brains.length,
6620
6624
  howTo: AGENTS.find((a) => a.id === agentId)?.howTo
6621
6625
  });
6622
6626
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanocollective/roster",
3
- "version": "0.1.0-alpha.27",
3
+ "version": "0.1.0-alpha.28",
4
4
  "description": "An agent-run org, powered by GitHub. Scaffold AI staff members whose brain is a repo.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -285,3 +285,7 @@ dialog.sheet[open]{animation:dlgin .18s var(--ease)}
285
285
  dialog.sheet::backdrop{background:rgba(0,0,0,.35);backdrop-filter:blur(8px)}
286
286
  .sheetbody{padding:22px 24px 26px;overflow:auto;max-height:calc(100vh - 50px)}
287
287
  .sheettitle{margin:0 0 14px;font:600 18px var(--sans);letter-spacing:-.02em}
288
+ .sheetloading{display:grid;place-items:center;min-height:260px}
289
+ .spinner{width:22px;height:22px;border-radius:50%;border:2px solid var(--line);border-top-color:var(--accent);
290
+ animation:spin .7s linear infinite}
291
+ @keyframes spin{to{transform:rotate(360deg)}}
@@ -183,3 +183,11 @@
183
183
  .pastenotes .md{border-left:3px solid var(--line);padding-left:12px}
184
184
  .pastefile .diff{white-space:pre-wrap;overflow-wrap:anywhere}
185
185
  .warnline{margin:10px 0 0;padding:8px 11px;border-radius:var(--r);background:color-mix(in srgb,var(--warn) 12%,transparent);color:var(--ink);font-size:13px}
186
+
187
+ /* A single-line text box, matching the question fields. */
188
+ .textfield{width:100%;background:var(--bg-elev);border:1px solid var(--border-strong);color:var(--ink);
189
+ border-radius:var(--r);padding:8px 10px;font:13px var(--sans);outline:none}
190
+ .textfield:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
191
+ .credform{display:grid;gap:10px}
192
+ .credform .row{margin:0}
193
+ .okline{margin:0 0 8px;color:var(--accent);font:600 13px var(--sans)}
@@ -0,0 +1,18 @@
1
+ /* How many requests the page has open. The setup dialog waits for this to reach zero before it
2
+ * shows itself, because the list it draws is only final once every answer is back. */
3
+
4
+ let open = 0;
5
+
6
+ if (typeof globalThis.fetch === "function" && !globalThis.fetch.counted) {
7
+ const real = globalThis.fetch.bind(globalThis);
8
+ const counted = (...args) => {
9
+ open++;
10
+ return real(...args).finally(() => {
11
+ open--;
12
+ });
13
+ };
14
+ counted.counted = true;
15
+ globalThis.fetch = counted;
16
+ }
17
+
18
+ export const pending = () => open;
@@ -12,11 +12,11 @@ export function credentialPanel(opts = {}) {
12
12
  if (!opts.bare) box.append(el("b", { textContent: "Agent credential" }));
13
13
  const body = el("div");
14
14
  box.append(body);
15
- body.append(el("p", { className: "sub", textContent: "Looking at where it would go…" }));
15
+ body.append(el("p", { className: "sub", textContent: "Checking…" }));
16
16
 
17
17
  getCredential()
18
18
  .then((c) => {
19
- opts.onStatus?.(Boolean(c.orgSecret));
19
+ opts.onStatus?.(Boolean(c.stored ?? c.orgSecret));
20
20
  draw(body, c, opts);
21
21
  })
22
22
  .catch((err) => body.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) })));
@@ -25,74 +25,60 @@ export function credentialPanel(opts = {}) {
25
25
 
26
26
  function draw(body, c, opts = {}) {
27
27
  body.replaceChildren();
28
- if (c.howTo) body.append(el("p", { textContent: "Where to get one: " + c.howTo }));
29
28
 
30
29
  if (!(c.brains ?? []).length) {
31
30
  // Inside a step, the step's own hint already says when this opens.
32
- if (opts.bare) return;
33
- body.append(
34
- el("p", {
35
- className: "sub",
36
- textContent:
37
- "Available after your first hire. You only add it once.",
38
- }),
39
- );
31
+ if (!opts.bare) body.append(el("p", { className: "sub", textContent: "Available after your first hire. You only add it once." }));
40
32
  return;
41
33
  }
42
34
 
43
- if (c.orgSecret) {
44
- body.append(
45
- el("p", {
46
- className: "sub",
47
- textContent:
48
- c.name + " is already set. Paste a new one only to replace it.",
49
- }),
50
- );
51
- }
52
-
53
- body.append(
54
- el("p", {
55
- className: "sub",
56
- textContent:
57
- (c.plan.mode === "org"
58
- ? "Stored as one org secret, " + c.name + ", shared with " + c.brains.join(", ") + ". "
59
- : "Stored as " + c.name + " on each of " + c.brains.join(", ") + ", because " + c.plan.reason + ". ") +
60
- "It's never written to disk.",
61
- }),
35
+ const form = el("div", { className: "credform" });
36
+ const input = el("input", { type: "password", placeholder: "Paste it here", autocomplete: "off", className: "textfield" });
37
+ const go = el("button", { className: "btn primary", textContent: "Save" });
38
+ const out = el("div");
39
+ form.append(
40
+ ...(c.howTo ? [el("p", { className: "sub", textContent: "To get one, " + c.howTo })] : []),
41
+ input,
42
+ el("div", { className: "row" }, [go]),
43
+ out,
62
44
  );
63
45
 
64
- const input = el("input", { type: "password", placeholder: c.name, autocomplete: "off" });
65
- input.style.width = "100%";
66
- const go = el("button", { className: "btn primary", textContent: "Store it" });
67
- const out = el("div", { style: "margin-top:9px" });
68
- body.append(input, el("div", { className: "row", style: "margin-top:9px" }, [go]), out);
46
+ // Stored already, on the org or on every brain repo: say so, and keep replacing it one click away.
47
+ if (c.stored ?? c.orgSecret) {
48
+ const replace = el("button", { className: "ghbtn", textContent: "Replace it" });
49
+ replace.onclick = () => {
50
+ replace.remove();
51
+ form.hidden = false;
52
+ input.focus?.();
53
+ };
54
+ form.hidden = true;
55
+ body.append(el("p", { className: "okline", textContent: "✓ Stored as " + c.name + "." }), replace, form);
56
+ } else {
57
+ body.append(form);
58
+ }
69
59
 
70
60
  go.onclick = async () => {
71
- const value = input.value.trim();
61
+ const value = String(input.value ?? "").trim();
72
62
  if (!value) {
73
- input.focus();
63
+ input.focus?.();
74
64
  return;
75
65
  }
76
66
  go.disabled = true;
77
- out.replaceChildren(el("p", { className: "sub", textContent: "Storing…" }));
67
+ go.textContent = "Saving…";
78
68
  try {
79
69
  const r = await storeCredential(value, false);
80
70
  input.value = "";
81
71
  if (!r.ok) throw new Error(r.error);
82
72
  opts.onStatus?.(true);
83
73
  out.replaceChildren(
84
- ...(r.fellBack ? [el("p", { className: "err", textContent: "The org secret was refused: " + r.fellBack })] : []),
85
- el("p", {
86
- textContent:
87
- r.mode === "org"
88
- ? "Saved."
89
- : "Saved on " + r.repos.join(", ") + ".",
90
- }),
74
+ ...(r.fellBack ? [el("p", { className: "sub", textContent: "Saved on each repo instead: " + r.fellBack })] : []),
75
+ el("p", { className: "okline", textContent: "✓ Saved." }),
91
76
  );
92
77
  } catch (err) {
93
78
  out.replaceChildren(el("p", { className: "err", textContent: String(err.message || err) }));
94
79
  } finally {
95
80
  go.disabled = false;
81
+ go.textContent = "Save";
96
82
  }
97
83
  };
98
84
  }
@@ -203,7 +203,7 @@ export function hireFlow(o) {
203
203
  hireStep(hire.body, role, o.onHired);
204
204
  for (const t of [app, charter, cred, run]) t.setLocked("Hire first");
205
205
  getCredential()
206
- .then((c) => credStatus(Boolean(c.orgSecret)))
206
+ .then((c) => credStatus(Boolean(c.stored ?? c.orgSecret)))
207
207
  .catch(() => {});
208
208
  return box;
209
209
  }
@@ -7,12 +7,38 @@
7
7
 
8
8
  import { post } from "../api.js";
9
9
  import { askYes, sheet } from "../dialog.js";
10
+ import { pending } from "../inflight.js";
10
11
  import { ago, el, esc } from "../dom.js";
11
12
  import { whatsLeft, sentence } from "../readiness.js";
12
13
  import { refreshAll } from "../refresh.js";
13
14
  import { S } from "../state.js";
14
15
  import { hireFlow, line, rolePicker } from "./hire.js";
15
16
 
17
+ /**
18
+ * Resolves once nothing is loading and `node` has stopped changing for a moment, or after a few
19
+ * seconds regardless. Quiet alone was not enough: while GitHub was still answering, nothing
20
+ * changed, and the half-built list was shown.
21
+ */
22
+ function settled(node, quiet = 250, most = 6000) {
23
+ return new Promise((resolve) => {
24
+ if (typeof MutationObserver !== "function") return resolve();
25
+ let lastChange = Date.now();
26
+ const obs = new MutationObserver(() => {
27
+ lastChange = Date.now();
28
+ });
29
+ obs.observe(node, { childList: true, subtree: true, attributes: true, characterData: true });
30
+ const started = Date.now();
31
+ const tick = setInterval(() => {
32
+ const now = Date.now();
33
+ if ((pending() === 0 && now - lastChange >= quiet) || now - started >= most) {
34
+ clearInterval(tick);
35
+ obs.disconnect();
36
+ resolve();
37
+ }
38
+ }, 50);
39
+ });
40
+ }
41
+
16
42
  /* The open dialog, if any. It lives on <body>, so it outlives a repaint of this screen, and a
17
43
  background refresh leaves whatever was typed into it alone. */
18
44
  let current = null;
@@ -31,18 +57,27 @@ export function viewStaff(m, o = {}) {
31
57
  /* Tasks open in a dialog. Where there is no dialog element (the test shim) they draw into
32
58
  the pane instead, which is how this screen always drew them. */
33
59
  function present(node) {
34
- if (current?.isOpen()) {
35
- current.set(node);
36
- return;
60
+ const spin = el("div", { className: "sheetloading" }, [el("span", { className: "spinner" })]);
61
+ if (current?.isOpen()) current.set(spin);
62
+ else {
63
+ current = sheet({
64
+ node: spin,
65
+ onClose: () => {
66
+ current = null;
67
+ S.staffOpen = null;
68
+ },
69
+ });
70
+ if (!current) {
71
+ pane.replaceChildren(node);
72
+ return;
73
+ }
37
74
  }
38
- current = sheet({
39
- node,
40
- onClose: () => {
41
- current = null;
42
- S.staffOpen = null;
43
- },
75
+ /* The list is built from half a dozen answers arriving one by one, and showing each as it
76
+ landed meant the dialog reflowed four times. It is shown once it has stopped changing. */
77
+ const mine = current;
78
+ settled(node).then(() => {
79
+ if (mine.isOpen() && current === mine) mine.set(node);
44
80
  });
45
- if (!current) pane.replaceChildren(node);
46
81
  }
47
82
  function dismiss() {
48
83
  if (current) current.close();