SLOPSHOPPER

claudefishing

claudefishing: a shared fishing lobby you play while Claude Code is open; your cat gets a slight buff while Claude is working.

newguardcommandtoaststatusprocess
v0.4.1no licenseupdated 2026-10-07bryanlin850/claudefishing
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claudefishing
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /fishing ⎿ claudefishing: fishing on · server https://claudefishing.io · offline (no identity: could not create /Users/dev/.claudefishin ⎿ claudefishing: this session: model claude-opus-5-5 · effort not known until a turn runs ⎿ claudefishing: identity: /Users/dev/.claudefishing/identity.json (stays on this machine) ⎿ claudefishing: usage: /fishing [status|open [app|browser]|on|off|link [code]|unlink] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ claudefishing: 🎣 offline
README

claudefishing

The Claude Code plugin for claudefishing, a small multiplayer fishing game you play while a Claude Code session is open. The plugin connects your sessions to the game:

  • the game is playable while at least one Claude Code session on your machine runs this plugin with fishing on;
  • while Claude is working (a turn is running, or it did something in the last 5 minutes, subagents included) your cat gets a slight buff, scaled by the session's model and effort, which also show on your nametag. Time Claude spends waiting on you (a permission prompt, a question, plan approval, an MCP form) does not count as working;
  • /fishing open opens the game. The first time, it asks whether you want an app window (a chromeless Chrome window) or a link to open in your own browser, and remembers the answer (/fishing open app or /fishing open browser changes it). With the app window, the game also opens on its own once per session when one starts (unless CLAUDEFISHING_AUTO_OPEN=0).

This is a Claude Code mod: hooks/register.ts runs inside Claude Code. The game and server are hosted separately; installing the mod requires no game source or build step. Requires Claude Code 2.1.287 or later; development checks use 2.1.289. The mod adds no skills, MCP server, or model calls.

Install

Pick one, then type /reload-plugins in any Claude Code session that is already open (a new session loads the plugin by itself).

Terminal (recommended):

claude plugin marketplace add bryanlin850/claudefishing
claude plugin install claudefishing@claudefishing

or, inside a terminal session of Claude Code, /plugin marketplace add bryanlin850/claudefishing, then /plugin install claudefishing@claudefishing.

Claude desktop app: typing /plugin marketplace add … in the Code tab drops its arguments, so either

  1. send Claude this message; it runs the two commands above with the app's own copy of claude, which is often not on your PATH:
   Install the claudefishing plugin for me by running this in the shell:
   c="${CLAUDE_CODE_EXECPATH:-claude}"; "$c" plugin marketplace add bryanlin850/claudefishing && "$c" plugin install claudefishing@claudefishing
   When it finishes, tell me to type /reload-plugins.
  1. or open Settings, scroll to Plugins, click Add, choose Add from a repository and paste https://github.com/bryanlin850/claudefishing. That adds the marketplace only: find claudefishing in the list and click Install.

Then type /reload-plugins in the Code tab.

One session, from a checkout

claude --plugin-dir /path/to/claudefishing

Every session from a checkout, including the desktop app: name the folder in CLAUDE_CODE_PLUGIN_DIRS, in your shell or in the env block of ~/.claude/settings.json (the desktop app reads the latter):

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claudefishing" } }

Settings

There is nothing to configure: the plugin declares no options (Claude Code would list them as "not yet set" on every install), and it only ever talks to https://claudefishing.io. The game opens by itself once per session when an interactive terminal session starts, or the desktop app or VS Code attaches (a phone never opens it; the server skips it while a game window is already connected, one was opened in the last minute, or fishing is off; and it never opens when you chose the browser link). To keep it from doing that, set CLAUDEFISHING_AUTO_OPEN to 0 (or false, no, off) in your shell or in the env block of ~/.claude/settings.json (the desktop app reads the latter):

{ "env": { "CLAUDEFISHING_AUTO_OPEN": "0" } }

Mods are enabled by default in supported Claude Code versions. Update Claude Code if /fishing is unknown, then run /reload-plugins or restart. Visual status is supported in the terminal and Desktop Code tab. Other surfaces can run hooks without displaying mod UI. The game opens in an external browser.

Updates. Marketplace installs do not update by themselves unless you turn auto-update on (/plugin → Marketplaces → claudefishing → Enable auto-update). By hand:

claude plugin marketplace update claudefishing && claude plugin update claudefishing@claudefishing

then type /reload-plugins (or restart Claude Code). In the desktop app, ask Claude to run it: its shell has the app's own claude as $CLAUDE_CODE_EXECPATH. When a newer plugin is out, the status line says · update available, a toast gives that command once, and /fishing shows it; when the server needs a newer one than yours, the status line says 🎣 update the plugin to play and the game shows an update screen.

Usage

/fishing              status (same as /fishing status)
/fishing open         open the game (pairs this machine with it; turns fishing on if it was off)
/fishing open app     open it in an app window, from now on
/fishing open browser give a link to open in your own browser, from now on
/fishing off          stop reporting from every session on this machine, and close its game window
/fishing on           resume
/fishing link         a one-time code for another device to play this cat
/fishing link <code>  play the cat that code was made for, here too
/fishing unlink       go back to this machine's own cat

/fishing status shows whether fishing is on, the server and whether it answers, whether the game window is open, your player, the devices that play it, how many live sessions they have, the model and effort shown on your nametag, the buff, this session's model and effort, and where the identity file is.

Several devices, one cat

Every machine starts with a cat of its own. /fishing link on a machine prints a code (like KQ74-MZ8P, good once, for 10 minutes; the command to type on the other device goes to the clipboard), and /fishing link KQ74-MZ8P on another machine makes that machine play the same cat: same fish, money, level and cosmetics. Its sessions count for the buff from then on, and a game window it has open reopens as that cat.

If the claiming machine's own cat has caught or bought something, a question comes first: it says what that cat has, what it is switching to and what becomes of the old one, and offers Switch to <cat> or Keep <cat>. Nothing is deleted: /fishing unlink switches a linked machine back to its own cat, again asking first if it is the last device of a cat with progress (that cat could not be reached again). In claude -p nobody can be asked, so nothing switches.

On and off is one switch for the whole machine, kept in ~/.claudefishing/fishing.json beside the identity: every session reads it every 5 seconds, whichever copy of the plugin it runs, and a new session starts the way you left it. The server keeps the switch too. While it is off, no session on the machine counts, not even one still running a plugin from before 0.3.0, and turning it off closes the game window the machine opened (the page closes itself, or says fishing is off if the browser will not let it). /fishing off always counts as a flip, so typing it again closes a window opened since. /fishing open turns fishing back on too; the automatic open never does, and opens nothing while fishing is off.

Sessions still on a plugin before 0.3.0 follow the switch at their next keepalive (they read it from the plugin's store, which the plugin keeps in step with the file), and their own /fishing on and /fishing off count as a flip once a session on 0.3.0 reads the store. A copy of the plugin loaded by hand keeps a store of its own: the server leaves its sessions out while fishing is off all the same.

The status line shows one of:

🎣 in gamethe server answers and a game window is connected
🎣 opening gamethis session just opened the game and its window has not joined yet (at most 45 s)
🎣 game closedthe server answers, no game window (/fishing open)
🎣 offlinethe server did not answer, or no session on the machine got an answer for 2½ minutes (the game stays locked unless another session reaches it)
🎣 off/fishing off

plus · ⚡+11% while the buff is on.

The first /fishing open asks "App window" or "Browser link" and keeps the answer in the plugin's store (dismissed, or in claude -p, it opens the app window and asks again next time). The app window opens with open -na "Google Chrome" --args --app=<url> (a chromeless app window), else open <url>, else xdg-open <url>. The browser link is printed and copied to the clipboard instead, to open in whichever browser you like. Either way the link carries a one-time pairing code; it works once, for two minutes.

What it sends, and what stays local

Every request goes to https://claudefishing.io (in development, to a server on the same machine) with Authorization: Bearer <secret>.

  • Identity. On first use the plugin creates ~/.claudefishing/identity.json ({ secret, createdAt }, a random 256-bit secret; the folder is mode 700, the file 600). It is written to a temporary file and linked into place, so sessions starting together all end up with the same one. It never leaves your machine except as that bearer token, and the server stores only hashes of it. Your browser never sees it: it is paired with a one-time code instead. Deleting the file makes you a new player; an unreadable one is kept as identity.json.corrupt-<time> and replaced. Set CLAUDEFISHING_HOME=/some/dir to keep it in /some/dir/identity.json instead (a second identity for testing).
  • POST /api/heartbeat per session, at once when something changes (Claude starts or stops working or starts waiting on you, the model or effort changes, activity after 5 idle minutes), every 5 s for up to 45 s after the session opens the game (until the answer says its window joined), once when the session ends, and once when fishing goes off (again a minute later while the server does not answer it). Otherwise as a keepalive once nothing was sent for 60 s, but only from a session Claude worked in during the last 5 minutes (the buff's window). An idle session stays quiet unless no session on the machine has sent a heartbeat for 60 s, so forty idle threads keep the game open with one heartbeat a minute, not forty. Quiet sessions' status lines show the last answer any session on the machine got. Each heartbeat carries:
Field
sessionIdthe Claude Code session id
enabledfalse once, when fishing goes off
endingtrue once, when the session ends; after /clear or /resume, once the next conversation has sent its first heartbeat
modelthe model id the last main-loop request used (claude-opus-5-5), or the one /model switched to, else the session's
effortthe last request's effort (low … max); null before this session's first turn, after a /model switch until the next request, and for a model without effort
workinga turn or a subagent is running right now and Claude is not waiting on you
activeAgoMsmilliseconds since Claude last started a turn, made a model request, called a tool, got a tool's result or finished a turn; null before any
modVersionthis plugin's version (a server may refuse versions older than its minimum)
fishingthe machine's switch, { on, rev }: rev counts the flips, so the server keeps the newest. The answer has the server's, which wins when newer (the file was lost, say)
workwhat Claude has done in the session, as totals (below), for game mechanics; the game may use them or not

work holds numbers only, totals since its random run id began, which only grow (a /clear or /resume starts a new run):

  • model requests answered, the main loop's and subagents', and their tokens by kind (input, output, cache read, cache write), also by the model that answered (claude-opus-5-5), for up to 8 models;
  • main-loop turns ended, of them how many you interrupted and how many ended on an error or a refusal, and their total length; subagent runs ended;
  • tool calls by tool: Claude Code's own tools by name (Bash, Read, Edit …), every MCP tool as mcp and anything else as other, never which server, plugin, command, file or input; and how many different MCP servers were called (their names stay in the session, on your machine);
  • the status line's figures at the last measure: how full the context window is and its size, your plan's rate-limit windows (percent used and when each resets), and what the session has cost so far.
  • The machine's keepalive and last answer, beside the identity and never sent anywhere: ~/.claudefishing/keepalive.json ({ at, sessionId }, stamped before every heartbeat; a session taking the keepalive over from another writes a claim first and beats only if its claim is still there a second later, so idle sessions finding it due together send one heartbeat) and ~/.claudefishing/answer.json (the newest heartbeat answer, which quiet sessions show).
  • POST /api/pair { sessionId, reason: 'auto' | 'manual' } when the game is opened. The server skips auto while fishing is off on the machine; manual (/fishing open) turns it on.
  • POST /api/link {} on /fishing link; POST /api/link/claim { code, sessionId, replace } on /fishing link <code> (replace is true only after you chose to switch); POST /api/unlink { sessionId, confirm } on /fishing unlink.

Nothing else: no prompts, answers, code, file names, tool inputs or paths.

Development

Use Node.js 24 LTS and install the development tools (including a local, pinned Claude Code binary):

npm ci
npm run validate
npm test
npm run typecheck

The tests run through Claude Code's engine with filesystem, processes, network and time mocked. They need no account or model call. Type checking first loads a disposable copy to generate .claude-plugin/types/ using the pinned Claude Code binary. It uses a temporary identity/configuration and a closed loopback port; it neither opens the game nor needs an account. Generated declarations are ignored by Git. Run npm run types:generate to refresh them separately.

For a local game server, load this checkout with the server in the environment:

CLAUDEFISHING_SERVER_URL=http://localhost:8790 CLAUDEFISHING_AUTO_OPEN=0 claude --plugin-dir /path/to/claudefishing

CLAUDEFISHING_SERVER_URL only takes a server on the same machine (localhost, 127.0.0.1 or [::1]); anything else is ignored. Every request carries the machine's secret, and a project's .claude/settings.json can set environment variables, so no setting may send it to another server. Use CLAUDEFISHING_HOME to choose a separate test identity (and on/off switch). To copy the mod into an existing hot-reload session, name that session explicitly:

npm run dev:sync -- /absolute/path/to/dev-mods/<session>

SERVER_URL overrides the copy's local server. The helper never guesses the most recent session, and refuses to replace a folder it did not create.

HTTP contract and releases

types/protocol.ts is the public contract for heartbeat, pairing and device linking. It contains no game rules, assets, storage schema or browser protocol. The game vendors an exact copy from a pinned public commit. release.json records its SHA-256, the plugin version and the stable update command.

For a release:

  1. Make backward-compatible changes to the contract and hooks.
  2. Run node scripts/prepare-release.mjs MAJOR.MINOR.PATCH to update version metadata and the contract hash.
  3. Run validation, tests and type checking; review and publish the commit.
  4. Tag that tested commit and update the game's pinned contract/release metadata. Only raise the server's minimum plugin version after the release is installable.

The marketplace uses the version in plugin.json; it does not duplicate it. The game can deploy independently while older plugins remain compatible.

Source and privacy

This repository contains only the Claude Code integration. Its history starts with that integration; game/server/admin source and their history remain in a separate private repository. The hosted game still delivers its browser code and assets to players. No open-source license is granted by this repository.

Source 4 files
hooks/register.ts 1463 lines
1// claudefishing: connects this machine's Claude Code sessions to the
2// claudefishing game. Heartbeats say a session is alive (so the game is
3// playable), which model/effort it runs and whether Claude is working (the
4// buff); /fishing opens the game, turns reporting on/off, shows status and
5// links devices so they play one cat.
6//
7// On and off are one switch for the whole machine: a file beside the
8// identity, which every session and every copy of the plugin reads. Each
9// flip is numbered, every heartbeat carries the switch, and the server keeps
10// each machine's newest: while it is off, no session of the machine counts,
11// not even one running an older plugin that never reads the file.
12//
13// Only sessions Claude works in count for the buff, so only they keep
14// themselves alive: while a turn runs and for the buff window after it. An
15// idle session stays quiet unless no session of the machine has beaten for a
16// keepalive (another file beside the identity), so a machine with forty
17// threads open keeps the game open with one keepalive, not forty. Quiet
18// sessions show the last answer any session of the machine got.
19//
20// Every heartbeat also carries what Claude did in the session, as totals of
21// numbers (model requests and their tokens, turns, tool calls, the status
22// line's figures), for game mechanics; nothing Claude read or wrote.
23
24import type { EngineInterface, Register, SessionMeasureInput, TurnCompleteInput, TurnStepResult, TurnUsage } from 'claude-code'
25import { MOD_VERSION } from '../types/version'
26
27import type { FishingActivity, FishingStep, FishingTurn } from '../types'
28import type {
29  FishingSwitch,
30  HeartbeatRequest,
31  HeartbeatResponse,
32  LinkClaimRequest,
33  LinkClaimResponse,
34  LinkResponse,
35  PairRequest,
36  PairResponse,
37  PlayerSummary,
38  UnlinkRequest,
39  UnlinkResponse,
40  WorkMeasure,
41  WorkReport,
42  WorkTokens,
43} from '../types/protocol'
44
45const DEFAULT_SERVER_URL = 'https://claudefishing.io' // npm run dev:sync swaps in the dev server in its copy only
46// No plugin.json userConfig: Claude Code reports declared options as "not yet set" on install, defaults or
47// not. CLAUDEFISHING_AUTO_OPEN=0 (false, no, off), from the shell or the `env` block of settings.json,
48// keeps the game from opening by itself. CLAUDEFISHING_SERVER_URL is for development and names a server
49// on this machine only: every request carries the machine's secret, and a project's settings can set
50// environment variables, so nothing in them may send it anywhere but the game.
51
52/**
53 * Every change is sent at once (queueBeat); with none, the server still hears this often from a
54 * session Claude worked in within the buff window, and from the machine (any idle session) otherwise.
55 */
56const KEEPALIVE_MS = 60_000
57/** Mirrors the server's presence.sessionTtlMs: no answer on the whole machine for this long, and a quiet session says offline. */
58const ANSWER_STALE_MS = 150_000
59/** A session taking over the machine's keepalive waits this long after its claim: of claims made together, the last one written beats. */
60const CLAIM_SETTLE_MS = 1_000
61/** How often the session checks, without the network, whether a keepalive is due or a turn went stale. */
62const TICK_MS = 5_000
63/** After this session opens the game, every tick beats until the server sees the window join, for at most this long. */
64const OPENING_MS = 45_000
65const HTTP_TIMEOUT_MS = 4_000
66/** /fishing off waits this long for a beat in flight, so its enabled:false lands after it. */
67const OFF_INFLIGHT_WAIT_MS = 300
68/** The switch before anyone flips it: on, no flips yet. */
69const FIRST_SWITCH: FishingSwitch = { on: true, rev: 0 }
70/** session.end has ~1.5 s for the whole chain: an exit spends at most this telling the server (its TTL drops the session anyway). */
71const END_TIMEOUT_MS = 600
72/** A beat already under way for a session id that just ended is dropped for this long; a later return to the id (/resume) beats again. */
73const ENDED_GUARD_MS = 5_000
74/** Mirrors the server's ENDED_TOMBSTONE_MS: it ignores beats for an id this long after that id ended. */
75const SERVER_TOMBSTONE_MS = 10_000
76/** After a /clear or /resume the engine may move to the next conversation's id a moment after session.end: poll for it. */
77const SWITCH_POLL_MS = 50
78const SWITCH_POLLS = 40
79/** Mirrors the server's buff.inactivityMs: activity after this long idle beats at once. */
80const INACTIVE_MS = 5 * 60_000
81/** A turn with no step or tool call for this long counts as over (its end was never seen); long tool runs stay inside it. */
82const STALE_TURN_MS = 30 * 60_000
83/** Notification types that mean a prompt or dialog waits on the person. */
84const PROMPT_NOTIFICATION = /permission_prompt|idle_prompt|elicitation(_url)?_dialog|needs_input/
85/** Tools whose call lasts until the person answers. */
86const ASKS_USER = new Set(['AskUserQuestion', 'ExitPlanMode'])
87const USAGE = 'usage: /fishing [status|open [app|browser]|on|off|link [code]|unlink]'
88/** Models and tools a work report keeps apart: past these, a request counts in the totals only, a tool as "other". */
89const WORK_MODELS = 8
90const WORK_TOOLS = 40
91/** MCP servers a run tells apart; a new one past these is not counted. */
92const WORK_MCP_SERVERS = 64
93/**
94 * The only tools a work report names: Claude Code's own (this build's, from its tool types, and a
95 * few of older builds'). A plugin can register a tool of any name, and an MCP tool names its server,
96 * so either could say what someone works with: those count as "other" and "mcp".
97 */
98const BUILTIN_TOOLS: ReadonlySet<string> = new Set([
99  'Agent', 'AppifactRepl', 'Artifact', 'ArtifactCheck', 'ArtifactComments', 'ArtifactData', 'AskUserQuestion', 'Bash', 'BashOutput',
100  'CronCreate', 'CronDelete', 'CronList', 'DesignSync', 'Edit', 'EndConversation', 'EnterPlanMode', 'EnterWorktree', 'ExitPlanMode',
101  'ExitWorktree', 'FetchInboxMessage', 'GetTask', 'Glob', 'Grep', 'KillShell', 'LS', 'LSP', 'ListAgents', 'ListConnectors',
102  'ListMcpResourcesTool', 'ListPlugins', 'ListSkills', 'Monitor', 'MultiEdit', 'NotebookEdit', 'NotebookRead', 'Poll', 'Projects',
103  'ProposeGoal', 'PushNotification', 'Read', 'ReadMcpResourceDirTool', 'ReadMcpResourceTool', 'ReadNotifications', 'RemoteTrigger',
104  'ReportFindings', 'ScheduleWakeup', 'SearchMcpRegistry', 'SearchPlugins', 'SearchSkills', 'SendFeedback', 'SendFile', 'SendMessage',
105  'SendUserFile', 'SendUserMessage', 'ShareOnboardingGuide', 'ShowOnboardingRolePicker', 'Skill', 'SlashCommand', 'SuggestConnectors',
106  'SuggestPluginInstall', 'SuggestSkills', 'Task', 'TaskCreate', 'TaskGet', 'TaskList', 'TaskOutput', 'TaskStop', 'TaskUpdate',
107  'TodoWrite', 'ToolSearch', 'WaitForMcpServers', 'WebFetch', 'WebSearch', 'Workflow', 'Write',
108])
109
110// Session-scoped values that survive hot reloads (module variables do not).
111const ACTIVITY = { plugin: 'claudefishing', key: 'activity' } as const
112const TURN = { plugin: 'claudefishing', key: 'turn' } as const
113const AUTO_OPEN = { plugin: 'claudefishing', key: 'autoOpen' } as const
114const WORK = { plugin: 'claudefishing', key: 'work' } as const
115const MCP_SEEN = { plugin: 'claudefishing', key: 'mcpSeen' } as const
116
117/** $.store: how /fishing open opens the game, as the person chose it the first time (or with /fishing open app|browser). */
118const OPEN_IN = 'openIn'
119/** app: a Chrome app window (else the default browser); browser: the link, to open in any browser. */
120type OpenIn = 'app' | 'browser'
121
122type Identity = { secret: string; createdAt: number }
123
124type Link = 'unknown' | 'online' | 'offline'
125
126/** `status` is set when the server answered with an HTTP error, absent when it could not be reached; `code` is the error the answer named. */
127type Reply = { ok: true; json: unknown } | { ok: false; error: string; status?: number; code?: string }
128
129type Runtime = {
130  /** DEFAULT_SERVER_URL, or a server on this machine from CLAUDEFISHING_SERVER_URL; no trailing slash. */
131  serverUrl: string
132  /** Open the game once per session (CLAUDEFISHING_AUTO_OPEN turns it off). */
133  autoOpen: boolean
134  /** This session acts on: reports while on; turned off it went quiet. Follows `fishing.on` within a tick. */
135  enabled: boolean
136  /** The machine's switch as this session last read it (the file, ~/.claudefishing/fishing.json). */
137  fishing: FishingSwitch
138  fishingPath: string | null
139  /** The flip whose off the server answered (null: none yet): an off it never heard is sent again. */
140  offSentRev: number | null
141  identityPath: string | null
142  identity: Identity | null
143  identityError: string | null
144  /** The one load (or creation) of the identity, shared by every caller while it runs. */
145  identityLoad: Promise<Identity | null> | null
146  /** Set when an unreadable identity file was moved aside. */
147  identityNote: string | null
148  activity: FishingActivity
149  turn: FishingTurn
150  /** What Claude did in this session, as every beat carries it. */
151  work: WorkReport
152  /** The MCP servers this run's tool calls went to, by name (`work.mcpServers` counts them): never sent. */
153  mcpSeen: string[]
154  lastSentWorking: boolean | null
155  /** When this session last sent a heartbeat: the next keepalive is due KEEPALIVE_MS after it. */
156  lastBeatAt: number | null
157  /** ~/.claudefishing/keepalive.json: when any session of the machine last beat. */
158  keepalivePath: string | null
159  /** ~/.claudefishing/answer.json: the last answer any session of the machine got. */
160  answerPath: string | null
161  /** When this session last got an answer, or took one from answer.json. */
162  answeredAt: number | null
163  /** answer.json as this session last wrote or showed it: a quiet session takes it again only once it changed. */
164  answerText: string | null
165  /** The plugin version an update toast was shown for (once each). */
166  updateToastFor: string | null
167  link: Link
168  linkError: string | null
169  last: HeartbeatResponse | null
170  timer: { cancel: () => void } | null
171  isBeatQueued: boolean
172  inFlight: Promise<Reply> | null
173  /** Beats are numbered so a late answer never overwrites a newer one. */
174  sentSeq: number
175  appliedSeq: number
176  isAutoOpening: boolean
177  /** The auto-open could not reach the server: the next beat it answers tries again. */
178  isAutoOpenPending: boolean
179  /** Until when a window this session opened is awaited (null: none): the line says "opening game" meanwhile. */
180  openingUntil: number | null
181  /** Session ids this process told the server were over, and when. */
182  endedAt: Map<string, number>
183}
184
185function hex(bytes: Uint8Array): string {
186  return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
187}
188
189function errorText(err: unknown): string {
190  const text = (err instanceof Error ? err.message : String(err)).split('\n')[0]!
191  if (text.includes('ECONNREFUSED')) return 'connection refused'
192  if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/.test(text)) return 'host not found'
193  return text.replace(/^claudefishing: \$\.[\w.]+(?:\(.*?\))?(?: failed)?: /, '').slice(0, 160)
194}
195
196// Waiting on the person (a permission prompt, a question) is not working, however long the turn stays open.
197function isWorking(rt: Runtime, now: number): boolean {
198  if (rt.turn.waiting || (!rt.turn.running && rt.turn.agents.length === 0)) return false
199  const last = rt.activity.lastActiveAt
200  return last !== null && now - last < STALE_TURN_MS
201}
202
203function isRecentlyEnded(rt: Runtime, sessionId: string, now: number): boolean {
204  const endedAt = rt.endedAt.get(sessionId)
205  return endedAt !== undefined && now - endedAt < ENDED_GUARD_MS
206}
207
208function markEnded(rt: Runtime, sessionId: string, now: number): void {
209  // An end older than a minute matters to nobody: the guard and the server's tombstone are seconds long.
210  for (const [id, at] of rt.endedAt) if (now - at > 60_000) rt.endedAt.delete(id)
211  rt.endedAt.set(sessionId, now)
212}
213
214/** CLAUDEFISHING_SERVER_URL when it names a server on this machine (development), else DEFAULT_SERVER_URL. */
215function serverUrlFrom(value: string | null | undefined): string {
216  const fallback = DEFAULT_SERVER_URL.replace(/\/+$/, '')
217  if (!value?.trim()) return fallback
218  try {
219    const url = new URL(value.trim())
220    const isThisMachine = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
221    return isThisMachine && (url.protocol === 'http:' || url.protocol === 'https:') ? url.origin : fallback
222  } catch {
223    return fallback
224  }
225}
226
227function autoOpenFrom(value: string | null | undefined): boolean {
228  return !/^(0|false|no|off)$/i.test(value?.trim() ?? '')
229}
230
231function pct(fraction: number): string {
232  return `+${Math.round(fraction * 100)}%`
233}
234
235function statusLine(rt: Runtime): string | undefined {
236  if (!rt.enabled) return '🎣 off'
237  if (rt.link === 'unknown') return undefined
238  if (rt.link === 'offline' || rt.last === null) return '🎣 offline'
239  if (rt.last.modUpdate?.required === true) return '🎣 update the plugin to play (/fishing status)'
240  const base = rt.last.clientConnected ? '🎣 in game' : rt.openingUntil !== null ? '🎣 opening game' : '🎣 game closed'
241  const { buff } = rt.last.presence
242  const update = rt.last.modUpdate ? ' · update available' : ''
243  return buff.active ? `${base} · ⚡${pct(buff.pct)}${update}` : `${base}${update}`
244}
245
246function asHeartbeatResponse(json: unknown): HeartbeatResponse | null {
247  const res = json as Partial<HeartbeatResponse> | null
248  if (res === null || typeof res !== 'object' || res.ok !== true) return null
249  const presence = res.presence
250  if (typeof presence !== 'object' || presence === null || typeof presence.buff !== 'object' || presence.buff === null) return null
251  return res as HeartbeatResponse
252}
253
254// ─── identity ──────────────────────────────────────────────────────────────
255
256/** ~/.claudefishing, where the identity and the switch live. */
257async function homeDir($: EngineInterface): Promise<string> {
258  // $.fs does not expand "~" (it resolves against the session cwd): build it from HOME.
259  const override = await $.env.get('CLAUDEFISHING_HOME')
260  if (override) return override.replace(/\/+$/, '')
261  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '.'
262  return `${home}/.claudefishing`
263}
264
265async function identityPath($: EngineInterface): Promise<string> {
266  return `${await homeDir($)}/identity.json`
267}
268
269function parseIdentity(text: string): Identity | null {
270  try {
271    const parsed = JSON.parse(text) as Partial<Identity>
272    if (typeof parsed.secret === 'string' && /^[0-9a-f]{64}$/.test(parsed.secret)) {
273      return { secret: parsed.secret, createdAt: Number(parsed.createdAt) || 0 }
274    }
275  } catch {
276    // corrupt
277  }
278  return null
279}
280
281async function readIdentity($: EngineInterface, path: string): Promise<Identity | 'missing' | 'corrupt'> {
282  if (!(await $.fs.exists(path))) return 'missing'
283  return parseIdentity(await $.fs.read(path)) ?? 'corrupt'
284}
285
286// Never leaves a half-written identity.json, and never replaces one: other sessions may be minting at the same moment.
287async function createIdentity($: EngineInterface, path: string): Promise<void> {
288  const identity: Identity = { secret: hex(crypto.getRandomValues(new Uint8Array(32))), createdAt: await $.clock.now() }
289  const text = `${JSON.stringify(identity, null, 2)}\n`
290  const tmp = `${path}.${hex(crypto.getRandomValues(new Uint8Array(6)))}.tmp`
291  await $.fs.write(tmp, text)
292  await $.process.run(['chmod', '600', tmp]).catch(() => undefined) // $.fs has no chmod
293  // link(2) fails when identity.json exists: of two sessions minting at once the first link wins, and both read it back.
294  const linked = await $.process.run(['ln', tmp, path]).catch(() => null)
295  await $.process.run(['rm', '-f', tmp]).catch(() => undefined)
296  if (linked === null && !(await $.fs.exists(path))) await $.fs.write(path, text) // no ln on this system
297}
298
299async function loadIdentity($: EngineInterface, rt: Runtime, path: string): Promise<Identity> {
300  const existing = await readIdentity($, path)
301  if (typeof existing === 'object') return existing
302  // An owner-only folder first, so the secret is never readable by others, even before its chmod.
303  const dir = path.slice(0, Math.max(0, path.lastIndexOf('/')))
304  if (dir !== '') await $.process.run(['mkdir', '-p', '-m', '700', dir]).catch(() => undefined)
305  if (existing === 'corrupt') {
306    // Moved aside, not overwritten: it may still hold a secret worth recovering by hand.
307    const aside = `${path}.corrupt-${await $.clock.now()}`
308    const moved = await $.process.run(['mv', path, aside]).catch(() => null)
309    if (moved?.exitCode === 0) rt.identityNote = `the unreadable one was kept as ${aside}`
310  }
311  await createIdentity($, path)
312  const created = await readIdentity($, path)
313  if (typeof created === 'object') return created
314  throw new Error(`could not create ${path}`)
315}
316
317async function loadIdentitySafely($: EngineInterface, rt: Runtime): Promise<Identity | null> {
318  try {
319    rt.identityPath ??= await identityPath($)
320    rt.identity = await loadIdentity($, rt, rt.identityPath)
321    rt.identityError = null
322  } catch (err) {
323    rt.identityError = errorText(err)
324    rt.identityLoad = null // the next caller tries again
325  }
326  return rt.identity
327}
328
329function ensureIdentity($: EngineInterface, rt: Runtime): Promise<Identity | null> {
330  rt.identityLoad ??= loadIdentitySafely($, rt)
331  return rt.identityLoad
332}
333
334// ─── the switch ────────────────────────────────────────────────────────────
335// /fishing on|off for the whole machine, in ~/.claudefishing/fishing.json. Plugins
336// before 0.3.0 kept it as `enabled` in $.store, which is one file per install (the
337// marketplace's, a hand-loaded copy's): each install's store is kept in step with
338// the file, so their sessions go on and off with it too. `switchRev` stamps the flip
339// a store was last brought to; `enabled` changed under that same stamp is an older
340// plugin's /fishing on|off, which counts as one flip more.
341
342function isSwitch(value: unknown): value is FishingSwitch {
343  const s = value as Partial<FishingSwitch> | null
344  return typeof s === 'object' && s !== null && typeof s.on === 'boolean' && Number.isSafeInteger(s.rev) && (s.rev as number) >= 0
345}
346
347function isSameSwitch(a: FishingSwitch, b: FishingSwitch): boolean {
348  return a.on === b.on && a.rev === b.rev
349}
350
351/** The file; FIRST_SWITCH while there is none, null when it does not read as a switch (being written, or edited by hand). */
352async function readSwitchFile($: EngineInterface, rt: Runtime): Promise<FishingSwitch | null> {
353  rt.fishingPath ??= `${await homeDir($)}/fishing.json`
354  if (!(await $.fs.exists(rt.fishingPath))) return FIRST_SWITCH
355  try {
356    const parsed: unknown = JSON.parse(await $.fs.read(rt.fishingPath))
357    return isSwitch(parsed) ? { on: parsed.on, rev: parsed.rev } : null
358  } catch {
359    return null
360  }
361}
362
363// Whole or not at all: other sessions read it every few seconds.
364async function writeSwitchFile($: EngineInterface, rt: Runtime, fishing: FishingSwitch): Promise<void> {
365  rt.fishingPath ??= `${await homeDir($)}/fishing.json`
366  const text = `${JSON.stringify({ on: fishing.on, rev: fishing.rev })}\n`
367  const tmp = `${rt.fishingPath}.${hex(crypto.getRandomValues(new Uint8Array(6)))}.tmp`
368  await $.fs.write(tmp, text)
369  const moved = await $.process.run(['mv', tmp, rt.fishingPath]).catch(() => null)
370  if (moved?.exitCode === 0) return
371  await $.process.run(['rm', '-f', tmp]).catch(() => undefined)
372  await $.fs.write(rt.fishingPath, text) // no mv on this system
373}
374
375/** This install's store, as plugins before 0.3.0 read it. `enabled` goes first: a stamp never covers a value it does not stand for. */
376async function syncStore($: EngineInterface, fishing: FishingSwitch): Promise<void> {
377  await $.store.set('enabled', fishing.on)
378  await $.store.set('switchRev', fishing.rev)
379}
380
381/** The machine's switch, with what an older plugin in this install did to it since; this install's store follows it. */
382async function loadSwitch($: EngineInterface, rt: Runtime): Promise<FishingSwitch> {
383  const file = await readSwitchFile($, rt)
384  if (file === null) return rt.fishing
385  const enabled = await $.store.get('enabled')
386  const stamp = await $.store.get('switchRev')
387  const stored = typeof enabled === 'boolean' ? enabled : null
388  let fishing = file
389  if (Number.isSafeInteger(stamp)) {
390    const rev = stamp as number
391    // An older plugin flipped `enabled` under the stamp: one flip more. A stamp ahead of the file: the file was lost.
392    if (rev === file.rev && stored !== null && stored !== file.on) fishing = { on: stored, rev: file.rev + 1 }
393    else if (rev > file.rev) fishing = { on: stored !== false, rev }
394  } else if (stored === false && isSameSwitch(file, FIRST_SWITCH)) {
395    fishing = { on: false, rev: 1 } // turned off before 0.3.0: the first flip
396  }
397  if (!isSameSwitch(fishing, file)) await writeSwitchFile($, rt, fishing)
398  if (stored !== fishing.on || stamp !== fishing.rev) await syncStore($, fishing)
399  rt.fishing = fishing
400  return fishing
401}
402
403/** /fishing on|off: one flip more, for every session of the machine (and, with the next beat, the server). */
404async function flip($: EngineInterface, rt: Runtime, on: boolean): Promise<FishingSwitch> {
405  const before = await loadSwitch($, rt)
406  const fishing = { on, rev: before.rev + 1 }
407  await writeSwitchFile($, rt, fishing)
408  await syncStore($, fishing)
409  rt.fishing = fishing
410  return fishing
411}
412
413/** The server's switch, when newer than this session's: a lost file, or the same flip settled the other way. True when taken. */
414async function adoptServerSwitch($: EngineInterface, rt: Runtime, theirs: unknown): Promise<boolean> {
415  if (!isSwitch(theirs)) return false
416  const ours = rt.fishing
417  if (theirs.rev < ours.rev || (theirs.rev === ours.rev && theirs.on === ours.on)) return false
418  const fishing = { on: theirs.on, rev: theirs.rev }
419  await writeSwitchFile($, rt, fishing)
420  await syncStore($, fishing)
421  rt.fishing = fishing
422  return true
423}
424
425// ─── HTTP ──────────────────────────────────────────────────────────────────
426
427// $.http.fetch has no timeout or signal: race it against $.clock.sleep (the fetch is abandoned, not aborted).
428async function postJson($: EngineInterface, rt: Runtime, path: string, secret: string, body: unknown, timeoutMs: number): Promise<Reply> {
429  const request = $.http
430    .fetch(`${rt.serverUrl}${path}`, {
431      method: 'POST',
432      headers: { 'content-type': 'application/json', authorization: `Bearer ${secret}` },
433      body: JSON.stringify(body),
434    })
435    .then(
436      (res): Reply => {
437        let json: unknown
438        try {
439          json = JSON.parse(res.text)
440        } catch {
441          return { ok: false, error: res.ok ? 'the answer was not JSON' : `HTTP ${res.status}`, status: res.status }
442        }
443        if (res.ok) return { ok: true, json }
444        const code = (json as { error?: unknown } | null)?.error
445        if (typeof code !== 'string') return { ok: false, error: `HTTP ${res.status}`, status: res.status }
446        return { ok: false, error: `HTTP ${res.status} ${code}`, status: res.status, code }
447      },
448      (err: unknown): Reply => ({ ok: false, error: errorText(err) }),
449    )
450  const timeout = $.clock.sleep(timeoutMs).then((): Reply => ({ ok: false, error: `no answer within ${timeoutMs / 1000} s` }))
451  return Promise.race([request, timeout])
452}
453
454// ─── heartbeats ────────────────────────────────────────────────────────────
455
456async function heartbeatBody($: EngineInterface, rt: Runtime, sessionId: string, now: number): Promise<HeartbeatRequest> {
457  const step = rt.activity.step
458  const lastActiveAt = rt.activity.lastActiveAt
459  return {
460    sessionId,
461    enabled: rt.enabled,
462    model: step?.model ?? (await $.session.model().catch(() => null)),
463    // Effort is only known from a real request (absent on models without effort), so it is
464    // null until this session's first turn; the buff only exists after a turn anyway.
465    effort: step?.effort ?? null,
466    working: isWorking(rt, now),
467    activeAgoMs: lastActiveAt === null ? null : Math.max(0, now - lastActiveAt),
468    modVersion: MOD_VERSION,
469    fishing: { on: rt.fishing.on, rev: rt.fishing.rev },
470    work: rt.work,
471  }
472}
473
474function applyReply(rt: Runtime, reply: Reply): HeartbeatResponse | null {
475  const res = reply.ok ? asHeartbeatResponse(reply.json) : null
476  rt.link = res !== null ? 'online' : 'offline'
477  rt.linkError = res !== null ? null : reply.ok ? 'unexpected answer' : reply.error
478  rt.last = res ?? rt.last
479  if (res?.clientConnected === true) rt.openingUntil = null // the awaited window joined
480  return res
481}
482
483/** This session is quiet from now on (the server knows: `offSentRev` is the off it heard). */
484function stopReporting(rt: Runtime, offSentRev: number | null): void {
485  rt.enabled = false
486  rt.lastSentWorking = null
487  rt.openingUntil = null
488  rt.offSentRev = offSentRev
489  rt.link = 'unknown'
490  rt.last = null
491}
492
493async function beat($: EngineInterface, rt: Runtime): Promise<void> {
494  // /fishing on|off may have run in another session, or another copy of the plugin, on this machine.
495  const fishing = await loadSwitch($, rt).catch(() => rt.fishing)
496  if (!fishing.on) {
497    // The server hears of an off once from each session (and again, while it never answers).
498    if (rt.enabled || rt.offSentRev !== fishing.rev) await turnOff($, rt)
499    $.ui.status('🎣 off')
500    return
501  }
502  rt.enabled = true
503  const sessionId = await $.session.id()
504  const identity = await ensureIdentity($, rt)
505  if (identity === null) {
506    rt.link = 'offline'
507    rt.linkError = `no identity: ${rt.identityError}`
508    $.ui.status(statusLine(rt))
509    return
510  }
511  const now = await $.clock.now()
512  const body = await heartbeatBody($, rt, sessionId, now)
513  if (isRecentlyEnded(rt, sessionId, now) || !rt.enabled) return // session.end or /fishing off ran meanwhile
514  rt.lastSentWorking = body.working
515  rt.lastBeatAt = now
516  // Before it goes, so idle sessions ticking meanwhile leave the machine's keepalive to this beat.
517  await stampKeepalive($, rt, sessionId, now).catch(() => undefined)
518  const seq = ++rt.sentSeq
519  const request = postJson($, rt, '/api/heartbeat', identity.secret, body, HTTP_TIMEOUT_MS)
520  rt.inFlight = request
521  const reply = await request
522  if (rt.inFlight === request) rt.inFlight = null
523  if (seq < rt.appliedSeq || !rt.enabled) return
524  rt.appliedSeq = seq
525  const res = applyReply(rt, reply)
526  if (res !== null) {
527    rt.answeredAt = now
528    await shareAnswer($, rt, now, res).catch(() => undefined)
529  }
530  // The server has a newer switch: off, this session goes quiet at once (the server already said so).
531  if (res !== null && (await adoptServerSwitch($, rt, res.fishing).catch(() => false)) && !rt.fishing.on) {
532    stopReporting(rt, rt.fishing.rev)
533    $.ui.status('🎣 off')
534    return
535  }
536  $.ui.status(statusLine(rt))
537  toastUpdate($, rt)
538  if (rt.isAutoOpenPending && rt.link === 'online') void autoOpenSafely($, rt)
539}
540
541/** Once per newer plugin, a toast with the command that updates this one. */
542function toastUpdate($: EngineInterface, rt: Runtime): void {
543  const update = rt.link === 'online' ? rt.last?.modUpdate : null
544  if (!update || rt.updateToastFor === update.latest) return
545  rt.updateToastFor = update.latest
546  const why = update.required ? `the game needs ${update.min ?? update.latest} or newer` : `${update.latest} is out`
547  $.ui.toast(`🎣 claudefishing ${MOD_VERSION}: ${why}. Update in a terminal: ${update.command}`)
548}
549
550async function runBeat($: EngineInterface, rt: Runtime): Promise<void> {
551  try {
552    await beat($, rt)
553  } catch (err) {
554    rt.link = 'offline'
555    rt.linkError = errorText(err)
556    $.ui.status(statusLine(rt))
557  }
558}
559
560// Beat as soon as this dispatch is over (a timer runs outside the hook's budget); one queued at a time.
561function queueBeat($: EngineInterface, rt: Runtime): void {
562  if (rt.isBeatQueued) return
563  rt.isBeatQueued = true
564  $.clock.after(0, () => {
565    rt.isBeatQueued = false
566    void runBeat($, rt)
567  })
568}
569
570// ─── one keepalive per machine ─────────────────────────────────────────────
571// The game needs two things of a machine: that Claude Code is open on it, and which sessions
572// Claude worked in within the buff window. Every beat is stamped in keepalive.json before it goes,
573// and an idle session keeps the machine alive only when that stamp is a keepalive old: the session
574// that kept it alive last goes on, any other claims it first (sessions finding it due together
575// would all beat otherwise). Every answer lands in answer.json unless a newer one is there, and the
576// quiet sessions' status lines follow it. Both are written in place: a torn read costs one extra
577// beat, or one tick of an older status line.
578
579/** `claim`: a session taking the keepalive over wrote it, and beats if it is still its claim CLAIM_SETTLE_MS later. */
580type KeepaliveStamp = { at: number; sessionId: string; claim?: string }
581
582type SharedAnswer = { at: number; modVersion: string; answer: HeartbeatResponse }
583
584/** Claude works in this session, or did within the buff window: the server counts it for the buff, so it keeps itself alive. */
585function isReporting(rt: Runtime, now: number): boolean {
586  const last = rt.activity.lastActiveAt
587  return isWorking(rt, now) || (last !== null && now - last < INACTIVE_MS)
588}
589
590/** When a session of the machine last beat (or claimed the keepalive), and which; null with no stamp, or one that does not read. */
591async function readKeepalive($: EngineInterface, rt: Runtime): Promise<KeepaliveStamp | null> {
592  rt.keepalivePath ??= `${await homeDir($)}/keepalive.json`
593  try {
594    if (!(await $.fs.exists(rt.keepalivePath))) return null
595    const { at, sessionId, claim } = JSON.parse(await $.fs.read(rt.keepalivePath)) as Partial<Record<keyof KeepaliveStamp, unknown>>
596    if (typeof at !== 'number' || !Number.isFinite(at) || typeof sessionId !== 'string') return null
597    return { at, sessionId, ...(typeof claim === 'string' ? { claim } : {}) }
598  } catch {
599    return null
600  }
601}
602
603async function stampKeepalive($: EngineInterface, rt: Runtime, sessionId: string, now: number, claim?: string): Promise<void> {
604  rt.keepalivePath ??= `${await homeDir($)}/keepalive.json`
605  const stamp: KeepaliveStamp = { at: now, sessionId, ...(claim !== undefined ? { claim } : {}) }
606  await $.fs.write(rt.keepalivePath, `${JSON.stringify(stamp)}\n`)
607}
608
609/** Claims the machine's keepalive: true when no claim written meanwhile (or beat sent) replaced this one. */
610async function claimKeepalive($: EngineInterface, rt: Runtime, sessionId: string, now: number): Promise<boolean> {
611  const claim = hex(crypto.getRandomValues(new Uint8Array(6)))
612  await stampKeepalive($, rt, sessionId, now, claim)
613  await $.clock.sleep(CLAIM_SETTLE_MS)
614  return (await readKeepalive($, rt))?.claim === claim
615}
616
617/** The last answer any session of the machine got, and the file's text; null with none, or one that does not read. */
618async function readAnswer($: EngineInterface, rt: Runtime): Promise<(SharedAnswer & { text: string }) | null> {
619  rt.answerPath ??= `${await homeDir($)}/answer.json`
620  try {
621    if (!(await $.fs.exists(rt.answerPath))) return null
622    const text = await $.fs.read(rt.answerPath)
623    const shared = JSON.parse(text) as Partial<SharedAnswer> | null
624    const answer = asHeartbeatResponse(shared?.answer)
625    if (answer === null || typeof answer.serverTime !== 'number' || typeof shared?.at !== 'number' || typeof shared.modVersion !== 'string') return null
626    return { at: shared.at, modVersion: shared.modVersion, answer, text }
627  } catch {
628    return null
629  }
630}
631
632/** Unless a newer answer is there already: two sessions' answers can land in either order (of two as old, the later one written stays). */
633async function shareAnswer($: EngineInterface, rt: Runtime, now: number, answer: HeartbeatResponse): Promise<void> {
634  const current = await readAnswer($, rt)
635  if (current !== null && current.answer.serverTime > answer.serverTime) return
636  rt.answerPath ??= `${await homeDir($)}/answer.json`
637  const text = `${JSON.stringify({ at: now, modVersion: MOD_VERSION, answer } satisfies SharedAnswer)}\n`
638  await $.fs.write(rt.answerPath, text)
639  rt.answerText = text
640}
641
642/** Its own keepalive while the server counts it for the buff; past that, the machine's, once no session has beaten for one. */
643async function isKeepaliveDue($: EngineInterface, rt: Runtime, now: number): Promise<boolean> {
644  if (rt.lastBeatAt !== null && now - rt.lastBeatAt < KEEPALIVE_MS) return false
645  if (isReporting(rt, now)) return true
646  const stamp = await readKeepalive($, rt)
647  if (stamp !== null && now - stamp.at < KEEPALIVE_MS) return false
648  const sessionId = await $.session.id()
649  return stamp?.sessionId === sessionId || claimKeepalive($, rt, sessionId, now)
650}
651
652// A quiet session shows the newest answer of the machine: one it has not shown yet, no older than its
653// own. With none for a session's lifetime on the server, no session of the machine reaches the game:
654// offline, as the one trying to will be.
655async function followAnswer($: EngineInterface, rt: Runtime, now: number): Promise<void> {
656  const shared = await readAnswer($, rt)
657  if (shared !== null && shared.text !== rt.answerText && shared.answer.serverTime >= (rt.last?.serverTime ?? -Infinity)) {
658    rt.answerText = shared.text
659    // An answer to another version of the plugin says nothing about this one's updates.
660    const modUpdate = shared.modVersion === MOD_VERSION ? (shared.answer.modUpdate ?? null) : (rt.last?.modUpdate ?? null)
661    rt.last = { ...shared.answer, modUpdate }
662    rt.link = 'online'
663    rt.linkError = null
664    rt.answeredAt = shared.at
665    if (rt.last.clientConnected) rt.openingUntil = null
666    $.ui.status(statusLine(rt))
667    toastUpdate($, rt)
668    if (rt.isAutoOpenPending) void autoOpenSafely($, rt)
669    return
670  }
671  const answeredAt = Math.max(rt.answeredAt ?? -Infinity, shared?.at ?? -Infinity)
672  if (rt.link === 'online' && now - answeredAt > ANSWER_STALE_MS) {
673    rt.link = 'offline'
674    rt.linkError = 'no session on this machine got an answer lately'
675    $.ui.status(statusLine(rt))
676  }
677}
678
679// A keepalive once one is due (isKeepaliveDue), a beat as soon as a turn goes stale (STALE_TURN_MS)
680// and stops counting as work, and one every tick while a window this session opened has not joined
681// (the server only says so in a beat's answer); otherwise the status line follows the machine's
682// answers. The switch is read every tick (no network): a flip anywhere on the machine reaches this
683// session within one.
684async function tick($: EngineInterface, rt: Runtime): Promise<void> {
685  const now = await $.clock.now()
686  if (rt.openingUntil !== null && now >= rt.openingUntil) {
687    rt.openingUntil = null // it never joined: "game closed" again, and keepalives only
688    $.ui.status(statusLine(rt))
689  }
690  const fishing = await loadSwitch($, rt).catch(() => rt.fishing)
691  if (fishing.on !== rt.enabled) return runBeat($, rt)
692  if (!fishing.on) {
693    // An off the server never answered goes again, a keepalive after the last try.
694    if (rt.offSentRev !== fishing.rev && (rt.lastBeatAt === null || now - rt.lastBeatAt >= KEEPALIVE_MS)) await runBeat($, rt)
695    return
696  }
697  const isStale = rt.lastSentWorking === true && !isWorking(rt, now)
698  if (rt.openingUntil === null && !isStale && !(await isKeepaliveDue($, rt, now))) return followAnswer($, rt, now).catch(() => undefined)
699  // Counted as a beat even if it cannot be sent (offline): the next try is a keepalive later.
700  rt.lastBeatAt = now
701  await runBeat($, rt)
702}
703
704function startTimer($: EngineInterface, rt: Runtime): void {
705  rt.timer?.cancel()
706  // Keeps ticking while off too (no network then) so a /fishing on from another session resumes this one.
707  rt.timer = $.clock.every(TICK_MS, () => void tick($, rt))
708}
709
710// /fishing on, and /fishing open when off: a flip on, which every session of the machine follows.
711async function turnOn($: EngineInterface, rt: Runtime): Promise<void> {
712  await flip($, rt, true)
713  rt.enabled = true
714  startTimer($, rt)
715  await runBeat($, rt)
716}
717
718// The server drops this session now rather than after its TTL. An off it has not heard of yet (a
719// newer flip) drops every session of the machine and closes the game window the machine opened.
720async function turnOff($: EngineInterface, rt: Runtime): Promise<void> {
721  const fishing = rt.fishing
722  stopReporting(rt, rt.offSentRev)
723  if (rt.inFlight !== null) await Promise.race([rt.inFlight, $.clock.sleep(OFF_INFLIGHT_WAIT_MS)]) // land it first
724  const identity = rt.identity
725  const sessionId = await $.session.id()
726  const now = await $.clock.now()
727  rt.lastBeatAt = now // a try that gets no answer goes again a keepalive later
728  if (identity === null || isRecentlyEnded(rt, sessionId, now)) return
729  const body = await heartbeatBody($, rt, sessionId, now)
730  const reply = await postJson($, rt, '/api/heartbeat', identity.secret, { ...body, enabled: false }, HTTP_TIMEOUT_MS)
731  if (!reply.ok) return
732  // Heard (a server from before the switch hears it too): sent. A newer switch there wins; on, the next tick resumes.
733  rt.offSentRev = fishing.rev
734  await adoptServerSwitch($, rt, asHeartbeatResponse(reply.json)?.fishing).catch(() => false)
735}
736
737/** `work`: the ended conversation's totals (after a /clear, this process already counts the next one's). */
738async function sendEnding($: EngineInterface, rt: Runtime, sessionId: string, timeoutMs: number, work: WorkReport = rt.work): Promise<void> {
739  const now = await $.clock.now()
740  markEnded(rt, sessionId, now)
741  const identity = rt.identity
742  if (!rt.enabled || identity === null) return
743  const body = await heartbeatBody($, rt, sessionId, now)
744  await postJson($, rt, '/api/heartbeat', identity.secret, { ...body, work, working: false, ending: true }, timeoutMs)
745}
746
747/** A conversation's run as it ended: its totals, and the MCP servers they count. */
748type EndedRun = { work: WorkReport; mcpSeen: string[] }
749
750// After a /clear or an in-session /resume the process goes on as another conversation: beat as it
751// first, then end the old id (with its totals), so the server never sees this machine without a session.
752async function switchSession($: EngineInterface, rt: Runtime, endedId: string, ended: EndedRun): Promise<void> {
753  let sessionId = await $.session.id()
754  for (let i = 0; sessionId === endedId && i < SWITCH_POLLS; i++) {
755    await $.clock.sleep(SWITCH_POLL_MS)
756    sessionId = await $.session.id()
757  }
758  if (sessionId === endedId) {
759    // Still the same conversation: nothing to end, and its run goes on.
760    rt.work = ended.work
761    rt.mcpSeen = ended.mcpSeen
762    await $.state.set(WORK, rt.work).catch(() => undefined)
763    await $.state.set(MCP_SEEN, rt.mcpSeen).catch(() => undefined)
764    return runBeat($, rt)
765  }
766  const endedAt = rt.endedAt.get(sessionId)
767  if (endedAt !== undefined) {
768    // Back to a conversation ended moments ago: the server ignores its beats until its tombstone passes.
769    const wait = endedAt + SERVER_TOMBSTONE_MS + 500 - (await $.clock.now())
770    if (wait > 0) await $.clock.sleep(wait)
771    rt.endedAt.delete(sessionId)
772  }
773  await runBeat($, rt)
774  await sendEnding($, rt, endedId, HTTP_TIMEOUT_MS, ended.work)
775}
776
777async function switchSessionSafely($: EngineInterface, rt: Runtime, endedId: string, ended: EndedRun): Promise<void> {
778  try {
779    await switchSession($, rt, endedId, ended)
780  } catch {
781    // the timer beats as the new id; the server's TTL drops the old one
782  }
783}
784
785// Every sign of Claude going on: stamps the activity and ends any wait on the person.
786async function touch($: EngineInterface, rt: Runtime, turn: Partial<FishingTurn> = {}, step?: FishingStep): Promise<void> {
787  const turnBefore = rt.turn
788  rt.turn = { ...turnBefore, waiting: false, ...turn }
789  const isTurnChanged = rt.turn.running !== turnBefore.running || rt.turn.waiting !== turnBefore.waiting || rt.turn.agents !== turnBefore.agents
790  const now = await $.clock.now()
791  const before = rt.activity
792  const wasInactive = before.lastActiveAt === null || now - before.lastActiveAt >= INACTIVE_MS
793  const isStepChanged = step !== undefined && (before.step?.model !== step.model || before.step?.effort !== step.effort)
794  rt.activity = { lastActiveAt: now, step: step ?? before.step }
795  await $.state.set(ACTIVITY, rt.activity)
796  if (isTurnChanged) await $.state.set(TURN, rt.turn)
797  if (rt.enabled && (wasInactive || isStepChanged || isWorking(rt, now) !== rt.lastSentWorking)) queueBeat($, rt)
798}
799
800// Hooks on the turn's path must never fail because of the game.
801async function touchSafely($: EngineInterface, rt: Runtime, turn?: Partial<FishingTurn>, step?: FishingStep): Promise<void> {
802  try {
803    await touch($, rt, turn, step)
804  } catch {
805    // the next timer beat carries whatever this missed
806  }
807}
808
809// Waiting stamps no activity: the buff runs out 5 minutes after Claude last did something.
810async function setWaiting($: EngineInterface, rt: Runtime, waiting: boolean): Promise<void> {
811  if (rt.turn.waiting === waiting) return
812  rt.turn = { ...rt.turn, waiting }
813  await $.state.set(TURN, rt.turn)
814  if (rt.enabled && isWorking(rt, await $.clock.now()) !== rt.lastSentWorking) queueBeat($, rt)
815}
816
817async function setWaitingSafely($: EngineInterface, rt: Runtime, waiting: boolean): Promise<void> {
818  try {
819    await setWaiting($, rt, waiting)
820  } catch {
821    // the next timer beat carries it
822  }
823}
824
825// The last step's effort belongs to the old model: unknown until the new one runs a step.
826async function switchModel($: EngineInterface, rt: Runtime, model: string): Promise<void> {
827  rt.activity = { ...rt.activity, step: { model, effort: null } }
828  await $.state.set(ACTIVITY, rt.activity)
829  if (rt.enabled) queueBeat($, rt)
830}
831
832async function switchModelSafely($: EngineInterface, rt: Runtime, model: string): Promise<void> {
833  try {
834    await switchModel($, rt, model)
835  } catch {
836    // the next step reports the model
837  }
838}
839
840// ─── what Claude did ───────────────────────────────────────────────────────
841// Totals for game mechanics, on the beats the session sends anyway (the server may use them or not):
842// numbers only, never what Claude read or wrote. Kept in $.state, so a hot reload goes on counting
843// the same run; a /clear or /resume starts a new one.
844
845function noTokens(): WorkTokens {
846  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
847}
848
849function newWork(): WorkReport {
850  return {
851    run: hex(crypto.getRandomValues(new Uint8Array(6))),
852    steps: 0,
853    tokens: noTokens(),
854    byModel: {},
855    turns: { count: 0, aborted: 0, failed: 0, ms: 0 },
856    agentRuns: 0,
857    tools: {},
858    mcpServers: 0,
859    measure: null,
860  }
861}
862
863/** The totals a previous load of the module kept, with any field it did not have yet at zero. */
864function restoreWork(kept: Partial<WorkReport> | undefined): WorkReport {
865  if (typeof kept?.run !== 'string') return newWork()
866  const fresh = newWork()
867  return {
868    ...fresh,
869    ...kept,
870    run: kept.run,
871    tokens: { ...fresh.tokens, ...kept.tokens },
872    byModel: { ...kept.byModel },
873    turns: { ...fresh.turns, ...kept.turns },
874    tools: { ...kept.tools },
875  }
876}
877
878/** A count from the engine; anything not a finite, non-negative number counts as none. */
879function amount(value: unknown): number {
880  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0
881}
882
883function addTokens(to: WorkTokens, usage: TurnUsage): void {
884  to.input += amount(usage.input_tokens)
885  to.output += amount(usage.output_tokens)
886  to.cacheRead += amount(usage.cache_read_input_tokens)
887  to.cacheWrite += amount(usage.cache_creation_input_tokens)
888}
889
890/** A tool as the game hears of it: Claude Code's own by name, never which MCP server or plugin. */
891function toolKind(tool: string): string {
892  if (tool.startsWith('mcp__')) return 'mcp'
893  return BUILTIN_TOOLS.has(tool) ? tool : 'other'
894}
895
896/** A response's model and tokens; a request no response answered counts for nothing. */
897function countStep(work: WorkReport, result: TurnStepResult): void {
898  if (result.stopReason === null && result.usage === null) return
899  work.steps += 1
900  const usage = result.usage
901  if (usage === null) return
902  addTokens(work.tokens, usage)
903  const model = typeof usage.model === 'string' && /^[a-z0-9][a-z0-9.:@-]{0,99}$/i.test(usage.model) ? usage.model : 'other'
904  const byModel = work.byModel[model] ?? (Object.keys(work.byModel).length < WORK_MODELS ? (work.byModel[model] = { steps: 0, ...noTokens() }) : null)
905  if (byModel === null) return
906  byModel.steps += 1
907  addTokens(byModel, usage)
908}
909
910/** True when the call went to an MCP server the run had not seen: `seen` holds the names, the report only how many. */
911function countTool(work: WorkReport, seen: string[], tool: string): boolean {
912  const kind = toolKind(tool)
913  const key = work.tools[kind] !== undefined || Object.keys(work.tools).length < WORK_TOOLS ? kind : 'other'
914  work.tools[key] = (work.tools[key] ?? 0) + 1
915  const server = kind === 'mcp' ? tool.split('__')[1] : undefined
916  if (!server || seen.includes(server) || seen.length >= WORK_MCP_SERVERS) return false
917  seen.push(server)
918  work.mcpServers += 1
919  return true
920}
921
922function countTurn(work: WorkReport, e: TurnCompleteInput): void {
923  if (e.agentId !== undefined) {
924    work.agentRuns += 1
925    return
926  }
927  work.turns.count += 1
928  if (e.isAborted) work.turns.aborted += 1
929  if (e.reason === 'error' || e.reason === 'refusal') work.turns.failed += 1
930  work.turns.ms += amount(e.durationMs)
931}
932
933function measureOf(e: SessionMeasureInput): WorkMeasure {
934  return {
935    contextPct: typeof e.context?.percent === 'number' && Number.isFinite(e.context.percent) ? e.context.percent : null,
936    contextWindow: amount(e.context?.window),
937    rateLimits: (e.rateLimits ?? []).slice(0, 4).map(limit => ({
938      kind: String(limit.kind).slice(0, 32),
939      percentUsed: amount(limit.percentUsed),
940      resetsAt: typeof limit.resetsAt === 'string' ? limit.resetsAt.slice(0, 40) : null,
941    })),
942    costUsd: typeof e.cost?.usd === 'number' && Number.isFinite(e.cost.usd) ? e.cost.usd : null,
943  }
944}
945
946// Counting never holds up Claude: a failure loses one count, kept in memory for the next beat.
947async function countSafely($: EngineInterface, rt: Runtime, change: (work: WorkReport) => void): Promise<void> {
948  try {
949    change(rt.work)
950    await $.state.set(WORK, rt.work)
951  } catch {
952    // the next count saves it
953  }
954}
955
956async function countToolSafely($: EngineInterface, rt: Runtime, tool: string): Promise<void> {
957  try {
958    const isNewServer = countTool(rt.work, rt.mcpSeen, tool)
959    await $.state.set(WORK, rt.work)
960    if (isNewServer) await $.state.set(MCP_SEEN, rt.mcpSeen)
961  } catch {
962    // the next count saves it
963  }
964}
965
966/** The kept server names, from a previous load of the module; anything else is none. */
967function restoreSeen(kept: unknown): string[] {
968  return Array.isArray(kept) ? kept.filter((name): name is string => typeof name === 'string').slice(0, WORK_MCP_SERVERS) : []
969}
970
971// ─── opening the game ──────────────────────────────────────────────────────
972
973async function openUrl($: EngineInterface, url: string): Promise<string | null> {
974  // macOS: -n so --args reach a Chrome that is already running; then the default browser.
975  const tries: [argv: string[], how: string][] = [
976    [['open', '-na', 'Google Chrome', '--args', `--app=${url}`], 'a Chrome app window'],
977    [['open', url], 'the default browser'],
978    [['xdg-open', url], 'the default browser'],
979  ]
980  for (const [argv, how] of tries) {
981    const result = await $.process.run(argv).catch(() => null)
982    if (result?.exitCode === 0) return how
983  }
984  return null
985}
986
987/** How the game opens as the person chose it; null until they did (or when the store cannot be read). */
988async function loadOpenIn($: EngineInterface): Promise<OpenIn | null> {
989  const value = await $.store.get(OPEN_IN).catch(() => undefined)
990  return value === 'app' || value === 'browser' ? value : null
991}
992
993const OPEN_OPTIONS = ['App window', 'Browser link'] as const
994
995/** The first /fishing open asks; null when the question was dismissed or nobody could be asked (`claude -p`). */
996async function askOpenIn($: EngineInterface): Promise<OpenIn | null> {
997  const question = 'Open claudefishing in an app window, or get a link to open in your own browser?'
998  const answer = await $.ui.ask(question, { header: 'Open game', options: OPEN_OPTIONS }).catch(() => null)
999  if (answer === null) return null
1000  // Typed under "Other" too: "app", "browser", "a link", "tab".
1001  if (answer === OPEN_OPTIONS[0] || /^\s*app\b/i.test(answer)) return 'app'
1002  if (answer === OPEN_OPTIONS[1] || /\b(browser|link|tab)\b/i.test(answer)) return 'browser'
1003  return null
1004}
1005
1006/** unreachable: the pair request got no answer at all (worth trying again); failed: anything else that went wrong. */
1007type OpenResult = { kind: 'opened' | 'skipped' | 'failed' | 'unreachable'; text: string }
1008
1009async function openGame($: EngineInterface, rt: Runtime, reason: PairRequest['reason'], openIn: OpenIn): Promise<OpenResult> {
1010  const identity = await ensureIdentity($, rt)
1011  if (identity === null) return { kind: 'failed', text: `no identity file: ${rt.identityError}` }
1012  const request: PairRequest = { sessionId: await $.session.id(), reason }
1013  const reply = await postJson($, rt, '/api/pair', identity.secret, request, HTTP_TIMEOUT_MS)
1014  if (!reply.ok) {
1015    return { kind: reply.status === undefined ? 'unreachable' : 'failed', text: `server unreachable at ${rt.serverUrl} (${reply.error})` }
1016  }
1017  const pair = reply.json as PairResponse | null
1018  if (pair === null || typeof pair !== 'object') return { kind: 'failed', text: 'unexpected answer from the server' }
1019  if (pair.ok === false) {
1020    const why = { 'client-connected': 'the game is already open', 'recently-opened': 'the game was opened moments ago', 'fishing-off': 'fishing is off on this machine' }
1021    return { kind: 'skipped', text: why[pair.skipped] ?? why['recently-opened'] }
1022  }
1023  if (typeof pair.code !== 'string') return { kind: 'failed', text: 'unexpected answer from the server' }
1024  const url = `${rt.serverUrl}/#pair=${encodeURIComponent(pair.code)}`
1025  let text: string
1026  if (openIn === 'browser') {
1027    const copied = (await $.ui.copy({ text: url }).catch(() => null))?.isCopied === true
1028    text = `open this link in your browser${copied ? ' (copied)' : ''}; it works once, for 2 minutes:\n${url}`
1029  } else {
1030    const how = await openUrl($, url)
1031    if (how === null) return { kind: 'failed', text: `could not start a browser; open ${url} yourself (the link works once, for 2 minutes)` }
1032    text = `opened in ${how}`
1033  }
1034  // The window takes a few seconds to load and join: until a beat's answer says it did, the line says so.
1035  rt.openingUntil = (await $.clock.now()) + OPENING_MS
1036  $.ui.status(statusLine(rt))
1037  return { kind: 'opened', text }
1038}
1039
1040// Once per session (the terminal at start, the desktop app or VS Code when it attaches); done once the
1041// server answered the pair, even with a skip. One it could not reach is tried again on a later beat.
1042async function autoOpen($: EngineInterface, rt: Runtime): Promise<void> {
1043  if (!rt.autoOpen || !rt.enabled || rt.isAutoOpening) return
1044  rt.isAutoOpening = true
1045  try {
1046    const { value: state } = await $.state.get(AUTO_OPEN)
1047    if (state === 'done') {
1048      rt.isAutoOpenPending = false
1049      return
1050    }
1051    // With the browser chosen there is no window to open, and a link nobody asked for would only expire.
1052    const result: OpenResult =
1053      (await loadOpenIn($)) === 'browser' ? { kind: 'skipped', text: 'the game opens with /fishing open' } : await openGame($, rt, 'auto', 'app')
1054    rt.isAutoOpenPending = result.kind === 'unreachable'
1055    await $.state.set(AUTO_OPEN, rt.isAutoOpenPending ? 'pending' : 'done')
1056    if (result.kind === 'opened') $.ui.toast(`🎣 game ${result.text}`)
1057  } finally {
1058    rt.isAutoOpening = false
1059  }
1060}
1061
1062async function autoOpenSafely($: EngineInterface, rt: Runtime): Promise<void> {
1063  try {
1064    await autoOpen($, rt)
1065  } catch {
1066    // /fishing open is always there
1067  }
1068}
1069
1070// ─── /fishing ──────────────────────────────────────────────────────────────
1071
1072function buffText(res: HeartbeatResponse): string {
1073  const { buff, working } = res.presence
1074  if (!buff.active) return `off (Claude idle; ${pct(buff.potentialPct)} while working)`
1075  if (working || buff.expiresAt === null) return `⚡${pct(buff.pct)} while Claude works`
1076  const minutes = Math.max(1, Math.ceil((buff.expiresAt - res.serverTime) / 60_000))
1077  return `⚡${pct(buff.pct)}, ${minutes} min left`
1078}
1079
1080// Older servers send neither field: one machine, its own cat.
1081function devicesText(res: HeartbeatResponse, cat: PlayerSummary): string {
1082  const machines = typeof res.machines === 'number' ? res.machines : 1
1083  if (res.linked === true) return `${machines} play ${cat.name}, this one linked (/fishing unlink leaves)`
1084  if (machines > 1) return `${machines} play ${cat.name} (/fishing link adds another)`
1085  return `this one only (/fishing link plays ${cat.name} on another too)`
1086}
1087
1088async function statusReport($: EngineInterface, rt: Runtime): Promise<string> {
1089  const now = await $.clock.now()
1090  const body = await heartbeatBody($, rt, await $.session.id(), now)
1091  const link = rt.link === 'online' ? 'online' : rt.link === 'offline' ? `offline (${rt.linkError ?? 'no answer'})` : 'not connected'
1092  const lines = [`fishing ${rt.enabled ? 'on' : 'off'} · server ${rt.serverUrl} · ${rt.enabled ? link : 'not reporting'}`]
1093  const res = rt.enabled && rt.link === 'online' ? rt.last : null
1094  if (res !== null) {
1095    const update = res.modUpdate
1096    if (update) {
1097      const why = update.required ? `the game needs ${update.min ?? update.latest} or newer` : `${update.latest} is out`
1098      lines.push(`plugin: ${MOD_VERSION}, ${why}. Update in a terminal, then restart Claude Code: ${update.command}`)
1099    }
1100    lines.push(`game: ${res.clientConnected ? 'in game' : rt.openingUntil !== null ? 'opening' : 'closed (/fishing open)'}`)
1101    if (res.player !== null) {
1102      lines.push(`player: ${res.player.name} · level ${res.player.level} ${res.player.rank} · $${res.player.money}`)
1103      lines.push(`devices: ${devicesText(res, res.player)}`)
1104    }
1105    const where = res.machines > 1 ? `across ${res.machines} devices` : 'on this machine'
1106    lines.push(`sessions: ${res.presence.sessionCount} reporting ${where} (idle ones stay quiet) · shown as ${res.presence.model ?? 'unknown'}${res.presence.effort ? ` · ${res.presence.effort}` : ''}`)
1107    lines.push(`buff: ${buffText(res)}`)
1108  }
1109  const doing = body.working ? ' · working' : rt.turn.waiting ? ' · waiting on you' : isReporting(rt, now) ? '' : ' · idle'
1110  lines.push(`this session: model ${body.model ?? 'unknown'} · effort ${body.effort ?? 'not known until a turn runs'}${doing}`)
1111  const note = rt.identityNote === null ? '' : `; ${rt.identityNote}`
1112  lines.push(`identity: ${rt.identityPath ?? (await identityPath($))} (stays on this machine${note})`)
1113  lines.push(USAGE)
1114  return lines.join('\n')
1115}
1116
1117// ─── linking devices ───────────────────────────────────────────────────────
1118// Every machine has its own cat until it claims a link code from a machine
1119// that plays another: from then on both play that one (one cat, one
1120// progress). /fishing unlink returns a machine to its own cat.
1121
1122type HasProgress = Extract<LinkClaimResponse, { error: 'has-progress' }>
1123
1124/** "Mochi (level 3, 12 fish, $120)" */
1125function catText(cat: PlayerSummary): string {
1126  return `${cat.name} (level ${cat.level}, ${cat.totalCaught} fish, $${cat.money})`
1127}
1128
1129/** Shown as two groups of four; the server reads it back in any case, with or without spaces and dashes. */
1130function showCode(code: string): string {
1131  return `${code.slice(0, 4)}-${code.slice(4)}`
1132}
1133
1134/** A link request that got no answer to act on. */
1135function linkFailure(rt: Runtime, reply: Extract<Reply, { ok: false }>): string {
1136  if (reply.status === undefined) return `server unreachable at ${rt.serverUrl} (${reply.error})`
1137  if (reply.code === 'invalid-code') return 'That code is wrong, expired or already used: run /fishing link on the other device for a new one.'
1138  if (reply.status === 429) return 'Too many link attempts from this network: wait a few minutes, then try again.'
1139  if (reply.status === 404) return `The server at ${rt.serverUrl} cannot link devices yet (it needs an update).`
1140  return `linking failed (${reply.error})`
1141}
1142
1143/** The label picked, or null when the question was dismissed or nobody could be asked (`claude -p`). */
1144async function choose($: EngineInterface, question: string, options: [yes: string, no: string]): Promise<string | null> {
1145  try {
1146    return await $.ui.ask(question, { header: 'Link device', options })
1147  } catch {
1148    return null
1149  }
1150}
1151
1152/** The warning before a machine with progress switches cats, and the two answers (switch first). */
1153function switchQuestion({ current, target, afterwards }: HasProgress): { question: string; options: [yes: string, no: string] } {
1154  const fate = {
1155    unlink: `${current.name} stays saved: /fishing unlink switches this machine back to it.`,
1156    shared: `${current.name} stays on the other devices that play it.`,
1157    lost: `No other device plays ${current.name}, so its progress could not be reached again.`,
1158  }[afterwards]
1159  const isSameName = current.name === target.name
1160  return {
1161    question: `This machine already has progress: ${catText(current)}. Linking switches it to ${catText(target)}. ${fate} Switch this machine to ${target.name}?`,
1162    options: isSameName ? ['Switch cats', 'Keep this one'] : [`Switch to ${target.name}`, `Keep ${current.name}`],
1163  }
1164}
1165
1166// The heartbeat answer (status line, /fishing status) follows the switch at once.
1167async function refresh($: EngineInterface, rt: Runtime): Promise<void> {
1168  if (rt.enabled) await runBeat($, rt)
1169}
1170
1171async function createLink($: EngineInterface, rt: Runtime): Promise<string> {
1172  const identity = await ensureIdentity($, rt)
1173  if (identity === null) return `no identity file: ${rt.identityError}`
1174  const reply = await postJson($, rt, '/api/link', identity.secret, {}, HTTP_TIMEOUT_MS)
1175  if (!reply.ok) return linkFailure(rt, reply)
1176  const res = reply.json as LinkResponse | null
1177  if (res?.ok === false && res.error === 'no-player') return 'This machine has no cat to share yet: /fishing open once, then /fishing link.'
1178  if (res?.ok !== true || typeof res.code !== 'string' || typeof res.player !== 'object') return 'unexpected answer from the server'
1179  const command = `/fishing link ${showCode(res.code)}`
1180  const copied = (await $.ui.copy({ text: command }).catch(() => null))?.isCopied === true
1181  const minutes = Math.max(1, Math.round((res.expiresAt - res.serverTime) / 60_000))
1182  return [
1183    `Link code ${showCode(res.code)}: works once, for the next ${minutes} min.`,
1184    `On your other device, run ${command}${copied ? ' (copied)' : ''}`,
1185    `It then plays ${catText(res.player)} too: one cat, the same progress on both.`,
1186  ].join('\n')
1187}
1188
1189async function claimLink($: EngineInterface, rt: Runtime, typed: string): Promise<string> {
1190  const code = typed.toUpperCase().replace(/[\s-]/g, '')
1191  if (!/^[0-9A-Z]{8}$/.test(code)) return `${typed} is not a link code: those are 8 letters and digits, like KQ74-MZ8P (/fishing link on the other device makes one).`
1192  const identity = await ensureIdentity($, rt)
1193  if (identity === null) return `no identity file: ${rt.identityError}`
1194  const sessionId = await $.session.id()
1195  const claim = (replace: boolean) =>
1196    postJson($, rt, '/api/link/claim', identity.secret, { code, sessionId, replace } satisfies LinkClaimRequest, HTTP_TIMEOUT_MS)
1197  let reply = await claim(false)
1198  const first = reply.ok ? (reply.json as LinkClaimResponse | null) : null
1199  if (first?.ok === false && first.error === 'has-progress') {
1200    const { question, options } = switchQuestion(first)
types/version.ts 3 lines
1// Checked against plugin.json and release.json by scripts/check-release.mjs.
2export const MOD_VERSION = '0.4.1'
3
types/protocol.ts 200 lines
1// Public HTTP contract between the Claude Code mod and the hosted game.
2// The game vendors this file from a pinned public commit; it has no game-rule dependencies.
3// Additive fields must remain compatible with older deployed servers.
4
5export type EffortLevel = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
6
7export type HeartbeatRequest = {
8  /** The Claude Code session id. */
9  sessionId: string
10  /** false = fishing is off (`fishing` says for the whole machine): drop this session now. */
11  enabled: boolean
12  /** true on session end: drop this session now. */
13  ending?: boolean
14  /** Model as the session reports it ("claude-opus-5-5", "opus", ...). */
15  model: string | null
16  /** Reasoning effort ('low'…'max'), a token budget, or null when unknown. */
17  effort: string | number | null
18  /** A turn is running right now. */
19  working: boolean
20  /** ms since the last sign of Claude working (sender's clock), null if never this session. */
21  activeAgoMs: number | null
22  /** Mod version: a session older than the server's minimum counts for nothing. */
23  modVersion?: string
24  /**
25   * The machine's /fishing on|off as this session read it. The server keeps each machine's newest
26   * (highest `rev`): while it is off no session of the machine counts, an older plugin's included,
27   * and the flip to off closes the game window the machine opened. Absent from plugins before 0.3.0.
28   */
29  fishing?: FishingSwitch
30  /**
31   * What Claude has done in this session, in numbers only, for game mechanics (a server may use it or
32   * not). Rides on the beats the session sends anyway. Absent from plugins before 0.4.0.
33   */
34  work?: WorkReport
35}
36
37/** Token counts by kind, as the API counts them. */
38export type WorkTokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
39
40/**
41 * What Claude has done in a session since `run` began, in numbers only: never a prompt, answer, file,
42 * path, command or tool input. The totals only grow while `run` stays the same, so a server takes the
43 * change since the last report of that run (a lost beat loses nothing, a repeated one adds nothing);
44 * a new `run` starts them over from zero (a /clear or /resume, a reload that lost them).
45 */
46export type WorkReport = {
47  /** A random id for these totals. */
48  run: string
49  /** Model requests answered, the main loop's and subagents' (turn.step). */
50  steps: number
51  /** What they used, summed. */
52  tokens: WorkTokens
53  /** The same by the model that answered ("claude-opus-5-5"), for the first 8 models; requests past those count only above. */
54  byModel: Record<string, WorkTokens & { steps: number }>
55  /** Main-loop turns ended (turn.complete): of them, how many were interrupted and how many ended on an error or a refusal; their wall-clock ms. */
56  turns: { count: number; aborted: number; failed: number; ms: number }
57  /** Subagent runs ended (turn.complete with an agent id). */
58  agentRuns: number
59  /** Tool calls by tool, subagents' included: Claude Code's own by name ("Bash", "Read"), any MCP server's as "mcp", anything else as "other". */
60  tools: Record<string, number>
61  /** How many different MCP servers those "mcp" calls went to; which ones never leaves the machine. */
62  mcpServers: number
63  /** The session's latest measure (session.measure); null before the first. */
64  measure: WorkMeasure | null
65}
66
67/** The session's figures as its status line has them, at the last measure. */
68export type WorkMeasure = {
69  /** The context window's fill, 0 to 100; null until a response reported one. */
70  contextPct: number | null
71  /** The context window's size, in tokens. */
72  contextWindow: number
73  /** The plan's rate-limit windows (`five_hour`, `seven_day`, a gateway's `spend_limit`): percent used, and when each resets (ISO 8601); empty off a subscription. */
74  rateLimits: { kind: string; percentUsed: number; resetsAt: string | null }[]
75  /** What the session has cost so far, in US dollars; null where the host keeps no ledger. */
76  costUsd: number | null
77}
78
79/**
80 * /fishing on|off for a whole machine, shared by every session and every copy of the plugin on it.
81 * `rev` counts the flips: of two, the higher is the newer.
82 */
83export type FishingSwitch = { on: boolean; rev: number }
84
85/**
86 * The plugin is older than the latest (`latest`). `required`: older than the server's minimum
87 * (`min`), so the game is locked until it is updated with `command`.
88 */
89export type ModUpdate = { required: boolean; latest: string; min: string | null; command: string }
90
91export type PresenceView = {
92  playable: boolean
93  sessionCount: number
94  /** Display label of the highest model, e.g. "Opus 5.5". */
95  model: string | null
96  effort: EffortLevel | null
97  working: boolean
98  buff: BuffView
99  /** A live session of the cat runs an older plugin than the latest. Absent from servers before 0.2.0. */
100  modUpdate?: ModUpdate | null
101}
102
103export type BuffView = {
104  active: boolean
105  /** Fraction, 0.08 = +8%. 0 while inactive. */
106  pct: number
107  potentialPct: number
108  /** Server-clock ms; null while a turn runs or while inactive. */
109  expiresAt: number | null
110}
111
112export type HeartbeatResponse = {
113  ok: true
114  serverTime: number
115  /** A game window for this machine's cat is connected right now (opened from any of its machines). */
116  clientConnected: boolean
117  /** The cat this machine plays; null until it has joined the game once. */
118  player: PlayerSummary | null
119  /** Machines that play this cat, this one included: more than 1 once devices are linked. */
120  machines: number
121  /** This machine plays a cat linked from another machine rather than its own. */
122  linked: boolean
123  /** From the sessions of every machine that plays this cat. */
124  presence: PresenceView
125  /** This session's plugin is behind (null: up to date). Absent from servers before 0.2.0. */
126  modUpdate?: ModUpdate | null
127  /**
128   * The machine's switch as the server keeps it. Newer than the plugin's (a higher `rev`, or the
129   * same flip settled the other way), it is the switch. Absent from servers before 0.3.0.
130   */
131  fishing?: FishingSwitch
132}
133
134/** What a cat has to show for itself, as the mod prints it. */
135export type PlayerSummary = { name: string; level: number; rank: string; money: number; totalCaught: number }
136
137export type PairRequest = {
138  sessionId: string
139  /**
140   * 'auto' (session start) is skipped while a window is connected, one was opened moments ago, or
141   * fishing is off on the machine; 'manual' always pairs, and turns an off machine on.
142   */
143  reason: 'auto' | 'manual'
144}
145
146export type PairResponse =
147  | { ok: true; code: string; expiresAt: number }
148  | { ok: false; skipped: 'client-connected' | 'recently-opened' | 'fishing-off' }
149
150// A machine plays its own cat (the player whose id is the machine's) until it
151// claims a link code made on a machine that plays another one; from then on
152// it plays that cat, and `/api/unlink` returns it to its own.
153
154/** A one-time code another machine claims to play this machine's cat, until `expiresAt` (server clock); a newer code replaces it. `no-player`: this machine has no cat yet. */
155export type LinkResponse = { ok: true; code: string; expiresAt: number; serverTime: number; player: PlayerSummary } | { ok: false; error: 'no-player' }
156
157export type LinkClaimRequest = {
158  code: string
159  /** The session asking: it counts for the new cat at once, the machine's other sessions with their next beat. */
160  sessionId: string
161  /** Switch even though this machine's cat has progress (the person chose to). */
162  replace: boolean
163}
164
165/** What becomes of the cat a machine stops playing: `unlink` brings it back to this machine, `shared` other machines still play it, `lost` no machine does. */
166export type LeftBehind = 'unlink' | 'shared' | 'lost'
167
168/**
169 * `has-progress`: this machine's cat has caught or bought something and
170 * `replace` was false; nothing changed and the code still works.
171 * `invalid-code` comes with HTTP 400, an over-budget claim with 429.
172 */
173export type LinkClaimResponse =
174  | {
175      ok: true
176      /** This machine already played that cat: nothing changed. */
177      already: boolean
178      player: PlayerSummary
179      /** The cat this machine played before; null if it had none (or `already`). */
180      previous: PlayerSummary | null
181      /** Machines that play the cat now, this one included. */
182      machines: number
183      /** This machine's game window was open and reopens as the new cat. */
184      windowSwitched: boolean
185    }
186  | { ok: false; error: 'has-progress'; current: PlayerSummary; target: PlayerSummary; afterwards: LeftBehind }
187  | { ok: false; error: 'invalid-code' }
188
189export type UnlinkRequest = {
190  sessionId: string
191  /** Unlink even though no other machine plays the linked cat (the person chose to). */
192  confirm: boolean
193}
194
195/** `last-device`: no other machine plays the linked cat, which has progress, and `confirm` was false. */
196export type UnlinkResponse =
197  | { ok: true; player: PlayerSummary | null; previous: PlayerSummary | null; windowSwitched: boolean }
198  | { ok: false; error: 'not-linked' }
199  | { ok: false; error: 'last-device'; current: PlayerSummary }
200
types/index.d.ts 60 lines
1/** Claude's activity in this session, as the mod saw it (kept in $.state so a hot reload keeps it). */
2export type FishingActivity = {
3  /** Clock ms of the last turn start, model request, tool call or return, or turn end; null before any. */
4  lastActiveAt: number | null
5  /** Model and effort of the last main-loop model request (or the model a /model switched to); null before the first. */
6  step: FishingStep | null
7}
8
9export type FishingStep = {
10  model: string
11  /** Absent on models without effort, and unknown after a /model until the next step: null. */
12  effort: string | number | null
13}
14
15/** Whether Claude is mid-turn (kept in $.state so a hot reload mid-turn keeps it). */
16export type FishingTurn = {
17  /** A main-loop turn is running (turn.start until its turn.complete). */
18  running: boolean
19  /** Subagents seen stepping whose turn.complete has not come yet. */
20  agents: string[]
21  /** Claude waits on the person: a permission prompt, a question, plan approval or an MCP form. */
22  waiting: boolean
23}
24
25/** pending: the auto-open could not reach the server and is tried again; done: the server answered it. */
26export type AutoOpenState = 'pending' | 'done'
27
28/** What Claude did in this session, as every heartbeat carries it (protocol.ts's WorkReport); kept in $.state so a hot reload goes on counting. */
29export type FishingWork = {
30  run: string
31  steps: number
32  tokens: FishingTokens
33  byModel: Record<string, FishingTokens & { steps: number }>
34  turns: { count: number; aborted: number; failed: number; ms: number }
35  agentRuns: number
36  tools: Record<string, number>
37  mcpServers: number
38  measure: {
39    contextPct: number | null
40    contextWindow: number
41    rateLimits: { kind: string; percentUsed: number; resetsAt: string | null }[]
42    costUsd: number | null
43  } | null
44}
45
46export type FishingTokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
47
48declare module 'claude-code' {
49  interface PluginState {
50    'claudefishing': {
51      activity: FishingActivity
52      turn: FishingTurn
53      autoOpen: AutoOpenState
54      work: FishingWork
55      /** The MCP servers the run's tool calls went to, by name: kept here to count `work.mcpServers`, never sent. */
56      mcpSeen: string[]
57    }
58  }
59}
60