
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 3I 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.
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.
| 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.
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.
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 …
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.
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.
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 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.
--whatis; --describe and describe are still understood, for the sessions that learned them.-g or --grep; -h in two lines.-t for the report and the tagged files a session made, to the minute it was at work.-w, the virtual desktop where a session is open.--bring, to move a session's window to the desktop I am on.--bring and -w.#!/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)