genshin-mods 4.0.0 → 4.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +14 -9
- package/package.json +3 -3
- package/src/models/AgentLaunch.ts +6 -0
- package/src/models/DelegationCall.ts +8 -0
- package/src/models/DelegationStep.ts +7 -0
- package/src/models/UsageWindow.ts +10 -0
- package/src/register.ts +8 -0
- package/src/services/InitialState.ts +3 -1
- package/src/services/ModDescriptionMap.ts +2 -1
- package/src/services/constants.ts +46 -1
- package/src/services/delegation/checkIsLookupCommand.ts +11 -0
- package/src/services/delegation/getCallStep.ts +16 -0
- package/src/services/delegation/getLaunchRefusal.ts +8 -0
- package/src/services/delegation/getNextStreak.ts +8 -0
- package/src/services/delegation/getNudge.ts +5 -0
- package/src/services/delegation/registerDelegation.ts +46 -0
- package/src/services/diskScan/getDiskScanRefusal.ts +85 -0
- package/src/services/diskScan/registerDiskScan.ts +17 -0
- package/src/services/registerLifecycle.ts +84 -12
- package/src/services/resin/RateLimitKindUsageWindowMap.ts +27 -0
- package/src/services/resin/formatResetsAt.ts +3 -0
- package/src/services/resin/getCacheWarningDelayMs.ts +7 -0
- package/src/services/resin/getReserveSummary.ts +8 -0
- package/src/services/resin/getReserveWindow.ts +32 -0
- package/src/services/resin/registerReserveGate.ts +27 -0
- package/src/services/resin/reserveText.ts +9 -0
- package/src/services/shell/getShellRefusal.ts +85 -0
- package/src/services/shell/joinDirectory.ts +17 -0
- package/src/services/ward/getCheckoutPeer.ts +21 -0
- package/src/services/ward/getParentFolder.ts +5 -0
- package/src/services/ward/getRecordsPath.ts +3 -0
- package/src/services/ward/getWholeIndexRefusal.ts +127 -0
- package/src/services/ward/parseRecords.ts +5 -0
- package/src/services/ward/registerWard.ts +8 -11
- package/src/services/ward/registerWholeIndex.ts +59 -0
- package/types/index.d.ts +14 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "genshin-mods",
|
|
4
4
|
"displayName": "Genshin Mods",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "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, compact and a one-click handoff), veil (recording mode), commission (a goal's task list and clock) and ward (a question before editing a file another session just changed), and a delegation guard that nudges a run of lookups toward a haiku agent.",
|
|
6
6
|
"author": { "name": "Esposter", "url": "https://github.com/Esposter" },
|
|
7
7
|
"homepage": "https://esposter.com/docs/infra/claude-interface/genshin-mods",
|
|
8
8
|
"repository": "https://github.com/Esposter/Esposter",
|
package/README.md
CHANGED
|
@@ -5,13 +5,17 @@
|
|
|
5
5
|
[![NPM downloads][badge-npm-downloads]][url-npm]
|
|
6
6
|
[![NPM Unpacked Size (with version)][badge-npm-unpacked-size]][url-npm]
|
|
7
7
|
|
|
8
|
-
A Claude Code plugin of five
|
|
8
|
+
A Claude Code plugin of 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), a delegation guard that refuses any subagent not on haiku and nudges a run of lookups toward a haiku agent, a disk-scan guard that refuses a Bash scan from a root or a home folder, and a whole-index guard that refuses a git command over the whole index while another session shares the checkout.
|
|
9
9
|
|
|
10
10
|
- **Waypoints** — after each answered turn, up to three next steps the session suggests, each a button that sends it as the next prompt.
|
|
11
|
-
- **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**,
|
|
11
|
+
- **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 four-fifths of a limit window, or past half of one spent ahead of an even pace, that has the session clean up on cheap agents, then wind down, until the window resets, refusing a workflow at launch meanwhile.
|
|
12
12
|
- **Veil** — recording mode: emails, amounts, phone numbers and secrets shown as placeholders while the model still reads the real values.
|
|
13
13
|
- **Commission** — a goal meter over the session's task list: the goal, tasks done, the share complete and the minutes since it started.
|
|
14
14
|
- **Ward** — before an edit to a file another session changed in the last half hour, a question: proceed, move to a worktree, or cancel.
|
|
15
|
+
- **Delegation guard** — every subagent launch not on haiku refused, a fork included, and 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.
|
|
16
|
+
- **Disk-scan guard** — a Bash `find`, `du`, `grep -r` or `ls -R` that starts at `/`, a drive, `~` or a home folder is refused, with the known place to look for the file.
|
|
17
|
+
- **Pulse** — keeps the machine watcher running from any session with the plugin, so its heartbeat and the machine's state keep being written.
|
|
18
|
+
- **Whole-index guard** — while another session edits this checkout, a Bash `git add .`, `git add -A`, `git reset` with no path, `git stash`, `git checkout .` or `git clean` is refused, with the path-scoped form to use instead.
|
|
15
19
|
|
|
16
20
|
## Table of Contents
|
|
17
21
|
|
|
@@ -47,13 +51,14 @@ We highly recommend you take a look at the [documentation](https://esposter.com/
|
|
|
47
51
|
|
|
48
52
|
Each mod is switched by its own slash command; bare, it flips, and the choice is kept across sessions:
|
|
49
53
|
|
|
50
|
-
| Command | What it does
|
|
51
|
-
| :------------------------ |
|
|
52
|
-
| `/waypoints [on \| off]` | Suggests the next steps after each answered turn, or stops
|
|
53
|
-
| `/resin [on \| off]` | Shows the cache, context, limits and cost row and its warning toast
|
|
54
|
-
| `/veil [on \| off]` | Turns recording mode on or off; off by default
|
|
55
|
-
| `/commission [on \| off]` | Shows the goal meter while a turn works through a task list
|
|
56
|
-
| `/
|
|
54
|
+
| Command | What it does |
|
|
55
|
+
| :------------------------ | :------------------------------------------------------------------------------------------------------------------------------ |
|
|
56
|
+
| `/waypoints [on \| off]` | Suggests the next steps after each answered turn, or stops |
|
|
57
|
+
| `/resin [on \| off]` | Shows the cache, context, limits and cost row and its warning toast |
|
|
58
|
+
| `/veil [on \| off]` | Turns recording mode on or off; off by default |
|
|
59
|
+
| `/commission [on \| off]` | Shows the goal meter while a turn works through a task list |
|
|
60
|
+
| `/pulse [on \| off]` | Keeps the machine watcher running, or stops |
|
|
61
|
+
| `/ward [on \| off]` | Asks before an edit to a file another session just changed, and refuses a whole-index git command while one shares the checkout |
|
|
57
62
|
|
|
58
63
|
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.
|
|
59
64
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "genshin-mods",
|
|
3
|
-
"version": "4.
|
|
4
|
-
"description": "A Claude Code plugin of five
|
|
3
|
+
"version": "4.1.1",
|
|
4
|
+
"description": "A Claude Code plugin of mods: five under one band above the prompt (the next steps, the cache and usage with a one-click handoff, recording mode, a goal's task list and a question before editing a file another session just changed) a delegation guard that nudges a run of lookups toward a haiku agent, a disk-scan guard that refuses a Bash scan from a root or a home folder, and a whole-index guard that refuses a git command over the whole index while another session shares the checkout.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
7
7
|
"claude-code-plugin",
|
|
@@ -38,5 +38,5 @@
|
|
|
38
38
|
"test": "vitest",
|
|
39
39
|
"validate": "claude plugin validate ."
|
|
40
40
|
},
|
|
41
|
-
"gitHead": "
|
|
41
|
+
"gitHead": "47b7795ab557d7731aa60588d0b9d1cdff243328"
|
|
42
42
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The part of a tool call the lookup count reads: the tool, a shell command with its background flag, and the agent
|
|
2
|
+
// A subagent's call names
|
|
3
|
+
export interface DelegationCall {
|
|
4
|
+
agentId?: string;
|
|
5
|
+
command?: string;
|
|
6
|
+
run_in_background?: boolean;
|
|
7
|
+
tool: string;
|
|
8
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface UsageWindow {
|
|
2
|
+
// The window's length, which its reset time is counted back from
|
|
3
|
+
durationMs: number;
|
|
4
|
+
// The usage line the window's maintenance tier starts at
|
|
5
|
+
maintenancePercentage: number;
|
|
6
|
+
// The window's name as the reserve's note spells it
|
|
7
|
+
name: string;
|
|
8
|
+
// The usage line the window's wind-down starts at
|
|
9
|
+
windDownPercentage: number;
|
|
10
|
+
}
|
package/src/register.ts
CHANGED
|
@@ -2,14 +2,22 @@ import type { Register } from "claude-code";
|
|
|
2
2
|
|
|
3
3
|
import { registerBand } from "./services/band/registerBand";
|
|
4
4
|
import { registerCommission } from "./services/commission/registerCommission";
|
|
5
|
+
import { registerDelegation } from "./services/delegation/registerDelegation";
|
|
6
|
+
import { registerDiskScan } from "./services/diskScan/registerDiskScan";
|
|
5
7
|
import { registerLifecycle } from "./services/registerLifecycle";
|
|
8
|
+
import { registerReserveGate } from "./services/resin/registerReserveGate";
|
|
6
9
|
import { registerVeil } from "./services/veil/registerVeil";
|
|
7
10
|
import { registerWard } from "./services/ward/registerWard";
|
|
11
|
+
import { registerWholeIndex } from "./services/ward/registerWholeIndex";
|
|
8
12
|
|
|
9
13
|
export const register: Register = (on) => {
|
|
10
14
|
registerBand(on);
|
|
11
15
|
registerCommission(on);
|
|
16
|
+
registerDelegation(on);
|
|
17
|
+
registerDiskScan(on);
|
|
12
18
|
registerLifecycle(on);
|
|
19
|
+
registerReserveGate(on);
|
|
13
20
|
registerVeil(on);
|
|
14
21
|
registerWard(on);
|
|
22
|
+
registerWholeIndex(on);
|
|
15
23
|
};
|
|
@@ -4,11 +4,13 @@ import type { PluginState } from "claude-code";
|
|
|
4
4
|
// The file that uses it, so each file makes its own and this is the one place their initials are written
|
|
5
5
|
export const InitialState: PluginState["genshin-mods"] = {
|
|
6
6
|
commission: { goal: "", openedAt: 0, tasks: [] },
|
|
7
|
-
enabledMods: { commission: true, resin: true, veil: false, ward: true, waypoints: true },
|
|
7
|
+
enabledMods: { commission: true, pulse: true, resin: true, veil: false, ward: true, waypoints: true },
|
|
8
8
|
isCommissionExpanded: false,
|
|
9
9
|
isHandingOff: false,
|
|
10
10
|
lastCacheRequestAt: 0,
|
|
11
11
|
lastPrompt: "",
|
|
12
|
+
lookupStreak: 0,
|
|
12
13
|
now: 0,
|
|
14
|
+
reserveWindow: { isAheadOfPace: false, isWindDown: false, name: "", percentage: 0, resetsAt: "" },
|
|
13
15
|
waypoints: [],
|
|
14
16
|
};
|
|
@@ -3,9 +3,10 @@ import type { EnabledMods } from "../../types";
|
|
|
3
3
|
// Each mod's command, `/<name> [on|off]`, as the menu describes it
|
|
4
4
|
export const ModDescriptionMap: Record<keyof EnabledMods, string> = {
|
|
5
5
|
commission: "Show a goal's task list, how far it is and how long it has run.",
|
|
6
|
+
pulse: "Keep the machine watcher running.",
|
|
6
7
|
resin: "Show the cache, context, limits and cost, with warm, compact and handoff.",
|
|
7
8
|
veil: "Recording mode: show emails, amounts, phone numbers and secrets as placeholders.",
|
|
8
|
-
ward: "Ask before editing a file another session changed in the last half hour.",
|
|
9
|
+
ward: "Ask before editing a file another session changed in the last half hour, and refuse a whole-index git command while one shares the checkout.",
|
|
9
10
|
waypoints: "Suggest the next steps after each answer, one press each.",
|
|
10
11
|
};
|
|
11
12
|
|
|
@@ -4,8 +4,25 @@ export const ACCENT_COLOR = "#d3bc8e";
|
|
|
4
4
|
export const CACHE_LIFETIME_MS: number = Temporal.Duration.from({ hours: 1 }).total("milliseconds");
|
|
5
5
|
export const CACHE_LOW_MS: number = Temporal.Duration.from({ minutes: 10 }).total("milliseconds");
|
|
6
6
|
export const CACHE_WARNING_MS: number = Temporal.Duration.from({ minutes: 5 }).total("milliseconds");
|
|
7
|
-
export const CLOCK_TICK_MS: number = Temporal.Duration.from({ minutes: 1 }).total("milliseconds");
|
|
8
7
|
export const USAGE_WARNING_PERCENTAGE = 80;
|
|
8
|
+
// The usage lines each window's two tiers start at: the maintenance tier, then the wind-down
|
|
9
|
+
export const FIVE_HOUR_MAINTENANCE_PERCENTAGE = 80;
|
|
10
|
+
export const FIVE_HOUR_WIND_DOWN_PERCENTAGE = 90;
|
|
11
|
+
export const WEEKLY_MAINTENANCE_PERCENTAGE = 80;
|
|
12
|
+
export const WEEKLY_WIND_DOWN_PERCENTAGE = 90;
|
|
13
|
+
// A window under this share is never read for its pace, since an early reading projects wildly
|
|
14
|
+
export const PACE_FLOOR_PERCENTAGE = 50;
|
|
15
|
+
// Each window's length, which its reset time is counted back from
|
|
16
|
+
export const FIVE_HOUR_WINDOW_MS: number = Temporal.Duration.from({ hours: 5 }).total("milliseconds");
|
|
17
|
+
export const WEEKLY_WINDOW_MS: number = Temporal.Duration.from({ hours: 168 }).total("milliseconds");
|
|
18
|
+
// The only model a subagent launches on: the main session thinks, and everything it delegates runs on haiku
|
|
19
|
+
export const AGENT_MODEL = "haiku";
|
|
20
|
+
// What a launch on any other model, or a fork, is told instead
|
|
21
|
+
// oxlint-disable-next-line typescript/no-inferrable-types -- `isolatedDeclarations` demands the annotation this template literal would otherwise infer
|
|
22
|
+
export const AGENT_MODEL_REFUSAL: string = `A subagent runs on "${AGENT_MODEL}" alone: pass model "${AGENT_MODEL}", and never a fork, which runs on the session's own model. Work too judged for ${AGENT_MODEL} stays in the main session, and a spec ${AGENT_MODEL} cannot carry still has a call open.`;
|
|
23
|
+
// What a workflow refused under a reserve is told to do instead, after the reserve's summary
|
|
24
|
+
// oxlint-disable-next-line typescript/no-inferrable-types -- `isolatedDeclarations` demands the annotation this template literal would otherwise infer
|
|
25
|
+
export const RESERVE_REFUSAL_INSTRUCTION: string = `Launch an agent with model "${AGENT_MODEL}" instead, for cleanup or a compute runner whose judgement is settled; otherwise write the unit down as a follow-up and leave it for after the reset.`;
|
|
9
26
|
export const MAX_WAYPOINTS = 3;
|
|
10
27
|
export const MAX_SHOWN_TASKS = 8;
|
|
11
28
|
// Whole minutes as the band writes them, `12m`
|
|
@@ -24,3 +41,31 @@ export const HANDOFF_QUESTION =
|
|
|
24
41
|
export const VEIL_SECTION_ID = "genshin-mods-veil";
|
|
25
42
|
export const VEIL_SYSTEM_SECTION =
|
|
26
43
|
"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.";
|
|
44
|
+
export const RESERVE_SECTION_ID = "genshin-mods-reserve";
|
|
45
|
+
// The wind-down's section ends on this instruction, the same for every window so only the window and its reset time vary
|
|
46
|
+
export const RESERVE_INSTRUCTION =
|
|
47
|
+
"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.";
|
|
48
|
+
// The maintenance tier's section ends on this instruction: the window's rest goes on cleanup a haiku agent can do cold
|
|
49
|
+
export const MAINTENANCE_INSTRUCTION =
|
|
50
|
+
"Until it resets, start no new feature, design or proposal; spend the rest of the window on haiku agents for cleanup: fix the findings of a code review of this window's own recent commits, drain an open sweep ledger, bring the docs and skills in line with the code changed this window, fix CI's typecheck and lint reds, and land the refactors, simplifications and optimisations the compute queue's runs surfaced; write a finding that needs judgement down as a follow-up (the follow-ups plugin's capture skill) rather than deciding it on haiku; keep the compute queue's runners going.";
|
|
51
|
+
// The tools that look something up, the tools that change files, and the shells whose command decides which it is
|
|
52
|
+
export const LOOKUP_TOOLS: readonly string[] = ["Glob", "Grep", "Read", "WebFetch"];
|
|
53
|
+
export const RESET_TOOLS: readonly string[] = ["Agent", "Edit", "NotebookEdit", "Write"];
|
|
54
|
+
export const SHELL_TOOLS: readonly string[] = ["Bash", "PowerShell"];
|
|
55
|
+
// The `export` and `cd` a session chains ahead of its command, each stripped as one leading segment
|
|
56
|
+
// oxlint-disable-next-line typescript/no-inferrable-types -- `isolatedDeclarations` demands the annotation this pattern would otherwise infer
|
|
57
|
+
export const LEADING_SEGMENT_REGEX: RegExp = /^\s*(?:export\s[^&;]*&&|cd\s[^&;]*&&|cd\s[^&;]*;)\s*/u;
|
|
58
|
+
// The commands that only read, a whole first word each so `catalog` is not `cat`. No interpreter is one, since what
|
|
59
|
+
// It runs may write as readily as read; PowerShell's cmdlets are matched in any case, as PowerShell matches them
|
|
60
|
+
// oxlint-disable-next-line typescript/no-inferrable-types -- `isolatedDeclarations` demands the annotation this pattern would otherwise infer
|
|
61
|
+
export const LOOKUP_COMMAND_REGEX: RegExp =
|
|
62
|
+
/^\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;
|
|
63
|
+
// Every third lookup in a row, the chain a haiku agent should answer as one bounded question
|
|
64
|
+
export const DELEGATION_NUDGE_EVERY = 3;
|
|
65
|
+
export const DELEGATION_NUDGE =
|
|
66
|
+
"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.";
|
|
67
|
+
// The repository's watcher, which the pulse runs as its child and reads the lines of
|
|
68
|
+
export const MACHINE_WATCHER_SCRIPT = "scripts/src/machine/watch/index.ts";
|
|
69
|
+
// How long after its watcher exits a session starts one again, so a session whose watcher found another's lock held takes
|
|
70
|
+
// Over the watching once that watcher's session ends
|
|
71
|
+
export const MACHINE_WATCHER_RETRY_MILLISECONDS: number = Temporal.Duration.from({ minutes: 1 }).total("milliseconds");
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { LEADING_SEGMENT_REGEX, LOOKUP_COMMAND_REGEX } from "../constants";
|
|
2
|
+
|
|
3
|
+
// A session chains `export` and `cd` ahead of its real command, so each leading segment is stripped before the
|
|
4
|
+
// Command's first word is read
|
|
5
|
+
const stripLeadingSegments = (command: string): string => {
|
|
6
|
+
const rest = command.replace(LEADING_SEGMENT_REGEX, "");
|
|
7
|
+
return rest === command ? command : stripLeadingSegments(rest);
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const checkIsLookupCommand = (command: string): boolean =>
|
|
11
|
+
LOOKUP_COMMAND_REGEX.test(stripLeadingSegments(command));
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { DelegationCall } from "../../models/DelegationCall";
|
|
2
|
+
|
|
3
|
+
import { DelegationStep } from "../../models/DelegationStep";
|
|
4
|
+
import { LOOKUP_TOOLS, RESET_TOOLS, SHELL_TOOLS } from "../constants";
|
|
5
|
+
import { checkIsLookupCommand } from "./checkIsLookupCommand";
|
|
6
|
+
|
|
7
|
+
// A subagent's calls, and a tool that is neither a lookup nor a reset, leave the main session's count alone. A shell
|
|
8
|
+
// Command in the background is never a lookup: its output is not read in the turn it runs
|
|
9
|
+
export const getCallStep = (call: DelegationCall): DelegationStep => {
|
|
10
|
+
if (call.agentId !== undefined) return DelegationStep.Neutral;
|
|
11
|
+
else if (LOOKUP_TOOLS.includes(call.tool)) return DelegationStep.Lookup;
|
|
12
|
+
else if (RESET_TOOLS.includes(call.tool)) return DelegationStep.Reset;
|
|
13
|
+
else if (!SHELL_TOOLS.includes(call.tool)) return DelegationStep.Neutral;
|
|
14
|
+
const isLookup = call.run_in_background !== true && checkIsLookupCommand(call.command || "");
|
|
15
|
+
return isLookup ? DelegationStep.Lookup : DelegationStep.Reset;
|
|
16
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AgentLaunch } from "../../models/AgentLaunch";
|
|
2
|
+
|
|
3
|
+
import { AGENT_MODEL, AGENT_MODEL_REFUSAL } from "../constants";
|
|
4
|
+
|
|
5
|
+
// Why an agent launch is refused, or undefined when it runs on the one model a subagent may. A fork never does, since
|
|
6
|
+
// It runs on the parent's model whatever it names
|
|
7
|
+
export const getLaunchRefusal = (launch: AgentLaunch): string | undefined =>
|
|
8
|
+
launch.subagent_type === "fork" || launch.model !== AGENT_MODEL ? AGENT_MODEL_REFUSAL : undefined;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { DelegationStep } from "../../models/DelegationStep";
|
|
2
|
+
|
|
3
|
+
// The lookups in a row after one call: a lookup adds one, a reset clears the run, and a neutral call keeps it
|
|
4
|
+
export const getNextStreak = (streak: number, step: DelegationStep): number => {
|
|
5
|
+
if (step === DelegationStep.Lookup) return streak + 1;
|
|
6
|
+
else if (step === DelegationStep.Reset) return 0;
|
|
7
|
+
else return streak;
|
|
8
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { DELEGATION_NUDGE, DELEGATION_NUDGE_EVERY } from "../constants";
|
|
2
|
+
|
|
3
|
+
// The nudge owed by a streak: on the third lookup in a row and every third after it
|
|
4
|
+
export const getNudge = (streak: number): string | undefined =>
|
|
5
|
+
streak > 0 && streak % DELEGATION_NUDGE_EVERY === 0 ? DELEGATION_NUDGE : undefined;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { EngineInterface, On } from "claude-code";
|
|
2
|
+
|
|
3
|
+
import { atom, update } from "claude-code";
|
|
4
|
+
|
|
5
|
+
import type { DelegationCall } from "../../models/DelegationCall";
|
|
6
|
+
|
|
7
|
+
import { DelegationStep } from "../../models/DelegationStep";
|
|
8
|
+
import { InitialState } from "../InitialState";
|
|
9
|
+
import { getCallStep } from "./getCallStep";
|
|
10
|
+
import { getLaunchRefusal } from "./getLaunchRefusal";
|
|
11
|
+
import { getNextStreak } from "./getNextStreak";
|
|
12
|
+
import { getNudge } from "./getNudge";
|
|
13
|
+
|
|
14
|
+
const lookupStreakAtom = atom({ key: "lookupStreak", plugin: "genshin-mods" } as const, InitialState.lookupStreak);
|
|
15
|
+
|
|
16
|
+
// The count is one update, so calls the model sends in one batch each see the count the call before them left. The
|
|
17
|
+
// Nudge is the count's, and a neutral call earns none
|
|
18
|
+
const countCall = async ($: EngineInterface, call: DelegationCall): Promise<string | undefined> => {
|
|
19
|
+
const step = getCallStep(call);
|
|
20
|
+
if (step === DelegationStep.Neutral) return undefined;
|
|
21
|
+
const streak = await update($, lookupStreakAtom, (value) => getNextStreak(value, step));
|
|
22
|
+
return step === DelegationStep.Lookup ? getNudge(streak) : undefined;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Any failure lets the call through, as the ward's does: a count that blocked on its own failure would block every
|
|
26
|
+
// Call after it. `next` is replay-safe here, so a call the hook had already counted is not run twice
|
|
27
|
+
const letCallThrough = <E, R>(_$: EngineInterface, e: E, next: (e: E) => R): R => next(e);
|
|
28
|
+
|
|
29
|
+
export const registerDelegation = (on: On): void => {
|
|
30
|
+
// An agent launch on any model but haiku is refused before it counts. A nudge rides on the call's own result as
|
|
31
|
+
// Context the model reads after it, and never blocks the call
|
|
32
|
+
// eslint-disable-next-line no-restricted-syntax -- The engine's registration handler, run when the hook rejects, not a promise
|
|
33
|
+
on(
|
|
34
|
+
"tool.call",
|
|
35
|
+
{ tool: ["Agent", "Bash", "Edit", "Glob", "Grep", "NotebookEdit", "PowerShell", "Read", "WebFetch", "Write"] },
|
|
36
|
+
async ($, e, next) => {
|
|
37
|
+
const refusal = e.tool === "Agent" ? getLaunchRefusal(e) : undefined;
|
|
38
|
+
if (refusal !== undefined) return { deny: refusal };
|
|
39
|
+
const nudge = await countCall($, e);
|
|
40
|
+
const result = await next(e);
|
|
41
|
+
return nudge !== undefined && result.deny === undefined
|
|
42
|
+
? { ...result, context: [...(result.context ?? []), nudge] }
|
|
43
|
+
: result;
|
|
44
|
+
},
|
|
45
|
+
).catch(letCallThrough);
|
|
46
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { CommandRefusal } from "../shell/getShellRefusal";
|
|
2
|
+
|
|
3
|
+
import { getShellRefusal } from "../shell/getShellRefusal";
|
|
4
|
+
|
|
5
|
+
// A root in any spelling is a folder whose scan reads the whole disk: the drive's folders, the users' folders and the
|
|
6
|
+
// Home folder by name, once Windows drive letters and backslashes are read as Git Bash's `/c/...` form
|
|
7
|
+
const ROOT_REGEX =
|
|
8
|
+
/^(?:\/[a-z]?|\/(?:c\/)?Users(?:\/[^/]+)?|\/home(?:\/[^/]+)?|~|\$HOME|\$\{HOME\}|\$USERPROFILE|%USERPROFILE%)$/u;
|
|
9
|
+
|
|
10
|
+
const SCAN_COMMANDS: ReadonlySet<string> = new Set(["du", "find", "grep", "ls"]);
|
|
11
|
+
|
|
12
|
+
// Backslashes become slashes, a drive letter becomes its `/c` folder, and a trailing glob and slash go, so "C:\", "/c/",
|
|
13
|
+
// "C:/" and "/c/*" all name "/c": a folder's glob expands to its entries, whose scan reads the whole folder
|
|
14
|
+
const normalizePath = (token: string) => {
|
|
15
|
+
const slashed = token.replaceAll("\\", "/");
|
|
16
|
+
const path = /^[A-Za-z]:/u.test(slashed) ? `/${slashed.charAt(0).toLowerCase()}${slashed.slice(2)}` : slashed;
|
|
17
|
+
return path.replace(/(?<=\/)\*$/u, "").replace(/(?<=.)\/+$/u, "");
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const isRoot = (token: string) => ROOT_REGEX.test(normalizePath(token));
|
|
21
|
+
|
|
22
|
+
// A flag group holding r or R: `-R`, `-rn`, `-lR`, `--recursive`
|
|
23
|
+
const isRecursiveFlag = (token: string) => /^-[a-zA-Z]*[rR]/u.test(token) || token === "--recursive";
|
|
24
|
+
|
|
25
|
+
// The ls flag recurses on the capital R alone, since its lowercase r sorts in reverse
|
|
26
|
+
const isUpperRecursiveFlag = (token: string) => /^-[a-zA-Z]*R/u.test(token);
|
|
27
|
+
|
|
28
|
+
// A flag that gives grep its pattern as the next argument, or as the rest of the same one
|
|
29
|
+
const SEPARATE_PATTERN_FLAG_REGEX = /^-[a-zA-Z]*[ef]$|^--(?:regexp|file)$/u;
|
|
30
|
+
const ATTACHED_PATTERN_FLAG_REGEX = /^-[a-zA-Z]*[ef].|^--(?:regexp|file)=/u;
|
|
31
|
+
|
|
32
|
+
// Grep's flags and search paths: a pattern flag's operand is its pattern and never a flag or a path, every argument
|
|
33
|
+
// After a `--` is a positional one, and with no pattern flag the first positional argument is the pattern
|
|
34
|
+
const readGrepArguments = (args: string[]): { flags: string[]; paths: string[] } => {
|
|
35
|
+
const flags: string[] = [];
|
|
36
|
+
const positionals: string[] = [];
|
|
37
|
+
let hasPatternFlag = false;
|
|
38
|
+
let isPatternOperand = false;
|
|
39
|
+
let isOptionEnd = false;
|
|
40
|
+
for (const arg of args)
|
|
41
|
+
if (isPatternOperand) isPatternOperand = false;
|
|
42
|
+
else if (isOptionEnd || !arg.startsWith("-")) positionals.push(arg);
|
|
43
|
+
else if (arg === "--") isOptionEnd = true;
|
|
44
|
+
else {
|
|
45
|
+
flags.push(arg);
|
|
46
|
+
if (SEPARATE_PATTERN_FLAG_REGEX.test(arg)) {
|
|
47
|
+
hasPatternFlag = true;
|
|
48
|
+
isPatternOperand = true;
|
|
49
|
+
} else if (ATTACHED_PATTERN_FLAG_REGEX.test(arg)) hasPatternFlag = true;
|
|
50
|
+
}
|
|
51
|
+
return { flags, paths: hasPatternFlag ? positionals : positionals.slice(1) };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// The scan's name when its arguments start it from a root, undefined when they do not
|
|
55
|
+
const getScanRefusal = (name: string, args: string[]): string | undefined => {
|
|
56
|
+
if (name === "find" || name === "du") return args.some((arg) => isRoot(arg)) ? name : undefined;
|
|
57
|
+
if (name === "ls")
|
|
58
|
+
return args.some((arg) => isUpperRecursiveFlag(arg)) && args.some((arg) => isRoot(arg)) ? name : undefined;
|
|
59
|
+
const { flags, paths } = readGrepArguments(args);
|
|
60
|
+
return flags.some((flag) => isRecursiveFlag(flag)) && paths.some((path) => isRoot(path)) ? name : undefined;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const getScanCommandRefusal: CommandRefusal = (name, args) =>
|
|
64
|
+
SCAN_COMMANDS.has(name) ? getScanRefusal(name, args) : undefined;
|
|
65
|
+
|
|
66
|
+
// The scan a Bash command runs from a root or a home folder, named by its command, undefined for every other command
|
|
67
|
+
export const getDiskScanRefusal = (command: string): string | undefined =>
|
|
68
|
+
getShellRefusal(command, getScanCommandRefusal);
|
|
69
|
+
|
|
70
|
+
// The known homes the refusal names. The mod ships without @esposter/shared, so the product's name is spelled here
|
|
71
|
+
// As the folder it is on the person's disk
|
|
72
|
+
const KNOWN_HOME_LINES: string[] = [
|
|
73
|
+
"- the repo, searched with `rg` or `git grep` (both skip node_modules);",
|
|
74
|
+
// oxlint-disable-next-line naming/no-site-name-literal -- A path on the person's disk, which a package shipped without @esposter/shared cannot derive from SITE_NAME
|
|
75
|
+
"- `~/Esposter/genshin-parity`, for game data and captures;",
|
|
76
|
+
"- `~/.claude/genshin-persona`, for the voice;",
|
|
77
|
+
"- a pinned tool (FFmpeg, yt-dlp) through `resolvePinnedTool`;",
|
|
78
|
+
"- a package through `require.resolve` from the package that depends on it.",
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
export const getDenyReason = (scanName: string): string =>
|
|
82
|
+
[
|
|
83
|
+
`Refused: ${scanName} scans from a root or a home folder, which reads the whole disk for a file whose home is known, and costs minutes of CPU and disk for every agent running. Look where the file lives instead:`,
|
|
84
|
+
...KNOWN_HOME_LINES,
|
|
85
|
+
].join("\n");
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { EngineInterface, On } from "claude-code";
|
|
2
|
+
|
|
3
|
+
import { getDenyReason, getDiskScanRefusal } from "./getDiskScanRefusal";
|
|
4
|
+
|
|
5
|
+
// Any failure lets the command through, as the ward's and the delegation guard's do: a guard that blocked on its own
|
|
6
|
+
// Failure would block every command after it
|
|
7
|
+
const letCommandThrough = <E, R>(_$: EngineInterface, e: E, next: (e: E) => R): R => next(e);
|
|
8
|
+
|
|
9
|
+
// A Bash command that scans from a root or a home folder is refused before it runs: the scan reads the whole disk for
|
|
10
|
+
// A file whose home is known, and the rule is cheapest enforced at the call itself
|
|
11
|
+
export const registerDiskScan = (on: On): void => {
|
|
12
|
+
// eslint-disable-next-line no-restricted-syntax -- The engine's registration handler, run when the hook rejects, not a promise
|
|
13
|
+
on("tool.call", { tool: "Bash" }, (_$, e, next) => {
|
|
14
|
+
const refusal = getDiskScanRefusal(e.command);
|
|
15
|
+
return refusal === undefined ? next(e) : { deny: getDenyReason(refusal) };
|
|
16
|
+
}).catch(letCommandThrough);
|
|
17
|
+
};
|
|
@@ -4,10 +4,22 @@ import { atom, read, update } from "claude-code";
|
|
|
4
4
|
|
|
5
5
|
import type { EnabledMods } from "../../types";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
CACHE_WARNING_MS,
|
|
9
|
+
MACHINE_WATCHER_RETRY_MILLISECONDS,
|
|
10
|
+
MACHINE_WATCHER_SCRIPT,
|
|
11
|
+
RESERVE_SECTION_ID,
|
|
12
|
+
VEIL_SECTION_ID,
|
|
13
|
+
VEIL_SYSTEM_SECTION,
|
|
14
|
+
WAYPOINTS_QUESTION,
|
|
15
|
+
} from "./constants";
|
|
8
16
|
import { InitialState } from "./InitialState";
|
|
9
17
|
import { ModDescriptionMap, ModNames } from "./ModDescriptionMap";
|
|
10
18
|
import { getCacheRemainingMs } from "./resin/getCacheRemainingMs";
|
|
19
|
+
import { getCacheWarningDelayMs } from "./resin/getCacheWarningDelayMs";
|
|
20
|
+
import { getReserveSummary } from "./resin/getReserveSummary";
|
|
21
|
+
import { getReserveWindow } from "./resin/getReserveWindow";
|
|
22
|
+
import { reserveText } from "./resin/reserveText";
|
|
11
23
|
import { parseWaypoints } from "./waypoints/parseWaypoints";
|
|
12
24
|
|
|
13
25
|
const commissionAtom = atom({ key: "commission", plugin: "genshin-mods" } as const, InitialState.commission);
|
|
@@ -21,7 +33,9 @@ const lastCacheRequestAtAtom = atom(
|
|
|
21
33
|
{ key: "lastCacheRequestAt", plugin: "genshin-mods" } as const,
|
|
22
34
|
InitialState.lastCacheRequestAt,
|
|
23
35
|
);
|
|
36
|
+
const lookupStreakAtom = atom({ key: "lookupStreak", plugin: "genshin-mods" } as const, InitialState.lookupStreak);
|
|
24
37
|
const nowAtom = atom({ key: "now", plugin: "genshin-mods" } as const, InitialState.now);
|
|
38
|
+
const reserveWindowAtom = atom({ key: "reserveWindow", plugin: "genshin-mods" } as const, InitialState.reserveWindow);
|
|
25
39
|
const waypointsAtom = atom({ key: "waypoints", plugin: "genshin-mods" } as const, InitialState.waypoints);
|
|
26
40
|
|
|
27
41
|
// The events a plugin may hook once with no matcher, each hooked here for every mod, with the commands that switch
|
|
@@ -30,7 +44,11 @@ const waypointsAtom = atom({ key: "waypoints", plugin: "genshin-mods" } as const
|
|
|
30
44
|
let warnedLastCacheRequestAt = 0;
|
|
31
45
|
let isTurnRunning = false;
|
|
32
46
|
|
|
33
|
-
|
|
47
|
+
// The cache goes cold at a known instant, so its warning is one timer armed for that instant at each turn's end, from the
|
|
48
|
+
// Request that last read or wrote the cache, rather than a check on a clock. The timer warns once for that request, and
|
|
49
|
+
// Stands down while a turn runs, since that turn's end arms it again. A timer left from an earlier turn finds the cache
|
|
50
|
+
// Renewed and does nothing more than the warning it would have given
|
|
51
|
+
const warnCache = async ($: EngineInterface) => {
|
|
34
52
|
const now = await $.clock.now();
|
|
35
53
|
await update($, nowAtom, () => now);
|
|
36
54
|
const lastCacheRequestAt = await read($, lastCacheRequestAtAtom);
|
|
@@ -48,11 +66,42 @@ const tick = async ($: EngineInterface) => {
|
|
|
48
66
|
$.ui.toast("The prompt cache goes cold in five minutes: warm it, compact or hand off from the band.");
|
|
49
67
|
};
|
|
50
68
|
|
|
69
|
+
const armCacheWarning = async ($: EngineInterface) => {
|
|
70
|
+
const delayMs = getCacheWarningDelayMs(await read($, lastCacheRequestAtAtom), await $.clock.now());
|
|
71
|
+
if (delayMs === undefined) return;
|
|
72
|
+
|
|
73
|
+
$.clock.after(delayMs, () => {
|
|
74
|
+
// oxlint-disable-next-line typescript/no-floating-promises -- The engine's timer slot takes no promise, and every call in the warning resolves
|
|
75
|
+
warnCache($);
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
|
|
51
79
|
const suggestWaypoints = async ($: EngineInterface) => {
|
|
52
80
|
const answer = await $.model.fork({ prompt: WAYPOINTS_QUESTION });
|
|
53
81
|
if (answer.isAnswered) await update($, waypointsAtom, () => parseWaypoints(answer.text));
|
|
54
82
|
};
|
|
55
83
|
|
|
84
|
+
// Keeps the machine watcher running as this session's child. The watcher writes the machine's state, holds its lock and
|
|
85
|
+
// Pushes its heartbeat, so its output is read only to keep the child's pipe drained. A watcher exits at once while another
|
|
86
|
+
// Session's holds the machine's lock, so each exit starts the watcher again after a wait, and the session that outlives the
|
|
87
|
+
// One watching takes the watching over
|
|
88
|
+
const watchPulse = async ($: EngineInterface) => {
|
|
89
|
+
if (!(await read($, enabledModsAtom)).pulse) return;
|
|
90
|
+
|
|
91
|
+
const cwd = await $.session.cwd();
|
|
92
|
+
if (!(await $.fs.exists(`${cwd}/${MACHINE_WATCHER_SCRIPT}`))) return;
|
|
93
|
+
|
|
94
|
+
// oxlint-disable-next-line no-underscore-dangle -- The chunks are discarded, and the binding is only the loop's syntax
|
|
95
|
+
for await (const _chunk of $.process.spawn({ argv: ["pnpm", "ai:machine:watch"], cwd })) {
|
|
96
|
+
// The watcher's output is discarded, since nothing in the session reads it
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
$.clock.after(MACHINE_WATCHER_RETRY_MILLISECONDS, () => {
|
|
100
|
+
// oxlint-disable-next-line typescript/no-floating-promises -- The engine's timer slot takes no promise, and the watcher's run settles as its process exits
|
|
101
|
+
watchPulse($);
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
56
105
|
const closeCommission = async ($: EngineInterface) => {
|
|
57
106
|
await update($, commissionAtom, () => InitialState.commission);
|
|
58
107
|
await update($, isCommissionExpandedAtom, () => InitialState.isCommissionExpanded);
|
|
@@ -79,11 +128,7 @@ export const registerLifecycle = (on: On): void => {
|
|
|
79
128
|
$.command.register({ argumentHint: "[on|off]", description: ModDescriptionMap[name], name }),
|
|
80
129
|
),
|
|
81
130
|
);
|
|
82
|
-
|
|
83
|
-
$.clock.every(CLOCK_TICK_MS, () => {
|
|
84
|
-
// oxlint-disable-next-line typescript/no-floating-promises -- The engine's timer slot takes no promise, and every call in the tick resolves
|
|
85
|
-
tick($);
|
|
86
|
-
});
|
|
131
|
+
watchPulse($);
|
|
87
132
|
return next(e);
|
|
88
133
|
});
|
|
89
134
|
|
|
@@ -100,21 +145,29 @@ export const registerLifecycle = (on: On): void => {
|
|
|
100
145
|
});
|
|
101
146
|
|
|
102
147
|
// The prompt is kept as the goal of a commission this turn may open, and a commission whose every task is done has
|
|
103
|
-
// Shown its finished row since the last turn ended, so it closes as the person moves on
|
|
148
|
+
// Shown its finished row since the last turn ended, so it closes as the person moves on. A new prompt also starts
|
|
149
|
+
// The lookup count again, since the lookups of the last turn are not the chain of this one
|
|
104
150
|
on("prompt.submit", async ($, e, next) => {
|
|
105
151
|
await update($, waypointsAtom, () => InitialState.waypoints);
|
|
152
|
+
await update($, lookupStreakAtom, () => InitialState.lookupStreak);
|
|
106
153
|
await update($, lastPromptAtom, () => e.text);
|
|
107
154
|
const { tasks } = await read($, commissionAtom);
|
|
108
155
|
if (tasks.length > 0 && tasks.every(({ status }) => status === "completed")) await closeCommission($);
|
|
109
156
|
return next(e);
|
|
110
157
|
});
|
|
111
158
|
|
|
112
|
-
// While the veil is on, the model is told to write placeholders too, so a reply never holds a value to hide
|
|
159
|
+
// While the veil is on, the model is told to write placeholders too, so a reply never holds a value to hide. While a
|
|
160
|
+
// Usage reserve holds and the resin mod is on, the model is told what the reserve's tier asks until the window resets,
|
|
161
|
+
// Which it may have done since the last measurement
|
|
113
162
|
on("prompt.compose", async ($, e, next) => {
|
|
114
163
|
const result = await next(e);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
164
|
+
const { resin, veil } = await read($, enabledModsAtom);
|
|
165
|
+
const reserveWindow = await read($, reserveWindowAtom);
|
|
166
|
+
const sections = [...result.sections];
|
|
167
|
+
if (veil) sections.push({ id: VEIL_SECTION_ID, scope: "session", text: VEIL_SYSTEM_SECTION });
|
|
168
|
+
if (resin && reserveWindow.name && Date.parse(reserveWindow.resetsAt) > (await $.clock.now()))
|
|
169
|
+
sections.push({ id: RESERVE_SECTION_ID, scope: "session", text: reserveText(reserveWindow) });
|
|
170
|
+
return { sections };
|
|
118
171
|
});
|
|
119
172
|
|
|
120
173
|
on("turn.start", (_$, e, next) => {
|
|
@@ -144,6 +197,7 @@ export const registerLifecycle = (on: On): void => {
|
|
|
144
197
|
|
|
145
198
|
const now = await $.clock.now();
|
|
146
199
|
await update($, nowAtom, () => now);
|
|
200
|
+
await armCacheWarning($);
|
|
147
201
|
if (e.reason === "answer" && (await read($, enabledModsAtom)).waypoints)
|
|
148
202
|
$.clock.after(0, () => {
|
|
149
203
|
// 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
|
|
@@ -151,4 +205,22 @@ export const registerLifecycle = (on: On): void => {
|
|
|
151
205
|
});
|
|
152
206
|
return result;
|
|
153
207
|
});
|
|
208
|
+
|
|
209
|
+
// The reserve is read off each measurement: it starts at the first window past a tier's line and lifts at the
|
|
210
|
+
// Measurement a window's reset raises, which finds none. The toast names the window once, as the reserve starts, and
|
|
211
|
+
// Again as its maintenance tier is raised to the wind-down
|
|
212
|
+
on("session.measure", async ($, e, next) => {
|
|
213
|
+
const now = await $.clock.now();
|
|
214
|
+
const reserveWindow = getReserveWindow(e.rateLimits, now);
|
|
215
|
+
const previousReserveWindow = await read($, reserveWindowAtom);
|
|
216
|
+
await update($, reserveWindowAtom, () => reserveWindow ?? InitialState.reserveWindow);
|
|
217
|
+
if (
|
|
218
|
+
reserveWindow &&
|
|
219
|
+
(!previousReserveWindow.name || (reserveWindow.isWindDown && !previousReserveWindow.isWindDown)) &&
|
|
220
|
+
(await read($, enabledModsAtom)).resin
|
|
221
|
+
)
|
|
222
|
+
$.ui.toast(`${getReserveSummary(reserveWindow)}.`);
|
|
223
|
+
|
|
224
|
+
return next(e);
|
|
225
|
+
});
|
|
154
226
|
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { UsageWindow } from "../../models/UsageWindow";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
FIVE_HOUR_MAINTENANCE_PERCENTAGE,
|
|
5
|
+
FIVE_HOUR_WIND_DOWN_PERCENTAGE,
|
|
6
|
+
FIVE_HOUR_WINDOW_MS,
|
|
7
|
+
WEEKLY_MAINTENANCE_PERCENTAGE,
|
|
8
|
+
WEEKLY_WIND_DOWN_PERCENTAGE,
|
|
9
|
+
WEEKLY_WINDOW_MS,
|
|
10
|
+
} from "../constants";
|
|
11
|
+
|
|
12
|
+
// The rate-limit windows a reserve is read off, five-hour before weekly, so the five-hour one is the one named when both
|
|
13
|
+
// Have passed a line of the same tier
|
|
14
|
+
export const RateLimitKindUsageWindowMap: Record<string, UsageWindow> = {
|
|
15
|
+
five_hour: {
|
|
16
|
+
durationMs: FIVE_HOUR_WINDOW_MS,
|
|
17
|
+
maintenancePercentage: FIVE_HOUR_MAINTENANCE_PERCENTAGE,
|
|
18
|
+
name: "five-hour",
|
|
19
|
+
windDownPercentage: FIVE_HOUR_WIND_DOWN_PERCENTAGE,
|
|
20
|
+
},
|
|
21
|
+
seven_day: {
|
|
22
|
+
durationMs: WEEKLY_WINDOW_MS,
|
|
23
|
+
maintenancePercentage: WEEKLY_MAINTENANCE_PERCENTAGE,
|
|
24
|
+
name: "weekly",
|
|
25
|
+
windDownPercentage: WEEKLY_WIND_DOWN_PERCENTAGE,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { CACHE_WARNING_MS } from "../constants";
|
|
2
|
+
import { getCacheRemainingMs } from "./getCacheRemainingMs";
|
|
3
|
+
|
|
4
|
+
// How long from `now` until the cache warning is due: the instant the cache goes cold less the warning margin.
|
|
5
|
+
// Nothing is due while no request has read or written the cache, and a deadline already past the margin is due at once
|
|
6
|
+
export const getCacheWarningDelayMs = (lastCacheRequestAt: number, now: number): number | undefined =>
|
|
7
|
+
lastCacheRequestAt > 0 ? Math.max(0, getCacheRemainingMs(lastCacheRequestAt, now) - CACHE_WARNING_MS) : undefined;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ReserveWindow } from "../../../types";
|
|
2
|
+
|
|
3
|
+
import { formatResetsAt } from "./formatResetsAt";
|
|
4
|
+
|
|
5
|
+
// The sentence a reserve opens with, naming its tier, window, the usage line that window has passed and its reset time,
|
|
6
|
+
// Which the system section and the toast both say
|
|
7
|
+
export const getReserveSummary = ({ isAheadOfPace, isWindDown, name, percentage, resetsAt }: ReserveWindow): string =>
|
|
8
|
+
`${isWindDown ? "Usage reserve" : "Usage maintenance"}: the ${name} usage window has passed ${percentage}%${isAheadOfPace ? " ahead of an even pace" : ""} and resets at ${formatResetsAt(resetsAt)}`;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SessionRateLimit } from "claude-code";
|
|
2
|
+
|
|
3
|
+
import type { ReserveWindow } from "../../../types";
|
|
4
|
+
|
|
5
|
+
import { PACE_FLOOR_PERCENTAGE } from "../constants";
|
|
6
|
+
import { RateLimitKindUsageWindowMap } from "./RateLimitKindUsageWindowMap";
|
|
7
|
+
|
|
8
|
+
// Each window a reserve is read off, five-hour before weekly, so the five-hour one is the one named when both have
|
|
9
|
+
// Passed a line of the same tier. A window is read once past its maintenance line, or once half of its allowance is
|
|
10
|
+
// Spent ahead of an even pace, the share of its length that has elapsed; the wind-down line is read as well. A window
|
|
11
|
+
// The engine gives no reset time for is skipped, since the reserve is named by that time and lifts at it
|
|
12
|
+
export const getReserveWindow = (rateLimits: SessionRateLimit[], now: number): ReserveWindow | undefined => {
|
|
13
|
+
const reserveWindows: ReserveWindow[] = [];
|
|
14
|
+
for (const [kind, { durationMs, maintenancePercentage, name, windDownPercentage }] of Object.entries(
|
|
15
|
+
RateLimitKindUsageWindowMap,
|
|
16
|
+
)) {
|
|
17
|
+
const rateLimit = rateLimits.find((limit) => limit.kind === kind);
|
|
18
|
+
if (rateLimit?.resetsAt === undefined) continue;
|
|
19
|
+
const isWindDown = rateLimit.percentUsed >= windDownPercentage;
|
|
20
|
+
const isPastMaintenance = rateLimit.percentUsed >= maintenancePercentage;
|
|
21
|
+
const elapsedPercentage = 100 * (1 - (Date.parse(rateLimit.resetsAt) - now) / durationMs);
|
|
22
|
+
const isAheadOfPace =
|
|
23
|
+
!isPastMaintenance && rateLimit.percentUsed >= PACE_FLOOR_PERCENTAGE && rateLimit.percentUsed > elapsedPercentage;
|
|
24
|
+
if (!isPastMaintenance && !isAheadOfPace) continue;
|
|
25
|
+
let percentage = PACE_FLOOR_PERCENTAGE;
|
|
26
|
+
if (isWindDown) percentage = windDownPercentage;
|
|
27
|
+
else if (isPastMaintenance) percentage = maintenancePercentage;
|
|
28
|
+
reserveWindows.push({ isAheadOfPace, isWindDown, name, percentage, resetsAt: rateLimit.resetsAt });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return reserveWindows.find(({ isWindDown }) => isWindDown) ?? reserveWindows[0];
|
|
32
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { EngineInterface, On } from "claude-code";
|
|
2
|
+
|
|
3
|
+
import { atom, read } from "claude-code";
|
|
4
|
+
|
|
5
|
+
import { RESERVE_REFUSAL_INSTRUCTION } from "../constants";
|
|
6
|
+
import { InitialState } from "../InitialState";
|
|
7
|
+
import { getReserveSummary } from "./getReserveSummary";
|
|
8
|
+
|
|
9
|
+
const enabledModsAtom = atom({ key: "enabledMods", plugin: "genshin-mods" } as const, InitialState.enabledMods);
|
|
10
|
+
const reserveWindowAtom = atom({ key: "reserveWindow", plugin: "genshin-mods" } as const, InitialState.reserveWindow);
|
|
11
|
+
|
|
12
|
+
// Any failure lets the launch through, as the ward's does: a gate that blocked on its own failure would block every
|
|
13
|
+
// Launch after it. `next` is replay-safe here, so a launch the hook had already decided is not run twice
|
|
14
|
+
const letLaunchThrough = <E, R>(_$: EngineInterface, e: E, next: (e: E) => R): R => next(e);
|
|
15
|
+
|
|
16
|
+
export const registerReserveGate = (on: On): void => {
|
|
17
|
+
// The section only describes the reserve, and a workflow already launched never reads it, so the launch itself is
|
|
18
|
+
// Refused while the reserve holds, past the window's reset too, which the next measurement is yet to read. An agent
|
|
19
|
+
// Launch is the delegation guard's, which refuses one on any model but haiku whether or not a reserve holds
|
|
20
|
+
// eslint-disable-next-line no-restricted-syntax -- The engine's registration handler, run when the hook rejects, not a promise
|
|
21
|
+
on("tool.call", { tool: ["Workflow"] }, async ($, e, next) => {
|
|
22
|
+
if (!(await read($, enabledModsAtom)).resin) return next(e);
|
|
23
|
+
const reserveWindow = await read($, reserveWindowAtom);
|
|
24
|
+
if (!reserveWindow.name || Date.parse(reserveWindow.resetsAt) <= (await $.clock.now())) return next(e);
|
|
25
|
+
return { deny: `${getReserveSummary(reserveWindow)}. ${RESERVE_REFUSAL_INSTRUCTION}` };
|
|
26
|
+
}).catch(letLaunchThrough);
|
|
27
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ReserveWindow } from "../../../types";
|
|
2
|
+
|
|
3
|
+
import { MAINTENANCE_INSTRUCTION, RESERVE_INSTRUCTION } from "../constants";
|
|
4
|
+
import { getReserveSummary } from "./getReserveSummary";
|
|
5
|
+
|
|
6
|
+
// The system section the reserve adds: its summary and the instruction of its tier are the only parts that vary, so the
|
|
7
|
+
// Text holds still for as long as the tier does
|
|
8
|
+
export const reserveText = (reserveWindow: ReserveWindow): string =>
|
|
9
|
+
`${getReserveSummary(reserveWindow)}. ${reserveWindow.isWindDown ? RESERVE_INSTRUCTION : MAINTENANCE_INSTRUCTION}`;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { joinDirectory } from "./joinDirectory";
|
|
2
|
+
|
|
3
|
+
// A shell command passed as one argument (`bash -c "…"`) is read again on its own, to this depth at most
|
|
4
|
+
const MAX_NESTING_DEPTH = 3;
|
|
5
|
+
|
|
6
|
+
// A segment runs up to a separator outside quotes, so a quoted argument such as a `bash -c` command stays whole
|
|
7
|
+
const SEGMENT_REGEX = /(?:"[^"]*"|'[^']*'|[^\n"&'();`|])+/gu;
|
|
8
|
+
// A backtick substitution runs even inside double quotes, so its body is read again on its own
|
|
9
|
+
const BACKTICK_SUBSTITUTION_REGEX = /`(?<body>[^`]*)`/gu;
|
|
10
|
+
const SHELL_FLAG_REGEX = /^-[a-zA-Z]*c$|^eval$/u;
|
|
11
|
+
const TOKEN_REGEX = /"(?<double>[^"]*)"|'(?<single>[^']*)'|(?<bare>\S+)/gu;
|
|
12
|
+
const PATH_SEPARATOR_REGEX = /[/\\]/u;
|
|
13
|
+
|
|
14
|
+
// The refusal for a command's name, the arguments after it and the folder it runs in from the session's ("" for the
|
|
15
|
+
// Session's own), undefined when the command is not one the guard reads
|
|
16
|
+
export type CommandRefusal<T = string> = (name: string, args: string[], directory: string) => T | undefined;
|
|
17
|
+
|
|
18
|
+
// A quoted argument keeps its inner spaces, so a shell command passed as one argument is read again on its own
|
|
19
|
+
const tokenize = (segment: string) =>
|
|
20
|
+
Array.from(
|
|
21
|
+
segment.matchAll(TOKEN_REGEX),
|
|
22
|
+
(match) => match.groups?.double ?? match.groups?.single ?? match.groups?.bare ?? "",
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
// The bodies of a command's `$(…)` substitutions, which run even inside double quotes: each runs to the parenthesis
|
|
26
|
+
// Closing it, past any a quote or a backslash holds, so a body such as `find / -name '(x)'` is read whole. A
|
|
27
|
+
// Substitution inside a body is the body's own, read when the body is
|
|
28
|
+
const getDollarSubstitutionBodies = (command: string): string[] => {
|
|
29
|
+
const bodies: string[] = [];
|
|
30
|
+
let start = command.indexOf("$(");
|
|
31
|
+
while (start !== -1) {
|
|
32
|
+
let depth = 1;
|
|
33
|
+
let quote = "";
|
|
34
|
+
let index = start + 2;
|
|
35
|
+
for (; index < command.length && depth > 0; index++) {
|
|
36
|
+
const character = command[index];
|
|
37
|
+
if (character === "\\" && quote !== "'") index++;
|
|
38
|
+
else if (quote !== "") {
|
|
39
|
+
if (character === quote) quote = "";
|
|
40
|
+
} else if (character === '"' || character === "'") quote = character;
|
|
41
|
+
else if (character === "(") depth++;
|
|
42
|
+
else if (character === ")") depth--;
|
|
43
|
+
}
|
|
44
|
+
if (depth > 0) break;
|
|
45
|
+
bodies.push(command.slice(start + 2, index - 1));
|
|
46
|
+
start = command.indexOf("$(", index);
|
|
47
|
+
}
|
|
48
|
+
return bodies;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// The refusal of the first command in a Bash command that the given reader refuses, undefined when none does. A `cd`
|
|
52
|
+
// Moves every command after it in the line; one to a home folder or back to the last is not followed
|
|
53
|
+
export const getShellRefusal = <T = string>(
|
|
54
|
+
command: string,
|
|
55
|
+
getRefusal: CommandRefusal<T>,
|
|
56
|
+
depth = 0,
|
|
57
|
+
startDirectory = "",
|
|
58
|
+
): T | undefined => {
|
|
59
|
+
if (depth < MAX_NESTING_DEPTH)
|
|
60
|
+
for (const body of [
|
|
61
|
+
...getDollarSubstitutionBodies(command),
|
|
62
|
+
...Array.from(command.matchAll(BACKTICK_SUBSTITUTION_REGEX), (match) => match.groups?.body ?? ""),
|
|
63
|
+
]) {
|
|
64
|
+
const nested = getShellRefusal(body, getRefusal, depth + 1, startDirectory);
|
|
65
|
+
if (nested !== undefined) return nested;
|
|
66
|
+
}
|
|
67
|
+
let directory = startDirectory;
|
|
68
|
+
for (const segment of command.match(SEGMENT_REGEX) ?? []) {
|
|
69
|
+
const tokens = tokenize(segment);
|
|
70
|
+
const [commandName, target] = tokens;
|
|
71
|
+
if (commandName === "cd" && target !== undefined && !target.startsWith("~") && !target.startsWith("-"))
|
|
72
|
+
directory = joinDirectory(directory, target);
|
|
73
|
+
for (const [index, token] of tokens.entries()) {
|
|
74
|
+
const name = token.split(PATH_SEPARATOR_REGEX).pop() ?? "";
|
|
75
|
+
const refusal = getRefusal(name, tokens.slice(index + 1), directory);
|
|
76
|
+
if (refusal !== undefined) return refusal;
|
|
77
|
+
const previous = tokens[index - 1];
|
|
78
|
+
if (depth < MAX_NESTING_DEPTH && previous !== undefined && SHELL_FLAG_REGEX.test(previous) && /\s/u.test(token)) {
|
|
79
|
+
const nested = getShellRefusal(token, getRefusal, depth + 1, directory);
|
|
80
|
+
if (nested !== undefined) return nested;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const ROOT_REGEX = /^(?:[a-zA-Z]:)?[/\\]/u;
|
|
2
|
+
const SEPARATOR_REGEX = /[/\\]/u;
|
|
3
|
+
|
|
4
|
+
// The folder `next` names from `base`: `next` itself where it is absolute, else the two joined, each `.` dropped and
|
|
5
|
+
// Each `..` taking the folder before it, so a path read off a command compares with the paths the ward records
|
|
6
|
+
export const joinDirectory = (base: string, next: string): string => {
|
|
7
|
+
if (next === "") return base;
|
|
8
|
+
const path = ROOT_REGEX.test(next) || base === "" ? next : `${base}/${next}`;
|
|
9
|
+
const root = ROOT_REGEX.exec(path)?.[0] ?? "";
|
|
10
|
+
const segments: string[] = [];
|
|
11
|
+
for (const segment of path.slice(root.length).split(SEPARATOR_REGEX))
|
|
12
|
+
if (segment === "" || segment === ".") continue;
|
|
13
|
+
else if (segment !== "..") segments.push(segment);
|
|
14
|
+
else if (segments.length > 0 && segments.at(-1) !== "..") segments.pop();
|
|
15
|
+
else if (root === "") segments.push(segment);
|
|
16
|
+
return `${root.replaceAll("\\", "/")}${segments.join("/")}`;
|
|
17
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { WardRecord } from "../../../types";
|
|
2
|
+
|
|
3
|
+
import { getCollidingRecord } from "./getCollidingRecord";
|
|
4
|
+
|
|
5
|
+
// Windows paths compare as one spelling: forward slashes, lowercase, since the file system ignores case
|
|
6
|
+
const getComparablePath = (path: string) => path.replaceAll("\\", "/").toLowerCase();
|
|
7
|
+
|
|
8
|
+
// The edited path of another session's record inside this checkout, undefined when no other session in the window
|
|
9
|
+
// Shares it, so a checkout alone with its session is never refused
|
|
10
|
+
export const getCheckoutPeer = (
|
|
11
|
+
records: Record<string, WardRecord>,
|
|
12
|
+
checkoutRoot: string,
|
|
13
|
+
sessionId: string,
|
|
14
|
+
now: number,
|
|
15
|
+
): string | undefined => {
|
|
16
|
+
const root = `${getComparablePath(checkoutRoot)}/`;
|
|
17
|
+
return Object.entries(records).find(
|
|
18
|
+
([path, record]) =>
|
|
19
|
+
getComparablePath(path).startsWith(root) && getCollidingRecord(record, sessionId, now) !== undefined,
|
|
20
|
+
)?.[0];
|
|
21
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { CommandRefusal } from "../shell/getShellRefusal";
|
|
2
|
+
|
|
3
|
+
import { getShellRefusal } from "../shell/getShellRefusal";
|
|
4
|
+
import { joinDirectory } from "../shell/joinDirectory";
|
|
5
|
+
|
|
6
|
+
const GIT_NAMES: ReadonlySet<string> = new Set(["git", "git.exe"]);
|
|
7
|
+
|
|
8
|
+
// The options git takes before its subcommand, each with its value as the next word
|
|
9
|
+
const GIT_VALUE_OPTIONS: ReadonlySet<string> = new Set(["-C", "-c"]);
|
|
10
|
+
|
|
11
|
+
// The reset modes that touch the working tree or the index beyond HEAD, so a reset with one of them is not a commit move
|
|
12
|
+
const RESET_INDEX_MODES: ReadonlySet<string> = new Set(["--hard", "--keep", "--merge", "--mixed"]);
|
|
13
|
+
|
|
14
|
+
const ADD_ALL_FLAGS: ReadonlySet<string> = new Set(["--all", "--update", "-A", "-u"]);
|
|
15
|
+
|
|
16
|
+
// The pathspecs git reads as every path of the checkout, from its root or the folder the command runs in
|
|
17
|
+
const WHOLE_CHECKOUT_PATHSPECS: ReadonlySet<string> = new Set([".", "./", ":/", ":/."]);
|
|
18
|
+
|
|
19
|
+
const isFlag = (arg: string) => arg.startsWith("-");
|
|
20
|
+
|
|
21
|
+
const isWholeCheckout = (operands: string[]) => operands.some((operand) => WHOLE_CHECKOUT_PATHSPECS.has(operand));
|
|
22
|
+
|
|
23
|
+
// The flags, the words before a `--` and the words after it, so a path is read whether or not git was given a separator
|
|
24
|
+
const splitArguments = (args: string[]) => {
|
|
25
|
+
const separator = args.indexOf("--");
|
|
26
|
+
const before = separator === -1 ? args : args.slice(0, separator);
|
|
27
|
+
const after = separator === -1 ? [] : args.slice(separator + 1);
|
|
28
|
+
return {
|
|
29
|
+
after,
|
|
30
|
+
flags: before.filter((arg) => isFlag(arg)),
|
|
31
|
+
hasSeparator: separator !== -1,
|
|
32
|
+
operands: [...before.filter((arg) => !isFlag(arg)), ...after],
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// The subcommand past the options git takes before it, the arguments after the subcommand, and the folders its `-C`
|
|
37
|
+
// Options name, each from the one before
|
|
38
|
+
const getSubcommand = (args: string[]) => {
|
|
39
|
+
const directories: string[] = [];
|
|
40
|
+
let index = 0;
|
|
41
|
+
while (index < args.length && isFlag(args[index] ?? "")) {
|
|
42
|
+
if (args[index] === "-C") directories.push(args[index + 1] ?? "");
|
|
43
|
+
index += GIT_VALUE_OPTIONS.has(args[index] ?? "") ? 2 : 1;
|
|
44
|
+
}
|
|
45
|
+
return { arguments: args.slice(index + 1), directories, name: args[index] ?? "" };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// `add .`, `add -A` and `add -N` over every path reach a peer's work, while a named path does not
|
|
49
|
+
const getAddRefusal = (args: string[]): string | undefined => {
|
|
50
|
+
const { flags, operands } = splitArguments(args);
|
|
51
|
+
if (isWholeCheckout(operands)) return "git add .";
|
|
52
|
+
const allFlag = flags.find((flag) => ADD_ALL_FLAGS.has(flag));
|
|
53
|
+
if (allFlag !== undefined && operands.length === 0) return `git add ${allFlag}`;
|
|
54
|
+
const isIntentToAdd = flags.some((flag) => flag === "-N" || flag === "--intent-to-add");
|
|
55
|
+
return isIntentToAdd && operands.length === 0 ? "git add -N" : undefined;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// A reset that names a path is path-scoped: after `--`, or as a second operand with none, since git reads the first
|
|
59
|
+
// As a commit or a path and every one after it as a path. The checkout's own pathspec names no commit, so it is the
|
|
60
|
+
// Whole checkout in either place. A lone operand may be a commit, so it is refused, and a reset that moves HEAD alone
|
|
61
|
+
// With `--soft` leaves the index as it is; every other reset rewrites the index to HEAD or the working tree, a peer's
|
|
62
|
+
// Staged entries included
|
|
63
|
+
const getResetRefusal = (args: string[]): string | undefined => {
|
|
64
|
+
const { after, flags, hasSeparator, operands } = splitArguments(args);
|
|
65
|
+
if (isWholeCheckout(hasSeparator ? after : operands)) return "git reset -- .";
|
|
66
|
+
const paths = hasSeparator ? after : operands.slice(1);
|
|
67
|
+
if (paths.length > 0) return undefined;
|
|
68
|
+
const isSoftOnly = flags.includes("--soft") && !flags.some((flag) => RESET_INDEX_MODES.has(flag));
|
|
69
|
+
return isSoftOnly ? undefined : "git reset";
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// `restore .` rewrites every path, staged or in the working tree, so the staged form is refused as the plain one is
|
|
73
|
+
const getRestoreRefusal = (args: string[]): string | undefined => {
|
|
74
|
+
const { flags, operands } = splitArguments(args);
|
|
75
|
+
if (!isWholeCheckout(operands)) return undefined;
|
|
76
|
+
const isStaged = flags.some((flag) => flag === "--staged" || flag === "-S");
|
|
77
|
+
return `git restore ${isStaged ? "--staged " : ""}.`;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// A stash in any form sets a peer's work aside, which is why the git skill never runs one
|
|
81
|
+
const getStashRefusal = (): string => "git stash";
|
|
82
|
+
|
|
83
|
+
// `checkout .` and `checkout -- .` overwrite every path's working tree copy, a peer's edits included
|
|
84
|
+
const getCheckoutRefusal = (args: string[]): string | undefined =>
|
|
85
|
+
isWholeCheckout(splitArguments(args).operands) ? "git checkout ." : undefined;
|
|
86
|
+
|
|
87
|
+
// A clean deletes every untracked file, a peer's new ones included; a dry run lists them and is allowed
|
|
88
|
+
const getCleanRefusal = (args: string[]): string | undefined => {
|
|
89
|
+
const { flags } = splitArguments(args);
|
|
90
|
+
const isDryRun = flags.some((flag) => flag === "--dry-run" || (!flag.startsWith("--") && flag.includes("n")));
|
|
91
|
+
return isDryRun ? undefined : "git clean";
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const WHOLE_INDEX_REFUSALS: ReadonlyMap<string, (args: string[]) => string | undefined> = new Map([
|
|
95
|
+
["add", getAddRefusal],
|
|
96
|
+
["checkout", getCheckoutRefusal],
|
|
97
|
+
["clean", getCleanRefusal],
|
|
98
|
+
["reset", getResetRefusal],
|
|
99
|
+
["restore", getRestoreRefusal],
|
|
100
|
+
["stash", getStashRefusal],
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
// The form refused and the folder git runs in, from the session's ("" for the session's own): the folder a `cd` before
|
|
104
|
+
// It moved to, then each of its `-C` folders
|
|
105
|
+
const getGitRefusal: CommandRefusal<{ directory: string; form: string }> = (name, args, directory) => {
|
|
106
|
+
if (!GIT_NAMES.has(name)) return undefined;
|
|
107
|
+
const subcommand = getSubcommand(args);
|
|
108
|
+
const form = WHOLE_INDEX_REFUSALS.get(subcommand.name)?.(subcommand.arguments);
|
|
109
|
+
return form === undefined
|
|
110
|
+
? undefined
|
|
111
|
+
: { directory: subcommand.directories.reduce((base, next) => joinDirectory(base, next), directory), form };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// The whole-index git form a Bash command runs, named as the form it is, with the folder it runs in, undefined for a
|
|
115
|
+
// Path-scoped or read-only command
|
|
116
|
+
export const getWholeIndexRefusal = (command: string): undefined | { directory: string; form: string } =>
|
|
117
|
+
getShellRefusal(command, getGitRefusal);
|
|
118
|
+
|
|
119
|
+
// The path-scoped forms that do the same job for the paths the session touched, named for the person's shared checkout
|
|
120
|
+
export const getWholeIndexDenyReason = (form: string, peerPath: string): string =>
|
|
121
|
+
[
|
|
122
|
+
`Refused: \`${form}\` reaches the staged and working changes of another session in this checkout, which is editing ${peerPath} now. Name the paths you touched instead:`,
|
|
123
|
+
"- `git add <path>` to stage them;",
|
|
124
|
+
"- `git reset -- <path>` to unstage them;",
|
|
125
|
+
'- `git commit -m "…" -- <path>` to commit only them.',
|
|
126
|
+
"The git skill's rule (`.agents/skills/git/SKILL.md`) owns the whole-index list.",
|
|
127
|
+
].join("\n");
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { WardRecord } from "../../../types";
|
|
2
|
+
|
|
3
|
+
export const parseRecords = (text: string): Record<string, WardRecord> =>
|
|
4
|
+
// oxlint-disable-next-line no-restricted-properties -- The records hold numbers and ids, no date, and a mod cannot import the shared reviver
|
|
5
|
+
JSON.parse(text) as Record<string, WardRecord>;
|
|
@@ -7,23 +7,20 @@ import type { WardRecord } from "../../../types";
|
|
|
7
7
|
import { WardAnswer, WardAnswers } from "../../models/WardAnswer";
|
|
8
8
|
import { InitialState } from "../InitialState";
|
|
9
9
|
import { getCollidingRecord } from "./getCollidingRecord";
|
|
10
|
+
import { getRecordsPath } from "./getRecordsPath";
|
|
10
11
|
import { getRecordsWithEdit } from "./getRecordsWithEdit";
|
|
12
|
+
import { parseRecords } from "./parseRecords";
|
|
11
13
|
|
|
12
14
|
const enabledModsAtom = atom({ key: "enabledMods", plugin: "genshin-mods" } as const, InitialState.enabledMods);
|
|
13
15
|
const AGE_FORMATTER = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
|
14
16
|
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const home = (await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE")) ?? "";
|
|
19
|
-
return `${home}/.claude/genshin-mods/ward.json`;
|
|
20
|
-
};
|
|
17
|
+
// The records file under the person's home folder, the home read from this file's own environment lookup
|
|
18
|
+
const resolveRecordsPath = async ($: EngineInterface): Promise<string> =>
|
|
19
|
+
getRecordsPath((await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE")) ?? "");
|
|
21
20
|
|
|
21
|
+
// The records as the file holds them, none when it does not yet exist
|
|
22
22
|
const 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
|
-
: {};
|
|
23
|
+
(await $.fs.exists(path)) ? parseRecords(await $.fs.read(path)) : {};
|
|
27
24
|
|
|
28
25
|
// Before the edit: the question, when another session's record calls for one. After it: the edit recorded
|
|
29
26
|
const ward = async (
|
|
@@ -32,7 +29,7 @@ const ward = async (
|
|
|
32
29
|
proceed: () => Promise<ToolCallResult>,
|
|
33
30
|
): Promise<ToolCallResult> => {
|
|
34
31
|
if (!(await read($, enabledModsAtom)).ward) return proceed();
|
|
35
|
-
const recordsPath = await
|
|
32
|
+
const recordsPath = await resolveRecordsPath($);
|
|
36
33
|
const sessionId = await $.session.id();
|
|
37
34
|
const now = await $.clock.now();
|
|
38
35
|
const collision = getCollidingRecord((await readRecords($, recordsPath))[path], sessionId, now);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { EngineInterface, On, ToolCallResult } from "claude-code";
|
|
2
|
+
|
|
3
|
+
import { atom, read } from "claude-code";
|
|
4
|
+
|
|
5
|
+
import type { WardRecord } from "../../../types";
|
|
6
|
+
|
|
7
|
+
import { InitialState } from "../InitialState";
|
|
8
|
+
import { joinDirectory } from "../shell/joinDirectory";
|
|
9
|
+
import { getCheckoutPeer } from "./getCheckoutPeer";
|
|
10
|
+
import { getParentFolder } from "./getParentFolder";
|
|
11
|
+
import { getRecordsPath } from "./getRecordsPath";
|
|
12
|
+
import { getWholeIndexDenyReason, getWholeIndexRefusal } from "./getWholeIndexRefusal";
|
|
13
|
+
import { parseRecords } from "./parseRecords";
|
|
14
|
+
|
|
15
|
+
const enabledModsAtom = atom({ key: "enabledMods", plugin: "genshin-mods" } as const, InitialState.enabledMods);
|
|
16
|
+
|
|
17
|
+
// The records file under the person's home folder, the home read from this file's own environment lookup
|
|
18
|
+
const resolveRecordsPath = async ($: EngineInterface): Promise<string> =>
|
|
19
|
+
getRecordsPath((await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE")) ?? "");
|
|
20
|
+
|
|
21
|
+
// The records as the file holds them, none when it does not yet exist
|
|
22
|
+
const readRecords = async ($: EngineInterface, path: string): Promise<Record<string, WardRecord>> =>
|
|
23
|
+
(await $.fs.exists(path)) ? parseRecords(await $.fs.read(path)) : {};
|
|
24
|
+
|
|
25
|
+
// The nearest folder at or above `folder` holding a `.git`, a worktree's file included, undefined when none does
|
|
26
|
+
const findCheckoutRoot = async ($: EngineInterface, folder: string): Promise<string | undefined> => {
|
|
27
|
+
if (await $.fs.exists(`${folder}/.git`)) return folder;
|
|
28
|
+
const parent = getParentFolder(folder);
|
|
29
|
+
return parent === undefined ? undefined : findCheckoutRoot($, parent);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// The checkout a session's git commands run in: the folder with the `.git`, or the session's own folder where none is
|
|
33
|
+
const getCheckoutRoot = async ($: EngineInterface, cwd: string): Promise<string> =>
|
|
34
|
+
(await findCheckoutRoot($, cwd)) ?? cwd;
|
|
35
|
+
|
|
36
|
+
// A whole-index git command is refused while the ward's records show another session editing the checkout it runs in,
|
|
37
|
+
// The session's folder moved by a `cd` before it and by its `-C`. With the Ward off nothing is recorded, so there is no
|
|
38
|
+
// Peer to see and the command runs
|
|
39
|
+
const wholeIndex = async (
|
|
40
|
+
$: EngineInterface,
|
|
41
|
+
command: string,
|
|
42
|
+
proceed: () => Promise<ToolCallResult>,
|
|
43
|
+
): Promise<ToolCallResult> => {
|
|
44
|
+
const refusal = getWholeIndexRefusal(command);
|
|
45
|
+
if (refusal === undefined || !(await read($, enabledModsAtom)).ward) return proceed();
|
|
46
|
+
const records = await readRecords($, await resolveRecordsPath($));
|
|
47
|
+
const checkoutRoot = await getCheckoutRoot($, joinDirectory(await $.session.cwd(), refusal.directory));
|
|
48
|
+
const peerPath = getCheckoutPeer(records, checkoutRoot, await $.session.id(), await $.clock.now());
|
|
49
|
+
return peerPath === undefined ? proceed() : { deny: getWholeIndexDenyReason(refusal.form, peerPath) };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Any failure lets the command through, as the disk-scan guard's does: a guard that blocked on its own failure would
|
|
53
|
+
// Block every command after it
|
|
54
|
+
const letCommandThrough = <E, R>(_$: EngineInterface, e: E, next: (e: E) => R): R => next(e);
|
|
55
|
+
|
|
56
|
+
export const registerWholeIndex = (on: On): void => {
|
|
57
|
+
// eslint-disable-next-line no-restricted-syntax -- The engine's registration handler, run when the hook rejects, not a promise
|
|
58
|
+
on("tool.call", { tool: "Bash" }, ($, e, next) => wholeIndex($, e.command, () => next(e))).catch(letCommandThrough);
|
|
59
|
+
};
|
package/types/index.d.ts
CHANGED
|
@@ -16,12 +16,24 @@ export interface CommissionTask {
|
|
|
16
16
|
// Which mods are on, each switched by its own command and kept in the store as one record
|
|
17
17
|
export interface EnabledMods {
|
|
18
18
|
commission: boolean;
|
|
19
|
+
pulse: boolean;
|
|
19
20
|
resin: boolean;
|
|
20
21
|
veil: boolean;
|
|
21
22
|
ward: boolean;
|
|
22
23
|
waypoints: boolean;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
// The rate-limit window past a usage line, named as its section spells it, the line it has passed, whether that line is
|
|
27
|
+
// The wind-down rather than the maintenance tier, whether the tier was entered by spending ahead of an even pace rather
|
|
28
|
+
// Than by the window's maintenance line, and when it resets; `""` in the name is the absent reserve
|
|
29
|
+
export interface ReserveWindow {
|
|
30
|
+
isAheadOfPace: boolean;
|
|
31
|
+
isWindDown: boolean;
|
|
32
|
+
name: string;
|
|
33
|
+
percentage: number;
|
|
34
|
+
resetsAt: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
export interface WardRecord {
|
|
26
38
|
editedAt: number;
|
|
27
39
|
sessionId: string;
|
|
@@ -36,7 +48,9 @@ declare module "claude-code" {
|
|
|
36
48
|
isHandingOff: boolean;
|
|
37
49
|
lastCacheRequestAt: number;
|
|
38
50
|
lastPrompt: string;
|
|
51
|
+
lookupStreak: number;
|
|
39
52
|
now: number;
|
|
53
|
+
reserveWindow: ReserveWindow;
|
|
40
54
|
waypoints: string[];
|
|
41
55
|
};
|
|
42
56
|
}
|