myclaude
Fabrice P. Lauss𝕪’s ℤetetic Web

myclaude

myclaude is a python script that lists my recent Claude Code sessions (last 25, up to 3 fabriads), numbered, each with how long ago it was last used it, a short code and what it is about.

I can resume the session through its number in this list:

myclaude 3

I can grep through keywords with -g. This goes through all the archived sessions, not only the top ones.

The sessions inform by themselves what they are/were doing, but this can be edited by me with --whatis on the session number.

With -w I find on which virtual desktop an open session is, and with --bring I fetch it to the one I am on.

Written with the help of Claude Opus 5.5 on 21 October (2026). Version 1.7.0.

Why

I keep many sessions going at once, in as many terminals, and return to them days or weeks later. More than once I had to open a fresh session only to ask it to find an older one: the session that computed 0MsIjb, the session on bubbles in JC. Claude Code keeps every conversation and gives each a title, but the title is made at the start and stays: my longest session, three months and thousands of exchanges on all sorts of things, is still called by its first task. And claude --resume wants the long session id, typed from the directory the session was started in.

Usage

Command What it does
myclaude the 25 sessions used last, newest first; -50 for fifty, -a for all of them
myclaude -g WORD... every session ever that features all the words; --grep is the same
myclaude -d WORD... the same, looking also through the whole conversations
myclaude N resume session N of the list last shown in this terminal; what follows N goes to claude, as in myclaude 3 --model sonnet
myclaude CODE resume by code, eight characters or more of the session id
myclaude -p N print the command instead of running it: cd DIR && claude --resume ID
myclaude -f N resume although the session is still open in another terminal
myclaude --whatis N edit what session N is listed as (below)
myclaude --whatis N TEXT replace it by TEXT outright; TEXT - removes my label
myclaude -t N its report, and the other AF-tagged files it made
myclaude -w N the virtual desktop where session N is open; --where is the same, and -w alone gives every open session, by desktop
myclaude --bring N move the window of session N to the desktop I am on, and raise it; -b is the same
myclaude -l full session ids instead of codes
myclaude --rescan read every transcript again from the start
myclaude -h this table in two lines

The columns are the number to resume it by, how long ago it was last used, its code (the first eight characters of the session id), the AF tag of its report when it has one, and what it is. A dot marks a session open right now in some terminal, green while it works, orange while it waits; a ✝ marks one whose transcript is gone (below); a hollow ○ marks a stray, whose process is still there but has lost its terminal (below too). The description is upright when the session wrote it, or I did, and dim and italic when it is only Claude Code's own title—the case of every session older than myclaude—or the first thing I typed in it. A session not started from my home directory shows its directory after a dot.

The numbers are those of the list last shown in the same terminal, so after a search myclaude 2 is the second hit, not the second most recent session; each terminal remembers its own list, for twelve hours, after which the numbers are those of the plain list again. The session is resumed from the directory it was started in, where claude --resume looks for it—not always the first directory its transcript mentions, since a session may cd before its first turn. A session still open somewhere is not opened a second time unless asked with -f: two processes would be writing one transcript.

Searching

A search—-g or --grep, then the words—goes through every session myclaude has ever seen, not only the 25 on display. A session features a word when it is in its description or an earlier one, in a title Claude Code gave it, in anything I typed in it, in its directory, or among the AF tags that turned up in it—made there, or met in a file it read. Case and accents do not matter (resume finds résumé), and several words must all be there. A word on its own is never a search—alone, a number is a session to resume—so numbers are searched as any word: myclaude -g 2026. When the word is not in what is shown, a line under the session says where it was found:

laussy@azag:~$ myclaude -g showme
   #   last use  code      description
   1 ●    1 min  82c9c0df  showme 4.8: open .svg/.svgz files in inkscape instead of …
   2        6 d  063193ff  Ignore trailing punctuation in showme
   3        6 d  9dde1162  {{upload}} template for lw
                           ↳ …race-bundler-0LSUBb-1440.webm}} it uses showme to conf…
   4        6 d  33d9db56  Showme AF tag lookup
   5        6 d  0a2c29ac  Showme console cromulon indicator
   6        6 d  f3f8d13a  Showme version
   7       2 wk  a6eed9a0  Showme reference lookup
     7 of 150 feature showme · ● open now · myclaude N resumes · -h

The AF tags are fished out of everything said and done in the session, and only tags that can exist count: the first two characters of a tag count the tridua since the Anno Fabri epoch, so one that would date from the future is a word that merely looks like a tag. Runs of base64 (pictures) are skipped, and so are the reminders Claude Code slips into the conversation—recalled memory files, instructions—which would otherwise make every session feature what they say. With -d the search also goes through the whole conversations, by grep first and then turn by turn, since a transcript also holds the system prompt sent with every request.

Reports and AF tags

When a session has a report, the list gives the report's AF tag in a column of its own, which often says what a session did better than its title: one that Claude Code still calls llw2lw October page sync wrote sections 15 to 21 of the Gerace gates report, 0GQsoR. myclaude -t N gives the report, and the other AF-tagged files the session made, for showme to open:

laussy@azag:~$ myclaude -t 7639b223
7639b223  Quantum to classical jc-bubbles transitions n_a and g2 vs Pσ
  report  0JOalb  ~/comp/claude/9--jc-bubbles/report/report.tex
  made    0JP6sb  0JP6sb.png
          0JK5hb  paper-fig5-0JK5hb.png
          0JCxub  paper-fig6insets-0JCxub.png
          …
          0N1Lm   lasetest-0N1Lm.png
  showme TAG opens one

A session's report is found three ways. The report may name the session: since mid-October each report opens with a claude-resume: line for every session behind it, and myclaude reads those lines in every report it knows of—those in ~/comp/claude/*/report/, and any .tex a session wrote. Or the session wrote the report (report.tex, informe-0E1as.tex) or something in its report/ folder. Or it built a tagged copy, report-0KqdZb.pdf, in a shell command. The tag is the one in the report's name, in its "Built pdf:" line, on the tagged pdf next to it (report.tex → report-0JOalb.pdf), or in its folder's name (historical-people-0Kd35b/report.tex); a report with none shows as report. A session with several—the CSI notes and the multimode report—shows the one changed last, and -t shows them all.

The files a session made are the AF-tagged names it wrote, or used in a shell command, whose tag was minted while that session was at work. A tag being a lossless timestamp, af -b gives back its second; the index keeps the minutes each session worked—a long computation counting all along—so that a file it only read, or one that another session made, is left out. A search for a tag puts first the sessions it belongs to:

laussy@azag:~$ myclaude -g 0JOalb -3
   #   last use  code      report  description
   1 ●      now  7639b223  0JOalb  Quantum to classical jc-bubbles transitions n_a a…
                                   ↳ its report 0JOalb: ~/comp/claude/9--jc-bubbles/…
   2        8 d  1cea924f  0JOalb  High-resolution Jaynes-Cummings bubbles
                                   ↳ its report 0JOalb: ~/comp/claude/9--jc-bubbles/…
   3 ●      now  fe02b723          myclaude: numbered session list, keyword search, …
                                   ↳ AF tag 0JOalb met in the session
     10 of 150 feature 0JOalb · 7 more: -10 or -a · ● open now · myclaude N resumes …

Where it is open

With sixteen virtual desktops, a session open somewhere is not always easy to find. myclaude -w N says on which desktop it is, numbered from 1 as in the KDE pager, and on which I am:

laussy@azag:~$ myclaude -w cd542f5d
cd542f5d  myclaude: add --where/-w N to show the virtual desktop a session runs on
  desktop 10 · you are on desktop 7 · window “~ : claude — Konsole” · pid 953645, busy

myclaude -w alone gives every open session, sorted by desktop, those on mine in green. A session that is not open says so, and how to resume it.

myclaude --bring N moves the window of the session to the desktop I am on, and raises it. In that order: asked to raise a window that is on another desktop, KWin switches the screen to that desktop instead.

To find the window, myclaude goes up from the session's process (Claude Code lists the open ones in ~/.claude/sessions/) to the first of its parents that owns a window—each of my Konsole windows is a process of its own—and asks wmctrl on which desktop that window is. A session inside tmux or screen, or over ssh, has no window of its own, and says so.

A session whose Konsole was closed while it was suspended (Ctrl-Z) is worse off: its process stays, stopped, with no terminal to come back to, and Claude Code goes on listing it as open. myclaude calls it a stray: -w and --bring say it is not open and has no window to find, the lists mark it with a hollow ○, and myclaude N ends that process before resuming the session in a new terminal.

How each session says what it is

A UserPromptSubmit hook, in ~/.claude/settings.json, runs myclaude hook each time I send a prompt, in every session:

"hooks": {
  "UserPromptSubmit": [
    {
      "hooks": [
        { "type": "command", "command": "/home/laussy/bin/myclaude hook", "timeout": 5 }
      ]
    }
  ]
}

While the session has no description, the hook adds this to what Claude reads along with my prompt:

myclaude: Fabrice finds and resumes his past sessions with `myclaude`, a numbered list showing each session by a one-line description, and this session has none yet. As soon as its task is clear (now, if it already is), run: myclaude --whatis "..." -- about 70 characters at most, specific and searchable: what is being done and to what (project directory such as 9--jc-bubbles, paper, bib key, AF tag, tool, wiki page, person by first name); no dates, no "Session about"; confidential names stay out. Run it again if the work changes course. No need to mention it in the reply.

and the session runs myclaude --whatis "…", which finds the session's own id in $CLAUDE_CODE_SESSION_ID. From then on the hook is silent, but for two occasions on which it shows the session its description and lets it change it: when the session is taken up again after six hours or more, and every 40 prompts. Within minutes of the hook being installed, a session I was working on described itself as 9--jc-bubbles Secs. 17-19: adaptive grid 6.5-8g, n_a and g2 vs Psigma, and some time later as Quantum to classical jc-bubbles transitions n_a and g2 vs Pσ. The earlier descriptions are kept, and searched. The hook takes some 30 ms per prompt and says nothing to sessions run headless (claude -p).

I can say it myself too: myclaude --whatis 3 puts what session 3 is listed as on an editable line, the cursor at its end, to change or to carry on (with the Emacs keys). Enter makes it my label, which wins over the session's own and stops the hook asking; an empty line gives the session its own back; Ctrl-C leaves everything as it was.

Kept for three fabriads

Claude Code deletes a transcript 30 days after it was last written to (its setting cleanupPeriodDays), and the session can no longer be resumed. I raised that to 514 days—3 fabriads, rounded up to whole days—about 32 GB of transcripts at my present rate. myclaude keeps its own index of what it has read, so a session whose transcript is gone is still found by a search, marked ✝, even though it cannot be resumed.

The index lives in ~/.local/share/myclaude/, readable by me only since it holds my prompts: scan/ for what was read from each transcript—each read on from where it was left, so that a call takes 30 ms—desc/ for the descriptions, hook/ for the prompt counts, lists/ for the last list shown in each terminal, docs.json for the heads of the reports read and aftimes.json for the tags af has read back.

The eyes

The eyes are this page's own picture, File:Lw-0NtRp.png. They open the line on top of the list, one line tall, beside the version and what the list is—my Claude Code sessions, last used first, or sessions featuring showme. In Konsole (and in iTerm2 or WezTerm) they are the picture itself, sent with the iTerm2 inline-image escape. Konsole scales such pictures nearest-neighbour, which speckles them, so myclaude asks the terminal for the size of a character cell (CSI 16 t) and makes the picture to the pixel for it—five cells of 8×15 pixels here—kept in ~/.cache/myclaude/, as showme does for its cromulon. Anywhere else they are the letters ʘ ʘ, as the cromulon falls back to ʘ︵ʘ. Above, the picture; below, the letters:

They are not printed when the list goes to a file or a pipe, nor with MYCLAUDE_EYES=off; MYCLAUDE_EYES=text asks for the letters.

Versions

On 21 October (2026):

  • 1.0.0—numbered list, search through all the sessions, resume by number or code, the hook.
  • 1.1.0—describing became an option, which with no text puts the description on an editable line.
  • 1.2.0—the option renamed --whatis; --describe and describe are still understood, for the sessions that learned them.
  • 1.3.0—the eyes of Claude Shannon, three lines tall, and half-blocks outside Konsole.
  • 1.4.0—the eyes down to one line, which they share with the version and what the list is; searching only with -g or --grep; -h in two lines.
  • 1.5.0—each session's report, and its AF tag in a column; -t for the report and the tagged files a session made, to the minute it was at work.

On 22 October (2026):

  • 1.6.0—-w, the virtual desktop where a session is open.
  • 1.7.0—--bring, to move a session's window to the desktop I am on.

On 26 October (2026):

  • 1.7.1—a session refused because it is open elsewhere points to --bring and -w.

On 30 October (2026):

  • 1.7.2—strays, sessions whose process lost its terminal, no longer pass for open; resuming one ends that process.

The script

#!/usr/bin/env python3
"""myclaude — my Claude Code sessions, numbered; `myclaude N` goes back into one.

    myclaude                  the 25 sessions used last, newest first
    myclaude -50 | -a         the last 50 | every session
    myclaude -g WORD...       (--grep) every session ever, not just the last 25,
                              featuring all the WORDs: in its description, title,
                              my prompts, the AF tags it made or met, its directory
    myclaude -d WORD...       the same, and also anywhere in the transcripts on disk
    myclaude N [ARGS]         claude --resume session N of the list last shown in
                              THIS terminal, from the directory it ran in;
                              ARGS go to claude (myclaude 3 --model sonnet)
    myclaude CODE             the same by code (at least 8 characters of the id)
    myclaude -f N             resume it although it is still open somewhere
    myclaude -p N             print the command (cd DIR && claude --resume ID) instead
    myclaude -t N             its report(s), and the other AF-tagged files it made
    myclaude -w N             (--where) the virtual desktop it is on, when it is
                              open; myclaude -w alone: every open session, by desktop
    myclaude --bring N        (-b) move its window onto the desktop I am on, and raise it
    myclaude -l ...           full session ids instead of codes
    myclaude --whatis N       edit what session N is listed as: the description is
                              put on the line, cursor at its end, to change or to
                              continue (Emacs keys); Enter makes it my label, which
                              wins over the session's own; an empty line gives the
                              session's own back; Ctrl-C leaves it as it was
    myclaude --whatis N TEXT  replace it by TEXT outright (TEXT - removes my label)
    myclaude --rescan         rebuild the index from the transcripts
    myclaude -h               (--help) all this in two lines

On top, one line: the eyes of Claude Shannon (the wiki's File:Lw-0NtRp.png), a
picture in Konsole and ʘ ʘ elsewhere, the version, and what the list is;
MYCLAUDE_EYES=off for no eyes, =text for the letters.
Columns: number, time since last use, code (start of the session id), the AF
tag of its report when it has one, what it is.  ● = open right now in some terminal (green: working, yellow: idle).  The
description is the session's own, else Claude Code's auto-title (dim), else the
first prompt (dim).  ✝ = transcript deleted by Claude Code: still found, no
longer resumable.  ○ = a stray: its process is still there but has lost its
terminal (the Konsole was closed while it was suspended with Ctrl-Z), so it
can never be typed into again; myclaude N ends that process and resumes it.

WHERE THE DESCRIPTIONS COME FROM.  A UserPromptSubmit hook in
~/.claude/settings.json runs `myclaude hook` on every prompt.  While a session
has no description, the hook slips a line into its context asking it to run
`myclaude --whatis "TEXT"` (one line, specific, searchable); the session's id
comes from $CLAUDE_CODE_SESSION_ID.  Once described the hook is silent, except
once after a 6-hour gap (a resumed session, maybe on something new) and once
every 40 prompts, when it shows the session its description and lets it
change it.  Old descriptions are kept and searched.

REPORTS.  A session's report is the .tex that names it in a claude-resume:
line, or a report*/informe* file it wrote or built, or the report.tex of a
report/ folder it wrote in; its tag, the one in the document's name, in its
"Built pdf:" line, on the tagged pdf beside it (report-0JOalb.pdf), or in its
folder's name.  The other files it MADE are the AF-tagged names it wrote or
used in a shell command whose tag was minted while the session was at work: a
tag is a lossless timestamp (`af -b` reads it back), and the index keeps the
minutes each session worked, a long command counting all along; so a file it
only read, or one another session made, is left out.  (Without af, the tag's
first two characters date it to the triduum.)  aftimes.json keeps what af said.

WHERE.  -w goes up from the process of the open session (~/.claude/sessions)
to the first of its ancestors that owns an X window -- every Konsole window is
a process of its own; if that one owns several, its $WINDOWID picks -- and asks
wmctrl the desktop of that window, numbered from 1 as KDE's pager does.
--bring sends that window to the desktop on the screen, and only then raises it:
asked to raise a window on another desktop, KWin switches to that desktop.
A session whose process has no controlling terminal any more (tty_nr 0 in
/proc/PID/stat) is a stray, not open: there is no window to find; resuming
it first ends that process (TERM, and CONT for a stopped one; KILL 3 s later).

THE INDEX, ~/.local/share/myclaude/: scan/ is read incrementally out of
~/.claude/projects/*/*.jsonl (each file from where it was left), desc/ holds
the descriptions, hook/ the per-session prompt counter, lists/ the last list
shown in each terminal, so that the numbers mean what was on the screen,
docs.json the heads of the reports read (by mtime).
Claude Code deletes transcripts after cleanupPeriodDays (30 by default); their
index entries stay, so a search still finds them.

Written 21 October 2026.
"""

import base64
import bisect
import glob
import json
import os
import re
import select
import shutil
import signal
import subprocess
import sys
import termios
import time
import unicodedata

__version__ = '1.7.2'

HOME = os.path.expanduser('~')
PROJECTS = os.path.join(HOME, '.claude', 'projects')
LIVE = os.path.join(HOME, '.claude', 'sessions')
DATA = os.path.join(HOME, '.local', 'share', 'myclaude')
SCAN, DESC, HOOK, LISTS = (os.path.join(DATA, d) for d in ('scan', 'desc', 'hook', 'lists'))
DOCS = os.path.join(DATA, 'docs.json')
AFTIMES = os.path.join(DATA, 'aftimes.json')
DEFAULT_N = int(os.environ.get('MYCLAUDE_N') or 25)
SCAN_V = 5              # bump to make every transcript be read again from the start
PROMPT_CAP = 600        # characters kept of each prompt, for the search
GAP = 6 * 3600          # a pause this long and the session is asked again
EVERY = 40              # ... and every this many prompts
UUID = re.compile(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\Z')
MARKS = (b'"type":"user"', b'"type":"assistant"', b'"type":"ai-title"', b'"type":"custom-title"')

# AF tags (~/bin/AF): five base-62 characters, the first two an era counter of
# 2.76-day blocks since 18 Aug 2026, so a real tag has an era not past today's.
B62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
AF_BASE, AF_ERA = 1787004000, 238328
AF_TOP = (int(time.time()) - AF_BASE) // AF_ERA + 1
AF_TAG = re.compile(r'(?<![0-9A-Za-z])([%s][0-9A-Za-z]{4}[A-Za-z]?)(?![0-9A-Za-z])'
                    % re.escape(B62[:AF_TOP // 62 + 1]))
BLOBS = re.compile(r'[A-Za-z0-9+/=_-]{100,}')       # base64 images would be all tags
FILE_TOKEN = re.compile(r'[\w.+~/-]+\.[A-Za-z][A-Za-z0-9]{0,5}(?![\w.])')
REPORT_FILE = re.compile(r'(?i)(report|informe)\b.*\.(tex|pdf)$')
BYPRODUCT = ('.log', '.aux', '.blg', '.bbl', '.out', '.fls', '.toc', '.bcf', '.xml', '.gz',
             '.fdb_latexmk', '.nav', '.snm', '.run')

NEW = ('myclaude: Fabrice finds and resumes his past sessions with `myclaude`, a '
       'numbered list showing each session by a one-line description, and this '
       'session has none yet. As soon as its task is clear (now, if it already '
       'is), run: myclaude --whatis "..." -- about 70 characters at most, specific '
       'and searchable: what is being done and to what (project directory such as '
       '9--jc-bubbles, paper, bib key, AF tag, tool, wiki page, person by first '
       'name); no dates, no "Session about"; confidential names stay out. Run it '
       'again if the work changes course. No need to mention it in the reply.')
AGAIN = ('myclaude: {why}; `myclaude` lists this session as "{text}". If that no '
         'longer says what the session is about (whole session, latest focus '
         'first), re-describe it: myclaude --whatis "...". Otherwise ignore this.')

TTY = sys.stdout.isatty() and not os.environ.get('NO_COLOR')

# The eyes of Claude Shannon, the wiki's File:Lw-0NtRp.png, open the line on
# top of the list, one line tall.  In Konsole (also iTerm2, WezTerm) they are
# the picture itself, through the iTerm2 inline-image escape; Konsole scales
# such pictures nearest-neighbour, so the picture is made to the pixel for the
# cell size the terminal reports (CSI 16 t), and kept in ~/.cache/myclaude.
# Anywhere else, the letters ʘ ʘ, as showme's cromulon falls back to ʘ︵ʘ.
# MYCLAUDE_EYES=off removes them, =text asks for the letters.
EYES_PNG = '/var/www/html/laussywiki/images/1/14/Lw-0NtRp.png'
EYES_TEXT = 'ʘ ʘ'
HELP = ('myclaude [-50|-a] list · [-p|-f] N|CODE resume · -g|--grep WORD... search all (-d deeper)\n'
        '         --whatis N [TEXT] what N is · -t N its report & AF tags · -w [N] its desktop · -b N bring it here'
        ' · -l full ids · --rescan · -v')


def paint(code, s):
    return f'\033[{code}m{s}\033[0m' if TTY and s else s


def die(msg, code=1):
    print(f'myclaude: {msg}', file=sys.stderr)
    sys.exit(code)


# ------------------------------------------------------------------ storage

def load(path):
    try:
        with open(path, encoding='utf-8') as fh:
            return json.load(fh)
    except (OSError, ValueError):
        return None


def save(path, obj):
    if not os.path.isdir(DATA):         # my prompts are in there: mine only
        os.makedirs(DATA, mode=0o700, exist_ok=True)
    os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
    tmp = f'{path}.{os.getpid()}.tmp'
    with open(tmp, 'w', encoding='utf-8') as fh:
        json.dump(obj, fh, ensure_ascii=False)
    os.replace(tmp, path)


def stamp(iso):
    from datetime import datetime
    try:
        return datetime.fromisoformat(iso.replace('Z', '+00:00')).timestamp()
    except (AttributeError, ValueError):
        return None


def short(path):
    return '~' + path[len(HOME):] if path and path.startswith(HOME) else (path or '?')


# ------------------------------------------------- reading the transcripts

def tag_era(tag):
    return B62.index(tag[0]) * 62 + B62.index(tag[1])


def era_of(when):
    return int((when - AF_BASE) // AF_ERA) if when else 0


def af_tags(text):
    """The AF tags in a piece of text that can exist, in order."""
    out = []
    for m in AF_TAG.finditer(text):
        if tag_era(m.group(1)) <= AF_TOP and m.group(1) not in out:
            out.append(m.group(1))
    return out


def note_files(tool, args, rec):
    """Files a tool call writes, and AF-tagged names a shell command uses."""
    if tool in ('Write', 'Edit', 'NotebookEdit'):
        path = args.get('file_path') or args.get('notebook_path')
        if isinstance(path, str) and path.startswith('/') and path not in rec['written']:
            rec['written'].append(path)
    elif tool == 'Bash' and isinstance(args.get('command'), str):
        for token in FILE_TOKEN.findall(args['command']):
            name = os.path.basename(token).lstrip('-.')     # $x-0JP6sb.png -> 0JP6sb.png
            if af_tags(name) and name not in rec['named'] and len(rec['named']) < 4000:
                rec['named'].append(name)


def tags_in(text, into):
    for m in AF_TAG.finditer(BLOBS.sub(' ', text)):
        tag = m.group(1)
        if B62.index(tag[0]) * 62 + B62.index(tag[1]) <= AF_TOP:
            into.add(tag)


REMINDER = re.compile(r'<system-reminder>.*?(?:</system-reminder>|\Z)', re.S)


def texts(content):
    """Every piece of text in a message: what was said, tool calls, tool results --
    less the <system-reminder> blocks Claude Code slips in (recalled memory files,
    CLAUDE.md...), which would make every session feature what they say."""
    if isinstance(content, str):
        yield REMINDER.sub(' ', content) if '<system-reminder>' in content else content
    elif isinstance(content, list):
        for b in content:
            if not isinstance(b, dict):
                continue
            kind = b.get('type')
            if kind == 'text':
                yield from texts(b.get('text') or '')
            elif kind == 'tool_use':
                yield json.dumps(b.get('input'), ensure_ascii=False)
            elif kind == 'tool_result':
                yield from texts(b.get('content'))


PASTE = re.compile(r'<pasted_content[^>]*>(.*?)</pasted_content[^>]*>', re.S)


def prompt_of(entry):
    """What I typed, or None for everything else that is filed as 'user'."""
    if entry.get('isMeta') or entry.get('isCompactSummary'):
        return None
    said = (entry.get('message') or {}).get('content')
    if isinstance(said, list):
        if any(isinstance(b, dict) and b.get('type') == 'tool_result' for b in said):
            return None
        said = ' '.join(b.get('text') or '' for b in said
                        if isinstance(b, dict) and b.get('type') == 'text')
    if not isinstance(said, str):
        return None
    said = said.strip()
    if (not said or said.startswith(('[Request interrupted', '[Image'))
            or (said.startswith('<') and not said.startswith('<pasted_content'))
            or re.fullmatch(r'/\S+', said)):
        return None
    said = PASTE.sub(lambda m: '«' + ' '.join(m.group(1).split())[:150] + '…»', said)
    return ' '.join(said.split())[:PROMPT_CAP]


def launch_dir(path, cwds):
    """The directory the session was started in: `claude --resume` must run from
    there, as it looks for the transcript in the project folder named after it.
    The first cwd in the file is not always it (a `cd` before the first turn)."""
    project = os.path.basename(os.path.dirname(path))
    for cwd in cwds:
        if re.sub(r'[^A-Za-z0-9]', '-', cwd) == project:
            return cwd
    return cwds[0] if cwds else None


def blank(sid, path):
    return {'v': SCAN_V, 'id': sid, 'path': path, 'cwd': None, 'entry': None,
            'cwds': [], 'started': None, 'last': None, 'prompts': [], 'title': None,
            'ctitle': None, 'titles': [], 'tags': [], 'written': [], 'named': [],
            'minutes': [], 'pending': None, 'offset': 0, 'size': 0, 'mtime': 0}


def scan(path, again=False):
    """Bring the index entry of one transcript up to date; read only what is new."""
    sid = os.path.basename(path)[:-6]
    where = os.path.join(SCAN, sid + '.json')
    rec = None if again else load(where)
    try:
        st = os.stat(path)
    except OSError:
        return rec
    if rec and rec.get('v') == SCAN_V and rec['size'] == st.st_size and rec['mtime'] == st.st_mtime:
        return rec
    if not rec or rec.get('v') != SCAN_V or st.st_size < rec['offset']:
        rec = blank(sid, path)
    rec['path'] = path
    tags = set(rec['tags'])
    minutes = set(rec['minutes'])               # when it was at work, to the minute
    with open(path, 'rb') as fh:
        fh.seek(rec['offset'])
        data = fh.read()
    end = data.rfind(b'\n') + 1                 # a half-written last line waits
    for raw in data[:end].split(b'\n'):
        if not any(m in raw for m in MARKS):
            continue
        try:
            e = json.loads(raw)
        except ValueError:
            continue
        kind = e.get('type')
        if kind in ('user', 'assistant'):
            if e.get('isSidechain'):
                continue
            when = stamp(e.get('timestamp'))
            if when:
                rec['started'] = rec['started'] or when
                rec['last'] = max(rec['last'] or 0, when)
                minutes.add(int(when // 60))
                blocks = (e.get('message') or {}).get('content')
                blocks = blocks if isinstance(blocks, list) else []
                kinds = {b.get('type') for b in blocks if isinstance(b, dict)}
                if kind == 'user' and 'tool_result' in kinds and rec['pending']:
                    # a long command: the session was at work all along
                    minutes.update(range(int(rec['pending'] // 60), int(when // 60) + 1)[:720])
                if kind == 'assistant' and 'tool_use' in kinds:
                    rec['pending'] = rec['pending'] or when
                elif kind == 'user' and 'tool_result' in kinds:
                    rec['pending'] = None
            if e.get('cwd') and e['cwd'] not in rec['cwds']:
                rec['cwds'].append(e['cwd'])
            rec['entry'] = rec['entry'] or e.get('entrypoint')
            if kind == 'user':
                said = prompt_of(e)
                if said:
                    rec['prompts'].append(said)
            for s in texts((e.get('message') or {}).get('content')):
                tags_in(s, tags)
            if kind == 'assistant':
                for b in (e.get('message') or {}).get('content') or []:
                    if isinstance(b, dict) and b.get('type') == 'tool_use':
                        note_files(b.get('name'), b.get('input') or {}, rec)
        elif kind == 'ai-title' and e.get('aiTitle'):
            rec['title'] = e['aiTitle']
            if e['aiTitle'] not in rec['titles']:
                rec['titles'].append(e['aiTitle'])
        elif kind == 'custom-title' and e.get('customTitle'):
            rec['ctitle'] = e['customTitle']
    rec['tags'] = sorted(tags)[:5000]
    rec['minutes'] = sorted(minutes)[-60000:]
    rec['cwd'] = launch_dir(path, rec['cwds'])
    rec['offset'] += end
    rec['size'], rec['mtime'] = st.st_size, st.st_mtime
    save(where, rec)
    return rec


def index(again=False):
    """Every session I ever had in a shell: transcripts on disk + what was kept of the rest."""
    recs = {}
    for path in glob.glob(os.path.join(PROJECTS, '*', '*.jsonl')):
        rec = scan(path, again)
        if rec:
            recs[rec['id']] = rec
    for path in glob.glob(os.path.join(SCAN, '*.json')):
        sid = os.path.basename(path)[:-5]
        if sid not in recs:
            rec = load(path)
            if rec:
                rec['gone'] = True
                recs[sid] = rec
    return {sid: r for sid, r in recs.items() if r.get('entry') == 'cli' and r['prompts']}


def doc_head(path, cache):
    """The claude-resume ids and the "Built pdf:" name in the head of a document."""
    try:
        mtime = os.path.getmtime(path)
    except OSError:
        return None
    head = cache.get(path)
    if head and head['mtime'] == mtime:
        return head
    try:
        with open(path, 'rb') as fh:
            text = fh.read(16384).decode('utf-8', 'replace')
    except OSError:
        return None
    built = re.search(r'Built pdf:\s*(\S+\.pdf)', text)
    head = cache[path] = {'mtime': mtime, 'built': built.group(1) if built else None,
                          'ids': sorted(set(re.findall(r'claude-resume:([0-9a-f-]{36})', text)))}
    return head


def doc_tag(path, head):
    """The AF tag a document goes by: in its name, in its "Built pdf:" line, on the
    tagged pdf beside it (report.tex -> report-0JOalb.pdf), or in its folder's name."""
    for tag in af_tags(os.path.basename(path)):
        return tag
    for tag in af_tags((head or {}).get('built') or ''):
        return tag
    stem, where = os.path.splitext(os.path.basename(path))[0], os.path.dirname(path)
    try:
        pdfs = [f for f in os.listdir(where) if f.startswith(stem + '-') and f.endswith('.pdf')
                and af_tags(f[len(stem):])]
    except OSError:
        pdfs = []
    if pdfs:
        newest = max(pdfs, key=lambda f: os.path.getmtime(os.path.join(where, f)))
        return af_tags(newest[len(stem):])[-1]
    for tag in af_tags(os.path.basename(where)):
        return tag
    return None


def documents(recs):
    """Session id -> its reports, [(tag or None, path or name)], latest first."""
    cache = load(DOCS) or {}
    before = len(cache), sum(h['mtime'] for h in cache.values())
    found = {sid: [] for sid in recs}
    wanted = set(glob.glob(os.path.join(HOME, 'comp', 'claude', '*', 'report', '*.tex')))
    for rec in recs.values():
        for path in rec.get('written', []):
            if path.endswith('.tex'):
                wanted.add(path)
            if os.path.basename(os.path.dirname(path)) == 'report':
                wanted.add(os.path.join(os.path.dirname(path), 'report.tex'))
    heads = {path: doc_head(path, cache) for path in wanted}
    for path, head in heads.items():            # the documents that name their sessions
        if head and head['ids']:
            tag = doc_tag(path, head)
            for sid in head['ids']:
                if sid in found:
                    found[sid].append((head['mtime'], tag, path))
    for sid, rec in recs.items():               # the reports a session wrote or built
        mine = found[sid]
        for path in rec.get('written', []):
            folder = os.path.dirname(path)
            doc = (path if REPORT_FILE.match(os.path.basename(path)) else
                   os.path.join(folder, 'report.tex') if os.path.basename(folder) == 'report' else None)
            if doc and heads.get(doc) and doc not in [m[2] for m in mine]:
                mine.append((heads[doc]['mtime'], doc_tag(doc, heads[doc]), doc))
        named = [n for n in rec.get('named', []) if REPORT_FILE.match(n)]
        times = tag_times(af_tags(n)[-1] for n in named)
        for name in named:                      # built by it, not just mentioned
            tag = af_tags(name)[-1]
            if at_work(rec, tag, times) and tag not in [m[1] for m in mine]:
                mine.append((0, tag, name))
    if (len(cache), sum(h['mtime'] for h in cache.values())) != before:
        save(DOCS, cache)
    out = {}
    for sid, docs in found.items():
        seen, out[sid] = set(), []
        for _, tag, where in sorted(docs, key=lambda d: -d[0]):
            if (tag or where) not in seen:
                seen.add(tag or where)
                out[sid].append((tag, where))
    return out


def tag_times(tags):
    """AF tag -> the second it stands for.  A tag is a lossless timestamp: `af -b`
    reads any number back in one go (5 ms); what it says is kept in aftimes.json."""
    cache = load(AFTIMES) or {}
    todo = sorted({t for t in tags if t not in cache})
    if todo and shutil.which('af'):
        for i in range(0, len(todo), 400):
            part = todo[i:i + 400]
            try:
                out = subprocess.run(['af', '-b', *part], capture_output=True, text=True,
                                     timeout=20).stdout.splitlines()
            except (OSError, subprocess.SubprocessError):
                break
            if len(out) == len(part):
                for tag, line in zip(part, out):
                    first = line.split('\t')[0]
                    cache[tag] = int(first) if first.isdigit() else None
        save(AFTIMES, cache)
    return cache


def at_work(rec, tag, times):
    """Was the session at work when the tag was made (within three minutes)?  Without
    `af` to read the tag: was the tag made during its lifetime, to the triduum."""
    when, minutes = times.get(tag), rec.get('minutes') or []
    if when is None or not minutes:
        return era_of(rec.get('started')) - 1 <= tag_era(tag) <= era_of(rec.get('last')) + 1
    i = bisect.bisect_left(minutes, when // 60 - 3)
    return i < len(minutes) and minutes[i] <= when // 60 + 3


def made(rec, skip=()):
    """AF tag -> the names it carries, for the files the session made: tagged names
    it wrote or used whose tag was made while it was at work, so that files it only
    read, or that another session made, stay out."""
    pairs = [(tag, name) for name in rec.get('named', []) + [os.path.basename(p) for p in rec.get('written', [])]
             for tag in af_tags(name) if tag not in skip]
    times = tag_times(tag for tag, _ in pairs)
    out = {}
    for tag, name in pairs:
        if name not in out.get(tag, []) and at_work(rec, tag, times):
            out.setdefault(tag, []).append(name)
    return out


def best_name(names):
    """The name to show for a tag: a pdf or picture before sources, by-products last."""
    order = ('.pdf', '.png', '.svg', '.gif', '.webm', '.mp4', '.jpg', '.tex', '.md', '.nb', '.py')
    def rank(n):
        ext = os.path.splitext(n)[1].lower()
        return (ext in BYPRODUCT, order.index(ext) if ext in order else len(order))
    return min(names, key=rank)


def proc_stat(pid):
    """The fields of /proc/PID/stat after the command's name (state first), or None."""
    try:
        with open(f'/proc/{pid}/stat') as fh:
            stat = fh.read()
    except OSError:
        return None
    return stat[stat.rfind(')') + 2:].split()


def open_sessions():
    """Session id -> its ~/.claude/sessions/<pid>.json, for the processes alive now;
    'state' added (R, S, T...), and 'stray' when the process has lost its terminal."""
    out = {}
    for path in glob.glob(os.path.join(LIVE, '*.json')):
        d = load(path)
        if not d or not d.get('sessionId') or 'pid' not in d:
            continue
        f = proc_stat(d['pid'])
        if not f or f[0] in 'ZX':
            continue
        if d.get('procStart') and str(d['procStart']) != f[19]:
            continue                    # the pid was handed to another process since
        d['state'] = f[0]
        if f[4] == '0' and d.get('kind', 'interactive') == 'interactive':
            d['stray'] = True           # no controlling terminal: its Konsole was closed
                                        # while it was suspended, and it waits for ever
        out[d['sessionId']] = d
    return out


def status(d):
    return 'suspended' if d.get('state') in ('T', 't') else d.get('status', '?')


def end_stray(d):
    """Make a stray's process exit: TERM, and CONT so that a stopped one acts on it
    (and lets go of its MCP servers); KILL if it is still there 3 s later."""
    pid, born = d['pid'], proc_stat(d['pid'])
    if not born:
        return 'gone already'
    try:
        os.kill(pid, signal.SIGTERM)
        os.kill(pid, signal.SIGCONT)
    except OSError as err:
        return f'could not end it ({err.strerror})'
    def gone():
        f = proc_stat(pid)
        return not f or f[0] in 'ZX' or f[19] != born[19]
    for _ in range(60):
        time.sleep(0.05)
        if gone():
            return 'ended'
    try:
        os.kill(pid, signal.SIGKILL)
    except OSError:
        pass
    for _ in range(20):
        time.sleep(0.05)
        if gone():
            return 'killed'
    return 'still there after KILL'


# ------------------------------------------------------------- one session

class Session:
    def __init__(self, rec, live):
        self.rec, self.id = rec, rec['id']
        self.desc = load(os.path.join(DESC, self.id + '.json')) or {}
        self.hook = load(os.path.join(HOOK, self.id + '.json')) or {}
        d = live.get(self.id)
        self.stray = d if d and d.get('stray') else None
        self.live = None if self.stray else d
        self.docs = []
        self.last = max(rec.get('last') or 0, self.hook.get('last') or 0,
                        (d or {}).get('statusUpdatedAt', 0) / 1000)

    def what(self):
        """(text, made by the session or me?)"""
        for text, own in ((self.desc.get('label'), True), (self.desc.get('text'), True),
                          (self.rec.get('ctitle'), True), (self.rec.get('title'), False),
                          (self.rec['prompts'][0], False)):
            if text:
                return text, own
        return '?', False

    def haystack(self):
        parts = [self.desc.get('label') or '', self.desc.get('text') or '',
                 self.rec.get('ctitle') or '', self.rec.get('cwd') or '']
        parts += [h['text'] for h in self.desc.get('history', [])]
        parts += self.rec['titles'] + self.rec['prompts'] + self.rec['tags']
        return parts


def fold(s):
    return ''.join(ch for ch in unicodedata.normalize('NFKD', s)
                   if not unicodedata.combining(ch)).casefold()


def width(s):
    return sum(0 if unicodedata.combining(ch) else
               2 if unicodedata.east_asian_width(ch) in 'WF' else 1 for ch in s)


def clip(s, room):
    if width(s) <= room:
        return s
    out, used = '', 0
    for ch in s:
        w = width(ch)
        if used + w > room - 1:
            break
        out, used = out + ch, used + w
    return out + '…'


def age(sec):
    if sec < 60:
        return 'now'
    if sec < 3600:
        return f'{int(sec // 60)} min'
    if sec < 48 * 3600:
        return f'{int(sec // 3600)} h'
    if sec < 14 * 86400:
        return f'{int(sec // 86400)} d'
    if sec < 365 * 86400:
        return f'{int(sec // 604800)} wk'
    return f'{sec / 31557600:.1f} y'


def gap_words(sec):
    return age(sec).replace('min', 'minutes').replace(' h', ' hours').replace(
        ' d', ' days').replace(' wk', ' weeks').replace(' y', ' years')


# ----------------------------------------------------------- the list shown

def cell_size():
    """(width, height) of a character cell in pixels, asked of the terminal."""
    try:
        fd = os.open('/dev/tty', os.O_RDWR | os.O_NOCTTY)
    except OSError:
        return None
    try:
        if os.tcgetpgrp(fd) != os.getpgrp():
            return None                 # a background job reading the tty would stop
        old = termios.tcgetattr(fd)
        raw = termios.tcgetattr(fd)
        raw[3] &= ~(termios.ICANON | termios.ECHO)
        termios.tcsetattr(fd, termios.TCSANOW, raw)
        reply, end = b'', time.time() + 0.5
        try:
            os.write(fd, b'\033[16t')
            while not reply.endswith(b't') and time.time() < end:
                if not select.select([fd], [], [], max(0, end - time.time()))[0]:
                    break
                reply += os.read(fd, 64)
        finally:
            termios.tcsetattr(fd, termios.TCSANOW, old)
        m = re.search(rb'\033\[6;(\d+);(\d+)t', reply)
        return (int(m.group(2)), int(m.group(1))) if m else None
    except OSError:
        return None
    finally:
        os.close(fd)


def eyes_picture():
    """The escape drawing the picture one line tall, or None."""
    env = os.environ
    inline = (int(env.get('KONSOLE_VERSION') or 0) >= 230400
              or env.get('TERM_PROGRAM') in ('iTerm.app', 'WezTerm'))
    if (not inline or env.get('MYCLAUDE_EYES') == 'text' or env.get('TMUX')
            or env.get('STY') or env.get('INSIDE_EMACS') or not os.path.exists(EYES_PNG)):
        return None
    try:
        from PIL import Image
    except ImportError:
        return None
    cell = cell_size()
    if not cell:
        return None
    cw, ch = cell
    with Image.open(EYES_PNG) as im:
        w, h = im.size
        cols = max(1, round(ch * w / h / cw))
        cache = os.path.join(os.environ.get('XDG_CACHE_HOME') or os.path.join(HOME, '.cache'),
                             'myclaude', f'eyes-{cols}x1-{cw}x{ch}.png')
        if not (os.path.exists(cache) and os.path.getmtime(cache) >= os.path.getmtime(EYES_PNG)):
            os.makedirs(os.path.dirname(cache), exist_ok=True)
            im.convert('RGB').resize((cols * cw, ch), Image.LANCZOS).save(cache)
    with open(cache, 'rb') as fh:
        png = fh.read()
    return (f'\033]1337;File=inline=1;size={len(png)};width={cols};height=1;'
            f'preserveAspectRatio=0:{base64.b64encode(png).decode()}\a')


def banner(subtitle):
    """One line on top: Shannon's eyes, the name and version, what this list is."""
    if not TTY or os.environ.get('MYCLAUDE_EYES') == 'off' or os.environ.get('TERM') == 'dumb':
        return
    eyes = eyes_picture() or paint('38;5;250', EYES_TEXT)
    print(eyes + ' ' + paint('1', 'myclaude') + paint('2', f' {__version__} · {subtitle}'))


def terminal_key():
    for fd in (0, 1, 2):
        try:
            return os.ttyname(fd).replace('/dev/', '').replace('/', '-')
        except OSError:
            continue
    return 'none'


def show(rows, long_ids, hits=None, footer=''):
    across = shutil.get_terminal_size((110, 24)).columns
    now = time.time()
    code_w = 36 if long_ids else 8
    if any(s.docs for s in rows):               # a column for the reports' tags
        code_w += 8
    print(paint('2', f'{"#":>4}   {"last use":>8}  {"code":<{(36 if long_ids else 8)}}'
                + ('  report' if any(s.docs for s in rows) else '') + '  description'))
    for i, s in enumerate(rows, 1):
        mark = ' '
        if s.live:
            mark = paint('32' if s.live.get('status') == 'busy' else '33', '●')
        elif s.stray:
            mark = paint('2', '○')
        elif s.rec.get('gone'):
            mark = paint('31', '✝')
        text, own = s.what()
        where = '' if s.rec.get('cwd') in (HOME, None) else short(s.rec['cwd'])
        if len(where) > 34:
            where = '…/' + '/'.join(where.split('/')[-2:])
        room = max(20, across - (19 + code_w) - 1)
        if where and room - (width(where) + 4) < 30:
            where = ''                  # a narrow terminal: the directory goes first
        text = clip(text, room - (width(where) + 4 if where else 0))
        line = (paint('1', f'{i:>4}') + f' {mark} {age(now - s.last):>8}  '
                + paint('36', s.id if long_ids else s.id[:8]) + '  '
                + (report_cell(s) if any(r.docs for r in rows) else '')
                + (text if own else paint('2;3', text))
                + (paint('2', '  · ' + where) if where else ''))
        print(line)
        if hits and hits.get(s.id):
            print(' ' * (19 + code_w) + paint('2', clip('↳ ' + hits[s.id], across - 20 - code_w)))
    if footer:
        print(paint('2', clip(footer, across - 1)))


def report_cell(s):
    if not s.docs:
        return ' ' * 8
    tag = s.docs[0][0]
    return paint('35', f'{tag:<6}') + '  ' if tag else paint('2', 'report') + '  '


def remember(rows):
    save(os.path.join(LISTS, terminal_key() + '.json'),
         {'at': time.time(), 'ids': [s.id for s in rows]})


def snippet(s, terms):
    """Why a search hit is one, when its description does not say it."""
    shown = fold(s.what()[0])
    if all(t in shown for t in terms):
        return None
    for tag, where in s.docs:
        if tag and fold(tag) in terms:
            return f'its report {tag}: {short(where)}'
    for tag, names in made(s.rec).items():
        if fold(tag) in terms:
            return f'made {best_name(names)}'
    for tag in s.rec['tags']:
        if fold(tag) in terms:
            return f'AF tag {tag} met in the session'
    for text in [h['text'] for h in s.desc.get('history', [])] + s.rec['titles'] + s.rec['prompts']:
        f = fold(text)
        if all(t in f for t in terms):
            at = text.lower().find(terms[0])
            at = 0 if at < 0 else max(0, at - 40)
            return ('…' if at else '') + text[at:at + 160]
    return None


def deep(paths, terms):
    """Transcript file -> a snippet, for those whose conversation holds every term.
    grep finds the candidates fast; but a transcript also carries the system prompt,
    CLAUDE.md and the memory files sent with each request, so a word from those is
    in every file: only what was said and done in the turns counts."""
    left = set(paths)
    for t in terms:
        if not left:
            break
        res = subprocess.run(['/usr/bin/grep', '-l', '-i', '-F', '-a', '-e', t, '--', *sorted(left)],
                             capture_output=True, text=True)
        left &= set(res.stdout.split('\n'))
    terms = [t.lower() for t in terms]
    found = {}
    for path in left:
        seen, first = set(), None
        try:
            with open(path, 'rb') as fh:
                for raw in fh:
                    if b'"type":"user"' not in raw and b'"type":"assistant"' not in raw:
                        continue
                    try:
                        e = json.loads(raw)
                    except ValueError:
                        continue
                    if e.get('type') not in ('user', 'assistant') or e.get('isSidechain'):
                        continue
                    for s in texts((e.get('message') or {}).get('content')):
                        low = s.lower()
                        for t in terms:
                            if t in low:
                                seen.add(t)
                                if first is None and t == terms[0]:
                                    at = max(0, low.find(t) - 40)
                                    first = ('…' if at else '') + ' '.join(s[at:at + 200].split())
                    if len(seen) == len(terms):
                        break
        except OSError:
            continue
        if len(seen) == len(terms):
            found[path] = 'in the transcript: ' + (first or '')
    return found


# ---------------------------------------------------------------- commands

def listing(n, words, deep_too, long_ids, again):
    recs = index(again)
    live = open_sessions()
    sessions = sorted((Session(r, live) for r in recs.values()), key=lambda s: -s.last)
    docs = documents(recs)
    for s in sessions:
        s.docs = docs.get(s.id, [])
    total = len(sessions)
    hits = {}
    if words:
        terms = [fold(w) for w in words]
        found = []
        for s in sessions:
            hay = fold('\n'.join(s.haystack()))
            if all(t in hay for t in terms):
                found.append(s)
                hits[s.id] = snippet(s, terms)
        if deep_too:
            rest = {s.rec['path']: s for s in sessions if s not in found and not s.rec.get('gone')}
            for path, why in deep(rest, words).items():
                found.append(rest[path])
                hits[rest[path].id] = why
        # a tag's own sessions first: its report's, or the one whose file it names
        found.sort(key=lambda s: (not (hits.get(s.id) or '').startswith(('its report', 'made ')),
                                  -s.last))
        sessions = found
        if not sessions:
            print(f'no session features {" + ".join(words)}'
                  + ('' if deep_too else '  (myclaude -d to look in the transcripts too)'))
            return 0
    rows = sessions if n is None else sessions[:n]
    more = len(sessions) - len(rows)
    nlive = sum(1 for s in rows if s.live)
    bits = []
    if words:
        bits.append(f'{len(sessions)} of {total} feature {" + ".join(words)}')
    else:
        bits.append(f'{len(rows)} of {total}')
    if more:
        bits.append(f'{more} more: -{len(sessions)} or -a')
    if nlive:
        bits.append('● open now')
    if any(s.stray for s in rows):
        bits.append('○ lost its terminal')
    if words:
        subtitle = f'sessions featuring {" + ".join(words)}' + (', transcripts too' if deep_too else '')
    else:
        subtitle = 'my Claude Code sessions, last used first'
    banner(subtitle)
    bits.append('myclaude N resumes · -h')
    show(rows, long_ids, hits, '     ' + ' · '.join(bits))
    remember(rows)
    return 0


def resolve(arg):
    """'3' (row of the list shown last in this terminal) or a code -> session id."""
    if arg.isdigit() and len(arg) <= 4:
        n = int(arg)
        mem = load(os.path.join(LISTS, terminal_key() + '.json'))
        if not mem or time.time() - mem.get('at', 0) > 12 * 3600:
            live = open_sessions()
            ids = [s.id for s in sorted((Session(r, live) for r in index().values()),
                                        key=lambda s: -s.last)]
        else:
            ids = mem['ids']
        if not 1 <= n <= len(ids):
            die(f'no number {n} in the list (it has {len(ids)})')
        return ids[n - 1]
    if re.fullmatch(r'[0-9a-f-]{8,36}', arg):
        known = {os.path.basename(p)[:-5] for p in glob.glob(os.path.join(SCAN, '*.json'))}
        known |= {os.path.basename(p)[:-6] for p in glob.glob(os.path.join(PROJECTS, '*', '*.jsonl'))}
        match = sorted(sid for sid in known if sid.startswith(arg))
        if len(match) == 1:
            return match[0]
        if len(match) > 1:
            die(f'{arg} is the start of {len(match)} sessions; give more of it')
    return None


def details(sid):
    """-t N: the session's report(s) and the AF-tagged files it made."""
    recs = index()
    rec = recs.get(sid) or load(os.path.join(SCAN, sid + '.json'))
    if not rec:
        die(f'no session {sid}')
    s = Session(rec, open_sessions())
    s.docs = documents({**recs, sid: rec}).get(sid, [])
    print(paint('36', sid[:8]) + '  ' + s.what()[0])
    for i, (tag, where) in enumerate(s.docs):
        print(f'  {"report" if i == 0 else "":<6}  ' + paint('35', f'{tag or "–":<6}') + '  ' + short(where))
    things = made(rec, {tag for tag, _ in s.docs})
    for i, (tag, names) in enumerate(things.items()):
        more = len([n for n in names if not n.endswith(BYPRODUCT)]) - 1
        print(f'  {"made" if i == 0 else "":<6}  ' + paint('35', f'{tag:<6}') + '  ' + best_name(names)
              + (paint('2', f'  (+{more})') if more > 0 else ''))
    if not s.docs and not things:
        print(paint('2', '  no report, and no AF-tagged file made'))
    else:
        print(paint('2', '  showme TAG opens one'))
    return 0


def windows():
    """({X window id: (desktop, pid, title)}, {desktop: name}, desktop on screen),
    from wmctrl; desktops are wmctrl's, from 0, and -1 is a window on all of them."""
    try:
        wins = subprocess.run(['wmctrl', '-lp'], capture_output=True, text=True, timeout=5)
        desks = subprocess.run(['wmctrl', '-d'], capture_output=True, text=True, timeout=5)
    except (OSError, subprocess.SubprocessError) as err:
        die(f'myclaude -w needs wmctrl ({err})')
    if wins.returncode:
        die(f'wmctrl: {wins.stderr.strip() or "failed"}')
    out = {}
    for line in wins.stdout.splitlines():
        f = line.split(None, 4)
        if len(f) >= 3 and re.fullmatch(r'-?\d+', f[1]) and f[2].isdigit():
            out[int(f[0], 16)] = (int(f[1]), int(f[2]), f[4] if len(f) > 4 else '')
    names, current = {}, None
    for line in desks.stdout.splitlines():
        f = line.split()
        if len(f) > 1 and f[0].isdigit():
            m = re.search(r'WA: \S+ \S+\s+(.*)', line)
            names[int(f[0])] = m.group(1).strip() if m else ''
            if f[1] == '*':
                current = int(f[0])
    return out, names, current


def window_of(pid, wins):
    """(the X window a process shows in, or None; its environment): the window of
    the first of its ancestors that owns one -- every Konsole window is a process
    of its own -- and the one its $WINDOWID names if that ancestor owns several."""
    try:
        with open(f'/proc/{pid}/environ', 'rb') as fh:
            env = dict(v.split(b'=', 1) for v in fh.read().split(b'\0') if b'=' in v)
    except OSError:
        env = {}
    named = env.get(b'WINDOWID', b'')
    named = int(named) if named.isdigit() else None
    owned = {}
    for wid, (_, owner, _) in wins.items():
        owned.setdefault(owner, []).append(wid)
    p = pid
    while p > 1:
        if p in owned:
            return (named if named in owned[p] else min(owned[p])), env
        try:
            with open(f'/proc/{p}/stat') as fh:
                stat = fh.read()
        except OSError:
            break
        p = int(stat[stat.rfind(')') + 2:].split()[1])
    return None, env


def desk_name(desk, names):
    if desk < 0:
        return 'every desktop'
    name = names.get(desk, '')
    return f'desktop {desk + 1}' + ('' if name in ('', f'Desktop {desk + 1}') else f' “{name}”')


def no_window(env):
    for var, why in ((b'TMUX', 'inside tmux'), (b'STY', 'inside screen'),
                     (b'SSH_CONNECTION', 'over ssh')):
        if env.get(var):
            return f'no window of its own found: it runs {why}'
    return 'no window of its own found'


def open_window(sid, arg):
    """For -w and --bring: a line naming the session; then, if it is open and has a
    window, (its ~/.claude/sessions entry, its window, wmctrl's windows, the
    desktops' names, the desktop on screen), else None after saying why not."""
    path = next(iter(glob.glob(os.path.join(PROJECTS, '*', sid + '.jsonl'))), None)
    rec = scan(path) if path else load(os.path.join(SCAN, sid + '.json'))
    if not rec:
        die(f'no session {sid}')
    s = Session(rec, open_sessions())
    print(paint('36', sid[:8]) + '  ' + (s.what()[0] if rec.get('prompts') else '(nothing said yet)'))
    if s.stray:
        print('  not open: its process lost its terminal (its Konsole was closed), no window to find'
              + paint('2', f' · pid {s.stray["pid"]}, {status(s.stray)}, last used {age(time.time() - s.last)} ago'
                      f': myclaude {arg} ends it and resumes the session'))
        return None
    if not s.live:
        ago = age(time.time() - s.last)
        print(paint('2', '  not open' + (f', last used {ago} ago' if ago != 'now' else ', closed just now')
                    + ('' if path else ' (transcript deleted)') + (f': myclaude {arg} resumes it' if path else '')))
        return None
    wins, names, current = windows()
    wid, env = window_of(s.live['pid'], wins)
    if wid is None:
        print(f'  {no_window(env)}' + paint('2', f' · pid {s.live["pid"]}, {status(s.live)}'))
        return None
    return s.live, wid, wins, names, current


def where(sid, arg):
    """-w N: the virtual desktop the window of an open session is on."""
    found = open_window(sid, arg)
    if not found:
        return 1
    live, wid, wins, names, current = found
    desk, _, title = wins[wid]
    here = (', the one you are on' if desk == current else
            '' if current is None else f' · you are on {desk_name(current, names)}')
    print('  ' + paint('1', desk_name(desk, names)) + here
          + paint('2', f' · window “{title}” · pid {live["pid"]}, {status(live)}'))
    return 0


def wmctrl(*args):
    try:
        return subprocess.run(['wmctrl', *args], capture_output=True, text=True, timeout=5).returncode
    except (OSError, subprocess.SubprocessError):
        return 1


def active_window():
    try:
        out = subprocess.run(['xprop', '-root', '_NET_ACTIVE_WINDOW'], capture_output=True,
                             text=True, timeout=5).stdout
    except (OSError, subprocess.SubprocessError):
        return None
    m = re.search(r'# (0x[0-9a-f]+)', out)
    return int(m.group(1), 16) if m else None


def move_window(wid, desk):
    """Send a window to a desktop (wmctrl's number); the desktop it is on after."""
    wmctrl('-i', '-r', f'0x{wid:08x}', '-t', str(desk))
    for _ in range(20):
        now = windows()[0].get(wid, (None,))[0]
        if now == desk:
            break
        time.sleep(0.05)
    return now


def bring(sid, arg):
    """--bring N: the window of an open session moved onto the desktop on screen,
    then raised.  In that order: asked to raise a window on another desktop, KWin
    switches the screen to that desktop instead."""
    found = open_window(sid, arg)
    if not found:
        return 1
    live, wid, wins, names, current = found
    desk, _, title = wins[wid]
    if current is None:
        die('wmctrl does not say which desktop is on the screen')
    if desk in (current, -1):
        said = 'on every desktop' if desk < 0 else f'already on {desk_name(desk, names)}, the one you are on'
    else:
        now = move_window(wid, current)
        if now != current:
            print(f'  could not bring it: still on {desk_name(now if now is not None else desk, names)}')
            return 1
        said = f'brought from {desk_name(desk, names)} to {desk_name(current, names)}, where you are'
    wmctrl('-i', '-a', f'0x{wid:08x}')
    for _ in range(20):
        if active_window() == wid:
            break
        time.sleep(0.05)
    else:
        said += ' (but KWin did not raise it: it may be under other windows)'
    print('  ' + paint('1', said) + paint('2', f' · window “{title}”'))
    return 0


def where_all():
    """-w alone: every open session, by the desktop it is on."""
    live = open_sessions()
    if not live:
        print('no session is open')
        return 0
    wins, names, current = windows()
    rows = []
    for sid, d in live.items():
        path = next(iter(glob.glob(os.path.join(PROJECTS, '*', sid + '.jsonl'))), None)
        rec = scan(path) if path else load(os.path.join(SCAN, sid + '.json'))
        s = Session(rec, live) if rec else None
        text = s.what()[0] if rec and rec.get('prompts') else '(nothing said yet)'
        wid, env = (None, None) if d.get('stray') else window_of(d['pid'], wins)
        desk = wins[wid][0] if wid is not None else None
        why = 'lost its terminal' if env is None else no_window(env) if wid is None else ''
        rows.append((desk, sid, text, d, why))
    rows.sort(key=lambda r: (r[0] is None, r[0] if r[0] is not None and r[0] >= 0 else 999, r[1]))
    banner('open sessions, by desktop')
    across = shutil.get_terminal_size((110, 24)).columns
    label_w = max(len(desk_name(r[0], names)) if r[0] is not None else 2 for r in rows)
    for desk, sid, text, d, why in rows:
        label = desk_name(desk, names) if desk is not None else '–'
        label = paint('1;32' if desk == current else '1', f'{label:<{label_w}}')
        mark = (paint('2', '○') if d.get('stray') else
                paint('32' if d.get('status') == 'busy' else '33', '●'))
        print(f'  {label}  {mark} ' + paint('36', sid[:8]) + '  '
              + clip(text + (f'  ({why})' if why else ''), max(20, across - label_w - 17)))
    strays = sum(1 for r in rows if r[3].get('stray'))
    print(paint('2', f'     {len(rows) - strays} open'
                + (f' · ○ {strays} lost its terminal: myclaude N ends it and resumes' if strays else '')
                + ('' if current is None else f' · you are on {desk_name(current, names)}')
                + ' · myclaude -w N for one'))
    return 0


def resume(sid, force, extra, dry=False):
    path = next(iter(glob.glob(os.path.join(PROJECTS, '*', sid + '.jsonl'))), None)
    rec = scan(path) if path else load(os.path.join(SCAN, sid + '.json'))
    if not rec:
        die(f'no session {sid}')
    s = Session(rec, open_sessions())
    text = s.what()[0]
    if not path:
        die(f'{sid[:8]} “{text}”: its transcript was deleted by Claude Code '
            '(cleanupPeriodDays), it cannot be resumed')
    if s.live and not force:
        die(f'{sid[:8]} “{text}” is open right now (pid {s.live.get("pid")}, '
            f'{status(s.live)}, in {short(s.live.get("cwd"))}): myclaude --bring {sid[:8]} brings its '
            f'window to this desktop (-w {sid[:8]}: which desktop it is on), or myclaude -f {sid[:8]} '
            'opens it a second time (two processes, one transcript)')
    cwd = rec.get('cwd') or HOME
    if not os.path.isdir(cwd):
        die(f'{sid[:8]} ran in {short(cwd)}, which is not there any more')
    if dry:
        print(f'cd {cwd} && claude --resume {sid}' + ''.join(' ' + a for a in extra))
        return 0
    if s.stray:
        print(paint('2', f'  pid {s.stray["pid"]}, its process that lost its terminal: '
                    + end_stray(s.stray)))
    print(paint('2', '→ ') + paint('36', sid[:8]) + f'  {text}\n'
          + paint('2', f'  claude --resume {sid}' + ('' if cwd == HOME else f'   (in {short(cwd)})')))
    sys.stdout.flush()
    os.chdir(cwd)
    os.execvp('claude', ['claude', '--resume', sid] + extra)


def edit_line(current):
    """The current text on an editable line, cursor at its end; None if given up."""
    import readline
    readline.set_startup_hook(lambda: readline.insert_text(current))
    try:
        return input('› ')
    except (EOFError, KeyboardInterrupt):
        print()
        return None
    finally:
        readline.set_startup_hook()


def describe(args):
    mine = os.environ.get('CLAUDE_CODE_SESSION_ID', '')
    target = len(args) == 1 and ((args[0].isdigit() and len(args[0]) <= 4)
                                 or re.fullmatch(r'[0-9a-f-]{8,36}', args[0]))
    if mine and UUID.match(mine) and not target:
        # inside a session: always this session, whatever the words look like
        if args[:1] in (['-s'], ['--session']) and len(args) >= 3:
            sid, text, by = resolve(args[1]), ' '.join(args[2:]), 'claude'
        elif args:
            sid, text, by = mine, ' '.join(args), 'claude'
        else:
            d = load(os.path.join(DESC, mine + '.json')) or {}
            print(d.get('label') or d.get('text') or '(no description yet)')
            return 0
    elif not args:
        die('myclaude --whatis N [TEXT]  (N from the list, or a code)')
    else:
        sid, text, by = resolve(args[0]), ' '.join(args[1:]), 'hand'
    if not sid:
        die(f'no session {args[0]!r}')
    where = os.path.join(DESC, sid + '.json')
    d = load(where) or {}
    if by == 'hand' and len(args) == 1:
        if not (sys.stdin.isatty() and sys.stdout.isatty()):
            die('no terminal to edit in: myclaude --whatis N TEXT')
        rec = load(os.path.join(SCAN, sid + '.json'))
        current = Session(rec, {}).what()[0] if rec and rec.get('prompts') else (
            d.get('label') or d.get('text') or '')
        print(paint('36', sid[:8]) + paint('2', '  Enter: my label · empty line: the session’s own'
                                       ' back · Ctrl-C: as it was'))
        text = edit_line(current)
        if text is None or ' '.join(text.split()) == ' '.join(current.split()):
            print('myclaude: unchanged')
            return 0
        if not text.strip():
            if not d.get('label'):
                print('myclaude: unchanged (no label of mine to remove)')
                return 0
            text = '-'
    text = ' '.join(text.split())
    hist = d.get('history', [])
    now = time.time()
    if by == 'hand':
        if d.get('label'):
            hist.append({'text': d['label'], 'at': d.get('label_at'), 'by': 'hand'})
        if text in ('', '-'):
            d.pop('label', None)
            d.pop('label_at', None)
        else:
            d['label'], d['label_at'] = text, now
    else:
        if not text:
            die('describe what?')
        if d.get('text') and d['text'] != text:
            hist.append({'text': d['text'], 'at': d.get('at')})
        h = load(os.path.join(HOOK, sid + '.json')) or {}
        d.update(text=text, at=now, n=h.get('n', 0))
    d['history'] = hist[-50:]
    save(where, d)
    shown = d.get('label') or d.get('text') or '(nothing)'
    print(f'myclaude: {sid[:8]} is now listed as “{shown}”'
          + (" (Fabrice's label wins over it)" if by == 'claude' and d.get('label') else ''))
    return 0


def hook():
    """UserPromptSubmit: count the prompt, ask for a description when one is due."""
    try:
        data = json.load(sys.stdin)
        sid = data.get('session_id') or ''
        if not UUID.match(sid) or os.environ.get('CLAUDE_CODE_ENTRYPOINT', 'cli') != 'cli':
            return 0
        where = os.path.join(HOOK, sid + '.json')
        h = load(where) or {'n': 0}
        now = time.time()
        pause = now - h['last'] if h.get('last') else 0
        h['n'] += 1
        h['last'] = now
        d = load(os.path.join(DESC, sid + '.json')) or {}
        say = None
        if d.get('label'):
            pass                        # labelled by hand: nothing to ask
        elif not d.get('text'):
            say = NEW
        elif pause > GAP:
            say = AGAIN.format(why=f'this session is taken up again after {gap_words(pause)}',
                               text=d['text'])
        elif h['n'] - max(d.get('n', 0), h.get('asked', 0)) >= EVERY:
            say = AGAIN.format(why=f'{h["n"] - d.get("n", 0)} prompts since it was described',
                               text=d['text'])
        if say and say is not NEW:
            h['asked'] = h['n']
        save(where, h)
        if say:
            print(json.dumps({'hookSpecificOutput': {'hookEventName': 'UserPromptSubmit',
                                                     'additionalContext': say}}))
    except Exception:
        pass                            # never get in the way of a prompt
    return 0


def main(argv):
    if argv[:1] == ['hook']:
        return hook()
    if argv[:1] == ['describe']:       # earlier spellings, which sessions that read
                                        # the hook before 1.2.0 may still use
        return describe(argv[1:])
    n, words, deep_too, force, long_ids, again, grep, dry = (DEFAULT_N, [], False, False,
                                                            False, False, False, False)
    tags = on_desk = fetch = False
    i = 0
    while i < len(argv):
        a = argv[i]
        if a in ('-h', '--help'):
            print(HELP)
            return 0
        if a in ('-v', '--version'):
            print(f'myclaude {__version__}')
            return 0
        if a in ('-a', '--all'):
            n = None
        elif re.fullmatch(r'-\d+', a):
            n = int(a[1:])
        elif a == '-n' and i + 1 < len(argv) and argv[i + 1].isdigit():
            n, i = int(argv[i + 1]), i + 1
        elif a in ('-d', '--deep'):
            deep_too = True
        elif a in ('-f', '--force'):
            force = True
        elif a in ('-l', '--long'):
            long_ids = True
        elif a in ('--whatis', '--describe'):
            return describe(argv[i + 1:])
        elif a in ('-p', '--print'):
            dry = True
        elif a in ('-g', '--grep'):
            grep = True
        elif a in ('-t', '--tags'):
            tags = True
        elif a in ('-w', '--where'):
            on_desk = True
        elif a in ('-b', '--bring'):
            fetch = True
        elif a == '--rescan':
            again = True
        elif a.startswith('-') and len(a) > 1:
            die(f'unknown option {a} (myclaude -h)')
        elif not words and not grep and not deep_too and ((a.isdigit() and len(a) <= 4)
                                                          or re.fullmatch(r'[0-9a-f-]{8,36}', a)):
            sid = resolve(a)
            if not sid:
                die(f'no session {a}; to search for it: myclaude -g {a}')
            if fetch:
                return bring(sid, a)
            if on_desk:
                return where(sid, a)
            return details(sid) if tags else resume(sid, force, argv[i + 1:], dry)
        else:
            words.append(a)
        i += 1
    if fetch:
        die('which session? myclaude --bring N')
    if on_desk:
        return where_all() if not words else die('which session? myclaude -w N')
    if tags:
        die('whose tags? myclaude -t N')
    if words and not (grep or deep_too):
        die(f'to search: myclaude -g {" ".join(words)}   (myclaude -h)')
    if (grep or deep_too) and not words:
        die('search for what? myclaude -g WORD...')
    return listing(n, words, deep_too, long_ids, again)


if __name__ == '__main__':
    try:
        sys.exit(main(sys.argv[1:]))
    except BrokenPipeError:
        sys.exit(0)
    except KeyboardInterrupt:
        sys.exit(130)