Browse Source

feat(skills): add summon widget one-shot card-picker builder

`summon widget --days N` emits the finished, self-contained in-chat
card-picker HTML in one command, replacing the token-heavy manual flow
(pick --json --rich -> hand-merge into template -> Read the assembled
file back to inline it). That flow was expensive and broke: --rich for
~75 sessions is >100 KB (spools to a tool-results file), and once
injected as one long JSON line the assembled file tripped the 25k-token
Read cap and couldn't be paginated.

The builder reuses the rich inventory in-process (session_rows, no
double transcript read), drops 0-turn stubs (--include-stubs to keep),
caps to the most-recently-active --limit (default 24), keeps only the
keys the template consumes, downsamples the density strip 24->12
(--full-density to keep), pre-selects the window <select> to match
--days, injects ONE session object per line (so Read can paginate the
mirror file), and holds the assembled HTML under a --max-kb byte budget
(default 28 KB) so it never spools or trips the Read cap. stdout is the
HTML; stderr carries the summary + capping notes; a copy is written to
--out (default <temp>/claude/summon-widget.html).

Also shrink the per-card action icons (font-size 16->13, donut 22->18px)
and make the header overflow-safe: .proj truncates (min-width:0 +
ellipsis) and the ctx donut/tags/icons are flex:none, so a long project
name no longer pushes the icons past the card border.

SKILL.md documents `summon widget` as THE in-chat path (manual injection
kept as a brief fallback) with a WHY note so the trimming isn't
"simplified" away. Tests add a widget suite (stub-drop, --limit,
whitelist, one-per-line, density downsample, byte-budget capping); full
summon suite 38/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0xDarkMatter 3 weeks ago
parent
commit
9127767573

File diff suppressed because it is too large
+ 0 - 0
skills/summon/SKILL.md


+ 15 - 11
skills/summon/assets/picker-widget.html

@@ -49,15 +49,19 @@
 .deck.list .card{display:grid;grid-template-columns:1fr 250px;grid-template-areas:"hd dens" "ttl stats" "sum stats" "dir dir";gap:6px 18px;align-items:center;padding:12px 16px}
 .deck.list .hd{grid-area:hd}.deck.list .ttl{grid-area:ttl}.deck.list .sum{grid-area:sum}.deck.list .dens{grid-area:dens}.deck.list .stats{grid-area:stats}.deck.list .dir{grid-area:dir}
 .deck.list .sum{-webkit-line-clamp:2}
-.hd{display:flex;align-items:center;gap:7px}
-.proj{font-size:12px;color:var(--text-secondary);font-weight:500}
-.tag{font-size:10px;border-radius:5px;padding:0 5px;border:.5px solid var(--border);color:var(--text-muted)}
+/* header is a single non-wrapping row; .proj must be the shrinker (min-width:0
+   + ellipsis) so a long project name truncates instead of pushing the ctx
+   donut and action icons past the card's right border. Icons are sized small
+   on purpose — four of them share a half-width grid card. */
+.hd{display:flex;align-items:center;gap:6px;min-width:0}
+.proj{font-size:12px;color:var(--text-secondary);font-weight:500;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.tag{font-size:10px;border-radius:5px;padding:0 5px;border:.5px solid var(--border);color:var(--text-muted);flex:none}
 .tag.run{background:var(--bg-success);color:var(--text-success);border:0}
-.ctx{display:inline-flex;align-items:center;gap:5px}
-.ctx b{font-size:12px;font-weight:500;color:var(--text-secondary);font-family:var(--font-mono)}
-.ib{border:0;background:transparent;color:var(--text-muted);cursor:pointer;padding:3px;display:inline-flex;font-size:16px;border-radius:6px}
+.ctx{display:inline-flex;align-items:center;gap:4px;flex:none}
+.ctx b{font-size:11px;font-weight:500;color:var(--text-secondary);font-family:var(--font-mono)}
+.ib{border:0;background:transparent;color:var(--text-muted);cursor:pointer;padding:2px;display:inline-flex;font-size:13px;border-radius:5px;flex:none}
 .ib:hover{color:var(--text-accent);background:var(--surface-1)}
-.divx{width:.5px;height:16px;background:var(--border);margin:0 1px}
+.divx{width:.5px;height:14px;background:var(--border);margin:0 1px;flex:none}
 .ttl{font-size:15px;font-weight:500;color:var(--text-primary);line-height:1.35}
 .sum{font-size:13px;color:var(--text-secondary);line-height:1.55;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden}
 .dens{display:flex;align-items:flex-end;gap:2px;height:30px}
@@ -115,10 +119,10 @@ function fTok(n){n=n||0;return n>=1000?Math.round(n/1000)+'k':''+n;}
 function esc(s){return (s||'').replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];});}
 function shortModel(m){m=m||'';if(m.indexOf('opus')>=0)return 'Opus';if(m.indexOf('fable')>=0)return 'Fable 5';if(m.indexOf('sonnet')>=0)return 'Sonnet';if(m.indexOf('haiku')>=0)return 'Haiku';return m;}
 function ctxColor(p){return p<50?'#1D9E75':p<75?'#BA7517':'#E24B4A';}
-function donut(pct){var r=8,C=2*Math.PI*r,off=C*(1-(pct||0)/100),col=ctxColor(pct||0);
- return '<svg width="22" height="22" viewBox="0 0 22 22" style="transform:rotate(-90deg)" aria-hidden="true">'
- +'<circle cx="11" cy="11" r="8" fill="none" stroke="var(--border-strong)" stroke-width="3"></circle>'
- +'<circle cx="11" cy="11" r="8" fill="none" stroke="'+col+'" stroke-width="3" stroke-linecap="round" stroke-dasharray="'+C.toFixed(1)+'" stroke-dashoffset="'+off.toFixed(1)+'"></circle></svg>';}
+function donut(pct){var r=6,C=2*Math.PI*r,off=C*(1-(pct||0)/100),col=ctxColor(pct||0);
+ return '<svg width="18" height="18" viewBox="0 0 18 18" style="transform:rotate(-90deg)" aria-hidden="true">'
+ +'<circle cx="9" cy="9" r="6" fill="none" stroke="var(--border-strong)" stroke-width="2.5"></circle>'
+ +'<circle cx="9" cy="9" r="6" fill="none" stroke="'+col+'" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="'+C.toFixed(1)+'" stroke-dashoffset="'+off.toFixed(1)+'"></circle></svg>';}
 function bars(b,col){b=b||[];var mx=Math.max.apply(null,b)||1;return b.map(function(v){var h=v>0?Math.max(2,Math.round(v/mx*30)):1;return '<i style="height:'+h+'px;background:'+col+';opacity:'+(v>0?1:0.18)+'"></i>';}).join('');}
 function chip(ic,val,lbl){return '<span class="chip"><i class="ti ti-'+ic+'" aria-hidden="true"></i>'+val+(lbl?'<span style="color:var(--text-muted)">'+lbl+'</span>':'')+'</span>';}
 var picked={};

+ 275 - 14
skills/summon/scripts/summon.py

@@ -11,7 +11,12 @@ Output:  transfer/pick/doctor render TTY panels; recover/pick emit a paste-ready
          the `claude` CLI is unavailable or --no-distill is set. doctor --json
          emits {"data": [...], "meta": {"schema": "claude-mods.summon.doctor/v1"}};
          pick --json emits the session inventory as
-         {"data": [...], "meta": {"schema": "claude-mods.summon.pick/v1"}}
+         {"data": [...], "meta": {"schema": "claude-mods.summon.pick/v1"}};
+         widget emits FINISHED, self-contained card-picker HTML on stdout (pass
+         it straight to show_widget) — the rich inventory pre-trimmed and
+         injected into assets/picker-widget.html under a hard byte budget, one
+         session object per line; a copy is written to --out (default
+         <temp>/claude/summon-widget.html)
 Stderr:  context panels for recover/pick, distillation progress, warnings, errors
 Exit:    0 ok (including the non-distilled fallback — worker unavailability is
          advisory, never fatal), 2 usage/ambiguous id, 3 session or path not
@@ -21,6 +26,7 @@ Examples:
   summon --to mknv74                          # transfer: push sessions to next account
   summon pick                                 # fzf/numbered picker -> distilled handover
   summon pick --json | jq '.data[]'           # machine-readable session inventory
+  summon widget --days 30                      # finished in-chat card-picker HTML on stdout
   summon recover 6577b24c                     # distilled handover brief for one session
   summon recover 6577b24c --refresh           # ignore cached brief, re-distill
   summon recover 6577b24c --no-distill        # plain pointer prompt, no LLM call
@@ -39,7 +45,7 @@ Deliberately single-file: skill scripts ship as self-contained portable units
 the `# ===` section headers — Sections: DESIGN(term) · Path discovery ·
 Account discovery · Sessions · Grouping · Listing · Picker · Workspace
 selection · Operate · Peek · Transcript/Distill · Modes (transfer / pick /
-recover / rebind / doctor) · CLI entry.
+recover / rebind / doctor) · Widget (card-picker builder) · CLI entry.
 """
 
 from __future__ import annotations
@@ -50,6 +56,7 @@ import os
 import re
 import shutil
 import sys
+import tempfile
 import time
 import uuid as uuidlib
 from collections import OrderedDict
@@ -1556,19 +1563,19 @@ def _iso_utc(ms: int) -> str:
     return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ms / 1000))
 
 
-def pick_json(candidates: list[Session], now_ms: int, rich: bool = False) -> int:
-    """`pick --json` — the session inventory as a claude-mods.summon.pick
-    envelope on stdout (JSON only; panels never touch stdout on this path).
+def session_rows(candidates: list[Session], now_ms: int, rich: bool = False) -> list[dict]:
+    """Build the pick inventory rows for a set of sessions (in-process).
 
-    Feeds the in-chat visual card picker (assets/picker-widget.html) and any
-    other scripted caller. An empty inventory is valid data, not an error:
-    exit 0.
+    The single source of truth for a session's machine-readable shape — shared
+    by ``pick --json`` (which wraps these in an envelope) and ``widget`` (which
+    trims them and injects them into the card-picker template). Keeping it one
+    function means the widget builder never has to shell out to ``pick`` or
+    re-read a transcript that ``pick`` already read.
 
-    With ``rich`` (``--rich``), each row gains transcript-derived display
-    metrics — context occupancy (donut), activity density (sparkline), event /
-    tool-call counts, on-disk size, duration, and the opening ask — and the
-    schema advances to pick/v2. Costs one linear transcript read per session,
-    so it's opt-in; the default v1 inventory stays metadata-only and instant.
+    With ``rich`` each row gains transcript-derived display metrics (context
+    occupancy, activity density, event/tool counts, size, duration, opening
+    ask) at the cost of one linear transcript read per session; without it the
+    rows are metadata-only and instant.
     """
     rows = []
     for s in candidates:
@@ -1610,6 +1617,21 @@ def pick_json(candidates: list[Session], now_ms: int, rich: bool = False) -> int
                 "firstAsk": m["firstAsk"],
             })
         rows.append(row)
+    return rows
+
+
+def pick_json(candidates: list[Session], now_ms: int, rich: bool = False) -> int:
+    """`pick --json` — the session inventory as a claude-mods.summon.pick
+    envelope on stdout (JSON only; panels never touch stdout on this path).
+
+    Feeds the in-chat visual card picker (assets/picker-widget.html) and any
+    other scripted caller. An empty inventory is valid data, not an error:
+    exit 0.
+
+    With ``rich`` (``--rich``) the schema advances to pick/v2 and each row
+    gains transcript-derived display metrics (see ``session_rows``).
+    """
+    rows = session_rows(candidates, now_ms, rich)
     print(json.dumps({
         "data": rows,
         "meta": {"count": len(rows),
@@ -1693,6 +1715,227 @@ def mode_pick(args, accounts: list[Account]) -> int:
     return emit_recovery_prompt(candidates[idx - 1], args)
 
 
+# ============================================================
+#  Widget (in-chat card-picker builder)
+# ============================================================
+#
+# WHY THIS MODE EXISTS — do not "simplify" the trimming away.
+# The in-chat card picker renders assets/picker-widget.html via the host's
+# show_widget tool. show_widget accepts ONLY inline widget_code (no file path)
+# and its CSP blocks any fetch, so the session data MUST be inlined into the
+# HTML. The naive flow (pick --json --rich -> hand-assemble template+data ->
+# Read the file back to inline it) is expensive and fragile: --rich for ~75
+# sessions is >100 KB (it spools to a tool-results file), and once injected as
+# ONE long JSON line the assembled file trips the 25k-token Read cap AND can't
+# be paginated (Read is line-based; the megaline is indivisible). This mode
+# does the whole job in one command: build the rich inventory in-process, trim
+# it to what the template consumes, inject it ONE-OBJECT-PER-LINE (so Read can
+# paginate the assembled file), and hold the result under a hard byte budget so
+# it never spools and never trips the Read cap. The agent's whole job becomes:
+# run it, pass its stdout straight to show_widget.
+
+# The only keys the card-picker template consumes (its header documents the
+# shape). Everything else session_rows() emits — id, cliSessionId, branch,
+# account, accountEmail, brokenCwd, transcriptPath — is dead weight in the
+# widget and is dropped so the payload stays under budget.
+WIDGET_KEYS = (
+    "sessionId", "title", "projectRoot", "cwd", "worktree", "isArchived",
+    "isRunning", "lastActivityAt", "turns", "model", "effort", "events",
+    "toolCalls", "densityBuckets", "durationMin", "sizeKB", "ctxTokens",
+    "ctxPeak", "ctxWindow", "ctxPct", "ctxPeakPct", "firstAsk",
+)
+WIDGET_FIRSTASK_CAP = 120        # chars — the card clamps to 2-3 lines anyway
+WIDGET_BUDGET_KB_DEFAULT = 28    # assembled HTML ceiling (~<15k tokens)
+WIDGET_LIMIT_DEFAULT = 24        # most-recently-active sessions kept
+
+# days -> the <select id="win"> option value the template ships with.
+_WIN_OPTION = {None: "", 1: "24", 3: "72", 7: "168", 30: "720"}
+
+
+def _is_stub(s: Session, now_ms: int) -> bool:
+    """A 0-turn, not-currently-running session — an empty shell, not real work."""
+    return s.turns < 1 and not _is_live(s, now_ms)
+
+
+def _downsample_pairs(buckets: list[int]) -> list[int]:
+    """24 activity buckets -> 12 by summing adjacent pairs.
+
+    The widget's bars() renders whatever length it's handed (bar width is
+    flex:1), so 12 renders identically to 24, at half the payload bytes.
+    """
+    out = [buckets[i] + buckets[i + 1] for i in range(0, len(buckets) - 1, 2)]
+    if len(buckets) % 2:
+        out.append(buckets[-1])
+    return out
+
+
+def _trim_widget_row(row: dict, *, full_density: bool) -> dict:
+    """Whitelist keys, clamp firstAsk, downsample density — the per-row shrink."""
+    out = {k: row[k] for k in WIDGET_KEYS if k in row}
+    ask = out.get("firstAsk") or ""
+    if len(ask) > WIDGET_FIRSTASK_CAP:
+        out["firstAsk"] = ask[:WIDGET_FIRSTASK_CAP].rstrip() + "…"
+    if not full_density:
+        b = out.get("densityBuckets") or []
+        if len(b) > 12:
+            out["densityBuckets"] = _downsample_pairs(b)
+    return out
+
+
+def _widget_data_block(rows: list[dict]) -> str:
+    """The injected JSON array, ONE SESSION OBJECT PER LINE.
+
+    Newline-delimited-inside-the-array so the assembled file has no
+    un-splittable megaline and line-based Read can paginate it (the failure the
+    manual flow hit). ensure_ascii keeps stdout safe on cp1252 consoles.
+    """
+    if not rows:
+        return "[]"
+    return "[\n" + ",\n".join(json.dumps(r) for r in rows) + "\n]"
+
+
+def _asset_template() -> Path:
+    """assets/picker-widget.html, resolved relative to this script.
+
+    scripts/summon.py -> ../assets/picker-widget.html holds in both the repo
+    and the installed ~/.claude/skills/summon/ copy.
+    """
+    return Path(__file__).resolve().parent.parent / "assets" / "picker-widget.html"
+
+
+def _inject_widget_data(template_html: str, data_block: str) -> str:
+    """Replace the <script id="D"> payload with data_block."""
+    start = '<script id="D" type="application/json">'
+    i = template_html.find(start)
+    if i < 0:
+        raise ValueError('template missing the <script id="D"> marker')
+    after = i + len(start)
+    j = template_html.find("</script>", after)
+    if j < 0:
+        raise ValueError('template <script id="D"> block is not closed')
+    return template_html[:after] + "\n" + data_block + "\n" + template_html[j:]
+
+
+def _set_widget_window(html: str, days: int | None) -> str:
+    """Move the ``selected`` attribute onto the <select id="win"> option that
+    matches --days, so the widget opens on the same window that was injected.
+    Non-standard windows leave the template's default (7d) untouched."""
+    if days not in _WIN_OPTION:
+        return html
+    val = _WIN_OPTION[days]
+    m = re.search(r'(<select id="win"[^>]*>)(.*?)(</select>)', html, re.S)
+    if not m:
+        return html
+    head, body, tail = m.group(1), m.group(2), m.group(3)
+    body = body.replace(" selected", "")
+    body = re.sub(r'(<option value="' + re.escape(val) + r'")', r'\1 selected',
+                  body, count=1)
+    return html[:m.start()] + head + body + tail + html[m.end():]
+
+
+def _assemble_widget(trimmed: list[dict], days: int | None, limit: int,
+                     budget_bytes: int, template_html: str) -> tuple[str, int, list[str]]:
+    """Inject rows into the template, dropping the oldest until the assembled
+    HTML fits budget_bytes. Returns (html, effective_count, notes)."""
+    notes: list[str] = []
+    eff = min(limit, len(trimmed))
+    while True:
+        html = _set_widget_window(
+            _inject_widget_data(template_html, _widget_data_block(trimmed[:eff])),
+            days)
+        if len(html.encode("utf-8")) <= budget_bytes or eff <= 1:
+            break
+        eff -= 1
+    if eff < min(limit, len(trimmed)):
+        notes.append(
+            f"capped to {eff} session(s) to stay under the "
+            f"{budget_bytes // 1024} KB HTML budget (--limit was {limit}); "
+            "widen with --max-kb or narrow with --cwd/--title/--days")
+    return html, eff, notes
+
+
+def mode_widget(args, accounts: list[Account]) -> int:
+    """`summon widget` — emit the FINISHED, self-contained card-picker HTML.
+
+    One command replacing the manual pick --json --rich -> assemble -> Read-back
+    -> trim -> inline dance. stdout is the HTML (feed it straight to
+    show_widget); stderr carries the summary, the written path, and any capping
+    note; a copy is also written to --out (default %TEMP%/claude/…) so the agent
+    can Read it without re-running.
+    """
+    try:
+        template_html = _asset_template().read_text(encoding="utf-8")
+    except OSError as e:
+        eecho(f"cannot read widget template {_asset_template()}: {e}")
+        return 3
+
+    sessions: list[Session] = []
+    for acct in accounts:
+        sessions.extend(load_sessions(acct))
+    days = None if args.all else (args.days if args.days is not None else 30)
+    candidates = filter_sessions(sessions, days=days,
+                                 cwd_pattern=args.cwd, title_pattern=args.title)
+    now_ms = int(time.time() * 1000)
+
+    n_all = len(candidates)
+    if not args.include_stubs:
+        candidates = [s for s in candidates if not _is_stub(s, now_ms)]
+    dropped = n_all - len(candidates)
+
+    limit = max(1, args.limit)
+    # Cap BEFORE the rich pass so we read at most `limit` transcripts, never all.
+    rows = session_rows(candidates[:limit], now_ms, rich=True)
+    trimmed = [_trim_widget_row(r, full_density=args.full_density) for r in rows]
+
+    budget_bytes = max(4096, round(args.max_kb * 1024))
+    html, eff, notes = _assemble_widget(trimmed, days, limit, budget_bytes,
+                                        template_html)
+
+    out_path: Path | None = Path(args.out) if args.out else _default_widget_out()
+    if out_path is not None:
+        try:
+            out_path.parent.mkdir(parents=True, exist_ok=True)
+            tmp = out_path.with_name(out_path.name + ".tmp")
+            tmp.write_text(html, encoding="utf-8")
+            os.replace(tmp, out_path)
+        except OSError as e:
+            eecho(f"warning: could not write widget HTML to {out_path}: {e}")
+            out_path = None
+
+    # --- stderr summary (stdout is the data product) ---
+    sep = Term.g("·", "|")
+    kb = round(len(html.encode("utf-8")) / 1024, 1)
+    eecho(panel_open(f"summon {Term.g('·', '|')} widget",
+                     indicator=f"{eff} card(s) {sep} {kb} KB"))
+    eecho(panel_blank())
+    summary = (f"{len(trimmed)} of {n_all} session(s) {sep} {_window_label(days)}"
+               + (f" {sep} {dropped} stub(s) dropped" if dropped else ""))
+    eecho(summary_line(summary))
+    for note in notes:
+        eecho(summary_line(note))
+    if out_path is not None:
+        eecho(summary_line(f"written: {out_path}"))
+    eecho(panel_blank())
+    eecho(panel_close(healths=[("ok", "HTML on stdout")]))
+    eecho()
+    eecho(Term.color("meta",
+          "next: pass this HTML straight to the show_widget tool (no manual "
+          "injection, no key-trimming, no Read-back — it's already finished)."))
+
+    # stdout = the finished HTML. Write UTF-8 bytes so a cp1252 console can't
+    # mangle the template's glyphs.
+    try:
+        sys.stdout.buffer.write(html.encode("utf-8"))
+        sys.stdout.buffer.write(b"\n")
+    except (AttributeError, OSError):
+        _print_safe(html)
+    return 0
+
+
+def _default_widget_out() -> Path:
+    return Path(tempfile.gettempdir()) / "claude" / "summon-widget.html"
+
+
 def mode_doctor(args, accounts: list[Account]) -> int:
     """Scan every wrapper for broken cwd bindings + transcript resolution."""
     broken: "OrderedDict[str, dict]" = OrderedDict()  # sessionId -> finding
@@ -1784,6 +2027,7 @@ def main():
         epilog="examples:\n"
                "  summon --to mknv74              push sessions to the next account\n"
                "  summon pick                     picker -> distilled handover brief\n"
+               "  summon widget --days 30         finished in-chat card-picker HTML on stdout\n"
                "  summon recover 6577b24c         distilled handover brief for one session\n"
                "  summon recover 6577b24c --no-distill   plain pointer prompt, no LLM call\n"
                "  summon recover 6577b24c --refresh      ignore cached brief, re-distill\n"
@@ -1791,7 +2035,8 @@ def main():
                "  summon doctor                   scan for broken cwd bindings\n",
         formatter_class=argparse.RawDescriptionHelpFormatter,
     )
-    p.add_argument("mode", nargs="?", choices=["rebind", "pick", "recover", "doctor"],
+    p.add_argument("mode", nargs="?",
+                   choices=["rebind", "pick", "recover", "doctor", "widget"],
                    help="Toolbox mode; omit for cross-account transfer")
     p.add_argument("target", nargs="?",
                    help="Session id for rebind/recover (sessionId or cliSessionId, prefix ok)")
@@ -1847,6 +2092,20 @@ def main():
     p.add_argument("--budget", type=int, default=EXTRACT_BUDGET_DEFAULT,
                    help=f"Recover/pick: char budget for the transcript extraction "
                         f"fed to the distiller (default: {EXTRACT_BUDGET_DEFAULT})")
+    p.add_argument("--limit", type=int, default=WIDGET_LIMIT_DEFAULT,
+                   help=f"Widget: cap to the N most-recently-active sessions "
+                        f"(default: {WIDGET_LIMIT_DEFAULT})")
+    p.add_argument("--include-stubs", action="store_true",
+                   help="Widget: keep 0-turn, not-running sessions (dropped by default)")
+    p.add_argument("--full-density", action="store_true",
+                   help="Widget: keep the full 24-bucket activity histogram "
+                        "(default downsamples to 12 to shrink the payload)")
+    p.add_argument("--max-kb", type=float, default=WIDGET_BUDGET_KB_DEFAULT,
+                   help=f"Widget: assembled-HTML byte ceiling in KB, so it never "
+                        f"spools or trips the Read cap (default: {WIDGET_BUDGET_KB_DEFAULT})")
+    p.add_argument("--out", metavar="PATH",
+                   help="Widget: also write the assembled HTML here "
+                        "(default: <temp>/claude/summon-widget.html)")
     args = p.parse_args()
 
     claude_dir = appdata_claude()
@@ -1865,6 +2124,8 @@ def main():
         sys.exit(mode_recover(args, accounts))
     if args.mode == "pick":
         sys.exit(mode_pick(args, accounts))
+    if args.mode == "widget":
+        sys.exit(mode_widget(args, accounts))
     if args.mode == "doctor":
         sys.exit(mode_doctor(args, accounts))
     if args.target:

+ 167 - 0
skills/summon/tests/test_summon.py

@@ -51,6 +51,13 @@ Pick --json (the in-chat visual picker's data feed):
 In-chat picker asset:
   23. assets/picker-widget.html exists, carries the <script id="D"> injection
       block + sendPrompt wiring, and is cited from SKILL.md
+
+Widget builder (summon widget -> finished card-picker HTML):
+  24. replaces the <script id="D"> payload, drops 0-turn stubs by default
+      (--include-stubs keeps them), honours --limit, keeps only whitelisted
+      keys, emits one session object per line, downsamples density 24->12
+      (--full-density keeps 24), sets the window <select>, and holds the HTML
+      under the byte budget (capping with a stderr note under a tight --max-kb)
 """
 
 from __future__ import annotations
@@ -653,6 +660,163 @@ def pick_json_tests() -> None:
         shutil.rmtree(tmp, ignore_errors=True)
 
 
+def build_widget_sandbox(tmp: Path) -> dict:
+    """One account, three real sessions + one 0-turn stub, all stale (not
+    running), transcripts present — the fixture for the widget builder tests."""
+    home = tmp / "home"
+    appdata = home / "AppData" / "Roaming"
+    env = os.environ.copy()
+    env["HOME"] = str(home)
+    env["USERPROFILE"] = str(home)
+    env["APPDATA"] = str(appdata)
+    env.pop("PYTHONIOENCODING", None)
+    env.pop("TERM_ASCII", None)
+
+    cdir = claude_dir(env)
+    projects = home / ".claude" / "projects"
+    now_ms = int(time.time() * 1000)
+
+    ws = cdir / "claude-code-sessions" / SRC_UUID / "11111111-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
+    ws.mkdir(parents=True)
+    proj = tmp / "proj"
+    proj.mkdir()
+
+    # (sid, cli, title, turns, age_min) — newest first; ages > 10m so none live.
+    specs = [
+        ("w0", "cli-w0", "widget alpha", 5, 20),
+        ("w1", "cli-w1", "widget beta", 3, 30),
+        ("w2", "cli-w2", "widget gamma", 9, 40),
+        ("wstub", "cli-wstub", "empty stub", 0, 50),  # 0-turn -> dropped by default
+    ]
+    for sid, cli, title, turns, age in specs:
+        (ws / f"local_{sid}.json").write_text(json.dumps({
+            "sessionId": f"local_{sid}", "cliSessionId": cli, "title": title,
+            "cwd": str(proj), "lastActivityAt": now_ms - age * 60_000,
+            "completedTurns": turns, "model": "claude-opus-4-8", "effort": "high",
+        }), encoding="utf-8")
+
+    enc_dir = projects / encode_cwd(str(proj))
+    enc_dir.mkdir(parents=True)
+    for _, cli, *_ in specs:
+        (enc_dir / f"{cli}.jsonl").write_text(
+            '{"type":"user","message":{"content":"hello there"},"timestamp":"2026-07-01T00:00:00Z"}\n'
+            '{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}],'
+            '"usage":{"input_tokens":100,"output_tokens":50}},"timestamp":"2026-07-01T00:05:00Z"}\n',
+            encoding="utf-8")
+
+    # Backdate wrapper mtimes past the 10-minute liveness window (the stub must
+    # read as NOT running so it is dropped by default).
+    stale = time.time() - 3600
+    for f in ws.glob("local_*.json"):
+        os.utime(f, (stale, stale))
+
+    return {"env": env, "ws": ws, "proj": proj, "projects": projects}
+
+
+def _widget_data_array(html: str):
+    """Extract + parse the injected <script id="D"> JSON array; also return the
+    raw block so line-formatting can be asserted."""
+    start = '<script id="D" type="application/json">'
+    i = html.find(start)
+    j = html.find("</script>", i + len(start)) if i >= 0 else -1
+    if i < 0 or j < 0:
+        return "", None
+    block = html[i + len(start):j]
+    try:
+        return block, json.loads(block)
+    except json.JSONDecodeError:
+        return block, None
+
+
+def widget_tests() -> None:
+    """24. summon widget: one-shot finished card-picker HTML.
+
+    Asserts the builder replaces the <script id="D"> payload, drops 0-turn
+    stubs by default (--include-stubs keeps them), honours --limit, keeps only
+    the whitelisted keys, emits one session object per line, sets the window
+    <select>, and holds the assembled HTML under the byte budget (capping with
+    a stderr note when it can't).
+    """
+    mod = _load_summon_module()
+    allowed = set(mod.WIDGET_KEYS)
+    tmp = Path(tempfile.mkdtemp(prefix="summon-widget-"))
+    try:
+        sb = build_widget_sandbox(tmp)
+        env = sb["env"]
+        out_html = tmp / "w.html"
+
+        # A) default run: placeholder replaced, stub dropped, keys whitelisted,
+        #    one-object-per-line, window set, under budget, mirror written.
+        rc, out, err = run_mode(env, ["widget", "--out", str(out_html)])
+        block, arr = _widget_data_array(out)
+        ids = [r["sessionId"] for r in arr] if arr else []
+        data_lines = [ln for ln in block.splitlines() if ln.strip().startswith("{")]
+        checks = {
+            "rc": rc == 0,
+            "parses": arr is not None,
+            "placeholder-gone": "local_0000" not in out,
+            "payload-present": "widget alpha" in out,
+            "stub-dropped": "local_wstub" not in ids,
+            "three-real": ids == ["local_w0", "local_w1", "local_w2"],
+            "keys-whitelisted": arr is not None and all(set(r).issubset(allowed) for r in arr),
+            "dead-keys-gone": arr is not None and all(
+                "cliSessionId" not in r and "transcriptPath" not in r
+                and "branch" not in r for r in arr),
+            "one-per-line": arr is not None and len(data_lines) == len(arr),
+            "window-30d": '<option value="720" selected>' in out,
+            "under-budget": len(out.encode("utf-8")) <= 28 * 1024,
+            "mirror-written": out_html.is_file() and "widget alpha" in out_html.read_text(encoding="utf-8"),
+            "stdout-not-stderr": "widget alpha" not in err,
+        }
+        if all(checks.values()):
+            ok("widget builds finished HTML: stub-drop, whitelist, 1/line, budget")
+        else:
+            no("widget builds finished HTML: stub-drop, whitelist, 1/line, budget",
+               f"failed={[k for k, v in checks.items() if not v]} rc={rc} err-tail={err[-200:]!r}")
+
+        # B) --limit caps the session count.
+        rc, out, _ = run_mode(env, ["widget", "--limit", "2", "--out", str(out_html)])
+        _, arr = _widget_data_array(out)
+        if rc == 0 and arr is not None and len(arr) == 2 and [r["sessionId"] for r in arr] == ["local_w0", "local_w1"]:
+            ok("widget --limit caps to the N most-recently-active sessions")
+        else:
+            no("widget --limit caps to the N most-recently-active sessions",
+               f"rc={rc} n={len(arr) if arr else None}")
+
+        # C) --include-stubs keeps the 0-turn session.
+        rc, out, _ = run_mode(env, ["widget", "--include-stubs", "--out", str(out_html)])
+        _, arr = _widget_data_array(out)
+        if rc == 0 and arr is not None and "local_wstub" in [r["sessionId"] for r in arr]:
+            ok("widget --include-stubs keeps 0-turn sessions")
+        else:
+            no("widget --include-stubs keeps 0-turn sessions",
+               f"rc={rc} ids={[r['sessionId'] for r in arr] if arr else None}")
+
+        # D) density downsampled to <=12 by default, full 24 with --full-density.
+        rc, out, _ = run_mode(env, ["widget", "--out", str(out_html)])
+        _, arr = _widget_data_array(out)
+        default_ds = len(arr[0]["densityBuckets"]) if arr else -1
+        rc2, out2, _ = run_mode(env, ["widget", "--full-density", "--out", str(out_html)])
+        _, arr2 = _widget_data_array(out2)
+        full_ds = len(arr2[0]["densityBuckets"]) if arr2 else -1
+        if rc == 0 and rc2 == 0 and default_ds == 12 and full_ds == 24:
+            ok("widget downsamples density 24->12 (default); --full-density keeps 24")
+        else:
+            no("widget downsamples density 24->12 (default); --full-density keeps 24",
+               f"default={default_ds} full={full_ds}")
+
+        # E) a tight budget forces capping with a stderr note (never spools).
+        rc, out, err = run_mode(env, ["widget", "--max-kb", "0.001", "--out", str(out_html)])
+        _, arr = _widget_data_array(out)
+        if rc == 0 and arr is not None and len(arr) == 1 and "capped" in err:
+            ok("widget honours --max-kb: caps to fit + notes the cap on stderr")
+        else:
+            no("widget honours --max-kb: caps to fit + notes the cap on stderr",
+               f"rc={rc} n={len(arr) if arr else None} err-tail={err[-200:]!r}")
+    finally:
+        shutil.rmtree(tmp, ignore_errors=True)
+
+
 def asset_tests() -> None:
     """In-chat picker asset: present, injectable, and cited from SKILL.md.
 
@@ -786,6 +950,9 @@ def main() -> int:
     # 23. In-chat picker asset: present + injectable + cited from SKILL.md
     asset_tests()
 
+    # 24. summon widget: one-shot finished card-picker HTML builder
+    widget_tests()
+
     print(f"\nsummon tests: {PASS} passed, {FAIL} failed")
     return 1 if FAIL else 0
 

Some files were not shown because too many files changed in this diff