sidebud 0.2.0 → 0.5.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.
@@ -1,4 +1,4 @@
1
- const SECTIONS = ['setup', 'connection', 'voice', 'execution', 'mcp', 'phone', 'layouts', 'guide', 'settings'];
1
+ const SECTIONS = ['setup', 'connection', 'voice', 'execution', 'decisions', 'mcp', 'phone', 'layouts', 'guide', 'settings'];
2
2
  // Links carry the token in the fragment (never sent to the server): `#t=<token>&s=<section>`.
3
3
  const linked = new URLSearchParams(location.hash.startsWith('#t=') ? location.hash.slice(1) : '');
4
4
  const token = linked.get('t') || sessionStorage.getItem('rvaManageToken');
@@ -59,7 +59,8 @@ async function api(path, payload) {
59
59
  }).catch(() => { throw new Error(RESTARTED); });
60
60
  const data = await response.json();
61
61
  if (!response.ok)
62
- throw new Error(data.error || `Request failed (${response.status})`);
62
+ // The body rides along for answers that ask something of the owner (an update that needs consent).
63
+ throw Object.assign(new Error(data.error || `Request failed (${response.status})`), { data });
63
64
  return data;
64
65
  }
65
66
  /** Re-reads companion state; background polls re-render only when it changed, so forms keep their input. */
@@ -70,8 +71,9 @@ async function reload(force = true) {
70
71
  state = next;
71
72
  if (changed) render();
72
73
  clearTimeout(reload.timer);
73
- // Poll only while something is in progress: an account connection, a setup check, or pairing during setup.
74
- if (state.connecting.length || setupWaiting() || state.account.status === 'pending') reload.timer = setTimeout(() => reload(false), 1500);
74
+ // Poll only while something is in progress: an account connection, a setup check, pairing during setup, a pack's setup, or a sync.
75
+ const packSetupRunning = Object.values(state.packSetup || {}).some((setup) => setup.running);
76
+ if (state.connecting.length || setupWaiting() || packSetupRunning || state.account.status === 'pending' || state.sync?.state === 'syncing') reload.timer = setTimeout(() => reload(false), 1500);
75
77
  } catch (error) {
76
78
  $('#app').innerHTML = `<div class="card">${esc(error.message)}</div>`;
77
79
  }
@@ -93,7 +95,7 @@ async function applyChanges() {
93
95
  await pause(500);
94
96
  try {
95
97
  const next = await api('state');
96
- if (!next.restart.pending) {
98
+ if (!next.restart.pending && !next.sync?.restartPending) {
97
99
  state = next;
98
100
  notice('Changes applied.');
99
101
  return true;
@@ -118,8 +120,21 @@ function showsExecution() {
118
120
  return section() === 'execution' || (section() === 'setup' && currentSetupStep() === 'execution' && Boolean(state.providers));
119
121
  }
120
122
  function restartBanner() {
121
- if (!state.restart.pending) return '';
122
- return `<div class="banner"><div><strong>Saved changes apply after a restart.</strong><p class="hint">Restarting takes a few seconds and ends any call in progress.</p></div><button class="primary" data-action="apply-changes" ${restarting ? 'disabled' : ''}>${restarting ? 'Applying…' : 'Apply now'}</button></div>`;
123
+ // Changes from the account (ADR 0014) that the running companion reads only at start apply the same way.
124
+ if (!state.restart.pending && !state.sync?.restartPending) return '';
125
+ return `<div class="banner"><div><strong>${state.restart.pending ? 'Saved changes apply after a restart.' : 'Changes from your account apply after a restart.'}</strong><p class="hint">Restarting takes a few seconds and ends any call in progress.</p></div><button class="primary" data-action="apply-changes" ${restarting ? 'disabled' : ''}>${restarting ? 'Applying…' : 'Apply now'}</button></div>`;
126
+ }
127
+ /** A newer release is on npm; updating is the user's command (shown exactly), never automatic. */
128
+ function updateBanner() {
129
+ const updates = state.install?.updates;
130
+ const update = state.install?.update;
131
+ if (!updates?.available) return '';
132
+ if (update?.scheduledAt)
133
+ return `<div class="banner"><div><strong>Sidebud ${esc(updates.latest)} installs ${esc(new Date(update.scheduledAt).toLocaleString())}</strong><p class="hint">It waits until no call or job is running, then restarts the companion.</p></div><div class="actions"><button data-action="update" data-when="now">Update now</button><button data-action="update" data-when="cancel">Cancel</button></div></div>`;
134
+ if (update?.canApply)
135
+ return `<div class="banner"><div><strong>Update available: Sidebud ${esc(updates.latest)}</strong><p class="hint">This computer has ${esc(updates.current)}. Updating restarts the companion and ends any call in progress.${update.lastError ? ` The last attempt failed: ${esc(update.lastError.message)}` : ''}</p></div><div class="actions"><button class="primary" data-action="update" data-when="now" ${update.applying ? 'disabled' : ''}>${update.applying ? 'Updating…' : 'Update now'}</button><button data-action="update" data-when="idle" title="As soon as no call is live and no agent work runs here">When idle</button><button data-action="update" data-when="tonight">Tonight</button></div></div>`;
136
+ const restart = state.install.kind === 'npx' ? 'Quit Sidebud first; the command starts the new version.' : state.install.loginItem.running ? 'The restart ends any call in progress.' : 'Then quit and start Sidebud again.';
137
+ return `<div class="banner"><div><strong>Update available: Sidebud ${esc(updates.latest)}</strong><p class="hint">This computer has ${esc(updates.current)}. Run <span class="code">${esc(updates.command)}</span> in a terminal. ${restart}</p></div></div>`;
123
138
  }
124
139
  function setupBanner() {
125
140
  if (state.setup.completedAt || section() === 'setup') return '';
@@ -134,11 +149,12 @@ function render() {
134
149
  .querySelectorAll('nav a')
135
150
  .forEach((a) => a.classList.toggle('active', a.hash === `#${current}`));
136
151
  const scrollTo = location.hash.startsWith('#guide/');
137
- $('#app').innerHTML = restartBanner() + setupBanner() + {
152
+ $('#app').innerHTML = restartBanner() + updateBanner() + setupBanner() + {
138
153
  setup: setupView,
139
154
  connection: connectionView,
140
155
  voice: voiceView,
141
156
  execution: executionView,
157
+ decisions: decisionsView,
142
158
  mcp: () => (packPageId() ? packPageView() : libraryPageSlug() ? libraryPackView() : mcpView()),
143
159
  phone: phoneView,
144
160
  layouts: layoutsView,
@@ -194,11 +210,47 @@ function connectionView() {
194
210
  const connected = new Set(state.connectedDeviceIds || []);
195
211
  return `<section class="section"><div><h2>Connect a phone</h2><p class="lede">Pair your phone with ${esc(state.name)}. This page manages the companion on this computer; choose among your computers in the phone app. Rename this computer under <a href="#settings">Settings</a>.</p></div><div class="card stack"><div><h3>Pairing code</h3><p class="hint">On your phone, choose Add computer → Scan pairing code. Each code works once and expires shortly.</p></div>${pairing ? `<img class="pairing-qr" src="${esc(pairing.qr)}" alt="Pairing QR code"><p class="hint">${Date.parse(pairing.expiresAt) <= Date.now() ? 'This code has expired. Generate a new one.' : `Expires ${esc(new Date(pairing.expiresAt).toLocaleString())}.`}</p>` : `<p class="muted">${pairingLoading ? 'Preparing pairing code…' : 'Generate a code to connect this phone.'}</p>`}${pairingError ? `<p role="alert" class="hint error-text">Could not create a pairing code: ${esc(pairingError)} Check this computer’s reachable address in config.json, then try again.</p>` : ''}<div class="actions"><button class="primary" data-action="pair-phone" ${pairingLoading ? 'disabled' : ''}>${pairingLoading ? 'Preparing…' : pairing ? 'Generate a new code' : pairingError ? 'Try again' : 'Generate pairing code'}</button></div></div><div><h3>Paired phones</h3><p class="lede">${active.length ? 'Phones authorized to use this computer.' : 'No phone is paired yet. Scan the code above to connect your first phone.'}</p></div><div class="grid">${active.map((device) => `<div class="card"><div class="row"><div><h3>${esc(device.name)}</h3><p class="hint">${esc(device.platform)} · Paired ${esc(new Date(device.createdAt).toLocaleDateString())}</p></div><span class="badge ${connected.has(device.id) ? 'good' : ''}">${connected.has(device.id) ? 'Connected now' : 'Offline'}</span></div><p class="hint">${device.lastSeenAt ? `Last seen ${esc(new Date(device.lastSeenAt).toLocaleString())}` : 'Has not connected yet'} · Credential expires ${esc(new Date(device.expiresAt).toLocaleDateString())}</p><div class="actions"><button class="danger" data-action="revoke-phone" data-id="${esc(device.id)}">Remove phone</button></div></div>`).join('') || '<div class="card muted">Your paired phones will appear here.</div>'}</div><div class="actions"><button data-action="refresh">Refresh status</button></div></section>`;
196
212
  }
213
+ const SOURCES = {
214
+ account: { label: 'Account', title: 'From your Sidebud account' },
215
+ override: { label: 'This computer', title: 'Kept on this computer when your account’s settings change' },
216
+ default: { label: 'Default', title: 'Sidebud’s default; neither your account nor this computer sets it' },
217
+ };
218
+ /** A value's source by dotted path: the path itself, else the nearest enclosing field, else the default. */
219
+ function settingSource(section, path) {
220
+ const sources = state.settingSources?.[section] || {};
221
+ const keys = path.split('.');
222
+ for (let length = keys.length; length > 0; length -= 1) {
223
+ const source = sources[keys.slice(0, length).join('.')];
224
+ if (source) return source;
225
+ }
226
+ return 'default';
227
+ }
228
+ /**
229
+ * Where a synced value comes from (ADR 0014). While sync is on: Account, This computer, or Default, with a
230
+ * toggle to pin it here or follow the account again. While sync is off, only values pinned here are marked.
231
+ */
232
+ function sourceBadge(section, path) {
233
+ if (!state.sync?.enabled) return state.settingSources?.[section]?.[path] === 'override' ? `<span class="badge source" title="${SOURCES.override.title}">${SOURCES.override.label}</span>` : '';
234
+ const source = settingSource(section, path);
235
+ const pinned = source === 'override';
236
+ return `<span class="badge source ${source}" title="${SOURCES[source].title}">${SOURCES[source].label}</span><button type="button" class="link pin-toggle" data-action="setting-pin" data-section="${esc(section)}" data-path="${esc(path)}" data-pinned="${pinned}">${pinned ? 'Follow account' : 'Keep on this computer'}</button>`;
237
+ }
238
+ /** A field label with its source badge on the same line; while sync is on, one badge per field (a pinned path first). */
239
+ function sourceLabel(text, section, ...paths) {
240
+ const shown = state.sync?.enabled ? [paths.find((path) => state.settingSources?.[section]?.[path] === 'override') || paths[0]] : paths;
241
+ const badges = shown.map((path) => sourceBadge(section, path)).join('');
242
+ return badges ? `<span class="label-text">${text}${badges}</span>` : text;
243
+ }
244
+ /** A synced voice choice can name a provider whose key is not on this computer; keys never come from the phone. */
245
+ function voiceNeedsKey(providerId) {
246
+ const key = state.voice.keyStates[providerId];
247
+ return Boolean(key && !key.isSet);
248
+ }
197
249
  function voiceKeyView(providerId) {
198
250
  const provider = state.voice.catalog[providerId];
199
251
  if (!provider) return '';
200
252
  const key = state.voice.keyStates[providerId];
201
- return `<div class="card"><div class="row"><div><h3>${esc(provider.label)} API key</h3><p class="hint">${key.fromEnv ? 'Provided by this computer’s environment. Environment keys take priority.' : key.isSet ? 'Stored in this computer’s keychain. Enter a new key to replace it.' : 'Add a key to use this provider. It stays in this computer’s keychain.'}</p></div><span class="badge ${key.isSet ? 'good' : 'warn'}">${key.isSet ? 'Configured' : 'Missing'}</span></div><div class="actions"><input data-voice-key="${esc(providerId)}" type="password" autocomplete="new-password" aria-label="${esc(provider.label)} API key" placeholder="Paste a key to set or replace"><button data-action="save-voice-key" data-provider="${esc(providerId)}">Save key</button>${key.isSet && !key.fromEnv ? `<button class="danger" data-action="clear-voice-key" data-provider="${esc(providerId)}">Clear key</button>` : ''}</div></div>`;
253
+ return `<div class="card"><div class="row"><div><h3>${esc(provider.label)} API key</h3><p class="hint">${key.fromEnv ? 'Provided by this computer’s environment. Environment keys take priority.' : key.isSet ? 'Stored in this computer’s keychain. Enter a new key to replace it.' : 'Add this provider’s key here to use it. It stays in this computer’s keychain and is never synced or asked for on the phone.'}</p></div><span class="badge ${key.isSet ? 'good' : 'warn'}">${key.isSet ? 'Configured' : 'Needs a key on this computer'}</span></div><div class="actions"><input data-voice-key="${esc(providerId)}" type="password" autocomplete="new-password" aria-label="${esc(provider.label)} API key" placeholder="Paste a key to set or replace"><button data-action="save-voice-key" data-provider="${esc(providerId)}">Save key</button>${key.isSet && !key.fromEnv ? `<button class="danger" data-action="clear-voice-key" data-provider="${esc(providerId)}">Clear key</button>` : ''}</div></div>`;
202
254
  }
203
255
  function voiceKeysView(voiceProvider) {
204
256
  return state.voice.catalog[voiceProvider]
@@ -221,10 +273,14 @@ const voiceSearchSupported = (provider) => Boolean(provider?.capabilities.webSea
221
273
  /** Region and workspace fields, for providers whose endpoint needs them. */
222
274
  function voiceEndpointFields(provider, voice) {
223
275
  const regions = provider?.regions || [];
224
- return `${regions.length ? `<label>Region<select id="voice-region">${regions.map((region) => `<option value="${esc(region.id)}" ${(voice.region || regions[0].id) === region.id ? 'selected' : ''}>${esc(region.label)}</option>`).join('')}</select></label>` : ''}${provider?.needsWorkspaceId ? `<label>Workspace ID<input id="voice-workspace" maxlength="200" autocomplete="off" spellcheck="false" value="${esc(voice.workspaceId || '')}" placeholder="ws-… or paste the API Host"></label><p class="hint">In Model Studio, paste the <strong>API Host</strong> (or base URL) shown with your API key, such as <span class="code">ws-abc123.ap-southeast-1.maas.aliyuncs.com</span>; the workspace ID and region are read from it. It is not a secret; the API key below is.</p>` : ''}`;
276
+ return `${regions.length ? `<label>${sourceLabel('Region', 'voice', 'region')}<select id="voice-region">${regions.map((region) => `<option value="${esc(region.id)}" ${(voice.region || regions[0].id) === region.id ? 'selected' : ''}>${esc(region.label)}</option>`).join('')}</select></label>` : ''}${provider?.needsWorkspaceId ? `<label>${sourceLabel('Workspace ID', 'voice', 'workspaceId')}<input id="voice-workspace" maxlength="200" autocomplete="off" spellcheck="false" value="${esc(voice.workspaceId || '')}" placeholder="ws-… or paste the API Host"></label><p class="hint">In Model Studio, paste the <strong>API Host</strong> (or base URL) shown with your API key, such as <span class="code">ws-abc123.ap-southeast-1.maas.aliyuncs.com</span>; the workspace ID and region are read from it. It is not a secret; the API key below is.</p>` : ''}`;
225
277
  }
226
278
  /** Model Studio shows an API host (`ws-….ap-southeast-1.maas.aliyuncs.com`) or base URL, not a bare ID; both carry the ID and region. */
227
279
  const MODEL_STUDIO_HOST = /^(?:[a-z]+:\/\/)?([a-z0-9-]+)\.(ap-southeast-1|cn-beijing)\.maas\.aliyuncs\.com/i;
280
+ /** The spoken command phrase fields, shared by the Voice page and setup; read back by commandPhrases(). */
281
+ function commandPhraseFields(commands) {
282
+ return `<p class="hint">Your phone listens for these during a call, on the phone itself. Muting the mic stops what the assistant hears (it still speaks reports); muting the voice stops it speaking on the phone (replies still arrive as text). Use at least two words each; separate alternatives with commas. Unmute phrases work while muted. Changes apply from the next call after you apply them.</p><label>Mute mic and voice<input id="voice-mute-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(commands.mute.join(', '))}"></label><label>Mute mic only<input id="voice-mute-mic-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(commands.muteMic.join(', '))}"></label><label>Mute voice only<input id="voice-mute-voice-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(commands.muteVoice.join(', '))}"></label><label>Unmute everything<input id="voice-unmute-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(commands.unmute.join(', '))}"></label><label>End the call<input id="voice-end-call-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(commands.endCall.join(', '))}"></label>`;
283
+ }
228
284
  /** Same rules as the companion's voiceCommandPhraseSchema, checked here to say what is wrong. */
229
285
  function commandPhrases() {
230
286
  const read = (id) => $(id).value.split(',').map((phrase) => phrase.toLowerCase().replace(/[^a-z'\s]/g, ' ').replace(/\s+/g, ' ').trim()).filter(Boolean);
@@ -249,12 +305,47 @@ function voiceUsageView(usage) {
249
305
  const usd = (value) => `$${value < 0.1 ? value.toFixed(3) : value.toFixed(2)}`;
250
306
  return `<p><strong>This month ≈ ${esc(usd(usage.estimatedUsd))}</strong> · ${esc(minutes(usage.minutes))} connected, ${esc(minutes(usage.speakingMinutes))} speaking, across ${usage.calls} ${usage.calls === 1 ? 'call' : 'calls'}</p><ul class="hint">${usage.models.map((row) => `<li>${esc(row.providerLabel)} · ${esc(row.model)}: ${esc(minutes(row.minutes))} connected, ${esc(minutes(row.speakingMinutes))} speaking${row.estimatedUsd === null ? ' (no list price)' : ` ≈ ${esc(usd(row.estimatedUsd))} (billed ${row.billing === 'connected' ? 'while connected' : 'for speech'})`}</li>`).join('')}</ul><p class="hint">An estimate from list prices, not a bill. Speaking time counts your voiced audio and the assistant's replies. Check your provider's billing page for actual charges.</p>`;
251
307
  }
308
+ /** Its own page while it is in beta: the fast-decision model and its key. */
309
+ function decisionsView() {
310
+ return `<section class="section"><div><h2>Fast decisions <span class="tag-beta">Beta</span></h2><p class="lede">Let simple requests skip the execution agent: a fast decision model answers or acts on this computer in about a second. Anything it is unsure about goes to the execution agent as usual.</p></div>${jevView()}</section>`;
311
+ }
312
+ /** Fast decisions (Jev): the user's own TypeSafe key, or a local server with the same API. */
313
+ function jevView() {
314
+ const jev = state.jev;
315
+ if (!jev) return '';
316
+ const { status, config, key } = jev;
317
+ const running = status.state === 'ready' ? `On · ${status.kind === 'local' ? 'local server' : 'TypeSafe'}` : status.state === 'needs_key' ? 'Off · needs a key' : 'Off';
318
+ const keyHint = key.fromEnv ? 'Provided by this computer’s environment (TYPESAFE_API_KEY).' : key.isSet ? 'Stored in this computer’s keychain.' : 'Use your own key from typesafe.ai. It stays in this computer’s keychain and is only sent to TypeSafe.';
319
+ return `<div class="card stack"><div class="row"><div><h3>Decision model (Jev)</h3><p class="hint">A System One model decides in about a tenth of a second whether the computer can do a request itself: open or quit apps, volume, music, dark mode, lock the screen, reminders, what agents are doing, one integration’s data, and home tile layout. Anything else, or anything it is unsure about, goes to the worker as usual. Without a key or local server, every request goes to the worker.</p></div><span class="badge ${status.state === 'ready' ? 'good' : 'warn'}">${esc(running)}</span></div>
320
+ <div class="check"><input id="jev-on" type="checkbox" ${config.mode === 'auto' ? 'checked' : ''}><label for="jev-on">Use fast decisions when a key or local server is set</label></div>
321
+ <label>Local Jev-compatible server (optional)<input id="jev-url" type="url" placeholder="Empty: TypeSafe’s hosted API" value="${esc(config.url ?? '')}"></label>
322
+ <p class="hint">For a model running on this computer or your network that serves the same <span class="code">POST /v1/systemone</span> API. No key is sent to it.</p>
323
+ <label>Model<input id="jev-model" value="${esc(config.model)}"></label>
324
+ <label>Act only at this confidence or higher (0.5–0.99)<input id="jev-confidence" type="number" min="0.5" max="0.99" step="0.01" value="${config.minConfidence}"></label>
325
+ <label>Give up after (ms)<input id="jev-timeout" type="number" min="200" max="10000" step="100" value="${config.timeoutMs}"></label>
326
+ ${jev.computerUse ? computerUseView(jev.computerUse) : ''}
327
+ <div class="actions"><button class="primary" data-action="save-jev">Save fast decisions</button><button data-action="test-jev" ${status.state === 'ready' ? '' : 'disabled'}>Test</button></div>
328
+ <h4>TypeSafe API key</h4><p class="hint">${keyHint}</p>
329
+ <div class="actions"><input id="jev-key" type="password" autocomplete="new-password" aria-label="TypeSafe API key" placeholder="Paste a key to set or replace"><button data-action="save-jev-key">Save key</button>${key.isSet && !key.fromEnv ? '<button class="danger" data-action="clear-jev-key">Clear key</button>' : ''}</div>
330
+ <p class="hint">Changes apply after a restart; you will be prompted.</p></div>`;
331
+ }
332
+ function computerUseView({ available, access }) {
333
+ if (!available) return '<div class="stack"><h4>Computer use</h4><p class="hint">Off: turn on fast decisions to let Jev drive the screen.</p></div>';
334
+ const host = access?.host || 'The app running the companion';
335
+ const badge = !access ? '' : access.accessibility ? `<span class="badge good">Accessibility allowed</span>` : `<span class="badge warn">Needs Accessibility</span>`;
336
+ const note = !access
337
+ ? 'The app running the companion needs Accessibility access in System Settings.'
338
+ : access.accessibility
339
+ ? `${esc(host)} (the app running the companion) has Accessibility access.`
340
+ : `${esc(host)} (the app running the companion) needs Accessibility access: open System Settings › Privacy &amp; Security › Accessibility, turn on ${esc(host)} (add it with + if it is not listed).`;
341
+ return `<div class="stack"><div class="row"><h4>Computer use</h4>${badge}</div><p class="hint">On: for click, scroll, and type requests that no integration covers, Jev moves the cursor, clicks, scrolls, and types on this computer. ${note}</p></div>`;
342
+ }
252
343
  function voiceView() {
253
344
  const { voice, catalog } = state.voice;
254
345
  const selected = catalog[voice.provider];
255
346
  const voices = voiceOptionsFor(selected, voice.model);
256
347
  return `<section class="section"><div><h2>Voice agent</h2><p class="lede">The voice agent talks with you in calls and answers typed messages in the chat, as one assistant. It hands all computer work to the execution agent below and cannot run local tools itself.</p></div>
257
- <div class="card stack"><h3>Voice agent model</h3><p class="hint">Used for calls and typed chat. Qwen and OpenAI Realtime reply to typed messages in text; the others speak, and their transcript is the reply (billed as audio). GPT-Live cannot read typed messages, so they go straight to the execution agent. With voice off, typed messages get simple local replies.</p><label>Voice provider<select id="voice-provider"><option value="none" ${voice.provider === 'none' ? 'selected' : ''}>Off</option>${Object.values(
348
+ <div class="card stack"><h3>Voice agent model</h3><p class="hint">Used for calls and typed chat. Qwen and OpenAI Realtime reply to typed messages in text; the others speak, and their transcript is the reply (billed as audio). GPT-Live cannot read typed messages, so they go straight to the execution agent. With voice off, typed messages get simple local replies.</p>${voiceNeedsKey(voice.provider) ? `<p class="hint error-text" role="status">${esc(selected.label)} needs a key on this computer. Add it under the API key below.</p>` : ''}<label>${sourceLabel('Voice provider', 'voice', 'provider')}<select id="voice-provider"><option value="none" ${voice.provider === 'none' ? 'selected' : ''}>Off</option>${Object.values(
258
349
  catalog
259
350
  )
260
351
  .map(
@@ -263,7 +354,7 @@ function voiceView() {
263
354
  )
264
355
  .join(
265
356
  ''
266
- )}<option value="echo" ${voice.provider === 'echo' ? 'selected' : ''}>Echo (audio test)</option></select></label><label>Voice model<select id="voice-model" ${selected ? '' : 'disabled'}>${voiceModelOptions(selected, voice.model)}</select></label><p id="voice-untested" class="hint" ${selected?.untested ? '' : 'hidden'}>Untested: this provider has not been run against the real service yet, so expect rough edges.</p><p class="hint">Prices are estimates from list prices. Most models charge only for speech, so a quiet or muted call costs little; models priced "while connected" charge for every minute the call is open, muted or not.</p><div id="voice-endpoint" class="stack">${voiceEndpointFields(selected, voice)}</div><label>Voice name<select id="voice-name">${voiceNameOptions(voices, voice.voiceName)}</select></label><div class="voice-preview"><button type="button" data-action="preview-voice" ${selected ? '' : 'disabled'}>Play voice sample</button><button type="button" data-action="stop-voice-preview" hidden>Stop sample</button><audio id="voice-preview-audio" controls preload="none" hidden></audio></div><p id="voice-preview-status" class="hint" role="status" aria-live="polite">${selected ? `Sample phrase: “Hi, I'm your assistant. What would you like to work on today?”` : 'Choose a live voice provider to hear a sample.'}</p><label>Maximum call length (minutes)<input id="voice-minutes" type="number" min="1" max="60" value="${voice.maxSessionMinutes}"></label><div class="stack"><h4>Spoken mute and unmute</h4><p class="hint">Your phone listens for these during a call, on the phone itself. Muting the mic stops what the assistant hears (it still speaks reports); muting the voice stops it speaking on the phone (replies still arrive as text). Use at least two words each; separate alternatives with commas. Unmute phrases work while muted. Changes apply from the next call after you apply them.</p><label>Mute mic and voice<input id="voice-mute-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(voice.commands.mute.join(', '))}"></label><label>Mute mic only<input id="voice-mute-mic-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(voice.commands.muteMic.join(', '))}"></label><label>Mute voice only<input id="voice-mute-voice-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(voice.commands.muteVoice.join(', '))}"></label><label>Unmute everything<input id="voice-unmute-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(voice.commands.unmute.join(', '))}"></label><label>End the call<input id="voice-end-call-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(voice.commands.endCall.join(', '))}"></label><label>Auto-mute the mic after this many quiet seconds<input id="voice-auto-mute" type="number" min="5" max="300" value="${voice.autoMute.seconds}"></label><div class="check"><input id="voice-auto-mute-on" type="checkbox" ${voice.autoMute.enabled ? 'checked' : ''}><label for="voice-auto-mute-on">Start calls with auto-mute on</label></div><p class="hint">Quiet means neither you nor the assistant is speaking. The call screen has an auto-mute toggle to change it during a call. Ending the call stops voice only; messages and task updates keep arriving in the app. Auto-mute works when the phone can listen for your unmute phrase.</p></div><div id="voice-search-row" class="check" ${voiceSearchSupported(selected) ? '' : 'hidden'}><input id="voice-search" type="checkbox" ${voice.webSearch ? 'checked' : ''}><label for="voice-search">Allow web search in voice calls</label></div><p id="voice-search-note" class="hint" ${selected && !voiceSearchSupported(selected) ? '' : 'hidden'}>This provider's built-in web search is not available in this companion.</p><div class="actions"><button class="primary" data-action="save-voice">Save voice settings</button></div><p class="hint">Provider and model changes apply after a restart; you will be prompted.</p></div><div class="card stack"><h3>Voice usage</h3>${voiceUsageView(state.voice.usage)}</div><div id="voice-key-panel" class="stack">${voiceKeysView(voice.provider)}</div></section>`;
357
+ )}<option value="echo" ${voice.provider === 'echo' ? 'selected' : ''}>Echo (audio test)</option></select></label><label>${sourceLabel('Voice model', 'voice', 'model')}<select id="voice-model" ${selected ? '' : 'disabled'}>${voiceModelOptions(selected, voice.model)}</select></label><p id="voice-untested" class="hint" ${selected?.untested ? '' : 'hidden'}>Untested: this provider has not been run against the real service yet, so expect rough edges.</p><p class="hint">Prices are estimates from list prices. Most models charge only for speech, so a quiet or muted call costs little; models priced "while connected" charge for every minute the call is open, muted or not.</p><div id="voice-endpoint" class="stack">${voiceEndpointFields(selected, voice)}</div><label>${sourceLabel('Voice name', 'voice', 'voiceName')}<select id="voice-name">${voiceNameOptions(voices, voice.voiceName)}</select></label><div class="voice-preview"><button type="button" data-action="preview-voice" ${selected ? '' : 'disabled'}>Play voice sample</button><button type="button" data-action="stop-voice-preview" hidden>Stop sample</button><audio id="voice-preview-audio" controls preload="none" hidden></audio></div><p id="voice-preview-status" class="hint" role="status" aria-live="polite">${selected ? `Sample phrase: “Hi, I'm your assistant. What would you like to work on today?”` : 'Choose a live voice provider to hear a sample.'}</p><label>${sourceLabel('Maximum call length (minutes)', 'voice', 'maxSessionMinutes')}<input id="voice-minutes" type="number" min="1" max="60" value="${voice.maxSessionMinutes}"></label><div class="stack"><h4>${sourceLabel('Spoken mute and unmute', 'voice', 'commands')}</h4>${commandPhraseFields(voice.commands)}<label>${sourceLabel('Auto-mute the mic after this many quiet seconds', 'voice', 'autoMute', 'autoMute.seconds')}<input id="voice-auto-mute" type="number" min="5" max="300" value="${voice.autoMute.seconds}"></label><div class="check"><input id="voice-auto-mute-on" type="checkbox" ${voice.autoMute.enabled ? 'checked' : ''}><label for="voice-auto-mute-on">Start calls with auto-mute on</label></div><p class="hint">Quiet means neither you nor the assistant is speaking. The call screen has an auto-mute toggle to change it during a call. Ending the call stops voice only; messages and task updates keep arriving in the app. Auto-mute works when the phone can listen for your unmute phrase.</p></div><div id="voice-search-row" class="check" ${voiceSearchSupported(selected) ? '' : 'hidden'}><input id="voice-search" type="checkbox" ${voice.webSearch ? 'checked' : ''}><label for="voice-search">${sourceLabel('Allow web search in voice calls', 'voice', 'webSearch')}</label></div><p id="voice-search-note" class="hint" ${selected && !voiceSearchSupported(selected) ? '' : 'hidden'}>This provider's built-in web search is not available in this companion.</p><div class="actions"><button class="primary" data-action="save-voice">Save voice settings</button></div><p class="hint">Provider and model changes apply after a restart; you will be prompted.</p></div><div class="card stack"><h3>Voice usage</h3>${voiceUsageView(state.voice.usage)}</div><div id="voice-key-panel" class="stack">${voiceKeysView(voice.provider)}</div></section>`;
267
358
  }
268
359
  function previewStatus(message, error = false) {
269
360
  const status = $('#voice-preview-status');
@@ -481,6 +572,15 @@ function updateModelChoices(selectedModel = '') {
481
572
  const interval = (seconds) => (seconds < 60 ? `${seconds} sec` : seconds >= 3600 && seconds % 3600 === 0 ? `${seconds / 3600} hr` : `${Math.round(seconds / 60)} min`);
482
573
 
483
574
  /** `embedded` drops the page heading when setup shows these controls under its own. */
575
+ /** The user's default agents: the agents app with their main work agents, separate from the execution agent. */
576
+ function defaultAgentsView() {
577
+ const apps = (state.packs?.packs || []).filter(pack => pack.agentsApp && pack.enabled);
578
+ const chosen = state.packs?.defaultAgents || '';
579
+ const hint = apps.length
580
+ ? '“What agents are running?” checks this app’s threads. It is not the execution agent above, which takes requests from voice and does the work.'
581
+ : 'Install and turn on an agents app (for example T3 Agents, Hermes, or OpenClaw) under MCP &amp; widgets to choose one.';
582
+ return `<div class="card stack"><h3>Default agents</h3><p class="hint">${hint}</p>${apps.length ? `<label>Your main work agents<select id="default-agents"><option value="" ${chosen ? '' : 'selected'}>${apps.length === 1 ? 'Automatic (the only agents app)' : 'Not chosen: the execution agent checks'}</option>${apps.map(app => `<option value="${esc(app.id)}" ${app.id === chosen ? 'selected' : ''}>${esc(app.name)}</option>`).join('')}</select></label><div class="actions"><button data-action="save-default-agents">Save default agents</button></div>` : ''}</div>`;
583
+ }
484
584
  function executionView({ embedded = false } = {}) {
485
585
  const providers = state.providers;
486
586
  const executors = state.executors || [];
@@ -504,9 +604,11 @@ function executionView({ embedded = false } = {}) {
504
604
  const readiness = instance => !instance.enabled ? 'Disabled' : !instance.status ? 'Not checked' : !instance.status.installed ? 'Not installed' : instance.status.auth === 'unauthenticated' ? 'Sign-in needed' : instance.status.auth === 'authenticated' ? 'Ready' : 'Installed · sign-in not verified';
505
605
  const profiles = providers?.profiles.filter(profile => instances.some(instance => [instance.instanceId, instance.displayName, instance.driver].includes(profile.provider))) || [];
506
606
  const promptFor = executor => executor.connector === 'direct' ? state.profilePrompts?.[executor.id.slice('direct:'.length)] : undefined;
507
- const agentRows = group.agents.map(executor => `<div class="agent-choice ${executor.isDefault ? 'chosen' : ''}"><div><strong>${esc(executor.label)}</strong><p class="hint">${esc(executor.detail || 'Uses the provider’s default model')}${promptFor(executor) ? ' · Custom system prompt' : ''}</p></div><div class="agent-actions">${executor.connector === 'direct' ? `<button data-action="edit-profile" data-id="${esc(executor.id.slice('direct:'.length))}" aria-label="Edit ${esc(executor.label)} model, thinking level, permissions, and system prompt">Edit</button>` : ''}${executor.isDefault ? '<span class="badge good">Default</span>' : `<button data-action="set-default-executor" data-id="${esc(executor.id)}" aria-label="Use ${esc(executor.label)} by default">Use by default</button>`}</div></div>`).join('');
607
+ const needsFor = executor => executor.connector === 'direct' ? needsBadge(executor.id.slice('direct:'.length)) : '';
608
+ const agentRows = group.agents.map(executor => `<div class="agent-choice ${executor.isDefault ? 'chosen' : ''}"><div><strong>${esc(executor.label)}</strong><p class="hint">${esc(executor.detail || 'Uses the provider’s default model')}${promptFor(executor) ? ' · Custom system prompt' : ''}</p></div><div class="agent-actions">${needsFor(executor)}${executor.connector === 'direct' ? `<button data-action="edit-profile" data-id="${esc(executor.id.slice('direct:'.length))}" aria-label="Edit ${esc(executor.label)} model, thinking level, permissions, and system prompt">Edit</button>` : ''}${executor.isDefault ? '<span class="badge good">Default</span>' : `<button data-action="set-default-executor" data-id="${esc(executor.id)}" aria-label="Use ${esc(executor.label)} by default">Use by default</button>`}</div></div>`).join('');
508
609
  return `<section class="section execution">${embedded ? '<p class="lede">Choose the agent on this computer that does the work you ask for. Voice hands requests to it.</p>' : '<div><h2>Who does the work?</h2><p class="lede">Choose the agent that handles tasks you send from your phone.</p></div>'}
509
- <div class="default-agent"><div><small>YOUR DEFAULT AGENT</small><h3>${esc(selected?.label || 'Choose an agent below')}</h3><p>${esc(selected?.detail || 'Add an agent, then choose it as your default.')}</p></div><span class="badge">${selected ? 'Used unless you name another agent' : 'Not set up yet'}</span></div>
610
+ <div class="default-agent"><div><small>YOUR DEFAULT AGENT</small><h3>${sourceLabel(esc(selected?.label || 'Choose an agent below'), 'agents', 'defaultProfileId')}</h3><p>${esc(selected?.detail || 'Add an agent, then choose it as your default.')}</p></div><span class="badge">${selected ? 'Used unless you name another agent' : 'Not set up yet'}</span></div>
611
+ ${waitingProfilesCard(providers)}
510
612
  <div class="row"><h3>Choose a provider</h3><button data-action="refresh-executors">Check availability</button></div>
511
613
  <div class="provider-tabs" role="tablist" aria-label="Execution providers">${groups.map(candidate => `<button role="tab" aria-selected="${candidate.id === group.id}" class="provider-tab ${candidate.id === group.id ? 'selected' : ''}" data-action="select-execution-provider" data-id="${esc(candidate.id)}"><strong>${esc(candidate.label)}</strong><span>${candidate.agents.length ? `${candidate.agents.length} agent${candidate.agents.length === 1 ? '' : 's'}` : 'Set up'}</span></button>`).join('')}</div>
512
614
  <div class="card stack" role="tabpanel" aria-label="${esc(group.label)} agents"><div class="row"><div><h3>${esc(group.label)}</h3><p class="hint">Agents and models running on this computer.</p></div></div>
@@ -522,11 +624,24 @@ function executionView({ embedded = false } = {}) {
522
624
  <details><summary>Permissions</summary><label>When the agent changes files or runs commands<select id="profile-mode">${runtimeModeChoices('approval-required')}</select></label></details>
523
625
  <button class="primary" data-action="quick-add-agent" data-driver="${esc(group.id)}">Add agent and use by default</button><p class="hint">Uses this provider’s local installation and sign-in. Adding an agent does not install the provider or grant access to additional folders.</p>
524
626
  </div></details>
525
- ${instances.length ? `<details><summary>Manage ${esc(group.label)} installation and agents</summary><div class="stack">${instances.map(instance => `<div class="row"><strong>${esc(instance.displayName)}</strong><div class="actions"><button data-action="edit-provider" data-id="${esc(instance.instanceId)}">Settings</button><button data-action="toggle-provider" data-id="${esc(instance.instanceId)}" data-driver="${esc(instance.driver)}" data-enabled="${instance.enabled}">${instance.enabled ? 'Disable' : 'Enable'}</button><button class="danger" data-action="remove-provider" data-id="${esc(instance.instanceId)}">Remove installation</button></div></div>`).join('')}${profiles.map(profile => `<div class="row"><span>${esc(profile.name)}${profile.available ? '' : ' · unavailable'}</span><button class="danger" data-action="remove-profile" data-id="${esc(profile.id)}">Remove agent</button></div>`).join('')}</div></details>` : ''}`}
627
+ ${instances.length ? `<details><summary>Manage ${esc(group.label)} installation and agents</summary><div class="stack">${instances.map(instance => `<div class="row"><strong>${esc(instance.displayName)}</strong><div class="actions"><button data-action="edit-provider" data-id="${esc(instance.instanceId)}">Settings</button><button data-action="toggle-provider" data-id="${esc(instance.instanceId)}" data-driver="${esc(instance.driver)}" data-enabled="${instance.enabled}">${instance.enabled ? 'Disable' : 'Enable'}</button><button class="danger" data-action="remove-provider" data-id="${esc(instance.instanceId)}">Remove installation</button></div></div>`).join('')}${profiles.map(profile => `<div class="row"><span>${esc(profile.name)}${profile.available ? '' : ' · unavailable'} ${needsBadge(profile.id)}</span><button class="danger" data-action="remove-profile" data-id="${esc(profile.id)}">Remove agent</button></div>`).join('')}</div></details>` : ''}`}
526
628
  </div><div id="profile-editor"></div><div id="provider-editor"></div>
527
629
  ${providers ? `<div class="card stack"><h3>Where local agents work</h3><p class="hint">${providers.folderRoots?.length ? `Any folder inside ${providers.folderRoots.map(root => `<span class="code">${esc(root)}</span>`).join(', ')}. Ask for a project by name or path; the companion lists the git projects it finds there.` : 'Only the named folders below.'} Set <span class="code">providers.folderRoots</span> in config.json to change this.</p>${providers.workspaces.length ? `<details><summary>Named folders · ${providers.workspaces.length}</summary>${providers.workspaces.map(workspace => `<p><strong>${esc(workspace.name)}</strong><br><span class="code">${esc(workspace.path)}</span></p>`).join('')}</details>` : ''}</div>` : ''}
630
+ ${defaultAgentsView()}
528
631
  </section>`;
529
632
  }
633
+ /** A profile whose app (driver) is not set up here, e.g. one from another computer: "Needs Claude Code on this computer". */
634
+ function needsBadge(profileId) {
635
+ const needs = state.profileNeeds?.[profileId];
636
+ return needs ? `<span class="badge warn">${esc(needs)}</span>` : '';
637
+ }
638
+ /** Profiles with no installation here at all, which no provider tab lists. */
639
+ function waitingProfilesCard(providers) {
640
+ const waiting = Object.keys(state.profileNeeds || {}).map(id => providers?.profiles.find(profile => profile.id === id) || { id, name: id, provider: null })
641
+ .filter(profile => !providers?.instances.some(instance => [instance.instanceId, instance.displayName, instance.driver].includes(profile.provider)));
642
+ if (!waiting.length) return '';
643
+ return `<div class="card stack"><div><h3>Agents waiting for their app</h3><p class="hint">Install and sign in to the app each agent uses on this computer, then check availability.</p></div>${waiting.map(profile => `<div class="row"><strong>${esc(profile.name)}</strong>${needsBadge(profile.id)}</div>`).join('')}</div>`;
644
+ }
530
645
  const RUNTIME_MODES = [['approval-required', 'Ask me first'], ['read-only', 'Read only'], ['auto-accept-edits', 'Accept edits automatically'], ['auto', 'Automatic'], ['full-access', 'Full access']];
531
646
  function runtimeModeChoices(selected) {
532
647
  return RUNTIME_MODES.map(([value, label]) => `<option value="${value}" ${value === selected ? 'selected' : ''}>${label}</option>`).join('');
@@ -559,18 +674,50 @@ function accountLabel(value) {
559
674
  }[value] || 'No OAuth sign-in required'
560
675
  );
561
676
  }
677
+ /** Packs the library pulled: turned off here, and said so at the top of the page until updated, rolled back, or lifted. */
678
+ function pulledBanner() {
679
+ const pulled = state.packs.packs.filter((pack) => pack.pulled);
680
+ return pulled.length ? `<div class="card trust-notice community" role="alert"><strong>${pulled.length === 1 ? `${esc(pulled[0].name)} was turned off` : `${pulled.length} integrations were turned off`}</strong>${pulled.map((pack) => `<p>Sidebud pulled ${esc(pack.name)} ${esc(pack.pulled.version)} from the widget library: ${esc(pack.pulled.reason)}. It stays off until you update it${pack.rollbackVersion ? ` or roll back to ${esc(pack.rollbackVersion)}` : ''}.</p>`).join('')}</div>` : '';
681
+ }
562
682
  function packStatusDetail(pack) {
563
683
  const detail = pack.status.detail || '';
564
684
  return detail.startsWith('Connect your account: run `rva-companion pack auth')
565
685
  ? 'Connect the account in Settings & permissions.'
566
686
  : detail;
567
687
  }
688
+ /** How the computer's agent's setup of a pack went: working on it, or its reply once done. */
689
+ function packSetupLine(packId) {
690
+ const setup = (state.packSetup || {})[packId];
691
+ if (!setup) return '';
692
+ if (setup.running) return '<p class="hint" role="status">Setup: this computer\'s agent is working on it…</p>';
693
+ // The agent answers in Markdown; the card shows plain text.
694
+ const text = (setup.reply || '').replace(/\*\*|__|`/g, '').replace(/^#+\s*/gm, '');
695
+ const reply = text.length > 600 ? `${text.slice(0, 600)}…` : text;
696
+ return setup.failed
697
+ ? `<p class="hint bad" role="status">Setup failed: ${esc(setup.blocker || reply || 'the agent stopped.')}</p>`
698
+ : `<p class="hint" role="status">Setup finished: ${esc(reply || 'no reply.')}</p>`;
699
+ }
700
+ /** A library card's state line: deprecated, pulled, or edited here, each with what to do about it. */
701
+ function catalogNotes(entry) {
702
+ return [
703
+ entry.pulled ? `<p class="hint"><span class="badge bad">Pulled</span> Sidebud pulled the installed version from the library: ${esc(entry.pulled)}. It is off until you update it${entry.canRollback ? ' or roll back' : ''}.</p>` : '',
704
+ entry.deprecated ? `<p class="hint"><span class="badge warn">Deprecated</span> ${esc(entry.deprecated)}</p>` : '',
705
+ entry.customized ? `<p class="hint"><span class="badge">Changed here</span> This copy was edited on this computer, so library updates are not installed over it. Customize a copy to keep your changes, or remove it and install again.</p>` : '',
706
+ ].join('');
707
+ }
708
+ function catalogAction(entry) {
709
+ if ((entry.publisher.source === 'community' || controlsComputer(entry.access)) && !entry.installed) return `<a class="button quiet" href="#mcp/library/${encodeURIComponent(entry.id)}">Review and install</a>`;
710
+ const rollback = entry.canRollback ? `<button data-action="rollback-pack" data-id="${esc(entry.packId)}">Roll back</button>` : '';
711
+ if (entry.updateAvailable) return `<div class="actions"><button data-action="install-library-pack" data-id="${esc(entry.id)}" data-replace="true" ${entry.customized ? 'disabled title="Edited on this computer: customize a copy to keep your changes, or remove it and install again."' : ''} aria-label="Update ${esc(entry.name)} from version ${esc(entry.installedVersion)} to ${esc(entry.version)}">Update to ${esc(entry.version)}</button>${rollback}</div>`;
712
+ const install = `<button data-action="install-library-pack" data-id="${esc(entry.id)}" ${entry.installed ? 'disabled' : ''} ${entry.needsReinstall ? `title="The installed copy no longer loads. Reinstalling keeps its settings and keys."` : ''}>${entry.installed ? `Installed · ${esc(entry.installedVersion)}` : entry.needsReinstall ? `Reinstall ${esc(entry.name)}` : `Install ${esc(entry.name)}`}</button>`;
713
+ return rollback ? `<div class="actions">${install}${rollback}</div>` : install;
714
+ }
568
715
  function catalogView() {
569
716
  const intro = `<div><h3>Widget library</h3><p class="hint">Widget packs from the Sidebud widget library. Install one and this computer's agent sets it up: it finds settings and keys already on this computer and reports what needs you. Every new install starts disabled until setup enables it. Your agent can also build new packs, or change any installed one, when you ask.</p>${state.libraryError ? `<p class="hint">The library could not be reached: ${esc(state.libraryError)}</p>` : ''}</div>`;
570
- return `${intro}<div class="grid">${state.integrationCatalog.map(entry => `<article class="card stack"><div class="row"><h3><a href="#mcp/library/${encodeURIComponent(entry.id)}">${esc(entry.name)}</a></h3><span class="badge ${entry.publisher.source === 'community' ? 'warn' : ''}">${entry.installed ? 'Installed' : entry.detected ? 'App found' : entry.publisher.source === 'community' ? 'Community' : esc(entry.publisher.name)}</span></div><p>${esc(entry.description)}</p><p class="hint">${esc(entry.account)} · ${esc(entry.connectsTo)}</p>${entry.tools.length ? `<details><summary>Access</summary><p class="hint">${entry.tools.map(tool => `${code(tool.name)} (${esc(tool.access)})`).join(', ')}</p></details>` : ''}<a class="hint" href="#mcp/library/${encodeURIComponent(entry.id)}">What it can do and how it looks →</a>${entry.publisher.source === 'community' && !entry.installed ? `<a class="button" href="#mcp/library/${encodeURIComponent(entry.id)}">Review and install</a>` : entry.updateAvailable ? `<button data-action="install-library-pack" data-id="${esc(entry.id)}" data-replace="true" aria-label="Update ${esc(entry.name)} from version ${esc(entry.installedVersion)} to ${esc(entry.version)}">Update to ${esc(entry.version)}</button>` : `<button data-action="install-library-pack" data-id="${esc(entry.id)}" ${entry.installed ? 'disabled' : ''} ${entry.needsReinstall ? `title="The installed copy no longer loads. Reinstalling keeps its settings and keys."` : ''}>${entry.installed ? `Installed · ${esc(entry.installedVersion)}` : entry.needsReinstall ? `Reinstall ${esc(entry.name)}` : `Install ${esc(entry.name)}`}</button>`}</article>`).join('')}</div>`;
717
+ return `${intro}<div class="grid">${state.integrationCatalog.map(entry => `<article class="card stack"><div class="row"><h3><a href="#mcp/library/${encodeURIComponent(entry.id)}">${esc(entry.name)}</a></h3><span class="badge ${entry.publisher.source === 'community' && !entry.publisher.official ? 'warn' : ''}">${entry.installed ? 'Installed' : entry.detected ? 'App found' : entry.publisher.official ? `Official · ${esc(entry.publisher.official.domain)}` : entry.publisher.source === 'community' ? 'Community' : esc(entry.publisher.name)}</span></div><p>${esc(entry.description)}</p><p class="hint">${esc(entry.account)} · ${esc(entry.connectsTo)}</p><div class="access-line">${accessBadge(entry.access)}${controlsComputer(entry.access) ? `<p class="hint">${esc(entry.access.points[0].text)}</p>` : ''}</div>${catalogNotes(entry)}<a class="hint" href="#mcp/library/${encodeURIComponent(entry.id)}">What it can do and how it looks →</a>${catalogAction(entry)}</article>`).join('')}</div>`;
571
718
  }
572
719
  function mcpView() {
573
- return `<section class="section"><div><h2>Integrations & widgets</h2><p class="lede">Connect tools to Sidebud and choose which ones appear on your phone. Credentials and connections stay on this computer. New here? Start with the <a href="#guide">Guide</a>.</p></div><h3>Installed integrations</h3><div class="grid">${state.packs.packs.map(p => `<div class="card"><div class="row"><h3><a href="#mcp/${encodeURIComponent(p.id)}">${esc(p.name)}</a></h3><span class="badge ${{ ok: 'good', needs_setup: 'warn', error: 'bad' }[p.status.state] || ''}">${esc({ ok: 'Enabled', needs_setup: 'Needs setup', error: 'Error', disabled: 'Disabled' }[p.status.state] || p.status.state)}</span></div><p class="hint">${esc(p.description)}</p><p class="hint">${esc(packStatusDetail(p))}</p>${state.packAuth[p.id] ? `<p class="hint">Account: ${esc(accountLabel(state.packAuth[p.id]))}</p>` : ''}${state.connectionErrors[p.id] ? `<p class="hint">Connection failed: ${esc(state.connectionErrors[p.id])}</p>` : ''}${p.refresh ? `<label class="hint">Refresh every <select data-refresh-pack="${esc(p.id)}" aria-label="Refresh ${esc(p.name)} every">${[...new Set([...p.refresh.options, p.refresh.defaultSeconds, p.refresh.seconds])].sort((a, b) => a - b).map((seconds) => `<option value="${seconds}" ${seconds === p.refresh.seconds ? 'selected' : ''}>${esc(interval(seconds))}${seconds === p.refresh.defaultSeconds ? ' (default)' : ''}</option>`).join('')}</select></label>` : ''}${p.announce ? `<p class="hint">Spoken updates: ${p.announce.on ? 'on' : 'off'}${p.announce.custom ? '' : ' (default)'} · <button class="link" data-action="toggle-announce" data-id="${esc(p.id)}" data-on="${p.announce.on}">${p.announce.on ? 'Turn off' : 'Turn on'}</button>${p.announce.custom ? ` · <button class="link" data-action="default-announce" data-id="${esc(p.id)}">Use default</button>` : ''}</p>` : ''}<div class="actions pack-actions"><button class="primary" data-action="setup-pack" data-id="${esc(p.id)}">${p.status.state === 'ok' ? 'Check setup with my agent' : 'Set up with my agent'}</button><button data-action="share-pack" disabled title="Publishing to your Sidebud account arrives with accounts">Share</button><details class="menu"><summary aria-label="More actions for ${esc(p.name)}">…</summary><div class="menu-list" role="menu"><button role="menuitem" data-action="toggle-pack" data-id="${esc(p.id)}" data-enabled="${p.enabled}">${p.enabled ? 'Disable' : 'Enable'}</button><a role="menuitem" class="button" href="#mcp/${encodeURIComponent(p.id)}">Settings & permissions</a><button role="menuitem" data-action="check-pack" data-id="${esc(p.id)}" ${p.enabled ? '' : 'disabled'}>Check connection</button><button role="menuitem" data-action="customize-pack" data-id="${esc(p.id)}">Customize a copy</button><button role="menuitem" data-action="export-pack" data-id="${esc(p.id)}">Export</button><button role="menuitem" data-action="remove-pack" data-id="${esc(p.id)}">Remove</button></div></details></div></div>`).join('') || '<div class="card muted">No integrations installed. Choose one from the widget library below, or ask your agent to build one.</div>'}</div><div id="pack-editor"></div>${catalogView()}<div class="card"><h3>Import a widget pack</h3><p class="hint">Choose a pack file exported from Sidebud or downloaded from the widget library. It installs disabled, then this computer's agent sets it up. Pack files hold the manifest, logo, skill, and any server files, never settings, keys, or account tokens.</p><div class="actions"><input id="pack-import" type="file" accept=".json,application/json" aria-label="Widget pack export file"><button data-action="import-pack">Import</button></div></div><div class="card"><h3>Install your own widget pack</h3><p class="hint">Provide the absolute folder containing pack.json. Existing installations are never overwritten.</p><div class="actions"><input id="pack-folder" aria-label="Widget pack folder" placeholder="/path/to/widget-pack"><button data-action="install-pack">Validate & install</button></div></div><div class="card"><h3>Build or change a pack</h3><p class="hint">Ask your agent: "make a widget that shows …" or "change my Gmail widget to …". It follows the widget-packs skill, builds or edits the pack, and asks before turning it on. See how it will look under <a href="#phone">Phone preview</a>, and how it all works in the <a href="#guide">Guide</a>.</p><p class="hint">Community submissions and reviewed release distribution are planned. Local packs can be installed now.</p></div></section>`;
720
+ return `<section class="section"><div><h2>Integrations & widgets</h2><p class="lede">Connect tools to Sidebud and choose which ones appear on your phone. Credentials and connections stay on this computer. New here? Start with the <a href="#guide">Guide</a>.</p></div>${pulledBanner()}<h3>Installed integrations</h3><div class="grid">${state.packs.packs.map(p => `<div class="card"><div class="row"><h3><a href="#mcp/${encodeURIComponent(p.id)}">${esc(p.name)}</a></h3>${p.pulled ? '<span class="badge bad">Pulled</span>' : `<span class="badge ${{ ok: 'good', needs_setup: 'warn', error: 'bad' }[p.status.state] || ''}">${esc({ ok: 'Enabled', needs_setup: 'Needs setup', error: 'Error', disabled: 'Disabled' }[p.status.state] || p.status.state)}</span>`}</div><p class="hint">${esc(p.description)}</p><div class="access-line">${accessBadge(state.packAccess[p.id])}</div><p class="hint">${esc(packStatusDetail(p))}</p>${packSetupLine(p.id)}${state.packAuth[p.id] ? `<p class="hint">Account: ${esc(accountLabel(state.packAuth[p.id]))}</p>` : ''}${state.connectionErrors[p.id] ? `<p class="hint">Connection failed: ${esc(state.connectionErrors[p.id])}</p>` : ''}${p.refresh ? `<label class="hint">Refresh every <select data-refresh-pack="${esc(p.id)}" aria-label="Refresh ${esc(p.name)} every">${[...new Set([...p.refresh.options, p.refresh.defaultSeconds, p.refresh.seconds])].sort((a, b) => a - b).map((seconds) => `<option value="${seconds}" ${seconds === p.refresh.seconds ? 'selected' : ''}>${esc(interval(seconds))}${seconds === p.refresh.defaultSeconds ? ' (default)' : ''}</option>`).join('')}</select></label>` : ''}${p.announce ? `<p class="hint">Spoken updates: ${p.announce.on ? 'on' : 'off'}${p.announce.custom ? '' : ' (default)'} · <button class="link" data-action="toggle-announce" data-id="${esc(p.id)}" data-on="${p.announce.on}">${p.announce.on ? 'Turn off' : 'Turn on'}</button>${p.announce.custom ? ` · <button class="link" data-action="default-announce" data-id="${esc(p.id)}">Use default</button>` : ''}</p>` : ''}<div class="actions pack-actions">${p.enabled ? '' : `<button class="primary" data-action="toggle-pack" data-id="${esc(p.id)}" data-enabled="false">Turn on</button>`}<button ${p.enabled ? 'class="primary" ' : ''}data-action="setup-pack" data-id="${esc(p.id)}">${p.status.state === 'ok' ? 'Check setup with my agent' : 'Set up with my agent'}</button><button data-action="share-pack" disabled title="Publishing to your Sidebud account arrives with accounts">Share</button><details class="menu"><summary aria-label="More actions for ${esc(p.name)}">…</summary><div class="menu-list" role="menu"><button role="menuitem" data-action="toggle-pack" data-id="${esc(p.id)}" data-enabled="${p.enabled}">${p.enabled ? 'Disable' : 'Enable'}</button><a role="menuitem" class="button" href="#mcp/${encodeURIComponent(p.id)}">Settings & permissions</a><button role="menuitem" data-action="check-pack" data-id="${esc(p.id)}" ${p.enabled ? '' : 'disabled'}>Check connection</button><button role="menuitem" data-action="customize-pack" data-id="${esc(p.id)}">Customize a copy</button><button role="menuitem" data-action="export-pack" data-id="${esc(p.id)}">Export</button><button role="menuitem" data-action="remove-pack" data-id="${esc(p.id)}">Remove</button></div></details></div></div>`).join('') || '<div class="card muted">No integrations installed. Choose one from the widget library below, or ask your agent to build one.</div>'}</div><div id="pack-editor"></div>${catalogView()}<div class="card"><h3>Import a widget pack</h3><p class="hint">Choose a pack file exported from Sidebud or downloaded from the widget library. It installs disabled, then this computer's agent sets it up. Pack files hold the manifest, logo, skill, and any server files, never settings, keys, or account tokens.</p><div class="actions"><input id="pack-import" type="file" accept=".json,application/json" aria-label="Widget pack export file"><button data-action="import-pack">Import</button></div></div><div class="card"><h3>Install your own widget pack</h3><p class="hint">Provide the absolute folder containing pack.json. Existing installations are never overwritten.</p><div class="actions"><input id="pack-folder" aria-label="Widget pack folder" placeholder="/path/to/widget-pack"><button data-action="install-pack">Validate & install</button></div></div><div class="card"><h3>Build or change a pack</h3><p class="hint">Ask your agent: "make a widget that shows …" or "change my Gmail widget to …". It follows the widget-packs skill, builds or edits the pack, and asks before turning it on. See how it will look under <a href="#phone">Phone preview</a>, and how it all works in the <a href="#guide">Guide</a>.</p><p class="hint">Community submissions and reviewed release distribution are planned. Local packs can be installed now.</p></div></section>`;
574
721
  }
575
722
  function customizePackEditor(p) {
576
723
  return `<div class="card stack"><h3>Customize ${esc(p.name)}</h3><p class="hint">Creates a disabled copy with its own settings and credentials. The original is preserved.</p><label>New integration ID<input id="copy-pack-id" placeholder="dev.yourname.notes"></label><label>New display name<input id="copy-pack-name" placeholder="My notes"></label><div class="actions"><button data-action="save-pack-copy" data-id="${esc(p.id)}">Create copy</button><button data-action="close-pack">Cancel</button></div></div>`;
@@ -738,6 +885,13 @@ document.addEventListener('click', async (event) => {
738
885
  await applyChanges();
739
886
  return;
740
887
  }
888
+ if (action === 'setting-pin') {
889
+ const pinned = button.dataset.pinned !== 'true';
890
+ await api('settings/pin', { section: button.dataset.section, path: button.dataset.path.split('.'), pinned });
891
+ notice(pinned ? 'Kept on this computer. Changes from your account no longer replace it.' : 'This setting follows your account again.');
892
+ await reload();
893
+ return;
894
+ }
741
895
  if (await handleSetupAction(action, button)) return;
742
896
  if (await handleSettingsAction(action, button)) return;
743
897
  if (action === 'pair-phone') {
@@ -777,6 +931,32 @@ document.addEventListener('click', async (event) => {
777
931
  await reload();
778
932
  return;
779
933
  }
934
+ if (action === 'save-jev') {
935
+ const url = $('#jev-url').value.trim();
936
+ await api('jev', {
937
+ jev: {
938
+ mode: $('#jev-on').checked ? 'auto' : 'off',
939
+ url: url || null,
940
+ model: $('#jev-model').value.trim() || 'jev-latest',
941
+ minConfidence: Number($('#jev-confidence').value),
942
+ timeoutMs: Number($('#jev-timeout').value),
943
+ },
944
+ });
945
+ notice('Fast decision settings saved. Restart to apply them.');
946
+ await reload();
947
+ return;
948
+ }
949
+ if (action === 'save-jev-key' || action === 'clear-jev-key') {
950
+ await api('jev-key', { value: action === 'clear-jev-key' ? '' : $('#jev-key').value });
951
+ notice('Key updated in this computer’s keychain. Restart to apply it.');
952
+ await reload();
953
+ return;
954
+ }
955
+ if (action === 'test-jev') {
956
+ const result = await api('jev/test', {});
957
+ notice(`Fast decisions work: ${result.model} answered “${result.choice}” in ${result.ms} ms.`);
958
+ return;
959
+ }
780
960
  if (action === 'save-voice-key' || action === 'clear-voice-key') {
781
961
  await api('voice-key', {
782
962
  provider: button.dataset.provider,
@@ -791,6 +971,12 @@ document.addEventListener('click', async (event) => {
791
971
  await reload();
792
972
  return;
793
973
  }
974
+ if (action === 'save-default-agents') {
975
+ await api('default-agents', { packId: $('#default-agents').value || null });
976
+ notice('Default agents saved.');
977
+ await reload();
978
+ return;
979
+ }
794
980
  if (action === 'set-default-executor') {
795
981
  await api('executor', { executorId: id });
796
982
  notice('Default executor updated.');
@@ -898,6 +1084,10 @@ document.addEventListener('click', async (event) => {
898
1084
  return;
899
1085
  }
900
1086
  if (action === 'toggle-pack') {
1087
+ const turningOn = button.dataset.enabled !== 'true';
1088
+ const access = state.packAccess[id];
1089
+ const pack = state.packs.packs.find((candidate) => candidate.id === id);
1090
+ if (turningOn && controlsComputer(access) && !confirm(`Turn on ${pack?.name || id}?\n\n${computerConfirmText(pack?.name || id, access)}`)) return;
901
1091
  await api('pack', {
902
1092
  packId: id,
903
1093
  enabled: button.dataset.enabled !== 'true',
@@ -947,7 +1137,8 @@ document.addEventListener('click', async (event) => {
947
1137
  }
948
1138
  if (action === 'setup-pack') {
949
1139
  await api('pack/setup', { packId: id });
950
- notice("This computer's agent is setting it up. Its task reports back on your phone when it finishes or if something needs you.");
1140
+ notice("This computer's agent is setting it up; how it goes shows on its card.");
1141
+ await reload();
951
1142
  return;
952
1143
  }
953
1144
  if (action === 'check-pack') {
@@ -1009,18 +1200,42 @@ document.addEventListener('click', async (event) => {
1009
1200
  return;
1010
1201
  }
1011
1202
  if (action === 'install-library-pack') {
1203
+ const replace = button.dataset.replace === 'true';
1204
+ const entry = state.integrationCatalog.find((candidate) => candidate.id === button.dataset.id);
1205
+ const installed = entry && state.packs.packs.find((pack) => pack.id === entry.packId);
1206
+ // An update keeps a pack that is on turned on, so a pack that controls the computer is confirmed first, as when turning it on.
1207
+ if (replace && installed?.enabled && controlsComputer(entry.access) && !confirm(`Update ${entry.name} to ${entry.version} and keep it on?\n\n${computerConfirmText(entry.name, entry.access)}`)) return;
1012
1208
  libraryPage = null;
1013
- const result = await api('pack/install-library', {
1014
- id: button.dataset.id,
1015
- ...(button.dataset.replace === 'true' ? { replace: true } : {}),
1016
- });
1017
- notice(
1018
- result.updated && !result.setup.started
1019
- ? `${result.name} updated to ${result.version}; its settings and keys were kept.`
1020
- : result.setup.started
1021
- ? `${result.name} installed. This computer's agent is setting it up and reports back when it finishes.`
1022
- : `${result.name} installed, but setup did not start: ${result.setup.note} Set it up in Settings & permissions.`
1023
- );
1209
+ button.disabled = true;
1210
+ button.textContent = replace ? 'Updating…' : 'Installing…';
1211
+ const request = { id: button.dataset.id, ...(replace ? { replace: true } : {}) };
1212
+ let result;
1213
+ try {
1214
+ result = await api('pack/install-library', request);
1215
+ } catch (error) {
1216
+ // An update that lets the pack do more waits for the owner: show what grows, in words, and ask.
1217
+ const asked = error.data?.consentRequired ? error.data : null;
1218
+ if (!asked) throw error;
1219
+ const lines = asked.changes.map((change) => `${change.grows ? '•' : '◦'} ${change.text}`).join('\n');
1220
+ if (!confirm(`Updating ${asked.name} to ${asked.version} lets it do more on this computer:\n\n${lines}\n\nInstall the update?`)) {
1221
+ notice(`${asked.name} was not updated. The installed version keeps working.`);
1222
+ await reload();
1223
+ return;
1224
+ }
1225
+ result = await api('pack/install-library', { ...request, acceptChanges: true });
1226
+ }
1227
+ if (result.keptOn && result.check && !result.check.ok) notice(`${result.name} updated to ${result.version} and turned back on, but its connection check failed: ${result.check.error}`, true);
1228
+ else if (result.keptOn) notice(`${result.name} updated to ${result.version} and still on${result.check ? '; its connection check passed' : ''}. Its settings and keys were kept.`);
1229
+ else if (result.setup.started) notice(`${result.name} ${result.updated ? `updated to ${result.version}` : 'installed'}. This computer's agent is setting it up; how it goes shows on its card.`);
1230
+ else notice(`${result.name} ${result.updated ? 'updated' : 'installed'}, but setup did not start: ${result.setup.note} Set it up in Settings & permissions.`, true);
1231
+ await reload();
1232
+ return;
1233
+ }
1234
+ if (action === 'rollback-pack') {
1235
+ const pack = state.packs.packs.find((candidate) => candidate.id === id);
1236
+ if (!confirm(`Go back to ${pack?.name || id} ${pack?.rollbackVersion || 'its previous version'}? Its settings and keys are kept.`)) return;
1237
+ const result = await api('pack/rollback', { packId: id });
1238
+ notice(`${result.name} is back to version ${result.version}${result.enabled ? ' and still on.' : '. Turn it on when you are ready.'}`);
1024
1239
  await reload();
1025
1240
  return;
1026
1241
  }