SLOPSHOPPER

Genshin Mods

Six Claude Code mods: five under one band above the prompt, waypoints (the next steps, one press each), resin (cache, context, limits and cost, with warm…

newbandrowsguardcommandtoast
★ 23v?Apache-2.0updated 2026-10-08Esposter/Esposter/packages/genshin-mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · genshin-mods
› 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 › /commission ⎿ genshin-mods: commission is off. ● Veil on: values are hidden on screen ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● Veil on: values are hidden on screen
README

genshin-mods

[![Apache-2.0 licensed][badge-license]][url-license] [![NPM version][badge-npm-version]][url-npm] [![NPM downloads][badge-npm-downloads]][url-npm] [![NPM Unpacked Size (with version)][badge-npm-unpacked-size]][url-npm]

A Claude Code plugin of six mods: five drawn as one band above the prompt, in the session character's colour (the next steps one press away, what the session is spending with a one-press handoff, recording mode, a goal's task list and clock, and a question before editing a file another session just changed), and a delegation guard that nudges a run of lookups toward a haiku agent.

  • Waypoints — after each answered turn, up to three next steps the session suggests, each a button that sends it as the next prompt.
  • Resin — the prompt cache's time left, the context window, the five-hour and weekly limits and the cost so far, with Warm, Compact and Handoff, a toast before the cache goes cold, and a usage reserve past nine-tenths of a limit window that has the session wind down until the window resets.
  • Veil — recording mode: emails, amounts, phone numbers and secrets shown as placeholders while the model still reads the real values.
  • Commission — a goal meter over the session's task list: the goal, tasks done, the share complete and the minutes since it started.
  • Ward — before an edit to a file another session changed in the last half hour, a question: proceed, move to a worktree, or cancel.
  • Delegation guard — after every third lookup in a row with no decision between them, a note on that call's result that the chain goes to a haiku agent as one bounded question.

Table of Contents


<a name="getting-started">🚀 Getting Started</a>

This repository is a Claude Code plugin marketplace named esposter:

claude plugin marketplace add Esposter/Esposter
claude plugin install genshin-mods@esposter

A clone of this repository needs neither command: .agents/settings.json enables the plugin at project scope once you trust the repository. With genshin-persona installed beside it, the band takes the session character's colour; without it, the game's interface gold.

Developing the plugin from a checkout loads the directory itself, with the installed copy disabled so only one of them answers:

claude plugin disable genshin-mods@esposter
claude --plugin-dir packages/genshin-mods

<a name="documentation">📖 Documentation</a>

We highly recommend you take a look at the documentation to level up.

Command reference

Each mod is switched by its own slash command; bare, it flips, and the choice is kept across sessions:

CommandWhat it does
`/waypoints [on \off]`Suggests the next steps after each answered turn, or stops
`/resin [on \off]`Shows the cache, context, limits and cost row and its warning toast
`/veil [on \off]`Turns recording mode on or off; off by default
`/commission [on \off]`Shows the goal meter while a turn works through a task list
`/ward [on \off]`Asks before an edit to a file another session just changed

With the band focused (a click or ctrl+x tab), w, c and h warm, compact and hand off, 1 to 3 send a waypoint and g opens or closes the whole task list.

Commands

Run from packages/genshin-mods/:

pnpm test         # vitest watch mode (coverage is run from the repo root)
pnpm validate     # claude plugin validate: the engine's own reading of the hooks module
pnpm lint:fix     # auto-fix lint

<a name="license">⚖️ License</a>

This project is licensed under the Apache-2.0 license.

[badge-license]: https://img.shields.io/github/license/Esposter/Esposter.svg?color=blue [url-license]: https://github.com/Esposter/Esposter/blob/main/LICENSE [badge-npm-version]: https://img.shields.io/npm/v/genshin-mods/latest?color=brightgreen [url-npm]: https://www.npmjs.com/package/genshin-mods/v/latest [badge-npm-unpacked-size]: https://img.shields.io/npm/unpacked-size/genshin-mods/latest?label=npm [badge-npm-downloads]: https://img.shields.io/npm/dm/genshin-mods.svg

Source 33 files
src/register.ts 18 lines
1import type { Register } from "claude-code";
2
3import { registerBand } from "./services/band/registerBand";
4import { registerCommission } from "./services/commission/registerCommission";
5import { registerDelegation } from "./services/delegation/registerDelegation";
6import { registerLifecycle } from "./services/registerLifecycle";
7import { registerVeil } from "./services/veil/registerVeil";
8import { registerWard } from "./services/ward/registerWard";
9
10export const register: Register = (on) => {
11  registerBand(on);
12  registerCommission(on);
13  registerDelegation(on);
14  registerLifecycle(on);
15  registerVeil(on);
16  registerWard(on);
17};
18
src/services/band/registerBand.ts 216 lines
1import type { EngineInterface, On, RenderElement, RenderInput } from "claude-code";
2
3import { atom, read, update } from "claude-code";
4
5import type { EnabledMods } from "../../../types";
6
7import { ACCENT_COLOR, HANDOFF_QUESTION, MAX_SHOWN_TASKS, WARM_QUESTION } from "../constants";
8import { InitialState } from "../InitialState";
9import { getResinFigures } from "../resin/getResinFigures";
10import { getCommissionSummary } from "./getCommissionSummary";
11
12// The plugin's one band, a row per mod with something to say. `$` is followed only into functions in the file that
13// Hooked it, so each row and each button's action lives here beside the hook
14const commissionAtom = atom({ key: "commission", plugin: "genshin-mods" } as const, InitialState.commission);
15const enabledModsAtom = atom({ key: "enabledMods", plugin: "genshin-mods" } as const, InitialState.enabledMods);
16const isCommissionExpandedAtom = atom(
17  { key: "isCommissionExpanded", plugin: "genshin-mods" } as const,
18  InitialState.isCommissionExpanded,
19);
20const isHandingOffAtom = atom({ key: "isHandingOff", plugin: "genshin-mods" } as const, InitialState.isHandingOff);
21const lastCacheRequestAtAtom = atom(
22  { key: "lastCacheRequestAt", plugin: "genshin-mods" } as const,
23  InitialState.lastCacheRequestAt,
24);
25const nowAtom = atom({ key: "now", plugin: "genshin-mods" } as const, InitialState.now);
26const waypointsAtom = atom({ key: "waypoints", plugin: "genshin-mods" } as const, InitialState.waypoints);
27
28// The engine's press slot takes no promise; every action here resolves each of its outcomes itself
29const press = (action: () => Promise<unknown>) => () => {
30  // oxlint-disable-next-line typescript/no-floating-promises -- The press slot is the engine's and returns nothing
31  action();
32};
33
34const StatusMarkMap = { completed: "✓", in_progress: "▸", pending: "·" } as const;
35
36// A fork re-sends the conversation's prefix, which renews the cache from the moment it is sent, and adds no row to the
37// Transcript
38const warmCache = async ($: EngineInterface) => {
39  const requestedAt = await $.clock.now();
40  const answer = await $.model.fork({ prompt: WARM_QUESTION });
41  if (!answer.isAnswered) {
42    $.ui.toast(`The cache was not warmed: ${answer.reason}.`);
43    return;
44  }
45
46  await update($, lastCacheRequestAtAtom, () => requestedAt);
47  await update($, nowAtom, () => requestedAt);
48};
49
50// The whole relay in one press: the clear waits for the handoff text, so a fork that fails clears nothing
51const relay = async ($: EngineInterface) => {
52  const answer = await $.model.fork({ prompt: HANDOFF_QUESTION });
53  if (answer.isAnswered && answer.text.trim()) {
54    await $.command.run({ command: "clear" });
55    await $.prompt.submit({ text: answer.text });
56  } else $.ui.toast(`The handoff was not written: ${answer.isAnswered ? "it came back empty" : answer.reason}.`);
57};
58
59// The relay is settled rather than awaited, so a clear or submit that rejects still brings the buttons back
60const handOff = async ($: EngineInterface) => {
61  await update($, isHandingOffAtom, () => true);
62  const [outcome] = await Promise.allSettled([relay($)]);
63  await update($, isHandingOffAtom, () => false);
64  if (outcome.status === "rejected") $.ui.toast(`The handoff failed: ${String(outcome.reason)}.`);
65};
66
67// The session character's colour, which the persona publishes, else the game's interface gold
68const readAccent = async ($: EngineInterface) =>
69  (await read($, { key: "character", plugin: "genshin-persona" } as const))?.color || ACCENT_COLOR;
70
71const drawResinRow = async (
72  $: EngineInterface,
73  e: RenderInput<"AbovePrompt">,
74  enabledMods: EnabledMods,
75  accent: string,
76): Promise<RenderElement | undefined> => {
77  const lastCacheRequestAt = await read($, lastCacheRequestAtAtom);
78  if (lastCacheRequestAt === 0 || !enabledMods.resin) return undefined;
79
80  const figures = getResinFigures(await $.session.usage(), lastCacheRequestAt, await read($, nowAtom));
81  const isHandingOff = await read($, isHandingOffAtom);
82  const { Box, Button, Text } = $.ui.resolve(e);
83  return Box({
84    children: [
85      Text({ bold: true, children: "Resin  ", color: accent }),
86      ...figures.map(({ isWarning, label, text }) =>
87        Text({ children: `${label} ${text}  `, color: isWarning ? "yellow" : undefined, dimColor: !isWarning }),
88      ),
89      isHandingOff
90        ? Text({ children: "writing the handoff…", dimColor: true })
91        : Box({
92            children: [
93              Button({ hotkey: "w", key: "resin-warm", label: "Warm", onPress: press(() => warmCache($)) }),
94              Button({
95                hotkey: "c",
96                key: "resin-compact",
97                label: "Compact",
98                onPress: press(() => $.session.compact()),
99              }),
100              Button({
101                hotkey: "h",
102                key: "resin-handoff",
103                label: "Handoff",
104                onPress: press(() => handOff($)),
105                variant: "primary",
106              }),
107            ],
108            flexDirection: "row",
109          }),
110    ],
111    flexDirection: "row",
112    flexWrap: "wrap",
113  });
114};
115
116const drawCommissionRow = async (
117  $: EngineInterface,
118  e: RenderInput<"AbovePrompt">,
119  enabledMods: EnabledMods,
120  accent: string,
121): Promise<RenderElement | undefined> => {
122  const commission = await read($, commissionAtom);
123  if (commission.tasks.length === 0 || !enabledMods.commission) return undefined;
124
125  const isExpanded = await read($, isCommissionExpandedAtom);
126  const shownTasks = isExpanded
127    ? commission.tasks.slice(0, MAX_SHOWN_TASKS)
128    : commission.tasks.filter(({ status }) => status === "in_progress");
129  const { Box, Button, Text } = $.ui.resolve(e);
130  return Box({
131    children: [
132      Box({
133        children: [
134          Text({ bold: true, children: "Commission  ", color: accent }),
135          Text({ children: `${getCommissionSummary(commission, await read($, nowAtom))}  `, wrap: "truncate-end" }),
136          Button({
137            hotkey: "g",
138            key: "commission-expand",
139            label: isExpanded ? "Fewer" : "All tasks",
140            onPress: press(() => update($, isCommissionExpandedAtom, (value) => !value)),
141            plain: true,
142          }),
143        ],
144        flexDirection: "row",
145      }),
146      ...shownTasks.map(({ status, subject }) =>
147        Text({
148          children: `  ${StatusMarkMap[status]} ${subject}`,
149          dimColor: status !== "in_progress",
150          wrap: "truncate-end",
151        }),
152      ),
153    ],
154    flexDirection: "column",
155  });
156};
157
158const drawWaypointsRow = async (
159  $: EngineInterface,
160  e: RenderInput<"AbovePrompt">,
161  enabledMods: EnabledMods,
162  accent: string,
163): Promise<RenderElement | undefined> => {
164  const waypoints = await read($, waypointsAtom);
165  if (waypoints.length === 0 || e.props.isWorking || !enabledMods.waypoints) return undefined;
166
167  const { Box, Button, Text } = $.ui.resolve(e);
168  return Box({
169    children: [
170      Text({ bold: true, children: "Waypoints", color: accent }),
171      ...waypoints.map((waypoint, index) =>
172        Button({
173          hotkey: `${index + 1}`,
174          key: `waypoint-${index}`,
175          label: waypoint,
176          onPress: press(() => $.prompt.submit({ text: waypoint })),
177          plain: true,
178        }),
179      ),
180      Button({
181        key: "waypoints-dismiss",
182        label: "Dismiss",
183        onPress: press(() => update($, waypointsAtom, () => InitialState.waypoints)),
184        plain: true,
185        role: "dismiss",
186      }),
187    ],
188    flexDirection: "column",
189  });
190};
191
192// The veil's marker first, so a recording never runs with it off unnoticed, and the engine's own band whenever no
193// Mod has anything to say
194export const registerBand = (on: On): void => {
195  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
196    if (e.props.hasSurvey) return next(e);
197
198    const enabledMods = await read($, enabledModsAtom);
199    const accent = await readAccent($);
200    const rows = (
201      await Promise.all([
202        drawResinRow($, e, enabledMods, accent),
203        drawCommissionRow($, e, enabledMods, accent),
204        drawWaypointsRow($, e, enabledMods, accent),
205      ])
206    ).filter((row) => row !== undefined);
207    if (rows.length === 0 && !enabledMods.veil) return next(e);
208
209    const { Box, Text } = $.ui.resolve(e);
210    const marker = enabledMods.veil
211      ? [Text({ bold: true, children: "● Veil on: values are hidden on screen", color: "red" })]
212      : [];
213    return Box({ children: [...marker, ...rows], flexDirection: "column" });
214  });
215};
216
src/services/commission/registerCommission.ts 49 lines
1import type { EngineInterface, On } from "claude-code";
2
3import { atom, read, update } from "claude-code";
4
5import type { TaskCall } from "../../models/TaskCall";
6
7import { TaskTool } from "../../models/TaskTool";
8import { InitialState } from "../InitialState";
9import { foldTaskCall } from "./foldTaskCall";
10
11const commissionAtom = atom({ key: "commission", plugin: "genshin-mods" } as const, InitialState.commission);
12const enabledModsAtom = atom({ key: "enabledMods", plugin: "genshin-mods" } as const, InitialState.enabledMods);
13const lastPromptAtom = atom({ key: "lastPrompt", plugin: "genshin-mods" } as const, InitialState.lastPrompt);
14
15// A task tool's call folded in once the tool has answered, opening the commission on its first task with the goal
16// Read off the prompt that started the turn
17const fold = async ($: EngineInterface, call: TaskCall) => {
18  if (!(await read($, enabledModsAtom)).commission) return;
19  const now = await $.clock.now();
20  const lastPrompt = await read($, lastPromptAtom);
21  await update($, commissionAtom, (commission) => ({
22    goal: commission.goal || (lastPrompt.split("\n").find(Boolean) ?? "").trim(),
23    openedAt: commission.openedAt || now,
24    tasks: foldTaskCall(commission.tasks, call),
25  }));
26};
27
28export const registerCommission = (on: On): void => {
29  on("tool.call", { tool: "TaskCreate" }, async ($, e, next) => {
30    const result = await next(e);
31    if (!result.deny && !result.isError && result.result)
32      await fold($, { id: result.result.task.id, subject: e.subject, tool: TaskTool.TaskCreate });
33    return result;
34  });
35
36  on("tool.call", { tool: "TaskUpdate" }, async ($, e, next) => {
37    const result = await next(e);
38    if (!result.deny && !result.isError)
39      await fold($, { id: e.taskId, status: e.status, subject: e.subject, tool: TaskTool.TaskUpdate });
40    return result;
41  });
42
43  on("tool.call", { tool: "TodoWrite" }, async ($, e, next) => {
44    const result = await next(e);
45    if (!result.deny && !result.isError) await fold($, { todos: e.todos, tool: TaskTool.TodoWrite });
46    return result;
47  });
48};
49
src/services/delegation/registerDelegation.ts 43 lines
1import type { EngineInterface, On } from "claude-code";
2
3import { atom, update } from "claude-code";
4
5import type { DelegationCall } from "../../models/DelegationCall";
6
7import { DelegationStep } from "../../models/DelegationStep";
8import { InitialState } from "../InitialState";
9import { getCallStep } from "./getCallStep";
10import { getNextStreak } from "./getNextStreak";
11import { getNudge } from "./getNudge";
12
13const lookupStreakAtom = atom({ key: "lookupStreak", plugin: "genshin-mods" } as const, InitialState.lookupStreak);
14
15// The count is one update, so calls the model sends in one batch each see the count the call before them left. The
16// Nudge is the count's, and a neutral call earns none
17const countCall = async ($: EngineInterface, call: DelegationCall): Promise<string | undefined> => {
18  const step = getCallStep(call);
19  if (step === DelegationStep.Neutral) return undefined;
20  const streak = await update($, lookupStreakAtom, (value) => getNextStreak(value, step));
21  return step === DelegationStep.Lookup ? getNudge(streak) : undefined;
22};
23
24// Any failure lets the call through, as the ward's does: a count that blocked on its own failure would block every
25// Call after it. `next` is replay-safe here, so a call the hook had already counted is not run twice
26const letCallThrough = <E, R>(_$: EngineInterface, e: E, next: (e: E) => R): R => next(e);
27
28export const registerDelegation = (on: On): void => {
29  // A nudge rides on the call's own result as context the model reads after it, and never blocks the call
30  // eslint-disable-next-line no-restricted-syntax -- The engine's registration handler, run when the hook rejects, not a promise
31  on(
32    "tool.call",
33    { tool: ["Agent", "Bash", "Edit", "Glob", "Grep", "NotebookEdit", "PowerShell", "Read", "WebFetch", "Write"] },
34    async ($, e, next) => {
35      const nudge = await countCall($, e);
36      const result = await next(e);
37      return nudge !== undefined && result.deny === undefined
38        ? { ...result, context: [...(result.context ?? []), nudge] }
39        : result;
40    },
41  ).catch(letCallThrough);
42};
43
src/services/registerLifecycle.ts 192 lines
1import type { EngineInterface, On } from "claude-code";
2
3import { atom, read, update } from "claude-code";
4
5import type { EnabledMods } from "../../types";
6
7import {
8  CACHE_WARNING_MS,
9  CLOCK_TICK_MS,
10  RESERVE_SECTION_ID,
11  USAGE_RESERVE_PERCENTAGE,
12  VEIL_SECTION_ID,
13  VEIL_SYSTEM_SECTION,
14  WAYPOINTS_QUESTION,
15} from "./constants";
16import { InitialState } from "./InitialState";
17import { ModDescriptionMap, ModNames } from "./ModDescriptionMap";
18import { formatResetsAt } from "./resin/formatResetsAt";
19import { getCacheRemainingMs } from "./resin/getCacheRemainingMs";
20import { getReserveWindow } from "./resin/getReserveWindow";
21import { reserveText } from "./resin/reserveText";
22import { parseWaypoints } from "./waypoints/parseWaypoints";
23
24const commissionAtom = atom({ key: "commission", plugin: "genshin-mods" } as const, InitialState.commission);
25const enabledModsAtom = atom({ key: "enabledMods", plugin: "genshin-mods" } as const, InitialState.enabledMods);
26const isCommissionExpandedAtom = atom(
27  { key: "isCommissionExpanded", plugin: "genshin-mods" } as const,
28  InitialState.isCommissionExpanded,
29);
30const lastPromptAtom = atom({ key: "lastPrompt", plugin: "genshin-mods" } as const, InitialState.lastPrompt);
31const lastCacheRequestAtAtom = atom(
32  { key: "lastCacheRequestAt", plugin: "genshin-mods" } as const,
33  InitialState.lastCacheRequestAt,
34);
35const lookupStreakAtom = atom({ key: "lookupStreak", plugin: "genshin-mods" } as const, InitialState.lookupStreak);
36const nowAtom = atom({ key: "now", plugin: "genshin-mods" } as const, InitialState.now);
37const reserveWindowAtom = atom({ key: "reserveWindow", plugin: "genshin-mods" } as const, InitialState.reserveWindow);
38const waypointsAtom = atom({ key: "waypoints", plugin: "genshin-mods" } as const, InitialState.waypoints);
39
40// The events a plugin may hook once with no matcher, each hooked here for every mod, with the commands that switch
41// The mods. Neither variable is drawn: the expiry already warned about, so each warns once, and whether a turn is
42// Renewing the cache itself, when no warning is owed
43let warnedLastCacheRequestAt = 0;
44let isTurnRunning = false;
45
46const tick = async ($: EngineInterface) => {
47  const now = await $.clock.now();
48  await update($, nowAtom, () => now);
49  const lastCacheRequestAt = await read($, lastCacheRequestAtAtom);
50  const remainingMs = getCacheRemainingMs(lastCacheRequestAt, now);
51  const isWarningDue = lastCacheRequestAt > 0 && remainingMs > 0 && remainingMs <= CACHE_WARNING_MS;
52  if (
53    !isWarningDue ||
54    isTurnRunning ||
55    warnedLastCacheRequestAt === lastCacheRequestAt ||
56    !(await read($, enabledModsAtom)).resin
57  )
58    return;
59
60  warnedLastCacheRequestAt = lastCacheRequestAt;
61  $.ui.toast("The prompt cache goes cold in five minutes: warm it, compact or hand off from the band.");
62};
63
64const suggestWaypoints = async ($: EngineInterface) => {
65  const answer = await $.model.fork({ prompt: WAYPOINTS_QUESTION });
66  if (answer.isAnswered) await update($, waypointsAtom, () => parseWaypoints(answer.text));
67};
68
69const closeCommission = async ($: EngineInterface) => {
70  await update($, commissionAtom, () => InitialState.commission);
71  await update($, isCommissionExpandedAtom, () => InitialState.isCommissionExpanded);
72};
73
74export const registerLifecycle = (on: On): void => {
75  // The bare command flips a mod and an argument sets it, kept in the store so it holds for every later session
76  for (const name of ModNames)
77    on("command.run", { command: name }, async ($, e) => {
78      const argument = e.args.trim().toLowerCase();
79      const enabledMods = await update($, enabledModsAtom, (value) => ({
80        ...value,
81        [name]: argument ? argument === "on" : !value[name],
82      }));
83      await $.store.set(enabledModsAtom.ref.key, enabledMods);
84      return { text: `${name} is ${enabledMods[name] ? "on" : "off"}.` };
85    });
86
87  on("session.start", async ($, e, next) => {
88    const stored = (await $.store.get(enabledModsAtom.ref.key)) as Partial<EnabledMods> | undefined;
89    await update($, enabledModsAtom, (value) => ({ ...value, ...stored }));
90    await Promise.all(
91      ModNames.map((name) =>
92        $.command.register({ argumentHint: "[on|off]", description: ModDescriptionMap[name], name }),
93      ),
94    );
95    // oxlint-disable-next-line unicorn/no-array-method-this-argument -- The engine's clock, not Array.prototype.every
96    $.clock.every(CLOCK_TICK_MS, () => {
97      // oxlint-disable-next-line typescript/no-floating-promises -- The engine's timer slot takes no promise, and every call in the tick resolves
98      tick($);
99    });
100    return next(e);
101  });
102
103  // A `/clear` or a resume carries on in this process under another conversation, with no `session.start` for it, so
104  // Nothing the old one showed is kept
105  on("session.end", async ($, e, next) => {
106    if (e.reason === "clear" || e.reason === "resume") {
107      await update($, lastCacheRequestAtAtom, () => InitialState.lastCacheRequestAt);
108      await update($, waypointsAtom, () => InitialState.waypoints);
109      await closeCommission($);
110    }
111
112    return next(e);
113  });
114
115  // The prompt is kept as the goal of a commission this turn may open, and a commission whose every task is done has
116  // Shown its finished row since the last turn ended, so it closes as the person moves on. A new prompt also starts
117  // The lookup count again, since the lookups of the last turn are not the chain of this one
118  on("prompt.submit", async ($, e, next) => {
119    await update($, waypointsAtom, () => InitialState.waypoints);
120    await update($, lookupStreakAtom, () => InitialState.lookupStreak);
121    await update($, lastPromptAtom, () => e.text);
122    const { tasks } = await read($, commissionAtom);
123    if (tasks.length > 0 && tasks.every(({ status }) => status === "completed")) await closeCommission($);
124    return next(e);
125  });
126
127  // While the veil is on, the model is told to write placeholders too, so a reply never holds a value to hide. While a
128  // Usage reserve holds and the resin mod is on, the model is told to wind down until the window resets, which it may
129  // Have done since the last measurement
130  on("prompt.compose", async ($, e, next) => {
131    const result = await next(e);
132    const { resin, veil } = await read($, enabledModsAtom);
133    const reserveWindow = await read($, reserveWindowAtom);
134    const sections = [...result.sections];
135    if (veil) sections.push({ id: VEIL_SECTION_ID, scope: "session", text: VEIL_SYSTEM_SECTION });
136    if (resin && reserveWindow.name && Date.parse(reserveWindow.resetsAt) > (await $.clock.now()))
137      sections.push({ id: RESERVE_SECTION_ID, scope: "session", text: reserveText(reserveWindow) });
138    return { sections };
139  });
140
141  on("turn.start", (_$, e, next) => {
142    isTurnRunning = true;
143    return next(e);
144  });
145
146  // The cache's lifetime runs from the request that read or wrote it, so each of the main conversation's requests
147  // Restarts the countdown as it is sent, not as its reply ends
148  on("turn.step", async function* ($, e, next) {
149    if (e.agentId === undefined) {
150      const now = await $.clock.now();
151      await update($, lastCacheRequestAtAtom, () => now);
152      await update($, nowAtom, () => now);
153    }
154
155    return yield* next(e);
156  });
157
158  // An answer of the main conversation asks for waypoints off the turn's own dispatch, so the fork never holds the
159  // Turn's end or the next prompt behind it
160  on("turn.complete", async ($, e, next) => {
161    const result = await next(e);
162    if (e.agentId !== undefined) return result;
163
164    isTurnRunning = false;
165
166    const now = await $.clock.now();
167    await update($, nowAtom, () => now);
168    if (e.reason === "answer" && (await read($, enabledModsAtom)).waypoints)
169      $.clock.after(0, () => {
170        // oxlint-disable-next-line typescript/no-floating-promises -- The engine's timer slot takes no promise, and a fork resolves every outcome rather than rejecting
171        suggestWaypoints($);
172      });
173    return result;
174  });
175
176  // The reserve is read off each measurement: it starts at the first window past the line and lifts at the measurement
177  // A window's reset raises, which finds none. The toast names the window once, as the reserve starts
178  on("session.measure", async ($, e, next) => {
179    const reserveWindow = getReserveWindow(e.rateLimits);
180    const previousReserveWindow = await read($, reserveWindowAtom);
181    await update($, reserveWindowAtom, () => reserveWindow ?? InitialState.reserveWindow);
182    if (reserveWindow && !previousReserveWindow.name && (await read($, enabledModsAtom)).resin) {
183      const { name, resetsAt } = reserveWindow;
184      $.ui.toast(
185        `Usage reserve: the ${name} usage window has passed ${USAGE_RESERVE_PERCENTAGE}% and resets at ${formatResetsAt(resetsAt)}.`,
186      );
187    }
188
189    return next(e);
190  });
191};
192
src/services/veil/registerVeil.ts 33 lines
1import type { On } from "claude-code";
2
3import { atom, read } from "claude-code";
4
5import { InitialState } from "../InitialState";
6import { veilText } from "./veilText";
7import { veilValue } from "./veilValue";
8
9const enabledModsAtom = atom({ key: "enabledMods", plugin: "genshin-mods" } as const, InitialState.enabledMods);
10
11// Only the drawing changes: the transcript and what the model reads keep the real values. Each read subscribes the
12// Row, so switching the veil redraws every row already on screen. The instruction the veil adds to the system prompt is
13// An unmatched hook, so it is the lifecycle file's
14export const registerVeil = (on: On): void => {
15  on("ui.render", { component: "AssistantMessage" }, async ($, e, next) =>
16    (await read($, enabledModsAtom)).veil
17      ? next({ ...e, props: { ...e.props, text: veilText(e.props.text) } })
18      : next(e),
19  );
20
21  on("ui.render", { component: "UserMessage" }, async ($, e, next) =>
22    (await read($, enabledModsAtom)).veil
23      ? next({ ...e, props: { ...e.props, text: veilText(e.props.text) } })
24      : next(e),
25  );
26
27  on("ui.render", { component: "ToolResult" }, async ($, e, next) =>
28    (await read($, enabledModsAtom)).veil
29      ? next({ ...e, props: { ...e.props, output: veilValue(e.props.output) } })
30      : next(e),
31  );
32};
33
src/services/ward/registerWard.ts 83 lines
1import type { EngineInterface, On, ToolCallResult } from "claude-code";
2
3import { atom, read } from "claude-code";
4
5import type { WardRecord } from "../../../types";
6
7import { WardAnswer, WardAnswers } from "../../models/WardAnswer";
8import { InitialState } from "../InitialState";
9import { getCollidingRecord } from "./getCollidingRecord";
10import { getRecordsWithEdit } from "./getRecordsWithEdit";
11
12const enabledModsAtom = atom({ key: "enabledMods", plugin: "genshin-mods" } as const, InitialState.enabledMods);
13const AGE_FORMATTER = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
14
15// One file every session on the machine reads afresh, beside the persona's state, so an edit made a moment ago in
16// Another session is always seen
17const readRecordsPath = async ($: EngineInterface) => {
18  const home = (await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE")) ?? "";
19  return `${home}/.claude/genshin-mods/ward.json`;
20};
21
22const readRecords = async ($: EngineInterface, path: string): Promise<Record<string, WardRecord>> =>
23  (await $.fs.exists(path))
24    ? // oxlint-disable-next-line no-restricted-properties -- The records hold numbers and ids, no date, and a mod cannot import the shared reviver
25      (JSON.parse(await $.fs.read(path)) as Record<string, WardRecord>)
26    : {};
27
28// Before the edit: the question, when another session's record calls for one. After it: the edit recorded
29const ward = async (
30  $: EngineInterface,
31  path: string,
32  proceed: () => Promise<ToolCallResult>,
33): Promise<ToolCallResult> => {
34  if (!(await read($, enabledModsAtom)).ward) return proceed();
35  const recordsPath = await readRecordsPath($);
36  const sessionId = await $.session.id();
37  const now = await $.clock.now();
38  const collision = getCollidingRecord((await readRecords($, recordsPath))[path], sessionId, now);
39  // A session nobody watches has nobody to ask, and a guard that blocks unattended work is worse than none
40  if (collision && (await $.session.surfaces()).length > 0) {
41    const minutes = -Math.round(Temporal.Duration.from({ milliseconds: now - collision.editedAt }).total("minutes"));
42    const age = AGE_FORMATTER.format(minutes, "minute");
43    const answer = await $.ui.ask(`Another session edited ${path} ${age}. Edit it here too?`, {
44      header: "Ward",
45      options: WardAnswers,
46    });
47    if (answer === WardAnswer.Worktree)
48      return {
49        deny: `The person asked to move this work into a git worktree before editing ${path}: another session changed it ${age}. Enter a worktree, then carry on there.`,
50      };
51    else if (answer === WardAnswer.Cancel)
52      return { deny: `The person stopped this edit: another session changed ${path} ${age}.` };
53    else if (answer !== WardAnswer.Proceed) return { deny: `The person stopped this edit and said: ${answer}` };
54  }
55
56  const result = await proceed();
57  if (!result.deny && !result.isError) {
58    const records = getRecordsWithEdit(await readRecords($, recordsPath), path, {
59      editedAt: await $.clock.now(),
60      sessionId,
61    });
62    await $.fs.write(recordsPath, JSON.stringify(records));
63  }
64
65  return result;
66};
67
68// Any failure lets the edit through, the question dismissed or the record unreadable alike: a guard that blocked on
69// Its own failure would block every edit after it, and Cancel is the person's way to stop one. `next` is replay-safe
70// Here, so an edit the hook had already made is not made twice
71const letEditThrough = <E, R>(_$: EngineInterface, e: E, next: (e: E) => R): R => next(e);
72
73export const registerWard = (on: On): void => {
74  // eslint-disable-next-line no-restricted-syntax -- The engine's registration handler, run when the hook rejects, not a promise
75  on("tool.call", { tool: "Edit" }, ($, e, next) => ward($, e.file_path, () => next(e))).catch(letEditThrough);
76  // eslint-disable-next-line no-restricted-syntax -- The engine's registration handler, run when the hook rejects, not a promise
77  on("tool.call", { tool: "Write" }, ($, e, next) => ward($, e.file_path, () => next(e))).catch(letEditThrough);
78  // eslint-disable-next-line no-restricted-syntax -- The engine's registration handler, run when the hook rejects, not a promise
79  on("tool.call", { tool: "NotebookEdit" }, ($, e, next) => ward($, e.notebook_path, () => next(e))).catch(
80    letEditThrough,
81  );
82};
83
src/services/constants.ts 46 lines
1// The game's own interface gold, the accent wherever the persona publishes no character
2export const ACCENT_COLOR = "#d3bc8e";
3// The prompt cache's lifetime, which the engine chooses and does not report: an hour on a subscription
4export const CACHE_LIFETIME_MS: number = Temporal.Duration.from({ hours: 1 }).total("milliseconds");
5export const CACHE_LOW_MS: number = Temporal.Duration.from({ minutes: 10 }).total("milliseconds");
6export const CACHE_WARNING_MS: number = Temporal.Duration.from({ minutes: 5 }).total("milliseconds");
7export const CLOCK_TICK_MS: number = Temporal.Duration.from({ minutes: 1 }).total("milliseconds");
8export const USAGE_WARNING_PERCENTAGE = 80;
9export const USAGE_RESERVE_PERCENTAGE = 90;
10export const MAX_WAYPOINTS = 3;
11export const MAX_SHOWN_TASKS = 8;
12// Whole minutes as the band writes them, `12m`
13export const MINUTE_FORMATTER: Intl.NumberFormat = new Intl.NumberFormat("en", {
14  style: "unit",
15  unit: "minute",
16  unitDisplay: "narrow",
17});
18export const WARD_WINDOW_MS: number = Temporal.Duration.from({ minutes: 30 }).total("milliseconds");
19export const NO_WAYPOINTS_ANSWER = "NONE";
20// oxlint-disable-next-line typescript/no-inferrable-types -- `isolatedDeclarations` demands the annotation this template literal would otherwise infer
21export const WAYPOINTS_QUESTION: string = `Leave the conversation as it is and answer one side question. List the next steps worth taking from here, at most ${MAX_WAYPOINTS}, most useful first: each one short imperative line the person could send you as their next prompt, with no numbering, no markup and nothing else. When the work is finished or waits on the person, answer ${NO_WAYPOINTS_ANSWER}.`;
22export const WARM_QUESTION = "Answer with the one word OK.";
23export const HANDOFF_QUESTION =
24  "Write a handoff of this session for a fresh conversation that will carry on the work with no other context. Write it as the prompt that conversation starts with: the goal, what is done, what is next in order, the files, commands and decisions that matter with their reasons, and any open question. Be complete but compact, plain markdown, nothing before or after it.";
25export const VEIL_SECTION_ID = "genshin-mods-veil";
26export const VEIL_SYSTEM_SECTION =
27  "Recording mode is on: the screen is being recorded or shared. Never write an email address, a phone number, a money amount, an API key, a token or a password in a reply; write a placeholder such as [email], [phone], [amount] or [secret] instead, even when asked for the value. Tool calls still use the real values.";
28export const RESERVE_SECTION_ID = "genshin-mods-reserve";
29// The reserve's section ends on this instruction, the same for every window so only the window and its reset time vary
30export const RESERVE_INSTRUCTION =
31  "Until it resets, start no new agent or workflow that writes code or designs; have each running one commit what builds and end on a handoff spec; commit and push the work in hand; write every open item down with what it takes to resume it cold (its paths, what is done, the calls made, the next step); clean up idle servers, shells and monitors; keep only long-running compute going.";
32// The tools that look something up, the tools that change files, and the shells whose command decides which it is
33export const LOOKUP_TOOLS: readonly string[] = ["Glob", "Grep", "Read", "WebFetch"];
34export const RESET_TOOLS: readonly string[] = ["Agent", "Edit", "NotebookEdit", "Write"];
35export const SHELL_TOOLS: readonly string[] = ["Bash", "PowerShell"];
36// The `export` and `cd` a session chains ahead of its command, each stripped as one leading segment
37export const LEADING_SEGMENT_REGEX = /^\s*(?:export\s[^&;]*&&|cd\s[^&;]*&&|cd\s[^&;]*;)\s*/u;
38// The commands that only read, a whole first word each so `catalog` is not `cat`. No interpreter is one, since what
39// It runs may write as readily as read; PowerShell's cmdlets are matched in any case, as PowerShell matches them
40export const LOOKUP_COMMAND_REGEX =
41  /^\s*(?:cat|sed|grep|rg|find|ls|head|tail|awk|wc|get-content|get-childitem|select-string|git\s+(?:show|log|diff)|gh\s+run\s+view)(?:\s|$)/iu;
42// Every third lookup in a row, the chain a haiku agent should answer as one bounded question
43export const DELEGATION_NUDGE_EVERY = 3;
44export const DELEGATION_NUDGE =
45  "Third lookup in a row with no decision between them: by the llm-delegation skill's hop rule this chain goes to a haiku agent as one bounded question.";
46
src/services/InitialState.ts 17 lines
1import type { PluginState } from "claude-code";
2
3// Every state value's initial, read by each file's atoms: the engine traces a state reference only to an atom made in
4// The file that uses it, so each file makes its own and this is the one place their initials are written
5export const InitialState: PluginState["genshin-mods"] = {
6  commission: { goal: "", openedAt: 0, tasks: [] },
7  enabledMods: { commission: true, resin: true, veil: false, ward: true, waypoints: true },
8  isCommissionExpanded: false,
9  isHandingOff: false,
10  lastCacheRequestAt: 0,
11  lastPrompt: "",
12  lookupStreak: 0,
13  now: 0,
14  reserveWindow: { name: "", resetsAt: "" },
15  waypoints: [],
16};
17
src/services/resin/getResinFigures.ts 45 lines
1import type { SessionUsage } from "claude-code";
2
3import type { ResinFigure } from "../../models/ResinFigure";
4
5import { CACHE_LOW_MS, MINUTE_FORMATTER, USAGE_WARNING_PERCENTAGE } from "../constants";
6import { getCacheRemainingMs } from "./getCacheRemainingMs";
7
8const TOKEN_FORMATTER = new Intl.NumberFormat("en", { maximumFractionDigits: 1, notation: "compact" });
9const USD_CURRENCY_FORMATTER = new Intl.NumberFormat("en", { currency: "USD", style: "currency" });
10const RateLimitLabelMap: Record<string, string> = { five_hour: "5h", seven_day: "7d" };
11
12// The row's figures in the order they are read: the cache only once a request has started its clock, and each other
13// Figure only once the engine has a reading, since a zero it never measured would read as a fact
14export const getResinFigures = (
15  { context, cost, rateLimits }: Pick<SessionUsage, "context" | "cost" | "rateLimits">,
16  lastCacheRequestAt: number,
17  now: number,
18): ResinFigure[] => {
19  const figures: ResinFigure[] = [];
20  if (lastCacheRequestAt > 0) {
21    const remainingMs = getCacheRemainingMs(lastCacheRequestAt, now);
22    const remainingMinutes = Math.ceil(Temporal.Duration.from({ milliseconds: remainingMs }).total("minutes"));
23    const remaining = remainingMs > 0 ? MINUTE_FORMATTER.format(remainingMinutes) : "cold";
24    const resend = context.tokens === undefined ? "" : ` · re-sends ${TOKEN_FORMATTER.format(context.tokens)}`;
25    figures.push({ isWarning: remainingMs < CACHE_LOW_MS, label: "cache", text: `${remaining}${resend}` });
26  }
27
28  if (context.tokens !== undefined)
29    figures.push({
30      isWarning: (context.percent ?? 0) >= USAGE_WARNING_PERCENTAGE,
31      label: "context",
32      text: `${TOKEN_FORMATTER.format(context.tokens)}/${TOKEN_FORMATTER.format(context.window)}`,
33    });
34
35  for (const { kind, percentUsed } of rateLimits)
36    figures.push({
37      isWarning: percentUsed >= USAGE_WARNING_PERCENTAGE,
38      label: RateLimitLabelMap[kind] ?? kind,
39      text: `${Math.round(percentUsed)}%`,
40    });
41
42  if (cost) figures.push({ isWarning: false, label: "cost", text: USD_CURRENCY_FORMATTER.format(cost.usd) });
43  return figures;
44};
45
src/services/band/getCommissionSummary.ts 22 lines
1import type { Commission } from "../../../types";
2
3import { MINUTE_FORMATTER } from "../constants";
4
5const PERCENT_FORMATTER = new Intl.NumberFormat("en", { style: "percent" });
6
7// `goal · 3 of 4 · 75% · 12m`, the clock counting whole minutes from the commission's opening
8export const getCommissionSummary = ({ goal, openedAt, tasks }: Commission, now: number): string => {
9  const done = tasks.filter(({ status }) => status === "completed").length;
10  const elapsedMinutes = Math.floor(
11    Temporal.Duration.from({ milliseconds: Math.max(0, now - openedAt) }).total("minutes"),
12  );
13  return [
14    goal,
15    `${done} of ${tasks.length}`,
16    PERCENT_FORMATTER.format(done / tasks.length),
17    MINUTE_FORMATTER.format(elapsedMinutes),
18  ]
19    .filter(Boolean)
20    .join(" · ");
21};
22
src/models/TaskCall.ts 15 lines
1import type { CommissionTask } from "../../types";
2import type { TaskTool } from "./TaskTool";
3
4// One task tool call with what its result says, in the shapes the engine's own tools declare
5export type TaskCall =
6  | {
7      id: string;
8      // oxlint-disable-next-line literal-union/no-string-literal-union -- The engine's own TaskUpdate statuses
9      status?: "deleted" | CommissionTask["status"];
10      subject?: string;
11      tool: TaskTool.TaskUpdate;
12    }
13  | { id: string; subject: string; tool: TaskTool.TaskCreate }
14  | { todos: { content: string; status: CommissionTask["status"] }[]; tool: TaskTool.TodoWrite };
15