feat: Claude Agent integration + cookbook reconnect + UI polish

- Claude Agent integration: AGENT_CONFIGS.claude, INTG_TYPES.claude,
  setup_claude_routes + integrations/claude/ skill bundle. Wired in
  app.py alongside the existing Codex integration; same scope-gated
  /api/codex/* backend; agent form has new description so users know
  it's setup for an external CLI, not an agent streamed inside Odysseus.
- Remove mark_email_boundaries action: not good enough yet. Stripped
  from task UI, scheduler defaults, registry, tool schema, clear-cache
  route. Added to RETIRED_HOUSEKEEPING_ACTIONS so existing rows + their
  task_runs auto-purge on startup.
- Cookbook download reliability: "Reconnect" fix button in the crash
  diagnosis runs _reconnectTask after probing has-session. 30s confirm
  window before marking a download "done" — kills the Finished/Downloading
  flicker when tmux briefly drops between captures.
- Mobile UX: tap anywhere on a note card body opens the editor;
  Update button morphs to Archive when no text was edited; bell icon
  accent-colored; chip-trashing notif pills fade so only the icon
  rotates into the trash zone.
- Settings integrations: SVG-per-provider in email + API preset
  dropdowns, custom drop-up-aware menus, accent sub-header icons
  (IMAP/SMTP), consistent card styling between list + edit, contacts
  Edit/Delete icons, agent form description copy.
This commit is contained in:
pewdiepie-archdaemon
2026-06-04 08:27:26 +09:00
parent 6e80d0de08
commit 089246614d
17 changed files with 1301 additions and 387 deletions

View File

@@ -1886,7 +1886,7 @@
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>Email Accounts</h2>
<div class="settings-row" style="align-items:center;">
<div class="admin-toggle-sub" style="margin:0;flex:1;">Add, edit, delete, and test accounts in Integrations.</div>
<button class="admin-btn-add" id="set-email-open-integrations">Manage in Integrations</button>
<button class="admin-btn-add" id="set-email-open-integrations" style="display:inline-flex;align-items:center;gap:6px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.7"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Manage in Integrations</button>
</div>
</div>
@@ -1894,7 +1894,7 @@
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M9 16l2 2 4-4"/></svg>Email Tasks</h2>
<div class="settings-row" style="align-items:center;">
<div class="admin-toggle-sub" style="margin:0;flex:1;">Manage email background tasks in Tasks.</div>
<button class="admin-btn-add" id="set-email-open-tasks">Open Tasks</button>
<button class="admin-btn-add" id="set-email-open-tasks" style="display:inline-flex;align-items:center;gap:6px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.7"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M9 16l2 2 4-4"/></svg>Open Tasks</button>
</div>
</div>
@@ -2115,12 +2115,12 @@
<!-- ═══ INTEGRATIONS TAB ═══ -->
<div data-settings-panel="integrations" class="hidden">
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Connections</h2>
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Integrations</h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">All external service connections in one place.</div>
<div id="unified-integrations-list"></div>
<div id="unified-intg-form" style="display:none"></div>
<div style="text-align:center;padding:8px 0;">
<button type="button" class="admin-btn-sm" id="unified-intg-add-btn">+ Add Integration</button>
<button type="button" class="admin-btn-sm" id="unified-intg-add-btn" style="display:inline-flex;align-items:center;gap:6px;">+ Add Integration<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>
</div>
</div>
</div>

View File

@@ -2456,6 +2456,26 @@ async function _reconnectTask(el, task) {
const isNetwork = /connection|timeout|timed out|incompleteread|chunkedencoding|reset by peer|protocolerror|all connection attempts failed/i.test(lastOutput);
const progressMatch = String(lastOutput || '').match(/(\d+)%\|/);
const nearDone = progressMatch && Number(progressMatch[1]) >= 80;
// Reconnect: most "crashed" downloads near the end are actually
// finished — we just missed the DOWNLOAD_OK / /snapshots/ marker
// because output rolled over, or the tmux session ended a tick
// before we polled. Probing has-session and re-attaching to
// capture-pane lets the existing _reconnectTask flow pick up
// the real state (running, finished, or truly dead).
const _reconnectFix = {
label: 'Reconnect',
action: () => {
_updateTask(task.sessionId, { status: 'running' });
el.dataset.status = 'running';
const badge2 = el.querySelector('.cookbook-task-status');
if (badge2) { badge2.textContent = _statusLabel('running', task.type); badge2.className = 'cookbook-task-status'; }
const _diagEl = el.querySelector('.cookbook-diagnosis');
if (_diagEl) _diagEl.remove();
const _wave = el.querySelector('.cookbook-task-wave'); if (_wave) _wave.style.display = '';
const _up = el.querySelector('.cookbook-task-uptime'); if (_up) _up.style.display = '';
_reconnectTask(el, task);
},
};
const diag = {
message: isDisk
? 'Download stopped because this server ran out of disk space.'
@@ -2467,28 +2487,88 @@ async function _reconnectTask(el, task) {
suggestion: isDisk
? 'Suggested action: free disk space, then retry the download. HuggingFace resumes incomplete files when possible.'
: nearDone
? 'Suggested action: retry the download. It may briefly look like it restarted while cached files are checked, then it should reuse incomplete files.'
: 'Suggested action: retry the download. HuggingFace resumes incomplete files when possible.',
fixes: [
{ label: 'Retry download', action: () => _retryTask(el, task) },
{ label: 'Copy last 50 lines', action: () => {
const last = String(lastOutput || '').split('\n').slice(-50).join('\n');
_copyText(last || 'No download log available.');
} },
],
? 'Suggested action: hit Reconnect first — the download may have finished after the output buffer rolled over. Retry only if reconnect cannot recover.'
: 'Suggested action: hit Reconnect to re-attach to the tmux session. If that fails, retry — HuggingFace resumes incomplete files when possible.',
fixes: isDisk
? [
{ label: 'Retry download', action: () => _retryTask(el, task) },
{ label: 'Copy last 50 lines', action: () => {
const last = String(lastOutput || '').split('\n').slice(-50).join('\n');
_copyText(last || 'No download log available.');
} },
]
: [
_reconnectFix,
{ label: 'Retry download', action: () => _retryTask(el, task) },
{ label: 'Copy last 50 lines', action: () => {
const last = String(lastOutput || '').split('\n').slice(-50).join('\n');
_copyText(last || 'No download log available.');
} },
],
};
_showDiagnosis(el, diag, lastOutput);
// Auto-probe: if the tmux session is still alive (download
// genuinely still in progress), _selfHealStaleTasks flips the
// task back to running and the diagnosis disappears without
// the user needing to click Reconnect.
if (nearDone) setTimeout(() => { _selfHealStaleTasks().catch(() => {}); }, 1200);
}
_showCookbookNotif(true);
} else {
_updateTask(task.sessionId, { status: 'done' });
el.dataset.status = 'done';
const badge = el.querySelector('.cookbook-task-status');
if (badge) { badge.textContent = _statusLabel('done', task.type); badge.className = 'cookbook-task-status cookbook-task-done'; }
const _chk = el.querySelector('.cookbook-task-check'); if (_chk) _chk.style.display = '';
const _sb = el.querySelector('.cookbook-task-serve-btn'); if (_sb) _sb.style.display = '';
_showCookbookNotif();
_refreshDepsAfterInstall(task);
// Debounce the done flip. Tmux capture-pane can fail transiently
// (network blip, ssh reconnect), and the verify has-session right
// above can briefly report dead even when the session is in the
// middle of finalizing. Marking done immediately + the periodic
// _selfHealStaleTasks then flipping back to running causes the
// status badge to oscillate between Finished and Downloading.
// Wait 30s and re-probe: only finalize as done if tmux is STILL
// gone. If the session resurfaces, restart _reconnectTask so live
// capture resumes without the user seeing a fake "done" first.
if (!task._doneConfirmAt) {
_updateTask(task.sessionId, { _doneConfirmAt: Date.now() + 30000 });
setTimeout(async () => {
try {
const fresh = _loadTasks().find(t => t.sessionId === task.sessionId);
if (!fresh) return;
let stillAlive = false;
try {
const probe = await fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: _tmuxCmd(task, `has-session -t ${task.sessionId}`), timeout: 5 }),
});
const pData = await probe.json();
stillAlive = pData.exit_code === 0;
} catch { /* network blip — treat as inconclusive, prefer running */ stillAlive = true; }
if (stillAlive) {
_updateTask(task.sessionId, { status: 'running', _doneConfirmAt: null });
const _el = document.querySelector(`.cookbook-task[data-task-id="${task.sessionId}"]`);
if (_el) {
_el.dataset.status = 'running';
const _badge = _el.querySelector('.cookbook-task-status');
if (_badge) { _badge.textContent = _statusLabel('running', task.type); _badge.className = 'cookbook-task-status'; }
const _wave = _el.querySelector('.cookbook-task-wave'); if (_wave) _wave.style.display = '';
const _up = _el.querySelector('.cookbook-task-uptime'); if (_up) _up.style.display = '';
_reconnectTask(_el, _loadTasks().find(t => t.sessionId === task.sessionId));
}
return;
}
_updateTask(task.sessionId, { status: 'done', _doneConfirmAt: null });
const _el = document.querySelector(`.cookbook-task[data-task-id="${task.sessionId}"]`);
if (_el) {
_el.dataset.status = 'done';
const _badge = _el.querySelector('.cookbook-task-status');
if (_badge) { _badge.textContent = _statusLabel('done', task.type); _badge.className = 'cookbook-task-status cookbook-task-done'; }
const _chk = _el.querySelector('.cookbook-task-check'); if (_chk) _chk.style.display = '';
const _sb = _el.querySelector('.cookbook-task-serve-btn'); if (_sb) _sb.style.display = '';
}
_showCookbookNotif();
_refreshDepsAfterInstall(task);
_renderRunningTab();
_processQueue();
} catch { /* swallow — next polling cycle will retry */ }
}, 30000);
}
}
}
_renderRunningTab();

View File

@@ -938,6 +938,7 @@ function _wireChipDrag(chip, dock) {
if (tz) {
const dx = (tz.left + tz.width / 2) - (l.x + l.width / 2);
const dy = (tz.top + tz.height / 2) - (l.y + l.height / 2);
l.chip.classList.add('chip-trashing');
l.chip.style.transition = 'transform 0.32s cubic-bezier(0.45, 0, 0.25, 1), opacity 0.3s ease-in, left 0.32s cubic-bezier(0.45, 0, 0.25, 1), top 0.32s cubic-bezier(0.45, 0, 0.25, 1)';
// Whirlpool: spin + shrink so the chip swirls into the X.
l.chip.style.transform = 'scale(0.15) rotate(720deg)';
@@ -1001,6 +1002,7 @@ function _wireChipDrag(chip, dock) {
// `!important`, so the close animation needs setProperty(...important)
// too or the styles don't apply and the chip just snaps.
const cur = chip.style.transform || 'translate(0,0)';
chip.classList.add('chip-trashing');
chip.style.setProperty('transition', 'transform 0.32s cubic-bezier(0.45, 0, 0.25, 1), opacity 0.3s ease-in', 'important');
// Whirlpool: spin + shrink as the chip swirls into the X.
chip.style.setProperty('transform', `${cur} scale(0.15) rotate(720deg)`, 'important');

View File

@@ -2145,6 +2145,21 @@ function _bindCardEvents(body) {
});
});
}
// Mobile, non-select: tapping anywhere on the card body (not on an
// interactive child — buttons, pin, checkbox, color dot, reminder pill,
// agent tag, links) opens the fullscreen editor. Previously only the
// title / content preview triggered edit, so padding + empty gutters were
// dead zones that felt broken on mobile.
if (_isNotesMobileMode() && !_selectMode) {
const _INTERACTIVE = 'button, a, input, label, .note-card-color-dot, .note-checkbox, .note-checkbox-rm, .note-cl-quickadd, .note-agent-tag, .note-card-pin, .note-card-corner-trash, .note-card-corner-menu, .note-card-corner-unarchive, .note-card-edit-corner, .note-card-reminder, .note-card-cb';
body.querySelectorAll('.note-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest(_INTERACTIVE)) return;
e.stopPropagation();
tapToEditOrSelect(card);
});
});
}
// Multi-select checkbox (only in select mode)
body.querySelectorAll('.note-card-cb').forEach(cb => {
cb.addEventListener('click', (e) => e.stopPropagation());
@@ -3456,6 +3471,14 @@ function _buildForm(note = null) {
// let repeated clicks create duplicate notes.
const _saveBtn = form.querySelector('.note-form-save');
if (_saveBtn._saving) return;
// Mobile: when an existing note is opened and closed without edits, the
// Update (✓) button morphs into Archive (set up below). Route the click
// to the hidden archive button so the existing archive flow + undo toast
// run unchanged.
if (_saveBtn.classList.contains('archive-mode')) {
form.querySelector('.note-form-archive-btn')?.click();
return;
}
_saveBtn._saving = true; _saveBtn.disabled = true; _saveBtn.style.opacity = '0.5';
try {
const title = form.querySelector('.note-form-title').value.trim();
@@ -3550,6 +3573,28 @@ function _buildForm(note = null) {
}
});
// Mobile-only: when editing an existing note, the Update (✓) button starts in
// archive-mode (visually + behaviorally) and flips to Update on the first
// edit. Lets the user tap a note to skim, then tap ✓ to archive without ever
// touching a separate Archive button.
if (isEdit && window.innerWidth <= 768) {
const _saveLabelEl = _saveBtnEl0.querySelector('.nft-label');
const _enterArchive = () => {
_saveBtnEl0.classList.add('archive-mode');
if (_saveLabelEl) _saveLabelEl.textContent = 'Archive';
_saveBtnEl0.title = 'Archive';
};
const _enterUpdate = () => {
if (!_saveBtnEl0.classList.contains('archive-mode')) return;
_saveBtnEl0.classList.remove('archive-mode');
if (_saveLabelEl) _saveLabelEl.textContent = 'Update';
_saveBtnEl0.title = 'Update';
};
_enterArchive();
form.addEventListener('input', _enterUpdate, true);
form.addEventListener('change', _enterUpdate, true);
}
// Cancel
form.querySelector('.note-form-cancel').addEventListener('click', () => { _clearDraft(isEdit ? note.id : '__new__'); _editingId = null; _renderNotes(); });

View File

@@ -3093,10 +3093,69 @@ const INTG_TYPES = {
carddav: { label: 'CardDAV', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>' },
email: { label: 'Email', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>' },
mcp: { label: 'MCP', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>' },
codex: { label: 'Codex', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="m8 9 3 3-3 3"/><path d="M13 15h3"/></svg>' },
codex: { label: 'Codex', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 10.696.453a6.023 6.023 0 0 0-5.75 4.172 6.061 6.061 0 0 0-3.946 2.945 6.024 6.024 0 0 0 .742 7.099 5.98 5.98 0 0 0 .516 4.911 6.046 6.046 0 0 0 6.51 2.9A5.996 5.996 0 0 0 13.26 23.547a6.023 6.023 0 0 0 5.75-4.172 6.061 6.061 0 0 0 3.946-2.945 6.024 6.024 0 0 0-.674-6.609zM13.26 21.047a4.508 4.508 0 0 1-2.886-1.041l.143-.082 4.793-2.769a.777.777 0 0 0 .391-.676V10.34l2.026 1.17a.072.072 0 0 1 .039.061v5.596a4.532 4.532 0 0 1-4.506 4.48zM3.968 17.64a4.473 4.473 0 0 1-.537-3.018l.143.086 4.793 2.769a.79.79 0 0 0 .782 0l5.852-3.379v2.34a.072.072 0 0 1-.029.062l-4.845 2.796a4.532 4.532 0 0 1-6.159-1.656zM2.804 7.922a4.49 4.49 0 0 1 2.348-1.973V11.6a.778.778 0 0 0 .391.676l5.852 3.378-2.026 1.17a.072.072 0 0 1-.068 0L4.456 14.03a4.532 4.532 0 0 1-1.652-6.108zm16.423 3.823L13.375 8.367l2.026-1.17a.072.072 0 0 1 .068 0l4.845 2.796a4.525 4.525 0 0 1-.7 8.08V12.42a.778.778 0 0 0-.387-.676zm2.015-3.025l-.143-.086-4.793-2.769a.79.79 0 0 0-.782 0L9.672 9.243V6.903a.072.072 0 0 1 .029-.062l4.845-2.796a4.525 4.525 0 0 1 6.696 4.675zM8.598 12.66L6.57 11.49a.072.072 0 0 1-.039-.061V5.833a4.525 4.525 0 0 1 7.413-3.48l-.143.082-4.793 2.769a.777.777 0 0 0-.391.676l-.019 6.78zm1.1-2.379l2.607-1.505 2.607 1.505v3.01l-2.607 1.505-2.607-1.505z"/></svg>' },
claude: { label: 'Claude', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z"/></svg>' },
vault: { label: 'Vault', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' },
};
// Config shared by the Codex Agent and Claude Agent forms. Both use the same
// scope-gated /api/codex/* backend; this just parameterizes the UI label,
// default token name, and the per-agent install commands.
const AGENT_CONFIGS = {
codex: {
label: 'Codex Agent',
word: 'Codex',
namePrefix: 'codex agent',
defaultName: 'Codex Agent',
pluginPath: '/api/codex/plugin.zip',
setupDescription: 'Downloads the plugin bundle and registers it with Codex. Sets <code>ODYSSEUS_URL</code> + <code>ODYSSEUS_API_TOKEN</code>, fetches the plugin from <a href="/api/codex/plugin.zip" style="color:var(--accent,var(--red));">this Odysseus instance</a>, and runs <code>codex plugin add odysseus@personal</code>.',
buildSetup: (origin, token) => `export ODYSSEUS_URL=${origin}
export ODYSSEUS_API_TOKEN='${token}'
mkdir -p ~/plugins
curl -fsSL -H "Authorization: Bearer $ODYSSEUS_API_TOKEN" "$ODYSSEUS_URL/api/codex/plugin.zip" -o /tmp/odysseus-codex-plugin.zip
python3 -m zipfile -e /tmp/odysseus-codex-plugin.zip ~/plugins
python3 - <<'PY'
import json
from pathlib import Path
p = Path.home() / ".agents" / "plugins" / "marketplace.json"
p.parent.mkdir(parents=True, exist_ok=True)
if p.exists():
data = json.loads(p.read_text())
else:
data = {"name": "personal", "interface": {"displayName": "Personal"}, "plugins": []}
data.setdefault("name", "personal")
data.setdefault("interface", {}).setdefault("displayName", "Personal")
plugins = data.setdefault("plugins", [])
entry = {
"name": "odysseus",
"source": {"source": "local", "path": "./plugins/odysseus"},
"policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"},
"category": "Productivity",
}
data["plugins"] = [item for item in plugins if item.get("name") != "odysseus"] + [entry]
p.write_text(json.dumps(data, indent=2) + "\\n")
PY
codex plugin add odysseus@personal
python3 ~/plugins/odysseus/scripts/odysseus_api.py capabilities`,
},
claude: {
label: 'Claude Agent',
word: 'Claude',
namePrefix: 'claude agent',
defaultName: 'Claude Agent',
pluginPath: '/api/claude/plugin.zip',
setupDescription: 'Downloads the skill bundle into <code>~/.claude/skills/odysseus/</code>. Sets <code>ODYSSEUS_URL</code> + <code>ODYSSEUS_API_TOKEN</code>, fetches the skill from <a href="/api/claude/plugin.zip" style="color:var(--accent,var(--red));">this Odysseus instance</a>. Claude Code auto-loads the skill on next start.',
buildSetup: (origin, token) => `export ODYSSEUS_URL=${origin}
export ODYSSEUS_API_TOKEN='${token}'
mkdir -p ~/.claude
curl -fsSL -H "Authorization: Bearer $ODYSSEUS_API_TOKEN" "$ODYSSEUS_URL/api/claude/plugin.zip" -o /tmp/odysseus-claude-skill.zip
python3 -m zipfile -e /tmp/odysseus-claude-skill.zip ~/.claude/
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py capabilities`,
},
};
let _unifiedInited = false;
async function initUnifiedIntegrations() {
@@ -3169,10 +3228,17 @@ async function initUnifiedIntegrations() {
}
for (const tok of (Array.isArray(tokenRes) ? tokenRes : [])) {
const scopes = tok.scopes || [];
const isCodex = (tok.name || '').toLowerCase().includes('codex') || scopes.some(s => String(s || '').startsWith('todos:') || String(s || '').startsWith('email:') || String(s || '').startsWith('documents:'));
if (!isCodex) continue;
const lowerName = (tok.name || '').toLowerCase();
let agentType = null;
if (lowerName.startsWith('claude agent')) agentType = 'claude';
else if (lowerName.startsWith('codex agent')) agentType = 'codex';
else if (scopes.some(s => String(s || '').startsWith('todos:') || String(s || '').startsWith('email:') || String(s || '').startsWith('documents:'))) {
// Legacy / un-prefixed scoped tokens fall back to Codex for backwards compat.
agentType = 'codex';
}
if (!agentType) continue;
const detail = `${tok.token_prefix || 'token'}... - ${scopes.join(', ') || 'chat'}`;
items.push({ type: 'codex', id: tok.id, name: tok.name || 'Codex Agent', detail, enabled: true, data: tok });
items.push({ type: agentType, id: tok.id, name: tok.name || (agentType === 'claude' ? 'Claude Agent' : 'Codex Agent'), detail, enabled: true, data: tok });
}
// Vaultwarden removed as an integration option.
return items;
@@ -3184,9 +3250,9 @@ async function initUnifiedIntegrations() {
// type gets. (The clickable glow-on-test variant for email was
// removed earlier; this matches the API/CalDAV/MCP pattern.)
const statusDot = item.enabled
? '<span style="width:8px;height:8px;border-radius:50%;background:var(--green,#50fa7b);flex-shrink:0" title="Active"></span>'
? '<span style="width:8px;height:8px;border-radius:50%;background:var(--color-success,#50fa7b);flex-shrink:0;--notif-glow:var(--color-success,#50fa7b);animation:cookbook-notif-pulse 2s ease-in-out infinite;" title="Active"></span>'
: '<span style="width:8px;height:8px;border-radius:50%;background:var(--fg);opacity:0.3;flex-shrink:0" title="Disabled"></span>';
return `<div class="intg-card" data-intg-id="${item.id}" data-intg-type="${item.type}" style="display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--border);border-radius:6px;margin-bottom:6px;cursor:pointer" title="Click to edit">
return `<div class="intg-card" data-intg-id="${item.id}" data-intg-type="${item.type}" style="display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:color-mix(in srgb, var(--fg) 3%, transparent);margin-bottom:6px;cursor:pointer;transition:all 0.15s;" title="Click to edit">
<span style="opacity:0.6;flex-shrink:0">${t.icon}</span>
<div style="flex:1;min-width:0">
<div style="font-size:12px;font-weight:600;display:flex;align-items:center;gap:6px">${item.name} <span style="font-size:9px;text-transform:uppercase;letter-spacing:0.5px;padding:1px 5px;border:1px solid color-mix(in srgb, var(--accent, var(--red)) 50%, transparent);border-radius:3px;color:var(--accent, var(--red));background:color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);">${t.label}</span></div>
@@ -3245,7 +3311,7 @@ async function initUnifiedIntegrations() {
}
else if (type === 'email') await fetch(`/api/email/accounts/${id}`, { method: 'DELETE', credentials: 'same-origin' });
else if (type === 'mcp') await fetch(`/api/mcp/servers/${id}`, { method: 'DELETE', credentials: 'same-origin' });
else if (type === 'codex') await fetch(`/api/tokens/${id}`, { method: 'DELETE', credentials: 'same-origin' });
else if (type === 'codex' || type === 'claude') await fetch(`/api/tokens/${id}`, { method: 'DELETE', credentials: 'same-origin' });
else if (type === 'vault') await fetch('/api/vault/logout', { method: 'POST', credentials: 'same-origin' });
} catch (_) {}
formEl.style.display = 'none';
@@ -3262,7 +3328,8 @@ async function initUnifiedIntegrations() {
else if (type === 'contacts' || type === 'carddav') showCardDavForm();
else if (type === 'email') showEmailForm(editId);
else if (type === 'mcp') showMcpForm(editId);
else if (type === 'codex') showCodexForm(editId);
else if (type === 'codex') showAgentForm('codex', editId);
else if (type === 'claude') showAgentForm('claude', editId);
else if (type === 'vault') showVaultForm();
}
@@ -3287,15 +3354,46 @@ async function initUnifiedIntegrations() {
// and they're patchy on mobile browsers. A native select renders
// the same everywhere and makes the available options visible
// without needing the user to type.
const selectOpts = presetEntries
.sort((a, b) => (a[1].name || a[0]).localeCompare(b[1].name || b[0]))
const sortedPresets = presetEntries.sort((a, b) => (a[1].name || a[0]).localeCompare(b[1].name || b[0]));
const selectOpts = sortedPresets
.map(([k, p]) => `<option value="${k}">${esc(p.name || k)}</option>`)
.join('');
// Letter-in-brand-color logo for each API preset; outline plug icon for
// "Custom (no preset)". Matches the email-provider dropdown pattern.
const _apiLetter = (letter, bg) => `<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="flex-shrink:0"><circle cx="12" cy="12" r="11" fill="${bg}"/><text x="12" y="16.5" font-size="13" font-weight="700" text-anchor="middle" fill="#fff" font-family="system-ui,sans-serif">${letter}</text></svg>`;
const _apiCustomIco = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0;opacity:0.7"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
const API_PRESET_LOGO = {
miniflux: _apiLetter('M', '#214c87'),
gitea: _apiLetter('G', '#609926'),
linkding: _apiLetter('L', '#1f2937'),
home_assistant: _apiLetter('H', '#41bdf5'),
ntfy: _apiLetter('n', '#317f43'),
vaultwarden: _apiLetter('V', '#175ddc'),
freshrss: _apiLetter('R', '#ef6c00'),
};
const _apiIconFor = (k) => {
if (!k) return _apiCustomIco;
if (API_PRESET_LOGO[k]) return API_PRESET_LOGO[k];
const first = (presets[k]?.name || k).trim().charAt(0).toUpperCase() || '?';
return _apiLetter(first, '#6b7280');
};
const _apiRows = [['', 'Custom (no preset)'], ...sortedPresets.map(([k, p]) => [k, p.name || k])]
.map(([k, label]) => `<button type="button" class="ufapi-option" data-value="${esc(k)}" style="display:flex;align-items:center;gap:10px;width:100%;padding:8px 10px;background:transparent;border:0;color:var(--fg);font:inherit;cursor:pointer;text-align:left;">${_apiIconFor(k)}<span>${esc(label)}</span></button>`).join('');
formEl.innerHTML = `
<div class="admin-card" style="margin-top:8px">
<h2 style="font-size:13px">${editId ? 'Edit' : 'Add'} API Integration</h2>
<h2 style="font-size:13px;display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>API Integration</h2>
<div class="settings-col">
<div class="settings-row"><label class="settings-label">Preset</label><select id="uf-api-preset" class="settings-select"><option value="">Custom (no preset)</option>${selectOpts}</select></div>
<div class="settings-row"><label class="settings-label">Preset</label>
<div style="position:relative;flex:1;min-width:0;">
<select id="uf-api-preset" tabindex="-1" aria-hidden="true" style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;"><option value="">Custom (no preset)</option>${selectOpts}</select>
<button type="button" id="uf-api-preset-trigger" class="settings-select" style="display:flex;align-items:center;gap:10px;cursor:pointer;text-align:left;width:100%;padding-right:24px;position:relative;">
<span class="ufapi-icon" style="display:inline-flex;align-items:center;">${_apiCustomIco}</span>
<span class="ufapi-label" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Custom (no preset)</span>
<span aria-hidden="true" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);opacity:0.5;font-size:10px;pointer-events:none;">▾</span>
</button>
<div id="uf-api-preset-menu" style="display:none;position:absolute;top:calc(100% + 2px);left:0;right:0;z-index:1000;background:var(--panel);border:1px solid var(--border);border-radius:6px;max-height:340px;overflow-y:auto;box-shadow:0 6px 18px rgba(0,0,0,0.25);">${_apiRows}</div>
</div>
</div>
<div class="settings-row"><label class="settings-label">Name</label><input id="uf-api-name" class="settings-input" placeholder="My Service"></div>
<div class="settings-row"><label class="settings-label">Base URL</label><input id="uf-api-url" class="settings-input" placeholder="http://localhost:8080"></div>
<div id="uf-api-ntfy-hint" style="display:none;font-size:11px;line-height:1.35;opacity:0.68;margin:-2px 0 2px 106px;"></div>
@@ -3305,6 +3403,48 @@ async function initUnifiedIntegrations() {
<div class="settings-row" style="margin-top:4px"><button class="admin-btn-sm" id="uf-api-save">Save</button><button class="admin-btn-sm" id="uf-api-test" style="opacity:0.7">Test</button><button class="admin-btn-sm" id="uf-api-cancel" style="opacity:0.7">Cancel</button><span id="uf-api-msg" style="font-size:11px"></span></div>
</div>
</div>`;
// Custom preset dropdown wire-up (hidden select stays as data source).
(() => {
const trig = el('uf-api-preset-trigger');
const menu = el('uf-api-preset-menu');
const sel = el('uf-api-preset');
if (!trig || !menu || !sel) return;
const lbl = trig.querySelector('.ufapi-label');
const ico = trig.querySelector('.ufapi-icon');
const _setFromKey = (k) => {
const row = menu.querySelector(`.ufapi-option[data-value="${k}"]`);
const text = row?.querySelector('span')?.textContent || 'Custom (no preset)';
if (lbl) lbl.textContent = text;
if (ico) ico.innerHTML = _apiIconFor(k);
};
const _close = () => { menu.style.display = 'none'; };
const _open = () => {
menu.style.display = 'block';
const tRect = trig.getBoundingClientRect();
const mRect = menu.getBoundingClientRect();
const below = window.innerHeight - tRect.bottom;
const above = tRect.top;
if (mRect.height > below && above > below) { menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)'; }
else { menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto'; }
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trig) { _close(); document.removeEventListener('click', onDoc, true); } };
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
};
trig.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _close() : _open(); });
menu.querySelectorAll('.ufapi-option').forEach(btn => {
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; });
btn.addEventListener('click', (e) => {
e.stopPropagation();
const k = btn.dataset.value || '';
sel.value = k;
_setFromKey(k);
_close();
sel.dispatchEvent(new Event('change', { bubbles: true }));
});
});
_setFromKey(sel.value || '');
})();
const preset = el('uf-api-preset'), name = el('uf-api-name'), url = el('uf-api-url'), auth = el('uf-api-auth'), header = el('uf-api-header'), key = el('uf-api-key'), ntfyHint = el('uf-api-ntfy-hint');
let _editId = editId && editId !== 'new' ? editId : null;
// Load existing
@@ -3462,17 +3602,27 @@ async function initUnifiedIntegrations() {
async function showCardDavForm() {
formEl.innerHTML = `
<div class="admin-card" style="margin-top:8px">
<h2 style="font-size:13px">Contacts (CardDAV)</h2>
<h2 style="font-size:13px;display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Contacts (CardDAV)</h2>
<div class="settings-col">
<div class="settings-row"><label class="settings-label">URL</label><input id="uf-carddav-url" class="settings-input" placeholder="http://localhost:5232/user/contacts/"></div>
<div class="settings-row"><label class="settings-label">Username</label><input id="uf-carddav-user" class="settings-input"></div>
<div class="settings-row"><label class="settings-label">Password</label><input id="uf-carddav-pass" class="settings-input" type="password"></div>
<div class="settings-row" style="margin-top:4px"><button class="admin-btn-sm" id="uf-carddav-save">Save</button><button class="admin-btn-sm" id="uf-carddav-cancel" style="opacity:0.7">Cancel</button><span id="uf-carddav-msg" style="font-size:11px"></span></div>
<div class="settings-row" style="margin-top:8px;align-items:center;">
<button class="admin-btn-add" id="uf-carddav-save" style="background:var(--red);border-color:var(--red);color:#fff;display:inline-flex;align-items:center;gap:5px;font-weight:600;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
Save
</button>
<span id="uf-carddav-msg" style="font-size:11px;flex:1;margin-left:8px"></span>
<button class="admin-btn-add" id="uf-carddav-cancel" style="opacity:0.7;display:inline-flex;align-items:center;gap:5px;position:relative;top:1px;margin-left:auto;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
Cancel
</button>
</div>
</div>
</div>
<div class="admin-card contacts-manager" style="margin-top:8px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<h2 style="font-size:13px;margin:0;">Contacts Import <span id="cm-count" style="opacity:0.5;font-weight:normal;font-size:11px;"></span></h2>
<h2 style="font-size:13px;margin:0;display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Contacts Import <span id="cm-count" style="opacity:0.5;font-weight:normal;font-size:11px;"></span></h2>
<button class="admin-btn-sm" id="cm-import-btn" style="margin-left:auto;">Import</button>
<button class="admin-btn-sm" id="cm-export-vcf-btn">Export .vcf</button>
<button class="admin-btn-sm" id="cm-export-csv-btn">Export .csv</button>
@@ -3632,8 +3782,14 @@ async function initUnifiedIntegrations() {
<div class="contact-name" style="font-size:12px;font-weight:600;">${esc(c.name || '(no name)')}</div>
<div class="contact-sub" style="font-size:10px;opacity:0.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(sub)}</div>
</div>
<button class="admin-btn-sm contact-edit" title="Edit">Edit</button>
<button class="admin-btn-sm contact-del" title="Delete" style="opacity:0.75;">Delete</button>
<button class="admin-btn-sm contact-edit" title="Edit" style="display:inline-flex;align-items:center;gap:4px;color:var(--accent, var(--red));border-color:color-mix(in srgb, var(--accent, var(--red)) 35%, var(--border));">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Edit
</button>
<button class="admin-btn-sm contact-del" title="Delete" style="opacity:0.85;display:inline-flex;align-items:center;gap:4px;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Delete
</button>
</div>
<div class="contact-row-edit" style="display:none;flex-direction:column;gap:4px;">
<input class="settings-input contact-edit-name" value="${esc(c.name || '')}" placeholder="Name">
@@ -3715,22 +3871,49 @@ async function initUnifiedIntegrations() {
};
const _providerOptions = Object.entries(PROVIDERS)
.map(([k, v]) => `<option value="${k}">${esc(v.label)}</option>`).join('');
// Provider logos — small SVGs the custom dropdown renders next to each
// option. Letter-in-brand-color circle for known providers; outline
// envelope for "Custom…". Inline SVG (no external assets, no emoji).
const _letterLogo = (letter, bg) => `<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="flex-shrink:0"><circle cx="12" cy="12" r="11" fill="${bg}"/><text x="12" y="16.5" font-size="13" font-weight="700" text-anchor="middle" fill="#fff" font-family="system-ui,sans-serif">${letter}</text></svg>`;
const _customLogo = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0;opacity:0.7"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>';
const PROV_LOGO = {
'': _customLogo,
gmail: _letterLogo('G', '#ea4335'),
migadu: _letterLogo('M', '#3aa39d'),
icloud: _letterLogo('i', '#3693f3'),
outlook: _letterLogo('O', '#0078d4'),
fastmail: _letterLogo('F', '#4a5fbb'),
yahoo: _letterLogo('Y', '#6001d2'),
dovecot: _letterLogo('D', '#6b7280'),
};
const _provOptionRows = [['', 'Custom…'], ...Object.entries(PROVIDERS).map(([k, v]) => [k, v.label])]
.map(([k, label]) => `<button type="button" class="ufp-option" data-value="${esc(k)}" style="display:flex;align-items:center;gap:8px;width:100%;padding:8px 10px;background:transparent;border:0;color:var(--fg);font:inherit;cursor:pointer;text-align:left;">${PROV_LOGO[k] || _customLogo}<span>${esc(label)}</span></button>`).join('');
const _smtpSecurity = (acct) => acct?.smtp_security || ((parseInt(acct?.smtp_port || 465) === 587) ? 'starttls' : 'ssl');
formEl.innerHTML = `
<div class="admin-card" style="margin-top:8px">
<h2 style="font-size:13px">${isEdit ? 'Edit' : 'Add'} Email Account</h2>
<div class="settings-col">
<div class="settings-row"><label class="settings-label">Provider${_hint('Pick a known provider to auto-fill the IMAP and SMTP host/port. Choose Custom to type your own.')}</label><select id="uf-email-provider" class="settings-select"><option value="">Custom…</option>${_providerOptions}</select></div>
<div class="settings-row"><label class="settings-label">Provider${_hint('Pick a known provider to auto-fill the IMAP and SMTP host/port. Choose Custom to type your own.')}</label>
<div class="ufp-wrap" style="position:relative;flex:1;min-width:0;">
<select id="uf-email-provider" tabindex="-1" aria-hidden="true" style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;"><option value="">Custom…</option>${_providerOptions}</select>
<button type="button" id="uf-email-provider-trigger" class="settings-select" style="display:flex;align-items:center;gap:8px;cursor:pointer;text-align:left;width:100%;padding-right:24px;position:relative;">
<span class="ufp-icon" style="display:inline-flex;align-items:center;">${PROV_LOGO['']}</span>
<span class="ufp-label" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Custom…</span>
<span aria-hidden="true" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);opacity:0.5;font-size:10px;pointer-events:none;">▾</span>
</button>
<div id="uf-email-provider-menu" style="display:none;position:absolute;top:calc(100% + 2px);left:0;right:0;z-index:1000;background:var(--panel);border:1px solid var(--border);border-radius:6px;max-height:280px;overflow-y:auto;box-shadow:0 6px 18px rgba(0,0,0,0.25);">${_provOptionRows}</div>
</div>
</div>
<div id="uf-email-provider-note" style="display:none;font-size:11px;line-height:1.5;padding:8px 10px;margin:2px 0 4px;border:1px solid color-mix(in srgb, var(--fg) 15%, transparent);border-left:3px solid var(--accent, var(--red));border-radius:4px;background:color-mix(in srgb, var(--fg) 4%, transparent);"></div>
<div class="settings-row"><label class="settings-label">Name${_hint('Optional label for this account (e.g. “Work” or “Personal”). Leave blank to use the email address.')}</label><input id="uf-email-name" class="settings-input" placeholder="(optional — leave blank to use email)"></div>
<div class="settings-row"><label class="settings-label">Email${_hint('Your email address. Used as the From: header on outgoing mail and as the display label when Name is blank.')}</label><input id="uf-email-from" class="settings-input" placeholder="you@example.com"></div>
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:4px 0 2px">IMAP (Receiving)</div>
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:4px 0 2px;display:flex;align-items:center;gap:5px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;" aria-hidden="true"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>IMAP (Receiving)</div>
<div class="settings-row"><label class="settings-label">Host${_hint('Your IMAP server, e.g. imap.gmail.com, imap.migadu.com, a LAN host, or a Tailscale IP for Dovecot.')}</label><input id="uf-imap-host" class="settings-input" placeholder="imap.example.com"></div>
<div class="settings-row"><label class="settings-label">Port${_hint('993 for IMAPS (most providers), 143 for plain or STARTTLS. Local servers often use a custom port like 31143.')}</label><input id="uf-imap-port" class="settings-input" type="number" placeholder="993" style="max-width:100px"></div>
<div class="settings-row"><label class="settings-label">Username${_hint('Yes — your full email address goes here too (e.g. you@gmail.com). Same as the Email field above for almost every provider.')}</label><input id="uf-imap-user" class="settings-input" placeholder="you@example.com"></div>
<div class="settings-row"><label class="settings-label">Password${_hint('For Gmail, iCloud, and Yahoo: paste your App Password (NOT your normal account password — those are blocked for IMAP). For Migadu, Fastmail, Outlook, etc.: your regular mailbox password works.')}</label><input id="uf-imap-pass" class="settings-input" type="password" placeholder="${placeholderPass}"></div>
<div class="settings-row"><label class="settings-label">STARTTLS${_hint('Turn ON for port 143/587 to upgrade plain to TLS. Turn OFF for port 993 (IMAPS — already encrypted) or a local server with no TLS configured.')}</label><label class="admin-switch" style="margin-left:0"><input type="checkbox" id="uf-imap-starttls" checked><span class="admin-slider"></span></label></div>
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px">SMTP (Sending) <span style="font-weight:normal;opacity:0.7">— optional, leave blank for read-only</span></div>
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px;display:flex;align-items:center;gap:5px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>SMTP (Sending) <span style="font-weight:normal;opacity:0.7">— optional, leave blank for read-only</span></div>
<div class="settings-row"><label class="settings-label">Host${_hint('Your outgoing-mail server, e.g. smtp.gmail.com. Leave blank to make this account read-only.')}</label><input id="uf-smtp-host" class="settings-input" placeholder="smtp.example.com"></div>
<div class="settings-row"><label class="settings-label">Port${_hint('465 for SSL/SMTPS, 587 for STARTTLS. 25 is usually blocked by ISPs.')}</label><input id="uf-smtp-port" class="settings-input" type="number" placeholder="465" style="max-width:100px"></div>
<div class="settings-row"><label class="settings-label">Security${_hint('SSL for port 465, STARTTLS for port 587, or None for local SMTP bridges such as Proton Mail Bridge.')}</label><select id="uf-smtp-security" class="settings-select"><option value="ssl">SSL</option><option value="starttls">STARTTLS</option><option value="none">None</option></select></div>
@@ -3847,6 +4030,56 @@ async function initUnifiedIntegrations() {
</div>`;
};
// Custom dropdown wire-up — the native <select> stays in the DOM as the
// data source and accessibility target, but the visible UI is a button +
// popup so each provider row can render with its SVG logo. Selecting an
// option updates select.value and dispatches a `change` event so the
// existing autofill handler below runs unchanged.
(() => {
const trigger = el('uf-email-provider-trigger');
const menu = el('uf-email-provider-menu');
const sel = el('uf-email-provider');
if (!trigger || !menu || !sel) return;
const labelEl = trigger.querySelector('.ufp-label');
const iconEl = trigger.querySelector('.ufp-icon');
const _setFromKey = (k) => {
const row = menu.querySelector(`.ufp-option[data-value="${k}"]`);
const lbl = row?.querySelector('span')?.textContent || 'Custom…';
if (labelEl) labelEl.textContent = lbl;
if (iconEl) iconEl.innerHTML = PROV_LOGO[k] || _customLogo;
};
const _closeMenu = () => { menu.style.display = 'none'; };
const _openMenu = () => {
menu.style.display = 'block';
// Drop-up when there's not enough room below the trigger.
const tRect = trigger.getBoundingClientRect();
const mRect = menu.getBoundingClientRect();
const below = window.innerHeight - tRect.bottom;
const above = tRect.top;
if (mRect.height > below && above > below) {
menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)';
} else {
menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto';
}
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trigger) { _closeMenu(); document.removeEventListener('click', onDoc, true); } };
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
};
trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); });
menu.querySelectorAll('.ufp-option').forEach(btn => {
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; });
btn.addEventListener('click', (e) => {
e.stopPropagation();
const k = btn.dataset.value || '';
sel.value = k;
_setFromKey(k);
_closeMenu();
sel.dispatchEvent(new Event('change', { bubbles: true }));
});
});
_setFromKey(sel.value || '');
})();
// Provider preset → autofill IMAP + SMTP host/port + STARTTLS, set the
// helper note, and update the Email/Username placeholders to a
// provider-specific example so users see the right format at a glance.
@@ -4313,7 +4546,8 @@ async function initUnifiedIntegrations() {
}
}
async function showCodexForm(editId) {
async function showAgentForm(kind, editId) {
const cfg = AGENT_CONFIGS[kind] || AGENT_CONFIGS.codex;
let tokens = [];
try {
const tokRes = await fetch('/api/tokens', { credentials: 'same-origin' });
@@ -4323,9 +4557,9 @@ async function initUnifiedIntegrations() {
const toolScopes = [
{ key: 'todos:read', label: 'Todos', detail: 'Read notes and checklists' },
{ key: 'todos:write', label: 'Todos write', detail: 'Create, update, delete, and toggle todo items' },
{ key: 'documents:read', label: 'Documents', detail: 'Read documents when a Codex document API is enabled' },
{ key: 'documents:read', label: 'Documents', detail: 'Read documents when a document API is enabled' },
{ key: 'documents:write', label: 'Documents write', detail: 'Create and update draft documents' },
{ key: 'email:read', label: 'Email', detail: 'Read email when a Codex email API is enabled' },
{ key: 'email:read', label: 'Email', detail: 'Read email when an email API is enabled' },
{ key: 'email:draft', label: 'Email drafts', detail: 'Create email reply drafts without sending' },
{ key: 'email:send', label: 'Email send', detail: 'Send email directly' },
{ key: 'calendar:read', label: 'Calendar', detail: 'Read calendar events when enabled' },
@@ -4333,101 +4567,114 @@ async function initUnifiedIntegrations() {
{ key: 'memory:read', label: 'Memory', detail: 'Read memory when enabled' },
{ key: 'memory:write', label: 'Memory write', detail: 'Write memory when enabled' },
];
const codexTokens = (Array.isArray(tokens) ? tokens : []).filter(tok => {
const scopes = tok.scopes || [];
return (tok.name || '').toLowerCase().includes('codex') || scopes.some(s => String(s || '').startsWith('todos:') || String(s || '').startsWith('email:') || String(s || '').startsWith('documents:'));
});
const current = codexTokens.find(t => String(t.id) === String(editId));
// Strict name-prefix match keeps Codex and Claude tokens in their own forms.
const agentTokens = (Array.isArray(tokens) ? tokens : []).filter(tok =>
(tok.name || '').toLowerCase().startsWith(cfg.namePrefix)
);
const current = agentTokens.find(t => String(t.id) === String(editId));
const _scopeIcons = {
todos: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><line x1="8" y1="9" x2="16" y2="9"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>',
documents: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
email: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2 6 12 13 22 6"/></svg>',
calendar: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
memory: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 2a2.5 2.5 0 0 0-2.5 2.5 2.5 2.5 0 0 0-2.5 2.5A2.5 2.5 0 0 0 2 9.5v3A2.5 2.5 0 0 0 4.5 15a2.5 2.5 0 0 0 2.5 2.5A2.5 2.5 0 0 0 9.5 20H10V2z"/><path d="M14.5 2a2.5 2.5 0 0 1 2.5 2.5 2.5 2.5 0 0 1 2.5 2.5A2.5 2.5 0 0 1 22 9.5v3A2.5 2.5 0 0 1 19.5 15a2.5 2.5 0 0 1-2.5 2.5A2.5 2.5 0 0 1 14.5 20H14V2z"/></svg>',
};
const _scopeNiceLabel = (label) => label.replace(/\s+(write|drafts?|send)$/i, '');
const _scopeAction = (key) => (key.split(':')[1] || '').toLowerCase();
const _pillStyle = (action) => {
if (action === 'read') return 'background:rgba(150,150,150,0.18);color:var(--fg-muted,#888);';
return 'background:color-mix(in srgb, var(--accent, var(--red)) 18%, transparent);color:var(--accent, var(--red));';
};
const scopeToggles = (t) => {
const scopes = new Set(t.scopes || []);
return toolScopes.map(scope => `
<label class="settings-row" style="align-items:flex-start;gap:10px;">
<span class="settings-label" style="padding-top:2px;">${esc(scope.label)}</span>
<span style="display:flex;align-items:flex-start;gap:8px;flex:1;min-width:0;">
<label class="admin-switch" style="margin-left:0;flex-shrink:0;"><input type="checkbox" class="uf-codex-scope" data-token-id="${esc(t.id)}" data-scope="${esc(scope.key)}" ${scopes.has(scope.key) ? 'checked' : ''}><span class="admin-slider"></span></label>
<span style="font-size:11px;line-height:1.35;opacity:0.62;">${esc(scope.detail)}</span>
</span>
</label>`).join('');
return toolScopes.map(scope => {
const tool = scope.key.split(':')[0];
const action = _scopeAction(scope.key);
const icon = _scopeIcons[tool] || '';
const niceLabel = _scopeNiceLabel(scope.label);
return `
<label class="settings-row" style="align-items:center;gap:8px;display:flex;min-height:30px;padding:2px 0;">
<span style="opacity:0.7;display:inline-flex;align-items:center;justify-content:center;width:16px;flex-shrink:0;">${icon}</span>
<span class="settings-label" style="width:75px;flex-shrink:0;padding:0;">${esc(niceLabel)}</span>
<span style="font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;padding:1px 7px;border-radius:999px;flex-shrink:0;min-width:44px;text-align:center;margin-left:-3px;box-sizing:border-box;${_pillStyle(action)}">${esc(action)}</span>
<span style="font-size:11px;line-height:1.35;opacity:0.62;flex:1;min-width:0;">${esc(scope.detail)}</span>
<label class="admin-switch" style="margin-left:auto;flex-shrink:0;"><input type="checkbox" class="uf-codex-scope" data-token-id="${esc(t.id)}" data-scope="${esc(scope.key)}" ${scopes.has(scope.key) ? 'checked' : ''}><span class="admin-slider"></span></label>
</label>`;
}).join('');
};
const tokenRows = codexTokens.length ? codexTokens.map(t => `
const tokenRows = agentTokens.length ? agentTokens.map(t => `
<div class="uf-codex-token" data-token-id="${esc(t.id)}" style="border:1px solid var(--border);border-radius:6px;padding:9px 10px;margin-top:8px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<div style="flex:1;min-width:0;">
<div style="font-size:12px;font-weight:600;">${esc(t.name || 'Codex Agent')}</div>
<div style="font-size:10px;opacity:0.52;">${esc(t.token_prefix || 'token')}...${t.last_used_at ? ` · Last used ${new Date(t.last_used_at).toLocaleDateString()}` : ' · Never used'}</div>
<input type="text" class="uf-codex-rename settings-input" data-token-id="${esc(t.id)}" value="${esc(t.name || cfg.defaultName)}" placeholder="${esc(cfg.defaultName)} (e.g. ${esc(cfg.word)} on laptop)" style="font-size:12px;font-weight:600;padding:3px 6px;width:100%;background:transparent;border:1px solid transparent;border-radius:4px;" title="Click to rename this agent">
<div style="font-size:10px;opacity:0.52;margin-top:2px;">${esc(t.token_prefix || 'token')}...${t.last_used_at ? ` · Last used ${new Date(t.last_used_at).toLocaleDateString()}` : ' · Never used'}</div>
</div>
<button class="admin-btn-sm uf-codex-copy-prefix" data-token-prefix="${esc(t.token_prefix || '')}" title="Copy token prefix (full token only shown once, at creation)" style="opacity:0.7">Copy</button>
<button class="admin-btn-delete uf-codex-revoke" data-token-id="${esc(t.id)}">Revoke</button>
</div>
<div style="font-size:11px;font-weight:600;opacity:0.62;margin-bottom:4px;">Tool access</div>
${scopeToggles(t)}
<div class="uf-codex-scope-msg" data-token-id="${esc(t.id)}" style="font-size:11px;min-height:14px;"></div>
</div>`).join('') : '<div style="opacity:0.45;font-size:11px;padding:8px 0;">No Codex tokens yet.</div>';
</div>`).join('') : `<div style="opacity:0.45;font-size:11px;padding:8px 0;">No ${esc(cfg.word)} tokens yet.</div>`;
const origin = window.location.origin || '';
const setupForToken = (token) => `export ODYSSEUS_URL=${origin}
export ODYSSEUS_API_TOKEN='${token}'
mkdir -p ~/plugins
curl -fsSL -H "Authorization: Bearer $ODYSSEUS_API_TOKEN" "$ODYSSEUS_URL/api/codex/plugin.zip" -o /tmp/odysseus-codex-plugin.zip
python3 -m zipfile -e /tmp/odysseus-codex-plugin.zip ~/plugins
python3 - <<'PY'
import json
from pathlib import Path
p = Path.home() / ".agents" / "plugins" / "marketplace.json"
p.parent.mkdir(parents=True, exist_ok=True)
if p.exists():
data = json.loads(p.read_text())
else:
data = {"name": "personal", "interface": {"displayName": "Personal"}, "plugins": []}
data.setdefault("name", "personal")
data.setdefault("interface", {}).setdefault("displayName", "Personal")
plugins = data.setdefault("plugins", [])
entry = {
"name": "odysseus",
"source": {"source": "local", "path": "./plugins/odysseus"},
"policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"},
"category": "Productivity",
}
data["plugins"] = [item for item in plugins if item.get("name") != "odysseus"] + [entry]
p.write_text(json.dumps(data, indent=2) + "\\n")
PY
codex plugin add odysseus@personal
python3 ~/plugins/odysseus/scripts/odysseus_api.py capabilities`;
const setupPlaceholder = `Add an agent to generate a one-time token and copy the full setup commands.`;
const setupForToken = (token) => cfg.buildSetup(origin, token);
formEl.innerHTML = `
<div class="admin-card" style="margin-top:8px">
<h2 style="font-size:13px">${current ? 'Codex Agent' : 'Add Codex Agent'}</h2>
<h2 style="font-size:13px">${esc(cfg.label)}</h2>
<div style="font-size:11px;opacity:0.65;line-height:1.45;margin:-2px 0 8px;">Generates a scoped token + setup commands so ${esc(cfg.word)} on your own machine can read/write your Odysseus data (todos, email, calendar, etc.). The agent runs in your terminal — it isn't streamed inside Odysseus.</div>
<div class="settings-col">
<div style="font-size:11px;line-height:1.45;opacity:0.68;margin-bottom:4px;">Create a Codex agent token, then toggle exactly which Odysseus tools it can use.</div>
<div style="font-size:11px;line-height:1.45;padding:8px 10px;border:1px solid var(--border);border-left:3px solid var(--accent, var(--red));border-radius:6px;background:rgba(0,0,0,0.06);">
<div style="font-weight:600;margin-bottom:4px;">Codex setup</div>
<div style="opacity:0.68;margin-bottom:6px;">Odysseus ships a Codex plugin in <code>integrations/codex</code>. <a href="/api/codex/plugin.zip" style="color:var(--accent,var(--red));">Download plugin bundle</a>, then set this instance URL and the token shown after adding an agent in the terminal where Codex runs.</div>
<pre style="margin:0;white-space:pre-wrap;word-break:break-word;font-size:10px;line-height:1.45;"><code id="uf-codex-setup-code">${esc(setupPlaceholder)}</code></pre>
<button class="admin-btn-sm" id="uf-codex-copy-setup" style="margin-top:6px;opacity:0.55;">Copy setup</button>
<div id="uf-codex-pending" style="display:${current ? 'none' : 'block'};font-size:11px;opacity:0.6;padding:6px 0;">Creating agent...</div>
<div id="uf-codex-reveal" style="display:none;padding:10px 12px;border:1px solid var(--border);border-left:3px solid var(--accent, var(--red));border-radius:6px;background:rgba(0,0,0,0.04);width:100%;box-sizing:border-box;">
<div style="font-weight:600;font-size:12px;margin-bottom:6px;">${esc(cfg.word)} setup</div>
<div style="font-size:11px;opacity:0.62;margin-bottom:4px;">Copy this token now &mdash; it will not be shown again.</div>
<code id="uf-codex-token" style="display:block;word-break:break-all;font-size:11px;padding:6px 8px;background:rgba(0,0,0,0.08);border-radius:4px;"></code>
<div style="margin-top:6px;">
<button class="admin-btn-sm" id="uf-codex-copy-token">Copy token</button>
</div>
<div style="margin-top:14px;font-weight:600;font-size:11px;margin-bottom:4px;">Quickstart &mdash; or copy setup directly in your terminal</div>
<div style="font-size:11px;opacity:0.62;margin-bottom:6px;">${cfg.setupDescription}</div>
<pre style="margin:0;white-space:pre;overflow-x:auto;max-height:220px;overflow-y:auto;font-size:10px;line-height:1.45;padding:8px 10px;background:rgba(0,0,0,0.08);border-radius:4px;width:100%;box-sizing:border-box;"><code id="uf-codex-setup-code"></code></pre>
<div style="margin-top:6px;">
<button class="admin-btn-sm" id="uf-codex-copy-setup">Copy setup</button>
</div>
<div style="margin-top:14px;font-weight:600;font-size:11px;margin-bottom:4px;">Configure access</div>
<div style="font-size:11px;opacity:0.62;margin-bottom:6px;">Toggle which Odysseus tools this agent can use. New agents start with chat only.</div>
<div id="uf-codex-inline-scopes"></div>
</div>
<div class="settings-row"><label class="settings-label">Name</label><input id="uf-codex-name" class="settings-input" value="${esc(current?.name || 'Codex Agent')}" placeholder="Codex Agent"></div>
<div class="settings-row" style="margin-top:4px">
<button class="admin-btn-sm" id="uf-codex-create">Add Agent</button>
<button class="admin-btn-sm" id="uf-codex-cancel" style="opacity:0.7">Cancel</button>
<span id="uf-codex-msg" style="font-size:11px"></span>
</div>
<div id="uf-codex-reveal" style="display:none;padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:rgba(0,0,0,0.08);">
<div style="font-size:11px;opacity:0.65;margin-bottom:4px;">Copy this token now. It will not be shown again. New agents start with chat only; use Configure access before testing todos or email.</div>
<code id="uf-codex-token" style="display:block;word-break:break-all;font-size:11px;"></code>
<button class="admin-btn-sm" id="uf-codex-copy-token" style="margin-top:6px;">Copy token</button>
<button class="admin-btn-sm" id="uf-codex-configure" style="display:none;margin-top:6px;">Configure access</button>
</div>
<div style="font-size:11px;font-weight:600;opacity:0.62;margin-top:8px;">Agents</div>
<div style="font-size:11px;font-weight:600;opacity:0.62;margin-top:10px;">${agentTokens.length ? 'Existing agents' : 'Agents'}</div>
<div id="uf-codex-token-list">${tokenRows}</div>
<div class="settings-row" style="margin-top:10px;align-items:center;">
<button class="admin-btn-add" id="uf-codex-save" style="background:var(--red);border-color:var(--red);color:#fff;display:inline-flex;align-items:center;gap:5px;font-weight:600;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
Save
</button>
<span id="uf-codex-msg" style="font-size:11px;flex:1;margin-left:8px"></span>
<button class="admin-btn-add" id="uf-codex-cancel" style="opacity:0.7;display:inline-flex;align-items:center;gap:5px;position:relative;top:1px;margin-left:auto;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
Cancel
</button>
</div>
</div>
</div>`;
el('uf-codex-cancel')?.addEventListener('click', () => { formEl.style.display = 'none'; });
el('uf-codex-create')?.addEventListener('click', async () => {
el('uf-codex-save')?.addEventListener('click', () => {
const msg = el('uf-codex-msg');
const name = el('uf-codex-name').value.trim();
if (!name) { msg.textContent = 'Name required'; msg.style.color = 'var(--red)'; return; }
if (msg) { msg.textContent = 'Saved'; msg.style.color = 'var(--green, #50fa7b)'; }
setTimeout(() => { formEl.style.display = 'none'; }, 350);
});
const _autoCreateCodex = async () => {
const msg = el('uf-codex-msg');
const pending = el('uf-codex-pending');
const existingNames = new Set(agentTokens.map(t => (t.name || '').trim()));
let name = cfg.defaultName;
let n = 2;
while (existingNames.has(name)) { name = `${cfg.defaultName} ${n++}`; }
const fd = new FormData();
fd.append('name', name);
fd.append('scopes', 'chat');
@@ -4435,28 +4682,38 @@ python3 ~/plugins/odysseus/scripts/odysseus_api.py capabilities`;
const r = await fetch('/api/tokens', { method: 'POST', credentials: 'same-origin', body: fd });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || 'Failed');
if (pending) pending.style.display = 'none';
el('uf-codex-token').textContent = d.token || '';
el('uf-codex-reveal').style.display = '';
const configureBtn = el('uf-codex-configure');
if (configureBtn) {
configureBtn.dataset.tokenId = d.id || '';
configureBtn.style.display = '';
}
const setupBtn = el('uf-codex-copy-setup');
if (setupBtn) {
setupBtn.dataset.token = d.token || '';
setupBtn.style.opacity = '';
}
if (setupBtn) setupBtn.dataset.token = d.token || '';
const setupCode = el('uf-codex-setup-code');
if (setupCode) setupCode.textContent = setupForToken(d.token || '');
msg.textContent = 'Created. Configure access before testing tools.';
msg.style.color = 'var(--green, #50fa7b)';
// Populate inline scope toggles for the just-created token (Configure access already open)
const newToken = { id: d.id, name, scopes: d.scopes || ['chat'] };
const inlineEl = el('uf-codex-inline-scopes');
if (inlineEl) {
inlineEl.innerHTML = `
<div class="uf-codex-token" data-token-id="${esc(newToken.id)}">
${scopeToggles(newToken)}
<div class="uf-codex-scope-msg" data-token-id="${esc(newToken.id)}" style="font-size:11px;min-height:14px;"></div>
</div>`;
_wireScopeChange(inlineEl);
}
if (msg) {
msg.textContent = `Created "${name}".`;
msg.style.color = 'var(--green, #50fa7b)';
}
await renderList();
} catch (err) {
msg.textContent = err?.message || 'Failed';
msg.style.color = 'var(--red)';
if (pending) pending.style.display = 'none';
if (msg) {
msg.textContent = err?.message || 'Failed';
msg.style.color = 'var(--red)';
}
}
});
};
if (!current) _autoCreateCodex();
const _copyCodexToken = async (text) => {
const value = String(text || '');
if (!value) return false;
@@ -4516,67 +4773,159 @@ python3 ~/plugins/odysseus/scripts/odysseus_api.py capabilities`;
if (!ok) _selectTextFallback(token, 'uf-codex-reveal');
setTimeout(() => { const latest = el('uf-codex-copy-token'); if (latest) latest.textContent = 'Copy token'; }, 1600);
});
el('uf-codex-configure')?.addEventListener('click', async () => {
await showCodexForm(el('uf-codex-configure')?.dataset.tokenId || null);
});
formEl.querySelectorAll('.uf-codex-revoke').forEach(btn => {
btn.addEventListener('click', async () => {
if (!await window.styledConfirm('Revoke this Codex token? Terminal agents using it will lose access.', { confirmText: 'Revoke', danger: true })) return;
if (!await window.styledConfirm(`Revoke this ${cfg.word} token? Terminal agents using it will lose access.`, { confirmText: 'Revoke', danger: true })) return;
await fetch(`/api/tokens/${btn.dataset.tokenId}`, { method: 'DELETE', credentials: 'same-origin' });
await showCodexForm(null);
formEl.style.display = 'none';
await renderList();
});
});
formEl.querySelectorAll('.uf-codex-scope').forEach(cb => {
cb.addEventListener('change', async () => {
const tokenId = cb.dataset.tokenId;
const panel = formEl.querySelector(`.uf-codex-token[data-token-id="${CSS.escape(tokenId)}"]`);
const msg = formEl.querySelector(`.uf-codex-scope-msg[data-token-id="${CSS.escape(tokenId)}"]`);
const scopes = Array.from(panel.querySelectorAll('.uf-codex-scope:checked')).map(input => input.dataset.scope);
// Rename: PATCH the token's name when the user blurs the input (or hits Enter).
formEl.querySelectorAll('.uf-codex-rename').forEach(input => {
const original = input.value;
const commit = async () => {
const name = (input.value || '').trim();
if (!name || name === original) return;
try {
const r = await fetch(`/api/tokens/${tokenId}`, {
const r = await fetch(`/api/tokens/${input.dataset.tokenId}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scopes }),
body: JSON.stringify({ name }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || 'Failed');
if (msg) { msg.textContent = 'Saved'; msg.style.color = 'var(--green, #50fa7b)'; }
if (!r.ok) throw new Error('Save failed');
input.style.borderColor = 'var(--green, #50fa7b)';
setTimeout(() => { input.style.borderColor = 'transparent'; }, 800);
await renderList();
} catch (err) {
cb.checked = !cb.checked;
if (msg) { msg.textContent = err?.message || 'Failed'; msg.style.color = 'var(--red)'; }
} catch (_) {
input.value = original;
input.style.borderColor = 'var(--red)';
setTimeout(() => { input.style.borderColor = 'transparent'; }, 1200);
}
};
input.addEventListener('blur', commit);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } });
});
// Copy token prefix (full token irrecoverable after the one-time creation reveal).
formEl.querySelectorAll('.uf-codex-copy-prefix').forEach(btn => {
btn.addEventListener('click', async () => {
const prefix = btn.dataset.tokenPrefix || '';
if (!prefix) return;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(prefix);
} else {
const ta = document.createElement('textarea');
ta.value = prefix;
ta.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch (_) {}
ta.remove();
}
const label = btn.textContent;
btn.textContent = 'Copied prefix';
setTimeout(() => { btn.textContent = label; }, 1400);
} catch (_) {}
});
});
function _wireScopeChange(scope) {
scope.querySelectorAll('.uf-codex-scope').forEach(cb => {
if (cb.dataset.wired === '1') return;
cb.dataset.wired = '1';
cb.addEventListener('change', async () => {
const tokenId = cb.dataset.tokenId;
const panel = formEl.querySelector(`.uf-codex-token[data-token-id="${CSS.escape(tokenId)}"]`);
const msg = formEl.querySelector(`.uf-codex-scope-msg[data-token-id="${CSS.escape(tokenId)}"]`);
const scopes = Array.from(panel.querySelectorAll('.uf-codex-scope:checked')).map(input => input.dataset.scope);
try {
const r = await fetch(`/api/tokens/${tokenId}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scopes }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || 'Failed');
if (msg) { msg.textContent = 'Saved'; msg.style.color = 'var(--green, #50fa7b)'; }
await renderList();
} catch (err) {
cb.checked = !cb.checked;
if (msg) { msg.textContent = err?.message || 'Failed'; msg.style.color = 'var(--red)'; }
}
});
});
}
_wireScopeChange(formEl);
}
// ── Add button with type picker ──
if (addBtn) {
addBtn.addEventListener('click', () => {
formEl.style.display = '';
const _typeOptions = [
['api', 'API Service'],
['caldav', 'CalDAV Calendar'],
['claude', 'Claude Agent'],
['codex', 'Codex Agent'],
['carddav', 'Contacts (CardDAV)'],
['contacts', 'Contacts Import'],
['email', 'Email (IMAP/SMTP)'],
['mcp', 'MCP Tool Server'],
];
const _iconFor = (k) => (INTG_TYPES[k]?.icon || '').replace(/width="14"/, 'width="16"').replace(/height="14"/, 'height="16"');
const _rowsHtml = _typeOptions.map(([k, label]) => `<button type="button" class="uf-type-option" data-value="${k}" style="display:flex;align-items:center;gap:10px;width:100%;padding:8px 10px;background:transparent;border:0;color:var(--fg);font:inherit;cursor:pointer;text-align:left;"><span style="display:inline-flex;color:var(--accent, var(--red));flex-shrink:0;">${_iconFor(k)}</span><span>${esc(label)}</span></button>`).join('');
formEl.innerHTML = `
<div class="admin-card" style="margin-top:8px">
<h2 style="font-size:13px">Add Integration</h2>
<h2 style="font-size:13px;display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Add Integration</h2>
<div class="settings-col">
<div class="settings-row"><label class="settings-label">Type</label>
<select id="uf-type-picker" class="settings-input">
<option value="">Select...</option>
<option value="api">API Service</option>
<option value="caldav">CalDAV Calendar</option>
<option value="contacts">Contacts Import</option>
<option value="carddav">Contacts (CardDAV)</option>
<option value="email">Email (IMAP/SMTP)</option>
<option value="mcp">MCP Tool Server</option>
<option value="codex">Codex Agent</option>
</select>
<div style="position:relative;flex:1;min-width:0;">
<button type="button" id="uf-type-trigger" class="settings-select" style="display:flex;align-items:center;gap:10px;cursor:pointer;text-align:left;width:100%;padding-right:24px;position:relative;">
<span class="uf-type-icon" style="display:inline-flex;color:var(--accent, var(--red));"></span>
<span class="uf-type-label" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:0.65;">Select...</span>
<span aria-hidden="true" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);opacity:0.5;font-size:10px;pointer-events:none;">▾</span>
</button>
<div id="uf-type-menu" style="display:none;position:absolute;top:calc(100% + 2px);left:0;right:0;z-index:1000;background:var(--panel);border:1px solid var(--border);border-radius:6px;max-height:340px;overflow-y:auto;box-shadow:0 6px 18px rgba(0,0,0,0.25);">${_rowsHtml}</div>
</div>
</div>
</div>
</div>`;
el('uf-type-picker').addEventListener('change', () => {
const v = el('uf-type-picker').value;
if (v) showForm(v, 'new');
const trigger = el('uf-type-trigger');
const menu = el('uf-type-menu');
const labelEl = trigger.querySelector('.uf-type-label');
const iconEl = trigger.querySelector('.uf-type-icon');
const _closeMenu = () => { menu.style.display = 'none'; };
const _openMenu = () => {
menu.style.display = 'block';
// Drop-up when there's not enough room below the trigger (mobile
// landscape / docked keyboard / long lists near the bottom of screen).
const tRect = trigger.getBoundingClientRect();
const mRect = menu.getBoundingClientRect();
const below = window.innerHeight - tRect.bottom;
const above = tRect.top;
if (mRect.height > below && above > below) {
menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)';
} else {
menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto';
}
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trigger) { _closeMenu(); document.removeEventListener('click', onDoc, true); } };
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
};
trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); });
menu.querySelectorAll('.uf-type-option').forEach(btn => {
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; });
btn.addEventListener('click', (e) => {
e.stopPropagation();
const k = btn.dataset.value;
const lbl = btn.querySelector('span:last-child')?.textContent || '';
if (labelEl) { labelEl.textContent = lbl; labelEl.style.opacity = '1'; }
if (iconEl) iconEl.innerHTML = _iconFor(k);
_closeMenu();
showForm(k, 'new');
});
});
});
}

View File

@@ -327,7 +327,6 @@ const _TASK_ICONS = {
draft_email_replies: '<polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/>',
extract_email_events:'<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/><path d="M7 14h5"/><path d="M7 18h8"/>',
classify_events: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/><path d="M8 15h.01M12 15h.01M16 15h.01"/>',
mark_email_boundaries:'<path d="M4 4h16v16H4z"/><path d="M4 9h16"/><path d="M9 4v16"/>',
learn_sender_signatures:'<path d="M20 6 9 17l-5-5"/><path d="M14 6h6v6"/>',
check_email_urgency: '<path d="M13.73 21a2 2 0 0 1-3.46 0"/><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/>',
// Skills
@@ -355,7 +354,6 @@ const _MODEL_BACKED_ACTIONS = new Set([
'draft_email_replies',
'extract_email_events',
'classify_events',
'mark_email_boundaries',
'learn_sender_signatures',
'check_email_urgency',
'test_skills',
@@ -498,7 +496,6 @@ const _CATEGORY_MAP = {
extract_email_events: 'Calendar',
summarize_emails: 'Email',
draft_email_replies: 'Email',
mark_email_boundaries: 'Email',
learn_sender_signatures: 'Email',
check_email_urgency: 'Email',
daily_brief: 'Assistant',
@@ -609,7 +606,6 @@ const _TASK_CACHE_LABELS = {
summarize_emails: 'email summaries',
draft_email_replies: 'AI reply drafts',
extract_email_events: 'email calendar cache',
mark_email_boundaries: 'email boundaries',
learn_sender_signatures: 'sender signatures',
check_email_urgency: 'email tags',
};
@@ -1739,7 +1735,7 @@ async function _renderActivityView() {
<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">
<h2 style="margin:0;padding:0;line-height:1;">Activity</h2>
<button class="memory-toolbar-btn" id="tasks-activity-refresh" title="Refresh" style="margin-left:auto;">Refresh</button>
<button class="memory-toolbar-btn" id="tasks-activity-refresh" title="Refresh" style="margin-left:auto;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
</div>
<p class="memory-desc">Recent task runs across all scheduled tasks.</p>
<div style="display:flex;align-items:center;gap:6px;margin:6px 0 8px;">

View File

@@ -1008,6 +1008,16 @@ body.bg-pattern-sparkles {
opacity: 1;
animation: whirlpool-burst 0.36s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
/* When a chip is swirling into the trash X, its inline `rotate(720deg)`
drags every child + ::after badge along with it the count/dot pill
spinning looks chaotic. Fade those out fast at the start of the close
so visually only the icon glyph rotates. */
.minimized-dock-chip.chip-trashing > :not(svg),
.minimized-dock-chip.chip-trashing::after,
.minimized-dock-chip.chip-trashing::before {
opacity: 0 !important;
transition: opacity 0.16s ease-out !important;
}
@keyframes whirlpool-spin { to { transform: rotate(360deg); } }
@keyframes whirlpool-burst {
0% { transform: rotate(0deg) scale(1); opacity: 1; }
@@ -10326,7 +10336,7 @@ textarea.memory-add-input {
}
.task-state-badge svg {
position: relative;
top: -1px;
top: 0;
}
.task-status-badge:hover {
filter: brightness(1.08) saturate(1.15);
@@ -13290,6 +13300,13 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn {
padding: 12px;
margin-bottom: 10px;
}
/* When the integrations editor opens, the inner admin-card should match the
listed integration cards (subtle tint, same border) instead of reverting
to the solid-panel admin-card surface used elsewhere. */
#unified-intg-form .admin-card,
#integrations-form .admin-card {
background: color-mix(in srgb, var(--fg) 3%, transparent);
}
.admin-card h2 {
font-size: 14px;
font-weight: 600;
@@ -21544,9 +21561,11 @@ body:not(.welcome-ready) #welcome-screen {
position: relative;
top: -1px;
}
.task-log-force-run svg {
.task-log-force-run svg,
.task-log-stop svg {
display: block;
flex-shrink: 0;
transform: translateY(1px);
}
.task-log-force-run:hover {
opacity: 1;
@@ -21918,13 +21937,14 @@ a.chat-link[href^="#research-"] {
padding-right: 0;
box-sizing: border-box;
justify-content: center;
top: 2px !important;
}
.task-card .task-state-badge .task-state-label {
display: none;
}
.task-card .task-card-run-btn {
margin-right: 1px !important;
top: 0;
top: 2px;
}
}
@@ -27291,6 +27311,20 @@ button .spinner-whirlpool {
max-width: 100%;
}
}
/* Mobile: long recipient lists (To/Cc with many addresses) shouldn't wrap to
N rows and push the body down. Keep them on one row, horizontally scrollable,
no scrollbar chrome. */
@media (max-width: 768px) {
.recipient-chips {
flex-wrap: nowrap !important;
overflow-x: auto !important;
overflow-y: hidden !important;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.recipient-chips::-webkit-scrollbar { display: none; }
.recipient-chip { flex-shrink: 0; }
}
.email-reader-actions {
display: flex; gap: 4px; flex-wrap: nowrap; align-items: center;
flex-shrink: 0;
@@ -30837,6 +30871,20 @@ body.notes-mobile-mode.notes-drag-mode .note-card-pin.active {
margin-top: -2px;
}
/* Reminder bell button */
/* Mobile-only: bell icon in the note editor is accent-coloured so it pops as
the primary "set a reminder" affordance. The Archive button is hidden the
Update () button morphs into an Archive action when the user opens a note
and clicks without making any edits (see notes.js `archive-mode` toggle). */
@media (max-width: 768px) {
.note-form-remind-btn { color: var(--accent, var(--red)) !important; }
.note-form-remind-btn > svg { color: var(--accent, var(--red)); }
.note-form-archive-btn { display: none !important; }
.note-form-save.archive-mode {
color: var(--accent, var(--red)) !important;
border-color: color-mix(in srgb, var(--accent, var(--red)) 50%, transparent) !important;
background: color-mix(in srgb, var(--accent, var(--red)) 10%, transparent) !important;
}
}
.note-form-remind-btn {
flex: 0 0 auto;
background: transparent;
@@ -33174,6 +33222,18 @@ button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm:hover .cal-add-label {
.email-attach-toggle-inline,
.email-undone-toggle-inline,
.email-reminder-toggle-inline { border-radius: 50% !important; opacity: 1 !important; }
/* Mobile: enlarge the icons inside the inline search-bar toggles
(done / attachment / reminders) buttons themselves stay the same,
only the SVG glyph scales up so it's tappable + visible. */
@media (max-width: 768px) {
.email-attach-toggle-inline svg,
.email-undone-toggle-inline svg,
.email-reminder-toggle-inline svg,
.email-filter-refresh-btn svg {
width: 15px !important;
height: 15px !important;
}
}
.email-attach-toggle:not(.email-attach-toggle-inline):hover svg {
animation: email-undone-jiggle 0.45s ease-in-out;
transform-origin: 50% 50%;