{"kind":"Skill","metadata":{"namespace":"community","name":"lobster","version":"0.1.0"},"spec":{"description":"Lobster executes multi-step workflows with approval checkpoints. Use it when:","files":{"README.md":"# Lobster (plugin)\n\nAdds the `lobster` agent tool as an **optional** plugin tool.\n\n## What this is\n\n- Lobster is a standalone workflow shell (typed JSON-first pipelines + approvals/resume).\n- This plugin integrates Lobster with OpenClaw _without core changes_.\n\n## Enable\n\nBecause this tool can trigger side effects (via workflows), it is registered with `optional: true`.\n\nEnable it in an agent allowlist:\n\n```json\n{\n  \"agents\": {\n    \"list\": [\n      {\n        \"id\": \"main\",\n        \"tools\": {\n          \"allow\": [\n            \"lobster\" // plugin id (enables all tools from this plugin)\n          ]\n        }\n      }\n    ]\n  }\n}\n```\n\n## Using `openclaw.invoke` (Lobster → OpenClaw tools)\n\nSome 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.).\n\nFor this to work, the OpenClaw Gateway must expose the tool bridge endpoint and the target tool must be allowed by policy:\n\n- OpenClaw provides an HTTP endpoint: `POST /tools/invoke`.\n- The request is gated by **gateway auth** (e.g. `Authorization: Bearer …` when token auth is enabled).\n- 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`.\n\n### Allowlisting recommended\n\nTo avoid letting workflows call arbitrary tools, set a tight allowlist on the agent that will be used by `openclaw.invoke`.\n\nExample (allow only a small set of tools):\n\n```jsonc\n{\n  \"agents\": {\n    \"list\": [\n      {\n        \"id\": \"main\",\n        \"tools\": {\n          \"allow\": [\"lobster\", \"web_fetch\", \"web_search\", \"gog\", \"gh\"],\n          \"deny\": [\"gateway\"],\n        },\n      },\n    ],\n  },\n}\n```\n\nNotes:\n\n- If `tools.allow` is omitted or empty, it behaves like \"allow everything (except denied)\". For a real allowlist, set a **non-empty** `allow`.\n- Tool names depend on which plugins you have installed/enabled.\n\n## Security\n\n- Runs Lobster in process via the published `@clawdbot/lobster/core` runtime.\n- Does not manage OAuth/tokens.\n- Uses timeouts, stdout caps, and strict JSON envelope parsing.\n","SKILL.md":"# Lobster\n\nLobster executes multi-step workflows with approval checkpoints. Use it when:\n\n- User wants a repeatable automation (triage, monitor, sync)\n- Actions need human approval before executing (send, post, delete)\n- Multiple tool calls should run as one deterministic operation\n\n## When to use Lobster\n\n| User intent                                            | Use Lobster?                                  |\n| ------------------------------------------------------ | --------------------------------------------- |\n| \"Triage my email\"                                      | Yes — multi-step, may send replies            |\n| \"Send a message\"                                       | No — single action, use message tool directly |\n| \"Check my email every morning and ask before replying\" | Yes — scheduled workflow with approval        |\n| \"What's the weather?\"                                  | No — simple query                             |\n| \"Monitor this PR and notify me of changes\"             | Yes — stateful, recurring                     |\n\n## Basic usage\n\n### Run a pipeline\n\n```json\n{\n  \"action\": \"run\",\n  \"pipeline\": \"gog.gmail.search --query 'newer_than:1d' --max 20 | email.triage\"\n}\n```\n\nReturns structured result:\n\n```json\n{\n  \"protocolVersion\": 1,\n  \"ok\": true,\n  \"status\": \"ok\",\n  \"output\": [{ \"summary\": {...}, \"items\": [...] }],\n  \"requiresApproval\": null\n}\n```\n\n### Handle approval\n\nIf the workflow needs approval:\n\n```json\n{\n  \"status\": \"needs_approval\",\n  \"output\": [],\n  \"requiresApproval\": {\n    \"prompt\": \"Send 3 draft replies?\",\n    \"items\": [...],\n    \"resumeToken\": \"...\"\n  }\n}\n```\n\nPresent the prompt to the user. If they approve:\n\n```json\n{\n  \"action\": \"resume\",\n  \"token\": \"\u003cresumeToken\u003e\",\n  \"approve\": true\n}\n```\n\n## Example workflows\n\n### Email triage\n\n```\ngog.gmail.search --query 'newer_than:1d' --max 20 | email.triage\n```\n\nFetches recent emails, classifies into buckets (needs_reply, needs_action, fyi).\n\n### Email triage with approval gate\n\n```\ngog.gmail.search --query 'newer_than:1d' | email.triage | approve --prompt 'Process these?'\n```\n\nSame as above, but halts for approval before returning.\n\n## Key behaviors\n\n- **Deterministic**: Same input → same output (no LLM variance in pipeline execution)\n- **Approval gates**: `approve` command halts execution, returns token\n- **Resumable**: Use `resume` action with token to continue\n- **Structured output**: Always returns JSON envelope with `protocolVersion`\n\n## Don't use Lobster for\n\n- Simple single-action requests (just use the tool directly)\n- Queries that need LLM interpretation mid-flow\n- One-off tasks that won't be repeated\n","index.ts":"import { definePluginEntry } from \"openclaw/plugin-sdk/plugin-entry\";\nimport type { AnyAgentTool, OpenClawPluginApi, OpenClawPluginToolFactory } from \"./runtime-api.js\";\nimport { createLobsterTool } from \"./src/lobster-tool.js\";\n\nexport default definePluginEntry({\n  id: \"lobster\",\n  name: \"Lobster\",\n  description: \"Optional local shell helper tools\",\n  register(api: OpenClawPluginApi) {\n    api.registerTool(\n      ((ctx) =\u003e {\n        if (ctx.sandboxed) {\n          return null;\n        }\n        const taskFlow =\n          api.runtime?.tasks.managedFlows \u0026\u0026 ctx.sessionKey\n            ? api.runtime.tasks.managedFlows.fromToolContext(ctx)\n            : undefined;\n        return createLobsterTool(api, { taskFlow }) as AnyAgentTool;\n      }) as OpenClawPluginToolFactory,\n      { optional: true },\n    );\n  },\n});\n","openclaw.plugin.json":"{\n  \"id\": \"lobster\",\n  \"activation\": {\n    \"onStartup\": true\n  },\n  \"name\": \"Lobster\",\n  \"description\": \"Typed workflow tool with resumable approvals.\",\n  \"contracts\": {\n    \"tools\": [\"lobster\"]\n  },\n  \"toolMetadata\": {\n    \"lobster\": {\n      \"optional\": true\n    }\n  },\n  \"configSchema\": {\n    \"type\": \"object\",\n    \"additionalProperties\": false,\n    \"properties\": {}\n  }\n}\n","package.json":"{\n  \"name\": \"@openclaw/lobster\",\n  \"version\": \"2026.5.17\",\n  \"description\": \"Lobster workflow tool plugin (typed pipelines + resumable approvals)\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/openclaw/openclaw\"\n  },\n  \"type\": \"module\",\n  \"dependencies\": {\n    \"@clawdbot/lobster\": \"2026.4.6\",\n    \"ajv\": \"8.20.0\",\n    \"typebox\": \"1.1.38\"\n  },\n  \"devDependencies\": {\n    \"@openclaw/plugin-sdk\": \"workspace:*\"\n  },\n  \"openclaw\": {\n    \"extensions\": [\n      \"./index.ts\"\n    ],\n    \"install\": {\n      \"npmSpec\": \"@openclaw/lobster\",\n      \"defaultChoice\": \"npm\",\n      \"minHostVersion\": \"\u003e=2026.4.25\"\n    },\n    \"compat\": {\n      \"pluginApi\": \"\u003e=2026.5.17\"\n    },\n    \"build\": {\n      \"openclawVersion\": \"2026.5.17\"\n    },\n    \"release\": {\n      \"publishToClawHub\": true,\n      \"publishToNpm\": true\n    }\n  }\n}\n","runtime-api.ts":"export { definePluginEntry } from \"openclaw/plugin-sdk/core\";\nexport type {\n  AnyAgentTool,\n  OpenClawPluginApi,\n  OpenClawPluginToolContext,\n  OpenClawPluginToolFactory,\n} from \"openclaw/plugin-sdk/core\";\nexport {\n  applyWindowsSpawnProgramPolicy,\n  materializeWindowsSpawnProgram,\n  resolveWindowsSpawnProgramCandidate,\n} from \"openclaw/plugin-sdk/windows-spawn\";\n","src/lobster-ajv-cache.ts":"import { createHash } from \"node:crypto\";\nimport AjvPkg, { type AnySchema, type ValidateFunction } from \"ajv\";\n\nconst installedSymbol = Symbol.for(\"openclaw.lobster.ajv-compile-cache.installed\");\nconst cacheSymbol = Symbol.for(\"openclaw.lobster.ajv-compile-cache.entries\");\nconst maxEntries = 512;\n\ntype AjvInstance = import(\"ajv\").default;\n\ntype CompileCacheEntry = {\n  schema: AnySchema;\n  validate: ValidateFunction;\n};\n\nconst AjvCtor = AjvPkg as unknown as {\n  new (opts?: object): AjvInstance;\n  prototype: AjvInstance;\n};\n\ntype AjvWithCompileCache = AjvInstance \u0026 {\n  [cacheSymbol]?: Map\u003cstring, CompileCacheEntry\u003e;\n};\n\ntype AjvPrototypePatch = {\n  [installedSymbol]?: boolean;\n  compile: (schema: AnySchema) =\u003e ValidateFunction;\n  removeSchema: (schemaKeyRef?: Parameters\u003cAjvInstance[\"removeSchema\"]\u003e[0]) =\u003e AjvInstance;\n};\n\ntype JsonLike = null | boolean | number | string | JsonLike[] | { [key: string]: JsonLike };\n\nfunction stableJsonStringify(value: unknown, seen = new WeakSet\u003cobject\u003e()): string {\n  if (value === null || typeof value !== \"object\") {\n    return JSON.stringify(value);\n  }\n  if (seen.has(value)) {\n    throw new TypeError(\"Cannot cache cyclic JSON schema\");\n  }\n  seen.add(value);\n  if (Array.isArray(value)) {\n    const items = value.map((entry) =\u003e stableJsonStringify(entry, seen));\n    seen.delete(value);\n    return `[${items.join(\",\")}]`;\n  }\n  const record = value as Record\u003cstring, unknown\u003e;\n  const keys = Object.keys(record).toSorted();\n  const properties = keys\n    .filter((key) =\u003e record[key] !== undefined)\n    .map((key) =\u003e `${JSON.stringify(key)}:${stableJsonStringify(record[key], seen)}`);\n  seen.delete(value);\n  return `{${properties.join(\",\")}}`;\n}\n\nfunction compileCacheKey(schema: unknown): string | null {\n  try {\n    return createHash(\"sha256\").update(stableJsonStringify(schema)).digest(\"hex\");\n  } catch {\n    return null;\n  }\n}\n\nfunction readCompileCache(instance: AjvWithCompileCache): Map\u003cstring, CompileCacheEntry\u003e {\n  let cache = instance[cacheSymbol];\n  if (!cache) {\n    cache = new Map\u003cstring, CompileCacheEntry\u003e();\n    Object.defineProperty(instance, cacheSymbol, {\n      value: cache,\n      configurable: true,\n    });\n  }\n  return cache;\n}\n\nfunction rememberCompiledValidator(params: {\n  cache: Map\u003cstring, CompileCacheEntry\u003e;\n  instance: AjvWithCompileCache;\n  key: string;\n  removeSchema: AjvPrototypePatch[\"removeSchema\"];\n  schema: AnySchema;\n  validate: ValidateFunction;\n}) {\n  const { cache, instance, key, removeSchema, schema, validate } = params;\n  if (!cache.has(key) \u0026\u0026 cache.size \u003e= maxEntries) {\n    const oldest = cache.keys().next().value;\n    if (oldest !== undefined) {\n      const evicted = cache.get(oldest);\n      cache.delete(oldest);\n      if (evicted) {\n        removeSchema.call(instance, evicted.schema);\n      }\n    }\n  }\n  cache.set(key, { schema, validate });\n}\n\nexport function installLobsterAjvCompileCache() {\n  const proto = AjvCtor.prototype as unknown as AjvPrototypePatch;\n  if (proto[installedSymbol]) {\n    return;\n  }\n\n  const originalCompile = proto.compile;\n  const originalRemoveSchema = proto.removeSchema;\n\n  Object.defineProperty(proto, installedSymbol, {\n    value: true,\n    configurable: true,\n  });\n\n  proto.compile = function compileWithContentCache(\n    this: AjvWithCompileCache,\n    schema: AnySchema,\n  ): ValidateFunction\u003cJsonLike\u003e {\n    const key = compileCacheKey(schema);\n    if (!key) {\n      return originalCompile.call(this, schema) as ValidateFunction\u003cJsonLike\u003e;\n    }\n    const cache = readCompileCache(this);\n    const cached = cache.get(key);\n    if (cached) {\n      return cached.validate as ValidateFunction\u003cJsonLike\u003e;\n    }\n    const validate = originalCompile.call(this, schema) as ValidateFunction\u003cJsonLike\u003e;\n    rememberCompiledValidator({\n      cache,\n      instance: this,\n      key,\n      removeSchema: originalRemoveSchema,\n      schema,\n      validate,\n    });\n    return validate;\n  };\n\n  proto.removeSchema = function removeSchemaAndClearContentCache(\n    this: AjvWithCompileCache,\n    schemaKeyRef?: Parameters\u003cAjvInstance[\"removeSchema\"]\u003e[0],\n  ) {\n    this[cacheSymbol]?.clear();\n    return originalRemoveSchema.call(this, schemaKeyRef);\n  };\n}\n","src/lobster-core.d.ts":"declare module \"@clawdbot/lobster/core\" {\n  type LobsterApprovalRequest = {\n    type: \"approval_request\";\n    prompt: string;\n    items: unknown[];\n    resumeToken?: string;\n    approvalId?: string;\n  } | null;\n\n  type LobsterToolContext = {\n    cwd?: string;\n    env?: Record\u003cstring, string | undefined\u003e;\n    stdin?: NodeJS.ReadableStream;\n    stdout?: NodeJS.WritableStream;\n    stderr?: NodeJS.WritableStream;\n    signal?: AbortSignal;\n    registry?: unknown;\n    llmAdapters?: Record\u003cstring, unknown\u003e;\n  };\n\n  type LobsterToolEnvelope =\n    | {\n        protocolVersion: 1;\n        ok: true;\n        status: \"ok\" | \"needs_approval\" | \"needs_input\" | \"cancelled\";\n        output: unknown[];\n        requiresApproval: LobsterApprovalRequest;\n        requiresInput?: {\n          prompt: string;\n          schema?: unknown;\n          items?: unknown[];\n          resumeToken?: string;\n          approvalId?: string;\n        } | null;\n      }\n    | {\n        protocolVersion: 1;\n        ok: false;\n        error: {\n          type: string;\n          message: string;\n        };\n      };\n\n  export function runToolRequest(params: {\n    pipeline?: string;\n    filePath?: string;\n    args?: Record\u003cstring, unknown\u003e;\n    ctx?: LobsterToolContext;\n  }): Promise\u003cLobsterToolEnvelope\u003e;\n\n  export function resumeToolRequest(params: {\n    token?: string;\n    approvalId?: string;\n    approved?: boolean;\n    response?: unknown;\n    cancel?: boolean;\n    ctx?: LobsterToolContext;\n  }): Promise\u003cLobsterToolEnvelope\u003e;\n}\n","src/lobster-runner.test.ts":"import fs from \"node:fs/promises\";\nimport { createRequire } from \"node:module\";\nimport os from \"node:os\";\nimport path from \"node:path\";\nimport { pathToFileURL } from \"node:url\";\nimport { afterEach, describe, expect, it, vi } from \"vitest\";\nimport {\n  createEmbeddedLobsterRunner,\n  loadEmbeddedToolRuntimeFromPackage,\n  resolveLobsterCwd,\n} from \"./lobster-runner.js\";\n\nconst requireForTest = createRequire(import.meta.url);\n\ntype AjvCacheOwner = {\n  _cache?: { size: number };\n};\n\nfunction readAjvInternalCacheSize(ajv: unknown): number {\n  return (ajv as AjvCacheOwner)[\"_cache\"]?.size ?? 0;\n}\n\nfunction createRepeatedResponseSchema() {\n  return {\n    type: \"object\",\n    properties: {\n      answer: { type: \"string\" },\n    },\n    required: [\"answer\"],\n    additionalProperties: false,\n  };\n}\n\nfunction createUniqueResponseSchema(index: number) {\n  return {\n    type: \"object\",\n    properties: {\n      [`answer${index}`]: { type: \"string\" },\n    },\n    required: [`answer${index}`],\n    additionalProperties: false,\n  };\n}\n\nfunction requireRecord(value: unknown, label: string): Record\u003cstring, unknown\u003e {\n  if (value === null || typeof value !== \"object\" || Array.isArray(value)) {\n    throw new Error(`expected ${label} to be a record`);\n  }\n  return value as Record\u003cstring, unknown\u003e;\n}\n\nfunction requireFirstCallParam(calls: ReadonlyArray\u003creadonly unknown[]\u003e, label: string) {\n  const call = calls[0];\n  if (!call) {\n    throw new Error(`expected ${label} call`);\n  }\n  return call[0];\n}\n\nfunction expectToolContext(value: unknown, expected: { cwd?: string; mode: \"tool\" }) {\n  const ctx = requireRecord(value, \"tool context\");\n  if (expected.cwd !== undefined) {\n    expect(ctx.cwd).toBe(expected.cwd);\n  }\n  expect(ctx.mode).toBe(expected.mode);\n  expect(ctx.signal).toBeInstanceOf(AbortSignal);\n}\n\ndescribe(\"resolveLobsterCwd\", () =\u003e {\n  it(\"defaults to the current working directory\", () =\u003e {\n    expect(resolveLobsterCwd(undefined)).toBe(process.cwd());\n  });\n\n  it(\"keeps relative paths inside the repo root\", () =\u003e {\n    expect(resolveLobsterCwd(\"extensions/lobster\")).toBe(\n      path.resolve(process.cwd(), \"extensions/lobster\"),\n    );\n  });\n});\n\ndescribe(\"createEmbeddedLobsterRunner\", () =\u003e {\n  afterEach(() =\u003e {\n    vi.restoreAllMocks();\n  });\n\n  it(\"runs inline pipelines through the embedded runtime\", async () =\u003e {\n    const runtime = {\n      runToolRequest: vi.fn().mockResolvedValue({\n        ok: true,\n        protocolVersion: 1,\n        status: \"ok\",\n        output: [{ hello: \"world\" }],\n        requiresApproval: null,\n      }),\n      resumeToolRequest: vi.fn(),\n    };\n\n    const runner = createEmbeddedLobsterRunner({\n      loadRuntime: vi.fn().mockResolvedValue(runtime),\n    });\n\n    const envelope = await runner.run({\n      action: \"run\",\n      pipeline: \"exec --json=true echo hi\",\n      cwd: process.cwd(),\n      timeoutMs: 2000,\n      maxStdoutBytes: 4096,\n    });\n\n    expect(runtime.runToolRequest).toHaveBeenCalledTimes(1);\n    const request = requireRecord(\n      requireFirstCallParam(runtime.runToolRequest.mock.calls, \"run tool request\"),\n      \"run tool request\",\n    );\n    expect(request.pipeline).toBe(\"exec --json=true echo hi\");\n    expectToolContext(request.ctx, { cwd: process.cwd(), mode: \"tool\" });\n    expect(envelope).toEqual({\n      ok: true,\n      status: \"ok\",\n      output: [{ hello: \"world\" }],\n      requiresApproval: null,\n    });\n  });\n\n  it(\"detects workflow files and parses argsJson\", async () =\u003e {\n    const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), \"openclaw-lobster-runner-\"));\n    const workflowPath = path.join(tempDir, \"workflow.lobster\");\n    await fs.writeFile(workflowPath, \"steps: []\\n\", \"utf8\");\n\n    try {\n      const runtime = {\n        runToolRequest: vi.fn().mockResolvedValue({\n          ok: true,\n          protocolVersion: 1,\n          status: \"ok\",\n          output: [],\n          requiresApproval: null,\n        }),\n        resumeToolRequest: vi.fn(),\n      };\n\n      const runner = createEmbeddedLobsterRunner({\n        loadRuntime: vi.fn().mockResolvedValue(runtime),\n      });\n\n      await runner.run({\n        action: \"run\",\n        pipeline: \"workflow.lobster\",\n        argsJson: '{\"limit\":3}',\n        cwd: tempDir,\n        timeoutMs: 2000,\n        maxStdoutBytes: 4096,\n      });\n\n      expect(runtime.runToolRequest).toHaveBeenCalledOnce();\n      const request = requireRecord(\n        requireFirstCallParam(runtime.runToolRequest.mock.calls, \"workflow run tool request\"),\n        \"workflow run tool request\",\n      );\n      expect(request.filePath).toBe(workflowPath);\n      expect(request.args).toEqual({ limit: 3 });\n      expectToolContext(request.ctx, { cwd: tempDir, mode: \"tool\" });\n    } finally {\n      await fs.rm(tempDir, { recursive: true, force: true });\n    }\n  });\n\n  it(\"returns a parse error when workflow args are invalid JSON\", async () =\u003e {\n    const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), \"openclaw-lobster-runner-\"));\n    const workflowPath = path.join(tempDir, \"workflow.lobster\");\n    await fs.writeFile(workflowPath, \"steps: []\\n\", \"utf8\");\n\n    try {\n      const runtime = {\n        runToolRequest: vi.fn(),\n        resumeToolRequest: vi.fn(),\n      };\n      const runner = createEmbeddedLobsterRunner({\n        loadRuntime: vi.fn().mockResolvedValue(runtime),\n      });\n\n      await expect(\n        runner.run({\n          action: \"run\",\n          pipeline: \"workflow.lobster\",\n          argsJson: \"{bad\",\n          cwd: tempDir,\n          timeoutMs: 2000,\n          maxStdoutBytes: 4096,\n        }),\n      ).rejects.toThrow(\"run --args-json must be valid JSON\");\n      expect(runtime.runToolRequest).not.toHaveBeenCalled();\n    } finally {\n      await fs.rm(tempDir, { recursive: true, force: true });\n    }\n  });\n\n  it(\"throws when the embedded runtime returns an error envelope\", async () =\u003e {\n    const runtime = {\n      runToolRequest: vi.fn().mockResolvedValue({\n        ok: false,\n        protocolVersion: 1,\n        error: {\n          type: \"runtime_error\",\n          message: \"boom\",\n        },\n      }),\n      resumeToolRequest: vi.fn(),\n    };\n\n    const runner = createEmbeddedLobsterRunner({\n      loadRuntime: vi.fn().mockResolvedValue(runtime),\n    });\n\n    await expect(\n      runner.run({\n        action: \"run\",\n        pipeline: \"exec --json=true echo hi\",\n        cwd: process.cwd(),\n        timeoutMs: 2000,\n        maxStdoutBytes: 4096,\n      }),\n    ).rejects.toThrow(\"boom\");\n  });\n\n  it(\"fails closed when the embedded runtime requests unsupported input\", async () =\u003e {\n    const runtime = {\n      runToolRequest: vi.fn().mockResolvedValue({\n        ok: true,\n        protocolVersion: 1,\n        status: \"needs_input\",\n        output: [],\n        requiresApproval: null,\n        requiresInput: {\n          prompt: \"Need more data\",\n          schema: { type: \"string\" },\n        },\n      }),\n      resumeToolRequest: vi.fn(),\n    };\n\n    const runner = createEmbeddedLobsterRunner({\n      loadRuntime: vi.fn().mockResolvedValue(runtime),\n    });\n\n    await expect(\n      runner.run({\n        action: \"run\",\n        pipeline: \"exec --json=true echo hi\",\n        cwd: process.cwd(),\n        timeoutMs: 2000,\n        maxStdoutBytes: 4096,\n      }),\n    ).rejects.toThrow(\"Lobster input requests are not supported by the OpenClaw Lobster tool yet\");\n  });\n\n  it(\"routes resume through the embedded runtime\", async () =\u003e {\n    const runtime = {\n      runToolRequest: vi.fn(),\n      resumeToolRequest: vi.fn().mockResolvedValue({\n        ok: true,\n        protocolVersion: 1,\n        status: \"cancelled\",\n        output: [],\n        requiresApproval: null,\n      }),\n    };\n\n    const runner = createEmbeddedLobsterRunner({\n      loadRuntime: vi.fn().mockResolvedValue(runtime),\n    });\n\n    const envelope = await runner.run({\n      action: \"resume\",\n      token: \"resume-token\",\n      approve: false,\n      cwd: process.cwd(),\n      timeoutMs: 2000,\n      maxStdoutBytes: 4096,\n    });\n\n    expect(runtime.resumeToolRequest).toHaveBeenCalledOnce();\n    const request = requireRecord(\n      requireFirstCallParam(runtime.resumeToolRequest.mock.calls, \"resume tool request\"),\n      \"resume tool request\",\n    );\n    expect(request.token).toBe(\"resume-token\");\n    expect(request.approved).toBe(false);\n    expectToolContext(request.ctx, { cwd: process.cwd(), mode: \"tool\" });\n    expect(envelope).toEqual({\n      ok: true,\n      status: \"cancelled\",\n      output: [],\n      requiresApproval: null,\n    });\n  });\n\n  it(\"forwards approvalId through resume when token is absent\", async () =\u003e {\n    const runtime = {\n      runToolRequest: vi.fn(),\n      resumeToolRequest: vi.fn().mockResolvedValue({\n        ok: true,\n        protocolVersion: 1,\n        status: \"ok\",\n        output: [],\n        requiresApproval: null,\n      }),\n    };\n\n    const runner = createEmbeddedLobsterRunner({\n      loadRuntime: vi.fn().mockResolvedValue(runtime),\n    });\n\n    await runner.run({\n      action: \"resume\",\n      approvalId: \"dbc98d05\",\n      approve: true,\n      cwd: process.cwd(),\n      timeoutMs: 2000,\n      maxStdoutBytes: 4096,\n    });\n\n    expect(runtime.resumeToolRequest).toHaveBeenCalledOnce();\n    const request = requireRecord(\n      requireFirstCallParam(runtime.resumeToolRequest.mock.calls, \"approval resume tool request\"),\n      \"approval resume tool request\",\n    );\n    expect(request.approvalId).toBe(\"dbc98d05\");\n    expect(request.approved).toBe(true);\n    expectToolContext(request.ctx, { mode: \"tool\" });\n  });\n\n  it(\"passes approvalId through the normalized needs_approval envelope\", async () =\u003e {\n    const runtime = {\n      runToolRequest: vi.fn().mockResolvedValue({\n        ok: true,\n        protocolVersion: 1,\n        status: \"needs_approval\",\n        output: [],\n        requiresApproval: {\n          type: \"approval_request\",\n          prompt: \"ok?\",\n          items: [],\n          resumeToken: \"eyJ...\",\n          approvalId: \"dbc98d05\",\n        },\n      }),\n      resumeToolRequest: vi.fn(),\n    };\n\n    const runner = createEmbeddedLobsterRunner({\n      loadRuntime: vi.fn().mockResolvedValue(runtime),\n    });\n\n    const envelope = await runner.run({\n      action: \"run\",\n      pipeline: \"exec --json=true echo hi\",\n      cwd: process.cwd(),\n      timeoutMs: 2000,\n      maxStdoutBytes: 4096,\n    });\n\n    expect(envelope).toEqual({\n      ok: true,\n      status: \"needs_approval\",\n      output: [],\n      requiresApproval: {\n        type: \"approval_request\",\n        prompt: \"ok?\",\n        items: [],\n        resumeToken: \"eyJ...\",\n        approvalId: \"dbc98d05\",\n      },\n    });\n  });\n\n  it(\"loads the embedded runtime once per runner\", async () =\u003e {\n    const runtime = {\n      runToolRequest: vi.fn().mockResolvedValue({\n        ok: true,\n        protocolVersion: 1,\n        status: \"ok\",\n        output: [],\n        requiresApproval: null,\n      }),\n      resumeToolRequest: vi.fn().mockResolvedValue({\n        ok: true,\n        protocolVersion: 1,\n        status: \"cancelled\",\n        output: [],\n        requiresApproval: null,\n      }),\n    };\n    const loadRuntime = vi.fn().mockResolvedValue(runtime);\n\n    const runner = createEmbeddedLobsterRunner({ loadRuntime });\n\n    await runner.run({\n      action: \"run\",\n      pipeline: \"exec --json=true echo hi\",\n      cwd: process.cwd(),\n      timeoutMs: 2000,\n      maxStdoutBytes: 4096,\n    });\n    await runner.run({\n      action: \"resume\",\n      token: \"resume-token\",\n      approve: false,\n      cwd: process.cwd(),\n      timeoutMs: 2000,\n      maxStdoutBytes: 4096,\n    });\n\n    expect(loadRuntime).toHaveBeenCalledTimes(1);\n  });\n\n  it(\"installs an Ajv content cache before loading the embedded runtime\", async () =\u003e {\n    const AjvModule = await import(\"ajv\");\n    const AjvCtor = AjvModule.default as unknown as new (opts?: object) =\u003e import(\"ajv\").default;\n    const ajv = new AjvCtor({ allErrors: true, strict: false, addUsedSchema: false });\n    const before = readAjvInternalCacheSize(ajv);\n\n    await loadEmbeddedToolRuntimeFromPackage({\n      importModule: async () =\u003e ({\n        runToolRequest: vi.fn(),\n        resumeToolRequest: vi.fn(),\n      }),\n    });\n\n    const first = ajv.compile(createRepeatedResponseSchema());\n    const second = ajv.compile(createRepeatedResponseSchema());\n    const afterRepeated = readAjvInternalCacheSize(ajv);\n\n    expect(second).toBe(first);\n    expect(afterRepeated - before).toBe(1);\n\n    for (let index = 0; index \u003c 520; index += 1) {\n      ajv.compile(createUniqueResponseSchema(index));\n    }\n\n    expect(readAjvInternalCacheSize(ajv)).toBeLessThanOrEqual(before + 512);\n  });\n\n  it(\"deduplicates content-identical schema compilation in the installed Lobster runtime\", async () =\u003e {\n    await loadEmbeddedToolRuntimeFromPackage();\n\n    const corePath = requireForTest.resolve(\"@clawdbot/lobster/core\");\n    const validationPath = path.join(path.dirname(path.dirname(corePath)), \"validation.js\");\n    const validationModule = (await import(pathToFileURL(validationPath).href)) as {\n      sharedAjv: import(\"ajv\").default;\n    };\n    const before = readAjvInternalCacheSize(validationModule.sharedAjv);\n\n    const first = validationModule.sharedAjv.compile(createRepeatedResponseSchema());\n    for (let index = 0; index \u003c 1000; index += 1) {\n      validationModule.sharedAjv.compile(createRepeatedResponseSchema());\n    }\n    const second = validationModule.sharedAjv.compile(createRepeatedResponseSchema());\n\n    expect(second).toBe(first);\n    expect(readAjvInternalCacheSize(validationModule.sharedAjv) - before).toBe(1);\n  });\n\n  it(\"falls back to the installed package core file when the core export is unavailable\", async () =\u003e {\n    const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), \"openclaw-lobster-package-\"));\n    const packageRoot = path.join(tempDir, \"node_modules\", \"@clawdbot\", \"lobster\");\n    const packageEntryPath = path.join(packageRoot, \"dist\", \"src\", \"sdk\", \"index.js\");\n    const packageCorePath = path.join(packageRoot, \"dist\", \"src\", \"core\", \"index.js\");\n\n    try {\n      await fs.mkdir(path.dirname(packageEntryPath), { recursive: true });\n      await fs.mkdir(path.dirname(packageCorePath), { recursive: true });\n      await fs.writeFile(\n        path.join(packageRoot, \"package.json\"),\n        JSON.stringify({\n          name: \"@clawdbot/lobster\",\n          type: \"module\",\n          main: \"./dist/src/sdk/index.js\",\n        }),\n        \"utf8\",\n      );\n      await fs.writeFile(packageEntryPath, \"export {};\\n\", \"utf8\");\n      await fs.writeFile(\n        packageCorePath,\n        [\n          \"export async function runToolRequest() {\",\n          \"  return { ok: true, status: 'ok', output: [{ source: 'fallback' }], requiresApproval: null };\",\n          \"}\",\n          \"export async function resumeToolRequest() {\",\n          \"  return { ok: true, status: 'cancelled', output: [], requiresApproval: null };\",\n          \"}\",\n          \"\",\n        ].join(\"\\n\"),\n        \"utf8\",\n      );\n\n      const runtime = await loadEmbeddedToolRuntimeFromPackage({\n        importModule: async (specifier) =\u003e {\n          if (specifier === \"@clawdbot/lobster/core\") {\n            throw new Error(\"package export missing\");\n          }\n          return (await import(`${specifier}?t=${Date.now()}`)) as object;\n        },\n        resolvePackageEntry: () =\u003e packageEntryPath,\n      });\n\n      await expect(runtime.runToolRequest({ pipeline: \"commands.list\" })).resolves.toEqual({\n        ok: true,\n        status: \"ok\",\n        output: [{ source: \"fallback\" }],\n        requiresApproval: null,\n      });\n    } finally {\n      await fs.rm(tempDir, { recursive: true, force: true });\n    }\n  });\n\n  it(\"requires a pipeline for run\", async () =\u003e {\n    const runner = createEmbeddedLobsterRunner({\n      loadRuntime: vi.fn().mockResolvedValue({\n        runToolRequest: vi.fn(),\n        resumeToolRequest: vi.fn(),\n      }),\n    });\n\n    await expect(\n      runner.run({\n        action: \"run\",\n        cwd: process.cwd(),\n        timeoutMs: 2000,\n        maxStdoutBytes: 4096,\n      }),\n    ).rejects.toThrow(/pipeline required/);\n  });\n\n  it(\"requires token and approve for resume\", async () =\u003e {\n    const runner = createEmbeddedLobsterRunner({\n      loadRuntime: vi.fn().mockResolvedValue({\n        runToolRequest: vi.fn(),\n        resumeToolRequest: vi.fn(),\n      }),\n    });\n\n    await expect(\n      runner.run({\n        action: \"resume\",\n        approve: true,\n        cwd: process.cwd(),\n        timeoutMs: 2000,\n        maxStdoutBytes: 4096,\n      }),\n    ).rejects.toThrow(/token or approvalId required/);\n\n    await expect(\n      runner.run({\n        action: \"resume\",\n        token: \"resume-token\",\n        cwd: process.cwd(),\n        timeoutMs: 2000,\n        maxStdoutBytes: 4096,\n      }),\n    ).rejects.toThrow(/approve required/);\n  });\n\n  it(\"aborts long-running embedded work\", async () =\u003e {\n    const runtime = {\n      runToolRequest: vi.fn(\n        async ({ ctx }: { ctx?: { signal?: AbortSignal } }) =\u003e\n          await new Promise((resolve, reject) =\u003e {\n            const timeout = setTimeout(\n              () =\u003e resolve({ ok: true, status: \"ok\", output: [], requiresApproval: null }),\n              500,\n            );\n            ctx?.signal?.addEventListener(\"abort\", () =\u003e {\n              clearTimeout(timeout);\n              reject(ctx.signal?.reason ?? new Error(\"aborted\"));\n            });\n          }),\n      ),\n      resumeToolRequest: vi.fn(),\n    };\n\n    const runner = createEmbeddedLobsterRunner({\n      loadRuntime: vi.fn().mockResolvedValue(runtime),\n    });\n\n    await expect(\n      runner.run({\n        action: \"run\",\n        pipeline: \"exec --json=true echo hi\",\n        cwd: process.cwd(),\n        timeoutMs: 200,\n        maxStdoutBytes: 4096,\n      }),\n    ).rejects.toThrow(/timed out|aborted/);\n  });\n});\n","src/lobster-runner.ts":"import { readFileSync } from \"node:fs\";\nimport { stat } from \"node:fs/promises\";\nimport { createRequire } from \"node:module\";\nimport path from \"node:path\";\nimport { Readable, Writable } from \"node:stream\";\nimport { pathToFileURL } from \"node:url\";\nimport { installLobsterAjvCompileCache } from \"./lobster-ajv-cache.js\";\n\nexport type LobsterEnvelope =\n  | {\n      ok: true;\n      status: \"ok\" | \"needs_approval\" | \"cancelled\";\n      output: unknown[];\n      requiresApproval: null | {\n        type: \"approval_request\";\n        prompt: string;\n        items: unknown[];\n        resumeToken?: string;\n        approvalId?: string;\n      };\n    }\n  | {\n      ok: false;\n      error: { type?: string; message: string };\n    };\n\nexport type LobsterRunnerParams = {\n  action: \"run\" | \"resume\";\n  pipeline?: string;\n  argsJson?: string;\n  token?: string;\n  approvalId?: string;\n  approve?: boolean;\n  cwd: string;\n  timeoutMs: number;\n  maxStdoutBytes: number;\n};\n\nexport type LobsterRunner = {\n  run: (params: LobsterRunnerParams) =\u003e Promise\u003cLobsterEnvelope\u003e;\n};\n\ntype EmbeddedToolContext = {\n  cwd?: string;\n  env?: Record\u003cstring, string | undefined\u003e;\n  mode?: \"tool\" | \"human\" | \"sdk\";\n  stdin?: NodeJS.ReadableStream;\n  stdout?: NodeJS.WritableStream;\n  stderr?: NodeJS.WritableStream;\n  signal?: AbortSignal;\n  registry?: unknown;\n  llmAdapters?: Record\u003cstring, unknown\u003e;\n};\n\ntype EmbeddedToolEnvelope = {\n  protocolVersion?: number;\n  ok: boolean;\n  status?: \"ok\" | \"needs_approval\" | \"needs_input\" | \"cancelled\";\n  output?: unknown[];\n  requiresApproval?: {\n    type?: \"approval_request\";\n    prompt: string;\n    items: unknown[];\n    preview?: string;\n    resumeToken?: string;\n    approvalId?: string;\n  } | null;\n  requiresInput?: {\n    prompt: string;\n    schema?: unknown;\n    items?: unknown[];\n    resumeToken?: string;\n    approvalId?: string;\n  } | null;\n  error?: {\n    type?: string;\n    message: string;\n  };\n};\n\ntype EmbeddedToolRuntime = {\n  runToolRequest: (params: {\n    pipeline?: string;\n    filePath?: string;\n    args?: Record\u003cstring, unknown\u003e;\n    ctx?: EmbeddedToolContext;\n  }) =\u003e Promise\u003cEmbeddedToolEnvelope\u003e;\n  resumeToolRequest: (params: {\n    token?: string;\n    approvalId?: string;\n    approved?: boolean;\n    response?: unknown;\n    cancel?: boolean;\n    ctx?: EmbeddedToolContext;\n  }) =\u003e Promise\u003cEmbeddedToolEnvelope\u003e;\n};\n\ntype LoadEmbeddedToolRuntime = () =\u003e Promise\u003cEmbeddedToolRuntime\u003e;\n\ntype LoadEmbeddedToolRuntimeFromPackageOptions = {\n  importModule?: (specifier: string) =\u003e Promise\u003cPartial\u003cEmbeddedToolRuntime\u003e\u003e;\n  resolvePackageEntry?: (specifier: string) =\u003e string;\n};\n\nconst lobsterRequire = createRequire(import.meta.url);\n\nfunction toEmbeddedToolRuntime(\n  moduleExports: Partial\u003cEmbeddedToolRuntime\u003e,\n  source: string,\n): EmbeddedToolRuntime {\n  const { runToolRequest, resumeToolRequest } = moduleExports;\n  if (typeof runToolRequest === \"function\" \u0026\u0026 typeof resumeToolRequest === \"function\") {\n    return { runToolRequest, resumeToolRequest };\n  }\n  throw new Error(`${source} does not export Lobster embedded runtime functions`);\n}\n\nfunction findLobsterPackageRoot(resolvedEntryPath: string): string {\n  let dir = path.dirname(resolvedEntryPath);\n  while (true) {\n    const packageJsonPath = path.join(dir, \"package.json\");\n    try {\n      const parsed = JSON.parse(readFileSync(packageJsonPath, \"utf8\")) as { name?: string };\n      if (parsed.name === \"@clawdbot/lobster\") {\n        return dir;\n      }\n    } catch {\n      // Keep walking until the installed package root is found.\n    }\n\n    const parent = path.dirname(dir);\n    if (parent === dir) {\n      throw new Error(`Could not locate @clawdbot/lobster package root from ${resolvedEntryPath}`);\n    }\n    dir = parent;\n  }\n}\n\nfunction normalizeForCwdSandbox(p: string): string {\n  const normalized = path.normalize(p);\n  return process.platform === \"win32\" ? normalized.toLowerCase() : normalized;\n}\n\nexport function resolveLobsterCwd(cwdRaw: unknown): string {\n  if (typeof cwdRaw !== \"string\" || !cwdRaw.trim()) {\n    return process.cwd();\n  }\n  const cwd = cwdRaw.trim();\n  if (path.isAbsolute(cwd)) {\n    throw new Error(\"cwd must be a relative path\");\n  }\n  const base = process.cwd();\n  const resolved = path.resolve(base, cwd);\n\n  const rel = path.relative(normalizeForCwdSandbox(base), normalizeForCwdSandbox(resolved));\n  if (rel === \"\" || rel === \".\") {\n    return resolved;\n  }\n  if (rel.startsWith(\"..\") || path.isAbsolute(rel)) {\n    throw new Error(\"cwd must stay within the gateway working directory\");\n  }\n  return resolved;\n}\n\nfunction createLimitedSink(maxBytes: number, label: \"stdout\" | \"stderr\") {\n  let bytes = 0;\n  return new Writable({\n    write(chunk, _encoding, callback) {\n      bytes += Buffer.byteLength(String(chunk), \"utf8\");\n      if (bytes \u003e maxBytes) {\n        callback(new Error(`lobster ${label} exceeded maxStdoutBytes`));\n        return;\n      }\n      callback();\n    },\n  });\n}\n\nfunction normalizeEnvelope(envelope: EmbeddedToolEnvelope): LobsterEnvelope {\n  if (envelope.ok) {\n    if (envelope.status === \"needs_input\") {\n      return {\n        ok: false,\n        error: {\n          type: \"unsupported_status\",\n          message: \"Lobster input requests are not supported by the OpenClaw Lobster tool yet\",\n        },\n      };\n    }\n    return {\n      ok: true,\n      status: envelope.status ?? \"ok\",\n      output: Array.isArray(envelope.output) ? envelope.output : [],\n      requiresApproval: envelope.requiresApproval\n        ? {\n            type: \"approval_request\",\n            prompt: envelope.requiresApproval.prompt,\n            items: envelope.requiresApproval.items,\n            ...(envelope.requiresApproval.resumeToken\n              ? { resumeToken: envelope.requiresApproval.resumeToken }\n              : {}),\n            ...(envelope.requiresApproval.approvalId\n              ? { approvalId: envelope.requiresApproval.approvalId }\n              : {}),\n          }\n        : null,\n    };\n  }\n  return {\n    ok: false,\n    error: {\n      type: envelope.error?.type,\n      message: envelope.error?.message ?? \"lobster runtime failed\",\n    },\n  };\n}\n\nfunction throwOnErrorEnvelope(envelope: LobsterEnvelope): Extract\u003cLobsterEnvelope, { ok: true }\u003e {\n  if (envelope.ok) {\n    return envelope;\n  }\n  throw new Error(envelope.error.message);\n}\n\nasync function resolveWorkflowFile(candidate: string, cwd: string) {\n  const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);\n  const fileStat = await stat(resolved);\n  if (!fileStat.isFile()) {\n    throw new Error(\"Workflow path is not a file\");\n  }\n  const ext = path.extname(resolved).toLowerCase();\n  if (![\".lobster\", \".yaml\", \".yml\", \".json\"].includes(ext)) {\n    throw new Error(\"Workflow file must end in .lobster, .yaml, .yml, or .json\");\n  }\n  return resolved;\n}\n\nasync function detectWorkflowFile(candidate: string, cwd: string) {\n  const trimmed = candidate.trim();\n  if (!trimmed || trimmed.includes(\"|\")) {\n    return null;\n  }\n  try {\n    return await resolveWorkflowFile(trimmed, cwd);\n  } catch {\n    return null;\n  }\n}\n\nfunction parseWorkflowArgs(argsJson: string) {\n  return JSON.parse(argsJson) as Record\u003cstring, unknown\u003e;\n}\n\nfunction createEmbeddedToolContext(\n  params: LobsterRunnerParams,\n  signal?: AbortSignal,\n): EmbeddedToolContext {\n  const env = { ...process.env } as Record\u003cstring, string | undefined\u003e;\n  return {\n    cwd: params.cwd,\n    env,\n    mode: \"tool\",\n    stdin: Readable.from([]),\n    stdout: createLimitedSink(Math.max(1024, params.maxStdoutBytes), \"stdout\"),\n    stderr: createLimitedSink(Math.max(1024, params.maxStdoutBytes), \"stderr\"),\n    signal,\n  };\n}\n\nasync function withTimeout\u003cT\u003e(\n  timeoutMs: number,\n  fn: (signal?: AbortSignal) =\u003e Promise\u003cT\u003e,\n): Promise\u003cT\u003e {\n  const timeout = Math.max(200, timeoutMs);\n  const controller = new AbortController();\n  return await new Promise\u003cT\u003e((resolve, reject) =\u003e {\n    const onTimeout = () =\u003e {\n      const error = new Error(\"lobster runtime timed out\");\n      controller.abort(error);\n      reject(error);\n    };\n\n    const timer = setTimeout(onTimeout, timeout);\n    void fn(controller.signal).then(\n      (value) =\u003e {\n        clearTimeout(timer);\n        resolve(value);\n      },\n      (error) =\u003e {\n        clearTimeout(timer);\n        reject(error);\n      },\n    );\n  });\n}\n\nexport async function loadEmbeddedToolRuntimeFromPackage(\n  options: LoadEmbeddedToolRuntimeFromPackageOptions = {},\n): Promise\u003cEmbeddedToolRuntime\u003e {\n  installLobsterAjvCompileCache();\n\n  const importModule =\n    options.importModule ??\n    (async (specifier: string) =\u003e (await import(specifier)) as Partial\u003cEmbeddedToolRuntime\u003e);\n  const resolvePackageEntry =\n    options.resolvePackageEntry ?? ((specifier: string) =\u003e lobsterRequire.resolve(specifier));\n\n  let coreLoadError: unknown;\n  try {\n    const coreSpecifier = [\"@clawdbot\", \"lobster\", \"core\"].join(\"/\");\n    return toEmbeddedToolRuntime(await importModule(coreSpecifier), \"@clawdbot/lobster/core\");\n  } catch (error) {\n    coreLoadError = error;\n  }\n\n  let fallbackLoadError: unknown;\n  try {\n    const packageEntryPath = resolvePackageEntry(\"@clawdbot/lobster\");\n    const packageRoot = findLobsterPackageRoot(packageEntryPath);\n    const coreRuntimeUrl = pathToFileURL(path.join(packageRoot, \"dist/src/core/index.js\")).href;\n    return toEmbeddedToolRuntime(await importModule(coreRuntimeUrl), coreRuntimeUrl);\n  } catch (error) {\n    fallbackLoadError = error;\n  }\n\n  throw new Error(\"Failed to load the Lobster embedded runtime\", {\n    cause: new AggregateError(\n      [coreLoadError, fallbackLoadError],\n      \"Both Lobster embedded runtime load paths failed\",\n    ),\n  });\n}\n\nexport function createEmbeddedLobsterRunner(options?: {\n  loadRuntime?: LoadEmbeddedToolRuntime;\n}): LobsterRunner {\n  const loadRuntime = options?.loadRuntime ?? loadEmbeddedToolRuntimeFromPackage;\n  let runtimePromise: Promise\u003cEmbeddedToolRuntime\u003e | undefined;\n  return {\n    async run(params) {\n      runtimePromise ??= loadRuntime();\n      const runtime = await runtimePromise;\n      return await withTimeout(params.timeoutMs, async (signal) =\u003e {\n        const ctx = createEmbeddedToolContext(params, signal);\n\n        if (params.action === \"run\") {\n          const pipeline = params.pipeline?.trim() ?? \"\";\n          if (!pipeline) {\n            throw new Error(\"pipeline required\");\n          }\n\n          const filePath = await detectWorkflowFile(pipeline, params.cwd);\n          if (filePath) {\n            const parsedArgsJson = params.argsJson?.trim() ?? \"\";\n            let args: Record\u003cstring, unknown\u003e | undefined;\n            if (parsedArgsJson) {\n              try {\n                args = parseWorkflowArgs(parsedArgsJson);\n              } catch {\n                throw new Error(\"run --args-json must be valid JSON\");\n              }\n            }\n            return throwOnErrorEnvelope(\n              normalizeEnvelope(await runtime.runToolRequest({ filePath, args, ctx })),\n            );\n          }\n\n          return throwOnErrorEnvelope(\n            normalizeEnvelope(await runtime.runToolRequest({ pipeline, ctx })),\n          );\n        }\n\n        const token = params.token?.trim() ?? \"\";\n        const approvalId = params.approvalId?.trim() ?? \"\";\n        if (!token \u0026\u0026 !approvalId) {\n          throw new Error(\"token or approvalId required\");\n        }\n        if (typeof params.approve !== \"boolean\") {\n          throw new Error(\"approve required\");\n        }\n\n        return throwOnErrorEnvelope(\n          normalizeEnvelope(\n            await runtime.resumeToolRequest({\n              ...(token ? { token } : {}),\n              ...(approvalId ? { approvalId } : {}),\n              approved: params.approve,\n              ctx,\n            }),\n          ),\n        );\n      });\n    },\n  };\n}\n","src/lobster-taskflow.test.ts":"import { describe, expect, it, vi } from \"vitest\";\nimport type { LobsterRunner } from \"./lobster-runner.js\";\nimport { resumeManagedLobsterFlow, runManagedLobsterFlow } from \"./lobster-taskflow.js\";\nimport { createFakeTaskFlow } from \"./taskflow-test-helpers.js\";\n\nfunction expectManagedFlowFailure(\n  result: Awaited\u003cReturnType\u003ctypeof runManagedLobsterFlow | typeof resumeManagedLobsterFlow\u003e\u003e,\n) {\n  expect(result.ok).toBe(false);\n  if (result.ok) {\n    throw new Error(\"Expected managed Lobster flow to fail\");\n  }\n  return result;\n}\nfunction createRunner(result: Awaited\u003cReturnType\u003cLobsterRunner[\"run\"]\u003e\u003e): LobsterRunner {\n  return {\n    run: vi.fn().mockResolvedValue(result),\n  };\n}\n\nfunction createRunFlowParams(\n  taskFlow: ReturnType\u003ctypeof createFakeTaskFlow\u003e,\n  runner: LobsterRunner,\n): Parameters\u003ctypeof runManagedLobsterFlow\u003e[0] {\n  return {\n    taskFlow,\n    runner,\n    runnerParams: {\n      action: \"run\",\n      pipeline: \"noop\",\n      cwd: process.cwd(),\n      timeoutMs: 1000,\n      maxStdoutBytes: 4096,\n    },\n    controllerId: \"tests/lobster\",\n    goal: \"Run Lobster workflow\",\n  };\n}\n\nfunction createResumeFlowParams(\n  taskFlow: ReturnType\u003ctypeof createFakeTaskFlow\u003e,\n  runner: LobsterRunner,\n): Parameters\u003ctypeof resumeManagedLobsterFlow\u003e[0] {\n  return {\n    taskFlow,\n    runner,\n    flowId: \"flow-1\",\n    expectedRevision: 4,\n    runnerParams: {\n      action: \"resume\",\n      token: \"resume-1\",\n      approve: true,\n      cwd: process.cwd(),\n      timeoutMs: 1000,\n      maxStdoutBytes: 4096,\n    },\n  };\n}\n\ndescribe(\"runManagedLobsterFlow\", () =\u003e {\n  it(\"creates a flow and finishes it when Lobster succeeds\", async () =\u003e {\n    const taskFlow = createFakeTaskFlow();\n    const runner = createRunner({\n      ok: true,\n      status: \"ok\",\n      output: [{ id: \"result-1\" }],\n      requiresApproval: null,\n    });\n\n    const result = await runManagedLobsterFlow(createRunFlowParams(taskFlow, runner));\n\n    expect(result.ok).toBe(true);\n    expect(taskFlow.createManaged).toHaveBeenCalledWith({\n      controllerId: \"tests/lobster\",\n      goal: \"Run Lobster workflow\",\n      currentStep: \"run_lobster\",\n    });\n    expect(taskFlow.finish).toHaveBeenCalledWith({\n      flowId: \"flow-1\",\n      expectedRevision: 1,\n    });\n  });\n\n  it(\"moves the flow to waiting when Lobster requests approval\", async () =\u003e {\n    const taskFlow = createFakeTaskFlow();\n    const createdAt = new Date(\"2026-04-05T21:00:00.000Z\");\n    const runner = createRunner({\n      ok: true,\n      status: \"needs_approval\",\n      output: [],\n      requiresApproval: {\n        type: \"approval_request\",\n        prompt: \"Approve this?\",\n        items: [{ id: \"item-1\", createdAt, count: 2n, skip: undefined }],\n        resumeToken: \"resume-1\",\n      },\n    });\n\n    const result = await runManagedLobsterFlow(createRunFlowParams(taskFlow, runner));\n\n    expect(result.ok).toBe(true);\n    expect(taskFlow.setWaiting).toHaveBeenCalledWith({\n      flowId: \"flow-1\",\n      expectedRevision: 1,\n      currentStep: \"await_lobster_approval\",\n      waitJson: {\n        kind: \"lobster_approval\",\n        prompt: \"Approve this?\",\n        items: [{ id: \"item-1\", createdAt: createdAt.toISOString(), count: \"2\" }],\n        resumeToken: \"resume-1\",\n      },\n    });\n  });\n\n  it(\"fails the flow when Lobster returns an error envelope\", async () =\u003e {\n    const taskFlow = createFakeTaskFlow();\n    const runner = createRunner({\n      ok: false,\n      error: {\n        type: \"runtime_error\",\n        message: \"boom\",\n      },\n    });\n\n    const result = expectManagedFlowFailure(\n      await runManagedLobsterFlow(createRunFlowParams(taskFlow, runner)),\n    );\n    expect(result.error.message).toBe(\"boom\");\n    expect(taskFlow.fail).toHaveBeenCalledWith({\n      flowId: \"flow-1\",\n      expectedRevision: 1,\n    });\n  });\n\n  it(\"fails the flow when the runner throws\", async () =\u003e {\n    const taskFlow = createFakeTaskFlow();\n    const runner: LobsterRunner = {\n      run: vi.fn().mockRejectedValue(new Error(\"crashed\")),\n    };\n\n    const result = expectManagedFlowFailure(\n      await runManagedLobsterFlow(createRunFlowParams(taskFlow, runner)),\n    );\n    expect(result.error.message).toBe(\"crashed\");\n    expect(taskFlow.fail).toHaveBeenCalledWith({\n      flowId: \"flow-1\",\n      expectedRevision: 1,\n    });\n  });\n});\n\ndescribe(\"resumeManagedLobsterFlow\", () =\u003e {\n  it(\"resumes the flow and finishes it on success\", async () =\u003e {\n    const taskFlow = createFakeTaskFlow();\n    const runner = createRunner({\n      ok: true,\n      status: \"ok\",\n      output: [],\n      requiresApproval: null,\n    });\n\n    const result = await resumeManagedLobsterFlow(createResumeFlowParams(taskFlow, runner));\n\n    expect(result.ok).toBe(true);\n    expect(taskFlow.resume).toHaveBeenCalledWith({\n      flowId: \"flow-1\",\n      expectedRevision: 4,\n      status: \"running\",\n      currentStep: \"resume_lobster\",\n    });\n    expect(taskFlow.finish).toHaveBeenCalledWith({\n      flowId: \"flow-1\",\n      expectedRevision: 5,\n    });\n  });\n\n  it(\"returns a mutation error when taskFlow resume is rejected\", async () =\u003e {\n    const taskFlow = createFakeTaskFlow({\n      resume: vi.fn().mockReturnValue({\n        applied: false,\n        code: \"revision_conflict\",\n      }),\n    });\n    const runner = createRunner({\n      ok: true,\n      status: \"ok\",\n      output: [],\n      requiresApproval: null,\n    });\n\n    const result = expectManagedFlowFailure(\n      await resumeManagedLobsterFlow(createResumeFlowParams(taskFlow, runner)),\n    );\n    expect(result.error.message).toMatch(/revision_conflict/);\n    expect(runner.run).not.toHaveBeenCalled();\n  });\n\n  it(\"returns to waiting when the resumed Lobster run needs approval again\", async () =\u003e {\n    const taskFlow = createFakeTaskFlow();\n    const runner = createRunner({\n      ok: true,\n      status: \"needs_approval\",\n      output: [],\n      requiresApproval: {\n        type: \"approval_request\",\n        prompt: \"Approve this too?\",\n        items: [{ id: \"item-2\" }],\n        resumeToken: \"resume-2\",\n      },\n    });\n\n    const result = await resumeManagedLobsterFlow(createResumeFlowParams(taskFlow, runner));\n\n    expect(result.ok).toBe(true);\n    expect(taskFlow.setWaiting).toHaveBeenCalledWith({\n      flowId: \"flow-1\",\n      expectedRevision: 5,\n      currentStep: \"await_lobster_approval\",\n      waitJson: {\n        kind: \"lobster_approval\",\n        prompt: \"Approve this too?\",\n        items: [{ id: \"item-2\" }],\n        resumeToken: \"resume-2\",\n      },\n    });\n  });\n});\n","src/lobster-taskflow.ts":"import type { OpenClawPluginApi } from \"../runtime-api.js\";\nimport type { LobsterEnvelope, LobsterRunner, LobsterRunnerParams } from \"./lobster-runner.js\";\n\ntype JsonLike =\n  | null\n  | boolean\n  | number\n  | string\n  | JsonLike[]\n  | {\n      [key: string]: JsonLike;\n    };\n\ntype BoundTaskFlow = ReturnType\u003c\n  NonNullable\u003cOpenClawPluginApi[\"runtime\"]\u003e[\"tasks\"][\"managedFlows\"][\"bindSession\"]\n\u003e;\n\ntype FlowRecord = ReturnType\u003cBoundTaskFlow[\"createManaged\"]\u003e;\ntype MutationResult = ReturnType\u003cBoundTaskFlow[\"setWaiting\"]\u003e;\n\ntype LobsterApprovalWaitState = {\n  kind: \"lobster_approval\";\n  prompt: string;\n  items: JsonLike[];\n  resumeToken?: string;\n  approvalId?: string;\n};\n\ntype RunManagedLobsterFlowParams = {\n  taskFlow: BoundTaskFlow;\n  runner: LobsterRunner;\n  runnerParams: LobsterRunnerParams;\n  controllerId: string;\n  goal: string;\n  stateJson?: JsonLike;\n  currentStep?: string;\n  waitingStep?: string;\n};\n\ntype ResumeManagedLobsterFlowParams = {\n  taskFlow: BoundTaskFlow;\n  runner: LobsterRunner;\n  runnerParams: LobsterRunnerParams \u0026 {\n    action: \"resume\";\n    approve: boolean;\n  } \u0026 ({ token: string } | { approvalId: string });\n  flowId: string;\n  expectedRevision: number;\n  currentStep?: string;\n  waitingStep?: string;\n};\n\nexport type ManagedLobsterFlowResult =\n  | {\n      ok: true;\n      envelope: LobsterEnvelope;\n      flow: FlowRecord;\n      mutation: MutationResult;\n    }\n  | {\n      ok: false;\n      flow?: FlowRecord;\n      mutation?: MutationResult;\n      error: Error;\n    };\n\nfunction toJsonLike(value: unknown, seen = new WeakSet\u003cobject\u003e()): JsonLike {\n  if (value === null) {\n    return null;\n  }\n  switch (typeof value) {\n    case \"boolean\":\n    case \"string\":\n      return value;\n    case \"number\":\n      return Number.isFinite(value) ? value : String(value);\n    case \"bigint\":\n      return value.toString();\n    case \"undefined\":\n    case \"function\":\n    case \"symbol\":\n      return null;\n    case \"object\": {\n      if (value instanceof Date) {\n        return value.toISOString();\n      }\n      if (Array.isArray(value)) {\n        return value.map((item) =\u003e toJsonLike(item, seen));\n      }\n      if (seen.has(value)) {\n        return \"[Circular]\";\n      }\n      seen.add(value);\n      const jsonObject: Record\u003cstring, JsonLike\u003e = {};\n      for (const [key, entry] of Object.entries(value)) {\n        if (entry === undefined || typeof entry === \"function\" || typeof entry === \"symbol\") {\n          continue;\n        }\n        jsonObject[key] = toJsonLike(entry, seen);\n      }\n      seen.delete(value);\n      return jsonObject;\n    }\n  }\n  return null;\n}\n\nfunction buildApprovalWaitState(envelope: Extract\u003cLobsterEnvelope, { ok: true }\u003e): JsonLike {\n  if (!envelope.requiresApproval) {\n    return {\n      kind: \"lobster_approval\",\n      prompt: \"\",\n      items: [],\n    } satisfies LobsterApprovalWaitState;\n  }\n  return {\n    kind: \"lobster_approval\",\n    prompt: envelope.requiresApproval.prompt,\n    items: envelope.requiresApproval.items.map((item) =\u003e toJsonLike(item)),\n    ...(envelope.requiresApproval.resumeToken\n      ? { resumeToken: envelope.requiresApproval.resumeToken }\n      : {}),\n    ...(envelope.requiresApproval.approvalId\n      ? { approvalId: envelope.requiresApproval.approvalId }\n      : {}),\n  } satisfies LobsterApprovalWaitState;\n}\n\nfunction applyEnvelopeToFlow(params: {\n  taskFlow: BoundTaskFlow;\n  flow: FlowRecord;\n  envelope: LobsterEnvelope;\n  waitingStep: string;\n}): MutationResult {\n  const { taskFlow, flow, envelope, waitingStep } = params;\n\n  if (!envelope.ok) {\n    return taskFlow.fail({\n      flowId: flow.flowId,\n      expectedRevision: flow.revision,\n    });\n  }\n\n  if (envelope.status === \"needs_approval\") {\n    return taskFlow.setWaiting({\n      flowId: flow.flowId,\n      expectedRevision: flow.revision,\n      currentStep: waitingStep,\n      waitJson: buildApprovalWaitState(envelope),\n    });\n  }\n\n  return taskFlow.finish({\n    flowId: flow.flowId,\n    expectedRevision: flow.revision,\n  });\n}\n\nfunction buildEnvelopeError(envelope: Extract\u003cLobsterEnvelope, { ok: false }\u003e) {\n  return new Error(envelope.error.message);\n}\n\nexport async function runManagedLobsterFlow(\n  params: RunManagedLobsterFlowParams,\n): Promise\u003cManagedLobsterFlowResult\u003e {\n  const flow = params.taskFlow.createManaged({\n    controllerId: params.controllerId,\n    goal: params.goal,\n    currentStep: params.currentStep ?? \"run_lobster\",\n    ...(params.stateJson !== undefined ? { stateJson: params.stateJson } : {}),\n  });\n\n  try {\n    const envelope = await params.runner.run(params.runnerParams);\n    const mutation = applyEnvelopeToFlow({\n      taskFlow: params.taskFlow,\n      flow,\n      envelope,\n      waitingStep: params.waitingStep ?? \"await_lobster_approval\",\n    });\n    if (!envelope.ok) {\n      return {\n        ok: false,\n        flow,\n        mutation,\n        error: buildEnvelopeError(envelope),\n      };\n    }\n    return {\n      ok: true,\n      envelope,\n      flow,\n      mutation,\n    };\n  } catch (error) {\n    const err = error instanceof Error ? error : new Error(String(error));\n    try {\n      const mutation = params.taskFlow.fail({\n        flowId: flow.flowId,\n        expectedRevision: flow.revision,\n      });\n      return {\n        ok: false,\n        flow,\n        mutation,\n        error: err,\n      };\n    } catch {\n      return {\n        ok: false,\n        flow,\n        error: err,\n      };\n    }\n  }\n}\n\nexport async function resumeManagedLobsterFlow(\n  params: ResumeManagedLobsterFlowParams,\n): Promise\u003cManagedLobsterFlowResult\u003e {\n  const resumed = params.taskFlow.resume({\n    flowId: params.flowId,\n    expectedRevision: params.expectedRevision,\n    status: \"running\",\n    currentStep: params.currentStep ?? \"resume_lobster\",\n  });\n\n  if (!resumed.applied) {\n    return {\n      ok: false,\n      mutation: resumed,\n      error: new Error(`TaskFlow resume failed: ${resumed.code}`),\n    };\n  }\n\n  try {\n    const envelope = await params.runner.run(params.runnerParams);\n    const mutation = applyEnvelopeToFlow({\n      taskFlow: params.taskFlow,\n      flow: resumed.flow,\n      envelope,\n      waitingStep: params.waitingStep ?? \"await_lobster_approval\",\n    });\n    if (!envelope.ok) {\n      return {\n        ok: false,\n        flow: resumed.flow,\n        mutation,\n        error: buildEnvelopeError(envelope),\n      };\n    }\n    return {\n      ok: true,\n      envelope,\n      flow: resumed.flow,\n      mutation,\n    };\n  } catch (error) {\n    const err = error instanceof Error ? error : new Error(String(error));\n    try {\n      const mutation = params.taskFlow.fail({\n        flowId: params.flowId,\n        expectedRevision: resumed.flow.revision,\n      });\n      return {\n        ok: false,\n        flow: resumed.flow,\n        mutation,\n        error: err,\n      };\n    } catch {\n      return {\n        ok: false,\n        flow: resumed.flow,\n        error: err,\n      };\n    }\n  }\n}\n","src/lobster-tool.test.ts":"import { createTestPluginApi } from \"openclaw/plugin-sdk/plugin-test-api\";\nimport { describe, expect, it, vi } from \"vitest\";\nimport type { OpenClawPluginApi, OpenClawPluginToolContext } from \"../runtime-api.js\";\nimport { createLobsterTool } from \"./lobster-tool.js\";\nimport { createFakeTaskFlow } from \"./taskflow-test-helpers.js\";\n\nfunction fakeApi(overrides: Partial\u003cOpenClawPluginApi\u003e = {}): OpenClawPluginApi {\n  return createTestPluginApi({\n    id: \"lobster\",\n    name: \"lobster\",\n    source: \"test\",\n    runtime: { version: \"test\" } as any,\n    resolvePath: (p) =\u003e p,\n    ...overrides,\n  });\n}\n\nfunction fakeCtx(overrides: Partial\u003cOpenClawPluginToolContext\u003e = {}): OpenClawPluginToolContext {\n  return {\n    config: {},\n    workspaceDir: \"/tmp\",\n    agentDir: \"/tmp\",\n    agentId: \"main\",\n    sessionKey: \"main\",\n    messageChannel: undefined,\n    agentAccountId: undefined,\n    sandboxed: false,\n    ...overrides,\n  };\n}\n\nfunction requireRecord(value: unknown, label: string): Record\u003cstring, unknown\u003e {\n  if (value === null || typeof value !== \"object\" || Array.isArray(value)) {\n    throw new Error(`expected ${label} to be a record`);\n  }\n  return value as Record\u003cstring, unknown\u003e;\n}\n\ndescribe(\"lobster plugin tool\", () =\u003e {\n  it(\"returns the Lobster envelope in details\", async () =\u003e {\n    const runner = {\n      run: vi.fn().mockResolvedValue({\n        ok: true,\n        status: \"ok\",\n        output: [{ hello: \"world\" }],\n        requiresApproval: null,\n      }),\n    };\n\n    const tool = createLobsterTool(fakeApi(), { runner });\n    const res = await tool.execute(\"call1\", {\n      action: \"run\",\n      pipeline: \"noop\",\n      timeoutMs: 1000,\n    });\n\n    expect(runner.run).toHaveBeenCalledWith({\n      action: \"run\",\n      pipeline: \"noop\",\n      cwd: process.cwd(),\n      timeoutMs: 1000,\n      maxStdoutBytes: 512_000,\n    });\n    const details = requireRecord(res.details, \"lobster tool details\");\n    expect(details.ok).toBe(true);\n    expect(details.status).toBe(\"ok\");\n    expect(details.output).toEqual([{ hello: \"world\" }]);\n    expect(details.requiresApproval).toBeNull();\n  });\n\n  it(\"supports approval envelopes without changing the tool contract\", async () =\u003e {\n    const runner = {\n      run: vi.fn().mockResolvedValue({\n        ok: true,\n        status: \"needs_approval\",\n        output: [],\n        requiresApproval: {\n          type: \"approval_request\",\n          prompt: \"Send these alerts?\",\n          items: [{ id: \"alert-1\" }],\n          resumeToken: \"resume-token-1\",\n        },\n      }),\n    };\n\n    const tool = createLobsterTool(fakeApi(), { runner });\n    const res = await tool.execute(\"call-injected-runner\", {\n      action: \"run\",\n      pipeline: \"noop\",\n      argsJson: '{\"since_hours\":1}',\n      timeoutMs: 1500,\n      maxStdoutBytes: 4096,\n    });\n\n    expect(runner.run).toHaveBeenCalledWith({\n      action: \"run\",\n      pipeline: \"noop\",\n      argsJson: '{\"since_hours\":1}',\n      cwd: process.cwd(),\n      timeoutMs: 1500,\n      maxStdoutBytes: 4096,\n    });\n    const details = requireRecord(res.details, \"approval lobster tool details\");\n    expect(details.ok).toBe(true);\n    expect(details.status).toBe(\"needs_approval\");\n    const approval = requireRecord(details.requiresApproval, \"approval request\");\n    expect(approval.type).toBe(\"approval_request\");\n    expect(approval.prompt).toBe(\"Send these alerts?\");\n    expect(approval.resumeToken).toBe(\"resume-token-1\");\n  });\n\n  it(\"throws when the runner returns an error envelope\", async () =\u003e {\n    const tool = createLobsterTool(fakeApi(), {\n      runner: {\n        run: vi.fn().mockResolvedValue({\n          ok: false,\n          error: {\n            type: \"runtime_error\",\n            message: \"boom\",\n          },\n        }),\n      },\n    });\n\n    await expect(\n      tool.execute(\"call-runner-error\", {\n        action: \"run\",\n        pipeline: \"noop\",\n      }),\n    ).rejects.toThrow(\"boom\");\n  });\n\n  it(\"can run through managed TaskFlow mode\", async () =\u003e {\n    const runner = {\n      run: vi.fn().mockResolvedValue({\n        ok: true,\n        status: \"needs_approval\",\n        output: [],\n        requiresApproval: {\n          type: \"approval_request\",\n          prompt: \"Approve this?\",\n          items: [{ id: \"item-1\" }],\n          resumeToken: \"resume-1\",\n          approvalId: \"approval-1\",\n        },\n      }),\n    };\n    const taskFlow = createFakeTaskFlow();\n\n    const tool = createLobsterTool(fakeApi(), { runner, taskFlow });\n    const res = await tool.execute(\"call-managed-run\", {\n      action: \"run\",\n      pipeline: \"noop\",\n      flowControllerId: \"tests/lobster\",\n      flowGoal: \"Run Lobster workflow\",\n      flowStateJson: '{\"lane\":\"email\"}',\n      flowCurrentStep: \"run_lobster\",\n      flowWaitingStep: \"await_review\",\n    });\n\n    expect(taskFlow.createManaged).toHaveBeenCalledWith({\n      controllerId: \"tests/lobster\",\n      goal: \"Run Lobster workflow\",\n      currentStep: \"run_lobster\",\n      stateJson: { lane: \"email\" },\n    });\n    expect(taskFlow.setWaiting).toHaveBeenCalledWith({\n      flowId: \"flow-1\",\n      expectedRevision: 1,\n      currentStep: \"await_review\",\n      waitJson: {\n        kind: \"lobster_approval\",\n        prompt: \"Approve this?\",\n        items: [{ id: \"item-1\" }],\n        resumeToken: \"resume-1\",\n        approvalId: \"approval-1\",\n      },\n    });\n    const details = requireRecord(res.details, \"managed run lobster tool details\");\n    expect(details.ok).toBe(true);\n    expect(details.status).toBe(\"needs_approval\");\n    const flow = requireRecord(details.flow, \"managed run flow details\");\n    expect(flow.flowId).toBe(\"flow-1\");\n    const mutation = requireRecord(details.mutation, \"managed run mutation details\");\n    expect(mutation.applied).toBe(true);\n  });\n\n  it(\"rejects managed TaskFlow params when no bound taskFlow runtime is available\", async () =\u003e {\n    const tool = createLobsterTool(fakeApi(), {\n      runner: { run: vi.fn() },\n    });\n\n    await expect(\n      tool.execute(\"call-missing-taskflow\", {\n        action: \"run\",\n        pipeline: \"noop\",\n        flowControllerId: \"tests/lobster\",\n        flowGoal: \"Run Lobster workflow\",\n      }),\n    ).rejects.toThrow(/Managed TaskFlow run mode requires a bound taskFlow runtime/);\n  });\n\n  it(\"rejects invalid flowStateJson in managed TaskFlow mode\", async () =\u003e {\n    const tool = createLobsterTool(fakeApi(), {\n      runner: { run: vi.fn() },\n      taskFlow: createFakeTaskFlow(),\n    });\n\n    await expect(\n      tool.execute(\"call-invalid-flow-json\", {\n        action: \"run\",\n        pipeline: \"noop\",\n        flowControllerId: \"tests/lobster\",\n        flowGoal: \"Run Lobster workflow\",\n        flowStateJson: \"{bad\",\n      }),\n    ).rejects.toThrow(/flowStateJson must be valid JSON/);\n  });\n\n  it(\"can resume managed TaskFlow mode with only approvalId\", async () =\u003e {\n    const runner = {\n      run: vi.fn().mockResolvedValue({\n        ok: true,\n        status: \"ok\",\n        output: [],\n        requiresApproval: null,\n      }),\n    };\n    const taskFlow = createFakeTaskFlow();\n    const tool = createLobsterTool(fakeApi(), { runner, taskFlow });\n\n    const res = await tool.execute(\"call-managed-resume-approval-id\", {\n      action: \"resume\",\n      approvalId: \"approval-1\",\n      approve: true,\n      flowId: \"flow-1\",\n      flowExpectedRevision: 1,\n      flowCurrentStep: \"resume_lobster\",\n    });\n\n    expect(taskFlow.resume).toHaveBeenCalledWith({\n      flowId: \"flow-1\",\n      expectedRevision: 1,\n      status: \"running\",\n      currentStep: \"resume_lobster\",\n    });\n    expect(runner.run).toHaveBeenCalledWith({\n      action: \"resume\",\n      approvalId: \"approval-1\",\n      approve: true,\n      cwd: process.cwd(),\n      timeoutMs: 20_000,\n      maxStdoutBytes: 512_000,\n    });\n    const details = requireRecord(res.details, \"managed resume lobster tool details\");\n    expect(details.ok).toBe(true);\n    expect(details.status).toBe(\"ok\");\n    const mutation = requireRecord(details.mutation, \"managed resume mutation details\");\n    expect(mutation.applied).toBe(true);\n  });\n\n  it(\"rejects managed TaskFlow resume mode without a token or approvalId\", async () =\u003e {\n    const tool = createLobsterTool(fakeApi(), {\n      runner: { run: vi.fn() },\n      taskFlow: createFakeTaskFlow(),\n    });\n\n    await expect(\n      tool.execute(\"call-missing-resume-token\", {\n        action: \"resume\",\n        flowId: \"flow-1\",\n        flowExpectedRevision: 1,\n        approve: true,\n      }),\n    ).rejects.toThrow(/token or approvalId required when using managed TaskFlow resume mode/);\n  });\n\n  it(\"rejects managed TaskFlow resume mode without approve\", async () =\u003e {\n    const tool = createLobsterTool(fakeApi(), {\n      runner: { run: vi.fn() },\n      taskFlow: createFakeTaskFlow(),\n    });\n\n    await expect(\n      tool.execute(\"call-missing-resume-approve\", {\n        action: \"resume\",\n        token: \"resume-token\",\n        flowId: \"flow-1\",\n        flowExpectedRevision: 1,\n      }),\n    ).rejects.toThrow(/approve required when using managed TaskFlow resume mode/);\n  });\n\n  it(\"requires action\", async () =\u003e {\n    const tool = createLobsterTool(fakeApi(), {\n      runner: { run: vi.fn() },\n    });\n    await expect(tool.execute(\"call-action-missing\", {})).rejects.toThrow(/action required/);\n  });\n\n  it(\"rejects unknown action\", async () =\u003e {\n    const tool = createLobsterTool(fakeApi(), {\n      runner: { run: vi.fn() },\n    });\n    await expect(\n      tool.execute(\"call-action-unknown\", {\n        action: \"explode\",\n      }),\n    ).rejects.toThrow(/Unknown action/);\n  });\n\n  it(\"rejects absolute cwd\", async () =\u003e {\n    const tool = createLobsterTool(fakeApi(), {\n      runner: { run: vi.fn() },\n    });\n    await expect(\n      tool.execute(\"call-absolute-cwd\", {\n        action: \"run\",\n        pipeline: \"noop\",\n        cwd: \"/tmp\",\n      }),\n    ).rejects.toThrow(/cwd must be a relative path/);\n  });\n\n  it(\"rejects cwd that escapes the gateway working directory\", async () =\u003e {\n    const tool = createLobsterTool(fakeApi(), {\n      runner: { run: vi.fn() },\n    });\n    await expect(\n      tool.execute(\"call-escape-cwd\", {\n        action: \"run\",\n        pipeline: \"noop\",\n        cwd: \"../../etc\",\n      }),\n    ).rejects.toThrow(/must stay within/);\n  });\n\n  it(\"can be gated off in sandboxed contexts\", () =\u003e {\n    const api = fakeApi();\n    const factoryTool = (ctx: OpenClawPluginToolContext) =\u003e {\n      if (ctx.sandboxed) {\n        return null;\n      }\n      return createLobsterTool(api, {\n        runner: { run: vi.fn() },\n      });\n    };\n\n    expect(factoryTool(fakeCtx({ sandboxed: true }))).toBeNull();\n    expect(factoryTool(fakeCtx({ sandboxed: false }))?.name).toBe(\"lobster\");\n  });\n});\n","src/lobster-tool.ts":"import { Type } from \"typebox\";\nimport type { OpenClawPluginApi } from \"../runtime-api.js\";\nimport {\n  createEmbeddedLobsterRunner,\n  resolveLobsterCwd,\n  type LobsterRunner,\n  type LobsterRunnerParams,\n} from \"./lobster-runner.js\";\nimport {\n  type ManagedLobsterFlowResult,\n  resumeManagedLobsterFlow,\n  runManagedLobsterFlow,\n} from \"./lobster-taskflow.js\";\n\ntype BoundTaskFlow = ReturnType\u003c\n  NonNullable\u003cOpenClawPluginApi[\"runtime\"]\u003e[\"tasks\"][\"managedFlows\"][\"bindSession\"]\n\u003e;\n\ntype JsonLike =\n  | null\n  | boolean\n  | number\n  | string\n  | JsonLike[]\n  | {\n      [key: string]: JsonLike;\n    };\n\ntype LobsterToolOptions = {\n  runner?: LobsterRunner;\n  taskFlow?: BoundTaskFlow;\n};\n\ntype ManagedFlowRunParams = {\n  controllerId: string;\n  goal: string;\n  currentStep?: string;\n  waitingStep?: string;\n  stateJson?: JsonLike;\n};\n\ntype ManagedFlowResumeParams = {\n  flowId: string;\n  expectedRevision: number;\n  currentStep?: string;\n  waitingStep?: string;\n};\n\ntype ManagedFlowSuccessResult = {\n  ok: true;\n  envelope: unknown;\n  flow: unknown;\n  mutation: unknown;\n};\n\nfunction readOptionalTrimmedString(value: unknown, fieldName: string): string | undefined {\n  if (value === undefined) {\n    return undefined;\n  }\n  if (typeof value !== \"string\") {\n    throw new Error(`${fieldName} must be a string`);\n  }\n  const trimmed = value.trim();\n  return trimmed ? trimmed : undefined;\n}\n\nfunction readOptionalNumber(value: unknown, fieldName: string): number | undefined {\n  if (value === undefined) {\n    return undefined;\n  }\n  if (typeof value !== \"number\" || !Number.isInteger(value)) {\n    throw new Error(`${fieldName} must be an integer`);\n  }\n  return value;\n}\n\nfunction readOptionalBoolean(value: unknown, fieldName: string): boolean | undefined {\n  if (value === undefined) {\n    return undefined;\n  }\n  if (typeof value !== \"boolean\") {\n    throw new Error(`${fieldName} must be a boolean`);\n  }\n  return value;\n}\n\nfunction parseOptionalFlowStateJson(value: unknown): JsonLike | undefined {\n  if (value === undefined) {\n    return undefined;\n  }\n  if (typeof value !== \"string\") {\n    throw new Error(\"flowStateJson must be a JSON string\");\n  }\n  try {\n    return JSON.parse(value) as JsonLike;\n  } catch {\n    throw new Error(\"flowStateJson must be valid JSON\");\n  }\n}\n\nfunction parseRunFlowParams(params: Record\u003cstring, unknown\u003e): ManagedFlowRunParams | null {\n  const controllerId = readOptionalTrimmedString(params.flowControllerId, \"flowControllerId\");\n  const goal = readOptionalTrimmedString(params.flowGoal, \"flowGoal\");\n  const currentStep = readOptionalTrimmedString(params.flowCurrentStep, \"flowCurrentStep\");\n  const waitingStep = readOptionalTrimmedString(params.flowWaitingStep, \"flowWaitingStep\");\n  const stateJson = parseOptionalFlowStateJson(params.flowStateJson);\n  const resumeFlowId = readOptionalTrimmedString(params.flowId, \"flowId\");\n  const resumeRevision = readOptionalNumber(params.flowExpectedRevision, \"flowExpectedRevision\");\n\n  const hasRunFields =\n    controllerId !== undefined ||\n    goal !== undefined ||\n    currentStep !== undefined ||\n    waitingStep !== undefined ||\n    stateJson !== undefined;\n\n  if (!hasRunFields) {\n    return null;\n  }\n  if (resumeFlowId !== undefined || resumeRevision !== undefined) {\n    throw new Error(\"run action does not accept flowId or flowExpectedRevision\");\n  }\n  if (!controllerId) {\n    throw new Error(\"flowControllerId required when using managed TaskFlow run mode\");\n  }\n  if (!goal) {\n    throw new Error(\"flowGoal required when using managed TaskFlow run mode\");\n  }\n  return {\n    controllerId,\n    goal,\n    ...(currentStep ? { currentStep } : {}),\n    ...(waitingStep ? { waitingStep } : {}),\n    ...(stateJson !== undefined ? { stateJson } : {}),\n  };\n}\n\nfunction parseResumeFlowParams(params: Record\u003cstring, unknown\u003e): ManagedFlowResumeParams | null {\n  const flowId = readOptionalTrimmedString(params.flowId, \"flowId\");\n  const expectedRevision = readOptionalNumber(params.flowExpectedRevision, \"flowExpectedRevision\");\n  const currentStep = readOptionalTrimmedString(params.flowCurrentStep, \"flowCurrentStep\");\n  const waitingStep = readOptionalTrimmedString(params.flowWaitingStep, \"flowWaitingStep\");\n  const token = readOptionalTrimmedString(params.token, \"token\");\n  const approvalId = readOptionalTrimmedString(params.approvalId, \"approvalId\");\n  const approve = readOptionalBoolean(params.approve, \"approve\");\n  const runControllerId = readOptionalTrimmedString(params.flowControllerId, \"flowControllerId\");\n  const runGoal = readOptionalTrimmedString(params.flowGoal, \"flowGoal\");\n  const stateJson = params.flowStateJson;\n\n  const hasResumeFields =\n    flowId !== undefined ||\n    expectedRevision !== undefined ||\n    currentStep !== undefined ||\n    waitingStep !== undefined;\n\n  if (!hasResumeFields) {\n    return null;\n  }\n  if (runControllerId !== undefined || runGoal !== undefined || stateJson !== undefined) {\n    throw new Error(\"resume action does not accept flowControllerId, flowGoal, or flowStateJson\");\n  }\n  if (!flowId) {\n    throw new Error(\"flowId required when using managed TaskFlow resume mode\");\n  }\n  if (expectedRevision === undefined) {\n    throw new Error(\"flowExpectedRevision required when using managed TaskFlow resume mode\");\n  }\n  if (!token \u0026\u0026 !approvalId) {\n    throw new Error(\"token or approvalId required when using managed TaskFlow resume mode\");\n  }\n  if (approve === undefined) {\n    throw new Error(\"approve required when using managed TaskFlow resume mode\");\n  }\n  return {\n    flowId,\n    expectedRevision,\n    ...(currentStep ? { currentStep } : {}),\n    ...(waitingStep ? { waitingStep } : {}),\n  };\n}\n\nfunction formatManagedFlowResult(result: ManagedFlowSuccessResult) {\n  const envelope =\n    result.envelope \u0026\u0026 typeof result.envelope === \"object\" \u0026\u0026 !Array.isArray(result.envelope)\n      ? result.envelope\n      : { envelope: result.envelope };\n  const details = {\n    ...envelope,\n    flow: result.flow,\n    mutation: result.mutation,\n  };\n  return {\n    content: [{ type: \"text\", text: JSON.stringify(details, null, 2) }],\n    details,\n  };\n}\n\nfunction requireTaskFlowRuntime(taskFlow: BoundTaskFlow | undefined, action: \"run\" | \"resume\") {\n  if (!taskFlow) {\n    throw new Error(`Managed TaskFlow ${action} mode requires a bound taskFlow runtime`);\n  }\n  return taskFlow;\n}\n\nfunction resolveManagedFlowToolResult(result: ManagedLobsterFlowResult) {\n  if (!result.ok) {\n    throw result.error;\n  }\n  return formatManagedFlowResult(result);\n}\n\nexport function createLobsterTool(api: OpenClawPluginApi, options?: LobsterToolOptions) {\n  const runner = options?.runner ?? createEmbeddedLobsterRunner();\n  return {\n    name: \"lobster\",\n    label: \"Lobster Workflow\",\n    description:\n      \"Run Lobster pipelines as a local-first workflow runtime (typed JSON envelope + resumable approvals).\",\n    parameters: Type.Object({\n      // NOTE: Prefer string enums in tool schemas; some providers reject unions/anyOf.\n      action: Type.Unsafe\u003c\"run\" | \"resume\"\u003e({ type: \"string\", enum: [\"run\", \"resume\"] }),\n      pipeline: Type.Optional(Type.String()),\n      argsJson: Type.Optional(Type.String()),\n      token: Type.Optional(Type.String()),\n      approvalId: Type.Optional(Type.String()),\n      approve: Type.Optional(Type.Boolean()),\n      cwd: Type.Optional(\n        Type.String({\n          description:\n            \"Relative working directory (optional). Must stay within the gateway working directory.\",\n        }),\n      ),\n      timeoutMs: Type.Optional(Type.Number()),\n      maxStdoutBytes: Type.Optional(Type.Number()),\n      flowControllerId: Type.Optional(Type.String()),\n      flowGoal: Type.Optional(Type.String()),\n      flowStateJson: Type.Optional(Type.String()),\n      flowId: Type.Optional(Type.String()),\n      flowExpectedRevision: Type.Optional(Type.Number()),\n      flowCurrentStep: Type.Optional(Type.String()),\n      flowWaitingStep: Type.Optional(Type.String()),\n    }),\n    async execute(_id: string, params: Record\u003cstring, unknown\u003e) {\n      const action = typeof params.action === \"string\" ? params.action.trim() : \"\";\n      if (!action) {\n        throw new Error(\"action required\");\n      }\n      if (action !== \"run\" \u0026\u0026 action !== \"resume\") {\n        throw new Error(`Unknown action: ${action}`);\n      }\n\n      const cwd = resolveLobsterCwd(params.cwd);\n      const timeoutMs = typeof params.timeoutMs === \"number\" ? params.timeoutMs : 20_000;\n      const maxStdoutBytes =\n        typeof params.maxStdoutBytes === \"number\" ? params.maxStdoutBytes : 512_000;\n\n      if (api.runtime?.version \u0026\u0026 api.logger?.debug) {\n        api.logger.debug(`lobster plugin runtime=${api.runtime.version}`);\n      }\n\n      const runnerParams: LobsterRunnerParams = {\n        action,\n        ...(typeof params.pipeline === \"string\" ? { pipeline: params.pipeline } : {}),\n        ...(typeof params.argsJson === \"string\" ? { argsJson: params.argsJson } : {}),\n        ...(typeof params.token === \"string\" ? { token: params.token } : {}),\n        ...(typeof params.approvalId === \"string\" ? { approvalId: params.approvalId } : {}),\n        ...(typeof params.approve === \"boolean\" ? { approve: params.approve } : {}),\n        cwd,\n        timeoutMs,\n        maxStdoutBytes,\n      };\n\n      const taskFlow = options?.taskFlow;\n      if (action === \"run\") {\n        const flowParams = parseRunFlowParams(params);\n        if (flowParams) {\n          return resolveManagedFlowToolResult(\n            await runManagedLobsterFlow({\n              taskFlow: requireTaskFlowRuntime(taskFlow, \"run\"),\n              runner,\n              runnerParams,\n              controllerId: flowParams.controllerId,\n              goal: flowParams.goal,\n              ...(flowParams.stateJson !== undefined ? { stateJson: flowParams.stateJson } : {}),\n              ...(flowParams.currentStep ? { currentStep: flowParams.currentStep } : {}),\n              ...(flowParams.waitingStep ? { waitingStep: flowParams.waitingStep } : {}),\n            }),\n          );\n        }\n      } else {\n        const flowParams = parseResumeFlowParams(params);\n        if (flowParams) {\n          return resolveManagedFlowToolResult(\n            await resumeManagedLobsterFlow({\n              taskFlow: requireTaskFlowRuntime(taskFlow, \"resume\"),\n              runner,\n              runnerParams: runnerParams as LobsterRunnerParams \u0026 {\n                action: \"resume\";\n                approve: boolean;\n              } \u0026 ({ token: string } | { approvalId: string }),\n              flowId: flowParams.flowId,\n              expectedRevision: flowParams.expectedRevision,\n              ...(flowParams.currentStep ? { currentStep: flowParams.currentStep } : {}),\n              ...(flowParams.waitingStep ? { waitingStep: flowParams.waitingStep } : {}),\n            }),\n          );\n        }\n      }\n\n      const envelope = await runner.run(runnerParams);\n      if (!envelope.ok) {\n        throw new Error(envelope.error.message);\n      }\n      return {\n        content: [{ type: \"text\", text: JSON.stringify(envelope, null, 2) }],\n        details: envelope,\n      };\n    },\n  };\n}\n","src/taskflow-test-helpers.ts":"import { vi } from \"vitest\";\nimport type { OpenClawPluginApi } from \"../runtime-api.js\";\n\ntype BoundTaskFlow = ReturnType\u003c\n  NonNullable\u003cOpenClawPluginApi[\"runtime\"]\u003e[\"tasks\"][\"managedFlows\"][\"bindSession\"]\n\u003e;\n\nexport function createFakeTaskFlow(overrides?: Partial\u003cBoundTaskFlow\u003e): BoundTaskFlow {\n  const baseFlow = {\n    flowId: \"flow-1\",\n    revision: 1,\n    syncMode: \"managed\" as const,\n    controllerId: \"tests/lobster\",\n    ownerKey: \"agent:main:main\",\n    status: \"running\" as const,\n    goal: \"Run Lobster workflow\",\n  };\n\n  return {\n    sessionKey: \"agent:main:main\",\n    createManaged: vi.fn().mockReturnValue(baseFlow),\n    get: vi.fn(),\n    list: vi.fn().mockReturnValue([]),\n    findLatest: vi.fn(),\n    resolve: vi.fn(),\n    getTaskSummary: vi.fn(),\n    setWaiting: vi.fn().mockImplementation((input) =\u003e ({\n      applied: true,\n      flow: { ...baseFlow, revision: input.expectedRevision + 1, status: \"waiting\" as const },\n    })),\n    resume: vi.fn().mockImplementation((input) =\u003e ({\n      applied: true,\n      flow: { ...baseFlow, revision: input.expectedRevision + 1, status: \"running\" as const },\n    })),\n    finish: vi.fn().mockImplementation((input) =\u003e ({\n      applied: true,\n      flow: { ...baseFlow, revision: input.expectedRevision + 1, status: \"completed\" as const },\n    })),\n    fail: vi.fn().mockImplementation((input) =\u003e ({\n      applied: true,\n      flow: { ...baseFlow, revision: input.expectedRevision + 1, status: \"failed\" as const },\n    })),\n    requestCancel: vi.fn(),\n    cancel: vi.fn(),\n    runTask: vi.fn(),\n    ...overrides,\n  };\n}\n","tsconfig.json":"{\n  \"extends\": \"../tsconfig.package-boundary.base.json\",\n  \"compilerOptions\": {\n    \"rootDir\": \".\"\n  },\n  \"include\": [\"./*.ts\", \"./src/**/*.ts\"],\n  \"exclude\": [\n    \"./**/*.test.ts\",\n    \"./dist/**\",\n    \"./node_modules/**\",\n    \"./src/test-support/**\",\n    \"./src/**/*test-helpers.ts\",\n    \"./src/**/*test-harness.ts\",\n    \"./src/**/*test-support.ts\"\n  ]\n}\n"},"import":{"commit_sha":"424c6d0a5f4665b803ad6768d08b0be7659deaf4","imported_at":"2026-05-18T20:13:36Z","license_text":"MIT License\n\nCopyright (c) 2025 Peter Steinberger\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n","owner":"openclaw","repo":"openclaw/openclaw","source_url":"https://github.com/openclaw/openclaw/tree/424c6d0a5f4665b803ad6768d08b0be7659deaf4/extensions/lobster"}},"content_hash":[18,97,4,63,172,2,82,162,208,163,64,195,237,47,70,186,115,134,254,54,61,127,132,42,154,32,27,115,128,188,107,29],"trust_level":"unsigned","yanked":false}
