@stratusagent/dashboard 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,148 @@
1
+ import { ago, duration, el, until } from '../lib/dom.js';
2
+ import { api } from '../lib/api.js';
3
+ import { avatar } from '../lib/avatar.js';
4
+ import { isActive, navigate, refreshCore, store } from '../app.js';
5
+
6
+ export const activityPanel = (filter) => {
7
+ const lines = (filter ? store.activity.filter(filter) : store.activity).slice(0, 60);
8
+ return el('section', { class: 'card' },
9
+ el('div', { class: 'card-head' },
10
+ el('h2', {}, 'Activity'),
11
+ el('span', { class: 'stream-status' },
12
+ el('span', { class: `dot${store.stream === 'live' ? '' : ' offline'}` }),
13
+ store.stream === 'live' ? 'live' : 'reconnecting',
14
+ ),
15
+ ),
16
+ lines.length === 0
17
+ ? el('p', { class: 'empty' }, 'Nothing yet. Events appear here as agents work.')
18
+ : el('div', { class: 'activity' }, ...lines.map((line) => el('div', { class: 'line' },
19
+ el('span', { class: 'when' }, ago(new Date(line.at).toISOString())),
20
+ el('span', { class: 'what', title: line.sessionId },
21
+ line.agent ? `${line.agent} ${line.text}` : line.text),
22
+ ))),
23
+ );
24
+ };
25
+
26
+ const approvalCard = () => {
27
+ if (store.approvals.length === 0) {
28
+ return null;
29
+ }
30
+
31
+ const decide = async (requestId, answer) => {
32
+ try {
33
+ await api.resolveApproval({ requestId, answer, actor: 'dashboard' });
34
+ } catch {
35
+ // A request that is no longer pending was already decided, expired, or
36
+ // its turn was cancelled. Refreshing shows the truth; retrying would
37
+ // just fail again.
38
+ }
39
+ await refreshCore();
40
+ };
41
+
42
+ return el('section', { class: 'card' },
43
+ el('div', { class: 'card-head' },
44
+ el('h2', {}, 'Approvals'),
45
+ el('span', { class: 'pill risk-gated' }, `${store.approvals.length} waiting`),
46
+ ),
47
+ el('div', { class: 'approvals-list' }, ...store.approvals.map((request) => {
48
+ const agent = store.agents.find((candidate) => candidate.id === request.agentId);
49
+ return el('div', { class: 'approval' },
50
+ el('div', { class: 'row' },
51
+ agent ? avatar(agent, 24) : null,
52
+ el('div', { class: 'grow' },
53
+ el('div', { class: 'title' }, `${agent?.name ?? request.agentId} wants to run ${request.call.toolName}`),
54
+ el('div', { class: 'sub' },
55
+ request.expiresAt ? `expires ${until(request.expiresAt)}` : 'no expiry',
56
+ ` · parked ${ago(request.parkedAt)}`),
57
+ ),
58
+ el('span', { class: `pill risk-${request.risk}` }, request.risk),
59
+ ),
60
+ el('div', { class: 'cmd' }, JSON.stringify(request.call.input)),
61
+ el('div', { class: 'actions' },
62
+ el('button', { class: 'primary small', onClick: () => decide(request.requestId, 'once') }, 'Allow once'),
63
+ el('button', { class: 'small', onClick: () => decide(request.requestId, 'always') }, 'Always allow'),
64
+ el('button', { class: 'small danger', onClick: () => decide(request.requestId, 'deny') }, 'Deny'),
65
+ ),
66
+ );
67
+ })),
68
+ );
69
+ };
70
+
71
+ const quickStats = () => {
72
+ const health = store.health;
73
+ const sessions = health?.sessions ?? { total: 0, byStatus: {} };
74
+ const running = sessions.byStatus?.running ?? 0;
75
+ const active = store.agents.filter(isActive).length;
76
+
77
+ return el('section', { class: 'card' },
78
+ el('h2', {}, 'Quick Stats'),
79
+ store.offlineSince
80
+ ? el('div', { class: 'banner bad' }, `stratusd stopped answering ${ago(new Date(store.offlineSince).toISOString())} — these numbers are from before that.`)
81
+ : null,
82
+ el('div', { class: 'stats' },
83
+ el('div', { class: 'stat' },
84
+ el('div', { class: 'value' }, String(store.agents.length)),
85
+ el('div', { class: 'label' }, `agents · ${active} active`)),
86
+ el('div', { class: 'stat' },
87
+ el('div', { class: 'value' }, String(running)),
88
+ el('div', { class: 'label' }, 'turns running')),
89
+ el('div', { class: 'stat' },
90
+ el('div', { class: 'value' }, String(sessions.total)),
91
+ el('div', { class: 'label' }, 'sessions stored')),
92
+ el('div', { class: `stat${store.approvals.length > 0 ? ' bad' : ''}` },
93
+ el('div', { class: 'value' }, String(store.approvals.length)),
94
+ el('div', { class: 'label' }, 'awaiting approval')),
95
+ el('div', { class: 'stat' },
96
+ el('div', { class: 'value' }, health ? duration(health.uptimeMs) : '—'),
97
+ el('div', { class: 'label' }, 'uptime')),
98
+ ),
99
+ health?.runtimes?.length
100
+ ? el('div', {},
101
+ el('h3', {}, 'Serving'),
102
+ el('div', { class: 'rows' }, ...health.runtimes.map((runtime) => el('div', { class: 'row' },
103
+ el('div', { class: 'grow' },
104
+ el('div', { class: 'title' }, runtime.model ? `${runtime.provider} · ${runtime.model}` : runtime.provider),
105
+ el('div', { class: 'sub' }, `credentials: ${runtime.credentials}`)),
106
+ ))))
107
+ : null,
108
+ );
109
+ };
110
+
111
+ const recentSessions = () => el('section', { class: 'card' },
112
+ el('div', { class: 'card-head' },
113
+ el('h2', {}, 'Recent Sessions'),
114
+ el('button', { class: 'small', onClick: () => void refreshCore() }, 'Refresh'),
115
+ ),
116
+ store.sessions.length === 0
117
+ ? el('p', { class: 'empty' }, 'No conversations yet. Open an agent to start one.')
118
+ : el('div', { class: 'rows' }, ...store.sessions.map((session) => {
119
+ const agent = store.agents.find((candidate) => candidate.id === session.agentId);
120
+ return el('div', { class: 'row' },
121
+ agent ? avatar(agent, 24) : null,
122
+ el('div', { class: 'grow' },
123
+ el('div', { class: 'title', title: session.id },
124
+ session.id.length <= 34 ? session.id : `${session.id.slice(0, 16)}…${session.id.slice(-10)}`),
125
+ el('div', { class: 'sub' }, `${agent?.name ?? session.agentId} · ${ago(session.updatedAt)}`)),
126
+ el('span', { class: `pill ${session.status}` }, session.status.replace('_', ' ')),
127
+ agent
128
+ ? el('button', {
129
+ class: 'small',
130
+ onClick: () => navigate({ name: 'agent', agentId: agent.id, sessionId: session.id }),
131
+ }, 'Open')
132
+ : null,
133
+ );
134
+ })),
135
+ );
136
+
137
+ export const renderDashboard = () => {
138
+ const node = el('div', { class: 'main' });
139
+ const update = () => node.replaceChildren(
140
+ el('div', { class: 'column' }, quickStats(), approvalCard(), recentSessions()),
141
+ el('div', { class: 'column' }, activityPanel()),
142
+ );
143
+ update();
144
+ // Nothing on this screen holds a caret or a scroll position worth keeping,
145
+ // so it simply rebuilds — the honest default when there is no reason to be
146
+ // cleverer.
147
+ return { node, update };
148
+ };
@@ -0,0 +1,86 @@
1
+ import { el } from '../lib/dom.js';
2
+ import { api } from '../lib/api.js';
3
+
4
+ /**
5
+ * What this daemon can actually do, and whose code it is.
6
+ *
7
+ * Two lists rather than one, because either alone misleads. The tools say
8
+ * what an agent can be granted and at what risk; the plugins say what the
9
+ * daemon was *asked* to load — including one that failed, which is
10
+ * invisible in a list of tools and is often the reason somebody opened this
11
+ * screen.
12
+ */
13
+
14
+ const riskPill = (risk) => el('span', { class: `pill risk-${risk}` }, risk);
15
+
16
+ const toolRow = (tool) => el('div', { class: 'row' },
17
+ el('div', { class: 'grow' },
18
+ el('div', { class: 'title' }, tool.name),
19
+ tool.description ? el('div', { class: 'sub' }, tool.description) : null,
20
+ ),
21
+ riskPill(tool.risk),
22
+ );
23
+
24
+ const pluginCard = (plugin) => {
25
+ if (plugin.error) {
26
+ return el('section', { class: 'card' },
27
+ el('div', { class: 'card-head' },
28
+ el('h2', {}, plugin.package),
29
+ el('span', { class: 'pill failed' }, 'did not load'),
30
+ ),
31
+ el('p', { class: 'field-note' }, plugin.error),
32
+ );
33
+ }
34
+
35
+ return el('section', { class: 'card' },
36
+ el('div', { class: 'card-head' },
37
+ el('h2', {}, plugin.package),
38
+ // Not decoration: a third-party plugin cannot declare a tool `safe`,
39
+ // so this says why the risks below read the way they do.
40
+ el('span', { class: plugin.trusted ? 'pill' : 'pill risk-gated' },
41
+ plugin.trusted ? 'first-party' : 'third-party'),
42
+ ),
43
+ (plugin.tools ?? []).length === 0
44
+ ? el('p', { class: 'field-note' }, 'Contributes no tools.')
45
+ : el('div', {}, ...(plugin.tools ?? []).map(toolRow)),
46
+ );
47
+ };
48
+
49
+ const kernelCard = (tools) => el('section', { class: 'card' },
50
+ el('div', { class: 'card-head' },
51
+ el('h2', {}, 'Built in'),
52
+ el('span', { class: 'pill' }, `${tools.length} tool${tools.length === 1 ? '' : 's'}`),
53
+ ),
54
+ el('div', {}, ...tools.map(toolRow)),
55
+ );
56
+
57
+ const emptyCard = () => el('section', { class: 'card' },
58
+ el('h2', {}, 'No plugins installed'),
59
+ el('p', {}, 'Capability arrives as plugins: a filesystem, a shell, a browser, a web fetcher. Install one and list it in ~/.stratus/config.json.'),
60
+ el('pre', { class: 'cmd' }, 'npm install @stratusagent/tool-fs\n\n"plugins": {\n "@stratusagent/tool-fs": { "enabled": true, "roots": ["~/notes"] }\n}'),
61
+ el('p', { class: 'field-note' }, 'Installing a plugin grants no agent anything — each agent’s soul still lists the tools it may call, on its Settings tab.'),
62
+ );
63
+
64
+ export const renderPlugins = () => {
65
+ const node = el('div', { class: 'main solo' }, el('section', { class: 'card' }, el('p', {}, 'Loading…')));
66
+
67
+ const paint = (tools, plugins) => {
68
+ const kernel = tools.filter((tool) => !tool.package);
69
+ node.replaceChildren(
70
+ ...(plugins.length === 0 ? [emptyCard()] : plugins.map(pluginCard)),
71
+ ...(kernel.length > 0 ? [kernelCard(kernel)] : []),
72
+ );
73
+ };
74
+
75
+ api.tools().then(
76
+ (payload) => paint(payload.tools ?? [], payload.plugins ?? []),
77
+ (error) => {
78
+ node.replaceChildren(el('section', { class: 'card' },
79
+ el('h2', {}, 'Plugins'),
80
+ el('p', { class: 'field-note' }, `Could not read the tool catalog: ${error.message}`),
81
+ ));
82
+ },
83
+ );
84
+
85
+ return { node };
86
+ };
@@ -0,0 +1,365 @@
1
+ import { el } from '../lib/dom.js';
2
+ import { api } from '../lib/api.js';
3
+ import { refreshCore, store } from '../app.js';
4
+
5
+ const PROVIDERS = ['anthropic', 'openai'];
6
+
7
+ /**
8
+ * Bring a `<select>`'s options up to date without disturbing a choice.
9
+ *
10
+ * The option lists here are data — models a sign-in can reach, agents on the
11
+ * roster — and they arrive after the form is already on screen. Rebuilding
12
+ * the whole pane to show them would throw away everything typed into it, so
13
+ * the select is kept and only its options are replaced, restoring the current
14
+ * value when it survived the refresh.
15
+ */
16
+ const syncOptions = (select, options, fallback) => {
17
+ const signature = options.map((option) => option.value).join(' ');
18
+ if (select.dataset.signature === signature) {
19
+ return;
20
+ }
21
+ select.dataset.signature = signature;
22
+ // A choice someone made survives; a value the browser merely settled on
23
+ // does not. The pane renders once before its data arrives, so the picker
24
+ // starts on whatever the empty option list allowed — and treating that as a
25
+ // selection meant the configured model never got selected when it finally
26
+ // appeared, leaving the picker on "follow the default" and the next Save
27
+ // deleting a pin nobody touched.
28
+ const chosen = select.dataset.touched === 'yes' ? select.value : fallback;
29
+ select.replaceChildren(...options.map((option) => el('option', { value: option.value }, option.label)));
30
+ const wanted = options.some((option) => option.value === chosen) ? chosen : fallback;
31
+ if (wanted !== undefined) {
32
+ select.value = wanted;
33
+ }
34
+ };
35
+
36
+ /** Mark a select as carrying a real choice rather than a default. */
37
+ const touchOnChange = (select) => {
38
+ select.addEventListener('change', () => { select.dataset.touched = 'yes'; });
39
+ return select;
40
+ };
41
+
42
+ export const renderSettings = (section) => {
43
+ const state = { credentials: undefined, models: undefined, config: undefined, notice: undefined, busy: false };
44
+ const node = el('div', { class: 'main solo' });
45
+
46
+ const say = (text, kind = 'ok') => {
47
+ state.notice = { text, kind };
48
+ update();
49
+ };
50
+
51
+ const load = async () => {
52
+ try {
53
+ if (section === 'providers' || section === 'channels') {
54
+ state.credentials = await api.credentials();
55
+ }
56
+ if (section === 'models') {
57
+ const [models, config] = await Promise.all([api.models(), api.config()]);
58
+ state.models = models.models ?? [];
59
+ state.config = config.config ?? {};
60
+ }
61
+ } catch (error) {
62
+ say(error.message, 'bad');
63
+ return;
64
+ }
65
+ update();
66
+ };
67
+
68
+ /**
69
+ * The pane, built once and kept.
70
+ *
71
+ * `update()` runs on every notice and on both edges of every request — the
72
+ * busy flag alone fires it twice per click. Rebuilding the pane there put a
73
+ * fresh, empty form on screen the moment someone pressed Verify, so a key
74
+ * that had just been checked was gone before it could be saved, and Save
75
+ * would then store an empty credential. Only the data-driven regions
76
+ * refresh; what someone typed is state, not a projection of the server.
77
+ */
78
+ let pane;
79
+
80
+ // ---- providers ---------------------------------------------------------
81
+
82
+ const buildProviders = () => {
83
+ const provider = el('select', {}, ...PROVIDERS.map((name) => el('option', { value: name }, name)));
84
+ const type = el('select', {},
85
+ el('option', { value: 'api_key' }, 'API key'),
86
+ el('option', { value: 'oauth_token' }, 'Claude subscription token'));
87
+ const value = el('input', { type: 'password', placeholder: 'paste the key' });
88
+ const baseUrl = el('input', { placeholder: 'optional — a proxy or local endpoint' });
89
+
90
+ const verify = async () => {
91
+ state.busy = true; update();
92
+ try {
93
+ const verdict = await api.verifyKey({
94
+ provider: provider.value,
95
+ // The type decides how the value is checked at all: a subscription
96
+ // token cannot reach the models endpoint, and checked as an API
97
+ // key it comes back rejected — condemning a credential that works
98
+ // once saved.
99
+ type: type.value,
100
+ key: value.value,
101
+ ...(baseUrl.value.trim() ? { baseUrl: baseUrl.value.trim() } : {}),
102
+ });
103
+ // Only an explicit auth failure condemns a key: plenty of compatible
104
+ // endpoints have no models route at all, and "unreachable" there says
105
+ // nothing about whether the key works.
106
+ say(
107
+ verdict.status === 'ok' ? 'That key works.'
108
+ : verdict.status === 'rejected' ? `Rejected: ${verdict.detail ?? 'the provider refused it'}`
109
+ : `Could not check it: ${verdict.detail ?? 'unreachable'} — it may still be fine.`,
110
+ verdict.status === 'rejected' ? 'bad' : 'ok',
111
+ );
112
+ } catch (error) {
113
+ say(error.message, 'bad');
114
+ }
115
+ state.busy = false; update();
116
+ };
117
+
118
+ const save = async () => {
119
+ state.busy = true; update();
120
+ try {
121
+ await api.storeKey(provider.value, {
122
+ type: type.value,
123
+ value: value.value,
124
+ ...(baseUrl.value.trim() ? { baseUrl: baseUrl.value.trim() } : {}),
125
+ });
126
+ value.value = '';
127
+ state.credentials = await api.credentials();
128
+ say('Stored in ~/.stratus/credentials.json (0600).');
129
+ } catch (error) {
130
+ say(error.message, 'bad');
131
+ }
132
+ state.busy = false; update();
133
+ };
134
+
135
+ const signIns = el('div', { class: 'rows' });
136
+ const verifyButton = el('button', { onClick: () => void verify() }, 'Verify');
137
+ const saveButton = el('button', { class: 'primary', onClick: () => void save() }, 'Save');
138
+
139
+ return {
140
+ node: el('section', { class: 'card' },
141
+ el('h2', {}, 'Providers'),
142
+ signIns,
143
+ el('p', { class: 'field-note' }, 'Keys are never sent back out — this page can tell you a sign-in exists, not what it is.'),
144
+ el('h3', {}, 'Add or replace a sign-in'),
145
+ el('div', { class: 'form-grid' },
146
+ el('div', {}, el('label', {}, 'Provider'), provider),
147
+ el('div', {}, el('label', {}, 'Type'), type),
148
+ ),
149
+ el('label', {}, 'Key'), value,
150
+ el('label', {}, 'Endpoint'), baseUrl,
151
+ el('p', { class: 'field-note' }, 'A key is bound to the endpoint you save it with, and is never sent anywhere else.'),
152
+ el('div', { class: 'actions' }, verifyButton, saveButton),
153
+ ),
154
+ refresh() {
155
+ signIns.replaceChildren(...(state.credentials?.providers ?? []).map((entry) => el('div', { class: 'row' },
156
+ el('div', { class: 'grow' },
157
+ el('div', { class: 'title' }, entry.provider),
158
+ el('div', { class: 'sub' }, entry.stored
159
+ ? `${entry.type === 'oauth_token' ? 'Claude subscription' : 'API key'}${entry.baseUrl ? ` · ${entry.baseUrl}` : ''}`
160
+ : 'not signed in')),
161
+ el('span', { class: `pill${entry.stored ? ' running' : ''}` }, entry.stored ? 'signed in' : 'none'),
162
+ )));
163
+ verifyButton.disabled = state.busy;
164
+ saveButton.disabled = state.busy;
165
+ },
166
+ };
167
+ };
168
+
169
+ // ---- models ------------------------------------------------------------
170
+
171
+ const buildModels = () => {
172
+ const picker = touchOnChange(el('select', {}));
173
+
174
+ const save = async () => {
175
+ state.busy = true; update();
176
+ try {
177
+ // Re-read rather than spreading the snapshot this page loaded with.
178
+ // PUT replaces the whole document, so a save built on a stale copy
179
+ // silently reverts anything another surface changed in the meantime
180
+ // — the CLI editing `approvals`, another tab picking a soul. This
181
+ // narrows that window to the round trip below; it does not close it,
182
+ // which would need a precondition the API does not offer yet.
183
+ const config = (await api.config()).config ?? {};
184
+ state.config = config;
185
+ const [provider, model] = picker.value ? picker.value.split(/:(.*)/s) : [undefined, undefined];
186
+ // A whole document, because PUT replaces rather than merges — sending
187
+ // only the two changed keys would silently drop everything else in
188
+ // the file, approvals and soul included. Which makes the *removals*
189
+ // below load-bearing rather than tidying.
190
+ const next = { ...config };
191
+ if (picker.dataset.touched !== 'yes') {
192
+ // Nobody chose anything here, so this save has no opinion about the
193
+ // model. Deriving one from the picker's displayed value would let
194
+ // opening the page and pressing Save rewrite a pin — which is how a
195
+ // configuration the catalog could not express got deleted.
196
+ } else if (provider || model) {
197
+ if (provider) {
198
+ next.provider = provider;
199
+ } else {
200
+ delete next.provider;
201
+ }
202
+ if (model) {
203
+ next.model = model;
204
+ } else {
205
+ delete next.model;
206
+ }
207
+ if (provider && provider !== config.provider) {
208
+ // An endpoint and a key-selector are chosen for one provider.
209
+ // Carried across, they point the new provider at the old one's
210
+ // service, or hand it the wrong credential — the same
211
+ // normalization the CLI setup flow performs on a switch.
212
+ delete next.baseUrl;
213
+ delete next.apiKeyEnv;
214
+ }
215
+ } else {
216
+ // "Follow the default" has to actually clear the pin. Leaving the
217
+ // old provider and model in place would report success and change
218
+ // nothing, with the previous model still there on reload.
219
+ delete next.provider;
220
+ delete next.model;
221
+ }
222
+ await api.saveConfig(next);
223
+ state.config = (await api.config()).config ?? {};
224
+ await refreshCore();
225
+ say('Saved. Every agent that does not pin its own follows this.');
226
+ } catch (error) {
227
+ say(error.message, 'bad');
228
+ }
229
+ state.busy = false; update();
230
+ };
231
+
232
+ const empty = el('p', { class: 'empty' }, 'No models reachable yet — add a provider sign-in first.');
233
+ const saveButton = el('button', { class: 'primary', onClick: () => void save() }, 'Save');
234
+
235
+ return {
236
+ node: el('section', { class: 'card' },
237
+ el('h2', {}, 'Models'),
238
+ empty,
239
+ el('label', {}, 'Default model'), picker,
240
+ el('p', { class: 'field-note' }, 'Listed live from the sign-ins you have. An agent whose soul pins a provider or model keeps it, whatever this says.'),
241
+ el('div', { class: 'actions' }, saveButton),
242
+ ),
243
+ refresh() {
244
+ const config = state.config ?? {};
245
+ // Either half counts as a pin. A config naming only a provider takes
246
+ // that provider's default model, and one naming only a model keeps
247
+ // the provider from elsewhere — both are configurations someone
248
+ // chose, and reading only the pair called them unpinned.
249
+ const pinned = config.provider || config.model
250
+ ? `${config.provider ?? ''}:${config.model ?? ''}`
251
+ : '';
252
+ const listed = (state.models ?? []).map((model) => ({
253
+ value: `${model.provider}:${model.id}`,
254
+ label: `${model.id} — ${model.provider}`,
255
+ }));
256
+ // The configured pin, even when the live catalog does not list it —
257
+ // an endpoint that omits the model, a sign-in the provider is
258
+ // currently refusing, or a partial pin the catalog cannot express.
259
+ // Without an option to select, the browser leaves the picker on
260
+ // "follow the default", which is a claim about the config that is not
261
+ // true. Labelled for what it is rather than shown as a normal choice.
262
+ if (pinned && !listed.some((option) => option.value === pinned)) {
263
+ const label = config.provider && config.model
264
+ ? `${config.model} — ${config.provider} (not listed right now)`
265
+ : config.provider
266
+ ? `${config.provider} — its default model`
267
+ : `${config.model} — whichever provider is default`;
268
+ listed.unshift({ value: pinned, label });
269
+ }
270
+ syncOptions(picker, [{ value: '', label: 'follow the default' }, ...listed], pinned);
271
+ empty.hidden = (state.models ?? []).length > 0;
272
+ saveButton.disabled = state.busy;
273
+ },
274
+ };
275
+ };
276
+
277
+ // ---- channels ----------------------------------------------------------
278
+
279
+ const buildChannels = () => {
280
+ const agentPicker = touchOnChange(el('select', {}));
281
+ const appToken = el('input', { type: 'password', placeholder: 'xapp-…' });
282
+ const botToken = el('input', { type: 'password', placeholder: 'xoxb-…' });
283
+
284
+ const save = async () => {
285
+ state.busy = true; update();
286
+ try {
287
+ await api.storeChannel('slack', {
288
+ agentId: agentPicker.value,
289
+ appToken: appToken.value,
290
+ botToken: botToken.value,
291
+ });
292
+ appToken.value = '';
293
+ botToken.value = '';
294
+ state.credentials = await api.credentials();
295
+ say('Stored. Restart stratusd to bring that agent online in Slack.');
296
+ } catch (error) {
297
+ say(error.message, 'bad');
298
+ }
299
+ state.busy = false; update();
300
+ };
301
+
302
+ const bound = el('div', {});
303
+ const saveButton = el('button', { class: 'primary', onClick: () => void save() }, 'Save');
304
+
305
+ return {
306
+ node: el('section', { class: 'card' },
307
+ el('h2', {}, 'Channels'),
308
+ bound,
309
+ el('h3', {}, 'Connect an agent'),
310
+ el('label', {}, 'Agent'), agentPicker,
311
+ el('label', {}, 'App token'), appToken,
312
+ el('label', {}, 'Bot token'), botToken,
313
+ el('p', { class: 'field-note' }, 'Channel tokens are gateway secrets in their own namespace — an agent can never read the tokens of the transport carrying it.'),
314
+ el('div', { class: 'actions' }, saveButton),
315
+ ),
316
+ refresh() {
317
+ // The built-in agent included, deliberately. It is the only agent a
318
+ // fresh install has, the gateway dispatches Slack turns to it, and
319
+ // the CLI's own Channels list starts with it — filtering it out here
320
+ // left the picker empty on exactly the install that needs it, with a
321
+ // Save that could only fail validation.
322
+ syncOptions(
323
+ agentPicker,
324
+ store.agents.map((agent) => ({ value: agent.id, label: agent.name })),
325
+ );
326
+ const connected = state.credentials?.channels?.slack ?? [];
327
+ bound.replaceChildren(connected.length === 0
328
+ ? el('p', { class: 'empty' }, 'No agent is connected to Slack yet.')
329
+ : el('div', { class: 'rows' }, ...connected.map((agentId) => {
330
+ const agent = store.agents.find((candidate) => candidate.id === agentId);
331
+ return el('div', { class: 'row' },
332
+ el('div', { class: 'grow' },
333
+ el('div', { class: 'title' }, agent?.name ?? agentId),
334
+ el('div', { class: 'sub' }, 'Slack app connected')),
335
+ el('span', { class: 'pill running' }, 'slack'),
336
+ );
337
+ })));
338
+ saveButton.disabled = state.busy;
339
+ },
340
+ };
341
+ };
342
+
343
+ const notice = el('div', {});
344
+
345
+ const update = () => {
346
+ pane ??= section === 'models' ? buildModels()
347
+ : section === 'channels' ? buildChannels()
348
+ : buildProviders();
349
+ pane.refresh();
350
+ notice.replaceChildren(
351
+ ...(state.notice ? [el('div', { class: `notice ${state.notice.kind}` }, state.notice.text)] : []),
352
+ );
353
+ if (!node.firstChild) {
354
+ node.replaceChildren(el('div', { class: 'column' }, pane.node, notice));
355
+ }
356
+ };
357
+
358
+ update();
359
+ void load();
360
+
361
+ // Nothing here re-renders from the event stream: these are forms, and a
362
+ // repaint driven by another agent's activity would take the caret out of
363
+ // whatever someone was typing.
364
+ return { node };
365
+ };