@openclaw/lobster 2026.1.29
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/README.md +80 -0
- package/SKILL.md +90 -0
- package/index.ts +13 -0
- package/openclaw.plugin.json +10 -0
- package/package.json +11 -0
- package/src/lobster-tool.test.ts +143 -0
- package/src/lobster-tool.ts +240 -0
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Lobster (plugin)
|
|
2
|
+
|
|
3
|
+
Adds the `lobster` agent tool as an **optional** plugin tool.
|
|
4
|
+
|
|
5
|
+
## What this is
|
|
6
|
+
|
|
7
|
+
- Lobster is a standalone workflow shell (typed JSON-first pipelines + approvals/resume).
|
|
8
|
+
- This plugin integrates Lobster with OpenClaw *without core changes*.
|
|
9
|
+
|
|
10
|
+
## Enable
|
|
11
|
+
|
|
12
|
+
Because this tool can trigger side effects (via workflows), it is registered with `optional: true`.
|
|
13
|
+
|
|
14
|
+
Enable it in an agent allowlist:
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"agents": {
|
|
19
|
+
"list": [
|
|
20
|
+
{
|
|
21
|
+
"id": "main",
|
|
22
|
+
"tools": {
|
|
23
|
+
"allow": [
|
|
24
|
+
"lobster" // plugin id (enables all tools from this plugin)
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Using `openclaw.invoke` (Lobster → OpenClaw tools)
|
|
34
|
+
|
|
35
|
+
Some Lobster pipelines may include a `openclaw.invoke` step to call back into OpenClaw tools/plugins (for example: `gog` for Google Workspace, `gh` for GitHub, `message.send`, etc.).
|
|
36
|
+
|
|
37
|
+
For this to work, the OpenClaw Gateway must expose the tool bridge endpoint and the target tool must be allowed by policy:
|
|
38
|
+
|
|
39
|
+
- OpenClaw provides an HTTP endpoint: `POST /tools/invoke`.
|
|
40
|
+
- The request is gated by **gateway auth** (e.g. `Authorization: Bearer …` when token auth is enabled).
|
|
41
|
+
- The invoked tool is gated by **tool policy** (global + per-agent + provider + group policy). If the tool is not allowed, OpenClaw returns `404 Tool not available`.
|
|
42
|
+
|
|
43
|
+
### Allowlisting recommended
|
|
44
|
+
|
|
45
|
+
To avoid letting workflows call arbitrary tools, set a tight allowlist on the agent that will be used by `openclaw.invoke`.
|
|
46
|
+
|
|
47
|
+
Example (allow only a small set of tools):
|
|
48
|
+
|
|
49
|
+
```jsonc
|
|
50
|
+
{
|
|
51
|
+
"agents": {
|
|
52
|
+
"list": [
|
|
53
|
+
{
|
|
54
|
+
"id": "main",
|
|
55
|
+
"tools": {
|
|
56
|
+
"allow": [
|
|
57
|
+
"lobster",
|
|
58
|
+
"web_fetch",
|
|
59
|
+
"web_search",
|
|
60
|
+
"gog",
|
|
61
|
+
"gh"
|
|
62
|
+
],
|
|
63
|
+
"deny": ["gateway"]
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Notes:
|
|
72
|
+
- If `tools.allow` is omitted or empty, it behaves like "allow everything (except denied)". For a real allowlist, set a **non-empty** `allow`.
|
|
73
|
+
- Tool names depend on which plugins you have installed/enabled.
|
|
74
|
+
|
|
75
|
+
## Security
|
|
76
|
+
|
|
77
|
+
- Runs the `lobster` executable as a local subprocess.
|
|
78
|
+
- Does not manage OAuth/tokens.
|
|
79
|
+
- Uses timeouts, stdout caps, and strict JSON envelope parsing.
|
|
80
|
+
- Prefer an absolute `lobsterPath` in production to avoid PATH hijack.
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Lobster
|
|
2
|
+
|
|
3
|
+
Lobster executes multi-step workflows with approval checkpoints. Use it when:
|
|
4
|
+
|
|
5
|
+
- User wants a repeatable automation (triage, monitor, sync)
|
|
6
|
+
- Actions need human approval before executing (send, post, delete)
|
|
7
|
+
- Multiple tool calls should run as one deterministic operation
|
|
8
|
+
|
|
9
|
+
## When to use Lobster
|
|
10
|
+
|
|
11
|
+
| User intent | Use Lobster? |
|
|
12
|
+
|-------------|--------------|
|
|
13
|
+
| "Triage my email" | Yes — multi-step, may send replies |
|
|
14
|
+
| "Send a message" | No — single action, use message tool directly |
|
|
15
|
+
| "Check my email every morning and ask before replying" | Yes — scheduled workflow with approval |
|
|
16
|
+
| "What's the weather?" | No — simple query |
|
|
17
|
+
| "Monitor this PR and notify me of changes" | Yes — stateful, recurring |
|
|
18
|
+
|
|
19
|
+
## Basic usage
|
|
20
|
+
|
|
21
|
+
### Run a pipeline
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"action": "run",
|
|
26
|
+
"pipeline": "gog.gmail.search --query 'newer_than:1d' --max 20 | email.triage"
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Returns structured result:
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"protocolVersion": 1,
|
|
34
|
+
"ok": true,
|
|
35
|
+
"status": "ok",
|
|
36
|
+
"output": [{ "summary": {...}, "items": [...] }],
|
|
37
|
+
"requiresApproval": null
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Handle approval
|
|
42
|
+
|
|
43
|
+
If the workflow needs approval:
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"status": "needs_approval",
|
|
47
|
+
"output": [],
|
|
48
|
+
"requiresApproval": {
|
|
49
|
+
"prompt": "Send 3 draft replies?",
|
|
50
|
+
"items": [...],
|
|
51
|
+
"resumeToken": "..."
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Present the prompt to the user. If they approve:
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"action": "resume",
|
|
60
|
+
"token": "<resumeToken>",
|
|
61
|
+
"approve": true
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Example workflows
|
|
66
|
+
|
|
67
|
+
### Email triage
|
|
68
|
+
```
|
|
69
|
+
gog.gmail.search --query 'newer_than:1d' --max 20 | email.triage
|
|
70
|
+
```
|
|
71
|
+
Fetches recent emails, classifies into buckets (needs_reply, needs_action, fyi).
|
|
72
|
+
|
|
73
|
+
### Email triage with approval gate
|
|
74
|
+
```
|
|
75
|
+
gog.gmail.search --query 'newer_than:1d' | email.triage | approve --prompt 'Process these?'
|
|
76
|
+
```
|
|
77
|
+
Same as above, but halts for approval before returning.
|
|
78
|
+
|
|
79
|
+
## Key behaviors
|
|
80
|
+
|
|
81
|
+
- **Deterministic**: Same input → same output (no LLM variance in pipeline execution)
|
|
82
|
+
- **Approval gates**: `approve` command halts execution, returns token
|
|
83
|
+
- **Resumable**: Use `resume` action with token to continue
|
|
84
|
+
- **Structured output**: Always returns JSON envelope with `protocolVersion`
|
|
85
|
+
|
|
86
|
+
## Don't use Lobster for
|
|
87
|
+
|
|
88
|
+
- Simple single-action requests (just use the tool directly)
|
|
89
|
+
- Queries that need LLM interpretation mid-flow
|
|
90
|
+
- One-off tasks that won't be repeated
|
package/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "../../src/plugins/types.js";
|
|
2
|
+
|
|
3
|
+
import { createLobsterTool } from "./src/lobster-tool.js";
|
|
4
|
+
|
|
5
|
+
export default function register(api: OpenClawPluginApi) {
|
|
6
|
+
api.registerTool(
|
|
7
|
+
(ctx) => {
|
|
8
|
+
if (ctx.sandboxed) return null;
|
|
9
|
+
return createLobsterTool(api);
|
|
10
|
+
},
|
|
11
|
+
{ optional: true },
|
|
12
|
+
);
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../../../src/plugins/types.js";
|
|
8
|
+
import { createLobsterTool } from "./lobster-tool.js";
|
|
9
|
+
|
|
10
|
+
async function writeFakeLobsterScript(scriptBody: string, prefix = "openclaw-lobster-plugin-") {
|
|
11
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
12
|
+
const isWindows = process.platform === "win32";
|
|
13
|
+
|
|
14
|
+
if (isWindows) {
|
|
15
|
+
const scriptPath = path.join(dir, "lobster.js");
|
|
16
|
+
const cmdPath = path.join(dir, "lobster.cmd");
|
|
17
|
+
await fs.writeFile(scriptPath, scriptBody, { encoding: "utf8" });
|
|
18
|
+
const cmd = `@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`;
|
|
19
|
+
await fs.writeFile(cmdPath, cmd, { encoding: "utf8" });
|
|
20
|
+
return { dir, binPath: cmdPath };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const binPath = path.join(dir, "lobster");
|
|
24
|
+
const file = `#!/usr/bin/env node\n${scriptBody}\n`;
|
|
25
|
+
await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 });
|
|
26
|
+
return { dir, binPath };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function writeFakeLobster(params: { payload: unknown }) {
|
|
30
|
+
const scriptBody =
|
|
31
|
+
`const payload = ${JSON.stringify(params.payload)};\n` +
|
|
32
|
+
`process.stdout.write(JSON.stringify(payload));\n`;
|
|
33
|
+
return await writeFakeLobsterScript(scriptBody);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fakeApi(): OpenClawPluginApi {
|
|
37
|
+
return {
|
|
38
|
+
id: "lobster",
|
|
39
|
+
name: "lobster",
|
|
40
|
+
source: "test",
|
|
41
|
+
config: {} as any,
|
|
42
|
+
runtime: { version: "test" } as any,
|
|
43
|
+
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
|
44
|
+
registerTool() {},
|
|
45
|
+
registerHttpHandler() {},
|
|
46
|
+
registerChannel() {},
|
|
47
|
+
registerGatewayMethod() {},
|
|
48
|
+
registerCli() {},
|
|
49
|
+
registerService() {},
|
|
50
|
+
registerProvider() {},
|
|
51
|
+
resolvePath: (p) => p,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function fakeCtx(overrides: Partial<OpenClawPluginToolContext> = {}): OpenClawPluginToolContext {
|
|
56
|
+
return {
|
|
57
|
+
config: {} as any,
|
|
58
|
+
workspaceDir: "/tmp",
|
|
59
|
+
agentDir: "/tmp",
|
|
60
|
+
agentId: "main",
|
|
61
|
+
sessionKey: "main",
|
|
62
|
+
messageChannel: undefined,
|
|
63
|
+
agentAccountId: undefined,
|
|
64
|
+
sandboxed: false,
|
|
65
|
+
...overrides,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("lobster plugin tool", () => {
|
|
70
|
+
it("runs lobster and returns parsed envelope in details", async () => {
|
|
71
|
+
const fake = await writeFakeLobster({
|
|
72
|
+
payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const tool = createLobsterTool(fakeApi());
|
|
76
|
+
const res = await tool.execute("call1", {
|
|
77
|
+
action: "run",
|
|
78
|
+
pipeline: "noop",
|
|
79
|
+
lobsterPath: fake.binPath,
|
|
80
|
+
timeoutMs: 1000,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(res.details).toMatchObject({ ok: true, status: "ok" });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("tolerates noisy stdout before the JSON envelope", async () => {
|
|
87
|
+
const payload = { ok: true, status: "ok", output: [], requiresApproval: null };
|
|
88
|
+
const { binPath } = await writeFakeLobsterScript(
|
|
89
|
+
`const payload = ${JSON.stringify(payload)};\n` +
|
|
90
|
+
`console.log("noise before json");\n` +
|
|
91
|
+
`process.stdout.write(JSON.stringify(payload));\n`,
|
|
92
|
+
"openclaw-lobster-plugin-noisy-",
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const tool = createLobsterTool(fakeApi());
|
|
96
|
+
const res = await tool.execute("call-noisy", {
|
|
97
|
+
action: "run",
|
|
98
|
+
pipeline: "noop",
|
|
99
|
+
lobsterPath: binPath,
|
|
100
|
+
timeoutMs: 1000,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(res.details).toMatchObject({ ok: true, status: "ok" });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("requires absolute lobsterPath when provided", async () => {
|
|
107
|
+
const tool = createLobsterTool(fakeApi());
|
|
108
|
+
await expect(
|
|
109
|
+
tool.execute("call2", {
|
|
110
|
+
action: "run",
|
|
111
|
+
pipeline: "noop",
|
|
112
|
+
lobsterPath: "./lobster",
|
|
113
|
+
}),
|
|
114
|
+
).rejects.toThrow(/absolute path/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("rejects invalid JSON from lobster", async () => {
|
|
118
|
+
const { binPath } = await writeFakeLobsterScript(
|
|
119
|
+
`process.stdout.write("nope");\n`,
|
|
120
|
+
"openclaw-lobster-plugin-bad-",
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const tool = createLobsterTool(fakeApi());
|
|
124
|
+
await expect(
|
|
125
|
+
tool.execute("call3", {
|
|
126
|
+
action: "run",
|
|
127
|
+
pipeline: "noop",
|
|
128
|
+
lobsterPath: binPath,
|
|
129
|
+
}),
|
|
130
|
+
).rejects.toThrow(/invalid JSON/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("can be gated off in sandboxed contexts", async () => {
|
|
134
|
+
const api = fakeApi();
|
|
135
|
+
const factoryTool = (ctx: OpenClawPluginToolContext) => {
|
|
136
|
+
if (ctx.sandboxed) return null;
|
|
137
|
+
return createLobsterTool(api);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
expect(factoryTool(fakeCtx({ sandboxed: true }))).toBeNull();
|
|
141
|
+
expect(factoryTool(fakeCtx({ sandboxed: false }))?.name).toBe("lobster");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { OpenClawPluginApi } from "../../../src/plugins/types.js";
|
|
6
|
+
|
|
7
|
+
type LobsterEnvelope =
|
|
8
|
+
| {
|
|
9
|
+
ok: true;
|
|
10
|
+
status: "ok" | "needs_approval" | "cancelled";
|
|
11
|
+
output: unknown[];
|
|
12
|
+
requiresApproval: null | {
|
|
13
|
+
type: "approval_request";
|
|
14
|
+
prompt: string;
|
|
15
|
+
items: unknown[];
|
|
16
|
+
resumeToken?: string;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
| {
|
|
20
|
+
ok: false;
|
|
21
|
+
error: { type?: string; message: string };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function resolveExecutablePath(lobsterPathRaw: string | undefined) {
|
|
25
|
+
const lobsterPath = lobsterPathRaw?.trim() || "lobster";
|
|
26
|
+
if (lobsterPath !== "lobster" && !path.isAbsolute(lobsterPath)) {
|
|
27
|
+
throw new Error("lobsterPath must be an absolute path (or omit to use PATH)");
|
|
28
|
+
}
|
|
29
|
+
return lobsterPath;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isWindowsSpawnEINVAL(err: unknown) {
|
|
33
|
+
if (!err || typeof err !== "object") return false;
|
|
34
|
+
const code = (err as { code?: unknown }).code;
|
|
35
|
+
return code === "EINVAL";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function runLobsterSubprocessOnce(
|
|
39
|
+
params: {
|
|
40
|
+
execPath: string;
|
|
41
|
+
argv: string[];
|
|
42
|
+
cwd: string;
|
|
43
|
+
timeoutMs: number;
|
|
44
|
+
maxStdoutBytes: number;
|
|
45
|
+
},
|
|
46
|
+
useShell: boolean,
|
|
47
|
+
) {
|
|
48
|
+
const { execPath, argv, cwd } = params;
|
|
49
|
+
const timeoutMs = Math.max(200, params.timeoutMs);
|
|
50
|
+
const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes);
|
|
51
|
+
|
|
52
|
+
const env = { ...process.env, LOBSTER_MODE: "tool" } as Record<string, string | undefined>;
|
|
53
|
+
const nodeOptions = env.NODE_OPTIONS ?? "";
|
|
54
|
+
if (nodeOptions.includes("--inspect")) {
|
|
55
|
+
delete env.NODE_OPTIONS;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return await new Promise<{ stdout: string }>((resolve, reject) => {
|
|
59
|
+
const child = spawn(execPath, argv, {
|
|
60
|
+
cwd,
|
|
61
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
62
|
+
env,
|
|
63
|
+
shell: useShell,
|
|
64
|
+
windowsHide: useShell ? true : undefined,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
let stdout = "";
|
|
68
|
+
let stdoutBytes = 0;
|
|
69
|
+
let stderr = "";
|
|
70
|
+
|
|
71
|
+
child.stdout?.setEncoding("utf8");
|
|
72
|
+
child.stderr?.setEncoding("utf8");
|
|
73
|
+
|
|
74
|
+
child.stdout?.on("data", (chunk) => {
|
|
75
|
+
const str = String(chunk);
|
|
76
|
+
stdoutBytes += Buffer.byteLength(str, "utf8");
|
|
77
|
+
if (stdoutBytes > maxStdoutBytes) {
|
|
78
|
+
try {
|
|
79
|
+
child.kill("SIGKILL");
|
|
80
|
+
} finally {
|
|
81
|
+
reject(new Error("lobster output exceeded maxStdoutBytes"));
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
stdout += str;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
child.stderr?.on("data", (chunk) => {
|
|
89
|
+
stderr += String(chunk);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const timer = setTimeout(() => {
|
|
93
|
+
try {
|
|
94
|
+
child.kill("SIGKILL");
|
|
95
|
+
} finally {
|
|
96
|
+
reject(new Error("lobster subprocess timed out"));
|
|
97
|
+
}
|
|
98
|
+
}, timeoutMs);
|
|
99
|
+
|
|
100
|
+
child.once("error", (err) => {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
reject(err);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
child.once("exit", (code) => {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
if (code !== 0) {
|
|
108
|
+
reject(new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
resolve({ stdout });
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function runLobsterSubprocess(params: {
|
|
117
|
+
execPath: string;
|
|
118
|
+
argv: string[];
|
|
119
|
+
cwd: string;
|
|
120
|
+
timeoutMs: number;
|
|
121
|
+
maxStdoutBytes: number;
|
|
122
|
+
}) {
|
|
123
|
+
try {
|
|
124
|
+
return await runLobsterSubprocessOnce(params, false);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
if (process.platform === "win32" && isWindowsSpawnEINVAL(err)) {
|
|
127
|
+
return await runLobsterSubprocessOnce(params, true);
|
|
128
|
+
}
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseEnvelope(stdout: string): LobsterEnvelope {
|
|
134
|
+
const trimmed = stdout.trim();
|
|
135
|
+
|
|
136
|
+
const tryParse = (input: string) => {
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(input) as unknown;
|
|
139
|
+
} catch {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
let parsed: unknown = tryParse(trimmed);
|
|
145
|
+
|
|
146
|
+
// Some environments can leak extra stdout (e.g. warnings/logs) before the
|
|
147
|
+
// final JSON envelope. Be tolerant and parse the last JSON-looking suffix.
|
|
148
|
+
if (parsed === undefined) {
|
|
149
|
+
const suffixMatch = trimmed.match(/({[\s\S]*}|\[[\s\S]*])\s*$/);
|
|
150
|
+
if (suffixMatch?.[1]) {
|
|
151
|
+
parsed = tryParse(suffixMatch[1]);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (parsed === undefined) {
|
|
156
|
+
throw new Error("lobster returned invalid JSON");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!parsed || typeof parsed !== "object") {
|
|
160
|
+
throw new Error("lobster returned invalid JSON envelope");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const ok = (parsed as { ok?: unknown }).ok;
|
|
164
|
+
if (ok === true || ok === false) {
|
|
165
|
+
return parsed as LobsterEnvelope;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
throw new Error("lobster returned invalid JSON envelope");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function createLobsterTool(api: OpenClawPluginApi) {
|
|
172
|
+
return {
|
|
173
|
+
name: "lobster",
|
|
174
|
+
description:
|
|
175
|
+
"Run Lobster pipelines as a local-first workflow runtime (typed JSON envelope + resumable approvals).",
|
|
176
|
+
parameters: Type.Object({
|
|
177
|
+
// NOTE: Prefer string enums in tool schemas; some providers reject unions/anyOf.
|
|
178
|
+
action: Type.Unsafe<"run" | "resume">({ type: "string", enum: ["run", "resume"] }),
|
|
179
|
+
pipeline: Type.Optional(Type.String()),
|
|
180
|
+
argsJson: Type.Optional(Type.String()),
|
|
181
|
+
token: Type.Optional(Type.String()),
|
|
182
|
+
approve: Type.Optional(Type.Boolean()),
|
|
183
|
+
lobsterPath: Type.Optional(Type.String()),
|
|
184
|
+
cwd: Type.Optional(Type.String()),
|
|
185
|
+
timeoutMs: Type.Optional(Type.Number()),
|
|
186
|
+
maxStdoutBytes: Type.Optional(Type.Number()),
|
|
187
|
+
}),
|
|
188
|
+
async execute(_id: string, params: Record<string, unknown>) {
|
|
189
|
+
const action = String(params.action || "").trim();
|
|
190
|
+
if (!action) throw new Error("action required");
|
|
191
|
+
|
|
192
|
+
const execPath = resolveExecutablePath(
|
|
193
|
+
typeof params.lobsterPath === "string" ? params.lobsterPath : undefined,
|
|
194
|
+
);
|
|
195
|
+
const cwd = typeof params.cwd === "string" && params.cwd.trim() ? params.cwd.trim() : process.cwd();
|
|
196
|
+
const timeoutMs = typeof params.timeoutMs === "number" ? params.timeoutMs : 20_000;
|
|
197
|
+
const maxStdoutBytes = typeof params.maxStdoutBytes === "number" ? params.maxStdoutBytes : 512_000;
|
|
198
|
+
|
|
199
|
+
const argv = (() => {
|
|
200
|
+
if (action === "run") {
|
|
201
|
+
const pipeline = typeof params.pipeline === "string" ? params.pipeline : "";
|
|
202
|
+
if (!pipeline.trim()) throw new Error("pipeline required");
|
|
203
|
+
const argv = ["run", "--mode", "tool", pipeline];
|
|
204
|
+
const argsJson = typeof params.argsJson === "string" ? params.argsJson : "";
|
|
205
|
+
if (argsJson.trim()) {
|
|
206
|
+
argv.push("--args-json", argsJson);
|
|
207
|
+
}
|
|
208
|
+
return argv;
|
|
209
|
+
}
|
|
210
|
+
if (action === "resume") {
|
|
211
|
+
const token = typeof params.token === "string" ? params.token : "";
|
|
212
|
+
if (!token.trim()) throw new Error("token required");
|
|
213
|
+
const approve = params.approve;
|
|
214
|
+
if (typeof approve !== "boolean") throw new Error("approve required");
|
|
215
|
+
return ["resume", "--token", token, "--approve", approve ? "yes" : "no"];
|
|
216
|
+
}
|
|
217
|
+
throw new Error(`Unknown action: ${action}`);
|
|
218
|
+
})();
|
|
219
|
+
|
|
220
|
+
if (api.runtime?.version && api.logger?.debug) {
|
|
221
|
+
api.logger.debug(`lobster plugin runtime=${api.runtime.version}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const { stdout } = await runLobsterSubprocess({
|
|
225
|
+
execPath,
|
|
226
|
+
argv,
|
|
227
|
+
cwd,
|
|
228
|
+
timeoutMs,
|
|
229
|
+
maxStdoutBytes,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const envelope = parseEnvelope(stdout);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }],
|
|
236
|
+
details: envelope,
|
|
237
|
+
};
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|