sidebud 0.1.0 → 0.2.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 (38) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/main.js +4725 -3310
  3. package/package.json +2 -2
  4. package/skills/widget-packs/SKILL.md +198 -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 +10 -0
  21. package/src/manage/manage.css +744 -0
  22. package/src/manage/manage.js +66 -35
  23. package/src/manage/packs.js +340 -0
  24. package/src/manage/setup.js +12 -13
  25. package/packs/BRAND_ASSETS.md +0 -8
  26. package/packs/calendar/logo.png +0 -0
  27. package/packs/calendar/pack.json +0 -127
  28. package/packs/discord/pack.json +0 -107
  29. package/packs/filesystem/pack.json +0 -84
  30. package/packs/github/pack.json +0 -127
  31. package/packs/gmail/logo.png +0 -0
  32. package/packs/gmail/pack.json +0 -274
  33. package/packs/obsidian/pack.json +0 -88
  34. package/packs/t3-agents/logo.png +0 -0
  35. package/packs/t3-agents/pack.json +0 -492
  36. package/packs/telegram/pack.json +0 -137
  37. package/packs/trello/pack.json +0 -102
  38. 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', '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,20 @@ 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
+ mcp: () => (packPageId() ? packPageView() : libraryPageSlug() ? libraryPackView() : mcpView()),
143
+ phone: phoneView,
141
144
  layouts: layoutsView,
145
+ guide: guideView,
142
146
  settings: settingsView,
143
147
  }[current]();
148
+ afterPacksRender();
149
+ if (scrollTo) scrollToGuideAnchor();
144
150
  if (showsExecution()) void discoverModels();
145
151
  if (current === 'setup') afterSetupRender();
146
152
  if (
@@ -474,13 +480,6 @@ function updateModelChoices(selectedModel = '') {
474
480
  }
475
481
  const interval = (seconds) => (seconds < 60 ? `${seconds} sec` : seconds >= 3600 && seconds % 3600 === 0 ? `${seconds / 3600} hr` : `${Math.round(seconds / 60)} min`);
476
482
 
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
483
  /** `embedded` drops the page heading when setup shows these controls under its own. */
485
484
  function executionView({ embedded = false } = {}) {
486
485
  const providers = state.providers;
@@ -505,7 +504,7 @@ function executionView({ embedded = false } = {}) {
505
504
  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
505
  const profiles = providers?.profiles.filter(profile => instances.some(instance => [instance.instanceId, instance.displayName, instance.driver].includes(profile.provider))) || [];
507
506
  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('');
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('');
509
508
  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
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>
511
510
  <div class="row"><h3>Choose a provider</h3><button data-action="refresh-executors">Check availability</button></div>
@@ -520,7 +519,7 @@ function executionView({ embedded = false } = {}) {
520
519
  <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
520
  <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
521
  <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>
522
+ <details><summary>Permissions</summary><label>When the agent changes files or runs commands<select id="profile-mode">${runtimeModeChoices('approval-required')}</select></label></details>
524
523
  <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
524
  </div></details>
526
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>` : ''}`}
@@ -528,13 +527,25 @@ function executionView({ embedded = false } = {}) {
528
527
  ${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>` : ''}
529
528
  </section>`;
530
529
  }
530
+ 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
+ function runtimeModeChoices(selected) {
532
+ return RUNTIME_MODES.map(([value, label]) => `<option value="${value}" ${value === selected ? 'selected' : ''}>${label}</option>`).join('');
533
+ }
534
+ /** The installation a profile runs on, by the id or display name it names. */
535
+ const profileInstance = profile => state.providers.instances.find(candidate => [candidate.instanceId, candidate.displayName].includes(profile.provider));
536
+ /** Thinking levels for the edit form's model; a saved level stays visible even before the model list loads. */
537
+ function editEffortLevels(instance, model, saved) {
538
+ const levels = effortLevelsFor(instance, model);
539
+ return saved && !levels.includes(saved) && !instance?.models?.length ? [...levels, saved] : levels;
540
+ }
531
541
  function profileEditor(profile) {
532
542
  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>`;
543
+ const instance = profileInstance(profile);
544
+ const shown = editEffortLevels(instance, profile.model, profile.reasoningEffort);
545
+ // A saved model the installation no longer lists (or has not listed yet) stays selectable.
546
+ const listed = (instance?.models || []).some(model => model.id === profile.model);
547
+ const models = `${modelChoices(instance, profile.model || '')}${profile.model && !listed ? `<option value="${esc(profile.model)}" selected>${esc(profile.model)}</option>` : ''}`;
548
+ 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
549
  }
539
550
  function providerEditor(p) {
540
551
  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 +566,15 @@ function packStatusDetail(pack) {
555
566
  : detail;
556
567
  }
557
568
  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>`;
569
+ 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>`;
559
571
  }
560
572
  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>`;
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>`;
562
574
  }
563
575
  function customizePackEditor(p) {
564
576
  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
577
  }
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
578
  function layoutsView() {
570
579
  const active = state.devices.filter((d) => !d.revokedAt);
571
580
  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 +608,8 @@ document.addEventListener('change', (event) => {
599
608
  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
609
  return;
601
610
  }
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.'));
611
+ if (event.target.dataset.announcePack) {
612
+ 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
613
  return;
605
614
  }
606
615
  if (event.target.id === 'profile-effort') {
@@ -611,6 +620,16 @@ document.addEventListener('change', (event) => {
611
620
  updateEffortChoices();
612
621
  return;
613
622
  }
623
+ if (event.target.id === 'profile-model-edit') {
624
+ // Keep the chosen thinking level when the new model offers it.
625
+ const profile = state.providers.profiles.find(candidate => candidate.id === event.target.dataset.id);
626
+ const effort = $('#profile-effort-edit');
627
+ const levels = editEffortLevels(profileInstance(profile), event.target.value, null);
628
+ effort.innerHTML = effortChoices(levels, levels.includes(effort.value) ? effort.value : '');
629
+ effort.disabled = !levels.length;
630
+ $('#profile-effort-edit-status').textContent = effortHint(levels);
631
+ return;
632
+ }
614
633
  if (event.target.id === 'profile-instance') {
615
634
  updateModelChoices();
616
635
  showModelLoading(false);
@@ -819,7 +838,7 @@ document.addEventListener('click', async (event) => {
819
838
  return;
820
839
  }
821
840
  if (action === 'save-profile') {
822
- await api('profile/update', { profileId: id, systemPrompt: $('#profile-prompt-edit').value, reasoningEffort: $('#profile-effort-edit').value || null });
841
+ 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
842
  notice('Agent saved. Changes apply to its next task.');
824
843
  await reload();
825
844
  return;
@@ -905,7 +924,7 @@ document.addEventListener('click', async (event) => {
905
924
  link.download = `${id}.sidebud-widget.json`;
906
925
  link.click();
907
926
  URL.revokeObjectURL(link.href);
908
- notice('Exported. The file holds the pack and its logo only; settings and keys stay on this computer.');
927
+ notice('Exported. The file holds the pack (manifest, logo, skill, and any server files); settings and keys stay on this computer.');
909
928
  return;
910
929
  }
911
930
  if (action === 'import-pack') {
@@ -936,11 +955,11 @@ document.addEventListener('click', async (event) => {
936
955
  notice(`Connected. All ${result.tools} declared tools are available. This check did not execute them.`);
937
956
  return;
938
957
  }
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' });
958
+ if (action === 'save-pack-tools') {
959
+ const blockedTools = [...document.querySelectorAll('[data-pack-tool]')].filter((box) => !box.checked).map((box) => box.dataset.packTool);
960
+ await api('pack', { packId: id, blockedTools });
961
+ 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.');
962
+ await reload();
944
963
  return;
945
964
  }
946
965
  if (action === 'close-pack') {
@@ -981,12 +1000,24 @@ document.addEventListener('click', async (event) => {
981
1000
  await reload();
982
1001
  return;
983
1002
  }
984
- if (action === 'install-bundled-pack') {
985
- const result = await api('pack/install-bundled', {
986
- service: button.dataset.service,
1003
+ if (action === 'remove-pack') {
1004
+ 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;
1005
+ await api('pack/remove', { packId: id });
1006
+ if (packPageId() === id) location.hash = '#mcp';
1007
+ notice('Integration removed.');
1008
+ await reload();
1009
+ return;
1010
+ }
1011
+ if (action === 'install-library-pack') {
1012
+ libraryPage = null;
1013
+ const result = await api('pack/install-library', {
1014
+ id: button.dataset.id,
1015
+ ...(button.dataset.replace === 'true' ? { replace: true } : {}),
987
1016
  });
988
1017
  notice(
989
- result.setup.started
1018
+ result.updated && !result.setup.started
1019
+ ? `${result.name} updated to ${result.version}; its settings and keys were kept.`
1020
+ : result.setup.started
990
1021
  ? `${result.name} installed. This computer's agent is setting it up and reports back when it finishes.`
991
1022
  : `${result.name} installed, but setup did not start: ${result.setup.note} Set it up in Settings & permissions.`
992
1023
  );