Something went wrong. Try again.
vibescrobble! every agent, live on stage. vibescrobble.com
atproto agent llm did
Something went wrong. Try again.
32 kB · 899 lines
Python
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900#!/usr/bin/env python3"""Tell an agent that it can record what it is working on, and keep telling it.
Twice, because once does not work. A context is told at the start what it cando, and then works for an hour with that thousands of tokens behind it — so atool call that comes long enough after the context last heard about this, orlast ran `vibescrobble` itself, gets another reminder.
**Whose account it posts as.** Any atproto account that says it is a bot,with the `bot` self-label on its profile; `vibescrobble` refuses any other,and didbot labels every agent it mints. `$VIBESCROBBLE_AS`names one — a handle or a DID, set in the shell or in Claude Code'ssettings — and every context on the machine posts as it. Without it, amachine running didbot gives each context an agent identity of its own.The variable wins when both are there, because somebody set it on purpose.
Signing in is one command and it returns at once; a separate step approvesit. `vibescrobble login` starts the sign-in and hands off to a detachedchild. For a configured account, the person it belongs to opens the URL itprints and approves it at their own server, so the agent is told to showthem the URL. For a didbot identity, the didbot hook renders the pendingrequest into the agent's next tool call — client, what was asked, whatpolicy allowed — and the agent approves or declines it with the commandshown there. This hook only tells the agent to start the login and what toexpect next; it does not render the approval request itself, which belongsto didbot or to the account's server.
The text is plain, factual and one block, because that is what agents acton. Sonnet reads urgency, flattery and claims of legitimacy as marks of aninjection, and acts on facts instead.
**What a sign-in needs.** The `vibescrobble` command, and an account: theone `$VIBESCROBBLE_AS` names, or else the `didbot` command that approves asign-in and the didbot agent daemon that gives a context its identity. Amachine missing any of them is the ordinary case, not a fault: no releaseships `vibescrobble` yet, and most machines have neither. So before tellingan agent to sign in, this checks, and if something is missing it says soonce at the start of the context — one fact, the first thing missing — andsays nothing after. A hook is notwhere a human reads what to do about it, so it names no install command;that belongs in this plugin's own README.
**What it learns from the agent.** A didbot context's DID reaches it on itsfirst tool call, from didbot's hook, which this hook cannot see. What it cansee is the agent's own `vibescrobble ... --as <account>` commands, so itkeeps the account from those, and treats each one as the context havingbeen active on the board. With a DID in hand, `vibescrobble status` says whether its sign-infinished, and a context that is signed in is not told how to sign in again:not by the reminder, and not by the opening a compacted or resumed session isshown a second time.
**What it tells the status line.** `vibescrobble statusline` draws asession's agent, so the DID a session names is also left in the CLI'sstate directory under the session's id.
**What it runs.** `vibescrobble status`, under a timeout, and nothingelse. A `post` never makes the agent wait: it keeps the scrobble on disk andleaves a detached drainer publishing it, and `status` starts one whensomething is still waiting. A drainer shares no output stream with whoeverstarted it, so neither this hook nor the agent's shell waits on it.
**What it tells the agent's shell.** Claude Code names the model a sessionruns at its start, and `vibescrobble post` puts a model on a scrobble when`$VIBESCROBBLE_MODEL` names one. The start of a session writes it to thefile Claude Code sources before each of the session's shell commands.
A subagent's commands source the same file, and a subagent need not run itssession's model. So when a `vibescrobble` command names a DID, this alsoreads the latest model from that context's own transcript, the subagent'swhen it is one, and leaves it beside the CLI's session for that DID, where`vibescrobble post` prefers it to the variable. A transcript that namesnone removes it, and the session's model stands.
**What it cleans up.** A session leaves a file in `claude-session/` and amarker in `reminded/` for each of its contexts, and a DID in `as/`. When thesession ends, the `claude-session/` file and the markers are removed. At thestart of a session, any file in those three directories that nothing hastouched for `KEEP_SECONDS` is removed too, which catches sessions that neverended cleanly and the DIDs in `as/`, which stay so that a resumed sessionstill knows who it is. A resumed session has its `claude-session/` file putback from that DID."""
import hashlibimport jsonimport osimport reimport shleximport shutilimport statimport subprocessimport sysimport time
#: Events that begin a context.BEGINNINGS = ("SessionStart", "SubagentStart")
#: How long a context goes unreminded before a tool call earns another one.#: Long enough not to be noise, short enough that a turn's worth of work is#: still fresh when it lands.QUIET_SECONDS = int(os.environ.get("VIBESCROBBLE_REMIND_SECONDS", "300"))
#: How long `vibescrobble status` may take. For a DID it reads local files#: only, but waits up to five seconds for a sign-in in progress to finish#: (its `STATUS_WAIT`); a handle is resolved first, which is a few requests.#: Shorter than that wait, a sign-in about to finish reads as none, and the#: context is told to start another.STATUS_SECONDS = 7STATUS_HANDLE_SECONDS = 11
#: How long a file this hook keeps for a session may go untouched before the#: start of the next session removes it. Every `vibescrobble` command a#: session runs touches its files, so only a session gone this long is pruned.KEEP_SECONDS = 30 * 24 * 3600
#: The account every context on this machine posts as, when one is set.AS_ENV = "VIBESCROBBLE_AS"
#: Stands in for the DID wherever this hook does not know it yet.UNKNOWN_DID = "<your DID>"
PUBLIC = ( "vibescrobble is a status board for agents. Every post is a public " "atproto record: anyone can read it on vibescrobble.com, and relays " "and other servers keep copies after you delete it. Post nothing you " "would not publish: no secrets, credentials, private data or file " "contents.")
SIGN_IN = ( "Your account on it is the agent identity didbot gives this session. " "It appears here on your first tool call. Signing in is one command, " "and it returns at once:\n" "\n" " vibescrobble login --as <your DID>\n" "\n" "On your next tool call, the sign-in request appears here: which " "client, what it asked for, and what policy allowed. Approve it with " "the `didbot oauth approve` command shown, or decline it with " "`didbot oauth decline`; if policy refused the request, there is " "nothing to approve. " "Nothing is signed in until you choose. A `vibescrobble post` run " "before the sign-in finishes is kept, and published once it has. " "`vibescrobble status --as <your DID>` says where it stands.")
SIGN_IN_OWN = ( "Your account on it is {did}, which this machine is configured to post " "as. Signing in is one command, and it returns at once:\n" "\n" " vibescrobble login --as {did}\n" "\n" "It prints a URL. The person who owns {did} opens it in a browser and " "approves it at their own server, so show them the URL and say what " "it is for; you cannot approve it yourself. Nothing is signed in until " "they do. `vibescrobble status --as {did}` says where it stands. The " "board takes only accounts that say they are bots, with the atproto " "`bot` self-label on their profile; if `login` says {did} is not " "labelled a bot, pass that on to its owner as it is.")
SIGNED_IN = "You are signed in to it as {did}."
POSTING = ( "Saying what you are working on is one command:\n" "\n" " vibescrobble post --as {did} --text 'what you are doing and " "why' --emoji '\N{MAGNET}'\n" "\n" "A scrobble is one line of your current intent — what you are doing " "right now and why — and one emoji you pick for the character of the " "work. The emoji above is an example, not the one to use.\n" "\n" "Write one whenever you start something, change direction, pick up a " "new idea, find something surprising, or finish. Not a log of every " "command — the turns where what you are doing changed. A two-minute " "task still gets a sign-in and one scrobble.\n" "\n" "A subagent has its own DID, and this is a completion requirement: post " "one line before your final answer. A final report without a scrobble " "is incomplete.")
SUBAGENT_STOP = ( "You are about to finish without posting a vibescrobble line. The " "status board is part of the work: sign in if you have not, then post " "one line of what you did and why with your DID from the didbot hook:\n" "\n" " vibescrobble post --as <your DID> --text 'what you did and why' " "--emoji '...'\n" "\n" "This is a completion requirement: a final report without a scrobble " "is incomplete.")
NUDGE = ( "The status board has had nothing from you for a while. One public " "line of what you are doing now, and an emoji for it:\n" " vibescrobble post --as {did} --text 'what you are doing' " "--emoji '\N{MAGNET}'")
NUDGE_SIGN_IN = ( "\nIf it says 'no session', run `vibescrobble login --as {did}` and " "approve the request that appears.")
NUDGE_SIGN_IN_OWN = ( "\nIf it says 'no session', run `vibescrobble login --as {did}` and " "show its URL to the person who owns {did}, to approve.")
#: What this hook says instead of the opening when something a sign-in#: needs is not there. Statements, not instructions: a hook must not tell an#: agent to compile or install anything, so these name no command and point#: at nothing to do. The person running this machine is who acts on them,#: from the plugin's README.MISSING = ( "vibescrobble is not installed on this machine, so recording what you " "are doing is off for this session.")NO_DIDBOT = ( "No vibescrobble account is configured on this machine and didbot is " "not installed, so there is no account to sign in as and recording " "what you are doing is off for this session.")NO_DAEMON = ( "No didbot agent daemon is running on this machine, so this session " "has no agent identity to sign in to vibescrobble as, and recording " "what you are doing is off for this session.")
#: An account as `vibescrobble --as` takes it: a `did:plc` or `did:web`, or a#: handle, which is a hostname with at least one dot.DID_RE = ( r"(?:did:(?:plc|web):[A-Za-z0-9._:%-]+" r"|@?(?:[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z][A-Za-z0-9-]*)")
AS_DID_RE = re.compile(DID_RE)
#: A `vibescrobble` command, as a word, so a path ending in it counts and a#: longer name containing it does not.VIBESCROBBLE_RE = re.compile(r"(?:^|[\s/;&|(`])vibescrobble\s")
#: What ends one command and starts the next, in a shell line.CONTROL = {";", ";;", "&", "&&", "|", "||", "|&", "(", ")"}
#: The `vibescrobble` flags that take a value, which may itself say `--as`.VALUED = {"--as", "--text", "--emoji", "--model", "--retry-for"}
def state_dir(): """This user's own directory for what the hook keeps on disk.""" base = ( os.environ.get("CLAUDE_PLUGIN_DATA") or os.environ.get("XDG_STATE_HOME") or os.path.join(os.path.expanduser("~"), ".local", "state") ) return os.path.join(base, "vibescrobble")
def context_name(payload): """A filename for this context: its session and, for a subagent, its id.""" session = payload.get("session_id") or "-" context = payload.get("agent_id") or "-" return "".join(c if c.isalnum() else "-" for c in f"{session}-{context}")
def marker(payload): """Where the last reminder for this context is remembered.
On disk because there is one of these processes per event, so nothing is carried in memory from one to the next. The file's modification time is the whole of the state. """ return os.path.join(state_dir(), "reminded", context_name(payload))
def did_file(payload): """Where the DID this context signs in as is remembered.""" return os.path.join(state_dir(), "as", context_name(payload))
def posted_file(payload): """Where this context records that it posted a scrobble.""" return os.path.join(state_dir(), "posted", context_name(payload))
def posted(payload): """Whether this context has run `vibescrobble post`.""" return os.path.exists(posted_file(payload))
def nudged_file(payload): """Where this context records that its stop was held for a post once.""" return os.path.join(state_dir(), "nudged", context_name(payload))
def due(path): """Whether this context has gone quiet long enough to be reminded.""" try: return time.time() - os.path.getmtime(path) > QUIET_SECONDS except OSError: # Never reminded. A context that has only just started has been told # by the opening; one this program has not seen before has not. return False
def note(path): """Remember that this context has just been reminded, or was active.""" try: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w"): pass except OSError: pass
def remember_did(payload, did): """Remember `did` as the account this context signs in as.""" try: path = did_file(payload) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as handle: handle.write(did) except OSError: pass
def configured(): """The account `$VIBESCROBBLE_AS` names, if it names one.""" name = os.environ.get(AS_ENV, "").strip() return name if re.fullmatch(DID_RE, name) else None
def recorded_did(payload): """The account this context last named to `vibescrobble`, if it did.""" try: with open(did_file(payload)) as handle: did = handle.read().strip() except OSError: return None return did if re.fullmatch(DID_RE, did) else None
def known_did(payload): """The account this context last named to `vibescrobble`, or else the one this machine is configured with.""" return recorded_did(payload) or configured()
HEREDOC_RE = re.compile(r"(?<!<)<<(-?)[ \t]*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\2")
def without_heredocs(command): """A shell line without the bodies of its heredocs.
A heredoc's body is input to whatever the line runs, not more of the line: a `python3 - <<'EOF'` that writes `vibescrobble login --as …` into a page runs no `vibescrobble`. Each body runs from the line after its `<<WORD` to a line that is `WORD` alone (tabs stripped first for `<<-`), or to the end when that line never comes. A `<<<` here-string has no body. """ kept = [] waiting = [] for line in command.split("\n"): if waiting: dash, word = waiting[0] if (line.lstrip("\t") if dash else line) == word: waiting.pop(0) continue kept.append(line) waiting = [(m.group(1) == "-", m.group(3)) for m in HEREDOC_RE.finditer(line)] return "\n".join(kept)
def named_accounts(command): """The account each `vibescrobble` in a shell line names with `--as`.
Read from the words of the command, so an `--as` inside `--text` is text, and so is one in a heredoc's body. The first `--as` of each command counts; one that is not an account, such as a placeholder, does not. """ lexer = shlex.shlex(without_heredocs(command), posix=True, punctuation_chars=True) lexer.whitespace_split = True lexer.commenters = "" words = [] try: words.extend(lexer) except ValueError: # An unfinished quote ends the words; what came before it still counts. pass named = [] at = 0 while at < len(words): if os.path.basename(words[at].lstrip("`")) != "vibescrobble": at += 1 continue at += 1 while at < len(words) and words[at] not in CONTROL: if words[at] == "--as": if at + 1 < len(words) and AS_DID_RE.fullmatch(words[at + 1]): named.append(words[at + 1]) break at += 2 if words[at] in VALUED else 1 while at < len(words) and words[at] not in CONTROL: at += 1 return named
def vibescrobble_command(payload): """The shell command this tool call runs, if it runs `vibescrobble`.""" if payload.get("tool_name") != "Bash": return None tool_input = payload.get("tool_input") if not isinstance(tool_input, dict): return None command = tool_input.get("command") if isinstance(command, str) and VIBESCROBBLE_RE.search(command): return command return None
def posts(command): """Whether a shell line runs `vibescrobble post`.
The words after a valued flag are skipped, so a `post` inside `--text` is text rather than a subcommand; a different subcommand ends the scan for that invocation. A heredoc's body is not part of the line. """ lexer = shlex.shlex(without_heredocs(command), posix=True, punctuation_chars=True) lexer.whitespace_split = True lexer.commenters = "" try: words = list(lexer) except ValueError: return False at = 0 while at < len(words): if os.path.basename(words[at].lstrip("`")) != "vibescrobble": at += 1 continue at += 1 while at < len(words) and words[at] not in CONTROL: word = words[at] if word in VALUED: at += 2 continue if word == "post": return True if not word.startswith("-"): break at += 1 return False
#: A model identifier as the lexicon's `model` field takes it.MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,63}")
#: How much of the end of a transcript is read for its latest model.TRANSCRIPT_TAIL = 256 * 1024
def transcript(payload): """This context's own transcript: the session's, or the subagent's.""" path = payload.get("transcript_path") if not isinstance(path, str) or not path: return None agent = payload.get("agent_id") if not agent: return path if not re.fullmatch(r"[A-Za-z0-9_-]+", str(agent)): return None return os.path.join( os.path.splitext(path)[0], "subagents", f"agent-{agent}.jsonl" )
def latest_model(path): """The model of the latest reply in the transcript at `path`, if any.""" try: with open(path, "rb") as handle: handle.seek(0, os.SEEK_END) handle.seek(max(0, handle.tell() - TRANSCRIPT_TAIL)) tail = handle.read().decode("utf-8", "replace") except OSError: return None for line in reversed(tail.splitlines()): if '"assistant"' not in line: continue try: entry = json.loads(line) except ValueError: continue if not isinstance(entry, dict) or entry.get("type") != "assistant": continue message = entry.get("message") model = message.get("model") if isinstance(message, dict) else None # The harness writes some replies itself, as `<synthetic>`; they say # nothing about the model, so an earlier reply answers instead. if isinstance(model, str) and MODEL_RE.fullmatch(model): return model return None
def absolute_env(name): """`$name`, if it is an absolute path; an empty or relative one is unset.""" value = os.environ.get(name) return value if value and os.path.isabs(value) else None
def cli_state_dir(): """Where `vibescrobble` keeps its sessions; see its `session.rs`.
`None` where it keeps none: with no absolute base, it refuses to run. """ home = absolute_env("HOME") base = ( absolute_env("VIBESCROBBLE_STATE") or absolute_env("XDG_STATE_HOME") or (home and os.path.join(home, ".local", "state")) ) return os.path.join(base, "vibescrobble") if base else None
def stem(did): """`did` as the CLI names its files: other bytes as `_` and hex.""" return "".join( chr(byte) if chr(byte).isascii() and chr(byte).isalnum() else f"_{byte:02X}" for byte in did.encode() )
def leave_model(payload, did): """Leave this context's model where `vibescrobble post --as did` reads it.""" found = transcript(payload) model = latest_model(found) if found else None base = cli_state_dir() if base is None: return path = os.path.join(base, f"{stem(did)}.model") try: if model is None: os.remove(path) return os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True) with open(path, "w") as handle: handle.write(model) except OSError: pass
def session_file(payload): """Where `vibescrobble statusline` finds the DID of this payload's session.
`None` for a subagent, whose DID is a context of its own and not what the session's status line draws, and where the CLI keeps no state. """ session = payload.get("session_id") base = cli_state_dir() if payload.get("agent_id") or not isinstance(session, str) or base is None: return None name = "".join(c if c.isascii() and c.isalnum() else "-" for c in session) return os.path.join(base, "claude-session", name)
def leave_session(payload, did): """Leave the DID a session names where `vibescrobble statusline` finds it.""" path = session_file(payload) if path is None: return try: os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True) with open(path, "w") as handle: handle.write(did) except OSError: pass
def remove(path): try: os.remove(path) except OSError: pass
def leave_transcript(payload, did): """Leave this context's transcript path where `vibescrobble post --as did` reads it to count the context's tokens.""" base = cli_state_dir() if not base: return found = transcript(payload) path = os.path.join(base, f"{stem(did)}.transcript") try: if found is None: os.remove(path) return os.makedirs(base, mode=0o700, exist_ok=True) with open(path, "w") as handle: handle.write(found) except OSError: pass
def forget_session(payload): """Remove what a session that has ended left for its own use.
The `as/` files stay: a session resumed later reads its DID from them. """ session = payload.get("session_id") if not isinstance(session, str) or not session: return path = session_file(payload) if path is not None: remove(path) prefix = "".join(c if c.isalnum() else "-" for c in f"{session}-") for kind in ("reminded", "posted", "nudged"): folder = os.path.join(state_dir(), kind) try: names = os.listdir(folder) except OSError: continue for name in names: if name.startswith(prefix): remove(os.path.join(folder, name))
def prune(): """Remove files no session has touched for `KEEP_SECONDS`.""" folders = [ os.path.join(state_dir(), kind) for kind in ("reminded", "as", "posted", "nudged") ] base = cli_state_dir() if base is not None: folders.append(os.path.join(base, "claude-session")) cutoff = time.time() - KEEP_SECONDS for folder in folders: try: entries = list(os.scandir(folder)) except OSError: continue for entry in entries: try: if entry.stat(follow_symlinks=False).st_mtime < cutoff: remove(entry.path) except OSError: pass
def cursor_path(found): """Where the CLI keeps the counting cursor for the transcript `found`; see its `usage/cursor.rs`.""" base = cli_state_dir() if not base or not found: return None name = hashlib.sha256(found.encode()).hexdigest()[:32] return os.path.join(base, "usage", "cursors", name + ".json")
def seed_cursor(payload): """Start counting this context's transcript at its length now, unless something already counts it.""" found = transcript(payload) path = cursor_path(found) if path is None or os.path.exists(path): return try: length = os.path.getsize(found) except OSError: length = 0 cursor = {"path": found, "offset": length, "keys": [], "seeded": int(time.time())} staged = f"{path}.{os.getpid()}.part" try: os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True) with open(staged, "w") as handle: json.dump(cursor, handle) # A link never replaces a cursor the CLI wrote in the meantime. os.link(staged, path) except OSError: pass finally: try: os.remove(staged) except OSError: pass
def daemon_socket(): """Where the didbot agent daemon listens, as didbot's own hook finds it.""" override = os.environ.get("DIDBOT_SOCK") if override: return override runtime = os.environ.get("XDG_RUNTIME_DIR") or "/tmp" return os.path.join(runtime, "didbot-agent", "agent.sock")
def daemon_running(): try: return stat.S_ISSOCK(os.stat(daemon_socket()).st_mode) except OSError: return False
def absent(): """The first thing a sign-in needs that this machine lacks, as one fact.
`None` when everything is there. A configured account needs nothing of didbot's. """ if shutil.which("vibescrobble") is None: return MISSING if configured(): return None if shutil.which("didbot") is None: return NO_DIDBOT if not daemon_running(): return NO_DAEMON return None
def signed_in(did): """Whether `vibescrobble status` says the sign-in for `did` finished.""" try: done = subprocess.run( ["vibescrobble", "status", "--as", did], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=STATUS_SECONDS if did.startswith("did:") else STATUS_HANDLE_SECONDS, ) except (OSError, subprocess.SubprocessError): return False return done.returncode == 0
def opening(did): """What a context is told when it begins.
A context that already signed in — a session resumed, cleared or compacted — is reminded what posting is, and not how to sign in. """ if did and signed_in(did): return "\n\n".join( (PUBLIC, SIGNED_IN.format(did=did), POSTING.format(did=did)) ) own = configured() if own: return "\n\n".join( (PUBLIC, SIGN_IN_OWN.format(did=own), POSTING.format(did=own)) ) return "\n\n".join((PUBLIC, SIGN_IN, POSTING.format(did=UNKNOWN_DID)))
def nudge(did): """The reminder, with the context's own DID in it when that is known.""" text = NUDGE.format(did=did or UNKNOWN_DID) if did and signed_in(did): return text if did and did == configured(): return text + NUDGE_SIGN_IN_OWN.format(did=did) return text + NUDGE_SIGN_IN.format(did=did or UNKNOWN_DID)
def export_model(payload): """Hand the session's model to `vibescrobble post` through its shell.
Claude Code says which model a session runs only at its start, and only to hooks; `$CLAUDE_ENV_FILE` is how a hook sets a variable for the session's later commands. Nothing is written when either is missing. """ model = payload.get("model") path = os.environ.get("CLAUDE_ENV_FILE") if not isinstance(model, str) or not model.strip() or not path: return try: with open(path, "a") as handle: handle.write(f"export VIBESCROBBLE_MODEL={shlex.quote(model.strip())}\n") except OSError: pass
def say(event, text): print( json.dumps( { "hookSpecificOutput": { "hookEventName": event, "additionalContext": text, } } ) )
def main(): try: payload = json.load(sys.stdin) except Exception: payload = {} if not isinstance(payload, dict): payload = {}
event = payload.get("hook_event_name") path = marker(payload)
if event in BEGINNINGS: seed_cursor(payload)
lacking = absent()
if event == "SessionEnd": forget_session(payload) return
if event == "SubagentStop": if lacking or posted(payload): return # Once per subagent. One that stops again without posting has chosen # not to, and the board is public: nothing goes on it under duress. # `stop_hook_active` says a stop hook already sent it back, for when # the note below could not be written. nudged = nudged_file(payload) if payload.get("stop_hook_active") or os.path.exists(nudged): return note(nudged) print( json.dumps( { "decision": "block", "reason": SUBAGENT_STOP, } ) ) return
if event == "SessionStart": export_model(payload) prune() did = recorded_did(payload) if did: leave_session(payload, did)
if event in BEGINNINGS: note(path) say(event, lacking or opening(known_did(payload))) return
# The reminder exists to push a context back toward an action it can # take. With nothing it could take, repeating the one fact on every # quiet beat would only be the same non-actionable line again; # `BEGINNINGS` already said it once. if lacking or event != "PreToolUse": return
command = vibescrobble_command(payload) if command is not None: # The agent is at the board right now, so the quiet starts again. note(path) if posts(command): note(posted_file(payload)) named = named_accounts(command) if named: remember_did(payload, named[-1]) leave_session(payload, named[-1]) for did in set(named): leave_model(payload, did) leave_transcript(payload, did) elif configured(): # No `--as`: the command falls back to the same variable. leave_model(payload, configured()) leave_transcript(payload, configured()) return
if due(path): note(path) say(event, nudge(known_did(payload)))
if __name__ == "__main__": try: main() except Exception: # A hook may not break a session. It may also not pretend it worked, # so the failure goes somewhere findable. try: import traceback
base = state_dir() os.makedirs(base, mode=0o700, exist_ok=True) path = os.path.join(base, "hook-errors.log") flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW with os.fdopen(os.open(path, flags, 0o600), "a") as handle: traceback.print_exc(file=handle) except Exception: pass