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

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:
/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.
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
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.
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" } }
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.
/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.
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 game | the server answers and a game window is connected |
🎣 opening game | this session just opened the game and its window has not joined yet (at most 45 s) |
🎣 game closed | the server answers, no game window (/fishing open) |
🎣 offline | the 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.
Every request goes to https://claudefishing.io (in development, to a server on the same machine) with Authorization: Bearer <secret>.
~/.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 | |
|---|---|
sessionId | the Claude Code session id |
enabled | false once, when fishing goes off |
ending | true once, when the session ends; after /clear or /resume, once the next conversation has sent its first heartbeat |
model | the model id the last main-loop request used (claude-opus-5-5), or the one /model switched to, else the session's |
effort | the 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 |
working | a turn or a subagent is running right now and Claude is not waiting on you |
activeAgoMs | milliseconds 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 |
modVersion | this plugin's version (a server may refuse versions older than its minimum) |
fishing | the 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) |
work | what 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):
claude-opus-5-5), for up to 8 models;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);~/.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.
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.
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:
node scripts/prepare-release.mjs MAJOR.MINOR.PATCH to update version metadata and the contract hash.The marketplace uses the version in plugin.json; it does not duplicate it. The game can deploy independently while older plugins remain compatible.
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.
hooks/register.ts 1463 lines1// 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 lines1// Checked against plugin.json and release.json by scripts/check-release.mjs.
2export const MOD_VERSION = '0.4.1'
3types/protocol.ts 200 lines1// 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 }
200types/index.d.ts 60 lines1/** 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