From 927920c162c182fb3369846bf7f31fb523316167 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 10 Oct 2026 19:57:52 -0400 Subject: [PATCH] fix: old links never re-create ended sessions Inbox entries, the switcher, palette switches, the attention chip and Matrix links (?arg=NAME&existing=1) attach with --existing; an ended session gets an explanation and an explicit "Start again". Inbox entries for ended sessions can't resurrect them; entries expire after 7 days. Closes #38 Co-Authored-By: Claude Opus 5.5 --- debian/changelog | 11 ++++++++ libexec/devdesk-notify-matrix | 4 ++- tests/attach.bats | 2 +- web/src/inbox.js | 15 +++++++--- web/src/main.js | 52 ++++++++++++++++++++++++++++------- 5 files changed, 68 insertions(+), 16 deletions(-) diff --git a/debian/changelog b/debian/changelog index c765768..af6ed07 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,14 @@ +devdesk (0.7.3) bookworm; urgency=medium + + * Fix (#38): links that mean "go to that session" no longer re-create one + that was ended. The 🔔 inbox, the session switcher, palette switches, the + "needs you" chip and Matrix links (now ?arg=NAME&existing=1) open sessions + existing-only; an ended one shows "NAME has ended" with an explicit + "Start NAME again". Inbox entries for ended sessions are marked as such, + and entries older than 7 days are dropped. + + -- Jared Vititoe Sat, 10 Oct 2026 19:57:52 -0400 + devdesk (0.7.2) bookworm; urgency=medium * Fix: Go to file failed in big non-git folders such as /root/code: the diff --git a/libexec/devdesk-notify-matrix b/libexec/devdesk-notify-matrix index 10f07a9..6c2cdee 100755 --- a/libexec/devdesk-notify-matrix +++ b/libexec/devdesk-notify-matrix @@ -28,7 +28,9 @@ attached=$(dd_tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev message=$(jq -rM '.message // "Claude is waiting for your input"' "$state_file" 2>/dev/null) url="" -[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session" +# existing=1: if the session was ended by the time you tap, say so instead of +# starting a new Claude there (devdesk#38). +[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session&existing=1" body=$(jq -cMn --arg s "$session" --arg m "$message" --arg u "$url" --arg h "$(hostname)" \ '{session: $s, message: $m, url: (if $u == "" then null else $u end), host: $h, text: ("Claude needs you in " + $s + ": " + $m + (if $u == "" then "" else " — " + $u end))}') diff --git a/tests/attach.bats b/tests/attach.bats index 1a560a7..ae17acb 100644 --- a/tests/attach.bats +++ b/tests/attach.bats @@ -310,7 +310,7 @@ posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; } sleep 2.5 [ "$(posts)" -eq 1 ] run jq -r '[.session, .url, (.text | test("Claude needs you in alpha"))] | @tsv' "$TMP/posts" - [ "$output" = "$(printf 'alpha\thttps://dev.example.org/?arg=alpha\ttrue')" ] + [ "$output" = "$(printf 'alpha\thttps://dev.example.org/?arg=alpha&existing=1\ttrue')" ] # answered within the delay: nothing is posted echo '{"hook_event_name":"PostToolUse"}' | hook diff --git a/web/src/inbox.js b/web/src/inbox.js index 8e565ee..e0c13ef 100644 --- a/web/src/inbox.js +++ b/web/src/inbox.js @@ -8,11 +8,12 @@ import { ago } from './sessions.js'; const KEY = 'devdesk.inbox'; const MAX = 30; +const MAX_AGE = 7 * 86400 * 1000; function load() { try { const list = JSON.parse(localStorage.getItem(KEY) || '[]'); - return Array.isArray(list) ? list : []; + return Array.isArray(list) ? list.filter((e) => Date.now() - e.at < MAX_AGE) : []; } catch { return []; } @@ -45,6 +46,7 @@ export class Inbox { this.events = load(); this.waiting = []; this.alerts = []; + this.known = null; // session names that exist, once the list has loaded this.list = panel.querySelector('.lt-notif-panel-list'); panel.querySelector('.lt-notif-panel-clear').addEventListener('click', () => this.markAllRead()); window.addEventListener('storage', (event) => { @@ -78,9 +80,10 @@ export class Inbox { } // Live state: other sessions waiting on you, firing alerts. - setLive({ waiting, alerts }) { + setLive({ waiting, alerts, sessions }) { if (waiting) this.waiting = waiting; if (alerts) this.alerts = alerts; + if (sessions) this.known = new Set(sessions); this.render(); } @@ -91,7 +94,7 @@ export class Inbox { } unread() { - return this.events.filter((e) => !e.read && e.kind !== 'waiting').length; + return this.events.filter((e) => !e.read && e.kind !== 'waiting' && !(this.known && e.session && !e.url && !this.known.has(e.session))).length; } render() { @@ -155,12 +158,16 @@ export class Inbox { nodes.push(el('div', { class: 'dd-notif-section' }, 'Recent')); for (const e of recent.slice(0, 12)) { const kind = KINDS[e.kind] ?? { tone: 'cyan' }; + // An entry about a session that has since ended must not bring it back (devdesk#38). + const ended = !e.url && e.session && this.known && !this.known.has(e.session); nodes.push(item({ - dot: e.read ? 'read' : kind.tone, unread: !e.read, title: e.text, meta: when(e.at), + dot: e.read || ended ? 'read' : kind.tone, unread: !e.read && !ended, title: e.text, + meta: `${when(e.at)}${ended ? ' · session ended' : ''}`, onclick: close(() => { this.events = this.events.map((x) => (x.id === e.id ? { ...x, read: true } : x)); save(this.events); if (e.url) window.open(e.url, '_blank', 'noopener'); + else if (ended) this.hooks.ended?.(e.session); else if (e.session) this.hooks.openSession(e.session); }), })); diff --git a/web/src/main.js b/web/src/main.js index ee6a426..29985d0 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -58,6 +58,8 @@ const ui = { }; const urlSession = sessionFromSearch(window.location.search); +// Notification links (Matrix) add existing=1: open, never re-create (devdesk#38). +const urlExisting = new URLSearchParams(window.location.search).get('existing') === '1'; const state = { session: urlSession, // null = picker conn: null, @@ -169,7 +171,11 @@ function recordTransitions(sessions) { } function renderPanelsBadge() { - inbox?.setLive({ waiting: waitingOthers, alerts: lastAlerts?.alerts ?? [] }); + inbox?.setLive({ + waiting: waitingOthers, + alerts: lastAlerts?.alerts ?? [], + sessions: sessionsPanel?.data ? sessionsPanel.data.sessions.map((x) => x.name) : null, + }); } function onSessionData({ fetched = false } = {}) { @@ -316,14 +322,19 @@ function updateUrl(session) { const url = new URL(window.location.href); if (session) url.searchParams.set('arg', session); else url.searchParams.delete('arg'); + url.searchParams.delete('existing'); if (url.href !== window.location.href) window.history.replaceState(null, '', url); } // ---------------------------------------------------------------- connection -// reconnect: automatic re-attach after a drop. It must not re-create a session -// that was ended or renamed meanwhile, so it asks for --existing (devdesk#4). -function connect(session, { reconnect = false } = {}) { +// existing: go to a session that should already exist (reconnects, the +// switcher, inbox entries, notification links). It must not re-create one that +// was ended or renamed meanwhile, so it asks for --existing (devdesk#4, #38). +// Creating is explicit: a typed name, the New dialog, the picker, recents. +function connect(session, { existing = false, reconnect = false } = {}) { + const existingOnly = Boolean(session) && (existing || reconnect); + state.existingOnly = existingOnly; clearTimeout(state.retryTimer); state.conn?.dispose(); state.session = session; @@ -339,7 +350,7 @@ function connect(session, { reconnect = false } = {}) { term.reset(); const conn = new TtydConnection({ - arg: session && reconnect ? ['--existing', session] : session, + arg: existingOnly ? ['--existing', session] : session, size: () => ({ cols: term.cols, rows: term.rows }), onOpen: () => { state.openedAt = Date.now(); @@ -373,6 +384,25 @@ function onClosed(code) { // few seconds of opening means the session could not start (the reason is // printed in the terminal), so don't retry forever. const failedFast = code !== 1000 && Date.now() - state.openedAt < 4000; + if (failedFast && state.existingOnly) { + // Most likely ended or renamed since the link was made: say so, and only + // start it again when asked. + const name = state.session; + sessionsPanel?.reload().then(() => { + const known = sessionsPanel.data?.sessions?.some((x) => x.name === name); + if (known) { + scheduleReconnect(); + return; + } + setWsState('disconnected', 'ended'); + showOverlay({ + title: `${name} has ended`, + text: `The session "${name}" no longer exists: it was ended or renamed. Its conversation is still in Claude's history.`, + actions: [pickerAction, { label: `Start ${name} again`, run: () => connect(name) }], + }); + }); + return; + } if (failedFast) { showOverlay({ title: 'Session did not start', @@ -515,7 +545,7 @@ function renderSwitcher() { return el('button', { type: 'button', role: 'menuitem', class: `dd-menu-item dd-switch-item is-${status.key}${s.name === state.session ? ' is-current' : ''}`, - title: s.dir || '', onclick: choose(() => connect(s.name)), + title: s.dir || '', onclick: choose(() => connect(s.name, { existing: true })), }, el('span', { class: `lt-dot ${status.dot}` }), el('span', { class: 'dd-switch-name' }, s.name), @@ -591,7 +621,7 @@ function buildCommands() { const status = sessionStatus(s); add({ id: `switch:${s.name}`, group: 'Sessions', label: `Switch to ${s.name}`, hint: status.label, keywords: s.dir, - icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name), + icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name, { existing: true }), }); } add({ id: 'new', group: 'Sessions', icon: '+', label: 'New session…', keywords: 'create open project', run: () => sessionsPanel.newDialog() }); @@ -866,7 +896,8 @@ function wireChrome() { badge: $('inbox-badge'), panel: $('inbox-panel'), hooks: { - openSession: (name) => connect(name), + openSession: (name) => connect(name, { existing: true }), + ended: (name) => toast.info(`${name} has ended; its conversation is still in Claude's history`), showAlerts: () => { setSidebar(true); $('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' }); @@ -957,7 +988,7 @@ function wireChrome() { if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshRepo(); }, 60 * 1000); ui.attention.addEventListener('click', () => { - if (ui.attention.dataset.target) connect(ui.attention.dataset.target); + if (ui.attention.dataset.target) connect(ui.attention.dataset.target, { existing: true }); }); } @@ -1057,7 +1088,8 @@ async function boot() { wireUploads(); refreshAlerts(); await sessionsPanel.refresh(); - connect(urlSession ?? defaultSession(sessionsPanel.data)); + if (urlSession) connect(urlSession, { existing: urlExisting }); + else connect(defaultSession(sessionsPanel.data), { existing: true }); } boot();