sidebud 0.1.0 → 0.4.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/main.js +9361 -4209
  3. package/package.json +3 -2
  4. package/skills/widget-packs/SKILL.md +199 -0
  5. package/src/manage/guide/app-arrange.png +0 -0
  6. package/src/manage/guide/app-disk.png +0 -0
  7. package/src/manage/guide/app-open.png +0 -0
  8. package/src/manage/guide/app-thread.png +0 -0
  9. package/src/manage/guide/app-tiles.png +0 -0
  10. package/src/manage/guide/draft.png +0 -0
  11. package/src/manage/guide/layouts.png +0 -0
  12. package/src/manage/guide/library.png +0 -0
  13. package/src/manage/guide/pack-page.png +0 -0
  14. package/src/manage/guide/permissions.png +0 -0
  15. package/src/manage/guide/phone-home.png +0 -0
  16. package/src/manage/guide/phone-open.png +0 -0
  17. package/src/manage/guide/widget-voice.png +0 -0
  18. package/src/manage/guide.js +155 -0
  19. package/src/manage/guide.md +139 -0
  20. package/src/manage/index.html +14 -0
  21. package/src/manage/manage.css +782 -0
  22. package/src/manage/manage.js +155 -39
  23. package/src/manage/packs.js +347 -0
  24. package/src/manage/settings.js +1 -1
  25. package/src/manage/setup.js +83 -23
  26. package/packs/BRAND_ASSETS.md +0 -8
  27. package/packs/calendar/logo.png +0 -0
  28. package/packs/calendar/pack.json +0 -127
  29. package/packs/discord/pack.json +0 -107
  30. package/packs/filesystem/pack.json +0 -84
  31. package/packs/github/pack.json +0 -127
  32. package/packs/gmail/logo.png +0 -0
  33. package/packs/gmail/pack.json +0 -274
  34. package/packs/obsidian/pack.json +0 -88
  35. package/packs/t3-agents/logo.png +0 -0
  36. package/packs/t3-agents/pack.json +0 -492
  37. package/packs/telegram/pack.json +0 -137
  38. package/packs/trello/pack.json +0 -102
  39. package/packs/whatsapp/pack.json +0 -118
@@ -1,11 +1,12 @@
1
- const SECTIONS = ['setup', 'connection', 'voice', 'execution', 'mcp', 'layouts', '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');
5
5
  if (token) {
6
6
  sessionStorage.setItem('rvaManageToken', token);
7
+ // A draft link (from the agent's packs__preview) opens that draft on the Phone preview.
7
8
  if (linked.get('t'))
8
- history.replaceState(null, '', `#${SECTIONS.includes(linked.get('s')) ? linked.get('s') : 'connection'}`);
9
+ history.replaceState(null, '', linked.get('s') === 'phone' && linked.get('d') ? `#phone/draft/${encodeURIComponent(linked.get('d'))}` : `#${SECTIONS.includes(linked.get('s')) ? linked.get('s') : 'connection'}`);
9
10
  }
10
11
  // Dark is the default; a light choice is remembered per browser.
11
12
  function applyTheme(theme) {
@@ -132,15 +133,21 @@ function render() {
132
133
  document
133
134
  .querySelectorAll('nav a')
134
135
  .forEach((a) => a.classList.toggle('active', a.hash === `#${current}`));
136
+ const scrollTo = location.hash.startsWith('#guide/');
135
137
  $('#app').innerHTML = restartBanner() + setupBanner() + {
136
138
  setup: setupView,
137
139
  connection: connectionView,
138
140
  voice: voiceView,
139
141
  execution: executionView,
140
- mcp: mcpView,
142
+ decisions: decisionsView,
143
+ mcp: () => (packPageId() ? packPageView() : libraryPageSlug() ? libraryPackView() : mcpView()),
144
+ phone: phoneView,
141
145
  layouts: layoutsView,
146
+ guide: guideView,
142
147
  settings: settingsView,
143
148
  }[current]();
149
+ afterPacksRender();
150
+ if (scrollTo) scrollToGuideAnchor();
144
151
  if (showsExecution()) void discoverModels();
145
152
  if (current === 'setup') afterSetupRender();
146
153
  if (
@@ -188,11 +195,25 @@ function connectionView() {
188
195
  const connected = new Set(state.connectedDeviceIds || []);
189
196
  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>`;
190
197
  }
198
+ /** Marks a value pinned on this computer (ADR 0014); account and default values carry no badge. */
199
+ function sourceBadge(section, path) {
200
+ return state.settingSources?.[section]?.[path] === 'override' ? '<span class="badge source" title="Kept on this computer when your account’s settings change">This computer</span>' : '';
201
+ }
202
+ /** A field label with its source badge on the same line. */
203
+ function sourceLabel(text, section, ...paths) {
204
+ const badges = paths.map((path) => sourceBadge(section, path)).join('');
205
+ return badges ? `<span class="label-text">${text}${badges}</span>` : text;
206
+ }
207
+ /** A synced voice choice can name a provider whose key is not on this computer; keys never come from the phone. */
208
+ function voiceNeedsKey(providerId) {
209
+ const key = state.voice.keyStates[providerId];
210
+ return Boolean(key && !key.isSet);
211
+ }
191
212
  function voiceKeyView(providerId) {
192
213
  const provider = state.voice.catalog[providerId];
193
214
  if (!provider) return '';
194
215
  const key = state.voice.keyStates[providerId];
195
- 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>`;
216
+ 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>`;
196
217
  }
197
218
  function voiceKeysView(voiceProvider) {
198
219
  return state.voice.catalog[voiceProvider]
@@ -219,6 +240,10 @@ function voiceEndpointFields(provider, voice) {
219
240
  }
220
241
  /** 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. */
221
242
  const MODEL_STUDIO_HOST = /^(?:[a-z]+:\/\/)?([a-z0-9-]+)\.(ap-southeast-1|cn-beijing)\.maas\.aliyuncs\.com/i;
243
+ /** The spoken command phrase fields, shared by the Voice page and setup; read back by commandPhrases(). */
244
+ function commandPhraseFields(commands) {
245
+ 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>`;
246
+ }
222
247
  /** Same rules as the companion's voiceCommandPhraseSchema, checked here to say what is wrong. */
223
248
  function commandPhrases() {
224
249
  const read = (id) => $(id).value.split(',').map((phrase) => phrase.toLowerCase().replace(/[^a-z'\s]/g, ' ').replace(/\s+/g, ' ').trim()).filter(Boolean);
@@ -243,12 +268,36 @@ function voiceUsageView(usage) {
243
268
  const usd = (value) => `$${value < 0.1 ? value.toFixed(3) : value.toFixed(2)}`;
244
269
  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>`;
245
270
  }
271
+ /** Its own page while it is in beta: the fast-decision model and its key. */
272
+ function decisionsView() {
273
+ 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>`;
274
+ }
275
+ /** Fast decisions (Jev): the user's own TypeSafe key, or a local server with the same API. */
276
+ function jevView() {
277
+ const jev = state.jev;
278
+ if (!jev) return '';
279
+ const { status, config, key } = jev;
280
+ const running = status.state === 'ready' ? `On · ${status.kind === 'local' ? 'local server' : 'TypeSafe'}` : status.state === 'needs_key' ? 'Off · needs a key' : 'Off';
281
+ 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.';
282
+ 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>
283
+ <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>
284
+ <label>Local Jev-compatible server (optional)<input id="jev-url" type="url" placeholder="Empty: TypeSafe’s hosted API" value="${esc(config.url ?? '')}"></label>
285
+ <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>
286
+ <label>Model<input id="jev-model" value="${esc(config.model)}"></label>
287
+ <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>
288
+ <label>Give up after (ms)<input id="jev-timeout" type="number" min="200" max="10000" step="100" value="${config.timeoutMs}"></label>
289
+ ${jev.computerUse ? `<div class="stack"><h4>Computer use</h4><p class="hint">${jev.computerUse.available ? 'On: for click, scroll, and type requests that no integration covers, Jev moves the cursor, clicks, scrolls, and types on this computer. The app running the companion needs Accessibility access in System Settings.' : 'Off: turn on fast decisions to let Jev drive the screen.'}</p></div>` : ''}
290
+ <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>
291
+ <h4>TypeSafe API key</h4><p class="hint">${keyHint}</p>
292
+ <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>
293
+ <p class="hint">Changes apply after a restart; you will be prompted.</p></div>`;
294
+ }
246
295
  function voiceView() {
247
296
  const { voice, catalog } = state.voice;
248
297
  const selected = catalog[voice.provider];
249
298
  const voices = voiceOptionsFor(selected, voice.model);
250
299
  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>
251
- <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(
300
+ <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(
252
301
  catalog
253
302
  )
254
303
  .map(
@@ -257,7 +306,7 @@ function voiceView() {
257
306
  )
258
307
  .join(
259
308
  ''
260
- )}<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>`;
309
+ )}<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>`;
261
310
  }
262
311
  function previewStatus(message, error = false) {
263
312
  const status = $('#voice-preview-status');
@@ -474,14 +523,16 @@ function updateModelChoices(selectedModel = '') {
474
523
  }
475
524
  const interval = (seconds) => (seconds < 60 ? `${seconds} sec` : seconds >= 3600 && seconds % 3600 === 0 ? `${seconds / 3600} hr` : `${Math.round(seconds / 60)} min`);
476
525
 
477
- /** Which widget updates the voice agent speaks during calls; each widget can override it. */
478
- function announceView() {
479
- const mode = state.packs.announceMode || 'agents';
480
- const option = (value, label) => `<option value="${value}" ${mode === value ? 'selected' : ''}>${label}</option>`;
481
- return `<div class="card stack"><div><h3>Spoken updates during calls</h3><p class="hint">In a call or work mode, the voice agent tells you at the next pause when a widget reports something: an agent thread finished, failed, or needs you; new mail; and so on. Each widget below can override this.</p></div><label>Speak updates from<select id="announce-mode">${option('agents', 'Agents only (recommended)')}${option('all', 'Every widget')}${option('none', 'No widgets')}</select></label></div>`;
482
- }
483
-
484
526
  /** `embedded` drops the page heading when setup shows these controls under its own. */
527
+ /** The user's default agents: the agents app with their main work agents, separate from the execution agent. */
528
+ function defaultAgentsView() {
529
+ const apps = (state.packs?.packs || []).filter(pack => pack.agentsApp && pack.enabled);
530
+ const chosen = state.packs?.defaultAgents || '';
531
+ const hint = apps.length
532
+ ? '“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.'
533
+ : 'Install and turn on an agents app (for example T3 Agents, Hermes, or OpenClaw) under MCP &amp; widgets to choose one.';
534
+ 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>`;
535
+ }
485
536
  function executionView({ embedded = false } = {}) {
486
537
  const providers = state.providers;
487
538
  const executors = state.executors || [];
@@ -505,9 +556,9 @@ function executionView({ embedded = false } = {}) {
505
556
  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';
506
557
  const profiles = providers?.profiles.filter(profile => instances.some(instance => [instance.instanceId, instance.displayName, instance.driver].includes(profile.provider))) || [];
507
558
  const promptFor = executor => executor.connector === 'direct' ? state.profilePrompts?.[executor.id.slice('direct:'.length)] : undefined;
508
- 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)} thinking level 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('');
559
+ 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('');
509
560
  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>'}
510
- <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>
561
+ <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>
511
562
  <div class="row"><h3>Choose a provider</h3><button data-action="refresh-executors">Check availability</button></div>
512
563
  <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>
513
564
  <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>
@@ -520,21 +571,34 @@ function executionView({ embedded = false } = {}) {
520
571
  <label>Model<select id="profile-model">${modelChoices(selectedInstance)}</select></label><div class="actions"><button type="button" data-action="refresh-models" data-driver="${esc(group.id)}">Refresh models</button></div><p id="profile-model-status" class="hint" role="status" aria-live="polite">${modelMessage(selectedInstance)}</p>
521
572
  <label>Thinking level<select id="profile-effort" ${effortLevelsFor(selectedInstance, '').length ? '' : 'disabled'}>${effortChoices(effortLevelsFor(selectedInstance, ''), effortLevelsFor(selectedInstance, '').includes(DEFAULT_EFFORT) ? DEFAULT_EFFORT : '')}</select></label><p id="profile-effort-status" class="hint">${effortHint(effortLevelsFor(selectedInstance, ''))}</p>
522
573
  <label><span>System prompt <span class="optional">· Optional</span></span><textarea id="profile-prompt" maxlength="32000" placeholder="For example: Keep changes small, explain your plan before editing, and run the tests you touch."></textarea></label><p class="hint">Added to ${esc(group.label)}’s own instructions for every task this agent runs.</p>
523
- <details><summary>Permissions</summary><label>When the agent changes files or runs commands<select id="profile-mode"><option value="approval-required">Ask me first</option><option value="read-only">Read only</option><option value="auto-accept-edits">Accept edits automatically</option><option value="auto">Automatic</option><option value="full-access">Full access</option></select></label></details>
574
+ <details><summary>Permissions</summary><label>When the agent changes files or runs commands<select id="profile-mode">${runtimeModeChoices('approval-required')}</select></label></details>
524
575
  <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>
525
576
  </div></details>
526
577
  ${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>` : ''}`}
527
578
  </div><div id="profile-editor"></div><div id="provider-editor"></div>
528
579
  ${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>` : ''}
580
+ ${defaultAgentsView()}
529
581
  </section>`;
530
582
  }
583
+ const RUNTIME_MODES = [['approval-required', 'Ask me first'], ['read-only', 'Read only'], ['auto-accept-edits', 'Accept edits automatically'], ['auto', 'Automatic'], ['full-access', 'Full access']];
584
+ function runtimeModeChoices(selected) {
585
+ return RUNTIME_MODES.map(([value, label]) => `<option value="${value}" ${value === selected ? 'selected' : ''}>${label}</option>`).join('');
586
+ }
587
+ /** The installation a profile runs on, by the id or display name it names. */
588
+ const profileInstance = profile => state.providers.instances.find(candidate => [candidate.instanceId, candidate.displayName].includes(profile.provider));
589
+ /** Thinking levels for the edit form's model; a saved level stays visible even before the model list loads. */
590
+ function editEffortLevels(instance, model, saved) {
591
+ const levels = effortLevelsFor(instance, model);
592
+ return saved && !levels.includes(saved) && !instance?.models?.length ? [...levels, saved] : levels;
593
+ }
531
594
  function profileEditor(profile) {
532
595
  const current = state.profilePrompts?.[profile.id] || '';
533
- const instance = state.providers.instances.find(candidate => [candidate.instanceId, candidate.displayName].includes(profile.provider));
534
- const levels = effortLevelsFor(instance, profile.model);
535
- // Keep a saved level visible even if the model list has not loaded yet.
536
- const shown = profile.reasoningEffort && !levels.includes(profile.reasoningEffort) ? [...levels, profile.reasoningEffort] : levels;
537
- return `<div class="card stack"><div class="row"><div><h3>${esc(profile.name)}</h3><p class="hint">${esc(profile.provider)}${profile.model ? ` · ${esc(profile.model)}` : ''}. Changes apply to tasks started after you save.</p></div><button data-action="close-profile">Close</button></div><label>Thinking level<select id="profile-effort-edit" ${shown.length ? '' : 'disabled'}>${effortChoices(shown, profile.reasoningEffort || '')}</select></label><p class="hint">${effortHint(shown)}</p><label>System prompt<textarea id="profile-prompt-edit" class="prompt-text" maxlength="32000" placeholder="Describe how this agent should work. Added to ${esc(profile.provider)}’s own instructions at the start of each task.">${esc(current)}</textarea></label><div class="actions"><button class="primary" data-action="save-profile" data-id="${esc(profile.id)}">Save agent</button>${current ? `<button class="danger" data-action="clear-profile-prompt" data-id="${esc(profile.id)}">Remove prompt</button>` : ''}</div></div>`;
596
+ const instance = profileInstance(profile);
597
+ const shown = editEffortLevels(instance, profile.model, profile.reasoningEffort);
598
+ // A saved model the installation no longer lists (or has not listed yet) stays selectable.
599
+ const listed = (instance?.models || []).some(model => model.id === profile.model);
600
+ const models = `${modelChoices(instance, profile.model || '')}${profile.model && !listed ? `<option value="${esc(profile.model)}" selected>${esc(profile.model)}</option>` : ''}`;
601
+ return `<div class="card stack"><div class="row"><div><h3>${esc(profile.name)}</h3><p class="hint">${esc(profile.provider)}${profile.model ? ` · ${esc(profile.model)}` : ''}. Changes apply to tasks started after you save.</p></div><button data-action="close-profile">Close</button></div><label>Model<select id="profile-model-edit" data-id="${esc(profile.id)}">${models}</select></label><p class="hint">${modelMessage(instance)}</p><label>Thinking level<select id="profile-effort-edit" ${shown.length ? '' : 'disabled'}>${effortChoices(shown, profile.reasoningEffort || '')}</select></label><p id="profile-effort-edit-status" class="hint">${effortHint(shown)}</p><label>When the agent changes files or runs commands<select id="profile-mode-edit">${runtimeModeChoices(profile.runtimeMode)}</select></label><p class="hint">For threads you start with this agent. As the execution agent (requests from your phone and its jobs) it always has full access, in the companion's own folder.</p><label>System prompt<textarea id="profile-prompt-edit" class="prompt-text" maxlength="32000" placeholder="Describe how this agent should work. Added to ${esc(profile.provider)}’s own instructions at the start of each task.">${esc(current)}</textarea></label><div class="actions"><button class="primary" data-action="save-profile" data-id="${esc(profile.id)}">Save agent</button>${current ? `<button class="danger" data-action="clear-profile-prompt" data-id="${esc(profile.id)}">Remove prompt</button>` : ''}</div></div>`;
538
602
  }
539
603
  function providerEditor(p) {
540
604
  return `<div class="card stack"><div class="row"><h3>${esc(p.displayName)} settings</h3><button data-action="close-provider">Close</button></div><label>Display name<input id="provider-name" value="${esc(p.displayName)}"></label>${p.settingsForm.map((field) => `<label>${esc(field.label)}${field.control === 'switch' ? `<input data-setting="${esc(field.id)}" type="checkbox" ${p.settings[field.id] ? 'checked' : ''}>` : field.control === 'select' ? `<select data-setting="${esc(field.id)}">${(field.options || []).map((o) => `<option value="${esc(o.id)}" ${p.settings[field.id] === o.id ? 'selected' : ''}>${esc(o.label)}</option>`).join('')}</select>` : `<input data-setting="${esc(field.id)}" type="${field.control === 'password' ? 'password' : 'text'}" value="${field.control === 'password' ? '' : esc(p.settings[field.id] || '')}" placeholder="${field.control === 'password' && p.secretsSet.includes(field.id) ? 'Already set — leave blank to keep' : esc(field.placeholder || '')}">`}</label>`).join('')}<details><summary>Environment variables</summary><p class="hint">One per line: NAME=value. Prefix a name with ! for a secret. Existing secret values are kept when left blank.</p><textarea id="provider-env">${esc(p.environment.map((v) => `${v.sensitive ? '!' : ''}${v.name}=${v.value || ''}`).join('\n'))}</textarea></details><div class="actions"><button class="primary" data-action="save-provider" data-id="${esc(p.instanceId)}">Save provider</button></div></div>`;
@@ -555,17 +619,15 @@ function packStatusDetail(pack) {
555
619
  : detail;
556
620
  }
557
621
  function catalogView() {
558
- return `<div><h3>Integration catalog</h3><p class="hint">Install a baseline 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.</p></div><div class="grid">${state.integrationCatalog.map(entry => `<article class="card stack"><div class="row"><h3>${esc(entry.name)}</h3><span class="badge">${entry.kind === 'planned' ? 'Planned' : entry.kind === 'executor' ? 'Executor' : entry.installed ? 'Installed' : 'Baseline'}</span></div><p>${esc(entry.description)}</p><p class="hint">${esc(entry.account)} · ${esc(entry.upstream)}</p><details><summary>Setup & access</summary><ul>${entry.setup.map(step => `<li>${esc(step)}</li>`).join('')}</ul><p class="hint">${esc(entry.verification)}</p>${entry.permissions.length ? `<p class="hint">${entry.permissions.map(tool => `${code(tool.name)} (${esc(tool.access)})`).join(', ')}</p>` : ''}</details>${entry.kind === 'pack' ? `<button data-action="install-bundled-pack" data-service="${esc(entry.id)}" ${entry.installed ? 'disabled' : ''}>${entry.installed ? 'Installed' : `Install ${esc(entry.name)}`}</button>` : entry.kind === 'executor' ? '<a href="#execution">Set up execution agent</a>' : '<p class="hint">Not available for installation yet.</p>'}</article>`).join('')}</div>`;
622
+ 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>`;
623
+ 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>${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>`;
559
624
  }
560
625
  function mcpView() {
561
- 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.</p></div>${announceView()}<h3>Installed integrations</h3><div class="grid">${state.packs.packs.map(p => `<div class="card"><div class="row"><h3>${esc(p.name)}</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><button role="menuitem" data-action="edit-pack" data-id="${esc(p.id)}">Settings & permissions</button><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></div></details></div></div>`).join('') || '<div class="card muted">No integrations installed. Choose a baseline below.</div>'}</div><div id="pack-editor"></div>${catalogView()}<div class="card"><h3>Import a widget pack</h3><p class="hint">Choose a <span class="code">.sidebud-widget.json</span> file exported from Sidebud. It installs disabled, then this computer's agent sets it up. Exports contain the pack and its logo only, 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>Create or share an adapter</h3><p class="hint">Use the <span class="code">skills/connect-mcp-widget/SKILL.md</span> guide to author a pack. A custom copy contains the manifest and logo only; configure its own credentials. Review any external server requirements before enabling it.</p><p class="hint">Community submissions and reviewed release distribution are planned. Local packs can be installed now.</p></div></section>`;
626
+ 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>`;
562
627
  }
563
628
  function customizePackEditor(p) {
564
629
  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>`;
565
630
  }
566
- function packEditor(p) {
567
- return `<div class="card stack"><div class="row"><div><h3>${esc(p.name)}</h3><p class="hint">${esc(p.author)} · ${esc(p.license)} · ${esc(p.version)}</p></div><button data-action="close-pack">Close</button></div><div><strong>Declared tools</strong><p class="hint">${p.permissions.tools.map((t) => `${code(t.name)} <span class="pill">${esc(t.access)}</span>`).join(' ') || 'None'}</p><p class="hint">Network: ${esc(p.permissions.network.join(', ') || 'none declared')}</p></div>${p.settings.map((setting) => `<label>${esc(setting.label)}${setting.type === 'boolean' ? `<input data-pack-setting="${esc(setting.id)}" type="checkbox" ${setting.value ? 'checked' : ''}>` : setting.type === 'select' ? `<select data-pack-setting="${esc(setting.id)}">${setting.options.map((o) => `<option value="${esc(o.id)}" ${setting.value === o.id ? 'selected' : ''}>${esc(o.label)}</option>`).join('')}</select>` : `<input data-pack-setting="${esc(setting.id)}" value="${esc(setting.value || '')}">`}</label>${setting.description ? `<p class="hint">${esc(setting.description)}</p>` : ''}`).join('')}${p.secrets.map((s) => `<label>${esc(s.name)} ${s.isSet ? '(set)' : ''}<input data-pack-secret="${esc(s.name)}" type="password" autocomplete="new-password" placeholder="${s.isSet ? 'Leave blank to keep' : 'Set on this computer'}"></label><p class="hint">${esc(s.description)}</p>${s.isSet ? `<button class="danger" data-action="clear-pack-secret" data-id="${esc(p.id)}" data-secret="${esc(s.name)}">Clear ${esc(s.name)}</button>` : ''}`).join('')}<div class="actions"><button class="primary" data-action="save-pack" data-id="${esc(p.id)}">Save settings</button>${state.packAuth[p.id] ? `<button data-action="connect-pack" data-id="${esc(p.id)}" ${state.connecting.includes(p.id) ? 'disabled' : ''}>${state.connecting.includes(p.id) ? 'Connecting…' : 'Connect account'}</button><button class="danger" data-action="disconnect-pack" data-id="${esc(p.id)}">Disconnect (local)</button>` : ''}</div><p class="hint">Account: ${esc(accountLabel(state.packAuth[p.id]))}. ${state.packAuth[p.id] ? "OAuth consent opens in this computer's browser. Disconnect removes local credentials and disables this pack; it does not revoke the provider grant." : 'Configure any required credentials above. Disable blocks new calls; clear saved credentials here and revoke them at the provider when removing access.'}</p></div>`;
568
- }
569
631
  function layoutsView() {
570
632
  const active = state.devices.filter((d) => !d.revokedAt);
571
633
  return `<section class="section"><div><h2>Phone layouts</h2><p class="lede">Review each paired phone's tile order, visibility, and size. Changes sync to that phone through the companion.</p></div>${
@@ -599,8 +661,8 @@ document.addEventListener('change', (event) => {
599
661
  void api('pack', { packId: pack.id, refreshSeconds: seconds === pack.refresh.defaultSeconds ? null : seconds }).then(() => { notice('Refresh interval saved.'); return reload(); }, (error) => notice(error.message || 'Could not save.'));
600
662
  return;
601
663
  }
602
- if (event.target.id === 'announce-mode') {
603
- void api('announce-mode', { mode: event.target.value }).then(() => { notice('Spoken updates saved.'); return reload(); }, (error) => notice(error.message || 'Could not save.'));
664
+ if (event.target.dataset.announcePack) {
665
+ void api('pack', { packId: event.target.dataset.announcePack, announce: event.target.value === 'on' }).then(() => { notice('Spoken updates saved.'); return reload(); }, (error) => notice(error.message || 'Could not save.'));
604
666
  return;
605
667
  }
606
668
  if (event.target.id === 'profile-effort') {
@@ -611,6 +673,16 @@ document.addEventListener('change', (event) => {
611
673
  updateEffortChoices();
612
674
  return;
613
675
  }
676
+ if (event.target.id === 'profile-model-edit') {
677
+ // Keep the chosen thinking level when the new model offers it.
678
+ const profile = state.providers.profiles.find(candidate => candidate.id === event.target.dataset.id);
679
+ const effort = $('#profile-effort-edit');
680
+ const levels = editEffortLevels(profileInstance(profile), event.target.value, null);
681
+ effort.innerHTML = effortChoices(levels, levels.includes(effort.value) ? effort.value : '');
682
+ effort.disabled = !levels.length;
683
+ $('#profile-effort-edit-status').textContent = effortHint(levels);
684
+ return;
685
+ }
614
686
  if (event.target.id === 'profile-instance') {
615
687
  updateModelChoices();
616
688
  showModelLoading(false);
@@ -758,6 +830,32 @@ document.addEventListener('click', async (event) => {
758
830
  await reload();
759
831
  return;
760
832
  }
833
+ if (action === 'save-jev') {
834
+ const url = $('#jev-url').value.trim();
835
+ await api('jev', {
836
+ jev: {
837
+ mode: $('#jev-on').checked ? 'auto' : 'off',
838
+ url: url || null,
839
+ model: $('#jev-model').value.trim() || 'jev-latest',
840
+ minConfidence: Number($('#jev-confidence').value),
841
+ timeoutMs: Number($('#jev-timeout').value),
842
+ },
843
+ });
844
+ notice('Fast decision settings saved. Restart to apply them.');
845
+ await reload();
846
+ return;
847
+ }
848
+ if (action === 'save-jev-key' || action === 'clear-jev-key') {
849
+ await api('jev-key', { value: action === 'clear-jev-key' ? '' : $('#jev-key').value });
850
+ notice('Key updated in this computer’s keychain. Restart to apply it.');
851
+ await reload();
852
+ return;
853
+ }
854
+ if (action === 'test-jev') {
855
+ const result = await api('jev/test', {});
856
+ notice(`Fast decisions work: ${result.model} answered “${result.choice}” in ${result.ms} ms.`);
857
+ return;
858
+ }
761
859
  if (action === 'save-voice-key' || action === 'clear-voice-key') {
762
860
  await api('voice-key', {
763
861
  provider: button.dataset.provider,
@@ -772,6 +870,12 @@ document.addEventListener('click', async (event) => {
772
870
  await reload();
773
871
  return;
774
872
  }
873
+ if (action === 'save-default-agents') {
874
+ await api('default-agents', { packId: $('#default-agents').value || null });
875
+ notice('Default agents saved.');
876
+ await reload();
877
+ return;
878
+ }
775
879
  if (action === 'set-default-executor') {
776
880
  await api('executor', { executorId: id });
777
881
  notice('Default executor updated.');
@@ -819,7 +923,7 @@ document.addEventListener('click', async (event) => {
819
923
  return;
820
924
  }
821
925
  if (action === 'save-profile') {
822
- await api('profile/update', { profileId: id, systemPrompt: $('#profile-prompt-edit').value, reasoningEffort: $('#profile-effort-edit').value || null });
926
+ await api('profile/update', { profileId: id, modelLabel: $('#profile-model-edit').value || null, runtimeMode: $('#profile-mode-edit').value, systemPrompt: $('#profile-prompt-edit').value, reasoningEffort: $('#profile-effort-edit').value || null });
823
927
  notice('Agent saved. Changes apply to its next task.');
824
928
  await reload();
825
929
  return;
@@ -905,7 +1009,7 @@ document.addEventListener('click', async (event) => {
905
1009
  link.download = `${id}.sidebud-widget.json`;
906
1010
  link.click();
907
1011
  URL.revokeObjectURL(link.href);
908
- notice('Exported. The file holds the pack and its logo only; settings and keys stay on this computer.');
1012
+ notice('Exported. The file holds the pack (manifest, logo, skill, and any server files); settings and keys stay on this computer.');
909
1013
  return;
910
1014
  }
911
1015
  if (action === 'import-pack') {
@@ -936,11 +1040,11 @@ document.addEventListener('click', async (event) => {
936
1040
  notice(`Connected. All ${result.tools} declared tools are available. This check did not execute them.`);
937
1041
  return;
938
1042
  }
939
- if (action === 'edit-pack') {
940
- $('#pack-editor').innerHTML = packEditor(
941
- state.packs.packs.find((p) => p.id === id)
942
- );
943
- $('#pack-editor').scrollIntoView({ behavior: 'smooth' });
1043
+ if (action === 'save-pack-tools') {
1044
+ const blockedTools = [...document.querySelectorAll('[data-pack-tool]')].filter((box) => !box.checked).map((box) => box.dataset.packTool);
1045
+ await api('pack', { packId: id, blockedTools });
1046
+ notice(blockedTools.length ? `Saved. ${blockedTools.length === 1 ? 'One tool is' : `${blockedTools.length} tools are`} turned off for this integration.` : 'Saved. Every declared tool is allowed.');
1047
+ await reload();
944
1048
  return;
945
1049
  }
946
1050
  if (action === 'close-pack') {
@@ -981,12 +1085,24 @@ document.addEventListener('click', async (event) => {
981
1085
  await reload();
982
1086
  return;
983
1087
  }
984
- if (action === 'install-bundled-pack') {
985
- const result = await api('pack/install-bundled', {
986
- service: button.dataset.service,
1088
+ if (action === 'remove-pack') {
1089
+ if (!confirm(`Remove ${id}? Its settings, keys, and account sign-in are deleted from this computer. You can install it again from the library or an exported file.`)) return;
1090
+ await api('pack/remove', { packId: id });
1091
+ if (packPageId() === id) location.hash = '#mcp';
1092
+ notice('Integration removed.');
1093
+ await reload();
1094
+ return;
1095
+ }
1096
+ if (action === 'install-library-pack') {
1097
+ libraryPage = null;
1098
+ const result = await api('pack/install-library', {
1099
+ id: button.dataset.id,
1100
+ ...(button.dataset.replace === 'true' ? { replace: true } : {}),
987
1101
  });
988
1102
  notice(
989
- result.setup.started
1103
+ result.updated && !result.setup.started
1104
+ ? `${result.name} updated to ${result.version}; its settings and keys were kept.`
1105
+ : result.setup.started
990
1106
  ? `${result.name} installed. This computer's agent is setting it up and reports back when it finishes.`
991
1107
  : `${result.name} installed, but setup did not start: ${result.setup.note} Set it up in Settings & permissions.`
992
1108
  );