[object Object]

← back to Desktop Dotbar

dotbar: mark orphaned sessions (live claude, no iTerm2 tab) so open-terminal only shows for focusable tabs

d56df0fca4c516cf8ccb751baa5f04ad55a4aff8 · 2026-09-23 12:35:47 -0700 · Steve Abrams

Cross-references iTerm2's attached-session ttys (split-pane-safe index walk) against
allcolordots' process-based live list. Rows whose claude survives under iTermServer with
no tab now render 'orphaned - no tab' instead of a dead-end 'open terminal' button.
Fixes 'open terminal never works' (79% of rows were orphans).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit d56df0fca4c516cf8ccb751baa5f04ad55a4aff8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 23 12:35:47 2026 -0700

    dotbar: mark orphaned sessions (live claude, no iTerm2 tab) so open-terminal only shows for focusable tabs
    
    Cross-references iTerm2's attached-session ttys (split-pane-safe index walk) against
    allcolordots' process-based live list. Rows whose claude survives under iTermServer with
    no tab now render 'orphaned - no tab' instead of a dead-end 'open terminal' button.
    Fixes 'open terminal never works' (79% of rows were orphans).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/index.html | 13 +++++++++++--
 server.js         | 50 ++++++++++++++++++++++++++++++++++++++++++++++----
 2 files changed, 57 insertions(+), 6 deletions(-)

diff --git a/public/index.html b/public/index.html
index 6ff543c..54c69d1 100644
--- a/public/index.html
+++ b/public/index.html
@@ -107,6 +107,9 @@
   .open { -webkit-app-region:no-drag; cursor:pointer; font-size:11px; padding:5px 9px; border-radius:7px;
     border:1px solid var(--line); background:var(--bg2); color:var(--fg); white-space:nowrap; }
   .open:hover { background:#2a2e37; }
+  /* Orphaned row: live claude process whose iTerm2 tab was closed — nothing to focus, so no open button. */
+  .orphan { -webkit-app-region:no-drag; font-size:11px; padding:5px 9px; border-radius:7px;
+    border:1px dashed var(--line); background:transparent; color:var(--dim); white-space:nowrap; opacity:.7; }
   .empty { padding:14px; color:var(--dim); }
 </style>
 </head>
@@ -261,7 +264,11 @@ function renderPanel(){
         + `<span class="tk">${s.ticket || s.id || '—'}</span>`
         + `<span class="doing" title="${(s.doing||'').replace(/"/g,'&quot;')}">${s.doing || ('('+s.kind+')')}</span>`
         + `<span class="tty">${s.tty || s.kind}</span>`
-        + (s.tty ? `<button class="open" onclick='reveal(${JSON.stringify(s.tty)})'>open terminal ↗</button>` : '')
+        + (s.tty
+            ? (s.attached === false
+                ? `<span class="orphan" title="Live process, but its iTerm2 tab was closed — nothing to focus.">orphaned · no tab</span>`
+                : `<button class="open" onclick='reveal(${JSON.stringify(s.tty)})'>open terminal ↗</button>`)
+            : '')
         + `</div>`;
     }
     panel.innerHTML = html; return;
@@ -276,7 +283,9 @@ function renderPanel(){
       + `<span class="tk">${s.ticket || '—'}</span>`
       + `<span class="doing" title="${(s.doing||'').replace(/"/g,'&quot;')}">${s.doing || '(no label)'}</span>`
       + `<span class="tty">${s.tty}</span>`
-      + `<button class="open" onclick='reveal(${JSON.stringify(s.tty)})'>open terminal ↗</button>`
+      + (s.attached === false
+          ? `<span class="orphan" title="Live process, but its iTerm2 tab was closed — nothing to focus.">orphaned · no tab</span>`
+          : `<button class="open" onclick='reveal(${JSON.stringify(s.tty)})'>open terminal ↗</button>`)
       + `</div>`;
   }
   panel.innerHTML = html;
diff --git a/server.js b/server.js
index a7f15af..57ce537 100755
--- a/server.js
+++ b/server.js
@@ -45,11 +45,49 @@ function ticketOf(label) {
   return m ? m[1].toUpperCase() : '';
 }
 
+// Set of tty basenames (e.g. "ttys014") that iTerm2 actually has an ATTACHED session for.
+// A claude process can be "live" (allcolordots sees it via a ps scan) yet ORPHANED — its
+// iTerm2 tab/window was closed, so the process survives under iTermServer with no session to
+// focus. reveal() (iTerm2-only) can never open an orphan, so we cross-reference here and mark
+// each row `attached`; the UI then hides the dead-end "open terminal" button for orphans.
+// Uses the split-pane-SAFE index walk: the `repeat with s in sessions of t` element-reference
+// form throws -1719 on split-pane tabs and silently drops those sessions (undercount), so we
+// index by number with a per-session try, exactly like reveal().
+async function iterm2AttachedTtys() {
+  const script = `set out to ""
+tell application "iTerm2"
+  repeat with wi from 1 to (count of windows)
+    set w to window wi
+    repeat with ti from 1 to (count of tabs of w)
+      set t to tab ti of w
+      repeat with si from 1 to (count of sessions of t)
+        try
+          set out to out & (tty of (session si of t)) & " "
+        end try
+      end repeat
+    end repeat
+  end repeat
+end tell
+return out`;
+  const { err, stdout } = await run('osascript', ['-e', script], 12000);
+  const set = new Set();
+  if (err) return null;                                  // iTerm2 unreachable -> don't mark anything orphaned (fail-open)
+  for (const dev of String(stdout || '').trim().split(/\s+/)) {
+    const b = dev.split('/').pop();
+    if (b) set.add(b);
+  }
+  return set;
+}
+const ttyBase = (t) => String(t || '').split('/').pop();
+
 async function getDots() {
   // allcolordots scans every terminal; on a busy/loaded box (many sessions) it can take 60-90s.
   // The default 8s cap timed out every refresh -> empty output -> the bar showed "0 live". Give a
   // generous cap; keep-last-good (in refresh()) covers the interim so the bar never flaps to 0.
   const { stdout } = await run('bash', [ALLCOLORDOTS, '--json'], 120000);
+  // Which live rows are actually attached to an iTerm2 tab (vs orphaned). null = iTerm2 unreachable.
+  const attachedSet = await iterm2AttachedTtys();
+  const isAttached = (tty) => attachedSet === null ? true : attachedSet.has(ttyBase(tty));
   let rows = [];
   try { rows = JSON.parse(stdout || '[]'); } catch { rows = []; }
   // Durable PARKED group straight from the registry (both tab + ticket kinds),
@@ -74,6 +112,7 @@ async function getDots() {
     groups[color].push({
       tty: r.tty,
       pid: r.pid,
+      attached: isAttached(r.tty),   // false => orphaned (live claude, no iTerm2 tab): UI hides the dead-end open button
       ticket: ticketOf(r.label),
       doing: cleanLabel(r.label),
     });
@@ -81,10 +120,13 @@ async function getDots() {
   const out = ORDER
     .map(color => ({ color, ...META[color], count: groups[color].length, sessions: groups[color] }))
     .filter(g => g.count > 0 || g.color === 'green'); // always show green; hide empty others
-  const parkedItems = parked.map(e => ({
-    kind: e.kind, id: e.id, ticket: e.kind === 'ticket' ? e.id : ticketOf(e.label),
-    tty: e.tty || (e.kind === 'tab' ? e.id : ''), doing: cleanLabel(e.label), parked_at: e.parked_at,
-  }));
+  const parkedItems = parked.map(e => {
+    const tty = e.tty || (e.kind === 'tab' ? e.id : '');
+    return {
+      kind: e.kind, id: e.id, ticket: e.kind === 'ticket' ? e.id : ticketOf(e.label),
+      tty, attached: tty ? isAttached(tty) : true, doing: cleanLabel(e.label), parked_at: e.parked_at,
+    };
+  });
   return { updated: Date.now(), total: rows.length, groups: out,
     parked: { count: parkedItems.length, items: parkedItems } };
 }

← cf77e2f Jev classifies dotbar dot colors ($0 builtin default; paid T  ·  back to Desktop Dotbar  ·  auto-data-snapshot: 2026-09-23T12:42:16 (1 data files) — sta de2776c →