Преглед изворни кода

feat(skills): Add summon — push Desktop sessions across accounts

Push Claude Desktop Code-tab sessions across Claude accounts so they
appear in the next account you switch to. Best run before switching:
copy (default) or move (--move) the session metadata into the
destination account, then Logout/Login as the natural switch.

Features:
- Hierarchical Account -> Project -> Session picker with global numbering
- --peek <id> previews transcript before deciding
- --list-accounts inventory of all logged-in accounts
- Recency aliases (--1d/--3d/--7d/--30d/--all) on top of --days N
- 8-hint rotating tip system (4 conditional + 4 always-eligible)
- Kitchen-sink fs.watch nudge (best-effort; Logout/Login canonical)
- Output follows docs/DESIGN.md panel grammar with ASCII fallback

Validated cross-account file moves via Desktop bundle inspection
(no live fs.watch on session dir; sessions loaded at login).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
0xDarkMatter пре 1 месец
родитељ
комит
ff393ef5f6
6 измењених фајлова са 1307 додато и 1 уклоњено
  1. 1 1
      .claude-plugin/plugin.json
  2. 3 0
      README.md
  3. 152 0
      skills/summon/SKILL.md
  4. 4 0
      skills/summon/bin/summon
  5. 4 0
      skills/summon/bin/summon.cmd
  6. 1143 0
      skills/summon/scripts/summon.py

+ 1 - 1
.claude-plugin/plugin.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "claude-mods",
   "name": "claude-mods",
-  "version": "2.4.10",
+  "version": "2.4.11",
   "description": "Custom commands, skills, and agents for Claude Code - session continuity, 23 expert agents, 71 skills, 3 commands, 6 rules, 4 hooks, 13 output styles, modern CLI tools",
   "description": "Custom commands, skills, and agents for Claude Code - session continuity, 23 expert agents, 71 skills, 3 commands, 6 rules, 4 hooks, 13 output styles, modern CLI tools",
   "author": "0xDarkMatter",
   "author": "0xDarkMatter",
   "repository": "https://github.com/0xDarkMatter/claude-mods",
   "repository": "https://github.com/0xDarkMatter/claude-mods",

+ 3 - 0
README.md

@@ -22,6 +22,9 @@ From Python async patterns to Rust ownership models, from AWS Fargate deployment
 
 
 ## Recent Updates
 ## Recent Updates
 
 
+**v2.4.11** (May 2026)
+- ✨ **`summon` skill** - Push Claude Desktop Code-tab sessions across accounts so they appear in the next account you switch to. Best run *before* switching — while still on your current near-limit account, push mid-flight sessions to the destination, then Logout/Login as the natural switch. Default is copy (sessions visible from both accounts); `--move` for lean cleanup. Hierarchical Account → Project → Session picker with global numbering, `--peek <id>` for transcript preview, `--list-accounts` inventory, recency aliases (`--1d/--3d/--7d/--all`), 8-hint rotating tip system. Output follows `docs/DESIGN.md` (Terminal Panel Design System).
+
 **v2.4.10** (April 2026)
 **v2.4.10** (April 2026)
 - 📌 **`github-ops` Recent Updates rule sharpened** - `references/readme-recent-updates.md` gains an explicit "Recent Updates is for *features*, not bugs" subsection with three inclusion criteria, four exclusion criteria, and a self-check ("are you writing this because *you* remembered the fix or because *the user* is waiting for it?"). Replaces a soft single-bullet rule that allowed pre-existing bug fixes to slip into feature-release entries silently.
 - 📌 **`github-ops` Recent Updates rule sharpened** - `references/readme-recent-updates.md` gains an explicit "Recent Updates is for *features*, not bugs" subsection with three inclusion criteria, four exclusion criteria, and a self-check ("are you writing this because *you* remembered the fix or because *the user* is waiting for it?"). Replaces a soft single-bullet rule that allowed pre-existing bug fixes to slip into feature-release entries silently.
 
 

+ 152 - 0
skills/summon/SKILL.md

@@ -0,0 +1,152 @@
+---
+name: summon
+description: "Push Claude Desktop Code-tab sessions across accounts so they appear in the next account you switch to — by copying (default) or moving (--move) the session metadata file. Best run BEFORE switching accounts: while still on your current near-limit account, push mid-flight sessions to the destination account, then Logout/Login as the natural switch. Triggers on: summon, summon sessions, push sessions, pull sessions, before switching accounts, account approaching usage limit, account ran out of usage, prepare next account, mid-flight desktop sessions, claude desktop multi-account workflow, transfer claude desktop sessions across accounts, peek session, see desktop sessions across accounts. Default copies the metadata wrapper so sessions are visible from both accounts; --move for lean cleanup. Transcript JSONLs are account-agnostic and stay where they are — both wrappers point at the same conversation. No API calls, no summarisation, full transcripts intact. Desktop's sidebar caches at login: a Logout/Login is required for new sessions to appear, which is why running summon BEFORE the natural account switch is the cleanest workflow."
+license: MIT
+allowed-tools: "Read Write Bash"
+metadata:
+  author: claude-mods
+---
+
+# Summon
+
+Copy (or move) Claude Desktop Code-tab sessions across accounts so they're visible from the account you switch to next. Full transcripts intact; no API calls; no summarisation.
+
+## When to run it
+
+**Before you switch accounts**, not after. The natural workflow:
+
+1. Notice you're approaching usage limit on the account you're currently using
+2. Run `summon --to <next-account>` — sessions get copied (default) into the next account's dir
+3. Logout from current account in Desktop → Login to the new account
+4. **All your mid-flight sessions appear in the new account's sidebar.** The Logout/Login is the natural switch you were going to do anyway.
+
+Running summon *after* hitting the usage limit also works — the file moves are pure local ops, no API needed — but you'll still need to Logout/Login on the destination to see the sessions, since Desktop's session list is cached at login. Doing it proactively just means the Logout/Login is no longer "extra friction," it's the same step you'd be doing anyway.
+
+## Mental model
+
+Each Desktop session has two halves:
+
+| Half | Location | Account-bound? |
+|------|----------|----------------|
+| Metadata JSON | `%APPDATA%/Claude/claude-code-sessions/<account>/<workspace>/local_<uuid>.json` | **Yes** — lives under `<account>` |
+| Transcript JSONL | `~/.claude/projects/<encoded-cwd>/<cli-uuid>.jsonl` | **No** — global, shared |
+
+Summon copies (or with `--move`, relocates) the metadata wrapper into the destination account's dir. The transcript stays put — both wrappers point at the same conversation. After Logout/Login on the destination, the new sidebar entries appear.
+
+## Run
+
+```bash
+# Wrapper (after install — see below)
+summon [flags]
+
+# Or direct
+python ~/.claude/skills/summon/scripts/summon.py [flags]
+```
+
+Default behaviour: list candidate sessions across **all non-destination accounts**, grouped Account → Project → Session, then prompt to copy them into the destination account. **Copy semantics by default** — sessions remain visible in the source account too. Last 3 days; remote-VM sessions auto-skipped.
+
+Two natural framings of the same operation:
+
+- **Push** (proactive): you're approaching usage limit on your current account. Run `summon --to <next-account>` while still on the current one. Pick which sessions to push. Then Logout/Login is the account switch you were going to do anyway.
+- **Pull** (rescue): you've already switched accounts and want to bring earlier sessions over. Run `summon` (no `--to`); destination defaults to your now-current account.
+
+Mechanically identical — the file moves are the same regardless of which framing you have in mind. Push is the recommended workflow because the Logout/Login becomes invisible.
+
+### Flags
+
+| Flag | Default | Effect |
+|------|---------|--------|
+| `--to <account>` | most-recently-active account | Destination — where the sessions land. Specify when **pushing** to a different account; omit when **pulling** into your current account. UUID prefix or email substring |
+| `--from <account>` | all non-destination accounts | Restrict source to one account |
+| `--days N` | 3 | Time window |
+| `--all` | | Disable time filter |
+| `--cwd <pattern>` | | Substring match against session cwd |
+| `--title <pattern>` | | Substring match against session title |
+| `--pick` | | Interactive multi-select by number |
+| `--move` | | Move instead of copy — delete source after copying (lean cleanup) |
+| `--dry-run` | | Preview without touching files |
+| `--list-accounts` | | Show all accounts and exit |
+| `--peek <id>` | | Preview a session's last messages and exit (id prefix or full) |
+| `--flat` | | Flat list instead of grouped hierarchy |
+| `--yes` | | Skip confirmation prompt |
+
+## Auto-detect rules
+
+- **Destination**: account with the most recent filesystem activity (mtime of any session JSON). This reliably tracks the active Desktop account.
+- **Source**: by default, all accounts except destination. Use `--from <account>` to restrict to one.
+- **Workspace dir under destination**: most-recently-active existing workspace. New UUID is created if the destination has no workspaces yet.
+
+## Display
+
+Output follows the [Terminal Panel Design System](../../docs/DESIGN.md) (panel header, body with `│` rail, footer, ASCII fallback when stdout isn't UTF-8). The candidate hierarchy is **Account → Project → Session**, with sessions globally numbered for picker selection (`3, 5, 7`).
+
+```
+╭── 🪄 summon ──────────────────────────────────────────────── → mknv74 ───●
+│
+├── 4 sessions · from 1 account · last 3d
+│
+├── mack@evolution7.com.au (4)
+│   ├── X:\Forge\Axiom (2)
+│   │   ├──  1. train-fasttext                    30t            16h
+│   │   └──  2. make-doom-for-mips                64t            16h
+│   └── X:\Forma\00_workspaces\evolution7 (2)
+│       ├──  3. timekeeper                        35t            16h
+│       └──  4. agency-os                         17t            16h
+│
+│   💡  best run BEFORE switching accounts: copy sessions to the next
+│       account first, then Logout/Login (the switch you were doing anyway)
+│
+╰── # select · a all · blank cancel ───────────────────────────────────●
+```
+
+Header shows `→ destination`. Summary line shows count, source breadth, and active filter window. Body shows Account → Project → Session hierarchy with global numbering for picker selection (`3,5,7`). A rotating hint tile sits above the footer; the footer shows the active hotkeys.
+
+## Edge cases handled
+
+| Case | Behaviour |
+|------|-----------|
+| Session cwd is `/sessions/<vm>/mnt` (remote) | Skipped — no local transcript to bridge |
+| Transcript JSONL missing on disk | Skipped with warning (orphan metadata) |
+| Same `sessionId` already in destination | Skipped (idempotent) |
+| Destination has no workspace dirs | New workspace UUID created |
+| Stdout is not UTF-8 (Windows cp1252) | ASCII fallback for all panel glyphs |
+| Stdout is not a TTY or `NO_COLOR` set | Plain text, no ANSI escapes |
+
+## Sidebar refresh
+
+Desktop loads sessions on login and doesn't watch the filesystem afterwards (verified via bundle inspection — no `chokidar`, no relevant `fs.watch` on the session dir, only direct `fs.readdir` calls). Summon throws a best-effort nudge at fs.watch (sentinel pings, mtime touches, rename ping-pong) but **don't rely on it** — assume Logout/Login is required to see new sessions.
+
+This is why summon is best run **before switching accounts**: the Logout/Login is what you'd do anyway. Running summon as a "rescue" after the fact still works mechanically, but the Logout/Login still has to happen.
+
+If sessions still don't appear:
+
+1. Try View → Reload (rarely helps; Ctrl+R only re-renders)
+2. **Logout → Login** triggers a full filesystem rescan and always works
+
+## Wrapper install
+
+Symlink (or copy) the wrapper into a directory on `PATH`:
+
+```bash
+# Linux/macOS/Git Bash
+ln -s ~/.claude/skills/summon/bin/summon ~/.local/bin/summon
+
+# Windows (PowerShell)
+copy "$env:USERPROFILE\.claude\skills\summon\bin\summon.cmd" "$env:USERPROFILE\bin\summon.cmd"
+```
+
+Then `summon --pick` works directly from any shell.
+
+## Architecture reference
+
+Full file system layout, session schemas, account binding, and the validated cross-account transfer procedure live in `docs/references/claude-desktop-internals.md` (claude-mods). That document is canonical; this skill is the operating manual.
+
+## Anti-patterns
+
+- **Waiting until you've already hit the limit** — the file moves still work, but you've burned the chance to wrap up your current message before switching. Run summon proactively while you still have usage on the source.
+- **Expecting sessions to appear in the sidebar without Logout/Login** — Desktop's session list is loaded on login; the kitchen-sink fs.watch nudge is best-effort and shouldn't be relied on. The Logout/Login becomes painless if you've timed summon as a *push* before switching.
+- **Running while Desktop is mid-write to a session JSON** — quit Desktop first if you've literally just closed the session you want to push.
+- **Trying to summon remote sessions** — they have no local transcript and can't be transferred.
+- **Hardcoding account UUIDs** — use `--list-accounts` first, then email substring (more readable, less brittle).
+- **Treating this as a transfer for archived sessions** — it's for mid-flight work; archived sessions belong in the source account's archive view.
+- **Using `--move` for sessions you might want to access from both accounts** — copy is default precisely because multi-account workflows are the common case.

+ 4 - 0
skills/summon/bin/summon

@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+# summon — pull Claude Desktop Code-tab sessions across accounts.
+# See: ~/.claude/skills/summon/SKILL.md
+exec python "$HOME/.claude/skills/summon/scripts/summon.py" "$@"

+ 4 - 0
skills/summon/bin/summon.cmd

@@ -0,0 +1,4 @@
+@echo off
+REM summon - pull Claude Desktop Code-tab sessions across accounts.
+REM See: %USERPROFILE%\.claude\skills\summon\SKILL.md
+python "%USERPROFILE%\.claude\skills\summon\scripts\summon.py" %*

+ 1143 - 0
skills/summon/scripts/summon.py

@@ -0,0 +1,1143 @@
+#!/usr/bin/env python3
+"""summon — pull Claude Desktop Code-tab sessions from another account into the active one.
+
+See SKILL.md for full documentation.
+
+Defaults:
+  - move (not copy)
+  - last 14 days only
+  - skip remote-VM sessions (cwd starts with /sessions/)
+  - prompt for confirmation
+  - hierarchy display: Account -> Project -> Session
+
+Auto-detects destination as the most-recently-active account.
+Source defaults to "all other accounts" — narrow with --from.
+
+Output rendering follows docs/DESIGN.md (Terminal Panel Design System).
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import shutil
+import sys
+import time
+import uuid as uuidlib
+from collections import OrderedDict
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Iterable
+
+
+# ============================================================
+#  DESIGN: terminal panel rendering (per docs/DESIGN.md)
+# ============================================================
+
+def _stdout_supports_unicode() -> bool:
+    enc = (getattr(sys.stdout, "encoding", "") or "").lower()
+    return "utf" in enc or "cp65001" in enc
+
+
+class Term:
+    WIDTH = 80
+    USE_ASCII = (
+        os.environ.get("TERM_ASCII") == "1"
+        or os.environ.get("TERM") == "dumb"
+        or not _stdout_supports_unicode()
+    )
+    USE_COLOR = (
+        sys.stdout.isatty()
+        and os.environ.get("NO_COLOR") is None
+        and os.environ.get("TERM") != "dumb"
+        and os.environ.get("FORCE_COLOR") != "0"
+    )
+
+    @classmethod
+    def g(cls, uni: str, asc: str) -> str:
+        return asc if cls.USE_ASCII else uni
+
+    @classmethod
+    def color(cls, token: str, text: str) -> str:
+        if not cls.USE_COLOR:
+            return text
+        codes = {
+            "accent": "36",   # cyan
+            "ok": "32",       # green
+            "warn": "33",     # yellow
+            "alarm": "31",    # red
+            "tag": "35",      # magenta
+            "meta": "2",      # dim
+            "dim": "2",
+            "default": "",
+        }
+        c = codes.get(token, "")
+        if not c:
+            return text
+        return f"\033[{c}m{text}\033[0m"
+
+
+_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
+
+
+def vlen(s: str) -> int:
+    """Visible length, excluding ANSI escape codes."""
+    return len(_ANSI_RE.sub("", s))
+
+
+def trunc(s: str, width: int) -> str:
+    """Truncate with ellipsis if too long."""
+    if vlen(s) <= width:
+        return s
+    ell = Term.g("…", "...")
+    return s[: width - vlen(ell)] + ell
+
+
+# Brand emoji for summon (not in DESIGN.md registry yet — registering here)
+BRAND_EMOJI = "🪄"
+BRAND_ASCII = "[S]"
+
+
+def panel_open(name: str, indicator: str = "") -> str:
+    """╭── 🪄 summon ─────────────────  indicator ───●"""
+    em = Term.g(BRAND_EMOJI, BRAND_ASCII)
+    tl = Term.g("╭", "+")
+    h = Term.g("─", "-")
+    term = Term.g("●", "*")
+    left = f"{tl}{h}{h} {em} {Term.color('accent', name)} "
+    if indicator:
+        right = f" {Term.color('meta', indicator)} {h}{h}{h}{term}"
+    else:
+        right = f" {h}{h}{h}{term}"
+    fill_count = max(2, Term.WIDTH - vlen(left) - vlen(right))
+    return left + (h * fill_count) + right
+
+
+def panel_close(hotkeys: list[tuple[str, str]] | None = None,
+                healths: list[tuple[str, str]] | None = None) -> str:
+    """╰── y confirm · n cancel ───── • 5 ready ───●"""
+    bl = Term.g("╰", "+")
+    h = Term.g("─", "-")
+    term = Term.g("●", "*")
+    bullet = Term.g("•", "(+)")
+    hotkeys = hotkeys or []
+    healths = healths or []
+
+    sep = Term.g(" · ", " | ")
+    hot_str = sep.join(f"{Term.color('accent', k)} {v}" for k, v in hotkeys)
+    health_str = "  ".join(f"{Term.color(c, bullet)} {v}" for c, v in healths)
+
+    left = f"{bl}{h}{h} {hot_str}" if hot_str else f"{bl}{h}{h}"
+    if hot_str:
+        left += " "
+    right = f" {health_str} {h}{h}{h}{term}" if health_str else f" {h}{h}{h}{term}"
+    fill = max(2, Term.WIDTH - vlen(left) - vlen(right))
+    return left + (h * fill) + right
+
+
+def panel_blank() -> str:
+    return Term.g("│", "|")
+
+
+def section(label: str, count: int = -1, color_token: str = "accent") -> str:
+    """├── LABEL (count)"""
+    tee = Term.g("├", "+")
+    h = Term.g("─", "-")
+    parens = f" ({count})" if count >= 0 else ""
+    return f"{tee}{h}{h} {Term.color(color_token, label)}{Term.color('meta', parens)}"
+
+
+def sub_section(label: str, count: int = -1, color_token: str = "default") -> str:
+    """│   ├── LABEL (count)   — second-level grouping"""
+    pipe = Term.g("│", "|")
+    tee = Term.g("├", "+")
+    h = Term.g("─", "-")
+    parens = f" ({count})" if count >= 0 else ""
+    return f"{pipe}   {tee}{h}{h} {Term.color(color_token, label)}{Term.color('meta', parens)}"
+
+
+def sub_section_last(label: str, count: int = -1, color_token: str = "default") -> str:
+    """│   └── LABEL (count)   — last sub-section"""
+    pipe = Term.g("│", "|")
+    corner = Term.g("└", "`")
+    h = Term.g("─", "-")
+    parens = f" ({count})" if count >= 0 else ""
+    return f"{pipe}   {corner}{h}{h} {Term.color(color_token, label)}{Term.color('meta', parens)}"
+
+
+def leaf(num: int, name: str, *, meta: str = "", age: str = "",
+         last: bool = False, depth: int = 2,
+         parent_last: bool = False,
+         meta_color: str = "meta", age_color: str = "meta") -> str:
+    """│   │   ├──  3. session-name        meta      age
+
+    parent_last: when at depth 2 inside a last-sub-section, drop the inner
+    pipe so it reads as siblings of the corner `└──` rather than continuing.
+    """
+    pipe = Term.g("│", "|")
+    h = Term.g("─", "-")
+    conn = Term.g("└", "`") if last else Term.g("├", "+")
+
+    if depth == 1:
+        prefix = f"{pipe}   "
+    elif depth == 2:
+        inner = "    " if parent_last else f"{pipe}   "
+        prefix = f"{pipe}   {inner}"
+    else:
+        prefix = pipe + ("   " * depth)
+
+    num_str = f"{num:>2}." if num else "   "
+    name_field = trunc(name, 32).ljust(32)
+    # Tight meta column — turn count only (e.g. "30t").
+    meta_width = 8
+    meta_visible = vlen(meta)
+    if meta_visible <= meta_width:
+        pad = " " * (meta_width - meta_visible)
+        meta_field = Term.color(meta_color, meta) + pad
+    else:
+        meta_field = Term.color(meta_color, meta)
+    age_field = Term.color(age_color, age).rjust(6) if age else " " * 6
+
+    return f"{prefix}{conn}{h}{h} {num_str} {name_field}  {meta_field}  {age_field}"
+
+
+def summary_line(text: str) -> str:
+    """├── 4 lanes · 3 active   (dim)"""
+    tee = Term.g("├", "+")
+    h = Term.g("─", "-")
+    return f"{tee}{h}{h} {Term.color('meta', text)}"
+
+
+# Hint registry — each entry has a `when` predicate (over a context dict) and
+# a `text` template (str.format-able). Predicates returning True make the hint
+# eligible; one is picked at random.
+HINTS: list[dict] = [
+    # --- Conditional ---
+    {
+        "id": "density",
+        "when": lambda c: c["count"] > 30,
+        "text": "{count} sessions — narrow with --cwd <pat> or --title <pat>, "
+                "or shorten the window with --1d/--3d/--7d",
+    },
+    {
+        "id": "generic-titles",
+        "when": lambda c: c["generic_count"] >= 3,
+        "text": "{generic_count} sessions have generic titles (dev, general, untitled). "
+                "Use `summon --peek <id>` to preview the last messages before pulling",
+    },
+    {
+        "id": "default-window",
+        "when": lambda c: c["window_days"] == 3 and c["count"] >= 5,
+        "text": "default window is 3 days — `--all` to see everything, "
+                "`--1d` for just today, `--7d` for a week, or `--days N` for custom",
+    },
+    {
+        "id": "remote-skipped",
+        "when": lambda c: c["remote_count"] > 0,
+        "text": "{remote_count} remote-VM session(s) auto-skipped — they have no "
+                "local transcript to bridge, so cross-account transfer isn't possible",
+    },
+    # --- Always-eligible (rotate as background tips) ---
+    {
+        "id": "peek",
+        "when": lambda _: True,
+        "text": "preview a session's last messages with `summon --peek <id>` — handy "
+                "when titles like 'dev' don't tell you which one is which",
+    },
+    {
+        "id": "copy-vs-move",
+        "when": lambda _: True,
+        "text": "default is copy (visible from both accounts) — pass `--move` "
+                "to delete the source for lean cleanup",
+    },
+    {
+        "id": "logout-login",
+        "when": lambda _: True,
+        "text": "Desktop only loads sessions at login — Cowork/Code toggle, Ctrl+R, "
+                "and tab clicks won't rescan. Plan for Logout/Login when you switch",
+    },
+    {
+        "id": "proactive",
+        "when": lambda _: True,
+        "text": "best run BEFORE switching accounts: copy sessions to the next "
+                "account first, then Logout/Login (the switch you were doing anyway)",
+    },
+    {
+        "id": "dry-run",
+        "when": lambda _: True,
+        "text": "`--dry-run` previews a move without touching files — pair it with "
+                "`--pick` to rehearse the picker without committing",
+    },
+]
+
+
+def _pick_hint(context: dict) -> str:
+    """Pick one hint from HINTS whose predicate matches the context, or '' if none."""
+    import random
+    eligible = [h for h in HINTS if _hint_safe(h["when"], context)]
+    if not eligible:
+        return ""
+    chosen = random.choice(eligible)
+    try:
+        return chosen["text"].format(**context)
+    except (KeyError, ValueError):
+        return chosen["text"]
+
+
+def _hint_safe(predicate, context) -> bool:
+    try:
+        return bool(predicate(context))
+    except Exception:
+        return False
+
+
+def hint(text: str, width: int = 70) -> str:
+    """│   💡  text — tip riding the panel rail.
+
+    Continuation lines wrap under the text, not under the icon, so the eye
+    follows the message rather than re-finding column alignment.
+    """
+    pipe = Term.g("│", "|")
+    bulb = Term.g("💡", "(i)")
+    # Visual cells: pipe(1) + 3sp + bulb(2 if emoji, 3 if ASCII) + 2sp
+    bulb_cells = 3 if Term.USE_ASCII else 2
+    indent_after_pipe = 3 + bulb_cells + 2  # spaces between pipe and text
+    cont_pad = " " * indent_after_pipe
+
+    # Word-wrap to `width` chars per content line.
+    words = text.split(" ")
+    lines: list[str] = []
+    current = ""
+    for w in words:
+        candidate = f"{current} {w}".strip()
+        if len(candidate) <= width:
+            current = candidate
+        else:
+            if current:
+                lines.append(current)
+            current = w
+    if current:
+        lines.append(current)
+    if not lines:
+        return ""
+
+    out = [f"{pipe}   {bulb}  {Term.color('meta', lines[0])}"]
+    for line in lines[1:]:
+        out.append(f"{pipe}{cont_pad}{Term.color('meta', line)}")
+    return "\n".join(out)
+
+
+def echo(*lines):
+    if not lines:
+        print()
+        return
+    for line in lines:
+        print(line)
+
+
+# ============================================================
+#  Path discovery
+# ============================================================
+
+def appdata_claude() -> Path:
+    plat = str(sys.platform)
+    if plat == "win32":
+        appdata = os.environ.get("APPDATA")
+        if not appdata:
+            sys.exit("APPDATA env var not set; can't locate Claude Desktop dir")
+        return Path(appdata) / "Claude"
+    if plat == "darwin":
+        return Path.home() / "Library/Application Support/Claude"
+    return Path.home() / ".config/Claude"
+
+
+def cli_jsonl_root() -> Path:
+    return Path.home() / ".claude" / "projects"
+
+
+def encode_cwd(cwd: str) -> str:
+    """Convert cwd to ~/.claude/projects/ subdir name.
+
+    Each ':', '\\', '/', '.' becomes '-'; consecutive separators stay consecutive.
+    'X:\\Forge\\Axiom\\.claude\\worktrees\\foo' -> 'X--Forge-Axiom--claude-worktrees-foo'
+    """
+    return (cwd
+            .replace(":", "-")
+            .replace("\\", "-")
+            .replace("/", "-")
+            .replace(".", "-"))
+
+
+# ============================================================
+#  Account discovery
+# ============================================================
+
+@dataclass
+class Account:
+    uuid: str
+    sessions_dir: Path
+    email: str = ""
+    last_activity: float = 0.0
+    session_count: int = 0
+
+    @property
+    def short(self) -> str:
+        return self.uuid[:8]
+
+    @property
+    def label(self) -> str:
+        sep = Term.g("·", "|")
+        return f"{self.email or '(unknown)'} {sep} {self.short}"
+
+
+def _iter_session_files(account_dir: Path) -> Iterable[Path]:
+    for ws in account_dir.iterdir():
+        if not ws.is_dir():
+            continue
+        yield from ws.glob("local_*.json")
+
+
+def _find_account_email(agent_root: Path, account_uuid: str) -> str:
+    acct_dir = agent_root / account_uuid
+    if not acct_dir.is_dir():
+        return ""
+    for ws in acct_dir.iterdir():
+        if not ws.is_dir():
+            continue
+        for f in ws.glob("local_*.json"):
+            try:
+                d = json.loads(f.read_text(encoding="utf-8"))
+                email = d.get("emailAddress", "")
+                if email:
+                    return email
+            except (json.JSONDecodeError, OSError):
+                continue
+    return ""
+
+
+def discover_accounts(claude_dir: Path) -> list[Account]:
+    sessions_root = claude_dir / "claude-code-sessions"
+    if not sessions_root.is_dir():
+        return []
+    agent_root = claude_dir / "local-agent-mode-sessions"
+    accounts: list[Account] = []
+    for acct_dir in sessions_root.iterdir():
+        if not acct_dir.is_dir():
+            continue
+        sessions = list(_iter_session_files(acct_dir))
+        if not sessions:
+            continue
+        last = max((s.stat().st_mtime for s in sessions), default=0.0)
+        accounts.append(Account(
+            uuid=acct_dir.name,
+            sessions_dir=acct_dir,
+            email=_find_account_email(agent_root, acct_dir.name),
+            last_activity=last,
+            session_count=len(sessions),
+        ))
+    return sorted(accounts, key=lambda a: -a.last_activity)
+
+
+def detect_destination(accounts: list[Account]) -> Account | None:
+    return accounts[0] if accounts else None
+
+
+def resolve_account(query: str, accounts: list[Account]) -> Account | None:
+    q = query.lower()
+    for a in accounts:
+        if a.uuid == query:
+            return a
+    for a in accounts:
+        if a.uuid.startswith(query):
+            return a
+    for a in accounts:
+        if q in a.email.lower():
+            return a
+    return None
+
+
+# ============================================================
+#  Sessions
+# ============================================================
+
+@dataclass
+class Session:
+    path: Path
+    data: dict
+    account: Account
+
+    @property
+    def sid(self) -> str:
+        return self.data.get("sessionId", "")
+
+    @property
+    def cli_id(self) -> str:
+        return self.data.get("cliSessionId", "")
+
+    @property
+    def cwd(self) -> str:
+        return self.data.get("cwd", "")
+
+    @property
+    def title(self) -> str:
+        return self.data.get("title", "(untitled)")
+
+    @property
+    def turns(self) -> int:
+        return int(self.data.get("completedTurns", 0))
+
+    @property
+    def last_activity_ms(self) -> int:
+        return int(self.data.get("lastActivityAt", 0))
+
+    @property
+    def is_remote(self) -> bool:
+        return self.cwd.startswith("/sessions/")
+
+    def transcript_path(self) -> Path | None:
+        if not self.cli_id or not self.cwd:
+            return None
+        return cli_jsonl_root() / encode_cwd(self.cwd) / f"{self.cli_id}.jsonl"
+
+
+def load_sessions(account: Account) -> list[Session]:
+    out: list[Session] = []
+    for f in _iter_session_files(account.sessions_dir):
+        try:
+            data = json.loads(f.read_text(encoding="utf-8"))
+        except (json.JSONDecodeError, OSError):
+            continue
+        out.append(Session(path=f, data=data, account=account))
+    return out
+
+
+def filter_sessions(
+    sessions: list[Session],
+    *,
+    days: int | None,
+    cwd_pattern: str = "",
+    title_pattern: str = "",
+) -> list[Session]:
+    now_ms = int(time.time() * 1000)
+    cutoff_ms = now_ms - (days * 86_400_000) if days is not None else 0
+    out = []
+    for s in sessions:
+        if s.is_remote:
+            continue
+        if days is not None and s.last_activity_ms < cutoff_ms:
+            continue
+        if cwd_pattern and cwd_pattern.lower() not in s.cwd.lower():
+            continue
+        if title_pattern and title_pattern.lower() not in s.title.lower():
+            continue
+        out.append(s)
+    return sorted(out, key=lambda s: -s.last_activity_ms)
+
+
+# ============================================================
+#  Grouping
+# ============================================================
+
+_WORKTREE_MARKERS = (
+    "\\.claude\\worktrees\\",
+    "/.claude/worktrees/",
+)
+
+
+def project_root(cwd: str) -> str:
+    for marker in _WORKTREE_MARKERS:
+        if marker in cwd:
+            return cwd.split(marker)[0]
+    return cwd
+
+
+def relative_under_root(cwd: str, root: str) -> str:
+    if cwd == root:
+        return ""
+    if cwd.startswith(root):
+        return cwd[len(root):].lstrip("\\/")
+    return cwd
+
+
+def worktree_name(cwd: str) -> str:
+    """If cwd is inside a `.claude/worktrees/<name>/...` path, return <name>; else ''."""
+    for marker in _WORKTREE_MARKERS:
+        if marker in cwd:
+            tail = cwd.split(marker, 1)[1]
+            # First path segment is the worktree name; strip any deeper subpath.
+            return tail.split("\\", 1)[0].split("/", 1)[0]
+    return ""
+
+
+# ============================================================
+#  Listing
+# ============================================================
+
+def render_hierarchy(sessions: list[Session], *, grouped: bool) -> dict[int, Session]:
+    """Print sessions; return {1-based-index: session}."""
+    if grouped:
+        return _render_grouped(sessions)
+    index_map: dict[int, Session] = {}
+    for n, s in enumerate(sessions, 1):
+        index_map[n] = s
+        ago = _ago(s.last_activity_ms)
+        meta = f"{s.turns} turns"
+        display = f"{s.title}  ({s.cwd})"
+        echo(leaf(n, display, meta=meta, age=ago, depth=1))
+    return index_map
+
+
+def _render_grouped(sessions: list[Session]) -> dict[int, Session]:
+    """3-level hierarchy: Account -> Project -> Session."""
+    index_map: dict[int, Session] = {}
+
+    by_account: "OrderedDict[str, list[Session]]" = OrderedDict()
+    for s in sessions:
+        by_account.setdefault(s.account.uuid, []).append(s)
+
+    n = 0
+    for _, acct_sessions in by_account.items():
+        acct = acct_sessions[0].account
+
+        # Group within account by project root
+        by_project: "OrderedDict[str, list[Session]]" = OrderedDict()
+        for s in acct_sessions:
+            by_project.setdefault(project_root(s.cwd), []).append(s)
+
+        # Account header
+        echo(panel_blank())
+        echo(section(acct.email or "(unknown)", len(acct_sessions), color_token="accent"))
+
+        proj_items = list(by_project.items())
+        for pi, (root, members) in enumerate(proj_items):
+            is_last_proj = pi == len(proj_items) - 1
+            sub_func = sub_section_last if is_last_proj else sub_section
+            echo(sub_func(root, len(members), color_token="default"))
+
+            for li, s in enumerate(members):
+                n += 1
+                index_map[n] = s
+                is_last_session = li == len(members) - 1
+                ago = _ago(s.last_activity_ms)
+                meta = f"{s.turns}t"
+                echo(leaf(n, s.title, meta=meta, age=ago,
+                          last=is_last_session, depth=2,
+                          parent_last=is_last_proj))
+
+    echo(panel_blank())
+    return index_map
+
+
+def _window_label(days: int | None) -> str:
+    """Render the active time-window filter label."""
+    if days is None:
+        return "all time"
+    if days <= 1:
+        return "last 24h"
+    return f"last {days}d"
+
+
+def _ago(ms: int) -> str:
+    if ms == 0:
+        return "?"
+    delta_s = max(0, int(time.time()) - (ms // 1000))
+    if delta_s < 60:
+        return f"{delta_s}s"
+    if delta_s < 3600:
+        return f"{delta_s // 60}m"
+    if delta_s < 86400:
+        return f"{delta_s // 3600}h"
+    return f"{delta_s // 86400}d"
+
+
+# ============================================================
+#  Picker
+# ============================================================
+
+def interactive_pick(sessions: list[Session], *, grouped: bool) -> list[Session]:
+    if not sessions:
+        return []
+    index_map = _render_grouped(sessions) if grouped else render_hierarchy(sessions, grouped=False)
+    print()
+    raw = input(Term.color("accent", "select> ")
+                + "(numbers like '3,5,7', 'a' for all, blank to cancel): ").strip()
+    if not raw:
+        return []
+    if raw.lower() == "a":
+        return sessions
+    picks = []
+    for tok in raw.split(","):
+        tok = tok.strip()
+        if not tok:
+            continue
+        try:
+            i = int(tok)
+            if i in index_map:
+                picks.append(index_map[i])
+        except ValueError:
+            continue
+    return picks
+
+
+# ============================================================
+#  Workspace selection
+# ============================================================
+
+def pick_destination_workspace(account: Account) -> Path:
+    workspaces = [w for w in account.sessions_dir.iterdir() if w.is_dir()]
+    if not workspaces:
+        new_ws = account.sessions_dir / str(uuidlib.uuid4())
+        new_ws.mkdir(parents=True)
+        return new_ws
+    workspaces.sort(key=lambda w: -w.stat().st_mtime)
+    return workspaces[0]
+
+
+# ============================================================
+#  Operate
+# ============================================================
+
+def summon_session(s: Session, dest_workspace: Path, *, move: bool, dry_run: bool) -> str:
+    target = dest_workspace / s.path.name
+    if target.exists():
+        return "skip (already there)"
+    if not s.cli_id:
+        return "skip (no cliSessionId)"
+    transcript = s.transcript_path()
+    if transcript and not transcript.exists():
+        return "skip (transcript missing)"
+    if dry_run:
+        return "would " + ("move" if move else "copy")
+    op = shutil.move if move else shutil.copy2
+    op(str(s.path), str(target))
+    return "moved" if move else "copied"
+
+
+def nudge_watcher(workspace_dir: Path, moved_files: list[Path] | None = None) -> None:
+    """Force fs.watch to fire on the destination workspace dir.
+
+    Desktop's fs.watch is finicky — sometimes it picks up move-in events
+    immediately, sometimes it doesn't. We throw the kitchen sink at it:
+
+      1. mtime update on each moved file (write event)
+      2. Rename ping-pong on each moved file (move-out + move-in events)
+      3. Sentinel create+delete in workspace dir (dir-mod event)
+      4. Sentinel create+delete in account dir (parent dir-mod event)
+      5. mtime update on workspace dir (dir-mod event)
+      6. mtime update on account dir (parent dir-mod event)
+
+    All paths are tried; failures are silent.
+
+    Empirically: even with all of these, Desktop's renderer may still
+    require a Logout -> Login cycle to refresh the sidebar. That's
+    documented in SKILL.md as the canonical fallback.
+    """
+    now = time.time()
+    account_dir = workspace_dir.parent
+
+    # 1. mtime update on moved files
+    for f in (moved_files or []):
+        try:
+            os.utime(f, (now, now))
+        except OSError:
+            pass
+
+    # 2. Rename ping-pong on moved files
+    for f in (moved_files or []):
+        if not f.exists():
+            continue
+        tmp = f.with_name(f.name + ".summon-tmp")
+        try:
+            f.rename(tmp)
+            tmp.rename(f)
+        except OSError:
+            try:
+                if tmp.exists():
+                    tmp.rename(f)
+            except OSError:
+                pass
+
+    # 3 + 4. Sentinel pings at workspace AND account level
+    for parent in (workspace_dir, account_dir):
+        sentinel = parent / f".summon-nudge-{uuidlib.uuid4().hex[:8]}"
+        try:
+            sentinel.touch()
+            sentinel.unlink()
+        except OSError:
+            pass
+
+    # 5 + 6. mtime touch on workspace and account dirs
+    for d in (workspace_dir, account_dir):
+        try:
+            os.utime(d, (now, now))
+        except OSError:
+            pass
+
+
+# ============================================================
+#  Peek
+# ============================================================
+
+def find_session_by_id(query: str, accounts: list[Account]) -> Session | None:
+    q = query.lower().removeprefix("local_")
+    for acct in accounts:
+        for s in load_sessions(acct):
+            sid = s.sid.lower().removeprefix("local_")
+            cli = s.cli_id.lower()
+            if sid == q or cli == query.lower():
+                return s
+            if sid.startswith(q) or cli.startswith(q):
+                return s
+    return None
+
+
+def peek_session(query: str, accounts: list[Account], turns: int = 3) -> int:
+    s = find_session_by_id(query, accounts)
+    if not s:
+        echo(panel_open(f"summon {Term.g('·', '|')} peek", indicator="not found"))
+        echo(panel_blank())
+        echo(f"   no session matching: {Term.color('alarm', query)}")
+        echo(panel_blank())
+        echo(panel_close())
+        return 1
+    transcript = s.transcript_path()
+    if not transcript or not transcript.exists():
+        echo(panel_open(f"summon {Term.g('·', '|')} peek", indicator=f"{s.account.short} {Term.g('·', '|')} {s.title}"))
+        echo(panel_blank())
+        echo(f"   transcript missing: {Term.color('alarm', str(transcript))}")
+        echo(panel_blank())
+        echo(panel_close())
+        return 2
+
+    exchanges: list[tuple[str, str]] = []
+    try:
+        with transcript.open("r", encoding="utf-8") as f:
+            for line in f:
+                try:
+                    rec = json.loads(line)
+                except json.JSONDecodeError:
+                    continue
+                t = rec.get("type")
+                if t not in ("user", "assistant"):
+                    continue
+                msg = rec.get("message", {})
+                text = _extract_text(msg.get("content"))
+                if text:
+                    exchanges.append((t, text))
+    except OSError as e:
+        echo(panel_open(f"summon {Term.g('·', '|')} peek", indicator="read error"))
+        echo(panel_blank())
+        echo(f"   {Term.color('alarm', str(e))}")
+        echo(panel_blank())
+        echo(panel_close())
+        return 2
+
+    indicator = f"{s.account.email or s.account.short}"
+    echo(panel_open(f"summon {Term.g('·', '|')} peek", indicator=indicator))
+    echo(panel_blank())
+    sep = Term.g("·", "|")
+    echo(summary_line(f"{s.title!r}   {s.cwd}"))
+    echo(summary_line(f"{s.turns} turns {sep} last activity {_ago(s.last_activity_ms)}"))
+    echo(panel_blank())
+
+    if not exchanges:
+        echo(f"   {Term.color('meta', '(transcript has no readable user/assistant messages)')}")
+        echo(panel_blank())
+        echo(panel_close())
+        return 0
+
+    tail = exchanges[-(turns * 2):]
+    echo(section(f"last {len(tail)} message(s)", color_token="accent"))
+    for role, text in tail:
+        marker = Term.color("accent", ">>") if role == "user" else Term.color("ok", "<<")
+        snippet = text.strip().replace("\n", " ")
+        if len(snippet) > 600:
+            snippet = snippet[:597] + "..."
+        echo(panel_blank())
+        # Wrap to 70 chars per line
+        words = snippet.split(" ")
+        line_width = 70
+        line = ""
+        first = True
+        for w in words:
+            candidate = (line + " " + w) if line else w
+            if len(candidate) <= line_width:
+                line = candidate
+            else:
+                pipe = Term.g("│", "|")
+                lead = f"{pipe}   {marker} " if first else f"{pipe}      "
+                echo(f"{lead}{line}")
+                line = w
+                first = False
+        if line:
+            pipe = Term.g("│", "|")
+            lead = f"{pipe}   {marker} " if first else f"{pipe}      "
+            echo(f"{lead}{line}")
+
+    echo(panel_blank())
+    echo(panel_close(hotkeys=[("q", "quit")]))
+    return 0
+
+
+def _extract_text(content) -> str:
+    if isinstance(content, str):
+        return content
+    if isinstance(content, list):
+        chunks = []
+        for block in content:
+            if isinstance(block, dict):
+                t = block.get("type")
+                if t == "text":
+                    chunks.append(block.get("text", ""))
+                elif t == "tool_use":
+                    chunks.append(f"[tool_use: {block.get('name', '?')}]")
+                elif t == "tool_result":
+                    chunks.append("[tool_result]")
+        return " ".join(chunks)
+    return ""
+
+
+# ============================================================
+#  Main
+# ============================================================
+
+def main():
+    p = argparse.ArgumentParser(
+        description="Summon Claude Desktop sessions from another account.",
+    )
+    p.add_argument("--to", help="Destination account (UUID prefix or email substring)")
+    p.add_argument("--from", dest="from_",
+                   help="Restrict source to one account (default: all non-destination accounts)")
+    # Time-window filter: --days N (custom) or one of the convenience aliases.
+    # Defaults to 14 days; --all disables.
+    p.add_argument("--days", type=int, default=3, help="Time window in days (default 3)")
+    p.add_argument("--all", action="store_true", help="Disable time filter (any age)")
+    p.add_argument("--1d", dest="window_1d", action="store_true", help="Last 24h (alias)")
+    p.add_argument("--3d", dest="window_3d", action="store_true", help="Last 3 days (alias)")
+    p.add_argument("--7d", dest="window_7d", action="store_true", help="Last 7 days (alias)")
+    p.add_argument("--30d", dest="window_30d", action="store_true", help="Last 30 days (alias)")
+    p.add_argument("--cwd", default="", help="Substring match against cwd")
+    p.add_argument("--title", default="", help="Substring match against title")
+    p.add_argument("--pick", action="store_true", help=argparse.SUPPRESS)  # legacy flag — default behavior now
+    p.add_argument("--move", action="store_true",
+                   help="Move semantics — delete source after copying (lean cleanup)")
+    p.add_argument("--dry-run", action="store_true", help="Preview without touching files")
+    p.add_argument("--list-accounts", action="store_true", help="List all accounts and exit")
+    p.add_argument("--peek", metavar="ID", help="Preview a session's last messages and exit (id prefix or full)")
+    p.add_argument("--flat", action="store_true", help="Flat list instead of grouped hierarchy")
+    p.add_argument("--yes", action="store_true",
+                   help="Non-interactive: select ALL candidates and proceed without prompting")
+    args = p.parse_args()
+
+    claude_dir = appdata_claude()
+    if not claude_dir.is_dir():
+        sys.exit(f"Claude dir not found: {claude_dir}")
+
+    accounts = discover_accounts(claude_dir)
+    if not accounts:
+        sys.exit(f"No accounts with sessions under {claude_dir}/claude-code-sessions/")
+
+    # --- Modes that exit early ---
+
+    if args.list_accounts:
+        echo(panel_open(f"summon {Term.g('·', '|')} accounts"))
+        echo(panel_blank())
+        echo(section("accounts", len(accounts), color_token="accent"))
+        for i, a in enumerate(accounts):
+            is_last = (i == len(accounts) - 1)
+            ago = _ago(int(a.last_activity * 1000))
+            echo(leaf(0, a.email or "(unknown)",
+                      meta=f"{a.short} {Term.g('·', '|')} {a.session_count}",
+                      age=ago, last=is_last, depth=1))
+        echo(panel_blank())
+        echo(panel_close(healths=[("ok", f"{len(accounts)} active")]))
+        return
+
+    if args.peek:
+        sys.exit(peek_session(args.peek, accounts))
+
+    # --- Pull mode ---
+
+    # Resolve destination
+    if not args.to:
+        dest = detect_destination(accounts)
+    else:
+        dest = resolve_account(args.to, accounts)
+    if not dest:
+        sys.exit(f"Cannot resolve destination account: {args.to}")
+
+    # Resolve source(s)
+    if args.from_:
+        src = resolve_account(args.from_, accounts)
+        if not src:
+            sys.exit(f"Cannot resolve source account: {args.from_}")
+        if src.uuid == dest.uuid:
+            sys.exit("Source and destination are the same; remove --from or pick another --to")
+        source_accounts = [src]
+    else:
+        source_accounts = [a for a in accounts if a.uuid != dest.uuid]
+    if not source_accounts:
+        sys.exit("No source accounts available (only one account exists)")
+
+    # Load + filter sessions
+    all_sessions: list[Session] = []
+    for src in source_accounts:
+        all_sessions.extend(load_sessions(src))
+
+    # Resolve recency window: --all > convenience alias > --days
+    if args.all:
+        days = None
+    elif args.window_1d:
+        days = 1
+    elif args.window_3d:
+        days = 3
+    elif args.window_7d:
+        days = 7
+    elif args.window_30d:
+        days = 30
+    else:
+        days = args.days
+
+    candidates = filter_sessions(all_sessions, days=days,
+                                 cwd_pattern=args.cwd, title_pattern=args.title)
+
+    # Header: short destination tag (just the email's local part or UUID short).
+    # Source detail goes into the summary line — header stays clean.
+    arrow = Term.g("→", "->")
+    dest_short = (dest.email.split("@")[0] if dest.email else dest.short)
+    indicator = f"{arrow} {dest_short}"
+    echo(panel_open("summon", indicator=indicator))
+
+    if not candidates:
+        echo(panel_blank())
+        echo(summary_line(f"no matching sessions  ({_window_label(days)})"))
+        echo(panel_blank())
+        echo(panel_close())
+        return
+
+    # Summary line at the TOP per DESIGN.md.
+    sep = Term.g("·", "|")
+    if len(source_accounts) == 1:
+        src_email = source_accounts[0].email or source_accounts[0].short
+        src_label = f"from {src_email}"
+    else:
+        src_label = f"from {len(source_accounts)} accounts"
+    summary_text = f"{len(candidates)} sessions {sep} {src_label} {sep} {_window_label(days)}"
+
+    echo(panel_blank())
+    echo(summary_line(summary_text))
+
+    # Render hierarchy + capture index_map
+    index_map = _render_grouped(candidates) if not args.flat else render_hierarchy(candidates, grouped=False)
+
+    # Pick a hint — conditional ones win when relevant, otherwise rotate background tips
+    generic_titles = {"dev", "general", "untitled", "(untitled)", ""}
+    generic_count = sum(1 for s in candidates if s.title.lower() in generic_titles)
+    remote_count = sum(1 for sess in all_sessions if sess.is_remote)
+
+    hint_ctx = {
+        "count": len(candidates),
+        "generic_count": generic_count,
+        "remote_count": remote_count,
+        "window_days": days if days is not None else 0,
+        "source_count": len(source_accounts),
+    }
+    hint_text = _pick_hint(hint_ctx)
+    if hint_text:
+        echo(hint(hint_text))
+        echo(panel_blank())
+
+    echo(panel_close(hotkeys=[("#", "select"), ("a", "all"), ("blank", "cancel")]))
+
+    # Selection (default) — prompt for picks unless --yes (auto-all).
+    if args.yes:
+        chosen = list(candidates)
+    else:
+        print()
+        prompt = Term.color("accent", "select> ") + \
+                 "(numbers like '3,5,7', 'a' for all, blank to cancel): "
+        raw = input(prompt).strip()
+        if not raw:
+            print(Term.color("meta", "cancelled."))
+            return
+        chosen: list[Session] = []
+        if raw.lower() == "a":
+            chosen = list(candidates)
+        else:
+            for tok in raw.split(","):
+                tok = tok.strip()
+                if not tok:
+                    continue
+                try:
+                    i = int(tok)
+                    if i in index_map:
+                        chosen.append(index_map[i])
+                except ValueError:
+                    continue
+        if not chosen:
+            print(Term.color("meta", "nothing selected — cancelled."))
+            return
+    candidates = chosen
+
+    dest_ws = pick_destination_workspace(dest)
+
+    # Operate + render results in a fresh stacked panel (DESIGN: 2 blank lines)
+    print()
+    print()
+    echo(panel_open(f"summon {Term.g('·', '|')} results", indicator=dest_ws.name[:8]))
+    echo(panel_blank())
+
+    success_states = {"copied", "moved", "would copy", "would move"}
+    skip_re = re.compile(r"^skip")
+    moved = 0
+    skipped = 0
+    moved_files: list[Path] = []
+    for i, s in enumerate(candidates):
+        is_last = i == len(candidates) - 1
+        status = summon_session(s, dest_ws, move=args.move, dry_run=args.dry_run)
+        if status in success_states:
+            moved += 1
+            color = "ok"
+            target = dest_ws / s.path.name
+            if not args.dry_run and target.exists():
+                moved_files.append(target)
+        elif skip_re.match(status):
+            skipped += 1
+            color = "warn"
+        else:
+            color = "alarm"
+        echo(leaf(0, s.title, meta=Term.color(color, status),
+                  age=s.account.short, last=is_last, depth=1))
+
+    echo(panel_blank())
+
+    # Nudge fs.watch — sentinel + rename ping-pong on each moved file
+    if moved and not args.dry_run:
+        nudge_watcher(dest_ws, moved_files=moved_files)
+
+    healths = []
+    if moved:
+        if args.dry_run:
+            verb = "would " + ("move" if args.move else "copy")
+        else:
+            verb = "moved" if args.move else "copied"
+        healths.append(("ok", f"{moved} {verb}"))
+    if skipped:
+        healths.append(("warn", f"{skipped} skipped"))
+
+    echo(panel_close(healths=healths))
+
+    if moved and not args.dry_run:
+        echo()
+        echo(Term.color("warn",
+             "next: switch accounts in Desktop. Logout from current, login to destination."))
+        echo(Term.color("meta",
+             "  the new sessions appear when destination's sidebar populates on login."))
+        echo(Term.color("meta",
+             "  (Desktop caches session list at login; tab toggles and Ctrl+R won't rescan.)"))
+
+
+if __name__ == "__main__":
+    main()