openclaw-plugin-grinders-farm 0.3.0
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/LICENSE +21 -0
- package/README.md +64 -0
- package/dist/delivery.js +140 -0
- package/dist/index.js +354 -0
- package/dist/start-image-server.js +81 -0
- package/dist/start-local-auto.js +137 -0
- package/openclaw.plugin.json +23 -0
- package/package.json +29 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Grinder's Farm contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# openclaw-plugin-grinders-farm
|
|
2
|
+
|
|
3
|
+
OpenClaw plugin for Grinder's Farm. Registers:
|
|
4
|
+
|
|
5
|
+
- `/farm` command
|
|
6
|
+
- `grinders_farm` tool (for skill `command-dispatch: tool`)
|
|
7
|
+
|
|
8
|
+
## Install (End User)
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
openclaw plugins install openclaw-plugin-grinders-farm --dangerously-force-unsafe-install
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
No local repo clone or build is required for end users.
|
|
15
|
+
|
|
16
|
+
## Runtime Resolution
|
|
17
|
+
|
|
18
|
+
The plugin tries these options in order:
|
|
19
|
+
|
|
20
|
+
1. `GRINDERS_FARM_ROOT` env
|
|
21
|
+
2. plugin config `gameRoot`
|
|
22
|
+
3. parent directory of this plugin
|
|
23
|
+
4. `grinders-farm` CLI in `PATH` (or `GRINDERS_FARM_CLI_BIN`)
|
|
24
|
+
|
|
25
|
+
If `gameRoot` is available, plugin can auto-start:
|
|
26
|
+
|
|
27
|
+
- local image server (`imageServerPort`)
|
|
28
|
+
- local auto worker (fixed 20s/day)
|
|
29
|
+
|
|
30
|
+
If only CLI mode is available, plugin still works and can auto-run `grinders-farm start`.
|
|
31
|
+
|
|
32
|
+
## Minimal OpenClaw Config
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"plugins": {
|
|
37
|
+
"entries": {
|
|
38
|
+
"grinders-farm": {
|
|
39
|
+
"enabled": true,
|
|
40
|
+
"source": "openclaw-plugin-grinders-farm",
|
|
41
|
+
"config": {
|
|
42
|
+
"autoStartWorkerOnGatewayBoot": true
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Optional if auto-detection fails:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"plugins": {
|
|
55
|
+
"entries": {
|
|
56
|
+
"grinders-farm": {
|
|
57
|
+
"config": {
|
|
58
|
+
"gameRoot": "/path/to/grinders-farm"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
package/dist/delivery.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 仅放在 openclaw-plugin/ 内,不 import 仓库 ../src。
|
|
3
|
+
* 安装到 ~/.openclaw/extensions/grinders-farm 时也能工作。
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as os from "node:os";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
export const OPENCLAW_DELIVERY_PATH = path.join(os.homedir(), ".grinders-farm", "openclaw-delivery.json");
|
|
9
|
+
export const OPENCLAW_DELIVERIES_PATH = path.join(os.homedir(), ".grinders-farm", "openclaw-deliveries.json");
|
|
10
|
+
function persistOpenclawDelivery(payload) {
|
|
11
|
+
fs.mkdirSync(path.dirname(OPENCLAW_DELIVERY_PATH), { recursive: true });
|
|
12
|
+
fs.writeFileSync(OPENCLAW_DELIVERY_PATH, JSON.stringify(payload, null, 2), "utf8");
|
|
13
|
+
persistOpenclawDeliveryList(payload);
|
|
14
|
+
}
|
|
15
|
+
function loadOpenclawDeliveryList() {
|
|
16
|
+
if (!fs.existsSync(OPENCLAW_DELIVERIES_PATH))
|
|
17
|
+
return [];
|
|
18
|
+
try {
|
|
19
|
+
const raw = JSON.parse(fs.readFileSync(OPENCLAW_DELIVERIES_PATH, "utf8"));
|
|
20
|
+
if (!Array.isArray(raw))
|
|
21
|
+
return [];
|
|
22
|
+
return raw
|
|
23
|
+
.map((entry) => {
|
|
24
|
+
if (!entry || typeof entry !== "object")
|
|
25
|
+
return null;
|
|
26
|
+
const r = entry;
|
|
27
|
+
const channel = typeof r.channel === "string" ? r.channel.trim() : "";
|
|
28
|
+
const target = typeof r.target === "string" ? r.target.trim() : "";
|
|
29
|
+
if (!channel || !target)
|
|
30
|
+
return null;
|
|
31
|
+
return {
|
|
32
|
+
channel,
|
|
33
|
+
target,
|
|
34
|
+
...(typeof r.accountId === "string" && r.accountId.trim() ? { accountId: r.accountId.trim() } : {}),
|
|
35
|
+
...(r.threadId != null ? { threadId: r.threadId } : {}),
|
|
36
|
+
...(typeof r.sessionKey === "string" && r.sessionKey.trim() ? { sessionKey: r.sessionKey.trim() } : {}),
|
|
37
|
+
};
|
|
38
|
+
})
|
|
39
|
+
.filter((entry) => entry != null);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function deliveryKey(entry) {
|
|
46
|
+
const channel = entry.channel.trim().toLowerCase();
|
|
47
|
+
const account = entry.accountId?.trim() ?? "";
|
|
48
|
+
const target = entry.target.trim();
|
|
49
|
+
const thread = entry.threadId == null ? "" : String(entry.threadId);
|
|
50
|
+
const session = entry.sessionKey?.trim() ?? "";
|
|
51
|
+
return [channel, account, target, thread, session].join("|");
|
|
52
|
+
}
|
|
53
|
+
function persistOpenclawDeliveryList(payload) {
|
|
54
|
+
const current = loadOpenclawDeliveryList();
|
|
55
|
+
const key = deliveryKey(payload);
|
|
56
|
+
const deduped = current.filter((entry) => deliveryKey(entry) !== key);
|
|
57
|
+
deduped.push(payload);
|
|
58
|
+
fs.writeFileSync(OPENCLAW_DELIVERIES_PATH, JSON.stringify(deduped, null, 2), "utf8");
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 从插件命令 ctx 写入投递。优先 to/from;若缺失则用 senderId(Web 控制端等常有 senderId 无 to/from)。
|
|
62
|
+
* channel 优先 ctx.channel,否则 ctx.channelId。
|
|
63
|
+
*/
|
|
64
|
+
export function saveOpenclawDeliveryFromBridge(ctx) {
|
|
65
|
+
const channel = (ctx.channel ?? ctx.channelId)?.toString().trim();
|
|
66
|
+
const target = (ctx.to ?? ctx.from ?? ctx.senderId)?.toString().trim();
|
|
67
|
+
if (!channel || !target)
|
|
68
|
+
return false;
|
|
69
|
+
persistOpenclawDelivery({
|
|
70
|
+
channel,
|
|
71
|
+
target,
|
|
72
|
+
...(ctx.accountId?.trim() ? { accountId: ctx.accountId.trim() } : {}),
|
|
73
|
+
...(ctx.messageThreadId != null ? { threadId: ctx.messageThreadId } : {}),
|
|
74
|
+
});
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* 先 sync 保存;失败时再试 OpenClaw 的会话绑定(部分界面 to/from 为空但绑定里有 conversationId)。
|
|
79
|
+
*/
|
|
80
|
+
export async function bindDeliveryFromPluginCommand(ctx) {
|
|
81
|
+
if (saveOpenclawDeliveryFromBridge(ctx))
|
|
82
|
+
return true;
|
|
83
|
+
if (typeof ctx.getCurrentConversationBinding !== "function")
|
|
84
|
+
return false;
|
|
85
|
+
try {
|
|
86
|
+
const b = await ctx.getCurrentConversationBinding();
|
|
87
|
+
if (!b?.channel?.trim() || !b.conversationId?.trim())
|
|
88
|
+
return false;
|
|
89
|
+
persistOpenclawDelivery({
|
|
90
|
+
channel: b.channel.trim(),
|
|
91
|
+
target: b.conversationId.trim(),
|
|
92
|
+
accountId: b.accountId?.trim(),
|
|
93
|
+
threadId: b.threadId,
|
|
94
|
+
});
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export function trySaveOpenclawDeliveryFromInboundClaim(event) {
|
|
102
|
+
const target = event.conversationId?.trim() || event.senderId?.trim();
|
|
103
|
+
const channel = event.channel?.trim();
|
|
104
|
+
if (!target || !channel)
|
|
105
|
+
return false;
|
|
106
|
+
const payload = {
|
|
107
|
+
channel,
|
|
108
|
+
target,
|
|
109
|
+
...(event.accountId?.trim() ? { accountId: event.accountId.trim() } : {}),
|
|
110
|
+
...(event.threadId != null ? { threadId: event.threadId } : {}),
|
|
111
|
+
};
|
|
112
|
+
if (!fs.existsSync(OPENCLAW_DELIVERY_PATH)) {
|
|
113
|
+
fs.mkdirSync(path.dirname(OPENCLAW_DELIVERY_PATH), { recursive: true });
|
|
114
|
+
fs.writeFileSync(OPENCLAW_DELIVERY_PATH, JSON.stringify(payload, null, 2), "utf8");
|
|
115
|
+
persistOpenclawDeliveryList(payload);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
persistOpenclawDeliveryList(payload);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
/** message_received 备用:部分通道 inbound_claim 字段不全 */
|
|
122
|
+
export function trySaveOpenclawDeliveryFromMessageHook(event, ctx) {
|
|
123
|
+
const target = ctx.conversationId?.trim() || event.from?.trim();
|
|
124
|
+
const channel = ctx.channelId?.trim();
|
|
125
|
+
if (!target || !channel)
|
|
126
|
+
return false;
|
|
127
|
+
const payload = {
|
|
128
|
+
channel,
|
|
129
|
+
target,
|
|
130
|
+
...(ctx.accountId?.trim() ? { accountId: ctx.accountId.trim() } : {}),
|
|
131
|
+
};
|
|
132
|
+
if (!fs.existsSync(OPENCLAW_DELIVERY_PATH)) {
|
|
133
|
+
fs.mkdirSync(path.dirname(OPENCLAW_DELIVERY_PATH), { recursive: true });
|
|
134
|
+
fs.writeFileSync(OPENCLAW_DELIVERY_PATH, JSON.stringify(payload, null, 2), "utf8");
|
|
135
|
+
persistOpenclawDeliveryList(payload);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
persistOpenclawDeliveryList(payload);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { OPENCLAW_DELIVERY_PATH, bindDeliveryFromPluginCommand, trySaveOpenclawDeliveryFromInboundClaim, trySaveOpenclawDeliveryFromMessageHook, } from "./delivery.js";
|
|
7
|
+
import { startImageServerAtRoot } from "./start-image-server.js";
|
|
8
|
+
import { startLocalAutoAtRoot } from "./start-local-auto.js";
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const FARM_IMAGE_PATH = path.join(os.homedir(), ".grinders-farm", "farm.png");
|
|
11
|
+
const OPENCLAW_MEDIA_DIR = path.join(os.homedir(), ".openclaw", "media", "grinders-farm");
|
|
12
|
+
const ONESHOT_RELATIVE_PATH = path.join("src", "adapters", "oneshot.ts");
|
|
13
|
+
const DEFAULT_START_INTERVAL_SEC = 20;
|
|
14
|
+
function resolveGameRoot(api) {
|
|
15
|
+
const raw = api.pluginConfig;
|
|
16
|
+
const fromEnv = process.env.GRINDERS_FARM_ROOT?.trim();
|
|
17
|
+
if (fromEnv)
|
|
18
|
+
return path.resolve(fromEnv);
|
|
19
|
+
if (raw?.gameRoot?.trim())
|
|
20
|
+
return path.resolve(raw.gameRoot.trim());
|
|
21
|
+
return path.resolve(__dirname, "..");
|
|
22
|
+
}
|
|
23
|
+
function isValidGameRoot(candidate) {
|
|
24
|
+
return fs.existsSync(path.join(candidate, ONESHOT_RELATIVE_PATH));
|
|
25
|
+
}
|
|
26
|
+
function resolveClawFarmCliBin() {
|
|
27
|
+
const fromEnv = process.env.GRINDERS_FARM_CLI_BIN?.trim();
|
|
28
|
+
if (fromEnv)
|
|
29
|
+
return fromEnv;
|
|
30
|
+
try {
|
|
31
|
+
const which = spawnSync("sh", ["-lc", "command -v grinders-farm"], {
|
|
32
|
+
encoding: "utf8",
|
|
33
|
+
maxBuffer: 1024 * 1024,
|
|
34
|
+
});
|
|
35
|
+
const out = (which.stdout ?? "").trim();
|
|
36
|
+
if (out)
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// ignore
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
function resolveRuntimeMode(api) {
|
|
45
|
+
const gameRoot = resolveGameRoot(api);
|
|
46
|
+
if (isValidGameRoot(gameRoot)) {
|
|
47
|
+
return { kind: "repo-tsx", gameRoot };
|
|
48
|
+
}
|
|
49
|
+
const bin = resolveClawFarmCliBin();
|
|
50
|
+
if (bin) {
|
|
51
|
+
return { kind: "cli-bin", bin };
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
function getPluginTiming(api) {
|
|
56
|
+
const c = api.pluginConfig;
|
|
57
|
+
const autoBoot = c?.autoStartWorkerOnGatewayBoot !== false;
|
|
58
|
+
const imageServerPort = typeof c?.imageServerPort === "number" && c.imageServerPort >= 1 ? c.imageServerPort : 18931;
|
|
59
|
+
return { autoBoot, imageServerPort };
|
|
60
|
+
}
|
|
61
|
+
function runOneshot(mode, argsLine) {
|
|
62
|
+
const argv = argsLine.trim().length > 0 ? argsLine.trim().split(/\s+/) : ["farm"];
|
|
63
|
+
const resolvedOpenclawBin = resolveOpenclawBin();
|
|
64
|
+
const result = mode.kind === "repo-tsx"
|
|
65
|
+
? spawnSync("npx", ["tsx", path.join(mode.gameRoot, ONESHOT_RELATIVE_PATH), ...argv], {
|
|
66
|
+
cwd: mode.gameRoot,
|
|
67
|
+
encoding: "utf8",
|
|
68
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
69
|
+
env: {
|
|
70
|
+
...process.env,
|
|
71
|
+
OPENCLAW_BIN: process.env.OPENCLAW_BIN?.trim() || resolvedOpenclawBin,
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
: spawnSync(mode.bin, argv, {
|
|
75
|
+
encoding: "utf8",
|
|
76
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
77
|
+
env: {
|
|
78
|
+
...process.env,
|
|
79
|
+
OPENCLAW_BIN: process.env.OPENCLAW_BIN?.trim() || resolvedOpenclawBin,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
const stderr = result.stderr?.trim() ?? "";
|
|
83
|
+
const stdout = result.stdout?.trim() ?? "";
|
|
84
|
+
const combined = [stdout, stderr].filter(Boolean).join("\n");
|
|
85
|
+
const code = result.status ?? (result.error ? 1 : 0);
|
|
86
|
+
return { text: combined || (result.error ? String(result.error.message) : "(no output)"), exitCode: code };
|
|
87
|
+
}
|
|
88
|
+
function isTelegramChannel(ctx) {
|
|
89
|
+
const surface = (ctx.channel ?? ctx.channelId ?? "").toString().trim().toLowerCase();
|
|
90
|
+
return surface === "tg" || surface.includes("telegram");
|
|
91
|
+
}
|
|
92
|
+
function canAttachFarmImage() {
|
|
93
|
+
return fs.existsSync(FARM_IMAGE_PATH);
|
|
94
|
+
}
|
|
95
|
+
function shouldAttachFarmImageForReply(text) {
|
|
96
|
+
// Only attach image when oneshot output explicitly includes farm image link.
|
|
97
|
+
// This avoids stale farm.png being sent for text-only commands like `shop`.
|
|
98
|
+
return text.includes("🖼 查看高清像素图:");
|
|
99
|
+
}
|
|
100
|
+
function stageFarmImageForOpenclaw() {
|
|
101
|
+
try {
|
|
102
|
+
fs.mkdirSync(OPENCLAW_MEDIA_DIR, { recursive: true });
|
|
103
|
+
const staged = path.join(OPENCLAW_MEDIA_DIR, `farm-${Date.now()}.png`);
|
|
104
|
+
fs.copyFileSync(FARM_IMAGE_PATH, staged);
|
|
105
|
+
return staged;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function resolveOpenclawBin() {
|
|
112
|
+
const fromEnv = process.env.OPENCLAW_BIN?.trim();
|
|
113
|
+
if (fromEnv && fromEnv.length > 0)
|
|
114
|
+
return fromEnv;
|
|
115
|
+
try {
|
|
116
|
+
const which = spawnSync("sh", ["-lc", "command -v openclaw"], {
|
|
117
|
+
encoding: "utf8",
|
|
118
|
+
maxBuffer: 1024 * 1024,
|
|
119
|
+
});
|
|
120
|
+
const fromWhich = (which.stdout ?? "").trim();
|
|
121
|
+
if (fromWhich)
|
|
122
|
+
return fromWhich;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// keep trying
|
|
126
|
+
}
|
|
127
|
+
const candidates = [
|
|
128
|
+
process.env.OPENCLAW_PATH?.trim(),
|
|
129
|
+
process.env.NVM_BIN ? path.join(process.env.NVM_BIN, "openclaw") : "",
|
|
130
|
+
path.join(path.dirname(process.execPath), "openclaw"),
|
|
131
|
+
"/opt/homebrew/bin/openclaw",
|
|
132
|
+
"/usr/local/bin/openclaw",
|
|
133
|
+
]
|
|
134
|
+
.map((p) => (p ?? "").trim())
|
|
135
|
+
.filter(Boolean);
|
|
136
|
+
for (const p of candidates) {
|
|
137
|
+
try {
|
|
138
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
139
|
+
return p;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// keep trying
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return "openclaw";
|
|
146
|
+
}
|
|
147
|
+
function resolveDeliveryFallback() {
|
|
148
|
+
try {
|
|
149
|
+
if (!fs.existsSync(OPENCLAW_DELIVERY_PATH))
|
|
150
|
+
return {};
|
|
151
|
+
const raw = JSON.parse(fs.readFileSync(OPENCLAW_DELIVERY_PATH, "utf8"));
|
|
152
|
+
const target = typeof raw.target === "string" ? raw.target.trim() : "";
|
|
153
|
+
const accountId = typeof raw.accountId === "string" ? raw.accountId.trim() : "";
|
|
154
|
+
const channel = typeof raw.channel === "string" ? raw.channel.trim() : "";
|
|
155
|
+
const threadId = typeof raw.threadId === "string" || typeof raw.threadId === "number" ? raw.threadId : undefined;
|
|
156
|
+
return {
|
|
157
|
+
...(target ? { target } : {}),
|
|
158
|
+
...(accountId ? { accountId } : {}),
|
|
159
|
+
...(channel ? { channel } : {}),
|
|
160
|
+
...(threadId != null ? { threadId } : {}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return {};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function buildTelegramImageCaption(text) {
|
|
168
|
+
const lines = text
|
|
169
|
+
.split("\n")
|
|
170
|
+
.map((line) => line.trim())
|
|
171
|
+
.filter((line) => line.length > 0);
|
|
172
|
+
const nonTableLines = lines.filter((line) => !line.startsWith("|"));
|
|
173
|
+
const title = nonTableLines[0] ?? "🌾 Grinder's Farm";
|
|
174
|
+
const orderDoneLine = nonTableLines.find((line) => line.startsWith("✅ 完成订单:"));
|
|
175
|
+
const orderLine = nonTableLines.find((line) => line.startsWith("📦 订单:"));
|
|
176
|
+
const marketLine = nonTableLines.find((line) => line.startsWith("📉 市场反馈:"));
|
|
177
|
+
const parts = [title, orderDoneLine, orderLine, marketLine].filter(Boolean);
|
|
178
|
+
return parts.join("\n");
|
|
179
|
+
}
|
|
180
|
+
function trySendTelegramFarmImage(api, ctx, caption) {
|
|
181
|
+
const fallback = resolveDeliveryFallback();
|
|
182
|
+
const target = (ctx.to ?? ctx.from ?? ctx.senderId ?? fallback.target)?.toString().trim();
|
|
183
|
+
if (!target) {
|
|
184
|
+
api.logger?.warn?.("grinders-farm: skip telegram image send: missing target.");
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
if (!canAttachFarmImage()) {
|
|
188
|
+
api.logger?.warn?.(`grinders-farm: skip telegram image send: image not found (${FARM_IMAGE_PATH}).`);
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
const stagedImagePath = stageFarmImageForOpenclaw();
|
|
192
|
+
if (!stagedImagePath) {
|
|
193
|
+
api.logger?.warn?.("grinders-farm: skip telegram image send: failed to stage image into ~/.openclaw/media.");
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
const args = ["message", "send", "--channel", "telegram", "--target", target, "--message", caption];
|
|
197
|
+
const accountId = ctx.accountId?.trim() || fallback.accountId;
|
|
198
|
+
if (accountId)
|
|
199
|
+
args.push("--account", accountId);
|
|
200
|
+
const threadId = ctx.messageThreadId ?? fallback.threadId;
|
|
201
|
+
if (threadId != null)
|
|
202
|
+
args.push("--thread-id", String(threadId));
|
|
203
|
+
args.push("--media", stagedImagePath);
|
|
204
|
+
const r = spawnSync(resolveOpenclawBin(), args, {
|
|
205
|
+
encoding: "utf8",
|
|
206
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
207
|
+
env: { ...process.env },
|
|
208
|
+
});
|
|
209
|
+
if (r.error) {
|
|
210
|
+
api.logger?.warn?.(`grinders-farm: telegram image send error: ${String(r.error.message)}`);
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
const code = r.status ?? 0;
|
|
214
|
+
if (code !== 0) {
|
|
215
|
+
const details = [r.stdout, r.stderr].filter(Boolean).join("\n").trim();
|
|
216
|
+
api.logger?.warn?.(`grinders-farm: telegram image send failed (exit ${code}) ${details || "(no output)"}`);
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
function createFarmTool(mode) {
|
|
222
|
+
return {
|
|
223
|
+
name: "grinders_farm",
|
|
224
|
+
label: "Grinder's Farm",
|
|
225
|
+
ownerOnly: false,
|
|
226
|
+
description: "Run grinders-farm one-shot CLI. Used by SKILL command-dispatch; params include `command` (raw args after /grinders_farm).",
|
|
227
|
+
parameters: {
|
|
228
|
+
type: "object",
|
|
229
|
+
additionalProperties: true,
|
|
230
|
+
properties: {
|
|
231
|
+
command: { type: "string", description: "Raw args after /grinders_farm, e.g. plant carrot A1" },
|
|
232
|
+
commandName: { type: "string" },
|
|
233
|
+
skillName: { type: "string" },
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
execute: async (_toolCallId, params) => {
|
|
237
|
+
const raw = typeof params.command === "string" ? params.command : "";
|
|
238
|
+
const { text, exitCode } = runOneshot(mode, raw);
|
|
239
|
+
return {
|
|
240
|
+
content: [{ type: "text", text }],
|
|
241
|
+
details: { exitCode },
|
|
242
|
+
isError: exitCode !== 0,
|
|
243
|
+
};
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const plugin = {
|
|
248
|
+
id: "grinders-farm",
|
|
249
|
+
name: "Grinder's Farm",
|
|
250
|
+
description: "Local grinders-farm subprocess only; no LLM in the game path.",
|
|
251
|
+
register(api) {
|
|
252
|
+
const runtime = resolveRuntimeMode(api);
|
|
253
|
+
if (!runtime) {
|
|
254
|
+
api.logger?.error?.("grinders-farm: 无法定位运行时。请任选其一:1) 在插件 config 里设置 gameRoot(含 src/adapters/oneshot.ts);2) 安装 grinders-farm CLI 并加入 PATH;3) 设置 GRINDERS_FARM_ROOT / GRINDERS_FARM_CLI_BIN。");
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
api.registerTool(createFarmTool(runtime));
|
|
258
|
+
api.registerCommand({
|
|
259
|
+
name: "farm",
|
|
260
|
+
description: "Grinder's Farm — run a game command without invoking the model (fast path). Binds this chat for farm push.",
|
|
261
|
+
acceptsArgs: true,
|
|
262
|
+
handler: async (ctx) => {
|
|
263
|
+
const bound = await bindDeliveryFromPluginCommand(ctx);
|
|
264
|
+
if (!bound) {
|
|
265
|
+
api.logger?.warn?.("grinders-farm: could not bind delivery (need channel + senderId/to/from, or conversation binding).");
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
const surface = (ctx.channel ?? ctx.channelId)?.toString().toLowerCase() ?? "";
|
|
269
|
+
if (surface === "webchat") {
|
|
270
|
+
api.logger?.info?.("grinders-farm: Web 控制端已绑定;自动推送使用 Gateway chat.inject(默认会话 agent:<agentId>:main)。");
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const { text, exitCode } = runOneshot(runtime, ctx.args ?? "");
|
|
274
|
+
const fallback = resolveDeliveryFallback();
|
|
275
|
+
const telegramLikeChannel = isTelegramChannel(ctx) || (fallback.channel?.toLowerCase().includes("telegram") ?? false);
|
|
276
|
+
if (exitCode === 0 && telegramLikeChannel && shouldAttachFarmImageForReply(text) && canAttachFarmImage()) {
|
|
277
|
+
const stagedImagePath = stageFarmImageForOpenclaw();
|
|
278
|
+
if (stagedImagePath) {
|
|
279
|
+
// Prefer native command reply media to avoid fallback-to-table behavior
|
|
280
|
+
// in newer OpenClaw versions when command replies are text-only.
|
|
281
|
+
return {
|
|
282
|
+
text: buildTelegramImageCaption(text),
|
|
283
|
+
mediaUrls: [stagedImagePath],
|
|
284
|
+
isError: false,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
api.logger?.warn?.("grinders-farm: skip telegram inline media reply: failed to stage image.");
|
|
288
|
+
}
|
|
289
|
+
return { text, isError: exitCode !== 0 };
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
api.on("gateway_start", async () => {
|
|
293
|
+
const { autoBoot, imageServerPort } = getPluginTiming(api);
|
|
294
|
+
if (runtime.kind !== "repo-tsx") {
|
|
295
|
+
if (!autoBoot)
|
|
296
|
+
return;
|
|
297
|
+
const autoStart = runOneshot(runtime, "start");
|
|
298
|
+
if (autoStart.exitCode === 0) {
|
|
299
|
+
api.logger?.info?.("grinders-farm: 已通过 CLI 模式启动自动推进(固定 20s)");
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
api.logger?.warn?.(`grinders-farm: CLI 模式自动推进启动失败:${autoStart.text}`);
|
|
303
|
+
}
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
const imageServer = await startImageServerAtRoot(runtime.gameRoot, imageServerPort);
|
|
308
|
+
if (imageServer.success) {
|
|
309
|
+
api.logger?.info?.(`grinders-farm: ${imageServer.message}`);
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
api.logger?.warn?.(`grinders-farm: gateway_start image-server: ${imageServer.message}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
catch (e) {
|
|
316
|
+
api.logger?.warn?.(`grinders-farm: gateway_start image-server: ${String(e)}`);
|
|
317
|
+
}
|
|
318
|
+
if (!autoBoot)
|
|
319
|
+
return;
|
|
320
|
+
try {
|
|
321
|
+
const r = await startLocalAutoAtRoot(runtime.gameRoot, DEFAULT_START_INTERVAL_SEC);
|
|
322
|
+
if (r.success) {
|
|
323
|
+
api.logger?.info?.(`grinders-farm: ${r.message}`);
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
api.logger?.warn?.(`grinders-farm: gateway_start worker: ${r.message}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch (e) {
|
|
330
|
+
api.logger?.warn?.(`grinders-farm: gateway_start: ${String(e)}`);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
api.on("inbound_claim", async (event, ctx) => {
|
|
334
|
+
const saved = trySaveOpenclawDeliveryFromInboundClaim({
|
|
335
|
+
channel: event.channel,
|
|
336
|
+
accountId: event.accountId ?? ctx.accountId,
|
|
337
|
+
conversationId: event.conversationId ?? ctx.conversationId,
|
|
338
|
+
senderId: event.senderId ?? ctx.senderId,
|
|
339
|
+
threadId: event.threadId,
|
|
340
|
+
});
|
|
341
|
+
if (saved) {
|
|
342
|
+
api.logger?.info?.("grinders-farm: wrote openclaw-delivery.json (inbound_claim)");
|
|
343
|
+
}
|
|
344
|
+
return { handled: false };
|
|
345
|
+
});
|
|
346
|
+
api.on("message_received", async (event, ctx) => {
|
|
347
|
+
const saved = trySaveOpenclawDeliveryFromMessageHook(event, ctx);
|
|
348
|
+
if (saved) {
|
|
349
|
+
api.logger?.info?.("grinders-farm: wrote openclaw-delivery.json (message_received)");
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
export default plugin;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
const DATA_DIR = path.join(os.homedir(), ".grinders-farm");
|
|
6
|
+
const PID_FILE = path.join(DATA_DIR, "image-server.pid");
|
|
7
|
+
const INFO_FILE = path.join(DATA_DIR, "image-server.json");
|
|
8
|
+
function isProcessRunning(pid) {
|
|
9
|
+
try {
|
|
10
|
+
process.kill(pid, 0);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function removeStalePidFile() {
|
|
18
|
+
if (!fs.existsSync(PID_FILE))
|
|
19
|
+
return;
|
|
20
|
+
const raw = fs.readFileSync(PID_FILE, "utf8").trim();
|
|
21
|
+
const pid = Number.parseInt(raw, 10);
|
|
22
|
+
if (!Number.isFinite(pid) || !isProcessRunning(pid)) {
|
|
23
|
+
try {
|
|
24
|
+
fs.unlinkSync(PID_FILE);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
/* ignore */
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
if (fs.existsSync(INFO_FILE))
|
|
31
|
+
fs.unlinkSync(INFO_FILE);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* ignore */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function tryGetBaseUrlFromInfoFile() {
|
|
39
|
+
if (!fs.existsSync(INFO_FILE))
|
|
40
|
+
return null;
|
|
41
|
+
try {
|
|
42
|
+
const raw = JSON.parse(fs.readFileSync(INFO_FILE, "utf8"));
|
|
43
|
+
return typeof raw.baseUrl === "string" && raw.baseUrl.trim() ? raw.baseUrl.trim() : null;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export async function startImageServerAtRoot(gameRoot, preferredPort) {
|
|
50
|
+
removeStalePidFile();
|
|
51
|
+
if (fs.existsSync(PID_FILE)) {
|
|
52
|
+
const pid = Number.parseInt(fs.readFileSync(PID_FILE, "utf8").trim(), 10);
|
|
53
|
+
if (Number.isFinite(pid) && isProcessRunning(pid)) {
|
|
54
|
+
const baseUrl = tryGetBaseUrlFromInfoFile();
|
|
55
|
+
return { success: true, message: `本地图片服务已在运行 (PID ${pid})${baseUrl ? ` · ${baseUrl}` : ""}` };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const worker = path.join(gameRoot, "src/adapters/image-server.ts");
|
|
59
|
+
const port = Number.isFinite(preferredPort) && preferredPort > 0 ? preferredPort : 18931;
|
|
60
|
+
const child = spawn("npx", ["tsx", worker, String(port)], {
|
|
61
|
+
cwd: gameRoot,
|
|
62
|
+
detached: true,
|
|
63
|
+
stdio: "ignore",
|
|
64
|
+
});
|
|
65
|
+
child.unref();
|
|
66
|
+
for (let i = 0; i < 60; i++) {
|
|
67
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
68
|
+
if (fs.existsSync(INFO_FILE)) {
|
|
69
|
+
const pid = fs.existsSync(PID_FILE) ? fs.readFileSync(PID_FILE, "utf8").trim() : "?";
|
|
70
|
+
const baseUrl = tryGetBaseUrlFromInfoFile();
|
|
71
|
+
return {
|
|
72
|
+
success: true,
|
|
73
|
+
message: `本地图片服务已启动 (PID ${pid})${baseUrl ? ` · ${baseUrl}` : ""}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
success: false,
|
|
79
|
+
message: "本地图片服务未就绪:请确认 gameRoot 指向 grinders-farm 仓库且可运行 npx tsx src/adapters/image-server.ts",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 不 import ../src/local-auto:插件装在 extensions 时 ../src 不存在。
|
|
3
|
+
*/
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as os from "node:os";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
const DATA_DIR = path.join(os.homedir(), ".grinders-farm");
|
|
9
|
+
const AUTO_PID_FILE = path.join(DATA_DIR, "auto.pid");
|
|
10
|
+
const AUTO_CONFIG_FILE = path.join(DATA_DIR, "auto.config.json");
|
|
11
|
+
function isProcessRunning(pid) {
|
|
12
|
+
try {
|
|
13
|
+
process.kill(pid, 0);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function listRunningAutoWorkerPids() {
|
|
21
|
+
try {
|
|
22
|
+
const ps = spawnSync("ps", ["-Ao", "pid,command"], {
|
|
23
|
+
encoding: "utf8",
|
|
24
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
25
|
+
});
|
|
26
|
+
const out = (ps.stdout ?? "").trim();
|
|
27
|
+
if (!out)
|
|
28
|
+
return [];
|
|
29
|
+
const pids = new Set();
|
|
30
|
+
for (const line of out.split("\n")) {
|
|
31
|
+
if (!line.includes("src/adapters/auto-worker.ts"))
|
|
32
|
+
continue;
|
|
33
|
+
const m = line.trim().match(/^(\d+)\s+/);
|
|
34
|
+
if (!m)
|
|
35
|
+
continue;
|
|
36
|
+
const pid = parseInt(m[1], 10);
|
|
37
|
+
if (!Number.isFinite(pid))
|
|
38
|
+
continue;
|
|
39
|
+
pids.add(pid);
|
|
40
|
+
}
|
|
41
|
+
return Array.from(pids.values()).sort((a, b) => a - b);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function resolveOpenclawBinForWorker() {
|
|
48
|
+
const fromEnv = process.env.OPENCLAW_BIN?.trim();
|
|
49
|
+
if (fromEnv)
|
|
50
|
+
return fromEnv;
|
|
51
|
+
const which = spawnSync("sh", ["-lc", "command -v openclaw"], {
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
maxBuffer: 1024 * 1024,
|
|
54
|
+
});
|
|
55
|
+
const fromWhich = (which.stdout ?? "").trim();
|
|
56
|
+
if (fromWhich)
|
|
57
|
+
return fromWhich;
|
|
58
|
+
const candidates = [
|
|
59
|
+
process.env.OPENCLAW_PATH?.trim(),
|
|
60
|
+
process.env.NVM_BIN ? path.join(process.env.NVM_BIN, "openclaw") : "",
|
|
61
|
+
"/opt/homebrew/bin/openclaw",
|
|
62
|
+
"/usr/local/bin/openclaw",
|
|
63
|
+
]
|
|
64
|
+
.map((p) => (p ?? "").trim())
|
|
65
|
+
.filter(Boolean);
|
|
66
|
+
for (const p of candidates) {
|
|
67
|
+
try {
|
|
68
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
69
|
+
return p;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// keep trying
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
function removeStalePidFile() {
|
|
78
|
+
if (!fs.existsSync(AUTO_PID_FILE))
|
|
79
|
+
return;
|
|
80
|
+
const raw = fs.readFileSync(AUTO_PID_FILE, "utf8").trim();
|
|
81
|
+
const pid = parseInt(raw, 10);
|
|
82
|
+
if (!Number.isFinite(pid) || !isProcessRunning(pid)) {
|
|
83
|
+
try {
|
|
84
|
+
fs.unlinkSync(AUTO_PID_FILE);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* ignore */
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
if (fs.existsSync(AUTO_CONFIG_FILE))
|
|
91
|
+
fs.unlinkSync(AUTO_CONFIG_FILE);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
/* ignore */
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export async function startLocalAutoAtRoot(gameRoot, intervalSec) {
|
|
99
|
+
removeStalePidFile();
|
|
100
|
+
const runningPids = listRunningAutoWorkerPids();
|
|
101
|
+
if (runningPids.length > 0) {
|
|
102
|
+
return { success: false, message: `自动推进已在运行中 (PID ${runningPids.join(", ")})` };
|
|
103
|
+
}
|
|
104
|
+
if (fs.existsSync(AUTO_PID_FILE)) {
|
|
105
|
+
const pid = parseInt(fs.readFileSync(AUTO_PID_FILE, "utf8").trim(), 10);
|
|
106
|
+
if (Number.isFinite(pid) && isProcessRunning(pid)) {
|
|
107
|
+
return { success: false, message: `自动推进已在运行中 (PID ${pid})` };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const worker = path.join(gameRoot, "src/adapters/auto-worker.ts");
|
|
111
|
+
const resolvedOpenclawBin = resolveOpenclawBinForWorker();
|
|
112
|
+
const child = spawn("npx", ["tsx", worker, String(intervalSec)], {
|
|
113
|
+
cwd: gameRoot,
|
|
114
|
+
detached: true,
|
|
115
|
+
stdio: "ignore",
|
|
116
|
+
env: {
|
|
117
|
+
...process.env,
|
|
118
|
+
...(resolvedOpenclawBin ? { OPENCLAW_BIN: resolvedOpenclawBin } : {}),
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
child.unref();
|
|
122
|
+
for (let i = 0; i < 50; i++) {
|
|
123
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
124
|
+
if (fs.existsSync(AUTO_PID_FILE)) {
|
|
125
|
+
const pid = fs.readFileSync(AUTO_PID_FILE, "utf8").trim();
|
|
126
|
+
const human = intervalSec >= 3600 ? `每 ${Math.round(intervalSec / 3600)} 小时` : `每 ${intervalSec} 秒`;
|
|
127
|
+
return {
|
|
128
|
+
success: true,
|
|
129
|
+
message: `本地 auto-worker 已启动 · ${human} · PID ${pid}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
success: false,
|
|
135
|
+
message: "auto-worker 未就绪:请确认 gameRoot 指向 grinders-farm 仓库且可运行 npx tsx src/adapters/auto-worker.ts",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "grinders-farm",
|
|
3
|
+
"name": "Grinder's Farm",
|
|
4
|
+
"description": "Local grinders-farm plugin: /farm and grinders_farm run deterministic local game logic (no LLM, no remote backend).",
|
|
5
|
+
"configSchema": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"gameRoot": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"description": "Path to grinders-farm repo root (contains src/adapters/oneshot.ts). Optional when `grinders-farm` CLI is already in PATH or `GRINDERS_FARM_ROOT` is set."
|
|
12
|
+
},
|
|
13
|
+
"autoStartWorkerOnGatewayBoot": {
|
|
14
|
+
"type": "boolean",
|
|
15
|
+
"description": "If true (default), auto-start background day-advance worker on Gateway boot. Set false to start manually."
|
|
16
|
+
},
|
|
17
|
+
"imageServerPort": {
|
|
18
|
+
"type": "number",
|
|
19
|
+
"description": "Local HTTP image server port for farm.png links (default 18931, listens on 127.0.0.1 only)."
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-plugin-grinders-farm",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "OpenClaw plugin for Grinder's Farm: deterministic /farm command and grinders_farm tool.",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc -p tsconfig.json",
|
|
11
|
+
"prepack": "npm run build"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist/",
|
|
15
|
+
"openclaw.plugin.json",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^24.3.0",
|
|
21
|
+
"typescript": "^5.9.2"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"openclaw": ">=2026.1.0"
|
|
25
|
+
},
|
|
26
|
+
"openclaw": {
|
|
27
|
+
"extensions": ["./dist/index.js"]
|
|
28
|
+
}
|
|
29
|
+
}
|