summon.py 89 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359
  1. #!/usr/bin/env python3
  2. """summon — Claude Desktop session toolbox: cross-account transfer + recover/rebind/doctor.
  3. Usage: summon [MODE] [ID] [OPTIONS]
  4. Input: optional MODE positional (rebind|pick|recover|doctor; omit for transfer),
  5. ID = sessionId/cliSessionId prefix for rebind/recover; picker reads stdin
  6. Output: transfer/pick/doctor render TTY panels; recover/pick emit a paste-ready
  7. handover on stdout — a Sonnet-distilled brief (Goal / What landed /
  8. Unfinished / Open decisions / Key context) + transcript pointer, cached
  9. at <transcript>.handover.md; falls back to the plain pointer prompt when
  10. the `claude` CLI is unavailable or --no-distill is set. doctor --json
  11. emits {"data": [...], "meta": {"schema": "claude-mods.summon.doctor/v1"}};
  12. pick --json emits the session inventory as
  13. {"data": [...], "meta": {"schema": "claude-mods.summon.pick/v1"}};
  14. widget emits FINISHED, self-contained card-picker HTML on stdout (pass
  15. it straight to show_widget) — the rich inventory pre-trimmed and
  16. injected into assets/picker-widget.html under a hard byte budget, one
  17. session object per line; a copy is written to --out (default
  18. <temp>/claude/summon-widget.html)
  19. Stderr: context panels for recover/pick, distillation progress, warnings, errors
  20. Exit: 0 ok (including the non-distilled fallback — worker unavailability is
  21. advisory, never fatal), 2 usage/ambiguous id, 3 session or path not
  22. found, 10 doctor found broken sessions
  23. Examples:
  24. summon --to mknv74 # transfer: push sessions to next account
  25. summon pick # fzf/numbered picker -> distilled handover
  26. summon pick --json | jq '.data[]' # machine-readable session inventory
  27. summon widget --days 30 # finished in-chat card-picker HTML on stdout
  28. summon recover 6577b24c # distilled handover brief for one session
  29. summon recover 6577b24c --refresh # ignore cached brief, re-distill
  30. summon recover 6577b24c --no-distill # plain pointer prompt, no LLM call
  31. summon recover 6577b24c --model haiku # distill with a different model
  32. summon rebind 6577b24c --cwd X:\\Maplab\\LCMap\\.claude\\worktrees\\funny-hypatia-5e54f7
  33. summon doctor # scan all sessions for broken cwd bindings
  34. summon doctor --json | jq '.data[]'
  35. Transfer mode (no positional) is documented in SKILL.md: copy by default, --move
  36. to relocate, destination auto-detected as the most-recently-active account.
  37. Output rendering follows docs/TERMINAL-DESIGN.md (Terminal Panel Design System).
  38. Deliberately single-file: skill scripts ship as self-contained portable units
  39. (docs/SKILL-RESOURCE-PROTOCOL.md) — do not split into a package. Navigate by
  40. the `# ===` section headers — Sections: DESIGN(term) · Path discovery ·
  41. Account discovery · Sessions · Grouping · Listing · Picker · Workspace
  42. selection · Operate · Peek · Transcript/Distill · Modes (transfer / pick /
  43. recover / rebind / doctor) · Widget (card-picker builder) · CLI entry.
  44. """
  45. from __future__ import annotations
  46. import argparse
  47. import json
  48. import os
  49. import re
  50. import shutil
  51. import sys
  52. import tempfile
  53. import time
  54. import uuid as uuidlib
  55. from collections import OrderedDict
  56. from dataclasses import dataclass
  57. from datetime import datetime
  58. from pathlib import Path
  59. from typing import Iterable
  60. # ============================================================
  61. # DESIGN: terminal panel rendering (per docs/TERMINAL-DESIGN.md)
  62. # ============================================================
  63. def _stdout_supports_unicode() -> bool:
  64. enc = (getattr(sys.stdout, "encoding", "") or "").lower()
  65. return "utf" in enc or "cp65001" in enc
  66. class Term:
  67. WIDTH = 80
  68. USE_ASCII = (
  69. os.environ.get("TERM_ASCII") == "1"
  70. or os.environ.get("TERM") == "dumb"
  71. or not _stdout_supports_unicode()
  72. )
  73. USE_COLOR = (
  74. sys.stdout.isatty()
  75. and os.environ.get("NO_COLOR") is None
  76. and os.environ.get("TERM") != "dumb"
  77. and os.environ.get("FORCE_COLOR") != "0"
  78. )
  79. @classmethod
  80. def g(cls, uni: str, asc: str) -> str:
  81. return asc if cls.USE_ASCII else uni
  82. @classmethod
  83. def color(cls, token: str, text: str) -> str:
  84. if not cls.USE_COLOR:
  85. return text
  86. codes = {
  87. "accent": "36", # cyan
  88. "ok": "32", # green
  89. "warn": "33", # yellow
  90. "alarm": "31", # red
  91. "tag": "35", # magenta
  92. "meta": "2", # dim
  93. "dim": "2",
  94. "default": "",
  95. }
  96. c = codes.get(token, "")
  97. if not c:
  98. return text
  99. return f"\033[{c}m{text}\033[0m"
  100. _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
  101. def vlen(s: str) -> int:
  102. """Visible length, excluding ANSI escape codes."""
  103. return len(_ANSI_RE.sub("", s))
  104. def trunc(s: str, width: int) -> str:
  105. """Truncate with ellipsis if too long."""
  106. if vlen(s) <= width:
  107. return s
  108. ell = Term.g("…", "...")
  109. return s[: width - vlen(ell)] + ell
  110. # Brand emoji for summon (not in TERMINAL-DESIGN.md registry yet — registering here)
  111. BRAND_EMOJI = "🪄"
  112. BRAND_ASCII = "[S]"
  113. def panel_open(name: str, indicator: str = "") -> str:
  114. """╭── 🪄 summon ───────────────── indicator ───●"""
  115. em = Term.g(BRAND_EMOJI, BRAND_ASCII)
  116. tl = Term.g("╭", "+")
  117. h = Term.g("─", "-")
  118. term = Term.g("●", "*")
  119. left = f"{tl}{h}{h} {em} {Term.color('accent', name)} "
  120. if indicator:
  121. right = f" {Term.color('meta', indicator)} {h}{h}{h}{term}"
  122. else:
  123. right = f" {h}{h}{h}{term}"
  124. fill_count = max(2, Term.WIDTH - vlen(left) - vlen(right))
  125. return left + (h * fill_count) + right
  126. def panel_close(hotkeys: list[tuple[str, str]] | None = None,
  127. healths: list[tuple[str, str]] | None = None) -> str:
  128. """╰── y confirm · n cancel ───── • 5 ready ───●"""
  129. bl = Term.g("╰", "+")
  130. h = Term.g("─", "-")
  131. term = Term.g("●", "*")
  132. bullet = Term.g("•", "(+)")
  133. hotkeys = hotkeys or []
  134. healths = healths or []
  135. sep = Term.g(" · ", " | ")
  136. hot_str = sep.join(f"{Term.color('accent', k)} {v}" for k, v in hotkeys)
  137. health_str = " ".join(f"{Term.color(c, bullet)} {v}" for c, v in healths)
  138. left = f"{bl}{h}{h} {hot_str}" if hot_str else f"{bl}{h}{h}"
  139. if hot_str:
  140. left += " "
  141. right = f" {health_str} {h}{h}{h}{term}" if health_str else f" {h}{h}{h}{term}"
  142. fill = max(2, Term.WIDTH - vlen(left) - vlen(right))
  143. return left + (h * fill) + right
  144. def panel_blank() -> str:
  145. return Term.g("│", "|")
  146. def section(label: str, count: int = -1, color_token: str = "accent") -> str:
  147. """├── LABEL (count)"""
  148. tee = Term.g("├", "+")
  149. h = Term.g("─", "-")
  150. parens = f" ({count})" if count >= 0 else ""
  151. return f"{tee}{h}{h} {Term.color(color_token, label)}{Term.color('meta', parens)}"
  152. def sub_section(label: str, count: int = -1, color_token: str = "default") -> str:
  153. """│ ├── LABEL (count) — second-level grouping"""
  154. pipe = Term.g("│", "|")
  155. tee = Term.g("├", "+")
  156. h = Term.g("─", "-")
  157. parens = f" ({count})" if count >= 0 else ""
  158. return f"{pipe} {tee}{h}{h} {Term.color(color_token, label)}{Term.color('meta', parens)}"
  159. def sub_section_last(label: str, count: int = -1, color_token: str = "default") -> str:
  160. """│ └── LABEL (count) — last sub-section"""
  161. pipe = Term.g("│", "|")
  162. corner = Term.g("└", "`")
  163. h = Term.g("─", "-")
  164. parens = f" ({count})" if count >= 0 else ""
  165. return f"{pipe} {corner}{h}{h} {Term.color(color_token, label)}{Term.color('meta', parens)}"
  166. def leaf(num: int, name: str, *, meta: str = "", age: str = "",
  167. last: bool = False, depth: int = 2,
  168. parent_last: bool = False,
  169. meta_color: str = "meta", age_color: str = "meta") -> str:
  170. """│ │ ├── 3. session-name meta age
  171. parent_last: when at depth 2 inside a last-sub-section, drop the inner
  172. pipe so it reads as siblings of the corner `└──` rather than continuing.
  173. """
  174. pipe = Term.g("│", "|")
  175. h = Term.g("─", "-")
  176. conn = Term.g("└", "`") if last else Term.g("├", "+")
  177. if depth == 1:
  178. prefix = f"{pipe} "
  179. elif depth == 2:
  180. inner = " " if parent_last else f"{pipe} "
  181. prefix = f"{pipe} {inner}"
  182. else:
  183. prefix = pipe + (" " * depth)
  184. num_str = f"{num:>2}." if num else " "
  185. name_field = trunc(name, 32).ljust(32)
  186. # Tight meta column — turn count only (e.g. "30t").
  187. meta_width = 8
  188. meta_visible = vlen(meta)
  189. if meta_visible <= meta_width:
  190. pad = " " * (meta_width - meta_visible)
  191. meta_field = Term.color(meta_color, meta) + pad
  192. else:
  193. meta_field = Term.color(meta_color, meta)
  194. age_field = Term.color(age_color, age).rjust(6) if age else " " * 6
  195. return f"{prefix}{conn}{h}{h} {num_str} {name_field} {meta_field} {age_field}"
  196. def summary_line(text: str) -> str:
  197. """├── 4 lanes · 3 active (dim)"""
  198. tee = Term.g("├", "+")
  199. h = Term.g("─", "-")
  200. return f"{tee}{h}{h} {Term.color('meta', text)}"
  201. # Hint registry — each entry has a `when` predicate (over a context dict) and
  202. # a `text` template (str.format-able). Predicates returning True make the hint
  203. # eligible; one is picked at random.
  204. HINTS: list[dict] = [
  205. # --- Conditional ---
  206. {
  207. "id": "density",
  208. "when": lambda c: c["count"] > 30,
  209. "text": "{count} sessions — narrow with --cwd <pat> or --title <pat>, "
  210. "or shorten the window with --1d/--3d/--7d",
  211. },
  212. {
  213. "id": "generic-titles",
  214. "when": lambda c: c["generic_count"] >= 3,
  215. "text": "{generic_count} sessions have generic titles (dev, general, untitled). "
  216. "Use `summon --peek <id>` to preview the last messages before pulling",
  217. },
  218. {
  219. "id": "default-window",
  220. "when": lambda c: c["window_days"] == 3 and c["count"] >= 5,
  221. "text": "default window is 3 days — `--all` to see everything, "
  222. "`--1d` for just today, `--7d` for a week, or `--days N` for custom",
  223. },
  224. {
  225. "id": "remote-skipped",
  226. "when": lambda c: c["remote_count"] > 0,
  227. "text": "{remote_count} remote-VM session(s) auto-skipped — they have no "
  228. "local transcript to bridge, so cross-account transfer isn't possible",
  229. },
  230. # --- Always-eligible (rotate as background tips) ---
  231. {
  232. "id": "peek",
  233. "when": lambda _: True,
  234. "text": "preview a session's last messages with `summon --peek <id>` — handy "
  235. "when titles like 'dev' don't tell you which one is which",
  236. },
  237. {
  238. "id": "copy-vs-move",
  239. "when": lambda _: True,
  240. "text": "default is copy (visible from both accounts) — pass `--move` "
  241. "to delete the source for lean cleanup",
  242. },
  243. {
  244. "id": "logout-login",
  245. "when": lambda _: True,
  246. "text": "Desktop only loads sessions at login — Cowork/Code toggle, Ctrl+R, "
  247. "and tab clicks won't rescan. Plan for Logout/Login when you switch",
  248. },
  249. {
  250. "id": "proactive",
  251. "when": lambda _: True,
  252. "text": "best run BEFORE switching accounts: copy sessions to the next "
  253. "account first, then Logout/Login (the switch you were doing anyway)",
  254. },
  255. {
  256. "id": "dry-run",
  257. "when": lambda _: True,
  258. "text": "`--dry-run` previews a move without touching files — pair it with "
  259. "`--pick` to rehearse the picker without committing",
  260. },
  261. ]
  262. def _pick_hint(context: dict) -> str:
  263. """Pick one hint from HINTS whose predicate matches the context, or '' if none."""
  264. import random
  265. eligible = [h for h in HINTS if _hint_safe(h["when"], context)]
  266. if not eligible:
  267. return ""
  268. chosen = random.choice(eligible)
  269. try:
  270. return chosen["text"].format(**context)
  271. except (KeyError, ValueError):
  272. return chosen["text"]
  273. def _hint_safe(predicate, context) -> bool:
  274. try:
  275. return bool(predicate(context))
  276. except Exception:
  277. return False
  278. def hint(text: str, width: int = 70) -> str:
  279. """│ 💡 text — tip riding the panel rail.
  280. Continuation lines wrap under the text, not under the icon, so the eye
  281. follows the message rather than re-finding column alignment.
  282. """
  283. pipe = Term.g("│", "|")
  284. bulb = Term.g("💡", "(i)")
  285. # Visual cells: pipe(1) + 3sp + bulb(2 if emoji, 3 if ASCII) + 2sp
  286. bulb_cells = 3 if Term.USE_ASCII else 2
  287. indent_after_pipe = 3 + bulb_cells + 2 # spaces between pipe and text
  288. cont_pad = " " * indent_after_pipe
  289. # Word-wrap to `width` chars per content line.
  290. words = text.split(" ")
  291. lines: list[str] = []
  292. current = ""
  293. for w in words:
  294. candidate = f"{current} {w}".strip()
  295. if len(candidate) <= width:
  296. current = candidate
  297. else:
  298. if current:
  299. lines.append(current)
  300. current = w
  301. if current:
  302. lines.append(current)
  303. if not lines:
  304. return ""
  305. out = [f"{pipe} {bulb} {Term.color('meta', lines[0])}"]
  306. for line in lines[1:]:
  307. out.append(f"{pipe}{cont_pad}{Term.color('meta', line)}")
  308. return "\n".join(out)
  309. def _print_safe(line: str = "") -> None:
  310. """print() that survives narrow stdout encodings (e.g. Windows cp1252).
  311. Session titles carry arbitrary Unicode (e.g. '→') that the console
  312. encoding may not represent; degrade those chars to '?' instead of
  313. crashing with UnicodeEncodeError.
  314. """
  315. try:
  316. print(line)
  317. except UnicodeEncodeError:
  318. enc = getattr(sys.stdout, "encoding", None) or "ascii"
  319. print(str(line).encode(enc, errors="replace").decode(enc, errors="replace"))
  320. def echo(*lines):
  321. if not lines:
  322. _print_safe()
  323. return
  324. for line in lines:
  325. _print_safe(line)
  326. # ============================================================
  327. # Path discovery
  328. # ============================================================
  329. def appdata_claude() -> Path:
  330. plat = str(sys.platform)
  331. if plat == "win32":
  332. appdata = os.environ.get("APPDATA")
  333. if not appdata:
  334. sys.exit("APPDATA env var not set; can't locate Claude Desktop dir")
  335. return Path(appdata) / "Claude"
  336. if plat == "darwin":
  337. return Path.home() / "Library/Application Support/Claude"
  338. return Path.home() / ".config/Claude"
  339. def cli_jsonl_root() -> Path:
  340. return Path.home() / ".claude" / "projects"
  341. def encode_cwd(cwd: str) -> str:
  342. """Convert cwd to ~/.claude/projects/ subdir name.
  343. Each ':', '\\', '/', '.' becomes '-'; consecutive separators stay consecutive.
  344. 'X:\\Forge\\Axiom\\.claude\\worktrees\\foo' -> 'X--Forge-Axiom--claude-worktrees-foo'
  345. """
  346. return (cwd
  347. .replace(":", "-")
  348. .replace("\\", "-")
  349. .replace("/", "-")
  350. .replace(".", "-"))
  351. # ============================================================
  352. # Account discovery
  353. # ============================================================
  354. @dataclass
  355. class Account:
  356. uuid: str
  357. sessions_dir: Path
  358. email: str = ""
  359. last_activity: float = 0.0
  360. session_count: int = 0
  361. @property
  362. def short(self) -> str:
  363. return self.uuid[:8]
  364. @property
  365. def label(self) -> str:
  366. sep = Term.g("·", "|")
  367. return f"{self.email or '(unknown)'} {sep} {self.short}"
  368. def _iter_session_files(account_dir: Path) -> Iterable[Path]:
  369. for ws in account_dir.iterdir():
  370. if not ws.is_dir():
  371. continue
  372. yield from ws.glob("local_*.json")
  373. def _find_account_email(agent_root: Path, account_uuid: str) -> str:
  374. acct_dir = agent_root / account_uuid
  375. if not acct_dir.is_dir():
  376. return ""
  377. for ws in acct_dir.iterdir():
  378. if not ws.is_dir():
  379. continue
  380. for f in ws.glob("local_*.json"):
  381. try:
  382. d = json.loads(f.read_text(encoding="utf-8"))
  383. email = d.get("emailAddress", "")
  384. if email:
  385. return email
  386. except (json.JSONDecodeError, OSError):
  387. continue
  388. return ""
  389. def discover_accounts(claude_dir: Path) -> list[Account]:
  390. sessions_root = claude_dir / "claude-code-sessions"
  391. if not sessions_root.is_dir():
  392. return []
  393. agent_root = claude_dir / "local-agent-mode-sessions"
  394. accounts: list[Account] = []
  395. for acct_dir in sessions_root.iterdir():
  396. if not acct_dir.is_dir():
  397. continue
  398. sessions = list(_iter_session_files(acct_dir))
  399. if not sessions:
  400. continue
  401. last = max((s.stat().st_mtime for s in sessions), default=0.0)
  402. accounts.append(Account(
  403. uuid=acct_dir.name,
  404. sessions_dir=acct_dir,
  405. email=_find_account_email(agent_root, acct_dir.name),
  406. last_activity=last,
  407. session_count=len(sessions),
  408. ))
  409. return sorted(accounts, key=lambda a: -a.last_activity)
  410. def detect_destination(accounts: list[Account]) -> Account | None:
  411. return accounts[0] if accounts else None
  412. def resolve_account(query: str, accounts: list[Account]) -> Account | None:
  413. q = query.lower()
  414. for a in accounts:
  415. if a.uuid == query:
  416. return a
  417. for a in accounts:
  418. if a.uuid.startswith(query):
  419. return a
  420. for a in accounts:
  421. if q in a.email.lower():
  422. return a
  423. return None
  424. # ============================================================
  425. # Sessions
  426. # ============================================================
  427. @dataclass
  428. class Session:
  429. path: Path
  430. data: dict
  431. account: Account
  432. @property
  433. def sid(self) -> str:
  434. return self.data.get("sessionId", "")
  435. @property
  436. def cli_id(self) -> str:
  437. return self.data.get("cliSessionId", "")
  438. @property
  439. def cwd(self) -> str:
  440. return self.data.get("cwd", "")
  441. @property
  442. def title(self) -> str:
  443. return self.data.get("title", "(untitled)")
  444. @property
  445. def turns(self) -> int:
  446. return int(self.data.get("completedTurns", 0))
  447. @property
  448. def last_activity_ms(self) -> int:
  449. return int(self.data.get("lastActivityAt", 0))
  450. @property
  451. def is_remote(self) -> bool:
  452. return self.cwd.startswith("/sessions/")
  453. def transcript_path(self) -> Path | None:
  454. if not self.cli_id or not self.cwd:
  455. return None
  456. return cli_jsonl_root() / encode_cwd(self.cwd) / f"{self.cli_id}.jsonl"
  457. def load_sessions(account: Account) -> list[Session]:
  458. out: list[Session] = []
  459. for f in _iter_session_files(account.sessions_dir):
  460. try:
  461. data = json.loads(f.read_text(encoding="utf-8"))
  462. except (json.JSONDecodeError, OSError):
  463. continue
  464. out.append(Session(path=f, data=data, account=account))
  465. return out
  466. def filter_sessions(
  467. sessions: list[Session],
  468. *,
  469. days: int | None,
  470. cwd_pattern: str = "",
  471. title_pattern: str = "",
  472. ) -> list[Session]:
  473. now_ms = int(time.time() * 1000)
  474. cutoff_ms = now_ms - (days * 86_400_000) if days is not None else 0
  475. out = []
  476. for s in sessions:
  477. if s.is_remote:
  478. continue
  479. if days is not None and s.last_activity_ms < cutoff_ms:
  480. continue
  481. if cwd_pattern and cwd_pattern.lower() not in s.cwd.lower():
  482. continue
  483. if title_pattern and title_pattern.lower() not in s.title.lower():
  484. continue
  485. out.append(s)
  486. return sorted(out, key=lambda s: -s.last_activity_ms)
  487. # ============================================================
  488. # Grouping
  489. # ============================================================
  490. _WORKTREE_MARKERS = (
  491. "\\.claude\\worktrees\\",
  492. "/.claude/worktrees/",
  493. )
  494. def project_root(cwd: str) -> str:
  495. for marker in _WORKTREE_MARKERS:
  496. if marker in cwd:
  497. return cwd.split(marker)[0]
  498. return cwd
  499. def relative_under_root(cwd: str, root: str) -> str:
  500. if cwd == root:
  501. return ""
  502. if cwd.startswith(root):
  503. return cwd[len(root):].lstrip("\\/")
  504. return cwd
  505. def worktree_name(cwd: str) -> str:
  506. """If cwd is inside a `.claude/worktrees/<name>/...` path, return <name>; else ''."""
  507. for marker in _WORKTREE_MARKERS:
  508. if marker in cwd:
  509. tail = cwd.split(marker, 1)[1]
  510. # First path segment is the worktree name; strip any deeper subpath.
  511. return tail.split("\\", 1)[0].split("/", 1)[0]
  512. return ""
  513. # ============================================================
  514. # Listing
  515. # ============================================================
  516. def render_hierarchy(sessions: list[Session], *, grouped: bool) -> dict[int, Session]:
  517. """Print sessions; return {1-based-index: session}."""
  518. if grouped:
  519. return _render_grouped(sessions)
  520. index_map: dict[int, Session] = {}
  521. for n, s in enumerate(sessions, 1):
  522. index_map[n] = s
  523. ago = _ago(s.last_activity_ms)
  524. meta = f"{s.turns} turns"
  525. display = f"{s.title} ({s.cwd})"
  526. echo(leaf(n, display, meta=meta, age=ago, depth=1))
  527. return index_map
  528. def _render_grouped(sessions: list[Session]) -> dict[int, Session]:
  529. """3-level hierarchy: Account -> Project -> Session."""
  530. index_map: dict[int, Session] = {}
  531. by_account: "OrderedDict[str, list[Session]]" = OrderedDict()
  532. for s in sessions:
  533. by_account.setdefault(s.account.uuid, []).append(s)
  534. n = 0
  535. for _, acct_sessions in by_account.items():
  536. acct = acct_sessions[0].account
  537. # Group within account by project root
  538. by_project: "OrderedDict[str, list[Session]]" = OrderedDict()
  539. for s in acct_sessions:
  540. by_project.setdefault(project_root(s.cwd), []).append(s)
  541. # Account header
  542. echo(panel_blank())
  543. echo(section(acct.email or "(unknown)", len(acct_sessions), color_token="accent"))
  544. proj_items = list(by_project.items())
  545. for pi, (root, members) in enumerate(proj_items):
  546. is_last_proj = pi == len(proj_items) - 1
  547. sub_func = sub_section_last if is_last_proj else sub_section
  548. echo(sub_func(root, len(members), color_token="default"))
  549. for li, s in enumerate(members):
  550. n += 1
  551. index_map[n] = s
  552. is_last_session = li == len(members) - 1
  553. ago = _ago(s.last_activity_ms)
  554. meta = f"{s.turns}t"
  555. echo(leaf(n, s.title, meta=meta, age=ago,
  556. last=is_last_session, depth=2,
  557. parent_last=is_last_proj))
  558. echo(panel_blank())
  559. return index_map
  560. def _window_label(days: int | None) -> str:
  561. """Render the active time-window filter label."""
  562. if days is None:
  563. return "all time"
  564. if days <= 1:
  565. return "last 24h"
  566. return f"last {days}d"
  567. def _ago(ms: int) -> str:
  568. if ms == 0:
  569. return "?"
  570. delta_s = max(0, int(time.time()) - (ms // 1000))
  571. if delta_s < 60:
  572. return f"{delta_s}s"
  573. if delta_s < 3600:
  574. return f"{delta_s // 60}m"
  575. if delta_s < 86400:
  576. return f"{delta_s // 3600}h"
  577. return f"{delta_s // 86400}d"
  578. # ============================================================
  579. # Picker
  580. # ============================================================
  581. def interactive_pick(sessions: list[Session], *, grouped: bool) -> list[Session]:
  582. if not sessions:
  583. return []
  584. index_map = _render_grouped(sessions) if grouped else render_hierarchy(sessions, grouped=False)
  585. print()
  586. raw = input(Term.color("accent", "select> ")
  587. + "(numbers like '3,5,7', 'a' for all, blank to cancel): ").strip()
  588. if not raw:
  589. return []
  590. if raw.lower() == "a":
  591. return sessions
  592. picks = []
  593. for tok in raw.split(","):
  594. tok = tok.strip()
  595. if not tok:
  596. continue
  597. try:
  598. i = int(tok)
  599. if i in index_map:
  600. picks.append(index_map[i])
  601. except ValueError:
  602. continue
  603. return picks
  604. # ============================================================
  605. # Workspace selection
  606. # ============================================================
  607. def pick_destination_workspace(account: Account) -> Path:
  608. workspaces = [w for w in account.sessions_dir.iterdir() if w.is_dir()]
  609. if not workspaces:
  610. new_ws = account.sessions_dir / str(uuidlib.uuid4())
  611. new_ws.mkdir(parents=True)
  612. return new_ws
  613. workspaces.sort(key=lambda w: -w.stat().st_mtime)
  614. return workspaces[0]
  615. # ============================================================
  616. # Operate
  617. # ============================================================
  618. def summon_session(s: Session, dest_workspace: Path, *, move: bool, dry_run: bool) -> str:
  619. target = dest_workspace / s.path.name
  620. if target.exists():
  621. return "skip (already there)"
  622. if not s.cli_id:
  623. return "skip (no cliSessionId)"
  624. transcript = s.transcript_path()
  625. if transcript and not transcript.exists():
  626. return "skip (transcript missing)"
  627. if dry_run:
  628. return "would " + ("move" if move else "copy")
  629. op = shutil.move if move else shutil.copy2
  630. op(str(s.path), str(target))
  631. return "moved" if move else "copied"
  632. def nudge_watcher(workspace_dir: Path, moved_files: list[Path] | None = None) -> None:
  633. """Force fs.watch to fire on the destination workspace dir.
  634. Desktop's fs.watch is finicky — sometimes it picks up move-in events
  635. immediately, sometimes it doesn't. We throw the kitchen sink at it:
  636. 1. mtime update on each moved file (write event)
  637. 2. Rename ping-pong on each moved file (move-out + move-in events)
  638. 3. Sentinel create+delete in workspace dir (dir-mod event)
  639. 4. Sentinel create+delete in account dir (parent dir-mod event)
  640. 5. mtime update on workspace dir (dir-mod event)
  641. 6. mtime update on account dir (parent dir-mod event)
  642. All paths are tried; failures are silent.
  643. Empirically: even with all of these, Desktop's renderer may still
  644. require a Logout -> Login cycle to refresh the sidebar. That's
  645. documented in SKILL.md as the canonical fallback.
  646. """
  647. now = time.time()
  648. account_dir = workspace_dir.parent
  649. # 1. mtime update on moved files
  650. for f in (moved_files or []):
  651. try:
  652. os.utime(f, (now, now))
  653. except OSError:
  654. pass
  655. # 2. Rename ping-pong on moved files
  656. for f in (moved_files or []):
  657. if not f.exists():
  658. continue
  659. tmp = f.with_name(f.name + ".summon-tmp")
  660. try:
  661. f.rename(tmp)
  662. tmp.rename(f)
  663. except OSError:
  664. try:
  665. if tmp.exists():
  666. tmp.rename(f)
  667. except OSError:
  668. pass
  669. # 3 + 4. Sentinel pings at workspace AND account level
  670. for parent in (workspace_dir, account_dir):
  671. sentinel = parent / f".summon-nudge-{uuidlib.uuid4().hex[:8]}"
  672. try:
  673. sentinel.touch()
  674. sentinel.unlink()
  675. except OSError:
  676. pass
  677. # 5 + 6. mtime touch on workspace and account dirs
  678. for d in (workspace_dir, account_dir):
  679. try:
  680. os.utime(d, (now, now))
  681. except OSError:
  682. pass
  683. # ============================================================
  684. # Peek
  685. # ============================================================
  686. def find_session_by_id(query: str, accounts: list[Account]) -> Session | None:
  687. q = query.lower().removeprefix("local_")
  688. for acct in accounts:
  689. for s in load_sessions(acct):
  690. sid = s.sid.lower().removeprefix("local_")
  691. cli = s.cli_id.lower()
  692. if sid == q or cli == query.lower():
  693. return s
  694. if sid.startswith(q) or cli.startswith(q):
  695. return s
  696. return None
  697. def peek_session(query: str, accounts: list[Account], turns: int = 3) -> int:
  698. s = find_session_by_id(query, accounts)
  699. if not s:
  700. echo(panel_open(f"summon {Term.g('·', '|')} peek", indicator="not found"))
  701. echo(panel_blank())
  702. echo(f" no session matching: {Term.color('alarm', query)}")
  703. echo(panel_blank())
  704. echo(panel_close())
  705. return 1
  706. transcript = s.transcript_path()
  707. if not transcript or not transcript.exists():
  708. echo(panel_open(f"summon {Term.g('·', '|')} peek", indicator=f"{s.account.short} {Term.g('·', '|')} {s.title}"))
  709. echo(panel_blank())
  710. echo(f" transcript missing: {Term.color('alarm', str(transcript))}")
  711. echo(panel_blank())
  712. echo(panel_close())
  713. return 2
  714. exchanges: list[tuple[str, str]] = []
  715. try:
  716. with transcript.open("r", encoding="utf-8") as f:
  717. for line in f:
  718. try:
  719. rec = json.loads(line)
  720. except json.JSONDecodeError:
  721. continue
  722. t = rec.get("type")
  723. if t not in ("user", "assistant"):
  724. continue
  725. msg = rec.get("message", {})
  726. text = _extract_text(msg.get("content"))
  727. if text:
  728. exchanges.append((t, text))
  729. except OSError as e:
  730. echo(panel_open(f"summon {Term.g('·', '|')} peek", indicator="read error"))
  731. echo(panel_blank())
  732. echo(f" {Term.color('alarm', str(e))}")
  733. echo(panel_blank())
  734. echo(panel_close())
  735. return 2
  736. indicator = f"{s.account.email or s.account.short}"
  737. echo(panel_open(f"summon {Term.g('·', '|')} peek", indicator=indicator))
  738. echo(panel_blank())
  739. sep = Term.g("·", "|")
  740. echo(summary_line(f"{s.title!r} {s.cwd}"))
  741. echo(summary_line(f"{s.turns} turns {sep} last activity {_ago(s.last_activity_ms)}"))
  742. echo(panel_blank())
  743. if not exchanges:
  744. echo(f" {Term.color('meta', '(transcript has no readable user/assistant messages)')}")
  745. echo(panel_blank())
  746. echo(panel_close())
  747. return 0
  748. tail = exchanges[-(turns * 2):]
  749. echo(section(f"last {len(tail)} message(s)", color_token="accent"))
  750. for role, text in tail:
  751. marker = Term.color("accent", ">>") if role == "user" else Term.color("ok", "<<")
  752. snippet = text.strip().replace("\n", " ")
  753. if len(snippet) > 600:
  754. snippet = snippet[:597] + "..."
  755. echo(panel_blank())
  756. # Wrap to 70 chars per line
  757. words = snippet.split(" ")
  758. line_width = 70
  759. line = ""
  760. first = True
  761. for w in words:
  762. candidate = (line + " " + w) if line else w
  763. if len(candidate) <= line_width:
  764. line = candidate
  765. else:
  766. pipe = Term.g("│", "|")
  767. lead = f"{pipe} {marker} " if first else f"{pipe} "
  768. echo(f"{lead}{line}")
  769. line = w
  770. first = False
  771. if line:
  772. pipe = Term.g("│", "|")
  773. lead = f"{pipe} {marker} " if first else f"{pipe} "
  774. echo(f"{lead}{line}")
  775. echo(panel_blank())
  776. echo(panel_close(hotkeys=[("q", "quit")]))
  777. return 0
  778. def _extract_text(content) -> str:
  779. if isinstance(content, str):
  780. return content
  781. if isinstance(content, list):
  782. chunks = []
  783. for block in content:
  784. if isinstance(block, dict):
  785. t = block.get("type")
  786. if t == "text":
  787. chunks.append(block.get("text", ""))
  788. elif t == "tool_use":
  789. chunks.append(f"[tool_use: {block.get('name', '?')}]")
  790. elif t == "tool_result":
  791. chunks.append("[tool_result]")
  792. return " ".join(chunks)
  793. return ""
  794. # ============================================================
  795. # Toolbox modes: rebind / pick / recover / doctor
  796. # ============================================================
  797. def eecho(*lines):
  798. """echo() to stderr — context/panels for modes whose stdout is the data product."""
  799. if not lines:
  800. lines = ("",)
  801. for line in lines:
  802. try:
  803. print(line, file=sys.stderr)
  804. except UnicodeEncodeError:
  805. enc = getattr(sys.stderr, "encoding", None) or "ascii"
  806. print(str(line).encode(enc, errors="replace").decode(enc, errors="replace"),
  807. file=sys.stderr)
  808. def resolve_transcript(s: Session) -> tuple[Path | None, str]:
  809. """Locate a session's transcript JSONL.
  810. Returns (path, how) with how in:
  811. "expected" — at ~/.claude/projects/<enc(cwd)>/<cliSessionId>.jsonl
  812. "scanned" — found by scanning all project dirs for <cliSessionId>.jsonl
  813. (the wrapper-uuid != transcript-filename trap: the transcript
  814. is named by cliSessionId, and may live under a munged dir that
  815. doesn't derive from the wrapper's recorded cwd)
  816. "" — not found anywhere (path is None)
  817. """
  818. if not s.cli_id:
  819. return None, ""
  820. expected = s.transcript_path()
  821. if expected and expected.exists():
  822. return expected, "expected"
  823. hits = sorted(cli_jsonl_root().glob(f"*/{s.cli_id}.jsonl"))
  824. if hits:
  825. return hits[0], "scanned"
  826. return None, ""
  827. # Boilerplate the first-ask sniffer must skip: slash-command echoes, skill
  828. # preambles, interrupt markers — none of which are the session's real opening ask.
  829. _ASK_SKIP_MARKERS = (
  830. "command-name", "local-command", "Sync - Session Bootstrap",
  831. "Base directory for this skill", "[Request interrupted",
  832. )
  833. def _ts_ms(value: str) -> int | None:
  834. try:
  835. return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000)
  836. except (ValueError, AttributeError):
  837. return None
  838. def analyze_transcript(path: Path | None, buckets: int = 24) -> dict:
  839. """Stream a transcript JSONL once and derive picker display metrics.
  840. Returns events / toolCalls / density buckets / durationMin / sizeKB /
  841. ctxTokens (last-turn context occupancy, matching Claude Code's live meter) /
  842. ctxPeak (max occupancy before any auto-compaction) / firstAsk. Every field
  843. degrades to a zero/empty default on a missing or unreadable transcript, so
  844. the picker still renders — just without the extras. One pass, so a large
  845. transcript costs one linear read, never a re-scan.
  846. """
  847. out = {"events": 0, "toolCalls": 0, "buckets": [0] * buckets,
  848. "durationMin": 0, "sizeKB": 0, "ctxTokens": 0, "ctxPeak": 0,
  849. "firstAsk": ""}
  850. if not path or not path.exists():
  851. return out
  852. try:
  853. out["sizeKB"] = round(path.stat().st_size / 1024)
  854. except OSError:
  855. pass
  856. stamps: list[int] = []
  857. last_ctx = peak_ctx = 0
  858. try:
  859. with path.open(encoding="utf-8") as fh:
  860. for line in fh:
  861. line = line.strip()
  862. if not line:
  863. continue
  864. out["events"] += 1
  865. try:
  866. obj = json.loads(line)
  867. except json.JSONDecodeError:
  868. continue
  869. stamp = obj.get("timestamp")
  870. if stamp:
  871. ms = _ts_ms(stamp)
  872. if ms:
  873. stamps.append(ms)
  874. msg = obj.get("message")
  875. msg = msg if isinstance(msg, dict) else {}
  876. usage = msg.get("usage")
  877. if isinstance(usage, dict):
  878. occ = (usage.get("input_tokens", 0)
  879. + usage.get("cache_creation_input_tokens", 0)
  880. + usage.get("cache_read_input_tokens", 0)
  881. + usage.get("output_tokens", 0))
  882. if occ:
  883. last_ctx = occ
  884. peak_ctx = max(peak_ctx, occ)
  885. typ = obj.get("type")
  886. if typ == "assistant":
  887. content = msg.get("content")
  888. if isinstance(content, list):
  889. out["toolCalls"] += sum(
  890. 1 for p in content
  891. if isinstance(p, dict) and p.get("type") == "tool_use")
  892. elif typ == "user" and not out["firstAsk"]:
  893. content = msg.get("content")
  894. if isinstance(content, str):
  895. txt = content
  896. elif isinstance(content, list):
  897. txt = "".join(p.get("text", "") for p in content
  898. if isinstance(p, dict) and p.get("type") == "text")
  899. else:
  900. txt = ""
  901. txt = txt.strip()
  902. if txt and not txt.startswith("<") and not any(
  903. m in txt for m in _ASK_SKIP_MARKERS):
  904. out["firstAsk"] = txt[:280]
  905. except OSError:
  906. return out
  907. out["ctxTokens"] = last_ctx
  908. out["ctxPeak"] = peak_ctx
  909. if stamps:
  910. lo, hi = min(stamps), max(stamps)
  911. span = hi - lo
  912. out["durationMin"] = round(span / 60000)
  913. counts = [0] * buckets
  914. for ms in stamps:
  915. idx = 0 if span <= 0 else min(buckets - 1, int((ms - lo) / span * buckets))
  916. counts[idx] += 1
  917. out["buckets"] = counts
  918. return out
  919. def find_wrappers_by_id(query: str, accounts: list[Account]) -> tuple[list[Session], bool]:
  920. """All wrapper files for one logical session (copies may exist in several accounts).
  921. Matches sessionId or cliSessionId, exact first, then prefix. Returns
  922. (matches, ambiguous): ambiguous=True when a prefix hits MORE than one
  923. distinct logical session.
  924. """
  925. q = query.lower().removeprefix("local_")
  926. exact: list[Session] = []
  927. prefix: list[Session] = []
  928. for acct in accounts:
  929. for s in load_sessions(acct):
  930. sid = s.sid.lower().removeprefix("local_")
  931. cli = s.cli_id.lower()
  932. if sid == q or cli == q:
  933. exact.append(s)
  934. elif sid.startswith(q) or cli.startswith(q):
  935. prefix.append(s)
  936. matches = exact or prefix
  937. logical = {s.sid for s in matches}
  938. return matches, len(logical) > 1
  939. def _rebase_path(old_val: str, old_cwd: str, new_cwd: str) -> str:
  940. """Rebase a sibling path field (originCwd/worktreePath) after cwd moves.
  941. Worktree sessions record originCwd as the project ROOT while cwd is the
  942. worktree path under it — so a plain prefix replace isn't enough:
  943. old_val == old_cwd -> new_cwd
  944. old_cwd == old_val + suffix (root case) -> strip the same suffix off new_cwd
  945. old_val == old_cwd + suffix (child) -> new_cwd + suffix
  946. Anything else is left untouched.
  947. """
  948. if not old_val:
  949. return old_val
  950. if old_val == old_cwd:
  951. return new_cwd
  952. if old_cwd.startswith(old_val):
  953. suffix = old_cwd[len(old_val):]
  954. if suffix and new_cwd.endswith(suffix):
  955. return new_cwd[: len(new_cwd) - len(suffix)]
  956. if old_val.startswith(old_cwd):
  957. return new_cwd + old_val[len(old_cwd):]
  958. return old_val
  959. def mode_rebind(args, accounts: list[Account]) -> int:
  960. """Fix a session's recorded cwd after a folder move.
  961. Backs up each wrapper OUTSIDE the live store, atomically rewrites
  962. cwd/originCwd/worktreePath, bridges the transcript into the new munged
  963. project dir (Desktop resolves it via enc(cwd)), and verifies by re-read.
  964. """
  965. if not args.target:
  966. eecho("usage: summon rebind <id> --cwd <newpath>")
  967. return 2
  968. if not args.cwd:
  969. eecho("rebind needs --cwd <newpath> — the folder's new location")
  970. return 2
  971. new_path = Path(args.cwd)
  972. if new_path.exists():
  973. new_cwd = str(new_path.resolve())
  974. elif args.force:
  975. new_cwd = str(new_path)
  976. else:
  977. eecho(f"new cwd does not exist on disk: {args.cwd}",
  978. "(pass --force to rebind to a not-yet-existing path)")
  979. return 3
  980. matches, ambiguous = find_wrappers_by_id(args.target, accounts)
  981. if not matches:
  982. eecho(f"no session matching: {args.target}")
  983. return 3
  984. if ambiguous:
  985. eecho(f"'{args.target}' matches {len({m.sid for m in matches})} different sessions — be more specific:")
  986. for m in matches[:8]:
  987. eecho(f" {m.sid} {m.title!r} {m.cwd}")
  988. return 2
  989. stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
  990. backup_root = Path.home() / ".claude" / "summon-backups" / stamp
  991. echo(panel_open(f"summon {Term.g('·', '|')} rebind",
  992. indicator="dry-run" if args.dry_run else matches[0].sid[:14]))
  993. echo(panel_blank())
  994. old_cwd = matches[0].cwd
  995. echo(summary_line(f"{old_cwd}"))
  996. echo(summary_line(f"{Term.g('→', '->')} {new_cwd}"))
  997. echo(panel_blank())
  998. problems = 0
  999. transcript_note = ""
  1000. for i, s in enumerate(matches):
  1001. is_last = i == len(matches) - 1
  1002. label = f"{s.account.short}/{s.path.name}"
  1003. if args.dry_run:
  1004. echo(leaf(0, label, meta=Term.color("meta", "would rebind"),
  1005. last=is_last, depth=1))
  1006. continue
  1007. # 1. Backup outside the live store
  1008. backup_root.mkdir(parents=True, exist_ok=True)
  1009. backup = backup_root / f"{s.account.uuid}__{s.path.parent.name}__{s.path.name}"
  1010. shutil.copy2(s.path, backup)
  1011. # 2. Atomic rewrite
  1012. data = dict(s.data)
  1013. data["cwd"] = new_cwd
  1014. for field in ("originCwd", "worktreePath"):
  1015. if field in data:
  1016. data[field] = _rebase_path(str(data[field] or ""), s.cwd, new_cwd)
  1017. tmp = s.path.with_name(s.path.name + ".tmp")
  1018. tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
  1019. os.replace(tmp, s.path)
  1020. # 3. Verify by re-read
  1021. try:
  1022. reread = json.loads(s.path.read_text(encoding="utf-8"))
  1023. except (json.JSONDecodeError, OSError):
  1024. reread = {}
  1025. if reread.get("cwd") != new_cwd:
  1026. problems += 1
  1027. shutil.copy2(backup, s.path) # restore from backup
  1028. echo(leaf(0, label, meta=Term.color("alarm", "verify FAILED — restored"),
  1029. last=is_last, depth=1))
  1030. continue
  1031. echo(leaf(0, label, meta=Term.color("ok", "rebound"), last=is_last, depth=1))
  1032. # 4. Transcript bridge — Desktop looks in enc(new cwd) after the rebind
  1033. s0 = matches[0]
  1034. if s0.cli_id:
  1035. old_transcript, how = resolve_transcript(s0) # s0.data still holds OLD cwd
  1036. new_dir = cli_jsonl_root() / encode_cwd(new_cwd)
  1037. new_transcript = new_dir / f"{s0.cli_id}.jsonl"
  1038. if new_transcript.exists():
  1039. transcript_note = "transcript already at new path"
  1040. elif not old_transcript:
  1041. transcript_note = "transcript missing everywhere — session may not reopen"
  1042. problems += 1
  1043. elif args.no_transcript:
  1044. transcript_note = f"transcript NOT copied (--no-transcript): {old_transcript}"
  1045. elif args.dry_run:
  1046. transcript_note = f"would copy transcript {Term.g('→', '->')} {new_transcript}"
  1047. else:
  1048. new_dir.mkdir(parents=True, exist_ok=True)
  1049. shutil.copy2(old_transcript, new_transcript)
  1050. transcript_note = f"transcript copied ({how}) {Term.g('→', '->')} {new_transcript}"
  1051. echo(panel_blank())
  1052. if transcript_note:
  1053. echo(summary_line(transcript_note))
  1054. if not args.dry_run:
  1055. echo(summary_line(f"backup: {backup_root}"))
  1056. echo(panel_blank())
  1057. healths = [("ok", f"{len(matches)} wrapper(s)")]
  1058. if problems:
  1059. healths.append(("alarm", f"{problems} problem(s)"))
  1060. echo(panel_close(healths=healths))
  1061. if not args.dry_run and not problems:
  1062. echo()
  1063. echo(Term.color("warn",
  1064. "next: restart Desktop (or Logout/Login) so the sidebar re-reads the wrapper."))
  1065. if any(m in new_cwd for m in _WORKTREE_MARKERS):
  1066. echo(Term.color("meta",
  1067. " git worktree links break on folder moves — run "
  1068. f"`git worktree repair {new_cwd}` from the repo root."))
  1069. return 1 if problems else 0
  1070. _RECOVERY_INSTRUCTION = (
  1071. "First read only the TAIL of the transcript (last ~150-200 lines) to see where "
  1072. "it left off — do not ingest the whole file; read earlier chunks selectively "
  1073. "only if something is unclear. Then: summarize the session state in a few "
  1074. "bullets (goal, what's done, what was in flight, blockers), and continue the "
  1075. "work from there in the current directory."
  1076. )
  1077. # --- Distilled handover: extract -> distill -> cache -> emit -----------------
  1078. EXTRACT_BUDGET_DEFAULT = 120_000 # chars of conversation fed to the distiller
  1079. VERBATIM_TAIL_TURNS = 15 # final turns always included in full
  1080. HEAD_TURN_CAP = 4_000 # per-turn cap for pre-tail turns (giant pastes)
  1081. DISTILL_TIMEOUT_S = 60
  1082. DISTILL_MODEL_DEFAULT = "sonnet"
  1083. _DISTILL_INSTRUCTION = """\
  1084. You are writing a HANDOVER BRIEF so a fresh Claude session can continue an \
  1085. interrupted one. Below is the previous session's conversation with tool noise \
  1086. removed; the final turns are verbatim.
  1087. Produce ONLY the brief, in markdown, with exactly these sections:
  1088. ## Goal
  1089. ## What landed
  1090. ## Unfinished
  1091. ## Open decisions
  1092. ## Key context
  1093. Rules:
  1094. - "What landed" names the branch and any commits/hashes if mentioned.
  1095. - Be specific: file paths, branch names, commands, decisions.
  1096. - Do not invent anything not present in the conversation.
  1097. - No preamble, no closing remarks, no tool use.
  1098. - Keep the entire brief under about 1000 words.
  1099. """
  1100. def _text_only(content) -> str:
  1101. """Conversational text only — tool_use inputs and tool_result blobs are
  1102. skipped entirely (they are most of a transcript's bytes)."""
  1103. if isinstance(content, str):
  1104. return content
  1105. if isinstance(content, list):
  1106. chunks = []
  1107. for block in content:
  1108. if isinstance(block, dict) and block.get("type") == "text":
  1109. t = block.get("text", "")
  1110. if t:
  1111. chunks.append(t)
  1112. return "\n".join(chunks)
  1113. return ""
  1114. def extract_conversation(transcript: Path, budget: int = EXTRACT_BUDGET_DEFAULT) -> str:
  1115. """Build the distillation input from a transcript JSONL — in-script, no LLM.
  1116. User/assistant text turns only. The final VERBATIM_TAIL_TURNS turns are
  1117. always included in full; earlier turns fill the remaining budget from the
  1118. START (so the goal statement survives), with the middle elided when the
  1119. session is too long to fit.
  1120. """
  1121. turns: list[tuple[str, str]] = []
  1122. try:
  1123. with transcript.open("r", encoding="utf-8", errors="replace") as f:
  1124. for line in f:
  1125. try:
  1126. rec = json.loads(line)
  1127. except json.JSONDecodeError:
  1128. continue
  1129. role = rec.get("type")
  1130. if role not in ("user", "assistant"):
  1131. continue
  1132. text = _text_only(rec.get("message", {}).get("content")).strip()
  1133. if text:
  1134. turns.append((role, text))
  1135. except OSError:
  1136. return ""
  1137. if not turns:
  1138. return ""
  1139. def fmt(role: str, text: str) -> str:
  1140. return f"{role.upper()}: {text}"
  1141. tail = turns[-VERBATIM_TAIL_TURNS:]
  1142. head = turns[:-VERBATIM_TAIL_TURNS]
  1143. tail_block = "\n\n".join(fmt(r, t) for r, t in tail)
  1144. if len(tail_block) >= budget:
  1145. return tail_block[-budget:] # most recent state wins
  1146. remaining = budget - len(tail_block)
  1147. head_parts: list[str] = []
  1148. elided = not head
  1149. for r, t in head:
  1150. if len(t) > HEAD_TURN_CAP:
  1151. t = t[:HEAD_TURN_CAP] + " …[turn truncated]"
  1152. piece = fmt(r, t)
  1153. cost = len(piece) + 2
  1154. if cost > remaining:
  1155. elided = True
  1156. break
  1157. head_parts.append(piece)
  1158. remaining -= cost
  1159. parts = list(head_parts)
  1160. if elided and head_parts:
  1161. parts.append("[… middle of session elided to fit extraction budget …]")
  1162. parts.append(tail_block)
  1163. return "\n\n".join(parts)
  1164. def handover_cache_path(transcript: Path) -> Path:
  1165. return transcript.with_name(transcript.name + ".handover.md")
  1166. def load_cached_brief(transcript: Path, *, refresh: bool) -> str | None:
  1167. """Cached brief, valid only when newer than the transcript itself."""
  1168. if refresh:
  1169. return None
  1170. cache = handover_cache_path(transcript)
  1171. try:
  1172. if cache.exists() and cache.stat().st_mtime > transcript.stat().st_mtime:
  1173. text = cache.read_text(encoding="utf-8").strip()
  1174. return text or None
  1175. except OSError:
  1176. pass
  1177. return None
  1178. def store_brief(transcript: Path, brief: str) -> None:
  1179. cache = handover_cache_path(transcript)
  1180. tmp = cache.with_name(cache.name + ".tmp")
  1181. try:
  1182. tmp.write_text(brief + "\n", encoding="utf-8")
  1183. os.replace(tmp, cache)
  1184. except OSError as e:
  1185. eecho(f"warning: could not cache handover brief at {cache}: {e}")
  1186. def distill_brief(extraction: str, s: Session, model: str) -> str | None:
  1187. """One-shot tool-less `claude -p` summarisation (gated child: dontAsk,
  1188. no allowlist — per loop-engineering, never bypassPermissions).
  1189. Returns None on any worker unavailability — absent CLI, non-zero exit,
  1190. timeout, empty output — with a stderr warning. Advisory, never fatal.
  1191. """
  1192. claude_bin = shutil.which("claude")
  1193. if not claude_bin:
  1194. eecho("warning: `claude` CLI not on PATH — emitting non-distilled pointer prompt")
  1195. return None
  1196. branch = str(s.data.get("branch") or "")
  1197. payload = (
  1198. _DISTILL_INSTRUCTION
  1199. + "\n--- SESSION METADATA ---\n"
  1200. + f"Title: {s.title}\nBranch: {branch or '(none)'}\nCwd: {s.cwd}\n"
  1201. + "\n--- CONVERSATION EXTRACTION ---\n"
  1202. + extraction
  1203. )
  1204. import subprocess
  1205. eecho(f"distilling handover brief via `claude -p --model {model}` "
  1206. f"({len(extraction)} chars in, ~{DISTILL_TIMEOUT_S}s timeout)…")
  1207. try:
  1208. r = subprocess.run(
  1209. [claude_bin, "-p", "--model", model, "--permission-mode", "dontAsk"],
  1210. input=payload.encode("utf-8"),
  1211. stdout=subprocess.PIPE, stderr=subprocess.PIPE,
  1212. timeout=DISTILL_TIMEOUT_S)
  1213. except subprocess.TimeoutExpired:
  1214. eecho(f"warning: distillation timed out after {DISTILL_TIMEOUT_S}s — "
  1215. "emitting non-distilled pointer prompt")
  1216. return None
  1217. except OSError as e:
  1218. eecho(f"warning: could not run `claude`: {e} — emitting non-distilled pointer prompt")
  1219. return None
  1220. if r.returncode != 0:
  1221. tail = r.stderr.decode("utf-8", "replace").strip()[-200:]
  1222. eecho(f"warning: `claude -p` exited {r.returncode}"
  1223. + (f" ({tail})" if tail else "")
  1224. + " — emitting non-distilled pointer prompt")
  1225. return None
  1226. brief = r.stdout.decode("utf-8", "replace").strip()
  1227. if not brief:
  1228. eecho("warning: distillation produced no output — emitting non-distilled pointer prompt")
  1229. return None
  1230. return brief
  1231. def _pointer_clause(s: Session, transcript: Path) -> str:
  1232. branch = str(s.data.get("branch") or "")
  1233. ident = s.sid.removeprefix("local_") or s.cli_id
  1234. branch_part = f", branch {branch}" if branch else ""
  1235. return (f"Full transcript at {transcript} (session {ident}{branch_part}); "
  1236. "consult it only if something specific is missing.")
  1237. def emit_recovery_prompt(s: Session, args) -> int:
  1238. """Print a paste-ready recovery prompt for a new session (stdout = the prompt).
  1239. Default flow: extract conversation -> distill via `claude -p` -> cache the
  1240. brief at <transcript>.handover.md -> emit brief + pointer clause. Falls
  1241. back to the plain pointer prompt when distillation is unavailable/disabled.
  1242. """
  1243. transcript, how = resolve_transcript(s)
  1244. sep = Term.g("·", "|")
  1245. eecho(panel_open(f"summon {Term.g('·', '|')} recover", indicator=s.account.short))
  1246. eecho(panel_blank())
  1247. eecho(summary_line(f"{s.title!r} {s.turns}t {sep} {_ago(s.last_activity_ms)}"))
  1248. if how == "scanned":
  1249. eecho(summary_line("transcript found by scan — wrapper cwd does not derive its location"))
  1250. eecho(panel_blank())
  1251. eecho(panel_close(healths=[("ok", "prompt on stdout")] if transcript
  1252. else [("alarm", "no transcript")]))
  1253. if not transcript:
  1254. eecho(f"no transcript found for cliSessionId {s.cli_id or '(none)'} — cannot recover")
  1255. return 3
  1256. branch = str(s.data.get("branch") or "")
  1257. # --- Distilled handover path ---
  1258. if not getattr(args, "no_distill", False):
  1259. brief = load_cached_brief(transcript, refresh=getattr(args, "refresh", False))
  1260. if brief:
  1261. eecho(f"reusing cached handover brief: {handover_cache_path(transcript)} "
  1262. "(--refresh to re-distill)")
  1263. else:
  1264. extraction = extract_conversation(
  1265. transcript, getattr(args, "budget", EXTRACT_BUDGET_DEFAULT))
  1266. if extraction:
  1267. brief = distill_brief(extraction, s,
  1268. getattr(args, "model", DISTILL_MODEL_DEFAULT))
  1269. if brief:
  1270. store_brief(transcript, brief)
  1271. else:
  1272. eecho("warning: transcript has no conversational text — "
  1273. "emitting non-distilled pointer prompt")
  1274. if brief:
  1275. lines = [f"Continue a previous Claude session: {s.title!r}."]
  1276. if branch:
  1277. lines.append(f"Branch: {branch}")
  1278. lines += ["", brief, "", _pointer_clause(s, transcript)]
  1279. for line in lines:
  1280. _print_safe(line)
  1281. return 0
  1282. # --- Fallback: non-distilled pointer prompt ---
  1283. cwd_note = ""
  1284. if s.cwd and not s.cwd.startswith("/sessions/") and not Path(s.cwd).exists():
  1285. cwd_note = " (missing on disk — folder moved?)"
  1286. lines = [
  1287. "Continue a previous Claude session.",
  1288. "",
  1289. f"Title: {s.title}",
  1290. ]
  1291. if branch:
  1292. lines.append(f"Branch: {branch}")
  1293. lines.append(f"Orig cwd: {s.cwd}{cwd_note}")
  1294. lines.append(f"Transcript: {transcript}")
  1295. lines += ["", _RECOVERY_INSTRUCTION]
  1296. for line in lines:
  1297. _print_safe(line)
  1298. return 0
  1299. def mode_recover(args, accounts: list[Account]) -> int:
  1300. if not args.target:
  1301. eecho("usage: summon recover <id> (sessionId or cliSessionId, prefix ok)")
  1302. return 2
  1303. matches, ambiguous = find_wrappers_by_id(args.target, accounts)
  1304. if not matches:
  1305. eecho(f"no session matching: {args.target}")
  1306. return 3
  1307. if ambiguous:
  1308. eecho(f"'{args.target}' matches {len({m.sid for m in matches})} different sessions — be more specific:")
  1309. for m in matches[:8]:
  1310. eecho(f" {m.sid} {m.title!r} {m.cwd}")
  1311. return 2
  1312. return emit_recovery_prompt(matches[0], args)
  1313. def _is_live(s: Session, now_ms: int) -> bool:
  1314. """Heuristic 'running state': wrapper touched within the last 10 minutes."""
  1315. recent = now_ms - 10 * 60_000
  1316. if s.last_activity_ms >= recent:
  1317. return True
  1318. try:
  1319. return int(s.path.stat().st_mtime * 1000) >= recent
  1320. except OSError:
  1321. return False
  1322. def _cwd_broken(s: Session) -> bool:
  1323. """Doctor's broken-binding check: recorded cwd no longer exists on disk."""
  1324. return not (bool(s.cwd) and Path(s.cwd).exists())
  1325. def _iso_utc(ms: int) -> str:
  1326. """Epoch-ms -> ISO-8601 Z, '' for the unset 0."""
  1327. if not ms:
  1328. return ""
  1329. return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ms / 1000))
  1330. def session_rows(candidates: list[Session], now_ms: int, rich: bool = False) -> list[dict]:
  1331. """Build the pick inventory rows for a set of sessions (in-process).
  1332. The single source of truth for a session's machine-readable shape — shared
  1333. by ``pick --json`` (which wraps these in an envelope) and ``widget`` (which
  1334. trims them and injects them into the card-picker template). Keeping it one
  1335. function means the widget builder never has to shell out to ``pick`` or
  1336. re-read a transcript that ``pick`` already read.
  1337. With ``rich`` each row gains transcript-derived display metrics (context
  1338. occupancy, activity density, event/tool counts, size, duration, opening
  1339. ask) at the cost of one linear transcript read per session; without it the
  1340. rows are metadata-only and instant.
  1341. """
  1342. rows = []
  1343. for s in candidates:
  1344. transcript = resolve_transcript(s)[0]
  1345. row = {
  1346. "id": s.sid.removeprefix("local_")[:8],
  1347. "sessionId": s.sid,
  1348. "cliSessionId": s.cli_id,
  1349. "title": s.title,
  1350. "cwd": s.cwd,
  1351. "projectRoot": project_root(s.cwd),
  1352. "worktree": worktree_name(s.cwd),
  1353. "branch": str(s.data.get("branch") or ""),
  1354. "model": str(s.data.get("model") or ""),
  1355. "effort": str(s.data.get("effort") or ""),
  1356. "turns": s.turns,
  1357. "isArchived": bool(s.data.get("isArchived")),
  1358. "isRunning": _is_live(s, now_ms),
  1359. "brokenCwd": _cwd_broken(s),
  1360. "lastActivityAt": _iso_utc(s.last_activity_ms),
  1361. "account": s.account.short,
  1362. "accountEmail": s.account.email,
  1363. "transcriptPath": str(transcript) if transcript else None,
  1364. }
  1365. if rich:
  1366. m = analyze_transcript(transcript)
  1367. window = 1_000_000 if m["ctxPeak"] > 200_000 else 200_000
  1368. row.update({
  1369. "events": m["events"],
  1370. "toolCalls": m["toolCalls"],
  1371. "densityBuckets": m["buckets"],
  1372. "durationMin": m["durationMin"],
  1373. "sizeKB": m["sizeKB"],
  1374. "ctxTokens": m["ctxTokens"],
  1375. "ctxPeak": m["ctxPeak"],
  1376. "ctxWindow": window,
  1377. "ctxPct": round(min(100, m["ctxTokens"] / window * 100)) if window else 0,
  1378. "ctxPeakPct": round(min(100, m["ctxPeak"] / window * 100)) if window else 0,
  1379. "firstAsk": m["firstAsk"],
  1380. })
  1381. rows.append(row)
  1382. return rows
  1383. def pick_json(candidates: list[Session], now_ms: int, rich: bool = False) -> int:
  1384. """`pick --json` — the session inventory as a claude-mods.summon.pick
  1385. envelope on stdout (JSON only; panels never touch stdout on this path).
  1386. Feeds the in-chat visual card picker (assets/picker-widget.html) and any
  1387. other scripted caller. An empty inventory is valid data, not an error:
  1388. exit 0.
  1389. With ``rich`` (``--rich``) the schema advances to pick/v2 and each row
  1390. gains transcript-derived display metrics (see ``session_rows``).
  1391. """
  1392. rows = session_rows(candidates, now_ms, rich)
  1393. print(json.dumps({
  1394. "data": rows,
  1395. "meta": {"count": len(rows),
  1396. "schema": "claude-mods.summon.pick/v2" if rich else
  1397. "claude-mods.summon.pick/v1"},
  1398. }, indent=2))
  1399. return 0
  1400. def mode_pick(args, accounts: list[Account]) -> int:
  1401. """Interactive picker over the whole session store -> recovery prompt."""
  1402. sessions: list[Session] = []
  1403. for acct in accounts:
  1404. sessions.extend(load_sessions(acct))
  1405. days = None if args.all else (args.days if args.days is not None else 30)
  1406. candidates = filter_sessions(sessions, days=days,
  1407. cwd_pattern=args.cwd, title_pattern=args.title)
  1408. if args.json:
  1409. return pick_json(candidates, int(time.time() * 1000), rich=args.rich)
  1410. if not candidates:
  1411. eecho(f"no sessions match ({_window_label(days)})")
  1412. return 3
  1413. now_ms = int(time.time() * 1000)
  1414. # fzf path — only when genuinely interactive and not answered via --select
  1415. use_fzf = (not args.select and shutil.which("fzf")
  1416. and sys.stdin.isatty() and sys.stdout.isatty())
  1417. if use_fzf:
  1418. rows = []
  1419. for i, s in enumerate(candidates, 1):
  1420. live = "● " if _is_live(s, now_ms) else " "
  1421. rows.append(f"{i}\t{live}{s.title}\t{s.cwd}\t{_ago(s.last_activity_ms)}\t{s.turns}t")
  1422. import subprocess
  1423. r = subprocess.run(
  1424. ["fzf", "--delimiter", "\t", "--with-nth", "2,3,4,5",
  1425. "--height", "60%", "--reverse",
  1426. "--prompt", "recover> ",
  1427. "--header", "● = active in last 10m — pick a session to build a recovery prompt"],
  1428. input="\n".join(rows).encode("utf-8"), stdout=subprocess.PIPE)
  1429. if r.returncode != 0 or not r.stdout.strip():
  1430. eecho("cancelled.")
  1431. return 0
  1432. idx = int(r.stdout.decode("utf-8", "replace").split("\t", 1)[0])
  1433. return emit_recovery_prompt(candidates[idx - 1], args)
  1434. # Fallback: numbered list (stderr), selection via --select or stdin
  1435. eecho(panel_open(f"summon {Term.g('·', '|')} pick",
  1436. indicator=_window_label(days)))
  1437. eecho(panel_blank())
  1438. for i, s in enumerate(candidates, 1):
  1439. live = Term.g("●", "*") + " " if _is_live(s, now_ms) else " "
  1440. eecho(leaf(i, f"{live}{s.title}", meta=f"{s.turns}t",
  1441. age=_ago(s.last_activity_ms),
  1442. last=(i == len(candidates)), depth=1))
  1443. eecho(panel_blank())
  1444. eecho(panel_close(hotkeys=[("#", "select"), ("blank", "cancel")]))
  1445. raw = args.select
  1446. if not raw:
  1447. try:
  1448. # Prompt on stderr — stdout stays clean for the recovery prompt.
  1449. print("recover> (number, blank to cancel): ", end="", file=sys.stderr, flush=True)
  1450. raw = input().strip()
  1451. except EOFError:
  1452. raw = ""
  1453. if not raw:
  1454. eecho("cancelled.")
  1455. return 0
  1456. try:
  1457. idx = int(raw.split(",")[0].strip())
  1458. except ValueError:
  1459. eecho(f"not a session number: {raw!r}")
  1460. return 2
  1461. if not (1 <= idx <= len(candidates)):
  1462. eecho(f"out of range: {idx}")
  1463. return 2
  1464. return emit_recovery_prompt(candidates[idx - 1], args)
  1465. # ============================================================
  1466. # Widget (in-chat card-picker builder)
  1467. # ============================================================
  1468. #
  1469. # WHY THIS MODE EXISTS — do not "simplify" the trimming away.
  1470. # The in-chat card picker renders assets/picker-widget.html via the host's
  1471. # show_widget tool. show_widget accepts ONLY inline widget_code (no file path)
  1472. # and its CSP blocks any fetch, so the session data MUST be inlined into the
  1473. # HTML. The naive flow (pick --json --rich -> hand-assemble template+data ->
  1474. # Read the file back to inline it) is expensive and fragile: --rich for ~75
  1475. # sessions is >100 KB (it spools to a tool-results file), and once injected as
  1476. # ONE long JSON line the assembled file trips the 25k-token Read cap AND can't
  1477. # be paginated (Read is line-based; the megaline is indivisible). This mode
  1478. # does the whole job in one command: build the rich inventory in-process, trim
  1479. # it to what the template consumes, inject it ONE-OBJECT-PER-LINE (so Read can
  1480. # paginate the assembled file), and hold the result under a hard byte budget so
  1481. # it never spools and never trips the Read cap. The agent's whole job becomes:
  1482. # run it, pass its stdout straight to show_widget.
  1483. # The only keys the card-picker template consumes (its header documents the
  1484. # shape). Everything else session_rows() emits — id, cliSessionId, branch,
  1485. # account, accountEmail, brokenCwd, transcriptPath — is dead weight in the
  1486. # widget and is dropped so the payload stays under budget.
  1487. WIDGET_KEYS = (
  1488. "sessionId", "title", "projectRoot", "cwd", "worktree", "isArchived",
  1489. "isRunning", "lastActivityAt", "turns", "model", "effort", "events",
  1490. "toolCalls", "densityBuckets", "durationMin", "sizeKB", "ctxTokens",
  1491. "ctxPeak", "ctxWindow", "ctxPct", "ctxPeakPct", "firstAsk",
  1492. )
  1493. WIDGET_FIRSTASK_CAP = 120 # chars — the card clamps to 2-3 lines anyway
  1494. WIDGET_BUDGET_KB_DEFAULT = 28 # assembled HTML ceiling (~<15k tokens)
  1495. WIDGET_LIMIT_DEFAULT = 24 # most-recently-active sessions kept
  1496. # days -> the <select id="win"> option value the template ships with.
  1497. _WIN_OPTION = {None: "", 1: "24", 3: "72", 7: "168", 30: "720"}
  1498. def _is_stub(s: Session, now_ms: int) -> bool:
  1499. """A 0-turn, not-currently-running session — an empty shell, not real work."""
  1500. return s.turns < 1 and not _is_live(s, now_ms)
  1501. def _downsample_pairs(buckets: list[int]) -> list[int]:
  1502. """24 activity buckets -> 12 by summing adjacent pairs.
  1503. The widget's bars() renders whatever length it's handed (bar width is
  1504. flex:1), so 12 renders identically to 24, at half the payload bytes.
  1505. """
  1506. out = [buckets[i] + buckets[i + 1] for i in range(0, len(buckets) - 1, 2)]
  1507. if len(buckets) % 2:
  1508. out.append(buckets[-1])
  1509. return out
  1510. def _trim_widget_row(row: dict, *, full_density: bool) -> dict:
  1511. """Whitelist keys, clamp firstAsk, downsample density — the per-row shrink."""
  1512. out = {k: row[k] for k in WIDGET_KEYS if k in row}
  1513. ask = out.get("firstAsk") or ""
  1514. if len(ask) > WIDGET_FIRSTASK_CAP:
  1515. out["firstAsk"] = ask[:WIDGET_FIRSTASK_CAP].rstrip() + "…"
  1516. if not full_density:
  1517. b = out.get("densityBuckets") or []
  1518. if len(b) > 12:
  1519. out["densityBuckets"] = _downsample_pairs(b)
  1520. return out
  1521. def _widget_data_block(rows: list[dict]) -> str:
  1522. """The injected JSON array, ONE SESSION OBJECT PER LINE.
  1523. Newline-delimited-inside-the-array so the assembled file has no
  1524. un-splittable megaline and line-based Read can paginate it (the failure the
  1525. manual flow hit). ensure_ascii keeps stdout safe on cp1252 consoles.
  1526. """
  1527. if not rows:
  1528. return "[]"
  1529. return "[\n" + ",\n".join(json.dumps(r) for r in rows) + "\n]"
  1530. def _asset_template() -> Path:
  1531. """assets/picker-widget.html, resolved relative to this script.
  1532. scripts/summon.py -> ../assets/picker-widget.html holds in both the repo
  1533. and the installed ~/.claude/skills/summon/ copy.
  1534. """
  1535. return Path(__file__).resolve().parent.parent / "assets" / "picker-widget.html"
  1536. def _inject_widget_data(template_html: str, data_block: str) -> str:
  1537. """Replace the <script id="D"> payload with data_block."""
  1538. start = '<script id="D" type="application/json">'
  1539. i = template_html.find(start)
  1540. if i < 0:
  1541. raise ValueError('template missing the <script id="D"> marker')
  1542. after = i + len(start)
  1543. j = template_html.find("</script>", after)
  1544. if j < 0:
  1545. raise ValueError('template <script id="D"> block is not closed')
  1546. return template_html[:after] + "\n" + data_block + "\n" + template_html[j:]
  1547. def _set_widget_window(html: str, days: int | None) -> str:
  1548. """Move the ``selected`` attribute onto the <select id="win"> option that
  1549. matches --days, so the widget opens on the same window that was injected.
  1550. Non-standard windows leave the template's default (7d) untouched."""
  1551. if days not in _WIN_OPTION:
  1552. return html
  1553. val = _WIN_OPTION[days]
  1554. m = re.search(r'(<select id="win"[^>]*>)(.*?)(</select>)', html, re.S)
  1555. if not m:
  1556. return html
  1557. head, body, tail = m.group(1), m.group(2), m.group(3)
  1558. body = body.replace(" selected", "")
  1559. body = re.sub(r'(<option value="' + re.escape(val) + r'")', r'\1 selected',
  1560. body, count=1)
  1561. return html[:m.start()] + head + body + tail + html[m.end():]
  1562. def _assemble_widget(trimmed: list[dict], days: int | None, limit: int,
  1563. budget_bytes: int, template_html: str) -> tuple[str, int, list[str]]:
  1564. """Inject rows into the template, dropping the oldest until the assembled
  1565. HTML fits budget_bytes. Returns (html, effective_count, notes)."""
  1566. notes: list[str] = []
  1567. eff = min(limit, len(trimmed))
  1568. while True:
  1569. html = _set_widget_window(
  1570. _inject_widget_data(template_html, _widget_data_block(trimmed[:eff])),
  1571. days)
  1572. if len(html.encode("utf-8")) <= budget_bytes or eff <= 1:
  1573. break
  1574. eff -= 1
  1575. if eff < min(limit, len(trimmed)):
  1576. notes.append(
  1577. f"capped to {eff} session(s) to stay under the "
  1578. f"{budget_bytes // 1024} KB HTML budget (--limit was {limit}); "
  1579. "widen with --max-kb or narrow with --cwd/--title/--days")
  1580. return html, eff, notes
  1581. def mode_widget(args, accounts: list[Account]) -> int:
  1582. """`summon widget` — emit the FINISHED, self-contained card-picker HTML.
  1583. One command replacing the manual pick --json --rich -> assemble -> Read-back
  1584. -> trim -> inline dance. stdout is the HTML (feed it straight to
  1585. show_widget); stderr carries the summary, the written path, and any capping
  1586. note; a copy is also written to --out (default %TEMP%/claude/…) so the agent
  1587. can Read it without re-running.
  1588. """
  1589. try:
  1590. template_html = _asset_template().read_text(encoding="utf-8")
  1591. except OSError as e:
  1592. eecho(f"cannot read widget template {_asset_template()}: {e}")
  1593. return 3
  1594. sessions: list[Session] = []
  1595. for acct in accounts:
  1596. sessions.extend(load_sessions(acct))
  1597. days = None if args.all else (args.days if args.days is not None else 30)
  1598. candidates = filter_sessions(sessions, days=days,
  1599. cwd_pattern=args.cwd, title_pattern=args.title)
  1600. now_ms = int(time.time() * 1000)
  1601. n_all = len(candidates)
  1602. if not args.include_stubs:
  1603. candidates = [s for s in candidates if not _is_stub(s, now_ms)]
  1604. dropped = n_all - len(candidates)
  1605. limit = max(1, args.limit)
  1606. # Cap BEFORE the rich pass so we read at most `limit` transcripts, never all.
  1607. rows = session_rows(candidates[:limit], now_ms, rich=True)
  1608. trimmed = [_trim_widget_row(r, full_density=args.full_density) for r in rows]
  1609. budget_bytes = max(4096, round(args.max_kb * 1024))
  1610. html, eff, notes = _assemble_widget(trimmed, days, limit, budget_bytes,
  1611. template_html)
  1612. out_path: Path | None = Path(args.out) if args.out else _default_widget_out()
  1613. if out_path is not None:
  1614. try:
  1615. out_path.parent.mkdir(parents=True, exist_ok=True)
  1616. tmp = out_path.with_name(out_path.name + ".tmp")
  1617. tmp.write_text(html, encoding="utf-8")
  1618. os.replace(tmp, out_path)
  1619. except OSError as e:
  1620. eecho(f"warning: could not write widget HTML to {out_path}: {e}")
  1621. out_path = None
  1622. # --- stderr summary (stdout is the data product) ---
  1623. sep = Term.g("·", "|")
  1624. kb = round(len(html.encode("utf-8")) / 1024, 1)
  1625. eecho(panel_open(f"summon {Term.g('·', '|')} widget",
  1626. indicator=f"{eff} card(s) {sep} {kb} KB"))
  1627. eecho(panel_blank())
  1628. summary = (f"{len(trimmed)} of {n_all} session(s) {sep} {_window_label(days)}"
  1629. + (f" {sep} {dropped} stub(s) dropped" if dropped else ""))
  1630. eecho(summary_line(summary))
  1631. for note in notes:
  1632. eecho(summary_line(note))
  1633. if out_path is not None:
  1634. eecho(summary_line(f"written: {out_path}"))
  1635. eecho(panel_blank())
  1636. eecho(panel_close(healths=[("ok", "HTML on stdout")]))
  1637. eecho()
  1638. eecho(Term.color("meta",
  1639. "next: pass this HTML straight to the show_widget tool (no manual "
  1640. "injection, no key-trimming, no Read-back — it's already finished)."))
  1641. # stdout = the finished HTML. Write UTF-8 bytes so a cp1252 console can't
  1642. # mangle the template's glyphs.
  1643. try:
  1644. sys.stdout.buffer.write(html.encode("utf-8"))
  1645. sys.stdout.buffer.write(b"\n")
  1646. except (AttributeError, OSError):
  1647. _print_safe(html)
  1648. return 0
  1649. def _default_widget_out() -> Path:
  1650. return Path(tempfile.gettempdir()) / "claude" / "summon-widget.html"
  1651. def mode_doctor(args, accounts: list[Account]) -> int:
  1652. """Scan every wrapper for broken cwd bindings + transcript resolution."""
  1653. broken: "OrderedDict[str, dict]" = OrderedDict() # sessionId -> finding
  1654. transcript_missing = 0
  1655. transcript_scanned = 0
  1656. checked = 0
  1657. remote = 0
  1658. for acct in accounts:
  1659. for s in load_sessions(acct):
  1660. if s.is_remote:
  1661. remote += 1
  1662. continue
  1663. checked += 1
  1664. cwd_ok = not _cwd_broken(s)
  1665. transcript, how = resolve_transcript(s)
  1666. if how == "scanned":
  1667. transcript_scanned += 1
  1668. if transcript is None:
  1669. transcript_missing += 1
  1670. if not cwd_ok:
  1671. f = broken.setdefault(s.sid, {
  1672. "sessionId": s.sid,
  1673. "cliSessionId": s.cli_id,
  1674. "title": s.title,
  1675. "cwd": s.cwd,
  1676. "accounts": [],
  1677. "archived": bool(s.data.get("isArchived")),
  1678. "transcript": str(transcript) if transcript else None,
  1679. "lastActivityAt": s.last_activity_ms,
  1680. })
  1681. if s.account.short not in f["accounts"]:
  1682. f["accounts"].append(s.account.short)
  1683. findings = list(broken.values())
  1684. if args.json:
  1685. print(json.dumps({
  1686. "data": findings,
  1687. "meta": {"count": len(findings), "checked": checked,
  1688. "remoteSkipped": remote,
  1689. "transcriptMissing": transcript_missing,
  1690. "transcriptFoundByScan": transcript_scanned,
  1691. "schema": "claude-mods.summon.doctor/v1"},
  1692. }, indent=2))
  1693. return 10 if findings else 0
  1694. sep = Term.g("·", "|")
  1695. echo(panel_open(f"summon {Term.g('·', '|')} doctor",
  1696. indicator=f"{checked} sessions"))
  1697. echo(panel_blank())
  1698. echo(summary_line(f"{checked} local {sep} {remote} remote skipped {sep} "
  1699. f"{transcript_missing} transcript-missing {sep} "
  1700. f"{transcript_scanned} found-by-scan"))
  1701. echo(panel_blank())
  1702. if not findings:
  1703. echo(section("all cwd bindings resolve", color_token="ok"))
  1704. echo(panel_blank())
  1705. echo(panel_close(healths=[("ok", "healthy")]))
  1706. return 0
  1707. echo(section("broken cwd bindings — recorded folder no longer exists",
  1708. len(findings), color_token="alarm"))
  1709. findings.sort(key=lambda f: -f["lastActivityAt"])
  1710. for i, f in enumerate(findings):
  1711. is_last = i == len(findings) - 1
  1712. tag = "arch" if f["archived"] else ",".join(f["accounts"])
  1713. echo(leaf(0, f["title"], meta=Term.color("warn", tag),
  1714. age=_ago(f["lastActivityAt"]), last=is_last, depth=1))
  1715. pipe = Term.g("│", "|")
  1716. echo(f"{pipe} {Term.color('meta', f['cwd'])}")
  1717. short = f["sessionId"].removeprefix("local_")[:8]
  1718. echo(f"{pipe} {Term.color('meta', f'summon rebind {short} --cwd <new-location>')}")
  1719. echo(panel_blank())
  1720. echo(panel_close(healths=[("alarm", f"{len(findings)} broken")]))
  1721. return 10
  1722. # ============================================================
  1723. # Main
  1724. # ============================================================
  1725. def main():
  1726. p = argparse.ArgumentParser(
  1727. description="Claude Desktop session toolbox — cross-account transfer, "
  1728. "recovery picker, cwd rebind, store doctor.",
  1729. epilog="examples:\n"
  1730. " summon --to mknv74 push sessions to the next account\n"
  1731. " summon pick picker -> distilled handover brief\n"
  1732. " summon widget --days 30 finished in-chat card-picker HTML on stdout\n"
  1733. " summon recover 6577b24c distilled handover brief for one session\n"
  1734. " summon recover 6577b24c --no-distill plain pointer prompt, no LLM call\n"
  1735. " summon recover 6577b24c --refresh ignore cached brief, re-distill\n"
  1736. " summon rebind 6577b24c --cwd X:\\Maplab\\LCMap fix cwd after folder move\n"
  1737. " summon doctor scan for broken cwd bindings\n",
  1738. formatter_class=argparse.RawDescriptionHelpFormatter,
  1739. )
  1740. p.add_argument("mode", nargs="?",
  1741. choices=["rebind", "pick", "recover", "doctor", "widget"],
  1742. help="Toolbox mode; omit for cross-account transfer")
  1743. p.add_argument("target", nargs="?",
  1744. help="Session id for rebind/recover (sessionId or cliSessionId, prefix ok)")
  1745. p.add_argument("--to", help="Destination account (UUID prefix or email substring)")
  1746. p.add_argument("--from", dest="from_",
  1747. help="Restrict source to one account (default: all non-destination accounts)")
  1748. # Time-window filter: --days N (custom) or one of the convenience aliases.
  1749. # Transfer defaults to 3 days, pick to 30; --all disables.
  1750. p.add_argument("--days", type=int, default=None,
  1751. help="Time window in days (default: 3 for transfer, 30 for pick)")
  1752. p.add_argument("--all", action="store_true", help="Disable time filter (any age)")
  1753. p.add_argument("--1d", dest="window_1d", action="store_true", help="Last 24h (alias)")
  1754. p.add_argument("--3d", dest="window_3d", action="store_true", help="Last 3 days (alias)")
  1755. p.add_argument("--7d", dest="window_7d", action="store_true", help="Last 7 days (alias)")
  1756. p.add_argument("--30d", dest="window_30d", action="store_true", help="Last 30 days (alias)")
  1757. p.add_argument("--cwd", default="",
  1758. help="Transfer/pick: substring match against cwd. "
  1759. "Rebind: the folder's NEW location (full path)")
  1760. p.add_argument("--title", default="", help="Substring match against title")
  1761. p.add_argument("--pick", action="store_true", help=argparse.SUPPRESS) # legacy flag — default behavior now
  1762. p.add_argument("--move", action="store_true",
  1763. help="Move semantics — delete source after copying (lean cleanup)")
  1764. p.add_argument("--dry-run", action="store_true", help="Preview without touching files")
  1765. p.add_argument("--list-accounts", action="store_true", help="List all accounts and exit")
  1766. p.add_argument("--peek", metavar="ID", help="Preview a session's last messages and exit (id prefix or full)")
  1767. p.add_argument("--flat", action="store_true", help="Flat list instead of grouped hierarchy")
  1768. p.add_argument("--select", metavar="PICKS", default="",
  1769. help="Non-interactive selection: numbers like '1,2,4', or 'a'/'all' for all")
  1770. p.add_argument("--yes", action="store_true",
  1771. help="Skip the final confirmation prompt only — selection is still "
  1772. "required (interactively, via piped stdin, or via --select)")
  1773. p.add_argument("--no-transcript", action="store_true",
  1774. help="Rebind: skip copying the transcript into the new munged project dir")
  1775. p.add_argument("--force", action="store_true",
  1776. help="Rebind: allow --cwd paths that don't exist on disk yet")
  1777. p.add_argument("--json", action="store_true",
  1778. help="Doctor: emit findings as a JSON envelope on stdout. "
  1779. "Pick: emit the session inventory as a JSON envelope "
  1780. "(no picker; stdout is JSON only)")
  1781. p.add_argument("--rich", action="store_true",
  1782. help="Pick --json: add transcript-derived display metrics per "
  1783. "session (context occupancy, activity density, tool/event "
  1784. "counts, size, duration, opening ask) — schema pick/v2. "
  1785. "One transcript read per session; powers the card picker")
  1786. p.add_argument("--no-distill", action="store_true",
  1787. help="Recover/pick: skip the LLM handover distillation and emit "
  1788. "the plain pointer prompt")
  1789. p.add_argument("--refresh", action="store_true",
  1790. help="Recover/pick: ignore a cached handover brief and re-distill")
  1791. p.add_argument("--model", default=DISTILL_MODEL_DEFAULT,
  1792. help=f"Recover/pick: model for the distillation call "
  1793. f"(default: {DISTILL_MODEL_DEFAULT})")
  1794. p.add_argument("--budget", type=int, default=EXTRACT_BUDGET_DEFAULT,
  1795. help=f"Recover/pick: char budget for the transcript extraction "
  1796. f"fed to the distiller (default: {EXTRACT_BUDGET_DEFAULT})")
  1797. p.add_argument("--limit", type=int, default=WIDGET_LIMIT_DEFAULT,
  1798. help=f"Widget: cap to the N most-recently-active sessions "
  1799. f"(default: {WIDGET_LIMIT_DEFAULT})")
  1800. p.add_argument("--include-stubs", action="store_true",
  1801. help="Widget: keep 0-turn, not-running sessions (dropped by default)")
  1802. p.add_argument("--full-density", action="store_true",
  1803. help="Widget: keep the full 24-bucket activity histogram "
  1804. "(default downsamples to 12 to shrink the payload)")
  1805. p.add_argument("--max-kb", type=float, default=WIDGET_BUDGET_KB_DEFAULT,
  1806. help=f"Widget: assembled-HTML byte ceiling in KB, so it never "
  1807. f"spools or trips the Read cap (default: {WIDGET_BUDGET_KB_DEFAULT})")
  1808. p.add_argument("--out", metavar="PATH",
  1809. help="Widget: also write the assembled HTML here "
  1810. "(default: <temp>/claude/summon-widget.html)")
  1811. args = p.parse_args()
  1812. claude_dir = appdata_claude()
  1813. if not claude_dir.is_dir():
  1814. sys.exit(f"Claude dir not found: {claude_dir}")
  1815. accounts = discover_accounts(claude_dir)
  1816. if not accounts:
  1817. sys.exit(f"No accounts with sessions under {claude_dir}/claude-code-sessions/")
  1818. # --- Toolbox modes ---
  1819. if args.mode == "rebind":
  1820. sys.exit(mode_rebind(args, accounts))
  1821. if args.mode == "recover":
  1822. sys.exit(mode_recover(args, accounts))
  1823. if args.mode == "pick":
  1824. sys.exit(mode_pick(args, accounts))
  1825. if args.mode == "widget":
  1826. sys.exit(mode_widget(args, accounts))
  1827. if args.mode == "doctor":
  1828. sys.exit(mode_doctor(args, accounts))
  1829. if args.target:
  1830. p.error("unexpected positional argument — transfer mode takes flags only")
  1831. # --- Modes that exit early ---
  1832. if args.list_accounts:
  1833. echo(panel_open(f"summon {Term.g('·', '|')} accounts"))
  1834. echo(panel_blank())
  1835. echo(section("accounts", len(accounts), color_token="accent"))
  1836. for i, a in enumerate(accounts):
  1837. is_last = (i == len(accounts) - 1)
  1838. ago = _ago(int(a.last_activity * 1000))
  1839. echo(leaf(0, a.email or "(unknown)",
  1840. meta=f"{a.short} {Term.g('·', '|')} {a.session_count}",
  1841. age=ago, last=is_last, depth=1))
  1842. echo(panel_blank())
  1843. echo(panel_close(healths=[("ok", f"{len(accounts)} active")]))
  1844. return
  1845. if args.peek:
  1846. sys.exit(peek_session(args.peek, accounts))
  1847. # --- Pull mode ---
  1848. # Resolve destination
  1849. if not args.to:
  1850. dest = detect_destination(accounts)
  1851. else:
  1852. dest = resolve_account(args.to, accounts)
  1853. if not dest:
  1854. sys.exit(f"Cannot resolve destination account: {args.to}")
  1855. # Resolve source(s)
  1856. if args.from_:
  1857. src = resolve_account(args.from_, accounts)
  1858. if not src:
  1859. sys.exit(f"Cannot resolve source account: {args.from_}")
  1860. if src.uuid == dest.uuid:
  1861. sys.exit("Source and destination are the same; remove --from or pick another --to")
  1862. source_accounts = [src]
  1863. else:
  1864. source_accounts = [a for a in accounts if a.uuid != dest.uuid]
  1865. if not source_accounts:
  1866. sys.exit("No source accounts available (only one account exists)")
  1867. # Load + filter sessions
  1868. all_sessions: list[Session] = []
  1869. for src in source_accounts:
  1870. all_sessions.extend(load_sessions(src))
  1871. # Resolve recency window: --all > convenience alias > --days
  1872. if args.all:
  1873. days = None
  1874. elif args.window_1d:
  1875. days = 1
  1876. elif args.window_3d:
  1877. days = 3
  1878. elif args.window_7d:
  1879. days = 7
  1880. elif args.window_30d:
  1881. days = 30
  1882. else:
  1883. days = args.days if args.days is not None else 3
  1884. candidates = filter_sessions(all_sessions, days=days,
  1885. cwd_pattern=args.cwd, title_pattern=args.title)
  1886. # Header: short destination tag (just the email's local part or UUID short).
  1887. # Source detail goes into the summary line — header stays clean.
  1888. arrow = Term.g("→", "->")
  1889. dest_short = (dest.email.split("@")[0] if dest.email else dest.short)
  1890. indicator = f"{arrow} {dest_short}"
  1891. echo(panel_open("summon", indicator=indicator))
  1892. if not candidates:
  1893. echo(panel_blank())
  1894. echo(summary_line(f"no matching sessions ({_window_label(days)})"))
  1895. echo(panel_blank())
  1896. echo(panel_close())
  1897. return
  1898. # Summary line at the TOP per TERMINAL-DESIGN.md.
  1899. sep = Term.g("·", "|")
  1900. if len(source_accounts) == 1:
  1901. src_email = source_accounts[0].email or source_accounts[0].short
  1902. src_label = f"from {src_email}"
  1903. else:
  1904. src_label = f"from {len(source_accounts)} accounts"
  1905. summary_text = f"{len(candidates)} sessions {sep} {src_label} {sep} {_window_label(days)}"
  1906. echo(panel_blank())
  1907. echo(summary_line(summary_text))
  1908. # Render hierarchy + capture index_map
  1909. index_map = _render_grouped(candidates) if not args.flat else render_hierarchy(candidates, grouped=False)
  1910. # Pick a hint — conditional ones win when relevant, otherwise rotate background tips
  1911. generic_titles = {"dev", "general", "untitled", "(untitled)", ""}
  1912. generic_count = sum(1 for s in candidates if s.title.lower() in generic_titles)
  1913. remote_count = sum(1 for sess in all_sessions if sess.is_remote)
  1914. hint_ctx = {
  1915. "count": len(candidates),
  1916. "generic_count": generic_count,
  1917. "remote_count": remote_count,
  1918. "window_days": days if days is not None else 0,
  1919. "source_count": len(source_accounts),
  1920. }
  1921. hint_text = _pick_hint(hint_ctx)
  1922. if hint_text:
  1923. echo(hint(hint_text))
  1924. echo(panel_blank())
  1925. echo(panel_close(hotkeys=[("#", "select"), ("a", "all"), ("blank", "cancel")]))
  1926. # Selection — --select answers the picker non-interactively; otherwise
  1927. # prompt for picks (piped stdin honoured too). --yes does NOT bypass
  1928. # selection; it only suppresses the final confirmation below.
  1929. def _parse_picks(raw: str) -> list[Session]:
  1930. if raw.lower() in ("a", "all"):
  1931. return list(candidates)
  1932. picks: list[Session] = []
  1933. for tok in raw.split(","):
  1934. tok = tok.strip()
  1935. if not tok:
  1936. continue
  1937. try:
  1938. i = int(tok)
  1939. except ValueError:
  1940. continue
  1941. if i in index_map:
  1942. picks.append(index_map[i])
  1943. return picks
  1944. if args.select:
  1945. chosen = _parse_picks(args.select)
  1946. if not chosen:
  1947. sys.exit(f"--select {args.select!r} matched none of the listed sessions")
  1948. else:
  1949. print()
  1950. prompt = Term.color("accent", "select> ") + \
  1951. "(numbers like '3,5,7', 'a' for all, blank to cancel): "
  1952. try:
  1953. raw = input(prompt).strip()
  1954. except EOFError:
  1955. raw = ""
  1956. if not raw:
  1957. echo(Term.color("meta", "cancelled."))
  1958. return
  1959. chosen = _parse_picks(raw)
  1960. if not chosen:
  1961. echo(Term.color("meta", "nothing selected — cancelled."))
  1962. return
  1963. candidates = chosen
  1964. # Final are-you-sure — the only prompt --yes suppresses. Dry-run touches
  1965. # nothing, so it skips the confirmation too.
  1966. if not args.yes and not args.dry_run:
  1967. verb = "move" if args.move else "copy"
  1968. confirm = Term.color("accent", "confirm> ") + \
  1969. f"{verb} {len(candidates)} session(s) to {dest_short}? (y/N): "
  1970. try:
  1971. resp = input(confirm).strip().lower()
  1972. except EOFError:
  1973. resp = ""
  1974. if resp not in ("y", "yes"):
  1975. echo(Term.color("meta", "cancelled."))
  1976. return
  1977. dest_ws = pick_destination_workspace(dest)
  1978. # Operate + render results in a fresh stacked panel (DESIGN: 2 blank lines)
  1979. print()
  1980. print()
  1981. echo(panel_open(f"summon {Term.g('·', '|')} results", indicator=dest_ws.name[:8]))
  1982. echo(panel_blank())
  1983. success_states = {"copied", "moved", "would copy", "would move"}
  1984. skip_re = re.compile(r"^skip")
  1985. moved = 0
  1986. skipped = 0
  1987. moved_files: list[Path] = []
  1988. for i, s in enumerate(candidates):
  1989. is_last = i == len(candidates) - 1
  1990. status = summon_session(s, dest_ws, move=args.move, dry_run=args.dry_run)
  1991. if status in success_states:
  1992. moved += 1
  1993. color = "ok"
  1994. target = dest_ws / s.path.name
  1995. if not args.dry_run and target.exists():
  1996. moved_files.append(target)
  1997. elif skip_re.match(status):
  1998. skipped += 1
  1999. color = "warn"
  2000. else:
  2001. color = "alarm"
  2002. echo(leaf(0, s.title, meta=Term.color(color, status),
  2003. age=s.account.short, last=is_last, depth=1))
  2004. echo(panel_blank())
  2005. # Nudge fs.watch — sentinel + rename ping-pong on each moved file
  2006. if moved and not args.dry_run:
  2007. nudge_watcher(dest_ws, moved_files=moved_files)
  2008. healths = []
  2009. if moved:
  2010. if args.dry_run:
  2011. verb = "would " + ("move" if args.move else "copy")
  2012. else:
  2013. verb = "moved" if args.move else "copied"
  2014. healths.append(("ok", f"{moved} {verb}"))
  2015. if skipped:
  2016. healths.append(("warn", f"{skipped} skipped"))
  2017. echo(panel_close(healths=healths))
  2018. if moved and not args.dry_run:
  2019. echo()
  2020. echo(Term.color("warn",
  2021. "next: switch accounts in Desktop. Logout from current, login to destination."))
  2022. echo(Term.color("meta",
  2023. " the new sessions appear when destination's sidebar populates on login."))
  2024. echo(Term.color("meta",
  2025. " (Desktop caches session list at login; tab toggles and Ctrl+R won't rescan.)"))
  2026. if __name__ == "__main__":
  2027. main()