Browse Source

feat(skills): Add in-chat visual picker mode to summon

- summon pick --json: machine-readable session inventory as a
  claude-mods.summon.pick/v1 envelope (SKILL-RESOURCE-PROTOCOL §4) —
  projectRoot/worktree split, branch, isArchived/isRunning, brokenCwd
  (doctor's check), ISO lastActivityAt, resolved transcriptPath via
  recover's wrapper->transcript helper. stdout JSON only; exit 0 even
  on an empty inventory.
- assets/picker-widget.html: user-validated show_widget template
  (host CSS variables + sendPrompt bridge, INJECT block documented
  in the header; comment fixed to name the actual S array).
- SKILL.md: 'In-chat mode (visual picker)' section — pick --json ->
  widget render -> act on recover/summon/peek sendPrompt callbacks;
  chat contexts only, terminal keeps the fzf/numbered picker.
- Tests: envelope parse + schema, glyph-free stdout, worktree split,
  brokenCwd fixture, asset presence + SKILL.md citation (31+1 pass).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0xDarkMatter 3 weeks ago
parent
commit
c0598a6b6a

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


+ 119 - 0
skills/summon/assets/picker-widget.html

@@ -0,0 +1,119 @@
+<!--
+  summon in-chat picker widget — TEMPLATE (proven working 2026-07-03 in Claude Desktop chat)
+
+  How Claude uses this: run `summon pick --json`, map the envelope's sessions into the
+  `S` array below (shape documented inline), render via the show_widget tool.
+  Everything else is self-contained. Styling uses ONLY host CSS variables (light/dark safe).
+  sendPrompt(text) is a host-provided global: submits a chat message as if the user typed it.
+
+  DATA SHAPE per session row:
+    [shortId, title, projectRoot, isArchived(0|1), isRunning(0|1), isoTimestamp, worktreeName("" if none)]
+  REBOUND: set of shortIds to badge as recently-rebound (optional; empty Set() is fine).
+-->
+<h2 class="sr-only" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)">Interactive picker listing Claude Desktop sessions grouped by project, with checkboxes to select sessions for recovery or cross-account summon.</h2>
+<div id="sp" style="font-family:var(--font-sans);color:var(--text-primary);border:1px solid var(--border);border-radius:12px;background:var(--surface-1);overflow:hidden">
+  <div style="display:flex;gap:var(--gap-sm);align-items:center;padding:var(--pad-md);border-bottom:1px solid var(--border);flex-wrap:wrap">
+    <span style="font-weight:600">🪄 Session picker</span>
+    <input id="flt" type="text" placeholder="filter title / path…" style="flex:1;min-width:140px;padding:6px 10px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-2);color:var(--text-primary);font:inherit;font-size:13px">
+    <span id="cnt" style="font-size:12px;color:var(--text-secondary);white-space:nowrap">0 selected</span>
+  </div>
+  <div id="groups"></div>
+  <div style="display:flex;gap:var(--gap-sm);padding:var(--pad-md);border-top:1px solid var(--border);flex-wrap:wrap;align-items:center">
+    <button id="recover" class="act" disabled>📥 Recover selected</button>
+    <button id="summon" class="act sec" disabled>🔁 Summon to other account</button>
+    <button id="clear" class="act sec" disabled>✕ Clear</button>
+    <span style="font-size:11px;color:var(--text-muted);margin-left:auto">👁 peek shows a session's last messages</span>
+  </div>
+</div>
+<style>
+  .act{padding:7px 14px;border-radius:var(--radius);border:1px solid var(--border-accent);background:var(--bg-accent);color:var(--text-accent);font:inherit;font-size:13px;font-weight:600;cursor:pointer}
+  .act.sec{background:var(--surface-2);border-color:var(--border);color:var(--text-secondary)}
+  .act:disabled{opacity:.45;cursor:default}
+  .grp summary{display:flex;gap:8px;align-items:center;padding:8px 12px;cursor:pointer;list-style:none;background:var(--surface-0);border-bottom:1px solid var(--border);font-size:13px;font-weight:600}
+  .grp summary::-webkit-details-marker{display:none}
+  .grp .car{transition:transform .15s;color:var(--text-muted);font-size:10px}
+  .grp[open] .car{transform:rotate(90deg)}
+  .row{display:flex;gap:8px;align-items:center;padding:6px 12px 6px 26px;border-bottom:1px solid var(--border);font-size:13px}
+  .row:hover{background:var(--surface-0)}
+  .row .ttl{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+  .badge{font-size:10px;padding:1px 6px;border-radius:8px;white-space:nowrap}
+  .b-arch{background:var(--surface-0);color:var(--text-muted);border:1px solid var(--border)}
+  .b-run{background:var(--bg-success);color:var(--text-success)}
+  .b-wt{background:var(--surface-0);color:var(--text-secondary);border:1px solid var(--border);font-family:var(--font-mono)}
+  .b-reb{background:var(--bg-accent);color:var(--text-accent)}
+  .age{font-size:11px;color:var(--text-muted);white-space:nowrap;width:34px;text-align:right}
+  .peek{cursor:pointer;border:none;background:none;font-size:13px;opacity:.55;padding:0 2px}
+  .peek:hover{opacity:1}
+  .gchk,.row input{accent-color:var(--text-accent)}
+</style>
+<script>
+// >>> INJECT: replace with real data from `summon pick --json` <<<
+const REBOUND = new Set([]);
+const S = [
+ ["00000000","Example session title","X:\\Example\\Project",0,0,"2026-01-01T00:00:00Z",""]
+];
+// >>> END INJECT <<<
+const NOW = Date.now();
+const age = ts => { const h = (NOW - Date.parse(ts)) / 36e5; return h < 1 ? Math.round(h*60)+"m" : h < 48 ? Math.round(h)+"h" : Math.round(h/24)+"d"; };
+const groups = {};
+S.forEach((s,i) => { (groups[s[2]] = groups[s[2]] || []).push(i); });
+const gEl = document.getElementById("groups");
+const ordered = Object.keys(groups).sort((a,b) => Math.max(...groups[b].map(i=>Date.parse(S[i][5]))) - Math.max(...groups[a].map(i=>Date.parse(S[i][5]))));
+ordered.forEach((proj, gi) => {
+  const det = document.createElement("details");
+  det.className = "grp"; det.open = gi < 2;
+  const ids = groups[proj];
+  det.innerHTML = `<summary><span class="car">▶</span><input type="checkbox" class="gchk" data-g="${gi}" onclick="event.stopPropagation()"><span style="font-family:var(--font-mono);font-size:12px">${proj}</span><span style="color:var(--text-muted);font-weight:400">(${ids.length})</span></summary>` +
+    ids.map(i => { const [id,t,,arch,run,ts,wt] = S[i]; return `<label class="row" data-i="${i}" data-txt="${(t+" "+proj+" "+wt).toLowerCase().replace(/"/g,"")}">
+      <input type="checkbox" class="chk" data-i="${i}" data-g="${gi}">
+      <span class="ttl" title="${t.replace(/"/g,"&quot;")}">${t}</span>
+      ${wt ? `<span class="badge b-wt">⌥ ${wt.replace(/-[0-9a-f]+$/,"")}</span>` : ""}
+      ${run ? `<span class="badge b-run">running</span>` : ""}
+      ${arch ? `<span class="badge b-arch">archived</span>` : ""}
+      ${REBOUND.has(id) ? `<span class="badge b-reb">✓ rebound</span>` : ""}
+      <button class="peek" data-i="${i}" title="peek last messages">👁</button>
+      <span class="age">${age(ts)}</span>
+    </label>`; }).join("");
+  gEl.appendChild(det);
+});
+const chks = [...document.querySelectorAll(".chk")];
+const cnt = document.getElementById("cnt");
+const btns = ["recover","summon","clear"].map(id => document.getElementById(id));
+function sel(){ return chks.filter(c => c.checked).map(c => S[+c.dataset.i]); }
+function refresh(){
+  const n = sel().length;
+  cnt.textContent = n + " selected";
+  btns.forEach(b => b.disabled = !n);
+  document.querySelectorAll(".gchk").forEach(g => {
+    const mine = chks.filter(c => c.dataset.g === g.dataset.g);
+    const on = mine.filter(c => c.checked).length;
+    g.checked = on === mine.length && on > 0;
+    g.indeterminate = on > 0 && on < mine.length;
+  });
+}
+document.getElementById("sp").addEventListener("change", e => {
+  if (e.target.classList.contains("gchk"))
+    chks.filter(c => c.dataset.g === e.target.dataset.g && c.closest(".row").style.display !== "none")
+        .forEach(c => c.checked = e.target.checked);
+  refresh();
+});
+document.getElementById("flt").addEventListener("input", e => {
+  const q = e.target.value.toLowerCase();
+  document.querySelectorAll(".row").forEach(r => r.style.display = r.dataset.txt.includes(q) ? "" : "none");
+  document.querySelectorAll(".grp").forEach(d => { if (q) d.open = true; });
+});
+document.querySelectorAll(".peek").forEach(p => p.addEventListener("click", e => {
+  e.preventDefault(); e.stopPropagation();
+  const [id, t] = S[+p.dataset.i];
+  sendPrompt(`Peek session ${id} ("${t}") — show me its last few messages via summon --peek so I can decide whether to recover it.`);
+}));
+function fmt(list){ return list.map(([id,t,cwd,,,,wt]) => `- ${id} "${t}" @ ${cwd}${wt ? "\\.claude\\worktrees\\" + wt : ""}`).join("\n"); }
+document.getElementById("recover").addEventListener("click", () => {
+  sendPrompt(`Recover these sessions I ticked in the picker (summon recover each — distilled handover brief + fresh-start prompt):\n${fmt(sel())}`);
+});
+document.getElementById("summon").addEventListener("click", () => {
+  sendPrompt(`Summon (copy) these sessions to my other account — run summon with --select for exactly these, show me the plan with --dry-run first:\n${fmt(sel())}`);
+});
+document.getElementById("clear").addEventListener("click", () => { chks.forEach(c => c.checked = false); refresh(); });
+refresh();
+</script>

+ 59 - 3
skills/summon/scripts/summon.py

@@ -9,7 +9,9 @@ Output:  transfer/pick/doctor render TTY panels; recover/pick emit a paste-ready
          Unfinished / Open decisions / Key context) + transcript pointer, cached
          at <transcript>.handover.md; falls back to the plain pointer prompt when
          the `claude` CLI is unavailable or --no-distill is set. doctor --json
-         emits {"data": [...], "meta": {"schema": "claude-mods.summon.doctor/v1"}}
+         emits {"data": [...], "meta": {"schema": "claude-mods.summon.doctor/v1"}};
+         pick --json emits the session inventory as
+         {"data": [...], "meta": {"schema": "claude-mods.summon.pick/v1"}}
 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
@@ -18,6 +20,7 @@ Exit:    0 ok (including the non-distilled fallback — worker unavailability is
 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 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
@@ -1434,6 +1437,53 @@ def _is_live(s: Session, now_ms: int) -> bool:
         return False
 
 
+def _cwd_broken(s: Session) -> bool:
+    """Doctor's broken-binding check: recorded cwd no longer exists on disk."""
+    return not (bool(s.cwd) and Path(s.cwd).exists())
+
+
+def _iso_utc(ms: int) -> str:
+    """Epoch-ms -> ISO-8601 Z, '' for the unset 0."""
+    if not ms:
+        return ""
+    return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ms / 1000))
+
+
+def pick_json(candidates: list[Session], now_ms: int) -> int:
+    """`pick --json` — the session inventory as a claude-mods.summon.pick/v1
+    envelope on stdout (JSON only; panels never touch stdout on this path).
+
+    Feeds the in-chat visual picker (assets/picker-widget.html) and any other
+    scripted caller. An empty inventory is valid data, not an error: exit 0.
+    """
+    rows = []
+    for s in candidates:
+        transcript = resolve_transcript(s)[0]
+        rows.append({
+            "id": s.sid.removeprefix("local_")[:8],
+            "sessionId": s.sid,
+            "cliSessionId": s.cli_id,
+            "title": s.title,
+            "cwd": s.cwd,
+            "projectRoot": project_root(s.cwd),
+            "worktree": worktree_name(s.cwd),
+            "branch": str(s.data.get("branch") or ""),
+            "turns": s.turns,
+            "isArchived": bool(s.data.get("isArchived")),
+            "isRunning": _is_live(s, now_ms),
+            "brokenCwd": _cwd_broken(s),
+            "lastActivityAt": _iso_utc(s.last_activity_ms),
+            "account": s.account.short,
+            "accountEmail": s.account.email,
+            "transcriptPath": str(transcript) if transcript else None,
+        })
+    print(json.dumps({
+        "data": rows,
+        "meta": {"count": len(rows), "schema": "claude-mods.summon.pick/v1"},
+    }, indent=2))
+    return 0
+
+
 def mode_pick(args, accounts: list[Account]) -> int:
     """Interactive picker over the whole session store -> recovery prompt."""
     sessions: list[Session] = []
@@ -1443,6 +1493,10 @@ def mode_pick(args, accounts: list[Account]) -> int:
     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)
+
+    if args.json:
+        return pick_json(candidates, int(time.time() * 1000))
+
     if not candidates:
         eecho(f"no sessions match ({_window_label(days)})")
         return 3
@@ -1518,7 +1572,7 @@ def mode_doctor(args, accounts: list[Account]) -> int:
                 remote += 1
                 continue
             checked += 1
-            cwd_ok = bool(s.cwd) and Path(s.cwd).exists()
+            cwd_ok = not _cwd_broken(s)
             transcript, how = resolve_transcript(s)
             if how == "scanned":
                 transcript_scanned += 1
@@ -1639,7 +1693,9 @@ def main():
     p.add_argument("--force", action="store_true",
                    help="Rebind: allow --cwd paths that don't exist on disk yet")
     p.add_argument("--json", action="store_true",
-                   help="Doctor: emit findings as a JSON envelope on stdout")
+                   help="Doctor: emit findings as a JSON envelope on stdout. "
+                        "Pick: emit the session inventory as a JSON envelope "
+                        "(no picker; stdout is JSON only)")
     p.add_argument("--no-distill", action="store_true",
                    help="Recover/pick: skip the LLM handover distillation and emit "
                         "the plain pointer prompt")

+ 18 - 3
skills/summon/tests/run.sh

@@ -8,7 +8,8 @@
 # (rebind/recover/pick/doctor, incl. the worktree-repair hint), and the
 # distilled-handover flow (extraction skips tool blobs, cache hit/miss on
 # mtime, --no-distill, degrade paths via a PATH-shimmed fake `claude` —
-# no real LLM call is ever made by this suite).
+# no real LLM call is ever made by this suite), the pick --json inventory
+# envelope, and the in-chat picker asset (present + cited from SKILL.md).
 #
 # Usage:   bash tests/run.sh
 # Exit:    0 all pass, 1 one or more failures
@@ -16,12 +17,26 @@
 set -uo pipefail
 
 HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+FAILED=0
+
+# --- In-chat picker asset: file present, INJECT block intact, cited from SKILL.md
+ASSET="$HERE/../assets/picker-widget.html"
+if [[ -f "$ASSET" ]] \
+    && grep -q ">>> INJECT" "$ASSET" \
+    && grep -q "sendPrompt" "$ASSET" \
+    && grep -q "assets/picker-widget.html" "$HERE/../SKILL.md"; then
+  echo "  PASS  picker-widget.html asset present + INJECT block + cited from SKILL.md"
+else
+  echo "  FAIL  picker-widget.html asset present + INJECT block + cited from SKILL.md"
+  FAILED=1
+fi
 
 # Pick a python that actually executes — skips the Windows Store python3 stub.
 PYTHON=""
 for c in python python3 py; do
   if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PYTHON="$c"; break; fi
 done
-[[ -z "$PYTHON" ]] && { echo "no working python found — skipping" >&2; exit 0; }
+[[ -z "$PYTHON" ]] && { echo "no working python found — skipping python suite" >&2; exit "$FAILED"; }
 
-"$PYTHON" "$HERE/test_summon.py"
+"$PYTHON" "$HERE/test_summon.py" || FAILED=1
+exit "$FAILED"

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

@@ -39,6 +39,14 @@ Distilled handover (recover -> extract -> distill -> cache -> emit):
       --refresh forces re-distillation; a newer transcript mtime busts the cache
   20. degrade: `claude` absent from PATH -> plain pointer prompt, exit 0,
       stderr warning; failing `claude` (exit 1) -> same advisory fallback
+
+Pick --json (the in-chat visual picker's data feed):
+  21. pick --json emits a parseable claude-mods.summon.pick/v1 envelope on a
+      stdout free of panel glyphs (pure-ASCII JSON, nothing before/after it)
+  22. worktree cwds are split into projectRoot + worktree; non-worktree cwds
+      get worktree ""; brokenCwd flags the session whose recorded cwd is gone;
+      transcriptPath is resolved (scan fallback included) and ISO timestamps
+      parse
 """
 
 from __future__ import annotations
@@ -533,6 +541,86 @@ def distill_tests() -> None:
         shutil.rmtree(tmp, ignore_errors=True)
 
 
+def pick_json_tests() -> None:
+    """21-22. pick --json: envelope shape, clean stdout, worktree split, brokenCwd."""
+    import re
+    tmp = Path(tempfile.mkdtemp(prefix="summon-pickjson-"))
+    try:
+        sb = build_toolbox_sandbox(tmp)
+        env = sb["env"]
+
+        # _is_live also checks wrapper mtime — the fixtures were just written,
+        # so backdate them past the 10-minute liveness window.
+        stale = time.time() - 3600
+        for f in sb["ws"].glob("local_*.json"):
+            os.utime(f, (stale, stale))
+
+        # 21. parseable envelope, schema match, stdout clean of panel glyphs
+        rc, out, _ = run_mode(env, ["pick", "--json"])
+        try:
+            envlp = json.loads(out)
+        except json.JSONDecodeError:
+            envlp = {}
+        pure_ascii = all(ord(c) < 128 for c in out)
+        checks = {
+            "rc": rc == 0,
+            "parses": bool(envlp),
+            "schema": envlp.get("meta", {}).get("schema") == "claude-mods.summon.pick/v1",
+            "count": envlp.get("meta", {}).get("count") == 3 == len(envlp.get("data", [])),
+            "stdout-json-only": out.lstrip().startswith("{") and pure_ascii,
+            "no-glyphs": not any(g in out for g in ("╭", "│", "╰", "├", "\x1b[")),
+        }
+        if all(checks.values()):
+            ok("pick --json emits parseable pick/v1 envelope; stdout clean")
+        else:
+            no("pick --json emits parseable pick/v1 envelope; stdout clean",
+               f"failed={[k for k, v in checks.items() if not v]} rc={rc} out-head={out[:200]!r}")
+            return
+
+        rows = {r["sessionId"]: r for r in envlp["data"]}
+        moved = rows.get("local_aaaa-moved", {})
+        healthy = rows.get("local_bbbb-healthy", {})
+        mismatch = rows.get("local_cccc-mismatch", {})
+
+        # 22a. worktree cwd split into projectRoot + worktree
+        if (moved.get("projectRoot") == str(sb["old_root"])
+                and moved.get("worktree") == "wt1"
+                and healthy.get("worktree") == ""
+                and healthy.get("projectRoot") == healthy.get("cwd")):
+            ok("pick --json splits worktree cwd into projectRoot + worktree")
+        else:
+            no("pick --json splits worktree cwd into projectRoot + worktree",
+               f"moved-root={moved.get('projectRoot')!r} wt={moved.get('worktree')!r}")
+
+        # 22b. brokenCwd flags the moved session only
+        if (moved.get("brokenCwd") is True and healthy.get("brokenCwd") is False
+                and mismatch.get("brokenCwd") is False):
+            ok("pick --json flags brokenCwd for the missing-cwd session")
+        else:
+            no("pick --json flags brokenCwd for the missing-cwd session",
+               f"moved={moved.get('brokenCwd')} healthy={healthy.get('brokenCwd')}")
+
+        # 22c. transcriptPath resolved (incl. scan fallback), booleans + ISO stamps
+        scan_path = sb["projects"] / "X--some-unrelated-munged-dir" / "cli-mismatch.jsonl"
+        iso_re = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
+        checks = {
+            "scan-fallback": mismatch.get("transcriptPath") == str(scan_path),
+            "expected-path": str(healthy.get("transcriptPath") or "").endswith("cli-healthy.jsonl"),
+            "branch": moved.get("branch") == "claude/wt1",
+            "not-running": healthy.get("isRunning") is False,
+            "not-archived": healthy.get("isArchived") is False,
+            "iso": all(iso_re.match(r["lastActivityAt"]) for r in envlp["data"]),
+            "short-id": moved.get("id") == "aaaa-mov",
+        }
+        if all(checks.values()):
+            ok("pick --json resolves transcriptPath; ISO stamps + field types sane")
+        else:
+            no("pick --json resolves transcriptPath; ISO stamps + field types sane",
+               f"failed={[k for k, v in checks.items() if not v]}")
+    finally:
+        shutil.rmtree(tmp, ignore_errors=True)
+
+
 def main() -> int:
     # 1. Piped selection honoured even with --yes (the original bug: --yes
     #    used to select ALL candidates, ignoring the piped picks).
@@ -635,6 +723,9 @@ def main() -> int:
     # 18-20. Distilled handover: shim claude, cache lifecycle, degrade paths
     distill_tests()
 
+    # 21-22. pick --json: envelope, clean stdout, worktree split, brokenCwd
+    pick_json_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