Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218import { SvelteMap } from 'svelte/reactivity';import { ApiError, configureSession, getSession, listSessions, type PreferencePatch, type Session } from './api';import type { HistoryEntry, Usage } from './context';import { followSessions, type StreamEvent } from './stream';import { busy } from './live.svelte';import { notifications } from './notifications.svelte';
export type Link = 'connecting' | 'online' | 'offline' | 'unauthorized';
// While the list's stream is up, it carries every change and the list is// read again only when it says to (and once a minute, in case). Without it// the list is polled.const POLL_MS = { streaming: 60_000, polling: 4000 };
// One shared root-list follow stays live for background notifications.// Chat streams and regular list polling still pause while hidden.class Sessions { list = $state.raw<Session[]>([]); link = $state<Link>('connecting'); loaded = $state(false); streaming = $state(false); /** Why the last pin or archive was refused, until the next one. */ problem = $state(''); // Agents a window shows: they are not in the list, which holds only the // sessions a person started, so a window of one keeps its record here. agents = $state.raw(new Map<string, Session>()); /** The newest usage and history entries each open chat has read, shared with undocked model chips. */ readings = new SvelteMap<string, { usage: Usage | null; entries: HistoryEntry[] }>(); byId = $derived(new Map([...this.agents, ...this.list.map((session): [string, Session] => [session.id, session])]));
recordReading(sessionId: string, usage: Usage | null, entries: HistoryEntry[]) { const prev = this.readings.get(sessionId); if (prev && prev.usage === usage && prev.entries === entries) return; this.readings.set(sessionId, { usage, entries }); }
clearReading(sessionId: string) { this.readings.delete(sessionId); }
#timer: ReturnType<typeof setTimeout> | undefined; #inflight: AbortController | undefined; #stopStream: (() => void) | null = null; #soon: ReturnType<typeof setTimeout> | undefined; #savedAt = new Map<string, number>();
start() { document.addEventListener('visibilitychange', () => (document.hidden ? this.#pause() : this.#resume())); notifications.start(); this.#resume(); }
async refresh() { clearTimeout(this.#timer); this.#inflight?.abort(); const controller = (this.#inflight = new AbortController()); try { const next = await listSessions(controller.signal); const previous = this.byId; for (const session of next) notifications.changed(previous.get(session.id), session); this.list = next; notifications.prune(next); this.link = 'online'; this.loaded = true; } catch (error) { if (controller.signal.aborted) return; this.link = error instanceof ApiError && error.status === 401 ? 'unauthorized' : 'offline'; } if (!document.hidden || !this.streaming) { const delay = document.hidden ? 30_000 : this.streaming ? POLL_MS.streaming : POLL_MS.polling; this.#timer = setTimeout(() => this.refresh(), delay); } }
#resume() { this.#stopStream ??= followSessions( (events) => this.#apply(events), (connected) => { this.streaming = connected; if (!connected) this.#later(0); }, true, ); this.refresh(); }
#pause() { clearTimeout(this.#timer); clearTimeout(this.#soon); this.#inflight?.abort(); if (!this.streaming) this.#later(30_000); }
/** Keeps an agent's record for the window about to show it. */ adopt(session: Session) { if (!this.list.some((listed) => listed.id === session.id)) this.agents = new Map(this.agents).set(session.id, session); }
/** Brings the kept agents up to date from a read of their family. */ refreshAgents(family: Session[]) { const changed = family.filter((session) => this.agents.has(session.id)); if (changed.length) this.agents = new Map([...this.agents, ...changed.map((session): [string, Session] => [session.id, session])]); }
/** Whether the session is known, reading an agent's record from the daemon when it is not in the list. */ async ensure(id: string): Promise<boolean> { if (this.byId.has(id)) return true; try { this.adopt(await getSession(id)); return true; } catch (error) { // Only the daemon saying it has no such session makes it gone; a read that failed is no answer. return !(error instanceof ApiError && error.status === 404); } }
/** The pinned sessions, first to last. */ pins = $derived( this.list.filter((session) => session.preferences.pinned).sort((a, b) => (a.preferences.pin_order ?? Infinity) - (b.preferences.pin_order ?? Infinity)), );
/** Pins or archives a session, shown at once and put back if the daemon refuses. */ mark(id: string, change: { pinned?: boolean; archived?: boolean }) { const nextOrder = change.pinned ? Math.max(-1, ...this.pins.map((pin) => pin.preferences.pin_order ?? -1)) + 1 : null; return this.#save([id], id, change, (session) => session.id !== id ? session : { ...session, preferences: { ...session.preferences, ...change, ...(change.pinned === false ? { pin_order: null } : change.pinned === true && session.preferences.pin_order === null ? { pin_order: nextOrder } : {}), }, }, ); }
/** Moves a pinned session before another pinned one, or last for null. */ movePin(id: string, before: string | null) { const order = this.pins.map((session) => session.id).filter((pin) => pin !== id); order.splice(before === null ? order.length : order.indexOf(before), 0, id); return this.#save(order, id, { pin_before: before }, (session) => { const place = order.indexOf(session.id); return place < 0 ? session : { ...session, preferences: { ...session.preferences, pin_order: place } }; }); }
async #save(touched: string[], id: string, preferences: PreferencePatch, show: (session: Session) => Session) { const previous = new Map( this.list.filter((session) => touched.includes(session.id)).map((session) => [session.id, session.preferences]), ); const now = Date.now(); for (const sid of touched) this.#savedAt.set(sid, now); this.problem = ''; this.list = this.list.map(show); try { const saved = await configureSession(id, { preferences }); const settled = Date.now(); for (const sid of touched) this.#savedAt.set(sid, settled); if (saved?.preferences) { this.list = this.list.map((session) => session.id === id ? { ...session, preferences: { ...session.preferences, ...saved.preferences } } : session, ); } } catch (error) { this.list = this.list.map((session) => { const old = previous.get(session.id); return old ? { ...session, preferences: old } : session; }); this.problem = error instanceof Error ? error.message : 'could not save'; } }
/** Reads the list again shortly, once for a burst of changes. */ #later(ms = 150) { clearTimeout(this.#soon); this.#soon = setTimeout(() => this.refresh(), ms); }
#apply(events: StreamEvent[]) { let patched = this.list; for (const event of events) { if (event.type === 'activity') { const { session_id: id, status, activity } = event.data ?? {}; const at = patched.findIndex((session) => session.id === id); if (at < 0) { this.#later(); continue; } const current = patched[at]; const next: Session = { ...current, status: status ?? current.status, activity: activity ?? current.activity }; // `observed_at` is when the session last said anything, a kernel // starting for a window that opened it too; only a turn in hand moves // the session, as the daemon's own `activity_at` counts it. if (busy(current) || busy(next)) next.activity_at = activity?.observed_at ?? current.activity_at; notifications.changed(current, next); patched = patched.map((session, index) => (index === at ? next : session)); } else if (event.type === 'mail') notifications.mail(event.data, patched); else if (event.type === 'invalidate') { const ids: string[] = Array.isArray(event.data?.session_ids) ? event.data.session_ids : []; const now = Date.now(); if (!event.data?.scope_dirty && ids.length > 0 && ids.every((sid) => now - (this.#savedAt.get(sid) ?? 0) < 2000)) { continue; } this.#later(); } else if (event.type === 'reset' || event.type === 'overflow') this.#later(); } if (patched !== this.list) this.list = patched; }}
export const sessions = new Sessions();